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/events.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Append-only activity log (~/.selfguard/events.jsonl): what appeared, what got fixed and
3
+ * which reminders were sent. Feeds the dashboard's activity feed. Local only.
4
+ */
5
+ import { appendFileSync, readFileSync, statSync } from "node:fs";
6
+ import { atomicWrite, ensureDir, isObject } from "./fsutil.js";
7
+ import { eventsFile, stateRoot } from "./paths.js";
8
+ import { groupByPackage } from "./report.js";
9
+ const MAX_BYTES = 512 * 1024;
10
+ const KEEP_LINES = 1000;
11
+ const MAX_PACKAGES = 20;
12
+ export function eventPackages(findings) {
13
+ const groups = groupByPackage(findings);
14
+ return {
15
+ total: groups.length,
16
+ packages: groups.slice(0, MAX_PACKAGES).map((g) => ({ name: g.name, version: g.version, severity: g.worst })),
17
+ };
18
+ }
19
+ export function appendEvents(events) {
20
+ if (events.length === 0)
21
+ return;
22
+ ensureDir(stateRoot());
23
+ const file = eventsFile();
24
+ appendFileSync(file, events.map((e) => `${JSON.stringify(e)}\n`).join(""), { mode: 0o600 });
25
+ try {
26
+ if (statSync(file).size > MAX_BYTES) {
27
+ const lines = readFileSync(file, "utf8").split("\n").filter(Boolean);
28
+ atomicWrite(file, `${lines.slice(-KEEP_LINES).join("\n")}\n`);
29
+ }
30
+ }
31
+ catch {
32
+ /* best effort */
33
+ }
34
+ }
35
+ /** Most recent events first. */
36
+ export function readEvents(limit = 100) {
37
+ let text;
38
+ try {
39
+ text = readFileSync(eventsFile(), "utf8");
40
+ }
41
+ catch {
42
+ return [];
43
+ }
44
+ const out = [];
45
+ const lines = text.split("\n");
46
+ for (let i = lines.length - 1; i >= 0 && out.length < limit; i--) {
47
+ const line = lines[i].trim();
48
+ if (!line)
49
+ continue;
50
+ try {
51
+ const e = JSON.parse(line);
52
+ if (isObject(e) && typeof e.at === "string" && typeof e.kind === "string" && Array.isArray(e.packages)) {
53
+ out.push(e);
54
+ }
55
+ }
56
+ catch {
57
+ /* skip corrupt line */
58
+ }
59
+ }
60
+ return out;
61
+ }
package/dist/guide.js CHANGED
@@ -19,14 +19,21 @@ project code or package managers.
19
19
  - repo groups: git worktrees and separate clones of the same repo (same remote + same subfolder) are
