fuzzi-cli 0.1.4 → 0.1.6
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 +11 -0
- package/assets/changelog.json +20 -0
- package/bin/fuzzi.js +9 -2
- package/dist/index.js +371 -188
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
1
2
|
var __defProp = Object.defineProperty;
|
|
2
3
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
4
|
var __esm = (fn, res) => function __init() {
|
|
@@ -35,7 +36,7 @@ var init_brand = __esm({
|
|
|
35
36
|
HIGH: BRAND.danger,
|
|
36
37
|
CRITICAL: BRAND.critical
|
|
37
38
|
};
|
|
38
|
-
VERSION = "0.1.
|
|
39
|
+
VERSION = "0.1.6";
|
|
39
40
|
APP_ORIGIN = "https://fuzzi-ten.vercel.app";
|
|
40
41
|
DEFAULT_API_URL = `${APP_ORIGIN}/api`;
|
|
41
42
|
SETTINGS_API_KEYS_URL = `${APP_ORIGIN}/settings/api-keys`;
|
|
@@ -52,7 +53,10 @@ function fuzziDir() {
|
|
|
52
53
|
return FUZZI_DIR;
|
|
53
54
|
}
|
|
54
55
|
async function ensureDir() {
|
|
55
|
-
await mkdir(FUZZI_DIR, {
|
|
56
|
+
await mkdir(FUZZI_DIR, {
|
|
57
|
+
recursive: true,
|
|
58
|
+
...process.platform === "win32" ? {} : { mode: 448 }
|
|
59
|
+
});
|
|
56
60
|
}
|
|
57
61
|
function normalizeCredentials(raw) {
|
|
58
62
|
if (raw.api_key && typeof raw.api_key === "string") {
|
|
@@ -128,7 +132,10 @@ import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2, chmod
|
|
|
128
132
|
import { homedir as homedir2 } from "os";
|
|
129
133
|
import { join as join2 } from "path";
|
|
130
134
|
async function ensureDir2() {
|
|
131
|
-
await mkdir2(fuzziDir(), {
|
|
135
|
+
await mkdir2(fuzziDir(), {
|
|
136
|
+
recursive: true,
|
|
137
|
+
...process.platform === "win32" ? {} : { mode: 448 }
|
|
138
|
+
});
|
|
132
139
|
}
|
|
133
140
|
async function loadConfig() {
|
|
134
141
|
for (const path of [CONFIG_PATH, LEGACY_CONFIG_PATH]) {
|
|
@@ -219,10 +226,40 @@ var init_logger = __esm({
|
|
|
219
226
|
}
|
|
220
227
|
});
|
|
221
228
|
|
|
229
|
+
// src/lib/api-utils.ts
|
|
230
|
+
function errorText(value) {
|
|
231
|
+
if (value == null) return "";
|
|
232
|
+
if (typeof value === "string") return value;
|
|
233
|
+
if (Array.isArray(value)) return value.map(errorText).filter(Boolean).join("; ");
|
|
234
|
+
if (typeof value === "object") {
|
|
235
|
+
const o = value;
|
|
236
|
+
if (typeof o.detail === "string") return o.detail;
|
|
237
|
+
if (Array.isArray(o.detail)) return o.detail.map(errorText).join("; ");
|
|
238
|
+
if (typeof o.message === "string") return o.message;
|
|
239
|
+
if (typeof o.error === "string") return o.error;
|
|
240
|
+
try {
|
|
241
|
+
return JSON.stringify(value);
|
|
242
|
+
} catch {
|
|
243
|
+
return String(value);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return String(value);
|
|
247
|
+
}
|
|
248
|
+
function asList(data) {
|
|
249
|
+
if (!data) return [];
|
|
250
|
+
if (Array.isArray(data)) return data;
|
|
251
|
+
return data.results ?? data.data ?? [];
|
|
252
|
+
}
|
|
253
|
+
var init_api_utils = __esm({
|
|
254
|
+
"src/lib/api-utils.ts"() {
|
|
255
|
+
"use strict";
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
222
259
|
// src/lib/api-client.ts
|
|
223
260
|
function mapErrorMessage(status, body) {
|
|
224
|
-
const code = body.code
|
|
225
|
-
const msg = body.error
|
|
261
|
+
const code = typeof body.code === "string" ? body.code.toLowerCase() : "";
|
|
262
|
+
const msg = errorText(body.error ?? body.message ?? body.detail);
|
|
226
263
|
if (status === 401) {
|
|
227
264
|
if (code === "key_revoked" || msg.toLowerCase().includes("revoked")) {
|
|
228
265
|
return "API key has been revoked. Please log in again.";
|
|
@@ -230,7 +267,7 @@ function mapErrorMessage(status, body) {
|
|
|
230
267
|
if (code === "key_expired" || msg.toLowerCase().includes("expired")) {
|
|
231
268
|
return `API key has expired. Generate a new one at ${SETTINGS_API_KEYS_URL}`;
|
|
232
269
|
}
|
|
233
|
-
return `Invalid API key. Generate a new one at ${SETTINGS_API_KEYS_URL}`;
|
|
270
|
+
return msg || `Invalid API key. Generate a new one at ${SETTINGS_API_KEYS_URL}`;
|
|
234
271
|
}
|
|
235
272
|
if (status === 403 && (code === "ssrf" || msg.toLowerCase().includes("private ip"))) {
|
|
236
273
|
return "This URL is not allowed (private IP address detected). Please scan a public-facing URL.";
|
|
@@ -262,6 +299,7 @@ var init_api_client = __esm({
|
|
|
262
299
|
init_credentials();
|
|
263
300
|
init_logger();
|
|
264
301
|
init_brand();
|
|
302
|
+
init_api_utils();
|
|
265
303
|
ApiError = class extends Error {
|
|
266
304
|
constructor(message, status, code, body, exitCode) {
|
|
267
305
|
super(message);
|
|
@@ -334,7 +372,7 @@ var init_api_client = __esm({
|
|
|
334
372
|
message = `Rate limit exceeded. Retry after ${seconds} seconds.`;
|
|
335
373
|
}
|
|
336
374
|
if (res.status >= 500) {
|
|
337
|
-
message = `
|
|
375
|
+
message = `Request failed: ${errorText(errBody.error ?? errBody.message ?? errBody.detail) || res.statusText}`;
|
|
338
376
|
}
|
|
339
377
|
throw new ApiError(message, res.status, errBody.code, data, 2);
|
|
340
378
|
}
|
|
@@ -398,7 +436,8 @@ function getCapabilities() {
|
|
|
398
436
|
const cols = stdout.columns ?? 80;
|
|
399
437
|
const term = process.env.TERM ?? "";
|
|
400
438
|
const colorterm = process.env.COLORTERM ?? "";
|
|
401
|
-
const
|
|
439
|
+
const onWindows = process.platform === "win32";
|
|
440
|
+
const trueColor = colorterm.includes("truecolor") || colorterm.includes("24bit") || term.includes("truecolor") || !!process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0" || onWindows && stdout.isTTY === true || !!process.env.WT_SESSION || !!process.env.TERM_PROGRAM;
|
|
402
441
|
cached = {
|
|
403
442
|
width: Math.max(60, cols),
|
|
404
443
|
trueColor,
|
|
@@ -524,10 +563,10 @@ var init_strings = __esm({
|
|
|
524
563
|
});
|
|
525
564
|
|
|
526
565
|
// src/terminal/width.ts
|
|
527
|
-
import { stdout as
|
|
566
|
+
import { stdout as stdout3 } from "process";
|
|
528
567
|
function terminalWidth() {
|
|
529
568
|
resetCapabilities();
|
|
530
|
-
return Math.max(64, (
|
|
569
|
+
return Math.max(64, (stdout3.columns ?? 80) - 2);
|
|
531
570
|
}
|
|
532
571
|
function contentWidth() {
|
|
533
572
|
return terminalWidth() - 4;
|
|
@@ -697,14 +736,41 @@ init_brand();
|
|
|
697
736
|
init_logger();
|
|
698
737
|
import { randomBytes } from "crypto";
|
|
699
738
|
import { createServer } from "http";
|
|
700
|
-
|
|
739
|
+
|
|
740
|
+
// src/lib/platform.ts
|
|
741
|
+
import { spawn } from "child_process";
|
|
742
|
+
import { stdout as stdout2, stderr } from "process";
|
|
743
|
+
function configurePlatform() {
|
|
744
|
+
if (process.platform !== "win32") return;
|
|
745
|
+
try {
|
|
746
|
+
if (stdout2.isTTY) enableWindowsVtMode(stdout2);
|
|
747
|
+
if (stderr.isTTY) enableWindowsVtMode(stderr);
|
|
748
|
+
} catch {
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
function enableWindowsVtMode(stream) {
|
|
752
|
+
const handle = stream._handle;
|
|
753
|
+
if (!handle?.setMode || handle.mode == null) return;
|
|
754
|
+
handle.setMode(handle.mode | 4);
|
|
755
|
+
}
|
|
701
756
|
function openBrowser(url) {
|
|
702
757
|
const platform = process.platform;
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
758
|
+
if (platform === "win32") {
|
|
759
|
+
spawn("cmd", ["/c", "start", "", url], {
|
|
760
|
+
detached: true,
|
|
761
|
+
stdio: "ignore",
|
|
762
|
+
windowsHide: true
|
|
763
|
+
}).unref();
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
if (platform === "darwin") {
|
|
767
|
+
spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
707
771
|
}
|
|
772
|
+
|
|
773
|
+
// src/lib/browser-auth.ts
|
|
708
774
|
function apiOrigin(apiUrl) {
|
|
709
775
|
return apiUrl.replace(/\/api\/?$/, "") || APP_ORIGIN;
|
|
710
776
|
}
|
|
@@ -718,6 +784,58 @@ async function openCliAuthPage() {
|
|
|
718
784
|
|
|
719
785
|
// src/commands/auth.ts
|
|
720
786
|
init_brand();
|
|
787
|
+
|
|
788
|
+
// src/terminal/interactive.ts
|
|
789
|
+
init_theme();
|
|
790
|
+
import { select, search } from "@inquirer/prompts";
|
|
791
|
+
var pauseHook = null;
|
|
792
|
+
var resumeHook = null;
|
|
793
|
+
function setReadlineHooks(pause, resume) {
|
|
794
|
+
pauseHook = pause;
|
|
795
|
+
resumeHook = resume;
|
|
796
|
+
}
|
|
797
|
+
async function runInteractive(fn) {
|
|
798
|
+
return withReadlinePaused(fn);
|
|
799
|
+
}
|
|
800
|
+
async function withReadlinePaused(fn) {
|
|
801
|
+
pauseHook?.();
|
|
802
|
+
try {
|
|
803
|
+
return await fn();
|
|
804
|
+
} finally {
|
|
805
|
+
resumeHook?.();
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
async function pickFromList(message, items) {
|
|
809
|
+
if (!items.length) return null;
|
|
810
|
+
try {
|
|
811
|
+
return await withReadlinePaused(() => select({ message, choices: items }));
|
|
812
|
+
} catch {
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
async function searchPalette(message, choices) {
|
|
817
|
+
try {
|
|
818
|
+
return await withReadlinePaused(
|
|
819
|
+
() => search({
|
|
820
|
+
message,
|
|
821
|
+
source: async (input5) => {
|
|
822
|
+
if (!input5) return choices;
|
|
823
|
+
const q = input5.toLowerCase();
|
|
824
|
+
return choices.filter(
|
|
825
|
+
(c) => c.value.toLowerCase().includes(q) || String(c.description ?? "").toLowerCase().includes(q)
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
})
|
|
829
|
+
);
|
|
830
|
+
} catch {
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
function formatChoice(name, description) {
|
|
835
|
+
return `${accent(name)}${muted(" \u2014 " + description)}`;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// src/commands/auth.ts
|
|
721
839
|
async function runAssistedBrowserLogin() {
|
|
722
840
|
await openCliAuthPage();
|
|
723
841
|
console.log("");
|
|
@@ -753,15 +871,17 @@ async function runApiKeyLogin(opts = {}) {
|
|
|
753
871
|
2
|
|
754
872
|
);
|
|
755
873
|
}
|
|
756
|
-
apiKey = await
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
874
|
+
apiKey = await runInteractive(
|
|
875
|
+
() => password({
|
|
876
|
+
message: "Paste your API key (fz_live_...):",
|
|
877
|
+
mask: "\u2022",
|
|
878
|
+
validate: (v) => {
|
|
879
|
+
if (!v.trim()) return "API key is required";
|
|
880
|
+
if (!isValidApiKeyFormat(v)) return "Key must start with fz_live_";
|
|
881
|
+
return true;
|
|
882
|
+
}
|
|
883
|
+
})
|
|
884
|
+
);
|
|
765
885
|
}
|
|
766
886
|
apiKey = apiKey.trim();
|
|
767
887
|
if (!isValidApiKeyFormat(apiKey)) {
|
|
@@ -1062,7 +1182,12 @@ function createProgress(label, stream = false) {
|
|
|
1062
1182
|
}
|
|
1063
1183
|
};
|
|
1064
1184
|
}
|
|
1065
|
-
let spinner = ora({
|
|
1185
|
+
let spinner = ora({
|
|
1186
|
+
text: label,
|
|
1187
|
+
color: "cyan",
|
|
1188
|
+
discardStdin: false,
|
|
1189
|
+
isEnabled: getCapabilities().interactive
|
|
1190
|
+
}).start();
|
|
1066
1191
|
return {
|
|
1067
1192
|
update(message) {
|
|
1068
1193
|
if (spinner) spinner.text = message;
|
|
@@ -1087,6 +1212,17 @@ function createProgress(label, stream = false) {
|
|
|
1087
1212
|
}
|
|
1088
1213
|
};
|
|
1089
1214
|
}
|
|
1215
|
+
async function withSpinner(label, fn) {
|
|
1216
|
+
const p = createProgress(label);
|
|
1217
|
+
try {
|
|
1218
|
+
const result = await fn();
|
|
1219
|
+
p.stop();
|
|
1220
|
+
return result;
|
|
1221
|
+
} catch (e) {
|
|
1222
|
+
p.fail();
|
|
1223
|
+
throw e;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1090
1226
|
|
|
1091
1227
|
// src/commands/scan.ts
|
|
1092
1228
|
init_strings();
|
|
@@ -1186,6 +1322,7 @@ async function runScanCommand(client, opts) {
|
|
|
1186
1322
|
}
|
|
1187
1323
|
|
|
1188
1324
|
// src/commands/scans.ts
|
|
1325
|
+
init_api_utils();
|
|
1189
1326
|
async function runScansListCommand(client, format = "table", filters) {
|
|
1190
1327
|
const params = new URLSearchParams({ page: "1", page_size: String(filters?.limit || 20) });
|
|
1191
1328
|
if (filters?.status) params.set("status", filters.status);
|
|
@@ -1193,7 +1330,7 @@ async function runScansListCommand(client, format = "table", filters) {
|
|
|
1193
1330
|
const data = await client.get(
|
|
1194
1331
|
`/scans?${params.toString()}`
|
|
1195
1332
|
);
|
|
1196
|
-
return renderScansList(data
|
|
1333
|
+
return renderScansList(asList(data), format);
|
|
1197
1334
|
}
|
|
1198
1335
|
async function runScanGetCommand(client, scanId, format = "table") {
|
|
1199
1336
|
const detail = await client.get(`/scan/${scanId}`);
|
|
@@ -1405,25 +1542,34 @@ import { stdin as input3, stdout as output, cwd as cwd3 } from "process";
|
|
|
1405
1542
|
// src/lib/assets.ts
|
|
1406
1543
|
import { readFile as readFile4 } from "fs/promises";
|
|
1407
1544
|
import { dirname, join as join4 } from "path";
|
|
1408
|
-
import { fileURLToPath } from "url";
|
|
1545
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
1409
1546
|
import { existsSync as existsSync2 } from "fs";
|
|
1547
|
+
import { createRequire } from "module";
|
|
1410
1548
|
function assetsDir() {
|
|
1411
1549
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
1412
1550
|
const candidates = [
|
|
1413
1551
|
join4(here, "..", "assets"),
|
|
1414
|
-
// dist/
|
|
1552
|
+
// dist/index.js or dist/lib → package/assets
|
|
1415
1553
|
join4(here, "..", "..", "assets"),
|
|
1416
1554
|
// src/lib → package/assets
|
|
1417
1555
|
join4(here, "assets")
|
|
1418
|
-
// bundled flat fallback
|
|
1419
1556
|
];
|
|
1557
|
+
try {
|
|
1558
|
+
const req = createRequire(import.meta.url);
|
|
1559
|
+
const pkgRoot = dirname(req.resolve("fuzzi-cli/package.json"));
|
|
1560
|
+
candidates.unshift(join4(pkgRoot, "assets"));
|
|
1561
|
+
} catch {
|
|
1562
|
+
}
|
|
1420
1563
|
for (const p of candidates) {
|
|
1421
1564
|
if (existsSync2(join4(p, "changelog.json"))) return p;
|
|
1422
1565
|
}
|
|
1423
1566
|
return candidates[0];
|
|
1424
1567
|
}
|
|
1568
|
+
function assetPath(name) {
|
|
1569
|
+
return join4(assetsDir(), name);
|
|
1570
|
+
}
|
|
1425
1571
|
async function readAsset(name) {
|
|
1426
|
-
return readFile4(
|
|
1572
|
+
return readFile4(assetPath(name), "utf8");
|
|
1427
1573
|
}
|
|
1428
1574
|
|
|
1429
1575
|
// src/shell/home-screen.ts
|
|
@@ -1453,6 +1599,10 @@ init_width();
|
|
|
1453
1599
|
function visibleLen(s) {
|
|
1454
1600
|
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
1455
1601
|
}
|
|
1602
|
+
function padCell(content, width) {
|
|
1603
|
+
const inner = width - 2;
|
|
1604
|
+
return " " + padEndVisible(content, inner) + " ";
|
|
1605
|
+
}
|
|
1456
1606
|
function topBanner(title, width = contentWidth()) {
|
|
1457
1607
|
const inner = ` ${title} `;
|
|
1458
1608
|
const dashes = Math.max(0, width - 2 - visibleLen(inner));
|
|
@@ -1480,7 +1630,7 @@ function tripleColumnPanel(cols, totalWidth = contentWidth()) {
|
|
|
1480
1630
|
const maxRows = Math.max(...cols.map((c) => c.lines.length), 1);
|
|
1481
1631
|
const body = [top];
|
|
1482
1632
|
for (let i = 0; i < maxRows; i++) {
|
|
1483
|
-
const cells = cols.map((c, idx) =>
|
|
1633
|
+
const cells = cols.map((c, idx) => padCell(c.lines[i] ?? "", widths[idx]));
|
|
1484
1634
|
body.push(
|
|
1485
1635
|
border("\u2502") + cells[0] + border("\u2502") + cells[1] + border("\u2502") + cells[2] + border("\u2502")
|
|
1486
1636
|
);
|
|
@@ -1498,7 +1648,7 @@ function singlePanel(title, lines, width = contentWidth()) {
|
|
|
1498
1648
|
const dashes = Math.max(0, inner - visibleLen(prefix));
|
|
1499
1649
|
const top = border(`\u250C${prefix}${"\u2500".repeat(dashes)}\u2510`);
|
|
1500
1650
|
const bottom = border(`\u2514${"\u2500".repeat(inner)}\u2518`);
|
|
1501
|
-
const body = lines.map((l) => border("\u2502") +
|
|
1651
|
+
const body = lines.map((l) => border("\u2502") + padCell(l, inner + 2) + border("\u2502"));
|
|
1502
1652
|
return [top, ...body, bottom].join("\n");
|
|
1503
1653
|
}
|
|
1504
1654
|
function tipPanel(text, width = contentWidth()) {
|
|
@@ -1507,9 +1657,9 @@ function tipPanel(text, width = contentWidth()) {
|
|
|
1507
1657
|
const dashes = Math.max(0, inner - visibleLen(prefix));
|
|
1508
1658
|
const top = border(`\u250C${prefix}${"\u2500".repeat(dashes)}\u2510`);
|
|
1509
1659
|
const bottom = border(`\u2514${"\u2500".repeat(inner)}\u2518`);
|
|
1510
|
-
return [top, border("\u2502") +
|
|
1660
|
+
return [top, border("\u2502") + padCell(text, inner + 2) + border("\u2502"), bottom].join("\n");
|
|
1511
1661
|
}
|
|
1512
|
-
function besideMark(mark, text, markCol =
|
|
1662
|
+
function besideMark(mark, text, markCol = 18, gap = 3) {
|
|
1513
1663
|
const rows = Math.max(mark.length, text.length);
|
|
1514
1664
|
const out = [];
|
|
1515
1665
|
for (let i = 0; i < rows; i++) {
|
|
@@ -1701,47 +1851,17 @@ function emptyState(title, hint, action) {
|
|
|
1701
1851
|
|
|
1702
1852
|
// src/commands/keys.ts
|
|
1703
1853
|
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
|
|
1854
|
+
init_api_utils();
|
|
1735
1855
|
async function runKeysListCommand(client) {
|
|
1736
1856
|
const data = await client.get("/keys");
|
|
1737
|
-
const keys = data
|
|
1857
|
+
const keys = asList(data);
|
|
1738
1858
|
if (!keys.length) {
|
|
1739
1859
|
return emptyState("No API keys", `Create one at ${SETTINGS_API_KEYS_URL}`, "[n] new key in this view");
|
|
1740
1860
|
}
|
|
1741
1861
|
const rows = keys.map((k) => [
|
|
1742
1862
|
k.name,
|
|
1743
1863
|
k.prefix,
|
|
1744
|
-
k.scopes
|
|
1864
|
+
k.scopes && Array.isArray(k.scopes) ? k.scopes.join(", ") : muted("\u2014"),
|
|
1745
1865
|
k.revoked ? muted("Revoked") : k.active ? success("Active") : muted("Inactive"),
|
|
1746
1866
|
k.last_used_at ? new Date(k.last_used_at).toLocaleDateString() : muted("Never")
|
|
1747
1867
|
]);
|
|
@@ -1753,13 +1873,16 @@ async function runKeyRevoke(client, keyId) {
|
|
|
1753
1873
|
}
|
|
1754
1874
|
async function pickKeyForRevoke(client) {
|
|
1755
1875
|
const data = await client.get("/keys");
|
|
1756
|
-
const active = (data
|
|
1876
|
+
const active = asList(data).filter((k) => !k.revoked && k.active);
|
|
1757
1877
|
return pickFromList(
|
|
1758
1878
|
"Select a key to revoke",
|
|
1759
1879
|
active.map((k) => ({ name: `${k.name} (${k.prefix})`, value: k.id }))
|
|
1760
1880
|
);
|
|
1761
1881
|
}
|
|
1762
1882
|
|
|
1883
|
+
// src/shell/slash-commands.ts
|
|
1884
|
+
init_api_utils();
|
|
1885
|
+
|
|
1763
1886
|
// src/shell/help-screen.ts
|
|
1764
1887
|
init_layout();
|
|
1765
1888
|
init_theme();
|
|
@@ -1843,7 +1966,10 @@ async function loadHistory() {
|
|
|
1843
1966
|
async function appendHistory(line) {
|
|
1844
1967
|
const trimmed = line.trim();
|
|
1845
1968
|
if (!trimmed || trimmed.startsWith("#")) return;
|
|
1846
|
-
await mkdir3(fuzziDir(), {
|
|
1969
|
+
await mkdir3(fuzziDir(), {
|
|
1970
|
+
recursive: true,
|
|
1971
|
+
...process.platform === "win32" ? {} : { mode: 448 }
|
|
1972
|
+
});
|
|
1847
1973
|
await appendFile(HISTORY_PATH, trimmed + "\n", "utf8");
|
|
1848
1974
|
}
|
|
1849
1975
|
|
|
@@ -1901,6 +2027,135 @@ function normalizeScanUrl(url) {
|
|
|
1901
2027
|
if (!/^https?:\/\//i.test(u)) return `https://${u}`;
|
|
1902
2028
|
return u;
|
|
1903
2029
|
}
|
|
2030
|
+
var SPINNER_LABELS = {
|
|
2031
|
+
"/scan": "Scanning target...",
|
|
2032
|
+
"/status": "Loading account status...",
|
|
2033
|
+
"/scans": "Loading scans...",
|
|
2034
|
+
"/keys": "Loading API keys...",
|
|
2035
|
+
"/config": "Updating configuration...",
|
|
2036
|
+
"/compare": "Comparing scans...",
|
|
2037
|
+
"/whatif": "Running simulation...",
|
|
2038
|
+
"/report": "Fetching report...",
|
|
2039
|
+
"/auth": "Signing in...",
|
|
2040
|
+
"/auth-key": "Validating API key...",
|
|
2041
|
+
"/login": "Signing in...",
|
|
2042
|
+
"/changelog": "Loading changelog...",
|
|
2043
|
+
"/history": "Loading history...",
|
|
2044
|
+
"/palette": "Opening command palette...",
|
|
2045
|
+
"/commands": "Opening command palette..."
|
|
2046
|
+
};
|
|
2047
|
+
function spinnerLabel(cmd2) {
|
|
2048
|
+
return SPINNER_LABELS[cmd2.toLowerCase()] ?? null;
|
|
2049
|
+
}
|
|
2050
|
+
async function executeSlashCommand(cmd2, arg, ctx) {
|
|
2051
|
+
switch (cmd2.toLowerCase()) {
|
|
2052
|
+
case "/help":
|
|
2053
|
+
ctx.sink.write(renderHelpScreen());
|
|
2054
|
+
break;
|
|
2055
|
+
case "/palette":
|
|
2056
|
+
case "/commands": {
|
|
2057
|
+
const picked = await openCommandPalette();
|
|
2058
|
+
if (picked) return dispatchSlashCommand(picked, ctx);
|
|
2059
|
+
break;
|
|
2060
|
+
}
|
|
2061
|
+
case "/clear":
|
|
2062
|
+
return { redraw: true };
|
|
2063
|
+
case "/history": {
|
|
2064
|
+
const hist = await loadHistory();
|
|
2065
|
+
ctx.sink.write(renderHistoryScreen(hist));
|
|
2066
|
+
break;
|
|
2067
|
+
}
|
|
2068
|
+
case "/scan": {
|
|
2069
|
+
if (!arg) {
|
|
2070
|
+
ctx.sink.write(error("Usage: /scan <url>"));
|
|
2071
|
+
break;
|
|
2072
|
+
}
|
|
2073
|
+
const client = await getAuthenticatedClient();
|
|
2074
|
+
const progress = createStreamProgress(ctx.sink);
|
|
2075
|
+
const result = await runScanCommand(client, {
|
|
2076
|
+
url: normalizeScanUrl(arg),
|
|
2077
|
+
wait: true,
|
|
2078
|
+
onProgress: progress.update,
|
|
2079
|
+
streamProgress: true
|
|
2080
|
+
});
|
|
2081
|
+
progress.stop();
|
|
2082
|
+
ctx.sink.write(result.output);
|
|
2083
|
+
break;
|
|
2084
|
+
}
|
|
2085
|
+
case "/status": {
|
|
2086
|
+
const client = await getAuthenticatedClient();
|
|
2087
|
+
const status = await runStatusCommand(client);
|
|
2088
|
+
const rate = await runRateLimitStatus(client);
|
|
2089
|
+
ctx.sink.write(rate ? `${status}
|
|
2090
|
+
|
|
2091
|
+
${muted("Rate limit")} ${rate}` : status);
|
|
2092
|
+
break;
|
|
2093
|
+
}
|
|
2094
|
+
case "/scans":
|
|
2095
|
+
await runScansInteractive(ctx);
|
|
2096
|
+
break;
|
|
2097
|
+
case "/keys":
|
|
2098
|
+
await runKeysInteractive(ctx);
|
|
2099
|
+
break;
|
|
2100
|
+
case "/config": {
|
|
2101
|
+
if (!arg.includes("=")) {
|
|
2102
|
+
ctx.sink.write(error("Usage: /config key=value"));
|
|
2103
|
+
break;
|
|
2104
|
+
}
|
|
2105
|
+
const [k, ...vParts] = arg.split("=");
|
|
2106
|
+
ctx.sink.write(await runConfigSet(k.trim(), vParts.join("=").trim()));
|
|
2107
|
+
break;
|
|
2108
|
+
}
|
|
2109
|
+
case "/changelog": {
|
|
2110
|
+
const raw = await readAsset("changelog.json");
|
|
2111
|
+
ctx.sink.write(renderChangelog(JSON.parse(raw)));
|
|
2112
|
+
break;
|
|
2113
|
+
}
|
|
2114
|
+
case "/compare": {
|
|
2115
|
+
const [a, b] = arg.split(/\s+/);
|
|
2116
|
+
if (!a || !b) {
|
|
2117
|
+
ctx.sink.write(error("Usage: /compare <scan-a> <scan-b>"));
|
|
2118
|
+
break;
|
|
2119
|
+
}
|
|
2120
|
+
const { runCompareCommand: runCompareCommand2 } = await Promise.resolve().then(() => (init_compare(), compare_exports));
|
|
2121
|
+
ctx.sink.write(await runCompareCommand2(a, b, "table"));
|
|
2122
|
+
break;
|
|
2123
|
+
}
|
|
2124
|
+
case "/whatif": {
|
|
2125
|
+
if (!arg) {
|
|
2126
|
+
ctx.sink.write(error("Usage: /whatif <scan-id>"));
|
|
2127
|
+
break;
|
|
2128
|
+
}
|
|
2129
|
+
const { runWhatIfCommand: runWhatIfCommand2 } = await Promise.resolve().then(() => (init_whatif(), whatif_exports));
|
|
2130
|
+
ctx.sink.write(await runWhatIfCommand2(arg, {}, "table"));
|
|
2131
|
+
break;
|
|
2132
|
+
}
|
|
2133
|
+
case "/report": {
|
|
2134
|
+
if (!arg) {
|
|
2135
|
+
ctx.sink.write(error("Usage: /report <scan-id>"));
|
|
2136
|
+
break;
|
|
2137
|
+
}
|
|
2138
|
+
const { runReportCommand: runReportCommand2 } = await Promise.resolve().then(() => (init_report(), report_exports));
|
|
2139
|
+
const client = await getAuthenticatedClient();
|
|
2140
|
+
ctx.sink.write(await runReportCommand2(client, arg, "json"));
|
|
2141
|
+
break;
|
|
2142
|
+
}
|
|
2143
|
+
case "/login":
|
|
2144
|
+
case "/auth": {
|
|
2145
|
+
const result = await runAssistedBrowserLogin();
|
|
2146
|
+
ctx.sink.write(result.message);
|
|
2147
|
+
return { profile: result.profile, redraw: true };
|
|
2148
|
+
}
|
|
2149
|
+
case "/auth-key": {
|
|
2150
|
+
ctx.sink.write(await runApiKeyLogin({ interactive: true }));
|
|
2151
|
+
const client = await getAuthenticatedClient();
|
|
2152
|
+
return { profile: await client.get("/me"), redraw: true };
|
|
2153
|
+
}
|
|
2154
|
+
default:
|
|
2155
|
+
ctx.sink.write(errorBox(`Unknown command: ${cmd2}`, "Type /help"));
|
|
2156
|
+
}
|
|
2157
|
+
return {};
|
|
2158
|
+
}
|
|
1904
2159
|
async function dispatchSlashCommand(line, ctx) {
|
|
1905
2160
|
const trimmed = line.trim();
|
|
1906
2161
|
if (!trimmed) return {};
|
|
@@ -1922,112 +2177,11 @@ ${accent("/help")} lists everything`
|
|
|
1922
2177
|
return {};
|
|
1923
2178
|
}
|
|
1924
2179
|
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"));
|
|
2180
|
+
const label = spinnerLabel(cmd2);
|
|
2181
|
+
if (label) {
|
|
2182
|
+
return await withSpinner(label, () => executeSlashCommand(cmd2, arg, ctx));
|
|
2030
2183
|
}
|
|
2184
|
+
return await executeSlashCommand(cmd2, arg, ctx);
|
|
2031
2185
|
} catch (e) {
|
|
2032
2186
|
ctx.sink.error(formatApiError(e));
|
|
2033
2187
|
}
|
|
@@ -2052,7 +2206,7 @@ async function runScansInteractive(ctx) {
|
|
|
2052
2206
|
while (true) {
|
|
2053
2207
|
const list = await runScansListCommand(client, "table", { limit: 20 });
|
|
2054
2208
|
const data = await client.get("/scans?page=1&page_size=20");
|
|
2055
|
-
const scans = data
|
|
2209
|
+
const scans = asList(data);
|
|
2056
2210
|
if (!scans.length) {
|
|
2057
2211
|
ctx.sink.write(emptyState("No scans yet", "Run /scan <url> to create your first scan", "/scan https://example.com"));
|
|
2058
2212
|
return;
|
|
@@ -2069,10 +2223,12 @@ async function runScansInteractive(ctx) {
|
|
|
2069
2223
|
if (!scanId) return;
|
|
2070
2224
|
const detail = await client.get(`/scan/${scanId}`);
|
|
2071
2225
|
ctx.sink.write(renderScanResult(detail, "table"));
|
|
2072
|
-
const cont = await
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2226
|
+
const cont = await runInteractive(
|
|
2227
|
+
() => input2({
|
|
2228
|
+
message: muted("Enter = back to list \xB7 q = exit"),
|
|
2229
|
+
default: ""
|
|
2230
|
+
})
|
|
2231
|
+
).catch(() => "q");
|
|
2076
2232
|
if (cont.toLowerCase() === "q") return;
|
|
2077
2233
|
}
|
|
2078
2234
|
}
|
|
@@ -2080,14 +2236,16 @@ async function runKeysInteractive(ctx) {
|
|
|
2080
2236
|
const client = await getAuthenticatedClient();
|
|
2081
2237
|
ctx.sink.write(await runKeysListCommand(client));
|
|
2082
2238
|
ctx.sink.write(muted("\nActions: [r] revoke [n] new key [Enter] back"));
|
|
2083
|
-
const action = await input2({ message: "Action", default: "" }).catch(() => "");
|
|
2239
|
+
const action = await runInteractive(() => input2({ message: "Action", default: "" })).catch(() => "");
|
|
2084
2240
|
if (action.toLowerCase() === "r") {
|
|
2085
2241
|
const keyId = await pickKeyForRevoke(client);
|
|
2086
2242
|
if (!keyId) return;
|
|
2087
|
-
const ok = await
|
|
2243
|
+
const ok = await runInteractive(
|
|
2244
|
+
() => confirm2({ message: "Revoke this API key?", default: false })
|
|
2245
|
+
).catch(() => false);
|
|
2088
2246
|
if (ok) ctx.sink.write(successBox(await runKeyRevoke(client, keyId)));
|
|
2089
2247
|
} else if (action.toLowerCase() === "n") {
|
|
2090
|
-
const name = await promptNewKeyName();
|
|
2248
|
+
const name = await runInteractive(() => promptNewKeyName());
|
|
2091
2249
|
const created = await client.post("/keys", { name });
|
|
2092
2250
|
ctx.sink.write(success(`Created: ${created.name} (${created.prefix})`));
|
|
2093
2251
|
ctx.sink.write(accent("Save this key \u2014 it won't be shown again:"));
|
|
@@ -2139,6 +2297,13 @@ async function runPromptLoop(initialProfile) {
|
|
|
2139
2297
|
rl.close();
|
|
2140
2298
|
process.exit(0);
|
|
2141
2299
|
});
|
|
2300
|
+
setReadlineHooks(
|
|
2301
|
+
() => rl.pause(),
|
|
2302
|
+
() => {
|
|
2303
|
+
process.stdout.write("\n");
|
|
2304
|
+
rl.resume();
|
|
2305
|
+
}
|
|
2306
|
+
);
|
|
2142
2307
|
const prompt = () => process.stdout.write(accent("> "));
|
|
2143
2308
|
prompt();
|
|
2144
2309
|
for await (const line of rl) {
|
|
@@ -2176,15 +2341,32 @@ init_theme();
|
|
|
2176
2341
|
init_credentials();
|
|
2177
2342
|
init_api_client();
|
|
2178
2343
|
init_logger();
|
|
2344
|
+
function profileFromCredentials(creds) {
|
|
2345
|
+
return {
|
|
2346
|
+
id: "local",
|
|
2347
|
+
email: creds.email || "user@local",
|
|
2348
|
+
full_name: creds.full_name || null,
|
|
2349
|
+
role: "analyst",
|
|
2350
|
+
organization: "\u2014",
|
|
2351
|
+
key_expires_at: creds.key_expires_at ?? null,
|
|
2352
|
+
key_prefix: creds.key_prefix ?? null
|
|
2353
|
+
};
|
|
2354
|
+
}
|
|
2179
2355
|
async function tryGetProfile() {
|
|
2356
|
+
const creds = await loadCredentials();
|
|
2357
|
+
if (!creds?.api_key) return null;
|
|
2180
2358
|
try {
|
|
2181
|
-
const
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
return await client.get("/me");
|
|
2359
|
+
const client = await FuzziApiClient.create();
|
|
2360
|
+
const profile = await client.get("/me");
|
|
2361
|
+
return profile;
|
|
2185
2362
|
} catch (e) {
|
|
2186
|
-
|
|
2187
|
-
|
|
2363
|
+
if (e instanceof ApiError && e.status === 401) {
|
|
2364
|
+
log.debug("stored credentials invalid, clearing");
|
|
2365
|
+
await clearCredentials();
|
|
2366
|
+
return null;
|
|
2367
|
+
}
|
|
2368
|
+
log.debug("profile bootstrap failed, using cached credentials", e);
|
|
2369
|
+
return profileFromCredentials(creds);
|
|
2188
2370
|
}
|
|
2189
2371
|
}
|
|
2190
2372
|
|
|
@@ -2226,6 +2408,7 @@ async function runInteractiveMode() {
|
|
|
2226
2408
|
}
|
|
2227
2409
|
|
|
2228
2410
|
// src/index.ts
|
|
2411
|
+
configurePlatform();
|
|
2229
2412
|
async function main(argv) {
|
|
2230
2413
|
if (argv.length <= 2) {
|
|
2231
2414
|
await runInteractiveMode();
|