fullcourtdefense-cli 1.14.17 → 1.15.1

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