Skip to content

Commit 328c186

Browse files
committed
Install Vite+ projects through vp during initialization
Use project-local or global Vite+, bootstrapping through npx when neither is available. This avoids asking an older global pnpm to launch a native pnpm 12 pin during init. Preserve existing package-manager pins and lockfiles. Fresh Vite+ projects use pnpm without forcing a version and retain the approved build policy. Add real subprocess regressions for installer selection, bootstrap and failure propagation, plus scaffold coverage for package-manager choices. Update the setup documentation. Fixes #406.
1 parent 5a1310e commit 328c186

8 files changed

Lines changed: 257 additions & 27 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ npx void init
2020
npm run dev
2121
```
2222

23-
Choose your UI framework and starter, then select Cloudflare to deploy to your account:
23+
Vite+ is the default toolchain; setup installs it for you and keeps your existing package-manager configuration. Choose your UI framework and starter, then select Cloudflare to deploy to your account:
2424

2525
```sh
2626
npx void deploy

‎docs/guide/quickstart.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ bunx void init
3636

3737
Choose your UI framework, a starter, and a deployment target. Vite+ is the default build tool. D1 is a good first database: it runs locally without a separate database server. Choose Cloudflare to deploy to your own account, or skip deployment setup for now.
3838

39+
You do not need to install Vite+ globally. Setup preserves your existing package-manager versions and lockfiles.
40+
3941
With pnpm, `pnpm create void my-app` also creates the project directory and configures native build permissions for you.
4042

4143
## Run locally

‎docs/reference/cli/setup.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ void init [--tsconfig] [--github] [--git | --no-git]
3333

3434
Set up a new or existing Void project. In an empty directory, choose Vite+ or Vite, a framework, and a D1, PostgreSQL, MySQL, or Static Pages starter.
3535

36+
Vite+ projects install dependencies through `vp install`; no global Vite+ installation is required. Void preserves existing package-manager versions and lockfiles. New Vite+ projects without a package-manager configuration use pnpm without pinning a version.
37+
3638
In an existing app, Void adds missing dependencies and scripts and updates the Vite config. If it cannot edit your config, it prints the snippet to add.
3739

3840
The wizard also sets up TypeScript, agent instructions, environment declarations, and a deployment target. Choose Cloudflare, a Void platform, or skip deployment setup. If you skip sign-in, run `void connect` when you're ready. See [Quick Start](../../guide/quickstart.md).

‎packages/void/AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ generated entries must preserve the module-boundary rules below.
9898

9999
- **Installed runtime module identity**: Keep `void` server runtime subpaths out of Vite dependency optimization. Generated entries import runtime modules by resolved file path; prebundling a bare `void/kv` creates a second `envContext` and loses request bindings. Workspace symlinks can hide this defect. The shared runtime-optimizer plugin excludes the package in server environments on every target while preserving client optimization; packed-consumer checks exercise actual npm/pnpm installations.
100100
- **pnpm scaffold build policy**: Keep `create-void` and `void init` aligned with the repository: `better-sqlite3: false` uses version 13's bundled Node-API binaries; `esbuild`, `sharp`, and `workerd` have approved builds. Do not require an unnecessary node-gyp rebuild in fresh applications.
101+
- **Scaffold dependency installation**: Vite+ projects install through local/global `vp`, bootstrapping with `npx` when neither is available. Preserve existing package-manager pins and lockfiles; fresh Vite+ projects select pnpm through the build-policy YAML without adding a version pin. Plain Vite projects use their selected package manager. Do not run a global package manager directly for Vite+ installs.
101102
- **Prerender discovery and environment validation**: Only `POST /__void/prerender-paths` bypasses whole-schema boot validation, because path discovery runs without remote production secrets. Discovery answers only when the build-time runner marks the request (the `__VOID_PRERENDER` Miniflare binding on Cloudflare, the Hono env argument on Node/Bun/Deno), never on a deployed app or dev server. Keep validation on readiness, application requests, page rendering, and event handlers.
102103
- **Pre-resolved paths in codegen**: Generated virtual module code uses absolute paths for `hono` and `void/response` (resolved via `import.meta.resolve()`). Virtual modules have no filesystem location, so bare specifiers can't resolve from them.
103104
- **CF plugin API**: Worker config (`main`, `d1_databases`, `kv_namespaces`, `r2_buckets`) goes inside the `config` option of `cloudflare()`, NOT as top-level properties. `persistState` takes `{ path: string }`, not a raw string. The `config` option uses a **function form** `(resolved: WorkerConfig) => Partial<WorkerConfig>` so that bindings from the user's `wrangler.json` (if present) take precedence over inferred placeholder bindings.

