@lifeaitools/clauth 1.1.0 → 1.3.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.
@@ -58,7 +58,10 @@ ALTER TABLE clauth_config ENABLE ROW LEVEL SECURITY;`,
58
58
  const CURRENT_SCHEMA_VERSION = 3;
59
59
 
60
60
  const PID_FILE = path.join(os.tmpdir(), "clauth-serve.pid");
61
+ const STAGED_PID_FILE = path.join(os.tmpdir(), "clauth-serve-staged.pid");
61
62
  const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
63
+ const LIVE_PORT = 52437;
64
+ const STAGED_PORT = 52438;
62
65
 
63
66
  // ── PID helpers ──────────────────────────────────────────────
64
67
  function readPid() {
@@ -73,6 +76,22 @@ function writePid(pid, port) {
73
76
  fs.writeFileSync(PID_FILE, `${pid}:${port}`, "utf8");
74
77
  }
75
78
 
79
+ function readStagedPid() {
80
+ try {
81
+ const raw = fs.readFileSync(STAGED_PID_FILE, "utf8").trim();
82
+ const [pid, port] = raw.split(":");
83
+ return { pid: parseInt(pid, 10), port: parseInt(port, 10) };
84
+ } catch { return null; }
85
+ }
86
+
87
+ function writeStagedPid(pid, port) {
88
+ fs.writeFileSync(STAGED_PID_FILE, `${pid}:${port}`, "utf8");
89
+ }
90
+
91
+ function removeStagedPid() {
92
+ try { fs.unlinkSync(STAGED_PID_FILE); } catch {}
93
+ }
94
+
76
95
  function removePid() {
77
96
  try { fs.unlinkSync(PID_FILE); } catch {}
78
97
  }
@@ -91,7 +110,7 @@ function openBrowser(url) {
91
110
  }
92
111
 
93
112
  // ── Dashboard HTML ───────────────────────────────────────────
94
- function dashboardHtml(port, whitelist) {
113
+ function dashboardHtml(port, whitelist, isStaged = false) {
95
114
  return `<!DOCTYPE html>
96
115
  <html lang="en">
97
116
  <head>
@@ -280,6 +299,40 @@ function dashboardHtml(port, whitelist) {
280
299
  color: rgba(255,255,255,0.6);
281
300
  font-size: 12px;
282
301
  }
