fullcourtdefense-cli 1.15.3 → 1.15.5

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.
@@ -77,6 +77,55 @@ function daemonDir() {
77
77
  function pidFile() {
78
78
  return path.join(daemonDir(), 'daemon.pid');
79
79
  }
80
+ /** Sidecar next to the pid lock recording WHICH build owns the daemon. */
81
+ function metaFile() {
82
+ return path.join(daemonDir(), 'daemon.meta.json');
83
+ }
84
+ function readDaemonMeta() {
85
+ try {
86
+ const meta = JSON.parse(fs.readFileSync(metaFile(), 'utf8'));
87
+ return meta && typeof meta.pid === 'number' ? meta : undefined;
88
+ }
89
+ catch {
90
+ return undefined;
91
+ }
92
+ }
93
+ function writeDaemonMeta() {
94
+ try {
95
+ const meta = {
96
+ pid: process.pid,
97
+ version: cliVersion(),
98
+ entry: cliEntry(),
99
+ startedAt: new Date().toISOString(),
100
+ };
101
+ fs.writeFileSync(metaFile(), JSON.stringify(meta, null, 2), 'utf8');
102
+ }
103
+ catch { /* best-effort */ }
104
+ }
105
+ /** Kill a process and wait (up to ~5s) for it to actually exit. */
106
+ function stopPid(pid) {
107
+ try {
108
+ process.kill(pid);
109
+ }
110
+ catch { /* may already be gone */ }
111
+ const deadline = Date.now() + 5_000;
112
+ const sleeper = new Int32Array(new SharedArrayBuffer(4));
113
+ while (Date.now() < deadline) {
114
+ if (!isPidAlive(pid))
115
+ return true;
116
+ Atomics.wait(sleeper, 0, 0, 250);
117
+ }
118
+ if (process.platform === 'win32') {
119
+ (0, child_process_1.spawnSync)('taskkill', ['/PID', String(pid), '/F'], { stdio: 'ignore', windowsHide: true, timeout: 5_000 });
120
+ }
121
+ else {
122
+ try {
123
+ process.kill(pid, 'SIGKILL');
124
+ }
125
+ catch { /* ignore */ }
126
+ }
127
+ return !isPidAlive(pid);
128
+ }
80
129
  function logFile() {
81
130
  return path.join(daemonDir(), 'daemon.log');
82
131
  }
@@ -117,24 +166,56 @@ function isPidAlive(pid) {
117
166
  return error.code === 'EPERM';
118
167
  }
119
168
  }
120
- /** Take the single-instance lock. Returns false when another daemon is already running. */
169
+ /** Lexicographic-free semver compare; missing/unparseable sorts oldest. */
170
+ function compareVersions(a, b) {
171
+ const parse = (value) => String(value || '0').split('.').map(part => parseInt(part, 10) || 0);
172
+ const [pa, pb] = [parse(a), parse(b)];
173
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
174
+ const diff = (pa[i] || 0) - (pb[i] || 0);
175
+ if (diff !== 0)
176
+ return diff;
177
+ }
178
+ return 0;
179
+ }
180
+ /**
181
+ * Take the single-instance lock. A NEWER build supersedes a running older
182
+ * daemon (e.g. the MSI-bundled copy kept running after an npm update): the old
183
+ * process is stopped and this one takes over, so the fleet never keeps running
184
+ * stale enforcement code silently. Same-or-newer versions keep the lock.
185
+ */
121
186
  function acquirePidLock() {
122
187
  fs.mkdirSync(daemonDir(), { recursive: true });
123
188
  try {
124
189
  const existing = Number(fs.readFileSync(pidFile(), 'utf8').trim());
125
190
  if (Number.isFinite(existing) && existing > 0 && existing !== process.pid && isPidAlive(existing)) {
126
- return false;
191
+ const meta = readDaemonMeta();
192
+ const runningVersion = meta && meta.pid === existing ? meta.version : undefined;
193
+ // No meta = pre-1.15.4 build (never wrote one) → treated as older.
194
+ if (compareVersions(cliVersion(), runningVersion) > 0) {
195
+ log(`Superseding older daemon (pid ${existing}, version ${runningVersion || 'unknown'}) with ${cliVersion() || 'this build'}.`);
196
+ if (!stopPid(existing))
197
+ return false; // could not stop it — yield rather than double-run
198
+ }
199
+ else {
200
+ return false;
201
+ }
127
202
  }
128
203
  }
129
204
  catch { /* no pidfile — free to start */ }
130
205
  fs.writeFileSync(pidFile(), String(process.pid), 'utf8');
206
+ writeDaemonMeta();
131
207
  return true;
132
208
  }
