From 3370dad08f18b0253a0c8fa5717383a50191e2fc Mon Sep 17 00:00:00 2001 From: sakana <185575200+SaKaNa-Y@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:50:25 +0800 Subject: [PATCH] fix(create): complete catalogs in existing pnpm workspaces Reuse existing catalog selections and fill missing managed entries before installation. Preserve user-defined versions and unrelated workspace configuration. --- .../package.json | 5 + .../pnpm-workspace.yaml | 2 + .../snapshots.toml | 13 ++ .../create_existing_pnpm_workspace.md | 28 ++++ .../__tests__/workspace-catalog.spec.ts | 135 ++++++++++++++++++ packages/cli/src/create/bin.ts | 9 ++ packages/cli/src/create/utils.ts | 50 ++++++- 7 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_existing_pnpm_workspace/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_existing_pnpm_workspace/pnpm-workspace.yaml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_existing_pnpm_workspace/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_existing_pnpm_workspace/snapshots/create_existing_pnpm_workspace.md create mode 100644 packages/cli/src/create/__tests__/workspace-catalog.spec.ts diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_existing_pnpm_workspace/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_existing_pnpm_workspace/package.json new file mode 100644 index 0000000000..4f251c1ba9 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_existing_pnpm_workspace/package.json @@ -0,0 +1,5 @@ +{ + "name": "plain-workspace", + "private": true, + "packageManager": "pnpm@11.24.0" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_existing_pnpm_workspace/pnpm-workspace.yaml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_existing_pnpm_workspace/pnpm-workspace.yaml new file mode 100644 index 0000000000..852bf6b8f9 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_existing_pnpm_workspace/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - apps/* diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_existing_pnpm_workspace/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_existing_pnpm_workspace/snapshots.toml new file mode 100644 index 0000000000..e6b82e155f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_existing_pnpm_workspace/snapshots.toml @@ -0,0 +1,13 @@ +[[case]] +name = "create_existing_pnpm_workspace" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL", "CI"] +comment = "Adding an application to a plain pnpm workspace supplies the catalog entries needed for installation." +steps = [ + { argv = ["vp", "create", "vite:application", "--directory", "apps/web", "--package-manager", "pnpm", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"], snapshot = false }, + { argv = ["vpt", "stat-file", "pnpm-lock.yaml", "--assert", "file"] }, + { argv = ["vpt", "print-file", "pnpm-workspace.yaml"] }, + { argv = ["vp", "install", "--frozen-lockfile"], snapshot = false }, + { argv = ["vp", "run", "build"], cwd = "apps/web", snapshot = false }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_existing_pnpm_workspace/snapshots/create_existing_pnpm_workspace.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_existing_pnpm_workspace/snapshots/create_existing_pnpm_workspace.md new file mode 100644 index 0000000000..342d20b26a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_existing_pnpm_workspace/snapshots/create_existing_pnpm_workspace.md @@ -0,0 +1,28 @@ +# create_existing_pnpm_workspace + +Adding an application to a plain pnpm workspace supplies the catalog entries needed for installation. + +## `vp create vite:application --directory apps/web --package-manager pnpm --no-interactive --no-agent --no-editor --no-hooks` + + +## `vpt stat-file pnpm-lock.yaml --assert file` + +``` +pnpm-lock.yaml: file +``` + +## `vpt print-file pnpm-workspace.yaml` + +``` +packages: + - apps/* +catalog: + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: +``` + +## `vp install --frozen-lockfile` + + +## `cd apps/web && vp run build` + diff --git a/packages/cli/src/create/__tests__/workspace-catalog.spec.ts b/packages/cli/src/create/__tests__/workspace-catalog.spec.ts new file mode 100644 index 0000000000..4786036f72 --- /dev/null +++ b/packages/cli/src/create/__tests__/workspace-catalog.spec.ts @@ -0,0 +1,135 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + createCatalogDependencyResolver, + rewriteMonorepoProject, +} from '../../migration/migrator.ts'; +import { PackageManager } from '../../types/index.ts'; +import { VITE_PLUS_OVERRIDE_PACKAGES, VITE_PLUS_VERSION } from '../../utils/constants.ts'; +import { readJsonFile } from '../../utils/json.ts'; +import { readYamlFile } from '../../utils/yaml.ts'; +import { ensurePnpmCreateCatalogEntries } from '../utils.ts'; + +let root: string; +let project: string; +let workspaceFile: string; + +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-create-catalog-')); + project = path.join(root, 'apps/web'); + fs.mkdirSync(project, { recursive: true }); + workspaceFile = path.join(root, 'pnpm-workspace.yaml'); + fs.writeFileSync(path.join(root, 'package.json'), '{"private":true}'); + fs.writeFileSync(workspaceFile, 'packages:\n - apps/*\n'); +}); + +afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + +function integrate(dependencies: Record = { vite: '^8.0.0' }) { + fs.writeFileSync( + path.join(project, 'package.json'), + JSON.stringify({ devDependencies: dependencies }), + ); + rewriteMonorepoProject( + project, + PackageManager.pnpm, + true, + true, + undefined, + createCatalogDependencyResolver(root, PackageManager.pnpm), + ); + ensurePnpmCreateCatalogEntries(root, project); + return readYamlFile(workspaceFile); +} + +describe('catalogs for applications created in existing pnpm workspaces', () => { + it('makes references generated by member integration resolvable in a plain workspace', () => { + const workspace = integrate(); + expect(readJsonFile(path.join(project, 'package.json')).devDependencies).toMatchObject({ + vite: 'catalog:', + 'vite-plus': 'catalog:', + }); + expect(workspace).toEqual({ + packages: ['apps/*'], + catalog: { vite: VITE_PLUS_OVERRIDE_PACKAGES.vite, 'vite-plus': VITE_PLUS_VERSION }, + }); + }); + + it('adds missing entries to the selected named catalog without changing existing versions', () => { + fs.writeFileSync( + workspaceFile, + `packages: [apps/*]\ncatalogs:\n tools:\n vite-plus: ${VITE_PLUS_VERSION}\n unrelated:\n react: ^19.0.0\n`, + ); + expect(integrate()).toEqual({ + packages: ['apps/*'], + catalogs: { + tools: { 'vite-plus': VITE_PLUS_VERSION, vite: VITE_PLUS_OVERRIDE_PACKAGES.vite }, + unrelated: { react: '^19.0.0' }, + }, + }); + expect(readJsonFile(path.join(project, 'package.json')).devDependencies).toMatchObject({ + vite: 'catalog:tools', + 'vite-plus': 'catalog:tools', + }); + }); + + it('uses catalogs.default without creating a conflicting top-level catalog', () => { + fs.writeFileSync( + workspaceFile, + 'packages: [apps/*]\ncatalogs:\n default:\n react: ^19.0.0\n', + ); + expect(integrate()).toEqual({ + packages: ['apps/*'], + catalogs: { + default: { + react: '^19.0.0', + vite: VITE_PLUS_OVERRIDE_PACKAGES.vite, + 'vite-plus': VITE_PLUS_VERSION, + }, + }, + }); + }); + + it('leaves a complete workspace file byte-for-byte unchanged', () => { + const content = + 'packages: [apps/*]\n# User-owned versions\ncatalog:\n vite: npm:@voidzero-dev/vite-plus-core@0.3.2\n vite-plus: 0.3.2\noverrides:\n react: ^19.0.0\n'; + fs.writeFileSync(workspaceFile, content); + integrate(); + expect(fs.readFileSync(workspaceFile, 'utf8')).toBe(content); + }); + + it('fills the exact catalog named by each dependency reference', () => { + const workspace = integrate({ vite: 'catalog:build', 'vite-plus': 'catalog:default' }); + expect(workspace).toEqual({ + packages: ['apps/*'], + catalog: { 'vite-plus': VITE_PLUS_VERSION }, + catalogs: { build: { vite: VITE_PLUS_OVERRIDE_PACKAGES.vite } }, + }); + }); + + it('leaves templates without a package manifest alone', () => { + const content = fs.readFileSync(workspaceFile, 'utf8'); + ensurePnpmCreateCatalogEntries(root, project); + expect(fs.readFileSync(workspaceFile, 'utf8')).toBe(content); + }); + + it('does not create catalog entries for concrete dependencies or unknown template references', () => { + const content = fs.readFileSync(workspaceFile, 'utf8'); + fs.writeFileSync( + path.join(project, 'package.json'), + JSON.stringify({ + devDependencies: { + vite: 'file:../core.tgz', + 'vite-plus': 'file:../cli.tgz', + custom: 'catalog:', + }, + }), + ); + ensurePnpmCreateCatalogEntries(root, project); + expect(fs.readFileSync(workspaceFile, 'utf8')).toBe(content); + }); +}); diff --git a/packages/cli/src/create/bin.ts b/packages/cli/src/create/bin.ts index f4edba51bd..ac870cead8 100644 --- a/packages/cli/src/create/bin.ts +++ b/packages/cli/src/create/bin.ts @@ -7,6 +7,7 @@ import * as prompts from '@voidzero-dev/vite-plus-prompts'; import { parseCreateArgs, vitePlusHeader } from '../../binding/index.js'; import { addFrameworkShim, + createCatalogDependencyResolver, detectEslintProject, detectFramework, detectPrettierProject, @@ -87,6 +88,7 @@ import { import { BuiltinTemplate, TemplateType } from './templates/types.ts'; import { deriveDefaultPackageName, + ensurePnpmCreateCatalogEntries, ensureDefaultGitignoreEntries, ensureGitignoreVsCodeEditorConfigs, formatTargetDir, @@ -1242,7 +1244,14 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h workspaceInfo.packageManager, skipStagedMigration, compactOutput, + undefined, + workspaceInfo.packageManager === PackageManager.pnpm + ? createCatalogDependencyResolver(workspaceInfo.rootDir, workspaceInfo.packageManager) + : undefined, ); + if (workspaceInfo.packageManager === PackageManager.pnpm) { + ensurePnpmCreateCatalogEntries(workspaceInfo.rootDir, fullPath); + } for (const framework of detectFramework(fullPath)) { if (!hasFrameworkShim(fullPath, framework)) { addFrameworkShim(fullPath, framework); diff --git a/packages/cli/src/create/utils.ts b/packages/cli/src/create/utils.ts index 4c3447f056..85f69c320a 100644 --- a/packages/cli/src/create/utils.ts +++ b/packages/cli/src/create/utils.ts @@ -2,12 +2,60 @@ import fs from 'node:fs'; import path from 'node:path'; import validateNpmPackageName from 'validate-npm-package-name'; +import { parseDocument } from 'yaml'; -import { editJsonFile } from '../utils/json.ts'; +import { + VITE_PLUS_NAME, + VITE_PLUS_OVERRIDE_PACKAGES, + VITE_PLUS_VERSION, +} from '../utils/constants.ts'; +import { editJsonFile, readJsonFile } from '../utils/json.ts'; import { getRandomProjectName } from './random-name.ts'; export type CreateEditorOption = string | false | undefined; +/** Complete only the managed catalog references of a newly created workspace member. + * Running the full root migrator here would also change unrelated packages and overrides. + */ +export function ensurePnpmCreateCatalogEntries(rootDir: string, projectDir: string): void { + const workspacePath = path.join(rootDir, 'pnpm-workspace.yaml'); + const packageJsonPath = path.join(projectDir, 'package.json'); + if (!fs.existsSync(workspacePath) || !fs.existsSync(packageJsonPath)) { + return; + } + const doc = parseDocument(fs.readFileSync(workspacePath, 'utf8')); + const pkg = readJsonFile(packageJsonPath) as Partial< + Record<'dependencies' | 'devDependencies' | 'optionalDependencies', Record> + >; + const versions: Record = { + ...VITE_PLUS_OVERRIDE_PACKAGES, + [VITE_PLUS_NAME]: VITE_PLUS_VERSION, + }; + let changed = false; + for (const field of ['dependencies', 'devDependencies', 'optionalDependencies'] as const) { + for (const [name, spec] of Object.entries(pkg[field] ?? {})) { + if (!Object.hasOwn(versions, name) || !spec.startsWith('catalog:')) { + continue; + } + const catalogName = spec.slice('catalog:'.length); + const catalogPath = + catalogName && catalogName !== 'default' + ? ['catalogs', catalogName] + : doc.has('catalog') || !doc.hasIn(['catalogs', 'default']) + ? ['catalog'] + : ['catalogs', 'default']; + const entryPath = [...catalogPath, name]; + if (!doc.hasIn(entryPath)) { + doc.setIn(entryPath, versions[name]); + changed = true; + } + } + } + if (changed) { + fs.writeFileSync(workspacePath, doc.toString({ singleQuote: true })); + } +} + function hasExplicitEditorOptIn(editor: CreateEditorOption): boolean { return typeof editor === 'string' && editor.trim() !== ''; }