facult 1.0.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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +383 -0
  3. package/bin/facult.cjs +302 -0
  4. package/package.json +78 -0
  5. package/src/adapters/claude-cli.ts +18 -0
  6. package/src/adapters/claude-desktop.ts +15 -0
  7. package/src/adapters/clawdbot.ts +18 -0
  8. package/src/adapters/codex.ts +19 -0
  9. package/src/adapters/cursor.ts +18 -0
  10. package/src/adapters/index.ts +69 -0
  11. package/src/adapters/mcp.ts +270 -0
  12. package/src/adapters/reference.ts +9 -0
  13. package/src/adapters/skills.ts +47 -0
  14. package/src/adapters/types.ts +42 -0
  15. package/src/adapters/version.ts +18 -0
  16. package/src/audit/agent.ts +1071 -0
  17. package/src/audit/index.ts +74 -0
  18. package/src/audit/static.ts +1130 -0
  19. package/src/audit/tui.ts +704 -0
  20. package/src/audit/types.ts +68 -0
  21. package/src/audit/update-index.ts +115 -0
  22. package/src/conflicts.ts +135 -0
  23. package/src/consolidate-conflict-action.ts +57 -0
  24. package/src/consolidate.ts +1637 -0
  25. package/src/enable-disable.ts +349 -0
  26. package/src/index-builder.ts +562 -0
  27. package/src/index.ts +589 -0
  28. package/src/manage.ts +894 -0
  29. package/src/migrate.ts +272 -0
  30. package/src/paths.ts +238 -0
  31. package/src/quarantine.ts +217 -0
  32. package/src/query.ts +186 -0
  33. package/src/remote-manifest-integrity.ts +367 -0
  34. package/src/remote-providers.ts +905 -0
  35. package/src/remote-source-policy.ts +237 -0
  36. package/src/remote-sources.ts +162 -0
  37. package/src/remote-types.ts +136 -0
  38. package/src/remote.ts +1970 -0
  39. package/src/scan.ts +2427 -0
  40. package/src/schema.ts +39 -0
  41. package/src/self-update.ts +408 -0
  42. package/src/snippets-cli.ts +293 -0
  43. package/src/snippets.ts +706 -0
  44. package/src/source-trust.ts +203 -0
  45. package/src/trust-list.ts +232 -0
  46. package/src/trust.ts +170 -0
  47. package/src/tui.ts +118 -0
  48. package/src/util/codex-toml.ts +126 -0
  49. package/src/util/json.ts +32 -0
  50. package/src/util/skills.ts +55 -0
