fuzzi-cli 0.1.3 → 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/dist/index.js CHANGED
@@ -16,18 +16,26 @@ var init_brand = __esm({
16
16
  BRAND = {
17
17
  accent: "#4FC3A1",
18
18
  accentDim: "#3A9A7E",
19
- text: "#FAFAFA",
20
- textSecondary: "#8C8C8C",
21
- bg: "#0A0A0A",
22
- border: "#2A2A2A"
19
+ bg: "#0A0C10",
20
+ surface: "#12151B",
21
+ surfaceHover: "#181C24",
22
+ borderSubtle: "#232832",
23
+ borderStrong: "#313846",
24
+ textPrimary: "#E8EAED",
25
+ textSecondary: "#9AA3B2",
26
+ textTertiary: "#5C6470",
27
+ success: "#22C55E",
28
+ warning: "#F59E0B",
29
+ danger: "#EF4444",
30
+ critical: "#A855F7"
23
31
  };
24
32
  RISK_COLORS = {
25
- LOW: "#22C55E",
26
- MEDIUM: "#F59E0B",
27
- HIGH: "#EF4444",
28
- CRITICAL: "#A855F7"
33
+ LOW: BRAND.success,
34
+ MEDIUM: BRAND.warning,
35
+ HIGH: BRAND.danger,
36
+ CRITICAL: BRAND.critical
29
37
  };
30
- VERSION = "0.1.3";
38
+ VERSION = "0.1.5";
31
39
  APP_ORIGIN = "https://fuzzi-ten.vercel.app";
32
40
  DEFAULT_API_URL = `${APP_ORIGIN}/api`;
33
41
  SETTINGS_API_KEYS_URL = `${APP_ORIGIN}/settings/api-keys`;
@@ -189,9 +197,6 @@ function shouldLog(level) {
189
197
  function stamp() {
190
198
  return (/* @__PURE__ */ new Date()).toISOString();
191
199
  }
192
- function isDebugMode() {
193
- return currentLevel() === "debug";
194
- }
195
200
  var LEVELS, log;
196
201
  var init_logger = __esm({
197
202
  "src/lib/logger.ts"() {
@@ -214,10 +219,40 @@ var init_logger = __esm({
214
219
  }
215
220
  });
216
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
+
217
252
  // src/lib/api-client.ts
218
253
  function mapErrorMessage(status, body) {
219
- const code = body.code?.toLowerCase();
220
- const msg = body.error || body.message || "";
254
+ const code = typeof body.code === "string" ? body.code.toLowerCase() : "";
255
+ const msg = errorText(body.error ?? body.message ?? body.detail);
221
256
  if (status === 401) {
222
257
  if (code === "key_revoked" || msg.toLowerCase().includes("revoked")) {
223
258
  return "API key has been revoked. Please log in again.";
@@ -225,7 +260,7 @@ function mapErrorMessage(status, body) {
225
260
  if (code === "key_expired" || msg.toLowerCase().includes("expired")) {
226
261
  return `API key has expired. Generate a new one at ${SETTINGS_API_KEYS_URL}`;
227
262
  }
228
- 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}`;
229
264
  }
230
265
  if (status === 403 && (code === "ssrf" || msg.toLowerCase().includes("private ip"))) {
231
266
  return "This URL is not allowed (private IP address detected). Please scan a public-facing URL.";
@@ -257,6 +292,7 @@ var init_api_client = __esm({
257
292
  init_credentials();
258
293
  init_logger();
259
294
  init_brand();
295
+ init_api_utils();
260
296
  ApiError = class extends Error {
261
297
  constructor(message, status, code, body, exitCode) {
262
298
  super(message);
@@ -329,7 +365,7 @@ var init_api_client = __esm({
329
365
  message = `Rate limit exceeded. Retry after ${seconds} seconds.`;
330
366
  }
331
367
  if (res.status >= 500) {
332
- message = `Scan failed: ${errBody.error || errBody.message || res.statusText}`;
368
+ message = `Request failed: ${errorText(errBody.error ?? errBody.message ?? errBody.detail) || res.statusText}`;
333
369
  }
334
370
  throw new ApiError(message, res.status, errBody.code, data, 2);
335
371
  }
@@ -395,7 +431,7 @@ function getCapabilities() {
395
431
  const colorterm = process.env.COLORTERM ?? "";
396
432
  const trueColor = colorterm.includes("truecolor") || colorterm.includes("24bit") || term.includes("truecolor") || !!process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0";
397
433
  cached = {
398
- width: Math.max(60, Math.min(cols, 120)),
434
+ width: Math.max(60, cols),
399
435
  trueColor,
400
436
  interactive: stdout.isTTY === true
401
437
  };
@@ -434,18 +470,18 @@ function scoreBold(n) {
434
470
  return chalk.bold(String(n));
435
471
  }
436
472
  function error(text) {
437
- return color("#EF4444", chalk.red)(text);
473
+ return color(BRAND.danger, chalk.red)(text);
438
474
  }
439
475
  function success(text) {
440
- return color("#22C55E", chalk.green)(text);
476
+ return color(BRAND.success, chalk.green)(text);
441
477
  }
442
478
  function warn(text) {
443
- return color("#F59E0B", chalk.yellow)(text);
479
+ return color(BRAND.warning, chalk.yellow)(text);
444
480
  }
445
- function info(text) {
446
- return color(BRAND.accent, chalk.cyan)(text);
481
+ function cmd(text) {
482
+ return accent(text);
447
483
  }
448
- var accent, accentBold, muted, bold, dim, italic;
484
+ var accent, accentBold, primary, primaryBold, muted, dimText, border, bold, dim, italic;
449
485
  var init_theme = __esm({
450
486
  "src/terminal/theme.ts"() {
451
487
  "use strict";
@@ -453,7 +489,11 @@ var init_theme = __esm({
453
489
  init_capabilities();
454
490
  accent = color(BRAND.accent, chalk.cyan);
455
491
  accentBold = accent.bold;
492
+ primary = color(BRAND.textPrimary, chalk.white);
493
+ primaryBold = primary.bold;
456
494
  muted = color(BRAND.textSecondary, chalk.gray);
495
+ dimText = color(BRAND.textTertiary, chalk.dim);
496
+ border = color(BRAND.borderSubtle, chalk.gray);
457
497
  bold = chalk.bold;
458
498
  dim = chalk.dim;
459
499
  italic = chalk.italic;
@@ -544,33 +584,6 @@ function panel(content, opts = {}) {
544
584
  width
545
585
  });
546
586
  }
547
- function centerInColumn(text, colWidth) {
548
- return text.split("\n").map((line) => {
549
- const plain = line.replace(/\x1b\[[0-9;]*m/g, "");
550
- const pad = Math.max(0, Math.floor((colWidth - plain.length) / 2));
551
- return " ".repeat(pad) + line;
552
- }).join("\n");
553
- }
554
- function splitHomePanel(opts) {
555
- const total = contentWidth();
556
- const leftW = Math.max(28, Math.floor(total * (opts.leftRatio ?? 0.34)));
557
- const rightW = total - leftW - 3;
558
- const leftLines = opts.left.split("\n");
559
- const rightTop = opts.rightTop.split("\n");
560
- const rightDiv = dim("\u2500".repeat(Math.max(10, rightW)));
561
- const rightBottom = opts.rightBottom.split("\n");
562
- const rightLines = [...rightTop, "", rightDiv, "", ...rightBottom];
563
- const rows = Math.max(leftLines.length, rightLines.length);
564
- const sep = dim("\u2502");
565
- const body = [""];
566
- for (let i = 0; i < rows; i++) {
567
- const l = padEndVisible(leftLines[i] ?? "", leftW);
568
- const r = rightLines[i] ?? "";
569
- body.push(`${l} ${sep} ${r}`);
570
- }
571
- body.push("");
572
- return panel(body.join("\n"), { title: opts.title, marginBottom: 0, borderStyle: "classic" });
573
- }
574
587
  function columns(left, right, leftWidth) {
575
588
  const total = contentWidth();
576
589
  const split = leftWidth ?? Math.floor(total * 0.48);
@@ -589,9 +602,6 @@ function divider(char = "\u2500", width) {
589
602
  const w = width ?? contentWidth();
590
603
  return dim(char.repeat(Math.max(20, w)));
591
604
  }
592
- function statusBar(parts) {
593
- return dim(parts.filter(Boolean).join(" \xB7 "));
594
- }
595
605
  function keyValue(rows, indent = 2) {
596
606
  const pad = " ".repeat(indent);
597
607
  const maxKey = Math.max(...rows.map(([k]) => k.length), 4);
@@ -719,103 +729,96 @@ init_logger();
719
729
  import { randomBytes } from "crypto";
720
730
  import { createServer } from "http";
721
731
  import { exec } from "child_process";
722
- var TIMEOUT_MS = 5 * 60 * 1e3;
723
- function generateState() {
724
- return randomBytes(24).toString("base64url");
725
- }
726
732
  function openBrowser(url) {
727
733
  const platform = process.platform;
728
- const cmd = platform === "darwin" ? `open ${JSON.stringify(url)}` : platform === "win32" ? `start "" ${JSON.stringify(url)}` : `xdg-open ${JSON.stringify(url)}`;
729
- exec(cmd, (err) => {
734
+ const cmd2 = platform === "darwin" ? `open ${JSON.stringify(url)}` : platform === "win32" ? `start "" ${JSON.stringify(url)}` : `xdg-open ${JSON.stringify(url)}`;
735
+ exec(cmd2, (err) => {
730
736
  if (err) log.warn("could not open browser automatically", err.message);
731
737
  });
732
738
  }
733
739
  function apiOrigin(apiUrl) {
734
740
  return apiUrl.replace(/\/api\/?$/, "") || APP_ORIGIN;
735
741
  }
736
- async function runBrowserLogin() {
742
+ async function openCliAuthPage() {
737
743
  const config = await loadConfig();
738
- const state = generateState();
739
- const handoffToken = await new Promise((resolve, reject) => {
740
- const server = createServer((req, res) => {
741
- try {
742
- const addr = server.address();
743
- const port = typeof addr === "object" && addr ? addr.port : 0;
744
- const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
745
- if (url.pathname !== "/callback") {
746
- res.writeHead(404);
747
- res.end();
748
- return;
749
- }
750
- const token = url.searchParams.get("token");
751
- const returnedState = url.searchParams.get("state");
752
- if (!token || returnedState !== state) {
753
- res.writeHead(400);
754
- res.end("Invalid callback");
755
- reject(new ApiError("Invalid sign-in callback.", 400, "invalid_callback", void 0, 2));
756
- return;
744
+ const url = `${apiOrigin(config.api_url)}/cli-auth`;
745
+ openBrowser(url);
746
+ log.debug("opened cli auth page", url);
747
+ return url;
748
+ }
749
+
750
+ // src/commands/auth.ts
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
+ );
757
792
  }
758
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
759
- res.end(`<!DOCTYPE html><html><body style="font-family:system-ui;text-align:center;padding:48px">
760
- <h1>Signed in to Fuzzi CLI</h1><p>Return to your terminal.</p>
761
- <script>setTimeout(()=>window.close(),1200)</script></body></html>`);
762
- server.close();
763
- resolve(token);
764
- } catch (e) {
765
- server.close();
766
- reject(e);
767
- }
768
- });
769
- const timer = setTimeout(() => {
770
- server.close();
771
- reject(new ApiError("Sign-in timed out after 5 minutes.", 408, "auth_timeout", void 0, 2));
772
- }, TIMEOUT_MS);
773
- server.listen(0, "127.0.0.1", () => {
774
- const addr = server.address();
775
- const port = typeof addr === "object" && addr ? addr.port : 0;
776
- const loginUrl = `${apiOrigin(config.api_url)}/cli-auth?state=${encodeURIComponent(state)}&callback_port=${port}`;
777
- openBrowser(loginUrl);
778
- log.debug("browser auth", loginUrl);
779
- });
780
- server.on("error", (e) => {
781
- clearTimeout(timer);
782
- reject(e);
783
- });
784
- });
785
- const client = new FuzziApiClient(config.api_url);
786
- const handoff = await client.post("/cli/handoff", {
787
- handoff_token: handoffToken,
788
- state
789
- });
790
- if (!handoff.api_key) {
791
- throw new ApiError("Sign-in failed: no API key returned.", 500, "handoff_failed", void 0, 2);
793
+ })
794
+ );
795
+ } catch {
796
+ return null;
792
797
  }
793
- client.setToken(handoff.api_key);
794
- const profile = await client.get("/me");
795
- await saveCredentials({
796
- api_key: handoff.api_key,
797
- auth_method: "api_key",
798
- key_prefix: handoff.prefix || profile.key_prefix || maskApiKey(handoff.api_key),
799
- key_expires_at: handoff.expires_at || profile.key_expires_at || void 0,
800
- email: profile.email,
801
- full_name: profile.full_name || void 0,
802
- saved_at: (/* @__PURE__ */ new Date()).toISOString()
803
- });
804
- const name = profile.full_name || profile.email;
805
- return { message: `Signed in as ${name}`, profile };
798
+ }
799
+ function formatChoice(name, description) {
800
+ return `${accent(name)}${muted(" \u2014 " + description)}`;
806
801
  }
807
802
 
808
803
  // src/commands/auth.ts
809
- init_brand();
804
+ async function runAssistedBrowserLogin() {
805
+ await openCliAuthPage();
806
+ console.log("");
807
+ console.log(accent(" Browser opened \u2014 authorize Fuzzi CLI on the web page."));
808
+ console.log(muted(" Copy the API key shown, then paste it below."));
809
+ console.log("");
810
+ const msg = await runApiKeyLogin({ interactive: true });
811
+ const client = await getAuthenticatedClient();
812
+ const profile = await client.get("/me");
813
+ return { message: msg, profile };
814
+ }
810
815
  async function runAuthLogin(opts = {}) {
811
- if (opts.browser || opts.interactive !== false && !opts.apiKey && !opts.apiKeyOnly) {
812
- try {
813
- const result = await runBrowserLogin();
814
- return success(result.message);
815
- } catch (e) {
816
- if (opts.browser) throw e;
817
- if (opts.apiKeyOnly) throw e;
818
- }
816
+ if (opts.apiKeyOnly || opts.apiKey) {
817
+ return runApiKeyLogin(opts);
818
+ }
819
+ if (opts.browser || opts.interactive !== false) {
820
+ const result = await runAssistedBrowserLogin();
821
+ return result.message;
819
822
  }
820
823
  return runApiKeyLogin(opts);
821
824
  }
@@ -833,15 +836,17 @@ async function runApiKeyLogin(opts = {}) {
833
836
  2
834
837
  );
835
838
  }
836
- apiKey = await password({
837
- message: "Paste your API key (fz_live_...):",
838
- mask: "\u2022",
839
- validate: (v) => {
840
- if (!v.trim()) return "API key is required";
841
- if (!isValidApiKeyFormat(v)) return "Key must start with fz_live_";
842
- return true;
843
- }
844
- });
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
+ );
845
850
  }
846
851
  apiKey = apiKey.trim();
847
852
  if (!isValidApiKeyFormat(apiKey)) {
@@ -1167,6 +1172,17 @@ function createProgress(label, stream = false) {
1167
1172
  }
1168
1173
  };
1169
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
+ }
1170
1186
 
1171
1187
  // src/commands/scan.ts
1172
1188
  init_strings();
@@ -1266,6 +1282,7 @@ async function runScanCommand(client, opts) {
1266
1282
  }
1267
1283
 
1268
1284
  // src/commands/scans.ts
1285
+ init_api_utils();
1269
1286
  async function runScansListCommand(client, format = "table", filters) {
1270
1287
  const params = new URLSearchParams({ page: "1", page_size: String(filters?.limit || 20) });
1271
1288
  if (filters?.status) params.set("status", filters.status);
@@ -1273,7 +1290,7 @@ async function runScansListCommand(client, format = "table", filters) {
1273
1290
  const data = await client.get(
1274
1291
  `/scans?${params.toString()}`
1275
1292
  );
1276
- return renderScansList(data.results || [], format);
1293
+ return renderScansList(asList(data), format);
1277
1294
  }
1278
1295
  async function runScanGetCommand(client, scanId, format = "table") {
1279
1296
  const detail = await client.get(`/scan/${scanId}`);
@@ -1480,33 +1497,7 @@ function buildProgram() {
1480
1497
 
1481
1498
  // src/shell/prompt-loop.ts
1482
1499
  import * as readline from "readline/promises";
1483
- import { stdin as input3, stdout as output } from "process";
1484
-
1485
- // src/shell/home-screen.ts
1486
- import { homedir as homedir3 } from "os";
1487
-
1488
- // src/shell/ascii-mark.ts
1489
- function renderFuzziMark() {
1490
- return [
1491
- " \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
1492
- " \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
1493
- " \u2588\u2588 \u2588\u2588",
1494
- " \u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588",
1495
- " \u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588",
1496
- " \u2588\u2588 \u2588\u2588\u2588\u2588 \u2588\u2588",
1497
- " \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
1498
- " \u2588\u2588 \u2588\u2588",
1499
- " \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
1500
- " \u2588\u2588 \u2588\u2588",
1501
- " \u2588\u2588 \u2588\u2588"
1502
- ].join("\n");
1503
- }
1504
-
1505
- // src/shell/home-screen.ts
1506
- init_brand();
1507
- init_theme();
1508
- init_layout();
1509
- init_width();
1500
+ import { stdin as input3, stdout as output, cwd as cwd3 } from "process";
1510
1501
 
1511
1502
  // src/lib/assets.ts
1512
1503
  import { readFile as readFile4 } from "fs/promises";
@@ -1533,130 +1524,265 @@ async function readAsset(name) {
1533
1524
  }
1534
1525
 
1535
1526
  // src/shell/home-screen.ts
1536
- async function fetchHomeData(profile, cwd4) {
1527
+ init_api_client();
1528
+
1529
+ // src/shell/ascii-mark.ts
1530
+ function renderFuzziMark() {
1531
+ return [
1532
+ " \u2554\u2550\u2550\u2550\u2550\u2557",
1533
+ " \u2551 \u25C6 \u2551",
1534
+ " \u255A\u2550\u2566\u2550\u2550\u255D",
1535
+ " \u2554\u2550\u2569\u2550\u2557",
1536
+ " \u2551 \u25C6 \u2551",
1537
+ " \u255A\u2550\u2550\u2550\u255D"
1538
+ ].join("\n");
1539
+ }
1540
+
1541
+ // src/shell/home-screen.ts
1542
+ init_brand();
1543
+ init_theme();
1544
+ init_width();
1545
+
1546
+ // src/components/Panel.ts
1547
+ init_theme();
1548
+ init_strings();
1549
+ init_width();
1550
+ function visibleLen(s) {
1551
+ return s.replace(/\x1b\[[0-9;]*m/g, "").length;
1552
+ }
1553
+ function padCell(content, width) {
1554
+ const inner = width - 2;
1555
+ return " " + padEndVisible(content, inner) + " ";
1556
+ }
1557
+ function topBanner(title, width = contentWidth()) {
1558
+ const inner = ` ${title} `;
1559
+ const dashes = Math.max(0, width - 2 - visibleLen(inner));
1560
+ const left = Math.floor(dashes / 2);
1561
+ const right = dashes - left;
1562
+ return [
1563
+ border(`\u250F${"\u2501".repeat(left)}${inner}${"\u2501".repeat(right)}\u2513`),
1564
+ border(`\u2517${"\u2501".repeat(width - 2)}\u251B`)
1565
+ ].join("\n");
1566
+ }
1567
+ function titleSegment(title, width) {
1568
+ const prefix = `\u2500 ${title} `;
1569
+ const dashes = Math.max(0, width - visibleLen(prefix));
1570
+ return prefix + "\u2500".repeat(dashes);
1571
+ }
1572
+ function tripleColumnPanel(cols, totalWidth = contentWidth()) {
1573
+ const sep = 1;
1574
+ const inner = totalWidth - 2;
1575
+ const ratios = [0.44, 0.28, 0.28];
1576
+ const raw = ratios.map((r) => Math.floor(inner * r));
1577
+ const used = raw.reduce((a, b) => a + b, 0) + 2 * sep;
1578
+ raw[0] += inner - used;
1579
+ const widths = raw;
1580
+ const top = border("\u250C") + titleSegment(cols[0].title, widths[0]) + border("\u252C") + titleSegment(cols[1].title, widths[1]) + border("\u252C") + titleSegment(cols[2].title, widths[2]) + border("\u2510");
1581
+ const maxRows = Math.max(...cols.map((c) => c.lines.length), 1);
1582
+ const body = [top];
1583
+ for (let i = 0; i < maxRows; i++) {
1584
+ const cells = cols.map((c, idx) => padCell(c.lines[i] ?? "", widths[idx]));
1585
+ body.push(
1586
+ border("\u2502") + cells[0] + border("\u2502") + cells[1] + border("\u2502") + cells[2] + border("\u2502")
1587
+ );
1588
+ }
1589
+ const bottom = border("\u2514") + border("\u2500".repeat(widths[0])) + border("\u2534") + border("\u2500".repeat(widths[1])) + border("\u2534") + border("\u2500".repeat(widths[2])) + border("\u2518");
1590
+ body.push(bottom);
1591
+ return body.join("\n");
1592
+ }
1593
+ function stackedPanels(cols, width = contentWidth()) {
1594
+ return cols.map((col) => singlePanel(col.title, col.lines, width)).join("\n\n");
1595
+ }
1596
+ function singlePanel(title, lines, width = contentWidth()) {
1597
+ const inner = width - 2;
1598
+ const prefix = `\u2500 ${title} `;
1599
+ const dashes = Math.max(0, inner - visibleLen(prefix));
1600
+ const top = border(`\u250C${prefix}${"\u2500".repeat(dashes)}\u2510`);
1601
+ const bottom = border(`\u2514${"\u2500".repeat(inner)}\u2518`);
1602
+ const body = lines.map((l) => border("\u2502") + padCell(l, inner + 2) + border("\u2502"));
1603
+ return [top, ...body, bottom].join("\n");
1604
+ }
1605
+ function tipPanel(text, width = contentWidth()) {
1606
+ const inner = width - 2;
1607
+ const prefix = "\u2500 Tip ";
1608
+ const dashes = Math.max(0, inner - visibleLen(prefix));
1609
+ const top = border(`\u250C${prefix}${"\u2500".repeat(dashes)}\u2510`);
1610
+ const bottom = border(`\u2514${"\u2500".repeat(inner)}\u2518`);
1611
+ return [top, border("\u2502") + padCell(text, inner + 2) + border("\u2502"), bottom].join("\n");
1612
+ }
1613
+ function besideMark(mark, text, markCol = 18, gap = 3) {
1614
+ const rows = Math.max(mark.length, text.length);
1615
+ const out = [];
1616
+ for (let i = 0; i < rows; i++) {
1617
+ const m = padEndVisible(mark[i] ?? "", markCol);
1618
+ const t = text[i] ?? "";
1619
+ out.push(m + " ".repeat(gap) + t);
1620
+ }
1621
+ return out;
1622
+ }
1623
+ function popularCommand(command, desc, cmdWidth = 14) {
1624
+ return padEndVisible(cmd(command), cmdWidth) + muted(desc);
1625
+ }
1626
+
1627
+ // src/shell/home-screen.ts
1628
+ function daysUntil2(dateStr) {
1629
+ const diff = new Date(dateStr).getTime() - Date.now();
1630
+ return Math.max(0, Math.ceil(diff / (1e3 * 60 * 60 * 24)));
1631
+ }
1632
+ function formatKeyExpiry(dateStr) {
1633
+ if (!dateStr) return null;
1634
+ const days = daysUntil2(dateStr);
1635
+ return `Key expires: ${days} days remaining`;
1636
+ }
1637
+ function formatKeyExpiryDate(dateStr) {
1638
+ if (!dateStr) return null;
1639
+ return `(${dateStr.slice(0, 10)})`;
1640
+ }
1641
+ async function fetchHomeData(profile) {
1537
1642
  let changelog = [];
1538
1643
  try {
1539
1644
  changelog = JSON.parse(await readAsset("changelog.json"));
1540
1645
  } catch {
1541
1646
  changelog = [];
1542
1647
  }
1543
- return { profile, cwd: cwd4, changelog };
1544
- }
1545
- function isHomeDir(dir) {
1546
- return dir === homedir3() || dir === homedir3().replace(/\/$/, "");
1648
+ let stats = null;
1649
+ if (profile) {
1650
+ try {
1651
+ const client = await getAuthenticatedClient();
1652
+ const rateRaw = await runRateLimitStatus(client);
1653
+ let rateHour = null;
1654
+ if (rateRaw) {
1655
+ const m = rateRaw.match(/(\d+)\/(\d+)/);
1656
+ if (m) {
1657
+ const remaining = Number(m[1]);
1658
+ const limit2 = Number(m[2]);
1659
+ const used2 = limit2 - remaining;
1660
+ rateHour = `${used2}/${limit2} scans this hour`;
1661
+ } else {
1662
+ rateHour = rateRaw;
1663
+ }
1664
+ }
1665
+ const used = profile.scans_used_this_month ?? profile.total_scans;
1666
+ const limit = profile.monthly_scan_limit;
1667
+ const usageMonth = used != null && limit != null ? `${used}/${limit} scans this month` : used != null ? `${used} scans total` : null;
1668
+ stats = { rateHour, usageMonth };
1669
+ } catch {
1670
+ stats = null;
1671
+ }
1672
+ }
1673
+ return { profile, changelog, stats };
1547
1674
  }
1548
- function renderLeftColumn(data) {
1549
- const colW = Math.max(28, Math.floor(contentWidth() * 0.34));
1550
- const name = data.profile?.full_name || data.profile?.email?.split("@")[0] || "there";
1551
- const org = data.profile?.organization?.trim();
1552
- const mark = centerInColumn(accent(renderFuzziMark()), colW);
1553
- const lines = [];
1675
+ function renderAccountColumn(data) {
1676
+ const mark = accent(renderFuzziMark()).split("\n");
1554
1677
  if (data.profile) {
1555
- lines.push(
1556
- accentBold(`Welcome back ${name}!`),
1557
- "",
1558
- mark,
1559
- "",
1560
- [accent("\u25CF Connected"), muted("\xB7"), info("API Key auth"), org ? muted("\xB7 " + org) : ""].filter(Boolean).join(" "),
1561
- muted(data.profile.email),
1562
- data.profile.role ? muted(`Role: ${data.profile.role}`) : "",
1563
- "",
1564
- muted(data.cwd),
1565
- "",
1566
- accent("/scan") + muted(" <url> scan a target"),
1567
- accent("/scans") + muted(" browse history"),
1568
- accent("/status") + muted(" account info"),
1569
- accent("/keys") + muted(" manage keys"),
1570
- accent("/palette") + muted(" find commands")
1571
- );
1572
- } else {
1573
- lines.push(
1574
- accentBold("Welcome to Fuzzi!"),
1678
+ const p = data.profile;
1679
+ const name = p.full_name || p.email.split("@")[0];
1680
+ const keyExpiry = formatKeyExpiry(p.key_expires_at ?? void 0);
1681
+ const keyDate = formatKeyExpiryDate(p.key_expires_at ?? void 0);
1682
+ const textBlock2 = [
1683
+ primaryBold(`Welcome back, ${name}!`),
1684
+ primary(p.email),
1575
1685
  "",
1576
- mark,
1577
- "",
1578
- muted("Not connected"),
1579
- info("Press Enter to sign in"),
1580
- "",
1581
- muted(data.cwd),
1582
- "",
1583
- accent("/auth") + muted(" browser sign-in"),
1584
- accent("/auth-key") + muted(" paste API key"),
1585
- accent("/scan") + muted(" <url> after login"),
1586
- accent("/help") + muted(" all commands")
1587
- );
1686
+ muted(`Organization: ${p.organization || "\u2014"}`),
1687
+ muted(`Role: ${p.role}`)
1688
+ ];
1689
+ const beside = besideMark(mark, textBlock2, 14, 4);
1690
+ const lines = ["", ...beside, ""];
1691
+ lines.push(success("\u25CF Connected"));
1692
+ lines.push(muted("Status: Ready"));
1693
+ if (data.stats?.rateHour) lines.push(muted(`Rate limit: ${data.stats.rateHour}`));
1694
+ if (data.stats?.usageMonth) lines.push(muted(`Usage: ${data.stats.usageMonth}`));
1695
+ if (keyExpiry) {
1696
+ lines.push("");
1697
+ lines.push(muted(keyExpiry));
1698
+ if (keyDate) lines.push(muted(keyDate));
1699
+ }
1700
+ return lines;
1588
1701
  }
1589
- return lines.filter((l) => l !== "").join("\n");
1702
+ const textBlock = [
1703
+ primaryBold("Welcome to Fuzzi!"),
1704
+ muted("Not connected"),
1705
+ "",
1706
+ muted("Press Enter to sign in"),
1707
+ muted("or run /auth-key")
1708
+ ];
1709
+ return ["", ...besideMark(mark, textBlock, 14, 4), ""];
1590
1710
  }
1591
- function renderTipsColumn(data) {
1711
+ function renderQuickStartColumn(data) {
1592
1712
  const lines = [
1593
- accentBold("Tips for getting started"),
1594
1713
  "",
1595
- `Run ${accent("/scan")}${muted(" <url>")} to scan a site for security risks`,
1596
- `Run ${accent("/palette")} to search every available command`,
1597
- `Run ${accent("/help")} for the full command reference`,
1598
- ""
1714
+ muted("Type ") + cmd("/help") + muted(" for all"),
1715
+ muted("commands."),
1716
+ "",
1717
+ primaryBold("Popular commands:"),
1718
+ "",
1719
+ popularCommand("/scan", "Start a security scan", 12),
1720
+ popularCommand("/scans", "Browse recent scans", 12),
1721
+ popularCommand("/status", "Show account info", 12)
1599
1722
  ];
1600
- if (!data.profile) {
1601
- lines.push(
1602
- muted("Note: You launched without credentials."),
1603
- muted("Press Enter at the prompt to open your browser,"),
1604
- muted("or use /auth-key to paste an API key."),
1605
- ""
1606
- );
1607
- } else if (isHomeDir(data.cwd)) {
1723
+ if (data.profile) {
1608
1724
  lines.push(
1609
- muted("Note: You launched fuzzi in your home directory."),
1610
- muted("cd into a project folder first for better context,"),
1611
- muted("or pass URLs directly: /scan https://example.com"),
1612
- ""
1725
+ popularCommand("/keys", "Manage API keys", 12),
1726
+ popularCommand("/config", "CLI settings", 12)
1613
1727
  );
1614
1728
  } else {
1615
1729
  lines.push(
1616
- muted("Note: Add a .fuzzirc in this directory to set default"),
1617
- muted("scan URL, environment, and output format for the team."),
1618
- ""
1730
+ popularCommand("/auth", "Browser sign-in", 12),
1731
+ popularCommand("/auth-key", "Paste API key", 12)
1619
1732
  );
1620
1733
  }
1621
- lines.push(
1622
- muted("CI usage: "),
1623
- muted("fuzzi scan <url> --fail-on critical --format json")
1624
- );
1625
- return lines.join("\n");
1734
+ return lines;
1626
1735
  }
1627
1736
  function renderWhatsNewColumn(data) {
1628
- const latest = data.changelog[0];
1629
- const lines = [accentBold("What's new"), ""];
1630
- if (latest) {
1631
- for (const h of latest.highlights.slice(0, 4)) {
1632
- lines.push(muted(h));
1633
- }
1634
- lines.push("");
1635
- lines.push(italic(muted("/changelog for more")));
1636
- } else {
1637
- lines.push(muted("Stay tuned for updates."));
1737
+ const lines = [""];
1738
+ const highlights = data.changelog[0]?.highlights ?? [
1739
+ "Confidence gating added",
1740
+ "Netflix-style false positives fixed",
1741
+ "CLI shell interface"
1742
+ ];
1743
+ for (const h of highlights.slice(0, 4)) {
1744
+ const text = h.replace(/^✓\s*/, "");
1745
+ lines.push(success("\u2713 ") + primary(text));
1638
1746
  }
1639
- return lines.join("\n");
1747
+ lines.push("");
1748
+ lines.push(muted("Run ") + cmd("/changelog") + muted(" for more details."));
1749
+ return lines;
1750
+ }
1751
+ function renderTip(data) {
1752
+ if (data.profile) {
1753
+ return muted("Tip: Run ") + cmd("/scan <url>") + muted(" to scan a target, or ") + cmd("/palette") + muted(" to find commands.");
1754
+ }
1755
+ return muted("Not logged in? Run ") + cmd("/auth-key") + muted(" to paste an API key from settings, or press Enter to sign in via browser.");
1640
1756
  }
1641
1757
  function renderHomeScreen(data) {
1642
- return splitHomePanel({
1643
- title: `Fuzzi CLI v${VERSION}`,
1644
- left: renderLeftColumn(data),
1645
- rightTop: renderTipsColumn(data),
1646
- rightBottom: renderWhatsNewColumn(data),
1647
- leftRatio: 0.36
1648
- });
1758
+ const width = contentWidth();
1759
+ const cols = [
1760
+ { title: "Account", lines: renderAccountColumn(data) },
1761
+ { title: "Quick Start", lines: renderQuickStartColumn(data) },
1762
+ { title: "What's New", lines: renderWhatsNewColumn(data) }
1763
+ ];
1764
+ const main2 = terminalWidth() >= 100 ? tripleColumnPanel(cols, width) : stackedPanels(cols, width);
1765
+ return [topBanner(`Fuzzi CLI v${VERSION}`, width), "", main2, "", tipPanel(renderTip(data), width)].join(
1766
+ "\n"
1767
+ );
1649
1768
  }
1650
1769
  function renderChangelog(entries) {
1651
1770
  if (!entries.length) return muted("No changelog entries.");
1652
1771
  return entries.map((e) => {
1653
1772
  const lines = [
1654
1773
  accentBold(`v${e.version}`) + muted(` \u2014 ${e.date}`),
1655
- ...e.highlights.map((h) => muted(` \xB7 ${h}`))
1774
+ ...e.highlights.map((h) => success("\u2713 ") + primary(h.replace(/^✓\s*/, "")))
1656
1775
  ];
1657
1776
  return lines.join("\n");
1658
1777
  }).join("\n\n");
1659
1778
  }
1779
+ function renderAuthGateScreen() {
1780
+ return renderHomeScreen({
1781
+ profile: null,
1782
+ changelog: [],
1783
+ stats: null
1784
+ });
1785
+ }
1660
1786
 
1661
1787
  // src/shell/slash-commands.ts
1662
1788
  init_api_client();
@@ -1676,47 +1802,17 @@ function emptyState(title, hint, action) {
1676
1802
 
1677
1803
  // src/commands/keys.ts
1678
1804
  init_brand();
1679
-
1680
- // src/terminal/interactive.ts
1681
- init_theme();
1682
- import { select, search } from "@inquirer/prompts";
1683
- async function pickFromList(message, items) {
1684
- if (!items.length) return null;
1685
- try {
1686
- return await select({ message, choices: items });
1687
- } catch {
1688
- return null;
1689
- }
1690
- }
1691
- async function searchPalette(message, choices) {
1692
- try {
1693
- return await search({
1694
- message,
1695
- source: async (input5) => {
1696
- if (!input5) return choices;
1697
- const q = input5.toLowerCase();
1698
- return choices.filter((c) => c.value.includes(q) || c.description?.includes(q));
1699
- }
1700
- });
1701
- } catch {
1702
- return null;
1703
- }
1704
- }
1705
- function formatChoice(name, description) {
1706
- return `${accent(name)}${muted(" \u2014 " + description)}`;
1707
- }
1708
-
1709
- // src/commands/keys.ts
1805
+ init_api_utils();
1710
1806
  async function runKeysListCommand(client) {
1711
1807
  const data = await client.get("/keys");
1712
- const keys = data.results || [];
1808
+ const keys = asList(data);
1713
1809
  if (!keys.length) {
1714
1810
  return emptyState("No API keys", `Create one at ${SETTINGS_API_KEYS_URL}`, "[n] new key in this view");
1715
1811
  }
1716
1812
  const rows = keys.map((k) => [
1717
1813
  k.name,
1718
1814
  k.prefix,
1719
- k.scopes?.join(", ") || muted("\u2014"),
1815
+ k.scopes && Array.isArray(k.scopes) ? k.scopes.join(", ") : muted("\u2014"),
1720
1816
  k.revoked ? muted("Revoked") : k.active ? success("Active") : muted("Inactive"),
1721
1817
  k.last_used_at ? new Date(k.last_used_at).toLocaleDateString() : muted("Never")
1722
1818
  ]);
@@ -1728,13 +1824,16 @@ async function runKeyRevoke(client, keyId) {
1728
1824
  }
1729
1825
  async function pickKeyForRevoke(client) {
1730
1826
  const data = await client.get("/keys");
1731
- const active = (data.results || []).filter((k) => !k.revoked && k.active);
1827
+ const active = asList(data).filter((k) => !k.revoked && k.active);
1732
1828
  return pickFromList(
1733
1829
  "Select a key to revoke",
1734
1830
  active.map((k) => ({ name: `${k.name} (${k.prefix})`, value: k.id }))
1735
1831
  );
1736
1832
  }
1737
1833
 
1834
+ // src/shell/slash-commands.ts
1835
+ init_api_utils();
1836
+
1738
1837
  // src/shell/help-screen.ts
1739
1838
  init_layout();
1740
1839
  init_theme();
@@ -1759,9 +1858,9 @@ var SLASH_COMMANDS = [
1759
1858
  { name: "/exit", description: "Exit the shell", aliases: ["/quit"] }
1760
1859
  ];
1761
1860
  function findCommand(input5) {
1762
- const cmd = input5.trim().split(/\s/)[0].toLowerCase();
1861
+ const cmd2 = input5.trim().split(/\s/)[0].toLowerCase();
1763
1862
  return SLASH_COMMANDS.find(
1764
- (c) => c.name === cmd || c.aliases?.some((a) => a === cmd)
1863
+ (c) => c.name === cmd2 || c.aliases?.some((a) => a === cmd2)
1765
1864
  );
1766
1865
  }
1767
1866
 
@@ -1876,13 +1975,142 @@ function normalizeScanUrl(url) {
1876
1975
  if (!/^https?:\/\//i.test(u)) return `https://${u}`;
1877
1976
  return u;
1878
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
+ }
1879
2107
  async function dispatchSlashCommand(line, ctx) {
1880
2108
  const trimmed = line.trim();
1881
2109
  if (!trimmed) return {};
1882
2110
  if (trimmed === "/exit" || trimmed === "/quit") return { exit: true };
1883
- const [cmd, ...rest] = trimmed.split(/\s+/);
2111
+ const [cmd2, ...rest] = trimmed.split(/\s+/);
1884
2112
  const arg = rest.join(" ").trim();
1885
- if (!cmd.startsWith("/")) {
2113
+ if (!cmd2.startsWith("/")) {
1886
2114
  ctx.sink.write(
1887
2115
  errorBox(
1888
2116
  `Not a shell command: ${trimmed}`,
@@ -1892,117 +2120,16 @@ ${accent("/help")} lists everything`
1892
2120
  );
1893
2121
  return {};
1894
2122
  }
1895
- if (!findCommand(cmd) && cmd.startsWith("/")) {
1896
- ctx.sink.write(errorBox(`Unknown command: ${cmd}`, "Type /help or /palette"));
2123
+ if (!findCommand(cmd2) && cmd2.startsWith("/")) {
2124
+ ctx.sink.write(errorBox(`Unknown command: ${cmd2}`, "Type /help or /palette"));
1897
2125
  return {};
1898
2126
  }
1899
2127
  try {
1900
- switch (cmd.toLowerCase()) {
1901
- case "/help":
1902
- ctx.sink.write(renderHelpScreen());
1903
- break;
1904
- case "/palette":
1905
- case "/commands": {
1906
- const picked = await openCommandPalette();
1907
- if (picked) return dispatchSlashCommand(picked, ctx);
1908
- break;
1909
- }
1910
- case "/clear":
1911
- return { redraw: true };
1912
- case "/history": {
1913
- const hist = await loadHistory();
1914
- ctx.sink.write(renderHistoryScreen(hist));
1915
- break;
1916
- }
1917
- case "/scan": {
1918
- if (!arg) {
1919
- ctx.sink.write(error("Usage: /scan <url>"));
1920
- break;
1921
- }
1922
- const client = await getAuthenticatedClient();
1923
- const progress = createStreamProgress(ctx.sink);
1924
- const result = await runScanCommand(client, {
1925
- url: normalizeScanUrl(arg),
1926
- wait: true,
1927
- onProgress: progress.update,
1928
- streamProgress: true
1929
- });
1930
- progress.stop();
1931
- ctx.sink.write(result.output);
1932
- break;
1933
- }
1934
- case "/status": {
1935
- const client = await getAuthenticatedClient();
1936
- const status = await runStatusCommand(client);
1937
- const rate = await runRateLimitStatus(client);
1938
- ctx.sink.write(rate ? `${status}
1939
-
1940
- ${muted("Rate limit")} ${rate}` : status);
1941
- break;
1942
- }
1943
- case "/scans":
1944
- await runScansInteractive(ctx);
1945
- break;
1946
- case "/keys":
1947
- await runKeysInteractive(ctx);
1948
- break;
1949
- case "/config": {
1950
- if (!arg.includes("=")) {
1951
- ctx.sink.write(error("Usage: /config key=value"));
1952
- break;
1953
- }
1954
- const [k, ...vParts] = arg.split("=");
1955
- ctx.sink.write(await runConfigSet(k.trim(), vParts.join("=").trim()));
1956
- break;
1957
- }
1958
- case "/changelog": {
1959
- const raw = await readAsset("changelog.json");
1960
- ctx.sink.write(renderChangelog(JSON.parse(raw)));
1961
- break;
1962
- }
1963
- case "/compare": {
1964
- const [a, b] = arg.split(/\s+/);
1965
- if (!a || !b) {
1966
- ctx.sink.write(error("Usage: /compare <scan-a> <scan-b>"));
1967
- break;
1968
- }
1969
- const { runCompareCommand: runCompareCommand2 } = await Promise.resolve().then(() => (init_compare(), compare_exports));
1970
- ctx.sink.write(await runCompareCommand2(a, b, "table"));
1971
- break;
1972
- }
1973
- case "/whatif": {
1974
- if (!arg) {
1975
- ctx.sink.write(error("Usage: /whatif <scan-id>"));
1976
- break;
1977
- }
1978
- const { runWhatIfCommand: runWhatIfCommand2 } = await Promise.resolve().then(() => (init_whatif(), whatif_exports));
1979
- ctx.sink.write(await runWhatIfCommand2(arg, {}, "table"));
1980
- break;
1981
- }
1982
- case "/report": {
1983
- if (!arg) {
1984
- ctx.sink.write(error("Usage: /report <scan-id>"));
1985
- break;
1986
- }
1987
- const { runReportCommand: runReportCommand2 } = await Promise.resolve().then(() => (init_report(), report_exports));
1988
- const client = await getAuthenticatedClient();
1989
- ctx.sink.write(await runReportCommand2(client, arg, "json"));
1990
- break;
1991
- }
1992
- case "/login":
1993
- case "/auth": {
1994
- ctx.sink.write(await runAuthLogin({ interactive: true, browser: true }));
1995
- const client = await getAuthenticatedClient();
1996
- return { profile: await client.get("/me"), redraw: true };
1997
- }
1998
- case "/auth-key": {
1999
- ctx.sink.write(await runApiKeyLogin({ interactive: true }));
2000
- const client = await getAuthenticatedClient();
2001
- return { profile: await client.get("/me"), redraw: true };
2002
- }
2003
- default:
2004
- ctx.sink.write(errorBox(`Unknown command: ${cmd}`, "Type /help"));
2128
+ const label = spinnerLabel(cmd2);
2129
+ if (label) {
2130
+ return await withSpinner(label, () => executeSlashCommand(cmd2, arg, ctx));
2005
2131
  }
2132
+ return await executeSlashCommand(cmd2, arg, ctx);
2006
2133
  } catch (e) {
2007
2134
  ctx.sink.error(formatApiError(e));
2008
2135
  }
@@ -2027,7 +2154,7 @@ async function runScansInteractive(ctx) {
2027
2154
  while (true) {
2028
2155
  const list = await runScansListCommand(client, "table", { limit: 20 });
2029
2156
  const data = await client.get("/scans?page=1&page_size=20");
2030
- const scans = data.results || [];
2157
+ const scans = asList(data);
2031
2158
  if (!scans.length) {
2032
2159
  ctx.sink.write(emptyState("No scans yet", "Run /scan <url> to create your first scan", "/scan https://example.com"));
2033
2160
  return;
@@ -2044,10 +2171,12 @@ async function runScansInteractive(ctx) {
2044
2171
  if (!scanId) return;
2045
2172
  const detail = await client.get(`/scan/${scanId}`);
2046
2173
  ctx.sink.write(renderScanResult(detail, "table"));
2047
- const cont = await input2({
2048
- message: muted("Enter = back to list \xB7 q = exit"),
2049
- default: ""
2050
- }).catch(() => "q");
2174
+ const cont = await runInteractive(
2175
+ () => input2({
2176
+ message: muted("Enter = back to list \xB7 q = exit"),
2177
+ default: ""
2178
+ })
2179
+ ).catch(() => "q");
2051
2180
  if (cont.toLowerCase() === "q") return;
2052
2181
  }
2053
2182
  }
@@ -2055,14 +2184,16 @@ async function runKeysInteractive(ctx) {
2055
2184
  const client = await getAuthenticatedClient();
2056
2185
  ctx.sink.write(await runKeysListCommand(client));
2057
2186
  ctx.sink.write(muted("\nActions: [r] revoke [n] new key [Enter] back"));
2058
- const action = await input2({ message: "Action", default: "" }).catch(() => "");
2187
+ const action = await runInteractive(() => input2({ message: "Action", default: "" })).catch(() => "");
2059
2188
  if (action.toLowerCase() === "r") {
2060
2189
  const keyId = await pickKeyForRevoke(client);
2061
2190
  if (!keyId) return;
2062
- const ok = await confirm2({ message: "Revoke this API key?", default: false }).catch(() => false);
2191
+ const ok = await runInteractive(
2192
+ () => confirm2({ message: "Revoke this API key?", default: false })
2193
+ ).catch(() => false);
2063
2194
  if (ok) ctx.sink.write(successBox(await runKeyRevoke(client, keyId)));
2064
2195
  } else if (action.toLowerCase() === "n") {
2065
- const name = await promptNewKeyName();
2196
+ const name = await runInteractive(() => promptNewKeyName());
2066
2197
  const created = await client.post("/keys", { name });
2067
2198
  ctx.sink.write(success(`Created: ${created.name} (${created.prefix})`));
2068
2199
  ctx.sink.write(accent("Save this key \u2014 it won't be shown again:"));
@@ -2072,7 +2203,6 @@ async function runKeysInteractive(ctx) {
2072
2203
 
2073
2204
  // src/shell/prompt-loop.ts
2074
2205
  init_theme();
2075
- import { cwd as cwd3 } from "process";
2076
2206
 
2077
2207
  // src/shell/completer.ts
2078
2208
  function buildCompleter(commands, history) {
@@ -2090,8 +2220,6 @@ function buildCompleter(commands, history) {
2090
2220
 
2091
2221
  // src/shell/prompt-loop.ts
2092
2222
  init_capabilities();
2093
- init_layout();
2094
- init_logger();
2095
2223
  async function runPromptLoop(initialProfile) {
2096
2224
  let profile = initialProfile;
2097
2225
  const workDir = cwd3();
@@ -2100,13 +2228,8 @@ async function runPromptLoop(initialProfile) {
2100
2228
  if (getCapabilities().interactive) {
2101
2229
  process.stdout.write("\x1B[2J\x1B[H");
2102
2230
  }
2103
- const data = await fetchHomeData(profile, workDir);
2231
+ const data = await fetchHomeData(profile);
2104
2232
  console.log(renderHomeScreen(data));
2105
- console.log(statusBar([
2106
- profile ? muted(profile.email) : muted("guest"),
2107
- dim(workDir),
2108
- isDebugMode() ? muted("debug") : null
2109
- ].filter(Boolean)));
2110
2233
  console.log("");
2111
2234
  };
2112
2235
  await refresh();
@@ -2122,7 +2245,14 @@ async function runPromptLoop(initialProfile) {
2122
2245
  rl.close();
2123
2246
  process.exit(0);
2124
2247
  });
2125
- const prompt = () => process.stdout.write(accent("\u203A "));
2248
+ setReadlineHooks(
2249
+ () => rl.pause(),
2250
+ () => {
2251
+ process.stdout.write("\n");
2252
+ rl.resume();
2253
+ }
2254
+ );
2255
+ const prompt = () => process.stdout.write(accent("> "));
2126
2256
  prompt();
2127
2257
  for await (const line of rl) {
2128
2258
  await appendHistory(line);
@@ -2151,73 +2281,48 @@ async function runPromptLoop(initialProfile) {
2151
2281
  }
2152
2282
 
2153
2283
  // src/shell/auth-gate.ts
2154
- init_layout();
2155
- init_theme();
2156
- init_width();
2157
2284
  import * as readline2 from "readline";
2158
2285
  import { stdin as input4, stdout as output2 } from "process";
2286
+ init_theme();
2159
2287
 
2160
2288
  // src/cli/profile.ts
2161
2289
  init_credentials();
2162
2290
  init_api_client();
2163
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
+ }
2164
2303
  async function tryGetProfile() {
2304
+ const creds = await loadCredentials();
2305
+ if (!creds?.api_key) return null;
2165
2306
  try {
2166
- const creds = await loadCredentials();
2167
- if (!creds?.api_key) return null;
2168
- const client = await getAuthenticatedClient();
2169
- return await client.get("/me");
2307
+ const client = await FuzziApiClient.create();
2308
+ const profile = await client.get("/me");
2309
+ return profile;
2170
2310
  } catch (e) {
2171
- log.debug("profile bootstrap failed", e);
2172
- return null;
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);
2173
2318
  }
2174
2319
  }
2175
2320
 
2176
2321
  // src/shell/auth-gate.ts
2177
- init_brand();
2178
- function renderAuthGate() {
2179
- const colW = Math.max(28, Math.floor(contentWidth() * 0.36));
2180
- const mark = centerInColumn(accent(renderFuzziMark()), colW);
2181
- const left = [
2182
- accentBold("Welcome to Fuzzi!"),
2183
- "",
2184
- mark,
2185
- "",
2186
- muted("Not connected"),
2187
- info("Sign in to run scans"),
2188
- "",
2189
- accent("/auth-key") + muted(" paste API key"),
2190
- accent("/help") + muted(" commands")
2191
- ].join("\n");
2192
- const rightTop = [
2193
- accentBold("Sign in to continue"),
2194
- "",
2195
- info("Press Enter to open your browser"),
2196
- muted("and authorize the CLI."),
2197
- "",
2198
- muted("A local server receives the callback"),
2199
- muted(`from ${APP_HOST} automatically.`)
2200
- ].join("\n");
2201
- const rightBottom = [
2202
- accentBold("Other options"),
2203
- "",
2204
- muted("Paste an API key with /auth-key"),
2205
- muted("from Settings \u2192 API Keys on the web."),
2206
- "",
2207
- italic(muted(SETTINGS_API_KEYS_URL))
2208
- ].join("\n");
2209
- return splitHomePanel({
2210
- title: `Fuzzi CLI v${VERSION}`,
2211
- left,
2212
- rightTop,
2213
- rightBottom,
2214
- leftRatio: 0.36
2215
- });
2216
- }
2217
2322
  function waitForEnter() {
2218
2323
  return new Promise((resolve) => {
2219
2324
  const rl = readline2.createInterface({ input: input4, output: output2, terminal: true });
2220
- output2.write(accent("\n \u203A Press Enter to open browser... "));
2325
+ output2.write(accent("\n> Press Enter to open browser... "));
2221
2326
  rl.once("line", () => {
2222
2327
  rl.close();
2223
2328
  resolve();
@@ -2228,18 +2333,15 @@ async function runAuthGate() {
2228
2333
  const existing = await tryGetProfile();
2229
2334
  if (existing) return existing;
2230
2335
  if (!output2.isTTY) return null;
2231
- console.log(renderAuthGate());
2336
+ console.log(renderAuthGateScreen());
2232
2337
  await waitForEnter();
2233
- const progress = createProgress("Opening browser...");
2234
2338
  try {
2235
- const result = await runBrowserLogin();
2236
- progress.succeed("Signed in");
2237
- console.log(accent(result.message));
2339
+ const result = await runAssistedBrowserLogin();
2340
+ console.log(result.message);
2238
2341
  return result.profile;
2239
2342
  } catch (e) {
2240
- progress.fail("Sign-in failed");
2241
2343
  console.log(muted(formatApiError(e)));
2242
- console.log(muted("Use /auth-key to paste an API key, or /auth to retry."));
2344
+ console.log(muted("Run /auth-key to paste your API key manually."));
2243
2345
  return null;
2244
2346
  }
2245
2347
  }