selfguard 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +152 -3
- package/dist/alerts.js +96 -0
- package/dist/api.js +327 -0
- package/dist/cli.js +462 -0
- package/dist/config.js +70 -0
- package/dist/discover.js +73 -0
- package/dist/fsutil.js +66 -0
- package/dist/guide.js +53 -0
- package/dist/hook.js +114 -0
- package/dist/lockfile/bun.js +113 -0
- package/dist/lockfile/common.js +79 -0
- package/dist/lockfile/detect.js +42 -0
- package/dist/lockfile/index.js +22 -0
- package/dist/lockfile/npm.js +77 -0
- package/dist/lockfile/pnpm.js +121 -0
- package/dist/lockfile/types.js +1 -0
- package/dist/lockfile/yaml.js +140 -0
- package/dist/lockfile/yarn.js +136 -0
- package/dist/match.js +81 -0
- package/dist/mcp.js +212 -0
- package/dist/notify.js +43 -0
- package/dist/osv.js +142 -0
- package/dist/paths.js +13 -0
- package/dist/repoid.js +145 -0
- package/dist/report.js +126 -0
- package/dist/sanitize.js +12 -0
- package/dist/scan.js +168 -0
- package/dist/schedule.js +82 -0
- package/dist/semver.js +65 -0
- package/dist/setup.js +233 -0
- package/dist/store.js +37 -0
- package/dist/tui.js +132 -0
- package/dist/version.js +2 -0
- package/package.json +45 -4
package/dist/semver.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal SemVer 2.0.0 parser/comparator (no dependencies on purpose).
|
|
3
|
+
* Only what we need to evaluate OSV ranges for the npm ecosystem.
|
|
4
|
+
*/
|
|
5
|
+
const SEMVER_RE = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
6
|
+
export function parse(input) {
|
|
7
|
+
if (typeof input !== "string" || input.length > 256)
|
|
8
|
+
return null;
|
|
9
|
+
const m = SEMVER_RE.exec(input.trim());
|
|
10
|
+
if (!m)
|
|
11
|
+
return null;
|
|
12
|
+
const major = Number(m[1]);
|
|
13
|
+
const minor = Number(m[2]);
|
|
14
|
+
const patch = Number(m[3]);
|
|
15
|
+
if (![major, minor, patch].every(Number.isSafeInteger))
|
|
16
|
+
return null;
|
|
17
|
+
const pre = m[4]
|
|
18
|
+
? m[4].split(".").map((id) => (/^\d+$/.test(id) && Number.isSafeInteger(Number(id)) ? Number(id) : id))
|
|
19
|
+
: [];
|
|
20
|
+
return { major, minor, patch, pre };
|
|
21
|
+
}
|
|
22
|
+
export function isValid(input) {
|
|
23
|
+
return parse(input) !== null;
|
|
24
|
+
}
|
|
25
|
+
function cmpNum(a, b) {
|
|
26
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
27
|
+
}
|
|
28
|
+
function cmpIdent(a, b) {
|
|
29
|
+
const an = typeof a === "number";
|
|
30
|
+
const bn = typeof b === "number";
|
|
31
|
+
if (an && bn)
|
|
32
|
+
return cmpNum(a, b);
|
|
33
|
+
if (an)
|
|
34
|
+
return -1; // numeric identifiers have lower precedence
|
|
35
|
+
if (bn)
|
|
36
|
+
return 1;
|
|
37
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
38
|
+
}
|
|
39
|
+
export function compareParsed(a, b) {
|
|
40
|
+
const core = cmpNum(a.major, b.major) || cmpNum(a.minor, b.minor) || cmpNum(a.patch, b.patch);
|
|
41
|
+
if (core !== 0)
|
|
42
|
+
return core;
|
|
43
|
+
// A version without pre-release has higher precedence than one with.
|
|
44
|
+
if (a.pre.length === 0 && b.pre.length === 0)
|
|
45
|
+
return 0;
|
|
46
|
+
if (a.pre.length === 0)
|
|
47
|
+
return 1;
|
|
48
|
+
if (b.pre.length === 0)
|
|
49
|
+
return -1;
|
|
50
|
+
const len = Math.min(a.pre.length, b.pre.length);
|
|
51
|
+
for (let i = 0; i < len; i++) {
|
|
52
|
+
const c = cmpIdent(a.pre[i], b.pre[i]);
|
|
53
|
+
if (c !== 0)
|
|
54
|
+
return c;
|
|
55
|
+
}
|
|
56
|
+
return cmpNum(a.pre.length, b.pre.length);
|
|
57
|
+
}
|
|
58
|
+
/** Compare two version strings. Throws if either is not valid SemVer. */
|
|
59
|
+
export function compare(a, b) {
|
|
60
|
+
const pa = parse(a);
|
|
61
|
+
const pb = parse(b);
|
|
62
|
+
if (!pa || !pb)
|
|
63
|
+
throw new Error(`invalid semver: ${!pa ? a : b}`);
|
|
64
|
+
return compareParsed(pa, pb);
|
|
65
|
+
}
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/** `selfguard setup`: interactive wizard to choose where your repos live. */
|
|
2
|
+
import { appendFileSync, readFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { candidateRoots, clampDepth, DEFAULT_DEPTH, loadConfig, MAX_DEPTH, normalizeDir, saveConfig } from "./config.js";
|
|
6
|
+
import { discover, discoverAll } from "./discover.js";
|
|
7
|
+
import { groupByPackage, paint, summarizeCounts, tildify, worstSeverity } from "./report.js";
|
|
8
|
+
import { scanProjects } from "./scan.js";
|
|
9
|
+
import { scheduleOn, scheduleStatus } from "./schedule.js";
|
|
10
|
+
import { listRepos, saveRepo } from "./store.js";
|
|
11
|
+
import { confirm, fitStart, isPrintable, readKeys, Screen, withSpinner } from "./tui.js";
|
|
12
|
+
const out = process.stdout;
|
|
13
|
+
/** Interactive folder picker. Resolves to the chosen roots, or null if cancelled. */
|
|
14
|
+
async function pickRoots(initial, exclude) {
|
|
15
|
+
const c = paint(out);
|
|
16
|
+
const items = [];
|
|
17
|
+
const add = (path, selected, depth) => {
|
|
18
|
+
if (items.some((i) => i.path.toLowerCase() === path.toLowerCase()))
|
|
19
|
+
return;
|
|
20
|
+
items.push({ path, selected, depth, count: null, generation: 0 });
|
|
21
|
+
};
|
|
22
|
+
for (const r of initial)
|
|
23
|
+
add(r.path, true, r.depth);
|
|
24
|
+
for (const p of candidateRoots())
|
|
25
|
+
add(p, false, DEFAULT_DEPTH);
|
|
26
|
+
let cursor = 0; // items.length = the "add folder" row
|
|
27
|
+
let mode = "list";
|
|
28
|
+
let input = "";
|
|
29
|
+
let message = "";
|
|
30
|
+
const screen = new Screen();
|
|
31
|
+
const draw = () => {
|
|
32
|
+
const lines = [];
|
|
33
|
+
lines.push(c.bold("Where do you keep your code?"));
|
|
34
|
+
lines.push(c.dim("selfguard only looks inside the folders you pick, nothing else on your disk."));
|
|
35
|
+
lines.push("");
|
|
36
|
+
const width = Math.max(20, Math.min(50, (out.columns ?? 80) - 40));
|
|
37
|
+
items.forEach((it, i) => {
|
|
38
|
+
const pointer = i === cursor && mode === "list" ? c.cyan("›") : " ";
|
|
39
|
+
const box = it.selected ? c.green("[x]") : "[ ]";
|
|
40
|
+
const name = fitStart(tildify(it.path), width).padEnd(width);
|
|
41
|
+
const label = (it.count === null ? "counting…" : it.count === 0 ? "no JS projects" : `${it.count} project${it.count === 1 ? "" : "s"}`).padEnd(14);
|
|
42
|
+
const count = it.count ? label : c.dim(label);
|
|
43
|
+
lines.push(`${pointer} ${box} ${i === cursor ? c.bold(name) : name} ${count} ${c.dim(`depth ${it.depth}`)}`);
|
|
44
|
+
});
|
|
45
|
+
const addRow = cursor === items.length && mode === "list";
|
|
46
|
+
lines.push(`${addRow ? c.cyan("›") : " "} ${c.cyan("+")} ${addRow ? c.bold("add another folder…") : "add another folder…"}`);
|
|
47
|
+
lines.push("");
|
|
48
|
+
if (mode === "input") {
|
|
49
|
+
lines.push(`${c.cyan("Folder:")} ${input}${c.inverseRed(" ")}`);
|
|
50
|
+
lines.push(c.dim("enter add · esc cancel · ~ = home"));
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
lines.push(c.dim("↑/↓ move · space select · ←/→ depth · a add folder · enter continue · q quit"));
|
|
54
|
+
}
|
|
55
|
+
lines.push(message ? c.yellow(message) : "");
|
|
56
|
+
screen.render(lines);
|
|
57
|
+
};
|
|
58
|
+
const count = (it) => {
|
|
59
|
+
const gen = ++it.generation;
|
|
60
|
+
it.count = null;
|
|
61
|
+
discover(it.path, { depth: it.depth, exclude, isCancelled: () => it.generation !== gen }).then((found) => {
|
|
62
|
+
if (it.generation !== gen)
|
|
63
|
+
return;
|
|
64
|
+
it.count = found.length;
|
|
65
|
+
draw();
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
items.forEach(count);
|
|
69
|
+
draw();
|
|
70
|
+
return readKeys((key, finish) => {
|
|
71
|
+
message = "";
|
|
72
|
+
if (mode === "input") {
|
|
73
|
+
if (key.name === "escape") {
|
|
74
|
+
mode = "list";
|
|
75
|
+
input = "";
|
|
76
|
+
}
|
|
77
|
+
else if (key.name === "return" || key.name === "enter") {
|
|
78
|
+
const dir = normalizeDir(input);
|
|
79
|
+
if (!dir) {
|
|
80
|
+
message = `Not a folder: ${input}`;
|
|
81
|
+
}
|
|
82
|
+
else if (dir === homedir() || dir === "/") {
|
|
83
|
+
message = "Pick a more specific folder than your whole home directory.";
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
add(dir, true, DEFAULT_DEPTH);
|
|
87
|
+
const it = items.find((i) => i.path === dir);
|
|
88
|
+
it.selected = true;
|
|
89
|
+
count(it);
|
|
90
|
+
cursor = items.indexOf(it);
|
|
91
|
+
mode = "list";
|
|
92
|
+
input = "";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
else if (key.name === "backspace") {
|
|
96
|
+
input = input.slice(0, -1);
|
|
97
|
+
}
|
|
98
|
+
else if (isPrintable(key) && input.length < 400) {
|
|
99
|
+
input += key.sequence;
|
|
100
|
+
}
|
|
101
|
+
draw();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const it = items[cursor];
|
|
105
|
+
switch (key.name) {
|
|
106
|
+
case "up":
|
|
107
|
+
case "k":
|
|
108
|
+
cursor = cursor === 0 ? items.length : cursor - 1;
|
|
109
|
+
break;
|
|
110
|
+
case "down":
|
|
111
|
+
case "j":
|
|
112
|
+
cursor = cursor === items.length ? 0 : cursor + 1;
|
|
113
|
+
break;
|
|
114
|
+
case "space":
|
|
115
|
+
if (it)
|
|
116
|
+
it.selected = !it.selected;
|
|
117
|
+
else
|
|
118
|
+
mode = "input";
|
|
119
|
+
break;
|
|
120
|
+
case "left":
|
|
121
|
+
case "right":
|
|
122
|
+
if (it) {
|
|
123
|
+
const d = clampDepth(it.depth + (key.name === "right" ? 1 : -1));
|
|
124
|
+
if (d !== it.depth) {
|
|
125
|
+
it.depth = d;
|
|
126
|
+
count(it);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
message = `Depth goes from 1 to ${MAX_DEPTH}.`;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
break;
|
|
133
|
+
case "a":
|
|
134
|
+
mode = "input";
|
|
135
|
+
break;
|
|
136
|
+
case "return":
|
|
137
|
+
case "enter":
|
|
138
|
+
if (!it && cursor === items.length) {
|
|
139
|
+
mode = "input";
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
if (!items.some((i) => i.selected)) {
|
|
143
|
+
message = "Select at least one folder (space), or press q to quit.";
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
screen.done();
|
|
147
|
+
finish(items.filter((i) => i.selected).map((i) => ({ path: i.path, depth: i.depth })));
|
|
148
|
+
return;
|
|
149
|
+
case "q":
|
|
150
|
+
case "escape":
|
|
151
|
+
screen.done();
|
|
152
|
+
finish(null);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
draw();
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
function hasZshHook() {
|
|
159
|
+
try {
|
|
160
|
+
return readFileSync(join(homedir(), ".zshrc"), "utf8").includes("selfguard init zsh");
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function printScanSummary(outcomes) {
|
|
167
|
+
const c = paint(out);
|
|
168
|
+
const risky = outcomes
|
|
169
|
+
.filter((o) => o.record.findings.length > 0)
|
|
170
|
+
.sort((a, b) => b.record.findings.length - a.record.findings.length);
|
|
171
|
+
const unsupported = outcomes.filter((o) => o.record.error && o.record.findings.length === 0).length;
|
|
172
|
+
out.write(`\n${c.bold(`${outcomes.length} project(s) scanned.`)}\n`);
|
|
173
|
+
if (risky.length === 0)
|
|
174
|
+
out.write(`${c.green("✓")} no known vulnerabilities\n`);
|
|
175
|
+
for (const o of risky.slice(0, 10)) {
|
|
176
|
+
const w = worstSeverity(o.record.findings);
|
|
177
|
+
const color = w === "malicious" || w === "critical" || w === "high" ? c.red : c.yellow;
|
|
178
|
+
const pkgs = groupByPackage(o.record.findings).length;
|
|
179
|
+
out.write(` ${color("●")} ${c.bold(tildify(o.record.path))} ${pkgs} vulnerable pkg(s) ${c.dim(`(${summarizeCounts(o.record.findings)})`)}\n`);
|
|
180
|
+
}
|
|
181
|
+
if (risky.length > 10)
|
|
182
|
+
out.write(c.dim(` …and ${risky.length - 10} more (selfguard status)\n`));
|
|
183
|
+
if (unsupported > 0)
|
|
184
|
+
out.write(c.dim(` ${unsupported} project(s) skipped: no lockfile yet, or unreadable\n`));
|
|
185
|
+
}
|
|
186
|
+
export async function runSetup() {
|
|
187
|
+
const c = paint(out);
|
|
188
|
+
out.write(`\n${c.bold(c.cyan("selfguard setup"))}\n\n`);
|
|
189
|
+
const cfg = loadConfig();
|
|
190
|
+
const roots = await pickRoots(cfg.roots, cfg.exclude);
|
|
191
|
+
if (!roots) {
|
|
192
|
+
out.write("Cancelled, nothing changed.\n");
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
saveConfig({ ...cfg, roots });
|
|
196
|
+
out.write(`${c.green("✓")} Saved ${roots.length} folder(s) to watch.\n\n`);
|
|
197
|
+
const projects = await withSpinner("Looking for projects…", () => discoverAll(roots, { exclude: cfg.exclude }));
|
|
198
|
+
const known = new Set(listRepos().map((r) => r.path));
|
|
199
|
+
const fresh = projects.filter((p) => !known.has(p.root));
|
|
200
|
+
out.write(`Found ${c.bold(String(projects.length))} project(s), ${c.bold(String(fresh.length))} not tracked yet.\n`);
|
|
201
|
+
for (const p of fresh.slice(0, 8))
|
|
202
|
+
out.write(c.dim(` ${tildify(p.root)}\n`));
|
|
203
|
+
if (fresh.length > 8)
|
|
204
|
+
out.write(c.dim(` …and ${fresh.length - 8} more\n`));
|
|
205
|
+
if (fresh.length > 0) {
|
|
206
|
+
out.write("\n");
|
|
207
|
+
if (await confirm(`Scan ${fresh.length} new project(s) now? (sends only package names+versions to OSV.dev)`)) {
|
|
208
|
+
const outcomes = await withSpinner(`Scanning ${fresh.length} project(s)…`, () => scanProjects(fresh));
|
|
209
|
+
for (const o of outcomes)
|
|
210
|
+
saveRepo(o.record);
|
|
211
|
+
printScanSummary(outcomes);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
out.write("\n");
|
|
215
|
+
if (hasZshHook()) {
|
|
216
|
+
out.write(`${c.green("✓")} Shell hook already in ~/.zshrc\n`);
|
|
217
|
+
}
|
|
218
|
+
else if (await confirm("Add the shell hook to ~/.zshrc (alerts when you cd into a project)?")) {
|
|
219
|
+
appendFileSync(join(homedir(), ".zshrc"), `\n# selfguard: dependency vulnerability alerts\neval "$(selfguard init zsh)"\n`);
|
|
220
|
+
out.write(`${c.green("✓")} Added. Open a new terminal to activate it.\n`);
|
|
221
|
+
}
|
|
222
|
+
if (process.platform === "darwin") {
|
|
223
|
+
if (scheduleStatus().loaded) {
|
|
224
|
+
out.write(`${c.green("✓")} Background sync already enabled\n`);
|
|
225
|
+
}
|
|
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
|
+
}
|
|
230
|
+
}
|
|
231
|
+
out.write(`\nDone. ${c.dim("selfguard status · selfguard setup (change folders) · selfguard --help")}\n`);
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { atomicWrite, isObject, readJsonFile, sha256 } from "./fsutil.js";
|
|
4
|
+
import { reposDir } from "./paths.js";
|
|
5
|
+
export const findingKey = (f) => `${f.id}|${f.name}@${f.version}`;
|
|
6
|
+
function recordFile(path) {
|
|
7
|
+
return join(reposDir(), `${sha256(path).slice(0, 40)}.json`);
|
|
8
|
+
}
|
|
9
|
+
export function loadRepo(path) {
|
|
10
|
+
const rec = readJsonFile(recordFile(path));
|
|
11
|
+
return rec && isObject(rec) && rec.schema === 1 && rec.path === path ? rec : null;
|
|
12
|
+
}
|
|
13
|
+
export function saveRepo(rec) {
|
|
14
|
+
atomicWrite(recordFile(rec.path), JSON.stringify(rec));
|
|
15
|
+
}
|
|
16
|
+
export function forgetRepo(path) {
|
|
17
|
+
const file = recordFile(path);
|
|
18
|
+
const existed = loadRepo(path) !== null;
|
|
19
|
+
rmSync(file, { force: true });
|
|
20
|
+
return existed;
|
|
21
|
+
}
|
|
22
|
+
export function listRepos() {
|
|
23
|
+
let files;
|
|
24
|
+
try {
|
|
25
|
+
files = readdirSync(reposDir()).filter((f) => f.endsWith(".json"));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const f of files) {
|
|
32
|
+
const rec = readJsonFile(join(reposDir(), f));
|
|
33
|
+
if (rec && isObject(rec) && rec.schema === 1 && typeof rec.path === "string")
|
|
34
|
+
out.push(rec);
|
|
35
|
+
}
|
|
36
|
+
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
37
|
+
}
|
package/dist/tui.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny dependency-free terminal UI primitives: redraw-in-place rendering and
|
|
3
|
+
* raw key handling with guaranteed terminal restore.
|
|
4
|
+
*/
|
|
5
|
+
import { emitKeypressEvents } from "node:readline";
|
|
6
|
+
const stdin = process.stdin;
|
|
7
|
+
const stdout = process.stdout;
|
|
8
|
+
export function isInteractive() {
|
|
9
|
+
return Boolean(stdin.isTTY && stdout.isTTY);
|
|
10
|
+
}
|
|
11
|
+
const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
|
|
12
|
+
export function visibleLength(s) {
|
|
13
|
+
return s.replace(ANSI, "").length;
|
|
14
|
+
}
|
|
15
|
+
export function columns() {
|
|
16
|
+
return Math.max(40, stdout.columns ?? 80);
|
|
17
|
+
}
|
|
18
|
+
/** Truncate plain (uncolored) text to fit the terminal, keeping the end of paths visible. */
|
|
19
|
+
export function fitStart(s, max) {
|
|
20
|
+
return s.length <= max ? s : `…${s.slice(s.length - max + 1)}`;
|
|
21
|
+
}
|
|
22
|
+
/** Redraws a block of lines in place. */
|
|
23
|
+
export class Screen {
|
|
24
|
+
height = 0;
|
|
25
|
+
render(lines) {
|
|
26
|
+
const width = columns() - 1;
|
|
27
|
+
// Never let a line wrap: wrapped lines would break the in-place redraw.
|
|
28
|
+
const safe = lines.map((l) => (visibleLength(l) > width ? `${l.replace(ANSI, "").slice(0, width - 1)}…` : l));
|
|
29
|
+
let out = "";
|
|
30
|
+
if (this.height > 0)
|
|
31
|
+
out += `\x1b[${this.height}A`;
|
|
32
|
+
out += "\r\x1b[J";
|
|
33
|
+
out += safe.join("\n");
|
|
34
|
+
this.height = safe.length - 1;
|
|
35
|
+
stdout.write(out);
|
|
36
|
+
}
|
|
37
|
+
/** Leave the last frame on screen and move below it. */
|
|
38
|
+
done() {
|
|
39
|
+
stdout.write("\n");
|
|
40
|
+
this.height = 0;
|
|
41
|
+
}
|
|
42
|
+
clear() {
|
|
43
|
+
if (this.height > 0)
|
|
44
|
+
stdout.write(`\x1b[${this.height}A`);
|
|
45
|
+
stdout.write("\r\x1b[J");
|
|
46
|
+
this.height = 0;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
let restoreInstalled = false;
|
|
50
|
+
function restoreTerminal() {
|
|
51
|
+
try {
|
|
52
|
+
if (stdin.isTTY)
|
|
53
|
+
stdin.setRawMode(false);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
/* ignore */
|
|
57
|
+
}
|
|
58
|
+
stdout.write("\x1b[?25h"); // show cursor
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Feed raw keypresses to `onKey` until it calls `finish(value)`.
|
|
62
|
+
* Ctrl+C always exits cleanly with the terminal restored.
|
|
63
|
+
*/
|
|
64
|
+
export function readKeys(onKey) {
|
|
65
|
+
if (!restoreInstalled) {
|
|
66
|
+
process.on("exit", restoreTerminal);
|
|
67
|
+
restoreInstalled = true;
|
|
68
|
+
}
|
|
69
|
+
emitKeypressEvents(stdin);
|
|
70
|
+
stdin.setRawMode(true);
|
|
71
|
+
stdin.resume();
|
|
72
|
+
stdout.write("\x1b[?25l"); // hide cursor
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
const cleanup = () => {
|
|
75
|
+
stdin.off("keypress", handler);
|
|
76
|
+
stdin.setRawMode(false);
|
|
77
|
+
stdin.pause();
|
|
78
|
+
stdout.write("\x1b[?25h");
|
|
79
|
+
};
|
|
80
|
+
const handler = (str, key) => {
|
|
81
|
+
const k = { ...(key ?? {}), ...(str !== undefined ? { sequence: str } : {}) };
|
|
82
|
+
if (k.ctrl && k.name === "c") {
|
|
83
|
+
cleanup();
|
|
84
|
+
stdout.write("\n");
|
|
85
|
+
process.exit(130);
|
|
86
|
+
}
|
|
87
|
+
onKey(k, (value) => {
|
|
88
|
+
cleanup();
|
|
89
|
+
resolve(value);
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
stdin.on("keypress", handler);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/** Single-key yes/no question. Enter picks the default. */
|
|
96
|
+
export async function confirm(question, def = true) {
|
|
97
|
+
const hint = def ? "[Y/n]" : "[y/N]";
|
|
98
|
+
stdout.write(`${question} ${hint} `);
|
|
99
|
+
const answer = await readKeys((key, finish) => {
|
|
100
|
+
if (key.name === "return" || key.name === "enter")
|
|
101
|
+
finish(def);
|
|
102
|
+
else if (key.name === "y")
|
|
103
|
+
finish(true);
|
|
104
|
+
else if (key.name === "n" || key.name === "escape")
|
|
105
|
+
finish(false);
|
|
106
|
+
});
|
|
107
|
+
stdout.write(`${answer ? "yes" : "no"}\n`);
|
|
108
|
+
return answer;
|
|
109
|
+
}
|
|
110
|
+
/** Is this a printable character we can insert into a text field? */
|
|
111
|
+
export function isPrintable(key) {
|
|
112
|
+
const s = key.sequence;
|
|
113
|
+
return !key.ctrl && !key.meta && typeof s === "string" && s.length === 1 && s >= " " && s !== "\x7f";
|
|
114
|
+
}
|
|
115
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
116
|
+
/** Show a spinner with a message while `work` runs. */
|
|
117
|
+
export async function withSpinner(message, work) {
|
|
118
|
+
if (!stdout.isTTY)
|
|
119
|
+
return work();
|
|
120
|
+
let i = 0;
|
|
121
|
+
stdout.write("\x1b[?25l");
|
|
122
|
+
const timer = setInterval(() => {
|
|
123
|
+
stdout.write(`\r${FRAMES[i++ % FRAMES.length]} ${message}`);
|
|
124
|
+
}, 80);
|
|
125
|
+
try {
|
|
126
|
+
return await work();
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
clearInterval(timer);
|
|
130
|
+
stdout.write("\r\x1b[K\x1b[?25h");
|
|
131
|
+
}
|
|
132
|
+
}
|
package/dist/version.js
ADDED
package/package.json
CHANGED
|
@@ -1,8 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "selfguard",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.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": {
|
|
7
|
-
|
|
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
|
}
|