@lifeaitools/clauth 1.1.0 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -280,6 +280,40 @@ function dashboardHtml(port, whitelist) {
280
280
  color: rgba(255,255,255,0.6);
281
281
  font-size: 12px;
282
282
  }
283
+ /* Tunnel setup wizard */
284
+ .wizard-panel{background:#0a1628;border:1px solid #1e3a5f;border-radius:10px;padding:1.5rem;margin-bottom:1.25rem;display:none}
285
+ .wizard-panel.open{display:block}
286
+ .wizard-header{display:flex;align-items:center;gap:10px;margin-bottom:1.25rem}
287
+ .wizard-title{font-size:1rem;font-weight:600;color:#f8fafc;flex:1}
288
+ .wizard-steps{display:flex;gap:6px;margin-bottom:1.5rem;flex-wrap:wrap}
289
+ .wstep{padding:4px 10px;border-radius:20px;font-size:.72rem;font-weight:600;border:1px solid #1e3a5f;color:#475569;background:#0f172a}
290
+ .wstep.active{border-color:#3b82f6;color:#60a5fa;background:rgba(59,130,246,.1)}
291
+ .wstep.done{border-color:#166534;color:#4ade80;background:rgba(74,222,128,.08)}
292
+ .wizard-body{min-height:80px}
293
+ .wizard-foot{display:flex;gap:8px;margin-top:1.25rem;align-items:center}
294
+ .btn-wiz-primary{background:#3b82f6;color:#fff;padding:8px 20px;font-size:.875rem;border-radius:7px;border:none;cursor:pointer;font-weight:600}
295
+ .btn-wiz-primary:hover{background:#2563eb}
296
+ .btn-wiz-primary:disabled{opacity:.4;cursor:not-allowed}
297
+ .btn-wiz-secondary{background:#1e293b;color:#94a3b8;padding:8px 16px;font-size:.875rem;border-radius:7px;border:1px solid #334155;cursor:pointer}
298
+ .btn-wiz-secondary:hover{color:#e2e8f0}
299
+ .wiz-input{width:100%;background:#0f172a;border:1px solid #334155;border-radius:6px;color:#e2e8f0;font-family:'Courier New',monospace;font-size:.9rem;padding:9px 12px;outline:none;margin-top:8px;transition:border-color .2s}
300
+ .wiz-input:focus{border-color:#3b82f6}
301
+ .wiz-label{font-size:.8rem;color:#64748b;margin-bottom:4px}
302
+ .wiz-msg{font-size:.82rem;margin-left:auto}
303
+ .wiz-msg.ok{color:#4ade80}.wiz-msg.fail{color:#f87171}
304
+ .wiz-log{background:#030712;border:1px solid #1e293b;border-radius:6px;padding:10px;font-family:'Courier New',monospace;font-size:.75rem;color:#6ee7b7;max-height:160px;overflow-y:auto;margin-top:10px;white-space:pre-wrap}
305
+ .wiz-tunnel-list{display:flex;flex-direction:column;gap:8px;margin-top:10px}
306
+ .wiz-tunnel-item{display:flex;align-items:center;gap:10px;padding:10px 12px;background:#0f172a;border:1px solid #1e3a5f;border-radius:6px;cursor:pointer;transition:border-color .15s}
307
+ .wiz-tunnel-item:hover,.wiz-tunnel-item.selected{border-color:#3b82f6;background:rgba(59,130,246,.08)}
308
+ .wiz-tunnel-name{font-weight:600;color:#f8fafc;font-size:.9rem;flex:1}
309
+ .wiz-tunnel-id{font-family:'Courier New',monospace;font-size:.72rem;color:#475569}
310
+ .wiz-tunnel-status{font-size:.72rem;padding:2px 7px;border-radius:4px;background:rgba(74,222,128,.1);color:#4ade80;border:1px solid rgba(74,222,128,.2)}
311
+ .wiz-desc{font-size:.85rem;color:#94a3b8;line-height:1.6;margin-bottom:.75rem}
312
+ .wiz-link{color:#60a5fa;text-decoration:none;font-size:.82rem}
313
+ .wiz-link:hover{text-decoration:underline}
314
+ .wiz-test-result{padding:12px 14px;border-radius:8px;font-size:.85rem;margin-top:10px;display:none}
315
+ .wiz-test-result.ok{background:rgba(74,222,128,.08);border:1px solid rgba(74,222,128,.2);color:#4ade80}
316
+ .wiz-test-result.fail{background:rgba(248,113,113,.08);border:1px solid rgba(248,113,113,.2);color:#f87171}
283
317
  </style>
284
318
  </head>
285
319
  <body>
@@ -441,6 +475,17 @@ function dashboardHtml(port, whitelist) {
441
475
  <div style="font-size:.72rem;color:#64748b;margin-top:4px">Paste these into <a href="https://claude.ai/settings/integrations" target="_blank" style="color:#60a5fa">claude.ai Settings → Integrations</a></div>
442
476
  </div>
443
477
 
478
+ <div class="wizard-panel" id="wizard-panel">
479
+ <div class="wizard-header">
480
+ <div class="tunnel-dot off" id="wiz-dot" style="width:10px;height:10px;border-radius:50%"></div>
481
+ <div class="wizard-title">claude.ai Tunnel Setup</div>
482
+ <button class="btn-cancel" onclick="closeSetupWizard()">✕ Dismiss</button>
483
+ </div>
484
+ <div class="wizard-steps" id="wizard-steps"></div>
485
+ <div class="wizard-body" id="wizard-body"><p class="loading">Checking setup state…</p></div>
486
+ <div class="wizard-foot" id="wizard-foot"></div>
487
+ </div>
488
+
444
489
  <div id="project-tabs" class="project-tabs" style="display:none"></div>
445
490
  <div id="grid" class="grid"><p class="loading">Loading services…</p></div>
446
491
  <div class="footer">localhost:${port} · 127.0.0.1 only · 10-strike lockout</div>
@@ -1193,9 +1238,7 @@ async function toggleTunnel(action) {
1193
1238
  } catch {}
1194
1239
  }
1195
1240
 
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
- }
1241
+ function runTunnelSetup() { openSetupWizard(); }
1199
1242
 
1200
1243
  async function testTunnel() {
1201
1244
  const liveState = document.querySelector("#tunnel-panel .tunnel-state.live");
@@ -1267,6 +1310,423 @@ function copyMcp(elId) {
1267
1310
  }).catch(() => {});
1268
1311
  }
1269
1312
 
1313
+ // ── Tunnel Setup Wizard ─────────────────────
1314
+ let wizStep = null;
1315
+ let wizData = {};
1316
+
1317
+ async function openSetupWizard() {
1318
+ const panel = document.getElementById("wizard-panel");
1319
+ panel.classList.add("open");
1320
+ panel.scrollIntoView({ behavior: "smooth", block: "start" });
1321
+ wizStep = "loading";
1322
+ renderWizBody('<p class="loading">Checking setup state…</p>', [], []);
1323
+
1324
+ const state = await apiFetch("/tunnel/setup-state");
1325
+ if (!state) {
1326
+ renderWizBody('<p class="wiz-desc" style="color:#f87171">Could not reach daemon.</p>', [], []);
1327
+ return;
1328
+ }
1329
+
1330
+ if (state.step === "ready") {
1331
+ await apiFetch("/tunnel/start", { method: "POST" });
1332
+ closeSetupWizard();
1333
+ return;
1334
+ }
1335
+
1336
+ wizData = state;
1337
+
1338
+ if (state.step === "need_cf_token") wizShowCfToken();
1339
+ else if (state.step === "pick_tunnel") wizShowPickTunnel(state.tunnels);
1340
+ else if (state.step === "no_tunnels") wizShowCreateViaCfApi(state.accountId);
1341
+ else wizShowInstallCheck(); // no CF token — needs cloudflared login
1342
+ }
1343
+
1344
+ function closeSetupWizard() {
1345
+ document.getElementById("wizard-panel").classList.remove("open");
1346
+ wizStep = null; wizData = {};
1347
+ }
1348
+
1349
+ function renderWizBody(html, steps, footHtml) {
1350
+ document.getElementById("wizard-body").innerHTML = html;
1351
+
1352
+ const stepLabels = ["CF Token","Tunnel","cloudflared","MCP Setup","Test"];
1353
+ const stepsEl = document.getElementById("wizard-steps");
1354
+ stepsEl.innerHTML = stepLabels.map((label, i) => {
1355
+ const stepKeys = ["need_cf_token","pick_tunnel","check_cf","mcp_setup","test"];
1356
+ const currentIdx = stepKeys.indexOf(wizStep);
1357
+ let cls = "wstep";
1358
+ if (i < currentIdx) cls += " done";
1359
+ else if (i === currentIdx) cls += " active";
1360
+ return \`<div class="\${cls}">\${i < currentIdx ? "✓ " : ""}\${label}</div>\`;
1361
+ }).join("");
1362
+
1363
+ document.getElementById("wizard-foot").innerHTML = footHtml.join("");
1364
+ }
1365
+
1366
+ // Step: Enter CF API token
1367
+ function wizShowCfToken() {
1368
+ wizStep = "need_cf_token";
1369
+ renderWizBody(\`
1370
+ <div class="wiz-desc">A Cloudflare API token is needed to check for existing tunnels and configure new ones.</div>
1371
+ <div class="wiz-label">Cloudflare API Token</div>
1372
+ <input class="wiz-input" id="wiz-cf-token-input" type="password" placeholder="Paste your CF API token…" autocomplete="off" spellcheck="false">
1373
+ <div style="margin-top:8px">
1374
+ <a class="wiz-link" href="https://dash.cloudflare.com/profile/api-tokens" target="_blank">↗ Create token at Cloudflare Dashboard</a>
1375
+ <span style="color:#475569;font-size:.78rem;margin-left:8px">(needs Cloudflare Tunnel: Edit permission)</span>
1376
+ </div>
1377
+ <div class="wiz-msg fail" id="wiz-cf-err" style="display:none;margin-top:8px"></div>
1378
+ \`, [], [
1379
+ \`<button class="btn-wiz-primary" id="wiz-cf-btn" onclick="wizSubmitCfToken()">Verify &amp; Save Token</button>\`,
1380
+ \`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`,
1381
+ \`<span class="wiz-msg" id="wiz-cf-msg"></span>\`
1382
+ ]);
1383
+
1384
+ document.getElementById("wiz-cf-token-input").addEventListener("keydown", e => {
1385
+ if (e.key === "Enter") wizSubmitCfToken();
1386
+ });
1387
+ }
1388
+
1389
+ async function wizSubmitCfToken() {
1390
+ const input = document.getElementById("wiz-cf-token-input");
1391
+ const btn = document.getElementById("wiz-cf-btn");
1392
+ const errEl = document.getElementById("wiz-cf-err");
1393
+ const token = input.value.trim();
1394
+ if (!token) return;
1395
+
1396
+ btn.disabled = true; btn.textContent = "Verifying…";
1397
+ if (errEl) errEl.style.display = "none";
1398
+
1399
+ const r = await fetch(BASE + "/tunnel/setup/cf-token", {
1400
+ method: "POST",
1401
+ headers: { "Content-Type": "application/json" },
1402
+ body: JSON.stringify({ token }),
1403
+ }).then(r => r.json()).catch(() => null);
1404
+
1405
+ btn.disabled = false; btn.textContent = "Verify & Save Token";
1406
+
1407
+ if (!r || r.error) {
1408
+ if (errEl) { errEl.textContent = r?.error || "Request failed"; errEl.style.display = "block"; }
1409
+ return;
1410
+ }
1411
+
1412
+ wizData.accountId = r.accountId;
1413
+ const tunnelState = await apiFetch("/tunnel/setup-state");
1414
+ if (tunnelState?.step === "pick_tunnel") wizShowPickTunnel(tunnelState.tunnels);
1415
+ else wizShowInstallCheck();
1416
+ }
1417
+
1418
+ // Step: Pick existing tunnel
1419
+ function wizShowPickTunnel(tunnels) {
1420
+ wizStep = "pick_tunnel";
1421
+
1422
+ const listHtml = tunnels.map(t => \`
1423
+ <div class="wiz-tunnel-item" id="wti-\${t.id}" onclick="wizSelectTunnel('\${t.id}','\${t.name}',this)">
1424
+ <div style="flex:1">
1425
+ <div class="wiz-tunnel-name">\${t.name}</div>
1426
+ <div class="wiz-tunnel-id">\${t.id}</div>
1427
+ </div>
1428
+ <div class="wiz-tunnel-status">\${t.status || "active"}</div>
1429
+ </div>
1430
+ \`).join("");
1431
+
1432
+ renderWizBody(\`
1433
+ <div class="wiz-desc">Found \${tunnels.length} existing Cloudflare tunnel\${tunnels.length !== 1 ? "s" : ""}. Select one to use, or create a new one.</div>
1434
+ <div class="wiz-tunnel-list">\${listHtml}</div>
1435
+ <div style="margin-top:12px">
1436
+ <div class="wiz-label">Public hostname for selected tunnel (e.g. clauth.yourdomain.com)</div>
1437
+ <input class="wiz-input" id="wiz-hostname-input" type="text" placeholder="clauth.yourdomain.com" spellcheck="false" autocomplete="off">
1438
+ </div>
1439
+ <div class="wiz-msg fail" id="wiz-pick-err" style="display:none;margin-top:8px"></div>
1440
+ \`, [], [
1441
+ \`<button class="btn-wiz-primary" id="wiz-pick-btn" onclick="wizSaveTunnel()">Use Selected Tunnel</button>\`,
1442
+ \`<button class="btn-wiz-secondary" onclick="wizShowInstallCheck()">Create New Tunnel Instead</button>\`,
1443
+ \`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`
1444
+ ]);
1445
+
1446
+ window._wizSelectedTunnel = null;
1447
+ }
1448
+
1449
+ function wizSelectTunnel(id, name, el) {
1450
+ document.querySelectorAll(".wiz-tunnel-item").forEach(e => e.classList.remove("selected"));
1451
+ el.classList.add("selected");
1452
+ window._wizSelectedTunnel = { id, name };
1453
+ }
1454
+
1455
+ async function wizSaveTunnel() {
1456
+ const sel = window._wizSelectedTunnel;
1457
+ const hostname = document.getElementById("wiz-hostname-input")?.value.trim();
1458
+ const errEl = document.getElementById("wiz-pick-err");
1459
+ const btn = document.getElementById("wiz-pick-btn");
1460
+
1461
+ if (!sel) { if (errEl) { errEl.textContent = "Select a tunnel first"; errEl.style.display="block"; } return; }
1462
+ if (!hostname) { if (errEl) { errEl.textContent = "Enter a public hostname"; errEl.style.display="block"; } return; }
1463
+
1464
+ btn.disabled = true; btn.textContent = "Saving…";
1465
+
1466
+ const r = await fetch(BASE + "/tunnel/setup/cf-save", {
1467
+ method: "POST",
1468
+ headers: { "Content-Type": "application/json" },
1469
+ body: JSON.stringify({ tunnelId: sel.id, tunnelName: sel.name, hostname }),
1470
+ }).then(r => r.json()).catch(() => null);
1471
+
1472
+ btn.disabled = false; btn.textContent = "Use Selected Tunnel";
1473
+
1474
+ if (!r?.ok) {
1475
+ if (errEl) { errEl.textContent = r?.error || "Save failed"; errEl.style.display="block"; }
1476
+ return;
1477
+ }
1478
+
1479
+ await apiFetch("/tunnel/start", { method: "POST" });
1480
+ wizShowMcpSetup(hostname);
1481
+ }
1482
+
1483
+ // Step: Create tunnel via CF API (CF token already in vault — no cloudflared login needed)
1484
+ function wizShowCreateViaCfApi(accountId) {
1485
+ wizStep = "pick_tunnel";
1486
+ wizData.accountId = accountId;
1487
+ renderWizBody(\`
1488
+ <div class="wiz-desc">No existing tunnels found. Create a new one using your Cloudflare API token.</div>
1489
+ <div class="wiz-label">Tunnel name</div>
1490
+ <input class="wiz-input" id="wiz-api-tname" type="text" value="clauth" spellcheck="false">
1491
+ <div class="wiz-label" style="margin-top:10px">Public hostname (e.g. clauth.yourdomain.com)</div>
1492
+ <input class="wiz-input" id="wiz-api-hostname" type="text" placeholder="clauth.yourdomain.com" spellcheck="false">
1493
+ <div class="wiz-msg fail" id="wiz-api-err" style="display:none;margin-top:8px"></div>
1494
+ \`, [], [
1495
+ \`<button class="btn-wiz-primary" id="wiz-api-btn" onclick="wizRunCreateViaCfApi()">Create Tunnel</button>\`,
1496
+ \`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`,
1497
+ \`<span class="wiz-msg" id="wiz-api-msg"></span>\`
1498
+ ]);
1499
+ }
1500
+
1501
+ async function wizRunCreateViaCfApi() {
1502
+ const name = document.getElementById("wiz-api-tname")?.value.trim();
1503
+ const hostname = document.getElementById("wiz-api-hostname")?.value.trim();
1504
+ const btn = document.getElementById("wiz-api-btn");
1505
+ const err = document.getElementById("wiz-api-err");
1506
+ const msg = document.getElementById("wiz-api-msg");
1507
+ if (!name || !hostname) { if(err){err.textContent="Name and hostname required";err.style.display="block";} return; }
1508
+ btn.disabled=true; btn.textContent="Creating…"; if(err) err.style.display="none";
1509
+ if(msg) msg.textContent="Calling Cloudflare API…";
1510
+ const r = await fetch(BASE + "/tunnel/setup/cf-create-api", {
1511
+ method: "POST",
1512
+ headers: { "Content-Type": "application/json" },
1513
+ body: JSON.stringify({ name, hostname, accountId: wizData.accountId }),
1514
+ }).then(r => r.json()).catch(() => null);
1515
+ btn.disabled=false; btn.textContent="Create Tunnel";
1516
+ if (!r?.ok) {
1517
+ if(err){err.textContent = r?.error || "Creation failed"; err.style.display="block";}
1518
+ if(msg) msg.textContent="";
1519
+ return;
1520
+ }
1521
+ await apiFetch("/tunnel/start", { method: "POST" });
1522
+ wizShowMcpSetup(hostname);
1523
+ }
1524
+
1525
+ // Step: cloudflared install check (for new tunnel creation flow)
1526
+ async function wizShowInstallCheck() {
1527
+ wizStep = "check_cf";
1528
+ renderWizBody('<p class="loading">Checking cloudflared…</p>', [], []);
1529
+
1530
+ const r = await apiFetch("/tunnel/setup/check-cloudflared");
1531
+
1532
+ if (r?.installed) {
1533
+ wizShowCfLogin();
1534
+ } else {
1535
+ renderWizBody(\`
1536
+ <div class="wiz-desc">cloudflared is required to create and run Cloudflare tunnels. It is not currently installed.</div>
1537
+ <div style="display:flex;gap:12px;margin-top:12px;flex-wrap:wrap">
1538
+ <a class="btn-wiz-secondary" href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/" target="_blank" style="text-decoration:none">↗ Download cloudflared</a>
1539
+ <span style="color:#475569;font-size:.8rem;align-self:center">or: <code style="color:#60a5fa">winget install Cloudflare.cloudflared</code></span>
1540
+ </div>
1541
+ <div class="wiz-desc" style="margin-top:12px;font-size:.78rem;color:#475569">After installing, click "Check Again".</div>
1542
+ \`, [], [
1543
+ \`<button class="btn-wiz-primary" onclick="wizShowInstallCheck()">Check Again</button>\`,
1544
+ \`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`
1545
+ ]);
1546
+ }
1547
+ }
1548
+
1549
+ // Step: cloudflared login (Cloudflare auth via browser)
1550
+ function wizShowCfLogin() {
1551
+ wizStep = "check_cf";
1552
+ renderWizBody(\`
1553
+ <div class="wiz-desc">Authenticate cloudflared with your Cloudflare account. This opens a browser window.</div>
1554
+ <div class="wiz-log" id="wiz-login-log" style="display:none"></div>
1555
+ \`, [], [
1556
+ \`<button class="btn-wiz-primary" id="wiz-login-btn" onclick="wizRunCfLogin()">Open Browser to Authenticate</button>\`,
1557
+ \`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`
1558
+ ]);
1559
+ }
1560
+
1561
+ async function wizRunCfLogin() {
1562
+ const btn = document.getElementById("wiz-login-btn");
1563
+ const log = document.getElementById("wiz-login-log");
1564
+ btn.disabled = true; btn.textContent = "Authenticating…";
1565
+ log.style.display = "block";
1566
+
1567
+ try {
1568
+ const resp = await fetch(BASE + "/tunnel/setup/cf-login", { method: "POST" });
1569
+ const reader = resp.body.getReader();
1570
+ const dec = new TextDecoder();
1571
+ let buf = "";
1572
+ while (true) {
1573
+ const { done, value } = await reader.read();
1574
+ if (done) break;
1575
+ buf += dec.decode(value, { stream: true });
1576
+ const lines = buf.split("\\n");
1577
+ buf = lines.pop();
1578
+ for (const line of lines) {
1579
+ if (!line.startsWith("data:")) continue;
1580
+ try {
1581
+ const d = JSON.parse(line.slice(5).trim());
1582
+ if (d.line !== undefined) { log.textContent += d.line + "\\n"; log.scrollTop = log.scrollHeight; }
1583
+ if (d.done) {
1584
+ if (d.code === 0) wizShowCreateTunnel();
1585
+ else { btn.disabled=false; btn.textContent="Retry"; }
1586
+ return;
1587
+ }
1588
+ } catch {}
1589
+ }
1590
+ }
1591
+ } catch(e) {
1592
+ log.textContent += "Error: " + e.message;
1593
+ btn.disabled = false; btn.textContent = "Retry";
1594
+ }
1595
+ }
1596
+
1597
+ // Step: Create new tunnel
1598
+ function wizShowCreateTunnel() {
1599
+ wizStep = "check_cf";
1600
+ renderWizBody(\`
1601
+ <div class="wiz-desc">Create a new named Cloudflare tunnel. Give it a name and a public hostname.</div>
1602
+ <div class="wiz-label">Tunnel name (e.g. clauth)</div>
1603
+ <input class="wiz-input" id="wiz-tname" type="text" value="clauth" spellcheck="false">
1604
+ <div class="wiz-label" style="margin-top:10px">Public hostname (e.g. clauth.yourdomain.com)</div>
1605
+ <input class="wiz-input" id="wiz-thostname" type="text" placeholder="clauth.yourdomain.com" spellcheck="false">
1606
+ <div class="wiz-log" id="wiz-create-log" style="display:none;margin-top:10px"></div>
1607
+ <div class="wiz-msg fail" id="wiz-create-err" style="display:none;margin-top:8px"></div>
1608
+ \`, [], [
1609
+ \`<button class="btn-wiz-primary" id="wiz-create-btn" onclick="wizRunCreateTunnel()">Create Tunnel</button>\`,
1610
+ \`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Postpone</button>\`
1611
+ ]);
1612
+ }
1613
+
1614
+ async function wizRunCreateTunnel() {
1615
+ const name = document.getElementById("wiz-tname")?.value.trim();
1616
+ const hostname = document.getElementById("wiz-thostname")?.value.trim();
1617
+ const btn = document.getElementById("wiz-create-btn");
1618
+ const log = document.getElementById("wiz-create-log");
1619
+ const err = document.getElementById("wiz-create-err");
1620
+
1621
+ if (!name || !hostname) { if(err){err.textContent="Name and hostname required";err.style.display="block";} return; }
1622
+
1623
+ btn.disabled=true; btn.textContent="Creating…";
1624
+ log.style.display="block"; err.style.display="none";
1625
+
1626
+ try {
1627
+ const resp = await fetch(BASE + "/tunnel/setup/cf-create", {
1628
+ method: "POST",
1629
+ headers: { "Content-Type": "application/json" },
1630
+ body: JSON.stringify({ name, hostname }),
1631
+ });
1632
+ const reader = resp.body.getReader();
1633
+ const dec = new TextDecoder();
1634
+ let buf = "";
1635
+ while (true) {
1636
+ const { done, value } = await reader.read();
1637
+ if (done) break;
1638
+ buf += dec.decode(value, { stream: true });
1639
+ const lines = buf.split("\\n");
1640
+ buf = lines.pop();
1641
+ for (const line of lines) {
1642
+ if (!line.startsWith("data:")) continue;
1643
+ try {
1644
+ const d = JSON.parse(line.slice(5).trim());
1645
+ if (d.line !== undefined) { log.textContent += d.line + "\\n"; log.scrollTop = log.scrollHeight; }
1646
+ if (d.done && d.hostname) {
1647
+ await apiFetch("/tunnel/start", { method: "POST" });
1648
+ wizShowMcpSetup(d.hostname);
1649
+ return;
1650
+ }
1651
+ if (d.error) { err.textContent = d.error; err.style.display="block"; btn.disabled=false; btn.textContent="Retry"; return; }
1652
+ } catch {}
1653
+ }
1654
+ }
1655
+ } catch(e) {
1656
+ if(err){err.textContent=e.message;err.style.display="block";}
1657
+ btn.disabled=false; btn.textContent="Retry";
1658
+ }
1659
+ }
1660
+
1661
+ // Step: MCP setup in claude.ai
1662
+ async function wizShowMcpSetup(hostname) {
1663
+ wizStep = "mcp_setup";
1664
+ const sseUrl = \`https://\${hostname}/sse\`;
1665
+
1666
+ const mcpData = await apiFetch("/mcp-setup");
1667
+
1668
+ renderWizBody(\`
1669
+ <div class="wiz-desc">Add clauth as an MCP server in claude.ai settings. Paste these values:</div>
1670
+ <div style="display:flex;flex-direction:column;gap:8px;margin-top:10px">
1671
+ <div class="mcp-row">
1672
+ <span class="mcp-label">URL</span>
1673
+ <span class="mcp-val" id="wiz-mcp-url">\${sseUrl}</span>
1674
+ <button class="mcp-copy" onclick="wizCopy('wiz-mcp-url',this)">copy</button>
1675
+ </div>
1676
+ <div class="mcp-row">
1677
+ <span class="mcp-label">Client ID</span>
1678
+ <span class="mcp-val" id="wiz-mcp-cid">\${mcpData?.clientId || '(unlock required)'}</span>
1679
+ <button class="mcp-copy" onclick="wizCopy('wiz-mcp-cid',this)">copy</button>
1680
+ </div>
1681
+ <div class="mcp-row">
1682
+ <span class="mcp-label">Secret</span>
1683
+ <span class="mcp-val" id="wiz-mcp-sec">\${mcpData?.clientSecret || '(unlock required)'}</span>
1684
+ <button class="mcp-copy" onclick="wizCopy('wiz-mcp-sec',this)">copy</button>
1685
+ </div>
1686
+ </div>
1687
+ <div style="margin-top:10px;font-size:.78rem;color:#64748b">
1688
+ Paste into <a class="wiz-link" href="https://claude.ai/settings/integrations" target="_blank">claude.ai → Settings → Integrations</a>
1689
+ </div>
1690
+ \`, [], [
1691
+ \`<button class="btn-wiz-primary" onclick="window.open('https://claude.ai/settings/integrations','_blank');wizShowTest()">I've Added It — Test Now</button>\`,
1692
+ \`<button class="btn-wiz-secondary" onclick="wizShowTest()">Skip Test</button>\`,
1693
+ \`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Done</button>\`
1694
+ ]);
1695
+ }
1696
+
1697
+ function wizCopy(elId, btn) {
1698
+ const val = document.getElementById(elId)?.textContent?.trim();
1699
+ if (!val) return;
1700
+ navigator.clipboard.writeText(val).then(() => {
1701
+ const orig = btn.textContent;
1702
+ btn.textContent = "✓"; btn.classList.add("ok");
1703
+ setTimeout(() => { btn.textContent = orig; btn.classList.remove("ok"); }, 1500);
1704
+ }).catch(() => {});
1705
+ }
1706
+
1707
+ // Step: Test — verify MCP is working
1708
+ function wizShowTest() {
1709
+ wizStep = "test";
1710
+ renderWizBody(\`
1711
+ <div class="wiz-desc">In a new claude.ai chat, ask Claude to run this MCP tool call to verify the connection:</div>
1712
+ <div style="background:#030712;border:1px solid #1e293b;border-radius:6px;padding:12px;margin:10px 0;font-family:'Courier New',monospace;font-size:.82rem;color:#6ee7b7;user-select:all">
1713
+ Use the clauth MCP tool: GET /ping — what is the response?
1714
+ </div>
1715
+ <div class="wiz-desc">Claude should report back: <code style="color:#4ade80">{"status":"ok","version":"..."}</code></div>
1716
+ <div class="wiz-test-result" id="wiz-test-result"></div>
1717
+ \`, [], [
1718
+ \`<button class="btn-wiz-primary" onclick="wizMarkTestDone()">✓ It Worked — Done</button>\`,
1719
+ \`<button class="btn-wiz-secondary" onclick="closeSetupWizard()">Close</button>\`
1720
+ ]);
1721
+ }
1722
+
1723
+ function wizMarkTestDone() {
1724
+ const r = document.getElementById("wiz-test-result");
1725
+ r.className = "wiz-test-result ok"; r.style.display="block";
1726
+ r.textContent = "✓ Tunnel and MCP are working. claude.ai can now access your vault.";
1727
+ document.getElementById("wizard-foot").innerHTML = \`<button class="btn-wiz-primary" onclick="closeSetupWizard()">Close Setup</button>\`;
1728
+ }
1729
+
1270
1730
  // ── Build Status ──────────────────────────────────
1271
1731
  async function updateBuildStatus() {
1272
1732
  try {
@@ -1614,6 +2074,26 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null)
1614
2074
  if (rows.length > 0 && rows[0].value && rows[0].value !== "null") {
1615
2075
  return typeof rows[0].value === "string" ? JSON.parse(rows[0].value) : rows[0].value;
1616
2076
  }
2077
+
2078
+ // DB has no hostname — check ~/.cloudflared/config.yml
2079
+ try {
2080
+ const cfConfig = path.join(os.homedir(), ".cloudflared", "config.yml");
2081
+ if (fs.existsSync(cfConfig)) {
2082
+ const yml = fs.readFileSync(cfConfig, "utf8");
2083
+ const hostMatch = yml.match(/^\s*-\s*hostname:\s*(\S+)/m);
2084
+ if (hostMatch) {
2085
+ const hostname = hostMatch[1];
2086
+ // Save to DB so future loads skip this
2087
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
2088
+ method: "POST",
2089
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
2090
+ body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(hostname) }),
2091
+ }).catch(() => {});
2092
+ return hostname;
2093
+ }
2094
+ }
2095
+ } catch {}
2096
+
1617
2097
  return null;
1618
2098
  } catch {
1619
2099
  return null;
@@ -2379,6 +2859,415 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null)
2379
2859
  }
2380
2860
  }
2381
2861
 
2862
+ // GET /tunnel/setup-state
2863
+ if (method === "GET" && reqPath === "/tunnel/setup-state") {
2864
+ if (lockedGuard(res)) return;
2865
+ try {
2866
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
2867
+ const sbKey = api.getAnonKey();
2868
+
2869
+ // Check for existing hostname in DB
2870
+ const hostnameResp = await fetch(
2871
+ `${sbUrl}/rest/v1/clauth_config?key=eq.tunnel_hostname&select=value`,
2872
+ { headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) }
2873
+ );
2874
+ if (hostnameResp.ok) {
2875
+ const rows = await hostnameResp.json();
2876
+ if (rows.length > 0 && rows[0].value && rows[0].value !== "null" && rows[0].value !== '""') {
2877
+ const hn = typeof rows[0].value === "string" ? JSON.parse(rows[0].value) : rows[0].value;
2878
+ if (hn) return ok(res, { step: "ready", hostname: hn });
2879
+ }
2880
+ }
2881
+
2882
+ // Check ~/.cloudflared/config.yml for a previously configured tunnel
2883
+ try {
2884
+ const cfConfig = path.join(os.homedir(), ".cloudflared", "config.yml");
2885
+ if (fs.existsSync(cfConfig)) {
2886
+ const cfYml = fs.readFileSync(cfConfig, "utf8");
2887
+ // Parse hostname from ingress block: " - hostname: <hostname>"
2888
+ const hostMatch = cfYml.match(/^\s*-\s*hostname:\s*(\S+)/m);
2889
+ const tunnelMatch = cfYml.match(/^tunnel:\s*(\S+)/m);
2890
+ if (hostMatch && tunnelMatch) {
2891
+ const localHostname = hostMatch[1];
2892
+ const localTunnelId = tunnelMatch[1];
2893
+ // Save to DB so next load skips this check
2894
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
2895
+ method: "POST",
2896
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
2897
+ body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(localHostname) }),
2898
+ });
2899
+ tunnelHostname = localHostname;
2900
+ return ok(res, { step: "ready", hostname: localHostname, tunnelId: localTunnelId, source: "local_config" });
2901
+ }
2902
+ }
2903
+ } catch {}
2904
+
2905
+ // Check for CF token in vault
2906
+ let cfToken = null;
2907
+ try {
2908
+ const { token: t, timestamp } = deriveToken(password, machineHash);
2909
+ const cr = await api.retrieve(password, machineHash, t, timestamp, "cloudflare");
2910
+ if (cr?.value) cfToken = cr.value;
2911
+ } catch {}
2912
+
2913
+ if (!cfToken) return ok(res, { step: "need_cf_token" });
2914
+
2915
+ // Get or fetch account ID
2916
+ let accountId = null;
2917
+ try {
2918
+ const acctResp = await fetch(
2919
+ `${sbUrl}/rest/v1/clauth_config?key=eq.cf_account_id&select=value`,
2920
+ { headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) }
2921
+ );
2922
+ if (acctResp.ok) {
2923
+ const rows = await acctResp.json();
2924
+ if (rows.length > 0 && rows[0].value) {
2925
+ accountId = typeof rows[0].value === "string" ? JSON.parse(rows[0].value) : rows[0].value;
2926
+ }
2927
+ }
2928
+ } catch {}
2929
+
2930
+ if (!accountId) {
2931
+ try {
2932
+ const ar = await fetch("https://api.cloudflare.com/client/v4/accounts", {
2933
+ headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000)
2934
+ });
2935
+ const ad = await ar.json();
2936
+ accountId = ad?.result?.[0]?.id;
2937
+ if (accountId) {
2938
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
2939
+ method: "POST",
2940
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
2941
+ body: JSON.stringify({ key: "cf_account_id", value: accountId }),
2942
+ });
2943
+ }
2944
+ } catch {}
2945
+ }
2946
+
2947
+ if (!accountId) return ok(res, { step: "setup_wizard", error: "Could not get Cloudflare account ID" });
2948
+
2949
+ // List tunnels
2950
+ try {
2951
+ const tr = await fetch(
2952
+ `https://api.cloudflare.com/client/v4/accounts/${accountId}/cfd_tunnel?is_deleted=false`,
2953
+ { headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000) }
2954
+ );
2955
+ const td = await tr.json();
2956
+ const tunnels = (td?.result || []).map(t => ({ id: t.id, name: t.name, status: t.status }));
2957
+ if (tunnels.length > 0) return ok(res, { step: "pick_tunnel", tunnels, accountId });
2958
+ // CF token exists but no tunnels — create via API (no cloudflared login needed)
2959
+ return ok(res, { step: "no_tunnels", accountId });
2960
+ } catch {}
2961
+
2962
+ return ok(res, { step: "setup_wizard" });
2963
+ } catch (err) {
2964
+ return ok(res, { step: "setup_wizard", error: err.message });
2965
+ }
2966
+ }
2967
+
2968
+ // POST /tunnel/setup/cf-token
2969
+ if (method === "POST" && reqPath === "/tunnel/setup/cf-token") {
2970
+ if (lockedGuard(res)) return;
2971
+ let body;
2972
+ try { body = await readBody(req); } catch { return strike(res, 400, "Invalid JSON"); }
2973
+ const { token: cfToken } = body;
2974
+ if (!cfToken) return strike(res, 400, "token required");
2975
+
2976
+ try {
2977
+ const vr = await fetch("https://api.cloudflare.com/client/v4/user/tokens/verify", {
2978
+ headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000)
2979
+ });
2980
+ const vd = await vr.json();
2981
+ if (!vd?.success) return strike(res, 400, "Invalid Cloudflare API token");
2982
+
2983
+ const ar = await fetch("https://api.cloudflare.com/client/v4/accounts", {
2984
+ headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000)
2985
+ });
2986
+ const ad = await ar.json();
2987
+ const accountId = ad?.result?.[0]?.id;
2988
+ const accountName = ad?.result?.[0]?.name;
2989
+
2990
+ // Save token to vault using api.write (same as /set/:service)
2991
+ const { token: t, timestamp } = deriveToken(password, machineHash);
2992
+ await api.write(password, machineHash, t, timestamp, "cloudflare", cfToken);
2993
+
2994
+ // Save accountId to clauth_config
2995
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
2996
+ const sbKey = api.getAnonKey();
2997
+ if (accountId) {
2998
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
2999
+ method: "POST",
3000
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
3001
+ body: JSON.stringify({ key: "cf_account_id", value: accountId }),
3002
+ });
3003
+ }
3004
+
3005
+ return ok(res, { ok: true, accountId, accountName });
3006
+ } catch (err) {
3007
+ return strike(res, 502, err.message);
3008
+ }
3009
+ }
3010
+
3011
+ // GET /tunnel/setup/cf-tunnels
3012
+ if (method === "GET" && reqPath === "/tunnel/setup/cf-tunnels") {
3013
+ if (lockedGuard(res)) return;
3014
+ try {
3015
+ const { token: t, timestamp } = deriveToken(password, machineHash);
3016
+ const cr = await api.retrieve(password, machineHash, t, timestamp, "cloudflare");
3017
+ const cfToken = cr?.value;
3018
+ if (!cfToken) return strike(res, 400, "No cloudflare token in vault");
3019
+
3020
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
3021
+ const sbKey = api.getAnonKey();
3022
+ const acctResp = await fetch(
3023
+ `${sbUrl}/rest/v1/clauth_config?key=eq.cf_account_id&select=value`,
3024
+ { headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) }
3025
+ );
3026
+ const acctRows = await acctResp.json();
3027
+ const accountId = acctRows?.[0]?.value ? JSON.parse(acctRows[0].value) : null;
3028
+ if (!accountId) return strike(res, 400, "No account ID — run cf-token first");
3029
+
3030
+ const tr = await fetch(
3031
+ `https://api.cloudflare.com/client/v4/accounts/${accountId}/cfd_tunnel?is_deleted=false`,
3032
+ { headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000) }
3033
+ );
3034
+ const td = await tr.json();
3035
+ return ok(res, { tunnels: (td?.result || []).map(t => ({ id: t.id, name: t.name, status: t.status, created_at: t.created_at })) });
3036
+ } catch (err) {
3037
+ return strike(res, 502, err.message);
3038
+ }
3039
+ }
3040
+
3041
+ // POST /tunnel/setup/cf-save
3042
+ if (method === "POST" && reqPath === "/tunnel/setup/cf-save") {
3043
+ if (lockedGuard(res)) return;
3044
+ let body;
3045
+ try { body = await readBody(req); } catch { return strike(res, 400, "Invalid JSON"); }
3046
+ const { tunnelId, tunnelName, hostname } = body;
3047
+ if (!hostname) return strike(res, 400, "hostname required");
3048
+
3049
+ try {
3050
+ const cfDir = path.join(os.homedir(), ".cloudflared");
3051
+ if (!fs.existsSync(cfDir)) fs.mkdirSync(cfDir, { recursive: true });
3052
+ const credFile = tunnelId ? path.join(cfDir, `${tunnelId}.json`) : path.join(cfDir, "tunnel.json");
3053
+ const configContent = `tunnel: ${tunnelId || tunnelName || "clauth"}\ncredentials-file: ${credFile}\ningress:\n - hostname: ${hostname}\n service: http://127.0.0.1:${port}\n - service: http_status:404\n`;
3054
+ fs.writeFileSync(path.join(cfDir, "config.yml"), configContent, "utf8");
3055
+
3056
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
3057
+ const sbKey = api.getAnonKey();
3058
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
3059
+ method: "POST",
3060
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
3061
+ body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(hostname) }),
3062
+ });
3063
+
3064
+ tunnelHostname = hostname;
3065
+ tunnelUrl = `https://${hostname}`;
3066
+
3067
+ return ok(res, { ok: true, hostname });
3068
+ } catch (err) {
3069
+ return strike(res, 502, err.message);
3070
+ }
3071
+ }
3072
+
3073
+ // POST /tunnel/setup/cf-create-api — create tunnel via CF API (no cloudflared login needed)
3074
+ if (method === "POST" && reqPath === "/tunnel/setup/cf-create-api") {
3075
+ if (lockedGuard(res)) return;
3076
+ let body;
3077
+ try { body = await readBody(req); } catch { return strike(res, 400, "Invalid JSON"); }
3078
+ const { name, hostname, accountId: bodyAccountId } = body;
3079
+ if (!name || !hostname) return strike(res, 400, "name and hostname required");
3080
+ try {
3081
+ const { token: t, timestamp } = deriveToken(password, machineHash);
3082
+ const cr = await api.retrieve(password, machineHash, t, timestamp, "cloudflare");
3083
+ const cfToken = cr?.value;
3084
+ if (!cfToken) return strike(res, 400, "No cloudflare token in vault");
3085
+
3086
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
3087
+ const sbKey = api.getAnonKey();
3088
+
3089
+ // Get accountId
3090
+ let accountId = bodyAccountId;
3091
+ if (!accountId) {
3092
+ const acctResp = await fetch(`${sbUrl}/rest/v1/clauth_config?key=eq.cf_account_id&select=value`,
3093
+ { headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) });
3094
+ const rows = await acctResp.json();
3095
+ accountId = rows?.[0]?.value ? JSON.parse(rows[0].value) : null;
3096
+ }
3097
+ if (!accountId) return strike(res, 400, "No account ID — verify CF token first");
3098
+
3099
+ // Generate tunnel secret (32 random bytes base64)
3100
+ const { randomBytes } = await import("crypto");
3101
+ const tunnelSecret = randomBytes(32).toString("base64");
3102
+
3103
+ // Create tunnel via CF API
3104
+ const createResp = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/cfd_tunnel`, {
3105
+ method: "POST",
3106
+ headers: { Authorization: `Bearer ${cfToken}`, "Content-Type": "application/json" },
3107
+ body: JSON.stringify({ name, tunnel_secret: tunnelSecret }),
3108
+ signal: AbortSignal.timeout(10000),
3109
+ });
3110
+ const createData = await createResp.json();
3111
+ if (!createData?.success) return strike(res, 400, createData?.errors?.[0]?.message || "Tunnel creation failed");
3112
+ const tunnelId = createData.result.id;
3113
+
3114
+ // Write credentials file
3115
+ const cfDir = path.join(os.homedir(), ".cloudflared");
3116
+ if (!fs.existsSync(cfDir)) fs.mkdirSync(cfDir, { recursive: true });
3117
+ const credFile = path.join(cfDir, `${tunnelId}.json`);
3118
+ fs.writeFileSync(credFile, JSON.stringify({ AccountTag: accountId, TunnelID: tunnelId, TunnelName: name, TunnelSecret: tunnelSecret }), "utf8");
3119
+
3120
+ // Write config.yml
3121
+ const configContent = `tunnel: ${tunnelId}\ncredentials-file: ${credFile}\ningress:\n - hostname: ${hostname}\n service: http://127.0.0.1:${port}\n - service: http_status:404\n`;
3122
+ fs.writeFileSync(path.join(cfDir, "config.yml"), configContent, "utf8");
3123
+
3124
+ // Route DNS via CF API — find zone for hostname
3125
+ const domain = hostname.split(".").slice(-2).join(".");
3126
+ const zoneResp = await fetch(`https://api.cloudflare.com/client/v4/zones?name=${domain}`,
3127
+ { headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000) });
3128
+ const zoneData = await zoneResp.json();
3129
+ const zoneId = zoneData?.result?.[0]?.id;
3130
+ if (zoneId) {
3131
+ await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`, {
3132
+ method: "POST",
3133
+ headers: { Authorization: `Bearer ${cfToken}`, "Content-Type": "application/json" },
3134
+ body: JSON.stringify({ type: "CNAME", name: hostname, content: `${tunnelId}.cfargotunnel.com`, proxied: false, ttl: 1 }),
3135
+ signal: AbortSignal.timeout(8000),
3136
+ });
3137
+ }
3138
+
3139
+ // Save hostname to DB and in-process
3140
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
3141
+ method: "POST",
3142
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
3143
+ body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(hostname) }),
3144
+ });
3145
+ tunnelHostname = hostname;
3146
+ tunnelUrl = `https://${hostname}`;
3147
+
3148
+ return ok(res, { ok: true, tunnelId, hostname });
3149
+ } catch (err) {
3150
+ return strike(res, 502, err.message);
3151
+ }
3152
+ }
3153
+
3154
+ // GET /tunnel/setup/check-cloudflared
3155
+ if (method === "GET" && reqPath === "/tunnel/setup/check-cloudflared") {
3156
+ let cfBin = "cloudflared";
3157
+ let installed = false;
3158
+ if (os.platform() === "win32") {
3159
+ const candidates = [
3160
+ "cloudflared",
3161
+ path.join(process.env.ProgramFiles || "", "cloudflared", "cloudflared.exe"),
3162
+ path.join(process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "cloudflared", "cloudflared.exe"),
3163
+ path.join(os.homedir(), "scoop", "shims", "cloudflared.exe"),
3164
+ "C:\\ProgramData\\chocolatey\\bin\\cloudflared.exe",
3165
+ ];
3166
+ for (const c of candidates) {
3167
+ try { if (fs.statSync(c).isFile()) { cfBin = c; installed = true; break; } } catch {}
3168
+ }
3169
+ }
3170
+ if (!installed) {
3171
+ try {
3172
+ const { execSync: es } = await import("child_process");
3173
+ es("cloudflared --version", { stdio: "ignore" });
3174
+ installed = true; cfBin = "cloudflared";
3175
+ } catch {}
3176
+ }
3177
+ return ok(res, { installed, path: installed ? cfBin : null });
3178
+ }
3179
+
3180
+ // POST /tunnel/setup/cf-login — SSE stream of cloudflared tunnel login
3181
+ if (method === "POST" && reqPath === "/tunnel/setup/cf-login") {
3182
+ if (lockedGuard(res)) return;
3183
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", ...CORS });
3184
+ const sendEvt = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
3185
+ try {
3186
+ const { spawn } = await import("child_process");
3187
+ const proc = spawn("cloudflared", ["tunnel", "login"], { stdio: ["ignore","pipe","pipe"] });
3188
+ proc.stdout.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l })));
3189
+ proc.stderr.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l })));
3190
+ proc.on("close", code => { sendEvt({ done: true, code }); res.end(); });
3191
+ req.on("close", () => { try { proc.kill(); } catch {} });
3192
+ } catch (err) {
3193
+ sendEvt({ done: true, code: 1, error: err.message });
3194
+ res.end();
3195
+ }
3196
+ }
3197
+
3198
+ // POST /tunnel/setup/cf-create — SSE stream create + route DNS + save
3199
+ if (method === "POST" && reqPath === "/tunnel/setup/cf-create") {
3200
+ if (lockedGuard(res)) return;
3201
+ let body;
3202
+ try { body = await readBody(req); } catch {
3203
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
3204
+ return res.end(JSON.stringify({ error: "Invalid JSON" }));
3205
+ }
3206
+ const { name, hostname } = body;
3207
+ if (!name || !hostname) {
3208
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS });
3209
+ return res.end(JSON.stringify({ error: "name and hostname required" }));
3210
+ }
3211
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", ...CORS });
3212
+ const sendEvt = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
3213
+ try {
3214
+ const { spawn } = await import("child_process");
3215
+
3216
+ // Step 1: create tunnel
3217
+ sendEvt({ line: `Creating tunnel "${name}"…`, step: 1 });
3218
+ let tunnelId = null;
3219
+ await new Promise((resolve, reject) => {
3220
+ const proc = spawn("cloudflared", ["tunnel", "create", name], { stdio: ["ignore","pipe","pipe"] });
3221
+ let output = "";
3222
+ proc.stdout.on("data", d => { const s = d.toString(); output += s; s.split("\n").forEach(l => l.trim() && sendEvt({ line: l, step: 1 })); });
3223
+ proc.stderr.on("data", d => { const s = d.toString(); output += s; s.split("\n").forEach(l => l.trim() && sendEvt({ line: l, step: 1 })); });
3224
+ proc.on("close", code => {
3225
+ const m = output.match(/Created tunnel .+ with id ([a-f0-9-]{36})/i);
3226
+ if (m) tunnelId = m[1];
3227
+ if (code !== 0 && !tunnelId) reject(new Error(`create failed (exit ${code})`));
3228
+ else resolve();
3229
+ });
3230
+ });
3231
+
3232
+ if (!tunnelId) {
3233
+ sendEvt({ error: "Could not parse tunnel ID from cloudflared output" });
3234
+ return res.end();
3235
+ }
3236
+
3237
+ // Step 2: route DNS
3238
+ sendEvt({ line: `Routing DNS: ${hostname}…`, step: 2 });
3239
+ await new Promise((resolve) => {
3240
+ const proc = spawn("cloudflared", ["tunnel", "route", "dns", name, hostname], { stdio: ["ignore","pipe","pipe"] });
3241
+ proc.stdout.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l, step: 2 })));
3242
+ proc.stderr.on("data", d => d.toString().split("\n").forEach(l => l.trim() && sendEvt({ line: l, step: 2 })));
3243
+ proc.on("close", () => resolve());
3244
+ });
3245
+
3246
+ // Step 3: save config
3247
+ const cfDir = path.join(os.homedir(), ".cloudflared");
3248
+ if (!fs.existsSync(cfDir)) fs.mkdirSync(cfDir, { recursive: true });
3249
+ const credFile = path.join(cfDir, `${tunnelId}.json`);
3250
+ const configContent = `tunnel: ${tunnelId}\ncredentials-file: ${credFile}\ningress:\n - hostname: ${hostname}\n service: http://127.0.0.1:${port}\n - service: http_status:404\n`;
3251
+ fs.writeFileSync(path.join(cfDir, "config.yml"), configContent, "utf8");
3252
+
3253
+ const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
3254
+ const sbKey = api.getAnonKey();
3255
+ await fetch(`${sbUrl}/rest/v1/clauth_config`, {
3256
+ method: "POST",
3257
+ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
3258
+ body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(hostname) }),
3259
+ });
3260
+ tunnelHostname = hostname;
3261
+ tunnelUrl = `https://${hostname}`;
3262
+
3263
+ sendEvt({ done: true, tunnelId, hostname });
3264
+ res.end();
3265
+ } catch (err) {
3266
+ sendEvt({ error: err.message, done: true });
3267
+ res.end();
3268
+ }
3269
+ }
3270
+
2382
3271
  // POST /change-pw — change master password (must be unlocked)
