@lifeaitools/clauth 2.1.1 → 2.10.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.
Files changed (98) hide show
  1. package/.clauth-skill/references/keys-guide.md +270 -270
  2. package/cli/api.js +238 -238
  3. package/cli/commands/install.js +413 -396
  4. package/cli/commands/login.test.js +49 -1
  5. package/cli/commands/ops-install.js +11 -10
  6. package/cli/commands/ops.test.js +270 -0
  7. package/cli/commands/scrub.test.js +70 -0
  8. package/cli/commands/serve.js +2613 -11219
  9. package/cli/commands/uninstall.js +164 -164
  10. package/cli/dashboard/dashboard.js +592 -0
  11. package/cli/dashboard/global.css +65 -0
  12. package/cli/dashboard/panel-element.js +79 -0
  13. package/cli/dashboard/panels/build-status-panel.css +12 -0
  14. package/cli/dashboard/panels/build-status-panel.js +98 -0
  15. package/cli/dashboard/panels/panel-registry.js +58 -0
  16. package/cli/dashboard/panels/services-panel.css +95 -0
  17. package/cli/dashboard/panels/services-panel.js +872 -0
  18. package/cli/dashboard/panels/surfaces-panel.css +59 -0
  19. package/cli/dashboard/panels/surfaces-panel.js +474 -0
  20. package/cli/dashboard/panels/toolbar-panel.css +42 -0
  21. package/cli/dashboard/panels/toolbar-panel.js +374 -0
  22. package/cli/dashboard/panels/tunnel-panel.css +61 -0
  23. package/cli/dashboard/panels/tunnel-panel.js +688 -0
  24. package/cli/dashboard/panels/webdav-panel.css +38 -0
  25. package/cli/dashboard/panels/webdav-panel.js +166 -0
  26. package/cli/fingerprint.test.js +95 -0
  27. package/cli/http/components/callagent-terminal-component.js +310 -0
  28. package/cli/http/components/dashboard-component.js +123 -0
  29. package/cli/http/components/inbox-component.js +41 -0
  30. package/cli/http/components/mcp-transport-component.js +275 -0
  31. package/cli/http/components/oauth-component.js +207 -0
  32. package/cli/http/components/service-crud-component.js +288 -0
  33. package/cli/http/components/supervisor-component.js +104 -0
  34. package/cli/http/components/system-component.js +184 -0
  35. package/cli/http/components/tunnel-component.js +508 -0
  36. package/cli/http/components/vault-auth-component.js +264 -0
  37. package/cli/http/components/watchdog-component.js +28 -0
  38. package/cli/http/request-utils.js +21 -0
  39. package/cli/http/servers/ops-server.js +335 -0
  40. package/cli/index.js +5 -91
  41. package/cli/lib/fs-git.test.js +467 -0
  42. package/cli/mcp/providers/call-agent-provider.js +30 -0
  43. package/cli/mcp/providers/clauth-core-provider.js +461 -0
  44. package/cli/mcp/providers/ops-provider.js +42 -0
  45. package/cli/mcp/providers/terminal-provider.js +81 -0
  46. package/cli/mcp/tool-registry.js +48 -0
  47. package/cli/mcp/tool-schemas.js +822 -0
  48. package/cli/mcp/tool-schemas.test.js +49 -0
  49. package/cli/ops/pm2-preflight.js +54 -0
  50. package/cli/recovery.js +6 -0
  51. package/cli/recovery.test.js +179 -0
  52. package/cli/services/agent-pool-instance.js +38 -0
  53. package/cli/services/claude-binary.js +29 -0
  54. package/cli/services/delegation-lane-instance.js +35 -0
  55. package/cli/services/delegation-lane-instance.test.js +37 -0
  56. package/cli/services/file-io.js +38 -0
  57. package/cli/services/file-io.test.js +10 -0
  58. package/cli/services/fs-mount.js +66 -0
  59. package/cli/services/fs-mount.test.js +14 -0
  60. package/cli/services/fs-upload-sessions.js +20 -0
  61. package/cli/services/fs-upload-sessions.test.js +14 -0
  62. package/cli/services/git-exec.js +37 -0
  63. package/cli/services/git-exec.test.js +41 -0
  64. package/cli/services/git-import-guard.js +28 -0
  65. package/cli/services/git-import-guard.test.js +18 -0
  66. package/cli/services/service-status.js +191 -0
  67. package/cli/services/service-status.test.js +39 -0
  68. package/cli/services/terminal-dispatch.js +135 -0
  69. package/cli/services/terminal-dispatch.test.js +9 -0
  70. package/cli/services/terminal-registry.js +39 -0
  71. package/cli/services/terminal-registry.test.js +31 -0
  72. package/cli/services/terminal-sessions.js +100 -0
  73. package/cli/services/terminal-sessions.test.js +26 -0
  74. package/cli/services/terminal-window.js +106 -0
  75. package/cli/services/vault-read.js +14 -0
  76. package/cli/services/vault-read.test.js +35 -0
  77. package/cli/supervisor-registry.js +116 -0
  78. package/cli/supervisor-registry.test.js +165 -0
  79. package/cli/supervisor-ui.test.js +230 -30
  80. package/cli/webdav-config.js +84 -0
  81. package/cli/webdav-config.test.js +79 -0
  82. package/cli/webdav-obscure.js +23 -0
  83. package/cli/webdav-obscure.test.js +41 -0
  84. package/cli/webdav-rclone-discovery.js +44 -0
  85. package/cli/webdav-service.js +30 -118
  86. package/cli/webdav-service.test.js +107 -0
  87. package/install.ps1 +102 -102
  88. package/install.sh +49 -49
  89. package/package.json +2 -2
  90. package/scripts/bootstrap.cjs +121 -121
  91. package/supabase/functions/auth-vault/index.ts +350 -350
  92. package/supabase/migrations/001_clauth_schema.sql +94 -94
  93. package/supabase/migrations/002_vault_helpers.sql +90 -90
  94. package/supabase/migrations/20260317_lockout.sql +26 -26
  95. package/cli/assets/codevelop/launcher-active.cmd.template +0 -20
  96. package/cli/assets/codevelop/launcher-static.cmd.template +0 -7
  97. package/cli/assets/codevelop/windows-terminal.profiles.json +0 -48
  98. package/cli/commands/codevelop.js +0 -1190
