hatchkit 0.1.47 → 0.2.2

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 (131) hide show
  1. package/dist/adopt.d.ts +61 -1
  2. package/dist/adopt.d.ts.map +1 -1
  3. package/dist/adopt.js +90 -86
  4. package/dist/adopt.js.map +1 -1
  5. package/dist/assets/env.d.ts +2 -2
  6. package/dist/assets/env.d.ts.map +1 -1
  7. package/dist/assets/index.js +11 -11
  8. package/dist/assets/index.js.map +1 -1
  9. package/dist/assets/mirror.js +1 -1
  10. package/dist/completion.d.ts.map +1 -1
  11. package/dist/completion.js +20 -2
  12. package/dist/completion.js.map +1 -1
  13. package/dist/config.d.ts +32 -1
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +364 -1
  16. package/dist/config.js.map +1 -1
  17. package/dist/deploy/coolify.d.ts +5 -0
  18. package/dist/deploy/coolify.d.ts.map +1 -1
  19. package/dist/deploy/coolify.js +67 -4
  20. package/dist/deploy/coolify.js.map +1 -1
  21. package/dist/deploy/ghcr.d.ts +1 -0
  22. package/dist/deploy/ghcr.d.ts.map +1 -1
  23. package/dist/deploy/ghcr.js +2 -2
  24. package/dist/deploy/ghcr.js.map +1 -1
  25. package/dist/deploy/github.d.ts.map +1 -1
  26. package/dist/deploy/github.js +3 -2
  27. package/dist/deploy/github.js.map +1 -1
  28. package/dist/deploy/rollback.d.ts.map +1 -1
  29. package/dist/deploy/rollback.js +9 -0
  30. package/dist/deploy/rollback.js.map +1 -1
  31. package/dist/dev-setup.d.ts +13 -5
  32. package/dist/dev-setup.d.ts.map +1 -1
  33. package/dist/dev-setup.js +268 -59
  34. package/dist/dev-setup.js.map +1 -1
  35. package/dist/doctor.d.ts.map +1 -1
  36. package/dist/doctor.js +65 -1
  37. package/dist/doctor.js.map +1 -1
  38. package/dist/email/index.js +5 -5
  39. package/dist/email/index.js.map +1 -1
  40. package/dist/email/setup.d.ts +1 -1
  41. package/dist/email/setup.d.ts.map +1 -1
  42. package/dist/email/setup.js +3 -3
  43. package/dist/email/setup.js.map +1 -1
  44. package/dist/explain.d.ts.map +1 -1
  45. package/dist/explain.js +9 -8
  46. package/dist/explain.js.map +1 -1
  47. package/dist/index.js +523 -91
  48. package/dist/index.js.map +1 -1
  49. package/dist/inventory.d.ts +1 -0
  50. package/dist/inventory.d.ts.map +1 -1
  51. package/dist/inventory.js +2 -0
  52. package/dist/inventory.js.map +1 -1
  53. package/dist/onboarding/plan.d.ts +54 -0
  54. package/dist/onboarding/plan.d.ts.map +1 -0
  55. package/dist/onboarding/plan.js +143 -0
  56. package/dist/onboarding/plan.js.map +1 -0
  57. package/dist/onboarding/review.d.ts +27 -0
  58. package/dist/onboarding/review.d.ts.map +1 -0
  59. package/dist/onboarding/review.js +55 -0
  60. package/dist/onboarding/review.js.map +1 -0
  61. package/dist/prompts.d.ts +13 -0
  62. package/dist/prompts.d.ts.map +1 -1
  63. package/dist/prompts.js +107 -89
  64. package/dist/prompts.js.map +1 -1
  65. package/dist/provision/glitchtip.d.ts +1 -0
  66. package/dist/provision/glitchtip.d.ts.map +1 -1
  67. package/dist/provision/glitchtip.js +16 -0
  68. package/dist/provision/glitchtip.js.map +1 -1
  69. package/dist/provision/index.d.ts +26 -3
  70. package/dist/provision/index.d.ts.map +1 -1
  71. package/dist/provision/index.js +215 -11
  72. package/dist/provision/index.js.map +1 -1
  73. package/dist/provision/openpanel.d.ts +1 -0
  74. package/dist/provision/openpanel.d.ts.map +1 -1
  75. package/dist/provision/openpanel.js +21 -0
  76. package/dist/provision/openpanel.js.map +1 -1
  77. package/dist/provision/plausible.d.ts +11 -0
  78. package/dist/provision/plausible.d.ts.map +1 -0
  79. package/dist/provision/plausible.js +108 -0
  80. package/dist/provision/plausible.js.map +1 -0
  81. package/dist/provision/resend.d.ts +4 -0
  82. package/dist/provision/resend.d.ts.map +1 -1
  83. package/dist/provision/resend.js +11 -6
  84. package/dist/provision/resend.js.map +1 -1
  85. package/dist/provision/search-console.d.ts +17 -0
  86. package/dist/provision/search-console.d.ts.map +1 -0
  87. package/dist/provision/search-console.js +142 -0
  88. package/dist/provision/search-console.js.map +1 -0
  89. package/dist/scaffold/app.d.ts +1 -0
  90. package/dist/scaffold/app.d.ts.map +1 -1
  91. package/dist/scaffold/app.js +6 -3
  92. package/dist/scaffold/app.js.map +1 -1
  93. package/dist/scaffold/infra.js +2 -0
  94. package/dist/scaffold/infra.js.map +1 -1
  95. package/dist/scaffold/manifest.d.ts +18 -2
  96. package/dist/scaffold/manifest.d.ts.map +1 -1
  97. package/dist/scaffold/manifest.js +7 -1
  98. package/dist/scaffold/manifest.js.map +1 -1
  99. package/dist/scaffold/server-add.d.ts +21 -0
  100. package/dist/scaffold/server-add.d.ts.map +1 -0
  101. package/dist/scaffold/server-add.js +275 -0
  102. package/dist/scaffold/server-add.js.map +1 -0
  103. package/dist/scaffold/starter-files.d.ts +3 -3
  104. package/dist/scaffold/starter-files.js +3 -3
  105. package/dist/scaffold/update.d.ts +1 -0
  106. package/dist/scaffold/update.d.ts.map +1 -1
  107. package/dist/scaffold/update.js +8 -5
  108. package/dist/scaffold/update.js.map +1 -1
  109. package/dist/status.d.ts.map +1 -1
  110. package/dist/status.js +27 -1
  111. package/dist/status.js.map +1 -1
  112. package/dist/templates/base/env.example.hbs +3 -0
  113. package/dist/utils/cloudflare-api.d.ts +5 -0
  114. package/dist/utils/cloudflare-api.d.ts.map +1 -1
  115. package/dist/utils/cloudflare-api.js +19 -0
  116. package/dist/utils/cloudflare-api.js.map +1 -1
  117. package/dist/utils/coolify-api.d.ts +3 -2
  118. package/dist/utils/coolify-api.d.ts.map +1 -1
  119. package/dist/utils/coolify-api.js +19 -5
  120. package/dist/utils/coolify-api.js.map +1 -1
  121. package/dist/utils/flags.d.ts.map +1 -1
  122. package/dist/utils/flags.js +16 -0
  123. package/dist/utils/flags.js.map +1 -1
  124. package/dist/utils/run-ledger.d.ts +3 -0
  125. package/dist/utils/run-ledger.d.ts.map +1 -1
  126. package/dist/utils/run-ledger.js.map +1 -1
  127. package/dist/utils/secrets.d.ts +5 -0
  128. package/dist/utils/secrets.d.ts.map +1 -1
  129. package/dist/utils/secrets.js +5 -0
  130. package/dist/utils/secrets.js.map +1 -1
  131. package/package.json +24 -3
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
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, getMlServices, isFirstRun, reconfigureProvider, resetConfig, runOnboarding, } from "./config.js";
6
+ import { ensureCoolify, 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";
@@ -11,9 +11,10 @@ import { pushProjectKeyToCoolify, pushProjectKeyToGh, rotateProjectKey, setProje
11
11
  import { handleCreateFailure, runRollback } from "./deploy/rollback.js";
12
12
  import { 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";
@@ -108,6 +109,11 @@ async function main() {
108
109
  return printHelp("update");
109
110
  await handleUpdate();
110
111
  break;
112
+ case "server":
113
+ if (args.includes("--help") && args.length === 2)
114
+ return printHelp("server");
115
+ await handleServer();
116
+ break;
111
117
  case "keys":
112
118
  if (args.includes("--help") && args.length === 2)
113
119
  return printHelp("keys");
@@ -433,10 +439,10 @@ function opts(result) {
433
439
  * dir already lives inside the project (`packages/server`,
434
440
  * `apps/web`, etc.). Returns undefined when no manifest is found —
435
441
  * callers fall back to "skip s3" with a hint. */
436
- function inferProjectDir(serverEnvDir) {
437
- if (!serverEnvDir)
442
+ function inferProjectDir(startDir) {
443
+ if (!startDir)
438
444
  return undefined;
439
- let cur = serverEnvDir;
445
+ let cur = startDir;
440
446
  for (let i = 0; i < 4; i++) {
441
447
  if (existsSync(join(cur, ".hatchkit.json")))
442
448
  return cur;
@@ -447,16 +453,191 @@ function inferProjectDir(serverEnvDir) {
447
453
  }
448
454
  return undefined;
449
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")
558
+ ledger.record({ kind: "plausible", project: event.project });
559
+ if (event.service === "resend")
560
+ ledger.record({ kind: "resend", client: event.client });
561
+ if (event.service === "s3" && event.minted) {
562
+ ledger.record({
563
+ kind: "r2Token",
564
+ tokenId: event.tokenId,
565
+ accountId: event.accountId,
566
+ audience: "account",
567
+ });
568
+ }
569
+ if (event.service === "search-console" && event.dnsRecord?.created) {
570
+ ledger.record({
571
+ kind: "cloudflareDnsRecord",
572
+ zoneId: event.dnsRecord.zoneId,
573
+ recordId: event.dnsRecord.id,
574
+ name: event.dnsRecord.name,
575
+ type: event.dnsRecord.type,
576
+ });
577
+ }
578
+ if (event.service === "email") {
579
+ if (event.destinationCreatedThisRun) {
580
+ ledger.record({
581
+ kind: "cloudflareEmailDestination",
582
+ accountId: event.accountId,
583
+ destinationId: event.destinationId,
584
+ email: event.destinationEmail,
585
+ });
586
+ }
587
+ for (const dns of event.dnsRecords) {
588
+ ledger.record({
589
+ kind: "cloudflareDnsRecord",
590
+ zoneId: event.zoneId,
591
+ recordId: dns.id,
592
+ name: dns.name,
593
+ type: dns.type,
594
+ });
595
+ }
596
+ for (const rule of event.rules) {
597
+ if (!rule.created)
598
+ continue;
599
+ ledger.record({
600
+ kind: "cloudflareEmailRoutingRule",
601
+ zoneId: event.zoneId,
602
+ ruleId: rule.id,
603
+ address: rule.address,
604
+ });
605
+ }
606
+ }
607
+ }
450
608
  async function handleAdd() {
451
609
  // Positional args are optional — anything missing is prompted for.
452
610
  // hatchkit add (fully interactive)
453
611
  // hatchkit add raptor-runner (prompts for services)
454
612
  // hatchkit add raptor-runner all
455
613
  // hatchkit add raptor-runner glitchtip,resend
614
+ const allServices = [
615
+ "glitchtip",
616
+ "openpanel",
617
+ "plausible",
618
+ "resend",
619
+ "s3",
620
+ "email",
621
+ "search-console",
622
+ ];
623
+ const isServiceExpr = (value) => {
624
+ if (!value)
625
+ return false;
626
+ if (value === "all")
627
+ return true;
628
+ return value
629
+ .split(",")
630
+ .map((s) => s.trim().toLowerCase())
631
+ .every((s) => allServices.includes(s));
632
+ };
456
633
  const positional = args.slice(1).filter((a) => !a.startsWith("--"));
457
- let baseName = positional[0];
458
- const rawService = positional[1];
459
- const allServices = ["glitchtip", "openpanel", "resend", "s3", "email"];
634
+ const inferredProjectDir = inferProjectDir(process.cwd());
635
+ const inferredManifest = inferredProjectDir ? readManifest(inferredProjectDir) : null;
636
+ const firstArgIsService = isServiceExpr(positional[0]);
637
+ let baseName = firstArgIsService
638
+ ? inferredManifest?.name
639
+ : (positional[0] ?? inferredManifest?.name);
640
+ const rawService = firstArgIsService ? positional[0] : positional[1];
460
641
  if (!baseName) {
461
642
  const { input } = await import("@inquirer/prompts");
462
643
  const { validateProjectName } = await import("./utils/validate.js");
@@ -465,31 +646,54 @@ async function handleAdd() {
465
646
  validate: validateProjectName,
466
647
  });
467
648
  }
649
+ const alreadyAdded = servicesAlreadyAdded({
650
+ baseName,
651
+ projectDir: inferredProjectDir,
652
+ manifest: inferredManifest,
653
+ });
654
+ const impossible = servicesImpossibleForProject(inferredManifest);
655
+ const hiddenServices = new Set([...alreadyAdded, ...impossible]);
656
+ const addableServices = allServices.filter((service) => !hiddenServices.has(service));
468
657
  let services;
469
658
  if (!rawService) {
659
+ if (addableServices.length === 0) {
660
+ console.log(chalk.green(` Nothing to add — ${baseName} already has every supported service.`));
661
+ return;
662
+ }
470
663
  const { multiselect } = await import("./utils/multiselect.js");
664
+ const serviceChoices = [
665
+ { name: "GlitchTip (error tracking)", value: "glitchtip", checked: false },
666
+ { name: "OpenPanel (product analytics)", value: "openpanel", checked: false },
667
+ { name: "Plausible (web analytics)", value: "plausible", checked: false },
668
+ { name: "Resend (transactional email)", value: "resend", checked: false },
669
+ {
670
+ name: "S3 / R2 (per-bucket scoped credentials from .hatchkit.json)",
671
+ value: "s3",
672
+ checked: false,
673
+ },
674
+ {
675
+ name: "Email forwarding (Cloudflare Email Routing — MX/SPF/DMARC + rules)",
676
+ value: "email",
677
+ checked: false,
678
+ },
679
+ {
680
+ name: "Google Search Console (DNS verification + domain property)",
681
+ value: "search-console",
682
+ checked: false,
683
+ },
684
+ ];
471
685
  services = await multiselect({
472
686
  message: "Which services to add?",
473
- choices: [
474
- { name: "GlitchTip (error tracking)", value: "glitchtip", checked: true },
475
- { name: "OpenPanel (product analytics)", value: "openpanel", checked: true },
476
- { name: "Resend (transactional email)", value: "resend", checked: true },
477
- {
478
- name: "S3 / R2 (per-bucket scoped credentials from .hatchkit.json)",
479
- value: "s3",
480
- checked: false,
481
- },
482
- {
483
- name: "Email forwarding (Cloudflare Email Routing — MX/SPF/DMARC + rules)",
484
- value: "email",
485
- checked: false,
486
- },
487
- ],
687
+ choices: serviceChoices.filter((choice) => addableServices.includes(choice.value)),
488
688
  required: true,
489
689
  });
490
690
  }
491
691
  else if (rawService === "all") {
492
- services = allServices;
692
+ services = addableServices;
693
+ if (services.length === 0) {
694
+ console.log(chalk.green(` Nothing to add — ${baseName} already has every supported service.`));
695
+ return;
696
+ }
493
697
  }
494
698
  else {
495
699
  const requested = rawService.split(",").map((s) => s.trim().toLowerCase());
@@ -499,11 +703,21 @@ async function handleAdd() {
499
703
  console.log(chalk.dim(` Valid: ${allServices.join(", ")}, or 'all'`));
500
704
  process.exit(1);
501
705
  }
502
- services = requested;
706
+ const skipped = requested.filter((service) => hiddenServices.has(service));
707
+ if (skipped.length > 0) {
708
+ console.log(chalk.red(` Refusing to add already-present/unavailable service(s): ${skipped.join(", ")}`));
709
+ console.log(chalk.dim(" Run `hatchkit remove` first if you want Hatchkit to recreate them."));
710
+ process.exit(1);
711
+ }
712
+ services = requested.filter((service) => !hiddenServices.has(service));
713
+ if (services.length === 0) {
714
+ console.log(chalk.green(` Nothing to add — requested service(s) are already present or unavailable.`));
715
+ return;
716
+ }
503
717
  }
504
718
  // Flag parsing:
505
719
  // --no-write → never write; print a cache summary only
506
- // --enable-dev-obs → also populate .env.development with GlitchTip/OpenPanel creds
720
+ // --enable-dev-obs → also populate .env.development with observability creds
507
721
  // --surfaces=<shared|separate|server-only|client-only>
508
722
  // --server-dir <path> → absolute or project-relative env dir for the server
509
723
  // --client-dir <path> → same for the client
@@ -554,7 +768,16 @@ async function handleAdd() {
554
768
  : inferProjectDir(needsServer ? resolvePath(serverDirFlag) : undefined),
555
769
  };
556
770
  }
557
- await runProvision({ baseName, services, surfaces, enableDevObs });
771
+ const ledger = RunLedger.resumeOrStart(baseName);
772
+ await runProvision({
773
+ baseName,
774
+ services,
775
+ surfaces,
776
+ enableDevObs,
777
+ failIfExists: true,
778
+ onProvisioned: (event) => recordProvisionedEvent(ledger, event),
779
+ });
780
+ ledger.complete();
558
781
  }
559
782
  async function handleProvisionS3() {
560
783
  // `hatchkit provision s3` — create the public+private bucket pair
@@ -783,7 +1006,15 @@ async function handleRemove() {
783
1006
  const skipConfirm = args.includes("--yes") || args.includes("-y");
784
1007
  let baseName = positional[0];
785
1008
  const rawService = positional[1];
786
- const allServices = ["glitchtip", "openpanel", "resend", "s3", "email"];
1009
+ const allServices = [
1010
+ "glitchtip",
1011
+ "openpanel",
1012
+ "plausible",
1013
+ "resend",
1014
+ "s3",
1015
+ "email",
1016
+ "search-console",
1017
+ ];
787
1018
  if (!baseName) {
788
1019
  const { input } = await import("@inquirer/prompts");
789
1020
  const { validateProjectName } = await import("./utils/validate.js");
@@ -800,6 +1031,7 @@ async function handleRemove() {
800
1031
  choices: [
801
1032
  { name: "GlitchTip (deletes the project)", value: "glitchtip", checked: true },
802
1033
  { name: "OpenPanel (deletes the project)", value: "openpanel", checked: true },
1034
+ { name: "Plausible (deletes the site)", value: "plausible", checked: false },
803
1035
  { name: "Resend (deletes the API key)", value: "resend", checked: true },
804
1036
  { name: "S3 / R2 (deletes per-bucket scoped tokens)", value: "s3", checked: false },
805
1037
  {
@@ -807,6 +1039,11 @@ async function handleRemove() {
807
1039
  value: "email",
808
1040
  checked: false,
809
1041
  },
1042
+ {
1043
+ name: "Google Search Console (removes property; keeps verification token)",
1044
+ value: "search-console",
1045
+ checked: false,
1046
+ },
810
1047
  ],
811
1048
  required: true,
812
1049
  });
@@ -841,7 +1078,7 @@ async function handleRemove() {
841
1078
  // project directory if it exists; the s3 unprovision falls back to
842
1079
  // a keychain sweep when the manifest can't be found.
843
1080
  let projectDir;
844
- if (services.includes("s3")) {
1081
+ if (services.includes("s3") || services.includes("search-console")) {
845
1082
  const guess = resolve(baseName);
846
1083
  if (existsSync(join(guess, ".hatchkit.json"))) {
847
1084
  projectDir = guess;
@@ -867,6 +1104,52 @@ async function handleDns() {
867
1104
  printHelp("dns");
868
1105
  }
869
1106
  }
1107
+ async function configureGhcrForCreate(repoUrl, isPrivateRepo, ledger) {
1108
+ const { repoSlugFromRemote } = await import("./deploy/gh-actions-secrets.js");
1109
+ const slug = repoSlugFromRemote(repoUrl);
1110
+ if (!slug) {
1111
+ console.log(chalk.dim(" · Couldn't resolve owner/repo from GitHub URL — skipping GHCR pull setup."));
1112
+ return;
1113
+ }
1114
+ const coolify = await getCoolifyConfig();
1115
+ if (!coolify) {
1116
+ console.log(chalk.dim(" · Coolify not configured — skipping GHCR pull setup."));
1117
+ return;
1118
+ }
1119
+ const { CoolifyApi } = await import("./utils/coolify-api.js");
1120
+ const { makeGhcrPackagePublic, registerGhcrCredsWithCoolify } = await import("./deploy/ghcr.js");
1121
+ if (!isPrivateRepo) {
1122
+ const result = await makeGhcrPackagePublic({ repoSlug: slug });
1123
+ if (result.kind === "public-set")
1124
+ return;
1125
+ if (result.kind === "skipped" || result.kind === "failed") {
1126
+ console.log(chalk.yellow(` GHCR public-image setup skipped: ${result.reason}`));
1127
+ console.log(chalk.dim(result.recovery.map((line) => ` ${line}`).join("\n")));
1128
+ }
1129
+ return;
1130
+ }
1131
+ const ghcrConfig = await getGhcrConfig();
1132
+ const api = new CoolifyApi({ url: coolify.url, token: coolify.token });
1133
+ const result = await registerGhcrCredsWithCoolify({
1134
+ api,
1135
+ repoSlug: slug,
1136
+ pullToken: ghcrConfig?.pullToken,
1137
+ username: ghcrConfig?.username,
1138
+ });
1139
+ if (result.kind === "private-registered") {
1140
+ if (result.created) {
1141
+ ledger?.record({ kind: "coolifyPrivateRegistry", uuid: result.registryUuid });
1142
+ }
1143
+ return;
1144
+ }
1145
+ if (result.kind === "skipped" || result.kind === "failed") {
1146
+ console.log(chalk.yellow(` GHCR private-image pull setup skipped: ${result.reason}`));
1147
+ console.log(chalk.dim(result.recovery.map((line) => ` ${line}`).join("\n")));
1148
+ }
1149
+ }
1150
+ function isCreatedGithubRepoPrivate(config) {
1151
+ return config.createGithubRepo && (config.githubRepoVisibility ?? "private") === "private";
1152
+ }
870
1153
  // ---------------------------------------------------------------------------
871
1154
  // Commands
872
1155
  // ---------------------------------------------------------------------------
@@ -875,20 +1158,22 @@ async function handleCreate() {
875
1158
  // the flow non-interactive; otherwise we still prompt for anything
876
1159
  // not supplied via flags / config file.
877
1160
  const flags = parseCreateFlags(args);
878
- const { yes: nonInteractive, dryRun, presets, forceNoGithub, forceNoDeploy, forceNoInstall, } = flags;
1161
+ const { yes: nonInteractive, dryRun, presets, forceNoGithub, forceNoDeploy, forceNoInstall, forceNoLocalDev, } = flags;
879
1162
  // Check if first run (skip onboarding when non-interactive — the
880
1163
  // onboarding prompts would stall automation).
881
1164
  if (!nonInteractive && (await isFirstRun())) {
882
1165
  await runOnboarding();
883
1166
  }
884
1167
  // Collect project config via interactive prompts (or presets).
885
- const config = await collectProjectConfig({ dryRun, presets, nonInteractive });
1168
+ const config = await collectProjectConfig({ dryRun, presets, nonInteractive, forceNoLocalDev });
886
1169
  if (forceNoGithub)
887
1170
  config.createGithubRepo = false;
888
1171
  if (forceNoDeploy)
889
1172
  config.runDeployment = false;
890
1173
  if (forceNoInstall)
891
1174
  config.installDeps = false;
1175
+ if (forceNoLocalDev)
1176
+ config.localDev = undefined;
892
1177
  // Ensure needed providers are configured (lazy prompting).
893
1178
  // Coolify + Hetzner only matter for the coolify deployment mode.
894
1179
  // gh-pages skips them entirely (no server, no Docker registry).
@@ -917,13 +1202,19 @@ async function handleCreate() {
917
1202
  await ensureS3(config.s3Provider);
918
1203
  }
919
1204
  }
920
- // Pre-flight observability + email + Stripe providers used by `hatchkit
921
- // create` directly (not just `add`): if the user picked the analytics
922
- // feature, GlitchTip needs to be configured before we can mint a DSN
923
- // for them. Same for Stripe webhook auto-provisioning.
1205
+ // Pre-flight observability + Stripe providers used by `hatchkit
1206
+ // create` directly (not just `add`): if the user picked analytics
1207
+ // providers, make sure they are configured before we can mint
1208
+ // project-scoped resources. Same for Stripe webhook auto-provisioning.
924
1209
  if (config.features.includes("analytics")) {
925
- const { ensureGlitchtip } = await import("./config.js");
926
- await ensureGlitchtip();
1210
+ const providers = config.analyticsProviders ?? ["glitchtip"];
1211
+ const { ensureGlitchtip, ensureOpenpanel, ensurePlausible } = await import("./config.js");
1212
+ if (providers.includes("glitchtip"))
1213
+ await ensureGlitchtip();
1214
+ if (providers.includes("openpanel"))
1215
+ await ensureOpenpanel();
1216
+ if (providers.includes("plausible"))
1217
+ await ensurePlausible();
927
1218
  }
928
1219
  if (config.features.includes("stripe")) {
929
1220
  const { ensureStripe } = await import("./config.js");
@@ -954,7 +1245,7 @@ async function handleCreate() {
954
1245
  console.log(` Features: ${config.features.length > 0 ? config.features.join(", ") : "none"}`);
955
1246
  console.log(` ML: ${config.mlServices.length > 0 ? config.mlServices.join(", ") : "none"}`);
956
1247
  console.log(` Scaffold: ${config.scaffoldRepo ? "yes" : "no"}`);
957
- console.log(` GitHub: ${config.createGithubRepo ? "yes" : "no"}`);
1248
+ console.log(` GitHub: ${config.createGithubRepo ? `yes (${config.githubRepoVisibility ?? "private"})` : "no"}`);
958
1249
  console.log(` Install: ${config.installDeps ? "yes (pnpm install)" : "no"}`);
959
1250
  console.log(` Deploy now: ${config.runDeployment ? "yes" : "no"}`);
960
1251
  if (config.dryRun) {
@@ -996,28 +1287,47 @@ async function handleCreate() {
996
1287
  const { printDotenvxSummary } = await import("./scaffold/dotenvx.js");
997
1288
  printDotenvxSummary(scaffoldResult.dotenvx, config.name);
998
1289
  }
999
- // Auto-provision GlitchTip + write its DSN encrypted into
1000
- // .env.production. The user picked the `analytics` feature; we
1001
- // already verified GlitchTip is configured during pre-flight.
1002
- // Skipped for client-only — the encrypt target lives in
1003
- // packages/server/, which doesn't exist post-prune. The client
1004
- // side of analytics (OpenPanel via NEXT_PUBLIC_*) still works
1005
- // without any provisioning.
1006
- if (config.features.includes("analytics") && config.surfaces !== "client-only") {
1290
+ // Auto-provision selected observability/analytics providers
1291
+ // through the same machinery used by `hatchkit add`, so create,
1292
+ // adopt, and existing-project provisioning stay aligned.
1293
+ if (config.features.includes("analytics")) {
1294
+ const analyticsServices = [
1295
+ ...(config.analyticsProviders ?? ["glitchtip"]),
1296
+ ];
1007
1297
  try {
1008
- const { provisionGlitchtipClient } = await import("./provision/glitchtip.js");
1009
- const { set: dotenvxSet } = await import("@dotenvx/dotenvx");
1010
- const ora = (await import("ora")).default;
1011
- const spinner = ora(`GlitchTip: creating project ${config.name}`).start();
1012
- const res = await provisionGlitchtipClient(config.name);
1013
- ledger?.record({ kind: "glitchtip", project: config.name });
1014
- spinner.succeed(`GlitchTip project ready (DSN encrypted into .env.production)`);
1015
- const prodEnvPath = join(appDir, "packages/server/.env.production");
1016
- dotenvxSet("GLITCHTIP_DSN", res.dsn, { path: prodEnvPath, encrypt: true });
1298
+ if (analyticsServices.length > 0) {
1299
+ const provisionMode = config.surfaces === "both"
1300
+ ? "shared"
1301
+ : config.surfaces === "server-only"
1302
+ ? "server-only"
1303
+ : "client-only";
1304
+ await runProvision({
1305
+ baseName: config.name,
1306
+ services: analyticsServices,
1307
+ domain: config.domain,
1308
+ surfaces: {
1309
+ mode: provisionMode,
1310
+ projectDir: appDir,
1311
+ serverEnvDir: config.surfaces === "client-only" ? undefined : join(appDir, "packages/server"),
1312
+ clientEnvDir: config.surfaces === "server-only" ? undefined : join(appDir, "packages/client"),
1313
+ },
1314
+ onProvisioned: (event) => {
1315
+ if (event.service === "glitchtip") {
1316
+ ledger?.record({ kind: "glitchtip", project: event.project });
1317
+ }
1318
+ else if (event.service === "openpanel") {
1319
+ ledger?.record({ kind: "openpanel", project: event.project });
1320
+ }
1321
+ else if (event.service === "plausible") {
1322
+ ledger?.record({ kind: "plausible", project: event.project });
1323
+ }
1324
+ },
1325
+ });
1326
+ }
1017
1327
  }
1018
1328
  catch (err) {
1019
- console.log(chalk.yellow(` Couldn't auto-provision GlitchTip: ${err.message}`));
1020
- console.log(chalk.dim(` Run \`hatchkit add ${config.name} glitchtip\` once GlitchTip is reachable.`));
1329
+ console.log(chalk.yellow(` Couldn't auto-provision analytics: ${err.message}`));
1330
+ console.log(chalk.dim(` Run \`hatchkit add ${config.name} ${analyticsServices.join(",")}\` once providers are reachable.`));
1021
1331
  }
1022
1332
  }
1023
1333
  // Stripe: walk the user through pasting per-project keys (sk + pk
@@ -1170,6 +1480,7 @@ async function handleCreate() {
1170
1480
  repoUrl: repoUrl ?? undefined,
1171
1481
  serverPort: scaffoldResult?.ports.server,
1172
1482
  clientPort: scaffoldResult?.ports.client,
1483
+ isPrivateRepo: isCreatedGithubRepoPrivate(config),
1173
1484
  });
1174
1485
  // Order matters: rollback iterates the ledger in REVERSE, so we
1175
1486
  // record parent-before-child (project before app). Otherwise
@@ -1223,18 +1534,26 @@ async function handleCreate() {
1223
1534
  if (repoUrl && config.scaffoldRepo) {
1224
1535
  try {
1225
1536
  const { findCoolifyAppsForProject } = await import("./deploy/coolify-app.js");
1226
- const { repoSlugFromRemote, setCoolifyDeploySecrets } = await import("./deploy/gh-actions-secrets.js");
1537
+ const { ghSecretExists, repoSlugFromRemote, setCoolifyDeploySecrets } = await import("./deploy/gh-actions-secrets.js");
1227
1538
  const slug = repoSlugFromRemote(repoUrl);
1228
1539
  const apps = await findCoolifyAppsForProject(config.name);
1229
- if (slug && apps.length > 0) {
1230
- await setCoolifyDeploySecrets({
1231
- projectDir: appDir,
1232
- repoSlug: slug,
1233
- apps,
1234
- });
1235
- }
1236
- else if (apps.length === 0) {
1237
- console.log(chalk.dim(` · No Coolify app named "${config.name}" / "${config.name}-server" / "${config.name}-client" / "${config.name}-web" found — skipping Actions secret push.`));
1540
+ if (slug) {
1541
+ if (apps.length > 0) {
1542
+ await setCoolifyDeploySecrets({
1543
+ projectDir: appDir,
1544
+ repoSlug: slug,
1545
+ apps,
1546
+ });
1547
+ }
1548
+ else {
1549
+ console.log(chalk.dim(` · No Coolify app named "${config.name}" / "${config.name}-server" / "${config.name}-client" / "${config.name}-web" found — skipping Coolify deploy secret push.`));
1550
+ }
1551
+ const secretName = "DOTENV_PRIVATE_KEY_PRODUCTION";
1552
+ const preExisted = await ghSecretExists(appDir, slug, secretName);
1553
+ await pushProjectKeyToGh(config.name, slug);
1554
+ if (!preExisted) {
1555
+ ledger?.record({ kind: "ghActionsSecret", repo: slug, name: secretName });
1556
+ }
1238
1557
  }
1239
1558
  }
1240
1559
  catch (err) {
@@ -1305,7 +1624,10 @@ async function handleCreate() {
1305
1624
  // created the repo + `origin` but deliberately skipped the push.
1306
1625
  if (config.scaffoldRepo && config.createGithubRepo && repoUrl) {
1307
1626
  const { pushInitialBranch } = await import("./deploy/github.js");
1308
- await pushInitialBranch(appDir);
1627
+ const pushed = await pushInitialBranch(appDir);
1628
+ if (pushed && config.deploymentMode === "coolify") {
1629
+ await configureGhcrForCreate(repoUrl, isCreatedGithubRepoPrivate(config), ledger);
1630
+ }
1309
1631
  }
1310
1632
  // Step 6.6: optional email forwarding setup (Cloudflare Email
1311
1633
  // Routing). Opt-in prompt — most projects want it but a scripted
@@ -1470,6 +1792,48 @@ async function handleUpdate() {
1470
1792
  console.log(chalk.yellow(" Run `pnpm install` to pick up @hatchkit/dev-plugin-next, then `hatchkit doctor` to confirm host plumbing."));
1471
1793
  }
1472
1794
  }
1795
+ async function handleServer() {
1796
+ const sub = args[1];
1797
+ if (sub !== "add") {
1798
+ console.log("Usage: hatchkit server add [--yes] [--dry-run] [--server-dir <path>]");
1799
+ console.log("Run `hatchkit help server` for details.");
1800
+ process.exit(1);
1801
+ }
1802
+ const { runServerAdd } = await import("./scaffold/server-add.js");
1803
+ const result = await runServerAdd(resolve("."), {
1804
+ yes: args.includes("--yes") || args.includes("-y"),
1805
+ dryRun: args.includes("--dry-run"),
1806
+ serverDir: flagValue("--server-dir"),
1807
+ sharedDir: flagValue("--shared-dir"),
1808
+ });
1809
+ if (args.includes("--json")) {
1810
+ console.log(JSON.stringify(result, null, 2));
1811
+ return;
1812
+ }
1813
+ if (result.dryRun) {
1814
+ console.log(chalk.yellow(" --dry-run — no files were changed."));
1815
+ }
1816
+ if (result.created.length > 0) {
1817
+ console.log(chalk.green(` ✓ Created: ${result.created.join(", ")}`));
1818
+ }
1819
+ if (result.updated.length > 0) {
1820
+ console.log(chalk.green(` ✓ Updated: ${result.updated.join(", ")}`));
1821
+ }
1822
+ if (result.reused.length > 0) {
1823
+ console.log(chalk.dim(` · Reused existing: ${result.reused.join(", ")}`));
1824
+ }
1825
+ for (const warning of result.warnings) {
1826
+ console.log(chalk.yellow(` ! ${warning}`));
1827
+ }
1828
+ if (result.skipped.length > 0 && !result.changed) {
1829
+ console.log(chalk.dim(` · ${result.skipped.join(", ")}`));
1830
+ }
1831
+ if (result.nextSteps.length > 0) {
1832
+ console.log(chalk.bold("\n Next:"));
1833
+ for (const step of result.nextSteps)
1834
+ console.log(` ${step}`);
1835
+ }
1836
+ }
1473
1837
  async function handleConfig() {
1474
1838
  const subcommand = args[1];
1475
1839
  switch (subcommand) {
@@ -1477,7 +1841,7 @@ async function handleConfig() {
1477
1841
  const provider = args[2];
1478
1842
  if (!provider) {
1479
1843
  console.log("Usage: hatchkit config add <provider>");
1480
- console.log("Providers: coolify, ghcr, hetzner, dns, s3, modal, runpod, hf, replicate, glitchtip, openpanel, resend, stripe");
1844
+ console.log("Providers: coolify, ghcr, hetzner, dns, s3, modal, runpod, hf, replicate, glitchtip, openpanel, plausible, resend, search-console, stripe");
1481
1845
  return;
1482
1846
  }
1483
1847
  // Handle provider setup based on name
@@ -1489,7 +1853,9 @@ async function handleConfig() {
1489
1853
  case "dns":
1490
1854
  case "glitchtip":
1491
1855
  case "openpanel":
1856
+ case "plausible":
1492
1857
  case "resend":
1858
+ case "search-console":
1493
1859
  case "stripe":
1494
1860
  case "ghcr":
1495
1861
  await reconfigureProvider(provider);
@@ -1528,7 +1894,7 @@ async function handleConfig() {
1528
1894
  default:
1529
1895
  if (!isGpuPlatform(provider)) {
1530
1896
  console.log(chalk.red(` Unknown provider: ${provider}`));
1531
- console.log(chalk.dim(" Valid: coolify, ghcr, hetzner, dns, s3, modal, runpod, hf, replicate, glitchtip, openpanel, resend, stripe"));
1897
+ console.log(chalk.dim(" Valid: coolify, ghcr, hetzner, dns, s3, modal, runpod, hf, replicate, glitchtip, openpanel, plausible, resend, search-console, stripe"));
1532
1898
  return;
1533
1899
  }
1534
1900
  await reconfigureProvider(`gpu.${provider}`);
@@ -1673,6 +2039,37 @@ function printHelp(topic) {
1673
2039
 
1674
2040
  ${chalk.bold("Removal is not supported.")} Removing features could delete
1675
2041
  user code — remove manually + edit the manifest.
2042
+ `);
2043
+ return;
2044
+ }
2045
+ if (topic === "server") {
2046
+ console.log(`
2047
+ ${chalk.bold("hatchkit server add")} — retrofit a server into a client-only project
2048
+
2049
+ ${chalk.bold("Usage:")}
2050
+ cd <project-dir> && hatchkit server add
2051
+ cd <project-dir> && hatchkit server add --yes
2052
+
2053
+ ${chalk.bold("What it does:")}
2054
+ Reads .hatchkit.json, copies the Hatchkit server package from the
2055
+ starter, restores shared server types, updates root scripts/workspace
2056
+ files, flips manifest surfaces from ${chalk.cyan("client-only")} to
2057
+ ${chalk.cyan("both")}, and switches gh-pages projects back to coolify.
2058
+
2059
+ ${chalk.bold("What it does not do:")}
2060
+ No provider calls. No Coolify, DNS, GitHub, keychain, or Terraform
2061
+ mutation. To wire deploy infra after the local scaffold:
2062
+
2063
+ hatchkit adopt --resume --regenerate-pipeline
2064
+
2065
+ ${chalk.bold("Options:")}
2066
+ --server-dir <path> Destination for the server package. Default:
2067
+ ${chalk.dim("packages/server")}.
2068
+ --shared-dir <path> Destination for the shared package. Default:
2069
+ ${chalk.dim("packages/shared")}.
2070
+ --yes, -y Skip confirmation.
2071
+ --dry-run Show planned local changes without writing.
2072
+ --json Machine-readable result.
1676
2073
  `);
1677
2074
  return;
1678
2075
  }
@@ -1792,20 +2189,22 @@ function printHelp(topic) {
1792
2189
  }
1793
2190
  if (topic === "dev-setup") {
1794
2191
  console.log(`
1795
- ${chalk.bold("hatchkit dev-setup")} — opt-in Tailscale-served dev URLs
2192
+ ${chalk.bold("hatchkit dev-setup")} — Tailscale-served dev URLs
1796
2193
 
1797
2194
  Wires up the host-wide plumbing that makes every scaffolded project
1798
2195
  reachable from any Tailscale peer at:
1799
2196
 
1800
- ${chalk.cyan("https://<slug>.local.ricoslabs.com/")}
2197
+ ${chalk.cyan("https://<slug>.local.<your-domain>/")}
1801
2198
 
1802
2199
  …without per-project DNS, port juggling, or framework basePath config.
1803
2200
 
1804
2201
  ${chalk.bold("Host-wide subcommands (run once per machine):")}
1805
2202
  dev-setup init [--force] Auto-write ~/.config/dev/Caddyfile, register
1806
2203
  a launchd job to run Caddy on a free port
1807
- (default 9443, auto-bumps on collision), and
1808
- register a tailscale serve TCP=443 bridge.
2204
+ (default 9443, auto-bumps on collision),
2205
+ register a tailscale serve TCP=443 bridge,
2206
+ and auto-upsert the wildcard DNS A record
2207
+ when Cloudflare credentials are available.
1809
2208
  Idempotent — safe to re-run.
1810
2209
  dev-setup status Print the same Local-dev rows that
1811
2210
  ${chalk.cyan("hatchkit doctor")} would show.
@@ -1825,8 +2224,15 @@ function printHelp(topic) {
1825
2224
  next.config + dep in place (they're inert
1826
2225
  without the fragment).
1827
2226
 
1828
- ${chalk.bold("One-time DNS bit you do yourself (per machine, not per project):")}
1829
- *.local.ricoslabs.com CNAME <your-machine>.<tailnet>.ts.net.
2227
+ ${chalk.bold("DNS:")}
2228
+ ${chalk.cyan("dev-setup init")} auto-upserts a DNS-only A record on a
2229
+ dedicated ${chalk.cyan("local.")} subdomain:
2230
+ *.local.<your-domain> A <your-tailnet-ip> (DNS-only)
2231
+
2232
+ Custom ${chalk.cyan("--domain")} values must use that ${chalk.cyan("local.")} prefix so
2233
+ Hatchkit never overwrites a production wildcard such as *.example.com.
2234
+
2235
+ If Cloudflare credentials are unavailable, add that record manually.
1830
2236
 
1831
2237
  This feature is fully optional: until you run ${chalk.cyan("dev-setup init")},
1832
2238
  ${chalk.cyan("hatchkit doctor")} surfaces zero Local-dev rows. Within a project,
@@ -1864,7 +2270,7 @@ function printHelp(topic) {
1864
2270
  · App fqdn references an apex with no Cloudflare zone
1865
2271
  · R2 bucket follows the \`<project>-<role>\` convention but has no
1866
2272
  matching Coolify app (orphan from a destroyed project)
1867
- · GlitchTip / OpenPanel project with no Coolify app counterpart
2273
+ · GlitchTip / OpenPanel / Plausible project/site with no Coolify app counterpart
1868
2274
  · Cloudflare zone with no Coolify app pointing into it
1869
2275
 
1870
2276
  ${chalk.bold("Flags:")}
@@ -1940,19 +2346,29 @@ function printHelp(topic) {
1940
2346
 
1941
2347
  ${chalk.bold("Usage:")}
1942
2348
  hatchkit add [<project-name>] [<services>] [flags]
2349
+ hatchkit add [<services>] [flags] ${chalk.dim("(inside a project with .hatchkit.json)")}
1943
2350
 
1944
2351
  ${chalk.bold("What it does:")}
1945
2352
  · GlitchTip / OpenPanel: ${chalk.bold("one project per product")}, events tagged by
1946
2353
  \`environment\` so dev / staging / prod share the same dashboard.
1947
- Written to ${chalk.cyan(".env.production")} only dev noise pollutes real metrics.
2354
+ · Plausible: one site for the public project domain, with browser tracker env.
2355
+ Observability values are written to ${chalk.cyan(".env.production")} only — dev noise pollutes real metrics.
1948
2356
  Pass ${chalk.cyan("--enable-dev-obs")} to populate ${chalk.cyan(".env.development")} too.
1949
2357
  · Resend: separate ${chalk.cyan("-dev")} and ${chalk.cyan("-prod")} API keys (audience
1950
2358
  safety). Written to the server's dev + prod env respectively.
2359
+ · Search Console: verifies the project domain via Cloudflare DNS TXT,
2360
+ then adds the ${chalk.cyan("sc-domain:<domain>")} property to your Google account.
2361
+ No runtime env is written.
1951
2362
  · ${chalk.cyan(".env.production")} is dotenvx-encrypted — commit-safe.
1952
2363
  ${chalk.cyan(".env.development")} is plaintext — gitignored, not encrypted.
1953
2364
  · A 0600 cache of every value is saved under
1954
2365
  ${chalk.dim("<config-dir>/provisioned/<project>.*.env")} for recoverability.
1955
2366
  ${chalk.dim("Secret values never hit stdout.")}
2367
+ · The interactive menu only shows services not already present for the
2368
+ current project, starts with nothing selected, and refuses explicit
2369
+ requests that would recreate known resources.
2370
+ · Before creating provider resources, add runs read-only existence probes
2371
+ for the selected services and stops on conflicts so cleanup stays safe.
1956
2372
 
1957
2373
  ${chalk.bold("Surfaces:")}
1958
2374
  hatchkit asks which surfaces your project has. Options:
@@ -1967,7 +2383,10 @@ function printHelp(topic) {
1967
2383
  ${chalk.bold("Services:")}
1968
2384
  glitchtip GLITCHTIP_DSN (server) / PUBLIC_GLITCHTIP_DSN (client)
1969
2385
  openpanel OPENPANEL_* (server) / PUBLIC_OPENPANEL_* (client)
2386
+ plausible NEXT_PUBLIC_PLAUSIBLE_DOMAIN / *_SCRIPT_URL (client only)
1970
2387
  resend RESEND_API_KEY (server only)
2388
+ search-console
2389
+ Google Search Console domain property (DNS verification; no env)
1971
2390
  s3 R2_<BUCKET>_ACCESS_KEY_ID / *_SECRET_ACCESS_KEY / *_BUCKET / R2_ENDPOINT
1972
2391
  — mints a per-bucket scoped Cloudflare R2 API token for every
1973
2392
  bucket declared in .hatchkit.json (s3Buckets). Single-bucket
@@ -1985,6 +2404,7 @@ function printHelp(topic) {
1985
2404
 
1986
2405
  ${chalk.bold("Examples:")}
1987
2406
  hatchkit add
2407
+ hatchkit add search-console
1988
2408
  hatchkit add raptor-runner
1989
2409
  hatchkit add raptor-runner all --enable-dev-obs
1990
2410
  hatchkit add raptor-runner glitchtip,resend --no-write
@@ -2016,8 +2436,10 @@ function printHelp(topic) {
2016
2436
  · Writes \`.hatchkit.json\` so \`update\`, \`add\`, \`keys\` recognise
2017
2437
  the project.
2018
2438
  · ${chalk.cyan("GitHub remote")} — \`git init\` (if needed),
2019
- commit, \`gh repo create --private --source=. --push\`. Skipped
2020
- when an \`origin\` is already set.
2439
+ commit, \`gh repo create --private|--public --source=. --push\`.
2440
+ Visibility is prompted (default private) or set with
2441
+ \`--github-visibility private|public\`. Skipped when an \`origin\`
2442
+ is already set.
2021
2443
  · ${chalk.cyan("Coolify + DNS")} — direct REST-API calls into the
2022
2444
  Coolify and Cloudflare you already configured (no Terraform,
2023
2445
  no submodule). Finds or creates the Coolify project, picks
@@ -2027,7 +2449,7 @@ function printHelp(topic) {
2027
2449
  (DOTENV_PRIVATE_KEY_PRODUCTION + GITHUB_REPO_URL), upserts an
2028
2450
  A record \`<domain> → <server-ip>\` on Cloudflare, and triggers
2029
2451
  the first deploy. Defaults ON when no matching app exists.
2030
- · Optionally provisions GlitchTip / OpenPanel / Resend clients
2452
+ · Optionally provisions GlitchTip / OpenPanel / Plausible / Resend clients
2031
2453
  (same machinery as \`hatchkit add\`).
2032
2454
  · Optionally pushes the dotenvx private key to Coolify
2033
2455
  (redundant when the Coolify+DNS step ran — it already does).
@@ -2086,7 +2508,11 @@ function printHelp(topic) {
2086
2508
  ${chalk.bold("Services:")}
2087
2509
  glitchtip Deletes the GlitchTip project
2088
2510
  openpanel Deletes the OpenPanel project (and clears cached creds)
2511
+ plausible Deletes the Plausible site cached for this project
2089
2512
  resend Finds API keys by name and deletes them
2513
+ search-console
2514
+ Removes the Search Console property from your Google account
2515
+ (keeps DNS verification token / ownership state)
2090
2516
  s3 Deletes per-bucket scoped Cloudflare R2 API tokens
2091
2517
  (clears the keychain entries and DELETEs upstream)
2092
2518
 
@@ -2124,7 +2550,7 @@ function printHelp(topic) {
2124
2550
  create + adopt:
2125
2551
  - GitHub repo ${chalk.dim("gh repo delete")}
2126
2552
  - dotenvx private key in keychain ${chalk.dim("keytar deletePassword")}
2127
- - GlitchTip / OpenPanel / Resend ${chalk.dim("DELETE")} per-vendor
2553
+ - GlitchTip / OpenPanel / Plausible / Resend ${chalk.dim("DELETE")} per-vendor
2128
2554
  - Coolify app / project / database ${chalk.dim("DELETE /api/v1/...")}
2129
2555
 
2130
2556
  adopt-only (fine-grained, never wider than what adopt itself wrote):
@@ -2269,7 +2695,7 @@ function printHelp(topic) {
2269
2695
  config Show status of every configured provider (alias: \`status\`)
2270
2696
  config add <p> Configure a provider
2271
2697
  (coolify, ghcr, hetzner, dns, s3, modal, runpod, hf, replicate,
2272
- glitchtip, openpanel, resend, stripe)
2698
+ glitchtip, openpanel, plausible, resend, search-console, stripe)
2273
2699
  config reset Clear ALL CLI config (providers, tokens, ML registry, ports)
2274
2700
  `);
2275
2701
  return;
@@ -2302,13 +2728,13 @@ function printHelp(topic) {
2302
2728
  }
2303
2729
  if (topic === "assets") {
2304
2730
  console.log(`
2305
- ${chalk.bold("hatchkit assets")} — move bytes between local MinIO and prod buckets
2731
+ ${chalk.bold("hatchkit assets")} — move bytes between local S3 and prod buckets
2306
2732
 
2307
2733
  ${chalk.bold("Subcommands:")}
2308
- assets seed [--from <dir>] Local dir → local MinIO bucket.
2734
+ assets seed [--from <dir>] Local dir → local S3 bucket.
2309
2735
  Defaults to ./seed/assets.
2310
- assets push [--bucket assets|state] Local MinIO → prod bucket.
2311
- assets pull [--bucket assets|state] Prod bucket → local MinIO.
2736
+ assets push [--bucket assets|state] Local S3 → prod bucket.
2737
+ assets pull [--bucket assets|state] Prod bucket → local S3.
2312
2738
  Caution: prod data may include PII.
2313
2739
  assets migrate --from-endpoint=URL External S3 → prod bucket.
2314
2740
  --from-bucket=NAME The adoption escape hatch — copy
@@ -2335,7 +2761,7 @@ function printHelp(topic) {
2335
2761
  the env doesn't carry them (R2's URL-driven assets bucket).
2336
2762
 
2337
2763
  ${chalk.bold("Examples:")}
2338
- hatchkit assets seed # ./seed/assets/ → local MinIO
2764
+ hatchkit assets seed # ./seed/assets/ → local S3
2339
2765
  hatchkit assets push --dry-run # see what would ship to prod
2340
2766
  hatchkit assets push # actually ship it
2341
2767
  hatchkit assets migrate --from-endpoint https://nyc3.digitaloceanspaces.com \\
@@ -2374,8 +2800,9 @@ function printHelp(topic) {
2374
2800
  create Scaffold a new project (interactive)
2375
2801
  adopt Bring an existing project under hatchkit management (run in project dir)
2376
2802
  update Add features to an already-scaffolded project (run in project dir)
2377
- add Create GlitchTip / OpenPanel / Resend clients for an existing project
2378
- assets Move bytes between local MinIO and prod buckets (seed/push/pull/migrate)
2803
+ server add Retrofit a server into a client-only project
2804
+ add Create GlitchTip / OpenPanel / Plausible / Resend clients for an existing project
2805
+ assets Move bytes between local S3 and prod buckets (seed/push/pull/migrate)
2379
2806
  remove Delete the -dev/-prod clients created by 'add' (inverse of add)
2380
2807
  destroy Roll back everything ${chalk.cyan("hatchkit create")} did for a project
2381
2808
  rename-domain Move a scaffolded project to a new domain (rewrites tfvars/env/manifest)
@@ -2408,7 +2835,12 @@ function printHelp(topic) {
2408
2835
  --yes, -y (with \`create\`) skip prompts, use defaults / --config values
2409
2836
  --config <path> (with \`create\`) load JSON overrides for ProjectConfig fields
2410
2837
  --name <name> (with \`create\`) set project name without prompting
2838
+ --local-dev[=<slug>] (with \`create\`) enable Tailscale dev URL, optionally with slug
2839
+ --no-local-dev (with \`create\`) skip local-dev wiring
2411
2840
  --no-github (with \`create\`) skip GitHub repo creation
2841
+ --github-visibility {private|public}
2842
+ (with \`create\`) visibility for a newly-created GitHub repo.
2843
+ Default: private. Shorthands: \`--private\`, \`--public\`.
2412
2844
  --no-deploy (with \`create\`) skip Terraform/Coolify/ML deployment
2413
2845
 
2414
2846
  ${chalk.bold("Environment:")}