hatchkit 0.1.41 → 0.1.42

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 (112) hide show
  1. package/dist/adopt.js +362 -13
  2. package/dist/adopt.js.map +1 -1
  3. package/dist/config.d.ts +32 -10
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +91 -38
  6. package/dist/config.js.map +1 -1
  7. package/dist/deploy/coolify-app.d.ts.map +1 -1
  8. package/dist/deploy/coolify-app.js +0 -7
  9. package/dist/deploy/coolify-app.js.map +1 -1
  10. package/dist/deploy/coolify.d.ts.map +1 -1
  11. package/dist/deploy/coolify.js +20 -1
  12. package/dist/deploy/coolify.js.map +1 -1
  13. package/dist/deploy/ghcr.d.ts +4 -2
  14. package/dist/deploy/ghcr.d.ts.map +1 -1
  15. package/dist/deploy/ghcr.js +1 -1
  16. package/dist/deploy/ghcr.js.map +1 -1
  17. package/dist/deploy/github.d.ts +4 -3
  18. package/dist/deploy/github.d.ts.map +1 -1
  19. package/dist/deploy/github.js +5 -2
  20. package/dist/deploy/github.js.map +1 -1
  21. package/dist/deploy/pages.d.ts.map +1 -1
  22. package/dist/deploy/pages.js +8 -14
  23. package/dist/deploy/pages.js.map +1 -1
  24. package/dist/deploy/regen-infra.d.ts.map +1 -1
  25. package/dist/deploy/regen-infra.js +1 -11
  26. package/dist/deploy/regen-infra.js.map +1 -1
  27. package/dist/deploy/rollback.d.ts.map +1 -1
  28. package/dist/deploy/rollback.js +30 -6
  29. package/dist/deploy/rollback.js.map +1 -1
  30. package/dist/deploy/terraform.d.ts.map +1 -1
  31. package/dist/deploy/terraform.js +20 -37
  32. package/dist/deploy/terraform.js.map +1 -1
  33. package/dist/dns.d.ts.map +1 -1
  34. package/dist/dns.js +4 -5
  35. package/dist/dns.js.map +1 -1
  36. package/dist/doctor.d.ts +15 -0
  37. package/dist/doctor.d.ts.map +1 -1
  38. package/dist/doctor.js +110 -36
  39. package/dist/doctor.js.map +1 -1
  40. package/dist/email/index.d.ts +31 -0
  41. package/dist/email/index.d.ts.map +1 -0
  42. package/dist/email/index.js +251 -0
  43. package/dist/email/index.js.map +1 -0
  44. package/dist/email/presets.d.ts +14 -0
  45. package/dist/email/presets.d.ts.map +1 -0
  46. package/dist/email/presets.js +33 -0
  47. package/dist/email/presets.js.map +1 -0
  48. package/dist/email/setup.d.ts +93 -0
  49. package/dist/email/setup.d.ts.map +1 -0
  50. package/dist/email/setup.js +263 -0
  51. package/dist/email/setup.js.map +1 -0
  52. package/dist/email/spf.d.ts +56 -0
  53. package/dist/email/spf.d.ts.map +1 -0
  54. package/dist/email/spf.js +102 -0
  55. package/dist/email/spf.js.map +1 -0
  56. package/dist/index.js +113 -4
  57. package/dist/index.js.map +1 -1
  58. package/dist/inventory.d.ts.map +1 -1
  59. package/dist/inventory.js +34 -11
  60. package/dist/inventory.js.map +1 -1
  61. package/dist/overview.d.ts.map +1 -1
  62. package/dist/overview.js +43 -15
  63. package/dist/overview.js.map +1 -1
  64. package/dist/prompts.d.ts +5 -0
  65. package/dist/prompts.d.ts.map +1 -1
  66. package/dist/prompts.js +29 -7
  67. package/dist/prompts.js.map +1 -1
  68. package/dist/provision/index.d.ts +20 -1
  69. package/dist/provision/index.d.ts.map +1 -1
  70. package/dist/provision/index.js +115 -0
  71. package/dist/provision/index.js.map +1 -1
  72. package/dist/provision/s3-buckets.js +1 -1
  73. package/dist/provision/s3-buckets.js.map +1 -1
  74. package/dist/scaffold/app.d.ts.map +1 -1
  75. package/dist/scaffold/app.js +15 -7
  76. package/dist/scaffold/app.js.map +1 -1
  77. package/dist/scaffold/build-pipeline.d.ts +16 -0
  78. package/dist/scaffold/build-pipeline.d.ts.map +1 -1
  79. package/dist/scaffold/build-pipeline.js +47 -4
  80. package/dist/scaffold/build-pipeline.js.map +1 -1
  81. package/dist/scaffold/infra.d.ts +4 -5
  82. package/dist/scaffold/infra.d.ts.map +1 -1
  83. package/dist/scaffold/infra.js +11 -56
  84. package/dist/scaffold/infra.js.map +1 -1
  85. package/dist/scaffold/manifest.d.ts.map +1 -1
  86. package/dist/scaffold/manifest.js +1 -0
  87. package/dist/scaffold/manifest.js.map +1 -1
  88. package/dist/scaffold/pages-heuristics.d.ts.map +1 -1
  89. package/dist/scaffold/pages-heuristics.js +10 -10
  90. package/dist/scaffold/pages-heuristics.js.map +1 -1
  91. package/dist/scaffold/pages-mode.js +2 -4
  92. package/dist/scaffold/pages-mode.js.map +1 -1
  93. package/dist/scaffold/pkg-json.d.ts +4 -0
  94. package/dist/scaffold/pkg-json.d.ts.map +1 -1
  95. package/dist/scaffold/pkg-json.js +17 -0
  96. package/dist/scaffold/pkg-json.js.map +1 -1
  97. package/dist/scaffold/update.js +1 -1
  98. package/dist/scaffold/update.js.map +1 -1
  99. package/dist/templates/build-pipeline/Dockerfile.nextjs.hbs +103 -0
  100. package/dist/templates/build-pipeline/docker-compose.yml.hbs +23 -6
  101. package/dist/utils/cloudflare-api.d.ts +146 -20
  102. package/dist/utils/cloudflare-api.d.ts.map +1 -1
  103. package/dist/utils/cloudflare-api.js +203 -11
  104. package/dist/utils/cloudflare-api.js.map +1 -1
  105. package/dist/utils/run-ledger.d.ts +22 -1
  106. package/dist/utils/run-ledger.d.ts.map +1 -1
  107. package/dist/utils/run-ledger.js.map +1 -1
  108. package/dist/utils/s3-admin.d.ts +9 -0
  109. package/dist/utils/s3-admin.d.ts.map +1 -0
  110. package/dist/utils/s3-admin.js +46 -0
  111. package/dist/utils/s3-admin.js.map +1 -0
  112. package/package.json +1 -1
