Skip to content

Commit 50f7fca

Browse files
committed
fix(migrate): preserve pnpm catalog layouts
1 parent 6acea1a commit 50f7fca

13 files changed

Lines changed: 561 additions & 75 deletions

File tree

docs/guide/migrate-rules.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,13 @@ Unrelated `bunx` commands and other package-executor forms remain unchanged.
186186
11 no longer reads the legacy package.json settings.
187187
- Migration keeps dependency references, default and named catalogs, overrides,
188188
and `peerDependencyRules` consistent.
189+
- pnpm accepts the logical default catalog as either top-level `catalog` or
190+
`catalogs.default`, but not both. Migration preserves the existing form and
191+
never creates the other form beside it.
192+
- When an existing named catalog already owns `vite-plus`, `vite`, or `vitest`,
193+
migration reuses that managed toolchain catalog for newly added dependencies
194+
and overrides. It creates a top-level default catalog only when no managed or
195+
default catalog can be reused.
189196
- Each package that lists `vite-plus` in `dependencies` or `devDependencies`
190197
gets a direct `vite` dev dependency unless it already declares `vite` in a
191198
dependency field.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"name": "migration-upgrade-pnpm-catalogs-default",
3+
"devDependencies": {
4+
"vite": "catalog:build",
5+
"vite-plus": "catalog:build"
6+
},
7+
"devEngines": {
8+
"packageManager": {
9+
"name": "pnpm",
10+
"version": "10.33.0",
11+
"onFail": "download"
12+
}
13+
}
14+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
packages:
2+
- .
3+
4+
catalogs:
5+
build:
6+
vite: npm:@voidzero-dev/vite-plus-core@^0.1.20
7+
vite-plus: ^0.1.20
8+
default:
9+
rari: ^0.14.12
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
> vp migrate --no-interactive # reuse the managed named catalog beside catalogs.default
2+
◇ Migrated . to Vite+
3+
• Node <semver> pnpm <semver>
4+
• 2 config updates applied
5+
6+
> cat package.json # existing catalog:build dependency references are preserved
7+
{
8+
"name": "migration-upgrade-pnpm-catalogs-default",
9+
"devDependencies": {
10+
"vite": "catalog:build",
11+
"vite-plus": "catalog:build"
12+
},
13+
"devEngines": {
14+
"packageManager": {
15+
"name": "pnpm",
16+
"version": "<semver>",
17+
"onFail": "download"
18+
}
19+
},
20+
"scripts": {
21+
"prepare": "vp config"
22+
}
23+
}
24+
25+
> cat pnpm-workspace.yaml # catalogs.default remains the only default catalog definition
26+
packages:
27+
- .
28+
29+
catalogs:
30+
build:
31+
vite: https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617
32+
vite-plus: https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617
33+
default:
34+
rari: ^0.14.12
35+
blockExoticSubdeps: false
36+
overrides:
37+
vite: catalog:build
38+
vite-plus: https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617
39+
peerDependencyRules:
40+
allowAny:
41+
- vite
42+
allowedVersions:
43+
vite: '*'
44+
45+
> node -e "const fs = require('node:fs'); const p = require('./package.json'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); if (p.devDependencies.vite !== 'catalog:build' || p.devDependencies['vite-plus'] !== 'catalog:build' || /^catalog:/m.test(y) || !y.includes(' default:') || !y.includes(' vite: catalog:build')) process.exit(1)" # no duplicate top-level catalog is created
46+
> node -e "const fs = require('node:fs'); fs.copyFileSync('package.json', 'package.after-first-migration.json'); fs.copyFileSync('pnpm-workspace.yaml', 'workspace.after-first-migration.yaml')" # capture first migration result
47+
> vp migrate --no-interactive # catalogs.default migration is idempotent
48+
◇ Migrated . to Vite+
49+
• Node <semver> pnpm <semver>
50+
51+
> node -e "const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8') || fs.readFileSync('pnpm-workspace.yaml', 'utf8') !== fs.readFileSync('workspace.after-first-migration.yaml', 'utf8')) process.exit(1)" # rerun leaves catalog placement unchanged
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"env": {
3+
"PNPM_CONFIG_BLOCK_EXOTIC_SUBDEPS": "false",
4+
"VP_FORCE_MIGRATE": "1",
5+
"VP_OVERRIDE_PACKAGES": "{\"vite\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617\"}",
6+
"VP_VERSION": "https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617"
7+
},
8+
"commands": [
9+
"vp migrate --no-interactive # reuse the managed named catalog beside catalogs.default",
10+
"cat package.json # existing catalog:build dependency references are preserved",
11+
"cat pnpm-workspace.yaml # catalogs.default remains the only default catalog definition",
12+
"node -e \"const fs = require('node:fs'); const p = require('./package.json'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); if (p.devDependencies.vite !== 'catalog:build' || p.devDependencies['vite-plus'] !== 'catalog:build' || /^catalog:/m.test(y) || !y.includes(' default:') || !y.includes(' vite: catalog:build')) process.exit(1)\" # no duplicate top-level catalog is created",
13+
"node -e \"const fs = require('node:fs'); fs.copyFileSync('package.json', 'package.after-first-migration.json'); fs.copyFileSync('pnpm-workspace.yaml', 'workspace.after-first-migration.yaml')\" # capture first migration result",
14+
"vp migrate --no-interactive # catalogs.default migration is idempotent",
15+
"node -e \"const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8') || fs.readFileSync('pnpm-workspace.yaml', 'utf8') !== fs.readFileSync('workspace.after-first-migration.yaml', 'utf8')) process.exit(1)\" # rerun leaves catalog placement unchanged"
16+
]
17+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"name": "migration-upgrade-pnpm-named-catalog",
3+
"devDependencies": {
4+
"vite": "catalog:vite-stack",
5+
"vite-plus": "catalog:vite-stack"
6+
},
7+
"devEngines": {
8+
"packageManager": {
9+
"name": "pnpm",
10+
"version": "10.33.0",
11+
"onFail": "download"
12+
}
13+
}
14+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
packages:
2+
- .
3+
4+
catalogs:
5+
repo-tooling:
6+
prettier: 3.8.3
7+
vite-stack:
8+
vite: npm:@voidzero-dev/vite-plus-core@0.1.21
9+
vitest: npm:@voidzero-dev/vite-plus-test@0.1.21
10+
vite-plus: 0.1.21
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
> vp migrate --no-interactive # reuse the existing named-only Vite stack catalog
2+
◇ Migrated . to Vite+
3+
• Node <semver> pnpm <semver>
4+
• 2 config updates applied
5+
6+
> cat package.json # catalog:vite-stack dependency references are preserved
7+
{
8+
"name": "migration-upgrade-pnpm-named-catalog",
9+
"devDependencies": {
10+
"vite": "catalog:vite-stack",
11+
"vite-plus": "catalog:vite-stack"
12+
},
13+
"devEngines": {
14+
"packageManager": {
15+
"name": "pnpm",
16+
"version": "<semver>",
17+
"onFail": "download"
18+
}
19+
},
20+
"scripts": {
21+
"prepare": "vp config"
22+
}
23+
}
24+
25+
> cat pnpm-workspace.yaml # managed versions and overrides stay in vite-stack
26+
packages:
27+
- .
28+
29+
catalogs:
30+
repo-tooling:
31+
prettier: <semver>
32+
vite-stack:
33+
vite: https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617
34+
vitest: <semver>
35+
vite-plus: https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617
36+
blockExoticSubdeps: false
37+
overrides:
38+
vite: catalog:vite-stack
39+
vite-plus: https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617
40+
peerDependencyRules:
41+
allowAny:
42+
- vite
43+
allowedVersions:
44+
vite: '*'
45+
46+
> node -e "const fs = require('node:fs'); const p = require('./package.json'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); if (p.devDependencies.vite !== 'catalog:vite-stack' || p.devDependencies['vite-plus'] !== 'catalog:vite-stack' || /^catalog:/m.test(y) || !y.includes(' vite-stack:') || !y.includes(' vite: catalog:vite-stack')) process.exit(1)" # no default catalog is introduced
47+
> node -e "const fs = require('node:fs'); fs.copyFileSync('package.json', 'package.after-first-migration.json'); fs.copyFileSync('pnpm-workspace.yaml', 'workspace.after-first-migration.yaml')" # capture first migration result
48+
> vp migrate --no-interactive # named-only catalog migration is idempotent
49+
◇ Migrated . to Vite+
50+
• Node <semver> pnpm <semver>
51+
52+
> node -e "const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8') || fs.readFileSync('pnpm-workspace.yaml', 'utf8') !== fs.readFileSync('workspace.after-first-migration.yaml', 'utf8')) process.exit(1)" # rerun leaves catalog placement unchanged
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"env": {
3+
"PNPM_CONFIG_BLOCK_EXOTIC_SUBDEPS": "false",
4+
"VP_FORCE_MIGRATE": "1",
5+
"VP_OVERRIDE_PACKAGES": "{\"vite\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617\"}",
6+
"VP_VERSION": "https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617"
7+
},
8+
"commands": [
9+
"vp migrate --no-interactive # reuse the existing named-only Vite stack catalog",
10+
"cat package.json # catalog:vite-stack dependency references are preserved",
11+
"cat pnpm-workspace.yaml # managed versions and overrides stay in vite-stack",
12+
"node -e \"const fs = require('node:fs'); const p = require('./package.json'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); if (p.devDependencies.vite !== 'catalog:vite-stack' || p.devDependencies['vite-plus'] !== 'catalog:vite-stack' || /^catalog:/m.test(y) || !y.includes(' vite-stack:') || !y.includes(' vite: catalog:vite-stack')) process.exit(1)\" # no default catalog is introduced",
13+
"node -e \"const fs = require('node:fs'); fs.copyFileSync('package.json', 'package.after-first-migration.json'); fs.copyFileSync('pnpm-workspace.yaml', 'workspace.after-first-migration.yaml')\" # capture first migration result",
14+
"vp migrate --no-interactive # named-only catalog migration is idempotent",
15+
"node -e \"const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8') || fs.readFileSync('pnpm-workspace.yaml', 'utf8') !== fs.readFileSync('workspace.after-first-migration.yaml', 'utf8')) process.exit(1)\" # rerun leaves catalog placement unchanged"
16+
]
17+
}

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

Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3179,7 +3179,7 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => {
31793179
peerDependencies: Record<string, string>;
31803180
};
31813181
expect(pkg.devDependencies.vite).toBe('catalog:vite7');
3182-
expect(pkg.devDependencies['vite-plus']).toBe('catalog:');
3182+
expect(pkg.devDependencies['vite-plus']).toBe('catalog:vite7');
31833183
expect(pkg.peerDependencies.vite).toBe('^7.0.0');
31843184
// Peer declarations do not keep the managed catalog alive. Resolve the
31853185
// catalog entry to its public range before pruning it so the peer cannot
@@ -3188,6 +3188,166 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => {
31883188
expect(pkg.peerDependencies).not.toHaveProperty('tsdown');
31893189
});
31903190

