From 1931c9295aa5d4fe7c57f48fa4afc969597ea636 Mon Sep 17 00:00:00 2001 From: yusuke <66258931+yusuke99@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:14:55 +0900 Subject: [PATCH 01/16] fix(migrate): prevent postinstall loops --- .../package.json | 11 ++ .../snapshots.toml | 11 ++ .../migration_nested_pnpm_postinstall.md | 38 +++++ .../web/package.json | 11 ++ .../web/pnpm-lock.yaml | 19 +++ .../src/migration/__tests__/migrator.spec.ts | 159 ++++++++++++++++++ .../cli/src/migration/migrator/catalog.ts | 13 ++ .../src/migration/migrator/orchestrators.ts | 11 ++ .../cli/src/migration/migrator/source-scan.ts | 53 ++++++ .../migration/migrator/vite-plus-bootstrap.ts | 10 ++ 10 files changed, 336 insertions(+) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots/migration_nested_pnpm_postinstall.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/web/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/web/pnpm-lock.yaml diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/package.json new file mode 100644 index 0000000000..0d862137b3 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/package.json @@ -0,0 +1,11 @@ +{ + "name": "migration-nested-pnpm-postinstall", + "private": true, + "scripts": { + "postinstall": "cd web && pnpm install" + }, + "devDependencies": { + "vite": "^8.0.1" + }, + "packageManager": "pnpm@10.33.0" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots.toml new file mode 100644 index 0000000000..e636b1d4c0 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots.toml @@ -0,0 +1,11 @@ +[[case]] +name = "migration_nested_pnpm_postinstall" +vp = "global" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "migrate a standalone project whose postinstall installs a nested pnpm project", timeout = 120000 }, + { argv = ["vp", "install"], comment = "install completes without postinstall recursion", snapshot = false, timeout = 120000 }, + { argv = ["vpt", "print-file", "web/pnpm-workspace.yaml"], comment = "the child keeps its install boundary" }, + { argv = ["vpt", "stat-file", "web/node_modules/test-vite-plus-install", "--assert-not", "missing"], comment = "the child's dependency was installed" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots/migration_nested_pnpm_postinstall.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots/migration_nested_pnpm_postinstall.md new file mode 100644 index 0000000000..03ae782db2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots/migration_nested_pnpm_postinstall.md @@ -0,0 +1,38 @@ +# migration_nested_pnpm_postinstall + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +migrate a standalone project whose postinstall installs a nested pnpm project + +``` +VITE+ - The Unified Toolchain for the Web + +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 1 config update applied +``` + +## `vp install` + +install completes without postinstall recursion + + +## `vpt print-file web/pnpm-workspace.yaml` + +the child keeps its install boundary + +``` +packages: + - "." +``` + +## `vpt stat-file web/node_modules/test-vite-plus-install --assert-not missing` + +the child's dependency was installed + +``` +web/node_modules/test-vite-plus-install: symlink +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/web/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/web/package.json new file mode 100644 index 0000000000..fb4d32685e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/web/package.json @@ -0,0 +1,11 @@ +{ + "name": "web", + "private": true, + "scripts": { + "postinstall": "echo 'postinstall from web/package.json'" + }, + "dependencies": { + "test-vite-plus-install": "1.0.0" + }, + "packageManager": "pnpm@10.33.0" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/web/pnpm-lock.yaml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/web/pnpm-lock.yaml new file mode 100644 index 0000000000..fe165a6548 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/web/pnpm-lock.yaml @@ -0,0 +1,19 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + .: + dependencies: + test-vite-plus-install: + specifier: 1.0.0 + version: 1.0.0 + +packages: + test-vite-plus-install@1.0.0: + resolution: {integrity: sha512-1tUZpG9YerpEquQ6jf5SnzNGP0tx02aiSF+h2UUErgWPJfPCAk/696KJGMjOVEqG6x6HeKjHZj2v6fZU+xGZ4g==} + +snapshots: + test-vite-plus-install@1.0.0: {} diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 5f2b4839a0..55a4994292 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -1889,6 +1889,165 @@ function overrideMap(pkg: Record, manager: PackageManager, vers >; } +describe('standalone pnpm child project boundaries', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-nested-pnpm-')); + fs.mkdirSync(path.join(tmpDir, 'web')); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'root', + devDependencies: { vite: '^8.0.1' }, + scripts: { postinstall: 'cd web && pnpm install' }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'web/package.json'), + JSON.stringify({ + name: 'web', + packageManager: 'pnpm@10.33.0', + pnpm: { overrides: { example: '1.0.0' } }, + }), + ); + fs.writeFileSync(path.join(tmpDir, 'web/pnpm-lock.yaml'), 'lockfileVersion: "9.0"\n'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('preserves the child install boundary when migrating with pnpm', () => { + const manifest = fs.readFileSync(path.join(tmpDir, 'web/package.json'), 'utf8'); + const lockfile = fs.readFileSync(path.join(tmpDir, 'web/pnpm-lock.yaml'), 'utf8'); + + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + + expect(readYamlObject(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toEqual({ + packages: ['.'], + overrides: { example: '1.0.0' }, + }); + expect(fs.readFileSync(path.join(tmpDir, 'web/package.json'), 'utf8')).toBe(manifest); + expect(fs.readFileSync(path.join(tmpDir, 'web/pnpm-lock.yaml'), 'utf8')).toBe(lockfile); + expect(readJson(path.join(tmpDir, 'package.json')).scripts).toEqual({ + postinstall: 'cd web && pnpm install', + }); + const childYaml = readYaml(path.join(tmpDir, 'web/pnpm-workspace.yaml')); + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + expect(readYaml(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toBe(childYaml); + }); + + it('preserves the child install boundary when upgrading with pnpm', () => { + const manifest = fs.readFileSync(path.join(tmpDir, 'web/package.json'), 'utf8'); + const lockfile = fs.readFileSync(path.join(tmpDir, 'web/pnpm-lock.yaml'), 'utf8'); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + ...readJson(path.join(tmpDir, 'package.json')), + devDependencies: { 'vite-plus': '^0.1.12', vite: '^8.0.1' }, + }), + ); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + expect(readYamlObject(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toEqual({ + packages: ['.'], + overrides: { example: '1.0.0' }, + }); + expect(fs.readFileSync(path.join(tmpDir, 'web/package.json'), 'utf8')).toBe(manifest); + expect(fs.readFileSync(path.join(tmpDir, 'web/pnpm-lock.yaml'), 'utf8')).toBe(lockfile); + expect(readJson(path.join(tmpDir, 'package.json')).scripts).toEqual({ + postinstall: 'cd web && pnpm install', + }); + const childYaml = readYaml(path.join(tmpDir, 'web/pnpm-workspace.yaml')); + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + expect(readYaml(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toBe(childYaml); + }); + + it.each([ + [ + 'the child declares npm in packageManager', + 'web/package.json', + JSON.stringify({ name: 'web', packageManager: 'npm@10.9.4' }), + ], + [ + 'the child declares npm in devEngines', + 'web/package.json', + JSON.stringify({ name: 'web', devEngines: { packageManager: { name: 'npm' } } }), + ], + [ + 'the child declares workspaces', + 'web/package.json', + JSON.stringify({ name: 'web', packageManager: 'pnpm@10.33.0', workspaces: ['services/*'] }), + ], + [ + 'the parent declares workspaces', + 'package.json', + JSON.stringify({ name: 'root', workspaces: ['web'], devDependencies: { vite: '^8.0.1' } }), + ], + ['the parent already has a pnpm workspace', 'pnpm-workspace.yaml', 'packages:\n - web\n'], + ])('does not add a child boundary when %s', (_, file, content) => { + fs.writeFileSync(path.join(tmpDir, file), content); + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + expect(fs.existsSync(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toBe(false); + }); + + it('does not add a child boundary when the child has no pnpm lockfile', () => { + fs.unlinkSync(path.join(tmpDir, 'web/pnpm-lock.yaml')); + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + expect(fs.existsSync(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toBe(false); + }); + + it('does not create pnpm workspace files when the parent uses npm', () => { + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.npm), true, true); + expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(false); + expect(fs.existsSync(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toBe(false); + }); + + it('detects a pnpm child from its own lockfile without a packageManager declaration', () => { + fs.writeFileSync(path.join(tmpDir, 'web/package.json'), JSON.stringify({ name: 'web' })); + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + expect(readYamlObject(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toEqual({ + packages: ['.'], + }); + }); + + it('preserves a child that declares pnpm in devEngines and has its own lockfile', () => { + fs.writeFileSync( + path.join(tmpDir, 'web/package.json'), + JSON.stringify({ name: 'web', devEngines: { packageManager: { name: 'pnpm' } } }), + ); + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + expect(readYamlObject(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toEqual({ + packages: ['.'], + }); + }); + + it('preserves independent install roots in multiple child projects', () => { + fs.mkdirSync(path.join(tmpDir, 'apps')); + fs.renameSync(path.join(tmpDir, 'web'), path.join(tmpDir, 'apps/web')); + fs.mkdirSync(path.join(tmpDir, 'apps/mobile')); + fs.writeFileSync(path.join(tmpDir, 'apps/mobile/package.json'), '{"name":"mobile"}'); + fs.writeFileSync(path.join(tmpDir, 'apps/mobile/pnpm-lock.yaml'), 'lockfileVersion: "9.0"\n'); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + ...readJson(path.join(tmpDir, 'package.json')), + scripts: { postinstall: 'pnpm --dir apps/web install && pnpm --dir apps/mobile install' }, + }), + ); + + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + + for (const directory of ['apps/web', 'apps/mobile']) { + expect(readYamlObject(path.join(tmpDir, directory, 'pnpm-workspace.yaml')).packages).toEqual([ + '.', + ]); + } + }); +}); + describe('WebDriverIO browser overrides', () => { let tmpDir: string; const provider = '@vitest/browser-webdriverio'; diff --git a/packages/cli/src/migration/migrator/catalog.ts b/packages/cli/src/migration/migrator/catalog.ts index 26c7e59d5e..30b819b5cb 100644 --- a/packages/cli/src/migration/migrator/catalog.ts +++ b/packages/cli/src/migration/migrator/catalog.ts @@ -251,6 +251,19 @@ export function migratePnpmSettingsToWorkspaceYaml( }); } +export function ensureStandalonePnpmWorkspace(projectPath: string): void { + const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); + if (fs.existsSync(pnpmWorkspaceYamlPath)) { + return; + } + const pkg = readJsonFile(path.join(projectPath, 'package.json')) as { + pnpm?: PnpmPackageJsonSettings; + }; + fs.writeFileSync(pnpmWorkspaceYamlPath, 'packages:\n - "."\n'); + // Extract from the in-memory copy only: keep the child's manifest and lockfile. + migratePnpmSettingsToWorkspaceYaml(projectPath, takePnpmWorkspaceSettings(pkg)); +} + /** * Rewrite pnpm-workspace.yaml to add vite-plus dependencies * @param projectPath - The path to the project diff --git a/packages/cli/src/migration/migrator/orchestrators.ts b/packages/cli/src/migration/migrator/orchestrators.ts index d5ebc609e7..6e15ab1b0a 100644 --- a/packages/cli/src/migration/migrator/orchestrators.ts +++ b/packages/cli/src/migration/migrator/orchestrators.ts @@ -12,11 +12,13 @@ import { collectOxlintDependencyNames, collectOxlintOwnerDirs, collectProviderSourceModes, + collectStandalonePnpmProjects, collectVitestEcosystemInstallDependencyNames, createCatalogDependencyResolver, dropDeadOxlintPluginsDependency, dropRemovePackageOverrideKeys, ensurePnpmWorkspaceExoticSubdepsSetting, + ensureStandalonePnpmWorkspace, findYarnWorkspaceHoisting, hasDirectVitePlusInstallEntry, hasOwnWebdriverioDependency, @@ -77,6 +79,12 @@ export function rewriteStandaloneProject( } const packageManager = workspaceInfo.packageManager; + const standalonePnpmProjects = + packageManager === PackageManager.pnpm && + !workspaceInfo.isMonorepo && + !fs.existsSync(path.join(projectPath, 'pnpm-workspace.yaml')) + ? collectStandalonePnpmProjects(projectPath) + : []; const catalogDependencyResolver = createCatalogDependencyResolver(projectPath, packageManager); const vitestEcosystemPackages = collectVitestEcosystemInstallDependencyNames(projectPath); // Captured before `rewritePackageJson` strips `oxlint`; the import rewriter @@ -308,6 +316,9 @@ export function rewriteStandaloneProject( if (packageManager === PackageManager.pnpm) { ensurePnpmWorkspaceExoticSubdepsSetting(projectPath); + for (const directory of standalonePnpmProjects) { + ensureStandalonePnpmWorkspace(directory); + } } if (packageManager === PackageManager.yarn) { diff --git a/packages/cli/src/migration/migrator/source-scan.ts b/packages/cli/src/migration/migrator/source-scan.ts index 6d01d0b872..da63385d19 100644 --- a/packages/cli/src/migration/migrator/source-scan.ts +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -12,6 +12,7 @@ import { OXLINT_PLUGINS_PACKAGE, packageOwnsOxlintApi, PLAYWRIGHT_PROVIDER, + isPlainRecord, readPackageJsonIfExists, type DependencyBag, } from './shared.ts'; @@ -172,6 +173,58 @@ const VITEST_SCAN_SKIP_DIRS = new Set([ // source and VCS metadata; dependency peer contracts are checked separately. const OXLINT_RETENTION_SKIP_DIRS = new Set(['node_modules', '.git', '.hg', '.svn']); +// Capture independent install roots before creating the parent's workspace file. +// An existing workspace boundary owns its descendants; do not split its members. +export function collectStandalonePnpmProjects(projectPath: string): string[] { + const projects: string[] = []; + const directories = [projectPath]; + for (const directory of directories) { + if (fs.existsSync(path.join(directory, 'pnpm-workspace.yaml'))) { + continue; + } + if (fs.existsSync(path.join(directory, 'package.json'))) { + let pkg: Record; + try { + pkg = readJsonFile(path.join(directory, 'package.json')); + } catch { + continue; + } + if (!isPlainRecord(pkg) || Object.hasOwn(pkg, 'workspaces')) { + continue; + } + if ( + directory !== projectPath && + fs.existsSync(path.join(directory, 'pnpm-lock.yaml')) && + (pkg.packageManager === undefined || + (typeof pkg.packageManager === 'string' && pkg.packageManager.startsWith('pnpm@'))) && + (pkg.packageManager !== undefined || + !isPlainRecord(pkg.devEngines) || + pkg.devEngines.packageManager === undefined || + (isPlainRecord(pkg.devEngines.packageManager) && + pkg.devEngines.packageManager.name === 'pnpm')) + ) { + projects.push(directory); + } + } + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(directory, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if ( + entry.isDirectory() && + !VITEST_SCAN_SKIP_DIRS.has(entry.name) && + !OXLINT_RETENTION_SKIP_DIRS.has(entry.name) + ) { + directories.push(path.join(directory, entry.name)); + } + } + } + return projects; +} + /** * Detect whether a package uses vitest's browser mode. * diff --git a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts index 5a4105b168..7da5eb5e3e 100644 --- a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts +++ b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts @@ -17,9 +17,11 @@ import { applyBuildAllowanceToPackageJsonPnpm, applyYarnWorkspaceHoistingFix, collectProviderSourceModes, + collectStandalonePnpmProjects, collectVitestEcosystemInstallDependencyNames, createCatalogDependencyResolver, ensurePnpmWorkspacePackages, + ensureStandalonePnpmWorkspace, findYarnWorkspaceHoisting, getAlignedVitestEcosystemDependencySpec, getCatalogDependencySpec, @@ -968,6 +970,11 @@ export function ensureVitePlusBootstrap( return result; } + const standalonePnpmProjects = + workspaceInfo.packageManager === PackageManager.pnpm && !workspaceInfo.isMonorepo + ? collectStandalonePnpmProjects(projectPath) + : []; + // Shared override/catalog sinks are workspace-wide, so keep vitest managed // when any package needs it. Each package's direct vitest dependency is // reconciled independently below. @@ -1250,6 +1257,9 @@ export function ensureVitePlusBootstrap( const after = readTextFileIfExists(pnpmWorkspaceYamlPath); result.packageManagerConfig = exoticChanged || pnpmWorkspaceCatalogBefore !== after; } + for (const directory of standalonePnpmProjects) { + ensureStandalonePnpmWorkspace(directory); + } } else if (workspaceInfo.packageManager === PackageManager.yarn) { const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); const before = readTextFileIfExists(yarnrcYmlPath); From 1f025cf9dadbafdaa75c8643701cfe6cecde46ad Mon Sep 17 00:00:00 2001 From: yusuke <66258931+yusuke99@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:43:09 +0900 Subject: [PATCH 02/16] fix(migrate): recognize array-valued devEngines package managers --- .../src/migration/__tests__/migrator.spec.ts | 42 ++++++++++++++----- .../cli/src/migration/migrator/source-scan.ts | 17 +++++--- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 55a4994292..48aa70ea99 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -2013,16 +2013,38 @@ describe('standalone pnpm child project boundaries', () => { }); }); - it('preserves a child that declares pnpm in devEngines and has its own lockfile', () => { - fs.writeFileSync( - path.join(tmpDir, 'web/package.json'), - JSON.stringify({ name: 'web', devEngines: { packageManager: { name: 'pnpm' } } }), - ); - rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); - expect(readYamlObject(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toEqual({ - packages: ['.'], - }); - }); + it.each([ + { packageManager: { name: 'pnpm' } }, + { packageManager: [{ name: 'pnpm' }] }, + { packageManager: [{ name: 'pnpm' }, { name: 'npm' }] }, + ])( + 'preserves a child that declares pnpm in devEngines (%j) and has its own lockfile', + ({ packageManager }) => { + fs.writeFileSync( + path.join(tmpDir, 'web/package.json'), + JSON.stringify({ name: 'web', devEngines: { packageManager } }), + ); + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + expect(readYamlObject(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toEqual({ + packages: ['.'], + }); + }, + ); + + it.each(['npm', 'yarn', 'bun'])( + 'does not add a child boundary when devEngines lists %s before pnpm', + (name) => { + fs.writeFileSync( + path.join(tmpDir, 'web/package.json'), + JSON.stringify({ + name: 'web', + devEngines: { packageManager: [{ name }, { name: 'pnpm' }] }, + }), + ); + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + expect(fs.existsSync(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toBe(false); + }, + ); it('preserves independent install roots in multiple child projects', () => { fs.mkdirSync(path.join(tmpDir, 'apps')); diff --git a/packages/cli/src/migration/migrator/source-scan.ts b/packages/cli/src/migration/migrator/source-scan.ts index da63385d19..8abf9a0eb8 100644 --- a/packages/cli/src/migration/migrator/source-scan.ts +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { type WorkspacePackage } from '../../types/index.ts'; +import { PackageManager, type WorkspacePackage } from '../../types/index.ts'; import { VITE_PLUS_NAME } from '../../utils/constants.ts'; import { editJsonFile, readJsonFile } from '../../utils/json.ts'; import { detectPackageMetadata } from '../../utils/package.ts'; @@ -192,16 +192,23 @@ export function collectStandalonePnpmProjects(projectPath: string): string[] { if (!isPlainRecord(pkg) || Object.hasOwn(pkg, 'workspaces')) { continue; } + const devEnginePackageManager = isPlainRecord(pkg.devEngines) + ? pkg.devEngines.packageManager + : undefined; if ( directory !== projectPath && fs.existsSync(path.join(directory, 'pnpm-lock.yaml')) && (pkg.packageManager === undefined || (typeof pkg.packageManager === 'string' && pkg.packageManager.startsWith('pnpm@'))) && (pkg.packageManager !== undefined || - !isPlainRecord(pkg.devEngines) || - pkg.devEngines.packageManager === undefined || - (isPlainRecord(pkg.devEngines.packageManager) && - pkg.devEngines.packageManager.name === 'pnpm')) + devEnginePackageManager === undefined || + (Array.isArray(devEnginePackageManager) + ? devEnginePackageManager.find( + (entry: unknown): entry is Record => + isPlainRecord(entry) && + Object.values(PackageManager).some((name) => name === entry.name), + )?.name === 'pnpm' + : isPlainRecord(devEnginePackageManager) && devEnginePackageManager.name === 'pnpm')) ) { projects.push(directory); } From 455b10a421ceddc2d715e520039aa51b0001fff2 Mon Sep 17 00:00:00 2001 From: yusuke <66258931+yusuke99@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:03:39 +0900 Subject: [PATCH 03/16] fix(migrate): fall back to pnpm lockfile when devEngines permits --- .../src/migration/__tests__/migrator.spec.ts | 27 +++++++++++++++++++ .../cli/src/migration/migrator/source-scan.ts | 24 +++++++++++------ 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 48aa70ea99..5c21d86cb8 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -2046,6 +2046,33 @@ describe('standalone pnpm child project boundaries', () => { }, ); + it.each([ + { packageManager: [], expected: true }, + { packageManager: { name: 'unsupported', onFail: 'ignore' }, expected: true }, + { packageManager: [{ name: 'unsupported', onFail: 'warn' }], expected: true }, + { + packageManager: [{ name: 'unsupported' }, { name: 'other', onFail: 'ignore' }], + expected: true, + }, + { packageManager: [{ name: 'unsupported' }], expected: false }, + { packageManager: [{ name: 'unsupported', onFail: 'error' }], expected: false }, + { packageManager: [{ name: 'unsupported', onFail: 'download' }], expected: false }, + { + packageManager: [{ name: 'unsupported', onFail: 'ignore' }, { name: 'other' }], + expected: false, + }, + ])( + 'uses the child pnpm lockfile only when devEngines permits fallback (%j)', + ({ packageManager, expected }) => { + fs.writeFileSync( + path.join(tmpDir, 'web/package.json'), + JSON.stringify({ name: 'web', devEngines: { packageManager } }), + ); + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + expect(fs.existsSync(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toBe(expected); + }, + ); + it('preserves independent install roots in multiple child projects', () => { fs.mkdirSync(path.join(tmpDir, 'apps')); fs.renameSync(path.join(tmpDir, 'web'), path.join(tmpDir, 'apps/web')); diff --git a/packages/cli/src/migration/migrator/source-scan.ts b/packages/cli/src/migration/migrator/source-scan.ts index 8abf9a0eb8..d99e68eb1d 100644 --- a/packages/cli/src/migration/migrator/source-scan.ts +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -195,20 +195,28 @@ export function collectStandalonePnpmProjects(projectPath: string): string[] { const devEnginePackageManager = isPlainRecord(pkg.devEngines) ? pkg.devEngines.packageManager : undefined; + const devEngineEntries: unknown[] = + devEnginePackageManager === undefined + ? [] + : Array.isArray(devEnginePackageManager) + ? devEnginePackageManager + : [devEnginePackageManager]; + const supportedDevEngine = devEngineEntries.find( + (entry): entry is Record => + isPlainRecord(entry) && Object.values(PackageManager).some((name) => name === entry.name), + ); + const lastDevEngine = devEngineEntries.at(-1); if ( directory !== projectPath && fs.existsSync(path.join(directory, 'pnpm-lock.yaml')) && (pkg.packageManager === undefined || (typeof pkg.packageManager === 'string' && pkg.packageManager.startsWith('pnpm@'))) && (pkg.packageManager !== undefined || - devEnginePackageManager === undefined || - (Array.isArray(devEnginePackageManager) - ? devEnginePackageManager.find( - (entry: unknown): entry is Record => - isPlainRecord(entry) && - Object.values(PackageManager).some((name) => name === entry.name), - )?.name === 'pnpm' - : isPlainRecord(devEnginePackageManager) && devEnginePackageManager.name === 'pnpm')) + devEngineEntries.length === 0 || + supportedDevEngine?.name === 'pnpm' || + (supportedDevEngine === undefined && + isPlainRecord(lastDevEngine) && + (lastDevEngine.onFail === 'ignore' || lastDevEngine.onFail === 'warn'))) ) { projects.push(directory); } From 0704536179dde8ca0912027e335f1ee88882d348 Mon Sep 17 00:00:00 2001 From: yusuke <66258931+yusuke99@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:26:44 +0900 Subject: [PATCH 04/16] fix(migrate): preserve pnpm child boundaries with package.json workspaces --- .../src/migration/__tests__/migrator.spec.ts | 23 +++++++++++++++---- .../cli/src/migration/migrator/source-scan.ts | 2 +- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 5c21d86cb8..c511eb1fb3 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -1976,11 +1976,6 @@ describe('standalone pnpm child project boundaries', () => { 'web/package.json', JSON.stringify({ name: 'web', devEngines: { packageManager: { name: 'npm' } } }), ], - [ - 'the child declares workspaces', - 'web/package.json', - JSON.stringify({ name: 'web', packageManager: 'pnpm@10.33.0', workspaces: ['services/*'] }), - ], [ 'the parent declares workspaces', 'package.json', @@ -1993,6 +1988,24 @@ describe('standalone pnpm child project boundaries', () => { expect(fs.existsSync(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toBe(false); }); + it('preserves the child pnpm boundary without enabling package.json workspaces', () => { + const manifest = JSON.stringify({ + name: 'web', + packageManager: 'pnpm@10.33.0', + workspaces: ['services/*'], + }); + fs.writeFileSync(path.join(tmpDir, 'web/package.json'), manifest); + const lockfile = readYaml(path.join(tmpDir, 'web/pnpm-lock.yaml')); + + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + + expect(readYamlObject(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toEqual({ + packages: ['.'], + }); + expect(fs.readFileSync(path.join(tmpDir, 'web/package.json'), 'utf8')).toBe(manifest); + expect(readYaml(path.join(tmpDir, 'web/pnpm-lock.yaml'))).toBe(lockfile); + }); + it('does not add a child boundary when the child has no pnpm lockfile', () => { fs.unlinkSync(path.join(tmpDir, 'web/pnpm-lock.yaml')); rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); diff --git a/packages/cli/src/migration/migrator/source-scan.ts b/packages/cli/src/migration/migrator/source-scan.ts index d99e68eb1d..551a385c2c 100644 --- a/packages/cli/src/migration/migrator/source-scan.ts +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -189,7 +189,7 @@ export function collectStandalonePnpmProjects(projectPath: string): string[] { } catch { continue; } - if (!isPlainRecord(pkg) || Object.hasOwn(pkg, 'workspaces')) { + if (!isPlainRecord(pkg) || (directory === projectPath && Object.hasOwn(pkg, 'workspaces'))) { continue; } const devEnginePackageManager = isPlainRecord(pkg.devEngines) From 27bd5111be245c9c7832744607609b0ecd85dd03 Mon Sep 17 00:00:00 2001 From: yusuke <66258931+yusuke99@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:08:17 +0900 Subject: [PATCH 05/16] fix(migrate): normalize devEngines entries before pnpm detection --- .../src/migration/__tests__/migrator.spec.ts | 10 +++++++++ .../cli/src/migration/migrator/source-scan.ts | 22 +++++++++---------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index c511eb1fb3..795a961bb2 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -2061,6 +2061,16 @@ describe('standalone pnpm child project boundaries', () => { it.each([ { packageManager: [], expected: true }, + { packageManager: {}, expected: true }, + { packageManager: null, expected: true }, + { packageManager: [null, {}, { name: 42 }, { name: ' \t' }], expected: true }, + { packageManager: { name: ' pnpm ' }, expected: true }, + { packageManager: [{ name: ' npm ' }, { name: 'pnpm' }], expected: false }, + { + packageManager: [{ name: 'unsupported', onFail: 'ignore' }, {}], + expected: true, + }, + { packageManager: [{ name: 'unsupported' }, { onFail: 'ignore' }], expected: false }, { packageManager: { name: 'unsupported', onFail: 'ignore' }, expected: true }, { packageManager: [{ name: 'unsupported', onFail: 'warn' }], expected: true }, { diff --git a/packages/cli/src/migration/migrator/source-scan.ts b/packages/cli/src/migration/migrator/source-scan.ts index 551a385c2c..250bcecf36 100644 --- a/packages/cli/src/migration/migrator/source-scan.ts +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -195,15 +195,16 @@ export function collectStandalonePnpmProjects(projectPath: string): string[] { const devEnginePackageManager = isPlainRecord(pkg.devEngines) ? pkg.devEngines.packageManager : undefined; - const devEngineEntries: unknown[] = - devEnginePackageManager === undefined - ? [] - : Array.isArray(devEnginePackageManager) - ? devEnginePackageManager - : [devEnginePackageManager]; - const supportedDevEngine = devEngineEntries.find( - (entry): entry is Record => - isPlainRecord(entry) && Object.values(PackageManager).some((name) => name === entry.name), + const devEngineEntries = ( + Array.isArray(devEnginePackageManager) ? devEnginePackageManager : [devEnginePackageManager] + ) + .filter( + (entry: unknown): entry is Record & { name: string } => + isPlainRecord(entry) && typeof entry.name === 'string' && entry.name.trim().length > 0, + ) + .map((entry) => ({ name: entry.name.trim(), onFail: entry.onFail })); + const supportedDevEngine = devEngineEntries.find((entry) => + Object.values(PackageManager).some((name) => name === entry.name), ); const lastDevEngine = devEngineEntries.at(-1); if ( @@ -215,8 +216,7 @@ export function collectStandalonePnpmProjects(projectPath: string): string[] { devEngineEntries.length === 0 || supportedDevEngine?.name === 'pnpm' || (supportedDevEngine === undefined && - isPlainRecord(lastDevEngine) && - (lastDevEngine.onFail === 'ignore' || lastDevEngine.onFail === 'warn'))) + (lastDevEngine?.onFail === 'ignore' || lastDevEngine?.onFail === 'warn'))) ) { projects.push(directory); } From 3be6dbf6cdf15b54567d5901eacd2e83451674fc Mon Sep 17 00:00:00 2001 From: yusuke <66258931+yusuke99@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:52:53 +0900 Subject: [PATCH 06/16] fix(migrate): fall back from unusable packageManager declarations --- .../src/migration/__tests__/migrator.spec.ts | 22 +++++++++++++++++++ .../cli/src/migration/migrator/source-scan.ts | 10 ++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 795a961bb2..f5fc045e80 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -2026,6 +2026,28 @@ describe('standalone pnpm child project boundaries', () => { }); }); + it.each([ + { packageManager: '', devEngine: undefined, expected: true }, + { packageManager: 'pnpm', devEngine: undefined, expected: true }, + { packageManager: '', devEngine: 'pnpm', expected: true }, + { packageManager: 'pnpm', devEngine: 'npm', expected: false }, + { packageManager: 'npm@10.9.4', devEngine: 'pnpm', expected: false }, + ])( + 'resolves child packageManager fallback in the correct order (%j)', + ({ packageManager, devEngine, expected }) => { + fs.writeFileSync( + path.join(tmpDir, 'web/package.json'), + JSON.stringify({ + name: 'web', + packageManager, + devEngines: devEngine === undefined ? undefined : { packageManager: { name: devEngine } }, + }), + ); + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + expect(fs.existsSync(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toBe(expected); + }, + ); + it.each([ { packageManager: { name: 'pnpm' } }, { packageManager: [{ name: 'pnpm' }] }, diff --git a/packages/cli/src/migration/migrator/source-scan.ts b/packages/cli/src/migration/migrator/source-scan.ts index 250bcecf36..df58cd57fb 100644 --- a/packages/cli/src/migration/migrator/source-scan.ts +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -192,6 +192,10 @@ export function collectStandalonePnpmProjects(projectPath: string): string[] { if (!isPlainRecord(pkg) || (directory === projectPath && Object.hasOwn(pkg, 'workspaces'))) { continue; } + const packageManager = + typeof pkg.packageManager === 'string' && !pkg.packageManager.includes('@') + ? undefined + : pkg.packageManager; const devEnginePackageManager = isPlainRecord(pkg.devEngines) ? pkg.devEngines.packageManager : undefined; @@ -210,9 +214,9 @@ export function collectStandalonePnpmProjects(projectPath: string): string[] { if ( directory !== projectPath && fs.existsSync(path.join(directory, 'pnpm-lock.yaml')) && - (pkg.packageManager === undefined || - (typeof pkg.packageManager === 'string' && pkg.packageManager.startsWith('pnpm@'))) && - (pkg.packageManager !== undefined || + (packageManager === undefined || + (typeof packageManager === 'string' && packageManager.startsWith('pnpm@'))) && + (packageManager !== undefined || devEngineEntries.length === 0 || supportedDevEngine?.name === 'pnpm' || (supportedDevEngine === undefined && From a5dafb19f0b61a48a856dae09bf84f716409be0c Mon Sep 17 00:00:00 2001 From: yusuke <66258931+yusuke99@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:44:10 +0900 Subject: [PATCH 07/16] fix(migrate): preserve pnpm child boundaries without lockfiles --- .../src/migration/__tests__/migrator.spec.ts | 57 ++++++++++++++++++- .../cli/src/migration/migrator/source-scan.ts | 26 ++++++--- 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index ed186af987..47d3968f38 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -2061,12 +2061,67 @@ describe('standalone pnpm child project boundaries', () => { expect(readYaml(path.join(tmpDir, 'web/pnpm-lock.yaml'))).toBe(lockfile); }); - it('does not add a child boundary when the child has no pnpm lockfile', () => { + it('does not add a child boundary without a pnpm declaration or lockfile', () => { fs.unlinkSync(path.join(tmpDir, 'web/pnpm-lock.yaml')); + fs.writeFileSync(path.join(tmpDir, 'web/package.json'), JSON.stringify({ name: 'web' })); rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); expect(fs.existsSync(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toBe(false); }); + it.each([ + { packageManager: 'pnpm@11.25.0' }, + { devEngines: { packageManager: { name: 'pnpm' } } }, + ])('preserves an explicitly declared pnpm child before its first install (%j)', (declaration) => { + fs.unlinkSync(path.join(tmpDir, 'web/pnpm-lock.yaml')); + const manifest = JSON.stringify({ name: 'web', ...declaration }); + fs.writeFileSync(path.join(tmpDir, 'web/package.json'), manifest); + + rewriteStandaloneProject( + tmpDir, + makeWorkspaceInfo(tmpDir, PackageManager.pnpm, '11.25.0'), + true, + true, + ); + + expect(readYamlObject(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toEqual({ + packages: ['.'], + }); + expect(fs.readFileSync(path.join(tmpDir, 'web/package.json'), 'utf8')).toBe(manifest); + expect(fs.existsSync(path.join(tmpDir, 'web/pnpm-lock.yaml'))).toBe(false); + }); + + it.each(['.pnpmfile.cjs', 'pnpmfile.cjs'])( + 'preserves a pnpm child identified by %s before its first install', + (file) => { + fs.unlinkSync(path.join(tmpDir, 'web/pnpm-lock.yaml')); + fs.writeFileSync(path.join(tmpDir, 'web/package.json'), JSON.stringify({ name: 'web' })); + fs.writeFileSync(path.join(tmpDir, 'web', file), 'module.exports = { hooks: {} };'); + + rewriteStandaloneProject( + tmpDir, + makeWorkspaceInfo(tmpDir, PackageManager.pnpm, '11.25.0'), + true, + true, + ); + + expect(readYamlObject(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toEqual({ + packages: ['.'], + }); + expect(fs.existsSync(path.join(tmpDir, 'web/pnpm-lock.yaml'))).toBe(false); + }, + ); + + it('prefers a child yarn lockfile over its pnpmfile', () => { + fs.unlinkSync(path.join(tmpDir, 'web/pnpm-lock.yaml')); + fs.writeFileSync(path.join(tmpDir, 'web/package.json'), JSON.stringify({ name: 'web' })); + fs.writeFileSync(path.join(tmpDir, 'web/.pnpmfile.cjs'), 'module.exports = { hooks: {} };'); + fs.writeFileSync(path.join(tmpDir, 'web/yarn.lock'), ''); + + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + + expect(fs.existsSync(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toBe(false); + }); + it('does not create pnpm workspace files when the parent uses npm', () => { rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.npm), true, true); expect(fs.existsSync(path.join(tmpDir, 'pnpm-workspace.yaml'))).toBe(false); diff --git a/packages/cli/src/migration/migrator/source-scan.ts b/packages/cli/src/migration/migrator/source-scan.ts index df58cd57fb..0c6fbd4fb4 100644 --- a/packages/cli/src/migration/migrator/source-scan.ts +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -211,16 +211,26 @@ export function collectStandalonePnpmProjects(projectPath: string): string[] { Object.values(PackageManager).some((name) => name === entry.name), ); const lastDevEngine = devEngineEntries.at(-1); + const declaredPackageManager = + packageManager === undefined + ? supportedDevEngine?.name + : typeof packageManager === 'string' + ? packageManager.split('@')[0] + : packageManager; if ( directory !== projectPath && - fs.existsSync(path.join(directory, 'pnpm-lock.yaml')) && - (packageManager === undefined || - (typeof packageManager === 'string' && packageManager.startsWith('pnpm@'))) && - (packageManager !== undefined || - devEngineEntries.length === 0 || - supportedDevEngine?.name === 'pnpm' || - (supportedDevEngine === undefined && - (lastDevEngine?.onFail === 'ignore' || lastDevEngine?.onFail === 'warn'))) + (declaredPackageManager === 'pnpm' || + (declaredPackageManager === undefined && + (devEngineEntries.length === 0 || + lastDevEngine?.onFail === 'ignore' || + lastDevEngine?.onFail === 'warn') && + (fs.existsSync(path.join(directory, 'pnpm-lock.yaml')) || + (!['yarn.lock', '.yarnrc.yml', 'package-lock.json', 'bun.lock', 'bun.lockb'].some( + (file) => fs.existsSync(path.join(directory, file)), + ) && + ['.pnpmfile.cjs', 'pnpmfile.cjs'].some((file) => + fs.existsSync(path.join(directory, file)), + ))))) ) { projects.push(directory); } From 375b8a42338539acf418a4f0084aba442a56a6e7 Mon Sep 17 00:00:00 2001 From: yusuke <66258931+yusuke99@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:47:09 +0900 Subject: [PATCH 08/16] fix(migrate): preserve workspace boundaries for unpinned child projects --- .../src/migration/__tests__/migrator.spec.ts | 34 ++++++++++++++++++- .../cli/src/migration/migrator/source-scan.ts | 7 ++-- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 47d3968f38..ec6292289c 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -2061,10 +2061,40 @@ describe('standalone pnpm child project boundaries', () => { expect(readYaml(path.join(tmpDir, 'web/pnpm-lock.yaml'))).toBe(lockfile); }); - it('does not add a child boundary without a pnpm declaration or lockfile', () => { + it('preserves an unpinned child before its first install', () => { + fs.unlinkSync(path.join(tmpDir, 'web/pnpm-lock.yaml')); + const manifest = JSON.stringify({ name: 'web' }); + fs.writeFileSync(path.join(tmpDir, 'web/package.json'), manifest); + + rewriteStandaloneProject( + tmpDir, + makeWorkspaceInfo(tmpDir, PackageManager.pnpm, '11.25.0'), + true, + true, + ); + + expect(readYamlObject(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toEqual({ + packages: ['.'], + }); + expect(fs.readFileSync(path.join(tmpDir, 'web/package.json'), 'utf8')).toBe(manifest); + expect(fs.existsSync(path.join(tmpDir, 'web/pnpm-lock.yaml'))).toBe(false); + }); + + it.each([ + 'yarn.lock', + '.yarnrc.yml', + 'package-lock.json', + 'bun.lock', + 'bun.lockb', + 'bunfig.toml', + 'yarn.config.cjs', + ])('does not add a boundary to an unpinned child identified by %s', (file) => { fs.unlinkSync(path.join(tmpDir, 'web/pnpm-lock.yaml')); fs.writeFileSync(path.join(tmpDir, 'web/package.json'), JSON.stringify({ name: 'web' })); + fs.writeFileSync(path.join(tmpDir, 'web', file), ''); + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + expect(fs.existsSync(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toBe(false); }); @@ -2096,6 +2126,8 @@ describe('standalone pnpm child project boundaries', () => { fs.unlinkSync(path.join(tmpDir, 'web/pnpm-lock.yaml')); fs.writeFileSync(path.join(tmpDir, 'web/package.json'), JSON.stringify({ name: 'web' })); fs.writeFileSync(path.join(tmpDir, 'web', file), 'module.exports = { hooks: {} };'); + fs.writeFileSync(path.join(tmpDir, 'web/bunfig.toml'), ''); + fs.writeFileSync(path.join(tmpDir, 'web/yarn.config.cjs'), 'module.exports = {};'); rewriteStandaloneProject( tmpDir, diff --git a/packages/cli/src/migration/migrator/source-scan.ts b/packages/cli/src/migration/migrator/source-scan.ts index 0c6fbd4fb4..aa43c616de 100644 --- a/packages/cli/src/migration/migrator/source-scan.ts +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -228,9 +228,12 @@ export function collectStandalonePnpmProjects(projectPath: string): string[] { (!['yarn.lock', '.yarnrc.yml', 'package-lock.json', 'bun.lock', 'bun.lockb'].some( (file) => fs.existsSync(path.join(directory, file)), ) && - ['.pnpmfile.cjs', 'pnpmfile.cjs'].some((file) => + (['.pnpmfile.cjs', 'pnpmfile.cjs'].some((file) => fs.existsSync(path.join(directory, file)), - ))))) + ) || + !['bunfig.toml', 'yarn.config.cjs'].some((file) => + fs.existsSync(path.join(directory, file)), + )))))) ) { projects.push(directory); } From d6c1afebb4ba795f2cb3cc792174370fa0a5a037 Mon Sep 17 00:00:00 2001 From: yusuke <66258931+yusuke99@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:52:49 +0900 Subject: [PATCH 09/16] fix(migrate): stop scanning inside non-pnpm workspaces --- .../src/migration/__tests__/migrator.spec.ts | 47 +++++++++++++++++++ .../cli/src/migration/migrator/source-scan.ts | 2 + 2 files changed, 49 insertions(+) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index ec6292289c..e529d4126b 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -2061,6 +2061,53 @@ describe('standalone pnpm child project boundaries', () => { expect(readYaml(path.join(tmpDir, 'web/pnpm-lock.yaml'))).toBe(lockfile); }); + it.each([ + { packageManager: 'yarn@4.9.2' }, + { packageManager: 'npm@10.9.4' }, + { packageManager: 'bun@1.3.0' }, + { devEngines: { packageManager: { name: 'yarn' } } }, + { file: 'yarn.lock' }, + { file: 'package-lock.json' }, + { file: 'bun.lock' }, + ])('does not split members of a nested non-pnpm workspace (%j)', (declaration) => { + fs.mkdirSync(path.join(tmpDir, 'vendor/packages/app'), { recursive: true }); + const manifest = JSON.stringify({ name: 'vendor', workspaces: ['packages/*'], ...declaration }); + fs.writeFileSync(path.join(tmpDir, 'vendor/package.json'), manifest); + fs.writeFileSync( + path.join(tmpDir, 'vendor/packages/app/package.json'), + JSON.stringify({ name: 'app' }), + ); + if (declaration.file !== undefined) { + fs.writeFileSync(path.join(tmpDir, 'vendor', declaration.file), ''); + } + + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + + expect(fs.existsSync(path.join(tmpDir, 'vendor/pnpm-workspace.yaml'))).toBe(false); + expect(fs.existsSync(path.join(tmpDir, 'vendor/packages/app/pnpm-workspace.yaml'))).toBe(false); + expect(fs.readFileSync(path.join(tmpDir, 'vendor/package.json'), 'utf8')).toBe(manifest); + expect(fs.existsSync(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toBe(true); + }); + + it('continues scanning below a non-pnpm standalone project', () => { + fs.mkdirSync(path.join(tmpDir, 'vendor/app'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'vendor/package.json'), + JSON.stringify({ name: 'vendor', packageManager: 'npm@10.9.4' }), + ); + fs.writeFileSync( + path.join(tmpDir, 'vendor/app/package.json'), + JSON.stringify({ name: 'app', packageManager: 'pnpm@11.25.0' }), + ); + + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + + expect(fs.existsSync(path.join(tmpDir, 'vendor/pnpm-workspace.yaml'))).toBe(false); + expect(readYamlObject(path.join(tmpDir, 'vendor/app/pnpm-workspace.yaml'))).toEqual({ + packages: ['.'], + }); + }); + it('preserves an unpinned child before its first install', () => { fs.unlinkSync(path.join(tmpDir, 'web/pnpm-lock.yaml')); const manifest = JSON.stringify({ name: 'web' }); diff --git a/packages/cli/src/migration/migrator/source-scan.ts b/packages/cli/src/migration/migrator/source-scan.ts index aa43c616de..95b8f37afd 100644 --- a/packages/cli/src/migration/migrator/source-scan.ts +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -236,6 +236,8 @@ export function collectStandalonePnpmProjects(projectPath: string): string[] { )))))) ) { projects.push(directory); + } else if (Object.hasOwn(pkg, 'workspaces')) { + continue; } } let entries: fs.Dirent[]; From 37d42d0a7f25fe2124321afbe6500f77364cbd72 Mon Sep 17 00:00:00 2001 From: yusuke <66258931+yusuke99@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:35:18 +0900 Subject: [PATCH 10/16] fix(migrate): only add workspace boundaries to positively pnpm children --- .../snapshots.toml | 1 + .../migration_nested_pnpm_postinstall.md | 5 + .../src/migration/__tests__/migrator.spec.ts | 131 ++++++++++++++++- .../cli/src/migration/migrator/catalog.ts | 20 ++- .../src/migration/migrator/orchestrators.ts | 13 +- .../cli/src/migration/migrator/source-scan.ts | 138 ++++++++++-------- .../migration/migrator/vite-plus-bootstrap.ts | 8 +- 7 files changed, 237 insertions(+), 79 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots.toml index e636b1d4c0..e79f2572d1 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots.toml @@ -6,6 +6,7 @@ unset-env = ["VP_SKIP_INSTALL"] steps = [ { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "migrate a standalone project whose postinstall installs a nested pnpm project", timeout = 120000 }, { argv = ["vp", "install"], comment = "install completes without postinstall recursion", snapshot = false, timeout = 120000 }, + { argv = ["pnpm", "install", "--frozen-lockfile"], cwd = "web", comment = "the child's frozen install still succeeds", snapshot = false, timeout = 120000 }, { argv = ["vpt", "print-file", "web/pnpm-workspace.yaml"], comment = "the child keeps its install boundary" }, { argv = ["vpt", "stat-file", "web/node_modules/test-vite-plus-install", "--assert-not", "missing"], comment = "the child's dependency was installed" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots/migration_nested_pnpm_postinstall.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots/migration_nested_pnpm_postinstall.md index 03ae782db2..fd7a534001 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots/migration_nested_pnpm_postinstall.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots/migration_nested_pnpm_postinstall.md @@ -20,6 +20,11 @@ VITE+ - The Unified Toolchain for the Web install completes without postinstall recursion +## `cd web && pnpm install --frozen-lockfile` + +the child's frozen install still succeeds + + ## `vpt print-file web/pnpm-workspace.yaml` the child keeps its install boundary diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index e529d4126b..4b5191552a 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -2043,11 +2043,45 @@ describe('standalone pnpm child project boundaries', () => { expect(fs.existsSync(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toBe(false); }); - it('preserves the child pnpm boundary without enabling package.json workspaces', () => { + it.each([ + { workspaces: ['services/*'] }, + { workspaces: { packages: ['services/*', '!services/fixtures'] } }, + ])( + 'preserves the child pnpm boundary without enabling package.json workspaces (%j)', + ({ workspaces }) => { + const manifest = JSON.stringify({ + name: 'web', + packageManager: 'pnpm@10.33.0', + workspaces, + }); + fs.writeFileSync(path.join(tmpDir, 'web/package.json'), manifest); + const lockfile = readYaml(path.join(tmpDir, 'web/pnpm-lock.yaml')); + + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + + expect(readYamlObject(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toEqual({ + packages: ['.'], + }); + expect(fs.readFileSync(path.join(tmpDir, 'web/package.json'), 'utf8')).toBe(manifest); + expect(readYaml(path.join(tmpDir, 'web/pnpm-lock.yaml'))).toBe(lockfile); + }, + ); + + it.each([ + { packageManager: 'pnpm@10.6.1', settingsInYaml: false }, + { packageManager: 'pnpm@10.6.2+sha512.example', settingsInYaml: true }, + { devEngines: { packageManager: { name: 'pnpm', version: '10.5.0' } }, settingsInYaml: false }, + { + devEngines: { + packageManager: [{ name: 'unsupported' }, { name: 'pnpm', version: '10.6.1' }], + }, + settingsInYaml: false, + }, + ])('preserves child pnpm settings according to its own pin (%j)', (declaration) => { const manifest = JSON.stringify({ name: 'web', - packageManager: 'pnpm@10.33.0', - workspaces: ['services/*'], + ...declaration, + pnpm: { onlyBuiltDependencies: ['example'], overrides: { example: '1.0.0' } }, }); fs.writeFileSync(path.join(tmpDir, 'web/package.json'), manifest); const lockfile = readYaml(path.join(tmpDir, 'web/pnpm-lock.yaml')); @@ -2056,11 +2090,62 @@ describe('standalone pnpm child project boundaries', () => { expect(readYamlObject(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toEqual({ packages: ['.'], + ...(declaration.settingsInYaml + ? { onlyBuiltDependencies: ['example'], overrides: { example: '1.0.0' } } + : {}), }); expect(fs.readFileSync(path.join(tmpDir, 'web/package.json'), 'utf8')).toBe(manifest); expect(readYaml(path.join(tmpDir, 'web/pnpm-lock.yaml'))).toBe(lockfile); }); + it.each(['10.5.0', '10.33.0', '11.25.0'])( + 'detects and repairs a missing child boundary on a pnpm %s workspace upgrade', + (version) => { + const workspace = makeWorkspaceInfo(tmpDir, PackageManager.pnpm, version); + ensureVitePlusBootstrap(workspace); + workspace.isMonorepo = true; + workspace.workspacePatterns = ['.']; + ensureVitePlusBootstrap(workspace); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm, [], version)).toBe(false); + expect(ensureVitePlusBootstrap(workspace).changed).toBe(false); + const childYaml = readYaml(path.join(tmpDir, 'web/pnpm-workspace.yaml')); + fs.unlinkSync(path.join(tmpDir, 'web/pnpm-workspace.yaml')); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm, [], version)).toBe(true); + const report = createMigrationReport(); + expect(ensureVitePlusBootstrap(workspace, report).packageManagerConfig).toBe(true); + expect(report.packageManagerBootstrapConfigured).toBe(true); + expect(readYaml(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toBe(childYaml); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm, [], version)).toBe(false); + expect(ensureVitePlusBootstrap(workspace).changed).toBe(false); + }, + ); + + it.each([false, true])( + 'repairs independent children outside existing workspace globs (monorepo: %s)', + (monorepo) => { + fs.mkdirSync(path.join(tmpDir, 'packages/member'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'packages/member/package.json'), + JSON.stringify({ name: 'member', packageManager: 'pnpm@10.33.0' }), + ); + fs.writeFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\n'); + const workspace = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); + workspace.isMonorepo = monorepo; + workspace.workspacePatterns = ['packages/*']; + workspace.packages = [{ name: 'member', path: 'packages/member' }]; + + if (monorepo) { + rewriteMonorepo(workspace, true, true); + } else { + rewriteStandaloneProject(tmpDir, workspace, true, true); + } + + expect(fs.existsSync(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toBe(true); + expect(fs.existsSync(path.join(tmpDir, 'packages/member/pnpm-workspace.yaml'))).toBe(false); + }, + ); + it.each([ { packageManager: 'yarn@4.9.2' }, { packageManager: 'npm@10.9.4' }, @@ -2108,7 +2193,23 @@ describe('standalone pnpm child project boundaries', () => { }); }); - it('preserves an unpinned child before its first install', () => { + it('leaves a malformed child manifest alone without hiding projects below it', () => { + fs.writeFileSync(path.join(tmpDir, 'web/package.json'), '{'); + fs.mkdirSync(path.join(tmpDir, 'web/server')); + fs.writeFileSync( + path.join(tmpDir, 'web/server/package.json'), + JSON.stringify({ name: 'server', packageManager: 'pnpm@10.33.0' }), + ); + + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + + expect(fs.existsSync(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toBe(false); + expect(readYamlObject(path.join(tmpDir, 'web/server/pnpm-workspace.yaml'))).toEqual({ + packages: ['.'], + }); + }); + + it('leaves a child without pnpm signals unchanged even when postinstall installs it', () => { fs.unlinkSync(path.join(tmpDir, 'web/pnpm-lock.yaml')); const manifest = JSON.stringify({ name: 'web' }); fs.writeFileSync(path.join(tmpDir, 'web/package.json'), manifest); @@ -2120,9 +2221,7 @@ describe('standalone pnpm child project boundaries', () => { true, ); - expect(readYamlObject(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toEqual({ - packages: ['.'], - }); + expect(fs.existsSync(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toBe(false); expect(fs.readFileSync(path.join(tmpDir, 'web/package.json'), 'utf8')).toBe(manifest); expect(fs.existsSync(path.join(tmpDir, 'web/pnpm-lock.yaml'))).toBe(false); }); @@ -2215,6 +2314,24 @@ describe('standalone pnpm child project boundaries', () => { }); }); + it.each([ + { packageManager: 'pnpm@10.33.0', expected: true }, + { packageManager: 'yarn@4.9.2', expected: false }, + { devEngines: { packageManager: { name: 'pnpm' } }, expected: true }, + { devEngines: { packageManager: { name: 'yarn' } }, expected: false }, + { expected: true }, + ])('resolves conflicting child lockfiles after explicit declarations (%j)', (declaration) => { + fs.writeFileSync( + path.join(tmpDir, 'web/package.json'), + JSON.stringify({ name: 'web', ...declaration }), + ); + fs.writeFileSync(path.join(tmpDir, 'web/yarn.lock'), ''); + + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + + expect(fs.existsSync(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toBe(declaration.expected); + }); + it.each([ { packageManager: '', devEngine: undefined, expected: true }, { packageManager: 'pnpm', devEngine: undefined, expected: true }, diff --git a/packages/cli/src/migration/migrator/catalog.ts b/packages/cli/src/migration/migrator/catalog.ts index 30b819b5cb..868eb6b6bc 100644 --- a/packages/cli/src/migration/migrator/catalog.ts +++ b/packages/cli/src/migration/migrator/catalog.ts @@ -17,6 +17,7 @@ import { editJsonFile, readJsonFile } from '../../utils/json.ts'; import { type NpmWorkspaces } from '../../utils/workspace.ts'; import { editYamlFile, readYamlFile, scalarString, type YamlDocument } from '../../utils/yaml.ts'; import { + declaredPackageManager, dropRemovePackageOverrideKeys, ensurePnpmExoticSubdepsSetting, hasDirectVitePlusInstallEntry, @@ -251,17 +252,22 @@ export function migratePnpmSettingsToWorkspaceYaml( }); } -export function ensureStandalonePnpmWorkspace(projectPath: string): void { +export function ensureStandalonePnpmWorkspace(projectPath: string): boolean { const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); if (fs.existsSync(pnpmWorkspaceYamlPath)) { - return; + return false; } - const pkg = readJsonFile(path.join(projectPath, 'package.json')) as { - pnpm?: PnpmPackageJsonSettings; - }; + const pkg = readJsonFile(path.join(projectPath, 'package.json')); + // pnpm ignores package.json#workspaces. Copying its globs would expand the + // install and invalidate the child's existing frozen lockfile. fs.writeFileSync(pnpmWorkspaceYamlPath, 'packages:\n - "."\n'); - // Extract from the in-memory copy only: keep the child's manifest and lockfile. - migratePnpmSettingsToWorkspaceYaml(projectPath, takePnpmWorkspaceSettings(pkg)); + const version = declaredPackageManager(pkg)?.version; + if (version === undefined || pnpmSupportsWorkspaceSettings(version)) { + // Extract from the in-memory copy only: keep the child's manifest and lockfile. + // Older pinned pnpm versions must keep reading their settings from package.json. + migratePnpmSettingsToWorkspaceYaml(projectPath, takePnpmWorkspaceSettings(pkg)); + } + return true; } /** diff --git a/packages/cli/src/migration/migrator/orchestrators.ts b/packages/cli/src/migration/migrator/orchestrators.ts index 22cb05b803..7517b8ecb7 100644 --- a/packages/cli/src/migration/migrator/orchestrators.ts +++ b/packages/cli/src/migration/migrator/orchestrators.ts @@ -81,11 +81,7 @@ export function rewriteStandaloneProject( const packageManager = workspaceInfo.packageManager; const standalonePnpmProjects = - packageManager === PackageManager.pnpm && - !workspaceInfo.isMonorepo && - !fs.existsSync(path.join(projectPath, 'pnpm-workspace.yaml')) - ? collectStandalonePnpmProjects(projectPath) - : []; + packageManager === PackageManager.pnpm ? collectStandalonePnpmProjects(projectPath) : []; const catalogDependencyResolver = createCatalogDependencyResolver(projectPath, packageManager); const vitestEcosystemPackages = collectVitestEcosystemInstallDependencyNames(projectPath); // Captured before `rewritePackageJson` strips `oxlint`; the import rewriter @@ -365,6 +361,10 @@ export function rewriteMonorepo( silent = false, report?: MigrationReport, ): void { + const standalonePnpmProjects = + workspaceInfo.packageManager === PackageManager.pnpm + ? collectStandalonePnpmProjects(workspaceInfo.rootDir) + : []; const catalogDependencyResolver = createCatalogDependencyResolver( workspaceInfo.rootDir, workspaceInfo.packageManager, @@ -456,6 +456,9 @@ export function rewriteMonorepo( [VITE_PLUS_NAME]: VITE_PLUS_VERSION, }); } + for (const directory of standalonePnpmProjects) { + ensureStandalonePnpmWorkspace(directory); + } } // (mergeViteConfigFiles below will sanitize the merged lint config // against this workspace's full package set.) diff --git a/packages/cli/src/migration/migrator/source-scan.ts b/packages/cli/src/migration/migrator/source-scan.ts index 95b8f37afd..346222d1ef 100644 --- a/packages/cli/src/migration/migrator/source-scan.ts +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -6,6 +6,8 @@ import { VITE_PLUS_NAME } from '../../utils/constants.ts'; import { editJsonFile, readJsonFile } from '../../utils/json.ts'; import { detectPackageMetadata } from '../../utils/package.ts'; import { hasVitestTypesInTsconfig } from '../../utils/tsconfig.ts'; +import { findPackageJsonFilesFromPatterns } from '../../utils/workspace.ts'; +import { readYamlFile } from '../../utils/yaml.ts'; import { projectUsesVitestDirectly } from '../migrator.ts'; import { OPT_IN_BROWSER_PROVIDERS, @@ -173,71 +175,95 @@ const VITEST_SCAN_SKIP_DIRS = new Set([ // source and VCS metadata; dependency peer contracts are checked separately. const OXLINT_RETENTION_SKIP_DIRS = new Set(['node_modules', '.git', '.hg', '.svn']); -// Capture independent install roots before creating the parent's workspace file. -// An existing workspace boundary owns its descendants; do not split its members. +// Match the Rust precedence: packageManager, then the first supported devEngines +// entry. Unsupported entries block file-based fallback unless onFail permits it. +export function declaredPackageManager( + pkg: Record, +): { name: string; version?: string } | undefined { + if (typeof pkg.packageManager === 'string' && pkg.packageManager.includes('@')) { + const [name = '', version] = pkg.packageManager.split('@'); + return { name, version: version?.split('+')[0] }; + } + const field = isPlainRecord(pkg.devEngines) ? pkg.devEngines.packageManager : undefined; + const entries = (Array.isArray(field) ? field : [field]) + .filter( + (entry): entry is Record & { name: string } => + isPlainRecord(entry) && typeof entry.name === 'string' && entry.name.trim().length > 0, + ) + .map((entry) => ({ + name: entry.name.trim(), + version: typeof entry.version === 'string' ? entry.version : undefined, + onFail: entry.onFail, + })); + const supported = entries.find((entry) => + Object.values(PackageManager).some((name) => name === entry.name), + ); + const last = entries.at(-1); + return supported ?? (last?.onFail === 'ignore' || last?.onFail === 'warn' ? undefined : last); +} + +// Capture independent pnpm install roots before creating workspace files. +// Existing workspaces own their matched members, not every descendant. export function collectStandalonePnpmProjects(projectPath: string): string[] { const projects: string[] = []; - const directories = [projectPath]; + const directories = [path.resolve(projectPath)]; + const workspaceMembers = new Set(); for (const directory of directories) { - if (fs.existsSync(path.join(directory, 'pnpm-workspace.yaml'))) { + if (workspaceMembers.has(directory)) { continue; } + const workspaceFile = path.join(directory, 'pnpm-workspace.yaml'); + const hasWorkspaceFile = fs.existsSync(workspaceFile); + let pkg: Record | undefined; if (fs.existsSync(path.join(directory, 'package.json'))) { - let pkg: Record; try { - pkg = readJsonFile(path.join(directory, 'package.json')); + const parsed = readJsonFile(path.join(directory, 'package.json')); + if (isPlainRecord(parsed)) { + pkg = parsed; + } } catch { - continue; + // A malformed manifest must not hide independent projects below it. } - if (!isPlainRecord(pkg) || (directory === projectPath && Object.hasOwn(pkg, 'workspaces'))) { - continue; - } - const packageManager = - typeof pkg.packageManager === 'string' && !pkg.packageManager.includes('@') - ? undefined - : pkg.packageManager; - const devEnginePackageManager = isPlainRecord(pkg.devEngines) - ? pkg.devEngines.packageManager - : undefined; - const devEngineEntries = ( - Array.isArray(devEnginePackageManager) ? devEnginePackageManager : [devEnginePackageManager] - ) - .filter( - (entry: unknown): entry is Record & { name: string } => - isPlainRecord(entry) && typeof entry.name === 'string' && entry.name.trim().length > 0, - ) - .map((entry) => ({ name: entry.name.trim(), onFail: entry.onFail })); - const supportedDevEngine = devEngineEntries.find((entry) => - Object.values(PackageManager).some((name) => name === entry.name), - ); - const lastDevEngine = devEngineEntries.at(-1); - const declaredPackageManager = - packageManager === undefined - ? supportedDevEngine?.name - : typeof packageManager === 'string' - ? packageManager.split('@')[0] - : packageManager; + } + if (pkg) { + const declared = declaredPackageManager(pkg); if ( - directory !== projectPath && - (declaredPackageManager === 'pnpm' || - (declaredPackageManager === undefined && - (devEngineEntries.length === 0 || - lastDevEngine?.onFail === 'ignore' || - lastDevEngine?.onFail === 'warn') && - (fs.existsSync(path.join(directory, 'pnpm-lock.yaml')) || - (!['yarn.lock', '.yarnrc.yml', 'package-lock.json', 'bun.lock', 'bun.lockb'].some( - (file) => fs.existsSync(path.join(directory, file)), - ) && - (['.pnpmfile.cjs', 'pnpmfile.cjs'].some((file) => - fs.existsSync(path.join(directory, file)), - ) || - !['bunfig.toml', 'yarn.config.cjs'].some((file) => - fs.existsSync(path.join(directory, file)), - )))))) + directory !== directories[0] && + !hasWorkspaceFile && + (declared + ? declared.name === PackageManager.pnpm + : fs.existsSync(path.join(directory, 'pnpm-lock.yaml')) || + (!['yarn.lock', '.yarnrc.yml', 'package-lock.json', 'bun.lock', 'bun.lockb'].some( + (file) => fs.existsSync(path.join(directory, file)), + ) && + ['.pnpmfile.cjs', 'pnpmfile.cjs'].some((file) => + fs.existsSync(path.join(directory, file)), + ))) ) { projects.push(directory); - } else if (Object.hasOwn(pkg, 'workspaces')) { - continue; + } + } + // A newly created boundary stays single-package; package.json#workspaces + // must not enable additional members that pnpm did not install before. + if (projects.at(-1) !== directory) { + let patterns = pkg?.workspaces; + if (hasWorkspaceFile) { + try { + patterns = (readYamlFile(workspaceFile) as { packages?: unknown })?.packages; + } catch { + // Leave an unreadable existing workspace and its descendants alone. + continue; + } + } else if (isPlainRecord(patterns)) { + patterns = patterns.packages; + } + if (Array.isArray(patterns)) { + for (const file of findPackageJsonFilesFromPatterns( + patterns.filter((pattern): pattern is string => typeof pattern === 'string'), + directory, + )) { + workspaceMembers.add(path.resolve(path.dirname(file))); + } } } let entries: fs.Dirent[]; @@ -247,11 +273,7 @@ export function collectStandalonePnpmProjects(projectPath: string): string[] { continue; } for (const entry of entries) { - if ( - entry.isDirectory() && - !VITEST_SCAN_SKIP_DIRS.has(entry.name) && - !OXLINT_RETENTION_SKIP_DIRS.has(entry.name) - ) { + if (entry.isDirectory() && !OXLINT_RETENTION_SKIP_DIRS.has(entry.name)) { directories.push(path.join(directory, entry.name)); } } diff --git a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts index 7da5eb5e3e..54b5636860 100644 --- a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts +++ b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts @@ -753,6 +753,9 @@ export function detectVitePlusBootstrapPending( ); } if (packageManager === PackageManager.pnpm) { + if (collectStandalonePnpmProjects(projectPath).length > 0) { + return true; + } if (!pnpmWorkspaceExoticSubdepsSettingSatisfied(projectPath)) { return true; } @@ -971,7 +974,7 @@ export function ensureVitePlusBootstrap( } const standalonePnpmProjects = - workspaceInfo.packageManager === PackageManager.pnpm && !workspaceInfo.isMonorepo + workspaceInfo.packageManager === PackageManager.pnpm ? collectStandalonePnpmProjects(projectPath) : []; @@ -1258,7 +1261,8 @@ export function ensureVitePlusBootstrap( result.packageManagerConfig = exoticChanged || pnpmWorkspaceCatalogBefore !== after; } for (const directory of standalonePnpmProjects) { - ensureStandalonePnpmWorkspace(directory); + const changed = ensureStandalonePnpmWorkspace(directory); + result.packageManagerConfig = result.packageManagerConfig || changed; } } else if (workspaceInfo.packageManager === PackageManager.yarn) { const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); From 0c16917117112da6e7bc9ea71365e18ee291dc88 Mon Sep 17 00:00:00 2001 From: yusuke <66258931+yusuke99@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:38:37 +0900 Subject: [PATCH 11/16] fix(migrate): ignore node_modules when globbing workspace members --- .../src/migration/__tests__/migrator.spec.ts | 34 +++++++++++++++++++ .../cli/src/utils/__tests__/workspace.spec.ts | 12 +++++++ packages/cli/src/utils/workspace.ts | 10 +++++- 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 4b5191552a..21ee1368d1 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -2146,6 +2146,40 @@ describe('standalone pnpm child project boundaries', () => { }, ); + it.each([ + ['a pnpm-workspace.yaml glob', 'pnpm-workspace.yaml', 'packages:\n - "**"\n'], + [ + 'package.json workspaces', + 'package.json', + JSON.stringify({ + name: 'root', + workspaces: ['**'], + devDependencies: { vite: '^8.0.1' }, + }), + ], + ])('does not treat installed dependency manifests as members of %s', (_label, file, content) => { + fs.mkdirSync(path.join(tmpDir, 'node_modules/foo'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'node_modules/foo/package.json'), + JSON.stringify({ name: 'foo', packageManager: 'pnpm@10.33.0' }), + ); + const stored = path.join(tmpDir, 'node_modules/.pnpm/y@1.0.0/node_modules/y'); + fs.mkdirSync(stored, { recursive: true }); + fs.writeFileSync( + path.join(stored, 'package.json'), + JSON.stringify({ name: 'y', packageManager: 'pnpm@10.33.0' }), + ); + fs.symlinkSync(stored, path.join(tmpDir, 'node_modules/y'), 'dir'); + fs.writeFileSync(path.join(tmpDir, file), content); + + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + + // `**` still owns the real child, and installed manifests get no boundary. + expect(fs.existsSync(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toBe(false); + expect(fs.existsSync(path.join(tmpDir, 'node_modules/foo/pnpm-workspace.yaml'))).toBe(false); + expect(fs.existsSync(path.join(stored, 'pnpm-workspace.yaml'))).toBe(false); + }); + it.each([ { packageManager: 'yarn@4.9.2' }, { packageManager: 'npm@10.9.4' }, diff --git a/packages/cli/src/utils/__tests__/workspace.spec.ts b/packages/cli/src/utils/__tests__/workspace.spec.ts index 716923df11..4398cf4f31 100644 --- a/packages/cli/src/utils/__tests__/workspace.spec.ts +++ b/packages/cli/src/utils/__tests__/workspace.spec.ts @@ -244,4 +244,16 @@ describe('findPackageJsonFilesFromPatterns', () => { it('returns empty when given no patterns', () => { expect(findPackageJsonFilesFromPatterns([], tmpDir)).toEqual([]); }); + + it('skips package.json files inside node_modules, including the pnpm store layout', () => { + writeJson(path.join(tmpDir, 'packages/a/package.json'), { name: 'a' }); + writeJson(path.join(tmpDir, 'node_modules/foo/package.json'), { name: 'foo' }); + const stored = path.join(tmpDir, 'node_modules/.pnpm/bar@1.0.0/node_modules/bar'); + writeJson(path.join(stored, 'package.json'), { name: 'bar' }); + fs.symlinkSync(stored, path.join(tmpDir, 'node_modules/bar'), 'dir'); + + const result = findPackageJsonFilesFromPatterns(['**'], tmpDir); + + expect(result).toEqual([`${tmpDir.replaceAll('\\', '/')}/packages/a/package.json`]); + }); }); diff --git a/packages/cli/src/utils/workspace.ts b/packages/cli/src/utils/workspace.ts index 04d2b1503f..9c36f2ec66 100644 --- a/packages/cli/src/utils/workspace.ts +++ b/packages/cli/src/utils/workspace.ts @@ -32,7 +32,15 @@ export function findPackageJsonFilesFromPatterns(patterns: string[], cwd: string } return globSync( patterns.map((pattern) => `${pattern}/package.json`), - { absolute: true, cwd, expandDirectories: false }, + { + absolute: true, + cwd, + // Installed dependencies are not workspace members. A broad pattern such + // as `**` would otherwise walk node_modules, including pnpm's symlink + // layout, after migrate has already installed dependencies. + ignore: ['**/node_modules/**'], + expandDirectories: false, + }, ); } From eab89f7d7c4699c8e89c5080eb509a85225736fc Mon Sep 17 00:00:00 2001 From: yusuke <66258931+yusuke99@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:22:09 +0900 Subject: [PATCH 12/16] fix(migrate): copy nested pnpm settings unless the pin is exact and below 10.6.2 --- docs/guide/migrate-rules.md | 1 + .../cli/src/migration/__tests__/migrator.spec.ts | 9 +++++++++ packages/cli/src/migration/migrator/catalog.ts | 13 +++++++++---- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 1a868d7202..3221e4d2aa 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -270,6 +270,7 @@ Migration converts legacy Node.js version-manager files to `.node-version`, the - When both files define the same migrated setting, object entries are merged recursively and unique array entries are retained. Values from `package.json#pnpm` win at conflicting scalar leaves, while workspace-only sibling entries are preserved. - Before pnpm 10.6.2, these settings stay in `package.json#pnpm`. (Workspace settings support arrived incrementally: 10.5.0 in general, 10.5.1 for overrides, 10.6.2 for `peerDependencyRules`. pnpm 11 no longer reads the legacy `package.json` settings.) +- A nested pnpm project outside the workspace `packages` gets its own `pnpm-workspace.yaml` with `packages: ["."]`, so running `pnpm install` inside that project stays independent. Its `package.json#pnpm` settings are also written to that file, and its `package.json` is left unchanged so older pnpm versions keep reading them there. If the project pins an exact pnpm version below 10.6.2, only `packages` is written. **Catalogs.** Catalogs are a separate feature, supported from pnpm 9.5.0, independent of the settings boundary above. Even below 10.6.2, where overrides stay in `package.json#pnpm`, migration still rewrites the workspace catalog off stale wrapper aliases and keeps `catalog:` overrides as references rather than inlining them to concrete versions. diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 21ee1368d1..7b55a49b63 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -2069,6 +2069,7 @@ describe('standalone pnpm child project boundaries', () => { it.each([ { packageManager: 'pnpm@10.6.1', settingsInYaml: false }, + { packageManager: 'pnpm@10.6.2', settingsInYaml: true }, { packageManager: 'pnpm@10.6.2+sha512.example', settingsInYaml: true }, { devEngines: { packageManager: { name: 'pnpm', version: '10.5.0' } }, settingsInYaml: false }, { @@ -2077,6 +2078,14 @@ describe('standalone pnpm child project boundaries', () => { }, settingsInYaml: false, }, + { devEngines: { packageManager: { name: 'pnpm', version: '*' } }, settingsInYaml: true }, + { devEngines: { packageManager: { name: 'pnpm', version: '>=10' } }, settingsInYaml: true }, + { devEngines: { packageManager: { name: 'pnpm', version: '^10' } }, settingsInYaml: true }, + { + devEngines: { packageManager: { name: 'pnpm', version: 'not-a-version' } }, + settingsInYaml: true, + }, + { devEngines: { packageManager: { name: 'pnpm', version: 'latest' } }, settingsInYaml: true }, ])('preserves child pnpm settings according to its own pin (%j)', (declaration) => { const manifest = JSON.stringify({ name: 'web', diff --git a/packages/cli/src/migration/migrator/catalog.ts b/packages/cli/src/migration/migrator/catalog.ts index 868eb6b6bc..d9203fef93 100644 --- a/packages/cli/src/migration/migrator/catalog.ts +++ b/packages/cli/src/migration/migrator/catalog.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { coerce, isGreaterThanOrEqual } from 'verkit'; +import { coerce, isGreaterThanOrEqual, isValid } from 'verkit'; import { Scalar, YAMLMap, YAMLSeq } from 'yaml'; import { PackageManager, type WorkspacePackage } from '../../types/index.ts'; @@ -262,9 +262,14 @@ export function ensureStandalonePnpmWorkspace(projectPath: string): boolean { // install and invalidate the child's existing frozen lockfile. fs.writeFileSync(pnpmWorkspaceYamlPath, 'packages:\n - "."\n'); const version = declaredPackageManager(pkg)?.version; - if (version === undefined || pnpmSupportsWorkspaceSettings(version)) { - // Extract from the in-memory copy only: keep the child's manifest and lockfile. - // Older pinned pnpm versions must keep reading their settings from package.json. + // Copying is additive: settings come from an in-memory copy and the child's + // package.json is never rewritten. Only an exact pin below 10.6.2 skips the + // copy. pnpm 10.5–10.6.1 reads yaml settings, but overrides and + // peerDependencyRules were still broken (see pnpmSupportsWorkspaceSettings). + // A range, `*`, dist-tag, or invalid string is not that pin. `latest` and + // `next` copy either way: they are not exact, and the support check treats + // them as current releases. + if (version === undefined || !isValid(version) || pnpmSupportsWorkspaceSettings(version)) { migratePnpmSettingsToWorkspaceYaml(projectPath, takePnpmWorkspaceSettings(pkg)); } return true; From b747ae10c53a99bf9f2ace41fcb4ba8d626a6c62 Mon Sep 17 00:00:00 2001 From: yusuke <66258931+yusuke99@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:20:32 +0900 Subject: [PATCH 13/16] fix(migrate): update nested pnpm lockfiles after copying settings On pnpm 11, package.json#pnpm is ignored, so a nested project's lockfile can lack settings such as overrides. Copying them into its pnpm-workspace.yaml would then make the next frozen install fail with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH. After copying settings, run `vp install --lockfile-only --ignore-scripts --no-frozen-lockfile` in each nested project that has a lockfile, before the root install. A failure is reported as a warning with the recovery command and sets a non-zero exit code without stopping the migration. --- .../snapshots.toml | 37 ++++++ .../migration_nested_pnpm11_overrides.md | 57 +++++++++ ...gration_nested_pnpm11_overrides_failure.md | 85 ++++++++++++++ docs/guide/migrate-rules.md | 2 +- .../src/migration/__tests__/migrator.spec.ts | 108 ++++++++++++++++++ packages/cli/src/migration/bin.ts | 25 +++- .../cli/src/migration/migrator/catalog.ts | 44 +++++++ 7 files changed, 353 insertions(+), 5 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots/migration_nested_pnpm11_overrides.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots/migration_nested_pnpm11_overrides_failure.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots.toml index e79f2572d1..e6961e4f45 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots.toml @@ -10,3 +10,40 @@ steps = [ { argv = ["vpt", "print-file", "web/pnpm-workspace.yaml"], comment = "the child keeps its install boundary" }, { argv = ["vpt", "stat-file", "web/node_modules/test-vite-plus-install", "--assert-not", "missing"], comment = "the child's dependency was installed" }, ] + +[[case]] +name = "migration_nested_pnpm11_overrides" +vp = "global" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vpt", "json-edit", "package.json", "packageManager", "pnpm@11.25.0"], snapshot = false }, + { argv = ["vpt", "json-edit", "web/package.json", "packageManager", "pnpm@11.25.0"], snapshot = false }, + { argv = ["vpt", "json-edit", "web/package.json", "pnpm.overrides", '{"test-vite-plus-install":"1.0.0"}'], snapshot = false }, + { argv = ["vpt", "json-edit", "web/package.json", "scripts.postinstall", "echo yes > postinstall-ran"], snapshot = false }, + { argv = ["pnpm", "install", "--frozen-lockfile", "--lockfile-only", "--ignore-scripts"], cwd = "web", comment = "the child's original lockfile is valid while pnpm 11 ignores package.json overrides", snapshot = false, timeout = 120000 }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "copying the child's overrides to YAML updates its lockfile", timeout = 120000 }, + { argv = ["vpt", "stat-file", "web/postinstall-ran", "--assert", "missing"], comment = "updating the child's lockfile does not run its lifecycle scripts" }, + { argv = ["pnpm", "install", "--frozen-lockfile", "--ignore-scripts"], cwd = "web", comment = "the child's frozen install succeeds immediately after migration", snapshot = false, timeout = 120000 }, + { argv = ["vpt", "print-file", "web/pnpm-workspace.yaml"], comment = "the child keeps its install boundary and migrated overrides" }, +] + +[[case]] +name = "migration_nested_pnpm11_overrides_failure" +vp = "global" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vpt", "json-edit", "package.json", "packageManager", "pnpm@11.25.0"], snapshot = false }, + { argv = ["vpt", "json-edit", "web/package.json", "packageManager", "pnpm@11.25.0"], snapshot = false }, + { argv = ["vpt", "json-edit", "web/package.json", "pnpm.overrides", '{"test-vite-plus-install":"file:./missing"}'], snapshot = false }, + { argv = ["vpt", "json-edit", "web/package.json", "scripts.postinstall", "echo yes > postinstall-ran"], snapshot = false }, + { argv = ["pnpm", "install", "--frozen-lockfile", "--lockfile-only", "--ignore-scripts"], cwd = "web", comment = "the original lockfile is valid before the ignored override becomes active", snapshot = false, timeout = 120000 }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "a failed child lockfile update reports a warning and completes the root install and migration summary", continue-on-failure = true, timeout = 120000 }, + { argv = ["vpt", "stat-file", "node_modules/vite-plus", "--assert-not", "missing"], comment = "the root's final install completed despite the child lockfile update failure" }, + { argv = ["vpt", "print-file", "web/pnpm-workspace.yaml"], comment = "the child's install boundary is preserved" }, + { argv = ["vpt", "replace-file-content", "web/pnpm-workspace.yaml", "file:./missing", "1.0.0"], comment = "repair the child's effective override", snapshot = false }, + { argv = ["vp", "install", "--lockfile-only", "--ignore-scripts", "--no-frozen-lockfile"], cwd = "web", comment = "the reported manual recovery command updates the child's lockfile", snapshot = false, timeout = 120000 }, + { argv = ["pnpm", "install", "--frozen-lockfile", "--ignore-scripts"], cwd = "web", comment = "the child's frozen install succeeds after manual recovery", snapshot = false, timeout = 120000 }, + { argv = ["vpt", "stat-file", "web/postinstall-ran", "--assert", "missing"], comment = "neither update attempt ran the child's lifecycle scripts" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots/migration_nested_pnpm11_overrides.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots/migration_nested_pnpm11_overrides.md new file mode 100644 index 0000000000..bf3bc1be79 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots/migration_nested_pnpm11_overrides.md @@ -0,0 +1,57 @@ +# migration_nested_pnpm11_overrides + +## `vpt json-edit package.json packageManager pnpm@11.25.0` + + +## `vpt json-edit web/package.json packageManager pnpm@11.25.0` + + +## `vpt json-edit web/package.json pnpm.overrides '{"test-vite-plus-install":"1.0.0"}'` + + +## `vpt json-edit web/package.json scripts.postinstall 'echo yes > postinstall-ran'` + + +## `cd web && pnpm install --frozen-lockfile --lockfile-only --ignore-scripts` + +the child's original lockfile is valid while pnpm 11 ignores package.json overrides + + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +copying the child's overrides to YAML updates its lockfile + +``` +VITE+ - The Unified Toolchain for the Web + +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 1 config update applied +``` + +## `vpt stat-file web/postinstall-ran --assert missing` + +updating the child's lockfile does not run its lifecycle scripts + +``` +web/postinstall-ran: missing +``` + +## `cd web && pnpm install --frozen-lockfile --ignore-scripts` + +the child's frozen install succeeds immediately after migration + + +## `vpt print-file web/pnpm-workspace.yaml` + +the child keeps its install boundary and migrated overrides + +``` +packages: + - "." +overrides: + test-vite-plus-install: 1.0.0 +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots/migration_nested_pnpm11_overrides_failure.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots/migration_nested_pnpm11_overrides_failure.md new file mode 100644 index 0000000000..880a1aaf63 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots/migration_nested_pnpm11_overrides_failure.md @@ -0,0 +1,85 @@ +# migration_nested_pnpm11_overrides_failure + +## `vpt json-edit package.json packageManager pnpm@11.25.0` + + +## `vpt json-edit web/package.json packageManager pnpm@11.25.0` + + +## `vpt json-edit web/package.json pnpm.overrides '{"test-vite-plus-install":"file:./missing"}'` + + +## `vpt json-edit web/package.json scripts.postinstall 'echo yes > postinstall-ran'` + + +## `cd web && pnpm install --frozen-lockfile --lockfile-only --ignore-scripts` + +the original lockfile is valid before the ignored override becomes active + + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +a failed child lockfile update reports a warning and completes the root install and migration summary + +**Exit code:** 1 + +``` +VITE+ - The Unified Toolchain for the Web + +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +• 1 config update applied +! Warnings: + - Could not update dependencies for the nested project at /web. +✓ Lockfile passes supply-chain policies (verified ago) +[ERR_PNPM_LINKED_PKG_DIR_NOT_FOUND] Could not install from "/web/missing" as it does not exist. + +This error happened while installing a direct dependency of /web + +[WARN] The "pnpm" field in package.json is no longer read by pnpm. The following keys were ignored: "pnpm.overrides". See https://pnpm.io/settings for the new home of each setting. +Run `vp install --lockfile-only --ignore-scripts --no-frozen-lockfile` in that directory. +``` + +## `vpt stat-file node_modules/vite-plus --assert-not missing` + +the root's final install completed despite the child lockfile update failure + +``` +node_modules/vite-plus: symlink +``` + +## `vpt print-file web/pnpm-workspace.yaml` + +the child's install boundary is preserved + +``` +packages: + - "." +overrides: + test-vite-plus-install: file:./missing +``` + +## `vpt replace-file-content web/pnpm-workspace.yaml file:./missing 1.0.0` + +repair the child's effective override + + +## `cd web && vp install --lockfile-only --ignore-scripts --no-frozen-lockfile` + +the reported manual recovery command updates the child's lockfile + + +## `cd web && pnpm install --frozen-lockfile --ignore-scripts` + +the child's frozen install succeeds after manual recovery + + +## `vpt stat-file web/postinstall-ran --assert missing` + +neither update attempt ran the child's lifecycle scripts + +``` +web/postinstall-ran: missing +``` diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 3221e4d2aa..e4ddc1a680 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -270,7 +270,7 @@ Migration converts legacy Node.js version-manager files to `.node-version`, the - When both files define the same migrated setting, object entries are merged recursively and unique array entries are retained. Values from `package.json#pnpm` win at conflicting scalar leaves, while workspace-only sibling entries are preserved. - Before pnpm 10.6.2, these settings stay in `package.json#pnpm`. (Workspace settings support arrived incrementally: 10.5.0 in general, 10.5.1 for overrides, 10.6.2 for `peerDependencyRules`. pnpm 11 no longer reads the legacy `package.json` settings.) -- A nested pnpm project outside the workspace `packages` gets its own `pnpm-workspace.yaml` with `packages: ["."]`, so running `pnpm install` inside that project stays independent. Its `package.json#pnpm` settings are also written to that file, and its `package.json` is left unchanged so older pnpm versions keep reading them there. If the project pins an exact pnpm version below 10.6.2, only `packages` is written. +- A nested pnpm project outside the workspace `packages` gets its own `pnpm-workspace.yaml` with `packages: ["."]`, so running `pnpm install` inside that project stays independent. Its `package.json#pnpm` settings are also written to that file, and its `package.json` is left unchanged so older pnpm versions keep reading them there. Its lockfile is updated to match. If the project pins an exact pnpm version below 10.6.2, only `packages` is written. **Catalogs.** Catalogs are a separate feature, supported from pnpm 9.5.0, independent of the settings boundary above. Even below 10.6.2, where overrides stay in `package.json#pnpm`, migration still rewrites the workspace catalog off stale wrapper aliases and keeps `catalog:` overrides as references rather than inlining them to concrete versions. diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 7b55a49b63..e7abc67cc3 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -8,6 +8,7 @@ import { parseAllDocuments, parse as parseYaml } from 'yaml'; import { rewriteScripts } from '../../../binding/index.js'; import { PackageManager } from '../../types/index.js'; +import * as command from '../../utils/command.ts'; import { SETUP_VP_VERSION, VITE_PLUS_OVERRIDE_PACKAGES, @@ -67,6 +68,8 @@ const { pnpmSupportsWorkspaceSettings, pnpmOverrideKey, setPackageManager, + collectStandalonePnpmProjects, + updateStandalonePnpmLockfiles, } = await import('../migrator.js'); // pnpm's managed override keys are range-qualified so they never rewrite an @@ -1971,6 +1974,111 @@ describe('standalone pnpm child project boundaries', () => { afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it('updates child lockfiles after copying settings without running lifecycle scripts', async () => { + const install = vi.spyOn(command, 'runCommandSilently').mockResolvedValue({ + exitCode: 0, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + }); + const projects = collectStandalonePnpmProjects(tmpDir); + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + + await expect(updateStandalonePnpmLockfiles(projects)).resolves.toBe(true); + + expect(install).toHaveBeenCalledExactlyOnceWith({ + command: process.env.VP_CLI_BIN ?? 'vp', + args: ['install', '--lockfile-only', '--ignore-scripts', '--no-frozen-lockfile'], + cwd: path.join(tmpDir, 'web'), + envs: process.env, + }); + }); + + it.each([ + [ + 'the child has no pnpm settings', + () => { + fs.writeFileSync( + path.join(tmpDir, 'web/package.json'), + JSON.stringify({ name: 'web', packageManager: 'pnpm@11.25.0' }), + ); + }, + ], + [ + 'the child pins pnpm older than 10.6.2', + () => { + fs.writeFileSync( + path.join(tmpDir, 'web/package.json'), + JSON.stringify({ + name: 'web', + packageManager: 'pnpm@10.5.0', + pnpm: { overrides: { example: '1.0.0' } }, + }), + ); + }, + ], + ['the child has no lockfile', () => fs.unlinkSync(path.join(tmpDir, 'web/pnpm-lock.yaml'))], + [ + 'the child already has a workspace file', + () => fs.writeFileSync(path.join(tmpDir, 'web/pnpm-workspace.yaml'), 'packages: ["."]\n'), + ], + ['installation is skipped', () => vi.stubEnv('VP_SKIP_INSTALL', '1')], + ])('does not update child lockfiles when %s', async (_, setup) => { + const install = vi.spyOn(command, 'runCommandSilently'); + setup(); + const projects = collectStandalonePnpmProjects(tmpDir); + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + + await updateStandalonePnpmLockfiles(projects); + + expect(install).not.toHaveBeenCalled(); + }); + + it('reports a failed child lockfile update without throwing or removing its boundary', async () => { + vi.spyOn(command, 'runCommandSilently').mockResolvedValue({ + exitCode: 1, + stdout: Buffer.alloc(0), + stderr: Buffer.from('Unable to resolve child dependency'), + }); + const projects = collectStandalonePnpmProjects(tmpDir); + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + const report = createMigrationReport(); + + await expect(updateStandalonePnpmLockfiles(projects, report)).resolves.toBe(false); + + expect(report.warnings).toContainEqual( + expect.stringContaining( + `Could not update dependencies for the nested project at ${path.join(tmpDir, 'web')}.\nUnable to resolve child dependency`, + ), + ); + expect(readYamlObject(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toEqual({ + packages: ['.'], + overrides: { example: '1.0.0' }, + }); + expect(collectStandalonePnpmProjects(tmpDir)).toEqual([]); + }); + + it('continues other children when a child install command throws', async () => { + fs.mkdirSync(path.join(tmpDir, 'web2')); + for (const file of ['package.json', 'pnpm-lock.yaml']) { + fs.copyFileSync(path.join(tmpDir, 'web', file), path.join(tmpDir, 'web2', file)); + } + const install = vi + .spyOn(command, 'runCommandSilently') + .mockRejectedValueOnce(new Error('Failed to start vp')) + .mockResolvedValue({ exitCode: 0, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }); + const projects = collectStandalonePnpmProjects(tmpDir); + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + const report = createMigrationReport(); + + await expect(updateStandalonePnpmLockfiles(projects, report)).resolves.toBe(false); + expect(install).toHaveBeenCalledTimes(2); + expect(report.warnings).toContainEqual(expect.stringContaining('Failed to start vp')); + expect(fs.existsSync(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toBe(true); + expect(fs.existsSync(path.join(tmpDir, 'web2/pnpm-workspace.yaml'))).toBe(true); }); it('preserves the child install boundary when migrating with pnpm', () => { diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index a16a10a6e5..259b56b8ca 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -48,6 +48,7 @@ import { checkVitestVersion, checkViteVersion, collectToolchainVersionChanges, + collectStandalonePnpmProjects, confirmPrettierMigration, detectEslintProject, detectFramework, @@ -77,6 +78,7 @@ import { rewriteStandaloneProject, planVitestV5Migration, refreshVitestV5Migration, + updateStandalonePnpmLockfiles, shouldSkipStagedMigrationForHooks, warnPackageLevelPrettier, vitestV5NeedsMigration, @@ -896,6 +898,10 @@ async function executeMigrationPlan( // 7. Rewrite configs updateMigrationProgress('Rewriting configs'); + const standalonePnpmProjects = + plan.packageManager === PackageManager.pnpm + ? collectStandalonePnpmProjects(workspaceInfo.rootDir) + : []; if (workspaceInfo.isMonorepo) { rewriteMonorepo(workspaceInfo, skipStagedMigration, true, report); } else { @@ -967,6 +973,10 @@ async function executeMigrationPlan( ? prepareNpmViteAliasReinstall(workspaceInfo.rootDir, getWorkspaceProjectPaths(workspaceInfo)) : undefined; updateMigrationProgress('Installing dependencies'); + const childLockfilesOk = await updateStandalonePnpmLockfiles(standalonePnpmProjects, report); + if (!childLockfilesOk) { + process.exitCode = 1; + } const finalInstallSummary = await runViteInstall( workspaceInfo.rootDir, interactive, @@ -1009,7 +1019,7 @@ async function executeMigrationPlan( } return { installDurationMs: initialInstallDurationMs + finalInstallDurationMs, - finalInstallOk: finalInstallSummary.status === 'installed', + finalInstallOk: childLockfilesOk && finalInstallSummary.status === 'installed', packageManagerVersion: downloadResult.version, report, }; @@ -1125,6 +1135,10 @@ async function main() { ? (workspaceInfoOptional.packageManager ?? (await selectPackageManager(options.interactive, true))) : workspaceInfoOptional.packageManager; + const standalonePnpmProjects = + packageManager === PackageManager.pnpm + ? collectStandalonePnpmProjects(workspaceInfoOptional.rootDir) + : []; let downloadedPackageManager: Awaited> | undefined; let packageManagerVersion = workspaceInfoOptional.packageManagerVersion; const downloadExistingPackageManager = async () => { @@ -1502,6 +1516,10 @@ async function main() { getWorkspaceProjectPaths(workspaceInfoOptional), ) : undefined; + const childLockfilesOk = await updateStandalonePnpmLockfiles(standalonePnpmProjects, report); + if (!childLockfilesOk) { + process.exitCode = 1; + } const installSummary = await runViteInstall( workspaceInfoOptional.rootDir, options.interactive, @@ -1530,8 +1548,8 @@ async function main() { } else { npmReinstallPreparation?.commit(); } - finalInstallOk = installSummary.status !== 'failed'; - canFormatMigratedProject = finalInstallOk && canFormatMigratedProject; + finalInstallOk = childLockfilesOk && installSummary.status !== 'failed'; + canFormatMigratedProject = installSummary.status !== 'failed' && canFormatMigratedProject; installDurationMs += handleInstallResult( installSummary, workspaceInfoOptional.rootDir, @@ -1577,7 +1595,6 @@ async function main() { if ( didMigrate && - finalInstallOk && canFormatMigratedProject && canFormatWithOxfmt(prettierProject.hasDependency, prettierMigrated) ) { diff --git a/packages/cli/src/migration/migrator/catalog.ts b/packages/cli/src/migration/migrator/catalog.ts index d9203fef93..6065066d27 100644 --- a/packages/cli/src/migration/migrator/catalog.ts +++ b/packages/cli/src/migration/migrator/catalog.ts @@ -5,6 +5,7 @@ import { coerce, isGreaterThanOrEqual, isValid } from 'verkit'; import { Scalar, YAMLMap, YAMLSeq } from 'yaml'; import { PackageManager, type WorkspacePackage } from '../../types/index.ts'; +import { runCommandSilently } from '../../utils/command.ts'; import { VITEST_AGE_GATE_EXEMPT_PACKAGES, VITEST_VERSION, @@ -32,6 +33,7 @@ import { shouldDropProviderOverrideKey, workspaceUsesWebdriverioProvider, } from '../migrator.ts'; +import type { MigrationReport } from '../report.ts'; import { LEGACY_WRAPPER_FALLBACK_VERSIONS, PROVIDER_OVERRIDE_DROP_NAMES, @@ -39,6 +41,7 @@ import { VITEST_IS_MANAGED_OVERRIDE, isPlainRecord, pnpmOverrideKey, + warnMigration, type CatalogDependencyResolver, type PackageJsonDependencyField, type PnpmPackageJsonSettings, @@ -275,6 +278,47 @@ export function ensureStandalonePnpmWorkspace(projectPath: string): boolean { return true; } +// Capture projectPaths before creating child workspace files. Copying settings +// can activate ignored package.json#pnpm settings on pnpm >= 11, +// invalidating the child's lockfile even though its manifest did not change. +export async function updateStandalonePnpmLockfiles( + projectPaths: string[], + report?: MigrationReport, +): Promise { + if (process.env.VP_SKIP_INSTALL) { + return true; + } + let success = true; + for (const projectPath of projectPaths) { + const workspacePath = path.join(projectPath, 'pnpm-workspace.yaml'); + if (!fs.existsSync(workspacePath) || !fs.existsSync(path.join(projectPath, 'pnpm-lock.yaml'))) { + continue; + } + try { + const settings = readYamlFile(workspacePath); + if (!PNPM_WORKSPACE_SETTING_KEYS.some((key) => Object.hasOwn(settings, key))) { + continue; + } + const { exitCode, stdout, stderr } = await runCommandSilently({ + command: process.env.VP_CLI_BIN ?? 'vp', + args: ['install', '--lockfile-only', '--ignore-scripts', '--no-frozen-lockfile'], + cwd: projectPath, + envs: process.env, + }); + if (exitCode !== 0) { + throw new Error(`${stdout.toString()}\n${stderr.toString()}`.trim()); + } + } catch (error) { + success = false; + warnMigration( + `Could not update dependencies for the nested project at ${projectPath}.\n${error instanceof Error ? error.message : String(error)}\nRun \`vp install --lockfile-only --ignore-scripts --no-frozen-lockfile\` in that directory.`, + report, + ); + } + } + return success; +} + /** * Rewrite pnpm-workspace.yaml to add vite-plus dependencies * @param projectPath - The path to the project From 524745f3a4057109d2c454327d8330cae06ab70c Mon Sep 17 00:00:00 2001 From: yusuke <66258931+yusuke99@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:23:08 +0900 Subject: [PATCH 14/16] fix(migrate): find nested pnpm projects below workspace members A matched workspace member was skipped together with its whole subtree, so an independent pnpm project below it but outside the workspace patterns never got its own pnpm-workspace.yaml. After migration created the root workspace file, installing in that project resolved to the root, and a root postinstall could recurse again. Keep excluding the member itself and ignore its package.json#workspaces, but still read its own pnpm-workspace.yaml and traverse its descendants. --- .../snapshots.toml | 19 ++++++ .../migration_nested_pnpm_workspace_member.md | 68 +++++++++++++++++++ .../vendor/package.json | 6 ++ .../vendor/packages/app/package.json | 6 ++ .../src/migration/__tests__/migrator.spec.ts | 47 +++++++++++++ .../cli/src/migration/migrator/source-scan.ts | 6 +- 6 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots/migration_nested_pnpm_workspace_member.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/vendor/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/vendor/packages/app/package.json diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots.toml index e6961e4f45..884485cb53 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots.toml @@ -47,3 +47,22 @@ steps = [ { argv = ["pnpm", "install", "--frozen-lockfile", "--ignore-scripts"], cwd = "web", comment = "the child's frozen install succeeds after manual recovery", snapshot = false, timeout = 120000 }, { argv = ["vpt", "stat-file", "web/postinstall-ran", "--assert", "missing"], comment = "neither update attempt ran the child's lifecycle scripts" }, ] + +[[case]] +name = "migration_nested_pnpm_workspace_member" +vp = "global" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vpt", "mkdir", "vendor/packages/app/examples"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "web", "vendor/packages/app/examples/demo"], snapshot = false }, + { argv = ["vpt", "rm", "-r", "web"], snapshot = false }, + { argv = ["vpt", "json-edit", "package.json", "scripts.postinstall", "cd vendor/packages/app/examples/demo && pnpm install"], snapshot = false }, + { argv = ["pnpm", "install", "--frozen-lockfile"], cwd = "vendor/packages/app/examples/demo", comment = "install the independent demo before migration", snapshot = false, timeout = 120000 }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "preserve the independent demo below a workspace member", timeout = 120000 }, + { argv = ["vp", "install"], comment = "install completes without postinstall recursion", snapshot = false, timeout = 120000 }, + { argv = ["pnpm", "install", "--frozen-lockfile"], cwd = "vendor/packages/app/examples/demo", comment = "the demo's frozen install still succeeds", snapshot = false, timeout = 120000 }, + { argv = ["vpt", "print-file", "vendor/packages/app/examples/demo/pnpm-workspace.yaml"], comment = "the demo keeps its install boundary" }, + { argv = ["vpt", "stat-file", "vendor/packages/app/pnpm-workspace.yaml", "--assert", "missing"], comment = "the workspace member gets no boundary" }, + { argv = ["vpt", "stat-file", "vendor/packages/app/examples/demo/node_modules/test-vite-plus-install", "--assert-not", "missing"], comment = "the demo's dependency was installed" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots/migration_nested_pnpm_workspace_member.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots/migration_nested_pnpm_workspace_member.md new file mode 100644 index 0000000000..d23dd35729 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots/migration_nested_pnpm_workspace_member.md @@ -0,0 +1,68 @@ +# migration_nested_pnpm_workspace_member + +## `vpt mkdir vendor/packages/app/examples` + + +## `vpt cp -r web vendor/packages/app/examples/demo` + + +## `vpt rm -r web` + + +## `vpt json-edit package.json scripts.postinstall 'cd vendor/packages/app/examples/demo && pnpm install'` + + +## `cd vendor/packages/app/examples/demo && pnpm install --frozen-lockfile` + +install the independent demo before migration + + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +preserve the independent demo below a workspace member + +``` +VITE+ - The Unified Toolchain for the Web + +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 1 config update applied +``` + +## `vp install` + +install completes without postinstall recursion + + +## `cd vendor/packages/app/examples/demo && pnpm install --frozen-lockfile` + +the demo's frozen install still succeeds + + +## `vpt print-file vendor/packages/app/examples/demo/pnpm-workspace.yaml` + +the demo keeps its install boundary + +``` +packages: + - "." +``` + +## `vpt stat-file vendor/packages/app/pnpm-workspace.yaml --assert missing` + +the workspace member gets no boundary + +``` +vendor/packages/app/pnpm-workspace.yaml: missing +``` + +## `vpt stat-file vendor/packages/app/examples/demo/node_modules/test-vite-plus-install --assert-not missing` + +the demo's dependency was installed + +``` +vendor/packages/app/examples/demo/node_modules/test-vite-plus-install: symlink +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/vendor/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/vendor/package.json new file mode 100644 index 0000000000..ee481e50e5 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/vendor/package.json @@ -0,0 +1,6 @@ +{ + "name": "vendor", + "private": true, + "packageManager": "npm@10.9.4", + "workspaces": ["packages/*"] +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/vendor/packages/app/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/vendor/packages/app/package.json new file mode 100644 index 0000000000..da2d3053dd --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/vendor/packages/app/package.json @@ -0,0 +1,6 @@ +{ + "name": "app", + "private": true, + "packageManager": "pnpm@10.33.0", + "workspaces": ["examples/*"] +} diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index e7abc67cc3..2dcc38e2ee 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -2238,6 +2238,53 @@ describe('standalone pnpm child project boundaries', () => { }, ); + it.each([ + ['outside workspace globs', 'packages: ["packages/*"]\n', undefined, true], + [ + 'matched by ancestor globs', + 'packages: ["packages/*", "packages/*/examples/*"]\n', + undefined, + false, + ], + [ + 'outside the member workspace globs', + 'packages: ["packages/*"]\n', + 'packages: ["services/*"]\n', + true, + ], + ])('collects independent descendants %s', (_label, ancestorYaml, memberYaml, collected) => { + fs.mkdirSync(path.join(tmpDir, 'vendor/packages/app/examples/demo'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, 'vendor/packages/app/services/api'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'vendor/pnpm-workspace.yaml'), ancestorYaml); + fs.writeFileSync( + path.join(tmpDir, 'vendor/packages/app/package.json'), + JSON.stringify({ + name: 'app', + packageManager: 'pnpm@10.33.0', + workspaces: ['examples/*'], + }), + ); + for (const child of ['examples/demo', 'services/api']) { + fs.writeFileSync( + path.join(tmpDir, 'vendor/packages/app', child, 'package.json'), + JSON.stringify({ name: child, packageManager: 'pnpm@10.33.0' }), + ); + } + if (memberYaml) { + fs.writeFileSync(path.join(tmpDir, 'vendor/packages/app/pnpm-workspace.yaml'), memberYaml); + } + + const projects = collectStandalonePnpmProjects(tmpDir); + + expect(projects).not.toContain(path.join(tmpDir, 'vendor/packages/app')); + expect(projects.includes(path.join(tmpDir, 'vendor/packages/app/examples/demo'))).toBe( + collected, + ); + expect(projects.includes(path.join(tmpDir, 'vendor/packages/app/services/api'))).toBe( + !memberYaml, + ); + }); + it.each([false, true])( 'repairs independent children outside existing workspace globs (monorepo: %s)', (monorepo) => { diff --git a/packages/cli/src/migration/migrator/source-scan.ts b/packages/cli/src/migration/migrator/source-scan.ts index 346222d1ef..922ce086ae 100644 --- a/packages/cli/src/migration/migrator/source-scan.ts +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -209,13 +209,11 @@ export function collectStandalonePnpmProjects(projectPath: string): string[] { const directories = [path.resolve(projectPath)]; const workspaceMembers = new Set(); for (const directory of directories) { - if (workspaceMembers.has(directory)) { - continue; - } const workspaceFile = path.join(directory, 'pnpm-workspace.yaml'); const hasWorkspaceFile = fs.existsSync(workspaceFile); let pkg: Record | undefined; - if (fs.existsSync(path.join(directory, 'package.json'))) { + // Members may contain independent projects, but their manifests do not own workspaces. + if (!workspaceMembers.has(directory) && fs.existsSync(path.join(directory, 'package.json'))) { try { const parsed = readJsonFile(path.join(directory, 'package.json')); if (isPlainRecord(parsed)) { From 9aa1ca300ca4ddfc4aa3ef09a92a45e50695d186 Mon Sep 17 00:00:00 2001 From: yusuke <66258931+yusuke99@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:58:22 +0900 Subject: [PATCH 15/16] fix(migrate): skip copying nested pnpm settings only when every allowed version is below 10.6.2 --- docs/guide/migrate-rules.md | 2 +- .../src/migration/__tests__/migrator.spec.ts | 21 +++++++++++++++++++ .../cli/src/migration/migrator/catalog.ts | 17 +++++++-------- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index e4ddc1a680..d3ec9b14d9 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -270,7 +270,7 @@ Migration converts legacy Node.js version-manager files to `.node-version`, the - When both files define the same migrated setting, object entries are merged recursively and unique array entries are retained. Values from `package.json#pnpm` win at conflicting scalar leaves, while workspace-only sibling entries are preserved. - Before pnpm 10.6.2, these settings stay in `package.json#pnpm`. (Workspace settings support arrived incrementally: 10.5.0 in general, 10.5.1 for overrides, 10.6.2 for `peerDependencyRules`. pnpm 11 no longer reads the legacy `package.json` settings.) -- A nested pnpm project outside the workspace `packages` gets its own `pnpm-workspace.yaml` with `packages: ["."]`, so running `pnpm install` inside that project stays independent. Its `package.json#pnpm` settings are also written to that file, and its `package.json` is left unchanged so older pnpm versions keep reading them there. Its lockfile is updated to match. If the project pins an exact pnpm version below 10.6.2, only `packages` is written. +- A nested pnpm project outside the workspace `packages` gets its own `pnpm-workspace.yaml` with `packages: ["."]`, so running `pnpm install` inside that project stays independent. Its `package.json#pnpm` settings are also written to that file, and its `package.json` is left unchanged so older pnpm versions keep reading them there. Its lockfile is updated to match. If the project only allows pnpm versions below 10.6.2, only `packages` is written. **Catalogs.** Catalogs are a separate feature, supported from pnpm 9.5.0, independent of the settings boundary above. Even below 10.6.2, where overrides stay in `package.json#pnpm`, migration still rewrites the workspace catalog off stale wrapper aliases and keeps `catalog:` overrides as references rather than inlining them to concrete versions. diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 2dcc38e2ee..2c6945102a 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -2179,7 +2179,28 @@ describe('standalone pnpm child project boundaries', () => { { packageManager: 'pnpm@10.6.1', settingsInYaml: false }, { packageManager: 'pnpm@10.6.2', settingsInYaml: true }, { packageManager: 'pnpm@10.6.2+sha512.example', settingsInYaml: true }, + { packageManager: 'pnpm@11.0.0-beta.1', settingsInYaml: true }, + { + devEngines: { packageManager: { name: 'pnpm', version: '10.14.0-0' } }, + settingsInYaml: true, + }, { devEngines: { packageManager: { name: 'pnpm', version: '10.5.0' } }, settingsInYaml: false }, + { + devEngines: { packageManager: { name: 'pnpm', version: '>=10.6.0 <10.6.2' } }, + settingsInYaml: false, + }, + { devEngines: { packageManager: { name: 'pnpm', version: '~10.5.0' } }, settingsInYaml: false }, + { devEngines: { packageManager: { name: 'pnpm', version: '<10.6.2' } }, settingsInYaml: false }, + { + devEngines: { packageManager: { name: 'pnpm', version: '<10.6.2 >=10.6.0' } }, + settingsInYaml: false, + }, + { + devEngines: { packageManager: { name: 'pnpm', version: '<10.6.2 || 9.x' } }, + settingsInYaml: false, + }, + { devEngines: { packageManager: { name: 'pnpm', version: '^10.0.0' } }, settingsInYaml: true }, + { devEngines: { packageManager: { name: 'pnpm', version: '10.6.x' } }, settingsInYaml: true }, { devEngines: { packageManager: [{ name: 'unsupported' }, { name: 'pnpm', version: '10.6.1' }], diff --git a/packages/cli/src/migration/migrator/catalog.ts b/packages/cli/src/migration/migrator/catalog.ts index 6065066d27..6a2cd62821 100644 --- a/packages/cli/src/migration/migrator/catalog.ts +++ b/packages/cli/src/migration/migrator/catalog.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { coerce, isGreaterThanOrEqual, isValid } from 'verkit'; +import { coerce, isGreaterThanOrEqual, isValid, isValidRange, rangesIntersect } from 'verkit'; import { Scalar, YAMLMap, YAMLSeq } from 'yaml'; import { PackageManager, type WorkspacePackage } from '../../types/index.ts'; @@ -265,14 +265,13 @@ export function ensureStandalonePnpmWorkspace(projectPath: string): boolean { // install and invalidate the child's existing frozen lockfile. fs.writeFileSync(pnpmWorkspaceYamlPath, 'packages:\n - "."\n'); const version = declaredPackageManager(pkg)?.version; - // Copying is additive: settings come from an in-memory copy and the child's - // package.json is never rewritten. Only an exact pin below 10.6.2 skips the - // copy. pnpm 10.5–10.6.1 reads yaml settings, but overrides and - // peerDependencyRules were still broken (see pnpmSupportsWorkspaceSettings). - // A range, `*`, dist-tag, or invalid string is not that pin. `latest` and - // `next` copy either way: they are not exact, and the support check treats - // them as current releases. - if (version === undefined || !isValid(version) || pnpmSupportsWorkspaceSettings(version)) { + // Copy if support is possible; judge prerelease pins by their release version. + if ( + version === undefined || + !isValidRange(version) || + (isValid(version) && pnpmSupportsWorkspaceSettings(version)) || + rangesIntersect(version, '>=' + PNPM_WORKSPACE_SETTINGS_MIN_VERSION) + ) { migratePnpmSettingsToWorkspaceYaml(projectPath, takePnpmWorkspaceSettings(pkg)); } return true; From 52f696f79e1cf2013ae216edf34e72a8f70b15bd Mon Sep 17 00:00:00 2001 From: yusuke <66258931+yusuke99@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:44:49 +0900 Subject: [PATCH 16/16] fix(migrate): honor non-pnpm workspaces rooted at matched members Scanning below workspace members skipped the member's own package.json, so when a matched member rooted a Yarn, npm, or Bun workspace, its members were not recorded. A member with a pnpm signal then received its own pnpm-workspace.yaml, and vp commands run from it switched from the enclosing workspace's package manager to pnpm. Read member manifests too, and honor their workspaces when the member uses a non-pnpm package manager (by declaration, or by lockfile when undeclared). pnpm members' npm-style workspaces stay ignored, and a member itself is still never treated as an independent project. --- .../src/migration/__tests__/migrator.spec.ts | 41 +++++++++++++++++++ .../cli/src/migration/migrator/source-scan.ts | 21 +++++++--- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 32fb33399d..2cfe390098 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -2222,6 +2222,47 @@ describe('standalone pnpm child project boundaries', () => { expect(fs.existsSync(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toBe(true); }); + it.each([ + { packageManager: 'yarn@4.9.2' }, + { packageManager: 'npm@10.9.4' }, + { packageManager: 'bun@1.3.0' }, + { devEngines: { packageManager: { name: 'yarn' } } }, + { file: 'yarn.lock' }, + { file: 'package-lock.json' }, + { file: 'bun.lock' }, + ])('preserves a non-pnpm workspace rooted at a matched member (%j)', (declaration) => { + fs.writeFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), 'packages: ["vendor/*"]\n'); + for (const child of ['packages/pinned', 'packages/locked', 'examples/demo']) { + fs.mkdirSync(path.join(tmpDir, 'vendor/app', child), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'vendor/app', child, 'package.json'), + JSON.stringify({ + name: child, + ...(child === 'packages/pinned' ? { packageManager: 'pnpm@10.33.0' } : {}), + }), + ); + fs.writeFileSync(path.join(tmpDir, 'vendor/app', child, 'pnpm-lock.yaml'), ''); + } + fs.writeFileSync( + path.join(tmpDir, 'vendor/app/package.json'), + JSON.stringify({ name: 'app', workspaces: ['packages/*'], ...declaration }), + ); + if (declaration.file !== undefined) { + fs.writeFileSync(path.join(tmpDir, 'vendor/app', declaration.file), ''); + } + + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + + for (const child of ['', 'packages/pinned', 'packages/locked']) { + expect(fs.existsSync(path.join(tmpDir, 'vendor/app', child, 'pnpm-workspace.yaml'))).toBe( + false, + ); + } + expect(fs.existsSync(path.join(tmpDir, 'vendor/app/examples/demo/pnpm-workspace.yaml'))).toBe( + true, + ); + }); + it('continues scanning below a non-pnpm standalone project', () => { fs.mkdirSync(path.join(tmpDir, 'vendor/app'), { recursive: true }); fs.writeFileSync( diff --git a/packages/cli/src/migration/migrator/source-scan.ts b/packages/cli/src/migration/migrator/source-scan.ts index 922ce086ae..9ac71d40f2 100644 --- a/packages/cli/src/migration/migrator/source-scan.ts +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -212,8 +212,8 @@ export function collectStandalonePnpmProjects(projectPath: string): string[] { const workspaceFile = path.join(directory, 'pnpm-workspace.yaml'); const hasWorkspaceFile = fs.existsSync(workspaceFile); let pkg: Record | undefined; - // Members may contain independent projects, but their manifests do not own workspaces. - if (!workspaceMembers.has(directory) && fs.existsSync(path.join(directory, 'package.json'))) { + // Matched members can themselves root a non-pnpm workspace. + if (fs.existsSync(path.join(directory, 'package.json'))) { try { const parsed = readJsonFile(path.join(directory, 'package.json')); if (isPlainRecord(parsed)) { @@ -223,17 +223,27 @@ export function collectStandalonePnpmProjects(projectPath: string): string[] { // A malformed manifest must not hide independent projects below it. } } + let patterns = pkg?.workspaces; if (pkg) { const declared = declaredPackageManager(pkg); + const usesNonPnpm = declared + ? declared.name !== PackageManager.pnpm + : !fs.existsSync(path.join(directory, 'pnpm-lock.yaml')) && + ['yarn.lock', '.yarnrc.yml', 'package-lock.json', 'bun.lock', 'bun.lockb'].some((file) => + fs.existsSync(path.join(directory, file)), + ); + // pnpm members' npm-style workspaces do not own their descendants. + if (workspaceMembers.has(directory) && !usesNonPnpm) { + patterns = undefined; + } if ( directory !== directories[0] && + !workspaceMembers.has(directory) && !hasWorkspaceFile && (declared ? declared.name === PackageManager.pnpm : fs.existsSync(path.join(directory, 'pnpm-lock.yaml')) || - (!['yarn.lock', '.yarnrc.yml', 'package-lock.json', 'bun.lock', 'bun.lockb'].some( - (file) => fs.existsSync(path.join(directory, file)), - ) && + (!usesNonPnpm && ['.pnpmfile.cjs', 'pnpmfile.cjs'].some((file) => fs.existsSync(path.join(directory, file)), ))) @@ -244,7 +254,6 @@ export function collectStandalonePnpmProjects(projectPath: string): string[] { // A newly created boundary stays single-package; package.json#workspaces // must not enable additional members that pnpm did not install before. if (projects.at(-1) !== directory) { - let patterns = pkg?.workspaces; if (hasWorkspaceFile) { try { patterns = (readYamlFile(workspaceFile) as { packages?: unknown })?.packages;