@lifeaitools/clauth 1.2.3 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/commands/serve.js +321 -38
- 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,7 +110,7 @@ function openBrowser(url) {
|
|
|
91
110
|
}
|
|
92
111
|
|
|
93
112
|
// ── Dashboard HTML ───────────────────────────────────────────
|
|
94
|
-
function dashboardHtml(port, whitelist) {
|
|
113
|
+
function dashboardHtml(port, whitelist, isStaged = false) {
|
|
95
114
|
return `<!DOCTYPE html>
|
|
96
115
|
<html lang="en">
|
|
97
116
|
<head>
|
|
@@ -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,19 +3596,36 @@ 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
|
|
@@ -3454,10 +3641,15 @@ async function actionStart(opts) {
|
|
|
3454
3641
|
}
|
|
3455
3642
|
}
|
|
3456
3643
|
|
|
3457
|
-
const server = createServer(password, whitelist, port, tunnelHostname);
|
|
3644
|
+
const server = createServer(password, whitelist, port, tunnelHostname, isStaged);
|
|
3458
3645
|
server.listen(port, "127.0.0.1", () => {
|
|
3459
|
-
|
|
3460
|
-
|
|
3646
|
+
if (isStaged) {
|
|
3647
|
+
writeStagedPid(process.pid, port);
|
|
3648
|
+
} else {
|
|
3649
|
+
writePid(process.pid, port);
|
|
3650
|
+
}
|
|
3651
|
+
const label = isStaged ? "STAGED" : "LIVE";
|
|
3652
|
+
const msg = `[${new Date().toISOString()}] clauth serve started [${label}] — PID ${process.pid}, port ${port}, services: ${whitelist ? whitelist.join(",") : "all"}\n`;
|
|
3461
3653
|
fs.appendFileSync(LOG_FILE, msg);
|
|
3462
3654
|
});
|
|
3463
3655
|
|
|
@@ -3467,7 +3659,10 @@ async function actionStart(opts) {
|
|
|
3467
3659
|
process.exit(1);
|
|
3468
3660
|
});
|
|
3469
3661
|
|
|
3470
|
-
const shutdown = () => {
|
|
3662
|
+
const shutdown = () => {
|
|
3663
|
+
if (isStaged) { removeStagedPid(); } else { removePid(); }
|
|
3664
|
+
process.exit(0);
|
|
3665
|
+
};
|
|
3471
3666
|
process.on("SIGTERM", shutdown);
|
|
3472
3667
|
process.on("SIGINT", shutdown);
|
|
3473
3668
|
return;
|
|
@@ -3499,12 +3694,13 @@ async function actionStart(opts) {
|
|
|
3499
3694
|
if (password) childArgs.push("--pw", password);
|
|
3500
3695
|
if (opts.services) childArgs.push("--services", opts.services);
|
|
3501
3696
|
if (tunnelHostname) childArgs.push("--tunnel", tunnelHostname);
|
|
3697
|
+
if (isStaged) childArgs.push("--staged");
|
|
3502
3698
|
|
|
3503
3699
|
const out = fs.openSync(LOG_FILE, "a");
|
|
3504
3700
|
const child = spawn(process.execPath, childArgs, {
|
|
3505
3701
|
detached: true,
|
|
3506
3702
|
stdio: ["ignore", out, out],
|
|
3507
|
-
env: { ...process.env, __CLAUTH_DAEMON: "1" },
|
|
3703
|
+
env: { ...process.env, __CLAUTH_DAEMON: "1", ...(isStaged ? { __CLAUTH_STAGED: "1" } : {}) },
|
|
3508
3704
|
});
|
|
3509
3705
|
child.unref();
|
|
3510
3706
|
|
|
@@ -3519,14 +3715,18 @@ async function actionStart(opts) {
|
|
|
3519
3715
|
} catch {}
|
|
3520
3716
|
}
|
|
3521
3717
|
|
|
3522
|
-
const
|
|
3718
|
+
const readInfo = isStaged ? readStagedPid : readPid;
|
|
3719
|
+
const info = readInfo();
|
|
3523
3720
|
if (started && info) {
|
|
3524
|
-
|
|
3721
|
+
const label = isStaged ? "⚡ STAGED" : "🔐";
|
|
3722
|
+
console.log(chalk.green(`\n ${label} clauth serve started`));
|
|
3525
3723
|
console.log(chalk.gray(` PID: ${info.pid}`));
|
|
3526
3724
|
console.log(chalk.gray(` Port: 127.0.0.1:${info.port}`));
|
|
3527
3725
|
console.log(chalk.gray(` Services: ${whitelist ? whitelist.join(", ") : "all"}`));
|
|
3528
3726
|
console.log(chalk.gray(` Log: ${LOG_FILE}`));
|
|
3529
|
-
if (
|
|
3727
|
+
if (isStaged) {
|
|
3728
|
+
console.log(chalk.yellow(`\n ⚡ Staged on port ${port} — open dashboard to verify, then click "Make Live"`));
|
|
3729
|
+
} else if (!password) {
|
|
3530
3730
|
console.log(chalk.cyan(`\n 👉 Open http://127.0.0.1:${info.port} to unlock the vault`));
|
|
3531
3731
|
}
|
|
3532
3732
|
console.log(chalk.gray(` Stop: clauth serve stop\n`));
|
|
@@ -4317,19 +4517,33 @@ async function installWindows(pw, tunnelHostname, execSync) {
|
|
|
4317
4517
|
].join("\n");
|
|
4318
4518
|
fs.writeFileSync(psScriptPath, psScript, "utf8");
|
|
4319
4519
|
} else {
|
|
4320
|
-
// ──
|
|
4321
|
-
//
|
|
4322
|
-
//
|
|
4323
|
-
// by running: clauth serve seal
|
|
4520
|
+
// ── Adaptive mode: check for boot.key on each restart ──
|
|
4521
|
+
// If boot.key exists (auto-sealed after browser unlock), decrypt and start unlocked.
|
|
4522
|
+
// If boot.key missing (user clicked Stop, or first run), start locked + open browser.
|
|
4324
4523
|
const psScript = [
|
|
4325
|
-
"# clauth autostart + watchdog (
|
|
4326
|
-
"#
|
|
4524
|
+
"# clauth autostart + watchdog (adaptive: sealed if boot.key exists, locked otherwise)",
|
|
4525
|
+
"# Restarts on crash every 15s. Auto-unlock if DPAPI boot.key available.",
|
|
4526
|
+
"",
|
|
4527
|
+
`$bootKey = '${bootKey}'`,
|
|
4327
4528
|
"",
|
|
4328
4529
|
"while ($true) {",
|
|
4329
4530
|
" try {",
|
|
4330
4531
|
" $ping = Invoke-RestMethod -Uri 'http://127.0.0.1:52437/ping' -TimeoutSec 3 -ErrorAction Stop",
|
|
4331
4532
|
" } catch {",
|
|
4332
|
-
|
|
4533
|
+
" if (Test-Path $bootKey) {",
|
|
4534
|
+
" # boot.key exists — decrypt via DPAPI and start unlocked",
|
|
4535
|
+
" try {",
|
|
4536
|
+
" $enc = (Get-Content $bootKey -Raw).Trim()",
|
|
4537
|
+
" $pw = [Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($enc),$null,'CurrentUser'))",
|
|
4538
|
+
` Start-Process '${nodeExe}' -ArgumentList "'${cliEntry}' serve start -p $pw${tunnelArg}" -WindowStyle Hidden`,
|
|
4539
|
+
" } catch {",
|
|
4540
|
+
" # DPAPI decrypt failed — start locked",
|
|
4541
|
+
` Start-Process '${nodeExe}' -ArgumentList "'${cliEntry}' serve start${tunnelArg}" -WindowStyle Hidden`,
|
|
4542
|
+
" }",
|
|
4543
|
+
" } else {",
|
|
4544
|
+
" # No boot.key — start locked, user enters password in browser",
|
|
4545
|
+
` Start-Process '${nodeExe}' -ArgumentList "'${cliEntry}' serve start${tunnelArg}" -WindowStyle Hidden`,
|
|
4546
|
+
" }",
|
|
4333
4547
|
" Start-Sleep -Seconds 5",
|
|
4334
4548
|
" }",
|
|
4335
4549
|
" Start-Sleep -Seconds 15",
|
|
@@ -4338,20 +4552,35 @@ async function installWindows(pw, tunnelHostname, execSync) {
|
|
|
4338
4552
|
fs.writeFileSync(psScriptPath, psScript, "utf8");
|
|
4339
4553
|
}
|
|
4340
4554
|
|
|
4341
|
-
// Register
|
|
4555
|
+
// Register auto-start — try HKCU\Run first (no admin), fall back to Scheduled Task
|
|
4342
4556
|
const mode = pw ? "sealed — fully unattended" : "locked — browser password entry";
|
|
4343
|
-
const spinner2 = ora(`Registering
|
|
4557
|
+
const spinner2 = ora(`Registering auto-start (${mode})...`).start();
|
|
4558
|
+
const psScriptEsc = psScriptPath.replace(/\\/g, "\\\\");
|
|
4559
|
+
const regValue = `powershell.exe -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${psScriptEsc}"`;
|
|
4560
|
+
let autoStartOk = false;
|
|
4561
|
+
|
|
4562
|
+
// Method 1: HKCU\Run registry key (no elevation needed)
|
|
4344
4563
|
try {
|
|
4345
|
-
const psScriptEsc = psScriptPath.replace(/\\/g, "\\\\");
|
|
4346
|
-
const args = `-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${psScriptEsc}"`;
|
|
4347
4564
|
execSync(
|
|
4348
|
-
|
|
4565
|
+
`reg add "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" /v "${TASK_NAME}" /t REG_SZ /d "${regValue}" /f`,
|
|
4349
4566
|
{ encoding: "utf8", stdio: "pipe" }
|
|
4350
4567
|
);
|
|
4351
|
-
spinner2.succeed(chalk.green(`
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4568
|
+
spinner2.succeed(chalk.green(`Registry auto-start registered (HKCU\\Run)`));
|
|
4569
|
+
autoStartOk = true;
|
|
4570
|
+
} catch {
|
|
4571
|
+
// Method 2: Scheduled Task (needs admin)
|
|
4572
|
+
try {
|
|
4573
|
+
const args = `-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${psScriptEsc}"`;
|
|
4574
|
+
execSync(
|
|
4575
|
+
`${process.env.SystemRoot || "C:\\\\Windows"}\\\\System32\\\\schtasks.exe /create /f /tn "${TASK_NAME}" /sc onlogon /tr "powershell.exe ${args}"`,
|
|
4576
|
+
{ encoding: "utf8", stdio: "pipe" }
|
|
4577
|
+
);
|
|
4578
|
+
spinner2.succeed(chalk.green(`Scheduled Task "${TASK_NAME}" registered`));
|
|
4579
|
+
autoStartOk = true;
|
|
4580
|
+
} catch (err) {
|
|
4581
|
+
spinner2.fail(chalk.yellow(`Auto-start registration failed (non-fatal): ${err.message}`));
|
|
4582
|
+
console.log(chalk.gray(" You can still start manually: clauth serve start"));
|
|
4583
|
+
}
|
|
4355
4584
|
}
|
|
4356
4585
|
|
|
4357
4586
|
// Start the daemon now
|
|
@@ -4600,7 +4829,13 @@ async function uninstallWindows(execSync) {
|
|
|
4600
4829
|
const bootKeyPath = path.join(autostartDir, "boot.key");
|
|
4601
4830
|
const psScriptPath = path.join(autostartDir, "autostart.ps1");
|
|
4602
4831
|
|
|
4603
|
-
// Remove
|
|
4832
|
+
// Remove HKCU\Run registry key
|
|
4833
|
+
try {
|
|
4834
|
+
execSync(`reg delete "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" /v "${TASK_NAME}" /f`, { encoding: "utf8", stdio: "pipe" });
|
|
4835
|
+
console.log(chalk.green(` Removed Registry auto-start: ${TASK_NAME}`));
|
|
4836
|
+
} catch { console.log(chalk.gray(` Registry key not found (already removed): ${TASK_NAME}`)); }
|
|
4837
|
+
|
|
4838
|
+
// Remove scheduled task (legacy)
|
|
4604
4839
|
try {
|
|
4605
4840
|
execSync(`${process.env.SystemRoot || "C:\\\\Windows"}\\\\System32\\\\schtasks.exe /delete /f /tn "${TASK_NAME}"`, { encoding: "utf8", stdio: "pipe" });
|
|
4606
4841
|
console.log(chalk.green(` Removed Scheduled Task: ${TASK_NAME}`));
|
|
@@ -4678,6 +4913,53 @@ async function uninstallLinux(execSync) {
|
|
|
4678
4913
|
}
|
|
4679
4914
|
|
|
4680
4915
|
// ── Export ────────────────────────────────────────────────────
|
|
4916
|
+
// ── Blue-green upgrade: npm update + start staged + open dashboard ──
|
|
4917
|
+
async function actionUpgrade(opts) {
|
|
4918
|
+
console.log(chalk.cyan("\n ⚡ Blue-green upgrade\n"));
|
|
4919
|
+
|
|
4920
|
+
// Step 1: Check current live version
|
|
4921
|
+
const liveInfo = readPid();
|
|
4922
|
+
let liveVersion = "unknown";
|
|
4923
|
+
if (liveInfo) {
|
|
4924
|
+
try {
|
|
4925
|
+
const resp = await fetch(`http://127.0.0.1:${liveInfo.port}/ping`);
|
|
4926
|
+
const data = await resp.json();
|
|
4927
|
+
liveVersion = data.app_version || "unknown";
|
|
4928
|
+
} catch {}
|
|
4929
|
+
}
|
|
4930
|
+
console.log(chalk.gray(` Live version: v${liveVersion} (port ${liveInfo?.port || LIVE_PORT})`));
|
|
4931
|
+
|
|
4932
|
+
// Step 2: npm update
|
|
4933
|
+
const spinner = ora("Updating @lifeaitools/clauth from npm...").start();
|
|
4934
|
+
try {
|
|
4935
|
+
execSyncTop("npm install -g @lifeaitools/clauth@latest", { encoding: "utf8", stdio: "pipe", timeout: 60000 });
|
|
4936
|
+
// Read new version from installed package
|
|
4937
|
+
const newPkg = JSON.parse(fs.readFileSync(
|
|
4938
|
+
path.join(path.dirname(process.execPath), "..", "lib", "node_modules", "@lifeaitools", "clauth", "package.json"), "utf8"
|
|
4939
|
+
).toString());
|
|
4940
|
+
spinner.succeed(chalk.green(`Updated to v${newPkg.version}`));
|
|
4941
|
+
console.log(chalk.gray(` Staged version: v${newPkg.version} (port ${STAGED_PORT})`));
|
|
4942
|
+
} catch (err) {
|
|
4943
|
+
// Try alternate npm global path (Windows)
|
|
4944
|
+
try {
|
|
4945
|
+
const npmGlobal = path.join(os.homedir(), "AppData", "Roaming", "npm");
|
|
4946
|
+
const newPkg = JSON.parse(fs.readFileSync(
|
|
4947
|
+
path.join(npmGlobal, "node_modules", "@lifeaitools", "clauth", "package.json"), "utf8"
|
|
4948
|
+
).toString());
|
|
4949
|
+
spinner.succeed(chalk.green(`Updated to v${newPkg.version}`));
|
|
4950
|
+
} catch {
|
|
4951
|
+
spinner.fail(chalk.yellow(`npm update may have failed: ${err.message}`));
|
|
4952
|
+
console.log(chalk.gray(" Continuing with staged start anyway..."));
|
|
4953
|
+
}
|
|
4954
|
+
}
|
|
4955
|
+
|
|
4956
|
+
// Step 3: Start staged instance
|
|
4957
|
+
console.log(chalk.gray(`\n Starting staged instance on port ${STAGED_PORT}...`));
|
|
4958
|
+
opts.staged = true;
|
|
4959
|
+
opts.port = String(STAGED_PORT);
|
|
4960
|
+
return actionStart(opts);
|
|
4961
|
+
}
|
|
4962
|
+
|
|
4681
4963
|
export async function runServe(opts) {
|
|
4682
4964
|
const action = opts.action || "foreground";
|
|
4683
4965
|
|
|
@@ -4690,9 +4972,10 @@ export async function runServe(opts) {
|
|
|
4690
4972
|
case "mcp": return actionMcp(opts);
|
|
4691
4973
|
case "install": return actionInstall(opts);
|
|
4692
4974
|
case "uninstall": return actionUninstall();
|
|
4975
|
+
case "upgrade": return actionUpgrade(opts);
|
|
4693
4976
|
default:
|
|
4694
4977
|
console.log(chalk.red(`\n Unknown serve action: ${action}`));
|
|
4695
|
-
console.log(chalk.gray(" Actions: start | stop | restart | ping | foreground | mcp | install | uninstall\n"));
|
|
4978
|
+
console.log(chalk.gray(" Actions: start | stop | restart | ping | foreground | mcp | install | uninstall | upgrade\n"));
|
|
4696
4979
|
process.exit(1);
|
|
4697
4980
|
}
|
|
4698
4981
|
}
|
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.
|