From c616767a1e62e95eb61b7022b0831383cd747db8 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sat, 17 Oct 2026 04:12:32 -0400 Subject: [PATCH 01/44] feat(integrations): `integrations connect` and `integrations connection` for hosted auth hooks `integrations connect ` binds the SDK's `integrations.connect` (typescript-sdk#74, planned 13.3.0): it connects the hosted auth email hook for the brand, sets up its auth app emails and prints `{ connectionId, webhookUrl, status, authEmails }`. Destructive class (a new secret replaces the old one, so deliveries signed with it stop verifying). The signing secret comes from `--signing-secret-env ` or `--signing-secret-stdin`, never a flag value; a secret in the wrong shape, or pasted where the variable name goes, is a usage error that never echoes it. `--project-url` (supabase_auth only) sets the base of confirmation links. Long-running route: the SDK's 300 s default. `integrations connection ` binds `integrations.getConnection`: `pending_verification` until the first signed delivery, then `active`; 404 NOT_FOUND when not connected. Co-Authored-By: Claude Opus 5.5 --- src/commands/integrations/connect.ts | 128 ++++++++ src/commands/integrations/connection.ts | 34 ++ src/lib/integrations/hosted-auth.ts | 137 ++++++++ src/lib/long-running.ts | 15 + src/registry.ts | 4 + tests/commands/integrations-connect.test.ts | 341 ++++++++++++++++++++ 6 files changed, 659 insertions(+) create mode 100644 src/commands/integrations/connect.ts create mode 100644 src/commands/integrations/connection.ts create mode 100644 src/lib/integrations/hosted-auth.ts create mode 100644 tests/commands/integrations-connect.test.ts diff --git a/src/commands/integrations/connect.ts b/src/commands/integrations/connect.ts new file mode 100644 index 00000000..41e9e56c --- /dev/null +++ b/src/commands/integrations/connect.ts @@ -0,0 +1,128 @@ +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 { + 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 }) => + `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) + return { + data: result, + human: renderConnected(result, getInvocationPrefix(ctx.io.env)), + } + }, +}) + +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} ready${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], + ] + 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/lib/integrations/hosted-auth.ts b/src/lib/integrations/hosted-auth.ts new file mode 100644 index 00000000..327901fe --- /dev/null +++ b/src/lib/integrations/hosted-auth.ts @@ -0,0 +1,137 @@ +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#2146): 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.3.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 +} + +/** `--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) { + // A secret pasted where the NAME goes must not be echoed back. + if ( + !/^[A-Za-z_][A-Za-z0-9_]*$/.test(input.envName) || + /^whsec_|^sk_|^sbp_/i.test(input.envName) || + input.envName.length > 64 + ) { + throw new CliUsageError( + `--signing-secret-env takes an environment variable NAME (e.g. SEND_EMAIL_HOOK_SECRET), not the secret.` + ) + } + 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(), '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..44419d10 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.3.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/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/integrations-connect.test.ts b/tests/commands/integrations-connect.test.ts new file mode 100644 index 00000000..7271bc32 --- /dev/null +++ b/tests/commands/integrations-connect.test.ts @@ -0,0 +1,341 @@ +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 } + ) + } + 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 } } + ) + expect(result.code).toBe(0) + 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(0) + expect(seen[0]?.body).toEqual({ signingSecret: CLERK_SECRET }) + expect(result.stdout).toContain( + 'Webhook URL: https://brew.new/api/integrations/clerk/webhook/conn_clerk' + ) + expect(result.stdout).toContain('1 failed: auth.otp (INTERNAL_ERROR)') + 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 supabase_auth --signing-secret-env HOOK_SECRET --json --yes', + }) + }) + + 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') + }) +}) + +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') + }) +}) From a46351a14e4791151f98886557b0a55c8a8dcb00 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sat, 17 Oct 2026 04:12:46 -0400 Subject: [PATCH 02/44] feat(init): provider switch for Supabase and Clerk, brand from --app-url, DNS connect link Provider step (`src/lib/init/providers/`). Nothing about an auth provider changes under a bare `--yes`: - `--configure-supabase` (destructive with init; `SUPABASE_ACCESS_TOKEN` read locally and sent to api.supabase.com only) reads the project's auth config, saves the Send Email hook settings to `brew.json#providerConfig` BEFORE changing anything (a previous hook secret goes to `.env.local`, never brew.json), connects Brew with a freshly generated `v1,whsec_` secret (`integrations.connect`), then PATCHes `/v1/projects/{ref}/config/auth` (`hook_send_email_enabled`, `hook_send_email_uri`, `hook_send_email_secrets`; Supabase Management API reference cited in code). A hook already on Brew is left alone. `--dry-run --json` lists the exact requests with the secret replaced. The ref comes from `--supabase-project`, `supabase link`, a `*SUPABASE_URL`, or Lovable's files. - `--configure-clerk` (`CLERK_SECRET_KEY` from the shell or .env.local) turns off Clerk's delivery (Backend API `POST /templates/email/{slug}/toggle_delivery`) for exactly the `CLERK_EMAIL_SLUG_ROUTES` slugs whose key has a published app email, only once Brew's Clerk connection is `active`; otherwise it refuses. - `--rollback-provider` restores what either changed, from brew.json. - Without the flags: manual steps; the self-hosted Supabase function (and its local config.toml patch) become the optional alternative. `--app-url` with an organization key picks the brand whose website is the app, or creates one (`POST /v1/brands`, idempotent) and waits for it (`--no-wait` defers the kit to a re-run); a 403 becomes a next step, and a brand-bound key is never moved. `--domain` adds the domain page link (Connect automatically runs in the app; v1 has no API for it). Report (schema v1, additive): `providers[]`, the `provider` step, `brand.domain|matchesAppUrl|created`, `domain.connectUrl`. vendorFetch redacts body secrets too. init's shared test helpers move to tests/helpers/init-api.ts. Co-Authored-By: Claude Opus 5.5 --- schemas/init-report.json | 65 +- src/commands/init.ts | 71 +- src/lib/init/brand.ts | 284 ++++++++ src/lib/init/pipeline.ts | 340 ++++++++-- src/lib/init/providers/clerk.ts | 132 ++++ src/lib/init/providers/rollback.ts | 307 +++++++++ src/lib/init/providers/step.ts | 944 ++++++++++++++++++++++++++ src/lib/init/providers/supabase.ts | 204 ++++++ src/lib/init/report.ts | 43 ++ src/lib/init/templates.ts | 4 +- src/lib/project/vendor-http.ts | 7 +- tests/commands/init-app-url.test.ts | 293 ++++++++ tests/commands/init-providers.test.ts | 887 ++++++++++++++++++++++++ tests/commands/init.test.ts | 296 +------- tests/helpers/init-api.ts | 432 ++++++++++++ tests/lib/init/providers.test.ts | 114 ++++ 16 files changed, 4089 insertions(+), 334 deletions(-) create mode 100644 src/lib/init/brand.ts create mode 100644 src/lib/init/providers/clerk.ts create mode 100644 src/lib/init/providers/rollback.ts create mode 100644 src/lib/init/providers/step.ts create mode 100644 src/lib/init/providers/supabase.ts create mode 100644 tests/commands/init-app-url.test.ts create mode 100644 tests/commands/init-providers.test.ts create mode 100644 tests/helpers/init-api.ts create mode 100644 tests/lib/init/providers.test.ts 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/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/lib/init/brand.ts b/src/lib/init/brand.ts new file mode 100644 index 00000000..8b8e03bf --- /dev/null +++ b/src/lib/init/brand.ts @@ -0,0 +1,284 @@ +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 + /** 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)) { + return { + brand: { ...current, matchesAppUrl: true, created: false }, + ready: true, + step: { + id: 'brand', + status: 'done', + detail: `${current.name ?? current.brandId} (matches ${host})`, + }, + nextSteps, + } + } + 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 { + let cursor: string | undefined + for (let page = 0; page < 10 && match === undefined; page += 1) { + 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) + ) + if (!result.pagination.hasMore || result.pagination.cursor === null) { + break + } + cursor = result.pagination.cursor + } + } catch (error) { + ctx.signal.throwIfAborted() + if (isForbidden(error)) { + throw error + } + return mismatch( + `Could not list brands (${describe(error)}); pick the brand for ${host} with --brand ` + ) + } + 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({ + id: 'brand-extracting', + actor: 'agent', + kind: 'run-command', + command: input.rerunCommand, + title: `Brew is still reading ${host} to build the brand (1 to 3 minutes); run init again when it is ready`, + blocking: true, + }) + } + 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, + } +} + +/** 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..c4bbde94 100644 --- a/src/lib/init/pipeline.ts +++ b/src/lib/init/pipeline.ts @@ -33,6 +33,7 @@ import { type ResolvedAuth, resolveAuth, } from '../client' +import { systemClock } from '../clock' import { readConfig, updateConfig } from '../config-store' import { buildConfirmCommand } from '../confirm' import { sdkTransportFor } from '../context' @@ -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,21 @@ 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 (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') @@ -619,14 +652,80 @@ 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 // ---- Brand. - if (brand !== null) { + if ( + options.appUrl !== undefined && + client !== undefined && + credential.auth !== undefined + ) { + let current: AppBrand | null = null + 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, + } + } 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) { + steps.push({ id: 'brand', status: 'failed', detail: lookupError }) + } else { + const resolution = await resolveAppBrand({ + ctx, + client, + appUrl: options.appUrl, + current, + 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) + 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,6 +770,7 @@ export async function runInit( added: false, records: [], settingsUrl: `${siteUrl}/domains`, + connectUrl: null, sandbox: null, } if (client === undefined) { @@ -716,6 +816,7 @@ export async function runInit( domain.domainId = shown.domainId domain.name = shown.name domain.status = shown.status + domain.connectUrl = shown.domainUrl ?? `${siteUrl}/domains` // A conflicting DKIM record is never shown: publishing it would // replace the key another sender signs with. if (shown.status !== 'verified' && selectorInUse === undefined) { @@ -771,6 +872,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 +908,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: 'the brand is still being built; run init again when it is ready', + }) + } else if (client === undefined || dryRun) { steps.push({ id: 'kit', status: 'planned', @@ -955,12 +1072,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) @@ -1109,7 +1226,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 +1251,58 @@ 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 (stack === 'supabase' || stack === 'clerk') { + if (!brandReady) { + steps.push({ + id: 'provider', + status: 'skipped', + detail: 'after the brand is ready', + }) + } else { + const result = await runProviderStep({ + ctx, + root: project.root, + stack, + dryRun, + configureSupabase: options.configureSupabase === true, + configureClerk: options.configureClerk === true, + supabaseProject: options.supabaseProject, + client, + writer, + prefix, + providerConfig, + // brew.json holds the provider's old settings BEFORE they change. + persist: (config) => { + stateWrittenEarly = true + writer.writeFile( + BREW_STATE_FILE, + serializeBrewState( + createBrewState({ + ...(state ?? {}), + projectId, + providerConfig: config, + }), + stateRead?.extra ?? {} + ), + { overwrite: true } + ) + }, + 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 +1447,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 +1460,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 +1470,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 +1493,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 +1540,7 @@ export async function runInit( files, patches, mcp, + providers, testEmail, paths, keys, @@ -1513,34 +1702,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 +1945,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 +2025,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 +2116,7 @@ function ambiguousReport( files: [], patches: [], mcp: [], + providers: [], testEmail: { status: 'skipped', key: null, to: null }, steps: [ { @@ -1962,6 +2135,85 @@ 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({ + 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..4412ade4 --- /dev/null +++ b/src/lib/init/providers/clerk.ts @@ -0,0 +1,132 @@ +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#2068). Delivery is turned off only for a slug whose key + * has a published app email in the brand. + */ +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 +} + +/** `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..f396ed34 --- /dev/null +++ b/src/lib/init/providers/rollback.ts @@ -0,0 +1,307 @@ +import { CliUsageError } from '../../errors' +import { progress } from '../../output' +import { getEnvValue, parseEnv } from '../../project/env-file' +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, + clerkToggleUrl, + readClerkSecret, + setClerkDelivery, +} from './clerk' +import type { InitProvider, ProviderConfig, ProviderRequest } from './step' +import { + 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 +} + +function localValue( + ctx: CliContext, + 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 +} + +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 }, + } + const secretMissing = + previous.hook_send_email_enabled && previousSecrets === undefined + if (secretMissing) { + nextSteps.push({ + id: 'restore-supabase-hook-secret', + actor: 'human', + kind: 'open-url', + url: supabaseHooksUrl(supabase.projectRef), + title: + "Re-enter the previous Send Email hook's secret in Supabase (Authentication > Hooks): this machine did not keep it", + blocking: true, + }) + } + if (token === undefined) { + problems.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, + }) + providers.push({ + provider: 'supabase_auth', + action: 'refused', + connection: null, + projectRef: supabase.projectRef, + previous, + requests: [], + rollbackCommand: null, + detail: `${SUPABASE_TOKEN_VARIABLE} is not set`, + }) + } 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 }, + })) + if (secretKey === undefined) { + problems.push(`${CLERK_SECRET_VARIABLE} not found`) + nextSteps.push({ + id: 'clerk-secret-key', + actor: 'human', + kind: 'set-secret', + title: `Make ${CLERK_SECRET_VARIABLE} available in this shell or .env.local, then run the rollback again (or turn "Delivered by Clerk" back on in the 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: `${CLERK_SECRET_VARIABLE} not found`, + }) + } 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: { savedAt: clerk.savedAt, 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..caf7d0cf --- /dev/null +++ b/src/lib/init/providers/step.ts @@ -0,0 +1,944 @@ +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 { ensureEnvVars, getEnvValue, parseEnv } from '../../project/env-file' +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, + clerkToggleUrl, + readClerkDelivery, + readClerkSecret, + setClerkDelivery, +} from './clerk' +import { + detectSupabaseRef, + generateHookSecret, + patchSendEmailHook, + REDACTED_SECRET, + readSendEmailHook, + type SendEmailHookBody, + type SendEmailHookConfig, + SUPABASE_PREVIOUS_SECRETS_VARIABLE, + SUPABASE_TOKEN_VARIABLE, + 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 + /** 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 + readonly providerConfig: ProviderConfig + /** Writes `brew.json` with this provider config now (before a change). */ + readonly persist: (config: ProviderConfig) => void + readonly now: () => number +} + +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, + } +} + +/** A local token: the shell first, then the app's `.env.local`. */ +function localToken( + input: Pick, + name: string +): string | undefined { + const fromEnv = input.ctx.io.env[name]?.trim() + if (fromEnv !== undefined && fromEnv !== '') { + return fromEnv + } + const text = input.writer.read('.env.local') + const value = + text === null ? undefined : getEnvValue(parseEnv(text), name)?.trim() + return value === undefined || value === '' ? undefined : value +} + +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 + 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 --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: `${prefix} init --configure-supabase${ref === null ? ' --supabase-project ' : ''} --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: `${prefix} 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 = localToken(input, 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, + } + } + 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 + if (hook.config.hook_send_email_enabled && hook.secrets !== null) { + // The replaced hook's secret stays on this machine, git-ignored, so a + // rollback can put the old hook back whole. Never in brew.json. + const envText = input.writer.read('.env.local') + const { text } = ensureEnvVars(envText, [ + { name: SUPABASE_PREVIOUS_SECRETS_VARIABLE, value: hook.secrets }, + ]) + const change = input.writer.writeFile('.env.local', text, { + overwrite: true, + mode: 0o600, + sensitive: true, + }) + if (change.action !== 'refused') { + previousSecretsVariable = SUPABASE_PREVIOUS_SECRETS_VARIABLE + } + } + providerConfig = { + ...providerConfig, + supabase_auth: { + projectRef: ref, + savedAt: new Date(input.now()).toISOString(), + previous: hook.config, + ...(previousSecretsVariable === undefined + ? {} + : { previousSecretsVariable }), + }, + } + input.persist(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 + ) + } + 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 + ) + } + const failedKeys = connected.authEmails.filter((email) => !email.ok) + 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\` 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, + } + ) + if (failedKeys.length > 0) { + nextSteps.push({ + id: 'rerun-supabase-connect', + actor: 'agent', + kind: 'run-command', + command: `${prefix} init --configure-supabase --yes`, + title: `Run again to finish the auth emails that failed (${failedKeys.map((email) => email.triggerKey).join(', ')}); until then Supabase sign-ups that need them fail`, + blocking: true, + }) + } + 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 --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\` turns active`, + blocking: false, + }) + } + } + if (!input.configureClerk) { + connectSteps() + if (connection?.status === 'active') { + nextSteps.push({ + id: 'configure-clerk-delivery', + actor: 'agent', + kind: 'run-command', + command: `${prefix} 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 + ) + } + // Exactly the templates Brew sends: a slug whose key has a published app + // email in this brand. + let published: Set + try { + published = await publishedKeys(input.client) + } catch (error) { + rethrowIfFatal(ctx, error) + return failed(input, 'clerk', describe(error), undefined, connection) + } + const slugs = Object.entries(CLERK_EMAIL_SLUG_KEYS) + .filter(([, key]) => published.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 published Brew app email matches a Clerk template; 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(), + templates: [ + ...new Set([ + ...(input.providerConfig.clerk?.templates ?? []), + ...toTurnOff, + ]), + ].sort(), + }, + } + input.persist(providerConfig) + 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, + } +} + +async function publishedKeys(client: BrewClient): Promise> { + const keys = new Set() + let cursor: string | undefined + for (let page = 0; page < 20; page += 1) { + const result = await client.appEmails.list({ + limit: 100, + ...(cursor === undefined ? {} : { cursor }), + }) + for (const row of result.data) { + if (row.published) { + keys.add(row.triggerKey) + } + } + if (!result.pagination.hasMore || result.pagination.cursor === null) { + break + } + cursor = result.pagination.cursor + } + return keys +} + +// ---------------------------------------------------------------- shared + +export function rollbackCommandFor( + input: Pick +): string { + return `${input.prefix} 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..26c96d5e --- /dev/null +++ b/src/lib/init/providers/supabase.ts @@ -0,0 +1,204 @@ +import { randomBytes } from 'node:crypto' +import { join } from 'node:path' +import { getEnvValue, parseEnv } from '../../project/env-file' +import { 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 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/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/tests/commands/init-app-url.test.ts b/tests/commands/init-app-url.test.ts new file mode 100644 index 00000000..ccbab1d9 --- /dev/null +++ b/tests/commands/init-app-url.test.ts @@ -0,0 +1,293 @@ +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { copyFixture } from '../helpers/fixtures' +import { + configEnv, + EXPLICIT_KEY, + expectValidReport, + init, + recordingSpawn, + useBrewApi, +} from '../helpers/init-api' +import { APP_KEY, BRAND, fakeClock } from '../helpers/login-server' + +const APP_URL = 'https://my-app.lovable.app' + +/** An app with its key in .env.local, so no sign-in runs. */ +function project(): string { + const root = copyFixture('express-resend') + writeFileSync(join(root, '.env.local'), `BREW_API_KEY=${APP_KEY}\n`) + return root +} + +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, and the kit lands there', async () => { + const root = project() + const requests = useBrewApi({ + orgKeyWithoutBrand: true, + brands: [ + { + brandId: BRAND.brandId, + domain: 'acme.com', + status: 'completed', + ready: true, + }, + { + 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, + } + ) + 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) + const kit = requests.find( + (request) => request.path === '/v1/app-emails/kit' + ) + expect(kit?.brandId).toBe('brand_lovable') + expect(kit?.authorization).toBe(`Bearer ${EXPLICIT_KEY}`) + }) + + 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.find((request) => request.path === '/v1/app-emails/kit')?.brandId + ).toBe('brand_new') + + // 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('--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-providers.test.ts b/tests/commands/init-providers.test.ts new file mode 100644 index 00000000..1fe56c4e --- /dev/null +++ b/tests/commands/init-providers.test.ts @@ -0,0 +1,887 @@ +import { existsSync, readFileSync, 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 { + configEnv, + expectValidReport, + init, + type Recorded, + read, + recordingSpawn, + useBrewApi, + webhookUrlFor, +} from '../helpers/init-api' +import { APP_KEY } from '../helpers/login-server' +import { server } from '../helpers/msw-server' + +const SUPABASE = 'https://api.supabase.com' +const CLERK = 'https://api.clerk.com/v1' +const REF = 'abcdefghijklmnopqrst' +const SB_TOKEN = 'sbp_0123456789abcdefghijklmnopqrstuvwxyz0123' +const CLERK_SECRET_KEY = 'sk_test_clerkSecret0123456789abcdefghijkl' +const OLD_HOOK_SECRET = 'v1,whsec_b2xkaG9va3NlY3JldGZvcmFub3RoZXJmdW5jdGlvbg==' +const OLD_HOOK_URI = + 'https://abcdefghijklmnopqrst.supabase.co/functions/v1/send-email' + +type VendorCall = { + readonly method: string + readonly path: string + readonly body: unknown + readonly authorization: string | null +} + +type HookState = { + enabled: boolean + uri: string | null + secrets: string | null +} + +/** The Management API's auth config, holding the Send Email hook. */ +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. */ +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. */ +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. */ +function expectBrewNeverSaw( + requests: readonly Recorded[], + secrets: readonly string[] +): void { + const text = JSON.stringify(requests) + for (const secret of secrets) { + expect(text).not.toContain(secret) + } +} + +function supabaseProject(): string { + const root = copyFixture('vite-supabase-lovable') + writeFileSync(join(root, '.env.local'), `BREW_API_KEY=${APP_KEY}\n`) + return root +} + +function clerkProject(): string { + const root = copyFixture('react-router-clerk') + writeFileSync(join(root, '.env.local'), `BREW_API_KEY=${APP_KEY}\n`) + return root +} + +const ACTIVE_CLERK = { + provider: 'clerk', + connectionId: 'conn_clerk', + webhookUrl: webhookUrlFor('clerk'), + status: 'active', +} + +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('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) + } + }) +}) diff --git a/tests/commands/init.test.ts b/tests/commands/init.test.ts index 95e050f3..66db8e51 100644 --- a/tests/commands/init.test.ts +++ b/tests/commands/init.test.ts @@ -1,28 +1,32 @@ 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' import { pendingLoginPath, readPendingLogin, } from '../../src/lib/auth-flow/pending' import { readConfig, writeConfig } from '../../src/lib/config-store' -import type { InitReport } from '../../src/lib/init/report' import { validateBrewState } from '../../src/lib/project/state' -import type { SpawnFn } from '../../src/lib/types' -import { contractHandler } from '../helpers/contract-artifacts' import { copyFixture, tempProject } from '../helpers/fixtures' -import { loadSchema, validateSchema } from '../helpers/json-schema' +import { + configEnv, + EXPLICIT_KEY, + expectValidReport, + init, + KIT_KEYS, + MUTATING, + type Recorded, + read, + recordingSpawn, + useBrewApi, +} from '../helpers/init-api' import { APP_KEY, APPROVER_EMAIL, @@ -34,282 +38,6 @@ import { USER_CODE, useLoginServer, } from '../helpers/login-server' -import { server } from '../helpers/msw-server' -import { - type RunCliOptions, - type RunCliResult, - runCli, -} from '../helpers/run-cli' - -const API = 'https://brew.new/api' -const EXPLICIT_KEY = 'brew_explicit0123456789abcdefghijklmno' -const SECRETS = [APP_KEY, CLI_KEY, EXPLICIT_KEY] -const REPORT_SCHEMA = loadSchema('init-report.json') - -const KIT_KEYS = [ - 'auth.verify-email', - 'auth.password-reset', - 'auth.magic-link', - 'lifecycle.welcome', -] - -type Recorded = { - readonly method: string - readonly path: string - readonly body: unknown - readonly idempotencyKey: string | null - readonly brandId: string | null - readonly authorization: string | null -} - -function titleOf(key: string): string { - return key - .split(/[.-]/) - .slice(1) - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' ') -} - -/** The kit's trigger rows, as `GET /v1/automations/triggers` lists them. */ -function kitTriggerRows() { - return KIT_KEYS.map((key, index) => ({ - triggerEventId: `tri_${index}`, - key, - title: titleOf(key), - provider: 'brew_api', - payloadSchema: { - type: 'object', - fields: [ - { key: 'email', type: 'string', required: true }, - { key: 'appName', type: 'string', required: false }, - { key: 'userName', type: 'string', required: false }, - ...(key.startsWith('auth.') - ? [ - { key: 'confirmationUrl', type: 'string', required: false }, - { key: 'otp', type: 'string', required: false }, - ] - : []), - ], - }, - createdAt: '2026-10-13T12:00:00.000Z', - updatedAt: '2026-10-13T12:00:00.000Z', - })) -} - -/** Brew's v1 API for init: brand, domains, kit, triggers, the by-key fire. */ -function useBrewApi( - options: { - readonly domains?: ReadonlyArray> - readonly failKeys?: readonly string[] - /** A kit key's refusal, by key (beats `failKeys`' INTERNAL_ERROR). */ - readonly refusals?: Readonly< - Record - > - /** `warnings` on the `POST /v1/domains` row. */ - readonly addWarnings?: ReadonlyArray> - /** 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 () => { diff --git a/tests/helpers/init-api.ts b/tests/helpers/init-api.ts new file mode 100644 index 00000000..be1d01f8 --- /dev/null +++ b/tests/helpers/init-api.ts @@ -0,0 +1,432 @@ +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` answers 400 BRAND_ID_REQUIRED (an organization key). */ + readonly orgKeyWithoutBrand?: boolean + /** `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> + > + /** `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) + if ( + options.orgKeyWithoutBrand === true && + request.headers.get('x-brand-id') === null + ) { + 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') ?? BRAND.brandId, + domain: options.brandDomain ?? '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.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) => ({ 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, + })), + 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/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) + }) +}) From 53c9e2e3ff852ce4552c7026fbd80b537618697f Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sat, 17 Oct 2026 04:12:46 -0400 Subject: [PATCH 03/44] feat(doctor): `doctor --project` checks an app against its brew.json Managed file hashes, BREW_API_KEY in .env.local (git-ignored, never under a NEXT_PUBLIC_/VITE_/PUBLIC_/EXPO_PUBLIC_ name in any env file), the SDK declared and installed at the range the generated files need, and with the app key: the key's brand is brew.json's, every key is set up and published (GET /v1/app-emails), a verified sending domain, and the hosted auth hook's status (GET connection). ok/warn/fail/skipped per check with a fix; any fail exits 1, no brew.json exits 2; `--json`. Co-Authored-By: Claude Opus 5.5 --- src/commands/doctor.ts | 64 ++- src/lib/doctor/project.ts | 616 ++++++++++++++++++++++++++ tests/commands/doctor-project.test.ts | 381 ++++++++++++++++ 3 files changed, 1058 insertions(+), 3 deletions(-) create mode 100644 src/lib/doctor/project.ts create mode 100644 tests/commands/doctor-project.test.ts 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/lib/doctor/project.ts b/src/lib/doctor/project.ts new file mode 100644 index 00000000..1aedb8ff --- /dev/null +++ b/src/lib/doctor/project.ts @@ -0,0 +1,616 @@ +import { readdirSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { BrewApiError } from '@brew.new/sdk' +import { + type BrewClient, + buildSdkClient, + maskApiKey, + type ResolvedAuth, + resolveAuth, +} from '../client' +import { sdkTransportFor } from '../context' +import { CliApiError, CliUsageError } from '../errors' +import { reachesSdkRange, SDK_PACKAGE, SDK_RANGE } from '../init/templates' +import { + findConnection, + type HostedAuthProvider, +} from '../integrations/hosted-auth' +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 cannot run); 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) +} + +function isAuthError(error: unknown): boolean { + return ( + (error instanceof BrewApiError || error instanceof CliApiError) && + (error.status === 401 || error.status === 403) + ) +} + +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 envLocalKey = (() => { + const text = readTextFile(join(root, '.env.local')) + const value = + text === null ? undefined : getEnvValue(parseEnv(text), APP_KEY_VARIABLE) + return value === undefined || value.trim() === '' ? undefined : value.trim() + })() + 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[] = [] + 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' || !isClientExposedName(line.key)) { + continue + } + const value = getEnvValue(parseEnv(text), line.key)?.trim() ?? '' + const looksLikeKey = + /BREW/i.test(line.key) || + value.startsWith('brew_') || + (envLocalKey !== undefined && value === envLocalKey) + if (looksLikeKey) { + exposed.push(`${file}: ${line.key}`) + } + } + } + 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 (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, 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 + 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) { + return { + apiKey: input.envLocalKey, + apiKeySource: 'env', + brandId: ctx.globals.brand, + apiUrl: resolved.apiUrl, + } + } + 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', + } + } + const rows = new Map() + try { + let cursor: string | undefined + for (let page = 0; page < 20; page += 1) { + const result = await client.appEmails.list({ + limit: 100, + ...(cursor === undefined ? {} : { cursor }), + }) + for (const row of result.data) { + rows.set(row.triggerKey, { + triggerEventId: row.triggerEventId, + published: row.published, + }) + } + if (!result.pagination.hasMore || result.pagination.cursor === null) { + break + } + cursor = result.pagination.cursor + } + } catch (error) { + ctx.signal.throwIfAborted() + return { + id: 'app-emails', + status: isAuthError(error) ? 'fail' : 'skipped', + 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 unpublished = keys.filter((key) => rows.get(key)?.published === 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 (unpublished.length > 0) { + return { + id: 'app-emails', + status: 'fail', + detail: `${unpublished.length} key(s) are not published: sends answer NO_PUBLISHED_AUTOMATION`, + items: unpublished, + fix: 'Publish them in Brew, or run `app-emails upsert --yes`.', + } + } + 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 published`, + } +} + +async function checkDomain( + ctx: CliContext, + client: BrewClient +): Promise { + try { + const list = await client.domains.list({ limit: 100 }) + const sending = list.data.find( + (row) => row.status === 'verified' && row.sendable + ) + if (sending !== undefined) { + return { + id: 'domain', + status: 'ok', + detail: `sending from ${sending.name}`, + } + } + const pending = list.data.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: 'skipped', + 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: isAuthError(error) ? 'fail' : 'skipped', + detail: `${provider}: could not read the connection: ${describe(error)}`, + }) + } + } + return checks +} diff --git a/tests/commands/doctor-project.test.ts b/tests/commands/doctor-project.test.ts new file mode 100644 index 00000000..c2bfbd1d --- /dev/null +++ b/tests/commands/doctor-project.test.ts @@ -0,0 +1,381 @@ +import { mkdirSync, mkdtempSync, 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 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.2.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.2.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.20.0:lib/brew.ts', + }, + }, + ...(options.providerConfig === undefined + ? {} + : { providerConfig: options.providerConfig }), + }, + null, + 2 + )}\n` + ) + return root +} + +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, + }, + { + triggerKey: 'lifecycle.welcome', + triggerEventId: 'tri_1', + published: true, + }, + ], + 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('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('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': '^12.3.0' } }) + ) + const old = await doctor(root) + expect(old.code).toBe(1) + expect( + old.report.checks.find((check) => check.id === 'sdk')?.detail + ).toContain('^12.3.0 is older than the ^13.2.0') + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ dependencies: { '@brew.new/sdk': '^13.2.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, + }, + ], + }) + 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('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('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) + }) +}) From dde08c42a91beaedce077dc58b25b232685a163c Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sat, 17 Oct 2026 04:12:46 -0400 Subject: [PATCH 04/44] docs: init providers, integrations connect and doctor --project in README, skills and CHANGELOG; 0.20.0 CHANGELOG 0.20.0 (not released yet: needs SDK 13.3.0 and brew-v2 #2146 + the brew-cli and brew-app-emails skills (regenerated), the AGENTS.md block's Supabase/Clerk notes, the command reference, and package.json + both launchers at 0.20.0 (`sync:launchers`). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 71 +++++++++++++++++++++++++++++++++ README.md | 63 ++++++++++++++++++++++++++++- RELEASING.md | 15 +++++++ docs/commands/README.md | 58 ++++++++++++++++++++++++--- skills/brew-app-emails/SKILL.md | 16 ++++++-- skills/brew-cli/SKILL.md | 25 ++++++++++++ src/generated/skills.ts | 4 +- 7 files changed, 238 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 881c1f80..66221dd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,76 @@ # Changelog +## 0.23.0 + +Not released yet. Needs `@brew.new/sdk` 13.3.0: `integrations.connect` and +`integrations.getConnection` (GetBrew/typescript-sdk#74, on top of the +13.2.0 that 0.18.0 needs; see the SDK gates in `RELEASING.md`), and the API +from GetBrew/brew-v2#2146 (`GET|PUT /v1/integrations/{provider}/connection`, +the `supabase_auth` hosted Send Email Hook) and #2068 (Clerk `email.created` +sends the app's auth emails). The bundled spec is 0.19.0's plus exactly +those two PRs' spec deltas. + +### 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. +- **`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. `--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 has a published app email in the brand, 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`. +- **`init --rollback-provider`** puts back the Supabase hook settings or + Clerk delivery saved in `brew.json`, and nothing else. +- 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. +- **`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. +- **`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. +- **`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.2.0`, and, + with the app key, the brand (the key's brand is `brew.json`'s), every key + set up and published, a verified sending domain, and the hosted hook's + status. `--json`; any failed check 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 bundled spec carries GetBrew/brew-v2#2146 and #2068. + ## 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..2d133f27 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,66 @@ 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 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`). + `--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 Brew publishes, 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. + +### 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..bc836f9a 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.3.0, after 13.2.0 above). +Release that SDK first, then `bun add @brew.new/sdk@^13.3.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#2146 (and #2068 for Clerk), must be deployed first: +re-fetch `https://brew.new/api/openapi.yaml` and compare the two +`/v1/integrations/{provider}/connection` operations with the bundled spec +before tagging. Generated app code still asks for `^13.2.0` +(`SDK_RANGE`): only the CLI itself needs 13.3.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/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..d0c5014f 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,25 @@ 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). `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/generated/skills.ts b/src/generated/skills.ts index 3ed01141..c2836ee7 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). `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", From b062494f10bf5703565bbda21af6e31c0377e323 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Wed, 21 Oct 2026 01:41:32 +0900 Subject: [PATCH 05/44] chore: 0.21.0 after #61's 0.20.0 (main published 0.17.0) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66221dd3..74212b7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ Not released yet. Needs `@brew.new/sdk` 13.3.0: `integrations.connect` and 13.2.0 that 0.18.0 needs; see the SDK gates in `RELEASING.md`), and the API from GetBrew/brew-v2#2146 (`GET|PUT /v1/integrations/{provider}/connection`, the `supabase_auth` hosted Send Email Hook) and #2068 (Clerk `email.created` -sends the app's auth emails). The bundled spec is 0.19.0's plus exactly +sends the app's auth emails). The bundled spec is 0.20.0's plus exactly those two PRs' spec deltas. ### Added From ccdfa6dd85d84321bfc6759e7c88dac01a5d85d2 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Wed, 21 Oct 2026 13:11:48 +0900 Subject: [PATCH 06/44] fix(integrations,init): adapt to #2169: idempotent connection, spec parity - The connection routes come with #54's vendored #2169 spec, so the `chore(spec)` commit for #2146/#2068 folded away and these commands empty the spec parity test's pending-build list. - README, the brew-cli skill and CHANGELOG say what #2169's idempotent PUT connection guarantees: a re-run keeps `connectionId` and `webhookUrl`, replaces the secret in place, keeps the auth emails set up and finishes the failed ones. - init --domain with a DKIM_SELECTOR_IN_USE warning offers neither the DNS records step nor Connect automatically (test). - RELEASING, CHANGELOG and comments name brew-v2 #2169 (deployed) and the spec-drift sentinel's URL. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 16 ++++++++++------ README.md | 4 +++- RELEASING.md | 4 ++-- skills/brew-cli/SKILL.md | 4 +++- src/generated/skills.ts | 2 +- src/lib/init/providers/clerk.ts | 2 +- src/lib/integrations/hosted-auth.ts | 2 +- tests/commands/app-emails.test.ts | 4 ++-- tests/commands/init.test.ts | 2 ++ tests/parity-spec.test.ts | 10 +--------- 10 files changed, 26 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74212b7d..fec145b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,10 @@ Not released yet. Needs `@brew.new/sdk` 13.3.0: `integrations.connect` and `integrations.getConnection` (GetBrew/typescript-sdk#74, on top of the 13.2.0 that 0.18.0 needs; see the SDK gates in `RELEASING.md`), and the API -from GetBrew/brew-v2#2146 (`GET|PUT /v1/integrations/{provider}/connection`, -the `supabase_auth` hosted Send Email Hook) and #2068 (Clerk `email.created` -sends the app's auth emails). The bundled spec is 0.20.0's plus exactly -those two PRs' spec deltas. +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.18.0's, #2169 @ +`6f8ef50544`. ### Added @@ -21,7 +21,10 @@ those two PRs' spec deltas. 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. + 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, and + keys that failed are finished. - **`brew-cli integrations connection `** reads it: `pending_verification` until the first signed delivery, then `active` (404 `NOT_FOUND` when not connected). @@ -69,7 +72,8 @@ those two PRs' spec deltas. - The init report (schema version 1, additive) gains `providers[]`, the `provider` step, `brand.domain` / `matchesAppUrl` / `created` and `domain.connectUrl`. -- The bundled spec carries GetBrew/brew-v2#2146 and #2068. +- The spec parity test's pending-build list is empty again: these + commands bind the two connection routes 0.18.0's spec already carried. ## 0.22.0 diff --git a/README.md b/README.md index 2d133f27..3022876c 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,9 @@ brew-cli integrations connection clerk # pending_verification until the first `connect` prints `{ connectionId, webhookUrl, status }` (and each auth email it set up): paste `webhookUrl` into Supabase's hook or Clerk's -endpoint. +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, and keys that failed are finished. ### Check a project: `doctor --project` diff --git a/RELEASING.md b/RELEASING.md index bc836f9a..b0534e8c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -61,8 +61,8 @@ arrive in GetBrew/typescript-sdk#74 (planned 13.3.0, after 13.2.0 above). Release that SDK first, then `bun add @brew.new/sdk@^13.3.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#2146 (and #2068 for Clerk), must be deployed first: -re-fetch `https://brew.new/api/openapi.yaml` and compare the two +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.2.0` (`SDK_RANGE`): only the CLI itself needs 13.3.0. diff --git a/skills/brew-cli/SKILL.md b/skills/brew-cli/SKILL.md index d0c5014f..463fecd3 100644 --- a/skills/brew-cli/SKILL.md +++ b/skills/brew-cli/SKILL.md @@ -121,7 +121,9 @@ brew-cli integrations connection clerk # pending_verification until the first 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). `init` can do +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. `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 diff --git a/src/generated/skills.ts b/src/generated/skills.ts index c2836ee7..6b5faefc 100644 --- a/src/generated/skills.ts +++ b/src/generated/skills.ts @@ -20,7 +20,7 @@ export const EMBEDDED_SKILLS: readonly EmbeddedSkill[] = [ 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\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). `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", + 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. `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/init/providers/clerk.ts b/src/lib/init/providers/clerk.ts index 4412ade4..ac7fce5a 100644 --- a/src/lib/init/providers/clerk.ts +++ b/src/lib/init/providers/clerk.ts @@ -33,7 +33,7 @@ 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#2068). Delivery is turned off only for a slug whose key + * (GetBrew/brew-v2#2169). Delivery is turned off only for a slug whose key * has a published app email in the brand. */ export const CLERK_EMAIL_SLUG_KEYS: Readonly> = { diff --git a/src/lib/integrations/hosted-auth.ts b/src/lib/integrations/hosted-auth.ts index 327901fe..6fdb14f4 100644 --- a/src/lib/integrations/hosted-auth.ts +++ b/src/lib/integrations/hosted-auth.ts @@ -10,7 +10,7 @@ import type { CliContext } from '../types' /** * The hosted auth email hooks (`GET|PUT /v1/integrations/{provider}/ - * connection`, GetBrew/brew-v2#2146): the providers that hand an app's auth + * 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.3.0). * 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/init.test.ts b/tests/commands/init.test.ts index 66db8e51..f5cfc682 100644 --- a/tests/commands/init.test.ts +++ b/tests/commands/init.test.ts @@ -611,6 +611,8 @@ 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.nextSteps.find((step) => step.id === 'dkim-selector-in-use') ).toEqual({ 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 From 18b28e3de8de9862ed4918f2b13291f7d5455a33 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 25 Oct 2026 22:31:27 +0900 Subject: [PATCH 07/44] chore(release): providers are 0.22.0; integrations.connect is SDK 13.7.0 After main's published 0.18.0 the DX numbers move up one (#54 0.19.0, #55 0.20.0, #61 0.21.0, this PR 0.22.0), and the SDK's DX releases follow its published 13.3.0 and pending 13.4.0: adapters 13.6.0 (SDK_RANGE), connect 13.7.0. doctor --project's tests use those ranges. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 ++++++------ RELEASING.md | 8 ++++---- src/lib/integrations/hosted-auth.ts | 2 +- src/lib/long-running.ts | 2 +- tests/commands/doctor-project.test.ts | 10 +++++----- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fec145b7..9a290822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,13 @@ ## 0.23.0 -Not released yet. Needs `@brew.new/sdk` 13.3.0: `integrations.connect` and +Not released yet. Needs `@brew.new/sdk` 13.7.0: `integrations.connect` and `integrations.getConnection` (GetBrew/typescript-sdk#74, on top of the -13.2.0 that 0.18.0 needs; see the SDK gates in `RELEASING.md`), and the API +13.6.0 that 0.19.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.18.0's, #2169 @ -`6f8ef50544`. +sending the app's auth emails). The bundled spec is 0.19.0's, the #2169 @ +`6f8ef50544` DX delta on main's spec (brew-v2 `87ac2c6242`). ### Added @@ -65,7 +65,7 @@ sending the app's auth emails). The bundled spec is 0.18.0's, #2169 @ the API. - **`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.2.0`, and, + 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 and published, a verified sending domain, and the hosted hook's status. `--json`; any failed check exits 1, no `brew.json` exits 2. @@ -73,7 +73,7 @@ sending the app's auth emails). The bundled spec is 0.18.0's, #2169 @ `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.18.0's spec already carried. + commands bind the two connection routes 0.19.0's spec already carried. ## 0.22.0 diff --git a/RELEASING.md b/RELEASING.md index b0534e8c..cf900375 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -57,15 +57,15 @@ plus the DX delta). `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.3.0, after 13.2.0 above). -Release that SDK first, then `bun add @brew.new/sdk@^13.3.0` (never +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.2.0` -(`SDK_RANGE`): only the CLI itself needs 13.3.0. +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 diff --git a/src/lib/integrations/hosted-auth.ts b/src/lib/integrations/hosted-auth.ts index 6fdb14f4..1bff514b 100644 --- a/src/lib/integrations/hosted-auth.ts +++ b/src/lib/integrations/hosted-auth.ts @@ -12,7 +12,7 @@ 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.3.0). + * 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 diff --git a/src/lib/long-running.ts b/src/lib/long-running.ts index 44419d10..eb3550f1 100644 --- a/src/lib/long-running.ts +++ b/src/lib/long-running.ts @@ -10,7 +10,7 @@ export const EXPORT_EMAIL_DEFAULT_TIMEOUT_MS = : 800_000 /** - * `integrations.connect` also runs the auth app email kit (SDK 13.3.0's + * `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 = diff --git a/tests/commands/doctor-project.test.ts b/tests/commands/doctor-project.test.ts index c2bfbd1d..dc4b7d69 100644 --- a/tests/commands/doctor-project.test.ts +++ b/tests/commands/doctor-project.test.ts @@ -33,13 +33,13 @@ function healthyProject( join(root, 'package.json'), JSON.stringify({ name: 'app', - dependencies: { '@brew.new/sdk': '^13.2.0' }, + 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.2.1' }) + JSON.stringify({ name: '@brew.new/sdk', version: '13.6.1' }) ) writeFileSync( join(root, 'brew.json'), @@ -265,16 +265,16 @@ describe('doctor --project', () => { useProjectApi({ verifiedDomain: true }) writeFileSync( join(root, 'package.json'), - JSON.stringify({ dependencies: { '@brew.new/sdk': '^12.3.0' } }) + 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('^12.3.0 is older than the ^13.2.0') + ).toContain('^13.3.0 is older than the ^13.6.0') writeFileSync( join(root, 'package.json'), - JSON.stringify({ dependencies: { '@brew.new/sdk': '^13.2.0' } }) + JSON.stringify({ dependencies: { '@brew.new/sdk': '^13.6.0' } }) ) rmSync(join(root, 'node_modules'), { recursive: true }) const notInstalled = await doctor(root) From a4a53d7b54c2ad4b3f950d3b8aed65956251bd68 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sat, 31 Oct 2026 17:21:13 -0400 Subject: [PATCH 08/44] test(integrations): map connect's flags to their body keys; main's rerun format Co-Authored-By: Claude Opus 5.5 --- tests/commands/integrations-connect.test.ts | 2 +- tests/parity-body-flags.test.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/commands/integrations-connect.test.ts b/tests/commands/integrations-connect.test.ts index 7271bc32..38ab4140 100644 --- a/tests/commands/integrations-connect.test.ts +++ b/tests/commands/integrations-connect.test.ts @@ -192,7 +192,7 @@ describe('integrations connect', () => { expect(result.json).toMatchObject({ confirmationRequired: true, confirmCommand: - 'brew-cli integrations connect supabase_auth --signing-secret-env HOOK_SECRET --json --yes', + 'brew-cli integrations connect --signing-secret-env=HOOK_SECRET --json --yes -- supabase_auth', }) }) 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] From 9182d71624043b6ac206e05bdff53e6fce1b33f3 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sat, 31 Oct 2026 17:21:13 -0400 Subject: [PATCH 09/44] chore(release): providers are 0.23.0 after migrate's 0.22.0 npm has CLI 0.19.0 from main, so the DX stack moves up one: #54 0.20.0, #55 0.21.0, #61 0.22.0, this PR 0.23.0 (launchers in lockstep). SDK numbers are unchanged: adapters 13.6.0 (SDK_RANGE), connect 13.7.0. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +++--- package.json | 2 +- packages/launchers/brew.new/package.json | 4 ++-- packages/launchers/create-brew/package.json | 4 ++-- tests/commands/doctor-project.test.ts | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a290822..ea977f5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,11 @@ 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.19.0 needs; see the SDK gates in `RELEASING.md`), and the API +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.19.0's, the #2169 @ -`6f8ef50544` DX delta on main's spec (brew-v2 `87ac2c6242`). +sending the app's auth emails). The bundled spec is 0.20.0's, the #2169 @ +`6f8ef50544` DX delta on main's spec (brew-v2 `373e900a9a`). ### Added 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/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/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/tests/commands/doctor-project.test.ts b/tests/commands/doctor-project.test.ts index dc4b7d69..e5b51ec8 100644 --- a/tests/commands/doctor-project.test.ts +++ b/tests/commands/doctor-project.test.ts @@ -63,7 +63,7 @@ function healthyProject( files: { 'lib/brew.ts': { sha256: sha256Hex(CLIENT_FILE), - generator: 'init@0.20.0:lib/brew.ts', + generator: 'init@0.21.0:lib/brew.ts', }, }, ...(options.providerConfig === undefined From 0e44ea3d44ae2c5c4e7717d50eaa23bebbcc67f1 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sat, 31 Oct 2026 23:01:45 -0400 Subject: [PATCH 10/44] docs(integrations): connect names 409 INTEGRATION_CONNECTED_IN_APP brew-v2 #2169 5084a5202f answers a Clerk connection made on its integration page in Brew with another signing secret with 409 INTEGRATION_CONNECTED_IN_APP; the API never replaces that connection. `integrations connect` already exits 1 with the envelope; a test pins it (one PUT, the suggestion intact, the secret kept out), and the 0.23.0 notes, README and skill say when it happens. The notes name the spec source 0.20.0 now vendors. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++-- README.md | 5 ++- skills/brew-cli/SKILL.md | 3 +- src/generated/skills.ts | 2 +- tests/commands/integrations-connect.test.ts | 42 +++++++++++++++++++++ 5 files changed, 54 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea977f5d..a58189d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,8 @@ Not released yet. Needs `@brew.new/sdk` 13.7.0: `integrations.connect` and 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 @ -`6f8ef50544` DX delta on main's spec (brew-v2 `373e900a9a`). +sending the app's auth emails). The bundled spec is 0.20.0's, the #2169 +head `5084a5202f` (main's spec, brew-v2 `373e900a9a`, plus the DX delta). ### Added @@ -24,7 +24,9 @@ sending the app's auth emails). The bundled spec is 0.20.0's, the #2169 @ 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, and - keys that failed are finished. + keys that failed are finished. 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). diff --git a/README.md b/README.md index 3022876c..cab2e6dd 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,10 @@ brew-cli integrations connection clerk # pending_verification until the first 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, and keys that failed are finished. +auth emails already set up are kept, 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` diff --git a/skills/brew-cli/SKILL.md b/skills/brew-cli/SKILL.md index 463fecd3..ebf23348 100644 --- a/skills/brew-cli/SKILL.md +++ b/skills/brew-cli/SKILL.md @@ -123,7 +123,8 @@ 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. `init` can do +emails that failed (a Clerk connected on its Brew integration page refuses +another secret: `409 INTEGRATION_CONNECTED_IN_APP`, rotate it there). `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 diff --git a/src/generated/skills.ts b/src/generated/skills.ts index 6b5faefc..2dad881b 100644 --- a/src/generated/skills.ts +++ b/src/generated/skills.ts @@ -20,7 +20,7 @@ export const EMBEDDED_SKILLS: readonly EmbeddedSkill[] = [ 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\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. `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", + 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). `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/tests/commands/integrations-connect.test.ts b/tests/commands/integrations-connect.test.ts index 38ab4140..73a22ae2 100644 --- a/tests/commands/integrations-connect.test.ts +++ b/tests/commands/integrations-connect.test.ts @@ -104,6 +104,22 @@ function useConnectionApi( { 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: [ @@ -309,6 +325,32 @@ describe('integrations connect', () => { 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', () => { From 71343711298289a23ebf0417350ad3915e2ce063 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sat, 31 Oct 2026 23:45:30 -0400 Subject: [PATCH 11/44] fix(integrations): check --signing-secret-env before the confirmation; stdin honors --timeout A secret pasted as the variable name was printed in the exit-4 confirmCommand: the name check now runs in confirmSummary, before the gate. --signing-secret-stdin passes ctx.signal to readStdin, so --timeout and interrupts end a read whose pipe never closes. Co-Authored-By: Claude Opus 5.5 --- src/commands/integrations/connect.ts | 7 +++- src/lib/integrations/hosted-auth.ts | 34 ++++++++++----- tests/commands/integrations-connect.test.ts | 46 +++++++++++++++++++++ 3 files changed, 74 insertions(+), 13 deletions(-) diff --git a/src/commands/integrations/connect.ts b/src/commands/integrations/connect.ts index 41e9e56c..b1e006a1 100644 --- a/src/commands/integrations/connect.ts +++ b/src/commands/integrations/connect.ts @@ -6,6 +6,7 @@ import { defineCommand } from '../../lib/define-command' import { CliUsageError } from '../../lib/errors' import { flagString } from '../../lib/input' import { + checkSecretEnvName, type HostedAuthProvider, parseHostedAuthProvider, readSigningSecret, @@ -80,8 +81,10 @@ export const integrationsConnectCommand = defineCommand({ '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 }) => - `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.`, + 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) diff --git a/src/lib/integrations/hosted-auth.ts b/src/lib/integrations/hosted-auth.ts index 1bff514b..be66a0f6 100644 --- a/src/lib/integrations/hosted-auth.ts +++ b/src/lib/integrations/hosted-auth.ts @@ -71,6 +71,23 @@ export function checkSigningSecret( 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, @@ -83,16 +100,7 @@ export async function readSigningSecret( ) } if (input.envName !== undefined) { - // A secret pasted where the NAME goes must not be echoed back. - if ( - !/^[A-Za-z_][A-Za-z0-9_]*$/.test(input.envName) || - /^whsec_|^sk_|^sbp_/i.test(input.envName) || - input.envName.length > 64 - ) { - throw new CliUsageError( - `--signing-secret-env takes an environment variable NAME (e.g. SEND_EMAIL_HOOK_SECRET), not the secret.` - ) - } + checkSecretEnvName(input.envName) const value = ctx.io.env[input.envName] if (value === undefined || value.trim() === '') { throw new CliUsageError( @@ -107,7 +115,11 @@ export async function readSigningSecret( '--signing-secret-stdin reads a pipe (e.g. `printenv SECRET | …`), not the terminal.' ) } - return checkSigningSecret(provider, await ctx.io.readStdin(), 'stdin') + 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]}.` diff --git a/tests/commands/integrations-connect.test.ts b/tests/commands/integrations-connect.test.ts index 73a22ae2..bb58f9a3 100644 --- a/tests/commands/integrations-connect.test.ts +++ b/tests/commands/integrations-connect.test.ts @@ -212,6 +212,52 @@ describe('integrations connect', () => { }) }) + 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', From 986a525086eb607c3b0729590df8193dd4909b5b Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sat, 31 Oct 2026 23:45:35 -0400 Subject: [PATCH 12/44] fix(doctor): warn on a key still in the team-only sandbox A verified domain of the other purpose (marketing only) passed the domain check while auth emails stayed in the sandbox. The app-emails check now warns on every key whose readiness is teamOnly. Co-Authored-By: Claude Opus 5.5 --- src/lib/doctor/project.ts | 17 ++++++++++++++- tests/commands/doctor-project.test.ts | 31 +++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/lib/doctor/project.ts b/src/lib/doctor/project.ts index 1aedb8ff..5927347b 100644 --- a/src/lib/doctor/project.ts +++ b/src/lib/doctor/project.ts @@ -452,7 +452,10 @@ async function checkAppEmails( detail: 'brew.json lists no keys', } } - const rows = new Map() + const rows = new Map< + string, + { triggerEventId: string; published: boolean; teamOnly: boolean } + >() try { let cursor: string | undefined for (let page = 0; page < 20; page += 1) { @@ -464,6 +467,7 @@ async function checkAppEmails( rows.set(row.triggerKey, { triggerEventId: row.triggerEventId, published: row.published, + teamOnly: row.readiness.teamOnly, }) } if (!result.pagination.hasMore || result.pagination.cursor === null) { @@ -504,6 +508,17 @@ async function checkAppEmails( fix: 'Publish them in Brew, or run `app-emails upsert --yes`.', } } + // A verified domain of the other purpose leaves a key in the sandbox. + const teamOnly = keys.filter((key) => rows.get(key)?.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', diff --git a/tests/commands/doctor-project.test.ts b/tests/commands/doctor-project.test.ts index e5b51ec8..a3e93f17 100644 --- a/tests/commands/doctor-project.test.ts +++ b/tests/commands/doctor-project.test.ts @@ -77,6 +77,8 @@ function healthyProject( return root } +const READY = { ready: true, teamOnly: false, blockers: [] } + function useProjectApi( options: { readonly brandId?: string @@ -123,11 +125,13 @@ function useProjectApi( 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 }, @@ -290,6 +294,7 @@ describe('doctor --project', () => { triggerKey: 'lifecycle.welcome', triggerEventId: 'tri_1', published: true, + readiness: READY, }, ], }) @@ -352,6 +357,32 @@ describe('doctor --project', () => { expect(statusOf(notConnected.report, 'provider')).toEqual(['warn']) }) + 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('exits 2 outside a project with brew.json', async () => { const root = tempProject('brew-doctor-empty-') const result = await runCli(['doctor', '--project', '--json'], { From 7daf6cfc8e6634201af6c2b4448288b2bae8db2d Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sat, 31 Oct 2026 23:45:42 -0400 Subject: [PATCH 13/44] fix(init): change a provider or brand only when nothing breaks - --app-url: an org key no longer sets the kit up in another brand than the app key in .env.local sends from (replace-app-key next step), and a matching brand that is still extracting waits like a new one. - --domain: no Connect automatically link for a DKIM_SELECTOR_IN_USE domain. - --configure-supabase: the hook changes only once every auth email is set up and reaches every user (not the sandbox); never takes over a connection active for another hook; stops when brew.json or the old secret's backup cannot be saved, and replaces a stale backup. - --configure-clerk: only templates whose email reaches every user; brew.json keeps a fingerprint of CLERK_SECRET_KEY so another instance's key never adds to or rolls back the saved templates. - --rollback-provider: refuses (and keeps the backup) while an enabled hook's old secret is missing. - Printed init commands keep --app. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 28 +- README.md | 12 +- src/lib/init/brand.ts | 31 +- src/lib/init/pipeline.ts | 36 ++- src/lib/init/providers/clerk.ts | 14 +- src/lib/init/providers/rollback.ts | 51 ++-- src/lib/init/providers/step.ts | 167 ++++++---- src/lib/init/providers/supabase.ts | 55 +++- tests/commands/init-app-url.test.ts | 41 ++- tests/commands/init-providers.test.ts | 421 +++++++++++++++----------- tests/commands/init.test.ts | 3 + tests/helpers/init-api.ts | 24 +- tests/helpers/init-providers.ts | 190 ++++++++++++ 13 files changed, 761 insertions(+), 312 deletions(-) create mode 100644 tests/helpers/init-providers.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a58189d1..7a1f8418 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,19 +39,25 @@ head `5084a5202f` (main's spec, brew-v2 `373e900a9a`, plus the DX delta). 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. `--dry-run --json` + 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 has a published app email in the brand, 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`. + 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. + 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. - 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 @@ -61,16 +67,20 @@ head `5084a5202f` (main's spec, brew-v2 `373e900a9a`, plus the DX delta). /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. - **`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. + 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 and published, a verified sending domain, and the hosted hook's - status. `--json`; any failed check exits 1, no `brew.json` exits 2. + 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 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`. diff --git a/README.md b/README.md index cab2e6dd..fd2e41e2 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,9 @@ 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. +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) @@ -103,12 +105,16 @@ flags below, init prints the manual steps. 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`). + (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 Brew publishes, through + "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. diff --git a/src/lib/init/brand.ts b/src/lib/init/brand.ts index 8b8e03bf..9cee6210 100644 --- a/src/lib/init/brand.ts +++ b/src/lib/init/brand.ts @@ -83,6 +83,8 @@ export async function resolveAppBrand(input: { 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 @@ -95,15 +97,18 @@ export async function resolveAppBrand(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: true, + ready, step: { id: 'brand', status: 'done', - detail: `${current.name ?? current.brandId} (matches ${host})`, + detail: `${current.name ?? current.brandId} (matches ${host})${ready ? '' : ' (still extracting)'}`, }, - nextSteps, + nextSteps: ready ? nextSteps : [extracting(input.rerunCommand, host)], } } const mismatch = (detail: string): BrandResolution => { @@ -226,14 +231,7 @@ export async function resolveAppBrand(input: { } const name = domain if (!ready) { - nextSteps.push({ - id: 'brand-extracting', - actor: 'agent', - kind: 'run-command', - command: input.rerunCommand, - title: `Brew is still reading ${host} to build the brand (1 to 3 minutes); run init again when it is ready`, - blocking: true, - }) + nextSteps.push(extracting(input.rerunCommand, host)) } return { brand: { brandId, name, domain, matchesAppUrl: true, created }, @@ -248,6 +246,17 @@ export async function resolveAppBrand(input: { } } +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: { diff --git a/src/lib/init/pipeline.ts b/src/lib/init/pipeline.ts index c4bbde94..13c03bc2 100644 --- a/src/lib/init/pipeline.ts +++ b/src/lib/init/pipeline.ts @@ -35,7 +35,7 @@ import { } 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' @@ -668,6 +668,7 @@ export async function runInit( credential.auth !== undefined ) { let current: AppBrand | null = null + let currentReady = false let lookupError: string | undefined try { const row = await client.brand.get() @@ -676,6 +677,7 @@ export async function runInit( 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. @@ -696,6 +698,7 @@ export async function runInit( client, appUrl: options.appUrl, current, + currentReady, canSwitch: credential.source === 'flag' || credential.source === 'env' || @@ -713,7 +716,19 @@ export async function runInit( brandReady = resolution.ready steps.push(resolution.step) nextSteps.push(...resolution.nextSteps) - if (resolution.switchTo !== undefined) { + if (resolution.switchTo !== undefined && existingAppKey !== undefined) { + // 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: `${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`, + blocking: true, + }) + } else if (resolution.switchTo !== undefined) { credential = authFor( credential.auth.apiKey, credential.source, @@ -816,9 +831,12 @@ export async function runInit( domain.domainId = shown.domainId domain.name = shown.name domain.status = shown.status - domain.connectUrl = shown.domainUrl ?? `${siteUrl}/domains` - // 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, @@ -912,7 +930,7 @@ export async function runInit( steps.push({ id: 'kit', status: 'skipped', - detail: 'the brand is still being built; run init again when it is ready', + detail: 'waits for the brand (see the next steps); run init again then', }) } else if (client === undefined || dryRun) { steps.push({ @@ -1275,11 +1293,11 @@ export async function runInit( client, writer, prefix, + init: `${prefix} init${options.app === undefined ? '' : ` --app ${shellQuote(options.app)}`}`, providerConfig, // brew.json holds the provider's old settings BEFORE they change. persist: (config) => { - stateWrittenEarly = true - writer.writeFile( + const change = writer.writeFile( BREW_STATE_FILE, serializeBrewState( createBrewState({ @@ -1291,6 +1309,8 @@ export async function runInit( ), { overwrite: true } ) + stateWrittenEarly ||= change.action !== 'refused' + return change.action !== 'refused' }, now: () => (ctx.io.clock ?? systemClock).now(), }) diff --git a/src/lib/init/providers/clerk.ts b/src/lib/init/providers/clerk.ts index ac7fce5a..e6e794d4 100644 --- a/src/lib/init/providers/clerk.ts +++ b/src/lib/init/providers/clerk.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { join } from 'node:path' import { CliVendorError } from '../../errors' import { getEnvValue, parseEnv } from '../../project/env-file' @@ -33,8 +34,8 @@ 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 - * has a published app email in the brand. + * (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', @@ -88,6 +89,15 @@ export function readClerkSecret(input: { 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, diff --git a/src/lib/init/providers/rollback.ts b/src/lib/init/providers/rollback.ts index f396ed34..64864d79 100644 --- a/src/lib/init/providers/rollback.ts +++ b/src/lib/init/providers/rollback.ts @@ -1,18 +1,19 @@ import { CliUsageError } from '../../errors' import { progress } from '../../output' -import { getEnvValue, parseEnv } from '../../project/env-file' 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, @@ -38,21 +39,6 @@ export type RollbackResult = { readonly providerConfig: ProviderConfig } -function localValue( - ctx: CliContext, - 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 -} - export async function runProviderRollback(input: { readonly ctx: CliContext readonly root: string @@ -98,21 +84,27 @@ export async function runProviderRollback(input: { ? 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: - "Re-enter the previous Send Email hook's secret in Supabase (Authentication > Hooks): this machine did not keep it", + 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) { - problems.push(`${SUPABASE_TOKEN_VARIABLE} is not set`) + refusals.push(`${SUPABASE_TOKEN_VARIABLE} is not set`) nextSteps.push({ id: 'supabase-access-token', actor: 'human', @@ -121,6 +113,9 @@ export async function runProviderRollback(input: { 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', @@ -129,7 +124,7 @@ export async function runProviderRollback(input: { previous, requests: [], rollbackCommand: null, - detail: `${SUPABASE_TOKEN_VARIABLE} is not set`, + detail: refusals.join('; '), }) } else if (dryRun) { providers.push({ @@ -194,13 +189,21 @@ export async function runProviderRollback(input: { url: clerkToggleUrl(slug), body: { delivered_by_clerk: true }, })) - if (secretKey === undefined) { - problems.push(`${CLERK_SECRET_VARIABLE} not found`) + // 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 ${CLERK_SECRET_VARIABLE} available in this shell or .env.local, then run the rollback again (or turn "Delivered by Clerk" back on in the dashboard for: ${clerk.templates.join(', ')})`, + 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, }) @@ -211,7 +214,7 @@ export async function runProviderRollback(input: { templates: clerk.templates, requests: [], rollbackCommand: null, - detail: `${CLERK_SECRET_VARIABLE} not found`, + detail, }) } else if (dryRun) { providers.push({ @@ -253,7 +256,7 @@ export async function runProviderRollback(input: { const left = clerk.templates.filter((slug) => !restored.includes(slug)) remaining = { ...remaining, - clerk: { savedAt: clerk.savedAt, templates: left }, + clerk: { ...clerk, templates: left }, } providers.push({ provider: 'clerk', diff --git a/src/lib/init/providers/step.ts b/src/lib/init/providers/step.ts index caf7d0cf..61cbd29e 100644 --- a/src/lib/init/providers/step.ts +++ b/src/lib/init/providers/step.ts @@ -6,7 +6,6 @@ import { type HostedAuthProvider, } from '../../integrations/hosted-auth' import { progress } from '../../output' -import { ensureEnvVars, getEnvValue, parseEnv } from '../../project/env-file' import type { ProjectWriter } from '../../project/fs-writer' import type { CliContext } from '../../types' import type { InitStep, NextStep } from '../report' @@ -14,14 +13,17 @@ 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, @@ -100,6 +102,8 @@ export type SupabaseRollback = { 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[] } @@ -121,12 +125,20 @@ export type ProviderStepInput = { readonly client: BrewClient | undefined readonly writer: ProjectWriter readonly prefix: string + /** ` init`, with the run's `--app `. */ + readonly init: string readonly providerConfig: ProviderConfig - /** Writes `brew.json` with this provider config now (before a change). */ - readonly persist: (config: ProviderConfig) => void + /** + * 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[] @@ -168,21 +180,6 @@ function connectionOf( } } -/** A local token: the shell first, then the app's `.env.local`. */ -function localToken( - input: Pick, - name: string -): string | undefined { - const fromEnv = input.ctx.io.env[name]?.trim() - if (fromEnv !== undefined && fromEnv !== '') { - return fromEnv - } - const text = input.writer.read('.env.local') - const value = - text === null ? undefined : getEnvValue(parseEnv(text), name)?.trim() - return value === undefined || value === '' ? undefined : value -} - export async function runProviderStep( input: ProviderStepInput ): Promise { @@ -253,7 +250,7 @@ async function supabaseStep( id: 'configure-supabase-hook', actor: 'agent', kind: 'run-command', - command: `${prefix} init --configure-supabase${ref === null ? ' --supabase-project ' : ''} --yes`, + command: `${input.init} --configure-supabase${ref === null ? ' --supabase-project ' : ''} --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, } @@ -314,7 +311,7 @@ async function supabaseStep( id: 'supabase-project', actor: 'agent', kind: 'run-command', - command: `${prefix} init --configure-supabase --supabase-project --yes`, + 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, @@ -328,7 +325,7 @@ async function supabaseStep( nextSteps ) } - const token = localToken(input, SUPABASE_TOKEN_VARIABLE) + const token = localValue(ctx, input.writer, SUPABASE_TOKEN_VARIABLE) if (token === undefined) { nextSteps.push({ id: 'supabase-access-token', @@ -391,6 +388,26 @@ async function supabaseStep( 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\` 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 ?? @@ -445,20 +462,17 @@ async function supabaseStep( if (saved === undefined || saved.projectRef !== ref) { let previousSecretsVariable: string | undefined if (hook.config.hook_send_email_enabled && hook.secrets !== null) { - // The replaced hook's secret stays on this machine, git-ignored, so a - // rollback can put the old hook back whole. Never in brew.json. - const envText = input.writer.read('.env.local') - const { text } = ensureEnvVars(envText, [ - { name: SUPABASE_PREVIOUS_SECRETS_VARIABLE, value: hook.secrets }, - ]) - const change = input.writer.writeFile('.env.local', text, { - overwrite: true, - mode: 0o600, - sensitive: true, - }) - if (change.action !== 'refused') { - previousSecretsVariable = SUPABASE_PREVIOUS_SECRETS_VARIABLE + // 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, @@ -471,7 +485,9 @@ async function supabaseStep( : { previousSecretsVariable }), }, } - input.persist(providerConfig) + if (!input.persist(providerConfig)) { + return failed(input, 'supabase_auth', NOT_SAVED, ref, connection) + } } const signingSecret = generateHookSecret() @@ -494,6 +510,36 @@ async function supabaseStep( 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) + } catch (error) { + rethrowIfFatal(ctx, error) + return failed(input, '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 --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( + input, + '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…`) @@ -517,7 +563,6 @@ async function supabaseStep( providerConfig ) } - const failedKeys = connected.authEmails.filter((email) => !email.ok) nextSteps.push( { id: 'verify-supabase-hook', @@ -536,16 +581,6 @@ async function supabaseStep( blocking: false, } ) - if (failedKeys.length > 0) { - nextSteps.push({ - id: 'rerun-supabase-connect', - actor: 'agent', - kind: 'run-command', - command: `${prefix} init --configure-supabase --yes`, - title: `Run again to finish the auth emails that failed (${failedKeys.map((email) => email.triggerKey).join(', ')}); until then Supabase sign-ups that need them fail`, - blocking: true, - }) - } return { step: { id: 'provider', @@ -610,7 +645,7 @@ async function clerkStep( id: 'configure-clerk-delivery', actor: 'agent', kind: 'run-command', - command: `${prefix} init --configure-clerk --yes`, + 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, }) @@ -688,17 +723,29 @@ async function clerkStep( nextSteps ) } - // Exactly the templates Brew sends: a slug whose key has a published app - // email in this brand. - let published: Set + // 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 { - published = await publishedKeys(input.client) + reachable = await publicKeys(input.client) } catch (error) { rethrowIfFatal(ctx, error) return failed(input, 'clerk', describe(error), undefined, connection) } const slugs = Object.entries(CLERK_EMAIL_SLUG_KEYS) - .filter(([, key]) => published.has(key)) + .filter(([, key]) => reachable.has(key)) .map(([slug]) => slug) const toTurnOff: string[] = [] try { @@ -725,7 +772,7 @@ async function clerkStep( status: 'done', detail: slugs.length === 0 - ? 'no published Brew app email matches a Clerk template; Clerk delivery left on' + ? '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, @@ -775,6 +822,7 @@ async function clerkStep( savedAt: input.providerConfig.clerk?.savedAt ?? new Date(input.now()).toISOString(), + keyId, templates: [ ...new Set([ ...(input.providerConfig.clerk?.templates ?? []), @@ -783,7 +831,9 @@ async function clerkStep( ].sort(), }, } - input.persist(providerConfig) + if (!input.persist(providerConfig)) { + return failed(input, 'clerk', NOT_SAVED, undefined, connection) + } const done: string[] = [] try { for (const slug of toTurnOff) { @@ -859,7 +909,8 @@ async function clerkStep( } } -async function publishedKeys(client: BrewClient): Promise> { +/** Keys whose app email a fire sends now to anyone, not the sandbox only. */ +async function publicKeys(client: BrewClient): Promise> { const keys = new Set() let cursor: string | undefined for (let page = 0; page < 20; page += 1) { @@ -868,7 +919,7 @@ async function publishedKeys(client: BrewClient): Promise> { ...(cursor === undefined ? {} : { cursor }), }) for (const row of result.data) { - if (row.published) { + if (row.readiness.ready && !row.readiness.teamOnly) { keys.add(row.triggerKey) } } @@ -883,9 +934,9 @@ async function publishedKeys(client: BrewClient): Promise> { // ---------------------------------------------------------------- shared export function rollbackCommandFor( - input: Pick + input: Pick ): string { - return `${input.prefix} init --rollback-provider --yes` + return `${input.init} --rollback-provider --yes` } function refused( diff --git a/src/lib/init/providers/supabase.ts b/src/lib/init/providers/supabase.ts index 26c96d5e..9b489dfd 100644 --- a/src/lib/init/providers/supabase.ts +++ b/src/lib/init/providers/supabase.ts @@ -1,7 +1,12 @@ import { randomBytes } from 'node:crypto' import { join } from 'node:path' -import { getEnvValue, parseEnv } from '../../project/env-file' -import { readTextFile } from '../../project/fs-writer' +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' @@ -62,6 +67,52 @@ 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 +} + +/** + * 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). False when it could not be saved as it is. + */ +export function backupHookSecret( + writer: ProjectWriter, + secrets: string +): boolean { + const env = parseEnv(writer.read('.env.local') ?? '') + const lines = env.lines.filter( + (line) => + line.kind !== 'var' || line.key !== SUPABASE_PREVIOUS_SECRETS_VARIABLE + ) + const { text } = ensureEnvVars(serializeEnv({ ...env, lines }), [ + { 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')}` diff --git a/tests/commands/init-app-url.test.ts b/tests/commands/init-app-url.test.ts index ccbab1d9..448c5001 100644 --- a/tests/commands/init-app-url.test.ts +++ b/tests/commands/init-app-url.test.ts @@ -70,7 +70,7 @@ describe('init --app-url: the brand', () => { expect(step?.title).toContain('my-app.lovable.app') }) - it('an organization key picks the brand whose website is the app, and the kit lands there', async () => { + 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, @@ -108,11 +108,15 @@ describe('init --app-url: the brand', () => { (request) => request.method === 'POST' && request.path === '/v1/brands' ) ).toBe(false) - const kit = requests.find( - (request) => request.path === '/v1/app-emails/kit' - ) - expect(kit?.brandId).toBe('brand_lovable') - expect(kit?.authorization).toBe(`Bearer ${EXPLICIT_KEY}`) + // .env.local's key still sends from its own brand: no kit lands in + // brand_lovable while the app could not send from it. + expect( + requests.some((request) => request.path === '/v1/app-emails/kit') + ).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 with no matching brand creates one, waits for it, and a re-run finds it instead of creating another', async () => { @@ -147,8 +151,8 @@ describe('init --app-url: the brand', () => { created: true, }) expect( - requests.find((request) => request.path === '/v1/app-emails/kit')?.brandId - ).toBe('brand_new') + requests.some((request) => request.path === '/v1/app-emails/kit') + ).toBe(false) // The brand exists now: a re-run reuses it. const again = useBrewApi({ @@ -243,6 +247,27 @@ describe('init --app-url: the brand', () => { ).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('--dry-run plans the brand without creating it', async () => { const root = project() const requests = useBrewApi({ orgKeyWithoutBrand: true }) diff --git a/tests/commands/init-providers.test.ts b/tests/commands/init-providers.test.ts index 1fe56c4e..487a00ed 100644 --- a/tests/commands/init-providers.test.ts +++ b/tests/commands/init-providers.test.ts @@ -1,199 +1,40 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { HttpResponse, http } from 'msw' +import { + existsSync, + mkdtempSync, + readFileSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' import { describe, expect, it } from 'vitest' import { copyFixture } from '../helpers/fixtures' import { configEnv, expectValidReport, init, - type Recorded, 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' -import { server } from '../helpers/msw-server' - -const SUPABASE = 'https://api.supabase.com' -const CLERK = 'https://api.clerk.com/v1' -const REF = 'abcdefghijklmnopqrst' -const SB_TOKEN = 'sbp_0123456789abcdefghijklmnopqrstuvwxyz0123' -const CLERK_SECRET_KEY = 'sk_test_clerkSecret0123456789abcdefghijkl' -const OLD_HOOK_SECRET = 'v1,whsec_b2xkaG9va3NlY3JldGZvcmFub3RoZXJmdW5jdGlvbg==' -const OLD_HOOK_URI = - 'https://abcdefghijklmnopqrst.supabase.co/functions/v1/send-email' - -type VendorCall = { - readonly method: string - readonly path: string - readonly body: unknown - readonly authorization: string | null -} - -type HookState = { - enabled: boolean - uri: string | null - secrets: string | null -} - -/** The Management API's auth config, holding the Send Email hook. */ -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. */ -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. */ -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. */ -function expectBrewNeverSaw( - requests: readonly Recorded[], - secrets: readonly string[] -): void { - const text = JSON.stringify(requests) - for (const secret of secrets) { - expect(text).not.toContain(secret) - } -} - -function supabaseProject(): string { - const root = copyFixture('vite-supabase-lovable') - writeFileSync(join(root, '.env.local'), `BREW_API_KEY=${APP_KEY}\n`) - return root -} - -function clerkProject(): string { - const root = copyFixture('react-router-clerk') - writeFileSync(join(root, '.env.local'), `BREW_API_KEY=${APP_KEY}\n`) - return root -} - -const ACTIVE_CLERK = { - provider: 'clerk', - connectionId: 'conn_clerk', - webhookUrl: webhookUrlFor('clerk'), - status: 'active', -} describe('init: Supabase Send Email hook', () => { it('without --configure-supabase prints the manual steps and the self-hosted alternative; Supabase is never called', async () => { @@ -885,3 +726,215 @@ describe('init: Clerk delivery', () => { } }) }) + +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("--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("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', + ]) + }) +}) diff --git a/tests/commands/init.test.ts b/tests/commands/init.test.ts index f5cfc682..50d4c404 100644 --- a/tests/commands/init.test.ts +++ b/tests/commands/init.test.ts @@ -13,6 +13,7 @@ import { readPendingLogin, } from '../../src/lib/auth-flow/pending' import { readConfig, writeConfig } from '../../src/lib/config-store' +import { renderReport } from '../../src/lib/init/report' import { validateBrewState } from '../../src/lib/project/state' import { copyFixture, tempProject } from '../helpers/fixtures' import { @@ -613,6 +614,8 @@ describe('init --yes: the full run', () => { 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/helpers/init-api.ts b/tests/helpers/init-api.ts index be1d01f8..01e1de03 100644 --- a/tests/helpers/init-api.ts +++ b/tests/helpers/init-api.ts @@ -88,6 +88,8 @@ export function useBrewApi( 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 /** `GET /v1/brands` rows (default: the signed-in brand only). */ @@ -98,6 +100,10 @@ export function useBrewApi( 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> } = {} @@ -138,8 +144,8 @@ export function useBrewApi( return HttpResponse.json({ brandId: request.headers.get('x-brand-id') ?? BRAND.brandId, domain: options.brandDomain ?? 'acme.com', - status: 'completed', - ready: true, + status: options.brandReady === false ? 'extracting' : 'completed', + ready: options.brandReady ?? true, identity: { brandName: BRAND.name }, }) }), @@ -215,7 +221,14 @@ export function useBrewApi( webhookUrl: webhookUrlFor(provider), status: 'pending_verification', authEmails: KIT_KEYS.filter((key) => key.startsWith('auth.')).map( - (triggerKey) => ({ triggerKey, ok: true }) + (triggerKey) => + options.connectFailKeys?.includes(triggerKey) === true + ? { + triggerKey, + ok: false, + error: { code: 'INTERNAL_ERROR', message: 'Failed.' }, + } + : { triggerKey, ok: true } ), }) } @@ -232,6 +245,11 @@ export function useBrewApi( 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 }, }) 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', +} From e8febab6bbd078337e2c635bbbc3adccafb85f17 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 00:48:50 -0400 Subject: [PATCH 14/44] test(init): imports for #55's tests after the helpers moved to init-api Co-Authored-By: Claude Opus 5.5 --- tests/commands/init.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/commands/init.test.ts b/tests/commands/init.test.ts index 50d4c404..a2a54f99 100644 --- a/tests/commands/init.test.ts +++ b/tests/commands/init.test.ts @@ -7,6 +7,7 @@ import { writeFileSync, } from 'node:fs' import { join } from 'node:path' +import { HttpResponse, http } from 'msw' import { describe, expect, it } from 'vitest' import { pendingLoginPath, @@ -17,6 +18,7 @@ import { renderReport } from '../../src/lib/init/report' import { validateBrewState } from '../../src/lib/project/state' import { copyFixture, tempProject } from '../helpers/fixtures' import { + API, configEnv, EXPLICIT_KEY, expectValidReport, @@ -39,6 +41,7 @@ import { USER_CODE, useLoginServer, } from '../helpers/login-server' +import { server } from '../helpers/msw-server' describe('init: confirmation and dry run', () => { it('exits 4 off a terminal without --yes, with the re-run command', async () => { From 35bc736255be69c2a6f3f99d2c2e82032b3ea023 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 00:49:23 -0400 Subject: [PATCH 15/44] fix(init): the provider step follows #55's auth set #55 now sets up nothing that routes the auth library's emails without the auth set. The provider step does the same: with --sets lifecycle, Supabase and Clerk keep their auth emails (no hook or delivery steps), and --configure-supabase or --configure-clerk exits 2 before any request. Co-Authored-By: Claude Opus 5.5 --- src/lib/init/pipeline.ts | 10 +++++++++- tests/commands/init-providers.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/lib/init/pipeline.ts b/src/lib/init/pipeline.ts index 13c03bc2..70c9452e 100644 --- a/src/lib/init/pipeline.ts +++ b/src/lib/init/pipeline.ts @@ -344,6 +344,14 @@ export async function runInit( `--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 }) @@ -1274,7 +1282,7 @@ export async function runInit( {}) as ProviderConfig let providers: readonly InitProvider[] = [] let stateWrittenEarly = false - if (stack === 'supabase' || stack === 'clerk') { + if (authSet && (stack === 'supabase' || stack === 'clerk')) { if (!brandReady) { steps.push({ id: 'provider', diff --git a/tests/commands/init-providers.test.ts b/tests/commands/init-providers.test.ts index 487a00ed..e61d4bb9 100644 --- a/tests/commands/init-providers.test.ts +++ b/tests/commands/init-providers.test.ts @@ -938,3 +938,28 @@ describe('init: a provider changes only when nothing breaks', () => { ]) }) }) + +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([]) + }) +}) From 03a94b9d4b5640d3b99734c37b7363ff897ec8a5 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 00:50:07 -0400 Subject: [PATCH 16/44] fix(launchers): --supabase-project takes a value #55's walk over init's value flags now covers the provider flags. Co-Authored-By: Claude Opus 5.5 --- packages/launchers/brew.new/launcher.mjs | 1 + packages/launchers/create-brew/launcher.mjs | 1 + packages/launchers/launcher.mjs | 1 + 3 files changed, 3 insertions(+) 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/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/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']) From 3419e784bd039698696aa25a174a2fd03cd6e3f0 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 01:01:48 -0400 Subject: [PATCH 17/44] fix(init): the domain step waits until the brand is ready, like the kit When --app-url picks a brand the app's key in .env.local is not bound to, the client still acts as the old brand: --domain no longer lists or adds a domain there. The step is skipped until a re-run with the right key. Co-Authored-By: Claude Opus 5.5 --- src/lib/init/pipeline.ts | 7 +++++++ tests/commands/init-app-url.test.ts | 13 +++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/lib/init/pipeline.ts b/src/lib/init/pipeline.ts index 70c9452e..99fa776b 100644 --- a/src/lib/init/pipeline.ts +++ b/src/lib/init/pipeline.ts @@ -798,6 +798,13 @@ export async function runInit( } 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) => diff --git a/tests/commands/init-app-url.test.ts b/tests/commands/init-app-url.test.ts index 448c5001..474a858e 100644 --- a/tests/commands/init-app-url.test.ts +++ b/tests/commands/init-app-url.test.ts @@ -90,7 +90,10 @@ describe('init --app-url: the brand', () => { ], }) const result = await init( - ['--yes', '--json', '--no-test-email', '--app-url', APP_URL], + [ + ...['--yes', '--json', '--no-test-email', '--app-url', APP_URL], + ...['--domain', 'mail.my-app.dev'], + ], { cwd: root, env: configEnv({ BREW_API_KEY: EXPLICIT_KEY }), @@ -108,10 +111,12 @@ describe('init --app-url: the brand', () => { (request) => request.method === 'POST' && request.path === '/v1/brands' ) ).toBe(false) - // .env.local's key still sends from its own brand: no kit lands in - // brand_lovable while the app could not send from it. + // .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) => request.path === '/v1/app-emails/kit') + requests.some((request) => + ['/v1/app-emails/kit', '/v1/domains'].includes(request.path) + ) ).toBe(false) expect(result.report.status).toBe('action_required') expect( From b9abd8c6d96dbb6df0c8601e71748b4193155ef3 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 01:15:50 -0400 Subject: [PATCH 18/44] fix(init,doctor): keep an app key already on the picked brand; doctor checks readiness - --app-url asks to replace the app key in .env.local only when that key is bound to another brand than the one picked (GET /v1/brand with the app key), not whenever the organization key had to switch. - doctor --project reads each key's readiness: a published key that cannot send (DOMAIN_REQUIRED, AUTOMATION_MISSING) fails with its blockers, and a draft over a live version no longer reads as unpublished. Co-Authored-By: Claude Opus 5.5 --- src/lib/doctor/project.ts | 30 ++++++++++----- src/lib/init/pipeline.ts | 17 ++++++++- tests/commands/doctor-project.test.ts | 29 ++++++++++++++ tests/commands/init-app-url.test.ts | 54 ++++++++++++++++++++------- tests/helpers/init-api.ts | 12 +++++- 5 files changed, 115 insertions(+), 27 deletions(-) diff --git a/src/lib/doctor/project.ts b/src/lib/doctor/project.ts index 5927347b..c09d500d 100644 --- a/src/lib/doctor/project.ts +++ b/src/lib/doctor/project.ts @@ -452,9 +452,18 @@ async function checkAppEmails( 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; published: boolean; teamOnly: boolean } + { + triggerEventId: string + readiness: { + ready: boolean + teamOnly: boolean + blockers: readonly string[] + } + } >() try { let cursor: string | undefined @@ -466,8 +475,7 @@ async function checkAppEmails( for (const row of result.data) { rows.set(row.triggerKey, { triggerEventId: row.triggerEventId, - published: row.published, - teamOnly: row.readiness.teamOnly, + readiness: row.readiness, }) } if (!result.pagination.hasMore || result.pagination.cursor === null) { @@ -489,7 +497,7 @@ async function checkAppEmails( rows.has(key) && rows.get(key)?.triggerEventId !== state.emails[key]?.triggerEventId ) - const unpublished = keys.filter((key) => rows.get(key)?.published === false) + const blocked = keys.filter((key) => rows.get(key)?.readiness.ready === false) if (missing.length > 0) { return { id: 'app-emails', @@ -499,17 +507,19 @@ async function checkAppEmails( fix: 'Run init again (it sets the kit up idempotently), or `app-emails upsert `.', } } - if (unpublished.length > 0) { + if (blocked.length > 0) { return { id: 'app-emails', status: 'fail', - detail: `${unpublished.length} key(s) are not published: sends answer NO_PUBLISHED_AUTOMATION`, - items: unpublished, - fix: 'Publish them in Brew, or run `app-emails upsert --yes`.', + 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)?.teamOnly === true) + const teamOnly = keys.filter( + (key) => rows.get(key)?.readiness.teamOnly === true + ) if (teamOnly.length > 0) { return { id: 'app-emails', @@ -529,7 +539,7 @@ async function checkAppEmails( : { id: 'app-emails', status: 'ok', - detail: `${keys.length} key(s) set up and published`, + detail: `${keys.length} key(s) set up and ready to send`, } } diff --git a/src/lib/init/pipeline.ts b/src/lib/init/pipeline.ts index 99fa776b..21e90a9f 100644 --- a/src/lib/init/pipeline.ts +++ b/src/lib/init/pipeline.ts @@ -668,6 +668,17 @@ export async function runInit( // 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 a key is bound to, or undefined when it cannot tell. */ + const brandOfKey = async (apiKey: string): Promise => { + try { + const auth = authFor(apiKey, 'env-file', undefined).auth as ResolvedAuth + return (await buildSdkClient(auth, sdkTransportFor(ctx)).brand.get()) + .brandId + } catch (error) { + rethrowIfFatal(ctx, error) + return undefined + } + } // ---- Brand. if ( @@ -724,7 +735,11 @@ export async function runInit( brandReady = resolution.ready steps.push(resolution.step) nextSteps.push(...resolution.nextSteps) - if (resolution.switchTo !== undefined && existingAppKey !== undefined) { + if ( + resolution.switchTo !== undefined && + existingAppKey !== undefined && + (await brandOfKey(existingAppKey)) !== resolution.switchTo + ) { // 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 diff --git a/tests/commands/doctor-project.test.ts b/tests/commands/doctor-project.test.ts index a3e93f17..08b549ef 100644 --- a/tests/commands/doctor-project.test.ts +++ b/tests/commands/doctor-project.test.ts @@ -383,6 +383,35 @@ describe('doctor --project', () => { ).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'], { diff --git a/tests/commands/init-app-url.test.ts b/tests/commands/init-app-url.test.ts index 474a858e..c4781571 100644 --- a/tests/commands/init-app-url.test.ts +++ b/tests/commands/init-app-url.test.ts @@ -14,6 +14,22 @@ import { APP_KEY, BRAND, fakeClock } from '../helpers/login-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(): string { const root = copyFixture('express-resend') @@ -74,20 +90,8 @@ describe('init --app-url: the brand', () => { const root = project() const requests = useBrewApi({ orgKeyWithoutBrand: true, - brands: [ - { - brandId: BRAND.brandId, - domain: 'acme.com', - status: 'completed', - ready: true, - }, - { - brandId: 'brand_lovable', - domain: 'my-app.lovable.app', - status: 'completed', - ready: true, - }, - ], + keyBrands: { [APP_KEY]: BRAND.brandId }, + brands: TWO_BRANDS, }) const result = await init( [ @@ -124,6 +128,28 @@ describe('init --app-url: the brand', () => { ).toMatchObject({ actor: 'human', path: '.env.local', blocking: 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 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 }) diff --git a/tests/helpers/init-api.ts b/tests/helpers/init-api.ts index 01e1de03..212c977a 100644 --- a/tests/helpers/init-api.ts +++ b/tests/helpers/init-api.ts @@ -92,6 +92,9 @@ export function useBrewApi( 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). */ @@ -126,9 +129,14 @@ export function useBrewApi( 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 + request.headers.get('x-brand-id') === null && + keyBrand === undefined ) { return HttpResponse.json( { @@ -142,7 +150,7 @@ export function useBrewApi( ) } return HttpResponse.json({ - brandId: request.headers.get('x-brand-id') ?? BRAND.brandId, + 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, From e6222ac27aa97dbec680e1e47eefb80ef0c0098e Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 01:20:36 -0400 Subject: [PATCH 19/44] fix(doctor): a key under a public name in the environment fails env-exposure doctor --project now checks the process environment as well as the .env files: a NEXT_PUBLIC_/VITE_/PUBLIC_/EXPO_PUBLIC_ name holding a Brew key there ships with the build too. Values are never printed. Co-Authored-By: Claude Opus 5.5 --- src/lib/doctor/project.ts | 27 ++++++++++++++++++--------- tests/commands/doctor-project.test.ts | 13 +++++++++++++ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/lib/doctor/project.ts b/src/lib/doctor/project.ts index c09d500d..841a02aa 100644 --- a/src/lib/doctor/project.ts +++ b/src/lib/doctor/project.ts @@ -238,25 +238,34 @@ export async function runProjectDoctor( } ) const exposed: string[] = [] + const looksLikeKey = (name: string, value: string): boolean => + isClientExposedName(name) && + (/BREW/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' || !isClientExposedName(line.key)) { - continue - } - const value = getEnvValue(parseEnv(text), line.key)?.trim() ?? '' - const looksLikeKey = - /BREW/i.test(line.key) || - value.startsWith('brew_') || - (envLocalKey !== undefined && value === envLocalKey) - if (looksLikeKey) { + 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 ? { diff --git a/tests/commands/doctor-project.test.ts b/tests/commands/doctor-project.test.ts index 08b549ef..da5c112a 100644 --- a/tests/commands/doctor-project.test.ts +++ b/tests/commands/doctor-project.test.ts @@ -252,6 +252,19 @@ describe('doctor --project', () => { }) }) + 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 }) From dce5ac3df64dd99602a92169423effbd2174bc1f Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 01:34:18 -0400 Subject: [PATCH 20/44] fix(integrations): connect exits 1 while an auth email failed to set up Like app-emails kit: the full response still prints, and the exit code tells a script the provider's auth emails are not all ready. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++- src/commands/integrations/connect.ts | 2 ++ tests/commands/integrations-connect.test.ts | 5 +++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a1f8418..534effd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,8 @@ head `5084a5202f` (main's spec, brew-v2 `373e900a9a`, plus the DX delta). 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, and - keys that failed are finished. A Clerk connected on its integration page + keys that failed are finished; while one has failed it exits 1, with + the full response. 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: diff --git a/src/commands/integrations/connect.ts b/src/commands/integrations/connect.ts index b1e006a1..35103a96 100644 --- a/src/commands/integrations/connect.ts +++ b/src/commands/integrations/connect.ts @@ -102,9 +102,11 @@ export const integrationsConnectCommand = defineCommand({ } 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 } : {}), } }, }) diff --git a/tests/commands/integrations-connect.test.ts b/tests/commands/integrations-connect.test.ts index bb58f9a3..48f8c9a8 100644 --- a/tests/commands/integrations-connect.test.ts +++ b/tests/commands/integrations-connect.test.ts @@ -154,7 +154,8 @@ describe('integrations connect', () => { ], { env: { HOOK_SECRET: SUPABASE_SECRET } } ) - expect(result.code).toBe(0) + // auth.otp failed to set up: the connection stands, the exit says so. + expect(result.code).toBe(1) expect(seen).toEqual([ { method: 'PUT', @@ -181,7 +182,7 @@ describe('integrations connect', () => { ['integrations', 'connect', 'clerk', '--signing-secret-stdin', '--yes'], { stdin: `${CLERK_SECRET}\n`, ttyOut: true } ) - expect(result.code).toBe(0) + 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' From 5d7b568521329d0117ebfcdf338f284dce601193 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 01:05:16 -0500 Subject: [PATCH 21/44] fix(init): a failed brand lookup holds every brand-specific step When `init --app-url` cannot read the key's brand (a 503 after retries), the brand step failed but brandReady stayed true, so the domain, kit and provider steps ran on the unswitched client: an organization key acting as brand A could add --domain and set up the kit there for an app that belongs to brand B. A failed lookup now sets brandReady to false, so those steps are skipped until a re-run (exit 1 for the failed step). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++- src/lib/init/pipeline.ts | 3 +++ tests/commands/init-app-url.test.ts | 38 +++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 534effd2..5c6b7f81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,7 +69,8 @@ head `5084a5202f` (main's spec, brew-v2 `373e900a9a`, plus the DX delta). 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. + the kit waits for a `replace-app-key` next step. When the brand lookup + fails, the domain, kit and provider steps wait for a re-run (exit 1). - **`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 diff --git a/src/lib/init/pipeline.ts b/src/lib/init/pipeline.ts index 21e90a9f..09a60b69 100644 --- a/src/lib/init/pipeline.ts +++ b/src/lib/init/pipeline.ts @@ -710,6 +710,9 @@ export async function runInit( } } 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({ diff --git a/tests/commands/init-app-url.test.ts b/tests/commands/init-app-url.test.ts index c4781571..7582bf96 100644 --- a/tests/commands/init-app-url.test.ts +++ b/tests/commands/init-app-url.test.ts @@ -1,8 +1,10 @@ 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, @@ -11,6 +13,7 @@ import { 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' @@ -299,6 +302,41 @@ describe('init --app-url: the brand', () => { ).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() + server.use( + http.get(`${API}/v1/brand`, () => + HttpResponse.json( + { + error: { + code: 'SERVICE_UNAVAILABLE', + type: 'api_error', + message: 'Try again.', + }, + }, + { status: 503 } + ) + ) + ) + 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('--dry-run plans the brand without creating it', async () => { const root = project() const requests = useBrewApi({ orgKeyWithoutBrand: true }) From 4c13154ff68477c5efca306baa8d189ef6647730 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 01:09:27 -0500 Subject: [PATCH 22/44] fix(init): no patch applies while the kit waits for the brand patchesHeld held a reviewed patch only when the files or kit step failed. A kit skipped because the brand is not ready (a failed lookup, a brand still extracting, an app key bound to another brand) let a pinned auth patch apply, wiring auth emails to kit keys that do not exist yet. Patches are now held while brandReady is false too; they stay pending with their apply next step. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + src/lib/init/pipeline.ts | 9 +++-- tests/commands/init-app-url.test.ts | 60 +++++++++++++++++++++-------- 3 files changed, 50 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c6b7f81..8384db21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,7 @@ head `5084a5202f` (main's spec, brew-v2 `373e900a9a`, plus the DX delta). 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 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 diff --git a/src/lib/init/pipeline.ts b/src/lib/init/pipeline.ts index 09a60b69..674893b2 100644 --- a/src/lib/init/pipeline.ts +++ b/src/lib/init/pipeline.ts @@ -1204,11 +1204,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) => diff --git a/tests/commands/init-app-url.test.ts b/tests/commands/init-app-url.test.ts index 7582bf96..047788b9 100644 --- a/tests/commands/init-app-url.test.ts +++ b/tests/commands/init-app-url.test.ts @@ -9,6 +9,7 @@ import { EXPLICIT_KEY, expectValidReport, init, + read, recordingSpawn, useBrewApi, } from '../helpers/init-api' @@ -34,12 +35,30 @@ const TWO_BRANDS = [ ] /** An app with its key in .env.local, so no sign-in runs. */ -function project(): string { - const root = copyFixture('express-resend') +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` is unavailable (after the SDK's retries). */ +function failBrandLookup(): void { + server.use( + http.get(`${API}/v1/brand`, () => + 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() @@ -305,20 +324,7 @@ describe('init --app-url: the brand', () => { it('a brand lookup that fails sets nothing up in whatever brand the key acts as', async () => { const root = project() const requests = useBrewApi() - server.use( - http.get(`${API}/v1/brand`, () => - HttpResponse.json( - { - error: { - code: 'SERVICE_UNAVAILABLE', - type: 'api_error', - message: 'Try again.', - }, - }, - { status: 503 } - ) - ) - ) + failBrandLookup() const result = await init( [ ...['--yes', '--json', '--no-test-email', '--app-url', APP_URL], @@ -337,6 +343,28 @@ describe('init --app-url: the brand', () => { ).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 }) From 11b92659aafc1ac8754e8998ac54b89b0763c726 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 02:01:05 -0500 Subject: [PATCH 23/44] docs(changelog): 0.23.0 bundles the #2169 head b884e78157 spec Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8384db21..d49d338b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Not released yet. Needs `@brew.new/sdk` 13.7.0: `integrations.connect` and 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 `5084a5202f` (main's spec, brew-v2 `373e900a9a`, plus the DX delta). +head `b884e78157` (main's spec, brew-v2 `373e900a9a`, plus the DX delta). ### Added From 56d36aad4dc8ba68814f01459b965626fde8639a Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 02:13:40 -0500 Subject: [PATCH 24/44] fix(init): a rollback that cannot write brew.json fails its state step --rollback-provider reported the state step done and exited 0 even when writer.writeFile refused brew.json (a symbolic link out of the project), so the restored provider's record stayed and a later run could repeat the rollback. A refused write is now a failed state step naming why (exit 1), as the .env.local step already reports one. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++- src/lib/init/pipeline.ts | 18 +++++++++++++----- tests/commands/init-providers.test.ts | 17 +++++++++++++++++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d49d338b..42f2795d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,7 +58,8 @@ head `b884e78157` (main's spec, brew-v2 `373e900a9a`, plus the DX delta). - **`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. + 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 diff --git a/src/lib/init/pipeline.ts b/src/lib/init/pipeline.ts index 674893b2..edcf6415 100644 --- a/src/lib/init/pipeline.ts +++ b/src/lib/init/pipeline.ts @@ -2237,11 +2237,19 @@ async function rollbackOnly(input: { { overwrite: true } ) files.push({ path: BREW_STATE_FILE, action: change.action, role: 'state' }) - steps.push({ - id: 'state', - status: 'done', - detail: 'restored providers removed from providerConfig', - }) + 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' }) } diff --git a/tests/commands/init-providers.test.ts b/tests/commands/init-providers.test.ts index e61d4bb9..1625bc98 100644 --- a/tests/commands/init-providers.test.ts +++ b/tests/commands/init-providers.test.ts @@ -2,6 +2,7 @@ import { existsSync, mkdtempSync, readFileSync, + rmSync, symlinkSync, writeFileSync, } from 'node:fs' @@ -812,6 +813,22 @@ describe('init: a provider changes only when nothing breaks', () => { 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' From ec74349b422a112346f5f830d580b155e53b906e Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 02:51:58 -0500 Subject: [PATCH 25/44] fix(integrations): connect counts auth emails set up, not ready A connect re-run leaves an auth email someone unpublished in Brew off, and a sandbox sender reaches the team only, yet the human output called every key that set up "ready". It now says "set up" and points at app-emails list for which ones send; the response is unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 +++++--- README.md | 3 ++- skills/brew-cli/SKILL.md | 3 ++- src/commands/integrations/connect.ts | 4 +++- src/generated/skills.ts | 2 +- tests/commands/integrations-connect.test.ts | 9 ++++++++- 6 files changed, 21 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42f2795d..5c813a2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,9 +23,11 @@ head `b884e78157` (main's spec, brew-v2 `373e900a9a`, plus the DX delta). `{ 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, and - keys that failed are finished; while one has failed it exits 1, with - the full response. A Clerk connected on its integration page + 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: diff --git a/README.md b/README.md index fd2e41e2..5b0af047 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,8 @@ brew-cli integrations connection clerk # pending_verification until the first 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, and keys that failed are finished. A +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). diff --git a/skills/brew-cli/SKILL.md b/skills/brew-cli/SKILL.md index ebf23348..5cef79a4 100644 --- a/skills/brew-cli/SKILL.md +++ b/skills/brew-cli/SKILL.md @@ -124,7 +124,8 @@ 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). `init` can do +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 diff --git a/src/commands/integrations/connect.ts b/src/commands/integrations/connect.ts index 35103a96..1cdc3e1e 100644 --- a/src/commands/integrations/connect.ts +++ b/src/commands/integrations/connect.ts @@ -120,9 +120,11 @@ function renderConnected( `Connected ${result.provider} (${result.status})`, `Webhook URL: ${result.webhookUrl}`, `Connection id: ${result.connectionId}`, - `Auth emails: ${result.authEmails.length - failed.length} ready${failed.length === 0 ? '' : `, ${failed.length} failed: ${failed.map((email) => `${email.triggerKey} (${email.error?.code ?? 'error'})`).join(', ')} (connect again to finish them)`}`, + `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( diff --git a/src/generated/skills.ts b/src/generated/skills.ts index 2dad881b..93c32989 100644 --- a/src/generated/skills.ts +++ b/src/generated/skills.ts @@ -20,7 +20,7 @@ export const EMBEDDED_SKILLS: readonly EmbeddedSkill[] = [ 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\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). `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", + 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/tests/commands/integrations-connect.test.ts b/tests/commands/integrations-connect.test.ts index 48f8c9a8..18eed533 100644 --- a/tests/commands/integrations-connect.test.ts +++ b/tests/commands/integrations-connect.test.ts @@ -187,7 +187,14 @@ describe('integrations connect', () => { expect(result.stdout).toContain( 'Webhook URL: https://brew.new/api/integrations/clerk/webhook/conn_clerk' ) - expect(result.stdout).toContain('1 failed: auth.otp (INTERNAL_ERROR)') + // 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') }) From 432bc571ba18a67897885cddc2dc99db44c98ad6 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 03:01:34 -0500 Subject: [PATCH 26/44] fix(doctor): a brand read that fails is a failed project check After the key worked, a 503 or connection error listing app emails, domains or the hook connection became skipped, so doctor --project could exit 0 without checking them. Those reads now fail the check; skipped is left for checks that do not apply. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++- src/lib/doctor/project.ts | 16 ++++-------- tests/commands/doctor-project.test.ts | 36 +++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c813a2c..173943ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,7 +86,8 @@ head `b884e78157` (main's spec, brew-v2 `373e900a9a`, plus the DX delta). 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 exits 1, no `brew.json` exits 2. + 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`. diff --git a/src/lib/doctor/project.ts b/src/lib/doctor/project.ts index 841a02aa..bb47a5df 100644 --- a/src/lib/doctor/project.ts +++ b/src/lib/doctor/project.ts @@ -31,7 +31,8 @@ import type { CliContext } from '../types' * 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 cannot run); any `fail` exits 1. Secrets never appear: + * (`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. */ @@ -138,13 +139,6 @@ function describe(error: unknown): string { return error instanceof Error ? error.message : String(error) } -function isAuthError(error: unknown): boolean { - return ( - (error instanceof BrewApiError || error instanceof CliApiError) && - (error.status === 401 || error.status === 403) - ) -} - export async function runProjectDoctor( ctx: CliContext ): Promise { @@ -496,7 +490,7 @@ async function checkAppEmails( ctx.signal.throwIfAborted() return { id: 'app-emails', - status: isAuthError(error) ? 'fail' : 'skipped', + status: 'fail', detail: `could not list app emails: ${describe(error)}`, } } @@ -581,7 +575,7 @@ async function checkDomain( ctx.signal.throwIfAborted() return { id: 'domain', - status: 'skipped', + status: 'fail', detail: `could not list domains: ${describe(error)}`, } } @@ -641,7 +635,7 @@ async function checkProviders( ctx.signal.throwIfAborted() checks.push({ id: 'provider', - status: isAuthError(error) ? 'fail' : 'skipped', + status: 'fail', detail: `${provider}: could not read the connection: ${describe(error)}`, }) } diff --git a/tests/commands/doctor-project.test.ts b/tests/commands/doctor-project.test.ts index da5c112a..7a0a6040 100644 --- a/tests/commands/doctor-project.test.ts +++ b/tests/commands/doctor-project.test.ts @@ -333,6 +333,42 @@ describe('doctor --project', () => { 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', From 72dc77d47c1d4f708df20d60f614eb5b31fef856 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 03:20:47 -0500 Subject: [PATCH 27/44] fix(init): a refused Supabase project change keeps its backup paired Configuring another project saves its hook settings to brew.json and its secret to .env.local before connecting. When an auth email was not ready, or the readiness read failed, the step returned the earlier project's settings, so brew.json and the backed-up secret described different hooks and a rollback would restore one hook with the other's secret. Those results now keep the settings that were saved. Co-Authored-By: Claude Opus 5.5 --- src/lib/init/providers/step.ts | 6 +- tests/commands/init-provider-backup.test.ts | 63 +++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 tests/commands/init-provider-backup.test.ts diff --git a/src/lib/init/providers/step.ts b/src/lib/init/providers/step.ts index 61cbd29e..41e2d1d2 100644 --- a/src/lib/init/providers/step.ts +++ b/src/lib/init/providers/step.ts @@ -489,6 +489,8 @@ async function supabaseStep( 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> @@ -517,7 +519,7 @@ async function supabaseStep( reachable = await publicKeys(client) } catch (error) { rethrowIfFatal(ctx, error) - return failed(input, 'supabase_auth', describe(error), ref, connection) + return failed(withSaved, 'supabase_auth', describe(error), ref, connection) } const notReady = connected.authEmails .filter((email) => !email.ok || !reachable.has(email.triggerKey)) @@ -532,7 +534,7 @@ async function supabaseStep( blocking: true, }) return refused( - input, + withSaved, 'supabase_auth', connectionOf(connected), ref, diff --git a/tests/commands/init-provider-backup.test.ts b/tests/commands/init-provider-backup.test.ts new file mode 100644 index 00000000..f8889cec --- /dev/null +++ b/tests/commands/init-provider-backup.test.ts @@ -0,0 +1,63 @@ +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}` + ) + }) +}) From a30ebd569f37909782b3f0fa02f71c81665c4294 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 03:26:46 -0500 Subject: [PATCH 28/44] fix(doctor): skip the SDK check where init installs no SDK init installs no @brew.new/sdk in a browser-only Vite app or Supabase Edge Functions (they send from the server or import npm:@brew.new/sdk), yet doctor --project failed those apps for not declaring it. The SDK check is now skipped for them, from the framework brew.json records. Co-Authored-By: Claude Opus 5.5 --- src/lib/doctor/project.ts | 13 +++++++++++++ tests/commands/doctor-project.test.ts | 22 +++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/lib/doctor/project.ts b/src/lib/doctor/project.ts index bb47a5df..c46a9c37 100644 --- a/src/lib/doctor/project.ts +++ b/src/lib/doctor/project.ts @@ -306,6 +306,19 @@ export async function runProjectDoctor( 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', diff --git a/tests/commands/doctor-project.test.ts b/tests/commands/doctor-project.test.ts index 7a0a6040..79c8c7d4 100644 --- a/tests/commands/doctor-project.test.ts +++ b/tests/commands/doctor-project.test.ts @@ -1,4 +1,10 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { HttpResponse, http } from 'msw' @@ -217,6 +223,20 @@ describe('doctor --project', () => { 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 }) From 8cac402585e65257de72f8a456d5dbae0168219c Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 03:36:41 -0500 Subject: [PATCH 29/44] docs(changelog): 0.23.0 bundles the #2169 head 02a3946b18 spec Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 173943ae..23f422a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Not released yet. Needs `@brew.new/sdk` 13.7.0: `integrations.connect` and 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 `b884e78157` (main's spec, brew-v2 `373e900a9a`, plus the DX delta). +head `02a3946b18` (main's spec, brew-v2 `373e900a9a`, plus the DX delta). ### Added From a41a3da0b790e5ba93579426989a098c444324ab Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 03:47:15 -0500 Subject: [PATCH 30/44] fix(init): a printed Supabase re-run keeps the project --supabase-project picked The rerun-supabase-connect command (an auth email not ready yet) and the configure-supabase-hook command dropped --supabase-project, so a re-run detected the project again from the app's files and could change another project's Send Email hook, or find none. Both now carry the picked ref. Co-Authored-By: Claude Opus 5.5 --- src/lib/init/providers/step.ts | 10 ++++++++-- tests/commands/init-providers.test.ts | 12 ++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/lib/init/providers/step.ts b/src/lib/init/providers/step.ts index 41e2d1d2..20e87280 100644 --- a/src/lib/init/providers/step.ts +++ b/src/lib/init/providers/step.ts @@ -231,6 +231,12 @@ async function supabaseStep( 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) @@ -250,7 +256,7 @@ async function supabaseStep( id: 'configure-supabase-hook', actor: 'agent', kind: 'run-command', - command: `${input.init} --configure-supabase${ref === null ? ' --supabase-project ' : ''} --yes`, + 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, } @@ -529,7 +535,7 @@ async function supabaseStep( id: 'rerun-supabase-connect', actor: 'agent', kind: 'run-command', - command: `${input.init} --configure-supabase --yes`, + 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, }) diff --git a/tests/commands/init-providers.test.ts b/tests/commands/init-providers.test.ts index 1625bc98..f8e96599 100644 --- a/tests/commands/init-providers.test.ts +++ b/tests/commands/init-providers.test.ts @@ -778,6 +778,18 @@ describe('init: a provider changes only when nothing breaks', () => { } ) + 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({ From 41a818c86acad97d7dc12e57658e34e5b5713a6c Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 03:52:24 -0500 Subject: [PATCH 31/44] fix(init): an organization key whose brand list fails holds every brand step init --app-url with an organization key acting as brand A answered a failed GET /v1/brands as a website mismatch on A: the brand step was done and the domain, kit and provider steps ran in A, though the app may be another brand's. A failed list now fails the brand step (exit 1), holds those steps like a failed brand lookup, and asks for a re-run or --brand. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++- src/lib/init/brand.ts | 24 +++++++++++++++++++--- tests/commands/init-app-url.test.ts | 32 ++++++++++++++++++++++++++--- 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23f422a6..889d96c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,7 +73,8 @@ head `02a3946b18` (main's spec, brew-v2 `373e900a9a`, plus the DX delta). 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 - fails, the domain, kit and provider steps wait for a re-run (exit 1). + 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 diff --git a/src/lib/init/brand.ts b/src/lib/init/brand.ts index 9cee6210..4bd164ab 100644 --- a/src/lib/init/brand.ts +++ b/src/lib/init/brand.ts @@ -174,9 +174,27 @@ export async function resolveAppBrand(input: { if (isForbidden(error)) { throw error } - return mismatch( - `Could not list brands (${describe(error)}); pick the brand for ${host} with --brand ` - ) + // 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 diff --git a/tests/commands/init-app-url.test.ts b/tests/commands/init-app-url.test.ts index 047788b9..5d58a3a2 100644 --- a/tests/commands/init-app-url.test.ts +++ b/tests/commands/init-app-url.test.ts @@ -41,10 +41,10 @@ function project(fixture = 'express-resend'): string { return root } -/** `GET /v1/brand` is unavailable (after the SDK's retries). */ -function failBrandLookup(): void { +/** `GET /v1/brand` (or `path`) is unavailable (after the SDK's retries). */ +function failBrandLookup(path = '/v1/brand'): void { server.use( - http.get(`${API}/v1/brand`, () => + http.get(`${API}${path}`, () => HttpResponse.json( { error: { @@ -343,6 +343,32 @@ describe('init --app-url: the brand', () => { ).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 = [ From a6df37d64dfc83fff6ba2782adbb97cb0fe0f124 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 04:33:43 -0500 Subject: [PATCH 32/44] fix(init): provider follow-ups name this run's brand and server The printed integrations connect / connection commands resolve their own key and brand (the CLI's saved key, BREW_BRAND_ID, BREW_API_URL), not the app key in .env.local, so after an init for brand B they could connect the CLI's brand A, or another server. They and the init follow-ups (--configure-clerk, --configure-supabase, --rollback-provider) now carry --brand and --api-url when a plain run would resolve another brand or server; never a credential. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++- src/lib/init/pipeline.ts | 25 ++++++++++++++++- src/lib/init/providers/step.ts | 17 ++++++++---- tests/commands/init-providers.test.ts | 40 +++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 889d96c3..6b290f80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,7 +65,10 @@ head `02a3946b18` (main's spec, brew-v2 `373e900a9a`, plus the DX delta). - 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. + 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 diff --git a/src/lib/init/pipeline.ts b/src/lib/init/pipeline.ts index edcf6415..47874cb7 100644 --- a/src/lib/init/pipeline.ts +++ b/src/lib/init/pipeline.ts @@ -1316,6 +1316,28 @@ export async function runInit( 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, @@ -1327,7 +1349,8 @@ export async function runInit( client, writer, prefix, - init: `${prefix} init${options.app === undefined ? '' : ` --app ${shellQuote(options.app)}`}`, + scope, + init: `${prefix} init${options.app === undefined ? '' : ` --app ${shellQuote(options.app)}`}${scope}`, providerConfig, // brew.json holds the provider's old settings BEFORE they change. persist: (config) => { diff --git a/src/lib/init/providers/step.ts b/src/lib/init/providers/step.ts index 20e87280..d2508c49 100644 --- a/src/lib/init/providers/step.ts +++ b/src/lib/init/providers/step.ts @@ -125,7 +125,12 @@ export type ProviderStepInput = { readonly client: BrewClient | undefined readonly writer: ProjectWriter readonly prefix: string - /** ` init`, with the run's `--app `. */ + /** + * ` --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 /** @@ -249,7 +254,7 @@ async function supabaseStep( 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 --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`, + 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, }, { @@ -402,7 +407,7 @@ async function supabaseStep( 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\` with a new secret and point every project's hook at ${connection.webhookUrl} with it`, + 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( @@ -576,7 +581,7 @@ async function supabaseStep( 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\` turns active`, + 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, }, { @@ -632,7 +637,7 @@ async function clerkStep( 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 --signing-secret-env CLERK_WEBHOOK_SIGNING_SECRET --yes\`, then change the endpoint URL to the webhook URL it prints`, + 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') { @@ -641,7 +646,7 @@ async function clerkStep( 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\` turns active`, + 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, }) } diff --git a/tests/commands/init-providers.test.ts b/tests/commands/init-providers.test.ts index f8e96599..53f71ec2 100644 --- a/tests/commands/init-providers.test.ts +++ b/tests/commands/init-providers.test.ts @@ -9,6 +9,7 @@ import { 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, @@ -526,6 +527,45 @@ describe('init: Clerk delivery', () => { }) }) + 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('without a Clerk connection, prints how to connect it; Clerk keeps its emails', async () => { const root = clerkProject() useBrewApi() From d35925aa0e5b8ab11dbca929d3e0faf4b6645125 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 04:36:03 -0500 Subject: [PATCH 33/44] test(init): import SpawnFn for the failed-install case from #55 #55's failed-install test types its spawn as SpawnFn; this PR moved the init test helpers out of init.test.ts and its SpawnFn import with them. Co-Authored-By: Claude Opus 5.5 --- tests/commands/init.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/commands/init.test.ts b/tests/commands/init.test.ts index a2a54f99..824f3662 100644 --- a/tests/commands/init.test.ts +++ b/tests/commands/init.test.ts @@ -16,6 +16,7 @@ import { import { readConfig, writeConfig } from '../../src/lib/config-store' import { renderReport } from '../../src/lib/init/report' import { validateBrewState } from '../../src/lib/project/state' +import type { SpawnFn } from '../../src/lib/types' import { copyFixture, tempProject } from '../helpers/fixtures' import { API, From c2b854005c46f0bcc4381768ab8127263d76a16a Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 04:48:08 -0500 Subject: [PATCH 34/44] fix(doctor): --project reads every page of domains before calling none verified The domain check read the first 100 domains only, so a brand whose verified sending domain sat on a later page was told it had none and sent to the DNS setup. It now pages through them like init does. Co-Authored-By: Claude Opus 5.5 --- src/lib/doctor/project.ts | 13 +++++++++--- tests/commands/doctor-project.test.ts | 29 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/lib/doctor/project.ts b/src/lib/doctor/project.ts index c46a9c37..bd025052 100644 --- a/src/lib/doctor/project.ts +++ b/src/lib/doctor/project.ts @@ -15,6 +15,7 @@ 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' @@ -564,8 +565,14 @@ async function checkDomain( client: BrewClient ): Promise { try { - const list = await client.domains.list({ limit: 100 }) - const sending = list.data.find( + // 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) { @@ -575,7 +582,7 @@ async function checkDomain( detail: `sending from ${sending.name}`, } } - const pending = list.data.map((row) => `${row.name} (${row.status})`) + const pending = rows.map((row) => `${row.name} (${row.status})`) return { id: 'domain', status: 'warn', diff --git a/tests/commands/doctor-project.test.ts b/tests/commands/doctor-project.test.ts index 79c8c7d4..8c396364 100644 --- a/tests/commands/doctor-project.test.ts +++ b/tests/commands/doctor-project.test.ts @@ -426,6 +426,35 @@ describe('doctor --project', () => { expect(statusOf(notConnected.report, 'provider')).toEqual(['warn']) }) + 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({ From a0121da3ded995c9657f8dc2102773b0c2a88b1a Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 04:56:59 -0500 Subject: [PATCH 35/44] fix(doctor): --project sends the .env.local key only to its saved server doctor --project paired the app key from .env.local with the server from --api-url, BREW_API_URL or the CLI config, so for an app set up against another server it sent that key elsewhere and reported that account. It now uses the BREW_API_URL saved beside the key (none = the default), as init does, and refuses another server named explicitly (exit 2) before any request. Co-Authored-By: Claude Opus 5.5 --- src/lib/doctor/project.ts | 42 ++++++++++++++++++++++----- tests/commands/doctor-project.test.ts | 33 +++++++++++++++++++++ 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/lib/doctor/project.ts b/src/lib/doctor/project.ts index bd025052..afaa68da 100644 --- a/src/lib/doctor/project.ts +++ b/src/lib/doctor/project.ts @@ -4,13 +4,19 @@ 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 { reachesSdkRange, SDK_PACKAGE, SDK_RANGE } from '../init/templates' +import { + API_URL_VARIABLE, + reachesSdkRange, + SDK_PACKAGE, + SDK_RANGE, +} from '../init/templates' import { findConnection, type HostedAuthProvider, @@ -205,12 +211,15 @@ export async function runProjectDoctor( // ---- The app key: present, and never under a browser-exposed name. const files = envFiles(root) - const envLocalKey = (() => { - const text = readTextFile(join(root, '.env.local')) + const envLocalText = readTextFile(join(root, '.env.local')) + const envLocalValue = (name: string): string | undefined => { const value = - text === null ? undefined : getEnvValue(parseEnv(text), APP_KEY_VARIABLE) + 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 @@ -360,7 +369,11 @@ export async function runProjectDoctor( } // ---- The brand's side, with the app's key. - const auth = resolveProjectAuth(ctx, { envLocalKey, state }) + 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({ @@ -431,6 +444,8 @@ 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 { @@ -443,11 +458,24 @@ function resolveProjectAuth( 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: resolved.apiUrl, + apiUrl: saved, } } if (resolved.apiKeySource === 'config') { diff --git a/tests/commands/doctor-project.test.ts b/tests/commands/doctor-project.test.ts index 8c396364..c1695fad 100644 --- a/tests/commands/doctor-project.test.ts +++ b/tests/commands/doctor-project.test.ts @@ -9,6 +9,7 @@ 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' @@ -426,6 +427,38 @@ describe('doctor --project', () => { 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 a verified sending domain past the first page of domains', async () => { const root = healthyProject() useProjectApi({ verifiedDomain: true }) From d26b14b46b6db33da081ad97a8d4e8a2823b8024 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 04:57:27 -0500 Subject: [PATCH 36/44] fix(init): provider follow-ups keep a --stack override The printed init follow-ups (--configure-clerk, --configure-supabase, --rollback-provider) kept --app and the brand and server but dropped --stack, so a run that needed --stack because detection was wrong printed a command that detected the old stack and exited 2. Co-Authored-By: Claude Opus 5.5 --- src/lib/init/pipeline.ts | 3 ++- tests/commands/init-providers.test.ts | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/lib/init/pipeline.ts b/src/lib/init/pipeline.ts index 47874cb7..fcd944a1 100644 --- a/src/lib/init/pipeline.ts +++ b/src/lib/init/pipeline.ts @@ -1350,7 +1350,8 @@ export async function runInit( writer, prefix, scope, - init: `${prefix} init${options.app === undefined ? '' : ` --app ${shellQuote(options.app)}`}${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) => { diff --git a/tests/commands/init-providers.test.ts b/tests/commands/init-providers.test.ts index 53f71ec2..23fbae3c 100644 --- a/tests/commands/init-providers.test.ts +++ b/tests/commands/init-providers.test.ts @@ -566,6 +566,21 @@ describe('init: Clerk delivery', () => { } ) + 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() From ee101e26b8e75ef223db40d87b8fec583eda7dc7 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 05:05:26 -0500 Subject: [PATCH 37/44] fix(doctor): a public Brew setting that holds no key passes the exposure check looksLikeKey flagged every browser-exposed name containing BREW, so VITE_BREW_API_URL=https://brew.new/api or an empty VITE_BREW_API_KEY placeholder failed doctor --project with advice to revoke a key. It now needs a value, and either a Brew key-like name (KEY, SECRET, TOKEN), a brew_ value, or the app key itself. Co-Authored-By: Claude Opus 5.5 --- src/lib/doctor/project.ts | 5 ++++- tests/commands/doctor-project.test.ts | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/lib/doctor/project.ts b/src/lib/doctor/project.ts index afaa68da..ab7833bb 100644 --- a/src/lib/doctor/project.ts +++ b/src/lib/doctor/project.ts @@ -242,9 +242,12 @@ export async function runProjectDoctor( } ) 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) && - (/BREW/i.test(name) || + value !== '' && + (/BREW.*(?:KEY|SECRET|TOKEN)/i.test(name) || value.startsWith('brew_') || (envLocalKey !== undefined && value === envLocalKey)) for (const file of files) { diff --git a/tests/commands/doctor-project.test.ts b/tests/commands/doctor-project.test.ts index c1695fad..ff7f2f89 100644 --- a/tests/commands/doctor-project.test.ts +++ b/tests/commands/doctor-project.test.ts @@ -273,6 +273,19 @@ describe('doctor --project', () => { }) }) + 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(), { From c45651f86d1f44144b66a24c1565c3ae65574d1c Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 05:12:14 -0500 Subject: [PATCH 38/44] fix(init): the .env.local key's brand is asked on its own server When --app-url picks a brand with an organization key, init asks which brand the app key in .env.local is bound to. That client used the run's server, so with --api-url (or BREW_API_URL) for another server the app key went there. It now asks only the server saved beside the key (BREW_API_URL in .env.local, none = the default). Co-Authored-By: Claude Opus 5.5 --- src/lib/init/pipeline.ts | 10 ++++++-- tests/commands/init-app-url.test.ts | 36 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/lib/init/pipeline.ts b/src/lib/init/pipeline.ts index fcd944a1..ab7b4252 100644 --- a/src/lib/init/pipeline.ts +++ b/src/lib/init/pipeline.ts @@ -668,10 +668,16 @@ export async function runInit( // 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 a key is bound to, or undefined when it cannot tell. */ + /** + * 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 + 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) { diff --git a/tests/commands/init-app-url.test.ts b/tests/commands/init-app-url.test.ts index 5d58a3a2..62006711 100644 --- a/tests/commands/init-app-url.test.ts +++ b/tests/commands/init-app-url.test.ts @@ -172,6 +172,42 @@ describe('init --app-url: the brand', () => { ).toBe(true) }) + it('asks about the app key only on the server saved beside it', 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, + }) + }) + ) + await init(['--yes', '--json', '--no-test-email', '--app-url', APP_URL], { + cwd: root, + env: configEnv({ BREW_API_KEY: EXPLICIT_KEY }), + spawn: recordingSpawn().spawn, + }) + expect( + requests.filter( + (request) => request.authorization === `Bearer ${APP_KEY}` + ) + ).toEqual([]) + expect(asked).toEqual([`Bearer ${APP_KEY}`]) + }) + 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 }) From 39a1206e243a94ced51360215acd12591509c751 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 05:19:22 -0500 Subject: [PATCH 39/44] fix(doctor): --project reads app emails until every brew.json key is found The app-emails check stopped after 20 pages even when more were left, so a brand with over 2,000 app emails could see working keys reported as missing. It now reads until every key in brew.json is found or the pages end (a repeated cursor ends it too). Co-Authored-By: Claude Opus 5.5 --- src/lib/doctor/project.ts | 16 ++++++++++--- tests/commands/doctor-project.test.ts | 34 +++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/lib/doctor/project.ts b/src/lib/doctor/project.ts index ab7833bb..dce6476b 100644 --- a/src/lib/doctor/project.ts +++ b/src/lib/doctor/project.ts @@ -514,8 +514,11 @@ async function checkAppEmails( } >() try { + // Every page until each brew.json key is found (a repeated cursor ends it). let cursor: string | undefined - for (let page = 0; page < 20; page += 1) { + const seen = new Set() + for (;;) { + ctx.signal.throwIfAborted() const result = await client.appEmails.list({ limit: 100, ...(cursor === undefined ? {} : { cursor }), @@ -526,10 +529,17 @@ async function checkAppEmails( readiness: row.readiness, }) } - if (!result.pagination.hasMore || result.pagination.cursor === null) { + const next = result.pagination.cursor + if ( + keys.every((key) => rows.has(key)) || + !result.pagination.hasMore || + next === null || + seen.has(next) + ) { break } - cursor = result.pagination.cursor + seen.add(next) + cursor = next } } catch (error) { ctx.signal.throwIfAborted() diff --git a/tests/commands/doctor-project.test.ts b/tests/commands/doctor-project.test.ts index ff7f2f89..a9393db9 100644 --- a/tests/commands/doctor-project.test.ts +++ b/tests/commands/doctor-project.test.ts @@ -472,6 +472,40 @@ describe('doctor --project', () => { 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 }) From e073a5c88f08378b221b8e8bcf59629d434023ac Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 05:28:49 -0500 Subject: [PATCH 40/44] docs(changelog): 0.23.0 bundles 0.20.0's spec, the #2169 head 6e4accea4c Restacked on #61, which sits on #54's spec from brew-v2 6e4accea4c (sha256 b86fb228...178bd3). This PR adds no spec change of its own. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b290f80..62ceceec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Not released yet. Needs `@brew.new/sdk` 13.7.0: `integrations.connect` and 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 `02a3946b18` (main's spec, brew-v2 `373e900a9a`, plus the DX delta). +head `6e4accea4c` (main's spec, brew-v2 `373e900a9a`, plus the DX delta). ### Added From 6ae554532ac2df1dd0c18425ccc39acec489287c Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 05:44:05 -0500 Subject: [PATCH 41/44] fix(init): a refused save keeps the old hook secret; read every app email page Two Greptile findings on #63, each with a test that was red first. P1: a --configure-supabase run for another project backed up that project's hook secret in .env.local before brew.json saved the new settings. When brew.json refused the write (a link outside the project), it kept the first project's settings but .env.local now held the other project's secret, so --rollback-provider would restore the first hook with the wrong secret. The step now remembers the backup it replaces and puts it back (or removes the new one) when the save fails. backupHookSecret takes undefined to remove the backup; savedHookSecret reads it. P2: publicKeys stopped after 20 pages of GET /v1/app-emails, so on a brand with more than 2,000 app emails a ready auth key on a later page counted as not ready, and Supabase setup refused every run (or Clerk kept its templates). It now reads until every key the provider needs has its row or the list ends, and stops on a repeated cursor instead of a page cap. Co-Authored-By: Claude Opus 5.5 --- src/lib/init/providers/step.ts | 41 +++++++++-- src/lib/init/providers/supabase.ts | 23 ++++-- tests/commands/init-provider-backup.test.ts | 44 +++++++++++ tests/commands/init-provider-pages.test.ts | 81 +++++++++++++++++++++ 4 files changed, 177 insertions(+), 12 deletions(-) create mode 100644 tests/commands/init-provider-pages.test.ts diff --git a/src/lib/init/providers/step.ts b/src/lib/init/providers/step.ts index d2508c49..665788c5 100644 --- a/src/lib/init/providers/step.ts +++ b/src/lib/init/providers/step.ts @@ -31,6 +31,7 @@ import { type SendEmailHookConfig, SUPABASE_PREVIOUS_SECRETS_VARIABLE, SUPABASE_TOKEN_VARIABLE, + savedHookSecret, supabaseAuthConfigUrl, supabaseHooksUrl, supabaseProjectUrl, @@ -472,6 +473,8 @@ async function supabaseStep( 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)) { @@ -497,6 +500,10 @@ async function supabaseStep( }, } 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) } } @@ -527,7 +534,10 @@ async function supabaseStep( // every user (not the team-only sandbox) before Supabase stops sending. let reachable: Set try { - reachable = await publicKeys(client) + reachable = await publicKeys( + client, + connected.authEmails.map((email) => email.triggerKey) + ) } catch (error) { rethrowIfFatal(ctx, error) return failed(withSaved, 'supabase_auth', describe(error), ref, connection) @@ -752,7 +762,10 @@ async function clerkStep( // email is published and not in the team-only sandbox. let reachable: Set try { - reachable = await publicKeys(input.client) + reachable = await publicKeys( + input.client, + Object.values(CLERK_EMAIL_SLUG_KEYS) + ) } catch (error) { rethrowIfFatal(ctx, error) return failed(input, 'clerk', describe(error), undefined, connection) @@ -922,24 +935,38 @@ async function clerkStep( } } -/** Keys whose app email a fire sends now to anyone, not the sandbox only. */ -async function publicKeys(client: BrewClient): Promise> { +/** + * 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 - for (let page = 0; page < 20; page += 1) { + 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) } } - if (!result.pagination.hasMore || result.pagination.cursor === null) { + const next = result.pagination.cursor + if (!result.pagination.hasMore || next === null || seen.has(next)) { break } - cursor = result.pagination.cursor + seen.add(next) + cursor = next } return keys } diff --git a/src/lib/init/providers/supabase.ts b/src/lib/init/providers/supabase.ts index 9b489dfd..dbd9144e 100644 --- a/src/lib/init/providers/supabase.ts +++ b/src/lib/init/providers/supabase.ts @@ -83,23 +83,36 @@ export function localValue( 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). False when it could not be saved as it is. + * 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 + 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 { text } = ensureEnvVars(serializeEnv({ ...env, lines }), [ - { name: SUPABASE_PREVIOUS_SECRETS_VARIABLE, value: secrets }, - ]) + 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 ) { diff --git a/tests/commands/init-provider-backup.test.ts b/tests/commands/init-provider-backup.test.ts index f8889cec..c4972193 100644 --- a/tests/commands/init-provider-backup.test.ts +++ b/tests/commands/init-provider-backup.test.ts @@ -1,3 +1,6 @@ +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, @@ -60,4 +63,45 @@ describe('init: the Supabase hook backup', () => { `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', + }) + }) +}) From c72830b9311907d1b1d489ee9254f6d43c233468 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 06:37:27 -0500 Subject: [PATCH 42/44] fix(init): an organization key reads every brand page for the app's brand resolveAppBrand stopped after 10 pages of 100 brands, so a matching brand past the first 1,000 was missed and init tried to create another, which Brew refuses as BRAND_DOMAIN_CONFLICT with a generic step. The lookup now reads pages until a match or the list's end; a cursor seen before ends it, so a server repeating one cannot loop it. Co-Authored-By: Claude Opus 5.5 --- src/lib/init/brand.ts | 12 +++-- tests/commands/init-app-url.test.ts | 79 +++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/src/lib/init/brand.ts b/src/lib/init/brand.ts index 4bd164ab..bd477785 100644 --- a/src/lib/init/brand.ts +++ b/src/lib/init/brand.ts @@ -152,8 +152,12 @@ export async function resolveAppBrand(input: { } | 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 - for (let page = 0; page < 10 && match === undefined; page += 1) { + const seen = new Set() + while (match === undefined) { const result = await client.brands.list({ limit: 100, ...(cursor === undefined ? {} : { cursor }), @@ -164,10 +168,12 @@ export async function resolveAppBrand(input: { row.status !== 'failed' && brandMatchesApp(row.domain, appUrl) ) - if (!result.pagination.hasMore || result.pagination.cursor === null) { + const next = result.pagination.cursor + if (!result.pagination.hasMore || next === null || seen.has(next)) { break } - cursor = result.pagination.cursor + seen.add(next) + cursor = next } } catch (error) { ctx.signal.throwIfAborted() diff --git a/tests/commands/init-app-url.test.ts b/tests/commands/init-app-url.test.ts index 62006711..f9d5e708 100644 --- a/tests/commands/init-app-url.test.ts +++ b/tests/commands/init-app-url.test.ts @@ -150,6 +150,85 @@ describe('init --app-url: the brand', () => { ).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, From ba40e0d9b4f889ebc732ba03d7483b54ee2e473a Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 06:49:10 -0500 Subject: [PATCH 43/44] fix(init): an app key for another server never clears a brand switch With an organization key on one server and .env.local's app key (with its BREW_API_URL) on another, the --app-url brand check asked the app key's server which brand the key is bound to and compared only the id. A staging copy with production's brand ids passed, so the domain, kit and provider setup ran on the organization key's server while the app kept sending to the other. The app key now holds the setup unless it is on this run's server and bound to the brand picked; the replace-app-key step names both servers when they differ, and the key is not sent anywhere. Co-Authored-By: Claude Opus 5.5 --- src/lib/init/pipeline.ts | 15 +++++++++++---- tests/commands/init-app-url.test.ts | 29 ++++++++++++++++++++++------- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/lib/init/pipeline.ts b/src/lib/init/pipeline.ts index ab7b4252..1f92b08b 100644 --- a/src/lib/init/pipeline.ts +++ b/src/lib/init/pipeline.ts @@ -416,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.` @@ -744,10 +744,15 @@ export async function runInit( brandReady = resolution.ready steps.push(resolution.step) nextSteps.push(...resolution.nextSteps) + // 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 ( resolution.switchTo !== undefined && existingAppKey !== undefined && - (await brandOfKey(existingAppKey)) !== resolution.switchTo + (!keyOnThisServer || + (await brandOfKey(existingAppKey)) !== resolution.switchTo) ) { // 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. @@ -757,7 +762,9 @@ export async function runInit( actor: 'human', kind: 'edit-file', path: '.env.local', - title: `${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`, + 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) { diff --git a/tests/commands/init-app-url.test.ts b/tests/commands/init-app-url.test.ts index f9d5e708..13b079f8 100644 --- a/tests/commands/init-app-url.test.ts +++ b/tests/commands/init-app-url.test.ts @@ -251,7 +251,7 @@ describe('init --app-url: the brand', () => { ).toBe(true) }) - it('asks about the app key only on the server saved beside it', async () => { + 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( @@ -274,17 +274,32 @@ describe('init --app-url: the brand', () => { }) }) ) - await init(['--yes', '--json', '--no-test-email', '--app-url', APP_URL], { - cwd: root, - env: configEnv({ BREW_API_KEY: EXPLICIT_KEY }), - spawn: recordingSpawn().spawn, - }) + 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([`Bearer ${APP_KEY}`]) + 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('an organization key with no matching brand creates one, waits for it, and a re-run finds it instead of creating another', async () => { From 1baeb78ebadf3812a719cbc78fd51dd62e428440 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Sun, 1 Nov 2026 06:55:29 -0500 Subject: [PATCH 44/44] fix(init): check the app key's server and brand when no brand switch is needed The app-key check ran only when --app-url switched the run to another brand. When the run's own key (--api-key or BREW_API_KEY) already had the app's brand, an app key in .env.local on another server, or bound to another brand, still let the domain, kit and provider setup run where the app does not send. The check now covers the brand the run sets up either way, whenever .env.local's key is not the key the run acts as. Co-Authored-By: Claude Opus 5.5 --- src/lib/init/pipeline.ts | 13 ++++-- tests/commands/init-app-url.test.ts | 63 +++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/lib/init/pipeline.ts b/src/lib/init/pipeline.ts index 1f92b08b..43ac4d29 100644 --- a/src/lib/init/pipeline.ts +++ b/src/lib/init/pipeline.ts @@ -744,15 +744,22 @@ export async function runInit( 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 ( - resolution.switchTo !== undefined && + target !== undefined && existingAppKey !== undefined && - (!keyOnThisServer || - (await brandOfKey(existingAppKey)) !== resolution.switchTo) + 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. diff --git a/tests/commands/init-app-url.test.ts b/tests/commands/init-app-url.test.ts index 13b079f8..5ddc9d97 100644 --- a/tests/commands/init-app-url.test.ts +++ b/tests/commands/init-app-url.test.ts @@ -302,6 +302,69 @@ describe('init --app-url: the brand', () => { 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 })