selfguard 0.1.0 → 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/api.js CHANGED
@@ -5,7 +5,9 @@
5
5
  */
6
6
  import { existsSync } from "node:fs";
7
7
  import { resolve } from "node:path";
8
- import { clampDepth, DEFAULT_DEPTH, isExcluded, loadConfig, normalizeDir, saveConfig, } from "./config.js";
8
+ import { activityOf } from "./activity.js";
9
+ import { announce } from "./announce.js";
10
+ import { clampDepth, DEFAULT_DEPTH, isExcluded, loadConfig, normalizeDir, REMIND_VALUES, saveConfig, } from "./config.js";
9
11
  import { discoverAll } from "./discover.js";
10
12
  import { tryLock } from "./fsutil.js";
11
13
  import { findProject } from "./lockfile/detect.js";
@@ -14,7 +16,8 @@ import { getVulns } from "./osv.js";
14
16
  import { countBySeverity, groupByPackage, worstSeverity } from "./report.js";
15
17
  import { repoIdentity } from "./repoid.js";
16
18
  import { clean } from "./sanitize.js";
17
- import { scanProjects } from "./scan.js";
19
+ import { lockChanged, scanProjects } from "./scan.js";
20
+ import { loadState, updateState } from "./state.js";
18
21
  import { forgetRepo, listRepos, loadRepo, saveRepo } from "./store.js";