@@ -0,0 +1,263 @@
1
+ /*
2
+ * Email setup orchestrator.
3
+ *
4
+ * Configures Cloudflare Email Routing end-to-end for one zone:
5
+ * 1. Enable Email Routing on the zone (idempotent).
6
+ * 2. Verify (or add) the destination address — the verification email
7
+ * lands in the user's inbox; they click the link before routing
8
+ * starts working. Skipping verify isn't an option — Cloudflare
9
+ * rejects forwards to unverified destinations.
10
+ * 3. Upsert the receiving MX records (3 Cloudflare hosts).
11
+ * 4. Upsert a single SPF TXT, merging Cloudflare's include with any
12
+ * pre-existing senders (e.g. Resend). One SPF record per zone is
13
+ * the RFC 7208 rule — multiple records cause PermError.
14
+ * 5. Upsert a DMARC TXT at `_dmarc.<zone>`. Default `p=quarantine`
15
+ * with `sp=none` to avoid breaking subdomain senders.
16
+ * 6. Create the forwarding rules — one per `<localPart>@<zone>` →
17
+ * destination. Idempotent: existing rules with matching matchers
18
+ * get their forward updated; otherwise we POST a new rule.
19
+ * 7. Optionally set the catch-all (a single rule per zone, separate
20
+ * endpoint from the rule list).
21
+ *
22
+ * Caller surfaces results via the {@link EmailSetupResult} so the
23
+ * create/adopt run-ledger can record each newly created resource for
24
+ * later rollback.
25
+ */
26
+ import chalk from "chalk";
27
+ import { CloudflareApi, } from "../utils/cloudflare-api.js";
28
+ import { buildDmarcRecord, buildSpfRecord, parseSpfIncludes } from "./spf.js";
29
+ /** Cloudflare-published MX hosts for Email Routing. Verified against
30
+ * what `GET /zones/{id}/email/routing/dns` returns — kept hardcoded
31
+ * so a hatchkit run can describe the records up-front without an
32
+ * extra API call, and because the values have been stable since the
33
+ * Email Routing product launched. The API stays the source of truth
34
+ * in practice: the email module calls `getEmailRoutingDnsRecords()`
35
+ * and uses *those* values, falling back to this list only if CF is
36
+ * unreachable. */
37
+ export const CLOUDFLARE_EMAIL_ROUTING_MX = [
38
+ { host: "route1.mx.cloudflare.net", priority: 1 },
39
+ { host: "route2.mx.cloudflare.net", priority: 2 },
40
+ { host: "route3.mx.cloudflare.net", priority: 3 },
41
+ ];
42
+ export async function runEmailSetup(opts) {
43
+ const cf = new CloudflareApi({ token: opts.token, accountId: opts.accountId });
44
+ const zone = await cf.getZoneByName(opts.domain);
45
+ if (!zone) {
46
+ throw new Error(`No Cloudflare zone for "${opts.domain}". Add the zone to your CF account and re-run.`);
47
+ }
48
+ const accountId = opts.accountId ?? zone.account?.id;
49
+ if (!accountId) {
50
+ throw new Error(`Cloudflare account id could not be resolved for zone ${opts.domain}. Set it via \`hatchkit config add dns\` (advanced — leave blank only when the token spans one account).`);
51
+ }
52
+ // Step 1 — enable routing on the zone. Idempotent; CF returns the
53
+ // current settings on re-enable. We still record whether *this* call
54
+ // flipped enabled=false → true so the rollback ledger doesn't yank
55
+ // a routing setup the user enabled by hand earlier.
56
+ const beforeRouting = await cf.getEmailRouting(zone.id);
57
+ if (!beforeRouting?.enabled) {
58
+ await cf.enableEmailRouting(zone.id);
59
+ }
60
+ const routingEnabledThisRun = !beforeRouting?.enabled;
61
+ // Step 2 — destination address. CF sends a verification email if it
62
+ // didn't already exist.
63
+ const dest = await cf.addEmailDestination(accountId, opts.destination);
64
+ // Step 3 — MX records. Fetch CF's recommended list first (in case the
65
+ // hosts change), fall back to the constant if the call fails.
66
+ let mxRecords = CLOUDFLARE_EMAIL_ROUTING_MX.map((m) => ({
67
+ name: opts.domain,
68
+ content: m.host,
69
+ priority: m.priority,
70
+ }));
71
+ try {
72
+ const recommended = await cf.getEmailRoutingDnsRecords(zone.id);
73
+ const mx = recommended.filter((r) => r.type === "MX");
74
+ if (mx.length > 0) {
75
+ mxRecords = mx.map((r) => ({
76
+ name: r.name,
77
+ content: r.content,
78
+ priority: r.priority ?? 10,
79
+ }));
80
+ }
81
+ }
82
+ catch {
83
+ // Fall back to constants — see the comment on
84
+ // CLOUDFLARE_EMAIL_ROUTING_MX. CF's list is authoritative; the
85
+ // fallback keeps setup working if that one endpoint flakes.
86
+ }
87
+ const dnsRecords = [];
88
+ for (const mx of mxRecords) {
89
+ const res = await cf.upsertRecord(zone.id, {
90
+ type: "MX",
91
+ name: mx.name,
92
+ content: mx.content,
93
+ priority: mx.priority,
94
+ });
95
+ dnsRecords.push({
96
+ id: res.id,
97
+ name: mx.name,
98
+ type: "MX",
99
+ content: mx.content,
100
+ created: res.created,
101
+ updated: res.updated,
102
+ });
103
+ }
104
+ // Step 4 — SPF. Merge Cloudflare's include with whatever's already on
105
+ // the zone (Resend, SES, etc.) and any caller-supplied includes.
106
+ const existingSpf = await findApexSpf(cf, zone.id, opts.domain);
107
+ const mergedIncludes = new Set(["_spf.mx.cloudflare.net"]);
108
+ for (const inc of opts.extraSpfIncludes ?? [])
109
+ mergedIncludes.add(inc);
110
+ if (existingSpf) {
111
+ for (const inc of parseSpfIncludes(existingSpf.content))
112
+ mergedIncludes.add(inc);
113
+ }
114
+ const spfContent = buildSpfRecord({ includes: [...mergedIncludes] });
115
+ // Delete any *other* SPF TXT records first — exactly one SPF record
116
+ // per zone per RFC 7208. The upsert below patches `existingSpf` in
117
+ // place; stale duplicates (zone moved between providers, etc.) get
118
+ // removed here.
119
+ await deleteStaleSpfRecords(cf, zone.id, opts.domain, existingSpf?.id);
120
+ const spfRes = await cf.upsertRecord(zone.id, {
121
+ type: "TXT",
122
+ name: opts.domain,
123
+ content: spfContent,
124
+ });
125
+ dnsRecords.push({
126
+ id: spfRes.id,
127
+ name: opts.domain,
128
+ type: "TXT",
129
+ content: spfContent,
130
+ created: spfRes.created,
131
+ updated: spfRes.updated,
132
+ });
133
+ // Step 5 — DMARC.
134
+ const dmarcName = `_dmarc.${opts.domain}`;
135
+ const dmarcContent = buildDmarcRecord({
136
+ rua: opts.dmarcRua ?? `dmarc@${opts.domain}`,
137
+ policy: opts.dmarcPolicy ?? "quarantine",
138
+ });
139
+ const dmarcRes = await cf.upsertRecord(zone.id, {
140
+ type: "TXT",
141
+ name: dmarcName,
142
+ content: dmarcContent,
143
+ });
144
+ dnsRecords.push({
145
+ id: dmarcRes.id,
146
+ name: dmarcName,
147
+ type: "TXT",
148
+ content: dmarcContent,
149
+ created: dmarcRes.created,
150
+ updated: dmarcRes.updated,
151
+ });
152
+ // Step 6 — per-address forwarding rules.
153
+ const rules = [];
154
+ for (const localPart of opts.addresses) {
155
+ const address = `${localPart}@${opts.domain}`;
156
+ const res = await cf.upsertEmailRoutingRule(zone.id, {
157
+ address,
158
+ forwardTo: [opts.destination],
159
+ name: `Forward ${address}`,
160
+ });
161
+ rules.push({ address, id: res.id, created: res.created, updated: res.updated });
162
+ }
163
+ // Step 7 — catch-all (zone-level singleton). `changed` reflects the
164
+ // catch-all rule itself — its `enabled` flag and forward destination —
165
+ // not the zone-level Email Routing toggle (that's `routingEnabledThisRun`).
166
+ let catchAll;
167
+ if (opts.catchAll) {
168
+ const beforeRule = await cf.getEmailCatchAll(zone.id);
169
+ const beforeEnabled = beforeRule?.enabled ?? false;
170
+ const beforeForward = (beforeRule?.actions?.[0]?.value ?? []).join(",");
171
+ const wantForward = [opts.destination].join(",");
172
+ await cf.setEmailCatchAll(zone.id, {
173
+ forwardTo: [opts.destination],
174
+ enabled: true,
175
+ name: "Catch-all",
176
+ });
177
+ catchAll = { enabled: true, changed: !beforeEnabled || beforeForward !== wantForward };
178
+ }
179
+ return {
180
+ domain: opts.domain,
181
+ zoneId: zone.id,
182
+ accountId,
183
+ routingEnabledThisRun,
184
+ destination: {
185
+ record: dest,
186
+ createdThisRun: !dest.existed,
187
+ verified: dest.verified ?? null,
188
+ },
189
+ dnsRecords,
190
+ rules,
191
+ catchAll,
192
+ };
193
+ }
194
+ /** Find the apex SPF TXT record (one starting with `v=spf1`). Returns
195
+ * null when no SPF record exists yet. There MAY be other TXT records
196
+ * at the apex (verification tokens, etc.) — they're left alone. */
197
+ async function findApexSpf(cf, zoneId, domain) {
198
+ const all = await cf.findRecordsByName(zoneId, domain, "TXT");
199
+ return all.find((r) => /^"?v=spf1\b/i.test(r.content)) ?? null;
200
+ }
201
+ /** Delete every *other* SPF TXT at the apex except the one we're about
202
+ * to upsert. Multiple SPF records cause receivers to PermError per
203
+ * RFC 7208, so a clean zone has exactly one. Skips the record we're
204
+ * keeping (identified by id). */
205
+ async function deleteStaleSpfRecords(cf, zoneId, domain, keepId) {
206
+ const all = await cf.findRecordsByName(zoneId, domain, "TXT");
207
+ for (const rec of all) {
208
+ if (!/^"?v=spf1\b/i.test(rec.content))
209
+ continue;
210
+ if (rec.id === keepId)
211
+ continue;
212
+ await cf.deleteRecord(zoneId, rec.id);
213
+ }
214
+ }
215
+ /** Pretty-print the result for the CLI. Centralised here so both the
216
+ * standalone command and the create/adopt-flow hook print the same
217
+ * status block. */
218
+ export function printEmailSetupSummary(result) {
219
+ console.log(chalk.bold(`\n ── Email setup: ${result.domain} ──────────────────────────\n`));
220
+ if (result.routingEnabledThisRun) {
221
+ console.log(chalk.green(" ✓ Enabled Cloudflare Email Routing on zone"));
222
+ }
223
+ else {
224
+ console.log(chalk.dim(" · Email Routing already enabled"));
225
+ }
226
+ const verifySuffix = result.destination.verified === "active"
227
+ ? chalk.green(" (verified)")
228
+ : chalk.yellow(" (pending — check inbox + click verify link)");
229
+ if (result.destination.createdThisRun) {
230
+ console.log(chalk.green(` ✓ Added destination ${result.destination.record.email}`) + verifySuffix);
231
+ }
232
+ else {
233
+ console.log(chalk.dim(` · Destination ${result.destination.record.email} already on account`) +
234
+ verifySuffix);
235
+ }
236
+ for (const rec of result.dnsRecords) {
237
+ const tag = rec.created
238
+ ? chalk.green("✓ created")
239
+ : rec.updated
240
+ ? chalk.yellow("· updated")
241
+ : chalk.dim("· unchanged");
242
+ console.log(` ${tag} ${rec.type.padEnd(4)} ${rec.name} → ${truncate(rec.content, 60)}`);
243
+ }
244
+ for (const r of result.rules) {
245
+ const tag = r.created
246
+ ? chalk.green("✓ created")
247
+ : r.updated
248
+ ? chalk.yellow("· updated")
249
+ : chalk.dim("· unchanged");
250
+ console.log(` ${tag} rule ${r.address}`);
251
+ }
252
+ if (result.catchAll) {
253
+ const tag = result.catchAll.changed ? chalk.green("✓ enabled") : chalk.dim("· already enabled");
254
+ console.log(` ${tag} catch-all *@${result.domain}`);
255
+ }
256
+ if (result.destination.verified !== "active") {
257
+ console.log(chalk.yellow(`\n ! Forwards won't deliver until ${result.destination.record.email} clicks the Cloudflare verification email.`));
258
+ }
259
+ }
260
+ function truncate(s, n) {
261
+ return s.length > n ? `${s.slice(0, n - 1)}…` : s;
262
+ }
263
+ //# sourceMappingURL=setup.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"setup.js","sourceRoot":"","sources":["../../src/email/setup.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,56 @@
1
+ /** Common SPF includes for popular senders. The Resend value is the
2
+ * one their dashboard tells you to add; AWS SES users would substitute
3
+ * `amazonses.com` etc. Hatchkit auto-includes Cloudflare's host; other
4
+ * senders come from configuration. */
5
+ export declare const SPF_INCLUDES: {
6
+ readonly cloudflareEmailRouting: "_spf.mx.cloudflare.net";
7
+ readonly resend: "_spf.resend.com";
8
+ readonly amazonSes: "amazonses.com";
9
+ readonly google: "_spf.google.com";
10
+ };
11
+ export type SpfQualifier = "~all" | "-all" | "?all" | "+all";
12
+ /** Build a single SPF TXT record content string. Sorts includes for
13
+ * deterministic output (matters for idempotent upserts: same input =
14
+ * same record content = no PATCH on re-run). */
15
+ export declare function buildSpfRecord(opts: {
16
+ /** Hostnames to wrap as `include:<host>`. Order-insensitive. */
17
+ includes: string[];
18
+ /** Optional `ip4:` / `ip6:` mechanisms. */
19
+ ip4?: string[];
20
+ ip6?: string[];
21
+ /** Default `~all` — see file header. */
22
+ qualifier?: SpfQualifier;
23
+ }): string;
24
+ /** Parse the includes out of an existing SPF record so we can union
25
+ * them with new ones (e.g. when adding Cloudflare to a zone that
26
+ * already has Resend). Lenient: returns an empty list for malformed
27
+ * input rather than throwing — the caller can decide whether to
28
+ * overwrite or surface the parse failure. */
29
+ export declare function parseSpfIncludes(record: string): string[];
30
+ /** Build a DMARC TXT record content string.
31
+ *
32
+ * Default policy is `p=quarantine` — failed-DMARC mail lands in spam
33
+ * rather than the inbox. `sp=none` deliberately exempts subdomains
34
+ * (e.g. `staging.<domain>`) because they often send via third-party
35
+ * services not aligned with the apex. `rua` is the aggregate-report
36
+ * destination — same address as the forwarding inbox by default, so
37
+ * the operator gets weekly delivery summaries.
38
+ *
39
+ * Tweaks (call sites can override):
40
+ * · `policy: "none"` — observe-only, no enforcement. Use during
41
+ * a soft rollout when you're not sure every legit sender is
42
+ * aligned yet.
43
+ * · `subdomainPolicy: "quarantine"` — extend enforcement to
44
+ * subdomains. Only safe once those subdomains' senders are known. */
45
+ export declare function buildDmarcRecord(opts: {
46
+ rua: string;
47
+ policy?: "none" | "quarantine" | "reject";
48
+ subdomainPolicy?: "none" | "quarantine" | "reject";
49
+ /** Percentage of mail subject to policy (0-100). Default 100. Drop
50
+ * to e.g. 20 during a soft rollout. */
51
+ percent?: number;
52
+ /** SPF/DKIM alignment mode — strict ("s") or relaxed ("r"). */
53
+ alignmentSpf?: "s" | "r";
54
+ alignmentDkim?: "s" | "r";
55
+ }): string;
56
+ //# sourceMappingURL=spf.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spf.d.ts","sourceRoot":"","sources":["../../src/email/spf.ts"],"names":[],"mappings":"AAgCA;;;uCAGuC;AACvC,eAAO,MAAM,YAAY;;;;;CAKf,CAAC;AAEX,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAE7D;;iDAEiD;AACjD,wBAAgB,cAAc,CAAC,IAAI,EAAE;IACnC,gEAAgE;IAChE,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,2CAA2C;IAC3C,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IACf,wCAAwC;IACxC,SAAS,CAAC,EAAE,YAAY,CAAC;CAC1B,GAAG,MAAM,CAUT;AAED;;;;8CAI8C;AAC9C,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CASzD;AAED;;;;;;;;;;;;;;yEAcyE;AACzE,wBAAgB,gBAAgB,CAAC,IAAI,EAAE;IACrC,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,GAAG,YAAY,GAAG,QAAQ,CAAC;IAC1C,eAAe,CAAC,EAAE,MAAM,GAAG,YAAY,GAAG,QAAQ,CAAC;IACnD;4CACwC;IACxC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,+DAA+D;IAC/D,YAAY,CAAC,EAAE,GAAG,GAAG,GAAG,CAAC;IACzB,aAAa,CAAC,EAAE,GAAG,GAAG,GAAG,CAAC;CAC3B,GAAG,MAAM,CAWT"}
@@ -0,0 +1,102 @@
1
+ /*
2
+ * SPF + DMARC TXT-record helpers.
3
+ *
4
+ * SPF rules-of-the-road that make this small but important:
5
+ *
6
+ * 1. **One SPF TXT per domain.** RFC 7208 §3.2 — multiple records
7
+ * cause receivers to PermError. So if Resend's records already
8
+ * include `v=spf1 …` and Cloudflare Email Routing wants its own
9
+ * `v=spf1 …`, they MUST be merged into ONE record, not added as
10
+ * siblings.
11
+ *
12
+ * 2. **Maximum 10 DNS lookups inside the SPF chain.** Each `include:`
13
+ * counts as one lookup. Cloudflare Email Routing's
14
+ * `_spf.mx.cloudflare.net` and (e.g.) Resend's
15
+ * `_spf.resend.com` each cost one — well under the cap, but worth
16
+ * knowing if a user later piles on more senders.
17
+ *
18
+ * 3. **Order of `include:` mechanisms does NOT matter for the verdict,
19
+ * but a stable order makes hatchkit's idempotent upserts a no-op
20
+ * on re-run.** We sort the includes alphabetically before joining.
21
+ *
22
+ * 4. **Qualifier (`~all` vs `-all`):** we default to `~all` (softfail)
23
+ * — recipients see a soft signal that mail from unauthorised IPs
24
+ * is suspicious but they still accept it. This pairs well with
25
+ * DMARC at `p=quarantine`, which makes the final disposition call
26
+ * via alignment rather than SPF-fail-alone. Upgrade to `-all`
27
+ * (hardfail) once you're confident no legitimate sender is missing
28
+ * from the include list.
29
+ */
30
+ const CLOUDFLARE_EMAIL_ROUTING_SPF_INCLUDE = "_spf.mx.cloudflare.net";
31
+ /** Common SPF includes for popular senders. The Resend value is the
32
+ * one their dashboard tells you to add; AWS SES users would substitute
33
+ * `amazonses.com` etc. Hatchkit auto-includes Cloudflare's host; other
34
+ * senders come from configuration. */
35
+ export const SPF_INCLUDES = {
36
+ cloudflareEmailRouting: CLOUDFLARE_EMAIL_ROUTING_SPF_INCLUDE,
37
+ resend: "_spf.resend.com",
38
+ amazonSes: "amazonses.com",
39
+ google: "_spf.google.com",
40
+ };
41
+ /** Build a single SPF TXT record content string. Sorts includes for
42
+ * deterministic output (matters for idempotent upserts: same input =
43
+ * same record content = no PATCH on re-run). */
44
+ export function buildSpfRecord(opts) {
45
+ const includes = [...new Set(opts.includes)].sort();
46
+ const ip4 = opts.ip4 ? [...new Set(opts.ip4)].sort() : [];
47
+ const ip6 = opts.ip6 ? [...new Set(opts.ip6)].sort() : [];
48
+ const parts = ["v=spf1"];
49
+ for (const i of ip4)
50
+ parts.push(`ip4:${i}`);
51
+ for (const i of ip6)
52
+ parts.push(`ip6:${i}`);
53
+ for (const inc of includes)
54
+ parts.push(`include:${inc}`);
55
+ parts.push(opts.qualifier ?? "~all");
56
+ return parts.join(" ");
57
+ }
58
+ /** Parse the includes out of an existing SPF record so we can union
59
+ * them with new ones (e.g. when adding Cloudflare to a zone that
60
+ * already has Resend). Lenient: returns an empty list for malformed
61
+ * input rather than throwing — the caller can decide whether to
62
+ * overwrite or surface the parse failure. */
63
+ export function parseSpfIncludes(record) {
64
+ const trimmed = record.trim().replace(/^"|"$/g, "").trim();
65
+ if (!/^v=spf1\b/i.test(trimmed))
66
+ return [];
67
+ const includes = [];
68
+ for (const part of trimmed.split(/\s+/)) {
69
+ const m = part.match(/^include:(.+)$/i);
70
+ if (m)
71
+ includes.push(m[1].toLowerCase());
72
+ }
73
+ return includes;
74
+ }
75
+ /** Build a DMARC TXT record content string.
76
+ *
77
+ * Default policy is `p=quarantine` — failed-DMARC mail lands in spam
78
+ * rather than the inbox. `sp=none` deliberately exempts subdomains
79
+ * (e.g. `staging.<domain>`) because they often send via third-party
80
+ * services not aligned with the apex. `rua` is the aggregate-report
81
+ * destination — same address as the forwarding inbox by default, so
82
+ * the operator gets weekly delivery summaries.
83
+ *
84
+ * Tweaks (call sites can override):
85
+ * · `policy: "none"` — observe-only, no enforcement. Use during
86
+ * a soft rollout when you're not sure every legit sender is
87
+ * aligned yet.
88
+ * · `subdomainPolicy: "quarantine"` — extend enforcement to
89
+ * subdomains. Only safe once those subdomains' senders are known. */
90
+ export function buildDmarcRecord(opts) {
91
+ const parts = [
92
+ "v=DMARC1",
93
+ `p=${opts.policy ?? "quarantine"}`,
94
+ `sp=${opts.subdomainPolicy ?? "none"}`,
95
+ `rua=mailto:${opts.rua}`,
96
+ `pct=${opts.percent ?? 100}`,
97
+ `adkim=${opts.alignmentDkim ?? "r"}`,
98
+ `aspf=${opts.alignmentSpf ?? "r"}`,
99
+ ];
100
+ return parts.join("; ");
101
+ }
102
+ //# sourceMappingURL=spf.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spf.js","sourceRoot":"","sources":["../../src/email/spf.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,MAAM,oCAAoC,GAAG,wBAAwB,CAAC;AAEtE;;;uCAGuC;AACvC,MAAM,CAAC,MAAM,YAAY,GAAG;IAC1B,sBAAsB,EAAE,oCAAoC;IAC5D,MAAM,EAAE,iBAAiB;IACzB,SAAS,EAAE,eAAe;IAC1B,MAAM,EAAE,iBAAiB;CACjB,CAAC;AAIX;;iDAEiD;AACjD,MAAM,UAAU,cAAc,CAAC,IAQ9B;IACC,MAAM,QAAQ,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IACpD,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IAC1D,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IAC1D,MAAM,KAAK,GAAG,CAAC,QAAQ,CAAC,CAAC;IACzB,KAAK,MAAM,CAAC,IAAI,GAAG;QAAE,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IAC5C,KAAK,MAAM,CAAC,IAAI,GAAG;QAAE,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IAC5C,KAAK,MAAM,GAAG,IAAI,QAAQ;QAAE,KAAK,CAAC,IAAI,CAAC,WAAW,GAAG,EAAE,CAAC,CAAC;IACzD,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,IAAI,MAAM,CAAC,CAAC;IACrC,OAAO,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AACzB,CAAC;AAED;;;;8CAI8C;AAC9C,MAAM,UAAU,gBAAgB,CAAC,MAAc;IAC7C,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IAC3D,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,OAAO,CAAC;QAAE,OAAO,EAAE,CAAC;IAC3C,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,KAAK,MAAM,IAAI,IAAI,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC;QACxC,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,iBAAiB,CAAC,CAAC;QACxC,IAAI,CAAC;YAAE,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC;IAC3C,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED;;;;;;;;;;;;;;yEAcyE;AACzE,MAAM,UAAU,gBAAgB,CAAC,IAUhC;IACC,MAAM,KAAK,GAAa;QACtB,UAAU;QACV,KAAK,IAAI,CAAC,MAAM,IAAI,YAAY,EAAE;QAClC,MAAM,IAAI,CAAC,eAAe,IAAI,MAAM,EAAE;QACtC,cAAc,IAAI,CAAC,GAAG,EAAE;QACxB,OAAO,IAAI,CAAC,OAAO,IAAI,GAAG,EAAE;QAC5B,SAAS,IAAI,CAAC,aAAa,IAAI,GAAG,EAAE;QACpC,QAAQ,IAAI,CAAC,YAAY,IAAI,GAAG,EAAE;KACnC,CAAC;IACF,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC1B,CAAC"}
package/dist/index.js CHANGED
@@ -235,6 +235,13 @@ async function main() {
235
235
  await handleDns();
236
236
  break;
237
237
  }
238
+ case "email": {
239
+ if (args.includes("--help") && args.length === 2)
240
+ return printHelp("email");
241
+ const { handleEmailCommand } = await import("./email/index.js");
242
+ await handleEmailCommand(args.slice(1));
243
+ break;
244
+ }
238
245
  case "gh-pages":
239
246
  case "pages": {
240
247
  if (args.includes("--help"))
@@ -442,7 +449,7 @@ async function handleAdd() {
442
449
  const positional = args.slice(1).filter((a) => !a.startsWith("--"));
443
450
  let baseName = positional[0];
444
451
  const rawService = positional[1];
445
- const allServices = ["glitchtip", "openpanel", "resend", "s3"];
452
+ const allServices = ["glitchtip", "openpanel", "resend", "s3", "email"];
446
453
  if (!baseName) {
447
454
  const { input } = await import("@inquirer/prompts");
448
455
  const { validateProjectName } = await import("./utils/validate.js");
@@ -465,6 +472,11 @@ async function handleAdd() {
465
472
  value: "s3",
466
473
  checked: false,
467
474
  },
475
+ {
476
+ name: "Email forwarding (Cloudflare Email Routing — MX/SPF/DMARC + rules)",
477
+ value: "email",
478
+ checked: false,
479
+ },
468
480
  ],
469
481
  required: true,
470
482
  });
@@ -764,7 +776,7 @@ async function handleRemove() {
764
776
  const skipConfirm = args.includes("--yes") || args.includes("-y");
765
777
  let baseName = positional[0];
766
778
  const rawService = positional[1];
767
- const allServices = ["glitchtip", "openpanel", "resend", "s3"];
779
+ const allServices = ["glitchtip", "openpanel", "resend", "s3", "email"];
768
780
  if (!baseName) {
769
781
  const { input } = await import("@inquirer/prompts");
770
782
  const { validateProjectName } = await import("./utils/validate.js");
@@ -783,6 +795,11 @@ async function handleRemove() {
783
795
  { name: "OpenPanel (deletes the project)", value: "openpanel", checked: true },
784
796
  { name: "Resend (deletes the API key)", value: "resend", checked: true },
785
797
  { name: "S3 / R2 (deletes per-bucket scoped tokens)", value: "s3", checked: false },
798
+ {
799
+ name: "Email forwarding (deletes routing rules + DNS records; keeps destination)",
800
+ value: "email",
801
+ checked: false,
802
+ },
786
803
  ],
787
804
  required: true,
788
805
  });
@@ -914,6 +931,9 @@ async function handleCreate() {
914
931
  // Summary before execution
915
932
  console.log(chalk.bold("\n ── Summary ───────────────────────────────────────────────\n"));
916
933
  console.log(` Project: ${chalk.cyan(config.name)}`);
934
+ if (config.description) {
935
+ console.log(` Descr.: ${chalk.cyan(config.description)}`);
936
+ }
917
937
  console.log(` Domain: ${chalk.cyan(config.domain)}`);
918
938
  if (config.deploymentMode === "gh-pages") {
919
939
  console.log(` Deploy to: ${chalk.cyan("GitHub Pages (static)")}`);
@@ -1277,6 +1297,61 @@ async function handleCreate() {
1277
1297
  const { pushInitialBranch } = await import("./deploy/github.js");
1278
1298
  await pushInitialBranch(appDir);
1279
1299
  }
1300
+ // Step 6.6: optional email forwarding setup (Cloudflare Email
1301
+ // Routing). Opt-in prompt — most projects want it but a scripted
1302
+ // / non-interactive create shouldn't pay the latency cost or sink
1303
+ // on a missing accountId without explicit consent.
1304
+ if (config.scaffoldRepo && !config.dryRun && !nonInteractive && process.stdin.isTTY) {
1305
+ try {
1306
+ const wantsEmail = await confirm({
1307
+ message: `Set up email forwarding for ${chalk.cyan(config.domain)} (Cloudflare Email Routing)?`,
1308
+ default: true,
1309
+ });
1310
+ if (wantsEmail) {
1311
+ const { runEmailSetupForDomain } = await import("./email/index.js");
1312
+ const result = await runEmailSetupForDomain({ domain: config.domain }, appDir);
1313
+ // Mirror adopt's ledger plumbing so `hatchkit destroy <project>`
1314
+ // can roll back the email-routing state we just created.
1315
+ if (ledger && result.destination.createdThisRun) {
1316
+ ledger.record({
1317
+ kind: "cloudflareEmailDestination",
1318
+ accountId: result.accountId,
1319
+ destinationId: result.destination.record.id,
1320
+ email: result.destination.record.email,
1321
+ });
1322
+ }
1323
+ for (const dns of result.dnsRecords) {
1324
+ if (!dns.created)
1325
+ continue;
1326
+ ledger?.record({
1327
+ kind: "cloudflareDnsRecord",
1328
+ zoneId: result.zoneId,
1329
+ recordId: dns.id,
1330
+ name: dns.name,
1331
+ type: dns.type,
1332
+ });
1333
+ }
1334
+ for (const rule of result.rules) {
1335
+ if (!rule.created)
1336
+ continue;
1337
+ ledger?.record({
1338
+ kind: "cloudflareEmailRoutingRule",
1339
+ zoneId: result.zoneId,
1340
+ ruleId: rule.id,
1341
+ address: rule.address,
1342
+ });
1343
+ }
1344
+ }
1345
+ }
1346
+ catch (err) {
1347
+ // Soft-fail: email forwarding is a follow-up convenience, not a
1348
+ // gating step for the rest of `create`. The user can re-run
1349
+ // `hatchkit email setup` once any underlying issue (e.g. zone
1350
+ // not yet in Cloudflare) is fixed.
1351
+ console.log(chalk.yellow(` ⚠ Email forwarding setup skipped: ${err.message}`));
1352
+ console.log(chalk.dim(` Re-run with \`hatchkit email setup --domain ${config.domain}\`.`));
1353
+ }
1354
+ }
1280
1355
  // Step 7: Deploy ML services
1281
1356
  if (config.runDeployment &&
1282
1357
  deploy.length > 0 &&
@@ -1361,7 +1436,7 @@ async function handleCreate() {
1361
1436
  }
1362
1437
  if (config.features.includes("desktop")) {
1363
1438
  console.log(chalk.yellow("\n Next (desktop): replace build/icon.png with a 512×512 logo, then:"));
1364
- console.log(chalk.dim(" pnpm icons:desktop # cross-platform (electron-icon-builder)"));
1439
+ console.log(chalk.dim(" pnpm icons:desktop # cross-platform (icon-gen)"));
1365
1440
  }
1366
1441
  if (config.features.includes("desktop") || config.features.includes("mobile")) {
1367
1442
  console.log(chalk.yellow("\n Server CORS: TRUSTED_ORIGINS is already set in .env.example for native clients."));
@@ -1653,8 +1728,41 @@ function printHelp(topic) {
1653
1728
  ${chalk.dim("INWX_SANDBOX=1")} → use the OTE sandbox instead of production.
1654
1729
 
1655
1730
  ${chalk.bold("Prerequisites:")}
1656
- Run ${chalk.cyan("hatchkit config add dns")} and choose Cloudflare, then answer
1731
+ Run ${chalk.cyan("hatchkit config add dns")} (Cloudflare-only), then answer
1657
1732
  ${chalk.cyan("yes")} to "Is INWX your domain registrar?" when prompted.
1733
+ `);
1734
+ return;
1735
+ }
1736
+ if (topic === "email") {
1737
+ console.log(`
1738
+ ${chalk.bold("hatchkit email")} — Cloudflare Email Routing setup
1739
+
1740
+ ${chalk.bold("Subcommands:")}
1741
+ setup Configure Email Routing + DNS (MX, SPF, DMARC) for a domain
1742
+ status Print current routing state (read-only)
1743
+
1744
+ ${chalk.bold("Flags (setup):")}
1745
+ --domain <fqdn> Override the project domain
1746
+ --to <email> Forwarding destination (saved globally on first use)
1747
+ --addresses <list> Comma-separated local parts (skips picker)
1748
+ --all-defaults Use every default preset; skip picker
1749
+ --no-catch-all Don't set the *@domain catch-all rule
1750
+ --dmarc <none|quarantine|reject> DMARC policy (default: quarantine)
1751
+ --no-resend-spf Skip auto-merging _spf.resend.com
1752
+
1753
+ ${chalk.bold("What it sets:")}
1754
+ · Email Routing enabled on the zone
1755
+ · Destination address verified at Cloudflare (verification email sent)
1756
+ · MX records → route1/route2/route3.mx.cloudflare.net
1757
+ · SPF TXT (single record, merged with Resend if detected)
1758
+ · DMARC TXT at _dmarc.<domain> (default p=quarantine sp=none)
1759
+ · One forwarding rule per picked address
1760
+ · Optional catch-all rule (*@<domain>)
1761
+
1762
+ ${chalk.bold("Prerequisites:")}
1763
+ DNS must be on Cloudflare (${chalk.cyan("hatchkit config add dns")}). The token
1764
+ needs Zone:DNS:Edit + Zone:Email Routing Rules:Edit +
1765
+ Account:Email Routing Addresses:Edit.
1658
1766
  `);
1659
1767
  return;
1660
1768
  }
@@ -2216,6 +2324,7 @@ function printHelp(topic) {
2216
2324
  sync Push the manifest's domain/ports onto the matching Coolify app(s)
2217
2325
  gh-pages Wire GitHub Pages for the current repo (static / Vite / Jekyll — with DNS)
2218
2326
  dns DNS reconciliation helpers (link-to-cloudflare, …)
2327
+ email Set up Cloudflare Email Routing + MX/SPF/DMARC (setup/status)
2219
2328
  keys show <p> Print the dotenvx private key for a project
2220
2329
  keys set <p> Upsert the key into the OS keychain (after \`dotenvx rotate\`)
2221
2330
  keys rotate <p> Rotate the dotenvx keypair, mirror to keychain + (optional) deploy targets