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.
@@ -1,14 +1,20 @@
1
1
  import { readFileSync, readdirSync } from 'node:fs';
2
2
  import { execFile as execFileCallback } from 'node:child_process';
3
3
  import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { registerTemporaryCleanup } from './process-cleanup.js';
4
5
  import { promisify } from 'node:util';
5
6
  import path from 'node:path';
6
7
  import { tmpdir } from 'node:os';
7
8
  import { structuredPatch } from 'diff';
8
9
  import { Project } from 'ts-morph';
10
+ import { IGNORED_PROJECT_DIRECTORIES, isSupportedSourceMajor } from './project-discovery.js';
11
+ import { parseManifest } from './manifest.js';
9
12
  const execFile = promisify(execFileCallback);
10
13
  const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']);
11
- const IGNORED_DIRS = new Set(['node_modules', 'dist', 'coverage', '.next', '.git', '.venv', 'venv']);
14
+ const MAX_DIFF_INPUT_BYTES = 1024 * 1024;
15
+ const MAX_DIFF_OUTPUT_CHARS = 64 * 1024;
16
+ const MAX_REPORT_DIFF_CHARS = 256 * 1024;
17
+ const LOCKFILE_NAMES = new Set(['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock']);
12
18
  export function loadProject(cwd, packageName = 'firebase-admin') {
13
19
  const project = new Project({
14
20
  skipAddingFilesFromTsConfig: true,
@@ -27,7 +33,7 @@ function loadScopedProject(root, migration) {
27
33
  let parsed;
28
34
  let invalid = false;
29
35
  try {
30
- parsed = JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
36
+ parsed = parseManifest(readFileSync(file, 'utf8'));
31
37
  }
32
38
  catch {
33
39
  invalid = true;
@@ -82,7 +88,7 @@ function loadScopedProject(root, migration) {
82
88
  });
83
89
  continue;
84
90
  }
85
- if (!isSourceMajorRange(declaration.range, migration.fromMajor)) {
91
+ if (!isSupportedSourceMajor(declaration.range, migration.fromMajor)) {
86
92
  manual.push({
87
93
  file: sourcePath,
88
94
  line: 1,
@@ -96,9 +102,6 @@ function loadScopedProject(root, migration) {
96
102
  }
97
103
  return { project, manual, manifestBySource };
98
104
  }
99
- function isSourceMajorRange(range, major) {
100
- return new RegExp(`^\\s*[~^]?${major}(?:\\.(?:\\d+|x|\\*)){0,2}\\s*$`, 'i').test(range);
101
- }
102
105
  function isRegistryVersionSpec(spec) {
103
106
  // A deliberately narrow subset: registry dist-tags or ordinary semver ranges.
104
107
  // Anything that can name a path, URL, Git host, workspace, alias, or patch is manual.
@@ -138,6 +141,9 @@ export function lockfileCommandEnvironment(home) {
138
141
  NPM_CONFIG_FUND: 'false',
139
142
  NPM_CONFIG_IGNORE_SCRIPTS: 'true',
140
143
  COREPACK_ENABLE_PROJECT_SPEC: '0',
144
+ COREPACK_ENABLE_AUTO_PIN: '0',
145
+ COREPACK_ENABLE_UNSAFE_CUSTOM_URLS: '0',
146
+ COREPACK_HOME: path.join(home, 'corepack'),
141
147
  GIT_CONFIG_NOSYSTEM: '1',
142
148
  GIT_TERMINAL_PROMPT: '0',
143
149
  };
@@ -148,7 +154,7 @@ function findSourceFiles(root, packageName) {
148
154
  for (const entry of readdirSync(directory, { withFileTypes: true })) {
149
155
  const absolutePath = path.join(directory, entry.name);
150
156
  if (entry.isDirectory()) {
151
- if (!IGNORED_DIRS.has(entry.name))
157
+ if (!IGNORED_PROJECT_DIRECTORIES.has(entry.name))
152
158
  visit(absolutePath);
153
159
  continue;
154
160
  }
@@ -172,7 +178,7 @@ export async function runMigration(migration, options) {
172
178
  }
173
179
  const scoped = loadScopedProject(options.cwd, migration);
174
180
  const project = scoped.project;
175
- const originalSources = new Map(project.getSourceFiles().map((file) => [file.getFilePath(), file.getFullText()]));
181
+ const originalSources = new Map(await Promise.all(project.getSourceFiles().map(async (file) => [file.getFilePath(), await readFile(file.getFilePath(), 'utf8')])));
176
182
  const result = migration.transform(project);
177
183
  result.manual.unshift(...scoped.manual);
178
184
  const compatibilityNotes = migration.inspectManifest
@@ -182,7 +188,7 @@ export async function runMigration(migration, options) {
182
188
  throw new Error('This migration has manual review items; no files were written. Resolve them and preview again.');
183
189
  }
184
190
  const manifestsWithChangedCode = new Set(result.filesChanged.map((sourcePath) => scoped.manifestBySource.get(sourcePath)).filter((file) => Boolean(file)));
185
- const manifest = result.manual.length === 0 && result.changes.length > 0
191
+ const manifest = !options.analysisOnly && result.manual.length === 0 && result.changes.length > 0
186
192
  ? await migrateManifests(migration, options, manifestsWithChangedCode)
187
193
  : {
188
194
  notes: [result.manual.length > 0
@@ -194,6 +200,11 @@ export async function runMigration(migration, options) {
194
200
  if (!options.dryRun && manifest.manual.length > 0) {
195
201
  throw new Error('A dependency manifest needs manual review; no files were written. Resolve it and preview again.');
196
202
  }
203
+ for (const update of result.fileUpdates ?? []) {
204
+ if (originalSources.get(update.file)?.startsWith('\uFEFF') && !update.contents.startsWith('\uFEFF')) {
205
+ update.contents = `\uFEFF${update.contents}`;
206
+ }
207
+ }
197
208
  const sourceUpdates = new Map((result.fileUpdates ?? []).map((update) => [update.file, update.contents]));
198
209
  const proposedFiles = [];
199
210
  const proposedDiff = result.filesChanged.flatMap((filePath) => {
@@ -212,6 +223,15 @@ export async function runMigration(migration, options) {
212
223
  if (diff)
213
224
  proposedDiff.push(diff);
214
225
  }
226
+ let reportedCharacters = 0;
227
+ for (let index = 0; index < proposedDiff.length; index++) {
228
+ const remaining = MAX_REPORT_DIFF_CHARS - reportedCharacters;
229
+ if (remaining < proposedDiff[index].length) {
230
+ proposedDiff.splice(index, proposedDiff.length - index, `[Further diffs withheld: the report limit is ${MAX_REPORT_DIFF_CHARS} characters.]`);
231
+ break;
232
+ }
233
+ reportedCharacters += proposedDiff[index].length;
234
+ }
215
235
  return {
216
236
  ...result,
217
237
  filesChanged: [
@@ -232,7 +252,7 @@ async function inspectManifests(migration, cwd) {
232
252
  if (!raw.includes(JSON.stringify(migration.packageName)))
233
253
  continue;
234
254
  try {
235
- const manifest = JSON.parse(raw.replace(/^\uFEFF/, ''));
255
+ const manifest = parseManifest(raw);
236
256
  notes.push(...(migration.inspectManifest?.(manifest) ?? []));
237
257
  }
238
258
  catch {
@@ -258,7 +278,7 @@ async function migrateManifests(migration, options, manifestsWithChangedCode) {
258
278
  }
259
279
  let manifest;
260
280
  try {
261
- manifest = JSON.parse(raw.replace(/^\uFEFF/, ''));
281
+ manifest = parseManifest(raw);
262
282
  }
263
283
  catch {
264
284
  manual.push({
@@ -323,7 +343,9 @@ async function migrateManifests(migration, options, manifestsWithChangedCode) {
323
343
  : existingLocks.length === 1 && path.basename(existingLocks[0]) === 'pnpm-lock.yaml'
324
344
  ? await regeneratePnpmLock(manifestPath, path.resolve(options.cwd, existingLocks[0]), updated, options.cwd)
325
345
  : existingLocks.length === 1 && path.basename(existingLocks[0]) === 'yarn.lock'
326
- ? await regenerateYarnLock(manifestPath, path.resolve(options.cwd, existingLocks[0]), updated, options.cwd)
346
+ ? options.allowYarnLockInstall
347
+ ? await regenerateYarnLock(manifestPath, path.resolve(options.cwd, existingLocks[0]), updated, options.cwd)
348
+ : { reason: 'Yarn Classic lockfile regeneration requires --allow-yarn-lock-install because it runs a full dependency installation (estimated 50–400 MB of downloads without a warm cache).' }
327
349
  : { reason: `This package uses unsupported lockfile(s): ${existingLocks.join(', ')}.` };
328
350
  if (!lockResult.contents) {
329
351
  notes.push(...fileNotes.filter((note) => !note.includes(' → ')));
@@ -375,7 +397,7 @@ async function regeneratePackageLock(manifestPath, lockPath, updatedManifest, pr
375
397
  let before;
376
398
  let lock;
377
399
  try {
378
- manifest = JSON.parse(updatedManifest.replace(/^\uFEFF/, ''));
400
+ manifest = parseManifest(updatedManifest);
379
401
  before = await readFile(lockPath, 'utf8');
380
402
  lock = JSON.parse(before);
381
403
  }
@@ -401,11 +423,14 @@ async function regeneratePackageLock(manifestPath, lockPath, updatedManifest, pr
401
423
  configDirectory = path.dirname(configDirectory);
402
424
  }
403
425
  let temporaryDirectory;
426
+ let unregisterCleanup;
404
427
  try {
405
428
  temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'upstreamfix-npm-lock-'));
429
+ unregisterCleanup = registerTemporaryCleanup(() => rm(temporaryDirectory, { recursive: true, force: true }));
406
430
  await writeFile(path.join(temporaryDirectory, 'package.json'), updatedManifest, 'utf8');
407
431
  await writeFile(path.join(temporaryDirectory, 'package-lock.json'), before, 'utf8');
408
432
  const args = ['install', '--package-lock-only', '--lockfile-version=3', '--ignore-scripts', '--no-audit', '--no-fund'];
433
+ // npm_execpath may be pnpm or Yarn when launched through dlx; resolve npm from PATH instead.
409
434
  await execFile('npm', args, { cwd: temporaryDirectory, env: lockfileCommandEnvironment(temporaryDirectory), timeout: 120_000, maxBuffer: 4 * 1024 * 1024 });
410
435
  const contents = await readFile(path.join(temporaryDirectory, 'package-lock.json'), 'utf8');
411
436
  return { before, contents };
@@ -414,6 +439,7 @@ async function regeneratePackageLock(manifestPath, lockPath, updatedManifest, pr
414
439
  return { reason: 'npm could not regenerate package-lock.json in an isolated temporary directory.' };
415
440
  }
416
441
  finally {
442
+ unregisterCleanup?.();
417
443
  if (temporaryDirectory)
418
444
  await rm(temporaryDirectory, { recursive: true, force: true });
419
445
  }
@@ -429,7 +455,7 @@ async function regeneratePnpmLock(manifestPath, lockPath, updatedManifest, proje
429
455
  let manifest;
430
456
  let before;
431
457
  try {
432
- manifest = JSON.parse(updatedManifest.replace(/^\uFEFF/, ''));
458
+ manifest = parseManifest(updatedManifest);
433
459
  before = await readFile(lockPath, 'utf8');
434
460
  }
435
461
  catch {
@@ -459,8 +485,10 @@ async function regeneratePnpmLock(manifestPath, lockPath, updatedManifest, proje
459
485
  return { reason: 'The packageManager field does not pin an exact pnpm version, so lockfile regeneration cannot be version-checked safely.' };
460
486
  }
461
487
  let temporaryDirectory;
488
+ let unregisterCleanup;
462
489
  try {
463
490
  const probeHome = await mkdtemp(path.join(tmpdir(), 'upstreamfix-pnpm-home-'));
491
+ const unregisterProbeHome = registerTemporaryCleanup(() => rm(probeHome, { recursive: true, force: true }));
464
492
  let versionOutput;
465
493
  try {
466
494
  ({ stdout: versionOutput } = await execFile('pnpm', ['--version'], {
@@ -471,6 +499,7 @@ async function regeneratePnpmLock(manifestPath, lockPath, updatedManifest, proje
471
499
  }));
472
500
  }
473
501
  finally {
502
+ unregisterProbeHome();
474
503
  await rm(probeHome, { recursive: true, force: true });
475
504
  }
476
505
  const installedVersion = versionOutput.trim();
@@ -484,6 +513,7 @@ async function regeneratePnpmLock(manifestPath, lockPath, updatedManifest, proje
484
513
  }
485
514
  }
486
515
  temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'upstreamfix-pnpm-lock-'));
516
+ unregisterCleanup = registerTemporaryCleanup(() => rm(temporaryDirectory, { recursive: true, force: true }));
487
517
  // pnpm reads packageManager and may download a different CLI automatically. The exact
488
518
  // version was checked above, so omit this metadata from the isolated temporary manifest.
489
519
  delete manifest.packageManager;
@@ -506,6 +536,7 @@ async function regeneratePnpmLock(manifestPath, lockPath, updatedManifest, proje
506
536
  return { reason: 'pnpm could not regenerate pnpm-lock.yaml in an isolated temporary directory; ensure the pinned pnpm version is installed and registry access is available.' };
507
537
  }
508
538
  finally {
539
+ unregisterCleanup?.();
509
540
  if (temporaryDirectory)
510
541
  await rm(temporaryDirectory, { recursive: true, force: true });
511
542
  }
@@ -521,7 +552,7 @@ async function regenerateYarnLock(manifestPath, lockPath, updatedManifest, proje
521
552
  let manifest;
522
553
  let before;
523
554
  try {
524
- manifest = JSON.parse(updatedManifest.replace(/^\uFEFF/, ''));
555
+ manifest = parseManifest(updatedManifest);
525
556
  before = await readFile(lockPath, 'utf8');
526
557
  }
527
558
  catch {
@@ -554,8 +585,10 @@ async function regenerateYarnLock(manifestPath, lockPath, updatedManifest, proje
554
585
  return { reason: 'Only an exact Yarn Classic 1.x packageManager pin is supported for automatic lockfile regeneration.' };
555
586
  }
556
587
  let temporaryDirectory;
588
+ let unregisterCleanup;
557
589
  try {
558
590
  const probeHome = await mkdtemp(path.join(tmpdir(), 'upstreamfix-yarn-home-'));
591
+ const unregisterProbeHome = registerTemporaryCleanup(() => rm(probeHome, { recursive: true, force: true }));
559
592
  let versionOutput;
560
593
  try {
561
594
  ({ stdout: versionOutput } = await execFile('yarn', ['--version'], {
@@ -566,6 +599,7 @@ async function regenerateYarnLock(manifestPath, lockPath, updatedManifest, proje
566
599
  }));
567
600
  }
568
601
  finally {
602
+ unregisterProbeHome();
569
603
  await rm(probeHome, { recursive: true, force: true });
570
604
  }
571
605
  const installedVersion = versionOutput.trim();
@@ -579,6 +613,7 @@ async function regenerateYarnLock(manifestPath, lockPath, updatedManifest, proje
579
613
  }
580
614
  }
581
615
  temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'upstreamfix-yarn-lock-'));
616
+ unregisterCleanup = registerTemporaryCleanup(() => rm(temporaryDirectory, { recursive: true, force: true }));
582
617
  delete manifest.packageManager;
583
618
  await writeFile(path.join(temporaryDirectory, 'package.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8');
584
619
  await writeFile(path.join(temporaryDirectory, 'yarn.lock'), before, 'utf8');
@@ -598,6 +633,7 @@ async function regenerateYarnLock(manifestPath, lockPath, updatedManifest, proje
598
633
  return { reason: 'Yarn Classic could not regenerate yarn.lock in an isolated temporary directory; ensure the pinned Yarn 1.x version is installed and registry access is available.' };
599
634
  }
600
635
  finally {
636
+ unregisterCleanup?.();
601
637
  if (temporaryDirectory)
602
638
  await rm(temporaryDirectory, { recursive: true, force: true });
603
639
  }
@@ -640,7 +676,7 @@ function findPackageManifests(root) {
640
676
  for (const entry of readdirSync(directory, { withFileTypes: true })) {
641
677
  const absolutePath = path.join(directory, entry.name);
642
678
  if (entry.isDirectory()) {
643
- if (!IGNORED_DIRS.has(entry.name))
679
+ if (!IGNORED_PROJECT_DIRECTORIES.has(entry.name))
644
680
  visit(absolutePath);
645
681
  }
646
682
  else if (entry.isFile() && entry.name === 'package.json') {
@@ -656,7 +692,7 @@ function addNodeEngineToManifest(raw) {
656
692
  if (raw[rootStart] !== '{')
657
693
  return undefined;
658
694
  const engineMember = findJsonObjectMember(raw, rootStart, 'engines');
659
- if (!engineMember && Object.hasOwn(JSON.parse(raw.replace(/^\uFEFF/, '')), 'engines'))
695
+ if (!engineMember && Object.hasOwn(parseManifest(raw), 'engines'))
660
696
  return undefined;
661
697
  const objectStart = engineMember?.valueStart ?? rootStart;
662
698
  if (raw[objectStart] !== '{')
@@ -676,7 +712,7 @@ function addNodeEngineToManifest(raw) {
676
712
  : `${hasMembers ? ', ' : ''}${engineMember ? '"node": ">=22"' : '"engines": { "node": ">=22" }'}`;
677
713
  const updated = raw.slice(0, insertAt) + addition + raw.slice(insertAt);
678
714
  try {
679
- const parsed = JSON.parse(updated.replace(/^\uFEFF/, ''));
715
+ const parsed = parseManifest(updated);
680
716
  return parsed.engines?.node === '>=22' ? updated : undefined;
681
717
  }
682
718
  catch {
@@ -816,11 +852,92 @@ function jsonValueEnd(text, start) {
816
852
  export function unifiedDiff(file, before, after) {
817
853
  if (before === after)
818
854
  return '';
855
+ if (LOCKFILE_NAMES.has(path.basename(file)))
856
+ return summarizeLockfile(file, before, after);
857
+ if (Buffer.byteLength(before) + Buffer.byteLength(after) > MAX_DIFF_INPUT_BYTES) {
858
+ return `${file}: diff withheld because the source exceeds ${MAX_DIFF_INPUT_BYTES} bytes.`;
859
+ }
819
860
  const patch = structuredPatch(`a/${file}`, `b/${file}`, before.replace(/\r\n/g, '\n'), after.replace(/\r\n/g, '\n'), '', '', { context: 3 });
820
861
  const output = [`--- a/${file}`, `+++ b/${file}`];
821
862
  for (const hunk of patch.hunks) {
822
863
  output.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`);
823
864
  output.push(...hunk.lines);
824
865
  }
825
- return output.join('\n');
866
+ const rendered = output.join('\n');
867
+ return rendered.length <= MAX_DIFF_OUTPUT_CHARS
868
+ ? rendered
869
+ : `${file}: diff withheld because it exceeds ${MAX_DIFF_OUTPUT_CHARS} characters.`;
870
+ }
871
+ export function summarizeLockfile(file, before, after) {
872
+ const previous = lockfilePackages(file, before);
873
+ const next = lockfilePackages(file, after);
874
+ if (!previous || !next)
875
+ return `${file}: lockfile changed; package counts unavailable (file too large or unrecognized format). Full diff withheld.`;
876
+ let added = 0;
877
+ let removed = 0;
878
+ let changed = 0;
879
+ for (const [name, version] of next) {
880
+ if (!previous.has(name))
881
+ added++;
882
+ else if (previous.get(name) !== version)
883
+ changed++;
884
+ }
885
+ for (const name of previous.keys())
886
+ if (!next.has(name))
887
+ removed++;
888
+ return `${file}: lockfile summary — added: ${added}, removed: ${removed}, changed: ${changed}. Full diff withheld.`;
889
+ }
890
+ function lockfilePackages(file, contents) {
891
+ if (Buffer.byteLength(contents) > 30 * 1024 * 1024)
892
+ return undefined;
893
+ if (file.endsWith('.json')) {
894
+ try {
895
+ const parsed = JSON.parse(contents);
896
+ if (!parsed.packages || typeof parsed.packages !== 'object')
897
+ return undefined;
898
+ return new Map(Object.entries(parsed.packages)
899
+ .filter(([name, value]) => name !== '' && value && typeof value.version === 'string')
900
+ .map(([name, value]) => [name, value.version]));
901
+ }
902
+ catch {
903
+ return undefined;
904
+ }
905
+ }
906
+ const packages = new Map();
907
+ let section = '';
908
+ let name = '';
909
+ for (const line of contents.split(/\r?\n/)) {
910
+ if (file.endsWith('pnpm-lock.yaml')) {
911
+ if (/^(packages|snapshots):\s*$/.test(line)) {
912
+ section = line.slice(0, -1);
913
+ name = '';
914
+ continue;
915
+ }
916
+ if (/^[^\s#]/.test(line)) {
917
+ section = '';
918
+ name = '';
919
+ }
920
+ const entry = section === 'packages' ? /^ (['"]?)(.+?)\1:\s*$/.exec(line) : null;
921
+ if (entry) {
922
+ const rawName = entry[2].replace(/^\//, '').replace(/\(.*$/, '');
923
+ const separator = rawName.lastIndexOf('@');
924
+ name = separator > 0 ? rawName.slice(0, separator) : rawName;
925
+ packages.set(name, separator > 0 ? rawName.slice(separator + 1).split('(')[0] : rawName);
926
+ }
927
+ const version = /^ version:\s*['"]?([^\s'"]+)/.exec(line);
928
+ if (name && version)
929
+ packages.set(name, version[1]);
930
+ }
931
+ else {
932
+ if (/^[^\s#].*:\s*$/.test(line)) {
933
+ const selector = line.slice(0, -1).split(',')[0].trim().replace(/^['"]|['"]$/g, '');
934
+ const separator = selector.lastIndexOf('@');
935
+ name = separator > 0 ? selector.slice(0, separator) : selector;
936
+ }
937
+ const version = /^ version\s+['"]([^'"]+)['"]/.exec(line);
938
+ if (name && version)
939
+ packages.set(name, version[1]);
940
+ }
941
+ }
942
+ return packages.size > 0 ? packages : undefined;
826
943
  }