diff --git a/.changeset/storybook-pnpm-allow-build.md b/.changeset/storybook-pnpm-allow-build.md new file mode 100644 index 00000000..12b42548 --- /dev/null +++ b/.changeset/storybook-pnpm-allow-build.md @@ -0,0 +1,5 @@ +--- +'@tanstack/create': patch +--- + +Add command-specific build approvals for Storybook's esbuild installation on pnpm 10.2 and newer. Older and unknown target versions retain the compatible plain dlx command. Add-on and starter command definitions can declare requested build approvals, with version-aware planning shared by create and add-to-existing-app flows. diff --git a/packages/create/src/add-to-app.ts b/packages/create/src/add-to-app.ts index d4af98e2..01f08fc7 100644 --- a/packages/create/src/add-to-app.ts +++ b/packages/create/src/add-to-app.ts @@ -144,9 +144,9 @@ function assertNoExclusiveAddOnConflicts(addOns: Array) { } } -async function runCreateApp(options: Required) { +async function runCreateApp(options: Required, packageManagerVersion?: string) { const { environment, output } = createMemoryEnvironment(options.targetDir) - await createApp(environment, options, { existingProject: true }) + await createApp(environment, options, { existingProject: true, packageManagerVersion }) return output } @@ -315,10 +315,11 @@ export async function runNewCommands( args: Array }> }, + packageManagerVersion?: string, ) { const originalOutput = await runCreateApp({ ...(await createOptions(originalOptions, [], cwd)), - } as Required) + } as Required, packageManagerVersion) const originalCommands = new Set( originalOutput.commands.map((c) => [c.command, ...c.args].join(' ')), @@ -370,11 +371,15 @@ export async function addToApp( }) const newOptions = await createOptions(persistedOptions, addOns, cwd) + const needsBuildApproval = newOptions.chosenAddOns.some((addOn) => addOn.command?.allowBuilds?.length) || newOptions.starter?.command?.allowBuilds?.length + const packageManagerVersion = newOptions.packageManager === 'pnpm' && needsBuildApproval + ? await environment.getPackageManagerVersion?.('pnpm', cwd) + : undefined const output = await runCreateApp({ ...newOptions, targetDir: cwd, - } as Required) + } as Required, packageManagerVersion) await writeFiles(environment, cwd, output, !!options?.forced) @@ -435,7 +440,7 @@ export async function addToApp( // Handle new commands - await runNewCommands(environment, persistedOptions, cwd, output) + await runNewCommands(environment, persistedOptions, cwd, output, packageManagerVersion) const intent = options?.intent ?? persistedOptions.intent ?? true newOptions.intent = intent diff --git a/packages/create/src/create-app.ts b/packages/create/src/create-app.ts index 1a7905c2..3e8edc9e 100644 --- a/packages/create/src/create-app.ts +++ b/packages/create/src/create-app.ts @@ -167,7 +167,22 @@ async function writeFiles( async function runCommandsAndInstallDependencies( environment: Environment, options: Options, + packageManagerVersion?: string, ) { + const needsBuildApproval = + options.chosenAddOns.some((addOn) => addOn.command?.allowBuilds?.length) || + options.starter?.command?.allowBuilds?.length + if ( + options.packageManager === 'pnpm' && + needsBuildApproval && + packageManagerVersion === undefined + ) { + packageManagerVersion = await environment.getPackageManagerVersion?.( + 'pnpm', + options.targetDir, + ) + } + const s = environment.spinner() // Setup git @@ -249,7 +264,8 @@ async function runCommandsAndInstallDependencies( const translated = translateExecuteCommand(options.packageManager, { command: addOn.command!.command, args: addOn.command!.args || [], - }) + allowBuilds: addOn.command!.allowBuilds, + }, packageManagerVersion) const cmd = formatCommand(translated) environment.startStep({ id: 'run-commands', @@ -277,7 +293,8 @@ async function runCommandsAndInstallDependencies( const starterTranslated = translateExecuteCommand(options.packageManager, { command: options.starter.command.command, args: options.starter.command.args || [], - }) + allowBuilds: options.starter.command.allowBuilds, + }, packageManagerVersion) const cmd = formatCommand(starterTranslated) environment.startStep({ id: 'run-starter-command', @@ -494,7 +511,7 @@ Please read the README.md file for information on ${readmeDescription}${errorSta export async function createApp( environment: Environment, options: Options, - context: { existingProject?: boolean } = {}, + context: { existingProject?: boolean; packageManagerVersion?: string } = {}, ) { validatePackageManagerSupport(options.packageManager, options.chosenAddOns) const effectiveOptions = stripExamplesFromOptions(normalizeAndValidateBundlerOptions(options)) @@ -503,7 +520,7 @@ export async function createApp( await writeFiles(environment, effectiveOptions, context) await seedEnvValues(environment, effectiveOptions) await writeEnvExample(environment, effectiveOptions) - await runCommandsAndInstallDependencies(environment, effectiveOptions) + await runCommandsAndInstallDependencies(environment, effectiveOptions, context.packageManagerVersion) environment.finishRun() report(environment, effectiveOptions) diff --git a/packages/create/src/edge-create-app.ts b/packages/create/src/edge-create-app.ts index cc6d6ff2..b304d557 100644 --- a/packages/create/src/edge-create-app.ts +++ b/packages/create/src/edge-create-app.ts @@ -292,7 +292,22 @@ async function setupIntent( async function runCommandsAndInstallDependencies( environment: Environment, options: Options, + packageManagerVersion?: string, ) { + const needsBuildApproval = + options.chosenAddOns.some((addOn) => addOn.command?.allowBuilds?.length) || + options.starter?.command?.allowBuilds?.length + if ( + options.packageManager === 'pnpm' && + needsBuildApproval && + packageManagerVersion === undefined + ) { + packageManagerVersion = await environment.getPackageManagerVersion?.( + 'pnpm', + options.targetDir, + ) + } + const s = environment.spinner() if (options.git) { @@ -370,7 +385,8 @@ async function runCommandsAndInstallDependencies( const translated = translateExecuteCommand(options.packageManager, { command: addOn.command!.command, args: addOn.command!.args || [], - }) + allowBuilds: addOn.command!.allowBuilds, + }, packageManagerVersion) const cmd = formatCommand(translated) environment.startStep({ id: 'run-commands', @@ -397,7 +413,8 @@ async function runCommandsAndInstallDependencies( const starterTranslated = translateExecuteCommand(options.packageManager, { command: options.starter.command.command, args: options.starter.command.args || [], - }) + allowBuilds: options.starter.command.allowBuilds, + }, packageManagerVersion) const cmd = formatCommand(starterTranslated) environment.startStep({ id: 'run-starter-command', @@ -610,7 +627,7 @@ Please read the README.md file for information on ${readmeDescription}${errorSta export async function createApp( environment: Environment, options: Options, - context: { existingProject?: boolean } = {}, + context: { existingProject?: boolean; packageManagerVersion?: string } = {}, ) { validatePackageManagerSupport(options.packageManager, options.chosenAddOns) const effectiveOptions = stripExamplesFromOptions(normalizeAndValidateBundlerOptions(options)) @@ -619,7 +636,7 @@ export async function createApp( await writeFiles(environment, effectiveOptions, context) await seedEnvValues(environment, effectiveOptions) await writeEnvExample(environment, effectiveOptions) - await runCommandsAndInstallDependencies(environment, effectiveOptions) + await runCommandsAndInstallDependencies(environment, effectiveOptions, context.packageManagerVersion) environment.finishRun() report(environment, effectiveOptions) diff --git a/packages/create/src/environment.ts b/packages/create/src/environment.ts index 46228942..5efd744b 100644 --- a/packages/create/src/environment.ts +++ b/packages/create/src/environment.ts @@ -44,6 +44,10 @@ export function createDefaultEnvironment(): Environment { await mkdir(dirname(path), { recursive: true }) return writeFile(path, getBinaryFile(base64Contents) as string) }, + getPackageManagerVersion: async (packageManager, cwd) => { + const result = await execa(packageManager, ['--version'], { cwd }) + return result.stdout.trim() + }, execute: async ( command: string, args: Array, diff --git a/packages/create/src/frameworks/react/add-ons/storybook/info.json b/packages/create/src/frameworks/react/add-ons/storybook/info.json index 929d6c1a..2122c230 100644 --- a/packages/create/src/frameworks/react/add-ons/storybook/info.json +++ b/packages/create/src/frameworks/react/add-ons/storybook/info.json @@ -10,6 +10,7 @@ "priority": 1, "command": { "command": "npx", - "args": ["storybook", "init", "-y", "--no-dev"] + "args": ["storybook", "init", "-y", "--no-dev"], + "allowBuilds": ["esbuild"] } } diff --git a/packages/create/src/package-manager.ts b/packages/create/src/package-manager.ts index b23813f2..d601a859 100644 --- a/packages/create/src/package-manager.ts +++ b/packages/create/src/package-manager.ts @@ -63,16 +63,41 @@ export function getPackageManagerScriptCommand( } } +export type ExecuteCommandOptions = { + /** + * Package names that are allowed to run postinstall/build scripts while the + * executed package is being installed. Supported by pnpm 10.2 and newer. + */ + allowBuilds?: Array + /** Unknown versions retain the compatible plain dlx command. */ + packageManagerVersion?: string +} + export function getPackageManagerExecuteCommand( packagerManager: PackageManager, pkg: string, args: Array = [], + options: ExecuteCommandOptions = {}, ) { switch (packagerManager) { case 'yarn': return { command: 'yarn', args: ['dlx', pkg, ...args] } - case 'pnpm': - return { command: 'pnpm', args: ['dlx', pkg, ...args] } + case 'pnpm': { + const version = options.packageManagerVersion + const parsedVersion = version?.match(/^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/) + if (version !== undefined && !parsedVersion && options.allowBuilds?.length) + throw new Error(`Invalid pnpm version: ${version}`) + const supportsAllowBuild = parsedVersion && ( + Number(parsedVersion[1]) > 10 || + (Number(parsedVersion[1]) === 10 && Number(parsedVersion[2]) >= 2)) + const allowBuildFlags = supportsAllowBuild + ? (options.allowBuilds || []).map((name) => `--allow-build=${name}`) + : [] + return { + command: 'pnpm', + args: [...allowBuildFlags, 'dlx', pkg, ...args], + } + } case 'bun': return { command: 'bunx', args: ['--bun', pkg, ...args] } case 'deno': @@ -143,12 +168,22 @@ export function packageManagerInstall( export function translateExecuteCommand( packageManager: PackageManager, - command: { command: string; args?: Array }, + command: { + command: string + args?: Array + allowBuilds?: Array + }, + packageManagerVersion?: string, ): { command: string; args: Array } { const args = command.args || [] const parsed = parseExecuteCommand(command.command, args) if (parsed) { - return getPackageManagerExecuteCommand(packageManager, parsed.pkg, parsed.args) + return getPackageManagerExecuteCommand( + packageManager, + parsed.pkg, + parsed.args, + { allowBuilds: command.allowBuilds, packageManagerVersion }, + ) } return { command: command.command, args } } diff --git a/packages/create/src/types.ts b/packages/create/src/types.ts index 502c7bff..2ff4673a 100644 --- a/packages/create/src/types.ts +++ b/packages/create/src/types.ts @@ -75,6 +75,7 @@ export const AddOnBaseSchema = z.object({ .object({ command: z.string(), args: z.array(z.string()).optional(), + allowBuilds: z.array(z.string()).optional(), }) .optional(), routes: z @@ -269,6 +270,7 @@ type ProjectEnvironment = { } type FileEnvironment = { + getPackageManagerVersion?: (packageManager: PackageManager, cwd: string) => Promise appendFile: (path: string, contents: string) => Promise copyFile: (from: string, to: string) => Promise writeFile: (path: string, contents: string) => Promise diff --git a/packages/create/tests/add-to-app.test.ts b/packages/create/tests/add-to-app.test.ts index 614e7065..84365d64 100644 --- a/packages/create/tests/add-to-app.test.ts +++ b/packages/create/tests/add-to-app.test.ts @@ -10,6 +10,7 @@ import { writeFiles, } from '../src/add-to-app.js' import { + getFrameworkById, __testClearFrameworks, __testRegisterFramework, } from '../src/frameworks.js' @@ -422,6 +423,27 @@ describe('writeFiles', () => { }) describe('runNewCommands', () => { + it.each(['10.1.0', '10.2.0'])( + 'does not rerun an existing approved command when planning for pnpm %s', + async (packageManagerVersion) => { + const framework = getFrameworkById('test') + if (!framework) throw new Error('Missing test framework') + const addOns = framework.getAddOns() + framework.getAddOns = () => addOns.map((addOn) => addOn.id === 'test' + ? { ...addOn, command: { command: 'npx', args: ['storybook', 'init'], allowBuilds: ['esbuild'] } } + : addOn) + const { environment, output } = createMemoryEnvironment('/foo') + const args = [ + ...(packageManagerVersion === '10.2.0' ? ['--allow-build=esbuild'] : []), + 'dlx', 'storybook', 'init', + ] + await runNewCommands(environment, { + ...fakeCTAJSON, packageManager: 'pnpm', chosenAddOns: ['test'], + }, '/foo', { commands: [{ command: 'pnpm', args }] }, packageManagerVersion) + expect(output.commands).toEqual([]) + }, + ) + it('should run new commands', async () => { const { environment, output } = createMemoryEnvironment('/foo') environment.startRun() diff --git a/packages/create/tests/package-manager.test.ts b/packages/create/tests/package-manager.test.ts index 90a02011..feb4c00f 100644 --- a/packages/create/tests/package-manager.test.ts +++ b/packages/create/tests/package-manager.test.ts @@ -331,3 +331,116 @@ describe('translateExecuteCommand', () => { ).toBe('deno task dev') }) }) + +describe('allowBuilds', () => { + it('should add --allow-build flags before dlx for pnpm', () => { + expect( + formatCommand( + getPackageManagerExecuteCommand( + 'pnpm', + 'storybook', + ['init', '-y', '--no-dev'], + { allowBuilds: ['esbuild'], packageManagerVersion: '10.2.0' }, + ), + ), + ).toBe('pnpm --allow-build=esbuild dlx storybook init -y --no-dev') + }) + it('should emit one --allow-build flag per package for pnpm', () => { + expect( + formatCommand( + getPackageManagerExecuteCommand('pnpm', 'storybook', ['init'], { + allowBuilds: ['esbuild', 'sharp'], + packageManagerVersion: '10.2.0', + }), + ), + ).toBe('pnpm --allow-build=esbuild --allow-build=sharp dlx storybook init') + }) + it('should ignore allowBuilds for non-pnpm package managers', () => { + expect( + formatCommand( + getPackageManagerExecuteCommand('npm', 'storybook', ['init'], { + allowBuilds: ['esbuild'], + }), + ), + ).toBe('npx -y storybook init') + expect( + formatCommand( + getPackageManagerExecuteCommand('yarn', 'storybook', ['init'], { + allowBuilds: ['esbuild'], + }), + ), + ).toBe('yarn dlx storybook init') + expect( + formatCommand( + getPackageManagerExecuteCommand('bun', 'storybook', ['init'], { + allowBuilds: ['esbuild'], + }), + ), + ).toBe('bunx --bun storybook init') + expect( + formatCommand( + getPackageManagerExecuteCommand('deno', 'storybook', ['init'], { + allowBuilds: ['esbuild'], + }), + ), + ).toBe('deno run npm:storybook init') + }) + it('should not add flags for pnpm when allowBuilds is empty', () => { + expect( + formatCommand( + getPackageManagerExecuteCommand('pnpm', 'storybook', ['init'], { + allowBuilds: [], + }), + ), + ).toBe('pnpm dlx storybook init') + }) + it('should forward allowBuilds through translateExecuteCommand for pnpm', () => { + expect( + formatCommand( + translateExecuteCommand('pnpm', { + command: 'npx', + args: ['storybook', 'init', '-y', '--no-dev'], + allowBuilds: ['esbuild'], + }, '10.2.0'), + ), + ).toBe('pnpm --allow-build=esbuild dlx storybook init -y --no-dev') + }) + it('should drop allowBuilds through translateExecuteCommand for npm', () => { + expect( + formatCommand( + translateExecuteCommand('npm', { + command: 'npx', + args: ['storybook', 'init', '-y', '--no-dev'], + allowBuilds: ['esbuild'], + }), + ), + ).toBe('npx -y storybook init -y --no-dev') + }) +}) + + +describe('pnpm dlx version compatibility', () => { + it.each([undefined, '9.15.5', '10.1.0'])( + 'retains plain dlx for pnpm %s', + (packageManagerVersion) => { + expect(getPackageManagerExecuteCommand('pnpm', 'storybook', ['init'], { + allowBuilds: ['esbuild'], packageManagerVersion, + })).toEqual({ command: 'pnpm', args: ['dlx', 'storybook', 'init'] }) + }, + ) + + it.each(['10.2.0', '10.34.0', '11.22.0'])( + 'approves only requested build scripts for pnpm %s', + (packageManagerVersion) => { + expect(getPackageManagerExecuteCommand('pnpm', 'storybook', ['init'], { + allowBuilds: ['esbuild'], packageManagerVersion, + })).toEqual({ command: 'pnpm', args: ['--allow-build=esbuild', 'dlx', 'storybook', 'init'] }) + }, + ) + + it('rejects an invalid detected version before executing approvals', () => { + expect(() => getPackageManagerExecuteCommand('pnpm', 'storybook', [], { + allowBuilds: ['esbuild'], packageManagerVersion: 'not-a-version', + })).toThrow('Invalid pnpm version') + }) +}) diff --git a/packages/create/tests/pnpm-command-version.test.ts b/packages/create/tests/pnpm-command-version.test.ts new file mode 100644 index 00000000..e6d01afe --- /dev/null +++ b/packages/create/tests/pnpm-command-version.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, vi } from 'vitest' +import { createApp as createNodeApp } from '../src/create-app.js' +import { createApp as createEdgeApp } from '../src/edge-create-app.js' +import { createMemoryEnvironment } from '../src/edge-environment.js' +import type { AddOn, Framework, Options } from '../src/types.js' + +const commandAddOn: AddOn = { + id: 'command-approval', + name: 'Command approval', + description: 'Version-aware command fixture', + type: 'add-on', + phase: 'add-on', + modes: ['file-router'], + command: { + command: 'npx', + args: ['storybook', 'init'], + allowBuilds: ['esbuild'], + }, + getFiles: async () => [], + getFileContents: async () => '', + getDeletedFiles: async () => [], +} + +const framework: Framework = { + id: 'version-fixture', + name: 'Version fixture', + description: 'Version fixture', + version: '1.0.0', + basePackageJSON: {}, + optionalPackages: {}, + supportedModes: { + 'file-router': { + displayName: 'File router', + description: '', + forceTypescript: true, + }, + }, + getFiles: async () => [], + getFileContents: async () => '', + getDeletedFiles: async () => [], + getAddOns: () => [commandAddOn], +} + +const options: Options = { + projectName: 'version-fixture', + targetDir: '/app', + framework, + mode: 'file-router', + typescript: true, + tailwind: false, + packageManager: 'pnpm', + git: false, + install: false, + intent: false, + chosenAddOns: [commandAddOn], + addOnOptions: {}, +} + +for (const [runtime, createApp] of Object.entries({ + Node: createNodeApp, + edge: createEdgeApp, +})) { + describe(`${runtime} pnpm command planning`, () => { + it.each(['9.15.5', '10.1.0', '10.2.0', '11.22.0'])( + 'uses the selected pnpm %s version without recording a probe command', + async (version) => { + const { environment, output } = createMemoryEnvironment('/app') + const lookup = vi.fn(async () => version) + environment.getPackageManagerVersion = lookup + await createApp(environment, options) + expect(lookup).toHaveBeenCalledExactlyOnceWith('pnpm', '/app') + expect(output.commands).toEqual([ + { + command: 'pnpm', + args: [ + ...(['10.2.0', '11.22.0'].includes(version) + ? ['--allow-build=esbuild'] + : []), + 'dlx', + 'storybook', + 'init', + ], + }, + ]) + }, + ) + + it.each(['10.1.0', '10.2.0'])( + 'applies the same approval boundary to starter commands on pnpm %s', + async (version) => { + const { environment, output } = createMemoryEnvironment('/app') + const lookup = vi.fn(async () => version) + environment.getPackageManagerVersion = lookup + await createApp(environment, { + ...options, + chosenAddOns: [], + starter: { + ...commandAddOn, + type: 'starter', + framework: framework.id, + mode: 'file-router', + typescript: true, + files: {}, + deletedFiles: [], + }, + }) + expect(lookup).toHaveBeenCalledExactlyOnceWith('pnpm', '/app') + expect(output.commands).toEqual([ + { + command: 'pnpm', + args: [ + ...(version === '10.2.0' ? ['--allow-build=esbuild'] : []), + 'dlx', + 'storybook', + 'init', + ], + }, + ]) + }, + ) + + it('uses explicit target versions in memory plans without probing the host', async () => { + const { environment, output } = createMemoryEnvironment('/app') + const lookup = vi.fn(async () => '11.22.0') + environment.getPackageManagerVersion = lookup + await createApp(environment, options, { packageManagerVersion: '10.1.0' }) + expect(lookup).not.toHaveBeenCalled() + expect(output.commands[0].args).toEqual(['dlx', 'storybook', 'init']) + }) + + it('does not probe pnpm for projects without command build approvals', async () => { + const { environment } = createMemoryEnvironment('/app') + const lookup = vi.fn(async () => '11.22.0') + environment.getPackageManagerVersion = lookup + await createApp(environment, { ...options, chosenAddOns: [] }) + expect(lookup).not.toHaveBeenCalled() + }) + }) +}