302
+ /* Tunnel setup wizard */
303
+ .wizard-panel{background:#0a1628;border:1px solid #1e3a5f;border-radius:10px;padding:1.5rem;margin-bottom:1.25rem;display:none}
304
+ .wizard-panel.open{display:block}
305
+ .wizard-header{display:flex;align-items:center;gap:10px;margin-bottom:1.25rem}
306
+ .wizard-title{font-size:1rem;font-weight:600;color:#f8fafc;flex:1}
307
+ .wizard-steps{display:flex;gap:6px;margin-bottom:1.5rem;flex-wrap:wrap}
308
+ .wstep{padding:4px 10px;border-radius:20px;font-size:.72rem;font-weight:600;border:1px solid #1e3a5f;color:#475569;background:#0f172a}
309
+ .wstep.active{border-color:#3b82f6;color:#60a5fa;background:rgba(59,130,246,.1)}
310
+ .wstep.done{border-color:#166534;color:#4ade80;background:rgba(74,222,128,.08)}
311
+ .wizard-body{min-height:80px}
312
+ .wizard-foot{display:flex;gap:8px;margin-top:1.25rem;align-items:center}
313
+ .btn-wiz-primary{background:#3b82f6;color:#fff;padding:8px 20px;font-size:.875rem;border-radius:7px;border:none;cursor:pointer;font-weight:600}
314
+ .btn-wiz-primary:hover{background:#2563eb}
315
+ .btn-wiz-primary:disabled{opacity:.4;cursor:not-allowed}
316
+ .btn-wiz-secondary{background:#1e293b;color:#94a3b8;padding:8px 16px;font-size:.875rem;border-radius:7px;border:1px solid #334155;cursor:pointer}
317
+ .btn-wiz-secondary:hover{color:#e2e8f0}
318
+ .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}
319
+ .wiz-input:focus{border-color:#3b82f6}
320
+ .wiz-label{font-size:.8rem;color:#64748b;margin-bottom:4px}
321
+ .wiz-msg{font-size:.82rem;margin-left:auto}
322
+ .wiz-msg.ok{color:#4ade80}.wiz-msg.fail{color:#f87171}
323
+ .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}
324
+ .wiz-tunnel-list{display:flex;flex-direction:column;gap:8px;margin-top:10px}
325
+ .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}
326
+ .wiz-tunnel-item:hover,.wiz-tunnel-item.selected{border-color:#3b82f6;background:rgba(59,130,246,.08)}
327
+ .wiz-tunnel-name{font-weight:600;color:#f8fafc;font-size:.9rem;flex:1}
328
+ .wiz-tunnel-id{font-family:'Courier New',monospace;font-size:.72rem;color:#475569}
329
+ .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)}
330
+ .wiz-desc{font-size:.85rem;color:#94a3b8;line-height:1.6;margin-bottom:.75rem}
331
+ .wiz-link{color:#60a5fa;text-decoration:none;font-size:.82rem}
332
+ .wiz-link:hover{text-decoration:underline}
333
+ .wiz-test-result{padding:12px 14px;border-radius:8px;font-size:.85rem;margin-top:10px;display:none}
334
+ .wiz-test-result.ok{background:rgba(74,222,128,.08);border:1px solid rgba(74,222,128,.2);color:#4ade80}
335
+ .wiz-test-result.fail{background:rgba(248,113,113,.08);border:1px solid rgba(248,113,113,.2);color:#f87171}
283
336
  </style>
284
337
  </head>
285
338
  <body>
@@ -307,8 +360,15 @@ function dashboardHtml(port, whitelist) {
307
360
  </div>
308
361
  <div class="header">
309
362
  <div class="dot" id="dot"></div>
310
- <h1>🔐 clauth vault <span style="font-size:0.55em;opacity:0.45;font-weight:400">v${VERSION}</span></h1>
363
+ <h1>🔐 clauth vault <span style="font-size:0.55em;opacity:0.45;font-weight:400">v${VERSION}</span>${isStaged ? `<span style="font-size:0.5em;background:#b45309;color:#fef3c7;border-radius:4px;padding:2px 10px;margin-left:12px;font-weight:600;letter-spacing:.5px">STAGED</span>` : ""}</h1>
311
364
  </div>
365
+ ${isStaged ? `<div id="staged-banner" style="background:linear-gradient(135deg,#78350f,#92400e);border:1px solid #b45309;border-radius:6px;padding:12px 16px;margin:0 16px 8px;display:flex;align-items:center;justify-content:space-between">
366
+ <div>
367
+ <strong style="color:#fef3c7">⚡ Staged deployment</strong>
368
+ <span style="color:#fcd34d;font-size:.85rem;margin-left:8px">Running on port ${port} — verify before making live</span>
369
+ </div>
370
+ <button onclick="makeLive()" style="background:#15803d;border:1px solid #16a34a;color:#dcfce7;padding:6px 16px;border-radius:4px;cursor:pointer;font-weight:600;font-size:.85rem">✓ Make Live</button>
371
+ </div>` : ""}
312
372
  <div id="error-bar" class="error-bar"></div>
313
373
  <div class="status-bar">
314
374
  <div>PID: <span id="s-pid">—</span></div>
@@ -330,6 +390,7 @@ function dashboardHtml(port, whitelist) {
330
390
  <button class="btn-add" onclick="toggleAddService()">+ Add Service</button>
331
391
  <button class="btn-check" id="check-btn" onclick="checkAll()">⬤ Check All</button>
332
392
  <button class="btn-lock" onclick="lockVault()">🔒 Lock</button>
393
+ <button class="btn-stop" onclick="stopDaemon()" style="background:#7f1d1d;border:1px solid #991b1b;color:#fca5a5" title="Stop daemon — password required on next start">⏹ Stop</button>
333
394
  <button class="btn-cancel" style="margin-left:auto" onclick="toggleChangePw()">Change Password</button>
334
395
  </div>
335
396
 
@@ -441,6 +502,17 @@ function dashboardHtml(port, whitelist) {
441
502
  <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>
442
503
  </div>
443
504
 
505
+ <div class="wizard-panel" id="wizard-panel">
506
+ <div class="wizard-header">
507
+ <div class="tunnel-dot off" id="wiz-dot" style="width:10px;height:10px;border-radius:50%"></div>
508
+ <div class="wizard-title">claude.ai Tunnel Setup</div>
509
+ <button class="btn-cancel" onclick="closeSetupWizard()">✕ Dismiss</button>
510
+ </div>
511
+ <div class="wizard-steps" id="wizard-steps"></div>
512
+ <div class="wizard-body" id="wizard-body"><p class="loading">Checking setup state…</p></div>
513
+ <div class="wizard-foot" id="wizard-foot"></div>
514
+ </div>
515
+
444
516
  <div id="project-tabs" class="project-tabs" style="display:none"></div>
445
517
  <div id="grid" class="grid"><p class="loading">Loading services…</p></div>
446
518
  <div class="footer">localhost:${port} · 127.0.0.1 only · 10-strike lockout</div>
@@ -650,6 +722,34 @@ async function lockVault() {
650
722
  showLockScreen();
651
723
  }
652
724
 
725
+ // ── Make Live (blue-green: promote staged instance to live port) ──
726
+ async function makeLive() {
727
+ if (!confirm("Promote this staged instance to live?\\n\\nThe current live daemon (port ${LIVE_PORT}) will be stopped and this instance will restart on port ${LIVE_PORT}.")) return;
728
+ const banner = document.getElementById("staged-banner");
729
+ if (banner) banner.innerHTML = '<div style="color:#fef3c7"><strong>⏳ Promoting to live...</strong></div>';
730
+ try {
731
+ const resp = await fetch(BASE + "/make-live", { method: "POST" });
732
+ const data = await resp.json();
733
+ if (data.ok) {
734
+ document.getElementById("main-view").innerHTML = '<div style="text-align:center;padding:80px 20px"><div style="font-size:3rem;margin-bottom:16px">✅</div><div style="font-size:1.1rem;color:#4ade80">Promoted to live!</div><div style="font-size:.85rem;color:#94a3b8;margin-top:8px">Restarting on port ${LIVE_PORT}... page will reload in 5 seconds.</div></div>';
735
+ setTimeout(() => { window.location.href = "http://127.0.0.1:" + ${LIVE_PORT}; }, 5000);
736
+ } else {
737
+ if (banner) banner.innerHTML = '<div style="color:#fca5a5"><strong>❌ Promotion failed:</strong> ' + (data.error || 'unknown') + '</div>';
738
+ }
739
+ } catch (err) {
740
+ if (banner) banner.innerHTML = '<div style="color:#fca5a5"><strong>❌ Promotion failed:</strong> ' + err.message + '</div>';
741
+ }
742
+ }
743
+
744
+ // ── Stop daemon (user-initiated — clears boot.key, requires password on restart) ──
745
+ async function stopDaemon() {
746
+ if (!confirm("Stop the daemon?\\n\\nCredentials will need to be re-entered on next start.")) return;
747
+ try {
748
+ await fetch(BASE + "/shutdown-ui", { method: "POST" });
749
+ } catch {}
750
+ document.getElementById("main-view").innerHTML = '<div style="text-align:center;padding:80px 20px"><div style="font-size:3rem;margin-bottom:16px">⏹</div><div style="font-size:1.1rem;color:#94a3b8">Daemon stopped</div><div style="font-size:.85rem;color:#64748b;margin-top:8px">Restart with: clauth serve start</div></div>';
751
+ }
752
+
653
753
  // ── Load services ───────────────────────────
654
754
  let allServices = [];
655
755
  let activeProjectTab = "all";
@@ -1193,9 +1293,7 @@ async function toggleTunnel(action) {
1193
1293
  } catch {}
1194
1294
  }
1195
1295
 
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.");
1198
- }
1296
+ function runTunnelSetup() { openSetupWizard(); }
1199
1297
 
1200
1298
  async function testTunnel() {
1201
1299
  const liveState = document.querySelector("#tunnel-panel .tunnel-state.live");
@@ -1267,6 +1365,423 @@ function copyMcp(elId) {
1267
1365
  }).catch(() => {});
1268
1366
  }
1269
1367
 
1368
+ // ── Tunnel Setup Wizard ─────────────────────
1369
+ let wizStep = null;
1370
+ let wizData = {};
1371
+
1372
+ async function openSetupWizard() {
1373
+ const panel = document.getElementById("wizard-panel");
1374
+ panel.classList.add("open");
1375
+ panel.scrollIntoView({ behavior: "smooth", block: "start" });
1376
+ wizStep = "loading";
1377
+ renderWizBody('<p class="loading">Checking setup state…</p>', [], []);
1378
+
1379
+ const state = await apiFetch("/tunnel/setup-state");
1380
+ if (!state) {
1381
+ renderWizBody('<p class="wiz-desc" style="color:#f87171">Could not reach daemon.</p>', [], []);
1382
+ return;
1383
+ }
1384
+
1385
+ if (state.step === "ready") {
1386
+ await apiFetch("/tunnel/start", { method: "POST" });
1387
+ closeSetupWizard();
1388
+ return;
1389
+ }
1390
+
1391
+ wizData = state;
1392
+
1393
+ if (state.step === "need_cf_token") wizShowCfToken();
1394
+ else if (state.step === "pick_tunnel") wizShowPickTunnel(state.tunnels);
1395
+ else if (state.step === "no_tunnels") wizShowCreateViaCfApi(state.accountId);
1396
+ else wizShowInstallCheck(); // no CF token — needs cloudflared login
1397
+ }
1398
+
1399
+ function closeSetupWizard() {
1400
+ document.getElementById("wizard-panel").classList.remove("open");
1401
+ wizStep = null; wizData = {};
1402
+ }
1403
+
1404
+ function renderWizBody(html, steps, footHtml) {
1405
+ document.getElementById("wizard-body").innerHTML = html;
1406
+
1407
+ const stepLabels = ["CF Token","Tunnel","cloudflared","MCP Setup","Test"];
1408
+ const stepsEl = document.getElementById("wizard-steps");
1409
+ stepsEl.innerHTML = stepLabels.map((label, i) => {
1410
+ const stepKeys = ["need_cf_token","pick_tunnel","check_cf","mcp_setup","test"];
1411
+ const currentIdx = stepKeys.indexOf(wizStep);
1412
+ let cls = "wstep";
1413
+ if (i < currentIdx) cls += " done";
1414
+ else if (i === currentIdx) cls += " active";
1415
+ return \`<div class="\${cls}">\${i < currentIdx ? "✓ " : ""}\${label}</div>\`;
1416
+ }).join("");
1417
+
1418
+ document.getElementById("wizard-foot").innerHTML = footHtml.join("");
1419
+ }
1420
+
1421
+ // Step: Enter CF API token
1422
+ function wizShowCfToken() {
1423
+ wizStep = "need_cf_token";
1424
+ renderWizBody(\`
1425
+ <div class="wiz-desc">A Cloudflare API token is needed to check for existing tunnels and configure new ones.</div>
1426
+ <div class="wiz-label">Cloudflare API Token</div>
1427
+ <input class="wiz-input" id="wiz-cf-token-input" type="password" placeholder="Paste your CF API token…" autocomplete="off" spellcheck="false">
1428
+ <div style="margin-top:8px">
1429
+ <a class="wiz-link" href="https://dash.cloudflare.com/profile/api-tokens" target="_blank">↗ Create token at Cloudflare Dashboard</a>
1430
+ <span style="color:#475569;font-size:.78rem;margin-left:8px">(needs Cloudflare Tunnel: Edit permission)</span>
1431
+ </div>
1432
+ <div class="wiz-msg fail" id="wiz-cf-err" style="display:none;margin-top:8px"></div>
1433
+ \`, [], [
1434
+ \`<button class="btn-wiz-primary" id="wiz-cf-btn" onclick="wizSubmitCfToken()">Verify &amp; Save Token</button>\`,
1435
+ \`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`,
1436
+ \`<span class="wiz-msg" id="wiz-cf-msg"></span>\`
1437
+ ]);
1438
+
1439
+ document.getElementById("wiz-cf-token-input").addEventListener("keydown", e => {
1440
+ if (e.key === "Enter") wizSubmitCfToken();
1441
+ });
1442
+ }
1443
+
1444
+ async function wizSubmitCfToken() {
1445
+ const input = document.getElementById("wiz-cf-token-input");
1446
+ const btn = document.getElementById("wiz-cf-btn");
1447
+ const errEl = document.getElementById("wiz-cf-err");
1448
+ const token = input.value.trim();
1449
+ if (!token) return;
1450
+
1451
+ btn.disabled = true; btn.textContent = "Verifying…";
1452
+ if (errEl) errEl.style.display = "none";
1453
+
1454
+ const r = await fetch(BASE + "/tunnel/setup/cf-token", {
1455
+ method: "POST",
1456
+ headers: { "Content-Type": "application/json" },
1457
+ body: JSON.stringify({ token }),
1458
+ }).then(r => r.json()).catch(() => null);
1459
+
1460
+ btn.disabled = false; btn.textContent = "Verify & Save Token";
1461
+
1462
+ if (!r || r.error) {
1463
+ if (errEl) { errEl.textContent = r?.error || "Request failed"; errEl.style.display = "block"; }
1464
+ return;
1465
+ }
1466
+
1467
+ wizData.accountId = r.accountId;
1468
+ const tunnelState = await apiFetch("/tunnel/setup-state");
1469
+ if (tunnelState?.step === "pick_tunnel") wizShowPickTunnel(tunnelState.tunnels);
1470
+ else wizShowInstallCheck();
1471
+ }
1472
+
1473
+ // Step: Pick existing tunnel
1474
+ function wizShowPickTunnel(tunnels) {
1475
+ wizStep = "pick_tunnel";
1476
+
1477
+ const listHtml = tunnels.map(t => \`
1478
+ <div class="wiz-tunnel-item" id="wti-\${t.id}" onclick="wizSelectTunnel('\${t.id}','\${t.name}',this)">
1479
+ <div style="flex:1">
1480
+ <div class="wiz-tunnel-name">\${t.name}</div>
1481
+ <div class="wiz-tunnel-id">\${t.id}</div>
1482
+ </div>
1483
+ <div class="wiz-tunnel-status">\${t.status || "active"}</div>
1484
+ </div>
1485
+ \`).join("");
1486
+
1487
+ renderWizBody(\`
1488
+ <div class="wiz-desc">Found \${tunnels.length} existing Cloudflare tunnel\${tunnels.length !== 1 ? "s" : ""}. Select one to use, or create a new one.</div>
1489
+ <div class="wiz-tunnel-list">\${listHtml}</div>
1490
+ <div style="margin-top:12px">
1491
+ <div class="wiz-label">Public hostname for selected tunnel (e.g. clauth.yourdomain.com)</div>
1492
+ <input class="wiz-input" id="wiz-hostname-input" type="text" placeholder="clauth.yourdomain.com" spellcheck="false" autocomplete="off">
1493
+ </div>
1494
+ <div class="wiz-msg fail" id="wiz-pick-err" style="display:none;margin-top:8px"></div>
1495
+ \`, [], [
1496
+ \`<button class="btn-wiz-primary" id="wiz-pick-btn" onclick="wizSaveTunnel()">Use Selected Tunnel</button>\`,
1497
+ \`<button class="btn-wiz-secondary" onclick="wizShowInstallCheck()">Create New Tunnel Instead</button>\`,
1498
+ \`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`
1499
+ ]);
1500
+
1501
+ window._wizSelectedTunnel = null;
1502
+ }
1503
+
1504
+ function wizSelectTunnel(id, name, el) {
1505
+ document.querySelectorAll(".wiz-tunnel-item").forEach(e => e.classList.remove("selected"));
1506
+ el.classList.add("selected");
1507
+ window._wizSelectedTunnel = { id, name };
1508
+ }
1509
+
1510
+ async function wizSaveTunnel() {
1511
+ const sel = window._wizSelectedTunnel;
1512
+ const hostname = document.getElementById("wiz-hostname-input")?.value.trim();
1513
+ const errEl = document.getElementById("wiz-pick-err");
1514
+ const btn = document.getElementById("wiz-pick-btn");
1515
+
1516
+ if (!sel) { if (errEl) { errEl.textContent = "Select a tunnel first"; errEl.style.display="block"; } return; }
1517
+ if (!hostname) { if (errEl) { errEl.textContent = "Enter a public hostname"; errEl.style.display="block"; } return; }
1518
+
1519
+ btn.disabled = true; btn.textContent = "Saving…";
1520
+
1521
+ const r = await fetch(BASE + "/tunnel/setup/cf-save", {
1522
+ method: "POST",
1523
+ headers: { "Content-Type": "application/json" },
1524
+ body: JSON.stringify({ tunnelId: sel.id, tunnelName: sel.name, hostname }),
1525
+ }).then(r => r.json()).catch(() => null);
1526
+
1527
+ btn.disabled = false; btn.textContent = "Use Selected Tunnel";
1528
+
1529
+ if (!r?.ok) {
1530
+ if (errEl) { errEl.textContent = r?.error || "Save failed"; errEl.style.display="block"; }
1531
+ return;
1532
+ }
1533
+
1534
+ await apiFetch("/tunnel/start", { method: "POST" });
1535
+ wizShowMcpSetup(hostname);
1536
+ }
1537
+
1538
+ // Step: Create tunnel via CF API (CF token already in vault — no cloudflared login needed)
1539
+ function wizShowCreateViaCfApi(accountId) {
1540
+ wizStep = "pick_tunnel";
1541
+ wizData.accountId = accountId;
1542
+ renderWizBody(\`
1543
+ <div class="wiz-desc">No existing tunnels found. Create a new one using your Cloudflare API token.</div>
1544
+ <div class="wiz-label">Tunnel name</div>
1545
+ <input class="wiz-input" id="wiz-api-tname" type="text" value="clauth" spellcheck="false">
1546
+ <div class="wiz-label" style="margin-top:10px">Public hostname (e.g. clauth.yourdomain.com)</div>
1547
+ <input class="wiz-input" id="wiz-api-hostname" type="text" placeholder="clauth.yourdomain.com" spellcheck="false">
1548
+ <div class="wiz-msg fail" id="wiz-api-err" style="display:none;margin-top:8px"></div>
1549
+ \`, [], [
1550
+ \`<button class="btn-wiz-primary" id="wiz-api-btn" onclick="wizRunCreateViaCfApi()">Create Tunnel</button>\`,
1551
+ \`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`,
1552
+ \`<span class="wiz-msg" id="wiz-api-msg"></span>\`
1553
+ ]);
1554
+ }
1555
+
1556
+ async function wizRunCreateViaCfApi() {
1557
+ const name = document.getElementById("wiz-api-tname")?.value.trim();
1558
+ const hostname = document.getElementById("wiz-api-hostname")?.value.trim();
1559
+ const btn = document.getElementById("wiz-api-btn");
1560
+ const err = document.getElementById("wiz-api-err");
1561
+ const msg = document.getElementById("wiz-api-msg");
1562
+ if (!name || !hostname) { if(err){err.textContent="Name and hostname required";err.style.display="block";} return; }
1563
+ btn.disabled=true; btn.textContent="Creating…"; if(err) err.style.display="none";
1564
+ if(msg) msg.textContent="Calling Cloudflare API…";
1565
+ const r = await fetch(BASE + "/tunnel/setup/cf-create-api", {
1566
+ method: "POST",
1567
+ headers: { "Content-Type": "application/json" },
1568
+ body: JSON.stringify({ name, hostname, accountId: wizData.accountId }),
1569
+ }).then(r => r.json()).catch(() => null);
1570
+ btn.disabled=false; btn.textContent="Create Tunnel";
1571
+ if (!r?.ok) {
1572
+ if(err){err.textContent = r?.error || "Creation failed"; err.style.display="block";}
1573
+ if(msg) msg.textContent="";
1574
+ return;
1575
+ }
1576
+ await apiFetch("/tunnel/start", { method: "POST" });
1577
+ wizShowMcpSetup(hostname);
1578
+ }
1579
+
1580
+ // Step: cloudflared install check (for new tunnel creation flow)
1581
+ async function wizShowInstallCheck() {
1582
+ wizStep = "check_cf";
1583
+ renderWizBody('<p class="loading">Checking cloudflared…</p>', [], []);
1584
+
1585
+ const r = await apiFetch("/tunnel/setup/check-cloudflared");
1586
+
1587
+ if (r?.installed) {
1588
+ wizShowCfLogin();
1589
+ } else {
1590
+ renderWizBody(\`
1591
+ <div class="wiz-desc">cloudflared is required to create and run Cloudflare tunnels. It is not currently installed.</div>
1592
+ <div style="display:flex;gap:12px;margin-top:12px;flex-wrap:wrap">
1593
+ <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>
1594
+ <span style="color:#475569;font-size:.8rem;align-self:center">or: <code style="color:#60a5fa">winget install Cloudflare.cloudflared</code></span>
1595
+ </div>
1596
+ <div class="wiz-desc" style="margin-top:12px;font-size:.78rem;color:#475569">After installing, click "Check Again".</div>
1597
+ \`, [], [
1598
+ \`<button class="btn-wiz-primary" onclick="wizShowInstallCheck()">Check Again</button>\`,
1599
+ \`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`
1600
+ ]);
1601
+ }
1602
+ }
1603
+
1604
+ // Step: cloudflared login (Cloudflare auth via browser)
1605
+ function wizShowCfLogin() {
1606
+ wizStep = "check_cf";
1607
+ renderWizBody(\`
1608
+ <div class="wiz-desc">Authenticate cloudflared with your Cloudflare account. This opens a browser window.</div>
1609
+ <div class="wiz-log" id="wiz-login-log" style="display:none"></div>
1610
+ \`, [], [
1611
+ \`<button class="btn-wiz-primary" id="wiz-login-btn" onclick="wizRunCfLogin()">Open Browser to Authenticate</button>\`,
1612
+ \`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`
1613
+ ]);
1614
+ }
1615
+
1616
+ async function wizRunCfLogin() {
1617
+ const btn = document.getElementById("wiz-login-btn");
1618
+ const log = document.getElementById("wiz-login-log");
1619
+ btn.disabled = true; btn.textContent = "Authenticating…";
1620
+ log.style.display = "block";
1621
+
1622
+ try {
1623
+ const resp = await fetch(BASE + "/tunnel/setup/cf-login", { method: "POST" });
1624
+ const reader = resp.body.getReader();
1625
+ const dec = new TextDecoder();
1626
+ let buf = "";
1627
+ while (true) {
1628
+ const { done, value } = await reader.read();
1629
+ if (done) break;
1630
+ buf += dec.decode(value, { stream: true });
1631
+ const lines = buf.split("\\n");
1632
+ buf = lines.pop();
1633
+ for (const line of lines) {
1634
+ if (!line.startsWith("data:")) continue;
1635
+ try {
1636
+ const d = JSON.parse(line.slice(5).trim());
1637
+ if (d.line !== undefined) { log.textContent += d.line + "\\n"; log.scrollTop = log.scrollHeight; }
1638
+ if (d.done) {
1639
+ if (d.code === 0) wizShowCreateTunnel();
1640
+ else { btn.disabled=false; btn.textContent="Retry"; }
1641
+ return;
1642
+ }
1643
+ } catch {}
1644
+ }
1645
+ }
1646
+ } catch(e) {
1647
+ log.textContent += "Error: " + e.message;
1648
+ btn.disabled = false; btn.textContent = "Retry";
1649
+ }
1650
+ }
1651
+
1652
+ // Step: Create new tunnel
1653
+ function wizShowCreateTunnel() {
1654
+ wizStep = "check_cf";
1655
+ renderWizBody(\`
1656
+ <div class="wiz-desc">Create a new named Cloudflare tunnel. Give it a name and a public hostname.</div>
1657
+ <div class="wiz-label">Tunnel name (e.g. clauth)</div>
1658
+ <input class="wiz-input" id="wiz-tname" type="text" value="clauth" spellcheck="false">
1659
+ <div class="wiz-label" style="margin-top:10px">Public hostname (e.g. clauth.yourdomain.com)</div>
1660
+ <input class="wiz-input" id="wiz-thostname" type="text" placeholder="clauth.yourdomain.com" spellcheck="false">
1661
+ <div class="wiz-log" id="wiz-create-log" style="display:none;margin-top:10px"></div>
1662
+ <div class="wiz-msg fail" id="wiz-create-err" style="display:none;margin-top:8px"></div>
1663
+ \`, [], [
1664
+ \`<button class="btn-wiz-primary" id="wiz-create-btn" onclick="wizRunCreateTunnel()">Create Tunnel</button>\`,
1665
+ \`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`
1666
+ ]);
1667
+ }
1668
+
1669
+ async function wizRunCreateTunnel() {
1670
+ const name = document.getElementById("wiz-tname")?.value.trim();
1671
+ const hostname = document.getElementById("wiz-thostname")?.value.trim();
1672
+ const btn = document.getElementById("wiz-create-btn");
1673
+ const log = document.getElementById("wiz-create-log");
1674
+ const err = document.getElementById("wiz-create-err");
1675
+
1676
+ if (!name || !hostname) { if(err){err.textContent="Name and hostname required";err.style.display="block";} return; }
1677
+
1678
+ btn.disabled=true; btn.textContent="Creating…";
1679
+ log.style.display="block"; err.style.display="none";
1680
+
1681
+ try {
1682
+ const resp = await fetch(BASE + "/tunnel/setup/cf-create", {
1683
+ method: "POST",
1684
+ headers: { "Content-Type": "application/json" },
1685
+ body: JSON.stringify({ name, hostname }),
1686
+ });
1687
+ const reader = resp.body.getReader();
1688
+ const dec = new TextDecoder();
1689
+ let buf = "";
1690
+ while (true) {
1691
+ const { done, value } = await reader.read();
1692
+ if (done) break;
1693
+ buf += dec.decode(value, { stream: true });
1694
+ const lines = buf.split("\\n");
1695
+ buf = lines.pop();
1696
+ for (const line of lines) {
1697
+ if (!line.startsWith("data:")) continue;
1698
+ try {
1699
+ const d = JSON.parse(line.slice(5).trim());
1700
+ if (d.line !== undefined) { log.textContent += d.line + "\\n"; log.scrollTop = log.scrollHeight; }
1701
+ if (d.done && d.hostname) {
1702
+ await apiFetch("/tunnel/start", { method: "POST" });
1703
+ wizShowMcpSetup(d.hostname);
1704
+ return;
1705
+ }
1706
+ if (d.error) { err.textContent = d.error; err.style.display="block"; btn.disabled=false; btn.textContent="Retry"; return; }
1707
+ } catch {}
1708
+ }
1709
+ }
1710
+ } catch(e) {
1711
+ if(err){err.textContent=e.message;err.style.display="block";}
1712
+ btn.disabled=false; btn.textContent="Retry";
1713
+ }
1714
+ }
1715
+
1716
+ // Step: MCP setup in claude.ai
1717
+ async function wizShowMcpSetup(hostname) {
1718
+ wizStep = "mcp_setup";
1719
+ const sseUrl = \`https://\${hostname}/sse\`;
1720
+
1721
+ const mcpData = await apiFetch("/mcp-setup");
1722
+
1723
+ renderWizBody(\`
1724
+ <div class="wiz-desc">Add clauth as an MCP server in claude.ai settings. Paste these values:</div>
1725
+ <div style="display:flex;flex-direction:column;gap:8px;margin-top:10px">
1726
+ <div class="mcp-row">
1727
+ <span class="mcp-label">URL</span>
1728
+ <span class="mcp-val" id="wiz-mcp-url">\${sseUrl}</span>
1729
+ <button class="mcp-copy" onclick="wizCopy('wiz-mcp-url',this)">copy</button>
1730
+ </div>
1731
+ <div class="mcp-row">
1732
+ <span class="mcp-label">Client ID</span>
1733
+ <span class="mcp-val" id="wiz-mcp-cid">\${mcpData?.clientId || '(unlock required)'}</span>
1734
+ <button class="mcp-copy" onclick="wizCopy('wiz-mcp-cid',this)">copy</button>
1735
+ </div>
1736
+ <div class="mcp-row">
1737
+ <span class="mcp-label">Secret</span>
1738
+ <span class="mcp-val" id="wiz-mcp-sec">\${mcpData?.clientSecret || '(unlock required)'}</span>
1739
+ <button class="mcp-copy" onclick="wizCopy('wiz-mcp-sec',this)">copy</button>
1740
+ </div>
1741
+ </div>
1742
+ <div style="margin-top:10px;font-size:.78rem;color:#64748b">
1743
+ Paste into <a class="wiz-link" href="https://claude.ai/settings/integrations" target="_blank">claude.ai → Settings → Integrations</a>
1744
+ </div>
1745
+ \`, [], [
1746
+ \`<button class="btn-wiz-primary" onclick="window.open('https://claude.ai/settings/integrations','_blank');wizShowTest()">I've Added It — Test Now</button>\`,
1747
+ \`<button class="btn-wiz-secondary" onclick="wizShowTest()">Skip Test</button>\`,
1748
+ \`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Done</button>\`
1749
+ ]);
1750
+ }
1751
+
1752
+ function wizCopy(elId, btn) {
1753
+ const val = document.getElementById(elId)?.textContent?.trim();
1754
+ if (!val) return;
1755
+ navigator.clipboard.writeText(val).then(() => {
1756
+ const orig = btn.textContent;
1757
+ btn.textContent = "✓"; btn.classList.add("ok");
1758
+ setTimeout(() => { btn.textContent = orig; btn.classList.remove("ok"); }, 1500);
1759
+ }).catch(() => {});
1760
+ }
1761
+
1762
+ // Step: Test — verify MCP is working
1763
+ function wizShowTest() {
1764
+ wizStep = "test";
1765
+ renderWizBody(\`
1766
+ <div class="wiz-desc">In a new claude.ai chat, ask Claude to run this MCP tool call to verify the connection:</div>
1767
+ <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">
1768
+ Use the clauth MCP tool: GET /ping — what is the response?
1769
+ </div>
1770
+ <div class="wiz-desc">Claude should report back: <code style="color:#4ade80">{"status":"ok","version":"..."}</code></div>
1771
+ <div class="wiz-test-result" id="wiz-test-result"></div>
1772
+ \`, [], [
1773
+ \`<button class="btn-wiz-primary" onclick="wizMarkTestDone()">✓ It Worked — Done</button>\`,
1774
+ \`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Close</button>\`
1775
+ ]);
1776
+ }
1777
+
1778
+ function wizMarkTestDone() {
1779
+ const r = document.getElementById("wiz-test-result");
1780
+ r.className = "wiz-test-result ok"; r.style.display="block";
1781
+ r.textContent = "✓ Tunnel and MCP are working. claude.ai can now access your vault.";
1782
+ document.getElementById("wizard-foot").innerHTML = \`<button class="btn-wiz-primary" onclick="closeSetupWizard()">Close Setup</button>\`;
1783
+ }
1784
+
1270
1785
  // ── Build Status ──────────────────────────────────
1271
1786
  async function updateBuildStatus() {
1272
1787
  try {
@@ -1320,7 +1835,7 @@ function readBody(req) {
1320
1835
  }
1321
1836
 
1322
1837
  // ── Server logic (shared by foreground + daemon) ─────────────
1323
- function createServer(initPassword, whitelist, port, tunnelHostnameInit = null) {
1838
+ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null, isStaged = false) {
1324
1839
  // tunnelHostname may be updated at runtime (fetched from DB after unlock)
1325
1840
  let tunnelHostname = tunnelHostnameInit;
1326
1841
 
@@ -1456,6 +1971,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null)
1456
1971
 
1457
1972
  const proc = spawnProc(cfBin, args, {
1458
1973
  stdio: ["ignore", "pipe", "pipe"],
1974
+ windowsHide: true,
1459
1975
  });
1460
1976
 
1461
1977
  tunnelProc = proc;
@@ -1614,6 +2130,26 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null)
1614
2130
  if (rows.length > 0 && rows[0].value && rows[0].value !== "null") {
1615
2131
  return typeof rows[0].value === "string" ? JSON.parse(rows[0].value) : rows[0].value;
1616
2132
  }
2133
+
2134
+ // DB has no hostname — check ~/.cloudflared/config.yml
2135
+ try {
2136
+ const cfConfig = path.join(os.homedir(), ".cloudflared", "config.yml");
2137
+ if (fs.existsSync(cfConfig)) {
2138
+ const yml = fs.readFileSync(cfConfig, "utf8");
2139
+ const hostMatch = yml.match(/^\s*-\s*hostname:\s*(\S+)/m);
2140
+ if (hostMatch) {
2141
+ const hostname = hostMatch[1];
2142
+ // Save to DB so future loads skip this
2143
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
2144
+ method: "POST",
2145
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
2146
+ body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(hostname) }),
2147
+ }).catch(() => {});
2148
+ return hostname;
2149
+ }
2150
+ }
2151
+ } catch {}
2152
+
1617
2153
  return null;
1618
2154
  } catch {
1619
2155
  return null;
@@ -2036,7 +2572,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null)
2036
2572
  // GET / — built-in web dashboard
2037
2573
  if (method === "GET" && reqPath === "/") {
2038
2574
  res.writeHead(200, { "Content-Type": "text/html", ...CORS });
2039
- return res.end(dashboardHtml(port, whitelist));
2575
+ return res.end(dashboardHtml(port, whitelist, isStaged));
2040
2576
  }
2041
2577
 
2042
2578
  // GET /ping
@@ -2052,6 +2588,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null)
2052
2588
  tunnel_status: tunnelStatus,
2053
2589
  tunnel_url: tunnelUrl || null,
2054
2590
  app_version: VERSION,
2591
+ staged: isStaged,
2055
2592
  schema_version: CURRENT_SCHEMA_VERSION,
2056
2593
  });
2057
2594
  }
@@ -2123,7 +2660,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null)
2123
2660
  return ok(res, { status: tunnelStatus });
2124
2661
  }
2125
2662
 
2126
- // GET /shutdown (for daemon stop)
2663
+ // GET /shutdown (for daemon stop — programmatic, keeps boot.key)
2127
2664
  if (method === "GET" && reqPath === "/shutdown") {
2128
2665
  stopTunnel();
2129
2666
  ok(res, { ok: true, message: "shutting down" });
@@ -2132,6 +2669,100 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null)
2132
2669
  return;
2133
2670
  }
2134
2671
 
2672
+ // POST /shutdown-ui (user-initiated stop — clears boot.key so password is required on restart)
2673
+ if (method === "POST" && reqPath === "/shutdown-ui") {
2674
+ stopTunnel();
2675
+ // Clear boot.key so watchdog can't auto-unlock on restart
2676
+ const bootKeyPath = getBootKeyPath();
2677
+ if (bootKeyPath) {
2678
+ try { fs.unlinkSync(bootKeyPath); } catch {}
2679
+ }
2680
+ ok(res, { ok: true, message: "shutting down (user-initiated, boot.key cleared)" });
2681
+ const logLine = `[${new Date().toISOString()}] User-initiated shutdown via UI — boot.key cleared\n`;
2682
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
2683
+ removePid();
2684
+ setTimeout(() => process.exit(0), 100);
2685
+ return;
2686
+ }
2687
+
2688
+ // POST /make-live (blue-green: staged instance promotes itself to live port)
2689
+ if (method === "POST" && reqPath === "/make-live") {
2690
+ if (!isStaged) {
2691
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
2692
+ return res.end(JSON.stringify({ error: "Not a staged instance" }));
2693
+ }
2694
+ const logLine = `[${new Date().toISOString()}] Make-live: promoting staged (port ${port}) to live (port ${LIVE_PORT})\n`;
2695
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
2696
+
2697
+ // Stop the current live daemon if running
2698
+ try { await fetch(`http://127.0.0.1:${LIVE_PORT}/shutdown`); } catch {}
2699
+
2700
+ // Wait for port to be released (Windows is slow to release sockets)
2701
+ let portFree = false;
2702
+ for (let i = 0; i < 10; i++) {
2703
+ await new Promise(r => setTimeout(r, 1000));
2704
+ try {
2705
+ await fetch(`http://127.0.0.1:${LIVE_PORT}/ping`);
2706
+ // Still responding — not dead yet
2707
+ } catch {
2708
+ portFree = true;
2709
+ break;
2710
+ }
2711
+ }
2712
+ if (!portFree) {
2713
+ const errLog = `[${new Date().toISOString()}] Make-live: live daemon on port ${LIVE_PORT} failed to stop within 10s\n`;
2714
+ try { fs.appendFileSync(LOG_FILE, errLog); } catch {}
2715
+ }
2716
+
2717
+ // Stop tunnel on this instance (will restart on new port)
2718
+ stopTunnel();
2719
+
2720
+ // Clean up staged PID file
2721
+ removeStagedPid();
2722
+
2723
+ // Spawn a new daemon on the live port with same credentials
2724
+ const { spawn } = await import("child_process");
2725
+ const cliEntry = path.resolve(__dirname, "../index.js");
2726
+ const childArgs = [cliEntry, "serve", "start", "--port", String(LIVE_PORT)];
2727
+ if (password) childArgs.push("--pw", password);
2728
+ if (whitelist) childArgs.push("--services", whitelist.join(","));
2729
+ if (tunnelHostname) childArgs.push("--tunnel", tunnelHostname);
2730
+
2731
+ const out = fs.openSync(LOG_FILE, "a");
2732
+ const childEnv = { ...process.env, __CLAUTH_DAEMON: "1" };
2733
+ delete childEnv.__CLAUTH_STAGED; // Clear staged flag — child is live
2734
+ const child = spawn(process.execPath, childArgs, {
2735
+ detached: true,
2736
+ stdio: ["ignore", out, out],
2737
+ env: childEnv,
2738
+ });
2739
+ child.unref();
2740
+
2741
+ // Wait for new live daemon to be reachable
2742
+ let promoted = false;
2743
+ for (let i = 0; i < 8; i++) {
2744
+ await new Promise(r => setTimeout(r, 1000));
2745
+ try {
2746
+ const resp = await fetch(`http://127.0.0.1:${LIVE_PORT}/ping`);
2747
+ if (resp.ok) { promoted = true; break; }
2748
+ } catch {}
2749
+ }
2750
+
2751
+ if (promoted) {
2752
+ const okLog = `[${new Date().toISOString()}] Make-live: promoted to live on port ${LIVE_PORT}\n`;
2753
+ try { fs.appendFileSync(LOG_FILE, okLog); } catch {}
2754
+ ok(res, { ok: true, message: "promoted to live", live_port: LIVE_PORT });
2755
+ } else {
2756
+ const failLog = `[${new Date().toISOString()}] Make-live: new daemon failed to start on port ${LIVE_PORT}\n`;
2757
+ try { fs.appendFileSync(LOG_FILE, failLog); } catch {}
2758
+ ok(res, { ok: false, error: "New daemon failed to start on live port — check log" });
2759
+ }
2760
+
2761
+ // Self-terminate after response is sent
2762
+ setTimeout(() => process.exit(0), 500);
2763
+ return;
2764
+ }
2765
+
2135
2766
  // Locked guard — returns true if locked and already responded
2136
2767
  function lockedGuard(res) {
2137
2768
  if (!password) {
@@ -2228,6 +2859,25 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null)
2228
2859
  password = pw; // unlock — store in process memory only
2229
2860
  const logLine = `[${new Date().toISOString()}] Vault unlocked\n`;
2230
2861
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
2862
+ // Auto-seal: DPAPI-encrypt password to boot.key for passwordless crash recovery
2863
+ // Only on Windows; only if autostart dir exists (i.e., install was run)
2864
+ if (os.platform() === "win32") {
2865
+ const autostartDir = path.join(os.homedir(), "AppData", "Roaming", "clauth");
2866
+ const bootKeyPath = path.join(autostartDir, "boot.key");
2867
+ if (fs.existsSync(autostartDir)) {
2868
+ try {
2869
+ const pwEscaped = pw.replace(/'/g, "''");
2870
+ const psExpr = `[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes('${pwEscaped}'),$null,'CurrentUser'))`;
2871
+ const encrypted = execSyncTop(`powershell -NoProfile -Command "${psExpr}"`, { encoding: "utf8", timeout: 5000 }).trim();
2872
+ fs.writeFileSync(bootKeyPath, encrypted, "utf8");
2873
+ const sealLog = `[${new Date().toISOString()}] Auto-sealed boot.key via DPAPI (crash recovery enabled)\n`;
2874
+ try { fs.appendFileSync(LOG_FILE, sealLog); } catch {}
2875
+ } catch (sealErr) {
2876
+ const sealLog = `[${new Date().toISOString()}] Auto-seal failed (non-fatal): ${sealErr.message}\n`;
2877
+ try { fs.appendFileSync(LOG_FILE, sealLog); } catch {}
2878
+ }
2879
+ }
2880
+ }
2231
2881
  // Auto-start tunnel: --tunnel flag takes priority, otherwise fetch from DB
2232
2882
  if (!tunnelHostname) {
2233
2883
  fetchTunnelConfig().then(configured => {
@@ -2379,6 +3029,415 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null)
2379
3029
  }
2380
3030
  }
2381
3031
 
3032
+ // GET /tunnel/setup-state
3033
+ if (method === "GET" && reqPath === "/tunnel/setup-state") {
3034
+ if (lockedGuard(res)) return;
3035
+ try {
3036
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
3037
+ const sbKey = api.getAnonKey();
3038
+
3039
+ // Check for existing hostname in DB
3040
+ const hostnameResp = await fetch(
3041
+ `${sbUrl}/rest/v1/clauth_config?key=eq.tunnel_hostname&select=value`,
3042
+ { headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) }
3043
+ );
3044
+ if (hostnameResp.ok) {
3045
+ const rows = await hostnameResp.json();
3046
+ if (rows.length > 0 && rows[0].value && rows[0].value !== "null" && rows[0].value !== '""') {
3047
+ const hn = typeof rows[0].value === "string" ? JSON.parse(rows[0].value) : rows[0].value;
3048
+ if (hn) return ok(res, { step: "ready", hostname: hn });
3049
+ }
3050
+ }
3051
+
3052
+ // Check ~/.cloudflared/config.yml for a previously configured tunnel
3053
+ try {
3054
+ const cfConfig = path.join(os.homedir(), ".cloudflared", "config.yml");
3055
+ if (fs.existsSync(cfConfig)) {
3056
+ const cfYml = fs.readFileSync(cfConfig, "utf8");
3057
+ // Parse hostname from ingress block: " - hostname: <hostname>"
3058
+ const hostMatch = cfYml.match(/^\s*-\s*hostname:\s*(\S+)/m);
3059
+ const tunnelMatch = cfYml.match(/^tunnel:\s*(\S+)/m);
3060
+ if (hostMatch && tunnelMatch) {
3061
+ const localHostname = hostMatch[1];
3062
+ const localTunnelId = tunnelMatch[1];
3063
+ // Save to DB so next load skips this check
3064
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
3065
+ method: "POST",
3066
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
3067
+ body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(localHostname) }),
3068
+ });
3069
+ tunnelHostname = localHostname;
3070
+ return ok(res, { step: "ready", hostname: localHostname, tunnelId: localTunnelId, source: "local_config" });
3071
+ }
3072
+ }
3073
+ } catch {}
3074
+
3075
+ // Check for CF token in vault
3076
+ let cfToken = null;
3077
+ try {
3078
+ const { token: t, timestamp } = deriveToken(password, machineHash);
3079
+ const cr = await api.retrieve(password, machineHash, t, timestamp, "cloudflare");
3080
+ if (cr?.value) cfToken = cr.value;
3081
+ } catch {}
3082
+
3083
+ if (!cfToken) return ok(res, { step: "need_cf_token" });
3084
+
3085
+ // Get or fetch account ID
3086
+ let accountId = null;
3087
+ try {
3088
+ const acctResp = await fetch(
3089
+ `${sbUrl}/rest/v1/clauth_config?key=eq.cf_account_id&select=value`,
3090
+ { headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) }
3091
+ );
3092
+ if (acctResp.ok) {
3093
+ const rows = await acctResp.json();
3094
+ if (rows.length > 0 && rows[0].value) {
3095
+ accountId = typeof rows[0].value === "string" ? JSON.parse(rows[0].value) : rows[0].value;
3096
+ }
3097
+ }
3098
+ } catch {}
3099
+
3100
+ if (!accountId) {
3101
+ try {
3102
+ const ar = await fetch("https://api.cloudflare.com/client/v4/accounts", {
3103
+ headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000)
3104
+ });
3105
+ const ad = await ar.json();
3106
+ accountId = ad?.result?.[0]?.id;
3107
+ if (accountId) {
3108
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
3109
+ method: "POST",
3110
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
3111
+ body: JSON.stringify({ key: "cf_account_id", value: accountId }),
3112
+ });
3113
+ }
3114
+ } catch {}
3115
+ }
3116
+
3117
+ if (!accountId) return ok(res, { step: "setup_wizard", error: "Could not get Cloudflare account ID" });
3118
+
3119
+ // List tunnels
3120
+ try {
3121
+ const tr = await fetch(
3122
+ `https://api.cloudflare.com/client/v4/accounts/${accountId}/cfd_tunnel?is_deleted=false`,
3123
+ { headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000) }
3124
+ );
3125
+ const td = await tr.json();
3126
+ const tunnels = (td?.result || []).map(t => ({ id: t.id, name: t.name, status: t.status }));
3127
+ if (tunnels.length > 0) return ok(res, { step: "pick_tunnel", tunnels, accountId });
3128
+ // CF token exists but no tunnels — create via API (no cloudflared login needed)
3129
+ return ok(res, { step: "no_tunnels", accountId });
3130
+ } catch {}
3131
+
3132
+ return ok(res, { step: "setup_wizard" });
3133
+ } catch (err) {
3134
+ return ok(res, { step: "setup_wizard", error: err.message });
3135
+ }
3136
+ }
3137
+
3138
+ // POST /tunnel/setup/cf-token
3139
+ if (method === "POST" && reqPath === "/tunnel/setup/cf-token") {
3140
+ if (lockedGuard(res)) return;
3141
+ let body;
3142
+ try { body = await readBody(req); } catch { return strike(res, 400, "Invalid JSON"); }
3143
+ const { token: cfToken } = body;
3144
+ if (!cfToken) return strike(res, 400, "token required");
3145
+
3146
+ try {
3147
+ const vr = await fetch("https://api.cloudflare.com/client/v4/user/tokens/verify", {
3148
+ headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000)
3149
+ });
3150
+ const vd = await vr.json();
3151
+ if (!vd?.success) return strike(res, 400, "Invalid Cloudflare API token");
3152
+
3153
+ const ar = await fetch("https://api.cloudflare.com/client/v4/accounts", {
3154
+ headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000)
3155
+ });
3156
+ const ad = await ar.json();
3157
+ const accountId = ad?.result?.[0]?.id;
3158
+ const accountName = ad?.result?.[0]?.name;
3159
+
3160
+ // Save token to vault using api.write (same as /set/:service)
3161
+ const { token: t, timestamp } = deriveToken(password, machineHash);
3162
+ await api.write(password, machineHash, t, timestamp, "cloudflare", cfToken);
3163
+
3164
+ // Save accountId to clauth_config
3165
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
3166
+ const sbKey = api.getAnonKey();
3167
+ if (accountId) {
3168
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
3169
+ method: "POST",
3170
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
3171
+ body: JSON.stringify({ key: "cf_account_id", value: accountId }),
3172
+ });
3173
+ }
3174
+
3175
+ return ok(res, { ok: true, accountId, accountName });
3176
+ } catch (err) {
3177
+ return strike(res, 502, err.message);
3178
+ }
3179
+ }
3180
+
3181
+ // GET /tunnel/setup/cf-tunnels
3182
+ if (method === "GET" && reqPath === "/tunnel/setup/cf-tunnels") {
3183
+ if (lockedGuard(res)) return;
3184
+ try {
3185
+ const { token: t, timestamp } = deriveToken(password, machineHash);
3186
+ const cr = await api.retrieve(password, machineHash, t, timestamp, "cloudflare");
3187
+ const cfToken = cr?.value;
3188
+ if (!cfToken) return strike(res, 400, "No cloudflare token in vault");
3189
+
3190
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
3191
+ const sbKey = api.getAnonKey();
3192
+ const acctResp = await fetch(
3193
+ `${sbUrl}/rest/v1/clauth_config?key=eq.cf_account_id&select=value`,
3194
+ { headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) }
3195
+ );
3196
+ const acctRows = await acctResp.json();
3197
+ const accountId = acctRows?.[0]?.value ? JSON.parse(acctRows[0].value) : null;
3198
+ if (!accountId) return strike(res, 400, "No account ID — run cf-token first");
3199
+
3200
+ const tr = await fetch(
3201
+ `https://api.cloudflare.com/client/v4/accounts/${accountId}/cfd_tunnel?is_deleted=false`,
3202
+ { headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000) }
3203
+ );
3204
+ const td = await tr.json();
3205
+ return ok(res, { tunnels: (td?.result || []).map(t => ({ id: t.id, name: t.name, status: t.status, created_at: t.created_at })) });
3206
+ } catch (err) {
3207
+ return strike(res, 502, err.message);
3208
+ }
3209
+ }
3210
+
3211
+ // POST /tunnel/setup/cf-save
3212
+ if (method === "POST" && reqPath === "/tunnel/setup/cf-save") {
3213
+ if (lockedGuard(res)) return;
3214
+ let body;
3215
+ try { body = await readBody(req); } catch { return strike(res, 400, "Invalid JSON"); }
3216
+ const { tunnelId, tunnelName, hostname } = body;
3217
+ if (!hostname) return strike(res, 400, "hostname required");
3218
+
3219
+ try {
3220
+ const cfDir = path.join(os.homedir(), ".cloudflared");
3221
+ if (!fs.existsSync(cfDir)) fs.mkdirSync(cfDir, { recursive: true });
3222
+ const credFile = tunnelId ? path.join(cfDir, `${tunnelId}.json`) : path.join(cfDir, "tunnel.json");
3223
+ 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`;
3224
+ fs.writeFileSync(path.join(cfDir, "config.yml"), configContent, "utf8");
3225
+
3226
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
3227
+ const sbKey = api.getAnonKey();
3228
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
3229
+ method: "POST",
3230
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
3231
+ body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(hostname) }),
3232
+ });
3233
+
3234
+ tunnelHostname = hostname;
3235
+ tunnelUrl = `https://${hostname}`;
3236
+
3237
+ return ok(res, { ok: true, hostname });
3238
+ } catch (err) {
3239
+ return strike(res, 502, err.message);
3240
+ }
3241
+ }
3242
+
3243
+ // POST /tunnel/setup/cf-create-api — create tunnel via CF API (no cloudflared login needed)
3244
+ if (method === "POST" && reqPath === "/tunnel/setup/cf-create-api") {
3245
+ if (lockedGuard(res)) return;
3246
+ let body;
3247
+ try { body = await readBody(req); } catch { return strike(res, 400, "Invalid JSON"); }
3248
+ const { name, hostname, accountId: bodyAccountId } = body;
3249
+ if (!name || !hostname) return strike(res, 400, "name and hostname required");
3250
+ try {
3251
+ const { token: t, timestamp } = deriveToken(password, machineHash);
3252
+ const cr = await api.retrieve(password, machineHash, t, timestamp, "cloudflare");
3253
+ const cfToken = cr?.value;
3254
+ if (!cfToken) return strike(res, 400, "No cloudflare token in vault");
3255
+
3256
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
3257
+ const sbKey = api.getAnonKey();
3258
+
3259
+ // Get accountId
3260
+ let accountId = bodyAccountId;
3261
+ if (!accountId) {
3262
+ const acctResp = await fetch(`${sbUrl}/rest/v1/clauth_config?key=eq.cf_account_id&select=value`,
3263
+ { headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) });
3264
+ const rows = await acctResp.json();
3265
+ accountId = rows?.[0]?.value ? JSON.parse(rows[0].value) : null;
3266
+ }
3267
+ if (!accountId) return strike(res, 400, "No account ID — verify CF token first");
3268
+
3269
+ // Generate tunnel secret (32 random bytes base64)
3270
+ const { randomBytes } = await import("crypto");
3271
+ const tunnelSecret = randomBytes(32).toString("base64");
3272
+
3273
+ // Create tunnel via CF API
3274
+ const createResp = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/cfd_tunnel`, {
3275
+ method: "POST",
3276
+ headers: { Authorization: `Bearer ${cfToken}`, "Content-Type": "application/json" },
3277
+ body: JSON.stringify({ name, tunnel_secret: tunnelSecret }),
3278
+ signal: AbortSignal.timeout(10000),
3279
+ });
3280
+ const createData = await createResp.json();
3281
+ if (!createData?.success) return strike(res, 400, createData?.errors?.[0]?.message || "Tunnel creation failed");
3282
+ const tunnelId = createData.result.id;
3283
+
3284
+ // Write credentials file
3285
+ const cfDir = path.join(os.homedir(), ".cloudflared");
3286
+ if (!fs.existsSync(cfDir)) fs.mkdirSync(cfDir, { recursive: true });
3287
+ const credFile = path.join(cfDir, `${tunnelId}.json`);
3288
+ fs.writeFileSync(credFile, JSON.stringify({ AccountTag: accountId, TunnelID: tunnelId, TunnelName: name, TunnelSecret: tunnelSecret }), "utf8");
3289
+
3290
+ // Write config.yml
3291
+ 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`;
3292
+ fs.writeFileSync(path.join(cfDir, "config.yml"), configContent, "utf8");
3293
+
3294
+ // Route DNS via CF API — find zone for hostname
3295
+ const domain = hostname.split(".").slice(-2).join(".");
3296
+ const zoneResp = await fetch(`https://api.cloudflare.com/client/v4/zones?name=${domain}`,
3297
+ { headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000) });
3298
+ const zoneData = await zoneResp.json();
3299
+ const zoneId = zoneData?.result?.[0]?.id;
3300
+ if (zoneId) {
3301
+ await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`, {
3302
+ method: "POST",
3303
+ headers: { Authorization: `Bearer ${cfToken}`, "Content-Type": "application/json" },
3304
+ body: JSON.stringify({ type: "CNAME", name: hostname, content: `${tunnelId}.cfargotunnel.com`, proxied: false, ttl: 1 }),
3305
+ signal: AbortSignal.timeout(8000),
3306
+ });
3307
+ }
3308
+
3309
+ // Save hostname to DB and in-process
3310
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
3311
+ method: "POST",
3312
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
3313
+ body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(hostname) }),
3314
+ });
3315
+ tunnelHostname = hostname;
3316
+ tunnelUrl = `https://${hostname}`;
3317
+
3318
+ return ok(res, { ok: true, tunnelId, hostname });
3319
+ } catch (err) {
3320
+ return strike(res, 502, err.message);
3321
+ }
3322
+ }
3323
+
3324
+ // GET /tunnel/setup/check-cloudflared
3325
+ if (method === "GET" && reqPath === "/tunnel/setup/check-cloudflared") {
3326
+ let cfBin = "cloudflared";
3327
+ let installed = false;
3328
+ if (os.platform() === "win32") {
3329
+ const candidates = [
3330
+ "cloudflared",
3331
+ path.join(process.env.ProgramFiles || "", "cloudflared", "cloudflared.exe"),
3332
+ path.join(process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "cloudflared", "cloudflared.exe"),
3333
+ path.join(os.homedir(), "scoop", "shims", "cloudflared.exe"),
3334
+ "C:\\ProgramData\\chocolatey\\bin\\cloudflared.exe",
3335
+ ];
3336
+ for (const c of candidates) {
3337
+ try { if (fs.statSync(c).isFile()) { cfBin = c; installed = true; break; } } catch {}
3338
+ }
3339
+ }
3340
+ if (!installed) {
3341
+ try {
3342
+ const { execSync: es } = await import("child_process");
3343
+ es("cloudflared --version", { stdio: "ignore" });
3344
+ installed = true; cfBin = "cloudflared";
3345
+ } catch {}
3346
+ }
3347
+ return ok(res, { installed, path: installed ? cfBin : null });
3348
+ }
3349
+
3350
+ // POST /tunnel/setup/cf-login — SSE stream of cloudflared tunnel login
3351
+ if (method === "POST" && reqPath === "/tunnel/setup/cf-login") {
3352
+ if (lockedGuard(res)) return;
3353
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", ...CORS });
3354
+ const sendEvt = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
3355
+ try {
3356
+ const { spawn } = await import("child_process");
3357
+ const proc = spawn("cloudflared", ["tunnel", "login"], { stdio: ["ignore","pipe","pipe"] });
3358
+ proc.stdout.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l })));
3359
+ proc.stderr.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l })));
3360
+ proc.on("close", code => { sendEvt({ done: true, code }); res.end(); });
3361
+ req.on("close", () => { try { proc.kill(); } catch {} });
3362
+ } catch (err) {
3363
+ sendEvt({ done: true, code: 1, error: err.message });
3364
+ res.end();
3365
+ }
3366
+ }
3367
+
3368
+ // POST /tunnel/setup/cf-create — SSE stream create + route DNS + save
3369
+ if (method === "POST" && reqPath === "/tunnel/setup/cf-create") {
3370
+ if (lockedGuard(res)) return;
3371
+ let body;
3372
+ try { body = await readBody(req); } catch {
3373
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
3374
+ return res.end(JSON.stringify({ error: "Invalid JSON" }));
3375
+ }
3376
+ const { name, hostname } = body;
3377
+ if (!name || !hostname) {
3378
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
3379
+ return res.end(JSON.stringify({ error: "name and hostname required" }));
3380
+ }
3381
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", ...CORS });
3382
+ const sendEvt = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
3383
+ try {
3384
+ const { spawn } = await import("child_process");
3385
+
3386
+ // Step 1: create tunnel
3387
+ sendEvt({ line: `Creating tunnel "${name}"…`, step: 1 });
3388
+ let tunnelId = null;
3389
+ await new Promise((resolve, reject) => {
3390
+ const proc = spawn("cloudflared", ["tunnel", "create", name], { stdio: ["ignore","pipe","pipe"] });
3391
+ let output = "";
3392
+ proc.stdout.on("data", d => { const s = d.toString(); output += s; s.split("\n").forEach(l => l.trim() && sendEvt({ line: l, step: 1 })); });
3393
+ proc.stderr.on("data", d => { const s = d.toString(); output += s; s.split("\n").forEach(l => l.trim() && sendEvt({ line: l, step: 1 })); });
3394
+ proc.on("close", code => {
3395
+ const m = output.match(/Created tunnel .+ with id ([a-f0-9-]{36})/i);
3396
+ if (m) tunnelId = m[1];
3397
+ if (code !== 0 && !tunnelId) reject(new Error(`create failed (exit ${code})`));
3398
+ else resolve();
3399
+ });
3400
+ });
3401
+
3402
+ if (!tunnelId) {
3403
+ sendEvt({ error: "Could not parse tunnel ID from cloudflared output" });
3404
+ return res.end();
3405
+ }
3406
+
3407
+ // Step 2: route DNS
3408
+ sendEvt({ line: `Routing DNS: ${hostname}…`, step: 2 });
3409
+ await new Promise((resolve) => {
3410
+ const proc = spawn("cloudflared", ["tunnel", "route", "dns", name, hostname], { stdio: ["ignore","pipe","pipe"] });
3411
+ proc.stdout.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l, step: 2 })));
3412
+ proc.stderr.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l, step: 2 })));
3413
+ proc.on("close", () => resolve());
3414
+ });
3415
+
3416
+ // Step 3: save config
3417
+ const cfDir = path.join(os.homedir(), ".cloudflared");
3418
+ if (!fs.existsSync(cfDir)) fs.mkdirSync(cfDir, { recursive: true });
3419
+ const credFile = path.join(cfDir, `${tunnelId}.json`);
3420
+ 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`;
3421
+ fs.writeFileSync(path.join(cfDir, "config.yml"), configContent, "utf8");
3422
+
3423
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
3424
+ const sbKey = api.getAnonKey();
3425
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
3426
+ method: "POST",
3427
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
3428
+ body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(hostname) }),
3429
+ });
3430
+ tunnelHostname = hostname;
3431
+ tunnelUrl = `https://${hostname}`;
3432
+
3433
+ sendEvt({ done: true, tunnelId, hostname });
3434
+ res.end();
3435
+ } catch (err) {
3436
+ sendEvt({ error: err.message, done: true });
3437
+ res.end();
3438
+ }
3439
+ }
3440
+
2382
3441
  // POST /change-pw — change master password (must be unlocked)
