@lifeaitools/clauth 0.7.6 → 1.2.3

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.
@@ -13,11 +13,50 @@ import { fileURLToPath } from "url";
13
13
  import { getMachineHash, deriveToken, deriveSeedHash } from "../fingerprint.js";
14
14
  import * as api from "../api.js";
15
15
  import chalk from "chalk";
16
+ import ora from "ora";
17
+ import { execSync as execSyncTop } from "child_process";
18
+ import Conf from "conf";
19
+ import { getConfOptions } from "../conf-path.js";
16
20
 
17
21
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
22
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../package.json"), "utf8"));
19
23
  const VERSION = pkg.version;
20
24
 
25
+ // ── Embedded migrations ─────────────────────────────────────────
26
+ // All SQL must be idempotent (IF NOT EXISTS, etc.)
27
+ // type: "safe" = auto-apply | "breaking" = requires user confirmation in UI
28
+ const MIGRATIONS = [
29
+ {
30
+ version: 1,
31
+ name: "001_clauth_schema",
32
+ description: "Initial schema (clauth_services, clauth_machines, clauth_audit)",
33
+ type: "safe",
34
+ sql: null, // pre-existing, skip if schema_version >= 1
35
+ },
36
+ {
37
+ version: 2,
38
+ name: "002_lockout",
39
+ description: "Machine lockout (fail_count, locked columns on clauth_machines)",
40
+ type: "safe",
41
+ sql: `ALTER TABLE clauth_machines ADD COLUMN IF NOT EXISTS fail_count integer NOT NULL DEFAULT 0;
42
+ ALTER TABLE clauth_machines ADD COLUMN IF NOT EXISTS locked boolean NOT NULL DEFAULT false;`,
43
+ },
44
+ {
45
+ version: 3,
46
+ name: "003_clauth_config",
47
+ description: "Daemon config store — tunnel hostname and schema versioning",
48
+ type: "safe",
49
+ sql: `CREATE TABLE IF NOT EXISTS clauth_config (
50
+ key text PRIMARY KEY,
51
+ value jsonb NOT NULL,
52
+ updated_at timestamptz DEFAULT now()
53
+ );
54
+ ALTER TABLE clauth_config ENABLE ROW LEVEL SECURITY;`,
55
+ },
56
+ ];
57
+
58
+ const CURRENT_SCHEMA_VERSION = 3;
59
+
21
60
  const PID_FILE = path.join(os.tmpdir(), "clauth-serve.pid");
22
61
  const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
23
62
 
@@ -47,7 +86,7 @@ function openBrowser(url) {
47
86
  const cmd = os.platform() === "win32" ? `start "" "${url}"`
48
87
  : os.platform() === "darwin" ? `open "${url}"`
49
88
  : `xdg-open "${url}"`;
50
- execSync(cmd, { stdio: "ignore" });
89
+ execSyncTop(cmd, { stdio: "ignore" });
51
90
  } catch {}
52
91
  }
53
92
 
