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