19
22
  export class ApiError extends Error {
20
23
  code;
@@ -25,10 +28,13 @@ export class ApiError extends Error {
25
28
  }
26
29
  const advisoryUrl = (id) => `https://osv.dev/vulnerability/${encodeURIComponent(id)}`;
27
30
  // ---------- helpers ----------
28
- function summarize(rec) {
31
+ function summarize(rec, activeDays = loadConfig().activeDays) {
32
+ const exists = existsSync(rec.path);
33
+ const repo = exists ? repoIdentity(rec.path) : null;
34
+ const activity = exists ? activityOf(repo, activeDays) : { active: false, lastActivity: null };
29
35
  return {
30
36
  path: rec.path,
31
- exists: existsSync(rec.path),
37
+ exists,
32
38
  lockfile: rec.lockfile,
33
39
  lockType: rec.lockType,
34
40
  packages: rec.deps.length,
@@ -39,7 +45,9 @@ function summarize(rec) {
39
45
  vulnerablePackages: groupByPackage(rec.findings).length,
40
46
  advisories: rec.findings.length,
41
47
  bySeverity: countBySeverity(rec.findings),
42
- repo: existsSync(rec.path) ? repoIdentity(rec.path) : null,
48
+ repo,
49
+ active: activity.active,
50
+ lastActivity: activity.lastActivity,
43
51
  };
44
52
  }
45
53
  export function toReport(rec) {
@@ -78,7 +86,8 @@ function resolveProject(path) {
78
86
  export function getStatus(opts = {}) {
79
87
  const rank = (w) => (w ? severityRank(w) : 99);
80
88
  const records = listRepos();
81
- const summaries = records.map(summarize);
89
+ const activeDays = loadConfig().activeDays;
90
+ const summaries = records.map((r) => summarize(r, activeDays));
82
91
  const groups = new Map();
83
92
  summaries.forEach((p, i) => {
84
93
  const rec = records[i];
@@ -86,38 +95,114 @@ export function getStatus(opts = {}) {
86
95
  let g = groups.get(key);
87
96
  if (!g) {
88
97
  const label = p.repo?.label ?? (p.path.split("/").pop() || p.path);
89
- g = { group: { key, label, worstSeverity: null, vulnerablePackages: 0, checkouts: [] }, pkgs: new Set(), findings: [] };
98
+ g = {
99
+ group: { key, label, active: false, worstSeverity: null, vulnerablePackages: 0, inactiveCheckouts: 0, checkouts: [] },
100
+ active: [],
101
+ all: [],
102
+ };
90
103
  groups.set(key, g);
91
104
  }
92
105
  g.group.checkouts.push({
93
106
  path: p.path,
94
107
  branch: p.repo?.branch ?? null,
95
108
  worktree: p.repo?.worktree ?? false,
109
+ orphaned: p.repo?.orphaned ?? false,
96
110
  exists: p.exists,
111
+ active: p.active,
112
+ lastActivity: p.lastActivity,
97
113
  worstSeverity: p.worstSeverity,
98
114
  vulnerablePackages: p.vulnerablePackages,
99
115
  advisories: p.advisories,
100
116
  error: p.error,
101
117
  });
102
- for (const f of rec.findings)
103
- g.pkgs.add(`${f.name}@${f.version}`);
104
- g.findings.push(...rec.findings);
118
+ if (p.active)
119
+ g.active.push(...rec.findings);
120
+ else
121
+ g.group.inactiveCheckouts++;
122
+ g.all.push(...rec.findings);
105
123
  });
106
124
  const repos = [...groups.values()]
107
- .map(({ group, pkgs, findings }) => {
108
- group.worstSeverity = worstSeverity(findings);
109
- group.vulnerablePackages = pkgs.size;
110
- // main checkout first, then riskiest worktrees
111
- group.checkouts.sort((a, b) => Number(a.worktree) - Number(b.worktree) || rank(a.worstSeverity) - rank(b.worstSeverity) || a.path.localeCompare(b.path));
125
+ .map(({ group, active, all }) => {
126
+ group.active = group.checkouts.some((k) => k.active);
127
+ const counted = group.active ? active : all;
128
+ group.worstSeverity = worstSeverity(counted);
129
+ group.vulnerablePackages = new Set(counted.map((f) => `${f.name}@${f.version}`)).size;
130
+ // active first, main checkout first, then riskiest
131
+ group.checkouts.sort((a, b) => Number(b.active) - Number(a.active) ||
132
+ Number(a.worktree) - Number(b.worktree) ||
133
+ rank(a.worstSeverity) - rank(b.worstSeverity) ||
134
+ a.path.localeCompare(b.path));
112
135
  return group;
113
136
  })
114
- .filter((g) => !opts.onlyVulnerable || g.vulnerablePackages > 0)
115
- .sort((a, b) => rank(a.worstSeverity) - rank(b.worstSeverity) || b.vulnerablePackages - a.vulnerablePackages || a.label.localeCompare(b.label));
137
+ .filter((g) => !opts.onlyVulnerable || g.checkouts.some((k) => k.vulnerablePackages > 0))
138
+ .sort((a, b) => Number(b.active) - Number(a.active) ||
139
+ rank(a.worstSeverity) - rank(b.worstSeverity) ||
140
+ b.vulnerablePackages - a.vulnerablePackages ||
141
+ a.label.localeCompare(b.label));
116
142
  const projects = summaries
117
143
  .filter((p) => !opts.onlyVulnerable || p.advisories > 0)
118
144
  .sort((a, b) => rank(a.worstSeverity) - rank(b.worstSeverity) || b.advisories - a.advisories || a.path.localeCompare(b.path));
119
145
  return { repos, projects };
120
146
  }
147
+ /**
148
+ * Input for reminders: per repo, the findings of the checkout that represents it — the main
149
+ * clone if it is active, otherwise the most recently active checkout. Feature worktrees
150
+ * branched from an old base get fixed when they merge, so they don't count twice.
151
+ */
152
+ export function getAttention() {
153
+ const activeDays = loadConfig().activeDays;
154
+ const best = new Map();
155
+ for (const rec of listRepos()) {
156
+ if (!existsSync(rec.path))
157
+ continue;
158
+ const repo = repoIdentity(rec.path);
159
+ const act = activityOf(repo, activeDays);
160
+ if (!act.active)
161
+ continue;
162
+ const key = repo?.key ?? `path:${rec.path}`;
163
+ const cand = { rec, repo, main: !(repo?.worktree ?? false), at: act.lastActivity ? Date.parse(act.lastActivity) : 0 };
164
+ const cur = best.get(key);
165
+ if (!cur || (cand.main && !cur.main) || (cand.main === cur.main && cand.at > cur.at))
166
+ best.set(key, cand);
167
+ }
168
+ return [...best.entries()]
169
+ .filter(([, b]) => b.rec.findings.length > 0)
170
+ .map(([key, b]) => ({
171
+ key,
172
+ label: b.repo?.label ?? (b.rec.path.split("/").pop() || b.rec.path),
173
+ path: b.rec.path,
174
+ branch: b.repo?.branch ?? null,
175
+ findings: b.rec.findings,
176
+ }));
177
+ }
178
+ /** All vulnerable packages of one repo group, merged across its checkouts (no network). */
179
+ export function getRepoDetail(key) {
180
+ const group = getStatus().repos.find((g) => g.key === key);
181
+ if (!group)
182
+ throw new ApiError("not_found", "unknown repo");
183
+ const byPkg = new Map();
184
+ for (const k of group.checkouts) {
185
+ const rec = loadRepo(k.path);
186
+ if (!rec)
187
+ continue;
188
+ for (const p of toReport(rec).vulnerable) {
189
+ const id = `${p.name}@${p.version}`;
190
+ let entry = byPkg.get(id);
191
+ if (!entry) {
192
+ entry = { ...p, checkouts: [] };
193
+ byPkg.set(id, entry);
194
+ }
195
+ entry.checkouts.push({ path: k.path, branch: k.branch, active: k.active });
196
+ }
197
+ }
198
+ const rank = (p) => (p.checkouts.some((c) => c.active) ? 0 : 1);
199
+ const packages = [...byPkg.values()].sort((a, b) => rank(a) - rank(b) ||
200
+ severityRank(a.worstSeverity) - severityRank(b.worstSeverity) ||
201
+ Number(a.dev) - Number(b.dev) ||
202
+ Number(b.direct) - Number(a.direct) ||
203
+ a.name.localeCompare(b.name));
204
+ return { key: group.key, label: group.label, checkouts: group.checkouts, packages };
205
+ }
121
206
  /** Last stored report for a project (no network). */
122
207
  export function getReport(path) {
123
208
  const project = resolveProject(path);
@@ -126,7 +211,7 @@ export function getReport(path) {
126
211
  throw new ApiError("not_tracked", `${project.root} is not tracked yet; scan it first`);
127
212
  return toReport(rec);
128
213
  }
129
- export async function scanPath(path) {
214
+ export async function scanPath(path, opts = {}) {
130
215
  const project = resolveProject(path);
131
216
  const release = tryLock(`scan:${project.root}`);
132
217
  if (!release)
@@ -134,13 +219,14 @@ export async function scanPath(path) {
134
219
  try {
135
220
  const [o] = await scanProjects([project]);
136
221
  saveRepo(o.record);
222
+ await announce([o], { notify: opts.notify ?? false });
137
223
  return { project: toReport(o.record), firstScan: o.firstScan, newAdvisories: o.newFindings.length };
138
224
  }
139
225
  finally {
140
226
  release();
141
227
  }
142
228
  }
143
- export async function sync() {
229
+ export async function sync(opts = {}) {
144
230
  const release = tryLock("sync");
145
231
  if (!release)
146
232
  throw new ApiError("busy", "a sync is already running");
@@ -169,6 +255,8 @@ export async function sync() {
169
255
  const outcomes = await scanProjects(projects);
170
256
  for (const o of outcomes)
171
257
  saveRepo(o.record);
258
+ await announce(outcomes, { notify: opts.notify ?? false });
259
+ updateState({ lastSync: new Date().toISOString() });
172
260
  return {
173
261
  projects: projects.length,
174
262
  discovered: discovered.map((p) => p.root),
@@ -204,6 +292,7 @@ export async function discoverNew(opts = {}) {
204
292
  const outcomes = await scanProjects(fresh);
205
293
  for (const o of outcomes)
206
294
  saveRepo(o.record);
295
+ await announce(outcomes, { notify: false });
207
296
  return {
208
297
  dryRun: false,
209
298
  found,
@@ -282,6 +371,84 @@ export function unexclude(path) {
282
371
  saveConfig(cfg);
283
372
  return { roots: cfg.roots, exclude: cfg.exclude, removed: cfg.exclude.length < before };
284
373
  }
374
+ /**
375
+ * Re-scan only tracked projects whose lockfile changed (cheap stat first). Run by the
376
+ * lockfile watcher after `git pull`, installs or branch switches.
377
+ */
378
+ export async function check(opts = {}) {
379
+ const release = tryLock("check");
380
+ if (!release)
381
+ throw new ApiError("busy", "a check is already running");
382
+ try {
383
+ const cfg = loadConfig();
384
+ const changed = [];
385
+ for (const r of listRepos()) {
386
+ if (!existsSync(r.path) || isExcluded(r.path, cfg.exclude))
387
+ continue;
388
+ const p = findProject(r.path);
389
+ if (p && p.root === r.path && p.lockfile && lockChanged(r, p))
390
+ changed.push(p);
391
+ }
392
+ updateState({ lastCheck: new Date().toISOString() });
393
+ if (changed.length === 0)
394
+ return { rescanned: [], errors: [] };
395
+ const outcomes = await scanProjects(changed);
396
+ for (const o of outcomes)
397
+ saveRepo(o.record);
398
+ await announce(outcomes, { notify: opts.notify ?? false });
399
+ return {
400
+ rescanned: outcomes.map((o) => o.record.path),
401
+ errors: outcomes.filter((o) => o.record.error).map((o) => ({ path: o.record.path, error: o.record.error })),
402
+ };
403
+ }
404
+ finally {
405
+ release();
406
+ }
407
+ }
408
+ /** Paths for the lockfile watcher: lockfile + folder of active checkouts, most recent first. */
409
+ export function watchTargets() {
410
+ const cfg = loadConfig();
411
+ const items = [];
412
+ for (const r of listRepos()) {
413
+ if (!existsSync(r.path) || isExcluded(r.path, cfg.exclude))
414
+ continue;
415
+ const act = activityOf(repoIdentity(r.path), cfg.activeDays);
416
+ if (!act.active)
417
+ continue;
418
+ const paths = [r.path];
419
+ if (r.lockfile && existsSync(r.lockfile))
420
+ paths.push(r.lockfile);
421
+ items.push({ paths, at: act.lastActivity ? Date.parse(act.lastActivity) : 0 });
422
+ }
423
+ return items.sort((a, b) => b.at - a.at).flatMap((i) => i.paths);
424
+ }
425
+ export function getSettings() {
426
+ const cfg = loadConfig();
427
+ const st = loadState();
428
+ return {
429
+ remind: cfg.remind,
430
+ activeDays: cfg.activeDays,
431
+ lastReminder: st.lastReminder ?? null,
432
+ lastSync: st.lastSync ?? null,
433
+ lastCheck: st.lastCheck ?? null,
434
+ };
435
+ }
436
+ export function updateSettings(patch) {
437
+ const cfg = loadConfig();
438
+ if (patch.remind !== undefined) {
439
+ if (!REMIND_VALUES.includes(patch.remind))
440
+ throw new ApiError("invalid", "remind must be daily, weekly or off");
441
+ cfg.remind = patch.remind;
442
+ }
443
+ if (patch.activeDays !== undefined) {
444
+ const n = patch.activeDays;
445
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > 365)
446
+ throw new ApiError("invalid", "activeDays must be an integer between 1 and 365");
447
+ cfg.activeDays = n;
448
+ }
449
+ saveConfig(cfg);
450
+ return getSettings();
451
+ }
285
452
  export async function getVulnerability(id) {
286
453
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id))
287
454
  throw new ApiError("invalid", "invalid advisory id");
package/dist/cli.js CHANGED
@@ -1,18 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from "node:util";
3
- import { buildAlert, deliver } from "./alerts.js";
3
+ import { buildReminder, needsAttention } from "./alerts.js";
4
+ import { maybeRemind } from "./announce.js";
4
5
  import * as api from "./api.js";
5
6
  import { GUIDE } from "./guide.js";
6
7
  import { hookEnter, ZSH_HOOK } from "./hook.js";
7
8
  import { findProject } from "./lockfile/detect.js";
8
9
  import { runMcpServer } from "./mcp.js";
9
- import { paint, renderFindings, severityLabel, tildify, worstSeverity } from "./report.js";
10
- import { repoIdentity } from "./repoid.js";
10
+ import { groupByPackage, paint, renderFindings, severityLabel, tildify } from "./report.js";
11
11
  import { clean } from "./sanitize.js";
12
- import { parseInterval, scheduleOff, scheduleOn, scheduleStatus } from "./schedule.js";
12
+ import { parseInterval, scheduleOff, scheduleOn, scheduleStatus, updateWatcher } from "./schedule.js";
13
13
  import { runSetup } from "./setup.js";
14
- import { findingKey, loadRepo } from "./store.js";
14
+ import { loadRepo } from "./store.js";
15
15
  import { isInteractive } from "./tui.js";
16
+ import { runUi } from "./ui/server.js";
16
17
  import { VERSION } from "./version.js";
17
18
  const HELP = `selfguard ${VERSION} — keeps an eye on the dependencies of every repo you work on
18
19
 
@@ -20,14 +21,25 @@ Setup:
20
21
  selfguard setup interactive: choose the folders where your repos live
21
22
  selfguard init zsh print the shell hook (eval "$(selfguard init zsh)" in ~/.zshrc)
22
23
  selfguard schedule on [--every 6h] | off | status
23
- periodic sync with macOS notifications (launchd)
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)
24
34
 
25
35
  Projects:
26
- selfguard status [--vulnerable] [--flat]
27
- tracked repos, riskiest first (worktrees/clones grouped)
36
+ selfguard status [--vulnerable] [--flat] [--all]
37
+ tracked repos, riskiest first (worktrees/clones grouped;
38
+ inactive checkouts hidden unless --all)
28
39
  selfguard scan [dir] [-v] scan a project now (exit 1 if vulnerable) and track it
29
40
  selfguard report [dir] [-v] last scan results, no network
30
41
  selfguard sync find new repos in your folders + re-check everything
42
+ selfguard check re-scan only projects whose lockfile changed
31
43
  selfguard where <package> which projects use <package>, which versions
32
44
  selfguard vuln <id> details of an advisory and which projects it affects
33
45
 
@@ -59,20 +71,11 @@ function emit(data) {
59
71
  function usage(message) {
60
72
  throw new UsageError(message);
61
73
  }
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
74
  /** Detached scan started by the shell hook: log + notify, never print to a terminal. */
68
75
  async function backgroundScan(path) {
69
76
  const stamp = new Date().toISOString();
70
77
  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 }]);
78
+ const r = await api.scanPath(path, { notify: true });
76
79
  out.write(`${stamp} scan ${r.project.path}: ${r.project.advisories} finding(s)${r.project.error ? ` (${r.project.error})` : ""}\n`);
77
80
  }
78
81
  catch (e) {
@@ -111,7 +114,10 @@ function cmdReport(args) {
111
114
  return report.advisories > 0 ? 1 : 0;
112
115
  }
113
116
  function cmdStatus(args) {
114
- const { values } = parseArgs({ args, options: { ...JSON_OPT, vulnerable: { type: "boolean" }, flat: { type: "boolean" } } });
117
+ const { values } = parseArgs({
118
+ args,
119
+ options: { ...JSON_OPT, vulnerable: { type: "boolean" }, flat: { type: "boolean" }, all: { type: "boolean" } },
120
+ });
115
121
  const status = api.getStatus({ onlyVulnerable: values.vulnerable ?? false });
116
122
  if (values.json) {
117
123
  emit(status);
@@ -135,7 +141,16 @@ function cmdStatus(args) {
135
141
  }
136
142
  return 0;
137
143
  }
138
- for (const g of status.repos) {
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 };
139
154
  const many = g.checkouts.length > 1;
140
155
  const firstError = g.checkouts.find((k) => k.error)?.error ?? null;
141
156
  // Headline: the main checkout; mention a branch that is worse than it.
@@ -152,31 +167,46 @@ function cmdStatus(args) {
152
167
  : c.dim("clean");
153
168
  const head = worse ? `${mainText} ${c.yellow(`· ${worse.vulnerablePackages} on ${clean(worse.branch ?? "another checkout", 40)}`)}` : mainText;
154
169
  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`);
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`);
156
173
  if (!many)
157
174
  continue;
158
175
  g.checkouts.forEach((k, i) => {
159
176
  const branch = k.branch ? c.cyan(clean(k.branch, 50)) : c.dim("(no branch)");
160
- const kind = k.worktree ? c.dim(" worktree") : "";
177
+ const kind = (k.worktree ? c.dim(" worktree") : "") + (k.active ? "" : c.dim(` inactive${k.lastActivity ? ` ${ago(k.lastActivity)}` : ""}`));
161
178
  const detail = k.advisories ? `${k.vulnerablePackages} vulnerable` : k.error ? c.dim(clean(k.error, 60)) : c.dim("clean");
162
179
  const tree = i === g.checkouts.length - 1 ? "└" : "├";
163
180
  out.write(` ${c.dim(tree)} ${branch}${kind} ${detail} ${c.dim(tildify(k.path))}${k.exists ? "" : c.dim(" (missing)")}\n`);
164
181
  });
165
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`));
166
185
  return 0;
167
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
+ }
168
191
  async function cmdSync(args) {
169
192
  const { values } = parseArgs({ args, options: { ...JSON_OPT, background: { type: "boolean" } } });
170
193
  let r;
171
194
  try {
172
- r = await api.sync();
195
+ r = await api.sync({ notify: true });
173
196
  }
174
197
  catch (e) {
175
198
  if (values.background && e instanceof api.ApiError && e.code === "busy")
176
199
  return 0;
177
200
  throw e;
178
201
  }
179
- await notifyNew(r.outcomes);
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);
180
210
  const { outcomes: _o, ...data } = r;
181
211
  const total = data.news.reduce((n, p) => n + p.packages.length, 0);
182
212
  if (values.json)
@@ -200,6 +230,93 @@ async function cmdSync(args) {
200
230
  }
201
231
  return 0;
202
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
+ }
203
320
  async function cmdDiscover(args) {
204
321
  const { values } = parseArgs({ args, options: { ...JSON_OPT, "dry-run": { type: "boolean" } } });
205
322
  const r = await api.discoverNew({ dryRun: values["dry-run"] ?? false });
@@ -347,11 +464,12 @@ function cmdSchedule(args) {
347
464
  switch (positionals[0]) {
348
465
  case "on": {
349
466
  const seconds = parseInterval(values.every ?? "6h");
350
- const plist = scheduleOn(seconds);
467
+ const targets = api.watchTargets();
468
+ const plist = scheduleOn(seconds, targets);
351
469
  if (values.json)
352
- emit({ enabled: true, intervalSeconds: seconds, plist, node: process.execPath });
470
+ emit({ enabled: true, intervalSeconds: seconds, plist, node: process.execPath, watchedPaths: targets.length });
353
471
  else {
354
- out.write(`Background sync every ${values.every}. (${tildify(plist)})\n`);
472
+ out.write(`Background sync every ${values.every}, plus instant checks when a lockfile changes. (${tildify(plist)})\n`);
355
473
  out.write(`Note: it runs with ${process.execPath}; re-run this if you change Node versions.\n`);
356
474
  }
357
475
  return 0;
@@ -361,7 +479,7 @@ function cmdSchedule(args) {
361
479
  if (values.json)
362
480
  emit({ enabled: false, wasEnabled: was });
363
481
  else
364
- out.write(was ? "Background sync disabled.\n" : "Background sync was not enabled.\n");
482
+ out.write(was ? "Background sync and lockfile watcher disabled.\n" : "Background sync was not enabled.\n");
365
483
  return 0;
366
484
  }
367
485
  case "status":
@@ -369,8 +487,11 @@ function cmdSchedule(args) {
369
487
  const s = scheduleStatus();
370
488
  if (values.json)
371
489
  emit(s);
372
- else
490
+ else {
373
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
+ }
374
495
  return 0;
375
496
  }
376
497
  default:
@@ -402,6 +523,14 @@ async function main(argv) {
402
523
  return cmdStatus(rest);
403
524
  case "sync":
404
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);
405
534
  case "discover":
406
535
  return cmdDiscover(rest);
407
536
  case "roots":
package/dist/config.js CHANGED
@@ -5,6 +5,8 @@ import { atomicWrite, isObject, readJsonFile } from "./fsutil.js";
5
5
  import { stateRoot } from "./paths.js";
6
6
  export const DEFAULT_DEPTH = 5;
7
7
  export const MAX_DEPTH = 10;
8
+ export const DEFAULT_ACTIVE_DAYS = 30;
9
+ export const REMIND_VALUES = ["daily", "weekly", "off"];
8
10
  const configFile = () => join(stateRoot(), "config.json");
9
11
  export function loadConfig() {
10
12
  const raw = readJsonFile(configFile());
@@ -18,7 +20,11 @@ export function loadConfig() {
18
20
  }
19
21
  }
20
22
  const exclude = raw && isObject(raw) && Array.isArray(raw.exclude) ? raw.exclude.filter((e) => typeof e === "string") : [];
21
- return { schema: 1, roots, exclude };
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 };
22
28
  }
23
29
  export function isExcluded(path, exclude) {
24
30
  return exclude.some((e) => path === e || path.startsWith(e.endsWith("/") ? e : `${e}/`));