shipready 1.2.0 → 1.3.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.
package/README.md CHANGED
@@ -53,9 +53,27 @@ Scans the project and prints a report with a 0-100 score.
53
53
  | `-v, --verbose` | show file and line locations for every finding |
54
54
  | `--json` | output the raw structured report as JSON (great for CI) |
55
55
  | `--fix` | apply safe fixes, re-scan, and show the score before/after |
56
+ | `--history` | also scan the **full git history** (all branches) for secrets that were committed and later removed |
57
+ | `--verify` | check detected keys against provider APIs to see if they are **live right now** |
56
58
 
57
59
  Exits with code `1` when errors are found, so you can use it in CI pipelines.
58
60
 
61
+ #### `--history`: secrets buried in old commits
62
+
63
+ Deleting a leaked key from your code does not delete it from git history — anyone who clones the repo can still read it. `shipready check --history` scans every added line in every commit on every branch, dedupes findings, and skips anything still present in the working tree (the regular scan already covers those). Each finding shows the abbreviated commit hash so you know where to look.
64
+
65
+ If something is found: rotate the key, then purge it with [git filter-repo](https://github.com/newren/git-filter-repo) or [BFG](https://rtyley.github.io/bfg-repo-cleaner/).
66
+
67
+ #### `--verify`: is the key actually live?
68
+
69
+ For 12+ providers (OpenAI, Anthropic, GitHub, GitLab, Stripe, SendGrid, Google, npm, Hugging Face, Figma, and more) shipready can make a single read-only "who am I" request to the provider's API:
70
+
71
+ - `[VERIFIED ACTIVE]` — the key works right now; this upgrades the finding to an error and jumps to the top of next steps
72
+ - `[not active - rotate anyway]` — the provider rejected it (revoked or fake)
73
+ - no marker — the provider has no safe verification endpoint, or the network was unavailable
74
+
75
+ Verification requests contain only the key itself, go directly to the provider's official API host, and never mutate remote state. Nothing is ever sent to any third party.
76
+
59
77
  ### `shipready init [path]`
60
78
 
61
79
  Generates AI-agent instruction files based on your detected framework, package manager, and scripts:
@@ -79,26 +97,28 @@ It never deletes user code and never overwrites files without `--force`. A summa
79
97
  ## Example output
80
98
 
81
99
  ```txt
82
- shipready report
83
-
84
- Project: Next.js
85
- Package manager: pnpm
86
-
87
- Summary:
88
- ✓ package.json found
89
- README.md found
90
- .env.example missing
91
- ✗ .env is not ignored
92
- 4 TODO/FIXME comments found
93
- 2 console.log calls found
94
- No obvious secrets found
95
-
96
- Score: 72/100
97
-
98
- Recommended next steps:
99
- 1. Add .env to .gitignore
100
- 2. Create .env.example (run: shipready fix)
101
- 3. Remove debug logs before shipping
100
+ ╭──────────────────────────────────────────────────────────╮
101
+ │ shipready v1.3.0 │
102
+ project Next.js · pm pnpm │
103
+ ╰──────────────────────────────────────────────────────────╯
104
+
105
+ Score ████████████████████░░░░░░░░ 72/100 almost there
106
+
107
+ package.json ok
108
+ README ok
109
+ Env safety .env.example missing (3 env vars used in code)
110
+ ✗ .env is not ignored by git
111
+ Secrets No obvious secrets found
112
+ Git history 1 secret buried in git history (removed from code but still exposed)
113
+ ⚠ Code hygiene 4 TODO/FIXME comments found
114
+ ⚠ 2 console.log calls found
115
+ ✓ .gitignore ok
116
+
117
+ Next steps
118
+ 1. Purge leaked secrets from git history (BFG or git filter-repo) and rotate them
119
+ 2. Add .env to .gitignore
120
+ 3. Create .env.example (run: shipready fix)
121
+ 4. Remove debug logs and debugger statements before shipping
102
122
  ```
103
123
 
104
124
  ## What it checks
@@ -166,6 +186,16 @@ False-positive protection:
166
186
  - **Bundle guard**: single-line minified blobs are skipped entirely
167
187
  - Matched values are always masked — shipready never prints a full secret
168
188
 
189
+ ### Suppressing a single finding
190
+
191
+ If shipready flags a line you know is safe (a demo value, an already-revoked key in docs), add the `shipready-ignore` marker to that line:
192
+
193
+ ```js
194
+ const DEMO_TOKEN = "ghp_thisIsADocumentationExample000000"; // shipready-ignore
195
+ ```
196
+
197
+ The scanner skips any line containing `shipready-ignore`. For project-wide exceptions, prefer `secretAllowlist` in the config (see below) so the suppression is reviewable in one place.
198
+
169
199
  ## Configuration
170
200
 
171
201
  Optional `shipready.config.json` in your project root:
@@ -0,0 +1,146 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { scanContentForSecrets } from "./secrets.js";
3
+ /** Paths in history we never scan (vendor noise, lockfiles, build output). */
4
+ const HISTORY_SKIP_RE = /(^|\/)(node_modules|vendor|dist|build|\.next|out|coverage)(\/|$)|\.(lock|min\.js|min\.css|map|png|jpg|jpeg|gif|webp|ico|pdf|zip|gz|woff2?)$|(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?)$/i;
5
+ /**
6
+ * Parses `git log -p --unified=0` output into added lines.
7
+ * Exported for testing.
8
+ */
9
+ export function extractAddedLines(diffText) {
10
+ const added = [];
11
+ let commit = "";
12
+ let file = "";
13
+ for (const line of diffText.split("\n")) {
14
+ if (line.startsWith("commit ")) {
15
+ commit = line.slice(7, 14); // abbreviated hash
16
+ continue;
17
+ }
18
+ if (line.startsWith("+++ b/")) {
19
+ file = line.slice(6);
20
+ continue;
21
+ }
22
+ if (line.startsWith("+++")) {
23
+ // +++ /dev/null (file deletion)
24
+ file = "";
25
+ continue;
26
+ }
27
+ if (line.startsWith("+") && !line.startsWith("+++") && file) {
28
+ const content = line.slice(1);
29
+ // Skip empty lines and vendor/binary-ish paths early.
30
+ if (content.trim().length === 0)
31
+ continue;
32
+ if (HISTORY_SKIP_RE.test(file))
33
+ continue;
34
+ // Minified blobs are noise, mirroring the working-tree scanner.
35
+ if (content.length > 10000)
36
+ continue;
37
+ added.push({ commit, file, content });
38
+ }
39
+ }
40
+ return added;
41
+ }
42
+ /** True when the directory is inside a git repository. */
43
+ export function isGitRepo(root) {
44
+ try {
45
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
46
+ cwd: root,
47
+ stdio: ["ignore", "pipe", "ignore"],
48
+ });
49
+ return true;
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
55
+ /**
56
+ * Scans the entire git history (all branches) for secrets in added lines.
57
+ * Findings that still exist in the working tree are excluded - the regular
58
+ * scanner already reports those; this check surfaces the ones hiding only
59
+ * in old commits.
60
+ */
61
+ export function scanGitHistory(root, allowlist = [], workingTreeSecrets = []) {
62
+ let diffText;
63
+ try {
64
+ diffText = execFileSync("git", ["log", "--all", "-p", "--unified=0", "--no-color", "--diff-filter=AM"], { cwd: root, maxBuffer: 512 * 1024 * 1024, encoding: "utf8" });
65
+ }
66
+ catch {
67
+ return [];
68
+ }
69
+ const addedLines = extractAddedLines(diffText);
70
+ const currentMasks = new Set(workingTreeSecrets.map((s) => `${s.file}::${s.masked}`));
71
+ const seen = new Set();
72
+ const found = [];
73
+ for (const { commit, file, content } of addedLines) {
74
+ const hits = scanContentForSecrets(content, file, allowlist);
75
+ for (const hit of hits) {
76
+ // Skip if the same secret is already reported from the working tree.
77
+ if (currentMasks.has(`${hit.file}::${hit.masked}`))
78
+ continue;
79
+ // Dedupe repeats across commits (same value in the same file).
80
+ const key = `${hit.file}::${hit.masked}::${hit.kind}`;
81
+ if (seen.has(key))
82
+ continue;
83
+ seen.add(key);
84
+ found.push({
85
+ ...hit,
86
+ line: 0,
87
+ source: "history",
88
+ commit,
89
+ });
90
+ }
91
+ }
92
+ return found;
93
+ }
94
+ /** Builds the CheckResult for git-history findings. */
95
+ export function checkHistory(historySecrets, scanned) {
96
+ const findings = [];
97
+ if (!scanned) {
98
+ findings.push({
99
+ severity: "info",
100
+ rule: "history.skipped",
101
+ message: "Not a git repository - history scan skipped",
102
+ });
103
+ return { name: "git-history", findings };
104
+ }
105
+ if (historySecrets.length === 0) {
106
+ findings.push({
107
+ severity: "success",
108
+ rule: "history.clean",
109
+ message: "No secrets found in git history",
110
+ });
111
+ return { name: "git-history", findings };
112
+ }
113
+ const high = historySecrets.filter((s) => s.confidence !== "medium");
114
+ const medium = historySecrets.filter((s) => s.confidence === "medium");
115
+ if (high.length > 0) {
116
+ findings.push({
117
+ severity: "error",
118
+ rule: "history.secret",
119
+ message: `${high.length} secret${high.length > 1 ? "s" : ""} buried in git history (removed from code but still exposed)`,
120
+ });
121
+ for (const s of high) {
122
+ findings.push({
123
+ severity: "error",
124
+ rule: "history.secret-item",
125
+ message: `${s.kind}: ${s.masked} (commit ${s.commit})`,
126
+ file: s.file,
127
+ });
128
+ }
129
+ }
130
+ if (medium.length > 0) {
131
+ findings.push({
132
+ severity: "warning",
133
+ rule: "history.possible",
134
+ message: `${medium.length} possible secret${medium.length > 1 ? "s" : ""} in git history (lower confidence)`,
135
+ });
136
+ for (const s of medium) {
137
+ findings.push({
138
+ severity: "warning",
139
+ rule: "history.possible-item",
140
+ message: `${s.kind}: ${s.masked} (commit ${s.commit})`,
141
+ file: s.file,
142
+ });
143
+ }
144
+ }
145
+ return { name: "git-history", findings };
146
+ }
@@ -300,6 +300,8 @@ export function scanContentForSecrets(content, file, allowlist = []) {
300
300
  line: i + 1,
301
301
  masked: maskSecret(value),
302
302
  confidence,
303
+ // Raw value stays in memory only; used by --verify, never printed.
304
+ raw: value,
303
305
  });
