diff --git a/CHANGELOG.md b/CHANGELOG.md index 715038bc..881c1f80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,59 @@ # Changelog +## 0.22.0 + +Needs the same SDK release as 0.20.0, and the API from GetBrew/brew-v2#2169 +(the import modes `props: "merge_tags"`, `placeholders: "resend"` and +`variables`, and the same modes on an app email's `source`); the bundled +spec is the #2169 head `6e4accea4c` (main's spec, brew-v2 `373e900a9a`, +plus the DX delta). + +### Added + +- **`brew-cli migrate resend`** moves a Resend account and the project + that sends through it to Brew. It plans by default: it reads Resend at + 2 requests a second with a full-access key read from `RESEND_API_KEY` + or `--resend-key-stdin` (never sent to Brew, never printed or stored), + reads the Brew brand, scans the code, and writes `brew.migration.json` + (`--dry-run` writes nothing). `--apply` (destructive class) copies: + templates as app emails by key (imported with `placeholders: "resend"` + and their variables' fallbacks, published where they were), React Email + files as app emails with props as merge tags (`props: "merge_tags"`), + contacts (subscribed ones with an import consent record, unsubscribed + ones stay unsubscribed, contacts with no state are left out), segments + and topics as boolean contact fields with an audience each (topics read + each contact's choices, one request per contact), suppressions and + unsubscribed contacts into `--domain`'s unsubscribe list, and draft + broadcasts as Brew draft emails (nothing is sent). Domains, webhooks + and automations are reported. Every write carries an idempotency key, + and `brew.migration.json` keeps cursors, so a re-run resumes; it never + holds an address, a name or a key. A template whose key's trigger + already starts a published automation no setup made + (`APP_EMAIL_TRIGGER_IN_USE`) is left `manual` with the API's message; + the next run sets it up once that automation is unpublished. A setup + whose answer was lost is finished on the same key by the next run, not + set up again under another. A Resend domain that is also in + Brew gets the `DKIM_SELECTOR_IN_USE` advice and a subdomain to verify + instead. Each `resend.emails.send` / `resend.batch.send` call site is + reported with what carries over and a suggested + `getBrew().send(key, payload)`; `--rewrite-imports` moves a file to + `@brew.new/sdk/resend` (with `lib/brew-resend-map.ts`) only when every + send through it is a migrated template whose every field it can name, + and its client reads `RESEND_API_KEY` from the environment. + `--only `, `--key-prefix`, `--manifest`, `--cwd`. A sending-only + Resend key exits 3 (`RESEND_KEY_SENDING_ONLY`); a failure exits 1 with + the cursor saved. + Sources sit behind a `MigrationSource` interface; Resend is the only one. +- The `brew-migrate-from-resend` agent skill (embedded; `init` installs it + in Claude Code projects that use Resend). +- `app-emails upsert` takes the import modes on `source` through + `--input`, e.g. + `--source-file emails/order-shipped.tsx --input '{"source":{"props":"merge_tags"}}'`: + `--source-file` fills `format` and `content` and keeps the rest of + `--input`'s `source` (before, it replaced it), and takes the format from + `--input`'s `source` when it cannot infer one. `migrate resend` names + that command for a React Email file it leaves to move by hand. + ## 0.21.0 Needs the same SDK release as 0.20.0 (`appEmails.setUpKit` and the auth diff --git a/README.md b/README.md index 6910d014..cbe3ae3b 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,40 @@ stack exits 2 before any request. `types` adds a `declare module '@brew.new/sdk'` block for keyed triggers; pass `--no-registry` in a project that does not install the SDK. +## Move from Resend: `migrate resend` + +```bash +printf %s "$RESEND_API_KEY" | brew-cli migrate resend --resend-key-stdin # plan: writes brew.migration.json, changes nothing +printf %s "$RESEND_API_KEY" | brew-cli migrate resend --resend-key-stdin --apply \ + --domain news.example.com --yes # copy it into your Brew brand +``` + +The plan reads the Resend account (with a full-access key that stays on +this machine and is never sent to Brew) at 2 requests a second, reads your +Brew brand, and scans the project for `resend.emails.send` and +`resend.batch.send`. `--apply` (destructive class: exit 4 without `--yes`) +then copies: + +| Resend | Brew | +| --- | --- | +| Templates (`{{{VAR}}}`) | App emails by key, imported with `placeholders: "resend"` | +| React Email files | App emails by key, props as merge tags (`props: "merge_tags"`) | +| Contacts | Contacts (subscribed with an import consent record; unsubscribed stay unsubscribed) | +| Segments, topics | A boolean contact field each, and an audience on it | +| Suppressions, unsubscribed contacts | `--domain`'s unsubscribe list | +| Draft broadcasts | Brew draft emails (nothing is sent) | +| Domains, webhooks, automations | Reported only | + +Progress lives in `brew.migration.json` (ids, keys, counts and cursors, +never an address, a name or a key), so an interrupted apply resumes where +it stopped. Brew signs with the same DKIM selector as Resend +(`resend._domainkey`): the plan suggests a subdomain to verify in Brew +until cutover. Sends that do not carry over (raw `html`, `attachments`, +`cc`, …) become `edit-file` next steps with a suggested +`getBrew().send(key, payload)`; `--rewrite-imports` moves a file to +`@brew.new/sdk/resend` only when every send through it is a migrated +template. + ## Connect your AI editor to Brew (MCP) ```bash diff --git a/RELEASING.md b/RELEASING.md index 43d1ac19..2d246cb6 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -44,9 +44,11 @@ registry; never hand-write a lockfile entry), run a frozen install, the full checks and `docs:commands:check`, and release the CLI. The API side must be live first: GetBrew/brew-v2#2169 deployed (the by-key -fire, `/v1/app-emails`, the team sandbox), and the live spec -(`https://brew.new/openapi/public-api-v1.yaml`, what the spec-drift -sentinel reads) matching `openapi/public-api-v1.yaml`, +fire, `/v1/app-emails`, the team sandbox, the import modes on +`POST /v1/emails/import`, which `migrate resend` sends, and the same modes +on an app email's `source`, which `app-emails upsert --input` passes +through), and the live spec (`https://brew.new/openapi/public-api-v1.yaml`, +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). diff --git a/docs/commands/README.md b/docs/commands/README.md index 3220573d..150db1c0 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -2,7 +2,7 @@ -156 commands. Classes: read (always safe), write +157 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). @@ -177,6 +177,7 @@ a `confirmCommand` otherwise, `--yes` to proceed). | `brew-cli docs api` | read | `GET /v1/help` | Fetch the live machine-readable API catalog (GET /v1/help) | | `brew-cli mcp install` | write | — | Add the Brew MCP server to your AI editors' config (Cursor, VS Code, Claude Code, Gemini CLI, Devin Desktop/Windsurf, Codex) | | `brew-cli init` | destructive | — | Set up Brew app email in this project: sign in, starter kit, SDK, lib/brew.ts, .env.local, MCP, a sandbox test email | +| `brew-cli migrate resend` | destructive | — | Move a Resend account and codebase to Brew: plan by default (writes brew.migration.json), --apply to copy templates, contacts, segments, topics, suppressions and draft broadcasts | | `brew-cli api` | destructive | — | Raw authenticated request against the Brew public API | ## Details @@ -1663,7 +1664,7 @@ Set up one app email by key in one idempotent call (trigger, contract, design, a - SDK: `brew.appEmails.upsert(...)` - Argument `triggerKey` — Trigger key your code sends by: a kit key (auth.password-reset) or your own (order.paid) - `--starter ` — Starter-kit template to use (a kit key alone uses its own starter) -- `--source-file ` — Your own design source (HTML, MJML or React Email JSX), or - for stdin +- `--source-file ` — Your own design source (HTML, MJML or React Email JSX), or - for stdin. Import modes (props, placeholders, variables) go in --input's source - `--format ` — Source format: html | mjml | jsx (default: from the file extension) - `--title ` — App email title in Brew - `--subject <text>` — Subject line @@ -1679,6 +1680,7 @@ brew-cli app-emails upsert auth.password-reset --yes brew-cli app-emails upsert order.paid --source-file emails/order-paid.tsx --subject "Your order is confirmed" --yes brew-cli app-emails upsert lifecycle.welcome --overwrite --yes brew-cli app-emails upsert account.sign-in-code --source-file emails/sign-in-code.tsx --input '{"payloadSchema":{"fields":[{"key":"code","type":"string","required":true,"secret":true}]}}' --yes +brew-cli app-emails upsert order.shipped --source-file emails/order-shipped.tsx --input '{"source":{"props":"merge_tags"}}' --yes ``` ### brew-cli app-emails kit @@ -2863,6 +2865,30 @@ brew-cli init --app apps/web --domain mail.example.com --yes brew-cli init --yes --apply-patches better-auth-config@3f9c2a7d1b04 ``` +### brew-cli migrate resend + +Move a Resend account and codebase to Brew: plan by default (writes brew.migration.json), --apply to copy templates, contacts, segments, topics, suppressions and draft broadcasts + +- Class: destructive +- Derived from `brew.brand.get(...)`, `brew.domains.list(...)`, `brew.appEmails.list(...)`, `brew.emails.import(...)`, `brew.appEmails.upsert(...)`, `brew.emails.delete(...)`, `brew.contacts.upsertMany(...)`, `brew.audiences.list(...)`, `brew.audiences.create(...)`, `brew.domains.unsubscribes.import(...)` +- `--apply` — Execute the plan: import templates as app emails, upsert contacts, create segment and topic audiences, import suppressions, copy draft broadcasts as Brew drafts (resumes from brew.migration.json) +- `--dry-run` — Plan and print the report without writing brew.migration.json +- `--only <phases...>` — Run only these phases: domains, templates, code, contacts, segments, suppressions, topics, broadcasts, webhooks, automations +- `--resend-key-stdin` — Read the Resend API key from stdin (else RESEND_API_KEY); it is never sent to Brew or printed +- `--cwd <dir>` — The project to scan and rewrite (default: the current directory) +- `--domain <domain>` — Brew domain (id or host) whose unsubscribe list takes Resend's suppressions and unsubscribed contacts +- `--manifest <path>` — Where the plan and progress live (default brew.migration.json) +- `--key-prefix <prefix>` — Prefix for the trigger keys made from template aliases and React Email file names (default none), e.g. resend. +- `--rewrite-imports` — With --apply: move files whose every send is a migrated template from 'resend' to '@brew.new/sdk/resend' and write lib/brew-resend-map.ts + +```bash +RESEND_API_KEY=re_123 brew-cli migrate resend +printf %s "$RESEND_API_KEY" | brew-cli migrate resend --resend-key-stdin --json +brew-cli migrate resend --only templates code --dry-run +brew-cli migrate resend --apply --yes +brew-cli migrate resend --apply --rewrite-imports --domain news.example.com --yes +``` + ### brew-cli api Raw authenticated request against the Brew public API diff --git a/package.json b/package.json index b08c6dcf..d1e97b62 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@brew.new/cli", - "version": "0.21.0", + "version": "0.22.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 dff8148e..daf04157 100644 --- a/packages/launchers/brew.new/package.json +++ b/packages/launchers/brew.new/package.json @@ -1,6 +1,6 @@ { "name": "brew.new", - "version": "0.21.0", + "version": "0.22.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.21.0" + "@brew.new/cli": "0.22.0" }, "publishConfig": { "access": "public" diff --git a/packages/launchers/create-brew/package.json b/packages/launchers/create-brew/package.json index 5f265656..a705b7e9 100644 --- a/packages/launchers/create-brew/package.json +++ b/packages/launchers/create-brew/package.json @@ -1,6 +1,6 @@ { "name": "create-brew", - "version": "0.21.0", + "version": "0.22.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.21.0" + "@brew.new/cli": "0.22.0" }, "publishConfig": { "access": "public" diff --git a/skills/brew-cli/SKILL.md b/skills/brew-cli/SKILL.md index d3128727..29ec7913 100644 --- a/skills/brew-cli/SKILL.md +++ b/skills/brew-cli/SKILL.md @@ -105,6 +105,15 @@ replaces a kit key's design built from `--source-file` with the starter, and any other key is refused (400). `automations triggers create --key <key>` gives any trigger a key. +## Moving from Resend + +`brew-cli migrate resend` plans by default (writes `brew.migration.json`, +changes nothing); `--apply --yes` copies templates, React Email files, +contacts, segments, topics, suppressions and draft broadcasts, and +resumes when re-run. Pass the Resend key on stdin with +`--resend-key-stdin`; it never goes to Brew. The +`brew-migrate-from-resend` skill has the full mapping. + ## The contract you can rely on - Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation diff --git a/skills/brew-migrate-from-resend/SKILL.md b/skills/brew-migrate-from-resend/SKILL.md new file mode 100644 index 00000000..b687c3a9 --- /dev/null +++ b/skills/brew-migrate-from-resend/SKILL.md @@ -0,0 +1,59 @@ +--- +name: brew-migrate-from-resend +description: Move an app and its Resend account to Brew with `brew-cli migrate resend`, covering templates and React Email as app emails, contacts, segments, topics, suppressions and broadcast drafts, plus the code that calls resend.emails.send. Use when a project depends on `resend`, someone asks to switch from Resend to Brew, or a migration report (brew.migration.json) needs finishing. +--- + +# Migrate from Resend to Brew + +Plan first, review with the person, then apply. Nothing in Resend is +changed or deleted, and the Resend key never leaves this machine for Brew. + +```bash +# 1. Plan: reads Resend (2 requests a second) and Brew, scans the code, +# writes brew.migration.json. Changes nothing. +printf %s "$RESEND_API_KEY" | npx @brew.new/cli migrate resend --resend-key-stdin --json + +# 2. Apply after the person reviewed the plan (destructive class: exit 4 +# without --yes). Re-running resumes; done items are skipped. +printf %s "$RESEND_API_KEY" | npx @brew.new/cli migrate resend --resend-key-stdin --apply --domain <brew-marketing-domain> --yes --json +``` + +Ask the person for a **full-access** Resend key (a sending-only key exits 3 +with `RESEND_KEY_SENDING_ONLY`). Pass it on stdin; never print it, write +it to a file, or put it in a command line that gets logged. Brew needs its +own key (`brew-cli login`, or `BREW_API_KEY`). + +## What moves where + +| Resend | Brew | +| --- | --- | +| Template (`{{{VAR}}}`) | App email by key (alias or name), published if it was; placeholders become `{{ trigger.VAR }}` | +| React Email file | App email by key (file name); props become merge tags | +| Contact | Contact; subscribed ones with an import consent record, unsubscribed stay unsubscribed; no state, left out | +| Segment | Boolean field `resend_segment_*` and an audience on it | +| Topic | Boolean field `resend_topic_*` (true = opted in) and an audience; one Resend request per contact | +| Suppression, unsubscribed contact | The `--domain`'s unsubscribe list | +| Draft broadcast | A Brew draft email (nothing is sent) | +| Domain, webhook, automation | Reported only | + +Brew signs mail with the same DKIM selector as Resend (`resend._domainkey`), +so a domain cannot verify in both. Verify a subdomain in Brew (the plan +suggests one, e.g. `mail.acme.com`) and keep the Resend domain until +cutover. + +## Finish the code + +- Every remaining `resend.emails.send` / `resend.batch.send` is a blocking + `edit-file` next step with a suggested `getBrew().send(key, payload)`. + `attachments`, `cc`, `bcc`, `headers`, `scheduledAt` and raw `html` do + not carry over: say so to the person instead of dropping them silently. +- `--rewrite-imports` (with `--apply`) moves a file to + `@brew.new/sdk/resend` only when every send through it is a migrated + template, and writes `lib/brew-resend-map.ts`. +- Then send one migrated email to yourself (`brew-cli send <key> --to + <you> --yes`; before a domain verifies, Brew delivers to your team only) + and cut over: production gets `BREW_API_KEY`, then retire + `RESEND_API_KEY` and the `resend` package. + +`brew.migration.json` holds ids, keys, counts and cursors, never an +address, a name or a key: it is safe to commit. diff --git a/src/cli.ts b/src/cli.ts index 50f56ebb..f480d51c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -54,6 +54,8 @@ const GROUP_SUMMARIES: Readonly<Record<string, string>> = { "Brew Insights: findings about the brand's email, plus its pulse, report, suggestions and memo", integrations: 'Read the connected-integration catalog', mcp: 'Connect AI editors and agents to the Brew MCP server', + migrate: + 'Move to Brew from another email provider: plan, then apply (Resend)', 'inbox-placement-tests': 'Inbox placement (seed) test results', notifications: 'What finished or failed: generations, sends, imports, domain checks', diff --git a/src/commands/app-emails/upsert.ts b/src/commands/app-emails/upsert.ts index be26b92b..86b09cd4 100644 --- a/src/commands/app-emails/upsert.ts +++ b/src/commands/app-emails/upsert.ts @@ -4,6 +4,7 @@ import { UPSERT_APP_EMAIL_DEFAULT_TIMEOUT_MS } from '@brew.new/sdk' import { APP_EMAIL_SOURCE_FORMATS, inlineInput, + inputSource, oneOfFlag, } from '../../lib/app-email-inputs' import { defineCommand } from '../../lib/define-command' @@ -52,7 +53,7 @@ export const appEmailsUpsertCommand = defineCommand({ { flag: '--source-file <path>', summary: - 'Your own design source (HTML, MJML or React Email JSX), or - for stdin', + "Your own design source (HTML, MJML or React Email JSX), or - for stdin. Import modes (props, placeholders, variables) go in --input's source", }, { flag: '--format <format>', @@ -89,6 +90,7 @@ export const appEmailsUpsertCommand = defineCommand({ 'brew-cli app-emails upsert order.paid --source-file emails/order-paid.tsx --subject "Your order is confirmed" --yes', 'brew-cli app-emails upsert lifecycle.welcome --overwrite --yes', `brew-cli app-emails upsert account.sign-in-code --source-file emails/sign-in-code.tsx --input '{"payloadSchema":{"fields":[{"key":"code","type":"string","required":true,"secret":true}]}}' --yes`, + `brew-cli app-emails upsert order.shipped --source-file emails/order-shipped.tsx --input '{"source":{"props":"merge_tags"}}' --yes`, ], confirmSummary: ({ args, flags }) => { if (flags.input === '-') { @@ -129,10 +131,14 @@ export const appEmailsUpsertCommand = defineCommand({ const base = await readJsonFlag(ctx, flags.input, '--input') const sourcePath = flagString(flags.sourceFile) const content = await readTextFlag(ctx, sourcePath, '--source-file') - let source: { format: string; content: string } | undefined + let source: Readonly<Record<string, unknown>> | undefined if (content !== undefined) { + // The file fills `format` and `content`; the rest of --input's + // `source` (the import modes) stays. + const baseSource = inputSource(base) const format = explicitFormat ?? + flagString(baseSource?.format) ?? (sourcePath === undefined || sourcePath === '-' ? undefined : FORMAT_BY_EXTENSION[extname(sourcePath).toLowerCase()]) @@ -141,7 +147,7 @@ export const appEmailsUpsertCommand = defineCommand({ '--format is required for this source: html | mjml | jsx.' ) } - source = { format, content } + source = { ...baseSource, format, content } } else if (explicitFormat !== undefined) { throw new CliUsageError('--format applies to --source-file only.') } diff --git a/src/commands/migrate/resend.ts b/src/commands/migrate/resend.ts new file mode 100644 index 00000000..49a4846f --- /dev/null +++ b/src/commands/migrate/resend.ts @@ -0,0 +1,177 @@ +import { resolve } from 'node:path' +import { defineCommand } from '../../lib/define-command' +import { CliUsageError } from '../../lib/errors' +import { flagString } from '../../lib/input' +import { keyPrefixProblem } from '../../lib/migrate/keys' +import { + MIGRATION_MANIFEST_FILE, + PHASES, + type PhaseId, +} from '../../lib/migrate/manifest' +import { runMigration } from '../../lib/migrate/pipeline' +import { MIGRATION_SOURCES } from '../../lib/migrate/resend/source' +import { resolveCwd } from '../../lib/project/process' + +const source = MIGRATION_SOURCES.resend + +/** `--only templates contacts` or `--only templates,contacts`. */ +function parsePhases(value: unknown): PhaseId[] { + if (value === undefined) { + return [...PHASES] + } + const words = (Array.isArray(value) ? value : [value]) + .flatMap((entry) => String(entry).split(',')) + .map((entry) => entry.trim()) + .filter((entry) => entry !== '') + const unknown = words.filter( + (word) => !(PHASES as readonly string[]).includes(word) + ) + if (words.length === 0 || unknown.length > 0) { + throw new CliUsageError( + `--only takes phases from: ${PHASES.join(', ')}${unknown.length > 0 ? `; got ${unknown.join(', ')}` : ''}.` + ) + } + return PHASES.filter((phase) => words.includes(phase)) +} + +function phaseList(flags: Readonly<Record<string, unknown>>): string { + try { + return parsePhases(flags.only).join(', ') + } catch { + return 'the chosen phases' + } +} + +export const migrateResendCommand = defineCommand({ + path: ['migrate', 'resend'], + summary: + 'Move a Resend account and codebase to Brew: plan by default (writes brew.migration.json), --apply to copy templates, contacts, segments, topics, suppressions and draft broadcasts', + sdkMethod: null, + derivedFrom: [ + 'brand.get', + 'domains.list', + 'appEmails.list', + 'emails.import', + 'appEmails.upsert', + 'emails.delete', + 'contacts.upsertMany', + 'audiences.list', + 'audiences.create', + 'domains.unsubscribes.import', + ], + commandClass: 'destructive', + flags: [ + { + flag: '--apply', + summary: + 'Execute the plan: import templates as app emails, upsert contacts, create segment and topic audiences, import suppressions, copy draft broadcasts as Brew drafts (resumes from brew.migration.json)', + }, + { + flag: '--dry-run', + summary: 'Plan and print the report without writing brew.migration.json', + }, + { + flag: '--only <phases...>', + summary: `Run only these phases: ${PHASES.join(', ')}`, + }, + { + flag: '--resend-key-stdin', + summary: + 'Read the Resend API key from stdin (else RESEND_API_KEY); it is never sent to Brew or printed', + }, + { + flag: '--cwd <dir>', + summary: + 'The project to scan and rewrite (default: the current directory)', + }, + { + flag: '--domain <domain>', + summary: + "Brew domain (id or host) whose unsubscribe list takes Resend's suppressions and unsubscribed contacts", + }, + { + flag: '--manifest <path>', + summary: `Where the plan and progress live (default ${MIGRATION_MANIFEST_FILE})`, + }, + { + flag: '--key-prefix <prefix>', + summary: + 'Prefix for the trigger keys made from template aliases and React Email file names (default none), e.g. resend.', + }, + { + flag: '--rewrite-imports', + summary: + "With --apply: move files whose every send is a migrated template from 'resend' to '@brew.new/sdk/resend' and write lib/brew-resend-map.ts", + }, + ], + examples: [ + 'RESEND_API_KEY=re_123 brew-cli migrate resend', + 'printf %s "$RESEND_API_KEY" | brew-cli migrate resend --resend-key-stdin --json', + 'brew-cli migrate resend --only templates code --dry-run', + 'brew-cli migrate resend --apply --yes', + 'brew-cli migrate resend --apply --rewrite-imports --domain news.example.com --yes', + ], + confirmSummary: ({ flags }) => { + if (flags.apply !== true || flags.dryRun === true) { + return + } + return [ + `Copy ${phaseList(flags)} from Resend into your Brew brand: import templates and React Email files as app emails (published where they were), upsert contacts (unsubscribed ones stay unsubscribed), create segment and topic fields and audiences, copy draft broadcasts as Brew drafts (nothing is sent)${flagString(flags.domain) === undefined ? '' : `, add suppressions to ${String(flags.domain)}'s unsubscribe list`}.`, + flags.rewriteImports === true + ? "Rewrite 'resend' imports to '@brew.new/sdk/resend' where every send is a migrated template, and write lib/brew-resend-map.ts." + : '', + `Progress is saved to ${flagString(flags.manifest) ?? MIGRATION_MANIFEST_FILE}. Nothing in Resend is changed or deleted, and the Resend key is never sent to Brew.`, + ] + .filter((part) => part !== '') + .join(' ') + }, + run: async ({ ctx, args, flags }) => { + const apply = flags.apply === true + const dryRun = flags.dryRun === true + if (apply && dryRun) { + throw new CliUsageError('--apply and --dry-run cannot be combined.') + } + const keyPrefix = flagString(flags.keyPrefix) ?? '' + if (keyPrefix !== '') { + const problem = keyPrefixProblem(keyPrefix) + if (problem !== undefined) { + throw new CliUsageError(`--key-prefix ${keyPrefix}: ${problem}.`) + } + } + const phases = parsePhases(flags.only) + let sourceKey: { value: string; from: 'stdin' | 'env' } | null = null + if (flags.resendKeyStdin === true) { + const text = await ctx.io.readStdin() + const value = text + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line !== '') + if (value === undefined) { + throw new CliUsageError( + '--resend-key-stdin read nothing: pipe the Resend API key on stdin.' + ) + } + sourceKey = { value, from: 'stdin' } + } else { + const value = ctx.io.env[source.envKey]?.trim() + if (value !== undefined && value !== '') { + sourceKey = { value, from: 'env' } + } + } + const cwd = resolve(resolveCwd(ctx.io), flagString(flags.cwd) ?? '.') + return await runMigration(ctx, source, { + mode: apply ? 'apply' : dryRun ? 'dry-run' : 'plan', + phases, + domain: flagString(flags.domain), + manifestPath: flagString(flags.manifest) ?? MIGRATION_MANIFEST_FILE, + keyPrefix, + rewriteImports: flags.rewriteImports === true, + cwd, + sourceKey, + rerun: { + command: migrateResendCommand, + invocation: { args, flags }, + }, + }) + }, +}) diff --git a/src/generated/skills.ts b/src/generated/skills.ts index 64d232d2..3ed01141 100644 --- a/src/generated/skills.ts +++ b/src/generated/skills.ts @@ -20,6 +20,12 @@ 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\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 <brandId>` / `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 <key> --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 <key>` gives any trigger a key.\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 <duration>` 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@<version> …`), 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 '<json>'` (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 (`<group> get\n <id>`) 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 <emailId> --include html,versions\nbrew-cli emails get <emailId> --email-version-id <id> # a saved version (ids from --include versions)\nbrew-cli emails preview-clients <emailId> # starts a rendering job (10 credits)\nbrew-cli emails get-client-preview <previewId> # poll it; screenshots are previews[].imageUrl\nbrew-cli sends get <sendId> --include events\nbrew-cli insights list --severity critical # what Brew Insights found; insights get <id> for one in full\nbrew-cli insights list --include pulse,report,suggestions,memo --json\nbrew-cli emails comments list <emailId> --include messages # teammates' comment threads on a design\nbrew-cli emails comments list <emailId> --comment-id <cmt_…> --all --json # one thread, every message\nbrew-cli chats list # recent Brew chats; chats get <chatId> to resume one\nbrew-cli notifications list --type email_send_failed # what finished or failed (reading marks nothing read)\nbrew-cli domains health <domainId> --include scoreHistory,scoreRuns\nbrew-cli contacts get <email> --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 <assetId> --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\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 <brandId>` / `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 <key> --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 <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 <duration>` 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@<version> …`), 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 '<json>'` (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 (`<group> get\n <id>`) 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 <emailId> --include html,versions\nbrew-cli emails get <emailId> --email-version-id <id> # a saved version (ids from --include versions)\nbrew-cli emails preview-clients <emailId> # starts a rendering job (10 credits)\nbrew-cli emails get-client-preview <previewId> # poll it; screenshots are previews[].imageUrl\nbrew-cli sends get <sendId> --include events\nbrew-cli insights list --severity critical # what Brew Insights found; insights get <id> for one in full\nbrew-cli insights list --include pulse,report,suggestions,memo --json\nbrew-cli emails comments list <emailId> --include messages # teammates' comment threads on a design\nbrew-cli emails comments list <emailId> --comment-id <cmt_…> --all --json # one thread, every message\nbrew-cli chats list # recent Brew chats; chats get <chatId> to resume one\nbrew-cli notifications list --type email_send_failed # what finished or failed (reading marks nothing read)\nbrew-cli domains health <domainId> --include scoreHistory,scoreRuns\nbrew-cli contacts get <email> --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 <assetId> --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", + description: "Move an app and its Resend account to Brew with `brew-cli migrate resend`, covering templates and React Email as app emails, contacts, segments, topics, suppressions and broadcast drafts, plus the code that calls resend.emails.send. Use when a project depends on `resend`, someone asks to switch from Resend to Brew, or a migration report (brew.migration.json) needs finishing.", + path: "skills/brew-migrate-from-resend/SKILL.md", + content: "---\nname: brew-migrate-from-resend\ndescription: Move an app and its Resend account to Brew with `brew-cli migrate resend`, covering templates and React Email as app emails, contacts, segments, topics, suppressions and broadcast drafts, plus the code that calls resend.emails.send. Use when a project depends on `resend`, someone asks to switch from Resend to Brew, or a migration report (brew.migration.json) needs finishing.\n---\n\n# Migrate from Resend to Brew\n\nPlan first, review with the person, then apply. Nothing in Resend is\nchanged or deleted, and the Resend key never leaves this machine for Brew.\n\n```bash\n# 1. Plan: reads Resend (2 requests a second) and Brew, scans the code,\n# writes brew.migration.json. Changes nothing.\nprintf %s \"$RESEND_API_KEY\" | npx @brew.new/cli migrate resend --resend-key-stdin --json\n\n# 2. Apply after the person reviewed the plan (destructive class: exit 4\n# without --yes). Re-running resumes; done items are skipped.\nprintf %s \"$RESEND_API_KEY\" | npx @brew.new/cli migrate resend --resend-key-stdin --apply --domain <brew-marketing-domain> --yes --json\n```\n\nAsk the person for a **full-access** Resend key (a sending-only key exits 3\nwith `RESEND_KEY_SENDING_ONLY`). Pass it on stdin; never print it, write\nit to a file, or put it in a command line that gets logged. Brew needs its\nown key (`brew-cli login`, or `BREW_API_KEY`).\n\n## What moves where\n\n| Resend | Brew |\n| --- | --- |\n| Template (`{{{VAR}}}`) | App email by key (alias or name), published if it was; placeholders become `{{ trigger.VAR }}` |\n| React Email file | App email by key (file name); props become merge tags |\n| Contact | Contact; subscribed ones with an import consent record, unsubscribed stay unsubscribed; no state, left out |\n| Segment | Boolean field `resend_segment_*` and an audience on it |\n| Topic | Boolean field `resend_topic_*` (true = opted in) and an audience; one Resend request per contact |\n| Suppression, unsubscribed contact | The `--domain`'s unsubscribe list |\n| Draft broadcast | A Brew draft email (nothing is sent) |\n| Domain, webhook, automation | Reported only |\n\nBrew signs mail with the same DKIM selector as Resend (`resend._domainkey`),\nso a domain cannot verify in both. Verify a subdomain in Brew (the plan\nsuggests one, e.g. `mail.acme.com`) and keep the Resend domain until\ncutover.\n\n## Finish the code\n\n- Every remaining `resend.emails.send` / `resend.batch.send` is a blocking\n `edit-file` next step with a suggested `getBrew().send(key, payload)`.\n `attachments`, `cc`, `bcc`, `headers`, `scheduledAt` and raw `html` do\n not carry over: say so to the person instead of dropping them silently.\n- `--rewrite-imports` (with `--apply`) moves a file to\n `@brew.new/sdk/resend` only when every send through it is a migrated\n template, and writes `lib/brew-resend-map.ts`.\n- Then send one migrated email to yourself (`brew-cli send <key> --to\n <you> --yes`; before a domain verifies, Brew delivers to your team only)\n and cut over: production gets `BREW_API_KEY`, then retire\n `RESEND_API_KEY` and the `resend` package.\n\n`brew.migration.json` holds ids, keys, counts and cursors, never an\naddress, a name or a key: it is safe to commit.\n", }, ] diff --git a/src/lib/app-email-inputs.ts b/src/lib/app-email-inputs.ts index 6a0138db..b3c70c30 100644 --- a/src/lib/app-email-inputs.ts +++ b/src/lib/app-email-inputs.ts @@ -84,6 +84,23 @@ export function oneOfFlag( return text } +/** + * `--input`'s `source` object, so `--source-file` can fill `format` and + * `content` and keep the rest: the import modes `props`, `placeholders` + * and `variables` (brew-v2#2169). + */ +export function inputSource( + base: unknown +): Readonly<Record<string, unknown>> | undefined { + if (base === null || typeof base !== 'object') { + return + } + const source = (base as Record<string, unknown>).source + return source !== null && typeof source === 'object' && !Array.isArray(source) + ? (source as Record<string, unknown>) + : undefined +} + /** `--input` given inline (not stdin), for confirmation summaries only. */ export function inlineInput(value: unknown): Record<string, unknown> { if (typeof value !== 'string' || value === '-') { diff --git a/src/lib/init/pipeline.ts b/src/lib/init/pipeline.ts index 72e6e3a8..85870926 100644 --- a/src/lib/init/pipeline.ts +++ b/src/lib/init/pipeline.ts @@ -921,6 +921,12 @@ export async function runInit( return change } const installsSkills = project.editors.includes('claude-code') + // The Resend migration skill only where Resend is in use. + const skills = EMBEDDED_SKILLS.filter( + (skill) => + skill.name !== 'brew-migrate-from-resend' || + project.existingEmail.some((email) => email.provider === 'resend') + ) const paths = { client: clientPath(project), auth: wantsAuthHelper(project, helperSets) ? authHelperPath(project) : null, @@ -974,7 +980,7 @@ export async function runInit( // already uses it: creating `.claude/` would make the next run detect // an editor nobody uses. if (options.agentFiles && installsSkills) { - for (const skill of EMBEDDED_SKILLS) { + for (const skill of skills) { writeManaged({ path: skillInstallPath(skill.name), content: skill.content, @@ -1198,7 +1204,7 @@ export async function runInit( keys, files: paths, skillPaths: installsSkills - ? EMBEDDED_SKILLS.map((skill) => skillInstallPath(skill.name)) + ? skills.map((skill) => skillInstallPath(skill.name)) : [], }) ) diff --git a/src/lib/migrate/code/analyze.ts b/src/lib/migrate/code/analyze.ts new file mode 100644 index 00000000..483c3010 --- /dev/null +++ b/src/lib/migrate/code/analyze.ts @@ -0,0 +1,392 @@ +import { + allowsJsx, + matchingBracket, + stringValue, + type Token, + tokenize, +} from './tokenize' +import type { Span } from './types' + +/** + * What one Resend send call asks for, read from its first argument: the + * kind of body (`template`, `react`, `html`, `text`), the template id, the + * React Email element and its props, the recipient expression, and every + * field that has no Brew equivalent. + */ + +export type SendKind = 'template' | 'react' | 'html' | 'text' | 'unknown' + +export type ComponentUse = { + /** `Welcome` in `<Welcome …/>` or `Welcome({ … })`; null for a variable. */ + readonly name: string | null + readonly props: ReadonlyArray<{ + readonly name: string + readonly value: string + }> + /** Spread expressions (`{...props}`). */ + readonly spreads: readonly string[] +} + +export type MessageAnalysis = { + readonly kind: SendKind + /** A literal `template.id`; null when it is computed or absent. */ + readonly templateId: string | null + /** `template.variables`' source. */ + readonly variables: string | null + readonly component: ComponentUse | null + /** The `to` expression's source. */ + readonly to: string | null + /** Fields that do not carry over to Brew (`attachments`, `cc`, …). */ + readonly unsupported: readonly string[] +} + +export type ArgumentAnalysis = { + /** `mixed` when a batch holds several kinds. */ + readonly kind: SendKind | 'mixed' + readonly shape: 'object' | 'array' | 'other' + readonly messages: readonly MessageAnalysis[] +} + +/** Resend fields Brew app email has no place for. */ +export const UNSUPPORTED_FIELDS = [ + 'attachments', + 'cc', + 'bcc', + 'headers', + 'scheduledAt', + 'scheduled_at', +] as const + +type Property = { + readonly name: string | null + readonly from: number + readonly to: number + readonly spread: boolean +} + +/** Top-level properties of the object literal whose `{` is at `open`. */ +function objectProperties(tokens: readonly Token[], open: number): Property[] { + const close = matchingBracket(tokens, open) + if (close === -1) { + return [] + } + const properties: Property[] = [] + let index = open + 1 + while (index < close) { + // One property: [key | ...spread] [: value] up to a top-level comma. + let end = index + let depth = 0 + while (end < close) { + const token = tokens[end] as Token + if (token.type === 'punct') { + if (['(', '[', '{'].includes(token.value)) { + depth += 1 + } else if ([')', ']', '}'].includes(token.value)) { + depth -= 1 + } else if (token.value === ',' && depth === 0) { + break + } + } + end += 1 + } + const first = tokens[index] as Token + const count = properties.length + if (first.type === 'punct' && first.value === '...') { + properties.push({ + name: null, + from: index + 1, + to: end - 1, + spread: true, + }) + } else if (first.type === 'ident' || first.type === 'string') { + const name = + first.type === 'ident' ? first.value : (stringValue(first) ?? '') + const second = tokens[index + 1] + if (second?.type === 'punct' && second.value === ':') { + properties.push({ name, from: index + 2, to: end - 1, spread: false }) + } else if (index + 1 === end) { + properties.push({ name, from: index, to: index, spread: false }) + } + } else if (first.type === 'punct' && first.value === '[') { + // `['cc']: manager` names its field like `cc: manager`. + const literal = tokens[index + 1] + const name = literal === undefined ? null : stringValue(literal) + const close = tokens[index + 2] + const colon = tokens[index + 3] + if ( + name !== null && + close?.type === 'punct' && + close.value === ']' && + colon?.type === 'punct' && + colon.value === ':' + ) { + properties.push({ name, from: index + 4, to: end - 1, spread: false }) + } + } + if (properties.length === count && index < end) { + // A name it cannot read (`[field]: x`, a method) may be any field. + properties.push({ name: null, from: index, to: end - 1, spread: true }) + } + index = end + 1 + } + return properties +} + +/** Top-level elements of the array literal whose `[` is at `open`. */ +function arrayElements( + tokens: readonly Token[], + open: number +): Array<{ readonly from: number; readonly to: number }> { + const close = matchingBracket(tokens, open) + if (close === -1) { + return [] + } + const elements: Array<{ from: number; to: number }> = [] + let from = open + 1 + let depth = 0 + for (let index = open + 1; index <= close; index += 1) { + const token = tokens[index] as Token + if ( + index === close || + (token.type === 'punct' && token.value === ',' && depth === 0) + ) { + if (index > from) { + elements.push({ from, to: index - 1 }) + } + from = index + 1 + continue + } + if (token.type === 'punct') { + if (['(', '[', '{'].includes(token.value)) { + depth += 1 + } else if ([')', ']', '}'].includes(token.value)) { + depth -= 1 + } + } + } + return elements +} + +function sourceOf( + text: string, + tokens: readonly Token[], + from: number, + to: number +): string { + const first = tokens[from] + const last = tokens[to] + if (first === undefined || last === undefined || to < from) { + return '' + } + return text.slice(first.start, last.end).trim() +} + +function componentFrom( + text: string, + tokens: readonly Token[], + from: number, + to: number +): ComponentUse { + const first = tokens[from] as Token + if (first.type === 'jsx') { + const props: Array<{ name: string; value: string }> = [] + const spreads: string[] = [] + for (const attribute of first.attributes ?? []) { + if (attribute.kind === 'spread') { + spreads.push(attribute.value ?? '') + } else if (attribute.name !== null) { + props.push({ + name: attribute.name, + value: + attribute.kind === 'boolean' ? 'true' : (attribute.value ?? ''), + }) + } + } + return { name: first.value === '' ? null : first.value, props, spreads } + } + // `Welcome({ name })` or `Welcome(props)` + const open = tokens[from + 1] + if (first.type === 'ident' && open?.type === 'punct' && open.value === '(') { + const argOpen = tokens[from + 2] + if (argOpen?.type === 'punct' && argOpen.value === '{') { + const props: Array<{ name: string; value: string }> = [] + const spreads: string[] = [] + for (const property of objectProperties(tokens, from + 2)) { + const value = sourceOf(text, tokens, property.from, property.to) + if (property.spread) { + spreads.push(value) + } else if (property.name !== null) { + props.push({ name: property.name, value }) + } + } + return { name: first.value, props, spreads } + } + return { + name: first.value, + props: [], + spreads: [sourceOf(text, tokens, from + 2, to - 1)], + } + } + return { name: null, props: [], spreads: [] } +} + +function analyzeMessage( + text: string, + tokens: readonly Token[], + open: number +): MessageAnalysis { + const properties = objectProperties(tokens, open) + const byName = new Map<string, Property>() + let spread = false + for (const property of properties) { + if (property.spread) { + spread = true + } else if (property.name !== null) { + byName.set(property.name, property) + } + } + const sourceOfProperty = (name: string): string | null => { + const property = byName.get(name) + return property === undefined + ? null + : sourceOf(text, tokens, property.from, property.to) + } + const unsupported: string[] = UNSUPPORTED_FIELDS.filter((field) => + byName.has(field) + ) + let kind: SendKind = 'unknown' + let templateId: string | null = null + let variables: string | null = null + let component: ComponentUse | null = null + const template = byName.get('template') + if (template !== undefined) { + kind = 'template' + const first = tokens[template.from] + if (first?.type === 'punct' && first.value === '{') { + for (const property of objectProperties(tokens, template.from)) { + if (property.name === 'id' && property.from === property.to) { + templateId = stringValue(tokens[property.from] as Token) + } else if (property.spread) { + // It may carry an `id` that replaces the one before it. + templateId = null + } else if (property.name === 'variables') { + variables = sourceOf(text, tokens, property.from, property.to) + } + } + } + } else if (byName.has('react')) { + kind = 'react' + const react = byName.get('react') as Property + component = componentFrom(text, tokens, react.from, react.to) + } else if (byName.has('html')) { + kind = 'html' + } else if (byName.has('text')) { + kind = 'text' + } + if (byName.has('html')) { + unsupported.push('html') + } + if (byName.has('text') && kind !== 'template') { + unsupported.push('text') + } + // A spread can carry any field (`cc`, `attachments`, another body). + if (spread) { + unsupported.push('...spread') + } + return { + kind, + templateId, + variables, + component, + to: sourceOfProperty('to'), + unsupported, + } +} + +const UNKNOWN_MESSAGE: MessageAnalysis = { + kind: 'unknown', + templateId: null, + variables: null, + component: null, + to: null, + unsupported: [], +} + +/** The first argument of a send call, at `span` in the file `path`. */ +export function analyzeArgument( + fileText: string, + span: Span, + path: string +): ArgumentAnalysis { + const text = fileText.slice(span.start, span.end) + let tokens: Token[] + try { + tokens = tokenize(text, { jsx: allowsJsx(path) }) + } catch { + return { kind: 'unknown', shape: 'other', messages: [UNKNOWN_MESSAGE] } + } + const first = tokens[0] + if (first?.type === 'punct' && first.value === '{') { + const message = analyzeMessage(text, tokens, 0) + return { kind: message.kind, shape: 'object', messages: [message] } + } + if (first?.type === 'punct' && first.value === '[') { + const elements = arrayElements(tokens, 0) + // An empty batch reads as one unknown message, like any other. + const messages = + elements.length === 0 + ? [UNKNOWN_MESSAGE] + : elements.map((element) => { + const head = tokens[element.from] + return head?.type === 'punct' && head.value === '{' + ? analyzeMessage(text, tokens, element.from) + : UNKNOWN_MESSAGE + }) + const kinds = new Set(messages.map((message) => message.kind)) + const kind = kinds.size === 1 ? ([...kinds][0] as SendKind) : 'mixed' + return { kind, shape: 'array', messages } + } + return { kind: 'unknown', shape: 'other', messages: [UNKNOWN_MESSAGE] } +} + +/** + * Value imports a React Email file makes from its own project (`./x`, + * `../x`, `@/x`), and the runtime constructs a static import cannot fold + * (hooks, `fetch`, `await`): reasons the file is migrated by hand. + */ +export function reactEmailRuntimeMarkers(text: string, path: string): string[] { + let tokens: Token[] + try { + tokens = tokenize(text, { jsx: allowsJsx(path) }) + } catch { + return ['the file could not be read'] + } + const found = new Set<string>() + const visit = (list: readonly Token[]): void => { + for (let index = 0; index < list.length; index += 1) { + const token = list[index] as Token + if (token.children !== undefined) { + visit(token.children) + } + if (token.type !== 'ident') { + continue + } + const next = list[index + 1] + const calls = next?.type === 'punct' && next.value === '(' + if (token.value === 'await' || token.value === 'async') { + found.add(token.value) + } else if (calls && token.value === 'fetch') { + found.add('fetch()') + } else if (calls && /^use[A-Z]/.test(token.value)) { + found.add(`${token.value}()`) + } else if ( + calls && + (token.value === 'setTimeout' || token.value === 'setInterval') + ) { + found.add(`${token.value}()`) + } + } + } + visit(tokens) + return [...found] +} diff --git a/src/lib/migrate/code/locate-typescript.ts b/src/lib/migrate/code/locate-typescript.ts new file mode 100644 index 00000000..5510127a --- /dev/null +++ b/src/lib/migrate/code/locate-typescript.ts @@ -0,0 +1,442 @@ +import type * as TS from 'typescript' +import { isProjectSpecifier } from './locate' +import type { + CallLocation, + ClientSite, + FileLocations, + LocalImport, + ResendImport, +} from './types' + +/** + * The TypeScript engine of the code scan: the same answers as + * `locateWithTokens`, read from the project's own compiler's syntax tree + * (exact for every construct the tokenizer approximates: JSX text, regular + * expressions, type parameters). Only the parser runs; nothing type-checks. + */ + +export type TypeScriptModule = typeof TS + +const RESEND_MODULE = 'resend' + +/** `a.name`, or `a['name']` with a literal name: `a` and the name. */ +function memberOf( + ts: TypeScriptModule, + node: TS.Node +): { readonly object: TS.Expression; readonly name: string } | null { + if (ts.isPropertyAccessExpression(node)) { + return { object: node.expression, name: node.name.text } + } + if ( + ts.isElementAccessExpression(node) && + ts.isStringLiteralLike(node.argumentExpression) + ) { + return { object: node.expression, name: node.argumentExpression.text } + } + return null +} + +function scriptKind(ts: TypeScriptModule, path: string): TS.ScriptKind { + if (/\.tsx$/.test(path)) { + return ts.ScriptKind.TSX + } + if (/\.(?:ts|mts|cts)$/.test(path)) { + return ts.ScriptKind.TS + } + if (/\.jsx$/.test(path)) { + return ts.ScriptKind.JSX + } + // `.js` files hold JSX often enough (React apps) that it must parse. + return ts.ScriptKind.JSX +} + +function hasModifier( + ts: TypeScriptModule, + node: TS.Node, + kind: TS.SyntaxKind +): boolean { + const modifiers = ts.canHaveModifiers(node) + ? ts.getModifiers(node) + : undefined + return modifiers?.some((modifier) => modifier.kind === kind) === true +} + +export function locateWithTypeScript( + ts: TypeScriptModule, + text: string, + path: string +): FileLocations { + const source = ts.createSourceFile( + path, + text, + ts.ScriptTarget.Latest, + true, + scriptKind(ts, path) + ) + const imports: ResendImport[] = [] + const localImports: LocalImport[] = [] + const exportsList: Array<{ local: string; exported: string }> = [] + const clients: ClientSite[] = [] + const calls: CallLocation[] = [] + let importInsertAt = 0 + let esm = false + let cjs = false + let relativeImportsUseJs = false + + const position = (node: TS.Node) => { + const { line, character } = source.getLineAndCharacterOfPosition( + node.getStart(source) + ) + return { line: line + 1, column: character + 1 } + } + + // ---- Module syntax: imports and exports at the top level. + for (const statement of source.statements) { + if (ts.isImportDeclaration(statement)) { + esm = true + importInsertAt = Math.max(importInsertAt, statement.getEnd()) + if (!ts.isStringLiteral(statement.moduleSpecifier)) { + continue + } + const specifier = statement.moduleSpecifier.text + const clause = statement.importClause + const typeOnly = clause?.isTypeOnly === true + const named = + clause?.namedBindings !== undefined && + ts.isNamedImports(clause.namedBindings) + ? clause.namedBindings.elements.map((element) => ({ + imported: (element.propertyName ?? element.name).text, + local: element.name.text, + typeOnly: typeOnly || element.isTypeOnly, + })) + : [] + const namespace = + clause?.namedBindings !== undefined && + ts.isNamespaceImport(clause.namedBindings) + ? clause.namedBindings.name.text + : null + const defaultLocal = clause?.name?.text ?? null + if (specifier === RESEND_MODULE) { + const literal = statement.moduleSpecifier + imports.push({ + kind: 'esm', + specifier: { start: literal.getStart(source), end: literal.getEnd() }, + typeOnly, + ctorNames: named + .filter((entry) => entry.imported === 'Resend' && !entry.typeOnly) + .map((entry) => entry.local), + namespaces: typeOnly + ? [] + : [namespace, defaultLocal].filter( + (name): name is string => name !== null + ), + importedNames: [ + ...(defaultLocal === null ? [] : ['default']), + ...(namespace === null ? [] : ['*']), + ...named.map((entry) => entry.imported), + ], + }) + } else if (isProjectSpecifier(specifier)) { + if (/\.js$/.test(specifier)) { + relativeImportsUseJs = true + } + if (!typeOnly) { + if (defaultLocal !== null) { + localImports.push({ + local: defaultLocal, + imported: 'default', + from: specifier, + }) + } + if (namespace !== null) { + localImports.push({ + local: namespace, + imported: '*', + from: specifier, + }) + } + for (const entry of named) { + if (!entry.typeOnly) { + localImports.push({ + local: entry.local, + imported: entry.imported, + from: specifier, + }) + } + } + } + } + continue + } + if (ts.isExportDeclaration(statement)) { + esm = true + if ( + statement.moduleSpecifier === undefined && + statement.exportClause !== undefined && + ts.isNamedExports(statement.exportClause) + ) { + for (const element of statement.exportClause.elements) { + exportsList.push({ + local: (element.propertyName ?? element.name).text, + exported: element.name.text, + }) + } + } + continue + } + if (ts.isExportAssignment(statement)) { + esm = true + if (ts.isIdentifier(statement.expression)) { + exportsList.push({ + local: statement.expression.text, + exported: 'default', + }) + } + continue + } + if ( + ts.isVariableStatement(statement) && + hasModifier(ts, statement, ts.SyntaxKind.ExportKeyword) + ) { + esm = true + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name)) { + exportsList.push({ + local: declaration.name.text, + exported: declaration.name.text, + }) + } + } + continue + } + // module.exports = …, exports.x = … + if ( + ts.isExpressionStatement(statement) && + ts.isBinaryExpression(statement.expression) && + statement.expression.operatorToken.kind === ts.SyntaxKind.EqualsToken + ) { + const { left, right } = statement.expression + const target = left.getText(source) + const member = /^(?:module\.)?exports(?:\.([A-Za-z_$][\w$]*))?$/.exec( + target + ) + if (member !== null && target !== 'exports') { + cjs = true + const exportedName = member[1] ?? null + if (ts.isObjectLiteralExpression(right) && exportedName === null) { + for (const property of right.properties) { + if (ts.isShorthandPropertyAssignment(property)) { + exportsList.push({ + local: property.name.text, + exported: property.name.text, + }) + } else if ( + ts.isPropertyAssignment(property) && + ts.isIdentifier(property.initializer) && + (ts.isIdentifier(property.name) || + ts.isStringLiteral(property.name)) + ) { + exportsList.push({ + local: property.initializer.text, + exported: property.name.text, + }) + } + } + } else if (ts.isIdentifier(right)) { + exportsList.push({ + local: right.text, + exported: exportedName ?? 'default', + }) + } + } + } + } + + // ---- require('…') anywhere. + const visitRequires = (node: TS.Node): void => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'require' && + node.arguments.length === 1 && + ts.isStringLiteral(node.arguments[0] as TS.Node) + ) { + cjs = true + const literal = node.arguments[0] as TS.StringLiteral + const specifier = literal.text + // `require('resend').Resend` + let holder: TS.Node = node + let member: string | null = null + if ( + ts.isPropertyAccessExpression(node.parent) && + node.parent.expression === node + ) { + member = node.parent.name.text + holder = node.parent + } + const declaration = holder.parent + let bindings: Array<{ imported: string; local: string }> = [] + if ( + ts.isVariableDeclaration(declaration) && + declaration.initializer === holder + ) { + if (ts.isIdentifier(declaration.name)) { + bindings = [{ imported: member ?? '*', local: declaration.name.text }] + } else if (ts.isObjectBindingPattern(declaration.name)) { + for (const element of declaration.name.elements) { + if (ts.isIdentifier(element.name)) { + const property = element.propertyName + bindings.push({ + imported: + property !== undefined && ts.isIdentifier(property) + ? property.text + : element.name.text, + local: element.name.text, + }) + } + } + } + } + if (specifier === RESEND_MODULE) { + imports.push({ + kind: 'require', + specifier: { start: literal.getStart(source), end: literal.getEnd() }, + typeOnly: false, + ctorNames: bindings + .filter((binding) => binding.imported === 'Resend') + .map((binding) => binding.local), + namespaces: bindings + .filter((binding) => binding.imported === '*') + .map((binding) => binding.local), + importedNames: bindings.map((binding) => binding.imported), + }) + } else if (isProjectSpecifier(specifier)) { + for (const binding of bindings) { + localImports.push({ + local: binding.local, + imported: binding.imported, + from: specifier, + }) + } + } + } + ts.forEachChild(node, visitRequires) + } + visitRequires(source) + + const ctorNames = new Set(imports.flatMap((entry) => entry.ctorNames)) + const namespaces = new Set(imports.flatMap((entry) => entry.namespaces)) + const exported = new Map( + exportsList.map((entry) => [entry.local, entry.exported]) + ) + + const isResendConstructor = (node: TS.Expression): boolean => + (ts.isIdentifier(node) && ctorNames.has(node.text)) || + (ts.isPropertyAccessExpression(node) && + node.name.text === 'Resend' && + ts.isIdentifier(node.expression) && + namespaces.has(node.expression.text)) + + const isClientExpression = (node: TS.Expression): boolean => + ts.isNewExpression(node) && isResendConstructor(node.expression) + + // ---- Clients and calls. + const visit = (node: TS.Node): void => { + if (ts.isNewExpression(node) && isResendConstructor(node.expression)) { + let binding: string | null = null + let exportName: string | null = null + let parent = node.parent + while ( + ts.isParenthesizedExpression(parent) || + ts.isAsExpression(parent) || + ts.isSatisfiesExpression(parent) || + ts.isNonNullExpression(parent) + ) { + parent = parent.parent + } + if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) { + binding = parent.name.text + const statement = parent.parent.parent + exportName = + ts.isVariableStatement(statement) && + hasModifier(ts, statement, ts.SyntaxKind.ExportKeyword) + ? binding + : (exported.get(binding) ?? null) + } else if ( + ts.isPropertyDeclaration(parent) && + (ts.isIdentifier(parent.name) || ts.isPrivateIdentifier(parent.name)) + ) { + binding = `this.${parent.name.text}` + } else if ( + ts.isBinaryExpression(parent) && + parent.operatorToken.kind === ts.SyntaxKind.EqualsToken + ) { + const left = parent.left + if (ts.isIdentifier(left)) { + binding = left.text + exportName = exported.get(binding) ?? null + } else if ( + ts.isPropertyAccessExpression(left) && + left.expression.kind === ts.SyntaxKind.ThisKeyword + ) { + binding = `this.${left.name.text}` + } + } else if (ts.isExportAssignment(parent)) { + exportName = 'default' + } + const args = node.arguments + const span = + args === undefined + ? { start: node.expression.getEnd(), end: node.expression.getEnd() } + : { start: args.pos, end: args.end } + const outer = memberOf(ts, node.parent) + const inline = + outer !== null && + outer.object === node && + (outer.name === 'emails' || outer.name === 'batch') + clients.push({ + binding, + exportName, + ...position(node), + args: span, + inline, + }) + } + const send = ts.isCallExpression(node) + ? memberOf(ts, node.expression) + : null + const group = send?.name === 'send' ? memberOf(ts, send.object) : null + if ( + ts.isCallExpression(node) && + group !== null && + (group.name === 'emails' || group.name === 'batch') + ) { + const receiver = group.object + const first = node.arguments[0] + const open = node.expression.getEnd() + calls.push({ + receiver: receiver.getText(source), + method: group.name === 'emails' ? 'emails.send' : 'batch.send', + ...position(receiver), + args: + first === undefined + ? { start: open + 1, end: open + 1 } + : { start: first.getStart(source), end: first.getEnd() }, + inlineClient: isClientExpression(receiver), + }) + } + ts.forEachChild(node, visit) + } + visit(source) + + return { + imports, + localImports, + clients, + calls, + exports: exportsList, + importInsertAt, + moduleSyntax: esm || !cjs ? 'esm' : 'cjs', + relativeImportsUseJs, + } +} diff --git a/src/lib/migrate/code/locate.ts b/src/lib/migrate/code/locate.ts new file mode 100644 index 00000000..3e68eb7d --- /dev/null +++ b/src/lib/migrate/code/locate.ts @@ -0,0 +1,742 @@ +import { + allowsJsx, + lineColumn, + matchingBracket, + memberAt, + stringValue, + type Token, + tokenize, +} from './tokenize' +import type { + CallLocation, + ClientSite, + FileLocations, + LocalImport, + ResendImport, +} from './types' + +/** + * The tokenizer engine of the code scan: Resend imports, `new Resend(…)` + * clients (and the names they are bound and exported under), and + * `.emails.send(…)` / `.batch.send(…)` calls. The TypeScript engine + * (`locate-typescript.ts`) answers the same questions from a real syntax + * tree when the project has a compiler installed. + */ + +const RESEND_MODULE = 'resend' +const DECLARATIONS = new Set(['const', 'let', 'var']) +const CLASS_MODIFIERS = new Set([ + 'private', + 'public', + 'protected', + 'readonly', + 'static', + 'declare', + 'override', +]) + +/** A relative or project-alias specifier (`./x`, `../x`, `@/x`, `~/x`). */ +export function isProjectSpecifier(specifier: string): boolean { + return /^(?:\.{1,2}\/|@\/|~\/)/.test(specifier) +} + +function isPunct(token: Token | undefined, value: string): boolean { + return token?.type === 'punct' && token.value === value +} + +function isIdent(token: Token | undefined, value?: string): boolean { + return ( + token?.type === 'ident' && (value === undefined || token.value === value) + ) +} + +function isDot(token: Token | undefined): boolean { + return isPunct(token, '.') || isPunct(token, '?.') +} + +/** The index of the bracket that opens the one at `close`, or -1. */ +function matchingOpen(tokens: readonly Token[], close: number): number { + let depth = 0 + for (let index = close; index >= 0; index -= 1) { + const token = tokens[index] as Token + if (token.type !== 'punct') { + continue + } + if ([')', ']', '}'].includes(token.value)) { + depth += 1 + } else if (['(', '[', '{'].includes(token.value)) { + depth -= 1 + if (depth === 0) { + return index + } + } + } + return -1 +} + +/** The first argument inside the parentheses at `open`: its token range. */ +function firstArgument( + tokens: readonly Token[], + open: number +): { readonly from: number; readonly to: number } | null { + const close = matchingBracket(tokens, open) + if (close === -1 || close === open + 1) { + return null + } + let depth = 0 + for (let index = open + 1; index < close; index += 1) { + const token = tokens[index] as Token + if (token.type === 'punct') { + if (['(', '[', '{'].includes(token.value)) { + depth += 1 + } else if ([')', ']', '}'].includes(token.value)) { + depth -= 1 + } else if (token.value === ',' && depth === 0) { + return { from: open + 1, to: index - 1 } + } + } + } + return { from: open + 1, to: close - 1 } +} + +type ImportClause = { + readonly defaultLocal: string | null + readonly namespace: string | null + readonly named: ReadonlyArray<{ + readonly imported: string + readonly local: string + readonly typeOnly: boolean + }> +} + +function parseImportClause(tokens: readonly Token[]): ImportClause { + let defaultLocal: string | null = null + let namespace: string | null = null + const named: Array<{ imported: string; local: string; typeOnly: boolean }> = + [] + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index] as Token + if (isPunct(token, '*') && isIdent(tokens[index + 1], 'as')) { + namespace = tokens[index + 2]?.value ?? null + index += 2 + continue + } + if (isPunct(token, '{')) { + const close = tokens.findIndex( + (candidate, at) => at > index && isPunct(candidate, '}') + ) + const inner = tokens.slice(index + 1, close === -1 ? undefined : close) + let element: Token[] = [] + const flush = (): void => { + if (element.length === 0) { + return + } + const typeOnly = + isIdent(element[0], 'type') && + element.length > 1 && + !isIdent(element[1], 'as') + const parts = typeOnly ? element.slice(1) : element + const imported = stringValue(parts[0] as Token) ?? parts[0]?.value ?? '' + const asIndex = parts.findIndex((part) => isIdent(part, 'as')) + const local = + asIndex === -1 ? imported : (parts[asIndex + 1]?.value ?? imported) + named.push({ imported, local, typeOnly }) + element = [] + } + for (const part of inner) { + if (isPunct(part, ',')) { + flush() + } else { + element.push(part) + } + } + flush() + index = close === -1 ? tokens.length : close + continue + } + if (token.type === 'ident' && defaultLocal === null && namespace === null) { + defaultLocal = token.value + } + } + return { defaultLocal, namespace, named } +} + +/** `{ Resend, Resend: R, a }` → elements (property → local). */ +function parseObjectPattern( + tokens: readonly Token[] +): ReadonlyArray<{ readonly imported: string; readonly local: string }> { + const out: Array<{ imported: string; local: string }> = [] + let element: Token[] = [] + const flush = (): void => { + const [first, colon, second] = element + if (first?.type === 'ident') { + out.push({ + imported: first.value, + local: + isPunct(colon, ':') && second?.type === 'ident' + ? second.value + : first.value, + }) + } + element = [] + } + for (const token of tokens) { + if (isPunct(token, ',')) { + flush() + } else { + element.push(token) + } + } + flush() + return out +} + +/** Whether the `{` at `open` starts a class body. */ +function isClassBody(tokens: readonly Token[], open: number): boolean { + for (let index = open - 1; index >= 0 && index >= open - 40; index -= 1) { + const token = tokens[index] as Token + if (isIdent(token, 'class')) { + return true + } + if ( + token.type === 'ident' || + token.type === 'string' || + ['.', '<', '>', ',', '?.', '[', ']'].includes(token.value) + ) { + continue + } + return false + } + return false +} + +type Collected = { + imports: ResendImport[] + localImports: LocalImport[] + exports: Array<{ local: string; exported: string }> + importInsertAt: number + esm: boolean + cjs: boolean + relativeImportsUseJs: boolean +} + +function collectModuleSyntax( + tokens: readonly Token[], + collected: Collected +): void { + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index] as Token + // import … from '…' / import '…' + if ( + isIdent(token, 'import') && + !isPunct(tokens[index + 1], '(') && + !isDot(tokens[index + 1]) + ) { + let at = index + 1 + let typeOnly = false + if ( + isIdent(tokens[at], 'type') && + !isIdent(tokens[at + 1], 'from') && + !isPunct(tokens[at + 1], ',') + ) { + typeOnly = true + at += 1 + } + let specifierIndex = -1 + let clause: Token[] = [] + if (tokens[at]?.type === 'string') { + specifierIndex = at + } else { + const fromIndex = tokens.findIndex( + (candidate, position) => + position > at && + isIdent(candidate, 'from') && + tokens[position + 1]?.type === 'string' + ) + if (fromIndex === -1) { + continue + } + clause = tokens.slice(at, fromIndex) + if (clause.some((part) => isPunct(part, ';'))) { + continue + } + specifierIndex = fromIndex + 1 + } + const specifierToken = tokens[specifierIndex] as Token + const specifier = stringValue(specifierToken) ?? '' + collected.esm = true + const statementEnd = isPunct(tokens[specifierIndex + 1], ';') + ? (tokens[specifierIndex + 1] as Token).end + : specifierToken.end + collected.importInsertAt = Math.max( + collected.importInsertAt, + statementEnd + ) + const parsed = parseImportClause(clause) + if (specifier === RESEND_MODULE) { + collected.imports.push({ + kind: 'esm', + specifier: { start: specifierToken.start, end: specifierToken.end }, + typeOnly, + ctorNames: typeOnly + ? [] + : parsed.named + .filter( + (entry) => entry.imported === 'Resend' && !entry.typeOnly + ) + .map((entry) => entry.local), + namespaces: typeOnly + ? [] + : [parsed.namespace, parsed.defaultLocal].filter( + (name): name is string => name !== null + ), + importedNames: [ + ...(parsed.defaultLocal === null ? [] : ['default']), + ...(parsed.namespace === null ? [] : ['*']), + ...parsed.named.map((entry) => entry.imported), + ], + }) + } else if (isProjectSpecifier(specifier)) { + if (/\.js$/.test(specifier)) { + collected.relativeImportsUseJs = true + } + if (!typeOnly) { + if (parsed.defaultLocal !== null) { + collected.localImports.push({ + local: parsed.defaultLocal, + imported: 'default', + from: specifier, + }) + } + if (parsed.namespace !== null) { + collected.localImports.push({ + local: parsed.namespace, + imported: '*', + from: specifier, + }) + } + for (const entry of parsed.named) { + if (!entry.typeOnly) { + collected.localImports.push({ + local: entry.local, + imported: entry.imported, + from: specifier, + }) + } + } + } + } + index = specifierIndex + continue + } + // require('…') + if ( + isIdent(token, 'require') && + isPunct(tokens[index + 1], '(') && + tokens[index + 2]?.type === 'string' && + isPunct(tokens[index + 3], ')') + ) { + const specifierToken = tokens[index + 2] as Token + const specifier = stringValue(specifierToken) ?? '' + collected.cjs = true + // `const X = require('resend').Resend` + const member = + isDot(tokens[index + 4]) && isIdent(tokens[index + 5]) + ? (tokens[index + 5] as Token).value + : null + let bindings: ReadonlyArray<{ imported: string; local: string }> = [] + if (isPunct(tokens[index - 1], '=')) { + const before = tokens[index - 2] + if (before?.type === 'ident') { + bindings = [{ imported: member ?? '*', local: before.value }] + } else if (isPunct(before, '}')) { + const open = matchingOpen(tokens, index - 2) + if (open !== -1) { + bindings = parseObjectPattern(tokens.slice(open + 1, index - 2)) + } + } + } + if (specifier === RESEND_MODULE) { + collected.imports.push({ + kind: 'require', + specifier: { start: specifierToken.start, end: specifierToken.end }, + typeOnly: false, + ctorNames: bindings + .filter((binding) => binding.imported === 'Resend') + .map((binding) => binding.local), + namespaces: bindings + .filter((binding) => binding.imported === '*') + .map((binding) => binding.local), + importedNames: bindings.map((binding) => binding.imported), + }) + } else if (isProjectSpecifier(specifier)) { + for (const binding of bindings) { + collected.localImports.push({ + local: binding.local, + imported: binding.imported === '*' ? '*' : binding.imported, + from: specifier, + }) + } + } + continue + } + // export const x / export { a as b } / export default x + if (isIdent(token, 'export')) { + collected.esm = true + const next = tokens[index + 1] + if (DECLARATIONS.has(next?.value ?? '') && isIdent(tokens[index + 2])) { + const name = (tokens[index + 2] as Token).value + collected.exports.push({ local: name, exported: name }) + } else if (isPunct(next, '{')) { + const close = matchingBracket(tokens, index + 1) + const fromAfter = isIdent(tokens[close + 1], 'from') + if (close !== -1 && !fromAfter) { + for (const entry of parseImportClause( + tokens.slice(index + 1, close + 1) + ).named) { + collected.exports.push({ + local: entry.imported, + exported: entry.local, + }) + } + } + } else if ( + isIdent(next, 'default') && + isIdent(tokens[index + 2]) && + !isIdent(tokens[index + 2], 'new') && + !isIdent(tokens[index + 2], 'function') && + !isIdent(tokens[index + 2], 'class') + ) { + collected.exports.push({ + local: (tokens[index + 2] as Token).value, + exported: 'default', + }) + } + continue + } + // module.exports = { a, b: c } / module.exports = x / exports.a = b + const isModuleExports = + isIdent(token, 'module') && + isDot(tokens[index + 1]) && + isIdent(tokens[index + 2], 'exports') + const isExportsMember = + isIdent(token, 'exports') && + isDot(tokens[index + 1]) && + !isDot(tokens[index - 1]) + if (isModuleExports || isExportsMember) { + collected.cjs = true + let at = isModuleExports ? index + 3 : index + 1 + let exportedName: string | null = null + if (isDot(tokens[at]) && isIdent(tokens[at + 1])) { + exportedName = (tokens[at + 1] as Token).value + at += 2 + } + if (!isPunct(tokens[at], '=')) { + continue + } + const value = tokens[at + 1] + if (isPunct(value, '{') && exportedName === null) { + const close = matchingBracket(tokens, at + 1) + for (const entry of parseObjectPattern(tokens.slice(at + 2, close))) { + collected.exports.push({ + local: entry.local, + exported: entry.imported, + }) + } + } else if (value?.type === 'ident') { + collected.exports.push({ + local: value.value, + exported: exportedName ?? 'default', + }) + } + } + } +} + +function walkCode( + text: string, + tokens: readonly Token[], + ctorNames: ReadonlySet<string>, + namespaces: ReadonlySet<string>, + exported: ReadonlyMap<string, string>, + clients: ClientSite[], + calls: CallLocation[] +): void { + const braceStack: boolean[] = [] + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index] as Token + if (token.children !== undefined && token.children.length > 0) { + walkCode( + text, + token.children, + ctorNames, + namespaces, + exported, + clients, + calls + ) + } + if (isPunct(token, '{')) { + braceStack.push(isClassBody(tokens, index)) + continue + } + if (isPunct(token, '}')) { + braceStack.pop() + continue + } + // new Resend(…) / new ns.Resend(…) + if (isIdent(token, 'new')) { + let ctorEnd = -1 + const first = tokens[index + 1] + if (first?.type === 'ident' && ctorNames.has(first.value)) { + ctorEnd = index + 1 + } else if ( + first?.type === 'ident' && + namespaces.has(first.value) && + isDot(tokens[index + 2]) && + isIdent(tokens[index + 3], 'Resend') + ) { + ctorEnd = index + 3 + } + if (ctorEnd === -1) { + continue + } + let args = { + start: (tokens[ctorEnd] as Token).end, + end: (tokens[ctorEnd] as Token).end, + } + let exprEnd = ctorEnd + if (isPunct(tokens[ctorEnd + 1], '(')) { + const close = matchingBracket(tokens, ctorEnd + 1) + if (close !== -1) { + args = { + start: (tokens[ctorEnd + 1] as Token).end, + end: (tokens[close] as Token).start, + } + exprEnd = close + } + } + const member = memberAt(tokens, exprEnd + 1) + const inline = member?.name === 'emails' || member?.name === 'batch' + const binding = bindingBefore(tokens, index, braceStack.at(-1) === true) + const exportName = binding.exportDefault + ? 'default' + : binding.name === null + ? null + : binding.exported + ? binding.name + : (exported.get(binding.name) ?? null) + const position = lineColumn(text, token.start) + clients.push({ + binding: binding.name, + exportName, + line: position.line, + column: position.column, + args, + inline, + }) + continue + } + // <receiver>.emails.send( / <receiver>.batch.send(, either member + // also as a literal computed one (`['emails']`). + const group = memberAt(tokens, index) + const send = + group?.name === 'emails' || group?.name === 'batch' + ? memberAt(tokens, group.next) + : null + if ( + group !== null && + send !== null && + send.name === 'send' && + isPunct(tokens[send.next], '(') + ) { + const receiver = receiverBefore(tokens, index, ctorNames, namespaces) + if (receiver === null) { + continue + } + const argument = firstArgument(tokens, send.next) + const open = tokens[send.next] as Token + const args = + argument === null + ? { start: open.end, end: open.end } + : { + start: (tokens[argument.from] as Token).start, + end: (tokens[argument.to] as Token).end, + } + const startToken = tokens[receiver.from] as Token + const position = lineColumn(text, startToken.start) + calls.push({ + receiver: text.slice( + startToken.start, + (tokens[index - 1] as Token).end + ), + method: group.name === 'emails' ? 'emails.send' : 'batch.send', + line: position.line, + column: position.column, + args, + inlineClient: receiver.inlineClient, + }) + } + } +} + +/** What a `new Resend(…)` at `newIndex` is assigned to. */ +function bindingBefore( + tokens: readonly Token[], + newIndex: number, + inClassBody: boolean +): { + readonly name: string | null + readonly exported: boolean + readonly exportDefault: boolean +} { + const previous = tokens[newIndex - 1] + if (isIdent(previous, 'default') && isIdent(tokens[newIndex - 2], 'export')) { + return { name: null, exported: false, exportDefault: true } + } + if (!isPunct(previous, '=')) { + return { name: null, exported: false, exportDefault: false } + } + // Step over a type annotation: `const resend: Resend | null = new …`. + let nameIndex = newIndex - 2 + for ( + let index = newIndex - 2; + index >= 0 && index >= newIndex - 16; + index -= 1 + ) { + const token = tokens[index] as Token + if ( + isPunct(token, ';') || + isPunct(token, '{') || + isPunct(token, '}') || + isPunct(token, '=') || + DECLARATIONS.has(token.value) + ) { + break + } + if (isPunct(token, ':') && isIdent(tokens[index - 1])) { + nameIndex = index - 1 + break + } + } + const nameToken = tokens[nameIndex] + if (nameToken?.type !== 'ident') { + return { name: null, exported: false, exportDefault: false } + } + if (isDot(tokens[nameIndex - 1])) { + return isIdent(tokens[nameIndex - 2], 'this') && + !isDot(tokens[nameIndex - 3]) + ? { + name: `this.${nameToken.value}`, + exported: false, + exportDefault: false, + } + : { name: null, exported: false, exportDefault: false } + } + const declaration = tokens[nameIndex - 1] + if (DECLARATIONS.has(declaration?.value ?? '')) { + return { + name: nameToken.value, + exported: isIdent(tokens[nameIndex - 2], 'export'), + exportDefault: false, + } + } + if ( + inClassBody && + (CLASS_MODIFIERS.has(declaration?.value ?? '') || + isPunct(declaration, ';') || + isPunct(declaration, '{') || + isPunct(declaration, '}') || + declaration === undefined) + ) { + return { + name: `this.${nameToken.value}`, + exported: false, + exportDefault: false, + } + } + return { name: nameToken.value, exported: false, exportDefault: false } +} + +/** The receiver expression ending right before the `.` at `dotIndex`. */ +function receiverBefore( + tokens: readonly Token[], + dotIndex: number, + ctorNames: ReadonlySet<string>, + namespaces: ReadonlySet<string> +): { readonly from: number; readonly inlineClient: boolean } | null { + let at = dotIndex - 1 + const last = tokens[at] + if (last === undefined) { + return null + } + const inlineClient = false + if (isPunct(last, ')') || isPunct(last, ']')) { + const open = matchingOpen(tokens, at) + if (open === -1) { + return null + } + at = open + // `new Resend(k)` / `new ns.Resend(k)` + const callee = tokens[at - 1] + if (callee?.type === 'ident') { + at -= 1 + if (ctorNames.has(callee.value) && isIdent(tokens[at - 1], 'new')) { + return { from: at - 1, inlineClient: true } + } + if ( + callee.value === 'Resend' && + isDot(tokens[at - 1]) && + namespaces.has(tokens[at - 2]?.value ?? '') && + isIdent(tokens[at - 3], 'new') + ) { + return { from: at - 3, inlineClient: true } + } + } else if (isPunct(last, ')')) { + // A parenthesized expression: `(await getClient()).emails.send`. + return { from: at, inlineClient: false } + } + } else if (last.type !== 'ident') { + return null + } + while (isDot(tokens[at - 1]) && tokens[at - 2]?.type === 'ident') { + at -= 2 + } + return { from: at, inlineClient } +} + +export function locateWithTokens(text: string, path: string): FileLocations { + const tokens = tokenize(text, { jsx: allowsJsx(path) }) + const collected: Collected = { + imports: [], + localImports: [], + exports: [], + importInsertAt: 0, + esm: false, + cjs: false, + relativeImportsUseJs: false, + } + collectModuleSyntax(tokens, collected) + const ctorNames = new Set( + collected.imports.flatMap((entry) => entry.ctorNames) + ) + const namespaces = new Set( + collected.imports.flatMap((entry) => entry.namespaces) + ) + const exported = new Map( + collected.exports.map((entry) => [entry.local, entry.exported] as const) + ) + const clients: ClientSite[] = [] + const calls: CallLocation[] = [] + walkCode(text, tokens, ctorNames, namespaces, exported, clients, calls) + return { + imports: collected.imports, + localImports: collected.localImports, + clients, + calls, + exports: collected.exports, + importInsertAt: collected.importInsertAt, + moduleSyntax: collected.esm || !collected.cjs ? 'esm' : 'cjs', + relativeImportsUseJs: collected.relativeImportsUseJs, + } +} diff --git a/src/lib/migrate/code/rewrite.ts b/src/lib/migrate/code/rewrite.ts new file mode 100644 index 00000000..94d058a5 --- /dev/null +++ b/src/lib/migrate/code/rewrite.ts @@ -0,0 +1,333 @@ +import { dirname, join, relative, sep } from 'node:path' +import type { CodeScan, ScannedCallSite, ScannedFile } from './scan' + +/** + * `--rewrite-imports`: move a file from `resend` to `@brew.new/sdk/resend` + * (Brew's Resend-shaped client) by changing its import and pointing its + * clients at the Brew key and the template map. Only where that is the + * whole job: every send through the file's clients names a template that + * migrated, and nothing else is imported from `resend`. Everything else is + * an agent next step with a suggested `brew.send` replacement. + */ + +export const ADAPTER_MODULE = '@brew.new/sdk/resend' +export const MAP_EXPORT = 'brewResendTemplates' + +export type RewriteDecision = + | { readonly file: string; readonly rewritable: true } + | { + readonly file: string + readonly rewritable: false + readonly reason: string + } + +/** + * Where the template map goes, by the project's helper directory and + * language. A JavaScript map is `.mjs` or `.cjs`, so Node loads it with its + * own module syntax whatever the package's `"type"`. + */ +export function mapFilePath( + libDir: string | null, + typescript: boolean, + moduleSyntax: 'esm' | 'cjs' +): string { + const extension = typescript ? 'ts' : moduleSyntax === 'cjs' ? 'cjs' : 'mjs' + return `${libDir ?? 'lib'}/brew-resend-map.${extension}` +} + +/** + * Whether each file that imports `resend` can move to the adapter as is. + * `knownTemplates` holds every template id and alias that has a migrated + * key; `allTemplatesMigrated` lets a computed template id through (the map + * then covers whatever it evaluates to). + */ +export function decideRewrites( + scan: CodeScan, + knownTemplates: ReadonlySet<string>, + allTemplatesMigrated: boolean +): RewriteDecision[] { + const decisions: RewriteDecision[] = [] + const sitesByClient = new Map<string, ScannedCallSite[]>() + for (const site of scan.callSites) { + if (site.clientId !== null) { + const list = sitesByClient.get(site.clientId) ?? [] + list.push(site) + sitesByClient.set(site.clientId, list) + } + } + for (const [file, scanned] of scan.files) { + const valueImports = scanned.locations.imports.filter( + (entry) => !entry.typeOnly + ) + if (valueImports.length === 0) { + continue + } + const reason = blockingReason( + file, + scanned, + scan, + sitesByClient, + knownTemplates, + allTemplatesMigrated + ) + decisions.push( + reason === null + ? { file, rewritable: true } + : { file, rewritable: false, reason } + ) + } + return decisions +} + +function blockingReason( + file: string, + scanned: ScannedFile, + scan: CodeScan, + sitesByClient: ReadonlyMap<string, readonly ScannedCallSite[]>, + knownTemplates: ReadonlySet<string>, + allTemplatesMigrated: boolean +): string | null { + if (scan.truncated) { + return 'the scan did not read every source file, so a send through it could be missed' + } + const { locations } = scanned + for (const entry of locations.imports) { + if (entry.typeOnly) { + continue + } + const other = entry.importedNames.filter((name) => name !== 'Resend') + if (other.length > 0 || entry.ctorNames.length === 0) { + return `it imports ${other.length > 0 ? other.join(', ') : 'the whole module'} from resend, which the Brew client does not provide` + } + } + // A rewrite swaps the class itself: a client another file builds from + // the export would get the Brew client with the Resend key and no map. + const ctorNames = new Set( + locations.imports.flatMap((entry) => + entry.typeOnly ? [] : entry.ctorNames + ) + ) + const exported = locations.exports.find((entry) => ctorNames.has(entry.local)) + if (exported !== undefined) { + return `it exports the Resend class (as ${exported.exported}), so a client another file builds from it would stop sending` + } + const clients = scan.clients.filter((client) => client.file === file) + const sites = [ + ...scan.callSites.filter((site) => site.file === file), + ...clients.flatMap((client) => sitesByClient.get(client.id) ?? []), + // A send whose client could not be followed may go through any of them. + ...(clients.length === 0 + ? [] + : scan.callSites.filter((site) => site.clientId === null)), + ] + if (sites.length === 0 && clients.length === 0) { + return 'it sends nothing through Resend here' + } + for (const site of sites) { + const reason = sendBlocker(site, knownTemplates, allTemplatesMigrated) + if (reason !== null) { + return reason + } + } + // The Brew client has emails.send and batch.send only: a client also + // used for anything else (contacts, emails.get) stays on Resend. + const shared = clients.find((client) => client.usedBeyondSendIn !== null) + if (shared !== undefined) { + return `${shared.usedBeyondSendIn} uses the client for more than emails.send and batch.send, which the Brew client does not provide` + } + for (const client of clients) { + const args = scanned.text.slice( + client.site.args.start, + client.site.args.end + ) + if (keyArgument(args) === null) { + return `the client at ${file}:${client.line} reads its key from somewhere other than an environment lookup of RESEND_API_KEY; point it at your Brew key by hand` + } + } + return null +} + +/** Why the Brew client cannot make this send as it is, or null when it can. */ +export function sendBlocker( + site: ScannedCallSite, + knownTemplates: ReadonlySet<string>, + allTemplatesMigrated: boolean +): string | null { + const where = `${site.file}:${site.line}` + if (site.analysis.kind !== 'template') { + return `the send at ${where} is ${site.analysis.kind === 'mixed' ? 'a mixed batch' : `a ${site.analysis.kind} send`}, not a template` + } + for (const message of site.analysis.messages) { + if (message.unsupported.length > 0) { + return `the send at ${where} uses ${message.unsupported.join(', ')}, which Brew app email does not take` + } + if (message.templateId === null && !allTemplatesMigrated) { + return `the send at ${where} computes its template id, and not every template migrated` + } + if ( + message.templateId !== null && + !knownTemplates.has(message.templateId) + ) { + return `the send at ${where} uses template ${message.templateId}, which has no Brew key yet` + } + } + return null +} + +/** + * The Brew key expression for a client's arguments: an environment lookup + * of `RESEND_API_KEY` (`process.env.RESEND_API_KEY`, `env['RESEND_API_KEY']`, + * `Deno.env.get('RESEND_API_KEY')`) renamed to `BREW_API_KEY`, or + * `process.env.BREW_API_KEY` when the client took none; null when the key + * comes from anywhere else, such as a local `RESEND_API_KEY` binding that + * a rename would leave undeclared. + */ +export function keyArgument(args: string): string | null { + const trimmed = args.trim() + if (trimmed === '') { + return 'process.env.BREW_API_KEY' + } + if (/,/.test(trimmed.replace(/\([^()]*\)/g, ''))) { + return null + } + const names = trimmed.match(/\bRESEND_API_KEY\b/g)?.length ?? 0 + const lookups = + trimmed.match(/\benv(?:\.|\[\s*['"`]|\.get\(\s*['"`])RESEND_API_KEY\b/g) + ?.length ?? 0 + return names > 0 && lookups === names + ? trimmed.replace(/\bRESEND_API_KEY\b/g, 'BREW_API_KEY') + : null +} + +/** + * The map's specifier from `fromFile`. A JavaScript map is named with its + * extension (Node ESM resolves no other); a TypeScript map as the file's + * other relative imports name theirs. + */ +function relativeSpecifier( + fromFile: string, + target: string, + useJs: boolean +): string { + const typescript = target.endsWith('.ts') + let path = relative( + dirname(fromFile), + typescript ? target.slice(0, -3) : target + ) + .split(sep) + .join('/') + if (!path.startsWith('.')) { + path = `./${path}` + } + return typescript && useJs ? `${path}.js` : path +} + +/** The rewritten text of a file `decideRewrites` cleared. */ +export function rewriteFile(input: { + readonly file: string + readonly scanned: ScannedFile + readonly mapFile: string +}): string { + const { text, locations } = input.scanned + const edits: Array<{ start: number; end: number; replacement: string }> = [] + for (const entry of locations.imports) { + if (entry.typeOnly) { + continue + } + const quote = text[entry.specifier.start] ?? "'" + edits.push({ + start: entry.specifier.start, + end: entry.specifier.end, + replacement: `${quote}${ADAPTER_MODULE}${quote}`, + }) + } + const specifier = relativeSpecifier( + input.file, + input.mapFile, + locations.relativeImportsUseJs + ) + const quote = text[locations.imports[0]?.specifier.start ?? -1] ?? "'" + const esm = locations.moduleSyntax === 'esm' + // CommonJS reads the map where the client is built, so no statement + // order (`require` and `new` on one line) can reach it undeclared. + const templates = esm + ? MAP_EXPORT + : `require(${quote}${specifier}${quote}).${MAP_EXPORT}` + for (const client of locations.clients) { + const args = text.slice(client.args.start, client.args.end) + const key = keyArgument(args) ?? 'process.env.BREW_API_KEY' + const replacement = `${key}, { templates: ${templates} }` + if ( + client.args.start === client.args.end && + text[client.args.start] !== ')' + ) { + // `new Resend` without parentheses. + edits.push({ + start: client.args.start, + end: client.args.end, + replacement: `(${replacement})`, + }) + } else { + edits.push({ + start: client.args.start, + end: client.args.end, + replacement, + }) + } + } + if (esm && locations.clients.length > 0) { + const at = locations.importInsertAt + // Its own line: code after the last import on that line stays apart. + const lineBreak = /[\r\n]/.test(text[at] ?? '\n') ? '' : '\n' + edits.push({ + start: at, + end: at, + replacement: `${at === 0 ? '' : '\n'}import { ${MAP_EXPORT} } from ${quote}${specifier}${quote}${lineBreak}`, + }) + } + edits.sort((a, b) => b.start - a.start) + let out = text + for (const edit of edits) { + out = out.slice(0, edit.start) + edit.replacement + out.slice(edit.end) + } + return out +} + +/** `lib/brew-resend-map.ts`: Resend template id and alias → Brew key. */ +export function mapFileContent(input: { + readonly entries: ReadonlyArray<readonly [string, string]> + readonly typescript: boolean + readonly moduleSyntax: 'esm' | 'cjs' + readonly rerunCommand: string +}): string { + const lines = input.entries.map( + ([id, key]) => ` ${JSON.stringify(id)}: ${JSON.stringify(key)},` + ) + const header = [ + '/**', + ' * Resend template id (and alias) → Brew trigger key, written by', + ` * \`${input.rerunCommand}\`. \`new Resend(key, { templates })\` from`, + ` * \`${ADAPTER_MODULE}\` sends each template by its key. Re-run the`, + ' * migration to refresh this file.', + ' */', + ] + if (input.moduleSyntax === 'cjs' && !input.typescript) { + return [...header, `exports.${MAP_EXPORT} = {`, ...lines, '}', ''].join( + '\n' + ) + } + return [ + ...header, + input.typescript + ? `export const ${MAP_EXPORT}: Readonly<Record<string, string>> = {` + : `export const ${MAP_EXPORT} = {`, + ...lines, + '}', + '', + ].join('\n') +} + +/** Project-relative POSIX path of `file` joined to the root. */ +export function absoluteIn(root: string, file: string): string { + return join(root, ...file.split('/')) +} diff --git a/src/lib/migrate/code/scan.ts b/src/lib/migrate/code/scan.ts new file mode 100644 index 00000000..c9be1084 --- /dev/null +++ b/src/lib/migrate/code/scan.ts @@ -0,0 +1,564 @@ +import { + existsSync, + lstatSync, + readdirSync, + readFileSync, + statSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { basename, dirname, extname, join, relative, sep } from 'node:path' +import { + type ArgumentAnalysis, + analyzeArgument, + reactEmailRuntimeMarkers, +} from './analyze' +import { locateWithTokens } from './locate' +import { + locateWithTypeScript, + type TypeScriptModule, +} from './locate-typescript' +import { allowsJsx, memberAt, type Token, tokenize } from './tokenize' +import type { ClientSite, FileLocations, ScanEngine } from './types' + +/** + * The project-wide code scan: every file that may send through Resend, its + * clients (followed across files through relative imports), each send call + * classified, and the React Email templates in the project. + */ + +const SOURCE_EXTENSIONS = [ + '.ts', + '.tsx', + '.mts', + '.cts', + '.js', + '.jsx', + '.mjs', + '.cjs', +] +const SKIP_DIRECTORIES = new Set([ + 'node_modules', + '.git', + '.next', + '.nuxt', + '.output', + '.svelte-kit', + '.vercel', + '.netlify', + '.turbo', + '.react-email', + '.wrangler', + '.cache', + 'dist', + 'build', + 'out', + 'coverage', +]) +const MAX_FILES = 20_000 +const MAX_FILE_BYTES = 1_000_000 +/** + * A file the send scan reads: it names Resend, or has an `emails.send` / + * `batch.send` in any form the scan reads as one (`.emails.send`, + * `?.emails?.send`, `['emails'].send`, `["batch"]["send"]`), so a caller + * that sends through a shared client under another name is never skipped. + */ +const SEND_HINT = + /resend|(?:\.\s*|\[\s*['"`])(?:emails|batch)\b['"`]?\s*\]?\s*(?:\?\.|\.)?\s*(?:\[\s*['"`])?send\b/i +const REACT_EMAIL_IMPORT = /from\s*['"](?:@react-email\/[^'"]+|react-email)['"]/ + +export type ScannedClient = { + /** `src/lib/resend.ts#resend`, or `src/x.ts:12` for an unnamed client. */ + readonly id: string + readonly file: string + readonly binding: string | null + readonly exportName: string | null + readonly line: number + readonly column: number + readonly inline: boolean + readonly site: ClientSite + /** The first file that uses it for more than emails.send and batch.send, or null. */ + readonly usedBeyondSendIn: string | null +} + +export type ScannedCallSite = { + readonly file: string + readonly line: number + readonly column: number + readonly method: 'emails.send' | 'batch.send' + readonly receiver: string + /** The client this call goes through; null when it could not be followed. */ + readonly clientId: string | null + readonly analysis: ArgumentAnalysis +} + +export type ReactEmailFile = { + /** Project-relative, POSIX. */ + readonly file: string + /** Why it is migrated by hand (relative imports, runtime logic); empty when it imports as is. */ + readonly manualReasons: readonly string[] +} + +export type ScannedFile = { + readonly text: string + readonly locations: FileLocations +} + +export type CodeScan = { + readonly engine: ScanEngine + readonly filesScanned: number + readonly truncated: boolean + /** Files that import `resend` or hold a send call, by project-relative path. */ + readonly files: ReadonlyMap<string, ScannedFile> + readonly clients: readonly ScannedClient[] + readonly callSites: readonly ScannedCallSite[] + readonly reactEmail: readonly ReactEmailFile[] +} + +export type CodeScanOptions = { + /** + * The TypeScript compiler to parse with. Absent: the project's own + * (`typescript` resolved from its root), else the built-in tokenizer. + * `null` forces the tokenizer. + */ + readonly typescript?: TypeScriptModule | null +} + +function toPosix(path: string): string { + return path.split(sep).join('/') +} + +/** The project's own TypeScript compiler, when it is installed and recent enough. */ +export function loadProjectTypeScript(root: string): TypeScriptModule | null { + try { + const require = createRequire(join(root, 'package.json')) + const ts = require('typescript') as TypeScriptModule + return typeof ts.createSourceFile === 'function' && + typeof ts.getModifiers === 'function' && + typeof ts.isSatisfiesExpression === 'function' + ? ts + : null + } catch { + return null + } +} + +function listSourceFiles(root: string): { + files: string[] + truncated: boolean +} { + const files: string[] = [] + let truncated = false + const walk = (dir: string): void => { + let entries: string[] + try { + entries = readdirSync(dir).sort() + } catch { + return + } + for (const entry of entries) { + if (files.length >= MAX_FILES) { + truncated = true + return + } + const path = join(dir, entry) + let stats: ReturnType<typeof lstatSync> + try { + stats = lstatSync(path) + } catch { + continue + } + if (stats.isSymbolicLink()) { + continue + } + if (stats.isDirectory()) { + if (!SKIP_DIRECTORIES.has(entry) && !entry.startsWith('.')) { + walk(path) + } + continue + } + if ( + stats.isFile() && + SOURCE_EXTENSIONS.includes(extname(entry)) && + !entry.endsWith('.d.ts') + ) { + files.push(path) + } + } + } + walk(root) + return { files, truncated } +} + +/** The file a project import names, resolved the way bundlers do. */ +export function resolveProjectModule( + root: string, + fromFile: string, + specifier: string +): string | null { + const bases: string[] = [] + if (/^[@~]\//.test(specifier)) { + const rest = specifier.slice(2) + bases.push(join(root, rest), join(root, 'src', rest)) + } else { + bases.push(join(dirname(fromFile), specifier)) + } + for (const base of bases) { + const candidates = [base] + const stripped = base.replace(/\.(?:js|jsx|mjs|cjs)$/, '') + for (const extension of SOURCE_EXTENSIONS) { + candidates.push(`${stripped}${extension}`) + } + for (const extension of SOURCE_EXTENSIONS) { + candidates.push(join(base, `index${extension}`)) + } + for (const candidate of candidates) { + try { + if (statSync(candidate).isFile()) { + return candidate + } + } catch {} + } + } + return null +} + +function locate( + ts: TypeScriptModule | null, + text: string, + path: string +): { readonly locations: FileLocations; readonly engine: ScanEngine } { + if (ts !== null) { + try { + return { + locations: locateWithTypeScript(ts, text, path), + engine: 'typescript', + } + } catch {} + } + return { locations: locateWithTokens(text, path), engine: 'tokenizer' } +} + +export function scanProject( + root: string, + options: CodeScanOptions = {} +): CodeScan { + const ts = + options.typescript === undefined + ? loadProjectTypeScript(root) + : options.typescript + const listed = listSourceFiles(root) + const files = new Map<string, ScannedFile>() + const reactEmail: ReactEmailFile[] = [] + let usedTypeScript = false + let truncated = listed.truncated + for (const absolute of listed.files) { + let text: string + try { + text = readFileSync(absolute, 'utf8') + } catch { + continue + } + const file = toPosix(relative(root, absolute)) + const isReactEmail = + /\.(?:tsx|jsx|js)$/.test(file) && + REACT_EMAIL_IMPORT.test(text) && + /\bexport\s+default\b/.test(text) && + /<Html[\s>]/.test(text) + if (!SEND_HINT.test(text) && !isReactEmail) { + continue + } + if (text.length > MAX_FILE_BYTES) { + // Too big to parse, and it may send: the scan is incomplete. + truncated = true + continue + } + const located = locate(ts, text, file) + usedTypeScript ||= located.engine === 'typescript' + if (isReactEmail) { + const reasons = [ + ...located.locations.localImports.map( + (entry) => `imports ${entry.from}` + ), + ...reactEmailRuntimeMarkers(text, file).map( + (marker) => `runs ${marker}` + ), + ] + reactEmail.push({ file, manualReasons: [...new Set(reasons)] }) + } + if ( + located.locations.imports.length > 0 || + located.locations.calls.length > 0 + ) { + files.set(file, { text, locations: located.locations }) + } + } + + // Clients, by file and by the name other files import them as. + const found: Array<Omit<ScannedClient, 'usedBeyondSendIn'>> = [] + for (const [file, scanned] of files) { + for (const site of scanned.locations.clients) { + found.push({ + id: + site.binding === null + ? `${file}:${site.line}` + : `${file}#${site.binding}`, + file, + binding: site.binding, + exportName: site.exportName, + line: site.line, + column: site.column, + inline: site.inline, + site, + }) + } + } + const users = clientUsers(root, ts, listed.files, files, found) + truncated ||= users.truncated + const clients: ScannedClient[] = found.map((client) => ({ + ...client, + usedBeyondSendIn: users.byClient.get(client.id) ?? null, + })) + const clientByBinding = new Map( + clients + .filter((client) => client.binding !== null) + .map((client) => [`${client.file}#${client.binding}`, client] as const) + ) + const clientByExport = new Map( + clients + .filter((client) => client.exportName !== null) + .map((client) => [`${client.file}#${client.exportName}`, client] as const) + ) + + const callSites: ScannedCallSite[] = [] + for (const [file, scanned] of files) { + const { locations } = scanned + const hasResendImport = locations.imports.some((entry) => !entry.typeOnly) + for (const call of locations.calls) { + let clientId: string | null = null + if (call.inlineClient) { + const inline = clients.find( + (client) => + client.file === file && client.inline && client.line === call.line + ) + clientId = inline?.id ?? `${file}:${call.line}` + } else { + const local = clientByBinding.get(`${file}#${call.receiver}`) + if (local !== undefined) { + clientId = local.id + } else { + const head = call.receiver.split(/\??\./)[0] ?? call.receiver + const imported = locations.localImports.find( + (entry) => entry.local === head + ) + if (imported !== undefined) { + const target = resolveProjectModule( + root, + join(root, file), + imported.from + ) + if (target !== null) { + const targetFile = toPosix(relative(root, target)) + const name = + imported.imported === '*' + ? (call.receiver.split(/\??\./)[1] ?? '') + : imported.imported + clientId = clientByExport.get(`${targetFile}#${name}`)?.id ?? null + } + } + } + } + // A call through something that is not a known client counts only + // when it plausibly is one: the file uses Resend, or the receiver is + // named for it. + if ( + clientId === null && + !hasResendImport && + !/resend/i.test(call.receiver) + ) { + continue + } + callSites.push({ + file, + line: call.line, + column: call.column, + method: call.method, + receiver: call.receiver, + clientId, + analysis: analyzeArgument(scanned.text, call.args, file), + }) + } + } + callSites.sort((a, b) => + a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1 + ) + + return { + engine: usedTypeScript ? 'typescript' : 'tokenizer', + filesScanned: listed.files.length, + truncated, + files, + clients, + callSites, + reactEmail, + } +} + +function isDot(token: Token | undefined): boolean { + return ( + token?.type === 'punct' && (token.value === '.' || token.value === '?.') + ) +} + +/** The index after `a.b.c` (or `a?.b`) at `at`, or -1. */ +function pathEnd( + tokens: readonly Token[], + at: number, + names: readonly string[] +): number { + let index = at + for (const [position, name] of names.entries()) { + if (position > 0) { + if (!isDot(tokens[index])) { + return -1 + } + index += 1 + } + const token = tokens[index] + if (token?.type !== 'ident' || token.value !== name) { + return -1 + } + index += 1 + } + return index +} + +/** + * Whether code (never a comment or a string) reaches `path`, a client's + * binding or import, for more than `emails.send` and `batch.send`. A + * literal computed member (`['emails']`) reads as its dot form; any other + * computed member (`[key]`) may be anything, so it is more. + */ +function reachesBeyondSend( + tokens: readonly Token[], + path: readonly string[] +): boolean { + return tokens.some((token, index) => { + if ( + token.children !== undefined && + reachesBeyondSend(token.children, path) + ) { + return true + } + const end = isDot(tokens[index - 1]) ? -1 : pathEnd(tokens, index, path) + const group = end === -1 ? null : memberAt(tokens, end) + if (group === null) { + return false + } + return ( + (group.name !== 'emails' && group.name !== 'batch') || + memberAt(tokens, group.next)?.name !== 'send' + ) + }) +} + +/** + * The first file that uses each client for more than `emails.send` and + * `batch.send` (all the Brew client has): its own file through its binding, + * or any project file whose import resolves to its module. Such an import + * names the module (its folder for an index file) or is `./`, so only files + * holding that are parsed; `truncated` when one is too big to. + */ +function clientUsers( + root: string, + ts: TypeScriptModule | null, + listed: readonly string[], + files: ReadonlyMap<string, ScannedFile>, + clients: ReadonlyArray<Omit<ScannedClient, 'usedBeyondSendIn'>> +): { + readonly byClient: ReadonlyMap<string, string> + readonly truncated: boolean +} { + const byClient = new Map<string, string>() + let truncated = false + const uses = (file: string, text: string, path: readonly string[]) => { + try { + return reachesBeyondSend(tokenize(text, { jsx: allowsJsx(file) }), path) + } catch { + return true + } + } + for (const client of clients) { + const own = files.get(client.file) + if ( + client.binding !== null && + own !== undefined && + uses(client.file, own.text, client.binding.split('.')) + ) { + byClient.set(client.id, client.file) + } + } + const shared = clients.filter((client) => client.exportName !== null) + for (const absolute of shared.length === 0 ? [] : listed) { + const file = toPosix(relative(root, absolute)) + let text: string + try { + text = files.get(file)?.text ?? readFileSync(absolute, 'utf8') + } catch { + continue + } + const named = shared.filter( + (client) => + client.file !== file && + !byClient.has(client.id) && + (text.includes(reactEmailBaseName(client.file)) || + /['"](?:\.{1,2}|[@~])\/['"]/.test(text)) + ) + if (named.length === 0) { + continue + } + if (text.length > MAX_FILE_BYTES) { + truncated = true + continue + } + const { localImports } = + files.get(file)?.locations ?? locate(ts, text, file).locations + for (const entry of localImports) { + const target = resolveProjectModule(root, absolute, entry.from) + for (const client of named) { + const path = + target === null || toPosix(relative(root, target)) !== client.file + ? null + : entry.imported === '*' + ? [entry.local, client.exportName ?? ''] + : entry.imported === client.exportName + ? [entry.local] + : null + if ( + path !== null && + !byClient.has(client.id) && + uses(file, text, path) + ) { + byClient.set(client.id, file) + } + } + } + } + return { byClient, truncated } +} + +/** The key a React Email file migrates under (before prefix and uniqueness). */ +export function reactEmailBaseName(file: string): string { + const name = basename(file, extname(file)) + return name === 'index' ? basename(dirname(file)) : name +} + +/** Whether `lib/brew.ts` (written by init) exists, for suggested replacements. */ +export function hasBrewClientFile( + root: string, + libDir: string | null +): boolean { + const dir = libDir ?? 'lib' + return ['ts', 'js', 'mjs'].some((extension) => + existsSync(join(root, dir, `brew.${extension}`)) + ) +} diff --git a/src/lib/migrate/code/tokenize.ts b/src/lib/migrate/code/tokenize.ts new file mode 100644 index 00000000..59b49d29 --- /dev/null +++ b/src/lib/migrate/code/tokenize.ts @@ -0,0 +1,625 @@ +/** + * A small JavaScript / TypeScript / JSX tokenizer for the migration's code + * scan, used when the project has no TypeScript compiler installed (and, + * always, for reading one call's argument). It knows exactly what the scan + * needs: comments and string contents are never mistaken for code, brackets + * balance, and a template literal or a JSX element is ONE token whose + * embedded expressions (`${…}`, `{…}`) are its `children`, so a caller that + * walks one level never trips over them. + * + * Heuristics, documented where they live: `/` starts a regular expression + * where an expression may start; `<` starts JSX there too (in a file that + * may hold JSX), and an element that never closes is rolled back to a plain + * `<`, so a TypeScript type parameter cannot swallow the rest of the file. + */ + +export type TokenType = + | 'ident' + | 'punct' + | 'string' + | 'template' + | 'number' + | 'regex' + | 'jsx' + +export type JsxAttribute = { + /** `null` for a spread attribute (`{...props}`). */ + readonly name: string | null + /** The value's source: a string literal (quotes kept) or the expression inside `{}`. */ + readonly value: string | null + readonly kind: 'string' | 'expression' | 'boolean' | 'spread' +} + +export type Token = { + readonly type: TokenType + /** Identifier or punctuator text; a literal's raw source; a JSX tag name (`''` for a fragment). */ + readonly value: string + readonly start: number + readonly end: number + /** Tokens of a template literal's `${}` or a JSX element's `{}` expressions (and nested elements). */ + readonly children?: readonly Token[] + readonly attributes?: readonly JsxAttribute[] +} + +const KEYWORDS_BEFORE_EXPRESSION = new Set([ + 'return', + 'typeof', + 'instanceof', + 'in', + 'of', + 'new', + 'delete', + 'void', + 'throw', + 'case', + 'do', + 'else', + 'yield', + 'await', + 'default', + 'export', +]) + +const MULTI_PUNCT = [ + '>>>=', + '...', + '===', + '!==', + '**=', + '<<=', + '>>=', + '&&=', + '||=', + '??=', + '=>', + '==', + '!=', + '<=', + '>=', + '&&', + '||', + '??', + '++', + '--', + '+=', + '-=', + '*=', + '/=', + '%=', + '&=', + '|=', + '^=', + '**', + '<<', +] + +class JsxBail extends Error {} + +function isIdentStart(char: string | undefined): boolean { + return char !== undefined && /[A-Za-z_$\u0080-￿]/.test(char) +} + +function isIdentPart(char: string | undefined): boolean { + return char !== undefined && /[A-Za-z0-9_$\u0080-￿]/.test(char) +} + +class Lexer { + pos = 0 + readonly text: string + readonly jsx: boolean + + constructor(text: string, jsx: boolean) { + this.text = text + this.jsx = jsx + } + + /** + * Tokens until `closer` at this level (consumed, not emitted), or the end + * of the text when `closer` is null. An unmatched closer at the top level + * is emitted as punctuation. + */ + lex(closer: '}' | null): Token[] { + const tokens: Token[] = [] + const stack: string[] = [] + const { text } = this + while (this.pos < text.length) { + const char = text[this.pos] as string + const next = text[this.pos + 1] + if (/\s/.test(char)) { + this.pos += 1 + continue + } + if (char === '/' && next === '/') { + const end = text.indexOf('\n', this.pos) + this.pos = end === -1 ? text.length : end + continue + } + if (char === '/' && next === '*') { + const end = text.indexOf('*/', this.pos + 2) + this.pos = end === -1 ? text.length : end + 2 + continue + } + if (char === '#' && next === '!' && this.pos === 0) { + const end = text.indexOf('\n') + this.pos = end === -1 ? text.length : end + continue + } + const start = this.pos + if (char === '"' || char === "'") { + this.pos = this.skipString(start, char) + tokens.push({ + type: 'string', + value: text.slice(start, this.pos), + start, + end: this.pos, + }) + continue + } + if (char === '`') { + const children: Token[] = [] + this.pos = this.skipTemplate(start, children) + tokens.push({ + type: 'template', + value: text.slice(start, this.pos), + start, + end: this.pos, + children, + }) + continue + } + if (/[0-9]/.test(char) || (char === '.' && /[0-9]/.test(next ?? ''))) { + let end = this.pos + 1 + while (end < text.length && /[0-9A-Za-z_.]/.test(text[end] as string)) { + end += 1 + } + this.pos = end + tokens.push({ + type: 'number', + value: text.slice(start, end), + start, + end, + }) + continue + } + if (isIdentStart(char) || (char === '#' && isIdentStart(next))) { + let end = this.pos + 1 + while (isIdentPart(text[end])) { + end += 1 + } + this.pos = end + tokens.push({ + type: 'ident', + value: text.slice(start, end), + start, + end, + }) + continue + } + if (char === '/' && expressionMayStart(tokens)) { + this.pos = this.skipRegex(start) + tokens.push({ + type: 'regex', + value: text.slice(start, this.pos), + start, + end: this.pos, + }) + continue + } + if ( + char === '<' && + this.jsx && + expressionMayStart(tokens) && + (isIdentStart(next) || next === '>') + ) { + const element = this.tryJsx(start) + if (element !== null) { + tokens.push(element) + continue + } + } + if (char === '}' && stack.length === 0 && closer === '}') { + this.pos += 1 + return tokens + } + if (char === '(' || char === '[' || char === '{') { + stack.push(char) + } else if (char === ')' || char === ']' || char === '}') { + stack.pop() + } + const multi = + char === '?' && next === '.' && !/[0-9]/.test(text[this.pos + 2] ?? '') + ? '?.' + : MULTI_PUNCT.find((punct) => text.startsWith(punct, this.pos)) + const value = multi ?? char + this.pos += value.length + tokens.push({ type: 'punct', value, start, end: this.pos }) + } + if (closer !== null) { + throw new JsxBail('unterminated expression') + } + return tokens + } + + skipString(start: number, quote: string): number { + const { text } = this + let index = start + 1 + while (index < text.length) { + const char = text[index] + if (char === '\\') { + index += 2 + continue + } + if (char === quote) { + return index + 1 + } + if (char === '\n') { + return index + } + index += 1 + } + return text.length + } + + skipTemplate(start: number, children: Token[]): number { + const { text } = this + let index = start + 1 + while (index < text.length) { + const char = text[index] + if (char === '\\') { + index += 2 + continue + } + if (char === '`') { + return index + 1 + } + if (char === '$' && text[index + 1] === '{') { + this.pos = index + 2 + try { + children.push(...this.lex('}')) + } catch (error) { + if (error instanceof JsxBail) { + return text.length + } + throw error + } + index = this.pos + continue + } + index += 1 + } + return text.length + } + + skipRegex(start: number): number { + const { text } = this + let index = start + 1 + let inClass = false + while (index < text.length) { + const char = text[index] + if (char === '\\') { + index += 2 + continue + } + if (char === '\n') { + return index + } + if (inClass) { + if (char === ']') { + inClass = false + } + } else if (char === '[') { + inClass = true + } else if (char === '/') { + index += 1 + while (isIdentPart(text[index])) { + index += 1 + } + return index + } + index += 1 + } + return text.length + } + + /** A JSX element at `start`, or null (position restored) when it is not one. */ + tryJsx(start: number): Token | null { + // `<T,>() =>` and `<T extends U>` are type parameters, not elements. + const head = /^<\s*([A-Za-z_$][\w$.:-]*)\s*(,|extends\b)?/.exec( + this.text.slice(start, start + 200) + ) + if (head?.[2] !== undefined) { + return null + } + try { + return this.jsxElement(start) + } catch (error) { + if (error instanceof JsxBail) { + this.pos = start + return null + } + throw error + } + } + + jsxElement(start: number): Token { + const { text } = this + this.pos = start + 1 + const children: Token[] = [] + const attributes: JsxAttribute[] = [] + const name = this.readJsxName() + // Attributes. + for (;;) { + this.skipSpace() + if (this.pos >= text.length) { + throw new JsxBail('unterminated element') + } + if (text.startsWith('/>', this.pos)) { + this.pos += 2 + return { + type: 'jsx', + value: name, + start, + end: this.pos, + children, + attributes, + } + } + if (text[this.pos] === '>') { + this.pos += 1 + break + } + if (text[this.pos] === '{') { + const open = this.pos + this.pos += 1 + children.push(...this.lex('}')) + const inner = text.slice(open + 1, this.pos - 1).trim() + attributes.push({ + name: null, + value: inner.replace(/^\.\.\./, '').trim(), + kind: 'spread', + }) + continue + } + const attrName = this.readJsxName() + if (attrName === '') { + throw new JsxBail('not an attribute') + } + this.skipSpace() + if (text[this.pos] !== '=') { + attributes.push({ name: attrName, value: null, kind: 'boolean' }) + continue + } + this.pos += 1 + this.skipSpace() + const quote = text[this.pos] + if (quote === '"' || quote === "'") { + const end = text.indexOf(quote, this.pos + 1) + if (end === -1) { + throw new JsxBail('unterminated attribute') + } + attributes.push({ + name: attrName, + value: text.slice(this.pos, end + 1), + kind: 'string', + }) + this.pos = end + 1 + continue + } + if (quote === '{') { + const open = this.pos + this.pos += 1 + children.push(...this.lex('}')) + attributes.push({ + name: attrName, + value: text.slice(open + 1, this.pos - 1).trim(), + kind: 'expression', + }) + continue + } + if (quote === '<') { + const nested = this.jsxElement(this.pos) + children.push(nested) + attributes.push({ + name: attrName, + value: text.slice(nested.start, nested.end), + kind: 'expression', + }) + continue + } + throw new JsxBail('unexpected attribute value') + } + // Children. + for (;;) { + if (this.pos >= text.length) { + throw new JsxBail('unterminated element') + } + if (text.startsWith('</', this.pos)) { + const close = text.indexOf('>', this.pos) + if (close === -1) { + throw new JsxBail('unterminated closing tag') + } + const closing = text.slice(this.pos + 2, close).trim() + if (closing !== name) { + throw new JsxBail('mismatched closing tag') + } + this.pos = close + 1 + return { + type: 'jsx', + value: name, + start, + end: this.pos, + children, + attributes, + } + } + const char = text[this.pos] + if (char === '<') { + children.push(this.jsxElement(this.pos)) + continue + } + if (char === '{') { + this.pos += 1 + children.push(...this.lex('}')) + continue + } + this.pos += 1 + } + } + + readJsxName(): string { + const { text } = this + const start = this.pos + while ( + this.pos < text.length && + /[\w$.:-]/.test(text[this.pos] as string) + ) { + this.pos += 1 + } + return text.slice(start, this.pos) + } + + skipSpace(): void { + while ( + this.pos < this.text.length && + /\s/.test(this.text[this.pos] as string) + ) { + this.pos += 1 + } + } +} + +/** True where a regular expression (or JSX) may start, judged by the last token. */ +function expressionMayStart(tokens: readonly Token[]): boolean { + const last = tokens.at(-1) + if (last === undefined) { + return true + } + switch (last.type) { + case 'ident': + return KEYWORDS_BEFORE_EXPRESSION.has(last.value) + case 'punct': + return ![')', ']', '}', '++', '--'].includes(last.value) + default: + return false + } +} + +/** + * Tokens of `text`. `jsx` enables JSX (every extension but `.ts`, `.mts` + * and `.cts`, where `<` is a type argument or a comparison). + */ +export function tokenize( + text: string, + options: { readonly jsx: boolean } +): Token[] { + const lexer = new Lexer(text, options.jsx) + return lexer.lex(null) +} + +/** Whether a file of this name may hold JSX. */ +export function allowsJsx(path: string): boolean { + return !/\.(?:ts|mts|cts)$/.test(path) +} + +/** The index of the bracket that closes the one at `open`, or -1. */ +export function matchingBracket( + tokens: readonly Token[], + open: number +): number { + const pairs: Readonly<Record<string, string>> = { + '(': ')', + '[': ']', + '{': '}', + } + const opener = tokens[open]?.value ?? '' + const closer = pairs[opener] + if (closer === undefined) { + return -1 + } + let depth = 0 + for (let index = open; index < tokens.length; index += 1) { + const token = tokens[index] as Token + if (token.type !== 'punct') { + continue + } + if (token.value === '(' || token.value === '[' || token.value === '{') { + depth += 1 + } else if ( + token.value === ')' || + token.value === ']' || + token.value === '}' + ) { + depth -= 1 + if (depth === 0) { + return token.value === closer ? index : -1 + } + } + } + return -1 +} + +/** + * The member access starting at `at`: `.name`, `?.name`, `['name']` or + * `?.['name']`, and the index after it. A computed access whose name is not + * a string literal (`[key]`) has a null name (and `next` -1); null when no + * member access starts at `at`. + */ +export function memberAt( + tokens: readonly Token[], + at: number +): { readonly name: string | null; readonly next: number } | null { + const token = tokens[at] + if (token?.type !== 'punct') { + return null + } + let open = at + if (token.value === '.' || token.value === '?.') { + const name = tokens[at + 1] + if (name?.type === 'ident') { + return { name: name.value, next: at + 2 } + } + if (token.value === '.') { + return null + } + open = at + 1 + } + const bracket = tokens[open] + if (bracket?.type !== 'punct' || bracket.value !== '[') { + return null + } + const key = tokens[open + 1] + const close = tokens[open + 2] + const name = key === undefined ? null : stringValue(key) + return name !== null && close?.type === 'punct' && close.value === ']' + ? { name, next: open + 3 } + : { name: null, next: -1 } +} + +/** A string literal token's value (simple escapes resolved). */ +export function stringValue(token: Token): string | null { + if (token.type === 'string') { + return token.value + .slice(1, token.value.endsWith(token.value[0] as string) ? -1 : undefined) + .replace(/\\(.)/g, '$1') + } + if (token.type === 'template' && (token.children ?? []).length === 0) { + return token.value.slice(1, -1) + } + return null +} + +/** 1-based line and column of an offset. */ +export function lineColumn( + text: string, + offset: number +): { readonly line: number; readonly column: number } { + let line = 1 + let lineStart = 0 + for (let index = 0; index < offset && index < text.length; index += 1) { + if (text[index] === '\n') { + line += 1 + lineStart = index + 1 + } + } + return { line, column: offset - lineStart + 1 } +} diff --git a/src/lib/migrate/code/types.ts b/src/lib/migrate/code/types.ts new file mode 100644 index 00000000..eb93bbd9 --- /dev/null +++ b/src/lib/migrate/code/types.ts @@ -0,0 +1,74 @@ +/** + * What the code scan finds in one file, whichever engine read it (the + * project's TypeScript compiler, or the built-in tokenizer). Offsets are + * UTF-16 indexes into the file's text; lines and columns are 1-based. + */ + +export type Span = { readonly start: number; readonly end: number } + +/** An import of the `resend` package (`import … from 'resend'`, `require('resend')`). */ +export type ResendImport = { + readonly kind: 'esm' | 'require' + /** The module string, quotes included. */ + readonly specifier: Span + readonly typeOnly: boolean + /** Locals bound to the `Resend` class (`Resend`, an alias). */ + readonly ctorNames: readonly string[] + /** Locals bound to the whole module (`import * as r`, `const r = require(…)`). */ + readonly namespaces: readonly string[] + /** Every name imported from the module (`Resend`, `CreateEmailOptions`, `default`, `*`). */ + readonly importedNames: readonly string[] +} + +/** A binding imported from another file of the project. */ +export type LocalImport = { + readonly local: string + /** `default`, `*`, or the exported name. */ + readonly imported: string + readonly from: string +} + +/** A `new Resend(…)`. */ +export type ClientSite = { + /** `resend`, `this.mailer`; null for an inline or unnamed client. */ + readonly binding: string | null + /** The name another file imports it by (`default` for a default export). */ + readonly exportName: string | null + readonly line: number + readonly column: number + /** Inside the parentheses (empty span when there are none). */ + readonly args: Span + /** True when a send is called on it directly: `new Resend(k).emails.send(…)`. */ + readonly inline: boolean +} + +/** A `<receiver>.emails.send(…)` or `<receiver>.batch.send(…)`. */ +export type CallLocation = { + readonly receiver: string + readonly method: 'emails.send' | 'batch.send' + readonly line: number + readonly column: number + /** The first argument's source span. */ + readonly args: Span + /** The receiver is a `new Resend(…)` expression. */ + readonly inlineClient: boolean +} + +export type FileLocations = { + readonly imports: readonly ResendImport[] + readonly localImports: readonly LocalImport[] + readonly clients: readonly ClientSite[] + readonly calls: readonly CallLocation[] + /** Names this file exports, as `local → exported`. */ + readonly exports: ReadonlyArray<{ + readonly local: string + readonly exported: string + }> + /** Where a new import statement goes: after the last import (0 = top). */ + readonly importInsertAt: number + readonly moduleSyntax: 'esm' | 'cjs' + /** Relative imports in this file spell a `.js` extension (NodeNext). */ + readonly relativeImportsUseJs: boolean +} + +export type ScanEngine = 'typescript' | 'tokenizer' diff --git a/src/lib/migrate/import-setup.ts b/src/lib/migrate/import-setup.ts new file mode 100644 index 00000000..f678d574 --- /dev/null +++ b/src/lib/migrate/import-setup.ts @@ -0,0 +1,213 @@ +import type { + EmailImportInput, + EmailImportResponse, + UpsertAppEmailInput, +} from '@brew.new/sdk' +import { asSdkInput } from '../input' +import type { MigrationItem } from './manifest' +import { + addWarning, + errorOf, + idempotencyKey, + type RunContext, + rethrowIfFatal, + say, +} from './run' + +/** + * One migrated email: import the source (`POST /v1/emails/import`, with the + * migration modes), then set up the app email by key from what the import + * produced (`PUT /v1/app-emails/{key}`), then delete the intermediate + * import so the brand keeps one design per key. + * + * The import modes (`props`, `placeholders`, `variables`) and the response's + * `variables` come from brew-v2#2169 (in the vendored spec, not yet in the + * SDK's types), so the body and the response are typed here and passed + * through the SDK, which sends a body verbatim. + */ + +export type ImportModeFields = { + readonly props?: 'fold' | 'merge_tags' + readonly placeholders?: 'resend' + readonly variables?: ReadonlyArray<{ + readonly key: string + readonly fallbackValue?: string + }> +} + +export type ImportBody = { + readonly format: 'html' | 'jsx' + readonly content: string + readonly title?: string + readonly subjectLine?: string +} & ImportModeFields + +export type ImportResponse = Omit<EmailImportResponse, 'assetReport'> & { + readonly variables?: ReadonlyArray<{ + readonly path: string + readonly fallback?: string + }> + readonly assetReport?: { + readonly warnings?: ReadonlyArray<{ + readonly code: string + readonly location?: string + readonly host?: string + }> + } +} + +const WARNING_MESSAGES: Readonly<Record<string, string>> = { + dynamic_prop_folded: + 'A prop is used in a way a merge tag cannot express (arithmetic, a style, a call), so it was folded to its preview or default value.', + dynamic_control_flow_unsupported: + 'A .map, .filter or condition over a prop cannot become Liquid, so it was folded to its preview value.', + escaping_changed: + 'Resend printed {{{VAR}}} values raw; Brew escapes merge values. Add `| raw` to a merge tag whose value is HTML.', + placeholder_unmapped: + 'A Resend placeholder with no Brew equivalent (RESEND_UNSUBSCRIBE_URL outside an href) was left as written.', + tailwind_config_unresolved: + 'The Tailwind config is imported from another file; the email-safe pixel preset was used instead.', + tailwind_class_dropped: + 'Some Tailwind classes have no inline style (unknown theme values, hover or breakpoint variants) and were dropped.', + foreign_component_dropped: + 'A component imported from another file was left out of the design.', +} + +export function importWarningMessage(code: string, host?: string): string { + return ( + WARNING_MESSAGES[code] ?? + `Import notice ${code}${host === undefined ? '' : ` (${host})`}.` + ) +} + +/** One warning per import notice code on the item. */ +export function recordImportWarnings( + item: MigrationItem, + imported: ImportResponse +): void { + const seen = new Set<string>() + for (const warning of imported.assetReport?.warnings ?? []) { + if (seen.has(warning.code)) { + continue + } + seen.add(warning.code) + addWarning(item, { + code: warning.code, + message: importWarningMessage(warning.code, warning.host), + }) + } +} + +export function looksLikeMissingImportModes(error: { + code: string + message: string +}): boolean { + return ( + error.code === 'INVALID_REQUEST' && + /placeholders|props|variables|unrecognized/i.test(error.message) + ) +} + +export async function importAndSetUp( + run: RunContext, + item: MigrationItem, + input: { + readonly body: ImportBody + readonly publish: boolean + readonly subject?: string + readonly from?: { readonly localPart?: string; readonly name?: string } + } +): Promise<void> { + const brew = run.brew + if (brew === undefined || item.key === undefined) { + return + } + const key = item.key + if (run.existingKeys === null) { + // The key was never checked: it could be another Brew email's. + item.status = 'failed' + item.error = { + code: 'BREW_APP_EMAILS_UNREADABLE', + message: `Brew app emails could not be listed, so ${key} was not checked against them; run again once they can be.`, + } + run.persist() + return + } + let importedId: string | undefined + try { + say(run, `Importing ${item.name} as app email ${key}…`) + const imported = (await brew.emails.import( + asSdkInput<EmailImportInput>(input.body), + { idempotencyKey: idempotencyKey(run, `import-${key}`, input.body) } + )) as unknown as ImportResponse + importedId = imported.emailId + item.warnings = [] + recordImportWarnings(item, imported) + const paths = (imported.variables ?? []).map((variable) => variable.path) + const subject = imported.subjectLine ?? input.subject + // Recorded first: if the answer is lost, Brew may have the email by + // now, and a re-run must finish it on this key, not take another. + item.brew = { ...item.brew, setUpKey: key } + run.persist() + const setup = await brew.appEmails.upsert( + asSdkInput<UpsertAppEmailInput>({ + triggerKey: key, + source: { format: 'html', content: imported.html }, + ...(input.body.title === undefined + ? {} + : { title: input.body.title.slice(0, 120) }), + ...(subject === undefined ? {} : { subject: subject.slice(0, 250) }), + ...(input.from === undefined ? {} : { from: input.from }), + publish: input.publish, + }) + ) + for (const warning of setup.warnings) { + addWarning(item, { code: warning.code, message: warning.message }) + } + item.status = 'done' + delete item.error + item.brew = { + triggerEventId: setup.triggerEventId, + automationId: setup.automationId, + emailId: setup.emailId, + published: setup.published, + } + item.detail = `app email ${key} ${setup.published ? 'published' : 'set up (not published)'}, sender ${setup.sender.mode}${paths.length === 0 ? '' : `; reads ${paths.join(', ')}`}` + } catch (error) { + rethrowIfFatal(run.ctx, error) + const failure = errorOf(run, error) + if (failure.code === 'APP_EMAIL_EDITED_IN_BREW') { + item.status = 'skipped' + item.detail = `${key} was edited in Brew since it was set up; the Brew version is kept` + } else if (failure.code === 'APP_EMAIL_TRIGGER_IN_USE') { + // Another published automation already sends on this key's trigger: + // a person picks which one stays, then a re-run sets it up. + item.status = 'manual' + item.error = failure + item.detail = failure.message + } else { + item.status = 'failed' + item.error = failure + if (looksLikeMissingImportModes(failure)) { + addWarning(item, { + code: 'IMPORT_MODES_UNAVAILABLE', + message: + "Brew's import does not take the migration modes yet (props, placeholders, variables); try again once it does.", + }) + } + } + } finally { + if (importedId !== undefined) { + try { + await brew.emails.delete({ emailId: importedId }) + } catch (error) { + rethrowIfFatal(run.ctx, error) + addWarning(item, { + code: 'IMPORT_DESIGN_LEFT', + message: `The intermediate import ${importedId} could not be deleted; delete it in Brew.`, + }) + } + } + run.persist() + } +} diff --git a/src/lib/migrate/keys.ts b/src/lib/migrate/keys.ts new file mode 100644 index 00000000..1f4c739e --- /dev/null +++ b/src/lib/migrate/keys.ts @@ -0,0 +1,124 @@ +import type { AppEmailKitKey } from '@brew.new/sdk' +import { TRIGGER_KEY_PATTERN } from '../project/state' + +/** + * Trigger keys for migrated emails. A Resend template alias (or name) and a + * React Email file name become a key in the shared grammar + * (`^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$`, unique per brand), clear of + * the reserved ones: `kit`, `by-key`, `brew.*`, and `auth.*` other than the + * canonical kit keys. + */ + +export const CANONICAL_KIT_KEYS = [ + 'auth.verify-email', + 'auth.magic-link', + 'auth.otp', + 'auth.password-reset', + 'auth.invite', + 'auth.email-change', + 'auth.reauthentication', + 'auth.security-notice', + 'lifecycle.welcome', + 'lifecycle.trial-ending', + 'lifecycle.payment-failed', + 'lifecycle.win-back', +] as const satisfies readonly AppEmailKitKey[] + +const CANONICAL = new Set<string>(CANONICAL_KIT_KEYS) +const MAX_KEY_LENGTH = 64 +const PREFIX_PATTERN = /^[a-z0-9][a-z0-9._-]{0,31}$/ + +export function isCanonicalKitKey(key: string): boolean { + return CANONICAL.has(key) +} + +/** Why a `--key-prefix` cannot be used, or `undefined` when it can. */ +export function keyPrefixProblem(prefix: string): string | undefined { + if (!PREFIX_PATTERN.test(prefix)) { + return 'it must start with a lowercase letter or digit and use only a-z, 0-9, ".", "_" and "-" (32 characters at most)' + } + if (prefix.startsWith('brew.') || prefix === 'brew') { + return '`brew.` keys are reserved' + } + if (prefix.startsWith('auth.') || prefix === 'auth') { + return '`auth.` keys are reserved for the starter kit' + } + return undefined +} + +/** `Reset Password!` → `reset-password`; empty when nothing usable is left. */ +export function slugifyKey(raw: string): string { + const slug = raw + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/-{2,}/g, '-') + .replace(/\.{2,}/g, '.') + return trimEnds(slug) +} + +/** `OrderShipped` → `order-shipped`, for React Email file names. */ +export function slugifyFileName(name: string): string { + return slugifyKey( + name + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') + ) +} + +function trimEnds(value: string): string { + return value.replace(/^[^a-z0-9]+/, '').replace(/[^a-z0-9]+$/, '') +} + +function fitKey(value: string, room: number): string { + return trimEnds(value.slice(0, Math.max(1, room))) +} + +/** A key clear of the reserved names (the prefix is already checked). */ +function clearOfReserved(key: string): string { + if (isCanonicalKitKey(key)) { + return key + } + if (key.startsWith('auth.')) { + return `auth-${key.slice('auth.'.length)}` + } + if (key.startsWith('brew.')) { + return `brew-${key.slice('brew.'.length)}` + } + if (key === 'kit' || key === 'by-key' || key === 'brew' || key === 'auth') { + return `${key}-email` + } + return key +} + +/** + * The key for `base` (an alias, a name, a file name) under `prefix`, + * unique against `taken` (which it joins). `fallback` names a source with + * nothing usable in it (`template`, `email`). + */ +export function deriveKey(input: { + readonly base: string + readonly prefix: string + readonly taken: Set<string> + readonly fallback: string +}): string { + const slug = slugifyKey(input.base) || input.fallback + let key = clearOfReserved( + fitKey(`${input.prefix}${slug}`, MAX_KEY_LENGTH) || input.fallback + ) + if (!TRIGGER_KEY_PATTERN.test(key)) { + key = input.fallback + } + let candidate = key + for (let n = 2; input.taken.has(candidate); n += 1) { + const suffix = `-${n}` + // A suffixed kit key (`auth.password-reset-2`) is no kit key: Brew + // refuses it as reserved, so it leaves auth.* as any other auth. key. + candidate = clearOfReserved( + `${fitKey(key, MAX_KEY_LENGTH - suffix.length)}${suffix}` + ) + } + input.taken.add(candidate) + return candidate +} diff --git a/src/lib/migrate/manifest.ts b/src/lib/migrate/manifest.ts new file mode 100644 index 00000000..032df9fe --- /dev/null +++ b/src/lib/migrate/manifest.ts @@ -0,0 +1,247 @@ +import { createHash, randomUUID } from 'node:crypto' +import { dirname } from 'node:path' +import { readTextFile, stripBom, writeFileAtomic } from '../project/fs-writer' + +/** + * `brew.migration.json`: what a migration planned and did, item by item, + * with the Brew ids it created and the cursors that let an interrupted + * apply resume. It is safe to commit and to show an agent: it never holds + * a contact's address or name, a key, or a token. + */ + +export const MIGRATION_MANIFEST_FILE = 'brew.migration.json' +export const MIGRATION_SCHEMA_VERSION = 1 + +/** Phases in the order a migration runs them. */ +export const PHASES = [ + 'domains', + 'templates', + 'code', + 'contacts', + 'segments', + 'suppressions', + 'topics', + 'broadcasts', + 'webhooks', + 'automations', +] as const + +export type PhaseId = (typeof PHASES)[number] + +export type ItemStatus = 'planned' | 'done' | 'failed' | 'skipped' | 'manual' + +export type PhaseStatus = ItemStatus | 'partial' + +export type ItemKind = + | 'domain' + | 'template' + | 'react-email' + | 'rewrite' + | 'segment' + | 'topic' + | 'broadcast' + | 'webhook' + | 'automation' + +export type MigrationWarning = { + readonly code: string + readonly message: string +} + +export type MigrationItem = { + /** Stable across runs: `template:<resendId>`, `react-email:<file>`, … */ + readonly id: string + readonly kind: ItemKind + status: ItemStatus + readonly name: string + readonly sourceId?: string + readonly file?: string + readonly alias?: string + key?: string + detail?: string + brew?: { + triggerEventId?: string + automationId?: string + emailId?: string + published?: boolean + audienceId?: string + fieldName?: string + audienceName?: string + /** The key a setup was sent for: Brew may have it even with no answer. */ + setUpKey?: string + } + warnings?: MigrationWarning[] + error?: { code: string; message: string } + /** Resume cursor for an item read page by page (a segment's members). */ + cursor?: string | null + counts?: Record<string, number> + /** Passes that ended with rows Brew refused: the next one sends new keys. */ + retry?: number +} + +export type PhaseState = { + status: PhaseStatus + detail?: string + items: MigrationItem[] + /** Resume cursor of a phase read page by page (contacts, suppressions). */ + cursor?: string | null + counts?: Record<string, number> + /** Passes that ended with rows Brew refused: the next one sends new keys. */ + retry?: number + warnings?: MigrationWarning[] + error?: { code: string; message: string } +} + +export type CallSiteRecord = { + readonly file: string + readonly line: number + readonly column: number + readonly method: 'emails.send' | 'batch.send' + readonly kind: string + /** The client the call goes through (`src/lib/resend.ts#resend`). */ + readonly client: string | null + readonly templateIds: readonly string[] + key: string | null + readonly component: string | null + /** Fields that do not carry over (`attachments`, `cc`, raw `html`, …). */ + readonly unsupported: readonly string[] + replacement: string + status: ItemStatus + note?: string +} + +export type MigrationManifest = { + readonly schemaVersion: 1 + readonly source: 'resend' + readonly migrationId: string + readonly createdAt: string + updatedAt: string + brandId: string | null + /** A salted hash of the Resend key the account was read with (never the key). */ + sourceKeyHash?: string + options: { keyPrefix: string; domain: string | null } + phases: Partial<Record<PhaseId, PhaseState>> + callSites: CallSiteRecord[] + /** Files the migration wrote, with the sha256 it last wrote (the map, rewritten sources). */ + files: Record<string, { sha256: string }> +} + +export class MigrationManifestError extends Error {} + +export function createManifest(input: { + readonly now: string + readonly keyPrefix: string +}): MigrationManifest { + return { + schemaVersion: MIGRATION_SCHEMA_VERSION, + source: 'resend', + migrationId: randomUUID(), + createdAt: input.now, + updatedAt: input.now, + brandId: null, + options: { keyPrefix: input.keyPrefix, domain: null }, + phases: {}, + callSites: [], + files: {}, + } +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** The manifest at `path`, or null when there is none. Throws on a broken one. */ +export function readManifest(path: string): MigrationManifest | null { + const text = readTextFile(path) + if (text === null) { + return null + } + let parsed: unknown + try { + parsed = JSON.parse(stripBom(text)) + } catch { + throw new MigrationManifestError(`${path} is not valid JSON`) + } + if (!isRecord(parsed)) { + throw new MigrationManifestError(`${path} is not a JSON object`) + } + if (parsed.schemaVersion !== MIGRATION_SCHEMA_VERSION) { + throw new MigrationManifestError( + `${path} has schemaVersion ${String(parsed.schemaVersion)}; this CLI reads ${MIGRATION_SCHEMA_VERSION}` + ) + } + if (parsed.source !== 'resend') { + throw new MigrationManifestError( + `${path} records a migration from ${String(parsed.source)}, not Resend` + ) + } + if ( + typeof parsed.migrationId !== 'string' || + !isRecord(parsed.phases) || + !Array.isArray(parsed.callSites) || + !isRecord(parsed.options) + ) { + throw new MigrationManifestError(`${path} is missing required fields`) + } + for (const [phase, state] of Object.entries(parsed.phases)) { + if ( + !(PHASES as readonly string[]).includes(phase) || + !isRecord(state) || + !Array.isArray(state.items) + ) { + throw new MigrationManifestError( + `${path} has an invalid phase "${phase}"` + ) + } + } + const manifest = parsed as unknown as MigrationManifest + return { + ...manifest, + files: isRecord(parsed.files) ? manifest.files : {}, + options: { + keyPrefix: + typeof parsed.options.keyPrefix === 'string' + ? parsed.options.keyPrefix + : '', + domain: + typeof parsed.options.domain === 'string' + ? parsed.options.domain + : null, + }, + } +} + +/** + * Binds the manifest to the Resend key it reads with. Contacts and + * suppressions keep one cursor and one done flag for the whole account, so + * under another key (another account, or the same account's new key) both + * passes run again, which their upserts and imports make safe to repeat. + * Items keyed by Resend ids (templates, segments, topics) need nothing. + */ +export function bindSourceKey( + manifest: MigrationManifest, + apiKey: string +): void { + const hash = createHash('sha256') + .update(`${manifest.migrationId}\n${apiKey}`) + .digest('hex') + .slice(0, 16) + if (manifest.sourceKeyHash !== undefined && manifest.sourceKeyHash !== hash) { + for (const id of ['contacts', 'suppressions'] as const) { + const phase = manifest.phases[id] + if (phase !== undefined) { + phase.status = 'planned' + phase.cursor = null + } + } + } + manifest.sourceKeyHash = hash +} + +export function serializeManifest(manifest: MigrationManifest): string { + return `${JSON.stringify(manifest, null, 2)}\n` +} + +export function writeManifest(path: string, manifest: MigrationManifest): void { + writeFileAtomic(path, serializeManifest(manifest), { within: dirname(path) }) +} diff --git a/src/lib/migrate/phase-code.ts b/src/lib/migrate/phase-code.ts new file mode 100644 index 00000000..58a2b691 --- /dev/null +++ b/src/lib/migrate/phase-code.ts @@ -0,0 +1,655 @@ +import { readFileSync } from 'node:fs' +import { join, relative, sep } from 'node:path' +import { SDK_PACKAGE, SDK_RANGE } from '../init/templates' +import { ProjectWriter, sha256Hex } from '../project/fs-writer' +import type { MessageAnalysis } from './code/analyze' +import { + absoluteIn, + decideRewrites, + MAP_EXPORT, + mapFileContent, + mapFilePath, + rewriteFile, + sendBlocker, +} from './code/rewrite' +import { + type CodeScan, + hasBrewClientFile, + reactEmailBaseName, + resolveProjectModule, + type ScannedCallSite, +} from './code/scan' +import { type Token, tokenize } from './code/tokenize' +import { importAndSetUp } from './import-setup' +import { deriveKey, isCanonicalKitKey, slugifyFileName } from './keys' +import type { CallSiteRecord, MigrationItem } from './manifest' +import { + addNextStep, + clean, + keepsKey, + phaseState, + pruneItems, + type RunContext, + reconcileItem, + say, + settlePhase, +} from './run' + +/** + * The code phase: find every Resend send in the project, say what each + * becomes in Brew, import React Email templates as app emails, and (with + * `--rewrite-imports`) move the files whose sends are all template sends to + * `@brew.new/sdk/resend`. Every other send is an agent next step with a + * suggested `brew.send(key, payload)` replacement. + */ + +export type CodePhaseResult = { + readonly engine: CodeScan['engine'] + readonly filesScanned: number +} + +function toPosix(path: string): string { + return path.split(sep).join('/') +} + +/** `{ a: 1, b }` → `a: 1, b`; anything else spreads. */ +function payloadEntries(source: string | null): string[] { + if (source === null || source === '') { + return [] + } + const trimmed = source.trim() + if (trimmed.startsWith('{') && trimmed.endsWith('}')) { + const inner = trimmed.slice(1, -1).trim().replace(/,\s*$/, '') + return inner === '' ? [] : [inner.replace(/\s*\n\s*/g, ' ')] + } + return [`...${trimmed}`] +} + +/** The recipient expression: a one-element array is unwrapped. */ +function recipient(to: string | null): string { + if (to === null) { + return '/* recipient email */' + } + const single = /^\[\s*([^,[\]]+?)\s*,?\s*\]$/.exec(to.trim()) + return (single?.[1] ?? to).replace(/\s*\n\s*/g, ' ') +} + +function oneLine(text: string): string { + return text.replace(/\s*\n\s*/g, ' ') +} + +/** + * Source text with every string and template literal replaced by `'…'`: + * a literal address or name in the code never reaches the manifest. + */ +function maskLiterals(source: string): string { + let tokens: readonly Token[] + try { + tokens = tokenize(source, { jsx: false }) + } catch { + return "'…'" + } + let out = source + for (const token of [...tokens].reverse()) { + if (token.type === 'string' || token.type === 'template') { + out = `${out.slice(0, token.start)}'…'${out.slice(token.end)}` + } + } + return out +} + +function sendLine( + client: string, + key: string, + message: MessageAnalysis, + extra: readonly string[] +): string { + const fields = [`email: ${recipient(message.to)}`, ...extra.map(oneLine)] + return `await ${client}.send('${key}', { ${fields.map(maskLiterals).join(', ')} })` +} + +type KeyIndex = { + readonly templates: ReadonlyMap<string, MigrationItem> + readonly reactEmail: ReadonlyMap<string, MigrationItem> +} + +function keyIndex(run: RunContext): KeyIndex { + const templates = new Map<string, MigrationItem>() + for (const item of run.manifest.phases.templates?.items ?? []) { + if (item.key === undefined) { + continue + } + if (item.sourceId !== undefined) { + templates.set(item.sourceId, item) + } + if (item.alias !== undefined) { + templates.set(item.alias, item) + } + } + const reactEmail = new Map<string, MigrationItem>() + for (const item of run.manifest.phases.code?.items ?? []) { + if (item.kind === 'react-email' && item.file !== undefined) { + reactEmail.set(item.file, item) + } + } + return { templates, reactEmail } +} + +/** The project file `name` (`Component`, `resend`) in `file` is imported from. */ +function importedFile( + run: RunContext, + scan: CodeScan, + file: string, + name: string +): string | null { + const scanned = scan.files.get(file) + const imported = scanned?.locations.localImports.find( + (entry) => entry.local === name + ) + if (imported === undefined) { + return null + } + const resolved = resolveProjectModule( + run.project.root, + join(run.project.root, file), + imported.from + ) + return resolved === null + ? null + : toPosix(relative(run.project.root, resolved)) +} + +function callSiteRecord( + run: RunContext, + scan: CodeScan, + site: ScannedCallSite, + index: KeyIndex, + client: string +): CallSiteRecord { + const { analysis } = site + const first = analysis.messages[0] as MessageAnalysis + const templateIds = analysis.messages.flatMap((message) => + message.templateId === null ? [] : [message.templateId] + ) + const unsupported = [ + ...new Set(analysis.messages.flatMap((message) => message.unsupported)), + ] + let key: string | null = null + let component: string | null = null + let replacement: string + let note: string | undefined + switch (analysis.kind) { + case 'template': { + const item = + first.templateId === null + ? undefined + : index.templates.get(first.templateId) + key = item?.key ?? null + if (key === null) { + note = + first.templateId === null + ? 'the template id is computed; map each id to its Brew key' + : `template ${first.templateId} is not in the migration` + } + replacement = sendLine( + client, + key ?? '<brew-key>', + first, + payloadEntries(first.variables) + ) + break + } + case 'react': { + component = first.component?.name ?? null + const file = + component === null + ? null + : importedFile(run, scan, site.file, component.split('.')[0] ?? '') + const item = file === null ? undefined : index.reactEmail.get(file) + key = item?.key ?? null + if (key === null) { + note = + component === null + ? 'the React element is built elsewhere; import its template as an app email' + : `<${component}> is not a React Email file the migration imports` + } else if (item?.status === 'manual') { + note = `${item.file} is migrated by hand (${item.detail ?? 'see the code phase'})` + } + const props = [ + ...(first.component?.props ?? []).map( + (prop) => `${prop.name}: ${prop.value}` + ), + ...(first.component?.spreads ?? []).map((spread) => `...${spread}`), + ] + replacement = sendLine(client, key ?? '<brew-key>', first, props) + break + } + case 'html': + case 'text': + note = `import the ${analysis.kind === 'html' ? 'HTML' : 'text'} as an app email first: ${run.prefix} app-emails upsert <brew-key> --source-file <file> --yes` + replacement = sendLine(client, '<brew-key>', first, []) + break + default: + note = + 'the message is built elsewhere: read it and pick the Brew key it sends' + replacement = sendLine(client, '<brew-key>', first, []) + } + if (site.method === 'batch.send' && analysis.messages.length !== 1) { + replacement = `send each message on its own: ${replacement}` + } + return { + file: site.file, + line: site.line, + column: site.column, + method: site.method, + kind: analysis.kind, + client: site.clientId, + templateIds, + key, + component, + unsupported, + replacement: clean(run, replacement), + status: 'manual', + ...(note === undefined ? {} : { note: clean(run, note) }), + } +} + +export async function codePhase(run: RunContext): Promise<CodePhaseResult> { + const phase = phaseState(run, 'code') + delete phase.error + const { root } = run.project + say(run, `Scanning ${root} for Resend sends…`) + const scan = run.source.scanCode(root) + say( + run, + `Scanned ${scan.filesScanned} files (${scan.engine}): ${scan.callSites.length} sends, ${scan.reactEmail.length} React Email templates` + ) + if (scan.truncated) { + run.findings.push({ + code: 'CODE_SCAN_TRUNCATED', + severity: 'warning', + message: + 'The scan skipped source files (more than it reads, or one too big to parse that mentions Resend), so no import is rewritten; run it from the app directory (--cwd).', + }) + } + + // ---- React Email templates → app emails (`props: 'merge_tags'`). + const taken = new Set<string>() + const ours = new Set<string>() + for (const item of [ + ...(run.manifest.phases.templates?.items ?? []), + ...phase.items, + ]) { + if (keepsKey(run, item)) { + taken.add(item.key) + ours.add(item.key) + } + } + for (const key of run.existingKeys ?? []) { + if (!ours.has(key) && !isCanonicalKitKey(key)) { + taken.add(key) + } + } + const keep = new Set<string>() + for (const file of scan.reactEmail) { + const id = `react-email:${file.file}` + keep.add(id) + const previous = phase.items.find((item) => item.id === id) + const key = keepsKey(run, previous) + ? previous.key + : deriveKey({ + base: slugifyFileName(reactEmailBaseName(file.file)), + prefix: run.options.keyPrefix, + taken, + fallback: 'email', + }) + const item = reconcileItem(phase, { + id, + kind: 'react-email', + status: 'planned', + name: file.file, + file: file.file, + key, + }) + if (item.status === 'done') { + continue + } + item.key = key + delete item.error + item.warnings = [] + if (file.manualReasons.length > 0) { + item.status = 'manual' + item.detail = `${file.manualReasons.join('; ')}: a static import cannot follow that, so move it by hand (inline what it needs, then ${run.prefix} app-emails upsert ${key} --source-file ${file.file} --input '{"source":{"props":"merge_tags"}}' --yes)` + } else { + item.status = 'planned' + item.detail = `→ app email ${key}; props become {{ trigger.* }} merge tags` + } + } + + const index = keyIndex(run) + const client = hasBrewClientFile(root, run.project.libDir) + ? 'getBrew()' + : 'brew' + const records = scan.callSites.map((site) => + callSiteRecord(run, scan, site, index, client) + ) + run.manifest.callSites = records + run.persist() + + if (run.apply) { + for (const item of phase.items) { + if ( + item.kind !== 'react-email' || + item.file === undefined || + (item.status !== 'planned' && item.status !== 'failed') + ) { + continue + } + run.ctx.signal.throwIfAborted() + let content: string + try { + content = readFileSync(absoluteIn(root, item.file), 'utf8') + } catch { + item.status = 'failed' + item.error = { + code: 'FILE_UNREADABLE', + message: `${item.file} could not be read`, + } + continue + } + await importAndSetUp(run, item, { + body: { + format: 'jsx', + content, + title: reactEmailBaseName(item.file), + props: 'merge_tags', + }, + publish: true, + }) + } + // React call sites pick up the keys their files now have. + const refreshed = keyIndex(run) + for (const [position, site] of scan.callSites.entries()) { + records[position] = callSiteRecord(run, scan, site, refreshed, client) + } + } + + // ---- Imports: which files can move to the Brew client as they are. + const templateItems = run.manifest.phases.templates?.items ?? [] + const migrated = templateItems.filter((item) => + run.apply + ? item.status === 'done' + : item.status === 'done' || item.status === 'planned' + ) + const knownTemplates = new Set( + migrated.flatMap((item) => [ + ...(item.sourceId === undefined ? [] : [item.sourceId]), + ...(item.alias === undefined ? [] : [item.alias]), + ]) + ) + const allMigrated = + templateItems.length > 0 && migrated.length === templateItems.length + const decisions = decideRewrites(scan, knownTemplates, allMigrated) + for (const decision of decisions) { + const id = `rewrite:${decision.file}` + keep.add(id) + const item = reconcileItem(phase, { + id, + kind: 'rewrite', + status: 'planned', + name: decision.file, + file: decision.file, + }) + if (item.status === 'done') { + continue + } + if (!decision.rewritable) { + item.status = 'manual' + item.detail = clean(run, `stays on resend: ${decision.reason}`) + } else if (!run.options.rewriteImports) { + item.status = 'skipped' + item.detail = `every send is a migrated template: --rewrite-imports moves it to @brew.new/sdk/resend` + } else { + item.status = 'planned' + item.detail = `import from @brew.new/sdk/resend, clients use BREW_API_KEY and ${MAP_EXPORT}` + } + } + pruneItems(phase, keep) + + const rewritable = phase.items.filter( + (item) => item.kind === 'rewrite' && item.status === 'planned' + ) + const rewritten = new Set( + phase.items + .filter((item) => item.kind === 'rewrite' && item.status === 'done') + .map((item) => item.file) + ) + // The map follows the migrated templates even when no new file moves. + if ( + run.apply && + run.options.rewriteImports && + (rewritable.length > 0 || rewritten.size > 0) + ) { + rewriteImports(run, scan, rewritable, templateItems, rewritten) + } + + // Call sites in files now on the Brew client are done. A re-run no longer + // sees a moved client as Resend's, so a send through one it imports is + // done when the Brew client can make it. + const doneClients = new Set( + scan.clients + .filter((entry) => rewritten.has(entry.file)) + .map((entry) => entry.id) + ) + for (const [position, record] of records.entries()) { + const site = scan.callSites[position] as ScannedCallSite + const through = + record.client === null + ? importedFile( + run, + scan, + site.file, + site.receiver.split(/\??\./)[0] ?? '' + ) + : null + if ( + rewritten.has(record.file) || + (record.client !== null && doneClients.has(record.client)) || + (through !== null && + rewritten.has(through) && + sendBlocker(site, knownTemplates, allMigrated) === null) + ) { + record.status = 'done' + record.replacement = `unchanged: @brew.new/sdk/resend sends template ${record.templateIds.join(', ') || '(computed id)'} by its Brew key` + delete record.note + } + } + run.manifest.callSites = records + callSiteNextSteps(run, records, client) + if (rewritten.size > 0) { + rewriteNextSteps(run) + } + settlePhase(phase, { apply: run.apply }) + phase.counts = { + sends: records.length, + sendsDone: records.filter((record) => record.status === 'done').length, + reactEmailTemplates: scan.reactEmail.length, + } + phase.detail = `${scan.callSites.length} Resend sends in ${new Set(scan.callSites.map((site) => site.file)).size} files` + run.persist() + return { engine: scan.engine, filesScanned: scan.filesScanned } +} + +function rewriteImports( + run: RunContext, + scan: CodeScan, + items: readonly MigrationItem[], + templateItems: readonly MigrationItem[], + rewritten: Set<string | undefined> +): void { + const writer = new ProjectWriter({ root: run.project.root }) + const { libDir, typescript } = run.project + type MapKind = { typescript: boolean; moduleSyntax: 'esm' | 'cjs' } + // A TypeScript sender reads the .ts map; a JavaScript one, which Node may + // run as it is, the .mjs or .cjs map of its own module syntax. + const kindOf = (item: MigrationItem): MapKind => ({ + typescript: typescript && /\.[cm]?tsx?$/.test(item.file ?? ''), + moduleSyntax: + scan.files.get(item.file ?? '')?.locations.moduleSyntax ?? 'esm', + }) + const pathOf = (kind: MapKind) => + mapFilePath(libDir, kind.typescript, kind.moduleSyntax) + // Every map a moved client reads, including one an earlier rewrite wrote, + // follows the templates. + const earlier = ( + [ + { typescript: true, moduleSyntax: 'esm' }, + { typescript: false, moduleSyntax: 'esm' }, + { typescript: false, moduleSyntax: 'cjs' }, + ] satisfies MapKind[] + ).filter((kind) => run.manifest.files[pathOf(kind)] !== undefined) + const maps = new Map<string, MapKind>() + for (const kind of [...items.map(kindOf), ...earlier]) { + maps.set(pathOf(kind), maps.get(pathOf(kind)) ?? kind) + } + const entries = new Map<string, string>() + for (const item of templateItems) { + if (item.status !== 'done' || item.key === undefined) { + continue + } + if (item.sourceId !== undefined) { + entries.set(item.sourceId, item.key) + } + if (item.alias !== undefined) { + entries.set(item.alias, item.key) + } + } + for (const [mapPath, kind] of maps) { + const content = mapFileContent({ + entries: [...entries.entries()], + ...kind, + rerunCommand: `${run.prefix} migrate resend --apply --rewrite-imports`, + }) + const recorded = run.manifest.files[mapPath]?.sha256 + const mapChange = writer.writeFile( + mapPath, + content, + recorded === undefined ? {} : { managedSha256: recorded } + ) + if (mapChange.action === 'refused') { + run.findings.push({ + code: 'MAP_FILE_REFUSED', + severity: 'error', + message: `${mapPath} was not written (${mapChange.note ?? 'unsafe path'}); no imports were rewritten.`, + }) + return + } + if (mapChange.action === 'kept-user-version') { + // A map the migration did not write may miss templates: no client moves to it. + run.findings.push({ + code: 'MAP_FILE_KEPT', + severity: 'error', + message: `${mapPath} was edited since the migration wrote it, so it was kept and no imports were rewritten; undo the edit or delete the file, then run again.`, + }) + return + } + if (mapChange.sha256 !== undefined) { + run.manifest.files[mapPath] = { sha256: mapChange.sha256 } + } + } + for (const item of items) { + const file = item.file ?? '' + const scanned = scan.files.get(file) + if (scanned === undefined) { + continue + } + const mapPath = pathOf(kindOf(item)) + const next = rewriteFile({ file, scanned, mapFile: mapPath }) + // Replaced only while it still holds the text the scan read: an edit + // made while the imports ran is kept, and a re-run scans it again. + const change = writer.writeFile(file, next, { + managedSha256: sha256Hex(scanned.text), + }) + if (change.action === 'kept-user-version') { + item.status = 'failed' + item.error = { + code: 'FILE_CHANGED', + message: `${file} changed while the migration ran, so it was left as is; run the migration again to rewrite it`, + } + continue + } + if (change.action === 'refused') { + item.status = 'failed' + item.error = { + code: 'WRITE_REFUSED', + message: change.note ?? 'unsafe path', + } + continue + } + item.status = 'done' + item.detail = `now imports @brew.new/sdk/resend and ${mapPath}` + if (change.sha256 !== undefined) { + run.manifest.files[file] = { sha256: change.sha256 } + } + rewritten.add(file) + } + run.persist() +} + +function callSiteNextSteps( + run: RunContext, + records: readonly CallSiteRecord[], + client: string +): void { + const open = records.filter((record) => record.status !== 'done') + if (open.length > 0 && client === 'brew') { + addNextStep(run, { + id: 'set-up-brew-client', + actor: 'agent', + kind: 'run-command', + command: `${run.prefix} init --yes`, + title: + 'Set up the Brew client (lib/brew.ts with getBrew()) and BREW_API_KEY before replacing the Resend sends', + blocking: true, + }) + } + for (const [position, record] of open.entries()) { + const notes = [ + ...(record.unsupported.length === 0 + ? [] + : [ + `${record.unsupported.join(', ')} do not carry over: drop them or rework the email`, + ]), + ...(record.note === undefined ? [] : [record.note]), + ] + addNextStep(run, { + id: `replace-send-${position + 1}`, + actor: 'agent', + kind: 'edit-file', + path: record.file, + title: `Replace the Resend ${record.method} at ${record.file}:${record.line} with ${record.replacement}${notes.length === 0 ? '' : ` (${notes.join('; ')})`}`, + blocking: true, + }) + } +} + +function rewriteNextSteps(run: RunContext): void { + if (!run.project.hasSdk) { + addNextStep(run, { + id: 'install-sdk', + actor: 'agent', + kind: 'run-command', + command: run.project.addSdkCommand ?? 'npm install @brew.new/sdk', + title: `Install ${SDK_PACKAGE}@${SDK_RANGE}: the rewritten files import ${SDK_PACKAGE}/resend`, + blocking: true, + }) + } + addNextStep(run, { + id: 'set-brew-api-key', + actor: 'human', + kind: 'set-secret', + title: + 'Set BREW_API_KEY wherever the app runs (local env, hosting, CI); the rewritten clients read it instead of RESEND_API_KEY', + blocking: true, + }) +} diff --git a/src/lib/migrate/phases-contacts.ts b/src/lib/migrate/phases-contacts.ts new file mode 100644 index 00000000..7af8121e --- /dev/null +++ b/src/lib/migrate/phases-contacts.ts @@ -0,0 +1,714 @@ +import type { UpsertManyContactRow } from '@brew.new/sdk' +import { slugifyKey } from './keys' +import type { MigrationItem } from './manifest' +import { brewOf, inventoryOf } from './phases' +import { + addNextStep, + addWarning, + errorOf, + idempotencyKey, + phaseState, + pruneItems, + type RunContext, + reconcileItem, + rethrowIfFatal, + say, + settlePhase, +} from './run' +import type { SourceContact } from './source' + +/** + * The contact phases: contacts, segments, suppressions and topics, read + * page by page from the source into Brew contacts, audiences and a domain's + * unsubscribe list. Each page persists its cursor, so a re-run resumes. + */ + +/** Pages a plan reads to count contacts or suppressions (100 rows each). */ +const PLAN_COUNT_PAGES = 50 +const IMPORT_CONSENT = { + source: 'import' as const, + evidence: + 'Subscribed in Resend (not unsubscribed) when migrated with brew-cli migrate resend', +} + +/** A contact's state as the contacts phase writes it: consent, or unsubscribed. */ +function subscription(contact: SourceContact) { + return contact.unsubscribed === true + ? { subscribed: false } + : { consent: IMPORT_CONSENT } +} + +/** Rows Brew refused: the next run reads every page again to retry them. */ +/** + * The idempotency scope prefix of a pass: none for the first, `r<n>-` after + * n passes that ended with refused rows. A replayed key would return the + * old refusal for 24 hours; an interrupted pass (outcome unknown) keeps its + * keys, since its cursor is kept. + */ +function pass(target: { retry?: number }): string { + return target.retry === undefined ? '' : `r${target.retry}-` +} + +/** Counts a finished pass that left rows refused. */ +function refusedPass(target: { retry?: number }): void { + target.retry = (target.retry ?? 0) + 1 +} + +function refusedRows(failed: number): { code: string; message: string } { + return { + code: 'CONTACT_ROWS_FAILED', + message: `Brew refused ${failed} contact rows (see the counts); fix those contacts in Resend, then run again to retry them.`, + } +} + +// ---- Contacts. + +function contactRow(contact: SourceContact): UpsertManyContactRow { + const customFields = + Object.keys(contact.properties).length === 0 + ? undefined + : contact.properties + return { + email: contact.email, + ...(contact.firstName === null ? {} : { firstName: contact.firstName }), + ...(contact.lastName === null ? {} : { lastName: contact.lastName }), + ...(customFields === undefined ? {} : { customFields }), + } +} + +function csvOf(emails: readonly string[]): string { + const quote = (value: string): string => + /[",\n\r]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value + return ['email', ...emails.map(quote)].join('\n') +} + +function bump( + state: { counts?: Record<string, number> }, + name: string, + by: number +): void { + const counts = state.counts ?? {} + counts[name] = (counts[name] ?? 0) + by + state.counts = counts +} + +async function countPages<T>( + read: ( + after: string | null + ) => Promise<{ rows: readonly T[]; next: string | null }>, + tally: (row: T) => void +): Promise<{ readonly truncated: boolean }> { + let after: string | null = null + for (let page = 0; page < PLAN_COUNT_PAGES; page += 1) { + const result: { rows: readonly T[]; next: string | null } = + await read(after) + for (const row of result.rows) { + tally(row) + } + if (result.next === null) { + return { truncated: false } + } + after = result.next + } + return { truncated: true } +} + +export async function contactsPhase(run: RunContext): Promise<void> { + const phase = phaseState(run, 'contacts') + delete phase.error + const inventory = inventoryOf(run) + if (!run.apply) { + // An interrupted apply keeps its counts with its cursor: the plan's + // estimates would drop the rows it has not landed yet. + if (phase.status === 'done' || typeof phase.cursor === 'string') { + return + } + const counts: Record<string, number> = {} + const { truncated } = await countPages( + (after) => inventory.contacts(after), + (contact) => { + const bucket = + contact.unsubscribed === null + ? 'noConsentState' + : contact.unsubscribed + ? 'unsubscribed' + : 'subscribed' + counts[bucket] = (counts[bucket] ?? 0) + 1 + } + ) + phase.counts = counts + phase.status = 'planned' + phase.detail = `${truncated ? `counted the first ${PLAN_COUNT_PAGES * 100}; ` : ''}subscribed contacts are upserted with an import consent record; unsubscribed ones stay unsubscribed${run.targetDomain === null ? '' : ` and join ${run.targetDomain.name}'s unsubscribe list`}; contacts without a subscription state are left out` + say( + run, + `Resend: ${Object.values(counts).reduce((a, b) => a + b, 0)}${truncated ? '+' : ''} contacts` + ) + return + } + if (phase.status === 'done') { + return + } + // Opt-outs join the --domain's unsubscribe list: while the Brew domain + // list cannot be read, contacts wait rather than finish without them. + if ( + run.targetDomain === null && + (run.options.domain ?? run.manifest.options.domain) != null + ) { + run.findings.push({ + code: 'CONTACTS_WAIT_FOR_DOMAIN', + severity: 'error', + message: + 'Contacts were not moved: their opt-outs join the --domain unsubscribe list, and the Brew domains could not be read. Run the migration again.', + }) + return + } + const brew = brewOf(run) + let after = phase.cursor ?? null + if (after === null) { + // A fresh apply: the plan's counts are estimates, not progress. + phase.counts = {} + phase.warnings = [] + } + phase.status = 'partial' + do { + run.ctx.signal.throwIfAborted() + const page = await inventory.contacts(after) + const subscribed = page.rows.filter((row) => row.unsubscribed === false) + const unsubscribed = page.rows.filter((row) => row.unsubscribed === true) + const unknown = page.rows.length - subscribed.length - unsubscribed.length + try { + if (subscribed.length > 0) { + const body = { + contacts: subscribed.map(contactRow), + consent: IMPORT_CONSENT, + } + const result = await brew.contacts.upsertMany(body, { + idempotencyKey: idempotencyKey( + run, + `${pass(phase)}contacts-${after ?? 'start'}`, + body + ), + }) + bump( + phase, + 'upserted', + result.summary.inserted + result.summary.updated + ) + bump(phase, 'failed', result.summary.failed) + } + if (unsubscribed.length > 0) { + const body = { + contacts: unsubscribed.map((row) => ({ + ...contactRow(row), + subscribed: false, + })), + } + const result = await brew.contacts.upsertMany(body, { + idempotencyKey: idempotencyKey( + run, + `${pass(phase)}contacts-unsub-${after ?? 'start'}`, + body + ), + }) + bump( + phase, + 'unsubscribed', + result.summary.inserted + result.summary.updated + ) + bump(phase, 'failed', result.summary.failed) + if (run.targetDomain !== null) { + const csv = csvOf(unsubscribed.map((row) => row.email)) + const imported = await brew.domains.unsubscribes.import( + { domainId: run.targetDomain.domainId, csv }, + { + idempotencyKey: idempotencyKey( + run, + `${pass(phase)}contacts-optout-${after ?? 'start'}`, + csv + ), + } + ) + bump( + phase, + 'domainUnsubscribes', + imported.summary.added + imported.summary.created + ) + } + } + } catch (error) { + rethrowIfFatal(run.ctx, error) + phase.error = errorOf(run, error) + phase.status = 'failed' + phase.cursor = after + run.persist() + return + } + bump(phase, 'noConsentState', unknown) + bump(phase, 'read', page.rows.length) + after = page.next + phase.cursor = after + run.persist() + } while (after !== null) + phase.cursor = null + const failed = phase.counts?.failed ?? 0 + if (failed > 0) { + phase.status = 'partial' + phase.error = refusedRows(failed) + refusedPass(phase) + } else { + phase.status = 'done' + } + if ((phase.counts?.noConsentState ?? 0) > 0) { + addWarning(phase, { + code: 'NO_CONSENT_STATE', + message: `${phase.counts?.noConsentState} contacts carried no subscription state in Resend and were left out.`, + }) + } + run.persist() +} + +// ---- Segments → a boolean contact field and an audience per segment. + +/** + * The audience selecting `fieldName = true`: the one named for the item, or + * `<name> (Resend)` when a Brew audience of that name selects something + * else. An existing audience is reused only when it selects exactly that. + */ +async function ensureAudience( + run: RunContext, + item: MigrationItem, + fieldName: string +): Promise<{ audienceId: string; audienceName: string }> { + const brew = brewOf(run) + const name = item.brew?.audienceName ?? item.name + const existing = await brew.audiences.list({ search: name, limit: 100 }) + const rows = existing.data + const selectsField = (row: (typeof rows)[number]): boolean => + row.filters.filters.length === 1 && + row.filters.filters[0]?.field === fieldName && + row.filters.filters[0]?.operator === 'is_true' + let audienceName = name + let match = rows.find((row) => row.audienceName === audienceName) + if (match !== undefined && !selectsField(match)) { + audienceName = `${name} (Resend)` + match = rows.find((row) => row.audienceName === audienceName) + if (match !== undefined && !selectsField(match)) { + throw new Error( + `Brew audiences "${name}" and "${audienceName}" both select other contacts; rename one, then run again.` + ) + } + } + let audienceId = match?.audienceId + if (audienceId === undefined) { + const body = { + name: audienceName, + filters: { + filters: [{ field: fieldName, operator: 'is_true' as const }], + logicalOperator: 'and' as const, + }, + } + const created = await brew.audiences.create(body, { + idempotencyKey: idempotencyKey(run, `audience-${item.id}`, body), + }) + audienceId = (created as { audienceId: string }).audienceId + } + return { audienceId, audienceName } +} + +export function segmentFieldName(name: string, taken: Set<string>): string { + const slug = slugifyKey(name).replace(/[.-]/g, '_').slice(0, 40) || 'segment' + let field = `resend_segment_${slug}` + for (let n = 2; taken.has(field); n += 1) { + field = `resend_segment_${slug}_${n}` + } + taken.add(field) + return field +} + +export async function segmentsPhase(run: RunContext): Promise<void> { + const phase = phaseState(run, 'segments') + delete phase.error + const inventory = inventoryOf(run) + const segments = await inventory.segments() + say(run, `Resend: ${segments.length} segments`) + const taken = new Set( + phase.items.flatMap((item) => + item.brew?.fieldName === undefined ? [] : [item.brew.fieldName] + ) + ) + const keep = new Set<string>() + for (const segment of segments) { + const id = `segment:${segment.id}` + keep.add(id) + const previous = phase.items.find((item) => item.id === id) + const fieldName = + previous?.brew?.fieldName ?? segmentFieldName(segment.name, taken) + const item = reconcileItem(phase, { + id, + kind: 'segment', + status: 'planned', + name: segment.name, + sourceId: segment.id, + brew: { fieldName, audienceName: segment.name }, + }) + if (item.status !== 'done') { + item.detail = `members get ${fieldName} = true; audience "${segment.name}" selects them` + } + } + pruneItems(phase, keep) + if (run.apply) { + const brew = brewOf(run) + for (const item of phase.items) { + if ( + item.status === 'done' || + item.status === 'skipped' || + item.sourceId === undefined + ) { + continue + } + const fieldName = item.brew?.fieldName as string + try { + let after = item.cursor ?? null + if (after === null) { + item.counts = {} + } + do { + run.ctx.signal.throwIfAborted() + const page = await inventory.segmentContacts(item.sourceId, after) + // As in the contacts phase: no subscription state, no Brew contact. + const rows = page.rows.filter((row) => row.unsubscribed !== null) + if (rows.length > 0) { + const body = { + contacts: rows.map((row) => ({ + email: row.email, + customFields: { [fieldName]: true }, + ...subscription(row), + })), + } + const result = await brew.contacts.upsertMany(body, { + idempotencyKey: idempotencyKey( + run, + `${pass(item)}segment-${item.sourceId}-${after ?? 'start'}`, + body + ), + }) + bump( + item, + 'members', + result.summary.inserted + result.summary.updated + ) + bump(item, 'failed', result.summary.failed) + } + after = page.next + item.cursor = after + run.persist() + } while (after !== null) + const failed = item.counts?.failed ?? 0 + if (failed > 0) { + item.status = 'failed' + item.error = refusedRows(failed) + refusedPass(item) + } else { + const { audienceId, audienceName } = await ensureAudience( + run, + item, + fieldName + ) + item.brew = { ...item.brew, audienceId, audienceName } + item.status = 'done' + item.detail = `${item.counts?.members ?? 0} members marked ${fieldName}; audience "${audienceName}"` + delete item.error + } + } catch (error) { + rethrowIfFatal(run.ctx, error) + item.status = 'failed' + item.error = errorOf(run, error) + } + run.persist() + } + } + settlePhase(phase, { apply: run.apply }) + if (segments.length === 0 && phase.items.length === 0) { + phase.detail = 'no segments in Resend' + } +} + +// ---- Suppressions → the chosen Brew domain's unsubscribe list. + +export async function suppressionsPhase(run: RunContext): Promise<void> { + const phase = phaseState(run, 'suppressions') + delete phase.error + const inventory = inventoryOf(run) + if (!run.apply) { + // As for contacts: an interrupted apply keeps its counts and cursor. + if (phase.status === 'done' || typeof phase.cursor === 'string') { + return + } + const counts: Record<string, number> = {} + const { truncated } = await countPages( + (after) => inventory.suppressions(after), + (row) => { + counts[row.origin] = (counts[row.origin] ?? 0) + 1 + } + ) + phase.counts = counts + const total = Object.values(counts).reduce((a, b) => a + b, 0) + phase.status = + total === 0 ? 'skipped' : run.targetDomain === null ? 'manual' : 'planned' + phase.detail = + total === 0 + ? 'no suppressions in Resend' + : run.targetDomain === null + ? 'pass --domain <your Brew domain> to import them into its unsubscribe list' + : `${truncated ? `counted the first ${PLAN_COUNT_PAGES * 100}; ` : ''}imported into ${run.targetDomain.name}'s unsubscribe list` + if (total > 0 && run.targetDomain === null) { + addNextStep(run, { + id: 'suppressions-domain', + actor: 'agent', + kind: 'run-command', + command: `${run.prefix} migrate resend --only suppressions --domain <brew-domain> --apply --yes`, + title: + "Import Resend suppressions into a Brew marketing domain's unsubscribe list (name the domain)", + blocking: false, + }) + } + return + } + if (phase.status === 'done') { + return + } + if (run.targetDomain === null) { + phase.status = 'manual' + phase.detail = 'pass --domain <your Brew domain> to import suppressions' + addNextStep(run, { + id: 'suppressions-domain', + actor: 'agent', + kind: 'run-command', + command: `${run.prefix} migrate resend --only suppressions --domain <brew-domain> --apply --yes`, + title: + "Import Resend suppressions into a Brew marketing domain's unsubscribe list (name the domain)", + blocking: false, + }) + return + } + const brew = brewOf(run) + const domain = run.targetDomain + let after = phase.cursor ?? null + if (after === null) { + phase.counts = {} + } + phase.status = 'partial' + do { + run.ctx.signal.throwIfAborted() + const page = await inventory.suppressions(after) + try { + if (page.rows.length > 0) { + const csv = csvOf(page.rows.map((row) => row.email)) + const result = await brew.domains.unsubscribes.import( + { domainId: domain.domainId, csv }, + { + idempotencyKey: idempotencyKey( + run, + `suppressions-${after ?? 'start'}`, + csv + ), + } + ) + bump(phase, 'added', result.summary.added + result.summary.created) + bump(phase, 'alreadyUnsubscribed', result.summary.alreadyUnsubscribed) + bump(phase, 'skipped', result.summary.skipped) + } + } catch (error) { + rethrowIfFatal(run.ctx, error) + phase.error = errorOf(run, error) + phase.status = 'failed' + phase.cursor = after + run.persist() + return + } + bump(phase, 'read', page.rows.length) + after = page.next + phase.cursor = after + run.persist() + } while (after !== null) + phase.status = 'done' + phase.cursor = null + phase.detail = `imported into ${domain.name}'s unsubscribe list` + run.persist() +} + +// ---- Topics → a boolean contact field (opted in) and an audience per topic. + +export function topicFieldName(name: string, taken: Set<string>): string { + const slug = slugifyKey(name).replace(/[.-]/g, '_').slice(0, 40) || 'topic' + let field = `resend_topic_${slug}` + for (let n = 2; taken.has(field); n += 1) { + field = `resend_topic_${slug}_${n}` + } + taken.add(field) + return field +} + +/** Minutes to read `contacts` choices one request each at `rate` per second. */ +function minutesFor(contacts: number, rate: number): number { + return Math.max(1, Math.ceil(contacts / Math.max(0.1, rate) / 60)) +} + +export async function topicsPhase(run: RunContext): Promise<void> { + const phase = phaseState(run, 'topics') + delete phase.error + const inventory = inventoryOf(run) + const topics = await inventory.topics() + say(run, `Resend: ${topics.length} topics`) + const taken = new Set( + phase.items.flatMap((item) => + item.brew?.fieldName === undefined ? [] : [item.brew.fieldName] + ) + ) + const keep = new Set<string>() + for (const topic of topics) { + const id = `topic:${topic.id}` + keep.add(id) + const previous = phase.items.find((item) => item.id === id) + if (previous === undefined) { + // A topic the contact pass has not read: the pass starts over. + phase.cursor = null + delete phase.counts?.passComplete + } + const fieldName = + previous?.brew?.fieldName ?? topicFieldName(topic.name, taken) + const item = reconcileItem(phase, { + id, + kind: 'topic', + status: 'planned', + name: topic.name, + sourceId: topic.id, + brew: { fieldName, audienceName: `${topic.name} (topic)` }, + }) + if (item.status !== 'done') { + item.detail = `each contact's choice becomes ${fieldName} (true = opted in; Resend default ${topic.defaultSubscription ?? 'unknown'}); audience "${topic.name} (topic)" selects the opted-in` + } + } + pruneItems(phase, keep) + if (topics.length === 0) { + settlePhase(phase, { apply: run.apply }) + phase.detail = 'no topics in Resend' + return + } + const known = run.manifest.phases.contacts?.counts + // The contacts phase's counts: the plan's estimate, or what an apply read. + const contacts = + known === undefined + ? null + : (known.subscribed ?? known.upserted ?? 0) + (known.unsubscribed ?? 0) + const rate = run.source.rateLimit.requestsPerSecond + if (!run.apply) { + settlePhase(phase, { apply: false }) + phase.detail = `reads each contact's topic choices: one ${run.source.displayName} request per contact${contacts === null || contacts === 0 ? '' : ` (about ${minutesFor(contacts, rate)} min for ${contacts} contacts at ${rate}/s)`}; resumable` + return + } + const brew = brewOf(run) + const fieldByTopic = new Map( + phase.items.flatMap((item) => + item.sourceId === undefined || item.brew?.fieldName === undefined + ? [] + : [[item.sourceId, item.brew.fieldName] as const] + ) + ) + // Pass 1: every contact's choices onto its record, page by page. + if (phase.counts?.passComplete !== 1) { + let after = phase.cursor ?? null + if (after === null) { + phase.counts = {} + } + try { + do { + run.ctx.signal.throwIfAborted() + const page = await inventory.contacts(after) + const rows: UpsertManyContactRow[] = [] + for (const contact of page.rows) { + // A contact with no subscription state never reaches Brew. + if (contact.unsubscribed === null) { + continue + } + const choices = await inventory.contactTopics(contact.id) + const customFields: Record<string, boolean> = {} + for (const choice of choices) { + const field = fieldByTopic.get(choice.topicId) + if (field !== undefined) { + customFields[field] = choice.subscribed + } + } + if (Object.keys(customFields).length === 0) { + continue + } + rows.push({ + email: contact.email, + customFields, + ...subscription(contact), + }) + } + if (rows.length > 0) { + const body = { contacts: rows } + const result = await brew.contacts.upsertMany(body, { + idempotencyKey: idempotencyKey( + run, + `${pass(phase)}topics-${after ?? 'start'}`, + body + ), + }) + bump( + phase, + 'contacts', + result.summary.inserted + result.summary.updated + ) + bump(phase, 'failed', result.summary.failed) + } + bump(phase, 'read', page.rows.length) + after = page.next + phase.cursor = after + run.persist() + } while (after !== null) + } catch (error) { + rethrowIfFatal(run.ctx, error) + phase.error = errorOf(run, error) + phase.status = 'failed' + run.persist() + return + } + phase.cursor = null + const failed = phase.counts?.failed ?? 0 + if (failed > 0) { + phase.error = refusedRows(failed) + phase.status = 'failed' + refusedPass(phase) + run.persist() + return + } + bump(phase, 'passComplete', 1) + run.persist() + } + // Pass 2: one audience per topic. + for (const item of phase.items) { + if (item.status === 'done' || item.brew?.fieldName === undefined) { + continue + } + try { + const { audienceId, audienceName } = await ensureAudience( + run, + item, + item.brew.fieldName + ) + item.brew = { ...item.brew, audienceId, audienceName } + item.status = 'done' + item.detail = `${item.brew.fieldName} on every contact with a choice; audience "${audienceName}"` + delete item.error + } catch (error) { + rethrowIfFatal(run.ctx, error) + item.status = 'failed' + item.error = errorOf(run, error) + } + run.persist() + } + settlePhase(phase, { apply: true }) +} diff --git a/src/lib/migrate/phases.ts b/src/lib/migrate/phases.ts new file mode 100644 index 00000000..1655418a --- /dev/null +++ b/src/lib/migrate/phases.ts @@ -0,0 +1,470 @@ +import type { EmailImportInput } from '@brew.new/sdk' +import { asSdkInput } from '../input' +import { + type ImportResponse, + importAndSetUp, + looksLikeMissingImportModes, + recordImportWarnings, +} from './import-setup' +import { deriveKey, isCanonicalKitKey } from './keys' +import type { MigrationItem, PhaseState } from './manifest' +import { + addNextStep, + addWarning, + clean, + errorOf, + idempotencyKey, + keepsKey, + phaseState, + pruneItems, + type RunContext, + reconcileItem, + rethrowIfFatal, + say, + settlePhase, +} from './run' +import type { SourceTemplate } from './source' + +/** + * The account phases: what lives in the Resend account (domains, templates, + * broadcasts; contacts, segments, suppressions and topics are in + * `phases-contacts.ts`) and what only gets reported (webhooks, + * automations). Each reads through the source's inventory, plans items, and + * on `--apply` executes them, persisting the manifest after every item or + * page so a re-run resumes. + */ + +const CONTACT_FIELDS = new Set(['FIRST_NAME', 'LAST_NAME', 'EMAIL']) +const VARIABLE_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/ + +export function inventoryOf(run: RunContext) { + const inventory = run.inventory + if (inventory === undefined) { + throw new Error('This phase reads Resend, and no Resend key was given.') + } + return inventory +} + +export function brewOf(run: RunContext) { + const brew = run.brew + if (brew === undefined) { + throw new Error('Applying needs a Brew API key.') + } + return brew +} + +/** A subdomain Brew can sign without touching Resend's `resend._domainkey`. */ +export function suggestSubdomain( + name: string, + taken: ReadonlySet<string> +): string { + const parts = name.split('.') + const base = parts.length > 2 ? parts.slice(1).join('.') : name + for (const label of ['mail', 'send', 'notify', 'brew']) { + const candidate = `${label}.${base}` + if (!taken.has(candidate)) { + return candidate + } + } + return `brew.${name}` +} + +// ---- Domains (report only). + +export async function domainsPhase(run: RunContext): Promise<void> { + const phase = phaseState(run, 'domains') + const domains = await inventoryOf(run).domains() + say(run, `Resend: ${domains.length} domains`) + const resendNames = new Set( + domains.map((domain) => domain.name.toLowerCase()) + ) + const brewNames = new Set( + (run.brewDomains ?? []).map((domain) => domain.name.toLowerCase()) + ) + const taken = new Set([...resendNames, ...brewNames]) + const keep = new Set<string>() + const suggestions: string[] = [] + for (const domain of domains) { + const id = `domain:${domain.id}` + keep.add(id) + const suggestion = suggestSubdomain(domain.name.toLowerCase(), taken) + const inBrew = brewNames.has(domain.name.toLowerCase()) + const item = reconcileItem(phase, { + id, + kind: 'domain', + status: 'manual', + name: domain.name, + sourceId: domain.id, + detail: `${domain.status} in Resend; Brew signs with the same DKIM selector (resend._domainkey), so send from ${suggestion} in Brew and keep ${domain.name} on Resend until cutover`, + }) + item.warnings = [] + if (inBrew) { + addWarning(item, { + code: 'DKIM_SELECTOR_IN_USE', + message: `${domain.name} is in Brew and in Resend: both need resend._domainkey.${domain.name}, which holds one key. Verify a subdomain in Brew instead, or move ${domain.name} after Resend is retired.`, + }) + } + if (!inBrew) { + suggestions.push(suggestion) + } + } + pruneItems(phase, keep) + const target = run.targetDomain + if (target !== null && resendNames.has(target.name.toLowerCase())) { + run.findings.push({ + code: 'DKIM_SELECTOR_IN_USE', + severity: 'warning', + message: `--domain ${target.name} is also a Resend domain; its resend._domainkey record serves one of them only.`, + }) + } + const verified = (run.brewDomains ?? []).some( + (domain) => domain.status === 'verified' + ) + const first = suggestions[0] + if (!verified && first !== undefined) { + addNextStep(run, { + id: 'add-brew-domain', + actor: 'human', + kind: 'add-dns-records', + title: `Add a sending domain in Brew on a subdomain Resend does not use (${suggestions.join(', ')}), then add its DNS records; until it verifies, app emails reach your Brew team only`, + command: `${run.prefix} domains add ${first}`, + blocking: false, + }) + } + settlePhase(phase, { apply: run.apply }) + if (domains.length === 0) { + phase.status = run.apply ? 'done' : 'planned' + phase.detail = 'no domains in Resend' + } +} + +// ---- Templates. + +async function loadTemplate( + run: RunContext, + id: string +): Promise<SourceTemplate | null> { + if (!run.templates.has(id)) { + run.templates.set(id, await inventoryOf(run).template(id)) + } + return run.templates.get(id) ?? null +} + +function parseFrom( + from: string | null +): { localPart?: string; name?: string } | undefined { + if (from === null) { + return undefined + } + const match = /^\s*(?:"?([^"<]*?)"?\s*)?<([^<>@\s]+)@[^<>\s]+>\s*$/.exec(from) + const bare = /^\s*([^<>@\s]+)@[^<>\s]+\s*$/.exec(from) + const name = match?.[1]?.trim() + const localPart = (match?.[2] ?? bare?.[1])?.trim() + if (localPart === undefined || localPart.length > 64) { + return undefined + } + return { + localPart, + ...(name === undefined || name === '' ? {} : { name: name.slice(0, 100) }), + } +} + +function planTemplate( + run: RunContext, + item: MigrationItem, + template: SourceTemplate | null +): void { + item.warnings = [] + delete item.error + if (template === null) { + item.status = 'skipped' + item.detail = 'no longer in Resend' + return + } + if (template.html === null) { + item.status = 'manual' + item.detail = 'has no HTML body in Resend; design it in Brew' + return + } + item.status = 'planned' + const published = template.status === 'published' + const existing = run.existingKeys?.has(item.key ?? '') === true + item.detail = `→ app email ${item.key} (${published ? 'published' : 'not published: a draft in Resend'}), ${template.variables.length} variable${template.variables.length === 1 ? '' : 's'}${existing ? '; updates the app email Brew already has' : ''}` + for (const variable of template.variables) { + if (CONTACT_FIELDS.has(variable.key)) { + addWarning(item, { + code: 'CONTACT_FIELD_VARIABLE', + message: `{{{${variable.key}}}} reads the recipient's Brew contact record, not the send payload.`, + }) + } + } +} + +export async function templatesPhase(run: RunContext): Promise<void> { + const phase = phaseState(run, 'templates') + delete phase.error + const summaries = await inventoryOf(run).templates() + say(run, `Resend: ${summaries.length} templates`) + const taken = new Set<string>() + const ours = new Set<string>() + for (const item of [ + ...phase.items, + ...(run.manifest.phases.code?.items ?? []), + ]) { + if (keepsKey(run, item)) { + taken.add(item.key) + ours.add(item.key) + } + } + for (const key of run.existingKeys ?? []) { + if (!ours.has(key) && !isCanonicalKitKey(key)) { + taken.add(key) + } + } + const keep = new Set<string>() + for (const summary of summaries) { + const id = `template:${summary.id}` + keep.add(id) + const previous = phase.items.find((item) => item.id === id) + const key = keepsKey(run, previous) + ? previous.key + : deriveKey({ + base: summary.alias ?? summary.name, + prefix: run.options.keyPrefix, + taken, + fallback: 'template', + }) + const item = reconcileItem(phase, { + id, + kind: 'template', + status: 'planned', + name: summary.name, + sourceId: summary.id, + ...(summary.alias === null ? {} : { alias: summary.alias }), + key, + }) + if (item.status === 'done') { + continue + } + item.key = key + planTemplate(run, item, await loadTemplate(run, summary.id)) + } + pruneItems(phase, keep) + run.persist() + if (run.apply) { + for (const item of phase.items) { + if (item.status !== 'planned' && item.status !== 'failed') { + continue + } + run.ctx.signal.throwIfAborted() + const template = await loadTemplate(run, item.sourceId ?? '') + if (template?.html == null) { + continue + } + const variables = template.variables.flatMap((variable) => + variable.fallbackValue === null || !VARIABLE_KEY.test(variable.key) + ? [] + : [ + { + key: variable.key, + fallbackValue: variable.fallbackValue.slice(0, 1000), + }, + ] + ) + const from = parseFrom(template.from) + await importAndSetUp(run, item, { + body: { + format: 'html', + content: template.html, + title: template.name.slice(0, 200), + ...(template.subject === null + ? {} + : { subjectLine: template.subject }), + placeholders: run.source.importPlaceholders, + ...(variables.length === 0 ? {} : { variables }), + }, + publish: template.status === 'published', + ...(template.subject === null ? {} : { subject: template.subject }), + ...(from === undefined ? {} : { from }), + }) + } + } + settlePhase(phase, { apply: run.apply }) + if (summaries.length === 0 && phase.items.length === 0) { + phase.detail = 'no templates in Resend' + } +} + +// ---- Report-only phases. + +function reportOnly( + run: RunContext, + phase: PhaseState, + items: readonly MigrationItem[], + empty: string +): void { + const keep = new Set(items.map((item) => item.id)) + for (const item of items) { + const merged = reconcileItem(phase, item) + merged.status = item.status + if (item.detail !== undefined) { + merged.detail = item.detail + } + } + pruneItems(phase, keep) + settlePhase(phase, { apply: run.apply }) + phase.detail = items.length === 0 ? empty : 'reported only: nothing is copied' +} + +// ---- Broadcasts → Brew draft emails (nothing is sent). + +export async function broadcastsPhase(run: RunContext): Promise<void> { + const phase = phaseState(run, 'broadcasts') + delete phase.error + const inventory = inventoryOf(run) + const broadcasts = await inventory.broadcasts() + say(run, `Resend: ${broadcasts.length} broadcasts`) + const audiences = new Map( + (run.manifest.phases.segments?.items ?? []).flatMap((item) => + item.sourceId === undefined || item.brew?.audienceName === undefined + ? [] + : [[item.sourceId, item.brew.audienceName] as const] + ) + ) + const keep = new Set<string>() + for (const broadcast of broadcasts) { + const id = `broadcast:${broadcast.id}` + keep.add(id) + const item = reconcileItem(phase, { + id, + kind: 'broadcast', + status: 'planned', + name: broadcast.name, + sourceId: broadcast.id, + }) + if (item.status === 'done') { + continue + } + if (broadcast.status === 'sent') { + item.status = 'skipped' + item.detail = 'sent; its history stays in Resend' + continue + } + const audience = + broadcast.segmentId === null + ? undefined + : audiences.get(broadcast.segmentId) + item.status = 'planned' + item.detail = `${broadcast.status} in Resend → a Brew draft email; nothing is sent${audience === undefined ? '' : `. Send it to the audience "${audience}" when ready`}` + } + pruneItems(phase, keep) + run.persist() + if (run.apply) { + const brew = brewOf(run) + for (const item of phase.items) { + if ( + (item.status !== 'planned' && item.status !== 'failed') || + item.sourceId === undefined + ) { + continue + } + run.ctx.signal.throwIfAborted() + try { + const detail = await inventory.broadcast(item.sourceId) + if (detail === null) { + item.status = 'skipped' + item.detail = 'no longer in Resend' + continue + } + if (detail.html === null) { + item.status = 'manual' + item.detail = 'has no HTML body in Resend; write it in Brew' + continue + } + const body = { + format: 'html' as const, + content: detail.html, + title: detail.name.slice(0, 200), + ...(detail.subject === null ? {} : { subjectLine: detail.subject }), + placeholders: run.source.importPlaceholders, + } + say(run, `Importing broadcast ${item.name} as a Brew draft…`) + const imported = (await brew.emails.import( + asSdkInput<EmailImportInput>(body), + { + idempotencyKey: idempotencyKey( + run, + `broadcast-${item.sourceId}`, + body + ), + } + )) as unknown as ImportResponse + item.warnings = [] + recordImportWarnings(item, imported) + const triggerTags = (imported.variables ?? []).filter((variable) => + variable.path.startsWith('trigger.') + ) + if (triggerTags.length > 0) { + addWarning(item, { + code: 'CAMPAIGN_TRIGGER_TAGS', + message: `A campaign has no trigger payload, so ${triggerTags.map((variable) => `{{ ${variable.path} }}`).join(', ')} render${triggerTags.length === 1 ? 's' : ''} ${triggerTags.length === 1 ? 'its' : 'their'} fallback: point them at contact fields before sending.`, + }) + } + item.brew = { emailId: imported.emailId } + item.status = 'done' + item.detail = `draft email ${imported.emailId} in Brew (not sent)` + delete item.error + } catch (error) { + rethrowIfFatal(run.ctx, error) + item.status = 'failed' + item.error = errorOf(run, error) + if (looksLikeMissingImportModes(item.error)) { + addWarning(item, { + code: 'IMPORT_MODES_UNAVAILABLE', + message: + "Brew's import does not take the migration modes yet (placeholders); try again once it does.", + }) + } + } + run.persist() + } + } + settlePhase(phase, { apply: run.apply }) + if (broadcasts.length === 0 && phase.items.length === 0) { + phase.detail = 'no broadcasts in Resend' + } +} + +export async function webhooksPhase(run: RunContext): Promise<void> { + const webhooks = await inventoryOf(run).webhooks() + reportOnly( + run, + phaseState(run, 'webhooks'), + webhooks.map((webhook) => ({ + id: `webhook:${webhook.id}`, + kind: 'webhook' as const, + status: 'manual' as const, + name: clean(run, webhook.endpointOrigin), + sourceId: webhook.id, + detail: `${webhook.status}, ${webhook.events.length} events (${webhook.events.slice(0, 4).join(', ')}${webhook.events.length > 4 ? ', …' : ''}): Brew does not post events to webhooks; read them with analytics events, and keep this one until Resend stops sending`, + })), + 'no webhooks in Resend' + ) +} + +export async function automationsPhase(run: RunContext): Promise<void> { + const automations = await inventoryOf(run).automations() + reportOnly( + run, + phaseState(run, 'automations'), + automations.map((automation) => ({ + id: `automation:${automation.id}`, + kind: 'automation' as const, + status: 'manual' as const, + name: automation.name, + sourceId: automation.id, + detail: `${automation.status} in Resend: rebuild it as a Brew automation (in the app, or with the Brew MCP)`, + })), + 'no automations in Resend' + ) +} diff --git a/src/lib/migrate/pipeline.ts b/src/lib/migrate/pipeline.ts new file mode 100644 index 00000000..1b1a3ac5 --- /dev/null +++ b/src/lib/migrate/pipeline.ts @@ -0,0 +1,586 @@ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import type { BrewClient } from '../client' +import { type Clock, systemClock } from '../clock' +import { buildConfirmCommand } from '../confirm' +import type { CommandOutcome, CommandSpec } from '../define-command' +import { CliAuthError, CliUsageError } from '../errors' +import { detectProject } from '../init/detect' +import type { NextStep } from '../init/report' +import { reachesSdkRange, SDK_PACKAGE, SDK_RANGE } from '../init/templates' +import { getInvocationPrefix } from '../invocation' +import { addCommand, formatCommandLine } from '../project/package-manager' +import type { RerunInvocation } from '../rerun' +import type { CliContext } from '../types' +import { + bindSourceKey, + createManifest, + type MigrationManifest, + MigrationManifestError, + PHASES, + type PhaseId, + readManifest, + writeManifest, +} from './manifest' +import { codePhase } from './phase-code' +import { + automationsPhase, + broadcastsPhase, + domainsPhase, + templatesPhase, + webhooksPhase, +} from './phases' +import { + contactsPhase, + segmentsPhase, + suppressionsPhase, + topicsPhase, +} from './phases-contacts' +import { + exitCodeForMigration, + type Finding, + MIGRATION_REPORT_SCHEMA_VERSION, + type MigrationReport, + type MigrationStatus, + renderMigrationReport, +} from './report' +import { ResendAccessError } from './resend/client' +import { + type BrewDomainRow, + errorOf, + type MigrateOptions, + type ProjectInfo, + type RunContext, + rethrowIfFatal, + say, +} from './run' +import type { MigrationSource } from './source' + +/** + * `migrate <source>`: plan (default) → review `brew.migration.json` → + * `--apply`. Reads the source account with a key that never leaves this + * machine for Brew, reads Brew to plan keys and domains, and only writes to + * Brew (and the project's files) on `--apply`. + */ + +export type MigrateInput = MigrateOptions & { + readonly cwd: string + readonly sourceKey: { + readonly value: string + readonly from: 'stdin' | 'env' + } | null + readonly clock?: Clock + /** The parsed invocation, re-serialized into the apply command it prints. */ + readonly rerun: { + readonly command: Pick<CommandSpec, 'path' | 'flags' | 'args'> + readonly invocation: RerunInvocation + } +} + +const PHASE_RUNNERS: Readonly< + Record<Exclude<PhaseId, 'code'>, (run: RunContext) => Promise<void>> +> = { + domains: domainsPhase, + templates: templatesPhase, + contacts: contactsPhase, + segments: segmentsPhase, + suppressions: suppressionsPhase, + topics: topicsPhase, + broadcasts: broadcastsPhase, + webhooks: webhooksPhase, + automations: automationsPhase, +} + +function projectInfo(cwd: string, env: CliContext['io']['env']): ProjectInfo { + const detected = detectProject(cwd, { env }) + if (detected.kind !== 'project') { + return { + root: detected.kind === 'ambiguous' ? detected.root : cwd, + framework: null, + typescript: false, + libDir: null, + packageManager: null, + addSdkCommand: null, + hasSdk: false, + } + } + const { project } = detected + // An SDK older than SDK_RANGE cannot load `@brew.new/sdk/resend`, so it + // counts as missing: the install step asks for SDK_RANGE over it. + let hasSdk = reachesSdkRange(project.sdkRange) + try { + const pkg = JSON.parse( + readFileSync(join(project.root, 'package.json'), 'utf8') + ) as { + dependencies?: Record<string, string> + } + hasSdk = reachesSdkRange(pkg.dependencies?.['@brew.new/sdk']) + } catch {} + return { + root: project.root, + framework: project.framework, + typescript: project.typescript, + libDir: project.libDir, + packageManager: project.packageManager.name, + addSdkCommand: formatCommandLine( + addCommand(project.packageManager, [`${SDK_PACKAGE}@${SDK_RANGE}`]) + ), + hasSdk, + } +} + +async function readBrew(run: RunContext): Promise<{ + brand: MigrationReport['brand'] +}> { + const brew = run.brew + if (brew === undefined) { + return { brand: null } + } + let brand: MigrationReport['brand'] = null + try { + const row = await brew.brand.get() + brand = { + brandId: row.brandId, + name: row.identity?.brandName ?? row.domain ?? null, + } + } catch (error) { + rethrowIfFatal(run.ctx, error) + run.findings.push({ + code: 'BREW_BRAND_UNREADABLE', + severity: 'warning', + message: `Could not read the Brew brand: ${errorOf(run, error).message}`, + }) + } + if (brand !== null) { + // Done items and cursors belong to the brand the manifest records. + const recorded = run.manifest.brandId + if (recorded !== null && recorded !== brand.brandId) { + throw new CliUsageError( + `${run.options.manifestPath} records a migration into Brew brand ${recorded}, not ${brand.brandId}. Use that brand's API key, or start a separate migration with --manifest <path>.` + ) + } + run.manifest.brandId = brand.brandId + } + try { + const list = await brew.domains.list({ limit: 100 }) + run.brewDomains = list.data.map((row) => ({ + domainId: row.domainId, + name: row.name, + status: row.status, + sendingPurpose: row.sendingPurpose ?? null, + })) + } catch (error) { + rethrowIfFatal(run.ctx, error) + run.findings.push({ + code: 'BREW_DOMAINS_UNREADABLE', + severity: 'warning', + message: `Could not list Brew domains: ${errorOf(run, error).message}`, + }) + } + try { + const keys = new Set<string>() + let cursor: string | undefined + for (let page = 0; page < 20; page += 1) { + const list = await brew.appEmails.list({ + limit: 100, + ...(cursor === undefined ? {} : { cursor }), + }) + for (const row of list.data) { + keys.add(row.triggerKey) + } + if (!list.pagination.hasMore || list.pagination.cursor === null) { + break + } + cursor = list.pagination.cursor + } + run.existingKeys = keys + } catch (error) { + rethrowIfFatal(run.ctx, error) + run.findings.push({ + code: 'BREW_APP_EMAILS_UNREADABLE', + severity: 'warning', + message: `Could not list Brew app emails (keys are planned without checking them): ${errorOf(run, error).message}`, + }) + } + return { brand } +} + +function resolveTargetDomain(run: RunContext, flag: string | undefined): void { + // A run without --domain resumes on the domain the manifest records. + const domain = flag ?? run.manifest.options.domain ?? undefined + if (domain === undefined) { + return + } + const names = (row: BrewDomainRow, text: string) => + row.domainId === text || row.name.toLowerCase() === text.toLowerCase() + const match = run.brewDomains?.find((row) => names(row, domain)) + // Unsubscribe imports resume from cursors read for the recorded domain, + // which --domain may name by its id or its name. + const recorded = run.manifest.options.domain + if ( + recorded !== null && + recorded.toLowerCase() !== domain.toLowerCase() && + (match === undefined || !names(match, recorded)) + ) { + throw new CliUsageError( + `${run.options.manifestPath} records --domain ${recorded}. Pass --domain ${recorded}, or start a separate migration with --manifest <path>.` + ) + } + if (run.brewDomains === null) { + return + } + if (match === undefined) { + throw new CliUsageError( + `--domain ${domain} is not a domain of this Brew brand. List them with \`${run.prefix} domains list\`.` + ) + } + // Contacts moved before any domain was named never reached its + // unsubscribe list: their pass runs again (an upsert's consent never + // re-subscribes an opt-out). + const contacts = run.manifest.phases.contacts + if (recorded === null && contacts?.status === 'done') { + contacts.status = 'planned' + } + run.manifest.options.domain = domain + run.targetDomain = match +} + +function overallStatus(input: { + readonly mode: MigrateOptions['mode'] + readonly manifest: MigrationManifest + readonly phases: readonly PhaseId[] + readonly findings: readonly Finding[] + readonly nextSteps: readonly NextStep[] +}): MigrationStatus { + if ( + input.findings.some( + (finding) => + finding.code === 'RESEND_KEY_SENDING_ONLY' || + finding.code === 'RESEND_KEY_REJECTED' + ) + ) { + return 'auth_required' + } + const failed = input.phases.some((id) => { + const phase = input.manifest.phases[id] + return ( + phase !== undefined && + (phase.status === 'failed' || + phase.status === 'partial' || + phase.items.some((item) => item.status === 'failed')) + ) + }) + if ( + failed || + input.findings.some((finding) => finding.severity === 'error') + ) { + return 'failed' + } + if (input.mode !== 'apply') { + return 'planned' + } + return input.nextSteps.some((step) => step.blocking) + ? 'action_required' + : 'complete' +} + +function agentInstructions( + report: Omit<MigrationReport, 'agentInstructions'> +): string { + const parts: string[] = [] + switch (report.status) { + case 'planned': + parts.push( + `Review ${report.manifest.path} (or this report) with the person, then apply it with the apply nextStep; nothing changed yet.` + ) + break + case 'auth_required': + parts.push( + 'The Resend key cannot read the account. Ask the person for a full-access Resend API key and pass it on stdin with --resend-key-stdin; never print or store it.' + ) + break + case 'failed': + parts.push( + 'Something failed (see phases[].error and items[].error). Fix the cause and run the same command again: done items are skipped and paged phases resume from their cursor.' + ) + break + default: + parts.push('The Resend account is copied into Brew.') + } + const blocking = report.nextSteps.filter((step) => step.blocking) + if (blocking.length > 0) { + parts.push( + `Do the blocking nextSteps (${blocking.length}); edit-file steps carry a suggested brew.send replacement. Hand human steps to the person.` + ) + } + parts.push( + 'Keep Resend configured (domain, key, webhooks) until a Brew send is verified; never delete Resend data from this flow.' + ) + return parts.join(' ') +} + +export async function runMigration( + ctx: CliContext, + source: MigrationSource, + input: MigrateInput +): Promise<CommandOutcome> { + const prefix = getInvocationPrefix(ctx.io.env) + const clock = input.clock ?? ctx.io.clock ?? systemClock + const apply = input.mode === 'apply' + const manifestPath = resolve(input.cwd, input.manifestPath) + let previous: MigrationManifest | null + try { + previous = readManifest(manifestPath) + } catch (error) { + if (error instanceof MigrationManifestError) { + throw new CliUsageError( + `${error.message}. Fix or remove it, then run the migration again.` + ) + } + throw error + } + const now = new Date(clock.now()).toISOString() + const manifest = + previous ?? createManifest({ now, keyPrefix: input.keyPrefix }) + + const accountPhases = input.phases.filter((phase) => phase !== 'code') + if (accountPhases.length > 0 && input.sourceKey === null) { + throw new CliUsageError( + `Reading the ${source.displayName} account needs its API key: set ${source.envKey}, or pipe it with --resend-key-stdin. (--only code scans the project without one.)` + ) + } + if (input.sourceKey !== null) { + bindSourceKey(manifest, input.sourceKey.value) + } + + let brew: BrewClient | undefined + try { + brew = ctx.client() + } catch (error) { + if (!(error instanceof CliAuthError) || apply) { + throw error + } + } + + const project = projectInfo(input.cwd, ctx.io.env) + const secrets = [ + ...(input.sourceKey === null ? [] : [input.sourceKey.value]), + ...(ctx.globals.apiKey === undefined ? [] : [ctx.globals.apiKey]), + ...(ctx.io.env.BREW_API_KEY === undefined ? [] : [ctx.io.env.BREW_API_KEY]), + ].filter((secret) => secret.length >= 4) + const run: RunContext = { + ctx, + source, + options: input, + apply, + manifest, + brew, + inventory: + input.sourceKey === null + ? undefined + : source.inventory(ctx, { apiKey: input.sourceKey.value, clock }), + project, + prefix, + secrets, + brewDomains: null, + targetDomain: null, + existingKeys: null, + templates: new Map(), + findings: [], + nextSteps: [], + persist: () => { + if (input.mode === 'dry-run') { + return + } + manifest.updatedAt = new Date(clock.now()).toISOString() + writeManifest(manifestPath, manifest) + }, + } + + say( + run, + `${apply ? 'Migrating' : 'Planning a migration'} from ${source.displayName} (${input.phases.join(', ')})…` + ) + const { brand } = await readBrew(run) + if (brew === undefined) { + run.findings.push({ + code: 'BREW_SIGN_IN_NEEDED', + severity: 'info', + message: + 'No Brew API key: the plan checks nothing in Brew (keys, domains). Applying needs one.', + }) + run.nextSteps.push({ + id: 'brew-login', + actor: 'human', + kind: 'run-command', + command: `${prefix} login`, + title: 'Sign in to Brew (applying writes to your brand)', + blocking: true, + }) + } + resolveTargetDomain(run, input.domain) + + let engine: MigrationReport['project']['scanEngine'] = null + let filesScanned = 0 + let stopAccount = false + try { + for (const id of PHASES) { + if (!input.phases.includes(id)) { + continue + } + ctx.signal.throwIfAborted() + if (id === 'code') { + const result = await codePhase(run) + engine = result.engine + filesScanned = result.filesScanned + continue + } + if (stopAccount) { + continue + } + try { + await PHASE_RUNNERS[id](run) + } catch (error) { + if (error instanceof ResendAccessError) { + stopAccount = true + run.findings.push({ + code: + error.reason === 'sending_only' + ? 'RESEND_KEY_SENDING_ONLY' + : 'RESEND_KEY_REJECTED', + severity: 'error', + message: error.message, + }) + run.nextSteps.push({ + id: 'resend-full-access-key', + actor: 'human', + kind: 'open-url', + url: 'https://resend.com/api-keys', + title: + 'Create a Resend API key with Full access (a sending-only key cannot read templates, contacts or domains), then pass it with --resend-key-stdin', + blocking: true, + }) + continue + } + rethrowIfFatal(ctx, error) + const phase = manifest.phases[id] ?? { + status: 'failed' as const, + items: [], + } + phase.status = 'failed' + phase.error = errorOf(run, error) + manifest.phases[id] = phase + } + run.persist() + } + } finally { + run.persist() + } + + if (apply) { + // An email left to a person is not migrated until they finish it. + const unfinished = (['templates', 'code'] as const) + .filter((id) => input.phases.includes(id)) + .flatMap((id) => manifest.phases[id]?.items ?? []) + .filter((item) => item.status === 'manual' && item.kind !== 'rewrite') + for (const item of unfinished) { + run.nextSteps.push({ + id: `finish-${item.id}`, + actor: item.kind === 'react-email' ? 'agent' : 'human', + kind: 'review', + title: `${item.name} (${item.key}) is not migrated: ${item.detail ?? 'see the manifest'}`, + blocking: true, + }) + } + const keys = (manifest.phases.templates?.items ?? []) + .concat(manifest.phases.code?.items ?? []) + .filter((item) => item.status === 'done' && item.key !== undefined) + const first = keys[0] + if (first?.key !== undefined) { + run.nextSteps.push({ + id: 'verify-send', + actor: 'agent', + kind: 'run-command', + command: `${prefix} send ${first.key} --to <your-email> --yes`, + title: `Send one migrated email to yourself (until a domain verifies, Brew delivers to your team only), and check app-emails list shows every key ready`, + blocking: false, + }) + } + run.nextSteps.push({ + id: 'cut-over', + actor: 'human', + kind: 'review', + title: + 'Cut over once Brew sends are verified: switch production to BREW_API_KEY, then retire RESEND_API_KEY and the resend package. Keep the Resend domain and webhooks until then', + blocking: false, + }) + } else if (input.mode === 'plan') { + run.nextSteps.unshift({ + id: 'apply', + actor: 'agent', + kind: 'run-command', + command: buildConfirmCommand( + { + args: input.rerun.invocation.args, + flags: { + ...input.rerun.invocation.flags, + dryRun: undefined, + apply: true, + }, + }, + input.rerun.command, + undefined, + prefix + ), + title: `After the person reviews ${input.manifestPath}, apply the plan (nothing in ${source.displayName} is changed or deleted)${input.sourceKey?.from === 'stdin' ? '; pipe the Resend key in again' : ''}`, + blocking: true, + }) + } + + const phases = PHASES.filter((id) => input.phases.includes(id)).flatMap( + (id) => { + const phase = manifest.phases[id] + return phase === undefined ? [] : [{ id, ...phase }] + } + ) + const status = overallStatus({ + mode: input.mode, + manifest, + phases: input.phases, + findings: run.findings, + nextSteps: run.nextSteps, + }) + const base: Omit<MigrationReport, 'agentInstructions'> = { + schemaVersion: MIGRATION_REPORT_SCHEMA_VERSION, + status, + mode: input.mode, + source: source.id, + manifest: { path: input.manifestPath, written: input.mode !== 'dry-run' }, + brand, + resend: { + keySource: input.sourceKey?.from ?? 'none', + requests: run.inventory?.requestCount() ?? 0, + }, + project: { + root: project.root, + framework: project.framework, + typescript: project.typescript, + scanEngine: engine, + filesScanned, + }, + phases, + callSites: manifest.callSites, + findings: run.findings, + nextSteps: run.nextSteps, + } + const report: MigrationReport = { + ...base, + agentInstructions: agentInstructions(base), + } + const exitCode = exitCodeForMigration(status) + return { + data: report, + human: renderMigrationReport(report), + ...(exitCode === 0 ? {} : { exitCode }), + } +} diff --git a/src/lib/migrate/report.ts b/src/lib/migrate/report.ts new file mode 100644 index 00000000..7c7b1752 --- /dev/null +++ b/src/lib/migrate/report.ts @@ -0,0 +1,178 @@ +import { EXIT_AUTH, EXIT_OK, EXIT_RUNTIME } from '../errors' +import type { NextStep } from '../init/report' +import type { + CallSiteRecord, + MigrationItem, + MigrationWarning, + PhaseId, + PhaseState, +} from './manifest' + +/** + * `migrate resend`'s result: one JSON document an agent can act on. Its + * `nextSteps` use the same shape as `init`'s report. Like the manifest, it + * never carries a contact address, a key or a token. + */ + +export const MIGRATION_REPORT_SCHEMA_VERSION = 1 + +export type MigrationMode = 'plan' | 'dry-run' | 'apply' + +export type MigrationStatus = + /** A plan (or dry run): nothing changed in Resend or Brew. */ + | 'planned' + /** Applied; nothing blocking is left. */ + | 'complete' + /** Applied; call sites or other blocking steps remain. */ + | 'action_required' + /** The Resend key cannot read the account (exit 3). */ + | 'auth_required' + /** A phase or an item failed (exit 1); re-run to resume. */ + | 'failed' + +export type Finding = { + readonly code: string + readonly severity: 'error' | 'warning' | 'info' + readonly message: string +} + +export type PhaseReport = { + readonly id: PhaseId +} & PhaseState + +export type MigrationReport = { + readonly schemaVersion: 1 + readonly status: MigrationStatus + readonly mode: MigrationMode + readonly source: 'resend' + readonly manifest: { readonly path: string; readonly written: boolean } + readonly brand: { + readonly brandId: string + readonly name: string | null + } | null + readonly resend: { + readonly keySource: 'stdin' | 'env' | 'none' + readonly requests: number + } + readonly project: { + readonly root: string + readonly framework: string | null + readonly typescript: boolean + readonly scanEngine: 'typescript' | 'tokenizer' | null + readonly filesScanned: number + } + readonly phases: readonly PhaseReport[] + readonly callSites: readonly CallSiteRecord[] + readonly findings: readonly Finding[] + readonly nextSteps: readonly NextStep[] + readonly agentInstructions: string +} + +export function exitCodeForMigration(status: MigrationStatus): number { + switch (status) { + case 'failed': + return EXIT_RUNTIME + case 'auth_required': + return EXIT_AUTH + default: + return EXIT_OK + } +} + +const HEADLINES: Readonly<Record<MigrationStatus, string>> = { + planned: 'Migration plan from Resend (nothing changed in Resend or Brew):', + complete: 'Migrated from Resend.', + action_required: + 'Migrated from Resend. Finish the steps marked "needed" to cut over.', + auth_required: 'Resend did not let this key read the account.', + failed: 'The migration did not finish. Run the same command again to resume.', +} + +const PHASE_LABELS: Readonly<Record<PhaseId, string>> = { + domains: 'Domains', + templates: 'Templates', + code: 'Code', + contacts: 'Contacts', + segments: 'Segments', + suppressions: 'Suppressions', + topics: 'Topics', + broadcasts: 'Broadcasts', + webhooks: 'Webhooks', + automations: 'Automations', +} + +function countsLine( + counts: Readonly<Record<string, number>> | undefined +): string { + if (counts === undefined) { + return '' + } + const parts = Object.entries(counts) + .filter(([, value]) => value > 0) + .map(([name, value]) => `${value} ${name}`) + return parts.length === 0 ? '' : ` (${parts.join(', ')})` +} + +function itemLine(item: MigrationItem): string { + const target = item.key === undefined ? '' : ` → ${item.key}` + const detail = item.error?.message ?? item.detail + return ` ${item.status.padEnd(8)} ${item.name}${target}${detail === undefined ? '' : `: ${detail}`}` +} + +function warningLines( + warnings: readonly MigrationWarning[] | undefined +): string[] { + return (warnings ?? []).map((warning) => ` ! ${warning.message}`) +} + +/** The human rendering (TTY). */ +export function renderMigrationReport(report: MigrationReport): string { + const lines: string[] = [HEADLINES[report.status], ''] + if (report.brand !== null) { + lines.push(`Brew brand: ${report.brand.name ?? report.brand.brandId}`) + } + for (const phase of report.phases) { + lines.push( + ` ${phase.status.padEnd(8)} ${PHASE_LABELS[phase.id]}${countsLine(phase.counts)}${phase.detail === undefined ? '' : `: ${phase.detail}`}` + ) + lines.push(...warningLines(phase.warnings)) + for (const item of phase.items) { + lines.push(itemLine(item)) + lines.push(...warningLines(item.warnings)) + } + } + if (report.callSites.length > 0) { + lines.push('', 'Resend call sites:') + for (const site of report.callSites) { + lines.push( + ` ${site.status.padEnd(8)} ${site.file}:${site.line} ${site.method} (${site.kind})${site.unsupported.length === 0 ? '' : `; doesn't carry over: ${site.unsupported.join(', ')}`}` + ) + lines.push(` ${site.replacement}`) + } + } + const notable = report.findings.filter( + (finding) => finding.severity !== 'info' + ) + if (notable.length > 0) { + lines.push('', 'Findings:') + for (const finding of notable) { + lines.push(` ${finding.severity.padEnd(8)} ${finding.message}`) + } + } + if (report.nextSteps.length > 0) { + lines.push('', 'Next:') + for (const step of report.nextSteps) { + const target = step.command ?? step.url ?? step.path + lines.push( + ` ${step.blocking ? 'needed ' : 'optional'} ${step.title}${target === undefined ? '' : `\n ${target}`}` + ) + } + } + lines.push( + '', + report.manifest.written + ? `Progress is in ${report.manifest.path}.` + : `Dry run: ${report.manifest.path} was not written.` + ) + return lines.join('\n') +} diff --git a/src/lib/migrate/resend/client.ts b/src/lib/migrate/resend/client.ts new file mode 100644 index 00000000..69dcce7a --- /dev/null +++ b/src/lib/migrate/resend/client.ts @@ -0,0 +1,158 @@ +import { CLI_VERSION } from '../../../version' +import { type Clock, systemClock } from '../../clock' +import { CliVendorError } from '../../errors' +import { vendorFetch } from '../../project/vendor-http' +import type { CliContext } from '../../types' + +/** + * Read-only access to the Resend API for a migration. The key stays on this + * machine: it rides only requests to `api.resend.com`, which go through + * `vendorFetch` (never recorded into the Brew transport, credentials + * redacted from every error) and never appears in output or the manifest. + * + * Requests are paced to `requestsPerSecond` (Resend's default team limit + * is shared with the app still sending through it, so the migration takes + * a small slice), and a 429 waits out its `retry-after` before retrying. + */ + +export const RESEND_API_URL = 'https://api.resend.com' +export const RESEND_VENDOR = 'Resend' + +const MAX_429_RETRIES = 5 +const MAX_RETRY_AFTER_MS = 60_000 +const DEFAULT_429_WAIT_MS = 1_000 + +/** The key may only send email: listing anything needs a full-access key. */ +export class ResendAccessError extends Error { + readonly status: number + readonly reason: 'sending_only' | 'rejected' + + constructor(input: { + readonly status: number + readonly reason: 'sending_only' | 'rejected' + readonly message: string + }) { + super(input.message) + this.name = 'ResendAccessError' + this.status = input.status + this.reason = input.reason + } +} + +export type ResendList<T> = { + readonly data: readonly T[] + readonly hasMore: boolean +} + +export type ResendClient = { + /** One GET, paced and retried on 429; `undefined` for a 404. */ + readonly get: ( + path: string, + query?: Readonly<Record<string, string | undefined>> + ) => Promise<unknown> + /** One page of a list endpoint (`limit` ≤ 100, `after` cursor). */ + readonly page: <T>( + path: string, + options?: { readonly after?: string; readonly limit?: number } + ) => Promise<ResendList<T>> + /** Requests made so far. */ + readonly requestCount: () => number +} + +type ClientContext = Pick<CliContext, 'signal' | 'budget' | 'io'> + +export function createResendClient( + ctx: ClientContext, + input: { + readonly apiKey: string + readonly requestsPerSecond: number + readonly clock?: Clock + } +): ResendClient { + const clock = input.clock ?? ctx.io.clock ?? systemClock + const interval = Math.ceil(1000 / Math.max(0.1, input.requestsPerSecond)) + let lastStart: number | undefined + let count = 0 + + const pace = async (): Promise<void> => { + if (lastStart !== undefined) { + const wait = lastStart + interval - clock.now() + if (wait > 0) { + await clock.sleep(wait, ctx.signal) + } + } + lastStart = clock.now() + } + + const get = async ( + path: string, + query: Readonly<Record<string, string | undefined>> = {} + ): Promise<unknown> => { + const url = new URL(path, RESEND_API_URL) + for (const [name, value] of Object.entries(query)) { + if (value !== undefined) { + url.searchParams.set(name, value) + } + } + for (let attempt = 1; ; attempt += 1) { + await pace() + count += 1 + try { + const response = await vendorFetch(ctx, { + vendor: RESEND_VENDOR, + method: 'GET', + url: url.toString(), + headers: { + authorization: `Bearer ${input.apiKey}`, + 'user-agent': `brew-cli/${CLI_VERSION} (migrate resend)`, + }, + maxAttempts: 1, + }) + return response.json + } catch (error) { + ctx.signal.throwIfAborted() + if (!(error instanceof CliVendorError)) { + throw error + } + if (error.status === 404) { + return undefined + } + if (error.status === 401 || error.status === 403) { + const sendingOnly = /restricted|only send|sending/i.test( + error.message + ) + throw new ResendAccessError({ + status: error.status, + reason: sendingOnly ? 'sending_only' : 'rejected', + message: sendingOnly + ? 'This Resend API key can only send emails. Reading templates, contacts and domains needs a full-access key.' + : `Resend rejected the API key (${error.status}). Check it is active and has full access.`, + }) + } + if (error.status === 429 && attempt <= MAX_429_RETRIES) { + const wait = Math.min( + error.retryAfterMs ?? DEFAULT_429_WAIT_MS * attempt, + MAX_RETRY_AFTER_MS + ) + await clock.sleep(wait, ctx.signal) + continue + } + throw error + } + } + } + + const page = async <T>( + path: string, + options: { readonly after?: string; readonly limit?: number } = {} + ): Promise<ResendList<T>> => { + const json = (await get(path, { + limit: String(options.limit ?? 100), + after: options.after, + })) as { data?: unknown; has_more?: unknown } | undefined + const data = Array.isArray(json?.data) ? (json.data as T[]) : [] + return { data, hasMore: json?.has_more === true && data.length > 0 } + } + + return { get, page, requestCount: () => count } +} diff --git a/src/lib/migrate/resend/source.ts b/src/lib/migrate/resend/source.ts new file mode 100644 index 00000000..19f7e0b7 --- /dev/null +++ b/src/lib/migrate/resend/source.ts @@ -0,0 +1,329 @@ +import { scanProject } from '../code/scan' +import type { + MigrationSource, + Page, + SourceBroadcast, + SourceContact, + SourceInventory, + SourceSuppression, +} from '../source' +import { createResendClient, type ResendClient } from './client' + +/** + * Resend as a migration source: the current API (contacts are global, + * audiences became segments, topics hold subscription preferences, + * templates carry their variables), read page by page with `after` + * cursors so an interrupted run resumes where it stopped. + */ + +type Row = Readonly<Record<string, unknown>> + +function text(value: unknown): string | null { + return typeof value === 'string' && value !== '' ? value : null +} + +function contactOf(row: Row): SourceContact | null { + const id = text(row.id) + const email = text(row.email) + if (id === null || email === null) { + return null + } + const properties: Record<string, string | number | boolean> = {} + const raw = row.properties + if (typeof raw === 'object' && raw !== null) { + for (const [key, entry] of Object.entries(raw as Row)) { + const value = + typeof entry === 'object' && entry !== null + ? (entry as { value?: unknown }).value + : entry + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + properties[key] = value + } + } + } + return { + id, + email, + firstName: text(row.first_name), + lastName: text(row.last_name), + unsubscribed: + typeof row.unsubscribed === 'boolean' ? row.unsubscribed : null, + properties, + } +} + +/** Every page of a list endpoint that returns all rows when asked for pages. */ +async function listAll(client: ResendClient, path: string): Promise<Row[]> { + const rows: Row[] = [] + let after: string | undefined + for (let pageCount = 0; pageCount < 1000; pageCount += 1) { + const page = await client.page<Row>( + path, + after === undefined ? {} : { after } + ) + rows.push(...page.data) + const last = text(page.data.at(-1)?.id) + if (!page.hasMore || last === null) { + break + } + after = last + } + return rows +} + +async function contactPage( + client: ResendClient, + path: string, + after: string | null +): Promise<Page<SourceContact>> { + const page = await client.page<Row>(path, after === null ? {} : { after }) + const rows = page.data + .map(contactOf) + .filter((row): row is SourceContact => row !== null) + const last = text(page.data.at(-1)?.id) + return { rows, next: page.hasMore && last !== null ? last : null } +} + +function endpointOrigin(value: unknown): string { + try { + return new URL(String(value)).origin + } catch { + return 'an invalid URL' + } +} + +export const resendSource: MigrationSource = { + id: 'resend', + displayName: 'Resend', + envKey: 'RESEND_API_KEY', + rateLimit: { requestsPerSecond: 2 }, + importPlaceholders: 'resend', + scanCode: (root, options) => scanProject(root, options), + inventory: (ctx, input): SourceInventory => { + const client = createResendClient(ctx, { + apiKey: input.apiKey, + requestsPerSecond: resendSource.rateLimit.requestsPerSecond, + ...(input.clock === undefined ? {} : { clock: input.clock }), + }) + return { + requestCount: client.requestCount, + domains: async () => + (await listAll(client, '/domains')).flatMap((row) => { + const id = text(row.id) + const name = text(row.name) + return id === null || name === null + ? [] + : [ + { + id, + name, + status: text(row.status) ?? 'unknown', + region: text(row.region), + }, + ] + }), + templates: async () => + (await listAll(client, '/templates')).flatMap((row) => { + const id = text(row.id) + return id === null + ? [] + : [ + { + id, + name: text(row.name) ?? id, + alias: text(row.alias), + status: text(row.status) ?? 'unknown', + }, + ] + }), + template: async (id) => { + const row = (await client.get( + `/templates/${encodeURIComponent(id)}` + )) as Row | undefined + if (row === undefined || row === null || typeof row !== 'object') { + return null + } + const variables = Array.isArray(row.variables) + ? (row.variables as Row[]) + : [] + const from = row.from + return { + id, + name: text(row.name) ?? id, + alias: text(row.alias), + status: text(row.status) ?? 'unknown', + html: text(row.html), + subject: text(row.subject), + from: + typeof from === 'string' + ? text(from) + : typeof from === 'object' && from !== null + ? formatFrom(from as Row) + : null, + variables: variables.flatMap((variable) => { + const key = text(variable.key) + const fallback = variable.fallback_value + return key === null + ? [] + : [ + { + key, + type: text(variable.type) ?? 'string', + fallbackValue: + fallback === null || fallback === undefined + ? null + : String(fallback), + }, + ] + }), + } + }, + contacts: (after) => contactPage(client, '/contacts', after), + segments: async () => + (await listAll(client, '/segments')).flatMap((row) => { + const id = text(row.id) + return id === null ? [] : [{ id, name: text(row.name) ?? id }] + }), + segmentContacts: (segmentId, after) => + contactPage( + client, + `/segments/${encodeURIComponent(segmentId)}/contacts`, + after + ), + topics: async () => + (await listAll(client, '/topics')).flatMap((row) => { + const id = text(row.id) + return id === null + ? [] + : [ + { + id, + name: text(row.name) ?? id, + defaultSubscription: text(row.default_subscription), + }, + ] + }), + suppressions: async (after): Promise<Page<SourceSuppression>> => { + const page = await client.page<Row>( + '/suppressions', + after === null ? {} : { after } + ) + const rows = page.data.flatMap((row) => { + const id = text(row.id) + const email = text(row.email) + return id === null || email === null + ? [] + : [{ id, email, origin: text(row.origin) ?? 'manual' }] + }) + const last = text(page.data.at(-1)?.id) + return { rows, next: page.hasMore && last !== null ? last : null } + }, + broadcasts: async () => + (await listAll(client, '/broadcasts')).flatMap((row) => { + const broadcast = broadcastOf(row) + return broadcast === null ? [] : [broadcast] + }), + broadcast: async (id) => { + const row = (await client.get( + `/broadcasts/${encodeURIComponent(id)}` + )) as Row | undefined + const broadcast = + row === undefined || row === null || typeof row !== 'object' + ? null + : broadcastOf(row) + return broadcast === null + ? null + : { + ...broadcast, + html: text((row as Row).html), + subject: text((row as Row).subject), + } + }, + contactTopics: async (contactId) => { + const choices: Array<{ topicId: string; subscribed: boolean }> = [] + let after: string | undefined + for (let pageCount = 0; pageCount < 20; pageCount += 1) { + const page = await client.page<Row>( + `/contacts/${encodeURIComponent(contactId)}/topics`, + after === undefined ? {} : { after } + ) + for (const row of page.data) { + const topicId = text(row.id) + const subscription = text(row.subscription) + if ( + topicId !== null && + (subscription === 'opt_in' || subscription === 'opt_out') + ) { + choices.push({ topicId, subscribed: subscription === 'opt_in' }) + } + } + const last = text(page.data.at(-1)?.id) + if (!page.hasMore || last === null) { + break + } + after = last + } + return choices + }, + webhooks: async () => + (await listAll(client, '/webhooks')).flatMap((row) => { + const id = text(row.id) + return id === null + ? [] + : [ + { + id, + endpointOrigin: endpointOrigin(row.endpoint), + events: Array.isArray(row.events) + ? row.events.filter( + (event): event is string => typeof event === 'string' + ) + : [], + status: text(row.status) ?? 'unknown', + }, + ] + }), + automations: async () => + (await listAll(client, '/automations')).flatMap((row) => { + const id = text(row.id) + return id === null + ? [] + : [ + { + id, + name: text(row.name) ?? id, + status: text(row.status) ?? 'unknown', + }, + ] + }), + } + }, +} + +function broadcastOf(row: Row): SourceBroadcast | null { + const id = text(row.id) + return id === null + ? null + : { + id, + name: text(row.name) ?? id, + status: text(row.status) ?? 'unknown', + segmentId: text(row.segment_id) ?? text(row.audience_id), + } +} + +function formatFrom(from: Row): string | null { + const email = text(from.email) ?? text(from.address) + if (email === null) { + return null + } + const name = text(from.name) + return name === null ? email : `${name} <${email}>` +} + +/** The registered sources, by command name. Resend only. */ +export const MIGRATION_SOURCES = { resend: resendSource } as const diff --git a/src/lib/migrate/run.ts b/src/lib/migrate/run.ts new file mode 100644 index 00000000..c5760cff --- /dev/null +++ b/src/lib/migrate/run.ts @@ -0,0 +1,243 @@ +import { createHash } from 'node:crypto' +import { BrewApiError } from '@brew.new/sdk' +import type { BrewClient } from '../client' +import { CliApiError, CliVendorError } from '../errors' +import type { NextStep } from '../init/report' +import { progress } from '../output' +import { redact } from '../project/vendor-http' +import type { CliContext } from '../types' +import type { + ItemStatus, + MigrationItem, + MigrationManifest, + MigrationWarning, + PhaseId, + PhaseState, + PhaseStatus, +} from './manifest' +import type { Finding, MigrationMode } from './report' +import { ResendAccessError } from './resend/client' +import type { MigrationSource, SourceInventory, SourceTemplate } from './source' + +/** State shared by the phases of one `migrate` run. */ + +export type BrewDomainRow = { + readonly domainId: string + readonly name: string + readonly status: string + readonly sendingPurpose: string | null +} + +export type ProjectInfo = { + readonly root: string + readonly framework: string | null + readonly typescript: boolean + readonly libDir: string | null + readonly packageManager: string | null + readonly addSdkCommand: string | null + readonly hasSdk: boolean +} + +export type MigrateOptions = { + readonly mode: MigrationMode + readonly phases: readonly PhaseId[] + readonly domain: string | undefined + readonly manifestPath: string + readonly keyPrefix: string + readonly rewriteImports: boolean +} + +export type RunContext = { + readonly ctx: CliContext + readonly source: MigrationSource + readonly options: MigrateOptions + readonly apply: boolean + readonly manifest: MigrationManifest + readonly brew: BrewClient | undefined + readonly inventory: SourceInventory | undefined + readonly project: ProjectInfo + readonly prefix: string + readonly secrets: readonly string[] + brewDomains: readonly BrewDomainRow[] | null + targetDomain: BrewDomainRow | null + /** App-email keys already in Brew; null when Brew was not read. */ + existingKeys: ReadonlySet<string> | null + readonly templates: Map<string, SourceTemplate | null> + readonly findings: Finding[] + readonly nextSteps: NextStep[] + /** Writes the manifest (no-op on a dry run). */ + readonly persist: () => void +} + +export function say(run: RunContext, message: string): void { + progress(run.ctx, message) +} + +/** `mig-<run>-<scope>-<body hash>`: a re-run replays, a changed body is new. */ +export function idempotencyKey( + run: RunContext, + scope: string, + body: unknown +): string { + const hash = createHash('sha256') + .update(JSON.stringify(body)) + .digest('hex') + .slice(0, 16) + return `mig-${run.manifest.migrationId.slice(0, 8)}-${scope.slice(0, 40)}-${hash}` +} + +/** Text that came from the user's code or a vendor, safe to print and store. */ +export function clean(run: RunContext, text: string): string { + return redact(text, run.secrets) +} + +export function phaseState(run: RunContext, id: PhaseId): PhaseState { + const existing = run.manifest.phases[id] + if (existing !== undefined) { + return existing + } + const created: PhaseState = { status: 'planned', items: [] } + run.manifest.phases[id] = created + return created +} + +/** + * The item with `item.id`, merged over what an earlier run recorded: a + * `done` item keeps its status, ids and key; anything else is re-planned + * (keeping its key, so keys stay stable across runs). + */ +export function reconcileItem( + phase: PhaseState, + item: MigrationItem +): MigrationItem { + const index = phase.items.findIndex((entry) => entry.id === item.id) + const previous = index === -1 ? undefined : phase.items[index] + let merged: MigrationItem + if (previous?.status === 'done') { + merged = { ...item, ...previous, status: 'done' } + } else { + merged = { + ...item, + ...(previous?.key === undefined || item.key === undefined + ? {} + : { key: previous.key }), + ...(previous?.cursor === undefined ? {} : { cursor: previous.cursor }), + ...(previous?.counts === undefined ? {} : { counts: previous.counts }), + ...(previous?.brew === undefined + ? {} + : { brew: { ...previous.brew, ...item.brew } }), + } + } + if (index === -1) { + phase.items.push(merged) + } else { + phase.items[index] = merged + } + return merged +} + +/** + * Whether an item keeps the key an earlier run gave it: a done item, or one + * whose setup was sent for that key (its answer may have been lost), + * always; a planned one while the key prefix is the same and Brew has no + * email by that key (a key planned before Brew's keys were read may be + * another's). + */ +export function keepsKey( + run: RunContext, + item: MigrationItem | undefined +): item is MigrationItem & { key: string } { + return ( + item?.key !== undefined && + (item.status === 'done' || + item.brew?.setUpKey === item.key || + (run.manifest.options.keyPrefix === run.options.keyPrefix && + run.existingKeys?.has(item.key) !== true)) + ) +} + +/** Drops items an earlier run planned that no longer exist (done ones stay). */ +export function pruneItems(phase: PhaseState, keep: ReadonlySet<string>): void { + phase.items = phase.items.filter( + (item) => keep.has(item.id) || item.status === 'done' + ) +} + +export function addWarning( + target: { warnings?: MigrationWarning[] }, + warning: MigrationWarning +): void { + const list = target.warnings ?? [] + if ( + !list.some( + (entry) => + entry.code === warning.code && entry.message === warning.message + ) + ) { + list.push(warning) + } + target.warnings = list +} + +/** A phase's status from its items (and its own read/apply outcome). */ +export function settlePhase( + phase: PhaseState, + input: { readonly apply: boolean; readonly failed?: boolean } +): void { + if (input.failed === true || phase.error !== undefined) { + phase.status = 'failed' + return + } + const statuses = new Set<ItemStatus>(phase.items.map((item) => item.status)) + let status: PhaseStatus + if (statuses.has('failed')) { + status = statuses.has('done') ? 'partial' : 'failed' + } else if (statuses.has('planned')) { + status = input.apply ? 'partial' : 'planned' + } else if (statuses.has('done')) { + status = 'done' + } else if (statuses.has('manual')) { + status = 'manual' + } else { + status = + phase.counts === undefined ? 'skipped' : input.apply ? 'done' : 'planned' + } + phase.status = status +} + +/** Never swallow a stop, or a Brew or Resend credential problem. */ +export function rethrowIfFatal(ctx: CliContext, error: unknown): void { + ctx.signal.throwIfAborted() + if ( + error instanceof ResendAccessError || + ((error instanceof BrewApiError || error instanceof CliApiError) && + (error.status === 401 || error.status === 403)) + ) { + throw error + } +} + +export function errorOf( + run: RunContext, + error: unknown +): { code: string; message: string } { + if (error instanceof BrewApiError || error instanceof CliApiError) { + return { code: error.code, message: clean(run, error.message) } + } + if (error instanceof CliVendorError) { + return { + code: `${error.vendor.toUpperCase()}_ERROR`, + message: clean(run, error.message), + } + } + return { + code: 'ERROR', + message: clean(run, error instanceof Error ? error.message : String(error)), + } +} + +export function addNextStep(run: RunContext, step: NextStep): void { + if (!run.nextSteps.some((existing) => existing.id === step.id)) { + run.nextSteps.push(step) + } +} diff --git a/src/lib/migrate/source.ts b/src/lib/migrate/source.ts new file mode 100644 index 00000000..980d5350 --- /dev/null +++ b/src/lib/migrate/source.ts @@ -0,0 +1,138 @@ +import type { Clock } from '../clock' +import type { CliContext } from '../types' +import type { CodeScan, CodeScanOptions } from './code/scan' + +/** + * A provider a project migrates from. Resend is the only one registered; + * the pipeline reads everything through this interface, so another source + * is a new implementation, not a new pipeline. + */ + +export type SourceDomain = { + readonly id: string + readonly name: string + readonly status: string + readonly region: string | null +} + +export type SourceTemplateSummary = { + readonly id: string + readonly name: string + readonly alias: string | null + readonly status: string +} + +export type SourceVariable = { + readonly key: string + readonly type: string + readonly fallbackValue: string | null +} + +export type SourceTemplate = SourceTemplateSummary & { + readonly html: string | null + readonly subject: string | null + readonly from: string | null + readonly variables: readonly SourceVariable[] +} + +export type SourceContact = { + /** The source's own id: the resume cursor. */ + readonly id: string + readonly email: string + readonly firstName: string | null + readonly lastName: string | null + /** null when the source says nothing about consent. */ + readonly unsubscribed: boolean | null + readonly properties: Readonly<Record<string, string | number | boolean>> +} + +export type SourceSegment = { readonly id: string; readonly name: string } + +export type SourceTopic = { + readonly id: string + readonly name: string + readonly defaultSubscription: string | null +} + +export type SourceSuppression = { + readonly id: string + readonly email: string + readonly origin: string +} + +export type SourceBroadcast = { + readonly id: string + readonly name: string + readonly status: string + readonly segmentId: string | null +} + +export type SourceBroadcastDetail = SourceBroadcast & { + readonly html: string | null + readonly subject: string | null +} + +/** One contact's choice for one topic. */ +export type SourceTopicChoice = { + readonly topicId: string + readonly subscribed: boolean +} + +export type SourceWebhook = { + readonly id: string + /** Origin only: a path or query can carry a token. */ + readonly endpointOrigin: string + readonly events: readonly string[] + readonly status: string +} + +export type SourceAutomation = { + readonly id: string + readonly name: string + readonly status: string +} + +export type Page<T> = { + readonly rows: readonly T[] + /** Pass to the next call; null when this was the last page. */ + readonly next: string | null +} + +/** Reads one account. Every call is paced to the source's rate limit. */ +export type SourceInventory = { + readonly domains: () => Promise<SourceDomain[]> + readonly templates: () => Promise<SourceTemplateSummary[]> + readonly template: (id: string) => Promise<SourceTemplate | null> + readonly contacts: (after: string | null) => Promise<Page<SourceContact>> + readonly segments: () => Promise<SourceSegment[]> + readonly segmentContacts: ( + segmentId: string, + after: string | null + ) => Promise<Page<SourceContact>> + readonly topics: () => Promise<SourceTopic[]> + readonly suppressions: ( + after: string | null + ) => Promise<Page<SourceSuppression>> + readonly broadcasts: () => Promise<SourceBroadcast[]> + readonly broadcast: (id: string) => Promise<SourceBroadcastDetail | null> + /** One request per contact: the source keeps topic choices per contact. */ + readonly contactTopics: (contactId: string) => Promise<SourceTopicChoice[]> + readonly webhooks: () => Promise<SourceWebhook[]> + readonly automations: () => Promise<SourceAutomation[]> + readonly requestCount: () => number +} + +export type MigrationSource = { + readonly id: 'resend' + readonly displayName: string + /** The environment variable the source's API key is read from. */ + readonly envKey: string + readonly rateLimit: { readonly requestsPerSecond: number } + /** The `placeholders` mode `POST /v1/emails/import` converts its templates with. */ + readonly importPlaceholders: 'resend' + readonly inventory: ( + ctx: Pick<CliContext, 'signal' | 'budget' | 'io'>, + input: { readonly apiKey: string; readonly clock?: Clock } + ) => SourceInventory + readonly scanCode: (root: string, options?: CodeScanOptions) => CodeScan +} diff --git a/src/lib/trigger-key.ts b/src/lib/trigger-key.ts index 4f91f5d7..7fbaaff8 100644 --- a/src/lib/trigger-key.ts +++ b/src/lib/trigger-key.ts @@ -1,4 +1,5 @@ import { CliUsageError } from './errors' +import { TRIGGER_KEY_PATTERN } from './project/state' /** * The trigger key grammar (brew-v2 `lib/triggers/trigger-key.ts`): 1–64 @@ -6,7 +7,7 @@ import { CliUsageError } from './errors' * ending with a letter or digit. Checked locally so a typo exits 2 before * any request instead of an API 400 (exit 1). */ -export const TRIGGER_KEY_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/ +export { TRIGGER_KEY_PATTERN } export function triggerKeyArg(value: unknown, label: string): string { const key = typeof value === 'string' ? value : '' diff --git a/src/registry.ts b/src/registry.ts index 1527688d..eff9c325 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -145,6 +145,7 @@ import { integrationsListCommand } from './commands/integrations/list' import { loginCommand } from './commands/login' import { logoutCommand } from './commands/logout' import { mcpInstallCommand } from './commands/mcp/install' +import { migrateResendCommand } from './commands/migrate/resend' import { notificationsListCommand } from './commands/notifications/list' import { sendCommand } from './commands/send' import { sendsCancelCommand } from './commands/sends/cancel' @@ -320,5 +321,6 @@ export const ALL_COMMANDS: readonly CommandSpec[] = [ docsApiCommand, mcpInstallCommand, initCommand, + migrateResendCommand, apiCommand, ] diff --git a/tests/commands/app-emails.test.ts b/tests/commands/app-emails.test.ts index a4af6f90..fb81775e 100644 --- a/tests/commands/app-emails.test.ts +++ b/tests/commands/app-emails.test.ts @@ -466,6 +466,95 @@ describe('app-emails upsert', () => { }) }) + it('passes the source import modes in --input through as given', async () => { + let body: unknown + server.use( + http.put(`${API}/v1/app-emails/order.paid`, async ({ request }) => { + body = await request.json() + return HttpResponse.json( + { ...SETUP, triggerKey: 'order.paid' }, + { status: 201 } + ) + }) + ) + // Typed against the vendored spec: brew-v2#2145 declares the modes. + const source: NonNullable<PutBody['source']> = { + format: 'jsx', + content: 'export default function E({ orderId }) { return orderId }', + props: 'merge_tags', + } + const result = await cli([ + 'app-emails', + 'upsert', + 'order.paid', + '--input', + JSON.stringify({ source }), + '--yes', + ]) + expect(result.code).toBe(0) + expect(body).toEqual({ source }) + }) + + it("--source-file fills format and content and keeps --input's source modes", async () => { + const dir = mkdtempSync(join(tmpdir(), 'brew-upsert-')) + const file = join(dir, 'order-paid.tsx') + writeFileSync(file, 'export default function E({ orderId }) {}') + const bodies: unknown[] = [] + server.use( + http.put(`${API}/v1/app-emails/:key`, async ({ request }) => { + bodies.push(await request.json()) + return HttpResponse.json(SETUP, { status: 201 }) + }) + ) + const jsx = await cli([ + 'app-emails', + 'upsert', + 'order.paid', + '--source-file', + file, + '--input', + '{"source":{"props":"merge_tags"}}', + '--yes', + ]) + const variables: NonNullable<PutBody['source']>['variables'] = [ + { key: 'INVOICE_URL', fallbackValue: 'https://acme.dev' }, + ] + // The format comes from --input's source when the file is stdin. + const html = await cli( + [ + 'app-emails', + 'upsert', + 'invoice.ready', + '--source-file', + '-', + '--input', + JSON.stringify({ + source: { format: 'html', placeholders: 'resend', variables }, + }), + '--yes', + ], + { stdin: '<p>Hi {{{FIRST_NAME|there}}}, {{{INVOICE_URL}}}</p>' } + ) + expect([jsx.code, html.code]).toEqual([0, 0]) + expect(bodies).toEqual([ + { + source: { + format: 'jsx', + content: readFileSync(file, 'utf8'), + props: 'merge_tags', + }, + }, + { + source: { + format: 'html', + content: '<p>Hi {{{FIRST_NAME|there}}}, {{{INVOICE_URL}}}</p>', + placeholders: 'resend', + variables, + }, + }, + ]) + }) + it('skips the gate with --no-publish and sends publish: false', async () => { let body: unknown server.use( diff --git a/tests/commands/migrate-resend-reruns.test.ts b/tests/commands/migrate-resend-reruns.test.ts new file mode 100644 index 00000000..bcdcf24b --- /dev/null +++ b/tests/commands/migrate-resend-reruns.test.ts @@ -0,0 +1,519 @@ +import { readFileSync, rmSync, 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 { fakeClock } from '../helpers/login-server' +import { + env, + MUTATING, + migrate, + phase, + RESEND_KEY, + readManifest, + useBrewApi, +} from '../helpers/migrate-resend' +import { server } from '../helpers/msw-server' +import { + RESEND_ACCOUNT, + RESEND_API, + type ResendAccount, + useResendApi, +} from '../helpers/resend-api' + +/** What a second `migrate resend` run does with what the first one left. */ + +const APPLY = ['--apply', '--yes', '--json'] + +function setUp(account: ResendAccount = RESEND_ACCOUNT) { + const root = copyFixture('migrate-resend') + const clock = fakeClock() + server.use(useResendApi({ key: RESEND_KEY, clock, account }).handler) + return { root, clock, run: { cwd: root, env: env(), clock } } +} + +describe('migrate resend: a re-run keeps its destination', () => { + it('refuses a manifest written for another Brew brand (exit 2)', async () => { + const { root, run } = setUp() + useBrewApi() + expect((await migrate(['--json', '--only', 'domains'], run)).code).toBe(0) + const before = readFileSync(join(root, 'brew.migration.json'), 'utf8') + useBrewApi({ brandId: 'bd_other' }) + const second = await migrate(['--json', '--only', 'domains'], run) + expect(second.code).toBe(2) + expect(second.stderr).toContain('bd_acme') + expect(readFileSync(join(root, 'brew.migration.json'), 'utf8')).toBe(before) + }) + + it('refuses another --domain than the one its unsubscribe imports resume on (exit 2), not the same one by id', async () => { + const { run } = setUp() + const domains = ['news', 'promo'].map((label) => ({ + domainId: `dom_${label}`, + name: `${label}.acme.dev`, + status: 'verified', + sendingPurpose: 'marketing', + })) + useBrewApi({ domains }) + const only = ['--only', 'suppressions', '--domain'] + expect( + (await migrate([...APPLY, ...only, 'news.acme.dev'], run)).code + ).toBe(0) + const second = await migrate([...APPLY, ...only, 'promo.acme.dev'], run) + expect(second.code).toBe(2) + expect(second.stderr).toContain('--domain news.acme.dev') + expect((await migrate([...APPLY, ...only, 'dom_news'], run)).code).toBe(0) + }) + + it('a --domain first named after the contacts moved gets their opt-outs', async () => { + const { run } = setUp() + const brew = useBrewApi() + const contacts = [...APPLY, '--only', 'contacts'] + expect((await migrate(contacts, run)).code).toBe(0) + await migrate([...contacts, '--domain', 'news.acme.dev'], run) + expect(brew.calls.map((call) => call.path)).toContain( + '/v1/domains/dom_news/unsubscribes/import' + ) + }) + + it('an apply without --domain resumes on the domain the plan recorded', async () => { + const { run } = setUp() + const brew = useBrewApi() + const only = ['--only', 'contacts'] + await migrate(['--json', ...only, '--domain', 'news.acme.dev'], run) + expect((await migrate([...APPLY, ...only], run)).code).toBe(0) + expect(brew.calls.map((call) => call.path)).toContain( + '/v1/domains/dom_news/unsubscribes/import' + ) + }) + + it('another Resend key reads the contacts again instead of skipping them as done', async () => { + const { root, clock, run } = setUp() + const brew = useBrewApi() + const contacts = [...APPLY, '--only', 'contacts'] + expect((await migrate(contacts, run)).code).toBe(0) + // A second account, read with its own key, against the same manifest. + const otherKey = 're_OTHER_account_key_0123456789' + server.use( + useResendApi({ + key: otherKey, + clock, + account: { + ...RESEND_ACCOUNT, + contacts: [ + { + id: 'ct_b1', + email: 'cy@example.org', + first_name: null, + last_name: null, + unsubscribed: false, + properties: {}, + }, + ], + }, + }).handler + ) + const before = brew.calls.length + const second = await migrate(contacts, { + ...run, + env: env({ RESEND_API_KEY: otherKey }), + }) + expect(second.code).toBe(0) + const upserted = brew.calls + .slice(before) + .filter((call) => call.method === 'POST' && call.path === '/v1/contacts') + .flatMap( + (call) => (call.body as { contacts: { email: string }[] }).contacts + ) + expect(upserted.map((row) => row.email)).toEqual(['cy@example.org']) + expect( + readFileSync(join(root, 'brew.migration.json'), 'utf8') + ).not.toContain(otherKey) + }) + + it('contacts wait while the recorded --domain cannot be read, then get their opt-outs', async () => { + const { run } = setUp() + useBrewApi() + const only = ['--only', 'contacts'] + await migrate(['--json', ...only, '--domain', 'news.acme.dev'], run) + server.use( + http.get('https://brew.new/api/v1/domains', () => + HttpResponse.json( + { error: { code: 'SERVICE_UNAVAILABLE', message: 'Try again.' } }, + { status: 503 } + ) + ) + ) + const blocked = await migrate( + [...APPLY, ...only, '--max-retries', '0'], + run + ) + expect(blocked.code).toBe(1) + expect(phase(blocked.report, 'contacts')?.status).not.toBe('done') + const brew = useBrewApi() + expect((await migrate([...APPLY, ...only], run)).code).toBe(0) + expect(brew.calls.map((call) => call.path)).toContain( + '/v1/domains/dom_news/unsubscribes/import' + ) + }) +}) + +describe('migrate resend: unfinished work is retried', () => { + it('rows Brew refuses keep the phase open until they land', async () => { + const { root, run } = setUp() + const brew = useBrewApi({ refuseRow: 'ada@example.com' }) + const first = await migrate([...APPLY, '--only', 'contacts'], run) + expect(first.code).toBe(1) + expect(phase(first.report, 'contacts')).toMatchObject({ + status: 'partial', + error: { code: 'CONTACT_ROWS_FAILED' }, + }) + + brew.heal() + const second = await migrate([...APPLY, '--only', 'contacts'], run) + expect(second.code).toBe(0) + expect(readManifest(root).phases.contacts).toMatchObject({ + status: 'done', + counts: { upserted: 1, failed: 0 }, + }) + }) + + it('a pass after refused rows sends new idempotency keys, so Brew tries them again', async () => { + const { run } = setUp() + const brew = useBrewApi({ refuseRow: 'ada@example.com' }) + const contacts = [...APPLY, '--only', 'contacts'] + await migrate(contacts, run) + const keysOf = (from: number) => + brew.calls + .slice(from) + .filter( + (call) => call.method === 'POST' && call.path === '/v1/contacts' + ) + .map((call) => call.idempotencyKey) + const first = keysOf(0) + const before = brew.calls.length + brew.heal() + await migrate(contacts, run) + const second = keysOf(before) + expect(first.length).toBeGreaterThan(0) + expect(second.some((key) => first.includes(key))).toBe(false) + // An interrupted pass (outcome unknown) keeps its keys: see the resume test. + }) + + it('a plan between an interrupted apply and its retry keeps the rows still to land', async () => { + const { root, clock, run } = setUp() + server.use(useResendApi({ key: RESEND_KEY, clock, pageSize: 1 }).handler) + // Page 1 has a refused row; page 2 interrupts the pass. + const brew = useBrewApi({ + refuseRow: 'ada@example.com', + failContact: 'bob@example.com', + }) + const contacts = ['--only', 'contacts'] + expect((await migrate([...APPLY, ...contacts], run)).code).toBe(1) + expect(readManifest(root).phases.contacts?.cursor).toBe('ct_1') + await migrate(['--json', ...contacts], run) + brew.heal() + await migrate([...APPLY, ...contacts], run) + // Ada's refused row has not landed: the phase stays open for it. + expect(readManifest(root).phases.contacts?.status).not.toBe('done') + await migrate([...APPLY, ...contacts], run) + expect(readManifest(root).phases.contacts?.status).toBe('done') + }) + + it('a topic added since the contact pass sends every contact through it again', async () => { + const { root, clock } = setUp() + useBrewApi() + const run = { cwd: root, env: env(), clock } + expect((await migrate([...APPLY, '--only', 'topics'], run)).code).toBe(0) + + const account: ResendAccount = { + ...RESEND_ACCOUNT, + topics: [ + ...RESEND_ACCOUNT.topics, + { id: 'top_2', name: 'Events', default_subscription: 'opt_out' }, + ], + contactTopics: { + ...RESEND_ACCOUNT.contactTopics, + ct_1: [{ id: 'top_2', name: 'Events', subscription: 'opt_in' }], + }, + } + server.use(useResendApi({ key: RESEND_KEY, clock, account }).handler) + const brew = useBrewApi() + expect((await migrate([...APPLY, '--only', 'topics'], run)).code).toBe(0) + expect( + brew.calls.some( + (call) => + call.path === '/v1/contacts' && + JSON.stringify(call.body).includes('"resend_topic_events":true') + ) + ).toBe(true) + }) + + it('a Resend key revoked while segment members are read asks for a new key (exit 3)', async () => { + const { run } = setUp() + server.use( + http.get(`${RESEND_API}/segments/:id/contacts`, () => + HttpResponse.json( + { + statusCode: 401, + name: 'validation_error', + message: 'API key is invalid', + }, + { status: 401 } + ) + ) + ) + useBrewApi() + const result = await migrate([...APPLY, '--only', 'segments'], run) + expect(result.code).toBe(3) + expect(result.report.status).toBe('auth_required') + expect( + result.report.nextSteps.some( + (step) => step.id === 'resend-full-access-key' + ) + ).toBe(true) + }) + + it('reuses no audience that selects other contacts', async () => { + const { run } = setUp() + const other = { + filters: [{ field: 'plan', operator: 'equals', value: 'pro' }], + logicalOperator: 'and', + } + const brew = useBrewApi({ + audiences: ['Beta users', 'Beta users (Resend)'].map((name, index) => ({ + audienceId: `aud_other_${index}`, + audienceName: name, + filters: other, + count: 3, + createdAt: '2026-10-01T00:00:00.000Z', + updatedAt: '2026-10-01T00:00:00.000Z', + })), + }) + const result = await migrate([...APPLY, '--only', 'segments'], run) + expect(result.code).toBe(1) + const item = phase(result.report, 'segments')?.items[0] + expect(item?.status).toBe('failed') + expect(item?.brew?.audienceId).toBeUndefined() + expect( + brew.calls.some( + (call) => call.method === 'POST' && call.path === '/v1/audiences' + ) + ).toBe(false) + }) +}) + +describe('migrate resend: app email keys', () => { + it('sets up nothing while Brew app emails are unreadable, then plans around the keys Brew has', async () => { + const { root, run } = setUp() + const blind = useBrewApi({ appEmailKeys: 'unreadable' }) + const first = await migrate([...APPLY, '--only', 'templates'], run) + expect(first.code).toBe(1) + expect(blind.calls.filter((call) => MUTATING.has(call.method))).toEqual([]) + expect( + readManifest(root).phases.templates?.items.map((item) => item.error?.code) + ).toEqual(['BREW_APP_EMAILS_UNREADABLE', 'BREW_APP_EMAILS_UNREADABLE']) + + // Another email holds reset-password: the template takes another key. + const brew = useBrewApi({ appEmailKeys: ['reset-password'] }) + expect((await migrate([...APPLY, '--only', 'templates'], run)).code).toBe(0) + const setUps = brew.calls + .filter((call) => call.method === 'PUT') + .map((call) => call.path) + expect(setUps).toHaveLength(2) + expect(setUps).not.toContain('/v1/app-emails/reset-password') + }) + + it('finishes a setup whose answer was lost on its own key, not a second email', async () => { + const { root, run } = setUp() + // Brew set up reset-password, but the answer never came back. + useBrewApi({ + refuseKeys: { + 'reset-password': { + status: 504, + error: { + code: 'GATEWAY_TIMEOUT', + type: 'internal', + message: 'Lost.', + }, + }, + }, + }) + const once = ['--max-retries', '0', ...APPLY, '--only', 'templates'] + expect((await migrate(once, run)).code).toBe(1) + + const brew = useBrewApi({ appEmailKeys: ['reset-password', 'receipt'] }) + expect((await migrate([...APPLY, '--only', 'templates'], run)).code).toBe(0) + expect( + brew.calls + .filter((call) => call.method === 'PUT') + .map((call) => call.path) + ).toEqual(['/v1/app-emails/reset-password']) + expect( + readManifest(root).phases.templates?.items.map((item) => item.key) + ).toContain('reset-password') + }) +}) + +describe('migrate resend --rewrite-imports: the template map', () => { + const REWRITE = [...APPLY, '--rewrite-imports', '--only', 'templates', 'code'] + const MAP = 'src/lib/brew-resend-map.ts' + /** A CommonJS job that sends a migrated template. */ + const RECEIPT_CJS = [ + "const { Resend } = require('resend')", + 'const resend = new Resend(process.env.RESEND_API_KEY)', + "module.exports = (to) => resend.emails.send({ to, template: { id: 'tmpl_receipt_1' } })", + '', + ].join('\n') + /** The account after a template was added since the clients moved. */ + const WITH_INVITE: ResendAccount = { + ...RESEND_ACCOUNT, + templates: [ + ...RESEND_ACCOUNT.templates, + { id: 'tmpl_new', name: 'Invite', alias: null, status: 'published' }, + ], + templateDetails: { + ...RESEND_ACCOUNT.templateDetails, + tmpl_new: { + id: 'tmpl_new', + name: 'Invite', + alias: null, + status: 'published', + subject: 'Join us', + from: null, + html: '<p>Join</p>', + variables: [], + }, + }, + } + + it('follows templates added after the clients moved', async () => { + const { root, clock } = setUp() + useBrewApi() + const run = { cwd: root, env: env(), clock } + expect((await migrate(REWRITE, run)).code).toBe(0) + + server.use( + useResendApi({ key: RESEND_KEY, clock, account: WITH_INVITE }).handler + ) + useBrewApi({ appEmailKeys: ['reset-password', 'receipt'] }) + await migrate(REWRITE, run) + expect(readFileSync(join(root, MAP), 'utf8')).toContain( + '"tmpl_new": "invite"' + ) + }) + + it('leaves a sender edited while the migration ran, and moves it on a re-run', async () => { + const { root, run } = setUp() + useBrewApi() + const client = join(root, 'src/lib/resend.ts') + let edited = false + // A formatter touches the client while the React Email imports run + // (after the scan); the handler falls through to the Brew mock. + server.use( + http.post( + 'https://brew.new/api/v1/emails/import', + async ({ request }) => { + const body = (await request.clone().json()) as { props?: string } + if (!edited && body.props === 'merge_tags') { + edited = true + writeFileSync( + client, + `${readFileSync(client, 'utf8')}// formatted\n` + ) + } + } + ) + ) + const first = await migrate(REWRITE, run) + expect(edited).toBe(true) + const kept = readFileSync(client, 'utf8') + expect(kept).toContain('// formatted') + expect(kept).toContain("from 'resend'") + expect( + phase(first.report, 'code')?.items?.find( + (item) => item.file === 'src/lib/resend.ts' && item.kind === 'rewrite' + ) + ).toMatchObject({ + status: 'failed', + error: { code: 'FILE_CHANGED' }, + }) + useBrewApi({ appEmailKeys: ['reset-password', 'receipt'] }) + await migrate(REWRITE, run) + const moved = readFileSync(client, 'utf8') + expect(moved).toContain("from '@brew.new/sdk/resend'") + expect(moved).toContain('// formatted') + }) + + it('counts the sends through a moved client as done on a re-run', async () => { + const { root, run } = setUp() + useBrewApi() + expect((await migrate(REWRITE, run)).code).toBe(0) + useBrewApi({ appEmailKeys: ['reset-password', 'receipt'] }) + const { report } = await migrate(REWRITE, run) + const notify = (sites: ReadonlyArray<{ file: string; status: string }>) => + sites + .filter((site) => site.file === 'src/lib/notify.ts') + .map((site) => site.status) + expect(notify(report.callSites)).toEqual(['done', 'done']) + expect(notify(readManifest(root).callSites)).toEqual(['done', 'done']) + expect( + report.nextSteps.filter((step) => step.path === 'src/lib/notify.ts') + ).toEqual([]) + }) + + it('in JavaScript, keeps the ESM map current after a CommonJS client moves', async () => { + const { root, clock } = setUp() + rmSync(join(root, 'tsconfig.json')) + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', dependencies: { next: '^16.0.0' } }) + ) + useBrewApi() + const run = { cwd: root, env: env(), clock } + expect((await migrate(REWRITE, run)).code).toBe(0) + writeFileSync(join(root, 'src/jobs/receipt.cjs'), RECEIPT_CJS) + server.use( + useResendApi({ key: RESEND_KEY, clock, account: WITH_INVITE }).handler + ) + useBrewApi({ appEmailKeys: ['reset-password', 'receipt'] }) + await migrate(REWRITE, run) + for (const map of [ + 'src/lib/brew-resend-map.mjs', + 'src/lib/brew-resend-map.cjs', + ]) { + expect(readFileSync(join(root, map), 'utf8')).toContain( + '"tmpl_new": "invite"' + ) + } + }) + + it('in TypeScript, a CommonJS job reads a .cjs map Node can load', async () => { + const { root, run } = setUp() + writeFileSync(join(root, 'src/jobs/receipt.cjs'), RECEIPT_CJS) + useBrewApi() + expect((await migrate(REWRITE, run)).code).toBe(0) + expect(readFileSync(join(root, 'src/jobs/receipt.cjs'), 'utf8')).toContain( + "require('../lib/brew-resend-map.cjs').brewResendTemplates" + ) + expect( + readFileSync(join(root, 'src/lib/brew-resend-map.cjs'), 'utf8') + ).toContain('exports.brewResendTemplates = {') + }) + + it('moves no client to a map the migration did not write', async () => { + const { root, run } = setUp() + useBrewApi() + const client = readFileSync(join(root, 'src/lib/resend.ts'), 'utf8') + writeFileSync( + join(root, MAP), + 'export const brewResendTemplates = { "reset-password": "reset" }\n' + ) + const result = await migrate(REWRITE, run) + expect(result.code).toBe(1) + expect( + result.report.findings.find((finding) => finding.code === 'MAP_FILE_KEPT') + ?.severity + ).toBe('error') + expect(readFileSync(join(root, 'src/lib/resend.ts'), 'utf8')).toBe(client) + }) +}) diff --git a/tests/commands/migrate-resend.test.ts b/tests/commands/migrate-resend.test.ts new file mode 100644 index 00000000..9fcad6c5 --- /dev/null +++ b/tests/commands/migrate-resend.test.ts @@ -0,0 +1,627 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { copyFixture } from '../helpers/fixtures' +import { fakeClock } from '../helpers/login-server' +import { + BREW_KEY, + env, + MUTATING, + migrate, + phase, + RESEND_KEY, + readManifest, + useBrewApi, +} from '../helpers/migrate-resend' +import { server } from '../helpers/msw-server' +import { + RESEND_ACCOUNT, + type ResendAccount, + useResendApi, +} from '../helpers/resend-api' + +describe('migrate resend: the plan (default)', () => { + it('reads Resend at 2 requests a second or slower, writes brew.migration.json, changes nothing', async () => { + const root = copyFixture('migrate-resend') + const clock = fakeClock() + const resend = useResendApi({ key: RESEND_KEY, clock }) + server.use(resend.handler) + const brew = useBrewApi({ + domains: [ + { + domainId: 'dom_acme', + name: 'acme.com', + status: 'verified', + sendingPurpose: 'transactional', + }, + ], + }) + const result = await migrate(['--json'], { cwd: root, env: env(), clock }) + expect(result.code).toBe(0) + const { report } = result + expect(report.status).toBe('planned') + expect(report.mode).toBe('plan') + expect(report.resend.keySource).toBe('env') + + // Nothing written to Brew; the Resend key never went there. + expect(brew.calls.filter((call) => MUTATING.has(call.method))).toEqual([]) + for (const call of brew.calls) { + expect(call.authorization).toBe(`Bearer ${BREW_KEY}`) + expect(JSON.stringify(call.body ?? null)).not.toContain(RESEND_KEY) + } + // Every Resend request carried the Resend key, never Brew's. + expect(resend.requests.length).toBeGreaterThan(5) + for (const request of resend.requests) { + expect(request.authorization).toBe(`Bearer ${RESEND_KEY}`) + } + // Paced: consecutive requests at least 500 ms apart (2 per second). + for (let index = 1; index < resend.requests.length; index++) { + const gap = + (resend.requests[index]?.at ?? 0) - + (resend.requests[index - 1]?.at ?? 0) + expect(gap).toBeGreaterThanOrEqual(500) + } + // The plan reads no contact's topic choices (one request per contact). + expect( + resend.requests.some( + (request) => + request.path.endsWith('/topics') && + request.path.startsWith('/contacts/') + ) + ).toBe(false) + + // Templates become app emails by key; aliases first. + const templates = phase(report, 'templates') + expect(templates?.items.map((item) => [item.key, item.status])).toEqual([ + ['reset-password', 'planned'], + ['receipt', 'planned'], + ]) + // The Resend domain is also in Brew: one resend._domainkey for both. + const domains = phase(report, 'domains') + expect(domains?.items[0]?.warnings?.[0]?.code).toBe('DKIM_SELECTOR_IN_USE') + expect(domains?.items[0]?.detail).toContain('mail.acme.com') + + // Contacts and suppressions are counted, never listed. + expect(phase(report, 'contacts')?.counts).toEqual({ + subscribed: 1, + unsubscribed: 1, + noConsentState: 1, + }) + expect(phase(report, 'suppressions')?.status).toBe('manual') + expect(phase(report, 'segments')?.items[0]?.brew?.fieldName).toBe( + 'resend_segment_beta_users' + ) + expect(phase(report, 'topics')?.items[0]?.brew?.fieldName).toBe( + 'resend_topic_product_updates' + ) + expect( + phase(report, 'broadcasts')?.items.map((item) => [item.name, item.status]) + ).toEqual([ + ['October news', 'planned'], + ['Launch', 'skipped'], + ]) + // Webhooks and automations are reported, never copied. + expect(phase(report, 'webhooks')?.items[0]).toMatchObject({ + status: 'manual', + name: 'https://hooks.acme.com', + }) + expect(phase(report, 'automations')?.items[0]?.status).toBe('manual') + + // The code scan found every send (not the ones in a comment, a string + // or a regular expression), and says which carry over. + expect(report.callSites).toHaveLength(5) + expect( + report.callSites.map((site) => `${site.file}:${site.method}`) + ).toEqual( + expect.arrayContaining([ + 'src/lib/notify.ts:emails.send', + 'src/app/api/signup/route.tsx:emails.send', + 'src/jobs/digest.cjs:batch.send', + 'src/jobs/digest.cjs:emails.send', + ]) + ) + const digest = report.callSites.find( + (site) => + site.file === 'src/jobs/digest.cjs' && site.method === 'emails.send' + ) + expect(digest?.unsupported).toEqual(expect.arrayContaining(['cc'])) + const code = phase(report, 'code') + expect( + code?.items + .filter((item) => item.kind === 'react-email') + .map((item) => [item.file, item.status]) + ).toEqual( + expect.arrayContaining([ + ['emails/welcome.tsx', 'planned'], + ['emails/invoice.tsx', 'manual'], + ]) + ) + // Moved by hand, it keeps its props as merge tags like the rest. + expect( + code?.items.find((item) => item.file === 'emails/invoice.tsx')?.detail + ).toContain( + `app-emails upsert invoice --source-file emails/invoice.tsx --input '{"source":{"props":"merge_tags"}}' --yes` + ) + + // The manifest is written, and the next step applies it. + const manifest = readManifest(root) + expect(manifest.brandId).toBe('bd_acme') + expect(manifest.phases.templates?.items).toHaveLength(2) + expect(report.nextSteps[0]).toMatchObject({ + id: 'apply', + blocking: true, + command: 'brew-cli migrate resend --apply --json --yes', + }) + }) + + it('--dry-run writes no manifest', async () => { + const root = copyFixture('migrate-resend') + const clock = fakeClock() + server.use(useResendApi({ key: RESEND_KEY, clock }).handler) + useBrewApi() + const result = await migrate(['--dry-run', '--json'], { + cwd: root, + env: env(), + clock, + }) + expect(result.code).toBe(0) + expect(result.report.manifest.written).toBe(false) + expect(existsSync(join(root, 'brew.migration.json'))).toBe(false) + }) + + it('--only code scans the project without a Resend key', async () => { + const root = copyFixture('migrate-resend') + useBrewApi() + const result = await migrate(['--only', 'code', '--json'], { + cwd: root, + env: env({ RESEND_API_KEY: undefined }), + clock: fakeClock(), + }) + expect(result.code).toBe(0) + expect(result.report.resend).toEqual({ keySource: 'none', requests: 0 }) + expect(result.report.callSites.length).toBeGreaterThan(0) + }) + + it('reading the account without a Resend key is a usage error (exit 2)', async () => { + const root = copyFixture('migrate-resend') + const result = await migrate(['--json'], { + cwd: root, + env: env({ RESEND_API_KEY: undefined }), + clock: fakeClock(), + }) + expect(result.code).toBe(2) + }) + + it('reads the Resend key from stdin with --resend-key-stdin', async () => { + const root = copyFixture('migrate-resend') + const clock = fakeClock() + server.use(useResendApi({ key: RESEND_KEY, clock }).handler) + useBrewApi() + const result = await migrate(['--resend-key-stdin', '--json'], { + cwd: root, + env: env({ RESEND_API_KEY: undefined }), + stdin: `${RESEND_KEY}\n`, + clock, + }) + expect(result.code).toBe(0) + expect(result.report.resend.keySource).toBe('stdin') + }) + + it('a sending-only Resend key exits 3 with a full-access next step', async () => { + const root = copyFixture('migrate-resend') + const clock = fakeClock() + server.use( + useResendApi({ + key: RESEND_KEY, + clock, + reject: { + status: 401, + body: { + statusCode: 401, + name: 'restricted_api_key', + message: 'This API key is restricted to only send emails', + }, + }, + }).handler + ) + useBrewApi() + const result = await migrate(['--json'], { cwd: root, env: env(), clock }) + expect(result.code).toBe(3) + expect(result.report.status).toBe('auth_required') + expect(result.report.findings.map((finding) => finding.code)).toContain( + 'RESEND_KEY_SENDING_ONLY' + ) + expect( + result.report.nextSteps.find( + (step) => step.id === 'resend-full-access-key' + ) + ).toMatchObject({ actor: 'human', blocking: true }) + }) +}) + +describe('migrate resend --apply', () => { + it('exits 4 without --yes off a terminal', async () => { + const root = copyFixture('migrate-resend') + const result = await migrate(['--apply', '--json'], { + cwd: root, + env: env(), + clock: fakeClock(), + }) + expect(result.code).toBe(4) + }) + + it('copies templates, React Email, contacts, segments, topics, suppressions and broadcasts', async () => { + const root = copyFixture('migrate-resend') + const clock = fakeClock() + server.use(useResendApi({ key: RESEND_KEY, clock }).handler) + const brew = useBrewApi() + const result = await migrate( + ['--apply', '--yes', '--json', '--domain', 'news.acme.dev'], + { cwd: root, env: env(), clock } + ) + expect(result.code).toBe(0) + const { report } = result + // Call sites outside templates are left for the agent. + expect(report.status).toBe('action_required') + + const writes = brew.calls.filter((call) => MUTATING.has(call.method)) + for (const call of writes) { + // Every Brew write replays safely, and never carries the Resend key. + expect(JSON.stringify(call.body ?? null)).not.toContain(RESEND_KEY) + if (call.method === 'POST') { + expect(call.idempotencyKey).toMatch(/^mig-/) + } + } + + // Resend templates: imported with the Resend placeholder mode, set up + // by key from the converted HTML, the intermediate import deleted. + const imports = writes.filter((call) => call.path === '/v1/emails/import') + expect(imports[0]?.body).toMatchObject({ + format: 'html', + title: 'Reset password', + subjectLine: 'Reset your password', + placeholders: 'resend', + variables: [{ key: 'PRODUCT', fallbackValue: 'Acme' }], + }) + const reset = writes.find( + (call) => call.path === '/v1/app-emails/reset-password' + ) + expect(reset?.body).toMatchObject({ + source: { format: 'html' }, + subject: 'Subject', + from: { localPart: 'hello', name: 'Acme' }, + publish: true, + }) + const receipt = writes.find( + (call) => call.path === '/v1/app-emails/receipt' + ) + // A draft in Resend stays unpublished in Brew. + expect(receipt?.body).toMatchObject({ + publish: false, + from: { localPart: 'billing', name: 'Acme Billing' }, + }) + // React Email: props become merge tags. + expect( + imports.find((call) => (call.body as { format: string }).format === 'jsx') + ?.body + ).toMatchObject({ + format: 'jsx', + props: 'merge_tags', + title: 'welcome', + }) + expect(writes.some((call) => call.path === '/v1/app-emails/welcome')).toBe( + true + ) + const deletes = writes.filter((call) => call.method === 'DELETE') + // Three app emails, each import deleted; the broadcast draft is kept. + expect(deletes).toHaveLength(3) + + // Contacts: subscribed with an import consent record; unsubscribed stay + // unsubscribed and join the domain's unsubscribe list; no state, left out. + const upserts = writes.filter((call) => call.path === '/v1/contacts') + const subscribed = upserts[0]?.body as { + contacts: Array<Record<string, unknown>> + consent?: { source: string } + } + expect(subscribed.contacts).toEqual([ + { + email: 'ada@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + customFields: { plan: 'pro' }, + }, + ]) + expect(subscribed.consent?.source).toBe('import') + expect(upserts[1]?.body).toEqual({ + contacts: [ + { email: 'bob@example.com', firstName: 'Bob', subscribed: false }, + ], + }) + expect(JSON.stringify(upserts.map((call) => call.body))).not.toContain( + 'carol@example.com' + ) + const optOuts = writes.filter( + (call) => call.path === '/v1/domains/dom_news/unsubscribes/import' + ) + expect(optOuts.map((call) => (call.body as { csv: string }).csv)).toEqual([ + 'email\nbob@example.com', + 'email\nbounced@example.com', + ]) + + // Segments and topics: a boolean field each, and an audience on it. A + // member keeps the state the contacts phase gives it (consent, or + // unsubscribed); one with no state is left out here too. + const consent = subscribed.consent + const segment = upserts.find((call) => + JSON.stringify(call.body).includes('"resend_segment_beta_users":true') + )?.body as { contacts: Array<Record<string, unknown>> } + expect(segment.contacts).toEqual([ + { + email: 'ada@example.com', + customFields: { resend_segment_beta_users: true }, + consent, + }, + { + email: 'bob@example.com', + customFields: { resend_segment_beta_users: true }, + subscribed: false, + }, + ]) + const topics = upserts.find((call) => + JSON.stringify(call.body).includes('resend_topic_product_updates') + )?.body as { contacts: Array<Record<string, unknown>> } + expect(topics.contacts).toEqual([ + { + email: 'ada@example.com', + customFields: { resend_topic_product_updates: true }, + consent, + }, + { + email: 'bob@example.com', + customFields: { resend_topic_product_updates: false }, + subscribed: false, + }, + ]) + const audiences = writes + .filter((call) => call.path === '/v1/audiences') + .map((call) => call.body) + expect(audiences).toEqual([ + { + name: 'Beta users', + filters: { + filters: [ + { field: 'resend_segment_beta_users', operator: 'is_true' }, + ], + logicalOperator: 'and', + }, + }, + { + name: 'Product updates (topic)', + filters: { + filters: [ + { field: 'resend_topic_product_updates', operator: 'is_true' }, + ], + logicalOperator: 'and', + }, + }, + ]) + + // Broadcasts: the draft becomes a Brew draft email; the sent one stays. + const broadcasts = phase(report, 'broadcasts') + expect(broadcasts?.items[0]).toMatchObject({ + status: 'done', + brew: { emailId: expect.stringMatching(/^eml_/) }, + }) + expect( + broadcasts?.items[0]?.warnings?.map((warning) => warning.code) + ).toContain('CAMPAIGN_TRIGGER_TAGS') + + const manifest = readManifest(root) + expect(manifest.phases.contacts).toMatchObject({ + status: 'done', + cursor: null, + }) + expect(manifest.phases.templates?.items.map((item) => item.status)).toEqual( + ['done', 'done'] + ) + // Edit-file steps carry a brew.send replacement for the remaining sends. + expect( + report.nextSteps.filter((step) => step.kind === 'edit-file').length + ).toBeGreaterThan(0) + }) + + it('leaves a key whose trigger another published automation sends from to a person', async () => { + const root = copyFixture('migrate-resend') + const clock = fakeClock() + server.use(useResendApi({ key: RESEND_KEY, clock }).handler) + const message = + '"reset-password" already starts the published automation "Reset" (aut_9), which no app-email setup made, so setting it up would send a second email on every fire. Unpublish or delete it, then run the setup again.' + useBrewApi({ + refuseKeys: { + 'reset-password': { + status: 409, + error: { + code: 'APP_EMAIL_TRIGGER_IN_USE', + type: 'conflict', + message, + details: { + triggerKey: 'reset-password', + triggerEventId: 'tri_9', + automationIds: ['aut_9'], + }, + }, + }, + }, + }) + const result = await migrate( + ['--apply', '--yes', '--json', '--only', 'templates'], + { cwd: root, env: env(), clock } + ) + expect(result.code).toBe(0) + const item = readManifest(root).phases.templates?.items.find( + (entry) => entry.key === 'reset-password' + ) + expect(item).toMatchObject({ + status: 'manual', + detail: message, + error: { code: 'APP_EMAIL_TRIGGER_IN_USE' }, + }) + // Not migrated until the person picks: the run is not complete. + expect(result.report.status).toBe('action_required') + expect( + result.report.nextSteps.find( + (step) => step.id === 'finish-template:tmpl_reset' + ) + ).toMatchObject({ actor: 'human', blocking: true }) + }) + + it('resumes an interrupted apply from the saved cursor', async () => { + const root = copyFixture('migrate-resend') + const clock = fakeClock() + const resend = useResendApi({ key: RESEND_KEY, clock, pageSize: 1 }) + server.use(resend.handler) + const brew = useBrewApi({ failContact: 'bob@example.com' }) + const first = await migrate( + ['--apply', '--yes', '--json', '--only', 'contacts'], + { cwd: root, env: env(), clock } + ) + expect(first.code).toBe(1) + expect(first.report.status).toBe('failed') + expect(readManifest(root).phases.contacts?.cursor).toBe('ct_1') + + brew.heal() + const before = resend.requests.length + const second = await migrate( + ['--apply', '--yes', '--json', '--only', 'contacts'], + { cwd: root, env: env(), clock } + ) + expect(second.code).toBe(0) + const resumed = resend.requests + .slice(before) + .filter((request) => request.path === '/contacts') + // The first page is not read again. + expect(resumed[0]?.after).toBe('ct_1') + expect(readManifest(root).phases.contacts).toMatchObject({ + status: 'done', + cursor: null, + }) + }) + + it('--rewrite-imports moves only files whose every send is a migrated template', async () => { + const root = copyFixture('migrate-resend') + const clock = fakeClock() + server.use(useResendApi({ key: RESEND_KEY, clock }).handler) + useBrewApi() + const digestBefore = readFileSync(join(root, 'src/jobs/digest.cjs'), 'utf8') + const result = await migrate( + [ + '--apply', + '--yes', + '--json', + '--rewrite-imports', + '--only', + 'templates', + 'code', + ], + { cwd: root, env: env(), clock } + ) + expect(result.code).toBe(0) + const client = readFileSync(join(root, 'src/lib/resend.ts'), 'utf8') + expect(client).toContain("from '@brew.new/sdk/resend'") + expect(client).not.toContain("from 'resend'") + const map = readFileSync(join(root, 'src/lib/brew-resend-map.ts'), 'utf8') + expect(map).toContain('"reset-password": "reset-password"') + expect(map).toContain('"tmpl_receipt_1": "receipt"') + // Raw HTML and attachments stay on Resend until rewritten by hand. + expect(readFileSync(join(root, 'src/jobs/digest.cjs'), 'utf8')).toBe( + digestBefore + ) + expect( + readFileSync(join(root, 'src/app/api/signup/route.tsx'), 'utf8') + ).toContain("from 'resend'") + const manifest = readManifest(root) + expect(Object.keys(manifest.files)).toEqual( + expect.arrayContaining([ + 'src/lib/brew-resend-map.ts', + 'src/lib/resend.ts', + ]) + ) + }) +}) + +describe('migrate resend: the SDK the rewritten files need', () => { + it.each([ + [undefined, true], + ['^12.3.0', true], + ['^13.3.0', true], + ['^13.5.0', true], + ['^13.6.0', false], + ])( + 'with %s declared, install-sdk is a next step: %s', + async (declared, expected) => { + const root = copyFixture('migrate-resend') + if (declared !== undefined) { + const path = join(root, 'package.json') + const manifest = JSON.parse(readFileSync(path, 'utf8')) as { + dependencies: Record<string, string> + } + manifest.dependencies['@brew.new/sdk'] = declared + writeFileSync(path, JSON.stringify(manifest, null, 2)) + } + const clock = fakeClock() + server.use(useResendApi({ key: RESEND_KEY, clock }).handler) + useBrewApi() + const result = await migrate( + [ + '--apply', + '--yes', + '--json', + '--rewrite-imports', + '--only', + 'templates', + 'code', + ], + { cwd: root, env: env(), clock } + ) + expect(result.code).toBe(0) + const install = result.report.nextSteps.find( + (step) => step.id === 'install-sdk' + ) + expect(install !== undefined).toBe(expected) + if (expected) { + expect(install?.command).toContain('@brew.new/sdk@^13.6.0') + } + } + ) +}) + +describe('migrate resend: report-only account', () => { + it('an empty Resend account plans nothing and still writes a manifest', async () => { + const root = copyFixture('migrate-resend') + const clock = fakeClock() + const empty: ResendAccount = { + ...RESEND_ACCOUNT, + domains: [], + templates: [], + contacts: [], + segments: [], + topics: [], + suppressions: [], + broadcasts: [], + webhooks: [], + automations: [], + } + server.use(useResendApi({ key: RESEND_KEY, clock, account: empty }).handler) + useBrewApi() + const result = await migrate(['--json', '--only', 'templates', 'topics'], { + cwd: root, + env: env(), + clock, + }) + expect(result.code).toBe(0) + expect(phase(result.report, 'templates')?.detail).toBe( + 'no templates in Resend' + ) + expect(phase(result.report, 'topics')?.detail).toBe('no topics in Resend') + }) +}) diff --git a/tests/fixtures/projects/migrate-resend/emails/components/footer.tsx b/tests/fixtures/projects/migrate-resend/emails/components/footer.tsx new file mode 100644 index 00000000..264bba9b --- /dev/null +++ b/tests/fixtures/projects/migrate-resend/emails/components/footer.tsx @@ -0,0 +1,5 @@ +import { Text } from '@react-email/components' + +export function Footer() { + return <Text>Acme Inc.</Text> +} diff --git a/tests/fixtures/projects/migrate-resend/emails/invoice.tsx b/tests/fixtures/projects/migrate-resend/emails/invoice.tsx new file mode 100644 index 00000000..7590bac3 --- /dev/null +++ b/tests/fixtures/projects/migrate-resend/emails/invoice.tsx @@ -0,0 +1,13 @@ +import { Body, Html, Text } from '@react-email/components' +import { Footer } from './components/footer' + +export default function InvoiceEmail({ total }: { total: string }) { + return ( + <Html> + <Body> + <Text>Total: {total}</Text> + <Footer /> + </Body> + </Html> + ) +} diff --git a/tests/fixtures/projects/migrate-resend/emails/welcome.tsx b/tests/fixtures/projects/migrate-resend/emails/welcome.tsx new file mode 100644 index 00000000..6ef43629 --- /dev/null +++ b/tests/fixtures/projects/migrate-resend/emails/welcome.tsx @@ -0,0 +1,13 @@ +import { Body, Button, Html, Text } from '@react-email/components' + +export default function WelcomeEmail({ name, plan = 'free' }: { name: string; plan?: string }) { + return ( + <Html> + <Body> + <Text>Welcome, {name}!</Text> + <Text>You are on the {plan} plan.</Text> + <Button href="https://acme.com/start">Get started</Button> + </Body> + </Html> + ) +} diff --git a/tests/fixtures/projects/migrate-resend/package-lock.json b/tests/fixtures/projects/migrate-resend/package-lock.json new file mode 100644 index 00000000..8d519aca --- /dev/null +++ b/tests/fixtures/projects/migrate-resend/package-lock.json @@ -0,0 +1 @@ +{ "name": "acme-app", "lockfileVersion": 3, "requires": true, "packages": {} } diff --git a/tests/fixtures/projects/migrate-resend/package.json b/tests/fixtures/projects/migrate-resend/package.json new file mode 100644 index 00000000..0922ef0a --- /dev/null +++ b/tests/fixtures/projects/migrate-resend/package.json @@ -0,0 +1,14 @@ +{ + "name": "acme-app", + "private": true, + "type": "module", + "dependencies": { + "@react-email/components": "^1.0.0", + "next": "^16.0.0", + "react": "^19.0.0", + "resend": "^6.0.0" + }, + "devDependencies": { + "typescript": "^5.9.0" + } +} diff --git a/tests/fixtures/projects/migrate-resend/src/app/api/signup/route.tsx b/tests/fixtures/projects/migrate-resend/src/app/api/signup/route.tsx new file mode 100644 index 00000000..f5916ba6 --- /dev/null +++ b/tests/fixtures/projects/migrate-resend/src/app/api/signup/route.tsx @@ -0,0 +1,18 @@ +import { Resend as ResendClient } from 'resend' +import WelcomeEmail from '../../../../emails/welcome' + +const marketing = new ResendClient(process.env.RESEND_MARKETING_KEY) +const note = "marketing.emails.send( is only text here" + +export async function POST(request: Request) { + const user = (await request.json()) as { email: string; name: string } + const greeting = <p>Don't panic: {user.name}</p> + await marketing.emails.send({ + from: 'Acme <hello@acme.com>', + to: [user.email], + subject: 'Welcome', + react: <WelcomeEmail name={user.name} plan="pro" />, + attachments: [], + }) + return Response.json({ ok: true, note, greeting: String(greeting) }) +} diff --git a/tests/fixtures/projects/migrate-resend/src/jobs/digest.cjs b/tests/fixtures/projects/migrate-resend/src/jobs/digest.cjs new file mode 100644 index 00000000..d5d17a78 --- /dev/null +++ b/tests/fixtures/projects/migrate-resend/src/jobs/digest.cjs @@ -0,0 +1,20 @@ +const { Resend } = require('resend') + +const resend = new Resend(process.env.RESEND_API_KEY) + +async function run() { + await resend.batch.send([ + { to: 'a@example.com', html: '<p>Hi</p>' }, + { to: 'b@example.com', template: { id: 'digest' } }, + ]) + await resend.emails.send({ + to: 'c@example.com', + text: 'plain text', + cc: 'boss@example.com', + scheduledAt: 'in 1 hour', + }) + const re = /resend\.emails\.send\(/g + return re +} + +module.exports = { run } diff --git a/tests/fixtures/projects/migrate-resend/src/lib/notify.ts b/tests/fixtures/projects/migrate-resend/src/lib/notify.ts new file mode 100644 index 00000000..6da4f2fe --- /dev/null +++ b/tests/fixtures/projects/migrate-resend/src/lib/notify.ts @@ -0,0 +1,18 @@ +import { resend } from './resend' + +/* resend.emails.send({ html: 'a comment, not a call' }) */ +export async function sendPasswordReset(email: string, resetUrl: string) { + return resend.emails.send({ + from: 'Acme <hello@acme.com>', + to: email, + template: { + id: 'reset-password', + variables: { RESET_URL: resetUrl }, + }, + }) +} + +export async function sendReceipt(email: string, total: string) { + const template = { id: 'tmpl_receipt_1', variables: { TOTAL: total } } + return resend.emails.send({ to: [email], template }) +} diff --git a/tests/fixtures/projects/migrate-resend/src/lib/resend.ts b/tests/fixtures/projects/migrate-resend/src/lib/resend.ts new file mode 100644 index 00000000..51813c6d --- /dev/null +++ b/tests/fixtures/projects/migrate-resend/src/lib/resend.ts @@ -0,0 +1,3 @@ +import { Resend } from 'resend' + +export const resend = new Resend(process.env.RESEND_API_KEY) diff --git a/tests/fixtures/projects/migrate-resend/tsconfig.json b/tests/fixtures/projects/migrate-resend/tsconfig.json new file mode 100644 index 00000000..068d968f --- /dev/null +++ b/tests/fixtures/projects/migrate-resend/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "jsx": "preserve", + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "paths": { "@/*": ["./src/*"] } + } +} diff --git a/tests/helpers/migrate-resend.ts b/tests/helpers/migrate-resend.ts new file mode 100644 index 00000000..258341d5 --- /dev/null +++ b/tests/helpers/migrate-resend.ts @@ -0,0 +1,289 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { HttpResponse, http } from 'msw' +import { expect } from 'vitest' +import type { MigrationManifest } from '../../src/lib/migrate/manifest' +import type { MigrationReport } from '../../src/lib/migrate/report' +import { copyFixture } from './fixtures' +import { server } from './msw-server' +import { type RunCliOptions, runCli } from './run-cli' + +/** Brew's v1 API as `migrate resend` uses it, and the run helpers its tests share. */ + +const API = 'https://brew.new/api' +export const BREW_KEY = 'brew_migrate0123456789abcdefghijklmn' +export const RESEND_KEY = 're_CANARY_resend_secret_0123456789' + +/** Everything that must never reach stdout, stderr or brew.migration.json. */ +export const SECRETS = [RESEND_KEY, BREW_KEY, 'WEBHOOKTOKEN123'] +const PII = [ + 'ada@example.com', + 'bob@example.com', + 'carol@example.com', + 'bounced@example.com', + 'Lovelace', + // Literal recipients in the fixture's code. + 'a@example.com', + 'b@example.com', + 'c@example.com', +] + +type BrewCall = { + readonly method: string + readonly path: string + readonly body: unknown + readonly idempotencyKey: string | null + readonly authorization: string | null +} + +export const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']) + +/** Brew's v1 API as the migration uses it; every call recorded. */ +export function useBrewApi( + options: { + readonly brandId?: string + readonly domains?: ReadonlyArray<Record<string, unknown>> + /** App emails Brew already has, by key; `unreadable` fails the list. */ + readonly appEmailKeys?: readonly string[] | 'unreadable' + readonly audiences?: ReadonlyArray<Record<string, unknown>> + /** Refuse the contacts upserts holding this address until healed. */ + readonly failContact?: string + /** Refuse this address's row (and land the rest) until healed. */ + readonly refuseRow?: string + /** Refuse the app email setup of these keys with this API error. */ + readonly refuseKeys?: Readonly< + Record<string, { readonly status: number; readonly error: object }> + > + } = {} +) { + const calls: BrewCall[] = [] + let imported = 0 + let healed = false + const record = async (request: Request): Promise<unknown> => { + const text = await request.text() + const body = text === '' ? undefined : (JSON.parse(text) as unknown) + calls.push({ + method: request.method, + path: new URL(request.url).pathname.replace(/^\/api/, ''), + body, + idempotencyKey: request.headers.get('idempotency-key'), + authorization: request.headers.get('authorization'), + }) + return body + } + server.use( + http.get(`${API}/v1/brand`, async ({ request }) => { + await record(request) + return HttpResponse.json({ + brandId: options.brandId ?? 'bd_acme', + domain: 'acme.com', + status: 'completed', + ready: true, + identity: { brandName: 'Acme' }, + }) + }), + http.get(`${API}/v1/domains`, async ({ request }) => { + await record(request) + return HttpResponse.json({ + data: options.domains ?? [ + { + domainId: 'dom_news', + name: 'news.acme.dev', + status: 'verified', + sendingPurpose: 'marketing', + }, + ], + pagination: { limit: 100, cursor: null, hasMore: false }, + }) + }), + http.get(`${API}/v1/app-emails`, async ({ request }) => { + await record(request) + if (options.appEmailKeys === 'unreadable') { + return HttpResponse.json( + { + error: { + code: 'INTERNAL_ERROR', + type: 'internal', + message: 'Try again.', + }, + }, + { status: 500 } + ) + } + return HttpResponse.json({ + data: (options.appEmailKeys ?? []).map((triggerKey) => ({ + triggerKey, + })), + pagination: { limit: 100, cursor: null, hasMore: false }, + }) + }), + http.post(`${API}/v1/emails/import`, async ({ request }) => { + const body = (await record(request)) as { title?: string } + imported += 1 + return HttpResponse.json( + { + emailId: `eml_${imported}`, + emailVersionId: `emv_${imported}`, + html: `<p>converted ${body.title ?? ''} {{ trigger.RESET_URL }}</p>`, + assetReport: { warnings: [{ code: 'escaping_changed' }] }, + variables: [{ path: 'trigger.RESET_URL' }], + group: null, + subjectLine: 'Subject', + }, + { status: 201 } + ) + }), + http.put(`${API}/v1/app-emails/:key`, async ({ request, params }) => { + const body = (await record(request)) as { publish?: boolean } + const key = String(params.key) + const refusal = options.refuseKeys?.[key] + if (refusal !== undefined) { + return HttpResponse.json( + { error: refusal.error }, + { status: refusal.status } + ) + } + return HttpResponse.json({ + triggerKey: key, + triggerEventId: `tri_${key}`, + emailId: `eml_app_${key}`, + emailVersionId: 'emv_app', + automationId: `aut_${key}`, + published: body.publish !== false, + created: { trigger: true, email: true, automation: true }, + sender: { mode: 'sandbox', from: 'acme@email.brew.new' }, + 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: [], + }) + }), + http.delete(`${API}/v1/emails/:emailId`, async ({ request }) => { + await record(request) + return HttpResponse.json({ deleted: true }) + }), + http.post(`${API}/v1/contacts`, async ({ request }) => { + const body = (await record(request)) as { + contacts: Array<{ email: string }> + } + if ( + options.failContact !== undefined && + !healed && + body.contacts.some((row) => row.email === options.failContact) + ) { + return HttpResponse.json( + { + error: { + code: 'INVALID_PAYLOAD', + type: 'invalid_request', + message: 'Upsert refused.', + }, + }, + { status: 422 } + ) + } + const refused = healed + ? [] + : body.contacts.flatMap((row, index) => + row.email === options.refuseRow + ? [ + { + email: row.email, + row: index + 1, + code: 'FIELD_TYPE_MISMATCH', + field: 'customFields.plan', + message: 'plan is a number in Brew.', + }, + ] + : [] + ) + return HttpResponse.json({ + summary: { + inserted: body.contacts.length - refused.length, + updated: 0, + failed: refused.length, + }, + fieldsCreated: [], + errors: refused, + warnings: [], + }) + }), + http.get(`${API}/v1/audiences`, async ({ request }) => { + await record(request) + return HttpResponse.json({ + data: options.audiences ?? [], + pagination: { limit: 100, cursor: null, hasMore: false }, + }) + }), + http.post(`${API}/v1/audiences`, async ({ request }) => { + const body = (await record(request)) as { name: string } + return HttpResponse.json( + { audienceId: `aud_${body.name.length}`, audienceName: body.name }, + { status: 201 } + ) + }), + http.post( + `${API}/v1/domains/:domainId/unsubscribes/import`, + async ({ request }) => { + const body = (await record(request)) as { csv: string } + const rows = body.csv.split('\n').length - 1 + return HttpResponse.json({ + summary: { + added: rows, + created: 0, + alreadyUnsubscribed: 0, + skipped: 0, + }, + }) + } + ) + ) + return { + calls, + heal: () => { + healed = true + }, + } +} + +export function env( + extra: Readonly<Record<string, string | undefined>> = {} +): Readonly<Record<string, string | undefined>> { + return { + BREW_CLI_CONFIG_DIR: join(copyFixture('no-package-json'), '.config'), + BREW_API_KEY: BREW_KEY, + RESEND_API_KEY: RESEND_KEY, + ...extra, + } +} + +export async function migrate( + argv: readonly string[], + options: RunCliOptions & { readonly cwd: string } +) { + const result = await runCli(['migrate', 'resend', ...argv], options) + // Secret canary: neither key (nor a webhook token) ever reaches the terminal. + for (const secret of SECRETS) { + expect(result.stdout).not.toContain(secret) + expect(result.stderr).not.toContain(secret) + } + return { ...result, report: result.json as MigrationReport } +} + +export function readManifest(root: string): MigrationManifest { + const text = readFileSync(join(root, 'brew.migration.json'), 'utf8') + // The manifest is safe to commit: no key, token, address or name. + for (const value of [...SECRETS, ...PII]) { + expect(text).not.toContain(value) + } + return JSON.parse(text) as MigrationManifest +} + +export function phase(report: MigrationReport, id: string) { + return report.phases.find((entry) => entry.id === id) +} diff --git a/tests/helpers/resend-api.ts b/tests/helpers/resend-api.ts new file mode 100644 index 00000000..3f639a1b --- /dev/null +++ b/tests/helpers/resend-api.ts @@ -0,0 +1,244 @@ +import { HttpResponse, http } from 'msw' +import type { Clock } from '../../src/lib/clock' + +/** + * A Resend account behind `https://api.resend.com`, for `migrate resend`. + * Lists page with `limit` / `after` like the real API, every request is + * recorded with the clock's time (to check the pacing), and the + * authorization header is checked against the expected key. + */ + +export const RESEND_API = 'https://api.resend.com' + +type Row = Record<string, unknown> + +export type ResendAccount = { + readonly domains: Row[] + readonly templates: Row[] + readonly templateDetails: Record<string, Row> + readonly contacts: Row[] + readonly segments: Row[] + readonly segmentContacts: Record<string, Row[]> + readonly topics: Row[] + readonly contactTopics: Record<string, Row[]> + readonly suppressions: Row[] + readonly broadcasts: Row[] + readonly broadcastDetails: Record<string, Row> + readonly webhooks: Row[] + readonly automations: Row[] +} + +export const RESEND_ACCOUNT: ResendAccount = { + domains: [ + { id: 'rdom_1', name: 'acme.com', status: 'verified', region: 'us-east-1' }, + ], + templates: [ + { + id: 'tmpl_reset', + name: 'Reset password', + alias: 'reset-password', + status: 'published', + }, + { + id: 'tmpl_receipt_1', + name: 'Receipt', + alias: null, + status: 'draft', + }, + ], + templateDetails: { + tmpl_reset: { + id: 'tmpl_reset', + name: 'Reset password', + alias: 'reset-password', + status: 'published', + subject: 'Reset your password', + from: 'Acme <hello@acme.com>', + html: '<p>Hi {{{FIRST_NAME|there}}}, <a href="{{{RESET_URL}}}">reset</a></p>', + variables: [ + { key: 'RESET_URL', type: 'string', fallback_value: null }, + { key: 'PRODUCT', type: 'string', fallback_value: 'Acme' }, + ], + }, + tmpl_receipt_1: { + id: 'tmpl_receipt_1', + name: 'Receipt', + alias: null, + status: 'draft', + subject: 'Your receipt', + from: { name: 'Acme Billing', email: 'billing@acme.com' }, + html: '<p>Total: {{{TOTAL}}}</p>', + variables: [{ key: 'TOTAL', type: 'string', fallback_value: null }], + }, + }, + contacts: [ + { + id: 'ct_1', + email: 'ada@example.com', + first_name: 'Ada', + last_name: 'Lovelace', + unsubscribed: false, + properties: { plan: { value: 'pro' } }, + }, + { + id: 'ct_2', + email: 'bob@example.com', + first_name: 'Bob', + last_name: null, + unsubscribed: true, + }, + { id: 'ct_3', email: 'carol@example.com', first_name: 'Carol' }, + ], + segments: [{ id: 'seg_1', name: 'Beta users' }], + segmentContacts: { + seg_1: [ + { id: 'ct_1', email: 'ada@example.com', unsubscribed: false }, + { id: 'ct_2', email: 'bob@example.com', unsubscribed: true }, + { id: 'ct_3', email: 'carol@example.com' }, + ], + }, + topics: [ + { id: 'top_1', name: 'Product updates', default_subscription: 'opt_in' }, + ], + contactTopics: { + ct_1: [{ id: 'top_1', name: 'Product updates', subscription: 'opt_in' }], + ct_2: [{ id: 'top_1', name: 'Product updates', subscription: 'opt_out' }], + }, + suppressions: [ + { id: 'sup_1', email: 'bounced@example.com', origin: 'bounce' }, + ], + broadcasts: [ + { id: 'bc_1', name: 'October news', status: 'draft', segment_id: 'seg_1' }, + { id: 'bc_2', name: 'Launch', status: 'sent', segment_id: 'seg_1' }, + ], + broadcastDetails: { + bc_1: { + id: 'bc_1', + name: 'October news', + status: 'draft', + segment_id: 'seg_1', + subject: 'October news', + html: '<p>Hi {{{FIRST_NAME|there}}}</p><a href="{{{RESEND_UNSUBSCRIBE_URL}}}">Unsubscribe</a>', + }, + }, + webhooks: [ + { + id: 'wh_1', + endpoint: 'https://hooks.acme.com/resend?token=WEBHOOKTOKEN123', + events: ['email.sent', 'email.bounced'], + status: 'enabled', + }, + ], + automations: [{ id: 'auto_1', name: 'Onboarding', status: 'enabled' }], +} + +export type ResendRequest = { + readonly path: string + readonly after: string | null + readonly at: number + readonly authorization: string | null +} + +/** Rows after `after`, `limit` at a time, as Resend pages them. */ +function pageOf(rows: readonly Row[], url: URL) { + const limit = Number(url.searchParams.get('limit') ?? '100') + const after = url.searchParams.get('after') + const start = + after === null ? 0 : rows.findIndex((row) => row.id === after) + 1 + const data = rows.slice(start, start + limit) + return { + object: 'list', + has_more: start + limit < rows.length, + data, + } +} + +export function useResendApi(input: { + readonly key: string + readonly clock: Clock + readonly account?: ResendAccount + /** Rows per page (the CLI asks for 100; a smaller page tests paging). */ + readonly pageSize?: number + /** Answer every request with this status and body instead. */ + readonly reject?: { readonly status: number; readonly body: Row } +}) { + const account = input.account ?? RESEND_ACCOUNT + const requests: ResendRequest[] = [] + const list = (rows: readonly Row[]) => (url: URL) => { + const sized = new URL(url) + if (input.pageSize !== undefined) { + sized.searchParams.set('limit', String(input.pageSize)) + } + return pageOf(rows, sized) + } + const routes: Array<[RegExp, (url: URL, match: RegExpExecArray) => unknown]> = + [ + [/^\/domains$/, list(account.domains)], + [/^\/templates$/, list(account.templates)], + [ + /^\/templates\/([^/]+)$/, + (_url, match) => account.templateDetails[match[1] ?? ''], + ], + [/^\/contacts$/, list(account.contacts)], + [ + /^\/contacts\/([^/]+)\/topics$/, + (url, match) => list(account.contactTopics[match[1] ?? ''] ?? [])(url), + ], + [/^\/segments$/, list(account.segments)], + [ + /^\/segments\/([^/]+)\/contacts$/, + (url, match) => + list(account.segmentContacts[match[1] ?? ''] ?? [])(url), + ], + [/^\/topics$/, list(account.topics)], + [/^\/suppressions$/, list(account.suppressions)], + [/^\/broadcasts$/, list(account.broadcasts)], + [ + /^\/broadcasts\/([^/]+)$/, + (_url, match) => account.broadcastDetails[match[1] ?? ''], + ], + [/^\/webhooks$/, list(account.webhooks)], + [/^\/automations$/, list(account.automations)], + ] + const handler = http.get(`${RESEND_API}/*`, ({ request }) => { + const url = new URL(request.url) + requests.push({ + path: url.pathname, + after: url.searchParams.get('after'), + at: input.clock.now(), + authorization: request.headers.get('authorization'), + }) + if (request.headers.get('authorization') !== `Bearer ${input.key}`) { + return HttpResponse.json( + { + statusCode: 401, + name: 'validation_error', + message: 'API key is invalid', + }, + { status: 401 } + ) + } + if (input.reject !== undefined) { + return HttpResponse.json(input.reject.body, { + status: input.reject.status, + }) + } + for (const [pattern, answer] of routes) { + const match = pattern.exec(url.pathname) + if (match !== null) { + const body = answer(url, match) + return body === undefined + ? HttpResponse.json( + { statusCode: 404, name: 'not_found', message: 'Not found' }, + { status: 404 } + ) + : HttpResponse.json(body) + } + } + return HttpResponse.json( + { statusCode: 404, name: 'not_found', message: 'Not found' }, + { status: 404 } + ) + }) + return { handler, requests } +} diff --git a/tests/lib/migrate/code.test.ts b/tests/lib/migrate/code.test.ts new file mode 100644 index 00000000..d87bd1ea --- /dev/null +++ b/tests/lib/migrate/code.test.ts @@ -0,0 +1,382 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import ts from 'typescript' +import { describe, expect, it } from 'vitest' +import { analyzeArgument } from '../../../src/lib/migrate/code/analyze' +import { + decideRewrites, + mapFilePath, + rewriteFile, +} from '../../../src/lib/migrate/code/rewrite' +import { type CodeScan, scanProject } from '../../../src/lib/migrate/code/scan' +import { tempProject } from '../../helpers/fixtures' + +function project( + files: Readonly<Record<string, string>>, + engine: 'tokenizer' | 'typescript' = 'tokenizer' +): CodeScan { + const root = tempProject('brew-cli-migrate-code-') + for (const [file, text] of Object.entries(files)) { + mkdirSync(dirname(join(root, file)), { recursive: true }) + writeFileSync(join(root, file), text) + } + return scanProject(root, { + typescript: engine === 'typescript' ? ts : null, + }) +} + +function analyze(argument: string) { + return analyzeArgument( + argument, + { start: 0, end: argument.length }, + 'send.ts' + ) +} + +function rewritten(scan: CodeScan, file: string, mapFile: string): string { + const scanned = scan.files.get(file) + if (scanned === undefined) { + throw new Error(`${file} was not scanned`) + } + return rewriteFile({ file, scanned, mapFile }) +} + +const CLIENT = `import { Resend } from 'resend' +export const resend = new Resend(process.env.RESEND_API_KEY) +` +const DEFAULT_CLIENT = `import { Resend } from 'resend' +const resend = new Resend(process.env.RESEND_API_KEY) +export default resend +` + +describe('analyzeArgument', () => { + it('a spread beside a template may carry anything: it does not carry over', () => { + const [message] = analyze( + "{ to, template: { id: 'receipt' }, ...options }" + ).messages + expect(message?.kind).toBe('template') + expect(message?.unsupported).toContain('...spread') + }) + + it('a spread inside template after its id may replace it: the id is computed', () => { + const [after] = analyze( + "{ to, template: { id: 'welcome', ...options } }" + ).messages + expect(after?.templateId).toBeNull() + const [before] = analyze( + "{ to, template: { ...options, id: 'welcome' } }" + ).messages + expect(before?.templateId).toBe('welcome') + }) + + it('reads a literal computed name, and a name it cannot read may be any field', () => { + const [literal] = analyze( + "{ to, template: { id: 'receipt' }, ['cc']: manager }" + ).messages + expect(literal?.unsupported).toContain('cc') + const [computed] = analyze( + "{ to, template: { id: 'receipt' }, [field]: manager }" + ).messages + expect(computed?.unsupported).not.toEqual([]) + }) + + it('an empty batch is one unknown message', () => { + const analysis = analyze('[]') + expect(analysis.kind).toBe('unknown') + expect(analysis.messages).toHaveLength(1) + }) +}) + +describe('decideRewrites', () => { + it('a client whose key is a local RESEND_API_KEY binding stays on Resend', () => { + const scan = project({ + 'src/mail.ts': `import { Resend } from 'resend' +const { RESEND_API_KEY } = process.env +const resend = new Resend(RESEND_API_KEY) +export const go = (to: string) => resend.emails.send({ to, template: { id: 'welcome' } }) +`, + }) + expect(decideRewrites(scan, new Set(['welcome']), true)).toEqual([ + expect.objectContaining({ file: 'src/mail.ts', rewritable: false }), + ]) + }) + + it('a send whose client the scan cannot follow keeps every client on Resend', () => { + const scan = project({ + 'src/resend.ts': CLIENT, + 'src/index.ts': "export { resend } from './resend'\n", + 'src/send.ts': `import { resend } from './index' +export const go = () => resend.emails.send({ to: 'x', html: '<p>Hi</p>' }) +`, + }) + expect(decideRewrites(scan, new Set(), true)).toEqual([ + expect.objectContaining({ file: 'src/resend.ts', rewritable: false }), + ]) + }) + + it('a file too big to parse that mentions Resend leaves the scan incomplete, and nothing moves', () => { + const scan = project({ + 'src/resend.ts': CLIENT, + 'src/notify.ts': `import { resend } from './resend' +export const go = () => resend.emails.send({ to: 'x', template: { id: 'welcome' } }) +`, + 'src/generated.ts': `${'// filler\n'.repeat(110_000)}resend.emails.send({ html })\n`, + }) + expect(scan.truncated).toBe(true) + expect(decideRewrites(scan, new Set(['welcome']), true)).toEqual([ + expect.objectContaining({ file: 'src/resend.ts', rewritable: false }), + ]) + }) + + it.each([ + [ + 'in its own file', + `${CLIENT}export const add = () => resend.contacts.create({ email: 'x' })\n`, + '', + ], + [ + 'through an import', + CLIENT, + "import { resend } from './resend'\nexport const add = () => resend.contacts.create({ email: 'x' })\n", + ], + ])( + 'a client used for more than sends stays on Resend (%s)', + (_case, client, other) => { + const scan = project({ + 'src/resend.ts': client, + 'src/notify.ts': `import { resend } from './resend' +export const go = () => resend.emails.send({ to: 'x', template: { id: 'welcome' } }) +${other}`, + }) + expect(decideRewrites(scan, new Set(['welcome']), true)).toEqual([ + expect.objectContaining({ file: 'src/resend.ts', rewritable: false }), + ]) + } + ) + + it.each([ + [ + 'export { Resend }', + `${CLIENT}export const go = () => resend.emails.send({ to: 'x', template: { id: 'welcome' } }) +export { Resend } +`, + ], + [ + 'module.exports', + `const { Resend } = require('resend') +const resend = new Resend(process.env.RESEND_API_KEY) +const go = () => resend.emails.send({ to: 'x', template: { id: 'welcome' } }) +module.exports = { go, Resend } +`, + ], + ])( + 'a file that exports the Resend class stays on Resend (%s)', + (_case, client) => { + const scan = project({ + 'src/resend.js': client, + // Builds its own client from the exported class: a rewrite would + // hand it Brew's client with the Resend key and no template map. + 'src/other.js': `const { Resend } = require('./resend') +const mailer = new Resend(process.env.RESEND_API_KEY) +module.exports = { mailer } +`, + }) + expect(decideRewrites(scan, new Set(['welcome']), true)).toEqual([ + { + file: 'src/resend.js', + rewritable: false, + reason: expect.stringContaining('exports the Resend class'), + }, + ]) + } + ) + + it('a comment or a string naming another client member moves the client anyway', () => { + const scan = project({ + 'src/resend.ts': CLIENT, + 'src/notify.ts': `import { resend } from './resend' +// resend.contacts.create({ email }) once the list exists +const hint = 'resend.emails.get(id)' + \`resend.contacts.list()\` +export const go = () => resend.emails.send({ to: 'x', template: { id: 'welcome' } }) +`, + }) + expect(decideRewrites(scan, new Set(['welcome']), true)).toEqual([ + { file: 'src/resend.ts', rewritable: true }, + ]) + }) + + it('a contact-only caller that never names Resend keeps the client on Resend', () => { + const scan = project({ + 'src/mail.ts': DEFAULT_CLIENT, + 'src/notify.ts': `import mailer from './mail' +export const go = () => mailer.emails.send({ to: 'x', template: { id: 'welcome' } }) +`, + 'src/contacts.ts': `import mailer from './mail' +export const add = () => mailer.contacts.create({ email: 'x' }) +`, + }) + expect(decideRewrites(scan, new Set(['welcome']), true)).toEqual([ + { + file: 'src/mail.ts', + rewritable: false, + reason: expect.stringContaining('src/contacts.ts uses the client'), + }, + ]) + }) + + describe.each(['tokenizer', 'typescript'] as const)( + 'computed member access (%s)', + (engine) => { + it.each([ + "resend['emails'].send", + 'resend["emails"]["send"]', + "resend.batch['send']", + ])('%s is a send the scan reads', (callee) => { + const scan = project( + { + 'src/resend.ts': CLIENT, + 'src/notify.ts': `import { resend } from './resend' +export const go = () => ${callee}({ to: 'x', html: '<p>Hi</p>' }) +`, + }, + engine + ) + expect(scan.callSites).toEqual([ + expect.objectContaining({ + file: 'src/notify.ts', + method: callee.includes('batch') ? 'batch.send' : 'emails.send', + }), + ]) + expect(decideRewrites(scan, new Set(['welcome']), true)).toEqual([ + expect.objectContaining({ + file: 'src/resend.ts', + rewritable: false, + reason: expect.stringContaining('not a template'), + }), + ]) + }) + + it.each([ + "resend['contacts'].create({ email: 'x' })", + 'resend[kind].send({ to: "x" })', + "resend?.['emails'].get('id')", + ])('%s is more than a send: the client stays on Resend', (use) => { + const scan = project( + { + 'src/resend.ts': CLIENT, + 'src/notify.ts': `import { resend } from './resend' +const kind = 'emails' +export const go = () => resend.emails.send({ to: 'x', template: { id: 'welcome' } }) +export const other = () => ${use} +`, + }, + engine + ) + expect(decideRewrites(scan, new Set(['welcome']), true)).toEqual([ + expect.objectContaining({ + file: 'src/resend.ts', + rewritable: false, + reason: expect.stringContaining('uses the client for more than'), + }), + ]) + }) + + it.each([ + "mailer['emails'].send", + 'mailer["batch"]["send"]', + 'mailer[`emails`] . send', + ])('%s in a file that never names Resend is scanned too', (callee) => { + const scan = project( + { + 'src/mail.ts': DEFAULT_CLIENT, + 'src/notify.ts': `import mailer from './mail' +export const go = () => ${callee}({ to: 'x', html: '<p>Hi</p>' }) +`, + }, + engine + ) + expect(scan.callSites).toEqual([ + expect.objectContaining({ file: 'src/notify.ts' }), + ]) + expect(decideRewrites(scan, new Set(['welcome']), true)).toEqual([ + expect.objectContaining({ + file: 'src/mail.ts', + rewritable: false, + reason: expect.stringContaining('not a template'), + }), + ]) + }) + + it("a template send through resend['emails'] still moves the client", () => { + const scan = project( + { + 'src/resend.ts': CLIENT, + 'src/notify.ts': `import { resend } from './resend' +export const go = () => resend['emails'].send({ to: 'x', template: { id: 'welcome' } }) +`, + }, + engine + ) + expect(decideRewrites(scan, new Set(['welcome']), true)).toEqual([ + { file: 'src/resend.ts', rewritable: true }, + ]) + }) + } + ) + + it('an import of another module is not the client', () => { + const scan = project({ + 'src/mail.ts': DEFAULT_CLIENT, + 'src/logger.ts': 'export default { info: (_: string) => {} }\n', + 'src/notify.ts': `import mailer from './mail' +import logger from './logger' +export const go = () => { + logger.info('sending') + return mailer.emails.send({ to: 'x', template: { id: 'welcome' } }) +} +`, + }) + expect(decideRewrites(scan, new Set(['welcome']), true)).toEqual([ + { file: 'src/mail.ts', rewritable: true }, + ]) + }) +}) + +describe('rewriteFile', () => { + it('a JavaScript ESM map is .mjs, so Node loads it as ESM without "type": "module"', () => { + const scan = project({ 'src/send.mjs': CLIENT }) + const map = mapFilePath('lib', false, 'esm') + expect(map).toBe('lib/brew-resend-map.mjs') + expect(rewritten(scan, 'src/send.mjs', map)).toContain( + "import { brewResendTemplates } from '../lib/brew-resend-map.mjs'" + ) + }) + + it('a CommonJS map is .cjs, and its client reads it where the client is built', () => { + const scan = project({ + 'job.cjs': + "const { Resend } = require('resend'); const resend = new Resend(process.env.RESEND_API_KEY)\nmodule.exports = { resend }\n", + }) + const map = mapFilePath('lib', false, 'cjs') + expect(map).toBe('lib/brew-resend-map.cjs') + expect(rewritten(scan, 'job.cjs', map)).toBe( + "const { Resend } = require('@brew.new/sdk/resend'); const resend = new Resend(process.env.BREW_API_KEY, { templates: require('./lib/brew-resend-map.cjs').brewResendTemplates })\nmodule.exports = { resend }\n" + ) + }) + + it('puts the map import on its own line after a one-line import and client', () => { + const scan = project({ + 'send.ts': + "import { Resend } from 'resend'; const resend = new Resend(process.env.RESEND_API_KEY);\nexport { resend }\n", + }) + expect( + rewritten(scan, 'send.ts', mapFilePath(null, true, 'esm')).split('\n') + ).toEqual([ + "import { Resend } from '@brew.new/sdk/resend';", + "import { brewResendTemplates } from './lib/brew-resend-map'", + ' const resend = new Resend(process.env.BREW_API_KEY, { templates: brewResendTemplates });', + 'export { resend }', + '', + ]) + }) +}) diff --git a/tests/lib/migrate/keys.test.ts b/tests/lib/migrate/keys.test.ts new file mode 100644 index 00000000..c716cd22 --- /dev/null +++ b/tests/lib/migrate/keys.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { + deriveKey, + keyPrefixProblem, + slugifyFileName, +} from '../../../src/lib/migrate/keys' +import { TRIGGER_KEY_PATTERN } from '../../../src/lib/project/state' + +function key(base: string, prefix = '', taken = new Set<string>()): string { + return deriveKey({ base, prefix, taken, fallback: 'template' }) +} + +describe('migration keys', () => { + it.each([ + ['Reset Password!', 'reset-password'], + ['reset-password', 'reset-password'], + ['Ünïcödé Welcome', 'unicode-welcome'], + ['auth.password-reset', 'auth.password-reset'], + ['auth.reset', 'auth-reset'], + ['brew.digest', 'brew-digest'], + ['kit', 'kit-email'], + ['by-key', 'by-key-email'], + ['!!!', 'template'], + ])('%s → %s', (base, expected) => { + const derived = key(base) + expect(derived).toBe(expected) + expect(TRIGGER_KEY_PATTERN.test(derived)).toBe(true) + }) + + it('keeps keys unique within a run, and under 64 characters', () => { + const taken = new Set<string>() + expect(key('Welcome', '', taken)).toBe('welcome') + expect(key('welcome', '', taken)).toBe('welcome-2') + const long = key('x'.repeat(80), 'resend.', taken) + expect(long.length).toBeLessThanOrEqual(64) + expect(long.startsWith('resend.')).toBe(true) + }) + + it('a second item on a canonical kit key gets a key outside auth.*', () => { + const taken = new Set<string>() + expect(key('auth.password-reset', '', taken)).toBe('auth.password-reset') + const second = key('auth.password-reset', '', taken) + expect(second).toBe('auth-password-reset-2') + expect(key('auth.password-reset', '', taken)).toBe('auth-password-reset-3') + expect(second.startsWith('auth.')).toBe(false) + }) + + it('names React Email files in kebab case', () => { + expect(slugifyFileName('OrderShipped')).toBe('order-shipped') + expect(slugifyFileName('HTMLDigest')).toBe('html-digest') + }) + + it('refuses reserved or malformed prefixes', () => { + expect(keyPrefixProblem('resend.')).toBeUndefined() + expect(keyPrefixProblem('brew.')).toContain('reserved') + expect(keyPrefixProblem('auth.')).toContain('reserved') + expect(keyPrefixProblem('Resend')).toBeDefined() + }) +})