Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "plain-workspace",
"private": true,
"packageManager": "pnpm@11.24.0"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
packages:
- apps/*
Original file line number Diff line number Diff line change
@@ -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 },
]
Original file line number Diff line number Diff line change
@@ -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@<version>
vite-plus: <version>
```

## `vp install --frozen-lockfile`


## `cd apps/web && vp run build`

135 changes: 135 additions & 0 deletions packages/cli/src/create/__tests__/workspace-catalog.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = { 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);
});
});
9 changes: 9 additions & 0 deletions packages/cli/src/create/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -87,6 +88,7 @@ import {
import { BuiltinTemplate, TemplateType } from './templates/types.ts';
import {
deriveDefaultPackageName,
ensurePnpmCreateCatalogEntries,
ensureDefaultGitignoreEntries,
ensureGitignoreVsCodeEditorConfigs,
formatTargetDir,
Expand Down Expand Up @@ -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);
Expand Down
50 changes: 49 additions & 1 deletion packages/cli/src/create/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>>
>;
const versions: Record<string, string> = {
...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() !== '';
}
Expand Down
Loading