133
209
  function releasePidLock() {
134
210
  try {
135
211
  const recorded = Number(fs.readFileSync(pidFile(), 'utf8').trim());
136
- if (recorded === process.pid)
212
+ if (recorded === process.pid) {
137
213
  fs.unlinkSync(pidFile());
214
+ try {
215
+ fs.unlinkSync(metaFile());
216
+ }
217
+ catch { /* best-effort */ }
218
+ }
138
219
  }
139
220
  catch { /* best-effort */ }
140
221
  }
@@ -356,6 +437,7 @@ async function runDaemon(args, config) {
356
437
  shieldKey: creds.shieldKey,
357
438
  developerName: identity.developerName,
358
439
  machineName: identity.hostname,
440
+ machineId: identity.machineId,
359
441
  force: true,
360
442
  });
361
443
  if (bundle.suspended && !suspended) {
@@ -467,8 +549,15 @@ function isWindowsRunKeyInstalled() {
467
549
  function startDaemonNowWindows(vbs) {
468
550
  try {
469
551
  const existing = Number(fs.readFileSync(pidFile(), 'utf8').trim());
470
- if (Number.isFinite(existing) && existing > 0 && isPidAlive(existing))
471
- return;
552
+ if (Number.isFinite(existing) && existing > 0 && isPidAlive(existing)) {
553
+ // Skip only when the running daemon is the same version or newer; an
554
+ // older one (e.g. stale MSI copy) gets superseded by the spawned daemon,
555
+ // whose acquirePidLock() stops it and takes over.
556
+ const meta = readDaemonMeta();
557
+ const runningVersion = meta && meta.pid === existing ? meta.version : undefined;
558
+ if (compareVersions(cliVersion(), runningVersion) <= 0)
559
+ return;
560
+ }
472
561
  }
473
562
  catch { /* not running */ }
474
563
  const escaped = vbs.replace(/'/g, "''");
@@ -513,23 +602,23 @@ function launchdPlistPath() {
513
602
  return path.join(os.homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
514
603
  }
515
604
  function installMacos() {
516
- const plist = `<?xml version="1.0" encoding="UTF-8"?>
517
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
518
- <plist version="1.0">
519
- <dict>
520
- <key>Label</key><string>${LAUNCHD_LABEL}</string>
521
- <key>ProgramArguments</key>
522
- <array>
523
- <string>${process.execPath}</string>
524
- <string>${cliEntry()}</string>
525
- <string>daemon</string>
526
- </array>
527
- <key>RunAtLoad</key><true/>
528
- <key>KeepAlive</key><true/>
529
- <key>StandardOutPath</key><string>${logFile()}</string>
530
- <key>StandardErrorPath</key><string>${logFile()}</string>
531
- </dict>
532
- </plist>
605
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
606
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
607
+ <plist version="1.0">
608
+ <dict>
609
+ <key>Label</key><string>${LAUNCHD_LABEL}</string>
610
+ <key>ProgramArguments</key>
611
+ <array>
612
+ <string>${process.execPath}</string>
613
+ <string>${cliEntry()}</string>
614
+ <string>daemon</string>
615
+ </array>
616
+ <key>RunAtLoad</key><true/>
617
+ <key>KeepAlive</key><true/>
618
+ <key>StandardOutPath</key><string>${logFile()}</string>
619
+ <key>StandardErrorPath</key><string>${logFile()}</string>
620
+ </dict>
621
+ </plist>
533
622
  `;
534
623
  const file = launchdPlistPath();
535
624
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -567,16 +656,16 @@ function isDaemonAutostartInstalled() {
567
656
  return fs.existsSync(systemdUnitPath());
568
657
  }
569
658
  function installLinux() {
570
- const unit = `[Unit]
571
- Description=FullCourtDefense resident daemon (config watch + heartbeat)
572
-
573
- [Service]
574
- ExecStart=${JSON.stringify(process.execPath)} ${JSON.stringify(cliEntry())} daemon
575
- Restart=always
576
- RestartSec=10
577
-
578
- [Install]
579
- WantedBy=default.target
659
+ const unit = `[Unit]
660
+ Description=FullCourtDefense resident daemon (config watch + heartbeat)
661
+
662
+ [Service]
663
+ ExecStart=${JSON.stringify(process.execPath)} ${JSON.stringify(cliEntry())} daemon
664
+ Restart=always
665
+ RestartSec=10
666
+
667
+ [Install]
668
+ WantedBy=default.target
580
669
  `;
581
670
  const file = systemdUnitPath();
582
671
  try {
@@ -621,9 +710,20 @@ function statusCommand() {
621
710
  try {
622
711
  const pid = Number(fs.readFileSync(pidFile(), 'utf8').trim());
623
712
  running = Number.isFinite(pid) && pid > 0 && isPidAlive(pid);
624
- console.log(running
625
- ? `${COLOR.green}Running${COLOR.reset} (pid ${pid})`
626
- : `${COLOR.yellow}Not running${COLOR.reset} (stale pid file: ${pidFile()})`);
713
+ if (running) {
714
+ const meta = readDaemonMeta();
715
+ const runningVersion = meta && meta.pid === pid ? meta.version : undefined;
716
+ const mine = cliVersion();
717
+ console.log(`${COLOR.green}Running${COLOR.reset} (pid ${pid}, version ${runningVersion || 'unknown — pre-1.15.4 build'})`);
718
+ if (meta?.entry)
719
+ console.log(`${COLOR.gray}Binary:${COLOR.reset} ${meta.entry}`);
720
+ if (mine && compareVersions(mine, runningVersion) > 0) {
721
+ console.log(`${COLOR.yellow}Outdated:${COLOR.reset} this CLI is ${mine} — run ${COLOR.bold}fullcourtdefense daemon${COLOR.reset} to supersede the old daemon.`);
722
+ }
723
+ }
724
+ else {
725
+ console.log(`${COLOR.yellow}Not running${COLOR.reset} (stale pid file: ${pidFile()})`);
726
+ }
627
727
  }
628
728
  catch {
629
729
  console.log(`${COLOR.yellow}Not running${COLOR.reset} (no pid file)`);
@@ -631,7 +731,16 @@ function statusCommand() {
631
731
  console.log(`${COLOR.gray}Log:${COLOR.reset} ${logFile()}`);
632
732
  console.log(`${COLOR.gray}Autostart:${COLOR.reset}`);
633
733
  if (process.platform === 'win32') {
634
- (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', TASK_NAME], { stdio: 'inherit' });
734
+ const task = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 5_000 });
735
+ if (task.status === 0) {
736
+ console.log(` Scheduled Task "${TASK_NAME}" installed`);
737
+ }
738
+ else if (isWindowsRunKeyInstalled()) {
739
+ console.log(` Run key installed (HKCU\\...\\Run\\${WINDOWS_RUN_VALUE}) — starts at logon`);
740
+ }
741
+ else {
742
+ console.log(' not installed — run: fullcourtdefense daemon --install true');
743
+ }
635
744
  }
636
745
  else if (process.platform === 'darwin') {
637
746
  console.log(fs.existsSync(launchdPlistPath()) ? ` launchd agent installed (${launchdPlistPath()})` : ' not installed');
@@ -39,6 +39,8 @@ export interface FetchBundleInput {
39
39
  shieldKey?: string;
40
40
  developerName?: string;
41
41
  machineName?: string;
42
+ /** Exact fleet machine ID — makes the server's pending-action lookup deterministic. */
43
+ machineId?: string;
42
44
  /** Override cache TTL (ms). */
43
45
  ttlMs?: number;
44
46
  /** Force a network refresh regardless of cache freshness. */
@@ -83,6 +83,8 @@ async function getRuntimeBundle(input) {
83
83
  params.set('developerName', input.developerName);
84
84
  if (input.machineName)
85
85
  params.set('machineName', input.machineName);
86
+ if (input.machineId)
87
+ params.set('machineId', input.machineId);
86
88
  const resp = await fetch(`${input.apiUrl}/api/cli/bundle?${params.toString()}`, { method: 'GET', headers, signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS) });
87
89
  // 304 Not Modified — cache is still valid; refresh its timestamp.
88
90
  if (resp.status === 304 && cached) {
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.15.3"
2
+ "version": "1.15.5"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.15.3",
3
+ "version": "1.15.5",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {