diff --git a/AGENTS.md b/AGENTS.md index 9bbb4e2c..45bd33c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,11 +72,12 @@ longer exist — only some keep an alias. | `wizard integrate` | `wizard` (default flow) | command removed | | `wizard events-audit` | `wizard audit events` | moved into `audit` family | | `wizard audit` (single) | `wizard audit ` | now a family — see audit subcommands below | +| `wizard migrate` (single) | `wizard migrate ` | becomes a family with the wizard's family command — see migrate subcommands below | | `wizard audit-3000` | *removed* | retired | | `wizard revenue` | `wizard revenue-analytics` | renamed (old `revenue` removed) | | `wizard upload-sourcemaps` | `wizard upload-source-maps` | renamed; `upload-sourcemaps` kept as alias | -**Audit subcommands** — the one family today backed by skills from this repo: +**Audit subcommands**, backed by skills from this repo: | Subcommand | Backing skill | |---|---| @@ -88,6 +89,13 @@ longer exist — only some keep an alias. | `wizard audit session-replay` | `audit-session-replay` | | `wizard audit web-analytics` | *(wizard-native, not a skill here)* | +**Migrate subcommands**, published as `cliEntries` with `parentCommand: migrate`. They run once the wizard runs `migrate` as a family. Until then, `wizard migrate` stays a flat command that runs `migrate-statsig`, and the wizard rejects a vendor after it: + +| Subcommand | Backing skill | +|---|---| +| `wizard migrate statsig` | `migrate-statsig` | +| `wizard migrate resend` | `migrate-email-resend` | + **Commands vs. skills:** those audit subcommands **are** skills, promoted to commands via `cli: role: command`. A `role: skill` skill is reachable only via `wizard skill `. Same machinery, two surfaces — so `wizard audit ` diff --git a/context/skills/migrate-email/config.yaml b/context/skills/migrate-email/config.yaml new file mode 100644 index 00000000..2accfcd5 --- /dev/null +++ b/context/skills/migrate-email/config.yaml @@ -0,0 +1,18 @@ +# Email migration skill group - move transactional email from another provider to PostHog, one variant per provider +type: skill +template: description.md +category: migrate +description: Migrate transactional email from {display_name} to PostHog. Use when asked to migrate from {display_name}, replace {display_name} with PostHog, or switch email providers. It finds every {display_name} send in the code, sets up the sender and DNS next to {display_name}'s records, imports {display_name}'s suppression list, hands over the PostHog secret key, and writes a migration report. It changes no send call site yet. +tags: [migration, workflows] +cli: + role: command + parentCommand: migrate +references: + preamble: "**Read ONLY this file.** Do not read any other reference file until this one tells you to." +shared_docs: + - https://posthog.com/docs/workflows/transactional-emails.md + - https://posthog.com/docs/workflows/transactional-emails/api.md +variants: + - id: resend + display_name: Resend + tags: [resend] diff --git a/context/skills/migrate-email/description.md b/context/skills/migrate-email/description.md new file mode 100644 index 00000000..7bb85a8d --- /dev/null +++ b/context/skills/migrate-email/description.md @@ -0,0 +1,57 @@ +# Migrate transactional email from {display_name} to PostHog + +Use this skill to move an app's transactional email, such as welcome emails, password resets, and receipts, from {display_name} to PostHog. + +This version prepares the move and changes no send call site. It finds every {display_name} send, sets up the PostHog sender and its DNS records next to {display_name}'s, imports {display_name}'s suppression list, hands over the PostHog secret key, and writes a report. Both providers can send during the cutover. + +The transactional email API is in beta. PostHog enables it per project. + +## Workflow + +{workflow} + +Read the step files in order, one at a time. Each step names the next one. Do not read a step before the one before it tells you to. + +## Provider files + +The folder `references//` holds everything specific to {display_name}. The steps tell you when to read each file: + +- `sdk-reference.md`: how {display_name} looks in code, so you can find every send. +- `mapping.md`: how each send field maps to PostHog, and what keeps a call site on {display_name}. +- `account.md`: {display_name}'s DNS records and how to export its suppression list. + +Everything you need is on disk. Do not fetch {display_name}'s docs. + +## Plan file + +The plan file `.posthog-email-migration-plan.md` lives at the project root. Step 3 writes it, the later steps update it, and the last step writes the report from it and deletes it. + +## Rules + +- Change no send call site. This version only prepares the move. +- Never touch DNS or the {display_name} account. Give the person the records and the links, and let them make the change. +- Never remove the {display_name} package, its client, or its API key. +- Never create, upload, or edit a template in PostHog. +- Never write a recipient's email address into a file in the repository, the plan, or the report. Count addresses instead. +- Keep keys in environment variables. Never hardcode a key, and never ask the person to paste one into the chat. +- Do not commit. The person reviews the diff and commits. + +## Inside the PostHog Wizard + +Read this section only when the Wizard's `wizard_ask` tool exists. Some runtimes name its tools `mcp__wizard-tools__wizard_ask` and `mcp__wizard-tools__set_env_values`: use the names the runtime shows. The Wizard has no chat, so the person answers through its tools. + +- The PostHog tools of this skill run through `posthog_exec`. Check that a tool exists with `search `. +- The Wizard's login may not carry the scopes of the `workflows-*` and `integrations-*` tools, so a missing tool does not mean the API is off. Step 2 says how to tell. +- Ask every question with `wizard_ask`, never in plain text. Batch the questions of one step into one call. +- Emit the `[STATUS]` line each step names when you start it, and the `[ABORT]` line a step names when it stops the run. +- Ask for a key with `wizard_ask` and `sensitive: true`, then pass the answer to `set_env_values` with the variable name the step gives. The answer is a reference that only `set_env_values` can read, so the key never shows in the run. + +Outside the Wizard, skip the `[STATUS]` lines, and tell the person why you stopped instead of an `[ABORT]` line. + +## Reference files + +{references} + +`transactional-emails.md` is the transactional email overview. It covers how to create a transactional email and its API key, idempotency, and the error codes. `api.md` covers the send route, including content from your code, and the checks of a send. + +When the `sending-transactional-emails` skill is available, read it too. Its `moving-from-other-providers.md` moves any provider's sends to a PostHog template. This skill adds what is specific to {display_name}: it finds every send, sets up the sender next to {display_name}'s DNS records, imports {display_name}'s suppression list, and hands over the key. diff --git a/context/skills/migrate-email/references/1-detect.md b/context/skills/migrate-email/references/1-detect.md new file mode 100644 index 00000000..fd4a47d3 --- /dev/null +++ b/context/skills/migrate-email/references/1-detect.md @@ -0,0 +1,41 @@ +--- +title: Find the email provider in the code +next_step: 2-preflight.md +--- + +# Step 1, find the email provider in the code + +Discovery only. Do not change any file. + +## Status + +``` +[STATUS] Looking for the email provider +``` + +## Procedure + +Read the provider's `sdk-reference.md`. It lists the provider's packages, imports, clients, send calls, HTTP host, SMTP host, and framework mailers, and the packages that look related but are not the provider. + +The provider is in this project when at least one of these is true: + +- Its package is in a dependency manifest, such as `package.json`, `requirements.txt`, `pyproject.toml`, `Gemfile`, `composer.json`, or `go.mod`. +- Source code imports it or creates its client. +- Source code calls its HTTP host. +- Source code, mail config, or an example env file names its SMTP host or its framework mailer. + +Search the source code, not only the manifest. Skip `node_modules`, build output, and lockfiles. + +The migration needs at least one send: a send call, a request to the send route, or mail through the SMTP host or the framework mailer. A package without a send is not a migration, and neither are calls that only read contacts, domains, or an email's status. + +A rendering package alone, such as `@react-email/components`, is not the provider. It only tells you how some emails are rendered. + +When you find no send, stop before any change: + +``` +[ABORT] no email provider sends found +``` + +Do not widen the search to other email providers. That outcome is correct, not a failure to work around. + +When you find a send, note each file that uses the provider for Step 3, and continue. diff --git a/context/skills/migrate-email/references/2-preflight.md b/context/skills/migrate-email/references/2-preflight.md new file mode 100644 index 00000000..129d17ba --- /dev/null +++ b/context/skills/migrate-email/references/2-preflight.md @@ -0,0 +1,38 @@ +--- +title: Check that the project can send +next_step: 3-inventory.md +--- + +# Step 2, check that the project can send + +Do not change any file. + +## Status + +``` +[STATUS] Checking the PostHog project +``` + +## The transactional email API + +Check which PostHog MCP tools exist. + +- When there are no PostHog tools at all, the PostHog MCP is not connected or not signed in. Ask the person to connect it, and wait. +- When `workflows-make-email-template-transactional-prepare` and `workflows-make-email-template-transactional-execute` exist, the API is on. Record `api: on`. +- When other PostHog tools exist but that pair doesn't, either the login lacks the `transactional_email:write` scope (a login with `hog_flow:write` works too), or transactional email is not on for the project yet. A missing tool cannot tell these apart. Tell the person both causes, record `api: not confirmed`, and continue. The report asks them to confirm it before cutover. + +Do not call a tool that changes anything to find out. + +Those tools confirm sends by template. PostHog enables sends with content from the code separately, and no tool shows it. The report asks the person to confirm it before cutover when a site that moves sends its content from the code. + +## The sending tier + +PostHog gives each project a sending allowance: how many emails it sends per hour and per day. A new project starts on a low tier, and the tier grows with low bounce and complaint rates. Emails above the allowance are not dropped, they are sent later. So a cutover with more mail than the tier allows delays email, such as password resets. + +- Ask the person how many emails the app sends in its busiest hour and on its busiest day. +- Ask them to compare these numbers with the "Sending allowance" in PostHog: Workflows, then Messaging, then Reputation. +- Record the answer as `tier: fits`, `tier: too low`, or `tier: not checked` when they don't know. Do not stop on `too low`: the report tells the person to move the sends in parts, or to ask PostHog about a higher tier, before cutover. + +## Write down + +Keep `api` and `tier` for Step 3, which writes them into the plan file. diff --git a/context/skills/migrate-email/references/3-inventory.md b/context/skills/migrate-email/references/3-inventory.md new file mode 100644 index 00000000..87086bac --- /dev/null +++ b/context/skills/migrate-email/references/3-inventory.md @@ -0,0 +1,76 @@ +--- +title: List and classify every send +next_step: 4-sender.md +--- + +# Step 3, list and classify every send + +List every place the code sends email through the provider, and decide for each one whether PostHog can send it. Change no source file. The only file you write is the plan file. + +## Status + +``` +[STATUS] Listing every send +[STATUS] Writing the migration plan +``` + +## Find the sends + +Read the provider's `sdk-reference.md` for the call shapes, and its `mapping.md` for the fields. + +A **site** is one place in the code that decides what a send contains: its content and its fields. Usually that is the `emails.send(...)` call or the HTTP request itself. + +- When a helper builds the send from a few parameters, such as a subject and a template name, the helper is one site, however many places call it. Read its callers anyway: they decide values such as how many recipients a send has. +- When a helper only forwards a payload that its callers build, each caller is a site, and the helper is not. + +For each site, read the surrounding code and record: + +- The file and the line of the call. +- How it sends: `sdk`, `http`, or `smtp`. +- The content: `template` for a template stored at the provider, `react` for a component the provider renders, `html`, or `text`. A site that sets both `html` and `text` is `html`. +- Every field the site sets, including fields set on the payload object after it is created and fields set through options, such as an idempotency key. +- Where `from` comes from: the literal address, or the name of the environment variable or config value that holds it. + +Also list every other call to the provider that is not a send, such as reading an email's status, contacts, or domains. These calls stay on the provider. Note what each one does, because a site whose code reads the status back stays on the provider too. + +When you find no site, stop before you write the plan file: + +``` +[ABORT] no email provider sends found +``` + +## Classify each site + +Check what each site sends first. PostHog's transactional send ignores marketing opt-outs, so it is only for an email that a person needs, such as a password reset, a receipt, or an invite. A site that sends marketing, such as a newsletter, a promotion, or a product announcement, stays, and the reason is `marketing email`. When neither the code nor the template's name tells you, ask the person once for every such site. A site they call marketing, or don't answer for, stays with that reason. + +Then go through `mapping.md` field by field. A site **moves** when PostHog can send every field it sets. A site **stays** on the provider when any one field or feature keeps it there: record that field or feature as the reason. Never plan to drop a field. + +When the code passes a value you can't read, such as a list of recipients built at run time, follow it to its callers. When you still can't tell, the site stays, and the reason says what you could not check. + +A `template` site moves only to a template made in PostHog, and the person makes that template. Check the site's other fields first: a site that another field keeps on the provider stays with that reason, and needs no question about its template. Ask the person once, for every other template the sites send, whether to move it to PostHog or keep it on the provider for now. A template they keep stays, and the reason is `kept on for now`. For each template they move, ask them to copy it from the provider's dashboard into a file outside the repository, and to tell you where they saved it. Read those files to classify the site, and never write the content into the plan. When the person can't give the content, the site stays, and the reason is `template content not checked`. + +A template whose content passes the checks moves only once the person has made it in PostHog. Ask them once, for every such template, to make it in PostHog from the provider's template and give you its template ID and the address it sends from, usually the provider template's sender. PostHog makes a template transactional only once a verified sender covers its address: ask them to make it transactional now when the project has one, and otherwise once Step 4 verifies the sender. When the email carries a login code, a reset link, or another secret, ask them to mark the template sensitive, as `mapping.md` says. The report lists both before cutover either way. Tell them how its placeholders change, as `mapping.md` says under "Content". Record each template ID with its site under the sites table, and write the address in the site's `from` column, so Step 4 sets up a sender for its domain. A template without an ID stays, and the reason is `PostHog template not made yet`. + +## Write the plan file + +Write `.posthog-email-migration-plan.md` at the project root with three sections. Never write an email address of a recipient into it. A `from` address is fine. + +A phase checklist, in this order. Tick `preflight` with what Step 2 recorded, and `inventory` once the file is written. Later steps tick the rest. + +``` +- [x] preflight — api: — tier: +- [x] inventory — sites, stay on the provider +- [ ] sender +- [ ] suppressions +- [ ] key +``` + +A sites table, one row per site, in source tree order: + +``` +| site | file | line | sends | content | from | moves | reason | +``` + +`moves` is `yes` or `no`. `reason` is empty when the site moves. + +A list of the other provider calls, each with its file, line, and what it does. diff --git a/context/skills/migrate-email/references/4-sender.md b/context/skills/migrate-email/references/4-sender.md new file mode 100644 index 00000000..10dee345 --- /dev/null +++ b/context/skills/migrate-email/references/4-sender.md @@ -0,0 +1,47 @@ +--- +title: Set up the senders and their DNS records +next_step: 5-suppressions.md +--- + +# Step 4, set up the senders and their DNS records + +PostHog sends from a verified sender: an address on a domain whose DNS records PostHog checked. Set up a sender for each domain the app sends from, next to the provider's records, so both providers can send during the cutover. Never change DNS yourself, and never change the provider account. + +## Status + +``` +[STATUS] Setting up the PostHog sender +``` + +## List the addresses + +Collect every `from` in the plan's sites table. When a `from` comes from an environment variable or a config value, read it from the example env file or the config, or ask the person for the address. A `from` can carry a name, such as `Example `: the sender is the address in it. + +Group the addresses by domain. Each domain needs one verified sender. + +## Check for verified senders + +Call `integrations-list` with `kind=email`, and read every page. A sender with `config.verified: true` can send. When the list shows senders without `config`, call `integrations-email-verify-create` once on the sender for the address: `success` means it is verified, and `pending` or `failed` means you continue from its records below. When the sender tools are missing, or the verify answers 403, ask the person whether a sender is verified for each domain. + +The first sender that verifies becomes the project's default sender. A send without `from` uses it. + +## Set up each domain without a verified sender + +Read the provider's `account.md` first. It lists the records the provider keeps on the domain and the label PostHog must not take. Keep every one of the provider's records: removing one stops the provider's sends before the cutover is done. + +Send from the domain of the existing `from` address, so the emails keep their sender. Ask for another domain only when the person wants one. + +- First check that the PostHog MCP has the sender tools: `integrations-email-create`, `integrations-email-verify-create`, `integrations-domain-connect-check-retrieve`, and `integrations-domain-connect-apply-url-create`. When they are missing, go to the last bullet. +- When the `setting-up-an-email-domain` skill is available, read it for the details of the records. Where it differs from this step, this step wins. +- Never create a sender for an address that already has one: creating it again marks it unverified. For an existing unverified sender, skip to its records. +- Create the sender with `integrations-email-create`, then get its DNS records with `integrations-email-verify-create`. +- PostHog never writes DNS. Call `integrations-domain-connect-check-retrieve` with the domain. When `supported` is true, give the person the URL from `integrations-domain-connect-apply-url-create`: they approve the records at their DNS host. Otherwise, give them the records that are not `success` as a table with type, name, value, and priority. +- Always include both SPF records, merged into an existing `v=spf1` record when there is one. Never add a second `_dmarc` record when the domain already has one. +- Call `integrations-email-verify-create` again after the records are published, at least a minute apart. `success` means verified. `failed` means fix the records, then verify again. After about 15 minutes of `pending` with every record published, tell the person that DNS is still propagating and that they can ask you to check again later, record the domain as not verified, and continue. +- When the person can't publish the records during this run, such as when another team owns DNS, record the domain as not verified with its records or its Domain Connect link, and continue. +- A 403 that says the person lacks permissions in the project means a project admin must verify the sender. Do not retry: record the domain as not verified, and continue. +- When the sender tools are missing or refused, do not try other integration tools. Ask the person to add a sender on the domain in PostHog: Workflows, then Channels, then add an email sender, and follow the DNS steps (https://posthog.com/docs/workflows/configure-channels). Record the domain as not verified unless they confirm it shows as verified, and continue. + +## Write down + +Tick the `sender` phase in the plan file: `- [x] sender — / verified — dns_ready: `. `dns_ready` is `true` only when every domain has a verified sender. List each domain that is not verified, with what the person still has to do. diff --git a/context/skills/migrate-email/references/5-suppressions.md b/context/skills/migrate-email/references/5-suppressions.md new file mode 100644 index 00000000..5dfeb60d --- /dev/null +++ b/context/skills/migrate-email/references/5-suppressions.md @@ -0,0 +1,44 @@ +--- +title: Import the suppression list +next_step: 6-key.md +--- + +# Step 5, import the suppression list + +The provider keeps a suppression list: addresses that hard-bounced or marked an email as spam. It skips them on every send. PostHog keeps its own list and starts empty, so without an import the first PostHog sends go to these addresses again. That hurts the sending reputation of the project and the domain. Import the list before cutover. + +## Status + +``` +[STATUS] Importing the suppression list +``` + +## Read the provider's notes + +Read the provider's `account.md` first, whether or not the import runs. Its "For the report" section, when it has one, names facts the report carries, such as a count of entries the import leaves out. Find each one as the section says, and write it under the phase in the plan file. Never write an address. + +## Check the import tool + +Look for the PostHog MCP tool `email-suppressions-add`, and read its schema with `info`. + +When it is missing, the import is not available to this person yet. Do not try other tools, and do not use the opt-out tools: opt-outs are marketing preferences, and transactional email ignores them. Tick the phase as `- [ ] suppressions — not imported: no import tool`, and continue to the next step. The report tells the person to add the addresses in PostHog (Workflows, then Messaging, then Suppression list) right before cutover, so the list holds the entries the provider adds until then. + +## Get the list + +The provider's `account.md` says how to export the list, which entries to take, and how each entry maps to PostHog. + +- Prefer the export the person downloads from the provider's dashboard. Ask them where they saved the file. +- Read the list from the provider's API only when the person gives you a key that can read it, through an environment variable that `account.md` names. The app's own sending key usually can't. +- Keep the list out of the repository. Read the file where the person saved it, or write the API's answer to a file in the system's temporary folder, and delete that file when the import is done. + +## Import it + +- Send the entries to `email-suppressions-add` in batches of the size its schema allows. Give each entry the source and the reason `account.md` names. +- Repeating an import is safe: an address already on the list stays on it. +- The provider keeps adding to its list while it sends, so the report asks the person for one last import right before cutover. +- Never print the addresses, and never write them into the plan or the report. Count them. +- When a call fails, read its error and fix the call. When it fails again, stop the import and record how many entries you imported. + +## Write down + +Tick the phase in the plan file only when every entry is imported: `- [x] suppressions — / imported`. When the list is empty, write `- [x] suppressions — empty list`. When the import stopped partway, write `- [ ] suppressions — / imported: `. When the person has no export and no key, write `- [ ] suppressions — not imported: `. diff --git a/context/skills/migrate-email/references/6-key.md b/context/skills/migrate-email/references/6-key.md new file mode 100644 index 00000000..2366c5ba --- /dev/null +++ b/context/skills/migrate-email/references/6-key.md @@ -0,0 +1,44 @@ +--- +title: Hand over the PostHog secret key +next_step: 7-report.md +--- + +# Step 6, hand over the PostHog secret key + +PostHog's send needs a project secret API key on the server. The provider's key stays where it is until the cutover is done. + +## Status + +``` +[STATUS] Setting up the PostHog secret key +``` + +## The PostHog key + +- Ask the person to create a project secret API key (it starts with `phs_`) with the `transactional_email:write` scope in their PostHog project settings. A key that already has `hog_flow:write` works too. +- The key goes into `POSTHOG_PROJECT_SECRET_API_KEY`, in the env file the app loads its provider key from, such as `.env` or `.env.local`. Check that git ignores that file. When it doesn't, ask the person where the key goes. +- Ask the person to add the key to that file themselves. Inside the Wizard, ask for it and store it as the "Inside the PostHog Wizard" section of `SKILL.md` says. +- Add the variable name, without a value, to the project's env example file when it has one. +- Never hardcode the key, never commit it, and never ask the person to paste it into the chat. + +## Check that the key is set + +The key is set only when the env file holds it. Inside the Wizard, it is set once `set_env_values` succeeds. Otherwise, once the person says they added it, check the file with a command that prints a count and never the key. It reads the last line that sets the variable, as the app's env loader does, and checks that the value, after an optional quote, starts with `phs_`: + +```bash +grep -E '^[[:space:]]*(export[[:space:]]+)?POSTHOG_PROJECT_SECRET_API_KEY[[:space:]]*=' .env | tail -n 1 | grep -cE "^[^=]*=[[:space:]]*[\"']?phs_" +``` + +Use the env file the key went into in place of `.env`. A count of `0` means the key is not set. + +A person who says they will add the key later has not set it, and neither has one who declines. Don't wait for them: record the key as not set, and go on. + +## Every other place the provider key lives + +The provider's `sdk-reference.md` names its key's environment variable. Search the whole repository for that name, including CI workflows, deploy and hosting config, Docker and compose files, env example files, and docs. Skip `node_modules` and build output. + +List each place with its file and line. Each one needs `POSTHOG_PROJECT_SECRET_API_KEY` too before the cutover, for example as a CI secret or a hosting environment variable. Do not change these files: the person adds the secret where the platform stores it. + +## Write down + +Tick the phase in the plan file only when the check above found the key: `- [x] key — POSTHOG_PROJECT_SECRET_API_KEY set in `. Otherwise write `- [ ] key — not set: `, such as `the person adds it to .env`. Under the phase list, add the places of the provider key. diff --git a/context/skills/migrate-email/references/7-report.md b/context/skills/migrate-email/references/7-report.md new file mode 100644 index 00000000..47a27361 --- /dev/null +++ b/context/skills/migrate-email/references/7-report.md @@ -0,0 +1,83 @@ +--- +title: Write the migration report +next_step: null +--- + +# Step 7, write the migration report + +The report comes from the plan file. Invent nothing. + +## Status + +``` +[STATUS] Writing the migration report +``` + +## Write the report + +Read `.posthog-email-migration-plan.md`. Write `posthog-migrate-email--report.md` at the project root, where `` is the name of the provider folder under `references/`. Use the structure below, then delete the plan file. Never write a recipient's email address into the report. + +```markdown +# Email migration report: to PostHog + + + +## Send call sites + +| File | Line | Content | Status | +|------|------|---------|--------| + +: ".> + +## Other calls + +.> + +## Sender and DNS + + + +## Suppression list + + + +## PostHog secret key + + + +## Sending tier + + + +## Before cutover + + + +## Summary +``` + +End the report with the summary block, as the last thing in the file. The Wizard parses it, so keep the keys, their order, and bare values: a whole number, `true`, `false`, or the provider folder name. Never add a comment or a reason to a value. The reasons are in the tables above. For example: + +```yaml +from: resend +sites_found: 3 +sites_rewritten: 0 +sites_left: 1 +dns_ready: false +suppressions_ready: true +``` + +- `from`: the provider folder name. +- `sites_found`: every row of the sites table. +- `sites_rewritten`: the sites this run changed to send with PostHog. This version changes none, so it is `0`. +- `sites_left`: the sites that stay on the provider. +- `dns_ready`: `true` when every sending domain has a verified sender. +- `suppressions_ready`: `true` when every entry of the suppression list was imported, or the list was empty. + +Sites that are ready to move count in `sites_found` only. + +After the report is written, print its path: + +``` +Created migration report: +``` diff --git a/context/skills/migrate-email/references/resend/account.md b/context/skills/migrate-email/references/resend/account.md new file mode 100644 index 00000000..dfd42d4b --- /dev/null +++ b/context/skills/migrate-email/references/resend/account.md @@ -0,0 +1,42 @@ +# Resend account + +What the migration needs from the person's Resend account. The agent never changes the account: it reads what the person exports or allows. + +## DNS records + +A domain verified at Resend carries these records. Keep all of them until the cutover is done. + +| Record | Name | What it does | +|---|---|---| +| `TXT` (DKIM) | `resend._domainkey` | Signs Resend's mail | +| `MX` | `send` | Return path for Resend's bounces | +| `TXT` (SPF) | `send` | Allows Resend to send for the return path | +| `TXT` (DMARC) | `_dmarc` | Optional, shared by every provider on the domain | + +- `send` is Resend's default return-path label. A domain can use a custom one: the Resend dashboard shows it under Domains. +- PostHog's sender uses its own label for its MAIL FROM, `feedback` by default. Never give PostHog the label Resend uses. +- PostHog's DKIM records have their own names, so they sit next to `resend._domainkey`. +- Keep the one `_dmarc` record. Never add a second one. + +## Suppression list + +Resend suppresses an address after a hard bounce or a spam complaint, and keeps addresses the team added by hand. + +**Export from the dashboard (preferred).** In Resend: Emails, then Suppressions, then **Download CSV**. Read the header row to find the address column and the origin column. + +**Read from the API.** `GET https://api.resend.com/suppressions?limit=100` with `Authorization: Bearer $RESEND_SUPPRESSIONS_API_KEY`. + +- The answer is `{ "object": "list", "has_more": , "data": [{ "id", "email", "origin", "source_id", "created_at" }] }`. +- Page with `after=` until `has_more` is `false`. +- Resend allows 10 requests per second per team. Wait and retry on `429`. +- A sending-only key can't read the list. Ask the person to create a full-access key, store it in `RESEND_SUPPRESSIONS_API_KEY` for the import, and delete the key in Resend afterwards. + +**Map each entry.** + +| Resend `origin` | PostHog source | Reason | +|---|---|---| +| `bounce` | bounce | `Imported from Resend: hard bounce` | +| `complaint` | complaint | `Imported from Resend: spam complaint` | +| `manual` | manual | `Imported from Resend: added by hand` | + +**Leave out unsubscribed contacts.** A Resend contact with `unsubscribed: true` still gets transactional email, and PostHog's opt-outs apply to marketing messages only. They are not suppressions. diff --git a/context/skills/migrate-email/references/resend/mapping.md b/context/skills/migrate-email/references/resend/mapping.md new file mode 100644 index 00000000..7dbd3587 --- /dev/null +++ b/context/skills/migrate-email/references/resend/mapping.md @@ -0,0 +1,53 @@ +# Resend to PostHog mapping + +This file decides, field by field, whether PostHog can send a Resend call site. A site moves only when every field it sets moves. Otherwise it stays on Resend, and the reason names the first field or feature that keeps it there. `api.md` has the PostHog side in full. + +## Fields + +| Resend | PostHog | A site stays on Resend when | +|---|---|---| +| `from` | `from` | Never. It needs a verified sender (Step 4) | +| `to` | `to`, one bare address | `to` can hold more than one address, its entry carries a name, such as `Jane `, or the code builds it at run time and you cannot tell that it holds a bare address only. A list with exactly one bare address moves as that address | +| `subject` | `subject` | Never | +| `html` | `html` | Never | +| `text` | `text` | Never | +| `react` | `html`, rendered in the app | Never. The app renders the component | +| `template` | a template made in PostHog, sent by its `templateId` with `templateData` | The person keeps it on Resend for now, its content can't be read, it uses syntax beyond variables and fallbacks, the app fills a variable with HTML, or the site sets `from`, `replyTo` / `reply_to`, or `subject` next to it | +| `cc`, `bcc` | none | Always | +| `replyTo` / `reply_to` | `replyTo` / `reply_to`, as a list | An entry carries a name, such as `Jane `, there are more than 10, or the code builds the value at run time and you cannot tell that it holds bare addresses only. A single address moves as a list of one | +| `headers` | none | Always | +| `tags` | none | Always | +| `attachments` | none | Always | +| `idempotencyKey` / `Idempotency-Key` | `idempotencyKey` / `Idempotency-Key` | The key can hold anything but visible ASCII characters, such as a space, or be longer than 255 characters. PostHog takes 1 to 255 visible ASCII characters. Otherwise the key keeps its value, and PostHog keeps it for 24 hours as Resend does. The two providers don't share keys: a retry that runs after the deploy, of a send Resend already took, sends a second email. Note it in the report | +| `scheduledAt` / `scheduled_at` | none | Always | +| `topicId` / `topic_id` | none | Always | + +## Calls + +| Resend call | A site stays on Resend when | +|---|---| +| `emails.send`, `emails.create`, `Emails.send`, `POST /emails` | A field above keeps it | +| `batch.send`, `Batch.send`, `POST /emails/batch` | Always | +| SMTP through `smtp.resend.com` | Always. PostHog has no SMTP relay | +| Laravel's or Rails' `resend` mailer | Always. PostHog has no mailer driver | +| A send in a language other than JavaScript or TypeScript | Always, until the skill moves that language | + +## Code around the send + +| Code | A site stays on Resend when | +|---|---| +| `if (error.name === '...')` | The name is not one of posthog-node's, which every site that moves sends through, an HTTP site too: PostHog's HTTP API answers with `type` and `code`, and only posthog-node gives these names: `validation_error`, `missing_api_key`, `invalid_permission`, `not_found`, `concurrent_idempotent_requests`, `invalid_idempotent_request`, `rate_limit_exceeded`, `monthly_quota_exceeded`, `service_unavailable`, `application_error` | +| `emails.get(id)` and a branch on `last_event` | Always: the send and its status read stay together. PostHog's status is not `last_event` | +| `data.id` stored for a webhook or a status read | Always, when Resend webhooks or status reads use the id | + +## Content + +- `html`, `text`, and `react` content is rendered for one recipient before the send. PostHog sends it as it is. +- A `template` site moves only to a template made in PostHog. This skill never makes one: the person makes it from the Resend template and gives you its template ID, and makes it transactional once a verified sender covers its address, as Step 3 says. Until they give the ID, the site stays on Resend. +- An email that carries a login code, a reset link, or another secret needs its PostHog template marked sensitive: PostHog then keeps no copy of each sent email's body or filled-in subject, and a clicked link keeps only the site's address. Templates are not sensitive by default. +- The check needs the template's content from the Resend dashboard, because the code has only its id. +- In the PostHog template, `{{{NAME}}}` becomes `{{ variables.NAME }}`, and the name keeps its case. `{{{NAME|there}}}` becomes `{{ variables.NAME | default: 'there' }}`, and a variable with a `fallback_value` in the template's definition gets the same `default`. Quote a fallback that holds a `'` with `"` instead. Liquid has no escape character in a quoted value. +- Liquid's `default` also replaces an empty string, which Resend inserts as it is. When the code can pass an empty string for a variable with a fallback, the email shows the fallback instead. Note it in the report. +- Resend inserts a variable's value as it is. PostHog's Liquid escapes HTML in a value, so `` shows as text. A variable is HTML when the code fills it with markup or with a renderer's output, and such a variable keeps the site on Resend. A variable the app fills with plain values, such as names, numbers, and URLs, renders the same. +- A template that holds anything other than `{{{VAR}}}` and `{{{VAR|fallback}}}` placeholders, such as a section, a loop, or a condition, stays on Resend. So does a fallback that holds both `'` and `"`, and a fallback that holds HTML, such as a link: Liquid escapes the fallback too, so the markup shows as text. +- The PostHog template carries its own sender, reply-to address, and subject. A site that sets `from`, `replyTo` / `reply_to`, or `subject` next to `template` stays on Resend. diff --git a/context/skills/migrate-email/references/resend/sdk-reference.md b/context/skills/migrate-email/references/resend/sdk-reference.md new file mode 100644 index 00000000..fa3cf5f0 --- /dev/null +++ b/context/skills/migrate-email/references/resend/sdk-reference.md @@ -0,0 +1,90 @@ +# Resend SDK reference + +This file says what Resend looks like in a codebase. It does not describe PostHog. `mapping.md` maps each field to PostHog. + +## Key + +The API key lives in `RESEND_API_KEY` by convention. Apps also use their own names, such as `PRIVATE_RESEND_API_KEY`: take the name the client reads. + +## Packages + +- Node.js: `resend`. +- Python: `resend` (PyPI). +- Ruby: `resend` (RubyGems), and `resend-rails`. +- PHP: `resend/resend-php`, and `resend/resend-laravel` for Laravel. +- Go: `github.com/resend/resend-go`. +- Elixir: `resend` (Hex). +- Java: `com.resend:resend-java`. +- .NET: `Resend`. +- Rust: `resend-rs`. + +These are not Resend, and alone they are not a migration: `@react-email/components`, `@react-email/render`, `react-email`, and `jsx-email` render emails, and `nodemailer` sends through any SMTP server. + +## Clients + +```js +import { Resend } from 'resend' +const resend = new Resend(process.env.RESEND_API_KEY) +``` + +```python +import resend +resend.api_key = os.environ["RESEND_API_KEY"] +``` + +Look for `new Resend(`, `from 'resend'`, `require('resend')`, `import resend`, `Resend::`, and `resend.NewClient(`. + +## Sends + +**Node.js.** `resend.emails.send(payload, options?)`, and its alias `resend.emails.create(...)`. The answer is `{ data, error }`: `data.id` is the email's id, and `error` has `name`, `message`, and `statusCode`. The SDK does not throw on API errors. + +```js +const { data, error } = await resend.emails.send({ + from: 'Example ', + to: ['jane@example.com'], + subject: 'Welcome', + html: '