2383
3272
  if (method === "POST" && reqPath === "/change-pw") {
2384
3273
  if (lockedGuard(res)) return;
package/cli/index.js CHANGED
@@ -532,40 +532,93 @@ program
532
532
  });
533
533
 
534
534
  // ──────────────────────────────────────────────
535
- // clauth tunnel setup
535
+ // clauth tunnel start|stop|status
536
+ // (setup moved to in-browser wizard at http://127.0.0.1:52437)
536
537
  // ──────────────────────────────────────────────
537
538
  const tunnelCmd = program.command("tunnel").description("Manage Cloudflare tunnel for claude.ai web integration");
538
539
 
539
540
  tunnelCmd
540
541
  .command("setup")
541
- .description("Interactive wizard configure cloudflared named tunnel")
542
+ .description("Open the tunnel setup wizard in your browser")
542
543
  .action(async () => {
543
- const { actionTunnelSetup } = await import("./commands/tunnel.js");
544
- await actionTunnelSetup();
544
+ console.log(chalk.cyan("\n Tunnel setup is now handled in the browser.\n"));
545
+ console.log(chalk.white(" 1. Start the daemon: clauth serve start"));
546
+ console.log(chalk.white(" 2. Open: http://127.0.0.1:52437"));
547
+ console.log(chalk.white(" 3. Unlock the vault and click \"Setup Tunnel\"\n"));
545
548
  });
