fullcourtdefense-cli 1.14.16 → 1.15.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/README.md CHANGED
@@ -12,22 +12,36 @@ npm install -g fullcourtdefense-cli
12
12
  npx fullcourtdefense-cli scan
13
13
  ```
14
14
 
15
- ## Quick Start
15
+ ## Quick Start (protect this machine)
16
+
17
+ One command takes a fresh machine to a verified protected machine: connectivity check, fleet enrollment, MCP gateway wrapping, IDE hooks, terminal guards, discovery upload, and a green/red verification checklist.
16
18
 
17
19
  ```bash
18
- # Show onboarding help
19
- fullcourtdefense help
20
+ # An org admin issues a fleet enrollment token in the web app:
21
+ # AI Fleet -> Settings -> Fleet enrollment token
20
22
 
21
- # 1. Check outbound HTTPS from the customer machine
22
- fullcourtdefense doctor
23
+ fullcourtdefense onboard --token <fleet-enrollment-token>
24
+ # or: set FCD_ENROLL_TOKEN and run `fullcourtdefense onboard`
25
+ ```
23
26
 
24
- # 2. Save Shield ID and Shield key
25
- fullcourtdefense configure
27
+ Then restart your AI clients (Cursor, Claude, VS Code, ...) so they pick up the wrapped configs. The machine reports to your org's AI Fleet in monitor-first mode until an admin enables enforcement.
28
+
29
+ Manual step-by-step equivalent:
26
30
 
27
- # 3. Run an in-organization local scan with guided questions
31
+ ```bash
32
+ fullcourtdefense doctor # 1. outbound HTTPS check
33
+ fullcourtdefense login --token <fleet-enrollment-token> # 2. enroll (zero paste)
34
+ fullcourtdefense install-all # 3. protect every AI client
35
+ fullcourtdefense protect-all --dry-run true # 4. verify gateways
36
+ ```
37
+
38
+ ## Quick Start (scanning)
39
+
40
+ ```bash
41
+ # Run an in-organization local scan with guided questions
28
42
  fullcourtdefense scan --local
29
43
 
30
- # 4. Run a detailed MCP report
44
+ # Run a detailed MCP report
31
45
  fullcourtdefense scan --local --type mcp --mcp-command node --mcp-args ./server.js --mcp-tool all --mode full --format report
32
46
 
33
47
  # Hosted CI/CD scan, if using an API key instead of local Shield scan
@@ -43,8 +57,11 @@ fullcourtdefense init
43
57
  ## Command Guide
44
58
 
45
59
  - `fullcourtdefense help` — shows the full onboarding flow and command reference.
60
+ - `fullcourtdefense onboard --token <token>` — one command: doctor + login + install-all + verification checklist (exits non-zero if a required surface failed — safe for MDM scripts).
46
61
  - `fullcourtdefense doctor` — confirms outbound HTTPS to FullCourtDefense is open before scanning.
47
- - `fullcourtdefense configure`saves Shield ID, Shield key, and API URL to `.fullcourtdefense.yml`.
62
+ - `fullcourtdefense login --token <token>` enrolls this machine with a fleet token and saves per-machine Shield credentials (no copy/paste).
63
+ - `fullcourtdefense install-all` — wraps every configured MCP server, installs IDE hooks and terminal guards, uploads discovery, schedules daily rescans.
64
+ - `fullcourtdefense configure` — legacy/manual setup: saves org API key, Shield ID, Shield key, and API URL to `.fullcourtdefense.yml` (use `login` instead when you have a fleet token).
48
65
  - `fullcourtdefense scan --local` — runs inside the customer network and asks whether to scan endpoint, MCP, or RAG.
49
66
  - `fullcourtdefense scan --local --type mcp ...` — launches a local stdio MCP server, calls tools, and sends tool responses to Shield.
50
67
  - `fullcourtdefense scan --local --type rag ...` — scans local RAG files/directories or a live RAG HTTP service.
