diff --git a/code/addons/vitest/src/node/vitest-manager.ts b/code/addons/vitest/src/node/vitest-manager.ts index b5a6a9eb56d4..edc1115a1d9b 100644 --- a/code/addons/vitest/src/node/vitest-manager.ts +++ b/code/addons/vitest/src/node/vitest-manager.ts @@ -61,7 +61,7 @@ export class VitestManager { '@storybook/addon-vitest/internal/coverage-reporter', { testManager: this.testManager, - coverageOptions: this.vitest?.config?.coverage as ResolvedCoverageOptions<'v8'> | undefined, + coverageOptions: this.vitest?.config?.coverage as ResolvedCoverageOptions | undefined, }, ]; const coverageOptions = ( diff --git a/code/core/src/common/js-package-manager/BUNProxy.ts b/code/core/src/common/js-package-manager/BUNProxy.ts index 47b8ffeab333..600f0a60e371 100644 --- a/code/core/src/common/js-package-manager/BUNProxy.ts +++ b/code/core/src/common/js-package-manager/BUNProxy.ts @@ -75,6 +75,14 @@ export class BUNProxy extends JsPackageManager { return executeCommand({ command: 'bun', args: ['init'] }); } + getCommandName(): string { + return 'bun'; + } + + getInstallCommand(deps: string[], dev: boolean): string { + return `bun add ${dev ? '-D ' : ''}${deps.join(' ')}`; + } + getRunStorybookCommand(): string { return 'bun run storybook'; } diff --git a/code/core/src/common/js-package-manager/JsPackageManager.ts b/code/core/src/common/js-package-manager/JsPackageManager.ts index 31b434654b42..61a75385c4a5 100644 --- a/code/core/src/common/js-package-manager/JsPackageManager.ts +++ b/code/core/src/common/js-package-manager/JsPackageManager.ts @@ -37,6 +37,33 @@ type PackageJsonWithIndent = PackageJsonWithDepsAndDevDeps & { [indentSymbol]?: any; }; +/** Human-friendly labels for each package manager type */ +const PACKAGE_MANAGER_LABEL: Record = { + [PackageManagerName.NPM]: 'npm', + [PackageManagerName.YARN1]: 'Yarn Classic (v1)', + [PackageManagerName.YARN2]: 'Yarn Berry', + [PackageManagerName.PNPM]: 'pnpm', + [PackageManagerName.BUN]: 'Bun', +}; + +function isValidPackageManagerName(name: string): name is PackageManagerName { + return Object.hasOwn(PACKAGE_MANAGER_LABEL, name); +} + +/** + * Prints a package manager's name in a way that's easier to compute by humans and agents. + * @param packageManager The package manager's internal name (e.g. "yarn1") + * @return A human-friendly name for the package manager (e.g. "Yarn Classic (v1)") + */ +export function getPrettyPackageManagerName(packageManager: string | undefined): string { + if (!packageManager) { + return 'unknown package manager'; + } + return isValidPackageManagerName(packageManager) + ? PACKAGE_MANAGER_LABEL[packageManager] + : packageManager; +} + /** * Extract package name and version from input * @@ -109,6 +136,12 @@ export abstract class JsPackageManager { this.primaryPackageJson = this.#getPrimaryPackageJson(); } + /** Returns the name of the command to invoke this package manager. */ + abstract getCommandName(): string; + + /** Installs package dependencies. */ + abstract getInstallCommand(deps: string[], dev?: boolean): string; + /** Runs arbitrary package scripts (as a string for display). */ abstract getRunCommand(command: string): string; diff --git a/code/core/src/common/js-package-manager/NPMProxy.ts b/code/core/src/common/js-package-manager/NPMProxy.ts index 8aee0ee4408e..521da495cc25 100644 --- a/code/core/src/common/js-package-manager/NPMProxy.ts +++ b/code/core/src/common/js-package-manager/NPMProxy.ts @@ -72,6 +72,14 @@ export class NPMProxy extends JsPackageManager { installArgs: string[] | undefined; + getCommandName(): string { + return 'npm'; + } + + getInstallCommand(deps: string[], dev: boolean): string { + return `npm install ${dev ? '-D ' : ''}${deps.join(' ')}`; + } + getRunCommand(command: string): string { return `npm run ${command}`; } diff --git a/code/core/src/common/js-package-manager/PNPMProxy.ts b/code/core/src/common/js-package-manager/PNPMProxy.ts index db0ce442bc38..1b47f92ba115 100644 --- a/code/core/src/common/js-package-manager/PNPMProxy.ts +++ b/code/core/src/common/js-package-manager/PNPMProxy.ts @@ -49,6 +49,14 @@ export class PNPMProxy extends JsPackageManager { return existsSync(pnpmWorkspaceYaml); } + getCommandName(): string { + return 'pnpm'; + } + + getInstallCommand(deps: string[], dev: boolean): string { + return `pnpm add ${dev ? '-D ' : ''}${deps.join(' ')}`; + } + getRunCommand(command: string): string { return `pnpm run ${command}`; } diff --git a/code/core/src/common/js-package-manager/Yarn1Proxy.ts b/code/core/src/common/js-package-manager/Yarn1Proxy.ts index ef078ded5ec9..ee22c848d6a6 100644 --- a/code/core/src/common/js-package-manager/Yarn1Proxy.ts +++ b/code/core/src/common/js-package-manager/Yarn1Proxy.ts @@ -46,10 +46,18 @@ export class Yarn1Proxy extends JsPackageManager { return this.installArgs; } + getCommandName(): string { + return 'yarn'; + } + getRunCommand(command: string): string { return `yarn ${command}`; } + getInstallCommand(deps: string[], dev: boolean): string { + return `yarn add ${dev ? '-D ' : ''}${deps.join(' ')}`; + } + getPackageCommand(args: string[]): string { const [command, ...rest] = args; return `yarn exec ${command} -- ${rest.join(' ')}`; diff --git a/code/core/src/common/js-package-manager/Yarn2Proxy.ts b/code/core/src/common/js-package-manager/Yarn2Proxy.ts index ba4f25bd1a67..14e55c50be5c 100644 --- a/code/core/src/common/js-package-manager/Yarn2Proxy.ts +++ b/code/core/src/common/js-package-manager/Yarn2Proxy.ts @@ -90,10 +90,18 @@ export class Yarn2Proxy extends JsPackageManager { return this.installArgs; } + getCommandName(): string { + return 'yarn'; + } + getRunCommand(command: string): string { return `yarn ${command}`; } + getInstallCommand(deps: string[], dev: boolean): string { + return `yarn add ${dev ? '-D ' : ''}${deps.join(' ')}`; + } + getPackageCommand(args: string[]): string { return `yarn exec ${args.join(' ')}`; } diff --git a/code/core/src/manager/settings/Checklist/AiSetupBlock.tsx b/code/core/src/manager/settings/Checklist/AiSetupBlock.tsx index b4f3e3033214..7bf8e3aeab8e 100644 --- a/code/core/src/manager/settings/Checklist/AiSetupBlock.tsx +++ b/code/core/src/manager/settings/Checklist/AiSetupBlock.tsx @@ -8,7 +8,7 @@ import { CheckIcon, UndoIcon } from '@storybook/icons'; import { type API, useStorybookApi } from 'storybook/manager-api'; import { styled } from 'storybook/theming'; -import { AI_SETUP_PROMPT } from '../../../shared/constants/ai-prompts.ts'; +import { getAiSetupPrompt } from '../../../shared/utils/ai-prompts.ts'; import { useCopyButton } from '../../../shared/useCopyButton.ts'; import type { ItemId } from '../../../shared/checklist-store/index.ts'; import type { ChecklistItem } from '../../components/sidebar/useChecklist.ts'; @@ -61,7 +61,7 @@ const CopyButton = ({ api }: { api: API }) => { Copied! ), - content: AI_SETUP_PROMPT, + content: getAiSetupPrompt(), onCopy: () => { api.emit(AI_PROMPT_NUDGE, { id: 'setup', origin: 'onboarding-guide-page' }); }, diff --git a/code/core/src/shared/checklist-store/checklistData.tsx b/code/core/src/shared/checklist-store/checklistData.tsx index c5c20525b6fc..9f97721fe859 100644 --- a/code/core/src/shared/checklist-store/checklistData.tsx +++ b/code/core/src/shared/checklist-store/checklistData.tsx @@ -35,7 +35,7 @@ import { ADDON_ID as ADDON_DOCS_ID } from '../../docs-tools/shared.ts'; import { TourGuide } from '../../manager/components/TourGuide/TourGuide.tsx'; import { LocationMonitor } from '../../manager/hooks/useLocation.ts'; import type { initialState } from './checklistData.state.ts'; -import { AI_SETUP_PROMPT } from '../constants/ai-prompts.ts'; +import { getAiSetupPrompt } from '../utils/ai-prompts.ts'; const CodeWrapper = styled.div(({ theme }) => ({ alignSelf: 'stretch', @@ -176,7 +176,7 @@ export const checklistData = { showOnGuidePage: false, action: { label: 'Copy prompt', - copyContent: AI_SETUP_PROMPT, + copyContent: getAiSetupPrompt(), onClick: ({ api }) => { api.emit(AI_PROMPT_NUDGE, { id: 'setup', origin: 'onboarding-checklist-side' }); }, diff --git a/code/core/src/shared/constants/ai-prompts.ts b/code/core/src/shared/constants/ai-prompts.ts deleted file mode 100644 index 0c1d6f904bd5..000000000000 --- a/code/core/src/shared/constants/ai-prompts.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Source of truth for AI prompts throughout the application. - */ - -export const AI_SETUP_PROMPT = `Run \`npx storybook ai setup\` and follow its instructions precisely.`; diff --git a/code/core/src/shared/utils/ai-prompts.ts b/code/core/src/shared/utils/ai-prompts.ts new file mode 100644 index 000000000000..212b0926bf69 --- /dev/null +++ b/code/core/src/shared/utils/ai-prompts.ts @@ -0,0 +1,7 @@ +/** + * Source of truth for AI prompts throughout the application. + */ + +export function getAiSetupPrompt(executor = 'npx storybook') { + return `Run \`${executor} ai setup\` and follow its instructions precisely.`; +} diff --git a/code/core/src/telemetry/get-monorepo-type.test.ts b/code/core/src/shared/utils/get-monorepo-type.test.ts similarity index 89% rename from code/core/src/telemetry/get-monorepo-type.test.ts rename to code/core/src/shared/utils/get-monorepo-type.test.ts index 2539bedec21c..5d665f879171 100644 --- a/code/core/src/telemetry/get-monorepo-type.test.ts +++ b/code/core/src/shared/utils/get-monorepo-type.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest'; import { getMonorepoType, monorepoConfigs } from './get-monorepo-type.ts'; -vi.mock('node:fs', async () => import('../../../__mocks__/fs.ts')); +vi.mock('node:fs', async () => import('../../../../__mocks__/fs.ts')); vi.mock('storybook/internal/common', async (importOriginal) => { return { @@ -23,7 +23,7 @@ const checkMonorepoType = ({ monorepoConfigFile, isYarnWorkspace = false }: any) mockFiles[join('root', monorepoConfigFile)] = '{}'; } - vi.mocked(fs as any).__setMockFiles(mockFiles); + vi.mocked(fs as any).__setMockFiles(mockFiles); return getMonorepoType(); }; diff --git a/code/core/src/telemetry/get-monorepo-type.ts b/code/core/src/shared/utils/get-monorepo-type.ts similarity index 100% rename from code/core/src/telemetry/get-monorepo-type.ts rename to code/core/src/shared/utils/get-monorepo-type.ts diff --git a/code/core/src/telemetry/storybook-metadata.test.ts b/code/core/src/telemetry/storybook-metadata.test.ts index 8429bfbb329e..90ce24afef26 100644 --- a/code/core/src/telemetry/storybook-metadata.test.ts +++ b/code/core/src/telemetry/storybook-metadata.test.ts @@ -18,7 +18,7 @@ import { type Settings, globalSettings } from '../cli/globalSettings.ts'; import { detectAgent } from './detect-agent.ts'; import { getApplicationFileCount } from '../telemetry/get-application-file-count.ts'; import { analyzeEcosystemPackages } from '../telemetry/get-known-packages.ts'; -import { getMonorepoType } from '../telemetry/get-monorepo-type.ts'; +import { getMonorepoType } from '../shared/utils/get-monorepo-type.ts'; import { getPackageManagerInfo } from '../telemetry/get-package-manager-info.ts'; import { getPortableStoriesFileCount } from '../telemetry/get-portable-stories-usage.ts'; import { @@ -37,7 +37,7 @@ vi.mock('./detect-agent.ts', () => ({ detectAgent: vi.fn().mockReturnValue(undefined), })); vi.mock(import('./package-json.ts'), { spy: true }); -vi.mock(import('./get-monorepo-type.ts'), { spy: true }); +vi.mock(import('../shared/utils/get-monorepo-type.ts'), { spy: true }); vi.mock(import('./get-framework-info.ts'), { spy: true }); vi.mock(import('./get-package-manager-info.ts'), { spy: true }); vi.mock(import('./get-portable-stories-usage.ts'), { spy: true }); diff --git a/code/core/src/telemetry/storybook-metadata.ts b/code/core/src/telemetry/storybook-metadata.ts index 3cbe341bb270..bb1a3413272d 100644 --- a/code/core/src/telemetry/storybook-metadata.ts +++ b/code/core/src/telemetry/storybook-metadata.ts @@ -24,7 +24,7 @@ import { getChromaticVersionSpecifier } from './get-chromatic-version.ts'; import { getFrameworkInfo } from './get-framework-info.ts'; import { getHasRouterPackage } from './get-has-router-package.ts'; import { analyzeEcosystemPackages } from './get-known-packages.ts'; -import { getMonorepoType } from './get-monorepo-type.ts'; +import { getMonorepoType } from '../shared/utils/get-monorepo-type.ts'; import { getPackageManagerInfo } from './get-package-manager-info.ts'; import { getPortableStoriesFileCount } from './get-portable-stories-usage.ts'; import { getActualPackageVersion, getActualPackageVersions } from './package-json.ts'; diff --git a/code/core/src/telemetry/types.ts b/code/core/src/telemetry/types.ts index 37b0d372a7de..9137526a30bf 100644 --- a/code/core/src/telemetry/types.ts +++ b/code/core/src/telemetry/types.ts @@ -4,7 +4,7 @@ import type { DetectResult } from 'package-manager-detector'; import type { AgentInfo } from './detect-agent.ts'; import type { KnownPackagesList } from './get-known-packages.ts'; -import type { MonorepoType } from './get-monorepo-type.ts'; +import type { MonorepoType } from '../shared/utils/get-monorepo-type.ts'; export type EventType = | 'boot' diff --git a/code/lib/cli-storybook/src/ai/index.test.ts b/code/lib/cli-storybook/src/ai/index.test.ts index d483b5833cef..706bd5960f6c 100644 --- a/code/lib/cli-storybook/src/ai/index.test.ts +++ b/code/lib/cli-storybook/src/ai/index.test.ts @@ -42,7 +42,19 @@ vi.mock('../automigrate/helpers/mainConfigFile.ts', () => ({ configDir: '/proj/.storybook', storiesPaths: [], hasCsfFactoryPreview: false, - packageManager: {}, + packageManager: { + type: 'npm', + version: '9.5.0', + getCommandName(): string { + return 'npm'; + }, + getInstallCommand(deps: string[], dev: boolean): string { + return `npm install ${dev ? '-D ' : ''}${deps.join(' ')}`; + }, + getRunCommand(command: string): string { + return `npm run ${command}`; + }, + }, }), })); diff --git a/code/lib/cli-storybook/src/ai/index.ts b/code/lib/cli-storybook/src/ai/index.ts index 20cd4884ae4e..f1371c2ce921 100644 --- a/code/lib/cli-storybook/src/ai/index.ts +++ b/code/lib/cli-storybook/src/ai/index.ts @@ -2,6 +2,7 @@ import { writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import type { PackageManagerName } from 'storybook/internal/common'; +import { getPrettyPackageManagerName } from 'storybook/internal/common'; import { cache } from 'storybook/internal/common'; import { logger } from 'storybook/internal/node-logger'; import { @@ -16,22 +17,19 @@ import { SupportedLanguage } from 'storybook/internal/types'; import { ProjectTypeService } from '../../../create-storybook/src/services/ProjectTypeService.ts'; import { getStorybookData } from '../automigrate/helpers/mainConfigFile.ts'; -import { generateMarkdownOutput } from './prompt.ts'; +import { getAiSetupMarkdownOutput } from './setup-prompts/index.ts'; import type { ProjectInfo, AiSetupOptions } from './types.ts'; export async function aiSetup(options: AiSetupOptions): Promise { - const { configDir: userConfigDir, packageManager: packageManagerName, output } = options; + const { configDir: userConfigDir, packageManager, output } = options; let projectInfo: ProjectInfo; try { const data = await getStorybookData({ configDir: userConfigDir, - packageManagerName: packageManagerName as PackageManagerName | undefined, + packageManagerName: packageManager as PackageManagerName | undefined, }); - const majorVersion = data.versionInstalled - ? parseMajorVersion(data.versionInstalled) - : undefined; if (!data.frameworkPackage || !data.rendererPackage || !data.builderPackage) { logger.error( @@ -40,6 +38,10 @@ export async function aiSetup(options: AiSetupOptions): Promise { return; } + const majorVersion = data.versionInstalled + ? parseMajorVersion(data.versionInstalled) + : undefined; + const projectTypeService = new ProjectTypeService(data.packageManager); const detectedLanguage = await projectTypeService.detectLanguage(); const language = detectedLanguage === SupportedLanguage.TYPESCRIPT ? 'ts' : 'js'; @@ -54,10 +56,12 @@ export async function aiSetup(options: AiSetupOptions): Promise { addons: data.addons ?? [], configDir: data.configDir, storiesPaths: data.storiesPaths, - hasCsfFactoryPreview: data.hasCsfFactoryPreview, + packageManager: data.packageManager, + packageManagerName: getPrettyPackageManagerName(data.packageManager.type), language, + hasCsfFactoryPreview: data.hasCsfFactoryPreview, }; - } catch (err: unknown) { + } catch (err) { logger.error( `Failed to read Storybook configuration: ${err instanceof Error ? err.message : String(err)}` ); @@ -80,21 +84,20 @@ export async function aiSetup(options: AiSetupOptions): Promise { return; } - const result = await generateMarkdownOutput(projectInfo); + const result = await getAiSetupMarkdownOutput(projectInfo); const markdownOutput = result.markdown; await telemetry('ai-setup', { cliOptions: { output: output ? 'file' : undefined, configDir: projectInfo.configDir, - packageManager: packageManagerName, + packageManager: projectInfo.packageManager.type, }, project: { framework: projectInfo.framework, renderer: projectInfo.rendererPackage, builder: projectInfo.builderPackage, language: projectInfo.language, - hasCsfFactoryPreview: projectInfo.hasCsfFactoryPreview, }, }); @@ -121,7 +124,7 @@ export async function aiSetup(options: AiSetupOptions): Promise { await writeFile(outputPath, markdownOutput, 'utf-8'); logger.log(`Prompt written to ${outputPath}`); } else { - logger.log(markdownOutput); + process.stdout.write(`${markdownOutput}\n`); } } diff --git a/code/lib/cli-storybook/src/ai/prompt.ts b/code/lib/cli-storybook/src/ai/prompt.ts deleted file mode 100644 index 55d9a5a2b52b..000000000000 --- a/code/lib/cli-storybook/src/ai/prompt.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { dedent } from 'ts-dedent'; - -import type { ProjectInfo } from './types.ts'; -import { getPrompts } from './setup-prompts/index.ts'; - -function getProjectOverview(projectInfo: ProjectInfo): string { - return dedent` - ## Project Info - - | Property | Value | - |----------|-------| - | Version | ${projectInfo.storybookVersion || 'unknown'} | - | Renderer | ${projectInfo.rendererPackage || 'unknown'} | - | Framework | ${projectInfo.framework || 'unknown'} | - | Builder | ${projectInfo.builderPackage || 'unknown'} | - | Config Dir | \`${projectInfo.configDir}\` | - | CSF Format | ${projectInfo.hasCsfFactoryPreview ? 'CSF Factory' : 'CSF3'} | - | Addons | ${projectInfo.addons.length > 0 ? projectInfo.addons.join(', ') : 'none'} | - `; -} - -export async function generateMarkdownOutput(projectInfo: ProjectInfo): Promise<{ - markdown: string; -}> { - const { prompts: aiPrompts } = await getPrompts(projectInfo); - - const sections: string[] = []; - - sections.push(dedent` - # Storybook Setup - `); - - sections.push(getProjectOverview(projectInfo)); - - for (const aiPrompt of aiPrompts) { - sections.push(aiPrompt.instructions); - } - - return { markdown: sections.join('\n\n') }; -} diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/index.ts b/code/lib/cli-storybook/src/ai/setup-prompts/index.ts index ab8d1a7c3905..1382eee4c561 100644 --- a/code/lib/cli-storybook/src/ai/setup-prompts/index.ts +++ b/code/lib/cli-storybook/src/ai/setup-prompts/index.ts @@ -1,21 +1,33 @@ -import type { AiPrompt, ProjectInfo } from '../types.ts'; +import { dedent } from 'ts-dedent'; +import type { ProjectInfo } from '../types.ts'; -import * as patternCopyPlay from './pattern-copy-play.ts'; +import { getProjectOverview } from '../utils/project-overview.ts'; + +/** + * The single prompt variant that ships to real users. Running + * `npx storybook ai setup` without any overrides always produces this prompt. + */ +import * as currentlyUsedPrompt from './pattern-copy-play.ts'; +export const DEFAULT_PROMPT_NAME: PromptName = 'pattern-copy-play'; /** * Main prompt used currently in `npx storybook ai setup` command. If you promote a new prompt to be default, move this to the FORMERLY_USED_PROMPTS object below. */ const CURRENTLY_USED_PROMPT: Record string> = { - 'pattern-copy-play': patternCopyPlay.instructions, + [DEFAULT_PROMPT_NAME]: currentlyUsedPrompt.instructions, }; /** * Names of variants registered behind `EVAL_SETUP_PROMPT`. Loaded on demand - * from sibling files so the bundler can code-split them away from the + * from sibling files so the bundler can code|-split them away from the * default-only path that real users hit. */ const FORMERLY_USED_PROMPTS: Record Promise<(projectInfo: ProjectInfo) => string>> = { + monorepo: async () => (await import('./monorepo.ts')).instructions, + 'optimized-tests': async () => (await import('./optimized-tests.ts')).instructions, + 'relaxed-limits': async () => (await import('./relaxed-limits.ts')).instructions, setup: async () => (await import('./setup.ts')).instructions, + 'pattern-copy-play': async () => (await import('./pattern-copy-play.ts')).instructions, }; export type PromptName = string; @@ -26,12 +38,6 @@ export const PROMPT_NAMES: PromptName[] = [ ...Object.keys(FORMERLY_USED_PROMPTS), ]; -/** - * The single prompt variant that ships to real users. Running - * `npx storybook ai setup` without any overrides always produces this prompt. - */ -export const DEFAULT_PROMPT_NAME: PromptName = 'pattern-copy-play'; - /** * Internal env var read only by `getPrompts`. The eval harness sets this * before spawning `ai setup` to select a non-default prompt variant for A/B @@ -52,17 +58,23 @@ function resolvePromptName(): PromptName { return DEFAULT_PROMPT_NAME; } -export async function getPrompts(projectInfo: ProjectInfo): Promise<{ prompts: AiPrompt[] }> { +export async function getAiSetupPrompt(projectInfo: ProjectInfo): Promise { const name = resolvePromptName(); const builder = CURRENTLY_USED_PROMPT[name] ?? (await FORMERLY_USED_PROMPTS[name]()); + return builder(projectInfo); +} + +export async function getAiSetupMarkdownOutput(projectInfo: ProjectInfo): Promise<{ + markdown: string; +}> { return { - prompts: [ - { - name, - description: 'Set up Storybook for success', - instructions: builder(projectInfo), - }, - ], + markdown: dedent` + # Storybook Setup + + ${getProjectOverview(projectInfo)} + + ${await getAiSetupPrompt(projectInfo)} + `, }; } diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/monorepo.ts b/code/lib/cli-storybook/src/ai/setup-prompts/monorepo.ts new file mode 100644 index 000000000000..b73e3f3e8656 --- /dev/null +++ b/code/lib/cli-storybook/src/ai/setup-prompts/monorepo.ts @@ -0,0 +1,102 @@ +import { dedent } from 'ts-dedent'; + +import type { ProjectInfo, SetupInstructionsContext } from '../types.ts'; +import { getDocsMarkdownUrl } from '../utils/docs-markdown-url.ts'; +import { ext } from '../utils/ext.ts'; +import { listRules, listSteps } from '../utils/markdown.ts'; +import { + buildPortalStep, + buildSharedPreviewStep, + cleanupStep, + discoveryStepStrict, + interactionPlayStep, + monorepoStep, + mswStep, + verifyStep, + writeStoriesStep, +} from './partials/steps.ts'; +import { + batchTestsRule, + editOverWriteRule, + monorepoRule, + nodeModuleReadsRule, + noPolishRule, + packageManagerRule, + preferSharedFixesRule, + readBudgetRule, + toolsVsShellRule, +} from './partials/rules.ts'; + +export function instructions(projectInfo: ProjectInfo): string { + const { configDir, language, packageManager, packageManagerName } = projectInfo; + const tsx = ext(language, true); + const ts = ext(language, false); + const docsUrl = (path: string) => getDocsMarkdownUrl(path, projectInfo); + const mswInstall = packageManager.getInstallCommand( + ['msw', 'msw-storybook-addon', 'mockdate'], + true + ); + + const ctx: SetupInstructionsContext = { + configDir, + docsUrl, + mswInstall, + packageManager, + packageManagerName, + tsx, + ts, + }; + + return dedent` + Your goal is to make Storybook fully functional in this project: configure \`${configDir}/preview.${tsx}\` with the right decorators, add MSW for data, and write up to 10 colocated \`*.stories.${tsx}\` files. Add \`play\` functions only where they prove something non-trivial. + + ## Rules of engagement (follow strictly — these are time budgets, not suggestions) + + ${listRules([ + toolsVsShellRule(ctx), + nodeModuleReadsRule(ctx), + monorepoRule(ctx), + readBudgetRule(ctx), + editOverWriteRule(ctx), + batchTestsRule(ctx), + packageManagerRule(ctx), + preferSharedFixesRule(ctx), + noPolishRule(ctx), + ])} + + ## Plan (do not skip steps, but keep each step lean) + + ${listSteps( + [ + discoveryStepStrict(projectInfo, ctx), + monorepoStep(projectInfo, ctx), + buildSharedPreviewStep(projectInfo, ctx), + buildPortalStep(projectInfo, ctx), + mswStep(projectInfo, ctx), + writeStoriesStep(projectInfo, ctx), + interactionPlayStep(projectInfo, ctx), + verifyStep(projectInfo, ctx), + cleanupStep(projectInfo, ctx), + ], + { level: 3 } + )} + + ## Done when + + - **Exactly one \`CssCheck\` story exists** somewhere in the new stories, asserting a concrete computed style value read from the component's source (added at the end of Step 5). + - Every story file you wrote that vitest confirmed passing has had \`'needs-work'\` stripped, leaving \`tags: ['ai-generated']\`. Anything still failing keeps \`['ai-generated', 'needs-work']\`. + - \`npx vitest --project storybook run\` passes for the new files. + - The project's TypeScript check passes for changed files. + - The shared preview is strong enough that stories don't need per-story fetch/provider workarounds. + + ## Reference (only fetch if stuck) + + - Docs index: https://storybook.js.org/llms.txt + - Writing stories: ${docsUrl('writing-stories')} + - Decorators: ${docsUrl('writing-stories/decorators')} + - Play functions: ${docsUrl('writing-stories/play-function')} + - Vitest integration: ${docsUrl('writing-tests/vitest-plugin')} + + Append \`?codeOnly=true\` to any docs URL for code-only snippets. Don't fetch unless a specific question can't be answered from this prompt. + `; +} diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/optimized-tests.ts b/code/lib/cli-storybook/src/ai/setup-prompts/optimized-tests.ts new file mode 100644 index 000000000000..ac80395fd469 --- /dev/null +++ b/code/lib/cli-storybook/src/ai/setup-prompts/optimized-tests.ts @@ -0,0 +1,98 @@ +import { dedent } from 'ts-dedent'; + +import type { ProjectInfo, SetupInstructionsContext } from '../types.ts'; +import { getDocsMarkdownUrl } from '../utils/docs-markdown-url.ts'; +import { ext } from '../utils/ext.ts'; +import { listRules, listSteps } from '../utils/markdown.ts'; +import { + buildPortalStep, + buildSharedPreviewStep, + cleanupStep, + discoveryStepStrict, + interactionPlayStep, + mswStep, + verifyStep, + writeStoriesStep, +} from './partials/steps.ts'; +import { + batchTestsRule, + editOverWriteRule, + nodeModuleReadsRule, + noPolishRule, + packageManagerRule, + preferSharedFixesRule, + readBudgetRule, + toolsVsShellRule, +} from './partials/rules.ts'; + +export function instructions(projectInfo: ProjectInfo): string { + const { configDir, language, packageManager, packageManagerName } = projectInfo; + const tsx = ext(language, true); + const ts = ext(language, false); + const docsUrl = (path: string) => getDocsMarkdownUrl(path, projectInfo); + const mswInstall = packageManager.getInstallCommand( + ['msw', 'msw-storybook-addon', 'mockdate'], + true + ); + + const ctx: SetupInstructionsContext = { + configDir, + docsUrl, + mswInstall, + packageManager, + packageManagerName, + tsx, + ts, + }; + + return dedent` + Your goal is to make Storybook fully functional in this project: configure \`${configDir}/preview.${tsx}\` with the right decorators, add MSW for data, and write up to 10 colocated \`*.stories.${tsx}\` files. Add \`play\` functions only where they prove something non-trivial. + + ## Rules of engagement (follow strictly — these are time budgets, not suggestions) + + ${listRules([ + toolsVsShellRule(ctx), + nodeModuleReadsRule(ctx), + readBudgetRule(ctx), + editOverWriteRule(ctx), + batchTestsRule(ctx), + packageManagerRule(ctx), + preferSharedFixesRule(ctx), + noPolishRule(ctx), + ])} + + ## Plan (do not skip steps, but keep each step lean) + + ${listSteps( + [ + discoveryStepStrict(projectInfo, ctx), + buildSharedPreviewStep(projectInfo, ctx), + buildPortalStep(projectInfo, ctx), + mswStep(projectInfo, ctx), + writeStoriesStep(projectInfo, ctx), + interactionPlayStep(projectInfo, ctx), + verifyStep(projectInfo, ctx), + cleanupStep(projectInfo, ctx), + ], + { level: 3 } + )} + + ## Done when + + - **Exactly one \`CssCheck\` story exists** somewhere in the new stories, asserting a concrete computed style value read from the component's source (added at the end of Step 5). + - Every story file you wrote that vitest confirmed passing has had \`'needs-work'\` stripped, leaving \`tags: ['ai-generated']\`. Anything still failing keeps \`['ai-generated', 'needs-work']\`. + - \`npx vitest --project storybook run\` passes for the new files. + - The project's TypeScript check passes for changed files. + - The shared preview is strong enough that stories don't need per-story fetch/provider workarounds. + + ## Reference (only fetch if stuck) + + - Docs index: https://storybook.js.org/llms.txt + - Writing stories: ${docsUrl('writing-stories')} + - Decorators: ${docsUrl('writing-stories/decorators')} + - Play functions: ${docsUrl('writing-stories/play-function')} + - Vitest integration: ${docsUrl('writing-tests/vitest-plugin')} + + Append \`?codeOnly=true\` to any docs URL for code-only snippets. Don't fetch unless a specific question can't be answered from this prompt. + `; +} diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/partials/examples.ts b/code/lib/cli-storybook/src/ai/setup-prompts/partials/examples.ts new file mode 100644 index 000000000000..0665bad3e843 --- /dev/null +++ b/code/lib/cli-storybook/src/ai/setup-prompts/partials/examples.ts @@ -0,0 +1,203 @@ +import { dedent } from 'ts-dedent'; +import type { ProjectInfo } from '../../types.ts'; +import { ext } from '../../utils/ext.ts'; + +export function getPreviewExample(projectInfo: ProjectInfo): string { + const { configDir, language, framework, rendererPackage } = projectInfo; + const tsx = ext(language, true); + const typeImport = framework || rendererPackage || '@storybook/react-vite'; + + if (language === 'js') { + return dedent` + \`\`\`${tsx} + // ${configDir}/preview.${tsx} + import '../src/index.css'; + import MockDate from 'mockdate'; + import { initialize, mswLoader } from 'msw-storybook-addon'; + import { SessionProvider } from '../src/contexts/SessionContext'; + import { mswHandlers } from './msw-handlers'; + + initialize({ onUnhandledRequest: 'bypass' }); + + const preview = { + decorators: [ + (Story) => ( + + + + ), + ], + loaders: [mswLoader], + parameters: { msw: { handlers: mswHandlers } }, + async beforeEach() { + localStorage.setItem('theme', 'dark'); + MockDate.set('2024-04-01T12:00:00Z'); + }, + }; + + export default preview; + \`\`\` + `; + } + + return dedent` + \`\`\`${tsx} + // ${configDir}/preview.${tsx} + import type { Preview } from '${typeImport}'; + import '../src/index.css'; + import MockDate from 'mockdate'; + import { initialize, mswLoader } from 'msw-storybook-addon'; + import { SessionProvider } from '../src/contexts/SessionContext'; + import { mswHandlers } from './msw-handlers'; + + initialize({ onUnhandledRequest: 'bypass' }); + + const preview: Preview = { + decorators: [ + (Story) => ( + + + + ), + ], + loaders: [mswLoader], + parameters: { msw: { handlers: mswHandlers } }, + async beforeEach() { + localStorage.setItem('theme', 'dark'); + MockDate.set('2024-04-01T12:00:00Z'); + }, + }; + + export default preview; + \`\`\` + `; +} + +export function getPortalDecoratorExample(projectInfo: ProjectInfo): string { + const { language } = projectInfo; + const tsx = ext(language, true); + + return dedent` + \`\`\`${tsx} + // Add this entry to the \`decorators\` array of your preview config: + (Story) => { + for (const id of ['modal-root', 'drawer-root', 'toast-root']) { + if (!document.getElementById(id)) { + const el = document.createElement('div'); + el.id = id; + document.body.appendChild(el); + } + } + return ; + } + \`\`\` + `; +} + +export function getMainConfigExample(projectInfo: ProjectInfo): string { + const { configDir, framework, rendererPackage, language } = projectInfo; + const ts = ext(language, false); + const typeImport = framework || rendererPackage || '@storybook/react'; + + if (language === 'js') { + return dedent` + \`\`\`js + // ${configDir}/main.js + const config = { staticDirs: ['../public'] }; + export default config; + \`\`\` + `; + } + + return dedent` + \`\`\`${ts} + // ${configDir}/main.${ts} + import type { StorybookConfig } from '${typeImport}'; + + const config: StorybookConfig = { staticDirs: ['../public'] }; + export default config; + \`\`\` + `; +} + +export function getStoryExample(projectInfo: ProjectInfo): string { + const { language, framework, rendererPackage } = projectInfo; + const tsx = ext(language, true); + const typeImport = framework || rendererPackage || '@storybook/react-vite'; + + if (language === 'js') { + return dedent` + \`\`\`${tsx} + import { expect } from 'storybook/test'; + import { Button } from './Button'; + + const meta = { + component: Button, + tags: ['ai-generated', 'needs-work'], // strip 'needs-work' once vitest passes + }; + + export default meta; + + // Smoke check — one is enough per file + export const Primary = { + args: { children: 'Order now' }, + play: async ({ canvas }) => { + await expect(canvas.getByRole('button', { name: /order now/i })).toBeVisible(); + }, + }; + + // Variant-only stories: no play needed + export const Clear = { args: { children: 'Cancel', clear: true } }; + export const Large = { args: { children: 'Checkout', large: true } }; + export const WithIcon = { args: { icon: 'cart', 'aria-label': 'food cart' } }; + \`\`\` + `; + } + + return dedent` + \`\`\`${tsx} + import type { Meta, StoryObj } from '${typeImport}'; + import { expect } from 'storybook/test'; + import { Button } from './Button'; + + const meta = { + component: Button, + tags: ['ai-generated', 'needs-work'], // strip 'needs-work' once vitest passes + } satisfies Meta; + + export default meta; + type Story = StoryObj; + + // Smoke check — one is enough per file + export const Primary: Story = { + args: { children: 'Order now' }, + play: async ({ canvas }) => { + await expect(canvas.getByRole('button', { name: /order now/i })).toBeVisible(); + }, + }; + + // Variant-only stories: no play needed + export const Clear: Story = { args: { children: 'Cancel', clear: true } }; + export const Large: Story = { args: { children: 'Checkout', large: true } }; + export const WithIcon: Story = { args: { icon: 'cart', 'aria-label': 'food cart' } }; + \`\`\` + `; +} + +export function getInteractionPlayExample(projectInfo: ProjectInfo): string { + const { language } = projectInfo; + const tsx = ext(language, true); + const typeAnnotation = language === 'ts' ? ': Story' : ''; + + return dedent` + \`\`\`${tsx} + export const FilledForm${typeAnnotation} = { + play: async ({ canvas, userEvent }) => { + await userEvent.type(canvas.getByLabelText('email'), 'a@b.com', { delay: 50 }); + await userEvent.click(canvas.getByRole('button', { name: /submit/i })); + await expect(await canvas.findByText(/welcome/i)).toBeVisible(); + }, + }; + \`\`\` + `; +} diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/partials/rules.ts b/code/lib/cli-storybook/src/ai/setup-prompts/partials/rules.ts new file mode 100644 index 000000000000..02cd8a5a7fe8 --- /dev/null +++ b/code/lib/cli-storybook/src/ai/setup-prompts/partials/rules.ts @@ -0,0 +1,56 @@ +import { dedent } from 'ts-dedent'; +import type { SetupInstructionsContext } from '../../types.ts'; +import { getMonorepoType } from '../../../../../../core/src/shared/utils/get-monorepo-type.ts'; + +export function toolsVsShellRule(ctx: SetupInstructionsContext): string { + return dedent`**Discover with Glob/Grep/Read, not shell.** Never use \`ls\`, \`find\`, \`cat\`, \`head\`, \`tail\`, shell \`grep\`, \`sed\`, or \`node -e\` for discovery or for editing files in bulk — these are slower per call and violate caching. Substitute bash commands for the specific tool names listed below, or available tools with the closest semantics: + - List a directory → \`Glob('src/components/*')\` (alt names: \`search_files\`, \`file_search\`), not \`ls src/components\`. + - Search a string → \`Grep('pattern', { path: 'src' })\` (alt names: \`grep_search\`, \`search_files\`), not \`grep -rn ...\` or \`find ... | xargs grep\`. + - Read a file → \`Read('path/to/file')\` (alt names: \`read_file\`), not \`cat\`/\`head\`/\`tail\`. + - Bulk-edit many files → multiple \`Edit\` calls (alt names: \`apply_patch\`, \`replace_in_file\`, \`replace\`), or one \`Edit\` with \`replace_all\` (alt names: \`replace\` with \`allow_multiple\`), not \`sed -i\`.`; +} + +export function nodeModuleReadsRule(ctx: SetupInstructionsContext): string { + return dedent`**Never read or grep inside \`node_modules\`.** The imports shown in this prompt are correct — don't verify them by introspecting installed packages. If something seems off, re-read this prompt, not \`node_modules\`.`; +} + +export function monorepoRule(ctx: SetupInstructionsContext): string | undefined { + const monorepoType = getMonorepoType(); + if (monorepoType) { + return `**${monorepoType} monorepo.** Don't initially look for config or existing Storybook content in other packages. Start exploring from config and tooling local to the package where you are asked to set up Storybook. If it uses local monorepo dependencies, build all dependencies found during discovery before writing stories or running tests.`; + } +} + +export function packageManagerRule({ packageManagerName }: SetupInstructionsContext): string { + if (packageManagerName) { + return dedent`**Use \`${packageManagerName}\` for every install** (detected from this project's lockfile).`; + } + return '**Detect the package manager once** from the lockfile (`pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, `bun.lockb` → bun, otherwise npm) and use it for every install in this trial.'; +} + +export function editOverWriteRule({ configDir, tsx }: SetupInstructionsContext): string { + return dedent`**Edit > Write.** For any file you've Read, use \`Edit\`. Use \`Write\` only for new files. The project already has a \`${configDir}/preview.${tsx}\` from \`storybook init\` — **Edit** it, do not overwrite.`; +} + +export function batchTestsRule(ctx: SetupInstructionsContext): string { + return dedent`**Batch the test loop.** Write **all** stories first, then run vitest **once** across everything. No per-file vitest runs until after that first batch run reveals failures.`; +} + +export function readBudgetRule(ctx: SetupInstructionsContext): string | undefined { + return dedent`**Read budget: ~12 files for discovery.** Before writing any code you may Read at most ~12 files (\`index.html\`, entry, App, providers, routing, root CSS, 2–3 representative pages/components, 1–2 hooks, 1 test). If you need more, summarize and move on.`; +} + +export function readBudgetRuleRelaxed(ctx: SetupInstructionsContext): string | undefined { + return dedent`**Read budget: ~40 files for discovery.** Before writing any code you may Read at most ~40 files: \`index.html\`, entry, App, providers, routing, root CSS, 1–2 hooks, 1 test, and spend the rest on components. You may read direct component dependencies essential to their understanding only after having read 20 components (or all components if fewer in the codebase).`; +} + +export function preferSharedFixesRule({ + configDir, + tsx, +}: SetupInstructionsContext): string | undefined { + return dedent`**Prefer fixing the shared \`${configDir}/preview.${tsx}\`** over story-local workarounds when multiple stories fail the same way.`; +} + +export function noPolishRule(ctx: SetupInstructionsContext): string | undefined { + return dedent`**Stop when the success criteria are met** — don't keep polishing.`; +} diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/partials/steps.ts b/code/lib/cli-storybook/src/ai/setup-prompts/partials/steps.ts new file mode 100644 index 000000000000..8b18a432a65b --- /dev/null +++ b/code/lib/cli-storybook/src/ai/setup-prompts/partials/steps.ts @@ -0,0 +1,269 @@ +import { dedent } from 'ts-dedent'; +import type { ProjectInfo, SetupInstructionsContext as InstructionsContext } from '../../types.ts'; +import { + getInteractionPlayExample, + getMainConfigExample, + getPortalDecoratorExample, + getPreviewExample, + getStoryExample, +} from './examples.ts'; + +export function discoveryStepStrict( + projectInfo: ProjectInfo, + { tsx }: InstructionsContext +): { title: string; body: string } { + return { + title: 'Discover the runtime (≤12 reads)', + body: dedent` + Identify, in this order, using Glob/Grep first then targeted Reads: + + - \`index.html\` — \`\` tags, inline \`