@lifeaitools/clauth 0.7.6 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +1135 -204
- 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
|
}
|
|
@@ -1308,6 +1587,108 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
1308
1587
|
fetchBuildStatus();
|
|
1309
1588
|
setInterval(fetchBuildStatus, 15000);
|
|
1310
1589
|
|
|
1590
|
+
applyPendingMigrations().then(result => {
|
|
1591
|
+
lastMigrationResult = result;
|
|
1592
|
+
if (result.applied?.length > 0) {
|
|
1593
|
+
const log = `[${new Date().toISOString()}] Migrations applied: ${result.applied.map(m => m.name).join(", ")}\n`;
|
|
1594
|
+
try { fs.appendFileSync(LOG_FILE, log); } catch {}
|
|
1595
|
+
}
|
|
1596
|
+
}).catch(() => {});
|
|
1597
|
+
|
|
1598
|
+
// ── Tunnel config (fetched from DB after unlock) ────────────
|
|
1599
|
+
async function fetchTunnelConfig() {
|
|
1600
|
+
try {
|
|
1601
|
+
const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
|
|
1602
|
+
const sbKey = api.getAnonKey();
|
|
1603
|
+
if (!sbUrl || !sbKey) return null;
|
|
1604
|
+
|
|
1605
|
+
const r = await fetch(
|
|
1606
|
+
`${sbUrl}/rest/v1/clauth_config?key=eq.tunnel_hostname&select=value`,
|
|
1607
|
+
{
|
|
1608
|
+
headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` },
|
|
1609
|
+
signal: AbortSignal.timeout(5000),
|
|
1610
|
+
}
|
|
1611
|
+
);
|
|
1612
|
+
if (!r.ok) return null;
|
|
1613
|
+
const rows = await r.json();
|
|
1614
|
+
if (rows.length > 0 && rows[0].value && rows[0].value !== "null") {
|
|
1615
|
+
return typeof rows[0].value === "string" ? JSON.parse(rows[0].value) : rows[0].value;
|
|
1616
|
+
}
|
|
1617
|
+
return null;
|
|
1618
|
+
} catch {
|
|
1619
|
+
return null;
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
// ── Auto-migration ────────────────────────────────────────────
|
|
1624
|
+
let pendingBreakingMigrations = [];
|
|
1625
|
+
let lastMigrationResult = null;
|
|
1626
|
+
|
|
1627
|
+
async function applyPendingMigrations() {
|
|
1628
|
+
try {
|
|
1629
|
+
const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
|
|
1630
|
+
const sbKey = api.getAnonKey();
|
|
1631
|
+
if (!sbUrl || !sbKey) return { applied: [], errors: [] };
|
|
1632
|
+
|
|
1633
|
+
// Read current schema version (may not exist yet)
|
|
1634
|
+
let currentVersion = 0;
|
|
1635
|
+
try {
|
|
1636
|
+
const r = await fetch(
|
|
1637
|
+
`${sbUrl}/rest/v1/clauth_config?key=eq.schema_version&select=value`,
|
|
1638
|
+
{ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) }
|
|
1639
|
+
);
|
|
1640
|
+
if (r.ok) {
|
|
1641
|
+
const rows = await r.json();
|
|
1642
|
+
if (rows.length > 0) currentVersion = Number(rows[0].value) || 0;
|
|
1643
|
+
}
|
|
1644
|
+
} catch {}
|
|
1645
|
+
|
|
1646
|
+
if (currentVersion >= CURRENT_SCHEMA_VERSION) return { applied: [], errors: [], currentVersion };
|
|
1647
|
+
|
|
1648
|
+
const applied = [];
|
|
1649
|
+
const errors = [];
|
|
1650
|
+
|
|
1651
|
+
for (const m of MIGRATIONS) {
|
|
1652
|
+
if (m.version <= currentVersion || !m.sql) continue;
|
|
1653
|
+
if (m.type === "breaking") {
|
|
1654
|
+
// Queue for UI confirmation — do not auto-apply
|
|
1655
|
+
pendingBreakingMigrations.push(m);
|
|
1656
|
+
continue;
|
|
1657
|
+
}
|
|
1658
|
+
try {
|
|
1659
|
+
// Apply via Edge Function proxy (anon key can't do DDL directly)
|
|
1660
|
+
const r = await fetch(`${(api.getBaseUrl() || "")}/run-migration`, {
|
|
1661
|
+
method: "POST",
|
|
1662
|
+
headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json" },
|
|
1663
|
+
body: JSON.stringify({ sql: m.sql, version: m.version }),
|
|
1664
|
+
signal: AbortSignal.timeout(15000),
|
|
1665
|
+
});
|
|
1666
|
+
if (r.ok) {
|
|
1667
|
+
applied.push(m);
|
|
1668
|
+
currentVersion = m.version;
|
|
1669
|
+
// Update schema_version in clauth_config
|
|
1670
|
+
await fetch(`${sbUrl}/rest/v1/clauth_config`, {
|
|
1671
|
+
method: "POST",
|
|
1672
|
+
headers: {
|
|
1673
|
+
apikey: sbKey, Authorization: `Bearer ${sbKey}`,
|
|
1674
|
+
"Content-Type": "application/json", Prefer: "resolution=merge-duplicates",
|
|
1675
|
+
},
|
|
1676
|
+
body: JSON.stringify({ key: "schema_version", value: m.version }),
|
|
1677
|
+
});
|
|
1678
|
+
} else {
|
|
1679
|
+
errors.push({ migration: m.name, error: await r.text() });
|
|
1680
|
+
}
|
|
1681
|
+
} catch (e) {
|
|
1682
|
+
errors.push({ migration: m.name, error: e.message });
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
return { applied, errors, currentVersion };
|
|
1687
|
+
} catch (e) {
|
|
1688
|
+
return { applied: [], errors: [{ migration: "init", error: e.message }] };
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1311
1692
|
const server = http.createServer(async (req, res) => {
|
|
1312
1693
|
const remote = req.socket.remoteAddress;
|
|
1313
1694
|
const isLocal = remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
|
|
@@ -1667,17 +2048,22 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
1667
2048
|
failures: failCount,
|
|
1668
2049
|
failures_remaining: MAX_FAILS - failCount,
|
|
1669
2050
|
services: whitelist || "all",
|
|
1670
|
-
port
|
|
2051
|
+
port,
|
|
2052
|
+
tunnel_status: tunnelStatus,
|
|
2053
|
+
tunnel_url: tunnelUrl || null,
|
|
2054
|
+
app_version: VERSION,
|
|
2055
|
+
schema_version: CURRENT_SCHEMA_VERSION,
|
|
1671
2056
|
});
|
|
1672
2057
|
}
|
|
1673
2058
|
|
|
1674
2059
|
// GET /tunnel — tunnel status (for dashboard polling)
|
|
1675
2060
|
if (method === "GET" && reqPath === "/tunnel") {
|
|
1676
2061
|
return ok(res, {
|
|
2062
|
+
status: tunnelStatus,
|
|
1677
2063
|
running: !!tunnelProc,
|
|
1678
|
-
url: tunnelUrl,
|
|
2064
|
+
url: tunnelUrl || null,
|
|
1679
2065
|
sseUrl: tunnelUrl && tunnelUrl.startsWith("http") ? `${tunnelUrl}/sse` : null,
|
|
1680
|
-
error: tunnelError,
|
|
2066
|
+
error: tunnelError || null,
|
|
1681
2067
|
});
|
|
1682
2068
|
}
|
|
1683
2069
|
|
|
@@ -1686,6 +2072,15 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
1686
2072
|
return ok(res, buildStatus);
|
|
1687
2073
|
}
|
|
1688
2074
|
|
|
2075
|
+
// GET /migrations — migration registry and last run result
|
|
2076
|
+
if (method === "GET" && reqPath === "/migrations") {
|
|
2077
|
+
return ok(res, {
|
|
2078
|
+
schema_version: CURRENT_SCHEMA_VERSION,
|
|
2079
|
+
last_result: lastMigrationResult,
|
|
2080
|
+
pending_breaking: pendingBreakingMigrations,
|
|
2081
|
+
});
|
|
2082
|
+
}
|
|
2083
|
+
|
|
1689
2084
|
// GET /mcp-setup — OAuth credentials for claude.ai MCP setup (localhost only)
|
|
1690
2085
|
if (method === "GET" && reqPath === "/mcp-setup") {
|
|
1691
2086
|
return ok(res, {
|
|
@@ -1695,7 +2090,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
1695
2090
|
});
|
|
1696
2091
|
}
|
|
1697
2092
|
|
|
1698
|
-
// POST /tunnel — start or stop tunnel manually
|
|
2093
|
+
// POST /tunnel — start or stop tunnel manually (action in body)
|
|
1699
2094
|
if (method === "POST" && reqPath === "/tunnel") {
|
|
1700
2095
|
if (lockedGuard(res)) return;
|
|
1701
2096
|
let body;
|
|
@@ -1705,11 +2100,27 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
1705
2100
|
}
|
|
1706
2101
|
if (body.action === "stop") {
|
|
1707
2102
|
stopTunnel();
|
|
1708
|
-
return ok(res, {
|
|
2103
|
+
return ok(res, { status: tunnelStatus, running: false });
|
|
1709
2104
|
}
|
|
1710
2105
|
// start
|
|
1711
2106
|
await startTunnel();
|
|
1712
|
-
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 });
|
|
1713
2124
|
}
|
|
1714
2125
|
|
|
1715
2126
|
// GET /shutdown (for daemon stop)
|
|
@@ -1756,12 +2167,14 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
1756
2167
|
}
|
|
1757
2168
|
}
|
|
1758
2169
|
|
|
1759
|
-
// GET /status
|
|
2170
|
+
// GET /status?project=xxx
|
|
1760
2171
|
if (method === "GET" && reqPath === "/status") {
|
|
1761
2172
|
if (lockedGuard(res)) return;
|
|
1762
2173
|
try {
|
|
2174
|
+
const parsedUrl = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
2175
|
+
const projectFilter = parsedUrl.searchParams.get("project") || undefined;
|
|
1763
2176
|
const { token, timestamp } = deriveToken(password, machineHash);
|
|
1764
|
-
const result = await api.status(password, machineHash, token, timestamp);
|
|
2177
|
+
const result = await api.status(password, machineHash, token, timestamp, projectFilter);
|
|
1765
2178
|
if (result.error) return strike(res, 502, result.error);
|
|
1766
2179
|
if (whitelist) {
|
|
1767
2180
|
result.services = (result.services || []).filter(
|
|
@@ -1815,8 +2228,32 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
1815
2228
|
password = pw; // unlock — store in process memory only
|
|
1816
2229
|
const logLine = `[${new Date().toISOString()}] Vault unlocked\n`;
|
|
1817
2230
|
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
1818
|
-
// Auto-start
|
|
1819
|
-
|
|
2231
|
+
// Auto-start tunnel: --tunnel flag takes priority, otherwise fetch from DB
|
|
2232
|
+
if (!tunnelHostname) {
|
|
2233
|
+
fetchTunnelConfig().then(configured => {
|
|
2234
|
+
if (configured) {
|
|
2235
|
+
tunnelHostname = configured;
|
|
2236
|
+
tunnelStatus = "starting";
|
|
2237
|
+
startTunnel().catch(() => {});
|
|
2238
|
+
} else {
|
|
2239
|
+
// No tunnel configured in DB and no --tunnel flag
|
|
2240
|
+
tunnelStatus = "not_configured";
|
|
2241
|
+
const msg = [
|
|
2242
|
+
`[${new Date().toISOString()}] No tunnel configured.`,
|
|
2243
|
+
" claude.ai web integration is inactive.",
|
|
2244
|
+
" To enable: run 'clauth tunnel setup'",
|
|
2245
|
+
].join("\n");
|
|
2246
|
+
try { fs.appendFileSync(LOG_FILE, msg + "\n"); } catch {}
|
|
2247
|
+
console.log("\n ⚠ No tunnel configured — claude.ai web integration inactive.");
|
|
2248
|
+
console.log(" Run: clauth tunnel setup\n");
|
|
2249
|
+
}
|
|
2250
|
+
}).catch(() => {
|
|
2251
|
+
tunnelStatus = "error";
|
|
2252
|
+
});
|
|
2253
|
+
} else {
|
|
2254
|
+
tunnelStatus = "starting";
|
|
2255
|
+
startTunnel().catch(() => {});
|
|
2256
|
+
}
|
|
1820
2257
|
return ok(res, { ok: true, locked: false });
|
|
1821
2258
|
} catch {
|
|
1822
2259
|
// Wrong password — not a lockout strike, just a UI auth attempt
|
|
@@ -2014,7 +2451,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
2014
2451
|
return res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
|
2015
2452
|
}
|
|
2016
2453
|
|
|
2017
|
-
const { name, label, key_type, description } = body;
|
|
2454
|
+
const { name, label, key_type, description, project } = body;
|
|
2018
2455
|
if (!name || typeof name !== "string" || !name.trim()) {
|
|
2019
2456
|
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
2020
2457
|
return res.end(JSON.stringify({ error: "name is required" }));
|
|
@@ -2028,7 +2465,7 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
2028
2465
|
|
|
2029
2466
|
try {
|
|
2030
2467
|
const { token, timestamp } = deriveToken(password, machineHash);
|
|
2031
|
-
const result = await api.addService(password, machineHash, token, timestamp, name.trim().toLowerCase(), label || name.trim(), type, description || "");
|
|
2468
|
+
const result = await api.addService(password, machineHash, token, timestamp, name.trim().toLowerCase(), label || name.trim(), type, description || "", project || undefined);
|
|
2032
2469
|
if (result.error) return strike(res, 502, result.error);
|
|
2033
2470
|
return ok(res, { ok: true, service: name.trim().toLowerCase() });
|
|
2034
2471
|
} catch (err) {
|
|
@@ -2036,6 +2473,42 @@ function createServer(initPassword, whitelist, port, tunnelHostname = null) {
|
|
|
2036
2473
|
}
|
|
2037
2474
|
}
|
|
2038
2475
|
|
|
2476
|
+
// POST /update-service — update service metadata (project, label, description)
|
|
2477
|
+
if (method === "POST" && reqPath === "/update-service") {
|
|
2478
|
+
if (lockedGuard(res)) return;
|
|
2479
|
+
|
|
2480
|
+
let body;
|
|
2481
|
+
try { body = await readBody(req); } catch {
|
|
2482
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
2483
|
+
return res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
const { service, project, label, description } = body;
|
|
2487
|
+
if (!service || typeof service !== "string") {
|
|
2488
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
2489
|
+
return res.end(JSON.stringify({ error: "service is required" }));
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
const updates = {};
|
|
2493
|
+
if (project !== undefined) updates.project = project;
|
|
2494
|
+
if (label !== undefined) updates.label = label;
|
|
2495
|
+
if (description !== undefined) updates.description = description;
|
|
2496
|
+
|
|
2497
|
+
if (Object.keys(updates).length === 0) {
|
|
2498
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
2499
|
+
return res.end(JSON.stringify({ error: "At least one field to update is required (project, label, description)" }));
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
try {
|
|
2503
|
+
const { token, timestamp } = deriveToken(password, machineHash);
|
|
2504
|
+
const result = await api.updateService(password, machineHash, token, timestamp, service.toLowerCase(), updates);
|
|
2505
|
+
if (result.error) return strike(res, 502, result.error);
|
|
2506
|
+
return ok(res, { ok: true, service: service.toLowerCase(), ...updates });
|
|
2507
|
+
} catch (err) {
|
|
2508
|
+
return strike(res, 502, err.message);
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2039
2512
|
// Unknown route — don't count browser/MCP noise as auth failures
|
|
2040
2513
|
// Don't count browser noise, MCP discovery probes, or OAuth probes as auth failures
|
|
2041
2514
|
const isBenign = reqPath.startsWith("/.well-known/") || [
|
|
@@ -2412,6 +2885,24 @@ const MCP_TOOLS = [
|
|
|
2412
2885
|
additionalProperties: false
|
|
2413
2886
|
}
|
|
2414
2887
|
},
|
|
2888
|
+
{
|
|
2889
|
+
name: "clauth_set_project",
|
|
2890
|
+
description: "Set or clear the project scope on a service. Pass empty string to clear.",
|
|
2891
|
+
inputSchema: {
|
|
2892
|
+
type: "object",
|
|
2893
|
+
properties: {
|
|
2894
|
+
service: { type: "string", description: "Service name (e.g. gmail, github)" },
|
|
2895
|
+
project: { type: "string", description: "Project name to assign (empty string to clear)" }
|
|
2896
|
+
},
|
|
2897
|
+
required: ["service", "project"],
|
|
2898
|
+
additionalProperties: false
|
|
2899
|
+
}
|
|
2900
|
+
},
|
|
2901
|
+
{
|
|
2902
|
+
name: "clauth_self_check",
|
|
2903
|
+
description: "Test whether the clauth MCP connector is reachable via the Cloudflare tunnel. Returns connectivity status and tunnel URL.",
|
|
2904
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false }
|
|
2905
|
+
},
|
|
2415
2906
|
];
|
|
2416
2907
|
|
|
2417
2908
|
function writeTempSecret(service, value) {
|
|
@@ -2479,13 +2970,14 @@ async function handleMcpTool(vault, name, args) {
|
|
|
2479
2970
|
if (vault.whitelist) {
|
|
2480
2971
|
services = services.filter(s => vault.whitelist.includes(s.name.toLowerCase()));
|
|
2481
2972
|
}
|
|
2482
|
-
const lines = ["SERVICE
|
|
2483
|
-
"
|
|
2973
|
+
const lines = ["SERVICE TYPE PROJECT STATUS KEY LAST RETRIEVED",
|
|
2974
|
+
"------- ---- ------- ------ --- --------------"];
|
|
2484
2975
|
for (const s of services) {
|
|
2485
2976
|
const status = s.enabled ? "ACTIVE" : (s.vault_key ? "SUSPENDED" : "NO KEY");
|
|
2486
2977
|
const hasKey = s.vault_key ? "yes" : "—";
|
|
2487
2978
|
const lastGet = s.last_retrieved ? new Date(s.last_retrieved).toLocaleDateString() : "never";
|
|
2488
|
-
|
|
2979
|
+
const proj = (s.project || "—").padEnd(22);
|
|
2980
|
+
lines.push(`${s.name.padEnd(24)} ${(s.key_type || "").padEnd(12)} ${proj} ${status.padEnd(12)} ${hasKey.padEnd(6)} ${lastGet}`);
|
|
2489
2981
|
}
|
|
2490
2982
|
return mcpResult(lines.join("\n"));
|
|
2491
2983
|
} catch (err) {
|
|
@@ -2652,6 +3144,57 @@ async function handleMcpTool(vault, name, args) {
|
|
|
2652
3144
|
}
|
|
2653
3145
|
}
|
|
2654
3146
|
|
|
3147
|
+
case "clauth_set_project": {
|
|
3148
|
+
if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
|
|
3149
|
+
const service = (args.service || "").toLowerCase();
|
|
3150
|
+
const project = args.project;
|
|
3151
|
+
if (!service) return mcpError("service is required");
|
|
3152
|
+
if (project === undefined) return mcpError("project is required (empty string to clear)");
|
|
3153
|
+
try {
|
|
3154
|
+
const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
|
|
3155
|
+
const result = await api.updateService(vault.password, vault.machineHash, token, timestamp, service, { project: project || "" });
|
|
3156
|
+
if (result.error) return mcpError(result.error);
|
|
3157
|
+
return mcpResult(project ? `${service} → project: ${project}` : `${service} → project cleared`);
|
|
3158
|
+
} catch (err) {
|
|
3159
|
+
return mcpError(err.message);
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
case "clauth_self_check": {
|
|
3164
|
+
// Test connectivity via the Cloudflare tunnel and/or localhost daemon
|
|
3165
|
+
const tunnelUrl = vault.tunnelUrl;
|
|
3166
|
+
const results = [];
|
|
3167
|
+
|
|
3168
|
+
// Check localhost daemon
|
|
3169
|
+
try {
|
|
3170
|
+
const r = await fetch("http://127.0.0.1:52437/ping", { signal: AbortSignal.timeout(3000) });
|
|
3171
|
+
const data = await r.json();
|
|
3172
|
+
results.push(`Local daemon: PASS (${data.locked ? "locked" : "unlocked"}, failures: ${data.failures ?? 0})`);
|
|
3173
|
+
} catch (err) {
|
|
3174
|
+
results.push(`Local daemon: FAIL — http://127.0.0.1:52437 not reachable (${err.message})`);
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
// Check tunnel
|
|
3178
|
+
if (tunnelUrl) {
|
|
3179
|
+
try {
|
|
3180
|
+
const r = await fetch(`${tunnelUrl}/ping`, { signal: AbortSignal.timeout(5000) });
|
|
3181
|
+
const data = await r.json();
|
|
3182
|
+
results.push(`Tunnel: PASS — ${tunnelUrl} reachable (${data.locked ? "locked" : "unlocked"})`);
|
|
3183
|
+
results.push(`claude.ai SSE endpoint: ${tunnelUrl}/sse`);
|
|
3184
|
+
results.push(`claude.ai MCP endpoint: ${tunnelUrl}/mcp`);
|
|
3185
|
+
} catch (err) {
|
|
3186
|
+
results.push(`Tunnel: FAIL — ${tunnelUrl} not reachable (${err.message})`);
|
|
3187
|
+
results.push("claude.ai MCP connector will not work until tunnel is restored.");
|
|
3188
|
+
results.push("Fix: clauth serve start --tunnel clauth.prtrust.fund");
|
|
3189
|
+
}
|
|
3190
|
+
} else {
|
|
3191
|
+
results.push("Tunnel: NOT RUNNING — claude.ai connector requires the tunnel.");
|
|
3192
|
+
results.push("Start with: clauth serve start --tunnel clauth.prtrust.fund");
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
return mcpResult(results.join("\n"));
|
|
3196
|
+
}
|
|
3197
|
+
|
|
2655
3198
|
default:
|
|
2656
3199
|
return mcpError(`Unknown tool: ${name}`);
|
|
2657
3200
|
}
|
|
@@ -2674,6 +3217,7 @@ function createMcpServer(initPassword, whitelist) {
|
|
|
2674
3217
|
const vault = {
|
|
2675
3218
|
password: initPassword || null,
|
|
2676
3219
|
get machineHash() { return ensureMachineHash(); },
|
|
3220
|
+
get tunnelUrl() { return null; }, // stdio server has no tunnel — self_check will try localhost daemon
|
|
2677
3221
|
whitelist,
|
|
2678
3222
|
failCount: 0,
|
|
2679
3223
|
MAX_FAILS: 10,
|
|
@@ -2765,61 +3309,154 @@ async function actionMcp(opts) {
|
|
|
2765
3309
|
createMcpServer(password, whitelist);
|
|
2766
3310
|
}
|
|
2767
3311
|
|
|
2768
|
-
// ──
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
3312
|
+
// ── Cross-platform auto-start install / uninstall ────────────
|
|
3313
|
+
// Windows: DPAPI + Scheduled Task
|
|
3314
|
+
// macOS: Keychain + LaunchAgent
|
|
3315
|
+
// Linux: libsecret/encrypted file + systemd user service
|
|
3316
|
+
|
|
3317
|
+
const TASK_NAME = "ClauthAutostart";
|
|
3318
|
+
|
|
3319
|
+
function getAutostartDir() {
|
|
3320
|
+
const platform = os.platform();
|
|
3321
|
+
if (platform === "win32") {
|
|
3322
|
+
return path.join(os.homedir(), "AppData", "Roaming", "clauth");
|
|
3323
|
+
} else if (platform === "darwin") {
|
|
3324
|
+
return path.join(os.homedir(), "Library", "LaunchAgents");
|
|
3325
|
+
} else {
|
|
3326
|
+
return path.join(os.homedir(), ".config", "systemd", "user");
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
|
|
3330
|
+
function getBootKeyPath() {
|
|
3331
|
+
if (os.platform() === "win32") {
|
|
3332
|
+
return path.join(os.homedir(), "AppData", "Roaming", "clauth", "boot.key");
|
|
3333
|
+
} else if (os.platform() === "darwin") {
|
|
3334
|
+
return null; // stored in Keychain, not a file
|
|
3335
|
+
} else {
|
|
3336
|
+
return path.join(os.homedir(), ".config", "clauth", "boot.key");
|
|
3337
|
+
}
|
|
3338
|
+
}
|
|
2773
3339
|
|
|
2774
3340
|
async function actionInstall(opts) {
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
3341
|
+
const platform = os.platform();
|
|
3342
|
+
const { execSync } = await import("child_process");
|
|
3343
|
+
const config = new Conf(getConfOptions());
|
|
3344
|
+
|
|
3345
|
+
const tunnelHostname = opts.tunnel || null;
|
|
3346
|
+
|
|
3347
|
+
// Persist tunnel hostname in config if provided
|
|
3348
|
+
if (tunnelHostname) {
|
|
3349
|
+
config.set("tunnel_hostname", tunnelHostname);
|
|
3350
|
+
console.log(chalk.gray(`\n Tunnel hostname saved: ${tunnelHostname}`));
|
|
2778
3351
|
}
|
|
2779
3352
|
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
3353
|
+
// Check for cloudflared if tunnel is requested
|
|
3354
|
+
if (tunnelHostname) {
|
|
3355
|
+
try {
|
|
3356
|
+
execSync("cloudflared --version", { encoding: "utf8", stdio: "pipe" });
|
|
3357
|
+
} catch {
|
|
3358
|
+
console.log(chalk.yellow("\n cloudflared not found — required for tunnel support"));
|
|
3359
|
+
console.log(chalk.gray(" Install: winget install Cloudflare.cloudflared"));
|
|
3360
|
+
try {
|
|
3361
|
+
const { installCloudflared } = await import("./doctor.js");
|
|
3362
|
+
await installCloudflared();
|
|
3363
|
+
} catch {
|
|
3364
|
+
console.log(chalk.yellow(" Continuing without tunnel — install cloudflared manually later"));
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
2785
3368
|
|
|
2786
|
-
|
|
3369
|
+
// Two modes:
|
|
3370
|
+
// 1. Password provided via -p flag → encrypt + install (non-interactive)
|
|
3371
|
+
// 2. No password → install watchdog that starts daemon in locked mode
|
|
3372
|
+
// The browser dashboard opens, user enters password there.
|
|
3373
|
+
// For passwordless restart, user can later run: clauth serve seal
|
|
3374
|
+
const pw = opts.pw || null;
|
|
2787
3375
|
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
const psExpr = `[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes('${pwEscaped}'),$null,'CurrentUser'))`;
|
|
2795
|
-
encrypted = execSync(`powershell -NoProfile -Command "${psExpr}"`, { encoding: "utf8" }).trim();
|
|
2796
|
-
fs.writeFileSync(BOOT_KEY_PATH, encrypted, "utf8");
|
|
2797
|
-
spinner.succeed(chalk.green("Password encrypted → boot.key"));
|
|
2798
|
-
} catch (err) {
|
|
2799
|
-
spinner.fail(chalk.red(`DPAPI encryption failed: ${err.message}`));
|
|
2800
|
-
process.exit(1);
|
|
3376
|
+
if (platform === "win32") {
|
|
3377
|
+
await installWindows(pw, tunnelHostname, execSync);
|
|
3378
|
+
} else if (platform === "darwin") {
|
|
3379
|
+
await installMacOS(pw, tunnelHostname, execSync);
|
|
3380
|
+
} else {
|
|
3381
|
+
await installLinux(pw, tunnelHostname, execSync);
|
|
2801
3382
|
}
|
|
3383
|
+
}
|
|
2802
3384
|
|
|
2803
|
-
|
|
3385
|
+
// ── Windows: DPAPI + Scheduled Task ────────────────────────
|
|
3386
|
+
async function installWindows(pw, tunnelHostname, execSync) {
|
|
3387
|
+
const autostartDir = path.join(os.homedir(), "AppData", "Roaming", "clauth");
|
|
3388
|
+
const bootKeyPath = path.join(autostartDir, "boot.key");
|
|
3389
|
+
const psScriptPath = path.join(autostartDir, "autostart.ps1");
|
|
2804
3390
|
const cliEntry = path.resolve(__dirname, "../index.js").replace(/\\/g, "\\\\");
|
|
2805
3391
|
const nodeExe = process.execPath.replace(/\\/g, "\\\\");
|
|
2806
|
-
const bootKey =
|
|
2807
|
-
const
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
3392
|
+
const bootKey = bootKeyPath.replace(/\\/g, "\\\\");
|
|
3393
|
+
const tunnelArg = tunnelHostname ? ` --tunnel ${tunnelHostname}` : "";
|
|
3394
|
+
|
|
3395
|
+
fs.mkdirSync(autostartDir, { recursive: true });
|
|
3396
|
+
|
|
3397
|
+
if (pw) {
|
|
3398
|
+
// ── Sealed mode: DPAPI-encrypt password for fully unattended restart ──
|
|
3399
|
+
const spinner = ora("Encrypting password with Windows DPAPI...").start();
|
|
3400
|
+
try {
|
|
3401
|
+
const pwEscaped = pw.replace(/'/g, "''");
|
|
3402
|
+
const psExpr = `[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes('${pwEscaped}'),$null,'CurrentUser'))`;
|
|
3403
|
+
const encrypted = execSync(`powershell -NoProfile -Command "${psExpr}"`, { encoding: "utf8" }).trim();
|
|
3404
|
+
fs.writeFileSync(bootKeyPath, encrypted, "utf8");
|
|
3405
|
+
spinner.succeed(chalk.green("Password sealed via DPAPI → boot.key"));
|
|
3406
|
+
} catch (err) {
|
|
3407
|
+
spinner.fail(chalk.red(`DPAPI encryption failed: ${err.message}`));
|
|
3408
|
+
process.exit(1);
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3411
|
+
// Watchdog script: decrypts password, starts daemon unlocked, monitors for crash
|
|
3412
|
+
const psScript = [
|
|
3413
|
+
"# clauth autostart + watchdog (sealed mode)",
|
|
3414
|
+
"# Starts unlocked and restarts on crash every 15s",
|
|
3415
|
+
"",
|
|
3416
|
+
`$enc = (Get-Content '${bootKey}' -Raw).Trim()`,
|
|
3417
|
+
`$pw = [Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($enc),$null,'CurrentUser'))`,
|
|
3418
|
+
"",
|
|
3419
|
+
"while ($true) {",
|
|
3420
|
+
" try {",
|
|
3421
|
+
" $ping = Invoke-RestMethod -Uri 'http://127.0.0.1:52437/ping' -TimeoutSec 3 -ErrorAction Stop",
|
|
3422
|
+
" } catch {",
|
|
3423
|
+
` Start-Process '${nodeExe}' -ArgumentList "'${cliEntry}' serve start -p $pw${tunnelArg}" -WindowStyle Hidden`,
|
|
3424
|
+
" Start-Sleep -Seconds 5",
|
|
3425
|
+
" }",
|
|
3426
|
+
" Start-Sleep -Seconds 15",
|
|
3427
|
+
"}",
|
|
3428
|
+
].join("\n");
|
|
3429
|
+
fs.writeFileSync(psScriptPath, psScript, "utf8");
|
|
3430
|
+
} else {
|
|
3431
|
+
// ── First-run mode: no password yet — start locked, browser opens for setup ──
|
|
3432
|
+
// Watchdog starts the daemon in locked mode; user enters password in the browser dashboard.
|
|
3433
|
+
// After entering password in the browser, user can seal it for future unattended restarts
|
|
3434
|
+
// by running: clauth serve seal
|
|
3435
|
+
const psScript = [
|
|
3436
|
+
"# clauth autostart + watchdog (locked mode — browser password entry)",
|
|
3437
|
+
"# Starts daemon locked, opens browser for password. Restarts on crash every 15s.",
|
|
3438
|
+
"",
|
|
3439
|
+
"while ($true) {",
|
|
3440
|
+
" try {",
|
|
3441
|
+
" $ping = Invoke-RestMethod -Uri 'http://127.0.0.1:52437/ping' -TimeoutSec 3 -ErrorAction Stop",
|
|
3442
|
+
" } catch {",
|
|
3443
|
+
` Start-Process '${nodeExe}' -ArgumentList "'${cliEntry}' serve start${tunnelArg}" -WindowStyle Hidden`,
|
|
3444
|
+
" Start-Sleep -Seconds 5",
|
|
3445
|
+
" }",
|
|
3446
|
+
" Start-Sleep -Seconds 15",
|
|
3447
|
+
"}",
|
|
3448
|
+
].join("\n");
|
|
3449
|
+
fs.writeFileSync(psScriptPath, psScript, "utf8");
|
|
3450
|
+
}
|
|
2814
3451
|
|
|
2815
3452
|
// Register Windows Scheduled Task — triggers on user logon
|
|
2816
|
-
const
|
|
3453
|
+
const mode = pw ? "sealed — fully unattended" : "locked — browser password entry";
|
|
3454
|
+
const spinner2 = ora(`Registering Scheduled Task (${mode})...`).start();
|
|
2817
3455
|
try {
|
|
2818
|
-
const
|
|
2819
|
-
const psScriptEsc = PS_SCRIPT_PATH.replace(/\\/g, "\\\\");
|
|
3456
|
+
const psScriptEsc = psScriptPath.replace(/\\/g, "\\\\");
|
|
2820
3457
|
const args = `-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${psScriptEsc}"`;
|
|
2821
3458
|
execSync(
|
|
2822
|
-
|
|
3459
|
+
`${process.env.SystemRoot || "C:\\\\Windows"}\\\\System32\\\\schtasks.exe /create /f /tn "${TASK_NAME}" /sc onlogon /tr "powershell.exe ${args}"`,
|
|
2823
3460
|
{ encoding: "utf8", stdio: "pipe" }
|
|
2824
3461
|
);
|
|
2825
3462
|
spinner2.succeed(chalk.green(`Scheduled Task "${TASK_NAME}" registered`));
|
|
@@ -2828,33 +3465,327 @@ async function actionInstall(opts) {
|
|
|
2828
3465
|
console.log(chalk.gray(" You can still start manually: clauth serve start"));
|
|
2829
3466
|
}
|
|
2830
3467
|
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
3468
|
+
// Start the daemon now
|
|
3469
|
+
const spinner3 = ora("Starting daemon...").start();
|
|
3470
|
+
try {
|
|
3471
|
+
if (pw) {
|
|
3472
|
+
execSync(`"${nodeExe.replace(/\\\\/g, "\\")}" "${cliEntry.replace(/\\\\/g, "\\")}" serve start -p "${pw}"`, {
|
|
3473
|
+
encoding: "utf8", stdio: "pipe", timeout: 10000,
|
|
3474
|
+
});
|
|
3475
|
+
} else {
|
|
3476
|
+
execSync(`"${nodeExe.replace(/\\\\/g, "\\")}" "${cliEntry.replace(/\\\\/g, "\\")}" serve start`, {
|
|
3477
|
+
encoding: "utf8", stdio: "pipe", timeout: 10000,
|
|
3478
|
+
});
|
|
3479
|
+
}
|
|
3480
|
+
spinner3.succeed(chalk.green("Daemon started"));
|
|
3481
|
+
} catch {
|
|
3482
|
+
spinner3.succeed(chalk.green("Daemon starting..."));
|
|
3483
|
+
}
|
|
3484
|
+
|
|
3485
|
+
// Open browser for first-run password setup (locked mode only)
|
|
3486
|
+
if (!pw) {
|
|
3487
|
+
try { openBrowser("http://127.0.0.1:52437"); } catch {}
|
|
3488
|
+
}
|
|
3489
|
+
|
|
3490
|
+
console.log(chalk.cyan("\n Auto-start installed (Windows):\n"));
|
|
3491
|
+
if (pw) {
|
|
3492
|
+
console.log(chalk.gray(` mode: sealed (DPAPI — fully unattended restart)`));
|
|
3493
|
+
console.log(chalk.gray(` boot.key: ${bootKeyPath}`));
|
|
3494
|
+
} else {
|
|
3495
|
+
console.log(chalk.gray(` mode: locked (enter password in browser on restart)`));
|
|
3496
|
+
console.log(chalk.gray(` browser: http://127.0.0.1:52437`));
|
|
3497
|
+
console.log(chalk.gray(` seal later: clauth serve seal (enables unattended restart)`));
|
|
3498
|
+
}
|
|
3499
|
+
console.log(chalk.gray(` watchdog: ${psScriptPath}`));
|
|
3500
|
+
console.log(chalk.gray(` task: ${TASK_NAME} (restarts every 15s on crash)`));
|
|
3501
|
+
if (tunnelHostname) console.log(chalk.gray(` tunnel: ${tunnelHostname}`));
|
|
3502
|
+
console.log(chalk.green("\n Done. Daemon will auto-start on login and restart on crash.\n"));
|
|
2836
3503
|
}
|
|
2837
3504
|
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
3505
|
+
// ── macOS: Keychain + LaunchAgent ──────────────────────────
|
|
3506
|
+
async function installMacOS(pw, tunnelHostname, execSync) {
|
|
3507
|
+
const launchAgentsDir = path.join(os.homedir(), "Library", "LaunchAgents");
|
|
3508
|
+
const plistPath = path.join(launchAgentsDir, "com.lifeai.clauth.plist");
|
|
3509
|
+
const keychainService = "com.lifeai.clauth";
|
|
3510
|
+
const keychainAccount = "clauth-daemon";
|
|
3511
|
+
|
|
3512
|
+
fs.mkdirSync(launchAgentsDir, { recursive: true });
|
|
3513
|
+
|
|
3514
|
+
// Store password in macOS Keychain
|
|
3515
|
+
const spinner = ora("Storing password in macOS Keychain...").start();
|
|
3516
|
+
try {
|
|
3517
|
+
// Delete existing entry if present (ignore errors)
|
|
3518
|
+
try {
|
|
3519
|
+
execSync(
|
|
3520
|
+
`security delete-generic-password -s "${keychainService}" -a "${keychainAccount}"`,
|
|
3521
|
+
{ encoding: "utf8", stdio: "pipe" }
|
|
3522
|
+
);
|
|
3523
|
+
} catch {}
|
|
3524
|
+
|
|
3525
|
+
execSync(
|
|
3526
|
+
`security add-generic-password -s "${keychainService}" -a "${keychainAccount}" -w "${pw.replace(/"/g, '\\"')}" -U`,
|
|
3527
|
+
{ encoding: "utf8", stdio: "pipe" }
|
|
3528
|
+
);
|
|
3529
|
+
spinner.succeed(chalk.green("Password stored in Keychain"));
|
|
3530
|
+
} catch (err) {
|
|
3531
|
+
spinner.fail(chalk.red(`Keychain storage failed: ${err.message}`));
|
|
2841
3532
|
process.exit(1);
|
|
2842
3533
|
}
|
|
3534
|
+
|
|
3535
|
+
// Find the node executable and cli entry
|
|
3536
|
+
const cliEntry = path.resolve(__dirname, "../index.js");
|
|
3537
|
+
const nodeExe = process.execPath;
|
|
3538
|
+
|
|
3539
|
+
// Build shell command that reads from Keychain and starts clauth
|
|
3540
|
+
const tunnelArg = tunnelHostname ? ` --tunnel ${tunnelHostname}` : "";
|
|
3541
|
+
const shellCmd = `PW=$(security find-generic-password -s "${keychainService}" -a "${keychainAccount}" -w) && exec "${nodeExe}" "${cliEntry}" serve start -p "$PW"${tunnelArg}`;
|
|
3542
|
+
|
|
3543
|
+
// Create LaunchAgent plist
|
|
3544
|
+
const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
|
|
3545
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3546
|
+
<plist version="1.0">
|
|
3547
|
+
<dict>
|
|
3548
|
+
<key>Label</key>
|
|
3549
|
+
<string>com.lifeai.clauth</string>
|
|
3550
|
+
<key>ProgramArguments</key>
|
|
3551
|
+
<array>
|
|
3552
|
+
<string>/bin/sh</string>
|
|
3553
|
+
<string>-c</string>
|
|
3554
|
+
<string>${shellCmd.replace(/&/g, "&").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();
|
|
2843
3698
|
const { execSync } = await import("child_process");
|
|
2844
3699
|
|
|
3700
|
+
if (platform === "win32") {
|
|
3701
|
+
await uninstallWindows(execSync);
|
|
3702
|
+
} else if (platform === "darwin") {
|
|
3703
|
+
await uninstallMacOS(execSync);
|
|
3704
|
+
} else {
|
|
3705
|
+
await uninstallLinux(execSync);
|
|
3706
|
+
}
|
|
3707
|
+
}
|
|
3708
|
+
|
|
3709
|
+
async function uninstallWindows(execSync) {
|
|
3710
|
+
const autostartDir = path.join(os.homedir(), "AppData", "Roaming", "clauth");
|
|
3711
|
+
const bootKeyPath = path.join(autostartDir, "boot.key");
|
|
3712
|
+
const psScriptPath = path.join(autostartDir, "autostart.ps1");
|
|
3713
|
+
|
|
2845
3714
|
// Remove scheduled task
|
|
2846
3715
|
try {
|
|
2847
|
-
execSync(
|
|
3716
|
+
execSync(`${process.env.SystemRoot || "C:\\\\Windows"}\\\\System32\\\\schtasks.exe /delete /f /tn "${TASK_NAME}"`, { encoding: "utf8", stdio: "pipe" });
|
|
2848
3717
|
console.log(chalk.green(` Removed Scheduled Task: ${TASK_NAME}`));
|
|
2849
3718
|
} catch { console.log(chalk.gray(` Task not found (already removed): ${TASK_NAME}`)); }
|
|
2850
3719
|
|
|
2851
3720
|
// Remove boot.key and autostart script
|
|
2852
|
-
for (const f of [
|
|
3721
|
+
for (const f of [bootKeyPath, psScriptPath]) {
|
|
2853
3722
|
try { fs.unlinkSync(f); console.log(chalk.green(` Deleted: ${f}`)); }
|
|
2854
3723
|
catch { console.log(chalk.gray(` Not found (skipped): ${f}`)); }
|
|
2855
3724
|
}
|
|
2856
3725
|
|
|
2857
|
-
console.log(chalk.cyan("\n Auto-start uninstalled.\n"));
|
|
3726
|
+
console.log(chalk.cyan("\n Auto-start uninstalled (Windows).\n"));
|
|
3727
|
+
}
|
|
3728
|
+
|
|
3729
|
+
async function uninstallMacOS(execSync) {
|
|
3730
|
+
const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", "com.lifeai.clauth.plist");
|
|
3731
|
+
const keychainService = "com.lifeai.clauth";
|
|
3732
|
+
const keychainAccount = "clauth-daemon";
|
|
3733
|
+
|
|
3734
|
+
// Unload LaunchAgent
|
|
3735
|
+
try {
|
|
3736
|
+
execSync(`launchctl unload "${plistPath}"`, { stdio: "pipe" });
|
|
3737
|
+
console.log(chalk.green(" Unloaded LaunchAgent"));
|
|
3738
|
+
} catch { console.log(chalk.gray(" LaunchAgent not loaded (already removed)")); }
|
|
3739
|
+
|
|
3740
|
+
// Remove plist
|
|
3741
|
+
try { fs.unlinkSync(plistPath); console.log(chalk.green(` Deleted: ${plistPath}`)); }
|
|
3742
|
+
catch { console.log(chalk.gray(` Not found (skipped): ${plistPath}`)); }
|
|
3743
|
+
|
|
3744
|
+
// Remove Keychain entry
|
|
3745
|
+
try {
|
|
3746
|
+
execSync(
|
|
3747
|
+
`security delete-generic-password -s "${keychainService}" -a "${keychainAccount}"`,
|
|
3748
|
+
{ encoding: "utf8", stdio: "pipe" }
|
|
3749
|
+
);
|
|
3750
|
+
console.log(chalk.green(" Removed Keychain entry"));
|
|
3751
|
+
} catch { console.log(chalk.gray(" Keychain entry not found (already removed)")); }
|
|
3752
|
+
|
|
3753
|
+
console.log(chalk.cyan("\n Auto-start uninstalled (macOS).\n"));
|
|
3754
|
+
}
|
|
3755
|
+
|
|
3756
|
+
async function uninstallLinux(execSync) {
|
|
3757
|
+
const configDir = path.join(os.homedir(), ".config", "clauth");
|
|
3758
|
+
const bootKeyPath = path.join(configDir, "boot.key");
|
|
3759
|
+
const wrapperPath = path.join(configDir, "start.sh");
|
|
3760
|
+
const serviceFile = path.join(os.homedir(), ".config", "systemd", "user", "clauth.service");
|
|
3761
|
+
|
|
3762
|
+
// Stop and disable systemd service
|
|
3763
|
+
try {
|
|
3764
|
+
execSync("systemctl --user stop clauth", { stdio: "pipe" });
|
|
3765
|
+
execSync("systemctl --user disable clauth", { stdio: "pipe" });
|
|
3766
|
+
console.log(chalk.green(" Stopped and disabled systemd service"));
|
|
3767
|
+
} catch { console.log(chalk.gray(" systemd service not running (already removed)")); }
|
|
3768
|
+
|
|
3769
|
+
// Remove service file
|
|
3770
|
+
try { fs.unlinkSync(serviceFile); console.log(chalk.green(` Deleted: ${serviceFile}`)); }
|
|
3771
|
+
catch { console.log(chalk.gray(` Not found (skipped): ${serviceFile}`)); }
|
|
3772
|
+
|
|
3773
|
+
// Reload systemd
|
|
3774
|
+
try { execSync("systemctl --user daemon-reload", { stdio: "pipe" }); } catch {}
|
|
3775
|
+
|
|
3776
|
+
// Remove boot.key and wrapper script
|
|
3777
|
+
for (const f of [bootKeyPath, wrapperPath]) {
|
|
3778
|
+
try { fs.unlinkSync(f); console.log(chalk.green(` Deleted: ${f}`)); }
|
|
3779
|
+
catch { console.log(chalk.gray(` Not found (skipped): ${f}`)); }
|
|
3780
|
+
}
|
|
3781
|
+
|
|
3782
|
+
// Try to remove secret-tool entry
|
|
3783
|
+
try {
|
|
3784
|
+
execSync("secret-tool clear service clauth account daemon", { stdio: "pipe" });
|
|
3785
|
+
console.log(chalk.green(" Removed secret-tool entry"));
|
|
3786
|
+
} catch { /* secret-tool may not be installed */ }
|
|
3787
|
+
|
|
3788
|
+
console.log(chalk.cyan("\n Auto-start uninstalled (Linux).\n"));
|
|
2858
3789
|
}
|
|
2859
3790
|
|
|
2860
3791
|
// ── Export ────────────────────────────────────────────────────
|