@juangadm/pre-post 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -182
- package/dist/baseline.d.ts +122 -3
- package/dist/baseline.d.ts.map +1 -1
- package/dist/baseline.js +491 -46
- package/dist/baseline.js.map +1 -1
- package/dist/bin/cli.js +41 -17
- package/dist/bin/cli.js.map +1 -1
- package/dist/browser.d.ts +38 -3
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +132 -11
- package/dist/browser.js.map +1 -1
- package/dist/commands/compare.d.ts.map +1 -1
- package/dist/commands/compare.js +5 -5
- package/dist/commands/compare.js.map +1 -1
- package/dist/commands/detect.d.ts +5 -0
- package/dist/commands/detect.d.ts.map +1 -1
- package/dist/commands/detect.js +3 -0
- package/dist/commands/detect.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +9 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/pr.d.ts +13 -1
- package/dist/commands/pr.d.ts.map +1 -1
- package/dist/commands/pr.js +222 -107
- package/dist/commands/pr.js.map +1 -1
- package/dist/comparison.d.ts +14 -0
- package/dist/comparison.d.ts.map +1 -1
- package/dist/comparison.js +48 -25
- package/dist/comparison.js.map +1 -1
- package/dist/diff-worker.d.ts +1 -0
- package/dist/diff-worker.d.ts.map +1 -1
- package/dist/diff-worker.js +1 -0
- package/dist/diff-worker.js.map +1 -1
- package/dist/diff.d.ts +42 -0
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +142 -24
- package/dist/diff.js.map +1 -1
- package/dist/doctor.d.ts +4 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +5 -1
- package/dist/doctor.js.map +1 -1
- package/dist/drift.d.ts +29 -0
- package/dist/drift.d.ts.map +1 -0
- package/dist/drift.js +106 -0
- package/dist/drift.js.map +1 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +13 -0
- package/dist/errors.js.map +1 -1
- package/dist/github.d.ts +34 -3
- package/dist/github.d.ts.map +1 -1
- package/dist/github.js +102 -11
- package/dist/github.js.map +1 -1
- package/dist/report.d.ts +27 -1
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +144 -38
- package/dist/report.js.map +1 -1
- package/dist/routes/imports.d.ts +21 -1
- package/dist/routes/imports.d.ts.map +1 -1
- package/dist/routes/imports.js +32 -2
- package/dist/routes/imports.js.map +1 -1
- package/dist/routes.d.ts +18 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +110 -29
- package/dist/routes.js.map +1 -1
- package/dist/run.d.ts +46 -12
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +138 -17
- package/dist/run.js.map +1 -1
- package/dist/sheet.d.ts +24 -0
- package/dist/sheet.d.ts.map +1 -0
- package/dist/sheet.js +94 -0
- package/dist/sheet.js.map +1 -0
- package/dist/timings.d.ts +46 -0
- package/dist/timings.d.ts.map +1 -0
- package/dist/timings.js +108 -0
- package/dist/timings.js.map +1 -0
- package/dist/types.d.ts +86 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
- package/skill/SKILL.md +34 -10
package/dist/baseline.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* The cost is time: a checkout, an install and a dev-server boot. So callers
|
|
12
12
|
* reach for it after a reachable deployment, not before one.
|
|
13
13
|
*/
|
|
14
|
-
import { spawn, execFileSync } from 'child_process';
|
|
14
|
+
import { spawn, execFile, execFileSync } from 'child_process';
|
|
15
15
|
import fs from 'fs';
|
|
16
16
|
import net from 'net';
|
|
17
17
|
import os from 'os';
|
|
@@ -19,6 +19,18 @@ import path from 'path';
|
|
|
19
19
|
import { NeedsHumanError } from './errors.js';
|
|
20
20
|
import { devScript, readPackage } from './pkg.js';
|
|
21
21
|
import { findAppRoots } from './routes.js';
|
|
22
|
+
/**
|
|
23
|
+
* Where a dev server's output goes: a file per server, never the terminal.
|
|
24
|
+
*
|
|
25
|
+
* It used to go nowhere (`stdio: 'ignore'`), so when a server answered 500
|
|
26
|
+
* there was no way to say why. A file rather than a pipe: the server is
|
|
27
|
+
* detached and must not stall when this process stops reading.
|
|
28
|
+
*/
|
|
29
|
+
export function serverLogFile(side, port) {
|
|
30
|
+
const dir = path.join(os.tmpdir(), 'pre-post', 'servers');
|
|
31
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
32
|
+
return path.join(dir, `${side}-${port}-${Date.now()}.log`);
|
|
33
|
+
}
|
|
22
34
|
const NPM = { bin: 'npm', install: ['install'], retry: { argv: ['install', '--legacy-peer-deps'], when: isPeerConflict }, run: (s, a) => ['run', s, '--', ...a] };
|
|
23
35
|
const PNPM = { bin: 'pnpm', install: ['install', '--prefer-offline'], run: (s, a) => ['run', s, ...a] };
|
|
24
36
|
const YARN = { bin: 'yarn', install: ['install'], run: (s, a) => ['run', s, ...a] };
|
|
@@ -115,6 +127,10 @@ export function servableDir(treeRoot, appPrefix) {
|
|
|
115
127
|
}
|
|
116
128
|
return null;
|
|
117
129
|
}
|
|
130
|
+
/** Has this app directory been installed? */
|
|
131
|
+
function isInstalled(appDir) {
|
|
132
|
+
return fs.existsSync(path.join(appDir, 'node_modules'));
|
|
133
|
+
}
|
|
118
134
|
/** Local env files, in the order a framework would layer them. */
|
|
119
135
|
const ENV_FILES = ['.env', '.env.local', '.env.development', '.env.development.local'];
|
|
120
136
|
/**
|
|
@@ -150,6 +166,90 @@ export function copyEnvFiles(from, to, appPrefix) {
|
|
|
150
166
|
}
|
|
151
167
|
return copied;
|
|
152
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Prefixes a framework uses to mark a variable as safe to send to the browser.
|
|
171
|
+
* Stripped before the name is matched, so `NEXT_PUBLIC_APP_URL` and `APP_URL`
|
|
172
|
+
* are the same question.
|
|
173
|
+
*/
|
|
174
|
+
const PUBLIC_PREFIXES = ['NEXT_PUBLIC_', 'NUXT_PUBLIC_', 'EXPO_PUBLIC_', 'REACT_APP_', 'GATSBY_', 'VITE_', 'PUBLIC_'];
|
|
175
|
+
/**
|
|
176
|
+
* Names that can only mean "the address this app is served at".
|
|
177
|
+
*
|
|
178
|
+
* Deliberately a list and not a pattern: `*_URL` would sweep up
|
|
179
|
+
* `DATABASE_URL` and an API on a second port, and pointing either of those at
|
|
180
|
+
* the dev server breaks the app more thoroughly than the wrong port ever did.
|
|
181
|
+
*
|
|
182
|
+
* And deliberately a short list. The generic names — `URL`, `BASE_URL`,
|
|
183
|
+
* `SERVER_URL`, `HOST_URL`, `PUBLIC_URL` — read as the app's own address about
|
|
184
|
+
* as often as they read as a backend on another port or, for `BASE_URL` under
|
|
185
|
+
* Vite and `PUBLIC_URL` under CRA, a path prefix that is not a URL at all.
|
|
186
|
+
* Rewriting one of those is the same class of mistake this fix is about: a
|
|
187
|
+
* page that renders and is quietly wrong. Every name here is qualified by what
|
|
188
|
+
* it addresses (`APP_`, `SITE_`, `AUTH_`), which is what makes it answerable.
|
|
189
|
+
*/
|
|
190
|
+
const ORIGIN_KEYS = new Set([
|
|
191
|
+
'APP_URL', 'APP_ORIGIN', 'APP_BASE_URL',
|
|
192
|
+
'SITE_URL', 'SITE_ORIGIN',
|
|
193
|
+
'WEBSITE_URL', 'CANONICAL_URL', 'DEPLOYMENT_URL',
|
|
194
|
+
'AUTH_URL', 'AUTH_ORIGIN', 'AUTH_BASE_URL', 'NEXTAUTH_URL', 'NEXTAUTH_URL_INTERNAL', 'BETTER_AUTH_URL',
|
|
195
|
+
]);
|
|
196
|
+
/** Does this variable name the app's own origin? */
|
|
197
|
+
export function isOriginKey(key) {
|
|
198
|
+
const prefix = PUBLIC_PREFIXES.find(p => key.startsWith(p) && key.length > p.length);
|
|
199
|
+
return ORIGIN_KEYS.has(key) || (prefix ? ORIGIN_KEYS.has(key.slice(prefix.length)) : false);
|
|
200
|
+
}
|
|
201
|
+
/** One env file's text with every origin variable pointed at `origin`. */
|
|
202
|
+
export function rewriteEnvOrigins(text, origin) {
|
|
203
|
+
const keys = [];
|
|
204
|
+
const lines = text.split('\n').map(line => {
|
|
205
|
+
const m = /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)(\s*=\s*)(.*)$/.exec(line);
|
|
206
|
+
if (!m || !isOriginKey(m[2]))
|
|
207
|
+
return line;
|
|
208
|
+
keys.push(m[2]);
|
|
209
|
+
// Keep the quoting the author used; drop whatever followed, because a
|
|
210
|
+
// trailing comment about the old address is now wrong.
|
|
211
|
+
const quote = /^["']/.test(m[4]) ? m[4][0] : '';
|
|
212
|
+
return `${m[1]}${m[2]}${m[3]}${quote}${origin}${quote}`;
|
|
213
|
+
});
|
|
214
|
+
return { text: lines.join('\n'), keys };
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Point the worktree's copied env files at the baseline's real origin.
|
|
218
|
+
*
|
|
219
|
+
* The copies name the port the developer's own dev server uses, and the
|
|
220
|
+
* baseline is on an ephemeral one. An app whose auth library is told it lives
|
|
221
|
+
* at :3000 while it is answering on :51234 rejects its own callbacks: the page
|
|
222
|
+
* renders, the session never resolves, and every capture is a loading
|
|
223
|
+
* skeleton. That reads as success — a screenshot came back — which is the
|
|
224
|
+
* failure mode this whole file exists to avoid.
|
|
225
|
+
*
|
|
226
|
+
* Same constraint as the copy: only ever writes inside `dir`, and returns
|
|
227
|
+
* names, never values.
|
|
228
|
+
*/
|
|
229
|
+
export function pointEnvFilesAt(dir, origin, appPrefix) {
|
|
230
|
+
const rewritten = new Set();
|
|
231
|
+
const root = path.resolve(dir);
|
|
232
|
+
for (const sub of appPrefix ? ['', appPrefix] : ['']) {
|
|
233
|
+
for (const name of ENV_FILES) {
|
|
234
|
+
const file = path.resolve(root, sub, name);
|
|
235
|
+
if (file !== root && !file.startsWith(root + path.sep))
|
|
236
|
+
continue;
|
|
237
|
+
try {
|
|
238
|
+
if (!fs.existsSync(file))
|
|
239
|
+
continue;
|
|
240
|
+
const before = fs.readFileSync(file, 'utf-8');
|
|
241
|
+
const { text, keys } = rewriteEnvOrigins(before, origin);
|
|
242
|
+
if (!keys.length || text === before)
|
|
243
|
+
continue;
|
|
244
|
+
fs.writeFileSync(file, text);
|
|
245
|
+
for (const key of keys)
|
|
246
|
+
rewritten.add(key);
|
|
247
|
+
}
|
|
248
|
+
catch { /* best effort: the same as a missing env file */ }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return [...rewritten];
|
|
252
|
+
}
|
|
153
253
|
/** An OS-assigned free port. */
|
|
154
254
|
export function freePort() {
|
|
155
255
|
return new Promise((resolve, reject) => {
|
|
@@ -175,19 +275,27 @@ function tail(text, lines = 24) {
|
|
|
175
275
|
* the buffer is transient and freed as soon as the tail is taken.
|
|
176
276
|
*/
|
|
177
277
|
const MAX_INSTALL_OUTPUT = 64 * 1024 * 1024;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
278
|
+
/**
|
|
279
|
+
* Run one command to completion, keeping the tail of what it said.
|
|
280
|
+
*
|
|
281
|
+
* Shared by the install and the setup step that follows it: they fail the same
|
|
282
|
+
* way, and a reader needs the same thing from both — the end of the output.
|
|
283
|
+
*/
|
|
284
|
+
function runCommand(bin, argv, cwd, timeoutMs, shell = false, signal) {
|
|
285
|
+
// Asynchronous, so the other side's server and the browser download keep
|
|
286
|
+
// moving while this one installs: a synchronous call froze the whole process.
|
|
287
|
+
return new Promise(resolve => {
|
|
288
|
+
execFile(bin, argv, { cwd, shell, signal, timeout: Math.max(1, timeoutMs), encoding: 'utf-8', maxBuffer: MAX_INSTALL_OUTPUT }, (err, stdout, stderr) => {
|
|
289
|
+
// Only a failure has anything to explain, so the successful run's log is
|
|
290
|
+
// dropped rather than split into lines nothing will read.
|
|
291
|
+
if (!err)
|
|
292
|
+
return resolve({ argv, ok: true, output: '' });
|
|
293
|
+
const output = [stdout, stderr].map(part => (part ? String(part) : '')).join('');
|
|
294
|
+
resolve({ argv, ok: false, output: tail(output || err.message || '') });
|
|
295
|
+
}).stdin?.end();
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
const runInstall = (bin, argv, cwd, timeoutMs, signal) => runCommand(bin, argv, cwd, timeoutMs, false, signal);
|
|
191
299
|
/**
|
|
192
300
|
* Does this look like npm refusing to resolve a peer range?
|
|
193
301
|
*
|
|
@@ -208,20 +316,20 @@ export function isPeerConflict(output) {
|
|
|
208
316
|
* the comparison this tool exists to make is between two renders of the same
|
|
209
317
|
* app, not between two dependency trees.
|
|
210
318
|
*/
|
|
211
|
-
export function installDeps(pm, cwd, timeoutMs, run = runInstall) {
|
|
319
|
+
export async function installDeps(pm, cwd, timeoutMs, run = runInstall, signal) {
|
|
212
320
|
const deadline = Date.now() + timeoutMs;
|
|
213
|
-
const first = run(pm.bin, pm.install, cwd, timeoutMs);
|
|
214
|
-
if (first.ok || !pm.retry?.when(first.output))
|
|
321
|
+
const first = await run(pm.bin, pm.install, cwd, timeoutMs, signal);
|
|
322
|
+
if (first.ok || signal?.aborted || !pm.retry?.when(first.output))
|
|
215
323
|
return { ok: first.ok, attempts: [first] };
|
|
216
324
|
// The remainder of the original budget, not a fresh one: a first attempt
|
|
217
325
|
// that burned the clock must not let the retry double the wall time.
|
|
218
|
-
const second = run(pm.bin, pm.retry.argv, cwd, deadline - Date.now());
|
|
326
|
+
const second = await run(pm.bin, pm.retry.argv, cwd, deadline - Date.now(), signal);
|
|
219
327
|
return { ok: second.ok, attempts: [first, second] };
|
|
220
328
|
}
|
|
221
329
|
/**
|
|
222
330
|
* The install failed and nothing else can rescue this run.
|
|
223
331
|
*
|
|
224
|
-
* Its own error rather than a quiet null, for the reason docs/portability.md
|
|
332
|
+
* Its own error rather than a quiet null, for the reason docs/internal/portability.md
|
|
225
333
|
* §1 gives about base resolution: a run that compared nothing must not exit
|
|
226
334
|
* clean. A null here fell through to "no baseline", and on a repository with a
|
|
227
335
|
* configured production URL it fell through to comparing against that instead
|
|
@@ -252,6 +360,238 @@ export class BaselineInstallError extends NeedsHumanError {
|
|
|
252
360
|
this.name = 'BaselineInstallError';
|
|
253
361
|
}
|
|
254
362
|
}
|
|
363
|
+
const TURBO_CONFIGS = ['turbo.json', 'turbo.jsonc'];
|
|
364
|
+
/** A binary the install just put in a `node_modules/.bin`, nearest first. */
|
|
365
|
+
function localBin(name, ...dirs) {
|
|
366
|
+
const file = process.platform === 'win32' ? `${name}.cmd` : name;
|
|
367
|
+
for (const dir of dirs) {
|
|
368
|
+
const bin = path.join(dir, 'node_modules', '.bin', file);
|
|
369
|
+
if (fs.existsSync(bin))
|
|
370
|
+
return bin;
|
|
371
|
+
}
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Build the workspace packages the app imports, before the dev server starts.
|
|
376
|
+
*
|
|
377
|
+
* A monorepo app depends on sibling packages that ship compiled output, and a
|
|
378
|
+
* fresh install does not produce it — `turbo dev` normally would, but the dev
|
|
379
|
+
* script this file runs is the app's own. So the app boots against a package
|
|
380
|
+
* with no `dist`, and the baseline is an error page or a shell.
|
|
381
|
+
*
|
|
382
|
+
* `^...` is turbo's "dependencies of, excluding itself": the siblings get
|
|
383
|
+
* built, the app is left to the dev server.
|
|
384
|
+
*/
|
|
385
|
+
export function turboDependencyBuild(treeRoot, appDir) {
|
|
386
|
+
// A single-package repo has no upstream workspace to build; its dev script
|
|
387
|
+
// is the whole thing.
|
|
388
|
+
if (path.resolve(appDir) === path.resolve(treeRoot))
|
|
389
|
+
return null;
|
|
390
|
+
if (!TURBO_CONFIGS.some(name => fs.existsSync(path.join(treeRoot, name))))
|
|
391
|
+
return null;
|
|
392
|
+
const name = readPackage(appDir)?.name;
|
|
393
|
+
if (typeof name !== 'string' || !name)
|
|
394
|
+
return null;
|
|
395
|
+
const bin = localBin('turbo', appDir, treeRoot);
|
|
396
|
+
if (!bin)
|
|
397
|
+
return null;
|
|
398
|
+
const argv = ['run', 'build', `--filter=${name}^...`];
|
|
399
|
+
return { label: `turbo ${argv.join(' ')}`, bin, argv, cwd: treeRoot };
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* What to run between install and dev: `baselineSetup` if the repository
|
|
403
|
+
* configured one, else turbo's dependency build when this is a turborepo.
|
|
404
|
+
*
|
|
405
|
+
* The configured command wins outright. It is the general answer — one line in
|
|
406
|
+
* `.pre-post.json` for a repository whose baseline needs a codegen step, a
|
|
407
|
+
* prisma generate, a build this tool has never heard of — and a repository
|
|
408
|
+
* that names one has already told us the guess is not wanted.
|
|
409
|
+
*/
|
|
410
|
+
export function setupStep(treeRoot, appDir, configured) {
|
|
411
|
+
const command = configured?.trim();
|
|
412
|
+
if (command)
|
|
413
|
+
return { label: command, bin: command, argv: [], cwd: appDir, shell: true };
|
|
414
|
+
return turboDependencyBuild(treeRoot, appDir);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* The configured setup command failed, and nothing else can rescue this run.
|
|
418
|
+
*
|
|
419
|
+
* One sentence, and the output goes to the log above it, for the reason
|
|
420
|
+
* `installFailureHint` gives. The directory named is the caller's own, never
|
|
421
|
+
* the worktree: cleanup has deleted the worktree by the time anyone reads it.
|
|
422
|
+
*/
|
|
423
|
+
export function setupFailureHint(label, ranIn) {
|
|
424
|
+
return `The \`baselineSetup\` command (\`${label}\`) failed while preparing the baseline; run it in ${ranIn} to fix it, or drop it from .pre-post.json.`;
|
|
425
|
+
}
|
|
426
|
+
export class BaselineSetupError extends NeedsHumanError {
|
|
427
|
+
constructor(label, ranIn) {
|
|
428
|
+
super(setupFailureHint(label, ranIn));
|
|
429
|
+
this.name = 'BaselineSetupError';
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Files that decide what an install produces. When none of them differ between
|
|
434
|
+
* the base commit and the working tree, the two installs would be the same
|
|
435
|
+
* dependency tree, so the one already on disk can stand in for a fresh one.
|
|
436
|
+
*/
|
|
437
|
+
const LOCKFILES = [...MANAGERS.map(m => m.lockfile), 'bun.lock', 'npm-shrinkwrap.json'];
|
|
438
|
+
const INSTALL_INPUTS = [
|
|
439
|
+
...LOCKFILES.map(f => `:(glob)**/${f}`),
|
|
440
|
+
':(glob)**/package.json',
|
|
441
|
+
':(glob)**/.npmrc',
|
|
442
|
+
':(glob)**/.yarnrc.yml',
|
|
443
|
+
':(glob)**/.pnpmfile.cjs',
|
|
444
|
+
':(glob)**/pnpm-workspace.yaml',
|
|
445
|
+
];
|
|
446
|
+
/** Run git without blocking the process; resolves null when it exits non-zero. */
|
|
447
|
+
function gitAsync(args, cwd) {
|
|
448
|
+
return new Promise(resolve => {
|
|
449
|
+
execFile('git', args, { cwd, encoding: 'utf-8', maxBuffer: MAX_INSTALL_OUTPUT }, (err, stdout) => resolve(err ? null : stdout));
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Can the base commit use the dependencies this checkout already installed?
|
|
454
|
+
*
|
|
455
|
+
* Only when no install input differs between the base and the working tree,
|
|
456
|
+
* uncommitted edits included, and a lockfile is tracked (without one there is
|
|
457
|
+
* nothing to prove the trees match). Returns the package directories whose
|
|
458
|
+
* node_modules to bring across, or null to install as usual. Once the diff is
|
|
459
|
+
* clean, the index lists the same package.json files the base has, so it
|
|
460
|
+
* answers that without walking the base commit's whole tree.
|
|
461
|
+
*/
|
|
462
|
+
export async function reusableInstall(repoRoot, sha) {
|
|
463
|
+
if (await gitAsync(['diff', '--quiet', sha, '--', ...INSTALL_INPUTS], repoRoot) === null)
|
|
464
|
+
return null;
|
|
465
|
+
// The diff sees tracked files only. A new package.json or lockfile the
|
|
466
|
+
// branch has not added yet is still a different dependency tree.
|
|
467
|
+
const untracked = await gitAsync(['ls-files', '--others', '--exclude-standard', '--', ...INSTALL_INPUTS], repoRoot);
|
|
468
|
+
if (untracked === null || untracked.trim())
|
|
469
|
+
return null;
|
|
470
|
+
const tracked = await gitAsync(['ls-files', '--', ...INSTALL_INPUTS], repoRoot);
|
|
471
|
+
const files = tracked?.split('\n') ?? [];
|
|
472
|
+
if (!files.some(f => LOCKFILES.includes(path.basename(f))))
|
|
473
|
+
return null;
|
|
474
|
+
const dirs = files
|
|
475
|
+
.filter(f => path.basename(f) === 'package.json' && !f.split('/').includes('node_modules'))
|
|
476
|
+
.map(f => path.dirname(f));
|
|
477
|
+
if (dirs.some(dir => generatesDuringInstall(path.join(repoRoot, dir))))
|
|
478
|
+
return null;
|
|
479
|
+
return dirs.filter(dir => {
|
|
480
|
+
try {
|
|
481
|
+
return fs.lstatSync(path.join(repoRoot, dir, 'node_modules')).isDirectory();
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
/** Scripts a package manager runs on the project itself during an install. */
|
|
489
|
+
const INSTALL_HOOKS = ['preinstall', 'install', 'postinstall', 'prepare'];
|
|
490
|
+
/**
|
|
491
|
+
* Could this package's install have written code built from its own source?
|
|
492
|
+
*
|
|
493
|
+
* An install hook can read files no install input covers — `prisma generate`
|
|
494
|
+
* reads a schema — and write the result into node_modules. Copying that into
|
|
495
|
+
* the baseline would render Pre with the branch's generated code, so such a
|
|
496
|
+
* package is installed fresh. Prisma is checked by its output as well, because
|
|
497
|
+
* `@prisma/client` generates from its own postinstall with no project hook.
|
|
498
|
+
*/
|
|
499
|
+
function generatesDuringInstall(dir) {
|
|
500
|
+
const scripts = readPackage(dir)?.scripts ?? {};
|
|
501
|
+
if (INSTALL_HOOKS.some(hook => typeof scripts[hook] === 'string' && scripts[hook].trim()))
|
|
502
|
+
return true;
|
|
503
|
+
return fs.existsSync(path.join(dir, 'node_modules', '.prisma'));
|
|
504
|
+
}
|
|
505
|
+
const COPY_CONCURRENCY = 6;
|
|
506
|
+
/**
|
|
507
|
+
* Installs running in a caller's own checkout, by repository root. The base
|
|
508
|
+
* worktree waits on these before reusing that checkout's node_modules, which
|
|
509
|
+
* lets both sides boot at once even when nothing was installed yet.
|
|
510
|
+
*/
|
|
511
|
+
const installsInFlight = new Map();
|
|
512
|
+
/**
|
|
513
|
+
* Copy one node_modules into the worktree as its own tree.
|
|
514
|
+
*
|
|
515
|
+
* A copy, not a symlink: package managers link workspace packages with
|
|
516
|
+
* relative paths, and through a symlink those would resolve back into this
|
|
517
|
+
* checkout, so Pre would quietly render the branch's own workspace code.
|
|
518
|
+
* Turbopack also refuses a node_modules that points outside the project. The
|
|
519
|
+
* copy keeps those links relative, so they land in the worktree. On macOS
|
|
520
|
+
* (APFS) and on reflink filesystems it is copy-on-write: near-instant and no
|
|
521
|
+
* extra disk.
|
|
522
|
+
*/
|
|
523
|
+
function copyTree(from, to) {
|
|
524
|
+
const argv = process.platform === 'darwin' ? ['-c', '-R', from, to] : ['-a', '--reflink=auto', from, to];
|
|
525
|
+
return new Promise(resolve => {
|
|
526
|
+
execFile('cp', argv, { maxBuffer: MAX_INSTALL_OUTPUT }, err => {
|
|
527
|
+
if (!err)
|
|
528
|
+
return resolve(true);
|
|
529
|
+
// A cp without the clone flag (older macOS, BSD): Node's own copy.
|
|
530
|
+
fs.promises.rm(to, { recursive: true, force: true })
|
|
531
|
+
.then(() => fs.promises.cp(from, to, { recursive: true, verbatimSymlinks: true }))
|
|
532
|
+
.then(() => resolve(true), () => fs.promises.rm(to, { recursive: true, force: true }).then(() => resolve(false)));
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Bring this checkout's installed dependencies into the base worktree.
|
|
538
|
+
* True when every one was copied; on any failure the partial copies are
|
|
539
|
+
* removed so the normal install starts from a clean tree.
|
|
540
|
+
*/
|
|
541
|
+
export async function reuseInstall(repoRoot, worktree, dirs) {
|
|
542
|
+
const targets = dirs.map(dir => ({ from: path.join(repoRoot, dir, 'node_modules'), to: path.join(worktree, dir, 'node_modules') }));
|
|
543
|
+
// A few at a time: a monorepo can have hundreds of packages, and hundreds of
|
|
544
|
+
// concurrent copies thrash the disk the dev servers are reading from.
|
|
545
|
+
const queue = [...targets];
|
|
546
|
+
let ok = true;
|
|
547
|
+
const worker = async () => {
|
|
548
|
+
for (let t = queue.shift(); t && ok; t = queue.shift())
|
|
549
|
+
ok = await copyTree(t.from, t.to) && ok;
|
|
550
|
+
};
|
|
551
|
+
await Promise.all(Array.from({ length: Math.min(COPY_CONCURRENCY, targets.length) }, worker));
|
|
552
|
+
if (ok)
|
|
553
|
+
return true;
|
|
554
|
+
await Promise.all(targets.map(t => fs.promises.rm(t.to, { recursive: true, force: true })));
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
/** Signal a detached child's whole process group; false when it is already gone. */
|
|
558
|
+
function signalGroup(child, signal) {
|
|
559
|
+
if (!child.pid)
|
|
560
|
+
return false;
|
|
561
|
+
try {
|
|
562
|
+
process.kill(-child.pid, signal);
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
return false;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
/** How long a dev server gets to exit on SIGTERM before it is killed. */
|
|
570
|
+
export const SERVER_STOP_TIMEOUT_MS = 5_000;
|
|
571
|
+
/**
|
|
572
|
+
* SIGTERM the server's process group, wait for it to exit, then make sure.
|
|
573
|
+
*
|
|
574
|
+
* The wait is on the child itself — the package manager, which exits once the
|
|
575
|
+
* server under it has — because Node reaps it, so its exit is a fact. Asking
|
|
576
|
+
* the group with signal 0 was not: in a container whose PID 1 does not reap,
|
|
577
|
+
* dead members linger as zombies that still answer, and every stop waited out
|
|
578
|
+
* the full timeout. Once the child is gone, or `timeoutMs` has passed, the
|
|
579
|
+
* whole group is SIGKILLed, so nothing it started is left writing into a
|
|
580
|
+
* worktree about to be removed.
|
|
581
|
+
*/
|
|
582
|
+
export async function stopGroup(child, timeoutMs = SERVER_STOP_TIMEOUT_MS) {
|
|
583
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
584
|
+
signalGroup(child, 'SIGKILL');
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
const exited = new Promise(resolve => child.once('exit', () => resolve()));
|
|
588
|
+
if (!signalGroup(child, 'SIGTERM'))
|
|
589
|
+
return;
|
|
590
|
+
let timer;
|
|
591
|
+
await Promise.race([exited, new Promise(resolve => { timer = setTimeout(resolve, timeoutMs); })]);
|
|
592
|
+
clearTimeout(timer);
|
|
593
|
+
signalGroup(child, 'SIGKILL');
|
|
594
|
+
}
|
|
255
595
|
async function waitForServer(url, timeoutMs, alive) {
|
|
256
596
|
const deadline = Date.now() + timeoutMs;
|
|
257
597
|
while (Date.now() < deadline) {
|
|
@@ -298,32 +638,42 @@ async function serveLocally(opts) {
|
|
|
298
638
|
};
|
|
299
639
|
const timeoutMs = opts.timeoutMs ?? 300_000;
|
|
300
640
|
const deadline = Date.now() + timeoutMs;
|
|
641
|
+
/** True, having cleaned up, when the caller has given up on this side. */
|
|
642
|
+
const aborted = async () => {
|
|
643
|
+
if (!opts.signal?.aborted)
|
|
644
|
+
return false;
|
|
645
|
+
await cleanup();
|
|
646
|
+
return true;
|
|
647
|
+
};
|
|
648
|
+
const side = opts.sha ? 'pre' : 'post';
|
|
649
|
+
const timed = (name, work) => opts.timings ? opts.timings.time(`${side}.${name}`, work) : work;
|
|
650
|
+
const started = Date.now();
|
|
301
651
|
const worktree = opts.sha ? fs.mkdtempSync(path.join(os.tmpdir(), 'pre-post-base-')) : opts.repoRoot;
|
|
302
652
|
let child = null;
|
|
303
653
|
let stopped = false;
|
|
304
654
|
// A detached dev server survives Ctrl-C and keeps its port, so tear it down
|
|
305
|
-
// on the signals that end the run as well as on the normal path.
|
|
306
|
-
|
|
655
|
+
// on the signals that end the run as well as on the normal path. Those
|
|
656
|
+
// paths cannot wait for anything — an 'exit' handler runs no more event
|
|
657
|
+
// loop — so they get the synchronous teardown.
|
|
658
|
+
const onSignal = () => { cleanupNow(); };
|
|
307
659
|
const untrap = () => {
|
|
308
660
|
process.off('SIGINT', onSignal);
|
|
309
661
|
process.off('SIGTERM', onSignal);
|
|
310
662
|
process.off('exit', onSignal);
|
|
311
663
|
};
|
|
312
|
-
|
|
664
|
+
// Guard on the thing that actually matters: only a throwaway worktree may be
|
|
665
|
+
// removed. Checking `opts.sha` instead would let any future caller that
|
|
666
|
+
// points `worktree` at a real checkout delete uncommitted work.
|
|
667
|
+
const throwaway = worktree !== opts.repoRoot;
|
|
668
|
+
/** The process is gone, a best-effort worktree removal, no waiting. */
|
|
669
|
+
const cleanupNow = () => {
|
|
313
670
|
if (stopped)
|
|
314
671
|
return;
|
|
315
672
|
stopped = true;
|
|
316
673
|
untrap();
|
|
317
|
-
if (child
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
}
|
|
321
|
-
catch { /* already gone */ }
|
|
322
|
-
}
|
|
323
|
-
// Guard on the thing that actually matters: only a throwaway worktree may be
|
|
324
|
-
// removed. Checking `opts.sha` instead would let any future caller that
|
|
325
|
-
// points `worktree` at a real checkout delete uncommitted work.
|
|
326
|
-
if (worktree === opts.repoRoot)
|
|
674
|
+
if (child)
|
|
675
|
+
signalGroup(child, 'SIGKILL');
|
|
676
|
+
if (!throwaway)
|
|
327
677
|
return;
|
|
328
678
|
try {
|
|
329
679
|
execFileSync('git', ['worktree', 'remove', '--force', worktree], { cwd: opts.repoRoot, stdio: 'ignore' });
|
|
@@ -332,17 +682,37 @@ async function serveLocally(opts) {
|
|
|
332
682
|
fs.rmSync(worktree, { recursive: true, force: true });
|
|
333
683
|
}
|
|
334
684
|
};
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
685
|
+
/**
|
|
686
|
+
* Stop the server, and only once it has exited, remove its worktree.
|
|
687
|
+
*
|
|
688
|
+
* The order is the point. Removing the tree under a dev server that is
|
|
689
|
+
* still shutting down races its last writes into `.next`, and the removal
|
|
690
|
+
* blocked the event loop while it walked the copied node_modules.
|
|
691
|
+
*/
|
|
692
|
+
const cleanup = async () => {
|
|
693
|
+
if (stopped)
|
|
694
|
+
return;
|
|
695
|
+
stopped = true;
|
|
696
|
+
untrap();
|
|
697
|
+
if (child)
|
|
698
|
+
await stopGroup(child);
|
|
699
|
+
if (!throwaway)
|
|
700
|
+
return;
|
|
701
|
+
if (await gitAsync(['worktree', 'remove', '--force', worktree], opts.repoRoot) === null) {
|
|
702
|
+
await fs.promises.rm(worktree, { recursive: true, force: true });
|
|
338
703
|
}
|
|
339
|
-
|
|
704
|
+
};
|
|
705
|
+
if (opts.sha) {
|
|
706
|
+
// Asynchronous: in a large repository a checkout takes seconds, and the
|
|
707
|
+
// other side is booting meanwhile.
|
|
708
|
+
if (await gitAsync(['worktree', 'add', '--detach', '--force', worktree, opts.sha], opts.repoRoot) === null || await aborted()) {
|
|
340
709
|
await cleanup();
|
|
341
710
|
return skip('the worktree checkout failed.');
|
|
342
711
|
}
|
|
343
712
|
const copied = copyEnvFiles(opts.repoRoot, worktree, opts.appPrefix);
|
|
344
713
|
if (copied.length)
|
|
345
714
|
log(`Using local env file(s) for the baseline: ${copied.join(', ')}`);
|
|
715
|
+
opts.timings?.add(`${side}.checkout`, Date.now() - started);
|
|
346
716
|
}
|
|
347
717
|
const app = servableDir(worktree, opts.appPrefix);
|
|
348
718
|
if (!app) {
|
|
@@ -361,7 +731,7 @@ async function serveLocally(opts) {
|
|
|
361
731
|
await cleanup();
|
|
362
732
|
return skip(`neither ${declared.bin} nor npm is on PATH, so nothing can install ${where}.`);
|
|
363
733
|
}
|
|
364
|
-
const install = !
|
|
734
|
+
const install = !isInstalled(appDir);
|
|
365
735
|
// Substituting npm for the declared manager is safe in a throwaway worktree
|
|
366
736
|
// and not in the caller's own checkout: npm cannot share a node_modules with
|
|
367
737
|
// pnpm, so installing over their tree leaves the working copy broken — the
|
|
@@ -373,12 +743,32 @@ async function serveLocally(opts) {
|
|
|
373
743
|
await cleanup();
|
|
374
744
|
return skip(`${declared.bin} is not on PATH, and installing ${where} with npm instead would leave a node_modules your ${declared.bin} cannot use. Install ${declared.bin}, or run \`${declared.bin} install\` in ${appIn}.`);
|
|
375
745
|
}
|
|
376
|
-
if (pm !== declared) {
|
|
377
|
-
log(`${declared.bin} is not on PATH; installing the baseline with ${pm.bin} instead (it will not honour the ${declared.bin} lockfile).`);
|
|
378
|
-
}
|
|
379
746
|
log(`Starting a dev server for ${what} (${pm.bin} ${script}) ...`);
|
|
380
|
-
|
|
381
|
-
|
|
747
|
+
// The slowest step of a first run, and usually one that would rebuild the
|
|
748
|
+
// exact tree already on disk: most branches change code, not dependencies.
|
|
749
|
+
const tryReuse = async (sha) => {
|
|
750
|
+
// Post may be installing this checkout right now; a half-written
|
|
751
|
+
// node_modules must never be copied, so wait for it to finish.
|
|
752
|
+
await installsInFlight.get(opts.repoRoot);
|
|
753
|
+
const dirs = await reusableInstall(opts.repoRoot, sha);
|
|
754
|
+
if (!dirs?.includes(rel || '.') || !await reuseInstall(opts.repoRoot, worktree, dirs))
|
|
755
|
+
return false;
|
|
756
|
+
log(`Dependencies are unchanged since ${sha.slice(0, 7)}; reusing this checkout's install for the baseline.`);
|
|
757
|
+
return true;
|
|
758
|
+
};
|
|
759
|
+
const reused = install && opts.sha ? await timed('reuse', tryReuse(opts.sha)) : false;
|
|
760
|
+
if (await aborted())
|
|
761
|
+
return null;
|
|
762
|
+
if (install && !reused) {
|
|
763
|
+
if (pm !== declared) {
|
|
764
|
+
log(`${declared.bin} is not on PATH; installing the baseline with ${pm.bin} instead (it will not honour the ${declared.bin} lockfile).`);
|
|
765
|
+
}
|
|
766
|
+
const installing = installDeps(pm, appDir, deadline - Date.now(), runInstall, opts.signal);
|
|
767
|
+
if (worktree === opts.repoRoot)
|
|
768
|
+
installsInFlight.set(opts.repoRoot, installing);
|
|
769
|
+
const result = await timed('install', installing).finally(() => installsInFlight.delete(opts.repoRoot));
|
|
770
|
+
if (await aborted())
|
|
771
|
+
return null;
|
|
382
772
|
if (!result.ok) {
|
|
383
773
|
const tried = result.attempts.map(a => `\`${pm.bin} ${a.argv.join(' ')}\``).join(', then ');
|
|
384
774
|
log(`${tried} failed${worktree === opts.repoRoot ? '' : ' in a throwaway worktree of the base commit'}:`);
|
|
@@ -391,23 +781,78 @@ async function serveLocally(opts) {
|
|
|
391
781
|
log(`\`${pm.bin} ${pm.install.join(' ')}\` hit a peer-dependency conflict; installed the baseline with \`${retried.argv.join(' ')}\` instead.`);
|
|
392
782
|
}
|
|
393
783
|
}
|
|
784
|
+
// Before the setup step, not after it: that step is a build, and a build that
|
|
785
|
+
// reads NEXT_PUBLIC_APP_URL bakes whatever the file says into its output.
|
|
786
|
+
// Rewriting afterwards would leave the compiled copy holding the old port —
|
|
787
|
+
// the same mismatch, now somewhere no later rewrite can reach. The cost is
|
|
788
|
+
// that the port is claimed across the build rather than a moment before the
|
|
789
|
+
// spawn; if something else takes it meanwhile, the server fails to bind and
|
|
790
|
+
// this run says so, which is the failure worth having.
|
|
394
791
|
const port = await freePort();
|
|
395
792
|
const url = `http://localhost:${port}`;
|
|
793
|
+
// Only the throwaway worktree's copies may be edited; the caller's own env
|
|
794
|
+
// files are theirs.
|
|
795
|
+
if (worktree !== opts.repoRoot) {
|
|
796
|
+
const pointed = pointEnvFilesAt(worktree, url, opts.appPrefix);
|
|
797
|
+
if (pointed.length)
|
|
798
|
+
log(`Pointed ${pointed.join(', ')} at ${url} so the baseline agrees with its own address.`);
|
|
799
|
+
}
|
|
800
|
+
// The inferred build follows a fresh install and nothing else: a tree that
|
|
801
|
+
// already had node_modules has its workspace packages built too, and running
|
|
802
|
+
// a repo-wide build over someone's own checkout to take a screenshot is not
|
|
803
|
+
// a trade this tool gets to make. A configured command is different — the
|
|
804
|
+
// repository asked for it, so it runs either way.
|
|
805
|
+
const configured = Boolean(opts.setup?.trim());
|
|
806
|
+
const setup = configured ? setupStep(worktree, appDir, opts.setup) : install ? setupStep(worktree, appDir) : null;
|
|
807
|
+
if (setup) {
|
|
808
|
+
log(`Preparing ${what} before its dev server (${setup.label}) ...`);
|
|
809
|
+
const attempt = await timed('setup', runCommand(setup.bin, setup.argv, setup.cwd, deadline - Date.now(), setup.shell, opts.signal));
|
|
810
|
+
if (await aborted())
|
|
811
|
+
return null;
|
|
812
|
+
if (!attempt.ok) {
|
|
813
|
+
// Loud either way. A dev server started over a half-built workspace
|
|
814
|
+
// serves an error page, and an error page is a baseline that reports a
|
|
815
|
+
// wall of changes this branch never made.
|
|
816
|
+
log(attempt.output);
|
|
817
|
+
await cleanup();
|
|
818
|
+
// A command the repository asked for is the install's case exactly
|
|
819
|
+
// (docs/internal/portability.md §1): yielding here lets the run fall through to a
|
|
820
|
+
// configured production URL and publish a different comparison as if it
|
|
821
|
+
// were this one, or die further along on an error about dev servers that
|
|
822
|
+
// names nothing the reader can fix. Only the inferred build yields —
|
|
823
|
+
// nobody asked for it, so it is allowed to be one option among several.
|
|
824
|
+
if (configured)
|
|
825
|
+
throw new BaselineSetupError(setup.label, appIn);
|
|
826
|
+
return skip(`\`${setup.label}\` failed.`);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
const logFile = serverLogFile(side, port);
|
|
830
|
+
const out = fs.openSync(logFile, 'a');
|
|
396
831
|
child = spawn(pm.bin, pm.run(script, ['--port', String(port)]), {
|
|
397
832
|
cwd: appDir,
|
|
398
|
-
stdio: 'ignore',
|
|
833
|
+
stdio: ['ignore', out, out],
|
|
399
834
|
detached: true,
|
|
400
835
|
env: { ...process.env, PORT: String(port), BROWSER: 'none' },
|
|
401
836
|
});
|
|
837
|
+
fs.closeSync(out);
|
|
402
838
|
child.unref();
|
|
403
|
-
const ready = await waitForServer(url, Math.max(1, deadline - Date.now()), () => !!child && child.exitCode === null);
|
|
839
|
+
const ready = await timed('boot', waitForServer(url, Math.max(1, deadline - Date.now()), () => !!child && child.exitCode === null && !opts.signal?.aborted));
|
|
840
|
+
if (await aborted())
|
|
841
|
+
return null;
|
|
404
842
|
if (!ready) {
|
|
843
|
+
// A server that died is not one that was slow. Reporting "did not start
|
|
844
|
+
// within 300s" about one that exited after two seconds, as a CI run did,
|
|
845
|
+
// sends the reader to look at timeouts instead of at the crash.
|
|
846
|
+
const exitCode = child?.exitCode ?? null;
|
|
405
847
|
await cleanup();
|
|
406
|
-
|
|
848
|
+
if (exitCode !== null) {
|
|
849
|
+
return skip(`${pm.bin} ${script} exited with code ${exitCode} before serving; its output is in ${logFile}.`);
|
|
850
|
+
}
|
|
851
|
+
return skip(`${pm.bin} ${script} did not start serving within ${Math.round(timeoutMs / 1000)}s (missing env vars are the usual cause); its output is in ${logFile}.`);
|
|
407
852
|
}
|
|
408
853
|
process.once('SIGINT', onSignal);
|
|
409
854
|
process.once('SIGTERM', onSignal);
|
|
410
855
|
process.once('exit', onSignal);
|
|
411
|
-
return { url, stop: cleanup };
|
|
856
|
+
return { url, stop: cleanup, logFile };
|
|
412
857
|
}
|
|
413
858
|
//# sourceMappingURL=baseline.js.map
|