diff --git a/AGENTS.md b/AGENTS.md index d18ae7ca6..514fecf6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -166,8 +166,14 @@ This auto-generates the matrix entries in `.github/workflows/tests-entry.yml`. N | `pnpm-workspace.yaml` | Workspace package locations and hoisting config | | `packages/cli/turbo.json` | CLI-specific build dependencies | | `packages/features/src/features.ts` | Feature flag definitions | +| `packages/cli/integrations.ts` | CLI-only integrations registry (e.g. `--storybook`) | +| `packages/cli/types.ts` | Integration types (`Integration`, `IntegrationContext`) | | `packages/features/src/rules/rules.ts` | Feature compatibility rules | +**Important:** CLI options are now split in two groups: +- Feature flags come from `packages/features/src/features.ts`. +- CLI-only integrations (that aren't product features) are registered in `packages/cli/integrations.ts` and provide their own arg definition + runtime hook. + ## Adding/Modifying Boilerplates **MAINTAINABILITY is the top priority.** Strive for clean code and good separation of concerns. diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 54a299d24..862de5869 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -13,9 +13,10 @@ import { type ArgDef, type CommandDef, defineCommand, type ParsedArgs, runMain, import * as colorette from "colorette"; import { blue, blueBright, bold, cyan, gray, green, red, underline, yellow } from "colorette"; import { kebabCase } from "scule"; +import { getEnabledIntegrations, getIntegrationArgDefs, runEnabledIntegrations } from "./integrations.js"; import packageJson from "./package.json" with { type: "json" }; import { type RuleMessage, rulesMessages } from "./rules.js"; -import type { BoilerplateDef, BoilerplateDefWithConfig, Hook } from "./types.js"; +import type { BatiArgDef, BoilerplateDef, BoilerplateDefWithConfig, Hook } from "./types.js"; printInit(); @@ -25,7 +26,6 @@ const isWin = process.platform === "win32"; const pm = packageManager(); type FeatureOrCategory = Flags | CategoryLabels; -type BatiArgDef = ArgDef & { invisible?: boolean }; type BatiArgsDef = Record; function boilerplatesDir() { @@ -160,7 +160,13 @@ function printInit() { console.log(`\n🔨 ${cyan("Vike Scaffolder")} 🔨 ${gray(`v${version}`)}\n`); } -async function printOK(dist: string, flags: string[], nextSteps: BatiConfigStep[]) { +async function printOK( + dist: string, + flags: string[], + nextSteps: BatiConfigStep[], + extraLabels: string[] = [], + integrationNextSteps: BatiConfigStep[] = [], +) { const indent = 1; const list = withIcon("-", gray, indent); const cmd = withIcon("$", gray, indent); @@ -174,6 +180,10 @@ async function printOK(dist: string, flags: string[], nextSteps: BatiConfigStep[ console.log(list(feature.label)); } + for (const label of extraLabels) { + console.log(list(label)); + } + console.log(`\n${bold("Next steps:")}`); // Step 1 console.log(cmd(`cd ${distPretty}`)); @@ -194,6 +204,14 @@ async function printOK(dist: string, flags: string[], nextSteps: BatiConfigStep[ // Step 4 console.log(cmd(`${pm.run} dev`)); + for (const step of integrationNextSteps) { + if (step.type === "command") { + console.log(cmd(step.step)); + } else { + console.log(withIcon("•️", gray, indent)(step.step)); + } + } + console.log("\nHappy coding! 🚀\n"); } @@ -229,7 +247,8 @@ type Args = typeof defaultDef & required: boolean; description: string | undefined; } - >; + > & + Record; export default function yn(value: unknown, default_?: boolean) { if (value === undefined || value === null) { @@ -378,13 +397,11 @@ async function checkFlagsIncludesUiFramework(flags: string[]) { } } -function checkFlagsExist(flags: string[]) { +function checkFlagsExist(flags: string[], knownOptions: string[]) { + const normalizedKnownOptions = new Set(knownOptions.map((option) => kebabCase(option))); + const inValidOptions = flags.reduce((acc: string[], flag: string) => { - if ( - !Object.hasOwn(defaultDef, flag) && - !Object.hasOwn(defaultDef, kebabCase(flag)) && - !features.some((f) => f.flag === flag || kebabCase(f.flag) === kebabCase(flag)) - ) { + if (!normalizedKnownOptions.has(kebabCase(flag))) { acc.push(flag); } return acc; @@ -398,6 +415,10 @@ function checkFlagsExist(flags: string[]) { } } +function isFeatureFlag(flag: string): flag is Flags { + return features.some((f) => f.flag === flag); +} + function checkRules(flags: string[]) { const potentialRulesMessages = execRules(flags as FeatureOrCategory[], rulesMessages); @@ -499,7 +520,14 @@ async function run() { const dir = boilerplatesDir(); const boilerplates = await loadBoilerplates(dir); - const optsArgs = Object.assign({}, defaultDef, ...cliFlags.map((k) => toArg(k, findFeature(k)))) as Args; + const integrationArgs = getIntegrationArgDefs(); + const optsArgs = Object.assign( + {}, + defaultDef, + integrationArgs, + ...cliFlags.map((k) => toArg(k, findFeature(k))), + ) as Args; + const knownOptionKeys = Object.keys(optsArgs); const main = defineCommand({ meta: { @@ -513,23 +541,34 @@ async function run() { const sources: string[] = []; const hooks: string[] = []; - const flags = [ + const selectedFlags = [ ...new Set( Object.entries(args) - .filter(([, val]) => val === true) - .flatMap(([key]) => { - const flag: string[] = [key]; - const dependsOn = (features as ReadonlyArray).find((f) => f.flag === key)?.dependsOn; - - if (dependsOn) { - flag.push(...dependsOn); - } - return flag; - }), + .filter(([, val]) => typeof val === "boolean" && val) + .map(([key]) => key), + ), + ]; + + checkFlagsExist(selectedFlags, knownOptionKeys); + + const flags = [ + ...new Set( + selectedFlags.flatMap((key) => { + if (!isFeatureFlag(key)) { + return []; + } + + const flag: string[] = [key]; + const dependsOn = (features as ReadonlyArray).find((f) => f.flag === key)?.dependsOn; + + if (dependsOn) { + flag.push(...dependsOn); + } + return flag; + }), ), ]; - checkFlagsExist(flags); await checkFlagsIncludesUiFramework(flags); checkRules(flags); @@ -583,12 +622,26 @@ async function run() { gitInit(args.project); } + const enabledIntegrations = getEnabledIntegrations(args as unknown as Record); + const appliedIntegrations = await runEnabledIntegrations(enabledIntegrations, { + project: args.project, + flags, + allFeatures: features, + packageManagerExec: pm.exec, + }); + const nextSteps = filteredBoilerplates .flatMap((b) => b.config.nextSteps?.(meta, pm.run, colorette)) .filter(Boolean) as BatiConfigStep[]; nextSteps.sort((s) => s.order ?? 0); - await printOK(args.project, flags, nextSteps); + const integrationNextSteps = appliedIntegrations + .flatMap((integration) => integration.nextSteps?.(pm.run) ?? []) + .filter(Boolean) as BatiConfigStep[]; + + const extraLabels = appliedIntegrations.map((integration) => integration.label); + + await printOK(args.project, flags, nextSteps, extraLabels, integrationNextSteps); }, }); diff --git a/packages/cli/integrations.ts b/packages/cli/integrations.ts new file mode 100644 index 000000000..7fa34148a --- /dev/null +++ b/packages/cli/integrations.ts @@ -0,0 +1,34 @@ +import { storybookIntegration } from "./storybook.js"; +import type { BatiArgDef, Integration, IntegrationContext } from "./types.js"; + +export const integrations: ReadonlyArray = [storybookIntegration]; + +function isEnabled(args: Record, flag: string): boolean { + return args[flag] === true; +} + +export function getIntegrationArgDefs(): Record { + return Object.fromEntries(integrations.map((integration) => [integration.flag, integration.arg])); +} + +export function getEnabledIntegrations(args: Record): Integration[] { + return integrations.filter((integration) => isEnabled(args, integration.flag)); +} + +export async function runEnabledIntegrations( + enabledIntegrations: ReadonlyArray, + context: IntegrationContext, +): Promise { + const appliedIntegrations: Integration[] = []; + + for (const integration of enabledIntegrations) { + const wasApplied = await integration.run(context); + if (wasApplied === false) { + continue; + } + + appliedIntegrations.push(integration); + } + + return appliedIntegrations; +} diff --git a/packages/cli/storybook.ts b/packages/cli/storybook.ts new file mode 100644 index 000000000..2115e0aec --- /dev/null +++ b/packages/cli/storybook.ts @@ -0,0 +1,62 @@ +import { execSync } from "node:child_process"; +import { confirm } from "@inquirer/prompts"; +import type { Feature } from "@batijs/features"; +import { red } from "colorette"; +import type { Integration } from "./types.js"; + +const supportedStorybookFrameworks = ["react", "vue", "solid"]; + +export function getUiFrameworkFlag(flags: string[], allFeatures: ReadonlyArray) { + const uiFrameworkFlags: string[] = allFeatures + .filter((feature) => feature.category === "UI Framework") + .map((feature) => feature.flag); + return flags.find((flag) => uiFrameworkFlags.includes(flag)); +} + +export function isStorybookFrameworkSupported(uiFramework: string | undefined) { + return Boolean(uiFramework && supportedStorybookFrameworks.includes(uiFramework)); +} + +export async function initStorybook( + cwd: string, + packageManagerExec: string, + interactive: boolean = true, +): Promise { + let shouldUseDefaultConfig = !interactive; + + // Prompt user if they want to initialize Storybook + if (interactive) { + shouldUseDefaultConfig = await confirm({ + message: "Use default Storybook configuration?", + default: true, + }); + } + + // Run Storybook init with interactive questionnaire + const command = `${packageManagerExec} storybook@latest init --no-dev${shouldUseDefaultConfig ? " --yes" : ""}`; + execSync(command, { cwd, stdio: "inherit" }); + return true; +} + +export const storybookIntegration: Integration = { + flag: "storybook", + label: "Storybook", + arg: { + type: "boolean", + description: "If true, initializes Storybook in the generated app (React, Vue, Solid only)", + required: false, + }, + async run({ project, flags, allFeatures, packageManagerExec }) { + const uiFramework = getUiFrameworkFlag(flags, allFeatures); + + if (!isStorybookFrameworkSupported(uiFramework)) { + console.error(`${red("⚠")} The \`--storybook\` flag is currently supported only with React, Vue, or Solid.`); + process.exit(6); + } + + return await initStorybook(project, packageManagerExec); + }, + nextSteps(packageManagerRun) { + return [{ type: "command", step: `${packageManagerRun} storybook` }]; + }, +}; diff --git a/packages/cli/types.ts b/packages/cli/types.ts index 428496f29..771f1b101 100644 --- a/packages/cli/types.ts +++ b/packages/cli/types.ts @@ -1,5 +1,7 @@ import type { VikeMeta } from "@batijs/core"; -import type { BatiConfig } from "@batijs/core/config"; +import type { BatiConfig, BatiConfigStep } from "@batijs/core/config"; +import type { Feature } from "@batijs/features"; +import type { ArgDef } from "citty"; export interface BoilerplateDef { folder: string; @@ -17,3 +19,20 @@ export interface ToBeCopied extends BoilerplateDef { } export type Hook = (cwd: string, meta: VikeMeta) => Promise | void; + +export type BatiArgDef = ArgDef & { invisible?: boolean }; + +export interface IntegrationContext { + project: string; + flags: string[]; + allFeatures: ReadonlyArray; + packageManagerExec: string; +} + +export interface Integration { + flag: string; + label: string; + arg: BatiArgDef; + run: (context: IntegrationContext) => Promise | boolean | void; + nextSteps?: (packageManagerRun: string) => BatiConfigStep[]; +} diff --git a/packages/features/README.md b/packages/features/README.md index dd44f9f9d..19c016e10 100644 --- a/packages/features/README.md +++ b/packages/features/README.md @@ -2,4 +2,6 @@ All features that should be visible in the WebUI and the CLI are defined in [src/features.ts](src/features.ts). +In the CLI, feature flags also come from [src/features.ts](src/features.ts), but some non-feature CLI options can be provided by integrations (for example `--storybook`). + All rules (conflicts/dependencies between features, features in beta, etc.) are defined in [src/rules](src/rules). diff --git a/packages/tests/src/load-test-files.ts b/packages/tests/src/load-test-files.ts index 5659504fb..fdcb94853 100644 --- a/packages/tests/src/load-test-files.ts +++ b/packages/tests/src/load-test-files.ts @@ -35,7 +35,7 @@ export async function loadTestFileMatrix(filepath: string) { assert(Array.isArray(exclude), `\`exclude\` export in "${filepath}" must be of type \`string[][]\``); } - const validKeys = new Set(flags); + const validKeys = new Set([...flags, "storybook"]); for (const m of matrix as unknown[]) { if (Array.isArray(m)) { diff --git a/packages/tests/tests/FRAMEWORK+storybook.spec.ts b/packages/tests/tests/FRAMEWORK+storybook.spec.ts new file mode 100644 index 000000000..400cca35f --- /dev/null +++ b/packages/tests/tests/FRAMEWORK+storybook.spec.ts @@ -0,0 +1,41 @@ +import { describeBati } from "@batijs/tests-utils"; + +export const matrix = [["solid", "react", "vue"], "storybook", "eslint"]; + +await describeBati(({ test, expect }) => { + test("storybook config files", async () => { + const fs = await import("fs/promises"); + const path = await import("path"); + + const storybookDir = (await import("child_process")).execSync("pwd", { encoding: "utf-8" }).trim(); + + const configExtensions = ["ts", "js", "mjs", "cjs"]; + let configFileExists = false; + + for (const ext of configExtensions) { + const configPath = path.join(storybookDir, ".storybook", `main.${ext}`); + try { + await fs.access(configPath); + configFileExists = true; + break; + } catch { + // Continue to next extension + } + } + + expect(configFileExists).toBe(true); + }); + + test("storybook scripts", async () => { + const fs = await import("fs/promises"); + const path = await import("path"); + + const cwd = (await import("child_process")).execSync("pwd", { encoding: "utf-8" }).trim(); + + const packageJsonPath = path.join(cwd, "package.json"); + const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf-8")); + + expect(packageJson.scripts.storybook).toBeTruthy(); + expect(packageJson.scripts["build-storybook"]).toBeTruthy(); + }); +});