upstreamfix 0.1.0 → 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.
@@ -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
- const ignoredDirectories = new Set(['.git', 'node_modules', 'dist', 'coverage', '.next', '.nuxt', '.turbo', '.cache', '.codex', '.claude']);
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.tmpdir(), GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: os.devNull, GIT_TERMINAL_PROMPT: '0' },
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('--verify requires a Git working tree so ignored and untracked files can be excluded safely.');
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) => ignoredDirectories.has(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 (!ignoredDirectories.has(entry.name))
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 JSON.parse(await readFile(path.join(directory, 'package.json'), 'utf8'));
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 findLockfileRoot(root, directory);
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', '--non-interactive', ...(yarnBerry ? ['--mode=skip-builds'] : ['--ignore-scripts'])],
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 root;
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 lockRoot = await findLockfileRoot(root, directory);
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
- let timedOut = false;
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
- timedOut = true;
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 remaining = 2 * 1024 * 1024 - Buffer.byteLength(current);
346
- return remaining <= 0 ? current : current + chunk.toString('utf8').slice(0, remaining);
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 (killTimer)
356
- clearTimeout(killTimer);
357
- if (error)
358
- reject(error);
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 (timedOut) {
372
- finish(Object.assign(new Error(`Command exceeded its ${timeoutMs} ms timeout.`), { code: 'ETIMEDOUT', stdout, stderr, timedOut: true }));
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
- else {
379
- finish(Object.assign(new Error(`Command exited with ${signal ? `signal ${signal}` : `code ${code}`}.`), { stdout, stderr }));
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 signalProcessGroup(child, signal) {
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
- return trimmed.length <= maxOutputLength ? trimmed : `${trimmed.slice(0, maxOutputLength)} … [truncated]`;
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 git(repositoryRoot, ['branch', '-D', branch]).catch(() => undefined);
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 git(repositoryRoot, ['branch', '-D', branch]).catch(() => undefined);
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 = JSON.parse(raw);
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 { stdout } = await execFile('git', [
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
- }).catch((error) => {
281
- if (String(error.code) !== '1')
282
- throw error;
283
- return { stdout: error.stdout ?? '' };
284
- });
285
- const dangerous = stdout.split(/\r?\n/).filter((line) => {
286
- const [key, ...value] = line.trim().split(/\s+/);
287
- if (!key)
288
- return false;
289
- if (key.toLowerCase() === 'core.fsmonitor')
290
- return value.join(' ') !== '' && !/^false$/i.test(value.join(' '));
291
- return value.join(' ').trim() !== '';
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
  }