@hogsend/cli 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/bin.js +1985 -807
  2. package/dist/bin.js.map +1 -1
  3. package/package.json +4 -4
  4. package/skills/hogsend-integrate/SKILL.md +198 -0
  5. package/skills/hogsend-integrate/references/auth-billing-seams.md +199 -0
  6. package/skills/hogsend-integrate/references/framework-recipes.md +208 -0
  7. package/skills/hogsend-integrate/references/verification.md +86 -0
  8. package/skills/hogsend-migrate/SKILL.md +147 -0
  9. package/skills/hogsend-migrate/references/customerio-mapping.md +93 -0
  10. package/skills/hogsend-migrate/references/cutover-checklist.md +136 -0
  11. package/skills/hogsend-migrate/references/loops-mapping.md +132 -0
  12. package/skills/hogsend-migrate/references/resend-broadcasts-mapping.md +120 -0
  13. package/src/__tests__/dev.test.ts +323 -0
  14. package/src/__tests__/dns-apply.test.ts +297 -0
  15. package/src/__tests__/dns.test.ts +143 -0
  16. package/src/__tests__/domain-command.test.ts +216 -0
  17. package/src/__tests__/proc.test.ts +177 -0
  18. package/src/__tests__/setup-steps.test.ts +363 -0
  19. package/src/commands/dev.ts +444 -0
  20. package/src/commands/domain.ts +437 -0
  21. package/src/commands/events.ts +4 -1
  22. package/src/commands/index.ts +4 -0
  23. package/src/commands/setup.ts +34 -163
  24. package/src/lib/dns-apply.ts +218 -0
  25. package/src/lib/dns.ts +217 -0
  26. package/src/lib/proc.ts +189 -0
  27. package/src/lib/setup-steps.ts +333 -0
  28. package/studio/assets/index-CSXAjTbe.js +265 -0
  29. package/studio/assets/index-DCsT0fnT.css +1 -0
  30. package/studio/index.html +2 -2
  31. package/studio/assets/index-BBOTQnww.js +0 -250
  32. package/studio/assets/index-DnfpcXbb.css +0 -1
