From f809b6c5063fef3a6f80827e7e6e3c30ec78833b Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Tue, 13 Oct 2026 10:57:55 +0200 Subject: [PATCH 01/10] feat(init): set up Brew app email in a project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `brew-cli init` (destructive; `--dry-run --json` plans, `--yes --json` runs): detect (package manager, monorepo, framework, auth library + Auth.js version, Stripe, existing email providers, editors, cloud shells) → sign in through the browser for a CLI key and an app key (`--no-wait` exits 3 with the link and code, `--resume` continues) → .env.local (app key only, 0600, git-ignored first) → brand → domain (sandbox report, `--domain` adds one and prints DNS records, never waits) → `appEmails.setUpKit` for the detected stack → install @brew.new/sdk with the project's package manager → create-only managed files (lib/brew.ts, lib/brew-auth.ts per auth library, lib/brew-contracts.ts with the key registry, the Supabase Send Email Hook function, .env.example, brew.json with hashes) → patches for the user's auth config (applied only with --apply-patches when the file still hashes to the plan) → MCP for detected editors → AGENTS.md block and, in Claude Code projects, the skills → a sandbox test email. One JSON report (schemas/init-report.json, schemaVersion 1) with steps, files, patches, nextSteps (actor agent|human) and agentInstructions; exit 0 complete/action_required, 1 failed, 2 usage/ambiguous monorepo, 3 sign-in required, 4 confirmation. Idempotency keys `init:::`, so identical re-runs replay. Keys are masked everywhere. Tests: detection matrix over 16 fixture projects; MSW end-to-end runs (exit 4, dry run with zero mutating requests, full run with exact request bodies, files, brew.json and schema, re-run unchanged with the same keys, exit 3 + resume, monorepo exit 2, per-stack runs); generated files type-checked against the installed SDK under a strict tsconfig (with a negative control); a secret canary on every run's stdout and stderr. Co-Authored-By: Claude Opus 5.5 --- docs/commands/README.md | 32 + schemas/init-report.json | 373 ++++ skills/brew-app-emails/SKILL.md | 72 + src/commands/init.ts | 169 ++ src/commands/mcp/install.ts | 2 +- src/generated/skills.ts | 6 + src/lib/init/detect.ts | 539 ++++++ src/lib/init/patches.ts | 354 ++++ src/lib/init/pipeline.ts | 1623 +++++++++++++++++ src/lib/init/report.ts | 311 ++++ src/lib/init/templates.ts | 445 +++++ src/registry.ts | 2 + tests/commands/init.test.ts | 987 ++++++++++ .../fixtures/projects/astro/package-lock.json | 1 + tests/fixtures/projects/astro/package.json | 7 + .../projects/astro/src/pages/index.astro | 1 + .../projects/express-resend/package-lock.json | 1 + .../projects/express-resend/package.json | 11 + .../projects/express-resend/src/index.ts | 3 + .../projects/express-resend/tsconfig.json | 1 + .../projects/hono-workers/package-lock.json | 1 + .../projects/hono-workers/package.json | 10 + .../projects/hono-workers/src/index.ts | 2 + .../projects/hono-workers/tsconfig.json | 1 + .../projects/hono-workers/wrangler.toml | 3 + .../projects/next-app-authjs-v5/package.json | 9 + .../next-app-authjs-v5/src/app/page.tsx | 3 + .../projects/next-app-authjs-v5/src/auth.ts | 6 + .../projects/next-app-authjs-v5/tsconfig.json | 1 + .../projects/next-app-authjs-v5/yarn.lock | 2 + .../next-better-auth/.cursor/rules/style.mdc | 1 + .../projects/next-better-auth/.gitignore | 3 + .../projects/next-better-auth/app/page.tsx | 3 + .../projects/next-better-auth/lib/auth.ts | 9 + .../next-better-auth/package-lock.json | 1 + .../projects/next-better-auth/package.json | 14 + .../projects/next-better-auth/tsconfig.json | 1 + .../projects/next-firebase/package-lock.json | 1 + .../projects/next-firebase/package.json | 11 + .../projects/next-firebase/src/app/page.tsx | 3 + .../next-pages-authjs-v4/package.json | 8 + .../pages/api/auth/[...nextauth].ts | 11 + .../next-pages-authjs-v4/pages/index.tsx | 3 + .../next-pages-authjs-v4/pnpm-lock.yaml | 1 + .../next-pages-authjs-v4/tsconfig.json | 1 + .../projects/react-email/emails/welcome.tsx | 3 + .../projects/react-email/package-lock.json | 1 + .../projects/react-email/package.json | 8 + .../projects/react-router-clerk/app/root.tsx | 3 + .../react-router-clerk/package-lock.json | 1 + .../projects/react-router-clerk/package.json | 14 + .../supabase-functions/supabase/config.toml | 4 + .../supabase/functions/hello/index.ts | 1 + .../fixtures/projects/sveltekit/package.json | 10 + .../projects/sveltekit/pnpm-lock.yaml | 1 + .../sveltekit/src/routes/+page.svelte | 1 + .../projects/vite-supabase-lovable/.gitignore | 3 + .../vite-supabase-lovable/package-lock.json | 1 + .../vite-supabase-lovable/package.json | 16 + .../vite-supabase-lovable/src/main.tsx | 2 + .../supabase/config.toml | 1 + tests/helpers/json-schema.ts | 96 + tests/init-typecheck.test.ts | 257 +++ tests/lib/init/detect.test.ts | 212 +++ tsconfig.json | 3 +- 65 files changed, 5685 insertions(+), 2 deletions(-) create mode 100644 schemas/init-report.json create mode 100644 skills/brew-app-emails/SKILL.md create mode 100644 src/commands/init.ts create mode 100644 src/lib/init/detect.ts create mode 100644 src/lib/init/patches.ts create mode 100644 src/lib/init/pipeline.ts create mode 100644 src/lib/init/report.ts create mode 100644 src/lib/init/templates.ts create mode 100644 tests/commands/init.test.ts create mode 100644 tests/fixtures/projects/astro/package-lock.json create mode 100644 tests/fixtures/projects/astro/package.json create mode 100644 tests/fixtures/projects/astro/src/pages/index.astro create mode 100644 tests/fixtures/projects/express-resend/package-lock.json create mode 100644 tests/fixtures/projects/express-resend/package.json create mode 100644 tests/fixtures/projects/express-resend/src/index.ts create mode 100644 tests/fixtures/projects/express-resend/tsconfig.json create mode 100644 tests/fixtures/projects/hono-workers/package-lock.json create mode 100644 tests/fixtures/projects/hono-workers/package.json create mode 100644 tests/fixtures/projects/hono-workers/src/index.ts create mode 100644 tests/fixtures/projects/hono-workers/tsconfig.json create mode 100644 tests/fixtures/projects/hono-workers/wrangler.toml create mode 100644 tests/fixtures/projects/next-app-authjs-v5/package.json create mode 100644 tests/fixtures/projects/next-app-authjs-v5/src/app/page.tsx create mode 100644 tests/fixtures/projects/next-app-authjs-v5/src/auth.ts create mode 100644 tests/fixtures/projects/next-app-authjs-v5/tsconfig.json create mode 100644 tests/fixtures/projects/next-app-authjs-v5/yarn.lock create mode 100644 tests/fixtures/projects/next-better-auth/.cursor/rules/style.mdc create mode 100644 tests/fixtures/projects/next-better-auth/.gitignore create mode 100644 tests/fixtures/projects/next-better-auth/app/page.tsx create mode 100644 tests/fixtures/projects/next-better-auth/lib/auth.ts create mode 100644 tests/fixtures/projects/next-better-auth/package-lock.json create mode 100644 tests/fixtures/projects/next-better-auth/package.json create mode 100644 tests/fixtures/projects/next-better-auth/tsconfig.json create mode 100644 tests/fixtures/projects/next-firebase/package-lock.json create mode 100644 tests/fixtures/projects/next-firebase/package.json create mode 100644 tests/fixtures/projects/next-firebase/src/app/page.tsx create mode 100644 tests/fixtures/projects/next-pages-authjs-v4/package.json create mode 100644 tests/fixtures/projects/next-pages-authjs-v4/pages/api/auth/[...nextauth].ts create mode 100644 tests/fixtures/projects/next-pages-authjs-v4/pages/index.tsx create mode 100644 tests/fixtures/projects/next-pages-authjs-v4/pnpm-lock.yaml create mode 100644 tests/fixtures/projects/next-pages-authjs-v4/tsconfig.json create mode 100644 tests/fixtures/projects/react-email/emails/welcome.tsx create mode 100644 tests/fixtures/projects/react-email/package-lock.json create mode 100644 tests/fixtures/projects/react-email/package.json create mode 100644 tests/fixtures/projects/react-router-clerk/app/root.tsx create mode 100644 tests/fixtures/projects/react-router-clerk/package-lock.json create mode 100644 tests/fixtures/projects/react-router-clerk/package.json create mode 100644 tests/fixtures/projects/supabase-functions/supabase/config.toml create mode 100644 tests/fixtures/projects/supabase-functions/supabase/functions/hello/index.ts create mode 100644 tests/fixtures/projects/sveltekit/package.json create mode 100644 tests/fixtures/projects/sveltekit/pnpm-lock.yaml create mode 100644 tests/fixtures/projects/sveltekit/src/routes/+page.svelte create mode 100644 tests/fixtures/projects/vite-supabase-lovable/.gitignore create mode 100644 tests/fixtures/projects/vite-supabase-lovable/package-lock.json create mode 100644 tests/fixtures/projects/vite-supabase-lovable/package.json create mode 100644 tests/fixtures/projects/vite-supabase-lovable/src/main.tsx create mode 100644 tests/fixtures/projects/vite-supabase-lovable/supabase/config.toml create mode 100644 tests/helpers/json-schema.ts create mode 100644 tests/init-typecheck.test.ts create mode 100644 tests/lib/init/detect.test.ts diff --git a/docs/commands/README.md b/docs/commands/README.md index c2dbee0..0f2407c 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -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,37 @@ 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.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 the printed patches to your auth config (only files unchanged since the patch was computed) +- `--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 +``` + ### brew-cli api Raw authenticated request against the Brew public API diff --git a/schemas/init-report.json b/schemas/init-report.json new file mode 100644 index 0000000..2ec6896 --- /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` (and `npx brew.new 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/skills/brew-app-emails/SKILL.md b/skills/brew-app-emails/SKILL.md new file mode 100644 index 0000000..8e044fd --- /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 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 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 app-emails upsert order.paid --source-file emails/order-paid.tsx --subject "Order confirmed" --yes +npx brew.new types --out lib/brew-contracts.ts +npx brew.new 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 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 app-emails upsert --yes` | +| `DOMAIN_REQUIRED` | Sandbox: the recipient is not in your Brew organization | Verify a sending domain (`npx brew.new 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..a9c794f --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,169 @@ +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 +} + +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', + '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 the printed patches to your auth config (only files unchanged since the patch was computed)', + }, + { 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', + ], + 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 === true + ? 'and apply the 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: flags.applyPatches === true, + 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..95e01f1 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 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 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 app-emails upsert order.paid --source-file emails/order-paid.tsx --subject \"Order confirmed\" --yes\nnpx brew.new types --out lib/brew-contracts.ts\nnpx brew.new 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 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 app-emails upsert --yes` |\n| `DOMAIN_REQUIRED` | Sandbox: the recipient is not in your Brew organization | Verify a sending domain (`npx brew.new 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..886abd9 --- /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 12.3" 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..6d339c7 --- /dev/null +++ b/src/lib/init/patches.ts @@ -0,0 +1,354 @@ +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] + if (passwordLines.length === 1 && passwordLine !== undefined) { + next.splice( + passwordLine + 1, + 0, + `${innerIndent(next, passwordLine)}sendResetPassword: brewEmails.sendResetPassword,` + ) + } + next.splice(opener + 1, 0, ...inserted) + return computed( + config.path, + config.text, + insertImport(next.join('\n'), importLine), + meta + ) +} + +/** 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' }, + } + } + 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..1bc9416 --- /dev/null +++ b/src/lib/init/pipeline.ts @@ -0,0 +1,1623 @@ +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, + rangeVersion, +} 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, + 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 + readonly applyPatches: boolean + 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`. */ +function rerunCommand( + ctx: CliContext, + options: { readonly drop: readonly string[]; readonly add: readonly string[] } +): string { + const argv = ctx.rawArgv.filter( + (token) => !options.drop.includes(token) && token !== '--yes' + ) + 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); 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) }, + 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, + }) + } + if (explicitKey === undefined) { + 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() + ) + 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 + } + } + 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}` + : domain.added + ? `added ${domain.name}; sandbox until its DNS verifies` + : 'sandbox (no verified domain yet)', + }) + 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`, + }) + 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(client) + const artifacts = await fetchContractArtifacts(client, triggers) + 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) => + options.applyPatches ? applyPatch(writer, computed) : computed.patch + ) + 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'], + }), + 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 + } + const version = rangeVersion(project.sdkRange ?? undefined) + if ( + version !== null && + (version[0] > 12 || (version[0] === 12 && version[1] >= 3)) + ) { + 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') { + 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) { + steps.push({ + id: 'test-email', + status: 'planned', + detail: `lifecycle.welcome to ${to ?? 'the person who signs in'}`, + }) + return { status: 'planned', key: 'lifecycle.welcome', 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) + steps.push({ + id: 'test-email', + status: 'failed', + detail: describeError(error), + }) + 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 }), + } +} 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..5885127 --- /dev/null +++ b/src/lib/init/templates.ts @@ -0,0 +1,445 @@ +import { posix } from 'node:path' +import type { AuthId, ProjectDetection } 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. */ +export const SDK_RANGE = '^12.3.0' +export const SDK_PACKAGE = '@brew.new/sdk' + +/** The project-facing command, for files teammates commit and read. */ +export const PROJECT_COMMAND = 'npx brew.new' + +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') + } + return [ + HEADER, + ts + ? "import { type BrewClient, createBrewClient } from '@brew.new/sdk'" + : "import { createBrewClient } from '@brew.new/sdk'", + '', + 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 = process.env.BREW_API_KEY', + " if (apiKey === undefined || apiKey === '') {", + " throw new Error('BREW_API_KEY is not set: add it to .env.local (npx brew.new 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 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..d90dd25 --- /dev/null +++ b/tests/commands/init.test.ts @@ -0,0 +1,987 @@ +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 { 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(' ') +} + +/** Brew's v1 API for init: brand, domains, kit, triggers, the by-key fire. */ +function useBrewApi( + options: { + readonly domains?: ReadonlyArray> + readonly failKeys?: readonly string[] + } = {} +): 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( + 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', + }, + { status: 201 } + ) + }), + http.post(`${API}/v1/app-emails/kit`, async ({ request }) => { + await record(request) + return HttpResponse.json({ + results: KIT_KEYS.map((key, index) => + 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: 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', + })), + 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@^12.3.0'") + }) + + 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@^12.3.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', + command: 'brew-cli init --json --apply-patches --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 result = await init( + ['--yes', '--json', '--apply-patches', '--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@^12.3.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('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@^12.3.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('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, + }) + }) +}) + +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, + 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' }), + 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@^12.3.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/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..53574d2 --- /dev/null +++ b/tests/init-typecheck.test.ts @@ -0,0 +1,257 @@ +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 { 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 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: [], + }, + }) + ), + http.get(`${API}/v1/automations/triggers`, () => + HttpResponse.json({ + data: 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', + })), + 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' + ) + 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, + }, + 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], +] + +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/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/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"] } From dff6e2b09eae5bb8ac5cb4b1891329434b0cbe54 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Tue, 13 Oct 2026 10:57:55 +0200 Subject: [PATCH 02/10] feat(launchers): `npx brew.new init` and `npm create brew` packages/launchers/{brew.new,create-brew}: each runs @brew.new/cli in the same process, pinned to exactly the CLI's version. The shared launcher inserts `init` when no subcommand is given (create-brew also for a bare --help), and sets BREW_CLI_INVOKED_AS (`npx -y brew.new@`, `npm create brew@latest --`) so every printed command re-enters through the launcher. - scripts/sync-launchers.ts [--check]: version, exact CLI pin, launcher copy and LICENSE per package (CI + release gate). - scripts/smoke-launchers.ts: npm pack the CLI and both launchers, install into a scratch project (offline, or --registry after the CLI publishes), run --version and --help. - release.yml publishes both launchers after the CLI, skipping a version npm already has. Nothing is published from this change. - Unit tests for the argv rules, the invocation prefix round trip and launch(); package metadata pinned. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 24 +++ AGENTS.md | 7 + RELEASING.md | 15 +- package.json | 7 +- packages/launchers/brew.new/LICENSE | 21 +++ packages/launchers/brew.new/README.md | 24 +++ packages/launchers/brew.new/bin.mjs | 8 + packages/launchers/brew.new/launcher.mjs | 89 ++++++++++ packages/launchers/brew.new/package.json | 49 ++++++ packages/launchers/create-brew/LICENSE | 21 +++ packages/launchers/create-brew/README.md | 17 ++ packages/launchers/create-brew/bin.mjs | 8 + packages/launchers/create-brew/launcher.mjs | 89 ++++++++++ packages/launchers/create-brew/package.json | 49 ++++++ packages/launchers/launcher.d.mts | 14 ++ packages/launchers/launcher.mjs | 89 ++++++++++ scripts/smoke-launchers.ts | 175 ++++++++++++++++++++ scripts/sync-launchers.ts | 148 +++++++++++++++++ tests/launchers.test.ts | 174 +++++++++++++++++++ tests/package-metadata.test.ts | 49 ++++++ 21 files changed, 1076 insertions(+), 4 deletions(-) create mode 100644 packages/launchers/brew.new/LICENSE create mode 100644 packages/launchers/brew.new/README.md create mode 100755 packages/launchers/brew.new/bin.mjs create mode 100644 packages/launchers/brew.new/launcher.mjs create mode 100644 packages/launchers/brew.new/package.json create mode 100644 packages/launchers/create-brew/LICENSE create mode 100644 packages/launchers/create-brew/README.md create mode 100755 packages/launchers/create-brew/bin.mjs create mode 100644 packages/launchers/create-brew/launcher.mjs create mode 100644 packages/launchers/create-brew/package.json create mode 100644 packages/launchers/launcher.d.mts create mode 100644 packages/launchers/launcher.mjs create mode 100644 scripts/smoke-launchers.ts create mode 100644 scripts/sync-launchers.ts create mode 100644 tests/launchers.test.ts 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..da0d940 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,7 +42,11 @@ jobs: - run: bun run test - run: bun run docs:commands:check - run: bun run generate:skills:check + # The launchers ship the CLI's own version, pinned exactly. + - run: bun run sync:launchers:check - run: bun run build + - name: Launcher pack smoke + run: bun run smoke:launchers - name: Compile standalone binaries run: bun run scripts/build-binaries.ts - name: Create GitHub Release @@ -62,6 +66,26 @@ jobs: npm publish --provenance --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # `npx brew.new` and `npm create brew`: thin launchers pinned to this + # CLI version, published after it. A version npm already has is + # skipped, so re-running the workflow is safe. + - name: Publish launchers (brew.new, create-brew) + run: | + if [ -z "$NODE_AUTH_TOKEN" ]; then + echo "NPM_TOKEN secret not set — skipping launcher publish." + exit 0 + fi + VERSION=$(node -p "require('./package.json').version") + bun run scripts/smoke-launchers.ts --registry + 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 + (cd "packages/launchers/$pkg" && npm publish --provenance --access public) + 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/RELEASING.md b/RELEASING.md index 38beb50..c95e3e4 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,12 @@ 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 a + registry smoke install (`scripts/smoke-launchers.ts --registry`). Each + is the CLI's version, depends on exactly that `@brew.new/cli`, and is + skipped when npm already has the version. +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 +96,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, or the launcher step fails after the CLI is out. + - **`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/package.json b/package.json index cf0575e..db31f4d 100644 --- a/package.json +++ b/package.json @@ -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..cf4fd37 --- /dev/null +++ b/packages/launchers/brew.new/launcher.mjs @@ -0,0 +1,89 @@ +// 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 `npx`, and `npm create brew@latest --` for `create-brew`. + */ +export function invokedAs(packageName, version) { + return packageName === 'create-brew' + ? 'npm create brew@latest --' + : `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..b57b060 --- /dev/null +++ b/packages/launchers/brew.new/package.json @@ -0,0 +1,49 @@ +{ + "name": "brew.new", + "version": "0.15.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.15.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..cf4fd37 --- /dev/null +++ b/packages/launchers/create-brew/launcher.mjs @@ -0,0 +1,89 @@ +// 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 `npx`, and `npm create brew@latest --` for `create-brew`. + */ +export function invokedAs(packageName, version) { + return packageName === 'create-brew' + ? 'npm create brew@latest --' + : `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..8fec48e --- /dev/null +++ b/packages/launchers/create-brew/package.json @@ -0,0 +1,49 @@ +{ + "name": "create-brew", + "version": "0.15.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.15.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..cf4fd37 --- /dev/null +++ b/packages/launchers/launcher.mjs @@ -0,0 +1,89 @@ +// 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 `npx`, and `npm create brew@latest --` for `create-brew`. + */ +export function invokedAs(packageName, version) { + return packageName === 'create-brew' + ? 'npm create brew@latest --' + : `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/scripts/smoke-launchers.ts b/scripts/smoke-launchers.ts new file mode 100644 index 0000000..186348a --- /dev/null +++ b/scripts/smoke-launchers.ts @@ -0,0 +1,175 @@ +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): string { + return execFileSync(command, [...args], { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }) +} + +/** `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)) + } + } + for (const entry of launchers) { + const bin = join(modules, entry.name, 'bin.mjs') + const printed = run(process.execPath, [bin, '--version'], project).trim() + if (printed !== version) { + throw new Error( + `${entry.name} --version printed "${printed}", not ${version}` + ) + } + const help = run(process.execPath, [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}"`) + } + console.log(`${entry.name}@${version}: --version and --help 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/tests/launchers.test.ts b/tests/launchers.test.ts new file mode 100644 index 0000000..2511448 --- /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@latest --') + expect( + getInvocationPrefix({ + BREW_CLI_INVOKED_AS: invokedAs('create-brew', '0.18.0'), + }) + ).toBe('npm create brew@latest --') + }) + + 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@latest -- 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/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') + }) +}) From db84e5d59e7f95a62c54423b0e8cd0f515e1481d Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Tue, 13 Oct 2026 10:57:55 +0200 Subject: [PATCH 03/10] docs: init and the launchers in README and CHANGELOG Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 40 ++++++++++++++++++++++++++++++++++++++++ README.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 560e7ce..d38dad8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ # 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#2066 and #2060. + +### 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` applies them when the file is unchanged), 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`, `--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::`. +- **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..3895cfa 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,50 @@ 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 init # or: npm create brew@latest, or brew-cli init +``` + +`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 (applied with `--apply-patches`, + only to files unchanged since the patch was computed); +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 init --dry-run --json # the plan; no writes, no sign-in, no mutating requests +npx brew.new 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`, From a799201daac8ba3fad0cc48c20a9b96091b91ea4 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Fri, 16 Oct 2026 07:33:22 -0400 Subject: [PATCH 04/10] chore: 0.18.0 on top of 0.17.0; init tests follow main's contract types - package.json and both launchers go to 0.18.0 (`sync:launchers`), and init's notes get their own 0.18.0 section. - `types` (and init's `lib/brew-contracts.ts`) now read the platform's TypeScript contract per trigger, so the init tests serve `GET /v1/automations/triggers/{id}/contract` from the same trigger rows (tests/helpers/contract-artifacts.ts). - The agent sign-in tests pin the fake clock: the login test server's session expires 10 minutes after a fixed instant, so on the real clock they started failing once that date passed. Co-Authored-By: Claude Opus 5.5 --- packages/launchers/brew.new/package.json | 4 +- packages/launchers/create-brew/package.json | 4 +- tests/commands/init.test.ts | 53 +++++++----- tests/helpers/contract-artifacts.ts | 92 +++++++++++++++++++++ tests/init-typecheck.test.ts | 46 ++++++----- 5 files changed, 153 insertions(+), 46 deletions(-) create mode 100644 tests/helpers/contract-artifacts.ts diff --git a/packages/launchers/brew.new/package.json b/packages/launchers/brew.new/package.json index b57b060..27142f9 100644 --- a/packages/launchers/brew.new/package.json +++ b/packages/launchers/brew.new/package.json @@ -1,6 +1,6 @@ { "name": "brew.new", - "version": "0.15.0", + "version": "0.18.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", @@ -41,7 +41,7 @@ "node": ">=20" }, "dependencies": { - "@brew.new/cli": "0.15.0" + "@brew.new/cli": "0.18.0" }, "publishConfig": { "access": "public" diff --git a/packages/launchers/create-brew/package.json b/packages/launchers/create-brew/package.json index 8fec48e..0748d95 100644 --- a/packages/launchers/create-brew/package.json +++ b/packages/launchers/create-brew/package.json @@ -1,6 +1,6 @@ { "name": "create-brew", - "version": "0.15.0", + "version": "0.18.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", @@ -41,7 +41,7 @@ "node": ">=20" }, "dependencies": { - "@brew.new/cli": "0.15.0" + "@brew.new/cli": "0.18.0" }, "publishConfig": { "access": "public" diff --git a/tests/commands/init.test.ts b/tests/commands/init.test.ts index d90dd25..fd50dbf 100644 --- a/tests/commands/init.test.ts +++ b/tests/commands/init.test.ts @@ -14,6 +14,7 @@ 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 { @@ -62,6 +63,32 @@ function titleOf(key: string): string { .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: { @@ -84,6 +111,7 @@ function useBrewApi( return body } server.use( + contractHandler(API, kitTriggerRows), http.get(`${API}/v1/brand`, async ({ request }) => { await record(request) return HttpResponse.json({ @@ -176,28 +204,7 @@ function useBrewApi( http.get(`${API}/v1/automations/triggers`, async ({ request }) => { await record(request) return HttpResponse.json({ - data: 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', - })), + data: kitTriggerRows(), pagination: { limit: 100, cursor: null, hasMore: false }, }) }), @@ -784,6 +791,7 @@ describe('init: sign-in for agents (exit 3)', () => { const pending = await init(['--yes', '--json', '--no-test-email'], { cwd: root, env, + clock: fakeClock(), spawn: recordingSpawn().spawn, }) expect(pending.code).toBe(3) @@ -837,6 +845,7 @@ describe('init: sign-in for agents (exit 3)', () => { 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) 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/init-typecheck.test.ts b/tests/init-typecheck.test.ts index 53574d2..33a778b 100644 --- a/tests/init-typecheck.test.ts +++ b/tests/init-typecheck.test.ts @@ -12,6 +12,7 @@ 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' @@ -37,6 +38,29 @@ const KEYS = [ '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`, () => @@ -60,28 +84,10 @@ function useApi(): void { }, }) ), + contractHandler(API, triggerRows), http.get(`${API}/v1/automations/triggers`, () => HttpResponse.json({ - data: 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', - })), + data: triggerRows(), pagination: { limit: 100, cursor: null, hasMore: false }, }) ) From fef8b40d93fc7c425808fefea71c4091cd8e1415 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Fri, 16 Oct 2026 07:50:50 -0400 Subject: [PATCH 05/10] fix(init,launchers): review fixes; nothing names unpublished launchers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of init against the plan and the platform: - release.yml: the launcher step runs after the CLI is published and never fails the release. Sync check, pack smoke and registry smoke moved into it; a version npm already has is skipped, and a failed check or publish (a name the token does not own yet) leaves a warning. CI still gates sync and pack smoke. - Generated files, the AGENTS.md block, the brew-app-emails skill, README and the report schema say `npx @brew.new/cli` (on npm today) instead of `npx brew.new`, whose name is not ours yet. - `--apply-patches @,…`: the report's nextStep carries the hash of the file the diff was computed on, and a file edited since then is reported `stale` and left alone. Re-run commands drop a flag's value with the flag. - The Auth.js patch is manual when the config has no database adapter (Auth.js refuses an email provider without one, breaking all sign-in). - After a browser sign-in the run acts as the new app key, so the kit, contracts and test email land in the brand that key is bound to, even when an explicit key of another brand started the run. - lib/brew.ts reads the key through `$env/dynamic/private` (SvelteKit) and `import.meta.env` (Astro); Express, Hono and plain Node get a blocking step to load .env.local. SvelteKit and Astro join the typecheck matrix. - A test email that does not go out is a non-blocking retry step, and the dry-run names the key the real run would send. - `--app-url` is shown on the approval page so the right brand is picked. - create-brew pins its version in BREW_CLI_INVOKED_AS (`npm create brew@ --`), and the launcher smoke runs the packed launchers under Node, including an `init --dry-run --json` argv check. - The generated code installs `@brew.new/sdk@^12.5.0` (npm's 12.3.0 has no `send` or `appEmails`); the Better Auth patch no longer promises a reset hook it could not add; a git-tracked .env.local gets the `git rm --cached` hint. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 27 +++--- CHANGELOG.md | 22 ++++- README.md | 16 ++-- RELEASING.md | 15 ++-- docs/commands/README.md | 7 +- packages/launchers/brew.new/launcher.mjs | 5 +- packages/launchers/create-brew/launcher.mjs | 5 +- packages/launchers/launcher.mjs | 5 +- schemas/init-report.json | 2 +- scripts/smoke-launchers.ts | 36 +++++++- skills/brew-app-emails/SKILL.md | 16 ++-- src/commands/init.ts | 64 ++++++++++++-- src/generated/skills.ts | 2 +- src/lib/init/patches.ts | 26 +++++- src/lib/init/pipeline.ts | 96 +++++++++++++++++---- src/lib/init/templates.ts | 24 ++++-- tests/commands/init.test.ts | 71 +++++++++++++-- tests/init-typecheck.test.ts | 21 ++++- tests/launchers.test.ts | 6 +- tests/lib/init/patches.test.ts | 39 +++++++++ 20 files changed, 417 insertions(+), 88 deletions(-) create mode 100644 tests/lib/init/patches.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index da0d940..5309800 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,11 +42,7 @@ jobs: - run: bun run test - run: bun run docs:commands:check - run: bun run generate:skills:check - # The launchers ship the CLI's own version, pinned exactly. - - run: bun run sync:launchers:check - run: bun run build - - name: Launcher pack smoke - run: bun run smoke:launchers - name: Compile standalone binaries run: bun run scripts/build-binaries.ts - name: Create GitHub Release @@ -66,23 +62,32 @@ jobs: npm publish --provenance --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - # `npx brew.new` and `npm create brew`: thin launchers pinned to this - # CLI version, published after it. A version npm already has is - # skipped, so re-running the workflow is safe. + # `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 "NPM_TOKEN secret not set — skipping launcher publish." + echo "::warning::NPM_TOKEN not set; launchers not published." exit 0 fi VERSION=$(node -p "require('./package.json').version") - bun run scripts/smoke-launchers.ts --registry + 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." + echo "$pkg@$VERSION is already on npm; skipping." continue fi - (cd "packages/launchers/$pkg" && npm publish --provenance --access public) + 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 }} diff --git a/CHANGELOG.md b/CHANGELOG.md index d38dad8..adef688 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,16 +19,32 @@ GetBrew/brew-v2#2066 and #2060. `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` applies them when the file is unchanged), configure + (`--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`, `--no-install`, `--no-mcp`, + `--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::`. + 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. The generated code and the + install command use `@brew.new/sdk@^12.5.0`, the planned release with + `send`, `appEmails` and the auth adapters. +- 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` (brew-v2 PR-L 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 diff --git a/README.md b/README.md index 3895cfa..01597e8 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,12 @@ brew-cli emails send --help ## Set up Brew in your app: `init` ```bash -npx brew.new init # or: npm create brew@latest, or brew-cli init +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: @@ -63,8 +66,11 @@ monorepo, editors), signs you in through the browser, and then: 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 (applied with `--apply-patches`, - only to files unchanged since the patch was computed); +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. @@ -80,8 +86,8 @@ Agents run it in two steps and read one JSON report `nextSteps[]` with `actor: agent | human`, `agentInstructions`): ```bash -npx brew.new init --dry-run --json # the plan; no writes, no sign-in, no mutating requests -npx brew.new init --yes --json # exit 3: show the person nextSteps' link + code, then run its resume command +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), diff --git a/RELEASING.md b/RELEASING.md index c95e3e4..ceed6b1 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -71,10 +71,15 @@ The workflow then: (`scripts/build-binaries.ts`) and attaches them + `checksums.txt` to an auto-generated GitHub Release. 5. Publishes the launchers `brew.new` (`npx brew.new init`) and - `create-brew` (`npm create brew`) from `packages/launchers/`, after a - registry smoke install (`scripts/smoke-launchers.ts --registry`). Each - is the CLI's version, depends on exactly that `@brew.new/cli`, and is - skipped when npm already has the version. + `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). @@ -99,7 +104,7 @@ brew install getbrew/tap/brew-cli - **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, or the launcher step fails after the CLI is out. + 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 diff --git a/docs/commands/README.md b/docs/commands/README.md index 0f2407c..019fa90 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -2825,14 +2825,15 @@ brew-cli mcp install --client vscode --print 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.send(...)` +- 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 the printed patches to your auth config (only files unchanged since the patch was computed) +- `--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 @@ -2848,7 +2849,7 @@ 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 +brew-cli init --yes --apply-patches better-auth-config@3f9c2a7d1b04 ``` ### brew-cli api diff --git a/packages/launchers/brew.new/launcher.mjs b/packages/launchers/brew.new/launcher.mjs index cf4fd37..30204f4 100644 --- a/packages/launchers/brew.new/launcher.mjs +++ b/packages/launchers/brew.new/launcher.mjs @@ -60,11 +60,12 @@ export function launcherArgv(args, options = {}) { /** * 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 `npx`, and `npm create brew@latest --` for `create-brew`. + * 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@latest --' + ? `npm create brew@${version} --` : `npx -y ${packageName}@${version}` } diff --git a/packages/launchers/create-brew/launcher.mjs b/packages/launchers/create-brew/launcher.mjs index cf4fd37..30204f4 100644 --- a/packages/launchers/create-brew/launcher.mjs +++ b/packages/launchers/create-brew/launcher.mjs @@ -60,11 +60,12 @@ export function launcherArgv(args, options = {}) { /** * 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 `npx`, and `npm create brew@latest --` for `create-brew`. + * 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@latest --' + ? `npm create brew@${version} --` : `npx -y ${packageName}@${version}` } diff --git a/packages/launchers/launcher.mjs b/packages/launchers/launcher.mjs index cf4fd37..30204f4 100644 --- a/packages/launchers/launcher.mjs +++ b/packages/launchers/launcher.mjs @@ -60,11 +60,12 @@ export function launcherArgv(args, options = {}) { /** * 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 `npx`, and `npm create brew@latest --` for `create-brew`. + * 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@latest --' + ? `npm create brew@${version} --` : `npx -y ${packageName}@${version}` } diff --git a/schemas/init-report.json b/schemas/init-report.json index 2ec6896..ce8a3bf 100644 --- a/schemas/init-report.json +++ b/schemas/init-report.json @@ -2,7 +2,7 @@ "$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` (and `npx brew.new init --json`) prints. schemaVersion 1. Never carries a secret: keys are masked.", + "description": "What `brew-cli init --json` prints. schemaVersion 1. Never carries a secret: keys are masked.", "type": "object", "additionalProperties": false, "required": [ diff --git a/scripts/smoke-launchers.ts b/scripts/smoke-launchers.ts index 186348a..dd4ca29 100644 --- a/scripts/smoke-launchers.ts +++ b/scripts/smoke-launchers.ts @@ -36,11 +36,17 @@ const EXPECTED_FILES = [ 'package/package.json', ] -function run(command: string, args: readonly string[], cwd: string): string { +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 } }), }) } @@ -146,15 +152,18 @@ async function main(): Promise { 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(process.execPath, [bin, '--version'], project).trim() + const printed = run('node', [bin, '--version'], project).trim() if (printed !== version) { throw new Error( `${entry.name} --version printed "${printed}", not ${version}` ) } - const help = run(process.execPath, [bin, '--help'], project) + const help = run('node', [bin, '--help'], project) const expected = entry.name === 'create-brew' ? 'Usage: brew-cli init' @@ -162,7 +171,26 @@ async function main(): Promise { if (!help.includes(expected)) { throw new Error(`${entry.name} --help did not print "${expected}"`) } - console.log(`${entry.name}@${version}: --version and --help ok`) + // 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 }) diff --git a/skills/brew-app-emails/SKILL.md b/skills/brew-app-emails/SKILL.md index 8e044fd..8254fbd 100644 --- a/skills/brew-app-emails/SKILL.md +++ b/skills/brew-app-emails/SKILL.md @@ -10,7 +10,7 @@ Every email this app sends has a **trigger key** (`auth.password-reset`, automation behind the key; code only sends the key and a payload: ```ts -import { getBrew } from './lib/brew' // written by `npx brew.new init` +import { getBrew } from './lib/brew' // written by `npx @brew.new/cli init` await getBrew().send( 'auth.password-reset', @@ -22,7 +22,7 @@ await getBrew().send( `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 types --out lib/brew-contracts.ts`. +`npx @brew.new/cli types --out lib/brew-contracts.ts`. ## Rules @@ -50,9 +50,9 @@ compile error. Regenerate it after changing a payload contract: ## A new email ```bash -npx brew.new app-emails upsert order.paid --source-file emails/order-paid.tsx --subject "Order confirmed" --yes -npx brew.new types --out lib/brew-contracts.ts -npx brew.new send order.paid --to you@example.com --data '{"orderId":"ord_1"}' --yes +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 @@ -62,9 +62,9 @@ A design edited in Brew is never replaced: `upsert` answers | Error | Meaning | Fix | | --- | --- | --- | -| `TRIGGER_KEY_NOT_FOUND` (404) | No trigger has the key in this brand | `npx brew.new 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 app-emails upsert --yes` | -| `DOMAIN_REQUIRED` | Sandbox: the recipient is not in your Brew organization | Verify a sending domain (`npx brew.new domains add `) | +| `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 | diff --git a/src/commands/init.ts b/src/commands/init.ts index a9c794f..d357d70 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -47,6 +47,51 @@ function parseStack(value: unknown): AppEmailKitStackProvider | undefined { 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: @@ -58,6 +103,7 @@ export const initCommand = defineCommand({ 'domains.add', 'appEmails.setUpKit', 'automations.triggers.list', + 'automations.triggers.getContract', 'send', ], commandClass: 'destructive', @@ -91,9 +137,14 @@ export const initCommand = defineCommand({ 'Send the test email here (default: the person who signed in; the sandbox reaches your Brew team only)', }, { - flag: '--apply-patches', + 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: - 'Apply the printed patches to your auth config (only files unchanged since the patch was computed)', + "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' }, @@ -123,7 +174,7 @@ export const initCommand = defineCommand({ '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', + 'brew-cli init --yes --apply-patches better-auth-config@3f9c2a7d1b04', ], confirmSummary: ({ flags }) => { if (flags.dryRun === true) { @@ -135,8 +186,8 @@ export const initCommand = defineCommand({ `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 === true - ? 'and apply the auth config patches;' + 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.', ] @@ -160,7 +211,8 @@ export const initCommand = defineCommand({ agentFiles: flags.agentFiles !== false, testEmail: flags.testEmail !== false, testTo: flagString(flags.testTo), - applyPatches: flags.applyPatches === true, + applyPatches: parsePatchPins(flags.applyPatches), + appUrl: parseAppUrl(flags.appUrl), noWait, resume, openBrowser: flags.open !== false, diff --git a/src/generated/skills.ts b/src/generated/skills.ts index 95e01f1..e332875 100644 --- a/src/generated/skills.ts +++ b/src/generated/skills.ts @@ -14,7 +14,7 @@ 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 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 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 app-emails upsert order.paid --source-file emails/order-paid.tsx --subject \"Order confirmed\" --yes\nnpx brew.new types --out lib/brew-contracts.ts\nnpx brew.new 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 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 app-emails upsert --yes` |\n| `DOMAIN_REQUIRED` | Sandbox: the recipient is not in your Brew organization | Verify a sending domain (`npx brew.new 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", + 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", diff --git a/src/lib/init/patches.ts b/src/lib/init/patches.ts index 6d339c7..8d41cfc 100644 --- a/src/lib/init/patches.ts +++ b/src/lib/init/patches.ts @@ -207,7 +207,8 @@ export function betterAuthPatch( /^\s*emailAndPassword\s*:\s*\{\s*$/.test(line) ? [index] : [] ) const passwordLine = passwordLines[0] - if (passwordLines.length === 1 && passwordLine !== undefined) { + const resetInserted = passwordLines.length === 1 && passwordLine !== undefined + if (resetInserted) { next.splice( passwordLine + 1, 0, @@ -215,11 +216,19 @@ export function betterAuthPatch( ) } 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), - meta + described ) } @@ -252,6 +261,19 @@ export function authjsPatch( 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 diff --git a/src/lib/init/pipeline.ts b/src/lib/init/pipeline.ts index 1bc9416..d984245 100644 --- a/src/lib/init/pipeline.ts +++ b/src/lib/init/pipeline.ts @@ -105,7 +105,10 @@ export type InitOptions = { readonly agentFiles: boolean readonly testEmail: boolean readonly testTo: string | undefined - readonly applyPatches: boolean + /** 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 @@ -151,13 +154,26 @@ function describeError(error: unknown): string { } /** 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 = ctx.rawArgv.filter( - (token) => !options.drop.includes(token) && token !== '--yes' - ) + 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) @@ -242,7 +258,7 @@ export async function runInit( id: 'env', status: 'failed', detail: - '.env.local is not git-ignored (a later rule re-includes it); no key was written', + '.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', @@ -351,7 +367,10 @@ export async function runInit( ? await resumeBrowserLogin(ctx, { waitMs, ...loginCommands }) : await startBrowserLogin(ctx, { keys, - client: { project: project.name.slice(0, 100) }, + client: { + project: project.name.slice(0, 100), + ...(options.appUrl === undefined ? {} : { appUrl: options.appUrl }), + }, openBrowser: options.openBrowser, waitMs, ...loginCommands, @@ -417,9 +436,10 @@ export async function runInit( brandName: approved.brand.name, }) } - if (explicitKey === undefined) { - credential = authFor(minted.key, 'browser', approved.brand.brandId) - } + // 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', @@ -823,9 +843,17 @@ export async function runInit( if (project.auth === 'supabase' && project.hasSupabaseDir) { computedPatches.push(supabaseConfigPatch(project)) } - const patches: InitPatch[] = computedPatches.map((computed) => - options.applyPatches ? applyPatch(writer, computed) : computed.patch - ) + 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({ @@ -837,7 +865,10 @@ export async function runInit( title: `${patch.title} (review the diff, then apply)`, command: rerunCommand(ctx, { drop: ['--resume', '--no-wait', '--dry-run', '--apply-patches'], - add: ['--apply-patches'], + add: [ + '--apply-patches', + `${patch.id}@${(patch.baseSha256 ?? '').slice(0, 12)}`, + ], }), blocking: true, }) @@ -1247,6 +1278,22 @@ function projectNextSteps(input: { 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', @@ -1288,12 +1335,17 @@ async function sendTestEmail(input: { 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: `lifecycle.welcome to ${to ?? 'the person who signs in'}`, + detail: `${planned} to ${to ?? 'the person who signs in'}`, }) - return { status: 'planned', key: 'lifecycle.welcome', to } + return { status: 'planned', key: planned, to } } if (key === null) { steps.push({ @@ -1357,10 +1409,20 @@ async function sendTestEmail(input: { } } 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: 'failed', - detail: describeError(error), + 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) } } diff --git a/src/lib/init/templates.ts b/src/lib/init/templates.ts index 5885127..1e5d838 100644 --- a/src/lib/init/templates.ts +++ b/src/lib/init/templates.ts @@ -9,11 +9,15 @@ import type { AuthId, ProjectDetection } from './detect' */ /** The SDK range generated code and the install step ask for. */ -export const SDK_RANGE = '^12.3.0' +// The first SDK release with `send`, `appEmails` and the auth adapter +// subpaths (GetBrew/typescript-sdk#55 + #64, planned 12.5.0; see the app +// email SDK gate in RELEASING.md). npm's 12.3.0 has none of them. +export const SDK_RANGE = '^12.5.0' export const SDK_PACKAGE = '@brew.new/sdk' /** The project-facing command, for files teammates commit and read. */ -export const PROJECT_COMMAND = 'npx brew.new' +// `@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' @@ -132,11 +136,21 @@ export function clientFile(project: ProjectDetection): string { '', ].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', '', @@ -149,9 +163,9 @@ export function clientFile(project: ProjectDetection): string { ? 'export function getBrew(): BrewClient {' : 'export function getBrew() {', ' if (client === undefined) {', - ' const apiKey = process.env.BREW_API_KEY', + ` const apiKey = ${keyRead}`, " if (apiKey === undefined || apiKey === '') {", - " throw new Error('BREW_API_KEY is not set: add it to .env.local (npx brew.new init writes it).')", + " throw new Error('BREW_API_KEY is not set: add it to .env.local (npx @brew.new/cli init writes it).')", ' }', ' client = createBrewClient({ apiKey })', ' }', @@ -300,7 +314,7 @@ export function authHelperFile( " * 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 init` prints the patch.', + ' * betterAuth({ … }): `npx @brew.new/cli init` prints the patch.', ...(after ? [ ' * Sends run after the response (`after`), so sign-up never waits on email.', diff --git a/tests/commands/init.test.ts b/tests/commands/init.test.ts index fd50dbf..5aac4d1 100644 --- a/tests/commands/init.test.ts +++ b/tests/commands/init.test.ts @@ -353,7 +353,7 @@ describe('init: confirmation and dry run', () => { ]) expect( result.report.steps.find((step) => step.id === 'install')?.detail - ).toBe("npm install '@brew.new/sdk@^12.3.0'") + ).toBe("npm install '@brew.new/sdk@^12.5.0'") }) it('--dry-run without any credential sends no request at all', async () => { @@ -476,7 +476,7 @@ describe('init --yes: the full run', () => { expect(spawn.calls).toEqual([ { command: 'npm', - args: ['install', '@brew.new/sdk@^12.3.0'], + args: ['install', '@brew.new/sdk@^12.5.0'], cwd: root, }, ]) @@ -512,7 +512,8 @@ describe('init --yes: the full run', () => { actor: 'agent', kind: 'apply-patch', patchId: 'better-auth-config', - command: 'brew-cli init --json --apply-patches --yes', + // 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') @@ -564,8 +565,21 @@ describe('init --yes: the full run', () => { 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', '--no-test-email', '--no-install'], + [ + '--yes', + '--json', + '--apply-patches', + pin, + '--no-test-email', + '--no-install', + ], { cwd: root, env, spawn: recordingSpawn().spawn } ) expect(result.code).toBe(0) @@ -595,7 +609,7 @@ describe('init --yes: the full run', () => { result.report.nextSteps.find((step) => step.id === 'install-sdk') ).toMatchObject({ blocking: true, - command: "npm install '@brew.new/sdk@^12.3.0'", + command: "npm install '@brew.new/sdk@^12.5.0'", }) useBrewApi() @@ -610,6 +624,49 @@ describe('init --yes: the full run', () => { 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() @@ -709,7 +766,7 @@ describe('init --yes: the full run', () => { "Deno.env.get('BREW_API_KEY')" ) expect(read(root, 'supabase/functions/send-email/index.ts')).toContain( - 'npm:@brew.new/sdk@^12.3.0/supabase' + 'npm:@brew.new/sdk@^12.5.0/supabase' ) expect(result.report.patches).toMatchObject([ { id: 'supabase-config-hook', status: 'pending' }, @@ -986,7 +1043,7 @@ describe('init per stack', () => { expect(spawn.calls).toEqual([ { command: 'pnpm', - args: ['add', '--filter', 'web', '@brew.new/sdk@^12.3.0'], + args: ['add', '--filter', 'web', '@brew.new/sdk@^12.5.0'], cwd: root, }, ]) diff --git a/tests/init-typecheck.test.ts b/tests/init-typecheck.test.ts index 33a778b..fe05d4e 100644 --- a/tests/init-typecheck.test.ts +++ b/tests/init-typecheck.test.ts @@ -128,6 +128,23 @@ function scratchProject(input: { 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` @@ -151,7 +168,7 @@ function scratchProject(input: { noEmit: true, skipLibCheck: true, }, - include: input.files, + include: [...input.files, 'framework-env.d.ts'], }, null, 2 @@ -181,6 +198,8 @@ const CASES: ReadonlyArray = [ ['hono-workers', false], ['express-resend', true], ['next-firebase', false], + ['sveltekit', false], + ['astro', false], ] describe('generated files type-check against @brew.new/sdk', () => { diff --git a/tests/launchers.test.ts b/tests/launchers.test.ts index 2511448..de09544 100644 --- a/tests/launchers.test.ts +++ b/tests/launchers.test.ts @@ -77,12 +77,12 @@ describe('BREW_CLI_INVOKED_AS from a launcher', () => { 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@latest --') + 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@latest --') + ).toBe('npm create brew@0.18.0 --') }) it('a printed command re-enters through the launcher without a second init', async () => { @@ -100,7 +100,7 @@ describe('BREW_CLI_INVOKED_AS from a launcher', () => { ) expect(result.code).toBe(4) const { confirmCommand } = result.json as { confirmCommand: string } - expect(confirmCommand).toBe('npm create brew@latest -- init --json --yes') + 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([ 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],') + }) +}) From 954048fa83cd0ba2a9ad10a227b8924f3c82de7b Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Fri, 16 Oct 2026 13:48:36 -0400 Subject: [PATCH 06/10] fix(init): generated code and the install step ask for @brew.new/sdk ^13.2.0 The SDK numbering is final: #55 (`send`) ships as 13.1.0 and #64 (`appEmails`, the auth adapters) as 13.2.0, after main's 13.0.0. init's generated code imports the adapters (Better Auth, Auth.js, the Supabase hook), so SDK_RANGE is `^13.2.0`; `migrate resend` shares it for `@brew.new/sdk/resend`. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++-- src/lib/init/templates.ts | 9 +++++---- tests/commands/init.test.ts | 10 +++++----- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index adef688..5361c8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,8 +37,8 @@ GetBrew/brew-v2#2066 and #2060. 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. The generated code and the - install command use `@brew.new/sdk@^12.5.0`, the planned release with - `send`, `appEmails` and the auth adapters. + install command use `@brew.new/sdk@^13.2.0`, the planned release with + `appEmails` and the auth adapters (on top of 13.1.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 diff --git a/src/lib/init/templates.ts b/src/lib/init/templates.ts index 1e5d838..d9c916b 100644 --- a/src/lib/init/templates.ts +++ b/src/lib/init/templates.ts @@ -9,10 +9,11 @@ import type { AuthId, ProjectDetection } from './detect' */ /** The SDK range generated code and the install step ask for. */ -// The first SDK release with `send`, `appEmails` and the auth adapter -// subpaths (GetBrew/typescript-sdk#55 + #64, planned 12.5.0; see the app -// email SDK gate in RELEASING.md). npm's 12.3.0 has none of them. -export const SDK_RANGE = '^12.5.0' +// The first SDK release with `appEmails` and the auth adapter subpaths +// (GetBrew/typescript-sdk#64, planned 13.2.0, on top of #55's `send` in +// 13.1.0; see the app email SDK gate in RELEASING.md). Generated code uses +// the adapters, so it needs 13.2.0. npm's 12.3.0 has none of them. +export const SDK_RANGE = '^13.2.0' export const SDK_PACKAGE = '@brew.new/sdk' /** The project-facing command, for files teammates commit and read. */ diff --git a/tests/commands/init.test.ts b/tests/commands/init.test.ts index 5aac4d1..d594b3d 100644 --- a/tests/commands/init.test.ts +++ b/tests/commands/init.test.ts @@ -353,7 +353,7 @@ describe('init: confirmation and dry run', () => { ]) expect( result.report.steps.find((step) => step.id === 'install')?.detail - ).toBe("npm install '@brew.new/sdk@^12.5.0'") + ).toBe("npm install '@brew.new/sdk@^13.2.0'") }) it('--dry-run without any credential sends no request at all', async () => { @@ -476,7 +476,7 @@ describe('init --yes: the full run', () => { expect(spawn.calls).toEqual([ { command: 'npm', - args: ['install', '@brew.new/sdk@^12.5.0'], + args: ['install', '@brew.new/sdk@^13.2.0'], cwd: root, }, ]) @@ -609,7 +609,7 @@ describe('init --yes: the full run', () => { result.report.nextSteps.find((step) => step.id === 'install-sdk') ).toMatchObject({ blocking: true, - command: "npm install '@brew.new/sdk@^12.5.0'", + command: "npm install '@brew.new/sdk@^13.2.0'", }) useBrewApi() @@ -766,7 +766,7 @@ describe('init --yes: the full run', () => { "Deno.env.get('BREW_API_KEY')" ) expect(read(root, 'supabase/functions/send-email/index.ts')).toContain( - 'npm:@brew.new/sdk@^12.5.0/supabase' + 'npm:@brew.new/sdk@^13.2.0/supabase' ) expect(result.report.patches).toMatchObject([ { id: 'supabase-config-hook', status: 'pending' }, @@ -1043,7 +1043,7 @@ describe('init per stack', () => { expect(spawn.calls).toEqual([ { command: 'pnpm', - args: ['add', '--filter', 'web', '@brew.new/sdk@^12.5.0'], + args: ['add', '--filter', 'web', '@brew.new/sdk@^13.2.0'], cwd: root, }, ]) From 7a2163e5b3e8f6fffae9f415df008d0f63ce7280 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Fri, 16 Oct 2026 13:53:05 -0400 Subject: [PATCH 07/10] fix(init): install the SDK unless the declared range reaches SDK_RANGE The install step skipped any app that already declared @brew.new/sdk 12.3 or later, but the generated code needs ^13.2.0 (the adapters and `appEmails`), so an app on 12.3 to 13.1 kept an SDK that cannot load the files init writes (Greptile on #55). `reachesSdkRange` (templates.ts, next to SDK_RANGE) takes the threshold from SDK_RANGE itself; tests cover 12.3, 13.1, 13.2 and 14.0. Co-Authored-By: Claude Opus 5.5 --- src/lib/init/detect.ts | 2 +- src/lib/init/pipeline.ts | 14 +++----------- src/lib/init/templates.ts | 18 +++++++++++++++++- tests/commands/init.test.ts | 31 +++++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 13 deletions(-) diff --git a/src/lib/init/detect.ts b/src/lib/init/detect.ts index 886abd9..00546fb 100644 --- a/src/lib/init/detect.ts +++ b/src/lib/init/detect.ts @@ -150,7 +150,7 @@ export function rangeMajor(range: string | undefined): number | null { return match === null ? null : Number(match[1]) } -/** `[major, minor]` of a range, for "at least 12.3" checks. */ +/** `[major, minor]` of a range, for "at least SDK_RANGE" checks. */ export function rangeVersion( range: string | undefined ): readonly [number, number] | null { diff --git a/src/lib/init/pipeline.ts b/src/lib/init/pipeline.ts index d984245..322ddef 100644 --- a/src/lib/init/pipeline.ts +++ b/src/lib/init/pipeline.ts @@ -51,12 +51,7 @@ import { serializeBrewState, } from '../project/state' import type { CliContext } from '../types' -import { - type AuthId, - detectProject, - type ProjectDetection, - rangeVersion, -} from './detect' +import { type AuthId, detectProject, type ProjectDetection } from './detect' import { applyPatch, authjsPatch, @@ -85,6 +80,7 @@ import { clientPath, contractsPath, type GeneratedFile, + reachesSdkRange, SDK_PACKAGE, SDK_RANGE, SUPABASE_FUNCTION_PATH, @@ -1132,11 +1128,7 @@ function planInstall(input: { }) return } - const version = rangeVersion(project.sdkRange ?? undefined) - if ( - version !== null && - (version[0] > 12 || (version[0] === 12 && version[1] >= 3)) - ) { + if (reachesSdkRange(project.sdkRange)) { steps.push({ id: 'install', status: 'skipped', diff --git a/src/lib/init/templates.ts b/src/lib/init/templates.ts index d9c916b..02d26f3 100644 --- a/src/lib/init/templates.ts +++ b/src/lib/init/templates.ts @@ -1,5 +1,5 @@ import { posix } from 'node:path' -import type { AuthId, ProjectDetection } from './detect' +import { type AuthId, type ProjectDetection, rangeVersion } from './detect' /** * The files `init` writes into an app. Each template is deterministic (same @@ -16,6 +16,22 @@ import type { AuthId, ProjectDetection } from './detect' export const SDK_RANGE = '^13.2.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' diff --git a/tests/commands/init.test.ts b/tests/commands/init.test.ts index d594b3d..bda46a9 100644 --- a/tests/commands/init.test.ts +++ b/tests/commands/init.test.ts @@ -356,6 +356,37 @@ describe('init: confirmation and dry run', () => { ).toBe("npm install '@brew.new/sdk@^13.2.0'") }) + it.each([ + ['^12.3.0', "npm install '@brew.new/sdk@^13.2.0'"], + ['^13.1.0', "npm install '@brew.new/sdk@^13.2.0'"], + ['^13.2.0', '@brew.new/sdk ^13.2.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'], { From 00d91ef4cc8a5e13bd968dc5d2e3e81c4103865c Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Wed, 21 Oct 2026 01:38:28 +0900 Subject: [PATCH 08/10] chore: 0.19.0 after main's 0.17.0 and #54's 0.18.0; init uses the shared pager main published 0.17.0, so #54 is 0.18.0 and this release (with the launchers, synced) is 0.19.0. `init` writes the contracts file through the `types` pager, which now walks main's cycle-safe `collectAll` with the command context, and passes the command's stop signal to the contract reads. Co-Authored-By: Claude Opus 5.5 --- packages/launchers/brew.new/package.json | 4 ++-- packages/launchers/create-brew/package.json | 4 ++-- src/lib/init/pipeline.ts | 8 ++++++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/launchers/brew.new/package.json b/packages/launchers/brew.new/package.json index 27142f9..945c6e5 100644 --- a/packages/launchers/brew.new/package.json +++ b/packages/launchers/brew.new/package.json @@ -1,6 +1,6 @@ { "name": "brew.new", - "version": "0.18.0", + "version": "0.19.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", @@ -41,7 +41,7 @@ "node": ">=20" }, "dependencies": { - "@brew.new/cli": "0.18.0" + "@brew.new/cli": "0.19.0" }, "publishConfig": { "access": "public" diff --git a/packages/launchers/create-brew/package.json b/packages/launchers/create-brew/package.json index 0748d95..71cd14f 100644 --- a/packages/launchers/create-brew/package.json +++ b/packages/launchers/create-brew/package.json @@ -1,6 +1,6 @@ { "name": "create-brew", - "version": "0.18.0", + "version": "0.19.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", @@ -41,7 +41,7 @@ "node": ">=20" }, "dependencies": { - "@brew.new/cli": "0.18.0" + "@brew.new/cli": "0.19.0" }, "publishConfig": { "access": "public" diff --git a/src/lib/init/pipeline.ts b/src/lib/init/pipeline.ts index 322ddef..a2321a3 100644 --- a/src/lib/init/pipeline.ts +++ b/src/lib/init/pipeline.ts @@ -778,8 +778,12 @@ export async function runInit( note: afterSignIn, }) } else { - const triggers = await listAllTriggers(client) - const artifacts = await fetchContractArtifacts(client, triggers) + const triggers = await listAllTriggers(ctx, client) + const artifacts = await fetchContractArtifacts( + client, + triggers, + ctx.signal + ) writeManaged({ path: paths.contracts, content: buildContractsFile({ artifacts }), From ceeaddde3289d3baa62a8555771123b11f6445e3 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Wed, 21 Oct 2026 13:07:33 +0900 Subject: [PATCH 09/10] fix(init): adapt to #2169's domain warning and trigger-in-use refusal - `--domain` on a domain that already publishes another DKIM key where Brew asks for its record (POST /v1/domains `warnings[]` DKIM_SELECTOR_IN_USE, read structurally since the published SDK's row type predates it): the report swaps the DNS-records step for a `dkim-selector-in-use` step naming the first suggested subdomain. - A kit key refused with APP_EMAIL_TRIGGER_IN_USE gets a human `trigger-in-use:` review step (the server's message names the automations) ahead of the re-run step. - CHANGELOG names brew-v2 #2169 @ 6f8ef50544 as the API side. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 ++- src/lib/init/pipeline.ts | 85 ++++++++++++++++++++-- tests/commands/init.test.ts | 139 ++++++++++++++++++++++++++++-------- 3 files changed, 198 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5361c8e..59f1912 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ 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#2066 and #2060. +GetBrew/brew-v2#2169 (spec @ `6f8ef50544`). ### Added @@ -36,13 +36,18 @@ GetBrew/brew-v2#2066 and #2060. `$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. The generated code and the + 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.2.0`, the planned release with `appEmails` and the auth adapters (on top of 13.1.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` (brew-v2 PR-L and SDK `integrations.connect`), and brand + 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/`): diff --git a/src/lib/init/pipeline.ts b/src/lib/init/pipeline.ts index a2321a3..a8d5903 100644 --- a/src/lib/init/pipeline.ts +++ b/src/lib/init/pipeline.ts @@ -556,6 +556,7 @@ export async function runInit( : 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 @@ -568,6 +569,7 @@ export async function runInit( idempotencyKey: idempotencyKey('domain', body), }) domain.added = true + selectorInUse = dkimSelectorInUse(target) } } const shown = target ?? chosen @@ -599,11 +601,26 @@ export async function runInit( detail: chosen !== undefined ? `sending from ${chosen.name}` - : domain.added - ? `added ${domain.name}; sandbox until its DNS verifies` - : 'sandbox (no verified domain yet)', + : 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 (domain.records.length > 0 && domain.domainId !== null) { + 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', @@ -684,6 +701,19 @@ export async function runInit( ? `${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', @@ -1679,3 +1709,50 @@ function finish(report: InitReport): CommandOutcome { ...(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/tests/commands/init.test.ts b/tests/commands/init.test.ts index bda46a9..165dd68 100644 --- a/tests/commands/init.test.ts +++ b/tests/commands/init.test.ts @@ -94,6 +94,12 @@ 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[] = [] @@ -153,6 +159,9 @@ function useBrewApi( ], createdAt: '2026-10-13T12:00:00.000Z', updatedAt: '2026-10-13T12:00:00.000Z', + ...(options.addWarnings === undefined + ? {} + : { warnings: options.addWarnings }), }, { status: 201 } ) @@ -161,36 +170,38 @@ function useBrewApi( await record(request) return HttpResponse.json({ results: KIT_KEYS.map((key, index) => - 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: [], - } + 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', @@ -845,6 +856,39 @@ describe('init --yes: the full run', () => { }) }) + 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'] }) @@ -869,6 +913,41 @@ describe('init --yes: the full run', () => { 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)', () => { From 01d5e0e3ded3b76fffd8c144c4a337bb547544a2 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 25 Oct 2026 22:29:51 +0900 Subject: [PATCH 10/10] chore(release): init is 0.20.0; generated code asks for @brew.new/sdk ^13.6.0 npm has CLI 0.18.0 and SDK 13.1.0-13.3.0 without the DX features, so the DX numbers move up: this PR is 0.20.0 (after #54's 0.19.0) and SDK_RANGE is the adapters release, 13.6.0. A declared ^13.3.0 (published, no adapters) now installs ^13.6.0. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +++--- docs/commands/README.md | 2 +- package.json | 2 +- packages/launchers/brew.new/package.json | 4 ++-- packages/launchers/create-brew/package.json | 4 ++-- src/lib/init/templates.ts | 8 ++++---- tests/commands/init.test.ts | 17 +++++++++-------- 7 files changed, 22 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59f1912..4f254e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ 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 (spec @ `6f8ef50544`). +GetBrew/brew-v2#2169 (its DX delta @ `6f8ef50544`). ### Added @@ -42,8 +42,8 @@ GetBrew/brew-v2#2169 (spec @ `6f8ef50544`). 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.2.0`, the planned release with - `appEmails` and the auth adapters (on top of 13.1.0's `send`). + 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 diff --git a/docs/commands/README.md b/docs/commands/README.md index 019fa90..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). diff --git a/package.json b/package.json index db31f4d..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", diff --git a/packages/launchers/brew.new/package.json b/packages/launchers/brew.new/package.json index 945c6e5..4216f0a 100644 --- a/packages/launchers/brew.new/package.json +++ b/packages/launchers/brew.new/package.json @@ -1,6 +1,6 @@ { "name": "brew.new", - "version": "0.19.0", + "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", @@ -41,7 +41,7 @@ "node": ">=20" }, "dependencies": { - "@brew.new/cli": "0.19.0" + "@brew.new/cli": "0.20.0" }, "publishConfig": { "access": "public" diff --git a/packages/launchers/create-brew/package.json b/packages/launchers/create-brew/package.json index 71cd14f..7a3712f 100644 --- a/packages/launchers/create-brew/package.json +++ b/packages/launchers/create-brew/package.json @@ -1,6 +1,6 @@ { "name": "create-brew", - "version": "0.19.0", + "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", @@ -41,7 +41,7 @@ "node": ">=20" }, "dependencies": { - "@brew.new/cli": "0.19.0" + "@brew.new/cli": "0.20.0" }, "publishConfig": { "access": "public" diff --git a/src/lib/init/templates.ts b/src/lib/init/templates.ts index 02d26f3..2c93c33 100644 --- a/src/lib/init/templates.ts +++ b/src/lib/init/templates.ts @@ -10,10 +10,10 @@ import { type AuthId, type ProjectDetection, rangeVersion } from './detect' /** 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.2.0, on top of #55's `send` in -// 13.1.0; see the app email SDK gate in RELEASING.md). Generated code uses -// the adapters, so it needs 13.2.0. npm's 12.3.0 has none of them. -export const SDK_RANGE = '^13.2.0' +// (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' /** diff --git a/tests/commands/init.test.ts b/tests/commands/init.test.ts index 165dd68..f6ca6f8 100644 --- a/tests/commands/init.test.ts +++ b/tests/commands/init.test.ts @@ -364,13 +364,14 @@ describe('init: confirmation and dry run', () => { ]) expect( result.report.steps.find((step) => step.id === 'install')?.detail - ).toBe("npm install '@brew.new/sdk@^13.2.0'") + ).toBe("npm install '@brew.new/sdk@^13.6.0'") }) it.each([ - ['^12.3.0', "npm install '@brew.new/sdk@^13.2.0'"], - ['^13.1.0', "npm install '@brew.new/sdk@^13.2.0'"], - ['^13.2.0', '@brew.new/sdk ^13.2.0 already declared'], + ['^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', @@ -518,7 +519,7 @@ describe('init --yes: the full run', () => { expect(spawn.calls).toEqual([ { command: 'npm', - args: ['install', '@brew.new/sdk@^13.2.0'], + args: ['install', '@brew.new/sdk@^13.6.0'], cwd: root, }, ]) @@ -651,7 +652,7 @@ describe('init --yes: the full run', () => { result.report.nextSteps.find((step) => step.id === 'install-sdk') ).toMatchObject({ blocking: true, - command: "npm install '@brew.new/sdk@^13.2.0'", + command: "npm install '@brew.new/sdk@^13.6.0'", }) useBrewApi() @@ -808,7 +809,7 @@ describe('init --yes: the full run', () => { "Deno.env.get('BREW_API_KEY')" ) expect(read(root, 'supabase/functions/send-email/index.ts')).toContain( - 'npm:@brew.new/sdk@^13.2.0/supabase' + 'npm:@brew.new/sdk@^13.6.0/supabase' ) expect(result.report.patches).toMatchObject([ { id: 'supabase-config-hook', status: 'pending' }, @@ -1153,7 +1154,7 @@ describe('init per stack', () => { expect(spawn.calls).toEqual([ { command: 'pnpm', - args: ['add', '--filter', 'web', '@brew.new/sdk@^13.2.0'], + args: ['add', '--filter', 'web', '@brew.new/sdk@^13.6.0'], cwd: root, }, ])