@juangadm/pre-post 1.1.0 → 1.3.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 (51) hide show
  1. package/README.md +123 -7
  2. package/dist/baseline.d.ts +183 -0
  3. package/dist/baseline.d.ts.map +1 -1
  4. package/dist/baseline.js +485 -16
  5. package/dist/baseline.js.map +1 -1
  6. package/dist/bin/cli.js +28 -2
  7. package/dist/bin/cli.js.map +1 -1
  8. package/dist/browser.d.ts +7 -2
  9. package/dist/browser.d.ts.map +1 -1
  10. package/dist/browser.js +66 -5
  11. package/dist/browser.js.map +1 -1
  12. package/dist/commands/doctor.d.ts.map +1 -1
  13. package/dist/commands/doctor.js +2 -3
  14. package/dist/commands/doctor.js.map +1 -1
  15. package/dist/commands/pr.d.ts +13 -1
  16. package/dist/commands/pr.d.ts.map +1 -1
  17. package/dist/commands/pr.js +158 -59
  18. package/dist/commands/pr.js.map +1 -1
  19. package/dist/comparison.d.ts +9 -0
  20. package/dist/comparison.d.ts.map +1 -1
  21. package/dist/comparison.js +44 -16
  22. package/dist/comparison.js.map +1 -1
  23. package/dist/errors.d.ts +7 -0
  24. package/dist/errors.d.ts.map +1 -1
  25. package/dist/errors.js +7 -0
  26. package/dist/errors.js.map +1 -1
  27. package/dist/github.d.ts +34 -3
  28. package/dist/github.d.ts.map +1 -1
  29. package/dist/github.js +102 -11
  30. package/dist/github.js.map +1 -1
  31. package/dist/report.d.ts +7 -0
  32. package/dist/report.d.ts.map +1 -1
  33. package/dist/report.js +41 -6
  34. package/dist/report.js.map +1 -1
  35. package/dist/run.d.ts.map +1 -1
  36. package/dist/run.js +12 -4
  37. package/dist/run.js.map +1 -1
  38. package/dist/sheet.d.ts +24 -0
  39. package/dist/sheet.d.ts.map +1 -0
  40. package/dist/sheet.js +94 -0
  41. package/dist/sheet.js.map +1 -0
  42. package/dist/timings.d.ts +46 -0
  43. package/dist/timings.d.ts.map +1 -0
  44. package/dist/timings.js +108 -0
  45. package/dist/timings.js.map +1 -0
  46. package/dist/types.d.ts +41 -0
  47. package/dist/types.d.ts.map +1 -1
  48. package/dist/types.js +17 -0
  49. package/dist/types.js.map +1 -1
  50. package/package.json +1 -1
  51. package/skill/SKILL.md +21 -6
package/dist/baseline.js CHANGED
@@ -11,14 +11,15 @@
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';
18
18
  import path from 'path';
19
+ import { NeedsHumanError } from './errors.js';
19
20
  import { devScript, readPackage } from './pkg.js';
20
21
  import { findAppRoots } from './routes.js';
21
- const NPM = { bin: 'npm', install: ['install'], run: (s, a) => ['run', s, '--', ...a] };
22
+ const NPM = { bin: 'npm', install: ['install'], retry: { argv: ['install', '--legacy-peer-deps'], when: isPeerConflict }, run: (s, a) => ['run', s, '--', ...a] };
22
23
  const PNPM = { bin: 'pnpm', install: ['install', '--prefer-offline'], run: (s, a) => ['run', s, ...a] };
23
24
  const YARN = { bin: 'yarn', install: ['install'], run: (s, a) => ['run', s, ...a] };
24
25
  const BUN = { bin: 'bun', install: ['install'], run: (s, a) => ['run', s, ...a] };
@@ -114,6 +115,10 @@ export function servableDir(treeRoot, appPrefix) {
114
115
  }
115
116
  return null;
116
117
  }
118
+ /** Has this app directory been installed? */
119
+ function isInstalled(appDir) {
120
+ return fs.existsSync(path.join(appDir, 'node_modules'));
121
+ }
117
122
  /** Local env files, in the order a framework would layer them. */
118
123
  const ENV_FILES = ['.env', '.env.local', '.env.development', '.env.development.local'];