546
549
 
547
550
  tunnelCmd
548
551
  .command("start")
549
552
  .description("Tell daemon to start the tunnel")
550
553
  .action(async () => {
551
- const { actionTunnelStart } = await import("./commands/tunnel.js");
552
- await actionTunnelStart();
554
+ try {
555
+ const r = await fetch("http://127.0.0.1:52437/tunnel/start", {
556
+ method: "POST",
557
+ headers: { "Content-Type": "application/json" },
558
+ signal: AbortSignal.timeout(5000),
559
+ });
560
+ const data = await r.json().catch(() => ({}));
561
+ if (!r.ok) {
562
+ console.error(` ✗ ${data.error || r.statusText}`);
563
+ if (r.status === 401) console.error(" Unlock the daemon first: http://127.0.0.1:52437");
564
+ process.exit(1);
565
+ }
566
+ console.log(` ✓ ${data.message || "Tunnel starting — check status with: clauth tunnel status"}`);
567
+ } catch (e) {
568
+ console.error(" ✗ Daemon not running. Start it with: clauth serve");
569
+ process.exit(1);
570
+ }
553
571
  });
554
572
 
555
573
  tunnelCmd
556
574
  .command("stop")
557
575
  .description("Tell daemon to stop the tunnel")
558
576
  .action(async () => {
559
- const { actionTunnelStop } = await import("./commands/tunnel.js");
560
- await actionTunnelStop();
577
+ try {
578
+ const r = await fetch("http://127.0.0.1:52437/tunnel/stop", {
579
+ method: "POST",
580
+ headers: { "Content-Type": "application/json" },
581
+ signal: AbortSignal.timeout(5000),
582
+ });
583
+ const data = await r.json().catch(() => ({}));
584
+ if (!r.ok) {
585
+ console.error(` ✗ ${data.error || r.statusText}`);
586
+ process.exit(1);
587
+ }
588
+ console.log(" ✓ Tunnel stopped.");
589
+ } catch (e) {
590
+ console.error(" ✗ Daemon not running.");
591
+ process.exit(1);
592
+ }
561
593
  });
