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.
- package/LICENSE +21 -0
- package/README.md +133 -3
- package/dist/activity.js +12 -0
- package/dist/alerts.js +189 -0
- package/dist/announce.js +136 -0
- package/dist/api.js +494 -0
- package/dist/cli.js +591 -0
- package/dist/config.js +76 -0
- package/dist/discover.js +73 -0
- package/dist/events.js +61 -0
- package/dist/fsutil.js +66 -0
- package/dist/guide.js +61 -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 +248 -0
- package/dist/notify.js +43 -0
- package/dist/osv.js +142 -0
- package/dist/paths.js +16 -0
- package/dist/repoid.js +171 -0
- package/dist/report.js +126 -0
- package/dist/sanitize.js +12 -0
- package/dist/scan.js +197 -0
- package/dist/schedule.js +142 -0
- package/dist/semver.js +65 -0
- package/dist/setup.js +236 -0
- package/dist/state.js +19 -0
- package/dist/store.js +37 -0
- package/dist/tui.js +132 -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 +2 -0
- package/package.json +45 -4
package/dist/setup.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
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 { watchTargets } from "./api.js";
|
|
11
|
+
import { announce } from "./announce.js";
|
|
12
|
+
import { listRepos, saveRepo } from "./store.js";
|
|
13
|
+
import { confirm, fitStart, isPrintable, readKeys, Screen, withSpinner } from "./tui.js";
|
|
14
|
+
const out = process.stdout;
|
|
15
|
+
/** Interactive folder picker. Resolves to the chosen roots, or null if cancelled. */
|
|
16
|
+
async function pickRoots(initial, exclude) {
|
|
17
|
+
const c = paint(out);
|
|
18
|
+
const items = [];
|
|
19
|
+
const add = (path, selected, depth) => {
|
|
20
|
+
if (items.some((i) => i.path.toLowerCase() === path.toLowerCase()))
|
|
21
|
+
return;
|
|
22
|
+
items.push({ path, selected, depth, count: null, generation: 0 });
|
|
23
|
+
};
|
|
24
|
+
for (const r of initial)
|
|
25
|
+
add(r.path, true, r.depth);
|
|
26
|
+
for (const p of candidateRoots())
|
|
27
|
+
add(p, false, DEFAULT_DEPTH);
|
|
28
|
+
let cursor = 0; // items.length = the "add folder" row
|
|
29
|
+
let mode = "list";
|
|
30
|
+
let input = "";
|
|
31
|
+
let message = "";
|
|
32
|
+
const screen = new Screen();
|
|
33
|
+
const draw = () => {
|
|
34
|
+
const lines = [];
|
|
35
|
+
lines.push(c.bold("Where do you keep your code?"));
|
|
36
|
+
lines.push(c.dim("selfguard only looks inside the folders you pick, nothing else on your disk."));
|
|
37
|
+
lines.push("");
|
|
38
|
+
const width = Math.max(20, Math.min(50, (out.columns ?? 80) - 40));
|
|
39
|
+
items.forEach((it, i) => {
|
|
40
|
+
const pointer = i === cursor && mode === "list" ? c.cyan("›") : " ";
|
|
41
|
+
const box = it.selected ? c.green("[x]") : "[ ]";
|
|
42
|
+
const name = fitStart(tildify(it.path), width).padEnd(width);
|
|
43
|
+
const label = (it.count === null ? "counting…" : it.count === 0 ? "no JS projects" : `${it.count} project${it.count === 1 ? "" : "s"}`).padEnd(14);
|
|
44
|
+
const count = it.count ? label : c.dim(label);
|
|
45
|
+
lines.push(`${pointer} ${box} ${i === cursor ? c.bold(name) : name} ${count} ${c.dim(`depth ${it.depth}`)}`);
|
|
46
|
+
});
|
|
47
|
+
const addRow = cursor === items.length && mode === "list";
|
|
48
|
+
lines.push(`${addRow ? c.cyan("›") : " "} ${c.cyan("+")} ${addRow ? c.bold("add another folder…") : "add another folder…"}`);
|
|
49
|
+
lines.push("");
|
|
50
|
+
if (mode === "input") {
|
|
51
|
+
lines.push(`${c.cyan("Folder:")} ${input}${c.inverseRed(" ")}`);
|
|
52
|
+
lines.push(c.dim("enter add · esc cancel · ~ = home"));
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
lines.push(c.dim("↑/↓ move · space select · ←/→ depth · a add folder · enter continue · q quit"));
|
|
56
|
+
}
|
|
57
|
+
lines.push(message ? c.yellow(message) : "");
|
|
58
|
+
screen.render(lines);
|
|
59
|
+
};
|
|
60
|
+
const count = (it) => {
|
|
61
|
+
const gen = ++it.generation;
|
|
62
|
+
it.count = null;
|
|
63
|
+
discover(it.path, { depth: it.depth, exclude, isCancelled: () => it.generation !== gen }).then((found) => {
|
|
64
|
+
if (it.generation !== gen)
|
|
65
|
+
return;
|
|
66
|
+
it.count = found.length;
|
|
67
|
+
draw();
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
items.forEach(count);
|
|
71
|
+
draw();
|
|
72
|
+
return readKeys((key, finish) => {
|
|
73
|
+
message = "";
|
|
74
|
+
if (mode === "input") {
|
|
75
|
+
if (key.name === "escape") {
|
|
76
|
+
mode = "list";
|
|
77
|
+
input = "";
|
|
78
|
+
}
|
|
79
|
+
else if (key.name === "return" || key.name === "enter") {
|
|
80
|
+
const dir = normalizeDir(input);
|
|
81
|
+
if (!dir) {
|
|
82
|
+
message = `Not a folder: ${input}`;
|
|
83
|
+
}
|
|
84
|
+
else if (dir === homedir() || dir === "/") {
|
|
85
|
+
message = "Pick a more specific folder than your whole home directory.";
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
add(dir, true, DEFAULT_DEPTH);
|
|
89
|
+
const it = items.find((i) => i.path === dir);
|
|
90
|
+
it.selected = true;
|
|
91
|
+
count(it);
|
|
92
|
+
cursor = items.indexOf(it);
|
|
93
|
+
mode = "list";
|
|
94
|
+
input = "";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
else if (key.name === "backspace") {
|
|
98
|
+
input = input.slice(0, -1);
|
|
99
|
+
}
|
|
100
|
+
else if (isPrintable(key) && input.length < 400) {
|
|
101
|
+
input += key.sequence;
|
|
102
|
+
}
|
|
103
|
+
draw();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const it = items[cursor];
|
|
107
|
+
switch (key.name) {
|
|
108
|
+
case "up":
|
|
109
|
+
case "k":
|
|
110
|
+
cursor = cursor === 0 ? items.length : cursor - 1;
|
|
111
|
+
break;
|
|
112
|
+
case "down":
|
|
113
|
+
case "j":
|
|
114
|
+
cursor = cursor === items.length ? 0 : cursor + 1;
|
|
115
|
+
break;
|
|
116
|
+
case "space":
|
|
117
|
+
if (it)
|
|
118
|
+
it.selected = !it.selected;
|
|
119
|
+
else
|
|
120
|
+
mode = "input";
|
|
121
|
+
break;
|
|
122
|
+
case "left":
|
|
123
|
+
case "right":
|
|
124
|
+
if (it) {
|
|
125
|
+
const d = clampDepth(it.depth + (key.name === "right" ? 1 : -1));
|
|
126
|
+
if (d !== it.depth) {
|
|
127
|
+
it.depth = d;
|
|
128
|
+
count(it);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
message = `Depth goes from 1 to ${MAX_DEPTH}.`;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
break;
|
|
135
|
+
case "a":
|
|
136
|
+
mode = "input";
|
|
137
|
+
break;
|
|
138
|
+
case "return":
|
|
139
|
+
case "enter":
|
|
140
|
+
if (!it && cursor === items.length) {
|
|
141
|
+
mode = "input";
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
if (!items.some((i) => i.selected)) {
|
|
145
|
+
message = "Select at least one folder (space), or press q to quit.";
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
screen.done();
|
|
149
|
+
finish(items.filter((i) => i.selected).map((i) => ({ path: i.path, depth: i.depth })));
|
|
150
|
+
return;
|
|
151
|
+
case "q":
|
|
152
|
+
case "escape":
|
|
153
|
+
screen.done();
|
|
154
|
+
finish(null);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
draw();
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
function hasZshHook() {
|
|
161
|
+
try {
|
|
162
|
+
return readFileSync(join(homedir(), ".zshrc"), "utf8").includes("selfguard init zsh");
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function printScanSummary(outcomes) {
|
|
169
|
+
const c = paint(out);
|
|
170
|
+
const risky = outcomes
|
|
171
|
+
.filter((o) => o.record.findings.length > 0)
|
|
172
|
+
.sort((a, b) => b.record.findings.length - a.record.findings.length);
|
|
173
|
+
const unsupported = outcomes.filter((o) => o.record.error && o.record.findings.length === 0).length;
|
|
174
|
+
out.write(`\n${c.bold(`${outcomes.length} project(s) scanned.`)}\n`);
|
|
175
|
+
if (risky.length === 0)
|
|
176
|
+
out.write(`${c.green("✓")} no known vulnerabilities\n`);
|
|
177
|
+
for (const o of risky.slice(0, 10)) {
|
|
178
|
+
const w = worstSeverity(o.record.findings);
|
|
179
|
+
const color = w === "malicious" || w === "critical" || w === "high" ? c.red : c.yellow;
|
|
180
|
+
const pkgs = groupByPackage(o.record.findings).length;
|
|
181
|
+
out.write(` ${color("●")} ${c.bold(tildify(o.record.path))} ${pkgs} vulnerable pkg(s) ${c.dim(`(${summarizeCounts(o.record.findings)})`)}\n`);
|
|
182
|
+
}
|
|
183
|
+
if (risky.length > 10)
|
|
184
|
+
out.write(c.dim(` …and ${risky.length - 10} more (selfguard status)\n`));
|
|
185
|
+
if (unsupported > 0)
|
|
186
|
+
out.write(c.dim(` ${unsupported} project(s) skipped: no lockfile yet, or unreadable\n`));
|
|
187
|
+
}
|
|
188
|
+
export async function runSetup() {
|
|
189
|
+
const c = paint(out);
|
|
190
|
+
out.write(`\n${c.bold(c.cyan("selfguard setup"))}\n\n`);
|
|
191
|
+
const cfg = loadConfig();
|
|
192
|
+
const roots = await pickRoots(cfg.roots, cfg.exclude);
|
|
193
|
+
if (!roots) {
|
|
194
|
+
out.write("Cancelled, nothing changed.\n");
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
saveConfig({ ...cfg, roots });
|
|
198
|
+
out.write(`${c.green("✓")} Saved ${roots.length} folder(s) to watch.\n\n`);
|
|
199
|
+
const projects = await withSpinner("Looking for projects…", () => discoverAll(roots, { exclude: cfg.exclude }));
|
|
200
|
+
const known = new Set(listRepos().map((r) => r.path));
|
|
201
|
+
const fresh = projects.filter((p) => !known.has(p.root));
|
|
202
|
+
out.write(`Found ${c.bold(String(projects.length))} project(s), ${c.bold(String(fresh.length))} not tracked yet.\n`);
|
|
203
|
+
for (const p of fresh.slice(0, 8))
|
|
204
|
+
out.write(c.dim(` ${tildify(p.root)}\n`));
|
|
205
|
+
if (fresh.length > 8)
|
|
206
|
+
out.write(c.dim(` …and ${fresh.length - 8} more\n`));
|
|
207
|
+
if (fresh.length > 0) {
|
|
208
|
+
out.write("\n");
|
|
209
|
+
if (await confirm(`Scan ${fresh.length} new project(s) now? (sends only package names+versions to OSV.dev)`)) {
|
|
210
|
+
const outcomes = await withSpinner(`Scanning ${fresh.length} project(s)…`, () => scanProjects(fresh));
|
|
211
|
+
for (const o of outcomes)
|
|
212
|
+
saveRepo(o.record);
|
|
213
|
+
await announce(outcomes, { notify: false });
|
|
214
|
+
printScanSummary(outcomes);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
out.write("\n");
|
|
218
|
+
if (hasZshHook()) {
|
|
219
|
+
out.write(`${c.green("✓")} Shell hook already in ~/.zshrc\n`);
|
|
220
|
+
}
|
|
221
|
+
else if (await confirm("Add the shell hook to ~/.zshrc (alerts when you cd into a project)?")) {
|
|
222
|
+
appendFileSync(join(homedir(), ".zshrc"), `\n# selfguard: dependency vulnerability alerts\neval "$(selfguard init zsh)"\n`);
|
|
223
|
+
out.write(`${c.green("✓")} Added. Open a new terminal to activate it.\n`);
|
|
224
|
+
}
|
|
225
|
+
if (process.platform === "darwin") {
|
|
226
|
+
if (scheduleStatus().loaded) {
|
|
227
|
+
out.write(`${c.green("✓")} Background sync already enabled\n`);
|
|
228
|
+
}
|
|
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`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
out.write(`\nDone. ${c.dim("selfguard ui (dashboard) · selfguard status · selfguard --help")}\n`);
|
|
235
|
+
return 0;
|
|
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
|
+
}
|
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
|
+
}
|