@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.
- package/README.md +123 -7
- package/dist/baseline.d.ts +183 -0
- package/dist/baseline.d.ts.map +1 -1
- package/dist/baseline.js +485 -16
- package/dist/baseline.js.map +1 -1
- package/dist/bin/cli.js +28 -2
- package/dist/bin/cli.js.map +1 -1
- package/dist/browser.d.ts +7 -2
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +66 -5
- package/dist/browser.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +2 -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 +158 -59
- package/dist/commands/pr.js.map +1 -1
- package/dist/comparison.d.ts +9 -0
- package/dist/comparison.d.ts.map +1 -1
- package/dist/comparison.js +44 -16
- package/dist/comparison.js.map +1 -1
- package/dist/errors.d.ts +7 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +7 -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 +7 -0
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +41 -6
- package/dist/report.js.map +1 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +12 -4
- 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 +41 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +17 -0
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- 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
|
-
|
|
245
|
-
|
|
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 = !
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|