562
594
 
563
595
  tunnelCmd
564
596
  .command("status")
565
597
  .description("Show current tunnel status")
566
598
  .action(async () => {
567
- const { actionTunnelStatus } = await import("./commands/tunnel.js");
568
- await actionTunnelStatus();
599
+ try {
600
+ const r = await fetch("http://127.0.0.1:52437/tunnel", {
601
+ signal: AbortSignal.timeout(5000),
602
+ });
603
+ const data = await r.json().catch(() => ({}));
604
+ const icons = {
605
+ live: "✓", starting: "◌", not_configured: "⚠",
606
+ not_started: "○", error: "✗", missing_cloudflared: "✗",
607
+ };
608
+ const labels = {
609
+ live: `Live — ${data.url || ""}`,
610
+ starting: "Starting...",
611
+ not_configured: "Not configured — open http://127.0.0.1:52437 and click Setup Tunnel",
612
+ not_started: "Not started — run: clauth tunnel start",
613
+ error: `Error${data.error ? ": " + data.error : ""} — check cloudflared config`,
614
+ missing_cloudflared: "cloudflared not installed — https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
615
+ };
616
+ const status = data.status || "unknown";
617
+ console.log(`\n ${icons[status] || "?"} Tunnel: ${labels[status] || status}\n`);
618
+ } catch (e) {
619
+ console.error(" ✗ Daemon not running. Start it with: clauth serve");
620
+ process.exit(1);
621
+ }
569
622
  });
