hatchkit 0.1.42 → 0.1.45
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.
- package/dist/adopt.d.ts +77 -0
- package/dist/adopt.d.ts.map +1 -1
- package/dist/adopt.js +395 -157
- package/dist/adopt.js.map +1 -1
- package/dist/deploy/rollback.d.ts.map +1 -1
- package/dist/deploy/rollback.js +9 -0
- package/dist/deploy/rollback.js.map +1 -1
- package/dist/dev-setup.d.ts +106 -0
- package/dist/dev-setup.d.ts.map +1 -0
- package/dist/dev-setup.js +1340 -0
- package/dist/dev-setup.js.map +1 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +6 -0
- package/dist/doctor.js.map +1 -1
- package/dist/index.js +58 -0
- package/dist/index.js.map +1 -1
- package/dist/prompts.d.ts +12 -0
- package/dist/prompts.d.ts.map +1 -1
- package/dist/prompts.js +42 -0
- package/dist/prompts.js.map +1 -1
- package/dist/provision/s3-buckets.d.ts.map +1 -1
- package/dist/provision/s3-buckets.js +44 -24
- package/dist/provision/s3-buckets.js.map +1 -1
- package/dist/provision/write-env.d.ts +6 -0
- package/dist/provision/write-env.d.ts.map +1 -1
- package/dist/provision/write-env.js +17 -0
- package/dist/provision/write-env.js.map +1 -1
- package/dist/scaffold/app.d.ts +6 -0
- package/dist/scaffold/app.d.ts.map +1 -1
- package/dist/scaffold/app.js +20 -1
- package/dist/scaffold/app.js.map +1 -1
- package/dist/scaffold/build-pipeline.d.ts +26 -2
- package/dist/scaffold/build-pipeline.d.ts.map +1 -1
- package/dist/scaffold/build-pipeline.js +159 -6
- package/dist/scaffold/build-pipeline.js.map +1 -1
- package/dist/scaffold/manifest.d.ts +12 -0
- package/dist/scaffold/manifest.d.ts.map +1 -1
- package/dist/scaffold/manifest.js +1 -0
- package/dist/scaffold/manifest.js.map +1 -1
- package/dist/scaffold/update.d.ts +20 -1
- package/dist/scaffold/update.d.ts.map +1 -1
- package/dist/scaffold/update.js +126 -54
- package/dist/scaffold/update.js.map +1 -1
- package/dist/templates/build-pipeline/Dockerfile.nextjs-monorepo.hbs +107 -0
- package/dist/templates/build-pipeline/docker-compose.yml.hbs +14 -2
- package/dist/utils/flags.d.ts +5 -0
- package/dist/utils/flags.d.ts.map +1 -1
- package/dist/utils/flags.js +13 -1
- package/dist/utils/flags.js.map +1 -1
- package/dist/utils/run-ledger.d.ts +9 -0
- package/dist/utils/run-ledger.d.ts.map +1 -1
- package/dist/utils/run-ledger.js.map +1 -1
- package/package.json +4 -2
- package/scripts/release-bump.mjs +29 -3
- package/scripts/release-packages.mjs +131 -0
|
@@ -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, "&")
|
|
176
|
+
.replace(/</g, "<")
|
|
177
|
+
.replace(/>/g, ">")
|
|
178
|
+
.replace(/"/g, """);
|
|
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
|