From 880ef7474191d3818fca14ad4909bb92a7b0b57e Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Tue, 24 Mar 2026 15:40:43 -0400 Subject: [PATCH 01/56] feat: codegen cli --- .github/workflows/init-nightly.yml | 48 +++ .github/workflows/init.yml | 56 ++++ docs/content/docs/cli.mdx | 47 ++- docs/content/docs/installation.mdx | 4 +- packages/cli/build.config.ts | 15 + packages/cli/package.json | 54 ++++ packages/cli/scripts/test-init.sh | 94 ++++++ packages/cli/src/commands/generate.ts | 18 ++ packages/cli/src/commands/init.ts | 299 ++++++++++++++++++ packages/cli/src/commands/migrate.ts | 18 ++ packages/cli/src/index.ts | 17 + .../cli/src/templates/nextjs/api-route.ts.hbs | 7 + .../src/templates/nextjs/pages-layout.tsx.hbs | 13 + .../src/templates/nextjs/pages-route.tsx.hbs | 27 ++ .../src/templates/nextjs/stack-client.tsx.hbs | 23 ++ .../cli/src/templates/nextjs/stack.ts.hbs | 22 ++ .../templates/react-router/api-route.ts.hbs | 10 + .../react-router/pages-route.tsx.hbs | 43 +++ .../react-router/stack-client.tsx.hbs | 23 ++ .../src/templates/react-router/stack.ts.hbs | 22 ++ .../templates/shared/lib/query-client.ts.hbs | 29 ++ .../src/templates/tanstack/api-route.ts.hbs | 14 + .../templates/tanstack/pages-route.tsx.hbs | 29 ++ .../templates/tanstack/stack-client.tsx.hbs | 23 ++ .../cli/src/templates/tanstack/stack.ts.hbs | 22 ++ packages/cli/src/types.ts | 46 +++ .../cli/src/utils/__tests__/detect.test.ts | 39 +++ .../cli/src/utils/__tests__/patchers.test.ts | 62 ++++ .../src/utils/__tests__/scaffold-plan.test.ts | 25 ++ packages/cli/src/utils/constants.ts | 135 ++++++++ packages/cli/src/utils/css-patcher.ts | 45 +++ packages/cli/src/utils/detect-alias.ts | 27 ++ packages/cli/src/utils/detect-css-file.ts | 30 ++ packages/cli/src/utils/detect-framework.ts | 34 ++ .../cli/src/utils/detect-package-manager.ts | 20 ++ .../cli/src/utils/detect-project-shape.ts | 44 +++ packages/cli/src/utils/file-writer.ts | 91 ++++++ packages/cli/src/utils/layout-patcher.ts | 122 +++++++ packages/cli/src/utils/package-installer.ts | 39 +++ packages/cli/src/utils/passthrough.ts | 43 +++ packages/cli/src/utils/render-template.ts | 40 +++ packages/cli/src/utils/scaffold-plan.ts | 220 +++++++++++++ .../cli/src/utils/validate-prerequisites.ts | 60 ++++ packages/cli/tsconfig.json | 10 + pnpm-lock.yaml | 176 ++++++++++- 45 files changed, 2282 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/init-nightly.yml create mode 100644 .github/workflows/init.yml create mode 100644 packages/cli/build.config.ts create mode 100644 packages/cli/package.json create mode 100644 packages/cli/scripts/test-init.sh create mode 100644 packages/cli/src/commands/generate.ts create mode 100644 packages/cli/src/commands/init.ts create mode 100644 packages/cli/src/commands/migrate.ts create mode 100644 packages/cli/src/index.ts create mode 100644 packages/cli/src/templates/nextjs/api-route.ts.hbs create mode 100644 packages/cli/src/templates/nextjs/pages-layout.tsx.hbs create mode 100644 packages/cli/src/templates/nextjs/pages-route.tsx.hbs create mode 100644 packages/cli/src/templates/nextjs/stack-client.tsx.hbs create mode 100644 packages/cli/src/templates/nextjs/stack.ts.hbs create mode 100644 packages/cli/src/templates/react-router/api-route.ts.hbs create mode 100644 packages/cli/src/templates/react-router/pages-route.tsx.hbs create mode 100644 packages/cli/src/templates/react-router/stack-client.tsx.hbs create mode 100644 packages/cli/src/templates/react-router/stack.ts.hbs create mode 100644 packages/cli/src/templates/shared/lib/query-client.ts.hbs create mode 100644 packages/cli/src/templates/tanstack/api-route.ts.hbs create mode 100644 packages/cli/src/templates/tanstack/pages-route.tsx.hbs create mode 100644 packages/cli/src/templates/tanstack/stack-client.tsx.hbs create mode 100644 packages/cli/src/templates/tanstack/stack.ts.hbs create mode 100644 packages/cli/src/types.ts create mode 100644 packages/cli/src/utils/__tests__/detect.test.ts create mode 100644 packages/cli/src/utils/__tests__/patchers.test.ts create mode 100644 packages/cli/src/utils/__tests__/scaffold-plan.test.ts create mode 100644 packages/cli/src/utils/constants.ts create mode 100644 packages/cli/src/utils/css-patcher.ts create mode 100644 packages/cli/src/utils/detect-alias.ts create mode 100644 packages/cli/src/utils/detect-css-file.ts create mode 100644 packages/cli/src/utils/detect-framework.ts create mode 100644 packages/cli/src/utils/detect-package-manager.ts create mode 100644 packages/cli/src/utils/detect-project-shape.ts create mode 100644 packages/cli/src/utils/file-writer.ts create mode 100644 packages/cli/src/utils/layout-patcher.ts create mode 100644 packages/cli/src/utils/package-installer.ts create mode 100644 packages/cli/src/utils/passthrough.ts create mode 100644 packages/cli/src/utils/render-template.ts create mode 100644 packages/cli/src/utils/scaffold-plan.ts create mode 100644 packages/cli/src/utils/validate-prerequisites.ts create mode 100644 packages/cli/tsconfig.json diff --git a/.github/workflows/init-nightly.yml b/.github/workflows/init-nightly.yml new file mode 100644 index 00000000..62fb7cbc --- /dev/null +++ b/.github/workflows/init-nightly.yml @@ -0,0 +1,48 @@ +name: BTST Init CLI Nightly + +on: + schedule: + - cron: '0 6 * * *' + workflow_dispatch: + +jobs: + nightly-init: + name: Nightly init smoke + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js 22 + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Build @btst/stack + run: pnpm --filter @btst/stack build + + - name: Build @btst/codegen + run: pnpm --filter @btst/codegen build + + - name: Run nightly init harness + working-directory: packages/cli + run: bash scripts/test-init.sh + timeout-minutes: 30 + env: + CI: true + + - name: Upload artifacts on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: btst-init-nightly-fixtures + path: /tmp/test-btst-init-*/ + retention-days: 5 diff --git a/.github/workflows/init.yml b/.github/workflows/init.yml new file mode 100644 index 00000000..7843191b --- /dev/null +++ b/.github/workflows/init.yml @@ -0,0 +1,56 @@ +name: BTST Init CLI + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'packages/cli/**' + - 'docs/content/docs/cli.mdx' + - 'docs/content/docs/installation.mdx' + - '.github/workflows/init.yml' + +concurrency: + group: init-cli-${{ github.ref }} + cancel-in-progress: true + +jobs: + init-smoke: + name: Init smoke (nextjs + memory) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js 22 + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Build @btst/stack + run: pnpm --filter @btst/stack build + + - name: Build @btst/codegen + run: pnpm --filter @btst/codegen build + + - name: Run init harness + working-directory: packages/cli + run: bash scripts/test-init.sh + timeout-minutes: 20 + env: + CI: true + + - name: Upload artifacts on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: btst-init-fixtures + path: /tmp/test-btst-init-*/ + retention-days: 3 diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index 244fb555..85e22cbd 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -7,7 +7,52 @@ import { Tabs, Tab } from "fumadocs-ui/components/tabs"; import { Callout } from "fumadocs-ui/components/callout"; -The BTST CLI (`@btst/cli`) helps you generate database schemas and migrations from your plugin `dbSchema` exports. +BTST has two CLI packages: + +- `@btst/codegen` owns `init` scaffolding (`npx @btst/codegen init`) +- `@btst/cli` owns low-level DB schema generation and migrations + +`@btst/codegen generate` and `@btst/codegen migrate` are passthrough entrypoints to the existing `@btst/cli` flow. + +## Init (Codegen) + +Use `init` to scaffold BTST into an existing Next.js (App Router), React Router v7, or TanStack Start project: + +```bash +npx @btst/codegen init +``` + +Common flags: + +| Flag | Description | +|------|-------------| +| `--framework` | `nextjs`, `react-router`, or `tanstack` | +| `--adapter` | `memory`, `prisma`, `drizzle`, `kysely`, or `mongodb` | +| `--cwd` | Target directory | +| `--skip-install` | Skip package installation step | +| `--yes` | Non-interactive defaults (useful in CI) | + +`init` scaffolds and patches: + +- `lib/stack.ts` (or framework equivalent) +- `lib/stack-client.tsx` +- `lib/query-client.ts` +- API catch-all route and pages catch-all route +- Global CSS imports (including plugin CSS) +- Root layout with `QueryClientProvider` where possible + +If root layout patching is not safe for your file shape, the command prints manual patch instructions instead of applying a destructive rewrite. + +## Generate and Migrate via Codegen + +If you prefer one command surface, these delegate to `@btst/cli`: + +```bash +npx @btst/codegen generate --orm=prisma --config=lib/stack.ts --output=schema.prisma +npx @btst/codegen migrate --config=lib/stack.ts --database-url=postgres://... +``` + +When a delegated command fails, fix the underlying issue and run the equivalent `npx @btst/cli ...` command directly. ## About Better DB diff --git a/docs/content/docs/installation.mdx b/docs/content/docs/installation.mdx index 8449717f..04f15f91 100644 --- a/docs/content/docs/installation.mdx +++ b/docs/content/docs/installation.mdx @@ -337,7 +337,9 @@ In order to use BTST, your application must meet the following requirements: - See the [CLI documentation](/cli) to learn more about generating database schemas and migrations. + See the [CLI documentation](/cli) for both: + - `npx @btst/codegen init` project scaffolding + - `@btst/cli` schema generation and migration commands. diff --git a/packages/cli/build.config.ts b/packages/cli/build.config.ts new file mode 100644 index 00000000..c4e7b6ab --- /dev/null +++ b/packages/cli/build.config.ts @@ -0,0 +1,15 @@ +import { defineBuildConfig } from "unbuild"; + +export default defineBuildConfig({ + declaration: true, + clean: true, + outDir: "dist", + entries: ["./src/index.ts"], + rollup: { + emitCJS: true, + esbuild: { + target: "node22", + }, + }, + externals: [], +}); diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 00000000..6897c000 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,54 @@ +{ + "name": "@btst/codegen", + "version": "0.1.0", + "description": "BTST project scaffolding and CLI passthrough commands.", + "repository": { + "type": "git", + "url": "git+https://github.com/better-stack-ai/better-stack.git" + }, + "keywords": [ + "btst", + "cli", + "codegen", + "scaffold" + ], + "author": "olliethedev", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "type": "module", + "bin": { + "btst": "./dist/index.cjs" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "src", + "scripts" + ], + "scripts": { + "build": "unbuild --clean", + "stub": "unbuild --stub", + "dev": "tsx src/index.ts", + "typecheck": "tsc --project tsconfig.json", + "test": "vitest run", + "test:init": "bash scripts/test-init.sh" + }, + "dependencies": { + "@clack/prompts": "^0.11.0", + "commander": "^14.0.1", + "execa": "^9.6.0", + "handlebars": "^4.7.8", + "ts-morph": "^27.0.2" + }, + "devDependencies": { + "@types/node": "^24.9.2", + "tsx": "catalog:", + "typescript": "catalog:", + "unbuild": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/cli/scripts/test-init.sh b/packages/cli/scripts/test-init.sh new file mode 100644 index 00000000..ef0cc93f --- /dev/null +++ b/packages/cli/scripts/test-init.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +set -euo pipefail + +RED='\033[0;31m'; GREEN='\033[0;32m'; BLUE='\033[0;34m'; YELLOW='\033[1;33m'; NC='\033[0m' +step() { echo -e "\n${BLUE}== $1 ==${NC}"; } +success() { echo -e "${GREEN}✓ $1${NC}"; } +warn() { echo -e "${YELLOW}⚠ $1${NC}"; } +error() { echo -e "${RED}✗ $1${NC}"; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PACKAGE_DIR="$(dirname "$SCRIPT_DIR")" +ROOT_DIR="$(cd "$PACKAGE_DIR/../.." && pwd)" +TEST_DIR="/tmp/test-btst-init-$(date +%s)" +TEST_PASSED=false + +cleanup() { + if [ "$TEST_PASSED" = true ]; then + rm -rf "$TEST_DIR" + else + warn "Fixture preserved for debugging: $TEST_DIR" + fi +} +trap cleanup EXIT + +step "Packing local tarballs" +cd "$ROOT_DIR/packages/stack" +STACK_TGZ=$(npm pack --quiet 2>/dev/null | tr -d '[:space:]') +STACK_TARBALL="$ROOT_DIR/packages/stack/$STACK_TGZ" +test -f "$STACK_TARBALL" +success "Packed @btst/stack -> $(basename "$STACK_TARBALL")" + +cd "$ROOT_DIR/packages/cli" +CODEGEN_TGZ=$(npm pack --quiet 2>/dev/null | tr -d '[:space:]') +CODEGEN_TARBALL="$ROOT_DIR/packages/cli/$CODEGEN_TGZ" +test -f "$CODEGEN_TARBALL" +success "Packed @btst/codegen -> $(basename "$CODEGEN_TARBALL")" + +step "Creating Next.js fixture" +mkdir -p "$TEST_DIR" +cd "$TEST_DIR" +npx --yes create-next-app@latest app \ + --typescript \ + --tailwind \ + --eslint \ + --app \ + --use-npm \ + --yes +cd "$TEST_DIR/app" +echo "legacy-peer-deps=true" > .npmrc +success "Fixture created at $TEST_DIR/app" + +step "Installing packed tarballs" +npm install "$STACK_TARBALL" "$CODEGEN_TARBALL" --legacy-peer-deps +success "Installed local @btst/stack and @btst/codegen" + +step "Running btst init (first pass)" +npx @btst/codegen init --yes --framework nextjs --adapter memory --skip-install 2>&1 | tee "$TEST_DIR/init-first.log" +if ! node -e 'const fs=require("fs");const s=fs.readFileSync(process.argv[1],"utf8");process.exit(s.includes("Running @btst/codegen init")?0:1)' "$TEST_DIR/init-first.log"; then + error "Expected runtime banner not found in init output" + exit 1 +fi +success "First init run completed" + +step "Installing runtime deps needed for generated files" +npm install @tanstack/react-query @btst/adapter-memory @btst/yar --legacy-peer-deps +success "Installed runtime deps" + +step "Asserting generated files and patches" +test -f "lib/stack.ts" +test -f "lib/stack-client.tsx" +test -f "lib/query-client.ts" +test -f "app/api/data/[[...all]]/route.ts" +test -f "app/pages/[[...all]]/page.tsx" +test -f "app/pages/layout.tsx" +node -e 'const fs=require("fs");const s=fs.readFileSync("lib/stack.ts","utf8");process.exit(s.includes("import { stack } from \"@btst/stack\"")?0:1)' +success "Generation + patch checks passed" + +step "Idempotency check (second pass)" +git init > /dev/null +git add . +git commit -m "baseline" > /dev/null +npx @btst/codegen init --yes --framework nextjs --adapter memory --skip-install > "$TEST_DIR/init-second.log" 2>&1 +if ! git diff --exit-code > /dev/null; then + error "Second init run produced file changes" + exit 1 +fi +success "Second run was idempotent" + +step "Compiling fixture project" +npm run build +success "Fixture build succeeded" + +TEST_PASSED=true +success "All init checks passed" diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts new file mode 100644 index 00000000..cf396a5a --- /dev/null +++ b/packages/cli/src/commands/generate.ts @@ -0,0 +1,18 @@ +import { Command } from "commander"; +import { runCliPassthrough } from "../utils/passthrough"; + +export function createGenerateCommand() { + return new Command("generate") + .description("Passthrough to @btst/cli generate") + .allowUnknownOption(true) + .allowExcessArguments(true) + .argument("[args...]", "Arguments forwarded to @btst/cli generate") + .action(async (args: string[] = []) => { + const code = await runCliPassthrough({ + cwd: process.cwd(), + command: "generate", + args, + }); + process.exitCode = code; + }); +} diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts new file mode 100644 index 00000000..abd5f96f --- /dev/null +++ b/packages/cli/src/commands/init.ts @@ -0,0 +1,299 @@ +import { access } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + cancel, + confirm, + intro, + isCancel, + multiselect, + outro, + select, + text, +} from "@clack/prompts"; +import { Command } from "commander"; +import { + ADAPTERS, + DEFAULT_PLUGIN_SELECTION, + PLUGINS, +} from "../utils/constants"; +import { detectAlias } from "../utils/detect-alias"; +import { detectCssFile } from "../utils/detect-css-file"; +import { detectFramework } from "../utils/detect-framework"; +import { detectPackageManager } from "../utils/detect-package-manager"; +import { detectProjectShape } from "../utils/detect-project-shape"; +import { writePlannedFiles, type ConflictPolicy } from "../utils/file-writer"; +import { patchCssImports } from "../utils/css-patcher"; +import { patchLayoutWithQueryClientProvider } from "../utils/layout-patcher"; +import { installInitDependencies } from "../utils/package-installer"; +import { + adapterNeedsGenerate, + getGenerateHintForAdapter, + runCliPassthrough, +} from "../utils/passthrough"; +import { buildScaffoldPlan } from "../utils/scaffold-plan"; +import { collectPrerequisiteWarnings } from "../utils/validate-prerequisites"; +import type { Adapter, Framework, PluginKey } from "../types"; + +interface InitCliOptions { + cwd?: string; + framework?: Framework; + adapter?: Adapter; + yes?: boolean; + skipInstall?: boolean; +} + +function ensureNotCancelled(value: T | symbol): T { + if (isCancel(value)) { + cancel("Init cancelled."); + process.exit(1); + } + return value as T; +} + +async function detectOrSelectFramework( + cwd: string, + options: InitCliOptions, +): Promise { + if (options.framework) return options.framework; + + const detected = await detectFramework(cwd); + if (detected) { + const accepted = ensureNotCancelled( + await confirm({ + message: `Detected framework: ${detected}. Use this?`, + initialValue: true, + }), + ); + if (accepted) return detected; + } + + return ensureNotCancelled( + await select({ + message: "Select framework", + options: [ + { label: "Next.js (App Router)", value: "nextjs" as const }, + { label: "React Router v7", value: "react-router" as const }, + { label: "TanStack Start", value: "tanstack" as const }, + ], + }), + ); +} + +async function detectOrSelectAdapter( + options: InitCliOptions, +): Promise { + if (options.adapter) return options.adapter; + return ensureNotCancelled( + await select({ + message: "Select adapter", + options: ADAPTERS.map((adapter) => ({ + label: adapter.label, + value: adapter.key, + })), + }), + ); +} + +async function selectPlugins(options: InitCliOptions): Promise { + if (options.yes) return DEFAULT_PLUGIN_SELECTION; + + const plugins = ensureNotCancelled( + await multiselect({ + message: "Select plugins to scaffold", + required: false, + options: PLUGINS.map((plugin) => ({ + label: plugin.label, + value: plugin.key, + hint: plugin.key, + })), + initialValues: DEFAULT_PLUGIN_SELECTION, + }), + ); + + return plugins as PluginKey[]; +} + +async function pickConflictPolicy( + yes: boolean | undefined, +): Promise { + if (yes) return "overwrite"; + const value = ensureNotCancelled( + await select({ + message: "When generated files already exist", + options: [ + { label: "Ask me per file", value: "ask" as const }, + { label: "Skip existing files", value: "skip" as const }, + { label: "Overwrite existing files", value: "overwrite" as const }, + ], + }), + ); + return value; +} + +export function createInitCommand() { + return new Command("init") + .description("Scaffold BTST into an existing app") + .option("--framework ", "nextjs | react-router | tanstack") + .option( + "--adapter ", + "memory | prisma | drizzle | kysely | mongodb", + ) + .option("--skip-install", "Skip dependency install") + .option("--cwd ", "Target project directory") + .option("--yes", "Accept defaults and skip prompts") + .action(async (rawOptions: InitCliOptions) => { + intro("Running @btst/codegen init"); + + const cwd = resolve(rawOptions.cwd ?? process.cwd()); + try { + await access(resolve(cwd, "package.json")); + } catch { + cancel(`No package.json found in ${cwd}`); + process.exit(1); + } + + const framework = await detectOrSelectFramework(cwd, rawOptions); + const shape = await detectProjectShape(cwd, framework); + if (!shape.ok) { + cancel( + `Project does not match expected ${framework} shape. Missing: ${shape.missingPaths.join(", ")}`, + ); + process.exit(1); + } + + const packageManager = await detectPackageManager(cwd); + const adapter = await detectOrSelectAdapter(rawOptions); + const alias = await detectAlias(cwd); + const selectedPlugins = await selectPlugins(rawOptions); + + let cssFile = await detectCssFile(cwd, framework); + if (!cssFile) { + cssFile = rawOptions.yes + ? framework === "nextjs" + ? "app/globals.css" + : framework === "react-router" + ? "app/app.css" + : "src/styles/globals.css" + : ensureNotCancelled( + await text({ + message: "Could not detect global CSS file path. Enter it:", + placeholder: "app/globals.css", + }), + ); + } else if (!rawOptions.yes) { + const keepDetected = ensureNotCancelled( + await confirm({ + message: `Use detected CSS file ${cssFile}?`, + initialValue: true, + }), + ); + if (!keepDetected) { + cssFile = ensureNotCancelled( + await text({ + message: "Enter CSS file path", + initialValue: cssFile, + }), + ); + } + } + + const conflictPolicy = await pickConflictPolicy(rawOptions.yes); + const finalCssFile = cssFile as string; + const plan = await buildScaffoldPlan({ + framework, + adapter, + plugins: selectedPlugins, + alias, + cssFile: finalCssFile, + }); + + const writeResult = await writePlannedFiles( + cwd, + plan.files, + conflictPolicy, + ); + const cssImports = PLUGINS.filter((plugin) => + selectedPlugins.includes(plugin.key), + ).map((plugin) => plugin.cssImport); + const cssPatch = await patchCssImports( + cwd, + plan.cssPatchTarget, + cssImports, + ); + const layoutPatch = + framework === "nextjs" + ? { updated: false as const } + : await patchLayoutWithQueryClientProvider( + cwd, + plan.layoutPatchTarget, + alias, + ); + + await installInitDependencies({ + cwd, + packageManager, + adapter, + skipInstall: rawOptions.skipInstall, + }); + + if (layoutPatch.warning) { + console.warn(`\n${layoutPatch.warning}\n`); + } + + const prerequisiteWarnings = await collectPrerequisiteWarnings(cwd); + if (prerequisiteWarnings.length > 0) { + console.warn("\nWarnings:"); + for (const warning of prerequisiteWarnings) { + console.warn(`- ${warning}`); + } + } + + if (adapterNeedsGenerate(adapter)) { + const hint = getGenerateHintForAdapter(adapter); + if (hint) { + const runNow = rawOptions.yes + ? false + : ensureNotCancelled( + await confirm({ + message: `Run generate now? (${hint})`, + initialValue: true, + }), + ); + if (runNow) { + const orm = ADAPTERS.find( + (item) => item.key === adapter, + )?.ormForGenerate; + const stackPath = + plan.files.find((file) => file.path.endsWith("lib/stack.ts")) + ?.path ?? + (framework === "react-router" + ? "app/lib/stack.ts" + : "src/lib/stack.ts"); + const args = orm + ? [`--orm=${orm}`, `--config=${stackPath}`] + : [`--config=${stackPath}`]; + const exitCode = await runCliPassthrough({ + cwd, + command: "generate", + args, + }); + if (exitCode !== 0) { + process.exitCode = exitCode; + } + } + } + } + + outro(`BTST init complete. +Files written: ${writeResult.written.length} +Files skipped: ${writeResult.skipped.length} +CSS updated: ${cssPatch.updated ? "yes" : "no"} +Layout patched: ${layoutPatch.updated ? "yes" : "manual action may be needed"} + +Next steps: +- Verify routes under /pages/* +- Run your build +- Use npx @btst/codegen generate or npx @btst/codegen migrate as needed +`); + }); +} diff --git a/packages/cli/src/commands/migrate.ts b/packages/cli/src/commands/migrate.ts new file mode 100644 index 00000000..151603cc --- /dev/null +++ b/packages/cli/src/commands/migrate.ts @@ -0,0 +1,18 @@ +import { Command } from "commander"; +import { runCliPassthrough } from "../utils/passthrough"; + +export function createMigrateCommand() { + return new Command("migrate") + .description("Passthrough to @btst/cli migrate") + .allowUnknownOption(true) + .allowExcessArguments(true) + .argument("[args...]", "Arguments forwarded to @btst/cli migrate") + .action(async (args: string[] = []) => { + const code = await runCliPassthrough({ + cwd: process.cwd(), + command: "migrate", + args, + }); + process.exitCode = code; + }); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 00000000..b7994cc8 --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,17 @@ +#!/usr/bin/env node +import { Command } from "commander"; +import { createGenerateCommand } from "./commands/generate"; +import { createInitCommand } from "./commands/init"; +import { createMigrateCommand } from "./commands/migrate"; + +const program = new Command(); + +program.name("btst").description("BTST codegen CLI"); +program.addCommand(createInitCommand()); +program.addCommand(createGenerateCommand()); +program.addCommand(createMigrateCommand()); + +program.parseAsync(process.argv).catch((error: unknown) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/cli/src/templates/nextjs/api-route.ts.hbs b/packages/cli/src/templates/nextjs/api-route.ts.hbs new file mode 100644 index 00000000..de220aca --- /dev/null +++ b/packages/cli/src/templates/nextjs/api-route.ts.hbs @@ -0,0 +1,7 @@ +import { handler } from "{{alias}}lib/stack" + +export const GET = handler +export const POST = handler +export const PUT = handler +export const PATCH = handler +export const DELETE = handler diff --git a/packages/cli/src/templates/nextjs/pages-layout.tsx.hbs b/packages/cli/src/templates/nextjs/pages-layout.tsx.hbs new file mode 100644 index 00000000..9a6f33ea --- /dev/null +++ b/packages/cli/src/templates/nextjs/pages-layout.tsx.hbs @@ -0,0 +1,13 @@ +"use client" + +import { QueryClientProvider } from "@tanstack/react-query" +import { getOrCreateQueryClient } from "{{alias}}lib/query-client" + +export default function BtstPagesLayout({ + children, +}: { + children: React.ReactNode +}) { + const queryClient = getOrCreateQueryClient() + return {children} +} diff --git a/packages/cli/src/templates/nextjs/pages-route.tsx.hbs b/packages/cli/src/templates/nextjs/pages-route.tsx.hbs new file mode 100644 index 00000000..74a8485f --- /dev/null +++ b/packages/cli/src/templates/nextjs/pages-route.tsx.hbs @@ -0,0 +1,27 @@ +import { dehydrate, HydrationBoundary } from "@tanstack/react-query" +import { normalizePath } from "@btst/stack/client" +import { notFound } from "next/navigation" +import { getOrCreateQueryClient } from "{{alias}}lib/query-client" +import { getStackClient } from "{{alias}}lib/stack-client" + +export default async function BtstPagesRoute({ + params, +}: { + params: Promise<{ all?: string[] }> +}) { + const pathParams = await params + const path = normalizePath(pathParams?.all) + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(path) + + if (route?.loader) { + await route.loader() + } + + return ( + + {route?.PageComponent ? : notFound()} + + ) +} diff --git a/packages/cli/src/templates/nextjs/stack-client.tsx.hbs b/packages/cli/src/templates/nextjs/stack-client.tsx.hbs new file mode 100644 index 00000000..94f4af68 --- /dev/null +++ b/packages/cli/src/templates/nextjs/stack-client.tsx.hbs @@ -0,0 +1,23 @@ +import { createStackClient } from "@btst/stack/client" +import { QueryClient } from "@tanstack/react-query" +{{#if clientImports}} +{{{clientImports}}} +{{/if}} + +const getBaseURL = () => + typeof window !== "undefined" + ? process.env.NEXT_PUBLIC_BASE_URL || window.location.origin + : process.env.BASE_URL || "http://localhost:3000" + +export function getStackClient(queryClient: QueryClient) { + const baseURL = getBaseURL() + return createStackClient({ + plugins: { +{{#if clientEntries}} +{{clientEntries}} +{{else}} + // Add client plugins here. +{{/if}} + }, + }) +} diff --git a/packages/cli/src/templates/nextjs/stack.ts.hbs b/packages/cli/src/templates/nextjs/stack.ts.hbs new file mode 100644 index 00000000..fe88eafe --- /dev/null +++ b/packages/cli/src/templates/nextjs/stack.ts.hbs @@ -0,0 +1,22 @@ +import { stack } from "@btst/stack" +{{{adapterImport}}} +{{#if backendImports}} +{{{backendImports}}} +{{/if}} + +{{#if adapterSetup}} +{{{adapterSetup}}} +{{/if}} +const myStack = stack({ + basePath: "/api/data", + plugins: { +{{#if backendEntries}} +{{backendEntries}} +{{else}} + // Add backend plugins here. +{{/if}} + }, + {{{adapterStackLine}}} +}) + +export const { handler, dbSchema } = myStack diff --git a/packages/cli/src/templates/react-router/api-route.ts.hbs b/packages/cli/src/templates/react-router/api-route.ts.hbs new file mode 100644 index 00000000..38ce4346 --- /dev/null +++ b/packages/cli/src/templates/react-router/api-route.ts.hbs @@ -0,0 +1,10 @@ +import type { Route } from "./+types/route" +import { handler } from "{{alias}}lib/stack" + +export function loader({ request }: Route.LoaderArgs) { + return handler(request) +} + +export function action({ request }: Route.ActionArgs) { + return handler(request) +} diff --git a/packages/cli/src/templates/react-router/pages-route.tsx.hbs b/packages/cli/src/templates/react-router/pages-route.tsx.hbs new file mode 100644 index 00000000..cb7940f6 --- /dev/null +++ b/packages/cli/src/templates/react-router/pages-route.tsx.hbs @@ -0,0 +1,43 @@ +import type { Route } from "./+types/index" +import { useLoaderData, useRouteError } from "react-router" +import { dehydrate, HydrationBoundary, QueryClient, useQueryClient } from "@tanstack/react-query" +import { normalizePath } from "@btst/stack/client" +import { getStackClient } from "{{alias}}lib/stack-client" + +export async function loader({ params }: Route.LoaderArgs) { + const queryClient = new QueryClient() + const path = normalizePath(params["*"]) + const route = getStackClient(queryClient).router.getRoute(path) + + if (route?.loader) { + await route.loader() + } + + return { + path, + dehydratedState: dehydrate(queryClient), + meta: route?.meta?.(), + } +} + +export function meta({ loaderData }: Route.MetaArgs) { + return loaderData.meta +} + +export default function BtstPagesRoute() { + const data = useLoaderData() + const queryClient = useQueryClient() + const route = getStackClient(queryClient).router.getRoute(data.path) + const page = route?.PageComponent ? :
Route not found
+ + return ( + + {page} + + ) +} + +export function ErrorBoundary() { + const error = useRouteError() + return
{String(error)}
+} diff --git a/packages/cli/src/templates/react-router/stack-client.tsx.hbs b/packages/cli/src/templates/react-router/stack-client.tsx.hbs new file mode 100644 index 00000000..00baa3f5 --- /dev/null +++ b/packages/cli/src/templates/react-router/stack-client.tsx.hbs @@ -0,0 +1,23 @@ +import { createStackClient } from "@btst/stack/client" +import { QueryClient } from "@tanstack/react-query" +{{#if clientImports}} +{{{clientImports}}} +{{/if}} + +const getBaseURL = () => + typeof window !== "undefined" + ? import.meta.env.VITE_BASE_URL || window.location.origin + : process.env.BASE_URL || "http://localhost:5173" + +export function getStackClient(queryClient: QueryClient) { + const baseURL = getBaseURL() + return createStackClient({ + plugins: { +{{#if clientEntries}} +{{clientEntries}} +{{else}} + // Add client plugins here. +{{/if}} + }, + }) +} diff --git a/packages/cli/src/templates/react-router/stack.ts.hbs b/packages/cli/src/templates/react-router/stack.ts.hbs new file mode 100644 index 00000000..fe88eafe --- /dev/null +++ b/packages/cli/src/templates/react-router/stack.ts.hbs @@ -0,0 +1,22 @@ +import { stack } from "@btst/stack" +{{{adapterImport}}} +{{#if backendImports}} +{{{backendImports}}} +{{/if}} + +{{#if adapterSetup}} +{{{adapterSetup}}} +{{/if}} +const myStack = stack({ + basePath: "/api/data", + plugins: { +{{#if backendEntries}} +{{backendEntries}} +{{else}} + // Add backend plugins here. +{{/if}} + }, + {{{adapterStackLine}}} +}) + +export const { handler, dbSchema } = myStack diff --git a/packages/cli/src/templates/shared/lib/query-client.ts.hbs b/packages/cli/src/templates/shared/lib/query-client.ts.hbs new file mode 100644 index 00000000..ee00cb1b --- /dev/null +++ b/packages/cli/src/templates/shared/lib/query-client.ts.hbs @@ -0,0 +1,29 @@ +import { QueryClient, isServer } from "@tanstack/react-query" +import { cache } from "react" + +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: isServer ? 60 * 1000 : 0, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: false, + }, + dehydrate: { + shouldDehydrateQuery: () => true, + }, + }, + }) +} + +let browserQueryClient: QueryClient | undefined + +const getServerQueryClient = cache(() => makeQueryClient()) + +export function getOrCreateQueryClient() { + if (isServer) return getServerQueryClient() + if (!browserQueryClient) browserQueryClient = makeQueryClient() + return browserQueryClient +} diff --git a/packages/cli/src/templates/tanstack/api-route.ts.hbs b/packages/cli/src/templates/tanstack/api-route.ts.hbs new file mode 100644 index 00000000..79aeedf1 --- /dev/null +++ b/packages/cli/src/templates/tanstack/api-route.ts.hbs @@ -0,0 +1,14 @@ +import { createFileRoute } from "@tanstack/react-router" +import { handler } from "{{alias}}lib/stack" + +export const Route = createFileRoute("/api/data/$")({ + server: { + handlers: { + GET: async ({ request }) => handler(request), + POST: async ({ request }) => handler(request), + PUT: async ({ request }) => handler(request), + PATCH: async ({ request }) => handler(request), + DELETE: async ({ request }) => handler(request), + }, + }, +}) diff --git a/packages/cli/src/templates/tanstack/pages-route.tsx.hbs b/packages/cli/src/templates/tanstack/pages-route.tsx.hbs new file mode 100644 index 00000000..2f0b5e75 --- /dev/null +++ b/packages/cli/src/templates/tanstack/pages-route.tsx.hbs @@ -0,0 +1,29 @@ +import { createFileRoute, notFound } from "@tanstack/react-router" +import { normalizePath } from "@btst/stack/client" +import { getStackClient } from "{{alias}}lib/stack-client" + +export const Route = createFileRoute("/pages/$")({ + ssr: true, + component: BtstPagesRoute, + loader: async ({ params, context }) => { + const routePath = normalizePath(params._splat) + const route = getStackClient(context.queryClient).router.getRoute(routePath) + if (!route) throw notFound() + if (route.loader) await route.loader() + return { meta: route.meta?.() } + }, + head: ({ loaderData }) => { + if (!loaderData?.meta || !Array.isArray(loaderData.meta)) { + return { title: "No Meta", meta: [{ title: "No Meta" }] } + } + return { meta: loaderData.meta } + }, +}) + +function BtstPagesRoute() { + const params = Route.useParams() + const context = Route.useRouteContext() + const routePath = normalizePath(params._splat) + const route = getStackClient(context.queryClient).router.getRoute(routePath) + return route?.PageComponent ? :
Route not found
+} diff --git a/packages/cli/src/templates/tanstack/stack-client.tsx.hbs b/packages/cli/src/templates/tanstack/stack-client.tsx.hbs new file mode 100644 index 00000000..6e834b0c --- /dev/null +++ b/packages/cli/src/templates/tanstack/stack-client.tsx.hbs @@ -0,0 +1,23 @@ +import { createStackClient } from "@btst/stack/client" +import { QueryClient } from "@tanstack/react-query" +{{#if clientImports}} +{{{clientImports}}} +{{/if}} + +const getBaseURL = () => + typeof window !== "undefined" + ? import.meta.env.VITE_BASE_URL || window.location.origin + : process.env.BASE_URL || "http://localhost:3000" + +export function getStackClient(queryClient: QueryClient) { + const baseURL = getBaseURL() + return createStackClient({ + plugins: { +{{#if clientEntries}} +{{clientEntries}} +{{else}} + // Add client plugins here. +{{/if}} + }, + }) +} diff --git a/packages/cli/src/templates/tanstack/stack.ts.hbs b/packages/cli/src/templates/tanstack/stack.ts.hbs new file mode 100644 index 00000000..fe88eafe --- /dev/null +++ b/packages/cli/src/templates/tanstack/stack.ts.hbs @@ -0,0 +1,22 @@ +import { stack } from "@btst/stack" +{{{adapterImport}}} +{{#if backendImports}} +{{{backendImports}}} +{{/if}} + +{{#if adapterSetup}} +{{{adapterSetup}}} +{{/if}} +const myStack = stack({ + basePath: "/api/data", + plugins: { +{{#if backendEntries}} +{{backendEntries}} +{{else}} + // Add backend plugins here. +{{/if}} + }, + {{{adapterStackLine}}} +}) + +export const { handler, dbSchema } = myStack diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts new file mode 100644 index 00000000..75894856 --- /dev/null +++ b/packages/cli/src/types.ts @@ -0,0 +1,46 @@ +export type Framework = "nextjs" | "react-router" | "tanstack"; + +export type Adapter = "memory" | "prisma" | "drizzle" | "kysely" | "mongodb"; + +export type PluginKey = + | "blog" + | "ai-chat" + | "cms" + | "form-builder" + | "ui-builder" + | "kanban" + | "comments" + | "media"; + +export type PackageManager = "pnpm" | "npm" | "yarn"; + +export type AliasPrefix = "@/" | "~/" | "./"; + +export interface InitOptions { + cwd?: string; + framework?: Framework; + adapter?: Adapter; + plugins?: PluginKey[]; + yes?: boolean; + skipInstall?: boolean; +} + +export interface ProjectContext { + cwd: string; + framework: Framework; + packageManager: PackageManager; + alias: AliasPrefix; + cssFile: string; +} + +export interface FileWritePlanItem { + path: string; + content: string; + description: string; +} + +export interface ScaffoldPlan { + files: FileWritePlanItem[]; + layoutPatchTarget: string; + cssPatchTarget: string; +} diff --git a/packages/cli/src/utils/__tests__/detect.test.ts b/packages/cli/src/utils/__tests__/detect.test.ts new file mode 100644 index 00000000..ab99fb20 --- /dev/null +++ b/packages/cli/src/utils/__tests__/detect.test.ts @@ -0,0 +1,39 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { detectAlias } from "../detect-alias"; +import { detectFramework } from "../detect-framework"; +import { detectPackageManager } from "../detect-package-manager"; + +async function makeTempProject(name: string): Promise { + const dir = join(tmpdir(), `btst-cli-${name}-${Date.now()}`); + await mkdir(dir, { recursive: true }); + return dir; +} + +describe("detection utilities", () => { + it("detects nextjs framework", async () => { + const cwd = await makeTempProject("framework-next"); + await writeFile( + join(cwd, "package.json"), + JSON.stringify({ dependencies: { next: "15.0.0" } }), + ); + await expect(detectFramework(cwd)).resolves.toBe("nextjs"); + }); + + it("detects pnpm from lockfile", async () => { + const cwd = await makeTempProject("package-manager"); + await writeFile(join(cwd, "pnpm-lock.yaml"), "lockfileVersion: 9"); + await expect(detectPackageManager(cwd)).resolves.toBe("pnpm"); + }); + + it("detects alias from tsconfig paths", async () => { + const cwd = await makeTempProject("alias"); + await writeFile( + join(cwd, "tsconfig.json"), + JSON.stringify({ compilerOptions: { paths: { "@/*": ["./src/*"] } } }), + ); + await expect(detectAlias(cwd)).resolves.toBe("@/"); + }); +}); diff --git a/packages/cli/src/utils/__tests__/patchers.test.ts b/packages/cli/src/utils/__tests__/patchers.test.ts new file mode 100644 index 00000000..edbe5fed --- /dev/null +++ b/packages/cli/src/utils/__tests__/patchers.test.ts @@ -0,0 +1,62 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { patchCssImports } from "../css-patcher"; +import { patchLayoutWithQueryClientProvider } from "../layout-patcher"; + +async function makeTempProject(name: string): Promise { + const dir = join(tmpdir(), `btst-cli-${name}-${Date.now()}`); + await mkdir(dir, { recursive: true }); + return dir; +} + +describe("patchers", () => { + it("patches css imports idempotently", async () => { + const cwd = await makeTempProject("css-patch"); + await mkdir(join(cwd, "app"), { recursive: true }); + const cssPath = join(cwd, "app/globals.css"); + await writeFile(cssPath, '@import "tailwindcss";\n'); + + await patchCssImports(cwd, "app/globals.css", [ + "test/base.css", + "test/plugin.css", + ]); + const first = await readFile(cssPath, "utf8"); + expect(first).toContain('@import "test/base.css";'); + + await patchCssImports(cwd, "app/globals.css", [ + "test/base.css", + "test/plugin.css", + ]); + const second = await readFile(cssPath, "utf8"); + expect(second.match(/test\/base\.css/g)?.length).toBe(1); + }); + + it("patches layout with QueryClientProvider", async () => { + const cwd = await makeTempProject("layout-patch"); + await mkdir(join(cwd, "app"), { recursive: true }); + const layoutPath = join(cwd, "app/layout.tsx"); + await writeFile( + layoutPath, + `export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} +`, + ); + + const result = await patchLayoutWithQueryClientProvider( + cwd, + "app/layout.tsx", + "@/", + ); + expect(result.updated).toBe(true); + const next = await readFile(layoutPath, "utf8"); + expect(next).toContain("QueryClientProvider"); + expect(next).toContain("getOrCreateQueryClient"); + }); +}); diff --git a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts new file mode 100644 index 00000000..67a28eb9 --- /dev/null +++ b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { buildScaffoldPlan } from "../scaffold-plan"; + +describe("scaffold plan", () => { + it("builds expected files for nextjs", async () => { + const plan = await buildScaffoldPlan({ + framework: "nextjs", + adapter: "memory", + plugins: ["blog"], + alias: "@/", + cssFile: "app/globals.css", + }); + + expect(plan.files.map((file) => file.path)).toEqual([ + "lib/stack.ts", + "lib/stack-client.tsx", + "lib/query-client.ts", + "app/api/data/[[...all]]/route.ts", + "app/pages/[[...all]]/page.tsx", + "app/pages/layout.tsx", + ]); + expect(plan.files[0]?.content).toContain("blogBackendPlugin()"); + expect(plan.files[1]?.content).toContain("blogClientPlugin"); + }); +}); diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts new file mode 100644 index 00000000..77f96896 --- /dev/null +++ b/packages/cli/src/utils/constants.ts @@ -0,0 +1,135 @@ +import type { Adapter, PluginKey } from "../types"; + +export interface AdapterMeta { + key: Adapter; + label: string; + packageName: string; + ormForGenerate?: "prisma" | "drizzle" | "kysely"; +} + +export interface PluginMeta { + key: PluginKey; + label: string; + cssImport: string; + backendImportPath: string; + backendSymbol: string; + clientImportPath: string; + clientSymbol: string; + configKey: string; +} + +export const ADAPTERS: readonly AdapterMeta[] = [ + { + key: "memory", + label: "Memory (local dev / testing)", + packageName: "@btst/adapter-memory", + }, + { + key: "prisma", + label: "Prisma", + packageName: "@btst/adapter-prisma", + ormForGenerate: "prisma", + }, + { + key: "drizzle", + label: "Drizzle", + packageName: "@btst/adapter-drizzle", + ormForGenerate: "drizzle", + }, + { + key: "kysely", + label: "Kysely", + packageName: "@btst/adapter-kysely", + ormForGenerate: "kysely", + }, + { + key: "mongodb", + label: "MongoDB", + packageName: "@btst/adapter-mongodb", + }, +]; + +export const PLUGINS: readonly PluginMeta[] = [ + { + key: "blog", + label: "Blog", + cssImport: "@btst/stack/plugins/blog/css", + backendImportPath: "@btst/stack/plugins/blog/api", + backendSymbol: "blogBackendPlugin", + clientImportPath: "@btst/stack/plugins/blog/client", + clientSymbol: "blogClientPlugin", + configKey: "blog", + }, + { + key: "ai-chat", + label: "AI Chat", + cssImport: "@btst/stack/plugins/ai-chat/css", + backendImportPath: "@btst/stack/plugins/ai-chat/api", + backendSymbol: "aiChatBackendPlugin", + clientImportPath: "@btst/stack/plugins/ai-chat/client", + clientSymbol: "aiChatClientPlugin", + configKey: "aiChat", + }, + { + key: "cms", + label: "CMS", + cssImport: "@btst/stack/plugins/cms/css", + backendImportPath: "@btst/stack/plugins/cms/api", + backendSymbol: "cmsBackendPlugin", + clientImportPath: "@btst/stack/plugins/cms/client", + clientSymbol: "cmsClientPlugin", + configKey: "cms", + }, + { + key: "form-builder", + label: "Form Builder", + cssImport: "@btst/stack/plugins/form-builder/css", + backendImportPath: "@btst/stack/plugins/form-builder/api", + backendSymbol: "formBuilderBackendPlugin", + clientImportPath: "@btst/stack/plugins/form-builder/client", + clientSymbol: "formBuilderClientPlugin", + configKey: "formBuilder", + }, + { + key: "ui-builder", + label: "UI Builder", + cssImport: "@btst/stack/plugins/ui-builder/css", + backendImportPath: "@btst/stack/plugins/ui-builder", + backendSymbol: "UI_BUILDER_CONTENT_TYPE", + clientImportPath: "@btst/stack/plugins/ui-builder/client", + clientSymbol: "uiBuilderClientPlugin", + configKey: "uiBuilder", + }, + { + key: "kanban", + label: "Kanban", + cssImport: "@btst/stack/plugins/kanban/css", + backendImportPath: "@btst/stack/plugins/kanban/api", + backendSymbol: "kanbanBackendPlugin", + clientImportPath: "@btst/stack/plugins/kanban/client", + clientSymbol: "kanbanClientPlugin", + configKey: "kanban", + }, + { + key: "comments", + label: "Comments", + cssImport: "@btst/stack/plugins/comments/css", + backendImportPath: "@btst/stack/plugins/comments/api", + backendSymbol: "commentsBackendPlugin", + clientImportPath: "@btst/stack/plugins/comments/client", + clientSymbol: "commentsClientPlugin", + configKey: "comments", + }, + { + key: "media", + label: "Media", + cssImport: "@btst/stack/plugins/media/css", + backendImportPath: "@btst/stack/plugins/media/api", + backendSymbol: "mediaBackendPlugin", + clientImportPath: "@btst/stack/plugins/media/client", + clientSymbol: "mediaClientPlugin", + configKey: "media", + }, +]; + +export const DEFAULT_PLUGIN_SELECTION: PluginKey[] = []; diff --git a/packages/cli/src/utils/css-patcher.ts b/packages/cli/src/utils/css-patcher.ts new file mode 100644 index 00000000..2a93fbf9 --- /dev/null +++ b/packages/cli/src/utils/css-patcher.ts @@ -0,0 +1,45 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +function toImportLine(specifier: string): string { + return `@import "${specifier}";`; +} + +export async function patchCssImports( + cwd: string, + cssFile: string, + importsToEnsure: string[], +): Promise<{ updated: boolean; added: string[] }> { + const fullPath = join(cwd, cssFile); + let content = await readFile(fullPath, "utf8"); + const added: string[] = []; + + for (const specifier of importsToEnsure) { + const line = toImportLine(specifier); + if (!content.includes(line)) { + added.push(specifier); + } + } + + if (added.length === 0) { + return { updated: false, added }; + } + + const importBlock = added + .map((specifier) => toImportLine(specifier)) + .join("\n"); + const firstNonImportIndex = content + .split("\n") + .findIndex((line) => !line.trimStart().startsWith("@import ")); + + if (firstNonImportIndex <= 0) { + content = `${importBlock}\n${content}`; + } else { + const lines = content.split("\n"); + lines.splice(firstNonImportIndex, 0, importBlock, ""); + content = lines.join("\n"); + } + + await writeFile(fullPath, content, "utf8"); + return { updated: true, added }; +} diff --git a/packages/cli/src/utils/detect-alias.ts b/packages/cli/src/utils/detect-alias.ts new file mode 100644 index 00000000..6a42b54e --- /dev/null +++ b/packages/cli/src/utils/detect-alias.ts @@ -0,0 +1,27 @@ +import { access, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { AliasPrefix } from "../types"; + +interface TsConfigLike { + compilerOptions?: { + paths?: Record; + }; +} + +export async function detectAlias(cwd: string): Promise { + for (const fileName of ["tsconfig.json", "jsconfig.json"]) { + const filePath = join(cwd, fileName); + try { + await access(filePath); + } catch { + continue; + } + + const parsed = JSON.parse(await readFile(filePath, "utf8")) as TsConfigLike; + const paths = parsed.compilerOptions?.paths ?? {}; + if ("@/*" in paths) return "@/"; + if ("~/*" in paths) return "~/"; + } + + return "@/"; +} diff --git a/packages/cli/src/utils/detect-css-file.ts b/packages/cli/src/utils/detect-css-file.ts new file mode 100644 index 00000000..079ae8bb --- /dev/null +++ b/packages/cli/src/utils/detect-css-file.ts @@ -0,0 +1,30 @@ +import { access } from "node:fs/promises"; +import { join } from "node:path"; +import type { Framework } from "../types"; + +async function exists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +const CSS_CANDIDATES: Record = { + nextjs: ["app/globals.css", "src/app/globals.css"], + "react-router": ["app/app.css", "src/app.css"], + tanstack: ["src/styles/globals.css", "src/app.css"], +}; + +export async function detectCssFile( + cwd: string, + framework: Framework, +): Promise { + for (const candidate of CSS_CANDIDATES[framework]) { + if (await exists(join(cwd, candidate))) { + return candidate; + } + } + return null; +} diff --git a/packages/cli/src/utils/detect-framework.ts b/packages/cli/src/utils/detect-framework.ts new file mode 100644 index 00000000..70a4a806 --- /dev/null +++ b/packages/cli/src/utils/detect-framework.ts @@ -0,0 +1,34 @@ +import { access, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { Framework } from "../types"; + +interface PackageJsonLike { + dependencies?: Record; + devDependencies?: Record; +} + +export async function detectFramework(cwd: string): Promise { + const packageJsonPath = join(cwd, "package.json"); + try { + await access(packageJsonPath); + } catch { + return null; + } + + const raw = await readFile(packageJsonPath, "utf8"); + const parsed = JSON.parse(raw) as PackageJsonLike; + const deps = { + ...(parsed.dependencies ?? {}), + ...(parsed.devDependencies ?? {}), + }; + + if ("next" in deps) return "nextjs"; + if ("react-router" in deps || "@react-router/node" in deps) { + return "react-router"; + } + if ("@tanstack/react-router" in deps || "@tanstack/start" in deps) { + return "tanstack"; + } + + return null; +} diff --git a/packages/cli/src/utils/detect-package-manager.ts b/packages/cli/src/utils/detect-package-manager.ts new file mode 100644 index 00000000..ce2775ac --- /dev/null +++ b/packages/cli/src/utils/detect-package-manager.ts @@ -0,0 +1,20 @@ +import { access } from "node:fs/promises"; +import { join } from "node:path"; +import type { PackageManager } from "../types"; + +async function exists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +export async function detectPackageManager( + cwd: string, +): Promise { + if (await exists(join(cwd, "pnpm-lock.yaml"))) return "pnpm"; + if (await exists(join(cwd, "yarn.lock"))) return "yarn"; + return "npm"; +} diff --git a/packages/cli/src/utils/detect-project-shape.ts b/packages/cli/src/utils/detect-project-shape.ts new file mode 100644 index 00000000..200ebb8c --- /dev/null +++ b/packages/cli/src/utils/detect-project-shape.ts @@ -0,0 +1,44 @@ +import { access } from "node:fs/promises"; +import { join } from "node:path"; +import type { Framework } from "../types"; + +async function exists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +const FRAMEWORK_REQUIRED_PATHS: Record = { + nextjs: [], + "react-router": ["app"], + tanstack: ["src/routes"], +}; + +export async function detectProjectShape( + cwd: string, + framework: Framework, +): Promise<{ ok: true } | { ok: false; missingPaths: string[] }> { + if (framework === "nextjs") { + const hasAppDir = + (await exists(join(cwd, "app"))) || (await exists(join(cwd, "src/app"))); + if (!hasAppDir) { + return { ok: false, missingPaths: ["app or src/app"] }; + } + return { ok: true }; + } + + const missingPaths: string[] = []; + for (const requiredPath of FRAMEWORK_REQUIRED_PATHS[framework]) { + if (!(await exists(join(cwd, requiredPath)))) { + missingPaths.push(requiredPath); + } + } + + if (missingPaths.length > 0) { + return { ok: false, missingPaths }; + } + return { ok: true }; +} diff --git a/packages/cli/src/utils/file-writer.ts b/packages/cli/src/utils/file-writer.ts new file mode 100644 index 00000000..a09603fc --- /dev/null +++ b/packages/cli/src/utils/file-writer.ts @@ -0,0 +1,91 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { confirm, isCancel, select } from "@clack/prompts"; +import type { FileWritePlanItem } from "../types"; + +export type ConflictPolicy = "ask" | "skip" | "overwrite"; + +function makeDiffPreview(previousContent: string, nextContent: string): string { + const before = previousContent.split("\n"); + const after = nextContent.split("\n"); + const max = Math.max(before.length, after.length); + const out: string[] = []; + + for (let index = 0; index < max; index++) { + const prev = before[index]; + const next = after[index]; + if (prev === next) continue; + if (prev !== undefined) out.push(`- ${prev}`); + if (next !== undefined) out.push(`+ ${next}`); + if (out.length > 12) break; + } + + return out.join("\n"); +} + +export async function writePlannedFiles( + cwd: string, + files: FileWritePlanItem[], + policy: ConflictPolicy, +): Promise<{ written: string[]; skipped: string[] }> { + const written: string[] = []; + const skipped: string[] = []; + + for (const file of files) { + const absolutePath = join(cwd, file.path); + let existingContent: string | null = null; + try { + existingContent = await readFile(absolutePath, "utf8"); + } catch { + existingContent = null; + } + + if (existingContent === file.content) { + skipped.push(file.path); + continue; + } + + let shouldWrite = true; + if (existingContent !== null) { + if (policy === "skip") shouldWrite = false; + if (policy === "ask") { + const action = await select({ + message: `File exists: ${file.path}`, + options: [ + { label: "Overwrite", value: "overwrite" }, + { label: "Skip", value: "skip" }, + { label: "Show diff preview", value: "diff" }, + ], + }); + if (isCancel(action)) { + throw new Error("Cancelled by user"); + } + if (action === "skip") { + shouldWrite = false; + } + if (action === "diff") { + console.log(`\n${makeDiffPreview(existingContent, file.content)}\n`); + const retry = await confirm({ + message: `Overwrite ${file.path}?`, + initialValue: false, + }); + if (isCancel(retry)) { + throw new Error("Cancelled by user"); + } + shouldWrite = Boolean(retry); + } + } + } + + if (!shouldWrite) { + skipped.push(file.path); + continue; + } + + await mkdir(dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, file.content, "utf8"); + written.push(file.path); + } + + return { written, skipped }; +} diff --git a/packages/cli/src/utils/layout-patcher.ts b/packages/cli/src/utils/layout-patcher.ts new file mode 100644 index 00000000..1109178b --- /dev/null +++ b/packages/cli/src/utils/layout-patcher.ts @@ -0,0 +1,122 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Project, SyntaxKind } from "ts-morph"; + +function createManualInstructions( + layoutPath: string, + queryClientImportPath: string, +) { + return [ + `Could not automatically patch ${layoutPath}.`, + "Please apply this manually:", + `1) Add imports:`, + ` import { QueryClientProvider } from "@tanstack/react-query"`, + ` import { getOrCreateQueryClient } from "${queryClientImportPath}"`, + "2) Inside your root component, create:", + " const queryClient = getOrCreateQueryClient()", + "3) Wrap your returned layout JSX with:", + " ...", + ].join("\n"); +} + +export async function patchLayoutWithQueryClientProvider( + cwd: string, + layoutPath: string, + aliasPrefix: string, +): Promise<{ updated: boolean; warning?: string }> { + const fullPath = join(cwd, layoutPath); + const queryClientImportPath = `${aliasPrefix}lib/query-client`; + + let rawContent: string; + try { + rawContent = await readFile(fullPath, "utf8"); + } catch { + return { + updated: false, + warning: createManualInstructions(layoutPath, queryClientImportPath), + }; + } + + if (rawContent.includes("QueryClientProvider")) { + return { updated: false }; + } + + try { + const project = new Project({ + useInMemoryFileSystem: false, + skipAddingFilesFromTsConfig: true, + }); + const sourceFile = project.addSourceFileAtPath(fullPath); + + sourceFile.addImportDeclaration({ + moduleSpecifier: "@tanstack/react-query", + namedImports: ["QueryClientProvider"], + }); + sourceFile.addImportDeclaration({ + moduleSpecifier: queryClientImportPath, + namedImports: ["getOrCreateQueryClient"], + }); + + const candidateFunctions = [ + ...sourceFile.getFunctions(), + ...sourceFile + .getVariableDeclarations() + .map((decl) => decl.getInitializerIfKind(SyntaxKind.ArrowFunction)) + .filter((node): node is NonNullable => Boolean(node)), + ]; + + let didPatch = false; + for (const fn of candidateFunctions) { + const body = fn.getBody(); + if (!body || !body.isKind(SyntaxKind.Block)) continue; + const returnStatement = body.getDescendantsOfKind( + SyntaxKind.ReturnStatement, + )[0]; + if (!returnStatement) continue; + const expression = returnStatement.getExpression(); + if (!expression) continue; + + if (expression.getText().includes("QueryClientProvider")) { + didPatch = true; + break; + } + + const hasQueryClientDeclaration = body + .getStatements() + .some((statement) => + statement.getText().includes("getOrCreateQueryClient()"), + ); + if (!hasQueryClientDeclaration) { + body.insertStatements( + 0, + "const queryClient = getOrCreateQueryClient()", + ); + } + + returnStatement.replaceWithText( + `return ( + + ${expression.getText()} + + )`, + ); + didPatch = true; + break; + } + + if (!didPatch) { + return { + updated: false, + warning: createManualInstructions(layoutPath, queryClientImportPath), + }; + } + + await sourceFile.save(); + return { updated: true }; + } catch { + return { + updated: false, + warning: createManualInstructions(layoutPath, queryClientImportPath), + }; + } +} diff --git a/packages/cli/src/utils/package-installer.ts b/packages/cli/src/utils/package-installer.ts new file mode 100644 index 00000000..53f8f386 --- /dev/null +++ b/packages/cli/src/utils/package-installer.ts @@ -0,0 +1,39 @@ +import { execa } from "execa"; +import { ADAPTERS } from "./constants"; +import type { Adapter, PackageManager } from "../types"; + +function getInstallCommand( + packageManager: PackageManager, + packages: string[], +): { command: string; args: string[] } { + if (packageManager === "pnpm") { + return { command: "pnpm", args: ["add", ...packages] }; + } + if (packageManager === "yarn") { + return { command: "yarn", args: ["add", ...packages] }; + } + return { command: "npm", args: ["install", ...packages] }; +} + +export async function installInitDependencies(input: { + cwd: string; + packageManager: PackageManager; + adapter: Adapter; + skipInstall?: boolean; +}): Promise { + if (input.skipInstall) return; + + const adapterMeta = ADAPTERS.find((item) => item.key === input.adapter); + if (!adapterMeta) { + throw new Error(`Unknown adapter: ${input.adapter}`); + } + + const packages = [ + "@btst/stack", + "@btst/yar", + "@tanstack/react-query", + adapterMeta.packageName, + ]; + const { command, args } = getInstallCommand(input.packageManager, packages); + await execa(command, args, { cwd: input.cwd, stdio: "inherit" }); +} diff --git a/packages/cli/src/utils/passthrough.ts b/packages/cli/src/utils/passthrough.ts new file mode 100644 index 00000000..9861bcfa --- /dev/null +++ b/packages/cli/src/utils/passthrough.ts @@ -0,0 +1,43 @@ +import { execa } from "execa"; +import { ADAPTERS } from "./constants"; +import type { Adapter } from "../types"; + +export function adapterNeedsGenerate(adapter: Adapter): boolean { + if (adapter === "memory") return false; + return Boolean(ADAPTERS.find((item) => item.key === adapter)?.ormForGenerate); +} + +export function getGenerateHintForAdapter(adapter: Adapter): string | null { + const meta = ADAPTERS.find((item) => item.key === adapter); + if (!meta?.ormForGenerate) return null; + + const output = + meta.ormForGenerate === "prisma" + ? "schema.prisma" + : meta.ormForGenerate === "drizzle" + ? "src/db/schema.ts" + : "migrations/schema.sql"; + + return `npx @btst/codegen generate --orm=${meta.ormForGenerate} --config=lib/stack.ts --output=${output}`; +} + +export async function runCliPassthrough(input: { + cwd: string; + command: "generate" | "migrate"; + args: string[]; +}): Promise { + const effectiveCommand = ["@btst/cli", input.command, ...input.args]; + console.log(`Delegating to: npx ${effectiveCommand.join(" ")}`); + try { + await execa("npx", effectiveCommand, { + cwd: input.cwd, + stdio: "inherit", + }); + return 0; + } catch (error) { + console.error( + `Delegated ${input.command} failed. Resolve the error, then run npx @btst/cli ${input.command} ... again.`, + ); + return 1; + } +} diff --git a/packages/cli/src/utils/render-template.ts b/packages/cli/src/utils/render-template.ts new file mode 100644 index 00000000..0f42de92 --- /dev/null +++ b/packages/cli/src/utils/render-template.ts @@ -0,0 +1,40 @@ +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import Handlebars from "handlebars"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function getTemplateRoot(): string { + return join(__dirname, "templates"); +} + +export async function renderTemplate( + templatePath: string, + context: Record, +): Promise { + const roots = [ + getTemplateRoot(), + join(__dirname, "..", "templates"), + join(__dirname, "..", "src", "templates"), + join(__dirname, "src", "templates"), + ]; + let source: string | null = null; + + for (const root of roots) { + try { + source = await readFile(join(root, templatePath), "utf8"); + break; + } catch { + // keep searching fallback roots + } + } + + if (!source) { + throw new Error(`Template not found: ${templatePath}`); + } + + const template = Handlebars.compile(source); + return `${template(context).trimEnd()}\n`; +} diff --git a/packages/cli/src/utils/scaffold-plan.ts b/packages/cli/src/utils/scaffold-plan.ts new file mode 100644 index 00000000..c44cf30a --- /dev/null +++ b/packages/cli/src/utils/scaffold-plan.ts @@ -0,0 +1,220 @@ +import { ADAPTERS, PLUGINS } from "./constants"; +import { renderTemplate } from "./render-template"; +import type { + Adapter, + AliasPrefix, + FileWritePlanItem, + Framework, + PluginKey, + ScaffoldPlan, +} from "../types"; + +interface BuildScaffoldPlanInput { + framework: Framework; + adapter: Adapter; + plugins: PluginKey[]; + alias: AliasPrefix; + cssFile: string; +} + +function getFrameworkPaths(framework: Framework, cssFile: string) { + if (framework === "nextjs") { + const prefix = cssFile.startsWith("src/") ? "src/" : ""; + return { + stackPath: `${prefix}lib/stack.ts`, + stackClientPath: `${prefix}lib/stack-client.tsx`, + queryClientPath: `${prefix}lib/query-client.ts`, + apiRoutePath: `${prefix}app/api/data/[[...all]]/route.ts`, + pageRoutePath: `${prefix}app/pages/[[...all]]/page.tsx`, + pagesLayoutPath: `${prefix}app/pages/layout.tsx`, + layoutPatchTarget: `${prefix}app/layout.tsx`, + }; + } + + if (framework === "react-router") { + return { + stackPath: "app/lib/stack.ts", + stackClientPath: "app/lib/stack-client.tsx", + queryClientPath: "app/lib/query-client.ts", + apiRoutePath: "app/routes/api/data/route.ts", + pageRoutePath: "app/routes/pages/index.tsx", + pagesLayoutPath: undefined, + layoutPatchTarget: "app/root.tsx", + }; + } + + return { + stackPath: "src/lib/stack.ts", + stackClientPath: "src/lib/stack-client.tsx", + queryClientPath: "src/lib/query-client.ts", + apiRoutePath: "src/routes/api/data/$.ts", + pageRoutePath: "src/routes/pages/$.tsx", + pagesLayoutPath: undefined, + layoutPatchTarget: "src/routes/__root.tsx", + }; +} + +function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { + const metas = PLUGINS.filter((plugin) => + selectedPlugins.includes(plugin.key), + ); + + return { + backendImports: metas + .filter((m) => m.key !== "ui-builder") + .map((m) => `import { ${m.backendSymbol} } from "${m.backendImportPath}"`) + .join("\n"), + clientImports: metas + .map((m) => `import { ${m.clientSymbol} } from "${m.clientImportPath}"`) + .join("\n"), + backendEntries: metas + .map((m) => { + if (m.key === "ui-builder") { + return `\t\t${m.configKey}: ${m.backendSymbol},`; + } + return `\t\t${m.configKey}: ${m.backendSymbol}(),`; + }) + .join("\n"), + clientEntries: metas + .map((m) => { + const siteBase = "/pages"; + return `\t\t\t${JSON.stringify(m.key)}: ${m.clientSymbol}({ +\t\t\t\tapiBaseURL: baseURL, +\t\t\t\tapiBasePath: "/api/data", +\t\t\t\tsiteBaseURL: baseURL, +\t\t\t\tsiteBasePath: "${siteBase}", +\t\t\t\tqueryClient, +\t\t\t}),`; + }) + .join("\n"), + }; +} + +function buildAdapterTemplateContext(adapter: Adapter) { + const meta = ADAPTERS.find((item) => item.key === adapter); + if (!meta) { + throw new Error(`Unsupported adapter: ${adapter}`); + } + + if (adapter === "memory") { + return { + adapterImport: `import { createMemoryAdapter } from "${meta.packageName}"`, + adapterSetup: "", + adapterStackLine: "adapter: (db) => createMemoryAdapter(db)({}),", + }; + } + + if (adapter === "prisma") { + return { + adapterImport: `import { createPrismaAdapter } from "${meta.packageName}" +import { PrismaClient } from "@prisma/client"`, + adapterSetup: `const prisma = new PrismaClient() + +const provider = process.env.BTST_PRISMA_PROVIDER ?? "postgresql" +`, + adapterStackLine: + "adapter: (db) => createPrismaAdapter(prisma, db, { provider }),", + }; + } + + if (adapter === "drizzle") { + return { + adapterImport: `import { createDrizzleAdapter } from "${meta.packageName}"`, + adapterSetup: + "// TODO: wire your Drizzle DB instance (drizzleDb)\nconst drizzleDb = {} as never\n", + adapterStackLine: + "adapter: (db) => createDrizzleAdapter(drizzleDb, db, {}),", + }; + } + + if (adapter === "kysely") { + return { + adapterImport: `import { createKyselyAdapter } from "${meta.packageName}"`, + adapterSetup: + "// TODO: wire your Kysely DB instance (kyselyDb)\nconst kyselyDb = {} as never\n", + adapterStackLine: + "adapter: (db) => createKyselyAdapter(kyselyDb, db, {}),", + }; + } + + return { + adapterImport: `import { createMongodbAdapter } from "${meta.packageName}"`, + adapterSetup: + "// TODO: wire your MongoDB database instance (mongoDb)\nconst mongoDb = {} as never\n", + adapterStackLine: "adapter: (db) => createMongodbAdapter(mongoDb, db, {}),", + }; +} + +export async function buildScaffoldPlan( + input: BuildScaffoldPlanInput, +): Promise { + const frameworkPaths = getFrameworkPaths(input.framework, input.cssFile); + const pluginContext = buildPluginTemplateContext(input.plugins); + const adapterContext = buildAdapterTemplateContext(input.adapter); + + const sharedContext = { + alias: input.alias, + ...pluginContext, + ...adapterContext, + }; + + const files: FileWritePlanItem[] = [ + { + path: frameworkPaths.stackPath, + content: await renderTemplate( + `${input.framework}/stack.ts.hbs`, + sharedContext, + ), + description: "BTST backend stack configuration", + }, + { + path: frameworkPaths.stackClientPath, + content: await renderTemplate( + `${input.framework}/stack-client.tsx.hbs`, + sharedContext, + ), + description: "BTST client stack configuration", + }, + { + path: frameworkPaths.queryClientPath, + content: await renderTemplate( + "shared/lib/query-client.ts.hbs", + sharedContext, + ), + description: "React Query client utility", + }, + { + path: frameworkPaths.apiRoutePath, + content: await renderTemplate( + `${input.framework}/api-route.ts.hbs`, + sharedContext, + ), + description: "BTST API route", + }, + { + path: frameworkPaths.pageRoutePath, + content: await renderTemplate( + `${input.framework}/pages-route.tsx.hbs`, + sharedContext, + ), + description: "BTST pages catch-all route", + }, + ]; + + if (frameworkPaths.pagesLayoutPath && input.framework === "nextjs") { + files.push({ + path: frameworkPaths.pagesLayoutPath, + content: await renderTemplate( + "nextjs/pages-layout.tsx.hbs", + sharedContext, + ), + description: "BTST pages layout wrapper", + }); + } + + return { + files, + layoutPatchTarget: frameworkPaths.layoutPatchTarget, + cssPatchTarget: input.cssFile, + }; +} diff --git a/packages/cli/src/utils/validate-prerequisites.ts b/packages/cli/src/utils/validate-prerequisites.ts new file mode 100644 index 00000000..48dab31f --- /dev/null +++ b/packages/cli/src/utils/validate-prerequisites.ts @@ -0,0 +1,60 @@ +import { access, readFile } from "node:fs/promises"; +import { join } from "node:path"; + +async function exists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +export async function collectPrerequisiteWarnings( + cwd: string, +): Promise { + const warnings: string[] = []; + + if (!(await exists(join(cwd, "components.json")))) { + warnings.push("Missing components.json (shadcn setup may be incomplete)."); + } + + let cssContent = ""; + for (const candidate of [ + "app/globals.css", + "src/styles/globals.css", + "app/app.css", + ]) { + try { + cssContent = await readFile(join(cwd, candidate), "utf8"); + break; + } catch { + // keep trying + } + } + if (cssContent && !cssContent.includes("tailwindcss")) { + warnings.push("Could not detect Tailwind v4 import in global CSS."); + } + + let hasSonner = false; + for (const candidate of [ + "app/layout.tsx", + "app/root.tsx", + "src/routes/__root.tsx", + ]) { + try { + const content = await readFile(join(cwd, candidate), "utf8"); + if (content.includes("Toaster")) { + hasSonner = true; + break; + } + } catch { + // ignore + } + } + if (!hasSonner) { + warnings.push("Could not find Sonner in root layout."); + } + + return warnings; +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 00000000..1f1ff4dd --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "lib": ["esnext"], + "types": ["node"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f1df8c1..d50cdf41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -528,6 +528,40 @@ importers: specifier: ^5.1.4 version: 5.1.4(typescript@5.9.3)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + packages/cli: + dependencies: + '@clack/prompts': + specifier: ^0.11.0 + version: 0.11.0 + commander: + specifier: ^14.0.1 + version: 14.0.3 + execa: + specifier: ^9.6.0 + version: 9.6.1 + handlebars: + specifier: ^4.7.8 + version: 4.7.8 + ts-morph: + specifier: ^27.0.2 + version: 27.0.2 + devDependencies: + '@types/node': + specifier: ^24.9.2 + version: 24.10.1 + tsx: + specifier: 'catalog:' + version: 4.21.0 + typescript: + specifier: 'catalog:' + version: 5.9.3 + unbuild: + specifier: 'catalog:' + version: 3.6.1(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3)) + vitest: + specifier: 'catalog:' + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.10(@types/node@24.10.1)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) + packages/stack: dependencies: '@btst/db': @@ -1489,6 +1523,12 @@ packages: '@chevrotain/utils@10.5.0': resolution: {integrity: sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==} + '@clack/core@0.5.0': + resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==} + + '@clack/prompts@0.11.0': + resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} + '@codemirror/autocomplete@6.19.1': resolution: {integrity: sha512-q6NenYkEy2fn9+JyjIxMWcNjzTL/IhwqfzOut1/G3PrIFkrbl4AL7Wkse5tLrQUUyqGoAKU5+Pi5jnnXxH5HGw==} @@ -3911,6 +3951,9 @@ packages: '@rushstack/eslint-patch@1.15.0': resolution: {integrity: sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw==} + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@shikijs/core@3.15.0': resolution: {integrity: sha512-8TOG6yG557q+fMsSVa8nkEDOZNTSxjbbR8l6lF2gyr6Np+jrPlslqDxQkN6rMXCECQ3isNPZAGszAfYoJOPGlg==} @@ -3938,6 +3981,10 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + '@smithy/abort-controller@4.2.12': resolution: {integrity: sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==} engines: {node: '>=18.0.0'} @@ -5582,6 +5629,10 @@ packages: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@7.2.0: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} @@ -6352,6 +6403,10 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + exit-hook@2.2.1: resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} engines: {node: '>=6'} @@ -6431,6 +6486,10 @@ packages: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -6667,6 +6726,10 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + get-symbol-description@1.1.0: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} @@ -6915,6 +6978,10 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -7138,6 +7205,10 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} @@ -7154,6 +7225,10 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-upper-case@1.1.2: resolution: {integrity: sha512-GQYSJMgfeAmVwh9ixyk888l7OIhNAGKtY6QA+IrWlu9MDTCaXmeozOZ2S9Knj7bQwBO/H6J2kb+pbyTUiMNbsw==} @@ -8004,6 +8079,10 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + npm-to-yarn@3.0.1: resolution: {integrity: sha512-tt6PvKu4WyzPwWUzy/hvPFqn+uwXO0K1ZHka8az3NnrhWJDmSqI8ncWq0fkL0k/lmmi5tAC11FXwXuh0rFbt1A==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -8164,6 +8243,10 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + parse5-htmlparser2-tree-adapter@7.1.0: resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} @@ -8202,6 +8285,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -8512,6 +8599,10 @@ packages: resolution: {integrity: sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==} engines: {node: '>= 0.8'} + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + prisma@7.5.0: resolution: {integrity: sha512-n30qZpWehaYQzigLjmuPisyEsvOzHt7bZeRyg8gZ5DvJo9FGjD+gNaY59Ns3hlLD5/jZH5GBeftIss0jDbUoLg==} engines: {node: ^20.19 || ^22.12 || >=24.0} @@ -9156,6 +9247,9 @@ packages: resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} engines: {node: '>= 10'} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -9315,6 +9409,10 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -9679,6 +9777,10 @@ packages: unenv@2.0.0-rc.21: resolution: {integrity: sha512-Wj7/AMtE9MRnAXa6Su3Lk0LNCfqDYgfwVjwRFVum9U7wsto1imuHqk4kTm7Jni+5A0Hn7dttL6O/zjvUvoo+8A==} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -10143,6 +10245,10 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + zeptomatch@2.1.0: resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} @@ -11486,6 +11592,17 @@ snapshots: '@chevrotain/utils@10.5.0': {} + '@clack/core@0.5.0': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@clack/prompts@0.11.0': + dependencies: + '@clack/core': 0.5.0 + picocolors: 1.1.1 + sisteransi: 1.0.5 + '@codemirror/autocomplete@6.19.1': dependencies: '@codemirror/language': 6.11.3 @@ -14099,6 +14216,8 @@ snapshots: '@rushstack/eslint-patch@1.15.0': {} + '@sec-ant/readable-stream@0.4.1': {} + '@shikijs/core@3.15.0': dependencies: '@shikijs/types': 3.15.0 @@ -14146,6 +14265,8 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@sindresorhus/merge-streams@4.0.0': {} + '@smithy/abort-controller@4.2.12': dependencies: '@smithy/types': 4.13.1 @@ -14776,7 +14897,7 @@ snapshots: prettier: 3.6.2 recast: 0.23.11 source-map: 0.7.6 - tsx: 4.20.6 + tsx: 4.21.0 zod: 3.25.76 transitivePeerDependencies: - supports-color @@ -16541,6 +16662,8 @@ snapshots: commander@11.1.0: {} + commander@14.0.3: {} + commander@7.2.0: {} commander@8.3.0: {} @@ -17559,6 +17682,21 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + exit-hook@2.2.1: {} expect-type@1.2.2: {} @@ -17668,6 +17806,10 @@ snapshots: dependencies: escape-string-regexp: 1.0.5 + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + file-entry-cache@6.0.1: dependencies: flat-cache: 3.2.0 @@ -17938,6 +18080,11 @@ snapshots: get-stream@6.0.1: {} + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + get-symbol-description@1.1.0: dependencies: call-bound: 1.0.4 @@ -18307,6 +18454,8 @@ snapshots: human-signals@2.1.0: {} + human-signals@8.0.1: {} + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -18528,6 +18677,8 @@ snapshots: is-stream@2.0.1: {} + is-stream@4.0.1: {} + is-string@1.1.1: dependencies: call-bound: 1.0.4 @@ -18545,6 +18696,8 @@ snapshots: is-unicode-supported@0.1.0: {} + is-unicode-supported@2.1.0: {} + is-upper-case@1.1.2: dependencies: upper-case: 1.1.3 @@ -19709,6 +19862,11 @@ snapshots: dependencies: path-key: 3.1.1 + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + npm-to-yarn@3.0.1: {} nth-check@2.1.1: @@ -19949,6 +20107,8 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse-ms@4.0.0: {} + parse5-htmlparser2-tree-adapter@7.1.0: dependencies: domhandler: 5.0.3 @@ -19983,6 +20143,8 @@ snapshots: path-key@3.1.1: {} + path-key@4.0.0: {} + path-parse@1.0.7: {} path-scurry@1.11.1: @@ -20262,6 +20424,10 @@ snapshots: pretty-hrtime@1.0.3: {} + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + prisma@7.5.0(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3): dependencies: '@prisma/config': 7.5.0 @@ -21173,6 +21339,8 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 + sisteransi@1.0.5: {} + slash@3.0.0: {} slash@5.1.0: {} @@ -21344,6 +21512,8 @@ snapshots: strip-final-newline@2.0.0: {} + strip-final-newline@4.0.0: {} + strip-json-comments@2.0.1: {} strip-json-comments@3.1.1: {} @@ -21708,6 +21878,8 @@ snapshots: pathe: 2.0.3 ufo: 1.6.1 + unicorn-magic@0.3.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -22359,6 +22531,8 @@ snapshots: yoctocolors-cjs@2.1.3: optional: true + yoctocolors@2.1.2: {} + zeptomatch@2.1.0: dependencies: grammex: 3.1.12 From 3da427beb8a0d9590c4b5ca1657433051fa773f8 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Tue, 24 Mar 2026 16:11:26 -0400 Subject: [PATCH 02/56] feat: enhance framework and adapter detection in init command to support automatic selection with --yes option --- packages/cli/src/commands/init.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index abd5f96f..60e59f05 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -57,6 +57,10 @@ async function detectOrSelectFramework( if (options.framework) return options.framework; const detected = await detectFramework(cwd); + if (options.yes) { + return detected ?? "nextjs"; + } + if (detected) { const accepted = ensureNotCancelled( await confirm({ @@ -83,6 +87,7 @@ async function detectOrSelectAdapter( options: InitCliOptions, ): Promise { if (options.adapter) return options.adapter; + if (options.yes) return "memory"; return ensureNotCancelled( await select({ message: "Select adapter", From 21f83152ba8532edf775f229cdf92272ae799c83 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Tue, 24 Mar 2026 16:11:31 -0400 Subject: [PATCH 03/56] chore: remove nightly initialization workflow from GitHub Actions --- .github/workflows/init-nightly.yml | 48 ------------------------------ 1 file changed, 48 deletions(-) delete mode 100644 .github/workflows/init-nightly.yml diff --git a/.github/workflows/init-nightly.yml b/.github/workflows/init-nightly.yml deleted file mode 100644 index 62fb7cbc..00000000 --- a/.github/workflows/init-nightly.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: BTST Init CLI Nightly - -on: - schedule: - - cron: '0 6 * * *' - workflow_dispatch: - -jobs: - nightly-init: - name: Nightly init smoke - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - - - name: Setup Node.js 22 - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install - - - name: Build @btst/stack - run: pnpm --filter @btst/stack build - - - name: Build @btst/codegen - run: pnpm --filter @btst/codegen build - - - name: Run nightly init harness - working-directory: packages/cli - run: bash scripts/test-init.sh - timeout-minutes: 30 - env: - CI: true - - - name: Upload artifacts on failure - if: failure() - uses: actions/upload-artifact@v4 - with: - name: btst-init-nightly-fixtures - path: /tmp/test-btst-init-*/ - retention-days: 5 From 0fa2cb4bc02f8278194f9fed6602ace988cfbc45 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Tue, 24 Mar 2026 16:13:39 -0400 Subject: [PATCH 04/56] chore: update GitHub Actions workflow to use specific versions of actions --- .github/workflows/init.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/init.yml b/.github/workflows/init.yml index 7843191b..12bc3172 100644 --- a/.github/workflows/init.yml +++ b/.github/workflows/init.yml @@ -20,13 +20,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 - name: Setup Node.js 22 - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 cache: 'pnpm' @@ -49,7 +49,7 @@ jobs: - name: Upload artifacts on failure if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: btst-init-fixtures path: /tmp/test-btst-init-*/ From f5418980c3204d61e5b93a58fd710cc58fbf91dc Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Tue, 24 Mar 2026 16:15:22 -0400 Subject: [PATCH 05/56] feat: implement hash-based idempotency check in test initialization script --- packages/cli/scripts/test-init.sh | 83 +++++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 4 deletions(-) diff --git a/packages/cli/scripts/test-init.sh b/packages/cli/scripts/test-init.sh index ef0cc93f..0f860c2e 100644 --- a/packages/cli/scripts/test-init.sh +++ b/packages/cli/scripts/test-init.sh @@ -76,11 +76,86 @@ node -e 'const fs=require("fs");const s=fs.readFileSync("lib/stack.ts","utf8");p success "Generation + patch checks passed" step "Idempotency check (second pass)" -git init > /dev/null -git add . -git commit -m "baseline" > /dev/null +node <<'EOF' > "$TEST_DIR/init-before.hash" +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); + +const root = process.cwd(); +const ignored = new Set(["node_modules", ".next", ".git"]); +const records = []; + +function walk(dir) { + for (const name of fs.readdirSync(dir, { withFileTypes: true })) { + if (ignored.has(name.name)) continue; + const absolutePath = path.join(dir, name.name); + const relativePath = path.relative(root, absolutePath); + if (name.isDirectory()) { + walk(absolutePath); + continue; + } + if (!name.isFile()) continue; + records.push({ + path: relativePath, + content: fs.readFileSync(absolutePath), + }); + } +} + +walk(root); +records.sort((a, b) => a.path.localeCompare(b.path)); + +const hash = crypto.createHash("sha256"); +for (const record of records) { + hash.update(record.path); + hash.update("\0"); + hash.update(record.content); + hash.update("\0"); +} +process.stdout.write(hash.digest("hex")); +EOF + npx @btst/codegen init --yes --framework nextjs --adapter memory --skip-install > "$TEST_DIR/init-second.log" 2>&1 -if ! git diff --exit-code > /dev/null; then +node <<'EOF' > "$TEST_DIR/init-after.hash" +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); + +const root = process.cwd(); +const ignored = new Set(["node_modules", ".next", ".git"]); +const records = []; + +function walk(dir) { + for (const name of fs.readdirSync(dir, { withFileTypes: true })) { + if (ignored.has(name.name)) continue; + const absolutePath = path.join(dir, name.name); + const relativePath = path.relative(root, absolutePath); + if (name.isDirectory()) { + walk(absolutePath); + continue; + } + if (!name.isFile()) continue; + records.push({ + path: relativePath, + content: fs.readFileSync(absolutePath), + }); + } +} + +walk(root); +records.sort((a, b) => a.path.localeCompare(b.path)); + +const hash = crypto.createHash("sha256"); +for (const record of records) { + hash.update(record.path); + hash.update("\0"); + hash.update(record.content); + hash.update("\0"); +} +process.stdout.write(hash.digest("hex")); +EOF + +if [ "$(cat "$TEST_DIR/init-before.hash")" != "$(cat "$TEST_DIR/init-after.hash")" ]; then error "Second init run produced file changes" exit 1 fi From e34b273f35051c418f8f9c1648ff081be6c7f8c8 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Tue, 24 Mar 2026 16:15:29 -0400 Subject: [PATCH 06/56] chore: remove ui-builder from backend imports in scaffold plan --- packages/cli/src/utils/scaffold-plan.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/src/utils/scaffold-plan.ts b/packages/cli/src/utils/scaffold-plan.ts index c44cf30a..f751d596 100644 --- a/packages/cli/src/utils/scaffold-plan.ts +++ b/packages/cli/src/utils/scaffold-plan.ts @@ -61,7 +61,6 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { return { backendImports: metas - .filter((m) => m.key !== "ui-builder") .map((m) => `import { ${m.backendSymbol} } from "${m.backendImportPath}"`) .join("\n"), clientImports: metas From bfc05aa83488dd7155b6ad5d2228567a1c43e3e9 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Tue, 24 Mar 2026 16:32:29 -0400 Subject: [PATCH 07/56] feat: improve CSS import handling to append imports correctly when file contains only import lines --- packages/cli/src/utils/__tests__/patchers.test.ts | 14 ++++++++++++++ packages/cli/src/utils/css-patcher.ts | 12 +++++++----- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/utils/__tests__/patchers.test.ts b/packages/cli/src/utils/__tests__/patchers.test.ts index edbe5fed..8236fb51 100644 --- a/packages/cli/src/utils/__tests__/patchers.test.ts +++ b/packages/cli/src/utils/__tests__/patchers.test.ts @@ -33,6 +33,20 @@ describe("patchers", () => { expect(second.match(/test\/base\.css/g)?.length).toBe(1); }); + it("appends imports when file contains only import lines", async () => { + const cwd = await makeTempProject("css-import-only"); + await mkdir(join(cwd, "app"), { recursive: true }); + const cssPath = join(cwd, "app/globals.css"); + await writeFile(cssPath, '@import "tailwindcss";\n@import "foo.css";'); + + await patchCssImports(cwd, "app/globals.css", ["test/plugin.css"]); + const next = await readFile(cssPath, "utf8"); + + expect(next).toBe( + '@import "tailwindcss";\n@import "foo.css";\n@import "test/plugin.css";', + ); + }); + it("patches layout with QueryClientProvider", async () => { const cwd = await makeTempProject("layout-patch"); await mkdir(join(cwd, "app"), { recursive: true }); diff --git a/packages/cli/src/utils/css-patcher.ts b/packages/cli/src/utils/css-patcher.ts index 2a93fbf9..ecfcca1a 100644 --- a/packages/cli/src/utils/css-patcher.ts +++ b/packages/cli/src/utils/css-patcher.ts @@ -28,14 +28,16 @@ export async function patchCssImports( const importBlock = added .map((specifier) => toImportLine(specifier)) .join("\n"); - const firstNonImportIndex = content - .split("\n") - .findIndex((line) => !line.trimStart().startsWith("@import ")); + const lines = content.split("\n"); + const firstNonImportIndex = lines.findIndex( + (line) => !line.trimStart().startsWith("@import "), + ); - if (firstNonImportIndex <= 0) { + if (firstNonImportIndex === 0) { content = `${importBlock}\n${content}`; + } else if (firstNonImportIndex === -1) { + content = content.length > 0 ? `${content}\n${importBlock}` : importBlock; } else { - const lines = content.split("\n"); lines.splice(firstNonImportIndex, 0, importBlock, ""); content = lines.join("\n"); } From e598d91168bcb54407d2df4cc28cd8d2cf28ecdd Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Tue, 24 Mar 2026 16:32:41 -0400 Subject: [PATCH 08/56] refactor: simplify ProjectContext interface and update InitCliOptions to omit plugins from InitOptions --- packages/cli/src/commands/init.ts | 10 ++-------- packages/cli/src/types.ts | 8 -------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 60e59f05..93ab37f3 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -32,15 +32,9 @@ import { } from "../utils/passthrough"; import { buildScaffoldPlan } from "../utils/scaffold-plan"; import { collectPrerequisiteWarnings } from "../utils/validate-prerequisites"; -import type { Adapter, Framework, PluginKey } from "../types"; +import type { Adapter, Framework, InitOptions, PluginKey } from "../types"; -interface InitCliOptions { - cwd?: string; - framework?: Framework; - adapter?: Adapter; - yes?: boolean; - skipInstall?: boolean; -} +type InitCliOptions = Omit; function ensureNotCancelled(value: T | symbol): T { if (isCancel(value)) { diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 75894856..4fbabe3c 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -25,14 +25,6 @@ export interface InitOptions { skipInstall?: boolean; } -export interface ProjectContext { - cwd: string; - framework: Framework; - packageManager: PackageManager; - alias: AliasPrefix; - cssFile: string; -} - export interface FileWritePlanItem { path: string; content: string; From b68c40fa4785d4415ae3bf87a9a2f13c91fafb6a Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Tue, 24 Mar 2026 16:44:53 -0400 Subject: [PATCH 09/56] feat: update stack-client templates to use a static baseURL when plugins are selected --- .../src/templates/nextjs/stack-client.tsx.hbs | 9 ++-- .../react-router/stack-client.tsx.hbs | 9 ++-- .../templates/tanstack/stack-client.tsx.hbs | 9 ++-- .../src/utils/__tests__/scaffold-plan.test.ts | 49 +++++++++++++++++++ 4 files changed, 58 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/templates/nextjs/stack-client.tsx.hbs b/packages/cli/src/templates/nextjs/stack-client.tsx.hbs index 94f4af68..355f1509 100644 --- a/packages/cli/src/templates/nextjs/stack-client.tsx.hbs +++ b/packages/cli/src/templates/nextjs/stack-client.tsx.hbs @@ -4,13 +4,10 @@ import { QueryClient } from "@tanstack/react-query" {{{clientImports}}} {{/if}} -const getBaseURL = () => - typeof window !== "undefined" - ? process.env.NEXT_PUBLIC_BASE_URL || window.location.origin - : process.env.BASE_URL || "http://localhost:3000" - export function getStackClient(queryClient: QueryClient) { - const baseURL = getBaseURL() +{{#if clientEntries}} + const baseURL = "http://localhost:3000" +{{/if}} return createStackClient({ plugins: { {{#if clientEntries}} diff --git a/packages/cli/src/templates/react-router/stack-client.tsx.hbs b/packages/cli/src/templates/react-router/stack-client.tsx.hbs index 00baa3f5..355f1509 100644 --- a/packages/cli/src/templates/react-router/stack-client.tsx.hbs +++ b/packages/cli/src/templates/react-router/stack-client.tsx.hbs @@ -4,13 +4,10 @@ import { QueryClient } from "@tanstack/react-query" {{{clientImports}}} {{/if}} -const getBaseURL = () => - typeof window !== "undefined" - ? import.meta.env.VITE_BASE_URL || window.location.origin - : process.env.BASE_URL || "http://localhost:5173" - export function getStackClient(queryClient: QueryClient) { - const baseURL = getBaseURL() +{{#if clientEntries}} + const baseURL = "http://localhost:3000" +{{/if}} return createStackClient({ plugins: { {{#if clientEntries}} diff --git a/packages/cli/src/templates/tanstack/stack-client.tsx.hbs b/packages/cli/src/templates/tanstack/stack-client.tsx.hbs index 6e834b0c..355f1509 100644 --- a/packages/cli/src/templates/tanstack/stack-client.tsx.hbs +++ b/packages/cli/src/templates/tanstack/stack-client.tsx.hbs @@ -4,13 +4,10 @@ import { QueryClient } from "@tanstack/react-query" {{{clientImports}}} {{/if}} -const getBaseURL = () => - typeof window !== "undefined" - ? import.meta.env.VITE_BASE_URL || window.location.origin - : process.env.BASE_URL || "http://localhost:3000" - export function getStackClient(queryClient: QueryClient) { - const baseURL = getBaseURL() +{{#if clientEntries}} + const baseURL = "http://localhost:3000" +{{/if}} return createStackClient({ plugins: { {{#if clientEntries}} diff --git a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts index 67a28eb9..fa3fca90 100644 --- a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts +++ b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts @@ -21,5 +21,54 @@ describe("scaffold plan", () => { ]); expect(plan.files[0]?.content).toContain("blogBackendPlugin()"); expect(plan.files[1]?.content).toContain("blogClientPlugin"); + expect(plan.files[1]?.content).toContain( + 'const baseURL = "http://localhost:3000"', + ); }); + + it.each(["nextjs", "react-router", "tanstack"] as const)( + "does not emit baseURL declarations when no plugins are selected (%s)", + async (framework) => { + const plan = await buildScaffoldPlan({ + framework, + adapter: "memory", + plugins: [], + alias: "@/", + cssFile: + framework === "nextjs" ? "app/globals.css" : "src/styles/app.css", + }); + + const stackClientFile = plan.files.find((file) => + file.path.endsWith("stack-client.tsx"), + ); + expect(stackClientFile?.content).toBeDefined(); + expect(stackClientFile?.content).not.toContain("const getBaseURL()"); + expect(stackClientFile?.content).not.toContain("const getBaseURL ="); + expect(stackClientFile?.content).not.toContain( + 'const baseURL = "http://localhost:3000"', + ); + }, + ); + + it.each(["nextjs", "react-router", "tanstack"] as const)( + "emits baseURL declarations when plugins are selected (%s)", + async (framework) => { + const plan = await buildScaffoldPlan({ + framework, + adapter: "memory", + plugins: ["blog"], + alias: "@/", + cssFile: + framework === "nextjs" ? "app/globals.css" : "src/styles/app.css", + }); + + const stackClientFile = plan.files.find((file) => + file.path.endsWith("stack-client.tsx"), + ); + expect(stackClientFile?.content).toBeDefined(); + expect(stackClientFile?.content).toContain( + 'const baseURL = "http://localhost:3000"', + ); + }, + ); }); From dcd9ccb392ee34c23237049bf50a426dce00b858 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Tue, 24 Mar 2026 16:58:39 -0400 Subject: [PATCH 10/56] feat: add early return in patchCssImports for empty imports and update tests --- packages/cli/src/utils/__tests__/patchers.test.ts | 6 ++++++ packages/cli/src/utils/css-patcher.ts | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/packages/cli/src/utils/__tests__/patchers.test.ts b/packages/cli/src/utils/__tests__/patchers.test.ts index 8236fb51..e2b4c164 100644 --- a/packages/cli/src/utils/__tests__/patchers.test.ts +++ b/packages/cli/src/utils/__tests__/patchers.test.ts @@ -12,6 +12,12 @@ async function makeTempProject(name: string): Promise { } describe("patchers", () => { + it("returns early when no imports are requested", async () => { + const cwd = await makeTempProject("css-empty-imports"); + const result = await patchCssImports(cwd, "src/styles/globals.css", []); + expect(result).toEqual({ updated: false, added: [] }); + }); + it("patches css imports idempotently", async () => { const cwd = await makeTempProject("css-patch"); await mkdir(join(cwd, "app"), { recursive: true }); diff --git a/packages/cli/src/utils/css-patcher.ts b/packages/cli/src/utils/css-patcher.ts index ecfcca1a..6f0182a6 100644 --- a/packages/cli/src/utils/css-patcher.ts +++ b/packages/cli/src/utils/css-patcher.ts @@ -10,6 +10,10 @@ export async function patchCssImports( cssFile: string, importsToEnsure: string[], ): Promise<{ updated: boolean; added: string[] }> { + if (importsToEnsure.length === 0) { + return { updated: false, added: [] }; + } + const fullPath = join(cwd, cssFile); let content = await readFile(fullPath, "utf8"); const added: string[] = []; From ccdf6ce7c7f8e991712fc3534f92a6e7bd47d3f2 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Tue, 24 Mar 2026 17:27:06 -0400 Subject: [PATCH 11/56] feat: add --plugins option to CLI documentation for enhanced plugin support --- docs/content/docs/cli.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index 85e22cbd..4adc75e1 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -28,6 +28,7 @@ Common flags: |------|-------------| | `--framework` | `nextjs`, `react-router`, or `tanstack` | | `--adapter` | `memory`, `prisma`, `drizzle`, `kysely`, or `mongodb` | +| `--plugins` | Comma-separated plugin keys: `blog`, `ai-chat`, `cms`, `form-builder`, `ui-builder`, `kanban`, `comments`, `media` (or `all`) | | `--cwd` | Target directory | | `--skip-install` | Skip package installation step | | `--yes` | Non-interactive defaults (useful in CI) | From 48043765082922ccaa459921da9bb0e0928edcf0 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Tue, 24 Mar 2026 17:28:04 -0400 Subject: [PATCH 12/56] feat: enhance CLI initialization with plugin support and compile safety checks --- packages/cli/scripts/test-init.sh | 19 +++++-- packages/cli/src/commands/init.ts | 53 +++++++++++++++++-- .../src/templates/nextjs/stack-client.tsx.hbs | 2 +- .../react-router/stack-client.tsx.hbs | 2 +- .../templates/tanstack/stack-client.tsx.hbs | 2 +- .../src/utils/__tests__/scaffold-plan.test.ts | 29 ++++++++++ packages/cli/src/utils/scaffold-plan.ts | 3 ++ 7 files changed, 100 insertions(+), 10 deletions(-) diff --git a/packages/cli/scripts/test-init.sh b/packages/cli/scripts/test-init.sh index 0f860c2e..cb8e0d3a 100644 --- a/packages/cli/scripts/test-init.sh +++ b/packages/cli/scripts/test-init.sh @@ -54,7 +54,7 @@ npm install "$STACK_TARBALL" "$CODEGEN_TARBALL" --legacy-peer-deps success "Installed local @btst/stack and @btst/codegen" step "Running btst init (first pass)" -npx @btst/codegen init --yes --framework nextjs --adapter memory --skip-install 2>&1 | tee "$TEST_DIR/init-first.log" +npx @btst/codegen init --yes --framework nextjs --adapter memory --plugins all --skip-install 2>&1 | tee "$TEST_DIR/init-first.log" if ! node -e 'const fs=require("fs");const s=fs.readFileSync(process.argv[1],"utf8");process.exit(s.includes("Running @btst/codegen init")?0:1)' "$TEST_DIR/init-first.log"; then error "Expected runtime banner not found in init output" exit 1 @@ -62,8 +62,9 @@ fi success "First init run completed" step "Installing runtime deps needed for generated files" -npm install @tanstack/react-query @btst/adapter-memory @btst/yar --legacy-peer-deps -success "Installed runtime deps" +STACK_PEERS=$(node -e 'const fs=require("fs");const p=JSON.parse(fs.readFileSync("node_modules/@btst/stack/package.json","utf8"));process.stdout.write(Object.keys(p.peerDependencies||{}).join(" "));') +npm install @btst/adapter-memory $STACK_PEERS --legacy-peer-deps +success "Installed runtime deps (adapter + @btst/stack peers)" step "Asserting generated files and patches" test -f "lib/stack.ts" @@ -73,6 +74,8 @@ test -f "app/api/data/[[...all]]/route.ts" test -f "app/pages/[[...all]]/page.tsx" test -f "app/pages/layout.tsx" node -e 'const fs=require("fs");const s=fs.readFileSync("lib/stack.ts","utf8");process.exit(s.includes("import { stack } from \"@btst/stack\"")?0:1)' +node -e 'const fs=require("fs");const s=fs.readFileSync("lib/stack.ts","utf8");process.exit(s.includes("mediaBackendPlugin()")?0:1)' +node -e 'const fs=require("fs");const s=fs.readFileSync("app/globals.css","utf8");process.exit(s.includes("@btst/stack/plugins/ui-builder/css")?0:1)' success "Generation + patch checks passed" step "Idempotency check (second pass)" @@ -115,7 +118,7 @@ for (const record of records) { process.stdout.write(hash.digest("hex")); EOF -npx @btst/codegen init --yes --framework nextjs --adapter memory --skip-install > "$TEST_DIR/init-second.log" 2>&1 +npx @btst/codegen init --yes --framework nextjs --adapter memory --plugins all --skip-install > "$TEST_DIR/init-second.log" 2>&1 node <<'EOF' > "$TEST_DIR/init-after.hash" const fs = require("fs"); const path = require("path"); @@ -161,6 +164,14 @@ if [ "$(cat "$TEST_DIR/init-before.hash")" != "$(cat "$TEST_DIR/init-after.hash" fi success "Second run was idempotent" +step "Preparing CSS for compile sanity check" +node -e 'const fs=require("fs");const p="app/globals.css";const s=fs.readFileSync(p,"utf8");const next=s.split("\n").filter((line)=>!line.includes("@btst/stack/plugins/")&&!line.includes("@btst/stack/ui/css")).join("\n");fs.writeFileSync(p,next);' +success "Temporarily removed BTST CSS imports before build" + +step "Generating compile-safe scaffold" +npx @btst/codegen init --yes --framework nextjs --adapter memory --skip-install > "$TEST_DIR/init-compile.log" 2>&1 +success "Regenerated baseline scaffold for compile check" + step "Compiling fixture project" npm run build success "Fixture build succeeded" diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 93ab37f3..0966b386 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -10,7 +10,7 @@ import { select, text, } from "@clack/prompts"; -import { Command } from "commander"; +import { Command, InvalidOptionArgumentError } from "commander"; import { ADAPTERS, DEFAULT_PLUGIN_SELECTION, @@ -34,7 +34,7 @@ import { buildScaffoldPlan } from "../utils/scaffold-plan"; import { collectPrerequisiteWarnings } from "../utils/validate-prerequisites"; import type { Adapter, Framework, InitOptions, PluginKey } from "../types"; -type InitCliOptions = Omit; +type InitCliOptions = InitOptions; function ensureNotCancelled(value: T | symbol): T { if (isCancel(value)) { @@ -94,6 +94,10 @@ async function detectOrSelectAdapter( } async function selectPlugins(options: InitCliOptions): Promise { + if (options.plugins?.length) { + return options.plugins; + } + if (options.yes) return DEFAULT_PLUGIN_SELECTION; const plugins = ensureNotCancelled( @@ -112,6 +116,37 @@ async function selectPlugins(options: InitCliOptions): Promise { return plugins as PluginKey[]; } +function parsePluginOption(value: string): PluginKey[] { + const available = PLUGINS.map((plugin) => plugin.key); + const availableSet = new Set(available); + + if (value.trim().toLowerCase() === "all") { + return [...available]; + } + + const requested = value + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + + if (requested.length === 0) { + throw new InvalidOptionArgumentError( + "Expected a comma-separated list of plugins or 'all'.", + ); + } + + const invalid = requested.filter( + (plugin) => !availableSet.has(plugin as PluginKey), + ); + if (invalid.length > 0) { + throw new InvalidOptionArgumentError( + `Unknown plugin(s): ${invalid.join(", ")}. Valid: ${available.join(", ")}`, + ); + } + + return Array.from(new Set(requested)) as PluginKey[]; +} + async function pickConflictPolicy( yes: boolean | undefined, ): Promise { @@ -137,6 +172,11 @@ export function createInitCommand() { "--adapter ", "memory | prisma | drizzle | kysely | mongodb", ) + .option( + "--plugins ", + "Comma-separated plugin keys, or 'all'", + parsePluginOption, + ) .option("--skip-install", "Skip dependency install") .option("--cwd ", "Target project directory") .option("--yes", "Accept defaults and skip prompts") @@ -283,11 +323,18 @@ export function createInitCommand() { } } + const layoutStatus = + framework === "nextjs" + ? "yes (generated app/pages/layout.tsx)" + : layoutPatch.updated + ? "yes" + : "manual action may be needed"; + outro(`BTST init complete. Files written: ${writeResult.written.length} Files skipped: ${writeResult.skipped.length} CSS updated: ${cssPatch.updated ? "yes" : "no"} -Layout patched: ${layoutPatch.updated ? "yes" : "manual action may be needed"} +Layout patched: ${layoutStatus} Next steps: - Verify routes under /pages/* diff --git a/packages/cli/src/templates/nextjs/stack-client.tsx.hbs b/packages/cli/src/templates/nextjs/stack-client.tsx.hbs index 355f1509..fee2abc5 100644 --- a/packages/cli/src/templates/nextjs/stack-client.tsx.hbs +++ b/packages/cli/src/templates/nextjs/stack-client.tsx.hbs @@ -11,7 +11,7 @@ export function getStackClient(queryClient: QueryClient) { return createStackClient({ plugins: { {{#if clientEntries}} -{{clientEntries}} +{{{clientEntries}}} {{else}} // Add client plugins here. {{/if}} diff --git a/packages/cli/src/templates/react-router/stack-client.tsx.hbs b/packages/cli/src/templates/react-router/stack-client.tsx.hbs index 355f1509..fee2abc5 100644 --- a/packages/cli/src/templates/react-router/stack-client.tsx.hbs +++ b/packages/cli/src/templates/react-router/stack-client.tsx.hbs @@ -11,7 +11,7 @@ export function getStackClient(queryClient: QueryClient) { return createStackClient({ plugins: { {{#if clientEntries}} -{{clientEntries}} +{{{clientEntries}}} {{else}} // Add client plugins here. {{/if}} diff --git a/packages/cli/src/templates/tanstack/stack-client.tsx.hbs b/packages/cli/src/templates/tanstack/stack-client.tsx.hbs index 355f1509..fee2abc5 100644 --- a/packages/cli/src/templates/tanstack/stack-client.tsx.hbs +++ b/packages/cli/src/templates/tanstack/stack-client.tsx.hbs @@ -11,7 +11,7 @@ export function getStackClient(queryClient: QueryClient) { return createStackClient({ plugins: { {{#if clientEntries}} -{{clientEntries}} +{{{clientEntries}}} {{else}} // Add client plugins here. {{/if}} diff --git a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts index fa3fca90..55b4363b 100644 --- a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts +++ b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts @@ -71,4 +71,33 @@ describe("scaffold plan", () => { ); }, ); + + it("renders ui-builder backend plugin without invoking it", async () => { + const plan = await buildScaffoldPlan({ + framework: "nextjs", + adapter: "memory", + plugins: ["ui-builder"], + alias: "@/", + cssFile: "app/globals.css", + }); + + const stackFile = plan.files.find((file) => file.path.endsWith("stack.ts")); + expect(stackFile?.content).toContain("uiBuilder: UI_BUILDER_CONTENT_TYPE,"); + expect(stackFile?.content).not.toContain("UI_BUILDER_CONTENT_TYPE()"); + }); + + it("renders ai-chat backend plugin with compile-safe placeholder model", async () => { + const plan = await buildScaffoldPlan({ + framework: "nextjs", + adapter: "memory", + plugins: ["ai-chat"], + alias: "@/", + cssFile: "app/globals.css", + }); + + const stackFile = plan.files.find((file) => file.path.endsWith("stack.ts")); + expect(stackFile?.content).toContain( + "aiChat: aiChatBackendPlugin({ model: undefined as any }),", + ); + }); }); diff --git a/packages/cli/src/utils/scaffold-plan.ts b/packages/cli/src/utils/scaffold-plan.ts index f751d596..498cc367 100644 --- a/packages/cli/src/utils/scaffold-plan.ts +++ b/packages/cli/src/utils/scaffold-plan.ts @@ -68,6 +68,9 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { .join("\n"), backendEntries: metas .map((m) => { + if (m.key === "ai-chat") { + return `\t\t${m.configKey}: ${m.backendSymbol}({ model: undefined as any }),`; + } if (m.key === "ui-builder") { return `\t\t${m.configKey}: ${m.backendSymbol},`; } From 204a525ee90be33a57180216c282148b700bb665 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Tue, 24 Mar 2026 17:36:13 -0400 Subject: [PATCH 13/56] feat: update release workflow to include README copying and typechecking for both @btst/stack and @btst/codegen --- .github/workflows/release.yml | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7116eac5..fb7a4843 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,15 +31,24 @@ jobs: - run: pnpm install - - name: Copy README to package - run: cp README.md packages/stack/README.md + - name: Copy README files to published packages + run: | + cp README.md packages/stack/README.md + cp README.md packages/cli/README.md - - name: Typecheck + - name: Typecheck @btst/stack working-directory: packages/stack run: pnpm typecheck - - name: Build packages - run: pnpm build --filter "@btst/stack" --force + - name: Typecheck @btst/codegen + working-directory: packages/cli + run: pnpm typecheck + + - name: Build @btst/stack + run: pnpm --filter "@btst/stack" build --force + + - name: Build @btst/codegen + run: pnpm --filter "@btst/codegen" build --force - name: Verify tag matches package version working-directory: packages/stack @@ -60,6 +69,16 @@ jobs: working-directory: packages/stack run: npm publish --access public --provenance + - name: Publish @btst/codegen to npm + working-directory: packages/cli + run: | + PKG_VERSION=$(node -p "require('./package.json').version") + if npm view "@btst/codegen@$PKG_VERSION" version >/dev/null 2>&1; then + echo "@btst/codegen@$PKG_VERSION is already published; skipping." + exit 0 + fi + npm publish --access public --provenance + - name: Upload npm logs on failure if: failure() uses: actions/upload-artifact@v4 From adb0b7fb3e2032a902ac6c83ce527cefa29a9220 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Tue, 24 Mar 2026 17:36:23 -0400 Subject: [PATCH 14/56] feat: enhance CSS import handling to preserve blank lines and improve insertion logic --- packages/cli/src/utils/__tests__/patchers.test.ts | 14 ++++++++++++++ packages/cli/src/utils/css-patcher.ts | 8 ++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/utils/__tests__/patchers.test.ts b/packages/cli/src/utils/__tests__/patchers.test.ts index e2b4c164..c17c9217 100644 --- a/packages/cli/src/utils/__tests__/patchers.test.ts +++ b/packages/cli/src/utils/__tests__/patchers.test.ts @@ -53,6 +53,20 @@ describe("patchers", () => { ); }); + it("keeps blank lines inside import block when inserting imports", async () => { + const cwd = await makeTempProject("css-import-gaps"); + await mkdir(join(cwd, "app"), { recursive: true }); + const cssPath = join(cwd, "app/globals.css"); + await writeFile(cssPath, '@import "a.css";\n\n@import "b.css";\n'); + + await patchCssImports(cwd, "app/globals.css", ["test/plugin.css"]); + const next = await readFile(cssPath, "utf8"); + + expect(next).toBe( + '@import "a.css";\n\n@import "b.css";\n@import "test/plugin.css";', + ); + }); + it("patches layout with QueryClientProvider", async () => { const cwd = await makeTempProject("layout-patch"); await mkdir(join(cwd, "app"), { recursive: true }); diff --git a/packages/cli/src/utils/css-patcher.ts b/packages/cli/src/utils/css-patcher.ts index 6f0182a6..437bc3f7 100644 --- a/packages/cli/src/utils/css-patcher.ts +++ b/packages/cli/src/utils/css-patcher.ts @@ -34,13 +34,17 @@ export async function patchCssImports( .join("\n"); const lines = content.split("\n"); const firstNonImportIndex = lines.findIndex( - (line) => !line.trimStart().startsWith("@import "), + (line) => + line.trim().length > 0 && !line.trimStart().startsWith("@import "), ); if (firstNonImportIndex === 0) { content = `${importBlock}\n${content}`; } else if (firstNonImportIndex === -1) { - content = content.length > 0 ? `${content}\n${importBlock}` : importBlock; + content = + content.length > 0 + ? `${content.replace(/\n+$/, "")}\n${importBlock}` + : importBlock; } else { lines.splice(firstNonImportIndex, 0, importBlock, ""); content = lines.join("\n"); From 48b8e87098e06773b00a95a3d4193a3768eafb97 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Tue, 24 Mar 2026 17:46:52 -0400 Subject: [PATCH 15/56] refactor: extract file existence check into a separate utility function for improved code reuse --- packages/cli/src/utils/detect-css-file.ts | 11 +---------- packages/cli/src/utils/detect-package-manager.ts | 11 +---------- packages/cli/src/utils/detect-project-shape.ts | 11 +---------- packages/cli/src/utils/exists.ts | 10 ++++++++++ packages/cli/src/utils/validate-prerequisites.ts | 12 ++---------- 5 files changed, 15 insertions(+), 40 deletions(-) create mode 100644 packages/cli/src/utils/exists.ts diff --git a/packages/cli/src/utils/detect-css-file.ts b/packages/cli/src/utils/detect-css-file.ts index 079ae8bb..b5a4f167 100644 --- a/packages/cli/src/utils/detect-css-file.ts +++ b/packages/cli/src/utils/detect-css-file.ts @@ -1,15 +1,6 @@ -import { access } from "node:fs/promises"; import { join } from "node:path"; import type { Framework } from "../types"; - -async function exists(path: string): Promise { - try { - await access(path); - return true; - } catch { - return false; - } -} +import { exists } from "./exists"; const CSS_CANDIDATES: Record = { nextjs: ["app/globals.css", "src/app/globals.css"], diff --git a/packages/cli/src/utils/detect-package-manager.ts b/packages/cli/src/utils/detect-package-manager.ts index ce2775ac..c9c35f10 100644 --- a/packages/cli/src/utils/detect-package-manager.ts +++ b/packages/cli/src/utils/detect-package-manager.ts @@ -1,15 +1,6 @@ -import { access } from "node:fs/promises"; import { join } from "node:path"; import type { PackageManager } from "../types"; - -async function exists(path: string): Promise { - try { - await access(path); - return true; - } catch { - return false; - } -} +import { exists } from "./exists"; export async function detectPackageManager( cwd: string, diff --git a/packages/cli/src/utils/detect-project-shape.ts b/packages/cli/src/utils/detect-project-shape.ts index 200ebb8c..b67b3922 100644 --- a/packages/cli/src/utils/detect-project-shape.ts +++ b/packages/cli/src/utils/detect-project-shape.ts @@ -1,15 +1,6 @@ -import { access } from "node:fs/promises"; import { join } from "node:path"; import type { Framework } from "../types"; - -async function exists(path: string): Promise { - try { - await access(path); - return true; - } catch { - return false; - } -} +import { exists } from "./exists"; const FRAMEWORK_REQUIRED_PATHS: Record = { nextjs: [], diff --git a/packages/cli/src/utils/exists.ts b/packages/cli/src/utils/exists.ts new file mode 100644 index 00000000..2e6debe6 --- /dev/null +++ b/packages/cli/src/utils/exists.ts @@ -0,0 +1,10 @@ +import { access } from "node:fs/promises"; + +export async function exists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} diff --git a/packages/cli/src/utils/validate-prerequisites.ts b/packages/cli/src/utils/validate-prerequisites.ts index 48dab31f..83cf1858 100644 --- a/packages/cli/src/utils/validate-prerequisites.ts +++ b/packages/cli/src/utils/validate-prerequisites.ts @@ -1,14 +1,6 @@ -import { access, readFile } from "node:fs/promises"; +import { readFile } from "node:fs/promises"; import { join } from "node:path"; - -async function exists(path: string): Promise { - try { - await access(path); - return true; - } catch { - return false; - } -} +import { exists } from "./exists"; export async function collectPrerequisiteWarnings( cwd: string, From bf5b98e1fcf92edc8832b658b27382431d004e65 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Tue, 24 Mar 2026 17:49:07 -0400 Subject: [PATCH 16/56] fix: update stack templates to use triple braces for backendEntries rendering --- packages/cli/src/templates/nextjs/stack.ts.hbs | 2 +- packages/cli/src/templates/react-router/stack.ts.hbs | 2 +- packages/cli/src/templates/tanstack/stack.ts.hbs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/templates/nextjs/stack.ts.hbs b/packages/cli/src/templates/nextjs/stack.ts.hbs index fe88eafe..30ac46c5 100644 --- a/packages/cli/src/templates/nextjs/stack.ts.hbs +++ b/packages/cli/src/templates/nextjs/stack.ts.hbs @@ -11,7 +11,7 @@ const myStack = stack({ basePath: "/api/data", plugins: { {{#if backendEntries}} -{{backendEntries}} +{{{backendEntries}}} {{else}} // Add backend plugins here. {{/if}} diff --git a/packages/cli/src/templates/react-router/stack.ts.hbs b/packages/cli/src/templates/react-router/stack.ts.hbs index fe88eafe..30ac46c5 100644 --- a/packages/cli/src/templates/react-router/stack.ts.hbs +++ b/packages/cli/src/templates/react-router/stack.ts.hbs @@ -11,7 +11,7 @@ const myStack = stack({ basePath: "/api/data", plugins: { {{#if backendEntries}} -{{backendEntries}} +{{{backendEntries}}} {{else}} // Add backend plugins here. {{/if}} diff --git a/packages/cli/src/templates/tanstack/stack.ts.hbs b/packages/cli/src/templates/tanstack/stack.ts.hbs index fe88eafe..30ac46c5 100644 --- a/packages/cli/src/templates/tanstack/stack.ts.hbs +++ b/packages/cli/src/templates/tanstack/stack.ts.hbs @@ -11,7 +11,7 @@ const myStack = stack({ basePath: "/api/data", plugins: { {{#if backendEntries}} -{{backendEntries}} +{{{backendEntries}}} {{else}} // Add backend plugins here. {{/if}} From 4cf5e666b41db3cd8ac6385004ed45f8c9c366fe Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Tue, 24 Mar 2026 17:58:10 -0400 Subject: [PATCH 17/56] fix: enhance error handling in detectAlias function and add tests for tsconfig parsing --- .../src/utils/__tests__/detect-alias.test.ts | 52 +++++++++++++++++++ packages/cli/src/utils/detect-alias.ts | 8 ++- 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/utils/__tests__/detect-alias.test.ts diff --git a/packages/cli/src/utils/__tests__/detect-alias.test.ts b/packages/cli/src/utils/__tests__/detect-alias.test.ts new file mode 100644 index 00000000..9e726cdc --- /dev/null +++ b/packages/cli/src/utils/__tests__/detect-alias.test.ts @@ -0,0 +1,52 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { detectAlias } from "../detect-alias"; + +async function makeTempProject(name: string): Promise { + const dir = join(tmpdir(), `btst-cli-detect-alias-${name}-${Date.now()}`); + await mkdir(dir, { recursive: true }); + return dir; +} + +describe("detectAlias", () => { + it("falls back to default alias when tsconfig contains JSONC", async () => { + const cwd = await makeTempProject("jsonc"); + await writeFile( + join(cwd, "tsconfig.json"), + `{ + // JSONC comment + "compilerOptions": { + "paths": { + "@/*": ["./src/*"] + } + } +} +`, + ); + + await expect(detectAlias(cwd)).resolves.toBe("@/"); + }); + + it("continues to jsconfig when tsconfig cannot be parsed", async () => { + const cwd = await makeTempProject("fallback-next-candidate"); + await writeFile(join(cwd, "tsconfig.json"), "{ invalid json"); + await writeFile( + join(cwd, "jsconfig.json"), + JSON.stringify( + { + compilerOptions: { + paths: { + "~/*": ["./src/*"], + }, + }, + }, + null, + 2, + ), + ); + + await expect(detectAlias(cwd)).resolves.toBe("~/"); + }); +}); diff --git a/packages/cli/src/utils/detect-alias.ts b/packages/cli/src/utils/detect-alias.ts index 6a42b54e..7ee8fffa 100644 --- a/packages/cli/src/utils/detect-alias.ts +++ b/packages/cli/src/utils/detect-alias.ts @@ -17,7 +17,13 @@ export async function detectAlias(cwd: string): Promise { continue; } - const parsed = JSON.parse(await readFile(filePath, "utf8")) as TsConfigLike; + let parsed: TsConfigLike; + try { + const raw = await readFile(filePath, "utf8"); + parsed = JSON.parse(raw) as TsConfigLike; + } catch { + continue; + } const paths = parsed.compilerOptions?.paths ?? {}; if ("@/*" in paths) return "@/"; if ("~/*" in paths) return "~/"; From 794a345cc5b3c0a1a87f02105fccad7ea815b018 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Tue, 24 Mar 2026 18:02:16 -0400 Subject: [PATCH 18/56] feat: add pagesLayoutPath to ScaffoldPlan and update initialization logic for Next.js --- packages/cli/src/commands/init.ts | 2 +- packages/cli/src/types.ts | 1 + .../cli/src/utils/__tests__/scaffold-plan.test.ts | 13 +++++++++++++ packages/cli/src/utils/scaffold-plan.ts | 1 + 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 0966b386..ea366e46 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -325,7 +325,7 @@ export function createInitCommand() { const layoutStatus = framework === "nextjs" - ? "yes (generated app/pages/layout.tsx)" + ? `yes (generated ${plan.pagesLayoutPath ?? "app/pages/layout.tsx"})` : layoutPatch.updated ? "yes" : "manual action may be needed"; diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 4fbabe3c..b5745980 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -35,4 +35,5 @@ export interface ScaffoldPlan { files: FileWritePlanItem[]; layoutPatchTarget: string; cssPatchTarget: string; + pagesLayoutPath?: string; } diff --git a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts index 55b4363b..0d8200c3 100644 --- a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts +++ b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts @@ -24,6 +24,19 @@ describe("scaffold plan", () => { expect(plan.files[1]?.content).toContain( 'const baseURL = "http://localhost:3000"', ); + expect(plan.pagesLayoutPath).toBe("app/pages/layout.tsx"); + }); + + it("resolves src-prefixed Next.js pages layout path", async () => { + const plan = await buildScaffoldPlan({ + framework: "nextjs", + adapter: "memory", + plugins: ["blog"], + alias: "@/", + cssFile: "src/app/globals.css", + }); + + expect(plan.pagesLayoutPath).toBe("src/app/pages/layout.tsx"); }); it.each(["nextjs", "react-router", "tanstack"] as const)( diff --git a/packages/cli/src/utils/scaffold-plan.ts b/packages/cli/src/utils/scaffold-plan.ts index 498cc367..0a67713c 100644 --- a/packages/cli/src/utils/scaffold-plan.ts +++ b/packages/cli/src/utils/scaffold-plan.ts @@ -218,5 +218,6 @@ export async function buildScaffoldPlan( files, layoutPatchTarget: frameworkPaths.layoutPatchTarget, cssPatchTarget: input.cssFile, + pagesLayoutPath: frameworkPaths.pagesLayoutPath, }; } From be68327222f7716a646c813a0047083821c55ba2 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Tue, 24 Mar 2026 18:02:21 -0400 Subject: [PATCH 19/56] chore: update package.json for CLI and stack; change CLI binary path and bump stack version to 2.10.0 --- packages/cli/package.json | 2 +- packages/stack/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 6897c000..12e1fc48 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -19,7 +19,7 @@ }, "type": "module", "bin": { - "btst": "./dist/index.cjs" + "btst": "dist/index.cjs" }, "main": "./dist/index.cjs", "module": "./dist/index.mjs", diff --git a/packages/stack/package.json b/packages/stack/package.json index 5b573966..afdea357 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -1,6 +1,6 @@ { "name": "@btst/stack", - "version": "2.9.4", + "version": "2.10.0", "description": "A composable, plugin-based library for building full-stack applications.", "repository": { "type": "git", From be9c080c6e66e9f5b280e7df00856554ea551341 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Tue, 24 Mar 2026 18:53:35 -0400 Subject: [PATCH 20/56] feat: implement project hash generation in test-init script for idempotency checks --- packages/cli/scripts/test-init.sh | 120 +++++++++++------------------- 1 file changed, 44 insertions(+), 76 deletions(-) diff --git a/packages/cli/scripts/test-init.sh b/packages/cli/scripts/test-init.sh index cb8e0d3a..4731f130 100644 --- a/packages/cli/scripts/test-init.sh +++ b/packages/cli/scripts/test-init.sh @@ -22,6 +22,48 @@ cleanup() { } trap cleanup EXIT +write_project_hash() { + local output_file="$1" + node <<'EOF' > "$output_file" +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); + +const root = process.cwd(); +const ignored = new Set(["node_modules", ".next", ".git"]); +const records = []; + +function walk(dir) { + for (const name of fs.readdirSync(dir, { withFileTypes: true })) { + if (ignored.has(name.name)) continue; + const absolutePath = path.join(dir, name.name); + const relativePath = path.relative(root, absolutePath); + if (name.isDirectory()) { + walk(absolutePath); + continue; + } + if (!name.isFile()) continue; + records.push({ + path: relativePath, + content: fs.readFileSync(absolutePath), + }); + } +} + +walk(root); +records.sort((a, b) => a.path.localeCompare(b.path)); + +const hash = crypto.createHash("sha256"); +for (const record of records) { + hash.update(record.path); + hash.update("\0"); + hash.update(record.content); + hash.update("\0"); +} +process.stdout.write(hash.digest("hex")); +EOF +} + step "Packing local tarballs" cd "$ROOT_DIR/packages/stack" STACK_TGZ=$(npm pack --quiet 2>/dev/null | tr -d '[:space:]') @@ -79,84 +121,10 @@ node -e 'const fs=require("fs");const s=fs.readFileSync("app/globals.css","utf8" success "Generation + patch checks passed" step "Idempotency check (second pass)" -node <<'EOF' > "$TEST_DIR/init-before.hash" -const fs = require("fs"); -const path = require("path"); -const crypto = require("crypto"); - -const root = process.cwd(); -const ignored = new Set(["node_modules", ".next", ".git"]); -const records = []; - -function walk(dir) { - for (const name of fs.readdirSync(dir, { withFileTypes: true })) { - if (ignored.has(name.name)) continue; - const absolutePath = path.join(dir, name.name); - const relativePath = path.relative(root, absolutePath); - if (name.isDirectory()) { - walk(absolutePath); - continue; - } - if (!name.isFile()) continue; - records.push({ - path: relativePath, - content: fs.readFileSync(absolutePath), - }); - } -} - -walk(root); -records.sort((a, b) => a.path.localeCompare(b.path)); - -const hash = crypto.createHash("sha256"); -for (const record of records) { - hash.update(record.path); - hash.update("\0"); - hash.update(record.content); - hash.update("\0"); -} -process.stdout.write(hash.digest("hex")); -EOF +write_project_hash "$TEST_DIR/init-before.hash" npx @btst/codegen init --yes --framework nextjs --adapter memory --plugins all --skip-install > "$TEST_DIR/init-second.log" 2>&1 -node <<'EOF' > "$TEST_DIR/init-after.hash" -const fs = require("fs"); -const path = require("path"); -const crypto = require("crypto"); - -const root = process.cwd(); -const ignored = new Set(["node_modules", ".next", ".git"]); -const records = []; - -function walk(dir) { - for (const name of fs.readdirSync(dir, { withFileTypes: true })) { - if (ignored.has(name.name)) continue; - const absolutePath = path.join(dir, name.name); - const relativePath = path.relative(root, absolutePath); - if (name.isDirectory()) { - walk(absolutePath); - continue; - } - if (!name.isFile()) continue; - records.push({ - path: relativePath, - content: fs.readFileSync(absolutePath), - }); - } -} - -walk(root); -records.sort((a, b) => a.path.localeCompare(b.path)); - -const hash = crypto.createHash("sha256"); -for (const record of records) { - hash.update(record.path); - hash.update("\0"); - hash.update(record.content); - hash.update("\0"); -} -process.stdout.write(hash.digest("hex")); -EOF +write_project_hash "$TEST_DIR/init-after.hash" if [ "$(cat "$TEST_DIR/init-before.hash")" != "$(cat "$TEST_DIR/init-after.hash")" ]; then error "Second init run produced file changes" From ad782b3a7634242054c32a1557f47a717ad4244b Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Tue, 24 Mar 2026 19:09:14 -0400 Subject: [PATCH 21/56] feat: update CLI documentation and scaffold plan to use camelCase for plugin config keys --- docs/content/docs/cli.mdx | 4 +++- .../src/utils/__tests__/scaffold-plan.test.ts | 24 +++++++++++++++++++ packages/cli/src/utils/scaffold-plan.ts | 2 +- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index 4adc75e1..191362f6 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -1,6 +1,6 @@ --- title: CLI -description: Generate database schemas and migrations using the BTST CLI. +description: Scaffold projects and run schema generation/migrations with BTST CLIs. --- import { Tabs, Tab } from "fumadocs-ui/components/tabs"; @@ -44,6 +44,8 @@ Common flags: If root layout patching is not safe for your file shape, the command prints manual patch instructions instead of applying a destructive rewrite. +Generated plugin config entries in `lib/stack.ts` and `lib/stack-client.tsx` use camelCase config keys (for example `aiChat`, `uiBuilder`, `formBuilder`) even though plugin selection flags use kebab-case names. + ## Generate and Migrate via Codegen If you prefer one command surface, these delegate to `@btst/cli`: diff --git a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts index 0d8200c3..95aa256d 100644 --- a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts +++ b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts @@ -99,6 +99,30 @@ describe("scaffold plan", () => { expect(stackFile?.content).not.toContain("UI_BUILDER_CONTENT_TYPE()"); }); + it("uses camelCase config keys for client plugins", async () => { + const plan = await buildScaffoldPlan({ + framework: "nextjs", + adapter: "memory", + plugins: ["ai-chat", "ui-builder", "form-builder"], + alias: "@/", + cssFile: "app/globals.css", + }); + + const stackClientFile = plan.files.find((file) => + file.path.endsWith("stack-client.tsx"), + ); + expect(stackClientFile?.content).toContain("aiChat: aiChatClientPlugin({"); + expect(stackClientFile?.content).toContain( + "uiBuilder: uiBuilderClientPlugin({", + ); + expect(stackClientFile?.content).toContain( + "formBuilder: formBuilderClientPlugin({", + ); + expect(stackClientFile?.content).not.toContain('"ai-chat":'); + expect(stackClientFile?.content).not.toContain('"ui-builder":'); + expect(stackClientFile?.content).not.toContain('"form-builder":'); + }); + it("renders ai-chat backend plugin with compile-safe placeholder model", async () => { const plan = await buildScaffoldPlan({ framework: "nextjs", diff --git a/packages/cli/src/utils/scaffold-plan.ts b/packages/cli/src/utils/scaffold-plan.ts index 0a67713c..539daede 100644 --- a/packages/cli/src/utils/scaffold-plan.ts +++ b/packages/cli/src/utils/scaffold-plan.ts @@ -80,7 +80,7 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { clientEntries: metas .map((m) => { const siteBase = "/pages"; - return `\t\t\t${JSON.stringify(m.key)}: ${m.clientSymbol}({ + return `\t\t\t${m.configKey}: ${m.clientSymbol}({ \t\t\t\tapiBaseURL: baseURL, \t\t\t\tapiBasePath: "/api/data", \t\t\t\tsiteBaseURL: baseURL, From 18f2096e4808f6853a71e33b21c2f01cc8023cb2 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Tue, 24 Mar 2026 19:57:49 -0400 Subject: [PATCH 22/56] feat: update getGenerateHintForAdapter to accept configPath and adjust initialization logic for stack file paths --- packages/cli/src/commands/init.ts | 13 ++++++------- packages/cli/src/utils/passthrough.ts | 7 +++++-- packages/cli/src/utils/validate-prerequisites.ts | 2 ++ 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index ea366e46..57f44f64 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -288,7 +288,12 @@ export function createInitCommand() { } if (adapterNeedsGenerate(adapter)) { - const hint = getGenerateHintForAdapter(adapter); + const stackPath = + plan.files.find((file) => file.path.endsWith("lib/stack.ts"))?.path ?? + (framework === "react-router" + ? "app/lib/stack.ts" + : "src/lib/stack.ts"); + const hint = getGenerateHintForAdapter(adapter, stackPath); if (hint) { const runNow = rawOptions.yes ? false @@ -302,12 +307,6 @@ export function createInitCommand() { const orm = ADAPTERS.find( (item) => item.key === adapter, )?.ormForGenerate; - const stackPath = - plan.files.find((file) => file.path.endsWith("lib/stack.ts")) - ?.path ?? - (framework === "react-router" - ? "app/lib/stack.ts" - : "src/lib/stack.ts"); const args = orm ? [`--orm=${orm}`, `--config=${stackPath}`] : [`--config=${stackPath}`]; diff --git a/packages/cli/src/utils/passthrough.ts b/packages/cli/src/utils/passthrough.ts index 9861bcfa..8cbcc0c1 100644 --- a/packages/cli/src/utils/passthrough.ts +++ b/packages/cli/src/utils/passthrough.ts @@ -7,7 +7,10 @@ export function adapterNeedsGenerate(adapter: Adapter): boolean { return Boolean(ADAPTERS.find((item) => item.key === adapter)?.ormForGenerate); } -export function getGenerateHintForAdapter(adapter: Adapter): string | null { +export function getGenerateHintForAdapter( + adapter: Adapter, + configPath: string, +): string | null { const meta = ADAPTERS.find((item) => item.key === adapter); if (!meta?.ormForGenerate) return null; @@ -18,7 +21,7 @@ export function getGenerateHintForAdapter(adapter: Adapter): string | null { ? "src/db/schema.ts" : "migrations/schema.sql"; - return `npx @btst/codegen generate --orm=${meta.ormForGenerate} --config=lib/stack.ts --output=${output}`; + return `npx @btst/codegen generate --orm=${meta.ormForGenerate} --config=${configPath} --output=${output}`; } export async function runCliPassthrough(input: { diff --git a/packages/cli/src/utils/validate-prerequisites.ts b/packages/cli/src/utils/validate-prerequisites.ts index 83cf1858..a3fb1cfc 100644 --- a/packages/cli/src/utils/validate-prerequisites.ts +++ b/packages/cli/src/utils/validate-prerequisites.ts @@ -14,6 +14,7 @@ export async function collectPrerequisiteWarnings( let cssContent = ""; for (const candidate of [ "app/globals.css", + "src/app/globals.css", "src/styles/globals.css", "app/app.css", ]) { @@ -31,6 +32,7 @@ export async function collectPrerequisiteWarnings( let hasSonner = false; for (const candidate of [ "app/layout.tsx", + "src/app/layout.tsx", "app/root.tsx", "src/routes/__root.tsx", ]) { From 09ebf0e6e1192ec25b10da85ff37e1f1ebd5a4de Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Tue, 24 Mar 2026 20:12:22 -0400 Subject: [PATCH 23/56] feat: implement caching for stack clients and update base URL retrieval logic in stack-client templates --- .../src/templates/nextjs/stack-client.tsx.hbs | 18 ++++++++++++++++-- .../react-router/stack-client.tsx.hbs | 18 ++++++++++++++++-- .../templates/tanstack/stack-client.tsx.hbs | 18 ++++++++++++++++-- .../src/utils/__tests__/scaffold-plan.test.ts | 9 ++++----- 4 files changed, 52 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/templates/nextjs/stack-client.tsx.hbs b/packages/cli/src/templates/nextjs/stack-client.tsx.hbs index fee2abc5..c9ff5845 100644 --- a/packages/cli/src/templates/nextjs/stack-client.tsx.hbs +++ b/packages/cli/src/templates/nextjs/stack-client.tsx.hbs @@ -4,11 +4,23 @@ import { QueryClient } from "@tanstack/react-query" {{{clientImports}}} {{/if}} +const stackClients = new WeakMap>() +{{#if clientEntries}} +const getBaseURL = () => + typeof window !== "undefined" + ? window.location.origin + : (process.env.BTST_BASE_URL ?? + process.env.NEXT_PUBLIC_BASE_URL ?? + "http://localhost:3000") +{{/if}} + export function getStackClient(queryClient: QueryClient) { + const cached = stackClients.get(queryClient) + if (cached) return cached {{#if clientEntries}} - const baseURL = "http://localhost:3000" + const baseURL = getBaseURL() {{/if}} - return createStackClient({ + const stackClient = createStackClient({ plugins: { {{#if clientEntries}} {{{clientEntries}}} @@ -17,4 +29,6 @@ export function getStackClient(queryClient: QueryClient) { {{/if}} }, }) + stackClients.set(queryClient, stackClient) + return stackClient } diff --git a/packages/cli/src/templates/react-router/stack-client.tsx.hbs b/packages/cli/src/templates/react-router/stack-client.tsx.hbs index fee2abc5..4c844366 100644 --- a/packages/cli/src/templates/react-router/stack-client.tsx.hbs +++ b/packages/cli/src/templates/react-router/stack-client.tsx.hbs @@ -4,11 +4,23 @@ import { QueryClient } from "@tanstack/react-query" {{{clientImports}}} {{/if}} +const stackClients = new WeakMap>() +{{#if clientEntries}} +const getBaseURL = () => + typeof window !== "undefined" + ? window.location.origin + : (process.env.BTST_BASE_URL ?? + import.meta.env.VITE_BASE_URL ?? + "http://localhost:3000") +{{/if}} + export function getStackClient(queryClient: QueryClient) { + const cached = stackClients.get(queryClient) + if (cached) return cached {{#if clientEntries}} - const baseURL = "http://localhost:3000" + const baseURL = getBaseURL() {{/if}} - return createStackClient({ + const stackClient = createStackClient({ plugins: { {{#if clientEntries}} {{{clientEntries}}} @@ -17,4 +29,6 @@ export function getStackClient(queryClient: QueryClient) { {{/if}} }, }) + stackClients.set(queryClient, stackClient) + return stackClient } diff --git a/packages/cli/src/templates/tanstack/stack-client.tsx.hbs b/packages/cli/src/templates/tanstack/stack-client.tsx.hbs index fee2abc5..4c844366 100644 --- a/packages/cli/src/templates/tanstack/stack-client.tsx.hbs +++ b/packages/cli/src/templates/tanstack/stack-client.tsx.hbs @@ -4,11 +4,23 @@ import { QueryClient } from "@tanstack/react-query" {{{clientImports}}} {{/if}} +const stackClients = new WeakMap>() +{{#if clientEntries}} +const getBaseURL = () => + typeof window !== "undefined" + ? window.location.origin + : (process.env.BTST_BASE_URL ?? + import.meta.env.VITE_BASE_URL ?? + "http://localhost:3000") +{{/if}} + export function getStackClient(queryClient: QueryClient) { + const cached = stackClients.get(queryClient) + if (cached) return cached {{#if clientEntries}} - const baseURL = "http://localhost:3000" + const baseURL = getBaseURL() {{/if}} - return createStackClient({ + const stackClient = createStackClient({ plugins: { {{#if clientEntries}} {{{clientEntries}}} @@ -17,4 +29,6 @@ export function getStackClient(queryClient: QueryClient) { {{/if}} }, }) + stackClients.set(queryClient, stackClient) + return stackClient } diff --git a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts index 95aa256d..16e9804b 100644 --- a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts +++ b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts @@ -21,9 +21,7 @@ describe("scaffold plan", () => { ]); expect(plan.files[0]?.content).toContain("blogBackendPlugin()"); expect(plan.files[1]?.content).toContain("blogClientPlugin"); - expect(plan.files[1]?.content).toContain( - 'const baseURL = "http://localhost:3000"', - ); + expect(plan.files[1]?.content).toContain("const getBaseURL = () =>"); expect(plan.pagesLayoutPath).toBe("app/pages/layout.tsx"); }); @@ -58,7 +56,7 @@ describe("scaffold plan", () => { expect(stackClientFile?.content).not.toContain("const getBaseURL()"); expect(stackClientFile?.content).not.toContain("const getBaseURL ="); expect(stackClientFile?.content).not.toContain( - 'const baseURL = "http://localhost:3000"', + "const baseURL = getBaseURL()", ); }, ); @@ -79,8 +77,9 @@ describe("scaffold plan", () => { file.path.endsWith("stack-client.tsx"), ); expect(stackClientFile?.content).toBeDefined(); + expect(stackClientFile?.content).toContain("const getBaseURL = () =>"); expect(stackClientFile?.content).toContain( - 'const baseURL = "http://localhost:3000"', + "const baseURL = getBaseURL()", ); }, ); From 09ebab7097c328ecc1b60447e853059abc2e8bed Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Tue, 24 Mar 2026 20:24:53 -0400 Subject: [PATCH 24/56] feat: refine stack file path resolution logic in createInitCommand to improve adaptability for different frameworks --- packages/cli/src/commands/init.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 57f44f64..db4cbe68 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -288,11 +288,17 @@ export function createInitCommand() { } if (adapterNeedsGenerate(adapter)) { + const fallbackStackPath = + framework === "react-router" + ? "app/lib/stack.ts" + : framework === "tanstack" + ? "src/lib/stack.ts" + : plan.files.some((file) => file.path.startsWith("src/")) + ? "src/lib/stack.ts" + : "lib/stack.ts"; const stackPath = plan.files.find((file) => file.path.endsWith("lib/stack.ts"))?.path ?? - (framework === "react-router" - ? "app/lib/stack.ts" - : "src/lib/stack.ts"); + fallbackStackPath; const hint = getGenerateHintForAdapter(adapter, stackPath); if (hint) { const runNow = rawOptions.yes From 3dcf30a110fa370b69d47bac2610a3095093126c Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Tue, 24 Mar 2026 20:25:00 -0400 Subject: [PATCH 25/56] refactor: streamline base URL retrieval logic in stack-client templates and remove caching mechanism for stack clients --- .../src/templates/nextjs/stack-client.tsx.hbs | 32 ++++++++++--------- .../react-router/stack-client.tsx.hbs | 32 ++++++++++--------- .../templates/tanstack/stack-client.tsx.hbs | 32 ++++++++++--------- .../src/utils/__tests__/scaffold-plan.test.ts | 8 +++-- 4 files changed, 56 insertions(+), 48 deletions(-) diff --git a/packages/cli/src/templates/nextjs/stack-client.tsx.hbs b/packages/cli/src/templates/nextjs/stack-client.tsx.hbs index c9ff5845..5c5e9db9 100644 --- a/packages/cli/src/templates/nextjs/stack-client.tsx.hbs +++ b/packages/cli/src/templates/nextjs/stack-client.tsx.hbs @@ -4,23 +4,11 @@ import { QueryClient } from "@tanstack/react-query" {{{clientImports}}} {{/if}} -const stackClients = new WeakMap>() -{{#if clientEntries}} -const getBaseURL = () => - typeof window !== "undefined" - ? window.location.origin - : (process.env.BTST_BASE_URL ?? - process.env.NEXT_PUBLIC_BASE_URL ?? - "http://localhost:3000") -{{/if}} - export function getStackClient(queryClient: QueryClient) { - const cached = stackClients.get(queryClient) - if (cached) return cached {{#if clientEntries}} const baseURL = getBaseURL() {{/if}} - const stackClient = createStackClient({ + return createStackClient({ plugins: { {{#if clientEntries}} {{{clientEntries}}} @@ -29,6 +17,20 @@ export function getStackClient(queryClient: QueryClient) { {{/if}} }, }) - stackClients.set(queryClient, stackClient) - return stackClient } +{{#if clientEntries}} +function getBaseURL() { + if (typeof window !== "undefined") { + return window.location.origin + } + + if (typeof process !== "undefined") { + const env = process.env + if (env.BTST_SITE_URL) return env.BTST_SITE_URL + if (env.NEXT_PUBLIC_SITE_URL) return env.NEXT_PUBLIC_SITE_URL + if (env.VERCEL_URL) return `https://${env.VERCEL_URL}` + } + + return "http://localhost:3000" +} +{{/if}} diff --git a/packages/cli/src/templates/react-router/stack-client.tsx.hbs b/packages/cli/src/templates/react-router/stack-client.tsx.hbs index 4c844366..7171ecd3 100644 --- a/packages/cli/src/templates/react-router/stack-client.tsx.hbs +++ b/packages/cli/src/templates/react-router/stack-client.tsx.hbs @@ -4,23 +4,11 @@ import { QueryClient } from "@tanstack/react-query" {{{clientImports}}} {{/if}} -const stackClients = new WeakMap>() -{{#if clientEntries}} -const getBaseURL = () => - typeof window !== "undefined" - ? window.location.origin - : (process.env.BTST_BASE_URL ?? - import.meta.env.VITE_BASE_URL ?? - "http://localhost:3000") -{{/if}} - export function getStackClient(queryClient: QueryClient) { - const cached = stackClients.get(queryClient) - if (cached) return cached {{#if clientEntries}} const baseURL = getBaseURL() {{/if}} - const stackClient = createStackClient({ + return createStackClient({ plugins: { {{#if clientEntries}} {{{clientEntries}}} @@ -29,6 +17,20 @@ export function getStackClient(queryClient: QueryClient) { {{/if}} }, }) - stackClients.set(queryClient, stackClient) - return stackClient } +{{#if clientEntries}} +function getBaseURL() { + if (typeof window !== "undefined") { + return window.location.origin + } + + if (typeof process !== "undefined") { + const env = process.env + if (env.BTST_SITE_URL) return env.BTST_SITE_URL + if (env.PUBLIC_SITE_URL) return env.PUBLIC_SITE_URL + if (env.VERCEL_URL) return `https://${env.VERCEL_URL}` + } + + return "http://localhost:3000" +} +{{/if}} diff --git a/packages/cli/src/templates/tanstack/stack-client.tsx.hbs b/packages/cli/src/templates/tanstack/stack-client.tsx.hbs index 4c844366..897a4b29 100644 --- a/packages/cli/src/templates/tanstack/stack-client.tsx.hbs +++ b/packages/cli/src/templates/tanstack/stack-client.tsx.hbs @@ -4,23 +4,11 @@ import { QueryClient } from "@tanstack/react-query" {{{clientImports}}} {{/if}} -const stackClients = new WeakMap>() -{{#if clientEntries}} -const getBaseURL = () => - typeof window !== "undefined" - ? window.location.origin - : (process.env.BTST_BASE_URL ?? - import.meta.env.VITE_BASE_URL ?? - "http://localhost:3000") -{{/if}} - export function getStackClient(queryClient: QueryClient) { - const cached = stackClients.get(queryClient) - if (cached) return cached {{#if clientEntries}} const baseURL = getBaseURL() {{/if}} - const stackClient = createStackClient({ + return createStackClient({ plugins: { {{#if clientEntries}} {{{clientEntries}}} @@ -29,6 +17,20 @@ export function getStackClient(queryClient: QueryClient) { {{/if}} }, }) - stackClients.set(queryClient, stackClient) - return stackClient } +{{#if clientEntries}} +function getBaseURL() { + if (typeof window !== "undefined") { + return window.location.origin + } + + if (typeof process !== "undefined") { + const env = process.env + if (env.BTST_SITE_URL) return env.BTST_SITE_URL + if (env.VITE_PUBLIC_SITE_URL) return env.VITE_PUBLIC_SITE_URL + if (env.VERCEL_URL) return `https://${env.VERCEL_URL}` + } + + return "http://localhost:3000" +} +{{/if}} diff --git a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts index 16e9804b..b768b80d 100644 --- a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts +++ b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts @@ -21,7 +21,7 @@ describe("scaffold plan", () => { ]); expect(plan.files[0]?.content).toContain("blogBackendPlugin()"); expect(plan.files[1]?.content).toContain("blogClientPlugin"); - expect(plan.files[1]?.content).toContain("const getBaseURL = () =>"); + expect(plan.files[1]?.content).toContain("const baseURL = getBaseURL()"); expect(plan.pagesLayoutPath).toBe("app/pages/layout.tsx"); }); @@ -56,7 +56,7 @@ describe("scaffold plan", () => { expect(stackClientFile?.content).not.toContain("const getBaseURL()"); expect(stackClientFile?.content).not.toContain("const getBaseURL ="); expect(stackClientFile?.content).not.toContain( - "const baseURL = getBaseURL()", + 'const baseURL = "http://localhost:3000"', ); }, ); @@ -77,10 +77,12 @@ describe("scaffold plan", () => { file.path.endsWith("stack-client.tsx"), ); expect(stackClientFile?.content).toBeDefined(); - expect(stackClientFile?.content).toContain("const getBaseURL = () =>"); expect(stackClientFile?.content).toContain( "const baseURL = getBaseURL()", ); + expect(stackClientFile?.content).toContain( + 'if (typeof window !== "undefined")', + ); }, ); From 3f97ce83971e8cf1a735e0159b259a3c128f6a4c Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Tue, 24 Mar 2026 20:27:26 -0400 Subject: [PATCH 26/56] feat: utilize shared query client utility in react-router pages route template to enhance query management --- .../react-router/pages-route.tsx.hbs | 5 +++-- .../src/utils/__tests__/scaffold-plan.test.ts | 22 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/templates/react-router/pages-route.tsx.hbs b/packages/cli/src/templates/react-router/pages-route.tsx.hbs index cb7940f6..352a7c7c 100644 --- a/packages/cli/src/templates/react-router/pages-route.tsx.hbs +++ b/packages/cli/src/templates/react-router/pages-route.tsx.hbs @@ -1,11 +1,12 @@ import type { Route } from "./+types/index" import { useLoaderData, useRouteError } from "react-router" -import { dehydrate, HydrationBoundary, QueryClient, useQueryClient } from "@tanstack/react-query" +import { dehydrate, HydrationBoundary, useQueryClient } from "@tanstack/react-query" import { normalizePath } from "@btst/stack/client" +import { getOrCreateQueryClient } from "{{alias}}lib/query-client" import { getStackClient } from "{{alias}}lib/stack-client" export async function loader({ params }: Route.LoaderArgs) { - const queryClient = new QueryClient() + const queryClient = getOrCreateQueryClient() const path = normalizePath(params["*"]) const route = getStackClient(queryClient).router.getRoute(path) diff --git a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts index b768b80d..df578972 100644 --- a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts +++ b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts @@ -138,4 +138,26 @@ describe("scaffold plan", () => { "aiChat: aiChatBackendPlugin({ model: undefined as any }),", ); }); + + it("uses shared query client utility in react-router pages route template", async () => { + const plan = await buildScaffoldPlan({ + framework: "react-router", + adapter: "memory", + plugins: ["blog"], + alias: "@/", + cssFile: "app/app.css", + }); + + const pagesRouteFile = plan.files.find((file) => + file.path.endsWith("routes/pages/index.tsx"), + ); + expect(pagesRouteFile).toBeDefined(); + expect(pagesRouteFile?.content).toContain( + 'import { getOrCreateQueryClient } from "@/lib/query-client"', + ); + expect(pagesRouteFile?.content).toContain( + "const queryClient = getOrCreateQueryClient()", + ); + expect(pagesRouteFile?.content).not.toContain("new QueryClient()"); + }); }); From 2ed8f4fb039a94662c1ef0686f4563ab92ff6a7d Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Wed, 25 Mar 2026 10:08:08 -0400 Subject: [PATCH 27/56] refactor: migrate stack template to shared library and remove framework-specific implementations for react-router and tanstack --- .../src/templates/react-router/stack.ts.hbs | 22 ------------------- .../{nextjs => shared/lib}/stack.ts.hbs | 0 .../cli/src/templates/tanstack/stack.ts.hbs | 22 ------------------- packages/cli/src/utils/scaffold-plan.ts | 14 +++++++----- 4 files changed, 9 insertions(+), 49 deletions(-) delete mode 100644 packages/cli/src/templates/react-router/stack.ts.hbs rename packages/cli/src/templates/{nextjs => shared/lib}/stack.ts.hbs (100%) delete mode 100644 packages/cli/src/templates/tanstack/stack.ts.hbs diff --git a/packages/cli/src/templates/react-router/stack.ts.hbs b/packages/cli/src/templates/react-router/stack.ts.hbs deleted file mode 100644 index 30ac46c5..00000000 --- a/packages/cli/src/templates/react-router/stack.ts.hbs +++ /dev/null @@ -1,22 +0,0 @@ -import { stack } from "@btst/stack" -{{{adapterImport}}} -{{#if backendImports}} -{{{backendImports}}} -{{/if}} - -{{#if adapterSetup}} -{{{adapterSetup}}} -{{/if}} -const myStack = stack({ - basePath: "/api/data", - plugins: { -{{#if backendEntries}} -{{{backendEntries}}} -{{else}} - // Add backend plugins here. -{{/if}} - }, - {{{adapterStackLine}}} -}) - -export const { handler, dbSchema } = myStack diff --git a/packages/cli/src/templates/nextjs/stack.ts.hbs b/packages/cli/src/templates/shared/lib/stack.ts.hbs similarity index 100% rename from packages/cli/src/templates/nextjs/stack.ts.hbs rename to packages/cli/src/templates/shared/lib/stack.ts.hbs diff --git a/packages/cli/src/templates/tanstack/stack.ts.hbs b/packages/cli/src/templates/tanstack/stack.ts.hbs deleted file mode 100644 index 30ac46c5..00000000 --- a/packages/cli/src/templates/tanstack/stack.ts.hbs +++ /dev/null @@ -1,22 +0,0 @@ -import { stack } from "@btst/stack" -{{{adapterImport}}} -{{#if backendImports}} -{{{backendImports}}} -{{/if}} - -{{#if adapterSetup}} -{{{adapterSetup}}} -{{/if}} -const myStack = stack({ - basePath: "/api/data", - plugins: { -{{#if backendEntries}} -{{{backendEntries}}} -{{else}} - // Add backend plugins here. -{{/if}} - }, - {{{adapterStackLine}}} -}) - -export const { handler, dbSchema } = myStack diff --git a/packages/cli/src/utils/scaffold-plan.ts b/packages/cli/src/utils/scaffold-plan.ts index 539daede..d214c079 100644 --- a/packages/cli/src/utils/scaffold-plan.ts +++ b/packages/cli/src/utils/scaffold-plan.ts @@ -54,6 +54,12 @@ function getFrameworkPaths(framework: Framework, cssFile: string) { }; } +function getPublicSiteURLVar(framework: Framework) { + if (framework === "nextjs") return "NEXT_PUBLIC_SITE_URL"; + if (framework === "react-router") return "PUBLIC_SITE_URL"; + return "VITE_PUBLIC_SITE_URL"; +} + function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { const metas = PLUGINS.filter((plugin) => selectedPlugins.includes(plugin.key), @@ -156,6 +162,7 @@ export async function buildScaffoldPlan( const sharedContext = { alias: input.alias, + publicSiteURLVar: getPublicSiteURLVar(input.framework), ...pluginContext, ...adapterContext, }; @@ -163,16 +170,13 @@ export async function buildScaffoldPlan( const files: FileWritePlanItem[] = [ { path: frameworkPaths.stackPath, - content: await renderTemplate( - `${input.framework}/stack.ts.hbs`, - sharedContext, - ), + content: await renderTemplate("shared/lib/stack.ts.hbs", sharedContext), description: "BTST backend stack configuration", }, { path: frameworkPaths.stackClientPath, content: await renderTemplate( - `${input.framework}/stack-client.tsx.hbs`, + "shared/lib/stack-client.tsx.hbs", sharedContext, ), description: "BTST client stack configuration", From db056d01a7f963a256f92ff29fb75a686f072106 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Wed, 25 Mar 2026 10:08:26 -0400 Subject: [PATCH 28/56] refactor: consolidate stack-client template into shared library, removing framework-specific versions for react-router and tanstack --- .../react-router/stack-client.tsx.hbs | 36 ------------------- .../lib}/stack-client.tsx.hbs | 2 +- .../templates/tanstack/stack-client.tsx.hbs | 36 ------------------- 3 files changed, 1 insertion(+), 73 deletions(-) delete mode 100644 packages/cli/src/templates/react-router/stack-client.tsx.hbs rename packages/cli/src/templates/{nextjs => shared/lib}/stack-client.tsx.hbs (92%) delete mode 100644 packages/cli/src/templates/tanstack/stack-client.tsx.hbs diff --git a/packages/cli/src/templates/react-router/stack-client.tsx.hbs b/packages/cli/src/templates/react-router/stack-client.tsx.hbs deleted file mode 100644 index 7171ecd3..00000000 --- a/packages/cli/src/templates/react-router/stack-client.tsx.hbs +++ /dev/null @@ -1,36 +0,0 @@ -import { createStackClient } from "@btst/stack/client" -import { QueryClient } from "@tanstack/react-query" -{{#if clientImports}} -{{{clientImports}}} -{{/if}} - -export function getStackClient(queryClient: QueryClient) { -{{#if clientEntries}} - const baseURL = getBaseURL() -{{/if}} - return createStackClient({ - plugins: { -{{#if clientEntries}} -{{{clientEntries}}} -{{else}} - // Add client plugins here. -{{/if}} - }, - }) -} -{{#if clientEntries}} -function getBaseURL() { - if (typeof window !== "undefined") { - return window.location.origin - } - - if (typeof process !== "undefined") { - const env = process.env - if (env.BTST_SITE_URL) return env.BTST_SITE_URL - if (env.PUBLIC_SITE_URL) return env.PUBLIC_SITE_URL - if (env.VERCEL_URL) return `https://${env.VERCEL_URL}` - } - - return "http://localhost:3000" -} -{{/if}} diff --git a/packages/cli/src/templates/nextjs/stack-client.tsx.hbs b/packages/cli/src/templates/shared/lib/stack-client.tsx.hbs similarity index 92% rename from packages/cli/src/templates/nextjs/stack-client.tsx.hbs rename to packages/cli/src/templates/shared/lib/stack-client.tsx.hbs index 5c5e9db9..eec21d78 100644 --- a/packages/cli/src/templates/nextjs/stack-client.tsx.hbs +++ b/packages/cli/src/templates/shared/lib/stack-client.tsx.hbs @@ -27,7 +27,7 @@ function getBaseURL() { if (typeof process !== "undefined") { const env = process.env if (env.BTST_SITE_URL) return env.BTST_SITE_URL - if (env.NEXT_PUBLIC_SITE_URL) return env.NEXT_PUBLIC_SITE_URL + if (env.{{publicSiteURLVar}}) return env.{{publicSiteURLVar}} if (env.VERCEL_URL) return `https://${env.VERCEL_URL}` } diff --git a/packages/cli/src/templates/tanstack/stack-client.tsx.hbs b/packages/cli/src/templates/tanstack/stack-client.tsx.hbs deleted file mode 100644 index 897a4b29..00000000 --- a/packages/cli/src/templates/tanstack/stack-client.tsx.hbs +++ /dev/null @@ -1,36 +0,0 @@ -import { createStackClient } from "@btst/stack/client" -import { QueryClient } from "@tanstack/react-query" -{{#if clientImports}} -{{{clientImports}}} -{{/if}} - -export function getStackClient(queryClient: QueryClient) { -{{#if clientEntries}} - const baseURL = getBaseURL() -{{/if}} - return createStackClient({ - plugins: { -{{#if clientEntries}} -{{{clientEntries}}} -{{else}} - // Add client plugins here. -{{/if}} - }, - }) -} -{{#if clientEntries}} -function getBaseURL() { - if (typeof window !== "undefined") { - return window.location.origin - } - - if (typeof process !== "undefined") { - const env = process.env - if (env.BTST_SITE_URL) return env.BTST_SITE_URL - if (env.VITE_PUBLIC_SITE_URL) return env.VITE_PUBLIC_SITE_URL - if (env.VERCEL_URL) return `https://${env.VERCEL_URL}` - } - - return "http://localhost:3000" -} -{{/if}} From 08f027b4ba40cdc95eab9b6d6ec93598da9055a0 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Wed, 25 Mar 2026 11:04:25 -0400 Subject: [PATCH 29/56] fix: handle missing CSS file gracefully in patchCssImports function and add corresponding test case --- packages/cli/src/utils/__tests__/patchers.test.ts | 8 ++++++++ packages/cli/src/utils/css-patcher.ts | 10 +++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/utils/__tests__/patchers.test.ts b/packages/cli/src/utils/__tests__/patchers.test.ts index c17c9217..50946b28 100644 --- a/packages/cli/src/utils/__tests__/patchers.test.ts +++ b/packages/cli/src/utils/__tests__/patchers.test.ts @@ -39,6 +39,14 @@ describe("patchers", () => { expect(second.match(/test\/base\.css/g)?.length).toBe(1); }); + it("does not throw when css file is missing", async () => { + const cwd = await makeTempProject("css-missing-file"); + const result = await patchCssImports(cwd, "app/globals.css", [ + "test/plugin.css", + ]); + expect(result).toEqual({ updated: false, added: [] }); + }); + it("appends imports when file contains only import lines", async () => { const cwd = await makeTempProject("css-import-only"); await mkdir(join(cwd, "app"), { recursive: true }); diff --git a/packages/cli/src/utils/css-patcher.ts b/packages/cli/src/utils/css-patcher.ts index 437bc3f7..5423fa6b 100644 --- a/packages/cli/src/utils/css-patcher.ts +++ b/packages/cli/src/utils/css-patcher.ts @@ -15,7 +15,15 @@ export async function patchCssImports( } const fullPath = join(cwd, cssFile); - let content = await readFile(fullPath, "utf8"); + let content: string; + try { + content = await readFile(fullPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { updated: false, added: [] }; + } + throw error; + } const added: string[] = []; for (const specifier of importsToEnsure) { From 0eb27ca85107077a0ae7b450aee7bcd10bfa21aa Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Wed, 25 Mar 2026 11:13:07 -0400 Subject: [PATCH 30/56] chore: bump version to 0.1.1 in package.json --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 12e1fc48..29933473 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@btst/codegen", - "version": "0.1.0", + "version": "0.1.1", "description": "BTST project scaffolding and CLI passthrough commands.", "repository": { "type": "git", From eb05aaf779f4c05039223235394d61983cde9bb5 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Wed, 25 Mar 2026 11:37:52 -0400 Subject: [PATCH 31/56] fix: update patchCssImports to correctly insert imports after the last existing import --- .../cli/src/utils/__tests__/patchers.test.ts | 34 +++++++++++++++++++ packages/cli/src/utils/css-patcher.ts | 21 +++++++----- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/utils/__tests__/patchers.test.ts b/packages/cli/src/utils/__tests__/patchers.test.ts index 50946b28..a0284c85 100644 --- a/packages/cli/src/utils/__tests__/patchers.test.ts +++ b/packages/cli/src/utils/__tests__/patchers.test.ts @@ -75,6 +75,40 @@ describe("patchers", () => { ); }); + it("inserts after the last import across comment separators", async () => { + const cwd = await makeTempProject("css-import-comment-separator"); + await mkdir(join(cwd, "app"), { recursive: true }); + const cssPath = join(cwd, "app/globals.css"); + await writeFile( + cssPath, + '@import "a.css";\n/* plugin imports */\n@import "b.css";\nbody { color: red; }\n', + ); + + await patchCssImports(cwd, "app/globals.css", ["test/plugin.css"]); + const next = await readFile(cssPath, "utf8"); + + expect(next).toBe( + '@import "a.css";\n/* plugin imports */\n@import "b.css";\n@import "test/plugin.css";\nbody { color: red; }\n', + ); + }); + + it("inserts after the last import even with at-rules between import groups", async () => { + const cwd = await makeTempProject("css-import-theme-separator"); + await mkdir(join(cwd, "app"), { recursive: true }); + const cssPath = join(cwd, "app/globals.css"); + await writeFile( + cssPath, + '@import "a.css";\n@theme {\n\t--color-brand: oklch(62% 0.19 275);\n}\n@import "b.css";\n', + ); + + await patchCssImports(cwd, "app/globals.css", ["test/plugin.css"]); + const next = await readFile(cssPath, "utf8"); + + expect(next).toBe( + '@import "a.css";\n@theme {\n\t--color-brand: oklch(62% 0.19 275);\n}\n@import "b.css";\n@import "test/plugin.css";\n', + ); + }); + it("patches layout with QueryClientProvider", async () => { const cwd = await makeTempProject("layout-patch"); await mkdir(join(cwd, "app"), { recursive: true }); diff --git a/packages/cli/src/utils/css-patcher.ts b/packages/cli/src/utils/css-patcher.ts index 5423fa6b..22693386 100644 --- a/packages/cli/src/utils/css-patcher.ts +++ b/packages/cli/src/utils/css-patcher.ts @@ -41,20 +41,23 @@ export async function patchCssImports( .map((specifier) => toImportLine(specifier)) .join("\n"); const lines = content.split("\n"); - const firstNonImportIndex = lines.findIndex( + const hasNonImportContent = lines.some( (line) => line.trim().length > 0 && !line.trimStart().startsWith("@import "), ); + const lastImportIndex = lines.reduce((index, line, lineIndex) => { + if (line.trimStart().startsWith("@import ")) { + return lineIndex; + } + return index; + }, -1); - if (firstNonImportIndex === 0) { - content = `${importBlock}\n${content}`; - } else if (firstNonImportIndex === -1) { - content = - content.length > 0 - ? `${content.replace(/\n+$/, "")}\n${importBlock}` - : importBlock; + if (lastImportIndex === -1) { + content = content.length > 0 ? `${importBlock}\n${content}` : importBlock; + } else if (!hasNonImportContent) { + content = `${content.replace(/\n+$/, "")}\n${importBlock}`; } else { - lines.splice(firstNonImportIndex, 0, importBlock, ""); + lines.splice(lastImportIndex + 1, 0, importBlock); content = lines.join("\n"); } From e00ca9112a6ddebfaf09d48097d947b6c3d5b552 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Wed, 25 Mar 2026 11:49:02 -0400 Subject: [PATCH 32/56] chore: use diff library for diffs of file-writer.ts --- packages/cli/package.json | 2 ++ packages/cli/src/utils/file-writer.ts | 25 +++++++++++++++---------- pnpm-lock.yaml | 22 ++++++++++++++++++---- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 29933473..b4d0775d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -40,11 +40,13 @@ "dependencies": { "@clack/prompts": "^0.11.0", "commander": "^14.0.1", + "diff": "^8.0.4", "execa": "^9.6.0", "handlebars": "^4.7.8", "ts-morph": "^27.0.2" }, "devDependencies": { + "@types/diff": "^8.0.0", "@types/node": "^24.9.2", "tsx": "catalog:", "typescript": "catalog:", diff --git a/packages/cli/src/utils/file-writer.ts b/packages/cli/src/utils/file-writer.ts index a09603fc..547d90ff 100644 --- a/packages/cli/src/utils/file-writer.ts +++ b/packages/cli/src/utils/file-writer.ts @@ -1,23 +1,28 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { confirm, isCancel, select } from "@clack/prompts"; +import { diffLines } from "diff"; import type { FileWritePlanItem } from "../types"; export type ConflictPolicy = "ask" | "skip" | "overwrite"; +const PREVIEW_LINE_LIMIT = 12; + function makeDiffPreview(previousContent: string, nextContent: string): string { - const before = previousContent.split("\n"); - const after = nextContent.split("\n"); - const max = Math.max(before.length, after.length); + const hunks = diffLines(previousContent, nextContent); const out: string[] = []; - for (let index = 0; index < max; index++) { - const prev = before[index]; - const next = after[index]; - if (prev === next) continue; - if (prev !== undefined) out.push(`- ${prev}`); - if (next !== undefined) out.push(`+ ${next}`); - if (out.length > 12) break; + for (const hunk of hunks) { + if (!hunk.added && !hunk.removed) continue; + const lines = hunk.value.replace(/\n$/, "").split("\n"); + const prefix = hunk.added ? "+" : "-"; + for (const line of lines) { + out.push(`${prefix} ${line}`); + if (out.length >= PREVIEW_LINE_LIMIT) { + out.push("... (diff truncated)"); + return out.join("\n"); + } + } } return out.join("\n"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d50cdf41..0fe92229 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -536,6 +536,9 @@ importers: commander: specifier: ^14.0.1 version: 14.0.3 + diff: + specifier: ^8.0.4 + version: 8.0.4 execa: specifier: ^9.6.0 version: 9.6.1 @@ -546,6 +549,9 @@ importers: specifier: ^27.0.2 version: 27.0.2 devDependencies: + '@types/diff': + specifier: ^8.0.0 + version: 8.0.0 '@types/node': specifier: ^24.9.2 version: 24.10.1 @@ -4710,6 +4716,10 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/diff@8.0.0': + resolution: {integrity: sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw==} + deprecated: This is a stub types definition. diff provides its own type definitions, so you do not need this installed. + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -5937,8 +5947,8 @@ packages: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} - diff@8.0.2: - resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} dir-glob@3.0.1: @@ -14982,7 +14992,7 @@ snapshots: '@babel/parser': 7.28.5 '@babel/preset-typescript': 7.28.5(@babel/core@7.28.5) ansis: 4.2.0 - diff: 8.0.2 + diff: 8.0.4 pathe: 2.0.3 tinyglobby: 0.2.15 transitivePeerDependencies: @@ -15475,6 +15485,10 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/diff@8.0.0': + dependencies: + diff: 8.0.4 + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -16930,7 +16944,7 @@ snapshots: diff@4.0.2: {} - diff@8.0.2: {} + diff@8.0.4: {} dir-glob@3.0.1: dependencies: From 383c71bb1736dd99b2ad079eca8e689917c4ad22 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Wed, 25 Mar 2026 12:08:12 -0400 Subject: [PATCH 33/56] feat: add BTST integration skill for AI agents and enhance documentation --- .agents/skills/btst-integration/REFERENCE.md | 275 +++++++++++++++++++ .agents/skills/btst-integration/SKILL.md | 101 +++++++ .agents/skills/write-a-skill/SKILL.md | 117 ++++++++ README.md | 12 + docs/content/docs/installation.mdx | 12 + skills-lock.json | 10 + 6 files changed, 527 insertions(+) create mode 100644 .agents/skills/btst-integration/REFERENCE.md create mode 100644 .agents/skills/btst-integration/SKILL.md create mode 100644 .agents/skills/write-a-skill/SKILL.md create mode 100644 skills-lock.json diff --git a/.agents/skills/btst-integration/REFERENCE.md b/.agents/skills/btst-integration/REFERENCE.md new file mode 100644 index 00000000..a4a76671 --- /dev/null +++ b/.agents/skills/btst-integration/REFERENCE.md @@ -0,0 +1,275 @@ +# BTST Integration — Reference + +## getBaseURL helper + +A server/client-safe URL helper — required for `apiBaseURL` in every plugin config and override. + +```ts +// Next.js +const getBaseURL = () => + typeof window !== "undefined" + ? (process.env.NEXT_PUBLIC_BASE_URL || window.location.origin) + : (process.env.BASE_URL || "http://localhost:3000") + +// Vite (React Router / TanStack) +const getBaseURL = () => + typeof window !== "undefined" + ? (import.meta.env.VITE_BASE_URL || window.location.origin) + : (process.env.BASE_URL || "http://localhost:5173") +``` + +--- + +## lib/stack-client.tsx shape + +```tsx +import { createStackClient } from "@btst/stack/client" +import { blogClientPlugin } from "@btst/stack/plugins/blog/client" +import { QueryClient } from "@tanstack/react-query" + +const getBaseURL = () => /* see above */ + +export const getStackClient = ( + queryClient: QueryClient, + options?: { headers?: Headers } +) => { + const baseURL = getBaseURL() + return createStackClient({ + plugins: { + blog: blogClientPlugin({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + siteBaseURL: baseURL, + siteBasePath: "/pages", + queryClient, + headers: options?.headers, // pass for SSR auth + seo: { siteName: "My App" }, // optional + hooks: { // optional client-side loader hooks + beforeLoadPost: async (slug, ctx) => { /* ... */ }, + afterLoadPost: async (post, slug, ctx) => { /* ... */ }, + onLoadError: async (error, ctx) => { /* ... */ }, + }, + }), + // add more plugins… + }, + }) +} +``` + +**Common client plugin config fields** (all plugins): + +| Field | Required | Description | +|---|---|---| +| `apiBaseURL` | Yes | Base URL for API calls (absolute) | +| `apiBasePath` | Yes | API route prefix, e.g. `/api/data` | +| `siteBaseURL` | Yes | Base URL for generated page links | +| `siteBasePath` | Yes | Pages route prefix, e.g. `/pages` | +| `queryClient` | Yes | The QueryClient for this request | +| `headers` | No | Pass incoming request headers for SSR auth | +| `seo` | No | `{ siteName, description, author, twitterHandle, … }` | +| `hooks` | No | Client-side loader hooks (see per-plugin docs) | + +--- + +## SSR headers forwarding (Next.js) + +Pass request cookies/auth headers into the stack client during SSR so plugins can perform authenticated prefetches: + +```ts +// app/pages/[[...all]]/page.tsx +import { headers } from "next/headers" + +export default async function Page({ params }) { + const headersList = await headers() + const headersObj = new Headers() + headersList.forEach((value, key) => headersObj.set(key, value)) + + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient, { headers: headersObj }) + const route = stackClient.router.getRoute(normalizePath((await params).all)) + + if (route?.loader) await route.loader() + return ( + + {route?.PageComponent ? : notFound()} + + ) +} +``` + +--- + +## StackProvider — pages layout + +The pages layout must be `"use client"` and wrap `QueryClientProvider` then `StackProvider`. + +```tsx +// Next.js: app/pages/layout.tsx (or app/pages/[[...all]]/layout.tsx) +"use client" +import { useState } from "react" +import { QueryClientProvider } from "@tanstack/react-query" +import { StackProvider } from "@btst/stack/context" +import { getOrCreateQueryClient } from "@/lib/query-client" +import type { BlogPluginOverrides } from "@btst/stack/plugins/blog/client" +import Link from "next/link" +import { useRouter } from "next/navigation" + +type PluginOverrides = { + blog: BlogPluginOverrides + // add one entry per plugin +} + +export default function PagesLayout({ children }: { children: React.ReactNode }) { + const router = useRouter() + const [queryClient] = useState(() => getOrCreateQueryClient()) + const baseURL = getBaseURL() + + return ( + + + basePath="/pages" + overrides={{ + blog: { + apiBaseURL: baseURL, + apiBasePath: "/api/data", + navigate: (path) => router.push(path), + refresh: () => router.refresh(), + Link: ({ href, ...props }) => , + Image: MyImageWrapper, // optional: Next.js Image wrapper + uploadImage: myUploadFn, // optional: returns uploaded URL + // lifecycle hooks (all optional): + onRouteRender: async (routeName, ctx) => { /* analytics, logging */ }, + onRouteError: async (routeName, err, ctx) => { /* error tracking */ }, + onBeforePostsPageRendered: (ctx) => true, // return false to block + onBeforePostPageRendered: (slug, ctx) => true, + }, + }} + > + {children} + + + ) +} +``` + +### StackProvider props + +| Prop | Required | Description | +|---|---|---| +| `basePath` | Yes | Must match your `/pages/*` catch-all route prefix | +| `overrides` | Yes | Per-plugin override objects, keyed by plugin name | + +### Common override fields (all data plugins) + +| Field | Description | +|---|---| +| `apiBaseURL` | Absolute base URL for API fetches | +| `apiBasePath` | API prefix, e.g. `/api/data` | +| `navigate(path)` | Framework navigation function | +| `Link` | Framework `` component wrapper | +| `Image` | Optional framework `` wrapper (important for Next.js) | +| `refresh()` | Optional router refresh (Next.js: `router.refresh()`) | +| `uploadImage(file)` | Optional — returns URL string after upload | +| `headers` | Optional headers for per-request auth | + +### Lifecycle hooks (available on most plugins) + +| Hook | When | +|---|---| +| `onRouteRender(routeName, ctx)` | After a plugin page renders (SSR or CSR) | +| `onRouteError(routeName, err, ctx)` | On plugin route render error | +| `onBefore{Page}PageRendered(ctx)` | Before a specific page renders; return `false` to block | + +`ctx` contains `{ isSSR: boolean, path: string }`. + +### Plugin-specific override extras + +**blog** +- `postBottomSlot: (post) => ReactNode` — injected below each blog post (e.g. ``) +- `imagePicker`, `imageInputField` — custom image picker components + +**ai-chat** +- `mode: "authenticated" | "public"` — conversation persistence mode +- `uploadFile(file): Promise` — for chat file attachments +- `chatSuggestions: string[]` — pre-filled prompt suggestions + +**ui-builder** +- `componentRegistry` — pass `defaultComponentRegistry` or a custom one + +**kanban** +- `resolveUser(id): Promise<{ name, avatar? }>` — assignee display +- `searchUsers(query): Promise` — assignee search +- `taskDetailBottomSlot: (task) => ReactNode` — inject below task detail (e.g. comments) + +**comments** (standalone, not via StackProvider — use `` directly) +- `currentUserId`, `resourceId`, `resourceType`, `apiBaseURL`, `apiBasePath`, `loginHref` + +**media** +- `queryClient` — pass the current QueryClient explicitly +- `uploadMode: "direct"` — direct-to-storage upload + +--- + +## CSS imports reference + +```css +/* Add to your global stylesheet, after @import "tailwindcss" */ +@import "@btst/stack/plugins/blog/css"; +@import "@btst/stack/plugins/cms/css"; +@import "@btst/stack/plugins/ai-chat/css"; +@import "@btst/stack/plugins/form-builder/css"; +@import "@btst/stack/plugins/ui-builder/css"; +@import "@btst/stack/plugins/kanban/css"; +@import "@btst/stack/plugins/comments/css"; +@import "@btst/stack/plugins/route-docs/css"; +``` + +No CSS import is needed for: `media`, `open-api`. + +--- + +## Backend plugin hooks reference + +Backend plugins accept a hooks object as their factory argument. Common hooks: + +**blog** +```ts +blogBackendPlugin({ + onBeforeCreatePost: async (data) => { /* throw to deny */ }, + onBeforeUpdatePost: async (postId) => { /* throw to deny */ }, + onBeforeDeletePost: async (postId) => { /* throw to deny */ }, + onBeforeListPosts: async (filter) => { /* throw to deny drafts to unauthed users */ }, + onPostCreated: async (post) => { revalidatePath("/pages/blog") }, + onPostUpdated: async (post) => { /* … */ }, + onPostDeleted: async (postId) => { /* … */ }, +}) +``` + +**comments** +```ts +commentsBackendPlugin({ + autoApprove: false, + resolveUser: async (authorId) => ({ name: "…" }), + resolveCurrentUserId: async (ctx) => ctx?.headers?.get("x-user-id") ?? null, + onBeforePost: async (input, ctx) => ({ authorId: "from-session" }), + onBeforeEdit: async (commentId, update, ctx) => { /* auth check */ }, + onBeforeStatusChange: async (commentId, status, ctx) => { /* admin check */ }, +}) +``` + +**ai-chat** +```ts +aiChatBackendPlugin({ + model: openai("gpt-4o"), + systemPrompt: "…", + mode: "authenticated", + tools: { myTool }, + enablePageTools: true, + getUserId: async (ctx) => ctx.headers?.get("x-user-id") ?? null, + hooks: { + onConversationCreated: async (convo) => { /* … */ }, + onAfterChat: async (conversationId, messages) => { /* … */ }, + onBeforeToolsActivated: async (toolNames, routeName, ctx) => toolNames, + }, +}) +``` diff --git a/.agents/skills/btst-integration/SKILL.md b/.agents/skills/btst-integration/SKILL.md new file mode 100644 index 00000000..49b50e82 --- /dev/null +++ b/.agents/skills/btst-integration/SKILL.md @@ -0,0 +1,101 @@ +--- +name: btst-integration +description: Guides developers and AI agents through manual BTST library consumption in existing apps, including plugin registration, CSS imports, adapter setup, StackProvider wiring, React Query, and CLI schema workflows. Use when integrating `@btst/stack`, configuring `@btst/adapter-*`, wiring plugin `api/client` packages, setting up `StackProvider` overrides, adding `/api/data` and `/pages` routes, or running `@btst/cli generate|migrate` without scaffolding. +--- + +# BTST Library Integration + +## Quick start + +1. Install `@btst/stack`, `@tanstack/react-query`, and one `@btst/adapter-*`. +2. Install and register each plugin's backend and client halves. +3. Create `lib/stack.ts` → export `{ handler, dbSchema }`. +4. Mount a catch-all API route at `/api/data/*` forwarding all methods to `handler`. +5. Add `@import "@btst/stack/plugins/{plugin}/css"` per plugin in your global CSS. +6. Create `lib/stack-client.tsx`, `lib/query-client.ts`, and the `/pages/*` catch-all route. +7. Create the pages **layout** file with `QueryClientProvider` + `StackProvider`. +8. Run `@btst/cli generate` (and `migrate` for Kysely). + +See [REFERENCE.md](REFERENCE.md) for full code shapes. + +## Workflow + +### 1) Install prerequisites and packages + +- Prereqs: shadcn/ui (CSS variables enabled), Sonner ``, Tailwind v4. +- Install: `@btst/stack`, `@tanstack/react-query`, and one adapter: + - `@btst/adapter-prisma` / `@btst/adapter-drizzle` / `@btst/adapter-kysely` / `@btst/adapter-mongodb` + - `@btst/adapter-memory` — dev/testing only, not for production +- Install each plugin package alongside `@btst/stack` (they ship inside the monorepo package). + +### 2) Register plugins (backend + client) + +- **Backend** (`lib/stack.ts`): import `{plugin}BackendPlugin` from `@btst/stack/plugins/{plugin}/api`. + - Pass hooks and config to the backend plugin factory (e.g. `blogBackendPlugin(hooks)`). + - Use camelCase keys for plugins that have compound names: `aiChat`, `formBuilder`, `uiBuilder`. +- **Client** (`lib/stack-client.tsx`): import `{plugin}ClientPlugin` from `@btst/stack/plugins/{plugin}/client`. + - Each client plugin factory receives: `{ apiBaseURL, apiBasePath, siteBaseURL, siteBasePath, queryClient, headers?, seo?, hooks? }`. + - Pass `headers` from the incoming request for SSR authentication. + +### 3) Configure backend stack + +- Call `stack({ basePath: "/api/data", plugins: { ... }, adapter: (db) => createXxxAdapter(..., db, {}) })`. +- Export `handler` and `dbSchema`. +- **Memory adapter + Next.js**: pin to `globalThis` to avoid two instances in the same process: + ```ts + const g = global as typeof global & { __btst__?: ReturnType } + export const myStack = g.__btst__ ??= stack({ ... }) + export const { handler, dbSchema } = myStack + ``` + +### 4) CSS — edit global stylesheet + +Add one line per plugin **before** your Tailwind layers: +```css +@import "@btst/stack/plugins/blog/css"; +@import "@btst/stack/plugins/cms/css"; +@import "@btst/stack/plugins/ai-chat/css"; +/* …one per selected plugin */ +``` +Do not duplicate — the patcher and manual edits must both be idempotent. + +### 5) Wire framework routes and client runtime + +- **API route**: catch-all at `/api/data/*`, forward GET/POST/PUT/PATCH/DELETE to `handler`. +- **Pages route**: catch-all at `/pages/*` — resolve via `stackClient.router.getRoute(path)`, run `route.loader?.()` server-side, wrap in `HydrationBoundary`. +- **Pages layout** (`"use client"` in Next.js): wrap in `QueryClientProvider` then `StackProvider`: + - `basePath="/pages"` (must match your pages catch-all prefix) + - `overrides={{ pluginKey: { apiBaseURL, apiBasePath, navigate, Link, Image?, refresh?, uploadImage?, ...hooks } }}` + - Define a typed `PluginOverrides` interface importing `{Plugin}Overrides` from each plugin client package. + - See [REFERENCE.md](REFERENCE.md) for the full per-plugin override shape and lifecycle hooks. + +### 6) Generate schemas and run migrations + +- Install `@btst/cli` as a dev dependency. +- Prisma: `npx @btst/cli generate --config=lib/stack.ts --orm=prisma --output=schema.prisma` +- Drizzle: `npx @btst/cli generate --config=lib/stack.ts --orm=drizzle --output=src/db/schema.ts` +- Kysely: `npx @btst/cli generate --config=lib/stack.ts --orm=kysely --output=migrations/schema.sql --database-url=...` +- Kysely migrate: `npx @btst/cli migrate --config=lib/stack.ts --database-url=...` +- Prisma/Drizzle: use native migration tooling after generate. + +## Validation checklist + +- `stack.ts` exports both `handler` and `dbSchema`. +- Every plugin is registered on both backend and client sides. +- API `basePath` and `stack({ basePath })` match exactly. +- Pages layout is `"use client"` and wraps `QueryClientProvider` then `StackProvider`. +- `StackProvider` `basePath` matches the `/pages` catch-all route prefix. +- Global CSS has one `@import` line per selected plugin. +- `/pages/*` routes render expected plugin pages. +- CLI commands run with required env vars in scope. + +## Gotchas + +- **`"use client"` missing on pages layout** — `StackProvider` uses hooks and must be client-side. +- **Wrong key casing** — backend/client plugin map keys must match; compound names use camelCase (`aiChat`, not `ai-chat`). +- **Missing CSS import** — plugin UI breaks silently even when data loads correctly. +- **Half-registered plugin** — backend-only = routes don't exist; client-only = 404 on data calls. +- **Memory adapter + Next.js** — always pin to `globalThis` to share one in-memory store across API and page bundles. +- **Path aliases in CLI** — `@btst/cli` executes your config file directly; use relative imports in `lib/stack.ts` and its dependencies. +- **Kysely generate needs DB** — pass `DATABASE_URL` or `--database-url`; use `dotenv-cli` for `.env.local`. +- **SSR headers for auth** — forward `await headers()` (Next.js) into `getStackClient(queryClient, { headers })` so plugins can read cookies/auth tokens during SSR. diff --git a/.agents/skills/write-a-skill/SKILL.md b/.agents/skills/write-a-skill/SKILL.md new file mode 100644 index 00000000..7339c8a3 --- /dev/null +++ b/.agents/skills/write-a-skill/SKILL.md @@ -0,0 +1,117 @@ +--- +name: write-a-skill +description: Create new agent skills with proper structure, progressive disclosure, and bundled resources. Use when user wants to create, write, or build a new skill. +--- + +# Writing Skills + +## Process + +1. **Gather requirements** - ask user about: + - What task/domain does the skill cover? + - What specific use cases should it handle? + - Does it need executable scripts or just instructions? + - Any reference materials to include? + +2. **Draft the skill** - create: + - SKILL.md with concise instructions + - Additional reference files if content exceeds 500 lines + - Utility scripts if deterministic operations needed + +3. **Review with user** - present draft and ask: + - Does this cover your use cases? + - Anything missing or unclear? + - Should any section be more/less detailed? + +## Skill Structure + +``` +skill-name/ +├── SKILL.md # Main instructions (required) +├── REFERENCE.md # Detailed docs (if needed) +├── EXAMPLES.md # Usage examples (if needed) +└── scripts/ # Utility scripts (if needed) + └── helper.js +``` + +## SKILL.md Template + +```md +--- +name: skill-name +description: Brief description of capability. Use when [specific triggers]. +--- + +# Skill Name + +## Quick start + +[Minimal working example] + +## Workflows + +[Step-by-step processes with checklists for complex tasks] + +## Advanced features + +[Link to separate files: See [REFERENCE.md](REFERENCE.md)] +``` + +## Description Requirements + +The description is **the only thing your agent sees** when deciding which skill to load. It's surfaced in the system prompt alongside all other installed skills. Your agent reads these descriptions and picks the relevant skill based on the user's request. + +**Goal**: Give your agent just enough info to know: + +1. What capability this skill provides +2. When/why to trigger it (specific keywords, contexts, file types) + +**Format**: + +- Max 1024 chars +- Write in third person +- First sentence: what it does +- Second sentence: "Use when [specific triggers]" + +**Good example**: + +``` +Extract text and tables from PDF files, fill forms, merge documents. Use when working with PDF files or when user mentions PDFs, forms, or document extraction. +``` + +**Bad example**: + +``` +Helps with documents. +``` + +The bad example gives your agent no way to distinguish this from other document skills. + +## When to Add Scripts + +Add utility scripts when: + +- Operation is deterministic (validation, formatting) +- Same code would be generated repeatedly +- Errors need explicit handling + +Scripts save tokens and improve reliability vs generated code. + +## When to Split Files + +Split into separate files when: + +- SKILL.md exceeds 100 lines +- Content has distinct domains (finance vs sales schemas) +- Advanced features are rarely needed + +## Review Checklist + +After drafting, verify: + +- [ ] Description includes triggers ("Use when...") +- [ ] SKILL.md under 100 lines +- [ ] No time-sensitive info +- [ ] Consistent terminology +- [ ] Concrete examples included +- [ ] References one level deep diff --git a/README.md b/README.md index 9c7af8c6..0681c82f 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,18 @@ Components are copied into `src/components/btst/{plugin}/client/` — all relati --- +## AI Agent Skills + +If you're using an AI coding agent (Cursor, Windsurf, etc.) you can install the BTST integration skill so your agent understands the plugin system, adapter setup, and wiring patterns out of the box: + +```bash +npx skills@latest add better-stack-ai/better-stack/.agents/skills/btst-integration +``` + +Or manually copy [`skills/btst-integration/SKILL.md`](./.agents/skills/btst-integration/SKILL.md) into your project's agent skills directory. + +--- + ## Examples * [Next.js App Router](./examples/nextjs) diff --git a/docs/content/docs/installation.mdx b/docs/content/docs/installation.mdx index 04f15f91..a0232bd3 100644 --- a/docs/content/docs/installation.mdx +++ b/docs/content/docs/installation.mdx @@ -8,6 +8,18 @@ import { Tabs, Tab } from "fumadocs-ui/components/tabs"; import { Callout } from "fumadocs-ui/components/callout"; +## AI Agent Skills + +If you're using an AI coding agent (Cursor, Windsurf, etc.) you can install the BTST integration skill so your agent understands the plugin system, adapter setup, and wiring patterns out of the box: + +```bash +npx skills@latest add better-stack-ai/better-stack/.agents/skills/btst-integration +``` + +Or manually copy the [`SKILL.md`](https://github.com/better-stack-ai/better-stack/blob/main/.agents/skills/btst-integration/SKILL.md) file into your project's agent skills directory. + +--- + ## Prerequisites In order to use BTST, your application must meet the following requirements: diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 00000000..9cd4cca2 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "skills": { + "write-a-skill": { + "source": "mattpocock/skills", + "sourceType": "github", + "computedHash": "b44d8aab2ead83c716e01af4c9a24ccc4575ce70ad58ec4f1749fb88c9cc82ba" + } + } +} From 149f2d95faf9f95a04ead92072f0370b4c742347 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Wed, 25 Mar 2026 12:10:26 -0400 Subject: [PATCH 34/56] fix: update return statement retrieval logic in patchLayoutWithQueryClientProvider to ensure the last return statement is correctly identified --- packages/cli/src/utils/layout-patcher.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/utils/layout-patcher.ts b/packages/cli/src/utils/layout-patcher.ts index 1109178b..9ac89874 100644 --- a/packages/cli/src/utils/layout-patcher.ts +++ b/packages/cli/src/utils/layout-patcher.ts @@ -69,9 +69,10 @@ export async function patchLayoutWithQueryClientProvider( for (const fn of candidateFunctions) { const body = fn.getBody(); if (!body || !body.isKind(SyntaxKind.Block)) continue; - const returnStatement = body.getDescendantsOfKind( - SyntaxKind.ReturnStatement, - )[0]; + const returnStatement = body + .getStatements() + .filter((s) => s.isKind(SyntaxKind.ReturnStatement)) + .at(-1); if (!returnStatement) continue; const expression = returnStatement.getExpression(); if (!expression) continue; From d17cf80e4e1a7b6a557a40b9c63492e3fc6e932d Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Wed, 25 Mar 2026 14:05:58 -0400 Subject: [PATCH 35/56] docs: update AI agent skills section in README and installation documentation to include additional coding agents --- README.md | 2 +- docs/content/docs/installation.mdx | 2 +- packages/cli/src/commands/init.ts | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0681c82f..1b6dfa42 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ Components are copied into `src/components/btst/{plugin}/client/` — all relati ## AI Agent Skills -If you're using an AI coding agent (Cursor, Windsurf, etc.) you can install the BTST integration skill so your agent understands the plugin system, adapter setup, and wiring patterns out of the box: +If you're using an AI coding agent (Cursor, Claude Code, VS Code, OpenAI Codex etc.) you can install the BTST integration skill so your agent understands the plugin system, adapter setup, and wiring patterns out of the box: ```bash npx skills@latest add better-stack-ai/better-stack/.agents/skills/btst-integration diff --git a/docs/content/docs/installation.mdx b/docs/content/docs/installation.mdx index a0232bd3..db2be09d 100644 --- a/docs/content/docs/installation.mdx +++ b/docs/content/docs/installation.mdx @@ -10,7 +10,7 @@ import { Callout } from "fumadocs-ui/components/callout"; ## AI Agent Skills -If you're using an AI coding agent (Cursor, Windsurf, etc.) you can install the BTST integration skill so your agent understands the plugin system, adapter setup, and wiring patterns out of the box: +If you're using an AI coding agent (Cursor, Claude Code, VS Code, OpenAI Codex etc.) you can install the BTST integration skill so your agent understands the plugin system, adapter setup, and wiring patterns out of the box: ```bash npx skills@latest add better-stack-ai/better-stack/.agents/skills/btst-integration diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index db4cbe68..b528c8c4 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -345,6 +345,9 @@ Next steps: - Verify routes under /pages/* - Run your build - Use npx @btst/codegen generate or npx @btst/codegen migrate as needed + +Tip: Using an AI coding agent (Cursor, Claude Code, VS Code, OpenAI Codex etc.)? Install the BTST skill so your agent understands the plugin system out of the box: + npx skills@latest add better-stack-ai/better-stack/.agents/skills/btst-integration `); }); } From 3be6707f86d2d0fb04db5f0dfc991dfed600a438 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Wed, 25 Mar 2026 15:35:32 -0400 Subject: [PATCH 36/56] refactor: agents.md to individial skills.md --- .agents/skills/btst-ai-context/SKILL.md | 92 ++ .../btst-backend-plugin-dev/REFERENCE.md | 205 +++++ .../skills/btst-backend-plugin-dev/SKILL.md | 82 ++ .agents/skills/btst-build-config/SKILL.md | 124 +++ .../skills/btst-client-plugin-dev/EXAMPLES.md | 104 +++ .../btst-client-plugin-dev/REFERENCE.md | 158 ++++ .../skills/btst-client-plugin-dev/SKILL.md | 117 +++ .agents/skills/btst-docs/SKILL.md | 67 ++ .agents/skills/btst-integration/REFERENCE.md | 141 ++++ .agents/skills/btst-integration/SKILL.md | 11 +- .agents/skills/btst-plugin-ssg/REFERENCE.md | 172 ++++ .agents/skills/btst-plugin-ssg/SKILL.md | 62 ++ .agents/skills/btst-registry/SKILL.md | 55 ++ .agents/skills/btst-testing/SKILL.md | 128 +++ AGENTS.md | 797 +----------------- 15 files changed, 1529 insertions(+), 786 deletions(-) create mode 100644 .agents/skills/btst-ai-context/SKILL.md create mode 100644 .agents/skills/btst-backend-plugin-dev/REFERENCE.md create mode 100644 .agents/skills/btst-backend-plugin-dev/SKILL.md create mode 100644 .agents/skills/btst-build-config/SKILL.md create mode 100644 .agents/skills/btst-client-plugin-dev/EXAMPLES.md create mode 100644 .agents/skills/btst-client-plugin-dev/REFERENCE.md create mode 100644 .agents/skills/btst-client-plugin-dev/SKILL.md create mode 100644 .agents/skills/btst-docs/SKILL.md create mode 100644 .agents/skills/btst-plugin-ssg/REFERENCE.md create mode 100644 .agents/skills/btst-plugin-ssg/SKILL.md create mode 100644 .agents/skills/btst-registry/SKILL.md create mode 100644 .agents/skills/btst-testing/SKILL.md diff --git a/.agents/skills/btst-ai-context/SKILL.md b/.agents/skills/btst-ai-context/SKILL.md new file mode 100644 index 00000000..dafdada2 --- /dev/null +++ b/.agents/skills/btst-ai-context/SKILL.md @@ -0,0 +1,92 @@ +--- +name: btst-ai-context +description: Patterns for registering page-level AI context in BTST plugin pages so the AI chat widget understands the current page and can act on it. Use when adding useRegisterPageAIContext to a plugin page, implementing clientTools for AI-driven form filling or editor updates, registering server-side tool schemas in BUILT_IN_PAGE_TOOL_SCHEMAS, or wiring PageAIContextProvider in layouts. +--- + +# BTST AI Chat Page Context + +Plugin pages can register AI context so the chat widget understands the current page and can act on it (fill forms, update editors, summarize content). + +## useRegisterPageAIContext + +Call inside `.internal.tsx` page components. + +### Read-only (content pages) + +```tsx +import { useRegisterPageAIContext } from "@btst/stack/plugins/ai-chat/client/context" + +// Pass null while data is loading — context is not registered until non-null +useRegisterPageAIContext(item ? { + routeName: "my-plugin-detail", + pageDescription: `Viewing: "${item.title}"\n\n${item.content?.slice(0, 16000)}`, + suggestions: ["Summarize this", "What are the key points?"], +} : null) +``` + +### With client-side tools (form/editor pages) + +```tsx +import { useRef } from "react" +import { useRegisterPageAIContext } from "@btst/stack/plugins/ai-chat/client/context" +import type { UseFormReturn } from "react-hook-form" + +const formRef = useRef | null>(null) + +useRegisterPageAIContext({ + routeName: "my-plugin-edit", + pageDescription: "User is editing an item. Help them fill out the form.", + suggestions: ["Fill in the form for me", "Suggest a title"], + clientTools: { + fillMyForm: async ({ title, description }) => { + if (!formRef.current) return { success: false, message: "Form not ready" } + formRef.current.setValue("title", title, { shouldValidate: true }) + formRef.current.setValue("description", description, { shouldValidate: true }) + return { success: true } + }, + }, +}) +``` + +`clientTools` execute client-side only. Return `{ success: boolean, message?: string }`. + +## Server-side tool schemas (first-party tools) + +For first-party tools, add the server-side schema to `BUILT_IN_PAGE_TOOL_SCHEMAS` in `src/plugins/ai-chat/api/page-tools.ts`. No `execute` — that's handled client-side: + +```typescript +// src/plugins/ai-chat/api/page-tools.ts +export const BUILT_IN_PAGE_TOOL_SCHEMAS = { + fillBlogForm: { /* existing */ }, + updatePageLayers: { /* existing */ }, + fillMyForm: { + description: "Fill the my-plugin edit form with the provided values", + parameters: z.object({ + title: z.string().describe("Item title"), + description: z.string().optional().describe("Item description"), + }), + }, +} +``` + +## PageAIContextProvider placement + +`PageAIContextProvider` must wrap the **root layout**, above all `StackProvider` instances: + +```tsx +import { PageAIContextProvider } from "@btst/stack/plugins/ai-chat/client/context" + +export default function RootLayout({ children }) { + return {children} +} +``` + +In the monorepo example apps this is already wired — don't add it again there. In a consumer app, add it once to the root layout when integrating the ai-chat plugin. + +## Reference examples in the codebase + +| File | Pattern | +|---|---| +| `src/plugins/blog/client/components/pages/new-post-page.internal.tsx` | `fillBlogForm` (clientTools) | +| `src/plugins/blog/client/components/pages/post-page.internal.tsx` | Read-only context | +| `src/plugins/ui-builder/client/components/pages/page-builder-page.internal.tsx` | `updatePageLayers` (clientTools) | diff --git a/.agents/skills/btst-backend-plugin-dev/REFERENCE.md b/.agents/skills/btst-backend-plugin-dev/REFERENCE.md new file mode 100644 index 00000000..983e9cae --- /dev/null +++ b/.agents/skills/btst-backend-plugin-dev/REFERENCE.md @@ -0,0 +1,205 @@ +# btst-backend-plugin-dev — Reference + +## defineBackendPlugin shape (api/plugin.ts) + +```typescript +import type { DBAdapter as Adapter } from "@btst/db" +import { defineBackendPlugin, createEndpoint } from "@btst/stack/plugins/api" +import { z } from "zod" +import { dbSchema } from "./db-schema" +import { listItems, getItemById } from "./getters" +import { createItem, updateItem, deleteItem } from "./mutations" + +const ItemQuerySchema = z.object({ limit: z.coerce.number().optional() }) +const CreateItemSchema = z.object({ name: z.string() }) + +export const myBackendPlugin = defineBackendPlugin({ + name: "my-plugin", + dbPlugin: dbSchema, + + // api factory — bound to shared adapter, no HTTP context + api: (adapter: Adapter) => ({ + listItems: () => listItems(adapter), + getItemById: (id: string) => getItemById(adapter, id), + createItem: (data: CreateItemInput) => createItem(adapter, data), + }), + + // routes factory — HTTP endpoints built with createEndpoint + routes: (adapter: Adapter) => { + const listItemsEndpoint = createEndpoint( + "/items", + { method: "GET", query: ItemQuerySchema }, + async (ctx) => { + return await listItems(adapter) + }, + ) + + const createItemEndpoint = createEndpoint( + "/items", + { method: "POST", body: CreateItemSchema }, + async (ctx) => { + return await createItem(adapter, ctx.body) + }, + ) + + const getItemEndpoint = createEndpoint( + "/items/:id", + { method: "GET" }, + async (ctx) => { + const item = await getItemById(adapter, ctx.params.id) + if (!item) throw ctx.error(404, { message: "Item not found" }) + return item + }, + ) + + return { listItems: listItemsEndpoint, createItem: createItemEndpoint, getItem: getItemEndpoint } as const + }, +}) + +// Router type for client consumption +export type MyApiRouter = ReturnType["routes"]> +``` + +### ctx object inside createEndpoint handlers + +| Property | Description | +|---|---| +| `ctx.query` | Validated query params (when `query:` schema provided) | +| `ctx.body` | Validated request body (when `body:` schema provided) | +| `ctx.params` | URL path params (e.g. `:id` → `ctx.params.id`) | +| `ctx.headers` | Request `Headers` object | +| `ctx.request` | Raw `Request` object | +| `ctx.error(status, { message })` | Create an HTTP error — always `throw` the result | + +--- + +## getters.ts + +Pure DB functions — no HTTP context, no lifecycle hooks, always accept `adapter` as first arg: + +```typescript +import type { DBAdapter as Adapter } from "@btst/db" +import type { Item } from "./types" + +// Authorization hooks are NOT called — callers are responsible for access control +export async function listItems(adapter: Adapter): Promise { + return adapter.findMany({ model: "item" }) +} + +export async function getItemById(adapter: Adapter, id: string): Promise { + return adapter.findOne({ model: "item", where: { id } }) ?? null +} +``` + +--- + +## mutations.ts + +Write operations — no auth hooks, no HTTP context. JSDoc disclaimer required: + +```typescript +import type { DBAdapter as Adapter } from "@btst/db" +import type { CreateItemInput, Item } from "./types" + +/** + * Create a new item directly in the database. + * Authorization hooks are NOT called — caller is responsible for access control. + */ +export async function createItem(adapter: Adapter, data: CreateItemInput): Promise { + return adapter.create({ + model: "item", + data: { id: crypto.randomUUID(), ...data, createdAt: new Date() }, + }) +} + +/** + * Update an existing item. + * Authorization hooks are NOT called — caller is responsible for access control. + */ +export async function updateItem( + adapter: Adapter, + id: string, + data: Partial, +): Promise { + return adapter.update({ model: "item", where: { id }, data }) ?? null +} + +/** + * Delete an item. + * Authorization hooks are NOT called — caller is responsible for access control. + */ +export async function deleteItem(adapter: Adapter, id: string): Promise { + await adapter.delete({ model: "item", where: { id } }) +} +``` + +--- + +## api/index.ts + +Re-export getters and mutations for direct server-side import (SSG, scripts, AI tools): + +```typescript +// Getters — read-only, no auth hooks +export { listItems, getItemById } from "./getters" + +// Mutations — write ops, no auth hooks +export { createItem, updateItem, deleteItem } from "./mutations" + +// Types for consumers +export type { MyApiRouter } from "./plugin" +export { MY_PLUGIN_QUERY_KEYS } from "./query-key-defs" +export { serializeItem } from "./serializers" +``` + +--- + +## Lifecycle hook implementation in routes + +```typescript +routes: (adapter: Adapter) => { + const createItemEndpoint = createEndpoint( + "/items", + { method: "POST", body: CreateItemSchema }, + async (ctx) => { + const context = { body: ctx.body, headers: ctx.headers } + + // before hook — throw to deny + await ctx.hooks?.onBeforeItemCreated?.(ctx.body, context) + + const item = await createItem(adapter, ctx.body) + + // after hook — fire and forget or await + await ctx.hooks?.onAfterItemCreated?.(item, context) + + return item + }, + ) + + return { createItem: createItemEndpoint } as const +}, +``` + +Hook naming always follows: `onBefore{Entity}{Action}`, `onAfter{Entity}{Action}`, `on{Entity}{Action}Error`. + +--- + +## Plugin stack() wiring (in stack.ts) + +```typescript +import { stack } from "@btst/stack" +import { myBackendPlugin } from "./src/plugins/my-plugin/api/plugin" + +export const myStack = stack({ + basePath: "/api/data", + plugins: { + myPlugin: myBackendPlugin, + }, + adapter: (db) => createDrizzleAdapter(schema, db, {}), +}) + +export const { handler, dbSchema } = myStack + +// Direct server-side access (bypasses auth hooks): +const items = await myStack.api.myPlugin.listItems() +``` diff --git a/.agents/skills/btst-backend-plugin-dev/SKILL.md b/.agents/skills/btst-backend-plugin-dev/SKILL.md new file mode 100644 index 00000000..d3df5264 --- /dev/null +++ b/.agents/skills/btst-backend-plugin-dev/SKILL.md @@ -0,0 +1,82 @@ +--- +name: btst-backend-plugin-dev +description: Patterns for writing BTST backend plugins inside the monorepo, including defineBackendPlugin structure, getters.ts/mutations.ts separation, the api factory, lifecycle hook naming conventions, and accessing the adapter in AI tool execute functions. Use when creating or modifying a backend plugin, adding DB getters or mutations, wiring the api factory, or implementing lifecycle hooks in src/plugins/{name}/api/. +--- + +# BTST Backend Plugin Development + +## File structure + +``` +src/plugins/{name}/ + api/ + plugin.ts ← defineBackendPlugin entry + getters.ts ← read-only DB functions (no HTTP context) + mutations.ts ← write DB functions (no auth hooks) + index.ts ← re-exports getters + mutations + types + query-keys.ts ← React Query key factory +``` + +## Rules + +- **`getters.ts`** — pure async DB functions only. No HTTP context, no lifecycle hooks. Always takes `adapter` as first arg. +- **`mutations.ts`** — write operations (create/update/delete). No auth hooks, no HTTP context. Add JSDoc: "Authorization hooks are NOT called." +- **`api/index.ts`** — re-export everything from getters + mutations for direct server-side import. +- The `api` factory and `routes` factory share the same adapter instance — bind getters inside the factory, don't pass adapter at call site. +- If the plugin has a one-time init step (e.g. `syncContentTypes`), call it inside each getter/mutation wrapper — not only inside `routes`. +- **Never** use `myStack.api.*` as a substitute for authenticated HTTP endpoints — auth hooks are not called. + +## Key patterns + +- Import `defineBackendPlugin` and `createEndpoint` from `"@btst/stack/plugins/api"` (not `@btst/stack/plugins`). +- Import the adapter type as `import type { DBAdapter as Adapter } from "@btst/db"`. +- Routes are defined with `createEndpoint(path, { method, query?, body? }, handler)` — not string-keyed `"GET /path"` objects. +- Route handlers return data directly (`return item`) — no `ctx.json()`. +- Throw errors with `throw ctx.error(statusCode, { message })`. +- The `routes` factory returns a named object: `return { listItems, createItem } as const`. +- Export the router type as `ReturnType["routes"]>`. + +## Lifecycle hook naming + +Pattern: `onBefore{Entity}{Action}`, `onAfter{Entity}{Action}`, `on{Entity}{Action}Error` + +```typescript +// Examples from existing plugins: +onBeforeListPosts, onPostsRead, onListPostsError +onBeforeCreatePost, onPostCreated, onCreatePostError +onBeforeUpdatePost, onPostUpdated, onUpdatePostError +onBeforeDeletePost, onPostDeleted, onDeletePostError +onBeforePost, onAfterPost // comments plugin (create comment) +onBeforeEdit, onAfterEdit // comments plugin (edit comment) +onBeforeDelete, onAfterDelete // comments plugin (delete comment) +onBeforeStatusChange, onAfterApprove +``` + +## Adapter in AI tool execute functions + +`myStack` is a module-level const. The `execute` closure runs lazily (only on HTTP request), so `myStack` is always initialised by then: + +```typescript +export const myStack = stack({ ... }) + +const myTool = tool({ + execute: async (params) => { + await createKanbanTask(myStack.adapter, { title: params.title, columnId: "col-id" }) + return { success: true } + } +}) +``` + +## Gotchas + +- **Wrong import path** — always import from `"@btst/stack/plugins/api"`, not `"@btst/stack/plugins"`. +- **Wrong adapter type** — use `import type { DBAdapter as Adapter } from "@btst/db"` in getters/mutations/plugin files. +- **`"GET /path"` string keys** — routes use `createEndpoint()`, not string-keyed method/path objects. +- **`ctx.json()`** — does not exist; return data directly from route handlers. +- **`stack().api` bypasses auth hooks** — never use for authenticated data access; enforce auth at the call site. +- **Plugin init not called via `api`** — if `routes` factory runs a setup (e.g. `syncContentTypes`), also await it inside each `api` getter wrapper. +- **Write ops in `getters.ts`** — write functions belong in `mutations.ts`, not `getters.ts`. + +## Full code patterns + +See [REFERENCE.md](REFERENCE.md) for complete `defineBackendPlugin`, getters, mutations, and `api/index.ts` code shapes. diff --git a/.agents/skills/btst-build-config/SKILL.md b/.agents/skills/btst-build-config/SKILL.md new file mode 100644 index 00000000..5dabacf6 --- /dev/null +++ b/.agents/skills/btst-build-config/SKILL.md @@ -0,0 +1,124 @@ +--- +name: btst-build-config +description: Patterns for configuring the BTST monorepo build when adding new plugin entry points, package exports, CSS exports, and updating all three example apps. Use when adding a new export path, updating build.config.ts entries, adding exports/typesVersions to package.json, exposing CSS, updating the Next.js/React Router/TanStack example apps, or adding shared UI components via @workspace/ui. +--- + +# BTST Build Configuration + +## Adding a new export path + +Two files must always be updated together: + +### 1. packages/stack/build.config.ts — add to entries array + +```typescript +entries: [ + // existing entries... + "./src/plugins/{name}/api/index.ts", + "./src/plugins/{name}/client/index.ts", + "./src/plugins/{name}/client/hooks/index.tsx", + "./src/plugins/{name}/client/components/index.tsx", + "./src/plugins/{name}/query-keys.ts", +] +``` + +### 2. packages/stack/package.json — add exports AND typesVersions + +```json +{ + "exports": { + "./plugins/{name}/client/hooks": { + "import": "./dist/plugins/{name}/client/hooks/index.mjs", + "require": "./dist/plugins/{name}/client/hooks/index.cjs" + } + }, + "typesVersions": { + "*": { + "plugins/{name}/client/hooks": ["./dist/plugins/{name}/client/hooks/index.d.ts"] + } + } +} +``` + +**Both entries are required** — missing `typesVersions` breaks TypeScript consumers even when runtime works. + +## CSS exports + +Plugins with UI components need two CSS files: + +1. `src/plugins/{name}/client.css` — client-side styles +2. `src/plugins/{name}/style.css` — full styles with Tailwind source directives + +The `postbuild.cjs` script auto-discovers and copies them — no manual registration needed. Only add the `package.json` export entry: + +```json +{ + "exports": { + "./plugins/{name}/css": "./dist/plugins/{name}/client.css" + } +} +``` + +## Updating all three example apps + +When adding a new plugin or changing plugin config, update ALL three: + +**Next.js** (`examples/nextjs/`) +- `lib/stack.tsx` — backend plugin registration +- `lib/stack-client.tsx` — client plugin registration +- `app/pages/[[...all]]/layout.tsx` — override configuration +- `app/globals.css` — `@import "@btst/stack/plugins/{name}/css";` + +**React Router** (`examples/react-router/`) +- `app/lib/stack.tsx` +- `app/lib/stack-client.tsx` +- `app/routes/pages/_layout.tsx` +- `app/app.css` + +**TanStack** (`examples/tanstack/`) +- `src/lib/stack.tsx` +- `src/lib/stack-client.tsx` +- `src/routes/pages/route.tsx` +- `src/styles/app.css` + +### Override type registration (in each layout) + +```typescript +import type { YourPluginOverrides } from "@btst/stack/plugins/{name}/client" + +type PluginOverrides = { + blog: BlogPluginOverrides, + "ai-chat": AiChatPluginOverrides, + "{name}": YourPluginOverrides, // add here +} +``` + +## Adding shared UI components (@workspace/ui) + +Components live in `packages/ui/src/components/`. Add via shadcn CLI: + +```bash +cd packages/ui +pnpm dlx shadcn@latest add {component-name} +``` + +Import in plugins: +```typescript +import { Button } from "@workspace/ui/button" +import { MarkdownContent } from "@workspace/ui/markdown-content" +``` + +## After changes + +```bash +pnpm build # rebuild all packages +# If turbo cache is stale: +pnpm turbo clean && pnpm build +``` + +## Gotchas + +- **Missing `typesVersions`** — always add alongside `exports`; TypeScript won't resolve the new path otherwise. +- **Build cache** — run `pnpm turbo clean` if changes aren't reflected in examples after `pnpm build`. +- **CSS not loading** — ensure `"./plugins/{name}/css"` entry exists in `package.json` exports; `postbuild.cjs` handles the rest automatically. +- **`@workspace/ui` sub-path components** — if a new component imports from a directory (not a single file), add it to `EXTERNAL_REGISTRY_COMPONENTS` in `build-registry.ts`. diff --git a/.agents/skills/btst-client-plugin-dev/EXAMPLES.md b/.agents/skills/btst-client-plugin-dev/EXAMPLES.md new file mode 100644 index 00000000..a420acf0 --- /dev/null +++ b/.agents/skills/btst-client-plugin-dev/EXAMPLES.md @@ -0,0 +1,104 @@ +# btst-client-plugin-dev — Examples + +## Full ComposedRoute page wiring + +### my-page.tsx (wrapper — public component) + +```typescript +import { lazy } from "react" +import { ComposedRoute } from "@btst/stack/client" +import { DefaultError } from "@workspace/ui/default-error" +import { PageSkeleton } from "@workspace/ui/page-skeleton" +import { NotFoundPage } from "@workspace/ui/not-found" + +// Always lazy-load the internal implementation +const MyPage = lazy(() => + import("./my-page.internal").then(m => ({ default: m.MyPage })) +) + +export function MyPageComponent({ id }: { id: string }) { + return ( + console.error("[my-plugin] page error", error)} + /> + ) +} +``` + +### my-page.internal.tsx (actual UI) + +```typescript +import { useSuspenseQuery } from "@tanstack/react-query" +import { usePluginOverrides } from "@btst/stack/context" +import { createMyQueryKeys } from "../../query-keys" +import { createApiClient } from "@btst/stack/client" +import type { MyApiRouter } from "../../api/plugin" +import type { MyItem } from "../../api/types" + +function useMyItem(id: string) { + const { apiBaseURL, apiBasePath, headers, queryClient } = usePluginOverrides("my-plugin") + const client = createApiClient({ baseURL: apiBaseURL, basePath: apiBasePath }) + const queries = createMyQueryKeys(client, headers) + + const { data, refetch, error, isFetching } = useSuspenseQuery({ + ...queries.items.detail(id), + staleTime: 60_000, + retry: false, + }) + + // useSuspenseQuery only throws on initial fetch — manually re-throw for refetch errors + if (error && !isFetching) throw error + + return { data: data as MyItem, refetch } +} + +export function MyPage({ id }: { id: string }) { + const { data, refetch } = useMyItem(id) + + return ( +
+

{data.title}

+

{data.description}

+ +
+ ) +} +``` + +--- + +## Client hooks (lifecycle) example + +```typescript +// In defineClientPlugin config: +hooks: { + beforeLoadDetail: async (id, ctx) => { + // Return false to prevent loading (e.g. user not authorised) + return true + }, + afterLoadDetail: async (item, id, ctx) => { + // item is the prefetched data + analytics.track("item_viewed", { id }) + }, + onLoadError: async (error, ctx) => { + Sentry.captureException(error, { extra: { path: ctx.path } }) + }, +} +``` + +--- + +## Error flow comparison + +| Situation | Correct pattern | +|---|---| +| Loader fetch fails (SSR) | Catch silently, don't re-throw. React Query stores the error. | +| Component throws | Wrap with ComposedRoute — ErrorBoundary renders DefaultError. | +| Refetch fails (client) | `if (error && !isFetching) throw error` inside the suspense hook. | +| User not found (404) | Return null from API → component calls `notFound()` from ComposedRoute. | diff --git a/.agents/skills/btst-client-plugin-dev/REFERENCE.md b/.agents/skills/btst-client-plugin-dev/REFERENCE.md new file mode 100644 index 00000000..4f827c18 --- /dev/null +++ b/.agents/skills/btst-client-plugin-dev/REFERENCE.md @@ -0,0 +1,158 @@ +# btst-client-plugin-dev — Reference + +## SSR loader (createMyLoader) + +```typescript +import { isConnectionError } from "@btst/stack/plugins/client" + +function createMyLoader(id: string, config: MyClientConfig) { + return async () => { + if (typeof window === "undefined") { + const { queryClient, apiBasePath, apiBaseURL, hooks, headers } = config + + const context: LoaderContext = { + path: `/my-plugin/${id}`, + params: { id }, + isSSR: true, + apiBaseURL, + apiBasePath, + headers, + } + + try { + if (hooks?.beforeLoad) { + const canLoad = await hooks.beforeLoad(id, context) + if (!canLoad) throw new Error("Load prevented by beforeLoad hook") + } + + const client = createApiClient({ baseURL: apiBaseURL, basePath: apiBasePath }) + const queries = createMyQueryKeys(client, headers) + + await queryClient.prefetchQuery(queries.items.detail(id)) + + if (hooks?.afterLoad) { + const data = queryClient.getQueryData(queries.items.detail(id).queryKey) + await hooks.afterLoad(data, id, context) + } + + const queryState = queryClient.getQueryState(queries.items.detail(id).queryKey) + if (queryState?.error && hooks?.onLoadError) { + const error = queryState.error instanceof Error + ? queryState.error + : new Error(String(queryState.error)) + await hooks.onLoadError(error, context) + } + } catch (error) { + if (isConnectionError(error)) { + console.warn("[btst/my-plugin] route.loader() failed — no server at build time. Use myStack.api.myPlugin.prefetchForRoute() for SSG.") + } + if (hooks?.onLoadError) { + await hooks.onLoadError(error as Error, context) + } + // Never re-throw — let React Query store errors for ErrorBoundary + } + } + } +} +``` + +--- + +## Meta generator (createMyMeta) + +```typescript +function createMyMeta(id: string, config: MyClientConfig) { + return () => { + const { queryClient, apiBaseURL, apiBasePath, siteBaseURL, siteBasePath, seo } = config + + const client = createApiClient({ baseURL: apiBaseURL, basePath: apiBasePath }) + const queries = createMyQueryKeys(client) + const data = queryClient.getQueryData(queries.items.detail(id).queryKey) + + if (!data) { + return [ + { title: "Not found" }, + { name: "robots", content: "noindex" }, + ] + } + + const fullUrl = `${siteBaseURL}${siteBasePath}/my-plugin/${id}` + + return [ + { title: data.title }, + { name: "description", content: data.description }, + { property: "og:type", content: "website" }, + { property: "og:title", content: data.title }, + { property: "og:url", content: fullUrl }, + { property: "og:site_name", content: seo?.siteName ?? "" }, + ] + } +} +``` + +--- + +## Query Keys Factory (query-keys.ts) + +```typescript +import { mergeQueryKeys, createQueryKeys } from "@lukemorales/query-key-factory" +import { createApiClient } from "@btst/stack/client" +import type { MyApiRouter } from "./api/plugin" + +export function createMyQueryKeys(client: ReturnType>, headers?: HeadersInit) { + return mergeQueryKeys( + createQueryKeys("myPlugin", { + list: () => ({ + queryKey: ["list"], + queryFn: async () => client.items.list({ headers }), + }), + detail: (id: string) => ({ + queryKey: [id], + queryFn: async () => client.items.get(id, { headers }), + }), + }) + ) +} +``` + +--- + +## defineClientPlugin shape (client/plugin.tsx) + +```typescript +import { defineClientPlugin, createRoute } from "@btst/stack/plugins" +import { lazy } from "react" + +const ListPage = lazy(() => + import("./components/pages/list-page").then(m => ({ default: m.ListPageComponent })) +) +const DetailPage = lazy(() => + import("./components/pages/detail-page").then(m => ({ default: m.DetailPageComponent })) +) + +export const myClientPlugin = defineClientPlugin({ + name: "my-plugin", + config: (overrides) => ({ + queryClient: overrides.queryClient, + apiBaseURL: overrides.apiBaseURL, + apiBasePath: overrides.apiBasePath, + siteBaseURL: overrides.siteBaseURL, + siteBasePath: overrides.siteBasePath, + hooks: overrides.hooks, + headers: overrides.headers, + seo: overrides.seo, + }), + routes: (config) => ({ + list: createRoute("/my-plugin", () => ({ + PageComponent: () => , + loader: createListLoader(config), + meta: createListMeta(config), + })), + detail: createRoute("/my-plugin/:id", ({ params }) => ({ + PageComponent: () => , + loader: createDetailLoader(params.id, config), + meta: createDetailMeta(params.id, config), + })), + }), +}) +``` diff --git a/.agents/skills/btst-client-plugin-dev/SKILL.md b/.agents/skills/btst-client-plugin-dev/SKILL.md new file mode 100644 index 00000000..b70c07da --- /dev/null +++ b/.agents/skills/btst-client-plugin-dev/SKILL.md @@ -0,0 +1,117 @@ +--- +name: btst-client-plugin-dev +description: Patterns for writing BTST client plugins inside the monorepo, including defineClientPlugin structure, route anatomy (PageComponent/loader/meta), lazy loading, SSR loader pattern, meta generators, ComposedRoute with ErrorBoundary/Suspense, useSuspenseQuery error throwing, query key factories, and client override shapes. Use when creating or modifying a client plugin, adding routes/loaders/meta, wiring ComposedRoute, implementing useSuspenseQuery, or building query key factories in src/plugins/{name}/client/. +--- + +# BTST Client Plugin Development + +## File structure + +``` +src/plugins/{name}/ + client/ + plugin.tsx ← defineClientPlugin entry + components/ + pages/ + my-page.tsx ← wrapper: ComposedRoute + lazy import + my-page.internal.tsx ← actual UI: useSuspenseQuery + query-keys.ts ← React Query key factory +``` + +## Route anatomy + +Each route returns exactly three things: + +```typescript +routes: (config) => ({ + myRoute: createRoute("/path/:id", ({ params }) => ({ + PageComponent: () => , + loader: createMyLoader(params.id, config), // SSR only + meta: createMyMeta(params.id, config), // SEO tags + })), +}) +``` + +## Lazy loading + +Use `React.lazy()` for all page components. Named exports need `.then()`: + +```typescript +const MyPage = lazy(() => + import("./components/pages/my-page").then(m => ({ default: m.MyPage })) +) +// Default exports: lazy(() => import("./components/pages/my-page")) +``` + +## SSR loader rules + +- Only execute inside `if (typeof window === "undefined")` guard +- **Never throw** — store errors in React Query, let ErrorBoundary catch during render +- Call `beforeLoad` / `afterLoad` / `onLoadError` hooks +- Use `queryClient.prefetchQuery()` to seed data +- Import `isConnectionError` from `@btst/stack/plugins/client` and warn on build-time failure + +## ComposedRoute (wrapper page) + +```typescript +export function MyPageComponent({ id }: { id: string }) { + return ( + console.error(error)} + /> + ) +} +``` + +**Critical**: always pass `LoadingComponent` unconditionally — guarding it with `typeof window !== "undefined"` shifts React's `useId()` counter and causes hydration mismatches. + +## useSuspenseQuery + error throwing (.internal.tsx) + +`useSuspenseQuery` only throws on initial fetch. Manually re-throw on refetch errors: + +```typescript +export function useMyData(id: string) { + const { data, refetch, error, isFetching } = useSuspenseQuery({ + ...queries.items.detail(id), + staleTime: 60_000, + retry: false, + }) + if (error && !isFetching) throw error // ← required for ErrorBoundary to catch refetch failures + return { data, refetch } +} +``` + +## Client overrides shape + +```typescript +type PluginOverrides = { + apiBaseURL: string + apiBasePath: string // e.g. "/api/data" + navigate: (path: string) => void + refresh?: () => void + Link: ComponentType + Image?: ComponentType + uploadImage?: (file: File) => Promise + headers?: HeadersInit + localization?: Partial +} +``` + +## Gotchas + +- **Missing `usePluginOverrides()` config** — client components crash if overrides aren't set in layout. +- **`staleTime: Infinity`** — use for data that should not auto-refetch. +- **Next.js Link href undefined** — use `href={href || "#"}` pattern. +- **Suspense errors not caught** — add `if (error && !isFetching) throw error` in every suspense hook. +- **Missing ComposedRoute wrapper** — without it, errors crash the entire app instead of hitting ErrorBoundary. + +## Full code patterns + +See [REFERENCE.md](REFERENCE.md) for complete SSR loader, meta generator, and query key factory code. +See [EXAMPLES.md](EXAMPLES.md) for a full ComposedRoute + internal page wiring example. diff --git a/.agents/skills/btst-docs/SKILL.md b/.agents/skills/btst-docs/SKILL.md new file mode 100644 index 00000000..9da42583 --- /dev/null +++ b/.agents/skills/btst-docs/SKILL.md @@ -0,0 +1,67 @@ +--- +name: btst-docs +description: Workflow for updating BTST documentation when making consumer-facing changes to plugins, components, or APIs. Use when adding new props to exported components, adding new exported types or hooks, changing API shapes, adding a new plugin, or making any change that consumers of @btst/stack need to know about. +--- + +# BTST Documentation + +## When to update docs + +Update `docs/content/docs/plugins/{name}.mdx` whenever you: + +- Add new props to an exported component (e.g. `ChatLayout`, `BlogLayout`) +- Add new exported types or interfaces +- Change behavior of existing props +- Add new hooks or exported functions +- Add or change API endpoints or request/response shapes +- Change backend or client plugin configuration options +- Make a breaking change (always document migration path) + +**Never skip this step** — the docs are the primary consumer reference. + +## File location + +``` +docs/content/docs/ + plugins/ + blog.mdx + ai-chat.mdx + cms.mdx + {name}.mdx ← create or update this +``` + +## AutoTypeTable + +Use `AutoTypeTable` to auto-generate prop tables directly from TypeScript types. It reads JSDoc comments — ensure all exported types have JSDoc: + +```mdx +import { AutoTypeTable } from "fumadocs-typescript/ui" + +## Props + + +``` + +JSDoc example: +```typescript +export interface MyComponentProps { + /** The unique identifier for the item */ + id: string + /** Called when the user submits the form */ + onSubmit?: (data: FormData) => void +} +``` + +## Verify the docs build + +Always run after editing: + +```bash +cd docs && pnpm build +``` + +Fix any TypeScript or MDX errors before merging. + +## Gotcha + +**Forgetting to update docs** is the most common oversight. Consumer-facing changes without doc updates leave users guessing. If `AutoTypeTable` is used, ensure JSDoc comments are in place on all exported types or the table will render empty. diff --git a/.agents/skills/btst-integration/REFERENCE.md b/.agents/skills/btst-integration/REFERENCE.md index a4a76671..ffe5d921 100644 --- a/.agents/skills/btst-integration/REFERENCE.md +++ b/.agents/skills/btst-integration/REFERENCE.md @@ -1,5 +1,145 @@ # BTST Integration — Reference +## lib/stack.ts shape + +```ts +import { stack } from "@btst/stack" +import { createDrizzleAdapter } from "@btst/adapter-drizzle" // or prisma / kysely / mongodb / memory +import { blogBackendPlugin } from "@btst/stack/plugins/blog/api" +import { aiChatBackendPlugin } from "@btst/stack/plugins/ai-chat/api" +// import more plugins… + +// Memory adapter + Next.js: pin to globalThis to share one instance across API and page bundles +const g = global as typeof global & { __btst__?: ReturnType } + +export const myStack = g.__btst__ ??= stack({ + basePath: "/api/data", + plugins: { + blog: blogBackendPlugin({ + // optional hooks — throw to deny + onBeforeCreatePost: async (data) => { /* auth check */ }, + onPostCreated: async (post) => { /* revalidate, notify */ }, + }), + aiChat: aiChatBackendPlugin({ + model: openai("gpt-4o"), + systemPrompt: "You are a helpful assistant.", + mode: "authenticated", + getUserId: async (ctx) => ctx.headers?.get("x-user-id") ?? null, + }), + // add more plugins… + }, + adapter: (db) => createDrizzleAdapter(schema, db, {}), + // For memory adapter: adapter: (db) => createMemoryAdapter(db)({}) +}) + +export const { handler, dbSchema } = myStack +``` + +**Rules:** +- For any real DB adapter (Drizzle, Prisma, Kysely, MongoDB), just call `stack()` at module level — no `globalThis` needed. +- Only pin to `globalThis` when using `@btst/adapter-memory` in Next.js. + +--- + +## lib/query-client.ts shape + +```ts +import { QueryClient } from "@tanstack/react-query" + +// Next.js: singleton pattern — one QueryClient per server request, reused on client +let queryClientSingleton: QueryClient | undefined + +export function getOrCreateQueryClient() { + if (typeof window === "undefined") { + // Server: always create a new instance so requests don't share data + return new QueryClient({ + defaultOptions: { queries: { staleTime: 60 * 1000 } }, + }) + } + // Client: reuse the same instance across navigations + return (queryClientSingleton ??= new QueryClient({ + defaultOptions: { queries: { staleTime: 60 * 1000 } }, + })) +} +``` + +--- + +## API catch-all route + +**Next.js** (`app/api/data/[[...all]]/route.ts`): + +```ts +import { myStack } from "@/lib/stack" + +export const { GET, POST, PUT, PATCH, DELETE } = myStack.handler +``` + +**React Router v7** (`app/routes/api.data.$.ts`): + +```ts +import { myStack } from "~/lib/stack" +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router" + +export async function loader({ request }: LoaderFunctionArgs) { + return myStack.handler(request) +} +export async function action({ request }: ActionFunctionArgs) { + return myStack.handler(request) +} +``` + +**TanStack Start** (`src/routes/api/data/$.ts`): + +```ts +import { myStack } from "~/lib/stack" +import { createAPIFileRoute } from "@tanstack/start/api" + +export const APIRoute = createAPIFileRoute("/api/data/$")({ + GET: ({ request }) => myStack.handler(request), + POST: ({ request }) => myStack.handler(request), + PUT: ({ request }) => myStack.handler(request), + PATCH: ({ request }) => myStack.handler(request), + DELETE: ({ request }) => myStack.handler(request), +}) +``` + +--- + +## Pages catch-all route + +**Next.js** (`app/pages/[[...all]]/page.tsx`): + +```tsx +import { notFound } from "next/navigation" +import { headers } from "next/headers" +import { HydrationBoundary, dehydrate } from "@tanstack/react-query" +import { normalizePath } from "@btst/stack/client" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" + +export default async function Page({ params }: { params: Promise<{ all?: string[] }> }) { + const headersList = await headers() + const headersObj = new Headers() + headersList.forEach((value, key) => headersObj.set(key, value)) + + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient, { headers: headersObj }) + const route = stackClient.router.getRoute(normalizePath((await params).all)) + + if (!route) notFound() + if (route.loader) await route.loader() + + return ( + + + + ) +} +``` + +--- + ## getBaseURL helper A server/client-safe URL helper — required for `apiBaseURL` in every plugin config and override. @@ -192,6 +332,7 @@ export default function PagesLayout({ children }: { children: React.ReactNode }) - `mode: "authenticated" | "public"` — conversation persistence mode - `uploadFile(file): Promise` — for chat file attachments - `chatSuggestions: string[]` — pre-filled prompt suggestions +- **Root layout requirement**: wrap the root layout (above all `StackProvider` instances) with `PageAIContextProvider` from `@btst/stack/plugins/ai-chat/client/context`. Individual pages then call `useRegisterPageAIContext()` — see the `btst-ai-context` skill. **ui-builder** - `componentRegistry` — pass `defaultComponentRegistry` or a custom one diff --git a/.agents/skills/btst-integration/SKILL.md b/.agents/skills/btst-integration/SKILL.md index 49b50e82..e8b75e64 100644 --- a/.agents/skills/btst-integration/SKILL.md +++ b/.agents/skills/btst-integration/SKILL.md @@ -16,7 +16,7 @@ description: Guides developers and AI agents through manual BTST library consump 7. Create the pages **layout** file with `QueryClientProvider` + `StackProvider`. 8. Run `@btst/cli generate` (and `migrate` for Kysely). -See [REFERENCE.md](REFERENCE.md) for full code shapes. +See [REFERENCE.md](REFERENCE.md) for full code shapes for every file. ## Workflow @@ -68,6 +68,15 @@ Do not duplicate — the patcher and manual edits must both be idempotent. - `overrides={{ pluginKey: { apiBaseURL, apiBasePath, navigate, Link, Image?, refresh?, uploadImage?, ...hooks } }}` - Define a typed `PluginOverrides` interface importing `{Plugin}Overrides` from each plugin client package. - See [REFERENCE.md](REFERENCE.md) for the full per-plugin override shape and lifecycle hooks. +- **ai-chat plugin only**: wrap the **root layout** (above `StackProvider`) with `PageAIContextProvider`: + ```tsx + import { PageAIContextProvider } from "@btst/stack/plugins/ai-chat/client/context" + // root layout.tsx (not the pages layout) + export default function RootLayout({ children }) { + return {children} + } + ``` + Pages then call `useRegisterPageAIContext(...)` to register their AI context — see the `btst-ai-context` skill. ### 6) Generate schemas and run migrations diff --git a/.agents/skills/btst-plugin-ssg/REFERENCE.md b/.agents/skills/btst-plugin-ssg/REFERENCE.md new file mode 100644 index 00000000..76ba0b9a --- /dev/null +++ b/.agents/skills/btst-plugin-ssg/REFERENCE.md @@ -0,0 +1,172 @@ +# btst-plugin-ssg — Reference + +## api/query-key-defs.ts + +Shared key shapes — import into both `query-keys.ts` and `prefetchForRoute` to prevent drift: + +```typescript +export function itemsListDiscriminator(params?: { limit?: number }) { + return { limit: params?.limit ?? 20 } +} + +export const MY_PLUGIN_QUERY_KEYS = { + itemsList: (params?: { limit?: number }) => + ["myPlugin", "list", itemsListDiscriminator(params)] as const, + itemDetail: (id: string) => + ["myPlugin", "detail", id] as const, +} +``` + +--- + +## api/serializers.ts + +Convert DB `Date` objects → ISO strings before `setQueryData`: + +```typescript +import type { Item, SerializedItem } from "./types" + +export function serializeItem(item: Item): SerializedItem { + return { + ...item, + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt?.toISOString() ?? null, + } +} +``` + +--- + +## prefetchForRoute typed overloads (api/plugin.ts) + +```typescript +import type { QueryClient } from "@tanstack/react-query" +import { MY_PLUGIN_QUERY_KEYS } from "./query-key-defs" +import { listItems, getItemById } from "./getters" +import { serializeItem } from "./serializers" +import type { Adapter } from "@btst/stack/plugins" + +export type MyPluginRouteKey = "list" | "detail" | "new" + +interface MyPluginPrefetchForRoute { + (key: "list" | "new", qc: QueryClient): Promise + (key: "detail", qc: QueryClient, params: { id: string }): Promise +} + +function createMyPluginPrefetchForRoute(adapter: Adapter): MyPluginPrefetchForRoute { + return async function prefetchForRoute(key, qc, params?) { + switch (key) { + case "list": { + const { items, total, limit, offset } = await listItems(adapter) + // useInfiniteQuery requires { pages, pageParams } shape + qc.setQueryData(MY_PLUGIN_QUERY_KEYS.itemsList(), { + pages: [{ items: items.map(serializeItem), total, limit, offset }], + pageParams: [0], + }) + break + } + case "detail": { + const item = await getItemById(adapter, params!.id) + if (item) { + qc.setQueryData(MY_PLUGIN_QUERY_KEYS.itemDetail(params!.id), serializeItem(item)) + } + break + } + case "new": + break // no prefetch needed for new/create pages + } + } as MyPluginPrefetchForRoute +} + +// Wire into the api factory in defineBackendPlugin: +api: (adapter) => ({ + listItems: () => listItems(adapter), + getItemById: (id: string) => getItemById(adapter, id), + prefetchForRoute: createMyPluginPrefetchForRoute(adapter), +}) +``` + +--- + +## SSG page.tsx (Next.js — outside [[...all]]/) + +Static page that bypasses `route.loader()` and seeds the cache directly: + +```tsx +// app/pages/my-plugin/page.tsx +import { notFound } from "next/navigation" +import { HydrationBoundary, dehydrate } from "@tanstack/react-query" +import type { Metadata } from "next" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" +import { myStack } from "@/lib/stack" +import { normalizePath, metaElementsToObject } from "@btst/stack/client" + +export async function generateStaticParams() { + return [{}] +} +// export const revalidate = 3600 // ISR — uncomment to enable + +export async function generateMetadata(): Promise { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["my-plugin"])) + if (!route) return { title: "Fallback" } + + await myStack.api.myPlugin.prefetchForRoute("list", queryClient) + return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata +} + +export default async function Page() { + const queryClient = getOrCreateQueryClient() + const stackClient = getStackClient(queryClient) + const route = stackClient.router.getRoute(normalizePath(["my-plugin"])) + if (!route) notFound() + + await myStack.api.myPlugin.prefetchForRoute("list", queryClient) + return ( + + + + ) +} +``` + +--- + +## query-keys.ts — import from query-key-defs.ts + +```typescript +// src/plugins/my-plugin/query-keys.ts +import { mergeQueryKeys, createQueryKeys } from "@lukemorales/query-key-factory" +import { itemsListDiscriminator, MY_PLUGIN_QUERY_KEYS } from "./api/query-key-defs" + +export function createMyPluginQueryKeys(client, headers?) { + return mergeQueryKeys( + createQueryKeys("myPlugin", { + list: (params?: { limit?: number }) => ({ + queryKey: [itemsListDiscriminator(params)], // ← reuse discriminator, never hardcode + queryFn: async () => client.items.list(params, { headers }), + }), + detail: (id: string) => ({ + queryKey: [id], + queryFn: async () => client.items.get(id, { headers }), + }), + }) + ) +} + +export { MY_PLUGIN_QUERY_KEYS } +``` + +--- + +## api/index.ts — re-export SSG types + +```typescript +export { listItems, getItemById } from "./getters" +export { createItem, updateItem, deleteItem } from "./mutations" +export { serializeItem } from "./serializers" +export { MY_PLUGIN_QUERY_KEYS } from "./query-key-defs" +export type { MyPluginRouteKey } from "./plugin" +``` diff --git a/.agents/skills/btst-plugin-ssg/SKILL.md b/.agents/skills/btst-plugin-ssg/SKILL.md new file mode 100644 index 00000000..8f2b149d --- /dev/null +++ b/.agents/skills/btst-plugin-ssg/SKILL.md @@ -0,0 +1,62 @@ +--- +name: btst-plugin-ssg +description: Patterns for adding SSG (static site generation) support to BTST plugins using prefetchForRoute, including query-key-defs.ts, serializers.ts, typed prefetchForRoute overloads, and the SSG page.tsx pattern for Next.js. Use when a plugin needs static generation support, when route.loader() silently fails at next build, when adding prefetchForRoute to the api factory, or when fixing infinite query shape/date serialization errors during SSG. +--- + +# BTST Plugin SSG Support + +## Why route.loader() fails at build time + +`route.loader()` makes HTTP requests. No server exists during `next build`, so fetches fail silently — static pages render empty. Solution: expose `prefetchForRoute` on the `api` factory to seed React Query directly from the DB. + +## Required files per plugin + +| File | Purpose | +|---|---| +| `api/query-key-defs.ts` | Shared key shapes — import into both `query-keys.ts` and `prefetchForRoute` | +| `api/serializers.ts` | Convert `Date` fields → ISO strings before `setQueryData` | +| `api/getters.ts` | Add any ID-based getters `prefetchForRoute` needs | +| `api/plugin.ts` | `RouteKey` type + typed overloads + wire `prefetchForRoute` into `api` factory | +| `api/index.ts` | Re-export `RouteKey`, serializers, `PLUGIN_QUERY_KEYS` | +| `query-keys.ts` | Import discriminator fn from `api/query-key-defs.ts` | +| `client/plugin.tsx` | `isConnectionError` warn in each loader `catch` block | + +## Key rules + +- **Serialize `Date` → ISO string** before every `setQueryData` call — DB returns `Date` objects, HTTP cache holds strings. +- **`useInfiniteQuery` lists** require `{ pages: [...], pageParams: [...] }` shape in `setQueryData`. Flat arrays break hydration. +- **Share key builders** via `api/query-key-defs.ts` — never hardcode key shapes in two places. +- **One-time init steps** (e.g. CMS `ensureSynced`) — call once at the top of `prefetchForRoute`; it's idempotent and safe for concurrent SSG. +- Place shared `StackProvider` layout at `app/pages/layout.tsx` (not inside `[[...all]]/`) so it applies to both SSG pages and the catch-all. + +## Plugins with SSG support + +| Plugin | Prefetched keys | Skipped | +|---|---|---| +| Blog | `posts`, `drafts`, `post`, `tag`, `editPost` | `newPost` | +| CMS | `dashboard`, `contentList`, `editContent` | `newContent` | +| Form Builder | `formList`, `editForm`, `submissions` | `newForm` | +| Kanban | `boards`, `board` | `newBoard` | +| AI Chat | — (per-user, not static) | all | + +## isConnectionError in loader catch + +```typescript +import { isConnectionError } from "@btst/stack/plugins/client" + +// in each loader catch block: +if (isConnectionError(error)) { + console.warn("[btst/my-plugin] route.loader() failed — no server at build time. Use myStack.api.myPlugin.prefetchForRoute() for SSG.") +} +``` + +## Gotchas + +- **`route.loader()` silently fails at build time** — use `prefetchForRoute` in SSG pages instead. +- **Query key drift** — always import discriminator fns from `api/query-key-defs.ts`; never hardcode key shapes in two places. +- **Wrong shape for infinite queries** — `setQueryData` needs `{ pages: [...], pageParams: [...] }`, not a flat array. +- **Dates not serialized** — always pass data through a serializer before `setQueryData`. + +## Full code patterns + +See [REFERENCE.md](REFERENCE.md) for complete `query-key-defs.ts`, `serializers.ts`, `prefetchForRoute` overloads, and SSG `page.tsx` boilerplate. diff --git a/.agents/skills/btst-registry/SKILL.md b/.agents/skills/btst-registry/SKILL.md new file mode 100644 index 00000000..ade1e170 --- /dev/null +++ b/.agents/skills/btst-registry/SKILL.md @@ -0,0 +1,55 @@ +--- +name: btst-registry +description: Patterns for maintaining the BTST shadcn v4 registry, including adding new plugins to build-registry.ts, EXTERNAL_REGISTRY_COMPONENTS for directory-based workspace/ui imports, design rules for what is and isn't ejectable, and the 4-step workflow for updating registry JSON files. Use when adding a plugin to the shadcn registry, updating build-registry.ts, handling EXTERNAL_REGISTRY_COMPONENTS, debugging hooks accidentally included in registry output, or running the registry E2E test. +--- + +# BTST Shadcn Registry + +Plugin UI pages are distributed as a shadcn v4 registry so consumers can eject and customize the UI layer via `npx shadcn@latest add`. + +## Key files + +| File | Purpose | +|---|---| +| `packages/stack/scripts/build-registry.ts` | Build script — globs plugin components, rewrites imports, outputs JSON | +| `packages/stack/scripts/test-registry.sh` | E2E test — packs @btst/stack, installs via shadcn, builds Next.js project | +| `packages/stack/registry/btst-{name}.json` | Per-plugin registry item (committed, regenerated by build script) | +| `packages/stack/registry/registry.json` | Combined registry collection (committed, regenerated) | +| `.github/workflows/registry.yml` | CI — rebuilds registry and runs E2E on PRs touching plugin source | + +## Design rules + +- **Hooks are excluded** — components import hooks from `@btst/stack/plugins/{name}/client/hooks`. Only the view layer is ejectable. +- **Routable pages** — wire back via `pageComponents` on the client plugin config when the plugin supports page overrides. +- **`@workspace/ui` imports are rewritten**: standard shadcn components → `registryDependencies`; custom components (`page-wrapper`, `empty`, etc.) → embedded as `registry:component` files from `packages/ui/src/`. +- **Directory structure is preserved** — source files land at `src/components/btst/{name}/client/{relative}` so all relative imports remain valid. +- **Workspace component API compatibility** — the workspace version must match the standard shadcn API. Never add custom props that standard shadcn doesn't have. + +## EXTERNAL_REGISTRY_COMPONENTS + +Multi-file `@workspace/ui` components (directories, not single files) must be mapped to an external registry URL: + +```typescript +// In packages/stack/scripts/build-registry.ts +const EXTERNAL_REGISTRY_COMPONENTS: Record = { + "auto-form": "https://shadcn-registry.example.com/auto-form", + "minimal-tiptap": "https://shadcn-registry.example.com/minimal-tiptap", + // add new multi-file components here +} +``` + +If a new component imports from `@workspace/ui/components/my-dir/...` (a directory), add it here. + +## Adding a plugin — 4 steps + +1. Add a `PluginConfig` entry to `PLUGINS` in `packages/stack/scripts/build-registry.ts` +2. Run `pnpm --filter @btst/stack build-registry` +3. Run `pnpm --filter @btst/stack test-registry` locally +4. Commit the updated `registry/*.json` files + +## Gotchas + +- **Registry not rebuilt after plugin changes** — always run `build-registry` and commit the updated JSON. CI auto-commits if forgotten, but don't rely on it. +- **`@workspace/ui` sub-path components not found** — if a new component imports from a directory, add it to `EXTERNAL_REGISTRY_COMPONENTS`. +- **Hooks accidentally included** — check that `hooks/` files show as `skip` in the build output. Update `shouldExclude()` in `build-registry.ts` if a plugin has hooks in a non-standard location. +- **Custom prop on workspace component** — remove the custom prop and use the standard shadcn API instead. The shadcn CLI will overwrite embedded components when installing other components that share the same dependency (e.g. `popover`). diff --git a/.agents/skills/btst-testing/SKILL.md b/.agents/skills/btst-testing/SKILL.md new file mode 100644 index 00000000..d4483aee --- /dev/null +++ b/.agents/skills/btst-testing/SKILL.md @@ -0,0 +1,128 @@ +--- +name: btst-testing +description: Patterns for running BTST unit tests (Vitest) and E2E tests (Playwright), including unit test structure using the memory adapter, smoke test commands, per-framework E2E runs, Playwright project configuration, API key guards, and environment variable setup. Use when writing or running unit tests for plugin getters/mutations, running e2e smoke tests, targeting a specific framework, writing a new smoke test, or setting up API key guards for external service tests. +--- + +# BTST Testing + +## Unit tests (Vitest) + +### Location and naming + +``` +packages/stack/src/plugins/{name}/__tests__/ + getters.test.ts + mutations.test.ts + plugin.test.ts + +packages/cli/src/utils/__tests__/ + {util}.test.ts +``` + +### Run commands + +```bash +# packages/stack — watch mode +cd packages/stack && pnpm test + +# packages/cli — run once +cd packages/cli && pnpm test +``` + +### Pattern: testing getters/mutations with the memory adapter + +Use `createMemoryAdapter` + `defineDb` to spin up an isolated in-memory DB per test: + +```typescript +import { describe, it, expect, beforeEach } from "vitest" +import { createMemoryAdapter } from "@btst/adapter-memory" +import { defineDb } from "@btst/db" +import type { DBAdapter as Adapter } from "@btst/db" +import { myPluginSchema } from "../db" +import { listItems, getItemById } from "../api/getters" + +const createTestAdapter = (): Adapter => { + const db = defineDb({}).use(myPluginSchema) + return createMemoryAdapter(db)({}) +} + +describe("my-plugin getters", () => { + let adapter: Adapter + + beforeEach(() => { + adapter = createTestAdapter() // fresh DB per test + }) + + it("returns empty result when no items exist", async () => { + const result = await listItems(adapter) + expect(result.items).toEqual([]) + expect(result.total).toBe(0) + }) +}) +``` + +Use `vi.mock` for external modules that don't exist in the test environment (e.g. `@vercel/blob/server`). + +--- + +## E2E tests (Playwright) + +### Location and naming + +Tests live in `e2e/tests/`. Naming convention: `smoke.{feature}.spec.ts` + +Examples: `smoke.chat.spec.ts`, `smoke.blog.spec.ts` + +### Run commands + +```bash +# All frameworks (starts all 3 servers) +cd e2e +export $(cat ../examples/nextjs/.env | xargs) +pnpm e2e:smoke + +# Single framework only +pnpm e2e:smoke:nextjs +pnpm e2e:smoke:tanstack +pnpm e2e:smoke:react-router + +# Specific test file +pnpm e2e:smoke -- tests/smoke.chat.spec.ts + +# Specific Playwright project +pnpm e2e:smoke -- --project="nextjs:memory" +``` + +### Playwright projects and ports + +| Project | Port | +|---|---| +| `nextjs:memory` | 3003 | +| `tanstack:memory` | 3004 | +| `react-router:memory` | 3005 | + +Defined in `playwright.config.ts`. By default all three servers start. Set `BTST_FRAMEWORK=nextjs|tanstack|react-router` to start only one — or use the per-framework scripts above. CI uses a matrix to run each in a separate parallel job. + +### API key guard pattern + +Features requiring external APIs (OpenAI, etc.) must skip gracefully when the key is absent: + +```typescript +test.beforeEach(async () => { + if (!process.env.OPENAI_API_KEY) { + test.skip() + } +}) +``` + +1. Check for the API key in `test.beforeEach` +2. Call `test.skip()` — don't throw +3. Document required env vars in a comment at the top of the spec file + +### Environment variables + +```bash +export $(cat ../examples/nextjs/.env | xargs) +``` + +For CI, the workflow uses a matrix — each framework job sets `BTST_FRAMEWORK` and only starts its own server. diff --git a/AGENTS.md b/AGENTS.md index b3e4ae36..a2463350 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,8 +5,6 @@ alwaysApply: true # BTST Monorepo - Agent Rules -This document contains essential rules and patterns for AI agents working with this monorepo. - ## Environment Setup ### Node.js Version @@ -22,789 +20,18 @@ pnpm typecheck # Type check all packages pnpm lint # Lint all packages ``` -## Plugin Development - -### Plugin Architecture Pattern - -Plugins consist of two parts that must be kept in sync: - -1. **API Plugin** (`src/plugins/{name}/api/plugin.ts`) - - Uses `defineBackendPlugin` from `@btst/stack/plugins` - - Defines database schema, API endpoints, and server-side hooks - - Exports types for the API router - -2. **Client Plugin** (`src/plugins/{name}/client/plugin.tsx`) - - Uses `defineClientPlugin` from `@btst/stack/plugins` - - Defines routes, loaders, meta generators, and client-side hooks - - Must configure `queryClient`, `siteBaseURL`, `siteBasePath` in config - -### Lifecycle Hooks Pattern - -Both API and client plugins should follow consistent hook naming: - -```typescript -// API Plugin Hooks -onBeforeChat, onAfterChat, onChatError -onBeforeConversationCreated, onAfterConversationCreated, onConversationCreateError -onBeforeConversationRead, onAfterConversationRead, onConversationReadError -onBeforeConversationUpdated, onAfterConversationUpdated, onConversationUpdateError -onBeforeConversationDeleted, onAfterConversationDeleted, onConversationDeleteError -onBeforeConversationsListed, onAfterConversationsListed, onConversationsListError - -// Client Plugin Hooks -beforeLoad*, afterLoad*, onLoadError -onRouteRender, onRouteError -onBefore*PageRendered -``` - -### Server-side API Factory (`api`) - -Every backend plugin can expose a typed `api` surface for direct server-side or SSG data access (no HTTP roundtrip). Add an `api` factory alongside `routes`: - -```typescript -// src/plugins/{name}/api/getters.ts — pure DB functions, no hooks/HTTP context -export async function listItems(adapter: Adapter): Promise { ... } -export async function getItemById(adapter: Adapter, id: string): Promise { ... } - -// src/plugins/{name}/api/plugin.ts -export const myBackendPlugin = defineBackendPlugin({ - name: "{name}", - dbPlugin: dbSchema, - api: (adapter) => ({ // ← bound to shared adapter - listItems: () => listItems(adapter), - getItemById: (id: string) => getItemById(adapter, id), - }), - routes: (adapter) => { /* HTTP endpoints */ }, -}) - -// src/plugins/{name}/api/index.ts — re-export getters for direct import -export { listItems, getItemById } from "./getters"; -``` - -After calling `stack()`, the result exposes `api` (namespaced per plugin) and `adapter`: - -```typescript -export const myStack = stack({ basePath, plugins, adapter }) -export const { handler, dbSchema } = myStack - -// Use in Server Components, generateStaticParams, scripts, etc. -const items = await myStack.api["{name}"].listItems() -const item = await myStack.api["{name}"].getItemById("abc") -``` - -**Rules:** -- Keep getters in a separate `getters.ts` — no HTTP context, no lifecycle hooks -- The `api` factory and `routes` factory share the same adapter instance -- If the plugin has a one-time init/sync step (like CMS `syncContentTypes`), call it inside each getter wrapper — not just inside `routes` -- Re-export getters from `api/index.ts` for consumers who need direct import (SSG/build-time) -- Authorization hooks are **not** called via `stack().api.*` — callers are responsible for access control - -### Server-side Mutations (`mutations.ts`) - -Plugins expose write operations in a separate `api/mutations.ts` file — distinct from the read-only `getters.ts`. Both are re-exported from `api/index.ts` and wired into the `api` factory. - -**Rules:** -- Keep mutations in `mutations.ts` — no authorization hooks, no HTTP context -- Document clearly in JSDoc: "Authorization hooks are NOT called" -- Re-export from `api/index.ts` alongside getters -- Common use case: AI tool `execute` callbacks, cron jobs, admin scripts - -**Accessing the adapter in AI tool `execute` functions:** -```typescript -export const myStack = stack({ ... }) - -const myTool = tool({ - execute: async (params) => { - await createKanbanTask(myStack.adapter, { title: params.title, columnId: "col-id" }) - return { success: true } - } -}) -``` - -`myStack` is a module-level `const`. The `execute` closure runs lazily — only when an HTTP request invokes the tool, never at module init time — so `myStack` is always defined by then. - -### SSG Support (`prefetchForRoute`) - -`route.loader()` makes HTTP requests that **silently fail at `next build`** (no server running). Plugins that support SSG expose `prefetchForRoute` on the `api` factory to seed the React Query cache directly from the DB instead. - -**Required files per plugin:** - -| File | Purpose | -|---|---| -| `api/query-key-defs.ts` | Shared key shapes — import into both `query-keys.ts` and `prefetchForRoute` to prevent drift | -| `api/serializers.ts` | Convert `Date` fields to ISO strings before `setQueryData` | -| `api/getters.ts` | Add any ID-based getters `prefetchForRoute` needs (e.g. `getItemById`) | -| `api/plugin.ts` | `RouteKey` type + typed overloads + wire `prefetchForRoute` into `api` factory | -| `api/index.ts` | Re-export `RouteKey`, serializers, `PLUGIN_QUERY_KEYS` | -| `query-keys.ts` | Import discriminator fn from `api/query-key-defs.ts` | -| `client/plugin.tsx` | Import and call `isConnectionError` in each loader `catch` block | - -**`api/query-key-defs.ts`:** -```typescript -export function itemsListDiscriminator(params?: { limit?: number }) { - return { limit: params?.limit ?? 20 } -} -export const PLUGIN_QUERY_KEYS = { - itemsList: (params?: { limit?: number }) => - ["items", "list", itemsListDiscriminator(params)] as const, - itemDetail: (id: string) => ["items", "detail", id] as const, -} -``` - -**`prefetchForRoute` in `api/plugin.ts`:** -```typescript -export type PluginRouteKey = "list" | "detail" | "new" - -// Typed overloads enforce correct params per route key -interface PluginPrefetchForRoute { - (key: "list" | "new", qc: QueryClient): Promise - (key: "detail", qc: QueryClient, params: { id: string }): Promise -} +## Agent Skills -function createPluginPrefetchForRoute(adapter: Adapter): PluginPrefetchForRoute { - return async function prefetchForRoute(key, qc, params?) { - switch (key) { - case "list": { - const { items, total, limit, offset } = await listItems(adapter) - // useInfiniteQuery lists require { pages, pageParams } shape - qc.setQueryData(PLUGIN_QUERY_KEYS.itemsList(), { - pages: [{ items: items.map(serializeItem), total, limit, offset }], - pageParams: [0], - }) - break - } - case "detail": { - const item = await getItemById(adapter, params!.id) - if (item) qc.setQueryData(PLUGIN_QUERY_KEYS.itemDetail(params!.id), serializeItem(item)) - break - } - case "new": break - } - } as PluginPrefetchForRoute -} - -api: (adapter) => ({ - listItems: () => listItems(adapter), - prefetchForRoute: createPluginPrefetchForRoute(adapter), -}) -``` +Detailed patterns and reference material are in the following skills. Read the relevant skill before working in that domain. -Rules: serialize `Date` → ISO string; for plugins with a one-time init step (e.g. CMS `ensureSynced`), call it once at the top of `prefetchForRoute` — it is idempotent and safe for concurrent SSG calls. - -**Build-time warning in `client/plugin.tsx` loader `catch` blocks:** -```typescript -import { isConnectionError } from "@btst/stack/plugins/client" - -// in each loader catch block: -if (isConnectionError(error)) { - console.warn("[btst/{plugin}] route.loader() failed — no server at build time. Use myStack.api.{plugin}.prefetchForRoute() for SSG.") -} -``` - -**SSG `page.tsx` pattern (Next.js — outside `[[...all]]/`):** -```tsx -export async function generateStaticParams() { return [{}] } -// export const revalidate = 3600 // ISR - -export async function generateMetadata(): Promise { - const queryClient = getOrCreateQueryClient() - const stackClient = getStackClient(queryClient) - const route = stackClient.router.getRoute(normalizePath(["{plugin}"])) - if (!route) return { title: "Fallback" } - await myStack.api.{plugin}.prefetchForRoute("list", queryClient) - return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata -} - -export default async function Page() { - const queryClient = getOrCreateQueryClient() - const stackClient = getStackClient(queryClient) - const route = stackClient.router.getRoute(normalizePath(["{plugin}"])) - if (!route) notFound() - await myStack.api.{plugin}.prefetchForRoute("list", queryClient) - return -} -``` - -The shared `StackProvider` layout must be at `app/pages/layout.tsx` (not `[[...all]]/layout.tsx`) so it applies to both SSG pages and the catch-all. - -**Plugins with SSG support:** - -| Plugin | Prefetched route keys | Skipped | +| Skill | Domain | Trigger | |---|---|---| -| Blog | `posts`, `drafts`, `post`, `tag`, `editPost` | `newPost` | -| CMS | `dashboard`, `contentList`, `editContent` | `newContent` | -| Form Builder | `formList`, `editForm`, `submissions` | `newForm` | -| Kanban | `boards`, `board` | `newBoard` | -| AI Chat | — (per-user, not static) | all | - -### Query Keys Factory - -Create a query keys file for React Query integration: - -```typescript -// src/plugins/{name}/query-keys.ts -import { mergeQueryKeys, createQueryKeys } from "@lukemorales/query-key-factory"; - -export function create{Name}QueryKeys(client, headers?) { - return mergeQueryKeys( - createQueryKeys("resourceName", { - list: () => ({ queryKey: ["list"], queryFn: async () => { /* ... */ } }), - detail: (id: string) => ({ queryKey: [id], queryFn: async () => { /* ... */ } }), - }) - ); -} -``` - -### Client Overrides - -Client plugins need overrides configured in consumer layouts. Required overrides: - -```typescript -type PluginOverrides = { - apiBaseURL: string; // Base URL for API calls - apiBasePath: string; // API route prefix (e.g., "/api/data") - navigate: (path: string) => void; - refresh?: () => void; - Link: ComponentType; - Image?: ComponentType; - uploadImage?: (file: File) => Promise; - headers?: HeadersInit; - localization?: Partial; -} -``` - -### Lazy Loading Page Components - -Use React.lazy() to code-split page components and reduce initial bundle size: - -```typescript -import { lazy } from "react"; - -// Lazy load page components for code splitting -// Use .then() to handle named exports -const HomePageComponent = lazy(() => - import("./components/pages/home-page").then(m => ({ default: m.HomePageComponent })) -); -const NewPostPageComponent = lazy(() => - import("./components/pages/new-post-page").then(m => ({ default: m.NewPostPageComponent })) -); -const EditPostPageComponent = lazy(() => - import("./components/pages/edit-post-page").then(m => ({ default: m.EditPostPageComponent })) -); -``` - -For default exports, the simpler form works: -```typescript -const PostPage = lazy(() => import("./components/pages/post-page")); -``` - -### Client Plugin Route Structure - -Each route in `defineClientPlugin` should return three parts: - -```typescript -routes: () => ({ - routeName: createRoute("/path/:param", ({ params }) => ({ - // 1. PageComponent - The React component to render - PageComponent: () => , - - // 2. loader - SSR data prefetching (runs only on server) - loader: createMyLoader(params.param, config), - - // 3. meta - SEO meta tag generator - meta: createMyMeta(params.param, config), - })), -}), -``` - -### SSR Loader Pattern - -Loaders should only run on the server and prefetch data into React Query: - -```typescript -function createMyLoader(param: string, config: MyClientConfig) { - return async () => { - // Only run on server - skip on client - if (typeof window === "undefined") { - const { queryClient, apiBasePath, apiBaseURL, hooks, headers } = config; - - const context: LoaderContext = { - path: `/resource/${param}`, - params: { param }, - isSSR: true, - apiBaseURL, - apiBasePath, - headers, - }; - - try { - // Before hook - allow consumers to cancel/modify loading - if (hooks?.beforeLoad) { - const canLoad = await hooks.beforeLoad(param, context); - if (!canLoad) { - throw new Error("Load prevented by beforeLoad hook"); - } - } - - // Create API client and query keys - const client = createApiClient({ - baseURL: apiBaseURL, - basePath: apiBasePath, - }); - const queries = createMyQueryKeys(client, headers); - - // Prefetch data into queryClient - await queryClient.prefetchQuery(queries.resource.detail(param)); - - // After hook - if (hooks?.afterLoad) { - const data = queryClient.getQueryData(queries.resource.detail(param).queryKey); - await hooks.afterLoad(data, param, context); - } - - // Check for errors - call hook but don't throw - const queryState = queryClient.getQueryState(queries.resource.detail(param).queryKey); - if (queryState?.error && hooks?.onLoadError) { - const error = queryState.error instanceof Error - ? queryState.error - : new Error(String(queryState.error)); - await hooks.onLoadError(error, context); - } - } catch (error) { - // Log error but don't re-throw during SSR - // Let Error Boundaries handle errors when components render - if (hooks?.onLoadError) { - await hooks.onLoadError(error as Error, context); - } - } - } - }; -} -``` - -Key patterns: -- **Server-only execution**: `if (typeof window === "undefined")` -- **Don't throw errors during SSR**: Let React Query store errors and Error Boundaries catch them during render -- **Hook integration**: Call before/after/error hooks for consumer customization -- **Prefetch into queryClient**: Use `queryClient.prefetchQuery()` so data is available immediately on client - -### Meta Generator Pattern - -Meta generators read prefetched data from queryClient: - -```typescript -function createMyMeta(param: string, config: MyClientConfig) { - return () => { - const { queryClient, apiBaseURL, apiBasePath, siteBaseURL, siteBasePath, seo } = config; - - // Get prefetched data from queryClient - const queries = createMyQueryKeys( - createApiClient({ baseURL: apiBaseURL, basePath: apiBasePath }) - ); - const data = queryClient.getQueryData(queries.resource.detail(param).queryKey); - - // Fallback if data not loaded - if (!data) { - return [ - { title: "Unknown route" }, - { name: "robots", content: "noindex" }, - ]; - } - - const fullUrl = `${siteBaseURL}${siteBasePath}/resource/${param}`; - - return [ - { title: data.title }, - { name: "description", content: data.description }, - { property: "og:type", content: "website" }, - { property: "og:title", content: data.title }, - { property: "og:url", content: fullUrl }, - // ... more meta tags - ]; - }; -} -``` - -### ComposedRoute & Error Handling - -Page components use `ComposedRoute` to wrap content with Suspense + ErrorBoundary: - -```typescript -// my-page.tsx - wrapper with boundaries -const MyPage = lazy(() => import("./my-page.internal").then(m => ({ default: m.MyPage }))); - -export function MyPageComponent({ id }: { id: string }) { - return ( - console.error(error)} - /> - ); -} -``` - -**How it works:** -- `ComposedRoute` renders nested `` + `` around `PageComponent` -- Loading fallbacks are always provided to `` on both server and client — never guard them with `typeof window !== "undefined"`, as that creates a different JSX tree on each side and shifts React's `useId()` counter, causing hydration mismatches in descendants (Radix `Select`, `Dialog`, etc.). Since Suspense only emits fallback HTML when the boundary actually suspends during SSR, having a consistent fallback prop is safe. -- `resetKeys={[path]}` resets the error boundary on navigation - -### Suspense Hooks & Error Throwing - -Use `useSuspenseQuery` in `.internal.tsx` files. Manually throw errors so ErrorBoundary catches them: - -```typescript -export function useSuspenseMyData(id: string) { - const { data, refetch, error, isFetching } = useSuspenseQuery({ - ...queries.items.detail(id), - staleTime: 60_000, - retry: false, - }); - - // IMPORTANT: useSuspenseQuery only throws on initial fetch, not refetch failures - if (error && !isFetching) { - throw error; - } - - return { data, refetch }; -} -``` - -### Error Flow - -| Scenario | What Happens | -|----------|--------------| -| **API returns error** | SSR loader stores error in cache → client hydrates → hook throws → ErrorBoundary catches | -| **Component crashes** | ErrorBoundary catches → renders DefaultError → `onError` callback fires | -| **Network fails on refetch** | Error stored in query state → hook throws → ErrorBoundary catches | - -SSR loaders **don't throw** - they let React Query store errors so ErrorBoundary handles them during render. - -## Build Configuration - -### Adding New Entry Points - -When creating new exports, update both files: - -1. **`packages/stack/build.config.ts`** - Add entry to the entries array: -```typescript -entries: [ - // ... existing entries - "./src/plugins/{name}/api/index.ts", - "./src/plugins/{name}/client/index.ts", - "./src/plugins/{name}/client/hooks/index.tsx", - "./src/plugins/{name}/client/components/index.tsx", - "./src/plugins/{name}/query-keys.ts", -] -``` - -2. **`packages/stack/package.json`** - Add exports AND typesVersions: -```json -{ - "exports": { - "./plugins/{name}/client/hooks": { - "import": "./dist/plugins/{name}/client/hooks/index.mjs", - "require": "./dist/plugins/{name}/client/hooks/index.cjs" - } - }, - "typesVersions": { - "*": { - "plugins/{name}/client/hooks": ["./dist/plugins/{name}/client/hooks/index.d.ts"] - } - } -} -``` - -### CSS Exports - -Plugins with UI components must provide CSS entry points: - -1. **`src/plugins/{name}/client.css`** - Client-side styles -2. **`src/plugins/{name}/style.css`** - Full styles with Tailwind source directives - -Export in package.json: -```json -{ - "exports": { - "./plugins/{name}/css": "./dist/plugins/{name}/client.css" - } -} -``` - -The `postbuild.cjs` script copies CSS files automatically. - -## Example Apps - -### Updating All Examples - -When adding a new plugin or changing plugin configuration, update ALL three example apps: - -1. **Next.js** (`examples/nextjs/`) - - `lib/stack.tsx` - Backend plugin registration - - `lib/stack-client.tsx` - Client plugin registration - - `app/pages/[[...all]]/layout.tsx` - Override configuration - - `app/globals.css` - CSS import: `@import "@btst/stack/plugins/{name}/css";` - -2. **React Router** (`examples/react-router/`) - - `app/lib/stack.tsx` - Backend plugin registration - - `app/lib/stack-client.tsx` - Client plugin registration - - `app/routes/pages/_layout.tsx` - Override configuration - - `app/app.css` - CSS import: `@import "@btst/stack/plugins/{name}/css";` - -3. **TanStack** (`examples/tanstack/`) - - `src/lib/stack.tsx` - Backend plugin registration - - `src/lib/stack-client.tsx` - Client plugin registration - - `src/routes/pages/route.tsx` - Override configuration - - `src/styles/app.css` - CSS import: `@import "@btst/stack/plugins/{name}/css";` - -### Override Type Registration - -Add your plugin's overrides to the PluginOverrides type in layouts: - -```typescript -import type { YourPluginOverrides } from "@btst/stack/plugins/{name}/client" - -type PluginOverrides = { - blog: BlogPluginOverrides, - "ai-chat": AiChatPluginOverrides, - "{name}": YourPluginOverrides, // Add new plugins here -} -``` - -## Testing - -### E2E Tests - -Tests are in `e2e/tests/` using Playwright. Pattern: `smoke.{feature}.spec.ts` - -Run tests with API keys from the nextjs example: -```bash -cd e2e -export $(cat ../examples/nextjs/.env | xargs) -pnpm e2e:smoke -``` - -Run for a single framework only (starts only that framework's server): -```bash -pnpm e2e:smoke:nextjs -pnpm e2e:smoke:tanstack -pnpm e2e:smoke:react-router -``` - -Run specific test file: -```bash -pnpm e2e:smoke -- tests/smoke.chat.spec.ts -``` - -Run for specific project: -```bash -pnpm e2e:smoke -- --project="nextjs:memory" -``` - -### Test Configuration - -The `playwright.config.ts` defines three projects: -- `nextjs:memory` - port 3003 -- `tanstack:memory` - port 3004 -- `react-router:memory` - port 3005 - -By default (`pnpm e2e:smoke`) all three web servers start. Set `BTST_FRAMEWORK=nextjs|tanstack|react-router` (or use the per-framework scripts above) to start only the matching server and run only its tests. The CI workflow uses a matrix to run each framework in a separate parallel job. - -### API Key Requirements - -Features requiring external APIs (like OpenAI) should: -1. Check for API key availability in tests -2. Skip tests gracefully when key is missing -3. Document required env vars in test files - -```typescript -test.beforeEach(async () => { - if (!process.env.OPENAI_API_KEY) { - test.skip(); - } -}); -``` - -## Shared UI Package - -### Using @workspace/ui - -Shared components live in `packages/ui/src/components/`. Import via: -```typescript -import { Button } from "@workspace/ui/button" -import { MarkdownContent } from "@workspace/ui/markdown-content" -``` - -### Adding Shadcn Components - -Use the shadcn CLI to add components to the UI package: -```bash -cd packages/ui -pnpm dlx shadcn@latest add {component-name} -``` - -## Documentation - -### FumaDocs Site - -Documentation is in `docs/content/docs/`. Update when adding/changing plugins: - -1. Create/update MDX file: `docs/content/docs/plugins/{name}.mdx` -2. Use `AutoTypeTable` for TypeScript interfaces -3. Include code examples with proper syntax highlighting -4. Document all configuration options, hooks, and overrides - -### IMPORTANT: Update Docs for Consumer-Facing Changes - -**When modifying any consumer-facing interfaces, you MUST update documentation:** - -1. **Props/Types changes** - Update the corresponding plugin MDX file when: - - Adding new props to exported components (e.g., `ChatLayout`, `BlogLayout`) - - Adding new exported types or interfaces - - Changing behavior of existing props - - Adding new hooks or exported functions - -2. **API changes** - Document new or modified: - - API endpoints - - Request/response shapes - - Backend plugin configuration options - - Client plugin configuration options - -3. **Breaking changes** - Always document migration paths - -**Example workflow:** -```bash -# 1. Make changes to component -# packages/stack/src/plugins/ai-chat/client/components/chat-layout.tsx - -# 2. Update corresponding docs -# docs/content/docs/plugins/ai-chat.mdx - -# 3. Verify docs build -cd docs && pnpm build -``` - -The `AutoTypeTable` component automatically pulls from TypeScript files, so ensure your types have JSDoc comments for good documentation. - -## AI Chat Plugin Integration - -Plugin pages can register AI context so the chat widget understands the current page and can act on it (fill forms, update editors, summarize content). - -**In the `.internal.tsx` page component**, call `useRegisterPageAIContext`: - -```tsx -import { useRegisterPageAIContext } from "@btst/stack/plugins/ai-chat/client/context"; - -// Read-only (content pages — summarization, suggestions only) -useRegisterPageAIContext(item ? { - routeName: "my-plugin-detail", - pageDescription: `Viewing: "${item.title}"\n\n${item.content?.slice(0, 16000)}`, - suggestions: ["Summarize this", "What are the key points?"], -} : null); // pass null while loading - -// With client-side tools (form/editor pages) -const formRef = useRef | null>(null); -useRegisterPageAIContext({ - routeName: "my-plugin-edit", - pageDescription: "User is editing…", - suggestions: ["Fill in the form for me"], - clientTools: { - fillMyForm: async ({ title }) => { - if (!formRef.current) return { success: false, message: "Form not ready" }; - formRef.current.setValue("title", title, { shouldValidate: true }); - return { success: true }; - }, - }, -}); -``` - -**For first-party tools**, add the server-side schema to `BUILT_IN_PAGE_TOOL_SCHEMAS` in `src/plugins/ai-chat/api/page-tools.ts` (no `execute` — handled client-side). Built-ins (`fillBlogForm`, `updatePageLayers`) are already registered there. - -**`PageAIContextProvider` must wrap the root layout** (above all `StackProvider` instances) in all three example apps — it is already wired up there. - -**References:** blog `new/edit-post-page.internal.tsx` (`fillBlogForm`), blog `post-page.internal.tsx` (read-only), ui-builder `page-builder-page.internal.tsx` (`updatePageLayers`). - ---- - -## Shadcn Registry - -Plugin UI pages are distributed as a shadcn v4 registry so consumers can eject and customize the UI layer via `npx shadcn@latest add`. - -### Files - -| File | Purpose | -|---|---| -| `packages/stack/scripts/schema.ts` | Zod schemas for the shadcn v4 registry format | -| `packages/stack/scripts/build-registry.ts` | Build script — globs plugin components, rewrites imports, outputs JSON | -| `packages/stack/scripts/test-registry.sh` | E2E test — packs @btst/stack, installs via shadcn, builds Next.js project | -| `packages/stack/registry/btst-{name}.json` | Per-plugin registry item (committed, regenerated by build script) | -| `packages/stack/registry/registry.json` | Combined collection (committed, regenerated by build script) | -| `.github/workflows/registry.yml` | CI — rebuilds registry and runs E2E test on PRs touching plugin source | - -### Key design rules - -- **Hooks are excluded** from the registry. Components import hooks from `@btst/stack/plugins/{name}/client/hooks`. Only the view layer is ejectable. -- **Routable plugin pages should be wired back in via `pageComponents`** on the client plugin config when the plugin supports page overrides. If a plugin intentionally does not support `pageComponents`, document the direct-import rendering pattern clearly in the plugin docs and shared shadcn registry guide. -- **`@workspace/ui` imports are rewritten**: standard shadcn components → `registryDependencies`; custom components (`page-wrapper`, `empty`, etc.) → embedded as `registry:component` files from `packages/ui/src/`; multi-file components (`auto-form`, `minimal-tiptap`, `ui-builder`) → external registry URL in `registryDependencies`. -- **Directory structure is preserved**: source files land at `src/components/btst/{name}/client/{relative}` so all relative imports remain valid with no rewriting. -- **`EXTERNAL_REGISTRY_COMPONENTS`** in `build-registry.ts` maps directory-based workspace/ui components to their external registry URLs. - -### Adding a new plugin to the registry - -1. Add a `PluginConfig` entry to `PLUGINS` in `packages/stack/scripts/build-registry.ts`. -2. Run `pnpm --filter @btst/stack build-registry`. -3. Run `pnpm --filter @btst/stack test-registry` locally. -4. Commit the updated `registry/*.json` files. - ---- - -## Common Pitfalls - -1. **Missing overrides** - Client components using `usePluginOverrides()` will crash if overrides aren't configured in the layout or default values are not provided to the hook. - -2. **Build cache** - Run `pnpm build` after changes to see them in examples. The turbo cache may need clearing: `pnpm turbo clean` - -3. **Type exports** - Always add both `exports` AND `typesVersions` entries for new paths - -4. **CSS not loading** - CSS files are auto-discovered from `src/plugins/` by `postbuild.cjs` — no manual registration needed. Ensure the `package.json` export entry (`"./plugins/{name}/css"`) is present - -5. **React Query stale data** - Use `staleTime: Infinity` for data that shouldn't refetch automatically - -6. **Link component href** - Next.js Link requires non-undefined href. Use `href={href || "#"}` pattern - -7. **AI SDK versions** - Use AI SDK v5 patterns. Check https://ai-sdk.dev/docs for current API - -8. **Forgetting to update docs** - When adding/changing consumer-facing props, types, or interfaces, ALWAYS update the corresponding documentation in `docs/content/docs/plugins/`. Use `AutoTypeTable` to auto-generate type documentation from source files. - -9. **Suspense errors not caught** - If errors from `useSuspenseQuery` aren't caught by ErrorBoundary, add the manual throw pattern: `if (error && !isFetching) { throw error; }` - -10. **Missing ComposedRoute wrapper** - Page components must be wrapped with `ComposedRoute` to get proper Suspense + ErrorBoundary handling. Without it, errors crash the entire app. - -11. **`stack().api` bypasses authorization hooks** - Getters accessed via `myStack.api.*` skip all `onBefore*` hooks. Never use them as a substitute for authenticated HTTP endpoints — enforce access control at the call site. - -12. **Plugin init steps not called via `api`** - If a plugin's `routes` factory runs a one-time setup (e.g. CMS `syncContentTypes`), that same setup must also be awaited inside the `api` getter wrappers, otherwise direct getter calls will query an uninitialised database. - -13. **`route.loader()` silently fails at build time** - No HTTP server exists during `next build`, so fetches fail silently and the static page renders empty. Use `myStack.api.{plugin}.prefetchForRoute()` in SSG pages instead. - -14. **Query key drift between HTTP and SSG paths** - Share key builders via `api/query-key-defs.ts`; import discriminator functions into `query-keys.ts`. Never hardcode key shapes in two places. - -15. **Wrong data shape for infinite queries** - Lists backed by `useInfiniteQuery` need `{ pages: [...], pageParams: [...] }` in `setQueryData`. Flat arrays will break hydration. - -16. **Dates not serialized before `setQueryData`** - DB getters return `Date` objects; the HTTP cache holds ISO strings. Always serialize (e.g. `serializePost`) before `setQueryData`. - -17. **Putting write operations in `getters.ts`** - Write functions (create, update, delete) belong in `mutations.ts`, not `getters.ts`. This keeps the naming convention clear and signals to callers that no authorization hooks are invoked. - -18. **Singleton pattern only needed with the in-memory adapter in Next.js** — Next.js bundles API routes and page components into separate module contexts in the same process, so a bare `stack()` call at module level would create two independent in-memory adapter instances with different data. Use `globalThis` to share the instance only when using `@btst/adapter-memory` in Next.js. With any real database adapter (Drizzle, Prisma, MongoDB, etc.), just call `stack()` at module level — multiple adapter instances all read and write the same database. - -19. **Registry not rebuilt after plugin changes** — always run `pnpm --filter @btst/stack build-registry` and commit the updated JSON files. The CI auto-commits them if forgotten. - -20. **`@workspace/ui` sub-path components not found** — if a new component imports from `@workspace/ui/components/my-dir/...` (a directory, not a single file), add it to `EXTERNAL_REGISTRY_COMPONENTS` in `build-registry.ts` with its external registry URL. - -21. **Hooks accidentally included** — check that `hooks/` directory files show as `skip` in the build output. If a new plugin has hooks in a non-standard location, update `shouldExclude()` in `build-registry.ts`. - -22. **Workspace component adds a custom prop that standard shadcn doesn't have** — remove the custom prop from the workspace source and use the standard API instead. If you embed the custom component, shadcn CLI will overwrite it when installing other standard components that also depend on it (e.g. `popover`). The workspace version must be API-compatible with the shadcn standard. - +| [`btst-backend-plugin-dev`](.agents/skills/btst-backend-plugin-dev/SKILL.md) | Backend plugin authoring | `defineBackendPlugin`, `getters.ts`, `mutations.ts`, lifecycle hooks, api factory | +| [`btst-client-plugin-dev`](.agents/skills/btst-client-plugin-dev/SKILL.md) | Client plugin authoring | `defineClientPlugin`, routes, SSR loaders, meta, `ComposedRoute`, `useSuspenseQuery` | +| [`btst-plugin-ssg`](.agents/skills/btst-plugin-ssg/SKILL.md) | SSG support | `prefetchForRoute`, `query-key-defs.ts`, serializers, `next build` silent failures | +| [`btst-build-config`](.agents/skills/btst-build-config/SKILL.md) | Build & exports | New entry points, `build.config.ts`, `exports`/`typesVersions`, example app updates | +| [`btst-testing`](.agents/skills/btst-testing/SKILL.md) | E2E testing | Playwright smoke tests, per-framework runs, API key guards | +| [`btst-docs`](.agents/skills/btst-docs/SKILL.md) | Documentation | FumaDocs, `AutoTypeTable`, when to update plugin MDX files | +| [`btst-registry`](.agents/skills/btst-registry/SKILL.md) | Shadcn registry | `build-registry.ts`, `EXTERNAL_REGISTRY_COMPONENTS`, adding a plugin | +| [`btst-ai-context`](.agents/skills/btst-ai-context/SKILL.md) | AI chat page context | `useRegisterPageAIContext`, `clientTools`, `BUILT_IN_PAGE_TOOL_SCHEMAS` | +| [`btst-integration`](.agents/skills/btst-integration/SKILL.md) | Consumer integration | Integrating `@btst/stack` into an external app (not monorepo work) | From 57006dd761a05c568bc9ac73ce832f876c5ec333 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Wed, 25 Mar 2026 16:04:05 -0400 Subject: [PATCH 37/56] feat: implement global singleton pattern for stack in Next.js to share in-memory store across contexts --- .../cli/src/templates/shared/lib/stack.ts.hbs | 28 +++++++++++++++++-- packages/cli/src/utils/scaffold-plan.ts | 2 ++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/templates/shared/lib/stack.ts.hbs b/packages/cli/src/templates/shared/lib/stack.ts.hbs index 30ac46c5..7f045e3b 100644 --- a/packages/cli/src/templates/shared/lib/stack.ts.hbs +++ b/packages/cli/src/templates/shared/lib/stack.ts.hbs @@ -3,11 +3,34 @@ import { stack } from "@btst/stack" {{#if backendImports}} {{{backendImports}}} {{/if}} - {{#if adapterSetup}} {{{adapterSetup}}} {{/if}} -const myStack = stack({ +{{#if useGlobalSingleton}} +// Next.js evaluates lib/stack.ts in multiple bundle contexts (API routes + page bundle) +// that share the same process. Pin to globalThis so both contexts reference the same +// in-memory store. +const globalForStack = global as typeof global & { __btst_stack__?: ReturnType } + +function createStack() { + const s = stack({ + basePath: "/api/data", + plugins: { +{{#if backendEntries}} +{{{backendEntries}}} +{{else}} + // Add backend plugins here. +{{/if}} + }, + {{{adapterStackLine}}} + }) + + return s +} + +export const myStack = globalForStack.__btst_stack__ ??= createStack() +{{else}} +export const myStack = stack({ basePath: "/api/data", plugins: { {{#if backendEntries}} @@ -18,5 +41,6 @@ const myStack = stack({ }, {{{adapterStackLine}}} }) +{{/if}} export const { handler, dbSchema } = myStack diff --git a/packages/cli/src/utils/scaffold-plan.ts b/packages/cli/src/utils/scaffold-plan.ts index d214c079..9920659d 100644 --- a/packages/cli/src/utils/scaffold-plan.ts +++ b/packages/cli/src/utils/scaffold-plan.ts @@ -163,6 +163,8 @@ export async function buildScaffoldPlan( const sharedContext = { alias: input.alias, publicSiteURLVar: getPublicSiteURLVar(input.framework), + useGlobalSingleton: + input.framework === "nextjs" && input.adapter === "memory", ...pluginContext, ...adapterContext, }; From c79a0993b02e67e0d76e076e14cbf04cd0f70ac6 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Wed, 25 Mar 2026 16:41:22 -0400 Subject: [PATCH 38/56] refactor: update route type imports and scaffold paths to use a unified type reference --- packages/cli/src/templates/react-router/api-route.ts.hbs | 2 +- packages/cli/src/templates/react-router/pages-route.tsx.hbs | 2 +- packages/cli/src/utils/scaffold-plan.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/templates/react-router/api-route.ts.hbs b/packages/cli/src/templates/react-router/api-route.ts.hbs index 38ce4346..09bc3c5e 100644 --- a/packages/cli/src/templates/react-router/api-route.ts.hbs +++ b/packages/cli/src/templates/react-router/api-route.ts.hbs @@ -1,4 +1,4 @@ -import type { Route } from "./+types/route" +import type { Route } from "./+types/$" import { handler } from "{{alias}}lib/stack" export function loader({ request }: Route.LoaderArgs) { diff --git a/packages/cli/src/templates/react-router/pages-route.tsx.hbs b/packages/cli/src/templates/react-router/pages-route.tsx.hbs index 352a7c7c..f0e562d3 100644 --- a/packages/cli/src/templates/react-router/pages-route.tsx.hbs +++ b/packages/cli/src/templates/react-router/pages-route.tsx.hbs @@ -1,4 +1,4 @@ -import type { Route } from "./+types/index" +import type { Route } from "./+types/$" import { useLoaderData, useRouteError } from "react-router" import { dehydrate, HydrationBoundary, useQueryClient } from "@tanstack/react-query" import { normalizePath } from "@btst/stack/client" diff --git a/packages/cli/src/utils/scaffold-plan.ts b/packages/cli/src/utils/scaffold-plan.ts index 9920659d..a109390b 100644 --- a/packages/cli/src/utils/scaffold-plan.ts +++ b/packages/cli/src/utils/scaffold-plan.ts @@ -36,8 +36,8 @@ function getFrameworkPaths(framework: Framework, cssFile: string) { stackPath: "app/lib/stack.ts", stackClientPath: "app/lib/stack-client.tsx", queryClientPath: "app/lib/query-client.ts", - apiRoutePath: "app/routes/api/data/route.ts", - pageRoutePath: "app/routes/pages/index.tsx", + apiRoutePath: "app/routes/api/data/$.ts", + pageRoutePath: "app/routes/pages/$.tsx", pagesLayoutPath: undefined, layoutPatchTarget: "app/root.tsx", }; From 4d03e5622f73478b5e2ba66f8a6b28062dc61f0e Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Wed, 25 Mar 2026 16:52:44 -0400 Subject: [PATCH 39/56] test: update scaffold plan test to check for new route file naming convention --- packages/cli/src/utils/__tests__/scaffold-plan.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts index df578972..463fc041 100644 --- a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts +++ b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts @@ -149,7 +149,7 @@ describe("scaffold plan", () => { }); const pagesRouteFile = plan.files.find((file) => - file.path.endsWith("routes/pages/index.tsx"), + file.path.endsWith("routes/pages/$.tsx"), ); expect(pagesRouteFile).toBeDefined(); expect(pagesRouteFile?.content).toContain( From 3b26213f0a64e6360b866b008af07b02e861e07d Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Wed, 25 Mar 2026 17:16:50 -0400 Subject: [PATCH 40/56] fix: handle optional cssImport in plugin metadata and filter valid CSS imports in init command --- packages/cli/src/commands/init.ts | 4 +++- packages/cli/src/utils/constants.ts | 3 +-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index b528c8c4..9d7523c8 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -253,7 +253,9 @@ export function createInitCommand() { ); const cssImports = PLUGINS.filter((plugin) => selectedPlugins.includes(plugin.key), - ).map((plugin) => plugin.cssImport); + ) + .map((plugin) => plugin.cssImport) + .filter((cssImport): cssImport is string => Boolean(cssImport)); const cssPatch = await patchCssImports( cwd, plan.cssPatchTarget, diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts index 77f96896..d1a3ea37 100644 --- a/packages/cli/src/utils/constants.ts +++ b/packages/cli/src/utils/constants.ts @@ -10,7 +10,7 @@ export interface AdapterMeta { export interface PluginMeta { key: PluginKey; label: string; - cssImport: string; + cssImport?: string; backendImportPath: string; backendSymbol: string; clientImportPath: string; @@ -123,7 +123,6 @@ export const PLUGINS: readonly PluginMeta[] = [ { key: "media", label: "Media", - cssImport: "@btst/stack/plugins/media/css", backendImportPath: "@btst/stack/plugins/media/api", backendSymbol: "mediaBackendPlugin", clientImportPath: "@btst/stack/plugins/media/client", From 105a593caa0968c88d72a89600097dcbae3bd643 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Wed, 25 Mar 2026 18:05:49 -0400 Subject: [PATCH 41/56] feat: enhance scaffold generation with support for layout overrides and compile-safe plugin configurations --- packages/cli/scripts/test-init.sh | 11 +-- .../src/templates/nextjs/pages-layout.tsx.hbs | 28 +++++++ .../src/utils/__tests__/scaffold-plan.test.ts | 82 ++++++++++++++++++- packages/cli/src/utils/scaffold-plan.ts | 73 ++++++++++++++++- 4 files changed, 182 insertions(+), 12 deletions(-) diff --git a/packages/cli/scripts/test-init.sh b/packages/cli/scripts/test-init.sh index 4731f130..8fe5c406 100644 --- a/packages/cli/scripts/test-init.sh +++ b/packages/cli/scripts/test-init.sh @@ -116,7 +116,7 @@ test -f "app/api/data/[[...all]]/route.ts" test -f "app/pages/[[...all]]/page.tsx" test -f "app/pages/layout.tsx" node -e 'const fs=require("fs");const s=fs.readFileSync("lib/stack.ts","utf8");process.exit(s.includes("import { stack } from \"@btst/stack\"")?0:1)' -node -e 'const fs=require("fs");const s=fs.readFileSync("lib/stack.ts","utf8");process.exit(s.includes("mediaBackendPlugin()")?0:1)' +node -e 'const fs=require("fs");const s=fs.readFileSync("lib/stack.ts","utf8");process.exit(s.includes("mediaBackendPlugin({ storageAdapter: undefined as any })")?0:1)' node -e 'const fs=require("fs");const s=fs.readFileSync("app/globals.css","utf8");process.exit(s.includes("@btst/stack/plugins/ui-builder/css")?0:1)' success "Generation + patch checks passed" @@ -132,13 +132,8 @@ if [ "$(cat "$TEST_DIR/init-before.hash")" != "$(cat "$TEST_DIR/init-after.hash" fi success "Second run was idempotent" -step "Preparing CSS for compile sanity check" -node -e 'const fs=require("fs");const p="app/globals.css";const s=fs.readFileSync(p,"utf8");const next=s.split("\n").filter((line)=>!line.includes("@btst/stack/plugins/")&&!line.includes("@btst/stack/ui/css")).join("\n");fs.writeFileSync(p,next);' -success "Temporarily removed BTST CSS imports before build" - -step "Generating compile-safe scaffold" -npx @btst/codegen init --yes --framework nextjs --adapter memory --skip-install > "$TEST_DIR/init-compile.log" 2>&1 -success "Regenerated baseline scaffold for compile check" +step "Verifying compile on all-plugin scaffold" +success "Keeping generated BTST CSS imports from --plugins all" step "Compiling fixture project" npm run build diff --git a/packages/cli/src/templates/nextjs/pages-layout.tsx.hbs b/packages/cli/src/templates/nextjs/pages-layout.tsx.hbs index 9a6f33ea..942c7579 100644 --- a/packages/cli/src/templates/nextjs/pages-layout.tsx.hbs +++ b/packages/cli/src/templates/nextjs/pages-layout.tsx.hbs @@ -1,6 +1,13 @@ "use client" +{{#if pagesLayoutOverrides}} +import { StackProvider } from "@btst/stack/context" +{{/if}} import { QueryClientProvider } from "@tanstack/react-query" +{{#if pagesLayoutOverrides}} +import Link from "next/link" +import { useRouter } from "next/navigation" +{{/if}} import { getOrCreateQueryClient } from "{{alias}}lib/query-client" export default function BtstPagesLayout({ @@ -8,6 +15,27 @@ export default function BtstPagesLayout({ }: { children: React.ReactNode }) { +{{#if pagesLayoutOverrides}} + const router = useRouter() +{{/if}} const queryClient = getOrCreateQueryClient() +{{#if pagesLayoutOverrides}} + const baseURL = window.location.origin + return ( + + + {children} + + + ) +{{else}} return {children} +{{/if}} } diff --git a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts index 463fc041..f3275403 100644 --- a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts +++ b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts @@ -22,6 +22,15 @@ describe("scaffold plan", () => { expect(plan.files[0]?.content).toContain("blogBackendPlugin()"); expect(plan.files[1]?.content).toContain("blogClientPlugin"); expect(plan.files[1]?.content).toContain("const baseURL = getBaseURL()"); + expect(plan.files[5]?.content).toContain( + 'import { StackProvider } from "@btst/stack/context"', + ); + expect(plan.files[5]?.content).toContain( + "navigate: (path) => router.push(path)", + ); + expect(plan.files[5]?.content).toContain( + 'Link: ({ href, ...props }) => ', + ); expect(plan.pagesLayoutPath).toBe("app/pages/layout.tsx"); }); @@ -58,6 +67,14 @@ describe("scaffold plan", () => { expect(stackClientFile?.content).not.toContain( 'const baseURL = "http://localhost:3000"', ); + if (framework === "nextjs") { + const pagesLayoutFile = plan.files.find((file) => + file.path.endsWith("app/pages/layout.tsx"), + ); + expect(pagesLayoutFile?.content).toBeDefined(); + expect(pagesLayoutFile?.content).not.toContain("StackProvider"); + expect(pagesLayoutFile?.content).not.toContain("useRouter"); + } }, ); @@ -86,7 +103,7 @@ describe("scaffold plan", () => { }, ); - it("renders ui-builder backend plugin without invoking it", async () => { + it("does not register ui-builder as a backend plugin entry", async () => { const plan = await buildScaffoldPlan({ framework: "nextjs", adapter: "memory", @@ -96,8 +113,22 @@ describe("scaffold plan", () => { }); const stackFile = plan.files.find((file) => file.path.endsWith("stack.ts")); - expect(stackFile?.content).toContain("uiBuilder: UI_BUILDER_CONTENT_TYPE,"); - expect(stackFile?.content).not.toContain("UI_BUILDER_CONTENT_TYPE()"); + expect(stackFile?.content).not.toContain("uiBuilder:"); + }); + + it("wires ui-builder content type into cms backend config", async () => { + const plan = await buildScaffoldPlan({ + framework: "nextjs", + adapter: "memory", + plugins: ["cms", "ui-builder"], + alias: "@/", + cssFile: "app/globals.css", + }); + + const stackFile = plan.files.find((file) => file.path.endsWith("stack.ts")); + expect(stackFile?.content).toContain( + "cms: cmsBackendPlugin({ contentTypes: [UI_BUILDER_CONTENT_TYPE] }),", + ); }); it("uses camelCase config keys for client plugins", async () => { @@ -139,6 +170,51 @@ describe("scaffold plan", () => { ); }); + it("renders cms backend plugin with compile-safe placeholder config", async () => { + const plan = await buildScaffoldPlan({ + framework: "nextjs", + adapter: "memory", + plugins: ["cms"], + alias: "@/", + cssFile: "app/globals.css", + }); + + const stackFile = plan.files.find((file) => file.path.endsWith("stack.ts")); + expect(stackFile?.content).toContain( + "cms: cmsBackendPlugin({ contentTypes: [] }),", + ); + }); + + it("renders comments backend plugin with compile-safe placeholder config", async () => { + const plan = await buildScaffoldPlan({ + framework: "nextjs", + adapter: "memory", + plugins: ["comments"], + alias: "@/", + cssFile: "app/globals.css", + }); + + const stackFile = plan.files.find((file) => file.path.endsWith("stack.ts")); + expect(stackFile?.content).toContain( + "comments: commentsBackendPlugin({ allowPosting: false }),", + ); + }); + + it("renders media backend plugin with compile-safe placeholder config", async () => { + const plan = await buildScaffoldPlan({ + framework: "nextjs", + adapter: "memory", + plugins: ["media"], + alias: "@/", + cssFile: "app/globals.css", + }); + + const stackFile = plan.files.find((file) => file.path.endsWith("stack.ts")); + expect(stackFile?.content).toContain( + "media: mediaBackendPlugin({ storageAdapter: undefined as any }),", + ); + }); + it("uses shared query client utility in react-router pages route template", async () => { const plan = await buildScaffoldPlan({ framework: "react-router", diff --git a/packages/cli/src/utils/scaffold-plan.ts b/packages/cli/src/utils/scaffold-plan.ts index a109390b..0b3d8af7 100644 --- a/packages/cli/src/utils/scaffold-plan.ts +++ b/packages/cli/src/utils/scaffold-plan.ts @@ -64,6 +64,7 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { const metas = PLUGINS.filter((plugin) => selectedPlugins.includes(plugin.key), ); + const hasUiBuilder = selectedPlugins.includes("ui-builder"); return { backendImports: metas @@ -77,11 +78,24 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { if (m.key === "ai-chat") { return `\t\t${m.configKey}: ${m.backendSymbol}({ model: undefined as any }),`; } + if (m.key === "cms") { + const contentTypes = hasUiBuilder + ? "[UI_BUILDER_CONTENT_TYPE]" + : "[]"; + return `\t\t${m.configKey}: ${m.backendSymbol}({ contentTypes: ${contentTypes} }),`; + } + if (m.key === "comments") { + return `\t\t${m.configKey}: ${m.backendSymbol}({ allowPosting: false }),`; + } + if (m.key === "media") { + return `\t\t${m.configKey}: ${m.backendSymbol}({ storageAdapter: undefined as any }),`; + } if (m.key === "ui-builder") { - return `\t\t${m.configKey}: ${m.backendSymbol},`; + return ""; } return `\t\t${m.configKey}: ${m.backendSymbol}(),`; }) + .filter(Boolean) .join("\n"), clientEntries: metas .map((m) => { @@ -95,6 +109,63 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { \t\t\t}),`; }) .join("\n"), + pagesLayoutOverrides: metas + .map((m) => { + if (m.key === "comments") { + return `\t\t\t\t\t${m.configKey}: { +\t\t\t\t\t\tapiBaseURL: baseURL, +\t\t\t\t\t\tapiBasePath: "/api/data", +\t\t\t\t\t},`; + } + if (m.key === "media") { + return `\t\t\t\t\t${m.configKey}: { +\t\t\t\t\t\tapiBaseURL: baseURL, +\t\t\t\t\t\tapiBasePath: "/api/data", +\t\t\t\t\t\tqueryClient, +\t\t\t\t\t\tnavigate: (path) => router.push(path), +\t\t\t\t\t\tLink: ({ href, ...props }) => , +\t\t\t\t\t},`; + } + if (m.key === "blog") { + return `\t\t\t\t\t${m.configKey}: { +\t\t\t\t\t\tapiBaseURL: baseURL, +\t\t\t\t\t\tapiBasePath: "/api/data", +\t\t\t\t\t\tnavigate: (path) => router.push(path), +\t\t\t\t\t\tLink: ({ href, ...props }) => , +\t\t\t\t\t\tuploadImage: async () => { +\t\t\t\t\t\t\tthrow new Error("TODO: implement blog.uploadImage override in app/pages/layout.tsx") +\t\t\t\t\t\t}, +\t\t\t\t\t},`; + } + if (m.key === "kanban") { + return `\t\t\t\t\t${m.configKey}: { +\t\t\t\t\t\tapiBaseURL: baseURL, +\t\t\t\t\t\tapiBasePath: "/api/data", +\t\t\t\t\t\tnavigate: (path) => router.push(path), +\t\t\t\t\t\tLink: ({ href, ...props }) => , +\t\t\t\t\t\tuploadImage: async () => { +\t\t\t\t\t\t\tthrow new Error("TODO: implement kanban.uploadImage override in app/pages/layout.tsx") +\t\t\t\t\t\t}, +\t\t\t\t\t\tresolveUser: async () => null, +\t\t\t\t\t\tsearchUsers: async () => [], +\t\t\t\t\t},`; + } + if (m.key === "ai-chat") { + return `\t\t\t\t\t${m.configKey}: { +\t\t\t\t\t\tapiBaseURL: baseURL, +\t\t\t\t\t\tapiBasePath: "/api/data", +\t\t\t\t\t\tnavigate: (path) => router.push(path), +\t\t\t\t\t\tLink: ({ href, ...props }) => , +\t\t\t\t\t},`; + } + return `\t\t\t\t\t${m.configKey}: { +\t\t\t\t\t\tapiBaseURL: baseURL, +\t\t\t\t\t\tapiBasePath: "/api/data", +\t\t\t\t\t\tnavigate: (path) => router.push(path), +\t\t\t\t\t\tLink: ({ href, ...props }) => , +\t\t\t\t\t},`; + }) + .join("\n"), }; } From ea18ee107a8eb6b93f2e30b8184960be25fbc6b0 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Wed, 25 Mar 2026 19:32:30 -0400 Subject: [PATCH 42/56] feat: initialize shadcn Next.js baseline in test setup --- packages/cli/scripts/test-init.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cli/scripts/test-init.sh b/packages/cli/scripts/test-init.sh index 8fe5c406..7d36d7da 100644 --- a/packages/cli/scripts/test-init.sh +++ b/packages/cli/scripts/test-init.sh @@ -89,6 +89,9 @@ npx --yes create-next-app@latest app \ --yes cd "$TEST_DIR/app" echo "legacy-peer-deps=true" > .npmrc +step "Initializing shadcn Next.js baseline" +npx --yes shadcn@latest init -t next -y > "$TEST_DIR/shadcn-init.log" 2>&1 +success "Initialized shadcn baseline in fixture" success "Fixture created at $TEST_DIR/app" step "Installing packed tarballs" From 8c133d8eb26cc033b8be8fd2cbbc887bffb1cc61 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 26 Mar 2026 11:27:57 -0400 Subject: [PATCH 43/56] feat: add getBaseURL function to support dynamic base URL retrieval in pages layout --- .../src/templates/nextjs/pages-layout.tsx.hbs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/templates/nextjs/pages-layout.tsx.hbs b/packages/cli/src/templates/nextjs/pages-layout.tsx.hbs index 942c7579..83679e73 100644 --- a/packages/cli/src/templates/nextjs/pages-layout.tsx.hbs +++ b/packages/cli/src/templates/nextjs/pages-layout.tsx.hbs @@ -10,6 +10,20 @@ import { useRouter } from "next/navigation" {{/if}} import { getOrCreateQueryClient } from "{{alias}}lib/query-client" +{{#if pagesLayoutOverrides}} +function getBaseURL() { + if (typeof window !== "undefined") { + return window.location.origin + } + + if (typeof process !== "undefined") { + return process.env.NEXT_PUBLIC_BASE_URL || process.env.BASE_URL || "http://localhost:3000" + } + + return "http://localhost:3000" +} +{{/if}} + export default function BtstPagesLayout({ children, }: { @@ -20,7 +34,7 @@ export default function BtstPagesLayout({ {{/if}} const queryClient = getOrCreateQueryClient() {{#if pagesLayoutOverrides}} - const baseURL = window.location.origin + const baseURL = getBaseURL() return ( Date: Thu, 26 Mar 2026 12:10:11 -0400 Subject: [PATCH 44/56] feat: specify shadcn version in test setup and validate Tailwind tokens in initialized baseline --- packages/cli/scripts/test-init.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/cli/scripts/test-init.sh b/packages/cli/scripts/test-init.sh index 7d36d7da..cbae713d 100644 --- a/packages/cli/scripts/test-init.sh +++ b/packages/cli/scripts/test-init.sh @@ -12,6 +12,7 @@ PACKAGE_DIR="$(dirname "$SCRIPT_DIR")" ROOT_DIR="$(cd "$PACKAGE_DIR/../.." && pwd)" TEST_DIR="/tmp/test-btst-init-$(date +%s)" TEST_PASSED=false +SHADCN_VERSION="4.0.5" cleanup() { if [ "$TEST_PASSED" = true ]; then @@ -90,8 +91,13 @@ npx --yes create-next-app@latest app \ cd "$TEST_DIR/app" echo "legacy-peer-deps=true" > .npmrc step "Initializing shadcn Next.js baseline" -npx --yes shadcn@latest init -t next -y > "$TEST_DIR/shadcn-init.log" 2>&1 -success "Initialized shadcn baseline in fixture" +npx --yes "shadcn@${SHADCN_VERSION}" init --defaults --force --base radix > "$TEST_DIR/shadcn-init.log" 2>&1 +if ! node -e 'const fs=require("fs");const s=fs.readFileSync("app/globals.css","utf8");const hasColorInput=s.includes("--color-input: var(--input);");const hasInputToken=s.includes("--input:");process.exit(hasColorInput&&hasInputToken?0:1)'; then + error "Shadcn baseline is missing required Tailwind tokens (--color-input / --input)" + error "See shadcn init log: $TEST_DIR/shadcn-init.log" + exit 1 +fi +success "Initialized shadcn baseline in fixture (radix, v${SHADCN_VERSION})" success "Fixture created at $TEST_DIR/app" step "Installing packed tarballs" From 36fe4f07d1f8bbf9ffccbd824dc609cdbdca4644 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 26 Mar 2026 12:42:53 -0400 Subject: [PATCH 45/56] fix: update navigate function type in scaffold plan to ensure type safety --- packages/cli/src/utils/__tests__/scaffold-plan.test.ts | 2 +- packages/cli/src/utils/scaffold-plan.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts index f3275403..a23ae217 100644 --- a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts +++ b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts @@ -26,7 +26,7 @@ describe("scaffold plan", () => { 'import { StackProvider } from "@btst/stack/context"', ); expect(plan.files[5]?.content).toContain( - "navigate: (path) => router.push(path)", + "navigate: (path: string) => router.push(path)", ); expect(plan.files[5]?.content).toContain( 'Link: ({ href, ...props }) => ', diff --git a/packages/cli/src/utils/scaffold-plan.ts b/packages/cli/src/utils/scaffold-plan.ts index 0b3d8af7..8b092063 100644 --- a/packages/cli/src/utils/scaffold-plan.ts +++ b/packages/cli/src/utils/scaffold-plan.ts @@ -122,7 +122,7 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { \t\t\t\t\t\tapiBaseURL: baseURL, \t\t\t\t\t\tapiBasePath: "/api/data", \t\t\t\t\t\tqueryClient, -\t\t\t\t\t\tnavigate: (path) => router.push(path), +\t\t\t\t\t\tnavigate: (path: string) => router.push(path), \t\t\t\t\t\tLink: ({ href, ...props }) => , \t\t\t\t\t},`; } @@ -130,7 +130,7 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { return `\t\t\t\t\t${m.configKey}: { \t\t\t\t\t\tapiBaseURL: baseURL, \t\t\t\t\t\tapiBasePath: "/api/data", -\t\t\t\t\t\tnavigate: (path) => router.push(path), +\t\t\t\t\t\tnavigate: (path: string) => router.push(path), \t\t\t\t\t\tLink: ({ href, ...props }) => , \t\t\t\t\t\tuploadImage: async () => { \t\t\t\t\t\t\tthrow new Error("TODO: implement blog.uploadImage override in app/pages/layout.tsx") @@ -141,7 +141,7 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { return `\t\t\t\t\t${m.configKey}: { \t\t\t\t\t\tapiBaseURL: baseURL, \t\t\t\t\t\tapiBasePath: "/api/data", -\t\t\t\t\t\tnavigate: (path) => router.push(path), +\t\t\t\t\t\tnavigate: (path: string) => router.push(path), \t\t\t\t\t\tLink: ({ href, ...props }) => , \t\t\t\t\t\tuploadImage: async () => { \t\t\t\t\t\t\tthrow new Error("TODO: implement kanban.uploadImage override in app/pages/layout.tsx") @@ -154,14 +154,14 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { return `\t\t\t\t\t${m.configKey}: { \t\t\t\t\t\tapiBaseURL: baseURL, \t\t\t\t\t\tapiBasePath: "/api/data", -\t\t\t\t\t\tnavigate: (path) => router.push(path), +\t\t\t\t\t\tnavigate: (path: string) => router.push(path), \t\t\t\t\t\tLink: ({ href, ...props }) => , \t\t\t\t\t},`; } return `\t\t\t\t\t${m.configKey}: { \t\t\t\t\t\tapiBaseURL: baseURL, \t\t\t\t\t\tapiBasePath: "/api/data", -\t\t\t\t\t\tnavigate: (path) => router.push(path), +\t\t\t\t\t\tnavigate: (path: string) => router.push(path), \t\t\t\t\t\tLink: ({ href, ...props }) => , \t\t\t\t\t},`; }) From 71dcf4e28a53f5c137465c8617ae0ebb93f800f0 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 26 Mar 2026 12:44:54 -0400 Subject: [PATCH 46/56] feat: integrate shared query client utility in tanstack pages route template --- .../templates/tanstack/pages-route.tsx.hbs | 10 +++++---- .../src/utils/__tests__/scaffold-plan.test.ts | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/templates/tanstack/pages-route.tsx.hbs b/packages/cli/src/templates/tanstack/pages-route.tsx.hbs index 2f0b5e75..85a0c6e3 100644 --- a/packages/cli/src/templates/tanstack/pages-route.tsx.hbs +++ b/packages/cli/src/templates/tanstack/pages-route.tsx.hbs @@ -1,13 +1,15 @@ import { createFileRoute, notFound } from "@tanstack/react-router" import { normalizePath } from "@btst/stack/client" import { getStackClient } from "{{alias}}lib/stack-client" +import { getOrCreateQueryClient } from "{{alias}}lib/query-client" export const Route = createFileRoute("/pages/$")({ ssr: true, component: BtstPagesRoute, - loader: async ({ params, context }) => { + loader: async ({ params }) => { + const queryClient = getOrCreateQueryClient() const routePath = normalizePath(params._splat) - const route = getStackClient(context.queryClient).router.getRoute(routePath) + const route = getStackClient(queryClient).router.getRoute(routePath) if (!route) throw notFound() if (route.loader) await route.loader() return { meta: route.meta?.() } @@ -22,8 +24,8 @@ export const Route = createFileRoute("/pages/$")({ function BtstPagesRoute() { const params = Route.useParams() - const context = Route.useRouteContext() + const queryClient = getOrCreateQueryClient() const routePath = normalizePath(params._splat) - const route = getStackClient(context.queryClient).router.getRoute(routePath) + const route = getStackClient(queryClient).router.getRoute(routePath) return route?.PageComponent ? :
Route not found
} diff --git a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts index a23ae217..169c5a2d 100644 --- a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts +++ b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts @@ -236,4 +236,26 @@ describe("scaffold plan", () => { ); expect(pagesRouteFile?.content).not.toContain("new QueryClient()"); }); + + it("uses shared query client utility in tanstack pages route template", async () => { + const plan = await buildScaffoldPlan({ + framework: "tanstack", + adapter: "memory", + plugins: ["blog"], + alias: "@/", + cssFile: "src/styles/app.css", + }); + + const pagesRouteFile = plan.files.find((file) => + file.path.endsWith("routes/pages/$.tsx"), + ); + expect(pagesRouteFile).toBeDefined(); + expect(pagesRouteFile?.content).toContain( + 'import { getOrCreateQueryClient } from "@/lib/query-client"', + ); + expect(pagesRouteFile?.content).toContain( + "const queryClient = getOrCreateQueryClient()", + ); + expect(pagesRouteFile?.content).not.toContain("context.queryClient"); + }); }); From a9c6ef47549b960c71fe756670c732768e04864a Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 26 Mar 2026 13:28:31 -0400 Subject: [PATCH 47/56] feat: enhance plugin template context to conditionally include CMS imports and update Link component type --- .../cli/src/utils/__tests__/scaffold-plan.test.ts | 3 ++- packages/cli/src/utils/scaffold-plan.ts | 13 ++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts index 169c5a2d..8cfc2927 100644 --- a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts +++ b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts @@ -29,7 +29,7 @@ describe("scaffold plan", () => { "navigate: (path: string) => router.push(path)", ); expect(plan.files[5]?.content).toContain( - 'Link: ({ href, ...props }) => ', + 'Link: ({ href, ...props }: any) => ', ); expect(plan.pagesLayoutPath).toBe("app/pages/layout.tsx"); }); @@ -114,6 +114,7 @@ describe("scaffold plan", () => { const stackFile = plan.files.find((file) => file.path.endsWith("stack.ts")); expect(stackFile?.content).not.toContain("uiBuilder:"); + expect(stackFile?.content).not.toContain("UI_BUILDER_CONTENT_TYPE"); }); it("wires ui-builder content type into cms backend config", async () => { diff --git a/packages/cli/src/utils/scaffold-plan.ts b/packages/cli/src/utils/scaffold-plan.ts index 8b092063..2fdb1db8 100644 --- a/packages/cli/src/utils/scaffold-plan.ts +++ b/packages/cli/src/utils/scaffold-plan.ts @@ -66,8 +66,11 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { ); const hasUiBuilder = selectedPlugins.includes("ui-builder"); + const hasCms = selectedPlugins.includes("cms"); + return { backendImports: metas + .filter((m) => m.key !== "ui-builder" || hasCms) .map((m) => `import { ${m.backendSymbol} } from "${m.backendImportPath}"`) .join("\n"), clientImports: metas @@ -123,7 +126,7 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { \t\t\t\t\t\tapiBasePath: "/api/data", \t\t\t\t\t\tqueryClient, \t\t\t\t\t\tnavigate: (path: string) => router.push(path), -\t\t\t\t\t\tLink: ({ href, ...props }) => , +\t\t\t\t\t\tLink: ({ href, ...props }: any) => , \t\t\t\t\t},`; } if (m.key === "blog") { @@ -131,7 +134,7 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { \t\t\t\t\t\tapiBaseURL: baseURL, \t\t\t\t\t\tapiBasePath: "/api/data", \t\t\t\t\t\tnavigate: (path: string) => router.push(path), -\t\t\t\t\t\tLink: ({ href, ...props }) => , +\t\t\t\t\t\tLink: ({ href, ...props }: any) => , \t\t\t\t\t\tuploadImage: async () => { \t\t\t\t\t\t\tthrow new Error("TODO: implement blog.uploadImage override in app/pages/layout.tsx") \t\t\t\t\t\t}, @@ -142,7 +145,7 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { \t\t\t\t\t\tapiBaseURL: baseURL, \t\t\t\t\t\tapiBasePath: "/api/data", \t\t\t\t\t\tnavigate: (path: string) => router.push(path), -\t\t\t\t\t\tLink: ({ href, ...props }) => , +\t\t\t\t\t\tLink: ({ href, ...props }: any) => , \t\t\t\t\t\tuploadImage: async () => { \t\t\t\t\t\t\tthrow new Error("TODO: implement kanban.uploadImage override in app/pages/layout.tsx") \t\t\t\t\t\t}, @@ -155,14 +158,14 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { \t\t\t\t\t\tapiBaseURL: baseURL, \t\t\t\t\t\tapiBasePath: "/api/data", \t\t\t\t\t\tnavigate: (path: string) => router.push(path), -\t\t\t\t\t\tLink: ({ href, ...props }) => , +\t\t\t\t\t\tLink: ({ href, ...props }: any) => , \t\t\t\t\t},`; } return `\t\t\t\t\t${m.configKey}: { \t\t\t\t\t\tapiBaseURL: baseURL, \t\t\t\t\t\tapiBasePath: "/api/data", \t\t\t\t\t\tnavigate: (path: string) => router.push(path), -\t\t\t\t\t\tLink: ({ href, ...props }) => , +\t\t\t\t\t\tLink: ({ href, ...props }: any) => , \t\t\t\t\t},`; }) .join("\n"), From 91c8da77699908db846f55ef6f5cd5e3a947b33b Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 26 Mar 2026 13:43:07 -0400 Subject: [PATCH 48/56] feat: update scaffold plan to conditionally include CMS plugin and adjust imports in template context --- packages/cli/src/utils/__tests__/scaffold-plan.test.ts | 2 +- packages/cli/src/utils/scaffold-plan.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts index 8cfc2927..7fa6e0dd 100644 --- a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts +++ b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts @@ -136,7 +136,7 @@ describe("scaffold plan", () => { const plan = await buildScaffoldPlan({ framework: "nextjs", adapter: "memory", - plugins: ["ai-chat", "ui-builder", "form-builder"], + plugins: ["ai-chat", "cms", "ui-builder", "form-builder"], alias: "@/", cssFile: "app/globals.css", }); diff --git a/packages/cli/src/utils/scaffold-plan.ts b/packages/cli/src/utils/scaffold-plan.ts index 2fdb1db8..5f50d4ee 100644 --- a/packages/cli/src/utils/scaffold-plan.ts +++ b/packages/cli/src/utils/scaffold-plan.ts @@ -74,6 +74,7 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { .map((m) => `import { ${m.backendSymbol} } from "${m.backendImportPath}"`) .join("\n"), clientImports: metas + .filter((m) => m.key !== "ui-builder" || hasCms) .map((m) => `import { ${m.clientSymbol} } from "${m.clientImportPath}"`) .join("\n"), backendEntries: metas @@ -101,6 +102,7 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { .filter(Boolean) .join("\n"), clientEntries: metas + .filter((m) => m.key !== "ui-builder" || hasCms) .map((m) => { const siteBase = "/pages"; return `\t\t\t${m.configKey}: ${m.clientSymbol}({ @@ -113,6 +115,7 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { }) .join("\n"), pagesLayoutOverrides: metas + .filter((m) => m.key !== "ui-builder" || hasCms) .map((m) => { if (m.key === "comments") { return `\t\t\t\t\t${m.configKey}: { From 85db0d43201b3a525342f52862266cf7ab48c105 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 26 Mar 2026 14:27:47 -0400 Subject: [PATCH 49/56] feat: add better-auth-ui plugin --- packages/cli/scripts/test-init.sh | 33 +++++++- packages/cli/src/types.ts | 3 +- .../src/utils/__tests__/scaffold-plan.test.ts | 79 +++++++++++++++++++ packages/cli/src/utils/constants.ts | 12 ++- packages/cli/src/utils/scaffold-plan.ts | 78 +++++++++++++++--- 5 files changed, 188 insertions(+), 17 deletions(-) diff --git a/packages/cli/scripts/test-init.sh b/packages/cli/scripts/test-init.sh index cbae713d..7fa4855e 100644 --- a/packages/cli/scripts/test-init.sh +++ b/packages/cli/scripts/test-init.sh @@ -73,11 +73,23 @@ test -f "$STACK_TARBALL" success "Packed @btst/stack -> $(basename "$STACK_TARBALL")" cd "$ROOT_DIR/packages/cli" +npm run build --silent 2>/dev/null CODEGEN_TGZ=$(npm pack --quiet 2>/dev/null | tr -d '[:space:]') CODEGEN_TARBALL="$ROOT_DIR/packages/cli/$CODEGEN_TGZ" test -f "$CODEGEN_TARBALL" success "Packed @btst/codegen -> $(basename "$CODEGEN_TARBALL")" +BETTER_AUTH_UI_DIR="$ROOT_DIR/../better-auth-ui" +if [ ! -d "$BETTER_AUTH_UI_DIR" ]; then + error "@btst/better-auth-ui source not found at $BETTER_AUTH_UI_DIR" + exit 1 +fi +cd "$BETTER_AUTH_UI_DIR" +BETTER_AUTH_UI_TGZ=$(npm pack --quiet 2>/dev/null | tr -d '[:space:]') +BETTER_AUTH_UI_TARBALL="$BETTER_AUTH_UI_DIR/$BETTER_AUTH_UI_TGZ" +test -f "$BETTER_AUTH_UI_TARBALL" +success "Packed @btst/better-auth-ui -> $(basename "$BETTER_AUTH_UI_TARBALL")" + step "Creating Next.js fixture" mkdir -p "$TEST_DIR" cd "$TEST_DIR" @@ -101,8 +113,8 @@ success "Initialized shadcn baseline in fixture (radix, v${SHADCN_VERSION})" success "Fixture created at $TEST_DIR/app" step "Installing packed tarballs" -npm install "$STACK_TARBALL" "$CODEGEN_TARBALL" --legacy-peer-deps -success "Installed local @btst/stack and @btst/codegen" +npm install "$STACK_TARBALL" "$CODEGEN_TARBALL" "$BETTER_AUTH_UI_TARBALL" --legacy-peer-deps +success "Installed local @btst/stack, @btst/codegen, and @btst/better-auth-ui" step "Running btst init (first pass)" npx @btst/codegen init --yes --framework nextjs --adapter memory --plugins all --skip-install 2>&1 | tee "$TEST_DIR/init-first.log" @@ -114,8 +126,16 @@ success "First init run completed" step "Installing runtime deps needed for generated files" STACK_PEERS=$(node -e 'const fs=require("fs");const p=JSON.parse(fs.readFileSync("node_modules/@btst/stack/package.json","utf8"));process.stdout.write(Object.keys(p.peerDependencies||{}).join(" "));') -npm install @btst/adapter-memory $STACK_PEERS --legacy-peer-deps -success "Installed runtime deps (adapter + @btst/stack peers)" +BETTER_AUTH_UI_PEERS=$(node -e ' +const fs=require("fs"); +const p=JSON.parse(fs.readFileSync("node_modules/@btst/better-auth-ui/package.json","utf8")); +const skip=new Set(["react","react-dom","tailwindcss","@btst/stack","@btst/yar","better-auth","@tanstack/react-query"]); +const optionalPrefixes=["@triplit","@instantdb","@daveyplate"]; +const keys=Object.keys(p.peerDependencies||{}).filter(d=>!skip.has(d)&&!optionalPrefixes.some(pre=>d.startsWith(pre))); +process.stdout.write(keys.join(" ")); +') +npm install @btst/adapter-memory better-auth $STACK_PEERS $BETTER_AUTH_UI_PEERS --legacy-peer-deps +success "Installed runtime deps (adapter + better-auth + @btst/stack and @btst/better-auth-ui peers)" step "Asserting generated files and patches" test -f "lib/stack.ts" @@ -127,6 +147,11 @@ test -f "app/pages/layout.tsx" node -e 'const fs=require("fs");const s=fs.readFileSync("lib/stack.ts","utf8");process.exit(s.includes("import { stack } from \"@btst/stack\"")?0:1)' node -e 'const fs=require("fs");const s=fs.readFileSync("lib/stack.ts","utf8");process.exit(s.includes("mediaBackendPlugin({ storageAdapter: undefined as any })")?0:1)' node -e 'const fs=require("fs");const s=fs.readFileSync("app/globals.css","utf8");process.exit(s.includes("@btst/stack/plugins/ui-builder/css")?0:1)' +node -e 'const fs=require("fs");const s=fs.readFileSync("app/globals.css","utf8");process.exit(s.includes("@btst/better-auth-ui/css")?0:1)' +node -e 'const fs=require("fs");const s=fs.readFileSync("lib/stack-client.tsx","utf8");process.exit(s.includes("authClientPlugin")?0:1)' +node -e 'const fs=require("fs");const s=fs.readFileSync("lib/stack-client.tsx","utf8");process.exit(s.includes("accountClientPlugin")?0:1)' +node -e 'const fs=require("fs");const s=fs.readFileSync("lib/stack-client.tsx","utf8");process.exit(s.includes("organizationClientPlugin")?0:1)' +node -e 'const fs=require("fs");const s=fs.readFileSync("lib/stack-client.tsx","utf8");process.exit(s.includes("@btst/better-auth-ui/client")?0:1)' success "Generation + patch checks passed" step "Idempotency check (second pass)" diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index b5745980..83d5fbd3 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -10,7 +10,8 @@ export type PluginKey = | "ui-builder" | "kanban" | "comments" - | "media"; + | "media" + | "better-auth-ui"; export type PackageManager = "pnpm" | "npm" | "yarn"; diff --git a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts index 7fa6e0dd..57969529 100644 --- a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts +++ b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { buildScaffoldPlan } from "../scaffold-plan"; +import { PLUGINS } from "../constants"; describe("scaffold plan", () => { it("builds expected files for nextjs", async () => { @@ -259,4 +260,82 @@ describe("scaffold plan", () => { ); expect(pagesRouteFile?.content).not.toContain("context.queryClient"); }); + + it("generates three client plugin entries for better-auth-ui with no backend registration", async () => { + const plan = await buildScaffoldPlan({ + framework: "nextjs", + adapter: "memory", + plugins: ["better-auth-ui"], + alias: "@/", + cssFile: "app/globals.css", + }); + + const stackFile = plan.files.find((file) => file.path.endsWith("stack.ts")); + const stackClientFile = plan.files.find((file) => + file.path.endsWith("stack-client.tsx"), + ); + const pagesLayoutFile = plan.files.find((file) => + file.path.endsWith("app/pages/layout.tsx"), + ); + + // No backend registration — stack.ts must not reference auth + expect(stackFile?.content).not.toContain("authClientPlugin"); + expect(stackFile?.content).not.toContain("better-auth-ui"); + + // Combined import for all three client plugins + expect(stackClientFile?.content).toContain( + 'import { authClientPlugin, accountClientPlugin, organizationClientPlugin } from "@btst/better-auth-ui/client"', + ); + + // Three client plugin entries + expect(stackClientFile?.content).toContain("auth: authClientPlugin({"); + expect(stackClientFile?.content).toContain( + "account: accountClientPlugin({", + ); + expect(stackClientFile?.content).toContain( + "organization: organizationClientPlugin({", + ); + + // No apiBaseURL/apiBasePath in better-auth-ui client entries + expect(stackClientFile?.content).not.toContain('apiBasePath: "/api/data"'); + + // Pages layout overrides — three blocks with authClient placeholder + expect(pagesLayoutFile?.content).toContain("authClient: undefined as any"); + expect(pagesLayoutFile?.content).toContain('basePath: "/pages/auth"'); + expect(pagesLayoutFile?.content).toContain('basePath: "/pages/account"'); + expect(pagesLayoutFile?.content).toContain('basePath: "/pages/org"'); + expect(pagesLayoutFile?.content).toContain( + "replace: (path: string) => router.replace(path)", + ); + expect(pagesLayoutFile?.content).toContain( + "onSessionChange: () => router.refresh()", + ); + }); + + it("does not include apiBaseURL/apiBasePath in better-auth-ui client entries when mixed with other plugins", async () => { + const plan = await buildScaffoldPlan({ + framework: "nextjs", + adapter: "memory", + plugins: ["blog", "better-auth-ui"], + alias: "@/", + cssFile: "app/globals.css", + }); + + const stackClientFile = plan.files.find((file) => + file.path.endsWith("stack-client.tsx"), + ); + + // blog entry still has apiBaseURL + expect(stackClientFile?.content).toContain("blog: blogClientPlugin({"); + // better-auth-ui entries have siteBaseURL but not apiBasePath + expect(stackClientFile?.content).toContain("auth: authClientPlugin({"); + expect(stackClientFile?.content).toContain( + "organization: organizationClientPlugin({", + ); + }); + + it("includes better-auth-ui in the PLUGINS registry", () => { + const allKeys = PLUGINS.map((p) => p.key); + expect(allKeys).toContain("better-auth-ui"); + }); }); diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts index d1a3ea37..34d5ef4f 100644 --- a/packages/cli/src/utils/constants.ts +++ b/packages/cli/src/utils/constants.ts @@ -11,8 +11,8 @@ export interface PluginMeta { key: PluginKey; label: string; cssImport?: string; - backendImportPath: string; - backendSymbol: string; + backendImportPath?: string; + backendSymbol?: string; clientImportPath: string; clientSymbol: string; configKey: string; @@ -129,6 +129,14 @@ export const PLUGINS: readonly PluginMeta[] = [ clientSymbol: "mediaClientPlugin", configKey: "media", }, + { + key: "better-auth-ui", + label: "Better Auth UI", + cssImport: "@btst/better-auth-ui/css", + clientImportPath: "@btst/better-auth-ui/client", + clientSymbol: "authClientPlugin", + configKey: "auth", + }, ]; export const DEFAULT_PLUGIN_SELECTION: PluginKey[] = []; diff --git a/packages/cli/src/utils/scaffold-plan.ts b/packages/cli/src/utils/scaffold-plan.ts index 5f50d4ee..9bb1f889 100644 --- a/packages/cli/src/utils/scaffold-plan.ts +++ b/packages/cli/src/utils/scaffold-plan.ts @@ -65,20 +65,35 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { selectedPlugins.includes(plugin.key), ); const hasUiBuilder = selectedPlugins.includes("ui-builder"); - const hasCms = selectedPlugins.includes("cms"); + const hasBetterAuthUi = selectedPlugins.includes("better-auth-ui"); + + const backendMetas = metas.filter( + (m) => + m.backendImportPath && + m.backendSymbol && + (m.key !== "ui-builder" || hasCms), + ); + + const clientMetas = metas.filter((m) => m.key !== "ui-builder" || hasCms); return { - backendImports: metas - .filter((m) => m.key !== "ui-builder" || hasCms) + backendImports: backendMetas .map((m) => `import { ${m.backendSymbol} } from "${m.backendImportPath}"`) .join("\n"), - clientImports: metas - .filter((m) => m.key !== "ui-builder" || hasCms) - .map((m) => `import { ${m.clientSymbol} } from "${m.clientImportPath}"`) + clientImports: clientMetas + .map((m) => { + if (m.key === "better-auth-ui") { + return `import { authClientPlugin, accountClientPlugin, organizationClientPlugin } from "${m.clientImportPath}"`; + } + return `import { ${m.clientSymbol} } from "${m.clientImportPath}"`; + }) .join("\n"), backendEntries: metas .map((m) => { + if (m.key === "better-auth-ui") { + return ""; + } if (m.key === "ai-chat") { return `\t\t${m.configKey}: ${m.backendSymbol}({ model: undefined as any }),`; } @@ -101,9 +116,23 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { }) .filter(Boolean) .join("\n"), - clientEntries: metas - .filter((m) => m.key !== "ui-builder" || hasCms) + clientEntries: clientMetas .map((m) => { + if (m.key === "better-auth-ui") { + const siteBase = "/pages"; + return `\t\t\tauth: authClientPlugin({ +\t\t\t\tsiteBaseURL: baseURL, +\t\t\t\tsiteBasePath: "${siteBase}", +\t\t\t}), +\t\t\taccount: accountClientPlugin({ +\t\t\t\tsiteBaseURL: baseURL, +\t\t\t\tsiteBasePath: "${siteBase}", +\t\t\t}), +\t\t\torganization: organizationClientPlugin({ +\t\t\t\tsiteBaseURL: baseURL, +\t\t\t\tsiteBasePath: "${siteBase}", +\t\t\t}),`; + } const siteBase = "/pages"; return `\t\t\t${m.configKey}: ${m.clientSymbol}({ \t\t\t\tapiBaseURL: baseURL, @@ -114,9 +143,37 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { \t\t\t}),`; }) .join("\n"), - pagesLayoutOverrides: metas - .filter((m) => m.key !== "ui-builder" || hasCms) + pagesLayoutOverrides: clientMetas .map((m) => { + if (m.key === "better-auth-ui") { + return `\t\t\t\t\tauth: { +\t\t\t\t\t\tauthClient: undefined as any, +\t\t\t\t\t\tnavigate: (path: string) => router.push(path), +\t\t\t\t\t\treplace: (path: string) => router.replace(path), +\t\t\t\t\t\tonSessionChange: () => router.refresh(), +\t\t\t\t\t\tLink: ({ href, ...props }: any) => , +\t\t\t\t\t\tbasePath: "/pages/auth", +\t\t\t\t\t\tredirectTo: "/pages/account/settings", +\t\t\t\t\t}, +\t\t\t\t\taccount: { +\t\t\t\t\t\tauthClient: undefined as any, +\t\t\t\t\t\tnavigate: (path: string) => router.push(path), +\t\t\t\t\t\treplace: (path: string) => router.replace(path), +\t\t\t\t\t\tonSessionChange: () => router.refresh(), +\t\t\t\t\t\tLink: ({ href, ...props }: any) => , +\t\t\t\t\t\tbasePath: "/pages/account", +\t\t\t\t\t\taccount: { fields: ["image", "name"] }, +\t\t\t\t\t}, +\t\t\t\t\torganization: { +\t\t\t\t\t\tauthClient: undefined as any, +\t\t\t\t\t\tnavigate: (path: string) => router.push(path), +\t\t\t\t\t\treplace: (path: string) => router.replace(path), +\t\t\t\t\t\tonSessionChange: () => router.refresh(), +\t\t\t\t\t\tLink: ({ href, ...props }: any) => , +\t\t\t\t\t\tbasePath: "/pages/org", +\t\t\t\t\t\torganization: { basePath: "/pages/org" }, +\t\t\t\t\t},`; + } if (m.key === "comments") { return `\t\t\t\t\t${m.configKey}: { \t\t\t\t\t\tapiBaseURL: baseURL, @@ -172,6 +229,7 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { \t\t\t\t\t},`; }) .join("\n"), + hasBetterAuthUi, }; } From 7b073ca440b69b611fca61c6c2f6c55f1e45d17c Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 26 Mar 2026 14:39:53 -0400 Subject: [PATCH 50/56] refactor: remove direct packing of better-auth-ui and update installation command in test-init script --- packages/cli/scripts/test-init.sh | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/cli/scripts/test-init.sh b/packages/cli/scripts/test-init.sh index 7fa4855e..3534eb2d 100644 --- a/packages/cli/scripts/test-init.sh +++ b/packages/cli/scripts/test-init.sh @@ -79,16 +79,6 @@ CODEGEN_TARBALL="$ROOT_DIR/packages/cli/$CODEGEN_TGZ" test -f "$CODEGEN_TARBALL" success "Packed @btst/codegen -> $(basename "$CODEGEN_TARBALL")" -BETTER_AUTH_UI_DIR="$ROOT_DIR/../better-auth-ui" -if [ ! -d "$BETTER_AUTH_UI_DIR" ]; then - error "@btst/better-auth-ui source not found at $BETTER_AUTH_UI_DIR" - exit 1 -fi -cd "$BETTER_AUTH_UI_DIR" -BETTER_AUTH_UI_TGZ=$(npm pack --quiet 2>/dev/null | tr -d '[:space:]') -BETTER_AUTH_UI_TARBALL="$BETTER_AUTH_UI_DIR/$BETTER_AUTH_UI_TGZ" -test -f "$BETTER_AUTH_UI_TARBALL" -success "Packed @btst/better-auth-ui -> $(basename "$BETTER_AUTH_UI_TARBALL")" step "Creating Next.js fixture" mkdir -p "$TEST_DIR" @@ -113,8 +103,8 @@ success "Initialized shadcn baseline in fixture (radix, v${SHADCN_VERSION})" success "Fixture created at $TEST_DIR/app" step "Installing packed tarballs" -npm install "$STACK_TARBALL" "$CODEGEN_TARBALL" "$BETTER_AUTH_UI_TARBALL" --legacy-peer-deps -success "Installed local @btst/stack, @btst/codegen, and @btst/better-auth-ui" +npm install "$STACK_TARBALL" "$CODEGEN_TARBALL" @btst/better-auth-ui --legacy-peer-deps +success "Installed local @btst/stack and @btst/codegen, and @btst/better-auth-ui from npm" step "Running btst init (first pass)" npx @btst/codegen init --yes --framework nextjs --adapter memory --plugins all --skip-install 2>&1 | tee "$TEST_DIR/init-first.log" From 4f776d778a4310f806a08ba66efd0afebbd8b74c Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 26 Mar 2026 14:45:38 -0400 Subject: [PATCH 51/56] feat: enhance plugin installation process by adding support for extra packages and updating test-init script --- packages/cli/scripts/test-init.sh | 8 ++++---- packages/cli/src/commands/init.ts | 1 + packages/cli/src/utils/constants.ts | 3 +++ packages/cli/src/utils/package-installer.ts | 11 +++++++++-- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/cli/scripts/test-init.sh b/packages/cli/scripts/test-init.sh index 3534eb2d..9000dce2 100644 --- a/packages/cli/scripts/test-init.sh +++ b/packages/cli/scripts/test-init.sh @@ -103,8 +103,8 @@ success "Initialized shadcn baseline in fixture (radix, v${SHADCN_VERSION})" success "Fixture created at $TEST_DIR/app" step "Installing packed tarballs" -npm install "$STACK_TARBALL" "$CODEGEN_TARBALL" @btst/better-auth-ui --legacy-peer-deps -success "Installed local @btst/stack and @btst/codegen, and @btst/better-auth-ui from npm" +npm install "$STACK_TARBALL" "$CODEGEN_TARBALL" --legacy-peer-deps +success "Installed local @btst/stack and @btst/codegen" step "Running btst init (first pass)" npx @btst/codegen init --yes --framework nextjs --adapter memory --plugins all --skip-install 2>&1 | tee "$TEST_DIR/init-first.log" @@ -124,8 +124,8 @@ const optionalPrefixes=["@triplit","@instantdb","@daveyplate"]; const keys=Object.keys(p.peerDependencies||{}).filter(d=>!skip.has(d)&&!optionalPrefixes.some(pre=>d.startsWith(pre))); process.stdout.write(keys.join(" ")); ') -npm install @btst/adapter-memory better-auth $STACK_PEERS $BETTER_AUTH_UI_PEERS --legacy-peer-deps -success "Installed runtime deps (adapter + better-auth + @btst/stack and @btst/better-auth-ui peers)" +npm install @btst/adapter-memory @btst/better-auth-ui better-auth $STACK_PEERS $BETTER_AUTH_UI_PEERS --legacy-peer-deps +success "Installed runtime deps (adapter + @btst/better-auth-ui + better-auth + @btst/stack and @btst/better-auth-ui peers)" step "Asserting generated files and patches" test -f "lib/stack.ts" diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 9d7523c8..824fa051 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -274,6 +274,7 @@ export function createInitCommand() { cwd, packageManager, adapter, + plugins: selectedPlugins, skipInstall: rawOptions.skipInstall, }); diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts index 34d5ef4f..4f49773a 100644 --- a/packages/cli/src/utils/constants.ts +++ b/packages/cli/src/utils/constants.ts @@ -16,6 +16,8 @@ export interface PluginMeta { clientImportPath: string; clientSymbol: string; configKey: string; + /** Additional npm packages that must be installed when this plugin is selected. */ + extraPackages?: string[]; } export const ADAPTERS: readonly AdapterMeta[] = [ @@ -136,6 +138,7 @@ export const PLUGINS: readonly PluginMeta[] = [ clientImportPath: "@btst/better-auth-ui/client", clientSymbol: "authClientPlugin", configKey: "auth", + extraPackages: ["@btst/better-auth-ui", "better-auth"], }, ]; diff --git a/packages/cli/src/utils/package-installer.ts b/packages/cli/src/utils/package-installer.ts index 53f8f386..ef93d33e 100644 --- a/packages/cli/src/utils/package-installer.ts +++ b/packages/cli/src/utils/package-installer.ts @@ -1,6 +1,6 @@ import { execa } from "execa"; -import { ADAPTERS } from "./constants"; -import type { Adapter, PackageManager } from "../types"; +import { ADAPTERS, PLUGINS } from "./constants"; +import type { Adapter, PackageManager, PluginKey } from "../types"; function getInstallCommand( packageManager: PackageManager, @@ -19,6 +19,7 @@ export async function installInitDependencies(input: { cwd: string; packageManager: PackageManager; adapter: Adapter; + plugins: PluginKey[]; skipInstall?: boolean; }): Promise { if (input.skipInstall) return; @@ -28,11 +29,17 @@ export async function installInitDependencies(input: { throw new Error(`Unknown adapter: ${input.adapter}`); } + const pluginExtraPackages = input.plugins.flatMap((key) => { + const meta = PLUGINS.find((p) => p.key === key); + return meta?.extraPackages ?? []; + }); + const packages = [ "@btst/stack", "@btst/yar", "@tanstack/react-query", adapterMeta.packageName, + ...pluginExtraPackages, ]; const { command, args } = getInstallCommand(input.packageManager, packages); await execa(command, args, { cwd: input.cwd, stdio: "inherit" }); From 4ed7ee7be13a8529750cd48ac47c7229598b560a Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 26 Mar 2026 14:48:52 -0400 Subject: [PATCH 52/56] fix: update test-init script to conditionally install better-auth-ui peers for improved dependency resolution --- packages/cli/scripts/test-init.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cli/scripts/test-init.sh b/packages/cli/scripts/test-init.sh index 9000dce2..51fb8832 100644 --- a/packages/cli/scripts/test-init.sh +++ b/packages/cli/scripts/test-init.sh @@ -116,6 +116,9 @@ success "First init run completed" step "Installing runtime deps needed for generated files" STACK_PEERS=$(node -e 'const fs=require("fs");const p=JSON.parse(fs.readFileSync("node_modules/@btst/stack/package.json","utf8"));process.stdout.write(Object.keys(p.peerDependencies||{}).join(" "));') +# Install adapter, @btst/better-auth-ui, better-auth, and @btst/stack peers first so +# @btst/better-auth-ui/package.json is present for peer resolution in the next step. +npm install @btst/adapter-memory @btst/better-auth-ui better-auth $STACK_PEERS --legacy-peer-deps BETTER_AUTH_UI_PEERS=$(node -e ' const fs=require("fs"); const p=JSON.parse(fs.readFileSync("node_modules/@btst/better-auth-ui/package.json","utf8")); @@ -124,7 +127,9 @@ const optionalPrefixes=["@triplit","@instantdb","@daveyplate"]; const keys=Object.keys(p.peerDependencies||{}).filter(d=>!skip.has(d)&&!optionalPrefixes.some(pre=>d.startsWith(pre))); process.stdout.write(keys.join(" ")); ') -npm install @btst/adapter-memory @btst/better-auth-ui better-auth $STACK_PEERS $BETTER_AUTH_UI_PEERS --legacy-peer-deps +if [ -n "$BETTER_AUTH_UI_PEERS" ]; then + npm install $BETTER_AUTH_UI_PEERS --legacy-peer-deps +fi success "Installed runtime deps (adapter + @btst/better-auth-ui + better-auth + @btst/stack and @btst/better-auth-ui peers)" step "Asserting generated files and patches" From 7a84f7583fa32a874c20deb2d5da2c405bf3f59d Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 26 Mar 2026 15:20:49 -0400 Subject: [PATCH 53/56] feat: add new plugins 'route-docs' and 'open-api' to CLI, update plugin documentation and scaffold plan for improved integration --- docs/content/docs/cli.mdx | 2 +- packages/cli/src/types.ts | 4 +++- packages/cli/src/utils/constants.ts | 19 +++++++++++++++++-- packages/cli/src/utils/scaffold-plan.ts | 16 +++++++++++++++- 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index 191362f6..6f613861 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -28,7 +28,7 @@ Common flags: |------|-------------| | `--framework` | `nextjs`, `react-router`, or `tanstack` | | `--adapter` | `memory`, `prisma`, `drizzle`, `kysely`, or `mongodb` | -| `--plugins` | Comma-separated plugin keys: `blog`, `ai-chat`, `cms`, `form-builder`, `ui-builder`, `kanban`, `comments`, `media` (or `all`) | +| `--plugins` | Comma-separated plugin keys: `blog`, `ai-chat`, `cms`, `form-builder`, `ui-builder`, `kanban`, `comments`, `media`, `route-docs`, `open-api` (or `all`) | | `--cwd` | Target directory | | `--skip-install` | Skip package installation step | | `--yes` | Non-interactive defaults (useful in CI) | diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 83d5fbd3..81a490a2 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -11,7 +11,9 @@ export type PluginKey = | "kanban" | "comments" | "media" - | "better-auth-ui"; + | "better-auth-ui" + | "route-docs" + | "open-api"; export type PackageManager = "pnpm" | "npm" | "yarn"; diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts index 4f49773a..b334a8fb 100644 --- a/packages/cli/src/utils/constants.ts +++ b/packages/cli/src/utils/constants.ts @@ -13,8 +13,8 @@ export interface PluginMeta { cssImport?: string; backendImportPath?: string; backendSymbol?: string; - clientImportPath: string; - clientSymbol: string; + clientImportPath?: string; + clientSymbol?: string; configKey: string; /** Additional npm packages that must be installed when this plugin is selected. */ extraPackages?: string[]; @@ -140,6 +140,21 @@ export const PLUGINS: readonly PluginMeta[] = [ configKey: "auth", extraPackages: ["@btst/better-auth-ui", "better-auth"], }, + { + key: "route-docs", + label: "Route Docs", + cssImport: "@btst/stack/plugins/route-docs/css", + clientImportPath: "@btst/stack/plugins/route-docs/client", + clientSymbol: "routeDocsClientPlugin", + configKey: "routeDocs", + }, + { + key: "open-api", + label: "OpenAPI", + backendImportPath: "@btst/stack/plugins/open-api/api", + backendSymbol: "openApiBackendPlugin", + configKey: "openApi", + }, ]; export const DEFAULT_PLUGIN_SELECTION: PluginKey[] = []; diff --git a/packages/cli/src/utils/scaffold-plan.ts b/packages/cli/src/utils/scaffold-plan.ts index 9bb1f889..a5533dd9 100644 --- a/packages/cli/src/utils/scaffold-plan.ts +++ b/packages/cli/src/utils/scaffold-plan.ts @@ -75,7 +75,12 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { (m.key !== "ui-builder" || hasCms), ); - const clientMetas = metas.filter((m) => m.key !== "ui-builder" || hasCms); + const clientMetas = metas.filter( + (m) => + (m.key !== "ui-builder" || hasCms) && + Boolean(m.clientImportPath) && + Boolean(m.clientSymbol), + ); return { backendImports: backendMetas @@ -91,6 +96,9 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { .join("\n"), backendEntries: metas .map((m) => { + if (!m.backendSymbol) { + return ""; + } if (m.key === "better-auth-ui") { return ""; } @@ -118,6 +126,9 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { .join("\n"), clientEntries: clientMetas .map((m) => { + if (m.key === "route-docs") { + return `\t\t\t${m.configKey}: ${m.clientSymbol}({\n\t\t\t\tqueryClient,\n\t\t\t\tsiteBasePath: "/pages",\n\t\t\t}),`; + } if (m.key === "better-auth-ui") { const siteBase = "/pages"; return `\t\t\tauth: authClientPlugin({ @@ -145,6 +156,9 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { .join("\n"), pagesLayoutOverrides: clientMetas .map((m) => { + if (m.key === "route-docs") { + return ""; + } if (m.key === "better-auth-ui") { return `\t\t\t\t\tauth: { \t\t\t\t\t\tauthClient: undefined as any, From ef9e56fa4a3ece09e7f6edb4c5960473127fb20d Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 26 Mar 2026 15:21:47 -0400 Subject: [PATCH 54/56] refactor: update API route handling to use createFileRoute and improve request handler structure --- .agents/skills/btst-integration/REFERENCE.md | 22 ++++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/.agents/skills/btst-integration/REFERENCE.md b/.agents/skills/btst-integration/REFERENCE.md index ffe5d921..b13c45b5 100644 --- a/.agents/skills/btst-integration/REFERENCE.md +++ b/.agents/skills/btst-integration/REFERENCE.md @@ -92,15 +92,19 @@ export async function action({ request }: ActionFunctionArgs) { **TanStack Start** (`src/routes/api/data/$.ts`): ```ts -import { myStack } from "~/lib/stack" -import { createAPIFileRoute } from "@tanstack/start/api" - -export const APIRoute = createAPIFileRoute("/api/data/$")({ - GET: ({ request }) => myStack.handler(request), - POST: ({ request }) => myStack.handler(request), - PUT: ({ request }) => myStack.handler(request), - PATCH: ({ request }) => myStack.handler(request), - DELETE: ({ request }) => myStack.handler(request), +import { createFileRoute } from "@tanstack/react-router" +import { handler } from "~/lib/stack" + +export const Route = createFileRoute("/api/data/$")({ + server: { + handlers: { + GET: async ({ request }) => handler(request), + POST: async ({ request }) => handler(request), + PUT: async ({ request }) => handler(request), + PATCH: async ({ request }) => handler(request), + DELETE: async ({ request }) => handler(request), + }, + }, }) ``` From d482a190e58d98b4488ef7f191f0a205aafda819 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 26 Mar 2026 15:27:22 -0400 Subject: [PATCH 55/56] feat: ensure CMS is included when ui-builder is selected, update related tests and documentation --- packages/cli/src/commands/init.ts | 10 ++++++- .../src/utils/__tests__/scaffold-plan.test.ts | 28 +++++++++++++++++++ packages/cli/src/utils/constants.ts | 2 +- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 824fa051..4b3eb3b8 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -203,7 +203,15 @@ export function createInitCommand() { const packageManager = await detectPackageManager(cwd); const adapter = await detectOrSelectAdapter(rawOptions); const alias = await detectAlias(cwd); - const selectedPlugins = await selectPlugins(rawOptions); + const rawSelectedPlugins = await selectPlugins(rawOptions); + + // ui-builder is a CMS sub-plugin — it has no standalone registration. + // Always ensure cms is present when ui-builder is selected. + const selectedPlugins: PluginKey[] = + rawSelectedPlugins.includes("ui-builder") && + !rawSelectedPlugins.includes("cms") + ? ["cms", ...rawSelectedPlugins] + : rawSelectedPlugins; let cssFile = await detectCssFile(cwd, framework); if (!cssFile) { diff --git a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts index 57969529..09ecc8c8 100644 --- a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts +++ b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts @@ -118,6 +118,34 @@ describe("scaffold plan", () => { expect(stackFile?.content).not.toContain("UI_BUILDER_CONTENT_TYPE"); }); + it("wires ui-builder correctly when cms is auto-injected (as init.ts does)", async () => { + // The CLI normalises ["ui-builder"] → ["cms", "ui-builder"] before calling + // buildScaffoldPlan, so cms is always present when ui-builder is selected. + const plan = await buildScaffoldPlan({ + framework: "nextjs", + adapter: "memory", + plugins: ["cms", "ui-builder"], + alias: "@/", + cssFile: "app/globals.css", + }); + + const stackFile = plan.files.find((file) => file.path.endsWith("stack.ts")); + const stackClientFile = plan.files.find((file) => + file.path.endsWith("stack-client.tsx"), + ); + + // cms backend must be registered with UI_BUILDER_CONTENT_TYPE + expect(stackFile?.content).toContain( + "cms: cmsBackendPlugin({ contentTypes: [UI_BUILDER_CONTENT_TYPE] }),", + ); + // ui-builder client plugin must be registered + expect(stackClientFile?.content).toContain( + "uiBuilder: uiBuilderClientPlugin({", + ); + // cms client plugin must also be registered + expect(stackClientFile?.content).toContain("cms: cmsClientPlugin({"); + }); + it("wires ui-builder content type into cms backend config", async () => { const plan = await buildScaffoldPlan({ framework: "nextjs", diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts index b334a8fb..377fdde7 100644 --- a/packages/cli/src/utils/constants.ts +++ b/packages/cli/src/utils/constants.ts @@ -94,7 +94,7 @@ export const PLUGINS: readonly PluginMeta[] = [ }, { key: "ui-builder", - label: "UI Builder", + label: "UI Builder (requires CMS — CMS will be added automatically)", cssImport: "@btst/stack/plugins/ui-builder/css", backendImportPath: "@btst/stack/plugins/ui-builder", backendSymbol: "UI_BUILDER_CONTENT_TYPE", From 373dae685346da429bf1843cfc9a2b4aca6a1698 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 26 Mar 2026 15:38:55 -0400 Subject: [PATCH 56/56] fix: filter out falsy values in plugin template context to ensure proper rendering --- packages/cli/src/utils/scaffold-plan.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/utils/scaffold-plan.ts b/packages/cli/src/utils/scaffold-plan.ts index a5533dd9..6a5ba29f 100644 --- a/packages/cli/src/utils/scaffold-plan.ts +++ b/packages/cli/src/utils/scaffold-plan.ts @@ -242,6 +242,7 @@ function buildPluginTemplateContext(selectedPlugins: PluginKey[]) { \t\t\t\t\t\tLink: ({ href, ...props }: any) => , \t\t\t\t\t},`; }) + .filter(Boolean) .join("\n"), hasBetterAuthUi, };