fuzzi-cli 0.1.0 → 0.1.2

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,7 +9,7 @@ var __export = (target, all) => {
9
9
  };
10
10
 
11
11
  // src/types/brand.ts
12
- var BRAND, RISK_COLORS, VERSION, DEFAULT_API_URL;
12
+ var BRAND, RISK_COLORS, VERSION, APP_ORIGIN, DEFAULT_API_URL;
13
13
  var init_brand = __esm({
14
14
  "src/types/brand.ts"() {
15
15
  "use strict";
@@ -27,8 +27,9 @@ var init_brand = __esm({
27
27
  HIGH: "#EF4444",
28
28
  CRITICAL: "#A855F7"
29
29
  };
30
- VERSION = "0.1.0";
31
- DEFAULT_API_URL = "https://app.fuzzi.dev/api";
30
+ VERSION = "0.1.2";
31
+ APP_ORIGIN = "https://app.fuzzi.dev";
32
+ DEFAULT_API_URL = `${APP_ORIGIN}/api`;
32
33
  }
33
34
  });
34
35
 
@@ -396,6 +397,9 @@ function getCapabilities() {
396
397
  };
397
398
  return cached;
398
399
  }
400
+ function resetCapabilities() {
401
+ cached = null;
402
+ }
399
403
  var cached;
