diff --git a/CHANGELOG.md b/CHANGELOG.md index 881c1f80..62ceceec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,103 @@ # Changelog +## 0.23.0 + +Not released yet. Needs `@brew.new/sdk` 13.7.0: `integrations.connect` and +`integrations.getConnection` (GetBrew/typescript-sdk#74, on top of the +13.6.0 that 0.20.0 needs; see the SDK gates in `RELEASING.md`), and the API +from GetBrew/brew-v2#2169 (`GET|PUT /v1/integrations/{provider}/connection`, +the `supabase_auth` hosted Send Email Hook, and Clerk `email.created` +sending the app's auth emails). The bundled spec is 0.20.0's, the #2169 +head `6e4accea4c` (main's spec, brew-v2 `373e900a9a`, plus the DX delta). + +### Added + +- **`brew-cli integrations connect `** connects a hosted auth + email hook, `supabase_auth` or `clerk` (`PUT + /v1/integrations/{provider}/connection`, destructive class: a new secret + replaces the old one), and sets up its auth app emails. The signing + secret comes from `--signing-secret-env ` or + `--signing-secret-stdin`, never a flag value; a value shaped wrong (or + pasted where the variable name goes) is a usage error that never echoes + it. `--project-url` sets Supabase's confirmation link base. Prints + `{ connectionId, webhookUrl, status, authEmails }`. 5-minute default + deadline, as the SDK's. Re-running it is safe: `connectionId` and + `webhookUrl` stay the same for the brand and provider, the new secret + replaces the old one in place, auth emails already set up are kept (one + unpublished in Brew stays off), and keys that failed are finished; while + one has failed it exits 1, with the full response. The human output + counts auth emails set up and points at `app-emails list` for which + send. A Clerk connected on its integration page + in Brew keeps its secret there: another secret exits 1 with `409 + INTEGRATION_CONNECTED_IN_APP` (rotate it on that page). +- **`brew-cli integrations connection `** reads it: + `pending_verification` until the first signed delivery, then `active` + (404 `NOT_FOUND` when not connected). +- **`init --configure-supabase`** (with `SUPABASE_ACCESS_TOKEN` on this + machine, sent to api.supabase.com only) connects Brew as the Send Email + Hook with a freshly generated `v1,whsec_` secret, then points the + project's hook at it through the Supabase Management API (`PATCH + /v1/projects/{ref}/config/auth`: `hook_send_email_enabled`, + `hook_send_email_uri`, `hook_send_email_secrets`). The previous hook + settings are saved to `brew.json#providerConfig` before the change (a + previous secret goes to `.env.local` as + `BREW_SUPABASE_PREVIOUS_HOOK_SECRETS`, never `brew.json`); a re-run that + finds the hook already on Brew changes nothing. The hook is left as it + is while an auth email failed or reaches the team only (sandbox), while + Brew's connection is active for another hook (a new secret would stop + it), or when that backup cannot be saved. `--dry-run --json` + lists the exact requests with the secret replaced. The project ref comes + from `--supabase-project `, `supabase link`, a `*SUPABASE_URL`, or + Lovable's files. +- **`init --configure-clerk`** turns off "Delivered by Clerk" (Clerk + Backend API `POST /templates/email/{slug}/toggle_delivery`, with + `CLERK_SECRET_KEY` from the shell or `.env.local`) for exactly the + templates whose kit key's app email reaches every user (published, not + the team-only sandbox), and only once Brew's Clerk connection is + `active`; otherwise it refuses and says what is missing. The templates + it turned off are saved in `brew.json`, with a fingerprint of the + `CLERK_SECRET_KEY` used (never the key). +- **`init --rollback-provider`** puts back the Supabase hook settings or + Clerk delivery saved in `brew.json`, and nothing else. It refuses, and + keeps the saved settings, while an enabled hook's old secret is missing + or `CLERK_SECRET_KEY` is another instance's. A `brew.json` it cannot + write (a symbolic link) fails the `state` step, exit 1. +- Without those flags, init prints the manual steps for Supabase (the + hosted hook first; the self-hosted Edge Function it writes is now the + non-blocking alternative) and Clerk. A bare `--yes` never changes a + provider. The commands those steps print (`integrations connect`, + `integrations connection`, `init --configure-…`) carry `--brand` and + `--api-url` when the CLI's own key would resolve another brand or + server than this run. +- **`init --app-url`** with an organization key picks the brand whose + website is the app (a subdomain counts), or creates one (`POST + /v1/brands`) and waits for it to be ready (`--no-wait`: the kit waits for + a re-run). A 403 (`ORG_SCOPE_REQUIRED`, no `brands` scope) becomes a next + step; a key bound to one brand is never moved, a mismatch is reported. + The app's own key in `.env.local` is one: when it is another brand's, + the kit waits for a `replace-app-key` next step. When the brand lookup + or an organization key's brand list fails, the domain, kit and provider + steps wait for a re-run (exit 1). + While the kit waits for the brand, so does every `--apply-patches` patch. +- **`init --domain`** also returns the domain's page in Brew + (`domain.connectUrl`, a `connect-dns-automatically` next step), where + Connect automatically (Entri) adds the records: it runs in the app, not + the API. A `DKIM_SELECTOR_IN_USE` domain gets neither (`connectUrl: + null`): its records would replace another sender's key. +- **`brew-cli doctor --project`** checks an app against its `brew.json`: + managed file hashes, `BREW_API_KEY` set, git-ignored and never under a + browser-exposed name, the SDK declared and installed at `^13.6.0`, and, + with the app key, the brand (the key's brand is `brew.json`'s), every key + set up, published and past the sandbox (a warning names a key whose + purpose has no verified domain), a verified sending domain, and the + hosted hook's status. `--json`; any failed check (a brand read that + fails included) exits 1, no `brew.json` exits 2. +- The init report (schema version 1, additive) gains `providers[]`, the + `provider` step, `brand.domain` / `matchesAppUrl` / `created` and + `domain.connectUrl`. +- The spec parity test's pending-build list is empty again: these + commands bind the two connection routes 0.19.0's spec already carried. + ## 0.22.0 Needs the same SDK release as 0.20.0, and the API from GetBrew/brew-v2#2169 diff --git a/README.md b/README.md index cbe3ae3b..5b0af047 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ are attached to every [GitHub Release](https://github.com/GetBrew/brew-cli/relea ```bash brew-cli login # sign in through the browser (or --paste a key) brew-cli doctor # trust check: auth, reachability, CLI-vs-API drift (exit-code gated) +brew-cli doctor --project # this app vs its brew.json: files, key, SDK, keys, domain, auth hook brew-cli whoami brew-cli contacts search --filter email:equals:jane@example.com brew-cli emails list --limit 10 @@ -78,8 +79,78 @@ monorepo, editors), signs you in through the browser, and then: 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. +your Brew organization only; `--domain mail.example.com` adds a +transactional domain and prints its DNS records without waiting, plus the +domain's page in Brew, where **Connect automatically** (Entri) adds the +records at your DNS provider. That button runs in the Brew app: the API +has no endpoint for it, so the CLI cannot do it for you. + +`--app-url https://app.example.com` shows the app's URL on the sign-in +approval page. With an organization API key (`BREW_API_KEY` or +`--api-key`), init also picks the brand whose website is the app, or +creates one from the URL (`POST /v1/brands`, organization keys with the +`brands` scope) and waits for it (`--no-wait` leaves that to a re-run). A +key bound to one brand is never moved: a mismatch is reported. The app's +own key in `.env.local` is one: when the app's brand is another, init +stops before the kit and says to replace that key. + +### The auth provider's own emails (Supabase, Clerk) + +Nothing about your auth provider changes under a bare `--yes`. Without the +flags below, init prints the manual steps. + +- **Supabase Auth**: `--configure-supabase` connects Brew as the hosted + Send Email Hook (`integrations connect supabase_auth`) and points + Authentication > Hooks > Send Email at it through the Supabase + Management API, using a Supabase personal access token from + `SUPABASE_ACCESS_TOKEN` on this machine (sent to api.supabase.com only, + never to Brew). The previous hook settings go into `brew.json` first + (a previous hook secret into `.env.local`, never `brew.json`). The hook + changes only once every auth email reaches every user (set up, with a + verified domain, not the team-only sandbox), and never takes over a + connection that is active for another hook. + `--dry-run --json` shows the exact PATCH with the secret replaced. The + self-hosted Edge Function init writes stays the alternative. +- **Clerk**: connect Clerk's `email.created` webhook first + (`integrations connect clerk`). `--configure-clerk` then turns off + "Delivered by Clerk" for exactly the templates whose Brew email reaches + every user (published, not the team-only sandbox), through + Clerk's Backend API with `CLERK_SECRET_KEY` (shell or `.env.local`), and + only once `integrations connection clerk` reports `active`, so no email + is dropped in between. + +`init --rollback-provider --yes` puts back what either flag changed, from +`brew.json`. + +### Hosted auth hooks: `integrations connect` + +```bash +# the secret comes from an environment variable or stdin, never a flag value +brew-cli integrations connect supabase_auth --signing-secret-env SEND_EMAIL_HOOK_SECRET \ + --project-url https://.supabase.co --yes +printenv CLERK_WEBHOOK_SIGNING_SECRET | brew-cli integrations connect clerk --signing-secret-stdin --yes +brew-cli integrations connection clerk # pending_verification until the first signed delivery, then active +``` + +`connect` prints `{ connectionId, webhookUrl, status }` (and each auth +email it set up): paste `webhookUrl` into Supabase's hook or Clerk's +endpoint. Running it again is safe: `connectionId` and `webhookUrl` stay +the same for the brand and provider, the new secret replaces the old one, +auth emails already set up are kept (one unpublished in Brew stays off; +`app-emails list` shows which send), and keys that failed are finished. A +Clerk connected on its integration page in Brew keeps its secret there: +another secret is `409 INTEGRATION_CONNECTED_IN_APP` (rotate it on that +page). + +### Check a project: `doctor --project` + +`brew-cli doctor --project [--json]` checks the app against its +`brew.json`: managed files unchanged, `BREW_API_KEY` in `.env.local`, +git-ignored and never under a `NEXT_PUBLIC_`/`VITE_`/`PUBLIC_` name, +`@brew.new/sdk` declared and installed at the version the generated files +need, and with the app key: every key in `brew.json` set up and published +(`GET /v1/app-emails`), a verified sending domain, and the hosted auth +hook's status. Any failed check exits 1. Agents run it in two steps and read one JSON report (`schemas/init-report.json`: `status`, `steps[]`, `files[]`, `patches[]`, diff --git a/RELEASING.md b/RELEASING.md index 2d246cb6..cf900375 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -52,6 +52,21 @@ what the spec-drift sentinel reads) matching `openapi/public-api-v1.yaml`, which is the #2169 head `6e4accea4c` (main's spec, brew-v2 `373e900a9a`, plus the DX delta). +### Hosted auth hook SDK gate + +`integrations connect`, `integrations connection`, `init`'s provider step +(`--configure-supabase`, `--configure-clerk`) and `doctor --project` call +`brew.integrations.connect` and `brew.integrations.getConnection`, which +arrive in GetBrew/typescript-sdk#74 (planned 13.7.0, after 13.6.0 above). +Release that SDK first, then `bun add @brew.new/sdk@^13.7.0` (never +hand-write a lockfile entry), run a frozen install, the full checks and +`docs:commands:check`, and release the CLI. The API side, +GetBrew/brew-v2#2169, must be deployed first: re-fetch +`https://brew.new/openapi/public-api-v1.yaml` and compare the two +`/v1/integrations/{provider}/connection` operations with the bundled spec +before tagging. Generated app code still asks for `^13.6.0` +(`SDK_RANGE`): only the CLI itself needs 13.7.0. + ```bash # on a clean main with CI green npm version --no-git-tag-version diff --git a/docs/commands/README.md b/docs/commands/README.md index 150db1c0..0609ddb1 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -2,7 +2,7 @@ -157 commands. Classes: read (always safe), write +159 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). @@ -164,6 +164,8 @@ a `confirmCommand` otherwise, `--yes` to proceed). | `brew-cli flows list` | read | `GET /v1/flows` | List public email flows (real multi-step sequences by brand) as cards; `flows get ` reads one | | `brew-cli flows get` | read | `GET /v1/flows/{slug}` | Fetch one public email flow by brand domain, with every step (day offset, wait, subject, template id) | | `brew-cli integrations list` | read | `GET /v1/integrations` | List the integration catalog with per-provider connected state (connect via Settings, not this CLI) | +| `brew-cli integrations connect` | destructive | `PUT /v1/integrations/{provider}/connection` | Connect a hosted auth email hook (Supabase Send Email Hook, Clerk email.created) and set up its auth app emails; prints the webhook URL to paste into the provider | +| `brew-cli integrations connection` | read | `GET /v1/integrations/{provider}/connection` | Show a hosted auth email hook's connection: webhook URL and status (pending_verification until the first signed delivery, then active) | | `brew-cli chats list` | read | `GET /v1/chats` | List the brand's Brew chats, most recently active first; `chats get` reads one | | `brew-cli chats get` | read | `GET /v1/chats/{chatId}` | Brand-scoped digest of a Brew chat (artifacts + transcript tail) | | `brew-cli canvas sessions create` | write | `POST /v1/canvas/sessions` | Create an authenticated group or single-email canvas grant | @@ -172,7 +174,7 @@ a `confirmCommand` otherwise, `--yes` to proceed). | `brew-cli notifications list` | read | `GET /v1/notifications` | List the brand's notifications (generations, sends, imports, domain checks), newest first | | `brew-cli health` | read | `GET /v1/health` | Check Brew API liveness (no auth required) | | `brew-cli usage` | read | `GET /v1/usage` | Show plan, credit balance, and email-send quota | -| `brew-cli doctor` | read | — | Trust check: auth, API reachability, and installed-CLI vs live-API drift | +| `brew-cli doctor` | read | — | Trust check: auth, API reachability, and installed-CLI vs live-API drift; --project checks this app against its brew.json | | `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) | @@ -2661,6 +2663,37 @@ brew-cli integrations list brew-cli integrations list --json ``` +### brew-cli integrations connect + +Connect a hosted auth email hook (Supabase Send Email Hook, Clerk email.created) and set up its auth app emails; prints the webhook URL to paste into the provider + +- Route: `PUT /v1/integrations/{provider}/connection` +- Class: destructive +- SDK: `brew.integrations.connect(...)` +- Argument `provider` — supabase_auth (the Supabase Auth Send Email Hook) or clerk (Clerk webhooks: email.created) +- `--signing-secret-env ` — Read the signing secret from this environment variable (never pass the secret itself) +- `--signing-secret-stdin` — Read the signing secret from stdin (a pipe) +- `--project-url ` — supabase_auth only: the project URL confirmation links point at (https://.supabase.co); default: the one Supabase sends + +```bash +brew-cli integrations connect supabase_auth --signing-secret-env SEND_EMAIL_HOOK_SECRET --project-url https://abcdefghijklmnopqrst.supabase.co --yes +printenv CLERK_WEBHOOK_SIGNING_SECRET | brew-cli integrations connect clerk --signing-secret-stdin --yes --json +``` + +### brew-cli integrations connection + +Show a hosted auth email hook's connection: webhook URL and status (pending_verification until the first signed delivery, then active) + +- Route: `GET /v1/integrations/{provider}/connection` +- Class: read +- SDK: `brew.integrations.getConnection(...)` +- Argument `provider` — supabase_auth or clerk + +```bash +brew-cli integrations connection supabase_auth +brew-cli integrations connection clerk --json +``` + ### brew-cli chats list List the brand's Brew chats, most recently active first; `chats get` reads one @@ -2781,13 +2814,17 @@ brew-cli usage --json ### brew-cli doctor -Trust check: auth, API reachability, and installed-CLI vs live-API drift +Trust check: auth, API reachability, and installed-CLI vs live-API drift; --project checks this app against its brew.json - Class: read +- Derived from `brew.health.get(...)`, `brew.usage.get(...)`, `brew.help.get(...)`, `brew.brand.get(...)`, `brew.appEmails.list(...)`, `brew.domains.list(...)`, `brew.integrations.getConnection(...)` +- `--project` — Check this app against its brew.json: managed files, BREW_API_KEY (set, git-ignored, never NEXT_PUBLIC_/VITE_/PUBLIC_), the SDK version, the brand's keys, sending domain and auth hook (exit 1 on any failure) ```bash brew-cli doctor brew-cli doctor --json +brew-cli doctor --project +brew-cli doctor --project --json ``` ### brew-cli docs @@ -2838,15 +2875,19 @@ 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.automations.triggers.getContract(...)`, `brew.send(...)` +- Derived from `brew.brand.get(...)`, `brew.brands.list(...)`, `brew.brands.create(...)`, `brew.brands.get(...)`, `brew.domains.list(...)`, `brew.domains.add(...)`, `brew.appEmails.setUpKit(...)`, `brew.appEmails.list(...)`, `brew.integrations.getConnection(...)`, `brew.integrations.connect(...)`, `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) +- `--domain ` — Add this transactional sending domain, print its DNS records and the Brew page that can add them for you (init does not wait for verification; the team sandbox applies until then) - `--sets ` — Kit sets to set up: auth, lifecycle (default both) - `--stack ` — Auth stack for the kit (default: detected): better-auth | authjs | supabase | clerk | firebase | custom - `--test-to ` — Send the test email here (default: the person who signed in; the sandbox reaches your Brew team only) - `--apply-patches ` — Apply reviewed patches to your auth config: comma-separated @ from the report (each applies only while its file is unchanged since that report) -- `--app-url ` — Your app's URL, shown on the brew.new approval page so the right brand is picked +- `--app-url ` — Your app's URL: shown on the approval page so the right brand is picked; with an organization key, init uses the brand with this website or creates one +- `--configure-supabase` — Point Supabase Auth's Send Email hook at Brew through the Supabase Management API (needs SUPABASE_ACCESS_TOKEN locally; the old hook settings are saved in brew.json for --rollback-provider) +- `--supabase-project ` — The Supabase project ref (the in https://.supabase.co) when init cannot find it +- `--configure-clerk` — Turn off Clerk's delivery for exactly the templates Brew sends, only once Brew's Clerk connection is active (needs CLERK_SECRET_KEY locally; saved for --rollback-provider) +- `--rollback-provider` — Put back the Supabase hook or Clerk delivery settings init saved in brew.json, and nothing else - `--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 @@ -2863,6 +2904,11 @@ brew-cli init --yes --no-wait --json brew-cli init --resume --yes --json brew-cli init --app apps/web --domain mail.example.com --yes brew-cli init --yes --apply-patches better-auth-config@3f9c2a7d1b04 +brew-cli init --configure-supabase --dry-run --json +brew-cli init --configure-supabase --yes --json +brew-cli init --configure-clerk --yes --json +brew-cli init --rollback-provider --yes --json +BREW_API_KEY= brew-cli init --app-url https://acme.com --yes --json ``` ### brew-cli migrate resend diff --git a/package.json b/package.json index d1e97b62..cce859b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@brew.new/cli", - "version": "0.22.0", + "version": "0.23.0", "description": "Official agent-first command-line interface for the Brew public API.", "license": "MIT", "author": "Brew", diff --git a/packages/launchers/brew.new/launcher.mjs b/packages/launchers/brew.new/launcher.mjs index a6033983..931d41df 100644 --- a/packages/launchers/brew.new/launcher.mjs +++ b/packages/launchers/brew.new/launcher.mjs @@ -17,6 +17,7 @@ const VALUE_FLAGS = new Set([ '--test-to', '--apply-patches', '--app-url', + '--supabase-project', ]) const VERSION_FLAGS = new Set(['--version', '-V']) diff --git a/packages/launchers/brew.new/package.json b/packages/launchers/brew.new/package.json index daf04157..2f1135f1 100644 --- a/packages/launchers/brew.new/package.json +++ b/packages/launchers/brew.new/package.json @@ -1,6 +1,6 @@ { "name": "brew.new", - "version": "0.22.0", + "version": "0.23.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.22.0" + "@brew.new/cli": "0.23.0" }, "publishConfig": { "access": "public" diff --git a/packages/launchers/create-brew/launcher.mjs b/packages/launchers/create-brew/launcher.mjs index a6033983..931d41df 100644 --- a/packages/launchers/create-brew/launcher.mjs +++ b/packages/launchers/create-brew/launcher.mjs @@ -17,6 +17,7 @@ const VALUE_FLAGS = new Set([ '--test-to', '--apply-patches', '--app-url', + '--supabase-project', ]) const VERSION_FLAGS = new Set(['--version', '-V']) diff --git a/packages/launchers/create-brew/package.json b/packages/launchers/create-brew/package.json index a705b7e9..21649945 100644 --- a/packages/launchers/create-brew/package.json +++ b/packages/launchers/create-brew/package.json @@ -1,6 +1,6 @@ { "name": "create-brew", - "version": "0.22.0", + "version": "0.23.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.22.0" + "@brew.new/cli": "0.23.0" }, "publishConfig": { "access": "public" diff --git a/packages/launchers/launcher.mjs b/packages/launchers/launcher.mjs index a6033983..931d41df 100644 --- a/packages/launchers/launcher.mjs +++ b/packages/launchers/launcher.mjs @@ -17,6 +17,7 @@ const VALUE_FLAGS = new Set([ '--test-to', '--apply-patches', '--app-url', + '--supabase-project', ]) const VERSION_FLAGS = new Set(['--version', '-V']) diff --git a/schemas/init-report.json b/schemas/init-report.json index ce8a3bff..7455618d 100644 --- a/schemas/init-report.json +++ b/schemas/init-report.json @@ -140,7 +140,10 @@ "required": ["brandId", "name"], "properties": { "brandId": { "type": "string" }, - "name": { "type": ["string", "null"] } + "name": { "type": ["string", "null"] }, + "domain": { "type": ["string", "null"] }, + "matchesAppUrl": { "type": ["boolean", "null"] }, + "created": { "type": "boolean" } } }, "credential": { @@ -195,6 +198,7 @@ } }, "settingsUrl": { "type": "string" }, + "connectUrl": { "type": ["string", "null"] }, "sandbox": { "type": ["object", "null"], "required": ["note"], @@ -295,6 +299,64 @@ } } }, + "providers": { + "description": "Where the auth provider's own emails go (Supabase's Send Email hook, Clerk's delivery). Requests carry no secret: each is replaced by a placeholder.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "provider", + "action", + "connection", + "requests", + "rollbackCommand", + "detail" + ], + "properties": { + "provider": { "enum": ["supabase_auth", "clerk"] }, + "action": { + "enum": [ + "manual", + "planned", + "configured", + "unchanged", + "refused", + "restored", + "failed" + ] + }, + "connection": { + "type": ["object", "null"], + "required": ["connectionId", "webhookUrl", "status"], + "properties": { + "connectionId": { "type": "string" }, + "webhookUrl": { "type": "string" }, + "status": { "type": "string" } + } + }, + "projectRef": { "type": ["string", "null"] }, + "previous": {}, + "templates": { "type": "array", "items": { "type": "string" } }, + "requests": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["vendor", "method", "url", "body"], + "properties": { + "vendor": { "enum": ["Brew", "Supabase", "Clerk"] }, + "method": { "enum": ["PATCH", "POST", "PUT"] }, + "url": { "type": "string" }, + "body": {} + } + } + }, + "rollbackCommand": { "type": ["string", "null"] }, + "detail": { "type": "string" } + } + } + }, "testEmail": { "type": "object", "required": ["status", "key", "to"], @@ -326,6 +388,7 @@ "install", "files", "patches", + "provider", "mcp", "agent-files", "state", diff --git a/skills/brew-app-emails/SKILL.md b/skills/brew-app-emails/SKILL.md index 8906fbed..91f6d726 100644 --- a/skills/brew-app-emails/SKILL.md +++ b/skills/brew-app-emails/SKILL.md @@ -42,10 +42,18 @@ compile error. Regenerate it after changing a payload contract: `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`). +- Supabase: Brew's hosted Send Email Hook sends every auth email once + connected (`npx @brew.new/cli integrations connect supabase_auth`, or + `init --configure-supabase` with `SUPABASE_ACCESS_TOKEN` set). The + function in `supabase/functions/send-email` is the self-hosted + alternative: deploy it and point Authentication > Hooks at it. +- Clerk: connect Clerk's `email.created` webhook to Brew + (`integrations connect clerk`); once `integrations connection clerk` is + `active`, `init --configure-clerk` turns off Clerk's delivery for the + templates Brew sends. Until then Clerk sends its own. Brew sends the + lifecycle emails (`sendWelcomeEmail`). +- Firebase, WorkOS, Stytch: the provider keeps its auth emails; Brew + sends lifecycle emails (`sendWelcomeEmail`). ## A new email diff --git a/skills/brew-cli/SKILL.md b/skills/brew-cli/SKILL.md index 29ec7913..5cef79a4 100644 --- a/skills/brew-cli/SKILL.md +++ b/skills/brew-cli/SKILL.md @@ -19,6 +19,12 @@ Exit 0 = API reachable, auth valid, and this CLI covers every live operation. Exit 1 = read the report: `DRIFT` lines list live operations this build lacks (update the CLI); auth/reachability failures name the fix. +In an app that `init` set up, `brew-cli doctor --project --json` checks the +app against its `brew.json` (managed files, `BREW_API_KEY` set, git-ignored +and never `NEXT_PUBLIC_`/`VITE_`/`PUBLIC_`, the SDK version, every key set +up and published, the sending domain, the hosted auth hook). Any `fail` +check exits 1; each carries a `fix`. + ## Auth and targeting - `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`). @@ -105,6 +111,29 @@ replaces a kit key's design built from `--source-file` with the starter, and any other key is refused (400). `automations triggers create --key ` gives any trigger a key. +## Auth emails from Supabase or Clerk (hosted hooks) + +```bash +brew-cli integrations connect supabase_auth --signing-secret-env SEND_EMAIL_HOOK_SECRET --project-url https://.supabase.co --yes +printenv CLERK_WEBHOOK_SIGNING_SECRET | brew-cli integrations connect clerk --signing-secret-stdin --yes +brew-cli integrations connection clerk # pending_verification until the first signed delivery, then active +``` + +The signing secret comes from an environment variable or stdin, never a +flag value; never print it. Paste the returned `webhookUrl` into the +provider (Supabase's Send Email hook, the Clerk endpoint). A re-run keeps +`connectionId` and `webhookUrl`, replaces the secret and finishes auth +emails that failed (a Clerk connected on its Brew integration page refuses +another secret: `409 INTEGRATION_CONNECTED_IN_APP`, rotate it there); one +unpublished in Brew stays off (`app-emails list` shows which send). `init` can do +the provider side, only when asked: `--configure-supabase` (with +`SUPABASE_ACCESS_TOKEN` on this machine) points the Send Email hook at +Brew; `--configure-clerk` turns off Clerk's delivery for the templates +Brew sends, only once the Clerk connection is `active`. Both save the old +settings in `brew.json`; `init --rollback-provider --yes` restores them. +Run them with `--dry-run --json` first and show the person the requests. +A bare `--yes` never changes a provider. + ## Moving from Resend `brew-cli migrate resend` plans by default (writes `brew.migration.json`, diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 6f021dd4..fc92b865 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,6 +1,10 @@ import { BrewApiError } from '@brew.new/sdk' import { maskApiKey, resolveAuth } from '../lib/client' import { defineCommand } from '../lib/define-command' +import { + type ProjectDoctorReport, + runProjectDoctor, +} from '../lib/doctor/project' import { CliApiError } from '../lib/errors' import { getInvocationPrefix } from '../lib/invocation' import type { CliContext } from '../lib/types' @@ -11,11 +15,40 @@ const NON_COMMAND_OPS = new Set(['GET /v1/llms.txt']) export const doctorCommand = defineCommand({ path: ['doctor'], summary: - 'Trust check: auth, API reachability, and installed-CLI vs live-API drift', + 'Trust check: auth, API reachability, and installed-CLI vs live-API drift; --project checks this app against its brew.json', sdkMethod: null, + derivedFrom: [ + 'health.get', + 'usage.get', + 'help.get', + 'brand.get', + 'appEmails.list', + 'domains.list', + 'integrations.getConnection', + ], commandClass: 'read', - examples: ['brew-cli doctor', 'brew-cli doctor --json'], - run: async ({ ctx }) => { + flags: [ + { + flag: '--project', + summary: + "Check this app against its brew.json: managed files, BREW_API_KEY (set, git-ignored, never NEXT_PUBLIC_/VITE_/PUBLIC_), the SDK version, the brand's keys, sending domain and auth hook (exit 1 on any failure)", + }, + ], + examples: [ + 'brew-cli doctor', + 'brew-cli doctor --json', + 'brew-cli doctor --project', + 'brew-cli doctor --project --json', + ], + run: async ({ ctx, flags }) => { + if (flags.project === true) { + const report = await runProjectDoctor(ctx) + return { + data: report, + human: renderProjectReport(report), + exitCode: report.ok ? 0 : 1, + } + } const auth = resolveAuth({ globals: ctx.globals, env: ctx.io.env, @@ -152,3 +185,28 @@ export const doctorCommand = defineCommand({ function rethrowIfStopped(ctx: CliContext): void { ctx.signal.throwIfAborted() } + +const MARKS: Readonly< + Record +> = { + ok: 'ok', + warn: 'WARN', + fail: 'FAIL', + skipped: '--', +} + +function renderProjectReport(report: ProjectDoctorReport): string { + const lines = [ + `${report.ok ? 'ok' : 'FAIL'} project ${report.root}${report.brandId === null ? '' : ` (brand ${report.brandId})`}`, + ] + for (const check of report.checks) { + lines.push(`${MARKS[check.status].padEnd(5)} ${check.id}: ${check.detail}`) + for (const item of check.items ?? []) { + lines.push(` ${item}`) + } + if (check.fix !== undefined && check.status !== 'ok') { + lines.push(` fix: ${check.fix}`) + } + } + return lines.join('\n') +} diff --git a/src/commands/init.ts b/src/commands/init.ts index 96442e5d..0a229691 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -2,6 +2,7 @@ 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 { SUPABASE_REF_PATTERN } from '../lib/init/providers/supabase' import { flagString } from '../lib/input' const SETS: readonly AppEmailKitSet[] = ['auth', 'lifecycle'] @@ -99,9 +100,15 @@ export const initCommand = defineCommand({ sdkMethod: null, derivedFrom: [ 'brand.get', + 'brands.list', + 'brands.create', + 'brands.get', 'domains.list', 'domains.add', 'appEmails.setUpKit', + 'appEmails.list', + 'integrations.getConnection', + 'integrations.connect', 'automations.triggers.list', 'automations.triggers.getContract', 'send', @@ -120,7 +127,7 @@ export const initCommand = defineCommand({ { flag: '--domain ', summary: - 'Add this sending domain and print its DNS records (init does not wait for verification)', + 'Add this transactional sending domain, print its DNS records and the Brew page that can add them for you (init does not wait for verification; the team sandbox applies until then)', }, { flag: '--sets ', @@ -144,7 +151,27 @@ export const initCommand = defineCommand({ { flag: '--app-url ', summary: - "Your app's URL, shown on the brew.new approval page so the right brand is picked", + "Your app's URL: shown on the approval page so the right brand is picked; with an organization key, init uses the brand with this website or creates one", + }, + { + flag: '--configure-supabase', + summary: + "Point Supabase Auth's Send Email hook at Brew through the Supabase Management API (needs SUPABASE_ACCESS_TOKEN locally; the old hook settings are saved in brew.json for --rollback-provider)", + }, + { + flag: '--supabase-project ', + summary: + 'The Supabase project ref (the in https://.supabase.co) when init cannot find it', + }, + { + flag: '--configure-clerk', + summary: + "Turn off Clerk's delivery for exactly the templates Brew sends, only once Brew's Clerk connection is active (needs CLERK_SECRET_KEY locally; saved for --rollback-provider)", + }, + { + flag: '--rollback-provider', + summary: + 'Put back the Supabase hook or Clerk delivery settings init saved in brew.json, and nothing else', }, { flag: '--no-install', summary: 'Do not install @brew.new/sdk' }, { flag: '--no-mcp', summary: 'Do not configure MCP for detected editors' }, @@ -175,11 +202,19 @@ export const initCommand = defineCommand({ 'brew-cli init --resume --yes --json', 'brew-cli init --app apps/web --domain mail.example.com --yes', 'brew-cli init --yes --apply-patches better-auth-config@3f9c2a7d1b04', + 'brew-cli init --configure-supabase --dry-run --json', + 'brew-cli init --configure-supabase --yes --json', + 'brew-cli init --configure-clerk --yes --json', + 'brew-cli init --rollback-provider --yes --json', + 'BREW_API_KEY= brew-cli init --app-url https://acme.com --yes --json', ], confirmSummary: ({ flags }) => { if (flags.dryRun === true) { return } + if (flags.rollbackProvider === true) { + return 'Put back the auth provider settings init saved in brew.json: the Supabase Send Email hook as it was, or Clerk delivery back on for the templates init turned off.' + } return [ 'Set up Brew in this project:', 'sign in if needed (an app key goes into .env.local, 0600, git-ignored);', @@ -189,6 +224,12 @@ export const initCommand = defineCommand({ flags.applyPatches !== undefined ? 'and apply the reviewed auth config patches;' : 'and print patches for your auth config;', + flags.configureSupabase === true + ? "point Supabase Auth's Send Email hook at Brew (the old settings go into brew.json for --rollback-provider);" + : '', + flags.configureClerk === true + ? "once Brew's Clerk connection is active, turn off Clerk's delivery for the templates Brew sends (saved for --rollback-provider);" + : '', flags.testEmail === false ? '' : 'then send you a sandbox test email.', ] .filter((part) => part !== '') @@ -200,6 +241,28 @@ export const initCommand = defineCommand({ if (resume && flags.dryRun === true) { throw new CliUsageError('--resume cannot be combined with --dry-run.') } + if ( + flags.rollbackProvider === true && + (flags.configureSupabase === true || flags.configureClerk === true) + ) { + throw new CliUsageError( + '--rollback-provider undoes --configure-supabase / --configure-clerk; pass one or the other.' + ) + } + if (flags.configureSupabase === true && flags.configureClerk === true) { + throw new CliUsageError( + 'Pass --configure-supabase or --configure-clerk: a project has one auth provider.' + ) + } + const supabaseProject = flagString(flags.supabaseProject) + if ( + supabaseProject !== undefined && + !SUPABASE_REF_PATTERN.test(supabaseProject) + ) { + throw new CliUsageError( + `--supabase-project takes a project ref: 20 lowercase letters, the in https://.supabase.co; got "${supabaseProject}".` + ) + } return await runInit(ctx, { dryRun: flags.dryRun === true, app: flagString(flags.app), @@ -216,6 +279,10 @@ export const initCommand = defineCommand({ noWait, resume, openBrowser: flags.open !== false, + configureSupabase: flags.configureSupabase === true, + configureClerk: flags.configureClerk === true, + supabaseProject, + rollbackProvider: flags.rollbackProvider === true, rerun: { command: initCommand, invocation: { args, flags } }, }) }, diff --git a/src/commands/integrations/connect.ts b/src/commands/integrations/connect.ts new file mode 100644 index 00000000..1cdc3e1e --- /dev/null +++ b/src/commands/integrations/connect.ts @@ -0,0 +1,135 @@ +import type { + ConnectIntegrationInput, + ConnectIntegrationResponse, +} from '@brew.new/sdk' +import { defineCommand } from '../../lib/define-command' +import { CliUsageError } from '../../lib/errors' +import { flagString } from '../../lib/input' +import { + checkSecretEnvName, + type HostedAuthProvider, + parseHostedAuthProvider, + readSigningSecret, +} from '../../lib/integrations/hosted-auth' +import { getInvocationPrefix } from '../../lib/invocation' +import { CONNECT_INTEGRATION_TIMEOUT_MS } from '../../lib/long-running' +import { progress } from '../../lib/output' + +const PROVIDER_ARG = { + name: 'provider', + summary: + 'supabase_auth (the Supabase Auth Send Email Hook) or clerk (Clerk webhooks: email.created)', + isRequired: true, +} as const + +/** Where the person pastes the webhook URL, per provider. */ +const PASTE_URL: Readonly> = { + supabase_auth: + 'In Supabase, set it as the Send Email hook URL (Authentication > Hooks), enable the hook and save.', + clerk: + 'In the Clerk Dashboard, set it as the endpoint URL (Webhooks > your endpoint, subscribed to email.created) and save.', +} + +function parseProjectUrl(value: unknown): string | undefined { + const text = flagString(value) + if (text === undefined) { + return + } + try { + const url = new URL(text) + if ( + url.protocol === 'https:' && + url.username === '' && + text.length <= 2048 + ) { + return text + } + } catch { + // reported below + } + throw new CliUsageError( + `--project-url takes your Supabase project's https URL (https://.supabase.co); got "${text}".` + ) +} + +export const integrationsConnectCommand = defineCommand({ + path: ['integrations', 'connect'], + summary: + 'Connect a hosted auth email hook (Supabase Send Email Hook, Clerk email.created) and set up its auth app emails; prints the webhook URL to paste into the provider', + sdkMethod: 'integrations.connect', + route: { method: 'PUT', path: '/v1/integrations/{provider}/connection' }, + commandClass: 'destructive', + defaultTimeoutMs: CONNECT_INTEGRATION_TIMEOUT_MS, + args: [PROVIDER_ARG], + flags: [ + { + flag: '--signing-secret-env ', + summary: + 'Read the signing secret from this environment variable (never pass the secret itself)', + }, + { + flag: '--signing-secret-stdin', + summary: 'Read the signing secret from stdin (a pipe)', + }, + { + flag: '--project-url ', + summary: + 'supabase_auth only: the project URL confirmation links point at (https://.supabase.co); default: the one Supabase sends', + }, + ], + examples: [ + 'brew-cli integrations connect supabase_auth --signing-secret-env SEND_EMAIL_HOOK_SECRET --project-url https://abcdefghijklmnopqrst.supabase.co --yes', + 'printenv CLERK_WEBHOOK_SIGNING_SECRET | brew-cli integrations connect clerk --signing-secret-stdin --yes --json', + ], + confirmSummary: ({ args, flags }) => { + checkSecretEnvName(flagString(flags.signingSecretEnv)) + return `Connect ${args.provider ?? 'the provider'} for this brand: Brew verifies its deliveries with this signing secret (replacing any saved before, so deliveries signed with the old one stop working) and sets up its auth app emails.` + }, + run: async ({ ctx, args, flags }) => { + const provider = parseHostedAuthProvider(args.provider ?? '') + const projectUrl = parseProjectUrl(flags.projectUrl) + if (projectUrl !== undefined && provider !== 'supabase_auth') { + throw new CliUsageError('--project-url is for supabase_auth only.') + } + const signingSecret = await readSigningSecret(ctx, provider, { + envName: flagString(flags.signingSecretEnv), + stdin: flags.signingSecretStdin === true, + }) + const input: ConnectIntegrationInput = { + provider, + signingSecret, + ...(projectUrl === undefined ? {} : { settings: { projectUrl } }), + } + progress(ctx, `Connecting ${provider} and setting up its auth emails…`) + const result = await ctx.client().integrations.connect(input) + // A key that failed to set up exits 1, as `app-emails kit` does. + return { + data: result, + human: renderConnected(result, getInvocationPrefix(ctx.io.env)), + ...(result.authEmails.some((email) => !email.ok) ? { exitCode: 1 } : {}), + } + }, +}) + +function renderConnected( + result: ConnectIntegrationResponse, + prefix: string +): string { + const failed = result.authEmails.filter((email) => !email.ok) + const lines = [ + `Connected ${result.provider} (${result.status})`, + `Webhook URL: ${result.webhookUrl}`, + `Connection id: ${result.connectionId}`, + `Auth emails: ${result.authEmails.length - failed.length} set up${failed.length === 0 ? '' : `, ${failed.length} failed: ${failed.map((email) => `${email.triggerKey} (${email.error?.code ?? 'error'})`).join(', ')} (connect again to finish them)`}`, + '', + PASTE_URL[result.provider], + // Set up is not sending, and the response does not say which send. + `\`${prefix} app-emails list\` shows which auth emails send: one unpublished in Brew stays off, and the sandbox reaches your team only.`, + ] + if (result.status !== 'active') { + lines.push( + `The connection turns active after the first signed delivery: \`${prefix} integrations connection ${result.provider}\` shows it.` + ) + } + return lines.join('\n') +} diff --git a/src/commands/integrations/connection.ts b/src/commands/integrations/connection.ts new file mode 100644 index 00000000..e77dc4ce --- /dev/null +++ b/src/commands/integrations/connection.ts @@ -0,0 +1,34 @@ +import { defineCommand } from '../../lib/define-command' +import { parseHostedAuthProvider } from '../../lib/integrations/hosted-auth' + +export const integrationsConnectionCommand = defineCommand({ + path: ['integrations', 'connection'], + summary: + "Show a hosted auth email hook's connection: webhook URL and status (pending_verification until the first signed delivery, then active)", + sdkMethod: 'integrations.getConnection', + route: { method: 'GET', path: '/v1/integrations/{provider}/connection' }, + commandClass: 'read', + args: [ + { + name: 'provider', + summary: 'supabase_auth or clerk', + isRequired: true, + }, + ], + examples: [ + 'brew-cli integrations connection supabase_auth', + 'brew-cli integrations connection clerk --json', + ], + run: async ({ ctx, args }) => { + const provider = parseHostedAuthProvider(args.provider ?? '') + const connection = await ctx.client().integrations.getConnection(provider) + return { + data: connection, + human: [ + `${connection.provider}: ${connection.status}`, + `Webhook URL: ${connection.webhookUrl}`, + `Connection id: ${connection.connectionId}`, + ].join('\n'), + } + }, +}) diff --git a/src/generated/skills.ts b/src/generated/skills.ts index 3ed01141..93c32989 100644 --- a/src/generated/skills.ts +++ b/src/generated/skills.ts @@ -14,13 +14,13 @@ export const EMBEDDED_SKILLS: readonly EmbeddedSkill[] = [ name: "brew-app-emails", description: "Send and change this app's emails through Brew, covering auth emails (verify, magic link, one-time code, password reset), lifecycle emails (welcome, trial ending, payment failed) and your own keyed emails. Use when code sends an email, an auth library needs an email callback, or a send fails with TRIGGER_KEY_NOT_FOUND, DOMAIN_REQUIRED or SANDBOX_DAILY_LIMIT.", path: "skills/brew-app-emails/SKILL.md", - content: "---\nname: brew-app-emails\ndescription: Send and change this app's emails through Brew, covering auth emails (verify, magic link, one-time code, password reset), lifecycle emails (welcome, trial ending, payment failed) and your own keyed emails. Use when code sends an email, an auth library needs an email callback, or a send fails with TRIGGER_KEY_NOT_FOUND, DOMAIN_REQUIRED or SANDBOX_DAILY_LIMIT.\n---\n\n# App emails with Brew\n\nEvery email this app sends has a **trigger key** (`auth.password-reset`,\n`lifecycle.welcome`, `order.paid`). Brew holds the design and the\nautomation behind the key; code only sends the key and a payload:\n\n```ts\nimport { getBrew } from './lib/brew' // written by `npx @brew.new/cli init`\n\nawait getBrew().send(\n 'auth.password-reset',\n { email: user.email, userName: user.name, confirmationUrl: resetUrl },\n { idempotencyKey: `auth.password-reset:${tokenId}` } // a retry never sends twice\n)\n```\n\n`brew.json` (committed) lists the keys this project set up;\n`lib/brew-contracts.ts` types each key's payload and makes an unknown key a\ncompile error. Regenerate it after changing a payload contract:\n`npx @brew.new/cli types --out lib/brew-contracts.ts`.\n\n## Rules\n\n- Send from server code only. `BREW_API_KEY` lives in `.env.local`\n (git-ignored); never give it a `NEXT_PUBLIC_`, `VITE_` or `PUBLIC_` name,\n never print it, never commit it.\n- Derive `idempotencyKey` from what makes the email unique (the token id,\n the order id, the user id), never from the token itself.\n- Fields like `confirmationUrl` and `otp` are template-only secrets: Brew\n renders them and never stores them on the contact.\n\n## Auth libraries\n\n`lib/brew-auth.ts` wires the one this app uses:\n\n- Better Auth: `brewEmails` from `@brew.new/sdk/better-auth` goes into\n `betterAuth({ emailVerification, emailAndPassword, databaseHooks })`.\n- Auth.js: `brewEmailProvider` (`@brew.new/sdk/next-auth`) goes into\n `providers`; email sign-in needs a database adapter.\n- Supabase: the Send Email Hook in `supabase/functions/send-email` sends\n every auth email; deploy it and point Authentication > Hooks at it.\n- Clerk, Firebase, WorkOS, Stytch: the provider keeps its auth emails;\n Brew sends lifecycle emails (`sendWelcomeEmail`).\n\n## A new email\n\n```bash\nnpx @brew.new/cli app-emails upsert order.paid --source-file emails/order-paid.tsx --subject \"Order confirmed\" --yes\nnpx @brew.new/cli types --out lib/brew-contracts.ts\nnpx @brew.new/cli send order.paid --to you@example.com --data '{\"orderId\":\"ord_1\"}' --yes\n```\n\nA design edited in Brew is never replaced: `upsert` answers\n`409 APP_EMAIL_EDITED_IN_BREW` until you pass `--overwrite`.\n\n## When a send fails\n\n| Error | Meaning | Fix |\n| --- | --- | --- |\n| `TRIGGER_KEY_NOT_FOUND` (404) | No trigger has the key in this brand | `npx @brew.new/cli app-emails list`; set it up with `app-emails upsert` or `init` |\n| `NO_PUBLISHED_AUTOMATION` (422) | The key exists, nothing publishes it | Publish it in Brew, or run the same `app-emails upsert` command that set it up, plus `--publish` (for a kit key `init` set up: `npx @brew.new/cli app-emails upsert --publish --yes`). Without the setup's `--source-file` or `--starter`, a kit key goes back to its starter design and any other key is refused (400) |\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", + 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: Brew's hosted Send Email Hook sends every auth email once\n connected (`npx @brew.new/cli integrations connect supabase_auth`, or\n `init --configure-supabase` with `SUPABASE_ACCESS_TOKEN` set). The\n function in `supabase/functions/send-email` is the self-hosted\n alternative: deploy it and point Authentication > Hooks at it.\n- Clerk: connect Clerk's `email.created` webhook to Brew\n (`integrations connect clerk`); once `integrations connection clerk` is\n `active`, `init --configure-clerk` turns off Clerk's delivery for the\n templates Brew sends. Until then Clerk sends its own. Brew sends the\n lifecycle emails (`sendWelcomeEmail`).\n- Firebase, WorkOS, Stytch: the provider keeps its auth emails; Brew\n 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 | Publish it in Brew, or run the same `app-emails upsert` command that set it up, plus `--publish` (for a kit key `init` set up: `npx @brew.new/cli app-emails upsert --publish --yes`). Without the setup's `--source-file` or `--starter`, a kit key goes back to its starter design and any other key is refused (400) |\n| `DOMAIN_REQUIRED` | Sandbox: the recipient is not in your Brew organization | Verify a sending domain (`npx @brew.new/cli domains add `) |\n| `SANDBOX_DAILY_LIMIT` (429) | The team sandbox's daily cap is spent | Verify a domain, or wait for the UTC day |\n| `INVALID_PAYLOAD` | The payload misses a required field | Check `lib/brew-contracts.ts`; `details.errors[]` names the field |\n\nUntil a sending domain verifies, app emails go out from Brew's team-only\nsandbox: members of your Brew organization receive them, nobody else.\n", }, { name: "brew-cli", description: "Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.", path: "skills/brew-cli/SKILL.md", - content: "---\nname: brew-cli\ndescription: 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.\n---\n\n# brew-cli for agents\n\nOne typed command per public-API operation (the generated reference is\n`docs/commands/README.md`; `brew-cli docs --agent` lists every command).\nJSON output is automatic when stdout is piped.\n\n## Start every session with the trust check\n\n```bash\nbrew-cli doctor\n```\n\nExit 0 = API reachable, auth valid, and this CLI covers every live\noperation. Exit 1 = read the report: `DRIFT` lines list live operations\nthis build lacks (update the CLI); auth/reachability failures name the fix.\n\n## Auth and targeting\n\n- `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`).\n- No key and a person to ask? Sign in through their browser without\n blocking on it:\n\n ```bash\n brew-cli login --no-wait --json # exit 3: {status: \"pending\", verificationUrl, userCode, expiresAt, resumeCommand}\n ```\n\n Show the person `verificationUrl` and `userCode`, and ask them to approve\n on brew.new only if the code on the page matches. Then run the\n `resumeCommand` (`brew-cli login --resume`): exit 0 stores the key and\n reports `userEmail` and `brandName`; exit 3 with `status: \"pending\"`\n means not approved yet (add `--wait 2m` to wait for it); exit 3 with\n `CLI_LOGIN_DENIED` or `CLI_LOGIN_EXPIRED` means start over. Never ask for\n or print the key itself. A plain `brew-cli login` off a terminal exits 2\n instead of waiting.\n- Default target is production. For a local dev server:\n `BREW_API_URL=http://localhost:3000/api` (Brew repo dev keys live in\n `.env.test.local` as `TEST_API_KEY`).\n- Organization-scoped keys need `--brand ` / `BREW_BRAND_ID` on\n brand-scoped commands (`brands list` shows ids).\n\n## Connect your editor to the Brew MCP server\n\n```bash\nbrew-cli mcp install # the clients this project uses (.cursor/, .vscode/, .claude/, .gemini/)\nbrew-cli mcp install --client cursor claude-code --dry-run --json\nbrew-cli mcp install --client codex --auth env # header filled from BREW_API_KEY by the client\nbrew-cli mcp install --client vscode --print # print the snippet, write nothing\n```\n\nIt merges a `brew` entry into each client's config and keeps every other\nserver. `--auth oauth` (default) writes the URL only; `--auth env` adds an\n`Authorization` header the client expands from `BREW_API_KEY` in its own\nenvironment (`${env:…}` in Cursor, VS Code, Devin Desktop), never a\nliteral key. An existing `brew` entry with other settings, a config with\ncomments, or a symbolic link leaving the project comes back `manual` with\nthe snippet to paste. Devin Desktop (`--client windsurf`,\n`~/.config/devin/mcp_config.json`) and Codex live in your home directory:\nonly written when named with `--client`. After a Codex OAuth entry, run\nthe result's `nextStep` (`codex mcp login brew`). Results: `created |\nupdated | unchanged | manual | printed`, plus one-click `deeplink`s for\nCursor and VS Code.\n\n## App emails: send by key\n\nAn app email is a keyed trigger wired to a design and a published\nautomation. Code sends it with `brew.send(key, payload)` (SDK) or:\n\n```bash\nbrew-cli app-emails kit --stack better-auth --yes # auth + lifecycle starter kit; exit 1 = a key failed, re-run\nbrew-cli app-emails upsert order.paid --source-file emails/order-paid.tsx --yes # one email from your own source\nbrew-cli app-emails list # sender (domain | sandbox | none) and readiness per key\nbrew-cli send auth.password-reset --to you@example.com --data '{\"confirmationUrl\":\"https://…\"}' --yes\nbrew-cli types --out lib/brew-contracts.ts # payload types + the BrewTriggerKeyRegistry augmentation\n```\n\nBefore a sending domain verifies, app emails use the team-only sandbox:\nthey reach members of the Brew organization only, under a daily cap\n(`sender.mode: \"sandbox\"`). `send` is destructive (real email): it exits 4\nwithout `--yes`. `upsert` and `kit` publish, so they confirm too;\n`upsert --no-publish` saves without the gate. `upsert` and `kit` need a key\nwith the `automations` and `emails` scopes (403 otherwise).\nRefusals worth knowing (exit 1, the envelope on stderr):\n`409 APP_EMAIL_EDITED_IN_BREW` means a teammate edited the design in Brew:\nkeep it, or pass `--overwrite`. `409 APP_EMAIL_TRIGGER_IN_USE`: the key's\ntrigger already starts a published automation no setup made, so every send\nwould go out twice; unpublish or delete `details.automationIds` first.\n`422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`: a kit key never sends from a\n`--domain` of the other purpose (transactional: auth, trial ending, payment\nfailed; marketing: welcome, win-back); pass one of its purpose or leave\n`--domain` out. `409 APP_EMAIL_SETUP_IN_PROGRESS`:\nanother setup of the key was writing; run it again later.\n`409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`: a `payloadSchema` that drops\nor retypes a field the live automation reads. A `DRAFT_SAVED_NOT_LIVE`\nwarning means the published version keeps sending until the draft is\npublished. An `APP_EMAIL_NOT_PUBLISHED` warning names why the email is off;\none unpublished in Brew stays off on a re-run (and `kit`) until you publish\nit in Brew or run the same `upsert` command that set it up, plus `--publish`.\nA bare `upsert --publish` repeats only a kit key's starter setup: it\nreplaces a kit key's design built from `--source-file` with the starter, and\nany other key is refused (400).\n`automations triggers create --key ` gives any trigger a key.\n\n## Moving from Resend\n\n`brew-cli migrate resend` plans by default (writes `brew.migration.json`,\nchanges nothing); `--apply --yes` copies templates, React Email files,\ncontacts, segments, topics, suppressions and draft broadcasts, and\nresumes when re-run. Pass the Resend key on stdin with\n`--resend-key-stdin`; it never goes to Brew. The\n`brew-migrate-from-resend` skill has the full mapping.\n\n## The contract you can rely on\n\n- Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation\n required · 130/143 interrupted by SIGINT/SIGTERM. Errors are JSON\n envelopes on stderr with stable `code`s and `requestId`.\n- A write that ends `CLI_TIMEOUT`, `CLI_CONNECTION` or `CLI_INTERRUPTED`\n (or a 5xx) may still complete on the server. Re-run the envelope's\n `retryCommand` — it carries the same `--idempotency-key`, so the API\n replays the first attempt instead of running it twice. Never re-run the\n original command with a fresh key. A `409 IDEMPOTENCY_IN_PROGRESS` means\n the first attempt is still running: wait, then re-run `retryCommand`.\n While the API's idempotency store is degraded, writes other than sends\n run without the replay guarantee: for a write that must not happen\n twice, pass `--max-retries 0` and check whether it landed before re-running.\n- `--timeout ` bounds the whole command (body, retries, pages);\n use it to fit a tool-call budget. `--max-retries 0` for one attempt.\n- Destructive commands (sends, deletes, fires, cancels) never hang: they\n exit 4 with a `confirmCommand` to re-run once a human approves, or take\n `--yes`. `confirmCommand` and `retryCommand` start the way you invoked\n the CLI (`brew-cli …`, or a package-runner prefix such as\n `npx -y @brew.new/cli@ …`), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit\n human instruction; `emails send --test --to you@x.com` is the safe lane.\n- `--input ''` (or `-` for stdin) carries full request bodies;\n positional ids always win over `--input`. `--all` drains pagination.\n `--idempotency-key` makes POST retries safe (except `api-keys create`,\n whose route never replays).\n- Every collection with ids to look up has a real detail read (` get\n `) returning the BARE row; list routes reject id filters\n (`notifications list` and `emails comments list` are list-only). Runs, sends, audience builds and\n inbox-placement tests share ONE status vocabulary: `queued | scheduled |\n running | paused | completed | partially_completed | failed | canceled`.\n\n## Recipes\n\n```bash\nbrew-cli docs --agent # machine-readable manifest of every command\nbrew-cli docs api # the live API catalog (/v1/help)\nbrew-cli contacts search --filter email:contains:@acme.com --json\nbrew-cli emails get --include html,versions\nbrew-cli emails get --email-version-id # a saved version (ids from --include versions)\nbrew-cli emails preview-clients # starts a rendering job (10 credits)\nbrew-cli emails get-client-preview # poll it; screenshots are previews[].imageUrl\nbrew-cli sends get --include events\nbrew-cli insights list --severity critical # what Brew Insights found; insights get for one in full\nbrew-cli insights list --include pulse,report,suggestions,memo --json\nbrew-cli emails comments list --include messages # teammates' comment threads on a design\nbrew-cli emails comments list --comment-id --all --json # one thread, every message\nbrew-cli chats list # recent Brew chats; chats get to resume one\nbrew-cli notifications list --type email_send_failed # what finished or failed (reading marks nothing read)\nbrew-cli domains health --include scoreHistory,scoreRuns\nbrew-cli contacts get --include openProfile # smart-send open-time profile (needs the emails scope)\nbrew-cli content upload-image ./logo.png # a local file into the brand library (free); prints its assetId\nbrew-cli brand delete-image --yes # remove one library image (destructive; its URL keeps working)\nbrew-cli api GET '/v1/sends?kind=campaign' # raw escape hatch for anything else\n```\n\nTo verify an API change you just made in the Brew repo: start the dev\nserver, point `BREW_API_URL` at it, and hit the changed route through the\ndedicated command (or `brew-cli api`) — the CLI prints the exact envelope\nyour change produced.\n", + content: "---\nname: brew-cli\ndescription: 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.\n---\n\n# brew-cli for agents\n\nOne typed command per public-API operation (the generated reference is\n`docs/commands/README.md`; `brew-cli docs --agent` lists every command).\nJSON output is automatic when stdout is piped.\n\n## Start every session with the trust check\n\n```bash\nbrew-cli doctor\n```\n\nExit 0 = API reachable, auth valid, and this CLI covers every live\noperation. Exit 1 = read the report: `DRIFT` lines list live operations\nthis build lacks (update the CLI); auth/reachability failures name the fix.\n\nIn an app that `init` set up, `brew-cli doctor --project --json` checks the\napp against its `brew.json` (managed files, `BREW_API_KEY` set, git-ignored\nand never `NEXT_PUBLIC_`/`VITE_`/`PUBLIC_`, the SDK version, every key set\nup and published, the sending domain, the hosted auth hook). Any `fail`\ncheck exits 1; each carries a `fix`.\n\n## Auth and targeting\n\n- `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`).\n- No key and a person to ask? Sign in through their browser without\n blocking on it:\n\n ```bash\n brew-cli login --no-wait --json # exit 3: {status: \"pending\", verificationUrl, userCode, expiresAt, resumeCommand}\n ```\n\n Show the person `verificationUrl` and `userCode`, and ask them to approve\n on brew.new only if the code on the page matches. Then run the\n `resumeCommand` (`brew-cli login --resume`): exit 0 stores the key and\n reports `userEmail` and `brandName`; exit 3 with `status: \"pending\"`\n means not approved yet (add `--wait 2m` to wait for it); exit 3 with\n `CLI_LOGIN_DENIED` or `CLI_LOGIN_EXPIRED` means start over. Never ask for\n or print the key itself. A plain `brew-cli login` off a terminal exits 2\n instead of waiting.\n- Default target is production. For a local dev server:\n `BREW_API_URL=http://localhost:3000/api` (Brew repo dev keys live in\n `.env.test.local` as `TEST_API_KEY`).\n- Organization-scoped keys need `--brand ` / `BREW_BRAND_ID` on\n brand-scoped commands (`brands list` shows ids).\n\n## Connect your editor to the Brew MCP server\n\n```bash\nbrew-cli mcp install # the clients this project uses (.cursor/, .vscode/, .claude/, .gemini/)\nbrew-cli mcp install --client cursor claude-code --dry-run --json\nbrew-cli mcp install --client codex --auth env # header filled from BREW_API_KEY by the client\nbrew-cli mcp install --client vscode --print # print the snippet, write nothing\n```\n\nIt merges a `brew` entry into each client's config and keeps every other\nserver. `--auth oauth` (default) writes the URL only; `--auth env` adds an\n`Authorization` header the client expands from `BREW_API_KEY` in its own\nenvironment (`${env:…}` in Cursor, VS Code, Devin Desktop), never a\nliteral key. An existing `brew` entry with other settings, a config with\ncomments, or a symbolic link leaving the project comes back `manual` with\nthe snippet to paste. Devin Desktop (`--client windsurf`,\n`~/.config/devin/mcp_config.json`) and Codex live in your home directory:\nonly written when named with `--client`. After a Codex OAuth entry, run\nthe result's `nextStep` (`codex mcp login brew`). Results: `created |\nupdated | unchanged | manual | printed`, plus one-click `deeplink`s for\nCursor and VS Code.\n\n## App emails: send by key\n\nAn app email is a keyed trigger wired to a design and a published\nautomation. Code sends it with `brew.send(key, payload)` (SDK) or:\n\n```bash\nbrew-cli app-emails kit --stack better-auth --yes # auth + lifecycle starter kit; exit 1 = a key failed, re-run\nbrew-cli app-emails upsert order.paid --source-file emails/order-paid.tsx --yes # one email from your own source\nbrew-cli app-emails list # sender (domain | sandbox | none) and readiness per key\nbrew-cli send auth.password-reset --to you@example.com --data '{\"confirmationUrl\":\"https://…\"}' --yes\nbrew-cli types --out lib/brew-contracts.ts # payload types + the BrewTriggerKeyRegistry augmentation\n```\n\nBefore a sending domain verifies, app emails use the team-only sandbox:\nthey reach members of the Brew organization only, under a daily cap\n(`sender.mode: \"sandbox\"`). `send` is destructive (real email): it exits 4\nwithout `--yes`. `upsert` and `kit` publish, so they confirm too;\n`upsert --no-publish` saves without the gate. `upsert` and `kit` need a key\nwith the `automations` and `emails` scopes (403 otherwise).\nRefusals worth knowing (exit 1, the envelope on stderr):\n`409 APP_EMAIL_EDITED_IN_BREW` means a teammate edited the design in Brew:\nkeep it, or pass `--overwrite`. `409 APP_EMAIL_TRIGGER_IN_USE`: the key's\ntrigger already starts a published automation no setup made, so every send\nwould go out twice; unpublish or delete `details.automationIds` first.\n`422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`: a kit key never sends from a\n`--domain` of the other purpose (transactional: auth, trial ending, payment\nfailed; marketing: welcome, win-back); pass one of its purpose or leave\n`--domain` out. `409 APP_EMAIL_SETUP_IN_PROGRESS`:\nanother setup of the key was writing; run it again later.\n`409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`: a `payloadSchema` that drops\nor retypes a field the live automation reads. A `DRAFT_SAVED_NOT_LIVE`\nwarning means the published version keeps sending until the draft is\npublished. An `APP_EMAIL_NOT_PUBLISHED` warning names why the email is off;\none unpublished in Brew stays off on a re-run (and `kit`) until you publish\nit in Brew or run the same `upsert` command that set it up, plus `--publish`.\nA bare `upsert --publish` repeats only a kit key's starter setup: it\nreplaces a kit key's design built from `--source-file` with the starter, and\nany other key is refused (400).\n`automations triggers create --key ` gives any trigger a key.\n\n## Auth emails from Supabase or Clerk (hosted hooks)\n\n```bash\nbrew-cli integrations connect supabase_auth --signing-secret-env SEND_EMAIL_HOOK_SECRET --project-url https://.supabase.co --yes\nprintenv CLERK_WEBHOOK_SIGNING_SECRET | brew-cli integrations connect clerk --signing-secret-stdin --yes\nbrew-cli integrations connection clerk # pending_verification until the first signed delivery, then active\n```\n\nThe signing secret comes from an environment variable or stdin, never a\nflag value; never print it. Paste the returned `webhookUrl` into the\nprovider (Supabase's Send Email hook, the Clerk endpoint). A re-run keeps\n`connectionId` and `webhookUrl`, replaces the secret and finishes auth\nemails that failed (a Clerk connected on its Brew integration page refuses\nanother secret: `409 INTEGRATION_CONNECTED_IN_APP`, rotate it there); one\nunpublished in Brew stays off (`app-emails list` shows which send). `init` can do\nthe provider side, only when asked: `--configure-supabase` (with\n`SUPABASE_ACCESS_TOKEN` on this machine) points the Send Email hook at\nBrew; `--configure-clerk` turns off Clerk's delivery for the templates\nBrew sends, only once the Clerk connection is `active`. Both save the old\nsettings in `brew.json`; `init --rollback-provider --yes` restores them.\nRun them with `--dry-run --json` first and show the person the requests.\nA bare `--yes` never changes a provider.\n\n## Moving from Resend\n\n`brew-cli migrate resend` plans by default (writes `brew.migration.json`,\nchanges nothing); `--apply --yes` copies templates, React Email files,\ncontacts, segments, topics, suppressions and draft broadcasts, and\nresumes when re-run. Pass the Resend key on stdin with\n`--resend-key-stdin`; it never goes to Brew. The\n`brew-migrate-from-resend` skill has the full mapping.\n\n## The contract you can rely on\n\n- Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation\n required · 130/143 interrupted by SIGINT/SIGTERM. Errors are JSON\n envelopes on stderr with stable `code`s and `requestId`.\n- A write that ends `CLI_TIMEOUT`, `CLI_CONNECTION` or `CLI_INTERRUPTED`\n (or a 5xx) may still complete on the server. Re-run the envelope's\n `retryCommand` — it carries the same `--idempotency-key`, so the API\n replays the first attempt instead of running it twice. Never re-run the\n original command with a fresh key. A `409 IDEMPOTENCY_IN_PROGRESS` means\n the first attempt is still running: wait, then re-run `retryCommand`.\n While the API's idempotency store is degraded, writes other than sends\n run without the replay guarantee: for a write that must not happen\n twice, pass `--max-retries 0` and check whether it landed before re-running.\n- `--timeout ` bounds the whole command (body, retries, pages);\n use it to fit a tool-call budget. `--max-retries 0` for one attempt.\n- Destructive commands (sends, deletes, fires, cancels) never hang: they\n exit 4 with a `confirmCommand` to re-run once a human approves, or take\n `--yes`. `confirmCommand` and `retryCommand` start the way you invoked\n the CLI (`brew-cli …`, or a package-runner prefix such as\n `npx -y @brew.new/cli@ …`), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit\n human instruction; `emails send --test --to you@x.com` is the safe lane.\n- `--input ''` (or `-` for stdin) carries full request bodies;\n positional ids always win over `--input`. `--all` drains pagination.\n `--idempotency-key` makes POST retries safe (except `api-keys create`,\n whose route never replays).\n- Every collection with ids to look up has a real detail read (` get\n `) returning the BARE row; list routes reject id filters\n (`notifications list` and `emails comments list` are list-only). Runs, sends, audience builds and\n inbox-placement tests share ONE status vocabulary: `queued | scheduled |\n running | paused | completed | partially_completed | failed | canceled`.\n\n## Recipes\n\n```bash\nbrew-cli docs --agent # machine-readable manifest of every command\nbrew-cli docs api # the live API catalog (/v1/help)\nbrew-cli contacts search --filter email:contains:@acme.com --json\nbrew-cli emails get --include html,versions\nbrew-cli emails get --email-version-id # a saved version (ids from --include versions)\nbrew-cli emails preview-clients # starts a rendering job (10 credits)\nbrew-cli emails get-client-preview # poll it; screenshots are previews[].imageUrl\nbrew-cli sends get --include events\nbrew-cli insights list --severity critical # what Brew Insights found; insights get for one in full\nbrew-cli insights list --include pulse,report,suggestions,memo --json\nbrew-cli emails comments list --include messages # teammates' comment threads on a design\nbrew-cli emails comments list --comment-id --all --json # one thread, every message\nbrew-cli chats list # recent Brew chats; chats get to resume one\nbrew-cli notifications list --type email_send_failed # what finished or failed (reading marks nothing read)\nbrew-cli domains health --include scoreHistory,scoreRuns\nbrew-cli contacts get --include openProfile # smart-send open-time profile (needs the emails scope)\nbrew-cli content upload-image ./logo.png # a local file into the brand library (free); prints its assetId\nbrew-cli brand delete-image --yes # remove one library image (destructive; its URL keeps working)\nbrew-cli api GET '/v1/sends?kind=campaign' # raw escape hatch for anything else\n```\n\nTo verify an API change you just made in the Brew repo: start the dev\nserver, point `BREW_API_URL` at it, and hit the changed route through the\ndedicated command (or `brew-cli api`) — the CLI prints the exact envelope\nyour change produced.\n", }, { name: "brew-migrate-from-resend", diff --git a/src/lib/doctor/project.ts b/src/lib/doctor/project.ts new file mode 100644 index 00000000..dce6476b --- /dev/null +++ b/src/lib/doctor/project.ts @@ -0,0 +1,705 @@ +import { readdirSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { BrewApiError } from '@brew.new/sdk' +import { + type BrewClient, + buildSdkClient, + DEFAULT_API_URL, + maskApiKey, + type ResolvedAuth, + resolveAuth, +} from '../client' +import { sdkTransportFor } from '../context' +import { CliApiError, CliUsageError } from '../errors' +import { + API_URL_VARIABLE, + reachesSdkRange, + SDK_PACKAGE, + SDK_RANGE, +} from '../init/templates' +import { + findConnection, + type HostedAuthProvider, +} from '../integrations/hosted-auth' +import { collectAll } from '../paginate' +import { getEnvValue, isClientExposedName, parseEnv } from '../project/env-file' +import { readTextFile, sha256Hex } from '../project/fs-writer' +import { isIgnored } from '../project/gitignore' +import { resolveCwd, resolveSpawn } from '../project/process' +import { + BREW_STATE_FILE, + type BrewProjectState, + parseBrewState, +} from '../project/state' +import type { CliContext } from '../types' + +/** + * `doctor --project`: does this app still match its `brew.json`? Local + * checks (managed files, the app key and where it lives, the SDK) and, + * with a key, the brand's side (the keys exist and publish, the sending + * domain, the hosted auth hook). Every check is `ok`, `warn` or `fail` + * (`skipped` when it does not apply; a read that fails is `fail`); any + * `fail` exits 1. Secrets never appear: + * the key is masked, env values are never echoed. + */ + +export type ProjectCheckStatus = 'ok' | 'warn' | 'fail' | 'skipped' + +export type ProjectCheck = { + readonly id: + | 'state' + | 'files' + | 'env' + | 'env-exposure' + | 'gitignore' + | 'sdk' + | 'auth' + | 'app-emails' + | 'domain' + | 'provider' + readonly status: ProjectCheckStatus + readonly detail: string + /** Paths, keys or variable names behind a warn/fail. */ + readonly items?: readonly string[] + readonly fix?: string +} + +export type ProjectDoctorReport = { + readonly ok: boolean + readonly root: string + readonly brandId: string | null + readonly apiKey: string | null + readonly checks: readonly ProjectCheck[] +} + +const APP_KEY_VARIABLE = 'BREW_API_KEY' + +/** Env files a framework may load (and so may inline into a bundle). */ +function envFiles(root: string): string[] { + try { + return readdirSync(root) + .filter((name) => name === '.env' || name.startsWith('.env.')) + .filter((name) => name !== '.env.example' && !name.endsWith('.sample')) + .sort() + } catch { + return [] + } +} + +/** The installed SDK's version, looking up from the app (hoisted installs). */ +function installedSdkVersion(root: string): string | null { + let dir = root + for (let depth = 0; depth < 8; depth += 1) { + const text = readTextFile( + join(dir, 'node_modules', SDK_PACKAGE, 'package.json') + ) + if (text !== null) { + try { + const version = (JSON.parse(text) as { version?: unknown }).version + return typeof version === 'string' ? version : null + } catch { + return null + } + } + const parent = dirname(dir) + if (parent === dir) { + break + } + dir = parent + } + return null +} + +function declaredSdkRange(root: string): { + hasPackageJson: boolean + range: string | null +} { + const text = readTextFile(join(root, 'package.json')) + if (text === null) { + return { hasPackageJson: false, range: null } + } + try { + const pkg = JSON.parse(text) as Record + for (const field of [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + ]) { + const deps = pkg[field] + if (typeof deps === 'object' && deps !== null) { + const range = (deps as Record)[SDK_PACKAGE] + if (typeof range === 'string') { + return { hasPackageJson: true, range } + } + } + } + } catch { + // An unreadable package.json reads as "not declared". + } + return { hasPackageJson: true, range: null } +} + +function describe(error: unknown): string { + if (error instanceof BrewApiError || error instanceof CliApiError) { + return `${error.status} ${error.code}: ${error.message}` + } + return error instanceof Error ? error.message : String(error) +} + +export async function runProjectDoctor( + ctx: CliContext +): Promise { + const root = resolveCwd(ctx.io) + const checks: ProjectCheck[] = [] + const stateText = readTextFile(join(root, BREW_STATE_FILE)) + if (stateText === null) { + throw new CliUsageError( + `No ${BREW_STATE_FILE} in ${root}. Run doctor --project in the app init set up (or run init there first).` + ) + } + const parsed = parseBrewState(stateText) + if (!parsed.ok) { + checks.push({ + id: 'state', + status: 'fail', + detail: `${BREW_STATE_FILE} is not valid`, + items: parsed.errors, + fix: `Fix ${BREW_STATE_FILE} by hand or remove it and run init again.`, + }) + return { ok: false, root, brandId: null, apiKey: null, checks } + } + const state: BrewProjectState = parsed.state + checks.push({ + id: 'state', + status: 'ok', + detail: `${BREW_STATE_FILE} is valid (${Object.keys(state.emails).length} keys, ${Object.keys(state.files).length} managed files)`, + }) + + // ---- Managed files: still what init wrote? + const modified: string[] = [] + const missing: string[] = [] + for (const [path, entry] of Object.entries(state.files)) { + const text = readTextFile(join(root, path)) + if (text === null) { + missing.push(path) + } else if (sha256Hex(text) !== entry.sha256) { + modified.push(path) + } + } + checks.push( + missing.length > 0 + ? { + id: 'files', + status: 'fail', + detail: `${missing.length} managed file(s) missing`, + items: missing, + fix: 'Run init again to recreate them (it never overwrites a file you edited).', + } + : modified.length > 0 + ? { + id: 'files', + status: 'warn', + detail: `${modified.length} managed file(s) edited since init wrote them (init keeps your version)`, + items: modified, + } + : { + id: 'files', + status: 'ok', + detail: `${Object.keys(state.files).length} managed file(s) unchanged`, + } + ) + + // ---- The app key: present, and never under a browser-exposed name. + const files = envFiles(root) + const envLocalText = readTextFile(join(root, '.env.local')) + const envLocalValue = (name: string): string | undefined => { + const value = + envLocalText === null + ? undefined + : getEnvValue(parseEnv(envLocalText), name) + return value === undefined || value.trim() === '' ? undefined : value.trim() + } + const envLocalKey = envLocalValue(APP_KEY_VARIABLE) + const processKey = ctx.io.env[APP_KEY_VARIABLE]?.trim() || undefined + checks.push( + envLocalKey !== undefined + ? { + id: 'env', + status: 'ok', + detail: `${APP_KEY_VARIABLE} is set in .env.local`, + } + : processKey !== undefined + ? { + id: 'env', + status: 'warn', + detail: `${APP_KEY_VARIABLE} comes from the environment, not .env.local`, + } + : { + id: 'env', + status: 'fail', + detail: `${APP_KEY_VARIABLE} is not set in .env.local`, + fix: 'Run init again (it signs in and writes the app key), or add the key to .env.local.', + } + ) + const exposed: string[] = [] + // A key: a value shaped like one, the app key itself, or any value under + // a Brew key-like name. A URL or an empty placeholder is not one. + const looksLikeKey = (name: string, value: string): boolean => + isClientExposedName(name) && + value !== '' && + (/BREW.*(?:KEY|SECRET|TOKEN)/i.test(name) || + value.startsWith('brew_') || + (envLocalKey !== undefined && value === envLocalKey)) + for (const file of files) { + const text = readTextFile(join(root, file)) + if (text === null) { + continue + } + for (const line of parseEnv(text).lines) { + if ( + line.kind === 'var' && + looksLikeKey( + line.key, + getEnvValue(parseEnv(text), line.key)?.trim() ?? '' + ) + ) { + exposed.push(`${file}: ${line.key}`) + } + } + } + // The shell or CI can hand the build a public name too. + for (const [name, value] of Object.entries(ctx.io.env)) { + if (looksLikeKey(name, value?.trim() ?? '')) { + exposed.push(`environment: ${name}`) + } + } + checks.push( + exposed.length > 0 + ? { + id: 'env-exposure', + status: 'fail', + detail: + 'a Brew key sits under a browser-exposed name (NEXT_PUBLIC_, VITE_, PUBLIC_, EXPO_PUBLIC_): it ships to every visitor', + items: exposed, + fix: `Rename it to ${APP_KEY_VARIABLE}, use it from server code only, and revoke the exposed key in Brew (Settings > API keys).`, + } + : { + id: 'env-exposure', + status: 'ok', + detail: 'no Brew key under a browser-exposed name', + } + ) + let ignored = false + try { + ignored = await isIgnored('.env.local', { + cwd: root, + spawn: resolveSpawn(ctx.io), + signal: ctx.signal, + }) + } catch { + ctx.signal.throwIfAborted() + } + checks.push( + ignored + ? { id: 'gitignore', status: 'ok', detail: '.env.local is git-ignored' } + : { + id: 'gitignore', + status: 'fail', + detail: + '.env.local is not git-ignored: the app key is one `git add` from a commit', + fix: 'Add .env.local to .gitignore (and `git rm --cached .env.local` if git tracks it).', + } + ) + + // ---- The SDK. + const declared = declaredSdkRange(root) + if (!declared.hasPackageJson) { + checks.push({ + id: 'sdk', + status: 'skipped', + detail: `no package.json (Edge Functions import npm:${SDK_PACKAGE}@${SDK_RANGE})`, + }) + } else if ( + state.framework === 'vite-react' || + state.framework === 'supabase-functions' + ) { + // init installs no SDK where the app's own code never sends. + checks.push({ + id: 'sdk', + status: 'skipped', + detail: + state.framework === 'vite-react' + ? 'browser-only app: send from your server or Supabase Edge Functions' + : `Edge Functions import npm:${SDK_PACKAGE}@${SDK_RANGE}`, + }) + } else if (declared.range === null) { + checks.push({ + id: 'sdk', + status: 'fail', + detail: `${SDK_PACKAGE} is not a dependency`, + fix: `Install ${SDK_PACKAGE}@${SDK_RANGE}.`, + }) + } else if (!reachesSdkRange(declared.range)) { + checks.push({ + id: 'sdk', + status: 'fail', + detail: `${SDK_PACKAGE} ${declared.range} is older than the ${SDK_RANGE} the generated files need`, + fix: `Install ${SDK_PACKAGE}@${SDK_RANGE}.`, + }) + } else { + const installed = installedSdkVersion(root) + checks.push( + installed === null + ? { + id: 'sdk', + status: 'warn', + detail: `${SDK_PACKAGE} ${declared.range} is declared but not installed`, + fix: 'Run your package manager install.', + } + : reachesSdkRange(installed) + ? { + id: 'sdk', + status: 'ok', + detail: `${SDK_PACKAGE} ${installed} installed (needs ${SDK_RANGE})`, + } + : { + id: 'sdk', + status: 'fail', + detail: `${SDK_PACKAGE} ${installed} is installed, older than ${SDK_RANGE}`, + fix: 'Run your package manager install (the lockfile pins an old version).', + } + ) + } + + // ---- The brand's side, with the app's key. + const auth = resolveProjectAuth(ctx, { + envLocalKey, + envLocalApiUrl: envLocalValue(API_URL_VARIABLE), + state, + }) + if (auth === undefined) { + for (const id of ['auth', 'app-emails', 'domain', 'provider'] as const) { + checks.push({ + id, + status: 'skipped', + detail: `no API key (set ${APP_KEY_VARIABLE} in .env.local)`, + }) + } + return finish(root, state, null, checks) + } + const client = buildSdkClient(auth, sdkTransportFor(ctx)) + try { + const brand = await client.brand.get() + checks.push( + state.brandId !== undefined && brand.brandId !== state.brandId + ? { + id: 'auth', + status: 'fail', + detail: `the key's brand (${brand.brandId}) is not brew.json's (${state.brandId})`, + fix: 'Use the app key init wrote, or run init again to re-link the project.', + } + : { + id: 'auth', + status: 'ok', + detail: `key ${maskApiKey(auth.apiKey)} reaches ${brand.identity?.brandName ?? brand.domain ?? brand.brandId}`, + } + ) + } catch (error) { + ctx.signal.throwIfAborted() + checks.push({ + id: 'auth', + status: 'fail', + detail: `the key does not work: ${describe(error)}`, + fix: 'Run init again to sign in and write a fresh app key.', + }) + for (const id of ['app-emails', 'domain', 'provider'] as const) { + checks.push({ id, status: 'skipped', detail: 'the key does not work' }) + } + return finish(root, state, auth, checks) + } + checks.push(await checkAppEmails(ctx, client, state)) + checks.push(await checkDomain(ctx, client)) + checks.push(...(await checkProviders(ctx, client, state))) + return finish(root, state, auth, checks) +} + +function finish( + root: string, + state: BrewProjectState, + auth: ResolvedAuth | null, + checks: readonly ProjectCheck[] +): ProjectDoctorReport { + return { + ok: !checks.some((check) => check.status === 'fail'), + root, + brandId: state.brandId ?? null, + apiKey: auth === null ? null : maskApiKey(auth.apiKey), + checks, + } +} + +/** + * The key to check with: `--api-key` / `BREW_API_KEY` first, then the app + * key in `.env.local` (bound to its brand: sent without X-Brand-Id), then + * the stored CLI login. + */ +function resolveProjectAuth( + ctx: CliContext, + input: { + readonly envLocalKey: string | undefined + /** The server the .env.local key is for (BREW_API_URL beside it). */ + readonly envLocalApiUrl: string | undefined + readonly state: BrewProjectState + } +): ResolvedAuth | undefined { + const resolved = resolveAuth({ + globals: ctx.globals, + env: ctx.io.env, + allowAnonymous: true, + }) + if (resolved.apiKeySource === 'flag' || resolved.apiKeySource === 'env') { + return { ...resolved, brandId: resolved.brandId ?? input.state.brandId } + } + if (input.envLocalKey !== undefined) { + // The key goes only to the server it is for (none saved = the default); + // another server named explicitly is refused before any request. + const saved = input.envLocalApiUrl ?? DEFAULT_API_URL + const named = + ctx.globals.apiUrl ?? (ctx.io.env.BREW_API_URL?.trim() || undefined) + if ( + named !== undefined && + named.replace(/\/+$/, '') !== saved.replace(/\/+$/, '') + ) { + throw new CliUsageError( + `${APP_KEY_VARIABLE} in .env.local is for ${saved}, not ${named}: run doctor --project without --api-url and BREW_API_URL (or with ${saved}).` + ) + } + return { + apiKey: input.envLocalKey, + apiKeySource: 'env', + brandId: ctx.globals.brand, + apiUrl: saved, + } + } + if (resolved.apiKeySource === 'config') { + return { ...resolved, brandId: input.state.brandId ?? resolved.brandId } + } + return undefined +} + +async function checkAppEmails( + ctx: CliContext, + client: BrewClient, + state: BrewProjectState +): Promise { + const keys = Object.keys(state.emails).sort() + if (keys.length === 0) { + return { + id: 'app-emails', + status: 'skipped', + detail: 'brew.json lists no keys', + } + } + // `readiness` is how a fire sends now: a draft over a live version still + // sends, a published key without a domain of its purpose does not. + const rows = new Map< + string, + { + triggerEventId: string + readiness: { + ready: boolean + teamOnly: boolean + blockers: readonly string[] + } + } + >() + try { + // Every page until each brew.json key is found (a repeated cursor ends it). + let cursor: string | undefined + const seen = new Set() + for (;;) { + ctx.signal.throwIfAborted() + const result = await client.appEmails.list({ + limit: 100, + ...(cursor === undefined ? {} : { cursor }), + }) + for (const row of result.data) { + rows.set(row.triggerKey, { + triggerEventId: row.triggerEventId, + readiness: row.readiness, + }) + } + const next = result.pagination.cursor + if ( + keys.every((key) => rows.has(key)) || + !result.pagination.hasMore || + next === null || + seen.has(next) + ) { + break + } + seen.add(next) + cursor = next + } + } catch (error) { + ctx.signal.throwIfAborted() + return { + id: 'app-emails', + status: 'fail', + detail: `could not list app emails: ${describe(error)}`, + } + } + const missing = keys.filter((key) => !rows.has(key)) + const moved = keys.filter( + (key) => + rows.has(key) && + rows.get(key)?.triggerEventId !== state.emails[key]?.triggerEventId + ) + const blocked = keys.filter((key) => rows.get(key)?.readiness.ready === false) + if (missing.length > 0) { + return { + id: 'app-emails', + status: 'fail', + detail: `${missing.length} key(s) in brew.json have no app email in this brand: sends answer TRIGGER_KEY_NOT_FOUND`, + items: missing, + fix: 'Run init again (it sets the kit up idempotently), or `app-emails upsert `.', + } + } + if (blocked.length > 0) { + return { + id: 'app-emails', + status: 'fail', + detail: `${blocked.length} key(s) cannot send: ${blocked.map((key) => `${key} (${rows.get(key)?.readiness.blockers.join(', ')})`).join('; ')}`, + items: blocked, + fix: 'Publish their automations in Brew (or run `app-emails upsert --yes`); DOMAIN_REQUIRED needs a verified domain of their purpose.', + } + } + // A verified domain of the other purpose leaves a key in the sandbox. + const teamOnly = keys.filter( + (key) => rows.get(key)?.readiness.teamOnly === true + ) + if (teamOnly.length > 0) { + return { + id: 'app-emails', + status: 'warn', + detail: `${teamOnly.length} key(s) reach your Brew team only (sandbox): no verified domain of their purpose (transactional for auth emails)`, + items: teamOnly, + fix: 'Verify a domain of their purpose, then run init again: their sender moves to it.', + } + } + return moved.length > 0 + ? { + id: 'app-emails', + status: 'warn', + detail: `${moved.length} key(s) now point at another trigger than brew.json records (run init again to refresh it)`, + items: moved, + } + : { + id: 'app-emails', + status: 'ok', + detail: `${keys.length} key(s) set up and ready to send`, + } +} + +async function checkDomain( + ctx: CliContext, + client: BrewClient +): Promise { + try { + // Every page: a verified domain past the first is still the sender. + const rows = await collectAll(ctx, (cursor) => + client.domains.list({ + limit: 100, + ...(cursor === undefined ? {} : { cursor }), + }) + ) + const sending = rows.find( + (row) => row.status === 'verified' && row.sendable + ) + if (sending !== undefined) { + return { + id: 'domain', + status: 'ok', + detail: `sending from ${sending.name}`, + } + } + const pending = rows.map((row) => `${row.name} (${row.status})`) + return { + id: 'domain', + status: 'warn', + detail: + 'no verified sending domain: app emails reach your Brew team only (sandbox)', + ...(pending.length === 0 ? {} : { items: pending }), + fix: 'Add the DNS records for a domain (or Connect automatically on its page in Brew), then `domains verify `.', + } + } catch (error) { + ctx.signal.throwIfAborted() + return { + id: 'domain', + status: 'fail', + detail: `could not list domains: ${describe(error)}`, + } + } +} + +async function checkProviders( + ctx: CliContext, + client: BrewClient, + state: BrewProjectState +): Promise { + const providers = new Set() + if (state.auth?.startsWith('supabase') === true) { + providers.add('supabase_auth') + } + if (state.auth?.startsWith('clerk') === true) { + providers.add('clerk') + } + for (const key of Object.keys(state.providerConfig ?? {})) { + if (key === 'supabase_auth' || key === 'clerk') { + providers.add(key) + } + } + if (providers.size === 0) { + return [ + { + id: 'provider', + status: 'skipped', + detail: 'no hosted auth hook for this auth stack', + }, + ] + } + const checks: ProjectCheck[] = [] + for (const provider of providers) { + try { + const connection = await findConnection(client, provider) + checks.push( + connection === null + ? { + id: 'provider', + status: 'warn', + detail: `${provider}: not connected (the provider sends its own auth emails, or a self-hosted hook does)`, + fix: `\`integrations connect ${provider}\`, or init's manual steps.`, + } + : connection.status === 'active' + ? { + id: 'provider', + status: 'ok', + detail: `${provider}: active (${connection.webhookUrl})`, + } + : { + id: 'provider', + status: 'warn', + detail: `${provider}: ${connection.status} until the first signed delivery; check the provider points at ${connection.webhookUrl}`, + } + ) + } catch (error) { + ctx.signal.throwIfAborted() + checks.push({ + id: 'provider', + status: 'fail', + detail: `${provider}: could not read the connection: ${describe(error)}`, + }) + } + } + return checks +} diff --git a/src/lib/init/brand.ts b/src/lib/init/brand.ts new file mode 100644 index 00000000..bd477785 --- /dev/null +++ b/src/lib/init/brand.ts @@ -0,0 +1,317 @@ +import { BrewApiError } from '@brew.new/sdk' +import type { BrewClient } from '../client' +import { systemClock } from '../clock' +import { CliApiError } from '../errors' +import { progress } from '../output' +import type { CliContext } from '../types' +import type { InitReport, InitStep, NextStep } from './report' + +/** + * `init --app-url`: the brand whose website is the app's. A brand that + * matches is used as it is; with a credential that may pick or create + * brands (an organization key from `--api-key` / `BREW_API_KEY`), init + * picks the matching brand, or creates one from the app's URL through + * `POST /v1/brands` (organization-scoped keys with the `brands` scope + * only; `403` otherwise, answered with what to do instead). A key bound to + * one brand (a fresh sign-in, the app key in `.env.local`) is never moved: + * a mismatch is reported, never "fixed" by sending the kit elsewhere. + * Idempotent: a re-run finds the brand it created by its website. + */ + +export type AppBrand = NonNullable + +/** How long init waits for a brand it created to finish extraction. */ +export const BRAND_READY_TIMEOUT_MS = 180_000 +const BRAND_POLL_MS = 5000 + +/** `acme.com` from `https://www.acme.com/x`, `acme.com`, or `ACME.com`. */ +export function normalizeHost(value: string): string | null { + const text = value.trim().toLowerCase() + if (text === '') { + return null + } + try { + const url = new URL( + /^[a-z][a-z0-9+.-]*:\/\//.test(text) ? text : `https://${text}` + ) + return url.hostname.replace(/^www\./, '').replace(/\.$/, '') || null + } catch { + return null + } +} + +/** + * The app is the brand's when its host is the brand's website or a + * subdomain of it (`app.acme.com` belongs to `acme.com`). + */ +export function brandMatchesApp( + brandDomain: string | null | undefined, + appUrl: string +): boolean { + const app = normalizeHost(appUrl) + const brand = + brandDomain === null || brandDomain === undefined + ? null + : normalizeHost(brandDomain) + return ( + app !== null && + brand !== null && + (app === brand || app.endsWith(`.${brand}`)) + ) +} + +export type BrandResolution = { + readonly brand: AppBrand | null + /** The brand the run's credential should act on from here, when it changed. */ + readonly switchTo?: string + /** False while a brand init created is still extracting. */ + readonly ready: boolean + readonly step: InitStep + readonly nextSteps: readonly NextStep[] +} + +function isForbidden(error: unknown): error is BrewApiError | CliApiError { + return ( + (error instanceof BrewApiError || error instanceof CliApiError) && + error.status === 403 + ) +} + +export async function resolveAppBrand(input: { + readonly ctx: CliContext + readonly client: BrewClient + readonly appUrl: string + /** The brand the credential acts on now, if it resolved to one. */ + readonly current: AppBrand | null + /** `current`'s `ready` (`GET /v1/brand`). */ + readonly currentReady: boolean + /** False for a key bound to one brand (sign-in, `.env.local`). */ + readonly canSwitch: boolean + readonly dryRun: boolean + readonly noWait: boolean + readonly idempotencyKey: (body: unknown) => string + readonly siteUrl: string + readonly rerunCommand: string +}): Promise { + const { ctx, client, appUrl, current } = input + const host = normalizeHost(appUrl) ?? appUrl + const nextSteps: NextStep[] = [] + if (current !== null && brandMatchesApp(current.domain, appUrl)) { + // A brand a `--no-wait` run created may still be extracting. + const ready = + input.currentReady || (await waitForBrand(input, current.brandId)) + return { + brand: { ...current, matchesAppUrl: true, created: false }, + ready, + step: { + id: 'brand', + status: 'done', + detail: `${current.name ?? current.brandId} (matches ${host})${ready ? '' : ' (still extracting)'}`, + }, + nextSteps: ready ? nextSteps : [extracting(input.rerunCommand, host)], + } + } + const mismatch = (detail: string): BrandResolution => { + nextSteps.push({ + id: 'brand-app-url', + actor: 'human', + kind: 'open-url', + url: `${input.siteUrl}/brands`, + title: detail, + blocking: current === null, + }) + return { + brand: + current === null + ? null + : { ...current, matchesAppUrl: false, created: false }, + ready: current !== null, + step: + current === null + ? { id: 'brand', status: 'failed', detail: `no brand for ${host}` } + : { + id: 'brand', + status: 'done', + detail: `${current.name ?? current.brandId} (does not match ${host})`, + }, + nextSteps, + } + } + if (!input.canSwitch) { + return mismatch( + `This app is at ${host}, but its key is for ${current?.name ?? 'another brand'}${current?.domain ? ` (${current.domain})` : ''}. Keep it if that is this app's brand. Otherwise create a brand for ${host} in Brew, remove BREW_API_KEY from .env.local, and run init again, picking that brand when you approve the sign-in` + ) + } + + // An organization key: pick the brand with the app's website, or make one. + let match: + | { + readonly brandId: string + readonly domain: string + readonly ready: boolean + } + | undefined + try { + // Every page until a match or the list's end (a cursor seen before + // ends it too): a match past any fixed page count would be re-created + // and refused as BRAND_DOMAIN_CONFLICT. + let cursor: string | undefined + const seen = new Set() + while (match === undefined) { + const result = await client.brands.list({ + limit: 100, + ...(cursor === undefined ? {} : { cursor }), + }) + match = result.data.find( + (row) => + row.status !== 'deleting' && + row.status !== 'failed' && + brandMatchesApp(row.domain, appUrl) + ) + const next = result.pagination.cursor + if (!result.pagination.hasMore || next === null || seen.has(next)) { + break + } + seen.add(next) + cursor = next + } + } catch (error) { + ctx.signal.throwIfAborted() + if (isForbidden(error)) { + throw error + } + // Not a mismatch: the app's brand is unknown, so nothing brand-specific + // runs in the brand the key acts as now. + return { + brand: null, + ready: false, + step: { + id: 'brand', + status: 'failed', + detail: `could not list brands: ${describe(error)}`, + }, + nextSteps: [ + { + id: 'brand-app-url', + actor: 'human', + kind: 'open-url', + url: `${input.siteUrl}/brands`, + title: `Could not list brands (${describe(error)}); run init again, or pick the brand for ${host} with --brand `, + blocking: true, + }, + ], + } + } + let created = false + let brandId = match?.brandId + let ready = match?.ready ?? false + let domain = match?.domain ?? host + if (brandId === undefined) { + if (input.dryRun) { + return { + brand: current === null ? null : { ...current, matchesAppUrl: false }, + ready: false, + step: { + id: 'brand', + status: 'planned', + detail: `create a brand for ${host} (POST /v1/brands; organization keys only)`, + }, + nextSteps, + } + } + const body = { url: appUrl } + try { + progress( + ctx, + `Creating a brand for ${host} (Brew reads the site for its design)…` + ) + const brand = await client.brands.create(body, { + idempotencyKey: input.idempotencyKey(body), + }) + brandId = brand.brandId + ready = brand.ready + domain = brand.domain + created = true + } catch (error) { + ctx.signal.throwIfAborted() + if (isForbidden(error)) { + return mismatch( + `Creating a brand for ${host} needs an organization API key with the brands scope (${error.code}). Create the brand in Brew (Brands > New brand, ${host}), or run init with an organization key: BREW_API_KEY= ${input.rerunCommand}` + ) + } + if (error instanceof BrewApiError || error instanceof CliApiError) { + if (error.status === 401) { + throw error + } + return mismatch( + `Could not create a brand for ${host} (${describe(error)}); create it in Brew, then run init again` + ) + } + throw error + } + } + if (!ready) { + ready = await waitForBrand(input, brandId) + } + const name = domain + if (!ready) { + nextSteps.push(extracting(input.rerunCommand, host)) + } + return { + brand: { brandId, name, domain, matchesAppUrl: true, created }, + ...(brandId === current?.brandId ? {} : { switchTo: brandId }), + ready, + step: { + id: 'brand', + status: 'done', + detail: `${created ? 'created' : 'using'} ${domain} for ${host}${ready ? '' : ' (still extracting)'}`, + }, + nextSteps, + } +} + +function extracting(rerunCommand: string, host: string): NextStep { + return { + id: 'brand-extracting', + actor: 'agent', + kind: 'run-command', + command: rerunCommand, + title: `Brew is still reading ${host} to build the brand (1 to 3 minutes); run init again when it is ready`, + blocking: true, + } +} + +/** Polls `GET /v1/brands/{brandId}` until `ready`, a failure, or the bound. */ +async function waitForBrand( + input: { + readonly ctx: CliContext + readonly client: BrewClient + readonly noWait: boolean + }, + brandId: string +): Promise { + if (input.noWait) { + return false + } + const clock = input.ctx.io.clock ?? systemClock + const deadline = clock.now() + BRAND_READY_TIMEOUT_MS + progress(input.ctx, 'Waiting for the brand to be ready (up to 3 minutes)…') + while (clock.now() < deadline) { + await clock.sleep(BRAND_POLL_MS, input.ctx.signal) + const status = await input.client.brands.get({ brandId }) + if (status.ready) { + return true + } + if (status.status === 'failed') { + return false + } + } + return false +} + +function describe(error: unknown): string { + if (error instanceof BrewApiError || error instanceof CliApiError) { + return `${error.code}: ${error.message}` + } + return error instanceof Error ? error.message : String(error) +} diff --git a/src/lib/init/pipeline.ts b/src/lib/init/pipeline.ts index 85870926..43ac4d29 100644 --- a/src/lib/init/pipeline.ts +++ b/src/lib/init/pipeline.ts @@ -33,8 +33,9 @@ import { type ResolvedAuth, resolveAuth, } from '../client' +import { systemClock } from '../clock' import { readConfig, updateConfig } from '../config-store' -import { buildConfirmCommand } from '../confirm' +import { buildConfirmCommand, shellQuote } from '../confirm' import { sdkTransportFor } from '../context' import type { CommandOutcome, CommandSpec } from '../define-command' import { CliApiError, CliUsageError } from '../errors' @@ -51,13 +52,16 @@ import { BREW_STATE_FILE, type BrewManagedFile, type BrewProjectEmail, + type BrewProjectState, BrewStateError, + type BrewStateExtra, createBrewState, readBrewState, serializeBrewState, } from '../project/state' import type { RerunInvocation } from '../rerun' import type { CliContext } from '../types' +import { type AppBrand, resolveAppBrand } from './brand' import { type AuthId, detectProject, type ProjectDetection } from './detect' import { applyPatch, @@ -67,6 +71,12 @@ import { type InitPatch, supabaseConfigPatch, } from './patches' +import { runProviderRollback } from './providers/rollback' +import { + type InitProvider, + type ProviderConfig, + runProviderStep, +} from './providers/step' import { exitCodeFor, INIT_REPORT_SCHEMA_VERSION, @@ -116,6 +126,14 @@ export type InitOptions = { readonly noWait: boolean readonly resume: boolean readonly openBrowser: boolean + /** Point Supabase Auth's Send Email hook at Brew (Management API). */ + readonly configureSupabase?: boolean + /** Turn off Clerk's delivery for the templates Brew sends. */ + readonly configureClerk?: boolean + /** The Supabase project ref, when it cannot be detected. */ + readonly supabaseProject?: string | undefined + /** Only put back the provider settings saved in brew.json. */ + readonly rollbackProvider?: boolean /** The parsed `init` invocation, re-serialized into the commands it prints. */ readonly rerun: InitRerun } @@ -316,6 +334,29 @@ export async function runInit( } const supabaseHook = authSet && project.auth === 'supabase' const stack = kitStackFor(project.auth) + if (options.configureSupabase === true && stack !== 'supabase') { + throw new CliUsageError( + `--configure-supabase is for Supabase Auth projects; this one's auth stack is ${stack} (pass --stack supabase if detection is wrong).` + ) + } + if (options.configureClerk === true && stack !== 'clerk') { + throw new CliUsageError( + `--configure-clerk is for Clerk projects; this one's auth stack is ${stack} (pass --stack clerk if detection is wrong).` + ) + } + if ( + !authSet && + (options.configureSupabase === true || options.configureClerk === true) + ) { + throw new CliUsageError( + `--configure-${stack} hands the auth emails to Brew, which needs the auth set: drop --sets, or include auth in it.` + ) + } + if (options.rollbackProvider === true) { + return finish( + await rollbackOnly({ ctx, project, state, stateRead, writer, dryRun }) + ) + } // ---- App key: .env.local must be git-ignored before a key goes in it. const envLocalText = writer.read('.env.local') @@ -375,14 +416,14 @@ export async function runInit( const savedApiUrl = nonEmpty( getEnvValue(parseEnv(envLocalText ?? ''), API_URL_VARIABLE) ) + const same = (a: string, b: string) => + a.replace(/\/+$/, '') === b.replace(/\/+$/, '') if ( explicitKey === undefined && (existingAppKey !== undefined || savedApiUrl !== undefined) ) { const saved = savedApiUrl ?? DEFAULT_API_URL const named = ctx.globals.apiUrl ?? nonEmpty(env.BREW_API_URL) - const same = (a: string, b: string) => - a.replace(/\/+$/, '') === b.replace(/\/+$/, '') if (named !== undefined && !same(named, saved)) { throw new CliUsageError( `.env.local is set up for ${printable(saved)}, not ${printable(named)}. Run init without --api-url and BREW_API_URL (or with ${printable(saved)}), or remove ${APP_KEY_VARIABLE} and ${API_URL_VARIABLE} from .env.local to set up the other server.` @@ -619,14 +660,133 @@ export async function runInit( }) } - const client: BrewClient | undefined = + let client: BrewClient | undefined = credential.auth === undefined ? undefined : buildSdkClient(credential.auth, sdkTransportFor(ctx)) const afterSignIn = 'after sign-in' + // False while a brand this run created is still being extracted: the + // kit and everything after it wait for a re-run. + let brandReady = true + /** + * The brand the .env.local key is bound to, or undefined when it cannot + * tell. Asked only on the server saved beside that key. + */ + const brandOfKey = async (apiKey: string): Promise => { + try { + const auth = { + ...(authFor(apiKey, 'env-file', undefined).auth as ResolvedAuth), + apiUrl: savedApiUrl ?? DEFAULT_API_URL, + } + return (await buildSdkClient(auth, sdkTransportFor(ctx)).brand.get()) + .brandId + } catch (error) { + rethrowIfFatal(ctx, error) + return undefined + } + } // ---- Brand. - if (brand !== null) { + if ( + options.appUrl !== undefined && + client !== undefined && + credential.auth !== undefined + ) { + let current: AppBrand | null = null + let currentReady = false + let lookupError: string | undefined + try { + const row = await client.brand.get() + current = { + brandId: row.brandId, + name: brand?.name ?? row.identity?.brandName ?? row.domain ?? null, + domain: row.domain ?? null, + } + currentReady = row.ready + } catch (error) { + rethrowIfFatal(ctx, error) + // 400 BRAND_ID_REQUIRED: an organization key with no brand picked yet. + if ( + !( + (error instanceof BrewApiError || error instanceof CliApiError) && + error.status === 400 + ) + ) { + lookupError = describeError(error) + } + } + if (lookupError !== undefined) { + // Unknown brand: nothing brand-specific runs on a key that may act + // as another brand than the app's. + brandReady = false + steps.push({ id: 'brand', status: 'failed', detail: lookupError }) + } else { + const resolution = await resolveAppBrand({ + ctx, + client, + appUrl: options.appUrl, + current, + currentReady, + canSwitch: + credential.source === 'flag' || + credential.source === 'env' || + credential.source === 'config', + dryRun, + noWait: options.noWait, + idempotencyKey: (body) => idempotencyKey('brand', body), + siteUrl, + rerunCommand: rerunCommand(ctx, options.rerun, { + drop: ['--resume', '--no-wait', '--dry-run'], + add: [], + }), + }) + brand = resolution.brand + brandReady = resolution.ready + steps.push(resolution.step) + nextSteps.push(...resolution.nextSteps) + // The brand this run sets up for the app: the one it switches to, or + // the key's own when that already has the app's website. + const target = + resolution.switchTo ?? + (resolution.brand?.matchesAppUrl === true + ? resolution.brand.brandId + : undefined) + // The server the .env.local key sends to: a brand id it reports there + // says nothing about a brand on this run's server. + const keyApiUrl = savedApiUrl ?? DEFAULT_API_URL + const keyOnThisServer = same(keyApiUrl, apiUrl) + if ( + target !== undefined && + existingAppKey !== undefined && + existingAppKey !== credential.auth.apiKey && + (!keyOnThisServer || (await brandOfKey(existingAppKey)) !== target) + ) { + // The app sends with its own key, bound to one brand: setting up + // another brand here would leave the app sending from the old one. + brandReady = false + nextSteps.push({ + id: 'replace-app-key', + actor: 'human', + kind: 'edit-file', + path: '.env.local', + title: keyOnThisServer + ? `${APP_KEY_VARIABLE} in .env.local is bound to another brand: to send from ${resolution.brand?.domain ?? 'this brand'}, remove it and run init again, picking that brand when you approve the sign-in` + : `${APP_KEY_VARIABLE} in .env.local sends to ${printable(keyApiUrl)}, not ${printable(apiUrl)}, where this run sets up ${resolution.brand?.domain ?? 'the brand'}: to send from there, remove ${APP_KEY_VARIABLE} and ${API_URL_VARIABLE} from .env.local and run init again, picking that brand when you approve the sign-in`, + blocking: true, + }) + } else if (resolution.switchTo !== undefined) { + credential = authFor( + credential.auth.apiKey, + credential.source, + resolution.switchTo + ) + client = buildSdkClient( + credential.auth as ResolvedAuth, + sdkTransportFor(ctx) + ) + } + } + } else if (brand !== null) { steps.push({ id: 'brand', status: 'done', @@ -671,10 +831,18 @@ export async function runInit( added: false, records: [], settingsUrl: `${siteUrl}/domains`, + connectUrl: null, sandbox: null, } if (client === undefined) { steps.push({ id: 'domain', status: 'planned', detail: afterSignIn }) + } else if (!brandReady) { + // The client may still act as another brand than the one picked. + steps.push({ + id: 'domain', + status: 'skipped', + detail: 'after the brand is ready', + }) } else { try { const rows = await collectAll(ctx, (cursor) => @@ -716,8 +884,12 @@ export async function runInit( domain.domainId = shown.domainId domain.name = shown.name domain.status = shown.status - // A conflicting DKIM record is never shown: publishing it would - // replace the key another sender signs with. + // A DKIM key conflict needs a subdomain: these records, shown or + // added automatically, would replace another sender's key. + domain.connectUrl = + selectorInUse === undefined + ? (shown.domainUrl ?? `${siteUrl}/domains`) + : null if (shown.status !== 'verified' && selectorInUse === undefined) { domain.records = shown.records.map((record) => ({ type: record.type, @@ -771,6 +943,16 @@ export async function runInit( url: shown?.domainUrl ?? domain.settingsUrl, blocking: false, }) + // Entri's automatic DNS connect runs in the Brew app only: v1 has no + // API for it, so the CLI hands the person the page with the button. + nextSteps.push({ + id: 'connect-dns-automatically', + actor: 'human', + kind: 'open-url', + url: domain.connectUrl ?? domain.settingsUrl, + title: `Or let Brew add the records for you: open ${domain.name} in Brew and choose Connect automatically (it signs in to your DNS provider; this runs in the Brew app, not the CLI). Until the domain verifies, app emails reach your Brew team only (sandbox)`, + blocking: false, + }) } else if (chosen === undefined) { nextSteps.push({ id: 'verify-domain', @@ -797,7 +979,13 @@ export async function runInit( const emails: InitEmail[] = [] // Kit keys someone unpublished in Brew: the kit never publishes them. const off: string[] = [] - if (client === undefined || dryRun) { + if (client !== undefined && !dryRun && !brandReady) { + steps.push({ + id: 'kit', + status: 'skipped', + detail: 'waits for the brand (see the next steps); run init again then', + }) + } else if (client === undefined || dryRun) { steps.push({ id: 'kit', status: 'planned', @@ -955,12 +1143,12 @@ export async function runInit( }) } if (paths.contracts !== null) { - if (client === undefined) { + if (client === undefined || !brandReady) { files.push({ path: paths.contracts, action: 'planned', role: 'contracts', - note: afterSignIn, + note: client === undefined ? afterSignIn : 'after the brand is ready', }) } else { const triggers = await listAllTriggers(ctx, client) @@ -1036,11 +1224,12 @@ export async function runInit( computedPatches.push(supabaseConfigPatch(project)) } // No patch applies while a generated file, a kit key, the app key in - // .env.local or the SDK install failed, or a kit key is unpublished (the - // patch would hand that email to a key that does not exist or sends - // nothing, to a client with no API key, or to a helper whose SDK import - // breaks the build). + // .env.local or the SDK install failed, the kit waits for the brand, or a + // kit key is unpublished (the patch would hand that email to a key that + // does not exist or sends nothing, to a client with no API key, or to a + // helper whose SDK import breaks the build). const patchesHeld = + !brandReady || off.length > 0 || steps.some( (step) => @@ -1109,7 +1298,9 @@ export async function runInit( `${patch.id}@${(patch.baseSha256 ?? '').slice(0, 12)}`, ], }), - blocking: true, + // The local config.toml hook serves the self-hosted function, the + // alternative to Brew's hosted hook: useful, never required. + blocking: patch.id !== 'supabase-config-hook', }) } else if (patch.status === 'manual') { nextSteps.push({ @@ -1132,6 +1323,84 @@ export async function runInit( : patches.map((patch) => `${patch.id} ${patch.status}`).join(', '), }) + // ---- Where the auth provider's own emails go (Supabase, Clerk). + let providerConfig: ProviderConfig = (state?.providerConfig ?? + {}) as ProviderConfig + let providers: readonly InitProvider[] = [] + let stateWrittenEarly = false + if (authSet && (stack === 'supabase' || stack === 'clerk')) { + if (!brandReady) { + steps.push({ + id: 'provider', + status: 'skipped', + detail: 'after the brand is ready', + }) + } else { + // A printed follow-up resolves its own brand and server (the CLI's + // saved key, BREW_BRAND_ID, BREW_API_URL): name this run's where they + // would differ, so it never acts on another brand. + const plain = resolveAuth({ + globals: { + ...ctx.globals, + apiKey: undefined, + brand: undefined, + apiUrl: undefined, + }, + env, + allowAnonymous: true, + }) + const runBrand = brand?.brandId ?? state?.brandId + const scope = [ + plain.apiKeySource !== 'anonymous' && + runBrand !== undefined && + plain.brandId !== runBrand + ? ` --brand ${shellQuote(runBrand)}` + : '', + plain.apiUrl === apiUrl ? '' : ` --api-url ${shellQuote(apiUrl)}`, + ].join('') + const result = await runProviderStep({ + ctx, + root: project.root, + stack, + dryRun, + configureSupabase: options.configureSupabase === true, + configureClerk: options.configureClerk === true, + supabaseProject: options.supabaseProject, + client, + writer, + prefix, + scope, + // A --stack override is kept: detection may say another stack. + init: `${prefix} init${options.app === undefined ? '' : ` --app ${shellQuote(options.app)}`}${options.stack === undefined ? '' : ` --stack ${options.stack}`}${scope}`, + providerConfig, + // brew.json holds the provider's old settings BEFORE they change. + persist: (config) => { + const change = writer.writeFile( + BREW_STATE_FILE, + serializeBrewState( + createBrewState({ + ...(state ?? {}), + projectId, + providerConfig: config, + }), + stateRead?.extra ?? {} + ), + { overwrite: true } + ) + stateWrittenEarly ||= change.action !== 'refused' + return change.action !== 'refused' + }, + now: () => (ctx.io.clock ?? systemClock).now(), + }) + if (result.step !== null) { + steps.push(result.step) + } + nextSteps.push(...result.nextSteps) + providers = result.providers + providerConfig = result.providerConfig + } + } + // ---- MCP clients the project already uses. const mcp: McpInstallResult[] = [] if (!options.mcp) { @@ -1276,8 +1545,9 @@ export async function runInit( ]), ] const brandId = brand?.brandId ?? state?.brandId + const { providerConfig: _savedProviderConfig, ...previousState } = state ?? {} const nextState = createBrewState({ - ...(state ?? {}), + ...previousState, projectId, ...(brandId === undefined ? {} : { brandId }), framework: project.framework, @@ -1288,6 +1558,7 @@ export async function runInit( ...(paths.contracts === null ? {} : { contractsPath: paths.contracts }), emails: stateEmails, files: stateFiles, + ...(Object.keys(providerConfig).length === 0 ? {} : { providerConfig }), ...(mcpIds.length === 0 ? {} : { mcp: mcpIds }), }) const stateChange = writer.writeFile( @@ -1297,7 +1568,13 @@ export async function runInit( ) files.push({ path: BREW_STATE_FILE, - action: stateChange.action, + // A provider change saved brew.json early; this run still created it. + action: + stateWrittenEarly && + stateRead === null && + stateChange.action === 'updated' + ? 'created' + : stateChange.action, role: 'state', }) steps.push( @@ -1314,16 +1591,25 @@ export async function runInit( projectNextSteps({ project, nextSteps, siteUrl, paths, prefix, supabaseHook }) // ---- 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, - }) + if (!brandReady) { + steps.push({ + id: 'test-email', + status: 'skipped', + detail: 'after the brand is ready', + }) + } + const testEmail: InitReport['testEmail'] = !brandReady + ? { status: 'skipped', key: null, to: null, reason: 'brand not ready' } + : await sendTestEmail({ + ctx, + client, + options, + emails, + to: options.testTo ?? signedInEmail, + idempotencyKey: (body) => idempotencyKey('test-email', body), + steps, + nextSteps, + }) const report = baseReport({ ctx, @@ -1352,6 +1638,7 @@ export async function runInit( files, patches, mcp, + providers, testEmail, paths, keys, @@ -1513,34 +1800,15 @@ function projectNextSteps(input: { }): void { const { project, nextSteps } = input if (input.supabaseHook) { - 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') { + // Brew's hosted hook is the default (the provider step); the function + // init writes is the self-hosted alternative. nextSteps.push({ - id: 'connect-clerk', - actor: 'human', - kind: 'open-url', - url: `${input.siteUrl}/integrations/clerk`, + id: 'deploy-supabase-hook', + actor: 'agent', + kind: 'run-command', + command: 'supabase functions deploy send-email --no-verify-jwt', 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)', + "Alternative to Brew's hosted hook: deploy the self-hosted Send Email Hook function, point Authentication > Hooks > Send Email at its URL, and store the hook's secret as SEND_EMAIL_HOOK_SECRET and BREW_API_KEY (from .env.local) in the Edge Function secrets", blocking: false, }) } @@ -1775,6 +2043,7 @@ function baseReport(input: { readonly files?: readonly InitFile[] readonly patches?: readonly InitPatch[] readonly mcp?: readonly McpInstallResult[] + readonly providers?: readonly InitProvider[] readonly testEmail?: InitReport['testEmail'] readonly paths?: { readonly client: string | null @@ -1854,6 +2123,7 @@ function baseReport(input: { files: input.files ?? [], patches: input.patches ?? [], mcp: input.mcp ?? [], + providers: input.providers ?? [], testEmail: input.testEmail ?? { status: 'skipped', key: null, to: null }, steps: input.steps, nextSteps: input.nextSteps, @@ -1944,6 +2214,7 @@ function ambiguousReport( files: [], patches: [], mcp: [], + providers: [], testEmail: { status: 'skipped', key: null, to: null }, steps: [ { @@ -1962,6 +2233,93 @@ function ambiguousReport( } } +/** + * `init --rollback-provider`: only the provider settings saved in + * brew.json go back; nothing else in the project or the brand changes. + */ +async function rollbackOnly(input: { + readonly ctx: CliContext + readonly project: ProjectDetection + readonly state: BrewProjectState | null + readonly stateRead: { readonly extra: BrewStateExtra } | null + readonly writer: ProjectWriter + readonly dryRun: boolean +}): Promise { + const { ctx, project, state, writer, dryRun } = input + if (state === null) { + throw new CliUsageError( + `No ${BREW_STATE_FILE} in ${project.root}: there is nothing to roll back. Run this in the app init set up.` + ) + } + const result = await runProviderRollback({ + ctx, + root: project.root, + writer, + dryRun, + providerConfig: (state.providerConfig ?? {}) as ProviderConfig, + }) + const steps: InitStep[] = [ + { id: 'detect', status: 'done', detail: project.framework }, + result.step, + ] + const files: InitFile[] = [] + const changed = + Object.keys(result.providerConfig).length !== + Object.keys(state.providerConfig ?? {}).length || + result.providers.some((provider) => provider.action === 'failed') + if (!dryRun && changed) { + const { providerConfig: _saved, ...rest } = state + const next = createBrewState({ + ...rest, + ...(Object.keys(result.providerConfig).length === 0 + ? {} + : { providerConfig: result.providerConfig }), + }) + const change = writer.writeFile( + BREW_STATE_FILE, + serializeBrewState(next, input.stateRead?.extra ?? {}), + { overwrite: true } + ) + files.push({ path: BREW_STATE_FILE, action: change.action, role: 'state' }) + steps.push( + change.action === 'refused' + ? { + id: 'state', + status: 'failed', + detail: `${BREW_STATE_FILE} not written: ${change.note ?? 'unsafe path'}`, + } + : { + id: 'state', + status: 'done', + detail: 'restored providers removed from providerConfig', + } + ) + } else { + steps.push({ id: 'state', status: dryRun ? 'planned' : 'skipped' }) + } + const appKey = nonEmpty( + getEnvValue(parseEnv(writer.read('.env.local') ?? ''), APP_KEY_VARIABLE) + ) + return baseReport({ + ctx, + project, + projectId: state.projectId, + dryRun, + steps, + nextSteps: result.nextSteps, + credential: { auth: undefined, source: 'none' }, + appKey: { + status: appKey === undefined ? 'missing' : 'existing', + masked: appKey === undefined ? null : maskApiKey(appKey), + }, + ...(state.brandId === undefined + ? {} + : { brand: { brandId: state.brandId, name: null } }), + files, + providers: result.providers, + }) +} + function finish(report: InitReport): CommandOutcome { const exitCode = exitCodeFor(report.status) return { diff --git a/src/lib/init/providers/clerk.ts b/src/lib/init/providers/clerk.ts new file mode 100644 index 00000000..e6e794d4 --- /dev/null +++ b/src/lib/init/providers/clerk.ts @@ -0,0 +1,142 @@ +import { createHash } from 'node:crypto' +import { join } from 'node:path' +import { CliVendorError } from '../../errors' +import { getEnvValue, parseEnv } from '../../project/env-file' +import { readTextFile } from '../../project/fs-writer' +import { vendorFetch } from '../../project/vendor-http' +import type { CliContext } from '../../types' + +/** + * Clerk's per-template "Delivered by Clerk" switch, through the Clerk + * Backend API with the app's own `CLERK_SECRET_KEY` (read locally, sent to + * api.clerk.com only, never to Brew). Base URL `https://api.clerk.com/v1`, + * `Authorization: Bearer sk_…`. + * + * - `GET /templates/{template_type}/{slug}` ("Retrieve a template"): the + * template, with `delivered_by_clerk`. + * - `POST /templates/{template_type}/{slug}/toggle_delivery` ("Toggle the + * delivery by Clerk for a template of a given type and slug"), body + * `{ "delivered_by_clerk": boolean }`; with it off, Clerk does not send + * the email and the app handles `email.created` itself. + * + * Both are in Clerk's Backend API reference + * (https://clerk.com/docs/reference/backend-api, tag "Email & SMS + * Templates"; spec https://github.com/clerk/openapi-specs/blob/main/bapi/2026-08-20.yml), + * where they are marked deprecated in favour of the dashboard but still + * served. A 404 is a template this instance does not have; 402/423 mean the + * instance's plan does not allow the change (do it in the dashboard). + */ + +export const CLERK_API_URL = 'https://api.clerk.com/v1' + +export const CLERK_SECRET_VARIABLE = 'CLERK_SECRET_KEY' + +/** + * Clerk email template slug → the kit key Brew sends for it: brew-v2 + * `lib/app-emails/auth-routing.ts` `CLERK_EMAIL_SLUG_ROUTES` + * (GetBrew/brew-v2#2169). Delivery is turned off only for a slug whose key's + * app email reaches every user (published, not the team-only sandbox). + */ +export const CLERK_EMAIL_SLUG_KEYS: Readonly> = { + verification_code: 'auth.otp', + reset_password_code: 'auth.password-reset', + magic_link_sign_in: 'auth.magic-link', + magic_link_sign_up: 'auth.verify-email', + magic_link_user_profile: 'auth.verify-email', + invitation: 'auth.invite', + organization_invitation: 'auth.invite', + password_changed: 'auth.security-notice', + password_removed: 'auth.security-notice', + primary_email_address_changed: 'auth.security-notice', + passkey_added: 'auth.security-notice', + passkey_removed: 'auth.security-notice', +} + +export function clerkTemplateUrl(slug: string): string { + return `${CLERK_API_URL}/templates/email/${encodeURIComponent(slug)}` +} + +export function clerkToggleUrl(slug: string): string { + return `${clerkTemplateUrl(slug)}/toggle_delivery` +} + +/** The Clerk Dashboard page with the per-template switch. */ +export const CLERK_EMAILS_DASHBOARD_URL = + 'https://dashboard.clerk.com/last-active?path=customization/email' + +/** + * `CLERK_SECRET_KEY` from the environment, else the app's `.env.local` or + * `.env` (where Clerk's quickstarts put it). Never sent to Brew. + */ +export function readClerkSecret(input: { + readonly root: string + readonly env: Readonly> +}): string | undefined { + const fromEnv = input.env[CLERK_SECRET_VARIABLE]?.trim() + if (fromEnv !== undefined && fromEnv !== '') { + return fromEnv + } + for (const file of ['.env.local', '.env']) { + const text = readTextFile(join(input.root, file)) + const value = + text === null + ? undefined + : getEnvValue(parseEnv(text), CLERK_SECRET_VARIABLE)?.trim() + if (value !== undefined && value !== '') { + return value + } + } + return undefined +} + +/** + * Which key, so which Clerk instance, changed delivery: a SHA-256 prefix + * brew.json keeps (the key cannot be recovered from it), so a rollback + * never turns delivery on in another instance. + */ +export function clerkKeyId(secretKey: string): string { + return createHash('sha256').update(secretKey).digest('hex').slice(0, 16) +} + +/** `delivered_by_clerk` for one email template; `null` when it does not exist. */ +export async function readClerkDelivery( + ctx: Pick, + input: { readonly secretKey: string; readonly slug: string } +): Promise { + try { + const response = await vendorFetch(ctx, { + vendor: 'Clerk', + method: 'GET', + url: clerkTemplateUrl(input.slug), + headers: { authorization: `Bearer ${input.secretKey}` }, + }) + const record = + typeof response.json === 'object' && response.json !== null + ? (response.json as Record) + : {} + return record.delivered_by_clerk !== false + } catch (error) { + if (error instanceof CliVendorError && error.status === 404) { + return null + } + throw error + } +} + +export async function setClerkDelivery( + ctx: Pick, + input: { + readonly secretKey: string + readonly slug: string + readonly deliveredByClerk: boolean + } +): Promise { + await vendorFetch(ctx, { + vendor: 'Clerk', + method: 'POST', + url: clerkToggleUrl(input.slug), + headers: { authorization: `Bearer ${input.secretKey}` }, + json: { delivered_by_clerk: input.deliveredByClerk }, + maxAttempts: 2, + }) +} diff --git a/src/lib/init/providers/rollback.ts b/src/lib/init/providers/rollback.ts new file mode 100644 index 00000000..64864d79 --- /dev/null +++ b/src/lib/init/providers/rollback.ts @@ -0,0 +1,310 @@ +import { CliUsageError } from '../../errors' +import { progress } from '../../output' +import type { ProjectWriter } from '../../project/fs-writer' +import type { CliContext } from '../../types' +import type { InitStep, NextStep } from '../report' +import { + CLERK_EMAILS_DASHBOARD_URL, + CLERK_SECRET_VARIABLE, + clerkKeyId, + clerkToggleUrl, + readClerkSecret, + setClerkDelivery, +} from './clerk' +import type { InitProvider, ProviderConfig, ProviderRequest } from './step' +import { + localValue, + patchSendEmailHook, + REDACTED_SECRET, + type SendEmailHookBody, + SUPABASE_TOKEN_VARIABLE, + supabaseAuthConfigUrl, + supabaseHooksUrl, +} from './supabase' + +/** + * `init --rollback-provider`: put back what `--configure-supabase` and + * `--configure-clerk` changed, from `brew.json#providerConfig`. Supabase's + * Send Email hook gets its previous `enabled` and `uri` (and its previous + * secret, from `.env.local`, when one was saved); Clerk's delivery goes back + * on for exactly the templates init turned off. A restored provider leaves + * `providerConfig`. Brew's connection stays as it is (harmless once the + * provider no longer delivers to it). + */ + +export type RollbackResult = { + readonly step: InitStep + readonly nextSteps: readonly NextStep[] + readonly providers: readonly InitProvider[] + readonly providerConfig: ProviderConfig +} + +export async function runProviderRollback(input: { + readonly ctx: CliContext + readonly root: string + readonly writer: ProjectWriter + readonly dryRun: boolean + readonly providerConfig: ProviderConfig +}): Promise { + const { ctx, writer, dryRun } = input + const saved = input.providerConfig + if (saved.supabase_auth === undefined && saved.clerk === undefined) { + throw new CliUsageError( + 'brew.json has no provider settings to roll back (init saves them only when --configure-supabase or --configure-clerk changes a provider).' + ) + } + let remaining: ProviderConfig = { ...saved } + const providers: InitProvider[] = [] + const nextSteps: NextStep[] = [] + const problems: string[] = [] + + const supabase = saved.supabase_auth + if (supabase !== undefined) { + const token = localValue(ctx, writer, SUPABASE_TOKEN_VARIABLE) + const previousSecrets = + supabase.previousSecretsVariable === undefined + ? undefined + : localValue(ctx, writer, supabase.previousSecretsVariable) + const previous = supabase.previous + // The hook's previous `enabled` and URL; a hook that was on also gets + // its secret back when this machine kept it. + const body: SendEmailHookBody = { + hook_send_email_enabled: previous.hook_send_email_enabled, + hook_send_email_uri: previous.hook_send_email_uri, + ...(previous.hook_send_email_enabled && previousSecrets !== undefined + ? { hook_send_email_secrets: previousSecrets } + : {}), + } + const shown: ProviderRequest = { + vendor: 'Supabase', + method: 'PATCH', + url: supabaseAuthConfigUrl(supabase.projectRef), + body: + body.hook_send_email_secrets === undefined + ? sendable(body) + : { ...sendable(body), hook_send_email_secrets: REDACTED_SECRET }, + } + // A hook that was on goes back only with its secret: enabled with + // Brew's, it would reject every email. + const secretMissing = + previous.hook_send_email_enabled && previousSecrets === undefined + const refusals: string[] = [] + if (secretMissing) { + refusals.push("the previous hook's secret is not on this machine") + nextSteps.push({ + id: 'restore-supabase-hook-secret', + actor: 'human', + kind: 'open-url', + url: supabaseHooksUrl(supabase.projectRef), + title: + supabase.previousSecretsVariable === undefined + ? 'Set the previous Send Email hook back by hand in Supabase (Authentication > Hooks), with its secret: this machine did not keep it' + : `Put the previous Send Email hook's secret back in ${supabase.previousSecretsVariable} (this shell or .env.local), then run the rollback again`, + blocking: true, + }) + } + if (token === undefined) { + refusals.push(`${SUPABASE_TOKEN_VARIABLE} is not set`) + nextSteps.push({ + id: 'supabase-access-token', + actor: 'human', + kind: 'set-secret', + url: 'https://supabase.com/dashboard/account/tokens', + title: `Export a Supabase personal access token as ${SUPABASE_TOKEN_VARIABLE}, then run the rollback again`, + blocking: true, + }) + } + if (token === undefined || secretMissing) { + problems.push(...refusals) + providers.push({ + provider: 'supabase_auth', + action: 'refused', + connection: null, + projectRef: supabase.projectRef, + previous, + requests: [], + rollbackCommand: null, + detail: refusals.join('; '), + }) + } else if (dryRun) { + providers.push({ + provider: 'supabase_auth', + action: 'planned', + connection: null, + projectRef: supabase.projectRef, + previous, + requests: [shown], + rollbackCommand: null, + detail: 'dry run', + }) + } else { + try { + progress( + ctx, + `Restoring Supabase project ${supabase.projectRef}'s Send Email hook…` + ) + await patchSendEmailHook(ctx, { + ref: supabase.projectRef, + token, + body: sendable(body), + }) + const { supabase_auth: _restored, ...rest } = remaining + remaining = rest + providers.push({ + provider: 'supabase_auth', + action: 'restored', + connection: null, + projectRef: supabase.projectRef, + previous, + requests: [shown], + rollbackCommand: null, + detail: previous.hook_send_email_enabled + ? 'previous Send Email hook restored' + : 'Send Email hook off again: Supabase sends its own auth emails', + }) + } catch (error) { + ctx.signal.throwIfAborted() + const detail = error instanceof Error ? error.message : String(error) + problems.push(detail) + providers.push({ + provider: 'supabase_auth', + action: 'failed', + connection: null, + projectRef: supabase.projectRef, + previous, + requests: [], + rollbackCommand: null, + detail, + }) + } + } + } + + const clerk = saved.clerk + if (clerk !== undefined) { + const secretKey = readClerkSecret({ root: input.root, env: ctx.io.env }) + const requests: ProviderRequest[] = clerk.templates.map((slug) => ({ + vendor: 'Clerk', + method: 'POST', + url: clerkToggleUrl(slug), + body: { delivered_by_clerk: true }, + })) + // Another key is another instance: never turn delivery on there. + const otherKey = + secretKey !== undefined && + clerk.keyId !== undefined && + clerkKeyId(secretKey) !== clerk.keyId + if (secretKey === undefined || otherKey) { + const detail = otherKey + ? `${CLERK_SECRET_VARIABLE} is not the key of the Clerk instance init changed` + : `${CLERK_SECRET_VARIABLE} not found` + problems.push(detail) + nextSteps.push({ + id: 'clerk-secret-key', + actor: 'human', + kind: 'set-secret', + title: `Make the ${CLERK_SECRET_VARIABLE} of the Clerk instance init changed available in this shell or .env.local, then run the rollback again (or turn "Delivered by Clerk" back on in that instance's dashboard for: ${clerk.templates.join(', ')})`, + url: CLERK_EMAILS_DASHBOARD_URL, + blocking: true, + }) + providers.push({ + provider: 'clerk', + action: 'refused', + connection: null, + templates: clerk.templates, + requests: [], + rollbackCommand: null, + detail, + }) + } else if (dryRun) { + providers.push({ + provider: 'clerk', + action: 'planned', + connection: null, + templates: clerk.templates, + requests, + rollbackCommand: null, + detail: 'dry run', + }) + } else { + const restored: string[] = [] + try { + for (const slug of clerk.templates) { + progress(ctx, `Turning Clerk delivery back on for ${slug}…`) + await setClerkDelivery(ctx, { + secretKey, + slug, + deliveredByClerk: true, + }) + restored.push(slug) + } + const { clerk: _restored, ...rest } = remaining + remaining = rest + providers.push({ + provider: 'clerk', + action: 'restored', + connection: null, + templates: restored, + requests, + rollbackCommand: null, + detail: 'Clerk delivers these templates again', + }) + } catch (error) { + ctx.signal.throwIfAborted() + const detail = error instanceof Error ? error.message : String(error) + problems.push(detail) + const left = clerk.templates.filter((slug) => !restored.includes(slug)) + remaining = { + ...remaining, + clerk: { ...clerk, templates: left }, + } + providers.push({ + provider: 'clerk', + action: 'failed', + connection: null, + templates: restored, + requests: requests.filter((request) => + restored.some((slug) => request.url === clerkToggleUrl(slug)) + ), + rollbackCommand: null, + detail, + }) + } + } + } + + const failedAny = providers.some((provider) => provider.action === 'failed') + const refusedAny = providers.some((provider) => provider.action === 'refused') + return { + step: { + id: 'provider', + status: failedAny + ? 'failed' + : dryRun + ? 'planned' + : refusedAny + ? 'skipped' + : 'done', + detail: + problems.length > 0 + ? `not rolled back: ${problems.join('; ')}` + : dryRun + ? `restore ${providers.map((provider) => provider.provider).join(' and ')}` + : `restored ${providers.map((provider) => provider.provider).join(' and ')}`, + }, + nextSteps, + providers, + providerConfig: remaining, + } +} + +/** The PATCH body without `undefined` members. */ +function sendable(body: SendEmailHookBody): SendEmailHookBody { + return { + hook_send_email_enabled: body.hook_send_email_enabled, + hook_send_email_uri: body.hook_send_email_uri, + ...(body.hook_send_email_secrets === undefined + ? {} + : { hook_send_email_secrets: body.hook_send_email_secrets }), + } +} diff --git a/src/lib/init/providers/step.ts b/src/lib/init/providers/step.ts new file mode 100644 index 00000000..665788c5 --- /dev/null +++ b/src/lib/init/providers/step.ts @@ -0,0 +1,1035 @@ +import { BrewApiError } from '@brew.new/sdk' +import type { BrewClient } from '../../client' +import { CliApiError } from '../../errors' +import { + findConnection, + type HostedAuthProvider, +} from '../../integrations/hosted-auth' +import { progress } from '../../output' +import type { ProjectWriter } from '../../project/fs-writer' +import type { CliContext } from '../../types' +import type { InitStep, NextStep } from '../report' +import { + CLERK_EMAIL_SLUG_KEYS, + CLERK_EMAILS_DASHBOARD_URL, + CLERK_SECRET_VARIABLE, + clerkKeyId, + clerkToggleUrl, + readClerkDelivery, + readClerkSecret, + setClerkDelivery, +} from './clerk' +import { + backupHookSecret, + detectSupabaseRef, + generateHookSecret, + localValue, + patchSendEmailHook, + REDACTED_SECRET, + readSendEmailHook, + type SendEmailHookBody, + type SendEmailHookConfig, + SUPABASE_PREVIOUS_SECRETS_VARIABLE, + SUPABASE_TOKEN_VARIABLE, + savedHookSecret, + supabaseAuthConfigUrl, + supabaseHooksUrl, + supabaseProjectUrl, +} from './supabase' + +/** + * `init`'s provider step: where the auth provider's own emails go. + * + * Nothing about a provider changes under a bare `--yes`. Pointing Supabase + * Auth's Send Email hook at Brew takes `--configure-supabase`; turning off + * Clerk's delivery takes `--configure-clerk`, and happens only once Brew's + * Clerk connection is `active` (a signed delivery arrived), so no email is + * ever dropped between the two. Before either change, the provider's + * previous settings go into `brew.json#providerConfig` (no secrets) and + * `init --rollback-provider` puts them back. Without the flags, the step + * prints the manual steps. Provider tokens stay on this machine: they go + * to api.supabase.com / api.clerk.com, never to Brew, and never into + * output or `brew.json`. + */ + +export type ProviderRequest = { + readonly vendor: 'Brew' | 'Supabase' | 'Clerk' + readonly method: 'PATCH' | 'POST' | 'PUT' + readonly url: string + /** The body, with every secret replaced by a placeholder. */ + readonly body: unknown +} + +export type InitProvider = { + readonly provider: HostedAuthProvider + /** + * `manual`: steps printed, nothing changed. `planned`: a dry run's plan. + * `configured`: changed now. `unchanged`: already pointing at Brew. + * `refused`: opted in, but a precondition (an active connection, a + * token) is missing. `restored`: a rollback put the old settings back. + */ + readonly action: + | 'manual' + | 'planned' + | 'configured' + | 'unchanged' + | 'refused' + | 'restored' + | 'failed' + readonly connection: { + readonly connectionId: string + readonly webhookUrl: string + readonly status: string + } | null + /** Supabase only. */ + readonly projectRef?: string | null + /** The settings before this run changed them (no secrets). */ + readonly previous?: unknown + /** Clerk only: the email templates this run turns (or turned) off / on. */ + readonly templates?: readonly string[] + /** Every mutating request made, or planned in a dry run. */ + readonly requests: readonly ProviderRequest[] + readonly rollbackCommand: string | null + readonly detail: string +} + +export type SupabaseRollback = { + readonly projectRef: string + readonly savedAt: string + readonly previous: SendEmailHookConfig + /** The `.env.local` variable holding the replaced hook's secret. */ + readonly previousSecretsVariable?: string +} + +export type ClerkRollback = { + readonly savedAt: string + /** `clerkKeyId` of the key whose instance init changed. */ + readonly keyId?: string + /** Templates init turned off; a rollback turns them back on. */ + readonly templates: readonly string[] +} + +export type ProviderConfig = { + readonly supabase_auth?: SupabaseRollback + readonly clerk?: ClerkRollback + readonly [other: string]: unknown +} + +export type ProviderStepInput = { + readonly ctx: CliContext + readonly root: string + readonly stack: string + readonly dryRun: boolean + readonly configureSupabase: boolean + readonly configureClerk: boolean + readonly supabaseProject: string | undefined + readonly client: BrewClient | undefined + readonly writer: ProjectWriter + readonly prefix: string + /** + * ` --brand --api-url ` (either part, or empty): this run's + * brand and server, where a follow-up command would resolve others. + */ + readonly scope: string + /** ` init`, with the run's `--app ` and `scope`. */ + readonly init: string + readonly providerConfig: ProviderConfig + /** + * Writes `brew.json` with this provider config now (before a change); + * false when it was not written. + */ + readonly persist: (config: ProviderConfig) => boolean + readonly now: () => number +} + +const NOT_SAVED = + 'left as it is: brew.json was not written, so --rollback-provider could not undo the change' + +export type ProviderStepResult = { + readonly step: InitStep | null + readonly nextSteps: readonly NextStep[] + readonly providers: readonly InitProvider[] + readonly providerConfig: ProviderConfig +} + +function describe(error: unknown): string { + if (error instanceof BrewApiError || error instanceof CliApiError) { + return `${error.code}: ${error.message}` + } + return error instanceof Error ? error.message : String(error) +} + +/** 401/403 from Brew are the credential's; a stop is never a finding. */ +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 connectionOf( + connection: { + readonly connectionId: string + readonly webhookUrl: string + readonly status: string + } | null +): InitProvider['connection'] { + return connection === null + ? null + : { + connectionId: connection.connectionId, + webhookUrl: connection.webhookUrl, + status: connection.status, + } +} + +export async function runProviderStep( + input: ProviderStepInput +): Promise { + if (input.stack === 'supabase') { + return await supabaseStep(input) + } + if (input.stack === 'clerk') { + return await clerkStep(input) + } + return { + step: null, + nextSteps: [], + providers: [], + providerConfig: input.providerConfig, + } +} + +async function readConnection( + input: ProviderStepInput, + provider: HostedAuthProvider +): Promise< + | { readonly ok: true; readonly connection: InitProvider['connection'] } + | { readonly ok: false; readonly error: string } +> { + if (input.client === undefined) { + return { ok: true, connection: null } + } + try { + return { + ok: true, + connection: connectionOf(await findConnection(input.client, provider)), + } + } catch (error) { + rethrowIfFatal(input.ctx, error) + return { ok: false, error: describe(error) } + } +} + +// ---------------------------------------------------------------- Supabase + +async function supabaseStep( + input: ProviderStepInput +): Promise { + const { ctx, prefix } = input + const nextSteps: NextStep[] = [] + const detected = detectSupabaseRef({ + root: input.root, + env: ctx.io.env, + flag: input.supabaseProject, + }) + const ref = detected?.ref ?? null + // A printed re-run keeps the project --supabase-project picked: without + // it, the re-run detects the project again, maybe another one. + const picked = + input.supabaseProject === undefined + ? '' + : ` --supabase-project ${input.supabaseProject}` + const read = await readConnection(input, 'supabase_auth') + if (!read.ok) { + return failed(input, 'supabase_auth', read.error, ref) + } + const connection = read.connection + const manualSteps = (): void => { + nextSteps.push( + { + id: 'connect-supabase-hook', + actor: 'human', + kind: 'open-url', + url: supabaseHooksUrl(ref), + title: `Hand Supabase's auth emails to Brew: in Supabase, add a Send Email hook (Authentication > Hooks, HTTPS) and generate its secret; put the secret in SEND_EMAIL_HOOK_SECRET and run \`${prefix} integrations connect supabase_auth${input.scope} --signing-secret-env SEND_EMAIL_HOOK_SECRET${ref === null ? '' : ` --project-url ${supabaseProjectUrl(ref)}`} --yes\`; paste the webhook URL it prints as the hook URL, enable the hook and save`, + blocking: true, + }, + { + id: 'configure-supabase-hook', + actor: 'agent', + kind: 'run-command', + command: `${input.init} --configure-supabase${ref === null ? ' --supabase-project ' : picked} --yes`, + title: `Or let init do it: with ${SUPABASE_TOKEN_VARIABLE} (a Supabase personal access token, supabase.com/dashboard/account/tokens) in the environment, init connects Brew and points the Send Email hook at it through the Management API, saving the old hook settings for --rollback-provider`, + blocking: false, + } + ) + } + if (!input.configureSupabase) { + if (connection?.status === 'active') { + return { + step: { + id: 'provider', + status: 'done', + detail: 'Supabase Send Email hook delivers to Brew (active)', + }, + nextSteps, + providers: [ + { + provider: 'supabase_auth', + action: 'unchanged', + connection, + projectRef: ref, + requests: [], + rollbackCommand: rollbackCommandFor(input), + detail: 'connected and active', + }, + ], + providerConfig: input.providerConfig, + } + } + manualSteps() + return { + step: { + id: 'provider', + status: 'skipped', + detail: + connection === null + ? 'Supabase Send Email hook not connected (manual steps below, or --configure-supabase)' + : `connected, ${connection.status}: set the Send Email hook URL in Supabase to ${connection.webhookUrl}`, + }, + nextSteps, + providers: [ + { + provider: 'supabase_auth', + action: 'manual', + connection, + projectRef: ref, + requests: [], + rollbackCommand: null, + detail: 'manual steps printed', + }, + ], + providerConfig: input.providerConfig, + } + } + + // --configure-supabase + if (ref === null) { + nextSteps.push({ + id: 'supabase-project', + actor: 'agent', + kind: 'run-command', + command: `${input.init} --configure-supabase --supabase-project --yes`, + title: + 'Name the Supabase project: its ref is the in https://.supabase.co (or run `supabase link`)', + blocking: true, + }) + return refused( + input, + 'supabase_auth', + connection, + null, + 'no Supabase project ref found', + nextSteps + ) + } + const token = localValue(ctx, input.writer, SUPABASE_TOKEN_VARIABLE) + if (token === undefined) { + nextSteps.push({ + id: 'supabase-access-token', + actor: 'human', + kind: 'set-secret', + url: 'https://supabase.com/dashboard/account/tokens', + title: `Create a Supabase personal access token and export it as ${SUPABASE_TOKEN_VARIABLE} in this shell (it stays on this machine; Brew never sees it), then run init --configure-supabase again`, + blocking: true, + }) + return refused( + input, + 'supabase_auth', + connection, + ref, + `${SUPABASE_TOKEN_VARIABLE} is not set`, + nextSteps + ) + } + if (input.client === undefined && !input.dryRun) { + return refused( + input, + 'supabase_auth', + connection, + ref, + 'no Brew credential', + nextSteps + ) + } + let hook: Awaited> + try { + hook = await readSendEmailHook(ctx, { ref, token }) + } catch (error) { + ctx.signal.throwIfAborted() + return failed(input, 'supabase_auth', describe(error), ref, connection) + } + if ( + connection !== null && + hook.config.hook_send_email_enabled && + hook.config.hook_send_email_uri === connection.webhookUrl + ) { + return { + step: { + id: 'provider', + status: 'done', + detail: `Supabase Send Email hook already delivers to Brew (${connection.status})`, + }, + nextSteps, + providers: [ + { + provider: 'supabase_auth', + action: 'unchanged', + connection, + projectRef: ref, + previous: input.providerConfig.supabase_auth?.previous ?? null, + requests: [], + rollbackCommand: rollbackCommandFor(input), + detail: 'already configured', + }, + ], + providerConfig: input.providerConfig, + } + } + if (connection?.status === 'active') { + // A hook that is not this project's verifies with the connection's + // secret: a new one would stop that hook's emails. + nextSteps.push({ + id: 'supabase-connection-in-use', + actor: 'human', + kind: 'open-url', + url: supabaseHooksUrl(ref), + title: `Brew's Supabase connection is active for a Send Email hook that is not project ${ref}'s. To move it here on purpose, run \`${prefix} integrations connect supabase_auth${input.scope}\` with a new secret and point every project's hook at ${connection.webhookUrl} with it`, + blocking: true, + }) + return refused( + input, + 'supabase_auth', + connection, + ref, + "hook left as it is: Brew's connection is active for another hook, which a new secret would stop", + nextSteps + ) + } + const projectUrl = supabaseProjectUrl(ref) + const webhookPlaceholder = + connection?.webhookUrl ?? + '' + const plannedRequests = (webhookUrl: string): ProviderRequest[] => [ + { + vendor: 'Brew', + method: 'PUT', + url: '/v1/integrations/supabase_auth/connection', + body: { signingSecret: REDACTED_SECRET, settings: { projectUrl } }, + }, + { + vendor: 'Supabase', + method: 'PATCH', + url: supabaseAuthConfigUrl(ref), + body: { + hook_send_email_enabled: true, + hook_send_email_uri: webhookUrl, + hook_send_email_secrets: REDACTED_SECRET, + } satisfies SendEmailHookBody, + }, + ] + if (input.dryRun) { + return { + step: { + id: 'provider', + status: 'planned', + detail: `connect Brew, then point project ${ref}'s Send Email hook at it (old settings saved for --rollback-provider)`, + }, + nextSteps, + providers: [ + { + provider: 'supabase_auth', + action: 'planned', + connection, + projectRef: ref, + previous: hook.config, + requests: plannedRequests(webhookPlaceholder), + rollbackCommand: rollbackCommandFor(input), + detail: 'dry run', + }, + ], + providerConfig: input.providerConfig, + } + } + const client = input.client as BrewClient + + // Save the settings being replaced BEFORE replacing them. The first save + // wins: a re-run never overwrites the pre-Brew settings with Brew's own. + let providerConfig = input.providerConfig + const saved = providerConfig.supabase_auth + if (saved === undefined || saved.projectRef !== ref) { + let previousSecretsVariable: string | undefined + // The backup an earlier save left, which goes back if this save fails. + const secretBefore = savedHookSecret(input.writer) + if (hook.config.hook_send_email_enabled && hook.secrets !== null) { + // A rollback puts the old hook back whole, secret included. + if (!backupHookSecret(input.writer, hook.secrets)) { + return failed( + input, + 'supabase_auth', + `hook left as it is: its secret could not be saved to .env.local (${SUPABASE_PREVIOUS_SECRETS_VARIABLE}) for --rollback-provider`, + ref, + connection + ) + } + previousSecretsVariable = SUPABASE_PREVIOUS_SECRETS_VARIABLE + } + providerConfig = { + ...providerConfig, + supabase_auth: { + projectRef: ref, + savedAt: new Date(input.now()).toISOString(), + previous: hook.config, + ...(previousSecretsVariable === undefined + ? {} + : { previousSecretsVariable }), + }, + } + if (!input.persist(providerConfig)) { + // brew.json keeps the earlier settings: they keep their secret too. + if (previousSecretsVariable !== undefined) { + backupHookSecret(input.writer, secretBefore) + } + return failed(input, 'supabase_auth', NOT_SAVED, ref, connection) + } + } + // From here every result keeps the saved settings with their secret. + const withSaved = { ...input, providerConfig } + + const signingSecret = generateHookSecret() + let connected: Awaited> + try { + progress(ctx, 'Connecting Brew as the Supabase Send Email hook…') + connected = await client.integrations.connect({ + provider: 'supabase_auth', + signingSecret, + settings: { projectUrl }, + }) + } catch (error) { + rethrowIfFatal(ctx, error) + return failed( + input, + 'supabase_auth', + describe(error), + ref, + connection, + providerConfig + ) + } + // The hook hands Brew every auth email: each must be set up and reach + // every user (not the team-only sandbox) before Supabase stops sending. + let reachable: Set + try { + reachable = await publicKeys( + client, + connected.authEmails.map((email) => email.triggerKey) + ) + } catch (error) { + rethrowIfFatal(ctx, error) + return failed(withSaved, 'supabase_auth', describe(error), ref, connection) + } + const notReady = connected.authEmails + .filter((email) => !email.ok || !reachable.has(email.triggerKey)) + .map((email) => email.triggerKey) + if (notReady.length > 0) { + nextSteps.push({ + id: 'rerun-supabase-connect', + actor: 'agent', + kind: 'run-command', + command: `${input.init} --configure-supabase${picked} --yes`, + title: `Run again once these auth emails reach every user (set up, and a verified transactional domain, not the sandbox): ${notReady.join(', ')}. Supabase keeps sending its own until then`, + blocking: true, + }) + return refused( + withSaved, + 'supabase_auth', + connectionOf(connected), + ref, + `hook left as it is: ${notReady.join(', ')} cannot reach every user yet`, + nextSteps + ) + } + const requests = plannedRequests(connected.webhookUrl) + try { + progress(ctx, `Pointing Supabase project ${ref}'s Send Email hook at Brew…`) + await patchSendEmailHook(ctx, { + ref, + token, + body: { + hook_send_email_enabled: true, + hook_send_email_uri: connected.webhookUrl, + hook_send_email_secrets: signingSecret, + }, + }) + } catch (error) { + ctx.signal.throwIfAborted() + return failed( + input, + 'supabase_auth', + `Brew is connected, but Supabase refused the hook: ${describe(error)}`, + ref, + connectionOf(connected), + providerConfig + ) + } + nextSteps.push( + { + id: 'verify-supabase-hook', + actor: 'human', + kind: 'review', + title: `Sign up (or request a magic link) with a test address: Supabase now hands the email to Brew, and \`${prefix} integrations connection supabase_auth${input.scope}\` turns active`, + blocking: false, + }, + { + id: 'rollback-supabase-hook', + actor: 'agent', + kind: 'run-command', + command: rollbackCommandFor(input), + title: + "To undo: put Supabase's previous Send Email hook settings back (with SUPABASE_ACCESS_TOKEN set)", + blocking: false, + } + ) + return { + step: { + id: 'provider', + status: 'done', + detail: `Supabase Send Email hook → Brew (${connected.status}); old settings saved for --rollback-provider`, + }, + nextSteps, + providers: [ + { + provider: 'supabase_auth', + action: 'configured', + connection: connectionOf(connected), + projectRef: ref, + previous: providerConfig.supabase_auth?.previous ?? hook.config, + requests, + rollbackCommand: rollbackCommandFor(input), + detail: 'connected and pointed at Brew', + }, + ], + providerConfig, + } +} + +// ------------------------------------------------------------------- Clerk + +async function clerkStep( + input: ProviderStepInput +): Promise { + const { ctx, prefix } = input + const nextSteps: NextStep[] = [] + const read = await readConnection(input, 'clerk') + if (!read.ok) { + return failed(input, 'clerk', read.error) + } + const connection = read.connection + const status = connection?.status ?? 'not connected' + const connectSteps = (): void => { + if (connection === null) { + nextSteps.push({ + id: 'connect-clerk', + actor: 'human', + kind: 'open-url', + url: 'https://dashboard.clerk.com/last-active?path=webhooks', + title: `Hand Clerk's auth emails to Brew (Clerk keeps sending them until then): in the Clerk Dashboard, add a webhook endpoint subscribed to email.created (any https URL for now) and copy its signing secret into CLERK_WEBHOOK_SIGNING_SECRET; run \`${prefix} integrations connect clerk${input.scope} --signing-secret-env CLERK_WEBHOOK_SIGNING_SECRET --yes\`, then change the endpoint URL to the webhook URL it prints`, + blocking: false, + }) + } else if (connection.status !== 'active') { + nextSteps.push({ + id: 'verify-clerk-webhook', + actor: 'human', + kind: 'open-url', + url: 'https://dashboard.clerk.com/last-active?path=webhooks', + title: `Make sure the Clerk endpoint URL is ${connection.webhookUrl}, then send it a test email.created (endpoint > Testing) or sign up a test user: \`${prefix} integrations connection clerk${input.scope}\` turns active`, + blocking: false, + }) + } + } + if (!input.configureClerk) { + connectSteps() + if (connection?.status === 'active') { + nextSteps.push({ + id: 'configure-clerk-delivery', + actor: 'agent', + kind: 'run-command', + command: `${input.init} --configure-clerk --yes`, + title: `Brew receives Clerk's emails now. To have Brew send them, turn off "Delivered by Clerk" for the templates Brew sends: run this with ${CLERK_SECRET_VARIABLE} available (saved for --rollback-provider), or switch them off in the Clerk Dashboard`, + blocking: false, + }) + } + return { + step: { + id: 'provider', + status: 'skipped', + detail: `Clerk keeps delivering its auth emails (connection: ${status}; opt in with --configure-clerk)`, + }, + nextSteps, + providers: [ + { + provider: 'clerk', + action: 'manual', + connection, + requests: [], + rollbackCommand: null, + detail: 'manual steps printed', + }, + ], + providerConfig: input.providerConfig, + } + } + + // --configure-clerk: only once Brew has verified Clerk's deliveries. + if (input.client === undefined) { + return { + step: { + id: 'provider', + status: 'planned', + detail: + "after sign-in, and only once Brew's Clerk connection is active", + }, + nextSteps, + providers: [ + { + provider: 'clerk', + action: 'planned', + connection: null, + requests: [], + rollbackCommand: rollbackCommandFor(input), + detail: 'needs a Brew credential', + }, + ], + providerConfig: input.providerConfig, + } + } + if (connection?.status !== 'active') { + connectSteps() + return refused( + input, + 'clerk', + connection, + undefined, + `Clerk delivery left on: Brew's Clerk connection is ${status}, not active, so Brew could not send these emails yet`, + nextSteps + ) + } + const secretKey = readClerkSecret({ root: input.root, env: ctx.io.env }) + if (secretKey === undefined) { + nextSteps.push({ + id: 'clerk-secret-key', + actor: 'human', + kind: 'set-secret', + title: `Make ${CLERK_SECRET_VARIABLE} (Clerk Dashboard > API keys) available in this shell or .env.local, then run init --configure-clerk again; it stays on this machine`, + blocking: true, + }) + return refused( + input, + 'clerk', + connection, + undefined, + `${CLERK_SECRET_VARIABLE} not found`, + nextSteps + ) + } + // A later run adds templates to the saved list: only from the same key. + const keyId = clerkKeyId(secretKey) + if ((input.providerConfig.clerk?.keyId ?? keyId) !== keyId) { + return refused( + input, + 'clerk', + connection, + undefined, + `${CLERK_SECRET_VARIABLE} is not the key of the Clerk instance brew.json saved; roll that instance back first`, + nextSteps + ) + } + // Exactly the templates Brew sends to every user: a slug whose key's app + // email is published and not in the team-only sandbox. + let reachable: Set + try { + reachable = await publicKeys( + input.client, + Object.values(CLERK_EMAIL_SLUG_KEYS) + ) + } catch (error) { + rethrowIfFatal(ctx, error) + return failed(input, 'clerk', describe(error), undefined, connection) + } + const slugs = Object.entries(CLERK_EMAIL_SLUG_KEYS) + .filter(([, key]) => reachable.has(key)) + .map(([slug]) => slug) + const toTurnOff: string[] = [] + try { + for (const slug of slugs) { + const delivered = await readClerkDelivery(ctx, { secretKey, slug }) + if (delivered === true) { + toTurnOff.push(slug) + } + } + } catch (error) { + ctx.signal.throwIfAborted() + return failed(input, 'clerk', describe(error), undefined, connection) + } + const requests: ProviderRequest[] = toTurnOff.map((slug) => ({ + vendor: 'Clerk', + method: 'POST', + url: clerkToggleUrl(slug), + body: { delivered_by_clerk: false }, + })) + if (toTurnOff.length === 0) { + return { + step: { + id: 'provider', + status: 'done', + detail: + slugs.length === 0 + ? 'no Brew app email for a Clerk template reaches every user yet (published, with a verified domain); Clerk delivery left on' + : 'Clerk delivery is already off for the templates Brew sends', + }, + nextSteps, + providers: [ + { + provider: 'clerk', + action: 'unchanged', + connection, + templates: [], + requests: [], + rollbackCommand: + input.providerConfig.clerk === undefined + ? null + : rollbackCommandFor(input), + detail: 'nothing to turn off', + }, + ], + providerConfig: input.providerConfig, + } + } + if (input.dryRun) { + return { + step: { + id: 'provider', + status: 'planned', + detail: `turn off Clerk delivery for ${toTurnOff.length} templates Brew sends (saved for --rollback-provider)`, + }, + nextSteps, + providers: [ + { + provider: 'clerk', + action: 'planned', + connection, + templates: toTurnOff, + previous: Object.fromEntries(toTurnOff.map((slug) => [slug, true])), + requests, + rollbackCommand: rollbackCommandFor(input), + detail: 'dry run', + }, + ], + providerConfig: input.providerConfig, + } + } + const providerConfig: ProviderConfig = { + ...input.providerConfig, + clerk: { + savedAt: + input.providerConfig.clerk?.savedAt ?? + new Date(input.now()).toISOString(), + keyId, + templates: [ + ...new Set([ + ...(input.providerConfig.clerk?.templates ?? []), + ...toTurnOff, + ]), + ].sort(), + }, + } + if (!input.persist(providerConfig)) { + return failed(input, 'clerk', NOT_SAVED, undefined, connection) + } + const done: string[] = [] + try { + for (const slug of toTurnOff) { + progress(ctx, `Turning off Clerk delivery for ${slug}…`) + await setClerkDelivery(ctx, { + secretKey, + slug, + deliveredByClerk: false, + }) + done.push(slug) + } + } catch (error) { + ctx.signal.throwIfAborted() + nextSteps.push({ + id: 'clerk-delivery-dashboard', + actor: 'human', + kind: 'open-url', + url: CLERK_EMAILS_DASHBOARD_URL, + title: `Turn off "Delivered by Clerk" by hand for: ${toTurnOff.filter((slug) => !done.includes(slug)).join(', ')}`, + blocking: false, + }) + return { + step: { + id: 'provider', + status: 'failed', + detail: `turned off ${done.length} of ${toTurnOff.length} Clerk templates: ${describe(error)}`, + }, + nextSteps, + providers: [ + { + provider: 'clerk', + action: 'failed', + connection, + templates: done, + requests: requests.filter((request) => + done.some((slug) => request.url === clerkToggleUrl(slug)) + ), + rollbackCommand: rollbackCommandFor(input), + detail: describe(error), + }, + ], + providerConfig, + } + } + nextSteps.push({ + id: 'rollback-clerk-delivery', + actor: 'agent', + kind: 'run-command', + command: rollbackCommandFor(input), + title: `To undo: turn Clerk's delivery back on for these templates (with ${CLERK_SECRET_VARIABLE} available)`, + blocking: false, + }) + return { + step: { + id: 'provider', + status: 'done', + detail: `Clerk delivery off for ${done.length} templates Brew sends (${done.join(', ')}); saved for --rollback-provider`, + }, + nextSteps, + providers: [ + { + provider: 'clerk', + action: 'configured', + connection, + templates: done, + previous: Object.fromEntries(done.map((slug) => [slug, true])), + requests, + rollbackCommand: rollbackCommandFor(input), + detail: 'delivery handed to Brew', + }, + ], + providerConfig, + } +} + +/** + * Which of `wanted` have an app email a fire sends now to anyone, not the + * sandbox only. Reads pages until every wanted key has its row or the list + * ends (a cursor seen before ends it too). + */ +async function publicKeys( + client: BrewClient, + wanted: readonly string[] +): Promise> { + const pending = new Set(wanted) + const keys = new Set() + const seen = new Set() + let cursor: string | undefined + while (pending.size > 0) { + const result = await client.appEmails.list({ + limit: 100, + ...(cursor === undefined ? {} : { cursor }), + }) + for (const row of result.data) { + if (!pending.delete(row.triggerKey)) { + continue + } + if (row.readiness.ready && !row.readiness.teamOnly) { + keys.add(row.triggerKey) + } + } + const next = result.pagination.cursor + if (!result.pagination.hasMore || next === null || seen.has(next)) { + break + } + seen.add(next) + cursor = next + } + return keys +} + +// ---------------------------------------------------------------- shared + +export function rollbackCommandFor( + input: Pick +): string { + return `${input.init} --rollback-provider --yes` +} + +function refused( + input: ProviderStepInput, + provider: HostedAuthProvider, + connection: InitProvider['connection'], + ref: string | null | undefined, + detail: string, + nextSteps: readonly NextStep[] +): ProviderStepResult { + return { + step: { id: 'provider', status: 'skipped', detail }, + nextSteps, + providers: [ + { + provider, + action: 'refused', + connection, + ...(ref === undefined ? {} : { projectRef: ref }), + requests: [], + rollbackCommand: null, + detail, + }, + ], + providerConfig: input.providerConfig, + } +} + +function failed( + input: ProviderStepInput, + provider: HostedAuthProvider, + detail: string, + ref?: string | null, + connection: InitProvider['connection'] = null, + providerConfig: ProviderConfig = input.providerConfig +): ProviderStepResult { + return { + step: { id: 'provider', status: 'failed', detail }, + nextSteps: [], + providers: [ + { + provider, + action: 'failed', + connection, + ...(ref === undefined ? {} : { projectRef: ref }), + requests: [], + rollbackCommand: + providerConfig[provider] === undefined + ? null + : rollbackCommandFor(input), + detail, + }, + ], + providerConfig, + } +} diff --git a/src/lib/init/providers/supabase.ts b/src/lib/init/providers/supabase.ts new file mode 100644 index 00000000..dbd9144e --- /dev/null +++ b/src/lib/init/providers/supabase.ts @@ -0,0 +1,268 @@ +import { randomBytes } from 'node:crypto' +import { join } from 'node:path' +import { + ensureEnvVars, + getEnvValue, + parseEnv, + serializeEnv, +} from '../../project/env-file' +import { type ProjectWriter, readTextFile } from '../../project/fs-writer' +import { vendorFetch } from '../../project/vendor-http' +import type { CliContext } from '../../types' + +/** + * Supabase Auth's Send Email hook, through the Supabase Management API with + * the person's own access token (`SUPABASE_ACCESS_TOKEN`, read locally and + * sent to api.supabase.com only, never to Brew): + * + * - `GET /v1/projects/{ref}/config/auth` ("Get a project's auth config", + * https://supabase.com/docs/reference/api/v1-get-auth-service-config) + * - `PATCH /v1/projects/{ref}/config/auth` ("Update a project's auth + * config", https://supabase.com/docs/reference/api/v1-update-auth-service-config; + * scope `auth:write`, fine-grained permission `auth_config_write`) + * + * The hook is three fields of that config, in both the request and the + * response (`UpdateAuthConfigBody` / `AuthConfigResponse` in the API spec, + * https://api.supabase.com/api/v1-json): `hook_send_email_enabled` + * (boolean), `hook_send_email_uri` (string) and `hook_send_email_secrets` + * (string). The secret is a Standard Webhooks secret, `v1,whsec_` + * (https://supabase.com/docs/guides/auth/auth-hooks/send-email-hook). + */ + +export const SUPABASE_API_URL = 'https://api.supabase.com' + +export const SUPABASE_TOKEN_VARIABLE = 'SUPABASE_ACCESS_TOKEN' + +/** A Supabase project ref: 20 lowercase letters (`.supabase.co`). */ +export const SUPABASE_REF_PATTERN = /^[a-z]{20}$/ + +/** The `.env.local` line that keeps a replaced hook's secret for rollback. */ +export const SUPABASE_PREVIOUS_SECRETS_VARIABLE = + 'BREW_SUPABASE_PREVIOUS_HOOK_SECRETS' + +/** What a dry run shows where the generated secret goes. */ +export const REDACTED_SECRET = '[generated v1,whsec_ secret, never shown]' + +export type SendEmailHookConfig = { + readonly hook_send_email_enabled: boolean + readonly hook_send_email_uri: string | null +} + +export type SendEmailHookBody = { + readonly hook_send_email_enabled: boolean + readonly hook_send_email_uri: string | null + readonly hook_send_email_secrets?: string | null +} + +export function supabaseAuthConfigUrl(ref: string): string { + return `${SUPABASE_API_URL}/v1/projects/${ref}/config/auth` +} + +export function supabaseProjectUrl(ref: string): string { + return `https://${ref}.supabase.co` +} + +/** The dashboard page where the hook lives, for manual steps. */ +export function supabaseHooksUrl(ref: string | null): string { + return `https://supabase.com/dashboard/project/${ref ?? '_'}/auth/hooks` +} + +/** A local value (a token, a saved secret): the shell, then `.env.local`. */ +export function localValue( + ctx: Pick, + writer: ProjectWriter, + name: string +): string | undefined { + const fromEnv = ctx.io.env[name]?.trim() + if (fromEnv !== undefined && fromEnv !== '') { + return fromEnv + } + const text = writer.read('.env.local') + const value = + text === null ? undefined : getEnvValue(parseEnv(text), name)?.trim() + return value === undefined || value === '' ? undefined : value +} + +/** The hook secret a rollback would put back, as `.env.local` holds it. */ +export function savedHookSecret(writer: ProjectWriter): string | undefined { + const text = writer.read('.env.local') + return text === null + ? undefined + : getEnvValue(parseEnv(text), SUPABASE_PREVIOUS_SECRETS_VARIABLE) +} + +/** + * Keeps a replaced hook's secret in `.env.local` (git-ignored, never + * brew.json) for `--rollback-provider`, replacing a backup an earlier run + * left (another hook's). `undefined` removes the backup. False when it + * could not be saved as it is. + */ +export function backupHookSecret( + writer: ProjectWriter, + secrets: string | undefined +): boolean { + const env = parseEnv(writer.read('.env.local') ?? '') + const lines = env.lines.filter( + (line) => + line.kind !== 'var' || line.key !== SUPABASE_PREVIOUS_SECRETS_VARIABLE + ) + const rest = serializeEnv({ ...env, lines }) + const { text } = + secrets === undefined + ? { text: rest } + : ensureEnvVars(rest, [ + { name: SUPABASE_PREVIOUS_SECRETS_VARIABLE, value: secrets }, + ]) + if ( + getEnvValue(parseEnv(text), SUPABASE_PREVIOUS_SECRETS_VARIABLE) !== secrets + ) { + return false + } + const change = writer.writeFile('.env.local', text, { + overwrite: true, + mode: 0o600, + sensitive: true, + }) + return change.action !== 'refused' +} + +/** A fresh Standard Webhooks secret, generated here and never printed. */ +export function generateHookSecret(): string { + return `v1,whsec_${randomBytes(32).toString('base64')}` +} + +/** + * The project's current Send Email hook. `secrets` is the hook's current + * secret, kept in memory only (a rollback puts it back from `.env.local`). + */ +export async function readSendEmailHook( + ctx: Pick, + input: { readonly ref: string; readonly token: string } +): Promise<{ + readonly config: SendEmailHookConfig + readonly secrets: string | null +}> { + const response = await vendorFetch(ctx, { + vendor: 'Supabase', + method: 'GET', + url: supabaseAuthConfigUrl(input.ref), + headers: { authorization: `Bearer ${input.token}` }, + }) + const record = + typeof response.json === 'object' && response.json !== null + ? (response.json as Record) + : {} + const uri = record.hook_send_email_uri + const secrets = record.hook_send_email_secrets + return { + config: { + hook_send_email_enabled: record.hook_send_email_enabled === true, + hook_send_email_uri: typeof uri === 'string' && uri !== '' ? uri : null, + }, + secrets: typeof secrets === 'string' && secrets !== '' ? secrets : null, + } +} + +export async function patchSendEmailHook( + ctx: Pick, + input: { + readonly ref: string + readonly token: string + readonly body: SendEmailHookBody + } +): Promise { + await vendorFetch(ctx, { + vendor: 'Supabase', + method: 'PATCH', + url: supabaseAuthConfigUrl(input.ref), + headers: { authorization: `Bearer ${input.token}` }, + json: input.body, + secrets: + typeof input.body.hook_send_email_secrets === 'string' + ? [ + input.body.hook_send_email_secrets, + input.body.hook_send_email_secrets.replace(/^v1,whsec_/, ''), + ] + : [], + // A PATCH is not retried on 429 past one short wait: it is idempotent, + // but a person waiting on init should see the limit, not a stall. + maxAttempts: 2, + }) +} + +const URL_VARIABLES = [ + 'SUPABASE_URL', + 'NEXT_PUBLIC_SUPABASE_URL', + 'VITE_SUPABASE_URL', + 'PUBLIC_SUPABASE_URL', + 'EXPO_PUBLIC_SUPABASE_URL', +] as const + +const REF_IN_URL = /^https:\/\/([a-z]{20})\.supabase\.co(?:\/|$)/ + +/** The ref in a `https://.supabase.co` URL. */ +function refFromUrl(value: string | undefined): string | undefined { + return value === undefined ? undefined : REF_IN_URL.exec(value.trim())?.[1] +} + +/** + * Which Supabase project this app uses, from (in order) `--supabase-project`, + * `supabase link`'s `supabase/.temp/project-ref`, a `*SUPABASE_URL` in the + * environment or the project's env files, Lovable's generated client + * (`src/integrations/supabase/client.ts`), or a `project_id` in + * `supabase/config.toml` shaped like a real ref (Lovable writes the ref + * there; a local-only project id is a free-form name). + */ +export function detectSupabaseRef(input: { + readonly root: string + readonly env: Readonly> + readonly flag: string | undefined +}): { readonly ref: string; readonly source: string } | null { + if (input.flag !== undefined) { + return { ref: input.flag, source: '--supabase-project' } + } + const linked = readTextFile( + join(input.root, 'supabase/.temp/project-ref') + )?.trim() + if (linked !== undefined && SUPABASE_REF_PATTERN.test(linked)) { + return { ref: linked, source: 'supabase/.temp/project-ref' } + } + for (const name of URL_VARIABLES) { + const ref = refFromUrl(input.env[name]) + if (ref !== undefined) { + return { ref, source: name } + } + } + for (const file of ['.env.local', '.env']) { + const text = readTextFile(join(input.root, file)) + if (text === null) { + continue + } + const parsed = parseEnv(text) + for (const name of URL_VARIABLES) { + const ref = refFromUrl(getEnvValue(parsed, name)) + if (ref !== undefined) { + return { ref, source: `${file} ${name}` } + } + } + } + const lovable = readTextFile( + join(input.root, 'src/integrations/supabase/client.ts') + ) + const fromClient = + lovable === null + ? undefined + : /https:\/\/([a-z]{20})\.supabase\.co/.exec(lovable)?.[1] + if (fromClient !== undefined) { + return { ref: fromClient, source: 'src/integrations/supabase/client.ts' } + } + const config = readTextFile(join(input.root, 'supabase/config.toml')) + const projectId = + config === null + ? undefined + : /^\s*project_id\s*=\s*"([^"]+)"/m.exec(config)?.[1] + if (projectId !== undefined && SUPABASE_REF_PATTERN.test(projectId)) { + return { ref: projectId, source: 'supabase/config.toml project_id' } + } + return null +} diff --git a/src/lib/init/report.ts b/src/lib/init/report.ts index 02d5a594..fa1b7f6f 100644 --- a/src/lib/init/report.ts +++ b/src/lib/init/report.ts @@ -3,6 +3,7 @@ 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' +import type { InitProvider } from './providers/step' /** * `init`'s result: one JSON document (schema `schemas/init-report.json`, @@ -39,6 +40,7 @@ export type InitStepId = | 'install' | 'files' | 'patches' + | 'provider' | 'mcp' | 'agent-files' | 'state' @@ -131,6 +133,12 @@ export type InitReport = { readonly brand: { readonly brandId: string readonly name: string | null + /** The brand's website, when known. */ + readonly domain?: string | null + /** `--app-url` only: whether the brand's website matches it. */ + readonly matchesAppUrl?: boolean | null + /** `--app-url` only: true when this run created the brand. */ + readonly created?: boolean } | null readonly credential: { /** Where this run's API key came from. */ @@ -152,6 +160,12 @@ export type InitReport = { readonly added: boolean readonly records: readonly DnsRecord[] readonly settingsUrl: string + /** + * The domain's page in Brew, where "Connect automatically" (Entri, in + * the app only: v1 has no API for it) adds the records at the DNS + * provider. `null` until a domain is chosen or added. + */ + readonly connectUrl?: string | null readonly sandbox: { readonly note: string } | null } readonly stack: { @@ -163,6 +177,8 @@ export type InitReport = { readonly files: readonly InitFile[] readonly patches: readonly InitPatch[] readonly mcp: readonly McpInstallResult[] + /** Where the auth provider's own emails go (Supabase, Clerk). */ + readonly providers?: readonly InitProvider[] readonly testEmail: { readonly status: 'sent' | 'replayed' | 'skipped' | 'planned' | 'failed' readonly key: string | null @@ -219,6 +235,7 @@ const STEP_LABELS: Readonly> = { install: 'Install @brew.new/sdk', files: 'Write files', patches: 'Auth config patches', + provider: 'Auth email provider', mcp: 'MCP clients', 'agent-files': 'AGENTS.md and skills', state: 'brew.json', @@ -301,6 +318,32 @@ export function renderReport(report: InitReport): string { for (const record of report.domain.records) { lines.push(` ${record.type.padEnd(6)} ${record.name} ${record.value}`) } + if ( + report.domain.connectUrl !== undefined && + report.domain.connectUrl !== null + ) { + lines.push( + ` Or add them automatically from Brew (Connect automatically): ${report.domain.connectUrl}` + ) + } + } + for (const provider of report.providers ?? []) { + if (provider.requests.length === 0) { + continue + } + lines.push( + '', + `${provider.provider} ${provider.action === 'planned' ? 'would send' : 'sent'} (secrets not shown):` + ) + for (const request of provider.requests) { + lines.push( + ` ${request.vendor} ${request.method} ${request.url}`, + ` ${JSON.stringify(request.body)}` + ) + } + if (provider.rollbackCommand !== null) { + lines.push(` Undo: ${provider.rollbackCommand}`) + } } if (report.nextSteps.length > 0) { lines.push('', 'Next:') diff --git a/src/lib/init/templates.ts b/src/lib/init/templates.ts index a9983bd3..56f8f70c 100644 --- a/src/lib/init/templates.ts +++ b/src/lib/init/templates.ts @@ -302,9 +302,9 @@ const AUTH_NOTES: Readonly< Record, string> > = { supabase: - 'With the auth set, Supabase Auth emails go through the Send Email Hook in supabase/functions/send-email.', + "With the auth set, Supabase Auth emails go through Brew's hosted Send Email Hook once connected (`integrations connect supabase_auth`, or `init --configure-supabase`); supabase/functions/send-email is the self-hosted alternative.", 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.', + "Clerk sends its own auth emails until its email.created webhook is connected to Brew (`integrations connect clerk`) and `init --configure-clerk` turns off Clerk's delivery for the templates Brew sends; lifecycle emails go out from your code.", firebase: 'Firebase Auth keeps sending its own auth emails; Brew sends your lifecycle emails from your code.', workos: diff --git a/src/lib/integrations/hosted-auth.ts b/src/lib/integrations/hosted-auth.ts new file mode 100644 index 00000000..be66a0f6 --- /dev/null +++ b/src/lib/integrations/hosted-auth.ts @@ -0,0 +1,149 @@ +import { + BrewApiError, + type IntegrationConnection, + type IntegrationConnectionProvider, + type RequestOptions, +} from '@brew.new/sdk' +import type { BrewClient } from '../client' +import { CliApiError, CliUsageError } from '../errors' +import type { CliContext } from '../types' + +/** + * The hosted auth email hooks (`GET|PUT /v1/integrations/{provider}/ + * connection`, GetBrew/brew-v2#2169): the providers that hand an app's auth + * emails to Brew once connected, through the SDK's `integrations.connect` + * and `integrations.getConnection` (typescript-sdk 13.7.0). + * + * A signing secret is read from an environment variable or stdin, never + * from argv (shell history, process lists, agent transcripts), and never + * printed: error text names the variable, not its value. + */ + +export const HOSTED_AUTH_PROVIDERS = [ + 'supabase_auth', + 'clerk', +] as const satisfies readonly IntegrationConnectionProvider[] + +export type HostedAuthProvider = (typeof HOSTED_AUTH_PROVIDERS)[number] + +export function parseHostedAuthProvider(value: string): HostedAuthProvider { + if ((HOSTED_AUTH_PROVIDERS as readonly string[]).includes(value)) { + return value as HostedAuthProvider + } + throw new CliUsageError( + ` takes ${HOSTED_AUTH_PROVIDERS.join(' or ')}; got "${value}". Other integrations connect in Brew (Settings > Integrations).` + ) +} + +/** + * The shape each provider shows its secret in: Supabase's Send Email hook + * `v1,whsec_`, a Clerk (Svix) endpoint `whsec_`. Checked + * here so a pasted URL or API key is a usage error that never leaves the + * machine; the API checks again. + */ +const SECRET_SHAPES: Readonly> = { + supabase_auth: /^v1,whsec_[A-Za-z0-9+/=]{16,}$/, + clerk: /^whsec_[A-Za-z0-9+/=]{16,}$/, +} + +const SECRET_HINTS: Readonly> = { + supabase_auth: + 'a Supabase Send Email hook secret (v1,whsec_…, from Authentication > Hooks)', + clerk: + 'a Clerk webhook signing secret (whsec_…, from Webhooks > your endpoint)', +} + +export function checkSigningSecret( + provider: HostedAuthProvider, + secret: string, + source: string +): string { + const trimmed = secret.trim() + if (trimmed === '') { + throw new CliUsageError(`${source} is empty.`) + } + if (trimmed.length > 4096 || !SECRET_SHAPES[provider].test(trimmed)) { + // Never quote the value: it may be a real secret pasted in the wrong place. + throw new CliUsageError( + `${source} does not look like ${SECRET_HINTS[provider]}.` + ) + } + return trimmed +} + +/** + * A secret pasted where the variable NAME goes must not be echoed back, so + * `integrations connect` checks it before its confirmation prints a re-run. + */ +export function checkSecretEnvName(name: string | undefined): void { + if ( + name !== undefined && + (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || + /^whsec_|^sk_|^sbp_/i.test(name) || + name.length > 64) + ) { + throw new CliUsageError( + `--signing-secret-env takes an environment variable NAME (e.g. SEND_EMAIL_HOOK_SECRET), not the secret.` + ) + } +} + +/** `--signing-secret-env ` or `--signing-secret-stdin`, exactly one. */ +export async function readSigningSecret( + ctx: CliContext, + provider: HostedAuthProvider, + input: { readonly envName: string | undefined; readonly stdin: boolean } +): Promise { + if (input.envName !== undefined && input.stdin) { + throw new CliUsageError( + 'Pass --signing-secret-env or --signing-secret-stdin, not both.' + ) + } + if (input.envName !== undefined) { + checkSecretEnvName(input.envName) + const value = ctx.io.env[input.envName] + if (value === undefined || value.trim() === '') { + throw new CliUsageError( + `${input.envName} is not set in this shell. Export the signing secret into it, then run the command again.` + ) + } + return checkSigningSecret(provider, value, input.envName) + } + if (input.stdin) { + if (ctx.io.isTtyIn) { + throw new CliUsageError( + '--signing-secret-stdin reads a pipe (e.g. `printenv SECRET | …`), not the terminal.' + ) + } + return checkSigningSecret( + provider, + await ctx.io.readStdin(ctx.signal), + 'stdin' + ) + } + throw new CliUsageError( + `The signing secret comes from --signing-secret-env or --signing-secret-stdin (never a flag value): ${SECRET_HINTS[provider]}.` + ) +} + +/** + * `GET …/connection` (`integrations.getConnection`): the brand's + * connection, or `null` when the provider is not connected (404). + */ +export async function findConnection( + client: BrewClient, + provider: HostedAuthProvider, + options: RequestOptions = {} +): Promise { + try { + return await client.integrations.getConnection(provider, options) + } catch (error) { + if ( + (error instanceof BrewApiError || error instanceof CliApiError) && + error.status === 404 + ) { + return null + } + throw error + } +} diff --git a/src/lib/long-running.ts b/src/lib/long-running.ts index b5143d0e..eb3550f1 100644 --- a/src/lib/long-running.ts +++ b/src/lib/long-running.ts @@ -9,6 +9,16 @@ export const EXPORT_EMAIL_DEFAULT_TIMEOUT_MS = ? sdk.EXPORT_EMAIL_DEFAULT_TIMEOUT_MS : 800_000 +/** + * `integrations.connect` also runs the auth app email kit (SDK 13.7.0's + * constant; older SDKs lack it, and the route runs for up to 300 s). + */ +export const CONNECT_INTEGRATION_TIMEOUT_MS = + 'CONNECT_INTEGRATION_DEFAULT_TIMEOUT_MS' in sdk && + typeof sdk.CONNECT_INTEGRATION_DEFAULT_TIMEOUT_MS === 'number' + ? sdk.CONNECT_INTEGRATION_DEFAULT_TIMEOUT_MS + : 300_000 + /** * Routes whose server work routinely outlasts the 30 s default, with the * SDK's own per-call default for each — one source for the numbers, so the @@ -83,6 +93,11 @@ export const LONG_RUNNING_ROUTES: ReadonlyArray<{ path: '/v1/app-emails/kit', timeoutMs: sdk.SET_UP_APP_EMAIL_KIT_DEFAULT_TIMEOUT_MS, }, + { + method: 'PUT', + path: '/v1/integrations/{provider}/connection', + timeoutMs: CONNECT_INTEGRATION_TIMEOUT_MS, + }, ] /** The long-running default for a concrete request path, if it has one. */ diff --git a/src/lib/project/vendor-http.ts b/src/lib/project/vendor-http.ts index 062c9e3a..07332f86 100644 --- a/src/lib/project/vendor-http.ts +++ b/src/lib/project/vendor-http.ts @@ -38,6 +38,11 @@ export type VendorRequest = { readonly maxRetryAfterMs?: number /** Attempts in total, 429 retries included. Default 3. */ readonly maxAttempts?: number + /** + * Secrets the body carries (a webhook signing secret), redacted from any + * error text the vendor echoes, like the credential headers are. + */ + readonly secrets?: readonly string[] } export type VendorResponse = { @@ -161,7 +166,7 @@ async function attemptOnce( /** Every credential the request carries, so error text can never echo one. */ function collectSecrets(request: VendorRequest): string[] { - const secrets: string[] = [] + const secrets: string[] = [...(request.secrets ?? [])] for (const [name, value] of Object.entries(request.headers ?? {})) { if (!CREDENTIAL_HEADER.test(name) || value === '') { continue diff --git a/src/registry.ts b/src/registry.ts index eff9c325..fce65cda 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -141,6 +141,8 @@ import { healthCommand } from './commands/health' import { initCommand } from './commands/init' import { insightsGetCommand } from './commands/insights/get' import { insightsListCommand } from './commands/insights/list' +import { integrationsConnectCommand } from './commands/integrations/connect' +import { integrationsConnectionCommand } from './commands/integrations/connection' import { integrationsListCommand } from './commands/integrations/list' import { loginCommand } from './commands/login' import { logoutCommand } from './commands/logout' @@ -308,6 +310,8 @@ export const ALL_COMMANDS: readonly CommandSpec[] = [ flowsListCommand, flowsGetCommand, integrationsListCommand, + integrationsConnectCommand, + integrationsConnectionCommand, chatsListCommand, chatsGetCommand, canvasSessionsCreateCommand, diff --git a/tests/commands/app-emails.test.ts b/tests/commands/app-emails.test.ts index fb81775e..7e23114a 100644 --- a/tests/commands/app-emails.test.ts +++ b/tests/commands/app-emails.test.ts @@ -431,7 +431,7 @@ describe('app-emails upsert', () => { } ) ) - // Typed against the vendored spec: brew-v2#2066 declares `secret`. + // Typed against the vendored spec: brew-v2#2169 declares `secret`. const fields: NonNullable['fields'] = [ { key: 'code', @@ -477,7 +477,7 @@ describe('app-emails upsert', () => { ) }) ) - // Typed against the vendored spec: brew-v2#2145 declares the modes. + // Typed against the vendored spec: brew-v2#2169 declares the modes. const source: NonNullable = { format: 'jsx', content: 'export default function E({ orderId }) { return orderId }', diff --git a/tests/commands/doctor-project.test.ts b/tests/commands/doctor-project.test.ts new file mode 100644 index 00000000..a9393db9 --- /dev/null +++ b/tests/commands/doctor-project.test.ts @@ -0,0 +1,619 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + 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 { writeConfig } from '../../src/lib/config-store' +import type { ProjectDoctorReport } from '../../src/lib/doctor/project' +import { sha256Hex } from '../../src/lib/project/fs-writer' +import { tempProject } from '../helpers/fixtures' +import { server } from '../helpers/msw-server' +import { + type RunCliOptions, + type RunCliResult, + runCli, +} from '../helpers/run-cli' + +const API = 'https://brew.new/api' +const APP_KEY = 'brew_appkey0123456789abcdefghijklmnopqrs' +const BRAND_ID = 'brand_acme' +const CLIENT_FILE = "import { createBrewClient } from '@brew.new/sdk'\n" + +type Seen = { readonly path: string; readonly brandId: string | null } + +/** A project exactly as init leaves it. */ +function healthyProject( + options: { readonly auth?: string; readonly providerConfig?: unknown } = {} +): string { + const root = tempProject('brew-doctor-') + mkdirSync(join(root, 'lib')) + writeFileSync(join(root, 'lib/brew.ts'), CLIENT_FILE) + writeFileSync(join(root, '.env.local'), `BREW_API_KEY=${APP_KEY}\n`) + writeFileSync(join(root, '.gitignore'), 'node_modules\n.env.local\n') + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + name: 'app', + dependencies: { '@brew.new/sdk': '^13.6.0' }, + }) + ) + mkdirSync(join(root, 'node_modules/@brew.new/sdk'), { recursive: true }) + writeFileSync( + join(root, 'node_modules/@brew.new/sdk/package.json'), + JSON.stringify({ name: '@brew.new/sdk', version: '13.6.1' }) + ) + writeFileSync( + join(root, 'brew.json'), + `${JSON.stringify( + { + version: 1, + projectId: '6f1c1d2e-3b4a-4c5d-8e9f-0a1b2c3d4e5f', + brandId: BRAND_ID, + framework: 'next-app', + auth: options.auth ?? 'better-auth', + emails: { + 'auth.verify-email': { + triggerEventId: 'tri_0', + automationId: 'aut_0', + }, + 'lifecycle.welcome': { + triggerEventId: 'tri_1', + automationId: 'aut_1', + }, + }, + files: { + 'lib/brew.ts': { + sha256: sha256Hex(CLIENT_FILE), + generator: 'init@0.21.0:lib/brew.ts', + }, + }, + ...(options.providerConfig === undefined + ? {} + : { providerConfig: options.providerConfig }), + }, + null, + 2 + )}\n` + ) + return root +} + +const READY = { ready: true, teamOnly: false, blockers: [] } + +function useProjectApi( + options: { + readonly brandId?: string + readonly brandStatus?: number + readonly appEmails?: ReadonlyArray> + readonly verifiedDomain?: boolean + readonly connection?: Record | null + } = {} +): Seen[] { + const seen: Seen[] = [] + const note = (request: Request) => + seen.push({ + path: new URL(request.url).pathname.replace(/^\/api/, ''), + brandId: request.headers.get('x-brand-id'), + }) + server.use( + http.get(`${API}/v1/brand`, ({ request }) => { + note(request) + if (options.brandStatus !== undefined) { + return HttpResponse.json( + { + error: { + code: 'INVALID_API_KEY', + type: 'authentication_error', + message: 'Invalid API key.', + }, + }, + { status: options.brandStatus } + ) + } + return HttpResponse.json({ + brandId: options.brandId ?? BRAND_ID, + domain: 'acme.com', + status: 'completed', + ready: true, + identity: { brandName: 'Acme' }, + }) + }), + http.get(`${API}/v1/app-emails`, ({ request }) => { + note(request) + return HttpResponse.json({ + data: options.appEmails ?? [ + { + triggerKey: 'auth.verify-email', + triggerEventId: 'tri_0', + published: true, + readiness: READY, + }, + { + triggerKey: 'lifecycle.welcome', + triggerEventId: 'tri_1', + published: true, + readiness: READY, + }, + ], + pagination: { limit: 100, cursor: null, hasMore: false }, + }) + }), + http.get(`${API}/v1/domains`, ({ request }) => { + note(request) + return HttpResponse.json({ + data: + options.verifiedDomain === true + ? [{ name: 'mail.acme.com', status: 'verified', sendable: true }] + : [{ name: 'mail.acme.com', status: 'pending', sendable: false }], + pagination: { limit: 100, cursor: null, hasMore: false }, + }) + }), + http.get(`${API}/v1/integrations/:provider/connection`, ({ request }) => { + note(request) + if (options.connection === undefined || options.connection === null) { + return HttpResponse.json( + { + error: { + code: 'NOT_FOUND', + type: 'not_found_error', + message: 'Not connected.', + }, + }, + { status: 404 } + ) + } + return HttpResponse.json(options.connection) + }) + ) + return seen +} + +async function doctor( + root: string, + options: RunCliOptions = {} +): Promise { + const result = await runCli(['doctor', '--project', '--json'], { + cwd: root, + ...options, + env: { + BREW_CLI_CONFIG_DIR: mkdtempSync(join(tmpdir(), 'brew-doctor-config-')), + ...options.env, + }, + }) + expect(result.stdout).not.toContain(APP_KEY) + expect(result.stderr).not.toContain(APP_KEY) + return { ...result, report: result.json as ProjectDoctorReport } +} + +function statusOf(report: ProjectDoctorReport, id: string): readonly string[] { + return report.checks + .filter((check) => check.id === id) + .map((check) => check.status) +} + +describe('doctor --project', () => { + it('a project as init left it: every check ok, the app key from .env.local (no X-Brand-Id), exit 0', async () => { + const root = healthyProject() + const seen = useProjectApi({ verifiedDomain: true }) + const result = await doctor(root) + expect(result.code).toBe(0) + expect(result.report.ok).toBe(true) + expect( + Object.fromEntries( + result.report.checks.map((check) => [check.id, check.status]) + ) + ).toEqual({ + state: 'ok', + files: 'ok', + env: 'ok', + 'env-exposure': 'ok', + gitignore: 'ok', + sdk: 'ok', + auth: 'ok', + 'app-emails': 'ok', + domain: 'ok', + provider: 'skipped', + }) + expect(seen.every((request) => request.brandId === null)).toBe(true) + expect(result.report.apiKey).toMatch(/^brew_/) + }) + + it('skips the SDK check for a browser-only app, which init installs no SDK for', async () => { + const root = healthyProject() + writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'app' })) + const state = JSON.parse(readFileSync(join(root, 'brew.json'), 'utf8')) + writeFileSync( + join(root, 'brew.json'), + `${JSON.stringify({ ...state, framework: 'vite-react' }, null, 2)}\n` + ) + useProjectApi({ verifiedDomain: true }) + const result = await doctor(root) + expect(statusOf(result.report, 'sdk')).toEqual(['skipped']) + expect(result.code).toBe(0) + }) + + it('fails on a deleted managed file, warns on an edited one', async () => { + const root = healthyProject() + useProjectApi({ verifiedDomain: true }) + writeFileSync(join(root, 'lib/brew.ts'), `${CLIENT_FILE}// mine\n`) + const edited = await doctor(root) + expect(edited.code).toBe(0) + expect(statusOf(edited.report, 'files')).toEqual(['warn']) + rmSync(join(root, 'lib/brew.ts')) + const missing = await doctor(root) + expect(missing.code).toBe(1) + expect( + missing.report.checks.find((check) => check.id === 'files') + ).toMatchObject({ + status: 'fail', + items: ['lib/brew.ts'], + }) + }) + + it('fails when the key sits under a browser-exposed name, without printing its value', async () => { + const root = healthyProject() + useProjectApi({ verifiedDomain: true }) + writeFileSync( + join(root, '.env'), + `NEXT_PUBLIC_BREW_API_KEY=${APP_KEY}\nVITE_TITLE=Acme\n` + ) + const result = await doctor(root) + expect(result.code).toBe(1) + expect( + result.report.checks.find((check) => check.id === 'env-exposure') + ).toMatchObject({ + status: 'fail', + items: ['.env: NEXT_PUBLIC_BREW_API_KEY'], + }) + }) + + it('passes a public Brew setting that holds no key (a URL, an empty placeholder)', async () => { + const root = healthyProject() + useProjectApi({ verifiedDomain: true }) + writeFileSync( + join(root, '.env'), + 'VITE_BREW_API_URL=https://brew.new/api\nVITE_BREW_API_KEY=\n' + ) + const result = await doctor(root) + expect( + result.report.checks.find((check) => check.id === 'env-exposure') + ).toMatchObject({ status: 'ok' }) + }) + + it('fails when the shell hands the build a key under a public name', async () => { + useProjectApi({ verifiedDomain: true }) + const result = await doctor(healthyProject(), { + env: { VITE_BREW_API_KEY: APP_KEY }, + }) + expect( + result.report.checks.find((check) => check.id === 'env-exposure') + ).toMatchObject({ + status: 'fail', + items: ['environment: VITE_BREW_API_KEY'], + }) + }) + + it('fails when .env.local is not git-ignored, or the key is missing', async () => { + const root = healthyProject() + useProjectApi({ verifiedDomain: true }) + writeFileSync(join(root, '.gitignore'), 'node_modules\n') + writeFileSync(join(root, '.env.local'), 'OTHER=1\n') + const result = await doctor(root) + expect(result.code).toBe(1) + expect(statusOf(result.report, 'gitignore')).toEqual(['fail']) + expect(statusOf(result.report, 'env')).toEqual(['fail']) + expect(statusOf(result.report, 'auth')).toEqual(['skipped']) + }) + + it('checks the SDK: declared too old fails, declared but not installed warns', async () => { + const root = healthyProject() + useProjectApi({ verifiedDomain: true }) + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ dependencies: { '@brew.new/sdk': '^13.3.0' } }) + ) + const old = await doctor(root) + expect(old.code).toBe(1) + expect( + old.report.checks.find((check) => check.id === 'sdk')?.detail + ).toContain('^13.3.0 is older than the ^13.6.0') + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ dependencies: { '@brew.new/sdk': '^13.6.0' } }) + ) + rmSync(join(root, 'node_modules'), { recursive: true }) + const notInstalled = await doctor(root) + expect(statusOf(notInstalled.report, 'sdk')).toEqual(['warn']) + }) + + it('fails when a key in brew.json has no app email in the brand, or the key belongs to another brand', async () => { + const root = healthyProject() + useProjectApi({ + verifiedDomain: true, + appEmails: [ + { + triggerKey: 'lifecycle.welcome', + triggerEventId: 'tri_1', + published: true, + readiness: READY, + }, + ], + }) + const result = await doctor(root) + expect(result.code).toBe(1) + expect( + result.report.checks.find((check) => check.id === 'app-emails') + ).toMatchObject({ + status: 'fail', + items: ['auth.verify-email'], + }) + useProjectApi({ brandId: 'brand_other', verifiedDomain: true }) + const other = await doctor(root) + expect(statusOf(other.report, 'auth')).toEqual(['fail']) + }) + + it('a rejected key fails auth and skips the brand checks (exit 1)', async () => { + const root = healthyProject() + useProjectApi({ brandStatus: 401 }) + const result = await doctor(root) + expect(result.code).toBe(1) + expect(statusOf(result.report, 'auth')).toEqual(['fail']) + expect(statusOf(result.report, 'app-emails')).toEqual(['skipped']) + }) + + it('a brand check whose read fails is a failure, not skipped (exit 1)', async () => { + const root = healthyProject({ auth: 'supabase' }) + useProjectApi({ verifiedDomain: true }) + const unavailable = () => + HttpResponse.json( + { + error: { + code: 'SERVICE_UNAVAILABLE', + type: 'api_error', + message: 'Try again.', + }, + }, + { status: 503 } + ) + server.use( + http.get(`${API}/v1/app-emails`, unavailable), + http.get(`${API}/v1/domains`, unavailable), + http.get(`${API}/v1/integrations/:provider/connection`, unavailable) + ) + const result = await runCli( + ['doctor', '--project', '--json', '--max-retries', '0'], + { + cwd: root, + env: { + BREW_CLI_CONFIG_DIR: mkdtempSync(join(tmpdir(), 'brew-doctor-')), + }, + } + ) + const report = result.json as ProjectDoctorReport + expect(result.code).toBe(1) + expect(statusOf(report, 'auth')).toEqual(['ok']) + for (const id of ['app-emails', 'domain', 'provider']) { + expect(statusOf(report, id), id).toEqual(['fail']) + } + }) + + it('warns in the sandbox and on a hook that is not active yet; ok once active', async () => { + const root = healthyProject({ + auth: 'supabase', + providerConfig: { + supabase_auth: { + projectRef: 'abcdefghijklmnopqrst', + savedAt: '2026-10-17T00:00:00.000Z', + previous: { + hook_send_email_enabled: false, + hook_send_email_uri: null, + }, + }, + }, + }) + const connection = { + provider: 'supabase_auth', + connectionId: 'conn_1', + webhookUrl: + 'https://brew.new/api/integrations/supabase_auth/webhook/conn_1', + status: 'pending_verification', + } + const seen = useProjectApi({ connection }) + const pending = await doctor(root) + expect(pending.code).toBe(0) + expect(statusOf(pending.report, 'domain')).toEqual(['warn']) + expect(statusOf(pending.report, 'provider')).toEqual(['warn']) + expect(seen.map((request) => request.path)).toContain( + '/v1/integrations/supabase_auth/connection' + ) + useProjectApi({ connection: { ...connection, status: 'active' } }) + const active = await doctor(root) + expect(statusOf(active.report, 'provider')).toEqual(['ok']) + useProjectApi({ connection: null }) + const notConnected = await doctor(root) + expect(statusOf(notConnected.report, 'provider')).toEqual(['warn']) + }) + + it('checks with the .env.local key on the server saved beside it, and refuses another (exit 2)', async () => { + const root = healthyProject() + writeFileSync( + join(root, '.env.local'), + `BREW_API_KEY=${APP_KEY}\nBREW_API_URL=${API}\n` + ) + const configDir = mkdtempSync(join(tmpdir(), 'brew-doctor-config-')) + // The CLI itself is pointed at a local server. + writeConfig( + { BREW_CLI_CONFIG_DIR: configDir }, + { apiUrl: 'http://localhost:3000/api' } + ) + const seen = useProjectApi() + const result = await doctor(root, { + env: { BREW_CLI_CONFIG_DIR: configDir }, + }) + expect(statusOf(result.report, 'auth')).toEqual(['ok']) + expect(seen.length).toBeGreaterThan(0) + const refused = await runCli( + [ + 'doctor', + '--project', + '--json', + '--api-url', + 'http://localhost:3000/api', + ], + { cwd: root, env: { BREW_CLI_CONFIG_DIR: configDir } } + ) + expect(refused.code).toBe(2) + expect(refused.stderr).toContain(API) + }) + + it('finds an app email past the twentieth page of app emails', async () => { + const root = healthyProject() + useProjectApi({ verifiedDomain: true }) + const row = (triggerKey: string, index: number) => ({ + triggerKey, + triggerEventId: `tri_${index}`, + published: true, + readiness: READY, + }) + server.use( + http.get(`${API}/v1/app-emails`, ({ request }) => { + const page = Number( + new URL(request.url).searchParams.get('cursor') ?? '0' + ) + const key = + page === 0 + ? 'auth.verify-email' + : page === 20 + ? 'lifecycle.welcome' + : `order.page-${page}` + return HttpResponse.json({ + data: [row(key, page === 20 ? 1 : page === 0 ? 0 : 100 + page)], + pagination: { + limit: 100, + cursor: page < 25 ? String(page + 1) : null, + hasMore: page < 25, + }, + }) + }) + ) + const result = await doctor(root) + expect(statusOf(result.report, 'app-emails')).toEqual(['ok']) + }) + + it('finds a verified sending domain past the first page of domains', async () => { + const root = healthyProject() + useProjectApi({ verifiedDomain: true }) + server.use( + http.get(`${API}/v1/domains`, ({ request }) => { + const cursor = new URL(request.url).searchParams.get('cursor') + return HttpResponse.json( + cursor === 'page2' + ? { + data: [ + { name: 'mail.acme.com', status: 'verified', sendable: true }, + ], + pagination: { limit: 100, cursor: null, hasMore: false }, + } + : { + data: [ + { name: 'old.acme.com', status: 'pending', sendable: false }, + ], + pagination: { limit: 100, cursor: 'page2', hasMore: true }, + } + ) + }) + ) + const result = await doctor(root) + expect( + result.report.checks.find((check) => check.id === 'domain') + ).toMatchObject({ status: 'ok', detail: 'sending from mail.acme.com' }) + }) + + it('warns on a key still in the sandbox although a domain of the other purpose verified', async () => { + const root = healthyProject() + useProjectApi({ + verifiedDomain: true, + appEmails: [ + { + triggerKey: 'auth.verify-email', + triggerEventId: 'tri_0', + published: true, + readiness: { ...READY, teamOnly: true }, + }, + { + triggerKey: 'lifecycle.welcome', + triggerEventId: 'tri_1', + published: true, + readiness: READY, + }, + ], + }) + const result = await doctor(root) + expect(result.code).toBe(0) + expect( + result.report.checks.find((check) => check.id === 'app-emails') + ).toMatchObject({ status: 'warn', items: ['auth.verify-email'] }) + }) + + it('fails on a published key that cannot send, naming its blockers', async () => { + const root = healthyProject() + useProjectApi({ + appEmails: [ + { + triggerKey: 'auth.verify-email', + triggerEventId: 'tri_0', + published: true, + readiness: { ...READY, ready: false, blockers: ['DOMAIN_REQUIRED'] }, + }, + { + triggerKey: 'lifecycle.welcome', + triggerEventId: 'tri_1', + published: true, + readiness: READY, + }, + ], + }) + const result = await doctor(root) + const check = result.report.checks.find( + (entry) => entry.id === 'app-emails' + ) + expect(check).toMatchObject({ + status: 'fail', + items: ['auth.verify-email'], + }) + expect(check?.detail).toContain('auth.verify-email (DOMAIN_REQUIRED)') + }) + + it('exits 2 outside a project with brew.json', async () => { + const root = tempProject('brew-doctor-empty-') + const result = await runCli(['doctor', '--project', '--json'], { + cwd: root, + env: { + BREW_CLI_CONFIG_DIR: mkdtempSync(join(tmpdir(), 'brew-doctor-config-')), + }, + }) + expect(result.code).toBe(2) + expect(result.stdout + result.stderr).toContain('No brew.json') + }) + + it('renders a human report', async () => { + const root = healthyProject() + useProjectApi() + const result = await runCli(['doctor', '--project'], { + cwd: root, + ttyOut: true, + env: { + BREW_CLI_CONFIG_DIR: mkdtempSync(join(tmpdir(), 'brew-doctor-config-')), + }, + }) + expect(result.code).toBe(0) + expect(result.stdout).toContain('WARN domain: no verified sending domain') + expect(result.stdout).not.toContain(APP_KEY) + }) +}) diff --git a/tests/commands/init-app-url.test.ts b/tests/commands/init-app-url.test.ts new file mode 100644 index 00000000..5ddc9d97 --- /dev/null +++ b/tests/commands/init-app-url.test.ts @@ -0,0 +1,634 @@ +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { HttpResponse, http } from 'msw' +import { describe, expect, it } from 'vitest' +import { copyFixture } from '../helpers/fixtures' +import { + API, + configEnv, + EXPLICIT_KEY, + expectValidReport, + init, + read, + recordingSpawn, + useBrewApi, +} from '../helpers/init-api' +import { APP_KEY, BRAND, fakeClock } from '../helpers/login-server' +import { server } from '../helpers/msw-server' + +const APP_URL = 'https://my-app.lovable.app' + +/** The signed-in brand and the one whose website is the app. */ +const TWO_BRANDS = [ + { + brandId: BRAND.brandId, + domain: 'acme.com', + status: 'completed', + ready: true, + }, + { + brandId: 'brand_lovable', + domain: 'my-app.lovable.app', + status: 'completed', + ready: true, + }, +] + +/** An app with its key in .env.local, so no sign-in runs. */ +function project(fixture = 'express-resend'): string { + const root = copyFixture(fixture) + writeFileSync(join(root, '.env.local'), `BREW_API_KEY=${APP_KEY}\n`) + return root +} + +/** `GET /v1/brand` (or `path`) is unavailable (after the SDK's retries). */ +function failBrandLookup(path = '/v1/brand'): void { + server.use( + http.get(`${API}${path}`, () => + HttpResponse.json( + { + error: { + code: 'SERVICE_UNAVAILABLE', + type: 'api_error', + message: 'Try again.', + }, + }, + { status: 503 } + ) + ) + ) +} + +describe('init --app-url: the brand', () => { + it("uses the key's brand when its website matches the app (a subdomain counts)", async () => { + const root = project() + const requests = useBrewApi() + const result = await init( + [ + '--yes', + '--json', + '--no-test-email', + '--app-url', + 'https://app.acme.com', + ], + { cwd: root, env: configEnv(), spawn: recordingSpawn().spawn } + ) + expect(result.code).toBe(0) + expectValidReport(result.report) + expect(result.report.brand).toMatchObject({ + brandId: BRAND.brandId, + domain: 'acme.com', + matchesAppUrl: true, + created: false, + }) + expect( + requests.filter((request) => request.path.startsWith('/v1/brands')) + ).toEqual([]) + }) + + it('never moves a key bound to one brand: a mismatch is reported, no brand is created', async () => { + const root = project() + const requests = useBrewApi() + const result = await init( + ['--yes', '--json', '--no-test-email', '--app-url', APP_URL], + { cwd: root, env: configEnv(), spawn: recordingSpawn().spawn } + ) + expect(result.code).toBe(0) + expect(result.report.brand).toMatchObject({ + brandId: BRAND.brandId, + matchesAppUrl: false, + }) + expect( + requests.filter((request) => request.path.startsWith('/v1/brands')) + ).toEqual([]) + const step = result.report.nextSteps.find( + (entry) => entry.id === 'brand-app-url' + ) + expect(step).toMatchObject({ actor: 'human', blocking: false }) + expect(step?.title).toContain('my-app.lovable.app') + }) + + it("an organization key picks the brand whose website is the app; the app's own key, bound to another brand, holds the kit until it is replaced", async () => { + const root = project() + const requests = useBrewApi({ + orgKeyWithoutBrand: true, + keyBrands: { [APP_KEY]: BRAND.brandId }, + brands: TWO_BRANDS, + }) + const result = await init( + [ + ...['--yes', '--json', '--no-test-email', '--app-url', APP_URL], + ...['--domain', 'mail.my-app.dev'], + ], + { + cwd: root, + env: configEnv({ BREW_API_KEY: EXPLICIT_KEY }), + spawn: recordingSpawn().spawn, + } + ) + expect(result.code).toBe(0) + expect(result.report.brand).toMatchObject({ + brandId: 'brand_lovable', + matchesAppUrl: true, + created: false, + }) + expect( + requests.some( + (request) => request.method === 'POST' && request.path === '/v1/brands' + ) + ).toBe(false) + // .env.local's key still sends from its own brand: no kit or domain + // lands in either brand while the app could not send from brand_lovable. + expect( + requests.some((request) => + ['/v1/app-emails/kit', '/v1/domains'].includes(request.path) + ) + ).toBe(false) + expect(result.report.status).toBe('action_required') + expect( + result.report.nextSteps.find((step) => step.id === 'replace-app-key') + ).toMatchObject({ actor: 'human', path: '.env.local', blocking: true }) + }) + + it('an organization key reads every brand page: a match past 1,000 brands is found, not re-created', async () => { + const requests = useBrewApi({ + orgKeyWithoutBrand: true, + keyBrands: { [APP_KEY]: 'brand_lovable' }, + }) + let pages = 0 + server.use( + http.get(`${API}/v1/brands`, ({ request }) => { + pages += 1 + const page = Number( + new URL(request.url).searchParams.get('cursor') ?? 0 + ) + const last = page === 10 + return HttpResponse.json({ + data: Array.from({ length: 100 }, (_, index) => + last && index === 99 + ? TWO_BRANDS[1] + : { + brandId: `brand_${page}_${index}`, + domain: `shop-${page}-${index}.example`, + status: 'completed', + ready: true, + } + ), + pagination: { + limit: 100, + cursor: last ? null : String(page + 1), + hasMore: !last, + }, + }) + }) + ) + const result = await init( + ['--yes', '--json', '--no-test-email', '--app-url', APP_URL], + { + cwd: project(), + env: configEnv({ BREW_API_KEY: EXPLICIT_KEY }), + spawn: recordingSpawn().spawn, + clock: fakeClock(), + } + ) + expect(pages).toBe(11) + expect(result.report.brand).toMatchObject({ + brandId: 'brand_lovable', + matchesAppUrl: true, + created: false, + }) + expect( + requests.some( + (request) => request.method === 'POST' && request.path === '/v1/brands' + ) + ).toBe(false) + }) + + it('a brand list that repeats its cursor ends the search', async () => { + useBrewApi({ orgKeyWithoutBrand: true }) + let pages = 0 + server.use( + http.get(`${API}/v1/brands`, () => { + pages += 1 + return HttpResponse.json({ + data: [TWO_BRANDS[0]], + pagination: { limit: 100, cursor: 'same', hasMore: true }, + }) + }) + ) + const result = await init( + ['--yes', '--json', '--no-test-email', '--app-url', APP_URL], + { + cwd: project(), + env: configEnv({ BREW_API_KEY: EXPLICIT_KEY }), + spawn: recordingSpawn().spawn, + clock: fakeClock(), + } + ) + expect(pages).toBe(2) + expect(result.report.brand).toMatchObject({ created: true }) + }) + + it('keeps an app key already bound to the brand --app-url picks', async () => { + const requests = useBrewApi({ + orgKeyWithoutBrand: true, + keyBrands: { [APP_KEY]: 'brand_lovable' }, + brands: TWO_BRANDS, + }) + const result = await init( + ['--yes', '--json', '--no-test-email', '--app-url', APP_URL], + { + cwd: project(), + env: configEnv({ BREW_API_KEY: EXPLICIT_KEY }), + spawn: recordingSpawn().spawn, + } + ) + expect(result.report.nextSteps.map((step) => step.id)).not.toContain( + 'replace-app-key' + ) + expect( + requests.some((request) => request.path === '/v1/app-emails/kit') + ).toBe(true) + }) + + it('an app key saved for another server holds the kit, even when that server has the same brand id', async () => { + const staging = 'https://staging.example/api' + const root = copyFixture('express-resend') + writeFileSync( + join(root, '.env.local'), + `BREW_API_KEY=${APP_KEY}\nBREW_API_URL=${staging}\n` + ) + const requests = useBrewApi({ + orgKeyWithoutBrand: true, + brands: TWO_BRANDS, + }) + const asked: Array = [] + server.use( + http.get(`${staging}/v1/brand`, ({ request }) => { + asked.push(request.headers.get('authorization')) + return HttpResponse.json({ + brandId: 'brand_lovable', + domain: 'my-app.lovable.app', + status: 'completed', + ready: true, + }) + }) + ) + const result = await init( + ['--yes', '--json', '--no-test-email', '--app-url', APP_URL], + { + cwd: root, + env: configEnv({ BREW_API_KEY: EXPLICIT_KEY }), + spawn: recordingSpawn().spawn, + } + ) + // The app key goes to no server: staging's brand ids say nothing about + // the brand this run (on brew.new) sets up. + expect( + requests.filter( + (request) => request.authorization === `Bearer ${APP_KEY}` + ) + ).toEqual([]) + expect(asked).toEqual([]) + expect( + requests.some((request) => + ['/v1/app-emails/kit', '/v1/domains'].includes(request.path) + ) + ).toBe(false) + const step = result.report.nextSteps.find( + (entry) => entry.id === 'replace-app-key' + ) + expect(step).toMatchObject({ path: '.env.local', blocking: true }) + expect(step?.title).toContain(staging) + }) + + it.each([ + ['on another server', 'https://staging.example/api', BRAND.brandId], + ['bound to another brand', undefined, 'brand_other'], + ])( + "an app key %s holds the kit when the run's key already has the app's brand", + async (_case, keyServer, keyBrand) => { + const root = copyFixture('express-resend') + writeFileSync( + join(root, '.env.local'), + `BREW_API_KEY=${APP_KEY}\n${keyServer === undefined ? '' : `BREW_API_URL=${keyServer}\n`}` + ) + const requests = useBrewApi({ + brandDomain: 'my-app.lovable.app', + keyBrands: { [APP_KEY]: keyBrand }, + }) + const result = await init( + ['--yes', '--json', '--no-test-email', '--app-url', APP_URL], + { + cwd: root, + env: configEnv({ BREW_API_KEY: EXPLICIT_KEY }), + spawn: recordingSpawn().spawn, + } + ) + expect(result.report.brand).toMatchObject({ + brandId: BRAND.brandId, + matchesAppUrl: true, + }) + expect( + requests.some((request) => + ['/v1/app-emails/kit', '/v1/domains'].includes(request.path) + ) + ).toBe(false) + const step = result.report.nextSteps.find( + (entry) => entry.id === 'replace-app-key' + ) + expect(step).toMatchObject({ path: '.env.local', blocking: true }) + if (keyServer !== undefined) { + expect(step?.title).toContain(keyServer) + } + } + ) + + it("an app key bound to the brand the run's key already has keeps the kit", async () => { + const requests = useBrewApi({ + brandDomain: 'my-app.lovable.app', + keyBrands: { [APP_KEY]: BRAND.brandId }, + }) + const result = await init( + ['--yes', '--json', '--no-test-email', '--app-url', APP_URL], + { + cwd: project(), + env: configEnv({ BREW_API_KEY: EXPLICIT_KEY }), + spawn: recordingSpawn().spawn, + } + ) + expect(result.report.nextSteps.map((step) => step.id)).not.toContain( + 'replace-app-key' + ) + expect( + requests.some((request) => request.path === '/v1/app-emails/kit') + ).toBe(true) + }) + + it('an organization key with no matching brand creates one, waits for it, and a re-run finds it instead of creating another', async () => { + const root = project() + const requests = useBrewApi({ orgKeyWithoutBrand: true }) + const clock = fakeClock() + const result = await init( + ['--yes', '--json', '--no-test-email', '--app-url', APP_URL], + { + cwd: root, + env: configEnv({ BREW_API_KEY: EXPLICIT_KEY }), + spawn: recordingSpawn().spawn, + clock, + } + ) + expect(result.code).toBe(0) + expectValidReport(result.report) + const create = requests.find( + (request) => request.method === 'POST' && request.path === '/v1/brands' + ) + expect(create?.body).toEqual({ url: APP_URL }) + expect(create?.idempotencyKey).toMatch( + /^init:[0-9a-f-]{36}:brand:[0-9a-f]{12}$/ + ) + expect( + requests.some((request) => request.path === '/v1/brands/brand_new') + ).toBe(true) + expect(clock.sleeps.length).toBeGreaterThan(0) + expect(result.report.brand).toMatchObject({ + brandId: 'brand_new', + matchesAppUrl: true, + created: true, + }) + expect( + requests.some((request) => request.path === '/v1/app-emails/kit') + ).toBe(false) + + // The brand exists now: a re-run reuses it. + const again = useBrewApi({ + orgKeyWithoutBrand: true, + brands: [ + { + brandId: 'brand_new', + domain: 'my-app.lovable.app', + status: 'completed', + ready: true, + }, + ], + }) + const rerun = await init( + ['--yes', '--json', '--no-test-email', '--app-url', APP_URL], + { + cwd: root, + env: configEnv({ BREW_API_KEY: EXPLICIT_KEY }), + spawn: recordingSpawn().spawn, + } + ) + expect(rerun.code).toBe(0) + expect( + again.some( + (request) => request.method === 'POST' && request.path === '/v1/brands' + ) + ).toBe(false) + expect(rerun.report.brand).toMatchObject({ + brandId: 'brand_new', + created: false, + }) + }) + + it('--no-wait: the new brand is still being built, so the kit waits for a re-run (exit 0, action required)', async () => { + const root = project() + const requests = useBrewApi({ orgKeyWithoutBrand: true }) + const result = await init( + ['--yes', '--json', '--no-wait', '--app-url', APP_URL], + { + cwd: root, + env: configEnv({ BREW_API_KEY: EXPLICIT_KEY }), + spawn: recordingSpawn().spawn, + } + ) + expect(result.code).toBe(0) + expectValidReport(result.report) + expect(result.report.status).toBe('action_required') + expect( + requests.some((request) => request.path === '/v1/app-emails/kit') + ).toBe(false) + expect(result.report.steps.find((step) => step.id === 'kit')).toMatchObject( + { + status: 'skipped', + } + ) + expect(result.report.testEmail).toMatchObject({ status: 'skipped' }) + expect( + result.report.nextSteps.find((step) => step.id === 'brand-extracting') + ).toMatchObject({ blocking: true }) + }) + + it('a key that may not create brands (403) gets a clear next step and the run goes on in its own brand', async () => { + const root = project() + const requests = useBrewApi({ createBrandStatus: 403 }) + const result = await init( + ['--yes', '--json', '--no-test-email', '--app-url', APP_URL], + { + cwd: root, + env: configEnv({ BREW_API_KEY: EXPLICIT_KEY }), + spawn: recordingSpawn().spawn, + } + ) + expect(result.code).toBe(0) + expect( + requests.some( + (request) => request.method === 'POST' && request.path === '/v1/brands' + ) + ).toBe(true) + expect(result.report.brand).toMatchObject({ + brandId: BRAND.brandId, + matchesAppUrl: false, + }) + const step = result.report.nextSteps.find( + (entry) => entry.id === 'brand-app-url' + ) + expect(step?.title).toContain( + 'needs an organization API key with the brands scope (ORG_SCOPE_REQUIRED)' + ) + expect(step?.title).toContain('BREW_API_KEY=') + expect( + requests.find((request) => request.path === '/v1/app-emails/kit')?.brandId + ).toBe(null) + }) + + it('a matching brand still being extracted (a re-run after --no-wait) holds the kit too', async () => { + const root = project() + const requests = useBrewApi({ + brandDomain: 'my-app.lovable.app', + brandReady: false, + }) + const result = await init( + ['--yes', '--json', '--no-wait', '--app-url', APP_URL], + { cwd: root, env: configEnv(), spawn: recordingSpawn().spawn } + ) + expect(result.code).toBe(0) + expectValidReport(result.report) + expect(result.report.status).toBe('action_required') + expect( + requests.some((request) => request.path === '/v1/app-emails/kit') + ).toBe(false) + expect( + result.report.nextSteps.find((step) => step.id === 'brand-extracting') + ).toMatchObject({ blocking: true }) + }) + + it('a brand lookup that fails sets nothing up in whatever brand the key acts as', async () => { + const root = project() + const requests = useBrewApi() + failBrandLookup() + const result = await init( + [ + ...['--yes', '--json', '--no-test-email', '--app-url', APP_URL], + ...['--domain', 'mail.my-app.dev'], + ], + { cwd: root, env: configEnv(), spawn: recordingSpawn().spawn } + ) + expect(result.code).toBe(1) + expect( + result.report.steps.find((step) => step.id === 'brand') + ).toMatchObject({ status: 'failed' }) + expect( + requests.some((request) => + ['/v1/app-emails/kit', '/v1/domains'].includes(request.path) + ) + ).toBe(false) + }) + + it("an organization key whose brand list fails sets nothing up in the key's brand", async () => { + const root = project() + const requests = useBrewApi({ brands: TWO_BRANDS }) + failBrandLookup('/v1/brands') + const result = await init( + [ + ...['--yes', '--json', '--no-test-email', '--app-url', APP_URL], + ...['--domain', 'mail.my-app.dev'], + ], + { + cwd: root, + env: configEnv({ BREW_API_KEY: EXPLICIT_KEY }), + spawn: recordingSpawn().spawn, + } + ) + expect(result.code).toBe(1) + expect( + result.report.steps.find((step) => step.id === 'brand') + ).toMatchObject({ status: 'failed' }) + expect( + requests.some((request) => + ['/v1/app-emails/kit', '/v1/domains'].includes(request.path) + ) + ).toBe(false) + }) + + it('a reviewed auth patch waits while the kit waits for the brand', async () => { + const root = project('next-better-auth') + const argv = [ + ...['--yes', '--json', '--no-test-email', '--no-install'], + ...['--app-url', APP_URL], + ] + const options = { + cwd: root, + env: configEnv(), + spawn: recordingSpawn().spawn, + } + useBrewApi() + const first = await init(argv, options) + const pin = `better-auth-config@${first.report.patches[0]?.baseSha256?.slice(0, 12)}` + const before = read(root, 'lib/auth.ts') + useBrewApi() + failBrandLookup() + const result = await init([...argv, '--apply-patches', pin], options) + expect(result.report.patches[0]?.status).toBe('pending') + expect(read(root, 'lib/auth.ts')).toBe(before) + }) + + it('--dry-run plans the brand without creating it', async () => { + const root = project() + const requests = useBrewApi({ orgKeyWithoutBrand: true }) + const result = await init(['--dry-run', '--json', '--app-url', APP_URL], { + cwd: root, + env: configEnv({ BREW_API_KEY: EXPLICIT_KEY }), + spawn: recordingSpawn().spawn, + }) + expect(result.code).toBe(0) + expect(requests.filter((request) => request.method !== 'GET')).toEqual([]) + expect( + result.report.steps.find((step) => step.id === 'brand') + ).toMatchObject({ + status: 'planned', + detail: + 'create a brand for my-app.lovable.app (POST /v1/brands; organization keys only)', + }) + }) +}) + +describe('init --domain: DNS', () => { + it('prints the records and the Brew page that adds them automatically (Entri, in the app), with the sandbox until verified', async () => { + const root = project() + useBrewApi() + 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) + expectValidReport(result.report) + expect(result.report.domain).toMatchObject({ + mode: 'sandbox', + added: true, + connectUrl: 'https://brew.new/domains/dom_new', + sandbox: { note: expect.stringContaining('team sandbox') }, + }) + const connect = result.report.nextSteps.find( + (step) => step.id === 'connect-dns-automatically' + ) + expect(connect).toMatchObject({ + actor: 'human', + kind: 'open-url', + url: 'https://brew.new/domains/dom_new', + blocking: false, + }) + expect(connect?.title).toContain('runs in the Brew app, not the CLI') + }) +}) diff --git a/tests/commands/init-provider-backup.test.ts b/tests/commands/init-provider-backup.test.ts new file mode 100644 index 00000000..c4972193 --- /dev/null +++ b/tests/commands/init-provider-backup.test.ts @@ -0,0 +1,107 @@ +import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + configEnv, + init, + read, + recordingSpawn, + useBrewApi, +} from '../helpers/init-api' +import { + OLD_HOOK_SECRET, + OLD_HOOK_URI, + SB_TOKEN, + supabaseProject, + useSupabaseApi, +} from '../helpers/init-providers' + +/** + * `--rollback-provider` restores the hook settings in brew.json with the + * secret backed up in .env.local: the two must describe the same hook. + */ + +const CONFIGURE = ['--configure-supabase', '--yes', '--json', '--no-test-email'] +const OTHER_REF = 'zyxwvutsrqponmlkjihg' +const OTHER_SECRET = 'v1,whsec_b3RoZXJwcm9qZWN0aG9va3NlY3JldGJhY2t1cA==' + +describe('init: the Supabase hook backup', () => { + it('a project change refused before its hook changes keeps the saved settings with their secret', async () => { + const root = supabaseProject() + const options = { + cwd: root, + env: configEnv({ SUPABASE_ACCESS_TOKEN: SB_TOKEN }), + spawn: recordingSpawn().spawn, + } + useBrewApi() + useSupabaseApi({ + enabled: true, + uri: OLD_HOOK_URI, + secrets: OLD_HOOK_SECRET, + }) + expect((await init(CONFIGURE, options)).code).toBe(0) + // Another project, whose hook has its own secret; an auth email still + // reaches the team only, so its hook is left as it is. + useBrewApi({ teamOnlyKeys: ['auth.magic-link'] }) + useSupabaseApi({ + enabled: true, + uri: `https://${OTHER_REF}.supabase.co/functions/v1/send-email`, + secrets: OTHER_SECRET, + }) + const other = await init( + [...CONFIGURE, '--supabase-project', OTHER_REF], + options + ) + expect(other.report.providers?.[0]).toMatchObject({ action: 'refused' }) + const state = JSON.parse(read(root, 'brew.json')) as { + providerConfig: { supabase_auth: { projectRef: string } } + } + // The backup now describes the other project's hook, secret included. + expect(state.providerConfig.supabase_auth.projectRef).toBe(OTHER_REF) + expect(read(root, '.env.local')).toContain( + `BREW_SUPABASE_PREVIOUS_HOOK_SECRETS=${OTHER_SECRET}` + ) + }) + + it('a project change brew.json cannot save keeps the saved secret beside the saved settings', async () => { + const root = supabaseProject() + const options = { + cwd: root, + env: configEnv({ SUPABASE_ACCESS_TOKEN: SB_TOKEN }), + spawn: recordingSpawn().spawn, + } + useBrewApi() + useSupabaseApi({ + enabled: true, + uri: OLD_HOOK_URI, + secrets: OLD_HOOK_SECRET, + }) + expect((await init(CONFIGURE, options)).code).toBe(0) + // brew.json still reads, but a write is refused: a link outside the + // project. + const outside = join(mkdtempSync(join(tmpdir(), 'brew-outside-')), 'b.json') + writeFileSync(outside, read(root, 'brew.json')) + rmSync(join(root, 'brew.json')) + symlinkSync(outside, join(root, 'brew.json')) + useBrewApi() + useSupabaseApi({ + enabled: true, + uri: `https://${OTHER_REF}.supabase.co/functions/v1/send-email`, + secrets: OTHER_SECRET, + }) + const other = await init( + [...CONFIGURE, '--supabase-project', OTHER_REF], + options + ) + expect(other.report.providers?.[0]).toMatchObject({ action: 'failed' }) + // The saved settings are still the first project's, and so is the + // secret a rollback puts back with them. + expect(read(root, 'brew.json')).not.toContain(OTHER_REF) + const env = read(root, '.env.local') + expect(env).toContain( + `BREW_SUPABASE_PREVIOUS_HOOK_SECRETS=${OLD_HOOK_SECRET}` + ) + expect(env).not.toContain(OTHER_SECRET) + }) +}) diff --git a/tests/commands/init-provider-pages.test.ts b/tests/commands/init-provider-pages.test.ts new file mode 100644 index 00000000..48dc9d24 --- /dev/null +++ b/tests/commands/init-provider-pages.test.ts @@ -0,0 +1,81 @@ +import { HttpResponse, http } from 'msw' +import { describe, expect, it } from 'vitest' +import { + API, + configEnv, + init, + KIT_KEYS, + recordingSpawn, + useBrewApi, +} from '../helpers/init-api' +import { + SB_TOKEN, + supabaseProject, + useSupabaseApi, +} from '../helpers/init-providers' +import { server } from '../helpers/msw-server' + +/** + * Before a hosted hook takes the auth emails, init checks each one reaches + * every user. A brand with many app emails lists them over many pages. + */ + +const CONFIGURE = ['--configure-supabase', '--yes', '--json', '--no-test-email'] +const PAGES = 25 + +/** `GET /v1/app-emails` with the kit keys on the last of PAGES pages. */ +function useManyAppEmailPages(): string[] { + const cursors: string[] = [] + server.use( + http.get(`${API}/v1/app-emails`, ({ request }) => { + const cursor = new URL(request.url).searchParams.get('cursor') + cursors.push(cursor ?? '') + const page = cursor === null ? 0 : Number(cursor.replace('page_', '')) + const last = page === PAGES - 1 + const keys = last + ? KIT_KEYS + : Array.from({ length: 100 }, (_, index) => `custom.p${page}-${index}`) + return HttpResponse.json({ + data: keys.map((key, index) => ({ + triggerKey: key, + triggerEventId: `tri_${page}_${index}`, + title: key, + emailId: `eml_${page}_${index}`, + automationId: `aut_${page}_${index}`, + published: true, + readiness: { ready: true, teamOnly: false, blockers: [] }, + })), + pagination: { + limit: 100, + cursor: last ? null : `page_${page + 1}`, + hasMore: !last, + }, + }) + }) + ) + return cursors +} + +describe('init: auth emails listed past many pages', () => { + it('--configure-supabase finds ready auth emails on a late page and points the hook at Brew', async () => { + const root = supabaseProject() + useBrewApi() + const cursors = useManyAppEmailPages() + const supabase = useSupabaseApi({ + enabled: false, + uri: null, + secrets: null, + }) + const result = await init(CONFIGURE, { + cwd: root, + env: configEnv({ SUPABASE_ACCESS_TOKEN: SB_TOKEN }), + spawn: recordingSpawn().spawn, + }) + expect(cursors).toHaveLength(PAGES) + expect(supabase.calls.map((call) => call.method)).toEqual(['GET', 'PATCH']) + expect(result.report.providers?.[0]).toMatchObject({ + provider: 'supabase_auth', + action: 'configured', + }) + }) +}) diff --git a/tests/commands/init-providers.test.ts b/tests/commands/init-providers.test.ts new file mode 100644 index 00000000..23fbae3c --- /dev/null +++ b/tests/commands/init-providers.test.ts @@ -0,0 +1,1049 @@ +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { writeConfig } from '../../src/lib/config-store' +import { copyFixture } from '../helpers/fixtures' +import { + configEnv, + expectValidReport, + init, + read, + recordingSpawn, + useBrewApi, + webhookUrlFor, +} from '../helpers/init-api' +import { + ACTIVE_CLERK, + CLERK, + CLERK_SECRET_KEY, + clerkProject, + expectBrewNeverSaw, + expectNoSecrets, + OLD_HOOK_SECRET, + OLD_HOOK_URI, + REF, + SB_TOKEN, + SUPABASE, + supabaseProject, + useClerkApi, + useSupabaseApi, +} from '../helpers/init-providers' +import { APP_KEY } from '../helpers/login-server' + +describe('init: Supabase Send Email hook', () => { + it('without --configure-supabase prints the manual steps and the self-hosted alternative; Supabase is never called', async () => { + const root = supabaseProject() + const requests = useBrewApi() + const supabase = useSupabaseApi({ + enabled: false, + uri: null, + secrets: null, + }) + const result = await init(['--yes', '--json', '--no-test-email'], { + cwd: root, + env: configEnv({ SUPABASE_ACCESS_TOKEN: SB_TOKEN }), + spawn: recordingSpawn().spawn, + }) + expect(result.code).toBe(0) + expectValidReport(result.report) + expect(supabase.calls).toEqual([]) + expect( + requests.filter((request) => request.path.includes('/integrations/')) + ).toMatchObject([ + { method: 'GET', path: '/v1/integrations/supabase_auth/connection' }, + ]) + expect(result.report.status).toBe('action_required') + expect(result.report.providers).toMatchObject([ + { provider: 'supabase_auth', action: 'manual', projectRef: REF }, + ]) + const ids = result.report.nextSteps.map((step) => step.id) + expect(ids).toEqual( + expect.arrayContaining([ + 'connect-supabase-hook', + 'configure-supabase-hook', + 'deploy-supabase-hook', + ]) + ) + const connect = result.report.nextSteps.find( + (step) => step.id === 'connect-supabase-hook' + ) + expect(connect).toMatchObject({ actor: 'human', blocking: true }) + expect(connect?.title).toContain( + 'integrations connect supabase_auth --signing-secret-env SEND_EMAIL_HOOK_SECRET --project-url https://abcdefghijklmnopqrst.supabase.co' + ) + expect( + result.report.nextSteps.find((step) => step.id === 'deploy-supabase-hook') + ).toMatchObject({ blocking: false }) + expectNoSecrets(root, result, [SB_TOKEN]) + }) + + it('--configure-supabase is destructive: off a terminal without --yes it exits 4 and calls nothing', async () => { + const root = supabaseProject() + const requests = useBrewApi() + const supabase = useSupabaseApi({ + enabled: false, + uri: null, + secrets: null, + }) + const result = await init(['--configure-supabase', '--json'], { + cwd: root, + env: configEnv({ SUPABASE_ACCESS_TOKEN: SB_TOKEN }), + }) + expect(result.code).toBe(4) + expect(requests).toEqual([]) + expect(supabase.calls).toEqual([]) + expect(result.json).toMatchObject({ + confirmationRequired: true, + confirmCommand: 'brew-cli init --configure-supabase --json --yes', + }) + }) + + it('--dry-run --json shows the exact PATCH (secret replaced) and changes nothing', async () => { + const root = supabaseProject() + const requests = useBrewApi() + const supabase = useSupabaseApi({ + enabled: false, + uri: null, + secrets: null, + }) + const result = await init(['--configure-supabase', '--dry-run', '--json'], { + cwd: root, + env: configEnv({ SUPABASE_ACCESS_TOKEN: SB_TOKEN }), + spawn: recordingSpawn().spawn, + }) + expect(result.code).toBe(0) + expectValidReport(result.report) + expect(supabase.calls.map((call) => call.method)).toEqual(['GET']) + expect(supabase.calls[0]?.authorization).toBe(`Bearer ${SB_TOKEN}`) + expect(requests.filter((request) => request.method !== 'GET')).toEqual([]) + expect(existsSync(join(root, 'brew.json'))).toBe(false) + expect(result.report.providers).toEqual([ + { + provider: 'supabase_auth', + action: 'planned', + connection: null, + projectRef: REF, + previous: { hook_send_email_enabled: false, hook_send_email_uri: null }, + requests: [ + { + vendor: 'Brew', + method: 'PUT', + url: '/v1/integrations/supabase_auth/connection', + body: { + signingSecret: '[generated v1,whsec_ secret, never shown]', + settings: { projectUrl: `https://${REF}.supabase.co` }, + }, + }, + { + vendor: 'Supabase', + method: 'PATCH', + url: `${SUPABASE}/v1/projects/${REF}/config/auth`, + body: { + hook_send_email_enabled: true, + hook_send_email_uri: + '', + hook_send_email_secrets: + '[generated v1,whsec_ secret, never shown]', + }, + }, + ], + rollbackCommand: 'brew-cli init --rollback-provider --yes', + detail: 'dry run', + }, + ]) + expectNoSecrets(root, result, [SB_TOKEN]) + }) + + it('--configure-supabase --yes saves the old hook to brew.json first, connects Brew, then points the hook at it; a re-run changes nothing', async () => { + const root = supabaseProject() + const requests = useBrewApi() + const supabase = useSupabaseApi( + { enabled: false, uri: null, secrets: null }, + { + beforePatch: () => { + // The rollback is on disk before Supabase changes. + const saved = JSON.parse(read(root, 'brew.json')) as { + providerConfig?: { supabase_auth?: { previous?: unknown } } + } + expect(saved.providerConfig?.supabase_auth?.previous).toEqual({ + hook_send_email_enabled: false, + hook_send_email_uri: null, + }) + }, + } + ) + const result = await init( + ['--configure-supabase', '--yes', '--json', '--no-test-email'], + { + cwd: root, + env: configEnv({ SUPABASE_ACCESS_TOKEN: SB_TOKEN }), + spawn: recordingSpawn().spawn, + } + ) + expect(result.code).toBe(0) + expectValidReport(result.report) + const put = requests.find( + (request) => + request.method === 'PUT' && + request.path === '/v1/integrations/supabase_auth/connection' + ) + const body = put?.body as { + signingSecret: string + settings: { projectUrl: string } + } + expect(body.signingSecret).toMatch(/^v1,whsec_[A-Za-z0-9+/]{43}=$/) + expect(body.settings).toEqual({ projectUrl: `https://${REF}.supabase.co` }) + expect(put?.authorization).toBe(`Bearer ${APP_KEY}`) + const patch = supabase.calls.find((call) => call.method === 'PATCH') + expect(patch?.body).toEqual({ + hook_send_email_enabled: true, + hook_send_email_uri: webhookUrlFor('supabase_auth'), + hook_send_email_secrets: body.signingSecret, + }) + expect(result.report.providers?.[0]).toMatchObject({ + action: 'configured', + connection: { + webhookUrl: webhookUrlFor('supabase_auth'), + status: 'pending_verification', + }, + rollbackCommand: 'brew-cli init --rollback-provider --yes', + }) + expect( + result.report.steps.find((step) => step.id === 'provider') + ).toMatchObject({ status: 'done' }) + const state = JSON.parse(read(root, 'brew.json')) as Record + expect(state.providerConfig).toEqual({ + supabase_auth: { + projectRef: REF, + savedAt: expect.any(String), + previous: { hook_send_email_enabled: false, hook_send_email_uri: null }, + }, + }) + expect( + result.report.files.find((file) => file.path === 'brew.json') + ).toMatchObject({ action: 'created' }) + expectNoSecrets(root, result, [ + SB_TOKEN, + body.signingSecret, + body.signingSecret.slice('v1,whsec_'.length), + ]) + expectBrewNeverSaw(requests, [SB_TOKEN]) + + // Re-run: the hook already delivers to Brew, so nothing is sent. + const again = useBrewApi({ + connections: { + supabase_auth: { + provider: 'supabase_auth', + connectionId: 'conn_supabase_auth', + webhookUrl: webhookUrlFor('supabase_auth'), + status: 'active', + }, + }, + }) + const callsBefore = supabase.calls.length + const rerun = await init( + ['--configure-supabase', '--yes', '--json', '--no-test-email'], + { + cwd: root, + env: configEnv({ SUPABASE_ACCESS_TOKEN: SB_TOKEN }), + spawn: recordingSpawn().spawn, + } + ) + expect(rerun.code).toBe(0) + expect(rerun.report.providers?.[0]).toMatchObject({ action: 'unchanged' }) + expect( + supabase.calls.slice(callsBefore).map((call) => call.method) + ).toEqual(['GET']) + expect( + again.filter( + (request) => + request.method === 'PUT' && request.path.includes('/integrations/') + ) + ).toEqual([]) + expect( + (JSON.parse(read(root, 'brew.json')) as Record) + .providerConfig + ).toEqual(state.providerConfig) + }) + + it('a hook that was on keeps its secret in .env.local (never brew.json), and --rollback-provider puts it all back', async () => { + const root = supabaseProject() + useBrewApi() + const supabase = useSupabaseApi({ + enabled: true, + uri: OLD_HOOK_URI, + secrets: OLD_HOOK_SECRET, + }) + const env = configEnv({ SUPABASE_ACCESS_TOKEN: SB_TOKEN }) + const result = await init( + ['--configure-supabase', '--yes', '--json', '--no-test-email'], + { cwd: root, env, spawn: recordingSpawn().spawn } + ) + expect(result.code).toBe(0) + expect(read(root, '.env.local')).toContain( + `BREW_SUPABASE_PREVIOUS_HOOK_SECRETS=${OLD_HOOK_SECRET}` + ) + expect(supabase.hook.uri).toBe(webhookUrlFor('supabase_auth')) + expectNoSecrets(root, result, [SB_TOKEN, OLD_HOOK_SECRET]) + + // Off a terminal, the rollback asks first. + const refused = await init(['--rollback-provider', '--json'], { + cwd: root, + env, + }) + expect(refused.code).toBe(4) + + const rollback = await init(['--rollback-provider', '--yes', '--json'], { + cwd: root, + env, + }) + expect(rollback.code).toBe(0) + expectValidReport(rollback.report) + expect(supabase.calls.at(-1)).toEqual({ + method: 'PATCH', + path: `/v1/projects/${REF}/config/auth`, + body: { + hook_send_email_enabled: true, + hook_send_email_uri: OLD_HOOK_URI, + hook_send_email_secrets: OLD_HOOK_SECRET, + }, + authorization: `Bearer ${SB_TOKEN}`, + }) + expect(supabase.hook).toEqual({ + enabled: true, + uri: OLD_HOOK_URI, + secrets: OLD_HOOK_SECRET, + }) + expect(rollback.report.providers).toMatchObject([ + { + provider: 'supabase_auth', + action: 'restored', + requests: [ + { + vendor: 'Supabase', + method: 'PATCH', + body: { + hook_send_email_enabled: true, + hook_send_email_uri: OLD_HOOK_URI, + hook_send_email_secrets: + '[generated v1,whsec_ secret, never shown]', + }, + }, + ], + }, + ]) + const state = JSON.parse(read(root, 'brew.json')) as Record + expect(state.providerConfig).toBeUndefined() + expectNoSecrets(root, rollback, [SB_TOKEN, OLD_HOOK_SECRET]) + }) + + it('--rollback-provider --dry-run lists the restore without sending it', async () => { + const root = supabaseProject() + useBrewApi() + const supabase = useSupabaseApi({ + enabled: false, + uri: null, + secrets: null, + }) + const env = configEnv({ SUPABASE_ACCESS_TOKEN: SB_TOKEN }) + await init(['--configure-supabase', '--yes', '--json', '--no-test-email'], { + cwd: root, + env, + spawn: recordingSpawn().spawn, + }) + const before = supabase.calls.length + const plan = await init(['--rollback-provider', '--dry-run', '--json'], { + cwd: root, + env, + }) + expect(plan.code).toBe(0) + expect(supabase.calls.length).toBe(before) + expect(plan.report.providers?.[0]).toMatchObject({ + action: 'planned', + requests: [ + { + method: 'PATCH', + body: { hook_send_email_enabled: false, hook_send_email_uri: null }, + }, + ], + }) + expect( + (JSON.parse(read(root, 'brew.json')) as Record) + .providerConfig + ).toBeDefined() + }) + + it('without SUPABASE_ACCESS_TOKEN it changes nothing and says how to get one', async () => { + const root = supabaseProject() + const requests = useBrewApi() + const supabase = useSupabaseApi({ + enabled: false, + uri: null, + secrets: null, + }) + const result = await init( + ['--configure-supabase', '--yes', '--json', '--no-test-email'], + { cwd: root, env: configEnv(), spawn: recordingSpawn().spawn } + ) + expect(result.code).toBe(0) + expect(result.report.status).toBe('action_required') + expect(supabase.calls).toEqual([]) + expect( + requests.filter( + (request) => + request.method === 'PUT' && request.path.includes('/integrations/') + ) + ).toEqual([]) + expect(result.report.providers?.[0]).toMatchObject({ action: 'refused' }) + expect( + result.report.nextSteps.find( + (step) => step.id === 'supabase-access-token' + ) + ).toMatchObject({ actor: 'human', kind: 'set-secret', blocking: true }) + }) + + it('a Supabase failure exits 1 with the vendor message redacted (no token, no secret)', async () => { + const root = supabaseProject() + const requests = useBrewApi() + useSupabaseApi( + { enabled: false, uri: null, secrets: null }, + { patchStatus: 403 } + ) + const result = await init( + ['--configure-supabase', '--yes', '--json', '--no-test-email'], + { + cwd: root, + env: configEnv({ SUPABASE_ACCESS_TOKEN: SB_TOKEN }), + spawn: recordingSpawn().spawn, + } + ) + expect(result.code).toBe(1) + expectValidReport(result.report) + const step = result.report.steps.find((entry) => entry.id === 'provider') + expect(step?.status).toBe('failed') + expect(step?.detail).toContain('Supabase answered 403') + expect(step?.detail).toContain('[redacted]') + const put = requests.find((request) => request.method === 'PUT') + const secret = String( + (put?.body as { signingSecret?: string } | undefined)?.signingSecret + ) + expect(secret).toMatch(/^v1,whsec_/) + expectNoSecrets(root, result, [ + SB_TOKEN, + secret, + secret.slice('v1,whsec_'.length), + ]) + }) + + it('needs a project ref it can find, and checks --supabase-project before any request', async () => { + const root = copyFixture('express-resend') + writeFileSync(join(root, '.env.local'), `BREW_API_KEY=${APP_KEY}\n`) + useBrewApi() + const supabase = useSupabaseApi({ + enabled: false, + uri: null, + secrets: null, + }) + const result = await init( + [ + '--stack', + 'supabase', + '--configure-supabase', + '--yes', + '--json', + '--no-test-email', + ], + { + cwd: root, + env: configEnv({ SUPABASE_ACCESS_TOKEN: SB_TOKEN }), + spawn: recordingSpawn().spawn, + } + ) + expect(result.code).toBe(0) + expect(supabase.calls).toEqual([]) + expect( + result.report.nextSteps.find((step) => step.id === 'supabase-project') + ).toMatchObject({ blocking: true }) + + const bad = await init( + ['--configure-supabase', '--supabase-project', 'Not-A-Ref', '--yes'], + { cwd: root, env: configEnv() } + ) + expect(bad.code).toBe(2) + expect(bad.stderr + bad.stdout).toContain('20 lowercase letters') + }) + + it('refuses --configure-supabase on a project whose auth is not Supabase (exit 2)', async () => { + const root = clerkProject() + const requests = useBrewApi() + const result = await init(['--configure-supabase', '--yes', '--json'], { + cwd: root, + env: configEnv(), + }) + expect(result.code).toBe(2) + expect(requests).toEqual([]) + }) +}) + +describe('init: Clerk delivery', () => { + const ROUTED = { + reset_password_code: true, + magic_link_sign_in: true, + magic_link_sign_up: true, + magic_link_user_profile: false, + verification_code: true, + } + + it('a bare --yes never touches Clerk, even with an active connection and CLERK_SECRET_KEY', async () => { + const root = clerkProject() + useBrewApi({ connections: { clerk: ACTIVE_CLERK } }) + const clerk = useClerkApi(ROUTED) + const result = await init(['--yes', '--json', '--no-test-email'], { + cwd: root, + env: configEnv({ CLERK_SECRET_KEY }), + spawn: recordingSpawn().spawn, + }) + expect(result.code).toBe(0) + expectValidReport(result.report) + expect(clerk.calls).toEqual([]) + expect(result.report.providers).toMatchObject([ + { provider: 'clerk', action: 'manual' }, + ]) + expect( + result.report.nextSteps.find( + (step) => step.id === 'configure-clerk-delivery' + ) + ).toMatchObject({ + command: 'brew-cli init --configure-clerk --yes', + blocking: false, + }) + }) + + it.each([ + [ + 'connect-clerk', + {}, + 'integrations connect clerk --brand bd_acme --api-url https://brew.new/api --signing-secret-env', + ], + [ + 'configure-clerk-delivery', + { connections: { clerk: ACTIVE_CLERK } }, + 'brew-cli init --brand bd_acme --api-url https://brew.new/api --configure-clerk --yes', + ], + ])( + "%s names this run's brand and server when the CLI's own key resolves to others", + async (id, api, expected) => { + const root = clerkProject() + useBrewApi(api) + const env = configEnv() + // The CLI's saved key belongs to another brand and server. + writeConfig(env, { + apiKey: 'brew_cli_other0123456789abcdefghijklmn', + brandId: 'bd_other', + apiUrl: 'http://localhost:3000/api', + }) + const result = await init( + [ + '--yes', + '--json', + '--no-test-email', + '--api-url', + 'https://brew.new/api', + ], + { cwd: root, env, spawn: recordingSpawn().spawn } + ) + expect(result.code).toBe(0) + const step = result.report.nextSteps.find((entry) => entry.id === id) + expect(`${step?.title} ${step?.command ?? ''}`).toContain(expected) + } + ) + + it('the init follow-ups keep a --stack override', async () => { + const root = clerkProject() + useBrewApi({ connections: { clerk: ACTIVE_CLERK } }) + const result = await init( + ['--yes', '--json', '--no-test-email', '--stack', 'clerk'], + { cwd: root, env: configEnv(), spawn: recordingSpawn().spawn } + ) + expect(result.code).toBe(0) + expect( + result.report.nextSteps.find( + (step) => step.id === 'configure-clerk-delivery' + )?.command + ).toBe('brew-cli init --stack clerk --configure-clerk --yes') + }) + + it('without a Clerk connection, prints how to connect it; Clerk keeps its emails', async () => { + const root = clerkProject() + useBrewApi() + const result = await init(['--yes', '--json', '--no-test-email'], { + cwd: root, + env: configEnv(), + spawn: recordingSpawn().spawn, + }) + expect(result.code).toBe(0) + const connect = result.report.nextSteps.find( + (step) => step.id === 'connect-clerk' + ) + expect(connect).toMatchObject({ actor: 'human', blocking: false }) + expect(connect?.title).toContain( + 'integrations connect clerk --signing-secret-env CLERK_WEBHOOK_SIGNING_SECRET' + ) + }) + + it('--configure-clerk refuses while the connection is not active: no Clerk call', async () => { + const root = clerkProject() + useBrewApi({ + connections: { + clerk: { ...ACTIVE_CLERK, status: 'pending_verification' }, + }, + }) + const clerk = useClerkApi(ROUTED) + const result = await init( + ['--configure-clerk', '--yes', '--json', '--no-test-email'], + { + cwd: root, + env: configEnv({ CLERK_SECRET_KEY }), + spawn: recordingSpawn().spawn, + } + ) + expect(result.code).toBe(0) + expect(clerk.calls).toEqual([]) + expect(result.report.providers?.[0]).toMatchObject({ + provider: 'clerk', + action: 'refused', + }) + expect( + result.report.steps.find((step) => step.id === 'provider')?.detail + ).toContain('pending_verification, not active') + expect(result.report.nextSteps.map((step) => step.id)).toContain( + 'verify-clerk-webhook' + ) + }) + + it('--configure-clerk --dry-run lists the exact toggles and sends none', async () => { + const root = clerkProject() + useBrewApi({ connections: { clerk: ACTIVE_CLERK } }) + const clerk = useClerkApi(ROUTED) + const result = await init(['--configure-clerk', '--dry-run', '--json'], { + cwd: root, + env: configEnv({ CLERK_SECRET_KEY }), + spawn: recordingSpawn().spawn, + }) + expect(result.code).toBe(0) + expect(clerk.calls.every((call) => call.method === 'GET')).toBe(true) + expect(result.report.providers?.[0]).toMatchObject({ + action: 'planned', + templates: [ + 'reset_password_code', + 'magic_link_sign_in', + 'magic_link_sign_up', + ], + requests: [ + { + vendor: 'Clerk', + method: 'POST', + url: `${CLERK}/templates/email/reset_password_code/toggle_delivery`, + body: { delivered_by_clerk: false }, + }, + { + vendor: 'Clerk', + method: 'POST', + url: `${CLERK}/templates/email/magic_link_sign_in/toggle_delivery`, + body: { delivered_by_clerk: false }, + }, + { + vendor: 'Clerk', + method: 'POST', + url: `${CLERK}/templates/email/magic_link_sign_up/toggle_delivery`, + body: { delivered_by_clerk: false }, + }, + ], + }) + expect(existsSync(join(root, 'brew.json'))).toBe(false) + expectNoSecrets(root, result, [CLERK_SECRET_KEY]) + }) + + it('--configure-clerk --yes turns off exactly the templates Brew publishes, saves them, and --rollback-provider turns them back on', async () => { + const root = clerkProject() + // The Clerk secret lives in .env.local, as Clerk's quickstarts put it. + writeFileSync( + join(root, '.env.local'), + `BREW_API_KEY=${APP_KEY}\nCLERK_SECRET_KEY=${CLERK_SECRET_KEY}\n` + ) + const requests = useBrewApi({ connections: { clerk: ACTIVE_CLERK } }) + const clerk = useClerkApi(ROUTED) + const result = await init( + ['--configure-clerk', '--yes', '--json', '--no-test-email'], + { cwd: root, env: configEnv(), spawn: recordingSpawn().spawn } + ) + expect(result.code).toBe(0) + expectValidReport(result.report) + const toggles = clerk.calls.filter((call) => call.method === 'POST') + expect(toggles).toEqual( + ['reset_password_code', 'magic_link_sign_in', 'magic_link_sign_up'].map( + (slug) => ({ + method: 'POST', + path: `/v1/templates/email/${slug}/toggle_delivery`, + body: { delivered_by_clerk: false }, + authorization: `Bearer ${CLERK_SECRET_KEY}`, + }) + ) + ) + // verification_code (auth.otp) has no published app email: untouched. + expect(clerk.delivered.verification_code).toBe(true) + expect(requests.some((request) => request.path === '/v1/app-emails')).toBe( + true + ) + const state = JSON.parse(read(root, 'brew.json')) as { + providerConfig?: { clerk?: { templates?: string[] } } + } + expect(state.providerConfig?.clerk?.templates).toEqual([ + 'magic_link_sign_in', + 'magic_link_sign_up', + 'reset_password_code', + ]) + expectNoSecrets(root, result, [CLERK_SECRET_KEY]) + expectBrewNeverSaw(requests, [CLERK_SECRET_KEY]) + + const rollback = await init(['--rollback-provider', '--yes', '--json'], { + cwd: root, + env: configEnv(), + }) + expect(rollback.code).toBe(0) + expect( + clerk.calls + .filter((call) => call.method === 'POST') + .slice(3) + .map((call) => [call.path, call.body]) + ).toEqual( + ['magic_link_sign_in', 'magic_link_sign_up', 'reset_password_code'].map( + (slug) => [ + `/v1/templates/email/${slug}/toggle_delivery`, + { delivered_by_clerk: true }, + ] + ) + ) + expect(clerk.delivered).toMatchObject({ + reset_password_code: true, + magic_link_sign_in: true, + magic_link_sign_up: true, + magic_link_user_profile: false, + }) + expect( + (JSON.parse(read(root, 'brew.json')) as Record) + .providerConfig + ).toBeUndefined() + expectNoSecrets(root, rollback, [CLERK_SECRET_KEY]) + }) + + it('--rollback-provider with nothing saved, or without brew.json, is a usage error (exit 2)', async () => { + const root = clerkProject() + const none = await init(['--rollback-provider', '--yes', '--json'], { + cwd: root, + env: configEnv(), + }) + expect(none.code).toBe(2) + expect(none.stderr + none.stdout).toContain('nothing to roll back') + writeFileSync( + join(root, 'brew.json'), + `${JSON.stringify({ version: 1, projectId: '6f1c1d2e-3b4a-4c5d-8e9f-0a1b2c3d4e5f', emails: {}, files: {} })}\n` + ) + const empty = await init(['--rollback-provider', '--yes', '--json'], { + cwd: root, + env: configEnv(), + }) + expect(empty.code).toBe(2) + expect(empty.stderr + empty.stdout).toContain('no provider settings') + expect(readFileSync(join(root, 'brew.json'), 'utf8')).toContain( + '6f1c1d2e-3b4a-4c5d-8e9f-0a1b2c3d4e5f' + ) + }) + + it('--configure-clerk with --configure-supabase or --rollback-provider is a usage error', async () => { + const root = clerkProject() + for (const argv of [ + ['--configure-clerk', '--configure-supabase'], + ['--configure-clerk', '--rollback-provider'], + ]) { + const result = await init([...argv, '--yes', '--json'], { + cwd: root, + env: configEnv(), + }) + expect(result.code).toBe(2) + } + }) +}) + +describe('init: a provider changes only when nothing breaks', () => { + const CONFIGURE = [ + '--configure-supabase', + '--yes', + '--json', + '--no-test-email', + ] + const OFF = { enabled: false, uri: null, secrets: null } + const run = ( + root: string, + argv: readonly string[], + env = configEnv({ SUPABASE_ACCESS_TOKEN: SB_TOKEN }) + ) => init(argv, { cwd: root, env, spawn: recordingSpawn().spawn }) + const integrationPuts = ( + requests: readonly { method: string; path: string }[] + ) => + requests.filter( + (request) => + request.method === 'PUT' && request.path.includes('/integrations/') + ) + + it.each([ + [ + 'an auth email failed to set up', + { connectFailKeys: ['auth.magic-link'] }, + ], + [ + 'an auth email reaches the team only (sandbox)', + { teamOnlyKeys: ['auth.magic-link'] }, + ], + ])( + '--configure-supabase leaves the hook as it is when %s', + async (_name, api) => { + const root = supabaseProject() + useBrewApi(api) + const supabase = useSupabaseApi(OFF) + const result = await run(root, CONFIGURE) + expect(supabase.calls.map((call) => call.method)).toEqual(['GET']) + expect(result.report.providers?.[0]).toMatchObject({ action: 'refused' }) + expect( + result.report.nextSteps.find( + (step) => step.id === 'rerun-supabase-connect' + ) + ).toMatchObject({ + blocking: true, + title: expect.stringContaining('auth.magic-link'), + }) + } + ) + + it('a printed re-run keeps the project --supabase-project picked', async () => { + const root = supabaseProject() + useBrewApi({ connectFailKeys: ['auth.magic-link'] }) + useSupabaseApi(OFF) + const picked = ['--supabase-project', 'zyxwvutsrqponmlkjihg'] + const result = await run(root, [...CONFIGURE, ...picked]) + const rerun = result.report.nextSteps.find( + (step) => step.id === 'rerun-supabase-connect' + ) + expect(rerun?.command).toContain(picked.join(' ')) + }) + + it("--configure-supabase never replaces the secret of a connection active for another project's hook", async () => { + const root = supabaseProject() + const requests = useBrewApi({ + connections: { + supabase_auth: { + provider: 'supabase_auth', + connectionId: 'conn_supabase_auth', + webhookUrl: webhookUrlFor('supabase_auth'), + status: 'active', + }, + }, + }) + const supabase = useSupabaseApi(OFF) + const result = await run(root, CONFIGURE) + expect(integrationPuts(requests)).toEqual([]) + expect(supabase.calls.map((call) => call.method)).toEqual(['GET']) + expect(result.report.providers?.[0]).toMatchObject({ action: 'refused' }) + expect(result.report.nextSteps.map((step) => step.id)).toContain( + 'supabase-connection-in-use' + ) + }) + + it('--configure-supabase changes nothing when brew.json cannot take the old settings', async () => { + const root = supabaseProject() + const outside = mkdtempSync(join(tmpdir(), 'brew-outside-')) + symlinkSync(join(outside, 'brew.json'), join(root, 'brew.json')) + const requests = useBrewApi() + const supabase = useSupabaseApi(OFF) + const result = await run(root, CONFIGURE) + expect(integrationPuts(requests)).toEqual([]) + expect(supabase.calls.map((call) => call.method)).toEqual(['GET']) + expect(result.report.providers?.[0]).toMatchObject({ action: 'failed' }) + expect(existsSync(join(outside, 'brew.json'))).toBe(false) + }) + + it('--rollback-provider fails its state step when brew.json cannot be written', async () => { + const root = supabaseProject() + useBrewApi() + useSupabaseApi(OFF) + expect((await run(root, CONFIGURE)).code).toBe(0) + const outside = join(mkdtempSync(join(tmpdir(), 'brew-outside-')), 'b.json') + writeFileSync(outside, read(root, 'brew.json')) + rmSync(join(root, 'brew.json')) + symlinkSync(outside, join(root, 'brew.json')) + const rollback = await run(root, ['--rollback-provider', '--yes', '--json']) + expect(rollback.code).toBe(1) + expect( + rollback.report.steps.find((step) => step.id === 'state') + ).toMatchObject({ status: 'failed' }) + }) + + it("replaces a hook secret backup an earlier run left, so a rollback gets this hook's", async () => { + const root = supabaseProject() + const stale = 'v1,whsec_c3RhbGVzZWNyZXRmcm9tYW5lYXJsaWVycnVu' + writeFileSync( + join(root, '.env.local'), + `BREW_API_KEY=${APP_KEY}\nBREW_SUPABASE_PREVIOUS_HOOK_SECRETS=${stale}\n` + ) + useBrewApi() + useSupabaseApi({ + enabled: true, + uri: OLD_HOOK_URI, + secrets: OLD_HOOK_SECRET, + }) + const result = await run(root, CONFIGURE) + expect(result.code).toBe(0) + const env = read(root, '.env.local') + expect(env).toContain( + `BREW_SUPABASE_PREVIOUS_HOOK_SECRETS=${OLD_HOOK_SECRET}` + ) + expect(env).not.toContain(stale) + }) + + it("--rollback-provider leaves the hook and keeps its backup while the old hook's secret is missing", async () => { + const root = supabaseProject() + useBrewApi() + const supabase = useSupabaseApi({ + enabled: true, + uri: OLD_HOOK_URI, + secrets: OLD_HOOK_SECRET, + }) + await run(root, CONFIGURE) + // .env.local lost the backup since. + writeFileSync(join(root, '.env.local'), `BREW_API_KEY=${APP_KEY}\n`) + const before = supabase.calls.length + const rollback = await run(root, ['--rollback-provider', '--yes', '--json']) + expect(supabase.calls.length).toBe(before) + expect(rollback.report.providers?.[0]).toMatchObject({ action: 'refused' }) + expect(rollback.report.nextSteps.map((step) => step.id)).toContain( + 'restore-supabase-hook-secret' + ) + const state = JSON.parse(read(root, 'brew.json')) as Record + expect(state.providerConfig).toMatchObject({ + supabase_auth: { projectRef: REF }, + }) + }) + + it('the commands it prints keep --app, so they run from the same directory', async () => { + const root = supabaseProject() + useBrewApi() + useSupabaseApi(OFF) + const app = basename(root) + const result = await run(dirname(root), [ + '--configure-supabase', + '--dry-run', + '--json', + '--app', + app, + ]) + expect(result.code).toBe(0) + expect(result.report.providers?.[0]?.rollbackCommand).toBe( + `brew-cli init --app ${app} --rollback-provider --yes` + ) + }) + + it('--configure-clerk keeps Clerk delivering a template whose Brew email reaches the team only (sandbox)', async () => { + const root = clerkProject() + useBrewApi({ + connections: { clerk: ACTIVE_CLERK }, + teamOnlyKeys: ['auth.password-reset'], + }) + const clerk = useClerkApi({ + reset_password_code: true, + magic_link_sign_in: true, + }) + const result = await run( + root, + ['--configure-clerk', '--yes', '--json', '--no-test-email'], + configEnv({ CLERK_SECRET_KEY }) + ) + expect(result.code).toBe(0) + expect( + clerk.calls + .filter((call) => call.method === 'POST') + .map((call) => call.path) + ).toEqual(['/v1/templates/email/magic_link_sign_in/toggle_delivery']) + expect(clerk.delivered.reset_password_code).toBe(true) + }) + + it('a CLERK_SECRET_KEY of another instance never adds to, or rolls back, what init changed', async () => { + const root = clerkProject() + const other = `${CLERK_SECRET_KEY}-other-instance` + useBrewApi({ connections: { clerk: ACTIVE_CLERK } }) + const clerk = useClerkApi({ reset_password_code: true }) + const configure = [ + '--configure-clerk', + '--yes', + '--json', + '--no-test-email', + ] + const result = await run(root, configure, configEnv({ CLERK_SECRET_KEY })) + expect(result.code).toBe(0) + expectNoSecrets(root, result, [CLERK_SECRET_KEY]) + const before = clerk.calls.length + const again = await run( + root, + configure, + configEnv({ CLERK_SECRET_KEY: other }) + ) + expect(again.report.providers?.[0]).toMatchObject({ action: 'refused' }) + const rollback = await run( + root, + ['--rollback-provider', '--yes', '--json'], + configEnv({ CLERK_SECRET_KEY: other }) + ) + expect(rollback.report.providers?.[0]).toMatchObject({ action: 'refused' }) + expect( + clerk.calls.slice(before).filter((call) => call.method === 'POST') + ).toEqual([]) + const state = JSON.parse(read(root, 'brew.json')) as { + providerConfig?: { clerk?: { templates?: string[] } } + } + expect(state.providerConfig?.clerk?.templates).toEqual([ + 'reset_password_code', + ]) + }) +}) + +describe('init: without the auth set, the provider keeps its auth emails', () => { + it.each([ + ['Supabase', supabaseProject], + ['Clerk', clerkProject], + ])('--sets lifecycle prints no %s provider step', async (_name, project) => { + useBrewApi({ connections: { clerk: ACTIVE_CLERK } }) + const result = await init( + ['--yes', '--json', '--no-test-email', '--sets', 'lifecycle'], + { cwd: project(), env: configEnv(), spawn: recordingSpawn().spawn } + ) + expect(result.code).toBe(0) + expect(result.report.providers ?? []).toEqual([]) + }) + + it('--configure-clerk without the auth set is a usage error (exit 2)', async () => { + const requests = useBrewApi() + const result = await init( + ['--configure-clerk', '--sets', 'lifecycle', '--yes', '--json'], + { cwd: clerkProject(), env: configEnv() } + ) + expect(result.code).toBe(2) + expect(requests).toEqual([]) + }) +}) diff --git a/tests/commands/init.test.ts b/tests/commands/init.test.ts index 95e050f3..824f3662 100644 --- a/tests/commands/init.test.ts +++ b/tests/commands/init.test.ts @@ -1,14 +1,11 @@ import { existsSync, mkdirSync, - mkdtempSync, - readFileSync, rmSync, statSync, symlinkSync, 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' @@ -17,12 +14,23 @@ import { readPendingLogin, } from '../../src/lib/auth-flow/pending' import { readConfig, writeConfig } from '../../src/lib/config-store' -import type { InitReport } from '../../src/lib/init/report' +import { renderReport } 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, tempProject } from '../helpers/fixtures' -import { loadSchema, validateSchema } from '../helpers/json-schema' +import { + API, + configEnv, + EXPLICIT_KEY, + expectValidReport, + init, + KIT_KEYS, + MUTATING, + type Recorded, + read, + recordingSpawn, + useBrewApi, +} from '../helpers/init-api' import { APP_KEY, APPROVER_EMAIL, @@ -35,281 +43,6 @@ import { useLoginServer, } from '../helpers/login-server' import { server } from '../helpers/msw-server' -import { - type RunCliOptions, - type RunCliResult, - runCli, -} from '../helpers/run-cli' - -const API = 'https://brew.new/api' -const EXPLICIT_KEY = 'brew_explicit0123456789abcdefghijklmno' -const SECRETS = [APP_KEY, CLI_KEY, EXPLICIT_KEY] -const REPORT_SCHEMA = loadSchema('init-report.json') - -const KIT_KEYS = [ - 'auth.verify-email', - 'auth.password-reset', - 'auth.magic-link', - 'lifecycle.welcome', -] - -type Recorded = { - readonly method: string - readonly path: string - readonly body: unknown - readonly idempotencyKey: string | null - readonly brandId: string | null - readonly authorization: string | null -} - -function titleOf(key: string): string { - return key - .split(/[.-]/) - .slice(1) - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' ') -} - -/** The kit's trigger rows, as `GET /v1/automations/triggers` lists them. */ -function kitTriggerRows() { - return KIT_KEYS.map((key, index) => ({ - triggerEventId: `tri_${index}`, - key, - title: titleOf(key), - provider: 'brew_api', - payloadSchema: { - type: 'object', - fields: [ - { key: 'email', type: 'string', required: true }, - { key: 'appName', type: 'string', required: false }, - { key: 'userName', type: 'string', required: false }, - ...(key.startsWith('auth.') - ? [ - { key: 'confirmationUrl', type: 'string', required: false }, - { key: 'otp', type: 'string', required: false }, - ] - : []), - ], - }, - createdAt: '2026-10-13T12:00:00.000Z', - updatedAt: '2026-10-13T12:00:00.000Z', - })) -} - -/** Brew's v1 API for init: brand, domains, kit, triggers, the by-key fire. */ -function useBrewApi( - options: { - readonly domains?: ReadonlyArray> - readonly failKeys?: readonly string[] - /** A kit key's refusal, by key (beats `failKeys`' INTERNAL_ERROR). */ - readonly refusals?: Readonly< - Record - > - /** `warnings` on the `POST /v1/domains` row. */ - readonly addWarnings?: ReadonlyArray> - /** Kit keys someone unpublished in Brew, which the kit leaves off. */ - readonly unpublished?: 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( - contractHandler(API, kitTriggerRows), - http.get(`${API}/v1/brand`, async ({ request }) => { - await record(request) - return HttpResponse.json({ - brandId: BRAND.brandId, - domain: 'acme.com', - status: 'completed', - ready: true, - identity: { brandName: BRAND.name }, - }) - }), - http.get(`${API}/v1/domains`, async ({ request }) => { - await record(request) - return HttpResponse.json({ - data: options.domains ?? [], - pagination: { limit: 100, cursor: null, hasMore: false }, - }) - }), - http.post(`${API}/v1/domains`, async ({ request }) => { - const body = (await record(request)) as { name: string } - return HttpResponse.json( - { - domainId: 'dom_new', - domainUrl: 'https://brew.new/domains/dom_new', - name: body.name, - region: 'us-east-1', - status: 'not_started', - sendingEnabled: false, - sendable: false, - sendingPurpose: 'transactional', - records: [ - { - record: 'DKIM', - name: `brew._domainkey.${body.name}`, - type: 'TXT', - ttl: 'Auto', - status: 'not_started', - value: 'p=MIGf…', - }, - ], - createdAt: '2026-10-13T12:00:00.000Z', - updatedAt: '2026-10-13T12:00:00.000Z', - ...(options.addWarnings === undefined - ? {} - : { warnings: options.addWarnings }), - }, - { status: 201 } - ) - }), - http.post(`${API}/v1/app-emails/kit`, async ({ request }) => { - await record(request) - return HttpResponse.json({ - results: KIT_KEYS.map((key, index) => - options.refusals?.[key] !== undefined - ? { triggerKey: key, ok: false, error: options.refusals[key] } - : options.failKeys?.includes(key) - ? { - triggerKey: key, - ok: false, - error: { code: 'INTERNAL_ERROR', message: 'Import failed.' }, - } - : { - ok: true, - triggerKey: key, - triggerEventId: `tri_${index}`, - emailId: `eml_${index}`, - emailVersionId: `emv_${index}`, - automationId: `aut_${index}`, - published: !options.unpublished?.includes(key), - 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.unpublished?.includes(key) - ? [{ code: 'APP_EMAIL_NOT_PUBLISHED', message: 'Off.' }] - : [], - } - ), - stack: { - provider: 'better-auth', - authDelivery: 'code', - note: 'Wire the callbacks into betterAuth.', - keys: KIT_KEYS.filter((key) => key.startsWith('auth.')), - snippets: [], - }, - }) - }), - http.get(`${API}/v1/automations/triggers`, async ({ request }) => { - await record(request) - return HttpResponse.json({ - data: kitTriggerRows(), - pagination: { limit: 100, cursor: null, hasMore: false }, - }) - }), - http.post( - `${API}/v1/automations/triggers/by-key/:key/fire`, - async ({ request, params }) => { - await record(request) - return HttpResponse.json( - { - triggerInstanceId: 'tin_1', - triggerEventId: 'tri_3', - triggerKey: String(params.key), - status: 'triggered', - automationRunIds: ['run_1'], - notStarted: [], - publishedAutomations: [{ automationId: 'aut_3' }], - counts: { automations: 1, skipped: 0 }, - warnings: [], - receivedAt: '2026-10-13T12:00:00.000Z', - }, - { status: 202 } - ) - } - ) - ) - return requests -} - -type SpawnCall = { - readonly command: string - readonly args: readonly string[] - readonly cwd: string | undefined -} - -/** git is "not a repository" (the .gitignore fallback); installs succeed. */ -function recordingSpawn(): { - readonly calls: SpawnCall[] - readonly spawn: SpawnFn -} { - const calls: SpawnCall[] = [] - return { - calls, - spawn: async (command, args, options) => { - if (command === 'git') { - return { code: 128, stdout: '', stderr: 'fatal: not a git repository' } - } - calls.push({ command, args, cwd: options?.cwd }) - return { code: 0, stdout: '', stderr: '' } - }, - } -} - -function configEnv( - extra: Record = {} -): Record { - return { - BREW_CLI_CONFIG_DIR: mkdtempSync(join(tmpdir(), 'brew-cli-init-')), - ...extra, - } -} - -async function init( - argv: readonly string[], - options: RunCliOptions & { readonly cwd: string } -): Promise { - const result = await runCli(['init', ...argv], options) - // Secret canary: no key ever reaches the terminal, in any mode. - for (const secret of SECRETS) { - expect(result.stdout).not.toContain(secret) - expect(result.stderr).not.toContain(secret) - } - return { ...result, report: result.json as InitReport } -} - -function expectValidReport(report: InitReport): void { - expect(validateSchema(REPORT_SCHEMA, report)).toEqual([]) -} - -function read(root: string, path: string): string { - return readFileSync(join(root, path), 'utf8') -} - -const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']) describe('init: confirmation and dry run', () => { it('exits 4 off a terminal without --yes, with the re-run command', async () => { @@ -883,6 +616,10 @@ describe('init --yes: the full run', () => { const ids = result.report.nextSteps.map((step) => step.id) expect(ids).not.toContain('add-dns-records') expect(result.report.domain.records).toEqual([]) + // Connect automatically would publish the same clashing DKIM record. + expect(ids).not.toContain('connect-dns-automatically') + expect(result.report.domain.connectUrl).toBeNull() + expect(renderReport(result.report)).not.toContain('Connect automatically') expect( result.report.nextSteps.find((step) => step.id === 'dkim-selector-in-use') ).toEqual({ diff --git a/tests/commands/integrations-connect.test.ts b/tests/commands/integrations-connect.test.ts new file mode 100644 index 00000000..18eed533 --- /dev/null +++ b/tests/commands/integrations-connect.test.ts @@ -0,0 +1,437 @@ +import { mkdtempSync } 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 { server } from '../helpers/msw-server' +import { + type RunCliOptions, + type RunCliResult, + runCli, +} from '../helpers/run-cli' + +const KEY = 'brew_abcdefghijklmnopqrstuvwxyz012345' +const API = 'https://brew.new/api' +const SUPABASE_SECRET = + 'v1,whsec_dGhpc2lzYW5leGFtcGxlc2VjcmV0Zm9yYnJldzEyMzQ1Ng==' +const CLERK_SECRET = 'whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD' +const SECRETS = [KEY, SUPABASE_SECRET, CLERK_SECRET, 'C2FVsBQIhrscChlQIMV'] + +async function cli( + argv: readonly string[], + options: RunCliOptions = {} +): Promise { + const result = await runCli(argv, { + ...options, + env: { + BREW_CLI_CONFIG_DIR: mkdtempSync(join(tmpdir(), 'brew-cli-test-')), + BREW_API_KEY: KEY, + ...options.env, + }, + }) + // Secret canary: a signing secret never reaches the terminal. + for (const secret of SECRETS) { + expect(result.stdout).not.toContain(secret) + expect(result.stderr).not.toContain(secret) + } + return result +} + +type Seen = { + method: string + path: string + body: unknown + idempotencyKey: string | null +} + +function useConnectionApi( + options: { readonly getStatus?: number; readonly putStatus?: number } = {} +): Seen[] { + const seen: Seen[] = [] + const connection = (provider: string) => ({ + provider, + connectionId: `conn_${provider}`, + webhookUrl: `https://brew.new/api/integrations/${provider}/webhook/conn_${provider}`, + status: 'pending_verification', + }) + server.use( + http.get( + `${API}/v1/integrations/:provider/connection`, + ({ request, params }) => { + seen.push({ + method: 'GET', + path: new URL(request.url).pathname, + body: undefined, + idempotencyKey: request.headers.get('idempotency-key'), + }) + if (options.getStatus === 404) { + return HttpResponse.json( + { + error: { + code: 'NOT_FOUND', + type: 'not_found_error', + message: 'Not connected.', + }, + }, + { status: 404 } + ) + } + return HttpResponse.json({ + ...connection(String(params.provider)), + status: 'active', + }) + } + ), + http.put( + `${API}/v1/integrations/:provider/connection`, + async ({ request, params }) => { + seen.push({ + method: 'PUT', + path: new URL(request.url).pathname, + body: await request.json(), + idempotencyKey: request.headers.get('idempotency-key'), + }) + if (options.putStatus === 400) { + // An API that echoes what it got must still not leak it. + return HttpResponse.json( + { + error: { + code: 'INVALID_REQUEST', + type: 'invalid_request_error', + message: 'The signing secret is not valid for this provider.', + }, + }, + { status: 400 } + ) + } + if (options.putStatus === 409) { + return HttpResponse.json( + { + error: { + code: 'INTEGRATION_CONNECTED_IN_APP', + type: 'conflict', + message: + 'The provider is connected on its integration page in Brew with another signing secret; the API connect never replaces that connection.', + suggestion: + 'Rotate the signing secret on the integration page, or send the secret the connection already has.', + details: { provider: String(params.provider) }, + }, + }, + { status: 409 } + ) + } + return HttpResponse.json({ + ...connection(String(params.provider)), + authEmails: [ + { triggerKey: 'auth.verify-email', ok: true }, + { + triggerKey: 'auth.otp', + ok: false, + error: { code: 'INTERNAL_ERROR', message: 'Import failed.' }, + }, + ], + }) + } + ) + ) + return seen +} + +describe('integrations connect', () => { + it('connects supabase_auth with the secret from an env var and prints the webhook URL', async () => { + const seen = useConnectionApi() + const result = await cli( + [ + 'integrations', + 'connect', + 'supabase_auth', + '--signing-secret-env', + 'HOOK_SECRET', + '--project-url', + 'https://abcdefghijklmnopqrst.supabase.co', + '--yes', + '--json', + ], + { env: { HOOK_SECRET: SUPABASE_SECRET } } + ) + // auth.otp failed to set up: the connection stands, the exit says so. + expect(result.code).toBe(1) + expect(seen).toEqual([ + { + method: 'PUT', + path: '/api/v1/integrations/supabase_auth/connection', + body: { + signingSecret: SUPABASE_SECRET, + settings: { projectUrl: 'https://abcdefghijklmnopqrst.supabase.co' }, + }, + idempotencyKey: null, + }, + ]) + expect(result.json).toMatchObject({ + provider: 'supabase_auth', + connectionId: 'conn_supabase_auth', + webhookUrl: + 'https://brew.new/api/integrations/supabase_auth/webhook/conn_supabase_auth', + status: 'pending_verification', + }) + }) + + it('reads a Clerk secret from stdin; the human output names the URL, the failed key, and what to paste', async () => { + const seen = useConnectionApi() + const result = await cli( + ['integrations', 'connect', 'clerk', '--signing-secret-stdin', '--yes'], + { stdin: `${CLERK_SECRET}\n`, ttyOut: true } + ) + expect(result.code).toBe(1) + expect(seen[0]?.body).toEqual({ signingSecret: CLERK_SECRET }) + expect(result.stdout).toContain( + 'Webhook URL: https://brew.new/api/integrations/clerk/webhook/conn_clerk' + ) + // Set up is not sending: a key unpublished in Brew stays off, and the + // connect response says nothing about it. + expect(result.stdout).toContain( + 'Auth emails: 1 set up, 1 failed: auth.otp (INTERNAL_ERROR) (connect again to finish them)' + ) + expect(result.stdout).toContain( + '`brew-cli app-emails list` shows which auth emails send: one unpublished in Brew stays off, and the sandbox reaches your team only.' + ) + expect(result.stdout).toContain('Clerk Dashboard') + }) + + it('is destructive: off a terminal without --yes it exits 4 before reading the secret or calling the API', async () => { + const seen = useConnectionApi() + const result = await cli( + [ + 'integrations', + 'connect', + 'supabase_auth', + '--signing-secret-env', + 'HOOK_SECRET', + '--json', + ], + { env: { HOOK_SECRET: SUPABASE_SECRET } } + ) + expect(result.code).toBe(4) + expect(seen).toEqual([]) + expect(result.json).toMatchObject({ + confirmationRequired: true, + confirmCommand: + 'brew-cli integrations connect --signing-secret-env=HOOK_SECRET --json --yes -- supabase_auth', + }) + }) + + it('without --yes, a secret pasted as the variable name is exit 2, never a confirmCommand that prints it', async () => { + const seen = useConnectionApi() + const result = await cli([ + 'integrations', + 'connect', + 'clerk', + '--signing-secret-env', + CLERK_SECRET.replace('+', ''), + '--json', + ]) + expect(result.code).toBe(2) + expect(seen).toEqual([]) + expect(result.stdout + result.stderr).toContain( + 'takes an environment variable NAME' + ) + }) + + it('--signing-secret-stdin stops at --timeout when the pipe never closes', async () => { + const seen = useConnectionApi() + const result = await cli( + [ + 'integrations', + 'connect', + 'clerk', + '--signing-secret-stdin', + '--timeout', + '20ms', + '--yes', + '--json', + ], + { + readStdin: (signal) => + new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal.reason), { + once: true, + }) + }), + } + ) + expect(result.code).toBe(1) + expect(JSON.parse(result.stderr)).toMatchObject({ + error: { code: 'CLI_TIMEOUT' }, + }) + expect(seen).toEqual([]) + }) + + it.each([ + [ + 'no secret source', + ['integrations', 'connect', 'clerk', '--yes'], + {}, + 'never a flag value', + ], + [ + 'an unset variable', + [ + 'integrations', + 'connect', + 'clerk', + '--signing-secret-env', + 'NOPE', + '--yes', + ], + {}, + 'NOPE is not set', + ], + [ + 'a secret in the wrong shape (a Clerk secret for Supabase)', + [ + 'integrations', + 'connect', + 'supabase_auth', + '--signing-secret-env', + 'HOOK_SECRET', + '--yes', + ], + { HOOK_SECRET: CLERK_SECRET }, + 'does not look like a Supabase Send Email hook secret', + ], + [ + 'the secret itself as the variable name', + [ + 'integrations', + 'connect', + 'clerk', + '--signing-secret-env', + CLERK_SECRET.replace('+', ''), + '--yes', + ], + {}, + 'takes an environment variable NAME', + ], + [ + 'both sources', + [ + 'integrations', + 'connect', + 'clerk', + '--signing-secret-env', + 'X', + '--signing-secret-stdin', + '--yes', + ], + { X: CLERK_SECRET }, + 'not both', + ], + [ + 'an unknown provider', + [ + 'integrations', + 'connect', + 'stripe', + '--signing-secret-env', + 'X', + '--yes', + ], + { X: CLERK_SECRET }, + 'supabase_auth or clerk', + ], + [ + '--project-url for Clerk', + [ + 'integrations', + 'connect', + 'clerk', + '--signing-secret-env', + 'X', + '--project-url', + 'https://abcdefghijklmnopqrst.supabase.co', + '--yes', + ], + { X: CLERK_SECRET }, + 'supabase_auth only', + ], + ])('exits 2 with no request on %s', async (_name, argv, env, message) => { + const seen = useConnectionApi() + const result = await cli([...argv, '--json'], { env }) + expect(result.code).toBe(2) + expect(seen).toEqual([]) + expect(result.stdout + result.stderr).toContain(message) + }) + + it('a 400 from the API exits 1 with its envelope, and the secret stays out of it', async () => { + useConnectionApi({ putStatus: 400 }) + const result = await cli( + [ + 'integrations', + 'connect', + 'clerk', + '--signing-secret-env', + 'X', + '--yes', + '--json', + ], + { env: { X: CLERK_SECRET } } + ) + expect(result.code).toBe(1) + expect(result.stdout + result.stderr).toContain('INVALID_REQUEST') + }) + + it('a Clerk connected on its Brew integration page with another secret exits 1 with 409 INTEGRATION_CONNECTED_IN_APP, sent once', async () => { + const seen = useConnectionApi({ putStatus: 409 }) + const result = await cli( + [ + 'integrations', + 'connect', + 'clerk', + '--signing-secret-env', + 'X', + '--yes', + '--json', + ], + { env: { X: CLERK_SECRET } } + ) + expect(result.code).toBe(1) + expect(seen.filter((entry) => entry.method === 'PUT')).toHaveLength(1) + // stderr: the progress line, then the API envelope. + const envelope = JSON.parse( + result.stderr.slice(result.stderr.indexOf('{')) + ) as { + error: { code: string; suggestion?: string } + } + expect(envelope.error.code).toBe('INTEGRATION_CONNECTED_IN_APP') + expect(envelope.error.suggestion).toContain('integration page') + }) +}) + +describe('integrations connection', () => { + it('prints the connection', async () => { + const seen = useConnectionApi() + const result = await cli(['integrations', 'connection', 'clerk', '--json']) + expect(result.code).toBe(0) + expect(seen.map((entry) => `${entry.method} ${entry.path}`)).toEqual([ + 'GET /api/v1/integrations/clerk/connection', + ]) + expect(result.json).toEqual({ + provider: 'clerk', + connectionId: 'conn_clerk', + webhookUrl: 'https://brew.new/api/integrations/clerk/webhook/conn_clerk', + status: 'active', + }) + }) + + it('a provider that is not connected is 404 NOT_FOUND, exit 1', async () => { + useConnectionApi({ getStatus: 404 }) + const result = await cli([ + 'integrations', + 'connection', + 'supabase_auth', + '--json', + ]) + expect(result.code).toBe(1) + expect(result.stdout + result.stderr).toContain('NOT_FOUND') + }) +}) diff --git a/tests/helpers/init-api.ts b/tests/helpers/init-api.ts new file mode 100644 index 00000000..212c977a --- /dev/null +++ b/tests/helpers/init-api.ts @@ -0,0 +1,458 @@ +import { mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { HttpResponse, http } from 'msw' +import { expect } from 'vitest' +import type { InitReport } from '../../src/lib/init/report' +import type { SpawnFn } from '../../src/lib/types' +import { contractHandler } from './contract-artifacts' +import { loadSchema, validateSchema } from './json-schema' +import { APP_KEY, BRAND, CLI_KEY } from './login-server' +import { server } from './msw-server' +import { type RunCliOptions, type RunCliResult, runCli } from './run-cli' + +/** + * Brew's v1 API as `init` uses it, plus the run helpers its suites share + * (`tests/commands/init*.test.ts`): a recording MSW handler set, a spawn + * that installs nothing, a throwaway config dir, and an `init` runner that + * checks the secret canary on every run. + */ + +export const API = 'https://brew.new/api' +export const EXPLICIT_KEY = 'brew_explicit0123456789abcdefghijklmno' +export const SECRETS = [APP_KEY, CLI_KEY, EXPLICIT_KEY] +export const REPORT_SCHEMA = loadSchema('init-report.json') + +export const KIT_KEYS = [ + 'auth.verify-email', + 'auth.password-reset', + 'auth.magic-link', + 'lifecycle.welcome', +] + +export type Recorded = { + readonly method: string + readonly path: string + readonly body: unknown + readonly idempotencyKey: string | null + readonly brandId: string | null + readonly authorization: string | null +} + +export function titleOf(key: string): string { + return key + .split(/[.-]/) + .slice(1) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') +} + +/** The kit's trigger rows, as `GET /v1/automations/triggers` lists them. */ +export 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. */ +export 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> + /** Kit keys someone unpublished in Brew, which the kit leaves off. */ + readonly unpublished?: readonly string[] + /** `GET /v1/brand`'s website (default acme.com). */ + readonly brandDomain?: string + /** `GET /v1/brand`'s `ready` (default true). */ + readonly brandReady?: boolean + /** `GET /v1/brand` answers 400 BRAND_ID_REQUIRED (an organization key). */ + readonly orgKeyWithoutBrand?: boolean + /** Brand-bound keys: `GET /v1/brand` with one of them answers its brand. */ + readonly keyBrands?: Readonly> + + /** `GET /v1/brands` rows (default: the signed-in brand only). */ + readonly brands?: ReadonlyArray> + /** `POST /v1/brands` answers this status (default 201). */ + readonly createBrandStatus?: number + /** Per provider: the connection `GET …/connection` returns (absent: 404). */ + readonly connections?: Partial< + Record<'supabase_auth' | 'clerk', Record> + > + /** Keys whose app email reaches the team only (sandbox). */ + readonly teamOnlyKeys?: readonly string[] + /** Auth keys `PUT …/connection` answers `ok: false` for. */ + readonly connectFailKeys?: readonly string[] + /** `GET /v1/app-emails` rows (default: every kit key, published). */ + readonly appEmails?: ReadonlyArray> + } = {} +): Recorded[] { + const requests: Recorded[] = [] + const record = async (request: Request): Promise => { + const text = await request.text() + const body = text === '' ? undefined : (JSON.parse(text) as unknown) + requests.push({ + method: request.method, + path: new URL(request.url).pathname.replace(/^\/api/, ''), + body, + idempotencyKey: request.headers.get('idempotency-key'), + brandId: request.headers.get('x-brand-id'), + authorization: request.headers.get('authorization'), + }) + return body + } + server.use( + contractHandler(API, kitTriggerRows), + http.get(`${API}/v1/brand`, async ({ request }) => { + await record(request) + const keyBrand = + options.keyBrands?.[ + (request.headers.get('authorization') ?? '').replace(/^Bearer /, '') + ] + if ( + options.orgKeyWithoutBrand === true && + request.headers.get('x-brand-id') === null && + keyBrand === undefined + ) { + return HttpResponse.json( + { + error: { + code: 'BRAND_ID_REQUIRED', + type: 'invalid_request_error', + message: 'Organization keys must send X-Brand-Id.', + }, + }, + { status: 400 } + ) + } + return HttpResponse.json({ + brandId: request.headers.get('x-brand-id') ?? keyBrand ?? BRAND.brandId, + domain: options.brandDomain ?? 'acme.com', + status: options.brandReady === false ? 'extracting' : 'completed', + ready: options.brandReady ?? true, + identity: { brandName: BRAND.name }, + }) + }), + http.get(`${API}/v1/domains`, async ({ request }) => { + await record(request) + return HttpResponse.json({ + data: options.domains ?? [], + pagination: { limit: 100, cursor: null, hasMore: false }, + }) + }), + http.post(`${API}/v1/domains`, async ({ request }) => { + const body = (await record(request)) as { name: string } + return HttpResponse.json( + { + domainId: 'dom_new', + domainUrl: 'https://brew.new/domains/dom_new', + name: body.name, + region: 'us-east-1', + status: 'not_started', + sendingEnabled: false, + sendable: false, + sendingPurpose: 'transactional', + records: [ + { + record: 'DKIM', + name: `brew._domainkey.${body.name}`, + type: 'TXT', + ttl: 'Auto', + status: 'not_started', + value: 'p=MIGf…', + }, + ], + createdAt: '2026-10-13T12:00:00.000Z', + updatedAt: '2026-10-13T12:00:00.000Z', + ...(options.addWarnings === undefined + ? {} + : { warnings: options.addWarnings }), + }, + { status: 201 } + ) + }), + http.get( + `${API}/v1/integrations/:provider/connection`, + async ({ request, params }) => { + await record(request) + const connection = + options.connections?.[ + String(params.provider) as 'supabase_auth' | 'clerk' + ] + if (connection === undefined) { + return HttpResponse.json( + { + error: { + code: 'NOT_FOUND', + type: 'not_found_error', + message: `${String(params.provider)} is not connected for this brand.`, + }, + }, + { status: 404 } + ) + } + return HttpResponse.json(connection) + } + ), + http.put( + `${API}/v1/integrations/:provider/connection`, + async ({ request, params }) => { + await record(request) + const provider = String(params.provider) + return HttpResponse.json({ + provider, + connectionId: `conn_${provider}`, + webhookUrl: webhookUrlFor(provider), + status: 'pending_verification', + authEmails: KIT_KEYS.filter((key) => key.startsWith('auth.')).map( + (triggerKey) => + options.connectFailKeys?.includes(triggerKey) === true + ? { + triggerKey, + ok: false, + error: { code: 'INTERNAL_ERROR', message: 'Failed.' }, + } + : { triggerKey, ok: true } + ), + }) + } + ), + http.get(`${API}/v1/app-emails`, async ({ request }) => { + await record(request) + return HttpResponse.json({ + data: + options.appEmails ?? + KIT_KEYS.map((key, index) => ({ + triggerKey: key, + triggerEventId: `tri_${index}`, + title: titleOf(key), + emailId: `eml_${index}`, + automationId: `aut_${index}`, + published: true, + readiness: { + ready: true, + teamOnly: options.teamOnlyKeys?.includes(key) === true, + blockers: [], + }, + })), + pagination: { limit: 100, cursor: null, hasMore: false }, + }) + }), + http.get(`${API}/v1/brands`, async ({ request }) => { + await record(request) + return HttpResponse.json({ + data: options.brands ?? [ + { + brandId: BRAND.brandId, + domain: options.brandDomain ?? 'acme.com', + status: 'completed', + ready: true, + }, + ], + pagination: { limit: 100, cursor: null, hasMore: false }, + }) + }), + http.post(`${API}/v1/brands`, async ({ request }) => { + const body = (await record(request)) as { url: string } + const status = options.createBrandStatus ?? 201 + if (status !== 201) { + return HttpResponse.json( + { + error: { + code: status === 403 ? 'ORG_SCOPE_REQUIRED' : 'INTERNAL_ERROR', + type: status === 403 ? 'permission_error' : 'internal_error', + message: + status === 403 + ? 'Creating a brand needs an organization-scoped key.' + : 'Failed.', + }, + }, + { status } + ) + } + return HttpResponse.json( + { + brandId: 'brand_new', + domain: new URL(body.url).hostname, + status: 'extracting', + ready: false, + progress: 0, + extraction: { chatId: 'chat_1', statusUrl: '/v1/brands/brand_new' }, + }, + { status: 201 } + ) + }), + http.get(`${API}/v1/brands/:brandId`, async ({ request, params }) => { + await record(request) + return HttpResponse.json({ + brandId: String(params.brandId), + domain: 'app.example.dev', + status: 'completed', + ready: true, + progress: 100, + }) + }), + http.post(`${API}/v1/app-emails/kit`, async ({ request }) => { + await record(request) + return HttpResponse.json({ + results: KIT_KEYS.map((key, index) => + options.refusals?.[key] !== undefined + ? { triggerKey: key, ok: false, error: options.refusals[key] } + : options.failKeys?.includes(key) + ? { + triggerKey: key, + ok: false, + error: { code: 'INTERNAL_ERROR', message: 'Import failed.' }, + } + : { + ok: true, + triggerKey: key, + triggerEventId: `tri_${index}`, + emailId: `eml_${index}`, + emailVersionId: `emv_${index}`, + automationId: `aut_${index}`, + published: !options.unpublished?.includes(key), + 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.unpublished?.includes(key) + ? [{ code: 'APP_EMAIL_NOT_PUBLISHED', message: 'Off.' }] + : [], + } + ), + stack: { + provider: 'better-auth', + authDelivery: 'code', + note: 'Wire the callbacks into betterAuth.', + keys: KIT_KEYS.filter((key) => key.startsWith('auth.')), + snippets: [], + }, + }) + }), + http.get(`${API}/v1/automations/triggers`, async ({ request }) => { + await record(request) + return HttpResponse.json({ + data: kitTriggerRows(), + pagination: { limit: 100, cursor: null, hasMore: false }, + }) + }), + http.post( + `${API}/v1/automations/triggers/by-key/:key/fire`, + async ({ request, params }) => { + await record(request) + return HttpResponse.json( + { + triggerInstanceId: 'tin_1', + triggerEventId: 'tri_3', + triggerKey: String(params.key), + status: 'triggered', + automationRunIds: ['run_1'], + notStarted: [], + publishedAutomations: [{ automationId: 'aut_3' }], + counts: { automations: 1, skipped: 0 }, + warnings: [], + receivedAt: '2026-10-13T12:00:00.000Z', + }, + { status: 202 } + ) + } + ) + ) + return requests +} + +export type SpawnCall = { + readonly command: string + readonly args: readonly string[] + readonly cwd: string | undefined +} + +/** git is "not a repository" (the .gitignore fallback); installs succeed. */ +export 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: '' } + }, + } +} + +export function configEnv( + extra: Record = {} +): Record { + return { + BREW_CLI_CONFIG_DIR: mkdtempSync(join(tmpdir(), 'brew-cli-init-')), + ...extra, + } +} + +export 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 } +} + +export function expectValidReport(report: InitReport): void { + expect(validateSchema(REPORT_SCHEMA, report)).toEqual([]) +} + +export function read(root: string, path: string): string { + return readFileSync(join(root, path), 'utf8') +} + +export const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']) + +/** The hosted hook URL the mocked connect returns. */ +export function webhookUrlFor(provider: string): string { + return `https://brew.new/api/integrations/${provider}/webhook/conn_${provider}` +} diff --git a/tests/helpers/init-providers.ts b/tests/helpers/init-providers.ts new file mode 100644 index 00000000..5a2fad80 --- /dev/null +++ b/tests/helpers/init-providers.ts @@ -0,0 +1,190 @@ +import { existsSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { HttpResponse, http } from 'msw' +import { expect } from 'vitest' +import { copyFixture } from './fixtures' +import { type Recorded, read, webhookUrlFor } from './init-api' +import { APP_KEY } from './login-server' +import { server } from './msw-server' + +/** Supabase's Management API and Clerk's Backend API for init's provider step. */ + +export const SUPABASE = 'https://api.supabase.com' +export const CLERK = 'https://api.clerk.com/v1' +export const REF = 'abcdefghijklmnopqrst' +export const SB_TOKEN = 'sbp_0123456789abcdefghijklmnopqrstuvwxyz0123' +export const CLERK_SECRET_KEY = 'sk_test_clerkSecret0123456789abcdefghijkl' +export const OLD_HOOK_SECRET = + 'v1,whsec_b2xkaG9va3NlY3JldGZvcmFub3RoZXJmdW5jdGlvbg==' +export const OLD_HOOK_URI = + 'https://abcdefghijklmnopqrst.supabase.co/functions/v1/send-email' + +export type VendorCall = { + readonly method: string + readonly path: string + readonly body: unknown + readonly authorization: string | null +} + +export type HookState = { + enabled: boolean + uri: string | null + secrets: string | null +} + +/** The Management API's auth config, holding the Send Email hook. */ +export function useSupabaseApi( + initial: HookState, + options: { + readonly patchStatus?: number + readonly beforePatch?: () => void + } = {} +): { readonly calls: VendorCall[]; readonly hook: HookState } { + const hook = { ...initial } + const calls: VendorCall[] = [] + server.use( + http.get(`${SUPABASE}/v1/projects/:ref/config/auth`, ({ request }) => { + calls.push({ + method: 'GET', + path: new URL(request.url).pathname, + body: undefined, + authorization: request.headers.get('authorization'), + }) + return HttpResponse.json({ + site_url: 'http://localhost:3000', + hook_send_email_enabled: hook.enabled, + hook_send_email_uri: hook.uri, + hook_send_email_secrets: hook.secrets, + }) + }), + http.patch( + `${SUPABASE}/v1/projects/:ref/config/auth`, + async ({ request }) => { + const body = (await request.json()) as Record + calls.push({ + method: 'PATCH', + path: new URL(request.url).pathname, + body, + authorization: request.headers.get('authorization'), + }) + options.beforePatch?.() + if (options.patchStatus !== undefined) { + // A vendor that echoes the secret back must not leak it. + return HttpResponse.json( + { + message: `Invalid hook secret ${String(body.hook_send_email_secrets)} for token ${SB_TOKEN}`, + }, + { status: options.patchStatus } + ) + } + if (typeof body.hook_send_email_enabled === 'boolean') { + hook.enabled = body.hook_send_email_enabled + } + if ('hook_send_email_uri' in body) { + hook.uri = body.hook_send_email_uri as string | null + } + if (typeof body.hook_send_email_secrets === 'string') { + hook.secrets = body.hook_send_email_secrets + } + return HttpResponse.json({}) + } + ) + ) + return { calls, hook } +} + +/** Clerk's email templates and their "Delivered by Clerk" switch. */ +export function useClerkApi(delivered: Record): { + readonly calls: VendorCall[] + readonly delivered: Record +} { + const calls: VendorCall[] = [] + const state = { ...delivered } + server.use( + http.get(`${CLERK}/templates/email/:slug`, ({ request, params }) => { + const slug = String(params.slug) + calls.push({ + method: 'GET', + path: new URL(request.url).pathname, + body: undefined, + authorization: request.headers.get('authorization'), + }) + if (!(slug in state)) { + return HttpResponse.json( + { errors: [{ message: 'not found', code: 'resource_not_found' }] }, + { status: 404 } + ) + } + return HttpResponse.json({ + object: 'template', + slug, + delivered_by_clerk: state[slug], + }) + }), + http.post( + `${CLERK}/templates/email/:slug/toggle_delivery`, + async ({ request, params }) => { + const body = (await request.json()) as { delivered_by_clerk: boolean } + calls.push({ + method: 'POST', + path: new URL(request.url).pathname, + body, + authorization: request.headers.get('authorization'), + }) + state[String(params.slug)] = body.delivered_by_clerk + return HttpResponse.json({ + object: 'template', + slug: String(params.slug), + delivered_by_clerk: body.delivered_by_clerk, + }) + } + ) + ) + return { calls, delivered: state } +} + +/** Every secret stays out of stdout, stderr and brew.json. */ +export function expectNoSecrets( + root: string, + result: { readonly stdout: string; readonly stderr: string }, + secrets: readonly string[] +): void { + const state = existsSync(join(root, 'brew.json')) + ? read(root, 'brew.json') + : '' + for (const secret of secrets) { + expect(result.stdout).not.toContain(secret) + expect(result.stderr).not.toContain(secret) + expect(state).not.toContain(secret) + } +} + +/** No Brew request ever carries a provider token. */ +export function expectBrewNeverSaw( + requests: readonly Recorded[], + secrets: readonly string[] +): void { + const text = JSON.stringify(requests) + for (const secret of secrets) { + expect(text).not.toContain(secret) + } +} + +export function supabaseProject(): string { + const root = copyFixture('vite-supabase-lovable') + writeFileSync(join(root, '.env.local'), `BREW_API_KEY=${APP_KEY}\n`) + return root +} + +export function clerkProject(): string { + const root = copyFixture('react-router-clerk') + writeFileSync(join(root, '.env.local'), `BREW_API_KEY=${APP_KEY}\n`) + return root +} + +export const ACTIVE_CLERK = { + provider: 'clerk', + connectionId: 'conn_clerk', + webhookUrl: webhookUrlFor('clerk'), + status: 'active', +} diff --git a/tests/lib/init/providers.test.ts b/tests/lib/init/providers.test.ts new file mode 100644 index 00000000..97914f89 --- /dev/null +++ b/tests/lib/init/providers.test.ts @@ -0,0 +1,114 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { brandMatchesApp, normalizeHost } from '../../../src/lib/init/brand' +import { readClerkSecret } from '../../../src/lib/init/providers/clerk' +import { + detectSupabaseRef, + generateHookSecret, +} from '../../../src/lib/init/providers/supabase' +import { tempProject } from '../../helpers/fixtures' + +const REF = 'abcdefghijklmnopqrst' + +describe('detectSupabaseRef', () => { + it('prefers the flag, then supabase link, then a SUPABASE_URL, then Lovable files', () => { + const root = tempProject() + expect(detectSupabaseRef({ root, env: {}, flag: undefined })).toBeNull() + writeFileSync( + join(root, '.env'), + `VITE_SUPABASE_URL=https://${REF}.supabase.co\n` + ) + expect(detectSupabaseRef({ root, env: {}, flag: undefined })).toEqual({ + ref: REF, + source: '.env VITE_SUPABASE_URL', + }) + expect( + detectSupabaseRef({ + root, + env: { + NEXT_PUBLIC_SUPABASE_URL: 'https://zyxwvutsrqponmlkjihg.supabase.co', + }, + flag: undefined, + })?.ref + ).toBe('zyxwvutsrqponmlkjihg') + mkdirSync(join(root, 'supabase/.temp'), { recursive: true }) + writeFileSync( + join(root, 'supabase/.temp/project-ref'), + 'qwertyuiopasdfghjklz\n' + ) + expect(detectSupabaseRef({ root, env: {}, flag: undefined })?.ref).toBe( + 'qwertyuiopasdfghjklz' + ) + expect( + detectSupabaseRef({ root, env: {}, flag: 'mnbvcxzlkjhgfdsapoiu' }) + ).toEqual({ + ref: 'mnbvcxzlkjhgfdsapoiu', + source: '--supabase-project', + }) + }) + + it("reads Lovable's client, and a config.toml project_id only when it is shaped like a ref", () => { + const root = tempProject() + mkdirSync(join(root, 'supabase')) + writeFileSync( + join(root, 'supabase/config.toml'), + 'project_id = "my-local-app"\n' + ) + expect(detectSupabaseRef({ root, env: {}, flag: undefined })).toBeNull() + writeFileSync(join(root, 'supabase/config.toml'), `project_id = "${REF}"\n`) + expect(detectSupabaseRef({ root, env: {}, flag: undefined })?.source).toBe( + 'supabase/config.toml project_id' + ) + mkdirSync(join(root, 'src/integrations/supabase'), { recursive: true }) + writeFileSync( + join(root, 'src/integrations/supabase/client.ts'), + 'const SUPABASE_URL = "https://lkjhgfdsapoiuytrewqm.supabase.co";\n' + ) + expect(detectSupabaseRef({ root, env: {}, flag: undefined })).toEqual({ + ref: 'lkjhgfdsapoiuytrewqm', + source: 'src/integrations/supabase/client.ts', + }) + }) +}) + +describe('generateHookSecret', () => { + it('is a fresh Standard Webhooks secret each time', () => { + const first = generateHookSecret() + expect(first).toMatch(/^v1,whsec_[A-Za-z0-9+/]{43}=$/) + expect(generateHookSecret()).not.toBe(first) + }) +}) + +describe('readClerkSecret', () => { + it('reads the shell first, then .env.local, then .env', () => { + const root = tempProject() + expect(readClerkSecret({ root, env: {} })).toBeUndefined() + writeFileSync(join(root, '.env'), 'CLERK_SECRET_KEY=sk_test_fromenv\n') + expect(readClerkSecret({ root, env: {} })).toBe('sk_test_fromenv') + writeFileSync( + join(root, '.env.local'), + 'CLERK_SECRET_KEY=sk_test_fromlocal\n' + ) + expect(readClerkSecret({ root, env: {} })).toBe('sk_test_fromlocal') + expect( + readClerkSecret({ root, env: { CLERK_SECRET_KEY: 'sk_test_shell' } }) + ).toBe('sk_test_shell') + }) +}) + +describe('brand matching', () => { + it('normalizes hosts and matches the brand website or its subdomains only', () => { + expect(normalizeHost('https://www.Acme.com/pricing')).toBe('acme.com') + expect(normalizeHost('acme.com')).toBe('acme.com') + expect(normalizeHost('')).toBeNull() + expect(brandMatchesApp('acme.com', 'https://acme.com')).toBe(true) + expect(brandMatchesApp('acme.com', 'https://app.acme.com')).toBe(true) + expect(brandMatchesApp('https://www.acme.com', 'https://acme.com/x')).toBe( + true + ) + expect(brandMatchesApp('acme.com', 'https://notacme.com')).toBe(false) + expect(brandMatchesApp('app.acme.com', 'https://acme.com')).toBe(false) + expect(brandMatchesApp(null, 'https://acme.com')).toBe(false) + }) +}) diff --git a/tests/parity-body-flags.test.ts b/tests/parity-body-flags.test.ts index 158bfa83..7d1113c4 100644 --- a/tests/parity-body-flags.test.ts +++ b/tests/parity-body-flags.test.ts @@ -150,6 +150,11 @@ const MAPPED: Readonly< '--no-publish': 'publish', }, 'app-emails kit': { '--domain': 'domainId' }, + 'integrations connect': { + '--signing-secret-env': 'signingSecret', + '--signing-secret-stdin': 'signingSecret', + '--project-url': 'settings.projectUrl', + }, } type Command = (typeof ALL_COMMANDS)[number] diff --git a/tests/parity-spec.test.ts b/tests/parity-spec.test.ts index 6034909c..2e85bf54 100644 --- a/tests/parity-spec.test.ts +++ b/tests/parity-spec.test.ts @@ -8,16 +8,8 @@ import { SPEC_SKIP_LIST } from '../src/skip-list' /** * Spec operations whose commands are still being built, phase by phase. * This list must ONLY shrink; it must be empty before 0.1.0 ships. - * - * The bundled spec is brew-v2 #2169 whole, which also carries the hosted - * auth hook connection routes. Their commands (`integrations connect`, - * `integrations connection`) land in GetBrew/brew-cli#63, which empties - * this list again. */ -const PENDING_BUILD_ROUTES: readonly string[] = [ - 'GET /v1/integrations/{provider}/connection', - 'PUT /v1/integrations/{provider}/connection', -] +const PENDING_BUILD_ROUTES: readonly string[] = [] /** * Commands built AHEAD of the platform: the payload-contract wave-2 API