shipready 1.7.0 → 1.8.1

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
@@ -11,6 +11,8 @@ shipready is a CLI for developers who build with AI and vibe-coding tools. It sc
11
11
 
12
12
  **No API key required. Works fully offline. No telemetry.**
13
13
 
14
+ ![shipready finds a hardcoded API key, moves it to .env automatically, and the score goes from 55 to 80](.github/assets/demo.gif)
15
+
14
16
  ## Why shipready exists
15
17
 
16
18
  AI coding tools are great at producing working code fast, but they routinely:
@@ -136,29 +138,37 @@ shipready fix --dry-run
136
138
 
137
139
  ```txt
138
140
  ╭──────────────────────────────────────────────────────────╮
139
- │ shipready v1.3.0 │
140
- │ project Next.js · pm pnpm
141
+ │ shipready v1.8.0 │
142
+ │ project Next.js · pm npm
141
143
  ╰──────────────────────────────────────────────────────────╯
142
144
 
143
- Score ████████████████████░░░░░░░░ 72/100 almost there
145
+ Score ███████████████░░░░░░░░░░░░░ 55/100 not ready to ship
146
+ -8 weak README · -6 no test script · -25 hardcoded secret · -6 TODO comments (3)
144
147
 
145
- ✓ package.json ok
146
- README ok
147
- Env safety .env.example missing (3 env vars used in code)
148
- ✗ .env is not ignored by git
149
- Secrets No obvious secrets found
150
- Git history 1 secret buried in git history (removed from code but still exposed)
151
- Code hygiene 4 TODO/FIXME comments found
152
- 2 console.log calls found
153
- .gitignore ok
148
+ ✓ package.json package.json found
149
+ ⚠ Missing scripts: test, lint
150
+ README README.md found
151
+ ⚠ README looks thin - add installation and usage instructions
152
+ Env safety No environment variables detected in code
153
+ Secrets 1 potential secret detected
154
+ 1 possible secret detected (lower confidence)
155
+ Code hygiene 2 TODO/FIXME comments found
156
+ ⚠ 1 console.log call found
157
+ ⚠ .gitignore .gitignore missing entries: .env, .env.local, .next
154
158
 
155
159
  Next steps
156
- 1. Purge leaked secrets from git history (BFG or git filter-repo) and rotate them
157
- 2. Add .env to .gitignore
158
- 3. Create .env.example (run: shipready fix)
159
- 4. Remove debug logs and debugger statements before shipping
160
+ 1. Rotate and remove hardcoded secrets immediately
161
+ 2. Expand README with installation and usage sections
162
+ 3. Add missing entries to .gitignore (run: shipready fix)
163
+ 4. Add missing package.json scripts (build/test/lint)
164
+ 5. Remove debug logs and debugger statements before shipping
165
+ 6. Resolve TODO/FIXME comments or track them as issues
166
+
167
+ Run with --verbose to see file locations.
160
168
  ```
161
169
 
