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/dev-setup.js CHANGED
@@ -2,14 +2,14 @@
2
2
  * `hatchkit dev-setup` — opt-in Tailscale-served dev URLs.
3
3
  *
4
4
  * Goal: every scaffolded project reachable from any Tailscale peer at
5
- * https://<slug>.local.ricoslabs.com/ with no per-project DNS work,
5
+ * https://<slug>.local.<project-domain>/ with no per-project DNS work,
6
6
  * no port juggling, no app-side base/basePath config, and zero
7
7
  * collisions between projects.
8
8
  *
9
9
  * Architecture (host-wide one-time setup):
10
10
  *
11
- * phone ──HTTPS──▶ <slug>.local.ricoslabs.com:443
12
- * │ DNS CNAME → laptop.<tailnet>.ts.net
11
+ * phone ──HTTPS──▶ <slug>.local.<project-domain>:443
12
+ * │ DNS A → laptop's 100.x tailnet IP
13
13
  * ▼
14
14
  * tailscale serve --tcp=443 (raw TCP passthrough)
15
15
  * ▼
@@ -29,9 +29,9 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
29
29
  import { createServer } from "node:net";
30
30
  import { homedir } from "node:os";
31
31
  import { join } from "node:path";
32
- import { CADDYFILE_PATH, DEV_CONFIG_DIR, isLocalDevActive, LOCAL_DEV_DOMAIN, LOCAL_DEV_DOMAIN_WILDCARD, MANAGED_MARKER, projectFragmentPath, PROJECTS_DIR, readCaddyPort, removeProjectFragment, tailscaleIdentity, tailscaleServeTcpTarget, writeProjectFragment, } from "@hatchkit/dev-shared";
32
+ import { CADDYFILE_PATH, DEV_CONFIG_DIR, LOCAL_DEV_DOMAIN, LOCAL_DEV_DOMAIN_WILDCARD, MANAGED_MARKER, PROJECTS_DIR, isLocalDevActive, localDevDomainFromProjectDomain, localDevUrl, normaliseLocalDevDomain, projectFragmentPath, readCaddyLocalDevDomain, readCaddyPort, removeProjectFragment, tailscaleIdentity, tailscaleServeTcpTarget, writeProjectFragment, } from "@hatchkit/dev-shared";
33
33
  import { exec, execOk } from "./utils/exec.js";
34
- export { CADDYFILE_PATH, DEV_CONFIG_DIR, LOCAL_DEV_DOMAIN, LOCAL_DEV_DOMAIN_WILDCARD, MANAGED_MARKER, PROJECTS_DIR, isLocalDevActive, readCaddyPort, tailscaleIdentity, tailscaleServeTcpTarget, };
34
+ export { CADDYFILE_PATH, DEV_CONFIG_DIR, LOCAL_DEV_DOMAIN, LOCAL_DEV_DOMAIN_WILDCARD, MANAGED_MARKER, PROJECTS_DIR, isLocalDevActive, readCaddyLocalDevDomain, readCaddyPort, tailscaleIdentity, tailscaleServeTcpTarget, };
35
35
  export const CADDY_LOG_PATH = join(DEV_CONFIG_DIR, "caddy.log");
36
36
  export const CADDY_ERR_LOG_PATH = join(DEV_CONFIG_DIR, "caddy.err.log");
37
37
  export const CADDY_WRAPPER_PATH = join(DEV_CONFIG_DIR, "caddy-wrapper.sh");
@@ -50,7 +50,9 @@ const CADDY_PORT_BUMP_LIMIT = 50;
50
50
  // ---------------------------------------------------------------------------
51
51
  // Caddyfile + launchd plist contents
52
52
  // ---------------------------------------------------------------------------