@@ -0,0 +1,86 @@
1
+ # Verification — prove the pipe works with the `hogsend` CLI
2
+
3
+ After wiring, verify end-to-end with the `hogsend` CLI (ships with
4
+ `@hogsend/cli`; run via `pnpm dlx @hogsend/cli` or a global install). It reads
5
+ the same env names the host app uses:
6
+
7
+ - **Base URL:** `--url <baseUrl>` > `HOGSEND_API_URL` > default
8
+ `http://localhost:3002`.
9
+ - **Data key** (writes — `events send`): `--data-key` > `HOGSEND_DATA_KEY` >
10
+ `HOGSEND_API_KEY`. The ingest key the host app already has works here.
11
+ - **Admin key** (reads — `events <userId>`, `contacts`, `stats`):
12
+ `--admin-key` > `HOGSEND_ADMIN_KEY` > `ADMIN_API_KEY`. Ask the user for one
13
+ if the host env only carries the ingest key.
14
+
15
+ Always pass `--json` when parsing output programmatically.
16
+
17
+ ## 0. Is the instance reachable?
18
+
19
+ ```bash
20
+ hogsend doctor --json
21
+ ```
22
+
23
+ Expect verdict `ok` (unauthenticated `/v1/health` — needs no key). If
24
+ `unreachable`, fix `HOGSEND_API_URL` before debugging anything else.
25
+
26
+ ## 1. Fire a synthetic event through the data plane
27
+
28
+ ```bash
29
+ TEST_ID="test_agent_$(date +%s)"
30
+ hogsend events send signup --user-id "$TEST_ID" --prop source=integration-test --json
31
+ ```
32
+
33
+ Expected output (202 path):
34
+
35
+ ```json
36
+ { "stored": true, "exits": [] }
37
+ ```
38
+
39
+ `stored: true` = the event row is durably written. This proves base URL + key +
40
+ scope are right, independent of the host app's code.
41
+
42
+ ## 2. Confirm it landed (read path, admin key)
43
+
44
+ ```bash
45
+ hogsend events "$TEST_ID" --json
46
+ ```
47
+
48
+ Expect one `signup` event with `properties.source = "integration-test"`. Also
49
+ useful:
50
+
51
+ ```bash
52
+ hogsend contacts get "$TEST_ID" --json # contact upserted by ingestion
53
+ hogsend contacts timeline "$TEST_ID" --json # merged event/email/journey view
54
+ ```
55
+
56
+ ## 3. Exercise the REAL seam
57
+
58
+ Trigger the actual code path you wired — sign a test user up, or replay a
59
+ provider webhook (`stripe trigger checkout.session.completed` with the Stripe
60
+ CLI, Clerk's "Send Example" button). Then re-run step 2 with the real user's
61
+ id/email and confirm the event + contact appear.
62
+
63
+ ## 4. Did anything react?
64
+
65
+ If journeys are defined on the Hogsend side, the send result's `exits` array
66
+ and `hogsend journeys list --json` (enabled journeys + state counts) show
67
+ whether the event enrolled/exited anyone. No journeys reacting is fine at this
68
+ stage — wiring the host comes first; authoring journeys is the
69
+ hogsend-authoring-journeys skill.
70
+
71
+ ## Triage table
72
+
73
+ | Symptom | Likely cause |
74
+ |---|---|
75
+ | `doctor` verdict `unreachable` | wrong `HOGSEND_API_URL`, instance down |
76
+ | 401 on `events send` | key lacks the `ingest` scope, or wrong key entirely |
77
+ | 401 on `events <userId>` | read path needs the ADMIN key, not the ingest key |
78
+ | `stored: false` | deduped — you re-sent an `idempotencyKey` already used |
79
+ | send ok, but nothing from the real seam | host code path not firing — log inside the seam, check the server actually restarted with new env |
80
+ | `RateLimitError` in app logs | back off `retryAfter` seconds; batch less aggressively |
81
+
82
+ ## Clean up
83
+
84
+ Synthetic `test_agent_*` contacts can be removed via the SDK
85
+ (`hogsend.contacts.delete({ userId: TEST_ID })`) or left — they're inert
86
+ without an email address.
@@ -0,0 +1,147 @@
1
+ ---
2
+ name: hogsend-migrate
3
+ description: Use when migrating a product OFF Loops, Customer.io, or Resend Broadcasts/Audiences onto Hogsend — auditing the existing email-SaaS integration (SDK calls in code + GUI-side workflows/campaigns/segments/templates), mapping each source concept to its Hogsend equivalent (contacts.upsert, events.send, defineJourney journeys, four-file react-email templates, defineList lists, buckets, campaigns, webhook sources), then executing an incremental dual-write → verify → switch → remove cutover that preserves unsubscribes/suppression. NOT for a greenfield integration with no incumbent (that is hogsend-integrate) and NOT for authoring mechanics themselves (delegate to the hogsend-authoring-* skills).
4
+ license: MIT
5
+ metadata:
6
+ author: withSeismic
7
+ version: "1.0.0"
8
+ ---
9
+
10
+ # Migrate to Hogsend (from Loops / Customer.io / Resend Broadcasts)
11
+
12
+ A migration has two halves: the **code half** (SDK calls you can grep) and the
13
+ **GUI half** (workflows, campaigns, segments, templates that live only in the
14
+ source platform's dashboard). This skill audits both, maps every source concept
15
+ to its Hogsend equivalent, and runs an incremental cutover — never a big-bang
16
+ switch.
17
+
18
+ Two codebases are in play. The **host product** keeps (rewired) SDK calls — work
19
+ there follows the **hogsend-integrate** patterns. The **Hogsend app** (a
20
+ scaffolded `create-hogsend` project) is where journeys, templates, and lists are
21
+ authored — mechanics delegate to the **hogsend-authoring-\*** skills.
22
+
23
+ ## Non-negotiable safety rules (read before anything else)
24
+
25
+ 1. **Import unsubscribes BEFORE any Hogsend send.** A migration that emails one
26
+ unsubscribed person is a failed migration (and a legal problem). Export the
27
+ source platform's suppression/unsubscribe list first and apply it (cutover
28
+ reference shows how). Never bulk-unsubscribe everyone "to be safe" either —
29
+ polarity matters.
30
+ 2. **Dual-write before switching.** Old and new pipelines run side by side
31
+ until verified. Hogsend writes are idempotent-friendly (`idempotencyKey`),
32
+ so dual-writing is cheap.
33
+ 3. **One sender of each email at a time.** When a Hogsend journey goes live,
34
+ pause the corresponding source-platform workflow in the same change window —
35
+ duplicated sends erode trust faster than missed ones.
36
+
37
+ ## Step 1 — audit what exists
38
+
39
+ **Code half** — grep the host product (full per-platform greps + before/after
40
+ snippets in the matching reference):
41
+
42
+ | Platform | Grep for |
43
+ |---|---|
44
+ | Loops | `loops` dep (`loops` / `@loops/*`), `sendEvent`, `updateContact`, `sendTransactionalEmail`, `transactionalId` |
45
+ | Customer.io | `customerio-node` dep, `cio.identify`, `cio.track`, `trackAnonymous`, `triggerBroadcast`, in-app `_cio` snippet |
46
+ | Resend Broadcasts | `resend.broadcasts.`, `resend.audiences.`, `resend.contacts.` — DISTINCT from plain `resend.emails.send` (transactional, may stay) |
47
+
48
+ **GUI half** — not in the codebase; ask the user to export or screenshot from
49
+ the source dashboard:
50
+
51
+ - workflows/campaigns (triggers, delays, branches, exit rules)
52
+ - segments/audiences (definitions, member counts)
53
+ - templates (HTML/content + which are transactional vs marketing)
54
+ - the suppression/unsubscribe list (CRITICAL — rule 1)
55
+ - API keys' event names actually flowing (last 30 days), to scope the event
56
+ vocabulary
57
+
58
+ Output of this step: an inventory table (source asset → type → destination in
59
+ Hogsend → owner) the user signs off on.
60
+
61
+ ## Step 2 — map concepts (per-platform tables)
62
+
63
+ The shared shape — almost everything lands in one of seven Hogsend concepts:
64
+
65
+ | Source concept (any platform) | Hogsend equivalent |
66
+ |---|---|
67
+ | Contact / person / audience member | contact — `hs.contacts.upsert` (`@hogsend/client`) |
68
+ | Event / track call | `hs.events.send` (`eventProperties` vs `contactProperties` split) |
69
+ | Workflow / loop / campaign-with-delays | journey — `defineJourney()` in the Hogsend app's `src/journeys/` |
70
+ | Transactional email | `hs.emails.send` + a four-file react-email template in `src/emails/` |
71
+ | Mailing list / audience / topic | list — `defineList()` (polarity via `defaultOptIn`) |
72
+ | Behavioral segment | bucket — `defineBucket()` (criteria-driven, real-time) |
73
+ | One-off broadcast/newsletter | campaign — `hs.campaigns.send` / `hogsend campaigns send` (targets a list or bucket) |
74
+
75
+ Platform-specific tables, audit greps, and rewrite examples:
76
+
77
+ - **Loops** → `references/loops-mapping.md`
78
+ - **Customer.io** → `references/customerio-mapping.md`
79
+ - **Resend Broadcasts/Audiences** → `references/resend-broadcasts-mapping.md`
80
+ (special case: Resend can REMAIN the delivery wire — Hogsend's default
81
+ `EmailProvider` is Resend, so this migration replaces the orchestration
82
+ layer, not necessarily the sending infrastructure)
83
+
84
+ ## Step 3 — generate the Hogsend setup
85
+
86
+ Work in the Hogsend app, one source asset at a time. This skill decides WHAT to
87
+ build; the authoring skills own HOW:
88
+
89
+ - **Each workflow/campaign-with-logic → a `defineJourney()`** in
90
+ `src/journeys/`. Translate: entry trigger → `trigger: { event, where? }`;
91
+ re-entry rules → `entryLimit: "once" | "once_per_period" | "unlimited"`;
92
+ goal/exit rules → `exitOn`; delays → `await ctx.sleep({ duration: days(n) })`;
93
+ branches → plain TypeScript `if` on `ctx.history.hasEvent(...)`. Mechanics +
94
+ registration ritual → **hogsend-authoring-journeys**; condition syntax →
95
+ **hogsend-conditions**.
96
+ - **Each template → the four-file contract** (`src/emails/<name>.tsx` +
97
+ `types.ts` props + `registry.ts` entry + `templates.d.ts` augmentation).
98
+ Port content into react-email components; subjects/preview/category live on
99
+ the registry entry. → **hogsend-authoring-emails**.
100
+ - **Each list/audience/topic → `defineList({ id, name, defaultOptIn })`** in
101
+ `src/lists/`. Get `defaultOptIn` right: a newsletter people subscribed to is
102
+ opt-in (`defaultOptIn: false`); product/account notices are typically opt-out
103
+ (`defaultOptIn: true`). → **hogsend-authoring-lists**.
104
+ - **Each behavioral segment → a bucket** (criteria tree over events/properties)
105
+ when it gates journeys or campaigns. → **hogsend-authoring-buckets**.
106
+ - **Inbound webhooks worth keeping** (the source platform was receiving
107
+ provider webhooks that should now hit Hogsend) → `defineWebhookSource()` or
108
+ the built-in Clerk/Supabase/Stripe/Segment presets. →
109
+ **hogsend-webhooks-and-workflows**.
110
+ - **Outbound syncs** (source platform pushed data to other tools) → webhook
111
+ endpoints / keyed destinations (`hs.webhooks.create` with `kind`). →
112
+ **hogsend-authoring-destinations**.
113
+
114
+ Then rewire the host product's SDK calls to `@hogsend/client` equivalents
115
+ (per-platform before/after in the references; client patterns in
116
+ **hogsend-client-sdk** / **hogsend-integrate**).
117
+
118
+ ## Step 4 — incremental cutover (dual-write → verify → switch → remove)
119
+
120
+ The full checklist with commands is `references/cutover-checklist.md`. The
121
+ stages:
122
+
123
+ 1. **Dual-write.** Import contacts + suppression state (suppression FIRST).
124
+ Add Hogsend calls BESIDE the existing SDK calls — same handlers, both fire.
125
+ Journeys are authored but disabled (`enabled: false` or left out of
126
+ `ENABLED_JOURNEYS`). Nothing user-visible changes.
127
+ 2. **Verify.** Compare event volume between platforms over a few days
128
+ (`hogsend stats --json`, `hogsend events <userId> --json` spot checks).
129
+ Enable each journey for a seed/test contact, walk the full flow, diff
130
+ rendered templates against the originals (`hogsend emails send <template>
131
+ --to you@…`).
132
+ 3. **Switch.** Per flow: enable the Hogsend journey
133
+ (`hogsend journeys enable <id>` or `ENABLED_JOURNEYS`) and pause the source
134
+ workflow in the same window. Flip transactional sends to `hs.emails.send`.
135
+ Run broadcasts as Hogsend campaigns.
136
+ 4. **Remove.** After a clean soak (1-2 send cycles), delete the old SDK calls,
137
+ uninstall the dep, revoke the old API keys, export a final archive from the
138
+ source platform, then close the account.
139
+
140
+ ## Task playbooks — load the matching reference
141
+
142
+ - **Migrating from Loops** → `references/loops-mapping.md`
143
+ - **Migrating from Customer.io** → `references/customerio-mapping.md`
144
+ - **Migrating from Resend Broadcasts/Audiences** →
145
+ `references/resend-broadcasts-mapping.md`
146
+ - **Executing the cutover (imports, verification commands, switch order,
147
+ rollback)** → `references/cutover-checklist.md`
@@ -0,0 +1,93 @@
1
+ # Customer.io → Hogsend mapping
2
+
3
+ Audit greps, the concept table, and before/after rewrites for a codebase using
4
+ Customer.io. Customer.io's API surface varies by SDK (`customerio-node` Track
5
+ vs App API clients) and product tier — treat the left-hand column as "look
6
+ for", and verify against the project's actual calls.
7
+
8
+ ## Audit greps (code half)
9
+
10
+ ```bash
11
+ grep -rn "customerio" package.json # commonly `customerio-node`
12
+ grep -rn "TrackClient\|APIClient\|cio\." src/
13
+ grep -rn "identify\|\.track(\|trackAnonymous\|triggerBroadcast" src/
14
+ grep -rn "_cio" src/ public/ # in-app/browser snippet
15
+ grep -rn "CUSTOMERIO\|CIO_" . --include="*.env*"
16
+ ```
17
+
18
+ GUI half (export/screenshot from the Customer.io dashboard): campaigns (entry
19
+ trigger, filters, delays, branches, goal/exit settings), segments (definitions
20
+ + whether data-driven or manual), broadcasts/newsletters, transactional
21
+ messages, subscription preferences/topics, and the suppression list.
22
+
23
+ ## Concept mapping
24
+
25
+ | Customer.io concept | Hogsend equivalent | Notes |
26
+ |---|---|---|
27
+ | Person + attributes (`identify`) | contact — `hs.contacts.upsert({ userId, email?, properties })` | CIO is id-keyed; Hogsend takes `userId` and/or `email`. Attributes → `properties` |
28
+ | `track(id, event, data)` | `hs.events.send({ userId, name, eventProperties, contactProperties? })` | CIO has ONE data bag; Hogsend has two. Sort each field: per-occurrence → `eventProperties`, durable → `contactProperties` |
29
+ | Anonymous events (`trackAnonymous`) | no direct equivalent | Hogsend writes need an identity (`email`/`userId`). Defer anonymous tracking to your product-analytics tool; ingest on identification |
30
+ | Campaign (triggered workflow) | journey — `defineJourney()` | Trigger → `trigger.event` (+ `where` for filters); frequency settings → `entryLimit`; goal/exit → `exitOn`; delays → `ctx.sleep`; time-windows → `ctx.when`; "wait until event" → `ctx.waitForEvent` |
31
+ | Segment (data-driven) | bucket — `defineBucket()` criteria tree | Real-time membership; journeys can trigger on `bucket.entered`/`bucket.left` transitions → hogsend-authoring-buckets |
32
+ | Segment (manual) | list — `defineList()` + explicit membership writes | Manual segments are membership-by-assignment, which is what lists are |
33
+ | Broadcast / newsletter | campaign — `hs.campaigns.send({ list \| bucket, template })` | API-triggered broadcasts (`triggerBroadcast`) also map here |
34
+ | Transactional message | `hs.emails.send({ to \| userId, template, props })` | Each transactional message id becomes a template key in `src/emails/` |
35
+ | Subscription preferences / topics | lists (`defineList`) + the built-in preference center | Topic opt-ins → `defaultOptIn: false`; suppression import → `cutover-checklist.md` |
36
+ | Reporting webhooks (CIO → your systems) | webhook endpoints / keyed destinations — `hs.webhooks.create({ url, eventTypes, kind?, config? })` | Hogsend emits `email.*`, `contact.*`, `journey.completed`, `bucket.*` on a durable signed spine |
37
+
38
+ ## Before / after
39
+
40
+ Identify + track (host product code):
41
+
42
+ ```ts
43
+ // BEFORE (customerio-node Track API — shape varies by version)
44
+ cio.identify("u_1", { email: "ada@example.com", plan: "pro" });
45
+ cio.track("u_1", { name: "subscription_started", data: { plan: "pro", amount: 49 } });
46
+
47
+ // AFTER (@hogsend/client)
48
+ import { hogsend } from "../lib/hogsend.js";
49
+
50
+ await hogsend.contacts.upsert({
51
+ userId: "u_1",
52
+ email: "ada@example.com",
53
+ properties: { plan: "pro" },
54
+ });
55
+ await hogsend.events.send({
56
+ userId: "u_1",
57
+ name: "subscription_started",
58
+ eventProperties: { plan: "pro", amount: 49 }, // facts about THIS event
59
+ contactProperties: { plan: "pro" }, // durable fact on the person
60
+ });
61
+ ```
62
+
63
+ Campaign → journey translation guide (authored in the Hogsend app —
64
+ mechanics → hogsend-authoring-journeys, conditions → hogsend-conditions):
65
+
66
+ | Campaign builder block | Journey code |
67
+ |---|---|
68
+ | "Person enters when event X" | `trigger: { event: "X" }` |
69
+ | "…and attribute filter" | `trigger: { where: { type: "property", … } }` |
70
+ | "Can enter once / every N days" | `entryLimit: "once"` / `"once_per_period"` |
71
+ | "Exit when goal event Y" | `exitOn: [{ event: "Y" }]` |
72
+ | "Wait 3 days" | `await ctx.sleep({ duration: days(3) })` |
73
+ | "Wait until Tuesday 9am (their tz)" | `ctx.when` scheduler + `ctx.sleepUntil` |
74
+ | "Wait up to 7 days for event Z" | `await ctx.waitForEvent({ event: "Z", timeout: days(7) })` |
75
+ | "True/false branch on behavior" | `if` on `ctx.history.hasEvent(...)` / `ctx.history.email(...)` |
76
+ | "Send email" | `await sendEmail({ …, journeyStateId: user.stateId })` |
77
+
78
+ ## Customer.io-specific gotchas
79
+
80
+ - **One data bag → two.** The single `data` object on `cio.track` must be
81
+ consciously split into `eventProperties` vs `contactProperties`. Default
82
+ per-field to `eventProperties`; promote to `contactProperties` only what a
83
+ later check on the person should read.
84
+ - **Anonymous → identified flows have no Hogsend equivalent** — plan to start
85
+ lifecycle tracking at identification (signup), which is where journeys
86
+ almost always start anyway.
87
+ - **In-app `_cio` snippet** (browser tracking) is product analytics, not
88
+ lifecycle orchestration. Don't port it to Hogsend; if the team uses PostHog,
89
+ that's its home, and Hogsend can consume those signals later.
90
+ - **Liquid templates** don't transplant. Re-author as react-email with typed
91
+ props; conditional content blocks become plain JSX conditionals.
92
+ - **Campaign analytics history stays behind.** Export CSVs for the record;
93
+ Hogsend metrics start at cutover.
@@ -0,0 +1,136 @@
1
+ # Cutover checklist — dual-write → verify → switch → remove
2
+
3
+ The execution playbook. Work flow-by-flow, never big-bang. Keys: data-plane
4
+ writes use the ingest key (`HOGSEND_API_KEY`); admin reads/writes use an admin
5
+ key (`HOGSEND_ADMIN_KEY` / `ADMIN_API_KEY`). `--json` on every CLI call you
6
+ parse.
7
+
8
+ ## Stage 0 — imports (BEFORE anything sends)
9
+
10
+ ### 0a. Contacts
11
+
12
+ Bulk path — the admin import endpoint (admin key):
13
+
14
+ ```
15
+ POST /v1/admin/contacts/import
16
+ { "format": "csv" | "json", "data": "<file contents>", "fileName?": "..." }
17
+ → 202 { jobId, status }
18
+ GET /v1/admin/contacts/import/{jobId} → processed/failed counts + errors
19
+ ```
20
+
21
+ Rows carry `externalId?`, `email?`, `properties?` (CSV: those columns; at least
22
+ one identity per row). **The bulk import does NOT carry list membership or
23
+ unsubscribe state** — handle those in 0b/0c.
24
+
25
+ Scripted alternative (and the way to set list membership in the same pass):
26
+ loop the export through the SDK —
27
+
28
+ ```ts
29
+ for (const row of exported) {
30
+ await hogsend.contacts.upsert({
31
+ email: row.email,
32
+ userId: row.externalId,
33
+ properties: row.properties,
34
+ lists: { newsletter: row.subscribedToNewsletter === true },
35
+ });
36
+ }
37
+ ```
38
+
39
+ `contacts.upsert` is a true upsert — re-running the loop is safe.
40
+
41
+ ### 0b. List membership
42
+
43
+ Either inline via `lists` on the upsert (above), or per-list:
44
+
45
+ ```ts
46
+ await hogsend.lists.subscribe({ list: "newsletter", email: row.email });
47
+ ```
48
+
49
+ Mind polarity: for an opt-in list (`defaultOptIn: false`) you only need to
50
+ subscribe the members; for an opt-out list you only need to record the
51
+ unsubscribes.
52
+
53
+ ### 0c. Suppression / unsubscribes (THE critical import)
54
+
55
+ - **List-level unsubscribes** →
56
+ `hogsend.lists.unsubscribe({ list, email })` per contact+list.
57
+ - **Global unsubscribes** ("never email this person") → the admin preferences
58
+ route, per contact (UUID or externalId both work as `{contactId}`):
59
+
60
+ ```
61
+ PUT /v1/admin/contacts/{contactId}/preferences
62
+ { "unsubscribedAll": true }
63
+ ```
64
+
65
+ (`suppressed: true` is also accepted there — use it for hard-bounced/complained
66
+ addresses from the source platform's suppression export.)
67
+
68
+ **Gate: do not proceed to any send until a spot check passes** — pick 3 known
69
+ unsubscribed addresses, confirm via
70
+ `hogsend contacts get <id> --json` / the preferences route that they show
71
+ `unsubscribedAll: true`, and confirm a test `hogsend emails send` to one of
72
+ them is refused by the preference check.
73
+
74
+ ## Stage 1 — dual-write
75
+
76
+ - Add `@hogsend/client` calls BESIDE the existing SDK calls (same handlers,
77
+ both fire). Use `idempotencyKey` on webhook-driven events.
78
+ - Author journeys/templates/lists in the Hogsend app, but keep journeys OFF:
79
+ `enabled: false` in meta, or excluded from the `ENABLED_JOURNEYS` env
80
+ (comma-separated ids; `*` = all).
81
+ - Keep the source platform fully live. Nothing user-visible changes.
82
+
83
+ ## Stage 2 — verify (run a few days)
84
+
85
+ ```bash
86
+ hogsend doctor --json # instance healthy
87
+ hogsend stats --json # totalContacts matches the import
88
+ hogsend events <userId> --json # spot-check: events arriving for real users
89
+ hogsend contacts timeline <id> --json # merged activity view for a known user
90
+ ```
91
+
92
+ - **Volume:** compare daily event counts per event name against the source
93
+ platform's numbers. Investigate gaps > a few percent.
94
+ - **Journeys:** enroll a seed contact (your own address):
95
+ `hogsend events send signup --email you@yourco.com --json`, temporarily
96
+ enable the journey (`hogsend journeys enable <id>`), walk the full flow —
97
+ delays, branches, sends — then disable again if the soak isn't done.
98
+ - **Templates:** send each to yourself
99
+ (`hogsend emails send <template> --to you@yourco.com --prop key=value`) and
100
+ diff against the source platform's rendering. Check the unsubscribe link
101
+ resolves.
102
+
103
+ ## Stage 3 — switch (per flow, old OFF in the same window)
104
+
105
+ For each lifecycle flow, in one change window:
106
+
107
+ 1. Enable the Hogsend journey — `hogsend journeys enable <id> --json` (or ship
108
+ `enabled: true` / add to `ENABLED_JOURNEYS` and deploy).
109
+ 2. Pause the corresponding workflow/campaign in the source platform's
110
+ dashboard.
111
+ 3. Watch the first real enrollments: `hogsend journeys get <id> --json`
112
+ (state counts + recent states).
113
+
114
+ For transactional: flip each call site from the old SDK to `hs.emails.send`
115
+ (delete the old call, don't dual-send transactional — a user must never get
116
+ two password resets). For broadcasts: run the next one as a Hogsend campaign
117
+ (`hogsend campaigns send --list … --template …`; progress via
118
+ `hogsend campaigns status <id> --json`).
119
+
120
+ ## Stage 4 — remove
121
+
122
+ After 1-2 clean send cycles:
123
+
124
+ - Delete the old SDK calls and the dep (`pnpm remove <pkg>`); remove its env
125
+ vars from every environment.
126
+ - Revoke the source platform's API keys.
127
+ - Export final archives (campaign history, analytics) — they don't migrate.
128
+ - Re-export the source's suppression list ONE more time and re-run 0c — people
129
+ unsubscribed during the dual-write window must not be lost.
130
+ - Close/downgrade the account.
131
+
132
+ ## Rollback
133
+
134
+ Until Stage 4, rollback is cheap and symmetrical: disable the Hogsend journey
135
+ (`hogsend journeys disable <id>`), resume the source workflow. That symmetry is
136
+ the reason for flow-by-flow switching — keep it until you delete the old code.
@@ -0,0 +1,132 @@
1
+ # Loops → Hogsend mapping
2
+
3
+ Audit greps, the concept table, and before/after rewrites for a codebase using
4
+ Loops (loops.so). Loops' own API may differ by SDK version — treat the
5
+ left-hand column as "look for", and verify against the project's actual calls.
6
+
7
+ ## Audit greps (code half)
8
+
9
+ ```bash
10
+ grep -rn "loops" package.json # the SDK dep (commonly `loops`)
11
+ grep -rn "sendEvent\|updateContact\|sendTransactionalEmail\|createContact" src/
12
+ grep -rn "transactionalId" src/ # transactional template ids
13
+ grep -rn "LOOPS_API_KEY\|loops.so" . --include="*.env*" --include="*.ts"
14
+ ```
15
+
16
+ GUI half (ask the user to export/screenshot from the Loops dashboard): Loops
17
+ (the workflows), audiences/segments, mailing lists + their opt-in state,
18
+ email templates, and the unsubscribed-contacts export.
19
+
20
+ ## Concept mapping
21
+
22
+ | Loops concept | Hogsend equivalent | Notes |
23
+ |---|---|---|
24
+ | Contact (+ contact properties) | contact — `hs.contacts.upsert({ email, userId?, properties })` | Loops keys contacts on email; carry the app's user id as `userId` too — Hogsend identity is `email` and/or `userId` |
25
+ | `sendEvent` (event + event/contact properties) | `hs.events.send({ …, eventProperties, contactProperties })` | Keep the bag split deliberate: per-occurrence facts → `eventProperties` (drive journey `trigger.where`/`exitOn`); durable facts → `contactProperties` |
26
+ | Loop (the visual workflow) | journey — `defineJourney()` in `src/journeys/` | Entry trigger → `trigger.event`; "enter once" → `entryLimit: "once"`; goal/exit → `exitOn`; timers → `ctx.sleep({ duration: days(n) })`; audience filters → `trigger.where` conditions |
27
+ | Transactional email (`sendTransactionalEmail` + `transactionalId`) | `hs.emails.send({ to | userId, template, props })` + a four-file template | Each `transactionalId` becomes a template key in the Hogsend app's `src/emails/` registry; `dataVariables` become typed `props` |
28
+ | Mailing list (opt-in/opt-out toggle) | list — `defineList({ id, name, defaultOptIn })` | Loops' "public" opt-in lists → `defaultOptIn: false`; default-subscribed lists → `defaultOptIn: true` |
29
+ | Audience / segment filter | bucket (`defineBucket`) when behavioral; `trigger.where` when it's just an entry filter | Don't over-build: a one-condition audience is usually just a `where` clause |
30
+ | Campaign (one-off blast) | campaign — `hs.campaigns.send({ list | bucket, template, props })` | Or `hogsend campaigns send --list … --template …` |
31
+ | Unsubscribed contacts | suppression import — see `cutover-checklist.md` | Import BEFORE any send |
32
+
33
+ ## Before / after
34
+
35
+ Contact + event (host product code):
36
+
37
+ ```ts
38
+ // BEFORE (Loops — shape varies by SDK version)
39
+ await loops.updateContact("ada@example.com", { plan: "pro" });
40
+ await loops.sendEvent({
41
+ email: "ada@example.com",
42
+ eventName: "subscription_started",
43
+ eventProperties: { plan: "pro" },
44
+ });
45
+
46
+ // AFTER (@hogsend/client)
47
+ import { hogsend } from "../lib/hogsend.js";
48
+
49
+ await hogsend.contacts.upsert({
50
+ email: "ada@example.com",
51
+ userId: user.id,
52
+ properties: { plan: "pro" },
53
+ });
54
+ await hogsend.events.send({
55
+ email: "ada@example.com",
56
+ userId: user.id,
57
+ name: "subscription_started",
58
+ eventProperties: { plan: "pro" },
59
+ });
60
+ ```
61
+
62
+ Transactional:
63
+
64
+ ```ts
65
+ // BEFORE (Loops)
66
+ await loops.sendTransactionalEmail({
67
+ transactionalId: "clxyz...",
68
+ email: "ada@example.com",
69
+ dataVariables: { resetUrl },
70
+ });
71
+
72
+ // AFTER — template authored in the Hogsend app first
73
+ // (four-file contract → hogsend-authoring-emails skill)
74
+ await hogsend.emails.send({
75
+ to: "ada@example.com",
76
+ template: "password-reset",
77
+ props: { resetUrl },
78
+ });
79
+ ```
80
+
81
+ A Loop becomes a journey (authored in the HOGSEND app, not the host —
82
+ mechanics → hogsend-authoring-journeys):
83
+
84
+ ```ts
85
+ // "When signup occurs → wait 2 days → if no project created, send nudge"
86
+ import { days } from "@hogsend/core";
87
+ import { defineJourney, sendEmail } from "@hogsend/engine";
88
+ import { Events, Templates } from "./constants/index.js";
89
+
90
+ export const onboardingNudge = defineJourney({
91
+ meta: {
92
+ id: "onboarding-nudge",
93
+ name: "Onboarding nudge",
94
+ enabled: false, // stays off until the cutover switch stage
95
+ trigger: { event: Events.SIGNUP },
96
+ entryLimit: "once",
97
+ suppress: days(7),
98
+ exitOn: [{ event: Events.PROJECT_CREATED }],
99
+ },
100
+ run: async (user, ctx) => {
101
+ await ctx.sleep({ duration: days(2), label: "post-signup" });
102
+ const { found } = await ctx.history.hasEvent({
103
+ userId: user.id,
104
+ event: Events.PROJECT_CREATED,
105
+ });
106
+ if (!found) {
107
+ await sendEmail({
108
+ to: user.email,
109
+ userId: user.id,
110
+ journeyStateId: user.stateId,
111
+ template: Templates.ONBOARDING_NUDGE,
112
+ journeyName: user.journeyName,
113
+ });
114
+ }
115
+ },
116
+ });
117
+ ```
118
+
119
+ ## Loops-specific gotchas
120
+
121
+ - **Email-keyed identity.** Loops contacts are keyed on email. If the app has
122
+ stable user ids, send BOTH on every Hogsend write so identities link
123
+ (`linked: true` in the upsert result confirms a merge).
124
+ - **Event property names become your journey vocabulary.** Journeys'
125
+ `trigger.where` matches `eventProperties` keys — keep names stable across the
126
+ rewrite, or update both sides together.
127
+ - **Template content is GUI-side.** Export each template's content from the
128
+ Loops editor; you are re-authoring it as react-email, not copying HTML
129
+ verbatim (use the Hogsend app's `src/emails/_components/` chrome).
130
+ - **Loops' unsubscribe is per-mailing-list + global.** Map list-level
131
+ unsubscribes to `hs.lists.unsubscribe({ list, email })` and global ones to
132
+ the admin preferences route (`unsubscribedAll`) — see `cutover-checklist.md`.