3191+
it('reuses catalogs.default without creating a duplicate top-level catalog', () => {
3192+
fs.writeFileSync(
3193+
path.join(tmpDir, 'package.json'),
3194+
JSON.stringify({
3195+
name: 'rari-shaped-workspace',
3196+
devDependencies: {
3197+
vite: 'catalog:build',
3198+
'vite-plus': 'catalog:build',
3199+
},
3200+
}),
3201+
);
3202+
fs.writeFileSync(
3203+
path.join(tmpDir, 'pnpm-workspace.yaml'),
3204+
[
3205+
'catalogs:',
3206+
' build:',
3207+
' vite: ^8.0.0',
3208+
' vite-plus: ^0.2.0',
3209+
' default:',
3210+
' rari: ^0.14.12',
3211+
'',
3212+
].join('\n'),
3213+
);
3214+
3215+
const savedForceMigrate = process.env.VP_FORCE_MIGRATE;
3216+
process.env.VP_FORCE_MIGRATE = '1';
3217+
try {
3218+
rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true);
3219+
} finally {
3220+
if (savedForceMigrate === undefined) {
3221+
delete process.env.VP_FORCE_MIGRATE;
3222+
} else {
3223+
process.env.VP_FORCE_MIGRATE = savedForceMigrate;
3224+
}
3225+
}
3226+
3227+
const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as {
3228+
catalog?: Record<string, string>;
3229+
catalogs: Record<string, Record<string, string>>;
3230+
overrides: Record<string, string>;
3231+
};
3232+
const pkg = readJson(path.join(tmpDir, 'package.json')) as {
3233+
devDependencies: Record<string, string>;
3234+
};
3235+
3236+
expect(workspace.catalog).toBeUndefined();
3237+
expect(workspace.catalogs.default).toEqual({ rari: '^0.14.12' });
3238+
expect(workspace.catalogs.build.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest');
3239+
expect(workspace.catalogs.build['vite-plus']).toBe('latest');
3240+
expect(workspace.overrides.vite).toBe('catalog:build');
3241+
expect(pkg.devDependencies.vite).toBe('catalog:build');
3242+
expect(pkg.devDependencies['vite-plus']).toBe('catalog:build');
3243+
expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false);
3244+
});
3245+
3246+
it('writes managed dependencies into an active catalogs.default definition', () => {
3247+
fs.writeFileSync(
3248+
path.join(tmpDir, 'package.json'),
3249+
JSON.stringify({
3250+
name: 'default-catalog-workspace',
3251+
devDependencies: {
3252+
vite: 'catalog:',
3253+
'vite-plus': 'catalog:',
3254+
},
3255+
}),
3256+
);
3257+
fs.writeFileSync(
3258+
path.join(tmpDir, 'pnpm-workspace.yaml'),
3259+
[
3260+
'catalogs:',
3261+
' default:',
3262+
' react: ^19.0.0',
3263+
' vite: ^8.0.0',
3264+
' vite-plus: ^0.2.0',
3265+
'',
3266+
].join('\n'),
3267+
);
3268+
3269+
const savedForceMigrate = process.env.VP_FORCE_MIGRATE;
3270+
process.env.VP_FORCE_MIGRATE = '1';
3271+
try {
3272+
rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true);
3273+
} finally {
3274+
if (savedForceMigrate === undefined) {
3275+
delete process.env.VP_FORCE_MIGRATE;
3276+
} else {
3277+
process.env.VP_FORCE_MIGRATE = savedForceMigrate;
3278+
}
3279+
}
3280+
3281+
const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as {
3282+
catalog?: Record<string, string>;
3283+
catalogs: Record<string, Record<string, string>>;
3284+
overrides: Record<string, string>;
3285+
};
3286+
3287+
expect(workspace.catalog).toBeUndefined();
3288+
expect(workspace.catalogs.default.react).toBe('^19.0.0');
3289+
expect(workspace.catalogs.default.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest');
3290+
expect(workspace.catalogs.default['vite-plus']).toBe('latest');
3291+
expect(workspace.overrides.vite).toBe('catalog:');
3292+
expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false);
3293+
});
3294+
3295+
it('reuses a named-only Vite stack catalog without creating a default catalog', () => {
3296+
fs.writeFileSync(
3297+
path.join(tmpDir, 'package.json'),
3298+
JSON.stringify({
3299+
name: 'vize-shaped-workspace',
3300+
devDependencies: {
3301+
vite: 'catalog:vite-stack',
3302+
'vite-plus': 'catalog:vite-stack',
3303+
},
3304+
}),
3305+
);
3306+
fs.writeFileSync(
3307+
path.join(tmpDir, 'pnpm-workspace.yaml'),
3308+
[
3309+
'catalogs:',
3310+
' repo-tooling:',
3311+
' prettier: 3.8.3',
3312+
' vite-stack:',
3313+
' vite: npm:@voidzero-dev/vite-plus-core@0.1.21',
3314+
' vitest: npm:@voidzero-dev/vite-plus-test@0.1.21',
3315+
' vite-plus: 0.1.21',
3316+
'',
3317+
].join('\n'),
3318+
);
3319+
3320+
const savedForceMigrate = process.env.VP_FORCE_MIGRATE;
3321+
process.env.VP_FORCE_MIGRATE = '1';
3322+
try {
3323+
rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true);
3324+
} finally {
3325+
if (savedForceMigrate === undefined) {
3326+
delete process.env.VP_FORCE_MIGRATE;
3327+
} else {
3328+
process.env.VP_FORCE_MIGRATE = savedForceMigrate;
3329+
}
3330+
}
3331+
3332+
const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as {
3333+
catalog?: Record<string, string>;
3334+
catalogs: Record<string, Record<string, string>>;
3335+
overrides: Record<string, string>;
3336+
};
3337+
const pkg = readJson(path.join(tmpDir, 'package.json')) as {
3338+
devDependencies: Record<string, string>;
3339+
};
3340+
3341+
expect(workspace.catalog).toBeUndefined();
3342+
expect(workspace.catalogs['repo-tooling']).toEqual({ prettier: '3.8.3' });
3343+
expect(workspace.catalogs['vite-stack'].vite).toBe('npm:@voidzero-dev/vite-plus-core@latest');
3344+
expect(workspace.catalogs['vite-stack']['vite-plus']).toBe('latest');
3345+
expect(workspace.overrides.vite).toBe('catalog:vite-stack');
3346+
expect(pkg.devDependencies.vite).toBe('catalog:vite-stack');
3347+
expect(pkg.devDependencies['vite-plus']).toBe('catalog:vite-stack');
3348+
expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false);
3349+
});
3350+
31913351
it('drops only global/vite-plus-parent selector-shaped REMOVE_PACKAGES overrides after moving pnpm config', () => {
31923352
// Project starts with its pnpm config in package.json (`pkg.pnpm.overrides`).
31933353
// A selector-shaped provider key is stripped only when it would re-pin

0 commit comments

Comments
 (0)