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/README.md CHANGED
@@ -1,155 +1,136 @@
1
1
  # selfguard
2
2
 
3
- A local-first guard for the dependencies of **every** JavaScript repo you work on.
4
-
5
- - **Hands-off**: `cd` into a project and selfguard registers it and scans it in the background.
6
- Run `npm install` and it notices that the lockfile changed and re-scans.
7
- - **Reverse alerts**: `selfguard sync` (or the scheduled job) re-checks all your projects against
8
- new advisories. You get a macOS notification when a repo you haven't opened in months becomes vulnerable.
9
- - **Malicious packages first**: OSV includes the OpenSSF malicious-packages feed (`MAL-*`), so
10
- compromised releases (Shai-Hulud style) are flagged above any CVE.
11
- - **Incident response**: `selfguard where <pkg>` answers "do I use this anywhere?" in one command.
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)
7
+
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
+ ```
12
24
 
13
- ## Install
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.
14
55
 
15
56
  ```sh
16
57
  npm install -g selfguard
17
58
  selfguard setup
18
59
  ```
19
60
 
20
- `setup` is a small terminal UI: pick the folders where you keep your repos (it suggests
21
- `~/repository`, `~/code`, `~/dev`… and shows how many projects each has), then it scans them and
22
- offers to install the shell hook and the background sync.
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:
23
64
 
65
+ ```sh
66
+ selfguard ui
24
67
  ```
68
+
69
+ ```text
25
70
  Where do you keep your code?
26
71
  selfguard only looks inside the folders you pick, nothing else on your disk.
27
72
 
28
- [x] ~/repository 205 projects depth 3
29
- › [x] ~/code 12 projects depth 2
73
+ [x] ~/code 42 projects depth 5
74
+ › [ ] ~/work 17 projects depth 5
30
75
  + add another folder…
31
76
 
32
77
  ↑/↓ move · space select · ←/→ depth · a add folder · enter continue · q quit
33
78
  ```
34
79
 
35
- ## How projects are found
36
-
37
- selfguard **never crawls your whole disk**. A project gets tracked in one of three ways:
38
-
39
- 1. **Your folders** (`setup` / `roots add`): a recursive search of every subfolder (depth-limited,
40
- default 5) that only skips dependency/generated folders: `node_modules`, `bower_components`,
41
- `vendor`, `Pods`, `venv`, `target`, `__pycache__`, hidden folders (`.git`, `.next`, `.cache`…),
42
- and symlinks. It also looks inside projects, so monorepos with several lockfiles are covered.
43
- `sync` re-runs it, so a fresh `git clone` gets watched even if you never open it.
44
- 2. **The shell hook**: `cd` anywhere and the enclosing project is registered.
45
- 3. **By hand**: `selfguard scan <dir>`.
46
-
47
- Worktrees and separate clones of the same repo are **grouped** (by remote URL, or by the main
48
- repo a worktree points to), and each subfolder project of a monorepo stays its own group:
49
-
50
- ```
51
- CRITICAL acme/datahub 31 vulnerable pkg(s) on main · 82 on feat/calculadora 3 checkouts
52
- ├ main 31 vulnerable ~/code/datahub
53
- ├ fix/query-perf worktree 30 vulnerable ~/code/datahub-query-perf
54
- └ feat/calculadora worktree 82 vulnerable ~/code/datahub-feat-calculadora
55
- ```
56
-
57
- Notifications about the same advisory in several checkouts of one repo arrive once. `sync` stops
58
- tracking folders that no longer exist (e.g. removed worktrees). `status --flat` lists every checkout.
59
-
60
- Test fixtures, examples or archived repos you don't care about: `selfguard forget <dir>` stops
61
- tracking them and excludes them from discovery (undo with `selfguard unexclude <dir>`).
80
+ Or scan a single project without setting anything up:
62
81
 
63
- ## Use it with AI (MCP)
82
+ ```text
83
+ $ selfguard scan
84
+ ~/code/payments-api 412 packages
85
+ 3 vulnerable package(s), 9 advisories: 1 critical, 4 high, 4 moderate
64
86
 
65
- selfguard ships an MCP server, so assistants like Claude Code or Cursor can triage and manage it:
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
66
90
 
67
- ```sh
68
- claude mcp add selfguard -- selfguard mcp
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
+ …
69
95
  ```
70
96
 
71
- Then ask things like *"which of my projects have critical vulnerabilities and how do I fix them?"*,
72
- *"is `@ctrl/tinycolor` used anywhere?"* or *"stop tracking the test fixtures in ~/code/shop"*.
97
+ ## Documentation
73
98
 
74
- | Tool | Purpose |
99
+ | | |
75
100
  |---|---|