@@ -190,6 +229,22 @@ function dashboardHtml(port, whitelist) {
190
229
  .add-foot{display:flex;gap:8px;align-items:center}
191
230
  .add-msg{font-size:.82rem}
192
231
  .add-msg.ok{color:#4ade80} .add-msg.fail{color:#f87171}
232
+ .project-tabs{display:flex;gap:0;margin-bottom:1rem;border-bottom:1px solid #1e293b;overflow-x:auto}
233
+ .project-tab{background:none;border:none;border-bottom:2px solid transparent;color:#64748b;padding:8px 16px;font-size:.82rem;font-weight:500;cursor:pointer;white-space:nowrap;transition:all .15s}
234
+ .project-tab:hover{color:#94a3b8;background:rgba(59,130,246,.05)}
235
+ .project-tab.active{color:#60a5fa;border-bottom-color:#3b82f6;background:rgba(59,130,246,.08)}
236
+ .project-tab .tab-count{font-size:.7rem;color:#475569;margin-left:4px;font-weight:400}
237
+ .project-tab.active .tab-count{color:#3b82f6}
238
+ .project-edit{display:none;margin-top:8px;padding:8px 10px;background:#0f172a;border:1px solid #334155;border-radius:6px}
239
+ .project-edit.open{display:flex;gap:6px;align-items:center}
240
+ .project-edit input{background:#1e293b;border:1px solid #334155;border-radius:4px;color:#e2e8f0;font-size:.78rem;padding:4px 8px;outline:none;flex:1;font-family:'Courier New',monospace;transition:border-color .15s}
241
+ .project-edit input:focus{border-color:#3b82f6}
242
+ .project-edit button{font-size:.72rem;padding:4px 8px;border-radius:4px;cursor:pointer;border:1px solid #334155;background:#1a1f2e;color:#94a3b8;transition:all .15s}
243
+ .project-edit button:hover{border-color:#60a5fa;color:#60a5fa}
244
+ .project-edit .pe-msg{font-size:.72rem;color:#4ade80}
245
+ .btn-project{font-size:.68rem;color:#64748b;background:none;border:1px solid #1e293b;border-radius:3px;padding:2px 6px;cursor:pointer;transition:all .15s}
246
+ .btn-project:hover{color:#3b82f6;border-color:#3b82f6}
247
+ .btn-mcp-setup.open{border-color:#f59e0b;color:#f59e0b;background:#1a1500}
193
248
  .footer{margin-top:2rem;font-size:.75rem;color:#475569;text-align:center}
194
249
  .oauth-fields{display:flex;flex-direction:column;gap:8px;margin-bottom:8px}
195
250
  .oauth-field{display:flex;flex-direction:column;gap:3px}
@@ -197,6 +252,68 @@ function dashboardHtml(port, whitelist) {
197
252
  .oauth-hint{font-size:.71rem;color:#475569;font-style:italic}
198
253
  .oauth-input{width:100%;background:#0a0f1a;border:1px solid #1e3a5f;border-radius:4px;color:#e2e8f0;font-family:'Courier New',monospace;font-size:.85rem;padding:7px 10px;outline:none;transition:border-color .2s}
199
254
  .oauth-input:focus{border-color:#3b82f6}
255
+ .upgrade-banner {
256
+ background: linear-gradient(135deg, rgba(0,200,100,0.12), rgba(0,150,80,0.08));
257
+ border: 1px solid rgba(0,200,100,0.3);
258
+ border-radius: 6px;
259
+ padding: 10px 16px;
260
+ margin-bottom: 12px;
261
+ font-size: 13px;
262
+ }
263
+ .upgrade-banner .upgrade-content {
264
+ display: flex;
265
+ align-items: center;
266
+ gap: 12px;
267
+ flex-wrap: wrap;
268
+ }
269
+ .upgrade-banner button {
270
+ margin-left: auto;
271
+ background: none;
272
+ border: 1px solid rgba(0,200,100,0.4);
273
+ color: #0c6;
274
+ border-radius: 4px;
275
+ padding: 2px 8px;
276
+ cursor: pointer;
277
+ font-size: 12px;
278
+ }
279
+ .upgrade-details-list {
280
+ color: rgba(255,255,255,0.6);
281
+ font-size: 12px;
282
+ }
283
+ /* Tunnel setup wizard */
284
+ .wizard-panel{background:#0a1628;border:1px solid #1e3a5f;border-radius:10px;padding:1.5rem;margin-bottom:1.25rem;display:none}
285
+ .wizard-panel.open{display:block}
286
+ .wizard-header{display:flex;align-items:center;gap:10px;margin-bottom:1.25rem}
287
+ .wizard-title{font-size:1rem;font-weight:600;color:#f8fafc;flex:1}
288
+ .wizard-steps{display:flex;gap:6px;margin-bottom:1.5rem;flex-wrap:wrap}
289
+ .wstep{padding:4px 10px;border-radius:20px;font-size:.72rem;font-weight:600;border:1px solid #1e3a5f;color:#475569;background:#0f172a}
290
+ .wstep.active{border-color:#3b82f6;color:#60a5fa;background:rgba(59,130,246,.1)}
291
+ .wstep.done{border-color:#166534;color:#4ade80;background:rgba(74,222,128,.08)}
292
+ .wizard-body{min-height:80px}
293
+ .wizard-foot{display:flex;gap:8px;margin-top:1.25rem;align-items:center}
294
+ .btn-wiz-primary{background:#3b82f6;color:#fff;padding:8px 20px;font-size:.875rem;border-radius:7px;border:none;cursor:pointer;font-weight:600}
295
+ .btn-wiz-primary:hover{background:#2563eb}
296
+ .btn-wiz-primary:disabled{opacity:.4;cursor:not-allowed}
297
+ .btn-wiz-secondary{background:#1e293b;color:#94a3b8;padding:8px 16px;font-size:.875rem;border-radius:7px;border:1px solid #334155;cursor:pointer}
298
+ .btn-wiz-secondary:hover{color:#e2e8f0}
299
+ .wiz-input{width:100%;background:#0f172a;border:1px solid #334155;border-radius:6px;color:#e2e8f0;font-family:'Courier New',monospace;font-size:.9rem;padding:9px 12px;outline:none;margin-top:8px;transition:border-color .2s}
300
+ .wiz-input:focus{border-color:#3b82f6}
301
+ .wiz-label{font-size:.8rem;color:#64748b;margin-bottom:4px}
302
+ .wiz-msg{font-size:.82rem;margin-left:auto}
303
+ .wiz-msg.ok{color:#4ade80}.wiz-msg.fail{color:#f87171}
304
+ .wiz-log{background:#030712;border:1px solid #1e293b;border-radius:6px;padding:10px;font-family:'Courier New',monospace;font-size:.75rem;color:#6ee7b7;max-height:160px;overflow-y:auto;margin-top:10px;white-space:pre-wrap}
305
+ .wiz-tunnel-list{display:flex;flex-direction:column;gap:8px;margin-top:10px}
306
+ .wiz-tunnel-item{display:flex;align-items:center;gap:10px;padding:10px 12px;background:#0f172a;border:1px solid #1e3a5f;border-radius:6px;cursor:pointer;transition:border-color .15s}
307
+ .wiz-tunnel-item:hover,.wiz-tunnel-item.selected{border-color:#3b82f6;background:rgba(59,130,246,.08)}
308
+ .wiz-tunnel-name{font-weight:600;color:#f8fafc;font-size:.9rem;flex:1}
309
+ .wiz-tunnel-id{font-family:'Courier New',monospace;font-size:.72rem;color:#475569}
310
+ .wiz-tunnel-status{font-size:.72rem;padding:2px 7px;border-radius:4px;background:rgba(74,222,128,.1);color:#4ade80;border:1px solid rgba(74,222,128,.2)}
311
+ .wiz-desc{font-size:.85rem;color:#94a3b8;line-height:1.6;margin-bottom:.75rem}
312
+ .wiz-link{color:#60a5fa;text-decoration:none;font-size:.82rem}
313
+ .wiz-link:hover{text-decoration:underline}
314
+ .wiz-test-result{padding:12px 14px;border-radius:8px;font-size:.85rem;margin-top:10px;display:none}
315
+ .wiz-test-result.ok{background:rgba(74,222,128,.08);border:1px solid rgba(74,222,128,.2);color:#4ade80}
316
+ .wiz-test-result.fail{background:rgba(248,113,113,.08);border:1px solid rgba(248,113,113,.2);color:#f87171}
200
317
  </style>
201
318
  </head>
202
319
  <body>
@@ -215,6 +332,13 @@ function dashboardHtml(port, whitelist) {
215
332
 
216
333
  <!-- ── Main view (shown after unlock) ──────── -->
217
334
  <div id="main-view">
335
+ <div id="upgrade-banner" style="display:none" class="upgrade-banner">
336
+ <div class="upgrade-content">
337
+ <strong>⬆ clauth upgraded to v<span id="upgrade-to-version"></span></strong>
338
+ <span id="upgrade-details"></span>
339
+ <button onclick="dismissUpgrade()">Dismiss ✕</button>
340
+ </div>
341
+ </div>
218
342
  <div class="header">
219
343
  <div class="dot" id="dot"></div>
220
344
  <h1>🔐 clauth vault <span style="font-size:0.55em;opacity:0.45;font-weight:400">v${VERSION}</span></h1>
@@ -256,6 +380,10 @@ function dashboardHtml(port, whitelist) {
256
380
  <option value="">Loading…</option>
257
381
  </select>
258
382
  </div>
383
+ <div class="add-field">
384
+ <label>Project <span style="color:#475569;font-weight:400">(optional)</span></label>
385
+ <input class="add-input" id="add-project" type="text" placeholder="e.g. marketing-engine" autocomplete="off" spellcheck="false">
386
+ </div>
259
387
  </div>
260
388
  <div class="add-foot">
261
389
  <button class="btn-chpw-save" onclick="addService()">Create Service</button>
@@ -284,15 +412,45 @@ function dashboardHtml(port, whitelist) {
284
412
  </div>
285
413
 
286
414
  <div class="tunnel-panel" id="tunnel-panel">
287
- <div class="tunnel-dot off" id="tunnel-dot"></div>
288
- <div class="tunnel-label" id="tunnel-label">
289
- <strong>claude.ai MCP</strong> — checking tunnel…
415
+ <!-- not_configured -->
416
+ <div class="tunnel-state not-configured" style="display:none;align-items:center;gap:10px;width:100%;flex-wrap:wrap">
417
+ <div class="tunnel-dot off"></div>
418
+ <div class="tunnel-label"><strong>claude.ai MCP</strong> — No tunnel configured</div>
419
+ <button class="btn-tunnel-stop" onclick="runTunnelSetup()">Setup Tunnel</button>
420
+ </div>
421
+ <!-- missing_cloudflared -->
422
+ <div class="tunnel-state missing-cloudflared" style="display:none;align-items:center;gap:10px;width:100%;flex-wrap:wrap">
423
+ <div class="tunnel-dot err"></div>
424
+ <div class="tunnel-label"><strong>claude.ai MCP</strong> — cloudflared not installed</div>
425
+ <a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/" target="_blank" style="color:#60a5fa;font-size:.8rem">Download cloudflared ↗</a>
426
+ <span class="tunnel-err" style="display:inline;font-size:.78rem;color:#94a3b8">Then run: clauth tunnel setup</span>
427
+ </div>
428
+ <!-- starting -->
429
+ <div class="tunnel-state starting" style="display:none;align-items:center;gap:10px;width:100%;flex-wrap:wrap">
430
+ <div class="tunnel-dot starting"></div>
431
+ <div class="tunnel-label"><strong>claude.ai MCP</strong> — Tunnel starting…</div>
432
+ </div>
433
+ <!-- live -->
434
+ <div class="tunnel-state live" style="display:none;align-items:center;gap:10px;width:100%;flex-wrap:wrap">
435
+ <div class="tunnel-dot on"></div>
436
+ <div class="tunnel-label"><strong>claude.ai MCP</strong> — <span class="tunnel-url"><a href="" target="_blank" id="tunnel-live-url"></a></span></div>
437
+ <button class="btn-check" style="padding:6px 12px;font-size:.8rem" onclick="testTunnel()">Test</button>
438
+ <button class="btn-claude" onclick="openClaude()">Connect claude.ai</button>
439
+ <button class="btn-mcp-setup" id="btn-mcp-setup" onclick="toggleMcpSetup()">Setup MCP</button>
440
+ <button class="btn-tunnel-stop" onclick="toggleTunnel('stop')">Stop</button>
441
+ </div>
442
+ <!-- error -->
443
+ <div class="tunnel-state error" style="display:none;align-items:center;gap:10px;width:100%;flex-wrap:wrap">
444
+ <div class="tunnel-dot err"></div>
445
+ <div class="tunnel-label"><strong>claude.ai MCP</strong> — Tunnel error — check cloudflared config</div>
446
+ <button class="btn-tunnel-stop" onclick="toggleTunnel('start')">Retry</button>
447
+ </div>
448
+ <!-- not_started -->
449
+ <div class="tunnel-state not-started" style="display:flex;align-items:center;gap:10px;width:100%;flex-wrap:wrap">
450
+ <div class="tunnel-dot off"></div>
451
+ <div class="tunnel-label"><strong>claude.ai MCP</strong> — checking tunnel…</div>
452
+ <button class="btn-tunnel-stop" onclick="toggleTunnel('start')">Start Tunnel</button>
290
453
  </div>
291
- <button class="btn-check" id="btn-tunnel-test" style="display:none;padding:6px 12px;font-size:.8rem" onclick="testTunnel()">Test</button>
292
- <button class="btn-claude" id="btn-claude" disabled onclick="openClaude()">Connect claude.ai</button>
293
- <button class="btn-tunnel-stop" id="btn-tunnel-toggle" style="display:none" onclick="toggleTunnel()">Stop</button>
294
- <button class="btn-mcp-setup" id="btn-mcp-setup" style="display:none" onclick="toggleMcpSetup()">Setup MCP</button>
295
- <div class="tunnel-err" id="tunnel-err" style="display:none"></div>
296
454
  </div>
297
455
 
298
456
  <div class="mcp-setup" id="mcp-setup-panel">
@@ -317,6 +475,18 @@ function dashboardHtml(port, whitelist) {
317
475
  <div style="font-size:.72rem;color:#64748b;margin-top:4px">Paste these into <a href="https://claude.ai/settings/integrations" target="_blank" style="color:#60a5fa">claude.ai Settings → Integrations</a></div>
318
476
  </div>
319
477
 
478
+ <div class="wizard-panel" id="wizard-panel">
479
+ <div class="wizard-header">
480
+ <div class="tunnel-dot off" id="wiz-dot" style="width:10px;height:10px;border-radius:50%"></div>
481
+ <div class="wizard-title">claude.ai Tunnel Setup</div>
482
+ <button class="btn-cancel" onclick="closeSetupWizard()">✕ Dismiss</button>
483
+ </div>
484
+ <div class="wizard-steps" id="wizard-steps"></div>
485
+ <div class="wizard-body" id="wizard-body"><p class="loading">Checking setup state…</p></div>
486
+ <div class="wizard-foot" id="wizard-foot"></div>
487
+ </div>
488
+
489
+ <div id="project-tabs" class="project-tabs" style="display:none"></div>
320
490
  <div id="grid" class="grid"><p class="loading">Loading services…</p></div>
321
491
  <div class="footer">localhost:${port} · 127.0.0.1 only · 10-strike lockout</div>
322
492
  </div>
@@ -346,6 +516,19 @@ const KEY_URLS = {
346
516
  "neo4j": "https://console.neo4j.io/",
347
517
  "npm": "https://www.npmjs.com/settings/~/tokens",
348
518
  "gmail": "https://console.cloud.google.com/apis/credentials",
519
+ "gcal": "https://console.cloud.google.com/apis/credentials",
520
+ };
521
+
522
+ // Extra links shown below the primary KEY_URLS link
523
+ const EXTRA_LINKS = {
524
+ "gmail": [
525
+ { label: "↗ OAuth Playground (get refresh token)", url: "https://developers.google.com/oauthplayground/" },
526
+ { label: "↗ Enable Gmail API", url: "https://console.cloud.google.com/apis/library/gmail.googleapis.com" },
527
+ ],
528
+ "gcal": [
529
+ { label: "↗ OAuth Playground (get refresh token)", url: "https://developers.google.com/oauthplayground/" },
530
+ { label: "↗ Enable Calendar API", url: "https://console.cloud.google.com/apis/library/calendar-json.googleapis.com" },
531
+ ],
349
532
  };
350
533
 
351
534
  // ── OAuth import config ─────────────────────
@@ -355,7 +538,11 @@ const KEY_URLS = {
355
538
  const OAUTH_IMPORT = {
356
539
  "gmail": {
357
540
  jsonFields: ["client_id", "client_secret"],
358
- extra: [{ key: "refresh_token", label: "Refresh Token", hint: "From Google OAuth Playground or your app's auth callback" }]
541
+ extra: [{ key: "refresh_token", label: "Refresh Token", hint: "From Google OAuth Playground select Gmail API scopes, authorize, then exchange for tokens" }]
542
+ },
543
+ "gcal": {
544
+ jsonFields: ["client_id", "client_secret"],
545
+ extra: [{ key: "refresh_token", label: "Refresh Token", hint: "From Google OAuth Playground — select Calendar API scopes, authorize, then exchange for tokens" }]
359
546
  }
360
547
  };
361
548
 
@@ -423,7 +610,40 @@ function showLockScreen() {
423
610
  setTimeout(() => document.getElementById("lock-input").focus(), 50);
424
611
  }
425
612
 
613
+ // ── Upgrade detection ────────────────────────────────────────────
614
+ function checkUpgrade(ping) {
615
+ const currentVersion = ping.app_version || ping.version;
616
+ if (!currentVersion) return;
617
+
618
+ const lastVersion = localStorage.getItem('clauth_last_version');
619
+
620
+ if (lastVersion && lastVersion !== currentVersion) {
621
+ // Version changed — show upgrade banner
622
+ document.getElementById('upgrade-to-version').textContent = currentVersion;
623
+
624
+ // Build details from migration result if available
625
+ let details = '';
626
+ if (ping.last_migrations?.applied?.length > 0) {
627
+ details = '· Migrations: ' + ping.last_migrations.applied.map(m => m.description).join(' · ');
628
+ }
629
+ // Show breaking migration warning if any pending
630
+ if (ping.pending_breaking?.length > 0) {
631
+ details += ' ⚠ Breaking migrations require confirmation — see below.';
632
+ }
633
+ document.getElementById('upgrade-details').textContent = details;
634
+ document.getElementById('upgrade-banner').style.display = 'block';
635
+ }
636
+
637
+ // Always update stored version
638
+ localStorage.setItem('clauth_last_version', currentVersion);
639
+ }
640
+
641
+ function dismissUpgrade() {
642
+ document.getElementById('upgrade-banner').style.display = 'none';
643
+ }
644
+
426
645
  function showMain(ping) {
646
+ checkUpgrade(ping);
427
647
  document.getElementById("lock-screen").style.display = "none";
428
648
  document.getElementById("main-view").style.display = "block";
429
649
  if (ping) {
@@ -476,6 +696,82 @@ async function lockVault() {
476
696
  }
477
697
 
478
698
  // ── Load services ───────────────────────────
699
+ let allServices = [];
700
+ let activeProjectTab = "all";
701
+
702
+ function renderProjectTabs(services) {
703
+ const tabsEl = document.getElementById("project-tabs");
704
+ const projects = new Map(); // project -> count
705
+ let unassigned = 0;
706
+ for (const s of services) {
707
+ if (s.project) {
708
+ projects.set(s.project, (projects.get(s.project) || 0) + 1);
709
+ } else {
710
+ unassigned++;
711
+ }
712
+ }
713
+ // Only show tabs if there's at least one project
714
+ if (projects.size === 0) { tabsEl.style.display = "none"; return; }
715
+ tabsEl.style.display = "flex";
716
+ const tabs = [
717
+ { key: "all", label: "All", count: services.length },
718
+ ...Array.from(projects.entries()).map(([name, count]) => ({ key: name, label: name, count })),
719
+ { key: "unassigned", label: "Global", count: unassigned },
720
+ ];
721
+ tabsEl.innerHTML = tabs.map(t =>
722
+ \`<button class="project-tab \${activeProjectTab === t.key ? "active" : ""}" onclick="switchProjectTab('\${t.key}')">\${t.label}<span class="tab-count">(\${t.count})</span></button>\`
723
+ ).join("");
724
+ }
725
+
726
+ function switchProjectTab(key) {
727
+ activeProjectTab = key;
728
+ renderProjectTabs(allServices);
729
+ renderServiceGrid(allServices);
730
+ }
731
+
732
+ function renderServiceGrid(services) {
733
+ const grid = document.getElementById("grid");
734
+ let filtered = services;
735
+ if (activeProjectTab === "unassigned") {
736
+ filtered = services.filter(s => !s.project);
737
+ } else if (activeProjectTab !== "all") {
738
+ filtered = services.filter(s => s.project === activeProjectTab);
739
+ }
740
+ if (!filtered.length) { grid.innerHTML = '<p class="loading">No services in this group.</p>'; return; }
741
+ grid.innerHTML = filtered.map(s => \`
742
+ <div class="card">
743
+ <div style="display:flex;align-items:flex-start;justify-content:space-between">
744
+ <div>
745
+ <div class="card-name">\${s.name}</div>
746
+ <div style="display:flex;align-items:center;gap:6px;margin-top:2px">
747
+ <div class="card-type">\${s.key_type || "secret"}</div>
748
+ <span class="svc-badge \${s.enabled === false ? "off" : "on"}" id="badge-\${s.name}">\${s.enabled === false ? "disabled" : "enabled"}</span>
749
+ \${s.project ? \`<span style="font-size:.68rem;color:#3b82f6;background:rgba(59,130,246,.1);border:1px solid rgba(59,130,246,.2);border-radius:3px;padding:1px 6px">\${s.project}</span>\` : ""}
750
+ </div>
751
+ \${KEY_URLS[s.name] ? \`<a class="card-getkey" href="\${KEY_URLS[s.name]}" target="_blank" rel="noopener">↗ Get / rotate key</a>\` : ""}
752
+ \${(EXTRA_LINKS[s.name] || []).map(l => \`<a class="card-getkey" href="\${l.url}" target="_blank" rel="noopener" style="margin-left:0">\${l.label}</a>\`).join("")}
753
+ </div>
754
+ <div class="status-dot" id="sdot-\${s.name}" title=""></div>
755
+ </div>
756
+ <div class="card-value" id="val-\${s.name}"></div>
757
+ <div class="card-actions">
758
+ <button class="btn btn-reveal" onclick="reveal('\${s.name}', this)">Reveal</button>
759
+ <button class="btn btn-copy" id="copybtn-\${s.name}" style="display:none" onclick="copyKey('\${s.name}')">Copy</button>
760
+ <button class="btn btn-set" onclick="toggleSet('\${s.name}')">Set</button>
761
+ <button class="btn-project" onclick="toggleProjectEdit('\${s.name}')">\${s.project ? "✎ Project" : "+ Project"}</button>
762
+ <button class="btn \${s.enabled === false ? "btn-enable" : "btn-disable"}" id="togbtn-\${s.name}" onclick="toggleService('\${s.name}')">\${s.enabled === false ? "Enable" : "Disable"}</button>
763
+ </div>
764
+ <div class="project-edit" id="pe-\${s.name}">
765
+ <input type="text" id="pe-input-\${s.name}" value="\${s.project || ""}" placeholder="Project name…" spellcheck="false" autocomplete="off">
766
+ <button onclick="saveProject('\${s.name}')">Save</button>
767
+ <button onclick="clearProject('\${s.name}')">Clear</button>
768
+ <span class="pe-msg" id="pe-msg-\${s.name}"></span>
769
+ </div>
770
+ \${renderSetPanel(s.name)}
771
+ </div>
772
+ \`).join("");
773
+ }
774
+
479
775
  async function loadServices() {
480
776
  const grid = document.getElementById("grid");
481
777
  const err = document.getElementById("error-bar");
@@ -487,32 +783,11 @@ async function loadServices() {
487
783
  if (status.locked) { showLockScreen(); return; }
488
784
  if (status.error) throw new Error(status.error);
489
785
 
490
- const services = status.services || [];
491
- if (!services.length) { grid.innerHTML = '<p class="loading">No services registered.</p>'; return; }
492
-
493
- grid.innerHTML = services.map(s => \`
494
- <div class="card">
495
- <div style="display:flex;align-items:flex-start;justify-content:space-between">
496
- <div>
497
- <div class="card-name">\${s.name}</div>
498
- <div style="display:flex;align-items:center;gap:6px;margin-top:2px">
499
- <div class="card-type">\${s.key_type || "secret"}</div>
500
- <span class="svc-badge \${s.enabled === false ? "off" : "on"}" id="badge-\${s.name}">\${s.enabled === false ? "disabled" : "enabled"}</span>
501
- </div>
502
- \${KEY_URLS[s.name] ? \`<a class="card-getkey" href="\${KEY_URLS[s.name]}" target="_blank" rel="noopener">↗ Get / rotate key</a>\` : ""}
503
- </div>
504
- <div class="status-dot" id="sdot-\${s.name}" title=""></div>
505
- </div>
506
- <div class="card-value" id="val-\${s.name}"></div>
507
- <div class="card-actions">
508
- <button class="btn btn-reveal" onclick="reveal('\${s.name}', this)">Reveal</button>
509
- <button class="btn btn-copy" id="copybtn-\${s.name}" style="display:none" onclick="copyKey('\${s.name}')">Copy</button>
510
- <button class="btn btn-set" onclick="toggleSet('\${s.name}')">Set</button>
511
- <button class="btn \${s.enabled === false ? "btn-enable" : "btn-disable"}" id="togbtn-\${s.name}" onclick="toggleService('\${s.name}')">\${s.enabled === false ? "Enable" : "Disable"}</button>
512
- </div>
513
- \${renderSetPanel(s.name)}
514
- </div>
515
- \`).join("");
786
+ allServices = status.services || [];
787
+ if (!allServices.length) { grid.innerHTML = '<p class="loading">No services registered.</p>'; return; }
788
+
789
+ renderProjectTabs(allServices);
790
+ renderServiceGrid(allServices);
516
791
  } catch (e) {
517
792
  err.textContent = "⚠ " + e.message;
518
793
  err.style.display = "block";
@@ -561,6 +836,47 @@ async function copyKey(name) {
561
836
  } catch {}
562
837
  }
563
838
 
839
+ // ── Project edit ────────────────────────────
840
+ function toggleProjectEdit(name) {
841
+ const panel = document.getElementById("pe-" + name);
842
+ const open = panel.classList.contains("open");
843
+ panel.classList.toggle("open");
844
+ if (!open) {
845
+ const svc = allServices.find(s => s.name === name);
846
+ document.getElementById("pe-input-" + name).value = svc ? (svc.project || "") : "";
847
+ document.getElementById("pe-msg-" + name).textContent = "";
848
+ document.getElementById("pe-input-" + name).focus();
849
+ }
850
+ }
851
+
852
+ async function saveProject(name) {
853
+ const input = document.getElementById("pe-input-" + name);
854
+ const msg = document.getElementById("pe-msg-" + name);
855
+ const project = input.value.trim();
856
+ msg.textContent = "Saving…"; msg.style.color = "#94a3b8";
857
+ try {
858
+ const r = await fetch(BASE + "/update-service", {
859
+ method: "POST",
860
+ headers: { "Content-Type": "application/json" },
861
+ body: JSON.stringify({ service: name, project: project || "" })
862
+ }).then(r => r.json());
863
+ if (r.locked) { showLockScreen(); return; }
864
+ if (r.error) throw new Error(r.error);
865
+ msg.style.color = "#4ade80"; msg.textContent = "✓ Saved";
866
+ // Update local state
867
+ const svc = allServices.find(s => s.name === name);
868
+ if (svc) svc.project = project || null;
869
+ setTimeout(() => { loadServices(); }, 800);
870
+ } catch (err) {
871
+ msg.style.color = "#f87171"; msg.textContent = err.message;
872
+ }
873
+ }
874
+
875
+ async function clearProject(name) {
876
+ document.getElementById("pe-input-" + name).value = "";
877
+ await saveProject(name);
878
+ }
879
+
564
880
  // ── Set key ─────────────────────────────────
565
881
  function toggleSet(name) {
566
882
  const panel = document.getElementById("set-panel-" + name);
@@ -793,6 +1109,7 @@ async function toggleAddService() {
793
1109
  panel.style.display = open ? "none" : "block";
794
1110
  if (!open) {
795
1111
  document.getElementById("add-name").value = "";
1112
+ document.getElementById("add-project").value = "";
796
1113
  document.getElementById("add-msg").textContent = "";
797
1114
  // Fetch available key types from server
798
1115
  const sel = document.getElementById("add-type");
@@ -815,6 +1132,7 @@ async function toggleAddService() {
815
1132
  async function addService() {
816
1133
  const name = document.getElementById("add-name").value.trim().toLowerCase();
817
1134
  const type = document.getElementById("add-type").value;
1135
+ const project = document.getElementById("add-project").value.trim();
818
1136
  const msg = document.getElementById("add-msg");
819
1137
 
820
1138
  if (!name) { msg.className = "add-msg fail"; msg.textContent = "Service name is required."; return; }
@@ -822,10 +1140,12 @@ async function addService() {
822
1140
 
823
1141
  msg.className = "add-msg"; msg.textContent = "Creating…";
824
1142
  try {
1143
+ const payload = { name, key_type: type, label: name };
1144
+ if (project) payload.project = project;
825
1145
  const r = await fetch(BASE + "/add-service", {
826
1146
  method: "POST",
827
1147
  headers: { "Content-Type": "application/json" },
828
- body: JSON.stringify({ name, key_type: type, label: name })
1148
+ body: JSON.stringify(payload)
829
1149
  }).then(r => r.json());
830
1150
 
831
1151
  if (r.locked) { showLockScreen(); return; }
@@ -852,143 +1172,109 @@ document.addEventListener("DOMContentLoaded", () => {
852
1172
  });
853
1173
  });
854
1174
 
1175
+ // ── Shared fetch helper ─────────────────────
1176
+ async function apiFetch(path, opts) {
1177
+ try {
1178
+ return await fetch(BASE + path, opts).then(r => r.json());
1179
+ } catch { return null; }
1180
+ }
1181
+
855
1182
  // ── Tunnel management ───────────────────────
856
1183
  let tunnelPollTimer = null;
857
1184
 
858
1185
  async function pollTunnel() {
859
1186
  if (tunnelPollTimer) clearInterval(tunnelPollTimer);
860
- await updateTunnelUI();
861
- // Poll every 3s until URL is found, then slow to 10s
1187
+ const r = await apiFetch("/tunnel");
1188
+ if (r) renderTunnelPanel(r.status, r.url, r.error);
1189
+ // Poll every 3s; slow to 10s once live
862
1190
  tunnelPollTimer = setInterval(async () => {
863
- const state = await updateTunnelUI();
864
- if (state === "connected") {
1191
+ const r = await apiFetch("/tunnel");
1192
+ if (!r) return;
1193
+ renderTunnelPanel(r.status, r.url, r.error);
1194
+ if (r.status === "live") {
865
1195
  clearInterval(tunnelPollTimer);
866
- tunnelPollTimer = setInterval(updateTunnelUI, 10000);
1196
+ tunnelPollTimer = setInterval(async () => {
1197
+ const r2 = await apiFetch("/tunnel");
1198
+ if (r2) renderTunnelPanel(r2.status, r2.url, r2.error);
1199
+ }, 10000);
867
1200
  }
868
1201
  }, 3000);
869
1202
  }
870
1203
 
871
- async function updateTunnelUI() {
872
- const dot = document.getElementById("tunnel-dot");
873
- const label = document.getElementById("tunnel-label");
874
- const btn = document.getElementById("btn-claude");
875
- const togBtn = document.getElementById("btn-tunnel-toggle");
876
- const mcpBtn = document.getElementById("btn-mcp-setup");
877
- const errEl = document.getElementById("tunnel-err");
878
-
879
- try {
880
- const t = await fetch(BASE + "/tunnel").then(r => r.json());
881
-
882
- errEl.style.display = "none";
883
-
884
- if (t.error) {
885
- dot.className = "tunnel-dot err";
886
- label.innerHTML = '<strong>claude.ai MCP</strong> ' + t.error;
887
- btn.disabled = true;
888
- mcpBtn.style.display = "none";
889
- togBtn.style.display = "none";
890
- togBtn.textContent = "Start Tunnel";
891
- togBtn.onclick = () => toggleTunnel("start");
892
- togBtn.style.display = "inline-block";
893
- document.getElementById("mcp-setup-panel").classList.remove("open");
894
- return "error";
895
- }
896
-
897
- if (t.running && t.url && t.url.startsWith("http")) {
898
- dot.className = "tunnel-dot on";
899
- label.innerHTML = '<strong>claude.ai MCP</strong> — <span class="tunnel-url"><a href="' + t.sseUrl + '" target="_blank">' + t.sseUrl + '</a></span>';
900
- btn.disabled = false;
901
- document.getElementById("btn-tunnel-test").style.display = "inline-block";
902
- togBtn.textContent = "Stop Tunnel";
903
- togBtn.onclick = () => toggleTunnel("stop");
904
- togBtn.style.display = "inline-block";
905
- mcpBtn.style.display = "inline-block";
906
- return "connected";
907
- }
908
-
909
- if (t.running) {
910
- dot.className = "tunnel-dot starting";
911
- label.innerHTML = '<strong>claude.ai MCP</strong> — starting tunnel…';
912
- btn.disabled = true;
913
- mcpBtn.style.display = "none";
914
- togBtn.textContent = "Stop Tunnel";
915
- togBtn.onclick = () => toggleTunnel("stop");
916
- togBtn.style.display = "inline-block";
917
- return "starting";
918
- }
919
-
920
- // Not running
921
- dot.className = "tunnel-dot off";
922
- label.innerHTML = '<strong>claude.ai MCP</strong> — tunnel not running';
923
- btn.disabled = true;
924
- mcpBtn.style.display = "none";
925
- togBtn.textContent = "Start Tunnel";
926
- togBtn.onclick = () => toggleTunnel("start");
927
- togBtn.style.display = "inline-block";
928
- document.getElementById("mcp-setup-panel").classList.remove("open");
929
- return "stopped";
930
-
931
- } catch {
932
- dot.className = "tunnel-dot off";
933
- label.innerHTML = '<strong>claude.ai MCP</strong> — unable to reach daemon';
934
- btn.disabled = true;
935
- mcpBtn.style.display = "none";
936
- togBtn.style.display = "none";
1204
+ function renderTunnelPanel(status, url, error) {
1205
+ const panel = document.getElementById("tunnel-panel");
1206
+ if (!panel) return;
1207
+ // Hide all state divs
1208
+ panel.querySelectorAll(".tunnel-state").forEach(el => el.style.display = "none");
1209
+ // Map status to CSS class (underscores → hyphens)
1210
+ const cls = (status || "not-started").replace(/_/g, "-");
1211
+ const active = panel.querySelector(".tunnel-state." + cls);
1212
+ if (active) active.style.display = "flex";
1213
+ // Update live URL
1214
+ if (status === "live" && url) {
1215
+ const sseUrl = url.startsWith("http") ? url + "/sse" : url;
1216
+ const link = panel.querySelector(".tunnel-state.live a");
1217
+ if (link) { link.href = sseUrl; link.textContent = sseUrl; }
1218
+ const liveUrlEl = document.getElementById("tunnel-live-url");
1219
+ if (liveUrlEl) { liveUrlEl.href = sseUrl; liveUrlEl.textContent = sseUrl; }
1220
+ }
1221
+ // Close MCP setup panel when not live
1222
+ if (status !== "live") {
937
1223
  document.getElementById("mcp-setup-panel").classList.remove("open");
938
- return "error";
1224
+ const mcpBtn = document.getElementById("btn-mcp-setup");
1225
+ if (mcpBtn) mcpBtn.classList.remove("open");
939
1226
  }
940
1227
  }
941
1228
 
942
1229
  async function toggleTunnel(action) {
943
- const togBtn = document.getElementById("btn-tunnel-toggle");
944
- togBtn.disabled = true; togBtn.textContent = "…";
945
1230
  try {
946
1231
  await fetch(BASE + "/tunnel", {
947
1232
  method: "POST",
948
1233
  headers: { "Content-Type": "application/json" },
949
1234
  body: JSON.stringify({ action })
950
1235
  });
951
- // Wait a beat for tunnel to start/stop, then refresh
952
- setTimeout(updateTunnelUI, action === "start" ? 5000 : 500);
953
- } catch {} finally {
954
- togBtn.disabled = false;
955
- }
1236
+ // Re-poll after a beat
1237
+ setTimeout(pollTunnel, action === "start" ? 2000 : 500);
1238
+ } catch {}
956
1239
  }
957
1240
 
1241
+ function runTunnelSetup() { openSetupWizard(); }
1242
+
958
1243
  async function testTunnel() {
959
- const btn = document.getElementById("btn-tunnel-test");
960
- const dot = document.getElementById("tunnel-dot");
961
- const label = document.getElementById("tunnel-label");
962
- btn.disabled = true; btn.textContent = "Testing…";
963
- dot.className = "tunnel-dot starting";
1244
+ const liveState = document.querySelector("#tunnel-panel .tunnel-state.live");
1245
+ const btn = liveState ? liveState.querySelector(".btn-check") : null;
1246
+ const labelEl = liveState ? liveState.querySelector(".tunnel-label") : null;
1247
+ if (btn) { btn.disabled = true; btn.textContent = "Testing…"; }
964
1248
 
965
1249
  try {
966
1250
  const r = await fetch(BASE + "/tunnel/test").then(r => r.json());
967
- if (r.ok) {
968
- dot.className = "tunnel-dot on";
969
- label.innerHTML = '<strong>claude.ai MCP</strong> — <span style="color:#4ade80">PASS</span> ' +
970
- r.latencyMs + 'ms roundtrip · SSE ' + (r.sseReachable ? 'reachable' : 'unreachable') +
971
- ' · <span class="tunnel-url"><a href="' + r.sseUrl + '" target="_blank">' + r.sseUrl + '</a></span>';
972
- } else {
973
- dot.className = "tunnel-dot err";
974
- label.innerHTML = '<strong>claude.ai MCP</strong> — <span style="color:#f87171">FAIL</span> ' + (r.reason || "unknown error");
1251
+ if (labelEl) {
1252
+ if (r.ok) {
1253
+ labelEl.innerHTML = '<strong>claude.ai MCP</strong> — <span style="color:#4ade80">PASS</span> ' +
1254
+ r.latencyMs + 'ms · SSE ' + (r.sseReachable ? 'reachable' : 'unreachable') +
1255
+ ' · <span class="tunnel-url"><a href="' + r.sseUrl + '" target="_blank">' + r.sseUrl + '</a></span>';
1256
+ } else {
1257
+ labelEl.innerHTML = '<strong>claude.ai MCP</strong> — <span style="color:#f87171">FAIL</span> ' + (r.reason || "unknown error");
1258
+ }
975
1259
  }
976
1260
  } catch (e) {
977
- dot.className = "tunnel-dot err";
978
- label.innerHTML = '<strong>claude.ai MCP</strong> — <span style="color:#f87171">FAIL</span> ' + e.message;
1261
+ if (labelEl) labelEl.innerHTML = '<strong>claude.ai MCP</strong> — <span style="color:#f87171">FAIL</span> ' + e.message;
979
1262
  } finally {
980
- btn.disabled = false; btn.textContent = "Test";
1263
+ if (btn) { btn.disabled = false; btn.textContent = "Test"; }
981
1264
  }
982
1265
  }
983
1266
 
984
1267
  function openClaude() {
985
1268
  // Copy the SSE URL to clipboard and open claude.ai settings
986
1269
  fetch(BASE + "/tunnel").then(r => r.json()).then(t => {
987
- if (t.sseUrl) {
988
- navigator.clipboard.writeText(t.sseUrl).then(() => {
989
- const btn = document.getElementById("btn-claude");
990
- btn.textContent = "SSE URL copied!";
991
- setTimeout(() => { btn.textContent = "Connect claude.ai"; }, 2000);
1270
+ const sseUrl = t.sseUrl || (t.url && t.url.startsWith("http") ? t.url + "/sse" : null);
1271
+ if (sseUrl) {
1272
+ navigator.clipboard.writeText(sseUrl).then(() => {
1273
+ const btn = document.querySelector("#tunnel-panel .tunnel-state.live .btn-claude");
1274
+ if (btn) {
1275
+ btn.textContent = "SSE URL copied!";
1276
+ setTimeout(() => { btn.textContent = "Connect claude.ai"; }, 2000);
1277
+ }
992
1278
  }).catch(() => {});
993
1279
  window.open("https://claude.ai/settings/integrations", "_blank");
994
1280
  }
@@ -997,7 +1283,10 @@ function openClaude() {
997
1283
 
998
1284
  async function toggleMcpSetup() {
999
1285
  const panel = document.getElementById("mcp-setup-panel");
1286
+ const btn = document.getElementById("btn-mcp-setup");
1000
1287
  const isOpen = panel.classList.toggle("open");
1288
+ btn.classList.toggle("open", isOpen);
1289
+ btn.textContent = isOpen ? "Close MCP" : "Setup MCP";
1001
1290
  if (isOpen) {
1002
1291
  try {
1003
1292
  const m = await fetch(BASE + "/mcp-setup").then(r => r.json());
@@ -1021,6 +1310,423 @@ function copyMcp(elId) {
1021
1310
  }).catch(() => {});
1022
1311
  }
1023
1312
 
1313
+ // ── Tunnel Setup Wizard ─────────────────────
1314
+ let wizStep = null;
1315
+ let wizData = {};
1316
+
1317
+ async function openSetupWizard() {
1318
+ const panel = document.getElementById("wizard-panel");
1319
+ panel.classList.add("open");
1320
+ panel.scrollIntoView({ behavior: "smooth", block: "start" });
1321
+ wizStep = "loading";
1322
+ renderWizBody('<p class="loading">Checking setup state…</p>', [], []);
1323
+
1324
+ const state = await apiFetch("/tunnel/setup-state");
1325
+ if (!state) {
1326
+ renderWizBody('<p class="wiz-desc" style="color:#f87171">Could not reach daemon.</p>', [], []);
1327
+ return;
1328
+ }
1329
+
1330
+ if (state.step === "ready") {
1331
+ await apiFetch("/tunnel/start", { method: "POST" });
1332
+ closeSetupWizard();
1333
+ return;
1334
+ }
1335
+
1336
+ wizData = state;
1337
+
1338
+ if (state.step === "need_cf_token") wizShowCfToken();
1339
+ else if (state.step === "pick_tunnel") wizShowPickTunnel(state.tunnels);
1340
+ else if (state.step === "no_tunnels") wizShowCreateViaCfApi(state.accountId);
1341
+ else wizShowInstallCheck(); // no CF token — needs cloudflared login
1342
+ }
1343
+
1344
+ function closeSetupWizard() {
1345
+ document.getElementById("wizard-panel").classList.remove("open");
1346
+ wizStep = null; wizData = {};
1347
+ }
1348
+
1349
+ function renderWizBody(html, steps, footHtml) {
1350
+ document.getElementById("wizard-body").innerHTML = html;
1351
+
1352
+ const stepLabels = ["CF Token","Tunnel","cloudflared","MCP Setup","Test"];
1353
+ const stepsEl = document.getElementById("wizard-steps");
1354
+ stepsEl.innerHTML = stepLabels.map((label, i) => {
1355
+ const stepKeys = ["need_cf_token","pick_tunnel","check_cf","mcp_setup","test"];
1356
+ const currentIdx = stepKeys.indexOf(wizStep);
1357
+ let cls = "wstep";
1358
+ if (i < currentIdx) cls += " done";
1359
+ else if (i === currentIdx) cls += " active";
1360
+ return \`<div class="\${cls}">\${i < currentIdx ? "✓ " : ""}\${label}</div>\`;
1361
+ }).join("");
1362
+
1363
+ document.getElementById("wizard-foot").innerHTML = footHtml.join("");
1364
+ }
1365
+
1366
+ // Step: Enter CF API token
1367
+ function wizShowCfToken() {
1368
+ wizStep = "need_cf_token";
1369
+ renderWizBody(\`
1370
+ <div class="wiz-desc">A Cloudflare API token is needed to check for existing tunnels and configure new ones.</div>
1371
+ <div class="wiz-label">Cloudflare API Token</div>
1372
+ <input class="wiz-input" id="wiz-cf-token-input" type="password" placeholder="Paste your CF API token…" autocomplete="off" spellcheck="false">
1373
+ <div style="margin-top:8px">
1374
+ <a class="wiz-link" href="https://dash.cloudflare.com/profile/api-tokens" target="_blank">↗ Create token at Cloudflare Dashboard</a>
1375
+ <span style="color:#475569;font-size:.78rem;margin-left:8px">(needs Cloudflare Tunnel: Edit permission)</span>
1376
+ </div>
1377
+ <div class="wiz-msg fail" id="wiz-cf-err" style="display:none;margin-top:8px"></div>
1378
+ \`, [], [
1379
+ \`<button class="btn-wiz-primary" id="wiz-cf-btn" onclick="wizSubmitCfToken()">Verify &amp; Save Token</button>\`,
1380
+ \`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`,
1381
+ \`<span class="wiz-msg" id="wiz-cf-msg"></span>\`
1382
+ ]);
1383
+
1384
+ document.getElementById("wiz-cf-token-input").addEventListener("keydown", e => {
1385
+ if (e.key === "Enter") wizSubmitCfToken();
1386
+ });
1387
+ }
1388
+
1389
+ async function wizSubmitCfToken() {
1390
+ const input = document.getElementById("wiz-cf-token-input");
1391
+ const btn = document.getElementById("wiz-cf-btn");
1392
+ const errEl = document.getElementById("wiz-cf-err");
1393
+ const token = input.value.trim();
1394
+ if (!token) return;
1395
+
1396
+ btn.disabled = true; btn.textContent = "Verifying…";
1397
+ if (errEl) errEl.style.display = "none";
1398
+
1399
+ const r = await fetch(BASE + "/tunnel/setup/cf-token", {
1400
+ method: "POST",
1401
+ headers: { "Content-Type": "application/json" },
1402
+ body: JSON.stringify({ token }),
1403
+ }).then(r => r.json()).catch(() => null);
1404
+
1405
+ btn.disabled = false; btn.textContent = "Verify & Save Token";
1406
+
1407
+ if (!r || r.error) {
1408
+ if (errEl) { errEl.textContent = r?.error || "Request failed"; errEl.style.display = "block"; }
1409
+ return;
1410
+ }
1411
+
1412
+ wizData.accountId = r.accountId;
1413
+ const tunnelState = await apiFetch("/tunnel/setup-state");
1414
+ if (tunnelState?.step === "pick_tunnel") wizShowPickTunnel(tunnelState.tunnels);
1415
+ else wizShowInstallCheck();
1416
+ }
1417
+
1418
+ // Step: Pick existing tunnel
1419
+ function wizShowPickTunnel(tunnels) {
1420
+ wizStep = "pick_tunnel";
1421
+
1422
+ const listHtml = tunnels.map(t => \`
1423
+ <div class="wiz-tunnel-item" id="wti-\${t.id}" onclick="wizSelectTunnel('\${t.id}','\${t.name}',this)">
1424
+ <div style="flex:1">
1425
+ <div class="wiz-tunnel-name">\${t.name}</div>
1426
+ <div class="wiz-tunnel-id">\${t.id}</div>
1427
+ </div>
1428
+ <div class="wiz-tunnel-status">\${t.status || "active"}</div>
1429
+ </div>
1430
+ \`).join("");
1431
+
1432
+ renderWizBody(\`
1433
+ <div class="wiz-desc">Found \${tunnels.length} existing Cloudflare tunnel\${tunnels.length !== 1 ? "s" : ""}. Select one to use, or create a new one.</div>
1434
+ <div class="wiz-tunnel-list">\${listHtml}</div>
1435
+ <div style="margin-top:12px">
1436
+ <div class="wiz-label">Public hostname for selected tunnel (e.g. clauth.yourdomain.com)</div>
1437
+ <input class="wiz-input" id="wiz-hostname-input" type="text" placeholder="clauth.yourdomain.com" spellcheck="false" autocomplete="off">
1438
+ </div>
1439
+ <div class="wiz-msg fail" id="wiz-pick-err" style="display:none;margin-top:8px"></div>
1440
+ \`, [], [
1441
+ \`<button class="btn-wiz-primary" id="wiz-pick-btn" onclick="wizSaveTunnel()">Use Selected Tunnel</button>\`,
1442
+ \`<button class="btn-wiz-secondary" onclick="wizShowInstallCheck()">Create New Tunnel Instead</button>\`,
1443
+ \`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`
1444
+ ]);
1445
+
1446
+ window._wizSelectedTunnel = null;
1447
+ }
1448
+
1449
+ function wizSelectTunnel(id, name, el) {
1450
+ document.querySelectorAll(".wiz-tunnel-item").forEach(e => e.classList.remove("selected"));
1451
+ el.classList.add("selected");
1452
+ window._wizSelectedTunnel = { id, name };
1453
+ }
1454
+
1455
+ async function wizSaveTunnel() {
1456
+ const sel = window._wizSelectedTunnel;
1457
+ const hostname = document.getElementById("wiz-hostname-input")?.value.trim();
1458
+ const errEl = document.getElementById("wiz-pick-err");
1459
+ const btn = document.getElementById("wiz-pick-btn");
1460
+
1461
+ if (!sel) { if (errEl) { errEl.textContent = "Select a tunnel first"; errEl.style.display="block"; } return; }
1462
+ if (!hostname) { if (errEl) { errEl.textContent = "Enter a public hostname"; errEl.style.display="block"; } return; }
1463
+
1464
+ btn.disabled = true; btn.textContent = "Saving…";
1465
+
1466
+ const r = await fetch(BASE + "/tunnel/setup/cf-save", {
1467
+ method: "POST",
1468
+ headers: { "Content-Type": "application/json" },
1469
+ body: JSON.stringify({ tunnelId: sel.id, tunnelName: sel.name, hostname }),
1470
+ }).then(r => r.json()).catch(() => null);
1471
+
1472
+ btn.disabled = false; btn.textContent = "Use Selected Tunnel";
1473
+
1474
+ if (!r?.ok) {
1475
+ if (errEl) { errEl.textContent = r?.error || "Save failed"; errEl.style.display="block"; }
1476
+ return;
1477
+ }
1478
+
1479
+ await apiFetch("/tunnel/start", { method: "POST" });
1480
+ wizShowMcpSetup(hostname);
1481
+ }
1482
+
1483
+ // Step: Create tunnel via CF API (CF token already in vault — no cloudflared login needed)
1484
+ function wizShowCreateViaCfApi(accountId) {
1485
+ wizStep = "pick_tunnel";
1486
+ wizData.accountId = accountId;
1487
+ renderWizBody(\`
1488
+ <div class="wiz-desc">No existing tunnels found. Create a new one using your Cloudflare API token.</div>
1489
+ <div class="wiz-label">Tunnel name</div>
1490
+ <input class="wiz-input" id="wiz-api-tname" type="text" value="clauth" spellcheck="false">
1491
+ <div class="wiz-label" style="margin-top:10px">Public hostname (e.g. clauth.yourdomain.com)</div>
1492
+ <input class="wiz-input" id="wiz-api-hostname" type="text" placeholder="clauth.yourdomain.com" spellcheck="false">
1493
+ <div class="wiz-msg fail" id="wiz-api-err" style="display:none;margin-top:8px"></div>
1494
+ \`, [], [
1495
+ \`<button class="btn-wiz-primary" id="wiz-api-btn" onclick="wizRunCreateViaCfApi()">Create Tunnel</button>\`,
1496
+ \`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`,
1497
+ \`<span class="wiz-msg" id="wiz-api-msg"></span>\`
1498
+ ]);
1499
+ }
1500
+
1501
+ async function wizRunCreateViaCfApi() {
1502
+ const name = document.getElementById("wiz-api-tname")?.value.trim();
1503
+ const hostname = document.getElementById("wiz-api-hostname")?.value.trim();
1504
+ const btn = document.getElementById("wiz-api-btn");
1505
+ const err = document.getElementById("wiz-api-err");
1506
+ const msg = document.getElementById("wiz-api-msg");
1507
+ if (!name || !hostname) { if(err){err.textContent="Name and hostname required";err.style.display="block";} return; }
1508
+ btn.disabled=true; btn.textContent="Creating…"; if(err) err.style.display="none";
1509
+ if(msg) msg.textContent="Calling Cloudflare API…";
1510
+ const r = await fetch(BASE + "/tunnel/setup/cf-create-api", {
1511
+ method: "POST",
1512
+ headers: { "Content-Type": "application/json" },
1513
+ body: JSON.stringify({ name, hostname, accountId: wizData.accountId }),
1514
+ }).then(r => r.json()).catch(() => null);
1515
+ btn.disabled=false; btn.textContent="Create Tunnel";
1516
+ if (!r?.ok) {
1517
+ if(err){err.textContent = r?.error || "Creation failed"; err.style.display="block";}
1518
+ if(msg) msg.textContent="";
1519
+ return;
1520
+ }
1521
+ await apiFetch("/tunnel/start", { method: "POST" });
1522
+ wizShowMcpSetup(hostname);
1523
+ }
1524
+
1525
+ // Step: cloudflared install check (for new tunnel creation flow)
1526
+ async function wizShowInstallCheck() {
1527
+ wizStep = "check_cf";
1528
+ renderWizBody('<p class="loading">Checking cloudflared…</p>', [], []);
1529
+
1530
+ const r = await apiFetch("/tunnel/setup/check-cloudflared");
1531
+
1532
+ if (r?.installed) {
1533
+ wizShowCfLogin();
1534
+ } else {
1535
+ renderWizBody(\`
1536
+ <div class="wiz-desc">cloudflared is required to create and run Cloudflare tunnels. It is not currently installed.</div>
1537
+ <div style="display:flex;gap:12px;margin-top:12px;flex-wrap:wrap">
1538
+ <a class="btn-wiz-secondary" href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/" target="_blank" style="text-decoration:none">↗ Download cloudflared</a>
1539
+ <span style="color:#475569;font-size:.8rem;align-self:center">or: <code style="color:#60a5fa">winget install Cloudflare.cloudflared</code></span>
1540
+ </div>
1541
+ <div class="wiz-desc" style="margin-top:12px;font-size:.78rem;color:#475569">After installing, click "Check Again".</div>
1542
+ \`, [], [
1543
+ \`<button class="btn-wiz-primary" onclick="wizShowInstallCheck()">Check Again</button>\`,
1544
+ \`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`
1545
+ ]);
1546
+ }
1547
+ }
1548
+
1549
+ // Step: cloudflared login (Cloudflare auth via browser)
1550
+ function wizShowCfLogin() {
1551
+ wizStep = "check_cf";
1552
+ renderWizBody(\`
1553
+ <div class="wiz-desc">Authenticate cloudflared with your Cloudflare account. This opens a browser window.</div>
1554
+ <div class="wiz-log" id="wiz-login-log" style="display:none"></div>
1555
+ \`, [], [
1556
+ \`<button class="btn-wiz-primary" id="wiz-login-btn" onclick="wizRunCfLogin()">Open Browser to Authenticate</button>\`,
1557
+ \`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`
1558
+ ]);
1559
+ }
1560
+
1561
+ async function wizRunCfLogin() {
1562
+ const btn = document.getElementById("wiz-login-btn");
1563
+ const log = document.getElementById("wiz-login-log");
1564
+ btn.disabled = true; btn.textContent = "Authenticating…";
1565
+ log.style.display = "block";
1566
+
1567
+ try {
1568
+ const resp = await fetch(BASE + "/tunnel/setup/cf-login", { method: "POST" });
1569
+ const reader = resp.body.getReader();
1570
+ const dec = new TextDecoder();
1571
+ let buf = "";
1572
+ while (true) {
1573
+ const { done, value } = await reader.read();
1574
+ if (done) break;
1575
+ buf += dec.decode(value, { stream: true });
1576
+ const lines = buf.split("\\n");
1577
+ buf = lines.pop();
1578
+ for (const line of lines) {
1579
+ if (!line.startsWith("data:")) continue;
1580
+ try {
1581
+ const d = JSON.parse(line.slice(5).trim());
1582
+ if (d.line !== undefined) { log.textContent += d.line + "\\n"; log.scrollTop = log.scrollHeight; }
1583
+ if (d.done) {
1584
+ if (d.code === 0) wizShowCreateTunnel();
1585
+ else { btn.disabled=false; btn.textContent="Retry"; }
1586
+ return;
1587
+ }
1588
+ } catch {}
1589
+ }
1590
+ }
1591
+ } catch(e) {
1592
+ log.textContent += "Error: " + e.message;
1593
+ btn.disabled = false; btn.textContent = "Retry";
1594
+ }
1595
+ }
1596
+
1597
+ // Step: Create new tunnel
1598
+ function wizShowCreateTunnel() {
1599
+ wizStep = "check_cf";
1600
+ renderWizBody(\`
1601
+ <div class="wiz-desc">Create a new named Cloudflare tunnel. Give it a name and a public hostname.</div>
1602
+ <div class="wiz-label">Tunnel name (e.g. clauth)</div>
1603
+ <input class="wiz-input" id="wiz-tname" type="text" value="clauth" spellcheck="false">
1604
+ <div class="wiz-label" style="margin-top:10px">Public hostname (e.g. clauth.yourdomain.com)</div>
1605
+ <input class="wiz-input" id="wiz-thostname" type="text" placeholder="clauth.yourdomain.com" spellcheck="false">
1606
+ <div class="wiz-log" id="wiz-create-log" style="display:none;margin-top:10px"></div>
1607
+ <div class="wiz-msg fail" id="wiz-create-err" style="display:none;margin-top:8px"></div>
1608
+ \`, [], [
1609
+ \`<button class="btn-wiz-primary" id="wiz-create-btn" onclick="wizRunCreateTunnel()">Create Tunnel</button>\`,
1610
+ \`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`
1611
+ ]);
1612
+ }
1613
+
1614
+ async function wizRunCreateTunnel() {
1615
+ const name = document.getElementById("wiz-tname")?.value.trim();
1616
+ const hostname = document.getElementById("wiz-thostname")?.value.trim();
1617
+ const btn = document.getElementById("wiz-create-btn");
1618
+ const log = document.getElementById("wiz-create-log");
1619
+ const err = document.getElementById("wiz-create-err");
1620
+
1621
+ if (!name || !hostname) { if(err){err.textContent="Name and hostname required";err.style.display="block";} return; }
1622
+
1623
+ btn.disabled=true; btn.textContent="Creating…";
1624
+ log.style.display="block"; err.style.display="none";
1625
+
1626
+ try {
1627
+ const resp = await fetch(BASE + "/tunnel/setup/cf-create", {
1628
+ method: "POST",
1629
+ headers: { "Content-Type": "application/json" },
1630
+ body: JSON.stringify({ name, hostname }),
1631
+ });
1632
+ const reader = resp.body.getReader();
1633
+ const dec = new TextDecoder();
1634
+ let buf = "";
1635
+ while (true) {
1636
+ const { done, value } = await reader.read();
1637
+ if (done) break;
1638
+ buf += dec.decode(value, { stream: true });
1639
+ const lines = buf.split("\\n");
1640
+ buf = lines.pop();
1641
+ for (const line of lines) {
1642
+ if (!line.startsWith("data:")) continue;
1643
+ try {
1644
+ const d = JSON.parse(line.slice(5).trim());
1645
+ if (d.line !== undefined) { log.textContent += d.line + "\\n"; log.scrollTop = log.scrollHeight; }
1646
+ if (d.done && d.hostname) {
1647
+ await apiFetch("/tunnel/start", { method: "POST" });
1648
+ wizShowMcpSetup(d.hostname);
1649
+ return;
1650
+ }
1651
+ if (d.error) { err.textContent = d.error; err.style.display="block"; btn.disabled=false; btn.textContent="Retry"; return; }
1652
+ } catch {}
1653
+ }
1654
+ }
1655
+ } catch(e) {
1656
+ if(err){err.textContent=e.message;err.style.display="block";}
1657
+ btn.disabled=false; btn.textContent="Retry";
1658
+ }
1659
+ }
1660
+
1661
+ // Step: MCP setup in claude.ai
1662
+ async function wizShowMcpSetup(hostname) {
1663
+ wizStep = "mcp_setup";
1664
+ const sseUrl = \`https://\${hostname}/sse\`;
1665
+
1666
+ const mcpData = await apiFetch("/mcp-setup");
1667
+
1668
+ renderWizBody(\`
1669
+ <div class="wiz-desc">Add clauth as an MCP server in claude.ai settings. Paste these values:</div>
1670
+ <div style="display:flex;flex-direction:column;gap:8px;margin-top:10px">
1671
+ <div class="mcp-row">
1672
+ <span class="mcp-label">URL</span>
1673
+ <span class="mcp-val" id="wiz-mcp-url">\${sseUrl}</span>
1674
+ <button class="mcp-copy" onclick="wizCopy('wiz-mcp-url',this)">copy</button>
1675
+ </div>
1676
+ <div class="mcp-row">
1677
+ <span class="mcp-label">Client ID</span>
1678
+ <span class="mcp-val" id="wiz-mcp-cid">\${mcpData?.clientId || '(unlock required)'}</span>
1679
+ <button class="mcp-copy" onclick="wizCopy('wiz-mcp-cid',this)">copy</button>
1680
+ </div>
1681
+ <div class="mcp-row">
1682
+ <span class="mcp-label">Secret</span>
1683
+ <span class="mcp-val" id="wiz-mcp-sec">\${mcpData?.clientSecret || '(unlock required)'}</span>
1684
+ <button class="mcp-copy" onclick="wizCopy('wiz-mcp-sec',this)">copy</button>
1685
+ </div>
1686
+ </div>
1687
+ <div style="margin-top:10px;font-size:.78rem;color:#64748b">
1688
+ Paste into <a class="wiz-link" href="https://claude.ai/settings/integrations" target="_blank">claude.ai → Settings → Integrations</a>
1689
+ </div>
1690
+ \`, [], [
1691
+ \`<button class="btn-wiz-primary" onclick="window.open('https://claude.ai/settings/integrations','_blank');wizShowTest()">I've Added It — Test Now</button>\`,
1692
+ \`<button class="btn-wiz-secondary" onclick="wizShowTest()">Skip Test</button>\`,
1693
+ \`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Done</button>\`
1694
+ ]);
1695
+ }
1696
+
1697
+ function wizCopy(elId, btn) {
1698
+ const val = document.getElementById(elId)?.textContent?.trim();
1699
+ if (!val) return;
1700
+ navigator.clipboard.writeText(val).then(() => {
1701
+ const orig = btn.textContent;
1702
+ btn.textContent = "✓"; btn.classList.add("ok");
1703
+ setTimeout(() => { btn.textContent = orig; btn.classList.remove("ok"); }, 1500);
1704
+ }).catch(() => {});
1705
+ }
1706
+
1707
+ // Step: Test — verify MCP is working
1708
+ function wizShowTest() {
1709
+ wizStep = "test";
1710
+ renderWizBody(\`
1711
+ <div class="wiz-desc">In a new claude.ai chat, ask Claude to run this MCP tool call to verify the connection:</div>
1712
+ <div style="background:#030712;border:1px solid #1e293b;border-radius:6px;padding:12px;margin:10px 0;font-family:'Courier New',monospace;font-size:.82rem;color:#6ee7b7;user-select:all">
1713
+ Use the clauth MCP tool: GET /ping — what is the response?
1714
+ </div>
1715
+ <div class="wiz-desc">Claude should report back: <code style="color:#4ade80">{"status":"ok","version":"..."}</code></div>
1716
+ <div class="wiz-test-result" id="wiz-test-result"></div>
1717
+ \`, [], [
1718
+ \`<button class="btn-wiz-primary" onclick="wizMarkTestDone()">✓ It Worked — Done</button>\`,
1719
+ \`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Close</button>\`
1720
+ ]);
1721
+ }
1722
+
1723
+ function wizMarkTestDone() {
1724
+ const r = document.getElementById("wiz-test-result");
1725
+ r.className = "wiz-test-result ok"; r.style.display="block";
1726
+ r.textContent = "✓ Tunnel and MCP are working. claude.ai can now access your vault.";
1727
+ document.getElementById("wizard-foot").innerHTML = \`<button class="btn-wiz-primary" onclick="closeSetupWizard()">Close Setup</button>\`;
1728
+ }
1729
+
1024
1730
  // ── Build Status ──────────────────────────────────
1025
1731
  async function updateBuildStatus() {
1026
1732
  try {
@@ -1074,7 +1780,10 @@ function readBody(req) {
1074
1780
  }
1075
1781
 
1076
1782
  // ── Server logic (shared by foreground + daemon) ─────────────
1077
- function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1783
+ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null) {
1784
+ // tunnelHostname may be updated at runtime (fetched from DB after unlock)
1785
+ let tunnelHostname = tunnelHostnameInit;
1786
+
1078
1787
  // Ensure Windows system tools are reachable (bash shells may lack these on PATH)
1079
1788
  if (os.platform() === "win32") {
1080
1789
  const sys32 = "C:\\Windows\\System32";
@@ -1106,6 +1815,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1106
1815
  get password() { return this._pw; },
1107
1816
  set password(v) { this._pw = v; },
1108
1817
  get machineHash() { return machineHash; },
1818
+ get tunnelUrl() { return tunnelUrl; },
1109
1819
  whitelist,
1110
1820
  failCount,
1111
1821
  MAX_FAILS,
@@ -1120,6 +1830,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1120
1830
  let tunnelProc = null;
1121
1831
  let tunnelUrl = null;
1122
1832
  let tunnelError = null;
1833
+ let tunnelStatus = "not_started"; // "not_started" | "not_configured" | "starting" | "live" | "error" | "missing_cloudflared"
1123
1834
 
1124
1835
  // ── OAuth provider (self-contained for claude.ai MCP) ──────
1125
1836
  const oauthClients = new Map(); // client_id → { client_secret, redirect_uris, client_name }
@@ -1169,6 +1880,28 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1169
1880
  }
1170
1881
  }
1171
1882
 
1883
+ // If binary is still the default name, verify it's actually on PATH
1884
+ if (cfBin === "cloudflared") {
1885
+ try {
1886
+ const { execSync } = await import("child_process");
1887
+ execSync("cloudflared --version", { stdio: "ignore" });
1888
+ } catch {
1889
+ tunnelError = "cloudflared is not installed or not on PATH.";
1890
+ tunnelStatus = "missing_cloudflared";
1891
+ const installMsg = [
1892
+ "",
1893
+ " \u2717 cloudflared not found.",
1894
+ " Download: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
1895
+ " Then run: clauth tunnel setup",
1896
+ "",
1897
+ ].join("\n");
1898
+ console.error(installMsg);
1899
+ try { fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${tunnelError}\n`); } catch {}
1900
+ tunnelProc = null;
1901
+ return;
1902
+ }
1903
+ }
1904
+
1172
1905
  // Named tunnel (fixed subdomain) or quick tunnel (random URL)
1173
1906
  let args;
1174
1907
  if (tunnelHostname) {
@@ -1196,6 +1929,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1196
1929
  const match = stderrBuf.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
1197
1930
  if (match) {
1198
1931
  tunnelUrl = match[0];
1932
+ tunnelStatus = "live";
1199
1933
  const logLine = `[${new Date().toISOString()}] Tunnel started: ${tunnelUrl}\n`;
1200
1934
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
1201
1935
  }
@@ -1206,6 +1940,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1206
1940
  tunnelError = err.code === "ENOENT"
1207
1941
  ? "cloudflared not found — install from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
1208
1942
  : err.message;
1943
+ tunnelStatus = "error";
1209
1944
  tunnelProc = null;
1210
1945
  const logLine = `[${new Date().toISOString()}] Tunnel error: ${tunnelError}\n`;
1211
1946
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
@@ -1215,6 +1950,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1215
1950
  if (tunnelProc === proc) {
1216
1951
  tunnelProc = null;
1217
1952
  if (!tunnelError) tunnelError = `Tunnel exited with code ${code}`;
1953
+ tunnelStatus = "error";
1218
1954
  const logLine = `[${new Date().toISOString()}] Tunnel exited: code ${code}\n`;
1219
1955
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
1220
1956
  }
@@ -1224,12 +1960,14 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1224
1960
  await new Promise(r => setTimeout(r, 4000));
1225
1961
 
1226
1962
  if (tunnelHostname && tunnelProc) {
1963
+ tunnelStatus = "live";
1227
1964
  const logLine = `[${new Date().toISOString()}] Named tunnel started: ${tunnelUrl}\n`;
1228
1965
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
1229
1966
  }
1230
1967
 
1231
1968
  } catch (err) {
1232
1969
  tunnelError = err.message;
1970
+ tunnelStatus = "error";
1233
1971
  tunnelProc = null;
1234
1972
  }
1235
1973
  }
@@ -1240,6 +1978,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1240
1978
  tunnelProc = null;
1241
1979
  tunnelUrl = null;
1242
1980
  tunnelError = null;
1981
+ tunnelStatus = "not_started";
1243
1982
  const logLine = `[${new Date().toISOString()}] Tunnel stopped\n`;
1244
1983
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
1245
1984
  }
@@ -1308,6 +2047,128 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1308
2047
  fetchBuildStatus();
1309
2048
  setInterval(fetchBuildStatus, 15000);
1310
2049
 
2050
+ applyPendingMigrations().then(result => {
2051
+ lastMigrationResult = result;
2052
+ if (result.applied?.length > 0) {
2053
+ const log = `[${new Date().toISOString()}] Migrations applied: ${result.applied.map(m => m.name).join(", ")}\n`;
2054
+ try { fs.appendFileSync(LOG_FILE, log); } catch {}
2055
+ }
2056
+ }).catch(() => {});
2057
+
2058
+ // ── Tunnel config (fetched from DB after unlock) ────────────
2059
+ async function fetchTunnelConfig() {
2060
+ try {
2061
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
2062
+ const sbKey = api.getAnonKey();
2063
+ if (!sbUrl || !sbKey) return null;
2064
+
2065
+ const r = await fetch(
2066
+ `${sbUrl}/rest/v1/clauth_config?key=eq.tunnel_hostname&select=value`,
2067
+ {
2068
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` },
2069
+ signal: AbortSignal.timeout(5000),
2070
+ }
2071
+ );
2072
+ if (!r.ok) return null;
2073
+ const rows = await r.json();
2074
+ if (rows.length > 0 && rows[0].value && rows[0].value !== "null") {
2075
+ return typeof rows[0].value === "string" ? JSON.parse(rows[0].value) : rows[0].value;
2076
+ }
2077
+
2078
+ // DB has no hostname — check ~/.cloudflared/config.yml
2079
+ try {
2080
+ const cfConfig = path.join(os.homedir(), ".cloudflared", "config.yml");
2081
+ if (fs.existsSync(cfConfig)) {
2082
+ const yml = fs.readFileSync(cfConfig, "utf8");
2083
+ const hostMatch = yml.match(/^\s*-\s*hostname:\s*(\S+)/m);
2084
+ if (hostMatch) {
2085
+ const hostname = hostMatch[1];
2086
+ // Save to DB so future loads skip this
2087
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
2088
+ method: "POST",
2089
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
2090
+ body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(hostname) }),
2091
+ }).catch(() => {});
2092
+ return hostname;
2093
+ }
2094
+ }
2095
+ } catch {}
2096
+
2097
+ return null;
2098
+ } catch {
2099
+ return null;
2100
+ }
2101
+ }
2102
+
2103
+ // ── Auto-migration ────────────────────────────────────────────
2104
+ let pendingBreakingMigrations = [];
2105
+ let lastMigrationResult = null;
2106
+
2107
+ async function applyPendingMigrations() {
2108
+ try {
2109
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
2110
+ const sbKey = api.getAnonKey();
2111
+ if (!sbUrl || !sbKey) return { applied: [], errors: [] };
2112
+
2113
+ // Read current schema version (may not exist yet)
2114
+ let currentVersion = 0;
2115
+ try {
2116
+ const r = await fetch(
2117
+ `${sbUrl}/rest/v1/clauth_config?key=eq.schema_version&select=value`,
2118
+ { headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) }
2119
+ );
2120
+ if (r.ok) {
2121
+ const rows = await r.json();
2122
+ if (rows.length > 0) currentVersion = Number(rows[0].value) || 0;
2123
+ }
2124
+ } catch {}
2125
+
2126
+ if (currentVersion >= CURRENT_SCHEMA_VERSION) return { applied: [], errors: [], currentVersion };
2127
+
2128
+ const applied = [];
2129
+ const errors = [];
2130
+
2131
+ for (const m of MIGRATIONS) {
2132
+ if (m.version <= currentVersion || !m.sql) continue;
2133
+ if (m.type === "breaking") {
2134
+ // Queue for UI confirmation — do not auto-apply
2135
+ pendingBreakingMigrations.push(m);
2136
+ continue;
2137
+ }
2138
+ try {
2139
+ // Apply via Edge Function proxy (anon key can't do DDL directly)
2140
+ const r = await fetch(`${(api.getBaseUrl() || "")}/run-migration`, {
2141
+ method: "POST",
2142
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json" },
2143
+ body: JSON.stringify({ sql: m.sql, version: m.version }),
2144
+ signal: AbortSignal.timeout(15000),
2145
+ });
2146
+ if (r.ok) {
2147
+ applied.push(m);
2148
+ currentVersion = m.version;
2149
+ // Update schema_version in clauth_config
2150
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
2151
+ method: "POST",
2152
+ headers: {
2153
+ apikey: sbKey, Authorization: `Bearer ${sbKey}`,
2154
+ "Content-Type": "application/json", Prefer: "resolution=merge-duplicates",
2155
+ },
2156
+ body: JSON.stringify({ key: "schema_version", value: m.version }),
2157
+ });
2158
+ } else {
2159
+ errors.push({ migration: m.name, error: await r.text() });
2160
+ }
2161
+ } catch (e) {
2162
+ errors.push({ migration: m.name, error: e.message });
2163
+ }
2164
+ }
2165
+
2166
+ return { applied, errors, currentVersion };
2167
+ } catch (e) {
2168
+ return { applied: [], errors: [{ migration: "init", error: e.message }] };
2169
+ }
2170
+ }
2171
+
1311
2172
  const server = http.createServer(async (req, res) => {
1312
2173
  const remote = req.socket.remoteAddress;
1313
2174
  const isLocal = remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
@@ -1667,17 +2528,22 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1667
2528
  failures: failCount,
1668
2529
  failures_remaining: MAX_FAILS - failCount,
1669
2530
  services: whitelist || "all",
1670
- port
2531
+ port,
2532
+ tunnel_status: tunnelStatus,
2533
+ tunnel_url: tunnelUrl || null,
2534
+ app_version: VERSION,
2535
+ schema_version: CURRENT_SCHEMA_VERSION,
1671
2536
  });
1672
2537
  }
1673
2538
 
1674
2539
  // GET /tunnel — tunnel status (for dashboard polling)
1675
2540
  if (method === "GET" && reqPath === "/tunnel") {
1676
2541
  return ok(res, {
2542
+ status: tunnelStatus,
1677
2543
  running: !!tunnelProc,
1678
- url: tunnelUrl,
2544
+ url: tunnelUrl || null,
1679
2545
  sseUrl: tunnelUrl && tunnelUrl.startsWith("http") ? `${tunnelUrl}/sse` : null,
1680
- error: tunnelError,
2546
+ error: tunnelError || null,
1681
2547
  });
1682
2548
  }
1683
2549
 
@@ -1686,6 +2552,15 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1686
2552
  return ok(res, buildStatus);
1687
2553
  }
1688
2554
 
2555
+ // GET /migrations — migration registry and last run result
2556
+ if (method === "GET" && reqPath === "/migrations") {
2557
+ return ok(res, {
2558
+ schema_version: CURRENT_SCHEMA_VERSION,
2559
+ last_result: lastMigrationResult,
2560
+ pending_breaking: pendingBreakingMigrations,
2561
+ });
2562
+ }
2563
+
1689
2564
  // GET /mcp-setup — OAuth credentials for claude.ai MCP setup (localhost only)
1690
2565
  if (method === "GET" && reqPath === "/mcp-setup") {
1691
2566
  return ok(res, {
@@ -1695,7 +2570,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1695
2570
  });
1696
2571
  }
1697
2572
 
1698
- // POST /tunnel — start or stop tunnel manually
2573
+ // POST /tunnel — start or stop tunnel manually (action in body)
1699
2574
  if (method === "POST" && reqPath === "/tunnel") {
1700
2575
  if (lockedGuard(res)) return;
1701
2576
  let body;
@@ -1705,11 +2580,27 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1705
2580
  }
1706
2581
  if (body.action === "stop") {
1707
2582
  stopTunnel();
1708
- return ok(res, { ok: true, running: false });
2583
+ return ok(res, { status: tunnelStatus, running: false });
1709
2584
  }
1710
2585
  // start
1711
2586
  await startTunnel();
1712
- return ok(res, { ok: true, running: !!tunnelProc, url: tunnelUrl, error: tunnelError });
2587
+ return ok(res, { status: tunnelStatus, running: !!tunnelProc, url: tunnelUrl, error: tunnelError });
2588
+ }
2589
+
2590
+ // POST /tunnel/start — explicit start endpoint
2591
+ if (method === "POST" && reqPath === "/tunnel/start") {
2592
+ if (lockedGuard(res)) return;
2593
+ if (tunnelProc) return ok(res, { status: tunnelStatus, message: "already running" });
2594
+ tunnelStatus = "starting";
2595
+ startTunnel().catch(() => {});
2596
+ return ok(res, { status: "starting" });
2597
+ }
2598
+
2599
+ // POST /tunnel/stop — explicit stop endpoint
2600
+ if (method === "POST" && reqPath === "/tunnel/stop") {
2601
+ if (lockedGuard(res)) return;
2602
+ stopTunnel();
2603
+ return ok(res, { status: tunnelStatus });
1713
2604
  }
1714
2605
 
1715
2606
  // GET /shutdown (for daemon stop)
@@ -1756,12 +2647,14 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1756
2647
  }
1757
2648
  }
1758
2649
 
1759
- // GET /status
2650
+ // GET /status?project=xxx
1760
2651
  if (method === "GET" && reqPath === "/status") {
1761
2652
  if (lockedGuard(res)) return;
1762
2653
  try {
2654
+ const parsedUrl = new URL(req.url, `http://127.0.0.1:${port}`);
2655
+ const projectFilter = parsedUrl.searchParams.get("project") || undefined;
1763
2656
  const { token, timestamp } = deriveToken(password, machineHash);
1764
- const result = await api.status(password, machineHash, token, timestamp);
2657
+ const result = await api.status(password, machineHash, token, timestamp, projectFilter);
1765
2658
  if (result.error) return strike(res, 502, result.error);
1766
2659
  if (whitelist) {
1767
2660
  result.services = (result.services || []).filter(
@@ -1815,8 +2708,32 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1815
2708
  password = pw; // unlock — store in process memory only
1816
2709
  const logLine = `[${new Date().toISOString()}] Vault unlocked\n`;
1817
2710
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
1818
- // Auto-start Cloudflare Tunnel for claude.ai MCP
1819
- startTunnel().catch(() => {});
2711
+ // Auto-start tunnel: --tunnel flag takes priority, otherwise fetch from DB
2712
+ if (!tunnelHostname) {
2713
+ fetchTunnelConfig().then(configured => {
2714
+ if (configured) {
2715
+ tunnelHostname = configured;
2716
+ tunnelStatus = "starting";
2717
+ startTunnel().catch(() => {});
2718
+ } else {
2719
+ // No tunnel configured in DB and no --tunnel flag
2720
+ tunnelStatus = "not_configured";
2721
+ const msg = [
2722
+ `[${new Date().toISOString()}] No tunnel configured.`,
2723
+ " claude.ai web integration is inactive.",
2724
+ " To enable: run 'clauth tunnel setup'",
2725
+ ].join("\n");
2726
+ try { fs.appendFileSync(LOG_FILE, msg + "\n"); } catch {}
2727
+ console.log("\n ⚠ No tunnel configured — claude.ai web integration inactive.");
2728
+ console.log(" Run: clauth tunnel setup\n");
2729
+ }
2730
+ }).catch(() => {
2731
+ tunnelStatus = "error";
2732
+ });
2733
+ } else {
2734
+ tunnelStatus = "starting";
2735
+ startTunnel().catch(() => {});
2736
+ }
1820
2737
  return ok(res, { ok: true, locked: false });
1821
2738
  } catch {
1822
2739
  // Wrong password — not a lockout strike, just a UI auth attempt
@@ -1942,6 +2859,415 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1942
2859
  }
1943
2860
  }
1944
2861
 
2862
+ // GET /tunnel/setup-state
2863
+ if (method === "GET" && reqPath === "/tunnel/setup-state") {
2864
+ if (lockedGuard(res)) return;
2865
+ try {
2866
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
2867
+ const sbKey = api.getAnonKey();
2868
+
2869
+ // Check for existing hostname in DB
2870
+ const hostnameResp = await fetch(
2871
+ `${sbUrl}/rest/v1/clauth_config?key=eq.tunnel_hostname&select=value`,
2872
+ { headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) }
2873
+ );
2874
+ if (hostnameResp.ok) {
2875
+ const rows = await hostnameResp.json();
2876
+ if (rows.length > 0 && rows[0].value && rows[0].value !== "null" && rows[0].value !== '""') {
2877
+ const hn = typeof rows[0].value === "string" ? JSON.parse(rows[0].value) : rows[0].value;
2878
+ if (hn) return ok(res, { step: "ready", hostname: hn });
2879
+ }
2880
+ }
2881
+
2882
+ // Check ~/.cloudflared/config.yml for a previously configured tunnel
2883
+ try {
2884
+ const cfConfig = path.join(os.homedir(), ".cloudflared", "config.yml");
2885
+ if (fs.existsSync(cfConfig)) {
2886
+ const cfYml = fs.readFileSync(cfConfig, "utf8");
2887
+ // Parse hostname from ingress block: " - hostname: <hostname>"
2888
+ const hostMatch = cfYml.match(/^\s*-\s*hostname:\s*(\S+)/m);
2889
+ const tunnelMatch = cfYml.match(/^tunnel:\s*(\S+)/m);
2890
+ if (hostMatch && tunnelMatch) {
2891
+ const localHostname = hostMatch[1];
2892
+ const localTunnelId = tunnelMatch[1];
2893
+ // Save to DB so next load skips this check
2894
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
2895
+ method: "POST",
2896
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
2897
+ body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(localHostname) }),
2898
+ });
2899
+ tunnelHostname = localHostname;
2900
+ return ok(res, { step: "ready", hostname: localHostname, tunnelId: localTunnelId, source: "local_config" });
2901
+ }
2902
+ }
2903
+ } catch {}
2904
+
2905
+ // Check for CF token in vault
2906
+ let cfToken = null;
2907
+ try {
2908
+ const { token: t, timestamp } = deriveToken(password, machineHash);
2909
+ const cr = await api.retrieve(password, machineHash, t, timestamp, "cloudflare");
2910
+ if (cr?.value) cfToken = cr.value;
2911
+ } catch {}
2912
+
2913
+ if (!cfToken) return ok(res, { step: "need_cf_token" });
2914
+
2915
+ // Get or fetch account ID
2916
+ let accountId = null;
2917
+ try {
2918
+ const acctResp = await fetch(
2919
+ `${sbUrl}/rest/v1/clauth_config?key=eq.cf_account_id&select=value`,
2920
+ { headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) }
2921
+ );
2922
+ if (acctResp.ok) {
2923
+ const rows = await acctResp.json();
2924
+ if (rows.length > 0 && rows[0].value) {
2925
+ accountId = typeof rows[0].value === "string" ? JSON.parse(rows[0].value) : rows[0].value;
2926
+ }
2927
+ }
2928
+ } catch {}
2929
+
2930
+ if (!accountId) {
2931
+ try {
2932
+ const ar = await fetch("https://api.cloudflare.com/client/v4/accounts", {
2933
+ headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000)
2934
+ });
2935
+ const ad = await ar.json();
2936
+ accountId = ad?.result?.[0]?.id;
2937
+ if (accountId) {
2938
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
2939
+ method: "POST",
2940
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
2941
+ body: JSON.stringify({ key: "cf_account_id", value: accountId }),
2942
+ });
2943
+ }
2944
+ } catch {}
2945
+ }
2946
+
2947
+ if (!accountId) return ok(res, { step: "setup_wizard", error: "Could not get Cloudflare account ID" });
2948
+
2949
+ // List tunnels
2950
+ try {
2951
+ const tr = await fetch(
2952
+ `https://api.cloudflare.com/client/v4/accounts/${accountId}/cfd_tunnel?is_deleted=false`,
2953
+ { headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000) }
2954
+ );
2955
+ const td = await tr.json();
2956
+ const tunnels = (td?.result || []).map(t => ({ id: t.id, name: t.name, status: t.status }));
2957
+ if (tunnels.length > 0) return ok(res, { step: "pick_tunnel", tunnels, accountId });
2958
+ // CF token exists but no tunnels — create via API (no cloudflared login needed)
2959
+ return ok(res, { step: "no_tunnels", accountId });
2960
+ } catch {}
2961
+
2962
+ return ok(res, { step: "setup_wizard" });
2963
+ } catch (err) {
2964
+ return ok(res, { step: "setup_wizard", error: err.message });
2965
+ }
2966
+ }
2967
+
2968
+ // POST /tunnel/setup/cf-token
2969
+ if (method === "POST" && reqPath === "/tunnel/setup/cf-token") {
2970
+ if (lockedGuard(res)) return;
2971
+ let body;
2972
+ try { body = await readBody(req); } catch { return strike(res, 400, "Invalid JSON"); }
2973
+ const { token: cfToken } = body;
2974
+ if (!cfToken) return strike(res, 400, "token required");
2975
+
2976
+ try {
2977
+ const vr = await fetch("https://api.cloudflare.com/client/v4/user/tokens/verify", {
2978
+ headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000)
2979
+ });
2980
+ const vd = await vr.json();
2981
+ if (!vd?.success) return strike(res, 400, "Invalid Cloudflare API token");
2982
+
2983
+ const ar = await fetch("https://api.cloudflare.com/client/v4/accounts", {
2984
+ headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000)
2985
+ });
2986
+ const ad = await ar.json();
2987
+ const accountId = ad?.result?.[0]?.id;
2988
+ const accountName = ad?.result?.[0]?.name;
2989
+
2990
+ // Save token to vault using api.write (same as /set/:service)
2991
+ const { token: t, timestamp } = deriveToken(password, machineHash);
2992
+ await api.write(password, machineHash, t, timestamp, "cloudflare", cfToken);
2993
+
2994
+ // Save accountId to clauth_config
2995
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
2996
+ const sbKey = api.getAnonKey();
2997
+ if (accountId) {
2998
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
2999
+ method: "POST",
3000
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
3001
+ body: JSON.stringify({ key: "cf_account_id", value: accountId }),
3002
+ });
3003
+ }
3004
+
3005
+ return ok(res, { ok: true, accountId, accountName });
3006
+ } catch (err) {
3007
+ return strike(res, 502, err.message);
3008
+ }
3009
+ }
3010
+
3011
+ // GET /tunnel/setup/cf-tunnels
3012
+ if (method === "GET" && reqPath === "/tunnel/setup/cf-tunnels") {
3013
+ if (lockedGuard(res)) return;
3014
+ try {
3015
+ const { token: t, timestamp } = deriveToken(password, machineHash);
3016
+ const cr = await api.retrieve(password, machineHash, t, timestamp, "cloudflare");
3017
+ const cfToken = cr?.value;
3018
+ if (!cfToken) return strike(res, 400, "No cloudflare token in vault");
3019
+
3020
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
3021
+ const sbKey = api.getAnonKey();
3022
+ const acctResp = await fetch(
3023
+ `${sbUrl}/rest/v1/clauth_config?key=eq.cf_account_id&select=value`,
3024
+ { headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) }
3025
+ );
3026
+ const acctRows = await acctResp.json();
3027
+ const accountId = acctRows?.[0]?.value ? JSON.parse(acctRows[0].value) : null;
3028
+ if (!accountId) return strike(res, 400, "No account ID — run cf-token first");
3029
+
3030
+ const tr = await fetch(
3031
+ `https://api.cloudflare.com/client/v4/accounts/${accountId}/cfd_tunnel?is_deleted=false`,
3032
+ { headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000) }
3033
+ );
3034
+ const td = await tr.json();
3035
+ return ok(res, { tunnels: (td?.result || []).map(t => ({ id: t.id, name: t.name, status: t.status, created_at: t.created_at })) });
3036
+ } catch (err) {
3037
+ return strike(res, 502, err.message);
3038
+ }
3039
+ }
3040
+
3041
+ // POST /tunnel/setup/cf-save
3042
+ if (method === "POST" && reqPath === "/tunnel/setup/cf-save") {
3043
+ if (lockedGuard(res)) return;
3044
+ let body;
3045
+ try { body = await readBody(req); } catch { return strike(res, 400, "Invalid JSON"); }
3046
+ const { tunnelId, tunnelName, hostname } = body;
3047
+ if (!hostname) return strike(res, 400, "hostname required");
3048
+
3049
+ try {
3050
+ const cfDir = path.join(os.homedir(), ".cloudflared");
3051
+ if (!fs.existsSync(cfDir)) fs.mkdirSync(cfDir, { recursive: true });
3052
+ const credFile = tunnelId ? path.join(cfDir, `${tunnelId}.json`) : path.join(cfDir, "tunnel.json");
3053
+ const configContent = `tunnel: ${tunnelId || tunnelName || "clauth"}\ncredentials-file: ${credFile}\ningress:\n - hostname: ${hostname}\n service: http://127.0.0.1:${port}\n - service: http_status:404\n`;
3054
+ fs.writeFileSync(path.join(cfDir, "config.yml"), configContent, "utf8");
3055
+
3056
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
3057
+ const sbKey = api.getAnonKey();
3058
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
3059
+ method: "POST",
3060
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
3061
+ body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(hostname) }),
3062
+ });
3063
+
3064
+ tunnelHostname = hostname;
3065
+ tunnelUrl = `https://${hostname}`;
3066
+
3067
+ return ok(res, { ok: true, hostname });
3068
+ } catch (err) {
3069
+ return strike(res, 502, err.message);
3070
+ }
3071
+ }
3072
+
3073
+ // POST /tunnel/setup/cf-create-api — create tunnel via CF API (no cloudflared login needed)
3074
+ if (method === "POST" && reqPath === "/tunnel/setup/cf-create-api") {
3075
+ if (lockedGuard(res)) return;
3076
+ let body;
3077
+ try { body = await readBody(req); } catch { return strike(res, 400, "Invalid JSON"); }
3078
+ const { name, hostname, accountId: bodyAccountId } = body;
3079
+ if (!name || !hostname) return strike(res, 400, "name and hostname required");
3080
+ try {
3081
+ const { token: t, timestamp } = deriveToken(password, machineHash);
3082
+ const cr = await api.retrieve(password, machineHash, t, timestamp, "cloudflare");
3083
+ const cfToken = cr?.value;
3084
+ if (!cfToken) return strike(res, 400, "No cloudflare token in vault");
3085
+
3086
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
3087
+ const sbKey = api.getAnonKey();
3088
+
3089
+ // Get accountId
3090
+ let accountId = bodyAccountId;
3091
+ if (!accountId) {
3092
+ const acctResp = await fetch(`${sbUrl}/rest/v1/clauth_config?key=eq.cf_account_id&select=value`,
3093
+ { headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) });
3094
+ const rows = await acctResp.json();
3095
+ accountId = rows?.[0]?.value ? JSON.parse(rows[0].value) : null;
3096
+ }
3097
+ if (!accountId) return strike(res, 400, "No account ID — verify CF token first");
3098
+
3099
+ // Generate tunnel secret (32 random bytes base64)
3100
+ const { randomBytes } = await import("crypto");
3101
+ const tunnelSecret = randomBytes(32).toString("base64");
3102
+
3103
+ // Create tunnel via CF API
3104
+ const createResp = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/cfd_tunnel`, {
3105
+ method: "POST",
3106
+ headers: { Authorization: `Bearer ${cfToken}`, "Content-Type": "application/json" },
3107
+ body: JSON.stringify({ name, tunnel_secret: tunnelSecret }),
3108
+ signal: AbortSignal.timeout(10000),
3109
+ });
3110
+ const createData = await createResp.json();
3111
+ if (!createData?.success) return strike(res, 400, createData?.errors?.[0]?.message || "Tunnel creation failed");
3112
+ const tunnelId = createData.result.id;
3113
+
3114
+ // Write credentials file
3115
+ const cfDir = path.join(os.homedir(), ".cloudflared");
3116
+ if (!fs.existsSync(cfDir)) fs.mkdirSync(cfDir, { recursive: true });
3117
+ const credFile = path.join(cfDir, `${tunnelId}.json`);
3118
+ fs.writeFileSync(credFile, JSON.stringify({ AccountTag: accountId, TunnelID: tunnelId, TunnelName: name, TunnelSecret: tunnelSecret }), "utf8");
3119
+
3120
+ // Write config.yml
3121
+ const configContent = `tunnel: ${tunnelId}\ncredentials-file: ${credFile}\ningress:\n - hostname: ${hostname}\n service: http://127.0.0.1:${port}\n - service: http_status:404\n`;
3122
+ fs.writeFileSync(path.join(cfDir, "config.yml"), configContent, "utf8");
3123
+
3124
+ // Route DNS via CF API — find zone for hostname
3125
+ const domain = hostname.split(".").slice(-2).join(".");
3126
+ const zoneResp = await fetch(`https://api.cloudflare.com/client/v4/zones?name=${domain}`,
3127
+ { headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000) });
3128
+ const zoneData = await zoneResp.json();
3129
+ const zoneId = zoneData?.result?.[0]?.id;
3130
+ if (zoneId) {
3131
+ await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`, {
3132
+ method: "POST",
3133
+ headers: { Authorization: `Bearer ${cfToken}`, "Content-Type": "application/json" },
3134
+ body: JSON.stringify({ type: "CNAME", name: hostname, content: `${tunnelId}.cfargotunnel.com`, proxied: false, ttl: 1 }),
3135
+ signal: AbortSignal.timeout(8000),
3136
+ });
3137
+ }
3138
+
3139
+ // Save hostname to DB and in-process
3140
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
3141
+ method: "POST",
3142
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
3143
+ body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(hostname) }),
3144
+ });
3145
+ tunnelHostname = hostname;
3146
+ tunnelUrl = `https://${hostname}`;
3147
+
3148
+ return ok(res, { ok: true, tunnelId, hostname });
3149
+ } catch (err) {
3150
+ return strike(res, 502, err.message);
3151
+ }
3152
+ }
3153
+
3154
+ // GET /tunnel/setup/check-cloudflared
3155
+ if (method === "GET" && reqPath === "/tunnel/setup/check-cloudflared") {
3156
+ let cfBin = "cloudflared";
3157
+ let installed = false;
3158
+ if (os.platform() === "win32") {
3159
+ const candidates = [
3160
+ "cloudflared",
3161
+ path.join(process.env.ProgramFiles || "", "cloudflared", "cloudflared.exe"),
3162
+ path.join(process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "cloudflared", "cloudflared.exe"),
3163
+ path.join(os.homedir(), "scoop", "shims", "cloudflared.exe"),
3164
+ "C:\\ProgramData\\chocolatey\\bin\\cloudflared.exe",
3165
+ ];
3166
+ for (const c of candidates) {
3167
+ try { if (fs.statSync(c).isFile()) { cfBin = c; installed = true; break; } } catch {}
3168
+ }
3169
+ }
3170
+ if (!installed) {
3171
+ try {
3172
+ const { execSync: es } = await import("child_process");
3173
+ es("cloudflared --version", { stdio: "ignore" });
3174
+ installed = true; cfBin = "cloudflared";
3175
+ } catch {}
3176
+ }
3177
+ return ok(res, { installed, path: installed ? cfBin : null });
3178
+ }
3179
+
3180
+ // POST /tunnel/setup/cf-login — SSE stream of cloudflared tunnel login
3181
+ if (method === "POST" && reqPath === "/tunnel/setup/cf-login") {
3182
+ if (lockedGuard(res)) return;
3183
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", ...CORS });
3184
+ const sendEvt = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
3185
+ try {
3186
+ const { spawn } = await import("child_process");
3187
+ const proc = spawn("cloudflared", ["tunnel", "login"], { stdio: ["ignore","pipe","pipe"] });
3188
+ proc.stdout.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l })));
3189
+ proc.stderr.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l })));
3190
+ proc.on("close", code => { sendEvt({ done: true, code }); res.end(); });
3191
+ req.on("close", () => { try { proc.kill(); } catch {} });
3192
+ } catch (err) {
3193
+ sendEvt({ done: true, code: 1, error: err.message });
3194
+ res.end();
3195
+ }
3196
+ }
3197
+
3198
+ // POST /tunnel/setup/cf-create — SSE stream create + route DNS + save
3199
+ if (method === "POST" && reqPath === "/tunnel/setup/cf-create") {
3200
+ if (lockedGuard(res)) return;
3201
+ let body;
3202
+ try { body = await readBody(req); } catch {
3203
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
3204
+ return res.end(JSON.stringify({ error: "Invalid JSON" }));
3205
+ }
3206
+ const { name, hostname } = body;
3207
+ if (!name || !hostname) {
3208
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
3209
+ return res.end(JSON.stringify({ error: "name and hostname required" }));
3210
+ }
3211
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", ...CORS });
3212
+ const sendEvt = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
3213
+ try {
3214
+ const { spawn } = await import("child_process");
3215
+
3216
+ // Step 1: create tunnel
3217
+ sendEvt({ line: `Creating tunnel "${name}"…`, step: 1 });
3218
+ let tunnelId = null;
3219
+ await new Promise((resolve, reject) => {
3220
+ const proc = spawn("cloudflared", ["tunnel", "create", name], { stdio: ["ignore","pipe","pipe"] });
3221
+ let output = "";
3222
+ proc.stdout.on("data", d => { const s = d.toString(); output += s; s.split("\n").forEach(l => l.trim() && sendEvt({ line: l, step: 1 })); });
3223
+ proc.stderr.on("data", d => { const s = d.toString(); output += s; s.split("\n").forEach(l => l.trim() && sendEvt({ line: l, step: 1 })); });
3224
+ proc.on("close", code => {
3225
+ const m = output.match(/Created tunnel .+ with id ([a-f0-9-]{36})/i);
3226
+ if (m) tunnelId = m[1];
3227
+ if (code !== 0 && !tunnelId) reject(new Error(`create failed (exit ${code})`));
3228
+ else resolve();
3229
+ });
3230
+ });
3231
+
3232
+ if (!tunnelId) {
3233
+ sendEvt({ error: "Could not parse tunnel ID from cloudflared output" });
3234
+ return res.end();
3235
+ }
3236
+
3237
+ // Step 2: route DNS
3238
+ sendEvt({ line: `Routing DNS: ${hostname}…`, step: 2 });
3239
+ await new Promise((resolve) => {
3240
+ const proc = spawn("cloudflared", ["tunnel", "route", "dns", name, hostname], { stdio: ["ignore","pipe","pipe"] });
3241
+ proc.stdout.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l, step: 2 })));
3242
+ proc.stderr.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l, step: 2 })));
3243
+ proc.on("close", () => resolve());
3244
+ });
3245
+
3246
+ // Step 3: save config
3247
+ const cfDir = path.join(os.homedir(), ".cloudflared");
3248
+ if (!fs.existsSync(cfDir)) fs.mkdirSync(cfDir, { recursive: true });
3249
+ const credFile = path.join(cfDir, `${tunnelId}.json`);
3250
+ const configContent = `tunnel: ${tunnelId}\ncredentials-file: ${credFile}\ningress:\n - hostname: ${hostname}\n service: http://127.0.0.1:${port}\n - service: http_status:404\n`;
3251
+ fs.writeFileSync(path.join(cfDir, "config.yml"), configContent, "utf8");
3252
+
3253
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
3254
+ const sbKey = api.getAnonKey();
3255
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
3256
+ method: "POST",
3257
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
3258
+ body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(hostname) }),
3259
+ });
3260
+ tunnelHostname = hostname;
3261
+ tunnelUrl = `https://${hostname}`;
3262
+
3263
+ sendEvt({ done: true, tunnelId, hostname });
3264
+ res.end();
3265
+ } catch (err) {
3266
+ sendEvt({ error: err.message, done: true });
3267
+ res.end();
3268
+ }
3269
+ }
3270
+
1945
3271
  // POST /change-pw — change master password (must be unlocked)
1946
3272
  if (method === "POST" && reqPath === "/change-pw") {
1947
3273
  if (lockedGuard(res)) return;
@@ -2014,7 +3340,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
2014
3340
  return res.end(JSON.stringify({ error: "Invalid JSON body" }));
2015
3341
  }
2016
3342
 
2017
- const { name, label, key_type, description } = body;
3343
+ const { name, label, key_type, description, project } = body;
2018
3344
  if (!name || typeof name !== "string" || !name.trim()) {
2019
3345
  res.writeHead(400, { "Content-Type": "application/json", ...CORS });
2020
3346
  return res.end(JSON.stringify({ error: "name is required" }));
@@ -2028,7 +3354,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
2028
3354
 
2029
3355
  try {
2030
3356
  const { token, timestamp } = deriveToken(password, machineHash);
2031
- const result = await api.addService(password, machineHash, token, timestamp, name.trim().toLowerCase(), label || name.trim(), type, description || "");
3357
+ const result = await api.addService(password, machineHash, token, timestamp, name.trim().toLowerCase(), label || name.trim(), type, description || "", project || undefined);
2032
3358
  if (result.error) return strike(res, 502, result.error);
2033
3359
  return ok(res, { ok: true, service: name.trim().toLowerCase() });
2034
3360
  } catch (err) {
@@ -2036,6 +3362,42 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
2036
3362
  }
2037
3363
  }
2038
3364
 
3365
+ // POST /update-service — update service metadata (project, label, description)
3366
+ if (method === "POST" && reqPath === "/update-service") {
3367
+ if (lockedGuard(res)) return;
3368
+
3369
+ let body;
3370
+ try { body = await readBody(req); } catch {
3371
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
3372
+ return res.end(JSON.stringify({ error: "Invalid JSON body" }));
3373
+ }
3374
+
3375
+ const { service, project, label, description } = body;
3376
+ if (!service || typeof service !== "string") {
3377
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
3378
+ return res.end(JSON.stringify({ error: "service is required" }));
3379
+ }
3380
+
3381
+ const updates = {};
3382
+ if (project !== undefined) updates.project = project;
3383
+ if (label !== undefined) updates.label = label;
3384
+ if (description !== undefined) updates.description = description;
3385
+
3386
+ if (Object.keys(updates).length === 0) {
3387
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
3388
+ return res.end(JSON.stringify({ error: "At least one field to update is required (project, label, description)" }));
3389
+ }
3390
+
3391
+ try {
3392
+ const { token, timestamp } = deriveToken(password, machineHash);
3393
+ const result = await api.updateService(password, machineHash, token, timestamp, service.toLowerCase(), updates);
3394
+ if (result.error) return strike(res, 502, result.error);
3395
+ return ok(res, { ok: true, service: service.toLowerCase(), ...updates });
3396
+ } catch (err) {
3397
+ return strike(res, 502, err.message);
3398
+ }
3399
+ }
3400
+
2039
3401
  // Unknown route — don't count browser/MCP noise as auth failures
2040
3402
  // Don't count browser noise, MCP discovery probes, or OAuth probes as auth failures
2041
3403
  const isBenign = reqPath.startsWith("/.well-known/") || [
@@ -2412,6 +3774,24 @@ const MCP_TOOLS = [
2412
3774
  additionalProperties: false
2413
3775
  }
2414
3776
  },
3777
+ {
3778
+ name: "clauth_set_project",
3779
+ description: "Set or clear the project scope on a service. Pass empty string to clear.",
3780
+ inputSchema: {
3781
+ type: "object",
3782
+ properties: {
3783
+ service: { type: "string", description: "Service name (e.g. gmail, github)" },
3784
+ project: { type: "string", description: "Project name to assign (empty string to clear)" }
3785
+ },
3786
+ required: ["service", "project"],
3787
+ additionalProperties: false
3788
+ }
3789
+ },
3790
+ {
3791
+ name: "clauth_self_check",
3792
+ description: "Test whether the clauth MCP connector is reachable via the Cloudflare tunnel. Returns connectivity status and tunnel URL.",
3793
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
3794
+ },
2415
3795
  ];
2416
3796
 
2417
3797
  function writeTempSecret(service, value) {
@@ -2479,13 +3859,14 @@ async function handleMcpTool(vault, name, args) {
2479
3859
  if (vault.whitelist) {
2480
3860
  services = services.filter(s => vault.whitelist.includes(s.name.toLowerCase()));
2481
3861
  }
2482
- const lines = ["SERVICE TYPE STATUS KEY LAST RETRIEVED",
2483
- "--- ---- ------ --- --------------"];
3862
+ const lines = ["SERVICE TYPE PROJECT STATUS KEY LAST RETRIEVED",
3863
+ "------- ---- ------- ------ --- --------------"];
2484
3864
  for (const s of services) {
2485
3865
  const status = s.enabled ? "ACTIVE" : (s.vault_key ? "SUSPENDED" : "NO KEY");
2486
3866
  const hasKey = s.vault_key ? "yes" : "—";
2487
3867
  const lastGet = s.last_retrieved ? new Date(s.last_retrieved).toLocaleDateString() : "never";
2488
- lines.push(`${s.name.padEnd(20)} ${(s.key_type || "").padEnd(12)} ${status.padEnd(12)} ${hasKey.padEnd(6)} ${lastGet}`);
3868
+ const proj = (s.project || "").padEnd(22);
3869
+ lines.push(`${s.name.padEnd(24)} ${(s.key_type || "").padEnd(12)} ${proj} ${status.padEnd(12)} ${hasKey.padEnd(6)} ${lastGet}`);
2489
3870
  }
2490
3871
  return mcpResult(lines.join("\n"));
2491
3872
  } catch (err) {
@@ -2652,6 +4033,57 @@ async function handleMcpTool(vault, name, args) {
2652
4033
  }
2653
4034
  }
2654
4035
 
4036
+ case "clauth_set_project": {
4037
+ if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
4038
+ const service = (args.service || "").toLowerCase();
4039
+ const project = args.project;
4040
+ if (!service) return mcpError("service is required");
4041
+ if (project === undefined) return mcpError("project is required (empty string to clear)");
4042
+ try {
4043
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
4044
+ const result = await api.updateService(vault.password, vault.machineHash, token, timestamp, service, { project: project || "" });
4045
+ if (result.error) return mcpError(result.error);
4046
+ return mcpResult(project ? `${service} → project: ${project}` : `${service} → project cleared`);
4047
+ } catch (err) {
4048
+ return mcpError(err.message);
4049
+ }
4050
+ }
4051
+
4052
+ case "clauth_self_check": {
4053
+ // Test connectivity via the Cloudflare tunnel and/or localhost daemon
4054
+ const tunnelUrl = vault.tunnelUrl;
4055
+ const results = [];
4056
+
4057
+ // Check localhost daemon
4058
+ try {
4059
+ const r = await fetch("http://127.0.0.1:52437/ping", { signal: AbortSignal.timeout(3000) });
4060
+ const data = await r.json();
4061
+ results.push(`Local daemon: PASS (${data.locked ? "locked" : "unlocked"}, failures: ${data.failures ?? 0})`);
4062
+ } catch (err) {
4063
+ results.push(`Local daemon: FAIL — http://127.0.0.1:52437 not reachable (${err.message})`);
4064
+ }
4065
+
4066
+ // Check tunnel
4067
+ if (tunnelUrl) {
4068
+ try {
4069
+ const r = await fetch(`${tunnelUrl}/ping`, { signal: AbortSignal.timeout(5000) });
4070
+ const data = await r.json();
4071
+ results.push(`Tunnel: PASS — ${tunnelUrl} reachable (${data.locked ? "locked" : "unlocked"})`);
4072
+ results.push(`claude.ai SSE endpoint: ${tunnelUrl}/sse`);
4073
+ results.push(`claude.ai MCP endpoint: ${tunnelUrl}/mcp`);
4074
+ } catch (err) {
4075
+ results.push(`Tunnel: FAIL — ${tunnelUrl} not reachable (${err.message})`);
4076
+ results.push("claude.ai MCP connector will not work until tunnel is restored.");
4077
+ results.push("Fix: clauth serve start --tunnel clauth.prtrust.fund");
4078
+ }
4079
+ } else {
4080
+ results.push("Tunnel: NOT RUNNING — claude.ai connector requires the tunnel.");
4081
+ results.push("Start with: clauth serve start --tunnel clauth.prtrust.fund");
4082
+ }
4083
+
4084
+ return mcpResult(results.join("\n"));
4085
+ }
4086
+
2655
4087
  default:
2656
4088
  return mcpError(`Unknown tool: ${name}`);
2657
4089
  }
@@ -2674,6 +4106,7 @@ function createMcpServer(initPassword, whitelist) {
2674
4106
  const vault = {
2675
4107
  password: initPassword || null,
2676
4108
  get machineHash() { return ensureMachineHash(); },
4109
+ get tunnelUrl() { return null; }, // stdio server has no tunnel — self_check will try localhost daemon
2677
4110
  whitelist,
2678
4111
  failCount: 0,
2679
4112
  MAX_FAILS: 10,
@@ -2765,61 +4198,154 @@ async function actionMcp(opts) {
2765
4198
  createMcpServer(password, whitelist);
2766
4199
  }
2767
4200
 
2768
- // ── DPAPI auto-start install / uninstall (Windows only) ──────
2769
- const AUTOSTART_DIR = path.join(os.homedir(), "AppData", "Roaming", "clauth");
2770
- const BOOT_KEY_PATH = path.join(AUTOSTART_DIR, "boot.key");
2771
- const PS_SCRIPT_PATH = path.join(AUTOSTART_DIR, "autostart.ps1");
2772
- const TASK_NAME = "ClauthAutostart";
4201
+ // ── Cross-platform auto-start install / uninstall ────────────
4202
+ // Windows: DPAPI + Scheduled Task
4203
+ // macOS: Keychain + LaunchAgent
4204
+ // Linux: libsecret/encrypted file + systemd user service
4205
+
4206
+ const TASK_NAME = "ClauthAutostart";
4207
+
4208
+ function getAutostartDir() {
4209
+ const platform = os.platform();
4210
+ if (platform === "win32") {
4211
+ return path.join(os.homedir(), "AppData", "Roaming", "clauth");
4212
+ } else if (platform === "darwin") {
4213
+ return path.join(os.homedir(), "Library", "LaunchAgents");
4214
+ } else {
4215
+ return path.join(os.homedir(), ".config", "systemd", "user");
4216
+ }
4217
+ }
4218
+
4219
+ function getBootKeyPath() {
4220
+ if (os.platform() === "win32") {
4221
+ return path.join(os.homedir(), "AppData", "Roaming", "clauth", "boot.key");
4222
+ } else if (os.platform() === "darwin") {
4223
+ return null; // stored in Keychain, not a file
4224
+ } else {
4225
+ return path.join(os.homedir(), ".config", "clauth", "boot.key");
4226
+ }
4227
+ }
2773
4228
 
2774
4229
  async function actionInstall(opts) {
2775
- if (os.platform() !== "win32") {
2776
- console.log(chalk.red("\n serve install is only supported on Windows\n"));
2777
- process.exit(1);
4230
+ const platform = os.platform();
4231
+ const { execSync } = await import("child_process");
4232
+ const config = new Conf(getConfOptions());
4233
+
4234
+ const tunnelHostname = opts.tunnel || null;
4235
+
4236
+ // Persist tunnel hostname in config if provided
4237
+ if (tunnelHostname) {
4238
+ config.set("tunnel_hostname", tunnelHostname);
4239
+ console.log(chalk.gray(`\n Tunnel hostname saved: ${tunnelHostname}`));
2778
4240
  }
2779
4241
 
2780
- const { default: inquirer } = await import("inquirer");
2781
- const { pw } = await inquirer.prompt([{
2782
- type: "password", name: "pw",
2783
- message: "Enter clauth password to store for auto-start:", mask: "*"
2784
- }]);
4242
+ // Check for cloudflared if tunnel is requested
4243
+ if (tunnelHostname) {
4244
+ try {
4245
+ execSync("cloudflared --version", { encoding: "utf8", stdio: "pipe" });
4246
+ } catch {
4247
+ console.log(chalk.yellow("\n cloudflared not found — required for tunnel support"));
4248
+ console.log(chalk.gray(" Install: winget install Cloudflare.cloudflared"));
4249
+ try {
4250
+ const { installCloudflared } = await import("./doctor.js");
4251
+ await installCloudflared();
4252
+ } catch {
4253
+ console.log(chalk.yellow(" Continuing without tunnel — install cloudflared manually later"));
4254
+ }
4255
+ }
4256
+ }
2785
4257
 
2786
- fs.mkdirSync(AUTOSTART_DIR, { recursive: true });
4258
+ // Two modes:
4259
+ // 1. Password provided via -p flag → encrypt + install (non-interactive)
4260
+ // 2. No password → install watchdog that starts daemon in locked mode
4261
+ // The browser dashboard opens, user enters password there.
4262
+ // For passwordless restart, user can later run: clauth serve seal
4263
+ const pw = opts.pw || null;
2787
4264
 
2788
- // Encrypt password with Windows DPAPI (CurrentUser scope machine+user bound)
2789
- const spinner = ora("Encrypting password with Windows DPAPI...").start();
2790
- let encrypted;
2791
- try {
2792
- const { execSync } = await import("child_process");
2793
- const pwEscaped = pw.replace(/'/g, "''");
2794
- const psExpr = `[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes('${pwEscaped}'),$null,'CurrentUser'))`;
2795
- encrypted = execSync(`powershell -NoProfile -Command "${psExpr}"`, { encoding: "utf8" }).trim();
2796
- fs.writeFileSync(BOOT_KEY_PATH, encrypted, "utf8");
2797
- spinner.succeed(chalk.green("Password encrypted → boot.key"));
2798
- } catch (err) {
2799
- spinner.fail(chalk.red(`DPAPI encryption failed: ${err.message}`));
2800
- process.exit(1);
4265
+ if (platform === "win32") {
4266
+ await installWindows(pw, tunnelHostname, execSync);
4267
+ } else if (platform === "darwin") {
4268
+ await installMacOS(pw, tunnelHostname, execSync);
4269
+ } else {
4270
+ await installLinux(pw, tunnelHostname, execSync);
2801
4271
  }
4272
+ }
2802
4273
 
2803
- // Write PowerShell autostart script decrypts boot.key and pipes to clauth serve start
4274
+ // ── Windows: DPAPI + Scheduled Task ────────────────────────
4275
+ async function installWindows(pw, tunnelHostname, execSync) {
4276
+ const autostartDir = path.join(os.homedir(), "AppData", "Roaming", "clauth");
4277
+ const bootKeyPath = path.join(autostartDir, "boot.key");
4278
+ const psScriptPath = path.join(autostartDir, "autostart.ps1");
2804
4279
  const cliEntry = path.resolve(__dirname, "../index.js").replace(/\\/g, "\\\\");
2805
4280
  const nodeExe = process.execPath.replace(/\\/g, "\\\\");
2806
- const bootKey = BOOT_KEY_PATH.replace(/\\/g, "\\\\");
2807
- const psScript = [
2808
- "# clauth autostart — generated by clauth serve install",
2809
- `$enc = (Get-Content '${bootKey}' -Raw).Trim()`,
2810
- `$pw = [Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($enc),$null,'CurrentUser'))`,
2811
- `Start-Process '${nodeExe}' -ArgumentList "'${cliEntry}' serve start -p $pw" -WindowStyle Hidden`,
2812
- ].join("\n");
2813
- fs.writeFileSync(PS_SCRIPT_PATH, psScript, "utf8");
4281
+ const bootKey = bootKeyPath.replace(/\\/g, "\\\\");
4282
+ const tunnelArg = tunnelHostname ? ` --tunnel ${tunnelHostname}` : "";
4283
+
4284
+ fs.mkdirSync(autostartDir, { recursive: true });
4285
+
4286
+ if (pw) {
4287
+ // ── Sealed mode: DPAPI-encrypt password for fully unattended restart ──
4288
+ const spinner = ora("Encrypting password with Windows DPAPI...").start();
4289
+ try {
4290
+ const pwEscaped = pw.replace(/'/g, "''");
4291
+ const psExpr = `[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes('${pwEscaped}'),$null,'CurrentUser'))`;
4292
+ const encrypted = execSync(`powershell -NoProfile -Command "${psExpr}"`, { encoding: "utf8" }).trim();
4293
+ fs.writeFileSync(bootKeyPath, encrypted, "utf8");
4294
+ spinner.succeed(chalk.green("Password sealed via DPAPI → boot.key"));
4295
+ } catch (err) {
4296
+ spinner.fail(chalk.red(`DPAPI encryption failed: ${err.message}`));
4297
+ process.exit(1);
4298
+ }
4299
+
4300
+ // Watchdog script: decrypts password, starts daemon unlocked, monitors for crash
4301
+ const psScript = [
4302
+ "# clauth autostart + watchdog (sealed mode)",
4303
+ "# Starts unlocked and restarts on crash every 15s",
4304
+ "",
4305
+ `$enc = (Get-Content '${bootKey}' -Raw).Trim()`,
4306
+ `$pw = [Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($enc),$null,'CurrentUser'))`,
4307
+ "",
4308
+ "while ($true) {",
4309
+ " try {",
4310
+ " $ping = Invoke-RestMethod -Uri 'http://127.0.0.1:52437/ping' -TimeoutSec 3 -ErrorAction Stop",
4311
+ " } catch {",
4312
+ ` Start-Process '${nodeExe}' -ArgumentList "'${cliEntry}' serve start -p $pw${tunnelArg}" -WindowStyle Hidden`,
4313
+ " Start-Sleep -Seconds 5",
4314
+ " }",
4315
+ " Start-Sleep -Seconds 15",
4316
+ "}",
4317
+ ].join("\n");
4318
+ fs.writeFileSync(psScriptPath, psScript, "utf8");
4319
+ } else {
4320
+ // ── First-run mode: no password yet — start locked, browser opens for setup ──
4321
+ // Watchdog starts the daemon in locked mode; user enters password in the browser dashboard.
4322
+ // After entering password in the browser, user can seal it for future unattended restarts
4323
+ // by running: clauth serve seal
4324
+ const psScript = [
4325
+ "# clauth autostart + watchdog (locked mode — browser password entry)",
4326
+ "# Starts daemon locked, opens browser for password. Restarts on crash every 15s.",
4327
+ "",
4328
+ "while ($true) {",
4329
+ " try {",
4330
+ " $ping = Invoke-RestMethod -Uri 'http://127.0.0.1:52437/ping' -TimeoutSec 3 -ErrorAction Stop",
4331
+ " } catch {",
4332
+ ` Start-Process '${nodeExe}' -ArgumentList "'${cliEntry}' serve start${tunnelArg}" -WindowStyle Hidden`,
4333
+ " Start-Sleep -Seconds 5",
4334
+ " }",
4335
+ " Start-Sleep -Seconds 15",
4336
+ "}",
4337
+ ].join("\n");
4338
+ fs.writeFileSync(psScriptPath, psScript, "utf8");
4339
+ }
2814
4340
 
2815
4341
  // Register Windows Scheduled Task — triggers on user logon
2816
- const spinner2 = ora("Registering Windows Scheduled Task...").start();
4342
+ const mode = pw ? "sealed fully unattended" : "locked — browser password entry";
4343
+ const spinner2 = ora(`Registering Scheduled Task (${mode})...`).start();
2817
4344
  try {
2818
- const { execSync } = await import("child_process");
2819
- const psScriptEsc = PS_SCRIPT_PATH.replace(/\\/g, "\\\\");
4345
+ const psScriptEsc = psScriptPath.replace(/\\/g, "\\\\");
2820
4346
  const args = `-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${psScriptEsc}"`;
2821
4347
  execSync(
2822
- `schtasks /create /f /tn "${TASK_NAME}" /sc onlogon /tr "powershell.exe ${args}"`,
4348
+ `${process.env.SystemRoot || "C:\\\\Windows"}\\\\System32\\\\schtasks.exe /create /f /tn "${TASK_NAME}" /sc onlogon /tr "powershell.exe ${args}"`,
2823
4349
  { encoding: "utf8", stdio: "pipe" }
2824
4350
  );
2825
4351
  spinner2.succeed(chalk.green(`Scheduled Task "${TASK_NAME}" registered`));
@@ -2828,33 +4354,327 @@ async function actionInstall(opts) {
2828
4354
  console.log(chalk.gray(" You can still start manually: clauth serve start"));
2829
4355
  }
2830
4356
 
2831
- console.log(chalk.cyan("\n Auto-start installed:\n"));
2832
- console.log(chalk.gray(` boot.key: ${BOOT_KEY_PATH}`));
2833
- console.log(chalk.gray(` script: ${PS_SCRIPT_PATH}`));
2834
- console.log(chalk.gray(` task: ${TASK_NAME}\n`));
2835
- console.log(chalk.green(" Daemon will auto-start on next Windows login.\n"));
4357
+ // Start the daemon now
4358
+ const spinner3 = ora("Starting daemon...").start();
4359
+ try {
4360
+ if (pw) {
4361
+ execSync(`"${nodeExe.replace(/\\\\/g, "\\")}" "${cliEntry.replace(/\\\\/g, "\\")}" serve start -p "${pw}"`, {
4362
+ encoding: "utf8", stdio: "pipe", timeout: 10000,
4363
+ });
4364
+ } else {
4365
+ execSync(`"${nodeExe.replace(/\\\\/g, "\\")}" "${cliEntry.replace(/\\\\/g, "\\")}" serve start`, {
4366
+ encoding: "utf8", stdio: "pipe", timeout: 10000,
4367
+ });
4368
+ }
4369
+ spinner3.succeed(chalk.green("Daemon started"));
4370
+ } catch {
4371
+ spinner3.succeed(chalk.green("Daemon starting..."));
4372
+ }
4373
+
4374
+ // Open browser for first-run password setup (locked mode only)
4375
+ if (!pw) {
4376
+ try { openBrowser("http://127.0.0.1:52437"); } catch {}
4377
+ }
4378
+
4379
+ console.log(chalk.cyan("\n Auto-start installed (Windows):\n"));
4380
+ if (pw) {
4381
+ console.log(chalk.gray(` mode: sealed (DPAPI — fully unattended restart)`));
4382
+ console.log(chalk.gray(` boot.key: ${bootKeyPath}`));
4383
+ } else {
4384
+ console.log(chalk.gray(` mode: locked (enter password in browser on restart)`));
4385
+ console.log(chalk.gray(` browser: http://127.0.0.1:52437`));
4386
+ console.log(chalk.gray(` seal later: clauth serve seal (enables unattended restart)`));
4387
+ }
4388
+ console.log(chalk.gray(` watchdog: ${psScriptPath}`));
4389
+ console.log(chalk.gray(` task: ${TASK_NAME} (restarts every 15s on crash)`));
4390
+ if (tunnelHostname) console.log(chalk.gray(` tunnel: ${tunnelHostname}`));
4391
+ console.log(chalk.green("\n Done. Daemon will auto-start on login and restart on crash.\n"));
2836
4392
  }
2837
4393
 
2838
- async function actionUninstall() {
2839
- if (os.platform() !== "win32") {
2840
- console.log(chalk.red("\n serve uninstall is only supported on Windows\n"));
4394
+ // ── macOS: Keychain + LaunchAgent ──────────────────────────
4395
+ async function installMacOS(pw, tunnelHostname, execSync) {
4396
+ const launchAgentsDir = path.join(os.homedir(), "Library", "LaunchAgents");
4397
+ const plistPath = path.join(launchAgentsDir, "com.lifeai.clauth.plist");
4398
+ const keychainService = "com.lifeai.clauth";
4399
+ const keychainAccount = "clauth-daemon";
4400
+
4401
+ fs.mkdirSync(launchAgentsDir, { recursive: true });
4402
+
4403
+ // Store password in macOS Keychain
4404
+ const spinner = ora("Storing password in macOS Keychain...").start();
4405
+ try {
4406
+ // Delete existing entry if present (ignore errors)
4407
+ try {
4408
+ execSync(
4409
+ `security delete-generic-password -s "${keychainService}" -a "${keychainAccount}"`,
4410
+ { encoding: "utf8", stdio: "pipe" }
4411
+ );
4412
+ } catch {}
4413
+
4414
+ execSync(
4415
+ `security add-generic-password -s "${keychainService}" -a "${keychainAccount}" -w "${pw.replace(/"/g, '\\"')}" -U`,
4416
+ { encoding: "utf8", stdio: "pipe" }
4417
+ );
4418
+ spinner.succeed(chalk.green("Password stored in Keychain"));
4419
+ } catch (err) {
4420
+ spinner.fail(chalk.red(`Keychain storage failed: ${err.message}`));
2841
4421
  process.exit(1);
2842
4422
  }
4423
+
4424
+ // Find the node executable and cli entry
4425
+ const cliEntry = path.resolve(__dirname, "../index.js");
4426
+ const nodeExe = process.execPath;
4427
+
4428
+ // Build shell command that reads from Keychain and starts clauth
4429
+ const tunnelArg = tunnelHostname ? ` --tunnel ${tunnelHostname}` : "";
4430
+ const shellCmd = `PW=$(security find-generic-password -s "${keychainService}" -a "${keychainAccount}" -w) && exec "${nodeExe}" "${cliEntry}" serve start -p "$PW"${tunnelArg}`;
4431
+
4432
+ // Create LaunchAgent plist
4433
+ const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
4434
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4435
+ <plist version="1.0">
4436
+ <dict>
4437
+ <key>Label</key>
4438
+ <string>com.lifeai.clauth</string>
4439
+ <key>ProgramArguments</key>
4440
+ <array>
4441
+ <string>/bin/sh</string>
4442
+ <string>-c</string>
4443
+ <string>${shellCmd.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")}</string>
4444
+ </array>
4445
+ <key>RunAtLoad</key>
4446
+ <true/>
4447
+ <key>KeepAlive</key>
4448
+ <true/>
4449
+ <key>StandardOutPath</key>
4450
+ <string>/tmp/clauth-serve.log</string>
4451
+ <key>StandardErrorPath</key>
4452
+ <string>/tmp/clauth-serve.log</string>
4453
+ </dict>
4454
+ </plist>`;
4455
+
4456
+ fs.writeFileSync(plistPath, plistContent, "utf8");
4457
+
4458
+ // Load the LaunchAgent
4459
+ const spinner2 = ora("Loading LaunchAgent...").start();
4460
+ try {
4461
+ // Unload first in case it's already loaded (ignore errors)
4462
+ try { execSync(`launchctl unload "${plistPath}"`, { stdio: "pipe" }); } catch {}
4463
+ execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
4464
+ spinner2.succeed(chalk.green("LaunchAgent loaded"));
4465
+ } catch (err) {
4466
+ spinner2.fail(chalk.yellow(`LaunchAgent load failed (non-fatal): ${err.message}`));
4467
+ console.log(chalk.gray(" You can still start manually: clauth serve start"));
4468
+ }
4469
+
4470
+ console.log(chalk.cyan("\n Auto-start installed (macOS):\n"));
4471
+ console.log(chalk.gray(` plist: ${plistPath}`));
4472
+ console.log(chalk.gray(` keychain: ${keychainService}`));
4473
+ if (tunnelHostname) console.log(chalk.gray(` tunnel: ${tunnelHostname}`));
4474
+ console.log(chalk.green("\n Daemon will auto-start on login and restart on crash.\n"));
4475
+ }
4476
+
4477
+ // ── Linux: encrypted file + systemd user service ───────────
4478
+ async function installLinux(pw, tunnelHostname, execSync) {
4479
+ const configDir = path.join(os.homedir(), ".config", "clauth");
4480
+ const bootKeyPath = path.join(configDir, "boot.key");
4481
+ const systemdDir = path.join(os.homedir(), ".config", "systemd", "user");
4482
+ const serviceFile = path.join(systemdDir, "clauth.service");
4483
+
4484
+ fs.mkdirSync(configDir, { recursive: true });
4485
+ fs.mkdirSync(systemdDir, { recursive: true });
4486
+
4487
+ // Try to store password using secret-tool (libsecret/GNOME Keyring)
4488
+ let useSecretTool = false;
4489
+ const spinner = ora("Storing password securely...").start();
4490
+
4491
+ try {
4492
+ execSync("which secret-tool", { encoding: "utf8", stdio: "pipe" });
4493
+ execSync(
4494
+ `echo -n "${pw.replace(/"/g, '\\"')}" | secret-tool store --label="clauth daemon password" service clauth account daemon`,
4495
+ { encoding: "utf8", stdio: "pipe" }
4496
+ );
4497
+ useSecretTool = true;
4498
+ spinner.succeed(chalk.green("Password stored via secret-tool (GNOME Keyring)"));
4499
+ } catch {
4500
+ // Fallback: encrypt with openssl and store in file
4501
+ // Uses a key derived from machine-id for basic protection at rest
4502
+ try {
4503
+ const machineId = fs.readFileSync("/etc/machine-id", "utf8").trim();
4504
+ const encrypted = execSync(
4505
+ `echo -n "${pw.replace(/"/g, '\\"')}" | openssl enc -aes-256-cbc -pbkdf2 -iter 100000 -pass pass:"${machineId}" -base64`,
4506
+ { encoding: "utf8", stdio: "pipe" }
4507
+ ).trim();
4508
+ fs.writeFileSync(bootKeyPath, encrypted, { mode: 0o600 });
4509
+ spinner.succeed(chalk.green("Password encrypted -> boot.key (openssl fallback)"));
4510
+ } catch (err) {
4511
+ spinner.fail(chalk.red(`Password storage failed: ${err.message}`));
4512
+ process.exit(1);
4513
+ }
4514
+ }
4515
+
4516
+ // Find the node executable and cli entry
4517
+ const cliEntry = path.resolve(__dirname, "../index.js");
4518
+ const nodeExe = process.execPath;
4519
+
4520
+ // Build the ExecStart command
4521
+ const tunnelArg = tunnelHostname ? ` --tunnel ${tunnelHostname}` : "";
4522
+ let execStart;
4523
+ if (useSecretTool) {
4524
+ // Create a wrapper script that reads from secret-tool
4525
+ const wrapperPath = path.join(configDir, "start.sh");
4526
+ const wrapperContent = [
4527
+ "#!/bin/sh",
4528
+ `PW=$(secret-tool lookup service clauth account daemon)`,
4529
+ `exec "${nodeExe}" "${cliEntry}" serve start -p "$PW"${tunnelArg}`,
4530
+ ].join("\n");
4531
+ fs.writeFileSync(wrapperPath, wrapperContent, { mode: 0o700 });
4532
+ execStart = `/bin/sh ${wrapperPath}`;
4533
+ } else {
4534
+ // Create a wrapper script that decrypts boot.key
4535
+ const wrapperPath = path.join(configDir, "start.sh");
4536
+ const wrapperContent = [
4537
+ "#!/bin/sh",
4538
+ `MACHINE_ID=$(cat /etc/machine-id)`,
4539
+ `PW=$(openssl enc -aes-256-cbc -pbkdf2 -iter 100000 -pass pass:"$MACHINE_ID" -base64 -d < "${bootKeyPath}")`,
4540
+ `exec "${nodeExe}" "${cliEntry}" serve start -p "$PW"${tunnelArg}`,
4541
+ ].join("\n");
4542
+ fs.writeFileSync(wrapperPath, wrapperContent, { mode: 0o700 });
4543
+ execStart = `/bin/sh ${wrapperPath}`;
4544
+ }
4545
+
4546
+ // Create systemd user service
4547
+ const serviceContent = `[Unit]
4548
+ Description=clauth credential daemon
4549
+ After=network-online.target
4550
+ Wants=network-online.target
4551
+
4552
+ [Service]
4553
+ Type=simple
4554
+ ExecStart=${execStart}
4555
+ Restart=always
4556
+ RestartSec=5
4557
+ Environment=NODE_ENV=production
4558
+
4559
+ [Install]
4560
+ WantedBy=default.target
4561
+ `;
4562
+
4563
+ fs.writeFileSync(serviceFile, serviceContent, "utf8");
4564
+
4565
+ // Enable and start the service
4566
+ const spinner2 = ora("Enabling systemd user service...").start();
4567
+ try {
4568
+ execSync("systemctl --user daemon-reload", { stdio: "pipe" });
4569
+ execSync("systemctl --user enable clauth", { stdio: "pipe" });
4570
+ execSync("systemctl --user start clauth", { stdio: "pipe" });
4571
+ spinner2.succeed(chalk.green("systemd user service enabled and started"));
4572
+ } catch (err) {
4573
+ spinner2.fail(chalk.yellow(`systemd setup failed (non-fatal): ${err.message}`));
4574
+ console.log(chalk.gray(" You can still start manually: clauth serve start"));
4575
+ }
4576
+
4577
+ console.log(chalk.cyan("\n Auto-start installed (Linux):\n"));
4578
+ console.log(chalk.gray(` service: ${serviceFile}`));
4579
+ console.log(chalk.gray(` password: ${useSecretTool ? "GNOME Keyring" : bootKeyPath}`));
4580
+ if (tunnelHostname) console.log(chalk.gray(` tunnel: ${tunnelHostname}`));
4581
+ console.log(chalk.green("\n Daemon will auto-start on login and restart on crash.\n"));
4582
+ }
4583
+
4584
+ // ── Cross-platform uninstall ───────────────────────────────
4585
+ async function actionUninstall() {
4586
+ const platform = os.platform();
2843
4587
  const { execSync } = await import("child_process");
2844
4588
 
4589
+ if (platform === "win32") {
4590
+ await uninstallWindows(execSync);
4591
+ } else if (platform === "darwin") {
4592
+ await uninstallMacOS(execSync);
4593
+ } else {
4594
+ await uninstallLinux(execSync);
4595
+ }
4596
+ }
4597
+
4598
+ async function uninstallWindows(execSync) {
4599
+ const autostartDir = path.join(os.homedir(), "AppData", "Roaming", "clauth");
4600
+ const bootKeyPath = path.join(autostartDir, "boot.key");
4601
+ const psScriptPath = path.join(autostartDir, "autostart.ps1");
4602
+
2845
4603
  // Remove scheduled task
2846
4604
  try {
2847
- execSync(`schtasks /delete /f /tn "${TASK_NAME}"`, { encoding: "utf8", stdio: "pipe" });
4605
+ execSync(`${process.env.SystemRoot || "C:\\\\Windows"}\\\\System32\\\\schtasks.exe /delete /f /tn "${TASK_NAME}"`, { encoding: "utf8", stdio: "pipe" });
2848
4606
  console.log(chalk.green(` Removed Scheduled Task: ${TASK_NAME}`));
2849
4607
  } catch { console.log(chalk.gray(` Task not found (already removed): ${TASK_NAME}`)); }
2850
4608
 
2851
4609
  // Remove boot.key and autostart script
2852
- for (const f of [BOOT_KEY_PATH, PS_SCRIPT_PATH]) {
4610
+ for (const f of [bootKeyPath, psScriptPath]) {
4611
+ try { fs.unlinkSync(f); console.log(chalk.green(` Deleted: ${f}`)); }
4612
+ catch { console.log(chalk.gray(` Not found (skipped): ${f}`)); }
4613
+ }
4614
+
4615
+ console.log(chalk.cyan("\n Auto-start uninstalled (Windows).\n"));
4616
+ }
4617
+
4618
+ async function uninstallMacOS(execSync) {
4619
+ const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", "com.lifeai.clauth.plist");
4620
+ const keychainService = "com.lifeai.clauth";
4621
+ const keychainAccount = "clauth-daemon";
4622
+
4623
+ // Unload LaunchAgent
4624
+ try {
4625
+ execSync(`launchctl unload "${plistPath}"`, { stdio: "pipe" });
4626
+ console.log(chalk.green(" Unloaded LaunchAgent"));
4627
+ } catch { console.log(chalk.gray(" LaunchAgent not loaded (already removed)")); }
4628
+
4629
+ // Remove plist
4630
+ try { fs.unlinkSync(plistPath); console.log(chalk.green(` Deleted: ${plistPath}`)); }
4631
+ catch { console.log(chalk.gray(` Not found (skipped): ${plistPath}`)); }
4632
+
4633
+ // Remove Keychain entry
4634
+ try {
4635
+ execSync(
4636
+ `security delete-generic-password -s "${keychainService}" -a "${keychainAccount}"`,
4637
+ { encoding: "utf8", stdio: "pipe" }
4638
+ );
4639
+ console.log(chalk.green(" Removed Keychain entry"));
4640
+ } catch { console.log(chalk.gray(" Keychain entry not found (already removed)")); }
4641
+
4642
+ console.log(chalk.cyan("\n Auto-start uninstalled (macOS).\n"));
4643
+ }
4644
+
4645
+ async function uninstallLinux(execSync) {
4646
+ const configDir = path.join(os.homedir(), ".config", "clauth");
4647
+ const bootKeyPath = path.join(configDir, "boot.key");
4648
+ const wrapperPath = path.join(configDir, "start.sh");
4649
+ const serviceFile = path.join(os.homedir(), ".config", "systemd", "user", "clauth.service");
4650
+
4651
+ // Stop and disable systemd service
4652
+ try {
4653
+ execSync("systemctl --user stop clauth", { stdio: "pipe" });
4654
+ execSync("systemctl --user disable clauth", { stdio: "pipe" });
4655
+ console.log(chalk.green(" Stopped and disabled systemd service"));
4656
+ } catch { console.log(chalk.gray(" systemd service not running (already removed)")); }
4657
+
4658
+ // Remove service file
4659
+ try { fs.unlinkSync(serviceFile); console.log(chalk.green(` Deleted: ${serviceFile}`)); }
4660
+ catch { console.log(chalk.gray(` Not found (skipped): ${serviceFile}`)); }
4661
+
4662
+ // Reload systemd
4663
+ try { execSync("systemctl --user daemon-reload", { stdio: "pipe" }); } catch {}
4664
+
4665
+ // Remove boot.key and wrapper script
4666
+ for (const f of [bootKeyPath, wrapperPath]) {
2853
4667
  try { fs.unlinkSync(f); console.log(chalk.green(` Deleted: ${f}`)); }
2854
4668
  catch { console.log(chalk.gray(` Not found (skipped): ${f}`)); }
2855
4669
  }
2856
4670
 
2857
- console.log(chalk.cyan("\n Auto-start uninstalled.\n"));
4671
+ // Try to remove secret-tool entry
4672
+ try {
4673
+ execSync("secret-tool clear service clauth account daemon", { stdio: "pipe" });
4674
+ console.log(chalk.green(" Removed secret-tool entry"));
4675
+ } catch { /* secret-tool may not be installed */ }
4676
+
4677
+ console.log(chalk.cyan("\n Auto-start uninstalled (Linux).\n"));
2858
4678
  }
2859
4679
 
2860
4680
  // ── Export ────────────────────────────────────────────────────