upstreamfix 0.1.2 → 0.1.3
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 +18 -1
- package/dist/cli.js +109 -29
- package/dist/core/diagnostic-diff.js +12 -9
- package/dist/core/diff-api.js +211 -0
- package/dist/core/docker-mount.js +6 -0
- package/dist/core/impact.js +72 -0
- package/dist/core/manifest.js +4 -0
- package/dist/core/node-range.js +19 -0
- package/dist/core/probe.js +155 -0
- package/dist/core/project-discovery.js +10 -0
- package/dist/core/report.js +70 -10
- package/dist/core/runner.js +136 -19
- package/dist/core/verify.js +110 -78
- package/dist/core/watch.js +81 -0
- package/dist/core/write-branch.js +45 -27
- package/dist/migrations/firebase-admin-v14/index.js +122 -10
- package/dist/migrations/index.js +3 -7
- package/dist/migrations/stripe-shared.js +75 -9
- package/dist/migrations/stripe-v23/index.js +2 -1
- package/package.json +1 -1
package/dist/core/verify.js
CHANGED
|
@@ -3,9 +3,11 @@ import { copyFile, lstat, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
6
7
|
import { applyPatch, parsePatch } from 'diff';
|
|
7
8
|
import { registerTemporaryCleanup, terminateProcessGroup, trackProcessGroup } from './process-cleanup.js';
|
|
8
|
-
|
|
9
|
+
import { IGNORED_PROJECT_DIRECTORIES } from './project-discovery.js';
|
|
10
|
+
import { parseManifest } from './manifest.js';
|
|
9
11
|
const ignoredFiles = new Set(['.npmrc', '.yarnrc', '.yarnrc.yml', '.pnpmfile.cjs', '.pnpmfile.mjs', 'yarn.config.cjs']);
|
|
10
12
|
const maxOutputLength = 4_000;
|
|
11
13
|
const maxCopyBytes = 100 * 1024 * 1024;
|
|
@@ -19,11 +21,14 @@ export async function verifyProposedMigration(cwd, diffs, options = {}) {
|
|
|
19
21
|
const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), 'upstreamfix-verify-'));
|
|
20
22
|
const projectCopy = path.join(temporaryRoot, 'project');
|
|
21
23
|
const isolatedHome = path.join(temporaryRoot, 'home');
|
|
24
|
+
const persistentPnpmStore = options.pnpmCacheRoot ?? path.join(os.homedir(), '.cache', 'upstreamfix', 'pnpm-store');
|
|
22
25
|
const checks = [];
|
|
23
26
|
const unregisterCleanup = registerTemporaryCleanup(() => rm(temporaryRoot, { recursive: true, force: true }));
|
|
24
27
|
try {
|
|
25
28
|
await mkdir(projectCopy, { recursive: true });
|
|
26
29
|
await mkdir(isolatedHome, { recursive: true });
|
|
30
|
+
// This cache is unique to the invocation and removed before returning. Project
|
|
31
|
+
// scripts never receive the user's npm cache path or credentials through env.
|
|
27
32
|
await mkdir(path.join(isolatedHome, 'npm-cache'), { recursive: true });
|
|
28
33
|
await Promise.all([
|
|
29
34
|
writeFile(path.join(isolatedHome, 'npmrc.user'), '', 'utf8'),
|
|
@@ -42,6 +47,7 @@ export async function verifyProposedMigration(cwd, diffs, options = {}) {
|
|
|
42
47
|
const env = sanitizedEnvironment(isolatedHome);
|
|
43
48
|
const installChecks = [];
|
|
44
49
|
const installDirs = new Set(packageDirs);
|
|
50
|
+
const installedRoots = new Set();
|
|
45
51
|
const rootPackage = await readPackageJson(projectCopy).catch(() => undefined);
|
|
46
52
|
if (rootPackage && (rootPackage.scripts?.typecheck || rootPackage.scripts?.test || rootPackage.workspaces) && hasDependencies(rootPackage)) {
|
|
47
53
|
installDirs.add(projectCopy);
|
|
@@ -51,6 +57,24 @@ export async function verifyProposedMigration(cwd, diffs, options = {}) {
|
|
|
51
57
|
if (!hasDependencies(pkg))
|
|
52
58
|
continue;
|
|
53
59
|
const plan = await installCommand(projectCopy, directory, pkg);
|
|
60
|
+
if (installedRoots.has(plan.cwd))
|
|
61
|
+
continue;
|
|
62
|
+
installedRoots.add(plan.cwd);
|
|
63
|
+
if (plan.command === 'yarn' && plan.yarnMajor && plan.yarnMajor >= 2) {
|
|
64
|
+
const versionCheck = await runCheck('Check Yarn version', 'yarn', ['--version'], plan.cwd, env, timeoutMs, options.execute);
|
|
65
|
+
checks.push(versionCheck);
|
|
66
|
+
const actualMajor = Number(/^\s*(\d+)\./.exec(versionCheck.output)?.[1]);
|
|
67
|
+
if (versionCheck.status !== 'passed' || !(actualMajor >= 2) || (plan.yarnExact && actualMajor !== plan.yarnMajor)) {
|
|
68
|
+
return { status: 'failed', checks, note: `Yarn ${plan.yarnMajor} is required by this project, but the temporary copy resolved ${versionCheck.output || 'an unknown version'}. Dependency installation was refused.` };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (plan.command === 'pnpm') {
|
|
72
|
+
const cacheInfo = await lstat(persistentPnpmStore).catch(() => undefined);
|
|
73
|
+
if (cacheInfo?.isSymbolicLink() || (cacheInfo && !cacheInfo.isDirectory()))
|
|
74
|
+
throw new Error('The private pnpm cache path is not a regular directory.');
|
|
75
|
+
await mkdir(persistentPnpmStore, { recursive: true, mode: 0o700 });
|
|
76
|
+
plan.args.push('--store-dir', persistentPnpmStore, '--prefer-offline');
|
|
77
|
+
}
|
|
54
78
|
const installLabel = path.relative(projectCopy, plan.cwd) || '.';
|
|
55
79
|
const check = await runCheck(`Install dependencies (${installLabel})`, plan.command, plan.args, plan.cwd, env, timeoutMs, options.execute);
|
|
56
80
|
installChecks.push(check);
|
|
@@ -103,26 +127,26 @@ export async function verifyProposedMigration(cwd, diffs, options = {}) {
|
|
|
103
127
|
await rm(temporaryRoot, { recursive: true, force: true });
|
|
104
128
|
}
|
|
105
129
|
}
|
|
106
|
-
async function copyProject(source, destination) {
|
|
130
|
+
export async function copyProject(source, destination, operation = '--verify') {
|
|
107
131
|
let listing;
|
|
108
132
|
try {
|
|
109
133
|
({ stdout: listing } = await execFile('git', [
|
|
110
134
|
'-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false', 'ls-files', '-co', '--exclude-standard', '-z',
|
|
111
135
|
], {
|
|
112
136
|
cwd: source,
|
|
113
|
-
env: { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: os.
|
|
137
|
+
env: { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: process.env.HOME ?? os.homedir(), GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' },
|
|
114
138
|
timeout: 15_000,
|
|
115
139
|
maxBuffer: 8 * 1024 * 1024,
|
|
116
140
|
}));
|
|
117
141
|
}
|
|
118
142
|
catch {
|
|
119
|
-
throw new Error(
|
|
143
|
+
throw new Error(`${operation} requires a Git working tree so ignored and untracked files can be excluded safely.`);
|
|
120
144
|
}
|
|
121
145
|
let totalBytes = 0;
|
|
122
146
|
for (const relative of listing.split('\0').filter(Boolean)) {
|
|
123
147
|
const portable = relative.replaceAll('\\', '/');
|
|
124
148
|
const parts = portable.split('/');
|
|
125
|
-
if (parts.some((part) =>
|
|
149
|
+
if (parts.some((part) => IGNORED_PROJECT_DIRECTORIES.has(part))
|
|
126
150
|
|| ignoredFiles.has(parts.at(-1)) || isSensitivePath(portable))
|
|
127
151
|
continue;
|
|
128
152
|
const sourcePath = path.resolve(source, relative);
|
|
@@ -186,6 +210,17 @@ export async function applyProposedFiles(projectCopy, changes) {
|
|
|
186
210
|
if (seen.has(key))
|
|
187
211
|
throw new Error(`A proposed change contains a duplicate path: ${change.relativePath}`);
|
|
188
212
|
seen.add(key);
|
|
213
|
+
let ancestor = root;
|
|
214
|
+
for (const component of relative.split(path.sep)) {
|
|
215
|
+
ancestor = path.join(ancestor, component);
|
|
216
|
+
const info = await lstat(ancestor).catch((error) => {
|
|
217
|
+
if (error.code === 'ENOENT')
|
|
218
|
+
return undefined;
|
|
219
|
+
throw error;
|
|
220
|
+
});
|
|
221
|
+
if (info?.isSymbolicLink())
|
|
222
|
+
throw new Error(`A proposed change crosses a symbolic link: ${change.relativePath}`);
|
|
223
|
+
}
|
|
189
224
|
const current = await readFile(absolute, 'utf8').catch(() => undefined);
|
|
190
225
|
if (current !== change.before)
|
|
191
226
|
throw new Error(`The temporary project does not match the migration input for ${change.relativePath}.`);
|
|
@@ -197,7 +232,9 @@ async function findRelevantPackageDirs(root, packageName) {
|
|
|
197
232
|
const manifests = await findPackageFiles(root);
|
|
198
233
|
const packageDirs = [];
|
|
199
234
|
for (const manifestPath of manifests) {
|
|
200
|
-
const pkg = await readPackageJson(path.dirname(manifestPath));
|
|
235
|
+
const pkg = await readPackageJson(path.dirname(manifestPath)).catch(() => undefined);
|
|
236
|
+
if (!pkg)
|
|
237
|
+
continue;
|
|
201
238
|
if (['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
|
|
202
239
|
.some((section) => typeof pkg[section] === 'object'
|
|
203
240
|
&& pkg[section]?.[packageName])) {
|
|
@@ -227,7 +264,7 @@ async function findPackageFiles(root) {
|
|
|
227
264
|
continue;
|
|
228
265
|
const absolute = path.join(directory, entry.name);
|
|
229
266
|
if (entry.isDirectory()) {
|
|
230
|
-
if (!
|
|
267
|
+
if (!IGNORED_PROJECT_DIRECTORIES.has(entry.name))
|
|
231
268
|
await visit(absolute);
|
|
232
269
|
}
|
|
233
270
|
else if (entry.isFile() && entry.name === 'package.json')
|
|
@@ -238,26 +275,14 @@ async function findPackageFiles(root) {
|
|
|
238
275
|
return found;
|
|
239
276
|
}
|
|
240
277
|
async function readPackageJson(directory) {
|
|
241
|
-
return
|
|
278
|
+
return parseManifest(await readFile(path.join(directory, 'package.json'), 'utf8'));
|
|
242
279
|
}
|
|
243
280
|
function hasDependencies(pkg) {
|
|
244
281
|
return ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
|
|
245
282
|
.some((section) => Object.keys(pkg[section] ?? {}).length > 0);
|
|
246
283
|
}
|
|
247
284
|
async function installCommand(root, directory, pkg) {
|
|
248
|
-
const lockRoot = await
|
|
249
|
-
const lockfile = await findLockfileName(lockRoot);
|
|
250
|
-
const rootPackage = await readPackageJson(lockRoot).catch(() => undefined);
|
|
251
|
-
const declared = pkg.packageManager ?? rootPackage?.packageManager;
|
|
252
|
-
let manager;
|
|
253
|
-
if (declared === undefined)
|
|
254
|
-
manager = lockfile === 'pnpm-lock.yaml' ? 'pnpm' : lockfile === 'yarn.lock' ? 'yarn' : 'npm';
|
|
255
|
-
else {
|
|
256
|
-
if (typeof declared !== 'string' || !/^(npm|pnpm|yarn)@\d+\.\d+\.\d+(?:\+sha\d+\.[0-9a-f]+)?$/.test(declared)) {
|
|
257
|
-
throw new Error(`Unsupported or unsafe packageManager field: ${String(declared)}. Use an exact npm, pnpm, or Yarn version.`);
|
|
258
|
-
}
|
|
259
|
-
manager = declared.slice(0, declared.indexOf('@'));
|
|
260
|
-
}
|
|
285
|
+
const { lockRoot, manager, declared } = await detectPackageManager(root, directory, pkg);
|
|
261
286
|
if (manager === 'pnpm')
|
|
262
287
|
return { command: 'pnpm', args: ['install', '--ignore-scripts', '--no-frozen-lockfile'], cwd: lockRoot };
|
|
263
288
|
if (manager === 'yarn') {
|
|
@@ -265,8 +290,9 @@ async function installCommand(root, directory, pkg) {
|
|
|
265
290
|
const yarnBerry = version >= 2 || (await readFile(path.join(lockRoot, 'yarn.lock'), 'utf8').catch(() => '')).includes('__metadata:');
|
|
266
291
|
return {
|
|
267
292
|
command: 'yarn',
|
|
268
|
-
args: ['install',
|
|
293
|
+
args: ['install', ...(yarnBerry ? ['--mode=skip-builds'] : ['--ignore-scripts'])],
|
|
269
294
|
cwd: lockRoot,
|
|
295
|
+
...(yarnBerry ? { yarnMajor: Math.max(version, 2), yarnExact: version >= 2 } : {}),
|
|
270
296
|
};
|
|
271
297
|
}
|
|
272
298
|
return { command: 'npm', args: ['install', '--ignore-scripts', '--no-audit', '--no-fund', '--prefer-offline'], cwd: lockRoot };
|
|
@@ -280,7 +306,7 @@ async function findLockfileRoot(root, directory) {
|
|
|
280
306
|
break;
|
|
281
307
|
current = path.dirname(current);
|
|
282
308
|
}
|
|
283
|
-
return
|
|
309
|
+
return directory;
|
|
284
310
|
}
|
|
285
311
|
async function findLockfileName(directory) {
|
|
286
312
|
for (const name of ['pnpm-lock.yaml', 'yarn.lock', 'package-lock.json', 'npm-shrinkwrap.json']) {
|
|
@@ -295,22 +321,34 @@ async function findLockfileName(directory) {
|
|
|
295
321
|
return undefined;
|
|
296
322
|
}
|
|
297
323
|
async function scriptCommand(root, directory, pkg, script) {
|
|
298
|
-
const
|
|
299
|
-
const lockfile = await findLockfileName(lockRoot);
|
|
300
|
-
const rootPackage = await readPackageJson(lockRoot).catch(() => undefined);
|
|
301
|
-
const manager = pkg.packageManager?.split('@')[0] ?? rootPackage?.packageManager?.split('@')[0]
|
|
302
|
-
?? (lockfile === 'pnpm-lock.yaml' ? 'pnpm' : lockfile === 'yarn.lock' ? 'yarn' : 'npm');
|
|
324
|
+
const { manager } = await detectPackageManager(root, directory, pkg);
|
|
303
325
|
if (manager === 'pnpm')
|
|
304
326
|
return { command: 'pnpm', args: ['run', script] };
|
|
305
327
|
if (manager === 'yarn')
|
|
306
328
|
return { command: 'yarn', args: ['run', script] };
|
|
307
329
|
return { command: 'npm', args: ['--ignore-scripts', 'run', script] };
|
|
308
330
|
}
|
|
331
|
+
async function detectPackageManager(root, directory, pkg) {
|
|
332
|
+
const lockRoot = await findLockfileRoot(root, directory);
|
|
333
|
+
const lockfile = await findLockfileName(lockRoot);
|
|
334
|
+
const rootPackage = await readPackageJson(lockRoot).catch(() => undefined);
|
|
335
|
+
const declared = pkg.packageManager ?? rootPackage?.packageManager;
|
|
336
|
+
if (declared !== undefined && (typeof declared !== 'string' || !/^(npm|pnpm|yarn)@\d+\.\d+\.\d+(?:\+sha\d+\.[0-9a-f]+)?$/.test(declared))) {
|
|
337
|
+
throw new Error(`Unsupported or unsafe packageManager field: ${String(declared)}. Use an exact npm, pnpm, or Yarn version.`);
|
|
338
|
+
}
|
|
339
|
+
const manager = declared === undefined
|
|
340
|
+
? lockfile === 'pnpm-lock.yaml' ? 'pnpm' : lockfile === 'yarn.lock' ? 'yarn' : 'npm'
|
|
341
|
+
: declared.slice(0, declared.indexOf('@'));
|
|
342
|
+
return { lockRoot, manager, ...(typeof declared === 'string' ? { declared } : {}) };
|
|
343
|
+
}
|
|
309
344
|
async function runCheck(name, command, args, cwd, env, timeoutMs, execute) {
|
|
310
345
|
const start = Date.now();
|
|
311
346
|
const commandText = [command, ...args].join(' ');
|
|
312
347
|
try {
|
|
313
348
|
const result = execute ? await execute(command, args, cwd, env, timeoutMs) : undefined;
|
|
349
|
+
if (execute && result === undefined && name.startsWith('Install dependencies')) {
|
|
350
|
+
throw new Error('The supplied executor did not provide an installation result; refusing to run a real package install.');
|
|
351
|
+
}
|
|
314
352
|
const output = result ?? await executeWithProcessGroup(command, args, cwd, env, timeoutMs);
|
|
315
353
|
return { name, command: commandText, status: 'passed', durationMs: Date.now() - start, output: truncate(`${output.stdout ?? ''}${output.stderr ?? ''}`) };
|
|
316
354
|
}
|
|
@@ -321,7 +359,7 @@ async function runCheck(name, command, args, cwd, env, timeoutMs, execute) {
|
|
|
321
359
|
return { name, command: commandText, status: 'failed', durationMs: Date.now() - start, output: truncate(`${timedOut ? 'Timed out. ' : ''}${output}`) };
|
|
322
360
|
}
|
|
323
361
|
}
|
|
324
|
-
async function executeWithProcessGroup(command, args, cwd, env, timeoutMs) {
|
|
362
|
+
export async function executeWithProcessGroup(command, args, cwd, env, timeoutMs) {
|
|
325
363
|
return new Promise((resolve, reject) => {
|
|
326
364
|
const child = spawn(command, args, {
|
|
327
365
|
cwd,
|
|
@@ -333,69 +371,52 @@ async function executeWithProcessGroup(command, args, cwd, env, timeoutMs) {
|
|
|
333
371
|
const unregisterGroup = child.pid ? trackProcessGroup(child.pid) : () => undefined;
|
|
334
372
|
let stdout = '';
|
|
335
373
|
let stderr = '';
|
|
336
|
-
|
|
374
|
+
const stdoutDecoder = new StringDecoder('utf8');
|
|
375
|
+
const stderrDecoder = new StringDecoder('utf8');
|
|
337
376
|
let settled = false;
|
|
338
|
-
let killTimer;
|
|
339
377
|
const timeout = setTimeout(() => {
|
|
340
|
-
|
|
341
|
-
signalProcessGroup(child, 'SIGTERM');
|
|
342
|
-
killTimer = setTimeout(() => signalProcessGroup(child, 'SIGKILL'), 750);
|
|
378
|
+
void finish(new Error(`Command exceeded its ${timeoutMs} ms timeout.`), true);
|
|
343
379
|
}, timeoutMs);
|
|
344
380
|
const append = (current, chunk) => {
|
|
345
|
-
const
|
|
346
|
-
|
|
381
|
+
const marker = '\n… [middle of command output omitted] …\n';
|
|
382
|
+
const limit = 2 * 1024 * 1024;
|
|
383
|
+
const next = current + chunk;
|
|
384
|
+
if (next.length <= limit)
|
|
385
|
+
return next;
|
|
386
|
+
const middle = current.indexOf(marker);
|
|
387
|
+
const head = middle >= 0 ? current.slice(0, middle) : next.slice(0, limit / 2);
|
|
388
|
+
return head + marker + next.slice(-limit / 2);
|
|
347
389
|
};
|
|
348
|
-
child.stdout?.on('data', (chunk) => { stdout = append(stdout, chunk); });
|
|
349
|
-
child.stderr?.on('data', (chunk) => { stderr = append(stderr, chunk); });
|
|
350
|
-
const finish = (error) => {
|
|
390
|
+
child.stdout?.on('data', (chunk) => { stdout = append(stdout, stdoutDecoder.write(chunk)); });
|
|
391
|
+
child.stderr?.on('data', (chunk) => { stderr = append(stderr, stderrDecoder.write(chunk)); });
|
|
392
|
+
const finish = async (error, timedOut = false) => {
|
|
351
393
|
if (settled)
|
|
352
394
|
return;
|
|
353
395
|
settled = true;
|
|
354
396
|
clearTimeout(timeout);
|
|
355
|
-
if (
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
};
|
|
360
|
-
child.once('error', async (error) => {
|
|
361
|
-
if (child.pid)
|
|
362
|
-
await terminateProcessGroup(child.pid, 0);
|
|
363
|
-
unregisterGroup();
|
|
364
|
-
const failure = Object.assign(error, { stdout, stderr, timedOut });
|
|
365
|
-
finish(failure);
|
|
366
|
-
});
|
|
367
|
-
child.once('close', async (code, signal) => {
|
|
397
|
+
if (!timedOut)
|
|
398
|
+
await new Promise((done) => setTimeout(done, 50));
|
|
399
|
+
stdout = append(stdout, stdoutDecoder.end());
|
|
400
|
+
stderr = append(stderr, stderrDecoder.end());
|
|
368
401
|
if (child.pid)
|
|
369
|
-
await terminateProcessGroup(child.pid);
|
|
402
|
+
await terminateProcessGroup(child.pid, timedOut ? 100 : 0);
|
|
403
|
+
child.stdout?.destroy();
|
|
404
|
+
child.stderr?.destroy();
|
|
370
405
|
unregisterGroup();
|
|
371
|
-
if (
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
else if (code === 0) {
|
|
375
|
-
finish();
|
|
406
|
+
if (error)
|
|
407
|
+
reject(Object.assign(error, { stdout, stderr, ...(timedOut ? { code: 'ETIMEDOUT', timedOut: true } : {}) }));
|
|
408
|
+
else
|
|
376
409
|
resolve({ stdout, stderr });
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
410
|
+
};
|
|
411
|
+
child.once('error', (error) => {
|
|
412
|
+
void finish(error);
|
|
413
|
+
});
|
|
414
|
+
child.once('exit', (code, signal) => {
|
|
415
|
+
void finish(code === 0 ? undefined : new Error(`Command exited with ${signal ? `signal ${signal}` : `code ${code}`}.`));
|
|
381
416
|
});
|
|
382
417
|
});
|
|
383
418
|
}
|
|
384
|
-
function
|
|
385
|
-
if (!child.pid)
|
|
386
|
-
return;
|
|
387
|
-
try {
|
|
388
|
-
if (process.platform === 'win32')
|
|
389
|
-
child.kill(signal);
|
|
390
|
-
else
|
|
391
|
-
process.kill(-child.pid, signal);
|
|
392
|
-
}
|
|
393
|
-
catch (error) {
|
|
394
|
-
if (error.code !== 'ESRCH')
|
|
395
|
-
child.kill(signal);
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
function sanitizedEnvironment(home) {
|
|
419
|
+
export function sanitizedEnvironment(home) {
|
|
399
420
|
const allowed = new Set(['PATH', 'SYSTEMROOT', 'WINDIR', 'TMP', 'TEMP', 'TMPDIR', 'LANG', 'LC_ALL', 'TZ']);
|
|
400
421
|
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => allowed.has(key)));
|
|
401
422
|
env.HOME = home;
|
|
@@ -403,13 +424,24 @@ function sanitizedEnvironment(home) {
|
|
|
403
424
|
env.NPM_CONFIG_USERCONFIG = path.join(home, 'npmrc.user');
|
|
404
425
|
env.NPM_CONFIG_GLOBALCONFIG = path.join(home, 'npmrc.global');
|
|
405
426
|
env.NPM_CONFIG_CACHE = path.join(home, 'npm-cache');
|
|
427
|
+
env.NPM_CONFIG_IGNORE_SCRIPTS = 'true';
|
|
428
|
+
env.YARN_IGNORE_SCRIPTS = 'true';
|
|
429
|
+
env.YARN_NODE_LINKER = 'node-modules';
|
|
430
|
+
env.YARN_ENABLE_SCRIPTS = 'false';
|
|
406
431
|
env.CI = '1';
|
|
407
432
|
env.COREPACK_ENABLE_UNSAFE_CUSTOM_URLS = '0';
|
|
433
|
+
env.COREPACK_ENABLE_PROJECT_SPEC = '0';
|
|
434
|
+
env.COREPACK_ENABLE_AUTO_PIN = '0';
|
|
435
|
+
env.COREPACK_HOME = path.join(home, 'corepack');
|
|
408
436
|
return env;
|
|
409
437
|
}
|
|
410
438
|
function truncate(output) {
|
|
411
439
|
const trimmed = output.trim();
|
|
412
|
-
|
|
440
|
+
if (trimmed.length <= maxOutputLength)
|
|
441
|
+
return trimmed;
|
|
442
|
+
const marker = '\n… [truncated middle; beginning and end retained] …\n';
|
|
443
|
+
const headLength = Math.floor((maxOutputLength - marker.length) / 2);
|
|
444
|
+
return trimmed.slice(0, headLength) + marker + trimmed.slice(-(maxOutputLength - marker.length - headLength));
|
|
413
445
|
}
|
|
414
446
|
async function fileExists(file) {
|
|
415
447
|
try {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
function exactVersion(value) {
|
|
5
|
+
return typeof value === 'string' && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value);
|
|
6
|
+
}
|
|
7
|
+
export async function readBoundedJson(url, maxBytes = 2 * 1024 * 1024, fetcher = fetch) {
|
|
8
|
+
const response = await fetcher(url, { signal: AbortSignal.timeout(10_000), redirect: 'error', headers: { accept: 'application/json' } });
|
|
9
|
+
if (!response.ok)
|
|
10
|
+
throw new Error(`Registry request failed (${response.status}) for ${url}`);
|
|
11
|
+
const chunks = [];
|
|
12
|
+
let bytes = 0;
|
|
13
|
+
for await (const chunk of response.body ?? []) {
|
|
14
|
+
bytes += chunk.byteLength;
|
|
15
|
+
if (bytes > maxBytes)
|
|
16
|
+
throw new Error(`Registry response exceeds ${maxBytes} bytes.`);
|
|
17
|
+
chunks.push(chunk);
|
|
18
|
+
}
|
|
19
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
20
|
+
}
|
|
21
|
+
export async function npmLatestVersion(packageName, fetcher = fetch) {
|
|
22
|
+
const data = await readBoundedJson(`https://registry.npmjs.org/-/package/${encodeURIComponent(packageName)}/dist-tags`, undefined, fetcher);
|
|
23
|
+
if (!exactVersion(data.latest))
|
|
24
|
+
throw new Error(`Registry returned an invalid latest version for ${packageName}.`);
|
|
25
|
+
return data.latest;
|
|
26
|
+
}
|
|
27
|
+
export async function watchPackages(statePath, packages, lookup = npmLatestVersion) {
|
|
28
|
+
const previous = await readFile(statePath, 'utf8')
|
|
29
|
+
.then((contents) => JSON.parse(contents), (error) => {
|
|
30
|
+
if (error.code === 'ENOENT')
|
|
31
|
+
return { versions: {}, events: [] };
|
|
32
|
+
throw error;
|
|
33
|
+
});
|
|
34
|
+
if (!previous || !previous.versions || typeof previous.versions !== 'object' || Array.isArray(previous.versions) || !Array.isArray(previous.events)
|
|
35
|
+
|| Object.values(previous.versions).some((version) => !exactVersion(version))
|
|
36
|
+
|| previous.events.some((event) => !event || typeof event.package !== 'string' || !exactVersion(event.from) || !exactVersion(event.to))) {
|
|
37
|
+
throw new Error('Invalid watch state file.');
|
|
38
|
+
}
|
|
39
|
+
const versions = Object.assign(Object.create(null), previous.versions);
|
|
40
|
+
const events = [];
|
|
41
|
+
for (const packageName of [...new Set(packages)]) {
|
|
42
|
+
const latest = await lookup(packageName);
|
|
43
|
+
if (!exactVersion(latest))
|
|
44
|
+
throw new Error(`Invalid version for ${packageName}.`);
|
|
45
|
+
const old = Object.hasOwn(versions, packageName) ? versions[packageName] : undefined;
|
|
46
|
+
if (old && compareVersions(latest, old) > 0 && (Number(latest.split('.')[0]) > Number(old.split('.')[0])
|
|
47
|
+
|| (Number(latest.split('.')[0]) === Number(old.split('.')[0]) && old.includes('-') && !latest.includes('-'))))
|
|
48
|
+
events.push({ package: packageName, from: old, to: latest });
|
|
49
|
+
if (!old || compareVersions(latest, old) > 0)
|
|
50
|
+
versions[packageName] = latest;
|
|
51
|
+
}
|
|
52
|
+
const next = { versions, events: [...previous.events, ...events].slice(-100) };
|
|
53
|
+
const absolute = path.resolve(statePath);
|
|
54
|
+
await mkdir(path.dirname(absolute), { recursive: true });
|
|
55
|
+
const temporary = `${absolute}.${randomUUID()}.tmp`;
|
|
56
|
+
try {
|
|
57
|
+
await writeFile(temporary, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
|
|
58
|
+
await rename(temporary, absolute);
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
await rm(temporary, { force: true });
|
|
62
|
+
}
|
|
63
|
+
return { versions, events };
|
|
64
|
+
}
|
|
65
|
+
function compareVersions(left, right) {
|
|
66
|
+
const a = left.split(/[.-]/).slice(0, 3).map(Number);
|
|
67
|
+
const b = right.split(/[.-]/).slice(0, 3).map(Number);
|
|
68
|
+
for (let index = 0; index < 3; index++) {
|
|
69
|
+
if (a[index] !== b[index])
|
|
70
|
+
return a[index] - b[index];
|
|
71
|
+
}
|
|
72
|
+
const leftPrerelease = left.split('-', 2)[1];
|
|
73
|
+
const rightPrerelease = right.split('-', 2)[1];
|
|
74
|
+
if (leftPrerelease === undefined && rightPrerelease !== undefined)
|
|
75
|
+
return 1;
|
|
76
|
+
if (leftPrerelease !== undefined && rightPrerelease === undefined)
|
|
77
|
+
return -1;
|
|
78
|
+
if (leftPrerelease === undefined || rightPrerelease === undefined)
|
|
79
|
+
return 0;
|
|
80
|
+
return leftPrerelease.localeCompare(rightPrerelease, undefined, { numeric: true });
|
|
81
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { execFile as execFileCallback } from 'node:child_process';
|
|
2
|
-
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
2
|
+
import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import { runMigration } from './runner.js';
|
|
7
7
|
import { applyProposedFiles, verifyProposedMigration } from './verify.js';
|
|
8
8
|
import { registerTemporaryCleanup } from './process-cleanup.js';
|
|
9
|
+
import { parseManifest } from './manifest.js';
|
|
9
10
|
const execFile = promisify(execFileCallback);
|
|
10
11
|
/**
|
|
11
12
|
* Builds and verifies a migration in a detached worktree, then commits only the
|
|
@@ -13,7 +14,7 @@ const execFile = promisify(execFileCallback);
|
|
|
13
14
|
* `WRITES_ENABLED` safety gate before calling this operation.
|
|
14
15
|
*/
|
|
15
16
|
export async function writeMigrationOnBranch(migration, options) {
|
|
16
|
-
const cwd = path.resolve(options.cwd);
|
|
17
|
+
const cwd = await realpath(path.resolve(options.cwd));
|
|
17
18
|
const repositoryRoot = await git(cwd, ['rev-parse', '--show-toplevel']).then((value) => value.trim()).catch(() => {
|
|
18
19
|
throw new Error('--write requires the project directory to be inside a Git repository.');
|
|
19
20
|
});
|
|
@@ -42,13 +43,13 @@ export async function writeMigrationOnBranch(migration, options) {
|
|
|
42
43
|
await git(repositoryRoot, ['worktree', 'remove', '--force', worktree]).catch(() => undefined);
|
|
43
44
|
await rm(worktreeParent, { recursive: true, force: true });
|
|
44
45
|
if (branchCreated && !committed)
|
|
45
|
-
await
|
|
46
|
+
await removeUncommittedBranch(repositoryRoot, branch, head);
|
|
46
47
|
});
|
|
47
48
|
try {
|
|
48
49
|
await git(repositoryRoot, ['worktree', 'add', '--detach', worktree, head]);
|
|
49
50
|
worktreeAdded = true;
|
|
50
51
|
const projectCopy = projectRelative ? path.join(worktree, projectRelative) : worktree;
|
|
51
|
-
const outcome = await runMigration(migration, { cwd: projectCopy, dryRun: true, nodeRuntime: options.nodeRuntime });
|
|
52
|
+
const outcome = await runMigration(migration, { cwd: projectCopy, dryRun: true, nodeRuntime: options.nodeRuntime, allowYarnLockInstall: options.allowYarnLockInstall });
|
|
52
53
|
if (outcome.manual.length > 0) {
|
|
53
54
|
throw new Error(`Migration has ${outcome.manual.length} manual review item(s); no branch or commit was created.`);
|
|
54
55
|
}
|
|
@@ -95,8 +96,8 @@ export async function writeMigrationOnBranch(migration, options) {
|
|
|
95
96
|
}
|
|
96
97
|
}
|
|
97
98
|
await git(worktree, ['commit', '-m', `Apply ${migration.id} migration with UpstreamFix`]);
|
|
98
|
-
const commit = (await git(worktree, ['rev-parse', 'HEAD'])).trim();
|
|
99
99
|
committed = true;
|
|
100
|
+
const commit = (await git(worktree, ['rev-parse', 'HEAD'])).trim();
|
|
100
101
|
return {
|
|
101
102
|
branch,
|
|
102
103
|
commit,
|
|
@@ -112,9 +113,14 @@ export async function writeMigrationOnBranch(migration, options) {
|
|
|
112
113
|
}
|
|
113
114
|
await rm(worktreeParent, { recursive: true, force: true });
|
|
114
115
|
if (branchCreated && !committed)
|
|
115
|
-
await
|
|
116
|
+
await removeUncommittedBranch(repositoryRoot, branch, head);
|
|
116
117
|
}
|
|
117
118
|
}
|
|
119
|
+
async function removeUncommittedBranch(repositoryRoot, branch, originalHead) {
|
|
120
|
+
const current = await git(repositoryRoot, ['rev-parse', '--verify', branch]).then((value) => value.trim()).catch(() => undefined);
|
|
121
|
+
if (current === originalHead)
|
|
122
|
+
await git(repositoryRoot, ['branch', '-D', branch]).catch(() => undefined);
|
|
123
|
+
}
|
|
118
124
|
/** Do not commit vNext source code while its containing manifest still installs vOld. */
|
|
119
125
|
async function assertChangedSourcesUseTargetSdk(projectRoot, migration, outcome) {
|
|
120
126
|
const proposed = new Map(outcome.proposedFiles.map((file) => [path.resolve(projectRoot, file.relativePath), file.after]));
|
|
@@ -133,7 +139,7 @@ async function assertChangedSourcesUseTargetSdk(projectRoot, migration, outcome)
|
|
|
133
139
|
const manifestFile = path.join(directory, 'package.json');
|
|
134
140
|
const raw = proposed.get(manifestFile) ?? await readFile(manifestFile, 'utf8').catch(() => undefined);
|
|
135
141
|
if (raw) {
|
|
136
|
-
const manifest =
|
|
142
|
+
const manifest = parseManifest(raw);
|
|
137
143
|
const ranges = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
|
|
138
144
|
.map((section) => manifest[section]?.[migration.packageName])
|
|
139
145
|
.filter((range) => typeof range === 'string');
|
|
@@ -161,15 +167,17 @@ async function assertChangedSourcesUseTargetSdk(projectRoot, migration, outcome)
|
|
|
161
167
|
}
|
|
162
168
|
async function git(cwd, args) {
|
|
163
169
|
try {
|
|
170
|
+
const settings = await readPreservedGitSettings(cwd);
|
|
164
171
|
const { stdout } = await execFile('git', [
|
|
165
172
|
'-c', `core.hooksPath=${os.devNull}`,
|
|
166
173
|
'-c', 'core.fsmonitor=false',
|
|
167
174
|
'-c', 'core.untrackedCache=false',
|
|
168
175
|
'-c', 'protocol.file.allow=never',
|
|
176
|
+
'-c', `commit.gpgsign=${settings.get('commit.gpgsign') === 'true' ? 'true' : 'false'}`,
|
|
169
177
|
...args,
|
|
170
178
|
], {
|
|
171
179
|
cwd,
|
|
172
|
-
env: await gitEnvironment(cwd),
|
|
180
|
+
env: await gitEnvironment(cwd, settings),
|
|
173
181
|
timeout: 30_000,
|
|
174
182
|
maxBuffer: 2 * 1024 * 1024,
|
|
175
183
|
windowsHide: true,
|
|
@@ -189,8 +197,7 @@ const preservedGitConfigKeys = [
|
|
|
189
197
|
'gpg.ssh.program',
|
|
190
198
|
'user.signingkey',
|
|
191
199
|
];
|
|
192
|
-
async function gitEnvironment(cwd) {
|
|
193
|
-
const settings = await readPreservedGitSettings(cwd);
|
|
200
|
+
async function gitEnvironment(cwd, settings) {
|
|
194
201
|
const env = {
|
|
195
202
|
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
|
196
203
|
HOME: process.env.HOME ?? os.homedir(),
|
|
@@ -262,10 +269,7 @@ async function readGitConfig(cwd, env, keys, globalOnly = false) {
|
|
|
262
269
|
return settings;
|
|
263
270
|
}
|
|
264
271
|
async function assertSafeLocalGitConfig(repositoryRoot) {
|
|
265
|
-
const
|
|
266
|
-
'-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false', '-c', 'protocol.file.allow=never',
|
|
267
|
-
'config', '--local', '--get-regexp', String.raw `^(core\.fsmonitor|filter\..*\.(clean|smudge|process|required)|diff\.external|diff\..*\.command)$`,
|
|
268
|
-
], {
|
|
272
|
+
const options = {
|
|
269
273
|
cwd: repositoryRoot,
|
|
270
274
|
env: {
|
|
271
275
|
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
|
@@ -277,19 +281,33 @@ async function assertSafeLocalGitConfig(repositoryRoot) {
|
|
|
277
281
|
timeout: 10_000,
|
|
278
282
|
maxBuffer: 1024 * 1024,
|
|
279
283
|
windowsHide: true,
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
284
|
+
};
|
|
285
|
+
const output = await Promise.all(['--local', '--worktree'].map(async (scope) => {
|
|
286
|
+
const { stdout } = await execFile('git', [
|
|
287
|
+
'-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false', '-c', 'protocol.file.allow=never',
|
|
288
|
+
'config', scope, '--includes', '--show-origin', '--null', '--list',
|
|
289
|
+
], options);
|
|
290
|
+
return stdout;
|
|
291
|
+
}));
|
|
292
|
+
const dangerous = [];
|
|
293
|
+
for (const listing of output) {
|
|
294
|
+
const fields = listing.split('\0');
|
|
295
|
+
for (let index = 0; index + 1 < fields.length; index += 2) {
|
|
296
|
+
const origin = fields[index] ?? '';
|
|
297
|
+
const record = fields[index + 1] ?? '';
|
|
298
|
+
const separator = record.indexOf('\n');
|
|
299
|
+
if (separator < 0)
|
|
300
|
+
continue;
|
|
301
|
+
const key = record.slice(0, separator).toLowerCase();
|
|
302
|
+
const value = record.slice(separator + 1).trim();
|
|
303
|
+
if (/^include(?:if\..*)?\.path$/.test(key))
|
|
304
|
+
dangerous.push(`${origin}: ${key}`);
|
|
305
|
+
else if (key === 'core.fsmonitor' && value && !/^false$/i.test(value))
|
|
306
|
+
dangerous.push(`${origin}: ${key}`);
|
|
307
|
+
else if (/^(?:filter\..*\.(?:clean|smudge|process|required)|diff\.external|diff\..*\.command|gpg\..*)$/.test(key) && value)
|
|
308
|
+
dangerous.push(`${origin}: ${key}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
293
311
|
if (dangerous.length > 0) {
|
|
294
312
|
throw new Error('The repository has configured Git filters, diff commands, or fsmonitor hooks. Remove them before using --write.');
|
|
295
313
|
}
|