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 +100 -119
- package/dist/activity.js +12 -0
- package/dist/alerts.js +94 -1
- package/dist/announce.js +136 -0
- package/dist/api.js +186 -19
- package/dist/cli.js +158 -29
- package/dist/config.js +7 -1
- package/dist/events.js +61 -0
- package/dist/guide.js +10 -2
- package/dist/mcp.js +37 -1
- package/dist/paths.js +3 -0
- package/dist/repoid.js +26 -0
- package/dist/scan.js +32 -3
- package/dist/schedule.js +87 -27
- package/dist/setup.js +7 -4
- package/dist/state.js +19 -0
- package/dist/ui/server.js +325 -0
- package/dist/ui/static/app.css +263 -0
- package/dist/ui/static/app.js +482 -0
- package/dist/ui/static/favicon.svg +1 -0
- package/dist/ui/static/index.html +47 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
|
166
|
-
|
|
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
|
-
/**
|
|
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
|
|
10
|
-
const
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
@@ -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
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
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>${
|
|
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
|
-
|
|
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(
|
|
45
|
-
<key>StandardErrorPath</key><string>${xml(
|
|
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()}/${
|
|
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
|
-
|
|
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
|
-
|
|
106
|
+
current = readFileSync(plistPath(WATCH_LABEL), "utf8");
|
|
63
107
|
}
|
|
64
108
|
catch {
|
|
65
|
-
/* not
|
|
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
|
|
128
|
+
let paths = 0;
|
|
74
129
|
try {
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
134
|
+
paths = 0;
|
|
80
135
|
}
|
|
81
|
-
return {
|
|
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("
|
|
227
|
-
scheduleOn(6 * 3600);
|
|
228
|
-
out.write(`${c.green("✓")} Enabled.
|
|
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
|
|
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
|
+
}
|