diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba5d66d..45b0c79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,12 +41,15 @@ jobs: - run: bun run test - run: bun run docs:commands:check - run: bun run generate:skills:check + - run: bun run sync:launchers:check - run: bun run build - name: Bin smoke run: | node dist/bin.js --version node dist/bin.js --help > /dev/null node dist/bin.js docs --agent > /dev/null + - name: Launcher pack smoke + run: bun run smoke:launchers # SIGINT/SIGTERM against the built binary: the in-process suite cannot # prove the process dies by the signal after printing its envelope. - name: Signal smoke diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa141bf..5309800 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,6 +62,35 @@ jobs: npm publish --provenance --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # `brew.new` and `create-brew`: thin launchers pinned to this CLI + # version, published after it. This step never fails the release (the + # CLI is already out): a version npm already has is skipped, and a + # failed check or publish, such as a name the token does not own yet, + # leaves a warning. + - name: Publish launchers (brew.new, create-brew) + continue-on-error: true + run: | + if [ -z "$NODE_AUTH_TOKEN" ]; then + echo "::warning::NPM_TOKEN not set; launchers not published." + exit 0 + fi + VERSION=$(node -p "require('./package.json').version") + if ! bun run sync:launchers:check || ! bun run smoke:launchers \ + || ! bun run scripts/smoke-launchers.ts --registry; then + echo "::warning::Launcher checks failed; brew.new and create-brew $VERSION not published. The CLI release is unaffected." + exit 0 + fi + for pkg in brew.new create-brew; do + if npm view "$pkg@$VERSION" version >/dev/null 2>&1; then + echo "$pkg@$VERSION is already on npm; skipping." + continue + fi + if ! (cd "packages/launchers/$pkg" && npm publish --provenance --access public); then + echo "::warning::$pkg@$VERSION was not published (is the npm name reserved for this token?). The CLI release is unaffected." + fi + done + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Bump Homebrew tap run: bun run scripts/update-tap.ts env: diff --git a/AGENTS.md b/AGENTS.md index f5e0785..4ce5bed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,6 +120,13 @@ bun run docs:commands:check - `openapi/public-api-v1.yaml` and `docs/commands/README.md` are generated — regenerate, never hand-edit. +## Launchers + +`packages/launchers/{brew.new,create-brew}` are thin `npx`/`npm create` +launchers for `init`. Edit `packages/launchers/launcher.mjs`, then +`bun run sync:launchers` (copies it, pins the CLI version); +`sync:launchers:check` and `smoke:launchers` gate CI. + ## Releasing See [`RELEASING.md`](./RELEASING.md) — tag-driven npm publish with diff --git a/CHANGELOG.md b/CHANGELOG.md index 560e7ce..4f254e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,66 @@ # Changelog +## 0.20.0 + +Needs the same SDK release as 0.19.0 (`appEmails.setUpKit` and the auth +adapter subpaths from GetBrew/typescript-sdk#64), and the API from +GetBrew/brew-v2#2169 (its DX delta @ `6f8ef50544`). + +### Added + +- **`brew-cli init`** sets up Brew app email in a project: detect + (package manager, monorepo, framework: Next.js app/pages, Vite + React, + React Router, Remix, SvelteKit, Astro, Express, Hono, Supabase Edge + Functions; auth: Better Auth, Auth.js v4/v5, Supabase, Clerk, Firebase, + WorkOS, Stytch; Stripe; existing email providers; editors; cloud + shells), sign in through the browser for a CLI key and an app key (the + app key only ever lands in `.env.local`, 0600, git-ignored), set up the + starter kit for the detected stack, install `@brew.new/sdk`, write + `lib/brew.ts`, `lib/brew-auth.ts`, `lib/brew-contracts.ts`, a Supabase + Send Email Hook function, `.env.example` and `brew.json` (create-only, + hashes kept in `brew.json`), print patches for the auth config + (`--apply-patches @,…` from the report applies each only while + its file still matches the reviewed hash), configure + MCP for detected editors, write an `AGENTS.md` block and (in Claude Code + projects) the skills, and send a sandbox test email. Destructive class: + `--dry-run --json` plans with no writes and no mutating requests, + `--yes --json` runs; `--no-wait` / `--resume` for agents (exit 3 with + the sign-in link and code). Flags: `--app`, `--domain`, `--sets`, + `--stack`, `--test-to`, `--apply-patches`, `--app-url` (shown on the + approval page so the right brand is picked), `--no-install`, `--no-mcp`, + `--no-agent-files`, `--no-test-email`, `--no-open`. The JSON report + follows `schemas/init-report.json` (`schemaVersion: 1`). Re-runs reuse + the idempotency keys `init:::`. After a + browser sign-in the run acts as the new app key, so the kit lands in the + brand that key is bound to. `lib/brew.ts` reads the key through + `$env/dynamic/private` in SvelteKit and `import.meta.env` in Astro; + Express, Hono and plain Node get a blocking step to load `.env.local`. + A test email that does not go out (say `SANDBOX_DAILY_LIMIT`) is a + non-blocking retry step, not a failed run. A domain `--domain` adds that + already publishes another DKIM key (`DKIM_SELECTOR_IN_USE`) gets a + `dkim-selector-in-use` step naming the suggested subdomain instead of + the DNS records step, and a kit key refused with + `APP_EMAIL_TRIGGER_IN_USE` gets a human `trigger-in-use:` review + step ahead of the re-run. The generated code and the + install command use `@brew.new/sdk@^13.6.0`, the planned release with + `appEmails` and the auth adapters (on top of 13.5.0's `send`). +- Not in this release (they need platform work first): the Supabase and + Clerk provider switch-over (Supabase Management API hook, Clerk + `toggle_delivery`; init prints them as human steps), `integrations + connect` (GetBrew/brew-v2#2169 and SDK `integrations.connect`), and brand + creation from `--app-url` (sign-in keys are bound to a brand picked on + the approval page). +- **Launchers** `brew.new` and `create-brew` (`packages/launchers/`): + each runs `@brew.new/cli` in-process, pinned to the same version, + inserts `init` when no subcommand is given, and sets + `BREW_CLI_INVOKED_AS` (`npx -y brew.new@`, `npm create + brew@latest --`). `bun run sync:launchers[:check]` keeps them in + lockstep; `bun run smoke:launchers` packs and runs them. The release + workflow publishes them after the CLI and skips them, with a warning, + while the npm names are not ours yet; they are not on npm today. +- The `brew-app-emails` agent skill (embedded; `init` installs it). +- `types` exports its contracts builder and trigger pager for `init`. + ## 0.19.0 Needs `@brew.new/sdk` 13.6.0: `brew.send` and diff --git a/README.md b/README.md index f7eed14..01597e8 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,56 @@ data on stdout and progress on stderr, and documents itself: brew-cli emails send --help ``` +## Set up Brew in your app: `init` + +```bash +npx @brew.new/cli init # or, installed: brew-cli init +``` + +Short launchers (`npx brew.new init`, `npm create brew`) live in +`packages/launchers/` and are not on npm yet. + +`init` reads the project (framework, auth library, package manager, +monorepo, editors), signs you in through the browser, and then: + +1. sets up and publishes the auth and lifecycle starter kit for your auth + library (`app-emails kit`); +2. installs `@brew.new/sdk` with your package manager; +3. writes `lib/brew.ts` (a lazy `getBrew()`), `lib/brew-auth.ts` (Better + Auth callbacks, the Auth.js provider, or welcome/auth helpers), + `lib/brew-contracts.ts` (payload types and the `brew.send` key + registry), a Supabase Send Email Hook function when you use Supabase + Auth, `.env.example` and `brew.json`; +4. puts the app key in `.env.local` (mode 0600, git-ignored, never under a + `NEXT_PUBLIC_`/`VITE_`/`PUBLIC_` name); +5. prints patches for your auth config; after review, the report's + `--apply-patches @` command applies each one only while its + file still matches the hash it was reviewed against (an Auth.js config + without a database adapter gets a manual step instead, since Auth.js + email sign-in needs one); +6. adds the Brew MCP server for the editors the project uses, an + `AGENTS.md` block, and (in Claude Code projects) the agent skills; +7. sends you a test email from the team sandbox. + +Every file is create-only: a file you changed is reported +`kept-user-version` and left alone, and a re-run with nothing new changes +nothing. Until a sending domain verifies, app emails reach members of +your Brew organization only; `--domain mail.example.com` adds one and +prints its DNS records without waiting. + +Agents run it in two steps and read one JSON report +(`schemas/init-report.json`: `status`, `steps[]`, `files[]`, `patches[]`, +`nextSteps[]` with `actor: agent | human`, `agentInstructions`): + +```bash +npx @brew.new/cli init --dry-run --json # the plan; no writes, no sign-in, no mutating requests +npx @brew.new/cli init --yes --json # exit 3: show the person nextSteps' link + code, then run its resume command +``` + +Exit codes: 0 `complete` or `action_required` (blocking `nextSteps` left), +1 `failed`, 2 usage or a monorepo root with several apps (pick one with +`--app apps/web`), 3 sign-in required, 4 confirmation required. + ## Send app email by key An app email is a trigger with a stable key (`auth.password-reset`, diff --git a/RELEASING.md b/RELEASING.md index 38beb50..ceed6b1 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -53,7 +53,8 @@ which is the #2169 @ `6f8ef50544` DX delta on main's spec (brew-v2 ```bash # on a clean main with CI green npm version --no-git-tag-version -git add package.json && git commit -m "Release vX.Y.Z" +bun run sync:launchers # brew.new + create-brew follow the CLI version +git add package.json packages/launchers && git commit -m "Release vX.Y.Z" git push origin main git tag vX.Y.Z git push origin vX.Y.Z # <-- triggers .github/workflows/release.yml @@ -69,7 +70,17 @@ The workflow then: darwin-arm64/x64, linux-x64/arm64, windows-x64 (`scripts/build-binaries.ts`) and attaches them + `checksums.txt` to an auto-generated GitHub Release. -5. Rewrites `Formula/brew-cli.rb` in `GetBrew/homebrew-tap` +5. Publishes the launchers `brew.new` (`npx brew.new init`) and + `create-brew` (`npm create brew`) from `packages/launchers/`, after the + sync check, a pack smoke and a registry smoke install + (`scripts/smoke-launchers.ts --registry`). Each is the CLI's version and + depends on exactly that `@brew.new/cli`. This step never fails the + release: the CLI is already out, so a version npm already has is skipped, + and a failed check or publish (for example a launcher name the token + does not own yet) leaves a warning on the run. Neither name is on npm + today; until they are reserved, docs and generated files point at + `npx @brew.new/cli`. +6. Rewrites `Formula/brew-cli.rb` in `GetBrew/homebrew-tap` (`scripts/update-tap.ts`; skipped with a notice when `TAP_GITHUB_TOKEN` is absent). @@ -90,6 +101,11 @@ brew install getbrew/tap/brew-cli ## One-time setup +- **The launcher names**: `brew.new` and `create-brew` must be owned on + npm by the account behind `NPM_TOKEN` (reserve them with a `0.0.x` + placeholder first), and the token must be allowed to publish unscoped + packages. Until then the launcher step warns and skips them. + - **`NPM_TOKEN`** (repo secret): npm Automation token with publish access to the `@brew.new` org — same recipe as [typescript-sdk/RELEASING.md](https://github.com/GetBrew/typescript-sdk/blob/main/RELEASING.md). diff --git a/docs/commands/README.md b/docs/commands/README.md index c2dbee0..3f7dcac 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -2,7 +2,7 @@ -155 commands. Classes: read (always safe), write +156 commands. Classes: read (always safe), write (mutating, retry-safe), destructive (irreversible — the confirmation protocol applies: interactive y/N on a TTY, exit 4 + JSON envelope with a `confirmCommand` otherwise, `--yes` to proceed). @@ -176,6 +176,7 @@ a `confirmCommand` otherwise, `--yes` to proceed). | `brew-cli docs` | read | — | Documentation pointers; --agent prints the command manifest | | `brew-cli docs api` | read | `GET /v1/help` | Fetch the live machine-readable API catalog (GET /v1/help) | | `brew-cli mcp install` | write | — | Add the Brew MCP server to your AI editors' config (Cursor, VS Code, Claude Code, Gemini CLI, Devin Desktop/Windsurf, Codex) | +| `brew-cli init` | destructive | — | Set up Brew app email in this project: sign in, starter kit, SDK, lib/brew.ts, .env.local, MCP, a sandbox test email | | `brew-cli api` | destructive | — | Raw authenticated request against the Brew public API | ## Details @@ -2819,6 +2820,38 @@ brew-cli mcp install --client all --dry-run --json brew-cli mcp install --client vscode --print ``` +### brew-cli init + +Set up Brew app email in this project: sign in, starter kit, SDK, lib/brew.ts, .env.local, MCP, a sandbox test email + +- Class: destructive +- Derived from `brew.brand.get(...)`, `brew.domains.list(...)`, `brew.domains.add(...)`, `brew.appEmails.setUpKit(...)`, `brew.automations.triggers.list(...)`, `brew.automations.triggers.getContract(...)`, `brew.send(...)` +- `--dry-run` — Plan only: detect, read, and report every change (--json for the plan); no writes, no sign-in, no mutating requests +- `--app ` — The app to set up inside a monorepo (relative to the cwd) +- `--domain ` — Add this sending domain and print its DNS records (init does not wait for verification) +- `--sets ` — Kit sets to set up: auth, lifecycle (default both) +- `--stack ` — Auth stack for the kit (default: detected): better-auth | authjs | supabase | clerk | firebase | custom +- `--test-to ` — Send the test email here (default: the person who signed in; the sandbox reaches your Brew team only) +- `--apply-patches ` — Apply reviewed patches to your auth config: comma-separated @ from the report (each applies only while its file is unchanged since that report) +- `--app-url ` — Your app's URL, shown on the brew.new approval page so the right brand is picked +- `--no-install` — Do not install @brew.new/sdk +- `--no-mcp` — Do not configure MCP for detected editors +- `--no-agent-files` — Do not write the AGENTS.md block or the skill files +- `--no-test-email` — Do not send the sandbox test email +- `--no-wait` — If a sign-in is needed, exit 3 at once with its link and code (agents), then run the resume command +- `--resume` — Collect the sign-in started with --no-wait, then continue +- `--no-open` — Print the sign-in link without opening a browser + +```bash +brew-cli init +brew-cli init --dry-run --json +brew-cli init --yes --json +brew-cli init --yes --no-wait --json +brew-cli init --resume --yes --json +brew-cli init --app apps/web --domain mail.example.com --yes +brew-cli init --yes --apply-patches better-auth-config@3f9c2a7d1b04 +``` + ### brew-cli api Raw authenticated request against the Brew public API diff --git a/package.json b/package.json index cf0575e..2efc9f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@brew.new/cli", - "version": "0.19.0", + "version": "0.20.0", "description": "Official agent-first command-line interface for the Brew public API.", "license": "MIT", "author": "Brew", @@ -55,14 +55,17 @@ "test": "vitest run", "test:watch": "vitest", "parity": "vitest run tests/parity-sdk.test.ts tests/parity-spec.test.ts", - "check": "bun run tsc && bun run lint && bun run test && bun run docs:commands:check && bun run generate:skills:check", + "check": "bun run tsc && bun run lint && bun run test && bun run docs:commands:check && bun run generate:skills:check && bun run sync:launchers:check", "docs:commands": "bun run scripts/generate-command-docs.ts", "docs:commands:check": "bun run scripts/generate-command-docs.ts --check", "generate:types": "node scripts/generate-types.mjs", "generate:skills": "bun run scripts/generate-skills.ts", "generate:skills:check": "bun run scripts/generate-skills.ts --check", "prepublishOnly": "bun run clean && bun run tsc && bun run lint && bun run test && bun run generate:skills:check && bun run build", - "smoke:signals": "bun run scripts/signal-smoke.ts" + "smoke:signals": "bun run scripts/signal-smoke.ts", + "sync:launchers": "bun run scripts/sync-launchers.ts", + "sync:launchers:check": "bun run scripts/sync-launchers.ts --check", + "smoke:launchers": "bun run scripts/smoke-launchers.ts" }, "dependencies": { "@brew.new/sdk": "^13.3.0", diff --git a/packages/launchers/brew.new/LICENSE b/packages/launchers/brew.new/LICENSE new file mode 100644 index 0000000..50eceb6 --- /dev/null +++ b/packages/launchers/brew.new/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Brew + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/launchers/brew.new/README.md b/packages/launchers/brew.new/README.md new file mode 100644 index 0000000..0f32649 --- /dev/null +++ b/packages/launchers/brew.new/README.md @@ -0,0 +1,24 @@ +# brew.new + +Set up [Brew](https://brew.new) email in your app with one command: + +```bash +npx brew.new init +``` + +`init` finds your framework and auth library, signs you in through the +browser, sets up the auth and lifecycle starter emails, installs +`@brew.new/sdk`, writes `lib/brew.ts` (and the helpers for Better Auth, +Auth.js or Supabase), puts the app key in `.env.local`, connects your AI +editor to the Brew MCP server, and sends you a test email. It never +overwrites a file you changed. + +```bash +npx brew.new init --dry-run --json # the plan, nothing changed +npx brew.new init --yes --json # for agents: exit 3 means approve the sign-in, then run the resume command +``` + +Every other command works too: `npx brew.new send lifecycle.welcome --to you@example.com --yes`. + +This package is a launcher for [`@brew.new/cli`](https://www.npmjs.com/package/@brew.new/cli) +(pinned to the same version). Docs: https://docs.brew.new diff --git a/packages/launchers/brew.new/bin.mjs b/packages/launchers/brew.new/bin.mjs new file mode 100755 index 0000000..c002da9 --- /dev/null +++ b/packages/launchers/brew.new/bin.mjs @@ -0,0 +1,8 @@ +#!/usr/bin/env node +import { readFileSync } from 'node:fs' +import { launch } from './launcher.mjs' + +const { version } = JSON.parse( + readFileSync(new URL('./package.json', import.meta.url), 'utf8') +) +await launch({ packageName: 'brew.new', version }) diff --git a/packages/launchers/brew.new/launcher.mjs b/packages/launchers/brew.new/launcher.mjs new file mode 100644 index 0000000..30204f4 --- /dev/null +++ b/packages/launchers/brew.new/launcher.mjs @@ -0,0 +1,90 @@ +// The `brew.new` and `create-brew` launchers: `npx brew.new init` and +// `npm create brew` run `@brew.new/cli` in this same process. Edit this file +// (packages/launchers/launcher.mjs), then run `bun run sync:launchers`, +// which copies it into each package. + +/** Flags whose next token is their value, never a subcommand. */ +const VALUE_FLAGS = new Set([ + '--api-key', + '--brand', + '--api-url', + '--timeout', + '--max-retries', + '--app', + '--domain', + '--sets', + '--stack', + '--test-to', +]) + +const VERSION_FLAGS = new Set(['--version', '-V']) +const HELP_FLAGS = new Set(['--help', '-h']) + +/** Whether the arguments name a subcommand (`send`, `init`, `login`, …). */ +export function hasSubcommand(args) { + for (let index = 0; index < args.length; index += 1) { + const token = args[index] + if (token === '--') { + return index + 1 < args.length + } + if (!token.startsWith('-')) { + return true + } + if (VALUE_FLAGS.has(token)) { + index += 1 + } + } + return false +} + +/** + * The CLI arguments a launcher runs: `init` when none names a subcommand. + * A bare `--version` reaches the CLI as is; so does a bare `--help` from + * `brew.new` (the CLI's help), while `create-brew` shows init's. + */ +export function launcherArgv(args, options = {}) { + if (hasSubcommand(args)) { + return [...args] + } + if (args.some((token) => VERSION_FLAGS.has(token))) { + return [...args] + } + const isBareHelp = + args.length > 0 && args.every((token) => HELP_FLAGS.has(token)) + if (isBareHelp && options.alwaysInit !== true) { + return [...args] + } + return ['init', ...args] +} + +/** + * How the person ran the launcher, as the CLI prints it in every command it + * suggests next (`BREW_CLI_INVOKED_AS`): pinned to this launcher's version + * for both (`npx -y brew.new@`, `npm create brew@ --`), so + * a resume or confirm command runs the same CLI that printed it. + */ +export function invokedAs(packageName, version) { + return packageName === 'create-brew' + ? `npm create brew@${version} --` + : `npx -y ${packageName}@${version}` +} + +/** + * Rewrites `argv` in place (the CLI reads `process.argv` when it loads), + * names the launcher in `BREW_CLI_INVOKED_AS` unless a caller already did, + * then loads the CLI, which runs and sets the exit code. + */ +export async function launch(input) { + const argv = input.argv ?? process.argv + const env = input.env ?? process.env + if (env.BREW_CLI_INVOKED_AS === undefined || env.BREW_CLI_INVOKED_AS === '') { + env.BREW_CLI_INVOKED_AS = invokedAs(input.packageName, input.version) + } + const args = launcherArgv(argv.slice(2), { + alwaysInit: input.packageName === 'create-brew', + }) + argv.splice(2, argv.length - 2, ...args) + const importCli = + input.importCli ?? (() => import('@brew.new/cli/dist/bin.js')) + await importCli() +} diff --git a/packages/launchers/brew.new/package.json b/packages/launchers/brew.new/package.json new file mode 100644 index 0000000..4216f0a --- /dev/null +++ b/packages/launchers/brew.new/package.json @@ -0,0 +1,49 @@ +{ + "name": "brew.new", + "version": "0.20.0", + "description": "Set up Brew email in your app with one command: npx brew.new init (auth and lifecycle email, the SDK, MCP for your editor).", + "license": "MIT", + "author": "Brew", + "homepage": "https://brew.new/developers", + "repository": { + "type": "git", + "url": "git+https://github.com/GetBrew/brew-cli.git", + "directory": "packages/launchers/brew.new" + }, + "bugs": { + "url": "https://github.com/GetBrew/brew-cli/issues" + }, + "keywords": [ + "brew", + "brew.new", + "email", + "init", + "transactional-email", + "auth-emails", + "better-auth", + "next-auth", + "supabase", + "resend-alternative", + "mcp", + "cli" + ], + "type": "module", + "bin": { + "brew.new": "bin.mjs" + }, + "files": [ + "bin.mjs", + "launcher.mjs", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=20" + }, + "dependencies": { + "@brew.new/cli": "0.20.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/launchers/create-brew/LICENSE b/packages/launchers/create-brew/LICENSE new file mode 100644 index 0000000..50eceb6 --- /dev/null +++ b/packages/launchers/create-brew/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Brew + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/launchers/create-brew/README.md b/packages/launchers/create-brew/README.md new file mode 100644 index 0000000..8a30509 --- /dev/null +++ b/packages/launchers/create-brew/README.md @@ -0,0 +1,17 @@ +# create-brew + +Set up [Brew](https://brew.new) email in your app: + +```bash +npm create brew@latest +# pnpm create brew · yarn create brew · bun create brew +``` + +It runs `init` from [`@brew.new/cli`](https://www.npmjs.com/package/@brew.new/cli) +(pinned to the same version): detect your framework and auth library, sign +in through the browser, set up the auth and lifecycle starter emails, +install `@brew.new/sdk`, write `lib/brew.ts`, put the app key in +`.env.local`, connect your AI editor, and send you a test email. Pass +flags after `--`: `npm create brew@latest -- --dry-run --json`. + +The same launcher as `npx brew.new init`. Docs: https://docs.brew.new diff --git a/packages/launchers/create-brew/bin.mjs b/packages/launchers/create-brew/bin.mjs new file mode 100755 index 0000000..5dc9dd1 --- /dev/null +++ b/packages/launchers/create-brew/bin.mjs @@ -0,0 +1,8 @@ +#!/usr/bin/env node +import { readFileSync } from 'node:fs' +import { launch } from './launcher.mjs' + +const { version } = JSON.parse( + readFileSync(new URL('./package.json', import.meta.url), 'utf8') +) +await launch({ packageName: 'create-brew', version }) diff --git a/packages/launchers/create-brew/launcher.mjs b/packages/launchers/create-brew/launcher.mjs new file mode 100644 index 0000000..30204f4 --- /dev/null +++ b/packages/launchers/create-brew/launcher.mjs @@ -0,0 +1,90 @@ +// The `brew.new` and `create-brew` launchers: `npx brew.new init` and +// `npm create brew` run `@brew.new/cli` in this same process. Edit this file +// (packages/launchers/launcher.mjs), then run `bun run sync:launchers`, +// which copies it into each package. + +/** Flags whose next token is their value, never a subcommand. */ +const VALUE_FLAGS = new Set([ + '--api-key', + '--brand', + '--api-url', + '--timeout', + '--max-retries', + '--app', + '--domain', + '--sets', + '--stack', + '--test-to', +]) + +const VERSION_FLAGS = new Set(['--version', '-V']) +const HELP_FLAGS = new Set(['--help', '-h']) + +/** Whether the arguments name a subcommand (`send`, `init`, `login`, …). */ +export function hasSubcommand(args) { + for (let index = 0; index < args.length; index += 1) { + const token = args[index] + if (token === '--') { + return index + 1 < args.length + } + if (!token.startsWith('-')) { + return true + } + if (VALUE_FLAGS.has(token)) { + index += 1 + } + } + return false +} + +/** + * The CLI arguments a launcher runs: `init` when none names a subcommand. + * A bare `--version` reaches the CLI as is; so does a bare `--help` from + * `brew.new` (the CLI's help), while `create-brew` shows init's. + */ +export function launcherArgv(args, options = {}) { + if (hasSubcommand(args)) { + return [...args] + } + if (args.some((token) => VERSION_FLAGS.has(token))) { + return [...args] + } + const isBareHelp = + args.length > 0 && args.every((token) => HELP_FLAGS.has(token)) + if (isBareHelp && options.alwaysInit !== true) { + return [...args] + } + return ['init', ...args] +} + +/** + * How the person ran the launcher, as the CLI prints it in every command it + * suggests next (`BREW_CLI_INVOKED_AS`): pinned to this launcher's version + * for both (`npx -y brew.new@`, `npm create brew@ --`), so + * a resume or confirm command runs the same CLI that printed it. + */ +export function invokedAs(packageName, version) { + return packageName === 'create-brew' + ? `npm create brew@${version} --` + : `npx -y ${packageName}@${version}` +} + +/** + * Rewrites `argv` in place (the CLI reads `process.argv` when it loads), + * names the launcher in `BREW_CLI_INVOKED_AS` unless a caller already did, + * then loads the CLI, which runs and sets the exit code. + */ +export async function launch(input) { + const argv = input.argv ?? process.argv + const env = input.env ?? process.env + if (env.BREW_CLI_INVOKED_AS === undefined || env.BREW_CLI_INVOKED_AS === '') { + env.BREW_CLI_INVOKED_AS = invokedAs(input.packageName, input.version) + } + const args = launcherArgv(argv.slice(2), { + alwaysInit: input.packageName === 'create-brew', + }) + argv.splice(2, argv.length - 2, ...args) + const importCli = + input.importCli ?? (() => import('@brew.new/cli/dist/bin.js')) + await importCli() +} diff --git a/packages/launchers/create-brew/package.json b/packages/launchers/create-brew/package.json new file mode 100644 index 0000000..7a3712f --- /dev/null +++ b/packages/launchers/create-brew/package.json @@ -0,0 +1,49 @@ +{ + "name": "create-brew", + "version": "0.20.0", + "description": "npm create brew: set up Brew email in your app (auth and lifecycle email, the SDK, MCP for your editor).", + "license": "MIT", + "author": "Brew", + "homepage": "https://brew.new/developers", + "repository": { + "type": "git", + "url": "git+https://github.com/GetBrew/brew-cli.git", + "directory": "packages/launchers/create-brew" + }, + "bugs": { + "url": "https://github.com/GetBrew/brew-cli/issues" + }, + "keywords": [ + "brew", + "brew.new", + "email", + "init", + "transactional-email", + "auth-emails", + "better-auth", + "next-auth", + "supabase", + "resend-alternative", + "mcp", + "cli" + ], + "type": "module", + "bin": { + "create-brew": "bin.mjs" + }, + "files": [ + "bin.mjs", + "launcher.mjs", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=20" + }, + "dependencies": { + "@brew.new/cli": "0.20.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/launchers/launcher.d.mts b/packages/launchers/launcher.d.mts new file mode 100644 index 0000000..4190fcf --- /dev/null +++ b/packages/launchers/launcher.d.mts @@ -0,0 +1,14 @@ +/** Types for `launcher.mjs`, for the repo's tests (not published). */ +export declare function hasSubcommand(args: readonly string[]): boolean +export declare function launcherArgv( + args: readonly string[], + options?: { readonly alwaysInit?: boolean } +): string[] +export declare function invokedAs(packageName: string, version: string): string +export declare function launch(input: { + readonly packageName: 'brew.new' | 'create-brew' + readonly version: string + readonly argv?: string[] + readonly env?: Record + readonly importCli?: () => Promise +}): Promise diff --git a/packages/launchers/launcher.mjs b/packages/launchers/launcher.mjs new file mode 100644 index 0000000..30204f4 --- /dev/null +++ b/packages/launchers/launcher.mjs @@ -0,0 +1,90 @@ +// The `brew.new` and `create-brew` launchers: `npx brew.new init` and +// `npm create brew` run `@brew.new/cli` in this same process. Edit this file +// (packages/launchers/launcher.mjs), then run `bun run sync:launchers`, +// which copies it into each package. + +/** Flags whose next token is their value, never a subcommand. */ +const VALUE_FLAGS = new Set([ + '--api-key', + '--brand', + '--api-url', + '--timeout', + '--max-retries', + '--app', + '--domain', + '--sets', + '--stack', + '--test-to', +]) + +const VERSION_FLAGS = new Set(['--version', '-V']) +const HELP_FLAGS = new Set(['--help', '-h']) + +/** Whether the arguments name a subcommand (`send`, `init`, `login`, …). */ +export function hasSubcommand(args) { + for (let index = 0; index < args.length; index += 1) { + const token = args[index] + if (token === '--') { + return index + 1 < args.length + } + if (!token.startsWith('-')) { + return true + } + if (VALUE_FLAGS.has(token)) { + index += 1 + } + } + return false +} + +/** + * The CLI arguments a launcher runs: `init` when none names a subcommand. + * A bare `--version` reaches the CLI as is; so does a bare `--help` from + * `brew.new` (the CLI's help), while `create-brew` shows init's. + */ +export function launcherArgv(args, options = {}) { + if (hasSubcommand(args)) { + return [...args] + } + if (args.some((token) => VERSION_FLAGS.has(token))) { + return [...args] + } + const isBareHelp = + args.length > 0 && args.every((token) => HELP_FLAGS.has(token)) + if (isBareHelp && options.alwaysInit !== true) { + return [...args] + } + return ['init', ...args] +} + +/** + * How the person ran the launcher, as the CLI prints it in every command it + * suggests next (`BREW_CLI_INVOKED_AS`): pinned to this launcher's version + * for both (`npx -y brew.new@`, `npm create brew@ --`), so + * a resume or confirm command runs the same CLI that printed it. + */ +export function invokedAs(packageName, version) { + return packageName === 'create-brew' + ? `npm create brew@${version} --` + : `npx -y ${packageName}@${version}` +} + +/** + * Rewrites `argv` in place (the CLI reads `process.argv` when it loads), + * names the launcher in `BREW_CLI_INVOKED_AS` unless a caller already did, + * then loads the CLI, which runs and sets the exit code. + */ +export async function launch(input) { + const argv = input.argv ?? process.argv + const env = input.env ?? process.env + if (env.BREW_CLI_INVOKED_AS === undefined || env.BREW_CLI_INVOKED_AS === '') { + env.BREW_CLI_INVOKED_AS = invokedAs(input.packageName, input.version) + } + const args = launcherArgv(argv.slice(2), { + alwaysInit: input.packageName === 'create-brew', + }) + argv.splice(2, argv.length - 2, ...args) + const importCli = + input.importCli ?? (() => import('@brew.new/cli/dist/bin.js')) + await importCli() +} diff --git a/schemas/init-report.json b/schemas/init-report.json new file mode 100644 index 0000000..ce8a3bf --- /dev/null +++ b/schemas/init-report.json @@ -0,0 +1,373 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://unpkg.com/@brew.new/cli/schemas/init-report.json", + "title": "brew-cli init report", + "description": "What `brew-cli init --json` prints. schemaVersion 1. Never carries a secret: keys are masked.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "status", + "dryRun", + "project", + "brand", + "credential", + "domain", + "stack", + "emails", + "files", + "patches", + "mcp", + "testEmail", + "steps", + "nextSteps", + "agentInstructions" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "status": { + "enum": [ + "planned", + "complete", + "action_required", + "auth_required", + "ambiguous_project", + "failed" + ] + }, + "dryRun": { "type": "boolean" }, + "project": { + "type": "object", + "required": [ + "root", + "name", + "projectId", + "packageManager", + "monorepo", + "framework", + "runtime", + "auth", + "authVersion", + "authDetected", + "billing", + "existingEmail", + "builder", + "typescript", + "editors", + "remote" + ], + "properties": { + "root": { "type": "string" }, + "name": { "type": "string" }, + "projectId": { "type": ["string", "null"] }, + "packageManager": { + "enum": ["npm", "pnpm", "yarn", "bun", "deno", null] + }, + "monorepo": { + "type": ["object", "null"], + "required": ["tool", "root", "workspace"], + "properties": { + "tool": { "type": "string" }, + "root": { "type": "string" }, + "workspace": { "type": ["string", "null"] } + } + }, + "candidates": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "dir", "framework"], + "properties": { + "name": { "type": "string" }, + "dir": { "type": "string" }, + "framework": { "type": "string" } + } + } + }, + "framework": { + "enum": [ + "next-app", + "next-pages", + "vite-react", + "react-router", + "remix", + "sveltekit", + "astro", + "express", + "hono", + "supabase-functions", + "node", + null + ] + }, + "runtime": { "enum": ["node", "workers", "deno", "browser", null] }, + "auth": { + "enum": [ + "better-auth", + "authjs", + "supabase", + "clerk", + "firebase", + "workos", + "stytch", + "none", + null + ] + }, + "authVersion": { "enum": ["v4", "v5", null] }, + "authDetected": { "type": "array", "items": { "type": "string" } }, + "billing": { "type": "array", "items": { "type": "string" } }, + "existingEmail": { + "type": "array", + "items": { + "type": "object", + "required": ["provider", "source"], + "properties": { + "provider": { "type": "string" }, + "source": { "enum": ["dependency", "directory"] }, + "path": { "type": "string" } + } + } + }, + "builder": { "type": ["string", "null"] }, + "typescript": { "type": "boolean" }, + "editors": { "type": "array", "items": { "type": "string" } }, + "remote": { "type": ["string", "null"] } + } + }, + "brand": { + "type": ["object", "null"], + "required": ["brandId", "name"], + "properties": { + "brandId": { "type": "string" }, + "name": { "type": ["string", "null"] } + } + }, + "credential": { + "type": "object", + "required": ["source", "apiKey", "appKey"], + "properties": { + "source": { + "enum": ["flag", "env", "config", "env-file", "browser", "none"] + }, + "apiKey": { "type": ["string", "null"] }, + "appKey": { + "type": "object", + "required": ["variable", "file", "status", "masked"], + "properties": { + "variable": { "const": "BREW_API_KEY" }, + "file": { "const": ".env.local" }, + "status": { "enum": ["existing", "written", "planned", "missing"] }, + "masked": { "type": ["string", "null"] } + } + } + } + }, + "domain": { + "type": "object", + "required": [ + "mode", + "domainId", + "name", + "status", + "added", + "records", + "settingsUrl", + "sandbox" + ], + "properties": { + "mode": { "enum": ["domain", "sandbox", "unknown"] }, + "domainId": { "type": ["string", "null"] }, + "name": { "type": ["string", "null"] }, + "status": { "type": ["string", "null"] }, + "added": { "type": "boolean" }, + "records": { + "type": "array", + "items": { + "type": "object", + "required": ["type", "name", "value"], + "properties": { + "type": { "type": "string" }, + "name": { "type": "string" }, + "value": { "type": "string" }, + "priority": { "type": "number" } + } + } + }, + "settingsUrl": { "type": "string" }, + "sandbox": { + "type": ["object", "null"], + "required": ["note"], + "properties": { "note": { "type": "string" } } + } + } + }, + "stack": { + "type": ["object", "null"], + "required": ["provider", "authDelivery", "note"], + "properties": { + "provider": { "type": "string" }, + "authDelivery": { "type": ["string", "null"] }, + "note": { "type": ["string", "null"] } + } + }, + "emails": { + "type": "array", + "items": { + "type": "object", + "required": ["key", "ok"], + "properties": { + "key": { "type": "string" }, + "ok": { "type": "boolean" }, + "triggerEventId": { "type": "string" }, + "automationId": { "type": "string" }, + "published": { "type": "boolean" }, + "sender": { "enum": ["domain", "sandbox"] }, + "created": { "type": "boolean" }, + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { "type": "string" }, + "message": { "type": "string" } + } + } + } + } + }, + "files": { + "type": "array", + "items": { + "type": "object", + "required": ["path", "action", "role"], + "properties": { + "path": { "type": "string" }, + "action": { + "enum": [ + "created", + "updated", + "unchanged", + "kept-user-version", + "missing", + "refused", + "planned" + ] + }, + "role": { "type": "string" }, + "note": { "type": "string" } + } + } + }, + "patches": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "path", "title", "description", "status"], + "properties": { + "id": { "type": "string" }, + "path": { "type": "string" }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "status": { + "enum": ["pending", "applied", "already-applied", "manual", "stale"] + }, + "baseSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "diff": { "type": "string" }, + "snippet": { "type": "string" } + } + } + }, + "mcp": { + "type": "array", + "items": { + "type": "object", + "required": ["client", "action"], + "properties": { + "client": { "type": "string" }, + "path": { "type": "string" }, + "action": { + "enum": ["created", "updated", "unchanged", "manual", "printed"] + }, + "deeplink": { "type": "string" }, + "snippet": { "type": "string" }, + "note": { "type": "string" }, + "nextStep": { "type": "string" } + } + } + }, + "testEmail": { + "type": "object", + "required": ["status", "key", "to"], + "properties": { + "status": { + "enum": ["sent", "replayed", "skipped", "planned", "failed"] + }, + "key": { "type": ["string", "null"] }, + "to": { "type": ["string", "null"] }, + "triggerInstanceId": { "type": "string" }, + "reason": { "type": "string" } + } + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "status"], + "properties": { + "id": { + "enum": [ + "detect", + "sign-in", + "env", + "brand", + "domain", + "kit", + "install", + "files", + "patches", + "mcp", + "agent-files", + "state", + "test-email" + ] + }, + "status": { + "enum": ["done", "skipped", "planned", "pending", "failed"] + }, + "detail": { "type": "string" } + } + } + }, + "nextSteps": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "actor", "kind", "title", "blocking"], + "properties": { + "id": { "type": "string" }, + "actor": { "enum": ["agent", "human"] }, + "kind": { + "enum": [ + "run-command", + "open-url", + "apply-patch", + "edit-file", + "set-secret", + "add-dns-records", + "review" + ] + }, + "title": { "type": "string" }, + "blocking": { "type": "boolean" }, + "command": { "type": "string" }, + "url": { "type": "string" }, + "path": { "type": "string" }, + "patchId": { "type": "string" } + } + } + }, + "agentInstructions": { "type": "string" } + } +} diff --git a/scripts/smoke-launchers.ts b/scripts/smoke-launchers.ts new file mode 100644 index 0000000..dd4ca29 --- /dev/null +++ b/scripts/smoke-launchers.ts @@ -0,0 +1,203 @@ +import { execFileSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { LAUNCHER_PACKAGES, LAUNCHERS_DIR } from './sync-launchers' + +/** + * Packs the CLI and both launchers the way npm publishes them, installs + * them into a scratch project, and runs each launcher's `--version` and + * `--help`. Needs `bun run build` first (the CLI tarball ships `dist/`). + * + * bun run smoke:launchers — offline: the packed CLI, with its + * dependencies linked from this repo + * bun run smoke:launchers --registry — `npm install` the launcher tarballs, + * resolving `@brew.new/cli` from npm + * (release, after the CLI publishes) + */ + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +const EXPECTED_FILES = [ + 'package/LICENSE', + 'package/README.md', + 'package/bin.mjs', + 'package/launcher.mjs', + 'package/package.json', +] + +function run( + command: string, + args: readonly string[], + cwd: string, + env?: Readonly> +): string { + return execFileSync(command, [...args], { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + ...(env === undefined ? {} : { env: { ...process.env, ...env } }), + }) +} + +/** `npm pack` into `destination`; returns the tarball path. */ +function pack(dir: string, destination: string): string { + const output = run( + 'npm', + ['pack', '--ignore-scripts', '--json', '--pack-destination', destination], + dir + ) + const [packed] = JSON.parse(output) as Array<{ filename: string }> + if (packed === undefined) { + throw new Error(`npm pack produced nothing in ${dir}`) + } + return join(destination, packed.filename) +} + +function extract(tarball: string, into: string): void { + mkdirSync(into, { recursive: true }) + run('tar', ['-xzf', tarball, '-C', into, '--strip-components=1'], into) +} + +function listTarball(tarball: string): string[] { + return run('tar', ['-tzf', tarball], dirname(tarball)) + .split('\n') + .filter((line) => line !== '') + .sort() +} + +function link(target: string, path: string): void { + mkdirSync(dirname(path), { recursive: true }) + symlinkSync(realpathSync(target), path, 'dir') +} + +function sleep(ms: number): Promise { + return new Promise((done) => { + setTimeout(done, ms) + }) +} + +async function main(): Promise { + const isRegistry = process.argv.includes('--registry') + if (!existsSync(join(REPO_ROOT, 'dist/bin.js'))) { + throw new Error('dist/bin.js is missing: run `bun run build` first.') + } + const { version } = JSON.parse( + readFileSync(join(REPO_ROOT, 'package.json'), 'utf8') + ) as { version: string } + const scratch = realpathSync(mkdtempSync(join(tmpdir(), 'brew-launchers-'))) + try { + const tarballs = join(scratch, 'tarballs') + mkdirSync(tarballs) + const project = join(scratch, 'project') + mkdirSync(project) + writeFileSync( + join(project, 'package.json'), + '{"name":"launcher-smoke","private":true}\n' + ) + const modules = join(project, 'node_modules') + const launchers = LAUNCHER_PACKAGES.map((name) => { + const tarball = pack(join(REPO_ROOT, LAUNCHERS_DIR, name), tarballs) + const files = listTarball(tarball) + if (JSON.stringify(files) !== JSON.stringify(EXPECTED_FILES)) { + throw new Error( + `${name} packs ${files.join(', ')}; expected ${EXPECTED_FILES.join(', ')}` + ) + } + return { name, tarball } + }) + if (isRegistry) { + // The CLI was just published: npm may need a moment to serve it. + for (let attempt = 1; ; attempt += 1) { + try { + run( + 'npm', + [ + 'install', + '--no-audit', + '--no-fund', + ...launchers.map((entry) => entry.tarball), + ], + project + ) + break + } catch (error) { + if (attempt === 6) { + throw error + } + console.log(`npm install failed (attempt ${attempt}); retrying…`) + await sleep(10_000) + } + } + } else { + const cli = pack(REPO_ROOT, tarballs) + extract(cli, join(modules, '@brew.new/cli')) + for (const dependency of ['@brew.new/sdk', 'commander']) { + link( + join(REPO_ROOT, 'node_modules', dependency), + join(modules, dependency) + ) + } + for (const entry of launchers) { + extract(entry.tarball, join(modules, entry.name)) + } + } + // Node, as `npx` runs the launchers (`bun run` would make + // process.execPath bun and hide Node's module loading). + const configDir = join(scratch, 'config') + for (const entry of launchers) { + const bin = join(modules, entry.name, 'bin.mjs') + const printed = run('node', [bin, '--version'], project).trim() + if (printed !== version) { + throw new Error( + `${entry.name} --version printed "${printed}", not ${version}` + ) + } + const help = run('node', [bin, '--help'], project) + const expected = + entry.name === 'create-brew' + ? 'Usage: brew-cli init' + : 'Usage: brew-cli' + if (!help.includes(expected)) { + throw new Error(`${entry.name} --help did not print "${expected}"`) + } + // argv: a bare launcher runs `init`; with no key a dry run plans the + // sign-in and sends nothing. + const args = + entry.name === 'create-brew' + ? ['--dry-run', '--json'] + : ['init', '--dry-run', '--json'] + const plan = JSON.parse( + run('node', [bin, ...args], project, { + BREW_CLI_CONFIG_DIR: configDir, + BREW_API_KEY: '', + }) + ) as { status?: string } + if (plan.status !== 'planned') { + throw new Error( + `${entry.name} ${args.join(' ')} reported status ${String(plan.status)}, not planned` + ) + } + console.log( + `${entry.name}@${version}: --version, --help and init --dry-run ok` + ) + } + } finally { + rmSync(scratch, { recursive: true, force: true }) + } +} + +const entry = process.argv[1] +if (entry !== undefined && resolve(entry) === fileURLToPath(import.meta.url)) { + await main() +} diff --git a/scripts/sync-launchers.ts b/scripts/sync-launchers.ts new file mode 100644 index 0000000..4723725 --- /dev/null +++ b/scripts/sync-launchers.ts @@ -0,0 +1,148 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { isDeepStrictEqual } from 'node:util' + +/** + * Keeps the launcher packages (`packages/launchers/{brew.new,create-brew}`) + * in lockstep with the CLI: each one's version IS the CLI's, it depends on + * exactly that `@brew.new/cli` version, and it carries a copy of the shared + * `launcher.mjs` and the repo LICENSE. + * bun run sync:launchers — write them + * bun run sync:launchers:check — fail when anything drifted (CI, release) + */ + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +export const LAUNCHERS_DIR = 'packages/launchers' +export const LAUNCHER_PACKAGES = ['brew.new', 'create-brew'] as const +export type LauncherName = (typeof LAUNCHER_PACKAGES)[number] + +const DESCRIPTIONS: Readonly> = { + 'brew.new': + 'Set up Brew email in your app with one command: npx brew.new init (auth and lifecycle email, the SDK, MCP for your editor).', + 'create-brew': + 'npm create brew: set up Brew email in your app (auth and lifecycle email, the SDK, MCP for your editor).', +} + +/** The package.json each launcher must have, for a CLI version. */ +export function launcherManifest( + name: LauncherName, + version: string +): Record { + return { + name, + version, + description: DESCRIPTIONS[name], + license: 'MIT', + author: 'Brew', + homepage: 'https://brew.new/developers', + repository: { + type: 'git', + url: 'git+https://github.com/GetBrew/brew-cli.git', + directory: `${LAUNCHERS_DIR}/${name}`, + }, + bugs: { url: 'https://github.com/GetBrew/brew-cli/issues' }, + keywords: [ + 'brew', + 'brew.new', + 'email', + 'init', + 'transactional-email', + 'auth-emails', + 'better-auth', + 'next-auth', + 'supabase', + 'resend-alternative', + 'mcp', + 'cli', + ], + type: 'module', + // npm 11 strips a bin path prefixed with `./` during publish packing. + bin: { [name]: 'bin.mjs' }, + files: ['bin.mjs', 'launcher.mjs', 'README.md', 'LICENSE'], + engines: { node: '>=20' }, + // Exact: a launcher version always runs the CLI of the same version. + dependencies: { '@brew.new/cli': version }, + publishConfig: { access: 'public' }, + } +} + +type Drift = { readonly path: string; readonly fix: () => void } + +function readText(path: string): string | null { + try { + return readFileSync(path, 'utf8') + } catch { + return null + } +} + +/** Every launcher file that differs from what the CLI version implies. */ +export function launcherDrift(root: string = REPO_ROOT): Drift[] { + const { version } = JSON.parse( + readFileSync(join(root, 'package.json'), 'utf8') + ) as { version: string } + const launcher = readFileSync( + join(root, LAUNCHERS_DIR, 'launcher.mjs'), + 'utf8' + ) + const license = readFileSync(join(root, 'LICENSE'), 'utf8') + const drift: Drift[] = [] + for (const name of LAUNCHER_PACKAGES) { + const dir = join(root, LAUNCHERS_DIR, name) + for (const [file, content] of [ + ['launcher.mjs', launcher], + ['LICENSE', license], + ] as const) { + const path = join(dir, file) + if (readText(path) !== content) { + drift.push({ path, fix: () => writeFileSync(path, content) }) + } + } + const manifestPath = join(dir, 'package.json') + const expected = launcherManifest(name, version) + const current = readText(manifestPath) + let parsed: unknown + try { + parsed = current === null ? null : JSON.parse(current) + } catch { + parsed = null + } + // Compared as data: formatting is the formatter's business. + if (!isDeepStrictEqual(parsed, expected)) { + drift.push({ + path: manifestPath, + fix: () => + writeFileSync(manifestPath, `${JSON.stringify(expected, null, 2)}\n`), + }) + } + } + return drift +} + +function main(): void { + const drift = launcherDrift() + if (process.argv.includes('--check')) { + if (drift.length > 0) { + for (const entry of drift) { + console.error(`${entry.path} is out of date.`) + } + console.error('Run `bun run sync:launchers` and commit.') + process.exit(1) + } + console.log('Launchers match the CLI version.') + return + } + for (const entry of drift) { + entry.fix() + console.log(`Wrote ${entry.path}`) + } + if (drift.length === 0) { + console.log('Launchers already match the CLI version.') + } +} + +const entry = process.argv[1] +if (entry !== undefined && resolve(entry) === fileURLToPath(import.meta.url)) { + main() +} diff --git a/skills/brew-app-emails/SKILL.md b/skills/brew-app-emails/SKILL.md new file mode 100644 index 0000000..8254fbd --- /dev/null +++ b/skills/brew-app-emails/SKILL.md @@ -0,0 +1,72 @@ +--- +name: brew-app-emails +description: Send and change this app's emails through Brew, covering auth emails (verify, magic link, one-time code, password reset), lifecycle emails (welcome, trial ending, payment failed) and your own keyed emails. Use when code sends an email, an auth library needs an email callback, or a send fails with TRIGGER_KEY_NOT_FOUND, DOMAIN_REQUIRED or SANDBOX_DAILY_LIMIT. +--- + +# App emails with Brew + +Every email this app sends has a **trigger key** (`auth.password-reset`, +`lifecycle.welcome`, `order.paid`). Brew holds the design and the +automation behind the key; code only sends the key and a payload: + +```ts +import { getBrew } from './lib/brew' // written by `npx @brew.new/cli init` + +await getBrew().send( + 'auth.password-reset', + { email: user.email, userName: user.name, confirmationUrl: resetUrl }, + { idempotencyKey: `auth.password-reset:${tokenId}` } // a retry never sends twice +) +``` + +`brew.json` (committed) lists the keys this project set up; +`lib/brew-contracts.ts` types each key's payload and makes an unknown key a +compile error. Regenerate it after changing a payload contract: +`npx @brew.new/cli types --out lib/brew-contracts.ts`. + +## Rules + +- Send from server code only. `BREW_API_KEY` lives in `.env.local` + (git-ignored); never give it a `NEXT_PUBLIC_`, `VITE_` or `PUBLIC_` name, + never print it, never commit it. +- Derive `idempotencyKey` from what makes the email unique (the token id, + the order id, the user id), never from the token itself. +- Fields like `confirmationUrl` and `otp` are template-only secrets: Brew + renders them and never stores them on the contact. + +## Auth libraries + +`lib/brew-auth.ts` wires the one this app uses: + +- Better Auth: `brewEmails` from `@brew.new/sdk/better-auth` goes into + `betterAuth({ emailVerification, emailAndPassword, databaseHooks })`. +- Auth.js: `brewEmailProvider` (`@brew.new/sdk/next-auth`) goes into + `providers`; email sign-in needs a database adapter. +- Supabase: the Send Email Hook in `supabase/functions/send-email` sends + every auth email; deploy it and point Authentication > Hooks at it. +- Clerk, Firebase, WorkOS, Stytch: the provider keeps its auth emails; + Brew sends lifecycle emails (`sendWelcomeEmail`). + +## A new email + +```bash +npx @brew.new/cli app-emails upsert order.paid --source-file emails/order-paid.tsx --subject "Order confirmed" --yes +npx @brew.new/cli types --out lib/brew-contracts.ts +npx @brew.new/cli send order.paid --to you@example.com --data '{"orderId":"ord_1"}' --yes +``` + +A design edited in Brew is never replaced: `upsert` answers +`409 APP_EMAIL_EDITED_IN_BREW` until you pass `--overwrite`. + +## When a send fails + +| Error | Meaning | Fix | +| --- | --- | --- | +| `TRIGGER_KEY_NOT_FOUND` (404) | No trigger has the key in this brand | `npx @brew.new/cli app-emails list`; set it up with `app-emails upsert` or `init` | +| `NO_PUBLISHED_AUTOMATION` (422) | The key exists, nothing publishes it | `npx @brew.new/cli app-emails upsert --yes` | +| `DOMAIN_REQUIRED` | Sandbox: the recipient is not in your Brew organization | Verify a sending domain (`npx @brew.new/cli domains add `) | +| `SANDBOX_DAILY_LIMIT` (429) | The team sandbox's daily cap is spent | Verify a domain, or wait for the UTC day | +| `INVALID_PAYLOAD` | The payload misses a required field | Check `lib/brew-contracts.ts`; `details.errors[]` names the field | + +Until a sending domain verifies, app emails go out from Brew's team-only +sandbox: members of your Brew organization receive them, nobody else. diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 0000000..d357d70 --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,221 @@ +import type { AppEmailKitSet, AppEmailKitStackProvider } from '@brew.new/sdk' +import { defineCommand } from '../lib/define-command' +import { CliUsageError } from '../lib/errors' +import { runInit } from '../lib/init/pipeline' +import { flagString } from '../lib/input' + +const SETS: readonly AppEmailKitSet[] = ['auth', 'lifecycle'] +const STACKS: readonly AppEmailKitStackProvider[] = [ + 'better-auth', + 'authjs', + 'supabase', + 'clerk', + 'firebase', + 'custom', +] + +function parseSets(value: unknown): AppEmailKitSet[] | undefined { + const text = flagString(value) + if (text === undefined) { + return + } + const items = text + .split(',') + .map((item) => item.trim()) + .filter((item) => item !== '') + const unknown = items.filter( + (item) => !(SETS as readonly string[]).includes(item) + ) + if (items.length === 0 || unknown.length > 0) { + throw new CliUsageError( + `--sets takes auth, lifecycle or both (comma-separated); got "${text}".` + ) + } + return [...new Set(items)] as AppEmailKitSet[] +} + +function parseStack(value: unknown): AppEmailKitStackProvider | undefined { + const text = flagString(value) + if (text === undefined) { + return + } + if (!(STACKS as readonly string[]).includes(text)) { + throw new CliUsageError( + `--stack takes one of ${STACKS.join(', ')}; got "${text}".` + ) + } + return text as AppEmailKitStackProvider +} + +/** + * `--apply-patches id@hash,…`: each id with the hash prefix (12+ hex) of + * the file it was computed from, as the report's nextSteps print it. + */ +function parsePatchPins(value: unknown): ReadonlyMap { + const text = flagString(value) + const pins = new Map() + if (text === undefined) { + return pins + } + for (const item of text.split(',')) { + const match = /^([a-z0-9-]+)@([0-9a-f]{12,64})$/.exec(item.trim()) + if (match?.[1] === undefined || match[2] === undefined) { + throw new CliUsageError( + `--apply-patches takes @ pairs from the report's nextSteps (e.g. better-auth-config@3f9c2a7d1b04); got "${item.trim()}".` + ) + } + pins.set(match[1], match[2]) + } + return pins +} + +function parseAppUrl(value: unknown): string | undefined { + const text = flagString(value) + if (text === undefined) { + return + } + try { + const url = new URL(text) + if ( + (url.protocol === 'https:' || url.protocol === 'http:') && + url.username === '' && + url.password === '' && + text.length <= 512 + ) { + return text + } + } catch { + // reported below + } + throw new CliUsageError( + `--app-url takes an http(s) URL without credentials; got "${text}".` + ) +} + +export const initCommand = defineCommand({ + path: ['init'], + summary: + 'Set up Brew app email in this project: sign in, starter kit, SDK, lib/brew.ts, .env.local, MCP, a sandbox test email', + sdkMethod: null, + derivedFrom: [ + 'brand.get', + 'domains.list', + 'domains.add', + 'appEmails.setUpKit', + 'automations.triggers.list', + 'automations.triggers.getContract', + 'send', + ], + commandClass: 'destructive', + flags: [ + { + flag: '--dry-run', + summary: + 'Plan only: detect, read, and report every change (--json for the plan); no writes, no sign-in, no mutating requests', + }, + { + flag: '--app ', + summary: 'The app to set up inside a monorepo (relative to the cwd)', + }, + { + flag: '--domain ', + summary: + 'Add this sending domain and print its DNS records (init does not wait for verification)', + }, + { + flag: '--sets ', + summary: 'Kit sets to set up: auth, lifecycle (default both)', + }, + { + flag: '--stack ', + summary: + 'Auth stack for the kit (default: detected): better-auth | authjs | supabase | clerk | firebase | custom', + }, + { + flag: '--test-to ', + summary: + 'Send the test email here (default: the person who signed in; the sandbox reaches your Brew team only)', + }, + { + flag: '--apply-patches ', + summary: + 'Apply reviewed patches to your auth config: comma-separated @ from the report (each applies only while its file is unchanged since that report)', + }, + { + flag: '--app-url ', + summary: + "Your app's URL, shown on the brew.new approval page so the right brand is picked", + }, + { flag: '--no-install', summary: 'Do not install @brew.new/sdk' }, + { flag: '--no-mcp', summary: 'Do not configure MCP for detected editors' }, + { + flag: '--no-agent-files', + summary: 'Do not write the AGENTS.md block or the skill files', + }, + { flag: '--no-test-email', summary: 'Do not send the sandbox test email' }, + { + flag: '--no-wait', + summary: + 'If a sign-in is needed, exit 3 at once with its link and code (agents), then run the resume command', + }, + { + flag: '--resume', + summary: 'Collect the sign-in started with --no-wait, then continue', + }, + { + flag: '--no-open', + summary: 'Print the sign-in link without opening a browser', + }, + ], + examples: [ + 'brew-cli init', + 'brew-cli init --dry-run --json', + 'brew-cli init --yes --json', + 'brew-cli init --yes --no-wait --json', + 'brew-cli init --resume --yes --json', + 'brew-cli init --app apps/web --domain mail.example.com --yes', + 'brew-cli init --yes --apply-patches better-auth-config@3f9c2a7d1b04', + ], + confirmSummary: ({ flags }) => { + if (flags.dryRun === true) { + return + } + return [ + 'Set up Brew in this project:', + 'sign in if needed (an app key goes into .env.local, 0600, git-ignored);', + `set up and publish the ${parseSets(flags.sets)?.join(' + ') ?? 'auth + lifecycle'} app emails;`, + flags.install === false ? '' : 'install @brew.new/sdk;', + 'write lib/brew.ts and friends (create-only, never over your edits)', + flags.applyPatches !== undefined + ? 'and apply the reviewed auth config patches;' + : 'and print patches for your auth config;', + flags.testEmail === false ? '' : 'then send you a sandbox test email.', + ] + .filter((part) => part !== '') + .join(' ') + }, + run: async ({ ctx, flags }) => { + const resume = flags.resume === true + const noWait = ctx.rawArgv.includes('--no-wait') + if (resume && flags.dryRun === true) { + throw new CliUsageError('--resume cannot be combined with --dry-run.') + } + return await runInit(ctx, { + dryRun: flags.dryRun === true, + app: flagString(flags.app), + domain: flagString(flags.domain), + sets: parseSets(flags.sets), + stack: parseStack(flags.stack), + install: flags.install !== false, + mcp: flags.mcp !== false, + agentFiles: flags.agentFiles !== false, + testEmail: flags.testEmail !== false, + testTo: flagString(flags.testTo), + applyPatches: parsePatchPins(flags.applyPatches), + appUrl: parseAppUrl(flags.appUrl), + noWait, + resume, + openBrowser: flags.open !== false, + }) + }, +}) diff --git a/src/commands/mcp/install.ts b/src/commands/mcp/install.ts index 3d4acb0..30c26a3 100644 --- a/src/commands/mcp/install.ts +++ b/src/commands/mcp/install.ts @@ -154,7 +154,7 @@ export const mcpInstallCommand = defineCommand({ }, }) -function installOne(input: { +export function installOne(input: { readonly client: McpClientId readonly scope: McpScope readonly auth: McpAuthMode diff --git a/src/generated/skills.ts b/src/generated/skills.ts index 2208d64..e332875 100644 --- a/src/generated/skills.ts +++ b/src/generated/skills.ts @@ -10,6 +10,12 @@ export type EmbeddedSkill = { } export const EMBEDDED_SKILLS: readonly EmbeddedSkill[] = [ + { + name: "brew-app-emails", + description: "Send and change this app's emails through Brew, covering auth emails (verify, magic link, one-time code, password reset), lifecycle emails (welcome, trial ending, payment failed) and your own keyed emails. Use when code sends an email, an auth library needs an email callback, or a send fails with TRIGGER_KEY_NOT_FOUND, DOMAIN_REQUIRED or SANDBOX_DAILY_LIMIT.", + path: "skills/brew-app-emails/SKILL.md", + content: "---\nname: brew-app-emails\ndescription: Send and change this app's emails through Brew, covering auth emails (verify, magic link, one-time code, password reset), lifecycle emails (welcome, trial ending, payment failed) and your own keyed emails. Use when code sends an email, an auth library needs an email callback, or a send fails with TRIGGER_KEY_NOT_FOUND, DOMAIN_REQUIRED or SANDBOX_DAILY_LIMIT.\n---\n\n# App emails with Brew\n\nEvery email this app sends has a **trigger key** (`auth.password-reset`,\n`lifecycle.welcome`, `order.paid`). Brew holds the design and the\nautomation behind the key; code only sends the key and a payload:\n\n```ts\nimport { getBrew } from './lib/brew' // written by `npx @brew.new/cli init`\n\nawait getBrew().send(\n 'auth.password-reset',\n { email: user.email, userName: user.name, confirmationUrl: resetUrl },\n { idempotencyKey: `auth.password-reset:${tokenId}` } // a retry never sends twice\n)\n```\n\n`brew.json` (committed) lists the keys this project set up;\n`lib/brew-contracts.ts` types each key's payload and makes an unknown key a\ncompile error. Regenerate it after changing a payload contract:\n`npx @brew.new/cli types --out lib/brew-contracts.ts`.\n\n## Rules\n\n- Send from server code only. `BREW_API_KEY` lives in `.env.local`\n (git-ignored); never give it a `NEXT_PUBLIC_`, `VITE_` or `PUBLIC_` name,\n never print it, never commit it.\n- Derive `idempotencyKey` from what makes the email unique (the token id,\n the order id, the user id), never from the token itself.\n- Fields like `confirmationUrl` and `otp` are template-only secrets: Brew\n renders them and never stores them on the contact.\n\n## Auth libraries\n\n`lib/brew-auth.ts` wires the one this app uses:\n\n- Better Auth: `brewEmails` from `@brew.new/sdk/better-auth` goes into\n `betterAuth({ emailVerification, emailAndPassword, databaseHooks })`.\n- Auth.js: `brewEmailProvider` (`@brew.new/sdk/next-auth`) goes into\n `providers`; email sign-in needs a database adapter.\n- Supabase: the Send Email Hook in `supabase/functions/send-email` sends\n every auth email; deploy it and point Authentication > Hooks at it.\n- Clerk, Firebase, WorkOS, Stytch: the provider keeps its auth emails;\n Brew sends lifecycle emails (`sendWelcomeEmail`).\n\n## A new email\n\n```bash\nnpx @brew.new/cli app-emails upsert order.paid --source-file emails/order-paid.tsx --subject \"Order confirmed\" --yes\nnpx @brew.new/cli types --out lib/brew-contracts.ts\nnpx @brew.new/cli send order.paid --to you@example.com --data '{\"orderId\":\"ord_1\"}' --yes\n```\n\nA design edited in Brew is never replaced: `upsert` answers\n`409 APP_EMAIL_EDITED_IN_BREW` until you pass `--overwrite`.\n\n## When a send fails\n\n| Error | Meaning | Fix |\n| --- | --- | --- |\n| `TRIGGER_KEY_NOT_FOUND` (404) | No trigger has the key in this brand | `npx @brew.new/cli app-emails list`; set it up with `app-emails upsert` or `init` |\n| `NO_PUBLISHED_AUTOMATION` (422) | The key exists, nothing publishes it | `npx @brew.new/cli app-emails upsert --yes` |\n| `DOMAIN_REQUIRED` | Sandbox: the recipient is not in your Brew organization | Verify a sending domain (`npx @brew.new/cli domains add `) |\n| `SANDBOX_DAILY_LIMIT` (429) | The team sandbox's daily cap is spent | Verify a domain, or wait for the UTC day |\n| `INVALID_PAYLOAD` | The payload misses a required field | Check `lib/brew-contracts.ts`; `details.errors[]` names the field |\n\nUntil a sending domain verifies, app emails go out from Brew's team-only\nsandbox: members of your Brew organization receive them, nobody else.\n", + }, { name: "brew-cli", description: "Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.", diff --git a/src/lib/init/detect.ts b/src/lib/init/detect.ts new file mode 100644 index 0000000..00546fb --- /dev/null +++ b/src/lib/init/detect.ts @@ -0,0 +1,539 @@ +import { existsSync, readdirSync, statSync } from 'node:fs' +import { basename, join, relative, resolve, sep } from 'node:path' +import { detectRemoteEnv } from '../auth-flow/remote-env' +import { detectMcpClients, type McpClientId } from '../mcp-clients' +import { readTextFile } from '../project/fs-writer' +import { + type AppCandidate, + type MonorepoInfo, + readJsonObject, + resolveProjectRoot, +} from '../project/monorepo' +import { + detectPackageManager, + type PackageManager, +} from '../project/package-manager' + +/** + * What `init` learns about the project before it changes anything: where + * the app is, how it builds and installs, which auth library sends its + * emails today, and what else (billing, an email provider, editors) is + * already there. Pure reads of the file system and the environment. + */ + +export type FrameworkId = + | 'next-app' + | 'next-pages' + | 'vite-react' + | 'react-router' + | 'remix' + | 'sveltekit' + | 'astro' + | 'express' + | 'hono' + | 'supabase-functions' + /** Any other Node server or script (fastify, koa, nest, nuxt, …). */ + | 'node' + +export type AuthId = + | 'better-auth' + | 'authjs' + | 'supabase' + | 'clerk' + | 'firebase' + | 'workos' + | 'stytch' + | 'none' + +/** Where server code runs, which decides how the generated client reads its key. */ +export type Runtime = 'node' | 'workers' | 'deno' | 'browser' + +export type ExistingEmail = { + /** `resend`, `@sendgrid/mail`, `postmark`, `nodemailer`, `react-email`. */ + readonly provider: string + readonly source: 'dependency' | 'directory' + /** The directory of React Email templates, for `source: 'directory'`. */ + readonly path?: string +} + +export type ProjectDetection = { + /** The app directory init acts on (absolute). */ + readonly root: string + readonly name: string + readonly hasPackageJson: boolean + readonly monorepo: { + readonly tool: MonorepoInfo['tool'] + readonly root: string + /** The app's directory relative to the monorepo root (POSIX). */ + readonly workspace: string | null + readonly workspaceName: string | null + } | null + readonly packageManager: PackageManager + readonly framework: FrameworkId + /** The dependency that decided the framework, when one did. */ + readonly frameworkDependency: string | null + readonly runtime: Runtime + readonly auth: AuthId + /** Auth.js major: `v4` (next-auth 4) or `v5` (next-auth 5, @auth/*). */ + readonly authVersion: 'v4' | 'v5' | null + /** Every auth library found, strongest first (`auth` is the first). */ + readonly authDetected: readonly AuthId[] + readonly billing: readonly string[] + readonly existingEmail: readonly ExistingEmail[] + /** The app builder that made the project, when it left a marker. */ + readonly builder: 'lovable' | 'bolt' | 'replit' | null + readonly typescript: boolean + /** `package.json#type === 'module'`. */ + readonly esm: boolean + /** Next.js major version from the dependency range, when Next. */ + readonly nextMajor: number | null + /** Root-relative directory for server-only helpers; null = no server. */ + readonly libDir: string | null + readonly hasSupabaseDir: boolean + /** The `@brew.new/sdk` range already declared, if any. */ + readonly sdkRange: string | null + readonly editors: readonly McpClientId[] + /** A cloud shell or CI (`Replit`, `GitHub Codespaces`, …). */ + readonly remote: string | null +} + +export type DetectResult = + | { readonly kind: 'project'; readonly project: ProjectDetection } + | { + /** A monorepo root with several apps: the caller must pick one. */ + readonly kind: 'ambiguous' + readonly root: string + readonly monorepo: MonorepoInfo + readonly candidates: readonly AppCandidate[] + } + | { + /** Nothing init can set up: no package.json, no Supabase functions. */ + readonly kind: 'none' + readonly root: string + } + +function dependencies( + pkg: Record | null +): Map { + const deps = new Map() + if (pkg === null) { + return deps + } + for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) { + const record = pkg[field] + if (typeof record === 'object' && record !== null) { + for (const [name, range] of Object.entries(record)) { + if (!deps.has(name)) { + deps.set(name, typeof range === 'string' ? range : '') + } + } + } + } + return deps +} + +function isDir(path: string): boolean { + try { + return statSync(path).isDirectory() + } catch { + return false + } +} + +function toPosix(path: string): string { + return path.split(sep).join('/') +} + +/** The first major version a dependency range names (`^15.1.0` → 15). */ +export function rangeMajor(range: string | undefined): number | null { + const match = /(\d+)(?:\.(\d+))?/.exec(range ?? '') + return match === null ? null : Number(match[1]) +} + +/** `[major, minor]` of a range, for "at least SDK_RANGE" checks. */ +export function rangeVersion( + range: string | undefined +): readonly [number, number] | null { + const match = /(\d+)\.(\d+)/.exec(range ?? '') + return match === null ? null : [Number(match[1]), Number(match[2])] +} + +function detectFrameworkId( + root: string, + deps: Map, + hasPackageJson: boolean +): { + readonly framework: FrameworkId + readonly dependency: string | null +} | null { + const has = (name: string): boolean => deps.has(name) + const dir = (path: string): boolean => isDir(join(root, path)) + if (has('next')) { + const isPages = + !(dir('app') || dir('src/app')) && (dir('pages') || dir('src/pages')) + return { + framework: isPages ? 'next-pages' : 'next-app', + dependency: 'next', + } + } + for (const [dependency, framework] of [ + ['@remix-run/react', 'remix'], + ['@remix-run/node', 'remix'], + ['@react-router/dev', 'react-router'], + ['@sveltejs/kit', 'sveltekit'], + ['astro', 'astro'], + ['hono', 'hono'], + ['express', 'express'], + ] as const) { + if (has(dependency)) { + return { framework, dependency } + } + } + if ( + has('vite') && + (has('react') || + has('@vitejs/plugin-react') || + has('@vitejs/plugin-react-swc')) + ) { + return { framework: 'vite-react', dependency: 'vite' } + } + if (dir('supabase/functions')) { + return { framework: 'supabase-functions', dependency: null } + } + if (hasPackageJson) { + const other = [ + 'fastify', + 'koa', + '@nestjs/core', + 'nuxt', + '@tanstack/react-start', + '@solidjs/start', + ].find(has) + return { framework: 'node', dependency: other ?? null } + } + return null +} + +const AUTH_ORDER: readonly AuthId[] = [ + 'better-auth', + 'authjs', + 'clerk', + 'workos', + 'stytch', + 'firebase', + 'supabase', +] + +function detectAuth( + deps: Map, + framework: FrameworkId +): { + readonly auth: AuthId + readonly version: 'v4' | 'v5' | null + readonly all: readonly AuthId[] +} { + const names = [...deps.keys()] + const found = new Set() + let version: 'v4' | 'v5' | null = null + if (deps.has('better-auth')) { + found.add('better-auth') + } + const nextAuth = deps.get('next-auth') + if (nextAuth !== undefined) { + found.add('authjs') + version = + /beta/.test(nextAuth) || (rangeMajor(nextAuth) ?? 0) >= 5 ? 'v5' : 'v4' + } else if ( + names.some( + (name) => + name.startsWith('@auth/') && + !name.endsWith('-adapter') && + !name.includes('adapter') + ) + ) { + found.add('authjs') + version = 'v5' + } + if (names.some((name) => name.startsWith('@clerk/'))) { + found.add('clerk') + } + if (names.some((name) => name.startsWith('@workos-inc/'))) { + found.add('workos') + } + if (deps.has('stytch') || names.some((name) => name.startsWith('@stytch/'))) { + found.add('stytch') + } + if (deps.has('firebase') || deps.has('firebase-admin')) { + found.add('firebase') + } + if ( + deps.has('@supabase/supabase-js') || + deps.has('@supabase/ssr') || + names.some((name) => name.startsWith('@supabase/auth-helpers')) || + framework === 'supabase-functions' + ) { + found.add('supabase') + } + const all = AUTH_ORDER.filter((id) => found.has(id)) + const auth = all[0] ?? 'none' + return { auth, version: auth === 'authjs' ? version : null, all } +} + +const EMAIL_DEPENDENCIES: ReadonlyArray = [ + ['resend', 'resend'], + ['@sendgrid/mail', '@sendgrid/mail'], + ['postmark', 'postmark'], + ['nodemailer', 'nodemailer'], + ['@react-email/components', 'react-email'], + ['@react-email/render', 'react-email'], + ['react-email', 'react-email'], +] + +function detectExistingEmail( + root: string, + deps: Map +): ExistingEmail[] { + const found: ExistingEmail[] = [] + const providers = new Set() + for (const [dependency, provider] of EMAIL_DEPENDENCIES) { + if (deps.has(dependency) && !providers.has(provider)) { + providers.add(provider) + found.push({ provider, source: 'dependency' }) + } + } + for (const dir of [ + 'emails', + 'src/emails', + 'react-email', + 'src/react-email', + ]) { + const absolute = join(root, dir) + if (!isDir(absolute)) { + continue + } + let entries: string[] + try { + entries = readdirSync(absolute) + } catch { + continue + } + if (entries.some((entry) => /\.(tsx|jsx)$/.test(entry))) { + found.push({ provider: 'react-email', source: 'directory', path: dir }) + break + } + } + return found +} + +function detectBuilder( + root: string, + deps: Map +): ProjectDetection['builder'] { + if (deps.has('lovable-tagger')) { + return 'lovable' + } + if (isDir(join(root, '.bolt'))) { + return 'bolt' + } + if (existsSync(join(root, '.replit'))) { + return 'replit' + } + return null +} + +function libDirFor( + root: string, + framework: FrameworkId, + hasSupabaseDir: boolean +): string | null { + const dir = (path: string): boolean => isDir(join(root, path)) + switch (framework) { + case 'vite-react': + // A browser-only app: a server key must never be bundled into it. + return null + case 'supabase-functions': + return hasSupabaseDir ? 'supabase/functions/_shared' : null + case 'sveltekit': + // `$lib/server` is the directory SvelteKit refuses to bundle for + // the browser. + return 'src/lib/server' + case 'astro': + return 'src/lib' + case 'react-router': + case 'remix': + return dir('app') ? 'app/lib' : dir('src') ? 'src/lib' : 'lib' + default: + return dir('src') ? 'src/lib' : 'lib' + } +} + +export type DetectOptions = { + /** `--app `: the app to set up inside a monorepo (relative to cwd). */ + readonly app?: string + readonly env: Readonly> +} + +export function detectProject( + cwd: string, + options: DetectOptions +): DetectResult { + const start = options.app === undefined ? cwd : resolve(cwd, options.app) + const resolved = resolveProjectRoot(start) + let root = resolved.root + let workspace = resolved.monorepo?.workspace ?? null + if ( + resolved.monorepo !== null && + workspace === null && + options.app === undefined + ) { + const apps = resolved.candidates + if (apps.length > 1) { + return { + kind: 'ambiguous', + root: resolved.monorepo.root, + monorepo: resolved.monorepo, + candidates: apps, + } + } + const only = apps[0] + if (only !== undefined) { + root = join(resolved.monorepo.root, only.dir) + workspace = { name: only.name, dir: only.dir } + } + } + const pkg = readJsonObject(join(root, 'package.json')) + const hasPackageJson = pkg !== null + const deps = dependencies(pkg) + const detected = detectFrameworkId(root, deps, hasPackageJson) + if (detected === null) { + return { kind: 'none', root } + } + const { framework } = detected + const auth = detectAuth(deps, framework) + const hasSupabaseDir = isDir(join(root, 'supabase')) + const isWorkers = + framework === 'hono' && + (['wrangler.toml', 'wrangler.json', 'wrangler.jsonc'].some((file) => + existsSync(join(root, file)) + ) || + deps.has('@cloudflare/workers-types')) + const runtime: Runtime = + framework === 'supabase-functions' + ? 'deno' + : framework === 'vite-react' + ? 'browser' + : isWorkers + ? 'workers' + : 'node' + const monorepoRoot = resolved.monorepo?.root + const packageManager = detectPackageManager( + monorepoRoot === undefined || monorepoRoot === root + ? [root] + : [root, monorepoRoot], + options.env + ) + const typescript = + existsSync(join(root, 'tsconfig.json')) || + deps.has('typescript') || + framework === 'supabase-functions' + const name = + pkg !== null && typeof pkg.name === 'string' && pkg.name !== '' + ? pkg.name + : basename(root) + return { + kind: 'project', + project: { + root, + name, + hasPackageJson, + monorepo: + resolved.monorepo === null + ? null + : { + tool: resolved.monorepo.tool, + root: resolved.monorepo.root, + workspace: + root === resolved.monorepo.root + ? null + : toPosix(relative(resolved.monorepo.root, root)), + workspaceName: workspace?.name ?? null, + }, + packageManager, + framework, + frameworkDependency: detected.dependency, + runtime, + auth: auth.auth, + authVersion: auth.version, + authDetected: auth.all, + billing: + deps.has('stripe') || + deps.has('@stripe/stripe-js') || + deps.has('@stripe/react-stripe-js') + ? ['stripe'] + : [], + existingEmail: detectExistingEmail(root, deps), + builder: detectBuilder(root, deps), + typescript, + esm: pkg?.type === 'module', + nextMajor: framework.startsWith('next-') + ? rangeMajor(deps.get('next')) + : null, + libDir: libDirFor(root, framework, hasSupabaseDir), + hasSupabaseDir, + sdkRange: deps.get('@brew.new/sdk') ?? null, + editors: detectMcpClients(monorepoRoot ?? root, options.env), + remote: detectRemoteEnv(options.env)?.name ?? null, + }, + } +} + +/** Files that may hold the auth library's config, root-relative. */ +const AUTH_CONFIG_CANDIDATES = [ + 'auth.ts', + 'auth.js', + 'src/auth.ts', + 'src/auth.js', + 'lib/auth.ts', + 'lib/auth.js', + 'src/lib/auth.ts', + 'src/lib/auth.js', + 'src/lib/server/auth.ts', + 'app/lib/auth.ts', + 'app/lib/auth.server.ts', + 'app/auth.server.ts', + 'server/auth.ts', + 'src/server/auth.ts', + 'utils/auth.ts', + 'src/utils/auth.ts', + 'pages/api/auth/[...nextauth].ts', + 'pages/api/auth/[...nextauth].js', + 'src/pages/api/auth/[...nextauth].ts', + 'app/api/auth/[...nextauth]/route.ts', + 'src/app/api/auth/[...nextauth]/route.ts', +] + +/** + * The file that configures the auth library (`betterAuth({`, `NextAuth(`), + * root-relative, or null when none of the usual places has it. + */ +export function findAuthConfig( + root: string, + auth: AuthId +): { readonly path: string; readonly text: string } | null { + const marker = + auth === 'better-auth' + ? /\bbetterAuth\(/ + : auth === 'authjs' + ? /\b(NextAuth|SvelteKitAuth|ExpressAuth)\(|providers\s*:\s*\[/ + : null + if (marker === null) { + return null + } + for (const path of AUTH_CONFIG_CANDIDATES) { + const text = readTextFile(join(root, path)) + if (text !== null && marker.test(text)) { + return { path, text } + } + } + return null +} diff --git a/src/lib/init/patches.ts b/src/lib/init/patches.ts new file mode 100644 index 0000000..8d41cfc --- /dev/null +++ b/src/lib/init/patches.ts @@ -0,0 +1,376 @@ +import { join } from 'node:path' +import { unifiedDiff } from '../project/diff' +import { + normalizeEol, + type ProjectWriter, + readTextFile, + sha256Hex, +} from '../project/fs-writer' +import { findAuthConfig, type ProjectDetection } from './detect' +import { + importSuffix, + relativeImport, + SUPABASE_CONFIG_BLOCK, +} from './templates' + +/** + * Edits to files the user owns (their auth config, `supabase/config.toml`). + * Init never writes these on its own: each is reported as a patch with a + * diff, and `--apply-patches` applies one only when the file still hashes + * to what the patch was computed from. A patch that would replace something + * the user wrote (an existing email sender) is never computed: it comes + * back `manual` with the snippet to add by hand. + */ + +export type PatchStatus = + /** Computed, not applied (no `--apply-patches`, or a dry run). */ + | 'pending' + | 'applied' + /** The file already has it. */ + | 'already-applied' + /** Init cannot place it safely: add `snippet` by hand. */ + | 'manual' + /** The file changed after the patch was computed: not applied. */ + | 'stale' + +export type InitPatch = { + readonly id: string + /** Root-relative path of the file the patch edits. */ + readonly path: string + readonly title: string + readonly description: string + readonly status: PatchStatus + /** sha256 of the file the patch was computed against (LF-normalized). */ + readonly baseSha256?: string + /** The change as a unified diff (`pending`, `applied`, `stale`). */ + readonly diff?: string + /** What to add by hand (`manual`). */ + readonly snippet?: string +} + +/** A patch plus the content it would write (never reported). */ +export type ComputedPatch = { + readonly patch: InitPatch + readonly next?: string +} + +function insertImport(text: string, statement: string): string { + const lines = text.split('\n') + const semicolon = lines.some((line) => /^import\s.*;\s*$/.test(line)) + const line = semicolon ? `${statement};` : statement + let lastImportEnd = -1 + let isOpen = false + const closes = + /\bfrom\s+['"][^'"]+['"]\s*;?\s*$|^import\s+['"][^'"]+['"]\s*;?\s*$/ + lines.forEach((current, index) => { + if (/^import\s/.test(current)) { + if (closes.test(current)) { + lastImportEnd = index + isOpen = false + } else { + isOpen = true + } + } else if (isOpen && closes.test(current)) { + lastImportEnd = index + isOpen = false + } + }) + if (lastImportEnd === -1) { + // After a leading 'use server' / 'use client' directive, else at the top. + const directive = /^\s*['"]use [a-z]+['"];?\s*$/.test(lines[0] ?? '') + lines.splice(directive ? 1 : 0, 0, line) + return lines.join('\n') + } + lines.splice(lastImportEnd + 1, 0, line) + return lines.join('\n') +} + +function indentOf(line: string): string { + return /^\s*/.exec(line)?.[0] ?? '' +} + +/** The indent of the first line inside an object opened on `lines[index]`. */ +function innerIndent(lines: readonly string[], index: number): string { + const opener = indentOf(lines[index] ?? '') + const next = lines[index + 1] ?? '' + const nextIndent = indentOf(next) + return next.trim() !== '' && + !next.trim().startsWith('}') && + nextIndent.length > opener.length + ? nextIndent + : `${opener} ` +} + +function computed( + path: string, + before: string, + after: string, + meta: Pick +): ComputedPatch { + return { + patch: { + ...meta, + path, + status: 'pending', + baseSha256: sha256Hex(before), + diff: unifiedDiff(normalizeEol(before), after, { fromPath: path }), + }, + next: after, + } +} + +function manual( + path: string, + meta: Pick, + snippet: string +): ComputedPatch { + return { patch: { ...meta, path, status: 'manual', snippet } } +} + +const BETTER_AUTH_SNIPPET = (lifecycle: boolean): string => + [ + 'betterAuth({', + ' emailVerification: { sendVerificationEmail: brewEmails.sendVerificationEmail },', + ' emailAndPassword: { enabled: true, sendResetPassword: brewEmails.sendResetPassword },', + ...(lifecycle + ? [ + ' databaseHooks: { user: { create: { after: brewEmails.onUserCreated } } },', + ] + : []), + ' plugins: [magicLink({ sendMagicLink: brewEmails.sendMagicLink })],', + '})', + ].join('\n') + +/** Wires `brewEmails` into the Better Auth config. */ +export function betterAuthPatch( + project: ProjectDetection, + authHelper: string, + options: { readonly lifecycle: boolean } +): ComputedPatch { + const config = findAuthConfig(project.root, 'better-auth') + const meta = { + id: 'better-auth-config', + title: 'Send Better Auth emails through Brew', + description: + 'Adds the brewEmails callbacks to betterAuth({ … }): verification email, password reset' + + (options.lifecycle ? ', and the welcome email on sign-up' : '') + + '. Magic-link and one-time-code plugins take brewEmails.sendMagicLink and brewEmails.sendVerificationOTP.', + } + const snippet = BETTER_AUTH_SNIPPET(options.lifecycle) + if (config === null) { + return manual('auth.ts', meta, snippet) + } + const text = normalizeEol(config.text) + const importLine = `import { brewEmails } from '${relativeImport(config.path, authHelper.replace(/\.(ts|js)$/, ''), importSuffix(project))}'` + if (text.includes('brewEmails')) { + return { + patch: { ...meta, path: config.path, status: 'already-applied' }, + } + } + const lines = text.split('\n') + const openers = lines.flatMap((line, index) => + /\bbetterAuth\(\s*\{\s*$/.test(line) ? [index] : [] + ) + const owned = [ + 'sendVerificationEmail', + 'sendResetPassword', + 'emailVerification', + ...(options.lifecycle ? ['databaseHooks'] : []), + ].filter((name) => text.includes(name)) + const opener = openers[0] + if (openers.length !== 1 || opener === undefined || owned.length > 0) { + return manual( + config.path, + { + ...meta, + description: `${meta.description} ${ + owned.length > 0 + ? `The config already sets ${owned.join(', ')}: replace your sender by hand.` + : 'The betterAuth({ … }) call could not be located safely.' + } Import: ${importLine}`, + }, + snippet + ) + } + const indent = innerIndent(lines, opener) + const inserted = [ + `${indent}emailVerification: { sendVerificationEmail: brewEmails.sendVerificationEmail },`, + ...(options.lifecycle + ? [ + `${indent}databaseHooks: { user: { create: { after: brewEmails.onUserCreated } } },`, + ] + : []), + ] + const next = [...lines] + // Bottom-up, so earlier indexes stay valid. + const passwordLines = next.flatMap((line, index) => + /^\s*emailAndPassword\s*:\s*\{\s*$/.test(line) ? [index] : [] + ) + const passwordLine = passwordLines[0] + const resetInserted = passwordLines.length === 1 && passwordLine !== undefined + if (resetInserted) { + next.splice( + passwordLine + 1, + 0, + `${innerIndent(next, passwordLine)}sendResetPassword: brewEmails.sendResetPassword,` + ) + } + next.splice(opener + 1, 0, ...inserted) + // The description must not promise a reset hook the diff does not add. + const described = + resetInserted || !/\bemailAndPassword\b/.test(text) + ? meta + : { + ...meta, + description: `${meta.description} emailAndPassword is not on its own lines here, so the patch leaves it alone: add sendResetPassword: brewEmails.sendResetPassword to it by hand.`, + } + return computed( + config.path, + config.text, + insertImport(next.join('\n'), importLine), + described + ) +} + +/** Adds `brewEmailProvider` to the Auth.js providers. */ +export function authjsPatch( + project: ProjectDetection, + authHelper: string +): ComputedPatch { + const config = findAuthConfig(project.root, 'authjs') + const meta = { + id: 'authjs-config', + title: 'Send Auth.js sign-in links through Brew', + description: + 'Adds brewEmailProvider to providers. Email sign-in needs a database adapter; send the welcome email from events.createUser with sendWelcomeEmail.', + } + const snippet = 'providers: [brewEmailProvider, /* your other providers */],' + if (config === null) { + return manual( + project.authVersion === 'v4' + ? 'pages/api/auth/[...nextauth].ts' + : 'auth.ts', + meta, + snippet + ) + } + const text = normalizeEol(config.text) + const importLine = `import { brewEmailProvider } from '${relativeImport(config.path, authHelper.replace(/\.(ts|js)$/, ''), importSuffix(project))}'` + if (text.includes('brewEmailProvider')) { + return { + patch: { ...meta, path: config.path, status: 'already-applied' }, + } + } + // Auth.js refuses an email provider without a database adapter at + // startup (MissingAdapter): adding one to an adapter-less config would + // break sign-in for every provider. + if (!/\badapter\s*:/.test(text)) { + return manual( + config.path, + { + ...meta, + description: `${meta.description} This config has no database adapter, which Auth.js email sign-in requires: add one first, then add the provider. Import: ${importLine}`, + }, + snippet + ) + } + const existingSender = + /\b(EmailProvider|Nodemailer|Resend|Sendgrid|Postmark)\(|sendVerificationRequest/.test( + text + ) + const lines = text.split('\n') + const providerLines = lines.flatMap((line, index) => + /\bproviders\s*:\s*\[/.test(line) ? [index] : [] + ) + const at = providerLines[0] + if (existingSender || providerLines.length !== 1 || at === undefined) { + return manual( + config.path, + { + ...meta, + description: `${meta.description} ${ + existingSender + ? 'The config already has an email provider: replace it by hand.' + : 'The providers list could not be located safely.' + } Import: ${importLine}`, + }, + snippet + ) + } + const line = lines[at] ?? '' + const next = [...lines] + if (/\bproviders\s*:\s*\[\s*$/.test(line)) { + next.splice(at + 1, 0, `${innerIndent(lines, at)}brewEmailProvider,`) + } else { + next[at] = line.replace(/\bproviders\s*:\s*\[\s*/, (match) => + /\[\s*\]/.test(line) + ? `${match}brewEmailProvider` + : `${match}brewEmailProvider, ` + ) + } + return computed( + config.path, + config.text, + insertImport(next.join('\n'), importLine), + meta + ) +} + +/** Turns the Send Email Hook on in `supabase/config.toml` (local dev). */ +export function supabaseConfigPatch(project: ProjectDetection): ComputedPatch { + const path = 'supabase/config.toml' + const meta = { + id: 'supabase-config-hook', + title: 'Turn on the Supabase Send Email Hook locally', + description: + 'Points [auth.hook.send_email] at the send-email function for `supabase start`. In production, set the hook in the Supabase dashboard (Authentication > Hooks).', + } + const text = readTextFile(join(project.root, path)) + if (text === null) { + return manual(path, meta, SUPABASE_CONFIG_BLOCK) + } + const normalized = normalizeEol(text) + if (normalized.includes('[auth.hook.send_email]')) { + return normalized.includes('/functions/v1/send-email') + ? { patch: { ...meta, path, status: 'already-applied' } } + : manual( + path, + { + ...meta, + description: `${meta.description} A send_email hook is already configured: point it at send-email by hand.`, + }, + SUPABASE_CONFIG_BLOCK + ) + } + const separator = normalized.endsWith('\n') ? '\n' : '\n\n' + return computed( + path, + text, + `${normalized}${separator}${SUPABASE_CONFIG_BLOCK}\n`, + meta + ) +} + +/** + * Applies a pending patch when the file still hashes to its base: the + * writer's managed-hash check makes "unchanged since planned" the only way + * in. Anything else is reported `stale`. + */ +export function applyPatch( + writer: ProjectWriter, + computedPatch: ComputedPatch +): InitPatch { + const { patch, next } = computedPatch + if (patch.status !== 'pending' || next === undefined) { + return patch + } + const change = writer.writeFile(patch.path, next, { + ...(patch.baseSha256 === undefined + ? {} + : { managedSha256: patch.baseSha256 }), + }) + if (change.action === 'updated' || change.action === 'unchanged') { + return { ...patch, status: writer.dryRun ? 'pending' : 'applied' } + } + return { ...patch, status: 'stale' } +} diff --git a/src/lib/init/pipeline.ts b/src/lib/init/pipeline.ts new file mode 100644 index 0000000..a8d5903 --- /dev/null +++ b/src/lib/init/pipeline.ts @@ -0,0 +1,1758 @@ +import { createHash, randomUUID } from 'node:crypto' +import { join } from 'node:path' +import type { + AppEmailKitSet, + AppEmailKitStackProvider, + SetUpAppEmailKitResponse, +} from '@brew.new/sdk' +import { BrewApiError } from '@brew.new/sdk' +import { installOne, type McpInstallResult } from '../../commands/mcp/install' +import { + buildContractsFile, + fetchContractArtifacts, + listAllTriggers, +} from '../../commands/types' +import { EMBEDDED_SKILLS } from '../../generated/skills' +import { CLI_VERSION } from '../../version' +import { + type BrowserLoginResult, + resumeBrowserLogin, + startBrowserLogin, +} from '../auth-flow/login' +import type { ApprovedLogin } from '../auth-flow/poll' +import { siteUrlFromApiUrl } from '../auth-flow/session' +import { + type BrewClient, + buildSdkClient, + maskApiKey, + type ResolvedAuth, + resolveAuth, +} from '../client' +import { readConfig, updateConfig } from '../config-store' +import { buildConfirmCommand } from '../confirm' +import { sdkTransportFor } from '../context' +import type { CommandOutcome } from '../define-command' +import { CliApiError, CliUsageError } from '../errors' +import { getInvocationPrefix } from '../invocation' +import { mcpUrlFromApiUrl, USER_SCOPE_ONLY } from '../mcp-clients' +import { progress } from '../output' +import { ensureEnvVars, getEnvValue, parseEnv } from '../project/env-file' +import { type PlannedChange, ProjectWriter } from '../project/fs-writer' +import { ensureIgnored } from '../project/gitignore' +import { addCommand, formatCommandLine } from '../project/package-manager' +import { resolveCwd, resolveHomeDir, resolveSpawn } from '../project/process' +import { + BREW_STATE_FILE, + type BrewManagedFile, + type BrewProjectEmail, + BrewStateError, + createBrewState, + readBrewState, + serializeBrewState, +} from '../project/state' +import type { CliContext } from '../types' +import { type AuthId, detectProject, type ProjectDetection } from './detect' +import { + applyPatch, + authjsPatch, + betterAuthPatch, + type ComputedPatch, + type InitPatch, + supabaseConfigPatch, +} from './patches' +import { + exitCodeFor, + INIT_REPORT_SCHEMA_VERSION, + type InitEmail, + type InitFile, + type InitReport, + type InitStep, + type NextStep, + overallStatus, + renderReport, +} from './report' +import { + APP_KEY_VARIABLE, + agentsBlock, + authHelperFile, + authHelperPath, + clientFile, + clientPath, + contractsPath, + type GeneratedFile, + reachesSdkRange, + SDK_PACKAGE, + SDK_RANGE, + SUPABASE_FUNCTION_PATH, + SUPABASE_HOOK_SECRET_VARIABLE, + skillInstallPath, + supabaseHookFile, + wantsAuthHelper, +} from './templates' + +export type InitOptions = { + readonly dryRun: boolean + readonly app: string | undefined + readonly domain: string | undefined + readonly sets: readonly AppEmailKitSet[] | undefined + readonly stack: AppEmailKitStackProvider | undefined + readonly install: boolean + readonly mcp: boolean + readonly agentFiles: boolean + readonly testEmail: boolean + readonly testTo: string | undefined + /** Patch id → hash prefix of the file it was reviewed against. */ + readonly applyPatches: ReadonlyMap + /** Shown on the approval page so the person picks the right brand. */ + readonly appUrl?: string | undefined + readonly noWait: boolean + readonly resume: boolean + readonly openBrowser: boolean +} + +/** The kit's auth stack for a detected auth library. */ +export function kitStackFor(auth: AuthId): AppEmailKitStackProvider { + switch (auth) { + case 'better-auth': + case 'authjs': + case 'supabase': + case 'clerk': + case 'firebase': + return auth + default: + return 'custom' + } +} + +const SANDBOX_NOTE = + 'Until a sending domain verifies, app emails reach members of your Brew organization only (team sandbox, daily cap).' + +function nonEmpty(value: string | undefined): string | undefined { + return value === undefined || value.trim() === '' ? undefined : value +} + +/** Never swallow a stop; 401 and 403 are the credential's, not a step's. */ +function rethrowIfFatal(ctx: CliContext, error: unknown): void { + ctx.signal.throwIfAborted() + if ( + (error instanceof BrewApiError || error instanceof CliApiError) && + (error.status === 401 || error.status === 403) + ) { + throw error + } +} + +function describeError(error: unknown): string { + if (error instanceof BrewApiError || error instanceof CliApiError) { + return `${error.code}: ${error.message}` + } + return error instanceof Error ? error.message : String(error) +} + +/** The command an agent re-runs to continue: same flags, `--yes`. */ +/** Flags of `init` whose value is the next token (`--apply-patches a@b`). */ +const VALUE_FLAGS: ReadonlySet = new Set(['--apply-patches']) + +function rerunCommand( + ctx: CliContext, + options: { readonly drop: readonly string[]; readonly add: readonly string[] } +): string { + const argv: string[] = [] + for (let index = 0; index < ctx.rawArgv.length; index++) { + const token = ctx.rawArgv[index] ?? '' + const name = token.split('=')[0] ?? token + if (token === '--yes' || options.drop.includes(name)) { + // A dropped flag that takes a value drops its value too. + if (VALUE_FLAGS.has(token)) { + index++ + } + continue + } + argv.push(token) + } + return buildConfirmCommand( + [...argv, ...options.add], + getInvocationPrefix(ctx.io.env) + ) +} + +type Credential = { + readonly auth: ResolvedAuth | undefined + readonly source: InitReport['credential']['source'] +} + +export async function runInit( + ctx: CliContext, + options: InitOptions +): Promise { + const env = ctx.io.env + const prefix = getInvocationPrefix(env) + const spawn = resolveSpawn(ctx.io) + const cwd = resolveCwd(ctx.io) + const { dryRun } = options + + const detected = detectProject(cwd, { + ...(options.app === undefined ? {} : { app: options.app }), + env, + }) + if (detected.kind === 'none') { + throw new CliUsageError( + `No app found in ${detected.root}: init needs a package.json (or supabase/functions). Run it in your app's directory${options.app === undefined ? ', or pass --app ' : ''}.` + ) + } + if (detected.kind === 'ambiguous') { + const report = ambiguousReport(detected, ctx, dryRun) + return finish(report) + } + const project = detected.project + progress( + ctx, + `Setting up Brew in ${project.root} (${project.framework}, auth: ${project.auth})…` + ) + const writer = new ProjectWriter({ root: project.root, dryRun }) + let stateRead: ReturnType + try { + stateRead = readBrewState(project.root) + } catch (error) { + if (error instanceof BrewStateError) { + throw new CliUsageError( + `${error.message}. Fix or remove ${BREW_STATE_FILE}, then run init again.` + ) + } + throw error + } + const state = stateRead?.state ?? null + const projectId = state?.projectId ?? randomUUID() + // `init:::`: an identical re-run replays the + // first request; a different body (another --stack, recipient or domain) + // is a new request, never an idempotency conflict. + const idempotencyKey = (step: string, body: unknown): string => + `init:${projectId}:${step}:${createHash('sha256').update(JSON.stringify(body)).digest('hex').slice(0, 12)}` + const steps: InitStep[] = [ + { + id: 'detect', + status: 'done', + detail: `${project.framework}${project.auth === 'none' ? '' : `, ${project.auth}`}, ${project.packageManager.name}`, + }, + ] + const nextSteps: NextStep[] = [] + const sets: readonly AppEmailKitSet[] = options.sets ?? ['auth', 'lifecycle'] + const lifecycle = sets.includes('lifecycle') + const stack = options.stack ?? kitStackFor(project.auth) + + // ---- App key: .env.local must be git-ignored before a key goes in it. + const envLocalText = writer.read('.env.local') + const existingAppKey = nonEmpty( + getEnvValue(parseEnv(envLocalText ?? ''), APP_KEY_VARIABLE) + ) + const ignore = await ensureIgnored(writer, ['.env.local'], { + spawn, + signal: ctx.signal, + }) + if (ignore.conflicts.length > 0) { + steps.push({ + id: 'env', + status: 'failed', + detail: + '.env.local is not git-ignored (a later rule re-includes it, or git already tracks it: `git rm --cached .env.local`); no key was written', + }) + nextSteps.push({ + id: 'gitignore-env-local', + actor: 'agent', + kind: 'edit-file', + path: '.gitignore', + title: + 'Make git ignore .env.local (remove the rule that re-includes it), then run init again', + blocking: true, + }) + return finish( + baseReport({ + ctx, + project, + projectId: state === null ? null : projectId, + dryRun, + steps, + nextSteps, + credential: { auth: undefined, source: 'none' }, + appKey: { status: 'missing', masked: null }, + }) + ) + } + + // ---- Credential: who this run acts as. + const stored = readConfig(env) + const apiUrl = resolveAuth({ + globals: ctx.globals, + env, + allowAnonymous: true, + }).apiUrl + const siteUrl = siteUrlFromApiUrl(apiUrl) + const explicitKey = + ctx.globals.apiKey ?? nonEmpty(env.BREW_API_KEY ?? undefined) + const explicitBrand = + ctx.globals.brand ?? nonEmpty(env.BREW_BRAND_ID) ?? state?.brandId + let credential: Credential = { auth: undefined, source: 'none' } + const authFor = ( + apiKey: string, + source: Credential['source'], + brandId: string | undefined + ): Credential => ({ + auth: { + apiKey, + apiKeySource: + source === 'flag' ? 'flag' : source === 'env' ? 'env' : 'config', + brandId, + apiUrl, + }, + source, + }) + if (explicitKey !== undefined) { + credential = authFor( + explicitKey, + ctx.globals.apiKey === undefined ? 'env' : 'flag', + explicitBrand + ) + } else if (existingAppKey !== undefined) { + credential = authFor(existingAppKey, 'env-file', state?.brandId) + } else if (dryRun && stored.apiKey !== undefined) { + credential = authFor( + stored.apiKey, + 'config', + state?.brandId ?? stored.brandId + ) + } + let appKey = existingAppKey + let appKeyStatus: InitReport['credential']['appKey']['status'] = + existingAppKey === undefined ? 'missing' : 'existing' + let brand: InitReport['brand'] = null + let signedInEmail: string | undefined = stored.userEmail + + if (existingAppKey !== undefined) { + steps.push({ + id: 'sign-in', + status: 'skipped', + detail: `${APP_KEY_VARIABLE} already in .env.local`, + }) + } else if (dryRun) { + steps.push({ + id: 'sign-in', + status: 'planned', + detail: + 'browser sign-in: approve on brew.new, and an app key goes into .env.local', + }) + appKeyStatus = 'planned' + } else { + const keys = ( + stored.apiKey === undefined && explicitKey === undefined + ? ['cli', 'app'] + : ['app'] + ) as Array<'cli' | 'app'> + const resumeCommand = rerunCommand(ctx, { + drop: ['--no-wait', '--resume'], + add: ['--resume'], + }) + const loginCommands = { + resumeCommand, + restartCommand: rerunCommand(ctx, { + drop: ['--no-wait', '--resume'], + add: [], + }), + } + const waitMs = options.noWait || !ctx.io.isTtyIn ? 0 : undefined + const result: BrowserLoginResult = options.resume + ? await resumeBrowserLogin(ctx, { waitMs, ...loginCommands }) + : await startBrowserLogin(ctx, { + keys, + client: { + project: project.name.slice(0, 100), + ...(options.appUrl === undefined ? {} : { appUrl: options.appUrl }), + }, + openBrowser: options.openBrowser, + waitMs, + ...loginCommands, + }) + if (result.status === 'pending') { + steps.push({ + id: 'sign-in', + status: 'pending', + detail: `approve the sign-in (code ${result.pending.userCode})`, + }) + nextSteps.push( + { + id: 'approve-sign-in', + actor: 'human', + kind: 'open-url', + url: result.pending.verificationUrl, + title: `Approve the Brew sign-in in a browser; check the code matches ${result.pending.userCode} (expires ${result.pending.expiresAt})`, + blocking: true, + }, + { + id: 'resume-init', + actor: 'agent', + kind: 'run-command', + command: result.pending.resumeCommand, + title: + 'After approval, resume init (exit 3 again means not approved yet)', + blocking: true, + } + ) + return finish( + baseReport({ + ctx, + project, + projectId: state === null ? null : projectId, + dryRun, + steps, + nextSteps, + credential, + appKey: { status: 'missing', masked: null }, + }) + ) + } + const approved: ApprovedLogin = result.approved + const minted = approved.keys.app + if (minted === undefined) { + throw new CliApiError({ + status: 200, + code: 'CLI_LOGIN_SEAL_INVALID', + type: 'internal_error', + message: 'The approved sign-in carried no app key.', + suggestion: `Run \`${loginCommands.restartCommand}\` again.`, + }) + } + appKey = minted.key + brand = { brandId: approved.brand.brandId, name: approved.brand.name } + signedInEmail = approved.approver.email + const cliKey = approved.keys.cli + if (cliKey !== undefined && keys.includes('cli')) { + updateConfig(env, { + apiKey: cliKey.key, + brandId: approved.brand.brandId, + userEmail: approved.approver.email, + brandName: approved.brand.name, + }) + } + // The kit, contracts and test email must land in the brand the new app + // key is bound to, so the rest of the run acts as that key, even when + // an explicit key (maybe another brand's) started it. + credential = authFor(minted.key, 'browser', approved.brand.brandId) + steps.push({ + id: 'sign-in', + status: 'done', + detail: `signed in as ${approved.approver.email} to ${approved.brand.name}`, + }) + } + + // ---- .env.local: the app key, 0600, never through a symbolic link. + if (appKey !== undefined || envLocalText !== null) { + const { text, results } = ensureEnvVars( + envLocalText, + appKey === undefined ? [] : [{ name: APP_KEY_VARIABLE, value: appKey }] + ) + const change = writer.writeFile('.env.local', text, { + overwrite: true, + mode: 0o600, + sensitive: true, + }) + const result = results[0] + if (change.action === 'refused') { + steps.push({ + id: 'env', + status: 'failed', + detail: `.env.local not written: ${change.note ?? 'unsafe path'}`, + }) + } else { + if ( + appKeyStatus !== 'existing' && + (result?.action === 'added' || result?.action === 'filled') + ) { + appKeyStatus = 'written' + } + steps.push({ + id: 'env', + status: dryRun ? 'planned' : 'done', + detail: + appKeyStatus === 'existing' + ? `${APP_KEY_VARIABLE} kept` + : `${APP_KEY_VARIABLE} ${dryRun ? 'will be added' : 'added'} (0600, git-ignored)`, + }) + } + } else { + steps.push({ + id: 'env', + status: 'planned', + detail: `${APP_KEY_VARIABLE} goes into .env.local after sign-in`, + }) + } + + const client: BrewClient | undefined = + credential.auth === undefined + ? undefined + : buildSdkClient(credential.auth, sdkTransportFor(ctx)) + const afterSignIn = 'after sign-in' + + // ---- Brand. + if (brand !== null) { + steps.push({ + id: 'brand', + status: 'done', + detail: brand.name ?? brand.brandId, + }) + } else if (client === undefined) { + steps.push({ + id: 'brand', + status: 'planned', + detail: 'the brand you pick when signing in', + }) + } else { + try { + const row = await client.brand.get() + brand = { + brandId: row.brandId, + name: row.identity?.brandName ?? row.domain ?? null, + } + steps.push({ + id: 'brand', + status: 'done', + detail: brand.name ?? brand.brandId, + }) + } catch (error) { + rethrowIfFatal(ctx, error) + steps.push({ + id: 'brand', + status: 'failed', + detail: describeError(error), + }) + } + } + + // ---- Domain: report it; add one when asked; never wait for DNS. + const domain: { + -readonly [K in keyof InitReport['domain']]: InitReport['domain'][K] + } = { + mode: 'unknown', + domainId: null, + name: null, + status: null, + added: false, + records: [], + settingsUrl: `${siteUrl}/domains`, + sandbox: null, + } + if (client === undefined) { + steps.push({ id: 'domain', status: 'planned', detail: afterSignIn }) + } else { + try { + const list = await client.domains.list({ limit: 100 }) + const verified = list.data.filter( + (row) => row.status === 'verified' && row.sendable + ) + const chosen = + verified.find((row) => row.sendingPurpose === 'transactional') ?? + verified[0] + let target = + options.domain === undefined + ? undefined + : list.data.find( + (row) => row.name.toLowerCase() === options.domain?.toLowerCase() + ) + let selectorInUse: DkimSelectorInUse | undefined + if (options.domain !== undefined && target === undefined) { + if (dryRun) { + domain.name = options.domain + } else { + const body = { + name: options.domain, + sendingPurpose: 'transactional' as const, + } + target = await client.domains.add(body, { + idempotencyKey: idempotencyKey('domain', body), + }) + domain.added = true + selectorInUse = dkimSelectorInUse(target) + } + } + const shown = target ?? chosen + if (shown !== undefined) { + domain.domainId = shown.domainId + domain.name = shown.name + domain.status = shown.status + if (shown.status !== 'verified') { + domain.records = shown.records.map((record) => ({ + type: record.type, + name: record.name, + value: record.value, + ...(record.priority === undefined + ? {} + : { priority: record.priority }), + })) + } + } + domain.mode = chosen === undefined ? 'sandbox' : 'domain' + if (chosen === undefined) { + domain.sandbox = { note: SANDBOX_NOTE } + } + steps.push({ + id: 'domain', + status: + options.domain !== undefined && target === undefined && dryRun + ? 'planned' + : 'done', + detail: + chosen !== undefined + ? `sending from ${chosen.name}` + : selectorInUse !== undefined + ? `added ${domain.name}, which already publishes another DKIM key; sandbox until a domain verifies` + : domain.added + ? `added ${domain.name}; sandbox until its DNS verifies` + : 'sandbox (no verified domain yet)', + }) + if (selectorInUse !== undefined) { + // Its DKIM record would replace the key another sender signs with. + const [suggested] = selectorInUse.suggestedNames + nextSteps.push({ + id: 'dkim-selector-in-use', + actor: 'human', + kind: suggested === undefined ? 'open-url' : 'run-command', + title: `${domain.name} already publishes a different DKIM key where Brew asks for its record: add a subdomain instead${selectorInUse.suggestedNames.length > 0 ? ` (${selectorInUse.suggestedNames.join(', ')})` : ''}`, + ...(suggested === undefined + ? { url: shownUrl(target, domain.settingsUrl) } + : { command: `${prefix} init --domain ${suggested}` }), + blocking: false, + }) + } else if (domain.records.length > 0 && domain.domainId !== null) { + nextSteps.push({ + id: 'add-dns-records', + actor: 'human', + kind: 'add-dns-records', + title: `Add the DNS records for ${domain.name} at your DNS provider, then verify`, + command: `${prefix} domains verify ${domain.domainId}`, + url: shown?.domainUrl ?? domain.settingsUrl, + blocking: false, + }) + } else if (chosen === undefined) { + nextSteps.push({ + id: 'verify-domain', + actor: 'human', + kind: 'open-url', + url: domain.settingsUrl, + title: + 'Add and verify a sending domain so app emails reach every user (the sandbox reaches your team only)', + blocking: false, + }) + } + } catch (error) { + rethrowIfFatal(ctx, error) + steps.push({ + id: 'domain', + status: 'failed', + detail: describeError(error), + }) + } + } + + // ---- The starter kit. + let kit: SetUpAppEmailKitResponse | undefined + const emails: InitEmail[] = [] + if (client === undefined || dryRun) { + steps.push({ + id: 'kit', + status: 'planned', + detail: `${sets.join(' + ')} kit for ${stack}${client === undefined ? `, ${afterSignIn}` : ''}`, + }) + } else { + try { + progress( + ctx, + `Setting up the ${sets.join(' + ')} app emails for ${stack}…` + ) + const body = { sets: [...sets], stack } + kit = await client.appEmails.setUpKit(body, { + idempotencyKey: idempotencyKey('kit', body), + }) + for (const entry of kit.results) { + emails.push( + entry.ok + ? { + key: entry.triggerKey, + ok: true, + triggerEventId: entry.triggerEventId, + automationId: entry.automationId, + published: entry.published, + sender: entry.sender.mode, + created: + entry.created.trigger || + entry.created.email || + entry.created.automation, + } + : { + key: entry.triggerKey, + ok: false, + error: { code: entry.error.code, message: entry.error.message }, + } + ) + } + const failed = emails.filter((email) => !email.ok) + steps.push({ + id: 'kit', + status: failed.length > 0 ? 'failed' : 'done', + detail: + failed.length > 0 + ? `${failed.length} of ${emails.length} keys failed: ${failed.map((email) => email.key).join(', ')}` + : `${emails.length} app emails published`, + }) + for (const email of failed) { + // A re-run alone cannot set up a key whose trigger another published + // automation already sends from: a person picks which one stays. + if (email.error?.code === 'APP_EMAIL_TRIGGER_IN_USE') { + nextSteps.push({ + id: `trigger-in-use:${email.key}`, + actor: 'human', + kind: 'review', + title: `${email.key}: ${email.error.message}`, + blocking: false, + }) + } + } + if (failed.length > 0) { + nextSteps.push({ + id: 'rerun-init', + actor: 'agent', + kind: 'run-command', + command: rerunCommand(ctx, { + drop: ['--resume', '--no-wait'], + add: [], + }), + title: 'Run init again to finish the keys that failed', + blocking: true, + }) + } + } catch (error) { + rethrowIfFatal(ctx, error) + steps.push({ id: 'kit', status: 'failed', detail: describeError(error) }) + } + } + + // ---- Install the SDK with the project's package manager. + const install = planInstall({ project, options, steps, nextSteps }) + if (install !== undefined) { + await runInstall(ctx, install, { steps, nextSteps, spawn }) + } + + // ---- Managed files. + const files: InitFile[] = [] + if (ignore.change !== undefined && ignore.change.action !== 'unchanged') { + files.push({ + path: '.gitignore', + action: ignore.change.action, + role: 'gitignore', + note: 'ignores .env.local', + }) + } + const managed: Array<{ + readonly file: GeneratedFile + readonly change: PlannedChange + }> = [] + const writeManaged = (file: GeneratedFile): PlannedChange => { + const recorded = state?.files[file.path]?.sha256 + const change = writer.writeFile( + file.path, + file.content, + recorded === undefined ? {} : { managedSha256: recorded } + ) + managed.push({ file, change }) + files.push({ + path: file.path, + action: change.action, + role: file.role, + ...(change.note === undefined ? {} : { note: change.note }), + }) + return change + } + const installsSkills = project.editors.includes('claude-code') + const paths = { + client: clientPath(project), + auth: wantsAuthHelper(project, { lifecycle }) + ? authHelperPath(project) + : null, + contracts: contractsPath(project), + } + try { + if (paths.client !== null) { + writeManaged({ + path: paths.client, + content: clientFile(project), + role: 'client', + }) + } + if (paths.auth !== null) { + writeManaged({ + path: paths.auth, + content: authHelperFile(project, { lifecycle }), + role: 'auth', + }) + } + if (project.auth === 'supabase') { + writeManaged({ + path: SUPABASE_FUNCTION_PATH, + content: supabaseHookFile(), + role: 'supabase-hook', + }) + } + if (paths.contracts !== null) { + if (client === undefined) { + files.push({ + path: paths.contracts, + action: 'planned', + role: 'contracts', + note: afterSignIn, + }) + } else { + const triggers = await listAllTriggers(ctx, client) + const artifacts = await fetchContractArtifacts( + client, + triggers, + ctx.signal + ) + writeManaged({ + path: paths.contracts, + content: buildContractsFile({ artifacts }), + role: 'contracts', + }) + } + } + // Skills go where Claude Code reads them, and only into a project that + // already uses it: creating `.claude/` would make the next run detect + // an editor nobody uses. + if (options.agentFiles && installsSkills) { + for (const skill of EMBEDDED_SKILLS) { + writeManaged({ + path: skillInstallPath(skill.name), + content: skill.content, + role: 'skill', + }) + } + } + const exampleText = writer.read('.env.example') + const example = ensureEnvVars(exampleText, [ + { name: APP_KEY_VARIABLE, value: '', secret: false }, + ...(project.auth === 'supabase' + ? [{ name: SUPABASE_HOOK_SECRET_VARIABLE, value: '', secret: false }] + : []), + ]) + const exampleChange = writer.writeFile('.env.example', example.text, { + overwrite: true, + }) + files.push({ + path: '.env.example', + action: exampleChange.action, + role: 'env-example', + }) + const kept = files.filter((file) => file.action === 'kept-user-version') + steps.push({ + id: 'files', + status: dryRun ? 'planned' : 'done', + detail: + kept.length > 0 + ? `kept your version of ${kept.map((file) => file.path).join(', ')}` + : `${files.filter((file) => file.action === 'created' || file.action === 'updated').length} ${dryRun ? 'to write' : 'written'}`, + }) + } catch (error) { + rethrowIfFatal(ctx, error) + steps.push({ id: 'files', status: 'failed', detail: describeError(error) }) + } + + // ---- Patches to the user's own config: printed, applied on request. + const computedPatches: ComputedPatch[] = [] + if (paths.auth !== null && project.auth === 'better-auth') { + computedPatches.push(betterAuthPatch(project, paths.auth, { lifecycle })) + } + if (paths.auth !== null && project.auth === 'authjs') { + computedPatches.push(authjsPatch(project, paths.auth)) + } + if (project.auth === 'supabase' && project.hasSupabaseDir) { + computedPatches.push(supabaseConfigPatch(project)) + } + const patches: InitPatch[] = computedPatches.map((computed) => { + const pin = options.applyPatches.get(computed.patch.id) + if (pin === undefined || computed.patch.status !== 'pending') { + return computed.patch + } + // Applied only when the file still hashes to what was reviewed: a file + // edited since the report would get a diff nobody saw. + return computed.patch.baseSha256?.startsWith(pin) === true + ? applyPatch(writer, computed) + : { ...computed.patch, status: 'stale' } + }) + for (const patch of patches) { + if (patch.status === 'pending' || patch.status === 'stale') { + nextSteps.push({ + id: `apply-${patch.id}`, + actor: 'agent', + kind: 'apply-patch', + patchId: patch.id, + path: patch.path, + title: `${patch.title} (review the diff, then apply)`, + command: rerunCommand(ctx, { + drop: ['--resume', '--no-wait', '--dry-run', '--apply-patches'], + add: [ + '--apply-patches', + `${patch.id}@${(patch.baseSha256 ?? '').slice(0, 12)}`, + ], + }), + blocking: true, + }) + } else if (patch.status === 'manual') { + nextSteps.push({ + id: `edit-${patch.id}`, + actor: 'agent', + kind: 'edit-file', + patchId: patch.id, + path: patch.path, + title: `${patch.title}: add the snippet by hand`, + blocking: true, + }) + } + } + steps.push({ + id: 'patches', + status: patches.length === 0 ? 'skipped' : dryRun ? 'planned' : 'done', + detail: + patches.length === 0 + ? 'nothing to patch' + : patches.map((patch) => `${patch.id} ${patch.status}`).join(', '), + }) + + // ---- MCP clients the project already uses. + const mcp: McpInstallResult[] = [] + if (!options.mcp) { + steps.push({ id: 'mcp', status: 'skipped', detail: '--no-mcp' }) + } else if (project.editors.length === 0) { + steps.push({ + id: 'mcp', + status: 'skipped', + detail: 'no editor config found', + }) + nextSteps.push({ + id: 'mcp-install', + actor: 'agent', + kind: 'run-command', + command: `${prefix} mcp install --client `, + title: 'Connect your AI editor to the Brew MCP server', + blocking: false, + }) + } else { + const mcpRoot = project.monorepo?.root ?? project.root + const mcpWriter = new ProjectWriter({ root: mcpRoot, dryRun }) + for (const editor of project.editors) { + mcp.push( + installOne({ + client: editor, + scope: 'project', + auth: 'oauth', + mcpUrl: mcpUrlFromApiUrl(apiUrl), + projectRoot: mcpRoot, + home: resolveHomeDir(env), + env, + writer: mcpWriter, + mode: USER_SCOPE_ONLY.has(editor) ? 'report' : 'write', + }) + ) + } + steps.push({ + id: 'mcp', + status: dryRun ? 'planned' : 'done', + detail: mcp.map((entry) => `${entry.client} ${entry.action}`).join(', '), + }) + } + + // ---- AGENTS.md block. + const keys = [ + ...new Set([ + ...emails.filter((email) => email.ok).map((email) => email.key), + ...Object.keys(state?.emails ?? {}), + ]), + ].sort() + if (options.agentFiles) { + const change = writer.upsertBlock( + 'AGENTS.md', + agentsBlock({ + project, + keys, + files: paths, + skillPaths: installsSkills + ? EMBEDDED_SKILLS.map((skill) => skillInstallPath(skill.name)) + : [], + }) + ) + files.push({ + path: 'AGENTS.md', + action: change.action, + role: 'agents-block', + ...(change.note === undefined ? {} : { note: change.note }), + }) + steps.push({ + id: 'agent-files', + status: dryRun ? 'planned' : 'done', + detail: installsSkills + ? `AGENTS.md block and ${EMBEDDED_SKILLS.length} skills in .claude/skills` + : 'AGENTS.md block (skills only go into a Claude Code project)', + }) + } else { + steps.push({ + id: 'agent-files', + status: 'skipped', + detail: '--no-agent-files', + }) + } + + // ---- brew.json: ids and hashes, so re-runs converge. + const stateEmails: Record = { + ...(state?.emails ?? {}), + } + for (const email of emails) { + if ( + email.ok && + email.triggerEventId !== undefined && + email.automationId !== undefined + ) { + stateEmails[email.key] = { + triggerEventId: email.triggerEventId, + automationId: email.automationId, + } + } + } + const stateFiles: Record = { + ...(state?.files ?? {}), + } + for (const { file, change } of managed) { + const previous = state?.files[file.path] + if ( + (change.action === 'created' || + change.action === 'updated' || + change.action === 'unchanged') && + change.sha256 !== undefined + ) { + stateFiles[file.path] = + change.action === 'unchanged' && previous?.sha256 === change.sha256 + ? previous + : { + sha256: change.sha256, + generator: `init@${CLI_VERSION}:${file.path}`, + } + } + } + const mcpIds = [ + ...new Set([ + ...(state?.mcp ?? []), + ...mcp + .filter((entry) => + ['created', 'updated', 'unchanged'].includes(entry.action) + ) + .map((entry) => entry.client), + ]), + ] + const brandId = brand?.brandId ?? state?.brandId + const nextState = createBrewState({ + ...(state ?? {}), + projectId, + ...(brandId === undefined ? {} : { brandId }), + framework: project.framework, + auth: + project.authVersion === null + ? project.auth + : `${project.auth}-${project.authVersion}`, + ...(paths.contracts === null ? {} : { contractsPath: paths.contracts }), + emails: stateEmails, + files: stateFiles, + ...(mcpIds.length === 0 ? {} : { mcp: mcpIds }), + }) + const stateChange = writer.writeFile( + BREW_STATE_FILE, + serializeBrewState(nextState, stateRead?.extra ?? {}), + { overwrite: true } + ) + files.push({ + path: BREW_STATE_FILE, + action: stateChange.action, + role: 'state', + }) + steps.push({ id: 'state', status: dryRun ? 'planned' : 'done' }) + + // ---- Project next steps that do not depend on a step's outcome. + projectNextSteps({ project, nextSteps, siteUrl, paths, prefix }) + + // ---- A sandbox test email to the person who signed in. + const testEmail = await sendTestEmail({ + ctx, + client, + options, + emails, + to: options.testTo ?? signedInEmail, + idempotencyKey: (body) => idempotencyKey('test-email', body), + steps, + nextSteps, + prefix, + }) + + const report = baseReport({ + ctx, + project, + // A dry run mints no project id: the one it would use is not kept. + projectId: dryRun ? (state?.projectId ?? null) : projectId, + dryRun, + steps, + nextSteps, + credential, + appKey: { + status: appKeyStatus, + masked: appKey === undefined ? null : maskApiKey(appKey), + }, + brand, + domain, + stack: + kit === undefined + ? { provider: stack, authDelivery: null, note: null } + : { + provider: kit.stack.provider, + authDelivery: kit.stack.authDelivery, + note: kit.stack.note, + }, + emails, + files, + patches, + mcp, + testEmail, + paths, + keys, + }) + return finish(report) +} + +type InstallPlan = { + readonly line: ReturnType + readonly cwd: string + readonly command: string +} + +/** + * The `add @brew.new/sdk` command for this project, or undefined when there + * is nothing to run now (already declared, opted out, a dry run, or a + * runtime that does not install from npm); those cases record their step. + */ +function planInstall(input: { + readonly project: ProjectDetection + readonly options: InitOptions + readonly steps: InitStep[] + readonly nextSteps: NextStep[] +}): InstallPlan | undefined { + const { project, options, steps, nextSteps } = input + if ( + !project.hasPackageJson || + project.runtime === 'deno' || + project.runtime === 'browser' + ) { + steps.push({ + id: 'install', + status: 'skipped', + detail: + project.runtime === 'browser' + ? 'browser-only app: send from your server or Supabase Edge Functions' + : `Edge Functions import npm:${SDK_PACKAGE}@${SDK_RANGE}`, + }) + return + } + if (reachesSdkRange(project.sdkRange)) { + steps.push({ + id: 'install', + status: 'skipped', + detail: `${SDK_PACKAGE} ${project.sdkRange} already declared`, + }) + return + } + const workspace = + project.monorepo !== null && + project.monorepo.workspace !== null && + project.monorepo.workspaceName !== null + ? { + name: project.monorepo.workspaceName, + dir: project.monorepo.workspace, + } + : undefined + const line = addCommand( + project.packageManager, + [`${SDK_PACKAGE}@${SDK_RANGE}`], + workspace === undefined ? {} : { workspace } + ) + const base = project.monorepo?.root ?? project.root + const command = formatCommandLine(line) + if (!options.install) { + steps.push({ id: 'install', status: 'skipped', detail: '--no-install' }) + nextSteps.push({ + id: 'install-sdk', + actor: 'agent', + kind: 'run-command', + command, + title: `Install ${SDK_PACKAGE} (the generated files import it)`, + blocking: true, + }) + return + } + if (options.dryRun) { + steps.push({ id: 'install', status: 'planned', detail: command }) + return + } + return { line, cwd: join(base, line.cwd ?? ''), command } +} + +async function runInstall( + ctx: CliContext, + install: InstallPlan, + sink: { + readonly steps: InitStep[] + readonly nextSteps: NextStep[] + readonly spawn: ReturnType + } +): Promise { + progress(ctx, `Installing: ${install.command}`) + let failure: string | undefined + try { + const result = await sink.spawn(install.line.command, install.line.args, { + cwd: install.cwd, + signal: ctx.signal, + }) + if (result.code !== 0) { + failure = `${install.line.command} exited ${result.code ?? 'by a signal'}` + } + } catch (error) { + ctx.signal.throwIfAborted() + failure = `${install.line.command} could not start (${error instanceof Error ? error.message : String(error)})` + } + if (failure === undefined) { + sink.steps.push({ id: 'install', status: 'done', detail: install.command }) + return + } + sink.steps.push({ id: 'install', status: 'failed', detail: failure }) + sink.nextSteps.push({ + id: 'install-sdk', + actor: 'agent', + kind: 'run-command', + command: install.command, + title: `Install ${SDK_PACKAGE} (the generated files import it)`, + blocking: true, + }) +} + +function projectNextSteps(input: { + readonly project: ProjectDetection + readonly nextSteps: NextStep[] + readonly siteUrl: string + readonly paths: { readonly client: string | null } + readonly prefix: string +}): void { + const { project, nextSteps } = input + if (project.auth === 'supabase') { + nextSteps.push( + { + id: 'deploy-supabase-hook', + actor: 'agent', + kind: 'run-command', + command: 'supabase functions deploy send-email --no-verify-jwt', + title: 'Deploy the Send Email Hook function', + blocking: true, + }, + { + id: 'connect-supabase-hook', + actor: 'human', + kind: 'open-url', + url: 'https://supabase.com/dashboard/project/_/auth/hooks', + title: + "In Supabase, set Authentication > Hooks > Send Email to the send-email function's URL; store the secret it shows as SEND_EMAIL_HOOK_SECRET and BREW_API_KEY (from .env.local) in the Edge Function secrets", + blocking: true, + } + ) + } + if (project.auth === 'clerk') { + nextSteps.push({ + id: 'connect-clerk', + actor: 'human', + kind: 'open-url', + url: `${input.siteUrl}/integrations/clerk`, + title: + 'Clerk keeps sending its own auth emails. Connect Clerk in Brew to start lifecycle automations from Clerk events (or call sendWelcomeEmail from your webhook)', + blocking: false, + }) + } + if (project.runtime === 'workers') { + nextSteps.push({ + id: 'worker-secret', + actor: 'human', + kind: 'set-secret', + command: 'wrangler secret put BREW_API_KEY', + title: + 'Store BREW_API_KEY as a Worker secret (and in .dev.vars for wrangler dev); the value is in .env.local', + blocking: true, + }) + } + if (project.runtime === 'browser' && project.auth !== 'supabase') { + nextSteps.push({ + id: 'server-needed', + actor: 'agent', + kind: 'review', + title: + 'This app runs only in the browser: send Brew email from a server (an API route, Supabase Edge Functions), never with the key in client code', + blocking: true, + }) + } + if ( + input.paths.client !== null && + project.runtime === 'node' && + (project.framework === 'express' || + project.framework === 'hono' || + project.framework === 'node') + ) { + nextSteps.push({ + id: 'load-env-local', + actor: 'agent', + kind: 'review', + title: + 'This server does not read .env.local by itself: start it with `node --env-file=.env.local …` (Node 20.6+) or load the file with dotenv before getBrew() runs', + blocking: true, + }) + } + if (input.paths.client !== null && project.runtime === 'node') { + nextSteps.push({ + id: 'production-env', + actor: 'human', + kind: 'set-secret', + title: + "Add BREW_API_KEY to your hosting provider's environment (Vercel, Netlify, Railway, …); the value is in .env.local", + blocking: false, + }) + } +} + +async function sendTestEmail(input: { + readonly ctx: CliContext + readonly client: BrewClient | undefined + readonly options: InitOptions + readonly emails: readonly InitEmail[] + readonly to: string | undefined + readonly idempotencyKey: (body: unknown) => string + readonly steps: InitStep[] + readonly nextSteps: NextStep[] + readonly prefix: string +}): Promise { + const { ctx, client, options, steps } = input + const ready = input.emails.filter( + (email) => email.ok && email.published !== false + ) + const key = + ready.find((email) => email.key === 'lifecycle.welcome')?.key ?? + ready.find((email) => email.key === 'auth.verify-email')?.key ?? + null + const to = input.to ?? null + if (!options.testEmail) { + steps.push({ + id: 'test-email', + status: 'skipped', + detail: '--no-test-email', + }) + return { status: 'skipped', key: null, to: null, reason: '--no-test-email' } + } + if (options.dryRun || client === undefined) { + // The key the real run picks: the welcome email, else the verify email. + const sets = options.sets ?? ['auth', 'lifecycle'] + const planned = sets.includes('lifecycle') + ? 'lifecycle.welcome' + : 'auth.verify-email' + steps.push({ + id: 'test-email', + status: 'planned', + detail: `${planned} to ${to ?? 'the person who signs in'}`, + }) + return { status: 'planned', key: planned, to } + } + if (key === null) { + steps.push({ + id: 'test-email', + status: 'skipped', + detail: 'no published app email to send', + }) + return { + status: 'skipped', + key: null, + to, + reason: 'no published app email', + } + } + if (to === null) { + steps.push({ + id: 'test-email', + status: 'skipped', + detail: 'no recipient: pass --test-to', + }) + input.nextSteps.push({ + id: 'send-test-email', + actor: 'agent', + kind: 'run-command', + command: `${input.prefix} send ${key} --to --yes`, + title: + 'Send yourself a test email (a member of your Brew organization while in the sandbox)', + blocking: false, + }) + return { status: 'skipped', key, to: null, reason: 'no recipient' } + } + try { + progress(ctx, `Sending a test email (${key}) to ${to}…`) + const payload = { email: to } + const result = await client.send(key, payload, { + idempotencyKey: input.idempotencyKey({ key, payload }), + }) + const blocked = result.notStarted?.find( + (entry) => entry.code === 'DOMAIN_REQUIRED' + ) + if (blocked !== undefined || (result.automationRunIds?.length ?? 0) === 0) { + const reason = + blocked !== undefined + ? `${to} is not a member of your Brew organization: the sandbox sends to your team only until a domain verifies` + : 'no published automation started' + steps.push({ id: 'test-email', status: 'skipped', detail: reason }) + return { + status: 'skipped', + key, + to, + triggerInstanceId: result.triggerInstanceId, + reason, + } + } + steps.push({ id: 'test-email', status: 'done', detail: `${key} to ${to}` }) + return { + status: result.status === 'replayed' ? 'replayed' : 'sent', + key, + to, + triggerInstanceId: result.triggerInstanceId, + } + } catch (error) { + rethrowIfFatal(ctx, error) + // The setup itself is done; a test email that did not go out (say + // 429 SANDBOX_DAILY_LIMIT) is a retry for later, not a failed init. + steps.push({ + id: 'test-email', + status: 'skipped', + detail: `not sent: ${describeError(error)}`, + }) + input.nextSteps.push({ + id: 'send-test-email', + actor: 'agent', + kind: 'run-command', + command: `${input.prefix} send ${key} --to ${to} --yes`, + title: 'Send the test email again later', + blocking: false, + }) + return { status: 'failed', key, to, reason: describeError(error) } + } +} + +function agentInstructions(input: { + readonly status: InitReport['status'] + readonly paths: { + readonly client: string | null + readonly auth: string | null + readonly contracts: string | null + } + readonly keys: readonly string[] + readonly nextSteps: readonly NextStep[] +}): string { + const parts: string[] = [] + if (input.status === 'auth_required') { + return 'Show the person the verificationUrl and code from nextSteps, ask them to approve on brew.new only if the code matches, then run the resume-init command. Never ask for or print an API key.' + } + if (input.status === 'ambiguous_project') { + return 'This is a monorepo with several apps. Ask which app sends the email (or pick the web app), then run the matching init command from nextSteps.' + } + if (input.paths.client !== null) { + parts.push( + `Send app email from server code with getBrew().send(key, payload) from ${input.paths.client}${input.paths.auth === null ? '' : `; auth and welcome helpers are in ${input.paths.auth}`}.` + ) + } + if (input.paths.contracts !== null) { + parts.push( + `Payload types and the key registry are in ${input.paths.contracts}.` + ) + } + if (input.keys.length > 0) { + parts.push(`Keys: ${input.keys.join(', ')}.`) + } + parts.push( + 'BREW_API_KEY lives in .env.local: never print it, commit it, or give it a NEXT_PUBLIC_/VITE_/PUBLIC_ name.' + ) + const blocking = input.nextSteps.filter((step) => step.blocking) + if (blocking.length > 0) { + parts.push( + `Do the blocking nextSteps in order (${blocking.map((step) => step.id).join(', ')}); hand the human ones to the person.` + ) + } + return parts.join(' ') +} + +function baseReport(input: { + readonly ctx: CliContext + readonly project: ProjectDetection + readonly projectId: string | null + readonly dryRun: boolean + readonly steps: readonly InitStep[] + readonly nextSteps: readonly NextStep[] + readonly credential: Credential + readonly appKey: { + readonly status: InitReport['credential']['appKey']['status'] + readonly masked: string | null + } + readonly brand?: InitReport['brand'] + readonly domain?: InitReport['domain'] + readonly stack?: InitReport['stack'] + readonly emails?: readonly InitEmail[] + readonly files?: readonly InitFile[] + readonly patches?: readonly InitPatch[] + readonly mcp?: readonly McpInstallResult[] + readonly testEmail?: InitReport['testEmail'] + readonly paths?: { + readonly client: string | null + readonly auth: string | null + readonly contracts: string | null + } + readonly keys?: readonly string[] +}): InitReport { + const { project } = input + const status = overallStatus({ + dryRun: input.dryRun, + steps: input.steps, + nextSteps: input.nextSteps, + }) + const siteUrl = siteUrlFromApiUrl( + resolveAuth({ + globals: input.ctx.globals, + env: input.ctx.io.env, + allowAnonymous: true, + }).apiUrl + ) + return { + schemaVersion: INIT_REPORT_SCHEMA_VERSION, + status, + dryRun: input.dryRun, + project: { + root: project.root, + name: project.name, + projectId: input.projectId, + packageManager: project.packageManager.name, + monorepo: + project.monorepo === null + ? null + : { + tool: project.monorepo.tool, + root: project.monorepo.root, + workspace: project.monorepo.workspace, + }, + framework: project.framework, + runtime: project.runtime, + auth: project.auth, + authVersion: project.authVersion, + authDetected: project.authDetected, + billing: project.billing, + existingEmail: project.existingEmail, + builder: project.builder, + typescript: project.typescript, + editors: project.editors, + remote: project.remote, + }, + brand: input.brand ?? null, + credential: { + source: input.credential.source, + apiKey: + input.credential.auth === undefined + ? null + : maskApiKey(input.credential.auth.apiKey), + appKey: { + variable: APP_KEY_VARIABLE, + file: '.env.local', + status: input.appKey.status, + masked: input.appKey.masked, + }, + }, + domain: input.domain ?? { + mode: 'unknown', + domainId: null, + name: null, + status: null, + added: false, + records: [], + settingsUrl: `${siteUrl}/domains`, + sandbox: null, + }, + stack: input.stack ?? null, + emails: input.emails ?? [], + files: input.files ?? [], + patches: input.patches ?? [], + mcp: input.mcp ?? [], + testEmail: input.testEmail ?? { status: 'skipped', key: null, to: null }, + steps: input.steps, + nextSteps: input.nextSteps, + agentInstructions: agentInstructions({ + status, + paths: input.paths ?? { client: null, auth: null, contracts: null }, + keys: input.keys ?? [], + nextSteps: input.nextSteps, + }), + } +} + +function ambiguousReport( + detected: Extract, { kind: 'ambiguous' }>, + ctx: CliContext, + dryRun: boolean +): InitReport { + const nextSteps: NextStep[] = detected.candidates.map((candidate) => ({ + id: `init-${candidate.dir.replaceAll('/', '-')}`, + actor: 'agent', + kind: 'run-command', + command: rerunCommand(ctx, { + drop: ['--resume', '--no-wait'], + add: ['--app', candidate.dir], + }), + title: `Set up ${candidate.name} (${candidate.framework})`, + blocking: true, + })) + const siteUrl = siteUrlFromApiUrl( + resolveAuth({ globals: ctx.globals, env: ctx.io.env, allowAnonymous: true }) + .apiUrl + ) + return { + schemaVersion: INIT_REPORT_SCHEMA_VERSION, + status: 'ambiguous_project', + dryRun, + project: { + root: detected.root, + name: detected.root.split(/[\\/]/).at(-1) ?? detected.root, + projectId: null, + packageManager: null, + monorepo: { + tool: detected.monorepo.tool, + root: detected.monorepo.root, + workspace: null, + }, + candidates: detected.candidates.map((candidate) => ({ + name: candidate.name, + dir: candidate.dir, + framework: candidate.framework, + })), + framework: null, + runtime: null, + auth: null, + authVersion: null, + authDetected: [], + billing: [], + existingEmail: [], + builder: null, + typescript: false, + editors: [], + remote: null, + }, + brand: null, + credential: { + source: 'none', + apiKey: null, + appKey: { + variable: APP_KEY_VARIABLE, + file: '.env.local', + status: 'missing', + masked: null, + }, + }, + domain: { + mode: 'unknown', + domainId: null, + name: null, + status: null, + added: false, + records: [], + settingsUrl: `${siteUrl}/domains`, + sandbox: null, + }, + stack: null, + emails: [], + files: [], + patches: [], + mcp: [], + testEmail: { status: 'skipped', key: null, to: null }, + steps: [ + { + id: 'detect', + status: 'failed', + detail: `${detected.candidates.length} apps in this monorepo: pick one with --app`, + }, + ], + nextSteps, + agentInstructions: agentInstructions({ + status: 'ambiguous_project', + paths: { client: null, auth: null, contracts: null }, + keys: [], + nextSteps, + }), + } +} + +function finish(report: InitReport): CommandOutcome { + const exitCode = exitCodeFor(report.status) + return { + data: report, + human: renderReport(report), + ...(exitCode === 0 ? {} : { exitCode }), + } +} + +type DkimSelectorInUse = { readonly suggestedNames: readonly string[] } + +/** + * The `DKIM_SELECTOR_IN_USE` warning on a domain just added (brew-v2 + * #2169): the domain already publishes a different DKIM key where Brew asks + * for its record, and `suggestedNames` are subdomains to add instead. + */ +function dkimSelectorInUse(row: unknown): DkimSelectorInUse | undefined { + if ( + typeof row !== 'object' || + row === null || + !('warnings' in row) || + !Array.isArray(row.warnings) + ) { + return + } + const warnings: unknown[] = row.warnings + for (const warning of warnings) { + if ( + typeof warning === 'object' && + warning !== null && + 'code' in warning && + warning.code === 'DKIM_SELECTOR_IN_USE' + ) { + const names: unknown[] = + 'suggestedNames' in warning && Array.isArray(warning.suggestedNames) + ? warning.suggestedNames + : [] + return { + suggestedNames: names.filter( + (name): name is string => typeof name === 'string' + ), + } + } + } + return +} + +function shownUrl(row: unknown, fallback: string): string { + return typeof row === 'object' && + row !== null && + 'domainUrl' in row && + typeof row.domainUrl === 'string' + ? row.domainUrl + : fallback +} diff --git a/src/lib/init/report.ts b/src/lib/init/report.ts new file mode 100644 index 0000000..96ce1e2 --- /dev/null +++ b/src/lib/init/report.ts @@ -0,0 +1,311 @@ +import type { McpInstallResult } from '../../commands/mcp/install' +import { EXIT_AUTH, EXIT_OK, EXIT_RUNTIME, EXIT_USAGE } from '../errors' +import type { ChangeAction } from '../project/fs-writer' +import type { AuthId, ExistingEmail, FrameworkId, Runtime } from './detect' +import type { InitPatch } from './patches' + +/** + * `init`'s result: one JSON document (schema `schemas/init-report.json`, + * `schemaVersion: 1`) that an agent can act on without reading prose. + * Secrets never appear in it: keys are masked, `.env.local` carries no + * diff, and every command it suggests resolves credentials itself. + */ + +export const INIT_REPORT_SCHEMA_VERSION = 1 + +export type InitStatus = + /** A dry run: the plan, nothing changed. */ + | 'planned' + /** Everything is set up; any next steps are optional. */ + | 'complete' + /** Set up, but a blocking next step remains (a patch, a deploy). */ + | 'action_required' + /** A sign-in must be approved first (exit 3). */ + | 'auth_required' + /** The cwd is a monorepo root with several apps (exit 2). */ + | 'ambiguous_project' + /** A step failed (exit 1); the rest of the report says what happened. */ + | 'failed' + +export type StepStatus = 'done' | 'skipped' | 'planned' | 'pending' | 'failed' + +export type InitStepId = + | 'detect' + | 'sign-in' + | 'env' + | 'brand' + | 'domain' + | 'kit' + | 'install' + | 'files' + | 'patches' + | 'mcp' + | 'agent-files' + | 'state' + | 'test-email' + +export type InitStep = { + readonly id: InitStepId + readonly status: StepStatus + readonly detail?: string +} + +export type NextStepKind = + | 'run-command' + | 'open-url' + | 'apply-patch' + | 'edit-file' + | 'set-secret' + | 'add-dns-records' + | 'review' + +export type NextStep = { + readonly id: string + readonly actor: 'agent' | 'human' + readonly kind: NextStepKind + readonly title: string + /** True when the app does not send its emails until this is done. */ + readonly blocking: boolean + readonly command?: string + readonly url?: string + readonly path?: string + readonly patchId?: string +} + +export type InitEmail = { + readonly key: string + readonly ok: boolean + readonly triggerEventId?: string + readonly automationId?: string + readonly published?: boolean + readonly sender?: 'domain' | 'sandbox' + readonly created?: boolean + readonly error?: { readonly code: string; readonly message: string } +} + +export type InitFile = { + readonly path: string + readonly action: ChangeAction | 'planned' + readonly role: string + readonly note?: string +} + +export type DnsRecord = { + readonly type: string + readonly name: string + readonly value: string + readonly priority?: number +} + +export type InitReport = { + readonly schemaVersion: 1 + readonly status: InitStatus + readonly dryRun: boolean + readonly project: { + readonly root: string + readonly name: string + readonly projectId: string | null + readonly packageManager: string | null + readonly monorepo: { + readonly tool: string + readonly root: string + readonly workspace: string | null + } | null + readonly candidates?: ReadonlyArray<{ + readonly name: string + readonly dir: string + readonly framework: string + }> + readonly framework: FrameworkId | null + readonly runtime: Runtime | null + readonly auth: AuthId | null + readonly authVersion: 'v4' | 'v5' | null + readonly authDetected: readonly AuthId[] + readonly billing: readonly string[] + readonly existingEmail: readonly ExistingEmail[] + readonly builder: string | null + readonly typescript: boolean + readonly editors: readonly string[] + readonly remote: string | null + } + readonly brand: { + readonly brandId: string + readonly name: string | null + } | null + readonly credential: { + /** Where this run's API key came from. */ + readonly source: 'flag' | 'env' | 'config' | 'env-file' | 'browser' | 'none' + /** Masked: `brew_abc…xyz`. */ + readonly apiKey: string | null + readonly appKey: { + readonly variable: string + readonly file: string + readonly status: 'existing' | 'written' | 'planned' | 'missing' + readonly masked: string | null + } + } + readonly domain: { + readonly mode: 'domain' | 'sandbox' | 'unknown' + readonly domainId: string | null + readonly name: string | null + readonly status: string | null + readonly added: boolean + readonly records: readonly DnsRecord[] + readonly settingsUrl: string + readonly sandbox: { readonly note: string } | null + } + readonly stack: { + readonly provider: string + readonly authDelivery: string | null + readonly note: string | null + } | null + readonly emails: readonly InitEmail[] + readonly files: readonly InitFile[] + readonly patches: readonly InitPatch[] + readonly mcp: readonly McpInstallResult[] + readonly testEmail: { + readonly status: 'sent' | 'replayed' | 'skipped' | 'planned' | 'failed' + readonly key: string | null + readonly to: string | null + readonly triggerInstanceId?: string + readonly reason?: string + } + readonly steps: readonly InitStep[] + readonly nextSteps: readonly NextStep[] + readonly agentInstructions: string +} + +/** The exit code that goes with a report's status. */ +export function exitCodeFor(status: InitStatus): number { + switch (status) { + case 'failed': + return EXIT_RUNTIME + case 'ambiguous_project': + return EXIT_USAGE + case 'auth_required': + return EXIT_AUTH + default: + return EXIT_OK + } +} + +/** The overall status, from the steps and what is left to do. */ +export function overallStatus(input: { + readonly dryRun: boolean + readonly steps: readonly InitStep[] + readonly nextSteps: readonly NextStep[] +}): InitStatus { + if (input.steps.some((step) => step.status === 'pending')) { + return 'auth_required' + } + if (input.steps.some((step) => step.status === 'failed')) { + return 'failed' + } + if (input.dryRun) { + return 'planned' + } + return input.nextSteps.some((step) => step.blocking) + ? 'action_required' + : 'complete' +} + +const STEP_LABELS: Readonly> = { + detect: 'Detect project', + 'sign-in': 'Sign in', + env: 'App key in .env.local', + brand: 'Brand', + domain: 'Sending domain', + kit: 'Starter kit', + install: 'Install @brew.new/sdk', + files: 'Write files', + patches: 'Auth config patches', + mcp: 'MCP clients', + 'agent-files': 'AGENTS.md and skills', + state: 'brew.json', + 'test-email': 'Test email', +} + +const STATUS_MARKS: Readonly> = { + done: 'done', + skipped: 'skipped', + planned: 'planned', + pending: 'waiting', + failed: 'FAILED', +} + +const HEADLINES: Readonly> = { + planned: 'Plan (dry run, nothing changed):', + complete: 'Brew is set up.', + action_required: 'Brew is set up. Finish the steps marked "needed".', + auth_required: 'Sign in to finish setting up Brew.', + ambiguous_project: 'This is a monorepo with several apps: pick one.', + failed: 'Setup did not finish.', +} + +/** The human rendering (TTY): steps, files, patches, next steps. */ +export function renderReport(report: InitReport): string { + const lines: string[] = [HEADLINES[report.status], ''] + const { project } = report + if (project.framework !== null) { + lines.push( + `Project: ${project.name} (${project.framework}${project.auth === null || project.auth === 'none' ? '' : `, ${project.auth}${project.authVersion === null ? '' : ` ${project.authVersion}`}`}${project.packageManager === null ? '' : `, ${project.packageManager}`})` + ) + } + if (report.brand !== null) { + lines.push(`Brand: ${report.brand.name ?? report.brand.brandId}`) + } + if (project.candidates !== undefined) { + for (const candidate of project.candidates) { + lines.push(` ${candidate.dir} (${candidate.framework})`) + } + } + if (report.steps.length > 0) { + lines.push('') + for (const step of report.steps) { + lines.push( + ` ${STATUS_MARKS[step.status].padEnd(8)} ${STEP_LABELS[step.id]}${step.detail === undefined ? '' : `: ${step.detail}`}` + ) + } + } + const changed = report.files.filter((file) => file.action !== 'unchanged') + if (changed.length > 0) { + lines.push('', 'Files:') + for (const file of changed) { + lines.push( + ` ${file.action.padEnd(17)} ${file.path}${file.note === undefined ? '' : ` (${file.note})`}` + ) + } + } + for (const patch of report.patches) { + if (patch.status === 'pending' && patch.diff !== undefined) { + lines.push( + '', + `Patch ${patch.id} (${patch.path}): ${patch.title}`, + patch.diff.trimEnd() + ) + } else if (patch.status === 'manual' && patch.snippet !== undefined) { + lines.push( + '', + `Add by hand to ${patch.path}: ${patch.title}`, + patch.description, + patch.snippet + ) + } + } + if (report.domain.records.length > 0) { + lines.push('', `DNS records for ${report.domain.name ?? 'your domain'}:`) + for (const record of report.domain.records) { + lines.push(` ${record.type.padEnd(6)} ${record.name} ${record.value}`) + } + } + if (report.nextSteps.length > 0) { + lines.push('', 'Next:') + for (const step of report.nextSteps) { + const target = step.command ?? step.url ?? step.path + lines.push( + ` ${step.blocking ? 'needed ' : 'optional'} ${step.title}${target === undefined ? '' : `\n ${target}`}` + ) + } + } + return lines.join('\n') +} diff --git a/src/lib/init/templates.ts b/src/lib/init/templates.ts new file mode 100644 index 0000000..2c93c33 --- /dev/null +++ b/src/lib/init/templates.ts @@ -0,0 +1,476 @@ +import { posix } from 'node:path' +import { type AuthId, type ProjectDetection, rangeVersion } from './detect' + +/** + * The files `init` writes into an app. Each template is deterministic (same + * detection, same bytes), so a re-run finds them `unchanged` and the hashes + * in `brew.json` keep matching. Every file is create-only: once someone + * edits one, init leaves it alone. + */ + +/** The SDK range generated code and the install step ask for. */ +// The first SDK release with `appEmails` and the auth adapter subpaths +// (GetBrew/typescript-sdk#64, planned 13.6.0, on top of #55's `send` in +// 13.5.0; see the app email SDK gate in RELEASING.md). Generated code uses +// the adapters, so it needs 13.6.0. npm's 13.3.0 has none of them. +export const SDK_RANGE = '^13.6.0' +export const SDK_PACKAGE = '@brew.new/sdk' + +/** + * Whether a declared `@brew.new/sdk` range already reaches SDK_RANGE. An + * older one lacks the imports and calls the generated code makes, so init + * and migrate install SDK_RANGE over it. + */ +export function reachesSdkRange(declared: string | null | undefined): boolean { + const version = rangeVersion(declared ?? undefined) + const needed = rangeVersion(SDK_RANGE) + return ( + version !== null && + needed !== null && + (version[0] > needed[0] || + (version[0] === needed[0] && version[1] >= needed[1])) + ) +} + +/** The project-facing command, for files teammates commit and read. */ +// `@brew.new/cli` is on npm; the `brew.new` launcher name is not ours yet. +export const PROJECT_COMMAND = 'npx @brew.new/cli' + +export const APP_KEY_VARIABLE = 'BREW_API_KEY' +export const SUPABASE_HOOK_SECRET_VARIABLE = 'SEND_EMAIL_HOOK_SECRET' +export const SUPABASE_FUNCTION_PATH = 'supabase/functions/send-email/index.ts' + +const HEADER = `// Generated by \`${PROJECT_COMMAND} init\`. Yours to edit: init never overwrites a file you changed.` + +export type GeneratedFile = { + /** Root-relative POSIX path. */ + readonly path: string + readonly content: string + /** What the file is, for the report. */ + readonly role: 'client' | 'auth' | 'contracts' | 'supabase-hook' | 'skill' +} + +/** `ts` or `js`, from whether the project uses TypeScript. */ +export function sourceExtension(project: ProjectDetection): 'js' | 'ts' { + return project.typescript ? 'ts' : 'js' +} + +/** + * The suffix a relative import needs: Node ESM resolution (an Express or + * Hono server with `"type": "module"`) wants `.js`; bundlers want none. + */ +export function importSuffix(project: ProjectDetection): string { + const isNodeServer = + project.framework === 'express' || + project.framework === 'hono' || + project.framework === 'node' + return isNodeServer && project.esm && project.runtime === 'node' ? '.js' : '' +} + +/** A relative import specifier from one root-relative file to another. */ +export function relativeImport( + fromFile: string, + toFileWithoutExtension: string, + suffix: string +): string { + const rel = posix.relative(posix.dirname(fromFile), toFileWithoutExtension) + return `${rel.startsWith('.') ? rel : `./${rel}`}${suffix}` +} + +export function clientPath(project: ProjectDetection): string | null { + return project.libDir === null + ? null + : `${project.libDir}/brew.${sourceExtension(project)}` +} + +export function authHelperPath(project: ProjectDetection): string | null { + return project.libDir === null || project.runtime === 'deno' + ? null + : `${project.libDir}/brew-auth.${sourceExtension(project)}` +} + +/** + * Where the payload types and the `brew.send` key registry go: TypeScript + * projects with server code only (a `.d.ts`-style augmentation means + * nothing to JavaScript, and Deno resolves `npm:` specifiers instead). + */ +export function contractsPath(project: ProjectDetection): string | null { + return project.libDir === null || + !project.typescript || + project.runtime === 'deno' + ? null + : `${project.libDir}/brew-contracts.ts` +} + +/** `lib/brew.ts`: the lazily created client. */ +export function clientFile(project: ProjectDetection): string { + const ts = project.typescript + if (project.runtime === 'workers') { + return [ + HEADER, + ts + ? "import { type BrewClient, createBrewClient } from '@brew.new/sdk'" + : "import { createBrewClient } from '@brew.new/sdk'", + '', + ts + ? 'const clients = new Map()' + : 'const clients = new Map()', + '', + '/**', + ' * The Brew client for a Worker request: pass `c.env`. BREW_API_KEY is a', + ' * Worker secret (`wrangler secret put BREW_API_KEY`; `.dev.vars` locally).', + ' */', + ts + ? 'export function getBrew(env: { BREW_API_KEY?: string }): BrewClient {' + : 'export function getBrew(env) {', + ' const apiKey = env.BREW_API_KEY', + " if (apiKey === undefined || apiKey === '') {", + " throw new Error('BREW_API_KEY is not set: run `wrangler secret put BREW_API_KEY`.')", + ' }', + ' let client = clients.get(apiKey)', + ' if (client === undefined) {', + ' client = createBrewClient({ apiKey })', + ' clients.set(apiKey, client)', + ' }', + ' return client', + '}', + '', + ].join('\n') + } + if (project.runtime === 'deno') { + return [ + HEADER, + `import { createBrewClient } from 'npm:${SDK_PACKAGE}@${SDK_RANGE}'`, + '', + '/** The Brew client for Edge Functions (BREW_API_KEY is a function secret). */', + 'export function getBrew() {', + " const apiKey = Deno.env.get('BREW_API_KEY')", + ' if (!apiKey) {', + " throw new Error('BREW_API_KEY is not set: add it to the Edge Function secrets.')", + ' }', + ' return createBrewClient({ apiKey })', + '}', + '', + ].join('\n') + } + // Where server code finds BREW_API_KEY: SvelteKit and Astro read .env + // files through Vite, not into process.env. + const sveltekit = project.framework === 'sveltekit' + const keyRead = + project.framework === 'astro' + ? 'import.meta.env.BREW_API_KEY ?? process.env.BREW_API_KEY' + : sveltekit + ? 'env.BREW_API_KEY' + : 'process.env.BREW_API_KEY' + return [ + HEADER, + ts + ? "import { type BrewClient, createBrewClient } from '@brew.new/sdk'" + : "import { createBrewClient } from '@brew.new/sdk'", + ...(sveltekit ? ["import { env } from '$env/dynamic/private'"] : []), + '', + ts ? 'let client: BrewClient | undefined' : 'let client', + '', + '/**', + ' * The Brew client for server code, created on first use, so a missing', + ' * BREW_API_KEY fails the send that needs it rather than the import.', + ' * Server-only: never import this from code that runs in the browser.', + ' */', + ts + ? 'export function getBrew(): BrewClient {' + : 'export function getBrew() {', + ' if (client === undefined) {', + ` const apiKey = ${keyRead}`, + " if (apiKey === undefined || apiKey === '') {", + " throw new Error('BREW_API_KEY is not set: add it to .env.local (npx @brew.new/cli init writes it).')", + ' }', + ' client = createBrewClient({ apiKey })', + ' }', + ' return client', + '}', + '', + ].join('\n') +} + +type HelperContext = { + readonly ts: boolean + /** Workers: helpers take the request's `env` first. */ + readonly workers: boolean +} + +function envParam(context: HelperContext): string { + if (!context.workers) { + return '' + } + return context.ts ? 'env: { BREW_API_KEY?: string }, ' : 'env, ' +} + +function brewCall(context: HelperContext): string { + return context.workers ? 'getBrew(env)' : 'getBrew()' +} + +/** The welcome-email helper (only when the lifecycle set is set up). */ +function welcomeHelper(context: HelperContext): string[] { + const { ts } = context + return [ + '/**', + ' * Send `lifecycle.welcome` once per user (the idempotency key is the user', + ' * id, so a retried sign-up never sends twice).', + ' */', + ts + ? `export async function sendWelcomeEmail(${envParam(context)}user: {` + : `export async function sendWelcomeEmail(${envParam(context)}user) {`, + ...(ts + ? [ + ' id: string', + ' email: string', + ' name?: string | null', + '}): Promise {', + ] + : []), + ` await ${brewCall(context)}.send(`, + " 'lifecycle.welcome',", + ' { email: user.email, ...(user.name ? { userName: user.name } : {}) },', + // biome-ignore lint/suspicious/noTemplateCurlyInString: generated source, a template literal on purpose + ' { idempotencyKey: `lifecycle.welcome:${user.id}` }', + ' )', + '}', + ] +} + +/** A typed pass-through for the auth keys, for apps without an auth library. */ +function sendAuthHelper(context: HelperContext): string[] { + const { ts } = context + return [ + '/**', + ' * Send a starter kit auth email by key, e.g.', + // biome-ignore lint/suspicious/noTemplateCurlyInString: generated source, a template literal on purpose + " * sendAuthEmail('auth.password-reset', { email, confirmationUrl }, `auth.password-reset:${tokenId}`).", + ' * Derive the idempotency key from the token so a retry never sends twice.', + ' */', + ...(ts + ? [ + 'export async function sendAuthEmail(', + ...(context.workers ? [' env: { BREW_API_KEY?: string },'] : []), + ' key: K,', + ' payload: BrewSendPayloadFor,', + ' idempotencyKey?: string', + '): Promise {', + ] + : [ + `export async function sendAuthEmail(${envParam(context)}key, payload, idempotencyKey) {`, + ]), + ` await ${brewCall(context)}.send(`, + ' key,', + ' payload,', + ' idempotencyKey === undefined ? undefined : { idempotencyKey }', + ' )', + '}', + ] +} + +const AUTH_NOTES: Readonly< + Record, string> +> = { + supabase: + 'Supabase Auth emails go through the Send Email Hook in supabase/functions/send-email.', + clerk: + 'Clerk keeps sending its own sign-in and verification emails; connect Clerk in Brew (Integrations) to start lifecycle automations from Clerk events, or send them from your code.', + firebase: + 'Firebase Auth keeps sending its own auth emails; Brew sends your lifecycle emails from your code.', + workos: + 'WorkOS keeps sending its own auth emails; Brew sends your lifecycle emails from your code.', + stytch: + 'Stytch keeps sending its own auth emails; Brew sends your lifecycle emails from your code.', + none: 'No auth library detected: send auth emails from your own code with sendAuthEmail.', +} + +export type AuthHelperOptions = { + /** The lifecycle set is set up, so `lifecycle.welcome` exists. */ + readonly lifecycle: boolean +} + +/** + * Whether `lib/brew-auth.ts` has anything to say: the auth adapters and the + * `sendAuthEmail` pass-through always do; a provider that sends its own + * auth emails only has the welcome helper, so only with the lifecycle set. + */ +export function wantsAuthHelper( + project: ProjectDetection, + options: AuthHelperOptions +): boolean { + return ( + project.auth === 'better-auth' || + project.auth === 'authjs' || + project.auth === 'none' || + options.lifecycle + ) +} + +/** `lib/brew-auth.ts`, per auth library. */ +export function authHelperFile( + project: ProjectDetection, + options: AuthHelperOptions +): string { + const context: HelperContext = { + ts: project.typescript, + workers: project.runtime === 'workers', + } + const clientImport = `import { getBrew } from './brew${importSuffix(project)}'` + const welcome = options.lifecycle ? ['', ...welcomeHelper(context)] : [] + switch (project.auth) { + case 'better-auth': { + const after = (project.nextMajor ?? 0) >= 15 + return [ + HEADER, + "import { brewAuthEmails } from '@brew.new/sdk/better-auth'", + ...(after ? ["import { after } from 'next/server'"] : []), + clientImport, + '', + '/**', + " * Better Auth's email callbacks, sent with the starter kit's keys", + ' * (auth.verify-email, auth.password-reset, auth.email-change,', + ' * auth.magic-link, auth.otp, lifecycle.welcome). Wire them into', + ' * betterAuth({ … }): `npx @brew.new/cli init` prints the patch.', + ...(after + ? [ + ' * Sends run after the response (`after`), so sign-up never waits on email.', + ] + : []), + ' */', + 'export const brewEmails = brewAuthEmails({', + ' client: getBrew,', + ...(after ? [' waitUntil: after,'] : []), + '})', + '', + ].join('\n') + } + case 'authjs': { + const v4 = project.authVersion === 'v4' + return [ + HEADER, + "import { BrewEmail } from '@brew.new/sdk/next-auth'", + clientImport, + '', + '/**', + ' * Auth.js email sign-in through Brew (`auth.magic-link`). Add it to', + ` * \`providers\` and sign in with signIn('${v4 ? 'email' : 'brew'}', { email }).`, + ' * Email sign-in needs a database adapter.', + ' */', + `export const brewEmailProvider = BrewEmail({ client: getBrew${v4 ? ", id: 'email'" : ''} })`, + ...welcome, + '', + ].join('\n') + } + case 'none': + return [ + HEADER, + ...(context.ts + ? [ + "import type { BrewSendKey, BrewSendPayloadFor } from '@brew.new/sdk'", + ] + : []), + clientImport, + '', + `// ${AUTH_NOTES.none}`, + '', + ...sendAuthHelper(context), + ...welcome, + '', + ].join('\n') + default: + return [ + HEADER, + clientImport, + '', + `// ${AUTH_NOTES[project.auth]}`, + ...welcome, + '', + ].join('\n') + } +} + +/** `supabase/functions/send-email/index.ts`: the Send Email Hook. */ +export function supabaseHookFile(): string { + return [ + HEADER, + '// Supabase Auth Send Email Hook: every auth email goes out through Brew.', + '// Deploy: supabase functions deploy send-email --no-verify-jwt', + '// Then in Supabase: Authentication > Hooks > Send Email, point it at this', + "// function's URL and store the secret it shows as SEND_EMAIL_HOOK_SECRET", + '// (and BREW_API_KEY) in the Edge Function secrets.', + `import { createBrewClient } from 'npm:${SDK_PACKAGE}@${SDK_RANGE}'`, + `import { createSupabaseSendEmailHook } from 'npm:${SDK_PACKAGE}@${SDK_RANGE}/supabase'`, + '', + 'Deno.serve(', + ' createSupabaseSendEmailHook({', + " client: () => createBrewClient({ apiKey: Deno.env.get('BREW_API_KEY') ?? '' }),", + " secret: Deno.env.get('SEND_EMAIL_HOOK_SECRET') ?? '',", + ' })', + ')', + '', + ].join('\n') +} + +/** The `supabase/config.toml` block that turns the hook on locally. */ +export const SUPABASE_CONFIG_BLOCK = [ + '[auth.hook.send_email]', + 'enabled = true', + 'uri = "http://host.docker.internal:54321/functions/v1/send-email"', + 'secrets = "env(SEND_EMAIL_HOOK_SECRET)"', + '', + '[functions.send-email]', + 'verify_jwt = false', +].join('\n') + +/** The `AGENTS.md` block: how agents in this repo send app email. */ +export function agentsBlock(input: { + readonly project: ProjectDetection + readonly keys: readonly string[] + readonly files: { + readonly client: string | null + readonly auth: string | null + readonly contracts: string | null + } + readonly skillPaths: readonly string[] +}): string { + const { files } = input + const lines = ['## Brew (app email)', ''] + if (files.client !== null) { + lines.push( + `This app sends email through Brew: server code calls \`getBrew().send(key, payload)\` from \`${files.client}\`${files.auth === null ? '' : ` (auth and welcome helpers in \`${files.auth}\`)`}.` + ) + } else if (input.project.runtime === 'deno' || input.project.hasSupabaseDir) { + lines.push( + `This app sends auth email through Brew from Supabase Edge Functions (\`${SUPABASE_FUNCTION_PATH}\`).` + ) + } else { + lines.push( + 'This app sends email through Brew from its server: call `brew.send(key, payload)` from `@brew.new/sdk` there, never from browser code.' + ) + } + if (files.contracts !== null) { + lines.push( + `Payload types and the key registry live in \`${files.contracts}\`; regenerate with \`${PROJECT_COMMAND} types --out ${files.contracts}\`.` + ) + } + lines.push('') + if (input.keys.length > 0) { + lines.push(`- Keys: ${input.keys.map((key) => `\`${key}\``).join(', ')}.`) + } + lines.push( + '- `BREW_API_KEY` is in `.env.local` (git-ignored). Never give it a `NEXT_PUBLIC_`, `VITE_` or `PUBLIC_` name.', + '- Until a sending domain verifies, app emails reach members of the Brew organization only (sandbox).', + `- A new email: \`${PROJECT_COMMAND} app-emails upsert --source-file --yes\`, then send it by key.`, + `- Re-run \`${PROJECT_COMMAND} init\` to update; it never overwrites files you changed.` + ) + for (const path of input.skillPaths) { + lines.push(`- Skill: \`${path}\`.`) + } + return lines.join('\n') +} + +/** Where an embedded skill is installed in the project. */ +export function skillInstallPath(name: string): string { + return `.claude/skills/${name}/SKILL.md` +} diff --git a/src/registry.ts b/src/registry.ts index 975c8ae..1527688 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -138,6 +138,7 @@ import { fieldsListCommand } from './commands/fields/list' import { flowsGetCommand } from './commands/flows/get' import { flowsListCommand } from './commands/flows/list' import { healthCommand } from './commands/health' +import { initCommand } from './commands/init' import { insightsGetCommand } from './commands/insights/get' import { insightsListCommand } from './commands/insights/list' import { integrationsListCommand } from './commands/integrations/list' @@ -318,5 +319,6 @@ export const ALL_COMMANDS: readonly CommandSpec[] = [ docsCommand, docsApiCommand, mcpInstallCommand, + initCommand, apiCommand, ] diff --git a/tests/commands/init.test.ts b/tests/commands/init.test.ts new file mode 100644 index 0000000..f6ca6f8 --- /dev/null +++ b/tests/commands/init.test.ts @@ -0,0 +1,1164 @@ +import { + existsSync, + mkdtempSync, + readFileSync, + statSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { HttpResponse, http } from 'msw' +import { describe, expect, it } from 'vitest' +import { pendingLoginPath } from '../../src/lib/auth-flow/pending' +import { readConfig } from '../../src/lib/config-store' +import type { InitReport } from '../../src/lib/init/report' +import { validateBrewState } from '../../src/lib/project/state' +import type { SpawnFn } from '../../src/lib/types' +import { contractHandler } from '../helpers/contract-artifacts' +import { copyFixture } from '../helpers/fixtures' +import { loadSchema, validateSchema } from '../helpers/json-schema' +import { + APP_KEY, + APPROVER_EMAIL, + BRAND, + CLI_KEY, + fakeClock, + SESSION_ID, + USER_CODE, + useLoginServer, +} from '../helpers/login-server' +import { server } from '../helpers/msw-server' +import { + type RunCliOptions, + type RunCliResult, + runCli, +} from '../helpers/run-cli' + +const API = 'https://brew.new/api' +const EXPLICIT_KEY = 'brew_explicit0123456789abcdefghijklmno' +const SECRETS = [APP_KEY, CLI_KEY, EXPLICIT_KEY] +const REPORT_SCHEMA = loadSchema('init-report.json') + +const KIT_KEYS = [ + 'auth.verify-email', + 'auth.password-reset', + 'auth.magic-link', + 'lifecycle.welcome', +] + +type Recorded = { + readonly method: string + readonly path: string + readonly body: unknown + readonly idempotencyKey: string | null + readonly brandId: string | null + readonly authorization: string | null +} + +function titleOf(key: string): string { + return key + .split(/[.-]/) + .slice(1) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') +} + +/** The kit's trigger rows, as `GET /v1/automations/triggers` lists them. */ +function kitTriggerRows() { + return KIT_KEYS.map((key, index) => ({ + triggerEventId: `tri_${index}`, + key, + title: titleOf(key), + provider: 'brew_api', + payloadSchema: { + type: 'object', + fields: [ + { key: 'email', type: 'string', required: true }, + { key: 'appName', type: 'string', required: false }, + { key: 'userName', type: 'string', required: false }, + ...(key.startsWith('auth.') + ? [ + { key: 'confirmationUrl', type: 'string', required: false }, + { key: 'otp', type: 'string', required: false }, + ] + : []), + ], + }, + createdAt: '2026-10-13T12:00:00.000Z', + updatedAt: '2026-10-13T12:00:00.000Z', + })) +} + +/** Brew's v1 API for init: brand, domains, kit, triggers, the by-key fire. */ +function useBrewApi( + options: { + readonly domains?: ReadonlyArray> + readonly failKeys?: readonly string[] + /** A kit key's refusal, by key (beats `failKeys`' INTERNAL_ERROR). */ + readonly refusals?: Readonly< + Record + > + /** `warnings` on the `POST /v1/domains` row. */ + readonly addWarnings?: ReadonlyArray> + } = {} +): Recorded[] { + const requests: Recorded[] = [] + const record = async (request: Request): Promise => { + const text = await request.text() + const body = text === '' ? undefined : (JSON.parse(text) as unknown) + requests.push({ + method: request.method, + path: new URL(request.url).pathname.replace(/^\/api/, ''), + body, + idempotencyKey: request.headers.get('idempotency-key'), + brandId: request.headers.get('x-brand-id'), + authorization: request.headers.get('authorization'), + }) + return body + } + server.use( + contractHandler(API, kitTriggerRows), + http.get(`${API}/v1/brand`, async ({ request }) => { + await record(request) + return HttpResponse.json({ + brandId: BRAND.brandId, + domain: 'acme.com', + status: 'completed', + ready: true, + identity: { brandName: BRAND.name }, + }) + }), + http.get(`${API}/v1/domains`, async ({ request }) => { + await record(request) + return HttpResponse.json({ + data: options.domains ?? [], + pagination: { limit: 100, cursor: null, hasMore: false }, + }) + }), + http.post(`${API}/v1/domains`, async ({ request }) => { + const body = (await record(request)) as { name: string } + return HttpResponse.json( + { + domainId: 'dom_new', + domainUrl: 'https://brew.new/domains/dom_new', + name: body.name, + region: 'us-east-1', + status: 'not_started', + sendingEnabled: false, + sendable: false, + sendingPurpose: 'transactional', + records: [ + { + record: 'DKIM', + name: `brew._domainkey.${body.name}`, + type: 'TXT', + ttl: 'Auto', + status: 'not_started', + value: 'p=MIGf…', + }, + ], + createdAt: '2026-10-13T12:00:00.000Z', + updatedAt: '2026-10-13T12:00:00.000Z', + ...(options.addWarnings === undefined + ? {} + : { warnings: options.addWarnings }), + }, + { status: 201 } + ) + }), + http.post(`${API}/v1/app-emails/kit`, async ({ request }) => { + await record(request) + return HttpResponse.json({ + results: KIT_KEYS.map((key, index) => + options.refusals?.[key] !== undefined + ? { triggerKey: key, ok: false, error: options.refusals[key] } + : options.failKeys?.includes(key) + ? { + triggerKey: key, + ok: false, + error: { code: 'INTERNAL_ERROR', message: 'Import failed.' }, + } + : { + ok: true, + triggerKey: key, + triggerEventId: `tri_${index}`, + emailId: `eml_${index}`, + emailVersionId: `emv_${index}`, + automationId: `aut_${index}`, + published: true, + created: { trigger: true, email: true, automation: true }, + sender: { + mode: 'sandbox', + from: 'acme@email.brew.new', + sandbox: { dailyLimit: 50, remainingToday: 50 }, + }, + payloadSchema: { type: 'object', fields: [] }, + fireExample: { + method: 'POST', + path: `/v1/automations/triggers/by-key/${key}/fire`, + examplePayload: { email: 'jane@example.com' }, + curl: 'curl …', + sdk: `await brew.send('${key}', { email })`, + }, + warnings: [], + } + ), + stack: { + provider: 'better-auth', + authDelivery: 'code', + note: 'Wire the callbacks into betterAuth.', + keys: KIT_KEYS.filter((key) => key.startsWith('auth.')), + snippets: [], + }, + }) + }), + http.get(`${API}/v1/automations/triggers`, async ({ request }) => { + await record(request) + return HttpResponse.json({ + data: kitTriggerRows(), + pagination: { limit: 100, cursor: null, hasMore: false }, + }) + }), + http.post( + `${API}/v1/automations/triggers/by-key/:key/fire`, + async ({ request, params }) => { + await record(request) + return HttpResponse.json( + { + triggerInstanceId: 'tin_1', + triggerEventId: 'tri_3', + triggerKey: String(params.key), + status: 'triggered', + automationRunIds: ['run_1'], + notStarted: [], + publishedAutomations: [{ automationId: 'aut_3' }], + counts: { automations: 1, skipped: 0 }, + warnings: [], + receivedAt: '2026-10-13T12:00:00.000Z', + }, + { status: 202 } + ) + } + ) + ) + return requests +} + +type SpawnCall = { + readonly command: string + readonly args: readonly string[] + readonly cwd: string | undefined +} + +/** git is "not a repository" (the .gitignore fallback); installs succeed. */ +function recordingSpawn(): { + readonly calls: SpawnCall[] + readonly spawn: SpawnFn +} { + const calls: SpawnCall[] = [] + return { + calls, + spawn: async (command, args, options) => { + if (command === 'git') { + return { code: 128, stdout: '', stderr: 'fatal: not a git repository' } + } + calls.push({ command, args, cwd: options?.cwd }) + return { code: 0, stdout: '', stderr: '' } + }, + } +} + +function configEnv( + extra: Record = {} +): Record { + return { + BREW_CLI_CONFIG_DIR: mkdtempSync(join(tmpdir(), 'brew-cli-init-')), + ...extra, + } +} + +async function init( + argv: readonly string[], + options: RunCliOptions & { readonly cwd: string } +): Promise { + const result = await runCli(['init', ...argv], options) + // Secret canary: no key ever reaches the terminal, in any mode. + for (const secret of SECRETS) { + expect(result.stdout).not.toContain(secret) + expect(result.stderr).not.toContain(secret) + } + return { ...result, report: result.json as InitReport } +} + +function expectValidReport(report: InitReport): void { + expect(validateSchema(REPORT_SCHEMA, report)).toEqual([]) +} + +function read(root: string, path: string): string { + return readFileSync(join(root, path), 'utf8') +} + +const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']) + +describe('init: confirmation and dry run', () => { + it('exits 4 off a terminal without --yes, with the re-run command', async () => { + const root = copyFixture('next-better-auth') + const result = await init(['--json'], { cwd: root, env: configEnv() }) + expect(result.code).toBe(4) + expect(result.json).toMatchObject({ + confirmationRequired: true, + command: 'brew-cli init', + confirmCommand: 'brew-cli init --json --yes', + }) + expect(existsSync(join(root, 'brew.json'))).toBe(false) + }) + + it('--dry-run reads with an explicit key, sends zero mutating requests, writes nothing', async () => { + const root = copyFixture('next-better-auth') + const requests = useBrewApi() + const spawn = recordingSpawn() + const before = read(root, 'lib/auth.ts') + const result = await init(['--dry-run', '--json'], { + cwd: root, + env: configEnv({ BREW_API_KEY: EXPLICIT_KEY }), + spawn: spawn.spawn, + }) + expect(result.code).toBe(0) + expectValidReport(result.report) + expect(result.report.status).toBe('planned') + expect(result.report.dryRun).toBe(true) + expect(requests.filter((request) => MUTATING.has(request.method))).toEqual( + [] + ) + expect( + requests.map((request) => `${request.method} ${request.path}`) + ).toEqual([ + 'GET /v1/brand', + 'GET /v1/domains', + 'GET /v1/automations/triggers', + ]) + expect(spawn.calls).toEqual([]) + for (const path of [ + 'brew.json', + '.env.local', + 'lib/brew.ts', + 'AGENTS.md', + '.cursor/mcp.json', + ]) { + expect(existsSync(join(root, path)), path).toBe(false) + } + expect(read(root, 'lib/auth.ts')).toBe(before) + expect(result.report.credential).toMatchObject({ + source: 'env', + apiKey: 'brew_exp…mno', + appKey: { status: 'planned' }, + }) + expect(result.report.domain).toMatchObject({ + mode: 'sandbox', + sandbox: { + note: expect.stringContaining('members of your Brew organization'), + }, + }) + expect(result.report.patches.map((patch) => patch.status)).toEqual([ + 'pending', + ]) + expect( + result.report.steps.find((step) => step.id === 'install')?.detail + ).toBe("npm install '@brew.new/sdk@^13.6.0'") + }) + + it.each([ + ['^12.3.0', "npm install '@brew.new/sdk@^13.6.0'"], + ['^13.3.0', "npm install '@brew.new/sdk@^13.6.0'"], + ['^13.5.0', "npm install '@brew.new/sdk@^13.6.0'"], + ['^13.6.0', '@brew.new/sdk ^13.6.0 already declared'], + ['^14.0.0', '@brew.new/sdk ^14.0.0 already declared'], + ])( + '--dry-run with %s declared: install unless it is the generated code range or newer', + async (declared, detail) => { + const root = copyFixture('next-better-auth') + const manifestPath = join(root, 'package.json') + const manifest = JSON.parse(read(root, 'package.json')) as { + dependencies?: Record + } + manifest.dependencies = { + ...manifest.dependencies, + '@brew.new/sdk': declared, + } + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)) + useBrewApi() + const result = await init(['--dry-run', '--json'], { + cwd: root, + env: configEnv({ BREW_API_KEY: EXPLICIT_KEY }), + spawn: recordingSpawn().spawn, + }) + expect(result.code).toBe(0) + expect( + result.report.steps.find((step) => step.id === 'install')?.detail + ).toBe(detail) + } + ) + + it('--dry-run without any credential sends no request at all', async () => { + const root = copyFixture('next-better-auth') + const result = await init(['--dry-run', '--json'], { + cwd: root, + env: configEnv(), + }) + expect(result.code).toBe(0) + expectValidReport(result.report) + expect(result.report.status).toBe('planned') + expect(result.report.project.projectId).toBeNull() + expect( + result.report.steps.find((step) => step.id === 'sign-in') + ).toMatchObject({ status: 'planned' }) + }) +}) + +describe('init --yes: the full run', () => { + it('signs in, sets up the kit, installs, writes files and brew.json, sends the test email', async () => { + const root = copyFixture('next-better-auth') + const login = useLoginServer({ steps: ['approved'] }) + const requests = useBrewApi() + const spawn = recordingSpawn() + const env = configEnv() + const authBefore = read(root, 'lib/auth.ts') + const result = await init(['--yes', '--json'], { + cwd: root, + env, + ttyIn: true, + clock: fakeClock(), + spawn: spawn.spawn, + }) + expect(result.code).toBe(0) + const { report } = result + expectValidReport(report) + // The patch to the user's auth config is the one thing left. + expect(report.status).toBe('action_required') + + // One sign-in for both keys, named after the project. + expect(login.createBodies).toHaveLength(1) + expect(login.createBodies[0]?.keys).toEqual(['cli', 'app']) + expect(login.createBodies[0]?.client).toMatchObject({ project: 'acme-web' }) + // The CLI key goes to the config; the app key only to .env.local. + expect(readConfig(env)).toMatchObject({ + apiKey: CLI_KEY, + brandId: BRAND.brandId, + userEmail: APPROVER_EMAIL, + }) + const envLocal = read(root, '.env.local') + expect(envLocal).toBe(`BREW_API_KEY=${APP_KEY}\n`) + expect(statSync(join(root, '.env.local')).mode & 0o777).toBe(0o600) + expect(report.credential).toEqual({ + source: 'browser', + apiKey: 'brew_app…nop', + appKey: { + variable: 'BREW_API_KEY', + file: '.env.local', + status: 'written', + masked: 'brew_app…nop', + }, + }) + + const state = validateBrewState(JSON.parse(read(root, 'brew.json'))) + expect(state.ok).toBe(true) + if (!state.ok) { + return + } + const { projectId } = state.state + expect(state.state.brandId).toBe(BRAND.brandId) + expect(state.state.framework).toBe('next-app') + expect(state.state.auth).toBe('better-auth') + expect(state.state.contractsPath).toBe('lib/brew-contracts.ts') + expect(state.state.emails['lifecycle.welcome']).toEqual({ + triggerEventId: 'tri_3', + automationId: 'aut_3', + }) + expect(Object.keys(state.state.files).sort()).toEqual([ + 'lib/brew-auth.ts', + 'lib/brew-contracts.ts', + 'lib/brew.ts', + ]) + expect(state.state.mcp).toEqual(['cursor']) + expect(report.project.projectId).toBe(projectId) + + // Exact API traffic: the app key, pinned to the approved brand. + expect( + requests.map((request) => `${request.method} ${request.path}`) + ).toEqual([ + 'GET /v1/domains', + 'POST /v1/app-emails/kit', + 'GET /v1/automations/triggers', + 'POST /v1/automations/triggers/by-key/lifecycle.welcome/fire', + ]) + for (const request of requests) { + expect(request.authorization).toBe(`Bearer ${APP_KEY}`) + expect(request.brandId).toBe(BRAND.brandId) + } + const kit = requests[1] + expect(kit?.body).toEqual({ + sets: ['auth', 'lifecycle'], + stack: 'better-auth', + }) + expect(kit?.idempotencyKey).toMatch( + new RegExp(`^init:${projectId}:kit:[0-9a-f]{12}$`) + ) + const fire = requests[3] + expect(fire?.body).toEqual({ payload: { email: APPROVER_EMAIL } }) + expect(fire?.idempotencyKey).toMatch( + new RegExp(`^init:${projectId}:test-email:[0-9a-f]{12}$`) + ) + expect(report.testEmail).toEqual({ + status: 'sent', + key: 'lifecycle.welcome', + to: APPROVER_EMAIL, + triggerInstanceId: 'tin_1', + }) + + // The SDK, with the project's package manager. + expect(spawn.calls).toEqual([ + { + command: 'npm', + args: ['install', '@brew.new/sdk@^13.6.0'], + cwd: root, + }, + ]) + + // Generated files. + expect(read(root, 'lib/brew.ts')).toContain('export function getBrew()') + const authHelper = read(root, 'lib/brew-auth.ts') + expect(authHelper).toContain("import { after } from 'next/server'") + expect(authHelper).toContain('waitUntil: after,') + const contracts = read(root, 'lib/brew-contracts.ts') + expect(contracts).toContain("declare module '@brew.new/sdk' {") + expect(contracts).toContain('"lifecycle.welcome": WelcomePayload') + expect(read(root, '.env.example')).toBe('BREW_API_KEY=\n') + const agents = read(root, 'AGENTS.md') + expect(agents).toContain('') + expect(agents).toContain('`lifecycle.welcome`') + expect(agents).not.toContain(APP_KEY) + // No Claude Code here: no .claude/ directory appears. + expect(existsSync(join(root, '.claude'))).toBe(false) + expect(JSON.parse(read(root, '.cursor/mcp.json'))).toEqual({ + mcpServers: { brew: { url: 'https://brew.new/api/mcp' } }, + }) + + // The user's auth config is patched only on request. + expect(read(root, 'lib/auth.ts')).toBe(authBefore) + expect(report.patches[0]).toMatchObject({ + id: 'better-auth-config', + path: 'lib/auth.ts', + status: 'pending', + }) + expect(report.nextSteps.find((step) => step.blocking)).toMatchObject({ + id: 'apply-better-auth-config', + actor: 'agent', + kind: 'apply-patch', + patchId: 'better-auth-config', + // Pinned to the reviewed file: the hash of lib/auth.ts it was computed on. + command: `brew-cli init --json --apply-patches better-auth-config@${report.patches[0]?.baseSha256?.slice(0, 12)} --yes`, + }) + expect(report.domain).toMatchObject({ mode: 'sandbox' }) + expect(report.nextSteps.map((step) => step.id)).toContain('verify-domain') + expect(report.emails.map((email) => email.key)).toEqual(KIT_KEYS) + + // Re-run: nothing changes, the same idempotency keys, no sign-in. + const firstState = read(root, 'brew.json') + const again = useBrewApi() + const rerun = await init(['--yes', '--json'], { + cwd: root, + env, + spawn: recordingSpawn().spawn, + }) + expect(rerun.code).toBe(0) + expectValidReport(rerun.report) + expect(read(root, 'brew.json')).toBe(firstState) + expect(login.createBodies).toHaveLength(1) + expect(rerun.report.credential.source).toBe('env-file') + expect(rerun.report.credential.appKey.status).toBe('existing') + expect( + rerun.report.files + .filter((file) => file.role !== 'agents-block') + .map((file) => [file.path, file.action]) + ).toEqual([ + ['lib/brew.ts', 'unchanged'], + ['lib/brew-auth.ts', 'unchanged'], + ['lib/brew-contracts.ts', 'unchanged'], + ['.env.example', 'unchanged'], + ['brew.json', 'unchanged'], + ]) + expect( + rerun.report.files.find((file) => file.path === 'AGENTS.md')?.action + ).toBe('unchanged') + expect(rerun.report.mcp).toMatchObject([ + { client: 'cursor', action: 'unchanged' }, + ]) + const keysOf = (list: Recorded[]) => + list + .filter((request) => request.method === 'POST') + .map((request) => request.idempotencyKey) + expect(keysOf(again)).toEqual(keysOf(requests)) + expect(again.every((request) => request.brandId === BRAND.brandId)).toBe( + true + ) + }) + + it('--apply-patches wires Better Auth when the file is unchanged, and a re-run finds it applied', async () => { + const root = copyFixture('next-better-auth') + useBrewApi() + const env = configEnv({ BREW_API_KEY: EXPLICIT_KEY }) + writeFileSync(join(root, '.env.local'), `BREW_API_KEY=${APP_KEY}\n`) + const reviewed = await init( + ['--yes', '--json', '--no-test-email', '--no-install'], + { cwd: root, env, spawn: recordingSpawn().spawn } + ) + const pin = `better-auth-config@${reviewed.report.patches[0]?.baseSha256?.slice(0, 12)}` + useBrewApi() + const result = await init( + [ + '--yes', + '--json', + '--apply-patches', + pin, + '--no-test-email', + '--no-install', + ], + { cwd: root, env, spawn: recordingSpawn().spawn } + ) + expect(result.code).toBe(0) + expect(result.report.patches[0]?.status).toBe('applied') + expect(read(root, 'lib/auth.ts')).toBe( + [ + "import { betterAuth } from 'better-auth'", + "import { db } from './db'", + "import { brewEmails } from './brew-auth'", + '', + 'export const auth = betterAuth({', + ' emailVerification: { sendVerificationEmail: brewEmails.sendVerificationEmail },', + ' databaseHooks: { user: { create: { after: brewEmails.onUserCreated } } },', + ' database: db,', + ' emailAndPassword: {', + ' sendResetPassword: brewEmails.sendResetPassword,', + ' enabled: true,', + ' },', + '})', + '', + ].join('\n') + ) + // An explicit key acts; the project's own key stays where it is. + expect(result.report.credential.source).toBe('env') + expect(result.report.credential.appKey.status).toBe('existing') + expect( + result.report.nextSteps.find((step) => step.id === 'install-sdk') + ).toMatchObject({ + blocking: true, + command: "npm install '@brew.new/sdk@^13.6.0'", + }) + + useBrewApi() + const rerun = await init( + ['--yes', '--json', '--no-test-email', '--no-install'], + { + cwd: root, + env, + spawn: recordingSpawn().spawn, + } + ) + expect(rerun.report.patches[0]?.status).toBe('already-applied') + }) + + it('--apply-patches leaves a file edited since the report alone (stale)', async () => { + const root = copyFixture('next-better-auth') + useBrewApi() + const env = configEnv({ BREW_API_KEY: EXPLICIT_KEY }) + writeFileSync(join(root, '.env.local'), `BREW_API_KEY=${APP_KEY}\n`) + const reviewed = await init( + ['--yes', '--json', '--no-test-email', '--no-install'], + { cwd: root, env, spawn: recordingSpawn().spawn } + ) + const pin = `better-auth-config@${reviewed.report.patches[0]?.baseSha256?.slice(0, 12)}` + const edited = `${read(root, 'lib/auth.ts')}// edited after the review\n` + writeFileSync(join(root, 'lib/auth.ts'), edited) + useBrewApi() + const result = await init( + [ + '--yes', + '--json', + '--apply-patches', + pin, + '--no-test-email', + '--no-install', + ], + { cwd: root, env, spawn: recordingSpawn().spawn } + ) + expect(result.report.patches[0]?.status).toBe('stale') + expect(read(root, 'lib/auth.ts')).toBe(edited) + expect( + result.report.nextSteps.find( + (step) => step.id === 'apply-better-auth-config' + )?.command + ).not.toContain(pin) + }) + + it('--apply-patches refuses a value that is not @ (exit 2)', async () => { + const root = copyFixture('next-better-auth') + const result = await init(['--yes', '--json', '--apply-patches', 'all'], { + cwd: root, + env: configEnv(), + spawn: recordingSpawn().spawn, + }) + expect(result.code).toBe(2) + }) + + it('keeps a generated file someone edited, and says so', async () => { + const root = copyFixture('express-resend') + useBrewApi() + const env = configEnv({ BREW_API_KEY: EXPLICIT_KEY }) + writeFileSync(join(root, '.env.local'), `BREW_API_KEY=${APP_KEY}\n`) + const options = { cwd: root, env, spawn: recordingSpawn().spawn } + await init(['--yes', '--json', '--no-test-email'], options) + const edited = `${read(root, 'src/lib/brew.ts')}// mine\n` + writeFileSync(join(root, 'src/lib/brew.ts'), edited) + useBrewApi() + const rerun = await init(['--yes', '--json', '--no-test-email'], options) + expect(read(root, 'src/lib/brew.ts')).toBe(edited) + expect( + rerun.report.files.find((file) => file.path === 'src/lib/brew.ts') + ).toMatchObject({ + action: 'kept-user-version', + note: 'edited since the CLI wrote it', + }) + // ESM Node server: relative imports carry .js. + expect(read(root, 'src/lib/brew-auth.ts')).toContain("from './brew.js'") + }) + + it('in a Claude Code project, installs the skills and the .mcp.json entry', async () => { + const root = copyFixture('express-resend') + useBrewApi() + writeFileSync(join(root, '.env.local'), `BREW_API_KEY=${APP_KEY}\n`) + const result = await init(['--yes', '--json', '--no-test-email'], { + cwd: root, + env: configEnv({ CLAUDECODE: '1' }), + spawn: recordingSpawn().spawn, + }) + expect(result.code).toBe(0) + expect(read(root, '.claude/skills/brew-app-emails/SKILL.md')).toContain( + 'name: brew-app-emails' + ) + expect(read(root, 'AGENTS.md')).toContain( + '- Skill: `.claude/skills/brew-app-emails/SKILL.md`.' + ) + expect(result.report.mcp).toMatchObject([ + { client: 'claude-code', path: '.mcp.json', action: 'created' }, + ]) + }) + + it('a changed request gets a new idempotency key under the same project id', async () => { + const root = copyFixture('express-resend') + writeFileSync(join(root, '.env.local'), `BREW_API_KEY=${APP_KEY}\n`) + const options = { + cwd: root, + env: configEnv(), + spawn: recordingSpawn().spawn, + } + const first = useBrewApi() + const created = await init(['--yes', '--json', '--no-test-email'], options) + // No .gitignore yet: init creates one that ignores .env.local. + expect( + created.report.files.find((file) => file.path === '.gitignore') + ).toEqual({ + path: '.gitignore', + action: 'created', + role: 'gitignore', + note: 'ignores .env.local', + }) + expect(read(root, '.gitignore')).toBe( + '# brew:start\n.env.local\n# brew:end\n' + ) + const second = useBrewApi() + await init( + ['--yes', '--json', '--no-test-email', '--sets', 'lifecycle'], + options + ) + const kitKey = (list: Recorded[]) => + list.find((request) => request.path === '/v1/app-emails/kit') + ?.idempotencyKey ?? '' + expect(kitKey(second)).not.toBe(kitKey(first)) + expect(kitKey(second).split(':').slice(0, 3)).toEqual( + kitKey(first).split(':').slice(0, 3) + ) + expect( + second.find((request) => request.path === '/v1/app-emails/kit')?.body + ).toEqual({ sets: ['lifecycle'], stack: 'custom' }) + }) + + it('Supabase Edge Functions only: the hook and a Deno client, nothing to install', async () => { + const root = copyFixture('supabase-functions') + useBrewApi() + writeFileSync(join(root, '.env.local'), `BREW_API_KEY=${APP_KEY}\n`) + const spawn = recordingSpawn() + const result = await init(['--yes', '--json', '--no-test-email'], { + cwd: root, + env: configEnv(), + spawn: spawn.spawn, + }) + expect(result.code).toBe(0) + expectValidReport(result.report) + expect(spawn.calls).toEqual([]) + expect(read(root, 'supabase/functions/_shared/brew.ts')).toContain( + "Deno.env.get('BREW_API_KEY')" + ) + expect(read(root, 'supabase/functions/send-email/index.ts')).toContain( + 'npm:@brew.new/sdk@^13.6.0/supabase' + ) + expect(result.report.patches).toMatchObject([ + { id: 'supabase-config-hook', status: 'pending' }, + ]) + }) + + it('adds a sending domain with --domain and prints its DNS records, without waiting', async () => { + const root = copyFixture('express-resend') + const requests = useBrewApi() + writeFileSync(join(root, '.env.local'), `BREW_API_KEY=${APP_KEY}\n`) + const result = await init( + ['--yes', '--json', '--domain', 'mail.acme.com', '--no-test-email'], + { cwd: root, env: configEnv(), spawn: recordingSpawn().spawn } + ) + expect(result.code).toBe(0) + const add = requests.find( + (request) => request.method === 'POST' && request.path === '/v1/domains' + ) + expect(add?.body).toEqual({ + name: 'mail.acme.com', + sendingPurpose: 'transactional', + }) + expect(add?.idempotencyKey).toMatch( + /^init:[0-9a-f-]{36}:domain:[0-9a-f]{12}$/ + ) + expect(result.report.domain).toMatchObject({ + mode: 'sandbox', + domainId: 'dom_new', + name: 'mail.acme.com', + added: true, + records: [ + { + type: 'TXT', + name: 'brew._domainkey.mail.acme.com', + value: 'p=MIGf…', + }, + ], + }) + expect( + result.report.nextSteps.find((step) => step.id === 'add-dns-records') + ).toMatchObject({ + actor: 'human', + command: 'brew-cli domains verify dom_new', + blocking: false, + }) + }) + + it('points at a subdomain when the added domain already publishes another DKIM key', async () => { + const root = copyFixture('express-resend') + useBrewApi({ + addWarnings: [ + { + code: 'DKIM_SELECTOR_IN_USE', + message: 'acme.com already publishes a different DKIM key.', + suggestedNames: ['mail.acme.com', 'send.acme.com'], + }, + ], + }) + writeFileSync(join(root, '.env.local'), `BREW_API_KEY=${APP_KEY}\n`) + const result = await init( + ['--yes', '--json', '--domain', 'acme.com', '--no-test-email'], + { cwd: root, env: configEnv(), spawn: recordingSpawn().spawn } + ) + expect(result.code).toBe(0) + expectValidReport(result.report) + const ids = result.report.nextSteps.map((step) => step.id) + expect(ids).not.toContain('add-dns-records') + expect( + result.report.nextSteps.find((step) => step.id === 'dkim-selector-in-use') + ).toEqual({ + id: 'dkim-selector-in-use', + actor: 'human', + kind: 'run-command', + title: + 'acme.com already publishes a different DKIM key where Brew asks for its record: add a subdomain instead (mail.acme.com, send.acme.com)', + command: 'brew-cli init --domain mail.acme.com', + blocking: false, + }) + }) + + it('exits 1 with the report when a kit key failed, pointing at a re-run', async () => { + const root = copyFixture('express-resend') + useBrewApi({ failKeys: ['auth.magic-link'] }) + writeFileSync(join(root, '.env.local'), `BREW_API_KEY=${APP_KEY}\n`) + const result = await init(['--yes', '--json'], { + cwd: root, + env: configEnv(), + spawn: recordingSpawn().spawn, + }) + expect(result.code).toBe(1) + expectValidReport(result.report) + expect(result.report.status).toBe('failed') + expect(result.report.emails.find((email) => !email.ok)).toEqual({ + key: 'auth.magic-link', + ok: false, + error: { code: 'INTERNAL_ERROR', message: 'Import failed.' }, + }) + expect( + result.report.nextSteps.find((step) => step.id === 'rerun-init') + ).toMatchObject({ + command: 'brew-cli init --json --yes', + blocking: true, + }) + }) + + it('asks a person about a key whose trigger another published automation sends from', async () => { + const root = copyFixture('express-resend') + const message = + '"lifecycle.welcome" already starts the published automation "Welcome" (aut_9), which no app-email setup made, so setting it up would send a second email on every fire. Unpublish or delete it, then run the setup again.' + useBrewApi({ + refusals: { + 'lifecycle.welcome': { code: 'APP_EMAIL_TRIGGER_IN_USE', message }, + }, + }) + writeFileSync(join(root, '.env.local'), `BREW_API_KEY=${APP_KEY}\n`) + const result = await init(['--yes', '--json', '--no-test-email'], { + cwd: root, + env: configEnv(), + spawn: recordingSpawn().spawn, + }) + expect(result.code).toBe(1) + expectValidReport(result.report) + const ids = result.report.nextSteps.map((step) => step.id) + expect( + result.report.nextSteps.find( + (step) => step.id === 'trigger-in-use:lifecycle.welcome' + ) + ).toEqual({ + id: 'trigger-in-use:lifecycle.welcome', + actor: 'human', + kind: 'review', + title: `lifecycle.welcome: ${message}`, + blocking: false, + }) + // The person decides first; then a re-run finishes the key. + expect(ids.indexOf('trigger-in-use:lifecycle.welcome')).toBeLessThan( + ids.indexOf('rerun-init') + ) + }) +}) + +describe('init: sign-in for agents (exit 3)', () => { + it('off a terminal, starts the sign-in, exits 3 with the link, then --resume finishes', async () => { + const root = copyFixture('next-better-auth') + const login = useLoginServer({ steps: ['approved'] }) + const env = configEnv() + const pending = await init(['--yes', '--json', '--no-test-email'], { + cwd: root, + env, + clock: fakeClock(), + spawn: recordingSpawn().spawn, + }) + expect(pending.code).toBe(3) + expectValidReport(pending.report) + expect(pending.report.status).toBe('auth_required') + expect(pending.report.nextSteps).toEqual([ + { + id: 'approve-sign-in', + actor: 'human', + kind: 'open-url', + url: `https://brew.new/cli/authorize?session=${SESSION_ID}`, + title: expect.stringContaining(USER_CODE), + blocking: true, + }, + { + id: 'resume-init', + actor: 'agent', + kind: 'run-command', + command: 'brew-cli init --json --no-test-email --resume --yes', + title: expect.any(String), + blocking: true, + }, + ]) + expect(login.polls()).toBe(0) + expect(existsSync(pendingLoginPath(env))).toBe(true) + expect(existsSync(join(root, 'brew.json'))).toBe(false) + expect(existsSync(join(root, '.env.local'))).toBe(false) + + const requests = useBrewApi() + const resumed = await init( + ['--json', '--no-test-email', '--resume', '--yes'], + { + cwd: root, + env, + clock: fakeClock(), + spawn: recordingSpawn().spawn, + } + ) + expect(resumed.code).toBe(0) + expect(resumed.report.credential.source).toBe('browser') + expect(read(root, '.env.local')).toBe(`BREW_API_KEY=${APP_KEY}\n`) + expect(existsSync(pendingLoginPath(env))).toBe(false) + expect( + requests.some((request) => request.path === '/v1/app-emails/kit') + ).toBe(true) + }) + + it('names the launcher in the resume command', async () => { + const root = copyFixture('next-better-auth') + useLoginServer() + const result = await init(['--yes', '--json'], { + cwd: root, + env: configEnv({ BREW_CLI_INVOKED_AS: 'npx -y brew.new@0.18.0' }), + clock: fakeClock(), + spawn: recordingSpawn().spawn, + }) + expect(result.code).toBe(3) + expect( + result.report.nextSteps.find((step) => step.id === 'resume-init')?.command + ).toBe('npx -y brew.new@0.18.0 init --json --resume --yes') + }) +}) + +describe('init: projects it cannot set up as they are', () => { + it('exits 2 at a monorepo root with several apps, listing them', async () => { + const root = copyFixture('pnpm-turbo') + const result = await init(['--yes', '--json'], { + cwd: root, + env: configEnv(), + }) + expect(result.code).toBe(2) + expectValidReport(result.report) + expect(result.report.status).toBe('ambiguous_project') + expect(result.report.project.candidates).toEqual([ + { name: 'api', dir: 'apps/api', framework: 'hono' }, + { name: 'web', dir: 'apps/web', framework: 'next' }, + ]) + expect(result.report.nextSteps.map((step) => step.command)).toEqual([ + 'brew-cli init --json --app apps/api --yes', + 'brew-cli init --json --app apps/web --yes', + ]) + }) + + it('exits 2 without a package.json', async () => { + const root = copyFixture('no-package-json') + const result = await init(['--yes', '--json'], { + cwd: root, + env: configEnv(), + }) + expect(result.code).toBe(2) + expect(result.stdout + result.stderr).toContain('No app found') + }) + + it('exits 2 on an unknown --stack before any request', async () => { + const root = copyFixture('next-better-auth') + const result = await init(['--yes', '--json', '--stack', 'lucia'], { + cwd: root, + env: configEnv(), + }) + expect(result.code).toBe(2) + }) +}) + +describe('init per stack', () => { + it('Supabase (Lovable): writes the Send Email Hook, patches config.toml only on request, installs nothing', async () => { + const root = copyFixture('vite-supabase-lovable') + useBrewApi() + writeFileSync(join(root, '.env.local'), `BREW_API_KEY=${APP_KEY}\n`) + const spawn = recordingSpawn() + const result = await init(['--yes', '--json', '--no-test-email'], { + cwd: root, + env: configEnv(), + spawn: spawn.spawn, + }) + expect(result.code).toBe(0) + expect(spawn.calls).toEqual([]) + expect(read(root, 'supabase/functions/send-email/index.ts')).toContain( + 'createSupabaseSendEmailHook' + ) + expect(existsSync(join(root, 'src/lib/brew.ts'))).toBe(false) + expect(read(root, '.env.example')).toBe( + 'BREW_API_KEY=\nSEND_EMAIL_HOOK_SECRET=\n' + ) + expect(result.report.patches).toMatchObject([ + { id: 'supabase-config-hook', status: 'pending' }, + ]) + expect(read(root, 'supabase/config.toml')).not.toContain('send_email') + expect(result.report.nextSteps.map((step) => step.id)).toEqual( + expect.arrayContaining(['deploy-supabase-hook', 'connect-supabase-hook']) + ) + expect(result.report.project.builder).toBe('lovable') + }) + + it('Clerk: lifecycle helper only, and a note that Clerk keeps its auth emails', async () => { + const root = copyFixture('react-router-clerk') + useBrewApi() + writeFileSync(join(root, '.env.local'), `BREW_API_KEY=${APP_KEY}\n`) + const result = await init(['--yes', '--json', '--no-test-email'], { + cwd: root, + env: configEnv(), + spawn: recordingSpawn().spawn, + }) + expect(result.code).toBe(0) + expect(read(root, 'app/lib/brew-auth.ts')).toContain('sendWelcomeEmail') + expect(result.report.patches).toEqual([]) + expect( + result.report.nextSteps.find((step) => step.id === 'connect-clerk') + ).toMatchObject({ + actor: 'human', + blocking: false, + }) + }) + + it('Workers: getBrew(env) and the Worker secret step', async () => { + const root = copyFixture('hono-workers') + useBrewApi() + writeFileSync(join(root, '.env.local'), `BREW_API_KEY=${APP_KEY}\n`) + const result = await init(['--yes', '--json', '--no-test-email'], { + cwd: root, + env: configEnv(), + spawn: recordingSpawn().spawn, + }) + expect(result.code).toBe(0) + expect(read(root, 'src/lib/brew.ts')).toContain( + 'export function getBrew(env: { BREW_API_KEY?: string }): BrewClient' + ) + expect( + result.report.nextSteps.find((step) => step.id === 'worker-secret') + ).toMatchObject({ + command: 'wrangler secret put BREW_API_KEY', + blocking: true, + }) + }) + + it('a monorepo app installs into its workspace from the root', async () => { + const root = copyFixture('pnpm-turbo') + useBrewApi() + writeFileSync( + join(root, 'apps/web/.env.local'), + `BREW_API_KEY=${APP_KEY}\n` + ) + const spawn = recordingSpawn() + const result = await init( + ['--yes', '--json', '--app', 'apps/web', '--no-test-email'], + { + cwd: root, + env: configEnv(), + spawn: spawn.spawn, + } + ) + expect(result.code).toBe(0) + expect(spawn.calls).toEqual([ + { + command: 'pnpm', + args: ['add', '--filter', 'web', '@brew.new/sdk@^13.6.0'], + cwd: root, + }, + ]) + expect(existsSync(join(root, 'apps/web/lib/brew.js'))).toBe(true) + expect(existsSync(join(root, 'apps/web/brew.json'))).toBe(true) + }) +}) diff --git a/tests/fixtures/projects/astro/package-lock.json b/tests/fixtures/projects/astro/package-lock.json new file mode 100644 index 0000000..54ca4c4 --- /dev/null +++ b/tests/fixtures/projects/astro/package-lock.json @@ -0,0 +1 @@ +{"lockfileVersion": 3, "packages": {}} diff --git a/tests/fixtures/projects/astro/package.json b/tests/fixtures/projects/astro/package.json new file mode 100644 index 0000000..aad8d39 --- /dev/null +++ b/tests/fixtures/projects/astro/package.json @@ -0,0 +1,7 @@ +{ + "name": "astro-site", + "type": "module", + "dependencies": { + "astro": "5.7.0" + } +} diff --git a/tests/fixtures/projects/astro/src/pages/index.astro b/tests/fixtures/projects/astro/src/pages/index.astro new file mode 100644 index 0000000..986a4a1 --- /dev/null +++ b/tests/fixtures/projects/astro/src/pages/index.astro @@ -0,0 +1 @@ +

