upstreamfix 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +73 -2
- package/dist/cli.js +167 -0
- package/dist/core/diagnostic-diff.js +41 -0
- package/dist/core/process-cleanup.js +77 -0
- package/dist/core/report.js +131 -0
- package/dist/core/runner.js +826 -0
- package/dist/core/safety.js +7 -0
- package/dist/core/types.js +1 -0
- package/dist/core/verify.js +422 -0
- package/dist/core/write-branch.js +296 -0
- package/dist/migrations/firebase-admin-v14/index.js +597 -0
- package/dist/migrations/firebase-admin-v14/mapping.js +80 -0
- package/dist/migrations/index.js +46 -0
- package/dist/migrations/stripe-shared.js +393 -0
- package/dist/migrations/stripe-v22/index.js +47 -0
- package/dist/migrations/stripe-v23/index.js +55 -0
- package/package.json +49 -4
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { execFile as execFileCallback } from 'node:child_process';
|
|
3
|
+
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { structuredPatch } from 'diff';
|
|
8
|
+
import { Project } from 'ts-morph';
|
|
9
|
+
const execFile = promisify(execFileCallback);
|
|
10
|
+
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']);
|
|
12
|
+
export function loadProject(cwd, packageName = 'firebase-admin') {
|
|
13
|
+
const project = new Project({
|
|
14
|
+
skipAddingFilesFromTsConfig: true,
|
|
15
|
+
compilerOptions: { allowJs: true },
|
|
16
|
+
});
|
|
17
|
+
for (const sourcePath of findSourceFiles(cwd, packageName))
|
|
18
|
+
project.addSourceFileAtPath(sourcePath);
|
|
19
|
+
return project;
|
|
20
|
+
}
|
|
21
|
+
function loadScopedProject(root, migration) {
|
|
22
|
+
const project = new Project({
|
|
23
|
+
skipAddingFilesFromTsConfig: true,
|
|
24
|
+
compilerOptions: { allowJs: true },
|
|
25
|
+
});
|
|
26
|
+
const manifests = findPackageManifests(root).map((file) => {
|
|
27
|
+
let parsed;
|
|
28
|
+
let invalid = false;
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
invalid = true;
|
|
34
|
+
}
|
|
35
|
+
return { file, parsed, invalid };
|
|
36
|
+
});
|
|
37
|
+
const manual = [];
|
|
38
|
+
const manifestBySource = new Map();
|
|
39
|
+
for (const sourcePath of findSourceFiles(root, migration.packageName)) {
|
|
40
|
+
const containingManifests = manifests
|
|
41
|
+
.filter(({ file }) => {
|
|
42
|
+
const directory = path.dirname(file);
|
|
43
|
+
return sourcePath === directory || sourcePath.startsWith(directory + path.sep);
|
|
44
|
+
})
|
|
45
|
+
.sort((left, right) => right.file.length - left.file.length);
|
|
46
|
+
let declaration;
|
|
47
|
+
let invalidManifest;
|
|
48
|
+
for (const candidate of containingManifests) {
|
|
49
|
+
if (candidate.invalid) {
|
|
50
|
+
invalidManifest = { file: candidate.file, reason: 'is invalid JSON' };
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
const manifest = candidate.parsed;
|
|
54
|
+
if (!manifest)
|
|
55
|
+
continue;
|
|
56
|
+
const ranges = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
|
|
57
|
+
.map((section) => manifest[section]?.[migration.packageName])
|
|
58
|
+
.filter((value) => typeof value === 'string');
|
|
59
|
+
if (ranges.length > 0) {
|
|
60
|
+
if (new Set(ranges).size === 1)
|
|
61
|
+
declaration = { file: candidate.file, range: ranges[0] };
|
|
62
|
+
else
|
|
63
|
+
invalidManifest = { file: candidate.file, reason: 'declares conflicting dependency ranges' };
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (invalidManifest) {
|
|
68
|
+
manual.push({
|
|
69
|
+
file: sourcePath,
|
|
70
|
+
line: 1,
|
|
71
|
+
reason: `The ${migration.packageName} version could not be confirmed because ${path.relative(root, invalidManifest.file)} ${invalidManifest.reason}.`,
|
|
72
|
+
snippet: readFileSync(sourcePath, 'utf8').split(/\r?\n/, 1)[0] ?? '',
|
|
73
|
+
});
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (!declaration) {
|
|
77
|
+
manual.push({
|
|
78
|
+
file: sourcePath,
|
|
79
|
+
line: 1,
|
|
80
|
+
reason: `No containing package.json declares ${migration.packageName}; this file was not transformed because its SDK version is unknown.`,
|
|
81
|
+
snippet: readFileSync(sourcePath, 'utf8').split(/\r?\n/, 1)[0] ?? '',
|
|
82
|
+
});
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (!isSourceMajorRange(declaration.range, migration.fromMajor)) {
|
|
86
|
+
manual.push({
|
|
87
|
+
file: sourcePath,
|
|
88
|
+
line: 1,
|
|
89
|
+
reason: `${migration.packageName} is declared as \`${declaration.range}\` in ${path.relative(root, declaration.file)}; this migration only analyzes major v${migration.fromMajor} ranges.`,
|
|
90
|
+
snippet: readFileSync(sourcePath, 'utf8').split(/\r?\n/, 1)[0] ?? '',
|
|
91
|
+
});
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
project.addSourceFileAtPath(sourcePath);
|
|
95
|
+
manifestBySource.set(sourcePath, declaration.file);
|
|
96
|
+
}
|
|
97
|
+
return { project, manual, manifestBySource };
|
|
98
|
+
}
|
|
99
|
+
function isSourceMajorRange(range, major) {
|
|
100
|
+
return new RegExp(`^\\s*[~^]?${major}(?:\\.(?:\\d+|x|\\*)){0,2}\\s*$`, 'i').test(range);
|
|
101
|
+
}
|
|
102
|
+
function isRegistryVersionSpec(spec) {
|
|
103
|
+
// A deliberately narrow subset: registry dist-tags or ordinary semver ranges.
|
|
104
|
+
// Anything that can name a path, URL, Git host, workspace, alias, or patch is manual.
|
|
105
|
+
if (/^(?:latest|next|beta|canary|rc)$/i.test(spec))
|
|
106
|
+
return true;
|
|
107
|
+
return /^(?:\^|~|>=?|<=?|=)?\s*v?\d+(?:\.\d+|\.x|\.\*){0,2}(?:\s*(?:\|\||-\s*v?\d+(?:\.\d+|\.x|\.\*){0,2}|\s+(?:<|>|<=|>=)\s*v?\d+(?:\.\d+|\.x|\.\*){0,2}))*$/.test(spec.trim());
|
|
108
|
+
}
|
|
109
|
+
export function hasUnsafeDependencySpecs(manifest) {
|
|
110
|
+
const dependencies = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
|
|
111
|
+
.flatMap((section) => Object.values(manifest[section] ?? {}));
|
|
112
|
+
if (dependencies.some((value) => typeof value !== 'string' || !isRegistryVersionSpec(value)))
|
|
113
|
+
return true;
|
|
114
|
+
const candidate = manifest;
|
|
115
|
+
const nestedStrings = [];
|
|
116
|
+
const collect = (value) => {
|
|
117
|
+
if (typeof value === 'string')
|
|
118
|
+
nestedStrings.push(value);
|
|
119
|
+
else if (value && typeof value === 'object')
|
|
120
|
+
Object.values(value).forEach(collect);
|
|
121
|
+
};
|
|
122
|
+
collect(candidate.overrides);
|
|
123
|
+
collect(candidate.resolutions);
|
|
124
|
+
collect(candidate.pnpm?.overrides);
|
|
125
|
+
return nestedStrings.some((spec) => !isRegistryVersionSpec(spec));
|
|
126
|
+
}
|
|
127
|
+
export function lockfileCommandEnvironment(home) {
|
|
128
|
+
const allowed = new Set(['PATH', 'SYSTEMROOT', 'WINDIR', 'TMP', 'TEMP', 'TMPDIR', 'LANG', 'LC_ALL', 'TZ']);
|
|
129
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => allowed.has(key)));
|
|
130
|
+
return {
|
|
131
|
+
...env,
|
|
132
|
+
HOME: home,
|
|
133
|
+
USERPROFILE: home,
|
|
134
|
+
NPM_CONFIG_USERCONFIG: path.join(home, 'upstreamfix-empty-user.npmrc'),
|
|
135
|
+
NPM_CONFIG_GLOBALCONFIG: path.join(home, 'upstreamfix-empty-global.npmrc'),
|
|
136
|
+
NPM_CONFIG_CACHE: path.join(home, 'npm-cache'),
|
|
137
|
+
NPM_CONFIG_AUDIT: 'false',
|
|
138
|
+
NPM_CONFIG_FUND: 'false',
|
|
139
|
+
NPM_CONFIG_IGNORE_SCRIPTS: 'true',
|
|
140
|
+
COREPACK_ENABLE_PROJECT_SPEC: '0',
|
|
141
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
142
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function findSourceFiles(root, packageName) {
|
|
146
|
+
const found = [];
|
|
147
|
+
const visit = (directory) => {
|
|
148
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
149
|
+
const absolutePath = path.join(directory, entry.name);
|
|
150
|
+
if (entry.isDirectory()) {
|
|
151
|
+
if (!IGNORED_DIRS.has(entry.name))
|
|
152
|
+
visit(absolutePath);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (!entry.isFile() || entry.name.endsWith('.d.ts'))
|
|
156
|
+
continue;
|
|
157
|
+
if (!SOURCE_EXTENSIONS.has(path.extname(entry.name).toLowerCase()))
|
|
158
|
+
continue;
|
|
159
|
+
if (readFileSync(absolutePath, 'utf8').includes(packageName))
|
|
160
|
+
found.push(absolutePath);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
visit(root);
|
|
164
|
+
return found;
|
|
165
|
+
}
|
|
166
|
+
export async function runMigration(migration, options) {
|
|
167
|
+
if (!options.dryRun) {
|
|
168
|
+
throw new Error('Direct writes through runMigration are unavailable; use --write for verified Git branch commits.');
|
|
169
|
+
}
|
|
170
|
+
if (options.nodeRuntime && migration.id !== 'firebase-admin@14') {
|
|
171
|
+
throw new Error('--node-runtime 22 is supported only for firebase-admin@14.');
|
|
172
|
+
}
|
|
173
|
+
const scoped = loadScopedProject(options.cwd, migration);
|
|
174
|
+
const project = scoped.project;
|
|
175
|
+
const originalSources = new Map(project.getSourceFiles().map((file) => [file.getFilePath(), file.getFullText()]));
|
|
176
|
+
const result = migration.transform(project);
|
|
177
|
+
result.manual.unshift(...scoped.manual);
|
|
178
|
+
const compatibilityNotes = migration.inspectManifest
|
|
179
|
+
? await inspectManifests(migration, options.cwd)
|
|
180
|
+
: [];
|
|
181
|
+
if (!options.dryRun && result.manual.length > 0) {
|
|
182
|
+
throw new Error('This migration has manual review items; no files were written. Resolve them and preview again.');
|
|
183
|
+
}
|
|
184
|
+
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
|
|
186
|
+
? await migrateManifests(migration, options, manifestsWithChangedCode)
|
|
187
|
+
: {
|
|
188
|
+
notes: [result.manual.length > 0
|
|
189
|
+
? 'Dependency versions were left unchanged because this migration has items requiring manual review.'
|
|
190
|
+
: 'Dependency versions were left unchanged because no supported code changes were found.'],
|
|
191
|
+
changes: [],
|
|
192
|
+
manual: [],
|
|
193
|
+
};
|
|
194
|
+
if (!options.dryRun && manifest.manual.length > 0) {
|
|
195
|
+
throw new Error('A dependency manifest needs manual review; no files were written. Resolve it and preview again.');
|
|
196
|
+
}
|
|
197
|
+
const sourceUpdates = new Map((result.fileUpdates ?? []).map((update) => [update.file, update.contents]));
|
|
198
|
+
const proposedFiles = [];
|
|
199
|
+
const proposedDiff = result.filesChanged.flatMap((filePath) => {
|
|
200
|
+
const before = originalSources.get(filePath);
|
|
201
|
+
const after = sourceUpdates.get(filePath);
|
|
202
|
+
if (before === undefined || after === undefined)
|
|
203
|
+
return [];
|
|
204
|
+
const relativePath = path.relative(options.cwd, filePath);
|
|
205
|
+
proposedFiles.push({ relativePath, before, after });
|
|
206
|
+
const diff = unifiedDiff(relativePath, before, after);
|
|
207
|
+
return diff ? [diff] : [];
|
|
208
|
+
});
|
|
209
|
+
for (const change of manifest.changes) {
|
|
210
|
+
proposedFiles.push({ relativePath: change.relativePath, before: change.before, after: change.after });
|
|
211
|
+
const diff = unifiedDiff(change.relativePath, change.before, change.after);
|
|
212
|
+
if (diff)
|
|
213
|
+
proposedDiff.push(diff);
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
...result,
|
|
217
|
+
filesChanged: [
|
|
218
|
+
...result.filesChanged.map((file) => path.relative(options.cwd, file)),
|
|
219
|
+
...manifest.changes.map((change) => change.relativePath),
|
|
220
|
+
],
|
|
221
|
+
changes: result.changes.map((c) => ({ ...c, file: path.relative(options.cwd, c.file) })),
|
|
222
|
+
manual: [...result.manual, ...manifest.manual].map((m) => ({ ...m, file: path.relative(options.cwd, m.file) })),
|
|
223
|
+
manifestNotes: [...new Set([...compatibilityNotes, ...manifest.notes])],
|
|
224
|
+
proposedDiff,
|
|
225
|
+
proposedFiles,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
async function inspectManifests(migration, cwd) {
|
|
229
|
+
const notes = [];
|
|
230
|
+
for (const manifestPath of findPackageManifests(cwd)) {
|
|
231
|
+
const raw = await readFile(manifestPath, 'utf8');
|
|
232
|
+
if (!raw.includes(JSON.stringify(migration.packageName)))
|
|
233
|
+
continue;
|
|
234
|
+
try {
|
|
235
|
+
const manifest = JSON.parse(raw.replace(/^\uFEFF/, ''));
|
|
236
|
+
notes.push(...(migration.inspectManifest?.(manifest) ?? []));
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// Invalid manifests are already surfaced as manual review items for source files.
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return [...new Set(notes)];
|
|
243
|
+
}
|
|
244
|
+
async function migrateManifests(migration, options, manifestsWithChangedCode) {
|
|
245
|
+
if (!migration.updateManifest)
|
|
246
|
+
return { notes: [], changes: [], manual: [] };
|
|
247
|
+
const notes = [];
|
|
248
|
+
const changes = [];
|
|
249
|
+
const manual = [];
|
|
250
|
+
for (const manifestPath of findPackageManifests(options.cwd)) {
|
|
251
|
+
const raw = await readFile(manifestPath, 'utf8');
|
|
252
|
+
if (!raw.includes(JSON.stringify(migration.packageName)))
|
|
253
|
+
continue;
|
|
254
|
+
const relativePath = path.relative(options.cwd, manifestPath);
|
|
255
|
+
if (!manifestsWithChangedCode.has(manifestPath)) {
|
|
256
|
+
notes.push(`\`${relativePath}\` was left unchanged because no analyzed source file owned by this package received a code transformation.`);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
let manifest;
|
|
260
|
+
try {
|
|
261
|
+
manifest = JSON.parse(raw.replace(/^\uFEFF/, ''));
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
manual.push({
|
|
265
|
+
file: relativePath,
|
|
266
|
+
line: 1,
|
|
267
|
+
reason: 'This package.json is invalid JSON; its dependency version was left unchanged.',
|
|
268
|
+
snippet: raw.split(/\r?\n/, 1)[0] ?? '',
|
|
269
|
+
});
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const before = structuredClone(manifest);
|
|
273
|
+
if (options.nodeRuntime === 22) {
|
|
274
|
+
if (manifest.engines !== undefined && (typeof manifest.engines !== 'object' || Array.isArray(manifest.engines) || manifest.engines === null)) {
|
|
275
|
+
manual.push({
|
|
276
|
+
file: relativePath,
|
|
277
|
+
line: 1,
|
|
278
|
+
reason: 'engines must be an object in package.json before --node-runtime 22 can add a Node.js range.',
|
|
279
|
+
snippet: 'engines',
|
|
280
|
+
});
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const declared = manifest.engines?.node;
|
|
284
|
+
if (declared !== undefined && (typeof declared !== 'string' || !/^(?:>=\s*22(?:\.\d+){0,2}|(?:\^|~)\s*22(?:\.\d+){0,2}|22(?:\.\d+){0,2})$/.test(declared.trim()))) {
|
|
285
|
+
manual.push({
|
|
286
|
+
file: relativePath,
|
|
287
|
+
line: 1,
|
|
288
|
+
reason: `engines.node is already declared as ${JSON.stringify(declared)}. Review and update it manually for Node.js 22 before migrating firebase-admin v14; --node-runtime never overrides a declared range.`,
|
|
289
|
+
snippet: `engines.node: ${declared}`,
|
|
290
|
+
});
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (declared === undefined)
|
|
294
|
+
manifest.engines = { ...manifest.engines, node: '>=22' };
|
|
295
|
+
}
|
|
296
|
+
const fileNotes = migration.updateManifest(manifest);
|
|
297
|
+
if (options.nodeRuntime === 22 && before.engines?.node === undefined) {
|
|
298
|
+
fileNotes.unshift(`\`${relativePath}\`: added \`engines.node\` = \`>=22\` after explicit --node-runtime 22 confirmation.`);
|
|
299
|
+
}
|
|
300
|
+
if (JSON.stringify(manifest) === JSON.stringify(before)) {
|
|
301
|
+
notes.push(...fileNotes);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const versionUpdated = preserveManifestFormatting(raw, before, manifest, migration.packageName);
|
|
305
|
+
const updated = versionUpdated && options.nodeRuntime === 22 && before.engines?.node === undefined
|
|
306
|
+
? addNodeEngineToManifest(versionUpdated)
|
|
307
|
+
: versionUpdated;
|
|
308
|
+
if (updated === undefined) {
|
|
309
|
+
notes.push(...fileNotes.filter((note) => !note.includes(' → ')));
|
|
310
|
+
manual.push({
|
|
311
|
+
file: relativePath,
|
|
312
|
+
line: 1,
|
|
313
|
+
reason: 'The dependency version could not be updated without rewriting or ambiguously editing package.json; update it manually.',
|
|
314
|
+
snippet: `${migration.packageName} dependency update withheld`,
|
|
315
|
+
});
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const existingLocks = await findLockfiles(options.cwd, path.dirname(manifestPath));
|
|
319
|
+
let lockChange;
|
|
320
|
+
if (existingLocks.length > 0) {
|
|
321
|
+
const lockResult = existingLocks.length === 1 && path.basename(existingLocks[0]) === 'package-lock.json'
|
|
322
|
+
? await regeneratePackageLock(manifestPath, path.resolve(options.cwd, existingLocks[0]), updated, options.cwd)
|
|
323
|
+
: existingLocks.length === 1 && path.basename(existingLocks[0]) === 'pnpm-lock.yaml'
|
|
324
|
+
? await regeneratePnpmLock(manifestPath, path.resolve(options.cwd, existingLocks[0]), updated, options.cwd)
|
|
325
|
+
: existingLocks.length === 1 && path.basename(existingLocks[0]) === 'yarn.lock'
|
|
326
|
+
? await regenerateYarnLock(manifestPath, path.resolve(options.cwd, existingLocks[0]), updated, options.cwd)
|
|
327
|
+
: { reason: `This package uses unsupported lockfile(s): ${existingLocks.join(', ')}.` };
|
|
328
|
+
if (!lockResult.contents) {
|
|
329
|
+
notes.push(...fileNotes.filter((note) => !note.includes(' → ')));
|
|
330
|
+
manual.push({
|
|
331
|
+
file: relativePath,
|
|
332
|
+
line: 1,
|
|
333
|
+
reason: `${lockResult.reason} The dependency and lockfile were left unchanged.`,
|
|
334
|
+
snippet: `${migration.packageName} dependency update withheld`,
|
|
335
|
+
});
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const lockPath = path.resolve(options.cwd, existingLocks[0]);
|
|
339
|
+
lockChange = {
|
|
340
|
+
absolutePath: lockPath,
|
|
341
|
+
relativePath: path.relative(options.cwd, lockPath),
|
|
342
|
+
before: lockResult.before ?? '',
|
|
343
|
+
after: lockResult.contents,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
notes.push(...fileNotes);
|
|
347
|
+
if (updated !== raw)
|
|
348
|
+
changes.push({ absolutePath: manifestPath, relativePath, before: raw, after: updated });
|
|
349
|
+
if (lockChange && lockChange.after !== lockChange.before) {
|
|
350
|
+
changes.push(lockChange);
|
|
351
|
+
const managerName = path.basename(lockChange.relativePath) === 'pnpm-lock.yaml'
|
|
352
|
+
? 'pnpm'
|
|
353
|
+
: path.basename(lockChange.relativePath) === 'yarn.lock'
|
|
354
|
+
? 'Yarn Classic'
|
|
355
|
+
: 'npm';
|
|
356
|
+
notes.push(`\`${lockChange.relativePath}\` was regenerated by ${managerName} in a temporary directory; preview requires registry access and install scripts were disabled.`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (manual.length > 0)
|
|
360
|
+
changes.length = 0;
|
|
361
|
+
if (changes.length === 0 && notes.length === 0 && manual.length === 0) {
|
|
362
|
+
notes.push(`No package.json containing ${migration.packageName} was found.`);
|
|
363
|
+
}
|
|
364
|
+
return { notes, changes, manual };
|
|
365
|
+
}
|
|
366
|
+
async function regeneratePackageLock(manifestPath, lockPath, updatedManifest, projectRoot) {
|
|
367
|
+
const directory = path.dirname(manifestPath);
|
|
368
|
+
if (directory !== path.dirname(lockPath)) {
|
|
369
|
+
return { reason: 'The package-lock.json is not beside the package.json; workspace lockfile updates are not supported yet.' };
|
|
370
|
+
}
|
|
371
|
+
if (directory !== projectRoot) {
|
|
372
|
+
return { reason: 'Nested package manifests require workspace-aware lockfile regeneration, which is not supported yet.' };
|
|
373
|
+
}
|
|
374
|
+
let manifest;
|
|
375
|
+
let before;
|
|
376
|
+
let lock;
|
|
377
|
+
try {
|
|
378
|
+
manifest = JSON.parse(updatedManifest.replace(/^\uFEFF/, ''));
|
|
379
|
+
before = await readFile(lockPath, 'utf8');
|
|
380
|
+
lock = JSON.parse(before);
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
return { reason: 'The package.json or package-lock.json could not be parsed for a safe lockfile update.' };
|
|
384
|
+
}
|
|
385
|
+
if (lock.lockfileVersion !== 3) {
|
|
386
|
+
return { reason: 'Only npm package-lock.json version 3 can currently be regenerated automatically.' };
|
|
387
|
+
}
|
|
388
|
+
if ('workspaces' in manifest) {
|
|
389
|
+
return { reason: 'npm workspaces are not supported by the isolated lockfile updater yet.' };
|
|
390
|
+
}
|
|
391
|
+
if (hasUnsafeDependencySpecs(manifest)) {
|
|
392
|
+
return { reason: 'Only ordinary npm registry versions and approved dist-tags are supported; Git, URL, local, aliased, override, resolution, and non-registry specs require manual lockfile updates.' };
|
|
393
|
+
}
|
|
394
|
+
let configDirectory = directory;
|
|
395
|
+
while (configDirectory === projectRoot || configDirectory.startsWith(projectRoot + path.sep)) {
|
|
396
|
+
if (await exists(path.join(configDirectory, '.npmrc'))) {
|
|
397
|
+
return { reason: 'A project .npmrc may contain registry or resolution settings that the isolated updater cannot safely reproduce.' };
|
|
398
|
+
}
|
|
399
|
+
if (configDirectory === projectRoot)
|
|
400
|
+
break;
|
|
401
|
+
configDirectory = path.dirname(configDirectory);
|
|
402
|
+
}
|
|
403
|
+
let temporaryDirectory;
|
|
404
|
+
try {
|
|
405
|
+
temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'upstreamfix-npm-lock-'));
|
|
406
|
+
await writeFile(path.join(temporaryDirectory, 'package.json'), updatedManifest, 'utf8');
|
|
407
|
+
await writeFile(path.join(temporaryDirectory, 'package-lock.json'), before, 'utf8');
|
|
408
|
+
const args = ['install', '--package-lock-only', '--lockfile-version=3', '--ignore-scripts', '--no-audit', '--no-fund'];
|
|
409
|
+
await execFile('npm', args, { cwd: temporaryDirectory, env: lockfileCommandEnvironment(temporaryDirectory), timeout: 120_000, maxBuffer: 4 * 1024 * 1024 });
|
|
410
|
+
const contents = await readFile(path.join(temporaryDirectory, 'package-lock.json'), 'utf8');
|
|
411
|
+
return { before, contents };
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
return { reason: 'npm could not regenerate package-lock.json in an isolated temporary directory.' };
|
|
415
|
+
}
|
|
416
|
+
finally {
|
|
417
|
+
if (temporaryDirectory)
|
|
418
|
+
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
async function regeneratePnpmLock(manifestPath, lockPath, updatedManifest, projectRoot) {
|
|
422
|
+
const directory = path.dirname(manifestPath);
|
|
423
|
+
if (directory !== path.dirname(lockPath)) {
|
|
424
|
+
return { reason: 'The pnpm-lock.yaml is not beside the package.json; workspace lockfile updates are not supported yet.' };
|
|
425
|
+
}
|
|
426
|
+
if (directory !== projectRoot) {
|
|
427
|
+
return { reason: 'Nested package manifests require workspace-aware lockfile regeneration, which is not supported yet.' };
|
|
428
|
+
}
|
|
429
|
+
let manifest;
|
|
430
|
+
let before;
|
|
431
|
+
try {
|
|
432
|
+
manifest = JSON.parse(updatedManifest.replace(/^\uFEFF/, ''));
|
|
433
|
+
before = await readFile(lockPath, 'utf8');
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
return { reason: 'The package.json or pnpm-lock.yaml could not be read for a safe lockfile update.' };
|
|
437
|
+
}
|
|
438
|
+
const lockVersion = /^lockfileVersion:\s*["']?([\d]+(?:\.[\d]+)?)/m.exec(before)?.[1];
|
|
439
|
+
if (!lockVersion)
|
|
440
|
+
return { reason: 'The pnpm lockfile version could not be identified safely.' };
|
|
441
|
+
if ('workspaces' in manifest)
|
|
442
|
+
return { reason: 'pnpm workspaces are not supported by the isolated lockfile updater yet.' };
|
|
443
|
+
if (hasUnsafeDependencySpecs(manifest)) {
|
|
444
|
+
return { reason: 'Only ordinary npm registry versions and approved dist-tags are supported; Git, URL, local, aliased, override, resolution, and non-registry specs require manual lockfile updates.' };
|
|
445
|
+
}
|
|
446
|
+
let configDirectory = directory;
|
|
447
|
+
while (configDirectory === projectRoot || configDirectory.startsWith(projectRoot + path.sep)) {
|
|
448
|
+
for (const configName of ['.npmrc', '.pnpmfile.cjs', '.pnpmfile.mjs', 'pnpm-workspace.yaml']) {
|
|
449
|
+
if (await exists(path.join(configDirectory, configName))) {
|
|
450
|
+
return { reason: `A project ${configName} may contain registry, resolution, hook, or workspace settings that the isolated updater cannot safely reproduce.` };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (configDirectory === projectRoot)
|
|
454
|
+
break;
|
|
455
|
+
configDirectory = path.dirname(configDirectory);
|
|
456
|
+
}
|
|
457
|
+
const declaredManager = manifest.packageManager;
|
|
458
|
+
if (declaredManager !== undefined && (typeof declaredManager !== 'string' || !/^pnpm@\d+\.\d+\.\d+(?:[-+].*)?$/.test(declaredManager))) {
|
|
459
|
+
return { reason: 'The packageManager field does not pin an exact pnpm version, so lockfile regeneration cannot be version-checked safely.' };
|
|
460
|
+
}
|
|
461
|
+
let temporaryDirectory;
|
|
462
|
+
try {
|
|
463
|
+
const probeHome = await mkdtemp(path.join(tmpdir(), 'upstreamfix-pnpm-home-'));
|
|
464
|
+
let versionOutput;
|
|
465
|
+
try {
|
|
466
|
+
({ stdout: versionOutput } = await execFile('pnpm', ['--version'], {
|
|
467
|
+
cwd: tmpdir(),
|
|
468
|
+
env: lockfileCommandEnvironment(probeHome),
|
|
469
|
+
timeout: 15_000,
|
|
470
|
+
maxBuffer: 1024 * 1024,
|
|
471
|
+
}));
|
|
472
|
+
}
|
|
473
|
+
finally {
|
|
474
|
+
await rm(probeHome, { recursive: true, force: true });
|
|
475
|
+
}
|
|
476
|
+
const installedVersion = versionOutput.trim();
|
|
477
|
+
if (!/^\d+\.\d+\.\d+(?:[-+].*)?$/.test(installedVersion)) {
|
|
478
|
+
return { reason: 'The installed pnpm version could not be identified safely.' };
|
|
479
|
+
}
|
|
480
|
+
if (typeof declaredManager === 'string') {
|
|
481
|
+
const pinnedVersion = /^pnpm@([^+]+)(?:\+.*)?$/.exec(declaredManager)?.[1];
|
|
482
|
+
if (pinnedVersion !== installedVersion) {
|
|
483
|
+
return { reason: `This project pins ${declaredManager}, but the installed package manager is pnpm@${installedVersion}; lockfile regeneration was withheld.` };
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'upstreamfix-pnpm-lock-'));
|
|
487
|
+
// pnpm reads packageManager and may download a different CLI automatically. The exact
|
|
488
|
+
// version was checked above, so omit this metadata from the isolated temporary manifest.
|
|
489
|
+
delete manifest.packageManager;
|
|
490
|
+
await writeFile(path.join(temporaryDirectory, 'package.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
491
|
+
await writeFile(path.join(temporaryDirectory, 'pnpm-lock.yaml'), before, 'utf8');
|
|
492
|
+
await execFile('pnpm', ['install', '--lockfile-only', '--ignore-scripts', '--no-frozen-lockfile'], {
|
|
493
|
+
cwd: temporaryDirectory,
|
|
494
|
+
env: lockfileCommandEnvironment(temporaryDirectory),
|
|
495
|
+
timeout: 120_000,
|
|
496
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
497
|
+
});
|
|
498
|
+
const contents = await readFile(path.join(temporaryDirectory, 'pnpm-lock.yaml'), 'utf8');
|
|
499
|
+
const generatedVersion = /^lockfileVersion:\s*["']?([\d]+(?:\.[\d]+)?)/m.exec(contents)?.[1];
|
|
500
|
+
if (generatedVersion !== lockVersion) {
|
|
501
|
+
return { reason: `The installed pnpm would change the lockfile format from ${lockVersion} to ${generatedVersion ?? 'unknown'}; update it manually with the project's pinned package manager.` };
|
|
502
|
+
}
|
|
503
|
+
return { before, contents };
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
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
|
+
}
|
|
508
|
+
finally {
|
|
509
|
+
if (temporaryDirectory)
|
|
510
|
+
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
async function regenerateYarnLock(manifestPath, lockPath, updatedManifest, projectRoot) {
|
|
514
|
+
const directory = path.dirname(manifestPath);
|
|
515
|
+
if (directory !== path.dirname(lockPath)) {
|
|
516
|
+
return { reason: 'The yarn.lock is not beside the package.json; workspace lockfile updates are not supported yet.' };
|
|
517
|
+
}
|
|
518
|
+
if (directory !== projectRoot) {
|
|
519
|
+
return { reason: 'Nested package manifests require workspace-aware lockfile regeneration, which is not supported yet.' };
|
|
520
|
+
}
|
|
521
|
+
let manifest;
|
|
522
|
+
let before;
|
|
523
|
+
try {
|
|
524
|
+
manifest = JSON.parse(updatedManifest.replace(/^\uFEFF/, ''));
|
|
525
|
+
before = await readFile(lockPath, 'utf8');
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
return { reason: 'The package.json or yarn.lock could not be read for a safe lockfile update.' };
|
|
529
|
+
}
|
|
530
|
+
if ('workspaces' in manifest)
|
|
531
|
+
return { reason: 'Yarn workspaces are not supported by the isolated lockfile updater yet.' };
|
|
532
|
+
if (!before.startsWith('# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.')) {
|
|
533
|
+
return { reason: 'Only Yarn Classic lockfile format can currently be regenerated automatically.' };
|
|
534
|
+
}
|
|
535
|
+
if (hasUnsafeDependencySpecs(manifest)) {
|
|
536
|
+
return { reason: 'Only ordinary npm registry versions and approved dist-tags are supported; Git, URL, local, aliased, override, resolution, and non-registry specs require manual lockfile updates.' };
|
|
537
|
+
}
|
|
538
|
+
let configDirectory = directory;
|
|
539
|
+
while (configDirectory === projectRoot || configDirectory.startsWith(projectRoot + path.sep)) {
|
|
540
|
+
for (const configName of ['.npmrc', '.yarnrc', '.yarnrc.yml', 'yarn.config.cjs']) {
|
|
541
|
+
if (await exists(path.join(configDirectory, configName))) {
|
|
542
|
+
return { reason: `A project ${configName} may contain registry, resolution, hook, or workspace settings that the isolated updater cannot safely reproduce.` };
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (await exists(path.join(configDirectory, '.yarn'))) {
|
|
546
|
+
return { reason: 'A project .yarn directory may contain Yarn Berry releases, plugins, patches, or configuration that cannot be safely reproduced in isolation.' };
|
|
547
|
+
}
|
|
548
|
+
if (configDirectory === projectRoot)
|
|
549
|
+
break;
|
|
550
|
+
configDirectory = path.dirname(configDirectory);
|
|
551
|
+
}
|
|
552
|
+
const declaredManager = manifest.packageManager;
|
|
553
|
+
if (declaredManager !== undefined && (typeof declaredManager !== 'string' || !/^yarn@1\.\d+\.\d+(?:[-+].*)?$/.test(declaredManager))) {
|
|
554
|
+
return { reason: 'Only an exact Yarn Classic 1.x packageManager pin is supported for automatic lockfile regeneration.' };
|
|
555
|
+
}
|
|
556
|
+
let temporaryDirectory;
|
|
557
|
+
try {
|
|
558
|
+
const probeHome = await mkdtemp(path.join(tmpdir(), 'upstreamfix-yarn-home-'));
|
|
559
|
+
let versionOutput;
|
|
560
|
+
try {
|
|
561
|
+
({ stdout: versionOutput } = await execFile('yarn', ['--version'], {
|
|
562
|
+
cwd: tmpdir(),
|
|
563
|
+
env: lockfileCommandEnvironment(probeHome),
|
|
564
|
+
timeout: 15_000,
|
|
565
|
+
maxBuffer: 1024 * 1024,
|
|
566
|
+
}));
|
|
567
|
+
}
|
|
568
|
+
finally {
|
|
569
|
+
await rm(probeHome, { recursive: true, force: true });
|
|
570
|
+
}
|
|
571
|
+
const installedVersion = versionOutput.trim();
|
|
572
|
+
if (!/^1\.\d+\.\d+$/.test(installedVersion)) {
|
|
573
|
+
return { reason: `Yarn ${installedVersion || '(unknown version)'} is not Yarn Classic 1.x; this version's lockfile updater is not supported yet.` };
|
|
574
|
+
}
|
|
575
|
+
if (typeof declaredManager === 'string') {
|
|
576
|
+
const pinnedVersion = /^yarn@([^+]+)(?:\+.*)?$/.exec(declaredManager)?.[1];
|
|
577
|
+
if (pinnedVersion !== installedVersion) {
|
|
578
|
+
return { reason: `This project pins ${declaredManager}, but the installed package manager is yarn@${installedVersion}; lockfile regeneration was withheld.` };
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'upstreamfix-yarn-lock-'));
|
|
582
|
+
delete manifest.packageManager;
|
|
583
|
+
await writeFile(path.join(temporaryDirectory, 'package.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
584
|
+
await writeFile(path.join(temporaryDirectory, 'yarn.lock'), before, 'utf8');
|
|
585
|
+
await execFile('yarn', ['install', '--ignore-scripts', '--non-interactive', '--no-progress'], {
|
|
586
|
+
cwd: temporaryDirectory,
|
|
587
|
+
env: lockfileCommandEnvironment(temporaryDirectory),
|
|
588
|
+
timeout: 120_000,
|
|
589
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
590
|
+
});
|
|
591
|
+
const contents = await readFile(path.join(temporaryDirectory, 'yarn.lock'), 'utf8');
|
|
592
|
+
if (!contents.startsWith('# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.')) {
|
|
593
|
+
return { reason: 'Yarn generated an unrecognized lockfile format; the lockfile update was withheld.' };
|
|
594
|
+
}
|
|
595
|
+
return { before, contents };
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
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
|
+
}
|
|
600
|
+
finally {
|
|
601
|
+
if (temporaryDirectory)
|
|
602
|
+
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
async function exists(file) {
|
|
606
|
+
try {
|
|
607
|
+
await stat(file);
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
610
|
+
catch (error) {
|
|
611
|
+
if (error.code === 'ENOENT')
|
|
612
|
+
return false;
|
|
613
|
+
throw error;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
async function findLockfiles(root, packageDirectory) {
|
|
617
|
+
const names = ['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock'];
|
|
618
|
+
const found = [];
|
|
619
|
+
let directory = packageDirectory;
|
|
620
|
+
while (directory === root || directory.startsWith(root + path.sep)) {
|
|
621
|
+
for (const name of names) {
|
|
622
|
+
try {
|
|
623
|
+
await stat(path.join(directory, name));
|
|
624
|
+
found.push(path.relative(root, path.join(directory, name)));
|
|
625
|
+
}
|
|
626
|
+
catch (error) {
|
|
627
|
+
if (error.code !== 'ENOENT')
|
|
628
|
+
throw error;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (directory === root)
|
|
632
|
+
break;
|
|
633
|
+
directory = path.dirname(directory);
|
|
634
|
+
}
|
|
635
|
+
return found;
|
|
636
|
+
}
|
|
637
|
+
function findPackageManifests(root) {
|
|
638
|
+
const found = [];
|
|
639
|
+
const visit = (directory) => {
|
|
640
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
641
|
+
const absolutePath = path.join(directory, entry.name);
|
|
642
|
+
if (entry.isDirectory()) {
|
|
643
|
+
if (!IGNORED_DIRS.has(entry.name))
|
|
644
|
+
visit(absolutePath);
|
|
645
|
+
}
|
|
646
|
+
else if (entry.isFile() && entry.name === 'package.json') {
|
|
647
|
+
found.push(absolutePath);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
visit(root);
|
|
652
|
+
return found;
|
|
653
|
+
}
|
|
654
|
+
function addNodeEngineToManifest(raw) {
|
|
655
|
+
const rootStart = raw.charCodeAt(0) === 0xfeff ? 1 : 0;
|
|
656
|
+
if (raw[rootStart] !== '{')
|
|
657
|
+
return undefined;
|
|
658
|
+
const engineMember = findJsonObjectMember(raw, rootStart, 'engines');
|
|
659
|
+
if (!engineMember && Object.hasOwn(JSON.parse(raw.replace(/^\uFEFF/, '')), 'engines'))
|
|
660
|
+
return undefined;
|
|
661
|
+
const objectStart = engineMember?.valueStart ?? rootStart;
|
|
662
|
+
if (raw[objectStart] !== '{')
|
|
663
|
+
return undefined;
|
|
664
|
+
const objectEnd = jsonValueEnd(raw, objectStart);
|
|
665
|
+
if (objectEnd < 0)
|
|
666
|
+
return undefined;
|
|
667
|
+
const close = objectEnd - 1;
|
|
668
|
+
const whitespace = raw.slice(objectStart + 1, close).match(/\s*$/)?.[0] ?? '';
|
|
669
|
+
const insertAt = close - whitespace.length;
|
|
670
|
+
const hasMembers = raw.slice(objectStart + 1, insertAt).trim().length > 0;
|
|
671
|
+
const lineEnding = raw.includes('\r\n') ? '\r\n' : '\n';
|
|
672
|
+
const indent = raw.match(/(?:\r?\n)([ \t]+)"[^"\r\n]+"\s*:/)?.[1] ?? ' ';
|
|
673
|
+
const childIndent = engineMember ? indent + indent : indent;
|
|
674
|
+
const addition = raw.includes('\n')
|
|
675
|
+
? `${hasMembers ? ',' : ''}${lineEnding}${childIndent}${engineMember ? '"node": ">=22"' : '"engines": { "node": ">=22" }'}`
|
|
676
|
+
: `${hasMembers ? ', ' : ''}${engineMember ? '"node": ">=22"' : '"engines": { "node": ">=22" }'}`;
|
|
677
|
+
const updated = raw.slice(0, insertAt) + addition + raw.slice(insertAt);
|
|
678
|
+
try {
|
|
679
|
+
const parsed = JSON.parse(updated.replace(/^\uFEFF/, ''));
|
|
680
|
+
return parsed.engines?.node === '>=22' ? updated : undefined;
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
return undefined;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
function preserveManifestFormatting(raw, before, after, packageName) {
|
|
687
|
+
let updated = raw;
|
|
688
|
+
for (const section of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
|
|
689
|
+
const oldValue = before[section]?.[packageName];
|
|
690
|
+
const newValue = after[section]?.[packageName];
|
|
691
|
+
if (typeof oldValue !== 'string' || typeof newValue !== 'string' || oldValue === newValue)
|
|
692
|
+
continue;
|
|
693
|
+
const rootStart = updated.charCodeAt(0) === 0xfeff ? 1 : 0;
|
|
694
|
+
const sectionMember = findJsonObjectMember(updated, rootStart, section);
|
|
695
|
+
if (!sectionMember || updated[sectionMember.valueStart] !== '{')
|
|
696
|
+
return undefined;
|
|
697
|
+
const dependencyMember = findJsonObjectMember(updated, sectionMember.valueStart, packageName);
|
|
698
|
+
if (!dependencyMember || updated[dependencyMember.valueStart] !== '"')
|
|
699
|
+
return undefined;
|
|
700
|
+
let decoded;
|
|
701
|
+
try {
|
|
702
|
+
decoded = JSON.parse(updated.slice(dependencyMember.valueStart, dependencyMember.valueEnd));
|
|
703
|
+
}
|
|
704
|
+
catch {
|
|
705
|
+
return undefined;
|
|
706
|
+
}
|
|
707
|
+
if (decoded !== oldValue)
|
|
708
|
+
return undefined;
|
|
709
|
+
updated =
|
|
710
|
+
updated.slice(0, dependencyMember.valueStart) +
|
|
711
|
+
JSON.stringify(newValue) +
|
|
712
|
+
updated.slice(dependencyMember.valueEnd);
|
|
713
|
+
}
|
|
714
|
+
return updated;
|
|
715
|
+
}
|
|
716
|
+
function findJsonObjectMember(text, objectStart, targetKey) {
|
|
717
|
+
if (text[objectStart] !== '{')
|
|
718
|
+
return undefined;
|
|
719
|
+
let cursor = objectStart + 1;
|
|
720
|
+
let found;
|
|
721
|
+
while (cursor < text.length) {
|
|
722
|
+
cursor = skipWhitespace(text, cursor);
|
|
723
|
+
if (text[cursor] === '}')
|
|
724
|
+
return found;
|
|
725
|
+
if (text[cursor] !== '"')
|
|
726
|
+
return undefined;
|
|
727
|
+
const keyEnd = jsonStringEnd(text, cursor);
|
|
728
|
+
if (keyEnd < 0)
|
|
729
|
+
return undefined;
|
|
730
|
+
let key;
|
|
731
|
+
try {
|
|
732
|
+
key = JSON.parse(text.slice(cursor, keyEnd));
|
|
733
|
+
}
|
|
734
|
+
catch {
|
|
735
|
+
return undefined;
|
|
736
|
+
}
|
|
737
|
+
cursor = skipWhitespace(text, keyEnd);
|
|
738
|
+
if (text[cursor] !== ':')
|
|
739
|
+
return undefined;
|
|
740
|
+
const valueStart = skipWhitespace(text, cursor + 1);
|
|
741
|
+
const valueEnd = jsonValueEnd(text, valueStart);
|
|
742
|
+
if (valueEnd < 0)
|
|
743
|
+
return undefined;
|
|
744
|
+
if (key === targetKey) {
|
|
745
|
+
if (found)
|
|
746
|
+
return undefined;
|
|
747
|
+
found = { valueStart, valueEnd };
|
|
748
|
+
}
|
|
749
|
+
cursor = skipWhitespace(text, valueEnd);
|
|
750
|
+
if (text[cursor] === ',')
|
|
751
|
+
cursor++;
|
|
752
|
+
else if (text[cursor] !== '}')
|
|
753
|
+
return undefined;
|
|
754
|
+
}
|
|
755
|
+
return undefined;
|
|
756
|
+
}
|
|
757
|
+
function skipWhitespace(text, start) {
|
|
758
|
+
let cursor = start;
|
|
759
|
+
while (/\s/.test(text[cursor] ?? ''))
|
|
760
|
+
cursor++;
|
|
761
|
+
return cursor;
|
|
762
|
+
}
|
|
763
|
+
function jsonStringEnd(text, start) {
|
|
764
|
+
let escaped = false;
|
|
765
|
+
for (let cursor = start + 1; cursor < text.length; cursor++) {
|
|
766
|
+
const char = text[cursor];
|
|
767
|
+
if (escaped)
|
|
768
|
+
escaped = false;
|
|
769
|
+
else if (char === '\\')
|
|
770
|
+
escaped = true;
|
|
771
|
+
else if (char === '"')
|
|
772
|
+
return cursor + 1;
|
|
773
|
+
}
|
|
774
|
+
return -1;
|
|
775
|
+
}
|
|
776
|
+
function jsonValueEnd(text, start) {
|
|
777
|
+
if (text[start] === '"')
|
|
778
|
+
return jsonStringEnd(text, start);
|
|
779
|
+
if (text[start] === '{' || text[start] === '[') {
|
|
780
|
+
const stack = [text[start] === '{' ? '}' : ']'];
|
|
781
|
+
let inString = false;
|
|
782
|
+
let escaped = false;
|
|
783
|
+
for (let cursor = start + 1; cursor < text.length; cursor++) {
|
|
784
|
+
const char = text[cursor];
|
|
785
|
+
if (inString) {
|
|
786
|
+
if (escaped)
|
|
787
|
+
escaped = false;
|
|
788
|
+
else if (char === '\\')
|
|
789
|
+
escaped = true;
|
|
790
|
+
else if (char === '"')
|
|
791
|
+
inString = false;
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
if (char === '"')
|
|
795
|
+
inString = true;
|
|
796
|
+
else if (char === '{')
|
|
797
|
+
stack.push('}');
|
|
798
|
+
else if (char === '[')
|
|
799
|
+
stack.push(']');
|
|
800
|
+
else if (char === '}' || char === ']') {
|
|
801
|
+
if (stack.pop() !== char)
|
|
802
|
+
return -1;
|
|
803
|
+
if (stack.length === 0)
|
|
804
|
+
return cursor + 1;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
return -1;
|
|
808
|
+
}
|
|
809
|
+
let cursor = start;
|
|
810
|
+
while (cursor < text.length && !',}]'.includes(text[cursor]))
|
|
811
|
+
cursor++;
|
|
812
|
+
while (cursor > start && /\s/.test(text[cursor - 1]))
|
|
813
|
+
cursor--;
|
|
814
|
+
return cursor > start ? cursor : -1;
|
|
815
|
+
}
|
|
816
|
+
export function unifiedDiff(file, before, after) {
|
|
817
|
+
if (before === after)
|
|
818
|
+
return '';
|
|
819
|
+
const patch = structuredPatch(`a/${file}`, `b/${file}`, before.replace(/\r\n/g, '\n'), after.replace(/\r\n/g, '\n'), '', '', { context: 3 });
|
|
820
|
+
const output = [`--- a/${file}`, `+++ b/${file}`];
|
|
821
|
+
for (const hunk of patch.hunks) {
|
|
822
|
+
output.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`);
|
|
823
|
+
output.push(...hunk.lines);
|
|
824
|
+
}
|
|
825
|
+
return output.join('\n');
|
|
826
|
+
}
|