From 61141999024392ad2ff80be6f9e8cb832241c4f3 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:11:34 -0400 Subject: [PATCH] feat(cli): restore Better Auth UI scaffold --- .github/workflows/init.yml | 37 ++ docs/content/docs/breaking-changes.mdx | 34 +- docs/content/docs/cli.mdx | 8 +- docs/content/docs/meta.json | 1 + docs/content/docs/plugins/better-auth-ui.mdx | 174 ++++++++ docs/content/docs/plugins/index.mdx | 8 +- packages/cli/package.json | 3 +- .../scripts/test-better-auth-ui-fixtures.mjs | 400 ++++++++++++++++++ packages/cli/scripts/test-init.sh | 19 +- packages/cli/src/commands/init.ts | 2 +- .../templates/nextjs/form-demo-client.tsx.hbs | 4 +- .../nextjs/pages-client-layout.tsx.hbs | 11 + .../templates/nextjs/preview-client.tsx.hbs | 4 +- .../nextjs/public-chat-client.tsx.hbs | 4 +- .../react-router/form-demo-route.tsx.hbs | 4 +- .../react-router/pages-layout.tsx.hbs | 12 +- .../react-router/preview-route.tsx.hbs | 4 +- .../react-router/public-chat-route.tsx.hbs | 4 +- .../templates/shared/lib/auth-client.ts.hbs | 13 + .../tanstack/form-demo-route.tsx.hbs | 4 +- .../templates/tanstack/pages-layout.tsx.hbs | 12 +- .../templates/tanstack/pages-route.tsx.hbs | 11 +- .../templates/tanstack/preview-route.tsx.hbs | 4 +- .../tanstack/public-chat-route.tsx.hbs | 4 +- packages/cli/src/types.ts | 3 +- .../src/utils/__tests__/init-command.test.ts | 12 + .../utils/__tests__/package-installer.test.ts | 32 +- .../src/utils/__tests__/scaffold-plan.test.ts | 176 +++++++- packages/cli/src/utils/constants.ts | 49 +++ packages/cli/src/utils/scaffold-plan.ts | 124 ++++-- playground/src/lib/plugin-selection.ts | 2 + 31 files changed, 1070 insertions(+), 109 deletions(-) create mode 100644 docs/content/docs/plugins/better-auth-ui.mdx create mode 100644 packages/cli/scripts/test-better-auth-ui-fixtures.mjs create mode 100644 packages/cli/src/templates/shared/lib/auth-client.ts.hbs create mode 100644 packages/cli/src/utils/__tests__/init-command.test.ts diff --git a/.github/workflows/init.yml b/.github/workflows/init.yml index 5feb97837..bd46dc5d4 100644 --- a/.github/workflows/init.yml +++ b/.github/workflows/init.yml @@ -7,6 +7,8 @@ on: - 'packages/cli/**' - 'docs/content/docs/cli.mdx' - 'docs/content/docs/installation.mdx' + - 'docs/content/docs/breaking-changes.mdx' + - 'docs/content/docs/plugins/better-auth-ui.mdx' - '.github/workflows/init.yml' concurrency: @@ -59,3 +61,38 @@ jobs: name: btst-init-fixtures path: /tmp/test-btst-init-*/ retention-days: 3 + + better-auth-ui-fixtures: + name: Better Auth UI packed fixtures + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup pnpm + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + + - name: Setup Node.js 22 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run packed framework fixtures + run: pnpm --filter @btst/codegen test:better-auth-ui-fixtures + env: + BTST_KEEP_FIXTURES: 1 + CI: true + + - name: Upload artifacts on failure + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: better-auth-ui-fixtures + path: /tmp/btst-better-auth-ui-*/ + retention-days: 3 diff --git a/docs/content/docs/breaking-changes.mdx b/docs/content/docs/breaking-changes.mdx index 7b38635f6..dff813883 100644 --- a/docs/content/docs/breaking-changes.mdx +++ b/docs/content/docs/breaking-changes.mdx @@ -421,8 +421,8 @@ deployment origins, never a server stack or request headers. BTST core is authentication-provider agnostic. Better Auth and Better Auth UI are not core dependencies or hidden identity bridges; adapt the provider your application already uses through `createClientAuth` and `createServerAuth`. -The separate Better Auth UI companion migration happens downstream of the -completed core DX migration. +Applications that already run Better Auth can separately opt into the migrated +Better Auth UI companion for auth and account pages. --- @@ -676,15 +676,19 @@ above. JavaScript applications must update removed keys too; removed callback names are not invoked at runtime. Keep hook denials exception-based while renaming them. -### 8. Replace the retired provider-specific auth scaffold +### 8. Replace the v2 provider-specific auth bridge -The v3 CLI no longer offers `better-auth-ui`, installs -`@btst/better-auth-ui` or Better Auth packages, or generates auth, account, -and organization routes. BTST authorization is provider-agnostic: keep your -authentication provider and UI in application code, then adapt its session -through the generic client and server identity resolvers. +The v3 CLI no longer generates the old Better Auth-to-BTST authorization +provider. BTST authorization is provider-agnostic: adapt your application +session through the generic client and server identity resolvers. -Before: +The stable-v3 CLI does offer an optional `better-auth-ui` companion scaffold +for applications that already own a Better Auth server. That selection creates +only the Better Auth browser client plus auth and account UI routes; it does not +generate the server, database, schema, migrations, providers, secrets, +organization plugin, or a BTST identity bridge. + +Remove the old bridge: ```bash npx @btst/codegen init --plugins blog,better-auth-ui @@ -698,7 +702,7 @@ import { createBetterAuthProvider } from "@btst/better-auth-ui" ``` -After: +Keep authorization application-owned: ```ts title="authorization.client.ts" export const clientAuth = createClientAuth({ @@ -717,9 +721,9 @@ export const serverAuth = createServerAuth({ Pass `clientAuth` to `StackProvider`, pass `serverAuth` to `createBackendStack({ auth: serverAuth })`, and keep -sign-in, account, and organization routes in your chosen authentication -framework. Remove the retired plugin from existing `btst init` commands and -delete its generated imports, overrides, CSS import, and package dependencies. +authorization independent from the optional Better Auth UI routes. See +[Better Auth UI Companion](/plugins/better-auth-ui) for the supported scaffold +and exact dependency cohort. ### Migration checklist @@ -735,7 +739,9 @@ delete its generated imports, overrides, CSS import, and package dependencies. - Change backend hook denials from boolean returns to thrown errors. - Replace every retired Form Builder, Kanban, and Media lifecycle spelling using the RC3 mapping tables. -- Replace the provider-specific auth scaffold with application-owned identity resolvers and routes. +- Keep the Better Auth backend and identity resolvers application-owned. When + selected, use the optional companion scaffold for browser auth/account routes + and connect it to that existing backend. - Run your framework build, TypeScript checks, and tests. --- diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index af4287a3b..322b6d243 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -32,7 +32,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`, `route-docs`, `open-api` (or `all`) | +| `--plugins` | Comma-separated plugin keys: `blog`, `ai-chat`, `cms`, `form-builder`, `ui-builder`, `kanban`, `comments`, `media`, `route-docs`, `open-api`, `better-auth-ui` (or `all`) | | `--cwd` | Target directory | | `--skip-install` | Skip package installation step | | `--yes` | Non-interactive defaults (useful in CI) | @@ -55,6 +55,12 @@ Generated v3 layouts never repeat framework router, API, or identity wiring in plugin overrides. Replace only the plugin-specific TODO values (for example an upload function or user resolver). +`better-auth-ui` is an optional companion selection. It generates the auth and +account client plugins, a browser client for an existing `/api/auth` endpoint, +and the framework-native session refresh callback. It never generates a Better +Auth server, database schema, migrations, providers, secrets, or BTST identity +adapter. See [Better Auth UI Companion](/plugins/better-auth-ui). + ## Generate and Migrate via Codegen If you prefer one command surface, these delegate to `@btst/cli`: diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index 4e84731a4..212b7165f 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -21,6 +21,7 @@ "plugins/media", "plugins/open-api", "plugins/route-docs", + "plugins/better-auth-ui", "plugins/development", "---[Database]Databases---", "databases/adapters", diff --git a/docs/content/docs/plugins/better-auth-ui.mdx b/docs/content/docs/plugins/better-auth-ui.mdx new file mode 100644 index 000000000..870a20e91 --- /dev/null +++ b/docs/content/docs/plugins/better-auth-ui.mdx @@ -0,0 +1,174 @@ +--- +title: Better Auth UI Companion +description: Add optional auth and account pages to an application that already runs Better Auth. +--- + +import { Callout } from "fumadocs-ui/components/callout"; +import { Tabs, Tab } from "fumadocs-ui/components/tabs"; + +[`@btst/better-auth-ui`](https://github.com/better-stack-ai/better-auth-ui) +is the separately maintained Better Auth UI companion for BTST v3. It adds +resolved auth and account routes while Better Auth UI continues to read its own +session and native permissions from your Better Auth client. + + + This integration assumes your application already owns a Better Auth server + endpoint. It does not generate a Better Auth backend, database adapter, + schema, migrations, authentication providers, secrets, or deployment + configuration. + + +## Generate the minimal integration + +Select the companion explicitly; it is never part of the default scaffold. + +```bash +npx @btst/codegen init --plugins better-auth-ui +``` + +The generated result: + +- registers only `authClientPlugin()` and `accountClientPlugin()`; +- creates one browser client for the existing `/api/auth` endpoint; +- mounts routes under the resolved BTST site path (`/pages/auth/*` and + `/pages/account/*` by default); +- configures API, site, and QueryClient runtime only once in + `createClientStack()`; and +- refreshes the framework explicitly after a Better Auth session change. + +Organization, API-key, passkey, multi-session, and other Better Auth extensions +are not enabled by the generated code. Add one only after the matching Better +Auth server and client plugin are configured in your application. + + + RC4 publishes API-key and passkey as required declaration peers because its + synthetic full `AuthClient` type exposes their surfaces. The CLI therefore + installs their aligned 1.6.16 packages to keep strict dependency trees clean, + but it does not import, register, or enable either runtime feature. Activation + remains an explicit application choice and requires the matching Better Auth + server/client plugins. + + +## Supported release cohort + +The corrected companion candidate is `@btst/better-auth-ui@2.0.0-rc.4`. Its +stable-v3 compatibility contract retains these exact versions: + +| Package | Version | +| --- | --- | +| `better-auth`, `@better-auth/core` | `1.6.16` | +| `@better-auth/api-key`, `@better-auth/passkey` | `1.6.16` | +| `@better-auth/utils` | `0.4.1` | +| `@better-fetch/fetch` | `1.2.2` | +| `better-call` | `1.3.6` | +| `@btst/db` and BTST database adapters | `2.2.3` | + +Do not combine this companion release with a Better Auth 1.7 dependency graph. +The CLI installs the corrected auth cohort without changing the retained +`@btst/db@2.2.3` or adapter versions. + +For a manual installation, add the companion and exact auth cohort alongside +your existing BTST dependencies: + +```bash +pnpm add @btst/better-auth-ui@2.0.0-rc.4 \ + better-auth@1.6.16 @better-auth/core@1.6.16 \ + @better-auth/api-key@1.6.16 @better-auth/passkey@1.6.16 \ + @better-auth/utils@0.4.1 @better-fetch/fetch@1.2.2 better-call@1.3.6 +``` + +The package declares its component-library peers. Resolve any peer warning +against the companion's published manifest. These optional data-adapter +subpaths add their own peers; do not install or import them unless you select +that integration: + +| Optional subpath | Additional peers | +| --- | --- | +| `@btst/better-auth-ui/tanstack` | `@daveyplate/better-auth-tanstack@^1.3.6` | +| `@btst/better-auth-ui/instantdb` | `@instantdb/react@>=0.18.0` | +| `@btst/better-auth-ui/triplit` | `@triplit/client@>=1.0.0`, `@triplit/react@>=1.0.0` | + +## Browser client and resolved routes + +The CLI generates the following application-owned seam: + +```ts title="lib/auth-client.ts" +import { createAuthClient } from "better-auth/react" + +export function createAppAuthClient(baseURL?: string) { + return createAuthClient({ + ...(baseURL ? { baseURL } : {}), + basePath: "/api/auth", + }) +} +``` + +Change `basePath` only when your existing Better Auth handler uses a different +path. The companion route bases are not configured here: they derive from the +site runtime passed once to `createClientStack()`. + +```tsx title="lib/stack-client.tsx" +import { accountClientPlugin, authClientPlugin } from "@btst/better-auth-ui/client" +import { createClientStack } from "@btst/stack/client" + +return createClientStack({ + api: { baseURL: apiOrigin, basePath: "/api/data" }, + site: { baseURL: siteOrigin, basePath: "/pages" }, + queryClient, + plugins: { + auth: authClientPlugin(), + account: accountClientPlugin(), + }, +}) +``` + +## Provider overrides + +The resolved stack infers both override keys. Configure the Better Auth client +once under `auth`; account-specific settings remain under `account`. + +```tsx + + {children} + +``` + +Use the framework-native synchronization generated for your target: + + + + ```ts + onSessionChange: () => router.refresh() + ``` + + + ```ts + onSessionChange: () => revalidator.revalidate() + ``` + + + ```ts + onSessionChange: () => router.invalidate() + ``` + + + +The bridge performs no hidden BTST identity refetch. If business plugins use +BTST authorization, map the Better Auth session separately with BTST's generic +`createClientAuth` and `createServerAuth` contracts and keep server +authorization authoritative. diff --git a/docs/content/docs/plugins/index.mdx b/docs/content/docs/plugins/index.mdx index 9ce5c9713..b8b780520 100644 --- a/docs/content/docs/plugins/index.mdx +++ b/docs/content/docs/plugins/index.mdx @@ -4,7 +4,7 @@ description: Available plugins and features for BTST --- import { Card, Cards } from "fumadocs-ui/components/card"; -import { BookOpen, Database, Hammer, Bot, FileText, FileCode, Route, Layout, Columns3, MessageSquare, ImageIcon } from "lucide-react"; +import { BookOpen, Database, Hammer, Bot, FileText, FileCode, Route, Layout, Columns3, MessageSquare, ImageIcon, ShieldCheck } from "lucide-react"; BTST provides a collection of full-stack plugins that you can easily integrate into your React application. Each plugin includes routes, APIs, database schemas, components, and hooks—everything you need to add complete features to your app. @@ -72,6 +72,12 @@ With more plugins coming soon, you can add complete features to your app in minu icon={} description="Auto-generated client route documentation with interactive navigation." /> + } + description="Optional auth and account routes for applications that already run Better Auth." + /> argument !== "--"); + if (requested.length === 0) return FRAMEWORKS; + for (const framework of requested) { + if (!FRAMEWORKS.includes(framework)) { + throw new Error( + `Unknown framework ${framework}. Expected: ${FRAMEWORKS.join(", ")}`, + ); + } + } + return [...new Set(requested)]; +} + +function logStep(message) { + console.log(`\n[better-auth-ui-fixture] ${message}`); +} + +async function run(command, args, options = {}) { + logStep([command, ...args].join(" ")); + try { + return await execFileAsync(command, args, { + cwd: options.cwd, + env: { + ...process.env, + CI: "true", + COREPACK_ENABLE_AUTO_PIN: "0", + ...options.env, + }, + maxBuffer: 64 * 1024 * 1024, + stdio: options.capture ? "pipe" : "inherit", + }); + } catch (error) { + if (error.stdout?.trim()) console.error(error.stdout.trim()); + if (error.stderr?.trim()) console.error(error.stderr.trim()); + throw new Error(`Command failed: ${command} ${args.join(" ")}`, { + cause: error, + }); + } +} + +async function packLocalPackage(packageDirectory, artifactsDirectory) { + const result = await run( + "npm", + ["pack", "--quiet", "--pack-destination", artifactsDirectory], + { cwd: packageDirectory, capture: true }, + ); + const tarballName = result.stdout.trim().split(/\s+/).at(-1); + if (!tarballName?.endsWith(".tgz")) { + throw new Error(`npm pack did not report a tarball: ${result.stdout}`); + } + return realpath(join(artifactsDirectory, basename(tarballName))); +} + +async function packPublicCompanion(artifactsDirectory) { + const result = await run( + "npm", + [ + "pack", + `@btst/better-auth-ui@${BETTER_AUTH_UI_VERSION}`, + "--quiet", + "--pack-destination", + artifactsDirectory, + ], + { cwd: REPOSITORY_ROOT, capture: true }, + ); + const tarballName = result.stdout.trim().split(/\s+/).at(-1); + if (!tarballName?.endsWith(".tgz")) { + throw new Error(`npm pack did not report a tarball: ${result.stdout}`); + } + return realpath(join(artifactsDirectory, basename(tarballName))); +} + +async function prepareArtifacts(tempRoot) { + await run("pnpm", ["--filter", "@btst/stack", "build"], { + cwd: REPOSITORY_ROOT, + }); + await run("pnpm", ["--filter", "@btst/codegen", "build"], { + cwd: REPOSITORY_ROOT, + }); + + const artifactsDirectory = join(tempRoot, "artifacts"); + await mkdir(artifactsDirectory, { recursive: true }); + return { + stack: await packLocalPackage( + join(REPOSITORY_ROOT, "packages/stack"), + artifactsDirectory, + ), + codegen: await packLocalPackage(CLI_DIRECTORY, artifactsDirectory), + betterAuthUi: await packPublicCompanion(artifactsDirectory), + }; +} + +async function patchManifest(projectDirectory, artifacts, framework) { + const manifestPath = join(projectDirectory, "package.json"); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + manifest.private = true; + manifest.dependencies = { + ...manifest.dependencies, + "@btst/adapter-memory": "2.2.3", + "@btst/better-auth-ui": `file:${artifacts.betterAuthUi}`, + "@btst/db": AUTH_COHORT["@btst/db"], + "@btst/stack": `file:${artifacts.stack}`, + "@btst/yar": "1.3.2", + "@better-auth/api-key": AUTH_COHORT["@better-auth/api-key"], + "@better-auth/core": AUTH_COHORT["@better-auth/core"], + "@better-auth/passkey": AUTH_COHORT["@better-auth/passkey"], + "@better-auth/utils": AUTH_COHORT["@better-auth/utils"], + "@better-fetch/fetch": AUTH_COHORT["@better-fetch/fetch"], + "@tanstack/react-query": "5.102.0", + "better-auth": AUTH_COHORT["better-auth"], + "better-call": AUTH_COHORT["better-call"], + "next-themes": "0.4.6", + }; + manifest.devDependencies = { + ...manifest.devDependencies, + "@btst/codegen": `file:${artifacts.codegen}`, + ...(framework === "tanstack" ? { eslint: "10.0.1" } : {}), + }; + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); +} + +function collectNamedVersions(tree, packageName, versions = new Set()) { + if (!tree || typeof tree !== "object") return versions; + if (Array.isArray(tree)) { + for (const item of tree) collectNamedVersions(item, packageName, versions); + return versions; + } + for (const [name, dependency] of Object.entries(tree.dependencies ?? {})) { + if (name === packageName && typeof dependency.version === "string") { + versions.add(dependency.version); + } + collectNamedVersions(dependency, packageName, versions); + } + return versions; +} + +async function assertCohort(projectDirectory) { + const problems = []; + for (const [packageName, expected] of Object.entries(AUTH_COHORT)) { + const versions = new Set(); + for (const depth of ["0", "Infinity"]) { + const result = await run( + "pnpm", + ["list", packageName, "--json", "--depth", depth], + { cwd: projectDirectory, capture: true }, + ); + collectNamedVersions(JSON.parse(result.stdout), packageName, versions); + } + const found = [...versions].sort(); + if (found.length !== 1 || found[0] !== expected) { + problems.push( + `${packageName}: expected only ${expected}; found ${found.join(", ") || "nothing"}`, + ); + } + } + if (problems.length > 0) { + throw new Error(`Auth cohort mismatch:\n- ${problems.join("\n- ")}`); + } +} + +async function assertGeneratedBoundary(projectDirectory, config) { + const authClient = await readFile( + join(projectDirectory, config.authClientPath), + "utf8", + ); + const stackClient = await readFile( + join(projectDirectory, config.stackClientPath), + "utf8", + ); + const provider = await readFile( + join(projectDirectory, config.providerPath), + "utf8", + ); + const css = await readFile(join(projectDirectory, config.cssFile), "utf8"); + const backend = await readFile( + join( + projectDirectory, + config.stackClientPath.replace("stack-client.tsx", "stack.ts"), + ), + "utf8", + ); + + const requirements = [ + [authClient.includes("createAuthClient"), "browser auth client"], + [authClient.includes('basePath: "/api/auth"'), "existing endpoint seam"], + [stackClient.includes("auth: authClientPlugin()"), "auth plugin"], + [stackClient.includes("account: accountClientPlugin()"), "account plugin"], + [ + !stackClient.includes("organizationClientPlugin"), + "no organization plugin", + ], + [provider.includes(config.refresh), "framework session refresh"], + [provider.includes("account: true"), "account override"], + [!provider.includes("organization:"), "no organization override"], + [!provider.includes("apiKey:"), "no API-key opt-in"], + [!provider.includes("passkey:"), "no passkey opt-in"], + [css.includes("@btst/better-auth-ui/css"), "companion CSS"], + [!backend.includes("better-auth"), "no Better Auth backend"], + ]; + const missing = requirements.filter(([ok]) => !ok).map(([, label]) => label); + if (missing.length > 0) { + throw new Error(`Generated boundary failed: ${missing.join(", ")}`); + } +} + +async function scaffoldFramework(tempRoot, framework, artifacts) { + const config = FIXTURE_CONFIG[framework]; + const projectName = `better-auth-ui-${framework}`; + const projectDirectory = join(tempRoot, projectName); + + await run( + "pnpm", + [ + "dlx", + `shadcn@${SHADCN_VERSION}`, + "init", + "-t", + config.template, + "--no-monorepo", + "--base", + "radix", + "--preset", + "nova", + "--name", + projectName, + "--yes", + ], + { cwd: tempRoot }, + ); + await run( + "pnpm", + [ + "dlx", + `shadcn@${SHADCN_VERSION}`, + "add", + "dropdown-menu", + "--yes", + "--overwrite", + ], + { cwd: projectDirectory }, + ); + + await rm(join(projectDirectory, ".git"), { recursive: true, force: true }); + await rm(join(projectDirectory, "node_modules"), { + recursive: true, + force: true, + }); + await rm(join(projectDirectory, "pnpm-lock.yaml"), { force: true }); + await rm(join(projectDirectory, "package-lock.json"), { force: true }); + await patchManifest(projectDirectory, artifacts, framework); + + await run("pnpm", ["install", "--strict-peer-dependencies"], { + cwd: projectDirectory, + }); + await run( + "pnpm", + [ + "exec", + "btst", + "init", + "--yes", + "--framework", + framework, + "--adapter", + "memory", + "--plugins", + "better-auth-ui", + "--skip-install", + ], + { cwd: projectDirectory }, + ); + + await assertGeneratedBoundary(projectDirectory, config); + await assertCohort(projectDirectory); + await run("pnpm", ["run", "build"], { + cwd: projectDirectory, + env: config.env, + }); + const generatedManifest = JSON.parse( + await readFile(join(projectDirectory, "package.json"), "utf8"), + ); + const typecheckCommand = generatedManifest.scripts?.typecheck + ? ["run", "typecheck"] + : ["exec", "tsc", "--noEmit"]; + await run("pnpm", typecheckCommand, { + cwd: projectDirectory, + env: config.env, + }); + logStep(`${framework} packed fixture passed`); +} + +async function main() { + assertNode22(); + const frameworks = selectedFrameworks(); + const tempRoot = await mkdtemp(join(tmpdir(), "btst-better-auth-ui-")); + let passed = false; + try { + const artifacts = await prepareArtifacts(tempRoot); + for (const framework of frameworks) { + await scaffoldFramework(tempRoot, framework, artifacts); + } + passed = true; + logStep(`all packed fixtures passed: ${frameworks.join(", ")}`); + } finally { + if (passed || process.env.BTST_KEEP_FIXTURES !== "1") { + await rm(tempRoot, { recursive: true, force: true }); + } else { + console.error(`Fixture retained for debugging: ${tempRoot}`); + } + } +} + +await main(); diff --git a/packages/cli/scripts/test-init.sh b/packages/cli/scripts/test-init.sh index 2638fb375..5349aa7a1 100644 --- a/packages/cli/scripts/test-init.sh +++ b/packages/cli/scripts/test-init.sh @@ -13,7 +13,7 @@ ROOT_DIR="$(cd "$PACKAGE_DIR/../.." && pwd)" TEST_DIR="/tmp/test-btst-init-$(date +%s)" TEST_PASSED=false SHADCN_VERSION="4.0.5" -MEMORY_PLUGIN_LIST="blog,ai-chat,cms,ui-builder,kanban,comments,media,route-docs,open-api" +MEMORY_PLUGIN_LIST="blog,ai-chat,cms,ui-builder,kanban,comments,media,route-docs,open-api,better-auth-ui" cleanup() { if [ "$TEST_PASSED" = true ]; then @@ -129,17 +129,6 @@ if [ "$(cat "$TEST_DIR/init-memory-before.hash")" != "$(cat "$TEST_DIR/init-memo fi success "Memory + Form Builder failed before scaffolding" -step "Rejecting the retired provider-specific authentication plugin" -if npx @btst/codegen init --yes --framework nextjs --adapter memory --plugins better-auth-ui --skip-install > "$TEST_DIR/init-retired-auth.log" 2>&1; then - error "Expected the retired authentication plugin selection to fail" - exit 1 -fi -if ! grep -q "Unknown plugin(s): better-auth-ui" "$TEST_DIR/init-retired-auth.log"; then - error "Expected retired authentication plugin guidance was not printed" - exit 1 -fi -success "Retired authentication plugin cannot be selected" - step "Running compatible memory btst init (first pass)" npx @btst/codegen init --yes --framework nextjs --adapter memory --plugins "$MEMORY_PLUGIN_LIST" --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 @@ -171,6 +160,7 @@ success "Ran @btst/cli@2.2.4 without adding it to the consumer graph" step "Asserting generated files and patches" test -f "lib/stack.ts" test -f "lib/stack-client.tsx" +test -f "lib/auth-client.ts" test -f "lib/stack-client.server.ts" test -f "lib/query-client.ts" test -f "app/api/data/[[...all]]/route.ts" @@ -181,10 +171,13 @@ test -f "app/pages/client-layout.tsx" node -e 'const fs=require("fs");const s=fs.readFileSync("lib/stack.ts","utf8");process.exit(s.includes("import { createBackendStack } from \"@btst/stack/api\"")?0:1)' node -e 'const fs=require("fs");const s=fs.readFileSync("lib/stack.ts","utf8");process.exit(s.includes("mediaBackendPlugin({ storageAdapter: localAdapter() })")?0:1)' node -e 'const fs=require("fs");const s=fs.readFileSync("lib/stack-client.tsx","utf8");process.exit(s.includes("createClientStack")&&s.includes("NEXT_PUBLIC_BASE_URL")&&!s.includes("getStackClientForRequest")?0:1)' +node -e 'const fs=require("fs");const s=fs.readFileSync("lib/stack-client.tsx","utf8");process.exit(s.includes("auth: authClientPlugin()")&&s.includes("account: accountClientPlugin()")&&!s.includes("organizationClientPlugin")?0:1)' +node -e 'const fs=require("fs");const s=fs.readFileSync("lib/auth-client.ts","utf8");process.exit(s.includes("createAuthClient")&&s.includes("/api/auth")?0:1)' node -e 'const fs=require("fs");const s=fs.readFileSync("lib/stack-client.server.ts","utf8");process.exit(s.includes("getStackClientForRequest")&&s.includes("resolveTrustedClientOrigins")&&s.includes("filterCredentialForwardingHeaders")&&s.includes("NEXT_PUBLIC_BASE_URL")?0:1)' node -e 'const fs=require("fs");const request=fs.readFileSync("app/(request)/pages/layout.tsx","utf8"),staticLayout=fs.readFileSync("app/(static)/pages/layout.tsx","utf8"),client=fs.readFileSync("app/pages/client-layout.tsx","utf8");process.exit(request.includes("getServerClientOriginsFromHeaders(await headers())")&&staticLayout.includes("getServerClientOrigins()")&&!staticLayout.includes("next/headers")&&client.includes("getStackClient(queryClient, clientOrigins)")?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"),path=require("path");const roots=["app","lib","package.json"];const retired=["@btst","better-auth-ui"].join("/");const read=(p)=>fs.statSync(p).isDirectory()?fs.readdirSync(p).flatMap((n)=>read(path.join(p,n))):[fs.readFileSync(p,"utf8")];process.exit(roots.flatMap(read).some((s)=>s.includes(retired))?1:0)' +node -e 'const fs=require("fs");const s=fs.readFileSync("app/pages/client-layout.tsx","utf8");process.exit(s.includes("authClient")&&s.includes("frameworkRouter.refresh()")&&s.includes("account: true")&&!s.includes("organization:")?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)' success "Generation + patch checks passed" step "Adding third-party public extension fixture" diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 0f4365853..416244236 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -179,7 +179,7 @@ export function createInitCommand() { ) .option( "--plugins ", - "Comma-separated plugin keys, or 'all'", + "Comma-separated plugin keys (use better-auth-ui for auth + account with an existing Better Auth backend), or 'all'", parsePluginOption, ) .option("--skip-install", "Skip dependency install") diff --git a/packages/cli/src/templates/nextjs/form-demo-client.tsx.hbs b/packages/cli/src/templates/nextjs/form-demo-client.tsx.hbs index 182dd4492..9716bce1d 100644 --- a/packages/cli/src/templates/nextjs/form-demo-client.tsx.hbs +++ b/packages/cli/src/templates/nextjs/form-demo-client.tsx.hbs @@ -29,10 +29,10 @@ export default function FormDemoPageClient({ getStackClient(queryClient, clientOrigins), [clientOrigins.apiOrigin, clientOrigins.siteOrigin, queryClient], ) +{{#if hasBetterAuthUi}} + const frameworkRouter = useRouter() + const authClient = useMemo( + () => createAppAuthClient(clientOrigins.siteOrigin), + [clientOrigins.siteOrigin], + ) +{{/if}} {{#if hasAiChat}} const hasApiKey = typeof process !== "undefined" && !!process.env.NEXT_PUBLIC_HAS_OPENAI_KEY const pathname = usePathname() diff --git a/packages/cli/src/templates/nextjs/preview-client.tsx.hbs b/packages/cli/src/templates/nextjs/preview-client.tsx.hbs index 08a7f8db2..63ed2fd11 100644 --- a/packages/cli/src/templates/nextjs/preview-client.tsx.hbs +++ b/packages/cli/src/templates/nextjs/preview-client.tsx.hbs @@ -33,10 +33,10 @@ export default function PreviewPageClient({ getStackClient(queryClient, { apiOrigin, siteOrigin }), [apiOrigin, queryClient, siteOrigin], ) +{{#if hasBetterAuthUi}} + const revalidator = useRevalidator() + const authClient = useMemo( + () => createAppAuthClient(siteOrigin), + [siteOrigin], + ) +{{/if}} {{#if hasAiChat}} const hasApiKey = !!import.meta.env.VITE_HAS_OPENAI_KEY const location = useLocation() diff --git a/packages/cli/src/templates/react-router/preview-route.tsx.hbs b/packages/cli/src/templates/react-router/preview-route.tsx.hbs index f3a0945f6..459364f62 100644 --- a/packages/cli/src/templates/react-router/preview-route.tsx.hbs +++ b/packages/cli/src/templates/react-router/preview-route.tsx.hbs @@ -33,10 +33,10 @@ export default function PreviewPage() { getStackClient(queryClient, { apiOrigin, siteOrigin }), [apiOrigin, queryClient, siteOrigin], ) +{{#if hasBetterAuthUi}} + const frameworkRouter = useRouter() + const authClient = useMemo( + () => createAppAuthClient(siteOrigin), + [siteOrigin], + ) +{{/if}} {{#if hasAiChat}} const hasApiKey = !!import.meta.env.VITE_HAS_OPENAI_KEY const location = useLocation() diff --git a/packages/cli/src/templates/tanstack/pages-route.tsx.hbs b/packages/cli/src/templates/tanstack/pages-route.tsx.hbs index eded418b8..b8ab3e894 100644 --- a/packages/cli/src/templates/tanstack/pages-route.tsx.hbs +++ b/packages/cli/src/templates/tanstack/pages-route.tsx.hbs @@ -1,7 +1,7 @@ import { createFileRoute } from "@tanstack/react-router" import { createTanStackPageOptions } from "@btst/stack/tanstack" import type { QueryClient } from "@tanstack/react-query" -import { createIsomorphicFn } from "@tanstack/react-start" +import { createIsomorphicFn, createServerOnlyFn } from "@tanstack/react-start" import { getRequest } from "@tanstack/react-start/server" import { getOrCreateQueryClient } from "{{alias}}lib/query-client" import { getStackClient } from "{{alias}}lib/stack-client" @@ -21,13 +21,20 @@ const getLoaderRequestContext = createIsomorphicFn() const getNavigationClientStack = async (queryClient: QueryClient) => getStackClient(queryClient, await getTrustedClientOrigins()) +const getRequestStackClient = createServerOnlyFn( + ( + queryClient: QueryClient, + requestContext: { headers: Headers; requestOrigin: string }, + ) => getStackClientForRequest(queryClient, requestContext), +) + export const Route = createFileRoute("/pages/$")( createTanStackPageOptions({ getStackClient, getLoaderStackClient: async (queryClient) => { const requestContext = await getLoaderRequestContext() return requestContext - ? getStackClientForRequest(queryClient, requestContext) + ? getRequestStackClient(queryClient, requestContext) : getNavigationClientStack(queryClient) }, getQueryClient: getOrCreateQueryClient, diff --git a/packages/cli/src/templates/tanstack/preview-route.tsx.hbs b/packages/cli/src/templates/tanstack/preview-route.tsx.hbs index ad9579fc1..d7c769851 100644 --- a/packages/cli/src/templates/tanstack/preview-route.tsx.hbs +++ b/packages/cli/src/templates/tanstack/preview-route.tsx.hbs @@ -31,10 +31,10 @@ function PreviewPage() { { + it("advertises the optional Better Auth UI scaffold boundary", () => { + const help = createInitCommand().helpInformation(); + + expect(help).toContain("better-auth-ui"); + expect(help).toContain("auth + account"); + expect(help).toContain("existing Better Auth backend"); + }); +}); diff --git a/packages/cli/src/utils/__tests__/package-installer.test.ts b/packages/cli/src/utils/__tests__/package-installer.test.ts index 2659ab4fe..60c9ec2a7 100644 --- a/packages/cli/src/utils/__tests__/package-installer.test.ts +++ b/packages/cli/src/utils/__tests__/package-installer.test.ts @@ -5,7 +5,6 @@ const { execa } = vi.hoisted(() => ({ execa: vi.fn() })); vi.mock("execa", () => ({ execa })); import { installInitDependencies } from "../package-installer"; -import { PLUGINS } from "../constants"; describe("installInitDependencies", () => { beforeEach(() => { @@ -13,26 +12,41 @@ describe("installInitDependencies", () => { execa.mockResolvedValue({}); }); - it("never installs a provider-specific authentication cohort", async () => { + it("installs the corrected Better Auth UI cohort only when selected", async () => { await installInitDependencies({ cwd: "/tmp/example", packageManager: "pnpm", adapter: "drizzle", - plugins: PLUGINS.map((plugin) => plugin.key), + plugins: ["better-auth-ui"], }); const installArguments = execa.mock.calls[0]?.[1] as string[]; expect(installArguments).toContain("@btst/adapter-drizzle@2.2.3"); expect(installArguments).toContain("drizzle-orm@0.45.2"); - expect(installArguments.join(" ")).not.toContain( - ["@btst", "better-auth-ui"].join("/"), - ); - expect(installArguments.join(" ")).not.toContain( - ["better", "auth"].join("-"), - ); + expect(installArguments).toContain("@btst/better-auth-ui@2.0.0-rc.4"); + expect(installArguments).toContain("better-auth@1.6.16"); + expect(installArguments).toContain("@better-auth/core@1.6.16"); + expect(installArguments).toContain("@better-auth/utils@0.4.1"); + expect(installArguments).toContain("@better-fetch/fetch@1.2.2"); + expect(installArguments).toContain("better-call@1.3.6"); + expect(installArguments).toContain("@better-auth/api-key@1.6.16"); + expect(installArguments).toContain("@better-auth/passkey@1.6.16"); expect(execa).toHaveBeenCalledTimes(1); }); + it("does not install Better Auth UI for the default scaffold", async () => { + await installInitDependencies({ + cwd: "/tmp/example", + packageManager: "pnpm", + adapter: "memory", + plugins: [], + }); + + const installArguments = execa.mock.calls[0]?.[1] as string[]; + expect(installArguments.join(" ")).not.toContain("better-auth-ui"); + expect(installArguments.join(" ")).not.toContain("better-auth@"); + }); + it("saves npm runtime versions exactly", async () => { await installInitDependencies({ cwd: "/tmp/example", diff --git a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts index c8dd592b0..48ee7259c 100644 --- a/packages/cli/src/utils/__tests__/scaffold-plan.test.ts +++ b/packages/cli/src/utils/__tests__/scaffold-plan.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { readFile } from "node:fs/promises"; import { buildScaffoldPlan } from "../scaffold-plan"; -import { PLUGINS } from "../constants"; +import { PLUGINS, PLUGIN_ROUTES } from "../constants"; describe("scaffold plan", () => { it.each(["memory", "mongodb"] as const)( @@ -75,6 +75,21 @@ describe("scaffold plan", () => { }, ); + it.each(["prisma", "drizzle", "kysely"] as const)( + "enables isolated transactions for Kanban in the %s scaffold", + async (adapter) => { + const plan = await buildScaffoldPlan({ + framework: "nextjs", + adapter, + plugins: ["kanban"], + alias: "@/", + cssFile: "app/globals.css", + }); + const stackFile = plan.files.find((file) => file.path === "lib/stack.ts"); + expect(stackFile?.content).toContain("transaction: true"); + }, + ); + it("rejects Media with the unsupported MongoDB generated configuration", async () => { await expect( buildScaffoldPlan({ @@ -425,6 +440,10 @@ describe("scaffold plan", () => { expect(pageRoute?.content).toContain("new URL(request.url).origin"); } else { expect(pageRoute?.content).toContain("createIsomorphicFn"); + expect(pageRoute?.content).toContain("createServerOnlyFn"); + expect(pageRoute?.content).toContain( + "getRequestStackClient(queryClient, requestContext)", + ); expect(pageRoute?.content).toContain("getRequest()"); expect(pageRoute?.content).toContain( "getStackClient(queryClient, await getTrustedClientOrigins())", @@ -522,6 +541,18 @@ describe("scaffold plan", () => { expect(source).not.toContain("StackProvider<"); }); + it("runs the three-framework Better Auth UI packed fixtures in CI", async () => { + const workflow = await readFile( + new URL("../../../../../.github/workflows/init.yml", import.meta.url), + "utf8", + ); + + expect(workflow).toContain("better-auth-ui-fixtures:"); + expect(workflow).toContain( + "pnpm --filter @btst/codegen test:better-auth-ui-fixtures", + ); + }); + it("does not register ui-builder as a backend plugin entry", async () => { const plan = await buildScaffoldPlan({ framework: "nextjs", @@ -944,13 +975,142 @@ describe("scaffold plan", () => { expect(layoutFile?.content).not.toContain("router.replace"); }); - it("excludes provider-specific authentication integrations from generated projects", () => { - const allKeys = PLUGINS.map((p) => p.key); - expect(allKeys).not.toContain(["better", "auth", "ui"].join("-")); - expect(JSON.stringify(PLUGINS)).not.toContain( - ["@btst", "better-auth-ui"].join("/"), - ); - }); + it.each([ + { + framework: "nextjs" as const, + authRefresh: "frameworkRouter.refresh()", + authClientPath: "lib/auth-client.ts", + }, + { + framework: "react-router" as const, + authRefresh: "revalidator.revalidate()", + authClientPath: "app/lib/auth-client.ts", + }, + { + framework: "tanstack" as const, + authRefresh: "frameworkRouter.invalidate()", + authClientPath: "src/lib/auth-client.ts", + }, + ])( + "generates the minimal Better Auth UI bridge for $framework", + async ({ framework, authRefresh, authClientPath }) => { + const plan = await buildScaffoldPlan({ + framework, + adapter: "drizzle", + plugins: ["better-auth-ui"], + alias: framework === "react-router" ? "~/" : "@/", + cssFile: + framework === "nextjs" + ? "app/globals.css" + : framework === "react-router" + ? "app/app.css" + : "src/styles.css", + }); + + const stack = plan.files.find((file) => + file.path.endsWith("lib/stack.ts"), + ); + const client = plan.files.find((file) => + file.path.endsWith("lib/stack-client.tsx"), + ); + const authClient = plan.files.find( + (file) => file.path === authClientPath, + ); + const provider = plan.files.find((file) => + file.content.includes(" plugin.key)).toContain("better-auth-ui"); + expect(PLUGIN_ROUTES["better-auth-ui"]).toEqual( + expect.arrayContaining([ + "/pages/auth/sign-in", + "/pages/account/settings", + "/pages/account/security", + ]), + ); + expect(stack?.content).not.toContain("better-auth"); + expect(stack?.content).not.toContain("transaction: true"); + expect(client?.content).toContain( + 'import { accountClientPlugin, authClientPlugin } from "@btst/better-auth-ui/client"', + ); + expect(client?.content).toContain("auth: authClientPlugin(),"); + expect(client?.content).toContain("account: accountClientPlugin(),"); + expect(client?.content).not.toContain("organizationClientPlugin"); + expect(authClient?.content).toContain( + 'import { createAuthClient } from "better-auth/react"', + ); + expect(authClient?.content).toContain('basePath: "/api/auth"'); + expect(provider?.content).toContain("authClient"); + expect(provider?.content).toContain(authRefresh); + expect(provider?.content).toContain( + 'redirectTo: "/pages/account/settings"', + ); + expect(provider?.content).toContain("account: {"); + expect(provider?.content).not.toContain("organization:"); + expect(provider?.content).not.toContain("apiKey:"); + expect(provider?.content).not.toContain("passkey:"); + expect(plan.cssImports).toContain("@btst/better-auth-ui/css"); + expect(plan.extraPackageVersions).toMatchObject({ + "@btst/better-auth-ui": "2.0.0-rc.4", + "better-auth": "1.6.16", + }); + }, + ); + + it.each([ + { + framework: "nextjs" as const, + primaryProviderPath: "app/pages/client-layout.tsx", + cssFile: "app/globals.css", + }, + { + framework: "react-router" as const, + primaryProviderPath: "app/routes/pages/_layout.tsx", + cssFile: "app/app.css", + }, + { + framework: "tanstack" as const, + primaryProviderPath: "src/routes/pages/route.tsx", + cssFile: "src/styles/globals.css", + }, + ])( + "keeps Better Auth UI state out of embedded $framework providers", + async ({ framework, primaryProviderPath, cssFile }) => { + const plan = await buildScaffoldPlan({ + framework, + adapter: "drizzle", + plugins: [ + "better-auth-ui", + "ai-chat", + "cms", + "ui-builder", + "form-builder", + "kanban", + ], + alias: framework === "react-router" ? "~/" : "@/", + cssFile, + }); + + const primaryProvider = plan.files.find( + (file) => file.path === primaryProviderPath, + ); + const embeddedProviders = plan.files.filter( + (file) => + file.path !== primaryProviderPath && + file.content.includes(" = { "route-docs": ["/pages/route-docs"], /** open-api registers an API route, not a page route */ "open-api": ["/api/data/reference"], + "better-auth-ui": [ + "/pages/auth/sign-in", + "/pages/auth/sign-up", + "/pages/auth/forgot-password", + "/pages/auth/reset-password", + "/pages/auth/magic-link", + "/pages/auth/email-otp", + "/pages/auth/two-factor", + "/pages/auth/recover-account", + "/pages/auth/callback", + "/pages/auth/sign-out", + "/pages/auth/accept-invitation", + "/pages/auth/email-verification", + "/pages/account/settings", + "/pages/account/security", + "/pages/account/api-keys", + "/pages/account/organizations", + "/pages/account/teams", + ], }; diff --git a/packages/cli/src/utils/scaffold-plan.ts b/packages/cli/src/utils/scaffold-plan.ts index f1e881cfb..9b0d29cd8 100644 --- a/packages/cli/src/utils/scaffold-plan.ts +++ b/packages/cli/src/utils/scaffold-plan.ts @@ -23,6 +23,7 @@ function getFrameworkPaths(framework: Framework, cssFile: string) { return { stackPath: `${prefix}lib/stack.ts`, stackClientPath: `${prefix}lib/stack-client.tsx`, + authClientPath: `${prefix}lib/auth-client.ts`, stackClientServerPath: `${prefix}lib/stack-client.server.ts`, stackClientOriginsPath: undefined, queryClientPath: `${prefix}lib/query-client.ts`, @@ -39,6 +40,7 @@ function getFrameworkPaths(framework: Framework, cssFile: string) { return { stackPath: "app/lib/stack.ts", stackClientPath: "app/lib/stack-client.tsx", + authClientPath: "app/lib/auth-client.ts", stackClientServerPath: "app/lib/stack-client.server.ts", stackClientOriginsPath: undefined, queryClientPath: "app/lib/query-client.ts", @@ -54,6 +56,7 @@ function getFrameworkPaths(framework: Framework, cssFile: string) { return { stackPath: "src/lib/stack.ts", stackClientPath: "src/lib/stack-client.tsx", + authClientPath: "src/lib/auth-client.ts", stackClientServerPath: "src/lib/stack-client.server.ts", stackClientOriginsPath: "src/lib/stack-client.origins.ts", queryClientPath: "src/lib/query-client.ts", @@ -121,6 +124,7 @@ function buildPluginTemplateContext( const hasFormBuilder = selectedPlugins.includes("form-builder"); const hasBlog = selectedPlugins.includes("blog"); const hasKanban = selectedPlugins.includes("kanban"); + const hasBetterAuthUi = selectedPlugins.includes("better-auth-ui"); const hasSitemap = hasBlog || hasCms || hasKanban; const backendMetas = metas.filter( @@ -139,6 +143,29 @@ function buildPluginTemplateContext( const backendImportLines = backendMetas .map((m) => `import { ${m.backendSymbol} } from "${m.backendImportPath}"`) .join("\n"); + const embeddedOverrides = clientMetas + .map((m) => { + const layoutFile = getPagesLayoutFilePath(framework); + if (m.key === "blog") { + return `\t\t\t\t\t${m.configKey}: { +\t\t\t\t\t\tuploadImage: async () => { +\t\t\t\t\t\t\tthrow new Error("TODO: implement blog.uploadImage override in ${layoutFile}") +\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\tuploadImage: async () => { +\t\t\t\t\t\t\tthrow new Error("TODO: implement kanban.uploadImage override in ${layoutFile}") +\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},`; + } + return ""; + }) + .filter(Boolean) + .join("\n"); return { hasAiChat, @@ -159,8 +186,15 @@ function buildPluginTemplateContext( ] .filter(Boolean) .join("\n"), - clientImports: clientMetas - .map((m) => `import { ${m.clientSymbol} } from "${m.clientImportPath}"`) + clientImports: [ + hasBetterAuthUi + ? 'import { accountClientPlugin, authClientPlugin } from "@btst/better-auth-ui/client"' + : "", + clientMetas + .map((m) => `import { ${m.clientSymbol} } from "${m.clientImportPath}"`) + .join("\n"), + ] + .filter(Boolean) .join("\n"), backendEntries: metas .map((m) => { @@ -200,48 +234,48 @@ function buildPluginTemplateContext( }) .filter(Boolean) .join("\n"), - clientEntries: clientMetas - .map((m) => { - if (m.key === "ai-chat") { - return `\t\t\t${m.configKey}: ${m.clientSymbol}({ mode: "public" }),`; - } - return `\t\t\t${m.configKey}: ${m.clientSymbol}(),`; - }) + clientEntries: [ + hasBetterAuthUi + ? "\t\t\tauth: authClientPlugin(),\n\t\t\taccount: accountClientPlugin()," + : "", + clientMetas + .map((m) => { + if (m.key === "ai-chat") { + return `\t\t\t${m.configKey}: ${m.clientSymbol}({ mode: "public" }),`; + } + return `\t\t\t${m.configKey}: ${m.clientSymbol}(),`; + }) + .join("\n"), + ] + .filter(Boolean) .join("\n"), clientApiEndpointEntries: clientMetas .filter((m) => m.backendSymbol && m.key !== "ui-builder") .map((m) => `\t\t\t\t${m.configKey}: crossOriginApiEndpoint,`) .join("\n"), - pagesLayoutOverrides: clientMetas - .map((m) => { - if (m.key === "route-docs" || m.key === "media") { - return ""; - } - const layoutFile = getPagesLayoutFilePath(framework); - if (m.key === "comments") { - return ""; - } - if (m.key === "blog") { - return `\t\t\t\t\t${m.configKey}: { -\t\t\t\t\t\tuploadImage: async () => { -\t\t\t\t\t\t\tthrow new Error("TODO: implement blog.uploadImage override in ${layoutFile}") -\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\tuploadImage: async () => { -\t\t\t\t\t\t\tthrow new Error("TODO: implement kanban.uploadImage override in ${layoutFile}") -\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 ""; - return ""; - }) + pagesLayoutOverrides: [ + hasBetterAuthUi + ? `\t\t\t\t\tauth: { +\t\t\t\t\t\tauthClient, +\t\t\t\t\t\tredirectTo: "/pages/account/settings", +\t\t\t\t\t\tonSessionChange: () => ${ + framework === "nextjs" + ? "frameworkRouter.refresh()" + : framework === "react-router" + ? "revalidator.revalidate()" + : "frameworkRouter.invalidate()" + }, +\t\t\t\t\t}, +\t\t\t\t\taccount: { +\t\t\t\t\t\taccount: true, +\t\t\t\t\t},` + : "", + embeddedOverrides, + ] .filter(Boolean) .join("\n"), + embeddedOverrides, + hasBetterAuthUi, }; } @@ -257,7 +291,9 @@ function buildAdapterTemplateContext( const hasFormBuilder = selectedPlugins.includes("form-builder"); const hasMedia = selectedPlugins.includes("media"); const hasAiChat = selectedPlugins.includes("ai-chat"); - const needsIsolatedTransactions = hasFormBuilder || hasMedia || hasAiChat; + const hasKanban = selectedPlugins.includes("kanban"); + const needsIsolatedTransactions = + hasFormBuilder || hasMedia || hasAiChat || hasKanban; if ( (hasFormBuilder && (adapter === "memory" || adapter === "mongodb")) || @@ -388,6 +424,18 @@ export async function buildScaffoldPlan( ), description: "BTST client stack configuration", }, + ...(pluginContext.hasBetterAuthUi + ? [ + { + path: frameworkPaths.authClientPath, + content: await renderTemplate( + "shared/lib/auth-client.ts.hbs", + sharedContext, + ), + description: "Better Auth browser client for an existing endpoint", + }, + ] + : []), { path: frameworkPaths.stackClientServerPath, content: await renderTemplate( diff --git a/playground/src/lib/plugin-selection.ts b/playground/src/lib/plugin-selection.ts index e4373d040..daba88fec 100644 --- a/playground/src/lib/plugin-selection.ts +++ b/playground/src/lib/plugin-selection.ts @@ -7,6 +7,8 @@ export const PLAYGROUND_UNSUPPORTED_PLUGINS: Partial< > = { "form-builder": "Requires a database adapter with isolated transactions, which the browser playground does not provide.", + "better-auth-ui": + "Requires an existing application-owned Better Auth endpoint, which the browser playground does not provide.", }; /**