@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.
@@ -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></h1>
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 port = parseInt(opts.port || "52437", 10);
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
- // Check for existing instance
3437
- const existing = readPid();
3438
- if (existing && isProcessAlive(existing.pid)) {
3439
- console.log(chalk.yellow(`\n clauth serve already running (PID ${existing.pid}, port ${existing.port})`));
3440
- console.log(chalk.gray(` Stop it first: clauth serve stop\n`));
3441
- process.exit(1);
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
- writePid(process.pid, port);
3460
- const msg = `[${new Date().toISOString()}] clauth serve started — PID ${process.pid}, port ${port}, services: ${whitelist ? whitelist.join(",") : "all"}\n`;
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 = () => { removePid(); process.exit(0); };
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 info = readPid();
3718
+ const readInfo = isStaged ? readStagedPid : readPid;
3719
+ const info = readInfo();
3523
3720
  if (started && info) {
3524
- console.log(chalk.green(`\n 🔐 clauth serve started`));
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 (!password) {
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
- // ── First-run mode: no password yet start locked, browser opens for setup ──
4321
- // Watchdog starts the daemon in locked mode; user enters password in the browser dashboard.
4322
- // After entering password in the browser, user can seal it for future unattended restarts
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 (locked mode browser password entry)",
4326
- "# Starts daemon locked, opens browser for password. Restarts on crash every 15s.",
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
- ` Start-Process '${nodeExe}' -ArgumentList "'${cliEntry}' serve start${tunnelArg}" -WindowStyle Hidden`,
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 Windows Scheduled Task triggers on user logon
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 Scheduled Task (${mode})...`).start();
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
- `${process.env.SystemRoot || "C:\\\\Windows"}\\\\System32\\\\schtasks.exe /create /f /tn "${TASK_NAME}" /sc onlogon /tr "powershell.exe ${args}"`,
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(`Scheduled Task "${TASK_NAME}" registered`));
4352
- } catch (err) {
4353
- spinner2.fail(chalk.yellow(`Scheduled task failed (non-fatal): ${err.message}`));
4354
- console.log(chalk.gray(" You can still start manually: clauth serve start"));
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 scheduled task
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 + Scheduled Task | macOS: Keychain + LaunchAgent | Linux: libsecret/openssl + systemd
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/clauth",
3
- "version": "1.2.3",
3
+ "version": "1.3.0",
4
4
  "description": "Hardware-bound credential vault for the LIFEAI infrastructure stack",
5
5
  "type": "module",
6
6
  "bin": {