@lifeaitools/clauth 0.7.6 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,34 @@ 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
+ }
200
283
  </style>
201
284
  </head>
202
285
  <body>
@@ -215,6 +298,13 @@ function dashboardHtml(port, whitelist) {
215
298
 
216
299
  <!-- ── Main view (shown after unlock) ──────── -->
217
300
  <div id="main-view">
301
+ <div id="upgrade-banner" style="display:none" class="upgrade-banner">
302
+ <div class="upgrade-content">
303
+ <strong>⬆ clauth upgraded to v<span id="upgrade-to-version"></span></strong>
304
+ <span id="upgrade-details"></span>
305
+ <button onclick="dismissUpgrade()">Dismiss ✕</button>
306
+ </div>
307
+ </div>
218
308
  <div class="header">
219
309
  <div class="dot" id="dot"></div>
220
310
  <h1>🔐 clauth vault <span style="font-size:0.55em;opacity:0.45;font-weight:400">v${VERSION}</span></h1>
@@ -256,6 +346,10 @@ function dashboardHtml(port, whitelist) {
256
346
  <option value="">Loading…</option>
257
347
  </select>
258
348
  </div>
349
+ <div class="add-field">
350
+ <label>Project <span style="color:#475569;font-weight:400">(optional)</span></label>
351
+ <input class="add-input" id="add-project" type="text" placeholder="e.g. marketing-engine" autocomplete="off" spellcheck="false">
352
+ </div>
259
353
  </div>
260
354
  <div class="add-foot">
261
355
  <button class="btn-chpw-save" onclick="addService()">Create Service</button>
@@ -284,15 +378,45 @@ function dashboardHtml(port, whitelist) {
284
378
  </div>
285
379
 
286
380
  <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…
381
+ <!-- not_configured -->
382
+ <div class="tunnel-state not-configured" style="display:none;align-items:center;gap:10px;width:100%;flex-wrap:wrap">
383
+ <div class="tunnel-dot off"></div>
384
+ <div class="tunnel-label"><strong>claude.ai MCP</strong> — No tunnel configured</div>
385
+ <button class="btn-tunnel-stop" onclick="runTunnelSetup()">Setup Tunnel</button>
386
+ </div>
387
+ <!-- missing_cloudflared -->
388
+ <div class="tunnel-state missing-cloudflared" style="display:none;align-items:center;gap:10px;width:100%;flex-wrap:wrap">
389
+ <div class="tunnel-dot err"></div>
390
+ <div class="tunnel-label"><strong>claude.ai MCP</strong> — cloudflared not installed</div>
391
+ <a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/" target="_blank" style="color:#60a5fa;font-size:.8rem">Download cloudflared ↗</a>
392
+ <span class="tunnel-err" style="display:inline;font-size:.78rem;color:#94a3b8">Then run: clauth tunnel setup</span>
393
+ </div>
394
+ <!-- starting -->
395
+ <div class="tunnel-state starting" style="display:none;align-items:center;gap:10px;width:100%;flex-wrap:wrap">
396
+ <div class="tunnel-dot starting"></div>
397
+ <div class="tunnel-label"><strong>claude.ai MCP</strong> — Tunnel starting…</div>
398
+ </div>
399
+ <!-- live -->
400
+ <div class="tunnel-state live" style="display:none;align-items:center;gap:10px;width:100%;flex-wrap:wrap">
401
+ <div class="tunnel-dot on"></div>
402
+ <div class="tunnel-label"><strong>claude.ai MCP</strong> — <span class="tunnel-url"><a href="" target="_blank" id="tunnel-live-url"></a></span></div>
403
+ <button class="btn-check" style="padding:6px 12px;font-size:.8rem" onclick="testTunnel()">Test</button>
404
+ <button class="btn-claude" onclick="openClaude()">Connect claude.ai</button>
405
+ <button class="btn-mcp-setup" id="btn-mcp-setup" onclick="toggleMcpSetup()">Setup MCP</button>
406
+ <button class="btn-tunnel-stop" onclick="toggleTunnel('stop')">Stop</button>
407
+ </div>
408
+ <!-- error -->
409
+ <div class="tunnel-state error" style="display:none;align-items:center;gap:10px;width:100%;flex-wrap:wrap">
410
+ <div class="tunnel-dot err"></div>
411
+ <div class="tunnel-label"><strong>claude.ai MCP</strong> — Tunnel error — check cloudflared config</div>
412
+ <button class="btn-tunnel-stop" onclick="toggleTunnel('start')">Retry</button>
413
+ </div>
414
+ <!-- not_started -->
415
+ <div class="tunnel-state not-started" style="display:flex;align-items:center;gap:10px;width:100%;flex-wrap:wrap">
416
+ <div class="tunnel-dot off"></div>
417
+ <div class="tunnel-label"><strong>claude.ai MCP</strong> — checking tunnel…</div>
418
+ <button class="btn-tunnel-stop" onclick="toggleTunnel('start')">Start Tunnel</button>
290
419
  </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
420
  </div>
297
421
 
298
422
  <div class="mcp-setup" id="mcp-setup-panel">
@@ -317,6 +441,7 @@ function dashboardHtml(port, whitelist) {
317
441
  <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
442
  </div>
319
443
 
444
+ <div id="project-tabs" class="project-tabs" style="display:none"></div>
320
445
  <div id="grid" class="grid"><p class="loading">Loading services…</p></div>
321
446
  <div class="footer">localhost:${port} · 127.0.0.1 only · 10-strike lockout</div>
322
447
  </div>
@@ -346,6 +471,19 @@ const KEY_URLS = {
346
471
  "neo4j": "https://console.neo4j.io/",
347
472
  "npm": "https://www.npmjs.com/settings/~/tokens",
348
473
  "gmail": "https://console.cloud.google.com/apis/credentials",
474
+ "gcal": "https://console.cloud.google.com/apis/credentials",
475
+ };
476
+
477
+ // Extra links shown below the primary KEY_URLS link
478
+ const EXTRA_LINKS = {
479
+ "gmail": [
480
+ { label: "↗ OAuth Playground (get refresh token)", url: "https://developers.google.com/oauthplayground/" },
481
+ { label: "↗ Enable Gmail API", url: "https://console.cloud.google.com/apis/library/gmail.googleapis.com" },
482
+ ],
483
+ "gcal": [
484
+ { label: "↗ OAuth Playground (get refresh token)", url: "https://developers.google.com/oauthplayground/" },
485
+ { label: "↗ Enable Calendar API", url: "https://console.cloud.google.com/apis/library/calendar-json.googleapis.com" },
486
+ ],
349
487
  };
350
488
 
351
489
  // ── OAuth import config ─────────────────────
@@ -355,7 +493,11 @@ const KEY_URLS = {
355
493
  const OAUTH_IMPORT = {
356
494
  "gmail": {
357
495
  jsonFields: ["client_id", "client_secret"],
358
- extra: [{ key: "refresh_token", label: "Refresh Token", hint: "From Google OAuth Playground or your app's auth callback" }]
496
+ extra: [{ key: "refresh_token", label: "Refresh Token", hint: "From Google OAuth Playground select Gmail API scopes, authorize, then exchange for tokens" }]
497
+ },
498
+ "gcal": {
499
+ jsonFields: ["client_id", "client_secret"],
500
+ extra: [{ key: "refresh_token", label: "Refresh Token", hint: "From Google OAuth Playground — select Calendar API scopes, authorize, then exchange for tokens" }]
359
501
  }
360
502
  };
361
503
 
@@ -423,7 +565,40 @@ function showLockScreen() {
423
565
  setTimeout(() => document.getElementById("lock-input").focus(), 50);
424
566
  }
425
567
 
568
+ // ── Upgrade detection ────────────────────────────────────────────
569
+ function checkUpgrade(ping) {
570
+ const currentVersion = ping.app_version || ping.version;
571
+ if (!currentVersion) return;
572
+
573
+ const lastVersion = localStorage.getItem('clauth_last_version');
574
+
575
+ if (lastVersion && lastVersion !== currentVersion) {
576
+ // Version changed — show upgrade banner
577
+ document.getElementById('upgrade-to-version').textContent = currentVersion;
578
+
579
+ // Build details from migration result if available
580
+ let details = '';
581
+ if (ping.last_migrations?.applied?.length > 0) {
582
+ details = '· Migrations: ' + ping.last_migrations.applied.map(m => m.description).join(' · ');
583
+ }
584
+ // Show breaking migration warning if any pending
585
+ if (ping.pending_breaking?.length > 0) {
586
+ details += ' ⚠ Breaking migrations require confirmation — see below.';
587
+ }
588
+ document.getElementById('upgrade-details').textContent = details;
589
+ document.getElementById('upgrade-banner').style.display = 'block';
590
+ }
591
+
592
+ // Always update stored version
593
+ localStorage.setItem('clauth_last_version', currentVersion);
594
+ }
595
+
596
+ function dismissUpgrade() {
597
+ document.getElementById('upgrade-banner').style.display = 'none';
598
+ }
599
+
426
600
  function showMain(ping) {
601
+ checkUpgrade(ping);
427
602
  document.getElementById("lock-screen").style.display = "none";
428
603
  document.getElementById("main-view").style.display = "block";
429
604
  if (ping) {
@@ -476,6 +651,82 @@ async function lockVault() {
476
651
  }
477
652
 
478
653
  // ── Load services ───────────────────────────
654
+ let allServices = [];
655
+ let activeProjectTab = "all";
656
+
657
+ function renderProjectTabs(services) {
658
+ const tabsEl = document.getElementById("project-tabs");
659
+ const projects = new Map(); // project -> count
660
+ let unassigned = 0;
661
+ for (const s of services) {
662
+ if (s.project) {
663
+ projects.set(s.project, (projects.get(s.project) || 0) + 1);
664
+ } else {
665
+ unassigned++;
666
+ }
667
+ }
668
+ // Only show tabs if there's at least one project
669
+ if (projects.size === 0) { tabsEl.style.display = "none"; return; }
670
+ tabsEl.style.display = "flex";
671
+ const tabs = [
672
+ { key: "all", label: "All", count: services.length },
673
+ ...Array.from(projects.entries()).map(([name, count]) => ({ key: name, label: name, count })),
674
+ { key: "unassigned", label: "Global", count: unassigned },
675
+ ];
676
+ tabsEl.innerHTML = tabs.map(t =>
677
+ \`<button class="project-tab \${activeProjectTab === t.key ? "active" : ""}" onclick="switchProjectTab('\${t.key}')">\${t.label}<span class="tab-count">(\${t.count})</span></button>\`
678
+ ).join("");
679
+ }
680
+
681
+ function switchProjectTab(key) {
682
+ activeProjectTab = key;
683
+ renderProjectTabs(allServices);
684
+ renderServiceGrid(allServices);
685
+ }
686
+
687
+ function renderServiceGrid(services) {
688
+ const grid = document.getElementById("grid");
689
+ let filtered = services;
690
+ if (activeProjectTab === "unassigned") {
691
+ filtered = services.filter(s => !s.project);
692
+ } else if (activeProjectTab !== "all") {
693
+ filtered = services.filter(s => s.project === activeProjectTab);
694
+ }
695
+ if (!filtered.length) { grid.innerHTML = '<p class="loading">No services in this group.</p>'; return; }
696
+ grid.innerHTML = filtered.map(s => \`
697
+ <div class="card">
698
+ <div style="display:flex;align-items:flex-start;justify-content:space-between">
699
+ <div>
700
+ <div class="card-name">\${s.name}</div>
701
+ <div style="display:flex;align-items:center;gap:6px;margin-top:2px">
702
+ <div class="card-type">\${s.key_type || "secret"}</div>
703
+ <span class="svc-badge \${s.enabled === false ? "off" : "on"}" id="badge-\${s.name}">\${s.enabled === false ? "disabled" : "enabled"}</span>
704
+ \${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>\` : ""}
705
+ </div>
706
+ \${KEY_URLS[s.name] ? \`<a class="card-getkey" href="\${KEY_URLS[s.name]}" target="_blank" rel="noopener">↗ Get / rotate key</a>\` : ""}
707
+ \${(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("")}
708
+ </div>
709
+ <div class="status-dot" id="sdot-\${s.name}" title=""></div>
710
+ </div>
711
+ <div class="card-value" id="val-\${s.name}"></div>
712
+ <div class="card-actions">
713
+ <button class="btn btn-reveal" onclick="reveal('\${s.name}', this)">Reveal</button>
714
+ <button class="btn btn-copy" id="copybtn-\${s.name}" style="display:none" onclick="copyKey('\${s.name}')">Copy</button>
715
+ <button class="btn btn-set" onclick="toggleSet('\${s.name}')">Set</button>
716
+ <button class="btn-project" onclick="toggleProjectEdit('\${s.name}')">\${s.project ? "✎ Project" : "+ Project"}</button>
717
+ <button class="btn \${s.enabled === false ? "btn-enable" : "btn-disable"}" id="togbtn-\${s.name}" onclick="toggleService('\${s.name}')">\${s.enabled === false ? "Enable" : "Disable"}</button>
718
+ </div>
719
+ <div class="project-edit" id="pe-\${s.name}">
720
+ <input type="text" id="pe-input-\${s.name}" value="\${s.project || ""}" placeholder="Project name…" spellcheck="false" autocomplete="off">
721
+ <button onclick="saveProject('\${s.name}')">Save</button>
722
+ <button onclick="clearProject('\${s.name}')">Clear</button>
723
+ <span class="pe-msg" id="pe-msg-\${s.name}"></span>
724
+ </div>
725
+ \${renderSetPanel(s.name)}
726
+ </div>
727
+ \`).join("");
728
+ }
729
+
479
730
  async function loadServices() {
480
731
  const grid = document.getElementById("grid");
481
732
  const err = document.getElementById("error-bar");
@@ -487,32 +738,11 @@ async function loadServices() {
487
738
  if (status.locked) { showLockScreen(); return; }
488
739
  if (status.error) throw new Error(status.error);
489
740
 
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("");
741
+ allServices = status.services || [];
742
+ if (!allServices.length) { grid.innerHTML = '<p class="loading">No services registered.</p>'; return; }
743
+
744
+ renderProjectTabs(allServices);
745
+ renderServiceGrid(allServices);
516
746
  } catch (e) {
517
747
  err.textContent = "⚠ " + e.message;
518
748
  err.style.display = "block";
@@ -561,6 +791,47 @@ async function copyKey(name) {
561
791
  } catch {}
562
792
  }
563
793
 
794
+ // ── Project edit ────────────────────────────
795
+ function toggleProjectEdit(name) {
796
+ const panel = document.getElementById("pe-" + name);
797
+ const open = panel.classList.contains("open");
798
+ panel.classList.toggle("open");
799
+ if (!open) {
800
+ const svc = allServices.find(s => s.name === name);
801
+ document.getElementById("pe-input-" + name).value = svc ? (svc.project || "") : "";
802
+ document.getElementById("pe-msg-" + name).textContent = "";
803
+ document.getElementById("pe-input-" + name).focus();
804
+ }
805
+ }
806
+
807
+ async function saveProject(name) {
808
+ const input = document.getElementById("pe-input-" + name);
809
+ const msg = document.getElementById("pe-msg-" + name);
810
+ const project = input.value.trim();
811
+ msg.textContent = "Saving…"; msg.style.color = "#94a3b8";
812
+ try {
813
+ const r = await fetch(BASE + "/update-service", {
814
+ method: "POST",
815
+ headers: { "Content-Type": "application/json" },
816
+ body: JSON.stringify({ service: name, project: project || "" })
817
+ }).then(r => r.json());
818
+ if (r.locked) { showLockScreen(); return; }
819
+ if (r.error) throw new Error(r.error);
820
+ msg.style.color = "#4ade80"; msg.textContent = "✓ Saved";
821
+ // Update local state
822
+ const svc = allServices.find(s => s.name === name);
823
+ if (svc) svc.project = project || null;
824
+ setTimeout(() => { loadServices(); }, 800);
825
+ } catch (err) {
826
+ msg.style.color = "#f87171"; msg.textContent = err.message;
827
+ }
828
+ }
829
+
830
+ async function clearProject(name) {
831
+ document.getElementById("pe-input-" + name).value = "";
832
+ await saveProject(name);
833
+ }
834
+
564
835
  // ── Set key ─────────────────────────────────
565
836
  function toggleSet(name) {
566
837
  const panel = document.getElementById("set-panel-" + name);
@@ -793,6 +1064,7 @@ async function toggleAddService() {
793
1064
  panel.style.display = open ? "none" : "block";
794
1065
  if (!open) {
795
1066
  document.getElementById("add-name").value = "";
1067
+ document.getElementById("add-project").value = "";
796
1068
  document.getElementById("add-msg").textContent = "";
797
1069
  // Fetch available key types from server
798
1070
  const sel = document.getElementById("add-type");
@@ -815,6 +1087,7 @@ async function toggleAddService() {
815
1087
  async function addService() {
816
1088
  const name = document.getElementById("add-name").value.trim().toLowerCase();
817
1089
  const type = document.getElementById("add-type").value;
1090
+ const project = document.getElementById("add-project").value.trim();
818
1091
  const msg = document.getElementById("add-msg");
819
1092
 
820
1093
  if (!name) { msg.className = "add-msg fail"; msg.textContent = "Service name is required."; return; }
@@ -822,10 +1095,12 @@ async function addService() {
822
1095
 
823
1096
  msg.className = "add-msg"; msg.textContent = "Creating…";
824
1097
  try {
1098
+ const payload = { name, key_type: type, label: name };
1099
+ if (project) payload.project = project;
825
1100
  const r = await fetch(BASE + "/add-service", {
826
1101
  method: "POST",
827
1102
  headers: { "Content-Type": "application/json" },
828
- body: JSON.stringify({ name, key_type: type, label: name })
1103
+ body: JSON.stringify(payload)
829
1104
  }).then(r => r.json());
830
1105
 
831
1106
  if (r.locked) { showLockScreen(); return; }
@@ -852,143 +1127,111 @@ document.addEventListener("DOMContentLoaded", () => {
852
1127
  });
853
1128
  });
854
1129
 
1130
+ // ── Shared fetch helper ─────────────────────
1131
+ async function apiFetch(path, opts) {
1132
+ try {
1133
+ return await fetch(BASE + path, opts).then(r => r.json());
1134
+ } catch { return null; }
1135
+ }
1136
+
855
1137
  // ── Tunnel management ───────────────────────
856
1138
  let tunnelPollTimer = null;
857
1139
 
858
1140
  async function pollTunnel() {
859
1141
  if (tunnelPollTimer) clearInterval(tunnelPollTimer);
860
- await updateTunnelUI();
861
- // Poll every 3s until URL is found, then slow to 10s
1142
+ const r = await apiFetch("/tunnel");
1143
+ if (r) renderTunnelPanel(r.status, r.url, r.error);
1144
+ // Poll every 3s; slow to 10s once live
862
1145
  tunnelPollTimer = setInterval(async () => {
863
- const state = await updateTunnelUI();
864
- if (state === "connected") {
1146
+ const r = await apiFetch("/tunnel");
1147
+ if (!r) return;
1148
+ renderTunnelPanel(r.status, r.url, r.error);
1149
+ if (r.status === "live") {
865
1150
  clearInterval(tunnelPollTimer);
866
- tunnelPollTimer = setInterval(updateTunnelUI, 10000);
1151
+ tunnelPollTimer = setInterval(async () => {
1152
+ const r2 = await apiFetch("/tunnel");
1153
+ if (r2) renderTunnelPanel(r2.status, r2.url, r2.error);
1154
+ }, 10000);
867
1155
  }
868
1156
  }, 3000);
869
1157
  }
870
1158
 
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";
1159
+ function renderTunnelPanel(status, url, error) {
1160
+ const panel = document.getElementById("tunnel-panel");
1161
+ if (!panel) return;
1162
+ // Hide all state divs
1163
+ panel.querySelectorAll(".tunnel-state").forEach(el => el.style.display = "none");
1164
+ // Map status to CSS class (underscores → hyphens)
1165
+ const cls = (status || "not-started").replace(/_/g, "-");
1166
+ const active = panel.querySelector(".tunnel-state." + cls);
1167
+ if (active) active.style.display = "flex";
1168
+ // Update live URL
1169
+ if (status === "live" && url) {
1170
+ const sseUrl = url.startsWith("http") ? url + "/sse" : url;
1171
+ const link = panel.querySelector(".tunnel-state.live a");
1172
+ if (link) { link.href = sseUrl; link.textContent = sseUrl; }
1173
+ const liveUrlEl = document.getElementById("tunnel-live-url");
1174
+ if (liveUrlEl) { liveUrlEl.href = sseUrl; liveUrlEl.textContent = sseUrl; }
1175
+ }
1176
+ // Close MCP setup panel when not live
1177
+ if (status !== "live") {
937
1178
  document.getElementById("mcp-setup-panel").classList.remove("open");
938
- return "error";
1179
+ const mcpBtn = document.getElementById("btn-mcp-setup");
1180
+ if (mcpBtn) mcpBtn.classList.remove("open");
939
1181
  }
940
1182
  }
941
1183
 
942
1184
  async function toggleTunnel(action) {
943
- const togBtn = document.getElementById("btn-tunnel-toggle");
944
- togBtn.disabled = true; togBtn.textContent = "…";
945
1185
  try {
946
1186
  await fetch(BASE + "/tunnel", {
947
1187
  method: "POST",
948
1188
  headers: { "Content-Type": "application/json" },
949
1189
  body: JSON.stringify({ action })
950
1190
  });
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
- }
1191
+ // Re-poll after a beat
1192
+ setTimeout(pollTunnel, action === "start" ? 2000 : 500);
1193
+ } catch {}
1194
+ }
1195
+
1196
+ function runTunnelSetup() {
1197
+ alert("Run in your terminal:\\n\\n clauth tunnel setup\\n\\nThis will guide you through Cloudflare authentication and tunnel creation.");
956
1198
  }
957
1199
 
958
1200
  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";
1201
+ const liveState = document.querySelector("#tunnel-panel .tunnel-state.live");
1202
+ const btn = liveState ? liveState.querySelector(".btn-check") : null;
1203
+ const labelEl = liveState ? liveState.querySelector(".tunnel-label") : null;
1204
+ if (btn) { btn.disabled = true; btn.textContent = "Testing…"; }
964
1205
 
965
1206
  try {
966
1207
  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");
1208
+ if (labelEl) {
1209
+ if (r.ok) {
1210
+ labelEl.innerHTML = '<strong>claude.ai MCP</strong> — <span style="color:#4ade80">PASS</span> ' +
1211
+ r.latencyMs + 'ms · SSE ' + (r.sseReachable ? 'reachable' : 'unreachable') +
1212
+ ' · <span class="tunnel-url"><a href="' + r.sseUrl + '" target="_blank">' + r.sseUrl + '</a></span>';
1213
+ } else {
1214
+ labelEl.innerHTML = '<strong>claude.ai MCP</strong> — <span style="color:#f87171">FAIL</span> ' + (r.reason || "unknown error");
1215
+ }
975
1216
  }
976
1217
  } catch (e) {
977
- dot.className = "tunnel-dot err";
978
- label.innerHTML = '<strong>claude.ai MCP</strong> — <span style="color:#f87171">FAIL</span> ' + e.message;
1218
+ if (labelEl) labelEl.innerHTML = '<strong>claude.ai MCP</strong> — <span style="color:#f87171">FAIL</span> ' + e.message;
979
1219
  } finally {
980
- btn.disabled = false; btn.textContent = "Test";
1220
+ if (btn) { btn.disabled = false; btn.textContent = "Test"; }
981
1221
  }
982
1222
  }
983
1223
 
984
1224
  function openClaude() {
985
1225
  // Copy the SSE URL to clipboard and open claude.ai settings
986
1226
  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);
1227
+ const sseUrl = t.sseUrl || (t.url && t.url.startsWith("http") ? t.url + "/sse" : null);
1228
+ if (sseUrl) {
1229
+ navigator.clipboard.writeText(sseUrl).then(() => {
1230
+ const btn = document.querySelector("#tunnel-panel .tunnel-state.live .btn-claude");
1231
+ if (btn) {
1232
+ btn.textContent = "SSE URL copied!";
1233
+ setTimeout(() => { btn.textContent = "Connect claude.ai"; }, 2000);
1234
+ }
992
1235
  }).catch(() => {});
993
1236
  window.open("https://claude.ai/settings/integrations", "_blank");
994
1237
  }
@@ -997,7 +1240,10 @@ function openClaude() {
997
1240
 
998
1241
  async function toggleMcpSetup() {
999
1242
  const panel = document.getElementById("mcp-setup-panel");
1243
+ const btn = document.getElementById("btn-mcp-setup");
1000
1244
  const isOpen = panel.classList.toggle("open");
1245
+ btn.classList.toggle("open", isOpen);
1246
+ btn.textContent = isOpen ? "Close MCP" : "Setup MCP";
1001
1247
  if (isOpen) {
1002
1248
  try {
1003
1249
  const m = await fetch(BASE + "/mcp-setup").then(r => r.json());
@@ -1074,7 +1320,10 @@ function readBody(req) {
1074
1320
  }
1075
1321
 
1076
1322
  // ── Server logic (shared by foreground + daemon) ─────────────
1077
- function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1323
+ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null) {
1324
+ // tunnelHostname may be updated at runtime (fetched from DB after unlock)
1325
+ let tunnelHostname = tunnelHostnameInit;
1326
+
1078
1327
  // Ensure Windows system tools are reachable (bash shells may lack these on PATH)
1079
1328
  if (os.platform() === "win32") {
1080
1329
  const sys32 = "C:\\Windows\\System32";
@@ -1106,6 +1355,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1106
1355
  get password() { return this._pw; },
1107
1356
  set password(v) { this._pw = v; },
1108
1357
  get machineHash() { return machineHash; },
1358
+ get tunnelUrl() { return tunnelUrl; },
1109
1359
  whitelist,
1110
1360
  failCount,
1111
1361
  MAX_FAILS,
@@ -1120,6 +1370,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1120
1370
  let tunnelProc = null;
1121
1371
  let tunnelUrl = null;
1122
1372
  let tunnelError = null;
1373
+ let tunnelStatus = "not_started"; // "not_started" | "not_configured" | "starting" | "live" | "error" | "missing_cloudflared"
1123
1374
 
1124
1375
  // ── OAuth provider (self-contained for claude.ai MCP) ──────
1125
1376
  const oauthClients = new Map(); // client_id → { client_secret, redirect_uris, client_name }
@@ -1169,6 +1420,28 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1169
1420
  }
1170
1421
  }
1171
1422
 
1423
+ // If binary is still the default name, verify it's actually on PATH
1424
+ if (cfBin === "cloudflared") {
1425
+ try {
1426
+ const { execSync } = await import("child_process");
1427
+ execSync("cloudflared --version", { stdio: "ignore" });
1428
+ } catch {
1429
+ tunnelError = "cloudflared is not installed or not on PATH.";
1430
+ tunnelStatus = "missing_cloudflared";
1431
+ const installMsg = [
1432
+ "",
1433
+ " \u2717 cloudflared not found.",
1434
+ " Download: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
1435
+ " Then run: clauth tunnel setup",
1436
+ "",
1437
+ ].join("\n");
1438
+ console.error(installMsg);
1439
+ try { fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${tunnelError}\n`); } catch {}
1440
+ tunnelProc = null;
1441
+ return;
1442
+ }
1443
+ }
1444
+
1172
1445
  // Named tunnel (fixed subdomain) or quick tunnel (random URL)
1173
1446
  let args;
1174
1447
  if (tunnelHostname) {
@@ -1196,6 +1469,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1196
1469
  const match = stderrBuf.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
1197
1470
  if (match) {
1198
1471
  tunnelUrl = match[0];
1472
+ tunnelStatus = "live";
1199
1473
  const logLine = `[${new Date().toISOString()}] Tunnel started: ${tunnelUrl}\n`;
1200
1474
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
1201
1475
  }
@@ -1206,6 +1480,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1206
1480
  tunnelError = err.code === "ENOENT"
1207
1481
  ? "cloudflared not found — install from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
1208
1482
  : err.message;
1483
+ tunnelStatus = "error";
1209
1484
  tunnelProc = null;
1210
1485
  const logLine = `[${new Date().toISOString()}] Tunnel error: ${tunnelError}\n`;
1211
1486
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
@@ -1215,6 +1490,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1215
1490
  if (tunnelProc === proc) {
1216
1491
  tunnelProc = null;
1217
1492
  if (!tunnelError) tunnelError = `Tunnel exited with code ${code}`;
1493
+ tunnelStatus = "error";
1218
1494
  const logLine = `[${new Date().toISOString()}] Tunnel exited: code ${code}\n`;
1219
1495
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
1220
1496
  }
@@ -1224,12 +1500,14 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1224
1500
  await new Promise(r => setTimeout(r, 4000));
1225
1501
 
1226
1502
  if (tunnelHostname && tunnelProc) {
1503
+ tunnelStatus = "live";
1227
1504
  const logLine = `[${new Date().toISOString()}] Named tunnel started: ${tunnelUrl}\n`;
1228
1505
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
1229
1506
  }
1230
1507
 
1231
1508
  } catch (err) {
1232
1509
  tunnelError = err.message;
1510
+ tunnelStatus = "error";
1233
1511
  tunnelProc = null;
1234
1512
  }
1235
1513
  }
@@ -1240,6 +1518,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1240
1518
  tunnelProc = null;
1241
1519
  tunnelUrl = null;
1242
1520
  tunnelError = null;
1521
+ tunnelStatus = "not_started";
1243
1522
  const logLine = `[${new Date().toISOString()}] Tunnel stopped\n`;
1244
1523
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
1245
1524
  }
@@ -1308,6 +1587,108 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1308
1587
  fetchBuildStatus();
1309
1588
  setInterval(fetchBuildStatus, 15000);
1310
1589
 
1590
+ applyPendingMigrations().then(result => {
1591
+ lastMigrationResult = result;
1592
+ if (result.applied?.length > 0) {
1593
+ const log = `[${new Date().toISOString()}] Migrations applied: ${result.applied.map(m => m.name).join(", ")}\n`;
1594
+ try { fs.appendFileSync(LOG_FILE, log); } catch {}
1595
+ }
1596
+ }).catch(() => {});
1597
+
1598
+ // ── Tunnel config (fetched from DB after unlock) ────────────
1599
+ async function fetchTunnelConfig() {
1600
+ try {
1601
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
1602
+ const sbKey = api.getAnonKey();
1603
+ if (!sbUrl || !sbKey) return null;
1604
+
1605
+ const r = await fetch(
1606
+ `${sbUrl}/rest/v1/clauth_config?key=eq.tunnel_hostname&select=value`,
1607
+ {
1608
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` },
1609
+ signal: AbortSignal.timeout(5000),
1610
+ }
1611
+ );
1612
+ if (!r.ok) return null;
1613
+ const rows = await r.json();
1614
+ if (rows.length > 0 && rows[0].value && rows[0].value !== "null") {
1615
+ return typeof rows[0].value === "string" ? JSON.parse(rows[0].value) : rows[0].value;
1616
+ }
1617
+ return null;
1618
+ } catch {
1619
+ return null;
1620
+ }
1621
+ }
1622
+
1623
+ // ── Auto-migration ────────────────────────────────────────────
1624
+ let pendingBreakingMigrations = [];
1625
+ let lastMigrationResult = null;
1626
+
1627
+ async function applyPendingMigrations() {
1628
+ try {
1629
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
1630
+ const sbKey = api.getAnonKey();
1631
+ if (!sbUrl || !sbKey) return { applied: [], errors: [] };
1632
+
1633
+ // Read current schema version (may not exist yet)
1634
+ let currentVersion = 0;
1635
+ try {
1636
+ const r = await fetch(
1637
+ `${sbUrl}/rest/v1/clauth_config?key=eq.schema_version&select=value`,
1638
+ { headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) }
1639
+ );
1640
+ if (r.ok) {
1641
+ const rows = await r.json();
1642
+ if (rows.length > 0) currentVersion = Number(rows[0].value) || 0;
1643
+ }
1644
+ } catch {}
1645
+
1646
+ if (currentVersion >= CURRENT_SCHEMA_VERSION) return { applied: [], errors: [], currentVersion };
1647
+
1648
+ const applied = [];
1649
+ const errors = [];
1650
+
1651
+ for (const m of MIGRATIONS) {
1652
+ if (m.version <= currentVersion || !m.sql) continue;
1653
+ if (m.type === "breaking") {
1654
+ // Queue for UI confirmation — do not auto-apply
1655
+ pendingBreakingMigrations.push(m);
1656
+ continue;
1657
+ }
1658
+ try {
1659
+ // Apply via Edge Function proxy (anon key can't do DDL directly)
1660
+ const r = await fetch(`${(api.getBaseUrl() || "")}/run-migration`, {
1661
+ method: "POST",
1662
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json" },
1663
+ body: JSON.stringify({ sql: m.sql, version: m.version }),
1664
+ signal: AbortSignal.timeout(15000),
1665
+ });
1666
+ if (r.ok) {
1667
+ applied.push(m);
1668
+ currentVersion = m.version;
1669
+ // Update schema_version in clauth_config
1670
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
1671
+ method: "POST",
1672
+ headers: {
1673
+ apikey: sbKey, Authorization: `Bearer ${sbKey}`,
1674
+ "Content-Type": "application/json", Prefer: "resolution=merge-duplicates",
1675
+ },
1676
+ body: JSON.stringify({ key: "schema_version", value: m.version }),
1677
+ });
1678
+ } else {
1679
+ errors.push({ migration: m.name, error: await r.text() });
1680
+ }
1681
+ } catch (e) {
1682
+ errors.push({ migration: m.name, error: e.message });
1683
+ }
1684
+ }
1685
+
1686
+ return { applied, errors, currentVersion };
1687
+ } catch (e) {
1688
+ return { applied: [], errors: [{ migration: "init", error: e.message }] };
1689
+ }
1690
+ }
1691
+
1311
1692
  const server = http.createServer(async (req, res) => {
1312
1693
  const remote = req.socket.remoteAddress;
1313
1694
  const isLocal = remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
@@ -1667,17 +2048,22 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1667
2048
  failures: failCount,
1668
2049
  failures_remaining: MAX_FAILS - failCount,
1669
2050
  services: whitelist || "all",
1670
- port
2051
+ port,
2052
+ tunnel_status: tunnelStatus,
2053
+ tunnel_url: tunnelUrl || null,
2054
+ app_version: VERSION,
2055
+ schema_version: CURRENT_SCHEMA_VERSION,
1671
2056
  });
1672
2057
  }
1673
2058
 
1674
2059
  // GET /tunnel — tunnel status (for dashboard polling)
1675
2060
  if (method === "GET" && reqPath === "/tunnel") {
1676
2061
  return ok(res, {
2062
+ status: tunnelStatus,
1677
2063
  running: !!tunnelProc,
1678
- url: tunnelUrl,
2064
+ url: tunnelUrl || null,
1679
2065
  sseUrl: tunnelUrl && tunnelUrl.startsWith("http") ? `${tunnelUrl}/sse` : null,
1680
- error: tunnelError,
2066
+ error: tunnelError || null,
1681
2067
  });
1682
2068
  }
1683
2069
 
@@ -1686,6 +2072,15 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1686
2072
  return ok(res, buildStatus);
1687
2073
  }
1688
2074
 
2075
+ // GET /migrations — migration registry and last run result
2076
+ if (method === "GET" && reqPath === "/migrations") {
2077
+ return ok(res, {
2078
+ schema_version: CURRENT_SCHEMA_VERSION,
2079
+ last_result: lastMigrationResult,
2080
+ pending_breaking: pendingBreakingMigrations,
2081
+ });
2082
+ }
2083
+
1689
2084
  // GET /mcp-setup — OAuth credentials for claude.ai MCP setup (localhost only)
1690
2085
  if (method === "GET" && reqPath === "/mcp-setup") {
1691
2086
  return ok(res, {
@@ -1695,7 +2090,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1695
2090
  });
1696
2091
  }
1697
2092
 
1698
- // POST /tunnel — start or stop tunnel manually
2093
+ // POST /tunnel — start or stop tunnel manually (action in body)
1699
2094
  if (method === "POST" && reqPath === "/tunnel") {
1700
2095
  if (lockedGuard(res)) return;
1701
2096
  let body;
@@ -1705,11 +2100,27 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1705
2100
  }
1706
2101
  if (body.action === "stop") {
1707
2102
  stopTunnel();
1708
- return ok(res, { ok: true, running: false });
2103
+ return ok(res, { status: tunnelStatus, running: false });
1709
2104
  }
1710
2105
  // start
1711
2106
  await startTunnel();
1712
- return ok(res, { ok: true, running: !!tunnelProc, url: tunnelUrl, error: tunnelError });
2107
+ return ok(res, { status: tunnelStatus, running: !!tunnelProc, url: tunnelUrl, error: tunnelError });
2108
+ }
2109
+
2110
+ // POST /tunnel/start — explicit start endpoint
2111
+ if (method === "POST" && reqPath === "/tunnel/start") {
2112
+ if (lockedGuard(res)) return;
2113
+ if (tunnelProc) return ok(res, { status: tunnelStatus, message: "already running" });
2114
+ tunnelStatus = "starting";
2115
+ startTunnel().catch(() => {});
2116
+ return ok(res, { status: "starting" });
2117
+ }
2118
+
2119
+ // POST /tunnel/stop — explicit stop endpoint
2120
+ if (method === "POST" && reqPath === "/tunnel/stop") {
2121
+ if (lockedGuard(res)) return;
2122
+ stopTunnel();
2123
+ return ok(res, { status: tunnelStatus });
1713
2124
  }
1714
2125
 
1715
2126
  // GET /shutdown (for daemon stop)
@@ -1756,12 +2167,14 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1756
2167
  }
1757
2168
  }
1758
2169
 
1759
- // GET /status
2170
+ // GET /status?project=xxx
1760
2171
  if (method === "GET" && reqPath === "/status") {
1761
2172
  if (lockedGuard(res)) return;
1762
2173
  try {
2174
+ const parsedUrl = new URL(req.url, `http://127.0.0.1:${port}`);
2175
+ const projectFilter = parsedUrl.searchParams.get("project") || undefined;
1763
2176
  const { token, timestamp } = deriveToken(password, machineHash);
1764
- const result = await api.status(password, machineHash, token, timestamp);
2177
+ const result = await api.status(password, machineHash, token, timestamp, projectFilter);
1765
2178
  if (result.error) return strike(res, 502, result.error);
1766
2179
  if (whitelist) {
1767
2180
  result.services = (result.services || []).filter(
@@ -1815,8 +2228,32 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
1815
2228
  password = pw; // unlock — store in process memory only
1816
2229
  const logLine = `[${new Date().toISOString()}] Vault unlocked\n`;
1817
2230
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
1818
- // Auto-start Cloudflare Tunnel for claude.ai MCP
1819
- startTunnel().catch(() => {});
2231
+ // Auto-start tunnel: --tunnel flag takes priority, otherwise fetch from DB
2232
+ if (!tunnelHostname) {
2233
+ fetchTunnelConfig().then(configured => {
2234
+ if (configured) {
2235
+ tunnelHostname = configured;
2236
+ tunnelStatus = "starting";
2237
+ startTunnel().catch(() => {});
2238
+ } else {
2239
+ // No tunnel configured in DB and no --tunnel flag
2240
+ tunnelStatus = "not_configured";
2241
+ const msg = [
2242
+ `[${new Date().toISOString()}] No tunnel configured.`,
2243
+ " claude.ai web integration is inactive.",
2244
+ " To enable: run 'clauth tunnel setup'",
2245
+ ].join("\n");
2246
+ try { fs.appendFileSync(LOG_FILE, msg + "\n"); } catch {}
2247
+ console.log("\n ⚠ No tunnel configured — claude.ai web integration inactive.");
2248
+ console.log(" Run: clauth tunnel setup\n");
2249
+ }
2250
+ }).catch(() => {
2251
+ tunnelStatus = "error";
2252
+ });
2253
+ } else {
2254
+ tunnelStatus = "starting";
2255
+ startTunnel().catch(() => {});
2256
+ }
1820
2257
  return ok(res, { ok: true, locked: false });
1821
2258
  } catch {
1822
2259
  // Wrong password — not a lockout strike, just a UI auth attempt
@@ -2014,7 +2451,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
2014
2451
  return res.end(JSON.stringify({ error: "Invalid JSON body" }));
2015
2452
  }
2016
2453
 
2017
- const { name, label, key_type, description } = body;
2454
+ const { name, label, key_type, description, project } = body;
2018
2455
  if (!name || typeof name !== "string" || !name.trim()) {
2019
2456
  res.writeHead(400, { "Content-Type": "application/json", ...CORS });
2020
2457
  return res.end(JSON.stringify({ error: "name is required" }));
@@ -2028,7 +2465,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
2028
2465
 
2029
2466
  try {
2030
2467
  const { token, timestamp } = deriveToken(password, machineHash);
2031
- const result = await api.addService(password, machineHash, token, timestamp, name.trim().toLowerCase(), label || name.trim(), type, description || "");
2468
+ const result = await api.addService(password, machineHash, token, timestamp, name.trim().toLowerCase(), label || name.trim(), type, description || "", project || undefined);
2032
2469
  if (result.error) return strike(res, 502, result.error);
2033
2470
  return ok(res, { ok: true, service: name.trim().toLowerCase() });
2034
2471
  } catch (err) {
@@ -2036,6 +2473,42 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
2036
2473
  }
2037
2474
  }
2038
2475
 
2476
+ // POST /update-service — update service metadata (project, label, description)
2477
+ if (method === "POST" && reqPath === "/update-service") {
2478
+ if (lockedGuard(res)) return;
2479
+
2480
+ let body;
2481
+ try { body = await readBody(req); } catch {
2482
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
2483
+ return res.end(JSON.stringify({ error: "Invalid JSON body" }));
2484
+ }
2485
+
2486
+ const { service, project, label, description } = body;
2487
+ if (!service || typeof service !== "string") {
2488
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
2489
+ return res.end(JSON.stringify({ error: "service is required" }));
2490
+ }
2491
+
2492
+ const updates = {};
2493
+ if (project !== undefined) updates.project = project;
2494
+ if (label !== undefined) updates.label = label;
2495
+ if (description !== undefined) updates.description = description;
2496
+
2497
+ if (Object.keys(updates).length === 0) {
2498
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
2499
+ return res.end(JSON.stringify({ error: "At least one field to update is required (project, label, description)" }));
2500
+ }
2501
+
2502
+ try {
2503
+ const { token, timestamp } = deriveToken(password, machineHash);
2504
+ const result = await api.updateService(password, machineHash, token, timestamp, service.toLowerCase(), updates);
2505
+ if (result.error) return strike(res, 502, result.error);
2506
+ return ok(res, { ok: true, service: service.toLowerCase(), ...updates });
2507
+ } catch (err) {
2508
+ return strike(res, 502, err.message);
2509
+ }
2510
+ }
2511
+
2039
2512
  // Unknown route — don't count browser/MCP noise as auth failures
2040
2513
  // Don't count browser noise, MCP discovery probes, or OAuth probes as auth failures
2041
2514
  const isBenign = reqPath.startsWith("/.well-known/") || [
@@ -2412,6 +2885,24 @@ const MCP_TOOLS = [
2412
2885
  additionalProperties: false
2413
2886
  }
2414
2887
  },
2888
+ {
2889
+ name: "clauth_set_project",
2890
+ description: "Set or clear the project scope on a service. Pass empty string to clear.",
2891
+ inputSchema: {
2892
+ type: "object",
2893
+ properties: {
2894
+ service: { type: "string", description: "Service name (e.g. gmail, github)" },
2895
+ project: { type: "string", description: "Project name to assign (empty string to clear)" }
2896
+ },
2897
+ required: ["service", "project"],
2898
+ additionalProperties: false
2899
+ }
2900
+ },
2901
+ {
2902
+ name: "clauth_self_check",
2903
+ description: "Test whether the clauth MCP connector is reachable via the Cloudflare tunnel. Returns connectivity status and tunnel URL.",
2904
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
2905
+ },
2415
2906
  ];
2416
2907
 
2417
2908
  function writeTempSecret(service, value) {
@@ -2479,13 +2970,14 @@ async function handleMcpTool(vault, name, args) {
2479
2970
  if (vault.whitelist) {
2480
2971
  services = services.filter(s => vault.whitelist.includes(s.name.toLowerCase()));
2481
2972
  }
2482
- const lines = ["SERVICE TYPE STATUS KEY LAST RETRIEVED",
2483
- "--- ---- ------ --- --------------"];
2973
+ const lines = ["SERVICE TYPE PROJECT STATUS KEY LAST RETRIEVED",
2974
+ "------- ---- ------- ------ --- --------------"];
2484
2975
  for (const s of services) {
2485
2976
  const status = s.enabled ? "ACTIVE" : (s.vault_key ? "SUSPENDED" : "NO KEY");
2486
2977
  const hasKey = s.vault_key ? "yes" : "—";
2487
2978
  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}`);
2979
+ const proj = (s.project || "").padEnd(22);
2980
+ lines.push(`${s.name.padEnd(24)} ${(s.key_type || "").padEnd(12)} ${proj} ${status.padEnd(12)} ${hasKey.padEnd(6)} ${lastGet}`);
2489
2981
  }
2490
2982
  return mcpResult(lines.join("\n"));
2491
2983
  } catch (err) {
@@ -2652,6 +3144,57 @@ async function handleMcpTool(vault, name, args) {
2652
3144
  }
2653
3145
  }
2654
3146
 
3147
+ case "clauth_set_project": {
3148
+ if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
3149
+ const service = (args.service || "").toLowerCase();
3150
+ const project = args.project;
3151
+ if (!service) return mcpError("service is required");
3152
+ if (project === undefined) return mcpError("project is required (empty string to clear)");
3153
+ try {
3154
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
3155
+ const result = await api.updateService(vault.password, vault.machineHash, token, timestamp, service, { project: project || "" });
3156
+ if (result.error) return mcpError(result.error);
3157
+ return mcpResult(project ? `${service} → project: ${project}` : `${service} → project cleared`);
3158
+ } catch (err) {
3159
+ return mcpError(err.message);
3160
+ }
3161
+ }
3162
+
3163
+ case "clauth_self_check": {
3164
+ // Test connectivity via the Cloudflare tunnel and/or localhost daemon
3165
+ const tunnelUrl = vault.tunnelUrl;
3166
+ const results = [];
3167
+
3168
+ // Check localhost daemon
3169
+ try {
3170
+ const r = await fetch("http://127.0.0.1:52437/ping", { signal: AbortSignal.timeout(3000) });
3171
+ const data = await r.json();
3172
+ results.push(`Local daemon: PASS (${data.locked ? "locked" : "unlocked"}, failures: ${data.failures ?? 0})`);
3173
+ } catch (err) {
3174
+ results.push(`Local daemon: FAIL — http://127.0.0.1:52437 not reachable (${err.message})`);
3175
+ }
3176
+
3177
+ // Check tunnel
3178
+ if (tunnelUrl) {
3179
+ try {
3180
+ const r = await fetch(`${tunnelUrl}/ping`, { signal: AbortSignal.timeout(5000) });
3181
+ const data = await r.json();
3182
+ results.push(`Tunnel: PASS — ${tunnelUrl} reachable (${data.locked ? "locked" : "unlocked"})`);
3183
+ results.push(`claude.ai SSE endpoint: ${tunnelUrl}/sse`);
3184
+ results.push(`claude.ai MCP endpoint: ${tunnelUrl}/mcp`);
3185
+ } catch (err) {
3186
+ results.push(`Tunnel: FAIL — ${tunnelUrl} not reachable (${err.message})`);
3187
+ results.push("claude.ai MCP connector will not work until tunnel is restored.");
3188
+ results.push("Fix: clauth serve start --tunnel clauth.prtrust.fund");
3189
+ }
3190
+ } else {
3191
+ results.push("Tunnel: NOT RUNNING — claude.ai connector requires the tunnel.");
3192
+ results.push("Start with: clauth serve start --tunnel clauth.prtrust.fund");
3193
+ }
3194
+
3195
+ return mcpResult(results.join("\n"));
3196
+ }
3197
+
2655
3198
  default:
2656
3199
  return mcpError(`Unknown tool: ${name}`);
2657
3200
  }
@@ -2674,6 +3217,7 @@ function createMcpServer(initPassword, whitelist) {
2674
3217
  const vault = {
2675
3218
  password: initPassword || null,
2676
3219
  get machineHash() { return ensureMachineHash(); },
3220
+ get tunnelUrl() { return null; }, // stdio server has no tunnel — self_check will try localhost daemon
2677
3221
  whitelist,
2678
3222
  failCount: 0,
2679
3223
  MAX_FAILS: 10,
@@ -2765,61 +3309,154 @@ async function actionMcp(opts) {
2765
3309
  createMcpServer(password, whitelist);
2766
3310
  }
2767
3311
 
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";
3312
+ // ── Cross-platform auto-start install / uninstall ────────────
3313
+ // Windows: DPAPI + Scheduled Task
3314
+ // macOS: Keychain + LaunchAgent
3315
+ // Linux: libsecret/encrypted file + systemd user service
3316
+
3317
+ const TASK_NAME = "ClauthAutostart";
3318
+
3319
+ function getAutostartDir() {
3320
+ const platform = os.platform();
3321
+ if (platform === "win32") {
3322
+ return path.join(os.homedir(), "AppData", "Roaming", "clauth");
3323
+ } else if (platform === "darwin") {
3324
+ return path.join(os.homedir(), "Library", "LaunchAgents");
3325
+ } else {
3326
+ return path.join(os.homedir(), ".config", "systemd", "user");
3327
+ }
3328
+ }
3329
+
3330
+ function getBootKeyPath() {
3331
+ if (os.platform() === "win32") {
3332
+ return path.join(os.homedir(), "AppData", "Roaming", "clauth", "boot.key");
3333
+ } else if (os.platform() === "darwin") {
3334
+ return null; // stored in Keychain, not a file
3335
+ } else {
3336
+ return path.join(os.homedir(), ".config", "clauth", "boot.key");
3337
+ }
3338
+ }
2773
3339
 
2774
3340
  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);
3341
+ const platform = os.platform();
3342
+ const { execSync } = await import("child_process");
3343
+ const config = new Conf(getConfOptions());
3344
+
3345
+ const tunnelHostname = opts.tunnel || null;
3346
+
3347
+ // Persist tunnel hostname in config if provided
3348
+ if (tunnelHostname) {
3349
+ config.set("tunnel_hostname", tunnelHostname);
3350
+ console.log(chalk.gray(`\n Tunnel hostname saved: ${tunnelHostname}`));
2778
3351
  }
2779
3352
 
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
- }]);
3353
+ // Check for cloudflared if tunnel is requested
3354
+ if (tunnelHostname) {
3355
+ try {
3356
+ execSync("cloudflared --version", { encoding: "utf8", stdio: "pipe" });
3357
+ } catch {
3358
+ console.log(chalk.yellow("\n cloudflared not found — required for tunnel support"));
3359
+ console.log(chalk.gray(" Install: winget install Cloudflare.cloudflared"));
3360
+ try {
3361
+ const { installCloudflared } = await import("./doctor.js");
3362
+ await installCloudflared();
3363
+ } catch {
3364
+ console.log(chalk.yellow(" Continuing without tunnel — install cloudflared manually later"));
3365
+ }
3366
+ }
3367
+ }
2785
3368
 
2786
- fs.mkdirSync(AUTOSTART_DIR, { recursive: true });
3369
+ // Two modes:
3370
+ // 1. Password provided via -p flag → encrypt + install (non-interactive)
3371
+ // 2. No password → install watchdog that starts daemon in locked mode
3372
+ // The browser dashboard opens, user enters password there.
3373
+ // For passwordless restart, user can later run: clauth serve seal
3374
+ const pw = opts.pw || null;
2787
3375
 
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);
3376
+ if (platform === "win32") {
3377
+ await installWindows(pw, tunnelHostname, execSync);
3378
+ } else if (platform === "darwin") {
3379
+ await installMacOS(pw, tunnelHostname, execSync);
3380
+ } else {
3381
+ await installLinux(pw, tunnelHostname, execSync);
2801
3382
  }
3383
+ }
2802
3384
 
2803
- // Write PowerShell autostart script decrypts boot.key and pipes to clauth serve start
3385
+ // ── Windows: DPAPI + Scheduled Task ────────────────────────
3386
+ async function installWindows(pw, tunnelHostname, execSync) {
3387
+ const autostartDir = path.join(os.homedir(), "AppData", "Roaming", "clauth");
3388
+ const bootKeyPath = path.join(autostartDir, "boot.key");
3389
+ const psScriptPath = path.join(autostartDir, "autostart.ps1");
2804
3390
  const cliEntry = path.resolve(__dirname, "../index.js").replace(/\\/g, "\\\\");
2805
3391
  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");
3392
+ const bootKey = bootKeyPath.replace(/\\/g, "\\\\");
3393
+ const tunnelArg = tunnelHostname ? ` --tunnel ${tunnelHostname}` : "";
3394
+
3395
+ fs.mkdirSync(autostartDir, { recursive: true });
3396
+
3397
+ if (pw) {
3398
+ // ── Sealed mode: DPAPI-encrypt password for fully unattended restart ──
3399
+ const spinner = ora("Encrypting password with Windows DPAPI...").start();
3400
+ try {
3401
+ const pwEscaped = pw.replace(/'/g, "''");
3402
+ const psExpr = `[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes('${pwEscaped}'),$null,'CurrentUser'))`;
3403
+ const encrypted = execSync(`powershell -NoProfile -Command "${psExpr}"`, { encoding: "utf8" }).trim();
3404
+ fs.writeFileSync(bootKeyPath, encrypted, "utf8");
3405
+ spinner.succeed(chalk.green("Password sealed via DPAPI → boot.key"));
3406
+ } catch (err) {
3407
+ spinner.fail(chalk.red(`DPAPI encryption failed: ${err.message}`));
3408
+ process.exit(1);
3409
+ }
3410
+
3411
+ // Watchdog script: decrypts password, starts daemon unlocked, monitors for crash
3412
+ const psScript = [
3413
+ "# clauth autostart + watchdog (sealed mode)",
3414
+ "# Starts unlocked and restarts on crash every 15s",
3415
+ "",
3416
+ `$enc = (Get-Content '${bootKey}' -Raw).Trim()`,
3417
+ `$pw = [Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($enc),$null,'CurrentUser'))`,
3418
+ "",
3419
+ "while ($true) {",
3420
+ " try {",
3421
+ " $ping = Invoke-RestMethod -Uri 'http://127.0.0.1:52437/ping' -TimeoutSec 3 -ErrorAction Stop",
3422
+ " } catch {",
3423
+ ` Start-Process '${nodeExe}' -ArgumentList "'${cliEntry}' serve start -p $pw${tunnelArg}" -WindowStyle Hidden`,
3424
+ " Start-Sleep -Seconds 5",
3425
+ " }",
3426
+ " Start-Sleep -Seconds 15",
3427
+ "}",
3428
+ ].join("\n");
3429
+ fs.writeFileSync(psScriptPath, psScript, "utf8");
3430
+ } else {
3431
+ // ── First-run mode: no password yet — start locked, browser opens for setup ──
3432
+ // Watchdog starts the daemon in locked mode; user enters password in the browser dashboard.
3433
+ // After entering password in the browser, user can seal it for future unattended restarts
3434
+ // by running: clauth serve seal
3435
+ const psScript = [
3436
+ "# clauth autostart + watchdog (locked mode — browser password entry)",
3437
+ "# Starts daemon locked, opens browser for password. Restarts on crash every 15s.",
3438
+ "",
3439
+ "while ($true) {",
3440
+ " try {",
3441
+ " $ping = Invoke-RestMethod -Uri 'http://127.0.0.1:52437/ping' -TimeoutSec 3 -ErrorAction Stop",
3442
+ " } catch {",
3443
+ ` Start-Process '${nodeExe}' -ArgumentList "'${cliEntry}' serve start${tunnelArg}" -WindowStyle Hidden`,
3444
+ " Start-Sleep -Seconds 5",
3445
+ " }",
3446
+ " Start-Sleep -Seconds 15",
3447
+ "}",
3448
+ ].join("\n");
3449
+ fs.writeFileSync(psScriptPath, psScript, "utf8");
3450
+ }
2814
3451
 
2815
3452
  // Register Windows Scheduled Task — triggers on user logon
2816
- const spinner2 = ora("Registering Windows Scheduled Task...").start();
3453
+ const mode = pw ? "sealed fully unattended" : "locked — browser password entry";
3454
+ const spinner2 = ora(`Registering Scheduled Task (${mode})...`).start();
2817
3455
  try {
2818
- const { execSync } = await import("child_process");
2819
- const psScriptEsc = PS_SCRIPT_PATH.replace(/\\/g, "\\\\");
3456
+ const psScriptEsc = psScriptPath.replace(/\\/g, "\\\\");
2820
3457
  const args = `-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${psScriptEsc}"`;
2821
3458
  execSync(
2822
- `schtasks /create /f /tn "${TASK_NAME}" /sc onlogon /tr "powershell.exe ${args}"`,
3459
+ `${process.env.SystemRoot || "C:\\\\Windows"}\\\\System32\\\\schtasks.exe /create /f /tn "${TASK_NAME}" /sc onlogon /tr "powershell.exe ${args}"`,
2823
3460
  { encoding: "utf8", stdio: "pipe" }
2824
3461
  );
2825
3462
  spinner2.succeed(chalk.green(`Scheduled Task "${TASK_NAME}" registered`));
@@ -2828,33 +3465,327 @@ async function actionInstall(opts) {
2828
3465
  console.log(chalk.gray(" You can still start manually: clauth serve start"));
2829
3466
  }
2830
3467
 
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"));
3468
+ // Start the daemon now
3469
+ const spinner3 = ora("Starting daemon...").start();
3470
+ try {
3471
+ if (pw) {
3472
+ execSync(`"${nodeExe.replace(/\\\\/g, "\\")}" "${cliEntry.replace(/\\\\/g, "\\")}" serve start -p "${pw}"`, {
3473
+ encoding: "utf8", stdio: "pipe", timeout: 10000,
3474
+ });
3475
+ } else {
3476
+ execSync(`"${nodeExe.replace(/\\\\/g, "\\")}" "${cliEntry.replace(/\\\\/g, "\\")}" serve start`, {
3477
+ encoding: "utf8", stdio: "pipe", timeout: 10000,
3478
+ });
3479
+ }
3480
+ spinner3.succeed(chalk.green("Daemon started"));
3481
+ } catch {
3482
+ spinner3.succeed(chalk.green("Daemon starting..."));
3483
+ }
3484
+
3485
+ // Open browser for first-run password setup (locked mode only)
3486
+ if (!pw) {
3487
+ try { openBrowser("http://127.0.0.1:52437"); } catch {}
3488
+ }
3489
+
3490
+ console.log(chalk.cyan("\n Auto-start installed (Windows):\n"));
3491
+ if (pw) {
3492
+ console.log(chalk.gray(` mode: sealed (DPAPI — fully unattended restart)`));
3493
+ console.log(chalk.gray(` boot.key: ${bootKeyPath}`));
3494
+ } else {
3495
+ console.log(chalk.gray(` mode: locked (enter password in browser on restart)`));
3496
+ console.log(chalk.gray(` browser: http://127.0.0.1:52437`));
3497
+ console.log(chalk.gray(` seal later: clauth serve seal (enables unattended restart)`));
3498
+ }
3499
+ console.log(chalk.gray(` watchdog: ${psScriptPath}`));
3500
+ console.log(chalk.gray(` task: ${TASK_NAME} (restarts every 15s on crash)`));
3501
+ if (tunnelHostname) console.log(chalk.gray(` tunnel: ${tunnelHostname}`));
3502
+ console.log(chalk.green("\n Done. Daemon will auto-start on login and restart on crash.\n"));
2836
3503
  }
2837
3504
 
2838
- async function actionUninstall() {
2839
- if (os.platform() !== "win32") {
2840
- console.log(chalk.red("\n serve uninstall is only supported on Windows\n"));
3505
+ // ── macOS: Keychain + LaunchAgent ──────────────────────────
3506
+ async function installMacOS(pw, tunnelHostname, execSync) {
3507
+ const launchAgentsDir = path.join(os.homedir(), "Library", "LaunchAgents");
3508
+ const plistPath = path.join(launchAgentsDir, "com.lifeai.clauth.plist");
3509
+ const keychainService = "com.lifeai.clauth";
3510
+ const keychainAccount = "clauth-daemon";
3511
+
3512
+ fs.mkdirSync(launchAgentsDir, { recursive: true });
3513
+
3514
+ // Store password in macOS Keychain
3515
+ const spinner = ora("Storing password in macOS Keychain...").start();
3516
+ try {
3517
+ // Delete existing entry if present (ignore errors)
3518
+ try {
3519
+ execSync(
3520
+ `security delete-generic-password -s "${keychainService}" -a "${keychainAccount}"`,
3521
+ { encoding: "utf8", stdio: "pipe" }
3522
+ );
3523
+ } catch {}
3524
+
3525
+ execSync(
3526
+ `security add-generic-password -s "${keychainService}" -a "${keychainAccount}" -w "${pw.replace(/"/g, '\\"')}" -U`,
3527
+ { encoding: "utf8", stdio: "pipe" }
3528
+ );
3529
+ spinner.succeed(chalk.green("Password stored in Keychain"));
3530
+ } catch (err) {
3531
+ spinner.fail(chalk.red(`Keychain storage failed: ${err.message}`));
2841
3532
  process.exit(1);
2842
3533
  }
3534
+
3535
+ // Find the node executable and cli entry
3536
+ const cliEntry = path.resolve(__dirname, "../index.js");
3537
+ const nodeExe = process.execPath;
3538
+
3539
+ // Build shell command that reads from Keychain and starts clauth
3540
+ const tunnelArg = tunnelHostname ? ` --tunnel ${tunnelHostname}` : "";
3541
+ const shellCmd = `PW=$(security find-generic-password -s "${keychainService}" -a "${keychainAccount}" -w) && exec "${nodeExe}" "${cliEntry}" serve start -p "$PW"${tunnelArg}`;
3542
+
3543
+ // Create LaunchAgent plist
3544
+ const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
3545
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3546
+ <plist version="1.0">
3547
+ <dict>
3548
+ <key>Label</key>
3549
+ <string>com.lifeai.clauth</string>
3550
+ <key>ProgramArguments</key>
3551
+ <array>
3552
+ <string>/bin/sh</string>
3553
+ <string>-c</string>
3554
+ <string>${shellCmd.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")}</string>
3555
+ </array>
3556
+ <key>RunAtLoad</key>
3557
+ <true/>
3558
+ <key>KeepAlive</key>
3559
+ <true/>
3560
+ <key>StandardOutPath</key>
3561
+ <string>/tmp/clauth-serve.log</string>
3562
+ <key>StandardErrorPath</key>
3563
+ <string>/tmp/clauth-serve.log</string>
3564
+ </dict>
3565
+ </plist>`;
3566
+
3567
+ fs.writeFileSync(plistPath, plistContent, "utf8");
3568
+
3569
+ // Load the LaunchAgent
3570
+ const spinner2 = ora("Loading LaunchAgent...").start();
3571
+ try {
3572
+ // Unload first in case it's already loaded (ignore errors)
3573
+ try { execSync(`launchctl unload "${plistPath}"`, { stdio: "pipe" }); } catch {}
3574
+ execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
3575
+ spinner2.succeed(chalk.green("LaunchAgent loaded"));
3576
+ } catch (err) {
3577
+ spinner2.fail(chalk.yellow(`LaunchAgent load failed (non-fatal): ${err.message}`));
3578
+ console.log(chalk.gray(" You can still start manually: clauth serve start"));
3579
+ }
3580
+
3581
+ console.log(chalk.cyan("\n Auto-start installed (macOS):\n"));
3582
+ console.log(chalk.gray(` plist: ${plistPath}`));
3583
+ console.log(chalk.gray(` keychain: ${keychainService}`));
3584
+ if (tunnelHostname) console.log(chalk.gray(` tunnel: ${tunnelHostname}`));
3585
+ console.log(chalk.green("\n Daemon will auto-start on login and restart on crash.\n"));
3586
+ }
3587
+
3588
+ // ── Linux: encrypted file + systemd user service ───────────
3589
+ async function installLinux(pw, tunnelHostname, execSync) {
3590
+ const configDir = path.join(os.homedir(), ".config", "clauth");
3591
+ const bootKeyPath = path.join(configDir, "boot.key");
3592
+ const systemdDir = path.join(os.homedir(), ".config", "systemd", "user");
3593
+ const serviceFile = path.join(systemdDir, "clauth.service");
3594
+
3595
+ fs.mkdirSync(configDir, { recursive: true });
3596
+ fs.mkdirSync(systemdDir, { recursive: true });
3597
+
3598
+ // Try to store password using secret-tool (libsecret/GNOME Keyring)
3599
+ let useSecretTool = false;
3600
+ const spinner = ora("Storing password securely...").start();
3601
+
3602
+ try {
3603
+ execSync("which secret-tool", { encoding: "utf8", stdio: "pipe" });
3604
+ execSync(
3605
+ `echo -n "${pw.replace(/"/g, '\\"')}" | secret-tool store --label="clauth daemon password" service clauth account daemon`,
3606
+ { encoding: "utf8", stdio: "pipe" }
3607
+ );
3608
+ useSecretTool = true;
3609
+ spinner.succeed(chalk.green("Password stored via secret-tool (GNOME Keyring)"));
3610
+ } catch {
3611
+ // Fallback: encrypt with openssl and store in file
3612
+ // Uses a key derived from machine-id for basic protection at rest
3613
+ try {
3614
+ const machineId = fs.readFileSync("/etc/machine-id", "utf8").trim();
3615
+ const encrypted = execSync(
3616
+ `echo -n "${pw.replace(/"/g, '\\"')}" | openssl enc -aes-256-cbc -pbkdf2 -iter 100000 -pass pass:"${machineId}" -base64`,
3617
+ { encoding: "utf8", stdio: "pipe" }
3618
+ ).trim();
3619
+ fs.writeFileSync(bootKeyPath, encrypted, { mode: 0o600 });
3620
+ spinner.succeed(chalk.green("Password encrypted -> boot.key (openssl fallback)"));
3621
+ } catch (err) {
3622
+ spinner.fail(chalk.red(`Password storage failed: ${err.message}`));
3623
+ process.exit(1);
3624
+ }
3625
+ }
3626
+
3627
+ // Find the node executable and cli entry
3628
+ const cliEntry = path.resolve(__dirname, "../index.js");
3629
+ const nodeExe = process.execPath;
3630
+
3631
+ // Build the ExecStart command
3632
+ const tunnelArg = tunnelHostname ? ` --tunnel ${tunnelHostname}` : "";
3633
+ let execStart;
3634
+ if (useSecretTool) {
3635
+ // Create a wrapper script that reads from secret-tool
3636
+ const wrapperPath = path.join(configDir, "start.sh");
3637
+ const wrapperContent = [
3638
+ "#!/bin/sh",
3639
+ `PW=$(secret-tool lookup service clauth account daemon)`,
3640
+ `exec "${nodeExe}" "${cliEntry}" serve start -p "$PW"${tunnelArg}`,
3641
+ ].join("\n");
3642
+ fs.writeFileSync(wrapperPath, wrapperContent, { mode: 0o700 });
3643
+ execStart = `/bin/sh ${wrapperPath}`;
3644
+ } else {
3645
+ // Create a wrapper script that decrypts boot.key
3646
+ const wrapperPath = path.join(configDir, "start.sh");
3647
+ const wrapperContent = [
3648
+ "#!/bin/sh",
3649
+ `MACHINE_ID=$(cat /etc/machine-id)`,
3650
+ `PW=$(openssl enc -aes-256-cbc -pbkdf2 -iter 100000 -pass pass:"$MACHINE_ID" -base64 -d < "${bootKeyPath}")`,
3651
+ `exec "${nodeExe}" "${cliEntry}" serve start -p "$PW"${tunnelArg}`,
3652
+ ].join("\n");
3653
+ fs.writeFileSync(wrapperPath, wrapperContent, { mode: 0o700 });
3654
+ execStart = `/bin/sh ${wrapperPath}`;
3655
+ }
3656
+
3657
+ // Create systemd user service
3658
+ const serviceContent = `[Unit]
3659
+ Description=clauth credential daemon
3660
+ After=network-online.target
3661
+ Wants=network-online.target
3662
+
3663
+ [Service]
3664
+ Type=simple
3665
+ ExecStart=${execStart}
3666
+ Restart=always
3667
+ RestartSec=5
3668
+ Environment=NODE_ENV=production
3669
+
3670
+ [Install]
3671
+ WantedBy=default.target
3672
+ `;
3673
+
3674
+ fs.writeFileSync(serviceFile, serviceContent, "utf8");
3675
+
3676
+ // Enable and start the service
3677
+ const spinner2 = ora("Enabling systemd user service...").start();
3678
+ try {
3679
+ execSync("systemctl --user daemon-reload", { stdio: "pipe" });
3680
+ execSync("systemctl --user enable clauth", { stdio: "pipe" });
3681
+ execSync("systemctl --user start clauth", { stdio: "pipe" });
3682
+ spinner2.succeed(chalk.green("systemd user service enabled and started"));
3683
+ } catch (err) {
3684
+ spinner2.fail(chalk.yellow(`systemd setup failed (non-fatal): ${err.message}`));
3685
+ console.log(chalk.gray(" You can still start manually: clauth serve start"));
3686
+ }
3687
+
3688
+ console.log(chalk.cyan("\n Auto-start installed (Linux):\n"));
3689
+ console.log(chalk.gray(` service: ${serviceFile}`));
3690
+ console.log(chalk.gray(` password: ${useSecretTool ? "GNOME Keyring" : bootKeyPath}`));
3691
+ if (tunnelHostname) console.log(chalk.gray(` tunnel: ${tunnelHostname}`));
3692
+ console.log(chalk.green("\n Daemon will auto-start on login and restart on crash.\n"));
3693
+ }
3694
+
3695
+ // ── Cross-platform uninstall ───────────────────────────────
3696
+ async function actionUninstall() {
3697
+ const platform = os.platform();
2843
3698
  const { execSync } = await import("child_process");
2844
3699
 
3700
+ if (platform === "win32") {
3701
+ await uninstallWindows(execSync);
3702
+ } else if (platform === "darwin") {
3703
+ await uninstallMacOS(execSync);
3704
+ } else {
3705
+ await uninstallLinux(execSync);
3706
+ }
3707
+ }
3708
+
3709
+ async function uninstallWindows(execSync) {
3710
+ const autostartDir = path.join(os.homedir(), "AppData", "Roaming", "clauth");
3711
+ const bootKeyPath = path.join(autostartDir, "boot.key");
3712
+ const psScriptPath = path.join(autostartDir, "autostart.ps1");
3713
+
2845
3714
  // Remove scheduled task
2846
3715
  try {
2847
- execSync(`schtasks /delete /f /tn "${TASK_NAME}"`, { encoding: "utf8", stdio: "pipe" });
3716
+ execSync(`${process.env.SystemRoot || "C:\\\\Windows"}\\\\System32\\\\schtasks.exe /delete /f /tn "${TASK_NAME}"`, { encoding: "utf8", stdio: "pipe" });
2848
3717
  console.log(chalk.green(` Removed Scheduled Task: ${TASK_NAME}`));
2849
3718
  } catch { console.log(chalk.gray(` Task not found (already removed): ${TASK_NAME}`)); }
2850
3719
 
2851
3720
  // Remove boot.key and autostart script
2852
- for (const f of [BOOT_KEY_PATH, PS_SCRIPT_PATH]) {
3721
+ for (const f of [bootKeyPath, psScriptPath]) {
2853
3722
  try { fs.unlinkSync(f); console.log(chalk.green(` Deleted: ${f}`)); }
2854
3723
  catch { console.log(chalk.gray(` Not found (skipped): ${f}`)); }
2855
3724
  }
2856
3725
 
2857
- console.log(chalk.cyan("\n Auto-start uninstalled.\n"));
3726
+ console.log(chalk.cyan("\n Auto-start uninstalled (Windows).\n"));
3727
+ }
3728
+
3729
+ async function uninstallMacOS(execSync) {
3730
+ const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", "com.lifeai.clauth.plist");
3731
+ const keychainService = "com.lifeai.clauth";
3732
+ const keychainAccount = "clauth-daemon";
3733
+
3734
+ // Unload LaunchAgent
3735
+ try {
3736
+ execSync(`launchctl unload "${plistPath}"`, { stdio: "pipe" });
3737
+ console.log(chalk.green(" Unloaded LaunchAgent"));
3738
+ } catch { console.log(chalk.gray(" LaunchAgent not loaded (already removed)")); }
3739
+
3740
+ // Remove plist
3741
+ try { fs.unlinkSync(plistPath); console.log(chalk.green(` Deleted: ${plistPath}`)); }
3742
+ catch { console.log(chalk.gray(` Not found (skipped): ${plistPath}`)); }
3743
+
3744
+ // Remove Keychain entry
3745
+ try {
3746
+ execSync(
3747
+ `security delete-generic-password -s "${keychainService}" -a "${keychainAccount}"`,
3748
+ { encoding: "utf8", stdio: "pipe" }
3749
+ );
3750
+ console.log(chalk.green(" Removed Keychain entry"));
3751
+ } catch { console.log(chalk.gray(" Keychain entry not found (already removed)")); }
3752
+
3753
+ console.log(chalk.cyan("\n Auto-start uninstalled (macOS).\n"));
3754
+ }
3755
+
3756
+ async function uninstallLinux(execSync) {
3757
+ const configDir = path.join(os.homedir(), ".config", "clauth");
3758
+ const bootKeyPath = path.join(configDir, "boot.key");
3759
+ const wrapperPath = path.join(configDir, "start.sh");
3760
+ const serviceFile = path.join(os.homedir(), ".config", "systemd", "user", "clauth.service");
3761
+
3762
+ // Stop and disable systemd service
3763
+ try {
3764
+ execSync("systemctl --user stop clauth", { stdio: "pipe" });
3765
+ execSync("systemctl --user disable clauth", { stdio: "pipe" });
3766
+ console.log(chalk.green(" Stopped and disabled systemd service"));
3767
+ } catch { console.log(chalk.gray(" systemd service not running (already removed)")); }
3768
+
3769
+ // Remove service file
3770
+ try { fs.unlinkSync(serviceFile); console.log(chalk.green(` Deleted: ${serviceFile}`)); }
3771
+ catch { console.log(chalk.gray(` Not found (skipped): ${serviceFile}`)); }
3772
+
3773
+ // Reload systemd
3774
+ try { execSync("systemctl --user daemon-reload", { stdio: "pipe" }); } catch {}
3775
+
3776
+ // Remove boot.key and wrapper script
3777
+ for (const f of [bootKeyPath, wrapperPath]) {
3778
+ try { fs.unlinkSync(f); console.log(chalk.green(` Deleted: ${f}`)); }
3779
+ catch { console.log(chalk.gray(` Not found (skipped): ${f}`)); }
3780
+ }
3781
+
3782
+ // Try to remove secret-tool entry
3783
+ try {
3784
+ execSync("secret-tool clear service clauth account daemon", { stdio: "pipe" });
3785
+ console.log(chalk.green(" Removed secret-tool entry"));
3786
+ } catch { /* secret-tool may not be installed */ }
3787
+
3788
+ console.log(chalk.cyan("\n Auto-start uninstalled (Linux).\n"));
2858
3789
  }
2859
3790
 
2860
3791
  // ── Export ────────────────────────────────────────────────────