Skip to content

Commit 70300c8

Browse files
authored
feat(migrate): support volta node version migration to .node-version (#1201)
### 🔗 Linked issue resolves #1183 ### 📚 Description We have added migration support features for projects that use Volta for Node.js version management. If a volta.node setting is found in package.json, vp migrate will now automatically detect it and create a .node-version file. If an .nvmrc file also exists, its settings will take priority. In such cases, a manual cleanup step will be added to remove the volta field from package.json.
1 parent ced26cd commit 70300c8

11 files changed

Lines changed: 282 additions & 27 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
v20.5.0
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"name": "migration-volta-with-nvmrc",
3+
"devDependencies": {
4+
"vite": "^7.0.0"
5+
},
6+
"volta": {
7+
"node": "18.0.0",
8+
"npm": "9.0.0"
9+
}
10+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
> vp migrate --no-interactive # .nvmrc should take priority over volta.node
2+
VITE+ - The Unified Toolchain for the Web
3+
4+
◇ Migrated . to Vite+<repeat>
5+
• Node <semver> pnpm <semver>
6+
• 2 config updates applied
7+
• Node version manager file migrated to .node-version
8+
→ Manual follow-up:
9+
- Remove the "volta" field from package.json
10+
11+
> cat .node-version # check .node-version comes from .nvmrc (v20.5.0), not volta.node (18.0.0)
12+
20.5.0
13+
14+
> test ! -f .nvmrc # check .nvmrc is removed
15+
> grep '"volta"' package.json # volta field must remain intact
16+
"volta": {
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"commands": [
3+
"vp migrate --no-interactive # .nvmrc should take priority over volta.node",
4+
"cat .node-version # check .node-version comes from .nvmrc (v20.5.0), not volta.node (18.0.0)",
5+
"test ! -f .nvmrc # check .nvmrc is removed",
6+
"grep '\"volta\"' package.json # volta field must remain intact"
7+
]
8+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"name": "migration-volta",
3+
"devDependencies": {
4+
"vite": "^7.0.0"
5+
},
6+
"volta": {
7+
"node": "20.5.0",
8+
"npm": "10.2.5"
9+
}
10+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
> vp migrate --no-interactive # migration should detect volta.node in package.json and migrate to .node-version
2+
VITE+ - The Unified Toolchain for the Web
3+
4+
◇ Migrated . to Vite+<repeat>
5+
• Node <semver> pnpm <semver>
6+
• 2 config updates applied
7+
• Node version manager file migrated to .node-version
8+
→ Manual follow-up:
9+
- Remove the "volta" field from package.json
10+
11+
> cat .node-version # check .node-version is created from volta.node
12+
20.5.0
13+
14+
> grep '"volta"' package.json # check volta field is preserved in package.json (not removed)
15+
"volta": {
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"commands": [
3+
"vp migrate --no-interactive # migration should detect volta.node in package.json and migrate to .node-version",
4+
"cat .node-version # check .node-version is created from volta.node",
5+
"grep '\"volta\"' package.json # check volta field is preserved in package.json (not removed)"
6+
]
7+
}

packages/cli/src/migration/__tests__/migrator.spec.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,35 @@ describe('detectNodeVersionManagerFile', () => {
247247
fs.writeFileSync(path.join(tmpDir, '.nvmrc'), 'v20.5.0\n');
248248
expect(detectNodeVersionManagerFile(tmpDir)).toEqual({ file: '.nvmrc' });
249249
});
250+
251+
it('detects volta node in package.json', () => {
252+
fs.writeFileSync(
253+
path.join(tmpDir, 'package.json'),
254+
JSON.stringify({ volta: { node: '20.5.0' } }),
255+
);
256+
expect(detectNodeVersionManagerFile(tmpDir)).toEqual({
257+
file: 'package.json',
258+
voltaNodeVersion: '20.5.0',
259+
});
260+
});
261+
262+
it('prefers .nvmrc over volta when both are present and sets voltaPresent', () => {
263+
fs.writeFileSync(path.join(tmpDir, '.nvmrc'), 'v20.5.0\n');
264+
fs.writeFileSync(
265+
path.join(tmpDir, 'package.json'),
266+
JSON.stringify({ volta: { node: '18.0.0' } }),
267+
);
268+
expect(detectNodeVersionManagerFile(tmpDir)).toEqual({ file: '.nvmrc', voltaPresent: true });
269+
});
270+
271+
it('returns undefined when .node-version already exists even with volta', () => {
272+
fs.writeFileSync(path.join(tmpDir, '.node-version'), '20.5.0\n');
273+
fs.writeFileSync(
274+
path.join(tmpDir, 'package.json'),
275+
JSON.stringify({ volta: { node: '20.5.0' } }),
276+
);
277+
expect(detectNodeVersionManagerFile(tmpDir)).toBeUndefined();
278+
});
250279
});
251280

