fuzzi-cli 0.1.2 → 0.1.4

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
@@ -9,27 +9,38 @@ var __export = (target, all) => {
9
9
  };
10
10
 
11
11
  // src/types/brand.ts
12
- var BRAND, RISK_COLORS, VERSION, APP_ORIGIN, DEFAULT_API_URL;
12
+ var BRAND, RISK_COLORS, VERSION, APP_ORIGIN, DEFAULT_API_URL, SETTINGS_API_KEYS_URL, CLI_AUTH_URL, APP_HOST;
13
13
  var init_brand = __esm({
14
14
  "src/types/brand.ts"() {
15
15
  "use strict";
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.2";
31
- APP_ORIGIN = "https://app.fuzzi.dev";
38
+ VERSION = "0.1.4";
39
+ APP_ORIGIN = "https://fuzzi-ten.vercel.app";
32
40
  DEFAULT_API_URL = `${APP_ORIGIN}/api`;
41
+ SETTINGS_API_KEYS_URL = `${APP_ORIGIN}/settings/api-keys`;
42
+ CLI_AUTH_URL = `${APP_ORIGIN}/cli-auth`;
43
+ APP_HOST = "fuzzi-ten.vercel.app";
33
44
  }
34
45
  });
35
46
 
@@ -186,9 +197,6 @@ function shouldLog(level) {
186
197
  function stamp() {
187
198
  return (/* @__PURE__ */ new Date()).toISOString();
188
199
  }
189
- function isDebugMode() {
190
- return currentLevel() === "debug";
191
- }
192
200
  var LEVELS, log;
193
201
  var init_logger = __esm({
194
202
  "src/lib/logger.ts"() {
@@ -220,9 +228,9 @@ function mapErrorMessage(status, body) {
220
228
  return "API key has been revoked. Please log in again.";
221
229
  }
222
230
  if (code === "key_expired" || msg.toLowerCase().includes("expired")) {
223
- return "API key has expired. Generate a new one at https://app.fuzzi.dev/settings/api-keys";
231
+ return `API key has expired. Generate a new one at ${SETTINGS_API_KEYS_URL}`;
224
232
  }
225
- return "Invalid API key. Generate a new one at https://app.fuzzi.dev/settings/api-keys";
233
+ return `Invalid API key. Generate a new one at ${SETTINGS_API_KEYS_URL}`;
226
234
  }
227
235
  if (status === 403 && (code === "ssrf" || msg.toLowerCase().includes("private ip"))) {
228
236
  return "This URL is not allowed (private IP address detected). Please scan a public-facing URL.";
@@ -253,6 +261,7 @@ var init_api_client = __esm({
253
261
  init_config();
254
262
  init_credentials();
255
263
  init_logger();
264
+ init_brand();
256
265
  ApiError = class extends Error {
257
266
  constructor(message, status, code, body, exitCode) {
258
267
  super(message);
@@ -302,7 +311,7 @@ var init_api_client = __esm({
302
311
  });
303
312
  } catch {
304
313
  throw new ApiError(
305
- "Could not connect to app.fuzzi.dev. Check your internet connection or try again later.",
314
+ `Could not connect to ${APP_HOST}. Check your internet connection or try again later.`,
306
315
  0,
307
316
  "network_error",
308
317
  void 0,
@@ -358,7 +367,7 @@ var init_api_client = __esm({
358
367
  res = await fetch(url, { headers: this.headers() });
359
368
  } catch {
360
369
  throw new ApiError(
361
- "Could not connect to app.fuzzi.dev. Check your internet connection or try again later.",
370
+ `Could not connect to ${APP_HOST}. Check your internet connection or try again later.`,
362
371
  0,
363
372
  "network_error",
364
373
  void 0,
@@ -391,7 +400,7 @@ function getCapabilities() {
391
400
  const colorterm = process.env.COLORTERM ?? "";
392
401
  const trueColor = colorterm.includes("truecolor") || colorterm.includes("24bit") || term.includes("truecolor") || !!process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0";
393
402
  cached = {
394
- width: Math.max(60, Math.min(cols, 120)),
403
+ width: Math.max(60, cols),
395
404
  trueColor,
396
405
  interactive: stdout.isTTY === true
397
406
  };
@@ -430,18 +439,18 @@ function scoreBold(n) {
430
439
  return chalk.bold(String(n));
431
440
  }
432
441
  function error(text) {
433
- return color("#EF4444", chalk.red)(text);
442
+ return color(BRAND.danger, chalk.red)(text);
434
443
  }
435
444
  function success(text) {
436
- return color("#22C55E", chalk.green)(text);
445
+ return color(BRAND.success, chalk.green)(text);
437
446
  }
438
447
  function warn(text) {
439
- return color("#F59E0B", chalk.yellow)(text);
448
+ return color(BRAND.warning, chalk.yellow)(text);
440
449
  }
441
- function info(text) {
442
- return color(BRAND.accent, chalk.cyan)(text);
450
+ function cmd(text) {
451
+ return accent(text);
443
452
  }
444
- var accent, accentBold, muted, bold, dim, italic;
453
+ var accent, accentBold, primary, primaryBold, muted, dimText, border, bold, dim, italic;
445
454
  var init_theme = __esm({
446
455
  "src/terminal/theme.ts"() {
447
456
  "use strict";
@@ -449,7 +458,11 @@ var init_theme = __esm({
449
458
  init_capabilities();
450
459
  accent = color(BRAND.accent, chalk.cyan);
451
460
  accentBold = accent.bold;
461
+ primary = color(BRAND.textPrimary, chalk.white);
462
+ primaryBold = primary.bold;
452
463
  muted = color(BRAND.textSecondary, chalk.gray);
464
+ dimText = color(BRAND.textTertiary, chalk.dim);
465
+ border = color(BRAND.borderSubtle, chalk.gray);
453
466
  bold = chalk.bold;
454
467
  dim = chalk.dim;
455
468
  italic = chalk.italic;
@@ -540,33 +553,6 @@ function panel(content, opts = {}) {
540
553
  width
541
554
  });
542
555
  }
543
- function centerInColumn(text, colWidth) {
544
- return text.split("\n").map((line) => {
545
- const plain = line.replace(/\x1b\[[0-9;]*m/g, "");
546
- const pad = Math.max(0, Math.floor((colWidth - plain.length) / 2));
547
- return " ".repeat(pad) + line;
548
- }).join("\n");
549
- }
550
- function splitHomePanel(opts) {
551
- const total = contentWidth();
552
- const leftW = Math.max(28, Math.floor(total * (opts.leftRatio ?? 0.34)));
553
- const rightW = total - leftW - 3;
554
- const leftLines = opts.left.split("\n");
555
- const rightTop = opts.rightTop.split("\n");
556
- const rightDiv = dim("\u2500".repeat(Math.max(10, rightW)));
557
- const rightBottom = opts.rightBottom.split("\n");
558
- const rightLines = [...rightTop, "", rightDiv, "", ...rightBottom];
559
- const rows = Math.max(leftLines.length, rightLines.length);
560
- const sep = dim("\u2502");
561
- const body = [""];
562
- for (let i = 0; i < rows; i++) {
563
- const l = padEndVisible(leftLines[i] ?? "", leftW);
564
- const r = rightLines[i] ?? "";
565
- body.push(`${l} ${sep} ${r}`);
566
- }
567
- body.push("");
568
- return panel(body.join("\n"), { title: opts.title, marginBottom: 0, borderStyle: "classic" });
569
- }
570
556
  function columns(left, right, leftWidth) {
571
557
  const total = contentWidth();
572
558
  const split = leftWidth ?? Math.floor(total * 0.48);
@@ -585,9 +571,6 @@ function divider(char = "\u2500", width) {
585
571
  const w = width ?? contentWidth();
586
572
  return dim(char.repeat(Math.max(20, w)));
587
573
  }
588
- function statusBar(parts) {
589
- return dim(parts.filter(Boolean).join(" \xB7 "));
590
- }
591
574
  function keyValue(rows, indent = 2) {
592
575
  const pad = " ".repeat(indent);
593
576
  const maxKey = Math.max(...rows.map(([k]) => k.length), 4);
@@ -715,102 +698,44 @@ init_logger();
715
698
  import { randomBytes } from "crypto";
716
699
  import { createServer } from "http";
717
700
  import { exec } from "child_process";
718
- var TIMEOUT_MS = 5 * 60 * 1e3;
719
- function generateState() {
720
- return randomBytes(24).toString("base64url");
721
- }
722
701
  function openBrowser(url) {
723
702
  const platform = process.platform;
724
- const cmd = platform === "darwin" ? `open ${JSON.stringify(url)}` : platform === "win32" ? `start "" ${JSON.stringify(url)}` : `xdg-open ${JSON.stringify(url)}`;
725
- exec(cmd, (err) => {
703
+ const cmd2 = platform === "darwin" ? `open ${JSON.stringify(url)}` : platform === "win32" ? `start "" ${JSON.stringify(url)}` : `xdg-open ${JSON.stringify(url)}`;
704
+ exec(cmd2, (err) => {
726
705
  if (err) log.warn("could not open browser automatically", err.message);
727
706
  });
728
707
  }
729
708
  function apiOrigin(apiUrl) {
730
709
  return apiUrl.replace(/\/api\/?$/, "") || APP_ORIGIN;
731
710
  }
732
- async function runBrowserLogin() {
711
+ async function openCliAuthPage() {
733
712
  const config = await loadConfig();
734
- const state = generateState();
735
- const handoffToken = await new Promise((resolve, reject) => {
736
- const server = createServer((req, res) => {
737
- try {
738
- const addr = server.address();
739
- const port = typeof addr === "object" && addr ? addr.port : 0;
740
- const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
741
- if (url.pathname !== "/callback") {
742
- res.writeHead(404);
743
- res.end();
744
- return;
745
- }
746
- const token = url.searchParams.get("token");
747
- const returnedState = url.searchParams.get("state");
748
- if (!token || returnedState !== state) {
749
- res.writeHead(400);
750
- res.end("Invalid callback");
751
- reject(new ApiError("Invalid sign-in callback.", 400, "invalid_callback", void 0, 2));
752
- return;
753
- }
754
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
755
- res.end(`<!DOCTYPE html><html><body style="font-family:system-ui;text-align:center;padding:48px">
756
- <h1>Signed in to Fuzzi CLI</h1><p>Return to your terminal.</p>
757
- <script>setTimeout(()=>window.close(),1200)</script></body></html>`);
758
- server.close();
759
- resolve(token);
760
- } catch (e) {
761
- server.close();
762
- reject(e);
763
- }
764
- });
765
- const timer = setTimeout(() => {
766
- server.close();
767
- reject(new ApiError("Sign-in timed out after 5 minutes.", 408, "auth_timeout", void 0, 2));
768
- }, TIMEOUT_MS);
769
- server.listen(0, "127.0.0.1", () => {
770
- const addr = server.address();
771
- const port = typeof addr === "object" && addr ? addr.port : 0;
772
- const loginUrl = `${apiOrigin(config.api_url)}/cli-auth?state=${encodeURIComponent(state)}&callback_port=${port}`;
773
- openBrowser(loginUrl);
774
- log.debug("browser auth", loginUrl);
775
- });
776
- server.on("error", (e) => {
777
- clearTimeout(timer);
778
- reject(e);
779
- });
780
- });
781
- const client = new FuzziApiClient(config.api_url);
782
- const handoff = await client.post("/cli/handoff", {
783
- handoff_token: handoffToken,
784
- state
785
- });
786
- if (!handoff.api_key) {
787
- throw new ApiError("Sign-in failed: no API key returned.", 500, "handoff_failed", void 0, 2);
788
- }
789
- client.setToken(handoff.api_key);
790
- const profile = await client.get("/me");
791
- await saveCredentials({
792
- api_key: handoff.api_key,
793
- auth_method: "api_key",
794
- key_prefix: handoff.prefix || profile.key_prefix || maskApiKey(handoff.api_key),
795
- key_expires_at: handoff.expires_at || profile.key_expires_at || void 0,
796
- email: profile.email,
797
- full_name: profile.full_name || void 0,
798
- saved_at: (/* @__PURE__ */ new Date()).toISOString()
799
- });
800
- const name = profile.full_name || profile.email;
801
- return { message: `Signed in as ${name}`, profile };
713
+ const url = `${apiOrigin(config.api_url)}/cli-auth`;
714
+ openBrowser(url);
715
+ log.debug("opened cli auth page", url);
716
+ return url;
802
717
  }
803
718
 
804
719
  // src/commands/auth.ts
720
+ init_brand();
721
+ async function runAssistedBrowserLogin() {
722
+ await openCliAuthPage();
723
+ console.log("");
724
+ console.log(accent(" Browser opened \u2014 authorize Fuzzi CLI on the web page."));
725
+ console.log(muted(" Copy the API key shown, then paste it below."));
726
+ console.log("");
727
+ const msg = await runApiKeyLogin({ interactive: true });
728
+ const client = await getAuthenticatedClient();
729
+ const profile = await client.get("/me");
730
+ return { message: msg, profile };
731
+ }
805
732
  async function runAuthLogin(opts = {}) {
806
- if (opts.browser || opts.interactive !== false && !opts.apiKey && !opts.apiKeyOnly) {
807
- try {
808
- const result = await runBrowserLogin();
809
- return success(result.message);
810
- } catch (e) {
811
- if (opts.browser) throw e;
812
- if (opts.apiKeyOnly) throw e;
813
- }
733
+ if (opts.apiKeyOnly || opts.apiKey) {
734
+ return runApiKeyLogin(opts);
735
+ }
736
+ if (opts.browser || opts.interactive !== false) {
737
+ const result = await runAssistedBrowserLogin();
738
+ return result.message;
814
739
  }
815
740
  return runApiKeyLogin(opts);
816
741
  }
@@ -841,7 +766,7 @@ async function runApiKeyLogin(opts = {}) {
841
766
  apiKey = apiKey.trim();
842
767
  if (!isValidApiKeyFormat(apiKey)) {
843
768
  throw new ApiError(
844
- "Invalid API key format. Generate a new one at https://app.fuzzi.dev/settings/api-keys",
769
+ `Invalid API key format. Generate a new one at ${SETTINGS_API_KEYS_URL}`,
845
770
  401,
846
771
  "invalid_key_format",
847
772
  void 0,
@@ -852,7 +777,7 @@ async function runApiKeyLogin(opts = {}) {
852
777
  const valid = await client.validateToken();
853
778
  if (!valid) {
854
779
  throw new ApiError(
855
- "Invalid API key. Generate a new one at https://app.fuzzi.dev/settings/api-keys",
780
+ `Invalid API key. Generate a new one at ${SETTINGS_API_KEYS_URL}`,
856
781
  401,
857
782
  "invalid_token",
858
783
  void 0,
@@ -993,13 +918,14 @@ init_theme();
993
918
 
994
919
  // src/lib/errors.ts
995
920
  init_api_client();
921
+ init_brand();
996
922
  function formatApiError(err) {
997
923
  if (err instanceof ApiError) {
998
924
  return err.message;
999
925
  }
1000
926
  if (err instanceof Error) {
1001
927
  if (err.message.includes("fetch failed") || err.message.includes("ECONNREFUSED")) {
1002
- return "Could not connect to app.fuzzi.dev. Check your internet connection or try again later.";
928
+ return `Could not connect to ${APP_HOST}. Check your internet connection or try again later.`;
1003
929
  }
1004
930
  return `An error occurred: ${err.message}. Please report this at https://github.com/fuzzi-cli/fuzzi-cli/issues`;
1005
931
  }
@@ -1474,33 +1400,7 @@ function buildProgram() {
1474
1400
 
1475
1401
  // src/shell/prompt-loop.ts
1476
1402
  import * as readline from "readline/promises";
1477
- import { stdin as input3, stdout as output } from "process";
1478
-
1479
- // src/shell/home-screen.ts
1480
- import { homedir as homedir3 } from "os";
1481
-
1482
- // src/shell/ascii-mark.ts
1483
- function renderFuzziMark() {
1484
- return [
1485
- " \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
1486
- " \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
1487
- " \u2588\u2588 \u2588\u2588",
1488
- " \u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588",
1489
- " \u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588",
1490
- " \u2588\u2588 \u2588\u2588\u2588\u2588 \u2588\u2588",
1491
- " \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
1492
- " \u2588\u2588 \u2588\u2588",
1493
- " \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
1494
- " \u2588\u2588 \u2588\u2588",
1495
- " \u2588\u2588 \u2588\u2588"
1496
- ].join("\n");
1497
- }
1498
-
1499
- // src/shell/home-screen.ts
1500
- init_brand();
1501
- init_theme();
1502
- init_layout();
1503
- init_width();
1403
+ import { stdin as input3, stdout as output, cwd as cwd3 } from "process";
1504
1404
 
1505
1405
  // src/lib/assets.ts
1506
1406
  import { readFile as readFile4 } from "fs/promises";
@@ -1527,130 +1427,261 @@ async function readAsset(name) {
1527
1427
  }
1528
1428
 
1529
1429
  // src/shell/home-screen.ts
1530
- async function fetchHomeData(profile, cwd4) {
1430
+ init_api_client();
1431
+
1432
+ // src/shell/ascii-mark.ts
1433
+ function renderFuzziMark() {
1434
+ return [
1435
+ " \u2554\u2550\u2550\u2550\u2550\u2557",
1436
+ " \u2551 \u25C6 \u2551",
1437
+ " \u255A\u2550\u2566\u2550\u2550\u255D",
1438
+ " \u2554\u2550\u2569\u2550\u2557",
1439
+ " \u2551 \u25C6 \u2551",
1440
+ " \u255A\u2550\u2550\u2550\u255D"
1441
+ ].join("\n");
1442
+ }
1443
+
1444
+ // src/shell/home-screen.ts
1445
+ init_brand();
1446
+ init_theme();
1447
+ init_width();
1448
+
1449
+ // src/components/Panel.ts
1450
+ init_theme();
1451
+ init_strings();
1452
+ init_width();
1453
+ function visibleLen(s) {
1454
+ return s.replace(/\x1b\[[0-9;]*m/g, "").length;
1455
+ }
1456
+ function topBanner(title, width = contentWidth()) {
1457
+ const inner = ` ${title} `;
1458
+ const dashes = Math.max(0, width - 2 - visibleLen(inner));
1459
+ const left = Math.floor(dashes / 2);
1460
+ const right = dashes - left;
1461
+ return [
1462
+ border(`\u250F${"\u2501".repeat(left)}${inner}${"\u2501".repeat(right)}\u2513`),
1463
+ border(`\u2517${"\u2501".repeat(width - 2)}\u251B`)
1464
+ ].join("\n");
1465
+ }
1466
+ function titleSegment(title, width) {
1467
+ const prefix = `\u2500 ${title} `;
1468
+ const dashes = Math.max(0, width - visibleLen(prefix));
1469
+ return prefix + "\u2500".repeat(dashes);
1470
+ }
1471
+ function tripleColumnPanel(cols, totalWidth = contentWidth()) {
1472
+ const sep = 1;
1473
+ const inner = totalWidth - 2;
1474
+ const ratios = [0.44, 0.28, 0.28];
1475
+ const raw = ratios.map((r) => Math.floor(inner * r));
1476
+ const used = raw.reduce((a, b) => a + b, 0) + 2 * sep;
1477
+ raw[0] += inner - used;
1478
+ const widths = raw;
1479
+ 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");
1480
+ const maxRows = Math.max(...cols.map((c) => c.lines.length), 1);
1481
+ const body = [top];
1482
+ for (let i = 0; i < maxRows; i++) {
1483
+ const cells = cols.map((c, idx) => padEndVisible(c.lines[i] ?? "", widths[idx]));
1484
+ body.push(
1485
+ border("\u2502") + cells[0] + border("\u2502") + cells[1] + border("\u2502") + cells[2] + border("\u2502")
1486
+ );
1487
+ }
1488
+ 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");
1489
+ body.push(bottom);
1490
+ return body.join("\n");
1491
+ }
1492
+ function stackedPanels(cols, width = contentWidth()) {
1493
+ return cols.map((col) => singlePanel(col.title, col.lines, width)).join("\n\n");
1494
+ }
1495
+ function singlePanel(title, lines, width = contentWidth()) {
1496
+ const inner = width - 2;
1497
+ const prefix = `\u2500 ${title} `;
1498
+ const dashes = Math.max(0, inner - visibleLen(prefix));
1499
+ const top = border(`\u250C${prefix}${"\u2500".repeat(dashes)}\u2510`);
1500
+ const bottom = border(`\u2514${"\u2500".repeat(inner)}\u2518`);
1501
+ const body = lines.map((l) => border("\u2502") + padEndVisible(l, inner) + border("\u2502"));
1502
+ return [top, ...body, bottom].join("\n");
1503
+ }
1504
+ function tipPanel(text, width = contentWidth()) {
1505
+ const inner = width - 2;
1506
+ const prefix = "\u2500 Tip ";
1507
+ const dashes = Math.max(0, inner - visibleLen(prefix));
1508
+ const top = border(`\u250C${prefix}${"\u2500".repeat(dashes)}\u2510`);
1509
+ const bottom = border(`\u2514${"\u2500".repeat(inner)}\u2518`);
1510
+ return [top, border("\u2502") + padEndVisible(text, inner) + border("\u2502"), bottom].join("\n");
1511
+ }
1512
+ function besideMark(mark, text, markCol = 14, gap = 2) {
1513
+ const rows = Math.max(mark.length, text.length);
1514
+ const out = [];
1515
+ for (let i = 0; i < rows; i++) {
1516
+ const m = padEndVisible(mark[i] ?? "", markCol);
1517
+ const t = text[i] ?? "";
1518
+ out.push(m + " ".repeat(gap) + t);
1519
+ }
1520
+ return out;
1521
+ }
1522
+ function popularCommand(command, desc, cmdWidth = 14) {
1523
+ return padEndVisible(cmd(command), cmdWidth) + muted(desc);
1524
+ }
1525
+
1526
+ // src/shell/home-screen.ts
1527
+ function daysUntil2(dateStr) {
1528
+ const diff = new Date(dateStr).getTime() - Date.now();
1529
+ return Math.max(0, Math.ceil(diff / (1e3 * 60 * 60 * 24)));
1530
+ }
1531
+ function formatKeyExpiry(dateStr) {
1532
+ if (!dateStr) return null;
1533
+ const days = daysUntil2(dateStr);
1534
+ return `Key expires: ${days} days remaining`;
1535
+ }
1536
+ function formatKeyExpiryDate(dateStr) {
1537
+ if (!dateStr) return null;
1538
+ return `(${dateStr.slice(0, 10)})`;
1539
+ }
1540
+ async function fetchHomeData(profile) {
1531
1541
  let changelog = [];
1532
1542
  try {
1533
1543
  changelog = JSON.parse(await readAsset("changelog.json"));
1534
1544
  } catch {
1535
1545
  changelog = [];
1536
1546
  }
1537
- return { profile, cwd: cwd4, changelog };
1538
- }
1539
- function isHomeDir(dir) {
1540
- return dir === homedir3() || dir === homedir3().replace(/\/$/, "");
1547
+ let stats = null;
1548
+ if (profile) {
1549
+ try {
1550
+ const client = await getAuthenticatedClient();
1551
+ const rateRaw = await runRateLimitStatus(client);
1552
+ let rateHour = null;
1553
+ if (rateRaw) {
1554
+ const m = rateRaw.match(/(\d+)\/(\d+)/);
1555
+ if (m) {
1556
+ const remaining = Number(m[1]);
1557
+ const limit2 = Number(m[2]);
1558
+ const used2 = limit2 - remaining;
1559
+ rateHour = `${used2}/${limit2} scans this hour`;
1560
+ } else {
1561
+ rateHour = rateRaw;
1562
+ }
1563
+ }
1564
+ const used = profile.scans_used_this_month ?? profile.total_scans;
1565
+ const limit = profile.monthly_scan_limit;
1566
+ const usageMonth = used != null && limit != null ? `${used}/${limit} scans this month` : used != null ? `${used} scans total` : null;
1567
+ stats = { rateHour, usageMonth };
1568
+ } catch {
1569
+ stats = null;
1570
+ }
1571
+ }
1572
+ return { profile, changelog, stats };
1541
1573
  }
1542
- function renderLeftColumn(data) {
1543
- const colW = Math.max(28, Math.floor(contentWidth() * 0.34));
1544
- const name = data.profile?.full_name || data.profile?.email?.split("@")[0] || "there";
1545
- const org = data.profile?.organization?.trim();
1546
- const mark = centerInColumn(accent(renderFuzziMark()), colW);
1547
- const lines = [];
1574
+ function renderAccountColumn(data) {
1575
+ const mark = accent(renderFuzziMark()).split("\n");
1548
1576
  if (data.profile) {
1549
- lines.push(
1550
- accentBold(`Welcome back ${name}!`),
1551
- "",
1552
- mark,
1553
- "",
1554
- [accent("\u25CF Connected"), muted("\xB7"), info("API Key auth"), org ? muted("\xB7 " + org) : ""].filter(Boolean).join(" "),
1555
- muted(data.profile.email),
1556
- data.profile.role ? muted(`Role: ${data.profile.role}`) : "",
1577
+ const p = data.profile;
1578
+ const name = p.full_name || p.email.split("@")[0];
1579
+ const keyExpiry = formatKeyExpiry(p.key_expires_at ?? void 0);
1580
+ const keyDate = formatKeyExpiryDate(p.key_expires_at ?? void 0);
1581
+ const textBlock2 = [
1582
+ primaryBold(`Welcome back, ${name}!`),
1583
+ primary(p.email),
1557
1584
  "",
1558
- muted(data.cwd),
1559
- "",
1560
- accent("/scan") + muted(" <url> scan a target"),
1561
- accent("/scans") + muted(" browse history"),
1562
- accent("/status") + muted(" account info"),
1563
- accent("/keys") + muted(" manage keys"),
1564
- accent("/palette") + muted(" find commands")
1565
- );
1566
- } else {
1567
- lines.push(
1568
- accentBold("Welcome to Fuzzi!"),
1569
- "",
1570
- mark,
1571
- "",
1572
- muted("Not connected"),
1573
- info("Press Enter to sign in"),
1574
- "",
1575
- muted(data.cwd),
1576
- "",
1577
- accent("/auth") + muted(" browser sign-in"),
1578
- accent("/auth-key") + muted(" paste API key"),
1579
- accent("/scan") + muted(" <url> after login"),
1580
- accent("/help") + muted(" all commands")
1581
- );
1585
+ muted(`Organization: ${p.organization || "\u2014"}`),
1586
+ muted(`Role: ${p.role}`)
1587
+ ];
1588
+ const beside = besideMark(mark, textBlock2, 14, 4);
1589
+ const lines = ["", ...beside, ""];
1590
+ lines.push(success("\u25CF Connected"));
1591
+ lines.push(muted("Status: Ready"));
1592
+ if (data.stats?.rateHour) lines.push(muted(`Rate limit: ${data.stats.rateHour}`));
1593
+ if (data.stats?.usageMonth) lines.push(muted(`Usage: ${data.stats.usageMonth}`));
1594
+ if (keyExpiry) {
1595
+ lines.push("");
1596
+ lines.push(muted(keyExpiry));
1597
+ if (keyDate) lines.push(muted(keyDate));
1598
+ }
1599
+ return lines;
1582
1600
  }
1583
- return lines.filter((l) => l !== "").join("\n");
1601
+ const textBlock = [
1602
+ primaryBold("Welcome to Fuzzi!"),
1603
+ muted("Not connected"),
1604
+ "",
1605
+ muted("Press Enter to sign in"),
1606
+ muted("or run /auth-key")
1607
+ ];
1608
+ return ["", ...besideMark(mark, textBlock, 14, 4), ""];
1584
1609
  }
1585
- function renderTipsColumn(data) {
1610
+ function renderQuickStartColumn(data) {
1586
1611
  const lines = [
1587
- accentBold("Tips for getting started"),
1588
1612
  "",
1589
- `Run ${accent("/scan")}${muted(" <url>")} to scan a site for security risks`,
1590
- `Run ${accent("/palette")} to search every available command`,
1591
- `Run ${accent("/help")} for the full command reference`,
1592
- ""
1613
+ muted("Type ") + cmd("/help") + muted(" for all"),
1614
+ muted("commands."),
1615
+ "",
1616
+ primaryBold("Popular commands:"),
1617
+ "",
1618
+ popularCommand("/scan", "Start a security scan", 12),
1619
+ popularCommand("/scans", "Browse recent scans", 12),
1620
+ popularCommand("/status", "Show account info", 12)
1593
1621
  ];
1594
- if (!data.profile) {
1595
- lines.push(
1596
- muted("Note: You launched without credentials."),
1597
- muted("Press Enter at the prompt to open your browser,"),
1598
- muted("or use /auth-key to paste an API key."),
1599
- ""
1600
- );
1601
- } else if (isHomeDir(data.cwd)) {
1622
+ if (data.profile) {
1602
1623
  lines.push(
1603
- muted("Note: You launched fuzzi in your home directory."),
1604
- muted("cd into a project folder first for better context,"),
1605
- muted("or pass URLs directly: /scan https://example.com"),
1606
- ""
1624
+ popularCommand("/keys", "Manage API keys", 12),
1625
+ popularCommand("/config", "CLI settings", 12)
1607
1626
  );
1608
1627
  } else {
1609
1628
  lines.push(
1610
- muted("Note: Add a .fuzzirc in this directory to set default"),
1611
- muted("scan URL, environment, and output format for the team."),
1612
- ""
1629
+ popularCommand("/auth", "Browser sign-in", 12),
1630
+ popularCommand("/auth-key", "Paste API key", 12)
1613
1631
  );
1614
1632
  }
1615
- lines.push(
1616
- muted("CI usage: "),
1617
- muted("fuzzi scan <url> --fail-on critical --format json")
1618
- );
1619
- return lines.join("\n");
1633
+ return lines;
1620
1634
  }
1621
1635
  function renderWhatsNewColumn(data) {
1622
- const latest = data.changelog[0];
1623
- const lines = [accentBold("What's new"), ""];
1624
- if (latest) {
1625
- for (const h of latest.highlights.slice(0, 4)) {
1626
- lines.push(muted(h));
1627
- }
1628
- lines.push("");
1629
- lines.push(italic(muted("/changelog for more")));
1630
- } else {
1631
- lines.push(muted("Stay tuned for updates."));
1636
+ const lines = [""];
1637
+ const highlights = data.changelog[0]?.highlights ?? [
1638
+ "Confidence gating added",
1639
+ "Netflix-style false positives fixed",
1640
+ "CLI shell interface"
1641
+ ];
1642
+ for (const h of highlights.slice(0, 4)) {
1643
+ const text = h.replace(/^✓\s*/, "");
1644
+ lines.push(success("\u2713 ") + primary(text));
1632
1645
  }
1633
- return lines.join("\n");
1646
+ lines.push("");
1647
+ lines.push(muted("Run ") + cmd("/changelog") + muted(" for more details."));
1648
+ return lines;
1649
+ }
1650
+ function renderTip(data) {
1651
+ if (data.profile) {
1652
+ return muted("Tip: Run ") + cmd("/scan <url>") + muted(" to scan a target, or ") + cmd("/palette") + muted(" to find commands.");
1653
+ }
1654
+ 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.");
1634
1655
  }
1635
1656
  function renderHomeScreen(data) {
1636
- return splitHomePanel({
1637
- title: `Fuzzi CLI v${VERSION}`,
1638
- left: renderLeftColumn(data),
1639
- rightTop: renderTipsColumn(data),
1640
- rightBottom: renderWhatsNewColumn(data),
1641
- leftRatio: 0.36
1642
- });
1657
+ const width = contentWidth();
1658
+ const cols = [
1659
+ { title: "Account", lines: renderAccountColumn(data) },
1660
+ { title: "Quick Start", lines: renderQuickStartColumn(data) },
1661
+ { title: "What's New", lines: renderWhatsNewColumn(data) }
1662
+ ];
1663
+ const main2 = terminalWidth() >= 100 ? tripleColumnPanel(cols, width) : stackedPanels(cols, width);
1664
+ return [topBanner(`Fuzzi CLI v${VERSION}`, width), "", main2, "", tipPanel(renderTip(data), width)].join(
1665
+ "\n"
1666
+ );
1643
1667
  }
1644
1668
  function renderChangelog(entries) {
1645
1669
  if (!entries.length) return muted("No changelog entries.");
1646
1670
  return entries.map((e) => {
1647
1671
  const lines = [
1648
1672
  accentBold(`v${e.version}`) + muted(` \u2014 ${e.date}`),
1649
- ...e.highlights.map((h) => muted(` \xB7 ${h}`))
1673
+ ...e.highlights.map((h) => success("\u2713 ") + primary(h.replace(/^✓\s*/, "")))
1650
1674
  ];
1651
1675
  return lines.join("\n");
1652
1676
  }).join("\n\n");
1653
1677
  }
1678
+ function renderAuthGateScreen() {
1679
+ return renderHomeScreen({
1680
+ profile: null,
1681
+ changelog: [],
1682
+ stats: null
1683
+ });
1684
+ }
1654
1685
 
1655
1686
  // src/shell/slash-commands.ts
1656
1687
  init_api_client();
@@ -1668,6 +1699,9 @@ function emptyState(title, hint, action) {
1668
1699
  return lines.join("\n");
1669
1700
  }
1670
1701
 
1702
+ // src/commands/keys.ts
1703
+ init_brand();
1704
+
1671
1705
  // src/terminal/interactive.ts
1672
1706
  init_theme();
1673
1707
  import { select, search } from "@inquirer/prompts";
@@ -1702,7 +1736,7 @@ async function runKeysListCommand(client) {
1702
1736
  const data = await client.get("/keys");
1703
1737
  const keys = data.results || [];
1704
1738
  if (!keys.length) {
1705
- return emptyState("No API keys", "Create one at app.fuzzi.dev/settings/api-keys", "[n] new key in this view");
1739
+ return emptyState("No API keys", `Create one at ${SETTINGS_API_KEYS_URL}`, "[n] new key in this view");
1706
1740
  }
1707
1741
  const rows = keys.map((k) => [
1708
1742
  k.name,
@@ -1750,9 +1784,9 @@ var SLASH_COMMANDS = [
1750
1784
  { name: "/exit", description: "Exit the shell", aliases: ["/quit"] }
1751
1785
  ];
1752
1786
  function findCommand(input5) {
1753
- const cmd = input5.trim().split(/\s/)[0].toLowerCase();
1787
+ const cmd2 = input5.trim().split(/\s/)[0].toLowerCase();
1754
1788
  return SLASH_COMMANDS.find(
1755
- (c) => c.name === cmd || c.aliases?.some((a) => a === cmd)
1789
+ (c) => c.name === cmd2 || c.aliases?.some((a) => a === cmd2)
1756
1790
  );
1757
1791
  }
1758
1792
 
@@ -1871,9 +1905,9 @@ async function dispatchSlashCommand(line, ctx) {
1871
1905
  const trimmed = line.trim();
1872
1906
  if (!trimmed) return {};
1873
1907
  if (trimmed === "/exit" || trimmed === "/quit") return { exit: true };
1874
- const [cmd, ...rest] = trimmed.split(/\s+/);
1908
+ const [cmd2, ...rest] = trimmed.split(/\s+/);
1875
1909
  const arg = rest.join(" ").trim();
1876
- if (!cmd.startsWith("/")) {
1910
+ if (!cmd2.startsWith("/")) {
1877
1911
  ctx.sink.write(
1878
1912
  errorBox(
1879
1913
  `Not a shell command: ${trimmed}`,
@@ -1883,12 +1917,12 @@ ${accent("/help")} lists everything`
1883
1917
  );
1884
1918
  return {};
1885
1919
  }
1886
- if (!findCommand(cmd) && cmd.startsWith("/")) {
1887
- ctx.sink.write(errorBox(`Unknown command: ${cmd}`, "Type /help or /palette"));
1920
+ if (!findCommand(cmd2) && cmd2.startsWith("/")) {
1921
+ ctx.sink.write(errorBox(`Unknown command: ${cmd2}`, "Type /help or /palette"));
1888
1922
  return {};
1889
1923
  }
1890
1924
  try {
1891
- switch (cmd.toLowerCase()) {
1925
+ switch (cmd2.toLowerCase()) {
1892
1926
  case "/help":
1893
1927
  ctx.sink.write(renderHelpScreen());
1894
1928
  break;
@@ -1982,9 +2016,9 @@ ${muted("Rate limit")} ${rate}` : status);
1982
2016
  }
1983
2017
  case "/login":
1984
2018
  case "/auth": {
1985
- ctx.sink.write(await runAuthLogin({ interactive: true, browser: true }));
1986
- const client = await getAuthenticatedClient();
1987
- return { profile: await client.get("/me"), redraw: true };
2019
+ const result = await runAssistedBrowserLogin();
2020
+ ctx.sink.write(result.message);
2021
+ return { profile: result.profile, redraw: true };
1988
2022
  }
1989
2023
  case "/auth-key": {
1990
2024
  ctx.sink.write(await runApiKeyLogin({ interactive: true }));
@@ -1992,7 +2026,7 @@ ${muted("Rate limit")} ${rate}` : status);
1992
2026
  return { profile: await client.get("/me"), redraw: true };
1993
2027
  }
1994
2028
  default:
1995
- ctx.sink.write(errorBox(`Unknown command: ${cmd}`, "Type /help"));
2029
+ ctx.sink.write(errorBox(`Unknown command: ${cmd2}`, "Type /help"));
1996
2030
  }
1997
2031
  } catch (e) {
1998
2032
  ctx.sink.error(formatApiError(e));
@@ -2063,7 +2097,6 @@ async function runKeysInteractive(ctx) {
2063
2097
 
2064
2098
  // src/shell/prompt-loop.ts
2065
2099
  init_theme();
2066
- import { cwd as cwd3 } from "process";
2067
2100
 
2068
2101
  // src/shell/completer.ts
2069
2102
  function buildCompleter(commands, history) {
@@ -2081,8 +2114,6 @@ function buildCompleter(commands, history) {
2081
2114
 
2082
2115
  // src/shell/prompt-loop.ts
2083
2116
  init_capabilities();
2084
- init_layout();
2085
- init_logger();
2086
2117
  async function runPromptLoop(initialProfile) {
2087
2118
  let profile = initialProfile;
2088
2119
  const workDir = cwd3();
@@ -2091,13 +2122,8 @@ async function runPromptLoop(initialProfile) {
2091
2122
  if (getCapabilities().interactive) {
2092
2123
  process.stdout.write("\x1B[2J\x1B[H");
2093
2124
  }
2094
- const data = await fetchHomeData(profile, workDir);
2125
+ const data = await fetchHomeData(profile);
2095
2126
  console.log(renderHomeScreen(data));
2096
- console.log(statusBar([
2097
- profile ? muted(profile.email) : muted("guest"),
2098
- dim(workDir),
2099
- isDebugMode() ? muted("debug") : null
2100
- ].filter(Boolean)));
2101
2127
  console.log("");
2102
2128
  };
2103
2129
  await refresh();
@@ -2113,7 +2139,7 @@ async function runPromptLoop(initialProfile) {
2113
2139
  rl.close();
2114
2140
  process.exit(0);
2115
2141
  });
2116
- const prompt = () => process.stdout.write(accent("\u203A "));
2142
+ const prompt = () => process.stdout.write(accent("> "));
2117
2143
  prompt();
2118
2144
  for await (const line of rl) {
2119
2145
  await appendHistory(line);
@@ -2142,11 +2168,9 @@ async function runPromptLoop(initialProfile) {
2142
2168
  }
2143
2169
 
2144
2170
  // src/shell/auth-gate.ts
2145
- init_layout();
2146
- init_theme();
2147
- init_width();
2148
2171
  import * as readline2 from "readline";
2149
2172
  import { stdin as input4, stdout as output2 } from "process";
2173
+ init_theme();
2150
2174
 
2151
2175
  // src/cli/profile.ts
2152
2176
  init_credentials();
@@ -2165,50 +2189,10 @@ async function tryGetProfile() {
2165
2189
  }
2166
2190
 
2167
2191
  // src/shell/auth-gate.ts
2168
- init_brand();
2169
- function renderAuthGate() {
2170
- const colW = Math.max(28, Math.floor(contentWidth() * 0.36));
2171
- const mark = centerInColumn(accent(renderFuzziMark()), colW);
2172
- const left = [
2173
- accentBold("Welcome to Fuzzi!"),
2174
- "",
2175
- mark,
2176
- "",
2177
- muted("Not connected"),
2178
- info("Sign in to run scans"),
2179
- "",
2180
- accent("/auth-key") + muted(" paste API key"),
2181
- accent("/help") + muted(" commands")
2182
- ].join("\n");
2183
- const rightTop = [
2184
- accentBold("Sign in to continue"),
2185
- "",
2186
- info("Press Enter to open your browser"),
2187
- muted("and authorize the CLI."),
2188
- "",
2189
- muted("A local server receives the callback"),
2190
- muted("from app.fuzzi.dev automatically.")
2191
- ].join("\n");
2192
- const rightBottom = [
2193
- accentBold("Other options"),
2194
- "",
2195
- muted("Paste an API key with /auth-key"),
2196
- muted("from Settings \u2192 API Keys on the web."),
2197
- "",
2198
- italic(muted("docs: app.fuzzi.dev/settings/api-keys"))
2199
- ].join("\n");
2200
- return splitHomePanel({
2201
- title: `Fuzzi CLI v${VERSION}`,
2202
- left,
2203
- rightTop,
2204
- rightBottom,
2205
- leftRatio: 0.36
2206
- });
2207
- }
2208
2192
  function waitForEnter() {
2209
2193
  return new Promise((resolve) => {
2210
2194
  const rl = readline2.createInterface({ input: input4, output: output2, terminal: true });
2211
- output2.write(accent("\n \u203A Press Enter to open browser... "));
2195
+ output2.write(accent("\n> Press Enter to open browser... "));
2212
2196
  rl.once("line", () => {
2213
2197
  rl.close();
2214
2198
  resolve();
@@ -2219,18 +2203,15 @@ async function runAuthGate() {
2219
2203
  const existing = await tryGetProfile();
2220
2204
  if (existing) return existing;
2221
2205
  if (!output2.isTTY) return null;
2222
- console.log(renderAuthGate());
2206
+ console.log(renderAuthGateScreen());
2223
2207
  await waitForEnter();
2224
- const progress = createProgress("Opening browser...");
2225
2208
  try {
2226
- const result = await runBrowserLogin();
2227
- progress.succeed("Signed in");
2228
- console.log(accent(result.message));
2209
+ const result = await runAssistedBrowserLogin();
2210
+ console.log(result.message);
2229
2211
  return result.profile;
2230
2212
  } catch (e) {
2231
- progress.fail("Sign-in failed");
2232
2213
  console.log(muted(formatApiError(e)));
2233
- console.log(muted("Use /auth-key to paste an API key, or /auth to retry."));
2214
+ console.log(muted("Run /auth-key to paste your API key manually."));
2234
2215
  return null;
2235
2216
  }
2236
2217
  }