400
404
  var init_capabilities = __esm({
401
405
  "src/terminal/capabilities.ts"() {
@@ -434,7 +438,10 @@ function success(text) {
434
438
  function warn(text) {
435
439
  return color("#F59E0B", chalk.yellow)(text);
436
440
  }
437
- var accent, accentBold, muted, bold, dim;
441
+ function info(text) {
442
+ return color(BRAND.accent, chalk.cyan)(text);
443
+ }
444
+ var accent, accentBold, muted, bold, dim, italic;
438
445
  var init_theme = __esm({
439
446
  "src/terminal/theme.ts"() {
440
447
  "use strict";
@@ -445,14 +452,7 @@ var init_theme = __esm({
445
452
  muted = color(BRAND.textSecondary, chalk.gray);
446
453
  bold = chalk.bold;
447
454
  dim = chalk.dim;
448
- }
449
- });
450
-
451
- // src/lib/theme.ts
452
- var init_theme2 = __esm({
453
- "src/lib/theme.ts"() {
454
- "use strict";
455
- init_theme();
455
+ italic = chalk.italic;
456
456
  }
457
457
  });
458
458
 
@@ -510,33 +510,79 @@ var init_strings = __esm({
510
510
  }
511
511
  });
512
512
 
513
+ // src/terminal/width.ts
514
+ import { stdout as stdout2 } from "process";
515
+ function terminalWidth() {
516
+ resetCapabilities();
517
+ return Math.max(64, (stdout2.columns ?? 80) - 2);
518
+ }
519
+ function contentWidth() {
520
+ return terminalWidth() - 4;
521
+ }
522
+ var init_width = __esm({
523
+ "src/terminal/width.ts"() {
524
+ "use strict";
525
+ init_capabilities();
526
+ }
527
+ });
528
+
513
529
  // src/terminal/layout.ts
514
530
  import boxen from "boxen";
515
531
  function panel(content, opts = {}) {
532
+ const width = opts.fullWidth !== false ? terminalWidth() : void 0;
516
533
  return boxen(content, {
517
534
  title: opts.title ? accentBold(opts.title) : void 0,
518
535
  padding: opts.padding ?? 1,
519
536
  margin: { top: 0, bottom: opts.marginBottom ?? 1, left: 0, right: 0 },
520
- borderStyle: "round",
537
+ borderStyle: opts.borderStyle ?? "classic",
521
538
  borderColor: getCapabilities().trueColor ? BRAND.accent : void 0,
522
- titleAlignment: "left"
539
+ titleAlignment: "left",
540
+ width
523
541
  });
524
542
  }
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
+ }
525
570
  function columns(left, right, leftWidth) {
526
- const width = leftWidth ?? Math.floor(getCapabilities().width * 0.45);
571
+ const total = contentWidth();
572
+ const split = leftWidth ?? Math.floor(total * 0.48);
527
573
  const leftLines = left.split("\n");
528
574
  const rightLines = right.split("\n");
529
575
  const rows = Math.max(leftLines.length, rightLines.length);
530
576
  const out = [];
531
577
  for (let i = 0; i < rows; i++) {
532
- const l = padEndVisible(leftLines[i] ?? "", width);
578
+ const l = padEndVisible(leftLines[i] ?? "", split);
533
579
  const r = rightLines[i] ?? "";
534
580
  out.push(`${l} ${r}`);
535
581
  }
536
582
  return out.join("\n");
537
583
  }
538
584
  function divider(char = "\u2500", width) {
539
- const w = width ?? getCapabilities().width - 4;
585
+ const w = width ?? contentWidth();
540
586
  return dim(char.repeat(Math.max(20, w)));
541
587
  }
542
588
  function statusBar(parts) {
@@ -554,6 +600,15 @@ var init_layout = __esm({
554
600
  init_theme();
555
601
  init_capabilities();
556
602
  init_strings();
603
+ init_width();
604
+ }
605
+ });
606
+
607
+ // src/lib/theme.ts
608
+ var init_theme2 = __esm({
609
+ "src/lib/theme.ts"() {
610
+ "use strict";
611
+ init_theme();
557
612
  }
558
613
  });
559
614
 
@@ -648,16 +703,125 @@ init_credentials();
648
703
  init_api_client();
649
704
  init_credentials();
650
705
  init_config();
651
- init_theme2();
652
- import { password, input } from "@inquirer/prompts";
706
+ init_theme();
707
+ import { password, input, confirm } from "@inquirer/prompts";
708
+
709
+ // src/lib/browser-auth.ts
710
+ init_config();
711
+ init_credentials();
712
+ init_api_client();
713
+ init_brand();
714
+ init_logger();
715
+ import { randomBytes } from "crypto";
716
+ import { createServer } from "http";
717
+ import { exec } from "child_process";
718
+ var TIMEOUT_MS = 5 * 60 * 1e3;
719
+ function generateState() {
720
+ return randomBytes(24).toString("base64url");
721
+ }
722
+ function openBrowser(url) {
723
+ 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) => {
726
+ if (err) log.warn("could not open browser automatically", err.message);
727
+ });
728
+ }
729
+ function apiOrigin(apiUrl) {
730
+ return apiUrl.replace(/\/api\/?$/, "") || APP_ORIGIN;
731
+ }
732
+ async function runBrowserLogin() {
733
+ 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 };
802
+ }
803
+
804
+ // src/commands/auth.ts
653
805
  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
+ }
814
+ }
815
+ return runApiKeyLogin(opts);
816
+ }
817
+ async function runApiKeyLogin(opts = {}) {
654
818
  const config = await loadConfig();
655
819
  const client = new FuzziApiClient(config.api_url);
656
820
  let apiKey = opts.apiKey?.trim();
657
821
  if (!apiKey) {
658
822
  if (opts.interactive === false) {
659
823
  throw new ApiError(
660
- "No API key provided. Generate one at https://app.fuzzi.dev/settings/api-keys",
824
+ "No API key provided. Run fuzzi auth login or sign in via browser.",
661
825
  401,
662
826
  "missing_key",
663
827
  void 0,
@@ -909,9 +1073,9 @@ function parseTomlSimple(raw) {
909
1073
  }
910
1074
  return config;
911
1075
  }
912
- async function loadProjectConfig(cwd5) {
913
- const fuzzirc = join3(cwd5, ".fuzzirc");
914
- const fuzzitoml = join3(cwd5, "fuzzi.toml");
1076
+ async function loadProjectConfig(cwd4) {
1077
+ const fuzzirc = join3(cwd4, ".fuzzirc");
1078
+ const fuzzitoml = join3(cwd4, "fuzzi.toml");
915
1079
  if (existsSync(fuzzirc)) {
916
1080
  try {
917
1081
  const raw = await readFile3(fuzzirc, "utf8");
@@ -1079,8 +1243,8 @@ async function runScanCommand(client, opts) {
1079
1243
  log.debug("creating scan", { url: opts.url, env });
1080
1244
  const created = await withRetry(() => client.post("/scan", body));
1081
1245
  if (!shouldWait) {
1082
- const output2 = format === "json" ? JSON.stringify(created, null, 2) : [success("Scan started"), muted(`ID: ${created.scan_id}`), muted(created.message)].join("\n");
1083
- return { output: output2, exitCode: 0 };
1246
+ const output3 = format === "json" ? JSON.stringify(created, null, 2) : [success("Scan started"), muted(`ID: ${created.scan_id}`), muted(created.message)].join("\n");
1247
+ return { output: output3, exitCode: 0 };
1084
1248
  }
1085
1249
  const host = hostnameFromUrl(opts.url);
1086
1250
  const progress = opts.onProgress ? { update: opts.onProgress, stop: () => {
@@ -1189,9 +1353,17 @@ function exitWith(code) {
1189
1353
  function buildProgram() {
1190
1354
  const program = new Command("fuzzi").name("fuzzi").description("Fuzzi security scanner CLI \u2014 interactive shell and scriptable commands").version(VERSION);
1191
1355
  const auth = program.command("auth").description("Authentication");
1192
- auth.command("login").description("Authenticate with an API key from https://app.fuzzi.dev/settings/api-keys").option("--api-key <key>", "API key (fz_live_...)").action(async (opts) => {
1356
+ auth.command("login").description("Sign in via browser (default) or API key").option("--browser", "Sign in via browser (default when interactive)").option("--api-key <key>", "Paste an API key (fz_live_...)").action(async (opts) => {
1193
1357
  try {
1194
- console.log(await runAuthLogin({ apiKey: opts.apiKey, interactive: !opts.apiKey }));
1358
+ const useApiKey = !!opts.apiKey;
1359
+ console.log(
1360
+ await runAuthLogin({
1361
+ apiKey: opts.apiKey,
1362
+ interactive: !opts.apiKey,
1363
+ browser: !useApiKey,
1364
+ apiKeyOnly: useApiKey
1365
+ })
1366
+ );
1195
1367
  } catch (e) {
1196
1368
  handleCommandError(e);
1197
1369
  }
@@ -1300,25 +1472,27 @@ function buildProgram() {
1300
1472
  return program;
1301
1473
  }
1302
1474
 
1303
- // src/cli/bootstrap.ts
1304
- init_credentials();
1305
- init_api_client();
1306
- import { cwd as cwd4 } from "process";
1307
-
1308
1475
  // src/shell/prompt-loop.ts
1309
1476
  import * as readline from "readline/promises";
1310
1477
  import { stdin as input3, stdout as output } from "process";
1311
1478
 
1479
+ // src/shell/home-screen.ts
1480
+ import { homedir as homedir3 } from "os";
1481
+
1312
1482
  // src/shell/ascii-mark.ts
1313
1483
  function renderFuzziMark() {
1314
1484
  return [
1315
- " \u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E",
1316
- " \u2571 \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2572",
1317
- " \u2502 \u2502 FUZZI \u2502 \u2502",
1318
- " \u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502",
1319
- " \u2502 \u2593\u2593\u2593 \u2502",
1320
- " \u2572 \u2571",
1321
- " \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F"
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"
1322
1496
  ].join("\n");
1323
1497
  }
1324
1498
 
@@ -1326,6 +1500,7 @@ function renderFuzziMark() {
1326
1500
  init_brand();
1327
1501
  init_theme();
1328
1502
  init_layout();
1503
+ init_width();
1329
1504
 
1330
1505
  // src/lib/assets.ts
1331
1506
  import { readFile as readFile4 } from "fs/promises";
@@ -1352,33 +1527,119 @@ async function readAsset(name) {
1352
1527
  }
1353
1528
 
1354
1529
  // src/shell/home-screen.ts
1355
- async function fetchHomeData(profile, cwd5) {
1530
+ async function fetchHomeData(profile, cwd4) {
1356
1531
  let changelog = [];
1357
1532
  try {
1358
1533
  changelog = JSON.parse(await readAsset("changelog.json"));
1359
1534
  } catch {
1360
1535
  changelog = [];
1361
1536
  }
1362
- return { profile, cwd: cwd5, changelog };
1537
+ return { profile, cwd: cwd4, changelog };
1363
1538
  }
1364
- function renderHomeScreen(data) {
1539
+ function isHomeDir(dir) {
1540
+ return dir === homedir3() || dir === homedir3().replace(/\/$/, "");
1541
+ }
1542
+ function renderLeftColumn(data) {
1543
+ const colW = Math.max(28, Math.floor(contentWidth() * 0.34));
1365
1544
  const name = data.profile?.full_name || data.profile?.email?.split("@")[0] || "there";
1366
1545
  const org = data.profile?.organization?.trim();
1367
- const welcome = data.profile ? accentBold(`Welcome back, ${name}!`) : muted("Welcome to Fuzzi");
1368
- const mark = accent(renderFuzziMark());
1369
- const connected = data.profile ? statusBar([accent("Connected"), org ?? null].filter(Boolean)) : muted("Not connected");
1370
- const headerBody = ["", welcome, "", mark, "", connected, muted(data.cwd), ""].join("\n");
1546
+ const mark = centerInColumn(accent(renderFuzziMark()), colW);
1547
+ const lines = [];
1548
+ 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}`) : "",
1557
+ "",
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
+ );
1582
+ }
1583
+ return lines.filter((l) => l !== "").join("\n");
1584
+ }
1585
+ function renderTipsColumn(data) {
1586
+ const lines = [
1587
+ accentBold("Tips for getting started"),
1588
+ "",
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
+ ""
1593
+ ];
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)) {
1602
+ 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
+ ""
1607
+ );
1608
+ } else {
1609
+ 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
+ ""
1613
+ );
1614
+ }
1615
+ lines.push(
1616
+ muted("CI usage: "),
1617
+ muted("fuzzi scan <url> --fail-on critical --format json")
1618
+ );
1619
+ return lines.join("\n");
1620
+ }
1621
+ function renderWhatsNewColumn(data) {
1371
1622
  const latest = data.changelog[0];
1372
- const whatsNew = latest ? [...latest.highlights.slice(0, 2).map((h) => muted(`\xB7 ${h}`)), muted("/changelog for more")].join("\n") : muted("Stay tuned for updates");
1373
- const quickActions = [
1374
- accent("/scan") + muted(" <url>") + muted(" scan a target"),
1375
- accent("/scans") + muted(" browse history"),
1376
- accent("/status") + muted(" account info"),
1377
- accent("/palette") + muted(" search commands"),
1378
- accent("/help") + muted(" all commands")
1379
- ].join("\n");
1380
- const panels = panel(columns(quickActions, whatsNew, 38), { title: "Quick actions" });
1381
- return panel(headerBody, { title: `Fuzzi CLI v${VERSION}`, marginBottom: 0 }) + "\n" + panels;
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."));
1632
+ }
1633
+ return lines.join("\n");
1634
+ }
1635
+ 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
+ });
1382
1643
  }
1383
1644
  function renderChangelog(entries) {
1384
1645
  if (!entries.length) return muted("No changelog entries.");
@@ -1393,7 +1654,7 @@ function renderChangelog(entries) {
1393
1654
 
1394
1655
  // src/shell/slash-commands.ts
1395
1656
  init_api_client();
1396
- import { confirm, input as input2 } from "@inquirer/prompts";
1657
+ import { confirm as confirm2, input as input2 } from "@inquirer/prompts";
1397
1658
 
1398
1659
  // src/commands/keys.ts
1399
1660
  init_table();
@@ -1422,9 +1683,9 @@ async function searchPalette(message, choices) {
1422
1683
  try {
1423
1684
  return await search({
1424
1685
  message,
1425
- source: async (input4) => {
1426
- if (!input4) return choices;
1427
- const q = input4.toLowerCase();
1686
+ source: async (input5) => {
1687
+ if (!input5) return choices;
1688
+ const q = input5.toLowerCase();
1428
1689
  return choices.filter((c) => c.value.includes(q) || c.description?.includes(q));
1429
1690
  }
1430
1691
  });
@@ -1482,13 +1743,14 @@ var SLASH_COMMANDS = [
1482
1743
  { name: "/palette", description: "Open command palette", aliases: ["/commands"] },
1483
1744
  { name: "/changelog", description: "View release notes" },
1484
1745
  { name: "/help", description: "Show all commands" },
1485
- { name: "/auth", description: "Log in with API key", aliases: ["/login"] },
1746
+ { name: "/auth", description: "Sign in via browser", aliases: ["/login"] },
1747
+ { name: "/auth-key", description: "Paste an API key instead", usage: "/auth-key" },
1486
1748
  { name: "/clear", description: "Clear screen and refresh home" },
1487
1749
  { name: "/history", description: "Show recent commands" },
1488
1750
  { name: "/exit", description: "Exit the shell", aliases: ["/quit"] }
1489
1751
  ];
1490
- function findCommand(input4) {
1491
- const cmd = input4.trim().split(/\s/)[0].toLowerCase();
1752
+ function findCommand(input5) {
1753
+ const cmd = input5.trim().split(/\s/)[0].toLowerCase();
1492
1754
  return SLASH_COMMANDS.find(
1493
1755
  (c) => c.name === cmd || c.aliases?.some((a) => a === cmd)
1494
1756
  );
@@ -1569,14 +1831,59 @@ function successBox(message) {
1569
1831
 
1570
1832
  // src/shell/slash-commands.ts
1571
1833
  init_strings();
1834
+ function normalizeInput(line) {
1835
+ let t = line.trim();
1836
+ if (!t || t.startsWith("/")) return t;
1837
+ if (t.toLowerCase().startsWith("fuzzi ")) t = t.slice(6).trim();
1838
+ const lower = t.toLowerCase();
1839
+ const aliases = {
1840
+ "auth login": "/auth",
1841
+ auth: "/auth",
1842
+ login: "/auth",
1843
+ "auth-key": "/auth-key",
1844
+ logout: "/exit",
1845
+ help: "/help",
1846
+ exit: "/exit",
1847
+ quit: "/exit",
1848
+ clear: "/clear",
1849
+ changelog: "/changelog",
1850
+ status: "/status",
1851
+ scans: "/scans",
1852
+ keys: "/keys",
1853
+ palette: "/palette"
1854
+ };
1855
+ if (aliases[lower]) return aliases[lower];
1856
+ if (lower.startsWith("scan ")) return `/scan ${t.slice(5).trim()}`;
1857
+ if (lower.startsWith("config set ")) {
1858
+ const parts = t.slice(11).trim().split(/\s+/);
1859
+ if (parts.length >= 2) return `/config ${parts[0]}=${parts.slice(1).join(" ")}`;
1860
+ }
1861
+ if (lower.startsWith("config ")) return `/config ${t.slice(7).trim().replace(/\s+/, "=")}`;
1862
+ if (!t.includes(" ") && t.includes(".")) return `/scan ${t}`;
1863
+ return t;
1864
+ }
1865
+ function normalizeScanUrl(url) {
1866
+ const u = url.trim();
1867
+ if (!/^https?:\/\//i.test(u)) return `https://${u}`;
1868
+ return u;
1869
+ }
1572
1870
  async function dispatchSlashCommand(line, ctx) {
1573
1871
  const trimmed = line.trim();
1574
1872
  if (!trimmed) return {};
1575
1873
  if (trimmed === "/exit" || trimmed === "/quit") return { exit: true };
1576
1874
  const [cmd, ...rest] = trimmed.split(/\s+/);
1577
1875
  const arg = rest.join(" ").trim();
1578
- const def = findCommand(cmd);
1579
- if (!def && cmd.startsWith("/")) {
1876
+ if (!cmd.startsWith("/")) {
1877
+ ctx.sink.write(
1878
+ errorBox(
1879
+ `Not a shell command: ${trimmed}`,
1880
+ `Use slash commands here \u2014 e.g. ${accent("/auth")} not "fuzzi auth login"
1881
+ ${accent("/help")} lists everything`
1882
+ )
1883
+ );
1884
+ return {};
1885
+ }
1886
+ if (!findCommand(cmd) && cmd.startsWith("/")) {
1580
1887
  ctx.sink.write(errorBox(`Unknown command: ${cmd}`, "Type /help or /palette"));
1581
1888
  return {};
1582
1889
  }
@@ -1606,9 +1913,10 @@ async function dispatchSlashCommand(line, ctx) {
1606
1913
  const client = await getAuthenticatedClient();
1607
1914
  const progress = createStreamProgress(ctx.sink);
1608
1915
  const result = await runScanCommand(client, {
1609
- url: arg,
1916
+ url: normalizeScanUrl(arg),
1610
1917
  wait: true,
1611
- onProgress: progress.update
1918
+ onProgress: progress.update,
1919
+ streamProgress: true
1612
1920
  });
1613
1921
  progress.stop();
1614
1922
  ctx.sink.write(result.output);
@@ -1674,7 +1982,12 @@ ${muted("Rate limit")} ${rate}` : status);
1674
1982
  }
1675
1983
  case "/login":
1676
1984
  case "/auth": {
1677
- ctx.sink.write(await runAuthLogin({ interactive: true }));
1985
+ ctx.sink.write(await runAuthLogin({ interactive: true, browser: true }));
1986
+ const client = await getAuthenticatedClient();
1987
+ return { profile: await client.get("/me"), redraw: true };
1988
+ }
1989
+ case "/auth-key": {
1990
+ ctx.sink.write(await runApiKeyLogin({ interactive: true }));
1678
1991
  const client = await getAuthenticatedClient();
1679
1992
  return { profile: await client.get("/me"), redraw: true };
1680
1993
  }
@@ -1737,7 +2050,7 @@ async function runKeysInteractive(ctx) {
1737
2050
  if (action.toLowerCase() === "r") {
1738
2051
  const keyId = await pickKeyForRevoke(client);
1739
2052
  if (!keyId) return;
1740
- const ok = await confirm({ message: "Revoke this API key?", default: false }).catch(() => false);
2053
+ const ok = await confirm2({ message: "Revoke this API key?", default: false }).catch(() => false);
1741
2054
  if (ok) ctx.sink.write(successBox(await runKeyRevoke(client, keyId)));
1742
2055
  } else if (action.toLowerCase() === "n") {
1743
2056
  const name = await promptNewKeyName();
@@ -1775,15 +2088,16 @@ async function runPromptLoop(initialProfile) {
1775
2088
  const workDir = cwd3();
1776
2089
  const history = await loadHistory();
1777
2090
  const refresh = async () => {
1778
- if (getCapabilities().interactive) console.clear();
2091
+ if (getCapabilities().interactive) {
2092
+ process.stdout.write("\x1B[2J\x1B[H");
2093
+ }
1779
2094
  const data = await fetchHomeData(profile, workDir);
1780
2095
  console.log(renderHomeScreen(data));
1781
- const bar = statusBar([
2096
+ console.log(statusBar([
1782
2097
  profile ? muted(profile.email) : muted("guest"),
1783
2098
  dim(workDir),
1784
2099
  isDebugMode() ? muted("debug") : null
1785
- ].filter(Boolean));
1786
- console.log(bar);
2100
+ ].filter(Boolean)));
1787
2101
  console.log("");
1788
2102
  };
1789
2103
  await refresh();
@@ -1809,7 +2123,8 @@ async function runPromptLoop(initialProfile) {
1809
2123
  error: (text) => console.error(text),
1810
2124
  clearLine: () => process.stdout.write("\r\x1B[K")
1811
2125
  };
1812
- const result = await dispatchSlashCommand(line, {
2126
+ const normalized = normalizeInput(line);
2127
+ const result = await dispatchSlashCommand(normalized, {
1813
2128
  cwd: workDir,
1814
2129
  profile,
1815
2130
  sink,
@@ -1826,22 +2141,16 @@ async function runPromptLoop(initialProfile) {
1826
2141
  }
1827
2142
  }
1828
2143
 
1829
- // src/shell/onboarding.ts
2144
+ // src/shell/auth-gate.ts
2145
+ init_layout();
1830
2146
  init_theme();
1831
- function renderOnboarding() {
1832
- return [
1833
- muted("Authenticate to run scans and browse results."),
1834
- "",
1835
- ` 1. Generate an API key at ${accent("app.fuzzi.dev/settings/api-keys")}`,
1836
- ` 2. Run ${accent("/auth")} or ${accent("fuzzi auth login")}`,
1837
- ` 3. Scan a URL with ${accent("/scan https://example.com")}`,
1838
- "",
1839
- muted("Type /help for all commands \xB7 /palette to search")
1840
- ].join("\n");
1841
- }
2147
+ init_width();
2148
+ import * as readline2 from "readline";
2149
+ import { stdin as input4, stdout as output2 } from "process";
1842
2150
 
1843
- // src/cli/bootstrap.ts
1844
- init_layout();
2151
+ // src/cli/profile.ts
2152
+ init_credentials();
2153
+ init_api_client();
1845
2154
  init_logger();
1846
2155
  async function tryGetProfile() {
1847
2156
  try {
@@ -1854,13 +2163,83 @@ async function tryGetProfile() {
1854
2163
  return null;
1855
2164
  }
1856
2165
  }
2166
+
2167
+ // 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
+ function waitForEnter() {
2209
+ return new Promise((resolve) => {
2210
+ const rl = readline2.createInterface({ input: input4, output: output2, terminal: true });
2211
+ output2.write(accent("\n \u203A Press Enter to open browser... "));
2212
+ rl.once("line", () => {
2213
+ rl.close();
2214
+ resolve();
2215
+ });
2216
+ });
2217
+ }
2218
+ async function runAuthGate() {
2219
+ const existing = await tryGetProfile();
2220
+ if (existing) return existing;
2221
+ if (!output2.isTTY) return null;
2222
+ console.log(renderAuthGate());
2223
+ await waitForEnter();
2224
+ const progress = createProgress("Opening browser...");
2225
+ try {
2226
+ const result = await runBrowserLogin();
2227
+ progress.succeed("Signed in");
2228
+ console.log(accent(result.message));
2229
+ return result.profile;
2230
+ } catch (e) {
2231
+ progress.fail("Sign-in failed");
2232
+ console.log(muted(formatApiError(e)));
2233
+ console.log(muted("Use /auth-key to paste an API key, or /auth to retry."));
2234
+ return null;
2235
+ }
2236
+ }
2237
+
2238
+ // src/cli/bootstrap.ts
1857
2239
  async function runInteractiveMode() {
1858
- const profile = await tryGetProfile();
1859
- const workDir = cwd4();
1860
- const data = await fetchHomeData(profile, workDir);
1861
- console.log(renderHomeScreen(data));
2240
+ let profile = await tryGetProfile();
1862
2241
  if (!profile) {
1863
- console.log(panel(renderOnboarding(), { title: "Get started" }));
2242
+ profile = await runAuthGate();
1864
2243
  }
1865
2244
  await runPromptLoop(profile);
1866
2245
  }