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/LICENSE +21 -0
- package/README.md +154 -2
- package/dist/alerts.js +96 -0
- package/dist/api.js +327 -0
- package/dist/cli.js +462 -0
- package/dist/config.js +70 -0
- package/dist/discover.js +73 -0
- package/dist/fsutil.js +66 -0
- package/dist/guide.js +53 -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 +212 -0
- package/dist/notify.js +43 -0
- package/dist/osv.js +142 -0
- package/dist/paths.js +13 -0
- package/dist/repoid.js +145 -0
- package/dist/report.js +126 -0
- package/dist/sanitize.js +12 -0
- package/dist/scan.js +168 -0
- package/dist/schedule.js +82 -0
- package/dist/semver.js +65 -0
- package/dist/setup.js +233 -0
- package/dist/store.js +37 -0
- package/dist/tui.js +132 -0
- package/dist/version.js +2 -0
- package/package.json +47 -4
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
|
+
}
|
package/dist/discover.js
ADDED
|
@@ -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
|
+
}
|