hatchkit 0.1.47 → 0.2.1

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 (112) 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/completion.d.ts.map +1 -1
  6. package/dist/completion.js +19 -1
  7. package/dist/completion.js.map +1 -1
  8. package/dist/config.d.ts +32 -1
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/config.js +364 -1
  11. package/dist/config.js.map +1 -1
  12. package/dist/deploy/coolify.d.ts +5 -0
  13. package/dist/deploy/coolify.d.ts.map +1 -1
  14. package/dist/deploy/coolify.js +67 -4
  15. package/dist/deploy/coolify.js.map +1 -1
  16. package/dist/deploy/ghcr.d.ts +1 -0
  17. package/dist/deploy/ghcr.d.ts.map +1 -1
  18. package/dist/deploy/ghcr.js +2 -2
  19. package/dist/deploy/ghcr.js.map +1 -1
  20. package/dist/deploy/github.d.ts.map +1 -1
  21. package/dist/deploy/github.js +3 -2
  22. package/dist/deploy/github.js.map +1 -1
  23. package/dist/deploy/rollback.d.ts.map +1 -1
  24. package/dist/deploy/rollback.js +9 -0
  25. package/dist/deploy/rollback.js.map +1 -1
  26. package/dist/dev-setup.d.ts +10 -4
  27. package/dist/dev-setup.d.ts.map +1 -1
  28. package/dist/dev-setup.js +166 -57
  29. package/dist/dev-setup.js.map +1 -1
  30. package/dist/doctor.d.ts.map +1 -1
  31. package/dist/doctor.js +65 -1
  32. package/dist/doctor.js.map +1 -1
  33. package/dist/email/index.js +5 -5
  34. package/dist/email/index.js.map +1 -1
  35. package/dist/email/setup.d.ts +1 -1
  36. package/dist/email/setup.d.ts.map +1 -1
  37. package/dist/email/setup.js +3 -3
  38. package/dist/email/setup.js.map +1 -1
  39. package/dist/explain.d.ts.map +1 -1
  40. package/dist/explain.js +8 -7
  41. package/dist/explain.js.map +1 -1
  42. package/dist/index.js +277 -60
  43. package/dist/index.js.map +1 -1
  44. package/dist/inventory.d.ts +1 -0
  45. package/dist/inventory.d.ts.map +1 -1
  46. package/dist/inventory.js +2 -0
  47. package/dist/inventory.js.map +1 -1
  48. package/dist/onboarding/plan.d.ts +54 -0
  49. package/dist/onboarding/plan.d.ts.map +1 -0
  50. package/dist/onboarding/plan.js +143 -0
  51. package/dist/onboarding/plan.js.map +1 -0
  52. package/dist/onboarding/review.d.ts +27 -0
  53. package/dist/onboarding/review.d.ts.map +1 -0
  54. package/dist/onboarding/review.js +55 -0
  55. package/dist/onboarding/review.js.map +1 -0
  56. package/dist/prompts.d.ts +13 -0
  57. package/dist/prompts.d.ts.map +1 -1
  58. package/dist/prompts.js +107 -89
  59. package/dist/prompts.js.map +1 -1
  60. package/dist/provision/index.d.ts +21 -3
  61. package/dist/provision/index.d.ts.map +1 -1
  62. package/dist/provision/index.js +112 -5
  63. package/dist/provision/index.js.map +1 -1
  64. package/dist/provision/plausible.d.ts +10 -0
  65. package/dist/provision/plausible.d.ts.map +1 -0
  66. package/dist/provision/plausible.js +103 -0
  67. package/dist/provision/plausible.js.map +1 -0
  68. package/dist/provision/search-console.d.ts +17 -0
  69. package/dist/provision/search-console.d.ts.map +1 -0
  70. package/dist/provision/search-console.js +142 -0
  71. package/dist/provision/search-console.js.map +1 -0
  72. package/dist/scaffold/app.d.ts +1 -0
  73. package/dist/scaffold/app.d.ts.map +1 -1
  74. package/dist/scaffold/app.js +4 -1
  75. package/dist/scaffold/app.js.map +1 -1
  76. package/dist/scaffold/infra.js +2 -0
  77. package/dist/scaffold/infra.js.map +1 -1
  78. package/dist/scaffold/manifest.d.ts +4 -2
  79. package/dist/scaffold/manifest.d.ts.map +1 -1
  80. package/dist/scaffold/manifest.js +7 -1
  81. package/dist/scaffold/manifest.js.map +1 -1
  82. package/dist/scaffold/server-add.d.ts +21 -0
  83. package/dist/scaffold/server-add.d.ts.map +1 -0
  84. package/dist/scaffold/server-add.js +273 -0
  85. package/dist/scaffold/server-add.js.map +1 -0
  86. package/dist/scaffold/update.d.ts +1 -0
  87. package/dist/scaffold/update.d.ts.map +1 -1
  88. package/dist/scaffold/update.js +8 -5
  89. package/dist/scaffold/update.js.map +1 -1
  90. package/dist/status.d.ts.map +1 -1
  91. package/dist/status.js +27 -1
  92. package/dist/status.js.map +1 -1
  93. package/dist/templates/base/env.example.hbs +3 -0
  94. package/dist/utils/cloudflare-api.d.ts +5 -0
  95. package/dist/utils/cloudflare-api.d.ts.map +1 -1
  96. package/dist/utils/cloudflare-api.js +19 -0
  97. package/dist/utils/cloudflare-api.js.map +1 -1
  98. package/dist/utils/coolify-api.d.ts +3 -2
  99. package/dist/utils/coolify-api.d.ts.map +1 -1
  100. package/dist/utils/coolify-api.js +19 -5
  101. package/dist/utils/coolify-api.js.map +1 -1
  102. package/dist/utils/flags.d.ts.map +1 -1
  103. package/dist/utils/flags.js +16 -0
  104. package/dist/utils/flags.js.map +1 -1
  105. package/dist/utils/run-ledger.d.ts +3 -0
  106. package/dist/utils/run-ledger.d.ts.map +1 -1
  107. package/dist/utils/run-ledger.js.map +1 -1
  108. package/dist/utils/secrets.d.ts +5 -0
  109. package/dist/utils/secrets.d.ts.map +1 -1
  110. package/dist/utils/secrets.js +5 -0
  111. package/dist/utils/secrets.js.map +1 -1
  112. 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").
