hatchkit 0.2.1 → 0.2.3

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 (57) hide show
  1. package/dist/adopt.js +1 -1
  2. package/dist/adopt.js.map +1 -1
  3. package/dist/assets/env.d.ts +2 -2
  4. package/dist/assets/env.d.ts.map +1 -1
  5. package/dist/assets/index.js +11 -11
  6. package/dist/assets/index.js.map +1 -1
  7. package/dist/assets/mirror.js +1 -1
  8. package/dist/completion.js +1 -1
  9. package/dist/completion.js.map +1 -1
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/config.js +39 -7
  12. package/dist/config.js.map +1 -1
  13. package/dist/deploy/terraform.d.ts +18 -0
  14. package/dist/deploy/terraform.d.ts.map +1 -1
  15. package/dist/deploy/terraform.js +52 -5
  16. package/dist/deploy/terraform.js.map +1 -1
  17. package/dist/dev-setup.d.ts +3 -1
  18. package/dist/dev-setup.d.ts.map +1 -1
  19. package/dist/dev-setup.js +104 -4
  20. package/dist/dev-setup.js.map +1 -1
  21. package/dist/doctor.d.ts.map +1 -1
  22. package/dist/doctor.js +4 -3
  23. package/dist/doctor.js.map +1 -1
  24. package/dist/explain.js +1 -1
  25. package/dist/explain.js.map +1 -1
  26. package/dist/index.js +265 -42
  27. package/dist/index.js.map +1 -1
  28. package/dist/provision/glitchtip.d.ts +1 -0
  29. package/dist/provision/glitchtip.d.ts.map +1 -1
  30. package/dist/provision/glitchtip.js +16 -0
  31. package/dist/provision/glitchtip.js.map +1 -1
  32. package/dist/provision/index.d.ts +6 -0
  33. package/dist/provision/index.d.ts.map +1 -1
  34. package/dist/provision/index.js +114 -9
  35. package/dist/provision/index.js.map +1 -1
  36. package/dist/provision/openpanel.d.ts +1 -0
  37. package/dist/provision/openpanel.d.ts.map +1 -1
  38. package/dist/provision/openpanel.js +27 -4
  39. package/dist/provision/openpanel.js.map +1 -1
  40. package/dist/provision/plausible.d.ts +10 -0
  41. package/dist/provision/plausible.d.ts.map +1 -1
  42. package/dist/provision/plausible.js +78 -17
  43. package/dist/provision/plausible.js.map +1 -1
  44. package/dist/provision/resend.d.ts +4 -0
  45. package/dist/provision/resend.d.ts.map +1 -1
  46. package/dist/provision/resend.js +11 -6
  47. package/dist/provision/resend.js.map +1 -1
  48. package/dist/scaffold/app.js +2 -2
  49. package/dist/scaffold/app.js.map +1 -1
  50. package/dist/scaffold/manifest.d.ts +14 -0
  51. package/dist/scaffold/manifest.d.ts.map +1 -1
  52. package/dist/scaffold/manifest.js.map +1 -1
  53. package/dist/scaffold/server-add.js +3 -1
  54. package/dist/scaffold/server-add.js.map +1 -1
  55. package/dist/scaffold/starter-files.d.ts +3 -3
  56. package/dist/scaffold/starter-files.js +3 -3
  57. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1,19 +1,20 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { confirm } from "@inquirer/prompts";
5
5
  import chalk from "chalk";
6
- import { ensureCoolify, ensureGitHub, ensureHetzner, ensureS3, getConfig, getConfigPath, getCoolifyConfig, getGhcrConfig, getMlServices, isFirstRun, reconfigureProvider, resetConfig, runOnboarding, } from "./config.js";
6
+ import { ensureCoolify, ensureDns, ensureGitHub, ensureHetzner, ensureS3, getConfig, getConfigPath, getCoolifyConfig, getGhcrConfig, getMlServices, isFirstRun, reconfigureProvider, resetConfig, runOnboarding, } from "./config.js";
7
7
  import { runCoolifySetup } from "./deploy/coolify.js";
8
8
  import { setupGitHub } from "./deploy/github.js";
9
9
  import { deployMlServices } from "./deploy/gpu.js";