@@ -0,0 +1,10 @@
1
+ import { BotGuardConfig } from '../config';
2
+ import { ProtectAllArgs } from './mcpGateway';
3
+ export interface DaemonArgs extends ProtectAllArgs {
4
+ install?: string;
5
+ uninstall?: string;
6
+ status?: string;
7
+ /** Suppress OS toasts (still logs). */
8
+ quiet?: string;
9
+ }
10
+ export declare function daemonCommand(args: DaemonArgs, config: BotGuardConfig): Promise<void>;
@@ -0,0 +1,498 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.daemonCommand = daemonCommand;
37
+ const fs = __importStar(require("fs"));
38
+ const os = __importStar(require("os"));
39
+ const path = __importStar(require("path"));
40
+ const child_process_1 = require("child_process");
41
+ const config_1 = require("../config");
42
+ const mcpGateway_1 = require("./mcpGateway");
43
+ const runtimeConfig_1 = require("../runtimeConfig");
44
+ const telemetry_1 = require("../telemetry");
45
+ const notify_1 = require("../notify");
46
+ const COLOR = {
47
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
48
+ red: '\x1b[31m', yellow: '\x1b[33m', green: '\x1b[32m', cyan: '\x1b[36m', gray: '\x1b[90m',
49
+ };
50
+ const TASK_NAME = 'FullCourtDefense Daemon';
51
+ const CRON_MARKER = '# FCD_DAEMON';
52
+ const LAUNCHD_LABEL = 'ai.fullcourtdefense.daemon';
53
+ const SYSTEMD_UNIT = 'fullcourtdefense-daemon.service';
54
+ function envMs(name, fallback) {
55
+ const value = Number(process.env[name]);
56
+ return Number.isFinite(value) && value > 0 ? value : fallback;
57
+ }
58
+ /** How long after our own protect-all writes we ignore watcher events (self-inflicted churn). */
59
+ const SELF_WRITE_QUIET_MS = envMs('FCD_DAEMON_QUIET_MS', 5_000);
60
+ /** Debounce window for filesystem events before re-protecting. */
61
+ const DEBOUNCE_MS = envMs('FCD_DAEMON_DEBOUNCE_MS', 2_000);
62
+ /** Re-enumerate config files this often to pick up newly installed clients. */
63
+ const RESCAN_INTERVAL_MS = envMs('FCD_DAEMON_RESCAN_MS', 5 * 60_000);
64
+ /** Heartbeat / spool flush cadence. */
65
+ const HEARTBEAT_INTERVAL_MS = envMs('FCD_DAEMON_HEARTBEAT_MS', 5 * 60_000);
66
+ /** Bundle (mode / suspension / policy version) poll cadence. */
67
+ const BUNDLE_POLL_MS = envMs('FCD_DAEMON_BUNDLE_POLL_MS', 60_000);
68
+ /** Rotate the daemon log when it grows past this size. */
69
+ const LOG_MAX_BYTES = 1_000_000;
70
+ function daemonDir() {
71
+ return path.join(os.homedir(), '.fullcourtdefense');
72
+ }
73
+ function pidFile() {
74
+ return path.join(daemonDir(), 'daemon.pid');
75
+ }
76
+ function logFile() {
77
+ return path.join(daemonDir(), 'daemon.log');
78
+ }
79
+ function cliEntry() {
80
+ return path.resolve(process.argv[1] || path.join(__dirname, '..', 'index.js'));
81
+ }
82
+ function log(message) {
83
+ const line = `[${new Date().toISOString()}] ${message}`;
84
+ console.log(line);
85
+ try {
86
+ fs.mkdirSync(daemonDir(), { recursive: true });
87
+ const file = logFile();
88
+ try {
89
+ if (fs.existsSync(file) && fs.statSync(file).size > LOG_MAX_BYTES) {
90
+ fs.renameSync(file, `${file}.1`);
91
+ }
92
+ }
93
+ catch { /* rotation is best-effort */ }
94
+ fs.appendFileSync(file, `${line}\n`, 'utf8');
95
+ }
96
+ catch { /* logging must never kill the daemon */ }
97
+ }
98
+ function isPidAlive(pid) {
99
+ try {
100
+ process.kill(pid, 0);
101
+ return true;
102
+ }
103
+ catch (error) {
104
+ return error.code === 'EPERM';
105
+ }
106
+ }
107
+ /** Take the single-instance lock. Returns false when another daemon is already running. */
108
+ function acquirePidLock() {
109
+ fs.mkdirSync(daemonDir(), { recursive: true });
110
+ try {
111
+ const existing = Number(fs.readFileSync(pidFile(), 'utf8').trim());
112
+ if (Number.isFinite(existing) && existing > 0 && existing !== process.pid && isPidAlive(existing)) {
113
+ return false;
114
+ }
115
+ }
116
+ catch { /* no pidfile — free to start */ }
117
+ fs.writeFileSync(pidFile(), String(process.pid), 'utf8');
118
+ return true;
119
+ }
120
+ function releasePidLock() {
121
+ try {
122
+ const recorded = Number(fs.readFileSync(pidFile(), 'utf8').trim());
123
+ if (recorded === process.pid)
124
+ fs.unlinkSync(pidFile());
125
+ }
126
+ catch { /* best-effort */ }
127
+ }
128
+ /** Cursor/Claude hook config files worth watching for tamper (deletion/rewrite). */
129
+ function hookConfigFiles() {
130
+ const home = os.homedir();
131
+ return [
132
+ path.join(home, '.cursor', 'hooks.json'),
133
+ path.join(home, '.claude', 'settings.json'),
134
+ ].filter(file => fs.existsSync(file));
135
+ }
136
+ // ---------------------------------------------------------------------------
137
+ // The resident loop
138
+ // ---------------------------------------------------------------------------
139
+ async function runDaemon(args, config) {
140
+ if (!acquirePidLock()) {
141
+ console.log(`${COLOR.yellow}Another FullCourtDefense daemon is already running (pid file: ${pidFile()}).${COLOR.reset}`);
142
+ return;
143
+ }
144
+ const creds = (0, config_1.resolveCliCredentials)(config, {
145
+ shieldId: args.shieldId,
146
+ shieldKey: args.shieldKey,
147
+ apiUrl: args.apiUrl,
148
+ });
149
+ const quiet = args.quiet === 'true';
150
+ log(`Daemon started (pid ${process.pid}, platform ${process.platform}).`);
151
+ log(`Shield: ${creds.shieldId || '(none — protect-only mode, telemetry disabled)'} API: ${creds.apiUrl}`);
152
+ // --- state ---------------------------------------------------------------
153
+ const watchers = new Map(); // directory -> watcher
154
+ const watchedFiles = new Set(); // lowercased absolute file paths we react to
155
+ let quietUntil = 0; // ignore events until this time (self-writes)
156
+ let debounceTimer = null;
157
+ let reprotecting = false;
158
+ let suspended = false;
159
+ let stopped = false;
160
+ const reprotect = async (reasonPaths) => {
161
+ if (reprotecting || stopped)
162
+ return;
163
+ if (suspended) {
164
+ log(`Config drift detected (${reasonPaths.join(', ')}) but machine is suspended — not re-protecting.`);
165
+ return;
166
+ }
167
+ reprotecting = true;
168
+ quietUntil = Date.now() + SELF_WRITE_QUIET_MS;
169
+ log(`Config drift detected: ${reasonPaths.join(', ')} — re-running protect-all.`);
170
+ try {
171
+ await (0, mcpGateway_1.protectAllCommand)({ ...args, dryRun: undefined }, config);
172
+ quietUntil = Date.now() + SELF_WRITE_QUIET_MS;
173
+ log('Re-protection pass complete.');
174
+ if (!quiet) {
175
+ (0, notify_1.notifyOs)({
176
+ title: 'FullCourtDefense re-protected this machine',
177
+ message: 'An MCP or hook config changed; the AgentGuard gateway was re-applied.',
178
+ url: (0, notify_1.consoleUrl)('/agent-security/users?view=desktop'),
179
+ });
180
+ }
181
+ }
182
+ catch (error) {
183
+ log(`Re-protection failed: ${error.message}`);
184
+ }
185
+ finally {
186
+ reprotecting = false;
187
+ }
188
+ };
189
+ const onFsEvent = (dir, filename) => {
190
+ if (stopped || Date.now() < quietUntil)
191
+ return;
192
+ const full = filename ? path.resolve(dir, filename.toString()) : dir;
193
+ const key = full.toLowerCase();
194
+ // Ignore our own backup files and unrelated churn in watched directories.
195
+ if (key.includes('.fcd-backup-'))
196
+ return;
197
+ if (filename && !watchedFiles.has(key))
198
+ return;
199
+ if (debounceTimer)
200
+ clearTimeout(debounceTimer);
201
+ debounceTimer = setTimeout(() => { void reprotect([full]); }, DEBOUNCE_MS);
202
+ };
203
+ /**
204
+ * (Re)build the watch set from the current MCP client configs + hook files.
205
+ * Watches parent directories (not files) so atomic replaces on Windows and
206
+ * editor save-via-rename still emit events.
207
+ */
208
+ const refreshWatchTargets = () => {
209
+ const targets = [
210
+ ...(0, mcpGateway_1.protectAllTargetFiles)(args.config).map(f => f.path),
211
+ ...hookConfigFiles(),
212
+ ];
213
+ watchedFiles.clear();
214
+ const wantedDirs = new Set();
215
+ for (const file of targets) {
216
+ watchedFiles.add(path.resolve(file).toLowerCase());
217
+ wantedDirs.add(path.dirname(path.resolve(file)));
218
+ }
219
+ for (const [dir, watcher] of watchers) {
220
+ if (!wantedDirs.has(dir)) {
221
+ watcher.close();
222
+ watchers.delete(dir);
223
+ }
224
+ }
225
+ for (const dir of wantedDirs) {
226
+ if (watchers.has(dir))
227
+ continue;
228
+ try {
229
+ const watcher = fs.watch(dir, (_event, filename) => onFsEvent(dir, filename ? String(filename) : null));
230
+ watcher.on('error', () => { watcher.close(); watchers.delete(dir); });
231
+ watchers.set(dir, watcher);
232
+ }
233
+ catch { /* directory may vanish; the rescan tick re-tries */ }
234
+ }
235
+ return targets.length;
236
+ };
237
+ const pollBundle = async () => {
238
+ if (!creds.shieldId)
239
+ return;
240
+ try {
241
+ const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({
242
+ apiUrl: creds.apiUrl,
243
+ shieldId: creds.shieldId,
244
+ shieldKey: creds.shieldKey,
245
+ force: true,
246
+ });
247
+ if (bundle.suspended && !suspended) {
248
+ suspended = true;
249
+ log('Machine SUSPENDED by admin — hooks/gateway deny all actions; daemon pauses re-protection.');
250
+ if (!quiet) {
251
+ (0, notify_1.notifyOs)({
252
+ title: 'FullCourtDefense: machine suspended',
253
+ message: 'An admin suspended this machine. AI tool calls are denied until it is resumed.',
254
+ url: (0, notify_1.consoleUrl)('/agent-security/users?view=desktop'),
255
+ });
256
+ }
257
+ }
258
+ else if (!bundle.suspended && suspended) {
259
+ suspended = false;
260
+ log('Machine resumed — normal enforcement restored.');
261
+ }
262
+ }
263
+ catch { /* offline — cached stance applies */ }
264
+ };
265
+ const heartbeat = async () => {
266
+ if (!creds.shieldId)
267
+ return;
268
+ try {
269
+ const result = await (0, telemetry_1.flushSpool)({
270
+ apiUrl: creds.apiUrl,
271
+ shieldId: creds.shieldId,
272
+ shieldKey: creds.shieldKey,
273
+ heartbeat: true,
274
+ });
275
+ if (result && result.accepted > 0)
276
+ log(`Heartbeat: flushed ${result.accepted} spooled event(s).`);
277
+ }
278
+ catch { /* spool stays on disk for the next tick */ }
279
+ };
280
+ // --- boot ---------------------------------------------------------------
281
+ const watched = refreshWatchTargets();
282
+ log(`Watching ${watched} config file(s) across ${watchers.size} director${watchers.size === 1 ? 'y' : 'ies'}.`);
283
+ await pollBundle();
284
+ await heartbeat();
285
+ // One protective pass at startup so a machine that drifted while the daemon
286
+ // was down converges immediately.
287
+ await reprotect(['startup pass']);
288
+ const rescanTimer = setInterval(() => {
289
+ const count = refreshWatchTargets();
290
+ log(`Rescan: watching ${count} config file(s).`);
291
+ }, RESCAN_INTERVAL_MS);
292
+ const bundleTimer = setInterval(() => { void pollBundle(); }, BUNDLE_POLL_MS);
293
+ const heartbeatTimer = setInterval(() => { void heartbeat(); }, HEARTBEAT_INTERVAL_MS);
294
+ const shutdown = (signal) => {
295
+ if (stopped)
296
+ return;
297
+ stopped = true;
298
+ log(`Received ${signal} — shutting down.`);
299
+ clearInterval(rescanTimer);
300
+ clearInterval(bundleTimer);
301
+ clearInterval(heartbeatTimer);
302
+ if (debounceTimer)
303
+ clearTimeout(debounceTimer);
304
+ for (const watcher of watchers.values())
305
+ watcher.close();
306
+ releasePidLock();
307
+ process.exit(0);
308
+ };
309
+ process.on('SIGINT', () => shutdown('SIGINT'));
310
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
311
+ console.log(`\n${COLOR.green}${COLOR.bold}Daemon running.${COLOR.reset} ${COLOR.gray}Log: ${logFile()} · Stop with Ctrl+C${COLOR.reset}\n`);
312
+ // Keep the process alive forever (timers alone would do it, but be explicit).
313
+ await new Promise(() => { });
314
+ }
315
+ // ---------------------------------------------------------------------------
316
+ // Autostart install / uninstall / status
317
+ // ---------------------------------------------------------------------------
318
+ /** Hidden VBS launcher so the logon task doesn't flash a console window. */
319
+ function ensureWindowsLauncher() {
320
+ const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
321
+ const dir = path.join(base, 'FullCourtDefense');
322
+ fs.mkdirSync(dir, { recursive: true });
323
+ const vbs = path.join(dir, 'daemon.vbs');
324
+ const inner = `"${process.execPath}" "${cliEntry()}" daemon`;
325
+ const content = `CreateObject("WScript.Shell").Run "cmd /c ${inner.replace(/"/g, '""')}", 0, False\n`;
326
+ fs.writeFileSync(vbs, content, 'utf8');
327
+ return vbs;
328
+ }
329
+ function installWindows() {
330
+ const vbs = ensureWindowsLauncher();
331
+ const result = (0, child_process_1.spawnSync)('schtasks', [
332
+ '/Create', '/TN', TASK_NAME, '/TR', `wscript.exe "${vbs}"`,
333
+ '/SC', 'ONLOGON', '/F', '/RL', 'LIMITED',
334
+ ], { stdio: 'inherit' });
335
+ return result.status === 0;
336
+ }
337
+ function uninstallWindows() {
338
+ const result = (0, child_process_1.spawnSync)('schtasks', ['/Delete', '/TN', TASK_NAME, '/F'], { stdio: 'inherit' });
339
+ const vbs = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'FullCourtDefense', 'daemon.vbs');
340
+ try {
341
+ if (fs.existsSync(vbs))
342
+ fs.unlinkSync(vbs);
343
+ }
344
+ catch { /* ignore */ }
345
+ return result.status === 0;
346
+ }
347
+ function launchdPlistPath() {
348
+ return path.join(os.homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
349
+ }
350
+ function installMacos() {
351
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
352
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
353
+ <plist version="1.0">
354
+ <dict>
355
+ <key>Label</key><string>${LAUNCHD_LABEL}</string>
356
+ <key>ProgramArguments</key>
357
+ <array>
358
+ <string>${process.execPath}</string>
359
+ <string>${cliEntry()}</string>
360
+ <string>daemon</string>
361
+ </array>
362
+ <key>RunAtLoad</key><true/>
363
+ <key>KeepAlive</key><true/>
364
+ <key>StandardOutPath</key><string>${logFile()}</string>
365
+ <key>StandardErrorPath</key><string>${logFile()}</string>
366
+ </dict>
367
+ </plist>
368
+ `;
369
+ const file = launchdPlistPath();
370
+ fs.mkdirSync(path.dirname(file), { recursive: true });
371
+ fs.writeFileSync(file, plist, 'utf8');
372
+ (0, child_process_1.spawnSync)('launchctl', ['unload', file], { stdio: 'ignore' });
373
+ const result = (0, child_process_1.spawnSync)('launchctl', ['load', file], { stdio: 'inherit' });
374
+ return result.status === 0;
375
+ }
376
+ function uninstallMacos() {
377
+ const file = launchdPlistPath();
378
+ const result = (0, child_process_1.spawnSync)('launchctl', ['unload', file], { stdio: 'inherit' });
379
+ try {
380
+ if (fs.existsSync(file))
381
+ fs.unlinkSync(file);
382
+ }
383
+ catch { /* ignore */ }
384
+ return result.status === 0;
385
+ }
386
+ function systemdUnitPath() {
387
+ const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
388
+ return path.join(base, 'systemd', 'user', SYSTEMD_UNIT);
389
+ }
390
+ function installLinux() {
391
+ const unit = `[Unit]
392
+ Description=FullCourtDefense resident daemon (config watch + heartbeat)
393
+
394
+ [Service]
395
+ ExecStart=${JSON.stringify(process.execPath)} ${JSON.stringify(cliEntry())} daemon
396
+ Restart=always
397
+ RestartSec=10
398
+
399
+ [Install]
400
+ WantedBy=default.target
401
+ `;
402
+ const file = systemdUnitPath();
403
+ try {
404
+ fs.mkdirSync(path.dirname(file), { recursive: true });
405
+ fs.writeFileSync(file, unit, 'utf8');
406
+ const reload = (0, child_process_1.spawnSync)('systemctl', ['--user', 'daemon-reload'], { stdio: 'inherit' });
407
+ const enable = (0, child_process_1.spawnSync)('systemctl', ['--user', 'enable', '--now', SYSTEMD_UNIT], { stdio: 'inherit' });
408
+ if (reload.status === 0 && enable.status === 0)
409
+ return true;
410
+ }
411
+ catch { /* fall back to cron */ }
412
+ // Fallback: cron @reboot for boxes without a systemd user session.
413
+ const command = `"${process.execPath}" "${cliEntry()}" daemon >/dev/null 2>&1`;
414
+ const list = (0, child_process_1.spawnSync)('crontab', ['-l'], { encoding: 'utf8' });
415
+ const existing = (list.status === 0 ? (list.stdout || '') : '').split('\n').filter(l => l && !l.includes(CRON_MARKER));
416
+ const write = (0, child_process_1.spawnSync)('crontab', ['-'], { input: [...existing, `@reboot ${command} ${CRON_MARKER}`].join('\n') + '\n', encoding: 'utf8' });
417
+ return write.status === 0;
418
+ }
419
+ function uninstallLinux() {
420
+ let ok = false;
421
+ const file = systemdUnitPath();
422
+ if (fs.existsSync(file)) {
423
+ (0, child_process_1.spawnSync)('systemctl', ['--user', 'disable', '--now', SYSTEMD_UNIT], { stdio: 'inherit' });
424
+ try {
425
+ fs.unlinkSync(file);
426
+ ok = true;
427
+ }
428
+ catch { /* ignore */ }
429
+ (0, child_process_1.spawnSync)('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' });
430
+ }
431
+ const list = (0, child_process_1.spawnSync)('crontab', ['-l'], { encoding: 'utf8' });
432
+ if (list.status === 0) {
433
+ const existing = (list.stdout || '').split('\n').filter(l => l && !l.includes(CRON_MARKER));
434
+ const write = (0, child_process_1.spawnSync)('crontab', ['-'], { input: existing.join('\n') + '\n', encoding: 'utf8' });
435
+ ok = ok || write.status === 0;
436
+ }
437
+ return ok;
438
+ }
439
+ function statusCommand() {
440
+ console.log(`\n${COLOR.bold}${COLOR.cyan}FullCourtDefense daemon status${COLOR.reset}\n`);
441
+ let running = false;
442
+ try {
443
+ const pid = Number(fs.readFileSync(pidFile(), 'utf8').trim());
444
+ running = Number.isFinite(pid) && pid > 0 && isPidAlive(pid);
445
+ console.log(running
446
+ ? `${COLOR.green}Running${COLOR.reset} (pid ${pid})`
447
+ : `${COLOR.yellow}Not running${COLOR.reset} (stale pid file: ${pidFile()})`);
448
+ }
449
+ catch {
450
+ console.log(`${COLOR.yellow}Not running${COLOR.reset} (no pid file)`);
451
+ }
452
+ console.log(`${COLOR.gray}Log:${COLOR.reset} ${logFile()}`);
453
+ console.log(`${COLOR.gray}Autostart:${COLOR.reset}`);
454
+ if (process.platform === 'win32') {
455
+ (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', TASK_NAME], { stdio: 'inherit' });
456
+ }
457
+ else if (process.platform === 'darwin') {
458
+ console.log(fs.existsSync(launchdPlistPath()) ? ` launchd agent installed (${launchdPlistPath()})` : ' not installed');
459
+ }
460
+ else {
461
+ console.log(fs.existsSync(systemdUnitPath()) ? ` systemd user unit installed (${systemdUnitPath()})` : ' not installed (check crontab -l for @reboot entry)');
462
+ }
463
+ }
464
+ async function daemonCommand(args, config) {
465
+ if (args.status === 'true') {
466
+ statusCommand();
467
+ return;
468
+ }
469
+ if (args.uninstall === 'true') {
470
+ const ok = process.platform === 'win32' ? uninstallWindows()
471
+ : process.platform === 'darwin' ? uninstallMacos()
472
+ : uninstallLinux();
473
+ console.log(ok
474
+ ? `${COLOR.green}Removed the FullCourtDefense daemon autostart.${COLOR.reset}`
475
+ : `${COLOR.yellow}No daemon autostart found (or removal failed).${COLOR.reset}`);
476
+ return;
477
+ }
478
+ if (args.install === 'true') {
479
+ console.log(`\n${COLOR.bold}${COLOR.cyan}FullCourtDefense — install resident daemon${COLOR.reset}`);
480
+ console.log(`${COLOR.gray}Watches MCP/hook configs and re-protects instantly on drift; heartbeats and flushes telemetry while the machine is idle.${COLOR.reset}\n`);
481
+ const ok = process.platform === 'win32' ? installWindows()
482
+ : process.platform === 'darwin' ? installMacos()
483
+ : installLinux();
484
+ if (ok) {
485
+ console.log(`${COLOR.green}${COLOR.bold}Daemon autostart installed.${COLOR.reset}`);
486
+ console.log(`${COLOR.gray}Trigger:${COLOR.reset} ${process.platform === 'win32' ? `at logon (Scheduled Task "${TASK_NAME}")` : process.platform === 'darwin' ? 'launchd (RunAtLoad + KeepAlive)' : 'systemd user unit (or @reboot cron)'}`);
487
+ console.log(`${COLOR.gray}Start now:${COLOR.reset} fullcourtdefense daemon`);
488
+ console.log(`${COLOR.gray}Status:${COLOR.reset} fullcourtdefense daemon --status true`);
489
+ console.log(`${COLOR.gray}Remove:${COLOR.reset} fullcourtdefense daemon --uninstall true`);
490
+ console.log(`${COLOR.gray}Note:${COLOR.reset} the daemon supersedes the scheduled auto-protect task — you can remove it with: fullcourtdefense auto-protect --uninstall true`);
491
+ }
492
+ else {
493
+ console.log(`${COLOR.red}Could not register the daemon autostart.${COLOR.reset} On Windows, retry from an elevated terminal if the task creation was denied.`);
494
+ }
495
+ return;
496
+ }
497
+ await runDaemon(args, config);
498
+ }
@@ -867,19 +867,6 @@ async function discoverCommand(args, config) {
867
867
  })
868
868
  : undefined;
869
869
  const host = buildHostMetadata(args.userEmail, deep && found.some(s => s.probeMode === 'deep') ? 'deep' : 'config');
870
- if (args.json === 'true') {
871
- console.log(JSON.stringify({
872
- host,
873
- surfaces: [...surfaces],
874
- scannedFiles: scanned.map(s => `${s.source} → ${s.path}`),
875
- clientCoverage,
876
- servers: found,
877
- secrets,
878
- agentFiles,
879
- posture,
880
- }, null, 2));
881
- return;
882
- }
883
870
  const home = os.homedir();
884
871
  const uploadExtras = {
885
872
  secrets,
@@ -919,6 +906,20 @@ async function discoverCommand(args, config) {
919
906
  }
920
907
  await upload(found, host, clientCoverage, creds.apiUrl, { apiKey: creds.apiKey, shieldId: creds.shieldId, shieldKey: creds.shieldKey, preferApiKey: !!args.apiKey }, args.connectorName, uploadExtras);
921
908
  }
909
+ if (args.json === 'true') {
910
+ await maybeUpload();
911
+ console.log(JSON.stringify({
912
+ host,
913
+ surfaces: [...surfaces],
914
+ scannedFiles: scanned.map(s => `${s.source} → ${s.path}`),
915
+ clientCoverage,
916
+ servers: found,
917
+ secrets,
918
+ agentFiles,
919
+ posture,
920
+ }, null, 2));
921
+ return;
922
+ }
922
923
  if (silent) {
923
924
  if (uploadRequested) {
924
925
  const creds = (0, config_1.resolveCliCredentials)(config, { apiKey: args.apiKey, apiUrl: args.apiUrl });
@@ -7,4 +7,6 @@ export interface ScheduleArgs {
7
7
  trigger?: 'daily' | 'logon';
8
8
  }
9
9
  export declare function installDailyDiscoverSchedule(args?: ScheduleArgs): void;
10
+ /** Whether the recurring discovery run is registered on this machine (any trigger). */
11
+ export declare function isDiscoverScheduleInstalled(): boolean;
10
12
  export declare function uninstallDailyDiscoverSchedule(): void;
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.installDailyDiscoverSchedule = installDailyDiscoverSchedule;
37
+ exports.isDiscoverScheduleInstalled = isDiscoverScheduleInstalled;
37
38
  exports.uninstallDailyDiscoverSchedule = uninstallDailyDiscoverSchedule;
38
39
  const child_process_1 = require("child_process");
39
40
  const fs = __importStar(require("fs"));
@@ -136,6 +137,27 @@ function installDailyDiscoverSchedule(args = {}) {
136
137
  else
137
138
  installLinuxCron(hour, args, trigger);
138
139
  }
140
+ /** Whether the recurring discovery run is registered on this machine (any trigger). */
141
+ function isDiscoverScheduleInstalled() {
142
+ if (process.platform === 'win32') {
143
+ try {
144
+ (0, child_process_1.execSync)(`schtasks /Query /TN "${TASK_NAME}"`, { stdio: 'ignore' });
145
+ return true;
146
+ }
147
+ catch { /* task not found */ }
148
+ return fs.existsSync(windowsStartupLauncherPath());
149
+ }
150
+ if (process.platform === 'darwin') {
151
+ return fs.existsSync(path.join(os.homedir(), 'Library', 'LaunchAgents', 'ai.fullcourtdefense.discover.plist'));
152
+ }
153
+ try {
154
+ const crontab = (0, child_process_1.execSync)('crontab -l', { encoding: 'utf8' });
155
+ return crontab.includes('fullcourtdefense-discover') || crontab.includes('discover --upload --deep --silent');
156
+ }
157
+ catch {
158
+ return false;
159
+ }
160
+ }
139
161
  function uninstallDailyDiscoverSchedule() {
140
162
  if (process.platform === 'win32') {
141
163
  try {