fullcourtdefense-cli 1.15.3 → 1.15.4

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
  }
@@ -467,8 +548,15 @@ function isWindowsRunKeyInstalled() {
467
548
  function startDaemonNowWindows(vbs) {
468
549
  try {
469
550
  const existing = Number(fs.readFileSync(pidFile(), 'utf8').trim());
470
- if (Number.isFinite(existing) && existing > 0 && isPidAlive(existing))
471
- return;
551
+ if (Number.isFinite(existing) && existing > 0 && isPidAlive(existing)) {
552
+ // Skip only when the running daemon is the same version or newer; an
553
+ // older one (e.g. stale MSI copy) gets superseded by the spawned daemon,
554
+ // whose acquirePidLock() stops it and takes over.
555
+ const meta = readDaemonMeta();
556
+ const runningVersion = meta && meta.pid === existing ? meta.version : undefined;
557
+ if (compareVersions(cliVersion(), runningVersion) <= 0)
558
+ return;
559
+ }
472
560
  }
473
561
  catch { /* not running */ }
474
562
  const escaped = vbs.replace(/'/g, "''");
@@ -513,23 +601,23 @@ function launchdPlistPath() {
513
601
  return path.join(os.homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
514
602
  }
515
603
  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>
604
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
605
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
606
+ <plist version="1.0">
607
+ <dict>
608
+ <key>Label</key><string>${LAUNCHD_LABEL}</string>
609
+ <key>ProgramArguments</key>
610
+ <array>
611
+ <string>${process.execPath}</string>
612
+ <string>${cliEntry()}</string>
613
+ <string>daemon</string>
614
+ </array>
615
+ <key>RunAtLoad</key><true/>
616
+ <key>KeepAlive</key><true/>
617
+ <key>StandardOutPath</key><string>${logFile()}</string>
618
+ <key>StandardErrorPath</key><string>${logFile()}</string>
619
+ </dict>
620
+ </plist>
533
621
  `;
534
622
  const file = launchdPlistPath();
535
623
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -567,16 +655,16 @@ function isDaemonAutostartInstalled() {
567
655
  return fs.existsSync(systemdUnitPath());
568
656
  }
569
657
  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
658
+ const unit = `[Unit]
659
+ Description=FullCourtDefense resident daemon (config watch + heartbeat)
660
+
661
+ [Service]
662
+ ExecStart=${JSON.stringify(process.execPath)} ${JSON.stringify(cliEntry())} daemon
663
+ Restart=always
664
+ RestartSec=10
665
+
666
+ [Install]
667
+ WantedBy=default.target
580
668
  `;
581
669
  const file = systemdUnitPath();
582
670
  try {
@@ -621,9 +709,20 @@ function statusCommand() {
621
709
  try {
622
710
  const pid = Number(fs.readFileSync(pidFile(), 'utf8').trim());
623
711
  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()})`);
712
+ if (running) {
713
+ const meta = readDaemonMeta();
714
+ const runningVersion = meta && meta.pid === pid ? meta.version : undefined;
715
+ const mine = cliVersion();
716
+ console.log(`${COLOR.green}Running${COLOR.reset} (pid ${pid}, version ${runningVersion || 'unknown — pre-1.15.4 build'})`);
717
+ if (meta?.entry)
718
+ console.log(`${COLOR.gray}Binary:${COLOR.reset} ${meta.entry}`);
719
+ if (mine && compareVersions(mine, runningVersion) > 0) {
720
+ console.log(`${COLOR.yellow}Outdated:${COLOR.reset} this CLI is ${mine} — run ${COLOR.bold}fullcourtdefense daemon${COLOR.reset} to supersede the old daemon.`);
721
+ }
722
+ }
723
+ else {
724
+ console.log(`${COLOR.yellow}Not running${COLOR.reset} (stale pid file: ${pidFile()})`);
725
+ }
627
726
  }
628
727
  catch {
629
728
  console.log(`${COLOR.yellow}Not running${COLOR.reset} (no pid file)`);
@@ -631,7 +730,16 @@ function statusCommand() {
631
730
  console.log(`${COLOR.gray}Log:${COLOR.reset} ${logFile()}`);
632
731
  console.log(`${COLOR.gray}Autostart:${COLOR.reset}`);
633
732
  if (process.platform === 'win32') {
634
- (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', TASK_NAME], { stdio: 'inherit' });
733
+ const task = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 5_000 });
734
+ if (task.status === 0) {
735
+ console.log(` Scheduled Task "${TASK_NAME}" installed`);
736
+ }
737
+ else if (isWindowsRunKeyInstalled()) {
738
+ console.log(` Run key installed (HKCU\\...\\Run\\${WINDOWS_RUN_VALUE}) — starts at logon`);
739
+ }
740
+ else {
741
+ console.log(' not installed — run: fullcourtdefense daemon --install true');
742
+ }
635
743
  }
636
744
  else if (process.platform === 'darwin') {
637
745
  console.log(fs.existsSync(launchdPlistPath()) ? ` launchd agent installed (${launchdPlistPath()})` : ' not installed');
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.15.3"
2
+ "version": "1.15.4"
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.4",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {