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,7 @@
|
|
|
1
|
+
/** Verified migrations can be committed on an isolated Git branch. */
|
|
2
|
+
export const WRITES_ENABLED = true;
|
|
3
|
+
export function assertWritesEnabled(dryRun) {
|
|
4
|
+
if (!dryRun && !WRITES_ENABLED) {
|
|
5
|
+
throw new Error('Applying migrations with --write is unavailable in this build.');
|
|
6
|
+
}
|
|
7
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { execFile as execFileCallback, spawn } from 'node:child_process';
|
|
2
|
+
import { copyFile, lstat, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { applyPatch, parsePatch } from 'diff';
|
|
7
|
+
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
|
+
const ignoredFiles = new Set(['.npmrc', '.yarnrc', '.yarnrc.yml', '.pnpmfile.cjs', '.pnpmfile.mjs', 'yarn.config.cjs']);
|
|
10
|
+
const maxOutputLength = 4_000;
|
|
11
|
+
const maxCopyBytes = 100 * 1024 * 1024;
|
|
12
|
+
const maxCopyFileBytes = 20 * 1024 * 1024;
|
|
13
|
+
const execFile = promisify(execFileCallback);
|
|
14
|
+
export async function verifyProposedMigration(cwd, diffs, options = {}) {
|
|
15
|
+
if (diffs.length === 0 && (options.fileChanges?.length ?? 0) === 0) {
|
|
16
|
+
return { status: 'skipped', checks: [], note: 'No proposed changes were available to verify.' };
|
|
17
|
+
}
|
|
18
|
+
const timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
|
|
19
|
+
const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), 'upstreamfix-verify-'));
|
|
20
|
+
const projectCopy = path.join(temporaryRoot, 'project');
|
|
21
|
+
const isolatedHome = path.join(temporaryRoot, 'home');
|
|
22
|
+
const checks = [];
|
|
23
|
+
const unregisterCleanup = registerTemporaryCleanup(() => rm(temporaryRoot, { recursive: true, force: true }));
|
|
24
|
+
try {
|
|
25
|
+
await mkdir(projectCopy, { recursive: true });
|
|
26
|
+
await mkdir(isolatedHome, { recursive: true });
|
|
27
|
+
await mkdir(path.join(isolatedHome, 'npm-cache'), { recursive: true });
|
|
28
|
+
await Promise.all([
|
|
29
|
+
writeFile(path.join(isolatedHome, 'npmrc.user'), '', 'utf8'),
|
|
30
|
+
writeFile(path.join(isolatedHome, 'npmrc.global'), '', 'utf8'),
|
|
31
|
+
]);
|
|
32
|
+
await copyProject(cwd, projectCopy);
|
|
33
|
+
if (options.fileChanges)
|
|
34
|
+
await applyProposedFiles(projectCopy, options.fileChanges);
|
|
35
|
+
else
|
|
36
|
+
await applyProposedDiffs(projectCopy, diffs);
|
|
37
|
+
const packageName = options.packageName ?? 'firebase-admin';
|
|
38
|
+
const packageDirs = await findRelevantPackageDirs(projectCopy, packageName);
|
|
39
|
+
if (packageDirs.length === 0) {
|
|
40
|
+
return { status: 'skipped', checks, note: `No package.json declaring ${packageName} was found in the temporary project copy.` };
|
|
41
|
+
}
|
|
42
|
+
const env = sanitizedEnvironment(isolatedHome);
|
|
43
|
+
const installChecks = [];
|
|
44
|
+
const installDirs = new Set(packageDirs);
|
|
45
|
+
const rootPackage = await readPackageJson(projectCopy).catch(() => undefined);
|
|
46
|
+
if (rootPackage && (rootPackage.scripts?.typecheck || rootPackage.scripts?.test || rootPackage.workspaces) && hasDependencies(rootPackage)) {
|
|
47
|
+
installDirs.add(projectCopy);
|
|
48
|
+
}
|
|
49
|
+
for (const directory of installDirs) {
|
|
50
|
+
const pkg = await readPackageJson(directory);
|
|
51
|
+
if (!hasDependencies(pkg))
|
|
52
|
+
continue;
|
|
53
|
+
const plan = await installCommand(projectCopy, directory, pkg);
|
|
54
|
+
const installLabel = path.relative(projectCopy, plan.cwd) || '.';
|
|
55
|
+
const check = await runCheck(`Install dependencies (${installLabel})`, plan.command, plan.args, plan.cwd, env, timeoutMs, options.execute);
|
|
56
|
+
installChecks.push(check);
|
|
57
|
+
if (check.status !== 'passed') {
|
|
58
|
+
checks.push(...installChecks);
|
|
59
|
+
return { status: 'failed', checks, note: 'Type checks and tests were not run because dependency installation failed.' };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
checks.push(...installChecks);
|
|
63
|
+
const checkDirs = await findCheckDirs(projectCopy, packageDirs);
|
|
64
|
+
for (const directory of checkDirs) {
|
|
65
|
+
const pkg = await readPackageJson(directory);
|
|
66
|
+
const relative = path.relative(projectCopy, directory) || '.';
|
|
67
|
+
if (pkg.scripts?.typecheck) {
|
|
68
|
+
const script = await scriptCommand(projectCopy, directory, pkg, 'typecheck');
|
|
69
|
+
checks.push(await runCheck(`Typecheck (${relative})`, script.command, script.args, directory, env, timeoutMs, options.execute));
|
|
70
|
+
}
|
|
71
|
+
else if (await fileExists(path.join(directory, 'tsconfig.json'))) {
|
|
72
|
+
const binary = path.join(projectCopy, 'node_modules', '.bin', 'tsc');
|
|
73
|
+
const packageBinary = path.join(directory, 'node_modules', '.bin', 'tsc');
|
|
74
|
+
const compiler = await fileExists(packageBinary) ? packageBinary : binary;
|
|
75
|
+
checks.push(await runCheck(`Typecheck (${relative})`, compiler, ['--noEmit'], directory, env, timeoutMs, options.execute));
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
checks.push({ name: `Typecheck (${relative})`, command: 'not configured', status: 'skipped', durationMs: 0, output: 'No typecheck script or tsconfig.json was found.' });
|
|
79
|
+
}
|
|
80
|
+
if (pkg.scripts?.test) {
|
|
81
|
+
const script = await scriptCommand(projectCopy, directory, pkg, 'test');
|
|
82
|
+
checks.push(await runCheck(`Tests (${relative})`, script.command, script.args, directory, env, timeoutMs, options.execute));
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
checks.push({ name: `Tests (${relative})`, command: 'not configured', status: 'skipped', durationMs: 0, output: 'No test script was found.' });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const ranChecks = checks.filter((check) => /^(?:Typecheck|Tests) /.test(check.name) && check.status !== 'skipped');
|
|
89
|
+
const status = ranChecks.length === 0
|
|
90
|
+
? 'skipped'
|
|
91
|
+
: ranChecks.some((check) => check.status === 'failed') ? 'failed' : 'passed';
|
|
92
|
+
return { status, checks, ...(status === 'skipped' ? { note: 'No typecheck or test commands were configured.' } : {}) };
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
return {
|
|
96
|
+
status: 'failed',
|
|
97
|
+
checks,
|
|
98
|
+
note: error instanceof Error ? error.message : String(error),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
unregisterCleanup();
|
|
103
|
+
await rm(temporaryRoot, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async function copyProject(source, destination) {
|
|
107
|
+
let listing;
|
|
108
|
+
try {
|
|
109
|
+
({ stdout: listing } = await execFile('git', [
|
|
110
|
+
'-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false', 'ls-files', '-co', '--exclude-standard', '-z',
|
|
111
|
+
], {
|
|
112
|
+
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' },
|
|
114
|
+
timeout: 15_000,
|
|
115
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
throw new Error('--verify requires a Git working tree so ignored and untracked files can be excluded safely.');
|
|
120
|
+
}
|
|
121
|
+
let totalBytes = 0;
|
|
122
|
+
for (const relative of listing.split('\0').filter(Boolean)) {
|
|
123
|
+
const portable = relative.replaceAll('\\', '/');
|
|
124
|
+
const parts = portable.split('/');
|
|
125
|
+
if (parts.some((part) => ignoredDirectories.has(part))
|
|
126
|
+
|| ignoredFiles.has(parts.at(-1)) || isSensitivePath(portable))
|
|
127
|
+
continue;
|
|
128
|
+
const sourcePath = path.resolve(source, relative);
|
|
129
|
+
const relativeCheck = path.relative(source, sourcePath);
|
|
130
|
+
if (path.isAbsolute(relativeCheck) || relativeCheck === '..' || relativeCheck.startsWith(`..${path.sep}`))
|
|
131
|
+
continue;
|
|
132
|
+
const info = await lstat(sourcePath).catch(() => undefined);
|
|
133
|
+
if (!info?.isFile() || info.isSymbolicLink())
|
|
134
|
+
continue;
|
|
135
|
+
if (info.size > maxCopyFileBytes)
|
|
136
|
+
throw new Error(`--verify refused a file larger than ${maxCopyFileBytes / (1024 * 1024)} MiB: ${portable}`);
|
|
137
|
+
totalBytes += info.size;
|
|
138
|
+
if (totalBytes > maxCopyBytes)
|
|
139
|
+
throw new Error(`--verify refused a project copy larger than ${maxCopyBytes / (1024 * 1024)} MiB.`);
|
|
140
|
+
const destinationPath = path.join(destination, relative);
|
|
141
|
+
await mkdir(path.dirname(destinationPath), { recursive: true });
|
|
142
|
+
await copyFile(sourcePath, destinationPath);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function isSensitivePath(relative) {
|
|
146
|
+
const parts = relative.split('/');
|
|
147
|
+
const base = parts.at(-1).toLowerCase();
|
|
148
|
+
return parts.some((part) => ['.aws', '.gcloud', '.ssh', 'secrets'].includes(part.toLowerCase()))
|
|
149
|
+
|| /(?:service[-_]?account[^/]*|firebase-adminsdk[^/]*|credentials?[^/]*|secrets?[^/]*|private[-_]?key[^/]*)\.json$/i.test(base)
|
|
150
|
+
|| /\.(?:pem|key)$/i.test(base)
|
|
151
|
+
|| /^\.env(?:\.|$)/i.test(base) && !/\.(?:example|sample|template)$/i.test(base);
|
|
152
|
+
}
|
|
153
|
+
export async function applyProposedDiffs(projectCopy, diffs) {
|
|
154
|
+
for (const diff of diffs) {
|
|
155
|
+
const patch = parsePatch(diff)[0];
|
|
156
|
+
if (!patch)
|
|
157
|
+
throw new Error('A proposed change could not be parsed as a unified diff.');
|
|
158
|
+
const destination = patch.newFileName?.replace(/^b\//, '');
|
|
159
|
+
if (!destination || destination === '/dev/null')
|
|
160
|
+
continue;
|
|
161
|
+
const portableDestination = destination.replaceAll('\\', '/');
|
|
162
|
+
const normalized = path.normalize(portableDestination);
|
|
163
|
+
if (path.isAbsolute(portableDestination) || /^[A-Za-z]:/.test(portableDestination)
|
|
164
|
+
|| normalized === '..' || normalized.startsWith(`..${path.sep}`)
|
|
165
|
+
|| portableDestination.split('/').includes('..')) {
|
|
166
|
+
throw new Error(`A proposed change has an unsafe path: ${destination}`);
|
|
167
|
+
}
|
|
168
|
+
const target = path.join(projectCopy, normalized);
|
|
169
|
+
const before = await readFile(target, 'utf8');
|
|
170
|
+
const after = applyPatch(before, patch);
|
|
171
|
+
if (after === false)
|
|
172
|
+
throw new Error(`Could not apply the proposed change to ${destination} in the temporary copy.`);
|
|
173
|
+
await writeFile(target, after, 'utf8');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
export async function applyProposedFiles(projectCopy, changes) {
|
|
177
|
+
const root = path.resolve(projectCopy);
|
|
178
|
+
const seen = new Set();
|
|
179
|
+
for (const change of changes) {
|
|
180
|
+
const absolute = path.resolve(root, change.relativePath);
|
|
181
|
+
const relative = path.relative(root, absolute);
|
|
182
|
+
if (path.isAbsolute(relative) || relative === '..' || relative.startsWith(`..${path.sep}`)) {
|
|
183
|
+
throw new Error(`A proposed change has an unsafe path: ${change.relativePath}`);
|
|
184
|
+
}
|
|
185
|
+
const key = process.platform === 'win32' ? relative.toLowerCase() : relative;
|
|
186
|
+
if (seen.has(key))
|
|
187
|
+
throw new Error(`A proposed change contains a duplicate path: ${change.relativePath}`);
|
|
188
|
+
seen.add(key);
|
|
189
|
+
const current = await readFile(absolute, 'utf8').catch(() => undefined);
|
|
190
|
+
if (current !== change.before)
|
|
191
|
+
throw new Error(`The temporary project does not match the migration input for ${change.relativePath}.`);
|
|
192
|
+
await mkdir(path.dirname(absolute), { recursive: true });
|
|
193
|
+
await writeFile(absolute, change.after, 'utf8');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async function findRelevantPackageDirs(root, packageName) {
|
|
197
|
+
const manifests = await findPackageFiles(root);
|
|
198
|
+
const packageDirs = [];
|
|
199
|
+
for (const manifestPath of manifests) {
|
|
200
|
+
const pkg = await readPackageJson(path.dirname(manifestPath));
|
|
201
|
+
if (['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
|
|
202
|
+
.some((section) => typeof pkg[section] === 'object'
|
|
203
|
+
&& pkg[section]?.[packageName])) {
|
|
204
|
+
packageDirs.push(path.dirname(manifestPath));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const rootPackage = await readPackageJson(root).catch(() => undefined);
|
|
208
|
+
if (rootPackage?.workspaces && packageDirs.length > 0)
|
|
209
|
+
return [root];
|
|
210
|
+
return [...new Set(packageDirs)];
|
|
211
|
+
}
|
|
212
|
+
async function findCheckDirs(root, packageDirs) {
|
|
213
|
+
const candidates = new Set([root, ...packageDirs]);
|
|
214
|
+
const output = [];
|
|
215
|
+
for (const directory of candidates) {
|
|
216
|
+
const pkg = await readPackageJson(directory).catch(() => undefined);
|
|
217
|
+
if (pkg)
|
|
218
|
+
output.push(directory);
|
|
219
|
+
}
|
|
220
|
+
return output;
|
|
221
|
+
}
|
|
222
|
+
async function findPackageFiles(root) {
|
|
223
|
+
const found = [];
|
|
224
|
+
async function visit(directory) {
|
|
225
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
226
|
+
if (entry.isSymbolicLink())
|
|
227
|
+
continue;
|
|
228
|
+
const absolute = path.join(directory, entry.name);
|
|
229
|
+
if (entry.isDirectory()) {
|
|
230
|
+
if (!ignoredDirectories.has(entry.name))
|
|
231
|
+
await visit(absolute);
|
|
232
|
+
}
|
|
233
|
+
else if (entry.isFile() && entry.name === 'package.json')
|
|
234
|
+
found.push(absolute);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
await visit(root);
|
|
238
|
+
return found;
|
|
239
|
+
}
|
|
240
|
+
async function readPackageJson(directory) {
|
|
241
|
+
return JSON.parse(await readFile(path.join(directory, 'package.json'), 'utf8'));
|
|
242
|
+
}
|
|
243
|
+
function hasDependencies(pkg) {
|
|
244
|
+
return ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
|
|
245
|
+
.some((section) => Object.keys(pkg[section] ?? {}).length > 0);
|
|
246
|
+
}
|
|
247
|
+
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
|
+
}
|
|
261
|
+
if (manager === 'pnpm')
|
|
262
|
+
return { command: 'pnpm', args: ['install', '--ignore-scripts', '--no-frozen-lockfile'], cwd: lockRoot };
|
|
263
|
+
if (manager === 'yarn') {
|
|
264
|
+
const version = Number(/^yarn@(\d+)/.exec(declared ?? '')?.[1] ?? 1);
|
|
265
|
+
const yarnBerry = version >= 2 || (await readFile(path.join(lockRoot, 'yarn.lock'), 'utf8').catch(() => '')).includes('__metadata:');
|
|
266
|
+
return {
|
|
267
|
+
command: 'yarn',
|
|
268
|
+
args: ['install', '--non-interactive', ...(yarnBerry ? ['--mode=skip-builds'] : ['--ignore-scripts'])],
|
|
269
|
+
cwd: lockRoot,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
return { command: 'npm', args: ['install', '--ignore-scripts', '--no-audit', '--no-fund', '--prefer-offline'], cwd: lockRoot };
|
|
273
|
+
}
|
|
274
|
+
async function findLockfileRoot(root, directory) {
|
|
275
|
+
let current = directory;
|
|
276
|
+
while (current === root || current.startsWith(root + path.sep)) {
|
|
277
|
+
if (await findLockfileName(current))
|
|
278
|
+
return current;
|
|
279
|
+
if (current === root)
|
|
280
|
+
break;
|
|
281
|
+
current = path.dirname(current);
|
|
282
|
+
}
|
|
283
|
+
return root;
|
|
284
|
+
}
|
|
285
|
+
async function findLockfileName(directory) {
|
|
286
|
+
for (const name of ['pnpm-lock.yaml', 'yarn.lock', 'package-lock.json', 'npm-shrinkwrap.json']) {
|
|
287
|
+
try {
|
|
288
|
+
await stat(path.join(directory, name));
|
|
289
|
+
return name;
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
// Check the next supported lockfile name.
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
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');
|
|
303
|
+
if (manager === 'pnpm')
|
|
304
|
+
return { command: 'pnpm', args: ['run', script] };
|
|
305
|
+
if (manager === 'yarn')
|
|
306
|
+
return { command: 'yarn', args: ['run', script] };
|
|
307
|
+
return { command: 'npm', args: ['--ignore-scripts', 'run', script] };
|
|
308
|
+
}
|
|
309
|
+
async function runCheck(name, command, args, cwd, env, timeoutMs, execute) {
|
|
310
|
+
const start = Date.now();
|
|
311
|
+
const commandText = [command, ...args].join(' ');
|
|
312
|
+
try {
|
|
313
|
+
const result = execute ? await execute(command, args, cwd, env, timeoutMs) : undefined;
|
|
314
|
+
const output = result ?? await executeWithProcessGroup(command, args, cwd, env, timeoutMs);
|
|
315
|
+
return { name, command: commandText, status: 'passed', durationMs: Date.now() - start, output: truncate(`${output.stdout ?? ''}${output.stderr ?? ''}`) };
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
const cause = error;
|
|
319
|
+
const output = `${cause.stdout ?? ''}${cause.stderr ?? ''}` || cause.message || 'Command failed without output.';
|
|
320
|
+
const timedOut = cause.timedOut || cause.code === 'ETIMEDOUT' || cause.killed;
|
|
321
|
+
return { name, command: commandText, status: 'failed', durationMs: Date.now() - start, output: truncate(`${timedOut ? 'Timed out. ' : ''}${output}`) };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
async function executeWithProcessGroup(command, args, cwd, env, timeoutMs) {
|
|
325
|
+
return new Promise((resolve, reject) => {
|
|
326
|
+
const child = spawn(command, args, {
|
|
327
|
+
cwd,
|
|
328
|
+
env,
|
|
329
|
+
detached: process.platform !== 'win32',
|
|
330
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
331
|
+
windowsHide: true,
|
|
332
|
+
});
|
|
333
|
+
const unregisterGroup = child.pid ? trackProcessGroup(child.pid) : () => undefined;
|
|
334
|
+
let stdout = '';
|
|
335
|
+
let stderr = '';
|
|
336
|
+
let timedOut = false;
|
|
337
|
+
let settled = false;
|
|
338
|
+
let killTimer;
|
|
339
|
+
const timeout = setTimeout(() => {
|
|
340
|
+
timedOut = true;
|
|
341
|
+
signalProcessGroup(child, 'SIGTERM');
|
|
342
|
+
killTimer = setTimeout(() => signalProcessGroup(child, 'SIGKILL'), 750);
|
|
343
|
+
}, timeoutMs);
|
|
344
|
+
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);
|
|
347
|
+
};
|
|
348
|
+
child.stdout?.on('data', (chunk) => { stdout = append(stdout, chunk); });
|
|
349
|
+
child.stderr?.on('data', (chunk) => { stderr = append(stderr, chunk); });
|
|
350
|
+
const finish = (error) => {
|
|
351
|
+
if (settled)
|
|
352
|
+
return;
|
|
353
|
+
settled = true;
|
|
354
|
+
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) => {
|
|
368
|
+
if (child.pid)
|
|
369
|
+
await terminateProcessGroup(child.pid);
|
|
370
|
+
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();
|
|
376
|
+
resolve({ stdout, stderr });
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
finish(Object.assign(new Error(`Command exited with ${signal ? `signal ${signal}` : `code ${code}`}.`), { stdout, stderr }));
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
}
|
|
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) {
|
|
399
|
+
const allowed = new Set(['PATH', 'SYSTEMROOT', 'WINDIR', 'TMP', 'TEMP', 'TMPDIR', 'LANG', 'LC_ALL', 'TZ']);
|
|
400
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => allowed.has(key)));
|
|
401
|
+
env.HOME = home;
|
|
402
|
+
env.USERPROFILE = home;
|
|
403
|
+
env.NPM_CONFIG_USERCONFIG = path.join(home, 'npmrc.user');
|
|
404
|
+
env.NPM_CONFIG_GLOBALCONFIG = path.join(home, 'npmrc.global');
|
|
405
|
+
env.NPM_CONFIG_CACHE = path.join(home, 'npm-cache');
|
|
406
|
+
env.CI = '1';
|
|
407
|
+
env.COREPACK_ENABLE_UNSAFE_CUSTOM_URLS = '0';
|
|
408
|
+
return env;
|
|
409
|
+
}
|
|
410
|
+
function truncate(output) {
|
|
411
|
+
const trimmed = output.trim();
|
|
412
|
+
return trimmed.length <= maxOutputLength ? trimmed : `${trimmed.slice(0, maxOutputLength)} … [truncated]`;
|
|
413
|
+
}
|
|
414
|
+
async function fileExists(file) {
|
|
415
|
+
try {
|
|
416
|
+
await readFile(file);
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
}
|