diff --git a/.changeset/pnpm-11-allow-builds.md b/.changeset/pnpm-11-allow-builds.md new file mode 100644 index 000000000..2113b959b --- /dev/null +++ b/.changeset/pnpm-11-allow-builds.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/sv-utils': patch +--- + +handle `pnpm@11`: add `pnpm.allowBuilds` helper that auto-detects the installed pnpm version and writes to `allowBuilds` (pnpm 11+) or the legacy `onlyBuiltDependencies` list (pnpm 10). Deprecate `pnpm.onlyBuiltDependencies` diff --git a/documentation/docs/50-api/20-sv-utils.md b/documentation/docs/50-api/20-sv-utils.md index 3e5a543cf..062863068 100644 --- a/documentation/docs/50-api/20-sv-utils.md +++ b/documentation/docs/50-api/20-sv-utils.md @@ -232,15 +232,20 @@ Namespaced helpers for AST manipulation: ## Package manager helpers -### `pnpm.onlyBuiltDependencies` +### `pnpm.allowBuilds` -Returns a transform for `pnpm-workspace.yaml` that adds packages to the `onlyBuiltDependencies` list. Use with `sv.file` when the project uses pnpm. +Returns a transform for `pnpm-workspace.yaml` that adds packages to the pnpm "allow builds" config. Use with `sv.file` when the project uses pnpm. + +The helper detects the installed pnpm version via `pnpm --version`: + +- pnpm `>= 11`: writes to the unified `allowBuilds` map (`{ pkg: true }`), migrating any legacy `onlyBuiltDependencies` list into the map. +- pnpm `< 11`: writes to the legacy `onlyBuiltDependencies` list. ```js // @noErrors import { pnpm } from '@sveltejs/sv-utils'; if (packageManager === 'pnpm') { - sv.file(file.findUp('pnpm-workspace.yaml'), pnpm.onlyBuiltDependencies('my-native-dep')); + sv.file(file.findUp('pnpm-workspace.yaml'), pnpm.allowBuilds('my-native-dep')); } ``` diff --git a/packages/sv-utils/api-surface.md b/packages/sv-utils/api-surface.md index 0e105cdda..317b5c1cf 100644 --- a/packages/sv-utils/api-surface.md +++ b/packages/sv-utils/api-surface.md @@ -717,9 +717,13 @@ declare const transforms: { text(cb: (file: { content: string; text: typeof text_d_exports }) => string | false): TransformFn; }; declare namespace pnpm_d_exports { - export { onlyBuiltDependencies }; + export { allowBuilds, onlyBuiltDependencies }; } +declare function allowBuilds(...packages: string[]): TransformFn; +/** + * @deprecated Use {@link allowBuilds} instead. + */ declare function onlyBuiltDependencies(...packages: string[]): TransformFn; type Version = { major?: number; diff --git a/packages/sv-utils/src/pnpm-internals.ts b/packages/sv-utils/src/pnpm-internals.ts new file mode 100644 index 000000000..a36f3697c --- /dev/null +++ b/packages/sv-utils/src/pnpm-internals.ts @@ -0,0 +1,14 @@ +import { execSync } from 'node:child_process'; +import { coerceVersion } from './semver.ts'; + +export function detectPnpmMajor(): number | undefined { + try { + const out = execSync('pnpm --version', { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'] + }); + return coerceVersion(out.trim()).major; + } catch { + return undefined; + } +} diff --git a/packages/sv-utils/src/pnpm.ts b/packages/sv-utils/src/pnpm.ts index c7979e8f3..def7c636b 100644 --- a/packages/sv-utils/src/pnpm.ts +++ b/packages/sv-utils/src/pnpm.ts @@ -1,20 +1,72 @@ +import { detectPnpmMajor } from './pnpm-internals.ts'; import { transforms, type TransformFn } from './tooling/transforms.ts'; +type YamlMap = { + get(key: string): unknown; + set(key: string, value: unknown): void; + has(key: string): boolean; +}; + +type YamlSeq = { items?: Array<{ value: string } | string> }; + +type YamlDoc = { + get(key: string): unknown; + set(key: string, value: unknown): void; + has(key: string): boolean; + delete(key: string): boolean; + createNode(value: unknown, options?: { flow?: boolean }): unknown; +}; + /** - * Returns a TransformFn for `pnpm-workspace.yaml` that adds packages to `onlyBuiltDependencies`. + * Returns a TransformFn for `pnpm-workspace.yaml` that adds packages to the + * pnpm "allow builds" config. + * + * The helper detects the installed pnpm version (via `pnpm --version`) and: + * - on pnpm `>= 11` writes to the unified `allowBuilds` map (`{ pkg: true }`), + * migrating any legacy `onlyBuiltDependencies` list into the map; + * - on pnpm `< 11` writes to the legacy `onlyBuiltDependencies` list. * - * Use with `sv.file`: * ```ts * if (packageManager === 'pnpm') { - * sv.file(file.findUp('pnpm-workspace.yaml'), pnpm.onlyBuiltDependencies('my-native-dep')); + * sv.file(file.findUp('pnpm-workspace.yaml'), pnpm.allowBuilds('my-native-dep')); * } * ``` */ -export function onlyBuiltDependencies(...packages: string[]): TransformFn { +export function allowBuilds(...packages: string[]): TransformFn { + const major = detectPnpmMajor(); + if (major !== undefined && major < 11) return writeLegacy(packages); + return writeAllowBuilds(packages); +} + +function writeAllowBuilds(packages: string[]): TransformFn { return transforms.yaml(({ data }) => { - const existing = data.get('onlyBuiltDependencies') as - | { items?: Array<{ value: string } | string> } - | undefined; + const doc = data as unknown as YamlDoc; + + const toMigrate: string[] = []; + const legacy = doc.get('onlyBuiltDependencies') as YamlSeq | undefined; + if (legacy?.items) { + for (const item of legacy.items) { + toMigrate.push(typeof item === 'object' ? item.value : item); + } + } + + let map = doc.get('allowBuilds') as YamlMap | undefined; + if (!map || typeof map.set !== 'function') { + map = doc.createNode({}, { flow: false }) as YamlMap; + doc.set('allowBuilds', map); + } + + for (const pkg of [...toMigrate, ...packages]) { + if (!map.has(pkg)) map.set(pkg, true); + } + + if (legacy) doc.delete('onlyBuiltDependencies'); + }); +} + +function writeLegacy(packages: string[]): TransformFn { + return transforms.yaml(({ data }) => { + const existing = data.get('onlyBuiltDependencies') as YamlSeq | undefined; const items: Array<{ value: string } | string> = existing?.items ?? []; for (const pkg of packages) { if (items.includes(pkg)) continue; @@ -24,3 +76,10 @@ export function onlyBuiltDependencies(...packages: string[]): TransformFn { data.set('onlyBuiltDependencies', items); }); } + +/** + * @deprecated Use {@link allowBuilds} instead. + */ +export function onlyBuiltDependencies(...packages: string[]): TransformFn { + return allowBuilds(...packages); +} diff --git a/packages/sv-utils/src/tests/pnpm.ts b/packages/sv-utils/src/tests/pnpm.ts new file mode 100644 index 000000000..7b803a164 --- /dev/null +++ b/packages/sv-utils/src/tests/pnpm.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { detectPnpmMajor } from '../pnpm-internals.ts'; +import { allowBuilds, onlyBuiltDependencies } from '../pnpm.ts'; + +const major = detectPnpmMajor(); +const isPnpm11 = major === undefined || major >= 11; + +describe.runIf(isPnpm11)('allowBuilds (pnpm >= 11: writes allowBuilds map)', () => { + it('creates allowBuilds map in empty file', () => { + expect(allowBuilds('esbuild')('')).toBe('allowBuilds:\n esbuild: true\n'); + }); + + it('appends to existing allowBuilds map', () => { + const input = `packages: + - 'packages/*' +allowBuilds: + bar: true +`; + expect(allowBuilds('esbuild')(input)).toBe(`packages: + - 'packages/*' +allowBuilds: + bar: true + esbuild: true +`); + }); + + it('preserves false entries when adding new packages', () => { + const input = `allowBuilds: + core-js: false +`; + expect(allowBuilds('esbuild')(input)).toBe(`allowBuilds: + core-js: false + esbuild: true +`); + }); + + it('migrates legacy onlyBuiltDependencies to allowBuilds', () => { + const input = `packages: + - 'packages/*' +onlyBuiltDependencies: + - foo + - bar +`; + expect(allowBuilds('esbuild')(input)).toBe(`packages: + - 'packages/*' +allowBuilds: + foo: true + bar: true + esbuild: true +`); + }); + + it('merges legacy and existing allowBuilds without duplicating', () => { + const input = `onlyBuiltDependencies: + - shared +allowBuilds: + shared: false +`; + expect(allowBuilds('newone')(input)).toBe(`allowBuilds: + shared: false + newone: true +`); + }); + + it('is idempotent when package already present', () => { + const input = `allowBuilds: + esbuild: true +`; + expect(allowBuilds('esbuild')(input)).toBe(input); + }); + + it('deprecated onlyBuiltDependencies delegates to allowBuilds', () => { + expect(onlyBuiltDependencies('esbuild')('')).toBe('allowBuilds:\n esbuild: true\n'); + }); +}); + +describe.runIf(!isPnpm11)('allowBuilds (pnpm < 11: writes onlyBuiltDependencies list)', () => { + it('creates onlyBuiltDependencies list in empty file', () => { + expect(allowBuilds('esbuild')('')).toBe('onlyBuiltDependencies:\n - esbuild\n'); + }); + + it('appends to existing onlyBuiltDependencies list', () => { + const input = `onlyBuiltDependencies: + - foo +`; + expect(allowBuilds('esbuild')(input)).toBe(`onlyBuiltDependencies: + - foo + - esbuild +`); + }); + + it('is idempotent on legacy list', () => { + const input = `onlyBuiltDependencies: + - esbuild +`; + expect(allowBuilds('esbuild')(input)).toBe(input); + }); + + it('deprecated onlyBuiltDependencies delegates to allowBuilds', () => { + expect(onlyBuiltDependencies('esbuild')('')).toBe('onlyBuiltDependencies:\n - esbuild\n'); + }); +}); diff --git a/packages/sv/src/addons/drizzle.ts b/packages/sv/src/addons/drizzle.ts index 6c1adabbb..de03f8885 100644 --- a/packages/sv/src/addons/drizzle.ts +++ b/packages/sv/src/addons/drizzle.ts @@ -136,7 +136,7 @@ export default defineAddon({ sv.dependency('better-sqlite3', '^12.8.0'); sv.devDependency('@types/better-sqlite3', '^7.6.13'); if (packageManager === 'pnpm') { - sv.file(file.findUp('pnpm-workspace.yaml'), pnpm.onlyBuiltDependencies('better-sqlite3')); + sv.file(file.findUp('pnpm-workspace.yaml'), pnpm.allowBuilds('better-sqlite3')); } } diff --git a/packages/sv/src/addons/tailwindcss.ts b/packages/sv/src/addons/tailwindcss.ts index 6c9d59993..3545b95bd 100644 --- a/packages/sv/src/addons/tailwindcss.ts +++ b/packages/sv/src/addons/tailwindcss.ts @@ -36,7 +36,7 @@ export default defineAddon({ sv.devDependency('tailwindcss', '^4.2.2'); sv.devDependency('@tailwindcss/vite', '^4.2.2'); if (packageManager === 'pnpm') { - sv.file(file.findUp('pnpm-workspace.yaml'), pnpm.onlyBuiltDependencies('@tailwindcss/oxide')); + sv.file(file.findUp('pnpm-workspace.yaml'), pnpm.allowBuilds('@tailwindcss/oxide')); } if (prettierInstalled) sv.devDependency('prettier-plugin-tailwindcss', '^0.7.2'); diff --git a/packages/sv/src/cli/add.ts b/packages/sv/src/cli/add.ts index 5f967f901..a717ffaf7 100644 --- a/packages/sv/src/cli/add.ts +++ b/packages/sv/src/cli/add.ts @@ -23,7 +23,7 @@ import { downloadPackage, getPackageJSON } from '../core/fetch-packages.ts'; import { formatFiles } from '../core/formatFiles.ts'; import { AGENT_NAMES, - addPnpmOnlyBuiltDependencies, + addPnpmAllowBuilds, installDependencies, installOption, packageManagerPrompt @@ -712,7 +712,7 @@ export async function runAddonsApply({ ? await packageManagerPrompt(options.cwd) : options.install; - addPnpmOnlyBuiltDependencies(workspace.cwd, packageManager, 'esbuild'); + addPnpmAllowBuilds(workspace.cwd, packageManager, 'esbuild'); const argsFormattedAddons: string[] = []; for (const loaded of successfulAddons) { diff --git a/packages/sv/src/cli/create.ts b/packages/sv/src/cli/create.ts index 5f28b9aa5..f2851b506 100644 --- a/packages/sv/src/cli/create.ts +++ b/packages/sv/src/cli/create.ts @@ -10,7 +10,7 @@ import type { LoadedAddon, OptionValues, SetupResult } from '../core/config.ts'; import { formatFiles } from '../core/formatFiles.ts'; import { AGENT_NAMES, - addPnpmOnlyBuiltDependencies, + addPnpmAllowBuilds, detectPackageManager, installDependencies, installOption, @@ -396,7 +396,7 @@ async function createProject(cwd: ProjectPath, options: Options) { } const addOnNextSteps = getNextSteps(addOnSuccessfulAddons, workspace, answers, addonSetupResults); - addPnpmOnlyBuiltDependencies(projectPath, packageManager, 'esbuild'); + addPnpmAllowBuilds(projectPath, packageManager, 'esbuild'); if (packageManager) { await installDependencies(packageManager, projectPath); await formatFiles({ packageManager, cwd: projectPath, filesToFormat: addOnFilesToFormat }); diff --git a/packages/sv/src/cli/tests/cli.ts b/packages/sv/src/cli/tests/cli.ts index 507f6d0d2..1a671bb8b 100644 --- a/packages/sv/src/cli/tests/cli.ts +++ b/packages/sv/src/cli/tests/cli.ts @@ -144,7 +144,7 @@ describe('cli', () => { const { data: packageJson } = parse.json(fs.readFileSync(packageJsonPath, 'utf-8')); packageJson.peerDependencies['sv'] = 'file:../../../..'; packageJson.devDependencies['sv'] = 'file:../../../..'; - packageJson.devDependencies['@sveltejs/sv-utils'] = 'file:../../../../sv-utils'; + packageJson.devDependencies['@sveltejs/sv-utils'] = 'file:../../../../../sv-utils'; fs.writeFileSync( packageJsonPath, JSON.stringify(packageJson, null, 3).replaceAll(' ', '\t') @@ -158,8 +158,18 @@ describe('cli', () => { ['run', 'test'] ]; for (const cmd of cmds) { - const res = await exec('pnpm', cmd, { - nodeOptions: { stdio: 'pipe', cwd: testOutputPath } + // use npm here so the install doesn't walk up into the monorepo's + // pnpm workspace and try to resolve packages from there + const res = await exec('npm', cmd, { + nodeOptions: { + stdio: 'pipe', + cwd: testOutputPath, + env: { + ...process.env, + // allow npm under a repo whose packageManager is pnpm + COREPACK_ENABLE_STRICT: '0' + } + } }); expect( res.exitCode, diff --git a/packages/sv/src/core/config.ts b/packages/sv/src/core/config.ts index f1467f09f..0987e1aaf 100644 --- a/packages/sv/src/core/config.ts +++ b/packages/sv/src/core/config.ts @@ -7,7 +7,7 @@ export type { OptionValues } from './options.ts'; export type ConditionDefinition = (Workspace: Workspace) => boolean; export type SvApi = { - /** @deprecated use `pnpm.onlyBuiltDependencies` from `@sveltejs/sv-utils` instead */ + /** @deprecated use `pnpm.allowBuilds` from `@sveltejs/sv-utils` instead */ pnpmBuildDependency: (pkg: string) => void; /** Add a package to the dependencies. */ dependency: (pkg: string, version: string) => void; diff --git a/packages/sv/src/core/engine.ts b/packages/sv/src/core/engine.ts index ecd19cd47..490c244ad 100644 --- a/packages/sv/src/core/engine.ts +++ b/packages/sv/src/core/engine.ts @@ -23,7 +23,7 @@ import { } from './config.ts'; import { svDeprecated } from './deprecated.ts'; import { TESTING } from './env.ts'; -import { addPnpmOnlyBuiltDependencies } from './package-manager.ts'; +import { addPnpmAllowBuilds } from './package-manager.ts'; import { createWorkspace, type Workspace } from './workspace.ts'; function alphabetizeRecord(obj: Record) { @@ -257,12 +257,12 @@ async function runAddon({ addon, loaded, multiple, workspace, workspaceOptions } devDependency: (pkg, version) => { dependencies.push({ pkg, version, dev: true }); }, - /** @deprecated use `pnpm.onlyBuiltDependencies` from `@sveltejs/sv-utils` instead */ + /** @deprecated use `pnpm.allowBuilds` from `@sveltejs/sv-utils` instead */ pnpmBuildDependency: (pkg) => { svDeprecated( - 'use `pnpm.onlyBuiltDependencies` from `@sveltejs/sv-utils` instead of `sv.pnpmBuildDependency`' + 'use `pnpm.allowBuilds` from `@sveltejs/sv-utils` instead of `sv.pnpmBuildDependency`' ); - addPnpmOnlyBuiltDependencies(workspace.cwd, workspace.packageManager, pkg); + addPnpmAllowBuilds(workspace.cwd, workspace.packageManager, pkg); } }; diff --git a/packages/sv/src/core/package-manager.ts b/packages/sv/src/core/package-manager.ts index 5154a3ceb..32c7cf557 100644 --- a/packages/sv/src/core/package-manager.ts +++ b/packages/sv/src/core/package-manager.ts @@ -92,7 +92,7 @@ export function getUserAgent(): AgentName | undefined { return AGENTS.includes(name) ? name : undefined; } -export function addPnpmOnlyBuiltDependencies( +export function addPnpmAllowBuilds( cwd: string, packageManager: AgentName | null | undefined, ...packages: string[] @@ -102,6 +102,6 @@ export function addPnpmOnlyBuiltDependencies( const found = find.up('pnpm-workspace.yaml', { cwd }); const filePath = found ?? path.join(cwd, 'pnpm-workspace.yaml'); const content = found ? fs.readFileSync(found, 'utf-8') : ''; - const newContent = pnpm.onlyBuiltDependencies(...packages)(content); + const newContent = pnpm.allowBuilds(...packages)(content); if (newContent !== content) fs.writeFileSync(filePath, newContent, 'utf-8'); } diff --git a/packages/sv/src/testing.ts b/packages/sv/src/testing.ts index 1075f4496..13cca0535 100644 --- a/packages/sv/src/testing.ts +++ b/packages/sv/src/testing.ts @@ -7,7 +7,7 @@ import pstree, { type PS } from 'ps-tree'; import { exec, x } from 'tinyexec'; import type { TestProject } from 'vitest/node'; import { add, type AddonMap, type OptionMap } from './core/engine.ts'; -import { addPnpmOnlyBuiltDependencies } from './core/package-manager.ts'; +import { addPnpmAllowBuilds } from './core/package-manager.ts'; import { create } from './create/index.ts'; export type ProjectVariant = 'kit-js' | 'kit-ts' | 'vite-js' | 'vite-ts'; @@ -343,7 +343,7 @@ export function createSetupTest( options: kind.options, packageManager: 'pnpm' }); - addPnpmOnlyBuiltDependencies(cwd, 'pnpm', 'esbuild'); + addPnpmAllowBuilds(cwd, 'pnpm', 'esbuild'); } execSync('pnpm install', { cwd: path.resolve(cwd, testName), stdio: 'pipe' });