10
10
  import { pushProjectKeyToCoolify, pushProjectKeyToGh, rotateProjectKey, setProjectKey, showProjectKey, } from "./deploy/keys.js";
11
11
  import { handleCreateFailure, runRollback } from "./deploy/rollback.js";
12
- import { runTerraform } from "./deploy/terraform.js";
12
+ import { requireCloudflareZoneForTerraform, runTerraform } from "./deploy/terraform.js";
13
13
  import { collectProjectConfig } from "./prompts.js";
14
- import { runProvision, runUnprovision } from "./provision/index.js";
14
+ import { runProvision, runUnprovision, } from "./provision/index.js";
15
15
  import { scaffoldApp } from "./scaffold/app.js";
16
16
  import { scaffoldInfra } from "./scaffold/infra.js";
17
+ import { readManifest } from "./scaffold/manifest.js";
17
18
  import { mlEnvVarName, printMlSummary, resolveMlServices } from "./scaffold/ml-client.js";
18
19
  import { runUpdate } from "./scaffold/update.js";
19
20
  import { installCancelHandler, isCancelInProgress, uninstallCancelHandler, } from "./utils/cancel-handler.js";
@@ -438,10 +439,10 @@ function opts(result) {
438
439
  * dir already lives inside the project (`packages/server`,
439
440
  * `apps/web`, etc.). Returns undefined when no manifest is found —
440
441
  * callers fall back to "skip s3" with a hint. */
441
- function inferProjectDir(serverEnvDir) {
442
- if (!serverEnvDir)
442
+ function inferProjectDir(startDir) {
443
+ if (!startDir)
443
444
  return undefined;
444
- let cur = serverEnvDir;
445
+ let cur = startDir;
445
446
  for (let i = 0; i < 4; i++) {
446
447
  if (existsSync(join(cur, ".hatchkit.json")))
447
448
  return cur;
@@ -452,15 +453,165 @@ function inferProjectDir(serverEnvDir) {
452
453
  }
453
454
  return undefined;
454
455
  }
456
+ function manifestBucketEntries(manifest) {
457
+ const buckets = manifest?.s3Buckets;
458
+ if (!buckets)
459
+ return [];
460
+ const out = [];
461
+ for (const [key, value] of Object.entries(buckets)) {
462
+ if (key === "tokenId" || key === "accountId")
463
+ continue;
464
+ if (value && typeof value === "object" && "name" in value) {
465
+ out.push(value);
466
+ }
467
+ }
468
+ return out;
469
+ }
470
+ function readIfExists(path) {
471
+ if (!existsSync(path))
472
+ return "";
473
+ try {
474
+ return readFileSync(path, "utf-8");
475
+ }
476
+ catch {
477
+ return "";
478
+ }
479
+ }
480
+ function readProjectEnvText(projectDir, baseName) {
481
+ const chunks = [];
482
+ if (projectDir) {
483
+ for (const dir of [
484
+ ".",
485
+ "packages/server",
486
+ "packages/client",
487
+ "packages/web",
488
+ "apps/server",
489
+ "apps/api",
490
+ "apps/web",
491
+ "apps/client",
492
+ "server",
493
+ "client",
494
+ "web",
495
+ ]) {
496
+ const abs = resolve(projectDir, dir);
497
+ chunks.push(readIfExists(join(abs, ".env.production")));
498
+ chunks.push(readIfExists(join(abs, ".env.development")));
499
+ }
500
+ }
501
+ if (baseName) {
502
+ const provisionedDir = join(dirname(getConfigPath()), "provisioned");
503
+ if (existsSync(provisionedDir)) {
504
+ for (const file of readdirSync(provisionedDir)) {
505
+ if (file.startsWith(`${baseName}.`) && file.endsWith(".env")) {
506
+ chunks.push(readIfExists(join(provisionedDir, file)));
507
+ }
508
+ }
509
+ }
510
+ }
511
+ return chunks.join("\n");
512
+ }
513
+ function servicesAlreadyAdded(args) {
514
+ const text = readProjectEnvText(args.projectDir, args.baseName);
515
+ const added = new Set();
516
+ if (/(^|\n)(PUBLIC_)?GLITCHTIP_DSN=/m.test(text))
517
+ added.add("glitchtip");
518
+ if (/(^|\n)(PUBLIC_)?OPENPANEL_CLIENT_ID=/m.test(text))
519
+ added.add("openpanel");
520
+ if (/(^|\n)(NEXT_PUBLIC_|PUBLIC_)?PLAUSIBLE_DOMAIN=/m.test(text))
521
+ added.add("plausible");
522
+ if (/(^|\n)RESEND_API_KEY=/m.test(text))
523
+ added.add("resend");
524
+ if (/(^|\n)R2(_[A-Z0-9]+)?_ACCESS_KEY_ID=/m.test(text))
525
+ added.add("s3");
526
+ if (manifestBucketEntries(args.manifest).some((bucket) => bucket.tokenId))
527
+ added.add("s3");
528
+ if (args.manifest?.integrations?.email)
529
+ added.add("email");
530
+ if (args.manifest?.integrations?.searchConsole)
531
+ added.add("search-console");
532
+ return added;
533
+ }
534
+ function servicesImpossibleForProject(manifest) {
535
+ const blocked = new Set();
536
+ if (!manifest)
537
+ return blocked;
538
+ if (!manifest.domain) {
539
+ blocked.add("email");
540
+ blocked.add("search-console");
541
+ }
542
+ if (manifest.surfaces === "server-only")
543
+ blocked.add("plausible");
544
+ if (manifest.surfaces === "client-only") {
545
+ blocked.add("resend");
546
+ blocked.add("s3");
547
+ }
548
+ if (manifestBucketEntries(manifest).length === 0)
549
+ blocked.add("s3");
550
+ return blocked;
551
+ }
552
+ function recordProvisionedEvent(ledger, event) {
553
+ if (event.service === "glitchtip")
554
+ ledger.record({ kind: "glitchtip", project: event.project });
555
+ if (event.service === "openpanel")
556
+ ledger.record({ kind: "openpanel", project: event.project });
557
+ if (event.service === "plausible" && event.created) {
558
+ ledger.record({ kind: "plausible", project: event.project });
559
+ }
560
+ if (event.service === "resend")
561
+ ledger.record({ kind: "resend", client: event.client });
562
+ if (event.service === "s3" && event.minted) {
563
+ ledger.record({
564
+ kind: "r2Token",
565
+ tokenId: event.tokenId,
566
+ accountId: event.accountId,
567
+ audience: "account",
568
+ });
569
+ }
570
+ if (event.service === "search-console" && event.dnsRecord?.created) {
571
+ ledger.record({
572
+ kind: "cloudflareDnsRecord",
573
+ zoneId: event.dnsRecord.zoneId,
574
+ recordId: event.dnsRecord.id,
575
+ name: event.dnsRecord.name,
576
+ type: event.dnsRecord.type,
577
+ });
578
+ }
579
+ if (event.service === "email") {
580
+ if (event.destinationCreatedThisRun) {
581
+ ledger.record({
582
+ kind: "cloudflareEmailDestination",
583
+ accountId: event.accountId,
584
+ destinationId: event.destinationId,
585
+ email: event.destinationEmail,
586
+ });
587
+ }
588
+ for (const dns of event.dnsRecords) {
589
+ ledger.record({
590
+ kind: "cloudflareDnsRecord",
591
+ zoneId: event.zoneId,
592
+ recordId: dns.id,
593
+ name: dns.name,
594
+ type: dns.type,
595
+ });
596
+ }
597
+ for (const rule of event.rules) {
598
+ if (!rule.created)
599
+ continue;
600
+ ledger.record({
601
+ kind: "cloudflareEmailRoutingRule",
602
+ zoneId: event.zoneId,
603
+ ruleId: rule.id,
604
+ address: rule.address,
605
+ });
606
+ }
607
+ }
608
+ }
455
609
  async function handleAdd() {
456
610
  // Positional args are optional — anything missing is prompted for.
457
611
  // hatchkit add (fully interactive)
458
612
  // hatchkit add raptor-runner (prompts for services)
459
613
  // hatchkit add raptor-runner all
460
614
  // hatchkit add raptor-runner glitchtip,resend
461
- const positional = args.slice(1).filter((a) => !a.startsWith("--"));
462
- let baseName = positional[0];
463
- const rawService = positional[1];
464
615
  const allServices = [
465
616
  "glitchtip",
466
617
  "openpanel",
@@ -470,6 +621,24 @@ async function handleAdd() {
470
621
  "email",
471
622
  "search-console",
472
623
  ];
624
+ const isServiceExpr = (value) => {
625
+ if (!value)
626
+ return false;
627
+ if (value === "all")
628
+ return true;
629
+ return value
630
+ .split(",")
631
+ .map((s) => s.trim().toLowerCase())
632
+ .every((s) => allServices.includes(s));
633
+ };
634
+ const positional = args.slice(1).filter((a) => !a.startsWith("--"));
635
+ const inferredProjectDir = inferProjectDir(process.cwd());
636
+ const inferredManifest = inferredProjectDir ? readManifest(inferredProjectDir) : null;
637
+ const firstArgIsService = isServiceExpr(positional[0]);
638
+ let baseName = firstArgIsService
639
+ ? inferredManifest?.name
640
+ : (positional[0] ?? inferredManifest?.name);
641
+ const rawService = firstArgIsService ? positional[0] : positional[1];
473
642
  if (!baseName) {
474
643
  const { input } = await import("@inquirer/prompts");
475
644
  const { validateProjectName } = await import("./utils/validate.js");
@@ -478,37 +647,54 @@ async function handleAdd() {
478
647
  validate: validateProjectName,
479
648
  });
480
649
  }
650
+ const alreadyAdded = servicesAlreadyAdded({
651
+ baseName,
652
+ projectDir: inferredProjectDir,
653
+ manifest: inferredManifest,
654
+ });
655
+ const impossible = servicesImpossibleForProject(inferredManifest);
656
+ const hiddenServices = new Set([...alreadyAdded, ...impossible]);
657
+ const addableServices = allServices.filter((service) => !hiddenServices.has(service));
481
658
  let services;
482
659
  if (!rawService) {
660
+ if (addableServices.length === 0) {
661
+ console.log(chalk.green(` Nothing to add — ${baseName} already has every supported service.`));
662
+ return;
663
+ }
483
664
  const { multiselect } = await import("./utils/multiselect.js");
665
+ const serviceChoices = [
666
+ { name: "GlitchTip (error tracking)", value: "glitchtip", checked: false },
667
+ { name: "OpenPanel (product analytics)", value: "openpanel", checked: false },
668
+ { name: "Plausible (web analytics)", value: "plausible", checked: false },
669
+ { name: "Resend (transactional email)", value: "resend", checked: false },
670
+ {
671
+ name: "S3 / R2 (per-bucket scoped credentials from .hatchkit.json)",
672
+ value: "s3",
673
+ checked: false,
674
+ },
675
+ {
676
+ name: "Email forwarding (Cloudflare Email Routing — MX/SPF/DMARC + rules)",
677
+ value: "email",
678
+ checked: false,
679
+ },
680
+ {
681
+ name: "Google Search Console (DNS verification + domain property)",
682
+ value: "search-console",
683
+ checked: false,
684
+ },
685
+ ];
484
686
  services = await multiselect({
485
687
  message: "Which services to add?",
486
- choices: [
487
- { name: "GlitchTip (error tracking)", value: "glitchtip", checked: true },
488
- { name: "OpenPanel (product analytics)", value: "openpanel", checked: true },
489
- { name: "Plausible (web analytics)", value: "plausible", checked: false },
490
- { name: "Resend (transactional email)", value: "resend", checked: true },
491
- {
492
- name: "S3 / R2 (per-bucket scoped credentials from .hatchkit.json)",
493
- value: "s3",
494
- checked: false,
495
- },
496
- {
497
- name: "Email forwarding (Cloudflare Email Routing — MX/SPF/DMARC + rules)",
498
- value: "email",
499
- checked: false,
500
- },
501
- {
502
- name: "Google Search Console (DNS verification + domain property)",
503
- value: "search-console",
504
- checked: false,
505
- },
506
- ],
688
+ choices: serviceChoices.filter((choice) => addableServices.includes(choice.value)),
507
689
  required: true,
508
690
  });
509
691
  }
510
692
  else if (rawService === "all") {
511
- services = allServices;
693
+ services = addableServices;
694
+ if (services.length === 0) {
695
+ console.log(chalk.green(` Nothing to add — ${baseName} already has every supported service.`));
696
+ return;
697
+ }
512
698
  }
513
699
  else {
514
700
  const requested = rawService.split(",").map((s) => s.trim().toLowerCase());
@@ -518,7 +704,17 @@ async function handleAdd() {
518
704
  console.log(chalk.dim(` Valid: ${allServices.join(", ")}, or 'all'`));
519
705
  process.exit(1);
520
706
  }
521
- services = requested;
707
+ const skipped = requested.filter((service) => hiddenServices.has(service));
708
+ if (skipped.length > 0) {
709
+ console.log(chalk.red(` Refusing to add already-present/unavailable service(s): ${skipped.join(", ")}`));
710
+ console.log(chalk.dim(" Run `hatchkit remove` first if you want Hatchkit to recreate them."));
711
+ process.exit(1);
712
+ }
713
+ services = requested.filter((service) => !hiddenServices.has(service));
714
+ if (services.length === 0) {
715
+ console.log(chalk.green(` Nothing to add — requested service(s) are already present or unavailable.`));
716
+ return;
717
+ }
522
718
  }
523
719
  // Flag parsing:
524
720
  // --no-write → never write; print a cache summary only
@@ -573,7 +769,16 @@ async function handleAdd() {
573
769
  : inferProjectDir(needsServer ? resolvePath(serverDirFlag) : undefined),
574
770
  };
575
771
  }
576
- await runProvision({ baseName, services, surfaces, enableDevObs });
772
+ const ledger = RunLedger.resumeOrStart(baseName);
773
+ await runProvision({
774
+ baseName,
775
+ services,
776
+ surfaces,
777
+ enableDevObs,
778
+ failIfExists: true,
779
+ onProvisioned: (event) => recordProvisionedEvent(ledger, event),
780
+ });
781
+ ledger.complete();
577
782
  }
578
783
  async function handleProvisionS3() {
579
784
  // `hatchkit provision s3` — create the public+private bucket pair
@@ -970,6 +1175,13 @@ async function handleCreate() {
970
1175
  config.installDeps = false;
971
1176
  if (forceNoLocalDev)
972
1177
  config.localDev = undefined;
1178
+ // Terraform's Cloudflare stacks can only write records into an
1179
+ // existing zone. Check that before project-specific mutations such as
1180
+ // scaffolding, provider clients, GitHub repos, or infra files.
1181
+ if (config.deploymentMode === "coolify" && config.runDeployment && !config.dryRun) {
1182
+ const dns = await ensureDns();
1183
+ await requireCloudflareZoneForTerraform(config.baseDomain, dns);
1184
+ }
973
1185
  // Ensure needed providers are configured (lazy prompting).
974
1186
  // Coolify + Hetzner only matter for the coolify deployment mode.
975
1187
  // gh-pages skips them entirely (no server, no Docker registry).
@@ -1114,7 +1326,7 @@ async function handleCreate() {
1114
1326
  else if (event.service === "openpanel") {
1115
1327
  ledger?.record({ kind: "openpanel", project: event.project });
1116
1328
  }
1117
- else if (event.service === "plausible") {
1329
+ else if (event.service === "plausible" && event.created) {
1118
1330
  ledger?.record({ kind: "plausible", project: event.project });
1119
1331
  }
1120
1332
  },
@@ -2021,9 +2233,13 @@ function printHelp(topic) {
2021
2233
  without the fragment).
2022
2234
 
2023
2235
  ${chalk.bold("DNS:")}
2024
- ${chalk.cyan("dev-setup init")} auto-upserts a DNS-only A record:
2236
+ ${chalk.cyan("dev-setup init")} auto-upserts a DNS-only A record on a
2237
+ dedicated ${chalk.cyan("local.")} subdomain:
2025
2238
  *.local.<your-domain> A <your-tailnet-ip> (DNS-only)
2026
2239
 
2240
+ Custom ${chalk.cyan("--domain")} values must use that ${chalk.cyan("local.")} prefix so
2241
+ Hatchkit never overwrites a production wildcard such as *.example.com.
2242
+
2027
2243
  If Cloudflare credentials are unavailable, add that record manually.
2028
2244
 
2029
2245
  This feature is fully optional: until you run ${chalk.cyan("dev-setup init")},
@@ -2138,6 +2354,7 @@ function printHelp(topic) {
2138
2354
 
2139
2355
  ${chalk.bold("Usage:")}
2140
2356
  hatchkit add [<project-name>] [<services>] [flags]
2357
+ hatchkit add [<services>] [flags] ${chalk.dim("(inside a project with .hatchkit.json)")}
2141
2358
 
2142
2359
  ${chalk.bold("What it does:")}
2143
2360
  · GlitchTip / OpenPanel: ${chalk.bold("one project per product")}, events tagged by
@@ -2155,6 +2372,11 @@ function printHelp(topic) {
2155
2372
  · A 0600 cache of every value is saved under
2156
2373
  ${chalk.dim("<config-dir>/provisioned/<project>.*.env")} for recoverability.
2157
2374
  ${chalk.dim("Secret values never hit stdout.")}
2375
+ · The interactive menu only shows services not already present for the
2376
+ current project, starts with nothing selected, and refuses explicit
2377
+ requests that would recreate known resources.
2378
+ · Before creating provider resources, add runs read-only existence probes
2379
+ for the selected services and stops on conflicts so cleanup stays safe.
2158
2380
 
2159
2381
  ${chalk.bold("Surfaces:")}
2160
2382
  hatchkit asks which surfaces your project has. Options:
@@ -2190,6 +2412,7 @@ function printHelp(topic) {
2190
2412
 
2191
2413
  ${chalk.bold("Examples:")}
2192
2414
  hatchkit add
2415
+ hatchkit add search-console
2193
2416
  hatchkit add raptor-runner
2194
2417
  hatchkit add raptor-runner all --enable-dev-obs
2195
2418
  hatchkit add raptor-runner glitchtip,resend --no-write
@@ -2513,13 +2736,13 @@ function printHelp(topic) {
2513
2736
  }
2514
2737
  if (topic === "assets") {
2515
2738
  console.log(`
2516
- ${chalk.bold("hatchkit assets")} — move bytes between local MinIO and prod buckets
2739
+ ${chalk.bold("hatchkit assets")} — move bytes between local S3 and prod buckets
2517
2740
 
2518
2741
  ${chalk.bold("Subcommands:")}
2519
- assets seed [--from <dir>] Local dir → local MinIO bucket.
2742
+ assets seed [--from <dir>] Local dir → local S3 bucket.
2520
2743
  Defaults to ./seed/assets.
2521
- assets push [--bucket assets|state] Local MinIO → prod bucket.
2522
- assets pull [--bucket assets|state] Prod bucket → local MinIO.
2744
+ assets push [--bucket assets|state] Local S3 → prod bucket.
2745
+ assets pull [--bucket assets|state] Prod bucket → local S3.
2523
2746
  Caution: prod data may include PII.
2524
2747
  assets migrate --from-endpoint=URL External S3 → prod bucket.
2525
2748
  --from-bucket=NAME The adoption escape hatch — copy
@@ -2546,7 +2769,7 @@ function printHelp(topic) {
2546
2769
  the env doesn't carry them (R2's URL-driven assets bucket).
2547
2770
 
2548
2771
  ${chalk.bold("Examples:")}
2549
- hatchkit assets seed # ./seed/assets/ → local MinIO
2772
+ hatchkit assets seed # ./seed/assets/ → local S3
2550
2773
  hatchkit assets push --dry-run # see what would ship to prod
2551
2774
  hatchkit assets push # actually ship it
2552
2775
  hatchkit assets migrate --from-endpoint https://nyc3.digitaloceanspaces.com \\
@@ -2587,7 +2810,7 @@ function printHelp(topic) {
2587
2810
  update Add features to an already-scaffolded project (run in project dir)
2588
2811
  server add Retrofit a server into a client-only project
2589
2812
  add Create GlitchTip / OpenPanel / Plausible / Resend clients for an existing project
2590
- assets Move bytes between local MinIO and prod buckets (seed/push/pull/migrate)
2813
+ assets Move bytes between local S3 and prod buckets (seed/push/pull/migrate)
2591
2814
  remove Delete the -dev/-prod clients created by 'add' (inverse of add)
2592
2815
  destroy Roll back everything ${chalk.cyan("hatchkit create")} did for a project
2593
2816
  rename-domain Move a scaffolded project to a new domain (rewrites tfvars/env/manifest)