From dd702c4d0958630cbb8f99ef0673e01b89ea67e0 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Fri, 16 Oct 2026 08:00:29 -0400 Subject: [PATCH 01/31] feat(migrate): `migrate resend` moves a Resend account and codebase to Brew MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan by default, `--apply` to execute (destructive class). The Resend key comes from RESEND_API_KEY or --resend-key-stdin, rides only requests to api.resend.com (paced to 2 a second, 429s waited out) and never reaches Brew, the terminal or the manifest. - Templates → app emails by key: imported with `placeholders: "resend"` and their variables' fallbacks, set up by key from the converted HTML, published where they were; the intermediate import is deleted. - React Email files → app emails with `props: "merge_tags"`. - Contacts → upsertMany (subscribed with an import consent record, unsubscribed stay unsubscribed, no state left out); unsubscribed ones and suppressions → `--domain`'s unsubscribe list. - Segments and topics → a boolean contact field each plus an audience; topic choices are read per contact. - Draft broadcasts → Brew draft emails (nothing is sent); sent ones, domains, webhooks (origin only) and automations are reported. - A Resend domain also in Brew gets the DKIM_SELECTOR_IN_USE advice (both sign with resend._domainkey) and a subdomain to verify instead. - The code scan finds every `emails.send` / `batch.send` (not those in comments, strings or regexes), says what does not carry over, and suggests `getBrew().send(key, payload)`; `--rewrite-imports` moves a file to `@brew.new/sdk/resend` only when every send through it is a migrated template. - `brew.migration.json`: ids, keys, counts and cursors (never an address, a name or a key), so an interrupted apply resumes. Every Brew write carries an idempotency key. - Sources implement `MigrationSource`; Resend is the only one. - The `brew-migrate-from-resend` skill; init installs it only where the project uses Resend. `lib/trigger-key.ts` reuses the one grammar in `lib/project/state.ts`. MSW tests cover the plan (no Brew writes, ≥500 ms between Resend requests, no PII or secret in the manifest), --dry-run, --only code without a key, the sending-only key (exit 3), --apply across every phase, resume from a saved cursor, and --rewrite-imports; every run checks stdout and stderr for the Resend key, the Brew key and a webhook token. Co-Authored-By: Claude Opus 5.5 --- docs/commands/README.md | 25 + skills/brew-migrate-from-resend/SKILL.md | 59 + src/cli.ts | 2 + src/commands/migrate/resend.ts | 173 +++ src/generated/skills.ts | 6 + src/lib/init/pipeline.ts | 10 +- src/lib/migrate/code/analyze.ts | 370 ++++++ src/lib/migrate/code/locate-typescript.ts | 427 +++++++ src/lib/migrate/code/locate.ts | 741 +++++++++++ src/lib/migrate/code/rewrite.ts | 280 +++++ src/lib/migrate/code/scan.ts | 400 ++++++ src/lib/migrate/code/tokenize.ts | 588 +++++++++ src/lib/migrate/code/types.ts | 74 ++ src/lib/migrate/import-setup.ts | 193 +++ src/lib/migrate/keys.ts | 120 ++ src/lib/migrate/manifest.ts | 212 ++++ src/lib/migrate/phase-code.ts | 575 +++++++++ src/lib/migrate/phases.ts | 1079 +++++++++++++++++ src/lib/migrate/pipeline.ts | 529 ++++++++ src/lib/migrate/report.ts | 178 +++ src/lib/migrate/resend/client.ts | 158 +++ src/lib/migrate/resend/source.ts | 329 +++++ src/lib/migrate/run.ts | 221 ++++ src/lib/migrate/source.ts | 138 +++ src/lib/trigger-key.ts | 3 +- src/registry.ts | 2 + tests/commands/migrate-resend.test.ts | 738 +++++++++++ .../emails/components/footer.tsx | 5 + .../migrate-resend/emails/invoice.tsx | 13 + .../migrate-resend/emails/welcome.tsx | 13 + .../projects/migrate-resend/package-lock.json | 1 + .../projects/migrate-resend/package.json | 14 + .../src/app/api/signup/route.tsx | 18 + .../migrate-resend/src/jobs/digest.cjs | 20 + .../projects/migrate-resend/src/lib/notify.ts | 18 + .../projects/migrate-resend/src/lib/resend.ts | 3 + .../projects/migrate-resend/tsconfig.json | 9 + tests/helpers/resend-api.ts | 240 ++++ tests/lib/migrate/keys.test.ts | 50 + 39 files changed, 8031 insertions(+), 3 deletions(-) create mode 100644 skills/brew-migrate-from-resend/SKILL.md create mode 100644 src/commands/migrate/resend.ts create mode 100644 src/lib/migrate/code/analyze.ts create mode 100644 src/lib/migrate/code/locate-typescript.ts create mode 100644 src/lib/migrate/code/locate.ts create mode 100644 src/lib/migrate/code/rewrite.ts create mode 100644 src/lib/migrate/code/scan.ts create mode 100644 src/lib/migrate/code/tokenize.ts create mode 100644 src/lib/migrate/code/types.ts create mode 100644 src/lib/migrate/import-setup.ts create mode 100644 src/lib/migrate/keys.ts create mode 100644 src/lib/migrate/manifest.ts create mode 100644 src/lib/migrate/phase-code.ts create mode 100644 src/lib/migrate/phases.ts create mode 100644 src/lib/migrate/pipeline.ts create mode 100644 src/lib/migrate/report.ts create mode 100644 src/lib/migrate/resend/client.ts create mode 100644 src/lib/migrate/resend/source.ts create mode 100644 src/lib/migrate/run.ts create mode 100644 src/lib/migrate/source.ts create mode 100644 tests/commands/migrate-resend.test.ts create mode 100644 tests/fixtures/projects/migrate-resend/emails/components/footer.tsx create mode 100644 tests/fixtures/projects/migrate-resend/emails/invoice.tsx create mode 100644 tests/fixtures/projects/migrate-resend/emails/welcome.tsx create mode 100644 tests/fixtures/projects/migrate-resend/package-lock.json create mode 100644 tests/fixtures/projects/migrate-resend/package.json create mode 100644 tests/fixtures/projects/migrate-resend/src/app/api/signup/route.tsx create mode 100644 tests/fixtures/projects/migrate-resend/src/jobs/digest.cjs create mode 100644 tests/fixtures/projects/migrate-resend/src/lib/notify.ts create mode 100644 tests/fixtures/projects/migrate-resend/src/lib/resend.ts create mode 100644 tests/fixtures/projects/migrate-resend/tsconfig.json create mode 100644 tests/helpers/resend-api.ts create mode 100644 tests/lib/migrate/keys.test.ts diff --git a/docs/commands/README.md b/docs/commands/README.md index 3220573d..784ca389 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -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 @@ -2863,6 +2864,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 ` — 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 ` — The project to scan and rewrite (default: the current directory) +- `--domain ` — Brew domain (id or host) whose unsubscribe list takes Resend's suppressions and unsubscribed contacts +- `--manifest ` — Where the plan and progress live (default brew.migration.json) +- `--key-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/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 --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 --to + --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> = { "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/migrate/resend.ts b/src/commands/migrate/resend.ts new file mode 100644 index 00000000..40c126fd --- /dev/null +++ b/src/commands/migrate/resend.ts @@ -0,0 +1,173 @@ +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>): 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 ', + 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 ', + summary: + 'The project to scan and rewrite (default: the current directory)', + }, + { + flag: '--domain ', + summary: + "Brew domain (id or host) whose unsubscribe list takes Resend's suppressions and unsubscribed contacts", + }, + { + flag: '--manifest ', + summary: `Where the plan and progress live (default ${MIGRATION_MANIFEST_FILE})`, + }, + { + flag: '--key-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, 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, + }) + }, +}) diff --git a/src/generated/skills.ts b/src/generated/skills.ts index 64d232d2..6e7a0039 100644 --- a/src/generated/skills.ts +++ b/src/generated/skills.ts @@ -22,4 +22,10 @@ export const EMBEDDED_SKILLS: readonly EmbeddedSkill[] = [ path: "skills/brew-cli/SKILL.md", content: "---\nname: brew-cli\ndescription: Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.\n---\n\n# brew-cli for agents\n\nOne typed command per public-API operation (the generated reference is\n`docs/commands/README.md`; `brew-cli docs --agent` lists every command).\nJSON output is automatic when stdout is piped.\n\n## Start every session with the trust check\n\n```bash\nbrew-cli doctor\n```\n\nExit 0 = API reachable, auth valid, and this CLI covers every live\noperation. Exit 1 = read the report: `DRIFT` lines list live operations\nthis build lacks (update the CLI); auth/reachability failures name the fix.\n\n## Auth and targeting\n\n- `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`).\n- No key and a person to ask? Sign in through their browser without\n blocking on it:\n\n ```bash\n brew-cli login --no-wait --json # exit 3: {status: \"pending\", verificationUrl, userCode, expiresAt, resumeCommand}\n ```\n\n Show the person `verificationUrl` and `userCode`, and ask them to approve\n on brew.new only if the code on the page matches. Then run the\n `resumeCommand` (`brew-cli login --resume`): exit 0 stores the key and\n reports `userEmail` and `brandName`; exit 3 with `status: \"pending\"`\n means not approved yet (add `--wait 2m` to wait for it); exit 3 with\n `CLI_LOGIN_DENIED` or `CLI_LOGIN_EXPIRED` means start over. Never ask for\n or print the key itself. A plain `brew-cli login` off a terminal exits 2\n instead of waiting.\n- Default target is production. For a local dev server:\n `BREW_API_URL=http://localhost:3000/api` (Brew repo dev keys live in\n `.env.test.local` as `TEST_API_KEY`).\n- Organization-scoped keys need `--brand ` / `BREW_BRAND_ID` on\n brand-scoped commands (`brands list` shows ids).\n\n## Connect your editor to the Brew MCP server\n\n```bash\nbrew-cli mcp install # the clients this project uses (.cursor/, .vscode/, .claude/, .gemini/)\nbrew-cli mcp install --client cursor claude-code --dry-run --json\nbrew-cli mcp install --client codex --auth env # header filled from BREW_API_KEY by the client\nbrew-cli mcp install --client vscode --print # print the snippet, write nothing\n```\n\nIt merges a `brew` entry into each client's config and keeps every other\nserver. `--auth oauth` (default) writes the URL only; `--auth env` adds an\n`Authorization` header the client expands from `BREW_API_KEY` in its own\nenvironment (`${env:…}` in Cursor, VS Code, Devin Desktop), never a\nliteral key. An existing `brew` entry with other settings, a config with\ncomments, or a symbolic link leaving the project comes back `manual` with\nthe snippet to paste. Devin Desktop (`--client windsurf`,\n`~/.config/devin/mcp_config.json`) and Codex live in your home directory:\nonly written when named with `--client`. After a Codex OAuth entry, run\nthe result's `nextStep` (`codex mcp login brew`). Results: `created |\nupdated | unchanged | manual | printed`, plus one-click `deeplink`s for\nCursor and VS Code.\n\n## App emails: send by key\n\nAn app email is a keyed trigger wired to a design and a published\nautomation. Code sends it with `brew.send(key, payload)` (SDK) or:\n\n```bash\nbrew-cli app-emails kit --stack better-auth --yes # auth + lifecycle starter kit; exit 1 = a key failed, re-run\nbrew-cli app-emails upsert order.paid --source-file emails/order-paid.tsx --yes # one email from your own source\nbrew-cli app-emails list # sender (domain | sandbox | none) and readiness per key\nbrew-cli send auth.password-reset --to you@example.com --data '{\"confirmationUrl\":\"https://…\"}' --yes\nbrew-cli types --out lib/brew-contracts.ts # payload types + the BrewTriggerKeyRegistry augmentation\n```\n\nBefore a sending domain verifies, app emails use the team-only sandbox:\nthey reach members of the Brew organization only, under a daily cap\n(`sender.mode: \"sandbox\"`). `send` is destructive (real email): it exits 4\nwithout `--yes`. `upsert` and `kit` publish, so they confirm too;\n`upsert --no-publish` saves without the gate. `upsert` and `kit` need a key\nwith the `automations` and `emails` scopes (403 otherwise).\nRefusals worth knowing (exit 1, the envelope on stderr):\n`409 APP_EMAIL_EDITED_IN_BREW` means a teammate edited the design in Brew:\nkeep it, or pass `--overwrite`. `409 APP_EMAIL_TRIGGER_IN_USE`: the key's\ntrigger already starts a published automation no setup made, so every send\nwould go out twice; unpublish or delete `details.automationIds` first.\n`422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`: a kit key never sends from a\n`--domain` of the other purpose (transactional: auth, trial ending, payment\nfailed; marketing: welcome, win-back); pass one of its purpose or leave\n`--domain` out. `409 APP_EMAIL_SETUP_IN_PROGRESS`:\nanother setup of the key was writing; run it again later.\n`409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`: a `payloadSchema` that drops\nor retypes a field the live automation reads. A `DRAFT_SAVED_NOT_LIVE`\nwarning means the published version keeps sending until the draft is\npublished. An `APP_EMAIL_NOT_PUBLISHED` warning names why the email is off;\none unpublished in Brew stays off on a re-run (and `kit`) until you publish\nit in Brew or run the same `upsert` command that set it up, plus `--publish`.\nA bare `upsert --publish` repeats only a kit key's starter setup: it\nreplaces a kit key's design built from `--source-file` with the starter, and\nany other key is refused (400).\n`automations triggers create --key ` gives any trigger a key.\n\n## The contract you can rely on\n\n- Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation\n required · 130/143 interrupted by SIGINT/SIGTERM. Errors are JSON\n envelopes on stderr with stable `code`s and `requestId`.\n- A write that ends `CLI_TIMEOUT`, `CLI_CONNECTION` or `CLI_INTERRUPTED`\n (or a 5xx) may still complete on the server. Re-run the envelope's\n `retryCommand` — it carries the same `--idempotency-key`, so the API\n replays the first attempt instead of running it twice. Never re-run the\n original command with a fresh key. A `409 IDEMPOTENCY_IN_PROGRESS` means\n the first attempt is still running: wait, then re-run `retryCommand`.\n While the API's idempotency store is degraded, writes other than sends\n run without the replay guarantee: for a write that must not happen\n twice, pass `--max-retries 0` and check whether it landed before re-running.\n- `--timeout ` bounds the whole command (body, retries, pages);\n use it to fit a tool-call budget. `--max-retries 0` for one attempt.\n- Destructive commands (sends, deletes, fires, cancels) never hang: they\n exit 4 with a `confirmCommand` to re-run once a human approves, or take\n `--yes`. `confirmCommand` and `retryCommand` start the way you invoked\n the CLI (`brew-cli …`, or a package-runner prefix such as\n `npx -y @brew.new/cli@ …`), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit\n human instruction; `emails send --test --to you@x.com` is the safe lane.\n- `--input ''` (or `-` for stdin) carries full request bodies;\n positional ids always win over `--input`. `--all` drains pagination.\n `--idempotency-key` makes POST retries safe (except `api-keys create`,\n whose route never replays).\n- Every collection with ids to look up has a real detail read (` get\n `) returning the BARE row; list routes reject id filters\n (`notifications list` and `emails comments list` are list-only). Runs, sends, audience builds and\n inbox-placement tests share ONE status vocabulary: `queued | scheduled |\n running | paused | completed | partially_completed | failed | canceled`.\n\n## Recipes\n\n```bash\nbrew-cli docs --agent # machine-readable manifest of every command\nbrew-cli docs api # the live API catalog (/v1/help)\nbrew-cli contacts search --filter email:contains:@acme.com --json\nbrew-cli emails get --include html,versions\nbrew-cli emails get --email-version-id # a saved version (ids from --include versions)\nbrew-cli emails preview-clients # starts a rendering job (10 credits)\nbrew-cli emails get-client-preview # poll it; screenshots are previews[].imageUrl\nbrew-cli sends get --include events\nbrew-cli insights list --severity critical # what Brew Insights found; insights get for one in full\nbrew-cli insights list --include pulse,report,suggestions,memo --json\nbrew-cli emails comments list --include messages # teammates' comment threads on a design\nbrew-cli emails comments list --comment-id --all --json # one thread, every message\nbrew-cli chats list # recent Brew chats; chats get to resume one\nbrew-cli notifications list --type email_send_failed # what finished or failed (reading marks nothing read)\nbrew-cli domains health --include scoreHistory,scoreRuns\nbrew-cli contacts get --include openProfile # smart-send open-time profile (needs the emails scope)\nbrew-cli content upload-image ./logo.png # a local file into the brand library (free); prints its assetId\nbrew-cli brand delete-image --yes # remove one library image (destructive; its URL keeps working)\nbrew-cli api GET '/v1/sends?kind=campaign' # raw escape hatch for anything else\n```\n\nTo verify an API change you just made in the Brew repo: start the dev\nserver, point `BREW_API_URL` at it, and hit the changed route through the\ndedicated command (or `brew-cli api`) — the CLI prints the exact envelope\nyour change produced.\n", }, + { + name: "brew-migrate-from-resend", + 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 --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 --to\n --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/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..13b09bce --- /dev/null +++ b/src/lib/migrate/code/analyze.ts @@ -0,0 +1,370 @@ +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 `` 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 + 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 }) + } + } + 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() + 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.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' + } else if (spread) { + kind = 'unknown' + } + if (byName.has('html')) { + unsupported.push('html') + } + if (byName.has('text') && kind !== 'template') { + unsupported.push('text') + } + if (spread && kind === 'unknown') { + 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 messages = arrayElements(tokens, 0).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) + : kinds.size === 0 + ? 'unknown' + : '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() + 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..d5e1a722 --- /dev/null +++ b/src/lib/migrate/code/locate-typescript.ts @@ -0,0 +1,427 @@ +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' + +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 = node.parent + const inline = + ts.isPropertyAccessExpression(outer) && + outer.expression === node && + (outer.name.text === 'emails' || outer.name.text === 'batch') + clients.push({ + binding, + exportName, + ...position(node), + args: span, + inline, + }) + } + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === 'send' && + ts.isPropertyAccessExpression(node.expression.expression) && + (node.expression.expression.name.text === 'emails' || + node.expression.expression.name.text === 'batch') + ) { + const receiver = node.expression.expression.expression + const first = node.arguments[0] + const open = node.expression.getEnd() + calls.push({ + receiver: receiver.getText(source), + method: + node.expression.expression.name.text === '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..663966ff --- /dev/null +++ b/src/lib/migrate/code/locate.ts @@ -0,0 +1,741 @@ +import { + allowsJsx, + lineColumn, + matchingBracket, + 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, + namespaces: ReadonlySet, + exported: ReadonlyMap, + 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 inline = + isDot(tokens[exprEnd + 1]) && + (isIdent(tokens[exprEnd + 2], 'emails') || + isIdent(tokens[exprEnd + 2], '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 + } + // .emails.send( / .batch.send( + if ( + isDot(token) && + (isIdent(tokens[index + 1], 'emails') || + isIdent(tokens[index + 1], 'batch')) && + isDot(tokens[index + 2]) && + isIdent(tokens[index + 3], 'send') && + isPunct(tokens[index + 4], '(') + ) { + const receiver = receiverBefore(tokens, index, ctorNames, namespaces) + if (receiver === null) { + continue + } + const argument = firstArgument(tokens, index + 4) + const open = tokens[index + 4] 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: isIdent(tokens[index + 1], '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, + namespaces: ReadonlySet +): { 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..d3917677 --- /dev/null +++ b/src/lib/migrate/code/rewrite.ts @@ -0,0 +1,280 @@ +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. */ +export function mapFilePath( + libDir: string | null, + typescript: boolean +): string { + return `${libDir ?? 'lib'}/brew-resend-map.${typescript ? 'ts' : 'js'}` +} + +/** + * 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, + allTemplatesMigrated: boolean +): RewriteDecision[] { + const decisions: RewriteDecision[] = [] + const sitesByClient = new Map() + 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, + knownTemplates: ReadonlySet, + allTemplatesMigrated: boolean +): string | null { + 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` + } + } + 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) ?? []), + ] + if (sites.length === 0 && clients.length === 0) { + return 'it sends nothing through Resend here' + } + for (const site of sites) { + 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` + } + } + } + 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 RESEND_API_KEY; point it at your Brew key by hand` + } + } + return null +} + +/** + * The Brew key expression for a client's arguments: `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 somewhere else. + */ +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 + } + return /\bRESEND_API_KEY\b/.test(trimmed) + ? trimmed.replace(/\bRESEND_API_KEY\b/g, 'BREW_API_KEY') + : null +} + +function relativeSpecifier( + fromFile: string, + target: string, + useJs: boolean +): string { + const withoutExtension = target.replace(/\.(?:ts|js)$/, '') + let path = relative(dirname(fromFile), withoutExtension).split(sep).join('/') + if (!path.startsWith('.')) { + path = `./${path}` + } + return 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}`, + }) + } + 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: ${MAP_EXPORT} }` + 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 (locations.clients.length > 0) { + const specifier = relativeSpecifier( + input.file, + input.mapFile, + locations.relativeImportsUseJs + ) + const quote = text[locations.imports[0]?.specifier.start ?? -1] ?? "'" + if (locations.moduleSyntax === 'esm') { + edits.push({ + start: locations.importInsertAt, + end: locations.importInsertAt, + replacement: `${locations.importInsertAt === 0 ? '' : '\n'}import { ${MAP_EXPORT} } from ${quote}${specifier}${quote}${locations.importInsertAt === 0 ? '\n' : ''}`, + }) + } else { + const requireAt = locations.imports.find( + (entry) => entry.kind === 'require' + ) + const lineEnd = + requireAt === undefined + ? 0 + : text.indexOf('\n', requireAt.specifier.end) + const at = lineEnd === -1 ? text.length : lineEnd + edits.push({ + start: at, + end: at, + replacement: `${at === 0 ? '' : '\n'}const { ${MAP_EXPORT} } = require(${quote}${specifier}${quote})${at === 0 ? '\n' : ''}`, + }) + } + } + 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 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> = {` + : `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..b52ee402 --- /dev/null +++ b/src/lib/migrate/code/scan.ts @@ -0,0 +1,400 @@ +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 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 +const SEND_HINT = /resend|\.emails\s*\??\.\s*send|\.batch\s*\??\.\s*send/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 +} + +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 + 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 + 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') && + stats.size <= MAX_FILE_BYTES + ) { + 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() + const reactEmail: ReactEmailFile[] = [] + let usedTypeScript = false + 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) && + /]/.test(text) + if (!SEND_HINT.test(text) && !isReactEmail) { + 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 clients: ScannedClient[] = [] + for (const [file, scanned] of files) { + for (const site of scanned.locations.clients) { + clients.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 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: listed.truncated, + files, + clients, + callSites, + reactEmail, + } +} + +/** 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..04b044d4 --- /dev/null +++ b/src/lib/migrate/code/tokenize.ts @@ -0,0 +1,588 @@ +/** + * 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 { + // `() =>` and `` 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) + 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> = { + '(': ')', + '[': ']', + '{': '}', + } + 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 +} + +/** 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 `.emails.send(…)` or `.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..ee72b65f --- /dev/null +++ b/src/lib/migrate/import-setup.ts @@ -0,0 +1,193 @@ +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#2064 (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 & { + 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> = { + 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() + 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 { + const brew = run.brew + if (brew === undefined || item.key === undefined) { + return + } + const key = item.key + let importedId: string | undefined + try { + say(run, `Importing ${item.name} as app email ${key}…`) + const imported = (await brew.emails.import( + asSdkInput(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 + const setup = await brew.appEmails.upsert( + asSdkInput({ + 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 { + 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..ab1af123 --- /dev/null +++ b/src/lib/migrate/keys.ts @@ -0,0 +1,120 @@ +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(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 + 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}` + candidate = `${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..67071529 --- /dev/null +++ b/src/lib/migrate/manifest.ts @@ -0,0 +1,212 @@ +import { 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:`, `react-email:`, … */ + 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 + } + 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 +} + +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 + 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 + options: { keyPrefix: string; domain: string | null } + phases: Partial> + callSites: CallSiteRecord[] + /** Files the migration wrote, with the sha256 it last wrote (the map, rewritten sources). */ + files: Record +} + +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 { + 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, + }, + } +} + +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..a033f41f --- /dev/null +++ b/src/lib/migrate/phase-code.ts @@ -0,0 +1,575 @@ +import { readFileSync } from 'node:fs' +import { join, relative, sep } from 'node:path' +import { SDK_PACKAGE, SDK_RANGE } from '../init/templates' +import { ProjectWriter } from '../project/fs-writer' +import type { MessageAnalysis } from './code/analyze' +import { + absoluteIn, + decideRewrites, + MAP_EXPORT, + mapFileContent, + mapFilePath, + rewriteFile, +} from './code/rewrite' +import { + type CodeScan, + hasBrewClientFile, + reactEmailBaseName, + resolveProjectModule, + type ScannedCallSite, +} from './code/scan' +import { importAndSetUp } from './import-setup' +import { deriveKey, isCanonicalKitKey, slugifyFileName } from './keys' +import type { CallSiteRecord, MigrationItem } from './manifest' +import { + addNextStep, + clean, + 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, ' ') +} + +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.join(', ')} })` +} + +type KeyIndex = { + readonly templates: ReadonlyMap + readonly reactEmail: ReadonlyMap +} + +function keyIndex(run: RunContext): KeyIndex { + const templates = new Map() + 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() + 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 React Email file a `` in `file` comes from. */ +function componentFile( + run: RunContext, + scan: CodeScan, + file: string, + component: string +): string | null { + const scanned = scan.files.get(file) + const imported = scanned?.locations.localImports.find( + (entry) => entry.local === component.split('.')[0] + ) + 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 ?? '', + first, + payloadEntries(first.variables) + ) + break + } + case 'react': { + component = first.component?.name ?? null + const file = + component === null + ? null + : componentFile(run, scan, site.file, component) + 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 ?? '', 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 --source-file --yes` + replacement = sendLine(client, '', first, []) + break + default: + note = + 'the message is built elsewhere: read it and pick the Brew key it sends' + replacement = sendLine(client, '', 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 { + 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 project has more source files than the scan reads; run it from the app directory (--cwd).', + }) + } + + // ---- React Email templates → app emails (`props: 'merge_tags'`). + const reuseKeys = run.manifest.options.keyPrefix === run.options.keyPrefix + const taken = new Set() + const ours = new Set() + for (const item of [ + ...(run.manifest.phases.templates?.items ?? []), + ...phase.items, + ]) { + if (item.key !== undefined && (item.status === 'done' || reuseKeys)) { + 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() + 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 = + previous?.key !== undefined && (previous.status === 'done' || reuseKeys) + ? 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} --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) + ) + if (run.apply && run.options.rewriteImports && rewritable.length > 0) { + rewriteImports(run, scan, rewritable, templateItems, rewritten) + } + + // Call sites in files now on the Brew client are done. + const doneClients = new Set( + scan.clients + .filter((entry) => rewritten.has(entry.file)) + .map((entry) => entry.id) + ) + for (const record of records) { + if ( + rewritten.has(record.file) || + (record.client !== null && doneClients.has(record.client)) + ) { + 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 +): void { + const writer = new ProjectWriter({ root: run.project.root }) + const mapPath = mapFilePath(run.project.libDir, run.project.typescript) + const entries = new Map() + 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) + } + } + const firstFile = scan.files.get(items[0]?.file ?? '') + const content = mapFileContent({ + entries: [...entries.entries()], + typescript: run.project.typescript, + moduleSyntax: firstFile?.locations.moduleSyntax ?? 'esm', + 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') { + run.findings.push({ + code: 'MAP_FILE_KEPT', + severity: 'warning', + message: `${mapPath} was edited since the migration wrote it, so it was kept; check it maps every template id.`, + }) + } else 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 next = rewriteFile({ file, scanned, mapFile: mapPath }) + const change = writer.writeFile(file, next, { overwrite: true }) + 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.ts b/src/lib/migrate/phases.ts new file mode 100644 index 00000000..4c352200 --- /dev/null +++ b/src/lib/migrate/phases.ts @@ -0,0 +1,1079 @@ +import type { EmailImportInput, UpsertManyContactRow } from '@brew.new/sdk' +import { asSdkInput } from '../input' +import { + type ImportResponse, + importAndSetUp, + looksLikeMissingImportModes, + recordImportWarnings, +} from './import-setup' +import { deriveKey, isCanonicalKitKey, slugifyKey } from './keys' +import type { MigrationItem, PhaseState } from './manifest' +import { + addNextStep, + addWarning, + clean, + errorOf, + idempotencyKey, + phaseState, + pruneItems, + type RunContext, + reconcileItem, + rethrowIfFatal, + say, + settlePhase, +} from './run' +import type { SourceContact, SourceTemplate } from './source' + +/** + * The account phases: what lives in the Resend account (domains, templates, + * contacts, segments, suppressions) and what only gets reported (topics, + * broadcasts, 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. + */ + +/** Pages a plan reads to count contacts or suppressions (100 rows each). */ +const PLAN_COUNT_PAGES = 50 +const CONTACT_FIELDS = new Set(['FIRST_NAME', 'LAST_NAME', 'EMAIL']) +const VARIABLE_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/ +const CONSENT_EVIDENCE = + 'Subscribed in Resend (not unsubscribed) when migrated with brew-cli migrate resend' + +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 +} + +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 { + 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 { + 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() + 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 { + 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 { + const phase = phaseState(run, 'templates') + delete phase.error + const summaries = await inventoryOf(run).templates() + say(run, `Resend: ${summaries.length} templates`) + const reuseKeys = run.manifest.options.keyPrefix === run.options.keyPrefix + const taken = new Set() + const ours = new Set() + for (const item of [ + ...phase.items, + ...(run.manifest.phases.code?.items ?? []), + ]) { + if (item.key !== undefined && (item.status === 'done' || reuseKeys)) { + 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() + for (const summary of summaries) { + const id = `template:${summary.id}` + keep.add(id) + const previous = phase.items.find((item) => item.id === id) + const key = + previous?.key !== undefined && (previous.status === 'done' || reuseKeys) + ? 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' + } +} + +// ---- 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 }, + name: string, + by: number +): void { + const counts = state.counts ?? {} + counts[name] = (counts[name] ?? 0) + by + state.counts = counts +} + +async function countPages( + 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 { + const phase = phaseState(run, 'contacts') + delete phase.error + const inventory = inventoryOf(run) + if (!run.apply) { + if (phase.status === 'done') { + return + } + const counts: Record = {} + 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 + } + 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.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: { source: 'import' as const, evidence: CONSENT_EVIDENCE }, + } + const result = await brew.contacts.upsertMany(body, { + idempotencyKey: idempotencyKey( + run, + `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, + `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, + `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.status = 'done' + phase.cursor = null + 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.`, + }) + } + if ((phase.counts?.failed ?? 0) > 0) { + addWarning(phase, { + code: 'CONTACT_ROWS_FAILED', + message: `${phase.counts?.failed} contact rows were refused by Brew (see the counts); the rest landed.`, + }) + } + run.persist() +} + +// ---- Segments → a boolean contact field and an audience per segment. + +/** + * The audience selecting `fieldName = true`: the one named for the item, or + * ` (Resend)` when a Brew audience of that name is not ours. + */ +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 as { + data?: Array<{ audienceId: string; audienceName: string }> + } + ).data ?? [] + const match = rows.find((row) => row.audienceName === name) + let audienceId = match?.audienceId + let audienceName = name + if (match !== undefined && item.brew?.audienceId !== match.audienceId) { + audienceName = `${name} (Resend)` + audienceId = rows.find( + (row) => row.audienceName === audienceName + )?.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 { + 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 { + 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() + 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 + do { + run.ctx.signal.throwIfAborted() + const page = await inventory.segmentContacts(item.sourceId, after) + if (page.rows.length > 0) { + const body = { + contacts: page.rows.map((row) => ({ + email: row.email, + customFields: { [fieldName]: true }, + ...(row.unsubscribed === true ? { subscribed: false } : {}), + })), + } + const result = await brew.contacts.upsertMany(body, { + idempotencyKey: idempotencyKey( + run, + `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 { 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 { + const phase = phaseState(run, 'suppressions') + delete phase.error + const inventory = inventoryOf(run) + if (!run.apply) { + if (phase.status === 'done') { + return + } + const counts: Record = {} + 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 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 --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 to import suppressions' + addNextStep(run, { + id: 'suppressions-domain', + actor: 'agent', + kind: 'run-command', + command: `${run.prefix} migrate resend --only suppressions --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() +} + +// ---- 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' +} + +// ---- Topics → a boolean contact field (opted in) and an audience per topic. + +export function topicFieldName(name: string, taken: Set): 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 { + 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() + for (const topic of topics) { + const id = `topic:${topic.id}` + keep.add(id) + const previous = phase.items.find((item) => item.id === id) + 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 = {} + 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, + ...(contact.unsubscribed ? { subscribed: false } : {}), + }) + } + if (rows.length > 0) { + const body = { contacts: rows } + const result = await brew.contacts.upsertMany(body, { + idempotencyKey: idempotencyKey( + run, + `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 + } + bump(phase, 'passComplete', 1) + phase.cursor = null + 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 }) +} + +// ---- Broadcasts → Brew draft emails (nothing is sent). + +export async function broadcastsPhase(run: RunContext): Promise { + 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() + 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(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 { + 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 { + 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..8c820eb3 --- /dev/null +++ b/src/lib/migrate/pipeline.ts @@ -0,0 +1,529 @@ +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 } from '../define-command' +import { CliAuthError, CliUsageError } from '../errors' +import { detectProject } from '../init/detect' +import type { NextStep } from '../init/report' +import { SDK_PACKAGE, SDK_RANGE } from '../init/templates' +import { getInvocationPrefix } from '../invocation' +import { addCommand, formatCommandLine } from '../project/package-manager' +import type { CliContext } from '../types' +import { + createManifest, + type MigrationManifest, + MigrationManifestError, + PHASES, + type PhaseId, + readManifest, + writeManifest, +} from './manifest' +import { codePhase } from './phase-code' +import { + automationsPhase, + broadcastsPhase, + contactsPhase, + domainsPhase, + segmentsPhase, + suppressionsPhase, + templatesPhase, + topicsPhase, + webhooksPhase, +} from './phases' +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 `: 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 +} + +const PHASE_RUNNERS: Readonly< + Record, (run: RunContext) => Promise> +> = { + 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 + let hasSdk = project.sdkRange !== null + try { + const pkg = JSON.parse( + readFileSync(join(project.root, 'package.json'), 'utf8') + ) as { + dependencies?: Record + } + hasSdk = pkg.dependencies?.['@brew.new/sdk'] !== undefined + } 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, + } + run.manifest.brandId = row.brandId + } 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}`, + }) + } + 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() + 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, + domain: string | undefined +): void { + if (domain === undefined) { + return + } + run.manifest.options.domain = domain + if (run.brewDomains === null) { + return + } + const wanted = domain.toLowerCase() + const match = run.brewDomains.find( + (row: BrewDomainRow) => + row.domainId === domain || row.name.toLowerCase() === wanted + ) + if (match === undefined) { + throw new CliUsageError( + `--domain ${domain} is not a domain of this Brew brand. List them with \`${run.prefix} domains list\`.` + ) + } + 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 +): 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 { + 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.)` + ) + } + + 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) { + 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 --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( + [ + ...ctx.rawArgv.filter( + (token) => token !== '--yes' && token !== '--dry-run' + ), + '--apply', + ], + 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 = { + 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> = { + 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> = { + domains: 'Domains', + templates: 'Templates', + code: 'Code', + contacts: 'Contacts', + segments: 'Segments', + suppressions: 'Suppressions', + topics: 'Topics', + broadcasts: 'Broadcasts', + webhooks: 'Webhooks', + automations: 'Automations', +} + +function countsLine( + counts: Readonly> | 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 = { + 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> + ) => Promise + /** One page of a list endpoint (`limit` ≤ 100, `after` cursor). */ + readonly page: ( + path: string, + options?: { readonly after?: string; readonly limit?: number } + ) => Promise> + /** Requests made so far. */ + readonly requestCount: () => number +} + +type ClientContext = Pick + +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 => { + 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> = {} + ): Promise => { + 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 ( + path: string, + options: { readonly after?: string; readonly limit?: number } = {} + ): Promise> => { + 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> + +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 = {} + 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 { + const rows: Row[] = [] + let after: string | undefined + for (let pageCount = 0; pageCount < 1000; pageCount += 1) { + const page = await client.page( + 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> { + const page = await client.page(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> => { + const page = await client.page( + '/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( + `/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..1b420152 --- /dev/null +++ b/src/lib/migrate/run.ts @@ -0,0 +1,221 @@ +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 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 | null + readonly templates: Map + 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---`: 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 +} + +/** Drops items an earlier run planned that no longer exist (done ones stay). */ +export function pruneItems(phase: PhaseState, keep: ReadonlySet): 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(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 credential problem. */ +export function rethrowIfFatal(ctx: CliContext, error: unknown): void { + ctx.signal.throwIfAborted() + if ( + (error instanceof BrewApiError || error instanceof CliApiError) && + (error.status === 401 || error.status === 403) + ) { + throw error + } +} + +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> +} + +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 = { + 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 + readonly templates: () => Promise + readonly template: (id: string) => Promise + readonly contacts: (after: string | null) => Promise> + readonly segments: () => Promise + readonly segmentContacts: ( + segmentId: string, + after: string | null + ) => Promise> + readonly topics: () => Promise + readonly suppressions: ( + after: string | null + ) => Promise> + readonly broadcasts: () => Promise + readonly broadcast: (id: string) => Promise + /** One request per contact: the source keeps topic choices per contact. */ + readonly contactTopics: (contactId: string) => Promise + readonly webhooks: () => Promise + readonly automations: () => Promise + 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, + 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/migrate-resend.test.ts b/tests/commands/migrate-resend.test.ts new file mode 100644 index 00000000..92a9c776 --- /dev/null +++ b/tests/commands/migrate-resend.test.ts @@ -0,0 +1,738 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { HttpResponse, http } from 'msw' +import { describe, expect, it } from 'vitest' +import type { MigrationManifest } from '../../src/lib/migrate/manifest' +import type { MigrationReport } from '../../src/lib/migrate/report' +import { copyFixture } from '../helpers/fixtures' +import { fakeClock } from '../helpers/login-server' +import { server } from '../helpers/msw-server' +import { + RESEND_ACCOUNT, + type ResendAccount, + useResendApi, +} from '../helpers/resend-api' +import { type RunCliOptions, runCli } from '../helpers/run-cli' + +const API = 'https://brew.new/api' +const BREW_KEY = 'brew_migrate0123456789abcdefghijklmn' +const RESEND_KEY = 're_CANARY_resend_secret_0123456789' + +/** Everything that must never reach stdout, stderr or brew.migration.json. */ +const SECRETS = [RESEND_KEY, BREW_KEY, 'WEBHOOKTOKEN123'] +const PII = [ + 'ada@example.com', + 'bob@example.com', + 'carol@example.com', + 'bounced@example.com', + 'Lovelace', +] + +type BrewCall = { + readonly method: string + readonly path: string + readonly body: unknown + readonly idempotencyKey: string | null + readonly authorization: string | null +} + +const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']) + +/** Brew's v1 API as the migration uses it; every call recorded. */ +function useBrewApi( + options: { + readonly domains?: ReadonlyArray> + /** Refuse the contacts upserts holding this address until healed. */ + readonly failContact?: string + } = {} +) { + const calls: BrewCall[] = [] + let imported = 0 + let healed = false + const record = async (request: Request): Promise => { + 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: '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) + return HttpResponse.json({ + data: [], + 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: `