@@ -416,6 +418,8 @@ export async function checkLocalDevHost() {
416
418
  export async function runDevSetupInit(opts = {}) {
417
419
  if (!existsSync(PROJECTS_DIR))
418
420
  mkdirSync(PROJECTS_DIR, { recursive: true });
421
+ const localDevDomain = normaliseLocalDevDomain(opts.localDevDomain) ??
422
+ (isLocalDevActive() ? readCaddyLocalDevDomain() : LOCAL_DEV_DOMAIN);
419
423
  let caddyPort = opts.force ? null : readCaddyPort();
420
424
  if (caddyPort === null) {
421
425
  caddyPort = await pickFreeCaddyPort();
@@ -428,7 +432,7 @@ export async function runDevSetupInit(opts = {}) {
428
432
  // unless --force is set; the user may be running their own dev domain
429
433
  // setup we shouldn't trample on first encounter.
430
434
  let wroteCaddyfile = false;
431
- const nextCaddyfile = caddyfileContents(caddyPort);
435
+ const nextCaddyfile = caddyfileContents(caddyPort, localDevDomain);
432
436
  if (existsSync(CADDYFILE_PATH)) {
433
437
  const existing = readFileSync(CADDYFILE_PATH, "utf-8");
434
438
  if (!existing.includes(MANAGED_MARKER) && !opts.force) {
@@ -483,7 +487,8 @@ export async function runDevSetupInit(opts = {}) {
483
487
  wroteWrapper = true;
484
488
  }
485
489
  const nextPlist = launchdPlistContentsWrapped(CADDY_WRAPPER_PATH);
486
- if (!existsSync(LAUNCHD_PLIST_PATH) || readFileSync(LAUNCHD_PLIST_PATH, "utf-8") !== nextPlist) {
490
+ if (!existsSync(LAUNCHD_PLIST_PATH) ||
491
+ readFileSync(LAUNCHD_PLIST_PATH, "utf-8") !== nextPlist) {
487
492
  writeFileSync(LAUNCHD_PLIST_PATH, nextPlist);
488
493
  wrotePlist = true;
489
494
  }
@@ -501,7 +506,8 @@ export async function runDevSetupInit(opts = {}) {
501
506
  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
507
  }
503
508
  const nextPlist = launchdPlistContents(caddyBinPath, token ?? null);
504
- if (!existsSync(LAUNCHD_PLIST_PATH) || readFileSync(LAUNCHD_PLIST_PATH, "utf-8") !== nextPlist) {
509
+ if (!existsSync(LAUNCHD_PLIST_PATH) ||
510
+ readFileSync(LAUNCHD_PLIST_PATH, "utf-8") !== nextPlist) {
505
511
  writeFileSync(LAUNCHD_PLIST_PATH, nextPlist);
506
512
  wrotePlist = true;
507
513
  }
@@ -528,7 +534,7 @@ export async function runDevSetupInit(opts = {}) {
528
534
  registeredServe = true;
529
535
  }
530
536
  }
531
- // DNS: ensure *.local.ricoslabs.com → laptop's tailnet IP (A record,
537
+ // DNS: ensure the local-dev wildcard → laptop's tailnet IP (A record,
532
538
  // DNS-only). Without this every project URL hits whatever the parent
533
539
  // zone's wildcard says (typically the Coolify host IP, proxied — which
534
540
  // doesn't reach the tailnet) and phone requests die at the TLS
@@ -536,8 +542,9 @@ export async function runDevSetupInit(opts = {}) {
536
542
  // but only when each peer has Tailscale's resolver in front of its
537
543
  // public DNS — fragile on iOS, where stub resolvers cache the
538
544
  // intermediate NXDOMAIN. Direct A record is the bulletproof shape.
539
- const dnsRecord = await ensureLocalDevDnsRecord(opts, notes);
545
+ const dnsRecord = await ensureLocalDevDnsRecord({ ...opts, localDevDomain }, notes);
540
546
  return {
547
+ localDevDomain,
541
548
  caddyPort,
542
549
  wroteCaddyfile,
543
550
  wrotePlist,
@@ -548,11 +555,13 @@ export async function runDevSetupInit(opts = {}) {
548
555
  notes,
549
556
  };
550
557
  }
551
- /** Upsert the `*.local.ricoslabs.com` A record to the laptop's current
558
+ /** Upsert the local-dev wildcard A record to the laptop's current
552
559
  * tailnet IP. Idempotent. Returns the action taken (or a reason it
553
560
  * was skipped). All failures are non-fatal — they only nudge the
554
561
  * user toward the manual command. */
555
562
  async function ensureLocalDevDnsRecord(opts, notes) {
563
+ const localDevDomain = normaliseLocalDevDomain(opts.localDevDomain) ?? LOCAL_DEV_DOMAIN;
564
+ const wildcard = `*.${localDevDomain}`;
556
565
  const identity = await tailscaleIdentity();
557
566
  if (!identity) {
558
567
  notes.push("DNS record skipped: tailscale daemon offline (no IP to point the record at).");
@@ -577,14 +586,14 @@ async function ensureLocalDevDnsRecord(opts, notes) {
577
586
  try {
578
587
  const { CloudflareApi } = await import("./utils/cloudflare-api.js");
579
588
  const cf = new CloudflareApi({ token });
580
- const zone = await resolveZoneForName(cf, `${LOCAL_DEV_DOMAIN_WILDCARD}`);
589
+ const zone = await resolveZoneForName(cf, wildcard);
581
590
  if (!zone) {
582
- notes.push(`DNS record skipped: no Cloudflare zone found for ${LOCAL_DEV_DOMAIN}. The token may lack Zone:Zone:Read scope.`);
591
+ notes.push(`DNS record skipped: no Cloudflare zone found for ${localDevDomain}. The token may lack Zone:Zone:Read scope.`);
583
592
  return "failed";
584
593
  }
585
594
  const upsert = await cf.upsertRecord(zone.id, {
586
595
  type: "A",
587
- name: LOCAL_DEV_DOMAIN_WILDCARD,
596
+ name: wildcard,
588
597
  content: identity.ip,
589
598
  proxied: false,
590
599
  ttl: 60,
@@ -601,9 +610,9 @@ async function ensureLocalDevDnsRecord(opts, notes) {
601
610
  }
602
611
  }
603
612
  /** Walk the candidate label chain looking for a Cloudflare zone we can
604
- * manage. For `*.local.ricoslabs.com` this tries `local.ricoslabs.com`
613
+ * manage. For `*.local.example.com` this tries `local.example.com`
605
614
  * 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
615
+ * `example.com`, then `com` (which won't be ours but completes the
607
616
  * chain symmetrically). Returns the first hit. */
608
617
  async function resolveZoneForName(cf, name) {
609
618
  const labels = name.replace(/^\*\./, "").split(".");
@@ -615,39 +624,39 @@ async function resolveZoneForName(cf, name) {
615
624
  }
616
625
  return null;
617
626
  }
618
- /** Probe the live `*.local.ricoslabs.com` Cloudflare record. Returns
627
+ /** Probe the live local-dev wildcard Cloudflare record. Returns
619
628
  * null when there's no token to check with — that's a configuration
620
629
  * state, not a failure. Otherwise reports drift between the record
621
630
  * and the laptop's current tailnet IP. */
622
631
  async function checkLocalDevDnsRecord(currentIp) {
632
+ const localDevDomain = readCaddyLocalDevDomain();
633
+ const wildcard = `*.${localDevDomain}`;
623
634
  const token = (await readCloudflareTokenFromConfig()) ?? (await readCloudflareTokenFromKeychain());
624
635
  if (!token)
625
636
  return null;
626
637
  try {
627
638
  const { CloudflareApi } = await import("./utils/cloudflare-api.js");
628
639
  const cf = new CloudflareApi({ token });
629
- const zone = await resolveZoneForName(cf, LOCAL_DEV_DOMAIN_WILDCARD);
640
+ const zone = await resolveZoneForName(cf, wildcard);
630
641
  if (!zone) {
631
642
  return {
632
643
  name: `Local-dev / DNS A record`,
633
644
  status: "fail",
634
- detail: `no Cloudflare zone found for ${LOCAL_DEV_DOMAIN}`,
645
+ detail: `no Cloudflare zone found for ${localDevDomain}`,
635
646
  hint: [
636
647
  "Token may lack Zone:Zone:Read on the parent zone, or the zone isn't on Cloudflare.",
637
648
  "Add the record manually if you're using a different DNS provider:",
638
- ` *.local.ricoslabs.com A ${currentIp} (DNS-only / unproxied, TTL 60)`,
649
+ ` ${wildcard} A ${currentIp} (DNS-only / unproxied, TTL 60)`,
639
650
  ],
640
651
  };
641
652
  }
642
- const record = await cf.findRecord(zone.id, LOCAL_DEV_DOMAIN_WILDCARD, "A");
653
+ const record = await cf.findRecord(zone.id, wildcard, "A");
643
654
  if (!record) {
644
655
  return {
645
656
  name: "Local-dev / DNS A record",
646
657
  status: "fail",
647
- detail: `no A record for ${LOCAL_DEV_DOMAIN_WILDCARD} in zone ${zone.name}`,
648
- hint: [
649
- "Run `hatchkit dev-setup init` to create it automatically.",
650
- ],
658
+ detail: `no A record for ${wildcard} in zone ${zone.name}`,
659
+ hint: ["Run `hatchkit dev-setup init` to create it automatically."],
651
660
  };
652
661
  }
653
662
  if (record.content !== currentIp) {
@@ -674,7 +683,7 @@ async function checkLocalDevDnsRecord(currentIp) {
674
683
  return {
675
684
  name: "Local-dev / DNS A record",
676
685
  status: "ok",
677
- detail: `${LOCAL_DEV_DOMAIN_WILDCARD} → ${currentIp} (DNS-only)`,
686
+ detail: `${wildcard} → ${currentIp} (DNS-only)`,
678
687
  };
679
688
  }
680
689
  catch (err) {
@@ -747,10 +756,24 @@ async function pluginVersionRange() {
747
756
  * safe to call from scaffold (first run) AND from `dev-setup enable`
748
757
  * on an already-wired project. */
749
758
  export async function enableProjectLocalDev(input) {
750
- const wroteFragment = writeProjectFragment(input.slug, input.devPort);
759
+ const manifest = await (async () => {
760
+ try {
761
+ const { readManifest } = await import("./scaffold/manifest.js");
762
+ return readManifest(input.projectDir);
763
+ }
764
+ catch {
765
+ return null;
766
+ }
767
+ })();
768
+ const localDevDomain = normaliseLocalDevDomain(input.localDevDomain) ??
769
+ normaliseLocalDevDomain(manifest?.localDev?.domain) ??
770
+ localDevDomainFromProjectDomain(manifest?.domain) ??
771
+ LOCAL_DEV_DOMAIN;
772
+ const wroteFragment = writeProjectFragment(input.slug, input.devPort, localDevDomain);
751
773
  const docsPath = join(input.projectDir, "docs", "dev-setup.md");
752
774
  const docsContent = renderDevSetupDocs({
753
775
  slug: input.slug,
776
+ localDevDomain,
754
777
  tailscale: await tailscaleIdentity(),
755
778
  });
756
779
  let wroteDocs = false;
@@ -954,8 +977,10 @@ function patchPluginPackageJsonDep(projectDir, versionRange, framework) {
954
977
  return "added";
955
978
  }
956
979
  export function renderDevSetupDocs(input) {
980
+ const localDevDomain = normaliseLocalDevDomain(input.localDevDomain) ?? LOCAL_DEV_DOMAIN;
981
+ const wildcard = `*.${localDevDomain}`;
957
982
  const tailnetHostname = input.tailscale?.fullName ?? "<your-machine>.<tailnet>.ts.net";
958
- const url = `https://${input.slug}.${LOCAL_DEV_DOMAIN}/`;
983
+ const url = localDevUrl(input.slug, localDevDomain);
959
984
  return `# Dev URL setup (\`${url}\`)
960
985
 
961
986
  This project ships with the **hatchkit local-dev** integration: when you run
@@ -973,20 +998,25 @@ framework \`base\` / \`basePath\` config.
973
998
 
974
999
  ## One-time host setup
975
1000
 
976
- Do this **once per machine**, not per project. After it's wired,
977
- every hatchkit project that opts in just works.
1001
+ Do this **once per machine**, not per project. New hatchkit projects opt in by
1002
+ default, so after the host is wired they just work.
1003
+
1004
+ \`\`\`
1005
+ hatchkit dev-setup init --domain ${localDevDomain}
1006
+ \`\`\`
978
1007
 
979
1008
  ### 1. Cloudflare DNS — auto-managed
980
1009
 
981
1010
  \`hatchkit dev-setup init\` creates a DNS-only A record:
982
1011
 
983
1012
  \`\`\`
984
- *.local.ricoslabs.com A <your-tailnet-ip> (DNS-only, TTL 60)
1013
+ ${wildcard} A <your-tailnet-ip> (DNS-only, TTL 60)
985
1014
  \`\`\`
986
1015
 
987
1016
  It uses your hatchkit DNS token (or the \`caddy-dev/cloudflare-acme\`
988
1017
  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.
1018
+ DNS-01 ACME. Required permissions: \`Zone:DNS:Edit\` + \`Zone:Zone:Read\`
1019
+ on the parent zone.
990
1020
 
991
1021
  **Why a direct A record instead of a CNAME to ${tailnetHostname}?**
992
1022
  A CNAME to a \`.ts.net\` name only resolves when each peer has
@@ -999,7 +1029,7 @@ else gets a useless 100.x address (intended).
999
1029
  If you're using a non-Cloudflare DNS provider, add the record yourself:
1000
1030
 
1001
1031
  \`\`\`
1002
- *.local.ricoslabs.com A <your-tailnet-ip> (DNS-only)
1032
+ ${wildcard} A <your-tailnet-ip> (DNS-only)
1003
1033
  \`\`\`
1004
1034
 
1005
1035
  ### 2. Cloudflare API token
@@ -1013,8 +1043,16 @@ hatchkit config add dns
1013
1043
  \`\`\`
1014
1044
 
1015
1045
  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\`.
1046
+ \`${localDevDomain}\`. For the cleanest setup, keep the token in Keychain:
1047
+
1048
+ \`\`\`
1049
+ security add-generic-password -s caddy-dev -a cloudflare-acme -w '<token>' -U
1050
+ \`\`\`
1051
+
1052
+ When that keychain entry exists, \`dev-setup init\` writes a tiny Caddy
1053
+ wrapper that reads the token at startup, so the launchd plist never stores
1054
+ the token in plaintext. Without the keychain entry, hatchkit falls back to
1055
+ embedding the DNS token from \`hatchkit config add dns\` in the plist.
1018
1056
 
1019
1057
  ### 3. Caddy with the Cloudflare DNS plugin
1020
1058
 
@@ -1037,10 +1075,11 @@ xcaddy build --with github.com/caddy-dns/cloudflare
1037
1075
  hatchkit dev-setup init
1038
1076
  \`\`\`
1039
1077
 
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.
1078
+ This writes \`~/.config/dev/Caddyfile\`, writes/loads a launchd job that runs
1079
+ Caddy on a free port (default 9443, auto-bumps if taken), registers
1080
+ \`tailscale serve --tcp=443 → localhost:<caddyPort>\`, and upserts the
1081
+ \`${wildcard}\` DNS-only A record when Cloudflare credentials are
1082
+ available. Idempotent — safe to re-run.
1044
1083
 
1045
1084
  ### 5. Verify
1046
1085
 
@@ -1048,13 +1087,14 @@ Idempotent — safe to re-run.
1048
1087
  hatchkit doctor
1049
1088
  \`\`\`
1050
1089
 
1051
- Look for the **Local-dev** rows. All six should be green:
1090
+ Look for the **Local-dev** rows. They should be green:
1052
1091
 
1053
1092
  - Tailscale daemon
1054
1093
  - Caddy installed
1055
1094
  - Caddy cloudflare plugin
1056
- - Cloudflare API token in plist
1095
+ - Cloudflare ACME token in keychain, or Cloudflare API token in plist
1057
1096
  - Caddy launchd job
1097
+ - DNS A record
1058
1098
  - Tailscale serve bridge
1059
1099
 
1060
1100
  ## Per-project bits
@@ -1077,6 +1117,35 @@ hatchkit dev plugin:
1077
1117
  \`HATCHKIT_LOCAL_DEV=0\` in the environment disables the plugin entirely;
1078
1118
  the dev server falls back to its default banner.
1079
1119
 
1120
+ ## Mobile/devices
1121
+
1122
+ For browser testing on a phone or tablet, install Tailscale on the device,
1123
+ sign in to the same tailnet, and open:
1124
+
1125
+ \`\`\`
1126
+ ${url}
1127
+ \`\`\`
1128
+
1129
+ For the native Capacitor loop, the scaffolded \`pnpm dev:ios\` and
1130
+ \`pnpm dev:android\` scripts run the WebView against \`CAP_DEV_URL\`.
1131
+ The iOS script targets the Simulator; the Android script targets an emulator
1132
+ or attached device. Simulators/emulators use local host routes automatically.
1133
+ Android physical devices auto-pick this Tailscale URL when \`.hatchkit.json\`
1134
+ has \`localDev.slug\`; otherwise Android devices can use either:
1135
+
1136
+ \`\`\`
1137
+ LAN_IP=<your-lan-ip> pnpm dev:android
1138
+ \`\`\`
1139
+
1140
+ or, when the device is on Tailscale and this host setup is green:
1141
+
1142
+ \`\`\`
1143
+ CAP_DEV_URL=${url} pnpm dev:android
1144
+ \`\`\`
1145
+
1146
+ For a real iPhone, set \`CAP_DEV_URL=${url}\`, run \`npx cap sync ios\`,
1147
+ then launch from Xcode via \`npx cap open ios\`.
1148
+
1080
1149
  ## Cleanup
1081
1150
 
1082
1151
  If you tear down this project:
@@ -1104,6 +1173,26 @@ export async function runDevSetupCli(args) {
1104
1173
  }
1105
1174
  if (sub === "init") {
1106
1175
  const force = args.includes("--force");
1176
+ const domainFlagIdx = args.findIndex((a) => a === "--domain" || a.startsWith("--domain="));
1177
+ const rawDomain = domainFlagIdx === -1
1178
+ ? undefined
1179
+ : args[domainFlagIdx].includes("=")
1180
+ ? args[domainFlagIdx].slice("--domain=".length)
1181
+ : args[domainFlagIdx + 1];
1182
+ let localDevDomain = rawDomain ? normaliseLocalDevDomain(rawDomain) : undefined;
1183
+ if (rawDomain && !localDevDomain) {
1184
+ console.log("Usage: --domain <local-dev-domain> (for example: local.example.com)");
1185
+ process.exit(1);
1186
+ }
1187
+ if (!localDevDomain) {
1188
+ const { resolve } = await import("node:path");
1189
+ const { readManifest } = await import("./scaffold/manifest.js");
1190
+ const manifest = readManifest(resolve("."));
1191
+ localDevDomain =
1192
+ normaliseLocalDevDomain(manifest?.localDev?.domain) ??
1193
+ localDevDomainFromProjectDomain(manifest?.domain) ??
1194
+ undefined;
1195
+ }
1107
1196
  // --caddy-token-keychain <service>:<account> → wrapper mode with custom pair
1108
1197
  // --no-caddy-token-keychain → force inline-env mode
1109
1198
  // (default) → auto-detect default pair
@@ -1125,9 +1214,10 @@ export async function runDevSetupCli(args) {
1125
1214
  caddyTokenKeychain = { service, account };
1126
1215
  }
1127
1216
  }
1128
- const result = await runDevSetupInit({ force, caddyTokenKeychain });
1217
+ const result = await runDevSetupInit({ force, caddyTokenKeychain, localDevDomain });
1129
1218
  const chalk = (await import("chalk")).default;
1130
1219
  console.log(chalk.bold("\n hatchkit dev-setup init\n"));
1220
+ console.log(` Local-dev domain: ${chalk.cyan(result.localDevDomain)}`);
1131
1221
  console.log(` Caddy port: ${chalk.cyan(result.caddyPort)}`);
1132
1222
  console.log(` Caddyfile: ${result.wroteCaddyfile ? chalk.green("wrote") : chalk.dim("unchanged")} ${chalk.dim(CADDYFILE_PATH)}`);
1133
1223
  if (result.wroteWrapper) {
@@ -1144,7 +1234,7 @@ export async function runDevSetupCli(args) {
1144
1234
  : result.dnsRecord === "failed"
1145
1235
  ? chalk.red("failed")
1146
1236
  : chalk.dim("skipped (no CF token)");
1147
- console.log(` DNS A record: ${dnsLabel} ${chalk.dim(`*.${LOCAL_DEV_DOMAIN}`)}`);
1237
+ console.log(` DNS A record: ${dnsLabel} ${chalk.dim(`*.${result.localDevDomain}`)}`);
1148
1238
  }
1149
1239
  if (result.notes.length > 0) {
1150
1240
  console.log(chalk.bold("\n Notes:"));
@@ -1162,13 +1252,17 @@ export async function runDevSetupCli(args) {
1162
1252
  }
1163
1253
  const chalk = (await import("chalk")).default;
1164
1254
  for (const r of checks) {
1165
- const icon = r.status === "ok" ? chalk.green("✓") : r.status === "fail" ? chalk.red("✗") : chalk.dim("·");
1255
+ const icon = r.status === "ok"
1256
+ ? chalk.green("✓")
1257
+ : r.status === "fail"
1258
+ ? chalk.red("✗")
1259
+ : chalk.dim("·");
1166
1260
  console.log(` ${icon} ${r.name}${r.detail ? chalk.dim(` — ${r.detail}`) : ""}`);
1167
1261
  }
1168
1262
  return;
1169
1263
  }
1170
1264
  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.");
1265
+ console.log("\n init [--domain] Auto-write ~/.config/dev/Caddyfile, launchd plist, register tailscale TCP bridge.");
1172
1266
  console.log(" status Run the same checks doctor runs, but only the Local-dev rows.");
1173
1267
  console.log(" enable [--slug] Wire the project in cwd for Tailscale dev URLs (writes Caddy fragment,");
1174
1268
  console.log(" docs/dev-setup.md, patches next.config, adds plugin dep).");
@@ -1176,7 +1270,7 @@ export async function runDevSetupCli(args) {
1176
1270
  }
1177
1271
  async function runDevSetupEnableCli(args) {
1178
1272
  const chalk = (await import("chalk")).default;
1179
- const { resolve, join: joinPath } = await import("node:path");
1273
+ const { resolve } = await import("node:path");
1180
1274
  const { readManifest, writeManifest } = await import("./scaffold/manifest.js");
1181
1275
  const { sanitiseSlug } = await import("@hatchkit/dev-shared");
1182
1276
  const { input, confirm: askConfirm } = await import("@inquirer/prompts");
@@ -1198,6 +1292,12 @@ async function runDevSetupEnableCli(args) {
1198
1292
  : args[projectDirFlagIdx].includes("=")
1199
1293
  ? args[projectDirFlagIdx].slice("--project-dir=".length)
1200
1294
  : args[projectDirFlagIdx + 1];
1295
+ const domainFlagIdx = args.findIndex((a) => a === "--domain" || a.startsWith("--domain="));
1296
+ const rawLocalDevDomain = domainFlagIdx === -1
1297
+ ? undefined
1298
+ : args[domainFlagIdx].includes("=")
1299
+ ? args[domainFlagIdx].slice("--domain=".length)
1300
+ : args[domainFlagIdx + 1];
1201
1301
  const projectDir = projectDirFlag ? resolve(projectDirFlag) : resolve(".");
1202
1302
  const manifest = readManifest(projectDir);
1203
1303
  if (!manifest) {
@@ -1205,12 +1305,20 @@ async function runDevSetupEnableCli(args) {
1205
1305
  console.log(chalk.dim(` Run from a hatchkit-managed project root, or pass --project-dir <path>.`));
1206
1306
  process.exit(1);
1207
1307
  }
1308
+ const localDevDomain = normaliseLocalDevDomain(rawLocalDevDomain) ??
1309
+ normaliseLocalDevDomain(manifest.localDev?.domain) ??
1310
+ localDevDomainFromProjectDomain(manifest.domain) ??
1311
+ LOCAL_DEV_DOMAIN;
1312
+ if (rawLocalDevDomain && !normaliseLocalDevDomain(rawLocalDevDomain)) {
1313
+ console.log(chalk.red(` --domain ${rawLocalDevDomain} is not a valid local-dev domain.`));
1314
+ process.exit(1);
1315
+ }
1208
1316
  // Slug: flag → manifest.localDev → manifest.name → prompt.
1209
1317
  let slug = slugFlag ? sanitiseSlug(slugFlag) : manifest.localDev?.slug;
1210
1318
  if (!slug) {
1211
1319
  const defaultSlug = sanitiseSlug(manifest.name);
1212
1320
  slug = await input({
1213
- message: "Slug for this project (https://<slug>.local.ricoslabs.com/):",
1321
+ message: `Slug for this project (${localDevUrl("<slug>", localDevDomain)}):`,
1214
1322
  default: defaultSlug,
1215
1323
  validate: (v) => {
1216
1324
  const s = sanitiseSlug(v);
@@ -1237,8 +1345,9 @@ async function runDevSetupEnableCli(args) {
1237
1345
  }
1238
1346
  console.log(chalk.bold(`\n Enabling local-dev for ${chalk.cyan(manifest.name)}\n`));
1239
1347
  console.log(` Slug: ${chalk.cyan(slug)}`);
1348
+ console.log(` Domain: ${chalk.cyan(localDevDomain)}`);
1240
1349
  console.log(` Dev port: ${chalk.cyan(devPort)}`);
1241
- console.log(` URL: ${chalk.cyan(`https://${slug}.${LOCAL_DEV_DOMAIN}/`)}`);
1350
+ console.log(` URL: ${chalk.cyan(localDevUrl(slug, localDevDomain))}`);
1242
1351
  const skipConfirm = args.includes("--yes") || args.includes("-y");
1243
1352
  if (!skipConfirm) {
1244
1353
  const ok = await askConfirm({ message: "Proceed?", default: true });
@@ -1247,11 +1356,11 @@ async function runDevSetupEnableCli(args) {
1247
1356
  return;
1248
1357
  }
1249
1358
  }
1250
- const result = await enableProjectLocalDev({ projectDir, slug, devPort });
1359
+ const result = await enableProjectLocalDev({ projectDir, slug, localDevDomain, devPort });
1251
1360
  // Persist the slug in the manifest so subsequent runs (plugin, doctor,
1252
1361
  // future `dev-setup disable`) all converge on the same identity.
1253
- if (manifest.localDev?.slug !== slug) {
1254
- const updated = { ...manifest, localDev: { slug } };
1362
+ if (manifest.localDev?.slug !== slug || manifest.localDev?.domain !== localDevDomain) {
1363
+ const updated = { ...manifest, localDev: { slug, domain: localDevDomain } };
1255
1364
  writeManifest(projectDir, updated);
1256
1365
  }
1257
1366
  console.log(`\n Framework: ${chalk.cyan(result.framework)}`);
@@ -1272,14 +1381,14 @@ async function runDevSetupEnableCli(args) {
1272
1381
  console.log(chalk.dim(" // …"));
1273
1382
  console.log(chalk.dim(" plugins: ["));
1274
1383
  console.log(chalk.dim(" // …your existing plugins"));
1275
- console.log(chalk.dim(` localDev({ slug: "${slug}" }),`));
1384
+ console.log(chalk.dim(` localDev({ slug: "${slug}", localDevDomain: "${localDevDomain}" }),`));
1276
1385
  console.log(chalk.dim(" ],"));
1277
- console.log(chalk.dim(' server: { allowedHosts: [".local.ricoslabs.com", ".ts.net", ".local"] },'));
1386
+ console.log(chalk.dim(` server: { allowedHosts: [".${localDevDomain}", ".ts.net", ".local"] },`));
1278
1387
  }
1279
1388
  if (result.patchedConfig === "unsupported-shape") {
1280
1389
  console.log(chalk.bold("\n Next config wiring (CJS / non-standard shape):"));
1281
1390
  console.log(chalk.dim(' const { withLocalDev } = require("@hatchkit/dev-plugin-next");'));
1282
- console.log(chalk.dim(` module.exports = withLocalDev(nextConfig, { slug: "${slug}" });`));
1391
+ console.log(chalk.dim(` module.exports = withLocalDev(nextConfig, { slug: "${slug}", localDevDomain: "${localDevDomain}" });`));
1283
1392
  }
1284
1393
  console.log(chalk.dim(`\n Verify with: \`hatchkit doctor\` (or \`hatchkit dev-setup status\`).`));
1285
1394
  }