Hello

diff --git a/tests/fixtures/projects/express-resend/package-lock.json b/tests/fixtures/projects/express-resend/package-lock.json new file mode 100644 index 0000000..54ca4c4 --- /dev/null +++ b/tests/fixtures/projects/express-resend/package-lock.json @@ -0,0 +1 @@ +{"lockfileVersion": 3, "packages": {}} diff --git a/tests/fixtures/projects/express-resend/package.json b/tests/fixtures/projects/express-resend/package.json new file mode 100644 index 0000000..98d276e --- /dev/null +++ b/tests/fixtures/projects/express-resend/package.json @@ -0,0 +1,11 @@ +{ + "name": "express-api", + "type": "module", + "dependencies": { + "express": "5.1.0", + "resend": "4.5.1" + }, + "devDependencies": { + "typescript": "5.8.3" + } +} diff --git a/tests/fixtures/projects/express-resend/src/index.ts b/tests/fixtures/projects/express-resend/src/index.ts new file mode 100644 index 0000000..2ddc161 --- /dev/null +++ b/tests/fixtures/projects/express-resend/src/index.ts @@ -0,0 +1,3 @@ +import express from 'express' +const app = express() +app.listen(3000) diff --git a/tests/fixtures/projects/express-resend/tsconfig.json b/tests/fixtures/projects/express-resend/tsconfig.json new file mode 100644 index 0000000..21e187e --- /dev/null +++ b/tests/fixtures/projects/express-resend/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "strict": true, "module": "nodenext" } } diff --git a/tests/fixtures/projects/hono-workers/package-lock.json b/tests/fixtures/projects/hono-workers/package-lock.json new file mode 100644 index 0000000..54ca4c4 --- /dev/null +++ b/tests/fixtures/projects/hono-workers/package-lock.json @@ -0,0 +1 @@ +{"lockfileVersion": 3, "packages": {}} diff --git a/tests/fixtures/projects/hono-workers/package.json b/tests/fixtures/projects/hono-workers/package.json new file mode 100644 index 0000000..9e79c44 --- /dev/null +++ b/tests/fixtures/projects/hono-workers/package.json @@ -0,0 +1,10 @@ +{ + "name": "hono-worker", + "dependencies": { + "hono": "4.7.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "4.20250410.0", + "wrangler": "4.10.0" + } +} diff --git a/tests/fixtures/projects/hono-workers/src/index.ts b/tests/fixtures/projects/hono-workers/src/index.ts new file mode 100644 index 0000000..2506711 --- /dev/null +++ b/tests/fixtures/projects/hono-workers/src/index.ts @@ -0,0 +1,2 @@ +import { Hono } from 'hono' +export default new Hono() diff --git a/tests/fixtures/projects/hono-workers/tsconfig.json b/tests/fixtures/projects/hono-workers/tsconfig.json new file mode 100644 index 0000000..bfebedc --- /dev/null +++ b/tests/fixtures/projects/hono-workers/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "strict": true } } diff --git a/tests/fixtures/projects/hono-workers/wrangler.toml b/tests/fixtures/projects/hono-workers/wrangler.toml new file mode 100644 index 0000000..1122929 --- /dev/null +++ b/tests/fixtures/projects/hono-workers/wrangler.toml @@ -0,0 +1,3 @@ +name = "hono-worker" +main = "src/index.ts" +compatibility_date = "2025-04-01" diff --git a/tests/fixtures/projects/next-app-authjs-v5/package.json b/tests/fixtures/projects/next-app-authjs-v5/package.json new file mode 100644 index 0000000..2a22712 --- /dev/null +++ b/tests/fixtures/projects/next-app-authjs-v5/package.json @@ -0,0 +1,9 @@ +{ + "name": "authjs-app", + "dependencies": { + "next": "15.1.0", + "next-auth": "5.0.0-beta.25", + "@auth/prisma-adapter": "2.7.4", + "react": "19.0.0" + } +} diff --git a/tests/fixtures/projects/next-app-authjs-v5/src/app/page.tsx b/tests/fixtures/projects/next-app-authjs-v5/src/app/page.tsx new file mode 100644 index 0000000..c174313 --- /dev/null +++ b/tests/fixtures/projects/next-app-authjs-v5/src/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return null +} diff --git a/tests/fixtures/projects/next-app-authjs-v5/src/auth.ts b/tests/fixtures/projects/next-app-authjs-v5/src/auth.ts new file mode 100644 index 0000000..8299458 --- /dev/null +++ b/tests/fixtures/projects/next-app-authjs-v5/src/auth.ts @@ -0,0 +1,6 @@ +import NextAuth from 'next-auth' +import GitHub from 'next-auth/providers/github' + +export const { handlers, auth, signIn, signOut } = NextAuth({ + providers: [GitHub], +}) diff --git a/tests/fixtures/projects/next-app-authjs-v5/tsconfig.json b/tests/fixtures/projects/next-app-authjs-v5/tsconfig.json new file mode 100644 index 0000000..bfebedc --- /dev/null +++ b/tests/fixtures/projects/next-app-authjs-v5/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "strict": true } } diff --git a/tests/fixtures/projects/next-app-authjs-v5/yarn.lock b/tests/fixtures/projects/next-app-authjs-v5/yarn.lock new file mode 100644 index 0000000..4a58018 --- /dev/null +++ b/tests/fixtures/projects/next-app-authjs-v5/yarn.lock @@ -0,0 +1,2 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 diff --git a/tests/fixtures/projects/next-better-auth/.cursor/rules/style.mdc b/tests/fixtures/projects/next-better-auth/.cursor/rules/style.mdc new file mode 100644 index 0000000..ef60bb8 --- /dev/null +++ b/tests/fixtures/projects/next-better-auth/.cursor/rules/style.mdc @@ -0,0 +1 @@ +Use TypeScript. diff --git a/tests/fixtures/projects/next-better-auth/.gitignore b/tests/fixtures/projects/next-better-auth/.gitignore new file mode 100644 index 0000000..0474dae --- /dev/null +++ b/tests/fixtures/projects/next-better-auth/.gitignore @@ -0,0 +1,3 @@ +node_modules +.next +.env*.local diff --git a/tests/fixtures/projects/next-better-auth/app/page.tsx b/tests/fixtures/projects/next-better-auth/app/page.tsx new file mode 100644 index 0000000..25239d5 --- /dev/null +++ b/tests/fixtures/projects/next-better-auth/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
Acme
+} diff --git a/tests/fixtures/projects/next-better-auth/lib/auth.ts b/tests/fixtures/projects/next-better-auth/lib/auth.ts new file mode 100644 index 0000000..ed28f3d --- /dev/null +++ b/tests/fixtures/projects/next-better-auth/lib/auth.ts @@ -0,0 +1,9 @@ +import { betterAuth } from 'better-auth' +import { db } from './db' + +export const auth = betterAuth({ + database: db, + emailAndPassword: { + enabled: true, + }, +}) diff --git a/tests/fixtures/projects/next-better-auth/package-lock.json b/tests/fixtures/projects/next-better-auth/package-lock.json new file mode 100644 index 0000000..54ca4c4 --- /dev/null +++ b/tests/fixtures/projects/next-better-auth/package-lock.json @@ -0,0 +1 @@ +{"lockfileVersion": 3, "packages": {}} diff --git a/tests/fixtures/projects/next-better-auth/package.json b/tests/fixtures/projects/next-better-auth/package.json new file mode 100644 index 0000000..fbbf7d2 --- /dev/null +++ b/tests/fixtures/projects/next-better-auth/package.json @@ -0,0 +1,14 @@ +{ + "name": "acme-web", + "private": true, + "dependencies": { + "better-auth": "1.7.2", + "next": "16.0.0", + "react": "19.2.0", + "stripe": "19.1.0" + }, + "devDependencies": { + "@types/node": "25.0.0", + "typescript": "6.0.2" + } +} diff --git a/tests/fixtures/projects/next-better-auth/tsconfig.json b/tests/fixtures/projects/next-better-auth/tsconfig.json new file mode 100644 index 0000000..bfebedc --- /dev/null +++ b/tests/fixtures/projects/next-better-auth/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "strict": true } } diff --git a/tests/fixtures/projects/next-firebase/package-lock.json b/tests/fixtures/projects/next-firebase/package-lock.json new file mode 100644 index 0000000..54ca4c4 --- /dev/null +++ b/tests/fixtures/projects/next-firebase/package-lock.json @@ -0,0 +1 @@ +{"lockfileVersion": 3, "packages": {}} diff --git a/tests/fixtures/projects/next-firebase/package.json b/tests/fixtures/projects/next-firebase/package.json new file mode 100644 index 0000000..6ff649d --- /dev/null +++ b/tests/fixtures/projects/next-firebase/package.json @@ -0,0 +1,11 @@ +{ + "name": "firebase-app", + "dependencies": { + "firebase": "11.6.0", + "next": "15.3.0", + "react": "19.1.0" + }, + "devDependencies": { + "typescript": "5.8.3" + } +} diff --git a/tests/fixtures/projects/next-firebase/src/app/page.tsx b/tests/fixtures/projects/next-firebase/src/app/page.tsx new file mode 100644 index 0000000..c174313 --- /dev/null +++ b/tests/fixtures/projects/next-firebase/src/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return null +} diff --git a/tests/fixtures/projects/next-pages-authjs-v4/package.json b/tests/fixtures/projects/next-pages-authjs-v4/package.json new file mode 100644 index 0000000..b54538b --- /dev/null +++ b/tests/fixtures/projects/next-pages-authjs-v4/package.json @@ -0,0 +1,8 @@ +{ + "name": "pages-app", + "dependencies": { + "next": "14.2.15", + "next-auth": "^4.24.10", + "react": "18.3.1" + } +} diff --git a/tests/fixtures/projects/next-pages-authjs-v4/pages/api/auth/[...nextauth].ts b/tests/fixtures/projects/next-pages-authjs-v4/pages/api/auth/[...nextauth].ts new file mode 100644 index 0000000..412a452 --- /dev/null +++ b/tests/fixtures/projects/next-pages-authjs-v4/pages/api/auth/[...nextauth].ts @@ -0,0 +1,11 @@ +import NextAuth from 'next-auth' +import GithubProvider from 'next-auth/providers/github' + +export default NextAuth({ + providers: [ + GithubProvider({ + clientId: process.env.GITHUB_ID ?? '', + clientSecret: process.env.GITHUB_SECRET ?? '', + }), + ], +}) diff --git a/tests/fixtures/projects/next-pages-authjs-v4/pages/index.tsx b/tests/fixtures/projects/next-pages-authjs-v4/pages/index.tsx new file mode 100644 index 0000000..bba50f1 --- /dev/null +++ b/tests/fixtures/projects/next-pages-authjs-v4/pages/index.tsx @@ -0,0 +1,3 @@ +export default function Home() { + return null +} diff --git a/tests/fixtures/projects/next-pages-authjs-v4/pnpm-lock.yaml b/tests/fixtures/projects/next-pages-authjs-v4/pnpm-lock.yaml new file mode 100644 index 0000000..b0a073a --- /dev/null +++ b/tests/fixtures/projects/next-pages-authjs-v4/pnpm-lock.yaml @@ -0,0 +1 @@ +lockfileVersion: '9.0' diff --git a/tests/fixtures/projects/next-pages-authjs-v4/tsconfig.json b/tests/fixtures/projects/next-pages-authjs-v4/tsconfig.json new file mode 100644 index 0000000..bfebedc --- /dev/null +++ b/tests/fixtures/projects/next-pages-authjs-v4/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "strict": true } } diff --git a/tests/fixtures/projects/react-email/emails/welcome.tsx b/tests/fixtures/projects/react-email/emails/welcome.tsx new file mode 100644 index 0000000..e872ec1 --- /dev/null +++ b/tests/fixtures/projects/react-email/emails/welcome.tsx @@ -0,0 +1,3 @@ +export default function Welcome() { + return null +} diff --git a/tests/fixtures/projects/react-email/package-lock.json b/tests/fixtures/projects/react-email/package-lock.json new file mode 100644 index 0000000..54ca4c4 --- /dev/null +++ b/tests/fixtures/projects/react-email/package-lock.json @@ -0,0 +1 @@ +{"lockfileVersion": 3, "packages": {}} diff --git a/tests/fixtures/projects/react-email/package.json b/tests/fixtures/projects/react-email/package.json new file mode 100644 index 0000000..439999b --- /dev/null +++ b/tests/fixtures/projects/react-email/package.json @@ -0,0 +1,8 @@ +{ + "name": "mailer", + "dependencies": { + "@react-email/components": "0.0.36", + "express": "4.21.2", + "nodemailer": "6.10.1" + } +} diff --git a/tests/fixtures/projects/react-router-clerk/app/root.tsx b/tests/fixtures/projects/react-router-clerk/app/root.tsx new file mode 100644 index 0000000..716a15a --- /dev/null +++ b/tests/fixtures/projects/react-router-clerk/app/root.tsx @@ -0,0 +1,3 @@ +export default function App() { + return null +} diff --git a/tests/fixtures/projects/react-router-clerk/package-lock.json b/tests/fixtures/projects/react-router-clerk/package-lock.json new file mode 100644 index 0000000..54ca4c4 --- /dev/null +++ b/tests/fixtures/projects/react-router-clerk/package-lock.json @@ -0,0 +1 @@ +{"lockfileVersion": 3, "packages": {}} diff --git a/tests/fixtures/projects/react-router-clerk/package.json b/tests/fixtures/projects/react-router-clerk/package.json new file mode 100644 index 0000000..5143f2d --- /dev/null +++ b/tests/fixtures/projects/react-router-clerk/package.json @@ -0,0 +1,14 @@ +{ + "name": "rr-clerk", + "type": "module", + "dependencies": { + "@clerk/react-router": "1.4.0", + "@react-router/node": "7.9.0", + "react-router": "7.9.0" + }, + "devDependencies": { + "@react-router/dev": "7.9.0", + "typescript": "5.9.2", + "vite": "7.1.0" + } +} diff --git a/tests/fixtures/projects/supabase-functions/supabase/config.toml b/tests/fixtures/projects/supabase-functions/supabase/config.toml new file mode 100644 index 0000000..9f885ac --- /dev/null +++ b/tests/fixtures/projects/supabase-functions/supabase/config.toml @@ -0,0 +1,4 @@ +project_id = "fnonly" + +[auth] +site_url = "http://127.0.0.1:3000" diff --git a/tests/fixtures/projects/supabase-functions/supabase/functions/hello/index.ts b/tests/fixtures/projects/supabase-functions/supabase/functions/hello/index.ts new file mode 100644 index 0000000..afb6836 --- /dev/null +++ b/tests/fixtures/projects/supabase-functions/supabase/functions/hello/index.ts @@ -0,0 +1 @@ +Deno.serve(() => new Response('hello')) diff --git a/tests/fixtures/projects/sveltekit/package.json b/tests/fixtures/projects/sveltekit/package.json new file mode 100644 index 0000000..ac45799 --- /dev/null +++ b/tests/fixtures/projects/sveltekit/package.json @@ -0,0 +1,10 @@ +{ + "name": "svelte-app", + "type": "module", + "devDependencies": { + "@sveltejs/kit": "2.20.0", + "svelte": "5.25.0", + "typescript": "5.8.0", + "vite": "6.2.0" + } +} diff --git a/tests/fixtures/projects/sveltekit/pnpm-lock.yaml b/tests/fixtures/projects/sveltekit/pnpm-lock.yaml new file mode 100644 index 0000000..b0a073a --- /dev/null +++ b/tests/fixtures/projects/sveltekit/pnpm-lock.yaml @@ -0,0 +1 @@ +lockfileVersion: '9.0' diff --git a/tests/fixtures/projects/sveltekit/src/routes/+page.svelte b/tests/fixtures/projects/sveltekit/src/routes/+page.svelte new file mode 100644 index 0000000..986a4a1 --- /dev/null +++ b/tests/fixtures/projects/sveltekit/src/routes/+page.svelte @@ -0,0 +1 @@ +