converted ${body.title ?? ''} {{ trigger.RESET_URL }}

`, + 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) + 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 } + ) + } + return HttpResponse.json({ + summary: { + inserted: body.contacts.length, + updated: 0, + failed: 0, + }, + fieldsCreated: [], + errors: [], + warnings: [], + }) + }), + http.get(`${API}/v1/audiences`, async ({ request }) => { + await record(request) + return HttpResponse.json({ + data: [], + 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 + }, + } +} + +function env( + extra: Readonly> = {} +): Readonly> { + return { + BREW_CLI_CONFIG_DIR: join(copyFixture('no-package-json'), '.config'), + BREW_API_KEY: BREW_KEY, + RESEND_API_KEY: RESEND_KEY, + ...extra, + } +} + +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 } +} + +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 +} + +function phase(report: MigrationReport, id: string) { + return report.phases.find((entry) => entry.id === id) +} + +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'], + ]) + ) + + // 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 --json --apply --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> + 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. + expect( + upserts.some((call) => + JSON.stringify(call.body).includes('"resend_segment_beta_users":true') + ) + ).toBe(true) + const topics = upserts.find((call) => + JSON.stringify(call.body).includes('resend_topic_product_updates') + )?.body as { contacts: Array> } + expect(topics.contacts).toEqual([ + { + email: 'ada@example.com', + customFields: { resend_topic_product_updates: true }, + }, + { + 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('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: 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 Acme Inc. +} 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 ( + + + Total: {total} +