@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.
Files changed (83) hide show
  1. package/README.md +52 -182
  2. package/dist/baseline.d.ts +122 -3
  3. package/dist/baseline.d.ts.map +1 -1
  4. package/dist/baseline.js +491 -46
  5. package/dist/baseline.js.map +1 -1
  6. package/dist/bin/cli.js +41 -17
  7. package/dist/bin/cli.js.map +1 -1
  8. package/dist/browser.d.ts +38 -3
  9. package/dist/browser.d.ts.map +1 -1
  10. package/dist/browser.js +132 -11
  11. package/dist/browser.js.map +1 -1
  12. package/dist/commands/compare.d.ts.map +1 -1
  13. package/dist/commands/compare.js +5 -5
  14. package/dist/commands/compare.js.map +1 -1
  15. package/dist/commands/detect.d.ts +5 -0
  16. package/dist/commands/detect.d.ts.map +1 -1
  17. package/dist/commands/detect.js +3 -0
  18. package/dist/commands/detect.js.map +1 -1
  19. package/dist/commands/doctor.d.ts.map +1 -1
  20. package/dist/commands/doctor.js +9 -3
  21. package/dist/commands/doctor.js.map +1 -1
  22. package/dist/commands/pr.d.ts +13 -1
  23. package/dist/commands/pr.d.ts.map +1 -1
  24. package/dist/commands/pr.js +222 -107
  25. package/dist/commands/pr.js.map +1 -1
  26. package/dist/comparison.d.ts +14 -0
  27. package/dist/comparison.d.ts.map +1 -1
  28. package/dist/comparison.js +48 -25
  29. package/dist/comparison.js.map +1 -1
  30. package/dist/diff-worker.d.ts +1 -0
  31. package/dist/diff-worker.d.ts.map +1 -1
  32. package/dist/diff-worker.js +1 -0
  33. package/dist/diff-worker.js.map +1 -1
  34. package/dist/diff.d.ts +42 -0
  35. package/dist/diff.d.ts.map +1 -1
  36. package/dist/diff.js +142 -24
  37. package/dist/diff.js.map +1 -1
  38. package/dist/doctor.d.ts +4 -1
  39. package/dist/doctor.d.ts.map +1 -1
  40. package/dist/doctor.js +5 -1
  41. package/dist/doctor.js.map +1 -1
  42. package/dist/drift.d.ts +29 -0
  43. package/dist/drift.d.ts.map +1 -0
  44. package/dist/drift.js +106 -0
  45. package/dist/drift.js.map +1 -0
  46. package/dist/errors.d.ts +13 -0
  47. package/dist/errors.d.ts.map +1 -1
  48. package/dist/errors.js +13 -0
  49. package/dist/errors.js.map +1 -1
  50. package/dist/github.d.ts +34 -3
  51. package/dist/github.d.ts.map +1 -1
  52. package/dist/github.js +102 -11
  53. package/dist/github.js.map +1 -1
  54. package/dist/report.d.ts +27 -1
  55. package/dist/report.d.ts.map +1 -1
  56. package/dist/report.js +144 -38
  57. package/dist/report.js.map +1 -1
  58. package/dist/routes/imports.d.ts +21 -1
  59. package/dist/routes/imports.d.ts.map +1 -1
  60. package/dist/routes/imports.js +32 -2
  61. package/dist/routes/imports.js.map +1 -1
  62. package/dist/routes.d.ts +18 -0
  63. package/dist/routes.d.ts.map +1 -1
  64. package/dist/routes.js +110 -29
  65. package/dist/routes.js.map +1 -1
  66. package/dist/run.d.ts +46 -12
  67. package/dist/run.d.ts.map +1 -1
  68. package/dist/run.js +138 -17
  69. package/dist/run.js.map +1 -1
  70. package/dist/sheet.d.ts +24 -0
  71. package/dist/sheet.d.ts.map +1 -0
  72. package/dist/sheet.js +94 -0
  73. package/dist/sheet.js.map +1 -0
  74. package/dist/timings.d.ts +46 -0
  75. package/dist/timings.d.ts.map +1 -0
  76. package/dist/timings.js +108 -0
  77. package/dist/timings.js.map +1 -0
  78. package/dist/types.d.ts +86 -1
  79. package/dist/types.d.ts.map +1 -1
  80. package/dist/types.js +8 -0
  81. package/dist/types.js.map +1 -1
  82. package/package.json +2 -2
  83. 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
- const runInstall = (bin, argv, cwd, timeoutMs) => {
179
- try {
180
- execFileSync(bin, argv, { cwd, timeout: Math.max(1, timeoutMs), encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: MAX_INSTALL_OUTPUT });
181
- // Only a failure has anything to explain, so the successful install's log
182
- // is dropped rather than split into lines nothing will read.
183
- return { argv, ok: true, output: '' };
184
- }
185
- catch (err) {
186
- const e = err;
187
- const output = [e.stdout, e.stderr].map(part => (part ? String(part) : '')).join('');
188
- return { argv, ok: false, output: tail(output || e.message || '') };
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
- const onSignal = () => { void cleanup(); };
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
- const cleanup = async () => {
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 && child.exitCode === null) {
318
- try {
319
- process.kill(-child.pid, 'SIGTERM');
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
- if (opts.sha) {
336
- try {
337
- execFileSync('git', ['worktree', 'add', '--detach', '--force', worktree, opts.sha], { cwd: opts.repoRoot, stdio: 'ignore' });
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
- catch {
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 = !fs.existsSync(path.join(appDir, 'node_modules'));
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
- if (install) {
381
- const result = installDeps(pm, appDir, deadline - Date.now());
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
- return skip(`${pm.bin} ${script} did not start serving within ${Math.round(timeoutMs / 1000)}s (missing env vars are the usual cause).`);
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