Skip to content
115 changes: 113 additions & 2 deletions packages/cli/src/migration/__tests__/migrator.spec.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 () => {
Expand Down Expand Up @@ -121,3 +130,105 @@ describe('rewritePackageJson', () => {
expect(pkg).toMatchSnapshot();
});
});

describe('parseNvmrcVersion', () => {
it('strips v prefix', () => {
expect(parseNvmrcVersion('v20.5.0\n')).toBe('20.5.0');
expect(parseNvmrcVersion('v20.5.0')).toBe('20.5.0');
});

it('passes through version without prefix', () => {
expect(parseNvmrcVersion('20.5.0\n')).toBe('20.5.0');
expect(parseNvmrcVersion('20\n')).toBe('20');
});

it('passes through lts aliases', () => {
expect(parseNvmrcVersion('lts/*\n')).toBe('lts/*');
expect(parseNvmrcVersion('lts/iron\n')).toBe('lts/iron');
expect(parseNvmrcVersion('lts/-1\n')).toBe('lts/-1');
});

it('returns null for unsupported aliases', () => {
expect(parseNvmrcVersion('node\n')).toBeNull();
expect(parseNvmrcVersion('stable\n')).toBeNull();
expect(parseNvmrcVersion('system\n')).toBeNull();
expect(parseNvmrcVersion('')).toBeNull();
});

it('returns null for invalid version strings', () => {
expect(parseNvmrcVersion('v\n')).toBeNull();
expect(parseNvmrcVersion('laetst\n')).toBeNull();
expect(parseNvmrcVersion('20.5.0.1\n')).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'), 'node\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);
});
});
52 changes: 51 additions & 1 deletion packages/cli/src/migration/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -168,6 +171,21 @@ async function promptPrettierMigration(
return true;
}

async function confirmNodeVersionFileMigration(interactive: boolean): Promise<boolean> {
if (interactive) {
const confirmed = await prompts.confirm({
message: 'Migrate .nvmrc to .node-version?',
Copy link
Contributor Author

@naokihaba naokihaba Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Vite+, the Node.js version is determined based on the following priority order:

The sequence is ".node-version", followed by "engines.node" in package.json, and then "devEngines.runtime" in package.json.
For this reason, we are treating the migration of the ".node-version" file as the highest priority in this migration process.

/// Resolve Node.js version for a directory.
///
/// Resolution order:
/// 0. `VITE_PLUS_NODE_VERSION` env var (session override from `vp env use`)
/// 1. `.session-node-version` file (session override written by `vp env use` for shell-wrapper-less environments)
/// 2. `.node-version` file in current or parent directories
/// 3. `package.json#engines.node` in current or parent directories
/// 4. `package.json#devEngines.runtime` in current or parent directories
/// 5. User default from config.json
/// 6. Latest LTS version
pub async fn resolve_version(cwd: &AbsolutePath) -> Result<VersionResolution, Error> {

initialValue: true,
});
if (prompts.isCancel(confirmed)) {
cancelAndExit();
}
return !!confirmed;
}
prompts.log.info('.nvmrc detected. Auto-migrating to .node-version...');
return true;
}

const helpMessage = renderCliDoc({
usage: 'vp migrate [PATH] [OPTIONS]',
summary:
Expand Down Expand Up @@ -319,6 +337,8 @@ interface MigrationPlan {
eslintConfigFile?: string;
migratePrettier: boolean;
prettierConfigFile?: string;
migrateNodeVersionFile: boolean;
nodeVersionDetection?: NodeVersionManagerDetection;
}

async function collectMigrationPlan(
Expand Down Expand Up @@ -436,6 +456,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,
Expand All @@ -447,6 +474,8 @@ async function collectMigrationPlan(
eslintConfigFile: eslintProject.configFile,
migratePrettier,
prettierConfigFile: prettierProject.configFile,
migrateNodeVersionFile,
nodeVersionDetection,
};

return plan;
Expand Down Expand Up @@ -523,6 +552,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`);
}
Expand Down Expand Up @@ -633,7 +665,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,
Expand Down Expand Up @@ -816,6 +854,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');
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/migration/detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
83 changes: 83 additions & 0 deletions packages/cli/src/migration/migrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1977,3 +1977,86 @@ 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 string from a .nvmrc file.
* Returns null for unsupported aliases like "node", "stable", "system".
*/
export function parseNvmrcVersion(content: string): string | null {
const version = content.split('\n')[0]?.trim();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse .nvmrc using full nvm syntax before migration

parseNvmrcVersion only inspects the first line (content.split('\n')[0]), so valid .nvmrc files that start with a comment/blank line or include inline comments/key-value lines are treated as unsupported and migration is skipped with a warning. Per nvm’s .nvmrc docs, comments and blank lines should be ignored during parsing (see https://github.com/nvm-sh/nvm#nvmrc), so this can silently miss real-world projects and fail the new auto-migration path even when a valid version is present later in the file.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existing process for reading .node-version (parse_node_version_content) is also designed to read only the first line without excluding comments.

Therefore, this implementation follows the same limitations to maintain consistency across the codebase.
If necessary, I can revisit this and make further adjustments.


if (!version) {
return null;
}

// Unsupported nvm aliases that have no direct version equivalent
if (['node', 'stable', 'iojs', 'system', 'default'].includes(version)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can convert all these to lts/* ?

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 version = parseNvmrcVersion(content);

if (!version) {
warnMigration(
'.nvmrc contains an unsupported version alias. Create .node-version manually with your desired Node.js version.',
report,
);
return false;
}

fs.writeFileSync(nodeVersionPath, `${version}\n`);
fs.unlinkSync(sourcePath);

if (report) {
report.nodeVersionFileMigrated = true;
}
return true;
}
2 changes: 2 additions & 0 deletions packages/cli/src/migration/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -26,6 +27,7 @@ export function createMigrationReport(): MigrationReport {
rewrittenImportErrors: [],
eslintMigrated: false,
prettierMigrated: false,
nodeVersionFileMigrated: false,
gitHooksConfigured: false,
warnings: [],
manualSteps: [],
Expand Down
Loading