From 858251dfc64310dc72aeaf78703c6280b2896264 Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Mon, 30 Mar 2026 00:34:51 +0900 Subject: [PATCH 01/18] feat(migrate): add support for detecting Volta node version in package.json --- packages/cli/src/migration/detector.ts | 38 +++++++++++++++----------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/migration/detector.ts b/packages/cli/src/migration/detector.ts index c820a5bb3b..6cda07aad2 100644 --- a/packages/cli/src/migration/detector.ts +++ b/packages/cli/src/migration/detector.ts @@ -12,6 +12,7 @@ export interface ConfigFiles { prettierConfig?: string; // e.g. '.prettierrc.json', 'prettier.config.js', PRETTIER_PACKAGE_JSON_CONFIG prettierIgnore?: boolean; nvmrcFile?: boolean; + voltaNode?: boolean; } // Sentinel value indicating Prettier config lives inside package.json "prettier" key. @@ -158,22 +159,6 @@ export function detectConfigs(projectPath: string): ConfigFiles { break; } } - // Check for "prettier" key in package.json if no config file found - if (!configs.prettierConfig) { - const packageJsonPath = path.join(projectPath, 'package.json'); - if (fs.existsSync(packageJsonPath)) { - try { - const content = fs.readFileSync(packageJsonPath, 'utf8'); - const pkg = JSON.parse(content); - if (pkg.prettier) { - configs.prettierConfig = PRETTIER_PACKAGE_JSON_CONFIG; - } - } catch { - // ignore parse errors - } - } - } - // Check for .prettierignore if (fs.existsSync(path.join(projectPath, '.prettierignore'))) { configs.prettierIgnore = true; @@ -184,5 +169,26 @@ export function detectConfigs(projectPath: string): ConfigFiles { configs.nvmrcFile = true; } + // Check package.json for "prettier" key and Volta node version + const packageJsonPath = path.join(projectPath, 'package.json'); + + if (fs.existsSync(packageJsonPath)) { + try { + const content = fs.readFileSync(packageJsonPath, 'utf8'); + const pkg = JSON.parse(content); + + if (!configs.prettierConfig && pkg.prettier) { + configs.prettierConfig = PRETTIER_PACKAGE_JSON_CONFIG; + } + + if (typeof pkg.volta?.node === 'string') { + configs.voltaNode = true; + } + + } catch { + // ignore parse errors + } + } + return configs; } From cce3235e0727bfdcf53c0b2087a9b5fa1b39650f Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:13:45 +0900 Subject: [PATCH 02/18] feat(migrate): add migration for Volta node version from package.json to .node-version --- packages/cli/src/migration/migrator.ts | 45 +++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index a1ce5c753a..88cda4083e 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -2097,6 +2097,10 @@ export function detectNodeVersionManagerFile( if (configs.nvmrcFile) { return { file: '.nvmrc' }; } + + if (configs.voltaNode) { + return { file: 'package.json' }; + } return undefined; } @@ -2143,7 +2147,6 @@ export function parseNvmrcVersion(alias: string): string | null { */ export function migrateNodeVersionManagerFile( projectPath: string, - _detection: NodeVersionManagerDetection, report?: MigrationReport, ): boolean { const sourcePath = path.join(projectPath, '.nvmrc'); @@ -2176,3 +2179,43 @@ export function migrateNodeVersionManagerFile( } return true; } + +/** + * Migrate Volta node version from package.json to .node-version. + * The volta field in package.json is left as-is; removal is left to the user's discretion. + * Returns true on success, false if migration was skipped or failed. + */ +export function migrateVoltaNodeVersion(projectPath: string, report?: MigrationReport): boolean { + const packageJsonPath = path.join(projectPath, 'package.json'); + const nodeVersionPath = path.join(projectPath, '.node-version'); + + const pkg: Record | null = (() => { + try { + return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as Record; + } catch { + return null; + } + })(); + + if (!pkg) { + warnMigration('Failed to parse package.json. Create .node-version manually.', report); + return false; + } + + const voltaNode = (pkg.volta as Record | undefined)?.node; + if (typeof voltaNode !== 'string' || !semver.valid(voltaNode)) { + warnMigration( + 'package.json volta.node is not a valid version. Create .node-version manually with your desired Node.js version.', + report, + ); + return false; + } + + fs.writeFileSync(nodeVersionPath, `${voltaNode}\n`); + prompts.log.info('You can now remove the "volta" field from package.json manually.'); + + if (report) { + report.nodeVersionFileMigrated = true; + } + return true; +} From bc8bebebc7248f851ea028cb0b97ca936967caeb Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:13:58 +0900 Subject: [PATCH 03/18] fix: remove unnecessary whitespace in detectConfigs function --- packages/cli/src/migration/detector.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli/src/migration/detector.ts b/packages/cli/src/migration/detector.ts index 6cda07aad2..e235834bcb 100644 --- a/packages/cli/src/migration/detector.ts +++ b/packages/cli/src/migration/detector.ts @@ -171,7 +171,7 @@ export function detectConfigs(projectPath: string): ConfigFiles { // Check package.json for "prettier" key and Volta node version const packageJsonPath = path.join(projectPath, 'package.json'); - + if (fs.existsSync(packageJsonPath)) { try { const content = fs.readFileSync(packageJsonPath, 'utf8'); @@ -184,7 +184,6 @@ export function detectConfigs(projectPath: string): ConfigFiles { if (typeof pkg.volta?.node === 'string') { configs.voltaNode = true; } - } catch { // ignore parse errors } From 9077945ffca24d4aa55e3c38cafd13a11ce91ae5 Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:33:05 +0900 Subject: [PATCH 04/18] refactor(migrate): remove migrateVoltaNodeVersion function and related logic --- packages/cli/src/migration/migrator.ts | 45 +------------------------- 1 file changed, 1 insertion(+), 44 deletions(-) diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 88cda4083e..a1ce5c753a 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -2097,10 +2097,6 @@ export function detectNodeVersionManagerFile( if (configs.nvmrcFile) { return { file: '.nvmrc' }; } - - if (configs.voltaNode) { - return { file: 'package.json' }; - } return undefined; } @@ -2147,6 +2143,7 @@ export function parseNvmrcVersion(alias: string): string | null { */ export function migrateNodeVersionManagerFile( projectPath: string, + _detection: NodeVersionManagerDetection, report?: MigrationReport, ): boolean { const sourcePath = path.join(projectPath, '.nvmrc'); @@ -2179,43 +2176,3 @@ export function migrateNodeVersionManagerFile( } return true; } - -/** - * Migrate Volta node version from package.json to .node-version. - * The volta field in package.json is left as-is; removal is left to the user's discretion. - * Returns true on success, false if migration was skipped or failed. - */ -export function migrateVoltaNodeVersion(projectPath: string, report?: MigrationReport): boolean { - const packageJsonPath = path.join(projectPath, 'package.json'); - const nodeVersionPath = path.join(projectPath, '.node-version'); - - const pkg: Record | null = (() => { - try { - return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as Record; - } catch { - return null; - } - })(); - - if (!pkg) { - warnMigration('Failed to parse package.json. Create .node-version manually.', report); - return false; - } - - const voltaNode = (pkg.volta as Record | undefined)?.node; - if (typeof voltaNode !== 'string' || !semver.valid(voltaNode)) { - warnMigration( - 'package.json volta.node is not a valid version. Create .node-version manually with your desired Node.js version.', - report, - ); - return false; - } - - fs.writeFileSync(nodeVersionPath, `${voltaNode}\n`); - prompts.log.info('You can now remove the "volta" field from package.json manually.'); - - if (report) { - report.nodeVersionFileMigrated = true; - } - return true; -} From cc23ccd5c7a800050ba9e1c14ec503ff1edb0dd3 Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:49:39 +0900 Subject: [PATCH 05/18] feat(migrate): support migrating Volta node version from package.json to .node-version --- packages/cli/src/migration/migrator.ts | 48 ++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index a1ce5c753a..5f843effee 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -2097,6 +2097,11 @@ export function detectNodeVersionManagerFile( if (configs.nvmrcFile) { return { file: '.nvmrc' }; } + + if (configs.voltaNode) { + return { file: 'package.json' }; + } + return undefined; } @@ -2138,16 +2143,53 @@ export function parseNvmrcVersion(alias: string): string | null { } /** - * Migrate .nvmrc to .node-version and remove .nvmrc. + * Migrate .nvmrc or Volta node version from package.json to .node-version. + * - For .nvmrc: the source file is removed after migration. + * - For package.json (Volta): the volta field is left as-is; removal is left to the user's discretion. * Returns true on success, false if migration was skipped or failed. */ export function migrateNodeVersionManagerFile( projectPath: string, - _detection: NodeVersionManagerDetection, + detection: NodeVersionManagerDetection, report?: MigrationReport, ): boolean { - const sourcePath = path.join(projectPath, '.nvmrc'); const nodeVersionPath = path.join(projectPath, '.node-version'); + + // Volta: read node version from package.json volta.node field + if (detection.file === 'package.json') { + const packageJsonPath = path.join(projectPath, 'package.json'); + const pkg: Record | null = (() => { + try { + return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as Record; + } catch { + return null; + } + })(); + + if (!pkg) { + warnMigration('Failed to parse package.json. Create .node-version manually.', report); + return false; + } + + const voltaNode = (pkg.volta as Record | undefined)?.node; + if (typeof voltaNode !== 'string' || !semver.valid(voltaNode)) { + warnMigration( + 'package.json volta.node is not a valid version. Create .node-version manually with your desired Node.js version.', + report, + ); + return false; + } + + fs.writeFileSync(nodeVersionPath, `${voltaNode}\n`); + prompts.log.info('You can now remove the "volta" field from package.json manually.'); + if (report) { + report.nodeVersionFileMigrated = true; + } + return true; + } + + // .nvmrc: parse version alias and write to .node-version + const sourcePath = path.join(projectPath, '.nvmrc'); const content = fs.readFileSync(sourcePath, 'utf8'); const originalAlias = content.split('\n')[0]?.trim() ?? ''; const version = parseNvmrcVersion(originalAlias); From 3a77e82b42ab4a7a290ec25950de169d63f16538 Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:49:45 +0900 Subject: [PATCH 06/18] feat(migrate): add tests for detecting and migrating Volta node version in package.json --- .../src/migration/__tests__/migrator.spec.ts | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 47a9086448..1fc025b28f 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -191,6 +191,23 @@ describe('detectNodeVersionManagerFile', () => { fs.writeFileSync(path.join(tmpDir, '.nvmrc'), 'v20.5.0\n'); expect(detectNodeVersionManagerFile(tmpDir)).toEqual({ file: '.nvmrc' }); }); + + it('detects volta node in package.json', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ volta: { node: '20.5.0' } }), + ); + expect(detectNodeVersionManagerFile(tmpDir)).toEqual({ file: 'package.json' }); + }); + + it('returns undefined when .node-version already exists even with volta', () => { + fs.writeFileSync(path.join(tmpDir, '.node-version'), '20.5.0\n'); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ volta: { node: '20.5.0' } }), + ); + expect(detectNodeVersionManagerFile(tmpDir)).toBeUndefined(); + }); }); describe('migrateNodeVersionManagerFile', () => { @@ -235,4 +252,63 @@ describe('migrateNodeVersionManagerFile', () => { expect(report.warnings.length).toBe(1); expect(fs.existsSync(path.join(tmpDir, '.node-version'))).toBe(false); }); + + it('migrates volta node version to .node-version and leaves package.json intact', () => { + const pkg = { name: 'my-app', volta: { node: '20.5.0' } }; + fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(pkg)); + const ok = migrateNodeVersionManagerFile(tmpDir, { file: 'package.json' }); + expect(ok).toBe(true); + expect(fs.readFileSync(path.join(tmpDir, '.node-version'), 'utf8')).toBe('20.5.0\n'); + // package.json must not be modified + expect(JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'))).toEqual(pkg); + }); + + it('sets nodeVersionFileMigrated in report for volta migration', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ volta: { node: '20.5.0' } }), + ); + const report = { + createdViteConfigCount: 0, + mergedConfigCount: 0, + mergedStagedConfigCount: 0, + inlinedLintStagedConfigCount: 0, + removedConfigCount: 0, + tsdownImportCount: 0, + rewrittenImportFileCount: 0, + rewrittenImportErrors: [], + eslintMigrated: false, + prettierMigrated: false, + nodeVersionFileMigrated: false, + gitHooksConfigured: false, + warnings: [], + manualSteps: [], + }; + migrateNodeVersionManagerFile(tmpDir, { file: 'package.json' }, report); + expect(report.nodeVersionFileMigrated).toBe(true); + }); + + it('returns false and warns when volta.node is not a valid semver', () => { + fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ volta: { node: 'lts' } })); + const report = { + createdViteConfigCount: 0, + mergedConfigCount: 0, + mergedStagedConfigCount: 0, + inlinedLintStagedConfigCount: 0, + removedConfigCount: 0, + tsdownImportCount: 0, + rewrittenImportFileCount: 0, + rewrittenImportErrors: [], + eslintMigrated: false, + prettierMigrated: false, + nodeVersionFileMigrated: false, + gitHooksConfigured: false, + warnings: [], + manualSteps: [], + }; + const ok = migrateNodeVersionManagerFile(tmpDir, { file: 'package.json' }, report); + expect(ok).toBe(false); + expect(report.warnings.length).toBe(1); + expect(fs.existsSync(path.join(tmpDir, '.node-version'))).toBe(false); + }); }); From c87fddb4b49d476cf7597e967506a64eef4e0b16 Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:54:31 +0900 Subject: [PATCH 07/18] fix(migrate): update NodeVersionManagerDetection to accept specific file types --- packages/cli/src/migration/migrator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 5f843effee..539eef8192 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -2078,7 +2078,7 @@ function setPackageManager( } export interface NodeVersionManagerDetection { - file: string; + file: '.nvmrc' | 'package.json'; } /** From 729ca56cb968f3c41dcbd4a4e8c530474e871064 Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:54:38 +0900 Subject: [PATCH 08/18] feat(migrate): enhance confirmNodeVersionFileMigration to support multiple file types --- packages/cli/src/migration/bin.ts | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 5e39af962a..c9cc43076a 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -171,10 +171,19 @@ async function promptPrettierMigration( return true; } -async function confirmNodeVersionFileMigration(interactive: boolean): Promise { +async function confirmNodeVersionFileMigration( + interactive: boolean, + detection: NodeVersionManagerDetection, +): Promise { + const confirmMessageByFile = { + 'package.json': 'Migrate Volta node version (package.json) to .node-version?', + '.nvmrc': 'Migrate .nvmrc to .node-version?', + } as const satisfies Record; + + const message = confirmMessageByFile[detection.file]; if (interactive) { const confirmed = await prompts.confirm({ - message: 'Migrate .nvmrc to .node-version?', + message, initialValue: true, }); if (prompts.isCancel(confirmed)) { @@ -459,7 +468,10 @@ async function collectMigrationPlan( const nodeVersionDetection = detectNodeVersionManagerFile(rootDir); let migrateNodeVersionFile = false; if (nodeVersionDetection) { - migrateNodeVersionFile = await confirmNodeVersionFileMigration(options.interactive); + migrateNodeVersionFile = await confirmNodeVersionFileMigration( + options.interactive, + nodeVersionDetection, + ); } const plan: MigrationPlan = { @@ -859,7 +871,10 @@ async function main() { // Check if node version manager file migration is needed const nodeVersionDetection = detectNodeVersionManagerFile(workspaceInfoOptional.rootDir); if (nodeVersionDetection) { - const confirmed = await confirmNodeVersionFileMigration(options.interactive); + const confirmed = await confirmNodeVersionFileMigration( + options.interactive, + nodeVersionDetection, + ); if ( confirmed && migrateNodeVersionManagerFile(workspaceInfoOptional.rootDir, nodeVersionDetection, report) From 6eae815b01c23d687a667056f5919b08ad85657d Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:06:08 +0900 Subject: [PATCH 09/18] feat(migrate): add migration configuration files for Volta node version --- .../migration-volta/package.json | 10 ++++++++++ .../snap-tests-global/migration-volta/snap.txt | 15 +++++++++++++++ .../snap-tests-global/migration-volta/steps.json | 7 +++++++ 3 files changed, 32 insertions(+) create mode 100644 packages/cli/snap-tests-global/migration-volta/package.json create mode 100644 packages/cli/snap-tests-global/migration-volta/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-volta/steps.json diff --git a/packages/cli/snap-tests-global/migration-volta/package.json b/packages/cli/snap-tests-global/migration-volta/package.json new file mode 100644 index 0000000000..d8f6102082 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-volta/package.json @@ -0,0 +1,10 @@ +{ + "name": "migration-volta", + "devDependencies": { + "vite": "^7.0.0" + }, + "volta": { + "node": "20.5.0", + "npm": "10.2.5" + } +} diff --git a/packages/cli/snap-tests-global/migration-volta/snap.txt b/packages/cli/snap-tests-global/migration-volta/snap.txt new file mode 100644 index 0000000000..fb4f52b28b --- /dev/null +++ b/packages/cli/snap-tests-global/migration-volta/snap.txt @@ -0,0 +1,15 @@ +> vp migrate --no-interactive # migration should detect volta.node in package.json and migrate to .node-version +VITE+ - The Unified Toolchain for the Web + + +You can now remove the "volta" field from package.json manually. +◇ Migrated . to Vite+ +• Node pnpm +• 2 config updates applied +• Node version manager file migrated to .node-version + +> cat .node-version # check .node-version is created from volta.node +20.5.0 + +> grep '"volta"' package.json # check volta field is preserved in package.json (not removed) + "volta": { diff --git a/packages/cli/snap-tests-global/migration-volta/steps.json b/packages/cli/snap-tests-global/migration-volta/steps.json new file mode 100644 index 0000000000..08a83c851f --- /dev/null +++ b/packages/cli/snap-tests-global/migration-volta/steps.json @@ -0,0 +1,7 @@ +{ + "commands": [ + "vp migrate --no-interactive # migration should detect volta.node in package.json and migrate to .node-version", + "cat .node-version # check .node-version is created from volta.node", + "grep '\"volta\"' package.json # check volta field is preserved in package.json (not removed)" + ] +} From d1e44531be345d7da427eb143372838be74979cd Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:16:07 +0900 Subject: [PATCH 10/18] feat(migrate): prioritize .nvmrc over volta.node in Node version manager detection --- packages/cli/src/migration/migrator.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 539eef8192..49ccfcbf02 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -2094,6 +2094,8 @@ export function detectNodeVersionManagerFile( } const configs = detectConfigs(projectPath); + + // .nvmrc takes priority over volta.node when both are present if (configs.nvmrcFile) { return { file: '.nvmrc' }; } From 6cfa4cece5775d09fffa41ff5d25a785d9ff078c Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:26:31 +0900 Subject: [PATCH 11/18] feat(migrate): update voltaNode detection to store version string and simplify migration logic --- packages/cli/src/migration/detector.ts | 7 +++--- packages/cli/src/migration/migrator.ts | 34 +++++++++----------------- 2 files changed, 15 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/migration/detector.ts b/packages/cli/src/migration/detector.ts index e235834bcb..c34c5578c5 100644 --- a/packages/cli/src/migration/detector.ts +++ b/packages/cli/src/migration/detector.ts @@ -12,7 +12,7 @@ export interface ConfigFiles { prettierConfig?: string; // e.g. '.prettierrc.json', 'prettier.config.js', PRETTIER_PACKAGE_JSON_CONFIG prettierIgnore?: boolean; nvmrcFile?: boolean; - voltaNode?: boolean; + voltaNode?: string; } // Sentinel value indicating Prettier config lives inside package.json "prettier" key. @@ -181,8 +181,9 @@ export function detectConfigs(projectPath: string): ConfigFiles { configs.prettierConfig = PRETTIER_PACKAGE_JSON_CONFIG; } - if (typeof pkg.volta?.node === 'string') { - configs.voltaNode = true; + const voltaNode = pkg.volta?.node; + if (typeof voltaNode === 'string') { + configs.voltaNode = voltaNode; } } catch { // ignore parse errors diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 49ccfcbf02..5b0bfda13f 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -2077,9 +2077,9 @@ function setPackageManager( }); } -export interface NodeVersionManagerDetection { - file: '.nvmrc' | 'package.json'; -} +export type NodeVersionManagerDetection = + | { file: '.nvmrc' } + | { file: 'package.json'; voltaNodeVersion: string }; /** * Detect a .nvmrc file in the project directory. @@ -2101,7 +2101,7 @@ export function detectNodeVersionManagerFile( } if (configs.voltaNode) { - return { file: 'package.json' }; + return { file: 'package.json', voltaNodeVersion: configs.voltaNode }; } return undefined; @@ -2157,24 +2157,10 @@ export function migrateNodeVersionManagerFile( ): boolean { const nodeVersionPath = path.join(projectPath, '.node-version'); - // Volta: read node version from package.json volta.node field + // Volta: node version was already extracted during detection — no package.json re-read needed if (detection.file === 'package.json') { - const packageJsonPath = path.join(projectPath, 'package.json'); - const pkg: Record | null = (() => { - try { - return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as Record; - } catch { - return null; - } - })(); - - if (!pkg) { - warnMigration('Failed to parse package.json. Create .node-version manually.', report); - return false; - } - - const voltaNode = (pkg.volta as Record | undefined)?.node; - if (typeof voltaNode !== 'string' || !semver.valid(voltaNode)) { + const { voltaNodeVersion } = detection; + if (!semver.valid(voltaNodeVersion)) { warnMigration( 'package.json volta.node is not a valid version. Create .node-version manually with your desired Node.js version.', report, @@ -2182,10 +2168,12 @@ export function migrateNodeVersionManagerFile( return false; } - fs.writeFileSync(nodeVersionPath, `${voltaNode}\n`); - prompts.log.info('You can now remove the "volta" field from package.json manually.'); + fs.writeFileSync(nodeVersionPath, `${voltaNodeVersion}\n`); if (report) { + report.manualSteps.push('Remove the "volta" field from package.json'); report.nodeVersionFileMigrated = true; + } else { + prompts.log.info('You can now remove the "volta" field from package.json manually.'); } return true; } From 5a74669d8a7ef47c298ba834c8cde294d51c37c3 Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:26:39 +0900 Subject: [PATCH 12/18] feat(migrate): enhance detectNodeVersionManagerFile to return voltaNodeVersion and prefer .nvmrc --- .../src/migration/__tests__/migrator.spec.ts | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 1fc025b28f..c2b76d41a3 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -197,7 +197,19 @@ describe('detectNodeVersionManagerFile', () => { path.join(tmpDir, 'package.json'), JSON.stringify({ volta: { node: '20.5.0' } }), ); - expect(detectNodeVersionManagerFile(tmpDir)).toEqual({ file: 'package.json' }); + expect(detectNodeVersionManagerFile(tmpDir)).toEqual({ + file: 'package.json', + voltaNodeVersion: '20.5.0', + }); + }); + + it('prefers .nvmrc over volta when both are present', () => { + fs.writeFileSync(path.join(tmpDir, '.nvmrc'), 'v20.5.0\n'); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ volta: { node: '18.0.0' } }), + ); + expect(detectNodeVersionManagerFile(tmpDir)).toEqual({ file: '.nvmrc' }); }); it('returns undefined when .node-version already exists even with volta', () => { @@ -253,21 +265,16 @@ describe('migrateNodeVersionManagerFile', () => { expect(fs.existsSync(path.join(tmpDir, '.node-version'))).toBe(false); }); - it('migrates volta node version to .node-version and leaves package.json intact', () => { - const pkg = { name: 'my-app', volta: { node: '20.5.0' } }; - fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(pkg)); - const ok = migrateNodeVersionManagerFile(tmpDir, { file: 'package.json' }); + it('migrates volta node version to .node-version', () => { + const ok = migrateNodeVersionManagerFile(tmpDir, { + file: 'package.json', + voltaNodeVersion: '20.5.0', + }); expect(ok).toBe(true); expect(fs.readFileSync(path.join(tmpDir, '.node-version'), 'utf8')).toBe('20.5.0\n'); - // package.json must not be modified - expect(JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'))).toEqual(pkg); }); - it('sets nodeVersionFileMigrated in report for volta migration', () => { - fs.writeFileSync( - path.join(tmpDir, 'package.json'), - JSON.stringify({ volta: { node: '20.5.0' } }), - ); + it('sets nodeVersionFileMigrated and manualSteps in report for volta migration', () => { const report = { createdViteConfigCount: 0, mergedConfigCount: 0, @@ -284,12 +291,12 @@ describe('migrateNodeVersionManagerFile', () => { warnings: [], manualSteps: [], }; - migrateNodeVersionManagerFile(tmpDir, { file: 'package.json' }, report); + migrateNodeVersionManagerFile(tmpDir, { file: 'package.json', voltaNodeVersion: '20.5.0' }, report); expect(report.nodeVersionFileMigrated).toBe(true); + expect(report.manualSteps).toContain('Remove the "volta" field from package.json'); }); it('returns false and warns when volta.node is not a valid semver', () => { - fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ volta: { node: 'lts' } })); const report = { createdViteConfigCount: 0, mergedConfigCount: 0, @@ -306,7 +313,11 @@ describe('migrateNodeVersionManagerFile', () => { warnings: [], manualSteps: [], }; - const ok = migrateNodeVersionManagerFile(tmpDir, { file: 'package.json' }, report); + const ok = migrateNodeVersionManagerFile( + tmpDir, + { file: 'package.json', voltaNodeVersion: 'lts' }, + report, + ); expect(ok).toBe(false); expect(report.warnings.length).toBe(1); expect(fs.existsSync(path.join(tmpDir, '.node-version'))).toBe(false); From 47f49e450c15fcc78bb821ca6eee620968fb59ed Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:27:04 +0900 Subject: [PATCH 13/18] docs(migration): update snap.txt to clarify manual removal of "volta" field from package.json --- packages/cli/snap-tests-global/migration-volta/snap.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/snap-tests-global/migration-volta/snap.txt b/packages/cli/snap-tests-global/migration-volta/snap.txt index fb4f52b28b..8486d52914 100644 --- a/packages/cli/snap-tests-global/migration-volta/snap.txt +++ b/packages/cli/snap-tests-global/migration-volta/snap.txt @@ -2,11 +2,12 @@ VITE+ - The Unified Toolchain for the Web -You can now remove the "volta" field from package.json manually. ◇ Migrated . to Vite+ • Node pnpm • 2 config updates applied • Node version manager file migrated to .node-version +→ Manual follow-up: + - Remove the "volta" field from package.json > cat .node-version # check .node-version is created from volta.node 20.5.0 From 18683a3659107af658f4cef1554dc9c59e6df294 Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:27:10 +0900 Subject: [PATCH 14/18] feat(migrate): add migration configuration files for .nvmrc and steps for Volta integration --- .../migration-volta-with-nvmrc/.nvmrc | 1 + .../migration-volta-with-nvmrc/package.json | 10 ++++++++++ .../migration-volta-with-nvmrc/steps.json | 8 ++++++++ 3 files changed, 19 insertions(+) create mode 100644 packages/cli/snap-tests-global/migration-volta-with-nvmrc/.nvmrc create mode 100644 packages/cli/snap-tests-global/migration-volta-with-nvmrc/package.json create mode 100644 packages/cli/snap-tests-global/migration-volta-with-nvmrc/steps.json diff --git a/packages/cli/snap-tests-global/migration-volta-with-nvmrc/.nvmrc b/packages/cli/snap-tests-global/migration-volta-with-nvmrc/.nvmrc new file mode 100644 index 0000000000..1df6fd41c7 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-volta-with-nvmrc/.nvmrc @@ -0,0 +1 @@ +v20.5.0 diff --git a/packages/cli/snap-tests-global/migration-volta-with-nvmrc/package.json b/packages/cli/snap-tests-global/migration-volta-with-nvmrc/package.json new file mode 100644 index 0000000000..647424901f --- /dev/null +++ b/packages/cli/snap-tests-global/migration-volta-with-nvmrc/package.json @@ -0,0 +1,10 @@ +{ + "name": "migration-volta-with-nvmrc", + "devDependencies": { + "vite": "^7.0.0" + }, + "volta": { + "node": "18.0.0", + "npm": "9.0.0" + } +} diff --git a/packages/cli/snap-tests-global/migration-volta-with-nvmrc/steps.json b/packages/cli/snap-tests-global/migration-volta-with-nvmrc/steps.json new file mode 100644 index 0000000000..498900d554 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-volta-with-nvmrc/steps.json @@ -0,0 +1,8 @@ +{ + "commands": [ + "vp migrate --no-interactive # .nvmrc should take priority over volta.node", + "cat .node-version # check .node-version comes from .nvmrc (v20.5.0), not volta.node (18.0.0)", + "test ! -f .nvmrc # check .nvmrc is removed", + "grep '\"volta\"' package.json # volta field must remain intact" + ] +} From daa26a88cdab9bebc0843e9561acc6600ceff850 Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:33:38 +0900 Subject: [PATCH 15/18] feat(migrate): enhance migration logic to include volta presence and normalize version handling --- .../src/migration/__tests__/migrator.spec.ts | 39 +++++++++++++++++-- packages/cli/src/migration/migrator.ts | 24 +++++++++--- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index c2b76d41a3..33fdd0a644 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -203,13 +203,13 @@ describe('detectNodeVersionManagerFile', () => { }); }); - it('prefers .nvmrc over volta when both are present', () => { + it('prefers .nvmrc over volta when both are present and sets voltaPresent', () => { fs.writeFileSync(path.join(tmpDir, '.nvmrc'), 'v20.5.0\n'); fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ volta: { node: '18.0.0' } }), ); - expect(detectNodeVersionManagerFile(tmpDir)).toEqual({ file: '.nvmrc' }); + expect(detectNodeVersionManagerFile(tmpDir)).toEqual({ file: '.nvmrc', voltaPresent: true }); }); it('returns undefined when .node-version already exists even with volta', () => { @@ -233,6 +233,28 @@ describe('migrateNodeVersionManagerFile', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + it('adds volta manual step when voltaPresent is set', () => { + fs.writeFileSync(path.join(tmpDir, '.nvmrc'), 'v20.5.0\n'); + const report = { + createdViteConfigCount: 0, + mergedConfigCount: 0, + mergedStagedConfigCount: 0, + inlinedLintStagedConfigCount: 0, + removedConfigCount: 0, + tsdownImportCount: 0, + rewrittenImportFileCount: 0, + rewrittenImportErrors: [], + eslintMigrated: false, + prettierMigrated: false, + nodeVersionFileMigrated: false, + gitHooksConfigured: false, + warnings: [], + manualSteps: [], + }; + migrateNodeVersionManagerFile(tmpDir, { file: '.nvmrc', voltaPresent: true }, report); + expect(report.manualSteps).toContain('Remove the "volta" field from package.json'); + }); + it('migrates .nvmrc to .node-version and removes .nvmrc', () => { fs.writeFileSync(path.join(tmpDir, '.nvmrc'), 'v20.5.0\n'); const ok = migrateNodeVersionManagerFile(tmpDir, { file: '.nvmrc' }); @@ -296,7 +318,16 @@ describe('migrateNodeVersionManagerFile', () => { expect(report.manualSteps).toContain('Remove the "volta" field from package.json'); }); - it('returns false and warns when volta.node is not a valid semver', () => { + it('normalizes volta.node "lts" to "lts/*"', () => { + const ok = migrateNodeVersionManagerFile(tmpDir, { + file: 'package.json', + voltaNodeVersion: 'lts', + }); + expect(ok).toBe(true); + expect(fs.readFileSync(path.join(tmpDir, '.node-version'), 'utf8')).toBe('lts/*\n'); + }); + + it('returns false and warns when volta.node is a partial version', () => { const report = { createdViteConfigCount: 0, mergedConfigCount: 0, @@ -315,7 +346,7 @@ describe('migrateNodeVersionManagerFile', () => { }; const ok = migrateNodeVersionManagerFile( tmpDir, - { file: 'package.json', voltaNodeVersion: 'lts' }, + { file: 'package.json', voltaNodeVersion: '20' }, report, ); expect(ok).toBe(false); diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 5b0bfda13f..74023d377c 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -2078,7 +2078,7 @@ function setPackageManager( } export type NodeVersionManagerDetection = - | { file: '.nvmrc' } + | { file: '.nvmrc'; voltaPresent?: true } | { file: 'package.json'; voltaNodeVersion: string }; /** @@ -2095,9 +2095,11 @@ export function detectNodeVersionManagerFile( const configs = detectConfigs(projectPath); - // .nvmrc takes priority over volta.node when both are present + // .nvmrc takes priority over volta.node when both are present. + // voltaPresent is carried through so the migration step can remind the user + // to remove the now-redundant volta field from package.json. if (configs.nvmrcFile) { - return { file: '.nvmrc' }; + return configs.voltaNode ? { file: '.nvmrc', voltaPresent: true } : { file: '.nvmrc' }; } if (configs.voltaNode) { @@ -2160,15 +2162,19 @@ export function migrateNodeVersionManagerFile( // Volta: node version was already extracted during detection — no package.json re-read needed if (detection.file === 'package.json') { const { voltaNodeVersion } = detection; - if (!semver.valid(voltaNodeVersion)) { + + // Normalize Volta's "lts" alias to the .node-version compatible form + const resolvedVersion = voltaNodeVersion === 'lts' ? 'lts/*' : voltaNodeVersion; + + if (!semver.valid(resolvedVersion) && resolvedVersion !== 'lts/*') { warnMigration( - 'package.json volta.node is not a valid version. Create .node-version manually with your desired Node.js version.', + `package.json volta.node "${voltaNodeVersion}" is not an exact version. Pin an exact version (e.g. ${voltaNodeVersion}.0 or run \`volta pin node@${voltaNodeVersion}\`) then re-run migration.`, report, ); return false; } - fs.writeFileSync(nodeVersionPath, `${voltaNodeVersion}\n`); + fs.writeFileSync(nodeVersionPath, `${resolvedVersion}\n`); if (report) { report.manualSteps.push('Remove the "volta" field from package.json'); report.nodeVersionFileMigrated = true; @@ -2205,6 +2211,12 @@ export function migrateNodeVersionManagerFile( if (report) { report.nodeVersionFileMigrated = true; + // Both .nvmrc and volta were present; .nvmrc was migrated but volta still lingers. + if (detection.voltaPresent) { + report.manualSteps.push('Remove the "volta" field from package.json'); + } + } else if (detection.voltaPresent) { + prompts.log.info('You can now remove the "volta" field from package.json manually.'); } return true; } From 7d3f229ec5d8ac49ec31df95ddfc005e1d98f2d0 Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:37:15 +0900 Subject: [PATCH 16/18] style(tests): format migrateNodeVersionManagerFile test for better readability --- packages/cli/src/migration/__tests__/migrator.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 33fdd0a644..2e3ebbaee6 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -313,7 +313,11 @@ describe('migrateNodeVersionManagerFile', () => { warnings: [], manualSteps: [], }; - migrateNodeVersionManagerFile(tmpDir, { file: 'package.json', voltaNodeVersion: '20.5.0' }, report); + migrateNodeVersionManagerFile( + tmpDir, + { file: 'package.json', voltaNodeVersion: '20.5.0' }, + report, + ); expect(report.nodeVersionFileMigrated).toBe(true); expect(report.manualSteps).toContain('Remove the "volta" field from package.json'); }); From ef38f4a91cfbf450132197ed95aa3e943cbfd361 Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:18:02 +0900 Subject: [PATCH 17/18] docs(migration): update migration instructions to prioritize .nvmrc over volta.node --- .../migration-volta-with-nvmrc/snap.txt | 16 ++++++++++++++++ .../snap-tests-global/migration-volta/snap.txt | 1 - 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 packages/cli/snap-tests-global/migration-volta-with-nvmrc/snap.txt diff --git a/packages/cli/snap-tests-global/migration-volta-with-nvmrc/snap.txt b/packages/cli/snap-tests-global/migration-volta-with-nvmrc/snap.txt new file mode 100644 index 0000000000..1eb075e9f7 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-volta-with-nvmrc/snap.txt @@ -0,0 +1,16 @@ +> vp migrate --no-interactive # .nvmrc should take priority over volta.node +VITE+ - The Unified Toolchain for the Web + +◇ Migrated . to Vite+ +• Node pnpm +• 2 config updates applied +• Node version manager file migrated to .node-version +→ Manual follow-up: + - Remove the "volta" field from package.json + +> cat .node-version # check .node-version comes from .nvmrc (v20.5.0), not volta.node (18.0.0) +20.5.0 + +> test ! -f .nvmrc # check .nvmrc is removed +> grep '"volta"' package.json # volta field must remain intact + "volta": { diff --git a/packages/cli/snap-tests-global/migration-volta/snap.txt b/packages/cli/snap-tests-global/migration-volta/snap.txt index 8486d52914..3986610da6 100644 --- a/packages/cli/snap-tests-global/migration-volta/snap.txt +++ b/packages/cli/snap-tests-global/migration-volta/snap.txt @@ -1,7 +1,6 @@ > vp migrate --no-interactive # migration should detect volta.node in package.json and migrate to .node-version VITE+ - The Unified Toolchain for the Web - ◇ Migrated . to Vite+ • Node pnpm • 2 config updates applied From d94019ec13fc37f53a136bd2bd79b8640fbd95d6 Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Wed, 1 Apr 2026 01:44:59 +0900 Subject: [PATCH 18/18] feat(migrate): enhance node version detection to include Volta in package.json --- packages/cli/src/migration/detector.ts | 1 - packages/cli/src/migration/migrator.ts | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/migration/detector.ts b/packages/cli/src/migration/detector.ts index c34c5578c5..4614d03bbd 100644 --- a/packages/cli/src/migration/detector.ts +++ b/packages/cli/src/migration/detector.ts @@ -171,7 +171,6 @@ export function detectConfigs(projectPath: string): ConfigFiles { // Check package.json for "prettier" key and Volta node version const packageJsonPath = path.join(projectPath, 'package.json'); - if (fs.existsSync(packageJsonPath)) { try { const content = fs.readFileSync(packageJsonPath, 'utf8'); diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 74023d377c..ddf11d3cb5 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -2083,6 +2083,8 @@ export type NodeVersionManagerDetection = /** * Detect a .nvmrc file in the project directory. + * If not found, check for a Volta node version in package.json. + * If either is found, return the relevant info for migration. * Returns undefined if not found or .node-version already exists. */ export function detectNodeVersionManagerFile(