304
306
  break; // one finding per line is enough
305
307
  }
@@ -319,6 +321,22 @@ export function checkSecrets(secretFindings) {
319
321
  });
320
322
  return { name: "secrets", findings };
321
323
  }
324
+ const verifiedActive = secretFindings.filter((s) => s.verified === "active");
325
+ if (verifiedActive.length > 0) {
326
+ findings.push({
327
+ severity: "error",
328
+ rule: "secrets.verified-active",
329
+ message: `${verifiedActive.length} key${verifiedActive.length > 1 ? "s" : ""} VERIFIED ACTIVE - rotate immediately`,
330
+ });
331
+ }
332
+ /** Suffix describing the live verification result, when available. */
333
+ const verifyNote = (s) => {
334
+ if (s.verified === "active")
335
+ return " [VERIFIED ACTIVE]";
336
+ if (s.verified === "inactive")
337
+ return " [not active - rotate anyway]";
338
+ return "";
339
+ };
322
340
  if (high.length > 0) {
323
341
  findings.push({
324
342
  severity: "error",
@@ -329,7 +347,7 @@ export function checkSecrets(secretFindings) {
329
347
  findings.push({
330
348
  severity: "error",
331
349
  rule: "secrets.detected-item",
332
- message: `${s.kind}: ${s.masked}`,
350
+ message: `${s.kind}: ${s.masked}${verifyNote(s)}`,
333
351
  file: s.file,
334
352
  line: s.line,
335
353
  });
@@ -343,9 +361,10 @@ export function checkSecrets(secretFindings) {
343
361
  });
344
362
  for (const s of medium) {
345
363
  findings.push({
346
- severity: "warning",
347
- rule: "secrets.possible-item",
348
- message: `${s.kind}: ${s.masked}`,
364
+ // A verified-active key is an error no matter the pattern confidence.
365
+ severity: s.verified === "active" ? "error" : "warning",
366
+ rule: s.verified === "active" ? "secrets.detected-item" : "secrets.possible-item",
367
+ message: `${s.kind}: ${s.masked}${verifyNote(s)}`,
349
368
  file: s.file,
350
369
  line: s.line,
351
370
  });
@@ -0,0 +1,106 @@
1
+ const TIMEOUT_MS = 6000;
2
+ /** fetch with an AbortController timeout; never throws. */
3
+ async function timedFetch(url, init) {
4
+ const ctrl = new AbortController();
5
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
6
+ try {
7
+ return await fetch(url, { ...init, signal: ctrl.signal });
8
+ }
9
+ catch {
10
+ return null;
11
+ }
12
+ finally {
13
+ clearTimeout(timer);
14
+ }
15
+ }
16
+ /**
17
+ * Maps an HTTP status to a verification result using a common convention:
18
+ * 401/403 => the key was understood but rejected (inactive/revoked),
19
+ * 2xx => accepted (active),
20
+ * anything else (network, 404, 5xx, rate limit) => unknown.
21
+ */
22
+ function statusToResult(res) {
23
+ if (!res)
24
+ return "unknown";
25
+ if (res.ok)
26
+ return "active";
27
+ if (res.status === 401 || res.status === 403)
28
+ return "inactive";
29
+ return "unknown";
30
+ }
31
+ /**
32
+ * Verifiers keyed by finding.kind. Only providers with a safe, well-known
33
+ * "who am I" endpoint are included. Everything else stays "unknown".
34
+ */
35
+ const VERIFIERS = {
36
+ "OpenAI key": async (raw) => statusToResult(await timedFetch("https://api.openai.com/v1/models", {
37
+ headers: { Authorization: `Bearer ${raw}` },
38
+ })),
39
+ "Anthropic key": async (raw) => {
40
+ // /v1/models requires the key; 401 => bad key, 200 => good.
41
+ const res = await timedFetch("https://api.anthropic.com/v1/models", {
42
+ headers: { "x-api-key": raw, "anthropic-version": "2023-06-01" },
43
+ });
44
+ return statusToResult(res);
45
+ },
46
+ "GitHub token": async (raw) => statusToResult(await timedFetch("https://api.github.com/user", {
47
+ headers: {
48
+ Authorization: `Bearer ${raw}`,
49
+ "User-Agent": "shipready",
50
+ Accept: "application/vnd.github+json",
51
+ },
52
+ })),
53
+ "GitLab token": async (raw) => statusToResult(await timedFetch("https://gitlab.com/api/v4/user", {
54
+ headers: { "PRIVATE-TOKEN": raw },
55
+ })),
56
+ "Stripe live key": async (raw) => statusToResult(await timedFetch("https://api.stripe.com/v1/account", {
57
+ headers: { Authorization: `Bearer ${raw}` },
58
+ })),
59
+ "Stripe test key": async (raw) => statusToResult(await timedFetch("https://api.stripe.com/v1/account", {
60
+ headers: { Authorization: `Bearer ${raw}` },
61
+ })),
62
+ "SendGrid key": async (raw) => statusToResult(await timedFetch("https://api.sendgrid.com/v3/scopes", {
63
+ headers: { Authorization: `Bearer ${raw}` },
64
+ })),
65
+ "Google/Gemini key": async (raw) => statusToResult(await timedFetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(raw)}`, {})),
66
+ "npm token": async (raw) => statusToResult(await timedFetch("https://registry.npmjs.org/-/whoami", {
67
+ headers: { Authorization: `Bearer ${raw}` },
68
+ })),
69
+ "Hugging Face token": async (raw) => statusToResult(await timedFetch("https://huggingface.co/api/whoami-v2", {
70
+ headers: { Authorization: `Bearer ${raw}` },
71
+ })),
72
+ "Figma token": async (raw) => statusToResult(await timedFetch("https://api.figma.com/v1/me", {
73
+ headers: { "X-Figma-Token": raw },
74
+ })),
75
+ "GitHub fine-grained token": async (raw) => statusToResult(await timedFetch("https://api.github.com/user", {
76
+ headers: {
77
+ Authorization: `Bearer ${raw}`,
78
+ "User-Agent": "shipready",
79
+ },
80
+ })),
81
+ };
82
+ /** Number of providers shipready can actively verify. */
83
+ export const VERIFIABLE_KINDS = Object.keys(VERIFIERS);
84
+ /**
85
+ * Verifies findings against provider APIs, mutating each finding's
86
+ * `verified` field in place. Runs with limited concurrency and only for
87
+ * findings whose `kind` has a known verifier and whose `raw` value is present.
88
+ */
89
+ export async function verifySecrets(findings, concurrency = 5) {
90
+ const targets = findings.filter((f) => f.raw && VERIFIERS[f.kind]);
91
+ let cursor = 0;
92
+ async function worker() {
93
+ while (cursor < targets.length) {
94
+ const finding = targets[cursor++];
95
+ try {
96
+ finding.verified = await VERIFIERS[finding.kind](finding.raw);
97
+ }
98
+ catch {
99
+ finding.verified = "unknown";
100
+ }
101
+ }
102
+ }
103
+ await Promise.all(Array.from({ length: Math.min(concurrency, targets.length) }, worker));
104
+ // Findings we didn't verify stay undefined (rendered as no live info).
105
+ return findings;
106
+ }
package/dist/cli.js CHANGED
@@ -69,14 +69,20 @@ export function buildProgram() {
69
69
  .option("-v, --verbose", "show file locations for every finding")
70
70
  .option("--json", "output the raw report as JSON")
71
71
  .option("--fix", "apply safe fixes, then re-scan and show the improved report")
72
+ .option("--history", "also scan the full git history for leaked secrets")
73
+ .option("--verify", "check detected keys against provider APIs to see if they are live")
72
74
  .action(async (dir, opts) => {
73
75
  try {
74
76
  const root = resolveRoot(dir);
75
- let report = await runScan(root);
77
+ const scanOpts = { history: opts.history, verify: opts.verify };
78
+ if (opts.verify && !opts.json) {
79
+ console.log(pc.dim("\n Verifying detected keys against provider APIs..."));
80
+ }
81
+ let report = await runScan(root, scanOpts);
76
82
  if (opts.fix) {
77
83
  const before = report.score;
78
84
  const results = await applyFixes(root, false);
79
- report = await runScan(root);
85
+ report = await runScan(root, scanOpts);
80
86
  if (!opts.json) {
81
87
  console.log("");
82
88
  console.log(pc.bold(pc.magenta("shipready check --fix")));
@@ -92,7 +98,7 @@ export function buildProgram() {
92
98
  console.log(JSON.stringify(report, null, 2));
93
99
  }
94
100
  else {
95
- console.log(renderReport(report, opts.verbose ?? false));
101
+ console.log(renderReport(report, opts.verbose ?? false, ownVersion()));
96
102
  }
97
103
  // Non-zero exit when errors are present, useful for CI.
98
104
  const hasErrors = report.results.some((r) => r.findings.some((f) => f.severity === "error"));
package/dist/scanner.js CHANGED
@@ -4,6 +4,8 @@ import { checkGitignore } from "./checks/gitignore.js";
4
4
  import { checkPackageJson } from "./checks/packageJson.js";
5
5
  import { checkReadme } from "./checks/readme.js";
6
6
  import { checkSecrets, scanContentForSecrets } from "./checks/secrets.js";
7
+ import { checkHistory, isGitRepo, scanGitHistory } from "./checks/history.js";
8
+ import { verifySecrets } from "./checks/verify.js";
7
9
  import { checkTodos, scanContentForTodos } from "./checks/todos.js";
8
10
  import { fileExists, findSourceFiles, isProbablyBinary, readJsonFile, readTextFile, } from "./utils/files.js";
9
11
  import { detectFramework, detectPackageManager } from "./utils/framework.js";
@@ -80,6 +82,8 @@ export function calculateScore(results) {
80
82
  score -= 15;
81
83
  const secretCount = count("secrets.detected-item");
82
84
  score -= Math.min(secretCount * 25, 50);
85
+ const historyCount = count("history.secret-item");
86
+ score -= Math.min(historyCount * 10, 30);
83
87
  const todoCount = count("todos.item");
84
88
  score -= Math.min(todoCount * 2, 15);
85
89
  return Math.max(0, score);
@@ -94,15 +98,27 @@ function applyDisabledRules(results, disableRules) {
94
98
  }));
95
99
  }
96
100
  /** Runs the full scan and returns a structured report. */
97
- export async function runScan(root) {
101
+ export async function runScan(root, options = {}) {
98
102
  const config = loadConfig(root);
99
103
  const project = await detectProject(root, config);
100
104
  const { envUsages, secrets, todos } = scanFiles(root, project.sourceFiles, config.secretAllowlist);
105
+ let historySecrets = [];
106
+ let historyScanned = false;
107
+ if (options.history) {
108
+ historyScanned = isGitRepo(root);
109
+ if (historyScanned) {
110
+ historySecrets = scanGitHistory(root, config.secretAllowlist, secrets);
111
+ }
112
+ }
113
+ if (options.verify) {
114
+ await verifySecrets([...secrets, ...historySecrets]);
115
+ }
101
116
  const rawResults = [
102
117
  checkPackageJson(project),
103
118
  checkReadme(root),
104
119
  checkEnv(root, envUsages),
105
120
  checkSecrets(secrets),
121
+ ...(options.history ? [checkHistory(historySecrets, historyScanned)] : []),
106
122
  checkTodos(todos),
107
123
  checkGitignore(root),
108
124
  ];
@@ -5,6 +5,59 @@ const ICONS = {
5
5
  warning: pc.yellow("\u26a0"),
6
6
  info: pc.cyan("\u2139"),
7
7
  };
8
+ /** Inner width of the report layout (visible characters). */
9
+ const WIDTH = 56;
10
+ /** Visible length of a string, ignoring ANSI escape codes. */
11
+ function visibleLength(s) {
12
+ // eslint-disable-next-line no-control-regex
13
+ return s.replace(/\u001b\[[0-9;]*m/g, "").length;
14
+ }
15
+ /** Pads a string (which may contain ANSI codes) to a visible width. */
16
+ function padVisible(s, width) {
17
+ const len = visibleLength(s);
18
+ return len >= width ? s : s + " ".repeat(width - len);
19
+ }
20
+ /** Draws a rounded box around the given lines. */
21
+ function box(lines) {
22
+ const top = pc.dim("\u256d" + "\u2500".repeat(WIDTH + 2) + "\u256e");
23
+ const bottom = pc.dim("\u2570" + "\u2500".repeat(WIDTH + 2) + "\u256f");
24
+ const body = lines.map((l) => `${pc.dim("\u2502")} ${padVisible(l, WIDTH)} ${pc.dim("\u2502")}`);
25
+ return [top, ...body, bottom];
26
+ }
27
+ /** Renders the score progress bar. */
28
+ function scoreBar(score) {
29
+ const total = 28;
30
+ const filled = Math.max(0, Math.min(total, Math.round((score / 100) * total)));
31
+ const fill = "\u2588".repeat(filled);
32
+ const rest = "\u2591".repeat(total - filled);
33
+ const painted = score >= 85 ? pc.green(fill) : score >= 60 ? pc.yellow(fill) : pc.red(fill);
34
+ return painted + pc.dim(rest);
35
+ }
36
+ function scoreLabel(score) {
37
+ const label = `${score}/100`;
38
+ if (score >= 85)
39
+ return pc.bold(pc.green(label));
40
+ if (score >= 60)
41
+ return pc.bold(pc.yellow(label));
42
+ return pc.bold(pc.red(label));
43
+ }
44
+ function verdict(score) {
45
+ if (score >= 85)
46
+ return pc.green("ready to ship");
47
+ if (score >= 60)
48
+ return pc.yellow("almost there");
49
+ return pc.red("not ready to ship");
50
+ }
51
+ /** Human-friendly labels for check names. */
52
+ const CHECK_LABELS = {
53
+ "package.json": "package.json",
54
+ README: "README",
55
+ env: "Env safety",
56
+ secrets: "Secrets",
57
+ todos: "Code hygiene",
58
+ gitignore: ".gitignore",
59
+ "git-history": "Git history",
60
+ };
8
61
  function colorFor(severity, text) {
9
62
  switch (severity) {
10
63
  case "success":
@@ -17,23 +70,19 @@ function colorFor(severity, text) {
17
70
  return pc.cyan(text);
18
71
  }
19
72
  }
20
- function scoreColor(score) {
21
- const label = `${score}/100`;
22
- if (score >= 85)
23
- return pc.green(label);
24
- if (score >= 60)
25
- return pc.yellow(label);
26
- return pc.red(label);
27
- }
28
73
  /** Builds recommended next steps from findings, highest severity first. */
29
74
  export function nextSteps(report) {
30
75
  const steps = [];
31
76
  const all = report.results.flatMap((r) => r.findings);
32
77
  const has = (rule) => all.some((f) => f.rule === rule);
78
+ if (has("secrets.verified-active"))
79
+ steps.push("A key was VERIFIED ACTIVE - rotate it right now");
33
80
  if (has("package-json.missing"))
34
81
  steps.push("Add a package.json (npm init -y)");
35
82
  if (has("secrets.detected"))
36
83
  steps.push("Rotate and remove hardcoded secrets immediately");
84
+ if (has("history.secret"))
85
+ steps.push("Purge leaked secrets from git history (BFG or git filter-repo) and rotate them");
37
86
  if (has("env.not-ignored"))
38
87
  steps.push("Add .env to .gitignore");
39
88
  if (has("env.example-missing"))
@@ -56,21 +105,45 @@ export function nextSteps(report) {
56
105
  steps.push("Resolve TODO/FIXME comments or track them as issues");
57
106
  return steps;
58
107
  }
108
+ /** Worst severity across a set of findings (error > warning > info > success). */
109
+ function worst(findings) {
110
+ if (findings.some((f) => f.severity === "error"))
111
+ return "error";
112
+ if (findings.some((f) => f.severity === "warning"))
113
+ return "warning";
114
+ if (findings.some((f) => f.severity === "info"))
115
+ return "info";
116
+ return "success";
117
+ }
59
118
  /** Renders the full report to a printable string. */
60
- export function renderReport(report, verbose = false) {
119
+ export function renderReport(report, verbose = false, version) {
61
120
  const lines = [];
62
121
  const { project } = report;
122
+ const title = pc.bold(pc.magenta("shipready")) + (version ? pc.dim(` v${version}`) : "");
123
+ const meta = pc.dim("project ") +
124
+ project.framework +
125
+ pc.dim(" \u00b7 ") +
126
+ pc.dim("pm ") +
127
+ project.packageManager;
63
128
  lines.push("");
64
- lines.push(pc.bold(pc.magenta("shipready report")));
129
+ lines.push(...box([title, meta]));
65
130
  lines.push("");
66
- lines.push(`${pc.dim("Project:")} ${project.framework}`);
67
- lines.push(`${pc.dim("Package manager:")} ${project.packageManager}`);
131
+ lines.push(` ${pc.bold("Score")} ${scoreBar(report.score)} ${scoreLabel(report.score)} ${verdict(report.score)}`);
68
132
  lines.push("");
69
- lines.push(pc.bold("Summary:"));
133
+ // Aligned checks table: one row per check, then its top-level findings.
134
+ const labelWidth = Math.max(...report.results.map((r) => (CHECK_LABELS[r.name] ?? r.name).length));
70
135
  for (const result of report.results) {
71
- for (const finding of summarize(result.findings)) {
72
- lines.push(`${ICONS[finding.severity]} ${finding.message}`);
136
+ const label = CHECK_LABELS[result.name] ?? result.name;
137
+ const summaryFindings = result.findings.filter((f) => !f.file);
138
+ const status = worst(result.findings);
139
+ if (summaryFindings.length === 0) {
140
+ lines.push(` ${ICONS[status]} ${pc.bold(padVisible(label, labelWidth))} ${pc.dim("ok")}`);
141
+ continue;
73
142
  }
143
+ summaryFindings.forEach((finding, i) => {
144
+ const name = i === 0 ? pc.bold(padVisible(label, labelWidth)) : " ".repeat(labelWidth);
145
+ lines.push(` ${ICONS[finding.severity]} ${name} ${finding.message}`);
146
+ });
74
147
  }
75
148
  // Detailed findings with file locations
76
149
  const located = report.results
@@ -78,31 +151,22 @@ export function renderReport(report, verbose = false) {
78
151
  .filter((f) => f.file && f.severity !== "success");
79
152
  if (located.length > 0 && verbose) {
80
153
  lines.push("");
81
- lines.push(pc.bold("Details:"));
154
+ lines.push(` ${pc.bold("Details")}`);
82
155
  for (const f of located) {
83
156
  const loc = f.line ? `${f.file}:${f.line}` : f.file;
84
- lines.push(` ${ICONS[f.severity]} ${pc.dim(loc ?? "")} ${colorFor(f.severity, f.message)}`);
157
+ lines.push(` ${ICONS[f.severity]} ${pc.dim(loc ?? "")} ${colorFor(f.severity, f.message)}`);
85
158
  }
86
159
  }
87
- lines.push("");
88
- lines.push(`${pc.bold("Score:")} ${scoreColor(report.score)}`);
89
160
  const steps = nextSteps(report);
90
161
  if (steps.length > 0) {
91
162
  lines.push("");
92
- lines.push(pc.bold("Recommended next steps:"));
93
- steps.forEach((step, i) => lines.push(`${i + 1}. ${step}`));
163
+ lines.push(` ${pc.bold("Next steps")}`);
164
+ steps.forEach((step, i) => lines.push(` ${pc.dim(`${i + 1}.`)} ${step}`));
94
165
  }
95
166
  if (located.length > 0 && !verbose) {
96
167
  lines.push("");
97
- lines.push(pc.dim("Run with --verbose to see file locations."));
168
+ lines.push(pc.dim(" Run with --verbose to see file locations."));
98
169
  }
99
170
  lines.push("");
100
171
  return lines.join("\n");
101
172
  }
102
- /**
103
- * Collapses per-file findings into summary lines while keeping
104
- * top-level findings (those without a file) as-is.
105
- */
106
- function summarize(findings) {
107
- return findings.filter((f) => !f.file);
108
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shipready",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Pre-flight check for your repo before shipping. Scans AI-coded projects for secrets, env issues, debug leftovers, and generates AI-agent instruction files.",
5
5
  "type": "module",
6
6
  "license": "MIT",