252281
describe('migrateNodeVersionManagerFile', () => {
@@ -260,6 +289,28 @@ describe('migrateNodeVersionManagerFile', () => {
260289
fs.rmSync(tmpDir, { recursive: true, force: true });
261290
});
262291

292+
it('adds volta manual step when voltaPresent is set', () => {
293+
fs.writeFileSync(path.join(tmpDir, '.nvmrc'), 'v20.5.0\n');
294+
const report = {
295+
createdViteConfigCount: 0,
296+
mergedConfigCount: 0,
297+
mergedStagedConfigCount: 0,
298+
inlinedLintStagedConfigCount: 0,
299+
removedConfigCount: 0,
300+
tsdownImportCount: 0,
301+
rewrittenImportFileCount: 0,
302+
rewrittenImportErrors: [],
303+
eslintMigrated: false,
304+
prettierMigrated: false,
305+
nodeVersionFileMigrated: false,
306+
gitHooksConfigured: false,
307+
warnings: [],
308+
manualSteps: [],
309+
};
310+
migrateNodeVersionManagerFile(tmpDir, { file: '.nvmrc', voltaPresent: true }, report);
311+
expect(report.manualSteps).toContain('Remove the "volta" field from package.json');
312+
});
313+
263314
it('migrates .nvmrc to .node-version and removes .nvmrc', () => {
264315
fs.writeFileSync(path.join(tmpDir, '.nvmrc'), 'v20.5.0\n');
265316
const ok = migrateNodeVersionManagerFile(tmpDir, { file: '.nvmrc' });
@@ -291,6 +342,77 @@ describe('migrateNodeVersionManagerFile', () => {
291342
expect(report.warnings.length).toBe(1);
292343
expect(fs.existsSync(path.join(tmpDir, '.node-version'))).toBe(false);
293344
});
345+
346+
it('migrates volta node version to .node-version', () => {
347+
const ok = migrateNodeVersionManagerFile(tmpDir, {
348+
file: 'package.json',
349+
voltaNodeVersion: '20.5.0',
350+
});
351+
expect(ok).toBe(true);
352+
expect(fs.readFileSync(path.join(tmpDir, '.node-version'), 'utf8')).toBe('20.5.0\n');
353+
});
354+
355+
it('sets nodeVersionFileMigrated and manualSteps in report for volta migration', () => {
356+
const report = {
357+
createdViteConfigCount: 0,
358+
mergedConfigCount: 0,
359+
mergedStagedConfigCount: 0,
360+
inlinedLintStagedConfigCount: 0,
361+
removedConfigCount: 0,
362+
tsdownImportCount: 0,
363+
rewrittenImportFileCount: 0,
364+
rewrittenImportErrors: [],
365+
eslintMigrated: false,
366+
prettierMigrated: false,
367+
nodeVersionFileMigrated: false,
368+
gitHooksConfigured: false,
369+
warnings: [],
370+
manualSteps: [],
371+
};
372+
migrateNodeVersionManagerFile(
373+
tmpDir,
374+
{ file: 'package.json', voltaNodeVersion: '20.5.0' },
375+
report,
376+
);
377+
expect(report.nodeVersionFileMigrated).toBe(true);
378+
expect(report.manualSteps).toContain('Remove the "volta" field from package.json');
379+
});
380+
381+
it('normalizes volta.node "lts" to "lts/*"', () => {
382+
const ok = migrateNodeVersionManagerFile(tmpDir, {
383+
file: 'package.json',
384+
voltaNodeVersion: 'lts',
385+
});
386+
expect(ok).toBe(true);
387+
expect(fs.readFileSync(path.join(tmpDir, '.node-version'), 'utf8')).toBe('lts/*\n');
388+
});
389+
390+
it('returns false and warns when volta.node is a partial version', () => {
391+
const report = {
392+
createdViteConfigCount: 0,
393+
mergedConfigCount: 0,
394+
mergedStagedConfigCount: 0,
395+
inlinedLintStagedConfigCount: 0,
396+
removedConfigCount: 0,
397+
tsdownImportCount: 0,
398+
rewrittenImportFileCount: 0,
399+
rewrittenImportErrors: [],
400+
eslintMigrated: false,
401+
prettierMigrated: false,
402+
nodeVersionFileMigrated: false,
403+
gitHooksConfigured: false,
404+
warnings: [],
405+
manualSteps: [],
406+
};
407+
const ok = migrateNodeVersionManagerFile(
408+
tmpDir,
409+
{ file: 'package.json', voltaNodeVersion: '20' },
410+
report,
411+
);
412+
expect(ok).toBe(false);
413+
expect(report.warnings.length).toBe(1);
414+
expect(fs.existsSync(path.join(tmpDir, '.node-version'))).toBe(false);
415+
});
294416
});
295417

296418
function makeWorkspaceInfo(

packages/cli/src/migration/bin.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -171,10 +171,19 @@ async function promptPrettierMigration(
171171
return true;
172172
}
173173

174-
async function confirmNodeVersionFileMigration(interactive: boolean): Promise<boolean> {
174+
async function confirmNodeVersionFileMigration(
175+
interactive: boolean,
176+
detection: NodeVersionManagerDetection,
177+
): Promise<boolean> {
178+
const confirmMessageByFile = {
179+
'package.json': 'Migrate Volta node version (package.json) to .node-version?',
180+
'.nvmrc': 'Migrate .nvmrc to .node-version?',
181+
} as const satisfies Record<NodeVersionManagerDetection['file'], string>;
182+
183+
const message = confirmMessageByFile[detection.file];
175184
if (interactive) {
176185
const confirmed = await prompts.confirm({
177-
message: 'Migrate .nvmrc to .node-version?',
186+
message,
178187
initialValue: true,
179188
});
180189
if (prompts.isCancel(confirmed)) {
@@ -459,7 +468,10 @@ async function collectMigrationPlan(
459468
const nodeVersionDetection = detectNodeVersionManagerFile(rootDir);
460469
let migrateNodeVersionFile = false;
461470
if (nodeVersionDetection) {
462-
migrateNodeVersionFile = await confirmNodeVersionFileMigration(options.interactive);
471+
migrateNodeVersionFile = await confirmNodeVersionFileMigration(
472+
options.interactive,
473+
nodeVersionDetection,
474+
);
463475
}
464476

465477
const plan: MigrationPlan = {
@@ -859,7 +871,10 @@ async function main() {
859871
// Check if node version manager file migration is needed
860872
const nodeVersionDetection = detectNodeVersionManagerFile(workspaceInfoOptional.rootDir);
861873
if (nodeVersionDetection) {
862-
const confirmed = await confirmNodeVersionFileMigration(options.interactive);
874+
const confirmed = await confirmNodeVersionFileMigration(
875+
options.interactive,
876+
nodeVersionDetection,
877+
);
863878
if (
864879
confirmed &&
865880
migrateNodeVersionManagerFile(workspaceInfoOptional.rootDir, nodeVersionDetection, report)

packages/cli/src/migration/detector.ts

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export interface ConfigFiles {
1212
prettierConfig?: string; // e.g. '.prettierrc.json', 'prettier.config.js', PRETTIER_PACKAGE_JSON_CONFIG
1313
prettierIgnore?: boolean;
1414
nvmrcFile?: boolean;
15+
voltaNode?: string;
1516
}
1617

1718
// Sentinel value indicating Prettier config lives inside package.json "prettier" key.
@@ -158,22 +159,6 @@ export function detectConfigs(projectPath: string): ConfigFiles {
158159
break;
159160
}
160161
}
161-
// Check for "prettier" key in package.json if no config file found
162-
if (!configs.prettierConfig) {
163-
const packageJsonPath = path.join(projectPath, 'package.json');
164-
if (fs.existsSync(packageJsonPath)) {
165-
try {
166-
const content = fs.readFileSync(packageJsonPath, 'utf8');
167-
const pkg = JSON.parse(content);
168-
if (pkg.prettier) {
169-
configs.prettierConfig = PRETTIER_PACKAGE_JSON_CONFIG;
170-
}
171-
} catch {
172-
// ignore parse errors
173-
}
174-
}
175-
}
176-
177162
// Check for .prettierignore
178163
if (fs.existsSync(path.join(projectPath, '.prettierignore'))) {
179164
configs.prettierIgnore = true;
@@ -184,5 +169,25 @@ export function detectConfigs(projectPath: string): ConfigFiles {
184169
configs.nvmrcFile = true;
185170
}
186171

172+
// Check package.json for "prettier" key and Volta node version
173+
const packageJsonPath = path.join(projectPath, 'package.json');
174+
if (fs.existsSync(packageJsonPath)) {
175+
try {
176+
const content = fs.readFileSync(packageJsonPath, 'utf8');
177+
const pkg = JSON.parse(content);
178+
179+
if (!configs.prettierConfig && pkg.prettier) {
180+
configs.prettierConfig = PRETTIER_PACKAGE_JSON_CONFIG;
181+
}
182+
183+
const voltaNode = pkg.volta?.node;
184+
if (typeof voltaNode === 'string') {
185+
configs.voltaNode = voltaNode;
186+
}
187+
} catch {
188+
// ignore parse errors
189+
}
190+
}
191+
187192
return configs;
188193
}

0 commit comments

Comments
 (0)