upstreamfix 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -1
- package/dist/cli.js +109 -29
- package/dist/core/diagnostic-diff.js +12 -9
- package/dist/core/diff-api.js +211 -0
- package/dist/core/docker-mount.js +6 -0
- package/dist/core/impact.js +72 -0
- package/dist/core/manifest.js +4 -0
- package/dist/core/node-range.js +19 -0
- package/dist/core/probe.js +155 -0
- package/dist/core/project-discovery.js +10 -0
- package/dist/core/report.js +70 -10
- package/dist/core/runner.js +136 -19
- package/dist/core/verify.js +110 -78
- package/dist/core/watch.js +81 -0
- package/dist/core/write-branch.js +45 -27
- package/dist/migrations/firebase-admin-v14/index.js +122 -10
- package/dist/migrations/index.js +3 -7
- package/dist/migrations/stripe-shared.js +75 -9
- package/dist/migrations/stripe-v23/index.js +2 -1
- package/package.json +1 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { newTypeScriptDiagnostics, parseTypeScriptDiagnostics } from './diagnostic-diff.js';
|
|
5
|
+
import { copyProject, executeWithProcessGroup, sanitizedEnvironment } from './verify.js';
|
|
6
|
+
import { validateExactVersion } from './diff-api.js';
|
|
7
|
+
import { migrations } from '../migrations/index.js';
|
|
8
|
+
import { registerTemporaryCleanup } from './process-cleanup.js';
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { parseManifest } from './manifest.js';
|
|
11
|
+
import { dockerBindArgs } from './docker-mount.js';
|
|
12
|
+
const nodeImage = 'node:24.22.0-bookworm-slim@sha256:a5f4366e834695d5f363b920c104953a535d3a9864d1bdf74566a0ebe01daee5';
|
|
13
|
+
function parseTarget(target) {
|
|
14
|
+
const at = target.lastIndexOf('@');
|
|
15
|
+
if (at <= 0)
|
|
16
|
+
throw new Error('Probe target must be a package with an exact version, such as stripe@23.0.0.');
|
|
17
|
+
const packageName = target.slice(0, at);
|
|
18
|
+
const version = target.slice(at + 1);
|
|
19
|
+
if (!/^(?:@[a-z0-9_][a-z0-9_.-]*\/)?[a-z0-9_][a-z0-9_.-]*$/i.test(packageName))
|
|
20
|
+
throw new Error('Invalid npm package name.');
|
|
21
|
+
validateExactVersion(version);
|
|
22
|
+
return { packageName, version };
|
|
23
|
+
}
|
|
24
|
+
const realExecutor = async (_phase, command, args, cwd, env, timeoutMs) => {
|
|
25
|
+
try {
|
|
26
|
+
const result = await executeWithProcessGroup(command, args, cwd, env, timeoutMs);
|
|
27
|
+
return { code: 0, output: `${result.stdout}${result.stderr}` };
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
const cause = error;
|
|
31
|
+
return { code: 1, output: `${cause.stdout ?? ''}${cause.stderr ?? ''}\n${cause.message}` };
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
export async function probeProject(cwd, target, options = {}) {
|
|
35
|
+
if (options.timeoutMs !== undefined && (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1000)) {
|
|
36
|
+
throw new Error('Probe timeout must be at least one second.');
|
|
37
|
+
}
|
|
38
|
+
const { packageName, version } = parseTarget(target);
|
|
39
|
+
const base = {
|
|
40
|
+
status: 'confirmation-required', package: packageName, targetVersion: version,
|
|
41
|
+
estimatedDownload: 'Estimated cold download: 100–500 MB for two npm installs, plus the pinned Node Docker image if absent.',
|
|
42
|
+
preexisting: [], newDiagnostics: [], manualFindings: [],
|
|
43
|
+
};
|
|
44
|
+
if (!options.confirmed)
|
|
45
|
+
return base;
|
|
46
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'upstreamfix-probe-'));
|
|
47
|
+
const unregisterCleanup = registerTemporaryCleanup(() => rm(root, { recursive: true, force: true }));
|
|
48
|
+
const project = path.join(root, 'project');
|
|
49
|
+
const home = path.join(root, 'home');
|
|
50
|
+
const execute = options.execute ?? ((phase, command, args, directory, env) => realExecutor(phase, command, args, directory, env, options.timeoutMs ?? 5 * 60 * 1000));
|
|
51
|
+
let unregisterContainerCleanup;
|
|
52
|
+
let removeContainer;
|
|
53
|
+
try {
|
|
54
|
+
await mkdir(project);
|
|
55
|
+
await mkdir(home);
|
|
56
|
+
await writeFile(path.join(home, 'npmrc.user'), '');
|
|
57
|
+
await writeFile(path.join(home, 'npmrc.global'), '');
|
|
58
|
+
await copyProject(path.resolve(cwd), project, 'probe');
|
|
59
|
+
const manifestPath = path.join(project, 'package.json');
|
|
60
|
+
const manifest = parseManifest(await readFile(manifestPath, 'utf8'));
|
|
61
|
+
const sections = ['dependencies', 'devDependencies', 'optionalDependencies'];
|
|
62
|
+
const section = sections.find((name) => typeof manifest[name]?.[packageName] === 'string');
|
|
63
|
+
if (!section)
|
|
64
|
+
return { ...base, status: 'unavailable', note: `${packageName} is not declared in the root package.json.` };
|
|
65
|
+
const lockContents = await readFile(path.join(project, 'package-lock.json'), 'utf8').catch(() => undefined);
|
|
66
|
+
if (!lockContents) {
|
|
67
|
+
return { ...base, status: 'unavailable', note: 'A root package-lock.json is required for a reproducible npm baseline.' };
|
|
68
|
+
}
|
|
69
|
+
const lock = JSON.parse(lockContents);
|
|
70
|
+
const lockedVersion = lock.packages?.[`node_modules/${packageName}`]?.version;
|
|
71
|
+
if (typeof lockedVersion !== 'string' || !/^\d+\.\d+\.\d+/.test(lockedVersion)) {
|
|
72
|
+
return { ...base, status: 'unavailable', note: `The lockfile does not contain an exact installed ${packageName} version.` };
|
|
73
|
+
}
|
|
74
|
+
const currentVersion = lockedVersion;
|
|
75
|
+
const env = sanitizedEnvironment(home);
|
|
76
|
+
const dockerEnv = { ...env };
|
|
77
|
+
for (const key of ['DOCKER_HOST', 'DOCKER_CONTEXT', 'DOCKER_TLS_VERIFY', 'DOCKER_CERT_PATH']) {
|
|
78
|
+
if (process.env[key])
|
|
79
|
+
dockerEnv[key] = process.env[key];
|
|
80
|
+
}
|
|
81
|
+
const containerName = `upstreamfix-probe-${process.pid}-${randomUUID()}`;
|
|
82
|
+
removeContainer = async () => {
|
|
83
|
+
await execute('baseline-typecheck', 'docker', ['rm', '-f', containerName], project, dockerEnv).catch(() => undefined);
|
|
84
|
+
};
|
|
85
|
+
unregisterContainerCleanup = registerTemporaryCleanup(removeContainer);
|
|
86
|
+
const run = async (phase, command, args) => {
|
|
87
|
+
const result = await execute(phase, command, args, project, command === 'docker' ? dockerEnv : env);
|
|
88
|
+
if (command === 'docker' && result.code !== 0) {
|
|
89
|
+
await execute(phase, 'docker', ['rm', '-f', containerName], project, dockerEnv).catch(() => undefined);
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
};
|
|
93
|
+
const baselineInstall = await run('baseline-install', 'npm', ['ci', '--ignore-scripts', '--no-audit', '--no-fund', '--prefer-offline']);
|
|
94
|
+
if (baselineInstall.code !== 0)
|
|
95
|
+
return { ...base, currentVersion, status: 'unavailable', note: `Baseline installation failed: ${baselineInstall.output.slice(-2000)}` };
|
|
96
|
+
await makeDockerReadable(project);
|
|
97
|
+
const typeArgs = [
|
|
98
|
+
'run', '--rm', '--init', '--name', containerName, '--network=none', '--read-only', '--cap-drop=ALL', '--security-opt=no-new-privileges',
|
|
99
|
+
'--memory=1536m', '--memory-swap=1536m', '--pids-limit=128', '--user=65534:65534',
|
|
100
|
+
'--tmpfs=/tmp:rw,nosuid,nodev,size=64m', ...dockerBindArgs(project, '/workspace', true), '-w', '/workspace',
|
|
101
|
+
nodeImage, 'node', '/workspace/node_modules/typescript/bin/tsc', '--noEmit', '--pretty', 'false',
|
|
102
|
+
];
|
|
103
|
+
const before = await run('baseline-typecheck', 'docker', typeArgs);
|
|
104
|
+
const baselineDiagnostics = parseTypeScriptDiagnostics(before.output);
|
|
105
|
+
if (before.code !== 0 && baselineDiagnostics.length === 0) {
|
|
106
|
+
return { ...base, currentVersion, status: 'unavailable', note: `Baseline typecheck failed without readable diagnostics: ${before.output.slice(-2000)}` };
|
|
107
|
+
}
|
|
108
|
+
const targetInstall = await run('target-install', 'npm', ['install', `${packageName}@${version}`, '--save-exact', '--ignore-scripts', '--no-audit', '--no-fund', '--prefer-offline']);
|
|
109
|
+
if (targetInstall.code !== 0)
|
|
110
|
+
return { ...base, currentVersion, status: 'unavailable', preexisting: baselineDiagnostics, note: `Target installation failed: ${targetInstall.output.slice(-2000)}` };
|
|
111
|
+
await makeDockerReadable(project);
|
|
112
|
+
const after = await run('target-typecheck', 'docker', typeArgs);
|
|
113
|
+
const afterDiagnostics = parseTypeScriptDiagnostics(after.output);
|
|
114
|
+
if (after.code !== 0 && afterDiagnostics.length === 0) {
|
|
115
|
+
return { ...base, currentVersion, status: 'unavailable', preexisting: baselineDiagnostics, note: `Target typecheck failed without readable diagnostics: ${after.output.slice(-2000)}` };
|
|
116
|
+
}
|
|
117
|
+
const newDiagnostics = newTypeScriptDiagnostics(baselineDiagnostics, afterDiagnostics);
|
|
118
|
+
const guideUrl = migrations.find((migration) => migration.packageName === packageName && migration.toMajor === Number(version.split('.')[0]))?.guideUrl;
|
|
119
|
+
const manualFindings = newDiagnostics.map((diagnostic) => ({
|
|
120
|
+
file: diagnostic.file,
|
|
121
|
+
line: diagnostic.line,
|
|
122
|
+
message: diagnostic.message,
|
|
123
|
+
symbol: /['`"]([^'`"]+)['`"]/.exec(diagnostic.message)?.[1],
|
|
124
|
+
confidence: 'candidate',
|
|
125
|
+
...(guideUrl ? { guideUrl } : {}),
|
|
126
|
+
}));
|
|
127
|
+
return { ...base, currentVersion, status: 'available', preexisting: baselineDiagnostics, newDiagnostics, manualFindings,
|
|
128
|
+
note: 'New compiler errors are candidates, not proof that the SDK caused them. No code was rewritten.' };
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
return { ...base, status: 'unavailable', note: error instanceof Error ? error.message : String(error) };
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
if (removeContainer)
|
|
135
|
+
await removeContainer();
|
|
136
|
+
unregisterContainerCleanup?.();
|
|
137
|
+
unregisterCleanup();
|
|
138
|
+
await rm(root, { recursive: true, force: true });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async function makeDockerReadable(directory) {
|
|
142
|
+
await chmod(directory, 0o755);
|
|
143
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
144
|
+
if (entry.name === '.git')
|
|
145
|
+
continue;
|
|
146
|
+
const absolute = path.join(directory, entry.name);
|
|
147
|
+
const info = await lstat(absolute);
|
|
148
|
+
if (info.isSymbolicLink())
|
|
149
|
+
continue;
|
|
150
|
+
if (info.isDirectory())
|
|
151
|
+
await makeDockerReadable(absolute);
|
|
152
|
+
else if (info.isFile())
|
|
153
|
+
await chmod(absolute, info.mode | 0o444);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Shared traversal exclusions for migration, scan, verification, and benchmark discovery. */
|
|
2
|
+
export const IGNORED_PROJECT_DIRECTORIES = new Set([
|
|
3
|
+
'.git', 'node_modules', 'dist', 'coverage', '.next', '.nuxt', '.turbo', '.cache',
|
|
4
|
+
'.codex', '.claude', '.venv', 'venv', '.yarn',
|
|
5
|
+
]);
|
|
6
|
+
/** A single-major npm declaration the migration engine can reason about safely. */
|
|
7
|
+
export function isSupportedSourceMajor(range, major) {
|
|
8
|
+
const match = /^\s*[~^]?(\d+)(?:\.(?:\d+|x|\*)){0,2}\s*$/i.exec(range);
|
|
9
|
+
return match !== null && Number(match[1]) === major;
|
|
10
|
+
}
|
package/dist/core/report.js
CHANGED
|
@@ -12,7 +12,7 @@ export function renderMarkdownReport(migration, outcome, dryRun, verification) {
|
|
|
12
12
|
lines.push(`- Official guide: ${migration.guideUrl}`);
|
|
13
13
|
lines.push('');
|
|
14
14
|
if (total === 0) {
|
|
15
|
-
lines.push(
|
|
15
|
+
lines.push(`No supported ${escapeMarkdownText(migration.title)} usages were found in the scanned files.`);
|
|
16
16
|
lines.push('');
|
|
17
17
|
}
|
|
18
18
|
if (outcome.manual.length > 0) {
|
|
@@ -38,7 +38,7 @@ export function renderMarkdownReport(migration, outcome, dryRun, verification) {
|
|
|
38
38
|
lines.push('- Scope: temporary project copy; the original project was not changed.');
|
|
39
39
|
lines.push('- Verification commands run on this machine with a restricted environment, without copied credentials or package install scripts; project test code is still executable code and is not network-isolated.');
|
|
40
40
|
if (verification.note)
|
|
41
|
-
lines.push(`- Note: ${verification.note}`);
|
|
41
|
+
lines.push(`- Note: ${escapeMarkdownText(verification.note)}`);
|
|
42
42
|
if (verification.checks.length > 0) {
|
|
43
43
|
lines.push('');
|
|
44
44
|
lines.push('| Check | Command | Result | Duration | Output |');
|
|
@@ -84,19 +84,46 @@ export function renderMarkdownReport(migration, outcome, dryRun, verification) {
|
|
|
84
84
|
lines.push('3. Handle the items listed under "Needs your attention".');
|
|
85
85
|
}
|
|
86
86
|
lines.push('');
|
|
87
|
-
const raw = lines.join('\n');
|
|
87
|
+
const raw = stripTerminalControls(lines.join('\n'));
|
|
88
88
|
const sanitized = maskSecrets(raw);
|
|
89
89
|
return sanitized === raw ? raw : `${sanitized}\n> Warning: possible credentials were detected in the report and redacted. Review the source before sharing this report.`;
|
|
90
90
|
}
|
|
91
91
|
/** Redact common provider credentials and private-key material from every report field. */
|
|
92
92
|
export function maskSecrets(value) {
|
|
93
|
-
return value
|
|
94
|
-
.replace(
|
|
95
|
-
.replace(/((?:"|\b)
|
|
93
|
+
return redactPrivateKeys(value)
|
|
94
|
+
.replace(/((?:\\?"private_key\\?"|private_key|privateKey)\s*:\s*)\\?"[^"\r\n]*\\?"/gi, '$1"[REDACTED]"')
|
|
95
|
+
.replace(/((?:"|\b)private\\?_key(?:"|\b)\s*:\s*)(?:"(?:\\.|[^"\\])*"|'[^']*'|[^,\r\n}]+)/gi, '$1"[REDACTED]"')
|
|
96
96
|
.replace(/\b(?:sk|rk|pk)\\?_(?:live|test)\\?_(?:[A-Za-z0-9-]|\\?_){8,}\b/gi, '[API key redacted]')
|
|
97
|
+
.replace(/\bsk_org_[A-Za-z0-9_-]{8,}\b/g, '[API key redacted]')
|
|
98
|
+
.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, '[GitHub token redacted]')
|
|
99
|
+
.replace(/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, '[GitHub token redacted]')
|
|
100
|
+
.replace(/\bxox[bp]-[A-Za-z0-9-]{20,}\b/g, '[Slack token redacted]')
|
|
101
|
+
.replace(/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, '[AWS key redacted]')
|
|
97
102
|
.replace(/\bwhsec\\?_(?:[A-Za-z0-9-]|\\?_){8,}\b/gi, '[webhook secret redacted]')
|
|
98
103
|
.replace(/\bAIza[0-9A-Za-z_-]{20,}\b/g, '[API key redacted]');
|
|
99
104
|
}
|
|
105
|
+
function redactPrivateKeys(value) {
|
|
106
|
+
const begin = /-----BEGIN ((?:(?:[A-Z0-9]+ )*PRIVATE KEY|PGP PRIVATE KEY BLOCK))-----/g;
|
|
107
|
+
let result = '';
|
|
108
|
+
let cursor = 0;
|
|
109
|
+
for (let match = begin.exec(value); match; match = begin.exec(value)) {
|
|
110
|
+
result += value.slice(cursor, match.index) + '[PRIVATE KEY REDACTED]';
|
|
111
|
+
const endMarker = `-----END ${match[1]}-----`;
|
|
112
|
+
const end = value.indexOf(endMarker, begin.lastIndex);
|
|
113
|
+
if (end < 0)
|
|
114
|
+
return result;
|
|
115
|
+
cursor = end + endMarker.length;
|
|
116
|
+
begin.lastIndex = cursor;
|
|
117
|
+
}
|
|
118
|
+
return result + value.slice(cursor);
|
|
119
|
+
}
|
|
120
|
+
/** Escape third-party command output before placing it in a public Markdown table. */
|
|
121
|
+
export function escapeUntrustedMarkdownCell(value) {
|
|
122
|
+
return maskSecrets(stripTerminalControls(value))
|
|
123
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
124
|
+
.replace(/`/g, '`').replace(/\[/g, '[').replace(/\]/g, ']')
|
|
125
|
+
.replaceAll('|', '|').replace(/[\r\n\u2028\u2029]+/g, ' ').trim();
|
|
126
|
+
}
|
|
100
127
|
/** Keep the Markdown artifact intact while making prose readable in a terminal. */
|
|
101
128
|
export function renderTerminalReport(markdown) {
|
|
102
129
|
let inFence = false;
|
|
@@ -113,17 +140,50 @@ export function renderTerminalReport(markdown) {
|
|
|
113
140
|
.replace(/&/g, '&')
|
|
114
141
|
.replace(/\\([\\`*_{}\[\]()#+.!])/g, '$1');
|
|
115
142
|
}).join('\n');
|
|
116
|
-
return
|
|
143
|
+
return sanitizeTerminalText(readable);
|
|
144
|
+
}
|
|
145
|
+
export function sanitizeTerminalText(value) {
|
|
146
|
+
return maskSecrets(stripTerminalControls(value));
|
|
147
|
+
}
|
|
148
|
+
function stripTerminalControls(value) {
|
|
149
|
+
let clean = '';
|
|
150
|
+
for (let i = 0; i < value.length; i++) {
|
|
151
|
+
const code = value.charCodeAt(i);
|
|
152
|
+
if (code === 0x1b) {
|
|
153
|
+
const kind = value[i + 1];
|
|
154
|
+
if (kind === '[') {
|
|
155
|
+
i += 2;
|
|
156
|
+
while (i < value.length && !(value.charCodeAt(i) >= 0x40 && value.charCodeAt(i) <= 0x7e))
|
|
157
|
+
i++;
|
|
158
|
+
}
|
|
159
|
+
else if (kind === ']' || kind === 'P' || kind === 'X' || kind === '^' || kind === '_') {
|
|
160
|
+
i += 2;
|
|
161
|
+
while (i < value.length && value.charCodeAt(i) !== 0x07 && !(value[i] === '\x1b' && value[i + 1] === '\\'))
|
|
162
|
+
i++;
|
|
163
|
+
if (value[i] === '\x1b')
|
|
164
|
+
i++;
|
|
165
|
+
}
|
|
166
|
+
else
|
|
167
|
+
i++;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if ((code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) || (code >= 0x7f && code <= 0x9f)
|
|
171
|
+
|| (code >= 0x202a && code <= 0x202e) || (code >= 0x2066 && code <= 0x2069))
|
|
172
|
+
continue;
|
|
173
|
+
clean += value[i];
|
|
174
|
+
}
|
|
175
|
+
return clean;
|
|
117
176
|
}
|
|
118
177
|
function markdownCell(value) {
|
|
119
178
|
return escapeMarkdownText(value).replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
|
|
120
179
|
}
|
|
121
180
|
function escapeMarkdownText(value) {
|
|
122
|
-
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
123
|
-
.replace(/([\\`*_{}\[\]()#+.!])/g, '\\$1')
|
|
181
|
+
return sanitizeTerminalText(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
182
|
+
.replace(/([\\`*_{}\[\]()#+.!])/g, '\\$1')
|
|
183
|
+
.replaceAll('\\[API key redacted\\]', '[API key redacted]');
|
|
124
184
|
}
|
|
125
185
|
function inlineCode(value) {
|
|
126
|
-
const normalized = value.replace(/\r?\n/g, ' ').replace(/\|/g, '\\|');
|
|
186
|
+
const normalized = sanitizeTerminalText(value).replace(/\r?\n/g, ' ').replace(/\|/g, '\\|');
|
|
127
187
|
const runs = [...normalized.matchAll(/`+/g)].map(([run]) => run.length);
|
|
128
188
|
const delimiter = '`'.repeat(Math.max(1, ...runs) + 1);
|
|
129
189
|
const needsPadding = normalized.startsWith('`') || normalized.endsWith('`') || /^\s|\s$/.test(normalized);
|