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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 selfguard contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,6 +1,136 @@
1
1
  # selfguard
2
2
 
3
- This version only reserves the package name. It contains no code.
3
+ [![npm](https://img.shields.io/npm/v/selfguard)](https://www.npmjs.com/package/selfguard)
4
+ [![CI](https://github.com/josseed/selfguard/actions/workflows/ci.yml/badge.svg)](https://github.com/josseed/selfguard/actions/workflows/ci.yml)
5
+ [![provenance](https://img.shields.io/badge/npm-provenance-blue)](https://www.npmjs.com/package/selfguard#provenance)
6
+ [![license](https://img.shields.io/npm/l/selfguard)](LICENSE)
4
7
 
5
- The first real release (published from GitHub Actions with provenance) will be available soon:
6
- https://github.com/josseed/selfguard
8
+ **A local-first guard for the dependencies of every JavaScript repo on your machine.**
9
+
10
+ Most dependency scanners only check a project when you remember to run them, or only for repos
11
+ hosted on a platform. selfguard tracks every project on your machine and checks their lockfiles
12
+ against [OSV.dev](https://osv.dev). It tells you when one of them is affected by a new
13
+ vulnerability or a malicious package, including repos you haven't opened in months.
14
+
15
+ ```text
16
+ $ selfguard status
17
+ CRITICAL acme/storefront 31 vulnerable pkg(s) on main · 82 on feat/checkout 3 checkouts
18
+ ├ main 31 vulnerable ~/code/storefront
19
+ ├ fix/query-perf worktree 30 vulnerable ~/code/storefront-query-perf
20
+ └ feat/checkout worktree 82 vulnerable ~/code/storefront-feat-checkout
21
+ HIGH acme/payments-api 3 vulnerable pkg(s) ~/code/payments-api
22
+ ✓ acme/docs-site clean ~/code/docs-site
23
+ ```
24
+
25
+ ## Features
26
+
27
+ - **It tells you on its own.** A background watcher re-checks a repo seconds after its lockfile
28
+ changes (`git pull`, installs, merges), a scheduled sync catches newly published advisories,
29
+ and a daily reminder lists what is still open and serious. When you fix something, you get a
30
+ "✓ fixed" notification too.
31
+ - **A dashboard, not just a terminal.** `selfguard ui` opens a local page with every repo, what
32
+ needs attention, how to fix it, and a feed of what changed.
33
+ - **Signal over noise.** Only checkouts with recent git activity alert you. Old clones and
34
+ abandoned worktrees stay listed but quiet (except for malicious packages), and a new worktree
35
+ of a known repo isn't reported as news.
36
+ - **Hands-off.** `cd` into a project and it is registered and scanned in the background. New
37
+ clones in your code folders are picked up automatically.
38
+ - **Malicious packages first.** OSV includes the OpenSSF malicious-packages feed (`MAL-*`).
39
+ Compromised releases, like the Shai-Hulud npm worm, outrank any CVE and open a dialog that
40
+ stays on screen until you dismiss it.
41
+ - **Actionable output.** One upgrade target per vulnerable package ("upgrade lodash to 4.18.0"),
42
+ not a wall of advisories.
43
+ - **Every package manager.** npm, pnpm, yarn (classic and berry) and bun lockfiles.
44
+ - **Repo-aware.** Git worktrees and clones of the same repo are grouped, each with its branch.
45
+ - **Incident response.** `selfguard where <package>` answers "do I use this anywhere?" in one
46
+ command.
47
+ - **AI-ready.** A built-in MCP server, `--json` output on every command, and a guide for agents.
48
+ - **Safe by design.** It never executes code from your projects, has zero runtime dependencies,
49
+ and only sends package names and versions over the network.
50
+
51
+ ## Quick start
52
+
53
+ Requires **Node.js 20.10+**. Works on **macOS** and **Linux**. Native notifications and
54
+ scheduling are macOS-only for now.
55
+
56
+ ```sh
57
+ npm install -g selfguard
58
+ selfguard setup
59
+ ```
60
+
61
+ `setup` is a small terminal UI. Pick the folders where you keep your code, and selfguard scans
62
+ them. It then offers to install the shell hook and background watching with notifications.
63
+ Then open the dashboard:
64
+
65
+ ```sh
66
+ selfguard ui
67
+ ```
68
+
69
+ ```text
70
+ Where do you keep your code?
71
+ selfguard only looks inside the folders you pick, nothing else on your disk.
72
+
73
+ [x] ~/code 42 projects depth 5
74
+ › [ ] ~/work 17 projects depth 5
75
+ + add another folder…
76
+
77
+ ↑/↓ move · space select · ←/→ depth · a add folder · enter continue · q quit
78
+ ```
79
+
80
+ Or scan a single project without setting anything up:
81
+
82
+ ```text
83
+ $ selfguard scan
84
+ ~/code/payments-api 412 packages
85
+ 3 vulnerable package(s), 9 advisories: 1 critical, 4 high, 4 moderate
86
+
87
+ CRITICAL minimist@1.2.5 (transitive)
88
+ → upgrade to 1.2.6 or later
89
+ • GHSA-xvch-5gv4-984h critical Prototype Pollution in minimist
90
+
91
+ HIGH axios@0.21.0 (direct)
92
+ → upgrade to 0.33.0 or later
93
+ • GHSA-cph5-m8f7-6c5x high axios Inefficient Regular Expression Complexity vulnerability
94
+ …
95
+ ```
96
+
97
+ ## Documentation
98
+
99
+ | | |
100
+ |---|---|
101
+ | [Getting started](docs/getting-started.md) | Install, setup, dashboard, alerts, uninstall |
102
+ | [CLI reference](docs/cli.md) | Every command and flag, exit codes, environment variables |
103
+ | [How it works](docs/how-it-works.md) | Discovery, lockfile parsing, repo grouping, OSV, caching, alerts |
104
+ | [AI and automation](docs/ai-and-automation.md) | MCP server setup, tools, JSON output, scripting and CI |
105
+ | [Security model](docs/security-model.md) | What selfguard trusts, what it sends, how it is released |
106
+ | [Troubleshooting](docs/troubleshooting.md) | Notifications, hook, scheduling, unsupported lockfiles |
107
+
108
+ ## How it compares
109
+
110
+ | | selfguard | `npm audit` | osv-scanner | Dependabot |
111
+ |---|---|---|---|---|
112
+ | Watches every project on your machine | ✅ | ❌ one project, on demand | ❌ on demand | ❌ hosted repos only |
113
+ | Alerts when a new advisory hits an old project | ✅ | ❌ | ❌ | ✅ |
114
+ | Local-only (no repo hosting required) | ✅ | ✅ | ✅ | ❌ |
115
+ | npm, pnpm, yarn, bun | ✅ | npm only | ✅ | ✅ |
116
+ | Malicious package feed | ✅ via OSV | ❌ | ✅ via OSV | partial |
117
+ | Opens PRs with fixes | ❌ | ❌ (`npm audit fix` locally) | guided remediation | ✅ |
118
+ | Ecosystems | JavaScript | JavaScript | many | many |
119
+
120
+ selfguard complements these tools rather than replacing them. Keep Dependabot or Renovate for
121
+ automated fix PRs, and use selfguard to know what is vulnerable on your machine right now.
122
+
123
+ ## Privacy
124
+
125
+ selfguard sends only `name` and `version` pairs of your locked dependencies to the OSV.dev API.
126
+ No file paths, repo names or source code ever leave your machine. All state lives in
127
+ `~/.selfguard`, readable only by your user. See the [security model](docs/security-model.md).
128
+
129
+ ## Contributing
130
+
131
+ Bug reports, ideas and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
132
+ Please report security issues privately as described in [SECURITY.md](SECURITY.md).
133
+
134
+ ## License
135
+
136
+ [MIT](LICENSE)
@@ -0,0 +1,12 @@
1
+ const DAY_MS = 24 * 60 * 60 * 1000;
2
+ export function activityOf(repo, activeDays, now = Date.now()) {
3
+ // Not a git checkout: we can't tell, so it counts (you added it on purpose).
4
+ if (!repo)
5
+ return { active: true, lastActivity: null };
6
+ if (repo.orphaned)
7
+ return { active: false, lastActivity: null };
8
+ if (!repo.lastActivity)
9
+ return { active: true, lastActivity: null };
10
+ const age = now - Date.parse(repo.lastActivity);
11
+ return { active: age <= activeDays * DAY_MS, lastActivity: repo.lastActivity };
12
+ }
package/dist/alerts.js ADDED
@@ -0,0 +1,189 @@
1
+ /** Decide what to tell the user (and how) after a scan or sync. Pure: easy to test. */
2
+ import { basename } from "node:path";
3
+ import { criticalDialog, notify } from "./notify.js";
4
+ import { groupByPackage, worstSeverity } from "./report.js";
5
+ const LABEL = {
6
+ malicious: "MALICIOUS",
7
+ critical: "CRITICAL",
8
+ high: "HIGH",
9
+ moderate: "MODERATE",
10
+ low: "LOW",
11
+ unknown: "UNKNOWN",
12
+ };
13
+ const name = (path) => basename(path) || path;
14
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
15
+ function listFixes(findings, max = 3) {
16
+ const groups = groupByPackage(findings);
17
+ const parts = groups.slice(0, max).map((g) => (g.upgradeTo ? `${g.name} → ${g.upgradeTo}` : `${g.name} (no fix yet)`));
18
+ if (groups.length > max)
19
+ parts.push(`+${groups.length - max} more`);
20
+ return parts.join(", ");
21
+ }
22
+ /** Merge checkouts of the same repo into one entry, de-duplicating findings. */
23
+ function byRepo(news) {
24
+ const groups = new Map();
25
+ for (const n of news) {
26
+ if (n.newFindings.length === 0)
27
+ continue;
28
+ const key = n.repo?.key ?? `path:${n.path}`;
29
+ let g = groups.get(key);
30
+ if (!g) {
31
+ g = { path: n.path, newFindings: [], label: n.repo?.label ?? name(n.path), checkouts: 0, seen: new Set() };
32
+ groups.set(key, g);
33
+ }
34
+ g.checkouts++;
35
+ for (const f of n.newFindings) {
36
+ const k = `${f.id}|${f.name}@${f.version}`;
37
+ if (g.seen.has(k))
38
+ continue;
39
+ g.seen.add(k);
40
+ g.newFindings.push(f);
41
+ }
42
+ }
43
+ return [...groups.values()].map(({ seen: _s, ...g }) => g);
44
+ }
45
+ export function buildAlert(news) {
46
+ const relevant = news.map((n) => n.active === false ? { ...n, newFindings: n.newFindings.filter((f) => f.severity === "malicious") } : n);
47
+ const affected = byRepo(relevant);
48
+ if (affected.length === 0)
49
+ return { kind: "none" };
50
+ const all = affected.flatMap((n) => n.newFindings);
51
+ const malicious = affected.flatMap((n) => n.newFindings.filter((f) => f.severity === "malicious").map((f) => ({ f, path: n.path })));
52
+ if (malicious.length > 0) {
53
+ const { f, path } = malicious[0];
54
+ const group = affected.find((n) => n.path === path);
55
+ const where = affected.length === 1 ? group.label : `${affected.length} projects`;
56
+ const others = malicious.length > 1 ? ` (+${malicious.length - 1} more malicious)` : "";
57
+ return {
58
+ kind: "critical",
59
+ title: `⛔ Malicious package in ${where}`,
60
+ subtitle: `${f.name}@${f.version} · ${f.id}`,
61
+ body: "Remove it now and rotate credentials on this machine.",
62
+ dialogTitle: `selfguard: malicious package in ${where}`,
63
+ dialogMessage: `${f.name}@${f.version} is a known malicious package${others}.\n\n` +
64
+ `Project: ${path}${group.checkouts > 1 ? ` (and ${group.checkouts - 1} other checkout(s) of ${group.label})` : ""}\n\n` +
65
+ "1. Remove it (or pin a safe version) and reinstall.\n" +
66
+ "2. Rotate credentials this machine or CI could reach: npm tokens, cloud keys, SSH keys, .env secrets.\n" +
67
+ "3. Check the advisory for what the payload does.",
68
+ url: `https://osv.dev/vulnerability/${encodeURIComponent(f.id)}`,
69
+ };
70
+ }
71
+ const worst = LABEL[worstSeverity(all) ?? "unknown"];
72
+ if (affected.length === 1) {
73
+ const n = affected[0];
74
+ const pkgs = groupByPackage(n.newFindings).length;
75
+ return {
76
+ kind: "banner",
77
+ title: `selfguard: ${n.label}${n.checkouts > 1 ? ` (${n.checkouts} checkouts)` : ""}`,
78
+ subtitle: `${plural(pkgs, "vulnerable package")} · worst: ${worst}`,
79
+ body: listFixes(n.newFindings),
80
+ };
81
+ }
82
+ const pkgs = affected.reduce((sum, n) => sum + groupByPackage(n.newFindings).length, 0);
83
+ const names = affected.map((n) => n.label);
84
+ return {
85
+ kind: "banner",
86
+ title: `selfguard: ${affected.length} projects affected`,
87
+ subtitle: `${plural(pkgs, "vulnerable package")} · worst: ${worst}`,
88
+ body: `${names.slice(0, 3).join(", ")}${names.length > 3 ? ` +${names.length - 3} more` : ""}. Run: selfguard status`,
89
+ };
90
+ }
91
+ const pkgKey = (f) => `${f.name}@${f.version}`;
92
+ /** "datahub ✓ 17 vulnerable packages fixed": only for active checkouts, grouped by repo. */
93
+ export function buildResolvedAlert(news) {
94
+ const groups = new Map();
95
+ for (const n of news) {
96
+ if (n.active === false || !n.resolvedFindings?.length)
97
+ continue;
98
+ const still = new Set((n.findings ?? []).map(pkgKey));
99
+ const fixed = n.resolvedFindings.map(pkgKey).filter((k) => !still.has(k));
100
+ if (fixed.length === 0)
101
+ continue;
102
+ const key = n.repo?.key ?? `path:${n.path}`;
103
+ let g = groups.get(key);
104
+ if (!g) {
105
+ g = { label: n.repo?.label ?? name(n.path), fixed: new Set(), left: new Map() };
106
+ groups.set(key, g);
107
+ }
108
+ for (const k of fixed)
109
+ g.fixed.add(k);
110
+ for (const f of n.findings ?? [])
111
+ g.left.set(`${f.id}|${pkgKey(f)}`, f);
112
+ }
113
+ if (groups.size === 0)
114
+ return { kind: "none" };
115
+ const list = [...groups.values()];
116
+ const total = list.reduce((sum, g) => sum + g.fixed.size, 0);
117
+ if (list.length === 1) {
118
+ const g = list[0];
119
+ const left = [...g.left.values()];
120
+ const leftPkgs = groupByPackage(left).length;
121
+ return {
122
+ kind: "banner",
123
+ title: `selfguard: ${g.label} ✓`,
124
+ subtitle: `${plural(g.fixed.size, "vulnerable package")} fixed`,
125
+ body: leftPkgs
126
+ ? `${leftPkgs} still vulnerable · worst: ${LABEL[worstSeverity(left) ?? "unknown"]}`
127
+ : "No known vulnerabilities left.",
128
+ };
129
+ }
130
+ const names = list.map((g) => g.label);
131
+ return {
132
+ kind: "banner",
133
+ title: `selfguard: fixes in ${list.length} repos`,
134
+ subtitle: `${plural(total, "vulnerable package")} fixed`,
135
+ body: `${names.slice(0, 3).join(", ")}${names.length > 3 ? ` +${names.length - 3} more` : ""}`,
136
+ };
137
+ }
138
+ /**
139
+ * What a reminder nags about: any malicious package; critical vulnerabilities outside dev
140
+ * dependencies; high ones in direct (non-dev) dependencies. The rest stays in the dashboard,
141
+ * so the reminder remains rare enough to be read.
142
+ */
143
+ export const needsAttention = (f) => f.severity === "malicious" || (!f.dev && (f.severity === "critical" || (f.severity === "high" && f.direct)));
144
+ function severityCounts(findings) {
145
+ const counts = new Map();
146
+ for (const g of groupByPackage(findings))
147
+ counts.set(g.worst, (counts.get(g.worst) ?? 0) + 1);
148
+ return ["malicious", "critical", "high"]
149
+ .filter((s) => counts.get(s))
150
+ .map((s) => `${counts.get(s)} ${s}`)
151
+ .join(", ");
152
+ }
153
+ /** Periodic reminder of unresolved serious findings in the repos you work on. */
154
+ export function buildReminder(repos) {
155
+ const rank = (s) => ["malicious", "critical", "high"].indexOf(s ?? "");
156
+ const serious = repos
157
+ .map((r) => ({ label: r.label, findings: r.findings.filter(needsAttention) }))
158
+ .filter((r) => r.findings.length > 0)
159
+ .sort((a, b) => rank(worstSeverity(a.findings)) - rank(worstSeverity(b.findings)) ||
160
+ groupByPackage(b.findings).length - groupByPackage(a.findings).length);
161
+ if (serious.length === 0)
162
+ return { kind: "none" };
163
+ const all = serious.flatMap((r) => r.findings);
164
+ if (serious.length === 1) {
165
+ const r = serious[0];
166
+ return {
167
+ kind: "banner",
168
+ title: `selfguard: ${r.label} needs attention`,
169
+ subtitle: `${severityCounts(r.findings)} · open: selfguard ui`,
170
+ body: listFixes(r.findings),
171
+ };
172
+ }
173
+ const parts = serious.slice(0, 3).map((r) => `${r.label} (${severityCounts(r.findings)})`);
174
+ if (serious.length > 3)
175
+ parts.push(`+${serious.length - 3} more`);
176
+ return {
177
+ kind: "banner",
178
+ title: `selfguard: ${serious.length} repos need attention`,
179
+ subtitle: `worst: ${LABEL[worstSeverity(all) ?? "unknown"]} · open: selfguard ui`,
180
+ body: parts.join(", "),
181
+ };
182
+ }
183
+ export async function deliver(alert) {
184
+ if (alert.kind === "none")
185
+ return;
186
+ await notify(alert.title, alert.body, alert.subtitle);
187
+ if (alert.kind === "critical")
188
+ await criticalDialog(alert.dialogTitle, alert.dialogMessage, alert.url);
189
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Side effects after a scan: record what changed in the activity log and tell the user
3
+ * (new advisories, fixes, periodic reminders). Decisions live in alerts.ts.
4
+ */
5
+ import { activityOf } from "./activity.js";
6
+ import { buildAlert, buildReminder, buildResolvedAlert, deliver, needsAttention } from "./alerts.js";
7
+ import { loadConfig } from "./config.js";
8
+ import { appendEvents, eventPackages } from "./events.js";
9
+ import { repoIdentity } from "./repoid.js";
10
+ import { loadState, updateState } from "./state.js";
11
+ import { findingKey, listRepos } from "./store.js";
12
+ const HOUR_MS = 60 * 60 * 1000;
13
+ /**
14
+ * Findings already known in other checkouts of the same repo (current or recently seen).
15
+ * A new worktree or clone of a repo isn't news: only what no checkout had before is.
16
+ */
17
+ function knownElsewhere(outcomes) {
18
+ const scanned = new Set(outcomes.map((o) => o.record.path));
19
+ let byRepo = null;
20
+ return (key, path) => {
21
+ if (!byRepo) {
22
+ byRepo = new Map();
23
+ for (const rec of listRepos()) {
24
+ const k = repoIdentity(rec.path)?.key ?? `path:${rec.path}`;
25
+ const keys = new Set([...Object.keys(rec.seen ?? {}), ...rec.findings.map(findingKey)]);
26
+ const list = byRepo.get(k) ?? [];
27
+ list.push({ path: rec.path, keys });
28
+ byRepo.set(k, list);
29
+ }
30
+ }
31
+ const out = new Set();
32
+ for (const other of byRepo.get(key) ?? []) {
33
+ // Other checkouts scanned in this same run don't count: they're news too.
34
+ if (other.path === path || scanned.has(other.path))
35
+ continue;
36
+ for (const k of other.keys)
37
+ out.add(k);
38
+ }
39
+ return out;
40
+ };
41
+ }
42
+ function toNews(outcomes, activeDays) {
43
+ const known = knownElsewhere(outcomes);
44
+ return outcomes.map((o) => {
45
+ const repo = repoIdentity(o.record.path);
46
+ const key = repo?.key ?? `path:${o.record.path}`;
47
+ const elsewhere = o.newFindings.length ? known(key, o.record.path) : new Set();
48
+ return {
49
+ path: o.record.path,
50
+ newFindings: o.newFindings.filter((f) => !elsewhere.has(findingKey(f))),
51
+ resolvedFindings: o.resolvedFindings,
52
+ findings: o.record.findings,
53
+ repo,
54
+ active: activityOf(repo, activeDays).active,
55
+ branch: repo?.branch ?? null,
56
+ firstScan: o.firstScan,
57
+ };
58
+ });
59
+ }
60
+ /** Log and notify what changed. Returns true if something was notified. */
61
+ export async function announce(outcomes, opts = {}) {
62
+ const news = toNews(outcomes, loadConfig().activeDays);
63
+ const at = new Date().toISOString();
64
+ const events = [];
65
+ for (const n of news) {
66
+ const base = {
67
+ at,
68
+ repo: n.repo?.label ?? n.path.split("/").pop() ?? n.path,
69
+ repoKey: n.repo?.key ?? `path:${n.path}`,
70
+ path: n.path,
71
+ branch: n.branch,
72
+ active: n.active !== false,
73
+ };
74
+ if (n.newFindings.length)
75
+ events.push({ ...base, kind: "new", ...(n.firstScan ? { firstScan: true } : {}), ...eventPackages(n.newFindings) });
76
+ const still = new Set((n.findings ?? []).map((f) => `${f.name}@${f.version}`));
77
+ const fixed = (n.resolvedFindings ?? []).filter((f) => !still.has(`${f.name}@${f.version}`));
78
+ if (fixed.length)
79
+ events.push({ ...base, kind: "resolved", ...eventPackages(fixed) });
80
+ }
81
+ try {
82
+ appendEvents(events);
83
+ }
84
+ catch {
85
+ /* the log is a convenience; never fail a scan because of it */
86
+ }
87
+ if (opts.notify === false)
88
+ return false;
89
+ const alerts = [buildAlert(news), buildResolvedAlert(news)].filter((a) => a.kind !== "none");
90
+ for (const a of alerts)
91
+ await deliver(a);
92
+ return alerts.length > 0;
93
+ }
94
+ /** Is a reminder due? Daytime only (9:00–21:00 local), at most once per period. */
95
+ export function reminderDue(every, last, now = new Date()) {
96
+ if (every === "off")
97
+ return false;
98
+ const hour = now.getHours();
99
+ if (hour < 9 || hour >= 21)
100
+ return false;
101
+ if (!last)
102
+ return true;
103
+ const gap = every === "daily" ? 20 * HOUR_MS : 6.5 * 24 * HOUR_MS;
104
+ return now.getTime() - Date.parse(last) >= gap;
105
+ }
106
+ /**
107
+ * Send the periodic reminder if it is due (or `force`). The input comes from api.getAttention().
108
+ * Returns whether a notification was sent.
109
+ */
110
+ export async function maybeRemind(source, opts = {}) {
111
+ const cfg = loadConfig();
112
+ if (!opts.force && !reminderDue(cfg.remind, loadState().lastReminder))
113
+ return false;
114
+ // Computed only when due: the watcher calls this often.
115
+ const repos = typeof source === "function" ? source() : source;
116
+ const alert = buildReminder(repos);
117
+ // Mark the period as handled even when everything is fine, so a later finding waits its turn.
118
+ updateState({ lastReminder: new Date().toISOString() });
119
+ if (alert.kind === "none")
120
+ return false;
121
+ await deliver(alert);
122
+ appendEvents([
123
+ {
124
+ at: new Date().toISOString(),
125
+ kind: "reminder",
126
+ repo: alert.title.replace(/^selfguard: /, ""),
127
+ repoKey: "reminder",
128
+ path: null,
129
+ branch: null,
130
+ active: true,
131
+ total: repos.filter((r) => r.findings.some(needsAttention)).length,
132
+ packages: [],
133
+ },
134
+ ]);
135
+ return true;
136
+ }