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/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-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" + ] +} 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..3986610da6 --- /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 + +◇ 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 + +> 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)" + ] +} diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index ede96e2fea..c98d117ceb 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -247,6 +247,35 @@ 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', + voltaNodeVersion: '20.5.0', + }); + }); + + 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', voltaPresent: true }); + }); + + 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', () => { @@ -260,6 +289,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' }); @@ -291,6 +342,77 @@ 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', () => { + 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'); + }); + + it('sets nodeVersionFileMigrated and manualSteps in report for volta migration', () => { + 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', voltaNodeVersion: '20.5.0' }, + report, + ); + expect(report.nodeVersionFileMigrated).toBe(true); + expect(report.manualSteps).toContain('Remove the "volta" field from package.json'); + }); + + 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, + 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', voltaNodeVersion: '20' }, + report, + ); + expect(ok).toBe(false); + expect(report.warnings.length).toBe(1); + expect(fs.existsSync(path.join(tmpDir, '.node-version'))).toBe(false); + }); }); function makeWorkspaceInfo( 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) diff --git a/packages/cli/src/migration/detector.ts b/packages/cli/src/migration/detector.ts index c820a5bb3b..4614d03bbd 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?: string; } // 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,25 @@ 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; + } + + const voltaNode = pkg.volta?.node; + if (typeof voltaNode === 'string') { + configs.voltaNode = voltaNode; + } + } catch { + // ignore parse errors + } + } + return configs; } diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index bc2069d0a0..eeceaacd97 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -2267,12 +2267,14 @@ function setPackageManager( }); } -export interface NodeVersionManagerDetection { - file: string; -} +export type NodeVersionManagerDetection = + | { file: '.nvmrc'; voltaPresent?: true } + | { file: 'package.json'; voltaNodeVersion: string }; /** * 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( @@ -2284,9 +2286,18 @@ export function detectNodeVersionManagerFile( } const configs = detectConfigs(projectPath); + + // .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) { + return { file: 'package.json', voltaNodeVersion: configs.voltaNode }; } + return undefined; } @@ -2328,16 +2339,45 @@ 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: node version was already extracted during detection — no package.json re-read needed + if (detection.file === 'package.json') { + const { voltaNodeVersion } = detection; + + // 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 "${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, `${resolvedVersion}\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; + } + + // .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); @@ -2363,6 +2403,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; }