@jhizzard/termdeck 1.8.0 → 1.9.0

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,1270 @@
1
+ #!/usr/bin/env node
2
+
3
+ // `termdeck init --bridge` — guided wizard for the Tier 5 Web-Chat Bridge
4
+ // permanent install (named cloudflared tunnel + stack supervisor). Automates
5
+ // the manual flow in docs/GETTING-STARTED.md § Tier 5 (PR #23); the doc and
6
+ // this wizard must stay in lockstep.
7
+ //
8
+ // What it does, in order:
9
+ // 1. Preflight: cloudflared on PATH (install hint if not), origin cert,
10
+ // existing tunnel credentials, existing config.yml / supervisor.env.
11
+ // 2. Prompts for tunnel name (default `termdeck-bridge`) + public hostname
12
+ // — or reuses the values already in ~/.termdeck/supervisor.env.
13
+ // 3. Persists ~/.termdeck/supervisor.env IMMEDIATELY after collection
14
+ // (merge-aware, backs up before changing) so an abort during the
15
+ // operator wait-loop below cannot lose typed-in answers. This runs
16
+ // BEFORE the cloudflared wait — INSTALLER-PITFALLS Class C
17
+ // (state-mutating writes before fallible/blocking steps; ledger #5).
18
+ // Safe against a live quick-tunnel stack: termdeck-supervise.sh's
19
+ // start_tunnel short-circuits on any running `cloudflared tunnel`
20
+ // process and only adopts the named tunnel after the next restart.
21
+ // 4. Stages the VENDORED supervisor script + operator one-shot scripts
22
+ // under ~/.termdeck/bridge-install/ and PRINTS the cloudflared
23
+ // login/create/route steps — it NEVER runs them (browser auth is
24
+ // operator-interactive). Single-line `bash <one-shot>` hand-offs per
25
+ // INSTALLER-PITFALLS Class J (checklist #11).
26
+ // 5. Waits/re-checks until tunnel credentials exist, then writes
27
+ // ~/.cloudflared/config.yml (tunnel id from the credentials JSON,
28
+ // ingress → http://127.0.0.1:8870). Existing files are backed up
29
+ // before any overwrite and a foreign (non-wizard) config.yml is never
30
+ // replaced without explicit consent.
31
+ // 6. Copies the supervisor install files AS FILES to their final paths —
32
+ // rendered launchd plist → ~/Library/LaunchAgents/ on darwin, systemd
33
+ // unit + timer → ~/.config/systemd/user/ on linux — then PRINTS the
34
+ // operator `launchctl load -w` / `systemctl enable` steps. It never
35
+ // runs launchctl/systemctl itself (ORCH decision 2026-06-11 20:58).
36
+ // 7. Verify pass: if the local bridge is up, runs the four Tier 5 public
37
+ // reachability checks against https://<hostname> and prints results.
38
+ //
39
+ // Supervisor assets are VENDORED into the npm tarball at
40
+ // packages/cli/assets/supervise/ (script byte-mirrors scripts/
41
+ // termdeck-supervise.sh; plist/service are __TERMDECK_*__-tokenized twins of
42
+ // the canonical repo artifacts so no developer-machine paths ever ship —
43
+ // pre-ship checklist #8). The wizard reads ONLY the vendored assets, so npm
44
+ // installs and repo checkouts behave identically. Because the staged script
45
+ // runs from ~/.termdeck/bridge-install/ (not the repo), its REPO_DIR
46
+ // parent-dir derivation would be wrong — the wizard therefore fills
47
+ // TERMDECK_REPO_DIR into supervisor.env (the supervisor's own env-override
48
+ // contract), pointing at this package's root. Never overwrites an
49
+ // operator-set TERMDECK_REPO_DIR.
50
+ //
51
+ // Flags:
52
+ // --help Print usage and exit
53
+ // --yes Reuse existing supervisor.env settings without prompting
54
+ // and auto-confirm overwrites (backups still written)
55
+ // --reset Ignore existing supervisor.env values and re-prompt
56
+ // --from-env No prompts; read TERMDECK_PUBLIC_HOSTNAME (required) and
57
+ // TERMDECK_TUNNEL_NAME (optional, default termdeck-bridge)
58
+ // from the environment. Strict by design.
59
+ // --tunnel-id <uuid> Disambiguate when ~/.cloudflared holds credentials
60
+ // for more than one tunnel (current cloudflared writes no
61
+ // TunnelName field into the credentials JSON, so the
62
+ // wizard cannot always match by name).
63
+ // --dry-run Print the plan; touch nothing on disk
64
+ // --skip-verify Skip the public reachability checks
65
+ // --verify-only Run ONLY the reachability checks (hostname from
66
+ // supervisor.env or env) — no prompts, no writes. Exits
67
+ // non-zero if any check fails, so it is script-friendly.
68
+ //
69
+ // Exit codes: 0 ok · 2 input/validation error · 3 tunnel credentials
70
+ // pending or ambiguous (resume with `--yes` / `--tunnel-id` after the
71
+ // operator steps) · 4 --verify-only found failures · 6 filesystem write
72
+ // failure.
73
+ //
74
+ // Sibling of init-mnestra.js — same step/ok/fail output idiom, the same
75
+ // status-object returns with `would-*` dry-run variants, the same
76
+ // timestamped `.bak.<YYYYMMDDhhmmss>` backup convention, and the same
77
+ // opts-injected paths so tests never touch the real HOME or the network.
78
+
79
+ const path = require('path');
80
+ const fs = require('fs');
81
+ const os = require('os');
82
+ const { execSync } = require('child_process');
83
+
84
+ const SETUP_DIR = path.join(__dirname, '..', '..', 'server', 'src', 'setup');
85
+ // Lazy: the setup aggregate pulls in pg + supabase helpers. Loading it at
86
+ // require-time would (a) slow `termdeck init --bridge --help`, and (b) make
87
+ // this module un-requirable from an extracted npm tarball without
88
+ // node_modules — which the packed-layout asset-resolution test does on
89
+ // purpose (ledger #21: exercise the production FS layout, not a mock).
90
+ let _setupMods = null;
91
+ function setupMods() {
92
+ if (!_setupMods) _setupMods = require(SETUP_DIR);
93
+ return _setupMods;
94
+ }
95
+ function promptsMod() { return setupMods().prompts; }
96
+ function dotenvMod() { return setupMods().dotenv; }
97
+
98
+ const BRIDGE_PORT = 8870;
99
+ const DEFAULT_TUNNEL_NAME = 'termdeck-bridge';
100
+ // Trust marker for config.yml files this wizard wrote — mirrors the
101
+ // TermDeck-managed-marker pattern the hook refresher uses to tell "ours,
102
+ // safe to refresh" from "user-authored, preserve" (ledger #15's safety gate).
103
+ const CONFIG_MARKER = 'Managed by `termdeck init --bridge`';
104
+ const SUPERVISE_LABEL = 'com.jhizzard.termdeck-supervise';
105
+
106
+ const HELP = [
107
+ '',
108
+ 'TermDeck Web-Chat Bridge Setup (Tier 5)',
109
+ '',
110
+ 'Usage: termdeck init --bridge [flags]',
111
+ '',
112
+ 'Flags:',
113
+ ' --help Print this message and exit',
114
+ ' --yes Reuse existing supervisor.env settings without prompting',
115
+ ' and auto-confirm overwrites (timestamped backups are',
116
+ ' still written before any replacement)',
117
+ ' --reset Ignore existing supervisor.env values and re-prompt',
118
+ ' --from-env Skip every prompt; read TERMDECK_PUBLIC_HOSTNAME',
119
+ ' (required) and TERMDECK_TUNNEL_NAME (optional, default',
120
+ ' termdeck-bridge) from environment variables',
121
+ ' --tunnel-id <id> Pick a specific tunnel credentials file when',
122
+ ' ~/.cloudflared holds more than one',
123
+ ' --dry-run Print the plan without touching the filesystem',
124
+ ' --skip-verify Skip the public reachability checks',
125
+ ' --verify-only Only run the four Tier 5 reachability checks against',
126
+ ' the configured hostname; exit 4 if any fail',
127
+ '',
128
+ 'What this does:',
129
+ ' 1. Prompts for a tunnel name + public hostname (or reuses what is',
130
+ ' already in ~/.termdeck/supervisor.env).',
131
+ ' 2. Writes ~/.termdeck/supervisor.env IMMEDIATELY (merge-aware, backed',
132
+ ' up) so a later abort cannot lose what you typed in.',
133
+ ' 3. PRINTS the operator-interactive cloudflared steps (login / create /',
134
+ ' route) — browser auth means the wizard never runs them itself — and',
135
+ ' waits until the tunnel credentials appear.',
136
+ ' 4. Writes ~/.cloudflared/config.yml (ingress → http://127.0.0.1:8870),',
137
+ ' backing up any existing file first. A config.yml this wizard did not',
138
+ ' write is never replaced without your explicit consent.',
139
+ ' 5. Copies the supervisor files into place from the vendored package',
140
+ ' assets (script → ~/.termdeck/bridge-install/, launchd plist /',
141
+ ' systemd units → their install dirs) and PRINTS the launchctl /',
142
+ ' systemctl steps — the wizard never runs those either.',
143
+ ' 6. Verifies public reachability with the four Tier 5 checks.',
144
+ '',
145
+ 'The manual flow lives in docs/GETTING-STARTED.md § Tier 5.',
146
+ ''
147
+ ].join('\n');
148
+
149
+ function parseFlags(argv) {
150
+ const out = {
151
+ help: false,
152
+ yes: false,
153
+ reset: false,
154
+ fromEnv: false,
155
+ dryRun: false,
156
+ skipVerify: false,
157
+ verifyOnly: false,
158
+ tunnelId: null
159
+ };
160
+ const args = argv || [];
161
+ for (let i = 0; i < args.length; i++) {
162
+ const a = args[i];
163
+ if (a === '--help' || a === '-h') out.help = true;
164
+ else if (a === '--yes' || a === '-y') out.yes = true;
165
+ else if (a === '--reset') out.reset = true;
166
+ else if (a === '--from-env') out.fromEnv = true;
167
+ else if (a === '--dry-run') out.dryRun = true;
168
+ else if (a === '--skip-verify') out.skipVerify = true;
169
+ else if (a === '--verify-only') out.verifyOnly = true;
170
+ else if (a === '--tunnel-id' && args[i + 1]) { out.tunnelId = args[i + 1]; i++; }
171
+ }
172
+ return out;
173
+ }
174
+
175
+ function step(msg) { process.stdout.write(`→ ${msg}`); }
176
+ function ok(suffix = '') { process.stdout.write(` ✓${suffix ? ' ' + suffix : ''}\n`); }
177
+ function fail(err) { process.stdout.write(` ✗\n ${err}\n`); }
178
+
179
+ function stamp() {
180
+ return new Date().toISOString().replace(/[-:T.Z]/g, '').slice(0, 14);
181
+ }
182
+
183
+ // Atomic write — tmp + rename, same shape as init-mnestra's _writeSettingsJson.
184
+ function writeFileAtomic(filePath, content, mode) {
185
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
186
+ const tmp = filePath + '.tmp';
187
+ if (mode != null) fs.writeFileSync(tmp, content, { mode });
188
+ else fs.writeFileSync(tmp, content);
189
+ fs.renameSync(tmp, filePath);
190
+ }
191
+
192
+ function validateTunnelName(v) {
193
+ if (!v || !v.trim()) return 'tunnel name is required';
194
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(v.trim())) {
195
+ return 'tunnel name must start alphanumeric and contain only letters, digits, ".", "_", "-" (max 64 chars)';
196
+ }
197
+ return null;
198
+ }
199
+
200
+ // Class D guard built into the validator: placeholder shapes like
201
+ // `bridge.<your-domain>` (straight from the docs) must never reach disk.
202
+ function validateHostname(v) {
203
+ if (!v || !v.trim()) return 'hostname is required';
204
+ const value = v.trim();
205
+ if (/[<>*\s]/.test(value)) return 'enter a real hostname — placeholders like bridge.<your-domain> cannot be used';
206
+ if (/^https?:\/\//i.test(value)) return 'hostname only — drop the https:// scheme';
207
+ if (value.includes('/')) return 'hostname only — no path component';
208
+ if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i.test(value)) {
209
+ return 'not a valid DNS hostname (expected something like bridge.example.com)';
210
+ }
211
+ return null;
212
+ }
213
+
214
+ function detectCloudflared(opts = {}) {
215
+ const lookup = opts.lookupImpl
216
+ || (() => execSync('command -v cloudflared', { stdio: 'ignore' }));
217
+ try { lookup(); return true; } catch (_e) { return false; }
218
+ }
219
+
220
+ // Scan ~/.cloudflared/*.json for tunnel credentials. Ground truth from the
221
+ // 2026-06-10 PR #23 machine: current cloudflared writes ONLY AccountTag /
222
+ // TunnelSecret / TunnelID / Endpoint — NO TunnelName — so name matching is
223
+ // best-effort (newer cloudflared versions do include TunnelName). Resolution
224
+ // order: explicit tunnelId > TunnelName match > single-file fallback >
225
+ // ambiguous (caller must disambiguate; never guess silently).
226
+ // The returned objects deliberately exclude TunnelSecret.
227
+ function findTunnelCredentials(opts = {}) {
228
+ const dir = opts.cloudflaredDir || path.join(os.homedir(), '.cloudflared');
229
+ const wantName = opts.tunnelName || null;
230
+ const wantId = opts.tunnelId || null;
231
+
232
+ let entries = [];
233
+ try {
234
+ entries = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
235
+ } catch (_e) {
236
+ return { status: 'none', candidates: [] };
237
+ }
238
+
239
+ const candidates = [];
240
+ for (const f of entries) {
241
+ const full = path.join(dir, f);
242
+ try {
243
+ const parsed = JSON.parse(fs.readFileSync(full, 'utf8'));
244
+ if (!parsed || typeof parsed !== 'object') continue;
245
+ if (typeof parsed.TunnelID !== 'string' || !parsed.TunnelID) continue;
246
+ if (typeof parsed.AccountTag !== 'string' || !parsed.AccountTag) continue;
247
+ candidates.push({
248
+ tunnelId: parsed.TunnelID,
249
+ tunnelName: typeof parsed.TunnelName === 'string' && parsed.TunnelName ? parsed.TunnelName : null,
250
+ file: full,
251
+ mtimeMs: fs.statSync(full).mtimeMs
252
+ });
253
+ } catch (_e) { /* malformed / unreadable → not a credentials file */ }
254
+ }
255
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
256
+
257
+ if (wantId) {
258
+ const hit = candidates.find((c) => c.tunnelId === wantId);
259
+ return hit
260
+ ? { status: 'match', creds: hit, candidates }
261
+ : { status: 'id-not-found', candidates };
262
+ }
263
+ if (wantName) {
264
+ const hit = candidates.find((c) => c.tunnelName === wantName);
265
+ if (hit) return { status: 'match', creds: hit, candidates };
266
+ }
267
+ if (candidates.length === 1) return { status: 'single', creds: candidates[0], candidates };
268
+ if (candidates.length === 0) return { status: 'none', candidates };
269
+ return { status: 'ambiguous', candidates };
270
+ }
271
+
272
+ function buildCloudflaredConfigYml({ tunnelId, credentialsFile, hostname }) {
273
+ return [
274
+ `# ${CONFIG_MARKER} — Tier 5 Web-Chat Bridge ingress.`,
275
+ '# A timestamped backup is written before this wizard ever replaces the file.',
276
+ `tunnel: ${tunnelId}`,
277
+ `credentials-file: ${credentialsFile}`,
278
+ 'ingress:',
279
+ ` - hostname: ${hostname}`,
280
+ ` service: http://127.0.0.1:${BRIDGE_PORT}`,
281
+ ' - service: http_status:404',
282
+ ''
283
+ ].join('\n');
284
+ }
285
+
286
+ // Semantic (not byte) match so a hand-written config.yml that already routes
287
+ // <hostname> → :8870 through <tunnelId> counts as configured — the wizard
288
+ // must be a no-op on machines where the manual Tier 5 flow already ran.
289
+ function readCloudflaredConfigState(configPath, desired) {
290
+ let raw;
291
+ try { raw = fs.readFileSync(configPath, 'utf8'); }
292
+ catch (_e) { return { status: 'missing' }; }
293
+ const trimmedLines = raw.split(/\r?\n/).map((l) => l.trim());
294
+ const has = (s) => trimmedLines.includes(s);
295
+ const matches = has(`tunnel: ${desired.tunnelId}`)
296
+ && has(`credentials-file: ${desired.credentialsFile}`)
297
+ && has(`- hostname: ${desired.hostname}`)
298
+ && raw.includes(`service: http://127.0.0.1:${BRIDGE_PORT}`);
299
+ return {
300
+ status: matches ? 'matches' : 'differs',
301
+ raw,
302
+ ours: raw.includes(CONFIG_MARKER)
303
+ };
304
+ }
305
+
306
+ // Write ~/.cloudflared/config.yml. Never clobbers silently: existing files
307
+ // are backed up first, and a foreign (non-wizard-marker) file additionally
308
+ // requires consent (interactive confirm, or --yes). confirmFn is injected by
309
+ // main(); absent confirmFn + absent assumeYes = keep the foreign file.
310
+ async function writeCloudflaredConfig({
311
+ cloudflaredDir,
312
+ configPath,
313
+ tunnelId,
314
+ hostname,
315
+ dryRun,
316
+ assumeYes,
317
+ confirmFn
318
+ } = {}) {
319
+ const dir = cloudflaredDir || path.join(os.homedir(), '.cloudflared');
320
+ const target = configPath || path.join(dir, 'config.yml');
321
+ const credentialsFile = path.join(dir, `${tunnelId}.json`);
322
+ const desired = buildCloudflaredConfigYml({ tunnelId, credentialsFile, hostname });
323
+ const state = readCloudflaredConfigState(target, { tunnelId, credentialsFile, hostname });
324
+
325
+ if (state.status === 'matches') {
326
+ return { status: 'already-configured', configPath: target };
327
+ }
328
+ if (state.status === 'missing') {
329
+ if (dryRun) return { status: 'would-write', configPath: target };
330
+ writeFileAtomic(target, desired);
331
+ return { status: 'written', configPath: target };
332
+ }
333
+
334
+ // differs
335
+ if (!state.ours) {
336
+ if (dryRun) return { status: 'would-replace-foreign', configPath: target };
337
+ let consent = !!assumeYes;
338
+ if (!consent && typeof confirmFn === 'function') {
339
+ consent = await confirmFn();
340
+ }
341
+ if (!consent) {
342
+ return { status: 'kept-foreign', configPath: target, desired };
343
+ }
344
+ } else if (dryRun) {
345
+ return { status: 'would-update', configPath: target };
346
+ }
347
+
348
+ let backup = `${target}.bak.${stamp()}`;
349
+ try { fs.copyFileSync(target, backup); }
350
+ catch (_e) { backup = null; /* best-effort, matching the sibling wizard */ }
351
+ writeFileAtomic(target, desired);
352
+ return { status: state.ours ? 'updated' : 'replaced', configPath: target, backup };
353
+ }
354
+
355
+ // Merge TERMDECK_TUNNEL_NAME + TERMDECK_PUBLIC_HOSTNAME into
356
+ // ~/.termdeck/supervisor.env. Reuses dotenv.writeSecrets (merge-aware:
357
+ // preserves comments, ordering, and every unrelated key — the operator may
358
+ // have custom overrides in here). No-change runs are detected BEFORE any
359
+ // write so idempotent re-runs leave mtime untouched and create no backup.
360
+ //
361
+ // fillIfMissing: keys written ONLY when absent/empty in the existing file —
362
+ // never overwriting an operator-set value (settings-invariant #4 posture).
363
+ // Used for TERMDECK_REPO_DIR: the staged supervisor script can't derive the
364
+ // repo/package root from its own location, so the wizard pins it here.
365
+ function mergeSupervisorEnv({ envPath, tunnelName, hostname, fillIfMissing = {}, dryRun } = {}) {
366
+ const target = envPath || path.join(os.homedir(), '.termdeck', 'supervisor.env');
367
+ const exists = fs.existsSync(target);
368
+ const existing = exists ? dotenvMod().readSecrets(target) : {};
369
+ const adds = {};
370
+ for (const [k, v] of Object.entries(fillIfMissing)) {
371
+ if (v == null || v === '') continue;
372
+ if (existing[k] === undefined || existing[k] === '') adds[k] = v;
373
+ }
374
+ if (existing.TERMDECK_TUNNEL_NAME === tunnelName
375
+ && existing.TERMDECK_PUBLIC_HOSTNAME === hostname
376
+ && Object.keys(adds).length === 0) {
377
+ return { status: 'already-set', envPath: target };
378
+ }
379
+ if (dryRun) {
380
+ return { status: exists ? 'would-update' : 'would-create', envPath: target, added: Object.keys(adds) };
381
+ }
382
+
383
+ let backup = null;
384
+ if (exists) {
385
+ backup = `${target}.bak.${stamp()}`;
386
+ try { fs.copyFileSync(target, backup); }
387
+ catch (_e) { backup = null; }
388
+ } else {
389
+ // Seed a supervisor-appropriate banner so writeSecrets doesn't stamp the
390
+ // fresh file with its secrets.env-specific header.
391
+ writeFileAtomic(
392
+ target,
393
+ '# TermDeck supervisor overrides — sourced by termdeck-supervise.sh on every tick.\n'
394
+ + '# Written by `termdeck init --bridge`; safe to hand-edit (re-read each tick, no reinstall needed).\n'
395
+ + '\n',
396
+ 0o600
397
+ );
398
+ }
399
+ dotenvMod().writeSecrets({
400
+ TERMDECK_TUNNEL_NAME: tunnelName,
401
+ TERMDECK_PUBLIC_HOSTNAME: hostname,
402
+ ...adds
403
+ }, target);
404
+ return { status: exists ? 'updated' : 'created', envPath: target, backup, added: Object.keys(adds) };
405
+ }
406
+
407
+ // ── Operator one-shot scripts + supervision install plan ────────────────────
408
+ //
409
+ // Every command hand-off is a staged script invoked by ONE single-line `bash
410
+ // <path>` (INSTALLER-PITFALLS Class J / checklist #11: multi-line clipboard
411
+ // pastes shred on \r\n-converting terminals; one logical operation per
412
+ // invocation). The wizard itself NEVER execs cloudflared / launchctl /
413
+ // systemctl — staging files into the user's home is the entire extent of
414
+ // its authority here.
415
+
416
+ function buildSetupTunnelScript({ tunnelName, hostname }) {
417
+ return `#!/usr/bin/env bash
418
+ # Staged by \`termdeck init --bridge\` — one-shot named-tunnel bootstrap.
419
+ # OPERATOR-RUN: \`cloudflared tunnel login\` opens a browser for Cloudflare
420
+ # auth; the wizard never runs these itself.
421
+ set -uo pipefail
422
+ command -v cloudflared >/dev/null 2>&1 || { echo "cloudflared not found — macOS: brew install cloudflared | Linux: https://pkg.cloudflare.com"; exit 1; }
423
+ if [ ! -s "$HOME/.cloudflared/cert.pem" ]; then
424
+ echo "==> cloudflared tunnel login (browser opens — authorize the zone that owns ${hostname})"
425
+ cloudflared tunnel login || exit 1
426
+ else
427
+ echo "==> origin cert already present ($HOME/.cloudflared/cert.pem) — skipping login"
428
+ fi
429
+ echo "==> cloudflared tunnel create ${tunnelName}"
430
+ cloudflared tunnel create '${tunnelName}' || echo " (create failed — fine if the tunnel already exists)"
431
+ echo "==> cloudflared tunnel route dns ${tunnelName} ${hostname}"
432
+ cloudflared tunnel route dns '${tunnelName}' '${hostname}' || echo " (route failed — fine if the DNS record already exists)"
433
+ echo "==> done. Back in the wizard, answer the re-check prompt — or re-run: termdeck init --bridge --yes"
434
+ `;
435
+ }
436
+
437
+ // Vendored supervisor assets — packed into the npm tarball via root
438
+ // package.json `files[]` entry `packages/cli/assets/**` (ORCH decision
439
+ // 2026-06-11 20:58; precedent: stack-installer's bundled hooks). The same
440
+ // relative layout exists in the monorepo and the extracted tarball, exactly
441
+ // like init-mnestra's HOOK_SOURCE resolution.
442
+ const ASSETS_DIR = path.join(__dirname, '..', 'assets', 'supervise');
443
+
444
+ function resolveSuperviseAssets(opts = {}) {
445
+ const dir = opts.assetsDir || ASSETS_DIR;
446
+ const names = {
447
+ script: 'termdeck-supervise.sh',
448
+ plist: `${SUPERVISE_LABEL}.plist`,
449
+ service: 'termdeck-supervise.service',
450
+ timer: 'termdeck-supervise.timer'
451
+ };
452
+ const paths = {};
453
+ const missing = [];
454
+ for (const [key, name] of Object.entries(names)) {
455
+ const p = path.join(dir, name);
456
+ if (fs.existsSync(p)) paths[key] = p;
457
+ else missing.push(name);
458
+ }
459
+ return { ok: missing.length === 0, dir, paths, missing };
460
+ }
461
+
462
+ // Token substitution for the vendored plist/service templates. Throws on any
463
+ // surviving __TERMDECK_*__ token so a half-rendered file can never reach disk
464
+ // (Class D: no placeholder may land in a config a runtime will consume).
465
+ function renderTemplate(content, tokens) {
466
+ let out = content;
467
+ for (const [token, value] of Object.entries(tokens)) {
468
+ out = out.split(token).join(value);
469
+ }
470
+ const residual = out.match(/__TERMDECK_[A-Z_]+__/);
471
+ if (residual) {
472
+ throw new Error(`unresolved template token ${residual[0]} — vendored asset and wizard are out of sync`);
473
+ }
474
+ return out;
475
+ }
476
+
477
+ function buildLaunchdPlist({ scriptPath, home, assetsDir }) {
478
+ const assets = resolveSuperviseAssets({ assetsDir });
479
+ if (!assets.paths.plist) throw new Error(`vendored plist asset missing under ${assets.dir}`);
480
+ return renderTemplate(fs.readFileSync(assets.paths.plist, 'utf8'), {
481
+ __TERMDECK_SUPERVISE_SCRIPT__: scriptPath,
482
+ __TERMDECK_HOME__: home
483
+ });
484
+ }
485
+
486
+ function buildSystemdService({ scriptPath, assetsDir }) {
487
+ const assets = resolveSuperviseAssets({ assetsDir });
488
+ if (!assets.paths.service) throw new Error(`vendored service asset missing under ${assets.dir}`);
489
+ return renderTemplate(fs.readFileSync(assets.paths.service, 'utf8'), {
490
+ __TERMDECK_SUPERVISE_SCRIPT__: scriptPath
491
+ });
492
+ }
493
+
494
+ function buildSystemdTimer({ assetsDir } = {}) {
495
+ const assets = resolveSuperviseAssets({ assetsDir });
496
+ if (!assets.paths.timer) throw new Error(`vendored timer asset missing under ${assets.dir}`);
497
+ return fs.readFileSync(assets.paths.timer, 'utf8');
498
+ }
499
+
500
+ // linux-only: the wizard copies the unit files itself, so the one-shot is
501
+ // just the enable sequence (3 commands → still worth a single-line hand-off
502
+ // per Class J checklist #11). darwin needs no one-shot — its operator step
503
+ // is a single `launchctl load -w` line.
504
+ function buildInstallSupervisorScript() {
505
+ return `#!/usr/bin/env bash
506
+ # Staged by \`termdeck init --bridge\` — enables the stack supervisor's 60s
507
+ # systemd user timer. The wizard already copied the unit files into
508
+ # ~/.config/systemd/user/; OPERATOR-RUN because the wizard never invokes
509
+ # systemctl/loginctl itself.
510
+ set -uo pipefail
511
+ systemctl --user daemon-reload
512
+ systemctl --user enable --now termdeck-supervise.timer
513
+ loginctl enable-linger "$(whoami)"
514
+ echo "==> supervisor timer enabled. Logs: journalctl --user -u termdeck-supervise.service -n 50"
515
+ `;
516
+ }
517
+
518
+ // Install plan, built ENTIRELY from the vendored package assets — identical
519
+ // behavior for npm-global installs and repo checkouts (no repo probe, no
520
+ // degraded path; ORCH decision 2026-06-11 20:58). The supervisor script is
521
+ // staged to ~/.termdeck/bridge-install/ and the launchd/systemd files point
522
+ // at THAT copy: a stable user-land path that survives npm upgrades and node
523
+ // version-manager switches, refreshed on every wizard run.
524
+ function buildSupervisorInstallPlan({ platform, home, stageDir, assetsDir } = {}) {
525
+ const plat = platform || process.platform;
526
+ const homeDir = home || os.homedir();
527
+ const assets = resolveSuperviseAssets({ assetsDir });
528
+ if (!assets.ok) {
529
+ // A healthy install can never hit this (the packed-layout test pins the
530
+ // tarball); reaching it means a corrupted/partial install — Class H made
531
+ // loud instead of silent.
532
+ return { ok: false, platform: plat, assetsDir: assets.dir, missing: assets.missing };
533
+ }
534
+ const stagedScript = path.join(stageDir, 'termdeck-supervise.sh');
535
+
536
+ if (plat === 'darwin') {
537
+ const dest = path.join(homeDir, 'Library', 'LaunchAgents', `${SUPERVISE_LABEL}.plist`);
538
+ return {
539
+ ok: true,
540
+ platform: plat,
541
+ assets,
542
+ stagedScript,
543
+ stageExtras: [],
544
+ targets: [{
545
+ kind: 'launchd plist',
546
+ dest,
547
+ mode: 0o644,
548
+ content: buildLaunchdPlist({ scriptPath: stagedScript, home: homeDir, assetsDir: assets.dir })
549
+ }],
550
+ operatorLines: [`launchctl load -w ${dest}`],
551
+ reloadHint: `launchctl unload -w ${dest}`,
552
+ oneShot: null
553
+ };
554
+ }
555
+
556
+ const unitDir = path.join(homeDir, '.config', 'systemd', 'user');
557
+ return {
558
+ ok: true,
559
+ platform: plat,
560
+ assets,
561
+ stagedScript,
562
+ stageExtras: [
563
+ { name: 'install-supervisor.sh', content: buildInstallSupervisorScript(), mode: 0o755 }
564
+ ],
565
+ targets: [
566
+ {
567
+ kind: 'systemd service',
568
+ dest: path.join(unitDir, 'termdeck-supervise.service'),
569
+ mode: 0o644,
570
+ content: buildSystemdService({ scriptPath: stagedScript, assetsDir: assets.dir })
571
+ },
572
+ {
573
+ kind: 'systemd timer',
574
+ dest: path.join(unitDir, 'termdeck-supervise.timer'),
575
+ mode: 0o644,
576
+ content: buildSystemdTimer({ assetsDir: assets.dir })
577
+ }
578
+ ],
579
+ operatorLines: [
580
+ 'systemctl --user daemon-reload',
581
+ 'systemctl --user enable --now termdeck-supervise.timer',
582
+ 'loginctl enable-linger "$(whoami)"'
583
+ ],
584
+ reloadHint: null,
585
+ oneShot: path.join(stageDir, 'install-supervisor.sh')
586
+ };
587
+ }
588
+
589
+ // Write a wizard-managed file (plist / systemd unit) to its FINAL path with
590
+ // the same safety contract as config.yml: byte-identical → no-op; a file we
591
+ // wrote (carries the wizard marker) → timestamped backup + update; a foreign
592
+ // file → kept unless explicitly consented (interactive confirm or --yes).
593
+ async function installManagedFile({ dest, content, mode = 0o644, dryRun, assumeYes, confirmFn } = {}) {
594
+ let existing = null;
595
+ try { existing = fs.readFileSync(dest, 'utf8'); } catch (_e) { existing = null; }
596
+
597
+ if (existing === content) {
598
+ return { status: 'already-current', dest };
599
+ }
600
+ if (existing === null) {
601
+ if (dryRun) return { status: 'would-install', dest };
602
+ writeFileAtomic(dest, content, mode);
603
+ return { status: 'installed', dest };
604
+ }
605
+
606
+ const ours = existing.includes('termdeck init --bridge');
607
+ if (!ours) {
608
+ if (dryRun) return { status: 'would-replace-foreign', dest };
609
+ let consent = !!assumeYes;
610
+ if (!consent && typeof confirmFn === 'function') consent = await confirmFn();
611
+ if (!consent) return { status: 'kept-foreign', dest };
612
+ } else if (dryRun) {
613
+ return { status: 'would-update', dest };
614
+ }
615
+
616
+ let backup = `${dest}.bak.${stamp()}`;
617
+ try { fs.copyFileSync(dest, backup); }
618
+ catch (_e) { backup = null; }
619
+ writeFileAtomic(dest, content, mode);
620
+ return { status: ours ? 'updated' : 'replaced', dest, backup };
621
+ }
622
+
623
+ function stageFiles({ stageDir, files, dryRun }) {
624
+ if (dryRun) return { status: 'would-stage', stageDir, names: files.map((f) => f.name) };
625
+ fs.mkdirSync(stageDir, { recursive: true });
626
+ for (const f of files) {
627
+ writeFileAtomic(path.join(stageDir, f.name), f.content, f.mode);
628
+ // writeFileAtomic's rename preserves the tmp file's mode, but be explicit
629
+ // in case the tmp inherited a stricter umask.
630
+ try { fs.chmodSync(path.join(stageDir, f.name), f.mode); } catch (_e) { /* best-effort */ }
631
+ }
632
+ return { status: 'staged', stageDir, names: files.map((f) => f.name) };
633
+ }
634
+
635
+ // ── Tier 5 reachability checks (GETTING-STARTED.md § Tier 5 Step 4) ────────
636
+
637
+ async function checkLocalBridgeUp({ fetchImpl, port = BRIDGE_PORT } = {}) {
638
+ const f = fetchImpl || fetch;
639
+ try {
640
+ const res = await f(`http://127.0.0.1:${port}/healthz`, { signal: AbortSignal.timeout(2500) });
641
+ return !!(res && res.ok);
642
+ } catch (_e) {
643
+ return false;
644
+ }
645
+ }
646
+
647
+ async function runReachabilityChecks({ hostname, fetchImpl } = {}) {
648
+ const f = fetchImpl || fetch;
649
+ const base = `https://${hostname}`;
650
+ const results = [];
651
+
652
+ async function probe(name, fn) {
653
+ try {
654
+ const detail = await fn();
655
+ results.push({ name, ok: true, detail: detail || '' });
656
+ } catch (err) {
657
+ results.push({ name, ok: false, detail: err && err.message || String(err) });
658
+ }
659
+ }
660
+
661
+ await probe(`GET ${base}/healthz`, async () => {
662
+ const res = await f(`${base}/healthz`, { signal: AbortSignal.timeout(8000) });
663
+ if (!res.ok) throw new Error(`HTTP ${res.status} (expected 200)`);
664
+ const body = await res.json();
665
+ if (body.ok !== true) throw new Error('body.ok !== true');
666
+ const wantResource = `${base}/mcp`;
667
+ if (body.resource !== wantResource) {
668
+ throw new Error(`resource is ${body.resource} (expected ${wantResource}) — the bridge is pinned to a stale URL; the supervisor re-pins it on its next tick`);
669
+ }
670
+ return `ok, resource=${body.resource}`;
671
+ });
672
+
673
+ await probe(`GET ${base}/.well-known/oauth-protected-resource/mcp`, async () => {
674
+ const res = await f(`${base}/.well-known/oauth-protected-resource/mcp`, { signal: AbortSignal.timeout(8000) });
675
+ if (!res.ok) throw new Error(`HTTP ${res.status} (expected 200)`);
676
+ const body = await res.json();
677
+ if (!body.resource) throw new Error('missing resource');
678
+ if (!Array.isArray(body.authorization_servers) || body.authorization_servers.length === 0) {
679
+ throw new Error('missing authorization_servers');
680
+ }
681
+ return 'resource + authorization_servers present';
682
+ });
683
+
684
+ await probe(`GET ${base}/.well-known/oauth-authorization-server`, async () => {
685
+ const res = await f(`${base}/.well-known/oauth-authorization-server`, { signal: AbortSignal.timeout(8000) });
686
+ if (!res.ok) throw new Error(`HTTP ${res.status} (expected 200)`);
687
+ const body = await res.json();
688
+ const methods = body.code_challenge_methods_supported || [];
689
+ if (!methods.includes('S256')) throw new Error('S256 missing from code_challenge_methods_supported');
690
+ return 'S256 PKCE advertised';
691
+ });
692
+
693
+ // The unauthenticated POST MUST be rejected — a 200 here means the OAuth
694
+ // gate is broken and the bridge is exposed; this check exists to fail loud.
695
+ await probe(`POST ${base}/mcp (unauthenticated)`, async () => {
696
+ const res = await f(`${base}/mcp`, {
697
+ method: 'POST',
698
+ headers: { 'content-type': 'application/json' },
699
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }),
700
+ signal: AbortSignal.timeout(8000)
701
+ });
702
+ if (res.status !== 401) {
703
+ throw new Error(`HTTP ${res.status} (expected 401 — unauthenticated requests MUST be rejected)`);
704
+ }
705
+ return '401 as required';
706
+ });
707
+
708
+ return results;
709
+ }
710
+
711
+ function printCheckResults(results) {
712
+ for (const r of results) {
713
+ if (r.ok) process.stdout.write(` ✓ ${r.name} — ${r.detail}\n`);
714
+ else process.stdout.write(` ✗ ${r.name} — ${r.detail}\n`);
715
+ }
716
+ }
717
+
718
+ // ── Wizard flow ──────────────────────────────────────────────────────────────
719
+
720
+ function printBanner() {
721
+ process.stdout.write(`
722
+ TermDeck Web-Chat Bridge Setup (Tier 5)
723
+ ───────────────────────────────────────
724
+
725
+ This wizard makes the Bridge's public URL permanent (named cloudflared
726
+ tunnel) and self-healing (stack supervisor on a 60s timer) by:
727
+ 1. Asking for a tunnel name + the public hostname for the Bridge
728
+ 2. Writing ~/.termdeck/supervisor.env immediately (merge-aware, backed
729
+ up — an abort later cannot lose what you typed in)
730
+ 3. PRINTING the cloudflared login/create/route steps for you to run
731
+ (browser auth — the wizard never runs them) and waiting for the
732
+ tunnel credentials to appear
733
+ 4. Writing ~/.cloudflared/config.yml (backing up any existing file)
734
+ 5. Copying the supervisor files into place from the vendored package
735
+ assets, then PRINTING the launchctl / systemctl enable steps
736
+ (never run by the wizard)
737
+ 6. Verifying public reachability with the four Tier 5 checks
738
+
739
+ Read-only by construction, approval-gated, egress-redacted — the security
740
+ model lives in packages/mcp-bridge/README.md. Manual flow + provider
741
+ wiring: docs/GETTING-STARTED.md § Tier 5.
742
+
743
+ Press Ctrl+C at any time to cancel.
744
+
745
+ `);
746
+ }
747
+
748
+ async function askWithDefaultValidated(question, defaultValue, validator) {
749
+ for (let i = 0; i < 3; i++) {
750
+ const answer = await promptsMod().ask(question, defaultValue ? { defaultValue } : {});
751
+ if (!answer) { process.stdout.write(' (required)\n'); continue; }
752
+ const err = validator(answer);
753
+ if (err) { process.stdout.write(` ${err}\n`); continue; }
754
+ return answer.trim();
755
+ }
756
+ throw new Error('Too many invalid attempts — cancelling. (Non-interactive? Use --from-env with TERMDECK_PUBLIC_HOSTNAME + TERMDECK_TUNNEL_NAME.)');
757
+ }
758
+
759
+ function inputsFromEnv() {
760
+ const hostname = (process.env.TERMDECK_PUBLIC_HOSTNAME || '').trim().toLowerCase();
761
+ const tunnelName = (process.env.TERMDECK_TUNNEL_NAME || '').trim() || DEFAULT_TUNNEL_NAME;
762
+ if (!hostname) {
763
+ throw new Error(
764
+ '--from-env is missing required environment variable(s): TERMDECK_PUBLIC_HOSTNAME.\n'
765
+ + 'Set it (and optionally TERMDECK_TUNNEL_NAME) and re-run, e.g.:\n'
766
+ + ' TERMDECK_PUBLIC_HOSTNAME=bridge.example.com TERMDECK_TUNNEL_NAME=termdeck-bridge termdeck init --bridge --from-env'
767
+ );
768
+ }
769
+ const hErr = validateHostname(hostname);
770
+ if (hErr) throw new Error(`TERMDECK_PUBLIC_HOSTNAME: ${hErr}`);
771
+ const tErr = validateTunnelName(tunnelName);
772
+ if (tErr) throw new Error(`TERMDECK_TUNNEL_NAME: ${tErr}`);
773
+ return { tunnelName, hostname };
774
+ }
775
+
776
+ async function collectInputs({ yes, reset, existing }) {
777
+ // Resume / idempotent-re-run path: a complete pair already in
778
+ // supervisor.env is offered back (update-or-keep), mirroring
779
+ // init-mnestra's saved-secrets reuse idiom.
780
+ const existingValid = existing
781
+ && existing.tunnelName && !validateTunnelName(existing.tunnelName)
782
+ && existing.hostname && !validateHostname(existing.hostname);
783
+ if (!reset && existingValid) {
784
+ process.stdout.write(
785
+ `Found existing bridge settings in ~/.termdeck/supervisor.env `
786
+ + `(tunnel '${existing.tunnelName}', hostname ${existing.hostname}).\n`
787
+ );
788
+ const reuse = yes ? true : await promptsMod().confirm(' Keep these settings?', { defaultYes: true });
789
+ if (reuse) {
790
+ process.stdout.write(' Keeping existing settings.\n\n');
791
+ return { tunnelName: existing.tunnelName, hostname: existing.hostname.toLowerCase() };
792
+ }
793
+ process.stdout.write(' Re-prompting.\n\n');
794
+ }
795
+
796
+ const tunnelName = await askWithDefaultValidated(
797
+ '? Tunnel name',
798
+ (existing && existing.tunnelName) || DEFAULT_TUNNEL_NAME,
799
+ validateTunnelName
800
+ );
801
+ const hostname = (await askWithDefaultValidated(
802
+ '? Public hostname for the Bridge (e.g. bridge.example.com — DNS must be on Cloudflare)',
803
+ (existing && existing.hostname) || null,
804
+ validateHostname
805
+ )).toLowerCase();
806
+ process.stdout.write('\n');
807
+ return { tunnelName, hostname };
808
+ }
809
+
810
+ function printOperatorTunnelSteps({ tunnelName, hostname, stageDir, staged, certPresent }) {
811
+ process.stdout.write('\nNext: create the named tunnel — operator steps, run in another terminal.\n');
812
+ process.stdout.write('(Browser auth is involved; the wizard never runs these itself.)\n\n');
813
+ if (staged) {
814
+ process.stdout.write(` One-shot: bash ${path.join(stageDir, 'setup-tunnel.sh')}\n\n`);
815
+ process.stdout.write(' …or step-by-step, one line at a time:\n');
816
+ } else {
817
+ process.stdout.write(' Step-by-step, one line at a time:\n');
818
+ }
819
+ process.stdout.write(` cloudflared tunnel login${certPresent ? ' # cert.pem already present — skip' : ''}\n`);
820
+ process.stdout.write(` cloudflared tunnel create '${tunnelName}'\n`);
821
+ process.stdout.write(` cloudflared tunnel route dns '${tunnelName}' '${hostname}'\n\n`);
822
+ }
823
+
824
+ function printResumeHint() {
825
+ process.stderr.write(
826
+ '\nYour answers are saved in ~/.termdeck/supervisor.env.\n'
827
+ + 'After completing the cloudflared steps, resume with:\n'
828
+ + ' termdeck init --bridge --yes\n'
829
+ );
830
+ }
831
+
832
+ function printNextSteps({ hostname }) {
833
+ process.stdout.write(`
834
+ The Web-Chat Bridge is scaffolded.
835
+
836
+ Connector URL (paste into each provider): https://${hostname}/mcp
837
+ Operator consent secret: ~/.termdeck/bridge-operator-secret.txt
838
+ (created by the supervisor's first tick if missing; it never rotates)
839
+
840
+ Provider wiring (full table: docs/GETTING-STARTED.md § Tier 5 Step 5):
841
+ Claude.ai Settings → Connectors → Add custom connector
842
+ ChatGPT Settings → Apps & Connectors → "New App" dialog
843
+ Grok grok.com → Connectors → New → Custom
844
+
845
+ Re-check public reachability anytime: termdeck init --bridge --verify-only
846
+ `);
847
+ }
848
+
849
+ async function runVerifyPass({ hostname, fetchImpl, verifyOnly }) {
850
+ step(`Checking the local bridge (http://127.0.0.1:${BRIDGE_PORT}/healthz)...`);
851
+ const localUp = await checkLocalBridgeUp({ fetchImpl });
852
+ if (!localUp) {
853
+ ok('not up yet');
854
+ process.stdout.write(
855
+ ' The stack isn\'t running locally — expected before the supervisor\'s first tick.\n'
856
+ + ' Once the supervisor is installed (step above), verify with:\n'
857
+ + ' termdeck init --bridge --verify-only\n'
858
+ );
859
+ return { ran: false, allOk: false };
860
+ }
861
+ ok('up');
862
+
863
+ step(`Running the four Tier 5 reachability checks against https://${hostname} ...`);
864
+ process.stdout.write('\n');
865
+ const results = await runReachabilityChecks({ hostname, fetchImpl });
866
+ printCheckResults(results);
867
+ const allOk = results.every((r) => r.ok);
868
+ if (allOk) {
869
+ process.stdout.write(' All four checks passed — any provider can discover, register, and complete OAuth.\n');
870
+ } else if (!verifyOnly) {
871
+ process.stdout.write(
872
+ ' Some checks failed. Fresh DNS routes + tunnel starts can take a minute to propagate;\n'
873
+ + ' re-check with: termdeck init --bridge --verify-only\n'
874
+ );
875
+ }
876
+ return { ran: true, allOk };
877
+ }
878
+
879
+ async function main(argv) {
880
+ const flags = parseFlags(argv || []);
881
+ if (flags.help) {
882
+ process.stdout.write(HELP);
883
+ return 0;
884
+ }
885
+
886
+ const HOME = os.homedir();
887
+ const cloudflaredDir = path.join(HOME, '.cloudflared');
888
+ const configPath = path.join(cloudflaredDir, 'config.yml');
889
+ const envPath = path.join(HOME, '.termdeck', 'supervisor.env');
890
+ const stageDir = path.join(HOME, '.termdeck', 'bridge-install');
891
+ const nonInteractive = flags.fromEnv || flags.yes;
892
+
893
+ // ── --verify-only: no prompts, no writes, exit 4 on any failure ──────────
894
+ if (flags.verifyOnly) {
895
+ const saved = fs.existsSync(envPath) ? dotenvMod().readSecrets(envPath) : {};
896
+ const hostname = (saved.TERMDECK_PUBLIC_HOSTNAME || process.env.TERMDECK_PUBLIC_HOSTNAME || '').trim().toLowerCase();
897
+ if (!hostname || validateHostname(hostname)) {
898
+ process.stderr.write(
899
+ '[init --bridge] --verify-only needs a configured hostname — none found in '
900
+ + '~/.termdeck/supervisor.env (TERMDECK_PUBLIC_HOSTNAME) or the environment.\n'
901
+ + 'Run the wizard first: termdeck init --bridge\n'
902
+ );
903
+ return 2;
904
+ }
905
+ process.stdout.write(`Verifying Bridge reachability for https://${hostname}\n\n`);
906
+ const verdict = await runVerifyPass({ hostname, verifyOnly: true });
907
+ return verdict.ran && verdict.allOk ? 0 : 4;
908
+ }
909
+
910
+ printBanner();
911
+
912
+ // ── Preflight ─────────────────────────────────────────────────────────────
913
+ step('Checking for cloudflared on PATH...');
914
+ const haveCloudflared = detectCloudflared();
915
+ if (haveCloudflared) ok();
916
+ else {
917
+ ok('not found');
918
+ process.stdout.write(
919
+ ' Install it first — macOS: brew install cloudflared | Linux: https://pkg.cloudflare.com\n'
920
+ + ' (The wizard continues; the staged one-shot re-checks before running anything.)\n'
921
+ );
922
+ }
923
+
924
+ const certPath = path.join(cloudflaredDir, 'cert.pem');
925
+ step('Checking ~/.cloudflared/cert.pem (origin cert from `cloudflared tunnel login`)...');
926
+ let certPresent = false;
927
+ try { certPresent = fs.statSync(certPath).size > 0; } catch (_e) { certPresent = false; }
928
+ ok(certPresent ? 'present' : 'not yet — the login step below creates it');
929
+
930
+ step('Checking ~/.termdeck/supervisor.env...');
931
+ const savedEnv = fs.existsSync(envPath) ? dotenvMod().readSecrets(envPath) : {};
932
+ const existing = {
933
+ tunnelName: savedEnv.TERMDECK_TUNNEL_NAME || null,
934
+ hostname: savedEnv.TERMDECK_PUBLIC_HOSTNAME || null
935
+ };
936
+ if (existing.tunnelName || existing.hostname) {
937
+ ok(`found (tunnel ${existing.tunnelName || '—'}, hostname ${existing.hostname || '—'})`);
938
+ } else {
939
+ ok('not yet created (will create)');
940
+ }
941
+
942
+ step('Checking ~/.cloudflared/config.yml...');
943
+ ok(fs.existsSync(configPath) ? 'present' : 'not yet created (will create)');
944
+ process.stdout.write('\n');
945
+
946
+ // ── Collect inputs ────────────────────────────────────────────────────────
947
+ let inputs;
948
+ try {
949
+ inputs = flags.fromEnv
950
+ ? inputsFromEnv()
951
+ : await collectInputs({ yes: flags.yes, reset: flags.reset, existing });
952
+ } catch (err) {
953
+ process.stderr.write(`\n[init --bridge] ${err.message}\n`);
954
+ return 2;
955
+ }
956
+ if (flags.fromEnv) {
957
+ process.stdout.write(`Using environment values (--from-env): tunnel '${inputs.tunnelName}', hostname ${inputs.hostname}.\n\n`);
958
+ }
959
+
960
+ // ── Persist-first (Class C): supervisor.env lands BEFORE the operator
961
+ // wait-loop so an abort there cannot lose the typed-in answers. ─────────
962
+ // TERMDECK_REPO_DIR fill: the supervisor script is staged under
963
+ // ~/.termdeck/bridge-install/, so its parent-dir REPO_DIR derivation would
964
+ // resolve wrong — pin the package/repo root via the supervisor's own env
965
+ // override. fill-if-missing only; an operator-set value always wins.
966
+ const pkgRoot = path.resolve(__dirname, '..', '..', '..');
967
+ step('Writing ~/.termdeck/supervisor.env (TERMDECK_TUNNEL_NAME + TERMDECK_PUBLIC_HOSTNAME)...');
968
+ let envResult;
969
+ try {
970
+ envResult = mergeSupervisorEnv({
971
+ envPath,
972
+ tunnelName: inputs.tunnelName,
973
+ hostname: inputs.hostname,
974
+ fillIfMissing: { TERMDECK_REPO_DIR: pkgRoot },
975
+ dryRun: flags.dryRun
976
+ });
977
+ } catch (err) {
978
+ fail(err.message);
979
+ process.stderr.write('\nFailed to write ~/.termdeck/supervisor.env. Check the directory is writable.\n');
980
+ return 6;
981
+ }
982
+ const addedSuffix = envResult.added && envResult.added.length
983
+ ? ` (set ${envResult.added.join(', ')})` : '';
984
+ if (envResult.status === 'already-set') ok('already set (no change)');
985
+ else if (envResult.status === 'created') ok(`created${addedSuffix}`);
986
+ else if (envResult.status === 'updated') ok((envResult.backup ? `updated (backup: ${path.basename(envResult.backup)})` : 'updated') + addedSuffix);
987
+ else ok(`(${envResult.status}${addedSuffix})`); // would-create / would-update (dry-run)
988
+
989
+ // ── Stage the vendored supervisor script + operator one-shots ─────────────
990
+ const plan = buildSupervisorInstallPlan({ platform: process.platform, home: HOME, stageDir });
991
+ const stageList = [
992
+ { name: 'setup-tunnel.sh', content: buildSetupTunnelScript(inputs), mode: 0o755 },
993
+ ...(plan.ok
994
+ ? [
995
+ { name: 'termdeck-supervise.sh', content: fs.readFileSync(plan.assets.paths.script, 'utf8'), mode: 0o755 },
996
+ ...plan.stageExtras
997
+ ]
998
+ : [])
999
+ ];
1000
+ step(`Staging the supervisor script + operator one-shots in ${stageDir}...`);
1001
+ try {
1002
+ const staged = stageFiles({ stageDir, files: stageList, dryRun: flags.dryRun });
1003
+ if (staged.status === 'staged') ok(`(${staged.names.join(', ')})`);
1004
+ else ok(`(dry-run — would stage ${staged.names.join(', ')})`);
1005
+ } catch (err) {
1006
+ fail(err.message);
1007
+ process.stderr.write('\nFailed to stage scripts under ~/.termdeck/. Check the directory is writable.\n');
1008
+ return 6;
1009
+ }
1010
+
1011
+ // ── Tunnel credentials: detect, or hand off to the operator and wait ─────
1012
+ step(`Looking for tunnel credentials in ~/.cloudflared/ (tunnel '${inputs.tunnelName}')...`);
1013
+ let found = findTunnelCredentials({
1014
+ cloudflaredDir,
1015
+ tunnelName: inputs.tunnelName,
1016
+ tunnelId: flags.tunnelId
1017
+ });
1018
+
1019
+ if (found.status === 'id-not-found') {
1020
+ ok('no match');
1021
+ process.stderr.write(
1022
+ `\n[init --bridge] --tunnel-id ${flags.tunnelId} does not match any credentials file in ~/.cloudflared/.\n`
1023
+ + (found.candidates.length
1024
+ ? `Found: ${found.candidates.map((c) => c.tunnelId).join(', ')}\n`
1025
+ : 'No tunnel credentials found at all — run the cloudflared steps first.\n')
1026
+ );
1027
+ printResumeHint();
1028
+ return 3;
1029
+ }
1030
+
1031
+ if (found.status === 'none') {
1032
+ ok('none yet');
1033
+ printOperatorTunnelSteps({ ...inputs, stageDir, staged: !flags.dryRun, certPresent });
1034
+ if (flags.dryRun) {
1035
+ process.stdout.write('(dry-run) config.yml step pending tunnel credentials — shown as a plan below.\n');
1036
+ } else if (nonInteractive) {
1037
+ // One immediate re-check covers the "operator already ran the steps in
1038
+ // parallel" race, then hand control back rather than hanging a script.
1039
+ found = findTunnelCredentials({ cloudflaredDir, tunnelName: inputs.tunnelName, tunnelId: flags.tunnelId });
1040
+ if (found.status === 'none' || found.status === 'ambiguous') {
1041
+ process.stdout.write('Tunnel credentials not present yet — run the steps above, then resume.\n');
1042
+ printResumeHint();
1043
+ return 3;
1044
+ }
1045
+ } else {
1046
+ while (found.status === 'none') {
1047
+ const again = await promptsMod().confirm(
1048
+ ' Completed those steps in another terminal — re-check for tunnel credentials?',
1049
+ { defaultYes: true }
1050
+ );
1051
+ if (!again) { printResumeHint(); return 3; }
1052
+ found = findTunnelCredentials({ cloudflaredDir, tunnelName: inputs.tunnelName, tunnelId: flags.tunnelId });
1053
+ if (found.status === 'none') {
1054
+ process.stdout.write(` Still no credentials JSON in ~/.cloudflared/ — looking for <tunnel-id>.json created by \`cloudflared tunnel create '${inputs.tunnelName}'\`.\n`);
1055
+ }
1056
+ }
1057
+ }
1058
+ } else if (found.status === 'match') {
1059
+ ok(`found (${found.creds.tunnelId})`);
1060
+ } else if (found.status === 'single') {
1061
+ ok(`found one credentials file (${found.creds.tunnelId})`);
1062
+ } else if (found.status === 'ambiguous') {
1063
+ ok(`${found.candidates.length} candidates`);
1064
+ }
1065
+
1066
+ // Disambiguate when multiple credentials exist and none is name-matched.
1067
+ // Never guess silently (Class D posture): interactive picks, non-interactive
1068
+ // exits with the list and the --tunnel-id escape hatch.
1069
+ if (found.status === 'ambiguous') {
1070
+ process.stdout.write('\nMultiple tunnel credentials found and none is name-matched (older cloudflared writes no TunnelName):\n');
1071
+ for (const c of found.candidates) {
1072
+ process.stdout.write(` ${c.tunnelId} (${c.tunnelName || 'unnamed'}, modified ${new Date(c.mtimeMs).toISOString().slice(0, 10)})\n`);
1073
+ }
1074
+ if (nonInteractive || flags.dryRun) {
1075
+ process.stderr.write('\n[init --bridge] cannot pick a tunnel non-interactively — re-run with --tunnel-id <uuid> from the list above.\n');
1076
+ return 3;
1077
+ }
1078
+ const ids = new Set(found.candidates.map((c) => c.tunnelId));
1079
+ const pickedId = await promptsMod().askRequired('? Tunnel ID to use for this bridge', {
1080
+ validate: (v) => (ids.has(v.trim()) ? null : 'not in the list above')
1081
+ });
1082
+ found = { status: 'match', creds: found.candidates.find((c) => c.tunnelId === pickedId.trim()), candidates: found.candidates };
1083
+ }
1084
+
1085
+ // 'single' with no name metadata: confirm it belongs to this tunnel name
1086
+ // before binding config.yml to it (auto-accepted under --yes/--from-env,
1087
+ // loudly logged either way).
1088
+ if (found.status === 'single' && !found.creds.tunnelName && !flags.tunnelId && !flags.dryRun) {
1089
+ if (!nonInteractive) {
1090
+ const useIt = await promptsMod().confirm(
1091
+ ` Use credentials ${path.basename(found.creds.file)} for tunnel '${inputs.tunnelName}'?`,
1092
+ { defaultYes: true }
1093
+ );
1094
+ if (!useIt) {
1095
+ process.stderr.write('\n[init --bridge] re-run with --tunnel-id <uuid> to pick explicitly.\n');
1096
+ return 3;
1097
+ }
1098
+ } else {
1099
+ process.stdout.write(` Using the only credentials file present: ${path.basename(found.creds.file)}\n`);
1100
+ }
1101
+ }
1102
+
1103
+ // ── config.yml ────────────────────────────────────────────────────────────
1104
+ if (found.creds) {
1105
+ step('Writing ~/.cloudflared/config.yml (ingress → http://127.0.0.1:' + BRIDGE_PORT + ')...');
1106
+ let cfg;
1107
+ try {
1108
+ cfg = await writeCloudflaredConfig({
1109
+ cloudflaredDir,
1110
+ configPath,
1111
+ tunnelId: found.creds.tunnelId,
1112
+ hostname: inputs.hostname,
1113
+ dryRun: flags.dryRun,
1114
+ assumeYes: flags.yes || flags.fromEnv,
1115
+ confirmFn: nonInteractive ? null : () => promptsMod().confirm(
1116
+ '\n Existing ~/.cloudflared/config.yml was NOT written by this wizard. Back it up and replace?',
1117
+ { defaultYes: false }
1118
+ )
1119
+ });
1120
+ } catch (err) {
1121
+ fail(err.message);
1122
+ process.stderr.write('\nFailed to write ~/.cloudflared/config.yml. Check the directory is writable.\n');
1123
+ return 6;
1124
+ }
1125
+ if (cfg.status === 'already-configured') ok('already configured (no change)');
1126
+ else if (cfg.status === 'written') ok('written');
1127
+ else if (cfg.status === 'updated' || cfg.status === 'replaced') ok(cfg.backup ? `${cfg.status} (backup: ${path.basename(cfg.backup)})` : cfg.status);
1128
+ else if (cfg.status === 'kept-foreign') {
1129
+ ok('kept existing file (your choice)');
1130
+ process.stdout.write(
1131
+ ' The named tunnel will not route to the Bridge until config.yml includes this ingress\n'
1132
+ + ' (merge it manually, or re-run with --yes to replace — a timestamped backup is always written):\n\n'
1133
+ + ` tunnel: ${found.creds.tunnelId}\n`
1134
+ + ` credentials-file: ${path.join(cloudflaredDir, found.creds.tunnelId + '.json')}\n`
1135
+ + ' ingress:\n'
1136
+ + ` - hostname: ${inputs.hostname}\n`
1137
+ + ` service: http://127.0.0.1:${BRIDGE_PORT}\n`
1138
+ + ' - service: http_status:404\n\n'
1139
+ );
1140
+ } else ok(`(${cfg.status})`); // would-write / would-update / would-replace-foreign (dry-run)
1141
+ }
1142
+
1143
+ // ── Supervisor install: copy the vendored files AS FILES to their final
1144
+ // paths, then print the operator load/enable steps — never exec'd
1145
+ // (ORCH decision 2026-06-11 20:58) ───────────────────────────────────────
1146
+ if (!plan.ok) {
1147
+ step('Installing supervisor files...');
1148
+ fail(`vendored supervisor assets missing under ${plan.assetsDir}: ${plan.missing.join(', ')}`);
1149
+ process.stdout.write(
1150
+ ' This install looks corrupted — the assets ship inside the npm package.\n'
1151
+ + ' Reinstall with: npm install -g @jhizzard/termdeck@latest\n'
1152
+ );
1153
+ } else {
1154
+ let wroteAny = false;
1155
+ let keptForeign = false;
1156
+ let reloadNeeded = false;
1157
+ for (const target of plan.targets) {
1158
+ step(`Installing ${target.kind} → ${target.dest}...`);
1159
+ let r;
1160
+ try {
1161
+ r = await installManagedFile({
1162
+ dest: target.dest,
1163
+ content: target.content,
1164
+ mode: target.mode,
1165
+ dryRun: flags.dryRun,
1166
+ assumeYes: flags.yes,
1167
+ confirmFn: nonInteractive ? null : () => promptsMod().confirm(
1168
+ `\n Existing ${target.kind} at ${target.dest} was NOT written by this wizard. Back it up and replace?`,
1169
+ { defaultYes: false }
1170
+ )
1171
+ });
1172
+ } catch (err) {
1173
+ // Fail-soft: a supervisor-file failure should not strand the verify
1174
+ // pass or the next-steps print; the operator can re-run.
1175
+ fail(err.message);
1176
+ continue;
1177
+ }
1178
+ if (r.status === 'already-current') ok('already installed (current)');
1179
+ else if (r.status === 'installed') { ok('installed'); wroteAny = true; }
1180
+ else if (r.status === 'updated' || r.status === 'replaced') {
1181
+ ok(r.backup ? `${r.status} (backup: ${path.basename(r.backup)})` : r.status);
1182
+ wroteAny = true;
1183
+ reloadNeeded = true;
1184
+ } else if (r.status === 'kept-foreign') {
1185
+ ok('kept existing file (your choice)');
1186
+ keptForeign = true;
1187
+ } else {
1188
+ ok(`(${r.status})`); // would-install / would-update / would-replace-foreign (dry-run)
1189
+ }
1190
+ }
1191
+ if (keptForeign) {
1192
+ process.stdout.write(
1193
+ ' Kept a non-wizard-managed supervisor file — it keeps running whatever it points at.\n'
1194
+ + ' Re-run with --yes to adopt the managed version (a timestamped backup is always written).\n'
1195
+ );
1196
+ }
1197
+ if (!flags.dryRun && !wroteAny && !keptForeign) {
1198
+ process.stdout.write(
1199
+ ' Supervisor files already current. The supervisor re-reads supervisor.env every tick —\n'
1200
+ + ' new tunnel settings need no reinstall.\n'
1201
+ + ` (Not ticking yet? Run: ${plan.oneShot ? `bash ${plan.oneShot}` : plan.operatorLines[0]})\n`
1202
+ );
1203
+ } else {
1204
+ process.stdout.write('\nEnable the supervisor — operator step, the wizard never runs '
1205
+ + (plan.platform === 'darwin' ? 'launchctl' : 'systemctl') + ':\n\n');
1206
+ if (plan.oneShot && !flags.dryRun) {
1207
+ process.stdout.write(` One-shot: bash ${plan.oneShot}\n\n`);
1208
+ process.stdout.write(' …or step-by-step, one line at a time:\n');
1209
+ }
1210
+ for (const line of plan.operatorLines) process.stdout.write(` ${line}\n`);
1211
+ if (reloadNeeded && plan.reloadHint) {
1212
+ process.stdout.write(` (already loaded? unload first so the change is picked up: ${plan.reloadHint})\n`);
1213
+ }
1214
+ process.stdout.write('\n');
1215
+ }
1216
+ }
1217
+
1218
+ // ── Verify pass ───────────────────────────────────────────────────────────
1219
+ if (flags.dryRun) {
1220
+ process.stdout.write('\nDry run complete. No changes were made.\n');
1221
+ return 0;
1222
+ }
1223
+ if (flags.skipVerify) {
1224
+ process.stdout.write('\nSkipping reachability checks (--skip-verify).\n');
1225
+ } else {
1226
+ process.stdout.write('\n');
1227
+ await runVerifyPass({ hostname: inputs.hostname, verifyOnly: false });
1228
+ }
1229
+
1230
+ printNextSteps({ hostname: inputs.hostname });
1231
+ return 0;
1232
+ }
1233
+
1234
+ if (require.main === module) {
1235
+ main(process.argv.slice(2))
1236
+ .then((code) => process.exit(code || 0))
1237
+ .catch((err) => {
1238
+ process.stderr.write(`\n[init --bridge] unexpected error: ${err && err.stack || err}\n`);
1239
+ process.exit(1);
1240
+ });
1241
+ }
1242
+
1243
+ module.exports = main;
1244
+ // Exported for packages/cli/tests/init-bridge.test.js — same pattern as
1245
+ // init-mnestra.js's test exports.
1246
+ module.exports.parseFlags = parseFlags;
1247
+ module.exports.validateTunnelName = validateTunnelName;
1248
+ module.exports.validateHostname = validateHostname;
1249
+ module.exports.detectCloudflared = detectCloudflared;
1250
+ module.exports.findTunnelCredentials = findTunnelCredentials;
1251
+ module.exports.buildCloudflaredConfigYml = buildCloudflaredConfigYml;
1252
+ module.exports.readCloudflaredConfigState = readCloudflaredConfigState;
1253
+ module.exports.writeCloudflaredConfig = writeCloudflaredConfig;
1254
+ module.exports.mergeSupervisorEnv = mergeSupervisorEnv;
1255
+ module.exports.buildSetupTunnelScript = buildSetupTunnelScript;
1256
+ module.exports.resolveSuperviseAssets = resolveSuperviseAssets;
1257
+ module.exports.renderTemplate = renderTemplate;
1258
+ module.exports.buildLaunchdPlist = buildLaunchdPlist;
1259
+ module.exports.buildSystemdService = buildSystemdService;
1260
+ module.exports.buildSystemdTimer = buildSystemdTimer;
1261
+ module.exports.buildInstallSupervisorScript = buildInstallSupervisorScript;
1262
+ module.exports.buildSupervisorInstallPlan = buildSupervisorInstallPlan;
1263
+ module.exports.installManagedFile = installManagedFile;
1264
+ module.exports.stageFiles = stageFiles;
1265
+ module.exports.SUPERVISE_ASSETS_DIR = ASSETS_DIR;
1266
+ module.exports.runReachabilityChecks = runReachabilityChecks;
1267
+ module.exports.checkLocalBridgeUp = checkLocalBridgeUp;
1268
+ module.exports.DEFAULT_TUNNEL_NAME = DEFAULT_TUNNEL_NAME;
1269
+ module.exports.BRIDGE_PORT = BRIDGE_PORT;
1270
+ module.exports.CONFIG_MARKER = CONFIG_MARKER;