Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 55 additions & 29 deletions packages/cli/src/migration/migrator/eslint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,39 +88,59 @@ export function detectEslintProject(

/**
* Run a `vp dlx @oxlint/migrate` step with graceful error handling.
* Returns true on success, false on failure (spawn error or non-zero exit).
*
* `packages` lists candidate `@oxlint/migrate` specs in priority order. The
* first entry is normally the version pinned to the bundled oxlint; later
* entries are fallbacks. `@oxlint/migrate` is published in lockstep with
* oxlint, but the migrate release can lag the linter by a few hours — when
* the pinned version isn't on npm yet, we silently fall through to the next
* candidate (the latest published migrate) instead of failing the migration.
*
* Returns `{ ok, usedPackage }` where `usedPackage` is the spec that ran
* successfully (so later steps reuse the same version).
*/
async function runOxlintMigrateStep(
vpBin: string,
cwd: string,
migratePackage: string,
packages: string[],
args: string[],
spinner: ReturnType<typeof getSpinner>,
failMessage: string,
manualHint: string,
): Promise<boolean> {
try {
const result = await runCommandSilently({
command: vpBin,
args: ['dlx', migratePackage, ...args],
cwd,
envs: process.env,
});
if (result.exitCode !== 0) {
): Promise<{ ok: boolean; usedPackage?: string }> {
for (let i = 0; i < packages.length; i++) {
const migratePackage = packages[i];
try {
const result = await runCommandSilently({
command: vpBin,
args: ['dlx', migratePackage, ...args],
cwd,
envs: process.env,
});
if (result.exitCode === 0) {
return { ok: true, usedPackage: migratePackage };
}
const stderr = result.stderr.toString();
// If the pinned version simply isn't published yet, try the next
// (unpinned) candidate silently rather than reporting a failure.
const missingVersion = /ERR_PNPM_NO_MATCHING_VERSION|No matching version found/i.test(stderr);
if (missingVersion && i < packages.length - 1) {
continue;
}
spinner.stop(failMessage);
const stderr = result.stderr.toString().trim();
if (stderr) {
prompts.log.warn(`⚠ ${stderr}`);
const trimmed = stderr.trim();
if (trimmed) {
prompts.log.warn(`⚠ ${trimmed}`);
}
prompts.log.info(manualHint);
return false;
return { ok: false };
} catch {
spinner.stop(failMessage);
prompts.log.info(manualHint);
return { ok: false };
}
return true;
} catch {
spinner.stop(failMessage);
prompts.log.info(manualHint);
return false;
}
return { ok: false };
}

export async function migrateEslintToOxlint(
Expand All @@ -147,10 +167,14 @@ export async function migrateEslintToOxlint(

// Steps 1-2: Only run @oxlint/migrate if there's an eslint config at root
if (eslintConfigFile) {
// Pin @oxlint/migrate to the bundled oxlint version.
// Pin @oxlint/migrate to the bundled oxlint version, falling back to the
// latest published migrate when that exact version isn't on npm yet (the
// migrate package can lag oxlint by a few hours after a release).
// @ts-expect-error — resolved at runtime from dist/ → dist/versions.js
const { versions } = await import('../versions.js');
const migratePackage = `@oxlint/migrate@${versions.oxlint}`;
const pinnedPackage = `@oxlint/migrate@${versions.oxlint}`;
const fallbackPackage = '@oxlint/migrate';
const migrateCandidates = [pinnedPackage, fallbackPackage];
const migrateArgs = [
'--merge',
...(!hasBaseUrlInTsconfig(projectPath) ? ['--type-aware'] : []),
Expand All @@ -160,32 +184,34 @@ export async function migrateEslintToOxlint(

// Step 1: Generate .oxlintrc.json from ESLint config
spinner.start('Migrating ESLint config to Oxlint...');
const migrateOk = await runOxlintMigrateStep(
const migrateResult = await runOxlintMigrateStep(
vpBin,
projectPath,
migratePackage,
migrateCandidates,
migrateArgs,
spinner,
'ESLint migration failed',
`You can run \`vp dlx ${migratePackage} ${migrateArgs.join(' ')}\` manually later`,
`You can run \`vp dlx ${fallbackPackage} ${migrateArgs.join(' ')}\` manually later`,
);
if (!migrateOk) {
if (!migrateResult.ok) {
return false;
}
// Reuse the version that actually resolved for the remaining steps.
const migratePackage = migrateResult.usedPackage ?? pinnedPackage;
spinner.stop('ESLint config migrated to .oxlintrc.json');

// Step 2: Replace eslint-disable comments with oxlint-disable
spinner.start('Replacing ESLint comments with Oxlint equivalents...');
const replaceOk = await runOxlintMigrateStep(
const replaceResult = await runOxlintMigrateStep(
vpBin,
projectPath,
migratePackage,
[migratePackage],
['--replace-eslint-comments'],
spinner,
'ESLint comment replacement failed',
`You can run \`vp dlx ${migratePackage} --replace-eslint-comments\` manually later`,
);
if (replaceOk) {
if (replaceResult.ok) {
spinner.stop('ESLint comments replaced');
}
// Continue with cleanup regardless — .oxlintrc.json was generated successfully
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@
"@oxc-node/core": "catalog:",
"@tsdown/css": "catalog:",
"@tsdown/exe": "catalog:",
"@vitejs/devtools": "^0.4.0",
"@vitejs/devtools": "^0.4.1",
"es-module-lexer": "^1.7.0",
"hookable": "^6.0.1",
"magic-string": "^0.30.21",
Expand Down
55 changes: 55 additions & 0 deletions packages/tools/src/local-npm-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,41 @@ function serializeForRegistry(value: unknown, registry: string): string {
return JSON.stringify(value).replaceAll('{REGISTRY}', registry);
}

// Rewrite every publish timestamp in a packument to the far past so
// package-manager "minimum release age" gates (pnpm `minimumReleaseAge` and
// its pnpm 11 supply-chain quarantine, Yarn Berry hardened-mode
// `npmMinimalAgeGate`) never quarantine a proxied dependency. Locally served
// tarballs already use `LOCAL_PACKAGE_TIME` for this reason; proxied upstream
// deps need the same treatment so a test running right after a toolchain
// release (e.g. a freshly published oxc/oxfmt/oxlint bump) stays deterministic
// instead of failing because the version is "too new".
function neutralizePackumentTime(packument: Packument): Packument {
if (!packument.time || typeof packument.time !== 'object') {
return packument;
}
const time: Record<string, string> = {};
for (const key of Object.keys(packument.time)) {
time[key] = LOCAL_PACKAGE_TIME;
}
return { ...packument, time };
}

// Cache proxied packuments (with neutralized times) — an install fetches each
// package's metadata repeatedly. `null` records a miss (non-200/unparseable),
// so those keys fall through to the byte-for-byte proxy instead of refetching.
const proxiedPackuments = new Map<string, Packument | null>();

async function resolveProxiedPackument(name: string): Promise<Packument | null> {
const cached = proxiedPackuments.get(name);
if (cached !== undefined) {
return cached;
}
const upstream = await fetchUpstreamPackument(name);
const result = upstream ? neutralizePackumentTime(upstream) : null;
proxiedPackuments.set(name, result);
return result;
}

function fetchUpstreamPackument(name: string): Promise<Packument | null> {
return new Promise((resolve) => {
httpsGet(
Expand Down Expand Up @@ -488,6 +523,26 @@ const server = createServer(async (req, res) => {
// fall through to proxy
}
}
// Package metadata (packument) requests: serve upstream metadata with
// neutralized publish times so age gates never quarantine a freshly
// published dependency. Only whole-packument GETs are intercepted — tarballs
// (`/-/`), version-specific manifests (`name/1.2.3`), and non-GET traffic
// (e.g. the audit bulk POST) keep proxying byte-for-byte below.
const isPackumentKey =
key.length > 0 &&
!key.includes('/-/') &&
(key.startsWith('@') ? key.split('/').length === 2 : !key.includes('/'));
if ((req.method ?? 'GET') === 'GET' && isPackumentKey) {
const packument = await resolveProxiedPackument(key);
if (packument) {
const address = server.address();
const registry =
address && typeof address !== 'string' ? `http://127.0.0.1:${address.port}` : '';
res.writeHead(200, { 'content-type': 'application/json' });
res.end(serializeForRegistry(packument, registry));
return;
}
}
// Proxy anything we don't mock (pnpm/latest, tarball downloads for real
// packages, etc.) to the upstream registry. Keeps the local packages in
// charge while letting everything else (package-manager download, real
Expand Down
Loading
Loading