@lifeaitools/clauth 1.2.3 → 1.3.1
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/commands/serve.js +329 -39
- package/cli/index.js +3 -1
- package/package.json +1 -1
package/cli/commands/serve.js
CHANGED
|
@@ -58,7 +58,10 @@ ALTER TABLE clauth_config ENABLE ROW LEVEL SECURITY;`,
|
|
|
58
58
|
const CURRENT_SCHEMA_VERSION = 3;
|
|
59
59
|
|
|
60
60
|
const PID_FILE = path.join(os.tmpdir(), "clauth-serve.pid");
|
|
61
|
+
const STAGED_PID_FILE = path.join(os.tmpdir(), "clauth-serve-staged.pid");
|
|
61
62
|
const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
|
|
63
|
+
const LIVE_PORT = 52437;
|
|
64
|
+
const STAGED_PORT = 52438;
|
|
62
65
|
|
|
63
66
|
// ── PID helpers ──────────────────────────────────────────────
|
|
64
67
|
function readPid() {
|
|
@@ -73,6 +76,22 @@ function writePid(pid, port) {
|
|
|
73
76
|
fs.writeFileSync(PID_FILE, `${pid}:${port}`, "utf8");
|
|
74
77
|
}
|
|
75
78
|
|
|
79
|
+
function readStagedPid() {
|
|
80
|
+
try {
|
|
81
|
+
const raw = fs.readFileSync(STAGED_PID_FILE, "utf8").trim();
|
|
82
|
+
const [pid, port] = raw.split(":");
|
|
83
|
+
return { pid: parseInt(pid, 10), port: parseInt(port, 10) };
|
|
84
|
+
} catch { return null; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function writeStagedPid(pid, port) {
|
|
88
|
+
fs.writeFileSync(STAGED_PID_FILE, `${pid}:${port}`, "utf8");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function removeStagedPid() {
|
|
92
|
+
try { fs.unlinkSync(STAGED_PID_FILE); } catch {}
|
|
93
|
+
}
|
|
94
|
+
|
|
76
95
|
function removePid() {
|
|
77
96
|
try { fs.unlinkSync(PID_FILE); } catch {}
|
|
78
97
|
}
|
|
@@ -91,13 +110,13 @@ function openBrowser(url) {
|
|
|
91
110
|
}
|
|
92
111
|
|
|
93
112
|
// ── Dashboard HTML ───────────────────────────────────────────
|
|
94
|
-
function dashboardHtml(port, whitelist) {
|
|
113
|
+
function dashboardHtml(port, whitelist, isStaged = false) {
|
|
95
114
|
return `<!DOCTYPE html>
|
|
96
115
|
<html lang="en">
|
|
97
116
|
<head>
|
|
98
117
|
<meta charset="utf-8">
|
|
99
118
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
100
|
-
<title>clauth vault v${VERSION}</title>
|
|
119
|
+
<title>clauth vault v${VERSION}${isStaged ? " (STAGED)" : ""}</title>
|
|
101
120
|
<style>
|
|
102
121
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
103
122
|
body{background:#0a0f1a;color:#e2e8f0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;min-height:100vh}
|
|
@@ -341,8 +360,15 @@ function dashboardHtml(port, whitelist) {
|
|
|
341
360
|
</div>
|
|
342
361
|
<div class="header">
|
|
343
362
|
<div class="dot" id="dot"></div>
|
|
344
|
-
<h1>🔐 clauth vault <span style="font-size:0.55em;opacity:0.45;font-weight:400">v${VERSION}</span
|
|
363
|
+
<h1>🔐 clauth vault <span style="font-size:0.55em;opacity:0.45;font-weight:400">v${VERSION}</span>${isStaged ? `<span style="font-size:0.5em;background:#b45309;color:#fef3c7;border-radius:4px;padding:2px 10px;margin-left:12px;font-weight:600;letter-spacing:.5px">STAGED</span>` : ""}</h1>
|
|
345
364
|
</div>
|
|
365
|
+
${isStaged ? `<div id="staged-banner" style="background:linear-gradient(135deg,#78350f,#92400e);border:1px solid #b45309;border-radius:6px;padding:12px 16px;margin:0 16px 8px;display:flex;align-items:center;justify-content:space-between">
|
|
366
|
+
<div>
|
|
367
|
+
<strong style="color:#fef3c7">⚡ Staged deployment</strong>
|
|
368
|
+
<span style="color:#fcd34d;font-size:.85rem;margin-left:8px">Running on port ${port} — verify before making live</span>
|
|
369
|
+
</div>
|
|
370
|
+
<button onclick="makeLive()" style="background:#15803d;border:1px solid #16a34a;color:#dcfce7;padding:6px 16px;border-radius:4px;cursor:pointer;font-weight:600;font-size:.85rem">✓ Make Live</button>
|
|
371
|
+
</div>` : ""}
|
|
346
372
|
<div id="error-bar" class="error-bar"></div>
|
|
347
373
|
<div class="status-bar">
|
|
348
374
|
<div>PID: <span id="s-pid">—</span></div>
|
|
@@ -364,6 +390,7 @@ function dashboardHtml(port, whitelist) {
|
|
|
364
390
|
<button class="btn-add" onclick="toggleAddService()">+ Add Service</button>
|
|
365
391
|
<button class="btn-check" id="check-btn" onclick="checkAll()">⬤ Check All</button>
|
|
366
392
|
<button class="btn-lock" onclick="lockVault()">🔒 Lock</button>
|
|
393
|
+
<button class="btn-stop" onclick="stopDaemon()" style="background:#7f1d1d;border:1px solid #991b1b;color:#fca5a5" title="Stop daemon — password required on next start">⏹ Stop</button>
|
|
367
394
|
<button class="btn-cancel" style="margin-left:auto" onclick="toggleChangePw()">Change Password</button>
|
|
368
395
|
</div>
|
|
369
396
|
|
|
@@ -695,6 +722,34 @@ async function lockVault() {
|
|
|
695
722
|
showLockScreen();
|
|
696
723
|
}
|
|
697
724
|
|
|
725
|
+
// ── Make Live (blue-green: promote staged instance to live port) ──
|
|
726
|
+
async function makeLive() {
|
|
727
|
+
if (!confirm("Promote this staged instance to live?\\n\\nThe current live daemon (port ${LIVE_PORT}) will be stopped and this instance will restart on port ${LIVE_PORT}.")) return;
|
|
728
|
+
const banner = document.getElementById("staged-banner");
|
|
729
|
+
if (banner) banner.innerHTML = '<div style="color:#fef3c7"><strong>⏳ Promoting to live...</strong></div>';
|
|
730
|
+
try {
|
|
731
|
+
const resp = await fetch(BASE + "/make-live", { method: "POST" });
|
|
732
|
+
const data = await resp.json();
|
|
733
|
+
if (data.ok) {
|
|
734
|
+
document.getElementById("main-view").innerHTML = '<div style="text-align:center;padding:80px 20px"><div style="font-size:3rem;margin-bottom:16px">✅</div><div style="font-size:1.1rem;color:#4ade80">Promoted to live!</div><div style="font-size:.85rem;color:#94a3b8;margin-top:8px">Restarting on port ${LIVE_PORT}... page will reload in 5 seconds.</div></div>';
|
|
735
|
+
setTimeout(() => { window.location.href = "http://127.0.0.1:" + ${LIVE_PORT}; }, 5000);
|
|
736
|
+
} else {
|
|
737
|
+
if (banner) banner.innerHTML = '<div style="color:#fca5a5"><strong>❌ Promotion failed:</strong> ' + (data.error || 'unknown') + '</div>';
|
|
738
|
+
}
|
|
739
|
+
} catch (err) {
|
|
740
|
+
if (banner) banner.innerHTML = '<div style="color:#fca5a5"><strong>❌ Promotion failed:</strong> ' + err.message + '</div>';
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// ── Stop daemon (user-initiated — clears boot.key, requires password on restart) ──
|
|
745
|
+
async function stopDaemon() {
|
|
746
|
+
if (!confirm("Stop the daemon?\\n\\nCredentials will need to be re-entered on next start.")) return;
|
|
747
|
+
try {
|
|
748
|
+
await fetch(BASE + "/shutdown-ui", { method: "POST" });
|
|
749
|
+
} catch {}
|
|
750
|
+
document.getElementById("main-view").innerHTML = '<div style="text-align:center;padding:80px 20px"><div style="font-size:3rem;margin-bottom:16px">⏹</div><div style="font-size:1.1rem;color:#94a3b8">Daemon stopped</div><div style="font-size:.85rem;color:#64748b;margin-top:8px">Restart with: clauth serve start</div></div>';
|
|
751
|
+
}
|
|
752
|
+
|
|
698
753
|
// ── Load services ───────────────────────────
|
|
699
754
|
let allServices = [];
|
|
700
755
|
let activeProjectTab = "all";
|
|
@@ -1780,7 +1835,7 @@ function readBody(req) {
|
|
|
1780
1835
|
}
|
|
1781
1836
|
|
|
1782
1837
|
// ── Server logic (shared by foreground + daemon) ─────────────
|
|
1783
|
-
function createServer(initPassword, whitelist, port, tunnelHostnameInit = null) {
|
|
1838
|
+
function createServer(initPassword, whitelist, port, tunnelHostnameInit = null, isStaged = false) {
|
|
1784
1839
|
// tunnelHostname may be updated at runtime (fetched from DB after unlock)
|
|
1785
1840
|
let tunnelHostname = tunnelHostnameInit;
|
|
1786
1841
|
|
|
@@ -1916,6 +1971,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null)
|
|
|
1916
1971
|
|
|
1917
1972
|
const proc = spawnProc(cfBin, args, {
|
|
1918
1973
|
stdio: ["ignore", "pipe", "pipe"],
|
|
1974
|
+
windowsHide: true,
|
|
1919
1975
|
});
|
|
1920
1976
|
|
|
1921
1977
|
tunnelProc = proc;
|
|
@@ -2516,7 +2572,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null)
|
|
|
2516
2572
|
// GET / — built-in web dashboard
|
|
2517
2573
|
if (method === "GET" && reqPath === "/") {
|
|
2518
2574
|
res.writeHead(200, { "Content-Type": "text/html", ...CORS });
|
|
2519
|
-
return res.end(dashboardHtml(port, whitelist));
|
|
2575
|
+
return res.end(dashboardHtml(port, whitelist, isStaged));
|
|
2520
2576
|
}
|
|
2521
2577
|
|
|
2522
2578
|
// GET /ping
|
|
@@ -2532,6 +2588,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null)
|
|
|
2532
2588
|
tunnel_status: tunnelStatus,
|
|
2533
2589
|
tunnel_url: tunnelUrl || null,
|
|
2534
2590
|
app_version: VERSION,
|
|
2591
|
+
staged: isStaged,
|
|
2535
2592
|
schema_version: CURRENT_SCHEMA_VERSION,
|
|
2536
2593
|
});
|
|
2537
2594
|
}
|
|
@@ -2603,7 +2660,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null)
|
|
|
2603
2660
|
return ok(res, { status: tunnelStatus });
|
|
2604
2661
|
}
|
|
2605
2662
|
|
|
2606
|
-
// GET /shutdown (for daemon stop)
|
|
2663
|
+
// GET /shutdown (for daemon stop — programmatic, keeps boot.key)
|
|
2607
2664
|
if (method === "GET" && reqPath === "/shutdown") {
|
|
2608
2665
|
stopTunnel();
|
|
2609
2666
|
ok(res, { ok: true, message: "shutting down" });
|
|
@@ -2612,6 +2669,100 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null)
|
|
|
2612
2669
|
return;
|
|
2613
2670
|
}
|
|
2614
2671
|
|
|
2672
|
+
// POST /shutdown-ui (user-initiated stop — clears boot.key so password is required on restart)
|
|
2673
|
+
if (method === "POST" && reqPath === "/shutdown-ui") {
|
|
2674
|
+
stopTunnel();
|
|
2675
|
+
// Clear boot.key so watchdog can't auto-unlock on restart
|
|
2676
|
+
const bootKeyPath = getBootKeyPath();
|
|
2677
|
+
if (bootKeyPath) {
|
|
2678
|
+
try { fs.unlinkSync(bootKeyPath); } catch {}
|
|
2679
|
+
}
|
|
2680
|
+
ok(res, { ok: true, message: "shutting down (user-initiated, boot.key cleared)" });
|
|
2681
|
+
const logLine = `[${new Date().toISOString()}] User-initiated shutdown via UI — boot.key cleared\n`;
|
|
2682
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
2683
|
+
removePid();
|
|
2684
|
+
setTimeout(() => process.exit(0), 100);
|
|
2685
|
+
return;
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
// POST /make-live (blue-green: staged instance promotes itself to live port)
|
|
2689
|
+
if (method === "POST" && reqPath === "/make-live") {
|
|
2690
|
+
if (!isStaged) {
|
|
2691
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
2692
|
+
return res.end(JSON.stringify({ error: "Not a staged instance" }));
|
|
2693
|
+
}
|
|
2694
|
+
const logLine = `[${new Date().toISOString()}] Make-live: promoting staged (port ${port}) to live (port ${LIVE_PORT})\n`;
|
|
2695
|
+
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
2696
|
+
|
|
2697
|
+
// Stop the current live daemon if running
|
|
2698
|
+
try { await fetch(`http://127.0.0.1:${LIVE_PORT}/shutdown`); } catch {}
|
|
2699
|
+
|
|
2700
|
+
// Wait for port to be released (Windows is slow to release sockets)
|
|
2701
|
+
let portFree = false;
|
|
2702
|
+
for (let i = 0; i < 10; i++) {
|
|
2703
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
2704
|
+
try {
|
|
2705
|
+
await fetch(`http://127.0.0.1:${LIVE_PORT}/ping`);
|
|
2706
|
+
// Still responding — not dead yet
|
|
2707
|
+
} catch {
|
|
2708
|
+
portFree = true;
|
|
2709
|
+
break;
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
if (!portFree) {
|
|
2713
|
+
const errLog = `[${new Date().toISOString()}] Make-live: live daemon on port ${LIVE_PORT} failed to stop within 10s\n`;
|
|
2714
|
+
try { fs.appendFileSync(LOG_FILE, errLog); } catch {}
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
// Stop tunnel on this instance (will restart on new port)
|
|
2718
|
+
stopTunnel();
|
|
2719
|
+
|
|
2720
|
+
// Clean up staged PID file
|
|
2721
|
+
removeStagedPid();
|
|
2722
|
+
|
|
2723
|
+
// Spawn a new daemon on the live port with same credentials
|
|
2724
|
+
const { spawn } = await import("child_process");
|
|
2725
|
+
const cliEntry = path.resolve(__dirname, "../index.js");
|
|
2726
|
+
const childArgs = [cliEntry, "serve", "start", "--port", String(LIVE_PORT)];
|
|
2727
|
+
if (password) childArgs.push("--pw", password);
|
|
2728
|
+
if (whitelist) childArgs.push("--services", whitelist.join(","));
|
|
2729
|
+
if (tunnelHostname) childArgs.push("--tunnel", tunnelHostname);
|
|
2730
|
+
|
|
2731
|
+
const out = fs.openSync(LOG_FILE, "a");
|
|
2732
|
+
const childEnv = { ...process.env, __CLAUTH_DAEMON: "1" };
|
|
2733
|
+
delete childEnv.__CLAUTH_STAGED; // Clear staged flag — child is live
|
|
2734
|
+
const child = spawn(process.execPath, childArgs, {
|
|
2735
|
+
detached: true,
|
|
2736
|
+
stdio: ["ignore", out, out],
|
|
2737
|
+
env: childEnv,
|
|
2738
|
+
});
|
|
2739
|
+
child.unref();
|
|
2740
|
+
|
|
2741
|
+
// Wait for new live daemon to be reachable
|
|
2742
|
+
let promoted = false;
|
|
2743
|
+
for (let i = 0; i < 8; i++) {
|
|
2744
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
2745
|
+
try {
|
|
2746
|
+
const resp = await fetch(`http://127.0.0.1:${LIVE_PORT}/ping`);
|
|
2747
|
+
if (resp.ok) { promoted = true; break; }
|
|
2748
|
+
} catch {}
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
if (promoted) {
|
|
2752
|
+
const okLog = `[${new Date().toISOString()}] Make-live: promoted to live on port ${LIVE_PORT}\n`;
|
|
2753
|
+
try { fs.appendFileSync(LOG_FILE, okLog); } catch {}
|
|
2754
|
+
ok(res, { ok: true, message: "promoted to live", live_port: LIVE_PORT });
|
|
2755
|
+
} else {
|
|
2756
|
+
const failLog = `[${new Date().toISOString()}] Make-live: new daemon failed to start on port ${LIVE_PORT}\n`;
|
|
2757
|
+
try { fs.appendFileSync(LOG_FILE, failLog); } catch {}
|
|
2758
|
+
ok(res, { ok: false, error: "New daemon failed to start on live port — check log" });
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
// Self-terminate after response is sent
|
|
2762
|
+
setTimeout(() => process.exit(0), 500);
|
|
2763
|
+
return;
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2615
2766
|
// Locked guard — returns true if locked and already responded
|
|
2616
2767
|
function lockedGuard(res) {
|
|
2617
2768
|
if (!password) {
|
|
@@ -2708,6 +2859,25 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null)
|
|
|
2708
2859
|
password = pw; // unlock — store in process memory only
|
|
2709
2860
|
const logLine = `[${new Date().toISOString()}] Vault unlocked\n`;
|
|
2710
2861
|
try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
|
|
2862
|
+
// Auto-seal: DPAPI-encrypt password to boot.key for passwordless crash recovery
|
|
2863
|
+
// Only on Windows; only if autostart dir exists (i.e., install was run)
|
|
2864
|
+
if (os.platform() === "win32") {
|
|
2865
|
+
const autostartDir = path.join(os.homedir(), "AppData", "Roaming", "clauth");
|
|
2866
|
+
const bootKeyPath = path.join(autostartDir, "boot.key");
|
|
2867
|
+
if (fs.existsSync(autostartDir)) {
|
|
2868
|
+
try {
|
|
2869
|
+
const pwEscaped = pw.replace(/'/g, "''");
|
|
2870
|
+
const psExpr = `[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes('${pwEscaped}'),$null,'CurrentUser'))`;
|
|
2871
|
+
const encrypted = execSyncTop(`powershell -NoProfile -Command "${psExpr}"`, { encoding: "utf8", timeout: 5000 }).trim();
|
|
2872
|
+
fs.writeFileSync(bootKeyPath, encrypted, "utf8");
|
|
2873
|
+
const sealLog = `[${new Date().toISOString()}] Auto-sealed boot.key via DPAPI (crash recovery enabled)\n`;
|
|
2874
|
+
try { fs.appendFileSync(LOG_FILE, sealLog); } catch {}
|
|
2875
|
+
} catch (sealErr) {
|
|
2876
|
+
const sealLog = `[${new Date().toISOString()}] Auto-seal failed (non-fatal): ${sealErr.message}\n`;
|
|
2877
|
+
try { fs.appendFileSync(LOG_FILE, sealLog); } catch {}
|
|
2878
|
+
}
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2711
2881
|
// Auto-start tunnel: --tunnel flag takes priority, otherwise fetch from DB
|
|
2712
2882
|
if (!tunnelHostname) {
|
|
2713
2883
|
fetchTunnelConfig().then(configured => {
|
|
@@ -3426,23 +3596,43 @@ async function verifyAuth(password) {
|
|
|
3426
3596
|
}
|
|
3427
3597
|
|
|
3428
3598
|
async function actionStart(opts) {
|
|
3429
|
-
const
|
|
3599
|
+
const isStaged = !!opts.staged || process.env.__CLAUTH_STAGED === "1";
|
|
3600
|
+
const port = isStaged ? STAGED_PORT : parseInt(opts.port || String(LIVE_PORT), 10);
|
|
3430
3601
|
const password = opts.pw;
|
|
3431
3602
|
const tunnelHostname = opts.tunnel || null;
|
|
3432
3603
|
const whitelist = opts.services
|
|
3433
3604
|
? opts.services.split(",").map(s => s.trim().toLowerCase())
|
|
3434
3605
|
: null;
|
|
3435
3606
|
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3607
|
+
if (isStaged) {
|
|
3608
|
+
// Staged mode: allow running alongside live instance
|
|
3609
|
+
const existingStaged = readStagedPid();
|
|
3610
|
+
if (existingStaged) {
|
|
3611
|
+
try {
|
|
3612
|
+
const resp = await fetch(`http://127.0.0.1:${existingStaged.port}/ping`);
|
|
3613
|
+
if (resp.ok) {
|
|
3614
|
+
console.log(chalk.yellow(`\n Staged instance already running (PID ${existingStaged.pid}, port ${existingStaged.port})`));
|
|
3615
|
+
console.log(chalk.gray(` Open http://127.0.0.1:${existingStaged.port} to verify, then click Make Live\n`));
|
|
3616
|
+
process.exit(1);
|
|
3617
|
+
}
|
|
3618
|
+
} catch {}
|
|
3619
|
+
removeStagedPid();
|
|
3620
|
+
}
|
|
3621
|
+
} else {
|
|
3622
|
+
// Normal mode: check for existing instance
|
|
3623
|
+
const existing = readPid();
|
|
3624
|
+
if (existing && isProcessAlive(existing.pid)) {
|
|
3625
|
+
console.log(chalk.yellow(`\n clauth serve already running (PID ${existing.pid}, port ${existing.port})`));
|
|
3626
|
+
console.log(chalk.gray(` Stop it first: clauth serve stop\n`));
|
|
3627
|
+
process.exit(1);
|
|
3628
|
+
}
|
|
3442
3629
|
}
|
|
3443
3630
|
|
|
3444
3631
|
// If we're the daemon child, run the server directly
|
|
3445
3632
|
if (process.env.__CLAUTH_DAEMON === "1") {
|
|
3633
|
+
// Set process title for task manager visibility
|
|
3634
|
+
process.title = isStaged ? `clauth-staged (port ${port})` : `clauth (port ${port})`;
|
|
3635
|
+
|
|
3446
3636
|
// Verify password only if one was provided at start (optional — browser can unlock later)
|
|
3447
3637
|
if (password) {
|
|
3448
3638
|
try {
|
|
@@ -3454,10 +3644,15 @@ async function actionStart(opts) {
|
|
|
3454
3644
|
}
|
|
3455
3645
|
}
|
|
3456
3646
|
|
|
3457
|
-
const server = createServer(password, whitelist, port, tunnelHostname);
|
|
3647
|
+
const server = createServer(password, whitelist, port, tunnelHostname, isStaged);
|
|
3458
3648
|
server.listen(port, "127.0.0.1", () => {
|
|
3459
|
-
|
|
3460
|
-
|
|
3649
|
+
if (isStaged) {
|
|
3650
|
+
writeStagedPid(process.pid, port);
|
|
3651
|
+
} else {
|
|
3652
|
+
writePid(process.pid, port);
|
|
3653
|
+
}
|
|
3654
|
+
const label = isStaged ? "STAGED" : "LIVE";
|
|
3655
|
+
const msg = `[${new Date().toISOString()}] clauth serve started [${label}] — PID ${process.pid}, port ${port}, services: ${whitelist ? whitelist.join(",") : "all"}\n`;
|
|
3461
3656
|
fs.appendFileSync(LOG_FILE, msg);
|
|
3462
3657
|
});
|
|
3463
3658
|
|
|
@@ -3467,7 +3662,10 @@ async function actionStart(opts) {
|
|
|
3467
3662
|
process.exit(1);
|
|
3468
3663
|
});
|
|
3469
3664
|
|
|
3470
|
-
const shutdown = () => {
|
|
3665
|
+
const shutdown = () => {
|
|
3666
|
+
if (isStaged) { removeStagedPid(); } else { removePid(); }
|
|
3667
|
+
process.exit(0);
|
|
3668
|
+
};
|
|
3471
3669
|
process.on("SIGTERM", shutdown);
|
|
3472
3670
|
process.on("SIGINT", shutdown);
|
|
3473
3671
|
return;
|
|
@@ -3499,12 +3697,13 @@ async function actionStart(opts) {
|
|
|
3499
3697
|
if (password) childArgs.push("--pw", password);
|
|
3500
3698
|
if (opts.services) childArgs.push("--services", opts.services);
|
|
3501
3699
|
if (tunnelHostname) childArgs.push("--tunnel", tunnelHostname);
|
|
3700
|
+
if (isStaged) childArgs.push("--staged");
|
|
3502
3701
|
|
|
3503
3702
|
const out = fs.openSync(LOG_FILE, "a");
|
|
3504
3703
|
const child = spawn(process.execPath, childArgs, {
|
|
3505
3704
|
detached: true,
|
|
3506
3705
|
stdio: ["ignore", out, out],
|
|
3507
|
-
env: { ...process.env, __CLAUTH_DAEMON: "1" },
|
|
3706
|
+
env: { ...process.env, __CLAUTH_DAEMON: "1", ...(isStaged ? { __CLAUTH_STAGED: "1" } : {}) },
|
|
3508
3707
|
});
|
|
3509
3708
|
child.unref();
|
|
3510
3709
|
|
|
@@ -3519,14 +3718,18 @@ async function actionStart(opts) {
|
|
|
3519
3718
|
} catch {}
|
|
3520
3719
|
}
|
|
3521
3720
|
|
|
3522
|
-
const
|
|
3721
|
+
const readInfo = isStaged ? readStagedPid : readPid;
|
|
3722
|
+
const info = readInfo();
|
|
3523
3723
|
if (started && info) {
|
|
3524
|
-
|
|
3724
|
+
const label = isStaged ? "⚡ STAGED" : "🔐";
|
|
3725
|
+
console.log(chalk.green(`\n ${label} clauth serve started`));
|
|
3525
3726
|
console.log(chalk.gray(` PID: ${info.pid}`));
|
|
3526
3727
|
console.log(chalk.gray(` Port: 127.0.0.1:${info.port}`));
|
|
3527
3728
|
console.log(chalk.gray(` Services: ${whitelist ? whitelist.join(", ") : "all"}`));
|
|
3528
3729
|
console.log(chalk.gray(` Log: ${LOG_FILE}`));
|
|
3529
|
-
if (
|
|
3730
|
+
if (isStaged) {
|
|
3731
|
+
console.log(chalk.yellow(`\n ⚡ Staged on port ${port} — open dashboard to verify, then click "Make Live"`));
|
|
3732
|
+
} else if (!password) {
|
|
3530
3733
|
console.log(chalk.cyan(`\n 👉 Open http://127.0.0.1:${info.port} to unlock the vault`));
|
|
3531
3734
|
}
|
|
3532
3735
|
console.log(chalk.gray(` Stop: clauth serve stop\n`));
|
|
@@ -4317,19 +4520,33 @@ async function installWindows(pw, tunnelHostname, execSync) {
|
|
|
4317
4520
|
].join("\n");
|
|
4318
4521
|
fs.writeFileSync(psScriptPath, psScript, "utf8");
|
|
4319
4522
|
} else {
|
|
4320
|
-
// ──
|
|
4321
|
-
//
|
|
4322
|
-
//
|
|
4323
|
-
// by running: clauth serve seal
|
|
4523
|
+
// ── Adaptive mode: check for boot.key on each restart ──
|
|
4524
|
+
// If boot.key exists (auto-sealed after browser unlock), decrypt and start unlocked.
|
|
4525
|
+
// If boot.key missing (user clicked Stop, or first run), start locked + open browser.
|
|
4324
4526
|
const psScript = [
|
|
4325
|
-
"# clauth autostart + watchdog (
|
|
4326
|
-
"#
|
|
4527
|
+
"# clauth autostart + watchdog (adaptive: sealed if boot.key exists, locked otherwise)",
|
|
4528
|
+
"# Restarts on crash every 15s. Auto-unlock if DPAPI boot.key available.",
|
|
4529
|
+
"",
|
|
4530
|
+
`$bootKey = '${bootKey}'`,
|
|
4327
4531
|
"",
|
|
4328
4532
|
"while ($true) {",
|
|
4329
4533
|
" try {",
|
|
4330
4534
|
" $ping = Invoke-RestMethod -Uri 'http://127.0.0.1:52437/ping' -TimeoutSec 3 -ErrorAction Stop",
|
|
4331
4535
|
" } catch {",
|
|
4332
|
-
|
|
4536
|
+
" if (Test-Path $bootKey) {",
|
|
4537
|
+
" # boot.key exists — decrypt via DPAPI and start unlocked",
|
|
4538
|
+
" try {",
|
|
4539
|
+
" $enc = (Get-Content $bootKey -Raw).Trim()",
|
|
4540
|
+
" $pw = [Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($enc),$null,'CurrentUser'))",
|
|
4541
|
+
` Start-Process '${nodeExe}' -ArgumentList "'${cliEntry}' serve start -p $pw${tunnelArg}" -WindowStyle Hidden`,
|
|
4542
|
+
" } catch {",
|
|
4543
|
+
" # DPAPI decrypt failed — start locked",
|
|
4544
|
+
` Start-Process '${nodeExe}' -ArgumentList "'${cliEntry}' serve start${tunnelArg}" -WindowStyle Hidden`,
|
|
4545
|
+
" }",
|
|
4546
|
+
" } else {",
|
|
4547
|
+
" # No boot.key — start locked, user enters password in browser",
|
|
4548
|
+
` Start-Process '${nodeExe}' -ArgumentList "'${cliEntry}' serve start${tunnelArg}" -WindowStyle Hidden`,
|
|
4549
|
+
" }",
|
|
4333
4550
|
" Start-Sleep -Seconds 5",
|
|
4334
4551
|
" }",
|
|
4335
4552
|
" Start-Sleep -Seconds 15",
|
|
@@ -4338,20 +4555,38 @@ async function installWindows(pw, tunnelHostname, execSync) {
|
|
|
4338
4555
|
fs.writeFileSync(psScriptPath, psScript, "utf8");
|
|
4339
4556
|
}
|
|
4340
4557
|
|
|
4341
|
-
// Register
|
|
4558
|
+
// Register auto-start — try HKCU\Run first (no admin), fall back to Scheduled Task
|
|
4342
4559
|
const mode = pw ? "sealed — fully unattended" : "locked — browser password entry";
|
|
4343
|
-
const spinner2 = ora(`Registering
|
|
4560
|
+
const spinner2 = ora(`Registering auto-start (${mode})...`).start();
|
|
4561
|
+
const psScriptEsc = psScriptPath.replace(/\\/g, "\\\\");
|
|
4562
|
+
const regValue = `powershell.exe -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${psScriptEsc}"`;
|
|
4563
|
+
let autoStartOk = false;
|
|
4564
|
+
|
|
4565
|
+
// Method 1: HKCU\Run registry key (no elevation needed)
|
|
4344
4566
|
try {
|
|
4345
|
-
const
|
|
4346
|
-
|
|
4567
|
+
const regExe = `${process.env.SystemRoot || "C:\\\\Windows"}\\\\System32\\\\reg.exe`;
|
|
4568
|
+
// Use single-backslash path for reg key (reg.exe expects it)
|
|
4569
|
+
const regKey = "HKCU\\\\Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Run";
|
|
4347
4570
|
execSync(
|
|
4348
|
-
`${
|
|
4571
|
+
`${regExe} add "${regKey}" /v "${TASK_NAME}" /t REG_SZ /d "${regValue}" /f`,
|
|
4349
4572
|
{ encoding: "utf8", stdio: "pipe" }
|
|
4350
4573
|
);
|
|
4351
|
-
spinner2.succeed(chalk.green(`
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4574
|
+
spinner2.succeed(chalk.green(`Registry auto-start registered (HKCU\\Run)`));
|
|
4575
|
+
autoStartOk = true;
|
|
4576
|
+
} catch {
|
|
4577
|
+
// Method 2: Scheduled Task (needs admin)
|
|
4578
|
+
try {
|
|
4579
|
+
const args = `-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${psScriptEsc}"`;
|
|
4580
|
+
execSync(
|
|
4581
|
+
`${process.env.SystemRoot || "C:\\\\Windows"}\\\\System32\\\\schtasks.exe /create /f /tn "${TASK_NAME}" /sc onlogon /tr "powershell.exe ${args}"`,
|
|
4582
|
+
{ encoding: "utf8", stdio: "pipe" }
|
|
4583
|
+
);
|
|
4584
|
+
spinner2.succeed(chalk.green(`Scheduled Task "${TASK_NAME}" registered`));
|
|
4585
|
+
autoStartOk = true;
|
|
4586
|
+
} catch (err) {
|
|
4587
|
+
spinner2.fail(chalk.yellow(`Auto-start registration failed (non-fatal): ${err.message}`));
|
|
4588
|
+
console.log(chalk.gray(" You can still start manually: clauth serve start"));
|
|
4589
|
+
}
|
|
4355
4590
|
}
|
|
4356
4591
|
|
|
4357
4592
|
// Start the daemon now
|
|
@@ -4600,7 +4835,14 @@ async function uninstallWindows(execSync) {
|
|
|
4600
4835
|
const bootKeyPath = path.join(autostartDir, "boot.key");
|
|
4601
4836
|
const psScriptPath = path.join(autostartDir, "autostart.ps1");
|
|
4602
4837
|
|
|
4603
|
-
// Remove
|
|
4838
|
+
// Remove HKCU\Run registry key
|
|
4839
|
+
try {
|
|
4840
|
+
const regExe = `${process.env.SystemRoot || "C:\\\\Windows"}\\\\System32\\\\reg.exe`;
|
|
4841
|
+
execSync(`${regExe} delete "HKCU\\\\Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Run" /v "${TASK_NAME}" /f`, { encoding: "utf8", stdio: "pipe" });
|
|
4842
|
+
console.log(chalk.green(` Removed Registry auto-start: ${TASK_NAME}`));
|
|
4843
|
+
} catch { console.log(chalk.gray(` Registry key not found (already removed): ${TASK_NAME}`)); }
|
|
4844
|
+
|
|
4845
|
+
// Remove scheduled task (legacy)
|
|
4604
4846
|
try {
|
|
4605
4847
|
execSync(`${process.env.SystemRoot || "C:\\\\Windows"}\\\\System32\\\\schtasks.exe /delete /f /tn "${TASK_NAME}"`, { encoding: "utf8", stdio: "pipe" });
|
|
4606
4848
|
console.log(chalk.green(` Removed Scheduled Task: ${TASK_NAME}`));
|
|
@@ -4678,6 +4920,53 @@ async function uninstallLinux(execSync) {
|
|
|
4678
4920
|
}
|
|
4679
4921
|
|
|
4680
4922
|
// ── Export ────────────────────────────────────────────────────
|
|
4923
|
+
// ── Blue-green upgrade: npm update + start staged + open dashboard ──
|
|
4924
|
+
async function actionUpgrade(opts) {
|
|
4925
|
+
console.log(chalk.cyan("\n ⚡ Blue-green upgrade\n"));
|
|
4926
|
+
|
|
4927
|
+
// Step 1: Check current live version
|
|
4928
|
+
const liveInfo = readPid();
|
|
4929
|
+
let liveVersion = "unknown";
|
|
4930
|
+
if (liveInfo) {
|
|
4931
|
+
try {
|
|
4932
|
+
const resp = await fetch(`http://127.0.0.1:${liveInfo.port}/ping`);
|
|
4933
|
+
const data = await resp.json();
|
|
4934
|
+
liveVersion = data.app_version || "unknown";
|
|
4935
|
+
} catch {}
|
|
4936
|
+
}
|
|
4937
|
+
console.log(chalk.gray(` Live version: v${liveVersion} (port ${liveInfo?.port || LIVE_PORT})`));
|
|
4938
|
+
|
|
4939
|
+
// Step 2: npm update
|
|
4940
|
+
const spinner = ora("Updating @lifeaitools/clauth from npm...").start();
|
|
4941
|
+
try {
|
|
4942
|
+
execSyncTop("npm install -g @lifeaitools/clauth@latest", { encoding: "utf8", stdio: "pipe", timeout: 60000 });
|
|
4943
|
+
// Read new version from installed package
|
|
4944
|
+
const newPkg = JSON.parse(fs.readFileSync(
|
|
4945
|
+
path.join(path.dirname(process.execPath), "..", "lib", "node_modules", "@lifeaitools", "clauth", "package.json"), "utf8"
|
|
4946
|
+
).toString());
|
|
4947
|
+
spinner.succeed(chalk.green(`Updated to v${newPkg.version}`));
|
|
4948
|
+
console.log(chalk.gray(` Staged version: v${newPkg.version} (port ${STAGED_PORT})`));
|
|
4949
|
+
} catch (err) {
|
|
4950
|
+
// Try alternate npm global path (Windows)
|
|
4951
|
+
try {
|
|
4952
|
+
const npmGlobal = path.join(os.homedir(), "AppData", "Roaming", "npm");
|
|
4953
|
+
const newPkg = JSON.parse(fs.readFileSync(
|
|
4954
|
+
path.join(npmGlobal, "node_modules", "@lifeaitools", "clauth", "package.json"), "utf8"
|
|
4955
|
+
).toString());
|
|
4956
|
+
spinner.succeed(chalk.green(`Updated to v${newPkg.version}`));
|
|
4957
|
+
} catch {
|
|
4958
|
+
spinner.fail(chalk.yellow(`npm update may have failed: ${err.message}`));
|
|
4959
|
+
console.log(chalk.gray(" Continuing with staged start anyway..."));
|
|
4960
|
+
}
|
|
4961
|
+
}
|
|
4962
|
+
|
|
4963
|
+
// Step 3: Start staged instance
|
|
4964
|
+
console.log(chalk.gray(`\n Starting staged instance on port ${STAGED_PORT}...`));
|
|
4965
|
+
opts.staged = true;
|
|
4966
|
+
opts.port = String(STAGED_PORT);
|
|
4967
|
+
return actionStart(opts);
|
|
4968
|
+
}
|
|
4969
|
+
|
|
4681
4970
|
export async function runServe(opts) {
|
|
4682
4971
|
const action = opts.action || "foreground";
|
|
4683
4972
|
|
|
@@ -4690,9 +4979,10 @@ export async function runServe(opts) {
|
|
|
4690
4979
|
case "mcp": return actionMcp(opts);
|
|
4691
4980
|
case "install": return actionInstall(opts);
|
|
4692
4981
|
case "uninstall": return actionUninstall();
|
|
4982
|
+
case "upgrade": return actionUpgrade(opts);
|
|
4693
4983
|
default:
|
|
4694
4984
|
console.log(chalk.red(`\n Unknown serve action: ${action}`));
|
|
4695
|
-
console.log(chalk.gray(" Actions: start | stop | restart | ping | foreground | mcp | install | uninstall\n"));
|
|
4985
|
+
console.log(chalk.gray(" Actions: start | stop | restart | ping | foreground | mcp | install | uninstall | upgrade\n"));
|
|
4696
4986
|
process.exit(1);
|
|
4697
4987
|
}
|
|
4698
4988
|
}
|
package/cli/index.js
CHANGED
|
@@ -644,6 +644,7 @@ program
|
|
|
644
644
|
.option("-p, --pw <password>", "clauth password (optional — omit to start locked, unlock in browser)")
|
|
645
645
|
.option("--services <list>", "Comma-separated service whitelist (default: all)")
|
|
646
646
|
.option("--tunnel <hostname>", "Fixed tunnel hostname (e.g. clauth.prtrust.fund) — uses named Cloudflare Tunnel instead of random URL")
|
|
647
|
+
.option("--staged", "Start on staging port (52438) for blue-green verification before make-live")
|
|
647
648
|
.option("--action <action>", "Internal: action override for daemon child")
|
|
648
649
|
.addHelpText("after", `
|
|
649
650
|
Actions:
|
|
@@ -654,8 +655,9 @@ Actions:
|
|
|
654
655
|
foreground Run in foreground (Ctrl+C to stop) — default if no action given
|
|
655
656
|
mcp Run as MCP stdio server for Claude Code (JSON-RPC over stdin/stdout)
|
|
656
657
|
install Store password securely + register auto-start service (cross-platform)
|
|
657
|
-
Windows: DPAPI +
|
|
658
|
+
Windows: DPAPI + HKCU\\Run | macOS: Keychain + LaunchAgent | Linux: libsecret/openssl + systemd
|
|
658
659
|
uninstall Remove auto-start service + delete stored password
|
|
660
|
+
upgrade Blue-green upgrade: start new version on staging port, verify, then make live
|
|
659
661
|
|
|
660
662
|
MCP SSE (built into start/foreground):
|
|
661
663
|
The HTTP daemon also serves MCP SSE transport at GET /sse + POST /message.
|