‎packages/void/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ npx void init
2020
npm run dev
2121
```
2222

23-
Choose your UI framework and starter, then select Cloudflare to deploy to your account:
23+
Vite+ is the default toolchain; setup installs it for you and keeps your existing package-manager configuration. Choose your UI framework and starter, then select Cloudflare to deploy to your account:
2424

2525
```sh
2626
npx void deploy

‎packages/void/src/cli/init.ts‎

Lines changed: 54 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
SCAFFOLD_FRAMEWORK_OPTIONS,
4747
SCAFFOLD_GITIGNORE_LINES,
4848
SCAFFOLD_TOOLCHAIN_OPTIONS,
49+
SCAFFOLD_VITE_PLUS_DEV_DEPENDENCIES,
4950
type ScaffoldFramework,
5051
type ScaffoldStarterMode,
5152
type ScaffoldToolchain,
@@ -1971,32 +1972,66 @@ function ensurePnpmAllowBuilds(root: string, pkg: JsonObject): void {
19711972
}
19721973
}
19731974

1974-
function installProjectDependencies(
1975+
export function installProjectDependencies(
19751976
root: string,
19761977
packageManager: ProjectState['packageManager'],
19771978
): void {
1978-
log.step(`Installing dependencies with ${packageManager}`);
1979+
const pkg = readPackageJson(root);
1980+
const useVitePlus = pkg !== null && hasPackageDependency(pkg, 'vite-plus');
1981+
const installer = useVitePlus ? 'vp' : packageManager;
1982+
log.step(`Installing dependencies with ${installer}`);
19791983

19801984
// Init has just changed the manifest. Refresh the lockfile even when pnpm
19811985
// detects a CI host; subsequent CI builds can use frozen installs normally.
1982-
const args = packageManager === 'pnpm' ? ['install', '--no-frozen-lockfile'] : ['install'];
1983-
const result = spawnSync(packageManager, args, {
1986+
let command: string = installer;
1987+
let args =
1988+
useVitePlus || packageManager === 'pnpm' ? ['install', '--no-frozen-lockfile'] : ['install'];
1989+
if (useVitePlus) {
1990+
let localBin: string | undefined;
1991+
try {
1992+
const require = createRequire(join(root, 'package.json'));
1993+
localBin = join(dirname(require.resolve('vite-plus/package.json')), 'bin/vp');
1994+
} catch {
1995+
// The starter's Vite+ dependency has not been installed yet.
1996+
}
1997+
if (localBin) {
1998+
command = process.execPath;
1999+
args = [localBin, ...args];
2000+
} else {
2001+
const probe = spawnSync('vp', ['--version'], {
2002+
cwd: root,
2003+
stdio: 'ignore',
2004+
shell: process.platform === 'win32',
2005+
timeout: 5_000,
2006+
});
2007+
if (probe.status !== 0) {
2008+
// npx void init must also work without a globally installed vp. Avoid
2009+
// asking an older pnpm to switch itself to a pinned native pnpm 12.
2010+
command = 'npx';
2011+
args = [
2012+
'--yes',
2013+
`--package=vite-plus@${SCAFFOLD_VITE_PLUS_DEV_DEPENDENCIES['vite-plus']}`,
2014+
'vp',
2015+
...args,
2016+
];
2017+
}
2018+
}
2019+
}
2020+
const result = spawnSync(command, args, {
19842021
cwd: root,
19852022
stdio: 'inherit',
1986-
shell: process.platform === 'win32',
2023+
shell: command !== process.execPath && process.platform === 'win32',
19872024
});
19882025

19892026
if (result.error) {
19902027
throw result.error;
19912028
}
19922029

19932030
if (result.status !== 0) {
1994-
throw new Error(
1995-
`${packageManager} install failed with exit code ${result.status ?? 'unknown'}`,
1996-
);
2031+
throw new Error(`${installer} install failed with exit code ${result.status ?? 'unknown'}`);
19972032
}
19982033

1999-
log.step(`Installed dependencies with ${packageManager}`);
2034+
log.step(`Installed dependencies with ${installer}`);
20002035
}
20012036

20022037
export function generateStarterMigration(
@@ -2121,17 +2156,9 @@ function scaffoldPagesApp(
21212156

21222157
// `detectProjectState` reads only local markers; `isPnpmProject` also covers
21232158
// pnpm workspace members, which need the root `allowBuilds`.
2124-
if (isPnpmProject(root, pkg)) {
2159+
if (packageManager === 'pnpm' || isPnpmProject(root, pkg)) {
21252160
ensurePnpmAllowBuilds(root, pkg);
21262161
}
2127-
// Workspace members keep their Corepack pin on the workspace root.
2128-
if (
2129-
packageManager === 'pnpm' &&
2130-
resolveWorkspaceDeferral(root, pkg) === undefined &&
2131-
(!pkg.packageManager || typeof pkg.packageManager !== 'string')
2132-
) {
2133-
pkg.packageManager = 'pnpm@12.4.1';
2134-
}
21352162

21362163
writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n');
21372164
log.step('Updated package.json with starter scripts and dependencies');
@@ -2523,8 +2550,16 @@ export async function runInitCommand(root: string, initArgs?: InitArgs): Promise
25232550
scaffoldedPagesFramework = await promptScaffoldFramework();
25242551
}
25252552
const starterMode = await promptStarterMode();
2553+
const existingPackageJson = readPackageJson(root);
2554+
const hasPackageManager =
2555+
hasLocalLockfile(root) ||
2556+
existsSync(join(root, 'pnpm-workspace.yaml')) ||
2557+
parsePackageManagerField(existingPackageJson) !== undefined ||
2558+
resolveWorkspaceDeferral(root, existingPackageJson) !== undefined;
25262559
const scaffoldPackageManager =
2527-
scaffoldToolchain === 'vite-plus' ? 'pnpm' : preScaffoldState.packageManager;
2560+
scaffoldToolchain === 'vite-plus' && !hasPackageManager
2561+
? 'pnpm'
2562+
: preScaffoldState.packageManager;
25282563
const scaffoldWroteDeps = scaffoldPagesApp(
25292564
root,
25302565
scaffoldPackageManager,
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
2+
import { tmpdir } from 'node:os';
3+
import { join } from 'node:path';
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test';
5+
import { installProjectDependencies } from '../../src/cli/init.ts';
6+
7+
let root: string;
8+
let bin: string;
9+
let callsFile: string;
10+
11+
function executable(name: string, status = 0): void {
12+
const script = join(bin, `${name}.cjs`);
13+
writeFileSync(
14+
script,
15+
`const { appendFileSync } = require('node:fs');
16+
appendFileSync(${JSON.stringify(callsFile)}, JSON.stringify({ command: ${JSON.stringify(name)}, args: process.argv.slice(2), cwd: process.cwd() }) + '\\n');
17+
process.exitCode = process.argv.includes('--version') ? 0 : ${status};
18+
`,
19+
);
20+
if (process.platform === 'win32') {
21+
writeFileSync(join(bin, `${name}.cmd`), `@"${process.execPath}" "${script}" %*\r\n`);
22+
} else {
23+
writeFileSync(join(bin, name), `#!${process.execPath}\nrequire(${JSON.stringify(script)});\n`, {
24+
mode: 0o755,
25+
});
26+
}
27+
}
28+
29+
function calls(): Array<{ command: string; args: Array<string>; cwd: string }> {
30+
return readFileSync(callsFile, 'utf8')
31+
.trim()
32+
.split('\n')
33+
.map((line) => JSON.parse(line));
34+
}
35+
36+
beforeEach(() => {
37+
root = realpathSync(mkdtempSync(join(tmpdir(), 'void-init-install-')));
38+
bin = join(root, 'bin');
39+
callsFile = join(root, 'calls.jsonl');
40+
mkdirSync(bin);
41+
writeFileSync(
42+
join(root, 'package.json'),
43+
JSON.stringify({
44+
private: true,
45+
packageManager: 'pnpm@12.4.1',
46+
devDependencies: { 'vite-plus': '^0.1.21' },
47+
}),
48+
);
49+
// The host's tools must not mask a missing vp or supply a newer pnpm.
50+
vi.stubEnv('PATH', bin);
51+
});
52+
53+
afterEach(() => {
54+
vi.unstubAllEnvs();
55+
rmSync(root, { recursive: true, force: true });
56+
});
57+
58+
describe('init dependency installation (real subprocesses)', () => {
59+
it('uses Vite+ instead of delegating a pnpm 12 pin to an older global pnpm', () => {
60+
executable('vp');
61+
executable('pnpm', 1);
62+
63+
installProjectDependencies(root, 'pnpm');
64+
65+
expect(calls()).toEqual([
66+
{ command: 'vp', args: ['--version'], cwd: root },
67+
{ command: 'vp', args: ['install', '--no-frozen-lockfile'], cwd: root },
68+
]);
69+
expect(JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).packageManager).toBe(
70+
'pnpm@12.4.1',
71+
);
72+
});
73+
74+
it('bootstraps Vite+ when neither a local nor a global vp is installed', () => {
75+
executable('npx');
76+
executable('pnpm', 1);
77+
78+
installProjectDependencies(root, 'pnpm');
79+
80+
expect(calls()).toEqual([
81+
{
82+
command: 'npx',
83+
args: ['--yes', '--package=vite-plus@^0.1.21', 'vp', 'install', '--no-frozen-lockfile'],
84+
cwd: root,
85+
},
86+
]);
87+
});
88+
89+
it('reports a Vite+ install failure without retrying through another installer', () => {
90+
executable('vp', 7);
91+
executable('npx');
92+
executable('pnpm', 1);
93+
94+
expect(() => installProjectDependencies(root, 'pnpm')).toThrow(
95+
'vp install failed with exit code 7',
96+
);
97+
expect(calls()).toEqual([
98+
{ command: 'vp', args: ['--version'], cwd: root },
99+
{ command: 'vp', args: ['install', '--no-frozen-lockfile'], cwd: root },
100+
]);
101+
});
102+
103+
it('uses an installed project-local Vite+ without requiring tools on PATH', () => {
104+
const vitePlus = join(root, 'node_modules', 'vite-plus');
105+
mkdirSync(join(vitePlus, 'bin'), { recursive: true });
106+
writeFileSync(join(vitePlus, 'package.json'), JSON.stringify({ name: 'vite-plus' }));
107+
executable('local-vp');
108+
writeFileSync(join(vitePlus, 'bin', 'vp'), readFileSync(join(bin, 'local-vp.cjs'), 'utf8'));
109+
110+
installProjectDependencies(root, 'pnpm');
111+
112+
expect(calls()).toEqual([
113+
{ command: 'local-vp', args: ['install', '--no-frozen-lockfile'], cwd: root },
114+
]);
115+
});
116+
117+
it('keeps plain Vite projects on their selected package manager', () => {
118+
writeFileSync(join(root, 'package.json'), JSON.stringify({ private: true }));
119+
executable('vp', 1);
120+
executable('pnpm');
121+
122+
installProjectDependencies(root, 'pnpm');
123+
124+
expect(calls()).toEqual([
125+
{ command: 'pnpm', args: ['install', '--no-frozen-lockfile'], cwd: root },
126+
]);
127+
});
128+
});

0 commit comments

Comments
 (0)