119
124
  /**
@@ -149,6 +154,90 @@ export function copyEnvFiles(from, to, appPrefix) {
149
154
  }
150
155
  return copied;
151
156
  }
157
+ /**
158
+ * Prefixes a framework uses to mark a variable as safe to send to the browser.
159
+ * Stripped before the name is matched, so `NEXT_PUBLIC_APP_URL` and `APP_URL`
160
+ * are the same question.
161
+ */
162
+ const PUBLIC_PREFIXES = ['NEXT_PUBLIC_', 'NUXT_PUBLIC_', 'EXPO_PUBLIC_', 'REACT_APP_', 'GATSBY_', 'VITE_', 'PUBLIC_'];
163
+ /**
164
+ * Names that can only mean "the address this app is served at".
165
+ *
166
+ * Deliberately a list and not a pattern: `*_URL` would sweep up
167
+ * `DATABASE_URL` and an API on a second port, and pointing either of those at
168
+ * the dev server breaks the app more thoroughly than the wrong port ever did.
169
+ *
170
+ * And deliberately a short list. The generic names — `URL`, `BASE_URL`,
171
+ * `SERVER_URL`, `HOST_URL`, `PUBLIC_URL` — read as the app's own address about
172
+ * as often as they read as a backend on another port or, for `BASE_URL` under
173
+ * Vite and `PUBLIC_URL` under CRA, a path prefix that is not a URL at all.
174
+ * Rewriting one of those is the same class of mistake this fix is about: a
175
+ * page that renders and is quietly wrong. Every name here is qualified by what
176
+ * it addresses (`APP_`, `SITE_`, `AUTH_`), which is what makes it answerable.
177
+ */
178
+ const ORIGIN_KEYS = new Set([
179
+ 'APP_URL', 'APP_ORIGIN', 'APP_BASE_URL',
180
+ 'SITE_URL', 'SITE_ORIGIN',
181
+ 'WEBSITE_URL', 'CANONICAL_URL', 'DEPLOYMENT_URL',
182
+ 'AUTH_URL', 'AUTH_ORIGIN', 'AUTH_BASE_URL', 'NEXTAUTH_URL', 'NEXTAUTH_URL_INTERNAL', 'BETTER_AUTH_URL',
183
+ ]);
184
+ /** Does this variable name the app's own origin? */
185
+ export function isOriginKey(key) {
186
+ const prefix = PUBLIC_PREFIXES.find(p => key.startsWith(p) && key.length > p.length);
187
+ return ORIGIN_KEYS.has(key) || (prefix ? ORIGIN_KEYS.has(key.slice(prefix.length)) : false);
188
+ }
189
+ /** One env file's text with every origin variable pointed at `origin`. */
190
+ export function rewriteEnvOrigins(text, origin) {
191
+ const keys = [];
192
+ const lines = text.split('\n').map(line => {
193
+ const m = /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)(\s*=\s*)(.*)$/.exec(line);
194
+ if (!m || !isOriginKey(m[2]))
195
+ return line;
196
+ keys.push(m[2]);
197
+ // Keep the quoting the author used; drop whatever followed, because a
198
+ // trailing comment about the old address is now wrong.
199
+ const quote = /^["']/.test(m[4]) ? m[4][0] : '';
200
+ return `${m[1]}${m[2]}${m[3]}${quote}${origin}${quote}`;
201
+ });
202
+ return { text: lines.join('\n'), keys };
203
+ }
204
+ /**
205
+ * Point the worktree's copied env files at the baseline's real origin.
206
+ *
207
+ * The copies name the port the developer's own dev server uses, and the
208
+ * baseline is on an ephemeral one. An app whose auth library is told it lives
209
+ * at :3000 while it is answering on :51234 rejects its own callbacks: the page
210
+ * renders, the session never resolves, and every capture is a loading
211
+ * skeleton. That reads as success — a screenshot came back — which is the
212
+ * failure mode this whole file exists to avoid.
213
+ *
214
+ * Same constraint as the copy: only ever writes inside `dir`, and returns
215
+ * names, never values.
216
+ */
217
+ export function pointEnvFilesAt(dir, origin, appPrefix) {
218
+ const rewritten = new Set();
219
+ const root = path.resolve(dir);
220
+ for (const sub of appPrefix ? ['', appPrefix] : ['']) {
221
+ for (const name of ENV_FILES) {
222
+ const file = path.resolve(root, sub, name);
223
+ if (file !== root && !file.startsWith(root + path.sep))
224
+ continue;
225
+ try {
226
+ if (!fs.existsSync(file))
227
+ continue;
228
+ const before = fs.readFileSync(file, 'utf-8');
229
+ const { text, keys } = rewriteEnvOrigins(before, origin);
230
+ if (!keys.length || text === before)
231
+ continue;
232
+ fs.writeFileSync(file, text);
233
+ for (const key of keys)
234
+ rewritten.add(key);
235
+ }
236
+ catch { /* best effort: the same as a missing env file */ }
237
+ }
238
+ }
239
+ return [...rewritten];
240
+ }
152
241
  /** An OS-assigned free port. */
