securevibe 0.1.10 → 0.1.13
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 +29 -7
- package/dist/engine/chat.js +76 -0
- package/dist/engine/fix/llm.js +15 -11
- package/dist/engine/git.js +34 -1
- package/dist/engine/github.js +59 -0
- package/dist/engine/init.js +13 -4
- package/dist/engine/taint.js +15 -3
- package/dist/index-support.js +16 -2
- package/dist/index.js +51 -5
- package/dist/repl.js +203 -66
- package/dist/ui/fix.js +1 -1
- package/dist/ui/prComment.js +55 -0
- package/dist/ui/prompt.js +6 -3
- package/dist/ui/repl-tui/App.js +132 -0
- package/dist/ui/repl-tui/InputBox.js +48 -0
- package/dist/ui/repl-tui/SlashMenu.js +10 -0
- package/dist/ui/repl-tui/Spinner.js +18 -0
- package/dist/ui/repl-tui/StatusLine.js +15 -0
- package/dist/ui/repl-tui/commandRegistry.js +38 -0
- package/dist/ui/repl-tui/outputStream.js +16 -0
- package/dist/version.js +1 -1
- package/package.json +71 -66
package/dist/repl.js
CHANGED
|
@@ -40,28 +40,64 @@ async function meterOrPrint(io) {
|
|
|
40
40
|
}
|
|
41
41
|
return r;
|
|
42
42
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
" fix [--apply] [--yes] [--no-llm] [--json] fix findings",
|
|
47
|
-
" explain [--json] AI explanations for critical/high findings",
|
|
48
|
-
" ai-audit | protect | attack-map | score | deps | ready [--json]",
|
|
49
|
-
" init [--force] wire up the pre-commit guard + CI",
|
|
50
|
-
" config show | set-key <groq|anthropic> | unset-key <groq|anthropic>",
|
|
51
|
-
" db update | status manage the local OSV database",
|
|
52
|
-
" cd <path> change the scan target",
|
|
53
|
-
" pwd show the current scan target",
|
|
54
|
-
" help this message",
|
|
55
|
-
" exit | quit leave the session",
|
|
56
|
-
].join("\n");
|
|
57
|
-
/** Public entry point: real stdio, refuses to start without a TTY. */
|
|
43
|
+
/** Public entry point: real stdio, refuses to start without a TTY. Mounts
|
|
44
|
+
* the Ink interactive app (ui/repl-tui/App.ts) -- runReplSession above stays
|
|
45
|
+
* the tested, TTY-free core; this is the real-terminal presentation layer. */
|
|
58
46
|
export async function runRepl(initialPath) {
|
|
59
47
|
if (!isInteractive()) {
|
|
60
48
|
process.stderr.write(" repl needs an interactive terminal.\n");
|
|
61
49
|
process.exitCode = 64;
|
|
62
50
|
return;
|
|
63
51
|
}
|
|
64
|
-
|
|
52
|
+
const { render } = await import("ink");
|
|
53
|
+
const React = (await import("react")).default;
|
|
54
|
+
const { App } = await import("./ui/repl-tui/App.js");
|
|
55
|
+
const { waitUntilExit } = render(React.createElement(App, { initialPath }));
|
|
56
|
+
await waitUntilExit();
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Handle one line of input against the given state: built-in commands
|
|
60
|
+
* (exit/quit/q, help, pwd, cd) plus everything dispatch() handles. Returns
|
|
61
|
+
* true if the session should end after this line. Shared by the legacy
|
|
62
|
+
* readline loop (runReplSession, below) and the Ink app (ui/repl-tui/App.ts)
|
|
63
|
+
* — extracted so both drive the exact same behavior from one place.
|
|
64
|
+
*/
|
|
65
|
+
export async function processLine(line, state, io) {
|
|
66
|
+
const trimmed = line.trim();
|
|
67
|
+
if (!trimmed)
|
|
68
|
+
return false;
|
|
69
|
+
const tokens = tokenize(trimmed);
|
|
70
|
+
const cmd = tokens[0];
|
|
71
|
+
const rest = tokens.slice(1);
|
|
72
|
+
try {
|
|
73
|
+
if ((cmd === "exit" || cmd === "quit" || cmd === "q") && rest.length === 0)
|
|
74
|
+
return true;
|
|
75
|
+
if ((cmd === "help" || cmd === "?") && rest.length === 0) {
|
|
76
|
+
const { renderHelp } = await import("./ui/repl-tui/commandRegistry.js");
|
|
77
|
+
write(io, renderHelp() + "\n\n");
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
if (cmd === "pwd" && rest.length === 0) {
|
|
81
|
+
write(io, ` ${state.root}\n\n`);
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
if (cmd === "cd" && rest.length <= 1) {
|
|
85
|
+
if (!rest[0]) {
|
|
86
|
+
write(io, " usage: cd <path>\n\n");
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
state.root = path.resolve(state.root, rest[0]);
|
|
90
|
+
state.lastResult = undefined; // stale results would ground chat in the wrong repo
|
|
91
|
+
state.chat?.reset();
|
|
92
|
+
write(io, ` target: ${state.root}\n\n`);
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
await dispatch(cmd, rest, state, io, trimmed);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
write(io, ` error: ${err?.message ?? err}\n\n`);
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
65
101
|
}
|
|
66
102
|
/** The actual session loop, over injectable streams — testable without a TTY. */
|
|
67
103
|
export async function runReplSession(initialPath, io) {
|
|
@@ -73,49 +109,61 @@ export async function runReplSession(initialPath, io) {
|
|
|
73
109
|
write(io, ` target: ${state.root}\n\n`);
|
|
74
110
|
while (true) {
|
|
75
111
|
const raw = await askLine(`securevibe (${path.basename(state.root)})> `, io.input, io.output);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
112
|
+
if (await processLine(raw, state, io))
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Progress callback for scan()/runFix()/explainFindings(): prefers io.onProgress
|
|
117
|
+
* (Ink path) over the default scrolling-line behavior (legacy readline path). */
|
|
118
|
+
function progressCallback(io, asJson) {
|
|
119
|
+
if (asJson)
|
|
120
|
+
return undefined;
|
|
121
|
+
return io.onProgress ?? ((m) => write(io, ` · ${m}\n`));
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* True when `rest` (the tokens after the command word) is a plausible
|
|
125
|
+
* argument list for `cmd`'s actual grammar -- known flags/subcommands only.
|
|
126
|
+
* A command WORD matching a real command but followed by tokens outside its
|
|
127
|
+
* grammar is natural language that happens to start with that word (e.g.
|
|
128
|
+
* "score seems too low, why?"), not an invocation -- dispatch() falls
|
|
129
|
+
* through to chat instead of running the command. Every dispatch-level name
|
|
130
|
+
* here must also appear in ui/repl-tui/commandRegistry.ts's COMMANDS list;
|
|
131
|
+
* repl-command-collision.test.ts cross-checks the two so they can't drift.
|
|
132
|
+
*/
|
|
133
|
+
export function matchesCommandGrammar(cmd, rest) {
|
|
134
|
+
const jsonOnly = new Set(["scan", "ai-audit", "protect", "attack-map", "score", "deps", "ready", "explain"]);
|
|
135
|
+
if (jsonOnly.has(cmd))
|
|
136
|
+
return rest.every((t) => t === "--json");
|
|
137
|
+
if (cmd === "fix") {
|
|
138
|
+
const known = new Set(["--apply", "--yes", "--no-llm", "--json"]);
|
|
139
|
+
return rest.every((t) => known.has(t));
|
|
140
|
+
}
|
|
141
|
+
if (cmd === "init")
|
|
142
|
+
return rest.every((t) => t === "--force");
|
|
143
|
+
if (cmd === "config") {
|
|
144
|
+
if (rest.length === 0)
|
|
145
|
+
return true;
|
|
146
|
+
if (rest[0] === "show")
|
|
147
|
+
return rest.length === 1;
|
|
148
|
+
if (rest[0] === "set-key" || rest[0] === "unset-key")
|
|
149
|
+
return rest.length <= 2;
|
|
150
|
+
return false;
|
|
107
151
|
}
|
|
152
|
+
if (cmd === "db")
|
|
153
|
+
return rest.length === 0 || (rest.length === 1 && (rest[0] === "update" || rest[0] === "status"));
|
|
154
|
+
return false;
|
|
108
155
|
}
|
|
109
|
-
async function dispatch(cmd, tokens, state, io) {
|
|
156
|
+
async function dispatch(cmd, tokens, state, io, rawLine) {
|
|
110
157
|
const asJson = hasFlag(tokens, "--json");
|
|
111
|
-
if (["scan", "ai-audit", "protect", "attack-map", "score", "deps", "ready"].includes(cmd)) {
|
|
158
|
+
if (["scan", "ai-audit", "protect", "attack-map", "score", "deps", "ready"].includes(cmd) && matchesCommandGrammar(cmd, tokens)) {
|
|
112
159
|
const { scan } = await import("./engine/scan.js");
|
|
113
160
|
const usage = await meterOrPrint(io);
|
|
114
161
|
const result = await scan(state.root, {
|
|
115
162
|
aiOnly: cmd === "ai-audit",
|
|
116
|
-
onProgress:
|
|
163
|
+
onProgress: progressCallback(io, asJson),
|
|
117
164
|
});
|
|
118
165
|
state.lastResult = result;
|
|
166
|
+
state.chat?.setScanResult(result);
|
|
119
167
|
if (asJson) {
|
|
120
168
|
const { toJson } = await import("./ui/emit.js");
|
|
121
169
|
write(io, toJson(result) + "\n\n");
|
|
@@ -124,21 +172,34 @@ async function dispatch(cmd, tokens, state, io) {
|
|
|
124
172
|
write(io, (await renderFor(cmd, result, usage)) + "\n");
|
|
125
173
|
return;
|
|
126
174
|
}
|
|
127
|
-
if (cmd === "fix") {
|
|
175
|
+
if (cmd === "fix" && matchesCommandGrammar(cmd, tokens)) {
|
|
128
176
|
const { runFix } = await import("./engine/fix/session.js");
|
|
129
177
|
const { renderFixSession, fixToJson, renderApprovalRequest } = await import("./ui/fix.js");
|
|
130
178
|
const opts = { apply: hasFlag(tokens, "--apply"), yes: hasFlag(tokens, "--yes"), json: asJson };
|
|
131
179
|
const decision = decideApply(opts, true);
|
|
132
180
|
if (decision.apply && !hasFlag(tokens, "--no-llm")) {
|
|
133
|
-
|
|
134
|
-
|
|
181
|
+
if (io.inkMode) {
|
|
182
|
+
const { noKeyNudgeMessage } = await import("./index-support.js");
|
|
183
|
+
const msg = noKeyNudgeMessage();
|
|
184
|
+
if (msg)
|
|
185
|
+
write(io, msg);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
const { maybeOfferAiFixerSetup } = await import("./index-support.js");
|
|
189
|
+
await maybeOfferAiFixerSetup();
|
|
190
|
+
}
|
|
135
191
|
}
|
|
136
192
|
const usage = await meterOrPrint(io);
|
|
193
|
+
const confirmFn = decision.prompt
|
|
194
|
+
? io.makeConfirm
|
|
195
|
+
? io.makeConfirm(renderApprovalRequest)
|
|
196
|
+
: makeFixConfirm(renderApprovalRequest, io.input, io.output)
|
|
197
|
+
: undefined;
|
|
137
198
|
const result = await runFix(state.root, {
|
|
138
199
|
apply: decision.apply,
|
|
139
200
|
noLlm: hasFlag(tokens, "--no-llm"),
|
|
140
|
-
confirm:
|
|
141
|
-
onProgress:
|
|
201
|
+
confirm: confirmFn,
|
|
202
|
+
onProgress: progressCallback(io, asJson),
|
|
142
203
|
});
|
|
143
204
|
if (asJson) {
|
|
144
205
|
write(io, fixToJson(result) + "\n\n");
|
|
@@ -150,18 +211,26 @@ async function dispatch(cmd, tokens, state, io) {
|
|
|
150
211
|
}
|
|
151
212
|
return;
|
|
152
213
|
}
|
|
153
|
-
if (cmd === "explain") {
|
|
214
|
+
if (cmd === "explain" && matchesCommandGrammar(cmd, tokens)) {
|
|
154
215
|
if (!asJson) {
|
|
155
|
-
|
|
156
|
-
|
|
216
|
+
if (io.inkMode) {
|
|
217
|
+
const { noKeyNudgeMessage } = await import("./index-support.js");
|
|
218
|
+
const msg = noKeyNudgeMessage();
|
|
219
|
+
if (msg)
|
|
220
|
+
write(io, msg);
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
const { maybeOfferAiFixerSetup } = await import("./index-support.js");
|
|
224
|
+
await maybeOfferAiFixerSetup();
|
|
225
|
+
}
|
|
157
226
|
}
|
|
158
227
|
const { explainFindings } = await import("./engine/explain.js");
|
|
159
228
|
const usage = await meterOrPrint(io);
|
|
160
229
|
const { scan } = await import("./engine/scan.js");
|
|
161
|
-
const scanResult = await scan(state.root, { onProgress:
|
|
230
|
+
const scanResult = await scan(state.root, { onProgress: progressCallback(io, asJson) });
|
|
162
231
|
state.lastResult = scanResult;
|
|
163
232
|
const report = await explainFindings(scanResult, {
|
|
164
|
-
onProgress:
|
|
233
|
+
onProgress: progressCallback(io, asJson),
|
|
165
234
|
});
|
|
166
235
|
if (asJson) {
|
|
167
236
|
const { explainToJson } = await import("./ui/explain.js");
|
|
@@ -172,24 +241,88 @@ async function dispatch(cmd, tokens, state, io) {
|
|
|
172
241
|
write(io, renderExplain(report, usage) + "\n");
|
|
173
242
|
return;
|
|
174
243
|
}
|
|
175
|
-
if (cmd === "init") {
|
|
244
|
+
if (cmd === "init" && matchesCommandGrammar(cmd, tokens)) {
|
|
176
245
|
const { runInit } = await import("./engine/init.js");
|
|
177
246
|
const { renderInit } = await import("./ui/init.js");
|
|
178
247
|
const result = await runInit(state.root, { force: hasFlag(tokens, "--force") });
|
|
179
248
|
write(io, renderInit(result) + "\n");
|
|
180
|
-
const { maybeOfferAiFixerSetupDuringInit } = await import("./index-support.js");
|
|
181
|
-
await maybeOfferAiFixerSetupDuringInit(state.root
|
|
249
|
+
const { maybeOfferAiFixerSetupDuringInit, noKeyNudgeMessage } = await import("./index-support.js");
|
|
250
|
+
await maybeOfferAiFixerSetupDuringInit(state.root, io.inkMode
|
|
251
|
+
? {
|
|
252
|
+
writePreamble: (text) => write(io, text),
|
|
253
|
+
offer: async () => {
|
|
254
|
+
const msg = noKeyNudgeMessage();
|
|
255
|
+
if (msg)
|
|
256
|
+
write(io, msg);
|
|
257
|
+
},
|
|
258
|
+
}
|
|
259
|
+
: undefined);
|
|
182
260
|
return;
|
|
183
261
|
}
|
|
184
|
-
if (cmd === "config") {
|
|
262
|
+
if (cmd === "config" && matchesCommandGrammar(cmd, tokens)) {
|
|
185
263
|
await handleConfig(tokens, io);
|
|
186
264
|
return;
|
|
187
265
|
}
|
|
188
|
-
if (cmd === "db") {
|
|
266
|
+
if (cmd === "db" && matchesCommandGrammar(cmd, tokens)) {
|
|
189
267
|
await handleDb(tokens, io);
|
|
190
268
|
return;
|
|
191
269
|
}
|
|
192
|
-
|
|
270
|
+
await handleChat(rawLine, state, io);
|
|
271
|
+
}
|
|
272
|
+
/** Two-space indent so answers sit inside the REPL's output gutter. */
|
|
273
|
+
function indentAnswer(text) {
|
|
274
|
+
return text
|
|
275
|
+
.split("\n")
|
|
276
|
+
.map((l) => " " + l)
|
|
277
|
+
.join("\n");
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Free text = a question about the last scan (spec 2026-07-12). Order of
|
|
281
|
+
* gates: scan first (nothing to talk about), then key (consent would be
|
|
282
|
+
* pointless without one), then once-per-session consent, then the ask.
|
|
283
|
+
* NOT metered: no scan runs here and the call spends the user's own key,
|
|
284
|
+
* the same reasoning that exempts --staged/--diff.
|
|
285
|
+
*/
|
|
286
|
+
async function handleChat(question, state, io) {
|
|
287
|
+
if (!state.lastResult) {
|
|
288
|
+
write(io, " Run `scan` first so I have findings to talk about. (Free text asks the AI about your last scan; type `help` for commands.)\n\n");
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const { llmAvailability } = await import("./engine/fix/llm.js");
|
|
292
|
+
const avail = llmAvailability(false);
|
|
293
|
+
if (!avail.available) {
|
|
294
|
+
write(io, " Chat needs an AI key. Run `securevibe config set-key groq` (outside the REPL) to enable it; `explain` and `fix` use the same key.\n\n");
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (state.chatConsent === false) {
|
|
298
|
+
write(io, " Chat is off for this session (you declined earlier). Commands still work; type `help`.\n\n");
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (state.chatConsent === undefined) {
|
|
302
|
+
const render = (req) => req.kind === "chat-consent"
|
|
303
|
+
? ` First question this session: send your questions and the scan findings to ${req.provider}?`
|
|
304
|
+
: "";
|
|
305
|
+
const confirm = io.makeConfirm ? io.makeConfirm(render) : makeFixConfirm(render, io.input, io.output);
|
|
306
|
+
const answer = await confirm({ kind: "chat-consent", provider: avail.provider });
|
|
307
|
+
state.chatConsent = answer === "yes";
|
|
308
|
+
if (!state.chatConsent) {
|
|
309
|
+
write(io, " Understood, chat stays off for this session. Commands still work; type `help`.\n\n");
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (!state.chat) {
|
|
314
|
+
const { ChatSession } = await import("./engine/chat.js");
|
|
315
|
+
state.chat = new ChatSession();
|
|
316
|
+
state.chat.setScanResult(state.lastResult);
|
|
317
|
+
}
|
|
318
|
+
const progress = progressCallback(io, false);
|
|
319
|
+
progress?.(`asking ${avail.provider}…`);
|
|
320
|
+
const answer = await state.chat.ask(question);
|
|
321
|
+
if (answer == null) {
|
|
322
|
+
write(io, " The AI call failed; nothing was answered. Try again, or use `explain` for per finding context.\n\n");
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
write(io, indentAnswer(answer) + "\n\n");
|
|
193
326
|
}
|
|
194
327
|
async function renderFor(cmd, result, usage) {
|
|
195
328
|
if (cmd === "scan" || cmd === "ai-audit") {
|
|
@@ -232,6 +365,10 @@ async function handleConfig(tokens, io) {
|
|
|
232
365
|
return;
|
|
233
366
|
}
|
|
234
367
|
if (action === "set-key") {
|
|
368
|
+
if (io.inkMode) {
|
|
369
|
+
write(io, ` config set-key needs a real terminal prompt — run \`securevibe config set-key ${provider}\` as a one-shot command outside the REPL (or set the ${provider === "groq" ? "GROQ_API_KEY" : "ANTHROPIC_API_KEY"} env var directly).\n\n`);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
235
372
|
const hint = provider === "groq" ? " (free — console.groq.com/keys)" : " (console.anthropic.com)";
|
|
236
373
|
const key = (await askLine(` Paste your ${provider} API key${hint}: `, io.input, io.output)).trim();
|
|
237
374
|
if (!key) {
|
package/dist/ui/fix.js
CHANGED
|
@@ -131,7 +131,7 @@ export function renderApprovalRequest(req) {
|
|
|
131
131
|
L.push(renderDiff(p.relPath, p.before, p.after));
|
|
132
132
|
L.push(" " + pc.dim("verified: finding clears · no new issue · file still parses"));
|
|
133
133
|
}
|
|
134
|
-
else {
|
|
134
|
+
else if (req.kind === "side-effects") {
|
|
135
135
|
L.push("");
|
|
136
136
|
L.push(" " + pc.bold("Supporting changes") + pc.dim(" (additive, no break risk)"));
|
|
137
137
|
for (const fx of req.effects) {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { SEVERITY_ORDER } from "../engine/types.js";
|
|
2
|
+
import { PR_COMMENT_MARKER } from "../engine/github.js";
|
|
3
|
+
const MAX_FINDINGS = 15;
|
|
4
|
+
const SEV_LABEL = {
|
|
5
|
+
critical: "CRITICAL",
|
|
6
|
+
high: "HIGH",
|
|
7
|
+
medium: "MEDIUM",
|
|
8
|
+
low: "LOW",
|
|
9
|
+
info: "INFO",
|
|
10
|
+
};
|
|
11
|
+
const READINESS_LABEL = {
|
|
12
|
+
ship: "✓ Ship",
|
|
13
|
+
warn: "⚠ Ship with warnings",
|
|
14
|
+
block: "✗ Block deploy",
|
|
15
|
+
};
|
|
16
|
+
function severityCounts(findings) {
|
|
17
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
18
|
+
for (const f of findings)
|
|
19
|
+
counts[f.severity]++;
|
|
20
|
+
return ["critical", "high", "medium", "low"]
|
|
21
|
+
.filter((s) => counts[s] > 0)
|
|
22
|
+
.map((s) => `**${counts[s]}** ${SEV_LABEL[s]}`)
|
|
23
|
+
.join(" · ");
|
|
24
|
+
}
|
|
25
|
+
export function renderPrComment(result) {
|
|
26
|
+
const L = [];
|
|
27
|
+
L.push(PR_COMMENT_MARKER);
|
|
28
|
+
L.push("## SecureVibe — changed-files scan");
|
|
29
|
+
L.push("");
|
|
30
|
+
L.push(`**Grade ${result.score.grade}** (${result.score.composite}/100) — ${READINESS_LABEL[result.score.readiness]}`);
|
|
31
|
+
L.push("");
|
|
32
|
+
if (result.findings.length === 0) {
|
|
33
|
+
L.push("No findings in the files changed by this PR. ✓");
|
|
34
|
+
return L.join("\n");
|
|
35
|
+
}
|
|
36
|
+
L.push(severityCounts(result.findings));
|
|
37
|
+
L.push("");
|
|
38
|
+
const sorted = [...result.findings].sort((a, b) => SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity] || b.confidence - a.confidence);
|
|
39
|
+
const shown = sorted.slice(0, MAX_FINDINGS);
|
|
40
|
+
const remaining = sorted.length - shown.length;
|
|
41
|
+
for (const f of shown) {
|
|
42
|
+
L.push(`### ${SEV_LABEL[f.severity]} — ${f.title}`);
|
|
43
|
+
L.push(`\`${f.file}:${f.line}\``);
|
|
44
|
+
L.push("");
|
|
45
|
+
L.push(`**why:** ${f.why}`);
|
|
46
|
+
L.push("");
|
|
47
|
+
L.push(`**fix:** ${f.fix}`);
|
|
48
|
+
L.push("");
|
|
49
|
+
}
|
|
50
|
+
if (remaining > 0) {
|
|
51
|
+
L.push(`_+${remaining} more finding${remaining === 1 ? "" : "s"} — run \`securevibe scan\` locally to see all._`);
|
|
52
|
+
L.push("");
|
|
53
|
+
}
|
|
54
|
+
return L.join("\n");
|
|
55
|
+
}
|
package/dist/ui/prompt.js
CHANGED
|
@@ -26,10 +26,11 @@ export function decideApply(opts, interactive) {
|
|
|
26
26
|
return { apply: false, prompt: false, notice: true };
|
|
27
27
|
return { apply: true, prompt: true, notice: false };
|
|
28
28
|
}
|
|
29
|
-
/** Map a raw keypress/line to an answer. Empty / unknown → the safe default "no".
|
|
29
|
+
/** Map a raw keypress/line to an answer. Empty / unknown → the safe default "no".
|
|
30
|
+
* Only "patch" offers all/quit; side-effects and chat-consent are yes/no. */
|
|
30
31
|
export function mapAnswer(raw, kind) {
|
|
31
32
|
const c = raw.trim().toLowerCase().charAt(0);
|
|
32
|
-
if (kind
|
|
33
|
+
if (kind !== "patch")
|
|
33
34
|
return c === "y" ? "yes" : "no";
|
|
34
35
|
if (c === "y")
|
|
35
36
|
return "yes";
|
|
@@ -55,7 +56,9 @@ export function makeFixConfirm(render, input = process.stdin, output = process.s
|
|
|
55
56
|
output.write(render(req) + "\n");
|
|
56
57
|
const prompt = req.kind === "patch"
|
|
57
58
|
? " Apply this change? [y]es / [n]o / [a]ll / [q]uit: "
|
|
58
|
-
:
|
|
59
|
+
: req.kind === "side-effects"
|
|
60
|
+
? " Apply these supporting changes? [y]es / [n]o: "
|
|
61
|
+
: " Send to your AI provider? [y]es / [n]o: ";
|
|
59
62
|
const raw = await askLine(prompt, input, output);
|
|
60
63
|
return mapAnswer(raw, req.kind);
|
|
61
64
|
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Box, Static, Text, useApp, useInput } from "ink";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { mapAnswer } from "../prompt.js";
|
|
5
|
+
import { processLine } from "../../repl.js";
|
|
6
|
+
import { makeCallbackWritable } from "./outputStream.js";
|
|
7
|
+
import { filterCommands } from "./commandRegistry.js";
|
|
8
|
+
import { StatusLine } from "./StatusLine.js";
|
|
9
|
+
import { InputBox } from "./InputBox.js";
|
|
10
|
+
import { SlashMenu } from "./SlashMenu.js";
|
|
11
|
+
import { Spinner } from "./Spinner.js";
|
|
12
|
+
const h = React.createElement;
|
|
13
|
+
// Static<T> is generic; createElement can't infer T from an untyped call the
|
|
14
|
+
// way JSX's <Static<string>> would. Pin T=string via a TS instantiation
|
|
15
|
+
// expression instead of casting the props object.
|
|
16
|
+
const StringStatic = (Static);
|
|
17
|
+
/**
|
|
18
|
+
* Top-level Ink app: <Static> scrollback (everything dispatch() writes,
|
|
19
|
+
* unchanged from the plain-text output the legacy readline loop already
|
|
20
|
+
* produces) above a live bottom region (status line, spinner, slash menu,
|
|
21
|
+
* input box). Every submitted line goes through the exact same
|
|
22
|
+
* processLine() the legacy readline loop and repl.test.ts already exercise
|
|
23
|
+
* (Task 1) -- this component only changes how a line is captured and how
|
|
24
|
+
* output is framed, never what a command does.
|
|
25
|
+
*/
|
|
26
|
+
export function App({ initialPath }) {
|
|
27
|
+
const { exit } = useApp();
|
|
28
|
+
const stateRef = useRef({ root: path.resolve(initialPath) });
|
|
29
|
+
const [lines, setLines] = useState([]);
|
|
30
|
+
const [value, setValue] = useState("");
|
|
31
|
+
const [history, setHistory] = useState([]);
|
|
32
|
+
const [busy, setBusy] = useState(false);
|
|
33
|
+
const [progressMessage, setProgressMessage] = useState(null);
|
|
34
|
+
const [confirmKind, setConfirmKind] = useState(null);
|
|
35
|
+
const [selectedMenuIndex, setSelectedMenuIndex] = useState(0);
|
|
36
|
+
const confirmResolverRef = useRef(null);
|
|
37
|
+
const pushLine = (text) => setLines((prev) => [...prev, text]);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
(async () => {
|
|
40
|
+
const { applyStoredKeysToEnv } = await import("../../config.js");
|
|
41
|
+
await applyStoredKeysToEnv();
|
|
42
|
+
const { renderBanner } = await import("../banner.js");
|
|
43
|
+
pushLine(renderBanner({ subtitle: "interactive session — type `help` or `/`" }) + "\n");
|
|
44
|
+
pushLine(` target: ${stateRef.current.root}\n`);
|
|
45
|
+
})();
|
|
46
|
+
}, []);
|
|
47
|
+
const menuOpen = value.startsWith("/");
|
|
48
|
+
const filteredCommands = menuOpen ? filterCommands(value.slice(1)) : [];
|
|
49
|
+
const clampedIndex = Math.min(selectedMenuIndex, Math.max(0, filteredCommands.length - 1));
|
|
50
|
+
useInput((_input, key) => {
|
|
51
|
+
if (!menuOpen)
|
|
52
|
+
return;
|
|
53
|
+
if (key.upArrow)
|
|
54
|
+
setSelectedMenuIndex((i) => Math.max(0, i - 1));
|
|
55
|
+
if (key.downArrow)
|
|
56
|
+
setSelectedMenuIndex((i) => Math.min(filteredCommands.length - 1, i + 1));
|
|
57
|
+
if (key.escape)
|
|
58
|
+
setValue("");
|
|
59
|
+
});
|
|
60
|
+
const handleChange = (v) => {
|
|
61
|
+
setSelectedMenuIndex(0);
|
|
62
|
+
setValue(v);
|
|
63
|
+
};
|
|
64
|
+
const handleSubmit = async (line) => {
|
|
65
|
+
if (confirmResolverRef.current) {
|
|
66
|
+
const answer = mapAnswer(line, confirmKind ?? "patch");
|
|
67
|
+
confirmResolverRef.current(answer);
|
|
68
|
+
setValue("");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
// A command is already running (busy stays true for its whole duration,
|
|
72
|
+
// including while a confirm is pending -- but that case already returned
|
|
73
|
+
// above). Ignore a second real submission rather than racing two
|
|
74
|
+
// processLine() calls over the same mutable ReplState. The legacy
|
|
75
|
+
// readline loop can't hit this: only one askLine() is ever pending, so
|
|
76
|
+
// there's no way to submit a second line before the first resolves.
|
|
77
|
+
if (busy)
|
|
78
|
+
return;
|
|
79
|
+
if (menuOpen) {
|
|
80
|
+
const chosen = filteredCommands[clampedIndex];
|
|
81
|
+
setValue(chosen ? chosen.name + " " : "");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (!line.trim()) {
|
|
85
|
+
setValue("");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
setHistory((prev) => [...prev, line]);
|
|
89
|
+
setValue("");
|
|
90
|
+
setBusy(true);
|
|
91
|
+
setProgressMessage(null);
|
|
92
|
+
const io = {
|
|
93
|
+
input: process.stdin,
|
|
94
|
+
output: makeCallbackWritable(pushLine),
|
|
95
|
+
inkMode: true,
|
|
96
|
+
onProgress: (m) => setProgressMessage(m),
|
|
97
|
+
makeConfirm: (render) => {
|
|
98
|
+
return (req) => new Promise((resolve) => {
|
|
99
|
+
pushLine(render(req) + "\n");
|
|
100
|
+
setConfirmKind(req.kind);
|
|
101
|
+
confirmResolverRef.current = (answer) => {
|
|
102
|
+
setConfirmKind(null);
|
|
103
|
+
confirmResolverRef.current = null;
|
|
104
|
+
resolve(answer);
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
const shouldExit = await processLine(line, stateRef.current, io);
|
|
110
|
+
setBusy(false);
|
|
111
|
+
setProgressMessage(null);
|
|
112
|
+
if (shouldExit)
|
|
113
|
+
exit();
|
|
114
|
+
};
|
|
115
|
+
return h(React.Fragment, null, h(StringStatic, {
|
|
116
|
+
items: lines,
|
|
117
|
+
children: (line, i) => h(Text, { key: i }, line),
|
|
118
|
+
}), h(Box, { flexDirection: "column" }, h(StatusLine, { targetRoot: stateRef.current.root }), busy && progressMessage ? h(Spinner, { message: progressMessage }) : null, confirmKind
|
|
119
|
+
? h(Text, { dimColor: true }, confirmKind === "patch"
|
|
120
|
+
? " Apply this change? [y]es / [n]o / [a]ll / [q]uit"
|
|
121
|
+
: confirmKind === "side-effects"
|
|
122
|
+
? " Apply these supporting changes? [y]es / [n]o"
|
|
123
|
+
: " Send to your AI provider? [y]es / [n]o")
|
|
124
|
+
: null, menuOpen ? h(SlashMenu, { items: filteredCommands, selectedIndex: clampedIndex }) : null, h(InputBox, {
|
|
125
|
+
value,
|
|
126
|
+
onChange: handleChange,
|
|
127
|
+
onSubmit: handleSubmit,
|
|
128
|
+
history,
|
|
129
|
+
onHistoryNavigate: setValue,
|
|
130
|
+
menuOpen,
|
|
131
|
+
})));
|
|
132
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import TextInput from "ink-text-input";
|
|
4
|
+
const h = React.createElement;
|
|
5
|
+
/**
|
|
6
|
+
* Bordered single-line input (`>` prompt, rounded border). Up/down arrow
|
|
7
|
+
* cycles `history`, but only when the slash menu isn't open -- App.ts's own
|
|
8
|
+
* useInput takes over up/down for menu-item selection in that state.
|
|
9
|
+
* TextInput itself only handles left/right cursor movement and
|
|
10
|
+
* backspace/delete, never up/down, so there's no conflict between this
|
|
11
|
+
* hook and TextInput's own internal input handling.
|
|
12
|
+
*/
|
|
13
|
+
export function InputBox({ value, onChange, onSubmit, history, onHistoryNavigate, menuOpen }) {
|
|
14
|
+
const [historyIndex, setHistoryIndex] = useState(null);
|
|
15
|
+
useInput((_input, key) => {
|
|
16
|
+
if (menuOpen)
|
|
17
|
+
return;
|
|
18
|
+
if (key.upArrow) {
|
|
19
|
+
if (history.length === 0)
|
|
20
|
+
return;
|
|
21
|
+
const next = historyIndex === null ? history.length - 1 : Math.max(0, historyIndex - 1);
|
|
22
|
+
setHistoryIndex(next);
|
|
23
|
+
onHistoryNavigate(history[next]);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (key.downArrow) {
|
|
27
|
+
if (historyIndex === null)
|
|
28
|
+
return;
|
|
29
|
+
const next = historyIndex + 1;
|
|
30
|
+
if (next >= history.length) {
|
|
31
|
+
setHistoryIndex(null);
|
|
32
|
+
onHistoryNavigate("");
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
setHistoryIndex(next);
|
|
36
|
+
onHistoryNavigate(history[next]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
return h(Box, { borderStyle: "round", paddingX: 1 }, h(Text, null, "> "), h(TextInput, {
|
|
41
|
+
value,
|
|
42
|
+
onChange: (v) => {
|
|
43
|
+
setHistoryIndex(null);
|
|
44
|
+
onChange(v);
|
|
45
|
+
},
|
|
46
|
+
onSubmit,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
const h = React.createElement;
|
|
4
|
+
/** Purely presentational -- App.ts owns navigation (up/down) and selection (Enter). */
|
|
5
|
+
export function SlashMenu({ items, selectedIndex }) {
|
|
6
|
+
if (items.length === 0) {
|
|
7
|
+
return h(Box, { paddingX: 1 }, h(Text, { dimColor: true }, "no matching commands"));
|
|
8
|
+
}
|
|
9
|
+
return h(Box, { flexDirection: "column", paddingX: 1 }, ...items.map((item, i) => h(Text, { key: item.name, color: i === selectedIndex ? "cyan" : undefined }, `${i === selectedIndex ? "› " : " "}/${item.name} ${item.args} — ${item.description}`)));
|
|
10
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
const h = React.createElement;
|
|
4
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
5
|
+
/**
|
|
6
|
+
* One live-updating line (braille spinner + current step's message),
|
|
7
|
+
* replacing the old scrolling "· message" lines during scan/fix/explain.
|
|
8
|
+
* Hand-rolled rather than a dependency -- ten frames and a setInterval is
|
|
9
|
+
* simple enough not to justify another package.
|
|
10
|
+
*/
|
|
11
|
+
export function Spinner({ message }) {
|
|
12
|
+
const [frame, setFrame] = useState(0);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const id = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), 80);
|
|
15
|
+
return () => clearInterval(id);
|
|
16
|
+
}, []);
|
|
17
|
+
return h(Box, null, h(Text, { color: "cyan" }, FRAMES[frame]), h(Text, null, ` ${message}`));
|
|
18
|
+
}
|