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.
@@ -0,0 +1,482 @@
1
+ // selfguard dashboard. No frameworks, no third-party code. Every piece of data comes from
2
+ // lockfiles or OSV.dev and is rendered with textContent only (never innerHTML).
3
+ "use strict";
4
+
5
+ const SEVERITIES = ["malicious", "critical", "high", "moderate", "low"];
6
+ const RANK = { malicious: 0, critical: 1, high: 2, moderate: 3, low: 4, unknown: 5 };
7
+
8
+ const state = {
9
+ token: "",
10
+ overview: null,
11
+ filter: "attention",
12
+ search: "",
13
+ selected: null, // repo key
14
+ detail: null,
15
+ scope: "rep", // packages of the representative checkout, or "all" checkouts
16
+ showAllCheckouts: false,
17
+ busy: false,
18
+ };
19
+
20
+ // ---------- tiny DOM helpers ----------
21
+
22
+ function h(tag, attrs, ...children) {
23
+ const el = document.createElement(tag);
24
+ for (const [k, v] of Object.entries(attrs || {})) {
25
+ if (v === undefined || v === null || v === false) continue;
26
+ if (k === "class") el.className = v;
27
+ else if (k.startsWith("on")) el.addEventListener(k.slice(2), v);
28
+ else el.setAttribute(k, v === true ? "" : String(v));
29
+ }
30
+ for (const c of children.flat()) {
31
+ if (c === undefined || c === null || c === false) continue;
32
+ el.append(c instanceof Node ? c : document.createTextNode(String(c)));
33
+ }
34
+ return el;
35
+ }
36
+
37
+ const $ = (id) => document.getElementById(id);
38
+
39
+ function toast(message) {
40
+ const t = $("toast");
41
+ t.textContent = message;
42
+ t.hidden = false;
43
+ clearTimeout(toast.timer);
44
+ toast.timer = setTimeout(() => (t.hidden = true), 3500);
45
+ }
46
+
47
+ function ago(iso) {
48
+ if (!iso) return "never";
49
+ const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
50
+ if (s < 60) return "just now";
51
+ if (s < 3600) return `${Math.floor(s / 60)} min ago`;
52
+ if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
53
+ const d = Math.floor(s / 86400);
54
+ return d < 60 ? `${d} d ago` : `${Math.floor(d / 30)} mo ago`;
55
+ }
56
+
57
+ function tildify(p) {
58
+ return p ? p.replace(/^\/(Users|home)\/[^/]+/, "~") : "";
59
+ }
60
+
61
+ const advisoryUrl = (id) => `https://osv.dev/vulnerability/${encodeURIComponent(id)}`;
62
+ const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
63
+
64
+ // ---------- API ----------
65
+
66
+ async function call(method, path, body) {
67
+ const res = await fetch(path, {
68
+ method,
69
+ headers: { "x-selfguard-token": state.token, ...(body ? { "content-type": "application/json" } : {}) },
70
+ body: body ? JSON.stringify(body) : undefined,
71
+ cache: "no-store",
72
+ });
73
+ const data = await res.json().catch(() => ({}));
74
+ if (!res.ok) throw new Error(data?.error?.message || `HTTP ${res.status}`);
75
+ return data;
76
+ }
77
+
78
+ async function refresh() {
79
+ try {
80
+ state.overview = await call("GET", "/api/overview");
81
+ render();
82
+ if (state.selected) await loadDetail(state.selected, { quiet: true });
83
+ } catch (e) {
84
+ $("status-line").textContent = `Not connected: ${e.message}. Run "selfguard ui" again.`;
85
+ }
86
+ }
87
+
88
+ async function loadDetail(key, opts = {}) {
89
+ try {
90
+ state.detail = await call("GET", `/api/repo?key=${encodeURIComponent(key)}`);
91
+ } catch (e) {
92
+ state.detail = null;
93
+ state.selected = null;
94
+ if (!opts.quiet) toast(e.message);
95
+ }
96
+ renderDetail();
97
+ }
98
+
99
+ async function action(button, label, fn) {
100
+ if (state.busy) return;
101
+ state.busy = true;
102
+ const original = button?.textContent;
103
+ if (button) {
104
+ button.disabled = true;
105
+ button.textContent = label;
106
+ }
107
+ try {
108
+ await fn();
109
+ } catch (e) {
110
+ toast(e.message);
111
+ } finally {
112
+ state.busy = false;
113
+ if (button) {
114
+ button.disabled = false;
115
+ button.textContent = original;
116
+ }
117
+ }
118
+ }
119
+
120
+ // ---------- derived data ----------
121
+
122
+ function attentionSet() {
123
+ return new Set(state.overview?.needsAttention || []);
124
+ }
125
+
126
+ /** The checkout that speaks for a repo: active main clone, else the most recent active one. */
127
+ function representative(g) {
128
+ const active = g.checkouts.filter((k) => k.active);
129
+ const recent = (a, b) => Date.parse(b.lastActivity || 0) - Date.parse(a.lastActivity || 0);
130
+ return active.find((k) => !k.worktree) || active.sort(recent)[0] || g.checkouts[0];
131
+ }
132
+
133
+ function groupsFor(filter) {
134
+ const repos = state.overview?.repos || [];
135
+ const att = attentionSet();
136
+ const q = state.search.trim().toLowerCase();
137
+ return repos
138
+ .filter((g) => {
139
+ if (filter === "attention") return att.has(g.key);
140
+ if (filter === "active") return g.active;
141
+ return !g.active;
142
+ })
143
+ .filter((g) => !q || g.label.toLowerCase().includes(q) || g.checkouts.some((k) => k.path.toLowerCase().includes(q)))
144
+ .map((g) => ({ g, rep: representative(g) }))
145
+ .sort((a, b) =>
146
+ (RANK[a.rep.worstSeverity] ?? 9) - (RANK[b.rep.worstSeverity] ?? 9) ||
147
+ b.rep.vulnerablePackages - a.rep.vulnerablePackages ||
148
+ a.g.label.localeCompare(b.g.label))
149
+ .map(({ g }) => g);
150
+ }
151
+
152
+ // ---------- rendering ----------
153
+
154
+ function render() {
155
+ const o = state.overview;
156
+ if (!o) return;
157
+
158
+ // status line
159
+ const parts = [`${o.activeCheckouts} active checkouts (${o.trackedProjects} tracked)`];
160
+ if (o.schedule) {
161
+ parts.push(o.schedule.enabled ? `background sync on${o.schedule.watcher ? ", lockfile watcher on" : ""}` : "background sync OFF");
162
+ }
163
+ parts.push(`last sync ${ago(o.settings.lastSync)}`);
164
+ $("status-line").textContent = parts.join(" · ");
165
+
166
+ // hero
167
+ const att = o.needsAttention.length;
168
+ const hero = $("hero");
169
+ hero.className = `hero ${att ? "bad" : "ok"}`;
170
+ $("hero-title").textContent = att ? `${plural(att, "repo")} need${att === 1 ? "s" : ""} attention` : "All clear";
171
+ $("hero-sub").textContent = att
172
+ ? "Malicious packages, critical vulnerabilities, or high ones in direct dependencies, in repos you are working on (dev-only excluded)."
173
+ : "Nothing serious in the repos you are working on. Lower-severity findings are listed under Active.";
174
+ $("tiles").replaceChildren(
175
+ ...SEVERITIES.map((s) =>
176
+ h("div", { class: `tile${o.totals[s] ? "" : " zero"}`, "data-sev": s, title: `${s} packages in active checkouts` },
177
+ h("div", { class: "n" }, o.totals[s]),
178
+ h("div", { class: "l" }, s)),
179
+ ),
180
+ );
181
+
182
+ // tabs
183
+ for (const b of $("tabs").querySelectorAll("button")) {
184
+ const f = b.dataset.filter;
185
+ b.setAttribute("aria-selected", String(f === state.filter));
186
+ const label = { attention: "Attention", active: "Active", inactive: "Inactive" }[f];
187
+ b.replaceChildren(label, h("span", { class: "count" }, groupsFor(f).length));
188
+ }
189
+
190
+ // list
191
+ const list = groupsFor(state.filter);
192
+ const ul = $("repo-list");
193
+ if (list.length === 0) {
194
+ const msg = {
195
+ attention: "Nothing needs attention right now.",
196
+ active: "No active repos match.",
197
+ inactive: "No inactive repos.",
198
+ }[state.filter];
199
+ ul.replaceChildren(h("li", { class: "empty" }, msg));
200
+ } else {
201
+ ul.replaceChildren(
202
+ ...list.map((g) => {
203
+ const rep = representative(g);
204
+ const sev = rep.worstSeverity || (rep.error ? "error" : "ok");
205
+ const shown = g.active ? g.checkouts.filter((k) => k.active) : g.checkouts;
206
+ const worse = shown.filter((k) => k !== rep && k.vulnerablePackages > rep.vulnerablePackages).length;
207
+ const meta = [
208
+ rep.vulnerablePackages ? `${plural(rep.vulnerablePackages, "vulnerable package")}` : "clean",
209
+ rep.branch ? `on ${rep.branch}` : "",
210
+ shown.length > 1 ? `${shown.length} checkouts${worse ? ` (${worse} worse)` : ""}` : "",
211
+ ].filter(Boolean).join(" · ");
212
+ return h("li", {},
213
+ h("button", {
214
+ type: "button",
215
+ "aria-current": String(g.key === state.selected),
216
+ onclick: () => select(g.key),
217
+ },
218
+ h("span", { class: "dot", "data-sev": sev }),
219
+ h("span", {}, h("div", { class: "name" }, g.label), h("div", { class: "meta" }, meta)),
220
+ rep.worstSeverity ? h("span", { class: "badge", "data-sev": rep.worstSeverity }, rep.worstSeverity) : h("span", { class: "chip ok" }, "ok")));
221
+ }),
222
+ );
223
+ }
224
+
225
+ if (!state.selected) renderHome();
226
+ }
227
+
228
+ function select(key) {
229
+ state.selected = key;
230
+ state.detail = null;
231
+ state.scope = "rep";
232
+ state.showAllCheckouts = false;
233
+ render();
234
+ renderDetail();
235
+ loadDetail(key);
236
+ }
237
+
238
+ function renderHome() {
239
+ const o = state.overview;
240
+ const panel = $("detail");
241
+ const reminder = h("select", { id: "remind" },
242
+ ...["daily", "weekly", "off"].map((v) => h("option", { value: v, selected: o.settings.remind === v }, v)));
243
+ reminder.addEventListener("change", () =>
244
+ action(null, "", async () => {
245
+ await call("POST", "/api/settings", { remind: reminder.value });
246
+ toast(`Reminder: ${reminder.value}`);
247
+ await refresh();
248
+ }));
249
+ const days = h("input", { type: "number", min: "1", max: "365", value: o.settings.activeDays });
250
+ days.addEventListener("change", () =>
251
+ action(null, "", async () => {
252
+ await call("POST", "/api/settings", { activeDays: Number(days.value) });
253
+ toast(`Active window: ${days.value} days`);
254
+ await refresh();
255
+ }));
256
+ const testBtn = h("button", { class: "btn small", type: "button" }, "Send reminder now");
257
+ testBtn.addEventListener("click", () =>
258
+ action(testBtn, "Sending…", async () => {
259
+ const r = await call("POST", "/api/remind", {});
260
+ toast(r.sent ? "Reminder sent (check your notifications)" : "Nothing serious to remind about");
261
+ }));
262
+
263
+ const schedule = o.schedule;
264
+ const scheduleNotice =
265
+ schedule && !schedule.enabled
266
+ ? h("div", { class: "notice" },
267
+ "Background watching is off, so selfguard can't alert you on its own. Turn it on with ",
268
+ h("code", {}, "selfguard schedule on"), ".")
269
+ : null;
270
+
271
+ panel.replaceChildren(
272
+ ...[
273
+ h("div", { class: "detail-head" }, h("h2", {}, "Activity")),
274
+ scheduleNotice,
275
+ renderFeed(o.events),
276
+ h("h3", {}, "Alerts"),
277
+ h("div", { class: "settings" },
278
+ h("div", { class: "setting" },
279
+ h("label", { for: "remind" }, "Reminder"),
280
+ reminder,
281
+ h("p", {}, "A notification about open malicious, critical or high issues in active repos. Sent between 9:00 and 21:00."),
282
+ h("div", { class: "gap-top" }, testBtn),
283
+ o.settings.lastReminder ? h("p", {}, `Last sent ${ago(o.settings.lastReminder)}.`) : null),
284
+ h("div", { class: "setting" },
285
+ h("label", {}, "Active window (days)"),
286
+ days,
287
+ h("p", {}, "Checkouts without git activity for longer are inactive: listed, but they only alert for malicious packages.")),
288
+ h("div", { class: "setting" },
289
+ h("label", {}, "Instant checks"),
290
+ h("p", {}, schedule?.watcher
291
+ ? `Watching ${schedule.watchedPaths} lockfiles and folders. A git pull, install or merge is checked within seconds.`
292
+ : "Lockfile watcher is not running. It starts with background watching (selfguard schedule on).")),
293
+ ),
294
+ ].filter(Boolean),
295
+ );
296
+ }
297
+
298
+ function renderFeed(events) {
299
+ const visible = (events || []).filter((e) => !(e.kind === "new" && e.firstScan)).slice(0, 40);
300
+ if (visible.length === 0) {
301
+ return h("p", { class: "muted" }, "No activity yet. New advisories, fixes and reminders will show up here.");
302
+ }
303
+ return h("ul", { class: "feed" },
304
+ ...visible.map((e) => {
305
+ const icon = { new: "⚠️", resolved: "✅", reminder: "🔔" }[e.kind] || "•";
306
+ const what =
307
+ e.kind === "new" ? `${plural(e.total, "new vulnerable package")} in ${e.repo}`
308
+ : e.kind === "resolved" ? `${plural(e.total, "vulnerable package")} fixed in ${e.repo}`
309
+ : `Reminder sent: ${e.repo}`;
310
+ const pkgs = (e.packages || []).slice(0, 4).map((p) => `${p.name}@${p.version}`).join(", ");
311
+ const more = e.total > 4 && e.packages?.length ? ` +${e.total - 4} more` : "";
312
+ return h("li", {},
313
+ h("span", { class: "icon", "aria-hidden": "true" }, icon),
314
+ h("div", {},
315
+ h("div", {}, what, e.branch ? h("span", { class: "muted" }, ` · ${e.branch}`) : null, e.active === false ? h("span", { class: "chip gap-left" }, "inactive") : null),
316
+ pkgs ? h("div", { class: "pkgs mono" }, pkgs + more) : null),
317
+ h("span", { class: "when", title: e.at }, ago(e.at)));
318
+ }));
319
+ }
320
+
321
+ function renderDetail() {
322
+ const panel = $("detail");
323
+ if (!state.selected) {
324
+ renderHome();
325
+ return;
326
+ }
327
+ const back = h("button", { class: "btn small", type: "button", onclick: () => { state.selected = null; state.detail = null; render(); } }, "← Activity");
328
+ const d = state.detail;
329
+ if (!d) {
330
+ panel.replaceChildren(h("div", { class: "detail-head" }, back), h("p", { class: "muted" }, "Loading…"));
331
+ return;
332
+ }
333
+
334
+ const rep = representative(d);
335
+ const listed = state.showAllCheckouts ? d.checkouts : d.checkouts.slice(0, 4);
336
+ const checkouts = h("div", { class: "checkouts" },
337
+ ...listed.map((k) => {
338
+ const rescan = h("button", { class: "btn small", type: "button" }, "Re-scan");
339
+ rescan.addEventListener("click", () =>
340
+ action(rescan, "Scanning…", async () => {
341
+ const r = await call("POST", "/api/scan", { path: k.path });
342
+ toast(r.project.vulnerablePackages ? `${plural(r.project.vulnerablePackages, "vulnerable package")}` : "No known vulnerabilities");
343
+ await refresh();
344
+ }));
345
+ const ignore = h("button", { class: "btn small danger", type: "button", title: "Stop tracking this folder and exclude it from discovery" }, "Ignore");
346
+ ignore.addEventListener("click", () => {
347
+ if (!confirm(`Stop tracking ${k.path}?\n\nIt will be excluded from discovery. Undo with: selfguard unexclude <path>`)) return;
348
+ action(ignore, "…", async () => {
349
+ await call("POST", "/api/forget", { path: k.path });
350
+ toast("Ignored");
351
+ await refresh();
352
+ });
353
+ });
354
+ const status = k.error && !k.vulnerablePackages
355
+ ? h("span", { class: "chip", title: k.error }, "error")
356
+ : k.vulnerablePackages
357
+ ? h("span", { class: "badge", "data-sev": k.worstSeverity }, `${k.vulnerablePackages} vulnerable`)
358
+ : h("span", { class: "chip ok" }, "clean");
359
+ return h("div", { class: `checkout${k.active ? "" : " inactive"}` },
360
+ h("div", {},
361
+ h("div", {},
362
+ h("strong", {}, k.branch || "(no branch)"), " ",
363
+ k.worktree ? h("span", { class: "chip" }, "worktree") : null, " ",
364
+ k.active ? null : h("span", { class: "chip" }, `inactive${k.lastActivity ? ` · ${ago(k.lastActivity)}` : ""}`), " ",
365
+ status),
366
+ h("div", { class: "path mono", title: k.path }, tildify(k.path)),
367
+ k.error ? h("div", { class: "path" }, k.error) : null),
368
+ h("div", { class: "actions" }, rescan, ignore));
369
+ }));
370
+
371
+ const moreCheckouts = d.checkouts.length > listed.length
372
+ ? h("button", { class: "btn small gap-top", type: "button", onclick: () => { state.showAllCheckouts = true; renderDetail(); } },
373
+ `Show all ${d.checkouts.length} checkouts`)
374
+ : null;
375
+
376
+ const many = d.checkouts.length > 1;
377
+ const pkgs = state.scope === "rep" && many ? d.packages.filter((p) => p.checkouts.some((c) => c.path === rep.path)) : d.packages;
378
+ const scopeToggle = many
379
+ ? h("div", { class: "tabs scope" },
380
+ ...[["rep", `On ${rep.branch || "main checkout"}`], ["all", "All checkouts"]].map(([v, label]) =>
381
+ h("button", { type: "button", "aria-selected": String(state.scope === v), onclick: () => { state.scope = v; renderDetail(); } }, label)))
382
+ : null;
383
+
384
+ let table;
385
+ if (pkgs.length === 0) {
386
+ table = h("p", { class: "muted" }, state.scope === "rep" && many ? `No known vulnerabilities on ${rep.branch || "this checkout"}. 🎉` : "No known vulnerabilities in any checkout. 🎉");
387
+ } else {
388
+ table = h("table", {},
389
+ h("thead", {}, h("tr", {}, h("th", {}, "Severity"), h("th", {}, "Package"), h("th", {}, "Fix"), h("th", {}, "Where"))),
390
+ h("tbody", {},
391
+ ...pkgs.map((p) => {
392
+ const activeHere = p.checkouts.some((c) => c.active);
393
+ const fix = p.worstSeverity === "malicious"
394
+ ? h("span", { class: "fix-remove" }, "Remove it, rotate credentials")
395
+ : p.upgradeTo
396
+ ? h("span", { class: "fix-ok" }, `→ ${p.upgradeTo}`)
397
+ : h("span", { class: "fix-none" }, "No fix yet");
398
+ const kind = [p.direct ? "direct" : "transitive", p.dev ? "dev" : null].filter(Boolean).join(" · ");
399
+ const branches = [...new Set(p.checkouts.map((c) => c.branch || "?"))];
400
+ return h("tr", { class: activeHere ? "" : "dim" },
401
+ h("td", {}, h("span", { class: "badge", "data-sev": p.worstSeverity }, p.worstSeverity)),
402
+ h("td", {},
403
+ h("div", { class: "mono" }, `${p.name}@${p.version}`),
404
+ h("div", { class: "muted small" }, kind),
405
+ h("details", { class: "adv" },
406
+ h("summary", {}, plural(p.advisories.length, "advisory")),
407
+ h("ul", {},
408
+ ...p.advisories.map((a) =>
409
+ h("li", {},
410
+ h("a", { href: advisoryUrl(a.id), target: "_blank", rel: "noopener noreferrer" }, a.id),
411
+ " ", h("span", { class: "muted" }, a.severity),
412
+ a.summary ? h("div", {}, a.summary) : null))))),
413
+ h("td", { class: "fix" }, fix, p.advisoriesWithoutFix && p.upgradeTo ? h("div", { class: "muted small" }, `${p.advisoriesWithoutFix} without fix`) : null),
414
+ h("td", {}, h("div", { class: "small" }, branches.slice(0, 3).join(", ") + (branches.length > 3 ? ` +${branches.length - 3}` : ""))));
415
+ })));
416
+ }
417
+
418
+ const serious = pkgs.filter((p) => p.worstSeverity === "malicious" || (!p.dev && (p.worstSeverity === "critical" || (p.worstSeverity === "high" && p.direct)))).length;
419
+ const direct = pkgs.filter((p) => p.direct && p.upgradeTo).length;
420
+ const summary = pkgs.length
421
+ ? h("p", { class: "muted" },
422
+ `${plural(pkgs.length, "vulnerable package")}, ${serious} serious. `,
423
+ direct ? `${direct === 1 ? "1 direct dependency" : `${direct} direct dependencies`} can be bumped directly; the rest are transitive (upgrade the package that pulls them in, or refresh the lockfile).` : "All are transitive: upgrade the packages that pull them in, or refresh the lockfile.")
424
+ : null;
425
+
426
+ panel.replaceChildren(
427
+ ...[
428
+ h("div", { class: "detail-head" }, h("h2", {}, d.label), back),
429
+ h("div", { class: "detail-head gap-top" }, h("h3", { class: "inline" }, "Vulnerable packages"), scopeToggle),
430
+ summary,
431
+ table,
432
+ h("h3", {}, `Checkouts (${d.checkouts.length})`),
433
+ checkouts,
434
+ moreCheckouts,
435
+ ].filter(Boolean),
436
+ );
437
+ }
438
+
439
+ // ---------- boot ----------
440
+
441
+ function boot() {
442
+ const params = new URLSearchParams(location.hash.slice(1));
443
+ const t = params.get("t");
444
+ if (t && /^[A-Za-z0-9_-]+$/.test(t)) sessionStorage.setItem("selfguard-token", t);
445
+ const deepLink = params.get("repo");
446
+ if (location.hash) history.replaceState(null, "", location.pathname);
447
+ state.token = sessionStorage.getItem("selfguard-token") || "";
448
+
449
+ for (const b of $("tabs").querySelectorAll("button")) {
450
+ b.addEventListener("click", () => {
451
+ state.filter = b.dataset.filter;
452
+ render();
453
+ });
454
+ }
455
+ $("search").addEventListener("input", (e) => {
456
+ state.search = e.target.value;
457
+ render();
458
+ });
459
+ const syncBtn = $("sync-btn");
460
+ syncBtn.addEventListener("click", () =>
461
+ action(syncBtn, "Syncing…", async () => {
462
+ const r = await call("POST", "/api/sync", {});
463
+ const fresh = r.news.reduce((n, p) => n + p.packages.length, 0);
464
+ toast(`Synced ${r.projects} projects${fresh ? `, ${fresh} newly vulnerable packages` : ", nothing new"}`);
465
+ await refresh();
466
+ }));
467
+
468
+ refresh().then(() => {
469
+ // Open on the most useful tab.
470
+ if (state.overview && state.overview.needsAttention.length === 0) {
471
+ state.filter = "active";
472
+ render();
473
+ }
474
+ if (deepLink) {
475
+ const g = state.overview?.repos.find((r) => r.key === deepLink || r.label === deepLink);
476
+ if (g) select(g.key);
477
+ }
478
+ });
479
+ setInterval(refresh, 30_000);
480
+ }
481
+
482
+ boot();
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M16 2 4 6.5v8.7c0 7.4 5.1 13.3 12 14.8 6.9-1.5 12-7.4 12-14.8V6.5L16 2Z" fill="#1f6feb"/><path d="m10.5 16.2 3.8 3.8 7.4-7.6" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
@@ -0,0 +1,47 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="referrer" content="no-referrer">
7
+ <title>selfguard</title>
8
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml">
9
+ <link rel="stylesheet" href="/app.css">
10
+ <script src="/app.js" defer></script>
11
+ </head>
12
+ <body>
13
+ <header class="topbar">
14
+ <div class="brand"><img src="/favicon.svg" alt="" width="22" height="22"><span>selfguard</span></div>
15
+ <div class="status-line" id="status-line">Loading…</div>
16
+ <button class="btn" id="sync-btn" type="button">Sync now</button>
17
+ </header>
18
+
19
+ <main>
20
+ <section class="hero" id="hero" aria-live="polite">
21
+ <div class="hero-text">
22
+ <h1 id="hero-title">…</h1>
23
+ <p id="hero-sub" class="muted"></p>
24
+ </div>
25
+ <div class="tiles" id="tiles"></div>
26
+ </section>
27
+
28
+ <div class="layout">
29
+ <section class="panel repos-panel" aria-label="Repositories">
30
+ <div class="panel-head">
31
+ <div class="tabs" role="tablist" id="tabs">
32
+ <button role="tab" data-filter="attention" type="button">Attention</button>
33
+ <button role="tab" data-filter="active" type="button">Active</button>
34
+ <button role="tab" data-filter="inactive" type="button">Inactive</button>
35
+ </div>
36
+ <input type="search" id="search" placeholder="Filter repos…" autocomplete="off" spellcheck="false">
37
+ </div>
38
+ <ul class="repo-list" id="repo-list"></ul>
39
+ </section>
40
+
41
+ <section class="panel detail-panel" id="detail" aria-live="polite"></section>
42
+ </div>
43
+ </main>
44
+
45
+ <div class="toast" id="toast" role="status" hidden></div>
46
+ </body>
47
+ </html>
@@ -0,0 +1,2 @@
1
+ // Kept in sync with package.json by hand (avoids reading files at runtime).
2
+ export const VERSION = "0.2.0";
package/package.json CHANGED
@@ -1,8 +1,49 @@
1
1
  {
2
2
  "name": "selfguard",
3
- "version": "0.0.1",
4
- "description": "Name reserved for selfguard: local-first guard for the dependencies of every JS repo you work on. First release coming soon.",
3
+ "version": "0.2.0",
4
+ "description": "Local-first guard that watches every JS repo you work on and alerts you about vulnerable or malicious dependencies.",
5
5
  "license": "MIT",
6
- "repository": { "type": "git", "url": "git+https://github.com/josseed/selfguard.git" },
7
- "homepage": "https://github.com/josseed/selfguard#readme"
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/josseed/selfguard.git"
9
+ },
10
+ "homepage": "https://github.com/josseed/selfguard#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/josseed/selfguard/issues"
13
+ },
14
+ "type": "module",
15
+ "bin": {
16
+ "selfguard": "dist/cli.js"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "engines": {
24
+ "node": ">=20.10"
25
+ },
26
+ "os": [
27
+ "darwin",
28
+ "linux"
29
+ ],
30
+ "scripts": {
31
+ "build": "node scripts/clean.mjs && tsc -p tsconfig.build.json && node scripts/postbuild.mjs",
32
+ "typecheck": "tsc -p tsconfig.json --noEmit",
33
+ "test": "node --test \"test/**/*.test.ts\"",
34
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
35
+ },
36
+ "keywords": [
37
+ "security",
38
+ "vulnerabilities",
39
+ "osv",
40
+ "npm",
41
+ "supply-chain",
42
+ "audit",
43
+ "cli"
44
+ ],
45
+ "devDependencies": {
46
+ "@types/node": "24.19.2",
47
+ "typescript": "7.0.2"
48
+ }
8
49
  }