hatchkit 0.1.43 → 0.1.47

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.
@@ -0,0 +1,1340 @@
1
+ /*
2
+ * `hatchkit dev-setup` — opt-in Tailscale-served dev URLs.
3
+ *
4
+ * Goal: every scaffolded project reachable from any Tailscale peer at
5
+ * https://<slug>.local.ricoslabs.com/ with no per-project DNS work,
6
+ * no port juggling, no app-side base/basePath config, and zero
7
+ * collisions between projects.
8
+ *
9
+ * Architecture (host-wide one-time setup):
10
+ *
11
+ * phone ──HTTPS──▶ <slug>.local.ricoslabs.com:443
12
+ * │ DNS CNAME → laptop.<tailnet>.ts.net
13
+ * ▼
14
+ * tailscale serve --tcp=443 (raw TCP passthrough)
15
+ * ▼
16
+ * localhost:<caddy-port> (Caddy, wildcard TLS terminator)
17
+ * │ reverse_proxy by Host header
18
+ * ▼
19
+ * localhost:<dev-port> (vite/next dev server)
20
+ *
21
+ * Shared runtime bits (fragment writer, tailscale probes, paths, slug
22
+ * resolution) live in @hatchkit/dev-shared so the dev plugins
23
+ * (@hatchkit/dev-plugin-{vite,next}) can reuse them without depending on
24
+ * the CLI. This module holds the CLI-only orchestration: plist
25
+ * generation, launchctl wiring, port picking, doctor checks, docs
26
+ * renderer.
27
+ */
28
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
29
+ import { createServer } from "node:net";
30
+ import { homedir } from "node:os";
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";
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, };
35
+ export const CADDY_LOG_PATH = join(DEV_CONFIG_DIR, "caddy.log");
36
+ export const CADDY_ERR_LOG_PATH = join(DEV_CONFIG_DIR, "caddy.err.log");
37
+ export const CADDY_WRAPPER_PATH = join(DEV_CONFIG_DIR, "caddy-wrapper.sh");
38
+ export const LAUNCHD_LABEL = "com.hatchkit.dev-caddy";
39
+ export const LAUNCHD_PLIST_PATH = join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
40
+ /** Default keychain pair used when the Caddy ACME token lives outside
41
+ * the launchd plist. When this entry exists, `dev-setup init` writes a
42
+ * wrapper-style plist that exec's a small shell script which pulls the
43
+ * token from keychain on demand — no plaintext secret in
44
+ * `~/Library/LaunchAgents/`. Override via
45
+ * `DevSetupInitOptions.caddyTokenKeychain`. */
46
+ export const DEFAULT_CADDY_KEYCHAIN_SERVICE = "caddy-dev";
47
+ export const DEFAULT_CADDY_KEYCHAIN_ACCOUNT = "cloudflare-acme";
48
+ const DEFAULT_CADDY_PORT = 9443;
49
+ const CADDY_PORT_BUMP_LIMIT = 50;
50
+ // ---------------------------------------------------------------------------
51
+ // Caddyfile + launchd plist contents
52
+ // ---------------------------------------------------------------------------
53
+ export function caddyfileContents(caddyPort) {
54
+ return `${MANAGED_MARKER}. Edit at your own risk — re-running
55
+ # \`hatchkit dev-setup init\` will overwrite this file (delete the marker
56
+ # line above to keep your edits across re-runs; doctor will then skip
57
+ # the Local-dev checks until you opt back in).
58
+
59
+ {
60
+ acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
61
+ https_port ${caddyPort}
62
+ # Disable the automatic HTTP→HTTPS redirect listener: it tries to bind
63
+ # privileged port 80, which a launchd user-session daemon can't open
64
+ # without root or a port-grant entitlement. DNS-01 ACME doesn't need
65
+ # port 80 either, so the redirect server has nothing to do here.
66
+ auto_https disable_redirects
67
+ }
68
+
69
+ https://${LOCAL_DEV_DOMAIN_WILDCARD}:${caddyPort} {
70
+ bind 127.0.0.1
71
+ # No explicit \`tls\` directive: the site address already names the
72
+ # wildcard subject, and the global \`acme_dns cloudflare\` block
73
+ # drives DNS-01 issuance. Adding \`tls *.local.ricoslabs.com\` here
74
+ # would be parsed as the email-or-keyword form and Caddy 2 rejects
75
+ # it ("single argument must either be 'internal', 'force_automate',
76
+ # or an email address").
77
+ import ${PROJECTS_DIR}/*.caddy
78
+ }
79
+ `;
80
+ }
81
+ function launchdPlistContents(caddyBinPath, cloudflareToken) {
82
+ const env = cloudflareToken
83
+ ? ` <key>EnvironmentVariables</key>
84
+ <dict>
85
+ <key>CLOUDFLARE_API_TOKEN</key>
86
+ <string>${escapeXml(cloudflareToken)}</string>
87
+ </dict>
88
+ `
89
+ : "";
90
+ return `<?xml version="1.0" encoding="UTF-8"?>
91
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
92
+ <plist version="1.0">
93
+ <dict>
94
+ <key>Label</key>
95
+ <string>${LAUNCHD_LABEL}</string>
96
+ <key>ProgramArguments</key>
97
+ <array>
98
+ <string>${caddyBinPath}</string>
99
+ <string>run</string>
100
+ <string>--watch</string>
101
+ <string>--config</string>
102
+ <string>${CADDYFILE_PATH}</string>
103
+ </array>
104
+ <key>RunAtLoad</key>
105
+ <true/>
106
+ <key>KeepAlive</key>
107
+ <true/>
108
+ <key>StandardOutPath</key>
109
+ <string>${CADDY_LOG_PATH}</string>
110
+ <key>StandardErrorPath</key>
111
+ <string>${CADDY_ERR_LOG_PATH}</string>
112
+ ${env}</dict>
113
+ </plist>
114
+ `;
115
+ }
116
+ /** Wrapper-style plist: launchd runs a tiny shell script that pulls
117
+ * CLOUDFLARE_API_TOKEN from keychain on each Caddy start and then
118
+ * exec's the real caddy binary. Keeps the token out of the plist
119
+ * (and therefore out of Time Machine backups, etc.). */
120
+ function launchdPlistContentsWrapped(wrapperPath) {
121
+ return `<?xml version="1.0" encoding="UTF-8"?>
122
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
123
+ <plist version="1.0">
124
+ <dict>
125
+ <key>Label</key>
126
+ <string>${LAUNCHD_LABEL}</string>
127
+ <key>ProgramArguments</key>
128
+ <array>
129
+ <string>${wrapperPath}</string>
130
+ </array>
131
+ <key>RunAtLoad</key>
132
+ <true/>
133
+ <key>KeepAlive</key>
134
+ <true/>
135
+ <key>StandardOutPath</key>
136
+ <string>${CADDY_LOG_PATH}</string>
137
+ <key>StandardErrorPath</key>
138
+ <string>${CADDY_ERR_LOG_PATH}</string>
139
+ </dict>
140
+ </plist>
141
+ `;
142
+ }
143
+ function caddyWrapperContents(caddyBinPath, keychainService, keychainAccount) {
144
+ return `#!/bin/zsh
145
+ # Managed by hatchkit dev-setup. Edit at your own risk — re-running
146
+ # \`hatchkit dev-setup init\` rewrites this file.
147
+ #
148
+ # Fetches the Cloudflare ACME token from the macOS Keychain at startup
149
+ # so the launchd plist doesn't have to embed a plaintext secret. launchd
150
+ # re-exec's this script every time Caddy restarts, so token rotation in
151
+ # keychain is picked up on the next reload.
152
+
153
+ set -euo pipefail
154
+ token=$(/usr/bin/security find-generic-password -s ${shellQuote(keychainService)} -a ${shellQuote(keychainAccount)} -w 2>/dev/null || true)
155
+ if [ -z "$token" ]; then
156
+ echo "caddy-wrapper: missing keychain entry ${keychainService}/${keychainAccount}." >&2
157
+ echo " Store the Cloudflare ACME token with:" >&2
158
+ echo " security add-generic-password -s ${keychainService} -a ${keychainAccount} -w '<token>' -U" >&2
159
+ exit 78
160
+ fi
161
+ export CLOUDFLARE_API_TOKEN="$token"
162
+ exec ${shellQuote(caddyBinPath)} run --watch --config ${shellQuote(CADDYFILE_PATH)}
163
+ `;
164
+ }
165
+ function shellQuote(s) {
166
+ // POSIX single-quote escape: a single literal apostrophe inside
167
+ // single-quoted text is impossible, so we close-quote, insert an
168
+ // escaped apostrophe, and re-open. Inputs here (paths, keychain
169
+ // service/account) shouldn't contain quotes in practice, but the
170
+ // round-trip stays safe if they ever do.
171
+ return `'${s.replace(/'/g, "'\\''")}'`;
172
+ }
173
+ function escapeXml(s) {
174
+ return s
175
+ .replace(/&/g, "&amp;")
176
+ .replace(/</g, "&lt;")
177
+ .replace(/>/g, "&gt;")
178
+ .replace(/"/g, "&quot;");
179
+ }
180
+ // ---------------------------------------------------------------------------
181
+ // Port probing
182
+ // ---------------------------------------------------------------------------
183
+ export async function pickFreeCaddyPort() {
184
+ for (let p = DEFAULT_CADDY_PORT; p < DEFAULT_CADDY_PORT + CADDY_PORT_BUMP_LIMIT; p++) {
185
+ if (await isPortFree(p))
186
+ return p;
187
+ }
188
+ return null;
189
+ }
190
+ function isPortFree(port) {
191
+ return new Promise((resolve) => {
192
+ const srv = createServer();
193
+ srv.once("error", () => {
194
+ resolve(false);
195
+ });
196
+ srv.once("listening", () => {
197
+ srv.close(() => resolve(true));
198
+ });
199
+ srv.listen(port, "127.0.0.1");
200
+ });
201
+ }
202
+ // ---------------------------------------------------------------------------
203
+ // Doctor checks
204
+ // ---------------------------------------------------------------------------
205
+ export async function checkLocalDevHost() {
206
+ if (!isLocalDevActive())
207
+ return [];
208
+ const out = [];
209
+ const caddyPort = readCaddyPort();
210
+ const tsId = await tailscaleIdentity();
211
+ if (!tsId) {
212
+ out.push({
213
+ name: "Local-dev / Tailscale daemon",
214
+ status: "fail",
215
+ detail: "tailscale CLI missing or daemon offline",
216
+ hint: [
217
+ "Install Tailscale and sign in once: https://tailscale.com/download",
218
+ "Then run `tailscale status` and confirm it reports your tailnet identity.",
219
+ ],
220
+ });
221
+ }
222
+ else {
223
+ out.push({
224
+ name: "Local-dev / Tailscale daemon",
225
+ status: "ok",
226
+ detail: `${tsId.fullName} (${tsId.ip})`,
227
+ });
228
+ }
229
+ const caddyAvailable = await execOk("caddy", ["version"]);
230
+ if (!caddyAvailable) {
231
+ out.push({
232
+ name: "Local-dev / Caddy installed",
233
+ status: "fail",
234
+ detail: "caddy CLI not on PATH",
235
+ hint: [
236
+ "Install: `brew install caddy` (macOS).",
237
+ "Caddy needs the caddy-dns/cloudflare plugin compiled in for DNS-01 ACME.",
238
+ "Brew's stock Caddy includes it on recent versions; otherwise rebuild via xcaddy.",
239
+ ],
240
+ });
241
+ }
242
+ else {
243
+ out.push({ name: "Local-dev / Caddy installed", status: "ok" });
244
+ }
245
+ if (caddyAvailable) {
246
+ const modules = await exec("caddy", ["list-modules"], { silent: true });
247
+ const hasPlugin = /dns\.providers\.cloudflare/i.test(modules.stdout);
248
+ if (!hasPlugin) {
249
+ out.push({
250
+ name: "Local-dev / Caddy cloudflare plugin",
251
+ status: "fail",
252
+ detail: "dns.providers.cloudflare module not loaded",
253
+ hint: [
254
+ "Rebuild Caddy with the Cloudflare DNS plugin:",
255
+ " go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest",
256
+ " xcaddy build --with github.com/caddy-dns/cloudflare",
257
+ "Replace the brew binary (or put the xcaddy output earlier on PATH) and reload Caddy.",
258
+ ],
259
+ });
260
+ }
261
+ else {
262
+ out.push({ name: "Local-dev / Caddy cloudflare plugin", status: "ok" });
263
+ }
264
+ }
265
+ const plistOk = existsSync(LAUNCHD_PLIST_PATH);
266
+ if (!plistOk) {
267
+ out.push({
268
+ name: "Local-dev / launchd plist",
269
+ status: "fail",
270
+ detail: `${LAUNCHD_PLIST_PATH} not found`,
271
+ hint: ["Run `hatchkit dev-setup init` to write the launchd plist."],
272
+ });
273
+ }
274
+ else {
275
+ // Two valid plist shapes:
276
+ // 1. Inline env: `EnvironmentVariables` dict contains CLOUDFLARE_API_TOKEN.
277
+ // 2. Wrapper: ProgramArguments points at CADDY_WRAPPER_PATH; token
278
+ // lives in keychain.
279
+ // Probe accordingly so doctor doesn't false-flag the wrapper path.
280
+ const plist = readFileSync(LAUNCHD_PLIST_PATH, "utf-8");
281
+ const usesWrapper = plist.includes(CADDY_WRAPPER_PATH);
282
+ if (usesWrapper) {
283
+ if (!existsSync(CADDY_WRAPPER_PATH)) {
284
+ out.push({
285
+ name: "Local-dev / Caddy wrapper script",
286
+ status: "fail",
287
+ detail: `${CADDY_WRAPPER_PATH} referenced by plist but missing on disk`,
288
+ hint: ["Re-run `hatchkit dev-setup init` to regenerate the wrapper."],
289
+ });
290
+ }
291
+ else {
292
+ const wrapper = readFileSync(CADDY_WRAPPER_PATH, "utf-8");
293
+ const m = wrapper.match(/security find-generic-password -s '([^']+)' -a '([^']+)'/);
294
+ const service = m?.[1] ?? DEFAULT_CADDY_KEYCHAIN_SERVICE;
295
+ const account = m?.[2] ?? DEFAULT_CADDY_KEYCHAIN_ACCOUNT;
296
+ if (!(await keychainEntryExists(service, account))) {
297
+ out.push({
298
+ name: "Local-dev / Cloudflare ACME token in keychain",
299
+ status: "fail",
300
+ detail: `keychain entry ${service}/${account} not found`,
301
+ hint: [
302
+ "Store the Cloudflare ACME token in the keychain:",
303
+ ` security add-generic-password -s ${service} -a ${account} -w '<token>' -U`,
304
+ "Then reload Caddy:",
305
+ ` launchctl unload ${LAUNCHD_PLIST_PATH} && launchctl load -w ${LAUNCHD_PLIST_PATH}`,
306
+ ],
307
+ });
308
+ }
309
+ else {
310
+ out.push({
311
+ name: "Local-dev / Cloudflare ACME token in keychain",
312
+ status: "ok",
313
+ detail: `${service}/${account}`,
314
+ });
315
+ }
316
+ }
317
+ }
318
+ else {
319
+ const hasToken = /<key>CLOUDFLARE_API_TOKEN<\/key>\s*<string>[^<]+<\/string>/.test(plist);
320
+ if (!hasToken) {
321
+ out.push({
322
+ name: "Local-dev / Cloudflare API token in plist",
323
+ status: "fail",
324
+ detail: "launchd plist has no CLOUDFLARE_API_TOKEN entry",
325
+ hint: [
326
+ "Configure DNS first: `hatchkit config add dns` (Cloudflare token with Zone:DNS:Edit).",
327
+ "Then re-run: `hatchkit dev-setup init` to refresh the plist.",
328
+ "Or switch to keychain-backed storage:",
329
+ ` security add-generic-password -s ${DEFAULT_CADDY_KEYCHAIN_SERVICE} -a ${DEFAULT_CADDY_KEYCHAIN_ACCOUNT} -w '<token>' -U`,
330
+ "then re-run `dev-setup init` (it'll generate a wrapper-style plist).",
331
+ ],
332
+ });
333
+ }
334
+ else {
335
+ out.push({ name: "Local-dev / Cloudflare API token in plist", status: "ok" });
336
+ }
337
+ }
338
+ }
339
+ const launchctlList = await exec("launchctl", ["list", LAUNCHD_LABEL], { silent: true });
340
+ if (launchctlList.exitCode !== 0) {
341
+ out.push({
342
+ name: "Local-dev / Caddy launchd job",
343
+ status: "fail",
344
+ detail: `launchctl reports ${LAUNCHD_LABEL} not loaded`,
345
+ hint: [
346
+ "Load it:",
347
+ ` launchctl load -w ${LAUNCHD_PLIST_PATH}`,
348
+ "Or re-run: `hatchkit dev-setup init` (idempotent).",
349
+ ],
350
+ });
351
+ }
352
+ else {
353
+ out.push({
354
+ name: "Local-dev / Caddy launchd job",
355
+ status: "ok",
356
+ detail: `${LAUNCHD_LABEL} loaded`,
357
+ });
358
+ }
359
+ if (tsId) {
360
+ // DNS A record probe. Skipped silently when no Cloudflare token is
361
+ // reachable (manual DNS setups) — that's a separate failure mode
362
+ // that the user already knows about, not a regression we should
363
+ // re-surface every doctor run.
364
+ const dnsCheck = await checkLocalDevDnsRecord(tsId.ip);
365
+ if (dnsCheck)
366
+ out.push(dnsCheck);
367
+ }
368
+ if (!caddyPort) {
369
+ out.push({
370
+ name: "Local-dev / Tailscale serve bridge",
371
+ status: "fail",
372
+ detail: "couldn't parse https_port from Caddyfile",
373
+ hint: ["Re-run `hatchkit dev-setup init` to rewrite the Caddyfile."],
374
+ });
375
+ }
376
+ else if (!tsId) {
377
+ out.push({ name: "Local-dev / Tailscale serve bridge", status: "skip" });
378
+ }
379
+ else {
380
+ const tcpTarget = await tailscaleServeTcpTarget();
381
+ if (tcpTarget === null) {
382
+ out.push({
383
+ name: "Local-dev / Tailscale serve bridge",
384
+ status: "fail",
385
+ detail: "no tcp:443 serve entry registered",
386
+ hint: [
387
+ "Register the one-shot host bridge:",
388
+ ` tailscale serve --bg --tcp=443 tcp://localhost:${caddyPort}`,
389
+ "Or re-run: `hatchkit dev-setup init` (registers it automatically).",
390
+ ],
391
+ });
392
+ }
393
+ else if (tcpTarget !== caddyPort) {
394
+ out.push({
395
+ name: "Local-dev / Tailscale serve bridge",
396
+ status: "fail",
397
+ detail: `tcp:443 points at localhost:${tcpTarget} but Caddyfile binds ${caddyPort}`,
398
+ hint: [
399
+ "Re-register the bridge against the current Caddy port:",
400
+ " tailscale serve reset",
401
+ ` tailscale serve --bg --tcp=443 tcp://localhost:${caddyPort}`,
402
+ "Or re-run: `hatchkit dev-setup init` to reconcile.",
403
+ ],
404
+ });
405
+ }
406
+ else {
407
+ out.push({
408
+ name: "Local-dev / Tailscale serve bridge",
409
+ status: "ok",
410
+ detail: `tcp:443 → localhost:${caddyPort}`,
411
+ });
412
+ }
413
+ }
414
+ return out;
415
+ }
416
+ export async function runDevSetupInit(opts = {}) {
417
+ if (!existsSync(PROJECTS_DIR))
418
+ mkdirSync(PROJECTS_DIR, { recursive: true });
419
+ let caddyPort = opts.force ? null : readCaddyPort();
420
+ if (caddyPort === null) {
421
+ caddyPort = await pickFreeCaddyPort();
422
+ if (caddyPort === null) {
423
+ throw new Error(`No free port found in [${DEFAULT_CADDY_PORT}, ${DEFAULT_CADDY_PORT + CADDY_PORT_BUMP_LIMIT})`);
424
+ }
425
+ }
426
+ const notes = [];
427
+ // Caddyfile. Refuse to clobber a hand-rolled (unmanaged) Caddyfile
428
+ // unless --force is set; the user may be running their own dev domain
429
+ // setup we shouldn't trample on first encounter.
430
+ let wroteCaddyfile = false;
431
+ const nextCaddyfile = caddyfileContents(caddyPort);
432
+ if (existsSync(CADDYFILE_PATH)) {
433
+ const existing = readFileSync(CADDYFILE_PATH, "utf-8");
434
+ if (!existing.includes(MANAGED_MARKER) && !opts.force) {
435
+ throw new Error(`${CADDYFILE_PATH} exists but is not hatchkit-managed (no marker line). ` +
436
+ `Re-run with --force to overwrite, or delete the file first.`);
437
+ }
438
+ if (existing !== nextCaddyfile) {
439
+ writeFileSync(CADDYFILE_PATH, nextCaddyfile);
440
+ wroteCaddyfile = true;
441
+ }
442
+ }
443
+ else {
444
+ if (!existsSync(DEV_CONFIG_DIR))
445
+ mkdirSync(DEV_CONFIG_DIR, { recursive: true });
446
+ writeFileSync(CADDYFILE_PATH, nextCaddyfile);
447
+ wroteCaddyfile = true;
448
+ }
449
+ let wrotePlist = false;
450
+ let wroteWrapper = false;
451
+ const caddyBinPath = opts.caddyBinPath ?? (await resolveCaddyBin());
452
+ // Wrapper-mode resolution. Explicit `false` opts out. Explicit pair
453
+ // opts in. Undefined falls back to auto-detect: if the default
454
+ // keychain pair is reachable via `security`, prefer wrapper mode so
455
+ // the secret stays off the launchd plist.
456
+ let keychainPair = null;
457
+ if (opts.caddyTokenKeychain === false) {
458
+ keychainPair = null;
459
+ }
460
+ else if (opts.caddyTokenKeychain) {
461
+ keychainPair = opts.caddyTokenKeychain;
462
+ }
463
+ else {
464
+ const exists = await keychainEntryExists(DEFAULT_CADDY_KEYCHAIN_SERVICE, DEFAULT_CADDY_KEYCHAIN_ACCOUNT);
465
+ if (exists) {
466
+ keychainPair = {
467
+ service: DEFAULT_CADDY_KEYCHAIN_SERVICE,
468
+ account: DEFAULT_CADDY_KEYCHAIN_ACCOUNT,
469
+ };
470
+ }
471
+ }
472
+ if (!caddyBinPath) {
473
+ notes.push("caddy CLI not on PATH — skipping plist write. Install caddy then re-run.");
474
+ }
475
+ else if (keychainPair) {
476
+ // Wrapper-style plist + helper script. Token stays in keychain.
477
+ const nextWrapper = caddyWrapperContents(caddyBinPath, keychainPair.service, keychainPair.account);
478
+ if (!existsSync(CADDY_WRAPPER_PATH) ||
479
+ readFileSync(CADDY_WRAPPER_PATH, "utf-8") !== nextWrapper) {
480
+ writeFileSync(CADDY_WRAPPER_PATH, nextWrapper);
481
+ const { chmodSync } = await import("node:fs");
482
+ chmodSync(CADDY_WRAPPER_PATH, 0o700);
483
+ wroteWrapper = true;
484
+ }
485
+ const nextPlist = launchdPlistContentsWrapped(CADDY_WRAPPER_PATH);
486
+ if (!existsSync(LAUNCHD_PLIST_PATH) || readFileSync(LAUNCHD_PLIST_PATH, "utf-8") !== nextPlist) {
487
+ writeFileSync(LAUNCHD_PLIST_PATH, nextPlist);
488
+ wrotePlist = true;
489
+ }
490
+ notes.push(`Cloudflare ACME token will be read from keychain ${keychainPair.service}/${keychainPair.account} at Caddy startup.`);
491
+ }
492
+ else {
493
+ // Legacy inline-env path: read token from hatchkit's DNS config and
494
+ // embed in the plist's EnvironmentVariables. Preserved for users who
495
+ // haven't set up the keychain entry yet.
496
+ const token = opts.cloudflareToken === undefined
497
+ ? await readCloudflareTokenFromConfig()
498
+ : opts.cloudflareToken;
499
+ if (!token) {
500
+ notes.push("No Cloudflare API token in hatchkit's DNS config — plist will lack CLOUDFLARE_API_TOKEN.");
501
+ 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
+ }
503
+ const nextPlist = launchdPlistContents(caddyBinPath, token ?? null);
504
+ if (!existsSync(LAUNCHD_PLIST_PATH) || readFileSync(LAUNCHD_PLIST_PATH, "utf-8") !== nextPlist) {
505
+ writeFileSync(LAUNCHD_PLIST_PATH, nextPlist);
506
+ wrotePlist = true;
507
+ }
508
+ }
509
+ let loadedLaunchd = false;
510
+ if (!opts.skipLaunchd && wrotePlist) {
511
+ await exec("launchctl", ["unload", LAUNCHD_PLIST_PATH], { silent: true });
512
+ const loadRes = await exec("launchctl", ["load", "-w", LAUNCHD_PLIST_PATH], { silent: true });
513
+ loadedLaunchd = loadRes.exitCode === 0;
514
+ if (!loadedLaunchd)
515
+ notes.push(`launchctl load failed: ${loadRes.stderr || loadRes.stdout}`);
516
+ }
517
+ let registeredServe = false;
518
+ if (!opts.skipServe) {
519
+ const current = await tailscaleServeTcpTarget();
520
+ if (current !== caddyPort) {
521
+ const serveRes = await exec("tailscale", ["serve", "--bg", "--tcp=443", `tcp://localhost:${caddyPort}`], { silent: true });
522
+ registeredServe = serveRes.exitCode === 0;
523
+ if (!registeredServe) {
524
+ notes.push(`tailscale serve register failed: ${serveRes.stderr || serveRes.stdout}`);
525
+ }
526
+ }
527
+ else {
528
+ registeredServe = true;
529
+ }
530
+ }
531
+ // DNS: ensure *.local.ricoslabs.com → laptop's tailnet IP (A record,
532
+ // DNS-only). Without this every project URL hits whatever the parent
533
+ // zone's wildcard says (typically the Coolify host IP, proxied — which
534
+ // doesn't reach the tailnet) and phone requests die at the TLS
535
+ // handshake. CNAMEing to the .ts.net MagicDNS name would also work,
536
+ // but only when each peer has Tailscale's resolver in front of its
537
+ // public DNS — fragile on iOS, where stub resolvers cache the
538
+ // intermediate NXDOMAIN. Direct A record is the bulletproof shape.
539
+ const dnsRecord = await ensureLocalDevDnsRecord(opts, notes);
540
+ return {
541
+ caddyPort,
542
+ wroteCaddyfile,
543
+ wrotePlist,
544
+ wroteWrapper,
545
+ loadedLaunchd,
546
+ registeredServe,
547
+ dnsRecord,
548
+ notes,
549
+ };
550
+ }
551
+ /** Upsert the `*.local.ricoslabs.com` A record to the laptop's current
552
+ * tailnet IP. Idempotent. Returns the action taken (or a reason it
553
+ * was skipped). All failures are non-fatal — they only nudge the
554
+ * user toward the manual command. */
555
+ async function ensureLocalDevDnsRecord(opts, notes) {
556
+ const identity = await tailscaleIdentity();
557
+ if (!identity) {
558
+ notes.push("DNS record skipped: tailscale daemon offline (no IP to point the record at).");
559
+ return null;
560
+ }
561
+ // Token resolution order:
562
+ // 1. Explicit (opts.cloudflareToken) — same field plist embedding uses.
563
+ // 2. hatchkit's DNS config (`hatchkit config add dns`).
564
+ // 3. The Caddy ACME keychain entry the wrapper script reads — same
565
+ // Zone:DNS:Edit scope, no need for a second token in keychain.
566
+ let token;
567
+ if (opts.cloudflareToken !== undefined) {
568
+ token = opts.cloudflareToken;
569
+ }
570
+ else {
571
+ token = (await readCloudflareTokenFromConfig()) ?? (await readCloudflareTokenFromKeychain());
572
+ }
573
+ if (!token) {
574
+ notes.push("DNS record skipped: no Cloudflare token reachable. Configure via `hatchkit config add dns` or set keychain entry caddy-dev/cloudflare-acme.");
575
+ return "skipped-no-token";
576
+ }
577
+ try {
578
+ const { CloudflareApi } = await import("./utils/cloudflare-api.js");
579
+ const cf = new CloudflareApi({ token });
580
+ const zone = await resolveZoneForName(cf, `${LOCAL_DEV_DOMAIN_WILDCARD}`);
581
+ if (!zone) {
582
+ notes.push(`DNS record skipped: no Cloudflare zone found for ${LOCAL_DEV_DOMAIN}. The token may lack Zone:Zone:Read scope.`);
583
+ return "failed";
584
+ }
585
+ const upsert = await cf.upsertRecord(zone.id, {
586
+ type: "A",
587
+ name: LOCAL_DEV_DOMAIN_WILDCARD,
588
+ content: identity.ip,
589
+ proxied: false,
590
+ ttl: 60,
591
+ });
592
+ if (upsert.created)
593
+ return "created";
594
+ if (upsert.updated)
595
+ return "updated";
596
+ return "unchanged";
597
+ }
598
+ catch (err) {
599
+ notes.push(`DNS record failed: ${err.message}`);
600
+ return "failed";
601
+ }
602
+ }
603
+ /** Walk the candidate label chain looking for a Cloudflare zone we can
604
+ * manage. For `*.local.ricoslabs.com` this tries `local.ricoslabs.com`
605
+ * 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
607
+ * chain symmetrically). Returns the first hit. */
608
+ async function resolveZoneForName(cf, name) {
609
+ const labels = name.replace(/^\*\./, "").split(".");
610
+ for (let i = 0; i < labels.length - 1; i++) {
611
+ const candidate = labels.slice(i).join(".");
612
+ const zone = await cf.getZoneByName(candidate);
613
+ if (zone)
614
+ return { id: zone.id, name: zone.name };
615
+ }
616
+ return null;
617
+ }
618
+ /** Probe the live `*.local.ricoslabs.com` Cloudflare record. Returns
619
+ * null when there's no token to check with — that's a configuration
620
+ * state, not a failure. Otherwise reports drift between the record
621
+ * and the laptop's current tailnet IP. */
622
+ async function checkLocalDevDnsRecord(currentIp) {
623
+ const token = (await readCloudflareTokenFromConfig()) ?? (await readCloudflareTokenFromKeychain());
624
+ if (!token)
625
+ return null;
626
+ try {
627
+ const { CloudflareApi } = await import("./utils/cloudflare-api.js");
628
+ const cf = new CloudflareApi({ token });
629
+ const zone = await resolveZoneForName(cf, LOCAL_DEV_DOMAIN_WILDCARD);
630
+ if (!zone) {
631
+ return {
632
+ name: `Local-dev / DNS A record`,
633
+ status: "fail",
634
+ detail: `no Cloudflare zone found for ${LOCAL_DEV_DOMAIN}`,
635
+ hint: [
636
+ "Token may lack Zone:Zone:Read on the parent zone, or the zone isn't on Cloudflare.",
637
+ "Add the record manually if you're using a different DNS provider:",
638
+ ` *.local.ricoslabs.com A ${currentIp} (DNS-only / unproxied, TTL 60)`,
639
+ ],
640
+ };
641
+ }
642
+ const record = await cf.findRecord(zone.id, LOCAL_DEV_DOMAIN_WILDCARD, "A");
643
+ if (!record) {
644
+ return {
645
+ name: "Local-dev / DNS A record",
646
+ 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
+ ],
651
+ };
652
+ }
653
+ if (record.content !== currentIp) {
654
+ return {
655
+ name: "Local-dev / DNS A record",
656
+ status: "fail",
657
+ detail: `record points at ${record.content} but tailnet IP is ${currentIp}`,
658
+ hint: [
659
+ "Tailnet IP changed (new node, reinstall). Re-run `hatchkit dev-setup init` to update.",
660
+ ],
661
+ };
662
+ }
663
+ if (record.proxied) {
664
+ return {
665
+ name: "Local-dev / DNS A record",
666
+ status: "fail",
667
+ detail: "record is Cloudflare-proxied (orange-cloud) — must be DNS-only",
668
+ hint: [
669
+ "Proxied records terminate TLS at Cloudflare and can't reach the tailnet.",
670
+ "Turn off the orange cloud on this record, or re-run `hatchkit dev-setup init` (it'll recreate as DNS-only).",
671
+ ],
672
+ };
673
+ }
674
+ return {
675
+ name: "Local-dev / DNS A record",
676
+ status: "ok",
677
+ detail: `${LOCAL_DEV_DOMAIN_WILDCARD} → ${currentIp} (DNS-only)`,
678
+ };
679
+ }
680
+ catch (err) {
681
+ return {
682
+ name: "Local-dev / DNS A record",
683
+ status: "fail",
684
+ detail: err.message.split("\n")[0],
685
+ hint: ["Cloudflare API call failed — token may be invalid or revoked."],
686
+ };
687
+ }
688
+ }
689
+ async function readCloudflareTokenFromKeychain() {
690
+ const res = await exec("security", [
691
+ "find-generic-password",
692
+ "-s",
693
+ DEFAULT_CADDY_KEYCHAIN_SERVICE,
694
+ "-a",
695
+ DEFAULT_CADDY_KEYCHAIN_ACCOUNT,
696
+ "-w",
697
+ ], { silent: true });
698
+ if (res.exitCode !== 0)
699
+ return null;
700
+ const out = res.stdout.trim();
701
+ return out.length > 0 ? out : null;
702
+ }
703
+ async function keychainEntryExists(service, account) {
704
+ // `security find-generic-password` exits 0 when the entry exists. We
705
+ // don't ask for the value (`-w`) — existence is enough. Suppress
706
+ // stderr so the "not found" line doesn't show up in normal runs.
707
+ const res = await exec("security", ["find-generic-password", "-s", service, "-a", account], {
708
+ silent: true,
709
+ });
710
+ return res.exitCode === 0;
711
+ }
712
+ async function resolveCaddyBin() {
713
+ const res = await exec("which", ["caddy"], { silent: true });
714
+ if (res.exitCode !== 0)
715
+ return null;
716
+ const path = res.stdout.trim();
717
+ return path || null;
718
+ }
719
+ async function readCloudflareTokenFromConfig() {
720
+ try {
721
+ const { getDnsConfig } = await import("./config.js");
722
+ const cfg = await getDnsConfig();
723
+ return cfg?.apiToken ?? null;
724
+ }
725
+ catch {
726
+ return null;
727
+ }
728
+ }
729
+ // ---------------------------------------------------------------------------
730
+ // Per-project enable/disable — shared by the scaffold postinit hook
731
+ // and the `hatchkit dev-setup enable` subcommand for retrofitting
732
+ // existing projects.
733
+ // ---------------------------------------------------------------------------
734
+ export { projectFragmentPath, removeProjectFragment, writeProjectFragment };
735
+ /** Version range we write into scaffolded `package.json` for the dev
736
+ * plugins. Locks to the CLI's own version (the release pipeline bumps
737
+ * every `@hatchkit/dev-*` package in lockstep with the CLI), so a
738
+ * user who installed `hatchkit@x.y.z` gets a scaffold that pulls
739
+ * `@hatchkit/dev-plugin-{next,vite}@^x.y.z` — guaranteed to exist on
740
+ * npm. Local-workspace development inside this monorepo bypasses the
741
+ * range via pnpm's workspace resolution. */
742
+ async function pluginVersionRange() {
743
+ const { getCliVersion } = await import("./utils/version.js");
744
+ return `^${getCliVersion()}`;
745
+ }
746
+ /** Wire a single project for Tailscale-served local dev. Idempotent —
747
+ * safe to call from scaffold (first run) AND from `dev-setup enable`
748
+ * on an already-wired project. */
749
+ export async function enableProjectLocalDev(input) {
750
+ const wroteFragment = writeProjectFragment(input.slug, input.devPort);
751
+ const docsPath = join(input.projectDir, "docs", "dev-setup.md");
752
+ const docsContent = renderDevSetupDocs({
753
+ slug: input.slug,
754
+ tailscale: await tailscaleIdentity(),
755
+ });
756
+ let wroteDocs = false;
757
+ if (!existsSync(docsPath) || readFileSync(docsPath, "utf-8") !== docsContent) {
758
+ const docsDir = join(input.projectDir, "docs");
759
+ if (!existsSync(docsDir))
760
+ mkdirSync(docsDir, { recursive: true });
761
+ writeFileSync(docsPath, docsContent);
762
+ wroteDocs = true;
763
+ }
764
+ // Framework detection: prefer Next when a next.config is reachable —
765
+ // the patcher handles ESM `export default` shapes automatically.
766
+ // Otherwise fall back to Vite. Vite configs come in too many shapes
767
+ // (defineConfig fn, plugin arrays in different positions, env-gated
768
+ // builds) for safe automated patching, so we inject the dep and
769
+ // surface a `manual-wire-required` flag for the caller to print
770
+ // instructions. Projects that are neither (server-only, exotic) get
771
+ // `none` and we skip the patch step entirely.
772
+ const framework = findNextConfig(input.projectDir)
773
+ ? "next"
774
+ : findViteConfig(input.projectDir)
775
+ ? "vite"
776
+ : "none";
777
+ let patchedConfig;
778
+ let patchedPackageJson;
779
+ if (input.patchNextConfig === false) {
780
+ patchedConfig = "skipped";
781
+ }
782
+ else if (framework === "next") {
783
+ patchedConfig = patchNextConfigWithLocalDev(input.projectDir, input.slug);
784
+ }
785
+ else if (framework === "vite") {
786
+ patchedConfig = "manual-wire-required";
787
+ }
788
+ else {
789
+ patchedConfig = "no-file";
790
+ }
791
+ if (input.patchPackageJson === false) {
792
+ patchedPackageJson = "skipped";
793
+ }
794
+ else {
795
+ const versionRange = await pluginVersionRange();
796
+ if (framework === "next") {
797
+ patchedPackageJson = patchPluginPackageJsonDep(input.projectDir, versionRange, "next");
798
+ }
799
+ else if (framework === "vite") {
800
+ patchedPackageJson = patchPluginPackageJsonDep(input.projectDir, versionRange, "vite");
801
+ }
802
+ else {
803
+ patchedPackageJson = "no-file";
804
+ }
805
+ }
806
+ return { wroteFragment, wroteDocs, framework, patchedConfig, patchedPackageJson };
807
+ }
808
+ /** Remove the project's Caddy fragment + the docs file. Leaves the
809
+ * next.config wrapper + the plugin dep in place — they're inert when
810
+ * `~/.config/dev/projects/<slug>.caddy` is gone, and ripping them
811
+ * back out risks colliding with user edits to either file. */
812
+ export function disableProjectLocalDev(projectDir, slug) {
813
+ const removedFragment = removeProjectFragment(slug);
814
+ const docsPath = join(projectDir, "docs", "dev-setup.md");
815
+ let removedDocs = false;
816
+ if (existsSync(docsPath)) {
817
+ rmSync(docsPath);
818
+ removedDocs = true;
819
+ }
820
+ return { removedFragment, removedDocs };
821
+ }
822
+ /** Subdirectories scanned for a Next config (in order). The first
823
+ * match wins. Covers standard hatchkit layout (`packages/client`),
824
+ * flat layout (project root), and common monorepo conventions for
825
+ * the next-bearing package (`showcase`, `web`, `site`, `app`, `docs`,
826
+ * `apps/web`). Caller can short-circuit by passing the exact path
827
+ * via the patcher's caller — this scan is the fallback. */
828
+ const FRAMEWORK_CONFIG_SUBDIRS = [
829
+ "packages/client",
830
+ "",
831
+ "showcase",
832
+ "web",
833
+ "site",
834
+ "app",
835
+ "docs",
836
+ "apps/web",
837
+ "apps/site",
838
+ ];
839
+ const NEXT_CONFIG_FILENAMES = ["next.config.ts", "next.config.mjs", "next.config.js"];
840
+ const VITE_CONFIG_FILENAMES = ["vite.config.ts", "vite.config.mjs", "vite.config.js"];
841
+ /** Locate the project's Next config file. Returns the absolute path of
842
+ * the first hit found by walking `FRAMEWORK_CONFIG_SUBDIRS × NEXT_CONFIG_FILENAMES`,
843
+ * or null when nothing matches. Exposed so the package.json patcher
844
+ * can target the sibling `package.json` rather than a different layout. */
845
+ function findNextConfig(projectDir) {
846
+ for (const sub of FRAMEWORK_CONFIG_SUBDIRS) {
847
+ for (const name of NEXT_CONFIG_FILENAMES) {
848
+ const candidate = join(projectDir, sub, name);
849
+ if (existsSync(candidate))
850
+ return candidate;
851
+ }
852
+ }
853
+ return null;
854
+ }
855
+ /** Locate the project's Vite config file. Mirrors `findNextConfig`. */
856
+ function findViteConfig(projectDir) {
857
+ for (const sub of FRAMEWORK_CONFIG_SUBDIRS) {
858
+ for (const name of VITE_CONFIG_FILENAMES) {
859
+ const candidate = join(projectDir, sub, name);
860
+ if (existsSync(candidate))
861
+ return candidate;
862
+ }
863
+ }
864
+ return null;
865
+ }
866
+ /** Wrap the project's Next config with `withLocalDev` from
867
+ * @hatchkit/dev-plugin-next. Idempotent: detects an existing import
868
+ * and bails before touching the file.
869
+ *
870
+ * Strategy is intentionally surgical — replace the
871
+ * `export default nextConfig;` line with the wrap + add a top-of-file
872
+ * import. We do NOT try to handle exotic config shapes
873
+ * (functional configs, conditional defaults). Returns `"no-file"`
874
+ * when no next.config can be located (server-only surfaces, unusual
875
+ * layouts) and `"unsupported-shape"` when the file exists but doesn't
876
+ * match an ESM `export default` pattern — most commonly CJS
877
+ * `module.exports = …`. */
878
+ function patchNextConfigWithLocalDev(projectDir, slug) {
879
+ const path = findNextConfig(projectDir);
880
+ if (!path)
881
+ return "no-file";
882
+ const content = readFileSync(path, "utf-8");
883
+ if (content.includes("@hatchkit/dev-plugin-next"))
884
+ return "already-wrapped";
885
+ // CJS configs use `module.exports = …`; we can't add a top-of-file
886
+ // ESM `import` to those. Caller surfaces the gap so the user can
887
+ // either migrate to ESM or wrap by hand.
888
+ if (/^\s*module\.exports\s*=/m.test(content))
889
+ return "unsupported-shape";
890
+ // Find the last `export default …;` and wrap whatever identifier it
891
+ // exports. The common shape is `export default nextConfig;` but some
892
+ // configs do `export default { … };` inline — we handle both by
893
+ // hoisting the expression into a const first when needed.
894
+ const exportMatch = content.match(/^\s*export\s+default\s+([^;]+);?\s*$/m);
895
+ if (!exportMatch)
896
+ return "unsupported-shape";
897
+ const expression = exportMatch[1].trim();
898
+ const isIdentifier = /^[a-zA-Z_$][\w$]*$/.test(expression);
899
+ const importLine = `import { withLocalDev } from "@hatchkit/dev-plugin-next";\n`;
900
+ let next = content;
901
+ if (isIdentifier) {
902
+ next = next.replace(exportMatch[0], `\nexport default withLocalDev(${expression}, { slug: "${slug}" });\n`);
903
+ }
904
+ else {
905
+ // Inline expression — hoist into a const so we can wrap it cleanly.
906
+ next = next.replace(exportMatch[0], `\nconst __hatchkitLocalDevConfig = ${expression};\nexport default withLocalDev(__hatchkitLocalDevConfig, { slug: "${slug}" });\n`);
907
+ }
908
+ next = `${importLine}${next}`;
909
+ writeFileSync(path, next);
910
+ return "added";
911
+ }
912
+ /** Inject the right `@hatchkit/dev-plugin-{next,vite}` package into the
913
+ * client package.json's dependencies (or devDependencies, matching where
914
+ * the framework's own dep already lives). Targets the package.json
915
+ * sibling of the framework config we found, so monorepo subdirs
916
+ * (gamedev's `showcase/`, conv3d's `docs/`) get patched correctly.
917
+ * Idempotent. */
918
+ function patchPluginPackageJsonDep(projectDir, versionRange, framework) {
919
+ const configPath = framework === "next" ? findNextConfig(projectDir) : findViteConfig(projectDir);
920
+ const path = configPath ? join(configPath, "..", "package.json") : null;
921
+ if (!path || !existsSync(path))
922
+ return "no-file";
923
+ const frameworkDep = framework;
924
+ const pluginPackage = `@hatchkit/dev-plugin-${framework}`;
925
+ const pkg = (() => {
926
+ try {
927
+ return JSON.parse(readFileSync(path, "utf-8"));
928
+ }
929
+ catch {
930
+ return null;
931
+ }
932
+ })();
933
+ if (!pkg)
934
+ return "no-file";
935
+ if (!pkg.dependencies?.[frameworkDep] && !pkg.devDependencies?.[frameworkDep]) {
936
+ return "no-file";
937
+ }
938
+ const alreadyIn = pkg.dependencies?.[pluginPackage] ?? pkg.devDependencies?.[pluginPackage];
939
+ if (alreadyIn)
940
+ return "already-present";
941
+ // Mirror the framework's own bucket. Projects that keep tooling in
942
+ // devDependencies (e.g. scaffolds generated by `create-next-app
943
+ // --use-pnpm`, or Vite's `npm create vite@latest`) shouldn't get
944
+ // their dev plugin shoved into runtime deps just because we have a
945
+ // default. Falls back to dependencies if the framework dep isn't in
946
+ // devDeps but appears in deps.
947
+ const bucket = pkg.devDependencies?.[frameworkDep]
948
+ ? "devDependencies"
949
+ : "dependencies";
950
+ pkg[bucket] = pkg[bucket] ?? {};
951
+ pkg[bucket][pluginPackage] = versionRange;
952
+ pkg[bucket] = Object.fromEntries(Object.entries(pkg[bucket]).sort(([a], [b]) => a.localeCompare(b)));
953
+ writeFileSync(path, `${JSON.stringify(pkg, null, 2)}\n`);
954
+ return "added";
955
+ }
956
+ export function renderDevSetupDocs(input) {
957
+ const tailnetHostname = input.tailscale?.fullName ?? "<your-machine>.<tailnet>.ts.net";
958
+ const url = `https://${input.slug}.${LOCAL_DEV_DOMAIN}/`;
959
+ return `# Dev URL setup (\`${url}\`)
960
+
961
+ This project ships with the **hatchkit local-dev** integration: when you run
962
+ \`pnpm dev\`, the dev server is reachable from any Tailscale peer (phone,
963
+ tablet, other laptop) at:
964
+
965
+ \`\`\`
966
+ ${url}
967
+ \`\`\`
968
+
969
+ Caddy on your host terminates TLS with a real Cloudflare-issued wildcard
970
+ cert, and tailscale serve forwards inbound port-443 traffic from the
971
+ tailnet to Caddy. No per-project DNS work, no port juggling, no
972
+ framework \`base\` / \`basePath\` config.
973
+
974
+ ## One-time host setup
975
+
976
+ Do this **once per machine**, not per project. After it's wired,
977
+ every hatchkit project that opts in just works.
978
+
979
+ ### 1. Cloudflare DNS — auto-managed
980
+
981
+ \`hatchkit dev-setup init\` creates a DNS-only A record:
982
+
983
+ \`\`\`
984
+ *.local.ricoslabs.com A <your-tailnet-ip> (DNS-only, TTL 60)
985
+ \`\`\`
986
+
987
+ It uses your hatchkit DNS token (or the \`caddy-dev/cloudflare-acme\`
988
+ 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.
990
+
991
+ **Why a direct A record instead of a CNAME to ${tailnetHostname}?**
992
+ A CNAME to a \`.ts.net\` name only resolves when each peer has
993
+ Tailscale's MagicDNS resolver in front of its public DNS. iOS's stub
994
+ resolver caches NXDOMAIN for the intermediate lookup, so phone requests
995
+ silently fail. Pointing the wildcard at the laptop's tailnet IP makes
996
+ the resolution a single hop — tailnet peers reach the laptop, anyone
997
+ else gets a useless 100.x address (intended).
998
+
999
+ If you're using a non-Cloudflare DNS provider, add the record yourself:
1000
+
1001
+ \`\`\`
1002
+ *.local.ricoslabs.com A <your-tailnet-ip> (DNS-only)
1003
+ \`\`\`
1004
+
1005
+ ### 2. Cloudflare API token
1006
+
1007
+ Caddy needs a Cloudflare token to fetch the wildcard cert via DNS-01
1008
+ ACME. \`hatchkit config add dns\` already prompts for one — if you ran
1009
+ \`hatchkit setup\`, you've got it. Otherwise:
1010
+
1011
+ \`\`\`
1012
+ hatchkit config add dns
1013
+ \`\`\`
1014
+
1015
+ 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\`.
1018
+
1019
+ ### 3. Caddy with the Cloudflare DNS plugin
1020
+
1021
+ \`\`\`
1022
+ brew install caddy
1023
+ caddy list-modules | grep cloudflare
1024
+ \`\`\`
1025
+
1026
+ If \`dns.providers.cloudflare\` isn't in the module list, rebuild with
1027
+ xcaddy:
1028
+
1029
+ \`\`\`
1030
+ go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
1031
+ xcaddy build --with github.com/caddy-dns/cloudflare
1032
+ \`\`\`
1033
+
1034
+ ### 4. Wire it all up
1035
+
1036
+ \`\`\`
1037
+ hatchkit dev-setup init
1038
+ \`\`\`
1039
+
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.
1044
+
1045
+ ### 5. Verify
1046
+
1047
+ \`\`\`
1048
+ hatchkit doctor
1049
+ \`\`\`
1050
+
1051
+ Look for the **Local-dev** rows. All six should be green:
1052
+
1053
+ - Tailscale daemon
1054
+ - Caddy installed
1055
+ - Caddy cloudflare plugin
1056
+ - Cloudflare API token in plist
1057
+ - Caddy launchd job
1058
+ - Tailscale serve bridge
1059
+
1060
+ ## Per-project bits
1061
+
1062
+ This project's slug is **\`${input.slug}\`**, recorded in
1063
+ \`.hatchkit.json\` under \`localDev.slug\`. When \`pnpm dev\` starts, the
1064
+ hatchkit dev plugin:
1065
+
1066
+ 1. Reads the slug + the live dev port from the running server.
1067
+ 2. Writes/updates \`~/.config/dev/projects/${input.slug}.caddy\` pointing at
1068
+ that port. Caddy's \`--watch\` picks it up without a restart.
1069
+ 3. Probes \`tailscale serve status\` for the TCP=443 bridge.
1070
+ 4. Prints a banner:
1071
+
1072
+ \`\`\`
1073
+ ➜ Local: http://localhost:<port>/
1074
+ ➜ Tailscale: ${url}
1075
+ \`\`\`
1076
+
1077
+ \`HATCHKIT_LOCAL_DEV=0\` in the environment disables the plugin entirely;
1078
+ the dev server falls back to its default banner.
1079
+
1080
+ ## Cleanup
1081
+
1082
+ If you tear down this project:
1083
+
1084
+ \`\`\`
1085
+ hatchkit destroy
1086
+ \`\`\`
1087
+
1088
+ …also removes \`~/.config/dev/projects/${input.slug}.caddy\`. Other projects'
1089
+ fragments stay put.
1090
+ `;
1091
+ }
1092
+ // ---------------------------------------------------------------------------
1093
+ // `hatchkit dev-setup` CLI entry — thin wrapper around the init runner.
1094
+ // ---------------------------------------------------------------------------
1095
+ export async function runDevSetupCli(args) {
1096
+ const sub = args[0];
1097
+ if (sub === "enable") {
1098
+ await runDevSetupEnableCli(args.slice(1));
1099
+ return;
1100
+ }
1101
+ if (sub === "disable") {
1102
+ await runDevSetupDisableCli(args.slice(1));
1103
+ return;
1104
+ }
1105
+ if (sub === "init") {
1106
+ const force = args.includes("--force");
1107
+ // --caddy-token-keychain <service>:<account> → wrapper mode with custom pair
1108
+ // --no-caddy-token-keychain → force inline-env mode
1109
+ // (default) → auto-detect default pair
1110
+ let caddyTokenKeychain;
1111
+ if (args.includes("--no-caddy-token-keychain")) {
1112
+ caddyTokenKeychain = false;
1113
+ }
1114
+ else {
1115
+ const flagIdx = args.findIndex((a) => a === "--caddy-token-keychain" || a.startsWith("--caddy-token-keychain="));
1116
+ if (flagIdx !== -1) {
1117
+ const raw = args[flagIdx].includes("=")
1118
+ ? args[flagIdx].slice("--caddy-token-keychain=".length)
1119
+ : args[flagIdx + 1];
1120
+ const [service, account] = (raw ?? "").split(":");
1121
+ if (!service || !account) {
1122
+ console.log("Usage: --caddy-token-keychain <service>:<account>");
1123
+ process.exit(1);
1124
+ }
1125
+ caddyTokenKeychain = { service, account };
1126
+ }
1127
+ }
1128
+ const result = await runDevSetupInit({ force, caddyTokenKeychain });
1129
+ const chalk = (await import("chalk")).default;
1130
+ console.log(chalk.bold("\n hatchkit dev-setup init\n"));
1131
+ console.log(` Caddy port: ${chalk.cyan(result.caddyPort)}`);
1132
+ console.log(` Caddyfile: ${result.wroteCaddyfile ? chalk.green("wrote") : chalk.dim("unchanged")} ${chalk.dim(CADDYFILE_PATH)}`);
1133
+ if (result.wroteWrapper) {
1134
+ console.log(` caddy wrapper: ${chalk.green("wrote")} ${chalk.dim(CADDY_WRAPPER_PATH)}`);
1135
+ }
1136
+ console.log(` launchd plist: ${result.wrotePlist ? chalk.green("wrote") : chalk.dim("unchanged")} ${chalk.dim(LAUNCHD_PLIST_PATH)}`);
1137
+ console.log(` launchctl load: ${result.loadedLaunchd ? chalk.green("ok") : chalk.dim("skipped")}`);
1138
+ console.log(` tailscale serve TCP: ${result.registeredServe ? chalk.green(`tcp:443 → localhost:${result.caddyPort}`) : chalk.yellow("not registered")}`);
1139
+ if (result.dnsRecord !== null) {
1140
+ const dnsLabel = result.dnsRecord === "created" || result.dnsRecord === "updated"
1141
+ ? chalk.green(result.dnsRecord)
1142
+ : result.dnsRecord === "unchanged"
1143
+ ? 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}`)}`);
1148
+ }
1149
+ if (result.notes.length > 0) {
1150
+ console.log(chalk.bold("\n Notes:"));
1151
+ for (const n of result.notes)
1152
+ console.log(` ${chalk.yellow("·")} ${n}`);
1153
+ }
1154
+ console.log(`\n Verify with: ${chalk.cyan("hatchkit doctor")} (look for the Local-dev / … checks).\n`);
1155
+ return;
1156
+ }
1157
+ if (sub === "status") {
1158
+ const checks = await checkLocalDevHost();
1159
+ if (checks.length === 0) {
1160
+ console.log("Feature not active. Run `hatchkit dev-setup init` once to enable it.");
1161
+ return;
1162
+ }
1163
+ const chalk = (await import("chalk")).default;
1164
+ for (const r of checks) {
1165
+ const icon = r.status === "ok" ? chalk.green("✓") : r.status === "fail" ? chalk.red("✗") : chalk.dim("·");
1166
+ console.log(` ${icon} ${r.name}${r.detail ? chalk.dim(` — ${r.detail}`) : ""}`);
1167
+ }
1168
+ return;
1169
+ }
1170
+ 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.");
1172
+ console.log(" status Run the same checks doctor runs, but only the Local-dev rows.");
1173
+ console.log(" enable [--slug] Wire the project in cwd for Tailscale dev URLs (writes Caddy fragment,");
1174
+ console.log(" docs/dev-setup.md, patches next.config, adds plugin dep).");
1175
+ console.log(" disable Reverse `enable` for the project in cwd. Leaves next.config + dep in place.");
1176
+ }
1177
+ async function runDevSetupEnableCli(args) {
1178
+ const chalk = (await import("chalk")).default;
1179
+ const { resolve, join: joinPath } = await import("node:path");
1180
+ const { readManifest, writeManifest } = await import("./scaffold/manifest.js");
1181
+ const { sanitiseSlug } = await import("@hatchkit/dev-shared");
1182
+ const { input, confirm: askConfirm } = await import("@inquirer/prompts");
1183
+ const slugFlagIdx = args.findIndex((a) => a === "--slug" || a.startsWith("--slug="));
1184
+ const slugFlag = slugFlagIdx === -1
1185
+ ? undefined
1186
+ : args[slugFlagIdx].includes("=")
1187
+ ? args[slugFlagIdx].slice("--slug=".length)
1188
+ : args[slugFlagIdx + 1];
1189
+ const portFlagIdx = args.findIndex((a) => a === "--port" || a.startsWith("--port="));
1190
+ const portFlag = portFlagIdx === -1
1191
+ ? undefined
1192
+ : args[portFlagIdx].includes("=")
1193
+ ? args[portFlagIdx].slice("--port=".length)
1194
+ : args[portFlagIdx + 1];
1195
+ const projectDirFlagIdx = args.findIndex((a) => a === "--project-dir" || a.startsWith("--project-dir="));
1196
+ const projectDirFlag = projectDirFlagIdx === -1
1197
+ ? undefined
1198
+ : args[projectDirFlagIdx].includes("=")
1199
+ ? args[projectDirFlagIdx].slice("--project-dir=".length)
1200
+ : args[projectDirFlagIdx + 1];
1201
+ const projectDir = projectDirFlag ? resolve(projectDirFlag) : resolve(".");
1202
+ const manifest = readManifest(projectDir);
1203
+ if (!manifest) {
1204
+ console.log(chalk.red(` No .hatchkit.json found at ${projectDir}.`));
1205
+ console.log(chalk.dim(` Run from a hatchkit-managed project root, or pass --project-dir <path>.`));
1206
+ process.exit(1);
1207
+ }
1208
+ // Slug: flag → manifest.localDev → manifest.name → prompt.
1209
+ let slug = slugFlag ? sanitiseSlug(slugFlag) : manifest.localDev?.slug;
1210
+ if (!slug) {
1211
+ const defaultSlug = sanitiseSlug(manifest.name);
1212
+ slug = await input({
1213
+ message: "Slug for this project (https://<slug>.local.ricoslabs.com/):",
1214
+ default: defaultSlug,
1215
+ validate: (v) => {
1216
+ const s = sanitiseSlug(v);
1217
+ if (s.length === 0)
1218
+ return "Slug must contain at least one [a-z0-9-] character.";
1219
+ if (s !== v)
1220
+ return `Use only [a-z0-9-]. Did you mean "${s}"?`;
1221
+ return true;
1222
+ },
1223
+ });
1224
+ slug = sanitiseSlug(slug);
1225
+ }
1226
+ // Port: flag → manifest.ports.client (preferred) → manifest.ports.server.
1227
+ let devPort;
1228
+ if (portFlag) {
1229
+ devPort = Number(portFlag);
1230
+ if (!Number.isFinite(devPort) || devPort <= 0) {
1231
+ console.log(chalk.red(` --port ${portFlag} is not a valid port number.`));
1232
+ process.exit(1);
1233
+ }
1234
+ }
1235
+ else {
1236
+ devPort = manifest.surfaces === "server-only" ? manifest.ports.server : manifest.ports.client;
1237
+ }
1238
+ console.log(chalk.bold(`\n Enabling local-dev for ${chalk.cyan(manifest.name)}\n`));
1239
+ console.log(` Slug: ${chalk.cyan(slug)}`);
1240
+ console.log(` Dev port: ${chalk.cyan(devPort)}`);
1241
+ console.log(` URL: ${chalk.cyan(`https://${slug}.${LOCAL_DEV_DOMAIN}/`)}`);
1242
+ const skipConfirm = args.includes("--yes") || args.includes("-y");
1243
+ if (!skipConfirm) {
1244
+ const ok = await askConfirm({ message: "Proceed?", default: true });
1245
+ if (!ok) {
1246
+ console.log(chalk.dim(" Aborted."));
1247
+ return;
1248
+ }
1249
+ }
1250
+ const result = await enableProjectLocalDev({ projectDir, slug, devPort });
1251
+ // Persist the slug in the manifest so subsequent runs (plugin, doctor,
1252
+ // future `dev-setup disable`) all converge on the same identity.
1253
+ if (manifest.localDev?.slug !== slug) {
1254
+ const updated = { ...manifest, localDev: { slug } };
1255
+ writeManifest(projectDir, updated);
1256
+ }
1257
+ console.log(`\n Framework: ${chalk.cyan(result.framework)}`);
1258
+ console.log(` Caddy fragment: ${chalk.green(result.wroteFragment)}`);
1259
+ console.log(` docs/dev-setup.md: ${result.wroteDocs ? chalk.green("wrote") : chalk.dim("unchanged")}`);
1260
+ const configLabel = result.framework === "vite" ? "vite.config: " : "next.config: ";
1261
+ console.log(` ${configLabel} ${formatPatch(result.patchedConfig)}`);
1262
+ console.log(` package.json: ${formatPatch(result.patchedPackageJson)}`);
1263
+ if (result.patchedPackageJson === "added") {
1264
+ console.log(chalk.dim(`\n Don't forget: run \`pnpm install\` in ${projectDir} to pull the plugin in.`));
1265
+ }
1266
+ if (result.patchedConfig === "manual-wire-required") {
1267
+ // Vite path: too much shape variance for safe auto-patching. Tell
1268
+ // the user exactly what to paste so they don't have to read the
1269
+ // generated `docs/dev-setup.md` to figure it out.
1270
+ console.log(chalk.bold("\n Vite config wiring (paste into your vite.config):"));
1271
+ console.log(chalk.dim(' import { localDev } from "@hatchkit/dev-plugin-vite";'));
1272
+ console.log(chalk.dim(" // …"));
1273
+ console.log(chalk.dim(" plugins: ["));
1274
+ console.log(chalk.dim(" // …your existing plugins"));
1275
+ console.log(chalk.dim(` localDev({ slug: "${slug}" }),`));
1276
+ console.log(chalk.dim(" ],"));
1277
+ console.log(chalk.dim(' server: { allowedHosts: [".local.ricoslabs.com", ".ts.net", ".local"] },'));
1278
+ }
1279
+ if (result.patchedConfig === "unsupported-shape") {
1280
+ console.log(chalk.bold("\n Next config wiring (CJS / non-standard shape):"));
1281
+ console.log(chalk.dim(' const { withLocalDev } = require("@hatchkit/dev-plugin-next");'));
1282
+ console.log(chalk.dim(` module.exports = withLocalDev(nextConfig, { slug: "${slug}" });`));
1283
+ }
1284
+ console.log(chalk.dim(`\n Verify with: \`hatchkit doctor\` (or \`hatchkit dev-setup status\`).`));
1285
+ }
1286
+ async function runDevSetupDisableCli(args) {
1287
+ const chalk = (await import("chalk")).default;
1288
+ const { resolve } = await import("node:path");
1289
+ const { readManifest, writeManifest } = await import("./scaffold/manifest.js");
1290
+ const projectDirFlagIdx = args.findIndex((a) => a === "--project-dir" || a.startsWith("--project-dir="));
1291
+ const projectDirFlag = projectDirFlagIdx === -1
1292
+ ? undefined
1293
+ : args[projectDirFlagIdx].includes("=")
1294
+ ? args[projectDirFlagIdx].slice("--project-dir=".length)
1295
+ : args[projectDirFlagIdx + 1];
1296
+ const projectDir = projectDirFlag ? resolve(projectDirFlag) : resolve(".");
1297
+ const manifest = readManifest(projectDir);
1298
+ if (!manifest) {
1299
+ console.log(chalk.red(` No .hatchkit.json found at ${projectDir}.`));
1300
+ process.exit(1);
1301
+ }
1302
+ const slug = manifest.localDev?.slug;
1303
+ if (!slug) {
1304
+ console.log(chalk.dim(` ${manifest.name} has no local-dev integration recorded. Nothing to disable.`));
1305
+ return;
1306
+ }
1307
+ const result = disableProjectLocalDev(projectDir, slug);
1308
+ // Drop the manifest field so future doctor / plugin runs don't think
1309
+ // the integration is still active.
1310
+ const { localDev: _, ...rest } = manifest;
1311
+ writeManifest(projectDir, rest);
1312
+ console.log(chalk.bold(`\n Disabled local-dev for ${chalk.cyan(manifest.name)}\n`));
1313
+ console.log(` Caddy fragment: ${result.removedFragment ? chalk.green("removed") : chalk.dim("not present")}`);
1314
+ console.log(` docs/dev-setup.md: ${result.removedDocs ? chalk.green("removed") : chalk.dim("not present")}`);
1315
+ console.log(chalk.dim("\n Left in place: next.config wrapper + package.json dep. Both inert without the fragment;"));
1316
+ console.log(chalk.dim(" remove by hand if you don't expect to re-enable later.\n"));
1317
+ }
1318
+ function formatPatch(state) {
1319
+ // chalk import is async on this path; defer to ANSI codes to keep this
1320
+ // helper sync. Inputs are bounded so this stays readable.
1321
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
1322
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
1323
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
1324
+ switch (state) {
1325
+ case "added":
1326
+ return green("patched");
1327
+ case "already-wrapped":
1328
+ case "already-present":
1329
+ return dim("already present");
1330
+ case "no-file":
1331
+ return dim("no file to patch");
1332
+ case "unsupported-shape":
1333
+ return yellow("unsupported shape — wrap manually");
1334
+ case "manual-wire-required":
1335
+ return yellow("manual wire required — see instructions below");
1336
+ case "skipped":
1337
+ return dim("skipped");
1338
+ }
1339
+ }
1340
+ //# sourceMappingURL=dev-setup.js.map