fullcourtdefense-cli 1.18.0 → 1.18.2

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.
@@ -76,6 +76,10 @@ const HEARTBEAT_INTERVAL_MS = envMs('FCD_DAEMON_HEARTBEAT_MS', 5 * 60_000);
76
76
  const BUNDLE_POLL_MS = envMs('FCD_DAEMON_BUNDLE_POLL_MS', 60_000);
77
77
  /** Delay before the one-time initial discovery sweep on a fresh machine. */
78
78
  const INITIAL_DISCOVER_DELAY_MS = envMs('FCD_DAEMON_INITIAL_DISCOVER_MS', 2 * 60_000);
79
+ /** A discovery upload older than this is stale — the daemon catches up itself. */
80
+ const DISCOVER_STALE_MS = envMs('FCD_DAEMON_DISCOVER_STALE_MS', 20 * 60 * 60_000);
81
+ /** How often the daemon re-checks discovery freshness. */
82
+ const DISCOVER_CHECK_INTERVAL_MS = envMs('FCD_DAEMON_DISCOVER_CHECK_MS', 60 * 60_000);
79
83
  /** Rotate the daemon log when it grows past this size. */
80
84
  const LOG_MAX_BYTES = 1_000_000;
81
85
  function daemonDir() {
@@ -535,9 +539,12 @@ async function runDaemon(args, config) {
535
539
  if (!target)
536
540
  throw new Error('No target version available — the control plane could not resolve the latest release.');
537
541
  log(`Upgrade CLI: admin requested an upgrade to ${target}.`);
538
- const outcome = (0, selfUpdate_1.maybeSelfUpdate)({ currentVersion: cliVersion(), targetVersion: target, enabled: true, log });
542
+ // force: bypass the auto-update retry cooldown an explicit admin
543
+ // action must actually attempt and report the real outcome, not
544
+ // "in progress" while a broken path silently retries hourly.
545
+ const outcome = (0, selfUpdate_1.maybeSelfUpdate)({ currentVersion: cliVersion(), targetVersion: target, enabled: true, force: true, log });
539
546
  if (!outcome) {
540
- resultSummary = `CLI ${cliVersion() || 'unknown'} is already at ${target} (or an upgrade is in progress).`;
547
+ resultSummary = `CLI ${cliVersion() || 'unknown'} is already at ${target}.`;
541
548
  }
542
549
  else if (outcome.started) {
543
550
  resultSummary = outcome.detail;
@@ -693,6 +700,45 @@ async function runDaemon(args, config) {
693
700
  await uploadLogTail();
694
701
  }, INITIAL_DISCOVER_DELAY_MS);
695
702
  }
703
+ // Staleness catch-up: the daily scheduled job fires at a FIXED hour, so a
704
+ // laptop that was asleep/powered off at that hour misses the whole day —
705
+ // admins then see day-old discovery/posture data even though the machine is
706
+ // online. The daemon closes that gap: whenever the last successful upload is
707
+ // older than ~20h, run a full sweep now. Checked shortly after boot (wake-up
708
+ // catch-up) and hourly thereafter; the marker written by `discover` keeps
709
+ // this idempotent alongside the scheduled task.
710
+ let discoverCatchUpRunning = false;
711
+ const maybeCatchUpDiscovery = async (reason) => {
712
+ if (stopped || discoverCatchUpRunning || !creds.shieldId)
713
+ return;
714
+ if (!(0, discoveryMarker_1.hasDiscoveryUploadMarker)())
715
+ return; // fresh machine — initial sweep owns it
716
+ const last = (0, discoveryMarker_1.lastDiscoveryUploadAt)();
717
+ if (last && Date.now() - last.getTime() < DISCOVER_STALE_MS)
718
+ return;
719
+ discoverCatchUpRunning = true;
720
+ log(`Discovery catch-up (${reason}): last upload ${last ? Math.round((Date.now() - last.getTime()) / 3_600_000) + 'h ago' : 'unknown'} — starting full sweep (MCP + secrets + agent files + posture)…`);
721
+ await uploadLogTail();
722
+ const logPump = setInterval(() => { void uploadLogTail(); }, 15_000);
723
+ try {
724
+ const exitCode = await runDiscoverSweep();
725
+ if (exitCode !== 0)
726
+ throw new Error(`discover exited with code ${exitCode}`);
727
+ log('Discovery catch-up: upload complete — dashboard discovery + posture timestamps are now fresh.');
728
+ }
729
+ catch (error) {
730
+ log(`Discovery catch-up failed (retried hourly): ${error.message}`);
731
+ }
732
+ finally {
733
+ clearInterval(logPump);
734
+ discoverCatchUpRunning = false;
735
+ await uploadLogTail();
736
+ }
737
+ };
738
+ // Give the machine a couple of minutes to settle after boot/wake before the
739
+ // first check — same grace as the initial sweep.
740
+ const discoverCatchUpBootTimer = setTimeout(() => { void maybeCatchUpDiscovery('after start'); }, INITIAL_DISCOVER_DELAY_MS);
741
+ const discoverCatchUpTimer = setInterval(() => { void maybeCatchUpDiscovery('hourly check'); }, DISCOVER_CHECK_INTERVAL_MS);
696
742
  const rescanTimer = setInterval(() => {
697
743
  const count = refreshWatchTargets();
698
744
  log(`Rescan: watching ${count} config file(s).`);
@@ -707,6 +753,8 @@ async function runDaemon(args, config) {
707
753
  clearInterval(rescanTimer);
708
754
  clearInterval(bundleTimer);
709
755
  clearInterval(heartbeatTimer);
756
+ clearTimeout(discoverCatchUpBootTimer);
757
+ clearInterval(discoverCatchUpTimer);
710
758
  if (initialDiscoverTimer)
711
759
  clearTimeout(initialDiscoverTimer);
712
760
  if (debounceTimer)
@@ -1,2 +1,9 @@
1
1
  export declare function hasDiscoveryUploadMarker(): boolean;
2
+ /**
3
+ * When the last successful full discovery upload completed, or undefined if
4
+ * this machine never uploaded one. Drives the daemon's staleness catch-up:
5
+ * laptops that were powered off at the daily task's fixed hour still get a
6
+ * fresh scan shortly after they wake up.
7
+ */
8
+ export declare function lastDiscoveryUploadAt(): Date | undefined;
2
9
  export declare function writeDiscoveryUploadMarker(trigger: string): void;
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.hasDiscoveryUploadMarker = hasDiscoveryUploadMarker;
37
+ exports.lastDiscoveryUploadAt = lastDiscoveryUploadAt;
37
38
  exports.writeDiscoveryUploadMarker = writeDiscoveryUploadMarker;
38
39
  const fs = __importStar(require("fs"));
39
40
  const os = __importStar(require("os"));
@@ -55,6 +56,22 @@ function hasDiscoveryUploadMarker() {
55
56
  return false;
56
57
  }
57
58
  }
59
+ /**
60
+ * When the last successful full discovery upload completed, or undefined if
61
+ * this machine never uploaded one. Drives the daemon's staleness catch-up:
62
+ * laptops that were powered off at the daily task's fixed hour still get a
63
+ * fresh scan shortly after they wake up.
64
+ */
65
+ function lastDiscoveryUploadAt() {
66
+ try {
67
+ const raw = JSON.parse(fs.readFileSync(markerFile(), 'utf8'));
68
+ const date = raw.completedAt ? new Date(raw.completedAt) : undefined;
69
+ return date && !Number.isNaN(date.getTime()) ? date : undefined;
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ }
58
75
  function writeDiscoveryUploadMarker(trigger) {
59
76
  try {
60
77
  fs.mkdirSync(path.dirname(markerFile()), { recursive: true });
@@ -36,6 +36,9 @@ export declare function maybeSelfUpdate(input: {
36
36
  log?: (message: string) => void;
37
37
  /** Skip while a remote machine action is executing (never upgrade mid-action). */
38
38
  busy?: boolean;
39
+ /** Bypass the retry cooldown — used by explicit admin upgrade_cli actions so
40
+ * they always attempt and report the REAL outcome instead of "in progress". */
41
+ force?: boolean;
39
42
  }): SelfUpdateResult | undefined;
40
43
  /** Version the MSI updater script reads from the installed package.json. */
41
44
  export declare function installedMsiVersion(installFolder: string): string | undefined;
@@ -116,14 +116,45 @@ function startNpmSelfUpdate(targetVersion, log) {
116
116
  * but group policy can deny it — in that case the daily trigger remains the
117
117
  * backstop and we report that honestly.
118
118
  */
119
+ /** Install folder of an MSI deployment (…\FullCourtDefense), from the running entry. */
120
+ function msiInstallRoot() {
121
+ const entry = path.resolve(process.argv[1] || '');
122
+ // dist\index.js → install root is the parent of dist.
123
+ const dist = path.dirname(entry);
124
+ const root = path.dirname(dist);
125
+ return fs.existsSync(path.join(root, 'Update-FullCourtDefense.ps1')) ? root : undefined;
126
+ }
127
+ /**
128
+ * Self-heal a missing updater task. Machines installed from an older MSI (or
129
+ * where task creation was denied at install time) ship the updater SCRIPT but
130
+ * not the scheduled task — without this, every self-update tick dead-ends with
131
+ * "reinstall the MSI". Registration needs an elevated token, so this succeeds
132
+ * on elevated daemons and stays a silent no-op otherwise.
133
+ */
134
+ function tryRegisterMsiUpdaterTask(log) {
135
+ const root = msiInstallRoot();
136
+ if (!root)
137
+ return false;
138
+ const script = path.join(root, 'Update-FullCourtDefense.ps1');
139
+ const taskCommand = `powershell.exe -NoProfile -ExecutionPolicy Bypass -File \\"${script}\\"`;
140
+ const create = (0, child_process_1.spawnSync)('schtasks', [
141
+ '/Create', '/TN', exports.MSI_UPDATER_TASK_NAME, '/TR', taskCommand,
142
+ '/SC', 'DAILY', '/ST', '03:07', '/RU', 'SYSTEM', '/RL', 'HIGHEST', '/F',
143
+ ], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
144
+ if (create.status === 0) {
145
+ log('Self-update: registered the missing MSI updater task (self-heal).');
146
+ return true;
147
+ }
148
+ return false;
149
+ }
119
150
  function startMsiSelfUpdate(targetVersion, log) {
120
151
  const query = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', exports.MSI_UPDATER_TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
121
- if (query.status !== 0) {
152
+ if (query.status !== 0 && !tryRegisterMsiUpdaterTask(log)) {
122
153
  log('Self-update: MSI updater task is not registered on this machine — reinstall the MSI to enable silent updates.');
123
154
  return {
124
155
  started: false,
125
156
  kind: 'msi',
126
- detail: `The "${exports.MSI_UPDATER_TASK_NAME}" scheduled task is missing. Reinstall the latest MSI (or redeploy via MDM) to restore silent updates.`,
157
+ detail: `The "${exports.MSI_UPDATER_TASK_NAME}" scheduled task is missing and could not be self-registered (needs an elevated daemon). Reinstall the latest MSI once (or redeploy via MDM) to restore silent updates.`,
127
158
  };
128
159
  }
129
160
  const run = (0, child_process_1.spawnSync)('schtasks', ['/Run', '/TN', exports.MSI_UPDATER_TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
@@ -153,7 +184,7 @@ function maybeSelfUpdate(input) {
153
184
  return undefined;
154
185
  if (compareCliVersions(input.targetVersion, input.currentVersion) <= 0)
155
186
  return undefined;
156
- if (Date.now() - updateInFlightSince < UPDATE_RETRY_COOLDOWN_MS)
187
+ if (!input.force && Date.now() - updateInFlightSince < UPDATE_RETRY_COOLDOWN_MS)
157
188
  return undefined;
158
189
  updateInFlightSince = Date.now();
159
190
  log(`Self-update: CLI ${input.currentVersion || 'unknown'} -> ${input.targetVersion} (org auto-update).`);
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.18.0"
2
+ "version": "1.18.2"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.18.0",
3
+ "version": "1.18.2",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {