hatchkit 0.1.40 → 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 (127) hide show
  1. package/dist/adopt.d.ts.map +1 -1
  2. package/dist/adopt.js +663 -82
  3. package/dist/adopt.js.map +1 -1
  4. package/dist/config.d.ts +32 -10
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +91 -38
  7. package/dist/config.js.map +1 -1
  8. package/dist/deploy/coolify-app.d.ts.map +1 -1
  9. package/dist/deploy/coolify-app.js +0 -7
  10. package/dist/deploy/coolify-app.js.map +1 -1
  11. package/dist/deploy/coolify.d.ts.map +1 -1
  12. package/dist/deploy/coolify.js +20 -1
  13. package/dist/deploy/coolify.js.map +1 -1
  14. package/dist/deploy/ghcr.d.ts +4 -2
  15. package/dist/deploy/ghcr.d.ts.map +1 -1
  16. package/dist/deploy/ghcr.js +1 -1
  17. package/dist/deploy/ghcr.js.map +1 -1
  18. package/dist/deploy/github.d.ts +4 -3
  19. package/dist/deploy/github.d.ts.map +1 -1
  20. package/dist/deploy/github.js +5 -2
  21. package/dist/deploy/github.js.map +1 -1
  22. package/dist/deploy/pages.d.ts +41 -0
  23. package/dist/deploy/pages.d.ts.map +1 -1
  24. package/dist/deploy/pages.js +363 -22
  25. package/dist/deploy/pages.js.map +1 -1
  26. package/dist/deploy/regen-infra.d.ts.map +1 -1
  27. package/dist/deploy/regen-infra.js +5 -11
  28. package/dist/deploy/regen-infra.js.map +1 -1
  29. package/dist/deploy/rollback.d.ts.map +1 -1
  30. package/dist/deploy/rollback.js +44 -6
  31. package/dist/deploy/rollback.js.map +1 -1
  32. package/dist/deploy/terraform.d.ts.map +1 -1
  33. package/dist/deploy/terraform.js +20 -37
  34. package/dist/deploy/terraform.js.map +1 -1
  35. package/dist/dns.d.ts.map +1 -1
  36. package/dist/dns.js +4 -5
  37. package/dist/dns.js.map +1 -1
  38. package/dist/doctor.d.ts +15 -0
  39. package/dist/doctor.d.ts.map +1 -1
  40. package/dist/doctor.js +110 -36
  41. package/dist/doctor.js.map +1 -1
  42. package/dist/email/index.d.ts +31 -0
  43. package/dist/email/index.d.ts.map +1 -0
  44. package/dist/email/index.js +251 -0
  45. package/dist/email/index.js.map +1 -0
  46. package/dist/email/presets.d.ts +14 -0
  47. package/dist/email/presets.d.ts.map +1 -0
  48. package/dist/email/presets.js +33 -0
  49. package/dist/email/presets.js.map +1 -0
  50. package/dist/email/setup.d.ts +93 -0
  51. package/dist/email/setup.d.ts.map +1 -0
  52. package/dist/email/setup.js +263 -0
  53. package/dist/email/setup.js.map +1 -0
  54. package/dist/email/spf.d.ts +56 -0
  55. package/dist/email/spf.d.ts.map +1 -0
  56. package/dist/email/spf.js +102 -0
  57. package/dist/email/spf.js.map +1 -0
  58. package/dist/index.js +306 -22
  59. package/dist/index.js.map +1 -1
  60. package/dist/inventory.d.ts +37 -0
  61. package/dist/inventory.d.ts.map +1 -1
  62. package/dist/inventory.js +536 -55
  63. package/dist/inventory.js.map +1 -1
  64. package/dist/overview.d.ts +101 -0
  65. package/dist/overview.d.ts.map +1 -0
  66. package/dist/overview.js +880 -0
  67. package/dist/overview.js.map +1 -0
  68. package/dist/prompts.d.ts +27 -0
  69. package/dist/prompts.d.ts.map +1 -1
  70. package/dist/prompts.js +262 -34
  71. package/dist/prompts.js.map +1 -1
  72. package/dist/provision/index.d.ts +20 -1
  73. package/dist/provision/index.d.ts.map +1 -1
  74. package/dist/provision/index.js +115 -0
  75. package/dist/provision/index.js.map +1 -1
  76. package/dist/provision/s3-buckets.js +1 -1
  77. package/dist/provision/s3-buckets.js.map +1 -1
  78. package/dist/scaffold/app.d.ts.map +1 -1
  79. package/dist/scaffold/app.js +15 -7
  80. package/dist/scaffold/app.js.map +1 -1
  81. package/dist/scaffold/build-pipeline.d.ts +16 -0
  82. package/dist/scaffold/build-pipeline.d.ts.map +1 -1
  83. package/dist/scaffold/build-pipeline.js +47 -4
  84. package/dist/scaffold/build-pipeline.js.map +1 -1
  85. package/dist/scaffold/infra.d.ts +4 -5
  86. package/dist/scaffold/infra.d.ts.map +1 -1
  87. package/dist/scaffold/infra.js +18 -57
  88. package/dist/scaffold/infra.js.map +1 -1
  89. package/dist/scaffold/manifest.d.ts +6 -0
  90. package/dist/scaffold/manifest.d.ts.map +1 -1
  91. package/dist/scaffold/manifest.js +2 -0
  92. package/dist/scaffold/manifest.js.map +1 -1
  93. package/dist/scaffold/pages-heuristics.d.ts +17 -0
  94. package/dist/scaffold/pages-heuristics.d.ts.map +1 -0
  95. package/dist/scaffold/pages-heuristics.js +344 -0
  96. package/dist/scaffold/pages-heuristics.js.map +1 -0
  97. package/dist/scaffold/pages-mode.d.ts +10 -0
  98. package/dist/scaffold/pages-mode.d.ts.map +1 -0
  99. package/dist/scaffold/pages-mode.js +107 -0
  100. package/dist/scaffold/pages-mode.js.map +1 -0
  101. package/dist/scaffold/pkg-json.d.ts +4 -0
  102. package/dist/scaffold/pkg-json.d.ts.map +1 -1
  103. package/dist/scaffold/pkg-json.js +17 -0
  104. package/dist/scaffold/pkg-json.js.map +1 -1
  105. package/dist/scaffold/surfaces.d.ts.map +1 -1
  106. package/dist/scaffold/surfaces.js +12 -1
  107. package/dist/scaffold/surfaces.js.map +1 -1
  108. package/dist/scaffold/update.js +1 -1
  109. package/dist/scaffold/update.js.map +1 -1
  110. package/dist/templates/build-pipeline/Dockerfile.nextjs.hbs +103 -0
  111. package/dist/templates/build-pipeline/docker-compose.yml.hbs +23 -6
  112. package/dist/utils/cloudflare-api.d.ts +158 -13
  113. package/dist/utils/cloudflare-api.d.ts.map +1 -1
  114. package/dist/utils/cloudflare-api.js +219 -11
  115. package/dist/utils/cloudflare-api.js.map +1 -1
  116. package/dist/utils/coolify-api.d.ts +9 -0
  117. package/dist/utils/coolify-api.d.ts.map +1 -1
  118. package/dist/utils/coolify-api.js +26 -0
  119. package/dist/utils/coolify-api.js.map +1 -1
  120. package/dist/utils/run-ledger.d.ts +42 -1
  121. package/dist/utils/run-ledger.d.ts.map +1 -1
  122. package/dist/utils/run-ledger.js.map +1 -1
  123. package/dist/utils/s3-admin.d.ts +9 -0
  124. package/dist/utils/s3-admin.d.ts.map +1 -0
  125. package/dist/utils/s3-admin.js +46 -0
  126. package/dist/utils/s3-admin.js.map +1 -0
  127. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -166,6 +166,13 @@ async function main() {
166
166
  await runDoctor({ json: isJson });
167
167
  break;
168
168
  }
