diff --git a/packages/cli/snap-tests-global/migration-nvmrc-lts/.nvmrc b/packages/cli/snap-tests-global/migration-nvmrc-lts/.nvmrc new file mode 100644 index 0000000000..9de2256827 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-nvmrc-lts/.nvmrc @@ -0,0 +1 @@ +lts/iron diff --git a/packages/cli/snap-tests-global/migration-nvmrc-lts/package.json b/packages/cli/snap-tests-global/migration-nvmrc-lts/package.json new file mode 100644 index 0000000000..9f77e80435 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-nvmrc-lts/package.json @@ -0,0 +1,6 @@ +{ + "name": "migration-nvmrc-lts", + "devDependencies": { + "vite": "^7.0.0" + } +} diff --git a/packages/cli/snap-tests-global/migration-nvmrc-lts/snap.txt b/packages/cli/snap-tests-global/migration-nvmrc-lts/snap.txt new file mode 100644 index 0000000000..674e0956ca --- /dev/null +++ b/packages/cli/snap-tests-global/migration-nvmrc-lts/snap.txt @@ -0,0 +1,12 @@ +> vp migrate --no-interactive # migration should detect .nvmrc with lts alias and auto-migrate +VITE+ - The Unified Toolchain for the Web + +◇ Migrated . to Vite+ +• Node pnpm +• 2 config updates applied +• Node version manager file migrated to .node-version + +> cat .node-version # check lts alias is preserved as-is +lts/iron + +> test ! -f .nvmrc # check .nvmrc is removed \ No newline at end of file diff --git a/packages/cli/snap-tests-global/migration-nvmrc-lts/steps.json b/packages/cli/snap-tests-global/migration-nvmrc-lts/steps.json new file mode 100644 index 0000000000..6b171382e1 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-nvmrc-lts/steps.json @@ -0,0 +1,7 @@ +{ + "commands": [ + "vp migrate --no-interactive # migration should detect .nvmrc with lts alias and auto-migrate", + "cat .node-version # check lts alias is preserved as-is", + "test ! -f .nvmrc # check .nvmrc is removed" + ] +} diff --git a/packages/cli/snap-tests-global/migration-nvmrc-node-alias/.nvmrc b/packages/cli/snap-tests-global/migration-nvmrc-node-alias/.nvmrc new file mode 100644 index 0000000000..64f5a0a681 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-nvmrc-node-alias/.nvmrc @@ -0,0 +1 @@ +node diff --git a/packages/cli/snap-tests-global/migration-nvmrc-node-alias/package.json b/packages/cli/snap-tests-global/migration-nvmrc-node-alias/package.json new file mode 100644 index 0000000000..aff9054c01 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-nvmrc-node-alias/package.json @@ -0,0 +1,6 @@ +{ + "name": "migration-nvmrc-node-alias", + "devDependencies": { + "vite": "^7.0.0" + } +} diff --git a/packages/cli/snap-tests-global/migration-nvmrc-node-alias/snap.txt b/packages/cli/snap-tests-global/migration-nvmrc-node-alias/snap.txt new file mode 100644 index 0000000000..d4ddebebb5 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-nvmrc-node-alias/snap.txt @@ -0,0 +1,14 @@ +> vp migrate --no-interactive # 'node' alias should be mapped to lts/* with an info message +VITE+ - The Unified Toolchain for the Web + + +"node" in .nvmrc is not a specific version; automatically mapping to "lts/*" +◇ Migrated . to Vite+ +• Node pnpm +• 2 config updates applied +• Node version manager file migrated to .node-version + +> cat .node-version # check node alias is mapped to lts/* +lts/* + +> test ! -f .nvmrc # check .nvmrc is removed \ No newline at end of file diff --git a/packages/cli/snap-tests-global/migration-nvmrc-node-alias/steps.json b/packages/cli/snap-tests-global/migration-nvmrc-node-alias/steps.json new file mode 100644 index 0000000000..4b4ef84a0e --- /dev/null +++ b/packages/cli/snap-tests-global/migration-nvmrc-node-alias/steps.json @@ -0,0 +1,7 @@ +{ + "commands": [ + "vp migrate --no-interactive # 'node' alias should be mapped to lts/* with an info message", + "cat .node-version # check node alias is mapped to lts/*", + "test ! -f .nvmrc # check .nvmrc is removed" + ] +} diff --git a/packages/cli/snap-tests-global/migration-nvmrc/.nvmrc b/packages/cli/snap-tests-global/migration-nvmrc/.nvmrc new file mode 100644 index 0000000000..d7fd7cd552 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-nvmrc/.nvmrc @@ -0,0 +1 @@ +v25.8.2 diff --git a/packages/cli/snap-tests-global/migration-nvmrc/package.json b/packages/cli/snap-tests-global/migration-nvmrc/package.json new file mode 100644 index 0000000000..da19acdd77 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-nvmrc/package.json @@ -0,0 +1,6 @@ +{ + "name": "migration-nvmrc", + "devDependencies": { + "vite": "^7.0.0" + } +} diff --git a/packages/cli/snap-tests-global/migration-nvmrc/snap.txt b/packages/cli/snap-tests-global/migration-nvmrc/snap.txt new file mode 100644 index 0000000000..2c8deae314 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-nvmrc/snap.txt @@ -0,0 +1,12 @@ +> vp migrate --no-interactive # migration should detect .nvmrc and auto-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 + +> cat .node-version # check .node-version is created with v prefix stripped +25.8.2 + +> test ! -f .nvmrc # check .nvmrc is removed \ No newline at end of file diff --git a/packages/cli/snap-tests-global/migration-nvmrc/steps.json b/packages/cli/snap-tests-global/migration-nvmrc/steps.json new file mode 100644 index 0000000000..f4b82e6b0a --- /dev/null +++ b/packages/cli/snap-tests-global/migration-nvmrc/steps.json @@ -0,0 +1,7 @@ +{ + "commands": [ + "vp migrate --no-interactive # migration should detect .nvmrc and auto-migrate to .node-version", + "cat .node-version # check .node-version is created with v prefix stripped", + "test ! -f .nvmrc # check .nvmrc is removed" + ] +} diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index bc2bb12ddd..47a9086448 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -1,4 +1,8 @@ -import { describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { PackageManager } from '../../types/index.js'; @@ -10,7 +14,12 @@ vi.mock('../../utils/constants.js', async (importOriginal) => { return { ...mod, VITE_PLUS_VERSION: 'latest' }; }); -const { rewritePackageJson } = await import('../migrator.js'); +const { + rewritePackageJson, + parseNvmrcVersion, + detectNodeVersionManagerFile, + migrateNodeVersionManagerFile, +} = await import('../migrator.js'); describe('rewritePackageJson', () => { it('should rewrite package.json scripts and extract staged config', async () => { @@ -121,3 +130,109 @@ describe('rewritePackageJson', () => { expect(pkg).toMatchSnapshot(); }); }); + +describe('parseNvmrcVersion', () => { + it('strips v prefix', () => { + expect(parseNvmrcVersion('v20.5.0')).toBe('20.5.0'); + }); + + it('passes through version without prefix', () => { + expect(parseNvmrcVersion('20.5.0')).toBe('20.5.0'); + expect(parseNvmrcVersion('20')).toBe('20'); + }); + + it('passes through lts aliases', () => { + expect(parseNvmrcVersion('lts/*')).toBe('lts/*'); + expect(parseNvmrcVersion('lts/iron')).toBe('lts/iron'); + expect(parseNvmrcVersion('lts/-1')).toBe('lts/-1'); + }); + + it('converts node/stable aliases to lts/*', () => { + expect(parseNvmrcVersion('node')).toBe('lts/*'); + expect(parseNvmrcVersion('stable')).toBe('lts/*'); + }); + + it('returns null for untranslatable aliases', () => { + expect(parseNvmrcVersion('iojs')).toBeNull(); + expect(parseNvmrcVersion('system')).toBeNull(); + expect(parseNvmrcVersion('default')).toBeNull(); + expect(parseNvmrcVersion('')).toBeNull(); + }); + + it('returns null for invalid version strings', () => { + expect(parseNvmrcVersion('v')).toBeNull(); + expect(parseNvmrcVersion('laetst')).toBeNull(); + expect(parseNvmrcVersion('20.5.0.1')).toBeNull(); + }); +}); + +describe('detectNodeVersionManagerFile', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns undefined when no version files found', () => { + expect(detectNodeVersionManagerFile(tmpDir)).toBeUndefined(); + }); + + it('returns undefined when .node-version already exists', () => { + fs.writeFileSync(path.join(tmpDir, '.node-version'), '20.5.0\n'); + fs.writeFileSync(path.join(tmpDir, '.nvmrc'), 'v20.5.0\n'); + expect(detectNodeVersionManagerFile(tmpDir)).toBeUndefined(); + }); + + it('detects .nvmrc', () => { + fs.writeFileSync(path.join(tmpDir, '.nvmrc'), 'v20.5.0\n'); + expect(detectNodeVersionManagerFile(tmpDir)).toEqual({ file: '.nvmrc' }); + }); +}); + +describe('migrateNodeVersionManagerFile', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + 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' }); + expect(ok).toBe(true); + expect(fs.readFileSync(path.join(tmpDir, '.node-version'), 'utf8')).toBe('20.5.0\n'); + expect(fs.existsSync(path.join(tmpDir, '.nvmrc'))).toBe(false); + }); + + it('returns false and warns for unsupported alias', () => { + fs.writeFileSync(path.join(tmpDir, '.nvmrc'), 'system\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: [], + }; + const ok = migrateNodeVersionManagerFile(tmpDir, { file: '.nvmrc' }, report); + expect(ok).toBe(false); + expect(report.warnings.length).toBe(1); + expect(fs.existsSync(path.join(tmpDir, '.node-version'))).toBe(false); + }); +}); diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index a20400f774..335ffc2d97 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -44,14 +44,17 @@ import { checkVitestVersion, checkViteVersion, detectEslintProject, + detectNodeVersionManagerFile, detectPrettierProject, installGitHooks, mergeViteConfigFiles, migrateEslintToOxlint, + migrateNodeVersionManagerFile, migratePrettierToOxfmt, preflightGitHooksSetup, rewriteMonorepo, rewriteStandaloneProject, + type NodeVersionManagerDetection, } from './migrator.js'; import { createMigrationReport, type MigrationReport } from './report.js'; @@ -168,6 +171,20 @@ async function promptPrettierMigration( return true; } +async function confirmNodeVersionFileMigration(interactive: boolean): Promise { + if (interactive) { + const confirmed = await prompts.confirm({ + message: 'Migrate .nvmrc to .node-version?', + initialValue: true, + }); + if (prompts.isCancel(confirmed)) { + cancelAndExit(); + } + return !!confirmed; + } + return true; +} + const helpMessage = renderCliDoc({ usage: 'vp migrate [PATH] [OPTIONS]', summary: @@ -319,6 +336,8 @@ interface MigrationPlan { eslintConfigFile?: string; migratePrettier: boolean; prettierConfigFile?: string; + migrateNodeVersionFile: boolean; + nodeVersionDetection?: NodeVersionManagerDetection; } async function collectMigrationPlan( @@ -436,6 +455,13 @@ async function collectMigrationPlan( warnPackageLevelPrettier(); } + // 10. Node version manager file detection + prompt + const nodeVersionDetection = detectNodeVersionManagerFile(rootDir); + let migrateNodeVersionFile = false; + if (nodeVersionDetection) { + migrateNodeVersionFile = await confirmNodeVersionFileMigration(options.interactive); + } + const plan: MigrationPlan = { packageManager, shouldSetupHooks, @@ -447,6 +473,8 @@ async function collectMigrationPlan( eslintConfigFile: eslintProject.configFile, migratePrettier, prettierConfigFile: prettierProject.configFile, + migrateNodeVersionFile, + nodeVersionDetection, }; return plan; @@ -523,6 +551,9 @@ function showMigrationSummary(options: { if (report.prettierMigrated) { log(`${styleText('gray', '•')} Prettier migrated to Oxfmt`); } + if (report.nodeVersionFileMigrated) { + log(`${styleText('gray', '•')} Node version manager file migrated to .node-version`); + } if (report.gitHooksConfigured) { log(`${styleText('gray', '•')} Git hooks configured`); } @@ -633,7 +664,13 @@ async function executeMigrationPlan( cancelAndExit('Vite+ cannot automatically migrate this project yet.', 1); } - // 3. Run vp install to ensure the project is ready + // 3. Migrate node version manager file → .node-version (independent of vite version) + if (plan.migrateNodeVersionFile && plan.nodeVersionDetection) { + updateMigrationProgress('Migrating node version file'); + migrateNodeVersionManagerFile(workspaceInfo.rootDir, plan.nodeVersionDetection, report); + } + + // 4. Run vp install to ensure the project is ready updateMigrationProgress('Installing dependencies'); const initialInstallSummary = await runViteInstall( workspaceInfo.rootDir, @@ -816,6 +853,18 @@ async function main() { workspaceInfoOptional.packages, ); + // Check if node version manager file migration is needed + const nodeVersionDetection = detectNodeVersionManagerFile(workspaceInfoOptional.rootDir); + if (nodeVersionDetection) { + const confirmed = await confirmNodeVersionFileMigration(options.interactive); + if ( + confirmed && + migrateNodeVersionManagerFile(workspaceInfoOptional.rootDir, nodeVersionDetection, report) + ) { + didMigrate = true; + } + } + // Merge configs and reinstall once if any tool migration happened if (eslintMigrated || prettierMigrated) { updateMigrationProgress('Rewriting configs'); diff --git a/packages/cli/src/migration/detector.ts b/packages/cli/src/migration/detector.ts index e93cdedd79..e649ee3eac 100644 --- a/packages/cli/src/migration/detector.ts +++ b/packages/cli/src/migration/detector.ts @@ -11,6 +11,7 @@ export interface ConfigFiles { eslintLegacyConfig?: string; prettierConfig?: string; // e.g. '.prettierrc.json', 'prettier.config.js', PRETTIER_PACKAGE_JSON_CONFIG prettierIgnore?: boolean; + nvmrcFile?: boolean; } // Sentinel value indicating Prettier config lives inside package.json "prettier" key. @@ -178,5 +179,10 @@ export function detectConfigs(projectPath: string): ConfigFiles { configs.prettierIgnore = true; } + // Check for .nvmrc (nvm) + if (fs.existsSync(path.join(projectPath, '.nvmrc'))) { + configs.nvmrcFile = true; + } + return configs; } diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index d50f1a3634..2cc8deed69 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -1994,3 +1994,103 @@ function setPackageManager( return pkg; }); } + +export interface NodeVersionManagerDetection { + file: string; +} + +/** + * Detect a .nvmrc file in the project directory. + * Returns undefined if not found or .node-version already exists. + */ +export function detectNodeVersionManagerFile( + projectPath: string, +): NodeVersionManagerDetection | undefined { + // already has .node-version — skip detection to avoid false positives and preserve existing file + if (fs.existsSync(path.join(projectPath, '.node-version'))) { + return undefined; + } + + const configs = detectConfigs(projectPath); + if (configs.nvmrcFile) { + return { file: '.nvmrc' }; + } + return undefined; +} + +/** + * Parse a version alias from a .nvmrc file into a .node-version compatible string. + * Accepts the first line of .nvmrc (pre-trimmed). + * Returns null for unsupported aliases like "system", "default", "iojs". + */ +export function parseNvmrcVersion(alias: string): string | null { + const version = alias.trim(); + + if (!version) { + return null; + } + + // "node" and "stable" mean "latest stable release" which maps closely to lts/*. + // Starting from Node 27, all releases will be LTS, so the gap is shrinking. + // We map these to lts/* and log the conversion so users are aware. + if (version === 'node' || version === 'stable') { + return 'lts/*'; + } + + // "iojs", "system", and "default" have no meaningful equivalent and cannot be auto-migrated. + if (version === 'iojs' || version === 'system' || version === 'default') { + return null; + } + + // LTS aliases (lts/*, lts/iron, etc.) pass through as-is + if (version.startsWith('lts/')) { + return version; + } + + // Strip optional 'v' prefix, then validate as a semver version or range + const normalized = version.startsWith('v') ? version.slice(1) : version; + if (!normalized || !semver.validRange(normalized)) { + return null; + } + return normalized; +} + +/** + * Migrate .nvmrc to .node-version and remove .nvmrc. + * Returns true on success, false if migration was skipped or failed. + */ +export function migrateNodeVersionManagerFile( + projectPath: string, + _detection: NodeVersionManagerDetection, + report?: MigrationReport, +): boolean { + const sourcePath = path.join(projectPath, '.nvmrc'); + const nodeVersionPath = path.join(projectPath, '.node-version'); + const content = fs.readFileSync(sourcePath, 'utf8'); + const originalAlias = content.split('\n')[0]?.trim() ?? ''; + const version = parseNvmrcVersion(originalAlias); + + if (!version) { + warnMigration( + '.nvmrc contains an unsupported version alias. Create .node-version manually with your desired Node.js version.', + report, + ); + return false; + } + + // TODO: remove this log once Node 27+ makes all releases LTS, at which point + // "node"/"stable" and "lts/*" will be effectively equivalent. + if (version === 'lts/*' && (originalAlias === 'node' || originalAlias === 'stable')) { + prompts.log.info( + `"${originalAlias}" in .nvmrc is not a specific version; automatically mapping to "lts/*"`, + ); + } + + fs.writeFileSync(nodeVersionPath, `${version}\n`); + fs.unlinkSync(sourcePath); + + if (report) { + report.nodeVersionFileMigrated = true; + } + return true; +} diff --git a/packages/cli/src/migration/report.ts b/packages/cli/src/migration/report.ts index 0201da93ac..a15c6b9cf7 100644 --- a/packages/cli/src/migration/report.ts +++ b/packages/cli/src/migration/report.ts @@ -9,6 +9,7 @@ export interface MigrationReport { rewrittenImportErrors: Array<{ path: string; message: string }>; eslintMigrated: boolean; prettierMigrated: boolean; + nodeVersionFileMigrated: boolean; gitHooksConfigured: boolean; warnings: string[]; manualSteps: string[]; @@ -26,6 +27,7 @@ export function createMigrationReport(): MigrationReport { rewrittenImportErrors: [], eslintMigrated: false, prettierMigrated: false, + nodeVersionFileMigrated: false, gitHooksConfigured: false, warnings: [], manualSteps: [],