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
package/src/migrate.ts ADDED
@@ -0,0 +1,272 @@
1
+ import {
2
+ copyFile,
3
+ lstat,
4
+ mkdir,
5
+ readdir,
6
+ readlink,
7
+ rename,
8
+ stat,
9
+ symlink,
10
+ utimes,
11
+ } from "node:fs/promises";
12
+ import { homedir } from "node:os";
13
+ import { basename, dirname, join, resolve } from "node:path";
14
+
15
+ function printHelp() {
16
+ console.log(`facult migrate — migrate a legacy canonical store to the facult path
17
+
18
+ Usage:
19
+ facult migrate [--from <path>] [--dry-run] [--move] [--write-config]
20
+
21
+ What it does:
22
+ - Auto-detects a legacy store under ~/agents/ (or use --from)
23
+ - Copies it to ~/agents/.facult (default, safe)
24
+ - Or moves it with --move (destructive; removes the legacy directory)
25
+
26
+ Options:
27
+ --from Path to a legacy store root directory
28
+ --dry-run Print what would happen without changing anything
29
+ --move Rename legacy dir to the new location instead of copying
30
+ --write-config Write ~/.facult/config.json to pin rootDir to ~/agents/.facult
31
+ `);
32
+ }
33
+
34
+ async function dirExists(p: string): Promise<boolean> {
35
+ try {
36
+ return (await stat(p)).isDirectory();
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ async function fileExists(p: string): Promise<boolean> {
43
+ try {
44
+ return (await stat(p)).isFile();
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ async function looksLikeStore(root: string): Promise<boolean> {
51
+ if (!(await dirExists(root))) {
52
+ return false;
53
+ }
54
+ // Heuristic: treat as a store if it contains something we'd create.
55
+ if (await fileExists(join(root, "index.json"))) {
56
+ return true;
57
+ }
58
+ for (const d of ["skills", "mcp", "snippets"]) {
59
+ if (await dirExists(join(root, d))) {
60
+ return true;
61
+ }
62
+ }
63
+ return false;
64
+ }
65
+
66
+ function expandHomePath(p: string, home: string): string {
67
+ if (p === "~") {
68
+ return home;
69
+ }
70
+ if (p.startsWith("~/")) {
71
+ return join(home, p.slice(2));
72
+ }
73
+ return p;
74
+ }
75
+
76
+ function resolvePath(p: string, home: string): string {
77
+ const expanded = expandHomePath(p, home);
78
+ return expanded.startsWith("/") ? expanded : resolve(expanded);
79
+ }
80
+
81
+ function parseFromFlag(argv: string[]): string | null {
82
+ for (let i = 0; i < argv.length; i += 1) {
83
+ const arg = argv[i];
84
+ if (!arg) {
85
+ continue;
86
+ }
87
+ if (arg === "--from") {
88
+ const next = argv[i + 1];
89
+ if (!next) {
90
+ throw new Error("--from requires a path");
91
+ }
92
+ return next;
93
+ }
94
+ if (arg.startsWith("--from=")) {
95
+ const raw = arg.slice("--from=".length);
96
+ if (!raw) {
97
+ throw new Error("--from requires a path");
98
+ }
99
+ return raw;
100
+ }
101
+ }
102
+ return null;
103
+ }
104
+
105
+ async function findLegacyStoreUnderAgents(home: string): Promise<string[]> {
106
+ const agentsDir = join(home, "agents");
107
+ let entries: any[];
108
+ try {
109
+ entries = await readdir(agentsDir, { withFileTypes: true });
110
+ } catch {
111
+ return [];
112
+ }
113
+
114
+ const out: string[] = [];
115
+ for (const ent of entries) {
116
+ if (!ent?.isDirectory?.()) {
117
+ continue;
118
+ }
119
+ const name = String(ent.name ?? "");
120
+ if (!name || name === ".facult") {
121
+ continue;
122
+ }
123
+ const abs = join(agentsDir, name);
124
+ if (await looksLikeStore(abs)) {
125
+ out.push(abs);
126
+ }
127
+ }
128
+ return out.sort();
129
+ }
130
+
131
+ async function copyTree(
132
+ src: string,
133
+ dst: string,
134
+ opts: { dryRun: boolean }
135
+ ): Promise<void> {
136
+ const st = await lstat(src);
137
+
138
+ if (st.isSymbolicLink()) {
139
+ const target = await readlink(src);
140
+ if (opts.dryRun) {
141
+ return;
142
+ }
143
+ await mkdir(dirname(dst), { recursive: true });
144
+ // Best-effort: preserve symlinks as symlinks (do not dereference).
145
+ await symlink(target, dst);
146
+ return;
147
+ }
148
+
149
+ if (st.isDirectory()) {
150
+ if (!opts.dryRun) {
151
+ await mkdir(dst, { recursive: true });
152
+ // Preserve directory timestamps best-effort after contents are copied.
153
+ }
154
+ const entries = await readdir(src, { withFileTypes: true });
155
+ for (const ent of entries) {
156
+ const name = String(ent.name ?? "");
157
+ if (!name) {
158
+ continue;
159
+ }
160
+ await copyTree(join(src, name), join(dst, name), opts);
161
+ }
162
+ if (!opts.dryRun) {
163
+ const s = await stat(src);
164
+ await utimes(dst, s.atime, s.mtime).catch(() => null);
165
+ }
166
+ return;
167
+ }
168
+
169
+ if (st.isFile()) {
170
+ if (opts.dryRun) {
171
+ return;
172
+ }
173
+ await mkdir(dirname(dst), { recursive: true });
174
+ await copyFile(src, dst);
175
+ const s = await stat(src);
176
+ await utimes(dst, s.atime, s.mtime).catch(() => null);
177
+ }
178
+ }
179
+
180
+ export async function migrateCommand(argv: string[]) {
181
+ if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
182
+ printHelp();
183
+ return;
184
+ }
185
+
186
+ const dryRun = argv.includes("--dry-run");
187
+ const move = argv.includes("--move");
188
+ const writeConfig = argv.includes("--write-config");
189
+
190
+ const home = homedir();
191
+ const dest = join(home, "agents", ".facult");
192
+ const stateDir = join(home, ".facult");
193
+ const configPath = join(stateDir, "config.json");
194
+
195
+ let legacy: string | null = null;
196
+ try {
197
+ const from = parseFromFlag(argv);
198
+ if (from) {
199
+ legacy = resolvePath(from, home);
200
+ }
201
+ } catch (err) {
202
+ console.error(err instanceof Error ? err.message : String(err));
203
+ process.exitCode = 2;
204
+ return;
205
+ }
206
+
207
+ if (!legacy) {
208
+ const candidates = await findLegacyStoreUnderAgents(home);
209
+ if (candidates.length === 0) {
210
+ console.error(
211
+ "No legacy store found under ~/agents. Pass --from <path> to migrate from a specific directory."
212
+ );
213
+ process.exitCode = 1;
214
+ return;
215
+ }
216
+ if (candidates.length > 1) {
217
+ const names = candidates.map((p) => basename(p)).join(", ");
218
+ console.error(
219
+ `Multiple legacy stores found under ~/agents: ${names}\nPass --from <path> to choose one.`
220
+ );
221
+ process.exitCode = 1;
222
+ return;
223
+ }
224
+ legacy = candidates[0] ?? null;
225
+ }
226
+
227
+ if (!(legacy && (await dirExists(legacy)))) {
228
+ console.error(
229
+ "Legacy store not found. Pass --from <path> to migrate from a specific directory."
230
+ );
231
+ process.exitCode = 1;
232
+ return;
233
+ }
234
+
235
+ if (await dirExists(dest)) {
236
+ if (await looksLikeStore(dest)) {
237
+ console.log(`Destination already exists: ${dest}`);
238
+ console.log("Nothing to do.");
239
+ return;
240
+ }
241
+ console.error(
242
+ `Destination exists but does not look like a facult store: ${dest}`
243
+ );
244
+ process.exitCode = 1;
245
+ return;
246
+ }
247
+
248
+ if (move) {
249
+ if (dryRun) {
250
+ console.log(`[dry-run] Would move ${legacy} -> ${dest}`);
251
+ } else {
252
+ await rename(legacy, dest);
253
+ console.log(`Moved ${legacy} -> ${dest}`);
254
+ }
255
+ } else if (dryRun) {
256
+ console.log(`[dry-run] Would copy ${legacy} -> ${dest}`);
257
+ } else {
258
+ await mkdir(dest, { recursive: true });
259
+ await copyTree(legacy, dest, { dryRun: false });
260
+ console.log(`Copied ${legacy} -> ${dest}`);
261
+ }
262
+
263
+ if (writeConfig) {
264
+ if (dryRun) {
265
+ console.log(`[dry-run] Would write ${configPath} with rootDir=${dest}`);
266
+ } else {
267
+ await mkdir(stateDir, { recursive: true });
268
+ await Bun.write(configPath, JSON.stringify({ rootDir: dest }, null, 2));
269
+ console.log(`Wrote ${configPath}`);
270
+ }
271
+ }
272
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,238 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import { parseJsonLenient } from "./util/json";
5
+
6
+ export interface FacultConfig {
7
+ /**
8
+ * Override the canonical root directory.
9
+ *
10
+ * This is where facult stores the consolidated "canonical" skill + MCP state
11
+ * (skills/, mcp/, snippets/, index.json, ...).
12
+ */
13
+ rootDir?: string;
14
+
15
+ /**
16
+ * Default scan roots (equivalent to passing `facult scan --from <path>`).
17
+ * Example: ["~", "~/dev", "~/work"]
18
+ */
19
+ scanFrom?: string[];
20
+
21
+ /**
22
+ * Extra ignore directory basenames applied under `scanFrom` roots.
23
+ * Example: ["vendor", ".venv"]
24
+ */
25
+ scanFromIgnore?: string[];
26
+
27
+ /** Disable the default ignore list for `scanFrom` roots. */
28
+ scanFromNoDefaultIgnore?: boolean;
29
+
30
+ /** Default max directories visited per scanFrom root (same as --from-max-visits). */
31
+ scanFromMaxVisits?: number;
32
+
33
+ /** Default max discovered paths per scanFrom root (same as --from-max-results). */
34
+ scanFromMaxResults?: number;
35
+ }
36
+
37
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
38
+ return !!v && typeof v === "object" && !Array.isArray(v);
39
+ }
40
+
41
+ function isSafePathString(p: string): boolean {
42
+ // Protect filesystem APIs from null-byte paths.
43
+ return !p.includes("\0");
44
+ }
45
+
46
+ function expandHomePath(p: string, home: string): string {
47
+ if (p === "~") {
48
+ return home;
49
+ }
50
+ if (p.startsWith("~/")) {
51
+ return join(home, p.slice(2));
52
+ }
53
+ return p;
54
+ }
55
+
56
+ function resolvePath(p: string, home: string): string {
57
+ const expanded = expandHomePath(p, home);
58
+ return expanded.startsWith("/") ? expanded : resolve(expanded);
59
+ }
60
+
61
+ function dirExists(p: string): boolean {
62
+ try {
63
+ return statSync(p).isDirectory();
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ function fileExists(p: string): boolean {
70
+ try {
71
+ return statSync(p).isFile();
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ function looksLikeFacultRoot(root: string): boolean {
78
+ if (!dirExists(root)) {
79
+ return false;
80
+ }
81
+ // Heuristic: treat as a facult store if it contains something we'd create.
82
+ return (
83
+ fileExists(join(root, "index.json")) ||
84
+ dirExists(join(root, "skills")) ||
85
+ dirExists(join(root, "mcp")) ||
86
+ dirExists(join(root, "snippets"))
87
+ );
88
+ }
89
+
90
+ function detectLegacyStoreUnderAgents(home: string): string | null {
91
+ const agentsDir = join(home, "agents");
92
+ let entries: any[];
93
+ try {
94
+ entries = readdirSync(agentsDir, { withFileTypes: true });
95
+ } catch {
96
+ return null;
97
+ }
98
+
99
+ const candidates: string[] = [];
100
+ for (const ent of entries) {
101
+ if (!ent?.isDirectory?.()) {
102
+ continue;
103
+ }
104
+ const name = String(ent.name ?? "");
105
+ if (!name || name === ".facult") {
106
+ continue;
107
+ }
108
+ const abs = join(agentsDir, name);
109
+ if (looksLikeFacultRoot(abs)) {
110
+ candidates.push(abs);
111
+ }
112
+ }
113
+
114
+ // Only auto-select when there is exactly one candidate.
115
+ // If there are multiple, require explicit config/env to choose.
116
+ candidates.sort();
117
+ return candidates.length === 1 ? (candidates[0] ?? null) : null;
118
+ }
119
+
120
+ export function facultStateDir(home: string = homedir()): string {
121
+ return join(home, ".facult");
122
+ }
123
+
124
+ export function facultConfigPath(home: string = homedir()): string {
125
+ return join(facultStateDir(home), "config.json");
126
+ }
127
+
128
+ export function readFacultConfig(
129
+ home: string = homedir()
130
+ ): FacultConfig | null {
131
+ const p = facultConfigPath(home);
132
+ if (!(isSafePathString(p) && fileExists(p))) {
133
+ return null;
134
+ }
135
+
136
+ try {
137
+ const txt = readFileSync(p, "utf8");
138
+ const parsed = parseJsonLenient(txt) as unknown;
139
+ if (!isPlainObject(parsed)) {
140
+ return null;
141
+ }
142
+ const rootDir =
143
+ typeof parsed.rootDir === "string" ? parsed.rootDir : undefined;
144
+
145
+ const scanFromRaw = (parsed as Record<string, unknown>).scanFrom;
146
+ const scanFrom = Array.isArray(scanFromRaw)
147
+ ? scanFromRaw
148
+ .filter((v) => typeof v === "string")
149
+ .map((v) => v.trim())
150
+ .filter(Boolean)
151
+ : undefined;
152
+
153
+ const scanFromIgnoreRaw = (parsed as Record<string, unknown>)
154
+ .scanFromIgnore;
155
+ const scanFromIgnore = Array.isArray(scanFromIgnoreRaw)
156
+ ? scanFromIgnoreRaw
157
+ .filter((v) => typeof v === "string")
158
+ .map((v) => v.trim())
159
+ .filter(Boolean)
160
+ : undefined;
161
+
162
+ const scanFromNoDefaultIgnore =
163
+ typeof (parsed as Record<string, unknown>).scanFromNoDefaultIgnore ===
164
+ "boolean"
165
+ ? ((parsed as Record<string, unknown>)
166
+ .scanFromNoDefaultIgnore as boolean)
167
+ : undefined;
168
+
169
+ const scanFromMaxVisitsRaw = (parsed as Record<string, unknown>)
170
+ .scanFromMaxVisits;
171
+ const scanFromMaxVisits =
172
+ typeof scanFromMaxVisitsRaw === "number" &&
173
+ Number.isFinite(scanFromMaxVisitsRaw) &&
174
+ scanFromMaxVisitsRaw > 0
175
+ ? Math.floor(scanFromMaxVisitsRaw)
176
+ : undefined;
177
+
178
+ const scanFromMaxResultsRaw = (parsed as Record<string, unknown>)
179
+ .scanFromMaxResults;
180
+ const scanFromMaxResults =
181
+ typeof scanFromMaxResultsRaw === "number" &&
182
+ Number.isFinite(scanFromMaxResultsRaw) &&
183
+ scanFromMaxResultsRaw > 0
184
+ ? Math.floor(scanFromMaxResultsRaw)
185
+ : undefined;
186
+
187
+ return {
188
+ rootDir,
189
+ scanFrom,
190
+ scanFromIgnore,
191
+ scanFromNoDefaultIgnore,
192
+ scanFromMaxVisits,
193
+ scanFromMaxResults,
194
+ };
195
+ } catch {
196
+ return null;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Return the canonical facult root directory.
202
+ *
203
+ * Precedence:
204
+ * 1) `FACULT_ROOT_DIR` env var
205
+ * 2) `~/.facult/config.json` { "rootDir": "..." }
206
+ * 3) `~/agents/.facult` (if it looks like a store)
207
+ * 4) a legacy store under `~/agents/` (if it looks like a store)
208
+ * 5) default: `~/agents/.facult`
209
+ */
210
+ export function facultRootDir(home: string = homedir()): string {
211
+ const envRoot = process.env.FACULT_ROOT_DIR?.trim();
212
+ if (envRoot) {
213
+ const resolved = resolvePath(envRoot, home);
214
+ return isSafePathString(resolved)
215
+ ? resolved
216
+ : join(home, "agents", ".facult");
217
+ }
218
+
219
+ const cfg = readFacultConfig(home);
220
+ const cfgRoot = cfg?.rootDir?.trim();
221
+ if (cfgRoot) {
222
+ const resolved = resolvePath(cfgRoot, home);
223
+ return isSafePathString(resolved)
224
+ ? resolved
225
+ : join(home, "agents", ".facult");
226
+ }
227
+
228
+ const preferred = join(home, "agents", ".facult");
229
+
230
+ if (looksLikeFacultRoot(preferred)) {
231
+ return preferred;
232
+ }
233
+ const legacy = detectLegacyStoreUnderAgents(home);
234
+ if (legacy) {
235
+ return legacy;
236
+ }
237
+ return preferred;
238
+ }
@@ -0,0 +1,217 @@
1
+ import {
2
+ copyFile,
3
+ lstat,
4
+ mkdir,
5
+ readdir,
6
+ readlink,
7
+ rename,
8
+ rm,
9
+ stat,
10
+ symlink,
11
+ utimes,
12
+ } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join, relative, resolve, sep } from "node:path";
15
+
16
+ export type QuarantineMode = "move" | "copy";
17
+
18
+ export interface QuarantineItem {
19
+ /** Absolute or relative path to a file or directory. */
20
+ path: string;
21
+ /** Optional label metadata for manifest/debugging. */
22
+ kind?: string;
23
+ item?: string;
24
+ }
25
+
26
+ export interface QuarantineEntry {
27
+ originalPath: string;
28
+ quarantinedPath: string;
29
+ mode: QuarantineMode;
30
+ kind?: string;
31
+ item?: string;
32
+ }
33
+
34
+ export interface QuarantineManifest {
35
+ version: 1;
36
+ timestamp: string;
37
+ entries: QuarantineEntry[];
38
+ }
39
+
40
+ function isSafePathString(p: string): boolean {
41
+ return !p.includes("\0");
42
+ }
43
+
44
+ async function pathExists(p: string): Promise<boolean> {
45
+ try {
46
+ await stat(p);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ async function ensureDir(p: string) {
54
+ await mkdir(p, { recursive: true });
55
+ }
56
+
57
+ function isSubpath(parent: string, child: string): boolean {
58
+ const rel = relative(parent, child);
59
+ return rel === "" || !(rel.startsWith("..") || rel.startsWith(`..${sep}`));
60
+ }
61
+
62
+ function relPathInQuarantine(args: { absPath: string; home: string }): string {
63
+ const { absPath, home } = args;
64
+
65
+ // Prefer preserving relative layout under the user's home for readability.
66
+ if (absPath === home) {
67
+ return join("home");
68
+ }
69
+ if (absPath.startsWith(home + sep)) {
70
+ return join("home", absPath.slice(home.length + 1));
71
+ }
72
+
73
+ // Fallback: treat as absolute path materialized under "abs/".
74
+ // On Unix, strip leading "/" so join doesn't ignore the prefix.
75
+ if (absPath.startsWith("/")) {
76
+ return join("abs", absPath.slice(1));
77
+ }
78
+
79
+ // Last resort: sanitize path-ish strings (e.g. Windows drive letters).
80
+ return join("abs", absPath.replace(/[:\\\\/]+/g, "_"));
81
+ }
82
+
83
+ async function uniqueDestinationPath(p: string): Promise<string> {
84
+ if (!(await pathExists(p))) {
85
+ return p;
86
+ }
87
+ const base = p;
88
+ for (let i = 2; i < 10_000; i += 1) {
89
+ const next = `${base}.dup${i}`;
90
+ if (!(await pathExists(next))) {
91
+ return next;
92
+ }
93
+ }
94
+ throw new Error(`Could not find unique quarantine path for ${p}`);
95
+ }
96
+
97
+ async function copyTree(src: string, dst: string): Promise<void> {
98
+ const st = await lstat(src);
99
+
100
+ if (st.isSymbolicLink()) {
101
+ const target = await readlink(src);
102
+ await ensureDir(dirname(dst));
103
+ await symlink(target, dst);
104
+ return;
105
+ }
106
+
107
+ if (st.isDirectory()) {
108
+ await ensureDir(dst);
109
+ const entries = await readdir(src, { withFileTypes: true });
110
+ for (const ent of entries) {
111
+ const name = String(ent.name ?? "");
112
+ if (!name) {
113
+ continue;
114
+ }
115
+ await copyTree(join(src, name), join(dst, name));
116
+ }
117
+ const s = await stat(src);
118
+ await utimes(dst, s.atime, s.mtime).catch(() => null);
119
+ return;
120
+ }
121
+
122
+ if (st.isFile()) {
123
+ await ensureDir(dirname(dst));
124
+ await copyFile(src, dst);
125
+ const s = await stat(src);
126
+ await utimes(dst, s.atime, s.mtime).catch(() => null);
127
+ }
128
+ }
129
+
130
+ async function movePath(src: string, dst: string): Promise<void> {
131
+ await ensureDir(dirname(dst));
132
+ try {
133
+ await rename(src, dst);
134
+ return;
135
+ } catch (e: unknown) {
136
+ const err = e as NodeJS.ErrnoException | null;
137
+ if (err?.code !== "EXDEV") {
138
+ throw e;
139
+ }
140
+ }
141
+
142
+ // Cross-device move fallback.
143
+ await copyTree(src, dst);
144
+ await rm(src, { recursive: true, force: true });
145
+ }
146
+
147
+ export async function quarantineItems(args: {
148
+ items: QuarantineItem[];
149
+ mode: QuarantineMode;
150
+ dryRun?: boolean;
151
+ timestamp?: string;
152
+ homeDir?: string;
153
+ /** Optional explicit destination directory (for deterministic runs). */
154
+ destDir?: string;
155
+ }): Promise<{ quarantineDir: string; manifest: QuarantineManifest }> {
156
+ const home = args.homeDir ?? homedir();
157
+ const ts = args.timestamp ?? new Date().toISOString();
158
+ const stamp = ts.replace(/[:.]/g, "-");
159
+ const quarantineDir =
160
+ args.destDir ?? join(home, ".facult", "quarantine", stamp);
161
+
162
+ const entries: QuarantineEntry[] = [];
163
+
164
+ for (const it of args.items) {
165
+ const raw = it.path;
166
+ const abs = raw.startsWith("/") ? raw : resolve(raw);
167
+
168
+ if (!isSafePathString(abs)) {
169
+ continue;
170
+ }
171
+
172
+ const rel = relPathInQuarantine({ absPath: abs, home });
173
+ const planned = resolve(quarantineDir, rel);
174
+ if (!isSubpath(quarantineDir, planned)) {
175
+ continue;
176
+ }
177
+
178
+ const dst = await uniqueDestinationPath(planned);
179
+ entries.push({
180
+ originalPath: abs,
181
+ quarantinedPath: dst,
182
+ mode: args.mode,
183
+ kind: it.kind,
184
+ item: it.item,
185
+ });
186
+ }
187
+
188
+ const manifest: QuarantineManifest = {
189
+ version: 1,
190
+ timestamp: ts,
191
+ entries,
192
+ };
193
+
194
+ if (args.dryRun) {
195
+ return { quarantineDir, manifest };
196
+ }
197
+
198
+ await ensureDir(quarantineDir);
199
+
200
+ for (const e of entries) {
201
+ if (!(await pathExists(e.originalPath))) {
202
+ continue;
203
+ }
204
+ if (args.mode === "move") {
205
+ await movePath(e.originalPath, e.quarantinedPath);
206
+ } else {
207
+ await copyTree(e.originalPath, e.quarantinedPath);
208
+ }
209
+ }
210
+
211
+ await Bun.write(
212
+ join(quarantineDir, "manifest.json"),
213
+ `${JSON.stringify(manifest, null, 2)}\n`
214
+ );
215
+
216
+ return { quarantineDir, manifest };
217
+ }