570
623
 
571
624
  // ──────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/clauth",
3
- "version": "1.1.0",
3
+ "version": "1.2.3",
4
4
  "description": "Hardware-bound credential vault for the LIFEAI infrastructure stack",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,210 +0,0 @@
1
- // cli/commands/tunnel.js
2
- // clauth tunnel setup — configures Cloudflare named tunnel for claude.ai web integration
3
-
4
- import { execSync, spawnSync } from "child_process";
5
- import os from "os";
6
- import path from "path";
7
- import fs from "fs";
8
- import readline from "readline";
9
-
10
- function ask(question) {
11
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
12
- return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans.trim()); }));
13
- }
14
-
15
- async function actionTunnelSetup() {
16
- console.log("\n clauth tunnel setup\n");
17
- console.log(" This wizard configures a Cloudflare named tunnel so claude.ai web");
18
- console.log(" can connect to your local clauth daemon without exposing any ports.\n");
19
-
20
- // Step 1: Check cloudflared is installed
21
- try {
22
- execSync("cloudflared --version", { stdio: "ignore" });
23
- console.log(" ✓ cloudflared detected\n");
24
- } catch {
25
- console.log(" ✗ cloudflared not found.\n");
26
- console.log(" Install it first:");
27
- console.log(" Windows: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/");
28
- console.log(" Or via winget: winget install Cloudflare.cloudflared\n");
29
- process.exit(1);
30
- }
31
-
32
- // Step 2: Authenticate with Cloudflare
33
- console.log(" Step 1/4 — Authenticate with Cloudflare");
34
- console.log(" This will open your browser to log in.\n");
35
- const doAuth = await ask(" Proceed? (y/n): ");
36
- if (doAuth.toLowerCase() !== "y") { console.log(" Aborted."); process.exit(0); }
37
-
38
- try {
39
- spawnSync("cloudflared", ["tunnel", "login"], { stdio: "inherit" });
40
- console.log(" ✓ Authenticated\n");
41
- } catch (e) {
42
- console.error(" ✗ Login failed:", e.message);
43
- process.exit(1);
44
- }
45
-
46
- // Step 3: Create tunnel
47
- console.log(" Step 2/4 — Create named tunnel");
48
- const tunnelName = await ask(" Tunnel name (default: clauth): ") || "clauth";
49
-
50
- let tunnelId;
51
- try {
52
- const result = execSync(`cloudflared tunnel create ${tunnelName}`, { encoding: "utf8" });
53
- const match = result.match(/Created tunnel (.+) with id ([a-f0-9-]+)/i);
54
- if (match) tunnelId = match[2];
55
- console.log(` ✓ Tunnel created: ${tunnelName} (${tunnelId || "see above"})\n`);
56
- } catch (e) {
57
- // Tunnel may already exist
58
- console.log(" (Tunnel may already exist — continuing)\n");
59
- try {
60
- const listResult = execSync(`cloudflared tunnel list`, { encoding: "utf8" });
61
- const lines = listResult.split("\n");
62
- for (const line of lines) {
63
- if (line.toLowerCase().includes(tunnelName.toLowerCase())) {
64
- const parts = line.trim().split(/\s+/);
65
- if (parts[0] && parts[0].includes("-")) tunnelId = parts[0];
66
- }
67
- }
68
- } catch {
69
- // ignore list errors
70
- }
71
- }
72
-
73
- // Step 4: Configure hostname
74
- console.log(" Step 3/4 — Set public hostname");
75
- const hostname = await ask(" Public hostname (e.g. clauth.yourdomain.com): ");
76
- if (!hostname) { console.log(" Hostname required."); process.exit(1); }
77
-
78
- // Write config.yml
79
- const cfDir = path.join(os.homedir(), ".cloudflared");
80
- if (!fs.existsSync(cfDir)) fs.mkdirSync(cfDir, { recursive: true });
81
-
82
- const configPath = path.join(cfDir, "config.yml");
83
- const credFile = tunnelId ? path.join(cfDir, `${tunnelId}.json`) : `<tunnel-id>.json`;
84
- const config = `tunnel: ${tunnelId || tunnelName}
85
- credentials-file: ${credFile}
86
- ingress:
87
- - hostname: ${hostname}
88
- service: http://127.0.0.1:52437
89
- - service: http_status:404
90
- `;
91
- fs.writeFileSync(configPath, config, "utf8");
92
- console.log(` ✓ Config written: ${configPath}\n`);
93
-
94
- // Route DNS
95
- console.log(" Step 4/4 — Configure DNS");
96
- try {
97
- execSync(`cloudflared tunnel route dns ${tunnelName} ${hostname}`, { stdio: "inherit" });
98
- console.log(` ✓ DNS configured: ${hostname}\n`);
99
- } catch {
100
- console.log(` ⚠ DNS route failed — add manually in Cloudflare dashboard:`);
101
- console.log(` CNAME ${hostname} → ${tunnelId}.cfargotunnel.com\n`);
102
- }
103
-
104
- // Save hostname to clauth config via api module
105
- try {
106
- const apiMod = await import("../api.js").catch(() => null);
107
- if (apiMod) {
108
- const sbUrl = (apiMod.getBaseUrl?.() || "").replace("/functions/v1/auth-vault", "");
109
- const sbKey = apiMod.getAnonKey?.();
110
- if (sbUrl && sbKey) {
111
- await fetch(
112
- `${sbUrl}/rest/v1/clauth_config`,
113
- {
114
- method: "POST",
115
- headers: {
116
- apikey: sbKey,
117
- Authorization: `Bearer ${sbKey}`,
118
- "Content-Type": "application/json",
119
- Prefer: "resolution=merge-duplicates",
120
- },
121
- body: JSON.stringify({ key: "tunnel_hostname", value: hostname }),
122
- }
123
- );
124
- console.log(` ✓ Hostname saved to clauth config: ${hostname}`);
125
- }
126
- }
127
- } catch {
128
- console.log(` ⚠ Could not save to DB. Hostname is set in config.yml — 'clauth serve' will pick it up.`);
129
- }
130
-
131
- console.log("\n Setup complete!");
132
- console.log(` Restart the daemon: clauth serve restart`);
133
- console.log(` Or use: npm run worker:restart (from clauth directory)\n`);
134
- }
135
-
136
- // clauth tunnel start — tells daemon to start the tunnel
137
- async function actionTunnelStart() {
138
- try {
139
- const r = await fetch("http://127.0.0.1:52437/tunnel/start", {
140
- method: "POST",
141
- headers: { "Content-Type": "application/json" },
142
- signal: AbortSignal.timeout(5000),
143
- });
144
- const data = await r.json().catch(() => ({}));
145
- if (!r.ok) {
146
- console.error(` ✗ ${data.error || r.statusText}`);
147
- if (r.status === 401) console.error(" Unlock the daemon first: http://127.0.0.1:52437");
148
- process.exit(1);
149
- }
150
- console.log(` ✓ ${data.message || "Tunnel starting — check status with: clauth tunnel status"}`);
151
- } catch (e) {
152
- console.error(" ✗ Daemon not running. Start it with: clauth serve");
153
- process.exit(1);
154
- }
155
- }
156
-
157
- // clauth tunnel stop — tells daemon to stop the tunnel
158
- async function actionTunnelStop() {
159
- try {
160
- const r = await fetch("http://127.0.0.1:52437/tunnel/stop", {
161
- method: "POST",
162
- headers: { "Content-Type": "application/json" },
163
- signal: AbortSignal.timeout(5000),
164
- });
165
- const data = await r.json().catch(() => ({}));
166
- if (!r.ok) {
167
- console.error(` ✗ ${data.error || r.statusText}`);
168
- process.exit(1);
169
- }
170
- console.log(" ✓ Tunnel stopped.");
171
- } catch (e) {
172
- console.error(" ✗ Daemon not running.");
173
- process.exit(1);
174
- }
175
- }
176
-
177
- // clauth tunnel status — shows current tunnel status from daemon
178
- async function actionTunnelStatus() {
179
- try {
180
- const r = await fetch("http://127.0.0.1:52437/tunnel", {
181
- signal: AbortSignal.timeout(5000),
182
- });
183
- const data = await r.json().catch(() => ({}));
184
-
185
- const icons = {
186
- live: "✓",
187
- starting: "◌",
188
- not_configured: "⚠",
189
- not_started: "○",
190
- error: "✗",
191
- missing_cloudflared: "✗",
192
- };
193
- const labels = {
194
- live: `Live — ${data.url || ""}`,
195
- starting: "Starting...",
196
- not_configured: "Not configured — run: clauth tunnel setup",
197
- not_started: "Not started — run: clauth tunnel start",
198
- error: `Error${data.error ? ": " + data.error : ""} — check cloudflared config`,
199
- missing_cloudflared: "cloudflared not installed — https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
200
- };
201
-
202
- const status = data.status || "unknown";
203
- console.log(`\n ${icons[status] || "?"} Tunnel: ${labels[status] || status}\n`);
204
- } catch (e) {
205
- console.error(" ✗ Daemon not running. Start it with: clauth serve");
206
- process.exit(1);
207
- }
208
- }
209
-
210
- export { actionTunnelSetup, actionTunnelStart, actionTunnelStop, actionTunnelStatus };