selfguard 0.0.1 → 0.2.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,591 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { buildReminder, needsAttention } from "./alerts.js";
4
+ import { maybeRemind } from "./announce.js";
5
+ import * as api from "./api.js";
6
+ import { GUIDE } from "./guide.js";
7
+ import { hookEnter, ZSH_HOOK } from "./hook.js";
8
+ import { findProject } from "./lockfile/detect.js";
9
+ import { runMcpServer } from "./mcp.js";
10
+ import { groupByPackage, paint, renderFindings, severityLabel, tildify } from "./report.js";
11
+ import { clean } from "./sanitize.js";
12
+ import { parseInterval, scheduleOff, scheduleOn, scheduleStatus, updateWatcher } from "./schedule.js";
13
+ import { runSetup } from "./setup.js";
14
+ import { loadRepo } from "./store.js";
15
+ import { isInteractive } from "./tui.js";
16
+ import { runUi } from "./ui/server.js";
17
+ import { VERSION } from "./version.js";
18
+ const HELP = `selfguard ${VERSION} — keeps an eye on the dependencies of every repo you work on
19
+
20
+ Setup:
21
+ selfguard setup interactive: choose the folders where your repos live
22
+ selfguard init zsh print the shell hook (eval "$(selfguard init zsh)" in ~/.zshrc)
23
+ selfguard schedule on [--every 6h] | off | status
24
+ background watching with macOS notifications (launchd):
25
+ lockfile changes right away, full sync every 6h
26
+
27
+ Dashboard and alerts:
28
+ selfguard ui [--no-open] [--port N]
29
+ local dashboard in your browser (127.0.0.1 only)
30
+ selfguard remind send the reminder of serious open issues now
31
+ selfguard settings [--remind daily|weekly|off] [--active-days N]
32
+ reminder frequency; days of git inactivity before a
33
+ checkout stops alerting (default 30)
34
+
35
+ Projects:
36
+ selfguard status [--vulnerable] [--flat] [--all]
37
+ tracked repos, riskiest first (worktrees/clones grouped;
38
+ inactive checkouts hidden unless --all)
39
+ selfguard scan [dir] [-v] scan a project now (exit 1 if vulnerable) and track it
40
+ selfguard report [dir] [-v] last scan results, no network
41
+ selfguard sync find new repos in your folders + re-check everything
42
+ selfguard check re-scan only projects whose lockfile changed
43
+ selfguard where <package> which projects use <package>, which versions
44
+ selfguard vuln <id> details of an advisory and which projects it affects
45
+
46
+ Folders:
47
+ selfguard roots list watched folders and exclusions
48
+ selfguard roots add <dir> [--depth N] | remove <dir>
49
+ selfguard discover [--dry-run] register new projects found in your folders
50
+ selfguard forget [dir] [--keep-discoverable]
51
+ stop tracking (and exclude) a project or folder
52
+ selfguard unexclude <dir> allow an excluded path to be discovered again
53
+
54
+ AI / automation:
55
+ --json any command: one JSON document on stdout
56
+ selfguard mcp MCP server over stdio (claude mcp add selfguard -- selfguard mcp)
57
+ selfguard guide usage guide for AI agents
58
+
59
+ Data: OSV.dev (GitHub advisories, CVEs, malicious packages). Only package names and versions
60
+ leave your machine. Nothing in your projects is executed. State: ~/.selfguard (SELFGUARD_HOME).
61
+ Exit codes: 0 ok, 1 vulnerabilities found (scan/report), 2 error.`;
62
+ const out = process.stdout;
63
+ const err = process.stderr;
64
+ const jsonMode = process.argv.includes("--json");
65
+ const JSON_OPT = { json: { type: "boolean" } };
66
+ class UsageError extends Error {
67
+ }
68
+ function emit(data) {
69
+ out.write(`${JSON.stringify(data, null, 2)}\n`);
70
+ }
71
+ function usage(message) {
72
+ throw new UsageError(message);
73
+ }
74
+ /** Detached scan started by the shell hook: log + notify, never print to a terminal. */
75
+ async function backgroundScan(path) {
76
+ const stamp = new Date().toISOString();
77
+ try {
78
+ const r = await api.scanPath(path, { notify: true });
79
+ out.write(`${stamp} scan ${r.project.path}: ${r.project.advisories} finding(s)${r.project.error ? ` (${r.project.error})` : ""}\n`);
80
+ }
81
+ catch (e) {
82
+ if (!(e instanceof api.ApiError && e.code === "busy"))
83
+ out.write(`${stamp} scan ${path}: ${e.message}\n`);
84
+ }
85
+ return 0;
86
+ }
87
+ async function cmdScan(args) {
88
+ const { values, positionals } = parseArgs({
89
+ args,
90
+ allowPositionals: true,
91
+ options: { ...JSON_OPT, background: { type: "boolean" }, verbose: { type: "boolean", short: "v" } },
92
+ });
93
+ const path = positionals[0] ?? ".";
94
+ if (values.background)
95
+ return backgroundScan(path);
96
+ const r = await api.scanPath(path);
97
+ if (values.json)
98
+ emit(r);
99
+ else
100
+ out.write(`${renderFindings(loadRepo(r.project.path), out, values.verbose ?? false)}\n`);
101
+ return r.project.advisories > 0 ? 1 : 0;
102
+ }
103
+ function cmdReport(args) {
104
+ const { values, positionals } = parseArgs({
105
+ args,
106
+ allowPositionals: true,
107
+ options: { ...JSON_OPT, verbose: { type: "boolean", short: "v" } },
108
+ });
109
+ const report = api.getReport(positionals[0] ?? ".");
110
+ if (values.json)
111
+ emit(report);
112
+ else
113
+ out.write(`${renderFindings(loadRepo(report.path), out, values.verbose ?? false)}\n`);
114
+ return report.advisories > 0 ? 1 : 0;
115
+ }
116
+ function cmdStatus(args) {
117
+ const { values } = parseArgs({
118
+ args,
119
+ options: { ...JSON_OPT, vulnerable: { type: "boolean" }, flat: { type: "boolean" }, all: { type: "boolean" } },
120
+ });
121
+ const status = api.getStatus({ onlyVulnerable: values.vulnerable ?? false });
122
+ if (values.json) {
123
+ emit(status);
124
+ return 0;
125
+ }
126
+ const c = paint(out);
127
+ if (status.projects.length === 0) {
128
+ out.write(`No projects tracked yet. Run ${c.bold("selfguard setup")}.\n`);
129
+ return 0;
130
+ }
131
+ const badge = (w, error) => w ? severityLabel(w, c) : error ? c.yellow("!") : c.green("✓");
132
+ if (values.flat) {
133
+ for (const p of status.projects) {
134
+ const counts = Object.entries(p.bySeverity).map(([s, n]) => `${n} ${s}`).join(", ");
135
+ const detail = p.advisories
136
+ ? `${p.vulnerablePackages} vulnerable pkg(s) ${c.dim(`(${counts})`)}`
137
+ : p.error
138
+ ? c.dim(clean(p.error, 80))
139
+ : c.dim(`${p.packages} packages, clean`);
140
+ out.write(`${badge(p.worstSeverity, p.error)} ${c.bold(tildify(p.path))}${p.exists ? "" : c.dim(" (missing)")} ${detail}\n`);
141
+ }
142
+ return 0;
143
+ }
144
+ const showAll = values.all ?? false;
145
+ let hiddenRepos = 0;
146
+ for (const g0 of status.repos) {
147
+ if (!g0.active && !showAll) {
148
+ hiddenRepos++;
149
+ continue;
150
+ }
151
+ const visible = showAll ? g0.checkouts : g0.checkouts.filter((k) => k.active);
152
+ const hiddenCheckouts = g0.checkouts.length - visible.length;
153
+ const g = { ...g0, checkouts: visible };
154
+ const many = g.checkouts.length > 1;
155
+ const firstError = g.checkouts.find((k) => k.error)?.error ?? null;
156
+ // Headline: the main checkout; mention a branch that is worse than it.
157
+ const main = g.checkouts.find((k) => !k.worktree) ?? g.checkouts[0];
158
+ const worse = g.checkouts
159
+ .filter((k) => k !== main && k.vulnerablePackages > main.vulnerablePackages)
160
+ .sort((a, b) => b.vulnerablePackages - a.vulnerablePackages)[0];
161
+ const mainText = main.vulnerablePackages
162
+ ? `${main.vulnerablePackages} vulnerable pkg(s)${many && main.branch ? ` on ${clean(main.branch, 40)}` : ""}`
163
+ : main.error && !many
164
+ ? c.dim(clean(main.error, 80))
165
+ : many && main.branch
166
+ ? c.dim(`clean on ${clean(main.branch, 40)}`)
167
+ : c.dim("clean");
168
+ const head = worse ? `${mainText} ${c.yellow(`· ${worse.vulnerablePackages} on ${clean(worse.branch ?? "another checkout", 40)}`)}` : mainText;
169
+ const where = many ? c.dim(`${g.checkouts.length} checkouts`) : c.dim(tildify(g.checkouts[0].path));
170
+ const more = hiddenCheckouts ? c.dim(` +${hiddenCheckouts} inactive`) : "";
171
+ const dimIf = (s) => (g.active ? s : c.dim(s));
172
+ out.write(`${badge(g.worstSeverity, firstError)} ${c.bold(dimIf(clean(g.label, 80)))} ${head} ${where}${more}\n`);
173
+ if (!many)
174
+ continue;
175
+ g.checkouts.forEach((k, i) => {
176
+ const branch = k.branch ? c.cyan(clean(k.branch, 50)) : c.dim("(no branch)");
177
+ const kind = (k.worktree ? c.dim(" worktree") : "") + (k.active ? "" : c.dim(` inactive${k.lastActivity ? ` ${ago(k.lastActivity)}` : ""}`));
178
+ const detail = k.advisories ? `${k.vulnerablePackages} vulnerable` : k.error ? c.dim(clean(k.error, 60)) : c.dim("clean");
179
+ const tree = i === g.checkouts.length - 1 ? "└" : "├";
180
+ out.write(` ${c.dim(tree)} ${branch}${kind} ${detail} ${c.dim(tildify(k.path))}${k.exists ? "" : c.dim(" (missing)")}\n`);
181
+ });
182
+ }
183
+ if (hiddenRepos)
184
+ out.write(c.dim(`${hiddenRepos} inactive repo(s) hidden (no git activity in ${api.getSettings().activeDays} days). Show them with --all.\n`));
185
+ return 0;
186
+ }
187
+ function ago(iso) {
188
+ const days = Math.floor((Date.now() - Date.parse(iso)) / 86_400_000);
189
+ return days < 1 ? "today" : days < 60 ? `${days}d ago` : `${Math.floor(days / 30)}mo ago`;
190
+ }
191
+ async function cmdSync(args) {
192
+ const { values } = parseArgs({ args, options: { ...JSON_OPT, background: { type: "boolean" } } });
193
+ let r;
194
+ try {
195
+ r = await api.sync({ notify: true });
196
+ }
197
+ catch (e) {
198
+ if (values.background && e instanceof api.ApiError && e.code === "busy")
199
+ return 0;
200
+ throw e;
201
+ }
202
+ try {
203
+ updateWatcher(api.watchTargets());
204
+ }
205
+ catch (e) {
206
+ err.write(`selfguard: could not update the lockfile watcher: ${clean(e.message, 200)}\n`);
207
+ }
208
+ if (values.background)
209
+ await maybeRemind(api.getAttention);
210
+ const { outcomes: _o, ...data } = r;
211
+ const total = data.news.reduce((n, p) => n + p.packages.length, 0);
212
+ if (values.json)
213
+ emit(data);
214
+ else if (values.background) {
215
+ out.write(`${new Date().toISOString()} sync: ${data.projects} project(s), ${data.discovered.length} discovered, ${total} newly vulnerable package(s)\n`);
216
+ }
217
+ else {
218
+ const c = paint(out);
219
+ if (data.discovered.length)
220
+ out.write(`Discovered ${data.discovered.length} new project(s) in your folders.\n`);
221
+ if (data.pruned.length)
222
+ out.write(c.dim(`Stopped tracking ${data.pruned.length} folder(s) that no longer exist.\n`));
223
+ out.write(`Synced ${data.projects} project(s). ${total ? c.red(`${total} newly vulnerable package(s).`) : c.green("Nothing new.")}\n`);
224
+ for (const p of data.news) {
225
+ const pkgs = p.packages.map((g) => `${clean(g.name)}@${clean(g.version)} ${c.dim(`(${g.advisories.length})`)}`);
226
+ out.write(` ${c.bold(tildify(p.path))}: ${pkgs.join(", ")}\n`);
227
+ }
228
+ for (const e of data.errors)
229
+ out.write(c.dim(` ! ${tildify(e.path)}: ${clean(e.error)}\n`));
230
+ }
231
+ return 0;
232
+ }
233
+ async function cmdCheck(args) {
234
+ const { values } = parseArgs({ args, options: { ...JSON_OPT, background: { type: "boolean" } } });
235
+ let r;
236
+ try {
237
+ r = await api.check({ notify: true });
238
+ }
239
+ catch (e) {
240
+ if (values.background && e instanceof api.ApiError && e.code === "busy")
241
+ return 0;
242
+ throw e;
243
+ }
244
+ if (values.background) {
245
+ if (r.rescanned.length)
246
+ out.write(`${new Date().toISOString()} check: re-scanned ${r.rescanned.join(", ")}\n`);
247
+ await maybeRemind(api.getAttention);
248
+ }
249
+ else if (values.json)
250
+ emit(r);
251
+ else
252
+ out.write(r.rescanned.length ? `Re-scanned ${r.rescanned.length} project(s) with changed lockfiles.\n` : "No lockfile changed.\n");
253
+ return 0;
254
+ }
255
+ async function cmdRemind(args) {
256
+ const { values } = parseArgs({ args, options: { ...JSON_OPT, "dry-run": { type: "boolean" } } });
257
+ const repos = api.getAttention();
258
+ const alert = buildReminder(repos);
259
+ const sent = values["dry-run"] ? false : await maybeRemind(repos, { force: true });
260
+ const serious = repos
261
+ .map((r) => ({ repo: r.label, packages: groupByPackage(r.findings.filter(needsAttention)) }))
262
+ .filter((r) => r.packages.length > 0);
263
+ if (values.json) {
264
+ emit({
265
+ sent,
266
+ notification: alert.kind === "none" ? null : { title: alert.title, subtitle: alert.subtitle, body: alert.body },
267
+ repos: serious.map((r) => ({
268
+ repo: r.repo,
269
+ packages: r.packages.map((g) => ({ name: g.name, version: g.version, worstSeverity: g.worst, upgradeTo: g.upgradeTo ?? null, direct: g.direct })),
270
+ })),
271
+ });
272
+ return 0;
273
+ }
274
+ const c = paint(out);
275
+ if (serious.length === 0) {
276
+ out.write(`${c.green("✓")} Nothing serious open in your active repos (malicious packages, critical vulnerabilities, or high ones in direct dependencies).\n`);
277
+ return 0;
278
+ }
279
+ for (const r of serious) {
280
+ out.write(`${c.bold(clean(r.repo, 80))}\n`);
281
+ for (const g of r.packages.slice(0, 6)) {
282
+ const fix = g.worst === "malicious" ? c.red("remove it") : g.upgradeTo ? c.green(`→ ${clean(g.upgradeTo, 40)}`) : c.yellow("no fix yet");
283
+ out.write(` ${severityLabel(g.worst, c)} ${clean(g.name, 100)}@${clean(g.version, 40)} ${c.dim(g.direct ? "direct" : "transitive")} ${fix}\n`);
284
+ }
285
+ if (r.packages.length > 6)
286
+ out.write(c.dim(` …and ${r.packages.length - 6} more (selfguard ui)\n`));
287
+ }
288
+ out.write(c.dim(sent ? "\nNotification sent.\n" : "\n"));
289
+ return 0;
290
+ }
291
+ function cmdSettings(args) {
292
+ const { values } = parseArgs({ args, options: { ...JSON_OPT, remind: { type: "string" }, "active-days": { type: "string" } } });
293
+ const patch = {};
294
+ if (values.remind !== undefined)
295
+ patch.remind = values.remind;
296
+ if (values["active-days"] !== undefined)
297
+ patch.activeDays = Number(values["active-days"]);
298
+ const s = Object.keys(patch).length ? api.updateSettings(patch) : api.getSettings();
299
+ if (values.json)
300
+ emit(s);
301
+ else {
302
+ out.write(`remind ${s.remind}${s.lastReminder ? paint(out).dim(` (last ${s.lastReminder})`) : ""}\n`);
303
+ out.write(`active-days ${s.activeDays}\n`);
304
+ if (s.lastSync)
305
+ out.write(paint(out).dim(`last sync ${s.lastSync}\n`));
306
+ }
307
+ return 0;
308
+ }
309
+ async function cmdUi(args) {
310
+ const { values } = parseArgs({
311
+ args,
312
+ options: { "no-open": { type: "boolean" }, port: { type: "string" }, serve: { type: "boolean" } },
313
+ });
314
+ const port = values.port === undefined ? 0 : Number(values.port);
315
+ if (!Number.isInteger(port) || port < 0 || port > 65535)
316
+ usage("--port must be a port number");
317
+ await runUi({ port, open: !values["no-open"], foreground: values.serve ?? false, log: (line) => out.write(`${line}\n`) });
318
+ return 0;
319
+ }
320
+ async function cmdDiscover(args) {
321
+ const { values } = parseArgs({ args, options: { ...JSON_OPT, "dry-run": { type: "boolean" } } });
322
+ const r = await api.discoverNew({ dryRun: values["dry-run"] ?? false });
323
+ if (values.json) {
324
+ emit(r);
325
+ return 0;
326
+ }
327
+ const c = paint(out);
328
+ if (r.found.length === 0)
329
+ out.write("No new projects found.\n");
330
+ for (const p of r.found) {
331
+ const note = p.lockType ? c.dim(` (${p.lockType})`) : c.dim(" (no lockfile)");
332
+ out.write(` ${c.green("+")} ${tildify(p.path)}${note}\n`);
333
+ }
334
+ if (r.dryRun && r.found.length)
335
+ out.write(`${r.found.length} new project(s) (dry run, nothing registered).\n`);
336
+ else if (r.registered)
337
+ out.write(`Registered ${r.registered} project(s); ${r.withVulnerabilities} with vulnerabilities. See: selfguard status\n`);
338
+ return 0;
339
+ }
340
+ function printRoots(r) {
341
+ const c = paint(out);
342
+ if (r.roots.length === 0)
343
+ out.write("No folders configured. Run: selfguard setup\n");
344
+ for (const root of r.roots)
345
+ out.write(`${tildify(root.path)} ${c.dim(`depth ${root.depth}`)}\n`);
346
+ if (r.exclude.length) {
347
+ out.write(c.dim("excluded:\n"));
348
+ for (const e of r.exclude)
349
+ out.write(c.dim(` ${tildify(e)}\n`));
350
+ }
351
+ }
352
+ function cmdRoots(args) {
353
+ const { values, positionals } = parseArgs({ args, allowPositionals: true, options: { ...JSON_OPT, depth: { type: "string" } } });
354
+ const [action, target] = positionals;
355
+ let r;
356
+ if (action === "add") {
357
+ if (!target)
358
+ usage("usage: selfguard roots add <dir> [--depth N]");
359
+ const depth = values.depth === undefined ? undefined : Number(values.depth);
360
+ if (depth !== undefined && !Number.isInteger(depth))
361
+ usage("--depth must be an integer");
362
+ r = api.addRoot(target, depth);
363
+ }
364
+ else if (action === "remove") {
365
+ if (!target)
366
+ usage("usage: selfguard roots remove <dir>");
367
+ r = api.removeRoot(target);
368
+ }
369
+ else if (action === undefined || action === "list") {
370
+ r = api.listRoots();
371
+ }
372
+ else {
373
+ usage("usage: selfguard roots [add <dir> [--depth N] | remove <dir>]");
374
+ }
375
+ if (values.json)
376
+ emit(r);
377
+ else
378
+ printRoots(r);
379
+ return 0;
380
+ }
381
+ function cmdWhere(args) {
382
+ const { values, positionals } = parseArgs({ args, allowPositionals: true, options: JSON_OPT });
383
+ const name = positionals[0];
384
+ if (!name)
385
+ usage("usage: selfguard where <package>");
386
+ const r = api.whereUsed(name);
387
+ if (values.json) {
388
+ emit(r);
389
+ return 0;
390
+ }
391
+ const c = paint(out);
392
+ if (r.usedIn.length === 0)
393
+ out.write(`${clean(name)} is not used in any tracked project.\n`);
394
+ for (const p of r.usedIn) {
395
+ const list = p.versions
396
+ .map((v) => `${v.vulnerable ? c.red(clean(v.version)) : clean(v.version)}${v.direct ? "" : c.dim(" (transitive)")}`)
397
+ .join(", ");
398
+ out.write(`${c.bold(tildify(p.path))} ${list}\n`);
399
+ }
400
+ return 0;
401
+ }
402
+ async function cmdVuln(args) {
403
+ const { values, positionals } = parseArgs({ args, allowPositionals: true, options: JSON_OPT });
404
+ const id = positionals[0];
405
+ if (!id)
406
+ usage("usage: selfguard vuln <advisory-id>");
407
+ const v = await api.getVulnerability(id);
408
+ if (values.json) {
409
+ emit(v);
410
+ return 0;
411
+ }
412
+ const c = paint(out);
413
+ out.write(`${severityLabel(v.severity, c)} ${c.bold(v.id)}${v.aliases.length ? c.dim(` (${v.aliases.join(", ")})`) : ""}\n`);
414
+ if (v.summary)
415
+ out.write(`${v.summary}\n`);
416
+ for (const a of v.affected) {
417
+ const ranges = a.ranges.map((e) => Object.entries(e).map(([k, x]) => `${k} ${clean(x)}`).join("")).join(", ");
418
+ const versions = a.versions.length ? ` versions ${a.versions.slice(0, 10).join(", ")}` : "";
419
+ out.write(c.dim(` ${a.package}: ${ranges}${versions}\n`));
420
+ }
421
+ out.write(v.affectsYourProjects.length
422
+ ? c.red(`Affects ${v.affectsYourProjects.length} place(s) in your projects:\n`)
423
+ : c.green("None of your tracked projects are affected.\n"));
424
+ for (const p of v.affectsYourProjects) {
425
+ out.write(` ${tildify(p.path)} ${clean(p.package)}@${clean(p.version)}${p.fixedIn ? c.green(` → ${clean(p.fixedIn)}`) : ""}\n`);
426
+ }
427
+ out.write(c.dim(`${v.url}\n`));
428
+ return 0;
429
+ }
430
+ function cmdForget(args) {
431
+ const { values, positionals } = parseArgs({
432
+ args,
433
+ allowPositionals: true,
434
+ options: { ...JSON_OPT, "keep-discoverable": { type: "boolean" } },
435
+ });
436
+ const r = api.forget(positionals[0] ?? ".", { exclude: !values["keep-discoverable"] });
437
+ if (values.json)
438
+ emit(r);
439
+ else {
440
+ out.write(r.wasTracked ? `Stopped tracking ${tildify(r.path)}` : `${tildify(r.path)} was not tracked`);
441
+ out.write(r.excluded ? " (excluded from discovery; undo with selfguard unexclude)\n" : "\n");
442
+ if (r.alsoForgotten.length)
443
+ out.write(`Also stopped tracking ${r.alsoForgotten.length} project(s) inside it.\n`);
444
+ }
445
+ return 0;
446
+ }
447
+ function cmdUnexclude(args) {
448
+ const { values, positionals } = parseArgs({ args, allowPositionals: true, options: JSON_OPT });
449
+ if (!positionals[0])
450
+ usage("usage: selfguard unexclude <dir>");
451
+ const r = api.unexclude(positionals[0]);
452
+ if (values.json)
453
+ emit(r);
454
+ else
455
+ out.write(r.removed ? "Removed from exclusions.\n" : "That path was not excluded.\n");
456
+ return 0;
457
+ }
458
+ function cmdSchedule(args) {
459
+ const { values, positionals } = parseArgs({
460
+ args,
461
+ allowPositionals: true,
462
+ options: { ...JSON_OPT, every: { type: "string", default: "6h" } },
463
+ });
464
+ switch (positionals[0]) {
465
+ case "on": {
466
+ const seconds = parseInterval(values.every ?? "6h");
467
+ const targets = api.watchTargets();
468
+ const plist = scheduleOn(seconds, targets);
469
+ if (values.json)
470
+ emit({ enabled: true, intervalSeconds: seconds, plist, node: process.execPath, watchedPaths: targets.length });
471
+ else {
472
+ out.write(`Background sync every ${values.every}, plus instant checks when a lockfile changes. (${tildify(plist)})\n`);
473
+ out.write(`Note: it runs with ${process.execPath}; re-run this if you change Node versions.\n`);
474
+ }
475
+ return 0;
476
+ }
477
+ case "off": {
478
+ const was = scheduleOff();
479
+ if (values.json)
480
+ emit({ enabled: false, wasEnabled: was });
481
+ else
482
+ out.write(was ? "Background sync and lockfile watcher disabled.\n" : "Background sync was not enabled.\n");
483
+ return 0;
484
+ }
485
+ case "status":
486
+ case undefined: {
487
+ const s = scheduleStatus();
488
+ if (values.json)
489
+ emit(s);
490
+ else {
491
+ out.write(s.loaded ? `enabled (${tildify(s.plist)})\n` : s.installed ? "installed but not loaded\n" : "disabled\n");
492
+ if (s.loaded)
493
+ out.write(s.watcher.loaded ? `lockfile watcher: ${s.watcher.paths} path(s)\n` : "lockfile watcher: not running (run selfguard sync)\n");
494
+ }
495
+ return 0;
496
+ }
497
+ default:
498
+ usage("usage: selfguard schedule on [--every 6h] | off | status");
499
+ }
500
+ }
501
+ async function main(argv) {
502
+ const [cmd, ...rest] = argv;
503
+ switch (cmd) {
504
+ case "setup":
505
+ if (!isInteractive())
506
+ usage("setup needs an interactive terminal; use `selfguard roots add <dir>` instead");
507
+ return runSetup();
508
+ case "init":
509
+ if (rest[0] !== "zsh")
510
+ usage("only zsh is supported for now: selfguard init zsh");
511
+ out.write(ZSH_HOOK);
512
+ return 0;
513
+ case "hook-enter": {
514
+ const { values } = parseArgs({ args: rest, options: { dir: { type: "string" }, prompt: { type: "boolean" } }, strict: false });
515
+ hookEnter(typeof values.dir === "string" ? values.dir : process.cwd(), values.prompt === true);
516
+ return 0;
517
+ }
518
+ case "scan":
519
+ return cmdScan(rest);
520
+ case "report":
521
+ return cmdReport(rest);
522
+ case "status":
523
+ return cmdStatus(rest);
524
+ case "sync":
525
+ return cmdSync(rest);
526
+ case "check":
527
+ return cmdCheck(rest);
528
+ case "remind":
529
+ return cmdRemind(rest);
530
+ case "settings":
531
+ return cmdSettings(rest);
532
+ case "ui":
533
+ return cmdUi(rest);
534
+ case "discover":
535
+ return cmdDiscover(rest);
536
+ case "roots":
537
+ return cmdRoots(rest);
538
+ case "where":
539
+ return cmdWhere(rest);
540
+ case "vuln":
541
+ return cmdVuln(rest);
542
+ case "forget":
543
+ return cmdForget(rest);
544
+ case "unexclude":
545
+ return cmdUnexclude(rest);
546
+ case "schedule":
547
+ return cmdSchedule(rest);
548
+ case "mcp":
549
+ await runMcpServer();
550
+ return 0;
551
+ case "guide":
552
+ out.write(GUIDE);
553
+ return 0;
554
+ case "-v":
555
+ case "--version":
556
+ case "version":
557
+ if (jsonMode)
558
+ emit({ version: VERSION });
559
+ else
560
+ out.write(`${VERSION}\n`);
561
+ return 0;
562
+ case undefined:
563
+ case "-h":
564
+ case "--help":
565
+ case "help":
566
+ out.write(`${HELP}\n`);
567
+ return 0;
568
+ default:
569
+ usage(`unknown command "${clean(cmd, 40)}". See selfguard --help`);
570
+ }
571
+ }
572
+ function errorCode(e) {
573
+ if (e instanceof api.ApiError)
574
+ return e.code;
575
+ if (e instanceof UsageError)
576
+ return "usage";
577
+ const nodeCode = e?.code;
578
+ if (typeof nodeCode === "string" && nodeCode.startsWith("ERR_PARSE_ARGS"))
579
+ return "usage";
580
+ return "error";
581
+ }
582
+ main(process.argv.slice(2)).then((code) => {
583
+ process.exitCode = code;
584
+ }, (e) => {
585
+ const message = clean(e instanceof Error ? e.message : String(e), 500);
586
+ if (jsonMode)
587
+ emit({ error: { code: errorCode(e), message } });
588
+ else
589
+ err.write(`selfguard: ${message}\n`);
590
+ process.exitCode = 2;
591
+ });
package/dist/config.js ADDED
@@ -0,0 +1,76 @@
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
+ export const DEFAULT_ACTIVE_DAYS = 30;
9
+ export const REMIND_VALUES = ["daily", "weekly", "off"];
10
+ const configFile = () => join(stateRoot(), "config.json");
11
+ export function loadConfig() {
12
+ const raw = readJsonFile(configFile());
13
+ const roots = [];
14
+ if (raw && isObject(raw) && Array.isArray(raw.roots)) {
15
+ for (const r of raw.roots) {
16
+ if (isObject(r) && typeof r.path === "string") {
17
+ const depth = typeof r.depth === "number" ? clampDepth(r.depth) : DEFAULT_DEPTH;
18
+ roots.push({ path: r.path, depth });
19
+ }
20
+ }
21
+ }
22
+ const exclude = raw && isObject(raw) && Array.isArray(raw.exclude) ? raw.exclude.filter((e) => typeof e === "string") : [];
23
+ const remind = raw && isObject(raw) && REMIND_VALUES.includes(raw.remind) ? raw.remind : "daily";
24
+ const activeDays = raw && isObject(raw) && typeof raw.activeDays === "number" && raw.activeDays >= 1
25
+ ? Math.min(365, Math.trunc(raw.activeDays))
26
+ : DEFAULT_ACTIVE_DAYS;
27
+ return { schema: 1, roots, exclude, remind, activeDays };
28
+ }
29
+ export function isExcluded(path, exclude) {
30
+ return exclude.some((e) => path === e || path.startsWith(e.endsWith("/") ? e : `${e}/`));
31
+ }
32
+ export function saveConfig(cfg) {
33
+ atomicWrite(configFile(), `${JSON.stringify(cfg, null, 2)}\n`);
34
+ }
35
+ export function clampDepth(n) {
36
+ return Math.max(1, Math.min(MAX_DEPTH, Math.trunc(n)));
37
+ }
38
+ export function expandHome(p) {
39
+ const t = p.trim();
40
+ if (t === "~")
41
+ return homedir();
42
+ if (t.startsWith("~/"))
43
+ return join(homedir(), t.slice(2));
44
+ return t;
45
+ }
46
+ /** Absolute, symlink-resolved directory path, or null if it isn't a directory. */
47
+ export function normalizeDir(p) {
48
+ try {
49
+ const abs = resolve(expandHome(p));
50
+ if (!existsSync(abs) || !statSync(abs).isDirectory())
51
+ return null;
52
+ return realpathSync(abs);
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ /** Common places people keep code, shown as suggestions in `selfguard setup`. */
59
+ const CANDIDATES = [
60
+ "repository", "repositories", "repos", "code", "Code", "dev", "Developer", "development",
61
+ "projects", "Projects", "src", "workspace", "work", "git", "github", "GitHub",
62
+ "Documents/GitHub", "Documents/code", "Documents/projects", "Sites",
63
+ ];
64
+ export function candidateRoots() {
65
+ const seen = new Set();
66
+ const out = [];
67
+ for (const c of CANDIDATES) {
68
+ const dir = normalizeDir(join(homedir(), c));
69
+ // macOS is case-insensitive: ~/code and ~/Code are the same folder
70
+ if (dir && !seen.has(dir.toLowerCase())) {
71
+ seen.add(dir.toLowerCase());
72
+ out.push(dir);
73
+ }
74
+ }
75
+ return out;
76
+ }