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..884485cb53 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots.toml @@ -0,0 +1,68 @@ +[[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 = ["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" }, +] + +[[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" }, +] + +[[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_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/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..fd7a534001 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_nested_pnpm_postinstall/snapshots/migration_nested_pnpm_postinstall.md @@ -0,0 +1,43 @@ +# 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 + + +## `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 + +``` +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/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/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/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 7f46795dcf..8555442060 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -291,6 +291,7 @@ Migration converts the Volta `volta.node` pin to `.node-version`, because Vite+ - 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 only allows pnpm versions below 10.6.2, only `packages` is written. Nested Git repositories, including submodules, and everything below them are left unchanged. **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 bd868a769a..0dca8c4752 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 @@ -1822,6 +1825,806 @@ 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 }); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it.each([ + ['file', (file: string) => fs.writeFileSync(file, 'gitdir: external\n')], + ['directory', (directory: string) => fs.mkdirSync(directory)], + ])('leaves nested checkouts with a .git %s untouched', async (_kind, createGit) => { + // The migration root can itself be a checkout or worktree. + for (const directory of [tmpDir, path.join(tmpDir, 'web')]) { + createGit(path.join(directory, '.git')); + } + fs.mkdirSync(path.join(tmpDir, 'web/example')); + fs.copyFileSync( + path.join(tmpDir, 'web/package.json'), + path.join(tmpDir, 'web/example/package.json'), + ); + fs.mkdirSync(path.join(tmpDir, 'sibling')); + fs.writeFileSync( + path.join(tmpDir, 'sibling/package.json'), + JSON.stringify({ name: 'sibling', packageManager: 'pnpm@10.33.0' }), + ); + 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 updateStandalonePnpmLockfiles(projects); + + expect(projects).toEqual([path.join(tmpDir, 'sibling')]); + expect(fs.existsSync(path.join(tmpDir, 'sibling/pnpm-workspace.yaml'))).toBe(true); + expect(fs.existsSync(path.join(tmpDir, 'web/pnpm-workspace.yaml'))).toBe(false); + expect(fs.existsSync(path.join(tmpDir, 'web/example/pnpm-workspace.yaml'))).toBe(false); + expect(fs.readFileSync(path.join(tmpDir, 'web/pnpm-lock.yaml'), 'utf8')).toBe( + 'lockfileVersion: "9.0"\n', + ); + expect(install).not.toHaveBeenCalled(); + }); + + 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', () => { + 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 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.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', 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' }], + }, + 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', + ...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')); + + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + + 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([ + ['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) => { + 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([ + ['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' }, + { 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.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' }, + { file: 'bunfig.toml' }, + { file: 'yarn.config.cjs' }, + ])('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( + 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('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); + + rewriteStandaloneProject( + tmpDir, + makeWorkspaceInfo(tmpDir, PackageManager.pnpm, '11.25.0'), + true, + true, + ); + + 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); + }); + + 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); + }); + + 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: {} };'); + fs.writeFileSync(path.join(tmpDir, 'web/bunfig.toml'), ''); + fs.writeFileSync(path.join(tmpDir, 'web/yarn.config.cjs'), 'module.exports = {};'); + + 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); + 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.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 }, + { 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' }] }, + { 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.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 }, + { + 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')); + 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/bin.ts b/packages/cli/src/migration/bin.ts index a699b6d2be..7b8311878d 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, retryPreservedOxlintConfigs, prepareOxlintConfigs, removeMigratedOxlintConfigs, @@ -898,6 +900,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 { @@ -969,6 +975,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, @@ -1011,7 +1021,7 @@ async function executeMigrationPlan( } return { installDurationMs: initialInstallDurationMs + finalInstallDurationMs, - finalInstallOk: finalInstallSummary.status === 'installed', + finalInstallOk: childLockfilesOk && finalInstallSummary.status === 'installed', packageManagerVersion: downloadResult.version, report, }; @@ -1127,6 +1137,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 () => { @@ -1528,6 +1542,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, @@ -1556,8 +1574,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, @@ -1603,7 +1621,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 cd6b13e998..66d762e78c 100644 --- a/packages/cli/src/migration/migrator/catalog.ts +++ b/packages/cli/src/migration/migrator/catalog.ts @@ -1,10 +1,11 @@ import fs from 'node:fs'; import path from 'node:path'; -import { coerce, isGreaterThanOrEqual } from 'verkit'; +import { coerce, isGreaterThanOrEqual, isValid, isValidRange, rangesIntersect } 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, @@ -17,6 +18,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, @@ -31,6 +33,7 @@ import { shouldDropProviderOverrideKey, workspaceUsesWebdriverioProvider, } from '../migrator.ts'; +import type { MigrationReport } from '../report.ts'; import { type PreparedOxlintConfigs, LEGACY_WRAPPER_FALLBACK_VERSIONS, @@ -39,6 +42,7 @@ import { VITEST_IS_MANAGED_OVERRIDE, isPlainRecord, pnpmOverrideKey, + warnMigration, type CatalogDependencyResolver, type PackageJsonDependencyField, type PnpmPackageJsonSettings, @@ -252,6 +256,69 @@ export function migratePnpmSettingsToWorkspaceYaml( }); } +export function ensureStandalonePnpmWorkspace(projectPath: string): boolean { + const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); + if (fs.existsSync(pnpmWorkspaceYamlPath)) { + return false; + } + 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'); + const version = declaredPackageManager(pkg)?.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; +} + +// 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 diff --git a/packages/cli/src/migration/migrator/orchestrators.ts b/packages/cli/src/migration/migrator/orchestrators.ts index dc4a2c5550..d6af7185a0 100644 --- a/packages/cli/src/migration/migrator/orchestrators.ts +++ b/packages/cli/src/migration/migrator/orchestrators.ts @@ -12,12 +12,14 @@ import { collectOxlintDependencyNames, collectOxlintOwnerDirs, collectProviderSourceModes, + collectStandalonePnpmProjects, collectVitestEcosystemInstallDependencyNames, createCatalogDependencyResolver, dropDeadOxlintPluginsDependency, dropRemovePackageOverrideKeys, ensurePnpmWorkspaceExoticSubdepsSetting, ensurePnpmWorkspacePackages, + ensureStandalonePnpmWorkspace, findYarnWorkspaceHoisting, hasDirectVitePlusInstallEntry, hasOwnWebdriverioDependency, @@ -81,6 +83,8 @@ export function rewriteStandaloneProject( } const packageManager = workspaceInfo.packageManager; + const standalonePnpmProjects = + packageManager === PackageManager.pnpm ? collectStandalonePnpmProjects(projectPath) : []; const catalogDependencyResolver = createCatalogDependencyResolver(projectPath, packageManager); const vitestEcosystemPackages = collectVitestEcosystemInstallDependencyNames(projectPath); // Captured before `rewritePackageJson` strips `oxlint`; the import rewriter @@ -312,6 +316,9 @@ export function rewriteStandaloneProject( if (packageManager === PackageManager.pnpm) { ensurePnpmWorkspaceExoticSubdepsSetting(projectPath); + for (const directory of standalonePnpmProjects) { + ensureStandalonePnpmWorkspace(directory); + } } if (packageManager === PackageManager.yarn) { @@ -357,6 +364,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, @@ -457,6 +468,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 6d01d0b872..b088536402 100644 --- a/packages/cli/src/migration/migrator/source-scan.ts +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -1,17 +1,20 @@ 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'; 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, OXLINT_PLUGINS_PACKAGE, packageOwnsOxlintApi, PLAYWRIGHT_PROVIDER, + isPlainRecord, readPackageJsonIfExists, type DependencyBag, } from './shared.ts'; @@ -172,6 +175,129 @@ 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']); +// 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 = [path.resolve(projectPath)]; + const workspaceMembers = new Set(); + for (const directory of directories) { + // Nested checkouts own their projects, including descendants outside workspace globs. + if (directory !== directories[0] && fs.existsSync(path.join(directory, '.git'))) { + continue; + } + const workspaceFile = path.join(directory, 'pnpm-workspace.yaml'); + const hasWorkspaceFile = fs.existsSync(workspaceFile); + let pkg: Record | undefined; + // 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)) { + pkg = parsed; + } + } catch { + // 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)), + ) || + (!['.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)), + ))); + // 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')) || + (!usesNonPnpm && + ['.pnpmfile.cjs', 'pnpmfile.cjs'].some((file) => + fs.existsSync(path.join(directory, file)), + ))) + ) { + projects.push(directory); + } + } + // 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) { + 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[]; + try { + entries = fs.readdirSync(directory, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (entry.isDirectory() && !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..54b5636860 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, @@ -751,6 +753,9 @@ export function detectVitePlusBootstrapPending( ); } if (packageManager === PackageManager.pnpm) { + if (collectStandalonePnpmProjects(projectPath).length > 0) { + return true; + } if (!pnpmWorkspaceExoticSubdepsSettingSatisfied(projectPath)) { return true; } @@ -968,6 +973,11 @@ export function ensureVitePlusBootstrap( return result; } + const standalonePnpmProjects = + workspaceInfo.packageManager === PackageManager.pnpm + ? 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 +1260,10 @@ export function ensureVitePlusBootstrap( const after = readTextFileIfExists(pnpmWorkspaceYamlPath); result.packageManagerConfig = exoticChanged || pnpmWorkspaceCatalogBefore !== after; } + for (const directory of standalonePnpmProjects) { + const changed = ensureStandalonePnpmWorkspace(directory); + result.packageManagerConfig = result.packageManagerConfig || changed; + } } else if (workspaceInfo.packageManager === PackageManager.yarn) { const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); const before = readTextFileIfExists(yarnrcYmlPath); 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, + }, ); }