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/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.4";
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, { recursive: true, mode: 448 });
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(), { recursive: true, mode: 448 });
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?.toLowerCase();
225
- const msg = body.error || body.message || "";
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 = `Scan failed: ${errBody.error || errBody.message || res.statusText}`;
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 trueColor = colorterm.includes("truecolor") || colorterm.includes("24bit") || term.includes("truecolor") || !!process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0";
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 stdout2 } from "process";
566
+ import { stdout as stdout3 } from "process";
528
567
  function terminalWidth() {
529
568
  resetCapabilities();
530
- return Math.max(64, (stdout2.columns ?? 80) - 2);
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
- import { exec } from "child_process";
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
- const cmd2 = platform === "darwin" ? `open ${JSON.stringify(url)}` : platform === "win32" ? `start "" ${JSON.stringify(url)}` : `xdg-open ${JSON.stringify(url)}`;
704
- exec(cmd2, (err) => {
705
- if (err) log.warn("could not open browser automatically", err.message);
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 password({
757
- message: "Paste your API key (fz_live_...):",
758
- mask: "\u2022",
759
- validate: (v) => {
760
- if (!v.trim()) return "API key is required";
761
- if (!isValidApiKeyFormat(v)) return "Key must start with fz_live_";
762
- return true;
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({ text: label, color: "cyan", discardStdin: false }).start();
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.results || [], format);
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/lib dist/../assets = package/assets
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(join4(assetsDir(), name), "utf8");
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) => padEndVisible(c.lines[i] ?? "", widths[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") + padEndVisible(l, inner) + 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") + padEndVisible(text, inner) + border("\u2502"), bottom].join("\n");
1660
+ return [top, border("\u2502") + padCell(text, inner + 2) + border("\u2502"), bottom].join("\n");
1511
1661
  }
1512
- function besideMark(mark, text, markCol = 14, gap = 2) {
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.results || [];
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?.join(", ") || muted("\u2014"),
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.results || []).filter((k) => !k.revoked && k.active);
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(), { recursive: true, mode: 448 });
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
- switch (cmd2.toLowerCase()) {
1926
- case "/help":
1927
- ctx.sink.write(renderHelpScreen());
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.results || [];
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 input2({
2073
- message: muted("Enter = back to list \xB7 q = exit"),
2074
- default: ""
2075
- }).catch(() => "q");
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 confirm2({ message: "Revoke this API key?", default: false }).catch(() => false);
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 creds = await loadCredentials();
2182
- if (!creds?.api_key) return null;
2183
- const client = await getAuthenticatedClient();
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
- log.debug("profile bootstrap failed", e);
2187
- return null;
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();