diff --git a/CLAUDE.md b/CLAUDE.md index 783e4169d..75af158a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,7 +133,7 @@ See [`packages/solid/README.md`](./packages/solid/README.md) for details. | -------- | --------- | ---------------------------------------------------------------------------------------------- | | Node.js | 24.13.1 | Runtime (TS type stripping enabled natively — no `tsx` or `--experimental-strip-types` needed) | | Vite+ | 0.1.x | Unified toolchain (`vp`, Vite, Vitest, Oxlint, Oxfmt, tsdown) | -| pnpm | 10.x | Package manager used by Vite+ for this workspace | +| pnpm | 11.x | Package manager used by Vite+ for this workspace | | tsdown | via Vite+ | Library packaging through `vp pack` | | wrangler | 4.x | CF Worker dev/deploy | | vitest | via Vite+ | Unit + integration tests via `vp test` | diff --git a/package.json b/package.json index 62c41b68d..929004794 100644 --- a/package.json +++ b/package.json @@ -28,5 +28,5 @@ "vite-plus": "catalog:", "vitest": "catalog:" }, - "packageManager": "pnpm@10.33.2" + "packageManager": "pnpm@11.0.8" } diff --git a/packages/void/src/cli/init.ts b/packages/void/src/cli/init.ts index 06dfc7714..9b43728c6 100644 --- a/packages/void/src/cli/init.ts +++ b/packages/void/src/cli/init.ts @@ -115,7 +115,7 @@ const EMPTY_PROJECT_ALLOWED_ENTRIES = new Set([ '.zencoder', ]); -const PNPM_ONLY_BUILT_DEPENDENCIES = ['better-sqlite3', 'esbuild', 'workerd'] as const; +const PNPM_ALLOWED_BUILDS = ['better-sqlite3', 'esbuild', 'workerd'] as const; const drizzleKitBin = join(fileURLToPath(import.meta.resolve('drizzle-kit')), '..', 'bin.cjs'); const EMPTY_PROJECT_ALLOWED_PACKAGES = new Set([ @@ -1801,23 +1801,97 @@ function ensureGitignore(root: string): void { log.success('Updated .gitignore with Void defaults'); } -function ensurePnpmBuiltDependencies(pkg: JsonObject): void { - const pnpm = asRecord(pkg.pnpm) ?? {}; - pkg.pnpm = pnpm; - - const onlyBuiltDependencies = Array.isArray(pnpm.onlyBuiltDependencies) - ? pnpm.onlyBuiltDependencies.filter( - (value: unknown): value is string => typeof value === 'string', - ) - : []; +function removeTopLevelYamlBlock(yaml: string, key: string): string { + const lines = yaml.split(/\r?\n/); + const next: Array = []; + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + if (!new RegExp(`^${key}:\\s*(?:#.*)?$`).test(line)) { + next.push(line); + continue; + } - for (const dependency of PNPM_ONLY_BUILT_DEPENDENCIES) { - if (!onlyBuiltDependencies.includes(dependency)) { - onlyBuiltDependencies.push(dependency); + i += 1; + while (i < lines.length && (lines[i].trim() === '' || /^\s/.test(lines[i]))) { + i += 1; } + i -= 1; + } + return next.join('\n'); +} + +function pnpmAllowBuildsYaml(): string { + const lines = ['allowBuilds:']; + for (const dependency of PNPM_ALLOWED_BUILDS) { + lines.push(` ${dependency}: true`); + } + return lines.join('\n'); +} + +function insertPnpmAllowBuilds(yaml: string): string { + const trimmed = yaml.trim(); + const allowBuilds = pnpmAllowBuildsYaml(); + if (trimmed === '') { + return `${allowBuilds}\n`; + } + + const lines = trimmed.split(/\r?\n/); + const packagesIndex = lines.findIndex((line) => /^packages:\s*(?:#.*)?$/.test(line)); + if (packagesIndex === -1) { + return `${trimmed}\n\n${allowBuilds}\n`; + } + + let insertIndex = packagesIndex + 1; + while ( + insertIndex < lines.length && + (lines[insertIndex].trim() === '' || /^\s/.test(lines[insertIndex])) + ) { + insertIndex += 1; + } + lines.splice(insertIndex, 0, '', ...allowBuilds.split('\n'), ''); + return `${lines.join('\n').replace(/\n{3,}/g, '\n\n')}\n`; +} + +function resolvePnpmConfigRoot(root: string, pkg: JsonObject): string { + if ( + existsSync(join(root, 'pnpm-workspace.yaml')) || + existsSync(join(root, 'pnpm-lock.yaml')) || + parsePackageManagerField(pkg) === 'pnpm' + ) { + return root; } - pnpm.onlyBuiltDependencies = onlyBuiltDependencies; + const workspaceRoot = findWorkspaceRoot(root); + if ( + workspaceRoot !== undefined && + isWorkspaceSubpackage(workspaceRoot, root) && + hasAncestorPnpmMarkers(root) + ) { + return workspaceRoot; + } + + return root; +} + +function ensurePnpmAllowBuilds(root: string, pkg: JsonObject): boolean { + const configRoot = resolvePnpmConfigRoot(root, pkg); + const workspacePath = join(configRoot, 'pnpm-workspace.yaml'); + const before = existsSync(workspacePath) ? readFileSync(workspacePath, 'utf-8') : ''; + const withoutLegacy = removeTopLevelYamlBlock( + removeTopLevelYamlBlock(before, 'onlyBuiltDependencies'), + 'allowBuilds', + ); + const after = insertPnpmAllowBuilds(withoutLegacy); + if (after !== before) { + writeFileSync(workspacePath, after); + log.success( + configRoot === root + ? 'Updated pnpm-workspace.yaml with approved builds' + : `Updated ${relative(root, workspacePath)} with approved builds`, + ); + return true; + } + return false; } function installProjectDependencies( @@ -2065,16 +2139,16 @@ function scaffoldPagesApp( ensureDependency('devDependencies', name, version); } - // Round 44: `ensurePnpmBuiltDependencies` must run for pnpm workspace + // Round 44: the pnpm build approval setup must run for workspace // subpackages too. `detectProjectState` only checks LOCAL markers and // returns `'npm'` for a subpackage without its own lockfile / pin, // but `isPnpmProject(root, pkg)` walks ancestors and (after round 43) // verifies membership. Gate the allowlist on the ancestor-aware check - // so the subpackage gets `pnpm.onlyBuiltDependencies` and its + // so the workspace gets `allowBuilds` and its // better-sqlite3 / esbuild / workerd postinstalls run on the next // workspace-root `pnpm install`. if (isPnpmProject(root, pkg)) { - ensurePnpmBuiltDependencies(pkg); + ensurePnpmAllowBuilds(root, pkg); } // The Corepack pin is ONLY for the standalone pnpm case. In a // workspace subpackage the pin lives on the workspace root's @@ -2084,7 +2158,7 @@ function scaffoldPagesApp( packageManager === 'pnpm' && (!pkg.packageManager || typeof pkg.packageManager !== 'string') ) { - pkg.packageManager = 'pnpm@10.33.0'; + pkg.packageManager = 'pnpm@11.0.8'; } writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n'); @@ -2188,14 +2262,7 @@ function ensureExistingProjectPackage( dependencyAdded = true; } - if (isPnpmProject(root, pkg)) { - const before = JSON.stringify(asRecord(pkg.pnpm)?.onlyBuiltDependencies ?? null); - ensurePnpmBuiltDependencies(pkg); - const after = JSON.stringify(asRecord(pkg.pnpm)?.onlyBuiltDependencies ?? null); - if (before !== after) { - changed = true; - } - } + const pnpmConfigChanged = isPnpmProject(root, pkg) ? ensurePnpmAllowBuilds(root, pkg) : false; if (changed) { writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n'); @@ -2210,7 +2277,7 @@ function ensureExistingProjectPackage( ); } - return { dependencyAdded, packageJsonMutated: changed }; + return { dependencyAdded, packageJsonMutated: changed || pnpmConfigChanged }; } function insertVoidImport(code: string): string { @@ -2855,9 +2922,8 @@ export function ensureVoidDependency(root: string): boolean { changed = true; } - // Mirror `scaffoldPagesApp`'s pnpm built-dep allowlist. pnpm 10 ignores - // build scripts unless the package is listed in - // `pnpm.onlyBuiltDependencies`; without this, the freshly scaffolded + // Mirror `scaffoldPagesApp`'s pnpm build approval config. pnpm requires + // build scripts to be approved through `allowBuilds`; without this, the freshly scaffolded // demo breaks on first local DB / dev commands because // `better-sqlite3`/`esbuild`/`workerd` never run their postinstall // build steps. Honor both pnpm signals via `isPnpmProject` — a committed @@ -2866,14 +2932,7 @@ export function ensureVoidDependency(root: string): boolean { // `detectProjectState` and `runInitCommand` already treat as pnpm; the // demo would then run `pnpm install` against the project but skip the // allowlist, leaving the local DB / dev setup broken on first run. - if (isPnpmProject(root, pkg)) { - const before = JSON.stringify(asRecord(pkg.pnpm)?.onlyBuiltDependencies ?? null); - ensurePnpmBuiltDependencies(pkg); - const after = JSON.stringify(asRecord(pkg.pnpm)?.onlyBuiltDependencies ?? null); - if (before !== after) { - changed = true; - } - } + const pnpmConfigChanged = isPnpmProject(root, pkg) ? ensurePnpmAllowBuilds(root, pkg) : false; if (changed) { writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n'); @@ -2884,7 +2943,7 @@ export function ensureVoidDependency(root: string): boolean { ); } - return changed; + return changed || pnpmConfigChanged; } interface AgentSetupSelection { diff --git a/packages/void/src/migrations/runner.ts b/packages/void/src/migrations/runner.ts index bd4ae1c2e..6218c3be6 100644 --- a/packages/void/src/migrations/runner.ts +++ b/packages/void/src/migrations/runner.ts @@ -34,7 +34,7 @@ function toBetterSqlite3Error(error: unknown): Error { return new Error( 'db: better-sqlite3 is installed but its native binding was not built. ' + - "Add it to your 'onlyBuiltDependencies' and run `pnpm rebuild better-sqlite3`. See https://pnpm.io/settings#onlybuiltdependencies", + 'Set `allowBuilds.better-sqlite3` to `true` in pnpm-workspace.yaml and run `pnpm rebuild better-sqlite3`. See https://pnpm.io/settings#allowbuilds', ); } diff --git a/packages/void/test/unit/init.test.ts b/packages/void/test/unit/init.test.ts index eb4ec613b..909e1dc4c 100644 --- a/packages/void/test/unit/init.test.ts +++ b/packages/void/test/unit/init.test.ts @@ -35,6 +35,15 @@ const mockIsStagingMode = vi.fn(); const mockListProjects = vi.fn(); const mockCreateProject = vi.fn(); +function expectPnpmAllowBuilds(root: string): void { + const yaml = readFileSync(join(root, 'pnpm-workspace.yaml'), 'utf-8'); + expect(yaml).toContain('allowBuilds:\n'); + expect(yaml).toContain(' better-sqlite3: true'); + expect(yaml).toContain(' esbuild: true'); + expect(yaml).toContain(' workerd: true'); + expect(yaml).not.toContain('onlyBuiltDependencies:'); +} + vi.mock('@clack/prompts', () => ({ intro: vi.fn(), outro: (...args: Array) => mockOutro(...args), @@ -1815,9 +1824,7 @@ export default config; expect(pkg.devDependencies['@rolldown/plugin-babel']).toBe('^0.2.3'); expect(pkg.devDependencies['@vitejs/plugin-react']).toBe('^6.0.1'); expect(pkg.devDependencies['babel-plugin-react-compiler']).toBe('^1.0.0'); - expect(pkg.pnpm.onlyBuiltDependencies).toEqual( - expect.arrayContaining(['better-sqlite3', 'esbuild', 'workerd']), - ); + expectPnpmAllowBuilds(root); const reactViteConfig = readFileSync(join(root, 'vite.config.ts'), 'utf-8'); expect(reactViteConfig).toContain('from "@rolldown/plugin-babel"'); @@ -3858,14 +3865,14 @@ export default config; }); // Round 44 regression: `scaffoldPagesApp` previously only wrote - // `pnpm.onlyBuiltDependencies` when `packageManager === 'pnpm'`. But + // pnpm build approvals when `packageManager === 'pnpm'`. But // for a workspace subpackage with no local lockfile / Corepack pin, // `detectProjectState` returns `'npm'` — the pnpm branch never fires // and the subpackage's built-deps allowlist is silently dropped, // which suppresses better-sqlite3 / esbuild / workerd postinstalls on // the next workspace-root `pnpm install`. Gate the allowlist on the // ancestor-aware `isPnpmProject` check instead. - it('writes pnpm.onlyBuiltDependencies for a pnpm workspace subpackage (Round 44)', async () => { + it('writes pnpm allowBuilds for a pnpm workspace subpackage (Round 44)', async () => { const wsroot = mkdtempSync(join(tmpdir(), 'void-init-')); try { // Workspace root: pnpm-workspace.yaml with `packages/*`. The root @@ -3874,7 +3881,7 @@ export default config; writeFileSync( join(wsroot, 'package.json'), JSON.stringify( - { name: 'workspace-root', private: true, packageManager: 'pnpm@10.33.0' }, + { name: 'workspace-root', private: true, packageManager: 'pnpm@11.0.8' }, null, 2, ) + '\n', @@ -3895,11 +3902,11 @@ export default config; const pkg = JSON.parse(readFileSync(join(subpkgRoot, 'package.json'), 'utf-8')); - // The built-deps allowlist MUST be present — this is the - // regression target. Mirrors the standalone-pnpm assertion above. - expect(pkg.pnpm?.onlyBuiltDependencies).toEqual( - expect.arrayContaining(['better-sqlite3', 'esbuild', 'workerd']), - ); + // The built-deps allowlist MUST be present in the workspace + // config — this is the regression target. Mirrors the + // standalone-pnpm assertion above. + expectPnpmAllowBuilds(wsroot); + expect(pkg.pnpm).toBeUndefined(); } finally { rmSync(wsroot, { recursive: true, force: true }); } @@ -3916,7 +3923,7 @@ export default config; writeFileSync( join(wsroot, 'package.json'), JSON.stringify( - { name: 'workspace-root', private: true, packageManager: 'pnpm@10.33.0' }, + { name: 'workspace-root', private: true, packageManager: 'pnpm@11.0.8' }, null, 2, ) + '\n', @@ -3940,7 +3947,7 @@ export default config; } }); - it('writes BOTH pnpm.onlyBuiltDependencies AND packageManager pin in a standalone pnpm project (Round 44)', async () => { + it('writes BOTH pnpm allowBuilds AND packageManager pin in a standalone pnpm project (Round 44)', async () => { // Symmetric proof that the standalone-pnpm path still writes both // the allowlist AND the Corepack pin, after the Round 44 split. const root = mkdtempSync(join(tmpdir(), 'void-init-')); @@ -3957,10 +3964,8 @@ export default config; await runInitCommand(root); const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')); - expect(pkg.pnpm?.onlyBuiltDependencies).toEqual( - expect.arrayContaining(['better-sqlite3', 'esbuild', 'workerd']), - ); - expect(pkg.packageManager).toBe('pnpm@10.33.0'); + expectPnpmAllowBuilds(root); + expect(pkg.packageManager).toBe('pnpm@11.0.8'); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/playground/ssr/blog/getting-started.md b/playground/ssr/blog/getting-started.md index 81d07f25e..dc007dbd7 100644 --- a/playground/ssr/blog/getting-started.md +++ b/playground/ssr/blog/getting-started.md @@ -5,7 +5,7 @@ This guide walks you through setting up a Void project with SSR and blog support ## Prerequisites - Node.js 22+ -- pnpm 10+ +- pnpm 11+ ## Steps diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5340a4906..050624f74 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,18 @@ packages: - playground/* - playground-local/* +allowBuilds: + '@parcel/watcher': false + '@prisma/engines': false + better-sqlite3: true + esbuild: true + lmdb: false + msgpackr-extract: false + prisma: false + sharp: false + vue-demi: false + workerd: true + catalog: '@clack/prompts': ^1.3.0 '@cloudflare/workers-types': ^4.20260508.1 @@ -15,11 +27,6 @@ catalog: vitest: npm:@voidzero-dev/vite-plus-test@0.1.20 wrangler: ^4.90.0 -onlyBuiltDependencies: - - better-sqlite3 - - esbuild - - workerd - overrides: esbuild: ^0.27.3 vite: 'catalog:'