hatchkit 0.1.41 → 0.1.43
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.
- package/dist/adopt.d.ts +77 -0
- package/dist/adopt.d.ts.map +1 -1
- package/dist/adopt.js +757 -170
- package/dist/adopt.js.map +1 -1
- package/dist/config.d.ts +32 -10
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +91 -38
- package/dist/config.js.map +1 -1
- package/dist/deploy/coolify-app.d.ts.map +1 -1
- package/dist/deploy/coolify-app.js +0 -7
- package/dist/deploy/coolify-app.js.map +1 -1
- package/dist/deploy/coolify.d.ts.map +1 -1
- package/dist/deploy/coolify.js +20 -1
- package/dist/deploy/coolify.js.map +1 -1
- package/dist/deploy/ghcr.d.ts +4 -2
- package/dist/deploy/ghcr.d.ts.map +1 -1
- package/dist/deploy/ghcr.js +1 -1
- package/dist/deploy/ghcr.js.map +1 -1
- package/dist/deploy/github.d.ts +4 -3
- package/dist/deploy/github.d.ts.map +1 -1
- package/dist/deploy/github.js +5 -2
- package/dist/deploy/github.js.map +1 -1
- package/dist/deploy/pages.d.ts.map +1 -1
- package/dist/deploy/pages.js +8 -14
- package/dist/deploy/pages.js.map +1 -1
- package/dist/deploy/regen-infra.d.ts.map +1 -1
- package/dist/deploy/regen-infra.js +1 -11
- package/dist/deploy/regen-infra.js.map +1 -1
- package/dist/deploy/rollback.d.ts.map +1 -1
- package/dist/deploy/rollback.js +30 -6
- package/dist/deploy/rollback.js.map +1 -1
- package/dist/deploy/terraform.d.ts.map +1 -1
- package/dist/deploy/terraform.js +20 -37
- package/dist/deploy/terraform.js.map +1 -1
- package/dist/dns.d.ts.map +1 -1
- package/dist/dns.js +4 -5
- package/dist/dns.js.map +1 -1
- package/dist/doctor.d.ts +15 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +110 -36
- package/dist/doctor.js.map +1 -1
- package/dist/email/index.d.ts +31 -0
- package/dist/email/index.d.ts.map +1 -0
- package/dist/email/index.js +251 -0
- package/dist/email/index.js.map +1 -0
- package/dist/email/presets.d.ts +14 -0
- package/dist/email/presets.d.ts.map +1 -0
- package/dist/email/presets.js +33 -0
- package/dist/email/presets.js.map +1 -0
- package/dist/email/setup.d.ts +93 -0
- package/dist/email/setup.d.ts.map +1 -0
- package/dist/email/setup.js +263 -0
- package/dist/email/setup.js.map +1 -0
- package/dist/email/spf.d.ts +56 -0
- package/dist/email/spf.d.ts.map +1 -0
- package/dist/email/spf.js +102 -0
- package/dist/email/spf.js.map +1 -0
- package/dist/index.js +113 -4
- package/dist/index.js.map +1 -1
- package/dist/inventory.d.ts.map +1 -1
- package/dist/inventory.js +34 -11
- package/dist/inventory.js.map +1 -1
- package/dist/overview.d.ts.map +1 -1
- package/dist/overview.js +43 -15
- package/dist/overview.js.map +1 -1
- package/dist/prompts.d.ts +5 -0
- package/dist/prompts.d.ts.map +1 -1
- package/dist/prompts.js +29 -7
- package/dist/prompts.js.map +1 -1
- package/dist/provision/index.d.ts +20 -1
- package/dist/provision/index.d.ts.map +1 -1
- package/dist/provision/index.js +115 -0
- package/dist/provision/index.js.map +1 -1
- package/dist/provision/s3-buckets.d.ts.map +1 -1
- package/dist/provision/s3-buckets.js +45 -25
- package/dist/provision/s3-buckets.js.map +1 -1
- package/dist/provision/write-env.d.ts +6 -0
- package/dist/provision/write-env.d.ts.map +1 -1
- package/dist/provision/write-env.js +17 -0
- package/dist/provision/write-env.js.map +1 -1
- package/dist/scaffold/app.d.ts.map +1 -1
- package/dist/scaffold/app.js +15 -7
- package/dist/scaffold/app.js.map +1 -1
- package/dist/scaffold/build-pipeline.d.ts +40 -0
- package/dist/scaffold/build-pipeline.d.ts.map +1 -1
- package/dist/scaffold/build-pipeline.js +201 -5
- package/dist/scaffold/build-pipeline.js.map +1 -1
- package/dist/scaffold/infra.d.ts +4 -5
- package/dist/scaffold/infra.d.ts.map +1 -1
- package/dist/scaffold/infra.js +11 -56
- package/dist/scaffold/infra.js.map +1 -1
- package/dist/scaffold/manifest.d.ts.map +1 -1
- package/dist/scaffold/manifest.js +1 -0
- package/dist/scaffold/manifest.js.map +1 -1
- package/dist/scaffold/pages-heuristics.d.ts.map +1 -1
- package/dist/scaffold/pages-heuristics.js +10 -10
- package/dist/scaffold/pages-heuristics.js.map +1 -1
- package/dist/scaffold/pages-mode.js +2 -4
- package/dist/scaffold/pages-mode.js.map +1 -1
- package/dist/scaffold/pkg-json.d.ts +4 -0
- package/dist/scaffold/pkg-json.d.ts.map +1 -1
- package/dist/scaffold/pkg-json.js +17 -0
- package/dist/scaffold/pkg-json.js.map +1 -1
- package/dist/scaffold/update.js +1 -1
- package/dist/scaffold/update.js.map +1 -1
- package/dist/templates/build-pipeline/Dockerfile.nextjs-monorepo.hbs +107 -0
- package/dist/templates/build-pipeline/Dockerfile.nextjs.hbs +103 -0
- package/dist/templates/build-pipeline/docker-compose.yml.hbs +37 -8
- package/dist/utils/cloudflare-api.d.ts +146 -20
- package/dist/utils/cloudflare-api.d.ts.map +1 -1
- package/dist/utils/cloudflare-api.js +203 -11
- package/dist/utils/cloudflare-api.js.map +1 -1
- package/dist/utils/run-ledger.d.ts +22 -1
- package/dist/utils/run-ledger.d.ts.map +1 -1
- package/dist/utils/run-ledger.js.map +1 -1
- package/dist/utils/s3-admin.d.ts +9 -0
- package/dist/utils/s3-admin.d.ts.map +1 -0
- package/dist/utils/s3-admin.js +46 -0
- package/dist/utils/s3-admin.js.map +1 -0
- 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 (
|
|
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")}
|
|
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
|