@@ -0,0 +1,508 @@
1
+ // cli/http/components/tunnel-component.js
2
+ // The Tunnel component: cloudflared tunnel lifecycle (start/stop/status),
3
+ // Cloudflare API setup wizard (token verify, list/create tunnels, save config,
4
+ // DNS routing), and diagnostics (roundtrip test, binary presence check).
5
+ // Registered via dependency injection -- these handlers close over the live
6
+ // daemon's tunnel process state (tunnelProc/tunnelUrl/tunnelError/tunnelStatus/
7
+ // tunnelHostname), which stays as plain "let" variables inside createServer();
8
+ // ctx.tunnel exposes them as get/set accessors so this file can read and
9
+ // mutate the SAME variables without serve.js needing a file-wide state-object
10
+ // refactor to thread them through.
11
+ import { deriveToken } from "../../fingerprint.js";
12
+ import * as api from "../../api.js";
13
+ import { operation } from "../../supervisor-registry.js";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import os from "node:os";
17
+ import { readBody } from "../request-utils.js";
18
+
19
+ const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
20
+
21
+ export function registerTunnelRoutes(ctx) {
22
+ ctx.registerReadOnlyRoute("GET", "/tunnel", async (req, res) =>
23
+ ctx.ok(res, {
24
+ status: ctx.tunnel.status,
25
+ running: !!ctx.tunnel.proc,
26
+ url: ctx.tunnel.url || null,
27
+ sseUrl: ctx.tunnel.url && ctx.tunnel.url.startsWith("http") ? `${ctx.tunnel.url}/sse` : null,
28
+ error: ctx.tunnel.error || null,
29
+ }));
30
+ ctx.registerReadOnlyRoute("GET", "/tunnel/test", async (req, res) => {
31
+ if (ctx.lockedGuard(res)) return;
32
+ if (!ctx.tunnel.url || !ctx.tunnel.url.startsWith("http")) {
33
+ return ctx.ok(res, { ok: false, reason: "Tunnel not connected" });
34
+ }
35
+ const start = Date.now();
36
+ try {
37
+ // Hit /ping through the public tunnel URL — proves full roundtrip
38
+ const resp = await fetch(ctx.tunnel.url + "/ping", { signal: AbortSignal.timeout(8000) });
39
+ const data = await resp.json();
40
+ const latencyMs = Date.now() - start;
41
+ if (data.status === "ok") {
42
+ // Also verify SSE endpoint is reachable
43
+ const sseResp = await fetch(ctx.tunnel.url + "/sse", { signal: AbortSignal.timeout(5000) });
44
+ const sseOk = sseResp.headers.get("content-type")?.includes("text/event-stream");
45
+ sseResp.body?.cancel?.();
46
+ return ctx.ok(res, {
47
+ ok: true,
48
+ latencyMs,
49
+ tunnelUrl: ctx.tunnel.url,
50
+ sseUrl: ctx.tunnel.url + "/sse",
51
+ sseReachable: !!sseOk,
52
+ pid: data.pid,
53
+ });
54
+ }
55
+ return ctx.ok(res, { ok: false, reason: "Ping returned unexpected response", data });
56
+ } catch (e) {
57
+ return ctx.ok(res, { ok: false, reason: e.message, latencyMs: Date.now() - start });
58
+ }
59
+ });
60
+ ctx.registerReadOnlyRoute("GET", "/tunnel/setup/cf-tunnels", async (req, res) => {
61
+ if (ctx.lockedGuard(res)) return;
62
+ try {
63
+ const { token: t, timestamp } = deriveToken(ctx.password, ctx.machineHash);
64
+ const cr = await api.retrieve(ctx.password, ctx.machineHash, t, timestamp, "cloudflare");
65
+ const cfToken = cr?.value;
66
+ if (!cfToken) return ctx.strike(res, 400, "No cloudflare token in vault");
67
+
68
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
69
+ const sbKey = api.getAnonKey();
70
+ const acctResp = await fetch(
71
+ `${sbUrl}/rest/v1/clauth_config?key=eq.cf_account_id&select=value`,
72
+ { headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) }
73
+ );
74
+ const acctRows = await acctResp.json();
75
+ const accountId = acctRows?.[0]?.value ? JSON.parse(acctRows[0].value) : null;
76
+ if (!accountId) return ctx.strike(res, 400, "No account ID — run cf-token first");
77
+
78
+ const tr = await fetch(
79
+ `https://api.cloudflare.com/client/v4/accounts/${accountId}/cfd_tunnel?is_deleted=false`,
80
+ { headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000) }
81
+ );
82
+ const td = await tr.json();
83
+ return ctx.ok(res, { tunnels: (td?.result || []).map(t => ({ id: t.id, name: t.name, status: t.status, created_at: t.created_at })) });
84
+ } catch (err) {
85
+ return ctx.strike(res, 502, err.message);
86
+ }
87
+ });
88
+ ctx.registerReadOnlyRoute("GET", "/tunnel/setup/check-cloudflared", async (req, res) => {
89
+ let cfBin = "cloudflared";
90
+ let installed = false;
91
+ if (os.platform() === "win32") {
92
+ const candidates = [
93
+ "cloudflared",
94
+ path.join(process.env.ProgramFiles || "", "cloudflared", "cloudflared.exe"),
95
+ path.join(process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "cloudflared", "cloudflared.exe"),
96
+ path.join(os.homedir(), "scoop", "shims", "cloudflared.exe"),
97
+ "C:\\ProgramData\\chocolatey\\bin\\cloudflared.exe",
98
+ ];
99
+ for (const c of candidates) {
100
+ try { if (fs.statSync(c).isFile()) { cfBin = c; installed = true; break; } } catch {}
101
+ }
102
+ }
103
+ if (!installed) {
104
+ try {
105
+ const { execSync: es } = await import("child_process");
106
+ es("cloudflared --version", { stdio: "ignore" });
107
+ installed = true; cfBin = "cloudflared";
108
+ } catch {}
109
+ }
110
+ return ctx.ok(res, { installed, path: installed ? cfBin : null });
111
+ });
112
+ ctx.registerWriteRoute("POST", "/tunnel", async (req, res) => {
113
+ if (ctx.lockedGuard(res)) return;
114
+ let body;
115
+ try { body = await readBody(req); } catch {
116
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
117
+ return res.end(JSON.stringify({ error: "Invalid JSON" }));
118
+ }
119
+ if (body.action === "stop") {
120
+ ctx.stopTunnel();
121
+ operation("tunnel.stop", {}, null, { ok: true });
122
+ return ctx.ok(res, { status: ctx.tunnel.status, running: false });
123
+ }
124
+ // start
125
+ await ctx.startTunnel();
126
+ operation("tunnel.start", {}, null, { ok: !!ctx.tunnel.proc, url: ctx.tunnel.url, error: ctx.tunnel.error });
127
+ return ctx.ok(res, { status: ctx.tunnel.status, running: !!ctx.tunnel.proc, url: ctx.tunnel.url, error: ctx.tunnel.error });
128
+ });
129
+ ctx.registerWriteRoute("POST", "/tunnel/start", async (req, res) => {
130
+ if (ctx.lockedGuard(res)) return;
131
+ if (ctx.tunnel.proc) return ctx.ok(res, { status: ctx.tunnel.status, message: "already running" });
132
+ ctx.tunnel.status = "starting";
133
+ ctx.startTunnel().catch(() => {});
134
+ operation("tunnel.start", {}, null, { ok: true, status: "starting" });
135
+ return ctx.ok(res, { status: "starting" });
136
+ });
137
+ ctx.registerWriteRoute("POST", "/tunnel/stop", async (req, res) => {
138
+ if (ctx.lockedGuard(res)) return;
139
+ ctx.stopTunnel();
140
+ operation("tunnel.stop", {}, null, { ok: true });
141
+ return ctx.ok(res, { status: ctx.tunnel.status });
142
+ });
143
+ ctx.registerWriteRoute("POST", "/tunnel/setup/cf-token", async (req, res) => {
144
+ if (ctx.writeGuard(req, res)) return;
145
+ let body;
146
+ try { body = await readBody(req); } catch { return ctx.strike(res, 400, "Invalid JSON"); }
147
+ const { token: cfToken } = body;
148
+ if (!cfToken) return ctx.strike(res, 400, "token required");
149
+
150
+ try {
151
+ const vr = await fetch("https://api.cloudflare.com/client/v4/user/tokens/verify", {
152
+ headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000)
153
+ });
154
+ const vd = await vr.json();
155
+ if (!vd?.success) return ctx.strike(res, 400, "Invalid Cloudflare API token");
156
+
157
+ const ar = await fetch("https://api.cloudflare.com/client/v4/accounts", {
158
+ headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000)
159
+ });
160
+ const ad = await ar.json();
161
+ const accountId = ad?.result?.[0]?.id;
162
+ const accountName = ad?.result?.[0]?.name;
163
+
164
+ // Save token to vault using the same guarded recovery path as /set/:service.
165
+ await writeCredentialWithRecovery({ password: ctx.password, machineHash: ctx.machineHash, service: "cloudflare", value: cfToken, logFile: LOG_FILE });
166
+
167
+ // Save accountId to clauth_config
168
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
169
+ const sbKey = api.getAnonKey();
170
+ if (accountId) {
171
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
172
+ method: "POST",
173
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
174
+ body: JSON.stringify({ key: "cf_account_id", value: accountId }),
175
+ });
176
+ }
177
+
178
+ operation("tunnel.setup.cf_token", {}, null, { ok: true, accountId, accountName });
179
+ return ctx.ok(res, { ok: true, accountId, accountName });
180
+ } catch (err) {
181
+ operation("tunnel.setup.cf_token", {}, null, { ok: false, error: err.message });
182
+ return ctx.strike(res, 502, err.message);
183
+ }
184
+ });
185
+ ctx.registerWriteRoute("POST", "/tunnel/setup/cf-save", async (req, res) => {
186
+ if (ctx.lockedGuard(res)) return;
187
+ let body;
188
+ try { body = await readBody(req); } catch { return ctx.strike(res, 400, "Invalid JSON"); }
189
+ const { tunnelId, tunnelName, hostname } = body;
190
+ if (!hostname) return ctx.strike(res, 400, "hostname required");
191
+
192
+ try {
193
+ const cfDir = path.join(os.homedir(), ".cloudflared");
194
+ if (!fs.existsSync(cfDir)) fs.mkdirSync(cfDir, { recursive: true });
195
+ const credFile = tunnelId ? path.join(cfDir, `${tunnelId}.json`) : path.join(cfDir, "tunnel.json");
196
+ const configContent = `tunnel: ${tunnelId || tunnelName || "clauth"}\ncredentials-file: ${credFile}\ningress:\n - hostname: ${hostname}\n service: http://127.0.0.1:${ctx.port}\n - service: http_status:404\n`;
197
+ fs.writeFileSync(path.join(cfDir, "config.yml"), configContent, "utf8");
198
+
199
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
200
+ const sbKey = api.getAnonKey();
201
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
202
+ method: "POST",
203
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
204
+ body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(hostname) }),
205
+ });
206
+
207
+ // Persist tunnel_id too — required for token-based self-heal after a
208
+ // daemon restart or a ~/.cloudflared wipe (getTunnelRunToken reads it).
209
+ if (tunnelId) {
210
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
211
+ method: "POST",
212
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
213
+ body: JSON.stringify({ key: "tunnel_id", value: JSON.stringify(tunnelId) }),
214
+ });
215
+ }
216
+
217
+ ctx.tunnel.hostname = hostname;
218
+ ctx.tunnel.url = `https://${hostname}`;
219
+
220
+ operation("tunnel.setup.cf_save", { hostname, tunnelId }, null, { ok: true });
221
+ return ctx.ok(res, { ok: true, hostname });
222
+ } catch (err) {
223
+ operation("tunnel.setup.cf_save", { hostname }, null, { ok: false, error: err.message });
224
+ return ctx.strike(res, 502, err.message);
225
+ }
226
+ });
227
+ ctx.registerWriteRoute("POST", "/tunnel/setup/cf-create-api", async (req, res) => {
228
+ if (ctx.lockedGuard(res)) return;
229
+ let body;
230
+ try { body = await readBody(req); } catch { return ctx.strike(res, 400, "Invalid JSON"); }
231
+ const { name, hostname, accountId: bodyAccountId } = body;
232
+ if (!name || !hostname) return ctx.strike(res, 400, "name and hostname required");
233
+ try {
234
+ const { token: t, timestamp } = deriveToken(ctx.password, ctx.machineHash);
235
+ const cr = await api.retrieve(ctx.password, ctx.machineHash, t, timestamp, "cloudflare");
236
+ const cfToken = cr?.value;
237
+ if (!cfToken) return ctx.strike(res, 400, "No cloudflare token in vault");
238
+
239
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
240
+ const sbKey = api.getAnonKey();
241
+
242
+ // Get accountId
243
+ let accountId = bodyAccountId;
244
+ if (!accountId) {
245
+ const acctResp = await fetch(`${sbUrl}/rest/v1/clauth_config?key=eq.cf_account_id&select=value`,
246
+ { headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) });
247
+ const rows = await acctResp.json();
248
+ accountId = rows?.[0]?.value ? JSON.parse(rows[0].value) : null;
249
+ }
250
+ if (!accountId) return ctx.strike(res, 400, "No account ID — verify CF token first");
251
+
252
+ // Generate tunnel secret (32 random bytes base64)
253
+ const { randomBytes } = await import("crypto");
254
+ const tunnelSecret = randomBytes(32).toString("base64");
255
+
256
+ // Create tunnel via CF API
257
+ const createResp = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/cfd_tunnel`, {
258
+ method: "POST",
259
+ headers: { Authorization: `Bearer ${cfToken}`, "Content-Type": "application/json" },
260
+ body: JSON.stringify({ name, tunnel_secret: tunnelSecret }),
261
+ signal: AbortSignal.timeout(10000),
262
+ });
263
+ const createData = await createResp.json();
264
+ if (!createData?.success) return ctx.strike(res, 400, createData?.errors?.[0]?.message || "Tunnel creation failed");
265
+ const tunnelId = createData.result.id;
266
+
267
+ // Write credentials file
268
+ const cfDir = path.join(os.homedir(), ".cloudflared");
269
+ if (!fs.existsSync(cfDir)) fs.mkdirSync(cfDir, { recursive: true });
270
+ const credFile = path.join(cfDir, `${tunnelId}.json`);
271
+ fs.writeFileSync(credFile, JSON.stringify({ AccountTag: accountId, TunnelID: tunnelId, TunnelName: name, TunnelSecret: tunnelSecret }), "utf8");
272
+
273
+ // Write config.yml
274
+ const configContent = `tunnel: ${tunnelId}\ncredentials-file: ${credFile}\ningress:\n - hostname: ${hostname}\n service: http://127.0.0.1:${ctx.port}\n - service: http_status:404\n`;
275
+ fs.writeFileSync(path.join(cfDir, "config.yml"), configContent, "utf8");
276
+
277
+ // Route DNS via CF API — find zone for hostname
278
+ const domain = hostname.split(".").slice(-2).join(".");
279
+ const zoneResp = await fetch(`https://api.cloudflare.com/client/v4/zones?name=${domain}`,
280
+ { headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000) });
281
+ const zoneData = await zoneResp.json();
282
+ const zoneId = zoneData?.result?.[0]?.id;
283
+ if (zoneId) {
284
+ await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`, {
285
+ method: "POST",
286
+ headers: { Authorization: `Bearer ${cfToken}`, "Content-Type": "application/json" },
287
+ body: JSON.stringify({ type: "CNAME", name: hostname, content: `${tunnelId}.cfargotunnel.com`, proxied: false, ttl: 1 }),
288
+ signal: AbortSignal.timeout(8000),
289
+ });
290
+ }
291
+
292
+ // Save hostname to DB and in-process
293
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
294
+ method: "POST",
295
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
296
+ body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(hostname) }),
297
+ });
298
+ ctx.tunnel.hostname = hostname;
299
+ ctx.tunnel.url = `https://${hostname}`;
300
+
301
+ operation("tunnel.setup.cf_create_api", { hostname, tunnelId }, null, { ok: true });
302
+ return ctx.ok(res, { ok: true, tunnelId, hostname });
303
+ } catch (err) {
304
+ operation("tunnel.setup.cf_create_api", { hostname }, null, { ok: false, error: err.message });
305
+ return ctx.strike(res, 502, err.message);
306
+ }
307
+ });
308
+ ctx.registerWriteRoute("POST", "/tunnel/setup/cf-login", async (req, res) => {
309
+ if (ctx.lockedGuard(res)) return;
310
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", ...ctx.CORS });
311
+ const sendEvt = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
312
+ try {
313
+ const { spawn } = await import("child_process");
314
+ const proc = spawn("cloudflared", ["tunnel", "login"], { stdio: ["ignore","pipe","pipe"], windowsHide: true });
315
+ proc.stdout.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l })));
316
+ proc.stderr.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l })));
317
+ proc.on("close", code => {
318
+ operation("tunnel.setup.cf_login", {}, null, { ok: code === 0, exit_code: code });
319
+ sendEvt({ done: true, code }); res.end();
320
+ });
321
+ req.on("close", () => { try { proc.kill(); } catch {} });
322
+ } catch (err) {
323
+ operation("tunnel.setup.cf_login", {}, null, { ok: false, error: err.message });
324
+ sendEvt({ done: true, code: 1, error: err.message });
325
+ res.end();
326
+ }
327
+ return;
328
+ });
329
+ ctx.registerWriteRoute("POST", "/tunnel/setup/cf-create", async (req, res) => {
330
+ if (ctx.lockedGuard(res)) return;
331
+ let body;
332
+ try { body = await readBody(req); } catch {
333
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
334
+ return res.end(JSON.stringify({ error: "Invalid JSON" }));
335
+ }
336
+ const { name, hostname } = body;
337
+ if (!name || !hostname) {
338
+ res.writeHead(400, { "Content-Type": "application/json", ...ctx.CORS });
339
+ return res.end(JSON.stringify({ error: "name and hostname required" }));
340
+ }
341
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", ...ctx.CORS });
342
+ const sendEvt = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
343
+ try {
344
+ const { spawn } = await import("child_process");
345
+
346
+ // Step 1: create tunnel
347
+ sendEvt({ line: `Creating tunnel "${name}"…`, step: 1 });
348
+ let tunnelId = null;
349
+ await new Promise((resolve, reject) => {
350
+ const proc = spawn("cloudflared", ["tunnel", "create", name], { stdio: ["ignore","pipe","pipe"], windowsHide: true });
351
+ let output = "";
352
+ proc.stdout.on("data", d => { const s = d.toString(); output += s; s.split("\n").forEach(l => l.trim() && sendEvt({ line: l, step: 1 })); });
353
+ proc.stderr.on("data", d => { const s = d.toString(); output += s; s.split("\n").forEach(l => l.trim() && sendEvt({ line: l, step: 1 })); });
354
+ proc.on("close", code => {
355
+ const m = output.match(/Created tunnel .+ with id ([a-f0-9-]{36})/i);
356
+ if (m) tunnelId = m[1];
357
+ if (code !== 0 && !tunnelId) reject(new Error(`create failed (exit ${code})`));
358
+ else resolve();
359
+ });
360
+ });
361
+
362
+ if (!tunnelId) {
363
+ sendEvt({ error: "Could not parse tunnel ID from cloudflared output" });
364
+ return res.end();
365
+ }
366
+
367
+ // Step 2: route DNS
368
+ sendEvt({ line: `Routing DNS: ${hostname}…`, step: 2 });
369
+ await new Promise((resolve) => {
370
+ const proc = spawn("cloudflared", ["tunnel", "route", "dns", name, hostname], { stdio: ["ignore","pipe","pipe"], windowsHide: true });
371
+ proc.stdout.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l, step: 2 })));
372
+ proc.stderr.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l, step: 2 })));
373
+ proc.on("close", () => resolve());
374
+ });
375
+
376
+ // Step 3: save config
377
+ const cfDir = path.join(os.homedir(), ".cloudflared");
378
+ if (!fs.existsSync(cfDir)) fs.mkdirSync(cfDir, { recursive: true });
379
+ const credFile = path.join(cfDir, `${tunnelId}.json`);
380
+ const configContent = `tunnel: ${tunnelId}\ncredentials-file: ${credFile}\ningress:\n - hostname: ${hostname}\n service: http://127.0.0.1:${ctx.port}\n - service: http_status:404\n`;
381
+ fs.writeFileSync(path.join(cfDir, "config.yml"), configContent, "utf8");
382
+
383
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
384
+ const sbKey = api.getAnonKey();
385
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
386
+ method: "POST",
387
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
388
+ body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(hostname) }),
389
+ });
390
+ ctx.tunnel.hostname = hostname;
391
+ ctx.tunnel.url = `https://${hostname}`;
392
+
393
+ operation("tunnel.setup.cf_create", { name, hostname, tunnelId }, null, { ok: true });
394
+ sendEvt({ done: true, tunnelId, hostname });
395
+ res.end();
396
+ } catch (err) {
397
+ operation("tunnel.setup.cf_create", { name, hostname }, null, { ok: false, error: err.message });
398
+ sendEvt({ error: err.message, done: true });
399
+ res.end();
400
+ }
401
+ return;
402
+ });
403
+ // GET /tunnel/setup-state
404
+ ctx.registerWriteRoute("GET", "/tunnel/setup-state", async (req, res) => {
405
+ if (ctx.lockedGuard(res)) return;
406
+ try {
407
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
408
+ const sbKey = api.getAnonKey();
409
+
410
+ // Check for existing hostname in DB
411
+ const hostnameResp = await fetch(
412
+ `${sbUrl}/rest/v1/clauth_config?key=eq.tunnel_hostname&select=value`,
413
+ { headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) }
414
+ );
415
+ if (hostnameResp.ok) {
416
+ const rows = await hostnameResp.json();
417
+ if (rows.length > 0 && rows[0].value && rows[0].value !== "null" && rows[0].value !== '""') {
418
+ const hn = typeof rows[0].value === "string" ? JSON.parse(rows[0].value) : rows[0].value;
419
+ if (hn) return ctx.ok(res, { step: "ready", hostname: hn });
420
+ }
421
+ }
422
+
423
+ // Check ~/.cloudflared/config.yml for a previously configured tunnel
424
+ try {
425
+ const cfConfig = path.join(os.homedir(), ".cloudflared", "config.yml");
426
+ if (fs.existsSync(cfConfig)) {
427
+ const cfYml = fs.readFileSync(cfConfig, "utf8");
428
+ // Parse hostname from ingress block: " - hostname: <hostname>"
429
+ const hostMatch = cfYml.match(/^\s*-\s*hostname:\s*(\S+)/m);
430
+ const tunnelMatch = cfYml.match(/^tunnel:\s*(\S+)/m);
431
+ if (hostMatch && tunnelMatch) {
432
+ const localHostname = hostMatch[1];
433
+ const localTunnelId = tunnelMatch[1];
434
+ // Save to DB so next load skips this check
435
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
436
+ method: "POST",
437
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
438
+ body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(localHostname) }),
439
+ });
440
+ ctx.tunnel.hostname = localHostname;
441
+ return ctx.ok(res, { step: "ready", hostname: localHostname, tunnelId: localTunnelId, source: "local_config" });
442
+ }
443
+ }
444
+ } catch {}
445
+
446
+ // Check for CF token in vault
447
+ let cfToken = null;
448
+ try {
449
+ const { token: t, timestamp } = deriveToken(ctx.password, ctx.machineHash);
450
+ const cr = await api.retrieve(ctx.password, ctx.machineHash, t, timestamp, "cloudflare");
451
+ if (cr?.value) cfToken = cr.value;
452
+ } catch {}
453
+
454
+ if (!cfToken) return ctx.ok(res, { step: "need_cf_token" });
455
+
456
+ // Get or fetch account ID
457
+ let accountId = null;
458
+ try {
459
+ const acctResp = await fetch(
460
+ `${sbUrl}/rest/v1/clauth_config?key=eq.cf_account_id&select=value`,
461
+ { headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) }
462
+ );
463
+ if (acctResp.ok) {
464
+ const rows = await acctResp.json();
465
+ if (rows.length > 0 && rows[0].value) {
466
+ accountId = typeof rows[0].value === "string" ? JSON.parse(rows[0].value) : rows[0].value;
467
+ }
468
+ }
469
+ } catch {}
470
+
471
+ if (!accountId) {
472
+ try {
473
+ const ar = await fetch("https://api.cloudflare.com/client/v4/accounts", {
474
+ headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000)
475
+ });
476
+ const ad = await ar.json();
477
+ accountId = ad?.result?.[0]?.id;
478
+ if (accountId) {
479
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
480
+ method: "POST",
481
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
482
+ body: JSON.stringify({ key: "cf_account_id", value: accountId }),
483
+ });
484
+ }
485
+ } catch {}
486
+ }
487
+
488
+ if (!accountId) return ctx.ok(res, { step: "setup_wizard", error: "Could not get Cloudflare account ID" });
489
+
490
+ // List tunnels
491
+ try {
492
+ const tr = await fetch(
493
+ `https://api.cloudflare.com/client/v4/accounts/${accountId}/cfd_tunnel?is_deleted=false`,
494
+ { headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000) }
495
+ );
496
+ const td = await tr.json();
497
+ const tunnels = (td?.result || []).map(t => ({ id: t.id, name: t.name, status: t.status }));
498
+ if (tunnels.length > 0) return ctx.ok(res, { step: "pick_tunnel", tunnels, accountId });
499
+ // CF token exists but no tunnels — create via API (no cloudflared login needed)
500
+ return ctx.ok(res, { step: "no_tunnels", accountId });
501
+ } catch {}
502
+
503
+ return ctx.ok(res, { step: "setup_wizard" });
504
+ } catch (err) {
505
+ return ctx.ok(res, { step: "setup_wizard", error: err.message });
506
+ }
507
+ });
508
+ }