selfguard 0.0.0-stage → 0.1.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/dist/cli.js ADDED
@@ -0,0 +1,462 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { buildAlert, deliver } from "./alerts.js";
4
+ import * as api from "./api.js";
5
+ import { GUIDE } from "./guide.js";
6
+ import { hookEnter, ZSH_HOOK } from "./hook.js";
7
+ import { findProject } from "./lockfile/detect.js";
8
+ import { runMcpServer } from "./mcp.js";
9
+ import { paint, renderFindings, severityLabel, tildify, worstSeverity } from "./report.js";
10
+ import { repoIdentity } from "./repoid.js";
11
+ import { clean } from "./sanitize.js";
12
+ import { parseInterval, scheduleOff, scheduleOn, scheduleStatus } from "./schedule.js";
13
+ import { runSetup } from "./setup.js";
14
+ import { findingKey, loadRepo } from "./store.js";
15
+ import { isInteractive } from "./tui.js";
16
+ import { VERSION } from "./version.js";
17
+ const HELP = `selfguard ${VERSION} — keeps an eye on the dependencies of every repo you work on
18
+
19
+ Setup:
20
+ selfguard setup interactive: choose the folders where your repos live
21
+ selfguard init zsh print the shell hook (eval "$(selfguard init zsh)" in ~/.zshrc)
22
+ selfguard schedule on [--every 6h] | off | status
23
+ periodic sync with macOS notifications (launchd)
24
+
25
+ Projects:
26
+ selfguard status [--vulnerable] [--flat]
27
+ tracked repos, riskiest first (worktrees/clones grouped)
28
+ selfguard scan [dir] [-v] scan a project now (exit 1 if vulnerable) and track it
29
+ selfguard report [dir] [-v] last scan results, no network
30
+ selfguard sync find new repos in your folders + re-check everything
31
+ selfguard where <package> which projects use <package>, which versions
32
+ selfguard vuln <id> details of an advisory and which projects it affects
33
+
34
+ Folders:
35
+ selfguard roots list watched folders and exclusions
36
+ selfguard roots add <dir> [--depth N] | remove <dir>
37
+ selfguard discover [--dry-run] register new projects found in your folders
38
+ selfguard forget [dir] [--keep-discoverable]
39
+ stop tracking (and exclude) a project or folder
40
+ selfguard unexclude <dir> allow an excluded path to be discovered again
41
+
42
+ AI / automation:
43
+ --json any command: one JSON document on stdout
44
+ selfguard mcp MCP server over stdio (claude mcp add selfguard -- selfguard mcp)
45
+ selfguard guide usage guide for AI agents
46
+
47
+ Data: OSV.dev (GitHub advisories, CVEs, malicious packages). Only package names and versions
48
+ leave your machine. Nothing in your projects is executed. State: ~/.selfguard (SELFGUARD_HOME).
49
+ Exit codes: 0 ok, 1 vulnerabilities found (scan/report), 2 error.`;
50
+ const out = process.stdout;
51
+ const err = process.stderr;
52
+ const jsonMode = process.argv.includes("--json");
53
+ const JSON_OPT = { json: { type: "boolean" } };
54
+ class UsageError extends Error {
55
+ }
56
+ function emit(data) {
57
+ out.write(`${JSON.stringify(data, null, 2)}\n`);
58
+ }
59
+ function usage(message) {
60
+ throw new UsageError(message);
61
+ }
62
+ function notifyNew(outcomes) {
63
+ return deliver(buildAlert(outcomes
64
+ .filter((o) => o.newFindings.length > 0)
65
+ .map((o) => ({ path: o.record.path, newFindings: o.newFindings, repo: repoIdentity(o.record.path) }))));
66
+ }
67
+ /** Detached scan started by the shell hook: log + notify, never print to a terminal. */
68
+ async function backgroundScan(path) {
69
+ const stamp = new Date().toISOString();
70
+ try {
71
+ const project = findProject(path);
72
+ const before = new Set((project ? loadRepo(project.root)?.findings ?? [] : []).map(findingKey));
73
+ const r = await api.scanPath(path);
74
+ const rec = loadRepo(r.project.path);
75
+ await notifyNew([{ record: rec, newFindings: rec.findings.filter((f) => !before.has(findingKey(f))), firstScan: r.firstScan }]);
76
+ out.write(`${stamp} scan ${r.project.path}: ${r.project.advisories} finding(s)${r.project.error ? ` (${r.project.error})` : ""}\n`);
77
+ }
78
+ catch (e) {
79
+ if (!(e instanceof api.ApiError && e.code === "busy"))
80
+ out.write(`${stamp} scan ${path}: ${e.message}\n`);
81
+ }
82
+ return 0;
83
+ }
84
+ async function cmdScan(args) {
85
+ const { values, positionals } = parseArgs({
86
+ args,
87
+ allowPositionals: true,
88
+ options: { ...JSON_OPT, background: { type: "boolean" }, verbose: { type: "boolean", short: "v" } },
89
+ });
90
+ const path = positionals[0] ?? ".";
91
+ if (values.background)
92
+ return backgroundScan(path);
93
+ const r = await api.scanPath(path);
94
+ if (values.json)
95
+ emit(r);
96
+ else
97
+ out.write(`${renderFindings(loadRepo(r.project.path), out, values.verbose ?? false)}\n`);
98
+ return r.project.advisories > 0 ? 1 : 0;
99
+ }
100
+ function cmdReport(args) {
101
+ const { values, positionals } = parseArgs({
102
+ args,
103
+ allowPositionals: true,
104
+ options: { ...JSON_OPT, verbose: { type: "boolean", short: "v" } },
105
+ });
106
+ const report = api.getReport(positionals[0] ?? ".");
107
+ if (values.json)
108
+ emit(report);
109
+ else
110
+ out.write(`${renderFindings(loadRepo(report.path), out, values.verbose ?? false)}\n`);
111
+ return report.advisories > 0 ? 1 : 0;
112
+ }
113
+ function cmdStatus(args) {
114
+ const { values } = parseArgs({ args, options: { ...JSON_OPT, vulnerable: { type: "boolean" }, flat: { type: "boolean" } } });
115
+ const status = api.getStatus({ onlyVulnerable: values.vulnerable ?? false });
116
+ if (values.json) {
117
+ emit(status);
118
+ return 0;
119
+ }
120
+ const c = paint(out);
121
+ if (status.projects.length === 0) {
122
+ out.write(`No projects tracked yet. Run ${c.bold("selfguard setup")}.\n`);
123
+ return 0;
124
+ }
125
+ const badge = (w, error) => w ? severityLabel(w, c) : error ? c.yellow("!") : c.green("✓");
126
+ if (values.flat) {
127
+ for (const p of status.projects) {
128
+ const counts = Object.entries(p.bySeverity).map(([s, n]) => `${n} ${s}`).join(", ");
129
+ const detail = p.advisories
130
+ ? `${p.vulnerablePackages} vulnerable pkg(s) ${c.dim(`(${counts})`)}`
131
+ : p.error
132
+ ? c.dim(clean(p.error, 80))
133
+ : c.dim(`${p.packages} packages, clean`);
134
+ out.write(`${badge(p.worstSeverity, p.error)} ${c.bold(tildify(p.path))}${p.exists ? "" : c.dim(" (missing)")} ${detail}\n`);
135
+ }
136
+ return 0;
137
+ }
138
+ for (const g of status.repos) {
139
+ const many = g.checkouts.length > 1;
140
+ const firstError = g.checkouts.find((k) => k.error)?.error ?? null;
141
+ // Headline: the main checkout; mention a branch that is worse than it.
142
+ const main = g.checkouts.find((k) => !k.worktree) ?? g.checkouts[0];
143
+ const worse = g.checkouts
144
+ .filter((k) => k !== main && k.vulnerablePackages > main.vulnerablePackages)
145
+ .sort((a, b) => b.vulnerablePackages - a.vulnerablePackages)[0];
146
+ const mainText = main.vulnerablePackages
147
+ ? `${main.vulnerablePackages} vulnerable pkg(s)${many && main.branch ? ` on ${clean(main.branch, 40)}` : ""}`
148
+ : main.error && !many
149
+ ? c.dim(clean(main.error, 80))
150
+ : many && main.branch
151
+ ? c.dim(`clean on ${clean(main.branch, 40)}`)
152
+ : c.dim("clean");
153
+ const head = worse ? `${mainText} ${c.yellow(`· ${worse.vulnerablePackages} on ${clean(worse.branch ?? "another checkout", 40)}`)}` : mainText;
154
+ const where = many ? c.dim(`${g.checkouts.length} checkouts`) : c.dim(tildify(g.checkouts[0].path));
155
+ out.write(`${badge(g.worstSeverity, firstError)} ${c.bold(clean(g.label, 80))} ${head} ${where}\n`);
156
+ if (!many)
157
+ continue;
158
+ g.checkouts.forEach((k, i) => {
159
+ const branch = k.branch ? c.cyan(clean(k.branch, 50)) : c.dim("(no branch)");
160
+ const kind = k.worktree ? c.dim(" worktree") : "";
161
+ const detail = k.advisories ? `${k.vulnerablePackages} vulnerable` : k.error ? c.dim(clean(k.error, 60)) : c.dim("clean");
162
+ const tree = i === g.checkouts.length - 1 ? "└" : "├";
163
+ out.write(` ${c.dim(tree)} ${branch}${kind} ${detail} ${c.dim(tildify(k.path))}${k.exists ? "" : c.dim(" (missing)")}\n`);
164
+ });
165
+ }
166
+ return 0;
167
+ }
168
+ async function cmdSync(args) {
169
+ const { values } = parseArgs({ args, options: { ...JSON_OPT, background: { type: "boolean" } } });
170
+ let r;
171
+ try {
172
+ r = await api.sync();
173
+ }
174
+ catch (e) {
175
+ if (values.background && e instanceof api.ApiError && e.code === "busy")
176
+ return 0;
177
+ throw e;
178
+ }
179
+ await notifyNew(r.outcomes);
180
+ const { outcomes: _o, ...data } = r;
181
+ const total = data.news.reduce((n, p) => n + p.packages.length, 0);
182
+ if (values.json)
183
+ emit(data);
184
+ else if (values.background) {
185
+ out.write(`${new Date().toISOString()} sync: ${data.projects} project(s), ${data.discovered.length} discovered, ${total} newly vulnerable package(s)\n`);
186
+ }
187
+ else {
188
+ const c = paint(out);
189
+ if (data.discovered.length)
190
+ out.write(`Discovered ${data.discovered.length} new project(s) in your folders.\n`);
191
+ if (data.pruned.length)
192
+ out.write(c.dim(`Stopped tracking ${data.pruned.length} folder(s) that no longer exist.\n`));
193
+ out.write(`Synced ${data.projects} project(s). ${total ? c.red(`${total} newly vulnerable package(s).`) : c.green("Nothing new.")}\n`);
194
+ for (const p of data.news) {
195
+ const pkgs = p.packages.map((g) => `${clean(g.name)}@${clean(g.version)} ${c.dim(`(${g.advisories.length})`)}`);
196
+ out.write(` ${c.bold(tildify(p.path))}: ${pkgs.join(", ")}\n`);
197
+ }
198
+ for (const e of data.errors)
199
+ out.write(c.dim(` ! ${tildify(e.path)}: ${clean(e.error)}\n`));
200
+ }
201
+ return 0;
202
+ }
203
+ async function cmdDiscover(args) {
204
+ const { values } = parseArgs({ args, options: { ...JSON_OPT, "dry-run": { type: "boolean" } } });
205
+ const r = await api.discoverNew({ dryRun: values["dry-run"] ?? false });
206
+ if (values.json) {
207
+ emit(r);
208
+ return 0;
209
+ }
210
+ const c = paint(out);
211
+ if (r.found.length === 0)
212
+ out.write("No new projects found.\n");
213
+ for (const p of r.found) {
214
+ const note = p.lockType ? c.dim(` (${p.lockType})`) : c.dim(" (no lockfile)");
215
+ out.write(` ${c.green("+")} ${tildify(p.path)}${note}\n`);
216
+ }
217
+ if (r.dryRun && r.found.length)
218
+ out.write(`${r.found.length} new project(s) (dry run, nothing registered).\n`);
219
+ else if (r.registered)
220
+ out.write(`Registered ${r.registered} project(s); ${r.withVulnerabilities} with vulnerabilities. See: selfguard status\n`);
221
+ return 0;
222
+ }
223
+ function printRoots(r) {
224
+ const c = paint(out);
225
+ if (r.roots.length === 0)
226
+ out.write("No folders configured. Run: selfguard setup\n");
227
+ for (const root of r.roots)
228
+ out.write(`${tildify(root.path)} ${c.dim(`depth ${root.depth}`)}\n`);
229
+ if (r.exclude.length) {
230
+ out.write(c.dim("excluded:\n"));
231
+ for (const e of r.exclude)
232
+ out.write(c.dim(` ${tildify(e)}\n`));
233
+ }
234
+ }
235
+ function cmdRoots(args) {
236
+ const { values, positionals } = parseArgs({ args, allowPositionals: true, options: { ...JSON_OPT, depth: { type: "string" } } });
237
+ const [action, target] = positionals;
238
+ let r;
239
+ if (action === "add") {
240
+ if (!target)
241
+ usage("usage: selfguard roots add <dir> [--depth N]");
242
+ const depth = values.depth === undefined ? undefined : Number(values.depth);
243
+ if (depth !== undefined && !Number.isInteger(depth))
244
+ usage("--depth must be an integer");
245
+ r = api.addRoot(target, depth);
246
+ }
247
+ else if (action === "remove") {
248
+ if (!target)
249
+ usage("usage: selfguard roots remove <dir>");
250
+ r = api.removeRoot(target);
251
+ }
252
+ else if (action === undefined || action === "list") {
253
+ r = api.listRoots();
254
+ }
255
+ else {
256
+ usage("usage: selfguard roots [add <dir> [--depth N] | remove <dir>]");
257
+ }
258
+ if (values.json)
259
+ emit(r);
260
+ else
261
+ printRoots(r);
262
+ return 0;
263
+ }
264
+ function cmdWhere(args) {
265
+ const { values, positionals } = parseArgs({ args, allowPositionals: true, options: JSON_OPT });
266
+ const name = positionals[0];
267
+ if (!name)
268
+ usage("usage: selfguard where <package>");
269
+ const r = api.whereUsed(name);
270
+ if (values.json) {
271
+ emit(r);
272
+ return 0;
273
+ }
274
+ const c = paint(out);
275
+ if (r.usedIn.length === 0)
276
+ out.write(`${clean(name)} is not used in any tracked project.\n`);
277
+ for (const p of r.usedIn) {
278
+ const list = p.versions
279
+ .map((v) => `${v.vulnerable ? c.red(clean(v.version)) : clean(v.version)}${v.direct ? "" : c.dim(" (transitive)")}`)
280
+ .join(", ");
281
+ out.write(`${c.bold(tildify(p.path))} ${list}\n`);
282
+ }
283
+ return 0;
284
+ }
285
+ async function cmdVuln(args) {
286
+ const { values, positionals } = parseArgs({ args, allowPositionals: true, options: JSON_OPT });
287
+ const id = positionals[0];
288
+ if (!id)
289
+ usage("usage: selfguard vuln <advisory-id>");
290
+ const v = await api.getVulnerability(id);
291
+ if (values.json) {
292
+ emit(v);
293
+ return 0;
294
+ }
295
+ const c = paint(out);
296
+ out.write(`${severityLabel(v.severity, c)} ${c.bold(v.id)}${v.aliases.length ? c.dim(` (${v.aliases.join(", ")})`) : ""}\n`);
297
+ if (v.summary)
298
+ out.write(`${v.summary}\n`);
299
+ for (const a of v.affected) {
300
+ const ranges = a.ranges.map((e) => Object.entries(e).map(([k, x]) => `${k} ${clean(x)}`).join("")).join(", ");
301
+ const versions = a.versions.length ? ` versions ${a.versions.slice(0, 10).join(", ")}` : "";
302
+ out.write(c.dim(` ${a.package}: ${ranges}${versions}\n`));
303
+ }
304
+ out.write(v.affectsYourProjects.length
305
+ ? c.red(`Affects ${v.affectsYourProjects.length} place(s) in your projects:\n`)
306
+ : c.green("None of your tracked projects are affected.\n"));
307
+ for (const p of v.affectsYourProjects) {
308
+ out.write(` ${tildify(p.path)} ${clean(p.package)}@${clean(p.version)}${p.fixedIn ? c.green(` → ${clean(p.fixedIn)}`) : ""}\n`);
309
+ }
310
+ out.write(c.dim(`${v.url}\n`));
311
+ return 0;
312
+ }
313
+ function cmdForget(args) {
314
+ const { values, positionals } = parseArgs({
315
+ args,
316
+ allowPositionals: true,
317
+ options: { ...JSON_OPT, "keep-discoverable": { type: "boolean" } },
318
+ });
319
+ const r = api.forget(positionals[0] ?? ".", { exclude: !values["keep-discoverable"] });
320
+ if (values.json)
321
+ emit(r);
322
+ else {
323
+ out.write(r.wasTracked ? `Stopped tracking ${tildify(r.path)}` : `${tildify(r.path)} was not tracked`);
324
+ out.write(r.excluded ? " (excluded from discovery; undo with selfguard unexclude)\n" : "\n");
325
+ if (r.alsoForgotten.length)
326
+ out.write(`Also stopped tracking ${r.alsoForgotten.length} project(s) inside it.\n`);
327
+ }
328
+ return 0;
329
+ }
330
+ function cmdUnexclude(args) {
331
+ const { values, positionals } = parseArgs({ args, allowPositionals: true, options: JSON_OPT });
332
+ if (!positionals[0])
333
+ usage("usage: selfguard unexclude <dir>");
334
+ const r = api.unexclude(positionals[0]);
335
+ if (values.json)
336
+ emit(r);
337
+ else
338
+ out.write(r.removed ? "Removed from exclusions.\n" : "That path was not excluded.\n");
339
+ return 0;
340
+ }
341
+ function cmdSchedule(args) {
342
+ const { values, positionals } = parseArgs({
343
+ args,
344
+ allowPositionals: true,
345
+ options: { ...JSON_OPT, every: { type: "string", default: "6h" } },
346
+ });
347
+ switch (positionals[0]) {
348
+ case "on": {
349
+ const seconds = parseInterval(values.every ?? "6h");
350
+ const plist = scheduleOn(seconds);
351
+ if (values.json)
352
+ emit({ enabled: true, intervalSeconds: seconds, plist, node: process.execPath });
353
+ else {
354
+ out.write(`Background sync every ${values.every}. (${tildify(plist)})\n`);
355
+ out.write(`Note: it runs with ${process.execPath}; re-run this if you change Node versions.\n`);
356
+ }
357
+ return 0;
358
+ }
359
+ case "off": {
360
+ const was = scheduleOff();
361
+ if (values.json)
362
+ emit({ enabled: false, wasEnabled: was });
363
+ else
364
+ out.write(was ? "Background sync disabled.\n" : "Background sync was not enabled.\n");
365
+ return 0;
366
+ }
367
+ case "status":
368
+ case undefined: {
369
+ const s = scheduleStatus();
370
+ if (values.json)
371
+ emit(s);
372
+ else
373
+ out.write(s.loaded ? `enabled (${tildify(s.plist)})\n` : s.installed ? "installed but not loaded\n" : "disabled\n");
374
+ return 0;
375
+ }
376
+ default:
377
+ usage("usage: selfguard schedule on [--every 6h] | off | status");
378
+ }
379
+ }
380
+ async function main(argv) {
381
+ const [cmd, ...rest] = argv;
382
+ switch (cmd) {
383
+ case "setup":
384
+ if (!isInteractive())
385
+ usage("setup needs an interactive terminal; use `selfguard roots add <dir>` instead");
386
+ return runSetup();
387
+ case "init":
388
+ if (rest[0] !== "zsh")
389
+ usage("only zsh is supported for now: selfguard init zsh");
390
+ out.write(ZSH_HOOK);
391
+ return 0;
392
+ case "hook-enter": {
393
+ const { values } = parseArgs({ args: rest, options: { dir: { type: "string" }, prompt: { type: "boolean" } }, strict: false });
394
+ hookEnter(typeof values.dir === "string" ? values.dir : process.cwd(), values.prompt === true);
395
+ return 0;
396
+ }
397
+ case "scan":
398
+ return cmdScan(rest);
399
+ case "report":
400
+ return cmdReport(rest);
401
+ case "status":
402
+ return cmdStatus(rest);
403
+ case "sync":
404
+ return cmdSync(rest);
405
+ case "discover":
406
+ return cmdDiscover(rest);
407
+ case "roots":
408
+ return cmdRoots(rest);
409
+ case "where":
410
+ return cmdWhere(rest);
411
+ case "vuln":
412
+ return cmdVuln(rest);
413
+ case "forget":
414
+ return cmdForget(rest);
415
+ case "unexclude":
416
+ return cmdUnexclude(rest);
417
+ case "schedule":
418
+ return cmdSchedule(rest);
419
+ case "mcp":
420
+ await runMcpServer();
421
+ return 0;
422
+ case "guide":
423
+ out.write(GUIDE);
424
+ return 0;
425
+ case "-v":
426
+ case "--version":
427
+ case "version":
428
+ if (jsonMode)
429
+ emit({ version: VERSION });
430
+ else
431
+ out.write(`${VERSION}\n`);
432
+ return 0;
433
+ case undefined:
434
+ case "-h":
435
+ case "--help":
436
+ case "help":
437
+ out.write(`${HELP}\n`);
438
+ return 0;
439
+ default:
440
+ usage(`unknown command "${clean(cmd, 40)}". See selfguard --help`);
441
+ }
442
+ }
443
+ function errorCode(e) {
444
+ if (e instanceof api.ApiError)
445
+ return e.code;
446
+ if (e instanceof UsageError)
447
+ return "usage";
448
+ const nodeCode = e?.code;
449
+ if (typeof nodeCode === "string" && nodeCode.startsWith("ERR_PARSE_ARGS"))
450
+ return "usage";
451
+ return "error";
452
+ }
453
+ main(process.argv.slice(2)).then((code) => {
454
+ process.exitCode = code;
455
+ }, (e) => {
456
+ const message = clean(e instanceof Error ? e.message : String(e), 500);
457
+ if (jsonMode)
458
+ emit({ error: { code: errorCode(e), message } });
459
+ else
460
+ err.write(`selfguard: ${message}\n`);
461
+ process.exitCode = 2;
462
+ });
package/dist/config.js ADDED
@@ -0,0 +1,70 @@
1
+ import { existsSync, realpathSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import { atomicWrite, isObject, readJsonFile } from "./fsutil.js";
5
+ import { stateRoot } from "./paths.js";
6
+ export const DEFAULT_DEPTH = 5;
7
+ export const MAX_DEPTH = 10;
8
+ const configFile = () => join(stateRoot(), "config.json");
9
+ export function loadConfig() {
10
+ const raw = readJsonFile(configFile());
11
+ const roots = [];
12
+ if (raw && isObject(raw) && Array.isArray(raw.roots)) {
13
+ for (const r of raw.roots) {
14
+ if (isObject(r) && typeof r.path === "string") {
15
+ const depth = typeof r.depth === "number" ? clampDepth(r.depth) : DEFAULT_DEPTH;
16
+ roots.push({ path: r.path, depth });
17
+ }
18
+ }
19
+ }
20
+ const exclude = raw && isObject(raw) && Array.isArray(raw.exclude) ? raw.exclude.filter((e) => typeof e === "string") : [];
21
+ return { schema: 1, roots, exclude };
22
+ }
23
+ export function isExcluded(path, exclude) {
24
+ return exclude.some((e) => path === e || path.startsWith(e.endsWith("/") ? e : `${e}/`));
25
+ }
26
+ export function saveConfig(cfg) {
27
+ atomicWrite(configFile(), `${JSON.stringify(cfg, null, 2)}\n`);
28
+ }
29
+ export function clampDepth(n) {
30
+ return Math.max(1, Math.min(MAX_DEPTH, Math.trunc(n)));
31
+ }
32
+ export function expandHome(p) {
33
+ const t = p.trim();
34
+ if (t === "~")
35
+ return homedir();
36
+ if (t.startsWith("~/"))
37
+ return join(homedir(), t.slice(2));
38
+ return t;
39
+ }
40
+ /** Absolute, symlink-resolved directory path, or null if it isn't a directory. */
41
+ export function normalizeDir(p) {
42
+ try {
43
+ const abs = resolve(expandHome(p));
44
+ if (!existsSync(abs) || !statSync(abs).isDirectory())
45
+ return null;
46
+ return realpathSync(abs);
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ /** Common places people keep code, shown as suggestions in `selfguard setup`. */
53
+ const CANDIDATES = [
54
+ "repository", "repositories", "repos", "code", "Code", "dev", "Developer", "development",
55
+ "projects", "Projects", "src", "workspace", "work", "git", "github", "GitHub",
56
+ "Documents/GitHub", "Documents/code", "Documents/projects", "Sites",
57
+ ];
58
+ export function candidateRoots() {
59
+ const seen = new Set();
60
+ const out = [];
61
+ for (const c of CANDIDATES) {
62
+ const dir = normalizeDir(join(homedir(), c));
63
+ // macOS is case-insensitive: ~/code and ~/Code are the same folder
64
+ if (dir && !seen.has(dir.toLowerCase())) {
65
+ seen.add(dir.toLowerCase());
66
+ out.push(dir);
67
+ }
68
+ }
69
+ return out;
70
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Discovery of JS projects under folders the user chose.
3
+ * Breadth-first and depth-limited. Descends into every subfolder (including inside
4
+ * projects, so monorepos with several lockfiles are found) except dependency and
5
+ * generated folders like node_modules. Never follows symlinks.
6
+ */
7
+ import { readdir } from "node:fs/promises";
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { isExcluded } from "./config.js";
11
+ import { LOCKFILES } from "./lockfile/detect.js";
12
+ /** Installed dependencies and generated caches: never your code. */
13
+ export const SKIP_DIRS = new Set([
14
+ "node_modules", "bower_components", "jspm_packages", // JS deps
15
+ "vendor", "Pods", "venv", "__pycache__", "target", // other ecosystems' deps/build caches
16
+ ]);
17
+ /** Only skipped when they sit directly in your home folder (macOS system folders). */
18
+ const HOME_SKIP = new Set(["Library", "Applications", "Movies", "Music", "Pictures", "Public"]);
19
+ export async function discover(root, opts) {
20
+ const maxDirs = opts.maxDirs ?? 50_000;
21
+ const exclude = opts.exclude ?? [];
22
+ const home = homedir();
23
+ const found = [];
24
+ let queue = [root];
25
+ let visited = 0;
26
+ for (let level = 0; level <= opts.depth && queue.length > 0; level++) {
27
+ const next = [];
28
+ for (const dir of queue) {
29
+ if (opts.isCancelled?.() || visited++ >= maxDirs)
30
+ return found;
31
+ if (isExcluded(dir, exclude))
32
+ continue;
33
+ let entries;
34
+ try {
35
+ entries = await readdir(dir, { withFileTypes: true });
36
+ }
37
+ catch {
38
+ continue; // no permission, vanished, etc.
39
+ }
40
+ const files = new Set(entries.filter((e) => e.isFile()).map((e) => e.name));
41
+ const lock = LOCKFILES.find(([name]) => files.has(name));
42
+ if (lock) {
43
+ found.push({ root: dir, lockfile: join(dir, lock[0]), lockType: lock[1] });
44
+ }
45
+ else if (files.has("package.json") && entries.some((e) => e.name === ".git")) {
46
+ // A repo without a lockfile yet. (Workspace packages without their own
47
+ // lockfile are covered by the root lockfile, so they aren't listed.)
48
+ found.push({ root: dir, lockfile: null, lockType: null });
49
+ }
50
+ for (const e of entries) {
51
+ if (!e.isDirectory() || e.isSymbolicLink())
52
+ continue;
53
+ if (e.name.startsWith("."))
54
+ continue; // .git, .next, .cache, .turbo, .yarn…
55
+ if (SKIP_DIRS.has(e.name))
56
+ continue;
57
+ if (dir === home && HOME_SKIP.has(e.name))
58
+ continue;
59
+ next.push(join(dir, e.name));
60
+ }
61
+ }
62
+ queue = next;
63
+ }
64
+ return found;
65
+ }
66
+ export async function discoverAll(roots, opts = {}) {
67
+ const byRoot = new Map();
68
+ for (const r of roots) {
69
+ for (const p of await discover(r.path, { ...opts, depth: r.depth }))
70
+ byRoot.set(p.root, p);
71
+ }
72
+ return [...byRoot.values()].sort((a, b) => a.root.localeCompare(b.root));
73
+ }
package/dist/fsutil.js ADDED
@@ -0,0 +1,66 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { locksDir } from "./paths.js";
5
+ /** Create a private directory (0700): our state reveals what you work on. */
6
+ export function ensureDir(dir) {
7
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
8
+ }
9
+ /** Write a file atomically (tmp + rename) with 0600 permissions. */
10
+ export function atomicWrite(file, data) {
11
+ ensureDir(dirname(file));
12
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
13
+ writeFileSync(tmp, data, { mode: 0o600 });
14
+ renameSync(tmp, file);
15
+ }
16
+ export function readJsonFile(file) {
17
+ try {
18
+ return JSON.parse(readFileSync(file, "utf8"));
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ export function sha256(data) {
25
+ return createHash("sha256").update(data).digest("hex");
26
+ }
27
+ export function isObject(v) {
28
+ return typeof v === "object" && v !== null && !Array.isArray(v);
29
+ }
30
+ const STALE_LOCK_MS = 5 * 60 * 1000;
31
+ /**
32
+ * Best-effort cross-process lock using O_EXCL. Returns a release function,
33
+ * or null if someone else holds a fresh lock.
34
+ */
35
+ export function tryLock(name) {
36
+ ensureDir(locksDir());
37
+ const file = join(locksDir(), `${sha256(name).slice(0, 32)}.lock`);
38
+ for (let attempt = 0; attempt < 2; attempt++) {
39
+ try {
40
+ const fd = openSync(file, "wx", 0o600);
41
+ writeFileSync(fd, String(process.pid));
42
+ closeSync(fd);
43
+ return () => {
44
+ try {
45
+ unlinkSync(file);
46
+ }
47
+ catch {
48
+ /* already gone */
49
+ }
50
+ };
51
+ }
52
+ catch (err) {
53
+ if (err.code !== "EEXIST")
54
+ throw err;
55
+ try {
56
+ if (Date.now() - statSync(file).mtimeMs < STALE_LOCK_MS)
57
+ return null;
58
+ unlinkSync(file); // stale: previous run crashed
59
+ }
60
+ catch {
61
+ /* raced with another process; retry once */
62
+ }
63
+ }
64
+ }
65
+ return null;
66
+ }