76
- | `selfguard_status` | All projects with risk summary |
77
- | `selfguard_project_report` | Vulnerable packages of a project, with `upgradeTo` |
78
- | `selfguard_scan` / `selfguard_sync` | Re-check one project / everything |
79
- | `selfguard_where` | Which projects use a package |
80
- | `selfguard_vulnerability` | Advisory details + which of your projects it affects |
81
- | `selfguard_roots_*`, `selfguard_discover` | Manage watched folders |
82
- | `selfguard_forget` / `selfguard_unexclude` | Stop tracking / re-include |
83
-
84
- The MCP tools never run package managers or project code. Fixes are proposed for the user to apply.
85
- Advisory text is marked as third-party data so the model treats it as data, not instructions.
86
-
87
- Without MCP, every command accepts `--json` (one JSON document on stdout, errors as
88
- `{"error":{"code","message"}}`, exit codes 0/1/2), and `selfguard guide` prints a guide written
89
- for agents.
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 |
90
107
 
91
- ## Commands
108
+ ## How it compares
92
109
 
93
- Run `selfguard --help` for everything. The main ones:
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 |
94
119
 
95
- | Command | What it does |
96
- |---|---|
97
- | `selfguard setup` | Interactive: choose your code folders, scan, install hook + sync |
98
- | `selfguard status [--vulnerable] [--flat]` | Tracked repos, riskiest first (worktrees/clones grouped) |
99
- | `selfguard scan [dir] [-v]` | Scan a project now (exit code 1 if anything is found) |
100
- | `selfguard report [dir] [-v]` | Last results for a project, no network |
101
- | `selfguard sync` | Find new repos in your folders, re-check everything, notify about news |
102
- | `selfguard where <pkg>` | Which projects use a package, and which versions |
103
- | `selfguard vuln <id>` | Advisory details and which of your projects it affects |
104
- | `selfguard roots [add <dir> [--depth N] \| remove <dir>]` | Manage your code folders |
105
- | `selfguard discover [--dry-run]` | Register new projects found in your folders |
106
- | `selfguard forget [dir]` / `unexclude <dir>` | Stop tracking + exclude / re-include |
107
- | `selfguard schedule on\|off\|status` | Periodic sync via launchd (macOS) |
108
- | `selfguard mcp` / `selfguard guide` | MCP server / guide for AI agents |
109
-
110
- ## Security model
111
-
112
- selfguard is a security tool, so it holds itself to a stricter standard:
113
-
114
- - **Never executes anything from your projects.** Lockfiles are parsed as data. No `npm ls`, no
115
- lifecycle scripts, no `.npmrc`. Entering a hostile repo is safe.
116
- - **Zero runtime dependencies.** Only Node built-ins. CI fails if a runtime dependency is added.
117
- - **Minimal data leaves your machine**: only `name` + `version` pairs go to
118
- [OSV.dev](https://osv.dev). No paths, repo names, or file contents.
119
- - **Untrusted text is sanitized** before it reaches your terminal or a notification: no terminal
120
- escape injection and no bidi spoofing. Notification text goes to AppleScript as arguments, never as code.
121
- - **Local state is private**: `~/.selfguard` is created `0700` and its files `0600`.
122
- - **Releases use npm Trusted Publishing with provenance and staged publishing**: no long-lived
123
- publish token exists, CI can only stage a version, and a maintainer must approve it with 2FA
124
- before anyone can install it. `npm view selfguard --json` shows the provenance attestation.
125
-
126
- ## How it works
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.
127
122
 
128
- ```
129
- cd ──► zsh hook (pure shell: walk up for package.json, stat lockfile on each prompt)
130
- │ only when the project or lockfile changes
131
- ▼
132
- selfguard hook-enter ──► cached summary on stderr
133
- │ lockfile changed / never scanned / >24h
134
- ▼
135
- detached background scan ──► parse lockfile ──► OSV querybatch ──► ~/.selfguard
136
- └─► macOS notification on new findings
137
- ```
123
+ ## Privacy
138
124
 
139
- Supported lockfiles: `package-lock.json` / `npm-shrinkwrap.json` (v1–v3), `pnpm-lock.yaml`
140
- (v5, v6, v9), `yarn.lock` (classic v1 and berry v2+) and `bun.lock`. The binary `bun.lockb` is
141
- not supported (`bun install --save-text-lockfile` converts it). All of them are parsed as data,
142
- with a dependency-free parser; the test suite checks that real lockfiles from every manager
143
- produce identical results for the same project.
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).
144
128
 
145
- ## Development
129
+ ## Contributing
146
130
 
147
- ```sh
148
- npm ci --ignore-scripts
149
- npm test # node:test runs the TypeScript sources directly (Node >= 22.18)
150
- npm run build # emits dist/
151
- ```
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).
152
133
 
153
134
  ## License
154
135
 
155
- MIT
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 CHANGED
@@ -43,7 +43,8 @@ function byRepo(news) {
43
43
  return [...groups.values()].map(({ seen: _s, ...g }) => g);
44
44
  }
45
45
  export function buildAlert(news) {
46
- const affected = byRepo(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);
47
48
  if (affected.length === 0)
48
49
  return { kind: "none" };
49
50
  const all = affected.flatMap((n) => n.newFindings);
@@ -87,6 +88,98 @@ export function buildAlert(news) {
87
88
  body: `${names.slice(0, 3).join(", ")}${names.length > 3 ? ` +${names.length - 3} more` : ""}. Run: selfguard status`,
88
89
  };
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
+ }
90
183
  export async function deliver(alert) {
91
184
  if (alert.kind === "none")
92
185
  return;
@@ -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
+ }