20
20
  grouped. status returns "repos" (grouped, with each checkout's branch) and "projects" (flat).
21
21
  A finding usually needs fixing once on the main branch; mention branches that are worse.
22
+ - active: a checkout with git activity in the last N days (default 30). Inactive checkouts are listed
23
+ but only alert for malicious packages. Repo-level counts in status cover active checkouts.
24
+ - attention: what the user's daily reminder is about: malicious packages, critical findings outside dev
25
+ dependencies, and high findings in direct dependencies, judged on each repo's main checkout.
22
26
 
23
27
  ## Use it via MCP (preferred) or the CLI with --json
24
28
  | Goal | MCP tool | CLI |
25
29
  |---|---|---|
30
+ | What to fix first | selfguard_attention | selfguard remind --dry-run --json |
26
31
  | Overview of all projects | selfguard_status | selfguard status --json |
27
32
  | Findings for one project (cached) | selfguard_project_report | selfguard report <dir> --json |
28
33
  | Re-scan one project now | selfguard_scan | selfguard scan <dir> --json |
29
34
  | Check everything for new advisories | selfguard_sync | selfguard sync --json |
35
+ | Re-check projects whose lockfile changed | selfguard_check | selfguard check --json |
36
+ | Alert settings | (CLI only) | selfguard settings [--remind daily|weekly|off] [--active-days N] --json |
30
37
  | Is package X used anywhere? | selfguard_where | selfguard where <pkg> --json |
31
38
  | Explain an advisory | selfguard_vulnerability | selfguard vuln <id> --json |
32
39
  | Watched folders | selfguard_roots_list / _add / _remove | selfguard roots [add|remove] --json |
@@ -37,13 +44,14 @@ CLI conventions: with --json, stdout is a single JSON document; errors are {"err
37
44
  Exit codes: 0 ok, 1 scan found vulnerabilities, 2 usage or runtime error. No prompts without a TTY.
38
45
 
39
46
  ## How to help the user
40
- 1. Start with status; prioritize malicious, then critical/high, and direct over transitive deps.
47
+ 1. Start with attention (or status); prioritize malicious, then critical/high, and direct over transitive deps.
41
48
  2. Malicious package: tell the user to remove it immediately and rotate credentials that were
42
49
  available on that machine/CI (npm tokens, cloud keys, SSH keys). Do not just upgrade it.
43
50
  3. Direct dependency: propose bumping it to >= upgradeTo in package.json, then the user runs the install.
44
51
  4. Transitive dependency: find which direct dependency pulls it in; propose upgrading that one, or an
45
52
  npm "overrides" entry pinning the package to >= upgradeTo.
46
- 5. After the user installs, run selfguard_scan on the project to verify.
53
+ 5. After the user installs, run selfguard_check (or selfguard_scan on the project) to verify.
54
+ The user can also follow everything in the local dashboard: selfguard ui.
47
55
  6. Dev-only findings matter less for production but still matter for developer machines and CI.
48
56
 
49
57
  ## Rules
package/dist/mcp.js CHANGED
@@ -6,7 +6,9 @@
6
6
  * names are third-party data; tool descriptions tell the model to treat them as such.
7
7
  */
8
8
  import { createInterface } from "node:readline";
9
+ import { needsAttention } from "./alerts.js";
9
10
  import * as api from "./api.js";
11
+ import { groupByPackage } from "./report.js";
10
12
  import { GUIDE } from "./guide.js";
11
13
  import { VERSION } from "./version.js";
12
14
  const KNOWN_PROTOCOLS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
@@ -29,11 +31,37 @@ export const TOOLS = [
29
31
  {
30
32
  name: "selfguard_status",
31
33
  title: "List tracked projects",
32
- description: "Risk overview of everything selfguard tracks. `repos` groups git worktrees and clones of the same repository (each checkout with its branch and counts); `projects` is the flat list. Reads local state only, no network. Start here.",
34
+ description: "Risk overview of everything selfguard tracks. `repos` groups git worktrees and clones of the same repository (each checkout with its branch, counts and whether it is `active`: git activity in the last N days); `projects` is the flat list. Repo counts cover active checkouts. Reads local state only, no network. Start here (or with selfguard_attention for the short list).",
33
35
  inputSchema: obj({ onlyVulnerable: { type: "boolean", description: "Only projects with findings." } }),
34
36
  annotations: { readOnlyHint: true, openWorldHint: false },
35
37
  run: (a) => api.getStatus({ onlyVulnerable: bool(a, "onlyVulnerable") ?? false }),
36
38
  },
39
+ {
40
+ name: "selfguard_attention",
41
+ title: "What needs attention",
42
+ description: "The short list the user's reminders are about: per repo, the serious findings (malicious packages, critical vulnerabilities outside dev dependencies, high ones in direct dependencies) on the checkout that represents it (the active main clone, or the most recently active checkout). Inactive checkouts are excluded. No network. Use it to decide what to fix first." +
43
+ UNTRUSTED,
44
+ inputSchema: obj({}),
45
+ annotations: { readOnlyHint: true, openWorldHint: false },
46
+ run: () => ({
47
+ repos: api
48
+ .getAttention()
49
+ .map((r) => ({
50
+ repo: r.label,
51
+ path: r.path,
52
+ branch: r.branch,
53
+ packages: groupByPackage(r.findings.filter(needsAttention)).map((g) => ({
54
+ name: g.name,
55
+ version: g.version,
56
+ worstSeverity: g.worst,
57
+ direct: g.direct,
58
+ upgradeTo: g.worst === "malicious" ? null : (g.upgradeTo ?? null),
59
+ advisories: g.findings.map((f) => f.id),
60
+ })),
61
+ }))
62
+ .filter((r) => r.packages.length > 0),
63
+ }),
64
+ },
37
65
  {
38
66
  name: "selfguard_project_report",
39
67
  title: "Project vulnerability report",
@@ -63,6 +91,14 @@ export const TOOLS = [
63
91
  return rest;
64
92
  },
65
93
  },
94
+ {
95
+ name: "selfguard_check",
96
+ title: "Re-check changed lockfiles",
97
+ description: "Re-scan only the tracked projects whose lockfile changed since their last scan (after installs, git pull or branch switches). Fast; use it to verify a fix instead of a full sync.",
98
+ inputSchema: obj({}),
99
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
100
+ run: () => api.check({ notify: false }),
101
+ },
66
102
  {
67
103
  name: "selfguard_where",
68
104
  title: "Where is a package used",
package/dist/paths.js CHANGED
@@ -11,3 +11,6 @@ export const reposDir = () => join(stateRoot(), "repos");
11
11
  export const vulnCacheDir = () => join(stateRoot(), "cache", "vulns");
12
12
  export const locksDir = () => join(stateRoot(), "locks");
13
13
  export const logFile = () => join(stateRoot(), "selfguard.log");
14
+ export const eventsFile = () => join(stateRoot(), "events.jsonl");
15
+ export const stateFile = () => join(stateRoot(), "state.json");
16
+ export const uiFile = () => join(stateRoot(), "ui.json");
package/dist/repoid.js CHANGED
@@ -24,6 +24,19 @@ function readSmall(file) {
24
24
  closeSync(fd);
25
25
  }
26
26
  }
27
+ /** Newest mtime among this checkout's own git files (not the shared ones, like FETCH_HEAD). */
28
+ function lastActivityOf(gitDir) {
29
+ let newest = 0;
30
+ for (const f of ["HEAD", "index", join("logs", "HEAD")]) {
31
+ try {
32
+ newest = Math.max(newest, statSync(join(gitDir, f)).mtimeMs);
33
+ }
34
+ catch {
35
+ /* missing */
36
+ }
37
+ }
38
+ return newest > 0 ? new Date(newest).toISOString() : null;
39
+ }
27
40
  function isDir(p) {
28
41
  try {
29
42
  return statSync(p).isDirectory();
@@ -114,6 +127,18 @@ export function repoIdentity(projectRoot) {
114
127
  worktree = true;
115
128
  }
116
129
  }
130
+ if (gitDir && worktree && !isDir(gitDir)) {
131
+ // The main repository was deleted: this folder is a leftover, not part of any repo.
132
+ const sub = relative(dir, resolve(projectRoot)).split(sep).join("/");
133
+ return {
134
+ key: `orphan:${dir}${sub ? `#${sub}` : ""}`,
135
+ label: `${basename(dir)}${sub ? `/${sub}` : ""} (orphaned worktree)`,
136
+ branch: null,
137
+ worktree: true,
138
+ orphaned: true,
139
+ lastActivity: null,
140
+ };
141
+ }
117
142
  if (gitDir) {
118
143
  // Linked worktrees point at <common>/.git/worktrees/<name>; "commondir" leads back.
119
144
  const commondir = worktree ? readSmall(join(gitDir, "commondir"))?.trim() : undefined;
@@ -134,6 +159,7 @@ export function repoIdentity(projectRoot) {
134
159
  label: sub ? `${repoName}/${sub}` : repoName,
135
160
  branch: branchFrom(readSmall(join(gitDir, "HEAD"))),
136
161
  worktree,
162
+ lastActivity: lastActivityOf(gitDir),
137
163
  };
138
164
  }
139
165
  const parent = dirname(dir);
package/dist/scan.js CHANGED
@@ -4,6 +4,7 @@ import { sha256 } from "./fsutil.js";
4
4
  import { isSupported, parseLockfile } from "./lockfile/index.js";
5
5
  import { evaluate, severityOf, severityRank } from "./match.js";
6
6
  import { getVulns, queryBatch } from "./osv.js";
7
+ import { repoIdentity } from "./repoid.js";
7
8
  import { clean } from "./sanitize.js";
8
9
  import { findingKey, loadRepo } from "./store.js";
9
10
  const MAX_LOCKFILE_BYTES = 100 * 1024 * 1024;
@@ -51,6 +52,7 @@ function inventory(project) {
51
52
  deps: [],
52
53
  skipped: 0,
53
54
  findings: [],
55
+ branch: repoIdentity(project.root)?.branch ?? null,
54
56
  };
55
57
  if (!project.lockfile || !project.lockType) {
56
58
  record.error = "no lockfile found (run your package manager's install to create one)";
@@ -121,6 +123,26 @@ function buildFindings(deps, refsPerDep, vulns, previous, now) {
121
123
  }
122
124
  return findings.sort((a, b) => severityRank(a.severity) - severityRank(b.severity) || Number(b.direct) - Number(a.direct) || a.name.localeCompare(b.name));
123
125
  }
126
+ const SEEN_TTL_MS = 180 * 24 * 60 * 60 * 1000;
127
+ const SEEN_MAX = 5000;
128
+ /** Remember every finding this checkout has had recently, on any branch. */
129
+ function updateSeen(previous, findings, now) {
130
+ const seen = Object.create(null);
131
+ const cutoff = Date.parse(now) - SEEN_TTL_MS;
132
+ for (const [k, at] of Object.entries(previous?.seen ?? {})) {
133
+ if (typeof at === "string" && Date.parse(at) >= cutoff)
134
+ seen[k] = at;
135
+ }
136
+ for (const f of previous?.findings ?? [])
137
+ seen[findingKey(f)] ??= previous?.scannedAt ?? now;
138
+ for (const f of findings)
139
+ seen[findingKey(f)] = now;
140
+ const entries = Object.entries(seen);
141
+ if (entries.length <= SEEN_MAX)
142
+ return seen;
143
+ entries.sort((a, b) => b[1].localeCompare(a[1]));
144
+ return Object.fromEntries(entries.slice(0, SEEN_MAX));
145
+ }
124
146
  /**
125
147
  * Scan one or many projects with a single deduplicated OSV query.
126
148
  * Records are returned, not saved: the caller decides.
@@ -155,14 +177,21 @@ export async function scanProjects(projects) {
155
177
  record.error = networkError;
156
178
  record.findings = previous?.findings ?? [];
157
179
  record.scannedAt = previous?.scannedAt ?? null;
158
- return { record, newFindings: [], firstScan: previous === null };
180
+ record.seen = previous?.seen ?? {};
181
+ return { record, newFindings: [], resolvedFindings: [], firstScan: previous === null };
159
182
  }
160
183
  if (!record.error) {
161
184
  record.findings = buildFindings(record.deps, refsPerDep, vulns, previous, now);
162
185
  record.scannedAt = now;
163
186
  }
164
187
  const before = new Set((previous?.findings ?? []).map(findingKey));
165
- const newFindings = record.findings.filter((f) => !before.has(findingKey(f)));
166
- return { record, newFindings, firstScan: previous === null };
188
+ const history = previous?.seen ?? {};
189
+ const newFindings = record.findings.filter((f) => !before.has(findingKey(f)) && !Object.hasOwn(history, findingKey(f)));
190
+ const after = new Set(record.findings.map(findingKey));
191
+ // Only a successful scan on the same branch can say something was fixed.
192
+ const sameBranch = previous?.branch === undefined || previous.branch === record.branch;
193
+ const resolvedFindings = previous && !record.error && sameBranch ? previous.findings.filter((f) => !after.has(findingKey(f))) : [];
194
+ record.seen = updateSeen(previous, record.findings, now);
195
+ return { record, newFindings, resolvedFindings, firstScan: previous === null };
167
196
  });
168
197
  }
package/dist/schedule.js CHANGED
@@ -1,13 +1,21 @@
1
- /** Periodic `selfguard sync` via a per-user launchd agent (macOS). */
1
+ /**
2
+ * Background work via per-user launchd agents (macOS):
3
+ * - dev.selfguard.sync: `selfguard sync` every N hours (discovery, new advisories, reminders);
4
+ * - dev.selfguard.watch: `selfguard check` as soon as a lockfile of an active checkout changes
5
+ * (git pull, installs, merges), so fixes and new risks show up in seconds.
6
+ */
2
7
  import { execFileSync } from "node:child_process";
3
- import { existsSync, rmSync } from "node:fs";
8
+ import { existsSync, readFileSync, rmSync } from "node:fs";
4
9
  import { homedir, userInfo } from "node:os";
5
10
  import { join } from "node:path";
6
11
  import { atomicWrite } from "./fsutil.js";
7
12
  import { cliEntry } from "./hook.js";
8
13
  import { logFile } from "./paths.js";
9
- const LABEL = "dev.selfguard.sync";
10
- const plistPath = () => join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
14
+ const SYNC_LABEL = "dev.selfguard.sync";
15
+ const WATCH_LABEL = "dev.selfguard.watch";
16
+ /** launchd watches each path with a kqueue descriptor; keep the set bounded. */
17
+ export const MAX_WATCH_PATHS = 200;
18
+ const plistPath = (label) => join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
11
19
  const domain = () => `gui/${userInfo().uid}`;
12
20
  function xml(s) {
13
21
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -25,58 +33,110 @@ function assertMac() {
25
33
  if (process.platform !== "darwin")
26
34
  throw new Error("scheduling is only implemented for macOS (launchd) for now");
27
35
  }
28
- export function scheduleOn(seconds) {
29
- assertMac();
30
- const args = [process.execPath, cliEntry(), "sync", "--background"];
31
- const plist = `<?xml version="1.0" encoding="UTF-8"?>
36
+ /** Plist for one of our agents. Pure: tested directly. */
37
+ export function agentPlist(label, args, trigger, log) {
38
+ const triggerXml = "interval" in trigger
39
+ ? ` <key>StartInterval</key><integer>${trigger.interval}</integer>\n <key>RunAtLoad</key><true/>`
40
+ : ` <key>WatchPaths</key>\n <array>\n${trigger.watch.map((p) => ` <string>${xml(p)}</string>`).join("\n")}\n </array>\n <key>ThrottleInterval</key><integer>15</integer>`;
41
+ return `<?xml version="1.0" encoding="UTF-8"?>
32
42
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
33
43
  <plist version="1.0">
34
44
  <dict>
35
- <key>Label</key><string>${LABEL}</string>
45
+ <key>Label</key><string>${xml(label)}</string>
36
46
  <key>ProgramArguments</key>
37
47
  <array>
38
48
  ${args.map((a) => ` <string>${xml(a)}</string>`).join("\n")}
39
49
  </array>
40
- <key>StartInterval</key><integer>${seconds}</integer>
41
- <key>RunAtLoad</key><true/>
50
+ ${triggerXml}
42
51
  <key>ProcessType</key><string>Background</string>
43
52
  <key>LowPriorityIO</key><true/>
44
- <key>StandardOutPath</key><string>${xml(logFile())}</string>
45
- <key>StandardErrorPath</key><string>${xml(logFile())}</string>
53
+ <key>StandardOutPath</key><string>${xml(log)}</string>
54
+ <key>StandardErrorPath</key><string>${xml(log)}</string>
46
55
  </dict>
47
56
  </plist>
48
57
  `;
58
+ }
59
+ function unload(label) {
49
60
  try {
50
- execFileSync("/bin/launchctl", ["bootout", `${domain()}/${LABEL}`], { stdio: "ignore" });
61
+ execFileSync("/bin/launchctl", ["bootout", `${domain()}/${label}`], { stdio: "ignore" });
51
62
  }
52
63
  catch {
53
64
  /* not loaded */
54
65
  }
55
- atomicWrite(plistPath(), plist);
56
- execFileSync("/bin/launchctl", ["bootstrap", domain(), plistPath()], { stdio: "ignore" });
57
- return plistPath();
58
66
  }
59
- export function scheduleOff() {
67
+ function install(label, plist) {
68
+ unload(label);
69
+ atomicWrite(plistPath(label), plist);
70
+ execFileSync("/bin/launchctl", ["bootstrap", domain(), plistPath(label)], { stdio: "ignore" });
71
+ }
72
+ function isLoaded(label) {
73
+ try {
74
+ execFileSync("/bin/launchctl", ["print", `${domain()}/${label}`], { stdio: "ignore" });
75
+ return true;
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
81
+ export function scheduleOn(seconds, watchPaths) {
60
82
  assertMac();
83
+ install(SYNC_LABEL, agentPlist(SYNC_LABEL, [process.execPath, cliEntry(), "sync", "--background"], { interval: seconds }, logFile()));
84
+ updateWatcher(watchPaths, { force: true });
85
+ return plistPath(SYNC_LABEL);
86
+ }
87
+ /**
88
+ * (Re)install the lockfile watcher with these paths, only when the schedule is on and the
89
+ * path set changed. Never call it from the watcher's own run: unloading would kill it.
90
+ */
91
+ export function updateWatcher(paths, opts = {}) {
92
+ if (process.platform !== "darwin" || process.env.SELFGUARD_NO_LAUNCHD === "1")
93
+ return { watching: 0, changed: false };
94
+ if (!opts.force && !existsSync(plistPath(SYNC_LABEL)))
95
+ return { watching: 0, changed: false };
96
+ const list = [...new Set(paths)].slice(0, MAX_WATCH_PATHS);
97
+ if (list.length === 0) {
98
+ const had = existsSync(plistPath(WATCH_LABEL));
99
+ unload(WATCH_LABEL);
100
+ rmSync(plistPath(WATCH_LABEL), { force: true });
101
+ return { watching: 0, changed: had };
102
+ }
103
+ const plist = agentPlist(WATCH_LABEL, [process.execPath, cliEntry(), "check", "--background"], { watch: list }, logFile());
104
+ let current = "";
61
105
  try {
62
- execFileSync("/bin/launchctl", ["bootout", `${domain()}/${LABEL}`], { stdio: "ignore" });
106
+ current = readFileSync(plistPath(WATCH_LABEL), "utf8");
63
107
  }
64
108
  catch {
65
- /* not loaded */
109
+ /* not installed */
110
+ }
111
+ if (current === plist && isLoaded(WATCH_LABEL))
112
+ return { watching: list.length, changed: false };
113
+ install(WATCH_LABEL, plist);
114
+ return { watching: list.length, changed: true };
115
+ }
116
+ export function scheduleOff() {
117
+ assertMac();
118
+ let existed = false;
119
+ for (const label of [SYNC_LABEL, WATCH_LABEL]) {
120
+ unload(label);
121
+ existed = existsSync(plistPath(label)) || existed;
122
+ rmSync(plistPath(label), { force: true });
66
123
  }
67
- const existed = existsSync(plistPath());
68
- rmSync(plistPath(), { force: true });
69
124
  return existed;
70
125
  }
71
126
  export function scheduleStatus() {
72
127
  assertMac();
73
- let loaded = false;
128
+ let paths = 0;
74
129
  try {
75
- execFileSync("/bin/launchctl", ["print", `${domain()}/${LABEL}`], { stdio: "ignore" });
76
- loaded = true;
130
+ const section = /<key>WatchPaths<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(readFileSync(plistPath(WATCH_LABEL), "utf8"));
131
+ paths = section?.[1]?.match(/<string>/g)?.length ?? 0;
77
132
  }
78
133
  catch {
79
- loaded = false;
134
+ paths = 0;
80
135
  }
81
- return { installed: existsSync(plistPath()), loaded, plist: plistPath() };
136
+ return {
137
+ installed: existsSync(plistPath(SYNC_LABEL)),
138
+ loaded: isLoaded(SYNC_LABEL),
139
+ plist: plistPath(SYNC_LABEL),
140
+ watcher: { installed: existsSync(plistPath(WATCH_LABEL)), loaded: isLoaded(WATCH_LABEL), paths },
141
+ };
82
142
  }
package/dist/setup.js CHANGED
@@ -7,6 +7,8 @@ import { discover, discoverAll } from "./discover.js";
7
7
  import { groupByPackage, paint, summarizeCounts, tildify, worstSeverity } from "./report.js";
8
8
  import { scanProjects } from "./scan.js";
9
9
  import { scheduleOn, scheduleStatus } from "./schedule.js";
10
+ import { watchTargets } from "./api.js";
11
+ import { announce } from "./announce.js";
10
12
  import { listRepos, saveRepo } from "./store.js";
11
13
  import { confirm, fitStart, isPrintable, readKeys, Screen, withSpinner } from "./tui.js";
12
14
  const out = process.stdout;
@@ -208,6 +210,7 @@ export async function runSetup() {
208
210
  const outcomes = await withSpinner(`Scanning ${fresh.length} project(s)…`, () => scanProjects(fresh));
209
211
  for (const o of outcomes)
210
212
  saveRepo(o.record);
213
+ await announce(outcomes, { notify: false });
211
214
  printScanSummary(outcomes);
212
215
  }
213
216
  }
@@ -223,11 +226,11 @@ export async function runSetup() {
223
226
  if (scheduleStatus().loaded) {
224
227
  out.write(`${c.green("✓")} Background sync already enabled\n`);
225
228
  }
226
- else if (await confirm("Check for new advisories every 6h in the background, with macOS notifications?")) {
227
- scheduleOn(6 * 3600);
228
- out.write(`${c.green("✓")} Enabled. New repos in your folders are picked up automatically too.\n`);
229
+ else if (await confirm("Watch your repos in the background (macOS notifications, daily reminder of serious issues)?")) {
230
+ scheduleOn(6 * 3600, watchTargets());
231
+ out.write(`${c.green("✓")} Enabled. Lockfile changes are checked right away; new repos are picked up every 6h.\n`);
229
232
  }
230
233
  }
231
- out.write(`\nDone. ${c.dim("selfguard status · selfguard setup (change folders) · selfguard --help")}\n`);
234
+ out.write(`\nDone. ${c.dim("selfguard ui (dashboard) · selfguard status · selfguard --help")}\n`);
232
235
  return 0;
233
236
  }
package/dist/state.js ADDED
@@ -0,0 +1,19 @@
1
+ /** Small runtime state that isn't configuration (e.g. when the last reminder was sent). */
2
+ import { atomicWrite, isObject, readJsonFile } from "./fsutil.js";
3
+ import { stateFile } from "./paths.js";
4
+ export function loadState() {
5
+ const raw = readJsonFile(stateFile());
6
+ if (!raw || !isObject(raw))
7
+ return {};
8
+ const out = {};
9
+ for (const k of ["lastReminder", "lastSync", "lastCheck"]) {
10
+ if (typeof raw[k] === "string")
11
+ out[k] = raw[k];
12
+ }
13
+ return out;
14
+ }
15
+ export function updateState(patch) {
16
+ const next = { ...loadState(), ...patch };
17
+ atomicWrite(stateFile(), JSON.stringify(next));
18
+ return next;
19
+ }