53
- export function caddyfileContents(caddyPort) {
53
+ export function caddyfileContents(caddyPort, localDevDomain = LOCAL_DEV_DOMAIN) {
54
+ const domain = normaliseLocalDevDomain(localDevDomain) ?? LOCAL_DEV_DOMAIN;
55
+ const wildcard = `*.${domain}`;
54
56
  return `${MANAGED_MARKER}. Edit at your own risk — re-running
55
57
  # \`hatchkit dev-setup init\` will overwrite this file (delete the marker
56
58
  # line above to keep your edits across re-runs; doctor will then skip
@@ -66,11 +68,11 @@ export function caddyfileContents(caddyPort) {
66
68
  auto_https disable_redirects
67
69
  }
68
70
 
69
- https://${LOCAL_DEV_DOMAIN_WILDCARD}:${caddyPort} {
71
+ https://${wildcard}:${caddyPort} {
70
72
  bind 127.0.0.1
71
73
  # No explicit \`tls\` directive: the site address already names the
72
74
  # wildcard subject, and the global \`acme_dns cloudflare\` block
73
- # drives DNS-01 issuance. Adding \`tls *.local.ricoslabs.com\` here
75
+ # drives DNS-01 issuance. Adding an explicit wildcard \`tls\` arg here
74
76
  # would be parsed as the email-or-keyword form and Caddy 2 rejects
75
77
  # it ("single argument must either be 'internal', 'force_automate',
76
78
  # or an email address").
@@ -413,9 +415,36 @@ export async function checkLocalDevHost() {
413
415
  }
414
416
  return out;
415
417
  }
418
+ export function localDevDomainSafetyIssue(input) {
419
+ const domain = normaliseLocalDevDomain(input);
420
+ if (!domain)
421
+ return "Local-dev domain is invalid. Use a domain like local.example.com.";
422
+ const labels = domain.split(".");
423
+ if (labels.length < 3 || labels[0] !== "local") {
424
+ const suggested = domain.startsWith("local.") ? domain : `local.${domain}`;
425
+ return (`Unsafe local-dev domain "${domain}". Use a dedicated local-dev subdomain, ` +
426
+ `for example "${suggested}", so Hatchkit manages "*.local..." DNS instead ` +
427
+ "of a production wildcard.");
428
+ }
429
+ return null;
430
+ }
431
+ export function isTailscaleIpv4(ip) {
432
+ const parts = ip.split(".").map((p) => Number(p));
433
+ if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) {
434
+ return false;
435
+ }
436
+ const n = ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3];
437
+ // Tailscale IPv4s live in CGNAT space 100.64.0.0/10.
438
+ return n >= 0x64400000 && n <= 0x647fffff;
439
+ }
416
440
  export async function runDevSetupInit(opts = {}) {
417
441
  if (!existsSync(PROJECTS_DIR))
418
442
  mkdirSync(PROJECTS_DIR, { recursive: true });
443
+ const localDevDomain = normaliseLocalDevDomain(opts.localDevDomain) ??
444
+ (isLocalDevActive() ? readCaddyLocalDevDomain() : LOCAL_DEV_DOMAIN);
445
+ const domainSafetyIssue = localDevDomainSafetyIssue(localDevDomain);
446
+ if (domainSafetyIssue)
447
+ throw new Error(domainSafetyIssue);
419
448
  let caddyPort = opts.force ? null : readCaddyPort();
420
449
  if (caddyPort === null) {
421
450
  caddyPort = await pickFreeCaddyPort();
@@ -428,7 +457,7 @@ export async function runDevSetupInit(opts = {}) {
428
457
  // unless --force is set; the user may be running their own dev domain
429
458
  // setup we shouldn't trample on first encounter.
430
459
  let wroteCaddyfile = false;
431
- const nextCaddyfile = caddyfileContents(caddyPort);
460
+ const nextCaddyfile = caddyfileContents(caddyPort, localDevDomain);
432
461
  if (existsSync(CADDYFILE_PATH)) {
433
462
  const existing = readFileSync(CADDYFILE_PATH, "utf-8");
434
463
  if (!existing.includes(MANAGED_MARKER) && !opts.force) {
@@ -483,7 +512,8 @@ export async function runDevSetupInit(opts = {}) {
483
512
  wroteWrapper = true;
484
513
  }
485
514
  const nextPlist = launchdPlistContentsWrapped(CADDY_WRAPPER_PATH);
486
- if (!existsSync(LAUNCHD_PLIST_PATH) || readFileSync(LAUNCHD_PLIST_PATH, "utf-8") !== nextPlist) {
515
+ if (!existsSync(LAUNCHD_PLIST_PATH) ||
516
+ readFileSync(LAUNCHD_PLIST_PATH, "utf-8") !== nextPlist) {
487
517
  writeFileSync(LAUNCHD_PLIST_PATH, nextPlist);
488
518
  wrotePlist = true;
489
519
  }
@@ -501,7 +531,8 @@ export async function runDevSetupInit(opts = {}) {
501
531
  notes.push(`For keychain-backed storage: \`security add-generic-password -s ${DEFAULT_CADDY_KEYCHAIN_SERVICE} -a ${DEFAULT_CADDY_KEYCHAIN_ACCOUNT} -w '<token>' -U\` then re-run \`dev-setup init\`.`);
502
532
  }
503
533
  const nextPlist = launchdPlistContents(caddyBinPath, token ?? null);
504
- if (!existsSync(LAUNCHD_PLIST_PATH) || readFileSync(LAUNCHD_PLIST_PATH, "utf-8") !== nextPlist) {
534
+ if (!existsSync(LAUNCHD_PLIST_PATH) ||
535
+ readFileSync(LAUNCHD_PLIST_PATH, "utf-8") !== nextPlist) {
505
536
  writeFileSync(LAUNCHD_PLIST_PATH, nextPlist);
506
537
  wrotePlist = true;
507
538
  }
@@ -528,7 +559,7 @@ export async function runDevSetupInit(opts = {}) {
528
559
  registeredServe = true;
529
560
  }
530
561
  }
531
- // DNS: ensure *.local.ricoslabs.com → laptop's tailnet IP (A record,
562
+ // DNS: ensure the local-dev wildcard → laptop's tailnet IP (A record,
532
563
  // DNS-only). Without this every project URL hits whatever the parent
533
564
  // zone's wildcard says (typically the Coolify host IP, proxied — which
534
565
  // doesn't reach the tailnet) and phone requests die at the TLS