169
+ case "overview": {
170
+ if (args.includes("--help"))
171
+ return printHelp("overview");
172
+ const { runOverview } = await import("./overview.js");
173
+ await runOverview({ json: isJson, all: args.includes("--all") });
174
+ break;
175
+ }
169
176
  case "inventory": {
170
177
  if (args.includes("--help"))
171
178
  return printHelp("inventory");
@@ -183,6 +190,8 @@ async function main() {
183
190
  await runInventory(resolve("."), {
184
191
  json: isJson,
185
192
  yes: args.includes("--yes") || args.includes("-y"),
193
+ save: args.includes("--save"),
194
+ noSave: args.includes("--no-save"),
186
195
  input: Object.keys(inputOverride).length > 0 ? inputOverride : undefined,
187
196
  });
188
197
  break;
@@ -226,6 +235,13 @@ async function main() {
226
235
  await handleDns();
227
236
  break;
228
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
+ }
229
245
  case "gh-pages":
230
246
  case "pages": {
231
247
  if (args.includes("--help"))
@@ -233,6 +249,14 @@ async function main() {
233
249
  if (command === "pages") {
234
250
  console.log(chalk.yellow(" Note: `hatchkit pages` has been renamed to `hatchkit gh-pages`."));
235
251
  }
252
+ if (args.includes("--undo")) {
253
+ const { runPagesUndo } = await import("./deploy/pages.js");
254
+ await runPagesUndo(resolve("."), {
255
+ dryRun: args.includes("--dry-run"),
256
+ yes: args.includes("--yes") || args.includes("-y"),
257
+ });
258
+ break;
259
+ }
236
260
  const { runPagesSetup } = await import("./deploy/pages.js");
237
261
  await runPagesSetup(resolve("."));
238
262
  break;
@@ -425,7 +449,7 @@ async function handleAdd() {
425
449
  const positional = args.slice(1).filter((a) => !a.startsWith("--"));
426
450
  let baseName = positional[0];
427
451
  const rawService = positional[1];
428
- const allServices = ["glitchtip", "openpanel", "resend", "s3"];
452
+ const allServices = ["glitchtip", "openpanel", "resend", "s3", "email"];
429
453
  if (!baseName) {
430
454
  const { input } = await import("@inquirer/prompts");
431
455
  const { validateProjectName } = await import("./utils/validate.js");
@@ -448,6 +472,11 @@ async function handleAdd() {
448
472
  value: "s3",
449
473
  checked: false,
450
474
  },
475
+ {
476
+ name: "Email forwarding (Cloudflare Email Routing — MX/SPF/DMARC + rules)",
477
+ value: "email",
478
+ checked: false,
479
+ },
451
480
  ],
452
481
  required: true,
453
482
  });
@@ -747,7 +776,7 @@ async function handleRemove() {
747
776
  const skipConfirm = args.includes("--yes") || args.includes("-y");
748
777
  let baseName = positional[0];
749
778
  const rawService = positional[1];
750
- const allServices = ["glitchtip", "openpanel", "resend", "s3"];
779
+ const allServices = ["glitchtip", "openpanel", "resend", "s3", "email"];
751
780
  if (!baseName) {
752
781
  const { input } = await import("@inquirer/prompts");
753
782
  const { validateProjectName } = await import("./utils/validate.js");
@@ -766,6 +795,11 @@ async function handleRemove() {
766
795
  { name: "OpenPanel (deletes the project)", value: "openpanel", checked: true },
767
796
  { name: "Resend (deletes the API key)", value: "resend", checked: true },
768
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
+ },
769
803
  ],
770
804
  required: true,
771
805
  });
@@ -849,17 +883,22 @@ async function handleCreate() {
849
883
  if (forceNoInstall)
850
884
  config.installDeps = false;
851
885
  // Ensure needed providers are configured (lazy prompting).
852
- // Coolify + Hetzner are only needed when actually deploying
853
- // scaffold-only + --no-deploy runs skip their setup prompts.
854
- if (config.deployTarget === "existing" || config.runDeployment) {
886
+ // Coolify + Hetzner only matter for the coolify deployment mode.
887
+ // gh-pages skips them entirely (no server, no Docker registry).
888
+ if (config.deploymentMode === "coolify" &&
889
+ (config.deployTarget === "existing" || config.runDeployment)) {
855
890
  await ensureCoolify();
856
891
  }
857
892
  // GitHub is checked here so auth failures surface before scaffold
858
- // (not deep inside `setupGitHub` after files are on disk).
859
- if (config.createGithubRepo) {
893
+ // (not deep inside `setupGitHub` after files are on disk). Pages
894
+ // also needs GitHub auth for the API calls that enable Pages and
895
+ // set the cname — so we require it whenever gh-pages is involved.
896
+ if (config.createGithubRepo || config.deploymentMode === "gh-pages") {
860
897
  await ensureGitHub();
861
898
  }
862
- if (config.deployTarget === "new" && config.runDeployment) {
899
+ if (config.deploymentMode === "coolify" &&
900
+ config.deployTarget === "new" &&
901
+ config.runDeployment) {
863
902
  await ensureHetzner();
864
903
  }
865
904
  if (config.features.includes("s3") &&
@@ -892,8 +931,19 @@ async function handleCreate() {
892
931
  // Summary before execution
893
932
  console.log(chalk.bold("\n ── Summary ───────────────────────────────────────────────\n"));
894
933
  console.log(` Project: ${chalk.cyan(config.name)}`);
934
+ if (config.description) {
935
+ console.log(` Descr.: ${chalk.cyan(config.description)}`);
936
+ }
895
937
  console.log(` Domain: ${chalk.cyan(config.domain)}`);
896
- console.log(` Deploy to: ${config.deployTarget === "existing" ? `existing server (${config.serverIpv4 ?? config.serverIp ?? "?"}${config.serverIpv6 ? ` · ${config.serverIpv6}` : ""})` : `new Hetzner ${config.serverSize}`}`);
938
+ if (config.deploymentMode === "gh-pages") {
939
+ console.log(` Deploy to: ${chalk.cyan("GitHub Pages (static)")}`);
940
+ }
941
+ else if (config.deploymentMode === "scaffold-only") {
942
+ console.log(` Deploy to: ${chalk.dim("scaffold only (no deploy)")}`);
943
+ }
944
+ else {
945
+ console.log(` Deploy to: ${config.deployTarget === "existing" ? `existing server (${config.serverIpv4 ?? config.serverIp ?? "?"}${config.serverIpv6 ? ` · ${config.serverIpv6}` : ""})` : `new Hetzner ${config.serverSize}`}`);
946
+ }
897
947
  console.log(` Features: ${config.features.length > 0 ? config.features.join(", ") : "none"}`);
898
948
  console.log(` ML: ${config.mlServices.length > 0 ? config.mlServices.join(", ") : "none"}`);
899
949
  console.log(` Scaffold: ${config.scaffoldRepo ? "yes" : "no"}`);
@@ -1028,10 +1078,19 @@ async function handleCreate() {
1028
1078
  }
1029
1079
  }
1030
1080
  if (config.dryRun) {
1031
- scaffoldInfra(config, INFRA_ROOT, {
1032
- serverPort: scaffoldResult?.ports.server,
1033
- clientPort: scaffoldResult?.ports.client,
1034
- });
1081
+ // Coolify mode previews the Terraform tfvars + Coolify env that
1082
+ // would be written. gh-pages and scaffold-only have nothing
1083
+ // equivalent — Pages reads no env, scaffold-only writes no infra.
1084
+ if (config.deploymentMode === "coolify") {
1085
+ scaffoldInfra(config, INFRA_ROOT, {
1086
+ serverPort: scaffoldResult?.ports.server,
1087
+ clientPort: scaffoldResult?.ports.client,
1088
+ });
1089
+ }
1090
+ else if (config.deploymentMode === "gh-pages") {
1091
+ console.log(chalk.dim(" · gh-pages mode — would write `.github/workflows/gh-pages.yml`, patch `next.config`,\n" +
1092
+ " write CNAME, enable Pages, configure DNS, and wait for the Let's Encrypt cert."));
1093
+ }
1035
1094
  console.log(chalk.green("\n ✓ Dry run complete. No changes were made.\n"));
1036
1095
  return;
1037
1096
  }
@@ -1081,8 +1140,10 @@ async function handleCreate() {
1081
1140
  if (infraResult.coolifyEnvPath) {
1082
1141
  ledger?.record({ kind: "coolifyEnv", path: infraResult.coolifyEnvPath });
1083
1142
  }
1084
- // Step 5: Terraform (DNS + optionally server)
1085
- if (config.runDeployment) {
1143
+ // Step 5: Terraform (DNS + optionally server). Coolify-only —
1144
+ // gh-pages handles its own DNS via `runPagesSetupProgrammatic`
1145
+ // a few steps down, and `scaffold-only` skips deploy entirely.
1146
+ if (config.runDeployment && config.deploymentMode === "coolify") {
1086
1147
  const tfResult = await runTerraform(config, INFRA_ROOT);
1087
1148
  if (tfResult.applied) {
1088
1149
  ledger?.record({
@@ -1092,8 +1153,9 @@ async function handleCreate() {
1092
1153
  });
1093
1154
  }
1094
1155
  }
1095
- // Step 6: Coolify setup
1096
- if (config.runDeployment) {
1156
+ // Step 6: Coolify setup. Only runs in coolify mode; gh-pages has
1157
+ // no Coolify app to provision (the site lives on GitHub's CDN).
1158
+ if (config.runDeployment && config.deploymentMode === "coolify") {
1097
1159
  const coolifyResult = await runCoolifySetup(config, {
1098
1160
  repoUrl: repoUrl ?? undefined,
1099
1161
  serverPort: scaffoldResult?.ports.server,
@@ -1170,6 +1232,63 @@ async function handleCreate() {
1170
1232
  }
1171
1233
  }
1172
1234
  }
1235
+ // Step 6.25 (gh-pages only): run Pages setup. Writes the
1236
+ // .github/workflows/gh-pages.yml + CNAME file locally and wires
1237
+ // the remote side (enable Pages, register cname, configure DNS,
1238
+ // poll for the Let's Encrypt cert, flip https_enforced). Must
1239
+ // happen BEFORE push so the new files land in the first push and
1240
+ // the workflow runs immediately.
1241
+ if (config.deploymentMode === "gh-pages" &&
1242
+ config.scaffoldRepo &&
1243
+ config.runDeployment &&
1244
+ repoUrl) {
1245
+ const { runPagesSetupProgrammatic } = await import("./deploy/pages.js");
1246
+ const { exec: bashExec } = await import("./utils/exec.js");
1247
+ // The scaffold's `pruneToClientOnly` rewrites the root build
1248
+ // script to `pnpm --filter @starter/shared run build && pnpm
1249
+ // --filter @starter/client run build` — runs from the repo
1250
+ // root, outputs to `packages/client/out/` (after the Pages-
1251
+ // mode Next config patch sets `output: "export"`).
1252
+ const detected = {
1253
+ kind: "node-build",
1254
+ publishDir: "packages/client/out",
1255
+ packageManager: "pnpm",
1256
+ buildScript: "build",
1257
+ workDir: "",
1258
+ };
1259
+ const slug = repoUrl.replace(/^https?:\/\/github\.com\//, "");
1260
+ try {
1261
+ const { pageUrl } = await runPagesSetupProgrammatic(appDir, {
1262
+ detected,
1263
+ domain: config.domain,
1264
+ });
1265
+ ledger?.record({
1266
+ kind: "ghPages",
1267
+ repo: slug,
1268
+ projectDir: appDir,
1269
+ cname: config.domain,
1270
+ });
1271
+ // Commit the workflow + CNAME file before the push step
1272
+ // below picks up the staged changes. Empty diffs (e.g. re-
1273
+ // running on an idempotent state) just produce a no-op commit.
1274
+ await bashExec("git", ["add", "-A"], { cwd: appDir, silent: true });
1275
+ const status = await bashExec("git", ["status", "--porcelain"], {
1276
+ cwd: appDir,
1277
+ silent: true,
1278
+ });
1279
+ if (status.stdout.trim()) {
1280
+ await bashExec("git", ["commit", "-m", "ci: GitHub Pages setup"], {
1281
+ cwd: appDir,
1282
+ silent: true,
1283
+ });
1284
+ }
1285
+ console.log(chalk.green(` ✓ GitHub Pages will publish at ${pageUrl}`));
1286
+ }
1287
+ catch (err) {
1288
+ console.log(chalk.yellow(` Couldn't auto-wire GitHub Pages: ${err.message}`));
1289
+ console.log(chalk.dim(` Run \`hatchkit gh-pages\` from ${appDir} once the issue is resolved.`));
1290
+ }
1291
+ }
1173
1292
  // Step 6.5: push the working branch to origin. Done AFTER Coolify
1174
1293
  // wiring + Actions-secret upserts so the workflow's first run
1175
1294
  // already has the secrets it needs to deploy. setupGitHub above
@@ -1178,6 +1297,61 @@ async function handleCreate() {
1178
1297
  const { pushInitialBranch } = await import("./deploy/github.js");
1179
1298
  await pushInitialBranch(appDir);
1180
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
+ }
1181
1355
  // Step 7: Deploy ML services
1182
1356
  if (config.runDeployment &&
1183
1357
  deploy.length > 0 &&
@@ -1242,6 +1416,9 @@ async function handleCreate() {
1242
1416
  if (config.surfaces !== "client-only") {
1243
1417
  console.log(` API: ${chalk.cyan(`https://${config.domain}/api`)}`);
1244
1418
  }
1419
+ if (config.deploymentMode === "gh-pages") {
1420
+ console.log(chalk.dim(` Hosting: GitHub Pages — first build kicks off on push, https cert provisions over the next few minutes.`));
1421
+ }
1245
1422
  console.log(` App dir: ${chalk.dim(appDir)}`);
1246
1423
  console.log(` Config: ${chalk.dim(getConfigPath())}`);
1247
1424
  if (config.scaffoldRepo) {
@@ -1259,7 +1436,7 @@ async function handleCreate() {
1259
1436
  }
1260
1437
  if (config.features.includes("desktop")) {
1261
1438
  console.log(chalk.yellow("\n Next (desktop): replace build/icon.png with a 512×512 logo, then:"));
1262
- console.log(chalk.dim(" pnpm icons:desktop # cross-platform (electron-icon-builder)"));
1439
+ console.log(chalk.dim(" pnpm icons:desktop # cross-platform (icon-gen)"));
1263
1440
  }
1264
1441
  if (config.features.includes("desktop") || config.features.includes("mobile")) {
1265
1442
  console.log(chalk.yellow("\n Server CORS: TRUSTED_ORIGINS is already set in .env.example for native clients."));
@@ -1391,13 +1568,20 @@ function printHelp(topic) {
1391
1568
  hatchkit create [--dry-run]
1392
1569
 
1393
1570
  ${chalk.bold("What it does (interactively):")}
1394
- 1. Prompts for project name, domain, deploy target, features, ML services
1571
+ 1. Prompts for project name, domain, surfaces, deployment mode, features, ML
1395
1572
  2. Copies the starter template and strips unselected features
1396
1573
  3. Assigns unique ports per project (server, client, native HMR)
1397
1574
  4. Runs \`pnpm install\` (if pnpm is present and you opt in)
1398
1575
  5. Initializes git, optionally creates a GitHub repo
1399
- 6. Generates Terraform tfvars + Coolify .env
1400
- 7. Optionally deploys: Terraform → Coolify → ML services
1576
+ 6. Generates Terraform tfvars + Coolify .env (Coolify mode)
1577
+ 7. Deploys: Terraform → Coolify → ML ${chalk.dim("OR")} GitHub Pages setup
1578
+
1579
+ ${chalk.bold("Deployment modes:")}
1580
+ ${chalk.cyan("coolify")} Full-stack on Hetzner — DB, providers, Docker. Default.
1581
+ ${chalk.cyan("gh-pages")} Static-only on GitHub Pages. Only offered when surfaces
1582
+ is ${chalk.dim("client-only")}; the scaffold's Next config is patched to
1583
+ ${chalk.dim('`output: "export"`')} and the gh-pages workflow is written.
1584
+ ${chalk.cyan("scaffold-only")} Write files, skip deploy. Pick this to defer setup.
1401
1585
 
1402
1586
  ${chalk.bold("Options:")}
1403
1587
  --dry-run Show the plan without writing anything
@@ -1485,6 +1669,7 @@ function printHelp(topic) {
1485
1669
 
1486
1670
  ${chalk.bold("Usage:")}
1487
1671
  cd <project-dir> && hatchkit gh-pages
1672
+ cd <project-dir> && hatchkit gh-pages --undo [--dry-run] [--yes]
1488
1673
 
1489
1674
  ${chalk.bold("What it does:")}
1490
1675
  1. Reads the repo via \`gh repo view\` (must be a GitHub repo you own).
@@ -1506,6 +1691,17 @@ function printHelp(topic) {
1506
1691
  ${chalk.bold("After running:")}
1507
1692
  git add -A && git commit -m "ci: deploy to GitHub Pages" && git push
1508
1693
 
1694
+ ${chalk.bold("Undo (--undo):")}
1695
+ Reverses what the command put in place:
1696
+ - Disables Pages via ${chalk.dim("DELETE /repos/<owner>/<repo>/pages")} (clears the cname too).
1697
+ - Deletes Cloudflare records that point at GitHub's Pages IPs / ${chalk.dim("<user>.github.io")}
1698
+ for the registered domain (only when a Cloudflare token is configured + the
1699
+ zone is in this account).
1700
+ - Removes ${chalk.cyan(".github/workflows/gh-pages.yml")} (only the file hatchkit writes
1701
+ — hand-written Pages workflows are left untouched).
1702
+ - Removes any ${chalk.cyan("CNAME")} files whose content matches the registered domain.
1703
+ ${chalk.dim("--dry-run")} prints the plan without changing anything. ${chalk.dim("--yes")} skips the confirm.
1704
+
1509
1705
  ${chalk.bold("Notes:")}
1510
1706
  - Private repos need a paid GitHub plan for Pages. Free-tier repos
1511
1707
  must be made public first.
@@ -1532,8 +1728,41 @@ function printHelp(topic) {
1532
1728
  ${chalk.dim("INWX_SANDBOX=1")} → use the OTE sandbox instead of production.
1533
1729
 
1534
1730
  ${chalk.bold("Prerequisites:")}
1535
- Run ${chalk.cyan("hatchkit config add dns")} and choose Cloudflare, then answer
1731
+ Run ${chalk.cyan("hatchkit config add dns")} (Cloudflare-only), then answer
1536
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.
1537
1766
  `);
1538
1767
  return;
1539
1768
  }
@@ -1545,6 +1774,46 @@ function printHelp(topic) {
1545
1774
  stored (Coolify /version, Hetzner /servers, Cloudflare /tokens/verify,
1546
1775
  Resend /domains, …). Reports ok / fail / not-configured per provider
1547
1776
  and exits non-zero if any check fails. Safe to run repeatedly.
1777
+ `);
1778
+ return;
1779
+ }
1780
+ if (topic === "overview") {
1781
+ console.log(`
1782
+ ${chalk.bold("hatchkit overview")} — fleet-level view of every configured provider
1783
+
1784
+ Distinct from ${chalk.cyan("status")} (which providers do I have credentials for?),
1785
+ ${chalk.cyan("doctor")} (are those credentials valid?), and ${chalk.cyan("inventory")} (what does THIS
1786
+ project have?). ${chalk.cyan("overview")} answers "what does my whole hatchkit
1787
+ footprint look like, across every configured provider?" — no name or
1788
+ domain filter, just a roll-up of top-level resources.
1789
+
1790
+ ${chalk.bold("What it lists:")}
1791
+ · Coolify applications, projects, databases
1792
+ · Cloudflare DNS zones
1793
+ · R2 buckets (whole account)
1794
+ · Hetzner S3 / AWS S3 credential presence (bucket listing not implemented)
1795
+ · Resend verified domains
1796
+ · GlitchTip projects in the configured org
1797
+ · OpenPanel projects
1798
+ · Stripe webhook endpoints (test + live)
1799
+
1800
+ ${chalk.bold("Cross-references:")}
1801
+ After listing every provider, ${chalk.cyan("overview")} cross-references the
1802
+ raw data to flag fleet-level inconsistencies — the kind of bitrot
1803
+ that a single-provider lens can't see:
1804
+
1805
+ · Coolify app deploys from a repo \`gh\` can't find (deleted/renamed)
1806
+ · App fqdn references an apex with no Cloudflare zone
1807
+ · R2 bucket follows the \`<project>-<role>\` convention but has no
1808
+ matching Coolify app (orphan from a destroyed project)
1809
+ · GlitchTip / OpenPanel project with no Coolify app counterpart
1810
+ · Cloudflare zone with no Coolify app pointing into it
1811
+
1812
+ ${chalk.bold("Flags:")}
1813
+ --all Print every resource per provider (default: 6-line preview)
1814
+ --json Machine-readable OverviewReport (non-interactive)
1815
+
1816
+ Read-only — every call is a GET. Safe to run repeatedly.
1548
1817
  `);
1549
1818
  return;
1550
1819
  }
@@ -1591,7 +1860,19 @@ function printHelp(topic) {
1591
1860
  --domain <domain> Override inferred domain
1592
1861
  --repo <owner/name> Override inferred GitHub repo
1593
1862
  --yes, -y Skip confirm-inferred-value prompts
1863
+ --save Write a minimal .hatchkit.json without prompting
1864
+ --no-save Suppress the end-of-run save prompt
1594
1865
  --json Machine-readable InventoryReport (non-interactive)
1866
+
1867
+ ${chalk.bold("Persisting identity:")}
1868
+ After an interactive run, when ${chalk.cyan(".hatchkit.json")} doesn't yet exist
1869
+ and both name + domain are inferred, hatchkit offers to write a
1870
+ minimal manifest. The manifest carries the right schema for every
1871
+ other command (adopt, update, sync, keys), with conservative defaults
1872
+ for fields inventory can't infer (features=[], s3Provider="none",
1873
+ deployTarget="existing", ports={server:3000,client:5173}). Run
1874
+ ${chalk.cyan("hatchkit adopt --resume")} afterwards to flesh out the rest via
1875
+ the adopt stepper.
1595
1876
  `);
1596
1877
  return;
1597
1878
  }
@@ -2028,6 +2309,7 @@ function printHelp(topic) {
2028
2309
  status Show what's configured and what's next
2029
2310
  doctor Health-check every provider with contextual fix hints
2030
2311
  inventory Survey what already exists for this project (and flag drift)
2312
+ overview Fleet-level survey — every resource across all configured providers
2031
2313
  explain One-page mental model of the CLI
2032
2314
 
2033
2315
  ${chalk.bold("Projects:")}
@@ -2042,6 +2324,7 @@ function printHelp(topic) {
2042
2324
  sync Push the manifest's domain/ports onto the matching Coolify app(s)
2043
2325
  gh-pages Wire GitHub Pages for the current repo (static / Vite / Jekyll — with DNS)
2044
2326
  dns DNS reconciliation helpers (link-to-cloudflare, …)
2327
+ email Set up Cloudflare Email Routing + MX/SPF/DMARC (setup/status)
2045
2328
  keys show <p> Print the dotenvx private key for a project
2046
2329
  keys set <p> Upsert the key into the OS keychain (after \`dotenvx rotate\`)
2047
2330
  keys rotate <p> Rotate the dotenvx keypair, mirror to keychain + (optional) deploy targets
@@ -2056,6 +2339,7 @@ function printHelp(topic) {
2056
2339
  status --json StatusSnapshot as JSON
2057
2340
  doctor --json Per-provider health with fix hints as JSON
2058
2341
  inventory --json InventoryReport — resources found per provider + drift
2342
+ overview --json OverviewReport — fleet-level resource counts + names
2059
2343
  completion <shell> Print a zsh/bash/fish completion script
2060
2344
 
2061
2345
  ${chalk.bold("Options:")}