Hello

diff --git a/tests/fixtures/projects/vite-supabase-lovable/.gitignore b/tests/fixtures/projects/vite-supabase-lovable/.gitignore new file mode 100644 index 0000000..804bd4a --- /dev/null +++ b/tests/fixtures/projects/vite-supabase-lovable/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +*.local diff --git a/tests/fixtures/projects/vite-supabase-lovable/package-lock.json b/tests/fixtures/projects/vite-supabase-lovable/package-lock.json new file mode 100644 index 0000000..54ca4c4 --- /dev/null +++ b/tests/fixtures/projects/vite-supabase-lovable/package-lock.json @@ -0,0 +1 @@ +{"lockfileVersion": 3, "packages": {}} diff --git a/tests/fixtures/projects/vite-supabase-lovable/package.json b/tests/fixtures/projects/vite-supabase-lovable/package.json new file mode 100644 index 0000000..8422345 --- /dev/null +++ b/tests/fixtures/projects/vite-supabase-lovable/package.json @@ -0,0 +1,16 @@ +{ + "name": "vite_react_shadcn_ts", + "private": true, + "type": "module", + "dependencies": { + "@supabase/supabase-js": "2.49.4", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react-swc": "3.9.0", + "lovable-tagger": "1.1.7", + "typescript": "5.8.3", + "vite": "6.3.5" + } +} diff --git a/tests/fixtures/projects/vite-supabase-lovable/src/main.tsx b/tests/fixtures/projects/vite-supabase-lovable/src/main.tsx new file mode 100644 index 0000000..f5f10ee --- /dev/null +++ b/tests/fixtures/projects/vite-supabase-lovable/src/main.tsx @@ -0,0 +1,2 @@ +import { createRoot } from 'react-dom/client' +createRoot(document.getElementById('root')!).render(null) diff --git a/tests/fixtures/projects/vite-supabase-lovable/supabase/config.toml b/tests/fixtures/projects/vite-supabase-lovable/supabase/config.toml new file mode 100644 index 0000000..bf02365 --- /dev/null +++ b/tests/fixtures/projects/vite-supabase-lovable/supabase/config.toml @@ -0,0 +1 @@ +project_id = "abcdefghijklmnopqrst" diff --git a/tests/helpers/contract-artifacts.ts b/tests/helpers/contract-artifacts.ts new file mode 100644 index 0000000..030afcf --- /dev/null +++ b/tests/helpers/contract-artifacts.ts @@ -0,0 +1,92 @@ +import { HttpResponse, http } from 'msw' + +/** + * `GET /v1/automations/triggers/{id}/contract?format=ts` for test trigger + * rows: the TypeScript artifact the platform serves (one exported payload + * type named after the trigger title), built from the row's declared + * scalar fields so `types` and `init` see a realistic contract. + */ + +type FieldRow = { + readonly key: string + readonly type: string + readonly required?: boolean +} + +export type ContractRow = { + readonly triggerEventId: string + readonly title: string + readonly payloadSchema?: { readonly fields?: ReadonlyArray } +} + +const IDENTIFIER_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/ + +function typeNameOf(title: string): string { + const words = title + .replace(/[^a-zA-Z0-9]+/g, ' ') + .trim() + .split(/\s+/) + .filter((word) => word !== '') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + return `${words.join('') || 'Trigger'}Payload` +} + +function scalar(type: string): string { + if (type === 'int' || type === 'float' || type === 'number') { + return 'number' + } + if (type === 'boolean') { + return 'boolean' + } + return 'string' +} + +export function contractArtifact(row: ContractRow): { + readonly typeName: string + readonly content: string +} { + const typeName = typeNameOf(row.title) + const fields = (row.payloadSchema?.fields ?? []).map( + (field) => + ` ${IDENTIFIER_RE.test(field.key) ? field.key : JSON.stringify(field.key)}${field.required === true ? '' : '?'}: ${scalar(field.type)}` + ) + return { + typeName, + content: [ + `/** Fire: POST /v1/automations/triggers/${row.triggerEventId}/fire — body { payload: ${typeName} } */`, + `export type ${typeName} = {`, + ...fields, + '}', + '', + ].join('\n'), + } +} + +export function contractHandler( + api: string, + rows: () => ReadonlyArray +) { + return http.get( + `${api}/v1/automations/triggers/:triggerEventId/contract`, + ({ params }) => { + const row = rows().find( + (candidate) => candidate.triggerEventId === params.triggerEventId + ) + if (row === undefined) { + return HttpResponse.json( + { error: { code: 'TRIGGER_EVENT_NOT_FOUND', type: 'not_found' } }, + { status: 404 } + ) + } + const artifact = contractArtifact(row) + return HttpResponse.json({ + format: 'ts', + typeName: artifact.typeName, + subjectKind: 'trigger', + subjectId: row.triggerEventId, + source: 'stored', + content: artifact.content, + }) + } + ) +} diff --git a/tests/helpers/json-schema.ts b/tests/helpers/json-schema.ts new file mode 100644 index 0000000..0cd75a9 --- /dev/null +++ b/tests/helpers/json-schema.ts @@ -0,0 +1,96 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +/** + * The subset of JSON Schema (draft-07) the shipped schemas use: `type` + * (with `null` and arrays of types), `enum`, `const`, `required`, + * `properties`, `additionalProperties: false`, `items`, `pattern`. Enough + * to hold a command's JSON output to its published contract without a + * validator dependency. + */ +type Schema = { + readonly type?: string | readonly string[] + readonly enum?: readonly unknown[] + readonly const?: unknown + readonly required?: readonly string[] + readonly properties?: Readonly> + readonly additionalProperties?: boolean + readonly items?: Schema + readonly pattern?: string +} + +function typeOf(value: unknown): string { + if (value === null) { + return 'null' + } + if (Array.isArray(value)) { + return 'array' + } + if (typeof value === 'number') { + return Number.isInteger(value) ? 'integer' : 'number' + } + return typeof value +} + +function matchesType(value: unknown, type: string): boolean { + const actual = typeOf(value) + return actual === type || (type === 'number' && actual === 'integer') +} + +export function validateSchema( + schema: Schema, + value: unknown, + path = '$' +): string[] { + const errors: string[] = [] + if (schema.const !== undefined && value !== schema.const) { + errors.push(`${path}: expected ${JSON.stringify(schema.const)}`) + } + if (schema.enum !== undefined && !schema.enum.includes(value)) { + errors.push(`${path}: ${JSON.stringify(value)} is not one of the enum`) + } + if (schema.type !== undefined) { + const types = typeof schema.type === 'string' ? [schema.type] : schema.type + if (!types.some((type) => matchesType(value, type))) { + errors.push(`${path}: expected ${types.join('|')}, got ${typeOf(value)}`) + return errors + } + } + if ( + schema.pattern !== undefined && + typeof value === 'string' && + !new RegExp(schema.pattern).test(value) + ) { + errors.push(`${path}: does not match ${schema.pattern}`) + } + if (Array.isArray(value) && schema.items !== undefined) { + value.forEach((item, index) => { + errors.push( + ...validateSchema(schema.items as Schema, item, `${path}[${index}]`) + ) + }) + } + if (typeOf(value) === 'object') { + const record = value as Record + for (const key of schema.required ?? []) { + if (!(key in record)) { + errors.push(`${path}: missing ${key}`) + } + } + for (const [key, child] of Object.entries(record)) { + const childSchema = schema.properties?.[key] + if (childSchema !== undefined) { + errors.push(...validateSchema(childSchema, child, `${path}.${key}`)) + } else if (schema.additionalProperties === false) { + errors.push(`${path}: unexpected property ${key}`) + } + } + } + return errors +} + +export function loadSchema(name: string): Schema { + return JSON.parse( + readFileSync(join(import.meta.dirname, '../../schemas', name), 'utf8') + ) as Schema +} diff --git a/tests/init-typecheck.test.ts b/tests/init-typecheck.test.ts new file mode 100644 index 0000000..fe05d4e --- /dev/null +++ b/tests/init-typecheck.test.ts @@ -0,0 +1,282 @@ +import { execFileSync } from 'node:child_process' +import { + cpSync, + mkdirSync, + mkdtempSync, + realpathSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { HttpResponse, http } from 'msw' +import { describe, expect, it } from 'vitest' +import type { InitReport } from '../src/lib/init/report' +import { contractHandler } from './helpers/contract-artifacts' +import { copyFixture } from './helpers/fixtures' +import { server } from './helpers/msw-server' +import { runCli } from './helpers/run-cli' + +/** + * The files `init` generates must compile against the real `@brew.new/sdk` + * types (the installed package), with the key registry from the generated + * contracts in force, under a strict tsconfig. Each case runs init against + * a fixture, copies the generated TypeScript into a scratch project that + * links the SDK, and runs the repo's own `tsc`. + */ + +const REPO = join(import.meta.dirname, '..') +const TSC = join(REPO, 'node_modules/typescript/bin/tsc') +const API = 'https://brew.new/api' +const APP_KEY = 'brew_typecheck0123456789abcdefghijkl' +const KEYS = [ + 'auth.verify-email', + 'auth.password-reset', + 'auth.magic-link', + 'auth.otp', + 'auth.email-change', + 'lifecycle.welcome', +] + +function triggerRows() { + return KEYS.map((key, index) => ({ + triggerEventId: `tri_${index}`, + key, + title: key, + provider: 'brew_api', + payloadSchema: { + type: 'object', + fields: [ + { key: 'email', type: 'string', required: true }, + { key: 'appName', type: 'string', required: false }, + { key: 'userName', type: 'string', required: false }, + { key: 'confirmationUrl', type: 'string', required: false }, + { key: 'otp', type: 'string', required: false }, + { key: 'newEmail', type: 'string', required: false }, + { key: 'expiresInMinutes', type: 'int', required: false }, + ], + }, + createdAt: '2026-10-13T12:00:00.000Z', + updatedAt: '2026-10-13T12:00:00.000Z', + })) +} + +function useApi(): void { + server.use( + http.get(`${API}/v1/brand`, () => + HttpResponse.json({ brandId: 'bd_1', domain: 'acme.com' }) + ), + http.get(`${API}/v1/domains`, () => + HttpResponse.json({ + data: [], + pagination: { limit: 100, cursor: null, hasMore: false }, + }) + ), + http.post(`${API}/v1/app-emails/kit`, () => + HttpResponse.json({ + results: [], + stack: { + provider: 'custom', + authDelivery: 'code', + note: '', + keys: [], + snippets: [], + }, + }) + ), + contractHandler(API, triggerRows), + http.get(`${API}/v1/automations/triggers`, () => + HttpResponse.json({ + data: triggerRows(), + pagination: { limit: 100, cursor: null, hasMore: false }, + }) + ) + ) +} + +function link(target: string, path: string): void { + mkdirSync(dirname(path), { recursive: true }) + symlinkSync(realpathSync(target), path, 'dir') +} + +/** A scratch TypeScript project holding the generated files. */ +function scratchProject(input: { + readonly root: string + readonly files: readonly string[] + readonly nodeNext: boolean +}): string { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'brew-init-tsc-'))) + for (const file of input.files) { + mkdirSync(dirname(join(dir, file)), { recursive: true }) + cpSync(join(input.root, file), join(dir, file)) + } + link( + join(REPO, 'node_modules/@brew.new/sdk'), + join(dir, 'node_modules/@brew.new/sdk') + ) + link( + join(REPO, 'node_modules/@types/node'), + join(dir, 'node_modules/@types/node') + ) + // `after` from next/server, as Next 15+ declares it. + mkdirSync(join(dir, 'node_modules/next'), { recursive: true }) + writeFileSync( + join(dir, 'node_modules/next/package.json'), + '{"name":"next","version":"16.0.0"}\n' + ) + writeFileSync( + join(dir, 'node_modules/next/server.d.ts'), + 'export declare function after(task: T | Promise | (() => T | Promise)): void\n' + ) + // SvelteKit's private env module and Vite's import.meta.env, as the + // frameworks declare them. + writeFileSync( + join(dir, 'framework-env.d.ts'), + [ + "declare module '$env/dynamic/private' {", + ' export const env: Record', + '}', + 'interface ImportMetaEnv {', + ' readonly [key: string]: string | undefined', + '}', + 'interface ImportMeta {', + ' readonly env: ImportMetaEnv', + '}', + '', + ].join('\n') + ) + writeFileSync( + join(dir, 'package.json'), + `{"name":"scratch","private":true${input.nodeNext ? ',"type":"module"' : ''}}\n` + ) + writeFileSync( + join(dir, 'tsconfig.json'), + JSON.stringify( + { + compilerOptions: { + target: 'ES2022', + lib: ['ES2022'], + module: input.nodeNext ? 'NodeNext' : 'ESNext', + moduleResolution: input.nodeNext ? 'NodeNext' : 'Bundler', + strict: true, + exactOptionalPropertyTypes: true, + noUncheckedIndexedAccess: true, + noUnusedLocals: true, + noUnusedParameters: true, + verbatimModuleSyntax: true, + types: ['node'], + noEmit: true, + skipLibCheck: true, + }, + include: [...input.files, 'framework-env.d.ts'], + }, + null, + 2 + ) + ) + return dir +} + +function typecheck(dir: string): string { + try { + execFileSync(process.execPath, [TSC, '-p', dir], { + encoding: 'utf8', + stdio: 'pipe', + }) + return '' + } catch (error) { + const failed = error as { stdout?: string; stderr?: string } + return `${failed.stdout ?? ''}${failed.stderr ?? ''}` || 'tsc failed' + } +} + +const CASES: ReadonlyArray = [ + ['next-better-auth', false], + ['next-app-authjs-v5', false], + ['next-pages-authjs-v4', false], + ['react-router-clerk', false], + ['hono-workers', false], + ['express-resend', true], + ['next-firebase', false], + ['sveltekit', false], + ['astro', false], +] + +describe('generated files type-check against @brew.new/sdk', () => { + it.each(CASES)( + '%s', + async (fixture, nodeNext) => { + useApi() + const root = copyFixture(fixture) + writeFileSync(join(root, '.env.local'), `BREW_API_KEY=${APP_KEY}\n`) + const result = await runCli( + [ + 'init', + '--yes', + '--json', + '--no-install', + '--no-test-email', + '--no-mcp', + '--no-agent-files', + ], + { + cwd: root, + env: { + BREW_CLI_CONFIG_DIR: mkdtempSync(join(tmpdir(), 'brew-cli-tsc-')), + }, + } + ) + const report = result.json as InitReport + const generated = report.files + .filter( + (file) => + ['client', 'auth', 'contracts'].includes(file.role) && + file.action === 'created' + ) + .map((file) => file.path) + expect(generated.length).toBeGreaterThanOrEqual(2) + const dir = scratchProject({ root, files: generated, nodeNext }) + expect(typecheck(dir)).toBe('') + }, + 60_000 + ) + + it('fails on an unknown key once the registry is generated (negative control)', async () => { + useApi() + const root = copyFixture('next-better-auth') + writeFileSync(join(root, '.env.local'), `BREW_API_KEY=${APP_KEY}\n`) + await runCli( + [ + 'init', + '--yes', + '--json', + '--no-install', + '--no-test-email', + '--no-mcp', + ], + { + cwd: root, + env: { + BREW_CLI_CONFIG_DIR: mkdtempSync(join(tmpdir(), 'brew-cli-tsc-')), + }, + } + ) + writeFileSync( + join(root, 'lib/typo.ts'), + [ + "import { getBrew } from './brew'", + '', + 'export async function typo(): Promise {', + " await getBrew().send('lifecycle.welcom', { email: 'a@b.c' })", + '}', + '', + ].join('\n') + ) + const dir = scratchProject({ + root, + files: ['lib/brew.ts', 'lib/brew-contracts.ts', 'lib/typo.ts'], + nodeNext: false, + }) + expect(typecheck(dir)).toContain('lifecycle.welcom') + }, 60_000) +}) diff --git a/tests/launchers.test.ts b/tests/launchers.test.ts new file mode 100644 index 0000000..de09544 --- /dev/null +++ b/tests/launchers.test.ts @@ -0,0 +1,174 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + hasSubcommand, + invokedAs, + launch, + launcherArgv, +} from '../packages/launchers/launcher.mjs' +import { LAUNCHER_PACKAGES, launcherDrift } from '../scripts/sync-launchers' +import { getInvocationPrefix } from '../src/lib/invocation' +import { copyFixture } from './helpers/fixtures' +import { runCli } from './helpers/run-cli' + +describe('launcher argv', () => { + it.each([ + [[], ['init']], + [ + ['--dry-run', '--json'], + ['init', '--dry-run', '--json'], + ], + [ + ['--yes', '--app', 'apps/web'], + ['init', '--yes', '--app', 'apps/web'], + ], + [ + ['--api-url', 'http://localhost:3000/api'], + ['init', '--api-url', 'http://localhost:3000/api'], + ], + [ + ['init', '--yes'], + ['init', '--yes'], + ], + [ + ['send', 'lifecycle.welcome', '--to', 'a@b.c'], + ['send', 'lifecycle.welcome', '--to', 'a@b.c'], + ], + [ + ['--api-url', 'http://x/api', 'login'], + ['--api-url', 'http://x/api', 'login'], + ], + [['--version'], ['--version']], + [['--help'], ['--help']], + ] as const)('brew.new %j → %j', (args, expected) => { + expect(launcherArgv(args)).toEqual(expected) + }) + + it.each([ + [[], ['init']], + [['--help'], ['init', '--help']], + [['--version'], ['--version']], + [ + ['init', '--resume', '--yes'], + ['init', '--resume', '--yes'], + ], + [ + ['send', 'x', '--to', 'a@b.c'], + ['send', 'x', '--to', 'a@b.c'], + ], + ] as const)('create-brew %j → %j', (args, expected) => { + expect(launcherArgv(args, { alwaysInit: true })).toEqual(expected) + }) + + it('treats a flag value as a value, not a subcommand', () => { + expect(hasSubcommand(['--domain', 'mail.acme.com'])).toBe(false) + expect(hasSubcommand(['--domain=mail.acme.com', 'init'])).toBe(true) + }) +}) + +describe('BREW_CLI_INVOKED_AS from a launcher', () => { + it('pins npx to the launcher version and is accepted as a prefix', () => { + expect(invokedAs('brew.new', '0.18.0')).toBe('npx -y brew.new@0.18.0') + expect( + getInvocationPrefix({ + BREW_CLI_INVOKED_AS: invokedAs('brew.new', '0.18.0'), + }) + ).toBe('npx -y brew.new@0.18.0') + expect(invokedAs('create-brew', '0.18.0')).toBe('npm create brew@0.18.0 --') + expect( + getInvocationPrefix({ + BREW_CLI_INVOKED_AS: invokedAs('create-brew', '0.18.0'), + }) + ).toBe('npm create brew@0.18.0 --') + }) + + it('a printed command re-enters through the launcher without a second init', async () => { + const root = copyFixture('next-better-auth') + const env = { + BREW_CLI_CONFIG_DIR: mkdtempSync(join(tmpdir(), 'brew-cli-launcher-')), + BREW_CLI_INVOKED_AS: invokedAs('create-brew', '0.18.0'), + } + const result = await runCli( + launcherArgv(['--json'], { alwaysInit: true }), + { + cwd: root, + env, + } + ) + expect(result.code).toBe(4) + const { confirmCommand } = result.json as { confirmCommand: string } + expect(confirmCommand).toBe('npm create brew@0.18.0 -- init --json --yes') + // What npm hands the launcher for that command: init stays single. + const rerun = confirmCommand.split(' ').slice(4) + expect(launcherArgv(rerun, { alwaysInit: true })).toEqual([ + 'init', + '--json', + '--yes', + ]) + }) +}) + +describe('launch()', () => { + it('rewrites argv in place, names the launcher, then loads the CLI once', async () => { + const argv = ['node', '/x/bin.mjs', '--dry-run'] + const env: Record = {} + const seen: string[][] = [] + await launch({ + packageName: 'brew.new', + version: '0.18.0', + argv, + env, + importCli: async () => { + seen.push([...argv]) + }, + }) + expect(seen).toEqual([['node', '/x/bin.mjs', 'init', '--dry-run']]) + expect(env.BREW_CLI_INVOKED_AS).toBe('npx -y brew.new@0.18.0') + }) + + it('keeps a BREW_CLI_INVOKED_AS the caller already set', async () => { + const env: Record = { + BREW_CLI_INVOKED_AS: 'pnpm dlx brew.new@0.18.0', + } + await launch({ + packageName: 'brew.new', + version: '0.18.0', + argv: ['node', 'bin.mjs'], + env, + importCli: async () => undefined, + }) + expect(env.BREW_CLI_INVOKED_AS).toBe('pnpm dlx brew.new@0.18.0') + }) +}) + +describe('launcher packages', () => { + it('match the CLI version, launcher source and LICENSE (sync:launchers)', () => { + expect(launcherDrift().map((entry) => entry.path)).toEqual([]) + }) + + it.each(LAUNCHER_PACKAGES)('%s packs exactly its five files', (name) => { + const output = execFileSync( + 'npm', + ['pack', '--dry-run', '--json', '--ignore-scripts'], + { + cwd: join(import.meta.dirname, '../packages/launchers', name), + encoding: 'utf8', + } + ) + const [packed] = JSON.parse(output) as Array<{ + files: Array<{ path: string; mode: number }> + }> + expect(packed?.files.map((file) => file.path).sort()).toEqual([ + 'LICENSE', + 'README.md', + 'bin.mjs', + 'launcher.mjs', + 'package.json', + ]) + const bin = packed?.files.find((file) => file.path === 'bin.mjs') + expect((bin?.mode ?? 0) & 0o111).not.toBe(0) + }) +}) diff --git a/tests/lib/init/detect.test.ts b/tests/lib/init/detect.test.ts new file mode 100644 index 0000000..4174f4f --- /dev/null +++ b/tests/lib/init/detect.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from 'vitest' +import { + detectProject, + type ProjectDetection, +} from '../../../src/lib/init/detect' +import { copyFixture } from '../../helpers/fixtures' + +function project(name: string): ProjectDetection { + const result = detectProject(copyFixture(name), { env: {} }) + if (result.kind !== 'project') { + throw new Error(`${name}: expected a project, got ${result.kind}`) + } + return result.project +} + +type Expected = Partial< + Pick< + ProjectDetection, + | 'framework' + | 'runtime' + | 'auth' + | 'authVersion' + | 'libDir' + | 'typescript' + | 'esm' + | 'builder' + | 'hasPackageJson' + | 'nextMajor' + > +> & { readonly pm?: string } + +const MATRIX: ReadonlyArray = [ + [ + 'next-better-auth', + { + framework: 'next-app', + auth: 'better-auth', + authVersion: null, + pm: 'npm', + libDir: 'lib', + typescript: true, + nextMajor: 16, + runtime: 'node', + }, + ], + [ + 'next-pages-authjs-v4', + { + framework: 'next-pages', + auth: 'authjs', + authVersion: 'v4', + pm: 'pnpm', + libDir: 'lib', + }, + ], + [ + 'next-app-authjs-v5', + { + framework: 'next-app', + auth: 'authjs', + authVersion: 'v5', + pm: 'yarn', + libDir: 'src/lib', + nextMajor: 15, + }, + ], + [ + 'vite-supabase-lovable', + { + framework: 'vite-react', + auth: 'supabase', + runtime: 'browser', + libDir: null, + builder: 'lovable', + pm: 'npm', + }, + ], + [ + 'react-router-clerk', + { + framework: 'react-router', + auth: 'clerk', + libDir: 'app/lib', + esm: true, + }, + ], + [ + 'sveltekit', + { + framework: 'sveltekit', + auth: 'none', + pm: 'pnpm', + libDir: 'src/lib/server', + }, + ], + [ + 'astro', + { framework: 'astro', auth: 'none', libDir: 'src/lib', typescript: false }, + ], + [ + 'express-resend', + { + framework: 'express', + auth: 'none', + esm: true, + libDir: 'src/lib', + runtime: 'node', + }, + ], + [ + 'hono-workers', + { framework: 'hono', runtime: 'workers', libDir: 'src/lib' }, + ], + ['bun', { framework: 'hono', runtime: 'node', pm: 'bun', libDir: 'lib' }], + [ + 'supabase-functions', + { + framework: 'supabase-functions', + auth: 'supabase', + runtime: 'deno', + hasPackageJson: false, + libDir: 'supabase/functions/_shared', + }, + ], + [ + 'next-firebase', + { framework: 'next-app', auth: 'firebase', libDir: 'src/lib' }, + ], + ['react-email', { framework: 'express', auth: 'none' }], + ['yarn-berry-pnp', { framework: 'react-router', auth: 'none', pm: 'yarn' }], +] + +describe('init project detection', () => { + it.each(MATRIX)('%s', (name, expected) => { + const detected = project(name) + const { pm, ...rest } = expected + expect(detected).toMatchObject(rest) + if (pm !== undefined) { + expect(detected.packageManager.name).toBe(pm) + } + }) + + it('reports billing, editors and existing email providers', () => { + const next = project('next-better-auth') + expect(next.billing).toEqual(['stripe']) + expect(next.editors).toEqual(['cursor']) + expect(project('express-resend').existingEmail).toEqual([ + { provider: 'resend', source: 'dependency' }, + ]) + expect(project('react-email').existingEmail).toEqual([ + { provider: 'nodemailer', source: 'dependency' }, + { provider: 'react-email', source: 'dependency' }, + { provider: 'react-email', source: 'directory', path: 'emails' }, + ]) + }) + + it('reads Yarn Plug-n-Play', () => { + const berry = project('yarn-berry-pnp') + expect(berry.packageManager).toMatchObject({ + name: 'yarn', + yarnFlavor: 'berry', + pnp: true, + }) + }) + + it('is ambiguous at a monorepo root with several apps, and lists them', () => { + const result = detectProject(copyFixture('pnpm-turbo'), { env: {} }) + expect(result.kind).toBe('ambiguous') + if (result.kind === 'ambiguous') { + expect( + result.candidates.map((candidate) => [ + candidate.dir, + candidate.framework, + ]) + ).toEqual([ + ['apps/api', 'hono'], + ['apps/web', 'next'], + ]) + } + }) + + it('picks one app of a monorepo with --app, as a workspace', () => { + const root = copyFixture('pnpm-turbo') + const result = detectProject(root, { app: 'apps/web', env: {} }) + expect(result.kind).toBe('project') + if (result.kind === 'project') { + expect(result.project.framework).toBe('next-app') + expect(result.project.monorepo).toMatchObject({ + tool: 'turbo', + workspace: 'apps/web', + workspaceName: 'web', + }) + expect(result.project.packageManager.name).toBe('pnpm') + } + }) + + it('finds nothing to set up without a package.json or Supabase functions', () => { + expect( + detectProject(copyFixture('no-package-json'), { env: {} }).kind + ).toBe('none') + }) + + it('takes the agent environment into account (Claude Code, a cloud shell)', () => { + const result = detectProject(copyFixture('next-firebase'), { + env: { CLAUDECODE: '1', REPL_ID: 'abc' }, + }) + expect(result.kind === 'project' && result.project.editors).toEqual([ + 'claude-code', + ]) + expect(result.kind === 'project' && result.project.remote).toBe('Replit') + }) +}) diff --git a/tests/lib/init/patches.test.ts b/tests/lib/init/patches.test.ts new file mode 100644 index 0000000..3a59cbe --- /dev/null +++ b/tests/lib/init/patches.test.ts @@ -0,0 +1,39 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { detectProject } from '../../../src/lib/init/detect' +import { authjsPatch } from '../../../src/lib/init/patches' +import { copyFixture } from '../../helpers/fixtures' + +function detect(root: string) { + const result = detectProject(root, { env: {} }) + if (result.kind !== 'project') { + throw new Error(`expected a project, got ${result.kind}`) + } + return result.project +} + +describe('authjsPatch', () => { + it('stays manual without a database adapter (Auth.js would refuse to start)', () => { + const root = copyFixture('next-app-authjs-v5') + const { patch, next } = authjsPatch(detect(root), 'src/lib/brew-auth.ts') + expect(patch.status).toBe('manual') + expect(patch.description).toContain('no database adapter') + expect(next).toBeUndefined() + }) + + it('adds the provider when the config has an adapter', () => { + const root = copyFixture('next-app-authjs-v5') + const path = join(root, 'src/auth.ts') + writeFileSync( + path, + readFileSync(path, 'utf8').replace( + ' providers: [GitHub],', + ' adapter: PrismaAdapter(prisma),\n providers: [GitHub],' + ) + ) + const { patch, next } = authjsPatch(detect(root), 'src/lib/brew-auth.ts') + expect(patch.status).toBe('pending') + expect(next).toContain('providers: [brewEmailProvider, GitHub],') + }) +}) diff --git a/tests/package-metadata.test.ts b/tests/package-metadata.test.ts index 03fa528..0f22cf1 100644 --- a/tests/package-metadata.test.ts +++ b/tests/package-metadata.test.ts @@ -48,3 +48,52 @@ describe('npm package metadata', () => { ]) }) }) + +type LauncherManifest = PackageManifest & { + readonly name?: string + readonly version?: string + readonly type?: string + readonly engines?: Readonly> + readonly publishConfig?: Readonly> +} + +const cliVersion = ( + JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8') + ) as { version: string } +).version + +describe.each(['brew.new', 'create-brew'])('launcher %s', (name) => { + const dir = new URL(`../packages/launchers/${name}/`, import.meta.url) + const launcher = JSON.parse( + readFileSync(new URL('package.json', dir), 'utf8') + ) as LauncherManifest + + it('is the CLI version, pinned exactly to the CLI', () => { + expect(launcher.name).toBe(name) + expect(launcher.version).toBe(cliVersion) + expect(launcher.dependencies).toEqual({ '@brew.new/cli': cliVersion }) + }) + + it('ships one ESM bin with a node shebang and nothing else', () => { + expect(launcher.bin).toEqual({ [name]: 'bin.mjs' }) + expect(launcher.type).toBe('module') + expect(launcher.files).toEqual([ + 'bin.mjs', + 'launcher.mjs', + 'README.md', + 'LICENSE', + ]) + expect(launcher.engines).toEqual({ node: '>=20' }) + expect(launcher.publishConfig).toEqual({ access: 'public' }) + expect( + readFileSync(new URL('bin.mjs', dir), 'utf8').startsWith( + '#!/usr/bin/env node\n' + ) + ).toBe(true) + }) + + it('keeps the CLI package itself free of launcher files', () => { + expect(manifest.files).not.toContain('packages') + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 3db1d12..b7b8839 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,5 +19,6 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["src", "tests", "scripts", "tsup.config.ts", "vitest.config.ts"] + "include": ["src", "tests", "scripts", "tsup.config.ts", "vitest.config.ts"], + "exclude": ["tests/fixtures"] }