@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.
- package/package.json +5 -3
- package/packages/cli/assets/supervise/com.jhizzard.termdeck-supervise.plist +38 -0
- package/packages/cli/assets/supervise/termdeck-supervise.service +27 -0
- package/packages/cli/assets/supervise/termdeck-supervise.sh +146 -0
- package/packages/cli/assets/supervise/termdeck-supervise.timer +14 -0
- package/packages/cli/src/index.js +15 -2
- package/packages/cli/src/init-bridge.js +1270 -0
- package/packages/cli/src/init.js +1 -0
- package/packages/client/public/app.js +135 -9
- package/packages/client/public/index.html +1 -0
- package/packages/client/public/input-guard.js +192 -0
- package/packages/client/public/style.css +63 -0
- package/packages/server/src/agent-adapters/agy.js +21 -30
- package/packages/server/src/agent-adapters/web-chat-grok.js +22 -22
- package/packages/server/src/index.js +98 -4
- package/packages/server/src/sprints/status-parser.js +14 -4
- package/packages/stack-installer/assets/hooks/README.md +25 -15
- package/packages/stack-installer/assets/hooks/memory-pre-compact.js +35 -7
- package/packages/stack-installer/assets/hooks/memory-session-end.js +121 -27
|
@@ -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;
|