Welcome

', +}, { idempotencyKey: `welcome:${user.id}` }) +``` + +**Python.** `resend.Emails.send(params)`, with the same fields in snake_case, and `options={"idempotency_key": ...}`. It raises on API errors. + +**Batch.** `resend.batch.send([...])` in Node.js and `resend.Batch.send([...])` in Python send up to 100 emails in one call. + +**HTTP.** `POST https://api.resend.com/emails` with `Authorization: Bearer `, a JSON body with the snake_case fields, and an optional `Idempotency-Key` header. `POST https://api.resend.com/emails/batch` is the batch send. Look for `api.resend.com` in `fetch`, `axios`, `got`, `requests`, `httpx`, and `curl` calls. + +**SMTP.** Resend also takes mail over SMTP at `smtp.resend.com`, with the user `resend` and the API key as the password. Look for that host in Nodemailer transports, `MAIL_HOST` or `SMTP_HOST` settings, and framework mail config. + +**Frameworks.** Laravel's `resend` mailer (`MAIL_MAILER=resend`) and Rails' `resend` delivery method send every framework mail through Resend: each mailable or mailer that sends is a site. + +## Send fields + +| Node.js field | HTTP field | Value | +|---|---|---| +| `from` | `from` | One address, with an optional name: `Name ` | +| `to` | `to` | One address, or a list of up to 50 | +| `subject` | `subject` | String | +| `html` | `html` | String | +| `text` | `text` | String. Resend derives one from `html` when it is absent | +| `react` | (none) | A React element that the Node.js SDK renders to HTML | +| `cc`, `bcc` | `cc`, `bcc` | One address or a list | +| `replyTo` | `reply_to` | One address or a list | +| `headers` | `headers` | Object of custom headers | +| `tags` | `tags` | List of `{ name, value }` | +| `attachments` | `attachments` | List of `{ filename, content, path, contentType, contentId }`. `content` is a Buffer or base64, `path` is a URL Resend fetches | +| `scheduledAt` | `scheduled_at` | A time to send later | +| `topicId` | `topic_id` | A topic the recipient can unsubscribe from | +| `template` | `template` | `{ id, variables }`: a template stored at Resend | +| `idempotencyKey` option | `Idempotency-Key` header | Up to 256 characters, kept for 24 hours | + +## Templates stored at Resend + +A send with `template: { id: 'tmpl_...', variables: { FIRST_NAME: 'Jane' } }` renders a template from the Resend dashboard. Its content is not in the code. The template reads variables as `{{{FIRST_NAME}}}`, with an optional fallback: `{{{FIRST_NAME|there}}}`. + +## Status reads + +`resend.emails.get(id)` and `GET https://api.resend.com/emails/{id}` return the email with `last_event`, such as `delivered`, `bounced`, or `complained`. Code that branches on `last_event` reads Resend's delivery state. + +## Other APIs + +`resend.contacts`, `resend.audiences`, `resend.segments`, `resend.broadcasts`, `resend.domains`, `resend.apiKeys`, `resend.webhooks`, and their HTTP routes are not transactional sends. diff --git a/context/skills/migrate/config.yaml b/context/skills/migrate/config.yaml index c56409d5..d4e2cb5a 100644 --- a/context/skills/migrate/config.yaml +++ b/context/skills/migrate/config.yaml @@ -5,7 +5,7 @@ description: Migrate an existing analytics or feature-flag vendor to PostHog. Re tags: [migration] cli: role: command - command: migrate + parentCommand: migrate references: preamble: "**Read ONLY this file.** Do not read any other reference file until this one tells you to." shared_docs: [] diff --git a/scripts/lib/skill-generator.js b/scripts/lib/skill-generator.js index 454142ac..c5b35505 100644 --- a/scripts/lib/skill-generator.js +++ b/scripts/lib/skill-generator.js @@ -594,6 +594,14 @@ function generateFrontmatter(skill, version) { return '---\n' + yaml.dump(frontmatter) + '---\n\n'; } +function markdownReferenceNames(dir, prefix) { + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir, { withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.endsWith('.md')) + .map(entry => path.posix.join(prefix, entry.name)) + .sort((a, b) => a.localeCompare(b)); +} + /** * Generate a complete skill package * @@ -667,21 +675,22 @@ async function generateSkill({ // Group config injects a shared `preamble`; per-file `next_step` frontmatter drives continuation links. const sourceReferencesDir = path.join(configDir, 'skills', ...skill._group.split('/'), 'references'); if (fs.existsSync(sourceReferencesDir)) { - const localReferences = fs.readdirSync(sourceReferencesDir, { withFileTypes: true }) - .filter(entry => entry.isFile() && entry.name.endsWith('.md')) - .sort((a, b) => a.name.localeCompare(b.name)); + const localReferences = [ + ...markdownReferenceNames(sourceReferencesDir, ''), + ...markdownReferenceNames(path.join(sourceReferencesDir, skill._shortId), skill._shortId), + ]; const refsConfig = skill._references || {}; for (const reference of localReferences) { - const sourcePath = path.join(sourceReferencesDir, reference.name); + const sourcePath = path.join(sourceReferencesDir, reference); const parsed = matter(fs.readFileSync(sourcePath, 'utf8')); const nextFile = parsed.data.next_step; const isWorkflowStep = 'next_step' in parsed.data; let body = expandPartials(parsed.content, configDir).replace(/^\n+/, '').replace(/\s+$/, ''); const headingMatch = body.match(/^#\s+(.+)$/m); - const displayTitle = parsed.data.title || headingMatch?.[1] || reference.name; - const displayDescription = parsed.data.description || headingMatch?.[1] || reference.name; + const displayTitle = parsed.data.title || headingMatch?.[1] || reference; + const displayDescription = parsed.data.description || headingMatch?.[1] || reference; if (nextFile) { if (refsConfig.preamble && headingMatch) { @@ -699,20 +708,18 @@ async function generateSkill({ ? `---\n${yaml.dump(emittedFrontmatter, { lineWidth: -1 })}---\n\n${body}` : body; - fs.writeFileSync( - path.join(referencesDir, reference.name), - fileContent, - 'utf8' - ); + const targetPath = path.join(referencesDir, reference); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, fileContent, 'utf8'); references.push({ - filename: reference.name, + filename: reference, description: displayDescription, }); if (isWorkflowStep) { workflowSteps.push({ - filename: reference.name, + filename: reference, title: displayTitle, }); } diff --git a/scripts/lib/tests/migrate-email-skill.test.js b/scripts/lib/tests/migrate-email-skill.test.js new file mode 100644 index 00000000..e55985fd --- /dev/null +++ b/scripts/lib/tests/migrate-email-skill.test.js @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { spawnSync } from 'child_process'; +import { join, relative } from 'path'; +import { tmpdir } from 'os'; +import { generateCliEntries } from '../build-phases.js'; + +const CONFIG_DIR = join(process.cwd(), 'context'); +const DOCS_PAGES = { + 'https://posthog.com/docs/workflows/transactional-emails.md': '# Transactional emails\n\nSend one email to one person.\n', + 'https://posthog.com/docs/workflows/transactional-emails/api.md': '# Send transactional emails with the API\n', +}; +const REFERENCE_PATH = /references\/[A-Za-z0-9_./-]+\.md/g; + +async function loadSkills() { + vi.resetModules(); + vi.stubEnv('DOCS_CACHE_TTL_MS', '0'); + vi.stubGlobal('fetch', async (url) => { + if (!(url in DOCS_PAGES)) return new Response('not found', { status: 404, statusText: 'Not Found' }); + return new Response(DOCS_PAGES[url], { status: 200 }); + }); + const generator = await import('../skill-generator.js'); + const skills = generator.expandSkillGroups(generator.loadSkillsConfig(CONFIG_DIR), CONFIG_DIR); + return { generator, skills }; +} + +async function buildSkill(skillId, outputDir) { + const { generator, skills } = await loadSkills(); + const skill = skills.find((candidate) => candidate.id === skillId); + expect(skill).toBeDefined(); + + await generator.generateSkill({ + skill, + version: 'test', + repoRoot: process.cwd(), + configDir: CONFIG_DIR, + outputDir, + skipPatterns: { global: [], examples: {} }, + commandmentsConfig: { commandments: {} }, + skillTemplate: skill._template, + sharedDocs: skill._sharedDocs, + }); + return join(outputDir, skillId); +} + +function markdownFiles(dir) { + return readdirSync(dir, { withFileTypes: true, recursive: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.md')) + .map((entry) => join(entry.parentPath ?? entry.path, entry.name)); +} + +function unresolvedReferences(skillDir) { + const missing = []; + for (const file of markdownFiles(skillDir)) { + for (const reference of readFileSync(file, 'utf8').match(REFERENCE_PATH) ?? []) { + if (!existsSync(join(skillDir, reference))) missing.push(`${relative(skillDir, file)} → ${reference}`); + } + } + return missing; +} + +describe('migrate-email skill', () => { + let outputDir; + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + if (outputDir) rmSync(outputDir, { recursive: true, force: true }); + }); + + it('builds migrate-email-resend as a bundle whose every reference path resolves', async () => { + outputDir = mkdtempSync(join(tmpdir(), 'migrate-skill-')); + + const skillDir = await buildSkill('migrate-email-resend', outputDir); + + expect(readFileSync(join(skillDir, 'SKILL.md'), 'utf8')).toMatch(/^---\nname: migrate-email-resend\n/); + expect(unresolvedReferences(skillDir)).toEqual([]); + }); + + it('bundles the transactional email docs and the Resend files', async () => { + outputDir = mkdtempSync(join(tmpdir(), 'migrate-skill-')); + + const skillDir = await buildSkill('migrate-email-resend', outputDir); + + expect(readFileSync(join(skillDir, 'references', 'transactional-emails.md'), 'utf8')).toBe( + DOCS_PAGES['https://posthog.com/docs/workflows/transactional-emails.md'], + ); + expect(readFileSync(join(skillDir, 'references', 'api.md'), 'utf8')).toBe( + DOCS_PAGES['https://posthog.com/docs/workflows/transactional-emails/api.md'], + ); + expect(existsSync(join(skillDir, 'references', 'resend', 'sdk-reference.md'))).toBe(true); + expect(markdownFiles(skillDir).map((file) => readFileSync(file, 'utf8')).join('\n')).not.toMatch(/\{\{>|\{display_name\}|\{references\}/); + }); + + describe('the key check in step 6', () => { + const keyStep = readFileSync(join(CONFIG_DIR, 'skills', 'migrate-email', 'references', '6-key.md'), 'utf8'); + const command = keyStep.match(/```bash\n(grep .+)\n```/)?.[1]; + let envDir; + + afterEach(() => { + if (envDir) rmSync(envDir, { recursive: true, force: true }); + envDir = undefined; + }); + + function check(envFile) { + envDir = mkdtempSync(join(tmpdir(), 'migrate-key-')); + writeFileSync(join(envDir, '.env'), envFile); + return spawnSync('bash', ['-c', command], { cwd: envDir, encoding: 'utf8' }); + } + + it.each([ + ['a plain key', 'POSTHOG_PROJECT_SECRET_API_KEY=phs_abc\n', '1'], + ['an exported, spaced and double-quoted key', ' export POSTHOG_PROJECT_SECRET_API_KEY = "phs_abc"\n', '1'], + ['a single-quoted key', "POSTHOG_PROJECT_SECRET_API_KEY='phs_abc'\n", '1'], + ['a key after an empty line for it', 'POSTHOG_PROJECT_SECRET_API_KEY=\nPOSTHOG_PROJECT_SECRET_API_KEY=phs_abc\n', '1'], + ['no line for the key', 'RESEND_API_KEY=re_abc\n', '0'], + ['an empty key', 'POSTHOG_PROJECT_SECRET_API_KEY=\n', '0'], + ['an empty quoted key', 'POSTHOG_PROJECT_SECRET_API_KEY=""\n', '0'], + ['a commented-out line', '# POSTHOG_PROJECT_SECRET_API_KEY=phs_abc\n', '0'], + ['a value that is only a comment', 'POSTHOG_PROJECT_SECRET_API_KEY= #phs_abc\n', '0'], + ['a key overridden by a later empty line', 'POSTHOG_PROJECT_SECRET_API_KEY=phs_abc\nPOSTHOG_PROJECT_SECRET_API_KEY=\n', '0'], + ['another variable ending in the name', 'OTHER_POSTHOG_PROJECT_SECRET_API_KEY=phs_abc\n', '0'], + ['a placeholder in angle brackets', 'POSTHOG_PROJECT_SECRET_API_KEY=\n', '0'], + ['a value with another prefix', 'POSTHOG_PROJECT_SECRET_API_KEY=xphs_abc\n', '0'], + ['a value that starts with =', 'POSTHOG_PROJECT_SECRET_API_KEY==phs_abc\n', '0'], + ])('finds the key in %s only when it is set, and never prints it', (_name, envFile, count) => { + expect(command).toBeDefined(); + const result = check(envFile); + + expect(result.stdout.trim()).toBe(count); + expect(result.stdout).not.toContain('phs_'); + expect(result.stderr).toBe(''); + }); + }); + + it('publishes Resend and Statsig as wizard migrate subcommands in the menu the Wizard reads', async () => { + const { generator, skills } = await loadSkills(); + const entries = generateCliEntries({ allSkills: skills.map(generator.serializeSkill) }); + const entryOf = (skillId) => entries.find((entry) => entry.skillId === skillId); + + expect(entryOf('migrate-email-resend')).toMatchObject({ role: 'command', parentCommand: 'migrate', command: 'resend' }); + expect(entryOf('migrate-statsig')).toMatchObject({ role: 'command', parentCommand: 'migrate', command: 'statsig' }); + }); +}); diff --git a/scripts/lib/tests/skill-generator-references-folder.test.js b/scripts/lib/tests/skill-generator-references-folder.test.js index d5108e5f..925f520b 100644 --- a/scripts/lib/tests/skill-generator-references-folder.test.js +++ b/scripts/lib/tests/skill-generator-references-folder.test.js @@ -69,4 +69,51 @@ describe('generateSkill local references', () => { expect(readFileSync(generatedRef, 'utf8')).toBe('# Product analytics best practices\n\nDetails'); expect(readFileSync(generatedSkill, 'utf8')).toContain('references/product-analytics.md'); }); + + it("ships a variant's own reference folder and no other variant's", async () => { + createFixture({ + skills: { + migrate: { + 'description.md': '# Migrate\n\n{references}\n', + references: { + '1-presence.md': '---\nnext_step: null\n---\n\n# Presence\n\nRead `references//mapping.md`.', + statsig: { 'mapping.md': '# Statsig mapping' }, + amplitude: { 'mapping.md': '# Amplitude mapping' }, + }, + }, + }, + }, tmpDir); + + const config = { + migrate: { + type: 'skill', + template: 'description.md', + variants: [ + { id: 'statsig', display_name: 'Statsig' }, + { id: 'amplitude', display_name: 'Amplitude' }, + ], + }, + }; + + const statsig = expandSkillGroups(config, tmpDir).find((skill) => skill.id === 'migrate-statsig'); + const outputDir = join(tmpDir, 'out'); + + await generateSkill({ + skill: statsig, + version: 'test', + repoRoot: tmpDir, + configDir: tmpDir, + outputDir, + skipPatterns: { global: [], examples: {} }, + commandmentsConfig: { commandments: {} }, + skillTemplate: statsig._template, + sharedDocs: [], + }); + + const references = join(outputDir, 'migrate-statsig', 'references'); + expect(readFileSync(join(references, 'statsig', 'mapping.md'), 'utf8')).toBe('# Statsig mapping'); + expect(existsSync(join(references, 'amplitude'))).toBe(false); + expect(readFileSync(join(outputDir, 'migrate-statsig', 'SKILL.md'), 'utf8')) + .toContain('`references/statsig/mapping.md`'); + }); });