2383
3442
  if (method === "POST" && reqPath === "/change-pw") {
2384
3443
  if (lockedGuard(res)) return;
@@ -2537,19 +3596,36 @@ async function verifyAuth(password) {
2537
3596
  }
2538
3597
 
2539
3598
  async function actionStart(opts) {
2540
- const port = parseInt(opts.port || "52437", 10);
3599
+ const isStaged = !!opts.staged || process.env.__CLAUTH_STAGED === "1";
3600
+ const port = isStaged ? STAGED_PORT : parseInt(opts.port || String(LIVE_PORT), 10);
2541
3601
  const password = opts.pw;
2542
3602
  const tunnelHostname = opts.tunnel || null;
2543
3603
  const whitelist = opts.services
2544
3604
  ? opts.services.split(",").map(s => s.trim().toLowerCase())
2545
3605
  : null;
2546
3606
 
2547
- // Check for existing instance
2548
- const existing = readPid();
2549
- if (existing && isProcessAlive(existing.pid)) {
2550
- console.log(chalk.yellow(`\n clauth serve already running (PID ${existing.pid}, port ${existing.port})`));
2551
- console.log(chalk.gray(` Stop it first: clauth serve stop\n`));
2552
- process.exit(1);
3607
+ if (isStaged) {
3608
+ // Staged mode: allow running alongside live instance
3609
+ const existingStaged = readStagedPid();
3610
+ if (existingStaged) {
3611
+ try {
3612
+ const resp = await fetch(`http://127.0.0.1:${existingStaged.port}/ping`);
3613
+ if (resp.ok) {
3614
+ console.log(chalk.yellow(`\n Staged instance already running (PID ${existingStaged.pid}, port ${existingStaged.port})`));
3615
+ console.log(chalk.gray(` Open http://127.0.0.1:${existingStaged.port} to verify, then click Make Live\n`));
3616
+ process.exit(1);
3617
+ }
3618
+ } catch {}
3619
+ removeStagedPid();
3620
+ }
3621
+ } else {
3622
+ // Normal mode: check for existing instance
3623
+ const existing = readPid();
3624
+ if (existing && isProcessAlive(existing.pid)) {
3625
+ console.log(chalk.yellow(`\n clauth serve already running (PID ${existing.pid}, port ${existing.port})`));
3626
+ console.log(chalk.gray(` Stop it first: clauth serve stop\n`));
3627
+ process.exit(1);
3628
+ }
2553
3629
  }
2554
3630
 
2555
3631
  // If we're the daemon child, run the server directly
@@ -2565,10 +3641,15 @@ async function actionStart(opts) {
2565
3641
  }
2566
3642
  }
2567
3643
 
2568
- const server = createServer(password, whitelist, port, tunnelHostname);
3644
+ const server = createServer(password, whitelist, port, tunnelHostname, isStaged);
2569
3645
  server.listen(port, "127.0.0.1", () => {
2570
- writePid(process.pid, port);
2571
- const msg = `[${new Date().toISOString()}] clauth serve started — PID ${process.pid}, port ${port}, services: ${whitelist ? whitelist.join(",") : "all"}\n`;
3646
+ if (isStaged) {
3647
+ writeStagedPid(process.pid, port);
3648
+ } else {
3649
+ writePid(process.pid, port);
3650
+ }
3651
+ const label = isStaged ? "STAGED" : "LIVE";
3652
+ const msg = `[${new Date().toISOString()}] clauth serve started [${label}] — PID ${process.pid}, port ${port}, services: ${whitelist ? whitelist.join(",") : "all"}\n`;
2572
3653
  fs.appendFileSync(LOG_FILE, msg);
2573
3654
  });
2574
3655
 
@@ -2578,7 +3659,10 @@ async function actionStart(opts) {
2578
3659
  process.exit(1);
2579
3660
  });
2580
3661
 
2581
- const shutdown = () => { removePid(); process.exit(0); };
3662
+ const shutdown = () => {
3663
+ if (isStaged) { removeStagedPid(); } else { removePid(); }
3664
+ process.exit(0);
3665
+ };
2582
3666
  process.on("SIGTERM", shutdown);
2583
3667
  process.on("SIGINT", shutdown);
2584
3668
  return;
@@ -2610,12 +3694,13 @@ async function actionStart(opts) {
2610
3694
  if (password) childArgs.push("--pw", password);
2611
3695
  if (opts.services) childArgs.push("--services", opts.services);
2612
3696
  if (tunnelHostname) childArgs.push("--tunnel", tunnelHostname);
3697
+ if (isStaged) childArgs.push("--staged");
2613
3698
 
2614
3699
  const out = fs.openSync(LOG_FILE, "a");
2615
3700
  const child = spawn(process.execPath, childArgs, {
2616
3701
  detached: true,
2617
3702
  stdio: ["ignore", out, out],
2618
- env: { ...process.env, __CLAUTH_DAEMON: "1" },
3703
+ env: { ...process.env, __CLAUTH_DAEMON: "1", ...(isStaged ? { __CLAUTH_STAGED: "1" } : {}) },
2619
3704
  });
2620
3705
  child.unref();
2621
3706
 
@@ -2630,14 +3715,18 @@ async function actionStart(opts) {
2630
3715
  } catch {}
2631
3716
  }
2632
3717
 
2633
- const info = readPid();
3718
+ const readInfo = isStaged ? readStagedPid : readPid;
3719
+ const info = readInfo();
2634
3720
  if (started && info) {
2635
- console.log(chalk.green(`\n 🔐 clauth serve started`));
3721
+ const label = isStaged ? "⚡ STAGED" : "🔐";
3722
+ console.log(chalk.green(`\n ${label} clauth serve started`));
2636
3723
  console.log(chalk.gray(` PID: ${info.pid}`));
2637
3724
  console.log(chalk.gray(` Port: 127.0.0.1:${info.port}`));
2638
3725
  console.log(chalk.gray(` Services: ${whitelist ? whitelist.join(", ") : "all"}`));
2639
3726
  console.log(chalk.gray(` Log: ${LOG_FILE}`));
2640
- if (!password) {
3727
+ if (isStaged) {
3728
+ console.log(chalk.yellow(`\n ⚡ Staged on port ${port} — open dashboard to verify, then click "Make Live"`));
3729
+ } else if (!password) {
2641
3730
  console.log(chalk.cyan(`\n 👉 Open http://127.0.0.1:${info.port} to unlock the vault`));
2642
3731
  }
2643
3732
  console.log(chalk.gray(` Stop: clauth serve stop\n`));
@@ -3428,19 +4517,33 @@ async function installWindows(pw, tunnelHostname, execSync) {
3428
4517
  ].join("\n");
3429
4518
  fs.writeFileSync(psScriptPath, psScript, "utf8");
3430
4519
  } 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
4520
+ // ── Adaptive mode: check for boot.key on each restart ──
4521
+ // If boot.key exists (auto-sealed after browser unlock), decrypt and start unlocked.
4522
+ // If boot.key missing (user clicked Stop, or first run), start locked + open browser.
3435
4523
  const psScript = [
3436
- "# clauth autostart + watchdog (locked mode browser password entry)",
3437
- "# Starts daemon locked, opens browser for password. Restarts on crash every 15s.",
4524
+ "# clauth autostart + watchdog (adaptive: sealed if boot.key exists, locked otherwise)",
4525
+ "# Restarts on crash every 15s. Auto-unlock if DPAPI boot.key available.",
4526
+ "",
4527
+ `$bootKey = '${bootKey}'`,
3438
4528
  "",
3439
4529
  "while ($true) {",
3440
4530
  " try {",
3441
4531
  " $ping = Invoke-RestMethod -Uri 'http://127.0.0.1:52437/ping' -TimeoutSec 3 -ErrorAction Stop",
3442
4532
  " } catch {",
3443
- ` Start-Process '${nodeExe}' -ArgumentList "'${cliEntry}' serve start${tunnelArg}" -WindowStyle Hidden`,
4533
+ " if (Test-Path $bootKey) {",
4534
+ " # boot.key exists — decrypt via DPAPI and start unlocked",
4535
+ " try {",
4536
+ " $enc = (Get-Content $bootKey -Raw).Trim()",
4537
+ " $pw = [Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($enc),$null,'CurrentUser'))",
4538
+ ` Start-Process '${nodeExe}' -ArgumentList "'${cliEntry}' serve start -p $pw${tunnelArg}" -WindowStyle Hidden`,
4539
+ " } catch {",
4540
+ " # DPAPI decrypt failed — start locked",
4541
+ ` Start-Process '${nodeExe}' -ArgumentList "'${cliEntry}' serve start${tunnelArg}" -WindowStyle Hidden`,
4542
+ " }",
4543
+ " } else {",
4544
+ " # No boot.key — start locked, user enters password in browser",
4545
+ ` Start-Process '${nodeExe}' -ArgumentList "'${cliEntry}' serve start${tunnelArg}" -WindowStyle Hidden`,
4546
+ " }",
3444
4547
  " Start-Sleep -Seconds 5",
3445
4548
  " }",
3446
4549
  " Start-Sleep -Seconds 15",
@@ -3449,20 +4552,35 @@ async function installWindows(pw, tunnelHostname, execSync) {
3449
4552
  fs.writeFileSync(psScriptPath, psScript, "utf8");
3450
4553
  }
3451
4554
 
3452
- // Register Windows Scheduled Task triggers on user logon
4555
+ // Register auto-start try HKCU\Run first (no admin), fall back to Scheduled Task
3453
4556
  const mode = pw ? "sealed — fully unattended" : "locked — browser password entry";
3454
- const spinner2 = ora(`Registering Scheduled Task (${mode})...`).start();
4557
+ const spinner2 = ora(`Registering auto-start (${mode})...`).start();
4558
+ const psScriptEsc = psScriptPath.replace(/\\/g, "\\\\");
4559
+ const regValue = `powershell.exe -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${psScriptEsc}"`;
4560
+ let autoStartOk = false;
4561
+
4562
+ // Method 1: HKCU\Run registry key (no elevation needed)
3455
4563
  try {
3456
- const psScriptEsc = psScriptPath.replace(/\\/g, "\\\\");
3457
- const args = `-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${psScriptEsc}"`;
3458
4564
  execSync(
3459
- `${process.env.SystemRoot || "C:\\\\Windows"}\\\\System32\\\\schtasks.exe /create /f /tn "${TASK_NAME}" /sc onlogon /tr "powershell.exe ${args}"`,
4565
+ `reg add "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" /v "${TASK_NAME}" /t REG_SZ /d "${regValue}" /f`,
3460
4566
  { encoding: "utf8", stdio: "pipe" }
3461
4567
  );
3462
- spinner2.succeed(chalk.green(`Scheduled Task "${TASK_NAME}" registered`));
3463
- } catch (err) {
3464
- spinner2.fail(chalk.yellow(`Scheduled task failed (non-fatal): ${err.message}`));
3465
- console.log(chalk.gray(" You can still start manually: clauth serve start"));
4568
+ spinner2.succeed(chalk.green(`Registry auto-start registered (HKCU\\Run)`));
4569
+ autoStartOk = true;
4570
+ } catch {
4571
+ // Method 2: Scheduled Task (needs admin)
4572
+ try {
4573
+ const args = `-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${psScriptEsc}"`;
4574
+ execSync(
4575
+ `${process.env.SystemRoot || "C:\\\\Windows"}\\\\System32\\\\schtasks.exe /create /f /tn "${TASK_NAME}" /sc onlogon /tr "powershell.exe ${args}"`,
4576
+ { encoding: "utf8", stdio: "pipe" }
4577
+ );
4578
+ spinner2.succeed(chalk.green(`Scheduled Task "${TASK_NAME}" registered`));
4579
+ autoStartOk = true;
4580
+ } catch (err) {
4581
+ spinner2.fail(chalk.yellow(`Auto-start registration failed (non-fatal): ${err.message}`));
4582
+ console.log(chalk.gray(" You can still start manually: clauth serve start"));
4583
+ }
3466
4584
  }
3467
4585
 
3468
4586
  // Start the daemon now
@@ -3711,7 +4829,13 @@ async function uninstallWindows(execSync) {
3711
4829
  const bootKeyPath = path.join(autostartDir, "boot.key");
3712
4830
  const psScriptPath = path.join(autostartDir, "autostart.ps1");
3713
4831
 
3714
- // Remove scheduled task
4832
+ // Remove HKCU\Run registry key
4833
+ try {
4834
+ execSync(`reg delete "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" /v "${TASK_NAME}" /f`, { encoding: "utf8", stdio: "pipe" });
4835
+ console.log(chalk.green(` Removed Registry auto-start: ${TASK_NAME}`));
4836
+ } catch { console.log(chalk.gray(` Registry key not found (already removed): ${TASK_NAME}`)); }
4837
+
4838
+ // Remove scheduled task (legacy)
3715
4839
  try {
3716
4840
  execSync(`${process.env.SystemRoot || "C:\\\\Windows"}\\\\System32\\\\schtasks.exe /delete /f /tn "${TASK_NAME}"`, { encoding: "utf8", stdio: "pipe" });
3717
4841
  console.log(chalk.green(` Removed Scheduled Task: ${TASK_NAME}`));
@@ -3789,6 +4913,53 @@ async function uninstallLinux(execSync) {
3789
4913
  }
3790
4914
 
3791
4915
  // ── Export ────────────────────────────────────────────────────
4916
+ // ── Blue-green upgrade: npm update + start staged + open dashboard ──
4917
+ async function actionUpgrade(opts) {
4918
+ console.log(chalk.cyan("\n ⚡ Blue-green upgrade\n"));
4919
+
4920
+ // Step 1: Check current live version
4921
+ const liveInfo = readPid();
4922
+ let liveVersion = "unknown";
4923
+ if (liveInfo) {
4924
+ try {
4925
+ const resp = await fetch(`http://127.0.0.1:${liveInfo.port}/ping`);
4926
+ const data = await resp.json();
4927
+ liveVersion = data.app_version || "unknown";
4928
+ } catch {}
4929
+ }
4930
+ console.log(chalk.gray(` Live version: v${liveVersion} (port ${liveInfo?.port || LIVE_PORT})`));
4931
+
4932
+ // Step 2: npm update
4933
+ const spinner = ora("Updating @lifeaitools/clauth from npm...").start();
4934
+ try {
4935
+ execSyncTop("npm install -g @lifeaitools/clauth@latest", { encoding: "utf8", stdio: "pipe", timeout: 60000 });
4936
+ // Read new version from installed package
4937
+ const newPkg = JSON.parse(fs.readFileSync(
4938
+ path.join(path.dirname(process.execPath), "..", "lib", "node_modules", "@lifeaitools", "clauth", "package.json"), "utf8"
4939
+ ).toString());
4940
+ spinner.succeed(chalk.green(`Updated to v${newPkg.version}`));
4941
+ console.log(chalk.gray(` Staged version: v${newPkg.version} (port ${STAGED_PORT})`));
4942
+ } catch (err) {
4943
+ // Try alternate npm global path (Windows)
4944
+ try {
4945
+ const npmGlobal = path.join(os.homedir(), "AppData", "Roaming", "npm");
4946
+ const newPkg = JSON.parse(fs.readFileSync(
4947
+ path.join(npmGlobal, "node_modules", "@lifeaitools", "clauth", "package.json"), "utf8"
4948
+ ).toString());
4949
+ spinner.succeed(chalk.green(`Updated to v${newPkg.version}`));
4950
+ } catch {
4951
+ spinner.fail(chalk.yellow(`npm update may have failed: ${err.message}`));
4952
+ console.log(chalk.gray(" Continuing with staged start anyway..."));
4953
+ }
4954
+ }
4955
+
4956
+ // Step 3: Start staged instance
4957
+ console.log(chalk.gray(`\n Starting staged instance on port ${STAGED_PORT}...`));
4958
+ opts.staged = true;
4959
+ opts.port = String(STAGED_PORT);
4960
+ return actionStart(opts);
4961
+ }
4962
+
3792
4963
  export async function runServe(opts) {
3793
4964
  const action = opts.action || "foreground";
3794
4965
 
@@ -3801,9 +4972,10 @@ export async function runServe(opts) {
3801
4972
  case "mcp": return actionMcp(opts);
3802
4973
  case "install": return actionInstall(opts);
3803
4974
  case "uninstall": return actionUninstall();
4975
+ case "upgrade": return actionUpgrade(opts);
3804
4976
  default:
3805
4977
  console.log(chalk.red(`\n Unknown serve action: ${action}`));
3806
- console.log(chalk.gray(" Actions: start | stop | restart | ping | foreground | mcp | install | uninstall\n"));
4978
+ console.log(chalk.gray(" Actions: start | stop | restart | ping | foreground | mcp | install | uninstall | upgrade\n"));
3807
4979
  process.exit(1);
3808
4980
  }
3809
4981
  }