fuzzi-cli 0.1.4 → 0.1.5
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/assets/changelog.json +10 -0
- package/dist/index.js +302 -172
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/assets/changelog.json
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"date": "2026-06-19",
|
|
5
|
+
"highlights": [
|
|
6
|
+
"Fixed API error parsing (msg.toLowerCase crash)",
|
|
7
|
+
"Loading spinners on all commands",
|
|
8
|
+
"Login state persists from saved credentials",
|
|
9
|
+
"Fixed readline prompt after interactive menus"
|
|
10
|
+
]
|
|
11
|
+
},
|
|
2
12
|
{
|
|
3
13
|
"version": "0.1.4",
|
|
4
14
|
"date": "2026-06-19",
|
package/dist/index.js
CHANGED
|
@@ -35,7 +35,7 @@ var init_brand = __esm({
|
|
|
35
35
|
HIGH: BRAND.danger,
|
|
36
36
|
CRITICAL: BRAND.critical
|
|
37
37
|
};
|
|
38
|
-
VERSION = "0.1.
|
|
38
|
+
VERSION = "0.1.5";
|
|
39
39
|
APP_ORIGIN = "https://fuzzi-ten.vercel.app";
|
|
40
40
|
DEFAULT_API_URL = `${APP_ORIGIN}/api`;
|
|
41
41
|
SETTINGS_API_KEYS_URL = `${APP_ORIGIN}/settings/api-keys`;
|
|
@@ -219,10 +219,40 @@ var init_logger = __esm({
|
|
|
219
219
|
}
|
|
220
220
|
});
|
|
221
221
|
|
|
222
|
+
// src/lib/api-utils.ts
|
|
223
|
+
function errorText(value) {
|
|
224
|
+
if (value == null) return "";
|
|
225
|
+
if (typeof value === "string") return value;
|
|
226
|
+
if (Array.isArray(value)) return value.map(errorText).filter(Boolean).join("; ");
|
|
227
|
+
if (typeof value === "object") {
|
|
228
|
+
const o = value;
|
|
229
|
+
if (typeof o.detail === "string") return o.detail;
|
|
230
|
+
if (Array.isArray(o.detail)) return o.detail.map(errorText).join("; ");
|
|
231
|
+
if (typeof o.message === "string") return o.message;
|
|
232
|
+
if (typeof o.error === "string") return o.error;
|
|
233
|
+
try {
|
|
234
|
+
return JSON.stringify(value);
|
|
235
|
+
} catch {
|
|
236
|
+
return String(value);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return String(value);
|
|
240
|
+
}
|
|
241
|
+
function asList(data) {
|
|
242
|
+
if (!data) return [];
|
|
243
|
+
if (Array.isArray(data)) return data;
|
|
244
|
+
return data.results ?? data.data ?? [];
|
|
245
|
+
}
|
|
246
|
+
var init_api_utils = __esm({
|
|
247
|
+
"src/lib/api-utils.ts"() {
|
|
248
|
+
"use strict";
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
222
252
|
// src/lib/api-client.ts
|
|
223
253
|
function mapErrorMessage(status, body) {
|
|
224
|
-
const code = body.code
|
|
225
|
-
const msg = body.error
|
|
254
|
+
const code = typeof body.code === "string" ? body.code.toLowerCase() : "";
|
|
255
|
+
const msg = errorText(body.error ?? body.message ?? body.detail);
|
|
226
256
|
if (status === 401) {
|
|
227
257
|
if (code === "key_revoked" || msg.toLowerCase().includes("revoked")) {
|
|
228
258
|
return "API key has been revoked. Please log in again.";
|
|
@@ -230,7 +260,7 @@ function mapErrorMessage(status, body) {
|
|
|
230
260
|
if (code === "key_expired" || msg.toLowerCase().includes("expired")) {
|
|
231
261
|
return `API key has expired. Generate a new one at ${SETTINGS_API_KEYS_URL}`;
|
|
232
262
|
}
|
|
233
|
-
return `Invalid API key. Generate a new one at ${SETTINGS_API_KEYS_URL}`;
|
|
263
|
+
return msg || `Invalid API key. Generate a new one at ${SETTINGS_API_KEYS_URL}`;
|
|
234
264
|
}
|
|
235
265
|
if (status === 403 && (code === "ssrf" || msg.toLowerCase().includes("private ip"))) {
|
|
236
266
|
return "This URL is not allowed (private IP address detected). Please scan a public-facing URL.";
|
|
@@ -262,6 +292,7 @@ var init_api_client = __esm({
|
|
|
262
292
|
init_credentials();
|
|
263
293
|
init_logger();
|
|
264
294
|
init_brand();
|
|
295
|
+
init_api_utils();
|
|
265
296
|
ApiError = class extends Error {
|
|
266
297
|
constructor(message, status, code, body, exitCode) {
|
|
267
298
|
super(message);
|
|
@@ -334,7 +365,7 @@ var init_api_client = __esm({
|
|
|
334
365
|
message = `Rate limit exceeded. Retry after ${seconds} seconds.`;
|
|
335
366
|
}
|
|
336
367
|
if (res.status >= 500) {
|
|
337
|
-
message = `
|
|
368
|
+
message = `Request failed: ${errorText(errBody.error ?? errBody.message ?? errBody.detail) || res.statusText}`;
|
|
338
369
|
}
|
|
339
370
|
throw new ApiError(message, res.status, errBody.code, data, 2);
|
|
340
371
|
}
|
|
@@ -718,6 +749,58 @@ async function openCliAuthPage() {
|
|
|
718
749
|
|
|
719
750
|
// src/commands/auth.ts
|
|
720
751
|
init_brand();
|
|
752
|
+
|
|
753
|
+
// src/terminal/interactive.ts
|
|
754
|
+
init_theme();
|
|
755
|
+
import { select, search } from "@inquirer/prompts";
|
|
756
|
+
var pauseHook = null;
|
|
757
|
+
var resumeHook = null;
|
|
758
|
+
function setReadlineHooks(pause, resume) {
|
|
759
|
+
pauseHook = pause;
|
|
760
|
+
resumeHook = resume;
|
|
761
|
+
}
|
|
762
|
+
async function runInteractive(fn) {
|
|
763
|
+
return withReadlinePaused(fn);
|
|
764
|
+
}
|
|
765
|
+
async function withReadlinePaused(fn) {
|
|
766
|
+
pauseHook?.();
|
|
767
|
+
try {
|
|
768
|
+
return await fn();
|
|
769
|
+
} finally {
|
|
770
|
+
resumeHook?.();
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
async function pickFromList(message, items) {
|
|
774
|
+
if (!items.length) return null;
|
|
775
|
+
try {
|
|
776
|
+
return await withReadlinePaused(() => select({ message, choices: items }));
|
|
777
|
+
} catch {
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
async function searchPalette(message, choices) {
|
|
782
|
+
try {
|
|
783
|
+
return await withReadlinePaused(
|
|
784
|
+
() => search({
|
|
785
|
+
message,
|
|
786
|
+
source: async (input5) => {
|
|
787
|
+
if (!input5) return choices;
|
|
788
|
+
const q = input5.toLowerCase();
|
|
789
|
+
return choices.filter(
|
|
790
|
+
(c) => c.value.toLowerCase().includes(q) || String(c.description ?? "").toLowerCase().includes(q)
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
})
|
|
794
|
+
);
|
|
795
|
+
} catch {
|
|
796
|
+
return null;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
function formatChoice(name, description) {
|
|
800
|
+
return `${accent(name)}${muted(" \u2014 " + description)}`;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// src/commands/auth.ts
|
|
721
804
|
async function runAssistedBrowserLogin() {
|
|
722
805
|
await openCliAuthPage();
|
|
723
806
|
console.log("");
|
|
@@ -753,15 +836,17 @@ async function runApiKeyLogin(opts = {}) {
|
|
|
753
836
|
2
|
|
754
837
|
);
|
|
755
838
|
}
|
|
756
|
-
apiKey = await
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
839
|
+
apiKey = await runInteractive(
|
|
840
|
+
() => password({
|
|
841
|
+
message: "Paste your API key (fz_live_...):",
|
|
842
|
+
mask: "\u2022",
|
|
843
|
+
validate: (v) => {
|
|
844
|
+
if (!v.trim()) return "API key is required";
|
|
845
|
+
if (!isValidApiKeyFormat(v)) return "Key must start with fz_live_";
|
|
846
|
+
return true;
|
|
847
|
+
}
|
|
848
|
+
})
|
|
849
|
+
);
|
|
765
850
|
}
|
|
766
851
|
apiKey = apiKey.trim();
|
|
767
852
|
if (!isValidApiKeyFormat(apiKey)) {
|
|
@@ -1087,6 +1172,17 @@ function createProgress(label, stream = false) {
|
|
|
1087
1172
|
}
|
|
1088
1173
|
};
|
|
1089
1174
|
}
|
|
1175
|
+
async function withSpinner(label, fn) {
|
|
1176
|
+
const p = createProgress(label);
|
|
1177
|
+
try {
|
|
1178
|
+
const result = await fn();
|
|
1179
|
+
p.stop();
|
|
1180
|
+
return result;
|
|
1181
|
+
} catch (e) {
|
|
1182
|
+
p.fail();
|
|
1183
|
+
throw e;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1090
1186
|
|
|
1091
1187
|
// src/commands/scan.ts
|
|
1092
1188
|
init_strings();
|
|
@@ -1186,6 +1282,7 @@ async function runScanCommand(client, opts) {
|
|
|
1186
1282
|
}
|
|
1187
1283
|
|
|
1188
1284
|
// src/commands/scans.ts
|
|
1285
|
+
init_api_utils();
|
|
1189
1286
|
async function runScansListCommand(client, format = "table", filters) {
|
|
1190
1287
|
const params = new URLSearchParams({ page: "1", page_size: String(filters?.limit || 20) });
|
|
1191
1288
|
if (filters?.status) params.set("status", filters.status);
|
|
@@ -1193,7 +1290,7 @@ async function runScansListCommand(client, format = "table", filters) {
|
|
|
1193
1290
|
const data = await client.get(
|
|
1194
1291
|
`/scans?${params.toString()}`
|
|
1195
1292
|
);
|
|
1196
|
-
return renderScansList(data
|
|
1293
|
+
return renderScansList(asList(data), format);
|
|
1197
1294
|
}
|
|
1198
1295
|
async function runScanGetCommand(client, scanId, format = "table") {
|
|
1199
1296
|
const detail = await client.get(`/scan/${scanId}`);
|
|
@@ -1453,6 +1550,10 @@ init_width();
|
|
|
1453
1550
|
function visibleLen(s) {
|
|
1454
1551
|
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
1455
1552
|
}
|
|
1553
|
+
function padCell(content, width) {
|
|
1554
|
+
const inner = width - 2;
|
|
1555
|
+
return " " + padEndVisible(content, inner) + " ";
|
|
1556
|
+
}
|
|
1456
1557
|
function topBanner(title, width = contentWidth()) {
|
|
1457
1558
|
const inner = ` ${title} `;
|
|
1458
1559
|
const dashes = Math.max(0, width - 2 - visibleLen(inner));
|
|
@@ -1480,7 +1581,7 @@ function tripleColumnPanel(cols, totalWidth = contentWidth()) {
|
|
|
1480
1581
|
const maxRows = Math.max(...cols.map((c) => c.lines.length), 1);
|
|
1481
1582
|
const body = [top];
|
|
1482
1583
|
for (let i = 0; i < maxRows; i++) {
|
|
1483
|
-
const cells = cols.map((c, idx) =>
|
|
1584
|
+
const cells = cols.map((c, idx) => padCell(c.lines[i] ?? "", widths[idx]));
|
|
1484
1585
|
body.push(
|
|
1485
1586
|
border("\u2502") + cells[0] + border("\u2502") + cells[1] + border("\u2502") + cells[2] + border("\u2502")
|
|
1486
1587
|
);
|
|
@@ -1498,7 +1599,7 @@ function singlePanel(title, lines, width = contentWidth()) {
|
|
|
1498
1599
|
const dashes = Math.max(0, inner - visibleLen(prefix));
|
|
1499
1600
|
const top = border(`\u250C${prefix}${"\u2500".repeat(dashes)}\u2510`);
|
|
1500
1601
|
const bottom = border(`\u2514${"\u2500".repeat(inner)}\u2518`);
|
|
1501
|
-
const body = lines.map((l) => border("\u2502") +
|
|
1602
|
+
const body = lines.map((l) => border("\u2502") + padCell(l, inner + 2) + border("\u2502"));
|
|
1502
1603
|
return [top, ...body, bottom].join("\n");
|
|
1503
1604
|
}
|
|
1504
1605
|
function tipPanel(text, width = contentWidth()) {
|
|
@@ -1507,9 +1608,9 @@ function tipPanel(text, width = contentWidth()) {
|
|
|
1507
1608
|
const dashes = Math.max(0, inner - visibleLen(prefix));
|
|
1508
1609
|
const top = border(`\u250C${prefix}${"\u2500".repeat(dashes)}\u2510`);
|
|
1509
1610
|
const bottom = border(`\u2514${"\u2500".repeat(inner)}\u2518`);
|
|
1510
|
-
return [top, border("\u2502") +
|
|
1611
|
+
return [top, border("\u2502") + padCell(text, inner + 2) + border("\u2502"), bottom].join("\n");
|
|
1511
1612
|
}
|
|
1512
|
-
function besideMark(mark, text, markCol =
|
|
1613
|
+
function besideMark(mark, text, markCol = 18, gap = 3) {
|
|
1513
1614
|
const rows = Math.max(mark.length, text.length);
|
|
1514
1615
|
const out = [];
|
|
1515
1616
|
for (let i = 0; i < rows; i++) {
|
|
@@ -1701,47 +1802,17 @@ function emptyState(title, hint, action) {
|
|
|
1701
1802
|
|
|
1702
1803
|
// src/commands/keys.ts
|
|
1703
1804
|
init_brand();
|
|
1704
|
-
|
|
1705
|
-
// src/terminal/interactive.ts
|
|
1706
|
-
init_theme();
|
|
1707
|
-
import { select, search } from "@inquirer/prompts";
|
|
1708
|
-
async function pickFromList(message, items) {
|
|
1709
|
-
if (!items.length) return null;
|
|
1710
|
-
try {
|
|
1711
|
-
return await select({ message, choices: items });
|
|
1712
|
-
} catch {
|
|
1713
|
-
return null;
|
|
1714
|
-
}
|
|
1715
|
-
}
|
|
1716
|
-
async function searchPalette(message, choices) {
|
|
1717
|
-
try {
|
|
1718
|
-
return await search({
|
|
1719
|
-
message,
|
|
1720
|
-
source: async (input5) => {
|
|
1721
|
-
if (!input5) return choices;
|
|
1722
|
-
const q = input5.toLowerCase();
|
|
1723
|
-
return choices.filter((c) => c.value.includes(q) || c.description?.includes(q));
|
|
1724
|
-
}
|
|
1725
|
-
});
|
|
1726
|
-
} catch {
|
|
1727
|
-
return null;
|
|
1728
|
-
}
|
|
1729
|
-
}
|
|
1730
|
-
function formatChoice(name, description) {
|
|
1731
|
-
return `${accent(name)}${muted(" \u2014 " + description)}`;
|
|
1732
|
-
}
|
|
1733
|
-
|
|
1734
|
-
// src/commands/keys.ts
|
|
1805
|
+
init_api_utils();
|
|
1735
1806
|
async function runKeysListCommand(client) {
|
|
1736
1807
|
const data = await client.get("/keys");
|
|
1737
|
-
const keys = data
|
|
1808
|
+
const keys = asList(data);
|
|
1738
1809
|
if (!keys.length) {
|
|
1739
1810
|
return emptyState("No API keys", `Create one at ${SETTINGS_API_KEYS_URL}`, "[n] new key in this view");
|
|
1740
1811
|
}
|
|
1741
1812
|
const rows = keys.map((k) => [
|
|
1742
1813
|
k.name,
|
|
1743
1814
|
k.prefix,
|
|
1744
|
-
k.scopes
|
|
1815
|
+
k.scopes && Array.isArray(k.scopes) ? k.scopes.join(", ") : muted("\u2014"),
|
|
1745
1816
|
k.revoked ? muted("Revoked") : k.active ? success("Active") : muted("Inactive"),
|
|
1746
1817
|
k.last_used_at ? new Date(k.last_used_at).toLocaleDateString() : muted("Never")
|
|
1747
1818
|
]);
|
|
@@ -1753,13 +1824,16 @@ async function runKeyRevoke(client, keyId) {
|
|
|
1753
1824
|
}
|
|
1754
1825
|
async function pickKeyForRevoke(client) {
|
|
1755
1826
|
const data = await client.get("/keys");
|
|
1756
|
-
const active = (data
|
|
1827
|
+
const active = asList(data).filter((k) => !k.revoked && k.active);
|
|
1757
1828
|
return pickFromList(
|
|
1758
1829
|
"Select a key to revoke",
|
|
1759
1830
|
active.map((k) => ({ name: `${k.name} (${k.prefix})`, value: k.id }))
|
|
1760
1831
|
);
|
|
1761
1832
|
}
|
|
1762
1833
|
|
|
1834
|
+
// src/shell/slash-commands.ts
|
|
1835
|
+
init_api_utils();
|
|
1836
|
+
|
|
1763
1837
|
// src/shell/help-screen.ts
|
|
1764
1838
|
init_layout();
|
|
1765
1839
|
init_theme();
|
|
@@ -1901,6 +1975,135 @@ function normalizeScanUrl(url) {
|
|
|
1901
1975
|
if (!/^https?:\/\//i.test(u)) return `https://${u}`;
|
|
1902
1976
|
return u;
|
|
1903
1977
|
}
|
|
1978
|
+
var SPINNER_LABELS = {
|
|
1979
|
+
"/scan": "Scanning target...",
|
|
1980
|
+
"/status": "Loading account status...",
|
|
1981
|
+
"/scans": "Loading scans...",
|
|
1982
|
+
"/keys": "Loading API keys...",
|
|
1983
|
+
"/config": "Updating configuration...",
|
|
1984
|
+
"/compare": "Comparing scans...",
|
|
1985
|
+
"/whatif": "Running simulation...",
|
|
1986
|
+
"/report": "Fetching report...",
|
|
1987
|
+
"/auth": "Signing in...",
|
|
1988
|
+
"/auth-key": "Validating API key...",
|
|
1989
|
+
"/login": "Signing in...",
|
|
1990
|
+
"/changelog": "Loading changelog...",
|
|
1991
|
+
"/history": "Loading history...",
|
|
1992
|
+
"/palette": "Opening command palette...",
|
|
1993
|
+
"/commands": "Opening command palette..."
|
|
1994
|
+
};
|
|
1995
|
+
function spinnerLabel(cmd2) {
|
|
1996
|
+
return SPINNER_LABELS[cmd2.toLowerCase()] ?? null;
|
|
1997
|
+
}
|
|
1998
|
+
async function executeSlashCommand(cmd2, arg, ctx) {
|
|
1999
|
+
switch (cmd2.toLowerCase()) {
|
|
2000
|
+
case "/help":
|
|
2001
|
+
ctx.sink.write(renderHelpScreen());
|
|
2002
|
+
break;
|
|
2003
|
+
case "/palette":
|
|
2004
|
+
case "/commands": {
|
|
2005
|
+
const picked = await openCommandPalette();
|
|
2006
|
+
if (picked) return dispatchSlashCommand(picked, ctx);
|
|
2007
|
+
break;
|
|
2008
|
+
}
|
|
2009
|
+
case "/clear":
|
|
2010
|
+
return { redraw: true };
|
|
2011
|
+
case "/history": {
|
|
2012
|
+
const hist = await loadHistory();
|
|
2013
|
+
ctx.sink.write(renderHistoryScreen(hist));
|
|
2014
|
+
break;
|
|
2015
|
+
}
|
|
2016
|
+
case "/scan": {
|
|
2017
|
+
if (!arg) {
|
|
2018
|
+
ctx.sink.write(error("Usage: /scan <url>"));
|
|
2019
|
+
break;
|
|
2020
|
+
}
|
|
2021
|
+
const client = await getAuthenticatedClient();
|
|
2022
|
+
const progress = createStreamProgress(ctx.sink);
|
|
2023
|
+
const result = await runScanCommand(client, {
|
|
2024
|
+
url: normalizeScanUrl(arg),
|
|
2025
|
+
wait: true,
|
|
2026
|
+
onProgress: progress.update,
|
|
2027
|
+
streamProgress: true
|
|
2028
|
+
});
|
|
2029
|
+
progress.stop();
|
|
2030
|
+
ctx.sink.write(result.output);
|
|
2031
|
+
break;
|
|
2032
|
+
}
|
|
2033
|
+
case "/status": {
|
|
2034
|
+
const client = await getAuthenticatedClient();
|
|
2035
|
+
const status = await runStatusCommand(client);
|
|
2036
|
+
const rate = await runRateLimitStatus(client);
|
|
2037
|
+
ctx.sink.write(rate ? `${status}
|
|
2038
|
+
|
|
2039
|
+
${muted("Rate limit")} ${rate}` : status);
|
|
2040
|
+
break;
|
|
2041
|
+
}
|
|
2042
|
+
case "/scans":
|
|
2043
|
+
await runScansInteractive(ctx);
|
|
2044
|
+
break;
|
|
2045
|
+
case "/keys":
|
|
2046
|
+
await runKeysInteractive(ctx);
|
|
2047
|
+
break;
|
|
2048
|
+
case "/config": {
|
|
2049
|
+
if (!arg.includes("=")) {
|
|
2050
|
+
ctx.sink.write(error("Usage: /config key=value"));
|
|
2051
|
+
break;
|
|
2052
|
+
}
|
|
2053
|
+
const [k, ...vParts] = arg.split("=");
|
|
2054
|
+
ctx.sink.write(await runConfigSet(k.trim(), vParts.join("=").trim()));
|
|
2055
|
+
break;
|
|
2056
|
+
}
|
|
2057
|
+
case "/changelog": {
|
|
2058
|
+
const raw = await readAsset("changelog.json");
|
|
2059
|
+
ctx.sink.write(renderChangelog(JSON.parse(raw)));
|
|
2060
|
+
break;
|
|
2061
|
+
}
|
|
2062
|
+
case "/compare": {
|
|
2063
|
+
const [a, b] = arg.split(/\s+/);
|
|
2064
|
+
if (!a || !b) {
|
|
2065
|
+
ctx.sink.write(error("Usage: /compare <scan-a> <scan-b>"));
|
|
2066
|
+
break;
|
|
2067
|
+
}
|
|
2068
|
+
const { runCompareCommand: runCompareCommand2 } = await Promise.resolve().then(() => (init_compare(), compare_exports));
|
|
2069
|
+
ctx.sink.write(await runCompareCommand2(a, b, "table"));
|
|
2070
|
+
break;
|
|
2071
|
+
}
|
|
2072
|
+
case "/whatif": {
|
|
2073
|
+
if (!arg) {
|
|
2074
|
+
ctx.sink.write(error("Usage: /whatif <scan-id>"));
|
|
2075
|
+
break;
|
|
2076
|
+
}
|
|
2077
|
+
const { runWhatIfCommand: runWhatIfCommand2 } = await Promise.resolve().then(() => (init_whatif(), whatif_exports));
|
|
2078
|
+
ctx.sink.write(await runWhatIfCommand2(arg, {}, "table"));
|
|
2079
|
+
break;
|
|
2080
|
+
}
|
|
2081
|
+
case "/report": {
|
|
2082
|
+
if (!arg) {
|
|
2083
|
+
ctx.sink.write(error("Usage: /report <scan-id>"));
|
|
2084
|
+
break;
|
|
2085
|
+
}
|
|
2086
|
+
const { runReportCommand: runReportCommand2 } = await Promise.resolve().then(() => (init_report(), report_exports));
|
|
2087
|
+
const client = await getAuthenticatedClient();
|
|
2088
|
+
ctx.sink.write(await runReportCommand2(client, arg, "json"));
|
|
2089
|
+
break;
|
|
2090
|
+
}
|
|
2091
|
+
case "/login":
|
|
2092
|
+
case "/auth": {
|
|
2093
|
+
const result = await runAssistedBrowserLogin();
|
|
2094
|
+
ctx.sink.write(result.message);
|
|
2095
|
+
return { profile: result.profile, redraw: true };
|
|
2096
|
+
}
|
|
2097
|
+
case "/auth-key": {
|
|
2098
|
+
ctx.sink.write(await runApiKeyLogin({ interactive: true }));
|
|
2099
|
+
const client = await getAuthenticatedClient();
|
|
2100
|
+
return { profile: await client.get("/me"), redraw: true };
|
|
2101
|
+
}
|
|
2102
|
+
default:
|
|
2103
|
+
ctx.sink.write(errorBox(`Unknown command: ${cmd2}`, "Type /help"));
|
|
2104
|
+
}
|
|
2105
|
+
return {};
|
|
2106
|
+
}
|
|
1904
2107
|
async function dispatchSlashCommand(line, ctx) {
|
|
1905
2108
|
const trimmed = line.trim();
|
|
1906
2109
|
if (!trimmed) return {};
|
|
@@ -1922,112 +2125,11 @@ ${accent("/help")} lists everything`
|
|
|
1922
2125
|
return {};
|
|
1923
2126
|
}
|
|
1924
2127
|
try {
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
break;
|
|
1929
|
-
case "/palette":
|
|
1930
|
-
case "/commands": {
|
|
1931
|
-
const picked = await openCommandPalette();
|
|
1932
|
-
if (picked) return dispatchSlashCommand(picked, ctx);
|
|
1933
|
-
break;
|
|
1934
|
-
}
|
|
1935
|
-
case "/clear":
|
|
1936
|
-
return { redraw: true };
|
|
1937
|
-
case "/history": {
|
|
1938
|
-
const hist = await loadHistory();
|
|
1939
|
-
ctx.sink.write(renderHistoryScreen(hist));
|
|
1940
|
-
break;
|
|
1941
|
-
}
|
|
1942
|
-
case "/scan": {
|
|
1943
|
-
if (!arg) {
|
|
1944
|
-
ctx.sink.write(error("Usage: /scan <url>"));
|
|
1945
|
-
break;
|
|
1946
|
-
}
|
|
1947
|
-
const client = await getAuthenticatedClient();
|
|
1948
|
-
const progress = createStreamProgress(ctx.sink);
|
|
1949
|
-
const result = await runScanCommand(client, {
|
|
1950
|
-
url: normalizeScanUrl(arg),
|
|
1951
|
-
wait: true,
|
|
1952
|
-
onProgress: progress.update,
|
|
1953
|
-
streamProgress: true
|
|
1954
|
-
});
|
|
1955
|
-
progress.stop();
|
|
1956
|
-
ctx.sink.write(result.output);
|
|
1957
|
-
break;
|
|
1958
|
-
}
|
|
1959
|
-
case "/status": {
|
|
1960
|
-
const client = await getAuthenticatedClient();
|
|
1961
|
-
const status = await runStatusCommand(client);
|
|
1962
|
-
const rate = await runRateLimitStatus(client);
|
|
1963
|
-
ctx.sink.write(rate ? `${status}
|
|
1964
|
-
|
|
1965
|
-
${muted("Rate limit")} ${rate}` : status);
|
|
1966
|
-
break;
|
|
1967
|
-
}
|
|
1968
|
-
case "/scans":
|
|
1969
|
-
await runScansInteractive(ctx);
|
|
1970
|
-
break;
|
|
1971
|
-
case "/keys":
|
|
1972
|
-
await runKeysInteractive(ctx);
|
|
1973
|
-
break;
|
|
1974
|
-
case "/config": {
|
|
1975
|
-
if (!arg.includes("=")) {
|
|
1976
|
-
ctx.sink.write(error("Usage: /config key=value"));
|
|
1977
|
-
break;
|
|
1978
|
-
}
|
|
1979
|
-
const [k, ...vParts] = arg.split("=");
|
|
1980
|
-
ctx.sink.write(await runConfigSet(k.trim(), vParts.join("=").trim()));
|
|
1981
|
-
break;
|
|
1982
|
-
}
|
|
1983
|
-
case "/changelog": {
|
|
1984
|
-
const raw = await readAsset("changelog.json");
|
|
1985
|
-
ctx.sink.write(renderChangelog(JSON.parse(raw)));
|
|
1986
|
-
break;
|
|
1987
|
-
}
|
|
1988
|
-
case "/compare": {
|
|
1989
|
-
const [a, b] = arg.split(/\s+/);
|
|
1990
|
-
if (!a || !b) {
|
|
1991
|
-
ctx.sink.write(error("Usage: /compare <scan-a> <scan-b>"));
|
|
1992
|
-
break;
|
|
1993
|
-
}
|
|
1994
|
-
const { runCompareCommand: runCompareCommand2 } = await Promise.resolve().then(() => (init_compare(), compare_exports));
|
|
1995
|
-
ctx.sink.write(await runCompareCommand2(a, b, "table"));
|
|
1996
|
-
break;
|
|
1997
|
-
}
|
|
1998
|
-
case "/whatif": {
|
|
1999
|
-
if (!arg) {
|
|
2000
|
-
ctx.sink.write(error("Usage: /whatif <scan-id>"));
|
|
2001
|
-
break;
|
|
2002
|
-
}
|
|
2003
|
-
const { runWhatIfCommand: runWhatIfCommand2 } = await Promise.resolve().then(() => (init_whatif(), whatif_exports));
|
|
2004
|
-
ctx.sink.write(await runWhatIfCommand2(arg, {}, "table"));
|
|
2005
|
-
break;
|
|
2006
|
-
}
|
|
2007
|
-
case "/report": {
|
|
2008
|
-
if (!arg) {
|
|
2009
|
-
ctx.sink.write(error("Usage: /report <scan-id>"));
|
|
2010
|
-
break;
|
|
2011
|
-
}
|
|
2012
|
-
const { runReportCommand: runReportCommand2 } = await Promise.resolve().then(() => (init_report(), report_exports));
|
|
2013
|
-
const client = await getAuthenticatedClient();
|
|
2014
|
-
ctx.sink.write(await runReportCommand2(client, arg, "json"));
|
|
2015
|
-
break;
|
|
2016
|
-
}
|
|
2017
|
-
case "/login":
|
|
2018
|
-
case "/auth": {
|
|
2019
|
-
const result = await runAssistedBrowserLogin();
|
|
2020
|
-
ctx.sink.write(result.message);
|
|
2021
|
-
return { profile: result.profile, redraw: true };
|
|
2022
|
-
}
|
|
2023
|
-
case "/auth-key": {
|
|
2024
|
-
ctx.sink.write(await runApiKeyLogin({ interactive: true }));
|
|
2025
|
-
const client = await getAuthenticatedClient();
|
|
2026
|
-
return { profile: await client.get("/me"), redraw: true };
|
|
2027
|
-
}
|
|
2028
|
-
default:
|
|
2029
|
-
ctx.sink.write(errorBox(`Unknown command: ${cmd2}`, "Type /help"));
|
|
2128
|
+
const label = spinnerLabel(cmd2);
|
|
2129
|
+
if (label) {
|
|
2130
|
+
return await withSpinner(label, () => executeSlashCommand(cmd2, arg, ctx));
|
|
2030
2131
|
}
|
|
2132
|
+
return await executeSlashCommand(cmd2, arg, ctx);
|
|
2031
2133
|
} catch (e) {
|
|
2032
2134
|
ctx.sink.error(formatApiError(e));
|
|
2033
2135
|
}
|
|
@@ -2052,7 +2154,7 @@ async function runScansInteractive(ctx) {
|
|
|
2052
2154
|
while (true) {
|
|
2053
2155
|
const list = await runScansListCommand(client, "table", { limit: 20 });
|
|
2054
2156
|
const data = await client.get("/scans?page=1&page_size=20");
|
|
2055
|
-
const scans = data
|
|
2157
|
+
const scans = asList(data);
|
|
2056
2158
|
if (!scans.length) {
|
|
2057
2159
|
ctx.sink.write(emptyState("No scans yet", "Run /scan <url> to create your first scan", "/scan https://example.com"));
|
|
2058
2160
|
return;
|
|
@@ -2069,10 +2171,12 @@ async function runScansInteractive(ctx) {
|
|
|
2069
2171
|
if (!scanId) return;
|
|
2070
2172
|
const detail = await client.get(`/scan/${scanId}`);
|
|
2071
2173
|
ctx.sink.write(renderScanResult(detail, "table"));
|
|
2072
|
-
const cont = await
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2174
|
+
const cont = await runInteractive(
|
|
2175
|
+
() => input2({
|
|
2176
|
+
message: muted("Enter = back to list \xB7 q = exit"),
|
|
2177
|
+
default: ""
|
|
2178
|
+
})
|
|
2179
|
+
).catch(() => "q");
|
|
2076
2180
|
if (cont.toLowerCase() === "q") return;
|
|
2077
2181
|
}
|
|
2078
2182
|
}
|
|
@@ -2080,14 +2184,16 @@ async function runKeysInteractive(ctx) {
|
|
|
2080
2184
|
const client = await getAuthenticatedClient();
|
|
2081
2185
|
ctx.sink.write(await runKeysListCommand(client));
|
|
2082
2186
|
ctx.sink.write(muted("\nActions: [r] revoke [n] new key [Enter] back"));
|
|
2083
|
-
const action = await input2({ message: "Action", default: "" }).catch(() => "");
|
|
2187
|
+
const action = await runInteractive(() => input2({ message: "Action", default: "" })).catch(() => "");
|
|
2084
2188
|
if (action.toLowerCase() === "r") {
|
|
2085
2189
|
const keyId = await pickKeyForRevoke(client);
|
|
2086
2190
|
if (!keyId) return;
|
|
2087
|
-
const ok = await
|
|
2191
|
+
const ok = await runInteractive(
|
|
2192
|
+
() => confirm2({ message: "Revoke this API key?", default: false })
|
|
2193
|
+
).catch(() => false);
|
|
2088
2194
|
if (ok) ctx.sink.write(successBox(await runKeyRevoke(client, keyId)));
|
|
2089
2195
|
} else if (action.toLowerCase() === "n") {
|
|
2090
|
-
const name = await promptNewKeyName();
|
|
2196
|
+
const name = await runInteractive(() => promptNewKeyName());
|
|
2091
2197
|
const created = await client.post("/keys", { name });
|
|
2092
2198
|
ctx.sink.write(success(`Created: ${created.name} (${created.prefix})`));
|
|
2093
2199
|
ctx.sink.write(accent("Save this key \u2014 it won't be shown again:"));
|
|
@@ -2139,6 +2245,13 @@ async function runPromptLoop(initialProfile) {
|
|
|
2139
2245
|
rl.close();
|
|
2140
2246
|
process.exit(0);
|
|
2141
2247
|
});
|
|
2248
|
+
setReadlineHooks(
|
|
2249
|
+
() => rl.pause(),
|
|
2250
|
+
() => {
|
|
2251
|
+
process.stdout.write("\n");
|
|
2252
|
+
rl.resume();
|
|
2253
|
+
}
|
|
2254
|
+
);
|
|
2142
2255
|
const prompt = () => process.stdout.write(accent("> "));
|
|
2143
2256
|
prompt();
|
|
2144
2257
|
for await (const line of rl) {
|
|
@@ -2176,15 +2289,32 @@ init_theme();
|
|
|
2176
2289
|
init_credentials();
|
|
2177
2290
|
init_api_client();
|
|
2178
2291
|
init_logger();
|
|
2292
|
+
function profileFromCredentials(creds) {
|
|
2293
|
+
return {
|
|
2294
|
+
id: "local",
|
|
2295
|
+
email: creds.email || "user@local",
|
|
2296
|
+
full_name: creds.full_name || null,
|
|
2297
|
+
role: "analyst",
|
|
2298
|
+
organization: "\u2014",
|
|
2299
|
+
key_expires_at: creds.key_expires_at ?? null,
|
|
2300
|
+
key_prefix: creds.key_prefix ?? null
|
|
2301
|
+
};
|
|
2302
|
+
}
|
|
2179
2303
|
async function tryGetProfile() {
|
|
2304
|
+
const creds = await loadCredentials();
|
|
2305
|
+
if (!creds?.api_key) return null;
|
|
2180
2306
|
try {
|
|
2181
|
-
const
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
return await client.get("/me");
|
|
2307
|
+
const client = await FuzziApiClient.create();
|
|
2308
|
+
const profile = await client.get("/me");
|
|
2309
|
+
return profile;
|
|
2185
2310
|
} catch (e) {
|
|
2186
|
-
|
|
2187
|
-
|
|
2311
|
+
if (e instanceof ApiError && e.status === 401) {
|
|
2312
|
+
log.debug("stored credentials invalid, clearing");
|
|
2313
|
+
await clearCredentials();
|
|
2314
|
+
return null;
|
|
2315
|
+
}
|
|
2316
|
+
log.debug("profile bootstrap failed, using cached credentials", e);
|
|
2317
|
+
return profileFromCredentials(creds);
|
|
2188
2318
|
}
|
|
2189
2319
|
}
|
|
2190
2320
|
|