@@ -536,8 +567,9 @@ export async function runDevSetupInit(opts = {}) {
536
567
  // but only when each peer has Tailscale's resolver in front of its
537
568
  // public DNS — fragile on iOS, where stub resolvers cache the
538
569
  // intermediate NXDOMAIN. Direct A record is the bulletproof shape.
539
- const dnsRecord = await ensureLocalDevDnsRecord(opts, notes);
570
+ const dnsRecord = await ensureLocalDevDnsRecord({ ...opts, localDevDomain }, notes);
540
571
  return {
572
+ localDevDomain,
541
573
  caddyPort,
542
574
  wroteCaddyfile,
543
575
  wrotePlist,
@@ -548,11 +580,13 @@ export async function runDevSetupInit(opts = {}) {
548
580
  notes,
549
581
  };
550
582
  }
551
- /** Upsert the `*.local.ricoslabs.com` A record to the laptop's current
583
+ /** Upsert the local-dev wildcard A record to the laptop's current
552
584
  * tailnet IP. Idempotent. Returns the action taken (or a reason it
553
585
  * was skipped). All failures are non-fatal — they only nudge the
554
586
  * user toward the manual command. */
555
587
  async function ensureLocalDevDnsRecord(opts, notes) {
588
+ const localDevDomain = normaliseLocalDevDomain(opts.localDevDomain) ?? LOCAL_DEV_DOMAIN;
589
+ const wildcard = `*.${localDevDomain}`;
556
590
  const identity = await tailscaleIdentity();
557
591
  if (!identity) {
558
592
  notes.push("DNS record skipped: tailscale daemon offline (no IP to point the record at).");
@@ -577,14 +611,27 @@ async function ensureLocalDevDnsRecord(opts, notes) {
577
611
  try {
578
612
  const { CloudflareApi } = await import("./utils/cloudflare-api.js");
579
613
  const cf = new CloudflareApi({ token });
580
- const zone = await resolveZoneForName(cf, `${LOCAL_DEV_DOMAIN_WILDCARD}`);
614
+ const zone = await resolveZoneForName(cf, wildcard);
581
615
  if (!zone) {
582
- notes.push(`DNS record skipped: no Cloudflare zone found for ${LOCAL_DEV_DOMAIN}. The token may lack Zone:Zone:Read scope.`);
616
+ notes.push(`DNS record skipped: no Cloudflare zone found for ${localDevDomain}. The token may lack Zone:Zone:Read scope.`);
583
617
  return "failed";
584
618
  }
619
+ const existing = await findLocalDevWildcardRecords(cf, zone.id, wildcard);
620
+ const conflict = existing.find((r) => r.type !== "A" || !isTailscaleIpv4(r.content));
621
+ if (conflict) {
622
+ notes.push(`DNS record skipped: ${formatRecord(conflict)} already exists. Hatchkit will not overwrite non-Tailscale wildcard DNS; choose --domain local.<your-domain> or change the record manually.`);
623
+ return "skipped-existing";
624
+ }
625
+ if (existing.length > 1) {
626
+ notes.push(`DNS record skipped: ${wildcard} has multiple local-dev-looking records. Clean them up manually before re-running.`);
627
+ return "skipped-existing";
628
+ }
629
+ const current = existing[0];
630
+ if (current && current.content === identity.ip && !current.proxied)
631
+ return "unchanged";
585
632
  const upsert = await cf.upsertRecord(zone.id, {
586
633
  type: "A",
587
- name: LOCAL_DEV_DOMAIN_WILDCARD,
634
+ name: wildcard,
588
635
  content: identity.ip,
589
636
  proxied: false,
590
637
  ttl: 60,
@@ -600,10 +647,21 @@ async function ensureLocalDevDnsRecord(opts, notes) {
600
647
  return "failed";
601
648
  }
602
649
  }
650
+ async function findLocalDevWildcardRecords(cf, zoneId, wildcard) {
651
+ const [a, aaaa, cname] = await Promise.all([
652
+ cf.findRecordsByName(zoneId, wildcard, "A"),
653
+ cf.findRecordsByName(zoneId, wildcard, "AAAA"),
654
+ cf.findRecordsByName(zoneId, wildcard, "CNAME"),
655
+ ]);
656
+ return [...a, ...aaaa, ...cname];
657
+ }
658
+ function formatRecord(record) {
659
+ return `${record.type} ${record.name} -> ${record.content}${record.proxied ? " (proxied)" : ""}`;
660
+ }
603
661
  /** Walk the candidate label chain looking for a Cloudflare zone we can
604
- * manage. For `*.local.ricoslabs.com` this tries `local.ricoslabs.com`
662
+ * manage. For `*.local.example.com` this tries `local.example.com`
605
663
  * first (in case the user has it delegated as its own zone), then
606
- * `ricoslabs.com`, then `com` (which won't be ours but completes the
664
+ * `example.com`, then `com` (which won't be ours but completes the
607
665
  * chain symmetrically). Returns the first hit. */
608
666
  async function resolveZoneForName(cf, name) {
609
667
  const labels = name.replace(/^\*\./, "").split(".");
@@ -615,41 +673,76 @@ async function resolveZoneForName(cf, name) {
615
673
  }
616
674
  return null;
617
675
  }
618
- /** Probe the live `*.local.ricoslabs.com` Cloudflare record. Returns
676
+ /** Probe the live local-dev wildcard Cloudflare record. Returns
619
677
  * null when there's no token to check with — that's a configuration
620
678
  * state, not a failure. Otherwise reports drift between the record
621
679
  * and the laptop's current tailnet IP. */
622
680
  async function checkLocalDevDnsRecord(currentIp) {
681
+ const localDevDomain = readCaddyLocalDevDomain();
682
+ const domainSafetyIssue = localDevDomainSafetyIssue(localDevDomain);
683
+ if (domainSafetyIssue) {
684
+ return {
685
+ name: "Local-dev / DNS domain",
686
+ status: "fail",
687
+ detail: domainSafetyIssue,
688
+ hint: [
689
+ "Re-run `hatchkit dev-setup init --domain local.<your-domain>`.",
690
+ "This prevents Hatchkit from managing a production wildcard such as *.example.com.",
691
+ ],
692
+ };
693
+ }
694
+ const wildcard = `*.${localDevDomain}`;
623
695
  const token = (await readCloudflareTokenFromConfig()) ?? (await readCloudflareTokenFromKeychain());
624
696
  if (!token)
625
697
  return null;
626
698
  try {
627
699
  const { CloudflareApi } = await import("./utils/cloudflare-api.js");
628
700
  const cf = new CloudflareApi({ token });
629
- const zone = await resolveZoneForName(cf, LOCAL_DEV_DOMAIN_WILDCARD);
701
+ const zone = await resolveZoneForName(cf, wildcard);
630
702
  if (!zone) {
631
703
  return {
632
704
  name: `Local-dev / DNS A record`,
633
705
  status: "fail",
634
- detail: `no Cloudflare zone found for ${LOCAL_DEV_DOMAIN}`,
706
+ detail: `no Cloudflare zone found for ${localDevDomain}`,
635
707
  hint: [
636
708
  "Token may lack Zone:Zone:Read on the parent zone, or the zone isn't on Cloudflare.",
637
709
  "Add the record manually if you're using a different DNS provider:",
638
- ` *.local.ricoslabs.com A ${currentIp} (DNS-only / unproxied, TTL 60)`,
710
+ ` ${wildcard} A ${currentIp} (DNS-only / unproxied, TTL 60)`,
639
711
  ],
640
712
  };
641
713
  }
642
- const record = await cf.findRecord(zone.id, LOCAL_DEV_DOMAIN_WILDCARD, "A");
643
- if (!record) {
714
+ const existing = await findLocalDevWildcardRecords(cf, zone.id, wildcard);
715
+ const conflict = existing.find((r) => r.type !== "A" || !isTailscaleIpv4(r.content));
716
+ if (conflict) {
717
+ return {
718
+ name: "Local-dev / DNS A record",
719
+ status: "fail",
720
+ detail: `existing non-local-dev wildcard record: ${formatRecord(conflict)}`,
721
+ hint: [
722
+ "Hatchkit will not overwrite this record automatically.",
723
+ "Use a dedicated local-dev suffix such as local.<your-domain>, or change DNS manually.",
724
+ ],
725
+ };
726
+ }
727
+ if (existing.length > 1) {
644
728
  return {
645
729
  name: "Local-dev / DNS A record",
646
730
  status: "fail",
647
- detail: `no A record for ${LOCAL_DEV_DOMAIN_WILDCARD} in zone ${zone.name}`,
731
+ detail: `${wildcard} has multiple A/AAAA/CNAME records`,
648
732
  hint: [
649
- "Run `hatchkit dev-setup init` to create it automatically.",
733
+ "Clean up duplicate wildcard records manually, then re-run `hatchkit dev-setup init`.",
650
734
  ],
651
735
  };
652
736
  }
737
+ const record = existing[0];
738
+ if (!record) {
739
+ return {
740
+ name: "Local-dev / DNS A record",
741
+ status: "fail",
742
+ detail: `no A record for ${wildcard} in zone ${zone.name}`,
743
+ hint: ["Run `hatchkit dev-setup init` to create it automatically."],
744
+ };
745
+ }
653
746
  if (record.content !== currentIp) {
654
747
  return {
655
748
  name: "Local-dev / DNS A record",
@@ -674,7 +767,7 @@ async function checkLocalDevDnsRecord(currentIp) {
674
767
  return {
675
768
  name: "Local-dev / DNS A record",
676
769
  status: "ok",
677
- detail: `${LOCAL_DEV_DOMAIN_WILDCARD} → ${currentIp} (DNS-only)`,
770
+ detail: `${wildcard} → ${currentIp} (DNS-only)`,
678
771
  };
679
772
  }
680
773
  catch (err) {
@@ -747,10 +840,27 @@ async function pluginVersionRange() {
747
840
  * safe to call from scaffold (first run) AND from `dev-setup enable`
748
841
  * on an already-wired project. */
749
842
  export async function enableProjectLocalDev(input) {
750
- const wroteFragment = writeProjectFragment(input.slug, input.devPort);
843
+ const manifest = await (async () => {
844
+ try {
845
+ const { readManifest } = await import("./scaffold/manifest.js");
846
+ return readManifest(input.projectDir);
847
+ }
848
+ catch {
849
+ return null;
850
+ }
851
+ })();
852
+ const localDevDomain = normaliseLocalDevDomain(input.localDevDomain) ??
853
+ normaliseLocalDevDomain(manifest?.localDev?.domain) ??
854
+ localDevDomainFromProjectDomain(manifest?.domain) ??
855
+ LOCAL_DEV_DOMAIN;
856
+ const domainSafetyIssue = localDevDomainSafetyIssue(localDevDomain);
857
+ if (domainSafetyIssue)
858
+ throw new Error(domainSafetyIssue);
859
+ const wroteFragment = writeProjectFragment(input.slug, input.devPort, localDevDomain);
751
860
  const docsPath = join(input.projectDir, "docs", "dev-setup.md");
752
861
  const docsContent = renderDevSetupDocs({
753
862
  slug: input.slug,
863
+ localDevDomain,
754
864
  tailscale: await tailscaleIdentity(),
755
865
  });
756
866
  let wroteDocs = false;
@@ -954,8 +1064,10 @@ function patchPluginPackageJsonDep(projectDir, versionRange, framework) {
954
1064
  return "added";
955
1065
  }
956
1066
  export function renderDevSetupDocs(input) {
1067
+ const localDevDomain = normaliseLocalDevDomain(input.localDevDomain) ?? LOCAL_DEV_DOMAIN;
1068
+ const wildcard = `*.${localDevDomain}`;
957
1069
  const tailnetHostname = input.tailscale?.fullName ?? "<your-machine>.<tailnet>.ts.net";
958
- const url = `https://${input.slug}.${LOCAL_DEV_DOMAIN}/`;
1070
+ const url = localDevUrl(input.slug, localDevDomain);
959
1071
  return `# Dev URL setup (\`${url}\`)
960
1072
 
961
1073
  This project ships with the **hatchkit local-dev** integration: when you run
@@ -973,20 +1085,25 @@ framework \`base\` / \`basePath\` config.
973
1085
 
974
1086
  ## One-time host setup
975
1087
 
976
- Do this **once per machine**, not per project. After it's wired,
977
- every hatchkit project that opts in just works.
1088
+ Do this **once per machine**, not per project. New hatchkit projects opt in by
1089
+ default, so after the host is wired they just work.
1090
+
1091
+ \`\`\`
1092
+ hatchkit dev-setup init --domain ${localDevDomain}
1093
+ \`\`\`
978
1094
 
979
1095
  ### 1. Cloudflare DNS — auto-managed
980
1096
 
981
1097
  \`hatchkit dev-setup init\` creates a DNS-only A record:
982
1098
 
983
1099
  \`\`\`
984
- *.local.ricoslabs.com A <your-tailnet-ip> (DNS-only, TTL 60)
1100
+ ${wildcard} A <your-tailnet-ip> (DNS-only, TTL 60)
985
1101
  \`\`\`
986
1102
 
987
1103
  It uses your hatchkit DNS token (or the \`caddy-dev/cloudflare-acme\`
988
1104
  keychain entry as a fallback) — the same token Caddy already needs for
989
- DNS-01 ACME. \`Zone:DNS:Edit\` + \`Zone:Zone:Read\` on the parent zone.
1105
+ DNS-01 ACME. Required permissions: \`Zone:DNS:Edit\` + \`Zone:Zone:Read\`
1106
+ on the parent zone.
990
1107
 
991
1108
  **Why a direct A record instead of a CNAME to ${tailnetHostname}?**
992
1109
  A CNAME to a \`.ts.net\` name only resolves when each peer has
@@ -999,7 +1116,7 @@ else gets a useless 100.x address (intended).
999
1116
  If you're using a non-Cloudflare DNS provider, add the record yourself:
1000
1117
 
1001
1118
  \`\`\`
1002
- *.local.ricoslabs.com A <your-tailnet-ip> (DNS-only)
1119
+ ${wildcard} A <your-tailnet-ip> (DNS-only)
1003
1120
  \`\`\`
1004
1121
 
1005
1122
  ### 2. Cloudflare API token
@@ -1013,8 +1130,16 @@ hatchkit config add dns
1013
1130
  \`\`\`
1014
1131
 
1015
1132
  Permissions: \`Zone:DNS:Edit\` + \`Zone:Zone:Read\` scoped to
1016
- \`ricoslabs.com\`. The token gets embedded in the launchd plist
1017
- during \`dev-setup init\`.
1133
+ \`${localDevDomain}\`. For the cleanest setup, keep the token in Keychain:
1134
+
1135
+ \`\`\`
1136
+ security add-generic-password -s caddy-dev -a cloudflare-acme -w '<token>' -U
1137
+ \`\`\`
1138
+
1139
+ When that keychain entry exists, \`dev-setup init\` writes a tiny Caddy
1140
+ wrapper that reads the token at startup, so the launchd plist never stores
1141
+ the token in plaintext. Without the keychain entry, hatchkit falls back to
1142
+ embedding the DNS token from \`hatchkit config add dns\` in the plist.
1018
1143
 
1019
1144
  ### 3. Caddy with the Cloudflare DNS plugin
1020
1145
 
@@ -1037,10 +1162,11 @@ xcaddy build --with github.com/caddy-dns/cloudflare
1037
1162
  hatchkit dev-setup init
1038
1163
  \`\`\`
1039
1164
 
1040
- This writes \`~/.config/dev/Caddyfile\`, a launchd plist that runs Caddy
1041
- on a free port (default 9443, auto-bumps if taken), loads the launchd
1042
- job, and registers \`tailscale serve --tcp=443 → localhost:<caddyPort>\`.
1043
- Idempotent safe to re-run.
1165
+ This writes \`~/.config/dev/Caddyfile\`, writes/loads a launchd job that runs
1166
+ Caddy on a free port (default 9443, auto-bumps if taken), registers
1167
+ \`tailscale serve --tcp=443 → localhost:<caddyPort>\`, and upserts the
1168
+ \`${wildcard}\` DNS-only A record when Cloudflare credentials are
1169
+ available. Idempotent — safe to re-run.
1044
1170
 
1045
1171
  ### 5. Verify
1046
1172
 
@@ -1048,13 +1174,14 @@ Idempotent — safe to re-run.
1048
1174
  hatchkit doctor
1049
1175
  \`\`\`
1050
1176
 
1051
- Look for the **Local-dev** rows. All six should be green:
1177
+ Look for the **Local-dev** rows. They should be green:
1052
1178
 
1053
1179
  - Tailscale daemon
1054
1180
  - Caddy installed
1055
1181
  - Caddy cloudflare plugin
1056
- - Cloudflare API token in plist
1182
+ - Cloudflare ACME token in keychain, or Cloudflare API token in plist
1057
1183
  - Caddy launchd job
1184
+ - DNS A record
1058
1185
  - Tailscale serve bridge
1059
1186
 
1060
1187
  ## Per-project bits
@@ -1077,6 +1204,35 @@ hatchkit dev plugin:
1077
1204
  \`HATCHKIT_LOCAL_DEV=0\` in the environment disables the plugin entirely;
1078
1205
  the dev server falls back to its default banner.
1079
1206
 
1207
+ ## Mobile/devices
1208
+
1209
+ For browser testing on a phone or tablet, install Tailscale on the device,
1210
+ sign in to the same tailnet, and open:
1211
+
1212
+ \`\`\`
1213
+ ${url}
1214
+ \`\`\`
1215
+
1216
+ For the native Capacitor loop, the scaffolded \`pnpm dev:ios\` and
1217
+ \`pnpm dev:android\` scripts run the WebView against \`CAP_DEV_URL\`.
1218
+ The iOS script targets the Simulator; the Android script targets an emulator
1219
+ or attached device. Simulators/emulators use local host routes automatically.
1220
+ Android physical devices auto-pick this Tailscale URL when \`.hatchkit.json\`
1221
+ has \`localDev.slug\`; otherwise Android devices can use either:
1222
+
1223
+ \`\`\`
1224
+ LAN_IP=<your-lan-ip> pnpm dev:android
1225
+ \`\`\`
1226
+
1227
+ or, when the device is on Tailscale and this host setup is green:
1228
+
1229
+ \`\`\`
1230
+ CAP_DEV_URL=${url} pnpm dev:android
1231
+ \`\`\`
1232
+
1233
+ For a real iPhone, set \`CAP_DEV_URL=${url}\`, run \`npx cap sync ios\`,
1234
+ then launch from Xcode via \`npx cap open ios\`.
1235
+
1080
1236
  ## Cleanup
1081
1237
 
1082
1238
  If you tear down this project:
@@ -1104,6 +1260,32 @@ export async function runDevSetupCli(args) {
1104
1260
  }
1105
1261
  if (sub === "init") {
1106
1262
  const force = args.includes("--force");
1263
+ const domainFlagIdx = args.findIndex((a) => a === "--domain" || a.startsWith("--domain="));
1264
+ const rawDomain = domainFlagIdx === -1
1265
+ ? undefined
1266
+ : args[domainFlagIdx].includes("=")
1267
+ ? args[domainFlagIdx].slice("--domain=".length)
1268
+ : args[domainFlagIdx + 1];
1269
+ let localDevDomain = rawDomain ? normaliseLocalDevDomain(rawDomain) : undefined;
1270
+ if (rawDomain && !localDevDomain) {
1271
+ console.log("Usage: --domain <local-dev-domain> (for example: local.example.com)");
1272
+ process.exit(1);
1273
+ }
1274
+ if (!localDevDomain) {
1275
+ const { resolve } = await import("node:path");
1276
+ const { readManifest } = await import("./scaffold/manifest.js");
1277
+ const manifest = readManifest(resolve("."));
1278
+ localDevDomain =
1279
+ normaliseLocalDevDomain(manifest?.localDev?.domain) ??
1280
+ localDevDomainFromProjectDomain(manifest?.domain) ??
1281
+ undefined;
1282
+ }
1283
+ const domainSafetyIssue = localDevDomainSafetyIssue(localDevDomain ?? LOCAL_DEV_DOMAIN);
1284
+ if (domainSafetyIssue) {
1285
+ const chalk = (await import("chalk")).default;
1286
+ console.log(chalk.red(` ${domainSafetyIssue}`));
1287
+ process.exit(1);
1288
+ }
1107
1289
  // --caddy-token-keychain <service>:<account> → wrapper mode with custom pair
1108
1290
  // --no-caddy-token-keychain → force inline-env mode
1109
1291
  // (default) → auto-detect default pair
@@ -1125,9 +1307,10 @@ export async function runDevSetupCli(args) {
1125
1307
  caddyTokenKeychain = { service, account };
1126
1308
  }
1127
1309
  }
1128
- const result = await runDevSetupInit({ force, caddyTokenKeychain });
1310
+ const result = await runDevSetupInit({ force, caddyTokenKeychain, localDevDomain });
1129
1311
  const chalk = (await import("chalk")).default;
1130
1312
  console.log(chalk.bold("\n hatchkit dev-setup init\n"));
1313
+ console.log(` Local-dev domain: ${chalk.cyan(result.localDevDomain)}`);
1131
1314
  console.log(` Caddy port: ${chalk.cyan(result.caddyPort)}`);
1132
1315
  console.log(` Caddyfile: ${result.wroteCaddyfile ? chalk.green("wrote") : chalk.dim("unchanged")} ${chalk.dim(CADDYFILE_PATH)}`);
1133
1316
  if (result.wroteWrapper) {
@@ -1141,10 +1324,12 @@ export async function runDevSetupCli(args) {
1141
1324
  ? chalk.green(result.dnsRecord)
1142
1325
  : result.dnsRecord === "unchanged"
1143
1326
  ? chalk.dim("unchanged")
1144
- : result.dnsRecord === "failed"
1145
- ? chalk.red("failed")
1146
- : chalk.dim("skipped (no CF token)");
1147
- console.log(` DNS A record: ${dnsLabel} ${chalk.dim(`*.${LOCAL_DEV_DOMAIN}`)}`);
1327
+ : result.dnsRecord === "skipped-existing"
1328
+ ? chalk.yellow("skipped (existing wildcard)")
1329
+ : result.dnsRecord === "failed"
1330
+ ? chalk.red("failed")
1331
+ : chalk.dim("skipped (no CF token)");
1332
+ console.log(` DNS A record: ${dnsLabel} ${chalk.dim(`*.${result.localDevDomain}`)}`);
1148
1333
  }
1149
1334
  if (result.notes.length > 0) {
1150
1335
  console.log(chalk.bold("\n Notes:"));
@@ -1162,13 +1347,17 @@ export async function runDevSetupCli(args) {
1162
1347
  }
1163
1348
  const chalk = (await import("chalk")).default;
1164
1349
  for (const r of checks) {
1165
- const icon = r.status === "ok" ? chalk.green("✓") : r.status === "fail" ? chalk.red("✗") : chalk.dim("·");
1350
+ const icon = r.status === "ok"
1351
+ ? chalk.green("✓")
1352
+ : r.status === "fail"
1353
+ ? chalk.red("✗")
1354
+ : chalk.dim("·");
1166
1355
  console.log(` ${icon} ${r.name}${r.detail ? chalk.dim(` — ${r.detail}`) : ""}`);
1167
1356
  }
1168
1357
  return;
1169
1358
  }
1170
1359
  console.log("Usage: hatchkit dev-setup <init|status|enable|disable> [flags]");
1171
- console.log("\n init Auto-write ~/.config/dev/Caddyfile, launchd plist, register tailscale TCP bridge.");
1360
+ console.log("\n init [--domain] Auto-write ~/.config/dev/Caddyfile, launchd plist, register tailscale TCP bridge.");
1172
1361
  console.log(" status Run the same checks doctor runs, but only the Local-dev rows.");
1173
1362
  console.log(" enable [--slug] Wire the project in cwd for Tailscale dev URLs (writes Caddy fragment,");
1174
1363
  console.log(" docs/dev-setup.md, patches next.config, adds plugin dep).");
@@ -1176,7 +1365,7 @@ export async function runDevSetupCli(args) {
1176
1365
  }
1177
1366
  async function runDevSetupEnableCli(args) {
1178
1367
  const chalk = (await import("chalk")).default;
1179
- const { resolve, join: joinPath } = await import("node:path");
1368
+ const { resolve } = await import("node:path");
1180
1369
  const { readManifest, writeManifest } = await import("./scaffold/manifest.js");
1181
1370
  const { sanitiseSlug } = await import("@hatchkit/dev-shared");
1182
1371
  const { input, confirm: askConfirm } = await import("@inquirer/prompts");
@@ -1198,6 +1387,12 @@ async function runDevSetupEnableCli(args) {
1198
1387
  : args[projectDirFlagIdx].includes("=")
1199
1388
  ? args[projectDirFlagIdx].slice("--project-dir=".length)
1200
1389
  : args[projectDirFlagIdx + 1];
1390
+ const domainFlagIdx = args.findIndex((a) => a === "--domain" || a.startsWith("--domain="));
1391
+ const rawLocalDevDomain = domainFlagIdx === -1
1392
+ ? undefined
1393
+ : args[domainFlagIdx].includes("=")
1394
+ ? args[domainFlagIdx].slice("--domain=".length)
1395
+ : args[domainFlagIdx + 1];
1201
1396
  const projectDir = projectDirFlag ? resolve(projectDirFlag) : resolve(".");
1202
1397
  const manifest = readManifest(projectDir);
1203
1398
  if (!manifest) {
@@ -1205,12 +1400,25 @@ async function runDevSetupEnableCli(args) {
1205
1400
  console.log(chalk.dim(` Run from a hatchkit-managed project root, or pass --project-dir <path>.`));
1206
1401
  process.exit(1);
1207
1402
  }
1403
+ const localDevDomain = normaliseLocalDevDomain(rawLocalDevDomain) ??
1404
+ normaliseLocalDevDomain(manifest.localDev?.domain) ??
1405
+ localDevDomainFromProjectDomain(manifest.domain) ??
1406
+ LOCAL_DEV_DOMAIN;
1407
+ if (rawLocalDevDomain && !normaliseLocalDevDomain(rawLocalDevDomain)) {
1408
+ console.log(chalk.red(` --domain ${rawLocalDevDomain} is not a valid local-dev domain.`));
1409
+ process.exit(1);
1410
+ }
1411
+ const domainSafetyIssue = localDevDomainSafetyIssue(localDevDomain);
1412
+ if (domainSafetyIssue) {
1413
+ console.log(chalk.red(` ${domainSafetyIssue}`));
1414
+ process.exit(1);
1415
+ }
1208
1416
  // Slug: flag → manifest.localDev → manifest.name → prompt.
1209
1417
  let slug = slugFlag ? sanitiseSlug(slugFlag) : manifest.localDev?.slug;
1210
1418
  if (!slug) {
1211
1419
  const defaultSlug = sanitiseSlug(manifest.name);
1212
1420
  slug = await input({
1213
- message: "Slug for this project (https://<slug>.local.ricoslabs.com/):",
1421
+ message: `Slug for this project (${localDevUrl("<slug>", localDevDomain)}):`,
1214
1422
  default: defaultSlug,
1215
1423
  validate: (v) => {
1216
1424
  const s = sanitiseSlug(v);
@@ -1237,8 +1445,9 @@ async function runDevSetupEnableCli(args) {
1237
1445
  }
1238
1446
  console.log(chalk.bold(`\n Enabling local-dev for ${chalk.cyan(manifest.name)}\n`));
1239
1447
  console.log(` Slug: ${chalk.cyan(slug)}`);
1448
+ console.log(` Domain: ${chalk.cyan(localDevDomain)}`);
1240
1449
  console.log(` Dev port: ${chalk.cyan(devPort)}`);
1241
- console.log(` URL: ${chalk.cyan(`https://${slug}.${LOCAL_DEV_DOMAIN}/`)}`);
1450
+ console.log(` URL: ${chalk.cyan(localDevUrl(slug, localDevDomain))}`);
1242
1451
  const skipConfirm = args.includes("--yes") || args.includes("-y");
1243
1452
  if (!skipConfirm) {
1244
1453
  const ok = await askConfirm({ message: "Proceed?", default: true });
@@ -1247,11 +1456,11 @@ async function runDevSetupEnableCli(args) {
1247
1456
  return;
1248
1457
  }
1249
1458
  }
1250
- const result = await enableProjectLocalDev({ projectDir, slug, devPort });
1459
+ const result = await enableProjectLocalDev({ projectDir, slug, localDevDomain, devPort });
1251
1460
  // Persist the slug in the manifest so subsequent runs (plugin, doctor,
1252
1461
  // future `dev-setup disable`) all converge on the same identity.
1253
- if (manifest.localDev?.slug !== slug) {
1254
- const updated = { ...manifest, localDev: { slug } };
1462
+ if (manifest.localDev?.slug !== slug || manifest.localDev?.domain !== localDevDomain) {
1463
+ const updated = { ...manifest, localDev: { slug, domain: localDevDomain } };
1255
1464
  writeManifest(projectDir, updated);
1256
1465
  }
1257
1466
  console.log(`\n Framework: ${chalk.cyan(result.framework)}`);
@@ -1272,14 +1481,14 @@ async function runDevSetupEnableCli(args) {
1272
1481
  console.log(chalk.dim(" // …"));
1273
1482
  console.log(chalk.dim(" plugins: ["));
1274
1483
  console.log(chalk.dim(" // …your existing plugins"));
1275
- console.log(chalk.dim(` localDev({ slug: "${slug}" }),`));
1484
+ console.log(chalk.dim(` localDev({ slug: "${slug}", localDevDomain: "${localDevDomain}" }),`));
1276
1485
  console.log(chalk.dim(" ],"));
1277
- console.log(chalk.dim(' server: { allowedHosts: [".local.ricoslabs.com", ".ts.net", ".local"] },'));
1486
+ console.log(chalk.dim(` server: { allowedHosts: [".${localDevDomain}", ".ts.net", ".local"] },`));
1278
1487
  }
1279
1488
  if (result.patchedConfig === "unsupported-shape") {
1280
1489
  console.log(chalk.bold("\n Next config wiring (CJS / non-standard shape):"));
1281
1490
  console.log(chalk.dim(' const { withLocalDev } = require("@hatchkit/dev-plugin-next");'));
1282
- console.log(chalk.dim(` module.exports = withLocalDev(nextConfig, { slug: "${slug}" });`));
1491
+ console.log(chalk.dim(` module.exports = withLocalDev(nextConfig, { slug: "${slug}", localDevDomain: "${localDevDomain}" });`));
1283
1492
  }
1284
1493
  console.log(chalk.dim(`\n Verify with: \`hatchkit doctor\` (or \`hatchkit dev-setup status\`).`));
1285
1494
  }