@ship-safe/cli 1.0.2 → 1.1.0

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.
Files changed (2) hide show
  1. package/dist/index.js +271 -148
  2. package/package.json +4 -3
package/dist/index.js CHANGED
@@ -11,8 +11,9 @@ import { Command, InvalidArgumentError, Option } from "commander";
11
11
  // src/commands/scan.ts
12
12
  import { resolve as resolve2, join as join4 } from "path";
13
13
  import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
14
- import chalk4 from "chalk";
14
+ import chalk5 from "chalk";
15
15
  import ora2 from "ora";
16
+ import { select } from "@inquirer/prompts";
16
17
  import { parse as parseYaml } from "yaml";
17
18
 
18
19
  // ../../node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/external.js
@@ -6903,144 +6904,215 @@ function collectFiles(rootDir, options) {
6903
6904
  }
6904
6905
 
6905
6906
  // src/lib/output.ts
6907
+ import chalk2 from "chalk";
6908
+
6909
+ // src/lib/banner.ts
6906
6910
  import chalk from "chalk";
6907
- var SEVERITY_LABEL = {
6908
- critical: chalk.bgRed.white.bold(" CRITICAL "),
6909
- high: chalk.bgYellow.black.bold(" HIGH "),
6910
- medium: chalk.bgHex("#ca8a04").black.bold(" MEDIUM "),
6911
- low: chalk.bgBlue.white.bold(" LOW "),
6912
- info: chalk.bgGray.white(" INFO ")
6911
+ var SHIELD = chalk.hex("#FF6B2B");
6912
+ var DIM_ORANGE = chalk.hex("#FF8C42");
6913
+ var BRAND2 = chalk.hex("#FF6B2B").bold;
6914
+ function printBanner() {
6915
+ console.log("");
6916
+ console.log(
6917
+ SHIELD(" \u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E")
6918
+ );
6919
+ console.log(
6920
+ SHIELD(" \u2502") + chalk.bold.white(" \u{1F6E1}\uFE0F ShipSafe Security Scanner ") + SHIELD("\u2502")
6921
+ );
6922
+ console.log(
6923
+ SHIELD(" \u2502") + chalk.dim(" Ship fast. Ship safe. ") + SHIELD("\u2502")
6924
+ );
6925
+ console.log(
6926
+ SHIELD(" \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F")
6927
+ );
6928
+ console.log("");
6929
+ }
6930
+ function printDivider(width = 50) {
6931
+ console.log(chalk.dim(" " + "\u2500".repeat(width)));
6932
+ }
6933
+ function printError(text) {
6934
+ console.log(chalk.red(` \u2717 ${text}`));
6935
+ }
6936
+ function printInfo(text) {
6937
+ console.log(chalk.dim(` ${text}`));
6938
+ }
6939
+ function printScanSummaryBox(stats) {
6940
+ const label = chalk.dim;
6941
+ const value = chalk.white.bold;
6942
+ console.log("");
6943
+ printDivider();
6944
+ console.log("");
6945
+ console.log(
6946
+ ` ${label("Files:")} ${value(String(stats.files))} ${chalk.dim("\xB7")} ${label("Lines:")} ${value(stats.lines.toLocaleString())} ${chalk.dim("\xB7")} ${label("Time:")} ${value(`${stats.duration}ms`)}`
6947
+ );
6948
+ console.log("");
6949
+ if (stats.findings === 0) {
6950
+ console.log(chalk.green.bold(" \u2713 No security issues found!"));
6951
+ } else {
6952
+ const parts = [];
6953
+ if (stats.critical > 0) parts.push(chalk.red.bold(`${stats.critical} critical`));
6954
+ if (stats.high > 0) parts.push(chalk.yellow.bold(`${stats.high} high`));
6955
+ if (stats.medium > 0) parts.push(chalk.hex("#ca8a04").bold(`${stats.medium} medium`));
6956
+ if (stats.low > 0) parts.push(chalk.blue(`${stats.low} low`));
6957
+ console.log(
6958
+ ` ${chalk.bold.white(`${stats.findings} issues found:`)} ${parts.join(chalk.dim(" \xB7 "))}`
6959
+ );
6960
+ }
6961
+ console.log("");
6962
+ printDivider();
6963
+ }
6964
+ function printUpsellBox(criticalHigh) {
6965
+ console.log("");
6966
+ console.log(DIM_ORANGE(" \u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E"));
6967
+ console.log(
6968
+ DIM_ORANGE(" \u2502") + BRAND2(" \u{1F512} Unlock AI-Powered Deep Analysis ") + DIM_ORANGE("\u2502")
6969
+ );
6970
+ console.log(DIM_ORANGE(" \u2502 \u2502"));
6971
+ if (criticalHigh > 0) {
6972
+ console.log(
6973
+ DIM_ORANGE(" \u2502 ") + chalk.white(`Found ${criticalHigh} critical/high issues. AI finds`) + " ".repeat(Math.max(0, 46 - `Found ${criticalHigh} critical/high issues. AI finds`.length)) + DIM_ORANGE("\u2502")
6974
+ );
6975
+ console.log(
6976
+ DIM_ORANGE(" \u2502 ") + chalk.dim("auth logic flaws, business logic bugs & more.") + " ".repeat(3) + DIM_ORANGE("\u2502")
6977
+ );
6978
+ } else {
6979
+ console.log(
6980
+ DIM_ORANGE(" \u2502 ") + chalk.dim("Surface scan clean. AI finds auth logic,") + " ".repeat(8) + DIM_ORANGE("\u2502")
6981
+ );
6982
+ console.log(
6983
+ DIM_ORANGE(" \u2502 ") + chalk.dim("RLS policies & business logic bugs.") + " ".repeat(13) + DIM_ORANGE("\u2502")
6984
+ );
6985
+ }
6986
+ console.log(DIM_ORANGE(" \u2502 \u2502"));
6987
+ console.log(
6988
+ DIM_ORANGE(" \u2502 ") + chalk.white("\u2192 Run ") + BRAND2("shipsafe login") + chalk.white(" to get started") + " ".repeat(13) + DIM_ORANGE("\u2502")
6989
+ );
6990
+ console.log(DIM_ORANGE(" \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F"));
6991
+ }
6992
+
6993
+ // src/lib/output.ts
6994
+ var SEVERITY_BADGE = {
6995
+ critical: chalk2.bgRed.white.bold(" CRITICAL "),
6996
+ high: chalk2.bgYellow.black.bold(" HIGH "),
6997
+ medium: chalk2.bgHex("#ca8a04").black.bold(" MEDIUM "),
6998
+ low: chalk2.bgBlue.white.bold(" LOW "),
6999
+ info: chalk2.bgGray.white(" INFO ")
6913
7000
  };
6914
7001
  var SEVERITY_COLOR = {
6915
- critical: chalk.red,
6916
- high: chalk.yellow,
6917
- medium: chalk.hex("#ca8a04"),
6918
- low: chalk.blue,
6919
- info: chalk.gray
7002
+ critical: chalk2.red,
7003
+ high: chalk2.yellow,
7004
+ medium: chalk2.hex("#ca8a04"),
7005
+ low: chalk2.blue,
7006
+ info: chalk2.gray
6920
7007
  };
6921
- function formatFinding(finding, index) {
7008
+ function formatFinding(finding) {
6922
7009
  const lines = [];
6923
7010
  const color = SEVERITY_COLOR[finding.severity];
6924
7011
  lines.push(
6925
- `
6926
- ${SEVERITY_LABEL[finding.severity]} ${chalk.bold(finding.title)}`
7012
+ `${SEVERITY_BADGE[finding.severity]} ${chalk2.bold.white(finding.title)}`
6927
7013
  );
6928
7014
  lines.push(
6929
- chalk.gray(` ${finding.file}:${finding.line}`) + (finding.cwe ? chalk.gray(` (${finding.cwe})`) : "")
7015
+ chalk2.dim(" \u{1F4C4} ") + chalk2.cyan(finding.file) + chalk2.dim(`:${finding.line}`) + (finding.cwe ? chalk2.dim(` \xB7 ${finding.cwe}`) : "")
6930
7016
  );
6931
- lines.push(` ${finding.description}`);
7017
+ lines.push(` ${chalk2.dim(finding.description)}`);
6932
7018
  if (finding.snippet) {
6933
7019
  lines.push("");
6934
- for (const snippetLine of finding.snippet.split("\n")) {
7020
+ const snippetLines = finding.snippet.split("\n");
7021
+ for (const snippetLine of snippetLines) {
6935
7022
  if (snippetLine.startsWith(">")) {
6936
- lines.push(` ${color(snippetLine)}`);
7023
+ lines.push(` ${color.bold(snippetLine)}`);
6937
7024
  } else {
6938
- lines.push(` ${chalk.gray(snippetLine)}`);
7025
+ lines.push(` ${chalk2.gray(snippetLine)}`);
6939
7026
  }
6940
7027
  }
6941
7028
  }
6942
7029
  lines.push("");
6943
- lines.push(chalk.green(` Fix: ${finding.fix.description}`));
7030
+ lines.push(chalk2.green(` \u{1F4A1} Fix: ${finding.fix.description}`));
6944
7031
  if (finding.fix.suggestion) {
6945
- lines.push(chalk.green.dim(` Suggestion: ${finding.fix.suggestion}`));
7032
+ lines.push(chalk2.green.dim(` ${finding.fix.suggestion}`));
6946
7033
  }
6947
7034
  return lines.join("\n");
6948
7035
  }
6949
7036
  function formatTableOutput(result, options = {}) {
6950
7037
  const lines = [];
6951
- lines.push("");
6952
- lines.push(chalk.bold.cyan(" ShipSafe Security Scan Results"));
6953
- lines.push(chalk.gray(" " + "\u2500".repeat(50)));
6954
- lines.push("");
6955
7038
  const { summary } = result;
6956
- lines.push(
6957
- ` ${chalk.bold("Files scanned:")} ${summary.filesScanned} | ${chalk.bold("Lines:")} ${summary.linesScanned.toLocaleString()} | ${chalk.bold("Duration:")} ${result.durationMs}ms`
6958
- );
6959
- lines.push("");
7039
+ printScanSummaryBox({
7040
+ files: summary.filesScanned,
7041
+ lines: summary.linesScanned,
7042
+ duration: result.durationMs,
7043
+ findings: summary.total,
7044
+ critical: summary.bySeverity.critical,
7045
+ high: summary.bySeverity.high,
7046
+ medium: summary.bySeverity.medium,
7047
+ low: summary.bySeverity.low
7048
+ });
6960
7049
  if (summary.total === 0) {
6961
- lines.push(chalk.green.bold(" No security issues found!"));
6962
7050
  if (!options.isLoggedIn) {
6963
- lines.push("");
6964
7051
  lines.push(
6965
- chalk.gray(
6966
- ` No issues in surface scan. Run ${chalk.bold.white("shipsafe login")} to unlock deep AI analysis.`
7052
+ chalk2.dim(
7053
+ ` Run ${chalk2.bold.white("shipsafe login")} to unlock deep AI analysis.`
6967
7054
  )
6968
7055
  );
7056
+ lines.push("");
6969
7057
  }
6970
- lines.push("");
6971
7058
  return lines.join("\n");
6972
7059
  }
6973
- const severityLine = [
6974
- summary.bySeverity.critical > 0 ? chalk.red.bold(`${summary.bySeverity.critical} critical`) : null,
6975
- summary.bySeverity.high > 0 ? chalk.yellow.bold(`${summary.bySeverity.high} high`) : null,
6976
- summary.bySeverity.medium > 0 ? chalk.hex("#ca8a04").bold(`${summary.bySeverity.medium} medium`) : null,
6977
- summary.bySeverity.low > 0 ? chalk.blue(`${summary.bySeverity.low} low`) : null,
6978
- summary.bySeverity.info > 0 ? chalk.gray(`${summary.bySeverity.info} info`) : null
6979
- ].filter(Boolean).join(" | ");
6980
- lines.push(` ${chalk.bold("Findings:")} ${summary.total} (${severityLine})`);
6981
7060
  const sorted = [...result.findings].sort(
6982
7061
  (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]
6983
7062
  );
6984
- for (let i = 0; i < sorted.length; i++) {
6985
- lines.push(formatFinding(sorted[i], i));
7063
+ let currentSeverity = null;
7064
+ let countInSeverity = 0;
7065
+ for (const finding of sorted) {
7066
+ if (finding.severity !== currentSeverity) {
7067
+ if (currentSeverity !== null) {
7068
+ lines.push("");
7069
+ }
7070
+ currentSeverity = finding.severity;
7071
+ countInSeverity = sorted.filter(
7072
+ (f) => f.severity === currentSeverity
7073
+ ).length;
7074
+ lines.push(
7075
+ chalk2.dim(" \u2500\u2500\u2500\u2500 ") + SEVERITY_COLOR[currentSeverity].bold(
7076
+ `${currentSeverity.toUpperCase()} (${countInSeverity})`
7077
+ ) + chalk2.dim(" " + "\u2500".repeat(35))
7078
+ );
7079
+ lines.push("");
7080
+ }
7081
+ lines.push(formatFinding(finding));
7082
+ lines.push("");
6986
7083
  }
6987
7084
  if (options.diff) {
6988
7085
  const { newFindings, resolvedFindings } = options.diff;
6989
7086
  if (newFindings.length > 0 || resolvedFindings.length > 0) {
6990
- lines.push("");
6991
- lines.push(chalk.bold(" Changes since last scan:"));
7087
+ printDivider();
7088
+ lines.push(chalk2.bold.white(" \u{1F4CA} Changes since last scan:"));
6992
7089
  if (newFindings.length > 0) {
6993
7090
  lines.push(
6994
- chalk.red(` \u2B06 ${newFindings.length} new issue${newFindings.length === 1 ? "" : "s"} introduced`)
7091
+ chalk2.red(
7092
+ ` \u2191 ${newFindings.length} new issue${newFindings.length === 1 ? "" : "s"} introduced`
7093
+ )
6995
7094
  );
6996
7095
  }
6997
7096
  if (resolvedFindings.length > 0) {
6998
7097
  lines.push(
6999
- chalk.green(` \u2B07 ${resolvedFindings.length} issue${resolvedFindings.length === 1 ? "" : "s"} resolved`)
7098
+ chalk2.green(
7099
+ ` \u2193 ${resolvedFindings.length} issue${resolvedFindings.length === 1 ? "" : "s"} resolved`
7100
+ )
7000
7101
  );
7001
7102
  }
7002
7103
  }
7003
7104
  }
7004
7105
  if (options.suppressedCount && options.suppressedCount > 0) {
7005
7106
  lines.push(
7006
- chalk.gray(`
7007
- ${options.suppressedCount} finding${options.suppressedCount === 1 ? "" : "s"} suppressed via .shipsafeignore`)
7107
+ chalk2.dim(
7108
+ `
7109
+ ${options.suppressedCount} finding${options.suppressedCount === 1 ? "" : "s"} suppressed via .shipsafeignore`
7110
+ )
7008
7111
  );
7009
7112
  }
7010
- lines.push("");
7011
- lines.push(chalk.gray(" " + "\u2500".repeat(50)));
7012
7113
  if (!options.isLoggedIn && summary.total > 0) {
7013
7114
  const criticalHigh = summary.bySeverity.critical + summary.bySeverity.high;
7014
- lines.push("");
7015
- if (criticalHigh > 0) {
7016
- lines.push(
7017
- chalk.yellow(
7018
- ` Found ${criticalHigh} critical/high issue${criticalHigh === 1 ? "" : "s"}. Deep AI analysis can find auth logic flaws,`
7019
- )
7020
- );
7021
- lines.push(
7022
- chalk.yellow(
7023
- ` business logic bugs, and more \u2014 run ${chalk.bold("shipsafe login")} to unlock.`
7024
- )
7025
- );
7026
- } else {
7027
- lines.push(
7028
- chalk.gray(
7029
- ` Surface scan clean. Run ${chalk.bold.white("shipsafe login")} for deep AI analysis`
7030
- )
7031
- );
7032
- lines.push(
7033
- chalk.gray(
7034
- ` covering auth logic, RLS policies, and business logic review.`
7035
- )
7036
- );
7037
- }
7038
- } else if (!options.isLoggedIn && summary.total === 0) {
7039
- lines.push(
7040
- chalk.gray(
7041
- ` No issues in surface scan. Run ${chalk.bold.white("shipsafe login")} to unlock deep AI analysis.`
7042
- )
7043
- );
7115
+ printUpsellBox(criticalHigh);
7044
7116
  }
7045
7117
  lines.push("");
7046
7118
  return lines.join("\n");
@@ -7089,7 +7161,7 @@ function formatSarifOutput(result) {
7089
7161
  }
7090
7162
 
7091
7163
  // src/commands/login.ts
7092
- import chalk2 from "chalk";
7164
+ import chalk3 from "chalk";
7093
7165
  import ora from "ora";
7094
7166
  import { readFileSync as readFileSync2, writeFileSync, mkdirSync, existsSync as existsSync2, unlinkSync } from "fs";
7095
7167
  import { homedir } from "os";
@@ -7101,7 +7173,7 @@ function getStoredToken() {
7101
7173
  if (!existsSync2(TOKEN_FILE)) return null;
7102
7174
  const data = JSON.parse(readFileSync2(TOKEN_FILE, "utf-8"));
7103
7175
  if (data.expiresAt < Date.now()) {
7104
- console.log(chalk2.yellow("Session expired. Please login again."));
7176
+ console.log(chalk3.yellow("Session expired. Please login again."));
7105
7177
  return null;
7106
7178
  }
7107
7179
  return data;
@@ -7128,7 +7200,7 @@ async function fetchProfile(apiUrl, rawToken) {
7128
7200
  }
7129
7201
  async function loginCommand(options) {
7130
7202
  const apiUrl = options.apiUrl || "https://ship-safe.co";
7131
- console.log(chalk2.bold("\nShipSafe CLI Login\n"));
7203
+ console.log(chalk3.bold("\nShipSafe CLI Login\n"));
7132
7204
  const spinner = ora("Requesting device code...").start();
7133
7205
  try {
7134
7206
  const res = await fetch(`${apiUrl}/api/cli/device-code`, {
@@ -7138,7 +7210,7 @@ async function loginCommand(options) {
7138
7210
  if (!res.ok) {
7139
7211
  spinner.fail("Failed to request device code");
7140
7212
  console.log(
7141
- chalk2.red(
7213
+ chalk3.red(
7142
7214
  "Could not connect to ShipSafe. Make sure the server is running."
7143
7215
  )
7144
7216
  );
@@ -7146,10 +7218,10 @@ async function loginCommand(options) {
7146
7218
  }
7147
7219
  const { deviceCode, userCode, verificationUrl, expiresIn } = await res.json();
7148
7220
  spinner.stop();
7149
- console.log(chalk2.bold("To login, open this URL in your browser:\n"));
7150
- console.log(chalk2.cyan(` ${verificationUrl}
7221
+ console.log(chalk3.bold("To login, open this URL in your browser:\n"));
7222
+ console.log(chalk3.cyan(` ${verificationUrl}
7151
7223
  `));
7152
- console.log(`Enter this code: ${chalk2.bold.green(userCode)}
7224
+ console.log(`Enter this code: ${chalk3.bold.green(userCode)}
7153
7225
  `);
7154
7226
  const pollSpinner = ora("Waiting for authorization...").start();
7155
7227
  const startTime = Date.now();
@@ -7180,17 +7252,17 @@ async function loginCommand(options) {
7180
7252
  }
7181
7253
  storeToken(tokenData);
7182
7254
  pollSpinner.succeed(
7183
- chalk2.green(`Logged in as ${chalk2.bold(pollData.email)}`)
7255
+ chalk3.green(`Logged in as ${chalk3.bold(pollData.email)}`)
7184
7256
  );
7185
7257
  if (profile?.cliTier) {
7186
7258
  console.log(
7187
- chalk2.cyan(
7259
+ chalk3.cyan(
7188
7260
  ` CLI plan: ${profile.cliTier} (${profile.aiQuota.remaining} AI scans remaining)`
7189
7261
  )
7190
7262
  );
7191
7263
  } else {
7192
7264
  console.log(
7193
- chalk2.dim(
7265
+ chalk3.dim(
7194
7266
  " No CLI plan. Upgrade at ship-safe.co/pricing for AI-powered scanning."
7195
7267
  )
7196
7268
  );
@@ -7199,7 +7271,7 @@ async function loginCommand(options) {
7199
7271
  }
7200
7272
  if (pollData.status === "expired") {
7201
7273
  pollSpinner.fail(
7202
- chalk2.yellow(
7274
+ chalk3.yellow(
7203
7275
  "Your login link expired. Run `shipsafe login` again."
7204
7276
  )
7205
7277
  );
@@ -7209,7 +7281,7 @@ async function loginCommand(options) {
7209
7281
  networkErrors++;
7210
7282
  if (networkErrors >= 3) {
7211
7283
  pollSpinner.fail(
7212
- chalk2.red(
7284
+ chalk3.red(
7213
7285
  "Lost connection to ShipSafe. Check your internet and run `shipsafe login` again."
7214
7286
  )
7215
7287
  );
@@ -7219,7 +7291,7 @@ async function loginCommand(options) {
7219
7291
  }
7220
7292
  }
7221
7293
  pollSpinner.fail(
7222
- chalk2.yellow(
7294
+ chalk3.yellow(
7223
7295
  "Your login link expired. Run `shipsafe login` again."
7224
7296
  )
7225
7297
  );
@@ -7227,13 +7299,13 @@ async function loginCommand(options) {
7227
7299
  spinner.fail("Could not connect to ShipSafe");
7228
7300
  if (error instanceof Error && (error.message.includes("fetch") || error.message.includes("ECONNREFUSED"))) {
7229
7301
  console.log(
7230
- chalk2.red(
7302
+ chalk3.red(
7231
7303
  "Make sure you have an internet connection and try again."
7232
7304
  )
7233
7305
  );
7234
7306
  } else {
7235
7307
  console.log(
7236
- chalk2.red(
7308
+ chalk3.red(
7237
7309
  error instanceof Error ? error.message : "Unknown error \u2014 try again."
7238
7310
  )
7239
7311
  );
@@ -7244,18 +7316,18 @@ async function logoutCommand() {
7244
7316
  try {
7245
7317
  if (existsSync2(TOKEN_FILE)) {
7246
7318
  unlinkSync(TOKEN_FILE);
7247
- console.log(chalk2.green("Logged out successfully."));
7319
+ console.log(chalk3.green("Logged out successfully."));
7248
7320
  } else {
7249
- console.log(chalk2.yellow("Not currently logged in."));
7321
+ console.log(chalk3.yellow("Not currently logged in."));
7250
7322
  }
7251
7323
  } catch {
7252
- console.log(chalk2.red("Failed to logout."));
7324
+ console.log(chalk3.red("Failed to logout."));
7253
7325
  }
7254
7326
  }
7255
7327
  async function whoamiCommand(options) {
7256
7328
  const token = getStoredToken();
7257
7329
  if (!token) {
7258
- console.log(chalk2.yellow("Not logged in. Run `shipsafe login` to login."));
7330
+ console.log(chalk3.yellow("Not logged in. Run `shipsafe login` to login."));
7259
7331
  return;
7260
7332
  }
7261
7333
  const apiUrl = options.apiUrl || "https://ship-safe.co";
@@ -7269,26 +7341,26 @@ async function whoamiCommand(options) {
7269
7341
  aiQuota: profile.aiQuota
7270
7342
  });
7271
7343
  spinner.stop();
7272
- console.log(chalk2.bold("\nShipSafe Account\n"));
7273
- console.log(` Email: ${chalk2.cyan(profile.email)}`);
7274
- console.log(` Web tier: ${chalk2.cyan(profile.tier)}`);
7344
+ console.log(chalk3.bold("\nShipSafe Account\n"));
7345
+ console.log(` Email: ${chalk3.cyan(profile.email)}`);
7346
+ console.log(` Web tier: ${chalk3.cyan(profile.tier)}`);
7275
7347
  if (profile.cliTier) {
7276
- console.log(` CLI plan: ${chalk2.green(profile.cliTier)}`);
7348
+ console.log(` CLI plan: ${chalk3.green(profile.cliTier)}`);
7277
7349
  console.log(
7278
- ` AI scans: ${chalk2.cyan(`${profile.aiQuota.used}/${profile.aiQuota.limit}`)} used this month (${chalk2.green(`${profile.aiQuota.remaining}`)} remaining)`
7350
+ ` AI scans: ${chalk3.cyan(`${profile.aiQuota.used}/${profile.aiQuota.limit}`)} used this month (${chalk3.green(`${profile.aiQuota.remaining}`)} remaining)`
7279
7351
  );
7280
7352
  } else {
7281
- console.log(` CLI plan: ${chalk2.dim("none")}`);
7353
+ console.log(` CLI plan: ${chalk3.dim("none")}`);
7282
7354
  console.log(
7283
- chalk2.dim(" Upgrade at ship-safe.co/pricing for AI-powered scanning.")
7355
+ chalk3.dim(" Upgrade at ship-safe.co/pricing for AI-powered scanning.")
7284
7356
  );
7285
7357
  }
7286
7358
  console.log();
7287
7359
  } else {
7288
7360
  spinner.stop();
7289
- console.log(chalk2.green(`Logged in as ${chalk2.bold(token.email)}`));
7361
+ console.log(chalk3.green(`Logged in as ${chalk3.bold(token.email)}`));
7290
7362
  if (token.cliTier) {
7291
- console.log(chalk2.cyan(` CLI plan: ${token.cliTier}`));
7363
+ console.log(chalk3.cyan(` CLI plan: ${token.cliTier}`));
7292
7364
  }
7293
7365
  }
7294
7366
  }
@@ -7296,7 +7368,7 @@ async function whoamiCommand(options) {
7296
7368
  // src/commands/ignore.ts
7297
7369
  import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2, appendFileSync } from "fs";
7298
7370
  import { resolve, join as join2 } from "path";
7299
- import chalk3 from "chalk";
7371
+ import chalk4 from "chalk";
7300
7372
  var IGNORE_FILE = ".shipsafeignore";
7301
7373
  function getIgnorePath(dir) {
7302
7374
  return resolve(dir || ".", IGNORE_FILE);
@@ -7320,7 +7392,7 @@ function ignoreCommand(ruleId, options) {
7320
7392
  if (existsSync3(ignorePath)) {
7321
7393
  const existing = loadIgnoredRules(".");
7322
7394
  if (existing.has(ruleId)) {
7323
- console.log(chalk3.yellow(`Rule "${ruleId}" is already in ${IGNORE_FILE}`));
7395
+ console.log(chalk4.yellow(`Rule "${ruleId}" is already in ${IGNORE_FILE}`));
7324
7396
  return;
7325
7397
  }
7326
7398
  }
@@ -7341,18 +7413,18 @@ ${line}
7341
7413
  `
7342
7414
  );
7343
7415
  }
7344
- console.log(chalk3.green(`Added "${ruleId}" to ${IGNORE_FILE}`));
7416
+ console.log(chalk4.green(`Added "${ruleId}" to ${IGNORE_FILE}`));
7345
7417
  if (options.reason) {
7346
- console.log(chalk3.gray(` Reason: ${options.reason}`));
7418
+ console.log(chalk4.gray(` Reason: ${options.reason}`));
7347
7419
  }
7348
7420
  console.log(
7349
- chalk3.gray(` This finding will be suppressed in future scans.`)
7421
+ chalk4.gray(` This finding will be suppressed in future scans.`)
7350
7422
  );
7351
7423
  }
7352
7424
  function unignoreCommand(ruleId) {
7353
7425
  const ignorePath = getIgnorePath();
7354
7426
  if (!existsSync3(ignorePath)) {
7355
- console.log(chalk3.yellow(`No ${IGNORE_FILE} found.`));
7427
+ console.log(chalk4.yellow(`No ${IGNORE_FILE} found.`));
7356
7428
  return;
7357
7429
  }
7358
7430
  const content = readFileSync3(ignorePath, "utf-8");
@@ -7364,11 +7436,11 @@ function unignoreCommand(ruleId) {
7364
7436
  return lineRuleId !== ruleId;
7365
7437
  });
7366
7438
  if (filtered.length === lines.length) {
7367
- console.log(chalk3.yellow(`Rule "${ruleId}" is not in ${IGNORE_FILE}`));
7439
+ console.log(chalk4.yellow(`Rule "${ruleId}" is not in ${IGNORE_FILE}`));
7368
7440
  return;
7369
7441
  }
7370
7442
  writeFileSync2(ignorePath, filtered.join("\n"));
7371
- console.log(chalk3.green(`Removed "${ruleId}" from ${IGNORE_FILE}`));
7443
+ console.log(chalk4.green(`Removed "${ruleId}" from ${IGNORE_FILE}`));
7372
7444
  }
7373
7445
 
7374
7446
  // src/lib/scan-history.ts
@@ -7453,28 +7525,81 @@ function loadConfig(dir) {
7453
7525
  async function scanCommand(targetPath, options) {
7454
7526
  const resolvedPath = resolve2(targetPath);
7455
7527
  if (!existsSync5(resolvedPath)) {
7456
- console.error(chalk4.red(`Error: Path "${targetPath}" does not exist.`));
7528
+ printError(`Path "${targetPath}" does not exist.`);
7457
7529
  process.exit(1);
7458
7530
  }
7531
+ if (options.output === "table") {
7532
+ printBanner();
7533
+ }
7534
+ let tokenData = getStoredToken();
7535
+ if (!tokenData && !options.ci && options.output === "table") {
7536
+ const choice = await select({
7537
+ message: chalk5.white("How would you like to scan?"),
7538
+ choices: [
7539
+ {
7540
+ name: `${chalk5.green("\u25B8")} ${chalk5.bold("Free scan")} ${chalk5.dim("\u2014 rule-based analysis, no account needed")}`,
7541
+ value: "free"
7542
+ },
7543
+ {
7544
+ name: `${chalk5.hex("#FF6B2B")("\u25B8")} ${chalk5.bold("Login first")} ${chalk5.dim("\u2014 unlock AI-powered deep analysis")}`,
7545
+ value: "login"
7546
+ }
7547
+ ],
7548
+ theme: {
7549
+ prefix: { idle: chalk5.hex("#FF6B2B")(" \u{1F6E1}\uFE0F"), done: chalk5.green(" \u2713") },
7550
+ style: {
7551
+ highlight: (text) => chalk5.hex("#FF6B2B")(text)
7552
+ }
7553
+ }
7554
+ });
7555
+ if (choice === "login") {
7556
+ await loginCommand({ apiUrl: options.apiUrl });
7557
+ tokenData = getStoredToken();
7558
+ if (!tokenData) {
7559
+ printInfo("Login skipped. Running free scan...\n");
7560
+ } else {
7561
+ console.log("");
7562
+ }
7563
+ } else {
7564
+ console.log("");
7565
+ }
7566
+ }
7459
7567
  const config = loadConfig(resolvedPath);
7460
7568
  const severity = options.severity || config.severity || "low";
7461
- const spinner = ora2("Collecting files...").start();
7569
+ const spinner = ora2({
7570
+ text: chalk5.dim("Collecting files..."),
7571
+ color: "yellow",
7572
+ spinner: "dots12"
7573
+ }).start();
7462
7574
  const files = collectFiles(resolvedPath, { exclude: config.exclude });
7463
7575
  if (files.length === 0) {
7464
- spinner.fail("No supported files found to scan.");
7576
+ spinner.fail(chalk5.red("No supported files found to scan."));
7465
7577
  process.exit(1);
7466
7578
  }
7467
- spinner.text = `Scanning ${files.length} files...`;
7579
+ const totalLines = files.reduce(
7580
+ (sum, f) => sum + f.content.split("\n").length,
7581
+ 0
7582
+ );
7583
+ spinner.text = chalk5.dim(
7584
+ `Scanning ${chalk5.white.bold(String(files.length))} files (${chalk5.white.bold(totalLines.toLocaleString())} lines)...`
7585
+ );
7468
7586
  const result = await scan(
7469
7587
  { files, tier: "free" },
7470
7588
  (progress) => {
7471
- spinner.text = `${progress.stage} (${progress.findingsCount} findings)`;
7589
+ spinner.text = chalk5.dim(
7590
+ `${progress.stage} ${chalk5.white(`(${progress.findingsCount} findings)`)}`
7591
+ );
7472
7592
  }
7473
7593
  );
7474
- spinner.stop();
7475
- const tokenData = getStoredToken();
7594
+ spinner.succeed(
7595
+ chalk5.green(`Scan complete \u2014 ${result.findings.length} findings in ${result.durationMs}ms`)
7596
+ );
7476
7597
  if (tokenData?.cliTier) {
7477
- const aiSpinner = ora2("Running AI-powered deep analysis...").start();
7598
+ const aiSpinner = ora2({
7599
+ text: chalk5.dim("Running AI-powered deep analysis..."),
7600
+ color: "yellow",
7601
+ spinner: "dots12"
7602
+ }).start();
7478
7603
  try {
7479
7604
  const aiRes = await fetch(`${options.apiUrl}/api/cli/ai-scan`, {
7480
7605
  method: "POST",
@@ -7515,31 +7640,29 @@ async function scanCommand(targetPath, options) {
7515
7640
  info: result.findings.filter((f) => f.severity === "info").length
7516
7641
  };
7517
7642
  aiSpinner.succeed(
7518
- chalk4.green(
7519
- `AI analysis complete: ${aiFindings.length} additional findings`
7643
+ chalk5.green(
7644
+ `AI analysis: ${aiFindings.length} additional findings`
7520
7645
  )
7521
7646
  );
7522
- console.log(
7523
- chalk4.dim(
7524
- ` AI scans: ${aiData.quota.used}/${aiData.quota.limit} used this month (${aiData.quota.remaining} remaining)`
7525
- )
7647
+ printInfo(
7648
+ `AI scans: ${aiData.quota.used}/${aiData.quota.limit} used this month (${aiData.quota.remaining} remaining)`
7526
7649
  );
7527
7650
  } else if (aiRes.status === 429) {
7528
7651
  aiSpinner.warn(
7529
- chalk4.yellow("AI scan quota reached. Showing rule-based results only.")
7652
+ chalk5.yellow("AI scan quota reached. Showing rule-based results only.")
7530
7653
  );
7531
7654
  } else if (aiRes.status === 401) {
7532
7655
  aiSpinner.warn(
7533
- chalk4.yellow("Session expired. Run `shipsafe login` to re-authenticate.")
7656
+ chalk5.yellow("Session expired. Run `shipsafe login` to re-authenticate.")
7534
7657
  );
7535
7658
  } else {
7536
7659
  aiSpinner.warn(
7537
- chalk4.yellow("AI analysis unavailable. Showing rule-based results only.")
7660
+ chalk5.yellow("AI analysis unavailable. Showing rule-based results only.")
7538
7661
  );
7539
7662
  }
7540
7663
  } catch {
7541
7664
  aiSpinner.warn(
7542
- chalk4.yellow("Could not reach AI scanning service. Showing rule-based results only.")
7665
+ chalk5.yellow("Could not reach AI scanning service. Showing rule-based results only.")
7543
7666
  );
7544
7667
  }
7545
7668
  }
@@ -7587,7 +7710,11 @@ async function scanCommand(targetPath, options) {
7587
7710
  }
7588
7711
  const token = getStoredToken();
7589
7712
  if (token && options.upload) {
7590
- const uploadSpinner = ora2("Syncing results to dashboard...").start();
7713
+ const uploadSpinner = ora2({
7714
+ text: chalk5.dim("Syncing results to dashboard..."),
7715
+ color: "yellow",
7716
+ spinner: "dots12"
7717
+ }).start();
7591
7718
  try {
7592
7719
  const apiFindings = result.findings.map((f) => ({
7593
7720
  ruleId: f.ruleId,
@@ -7620,14 +7747,10 @@ async function scanCommand(targetPath, options) {
7620
7747
  if (res.ok) {
7621
7748
  const data = await res.json();
7622
7749
  uploadSpinner.succeed(
7623
- chalk4.green(`Results synced: ${data.dashboardUrl || "view in dashboard"}`)
7750
+ chalk5.green(`Results synced: ${data.dashboardUrl || "view in dashboard"}`)
7624
7751
  );
7625
7752
  if (data.deepScanStatus === "running") {
7626
- console.log(
7627
- chalk4.cyan(
7628
- " Deep AI analysis running \u2014 check dashboard for updates."
7629
- )
7630
- );
7753
+ printInfo("Deep AI analysis running \u2014 check dashboard for updates.");
7631
7754
  }
7632
7755
  } else if (res.status === 401) {
7633
7756
  uploadSpinner.warn(
@@ -7643,7 +7766,7 @@ async function scanCommand(targetPath, options) {
7643
7766
  }
7644
7767
  } else if (options.upload) {
7645
7768
  console.log(
7646
- chalk4.yellow(
7769
+ chalk5.yellow(
7647
7770
  "\nNot logged in. Run `shipsafe login` first to sync results."
7648
7771
  )
7649
7772
  );
@@ -7661,7 +7784,7 @@ async function scanCommand(targetPath, options) {
7661
7784
  // src/commands/init.ts
7662
7785
  import { writeFileSync as writeFileSync4, existsSync as existsSync6 } from "fs";
7663
7786
  import { resolve as resolve3 } from "path";
7664
- import chalk5 from "chalk";
7787
+ import chalk6 from "chalk";
7665
7788
  var DEFAULT_CONFIG = `# ShipSafe Configuration
7666
7789
  # https://ship-safe.co/docs/config
7667
7790
 
@@ -7692,11 +7815,11 @@ severity: low
7692
7815
  function initCommand() {
7693
7816
  const configPath = resolve3(".shipsafe.yml");
7694
7817
  if (existsSync6(configPath)) {
7695
- console.log(chalk5.yellow("A .shipsafe.yml already exists in this directory."));
7818
+ console.log(chalk6.yellow("A .shipsafe.yml already exists in this directory."));
7696
7819
  return;
7697
7820
  }
7698
7821
  writeFileSync4(configPath, DEFAULT_CONFIG, "utf-8");
7699
- console.log(chalk5.green("Created .shipsafe.yml configuration file."));
7822
+ console.log(chalk6.green("Created .shipsafe.yml configuration file."));
7700
7823
  }
7701
7824
 
7702
7825
  // src/index.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ship-safe/cli",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Security scanner for AI-generated code — find vulnerabilities before you ship",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -40,6 +40,7 @@
40
40
  },
41
41
  "dependencies": {
42
42
  "@anthropic-ai/sdk": "^0.39",
43
+ "@inquirer/prompts": "^8.3.2",
43
44
  "chalk": "^5",
44
45
  "commander": "^13",
45
46
  "ignore": "^7",
@@ -50,8 +51,8 @@
50
51
  "@types/node": "^22",
51
52
  "tsup": "^8",
52
53
  "typescript": "^5.7",
53
- "@shipsafe/shared": "0.1.0",
54
- "@shipsafe/scanner": "0.1.0"
54
+ "@shipsafe/scanner": "0.1.0",
55
+ "@shipsafe/shared": "0.1.0"
55
56
  },
56
57
  "scripts": {
57
58
  "build": "tsup",