153
242
  export function freePort() {
154
243
  return new Promise((resolve, reject) => {
@@ -160,6 +249,299 @@ export function freePort() {
160
249
  });
161
250
  });
162
251
  }
252
+ /** Keep the end of the output: managers put the diagnosis last. */
253
+ function tail(text, lines = 24) {
254
+ const kept = text.replace(/\s+$/, '').split('\n').slice(-lines);
255
+ return kept.join('\n').trim();
256
+ }
257
+ /**
258
+ * Room for the noisiest install that still works.
259
+ *
260
+ * Node's default is 1 MiB per stream, and exceeding it kills the child with
261
+ * ENOBUFS — which this code would read as a failed install. A verbose set of
262
+ * postinstall scripts must not be the reason a baseline cannot be built, and
263
+ * the buffer is transient and freed as soon as the tail is taken.
264
+ */
265
+ const MAX_INSTALL_OUTPUT = 64 * 1024 * 1024;
266
+ /**
267
+ * Run one command to completion, keeping the tail of what it said.
268
+ *
269
+ * Shared by the install and the setup step that follows it: they fail the same
270
+ * way, and a reader needs the same thing from both — the end of the output.
271
+ */
272
+ function runCommand(bin, argv, cwd, timeoutMs, shell = false, signal) {
273
+ // Asynchronous, so the other side's server and the browser download keep
274
+ // moving while this one installs: a synchronous call froze the whole process.
275
+ return new Promise(resolve => {
276
+ execFile(bin, argv, { cwd, shell, signal, timeout: Math.max(1, timeoutMs), encoding: 'utf-8', maxBuffer: MAX_INSTALL_OUTPUT }, (err, stdout, stderr) => {
277
+ // Only a failure has anything to explain, so the successful run's log is
278
+ // dropped rather than split into lines nothing will read.
279
+ if (!err)
280
+ return resolve({ argv, ok: true, output: '' });
281
+ const output = [stdout, stderr].map(part => (part ? String(part) : '')).join('');
282
+ resolve({ argv, ok: false, output: tail(output || err.message || '') });
283
+ }).stdin?.end();
284
+ });
285
+ }
286
+ const runInstall = (bin, argv, cwd, timeoutMs, signal) => runCommand(bin, argv, cwd, timeoutMs, false, signal);
287
+ /**
288
+ * Does this look like npm refusing to resolve a peer range?
289
+ *
290
+ * Matched on npm's own error code rather than on prose, so it does not depend
291
+ * on the wording of a given npm release. The second pattern is the same
292
+ * failure as reported by older npm, which does not always print the code.
293
+ */
294
+ export function isPeerConflict(output) {
295
+ return /ERESOLVE/i.test(output) || /could not resolve dependency/i.test(output);
296
+ }
297
+ /**
298
+ * Install this app's dependencies, retrying once past a peer-dependency wall.
299
+ *
300
+ * The retry is deliberately narrow: only on the specific failure that a looser
301
+ * resolver would not have had, and only for a manager that has a looser mode.
302
+ * A baseline installed with `--legacy-peer-deps` is not a perfect reproduction
303
+ * of the branch's own install — but the alternative is no baseline at all, and
304
+ * the comparison this tool exists to make is between two renders of the same
305
+ * app, not between two dependency trees.
306
+ */
307
+ export async function installDeps(pm, cwd, timeoutMs, run = runInstall, signal) {
308
+ const deadline = Date.now() + timeoutMs;
309
+ const first = await run(pm.bin, pm.install, cwd, timeoutMs, signal);
310
+ if (first.ok || signal?.aborted || !pm.retry?.when(first.output))
311
+ return { ok: first.ok, attempts: [first] };
312
+ // The remainder of the original budget, not a fresh one: a first attempt
313
+ // that burned the clock must not let the retry double the wall time.
314
+ const second = await run(pm.bin, pm.retry.argv, cwd, deadline - Date.now(), signal);
315
+ return { ok: second.ok, attempts: [first, second] };
316
+ }
317
+ /**
318
+ * The install failed and nothing else can rescue this run.
319
+ *
320
+ * Its own error rather than a quiet null, for the reason docs/portability.md
321
+ * §1 gives about base resolution: a run that compared nothing must not exit
322
+ * clean. A null here fell through to "no baseline", and on a repository with a
323
+ * configured production URL it fell through to comparing against that instead
324
+ * — a different answer to a different question, published as if it were this
325
+ * one.
326
+ */
327
+ /**
328
+ * What a reader has to do about a failed install, in one sentence.
329
+ *
330
+ * One sentence because that is what `NeedsHumanError` is for (AGENTS.md): the
331
+ * CLI prints the whole message for exit 3, so pasting a screen of package
332
+ * manager output here would bury the instruction inside it. The output is
333
+ * still the thing that answers "why", so it goes to the log instead — above
334
+ * this line, where it reads as diagnostics rather than as the remedy.
335
+ *
336
+ * The command names the caller's own directory, never the worktree: by the
337
+ * time anyone reads this, cleanup has deleted the worktree, so telling them to
338
+ * run it there would be an instruction they cannot follow.
339
+ */
340
+ export function installFailureHint(where, ranIn) {
341
+ return `Could not install ${where} for the baseline; run \`install\` in ${ranIn} to fix it, or re-run with --no-local-baseline and pass --before <production-url>.`;
342
+ }
343
+ export class BaselineInstallError extends NeedsHumanError {
344
+ result;
345
+ constructor(result, where, ranIn) {
346
+ super(installFailureHint(where, ranIn));
347
+ this.result = result;
348
+ this.name = 'BaselineInstallError';
349
+ }
350
+ }
351
+ const TURBO_CONFIGS = ['turbo.json', 'turbo.jsonc'];
352
+ /** A binary the install just put in a `node_modules/.bin`, nearest first. */
353
+ function localBin(name, ...dirs) {
354
+ const file = process.platform === 'win32' ? `${name}.cmd` : name;
355
+ for (const dir of dirs) {
356
+ const bin = path.join(dir, 'node_modules', '.bin', file);
357
+ if (fs.existsSync(bin))
358
+ return bin;
359
+ }
360
+ return null;
361
+ }
362
+ /**
363
+ * Build the workspace packages the app imports, before the dev server starts.
364
+ *
365
+ * A monorepo app depends on sibling packages that ship compiled output, and a
366
+ * fresh install does not produce it — `turbo dev` normally would, but the dev
367
+ * script this file runs is the app's own. So the app boots against a package
368
+ * with no `dist`, and the baseline is an error page or a shell.
369
+ *
370
+ * `^...` is turbo's "dependencies of, excluding itself": the siblings get
371
+ * built, the app is left to the dev server.
372
+ */
373
+ export function turboDependencyBuild(treeRoot, appDir) {
374
+ // A single-package repo has no upstream workspace to build; its dev script
375
+ // is the whole thing.
376
+ if (path.resolve(appDir) === path.resolve(treeRoot))
377
+ return null;
378
+ if (!TURBO_CONFIGS.some(name => fs.existsSync(path.join(treeRoot, name))))
379
+ return null;
380
+ const name = readPackage(appDir)?.name;
381
+ if (typeof name !== 'string' || !name)
382
+ return null;
383
+ const bin = localBin('turbo', appDir, treeRoot);
384
+ if (!bin)
385
+ return null;
386
+ const argv = ['run', 'build', `--filter=${name}^...`];
387
+ return { label: `turbo ${argv.join(' ')}`, bin, argv, cwd: treeRoot };
388
+ }
389
+ /**
390
+ * What to run between install and dev: `baselineSetup` if the repository
391
+ * configured one, else turbo's dependency build when this is a turborepo.
392
+ *
393
+ * The configured command wins outright. It is the general answer — one line in
394
+ * `.pre-post.json` for a repository whose baseline needs a codegen step, a
395
+ * prisma generate, a build this tool has never heard of — and a repository
396
+ * that names one has already told us the guess is not wanted.
397
+ */
398
+ export function setupStep(treeRoot, appDir, configured) {
399
+ const command = configured?.trim();
400
+ if (command)
401
+ return { label: command, bin: command, argv: [], cwd: appDir, shell: true };
402
+ return turboDependencyBuild(treeRoot, appDir);
403
+ }
404
+ /**
405
+ * The configured setup command failed, and nothing else can rescue this run.
406
+ *
407
+ * One sentence, and the output goes to the log above it, for the reason
408
+ * `installFailureHint` gives. The directory named is the caller's own, never
409
+ * the worktree: cleanup has deleted the worktree by the time anyone reads it.
410
+ */
411
+ export function setupFailureHint(label, ranIn) {
412
+ return `The \`baselineSetup\` command (\`${label}\`) failed while preparing the baseline; run it in ${ranIn} to fix it, or drop it from .pre-post.json.`;
413
+ }
414
+ export class BaselineSetupError extends NeedsHumanError {
415
+ constructor(label, ranIn) {
416
+ super(setupFailureHint(label, ranIn));
417
+ this.name = 'BaselineSetupError';
418
+ }
419
+ }
420
+ /**
421
+ * Files that decide what an install produces. When none of them differ between
422
+ * the base commit and the working tree, the two installs would be the same
423
+ * dependency tree, so the one already on disk can stand in for a fresh one.
424
+ */
425
+ const LOCKFILES = [...MANAGERS.map(m => m.lockfile), 'bun.lock', 'npm-shrinkwrap.json'];
426
+ const INSTALL_INPUTS = [
427
+ ...LOCKFILES.map(f => `:(glob)**/${f}`),
428
+ ':(glob)**/package.json',
429
+ ':(glob)**/.npmrc',
430
+ ':(glob)**/.yarnrc.yml',
431
+ ':(glob)**/.pnpmfile.cjs',
432
+ ':(glob)**/pnpm-workspace.yaml',
433
+ ];
434
+ /** Run git without blocking the process; resolves null when it exits non-zero. */
435
+ function gitAsync(args, cwd) {
436
+ return new Promise(resolve => {
437
+ execFile('git', args, { cwd, encoding: 'utf-8', maxBuffer: MAX_INSTALL_OUTPUT }, (err, stdout) => resolve(err ? null : stdout));
438
+ });
439
+ }
440
+ /**
441
+ * Can the base commit use the dependencies this checkout already installed?
442
+ *
443
+ * Only when no install input differs between the base and the working tree,
444
+ * uncommitted edits included, and a lockfile is tracked (without one there is
445
+ * nothing to prove the trees match). Returns the package directories whose
446
+ * node_modules to bring across, or null to install as usual. Once the diff is
447
+ * clean, the index lists the same package.json files the base has, so it
448
+ * answers that without walking the base commit's whole tree.
449
+ */
450
+ export async function reusableInstall(repoRoot, sha) {
451
+ if (await gitAsync(['diff', '--quiet', sha, '--', ...INSTALL_INPUTS], repoRoot) === null)
452
+ return null;
453
+ // The diff sees tracked files only. A new package.json or lockfile the
454
+ // branch has not added yet is still a different dependency tree.
455
+ const untracked = await gitAsync(['ls-files', '--others', '--exclude-standard', '--', ...INSTALL_INPUTS], repoRoot);
456
+ if (untracked === null || untracked.trim())
457
+ return null;
458
+ const tracked = await gitAsync(['ls-files', '--', ...INSTALL_INPUTS], repoRoot);
459
+ const files = tracked?.split('\n') ?? [];
460
+ if (!files.some(f => LOCKFILES.includes(path.basename(f))))
461
+ return null;
462
+ const dirs = files
463
+ .filter(f => path.basename(f) === 'package.json' && !f.split('/').includes('node_modules'))
464
+ .map(f => path.dirname(f));
465
+ if (dirs.some(dir => generatesDuringInstall(path.join(repoRoot, dir))))
466
+ return null;
467
+ return dirs.filter(dir => {
468
+ try {
469
+ return fs.lstatSync(path.join(repoRoot, dir, 'node_modules')).isDirectory();
470
+ }
471
+ catch {
472
+ return false;
473
+ }
474
+ });
475
+ }
476
+ /** Scripts a package manager runs on the project itself during an install. */
477
+ const INSTALL_HOOKS = ['preinstall', 'install', 'postinstall', 'prepare'];
478
+ /**
479
+ * Could this package's install have written code built from its own source?
480
+ *
481
+ * An install hook can read files no install input covers — `prisma generate`
482
+ * reads a schema — and write the result into node_modules. Copying that into
483
+ * the baseline would render Pre with the branch's generated code, so such a
484
+ * package is installed fresh. Prisma is checked by its output as well, because
485
+ * `@prisma/client` generates from its own postinstall with no project hook.
486
+ */
487
+ function generatesDuringInstall(dir) {
488
+ const scripts = readPackage(dir)?.scripts ?? {};
489
+ if (INSTALL_HOOKS.some(hook => typeof scripts[hook] === 'string' && scripts[hook].trim()))
490
+ return true;
491
+ return fs.existsSync(path.join(dir, 'node_modules', '.prisma'));
492
+ }
493
+ const COPY_CONCURRENCY = 6;
494
+ /**
495
+ * Installs running in a caller's own checkout, by repository root. The base
496
+ * worktree waits on these before reusing that checkout's node_modules, which
497
+ * lets both sides boot at once even when nothing was installed yet.
498
+ */
499
+ const installsInFlight = new Map();
500
+ /**
501
+ * Copy one node_modules into the worktree as its own tree.
502
+ *
503
+ * A copy, not a symlink: package managers link workspace packages with
504
+ * relative paths, and through a symlink those would resolve back into this
505
+ * checkout, so Pre would quietly render the branch's own workspace code.
506
+ * Turbopack also refuses a node_modules that points outside the project. The
507
+ * copy keeps those links relative, so they land in the worktree. On macOS
508
+ * (APFS) and on reflink filesystems it is copy-on-write: near-instant and no
509
+ * extra disk.
510
+ */
511
+ function copyTree(from, to) {
512
+ const argv = process.platform === 'darwin' ? ['-c', '-R', from, to] : ['-a', '--reflink=auto', from, to];
513
+ return new Promise(resolve => {
514
+ execFile('cp', argv, { maxBuffer: MAX_INSTALL_OUTPUT }, err => {
515
+ if (!err)
516
+ return resolve(true);
517
+ // A cp without the clone flag (older macOS, BSD): Node's own copy.
518
+ fs.promises.rm(to, { recursive: true, force: true })
519
+ .then(() => fs.promises.cp(from, to, { recursive: true, verbatimSymlinks: true }))
520
+ .then(() => resolve(true), () => fs.promises.rm(to, { recursive: true, force: true }).then(() => resolve(false)));
521
+ });
522
+ });
523
+ }
524
+ /**
525
+ * Bring this checkout's installed dependencies into the base worktree.
526
+ * True when every one was copied; on any failure the partial copies are
527
+ * removed so the normal install starts from a clean tree.
528
+ */
529
+ export async function reuseInstall(repoRoot, worktree, dirs) {
530
+ const targets = dirs.map(dir => ({ from: path.join(repoRoot, dir, 'node_modules'), to: path.join(worktree, dir, 'node_modules') }));
531
+ // A few at a time: a monorepo can have hundreds of packages, and hundreds of
532
+ // concurrent copies thrash the disk the dev servers are reading from.
533
+ const queue = [...targets];
534
+ let ok = true;
535
+ const worker = async () => {
536
+ for (let t = queue.shift(); t && ok; t = queue.shift())
537
+ ok = await copyTree(t.from, t.to) && ok;
538
+ };
539
+ await Promise.all(Array.from({ length: Math.min(COPY_CONCURRENCY, targets.length) }, worker));
540
+ if (ok)
541
+ return true;
542
+ await Promise.all(targets.map(t => fs.promises.rm(t.to, { recursive: true, force: true })));
543
+ return false;
544
+ }
163
545
  async function waitForServer(url, timeoutMs, alive) {
164
546
  const deadline = Date.now() + timeoutMs;
165
547
  while (Date.now() < deadline) {
@@ -206,6 +588,16 @@ async function serveLocally(opts) {
206
588
  };
207
589
  const timeoutMs = opts.timeoutMs ?? 300_000;
208
590
  const deadline = Date.now() + timeoutMs;
591
+ /** True, having cleaned up, when the caller has given up on this side. */
592
+ const aborted = async () => {
593
+ if (!opts.signal?.aborted)
594
+ return false;
595
+ await cleanup();
596
+ return true;
597
+ };
598
+ const side = opts.sha ? 'pre' : 'post';
599
+ const timed = (name, work) => opts.timings ? opts.timings.time(`${side}.${name}`, work) : work;
600
+ const started = Date.now();
209
601
  const worktree = opts.sha ? fs.mkdtempSync(path.join(os.tmpdir(), 'pre-post-base-')) : opts.repoRoot;
210
602
  let child = null;
211
603
  let stopped = false;
@@ -241,16 +633,16 @@ async function serveLocally(opts) {
241
633
  }
242
634
  };
243
635
  if (opts.sha) {
244
- try {
245
- execFileSync('git', ['worktree', 'add', '--detach', '--force', worktree, opts.sha], { cwd: opts.repoRoot, stdio: 'ignore' });
246
- }
247
- catch {
636
+ // Asynchronous: in a large repository a checkout takes seconds, and the
637
+ // other side is booting meanwhile.
638
+ if (await gitAsync(['worktree', 'add', '--detach', '--force', worktree, opts.sha], opts.repoRoot) === null || await aborted()) {
248
639
  await cleanup();
249
640
  return skip('the worktree checkout failed.');
250
641
  }
251
642
  const copied = copyEnvFiles(opts.repoRoot, worktree, opts.appPrefix);
252
643
  if (copied.length)
253
644
  log(`Using local env file(s) for the baseline: ${copied.join(', ')}`);
645
+ opts.timings?.add(`${side}.checkout`, Date.now() - started);
254
646
  }
255
647
  const app = servableDir(worktree, opts.appPrefix);
256
648
  if (!app) {
@@ -269,7 +661,7 @@ async function serveLocally(opts) {
269
661
  await cleanup();
270
662
  return skip(`neither ${declared.bin} nor npm is on PATH, so nothing can install ${where}.`);
271
663
  }
272
- const install = !fs.existsSync(path.join(appDir, 'node_modules'));
664
+ const install = !isInstalled(appDir);
273
665
  // Substituting npm for the declared manager is safe in a throwaway worktree
274
666
  // and not in the caller's own checkout: npm cannot share a node_modules with
275
667
  // pnpm, so installing over their tree leaves the working copy broken — the
@@ -281,21 +673,89 @@ async function serveLocally(opts) {
281
673
  await cleanup();
282
674
  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}.`);
283
675
  }
284
- if (pm !== declared) {
285
- log(`${declared.bin} is not on PATH; installing the baseline with ${pm.bin} instead (it will not honour the ${declared.bin} lockfile).`);
286
- }
287
676
  log(`Starting a dev server for ${what} (${pm.bin} ${script}) ...`);
288
- if (install) {
289
- try {
290
- execFileSync(pm.bin, pm.install, { cwd: appDir, stdio: 'ignore', timeout: Math.max(1, deadline - Date.now()) });
677
+ // The slowest step of a first run, and usually one that would rebuild the
678
+ // exact tree already on disk: most branches change code, not dependencies.
679
+ const tryReuse = async (sha) => {
680
+ // Post may be installing this checkout right now; a half-written
681
+ // node_modules must never be copied, so wait for it to finish.
682
+ await installsInFlight.get(opts.repoRoot);
683
+ const dirs = await reusableInstall(opts.repoRoot, sha);
684
+ if (!dirs?.includes(rel || '.') || !await reuseInstall(opts.repoRoot, worktree, dirs))
685
+ return false;
686
+ log(`Dependencies are unchanged since ${sha.slice(0, 7)}; reusing this checkout's install for the baseline.`);
687
+ return true;
688
+ };
689
+ const reused = install && opts.sha ? await timed('reuse', tryReuse(opts.sha)) : false;
690
+ if (await aborted())
691
+ return null;
692
+ if (install && !reused) {
693
+ if (pm !== declared) {
694
+ log(`${declared.bin} is not on PATH; installing the baseline with ${pm.bin} instead (it will not honour the ${declared.bin} lockfile).`);
291
695
  }
292
- catch {
696
+ const installing = installDeps(pm, appDir, deadline - Date.now(), runInstall, opts.signal);
697
+ if (worktree === opts.repoRoot)
698
+ installsInFlight.set(opts.repoRoot, installing);
699
+ const result = await timed('install', installing).finally(() => installsInFlight.delete(opts.repoRoot));
700
+ if (await aborted())
701
+ return null;
702
+ if (!result.ok) {
703
+ const tried = result.attempts.map(a => `\`${pm.bin} ${a.argv.join(' ')}\``).join(', then ');
704
+ log(`${tried} failed${worktree === opts.repoRoot ? '' : ' in a throwaway worktree of the base commit'}:`);
705
+ log(result.attempts[result.attempts.length - 1].output);
293
706
  await cleanup();
294
- return skip(`\`${pm.bin} ${pm.install.join(' ')}\` failed. Run it in ${appIn} to see why.`);
707
+ throw new BaselineInstallError(result, where, appIn);
708
+ }
709
+ const retried = result.attempts[1];
710
+ if (retried) {
711
+ log(`\`${pm.bin} ${pm.install.join(' ')}\` hit a peer-dependency conflict; installed the baseline with \`${retried.argv.join(' ')}\` instead.`);
295
712
  }
296
713
  }
714
+ // Before the setup step, not after it: that step is a build, and a build that
715
+ // reads NEXT_PUBLIC_APP_URL bakes whatever the file says into its output.
716
+ // Rewriting afterwards would leave the compiled copy holding the old port —
717
+ // the same mismatch, now somewhere no later rewrite can reach. The cost is
718
+ // that the port is claimed across the build rather than a moment before the
719
+ // spawn; if something else takes it meanwhile, the server fails to bind and
720
+ // this run says so, which is the failure worth having.
297
721
  const port = await freePort();
298
722
  const url = `http://localhost:${port}`;
723
+ // Only the throwaway worktree's copies may be edited; the caller's own env
724
+ // files are theirs.
725
+ if (worktree !== opts.repoRoot) {
726
+ const pointed = pointEnvFilesAt(worktree, url, opts.appPrefix);
727
+ if (pointed.length)
728
+ log(`Pointed ${pointed.join(', ')} at ${url} so the baseline agrees with its own address.`);
729
+ }
730
+ // The inferred build follows a fresh install and nothing else: a tree that
731
+ // already had node_modules has its workspace packages built too, and running
732
+ // a repo-wide build over someone's own checkout to take a screenshot is not
733
+ // a trade this tool gets to make. A configured command is different — the
734
+ // repository asked for it, so it runs either way.
735
+ const configured = Boolean(opts.setup?.trim());
736
+ const setup = configured ? setupStep(worktree, appDir, opts.setup) : install ? setupStep(worktree, appDir) : null;
737
+ if (setup) {
738
+ log(`Preparing ${what} before its dev server (${setup.label}) ...`);
739
+ const attempt = await timed('setup', runCommand(setup.bin, setup.argv, setup.cwd, deadline - Date.now(), setup.shell, opts.signal));
740
+ if (await aborted())
741
+ return null;
742
+ if (!attempt.ok) {
743
+ // Loud either way. A dev server started over a half-built workspace
744
+ // serves an error page, and an error page is a baseline that reports a
745
+ // wall of changes this branch never made.
746
+ log(attempt.output);
747
+ await cleanup();
748
+ // A command the repository asked for is the install's case exactly
749
+ // (docs/portability.md §1): yielding here lets the run fall through to a
750
+ // configured production URL and publish a different comparison as if it
751
+ // were this one, or die further along on an error about dev servers that
752
+ // names nothing the reader can fix. Only the inferred build yields —
753
+ // nobody asked for it, so it is allowed to be one option among several.
754
+ if (configured)
755
+ throw new BaselineSetupError(setup.label, appIn);
756
+ return skip(`\`${setup.label}\` failed.`);
757
+ }
758
+ }
299
759
  child = spawn(pm.bin, pm.run(script, ['--port', String(port)]), {
300
760
  cwd: appDir,
301
761
  stdio: 'ignore',
@@ -303,9 +763,18 @@ async function serveLocally(opts) {
303
763
  env: { ...process.env, PORT: String(port), BROWSER: 'none' },
304
764
  });
305
765
  child.unref();
306
- const ready = await waitForServer(url, Math.max(1, deadline - Date.now()), () => !!child && child.exitCode === null);
766
+ const ready = await timed('boot', waitForServer(url, Math.max(1, deadline - Date.now()), () => !!child && child.exitCode === null && !opts.signal?.aborted));
767
+ if (await aborted())
768
+ return null;
307
769
  if (!ready) {
770
+ // A server that died is not one that was slow. Reporting "did not start
771
+ // within 300s" about one that exited after two seconds, as a CI run did,
772
+ // sends the reader to look at timeouts instead of at the crash.
773
+ const exitCode = child?.exitCode ?? null;
308
774
  await cleanup();
775
+ if (exitCode !== null) {
776
+ return skip(`${pm.bin} ${script} exited with code ${exitCode} before serving; run it in ${appIn} to see why.`);
777
+ }
309
778
  return skip(`${pm.bin} ${script} did not start serving within ${Math.round(timeoutMs / 1000)}s (missing env vars are the usual cause).`);
310
779
  }
311
780
  process.once('SIGINT', onSignal);