@@ -0,0 +1,704 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import {
4
+ confirm,
5
+ intro,
6
+ isCancel,
7
+ multiselect,
8
+ note,
9
+ outro,
10
+ select,
11
+ spinner,
12
+ text,
13
+ } from "@clack/prompts";
14
+ import { buildIndex } from "../index-builder";
15
+ import { facultRootDir, readFacultConfig } from "../paths";
16
+ import { type QuarantineMode, quarantineItems } from "../quarantine";
17
+ import { type AgentAuditReport, runAgentAudit } from "./agent";
18
+ import { runStaticAudit } from "./static";
19
+ import {
20
+ type AuditFinding,
21
+ type AuditItemResult,
22
+ SEVERITY_ORDER,
23
+ type Severity,
24
+ type StaticAuditReport,
25
+ } from "./types";
26
+
27
+ function parseFromFlags(argv: string[]): string[] {
28
+ const from: string[] = [];
29
+ for (let i = 0; i < argv.length; i += 1) {
30
+ const arg = argv[i];
31
+ if (!arg) {
32
+ continue;
33
+ }
34
+ if (arg === "--from") {
35
+ const next = argv[i + 1];
36
+ if (next) {
37
+ from.push(next);
38
+ }
39
+ i += 1;
40
+ continue;
41
+ }
42
+ if (arg.startsWith("--from=")) {
43
+ const value = arg.slice("--from=".length);
44
+ if (value) {
45
+ from.push(value);
46
+ }
47
+ }
48
+ }
49
+ return from;
50
+ }
51
+
52
+ function maxSeverity(findings: { severity: Severity }[]): Severity | null {
53
+ if (!findings.length) {
54
+ return null;
55
+ }
56
+ let best: Severity = "low";
57
+ for (const f of findings) {
58
+ if (SEVERITY_ORDER[f.severity] > SEVERITY_ORDER[best]) {
59
+ best = f.severity;
60
+ }
61
+ }
62
+ return best;
63
+ }
64
+
65
+ function labelForResult(r: AuditItemResult): string {
66
+ const sev = maxSeverity(r.findings);
67
+ const sevLabel = sev ? sev.toUpperCase() : "OK";
68
+ const n = r.findings.length;
69
+ const status = n === 0 ? "OK" : r.passed ? "WARN" : "FAIL";
70
+ const kind =
71
+ r.type === "mcp"
72
+ ? `mcp:${r.item}`
73
+ : r.type === "asset"
74
+ ? `asset:${r.item}`
75
+ : r.type === "mcp-config"
76
+ ? `mcp-config:${r.item}`
77
+ : r.item;
78
+ return `[${status} ${sevLabel}] ${kind} (${n} finding${n === 1 ? "" : "s"})`;
79
+ }
80
+
81
+ function hintForResult(r: AuditItemResult): string {
82
+ return r.path;
83
+ }
84
+
85
+ function summarizeReportStatic(report: StaticAuditReport): string {
86
+ const s = report.summary.bySeverity;
87
+ return `flagged=${report.summary.flaggedItems} findings=${report.summary.totalFindings} (critical=${s.critical}, high=${s.high}, medium=${s.medium}, low=${s.low})`;
88
+ }
89
+
90
+ function summarizeReportAgent(report: AgentAuditReport): string {
91
+ const s = report.summary.bySeverity;
92
+ const model = report.agent.model ? ` (${report.agent.model})` : "";
93
+ return `agent=${report.agent.tool}${model} flagged=${report.summary.flaggedItems} findings=${report.summary.totalFindings} (critical=${s.critical}, high=${s.high}, medium=${s.medium}, low=${s.low})`;
94
+ }
95
+
96
+ function uniqueByKey<T>(items: T[], key: (v: T) => string): T[] {
97
+ const seen = new Set<string>();
98
+ const out: T[] = [];
99
+ for (const it of items) {
100
+ const k = key(it);
101
+ if (seen.has(k)) {
102
+ continue;
103
+ }
104
+ seen.add(k);
105
+ out.push(it);
106
+ }
107
+ return out;
108
+ }
109
+
110
+ function keyForResult(r: AuditItemResult): string {
111
+ return `${r.type}\0${r.item}\0${r.path}`;
112
+ }
113
+
114
+ function prefixRuleId(
115
+ f: AuditFinding,
116
+ prefix: "static" | "agent"
117
+ ): AuditFinding {
118
+ const want = `${prefix}:`;
119
+ if (f.ruleId.startsWith(want)) {
120
+ return f;
121
+ }
122
+ return { ...f, ruleId: `${want}${f.ruleId}` };
123
+ }
124
+
125
+ function mergeStaticAndAgentResults(args: {
126
+ static: AuditItemResult[];
127
+ agent: AuditItemResult[];
128
+ }): AuditItemResult[] {
129
+ const byKey = new Map<
130
+ string,
131
+ { static?: AuditItemResult; agent?: AuditItemResult }
132
+ >();
133
+
134
+ for (const r of args.static) {
135
+ const k = keyForResult(r);
136
+ const prev = byKey.get(k) ?? {};
137
+ byKey.set(k, { ...prev, static: r });
138
+ }
139
+ for (const r of args.agent) {
140
+ const k = keyForResult(r);
141
+ const prev = byKey.get(k) ?? {};
142
+ byKey.set(k, { ...prev, agent: r });
143
+ }
144
+
145
+ const out: AuditItemResult[] = [];
146
+ for (const k of [...byKey.keys()].sort()) {
147
+ const ent = byKey.get(k);
148
+ if (!ent) {
149
+ continue;
150
+ }
151
+ const a = ent.agent;
152
+ const s = ent.static;
153
+ if (a && s) {
154
+ out.push({
155
+ ...a,
156
+ // If either side failed, the combined view should be a failure.
157
+ passed: a.passed && s.passed,
158
+ findings: [
159
+ ...a.findings.map((f) => prefixRuleId(f, "agent")),
160
+ ...s.findings.map((f) => prefixRuleId(f, "static")),
161
+ ],
162
+ });
163
+ continue;
164
+ }
165
+ out.push(a ?? s!);
166
+ }
167
+ return out;
168
+ }
169
+
170
+ function viewFindingDetails(r: AuditItemResult) {
171
+ const lines: string[] = [];
172
+ lines.push(`Path: ${r.path}`);
173
+ lines.push(`Type: ${r.type}`);
174
+ if (r.sourceId) {
175
+ lines.push(`Source: ${r.sourceId}`);
176
+ }
177
+ if (r.notes) {
178
+ lines.push("");
179
+ lines.push("Notes:");
180
+ lines.push(r.notes);
181
+ }
182
+ lines.push("");
183
+ if (r.findings.length) {
184
+ for (const f of r.findings) {
185
+ const loc = f.location ? ` @ ${f.location}` : "";
186
+ lines.push(`[${f.severity.toUpperCase()}] ${f.ruleId}${loc}`);
187
+ lines.push(` ${f.message}`);
188
+ if (f.evidence) {
189
+ lines.push(` evidence: ${f.evidence}`);
190
+ }
191
+ lines.push("");
192
+ }
193
+ } else {
194
+ lines.push("No findings.");
195
+ }
196
+ note(lines.join("\n"), "Findings");
197
+ }
198
+
199
+ function printHelp() {
200
+ console.log(`facult audit tui — interactive security audit + quarantine
201
+
202
+ Usage:
203
+ facult audit tui
204
+ facult audit tui --from <path> [--from <path> ...]
205
+ facult audit tui --no-config-from
206
+
207
+ Notes:
208
+ - This is an interactive wizard (TTY required).
209
+ - Quarantine will move/copy files into ~/.facult/quarantine/<timestamp>/ and write a manifest.json.
210
+ - For non-interactive runs, use: facult audit --non-interactive ...
211
+ `);
212
+ }
213
+
214
+ export async function auditTuiCommand(argv: string[]) {
215
+ if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
216
+ printHelp();
217
+ return;
218
+ }
219
+
220
+ const noConfigFrom = argv.includes("--no-config-from");
221
+ const parsedFrom = parseFromFlags(argv);
222
+
223
+ const cfg = noConfigFrom ? null : readFacultConfig();
224
+ const cfgRoots = cfg?.scanFrom ?? [];
225
+
226
+ intro("facult audit");
227
+
228
+ if (cfgRoots.length) {
229
+ note(
230
+ `Configured scanFrom roots:\n- ${cfgRoots.join("\n- ")}`,
231
+ "~/.facult/config.json"
232
+ );
233
+ }
234
+
235
+ const availableAgentTools = [
236
+ ...(Bun.which("claude") ? ["claude" as const] : []),
237
+ ...(Bun.which("codex") ? ["codex" as const] : []),
238
+ ];
239
+
240
+ const mode = await select({
241
+ message: "What should we run?",
242
+ options: [
243
+ {
244
+ value: "static",
245
+ label: "Static audit (fast)",
246
+ hint: "regex + structured checks",
247
+ },
248
+ ...(availableAgentTools.length
249
+ ? [
250
+ {
251
+ value: "both",
252
+ label: "Static + agent audit",
253
+ hint: "best coverage",
254
+ },
255
+ {
256
+ value: "agent",
257
+ label: "Agent audit (slower)",
258
+ hint: "LLM review",
259
+ },
260
+ ]
261
+ : []),
262
+ ],
263
+ });
264
+ if (isCancel(mode)) {
265
+ outro("Cancelled.");
266
+ return;
267
+ }
268
+
269
+ const scope = await select({
270
+ message: "Audit scope",
271
+ options: [
272
+ {
273
+ value: "defaults",
274
+ label: "Defaults only",
275
+ hint: "tool defaults; fastest",
276
+ },
277
+ {
278
+ value: "home",
279
+ label: "Home directory (~)",
280
+ hint: "broad project discovery",
281
+ },
282
+ { value: "custom", label: "Custom roots", hint: "comma-separated list" },
283
+ ...(cfgRoots.length
284
+ ? [
285
+ {
286
+ value: "config",
287
+ label: "Use configured scanFrom",
288
+ hint: "from ~/.facult/config.json",
289
+ },
290
+ ]
291
+ : []),
292
+ ],
293
+ });
294
+ if (isCancel(scope)) {
295
+ outro("Cancelled.");
296
+ return;
297
+ }
298
+
299
+ let includeConfigFrom = !noConfigFrom;
300
+ let from: string[] = [];
301
+ if (scope === "defaults") {
302
+ includeConfigFrom = false;
303
+ from = [];
304
+ } else if (scope === "home") {
305
+ from = parsedFrom.length ? parsedFrom : ["~"];
306
+ } else if (scope === "config") {
307
+ from = parsedFrom;
308
+ } else {
309
+ const raw = await text({
310
+ message: "Roots to scan (comma-separated)",
311
+ placeholder: parsedFrom.length ? parsedFrom.join(", ") : "~, ~/dev",
312
+ });
313
+ if (isCancel(raw)) {
314
+ outro("Cancelled.");
315
+ return;
316
+ }
317
+ from = String(raw)
318
+ .split(",")
319
+ .map((s) => s.trim())
320
+ .filter(Boolean);
321
+ }
322
+
323
+ const includeGitHooks = await confirm({
324
+ message: "Include git hooks (husky + .git/hooks)?",
325
+ initialValue: false,
326
+ });
327
+ if (isCancel(includeGitHooks)) {
328
+ outro("Cancelled.");
329
+ return;
330
+ }
331
+
332
+ let minSeverity: Severity = "high";
333
+ if (mode === "static" || mode === "both") {
334
+ const sev = await select({
335
+ message: "Minimum severity to show",
336
+ options: [
337
+ { value: "critical", label: "critical", hint: "only critical" },
338
+ { value: "high", label: "high", hint: "high + critical" },
339
+ { value: "medium", label: "medium", hint: "medium + above" },
340
+ { value: "low", label: "low", hint: "everything" },
341
+ ],
342
+ });
343
+ if (isCancel(sev)) {
344
+ outro("Cancelled.");
345
+ return;
346
+ }
347
+ minSeverity = sev as Severity;
348
+ }
349
+
350
+ let agentTool: "claude" | "codex" | null = null;
351
+ let maxItems = 50;
352
+ if (mode === "agent" || mode === "both") {
353
+ if (availableAgentTools.length === 0) {
354
+ note('No agent tool found. Install "claude" or "codex".', "Agent audit");
355
+ } else if (availableAgentTools.length === 1) {
356
+ agentTool = availableAgentTools[0] ?? null;
357
+ } else {
358
+ const chosen = await select({
359
+ message: "Agent tool",
360
+ options: availableAgentTools.map((t) => ({
361
+ value: t,
362
+ label: t,
363
+ })),
364
+ });
365
+ if (isCancel(chosen)) {
366
+ outro("Cancelled.");
367
+ return;
368
+ }
369
+ agentTool = chosen as "claude" | "codex";
370
+ }
371
+
372
+ const rawMax = await text({
373
+ message: "Max items to send to the agent",
374
+ placeholder: "50 (or 'all')",
375
+ defaultValue: String(maxItems),
376
+ });
377
+ if (isCancel(rawMax)) {
378
+ outro("Cancelled.");
379
+ return;
380
+ }
381
+ const raw = String(rawMax).trim().toLowerCase();
382
+ if (raw === "all" || raw === "0") {
383
+ maxItems = 0;
384
+ } else {
385
+ const n = Number(raw);
386
+ if (Number.isFinite(n) && n > 0) {
387
+ maxItems = Math.floor(n);
388
+ }
389
+ }
390
+ }
391
+
392
+ const reports: { static?: StaticAuditReport; agent?: AgentAuditReport } = {};
393
+
394
+ if (mode === "static" || mode === "both") {
395
+ const sp = spinner();
396
+ sp.start("Running static audit...");
397
+ try {
398
+ reports.static = await runStaticAudit({
399
+ argv: [],
400
+ homeDir: homedir(),
401
+ minSeverity,
402
+ includeConfigFrom,
403
+ includeGitHooks: includeGitHooks === true,
404
+ from,
405
+ });
406
+ sp.stop("Static audit complete.");
407
+ } catch (err) {
408
+ sp.stop("Static audit failed.");
409
+ outro(err instanceof Error ? err.message : String(err));
410
+ process.exitCode = 1;
411
+ return;
412
+ }
413
+ }
414
+
415
+ if ((mode === "agent" || mode === "both") && agentTool) {
416
+ const sp = spinner();
417
+ sp.start("Running agent audit...");
418
+ try {
419
+ reports.agent = await runAgentAudit({
420
+ argv: [],
421
+ homeDir: homedir(),
422
+ cwd: process.cwd(),
423
+ includeConfigFrom,
424
+ includeGitHooks: includeGitHooks === true,
425
+ from,
426
+ withTool: agentTool,
427
+ maxItems,
428
+ onProgress: (p) => {
429
+ if (p.phase !== "start") {
430
+ return;
431
+ }
432
+ const name = `${p.type}:${p.item}`;
433
+ const short =
434
+ name.length > 60 ? `${name.slice(0, 57).trimEnd()}...` : name;
435
+ sp.message(`Agent audit (${p.current}/${p.total}) ${short}`);
436
+ },
437
+ });
438
+ sp.stop("Agent audit complete.");
439
+ } catch (err) {
440
+ sp.stop("Agent audit failed.");
441
+ outro(err instanceof Error ? err.message : String(err));
442
+ process.exitCode = 1;
443
+ return;
444
+ }
445
+ }
446
+
447
+ const summaries: string[] = [];
448
+ if (reports.static) {
449
+ summaries.push(`Static: ${summarizeReportStatic(reports.static)}`);
450
+ summaries.push(
451
+ `Wrote ${join(homedir(), ".facult", "audit", "static-latest.json")}`
452
+ );
453
+ }
454
+ if (reports.agent) {
455
+ summaries.push(`Agent: ${summarizeReportAgent(reports.agent)}`);
456
+ summaries.push(
457
+ `Wrote ${join(homedir(), ".facult", "audit", "agent-latest.json")}`
458
+ );
459
+ }
460
+ if (summaries.length) {
461
+ note(summaries.join("\n"), "Summary");
462
+ }
463
+
464
+ const combined = uniqueByKey(
465
+ mergeStaticAndAgentResults({
466
+ static: reports.static?.results ?? [],
467
+ agent: reports.agent?.results ?? [],
468
+ }),
469
+ keyForResult
470
+ );
471
+
472
+ let review: "static" | "agent" | "combined" = reports.agent
473
+ ? "agent"
474
+ : "static";
475
+ if (reports.agent && reports.static) {
476
+ const chosen = await select({
477
+ message: "Which results do you want to review?",
478
+ options: [
479
+ { value: "agent", label: "Agent findings", hint: "LLM-scored items" },
480
+ {
481
+ value: "static",
482
+ label: "Static findings",
483
+ hint: "regex + structured checks",
484
+ },
485
+ { value: "combined", label: "Combined", hint: "agent + static merged" },
486
+ ],
487
+ initialValue: "agent",
488
+ });
489
+ if (!isCancel(chosen)) {
490
+ review = chosen as "static" | "agent" | "combined";
491
+ }
492
+ }
493
+
494
+ const results =
495
+ review === "combined"
496
+ ? combined
497
+ : review === "agent"
498
+ ? (reports.agent?.results ?? [])
499
+ : (reports.static?.results ?? []);
500
+
501
+ const withFindings = results
502
+ .filter((r) => r.findings.length > 0)
503
+ .sort((a, b) => {
504
+ const sa = SEVERITY_ORDER[maxSeverity(a.findings) ?? "low"];
505
+ const sb = SEVERITY_ORDER[maxSeverity(b.findings) ?? "low"];
506
+ return (
507
+ sb - sa || a.type.localeCompare(b.type) || a.item.localeCompare(b.item)
508
+ );
509
+ });
510
+
511
+ if (withFindings.length === 0) {
512
+ outro("No findings.");
513
+ return;
514
+ }
515
+
516
+ const failCount = withFindings.filter((r) => !r.passed).length;
517
+ const warnCount = withFindings.length - failCount;
518
+ note(`fail=${failCount} warn=${warnCount}`, "Review");
519
+
520
+ while (true) {
521
+ const action = await select({
522
+ message: "Next action",
523
+ options: [
524
+ {
525
+ value: "quarantine",
526
+ label: "Quarantine items",
527
+ hint: "move/copy to ~/.facult/quarantine",
528
+ },
529
+ { value: "view", label: "View item details", hint: "inspect findings" },
530
+ { value: "exit", label: "Exit", hint: "leave files unchanged" },
531
+ ],
532
+ });
533
+ if (isCancel(action) || action === "exit") {
534
+ outro("Done.");
535
+ return;
536
+ }
537
+
538
+ if (action === "view") {
539
+ const viewList = withFindings.slice(0, 200);
540
+ const chosen = await select({
541
+ message: "Pick an item to view",
542
+ options: viewList.map((r, idx) => ({
543
+ value: String(idx),
544
+ label: labelForResult(r),
545
+ hint: hintForResult(r),
546
+ })),
547
+ });
548
+ if (isCancel(chosen)) {
549
+ continue;
550
+ }
551
+ const idx = Number(String(chosen));
552
+ const r = viewList[idx];
553
+ if (r) {
554
+ await viewFindingDetails(r);
555
+ }
556
+ continue;
557
+ }
558
+
559
+ // quarantine
560
+ const quarantineList = withFindings.slice(0, 500);
561
+ const picked = await multiselect({
562
+ message: "Select items to quarantine",
563
+ options: quarantineList.map((r, idx) => ({
564
+ value: String(idx),
565
+ label: labelForResult(r),
566
+ hint: hintForResult(r),
567
+ })),
568
+ initialValues: quarantineList
569
+ .map((r, idx) => (r.passed ? null : String(idx)))
570
+ .filter(Boolean) as string[],
571
+ required: false,
572
+ });
573
+ if (isCancel(picked)) {
574
+ continue;
575
+ }
576
+
577
+ const indices = (picked as string[]).map((v) => Number(v));
578
+ const selected = indices
579
+ .map((i) => quarantineList[i])
580
+ .filter(Boolean) as AuditItemResult[];
581
+ if (selected.length === 0) {
582
+ note("No items selected.", "Quarantine");
583
+ continue;
584
+ }
585
+
586
+ const modeChoice = await select({
587
+ message: "Quarantine mode",
588
+ options: [
589
+ {
590
+ value: "move",
591
+ label: "Move (quarantine)",
592
+ hint: "removes from original location",
593
+ },
594
+ { value: "copy", label: "Copy (snapshot)", hint: "non-destructive" },
595
+ ],
596
+ });
597
+ if (isCancel(modeChoice)) {
598
+ continue;
599
+ }
600
+
601
+ const qMode = modeChoice as QuarantineMode;
602
+
603
+ // Deduplicate by path to avoid double-moving shared config files.
604
+ const uniquePaths = uniqueByKey(selected, (r) => r.path);
605
+ const items = uniquePaths.map((r) => ({
606
+ path: r.path,
607
+ kind: r.type,
608
+ item:
609
+ r.type === "mcp"
610
+ ? `mcp:${r.item}`
611
+ : r.type === "asset"
612
+ ? `asset:${r.item}`
613
+ : r.type === "mcp-config"
614
+ ? `mcp-config:${r.item}`
615
+ : r.item,
616
+ }));
617
+
618
+ const ts = new Date().toISOString();
619
+ const stamp = ts.replace(/[:.]/g, "-");
620
+ const destDir = join(homedir(), ".facult", "quarantine", stamp);
621
+
622
+ const plan = await quarantineItems({
623
+ items,
624
+ mode: qMode,
625
+ dryRun: true,
626
+ timestamp: ts,
627
+ destDir,
628
+ });
629
+
630
+ const preview = plan.manifest.entries
631
+ .slice(0, 12)
632
+ .map(
633
+ (e) =>
634
+ `${qMode.toUpperCase()} ${e.originalPath} -> ${e.quarantinedPath}`
635
+ )
636
+ .join("\n");
637
+ note(
638
+ `${preview}${plan.manifest.entries.length > 12 ? `\n... (${plan.manifest.entries.length - 12} more)` : ""}`,
639
+ "Planned quarantine"
640
+ );
641
+
642
+ const ok = await confirm({
643
+ message: "Proceed with quarantine?",
644
+ initialValue: false,
645
+ });
646
+ if (isCancel(ok) || ok === false) {
647
+ continue;
648
+ }
649
+
650
+ const sp = spinner();
651
+ sp.start("Quarantining...");
652
+ try {
653
+ const res = await quarantineItems({
654
+ items,
655
+ mode: qMode,
656
+ timestamp: ts,
657
+ destDir,
658
+ });
659
+ sp.stop("Quarantine complete.");
660
+ note(
661
+ `Quarantine directory:\n${res.quarantineDir}\n\nManifest:\n${join(
662
+ res.quarantineDir,
663
+ "manifest.json"
664
+ )}`,
665
+ "Quarantine"
666
+ );
667
+
668
+ // If we quarantined canonical-store paths, offer to rebuild the index so list/show stay accurate.
669
+ if (qMode === "move") {
670
+ const root = facultRootDir();
671
+ const touchedCanonical = res.manifest.entries.some(
672
+ (e) =>
673
+ e.originalPath === root || e.originalPath.startsWith(`${root}/`)
674
+ );
675
+ if (touchedCanonical) {
676
+ const rebuild = await confirm({
677
+ message: "Rebuild canonical index.json now?",
678
+ initialValue: true,
679
+ });
680
+ if (!isCancel(rebuild) && rebuild === true) {
681
+ const isp = spinner();
682
+ isp.start("Rebuilding index...");
683
+ try {
684
+ const { outputPath } = await buildIndex({ force: false });
685
+ isp.stop("Index rebuilt.");
686
+ note(`Index written to:\n${outputPath}`, "Index");
687
+ } catch (e: unknown) {
688
+ isp.stop("Index rebuild failed.");
689
+ note(e instanceof Error ? e.message : String(e), "Index");
690
+ }
691
+ }
692
+ }
693
+ }
694
+
695
+ outro("Done.");
696
+ return;
697
+ } catch (err) {
698
+ sp.stop("Quarantine failed.");
699
+ outro(err instanceof Error ? err.message : String(err));
700
+ process.exitCode = 1;
701
+ return;
702
+ }
703
+ }
704
+ }