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/LICENSE +21 -0
- package/README.md +133 -3
- package/dist/activity.js +12 -0
- package/dist/alerts.js +189 -0
- package/dist/announce.js +136 -0
- package/dist/api.js +494 -0
- package/dist/cli.js +591 -0
- package/dist/config.js +76 -0
- package/dist/discover.js +73 -0
- package/dist/events.js +61 -0
- package/dist/fsutil.js +66 -0
- package/dist/guide.js +61 -0
- package/dist/hook.js +114 -0
- package/dist/lockfile/bun.js +113 -0
- package/dist/lockfile/common.js +79 -0
- package/dist/lockfile/detect.js +42 -0
- package/dist/lockfile/index.js +22 -0
- package/dist/lockfile/npm.js +77 -0
- package/dist/lockfile/pnpm.js +121 -0
- package/dist/lockfile/types.js +1 -0
- package/dist/lockfile/yaml.js +140 -0
- package/dist/lockfile/yarn.js +136 -0
- package/dist/match.js +81 -0
- package/dist/mcp.js +248 -0
- package/dist/notify.js +43 -0
- package/dist/osv.js +142 -0
- package/dist/paths.js +16 -0
- package/dist/repoid.js +171 -0
- package/dist/report.js +126 -0
- package/dist/sanitize.js +12 -0
- package/dist/scan.js +197 -0
- package/dist/schedule.js +142 -0
- package/dist/semver.js +65 -0
- package/dist/setup.js +236 -0
- package/dist/state.js +19 -0
- package/dist/store.js +37 -0
- package/dist/tui.js +132 -0
- package/dist/ui/server.js +325 -0
- package/dist/ui/static/app.css +263 -0
- package/dist/ui/static/app.js +482 -0
- package/dist/ui/static/favicon.svg +1 -0
- package/dist/ui/static/index.html +47 -0
- package/dist/version.js +2 -0
- package/package.json +45 -4
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
|
+
}
|