170
+ Run `shipready fix` and the hardcoded key moves to `.env`, `.gitignore` gets patched, and the score jumps to 80 - see the [secret autofix](#secret-autofix) section.
171
+
162
172
  ## What it checks
163
173
 
164
174
  | Check | What it looks for |
@@ -242,6 +252,29 @@ console.log("startup banner"); // shipready-ignore
242
252
 
243
253
  For project-wide exceptions, prefer `secretAllowlist` in the config (see below) so the suppression is reviewable in one place.
244
254
 
255
+ ## Secret autofix
256
+
257
+ `shipready fix` moves hardcoded secrets out of your code into `.env`:
258
+
259
+ ```js
260
+ // before
261
+ const githubToken = "ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
262
+ // after
263
+ const githubToken = process.env.GITHUB_TOKEN;
264
+ ```
265
+
266
+ The value lands in `.env` (gitignored), a placeholder lands in `.env.example`, and env var names are derived from your identifiers (`stripeApiKey` becomes `STRIPE_API_KEY`). TypeScript gets `process.env.NAME!` to keep strict mode compiling; Python gets `os.environ["NAME"]` with the `import os` added.
267
+
268
+ It never breaks your program - a rewrite only happens when it is provably safe:
269
+
270
+ - only complete quoted string literals are replaced; a password embedded in a database URL is flagged for manual restructuring instead
271
+ - client-side code (`"use client"`, `import.meta.env`) is never rewritten - an env var would still ship to the browser, so shipready tells you to move the call behind a server endpoint
272
+ - after rewriting, the file is re-scanned; if the secret somehow survived, the file is restored untouched
273
+ - identical values across files map to one env var; name collisions get numeric suffixes; existing `.env` entries are never overwritten
274
+ - if nothing in your project loads `.env` (no dotenv/Next.js/Vite), shipready tells you to run with `node --env-file=.env`
275
+
276
+ Use `--dry-run` to preview every change first. Remember to rotate any key that was already pushed - moving it to `.env` does not un-leak it.
277
+
245
278
  ## Scoring
246
279
 
247
280
  The score starts at 100 and every deduction is itemized right under the score bar - the number is never a black box:
@@ -358,24 +391,6 @@ Catch secrets **before** they enter history at all — see [`shipready staged`](
358
391
 
359
392
  Use `--json` to feed the report into other tooling, or `--sarif` for anything that speaks SARIF.
360
393
 
361
- ## Scoring
362
-
363
- Every project starts at 100 and loses points for issues:
364
-
365
- | Issue | Deduction |
366
- | --- | --- |
367
- | Missing package.json | -20 |
368
- | Missing README | -15 |
369
- | Weak README | -8 |
370
- | Missing build script | -8 |
371
- | Missing test script | -6 |
372
- | Missing .env.example (when env vars exist) | -10 |
373
- | .env not gitignored | -15 |
374
- | Possible secret | -25 each (max -50) |
375
- | TODO/debug leftovers | -2 each (max -15) |
376
-
377
- The score never goes below 0.
378
-
379
394
  ## Development
380
395
 
381
396
  ```bash
package/dist/cli.js CHANGED
@@ -7,6 +7,7 @@ import { detectProject, runScan, scanFiles } from "./scanner.js";
7
7
  import { loadConfig } from "./config.js";
8
8
  import { fixAgentFiles } from "./fixers/agentFiles.js";
9
9
  import { fixEnvExample } from "./fixers/envExample.js";
10
+ import { fixSecrets, projectLoadsDotenv } from "./fixers/secrets.js";
10
11
  import { fixGitignore } from "./fixers/gitignore.js";
11
12
  import { renderReport } from "./utils/report.js";
12
13
  import { toSarif } from "./utils/sarif.js";
@@ -47,12 +48,33 @@ function resolveRoot(dir) {
47
48
  async function applyFixes(root, force, dryRun = false) {
48
49
  const config = loadConfig(root);
49
50
  const project = await detectProject(root, config);
50
- const { envUsages } = scanFiles(root, project.sourceFiles, config.secretAllowlist);
51
- return [
52
- fixEnvExample(root, envUsages, force, dryRun),
53
- fixGitignore(root, dryRun),
51
+ const { envUsages, secrets } = scanFiles(root, project.sourceFiles, config.secretAllowlist);
52
+ const secretFix = fixSecrets(root, secrets, dryRun);
53
+ const handled = new Set(secretFix.results.map((r) => r.file));
54
+ const results = [
55
+ ...secretFix.results,
56
+ ...[fixEnvExample(root, envUsages, force, dryRun)].filter((r) => !(r.action === "skipped" && handled.has(r.file))),
57
+ fixGitignore(root, dryRun, project.framework, {
58
+ hasBuildScript: Boolean(project.scripts["build"]),
59
+ usesEnv: envUsages.length > 0 || secretFix.envAdditions.length > 0,
60
+ }),
54
61
  ...fixAgentFiles(root, project, force, dryRun),
55
62
  ];
63
+ for (const { finding, reason } of secretFix.manual) {
64
+ results.push({
65
+ file: finding.file,
66
+ action: "skipped",
67
+ reason: `secret at line ${finding.line} needs a manual fix: ${reason}`,
68
+ });
69
+ }
70
+ if (secretFix.envAdditions.length > 0 && !projectLoadsDotenv(root)) {
71
+ results.push({
72
+ file: ".env",
73
+ action: "skipped",
74
+ reason: "heads up: nothing loads .env automatically - run with `node --env-file=.env` (Node 20+) or add dotenv",
75
+ });
76
+ }
77
+ return results;
56
78
  }
57
79
  /** Reads the CLI's own version from its package.json (works from dist/ at runtime). */
58
80
  function ownVersion() {
@@ -152,7 +174,7 @@ export function buildProgram() {
152
174
  });
153
175
  program
154
176
  .command("fix")
155
- .description("Apply safe automatic fixes (.env.example, .gitignore, agent files)")
177
+ .description("Apply safe automatic fixes (move secrets to .env, .env.example, .gitignore, agent files)")
156
178
  .argument("[path]", "project directory (defaults to current directory)")
157
179
  .option("-f, --force", "overwrite existing files")
158
180
  .option("--dry-run", "show what would change without writing anything")
@@ -1,13 +1,13 @@
1
- import { importantIgnoresFor, missingIgnoreEntries } from "../checks/gitignore.js";
1
+ import { importantIgnoresFor, missingIgnoreEntries, } from "../checks/gitignore.js";
2
2
  import { readTextFile, writeTextFile } from "../utils/files.js";
3
3
  /** Adds missing important entries to .gitignore (creates it if absent). */
4
- export function fixGitignore(root, dryRun = false, framework) {
4
+ export function fixGitignore(root, dryRun = false, framework, ctx) {
5
5
  const file = ".gitignore";
6
6
  const existing = readTextFile(root, file);
7
7
  if (existing === null) {
8
8
  const content = [
9
9
  "# Added by shipready",
10
- ...importantIgnoresFor(framework),
10
+ ...importantIgnoresFor(framework, ctx),
11
11
  "",
12
12
  ].join("\n");
13
13
  if (!dryRun) {
@@ -15,7 +15,7 @@ export function fixGitignore(root, dryRun = false, framework) {
15
15
  }
16
16
  return { file, action: "created", dryRun, preview: content };
17
17
  }
18
- const missing = missingIgnoreEntries(existing, framework);
18
+ const missing = missingIgnoreEntries(existing, framework, ctx);
19
19
  if (missing.length === 0) {
20
20
  return { file, action: "skipped", reason: "already complete" };
21
21
  }
@@ -0,0 +1,269 @@
1
+ import path from "node:path";
2
+ import { scanContentForSecrets } from "../checks/secrets.js";
3
+ import { fileExists, readTextFile, writeTextFile } from "../utils/files.js";
4
+ /** File extensions where we know how to rewrite a string literal safely. */
5
+ const CODE_EXTENSIONS = new Set([
6
+ ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".py",
7
+ ]);
8
+ const QUOTES = new Set(["'", '"', "`"]);
9
+ /**
10
+ * Converts an identifier (stripeKey, api_key, STRIPE_KEY) to
11
+ * SCREAMING_SNAKE_CASE for use as an env var name.
12
+ */
13
+ function toEnvName(identifier) {
14
+ return identifier
15
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
16
+ .replace(/[^A-Za-z0-9]+/g, "_")
17
+ .replace(/^_+|_+$/g, "")
18
+ .toUpperCase();
19
+ }
20
+ /** Names too vague to stand alone as env vars. */
21
+ const GENERIC_NAMES = new Set(["KEY", "TOKEN", "SECRET", "PASSWORD", "AUTH", "API_KEY"]);
22
+ /** Derives an env var name from the secret's kind ("GitHub token" -> GITHUB_TOKEN). */
23
+ function nameFromKind(kind) {
24
+ return toEnvName(kind);
25
+ }
26
+ /**
27
+ * Derives an env var name from the code context: the identifier being
28
+ * assigned right before the literal, falling back to the finding's kind.
29
+ */
30
+ function deriveEnvName(linePrefix, kind) {
31
+ const m = linePrefix.match(/([A-Za-z_$][A-Za-z0-9_$]*)\s*["']?\s*[:=]\s*$/);
32
+ if (m) {
33
+ const name = toEnvName(m[1]);
34
+ // "key" or "token" alone says nothing - qualify it with the kind.
35
+ if (GENERIC_NAMES.has(name))
36
+ return nameFromKind(kind);
37
+ if (name.length >= 3)
38
+ return name;
39
+ }
40
+ return nameFromKind(kind);
41
+ }
42
+ /** Builds the language-appropriate replacement expression. */
43
+ function replacementFor(file, envName) {
44
+ const ext = path.extname(file);
45
+ if (ext === ".py")
46
+ return `os.environ["${envName}"]`;
47
+ // Non-null assertion keeps TS strict mode compiling; at runtime the
48
+ // behavior matches plain JS (undefined if unset).
49
+ if (ext === ".ts" || ext === ".tsx")
50
+ return `process.env.${envName}!`;
51
+ return `process.env.${envName}`;
52
+ }
53
+ /** True when the file is client-bundle code where env vars still leak. */
54
+ function looksLikeClientCode(content) {
55
+ return (/^\s*["']use client["']/m.test(content) || content.includes("import.meta.env"));
56
+ }
57
+ /** Ensures `import os` exists in a Python file, inserting it if needed. */
58
+ function ensurePythonOsImport(lines) {
59
+ if (lines.some((l) => /^\s*(import os\b|from os\b)/.test(l)))
60
+ return;
61
+ // Insert after shebang / encoding comments, before the first real line.
62
+ let insertAt = 0;
63
+ while (insertAt < lines.length &&
64
+ (lines[insertAt].startsWith("#!") || /^#.*coding[:=]/.test(lines[insertAt]))) {
65
+ insertAt++;
66
+ }
67
+ lines.splice(insertAt, 0, "import os");
68
+ }
69
+ /**
70
+ * Moves hardcoded secrets out of source code into .env, replacing each
71
+ * literal with the language's env accessor.
72
+ *
73
+ * Safety rules - a replacement only happens when ALL hold, otherwise the
74
+ * finding is reported for manual fixing:
75
+ * - the file is a known code type (JS/TS family or Python)
76
+ * - the file is not client-bundle code (env vars still ship to the browser)
77
+ * - the secret is a COMPLETE quoted string literal, not a substring of one
78
+ * (a password inside a URL needs restructuring, not substitution)
79
+ * - after rewriting, a re-scan of the file confirms the raw value is gone;
80
+ * if not, the file is restored untouched
81
+ */
82
+ export function fixSecrets(root, secrets, dryRun = false) {
83
+ const results = [];
84
+ const manual = [];
85
+ const envAdditions = [];
86
+ // Same raw value everywhere maps to one env var; name collisions with
87
+ // different values get numeric suffixes.
88
+ const valueToName = new Map();
89
+ const nameToValue = new Map();
90
+ // Pre-seed with existing .env so we never clobber an existing entry.
91
+ const existingEnv = readTextFile(root, ".env") ?? "";
92
+ for (const line of existingEnv.split("\n")) {
93
+ const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
94
+ if (m)
95
+ nameToValue.set(m[1], m[2]);
96
+ }
97
+ const claimName = (wanted, value) => {
98
+ const known = valueToName.get(value);
99
+ if (known)
100
+ return known;
101
+ let name = wanted;
102
+ let n = 2;
103
+ while (nameToValue.has(name) && nameToValue.get(name) !== value) {
104
+ name = `${wanted}_${n++}`;
105
+ }
106
+ valueToName.set(value, name);
107
+ nameToValue.set(name, value);
108
+ return name;
109
+ };
110
+ const byFile = new Map();
111
+ for (const f of secrets) {
112
+ if (!f.raw)
113
+ continue;
114
+ const list = byFile.get(f.file) ?? [];
115
+ list.push(f);
116
+ byFile.set(f.file, list);
117
+ }
118
+ for (const [file, findings] of byFile) {
119
+ const ext = path.extname(file);
120
+ if (!CODE_EXTENSIONS.has(ext)) {
121
+ for (const f of findings) {
122
+ manual.push({ finding: f, reason: `unsupported file type (${ext || "no extension"}) - move the value manually` });
123
+ }
124
+ continue;
125
+ }
126
+ const original = readTextFile(root, file);
127
+ if (original === null) {
128
+ for (const f of findings)
129
+ manual.push({ finding: f, reason: "file unreadable" });
130
+ continue;
131
+ }
132
+ if (looksLikeClientCode(original)) {
133
+ for (const f of findings) {
134
+ manual.push({
135
+ finding: f,
136
+ reason: "client-side code - an env var would still ship to the browser; move this call behind a server endpoint",
137
+ });
138
+ }
139
+ continue;
140
+ }
141
+ const lines = original.split("\n");
142
+ const fixedNames = [];
143
+ let changed = false;
144
+ // Bottom-up so earlier line numbers stay valid if we insert imports.
145
+ const sorted = [...findings].sort((a, b) => b.line - a.line);
146
+ for (const f of sorted) {
147
+ const raw = f.raw;
148
+ const lineIdx = f.line - 1;
149
+ const line = lines[lineIdx];
150
+ if (line === undefined) {
151
+ manual.push({ finding: f, reason: "line not found (file changed since scan)" });
152
+ continue;
153
+ }
154
+ const idx = line.indexOf(raw);
155
+ if (idx === -1) {
156
+ manual.push({ finding: f, reason: "value not found on its line (file changed since scan)" });
157
+ continue;
158
+ }
159
+ const before = line[idx - 1];
160
+ const after = line[idx + raw.length];
161
+ const isWholeLiteral = before !== undefined && after !== undefined && before === after && QUOTES.has(before);
162
+ if (!isWholeLiteral) {
163
+ manual.push({
164
+ finding: f,
165
+ reason: "secret is embedded inside a larger string (e.g. a URL) - restructure it manually",
166
+ });
167
+ continue;
168
+ }
169
+ const envName = claimName(deriveEnvName(line.slice(0, idx - 1), f.kind), raw);
170
+ const replacement = replacementFor(file, envName);
171
+ lines[lineIdx] =
172
+ line.slice(0, idx - 1) + replacement + line.slice(idx + raw.length + 1);
173
+ envAdditions.push({ name: envName, value: raw });
174
+ fixedNames.push(envName);
175
+ changed = true;
176
+ }
177
+ if (!changed)
178
+ continue;
179
+ if (ext === ".py")
180
+ ensurePythonOsImport(lines);
181
+ const updated = lines.join("\n");
182
+ // Verification gate: the raw values must be gone from a fresh scan.
183
+ const stillLeaking = scanContentForSecrets(updated, file).some((s) => findings.some((f) => f.raw === s.raw && fixedNames.length > 0));
184
+ const rawsGone = findings
185
+ .filter((f) => f.raw && fixedNames.length > 0)
186
+ .every((f) => !updated.includes(f.raw) || manual.some((m) => m.finding === f));
187
+ if (stillLeaking || !rawsGone) {
188
+ // Restore and report rather than half-fix.
189
+ for (const f of findings) {
190
+ manual.push({ finding: f, reason: "post-fix verification failed - file left untouched" });
191
+ }
192
+ // Roll back env additions for this file.
193
+ for (const name of fixedNames) {
194
+ const value = nameToValue.get(name);
195
+ if (value !== undefined) {
196
+ valueToName.delete(value);
197
+ nameToValue.delete(name);
198
+ }
199
+ const i = envAdditions.findIndex((e) => e.name === name);
200
+ if (i !== -1)
201
+ envAdditions.splice(i, 1);
202
+ }
203
+ continue;
204
+ }
205
+ if (!dryRun)
206
+ writeTextFile(root, file, updated);
207
+ results.push({
208
+ file,
209
+ action: "updated",
210
+ dryRun,
211
+ preview: fixedNames.map((n) => `-> ${replacementFor(file, n)}`).join("\n"),
212
+ });
213
+ }
214
+ // Write .env and .env.example additions.
215
+ if (envAdditions.length > 0) {
216
+ const uniqueAdditions = envAdditions.filter((e, i) => envAdditions.findIndex((x) => x.name === e.name) === i);
217
+ const newEnvLines = uniqueAdditions
218
+ .filter((e) => !new RegExp(`^${e.name}=`, "m").test(existingEnv))
219
+ .map((e) => `${e.name}=${e.value}`);
220
+ if (newEnvLines.length > 0) {
221
+ const envContent = existingEnv.length > 0
222
+ ? existingEnv.replace(/\n*$/, "\n") + newEnvLines.join("\n") + "\n"
223
+ : "# Managed by shipready - do not commit this file.\n" + newEnvLines.join("\n") + "\n";
224
+ if (!dryRun)
225
+ writeTextFile(root, ".env", envContent);
226
+ results.push({
227
+ file: ".env",
228
+ action: existingEnv ? "updated" : "created",
229
+ dryRun,
230
+ preview: newEnvLines.map((l) => l.replace(/=.*/, "=***")).join("\n"),
231
+ });
232
+ }
233
+ const existingExample = readTextFile(root, ".env.example") ?? "";
234
+ const newExampleLines = uniqueAdditions
235
+ .filter((e) => !new RegExp(`^${e.name}=`, "m").test(existingExample))
236
+ .map((e) => `${e.name}=`);
237
+ if (newExampleLines.length > 0) {
238
+ const exampleContent = existingExample.length > 0
239
+ ? existingExample.replace(/\n*$/, "\n") + newExampleLines.join("\n") + "\n"
240
+ : newExampleLines.join("\n") + "\n";
241
+ if (!dryRun)
242
+ writeTextFile(root, ".env.example", exampleContent);
243
+ results.push({
244
+ file: ".env.example",
245
+ action: fileExists(root, ".env.example") && existingExample ? "updated" : "created",
246
+ dryRun,
247
+ preview: newExampleLines.join("\n"),
248
+ });
249
+ }
250
+ }
251
+ return { results, envAdditions, manual };
252
+ }
253
+ /**
254
+ * True when the project will pick up .env automatically at runtime; when
255
+ * false the caller should tell the user how to load it.
256
+ */
257
+ export function projectLoadsDotenv(root) {
258
+ const pkgRaw = readTextFile(root, "package.json");
259
+ if (!pkgRaw)
260
+ return false;
261
+ try {
262
+ const pkg = JSON.parse(pkgRaw);
263
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
264
+ return Boolean(deps["dotenv"] || deps["next"] || deps["vite"] || deps["@dotenvx/dotenvx"]);
265
+ }
266
+ catch {
267
+ return false;
268
+ }
269
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shipready",
3
- "version": "1.7.0",
3
+ "version": "1.8.1",
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",