shippingszn 0.7.1 → 0.8.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/README.md CHANGED
@@ -1,10 +1,9 @@
1
1
  # shippingszn
2
2
 
3
- A small, read-only CLI that scans a project for common pre-launch issues from
4
- the [shippingszn.com launch checklist](https://shippingszn.com). Run it
5
- before you ship to catch obvious mistakes leaked API keys, missing
6
- `robots.txt`, no security headers, and so on — and get a friendly report
7
- linking each finding back to the matching checklist item.
3
+ Primary local scanner for shippingszn launch readiness. Run it inside the app
4
+ you are about to ship to catch the launch blockers AI builders commonly miss:
5
+ leaked API keys, missing crawl assets, weak browser defenses, dangerous code
6
+ patterns, and last-mile polish gaps.
8
7
 
9
8
  ```bash
10
9
  npx shippingszn
@@ -12,8 +11,74 @@ npx shippingszn
12
11
  pnpm dlx shippingszn
13
12
  ```
14
13
 
15
- Run it inside any project root. The CLI **never writes, modifies, or deletes**
16
- any files it only reads. Everything stays on your machine.
14
+ The CLI **never writes, modifies, or deletes** any files - it only reads.
15
+ Everything stays on your machine unless you explicitly opt in with `--proof`
16
+ or `--publish`.
17
+
18
+ Human output starts with a Launch Readiness Score, scope covered, the top next
19
+ step to fix, and severity counts. Run with `--json` to get machine-readable
20
+ findings, copy/paste fix prompts for your AI builder, and proof/report handoff
21
+ fields:
22
+
23
+ ```json
24
+ {
25
+ "launchReadiness": {
26
+ "score": 43,
27
+ "rawScore": 43,
28
+ "label": "No-go: launch blocked",
29
+ "decisionLabel": "No-go: launch blocked",
30
+ "goNoGoLabel": "No-go",
31
+ "confidence": "high",
32
+ "coveragePenalty": 0,
33
+ "coverageSummary": "CLI scan covered 4 readiness areas with 4 areas requiring owner verification.",
34
+ "topNextStep": "Fix Lock up your API keys and passwords: Possible OpenAI API key hardcoded in source.",
35
+ "proofCreatePath": "https://shippingszn.com/scan",
36
+ "proofUploadHint": "Run `npx shippingszn --json > shippingszn-scan.json`, then paste or upload that JSON in the web scan proof flow to create a shareable launch proof result.",
37
+ "proofUrl": "https://shippingszn.com/proof/00000000-0000-4000-8000-000000000123",
38
+ "proofResultId": "00000000-0000-4000-8000-000000000123",
39
+ "badgeMarkdown": "[![Launch readiness proof: 70%](https://shippingszn.com/api/badge.svg?...)](https://shippingszn.com/proof/00000000-0000-4000-8000-000000000123)",
40
+ "wallUrl": "https://shippingszn.com/wall",
41
+ "reportUrl": "https://shippingszn.com/report?scanResultId=00000000-0000-4000-8000-000000000123"
42
+ },
43
+ "findings": [
44
+ {
45
+ "severity": "critical",
46
+ "message": "Possible OpenAI API key hardcoded in source.",
47
+ "remediation": {
48
+ "fixPrompt": "You are fixing a shippingszn launch-readiness finding...",
49
+ "verify": "Re-run npx shippingszn --json and confirm this exact finding is gone...",
50
+ "escalation": "Escalate before launch if this touches secrets, auth, payments...",
51
+ "proofNextStep": "After the fix verifies clean, run npx shippingszn --json..."
52
+ }
53
+ }
54
+ ]
55
+ }
56
+ ```
57
+
58
+ When Critical or High findings make paid confidence relevant, the summary also
59
+ includes a `/report` link. Clean scans and lower-risk findings do not push the
60
+ paid report CTA.
61
+
62
+ ## Direct proof publishing
63
+
64
+ Use `--proof` when you want the current scan to become shareable launch proof:
65
+
66
+ ```bash
67
+ npx shippingszn --json --proof > shippingszn-scan.json
68
+ ```
69
+
70
+ That opt-in posts the scan summary and remediation fields to
71
+ `/api/scan-results`, then adds `launchReadiness.proofUrl`,
72
+ `launchReadiness.proofResultId`, `launchReadiness.badgeMarkdown`,
73
+ `launchReadiness.wallUrl`, and `launchReadiness.reportUrl` to the JSON. It
74
+ also posts a proof-linked severity summary to the Wall of Launches. If proof
75
+ upload fails, findings still print and JSON includes
76
+ `launchReadiness.proofUploadError`; if the Wall publish fails, JSON includes
77
+ `launchReadiness.wallPublishError`.
78
+
79
+ For a manual handoff, you can still run `npx shippingszn --json >
80
+ shippingszn-scan.json` and paste or upload the file at
81
+ `https://shippingszn.com/scan`.
17
82
 
18
83
  ## What gets checked
19
84
 
@@ -62,6 +127,12 @@ shippingszn [path] [options]
62
127
 
63
128
  Options:
64
129
  --json Output a machine-readable JSON report.
130
+ --publish Opt in to posting an anonymous summary to the public
131
+ Wall of Launches. Never uploads source code, paths,
132
+ filenames, or project names.
133
+ --proof Opt in to posting this scan to /api/scan-results and
134
+ printing a proof URL, Wall URL, report URL, and badge.
135
+ Can also be enabled with SHIPPINGSZN_PROOF=1.
65
136
  --base-url <url> Base URL used to build links back to checklist items.
66
137
  --cwd <path> Directory to scan. Default: current working directory.
67
138
  --no-color Disable ANSI colors in the human-readable report.
@@ -79,15 +150,33 @@ This makes the CLI suitable for CI:
79
150
 
80
151
  ```yaml
81
152
  # .github/workflows/launch-check.yml
82
- - run: npx shippingszn --json > launch-check.json
153
+ - run: npx shippingszn --json --proof > launch-check.json
83
154
  ```
84
155
 
156
+ For PR scan proof, have GitHub Actions run the scanner with `--proof` and post
157
+ the JSON summary as a comment: score, severity counts, top next step, proof URL,
158
+ badge markdown, Wall URL, report URL, and worst Critical/High findings. Keep
159
+ `--publish` out of the workflow unless you explicitly want to publish an
160
+ anonymous Wall summary when proof upload fails.
161
+
85
162
  ## Privacy
86
163
 
87
164
  `shippingszn` reads files on your machine. It never uploads source code,
88
- makes outbound network calls, or phones home. No telemetry. No accounts.
165
+ makes outbound network calls, or phones home by default. No accounts.
89
166
  Inspect the source or audit `npm pack --dry-run` to confirm.
90
167
 
168
+ If you want public launch-readiness proof, pass `--proof`. That opt-in posts
169
+ the current scan result to shippingszn so it can create a proof page, report
170
+ handoff, README badge, and proof-linked Wall entry. It uploads finding titles,
171
+ messages, remediation prompts, verification text, checklist permalinks,
172
+ severity counts, score, file count, target folder name, and scanner version. It
173
+ does not upload source code or secret values.
174
+
175
+ If you only want an anonymous Wall of Launches summary, pass `--publish`.
176
+ That opt-in posts files scanned count, finding counts by severity, detected
177
+ stack tags, and scanner version. It never uploads source code, file paths,
178
+ filenames, project names, secrets, or report contents.
179
+
91
180
  ## License
92
181
 
93
182
  MIT. See [LICENSE](./LICENSE).
@@ -11,19 +11,38 @@ export function findLine(content, idx) {
11
11
  return line;
12
12
  }
13
13
  /**
14
- * Inline opt-out marker. Any line containing this token is exempt from
15
- * substring/regex-based checks (placeholder content, dangerous patterns).
16
- * Used by the scanner's own source to avoid matching its pattern definitions.
14
+ * Inline opt-out markers. Two flavors, both optional:
17
15
  *
18
- * The literal value is split across a concatenation so that grep'ing for the
19
- * marker only finds the *uses*, not this definition.
16
+ * - `shippingszn:ignore` on the same line as the match suppresses it.
17
+ * - `shippingszn:ignore-next-line` on the line ABOVE the match suppresses
18
+ * it. This exists because formatters (prettier, biome) routinely
19
+ * reflow trailing comments onto their own line, which would otherwise
20
+ * silently break the same-line marker. Mirrors the eslint /
21
+ * prettier-ignore-next conventions.
22
+ *
23
+ * The literal values are split across concatenations so that grep'ing
24
+ * for the marker only finds the *uses*, not these definitions.
20
25
  */
21
26
  export const IGNORE_MARKER = "shippingszn" + ":ignore";
22
- export function lineContainsIgnoreMarker(content, charIndex) {
27
+ export const IGNORE_NEXT_LINE_MARKER = "shippingszn" + ":ignore-next-line";
28
+ function getLine(content, charIndex) {
23
29
  const lineStart = content.lastIndexOf("\n", charIndex - 1) + 1;
24
30
  const lineEnd = content.indexOf("\n", charIndex);
25
- const line = content.slice(lineStart, lineEnd === -1 ? undefined : lineEnd);
26
- return line.includes(IGNORE_MARKER);
31
+ return content.slice(lineStart, lineEnd === -1 ? undefined : lineEnd);
32
+ }
33
+ function getPreviousLine(content, charIndex) {
34
+ const lineStart = content.lastIndexOf("\n", charIndex - 1) + 1;
35
+ if (lineStart === 0)
36
+ return null;
37
+ const prevEnd = lineStart - 1;
38
+ const prevStart = content.lastIndexOf("\n", prevEnd - 1) + 1;
39
+ return content.slice(prevStart, prevEnd);
40
+ }
41
+ export function lineContainsIgnoreMarker(content, charIndex) {
42
+ if (getLine(content, charIndex).includes(IGNORE_MARKER))
43
+ return true;
44
+ const prev = getPreviousLine(content, charIndex);
45
+ return prev !== null && prev.includes(IGNORE_NEXT_LINE_MARKER);
27
46
  }
28
47
  const PUBLIC_DIR_CANDIDATES = [
29
48
  "public",
@@ -87,6 +106,11 @@ const PATTERN_DEFINITION_FILES = new Set([
87
106
  "tools/cli/src/checks/quality.ts",
88
107
  "tools/cli/src/checks/language.ts",
89
108
  "tools/cli/README.md",
109
+ // Test fixture for the redaction module: contains intentional fake
110
+ // secret patterns whose whole purpose is to verify the redactor scrubs
111
+ // them. Functionally identical to tools/cli/test/fixtures/, just lives
112
+ // in a different package.
113
+ "artifacts/api-server/src/lib/__tests__/redaction.test.ts",
90
114
  ]);
91
115
  const PATTERN_DEFINITION_PREFIXES = [
92
116
  "tools/cli/test/fixtures/",
@@ -104,7 +104,7 @@ export async function checkFavicon(ctx) {
104
104
  checkId: "missing-favicon",
105
105
  itemId: "launch-polish",
106
106
  severity: "lower",
107
- message: "No custom favicon found in your public directory. The default browser favicon (or the framework starter one) tells visitors this is a vibe-coded project.",
107
+ message: "No custom favicon found in your public directory. The default browser favicon (or the framework starter one) tells visitors this is an unfinished AI-built project.",
108
108
  },
109
109
  ];
110
110
  }
package/dist/index.js CHANGED
@@ -6,6 +6,9 @@ import { ALL_CHECKS } from "./checks.js";
6
6
  import { listFiles, getTrackedFiles } from "./scan.js";
7
7
  import { CHECKLIST_ITEMS, permalinkFor } from "./items.js";
8
8
  import { publishScan } from "./publish.js";
9
+ import { shouldUploadProof, uploadProof, } from "./proof.js";
10
+ import { buildRemediationPrompt, } from "./remediation.js";
11
+ import { assessLaunchReadiness, normalizeLaunchFinding, } from "@workspace/launch-readiness";
9
12
  const UNTRACKED_DOWNGRADE = {
10
13
  critical: "lower",
11
14
  high: "lower",
@@ -41,10 +44,14 @@ function parseArgs(argv) {
41
44
  const opts = {
42
45
  cwd: process.cwd(),
43
46
  json: false,
44
- baseUrl: process.env.VIBE_LAUNCH_CHECK_BASE_URL ?? DEFAULT_BASE_URL,
47
+ baseUrl: process.env.SHIPPINGSZN_BASE_URL ??
48
+ process.env.VIBE_LAUNCH_CHECK_BASE_URL ??
49
+ DEFAULT_BASE_URL,
45
50
  help: false,
46
51
  version: false,
47
52
  noColor: !!process.env.NO_COLOR,
53
+ publish: false,
54
+ proof: shouldUploadProof(),
48
55
  };
49
56
  for (let i = 0; i < argv.length; i++) {
50
57
  const a = argv[i];
@@ -54,6 +61,10 @@ function parseArgs(argv) {
54
61
  opts.version = true;
55
62
  else if (a === "--json")
56
63
  opts.json = true;
64
+ else if (a === "--publish")
65
+ opts.publish = true;
66
+ else if (a === "--proof")
67
+ opts.proof = true;
57
68
  else if (a === "--no-color")
58
69
  opts.noColor = true;
59
70
  else if (a === "--base-url")
@@ -90,13 +101,19 @@ function printHelp() {
90
101
  process.stdout.write(`shippingszn v${PKG_VERSION}
91
102
 
92
103
  Read-only scanner that checks the current project against a small set of
93
- high-signal items from the Vibe Coder Launch Checklist.
104
+ high-signal launch-readiness items from shippingszn.
94
105
 
95
106
  Usage:
96
107
  npx shippingszn [path] [options]
97
108
 
98
109
  Options:
99
110
  --json Output a machine-readable JSON report.
111
+ --publish Opt in to posting an anonymous summary to the public
112
+ Wall of Launches. Never uploads source code, paths,
113
+ filenames, or project names.
114
+ --proof Opt in to posting this scan to /api/scan-results and
115
+ printing a proof URL, Wall URL, report URL, and badge.
116
+ Can also be enabled with SHIPPINGSZN_PROOF=1.
100
117
  --base-url <url> Base URL used to build links back to checklist items.
101
118
  (default: ${DEFAULT_BASE_URL})
102
119
  --cwd <path> Directory to scan. Default: current working directory.
@@ -104,10 +121,21 @@ Options:
104
121
  -h, --help Show this help.
105
122
  -v, --version Print version.
106
123
 
107
- The scanner only reads files. It never writes, modifies, or deletes anything.
124
+ The scanner only reads files. It never writes, modifies, deletes, or makes
125
+ network requests unless you explicitly opt in with --publish or --proof.
108
126
  Exit code is non-zero if any Critical findings are detected.
109
127
  `);
110
128
  }
129
+ function buildTopNextStep(findings) {
130
+ const top = findings[0];
131
+ if (!top) {
132
+ return "Attach this clean repo scan proof to the launch record, then monitor first production traffic.";
133
+ }
134
+ return `Fix ${top.itemTitle}: ${top.message}`;
135
+ }
136
+ function scanSource() {
137
+ return process.env.GITHUB_ACTIONS === "true" ? "github" : "cli";
138
+ }
111
139
  async function run() {
112
140
  const opts = parseArgs(process.argv.slice(2));
113
141
  if (opts.help) {
@@ -138,13 +166,43 @@ async function run() {
138
166
  });
139
167
  }
140
168
  }
169
+ const trimmedBaseUrl = opts.baseUrl.replace(/\/$/, "");
170
+ const reportUrl = `${trimmedBaseUrl}/report`;
171
+ const proofCreatePath = `${trimmedBaseUrl}/scan`;
172
+ const proofUploadHint = "Run `npx shippingszn --json > shippingszn-scan.json`, then paste or upload that JSON in the web scan proof flow to create a shareable launch proof result.";
141
173
  const tracked_aware = applyTrackingAwareSeverity(all, tracked);
174
+ const source = scanSource();
142
175
  const enriched = tracked_aware.map((f) => {
143
176
  const item = CHECKLIST_ITEMS[f.itemId];
177
+ const remediation = buildRemediationPrompt({
178
+ finding: f,
179
+ item,
180
+ proofCreatePath,
181
+ reportUrl,
182
+ });
183
+ const itemTitle = item?.title ?? f.itemId;
184
+ const normalized = normalizeLaunchFinding({
185
+ severity: f.severity,
186
+ title: itemTitle,
187
+ body: f.message,
188
+ message: f.message,
189
+ evidence: f.evidence,
190
+ file: f.file,
191
+ line: f.line,
192
+ permalink: permalinkFor(f.itemId, opts.baseUrl),
193
+ itemTitle,
194
+ fixInstructions: remediation.fixPrompt,
195
+ aiBuilderPrompt: remediation.fixPrompt,
196
+ verificationStep: remediation.verify,
197
+ fixPrompt: remediation.fixPrompt,
198
+ verify: remediation.verify,
199
+ }, source);
144
200
  return {
145
201
  ...f,
146
- itemTitle: item?.title ?? f.itemId,
202
+ ...normalized,
203
+ itemTitle,
147
204
  permalink: permalinkFor(f.itemId, opts.baseUrl),
205
+ remediation,
148
206
  };
149
207
  });
150
208
  enriched.sort((a, b) => {
@@ -164,24 +222,72 @@ async function run() {
164
222
  };
165
223
  for (const f of enriched)
166
224
  totals[f.severity]++;
225
+ const assessment = assessLaunchReadiness({
226
+ source,
227
+ findings: enriched,
228
+ counts: totals,
229
+ });
230
+ const launchReadiness = {
231
+ score: assessment.score,
232
+ rawScore: assessment.rawScore,
233
+ label: assessment.label,
234
+ decision: assessment.decision,
235
+ decisionLabel: assessment.decisionLabel,
236
+ goNoGoLabel: assessment.goNoGoLabel,
237
+ confidence: assessment.confidence,
238
+ coveragePenalty: assessment.coveragePenalty,
239
+ coverageSummary: assessment.coverageSummary,
240
+ topNextStep: assessment.topNextStep,
241
+ proofCreatePath,
242
+ proofUploadHint,
243
+ reportRecommended: assessment.reportRecommended,
244
+ ...(assessment.reportRecommended ? { reportUrl } : {}),
245
+ };
167
246
  const report = {
168
247
  generatedAt: new Date().toISOString(),
248
+ source,
169
249
  baseUrl: opts.baseUrl,
170
250
  cwd: opts.cwd,
171
251
  filesScanned: files.length,
172
252
  totals,
253
+ launchReadiness,
173
254
  findings: enriched,
174
255
  };
175
- // Best-effort anonymous publish to the Wall of Launches. Runs once per
176
- // scan, before any return path. Never blocks on network failure. Full
177
- // opt-out via SHIPPINGSZN_DISABLE_PUBLISH=1.
178
- let publishResult = "skipped";
179
- try {
180
- publishResult = await publishScan(totals, files.length, {
181
- cwd: opts.cwd,
256
+ let proofResult = { status: "skipped" };
257
+ if (opts.proof) {
258
+ proofResult = await uploadProof(report, {
182
259
  baseUrl: opts.baseUrl,
183
260
  scannerVersion: PKG_VERSION,
184
261
  });
262
+ if (proofResult.status === "uploaded") {
263
+ report.launchReadiness.proofUrl = proofResult.proofUrl;
264
+ report.launchReadiness.proofResultId = proofResult.id;
265
+ report.launchReadiness.badgeMarkdown = proofResult.badgeMarkdown;
266
+ report.launchReadiness.reportUrl = proofResult.reportUrl;
267
+ report.launchReadiness.wallUrl = proofResult.wallUrl;
268
+ report.launchReadiness.wallPublishError = proofResult.wallPublishError;
269
+ }
270
+ else if (proofResult.status === "failed") {
271
+ report.launchReadiness.proofUploadError =
272
+ proofResult.error ?? "Proof upload failed.";
273
+ }
274
+ }
275
+ // Best-effort anonymous publish to the Wall of Launches. This is explicit
276
+ // opt-in because shippingszn is a trust product; scanning must stay local
277
+ // unless the user asks to publish proof.
278
+ let publishResult = "skipped";
279
+ try {
280
+ if (opts.publish) {
281
+ process.env["SHIPPINGSZN_PUBLISH"] = "1";
282
+ }
283
+ const proofAlreadyPublishedWall = proofResult.status === "uploaded" && !!proofResult.wallUrl;
284
+ if (!proofAlreadyPublishedWall) {
285
+ publishResult = await publishScan(totals, files.length, {
286
+ cwd: opts.cwd,
287
+ baseUrl: opts.baseUrl,
288
+ scannerVersion: PKG_VERSION,
289
+ });
290
+ }
185
291
  }
186
292
  catch {
187
293
  /* never block on wall publish */
@@ -200,19 +306,44 @@ async function run() {
200
306
  return c.blue;
201
307
  return c.gray;
202
308
  };
309
+ // Strip ASCII control characters (including ESC) so a maliciously-named
310
+ // file or matched secret slice cannot inject ANSI escape sequences into
311
+ // the operator's terminal.
312
+ const safe = (s) => s.replace(/[\x00-\x1f\x7f]/g, "?");
203
313
  process.stdout.write(`\n${c.bold("shippingszn")} ${c.dim(`v${PKG_VERSION}`)}\n`);
204
314
  process.stdout.write(c.dim(`Scanned ${files.length} files in ${opts.cwd}\n\n`));
315
+ process.stdout.write(`${c.bold("Launch Readiness Score:")} ${launchReadiness.score}/100 ${c.dim(`(${launchReadiness.label})`)}\n`);
316
+ process.stdout.write(`${c.bold("Scope:")} ${safe(launchReadiness.coverageSummary)}\n`);
317
+ process.stdout.write(`${c.bold("Top next step:")} ${safe(launchReadiness.topNextStep)}\n`);
318
+ process.stdout.write(`${c.bold("Fix prompts:")} ${c.dim("Run with --json for copy/paste AI-builder remediation prompts.")}\n`);
319
+ if (launchReadiness.reportUrl) {
320
+ process.stdout.write(`${c.bold("Need paid confidence?")} ${c.dim(`Get a launch-readiness report: ${launchReadiness.reportUrl}`)}\n`);
321
+ }
322
+ if (proofResult.status === "uploaded") {
323
+ process.stdout.write(`${c.bold("Proof URL:")} ${c.dim(proofResult.proofUrl ?? "")}\n`);
324
+ process.stdout.write(`${c.bold("Report URL:")} ${c.dim(proofResult.reportUrl ?? "")}\n`);
325
+ if (proofResult.wallUrl) {
326
+ process.stdout.write(`${c.bold("Wall URL:")} ${c.dim(proofResult.wallUrl)}\n`);
327
+ }
328
+ process.stdout.write(`${c.bold("Badge Markdown:")} ${c.dim(proofResult.badgeMarkdown ?? "")}\n`);
329
+ if (proofResult.wallPublishError) {
330
+ process.stdout.write(`${c.bold("Wall publish failed:")} ${c.dim(proofResult.wallPublishError)}\n`);
331
+ }
332
+ }
333
+ else if (proofResult.status === "failed") {
334
+ process.stdout.write(`${c.bold("Proof upload failed:")} ${c.dim(proofResult.error ?? "Unknown upload error.")}\n`);
335
+ }
336
+ else {
337
+ process.stdout.write(`${c.bold("Share proof:")} ${c.dim("Run with --proof to publish this scan to a public proof URL.")}\n`);
338
+ }
339
+ process.stdout.write("\n");
205
340
  if (enriched.length === 0) {
206
- process.stdout.write(c.green("No findings. Nice work still walk through the full checklist before launch.\n\n"));
341
+ process.stdout.write(c.green("No findings. Attach the clean scan proof to the launch record before shipping.\n\n"));
207
342
  if (publishResult === "published") {
208
- process.stdout.write(c.dim(`Posted an anonymous summary to the Wall: ${opts.baseUrl}/wall\n(opt out: SHIPPINGSZN_DISABLE_PUBLISH=1)\n\n`));
343
+ process.stdout.write(c.dim(`Posted an anonymous summary to the Wall: ${opts.baseUrl}/wall\n`));
209
344
  }
210
345
  return 0;
211
346
  }
212
- // Strip ASCII control characters (including ESC) so a maliciously-named
213
- // file or matched secret slice cannot inject ANSI escape sequences into
214
- // the operator's terminal.
215
- const safe = (s) => s.replace(/[\x00-\x1f\x7f]/g, "?");
216
347
  for (const sev of SEVERITY_ORDER) {
217
348
  const group = enriched.filter((f) => f.severity === sev);
218
349
  if (group.length === 0)
@@ -232,7 +363,7 @@ async function run() {
232
363
  }
233
364
  process.stdout.write(`${c.bold("Summary:")} ${c.red(`${totals.critical} critical`)}, ${c.yellow(`${totals.high} high`)}, ${c.blue(`${totals.medium} medium`)}, ${c.gray(`${totals.lower} lower`)}\n`);
234
365
  if (publishResult === "published") {
235
- process.stdout.write(c.dim(`\nPosted an anonymous summary to the Wall: ${opts.baseUrl}/wall\n(opt out: SHIPPINGSZN_DISABLE_PUBLISH=1)\n`));
366
+ process.stdout.write(c.dim(`\nPosted an anonymous summary to the Wall: ${opts.baseUrl}/wall\n`));
236
367
  }
237
368
  if (totals.critical > 0) {
238
369
  process.stdout.write(c.red("\nCritical findings detected. Exiting with code 1.\n"));
package/dist/proof.js ADDED
@@ -0,0 +1,161 @@
1
+ import * as path from "node:path";
2
+ const PROOF_TIMEOUT_MS = 5000;
3
+ const MAX_FINDINGS = 100;
4
+ export function shouldUploadProof() {
5
+ const v = process.env["SHIPPINGSZN_PROOF"] ?? "";
6
+ return v === "1" || v.toLowerCase() === "true" || v === "yes";
7
+ }
8
+ function clampText(value, max, fallback = "") {
9
+ const text = value ?? fallback;
10
+ return text.length > max ? text.slice(0, max - 3) + "..." : text;
11
+ }
12
+ function locationFromFinding(finding) {
13
+ if (!finding.file)
14
+ return undefined;
15
+ return finding.line ? `${finding.file}:${finding.line}` : finding.file;
16
+ }
17
+ export function buildBadgeMarkdown(baseUrl, id, score) {
18
+ const params = new URLSearchParams({
19
+ scanResultId: id,
20
+ theme: "dark",
21
+ });
22
+ const proofUrl = `${baseUrl}/proof/${encodeURIComponent(id)}`;
23
+ return `[![Launch readiness proof: ${score}%](${baseUrl}/api/badge.svg?${params.toString()})](${proofUrl})`;
24
+ }
25
+ export function buildProofPayload(report, scannerVersion) {
26
+ const counts = {
27
+ critical: report.totals.critical,
28
+ high: report.totals.high,
29
+ medium: report.totals.medium,
30
+ lower: report.totals.lower,
31
+ };
32
+ return {
33
+ version: 1,
34
+ source: report.source ?? "cli",
35
+ scanner: "shippingszn",
36
+ targetName: path.basename(report.cwd) || "CLI scan",
37
+ score: report.launchReadiness.score,
38
+ label: report.launchReadiness.label,
39
+ decision: report.launchReadiness.decision,
40
+ decisionLabel: report.launchReadiness.decisionLabel,
41
+ goNoGoLabel: report.launchReadiness.goNoGoLabel,
42
+ confidence: report.launchReadiness.confidence,
43
+ checkedAt: report.generatedAt,
44
+ counts,
45
+ findings: report.findings.slice(0, MAX_FINDINGS).map((finding) => ({
46
+ severity: finding.severity,
47
+ title: clampText(finding.itemTitle || finding.checkId, 160),
48
+ body: clampText(finding.message, 3000),
49
+ whatFailed: clampText(finding.whatFailed, 3000),
50
+ whyItBlocksLaunch: clampText(finding.whyItBlocksLaunch, 3000),
51
+ fixInstructions: clampText(finding.fixInstructions, 4000),
52
+ aiBuilderPrompt: clampText(finding.aiBuilderPrompt, 4000),
53
+ evidence: clampText(finding.evidence, 3000),
54
+ confidence: finding.confidence,
55
+ fixPrompt: clampText(finding.aiBuilderPrompt, 4000),
56
+ verify: clampText(finding.verificationStep, 3000),
57
+ verificationStep: clampText(finding.verificationStep, 3000),
58
+ ...(locationFromFinding(finding)
59
+ ? { location: clampText(locationFromFinding(finding), 500) }
60
+ : {}),
61
+ permalink: finding.permalink,
62
+ itemTitle: clampText(finding.itemTitle, 160),
63
+ })),
64
+ filesScanned: report.filesScanned,
65
+ topNextStep: clampText(report.launchReadiness.topNextStep, 1000),
66
+ reportRecommended: report.launchReadiness.reportRecommended,
67
+ ...(report.launchReadiness.reportUrl
68
+ ? { reportUrl: report.launchReadiness.reportUrl }
69
+ : {}),
70
+ scannerVersion,
71
+ };
72
+ }
73
+ function buildWallPayload(report, scanResultId, scannerVersion) {
74
+ return {
75
+ source: "cli",
76
+ scanResultId,
77
+ score: report.launchReadiness.score,
78
+ label: report.launchReadiness.label,
79
+ filesScanned: report.filesScanned,
80
+ findingsCritical: report.totals.critical,
81
+ findingsHigh: report.totals.high,
82
+ findingsMedium: report.totals.medium,
83
+ findingsLower: report.totals.lower,
84
+ scannerVersion,
85
+ };
86
+ }
87
+ async function publishProofToWall(baseUrl, report, scanResultId, scannerVersion) {
88
+ const controller = new AbortController();
89
+ const timer = setTimeout(() => controller.abort(), PROOF_TIMEOUT_MS);
90
+ try {
91
+ const res = await fetch(`${baseUrl}/api/wall`, {
92
+ method: "POST",
93
+ headers: {
94
+ "content-type": "application/json",
95
+ "user-agent": `shippingszn-cli/${scannerVersion}`,
96
+ },
97
+ body: JSON.stringify(buildWallPayload(report, scanResultId, scannerVersion)),
98
+ signal: controller.signal,
99
+ });
100
+ clearTimeout(timer);
101
+ if (!res.ok) {
102
+ return { error: `Wall publish failed with HTTP ${res.status}.` };
103
+ }
104
+ return { wallUrl: `${baseUrl}/wall` };
105
+ }
106
+ catch (err) {
107
+ clearTimeout(timer);
108
+ return {
109
+ error: `Wall publish failed: ${err instanceof Error ? err.message : String(err)}`,
110
+ };
111
+ }
112
+ }
113
+ export async function uploadProof(report, opts) {
114
+ const baseUrl = opts.baseUrl.replace(/\/$/, "");
115
+ const payload = buildProofPayload(report, opts.scannerVersion);
116
+ const controller = new AbortController();
117
+ const timer = setTimeout(() => controller.abort(), PROOF_TIMEOUT_MS);
118
+ try {
119
+ const res = await fetch(`${baseUrl}/api/scan-results`, {
120
+ method: "POST",
121
+ headers: {
122
+ "content-type": "application/json",
123
+ "user-agent": `shippingszn-cli/${opts.scannerVersion}`,
124
+ },
125
+ body: JSON.stringify(payload),
126
+ signal: controller.signal,
127
+ });
128
+ clearTimeout(timer);
129
+ if (!res.ok) {
130
+ return {
131
+ status: "failed",
132
+ error: `Proof upload failed with HTTP ${res.status}.`,
133
+ };
134
+ }
135
+ const body = (await res.json());
136
+ const id = typeof body.id === "string" ? body.id : "";
137
+ if (!id) {
138
+ return {
139
+ status: "failed",
140
+ error: "Proof upload succeeded but the response did not include an id.",
141
+ };
142
+ }
143
+ const wall = await publishProofToWall(baseUrl, report, id, opts.scannerVersion);
144
+ return {
145
+ status: "uploaded",
146
+ id,
147
+ proofUrl: `${baseUrl}/proof/${encodeURIComponent(id)}`,
148
+ reportUrl: `${baseUrl}/report?${new URLSearchParams({ scanResultId: id }).toString()}`,
149
+ badgeMarkdown: buildBadgeMarkdown(baseUrl, id, report.launchReadiness.score),
150
+ ...(wall.wallUrl ? { wallUrl: wall.wallUrl } : {}),
151
+ ...(wall.error ? { wallPublishError: wall.error } : {}),
152
+ };
153
+ }
154
+ catch (err) {
155
+ clearTimeout(timer);
156
+ return {
157
+ status: "failed",
158
+ error: `Proof upload failed: ${err instanceof Error ? err.message : String(err)}`,
159
+ };
160
+ }
161
+ }
package/dist/publish.js CHANGED
@@ -1,22 +1,21 @@
1
1
  /**
2
- * Anonymous auto-publish of scan results to the Wall of Launches at
3
- * shippingszn.com. Runs once per scan, best-effort. Never blocks the CLI
4
- * on network failure — if anything goes wrong we fail silently and the
5
- * scan result still prints normally.
2
+ * Anonymous opt-in publish of scan results to the Wall of Launches at
3
+ * shippingszn.com. Never blocks the CLI on network failure if anything
4
+ * goes wrong we fail silently and the scan result still prints normally.
6
5
  *
7
6
  * Absolute guarantees about the payload: no secrets, no paths, no
8
7
  * filenames, no project-name-derived strings. The only thing we send
9
8
  * is: files scanned count, findings counts by severity, detected stack
10
- * tags, scanner version. Users can opt out entirely by setting
11
- * SHIPPINGSZN_DISABLE_PUBLISH=1 in their environment.
9
+ * tags, scanner version. Publishing only runs when the user passes
10
+ * --publish or sets SHIPPINGSZN_PUBLISH=1 in their environment.
12
11
  */
13
12
  import { promises as fs } from "node:fs";
14
13
  import * as path from "node:path";
15
14
  const DEFAULT_BASE_URL = "https://shippingszn.com";
16
15
  const PUBLISH_TIMEOUT_MS = 3000;
17
16
  function shouldPublish() {
18
- const v = process.env["SHIPPINGSZN_DISABLE_PUBLISH"] ?? "";
19
- return v !== "1" && v.toLowerCase() !== "true" && v !== "yes";
17
+ const v = process.env["SHIPPINGSZN_PUBLISH"] ?? "";
18
+ return v === "1" || v.toLowerCase() === "true" || v === "yes";
20
19
  }
21
20
  // Detect a small list of tech-stack tags from package.json / manifest
22
21
  // files. Deliberately shallow: we only want a handful of widely-known
@@ -0,0 +1,36 @@
1
+ const SEVERITY_INTENT = {
2
+ critical: "Treat this as launch-blocking until fixed.",
3
+ high: "Fix this before public traffic or paid acquisition.",
4
+ medium: "Fix this before announcing or indexing the launch.",
5
+ lower: "Fix this when polishing the launch artifact.",
6
+ };
7
+ const ESCALATION = {
8
+ critical: "Escalate before launch if this touches secrets, auth, payments, customer data, production infrastructure, or anything already exposed publicly.",
9
+ high: "Escalate if the fix affects auth, payments, deploy config, browser security, or a user-visible production path.",
10
+ medium: "Escalate only if the fix changes routing, indexing, analytics, or public launch messaging.",
11
+ lower: "Escalation is usually unnecessary unless this blocks installability, branding, or a promised launch surface.",
12
+ };
13
+ function locationForPrompt(finding) {
14
+ if (!finding.file)
15
+ return "No specific file was attached to the finding.";
16
+ const line = finding.line ? `:${finding.line}` : "";
17
+ return `Likely location: ${finding.file}${line}.`;
18
+ }
19
+ function proofStepForSeverity(severity, proofCreatePath, reportUrl) {
20
+ const proofStep = `After the fix verifies clean, run npx shippingszn --json and paste or upload the JSON at ${proofCreatePath} to create launch proof.`;
21
+ if (severity === "critical" || severity === "high") {
22
+ return `${proofStep} If this app has users, revenue, client trust, or paid API exposure, attach the clean scan to a paid launch-readiness report at ${reportUrl}.`;
23
+ }
24
+ return proofStep;
25
+ }
26
+ export function buildRemediationPrompt({ finding, item, proofCreatePath, reportUrl, }) {
27
+ const itemTitle = item?.title ?? finding.itemId;
28
+ const location = locationForPrompt(finding);
29
+ const intent = SEVERITY_INTENT[finding.severity];
30
+ return {
31
+ fixPrompt: `You are fixing a shippingszn launch-readiness finding. Severity: ${finding.severity}. Checklist area: ${itemTitle}. Finding: ${finding.message} ${location} ${intent} Make the smallest production-safe code or config change that removes the underlying risk, preserve existing behavior, and list the files changed. Do not suppress the scanner unless you can prove the finding is a false positive.`,
32
+ verify: "Re-run npx shippingszn --json and confirm this exact finding is gone. Also run the app's normal typecheck/test/build command when the fix changes code, config, routing, security behavior, or public assets.",
33
+ escalation: ESCALATION[finding.severity],
34
+ proofNextStep: proofStepForSeverity(finding.severity, proofCreatePath, reportUrl),
35
+ };
36
+ }
package/dist/scan.js CHANGED
@@ -21,6 +21,29 @@ export function getTrackedFiles(rootDir) {
21
21
  }
22
22
  return out;
23
23
  }
24
+ /**
25
+ * Ask git for tracked + untracked-but-not-ignored files. Used to filter the
26
+ * filesystem walk so that gitignored output (build artifacts, generated
27
+ * playwright reports, local caches, .env files) doesn't generate noisy
28
+ * false-positive findings. Returns null when the directory isn't a git
29
+ * work tree, in which case the caller falls back to scanning everything
30
+ * the directory walker finds.
31
+ *
32
+ * `-c` cached, `-o` others, `--exclude-standard` honors .gitignore +
33
+ * .git/info/exclude + the user's global excludesfile. This is the
34
+ * standard "git-aware" file listing approach.
35
+ */
36
+ export function getNotIgnoredFiles(rootDir) {
37
+ const result = spawnSync("git", ["-C", rootDir, "ls-files", "-co", "--exclude-standard", "-z"], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
38
+ if (result.status !== 0 || !result.stdout)
39
+ return null;
40
+ const out = new Set();
41
+ for (const rel of result.stdout.split("\0")) {
42
+ if (rel)
43
+ out.add(rel);
44
+ }
45
+ return out;
46
+ }
24
47
  const DEFAULT_IGNORES = new Set([
25
48
  "node_modules",
26
49
  ".git",
@@ -85,6 +108,13 @@ export async function listFiles(rootDir) {
85
108
  const out = [];
86
109
  const visited = new Set();
87
110
  const rootResolved = path.resolve(rootDir);
111
+ // When running inside a git repo, exclude anything .gitignore'd from the
112
+ // walk. This stops generated artifacts (e2e/playwright-report/, dist/
113
+ // contents the user's repo gitignores explicitly, .env files in projects
114
+ // that ignore them but live outside our DEFAULT_IGNORES) from producing
115
+ // noisy false-positive findings. Outside a git repo we fall back to
116
+ // walking everything DEFAULT_IGNORES doesn't already strip.
117
+ const allowed = getNotIgnoredFiles(rootDir);
88
118
  async function walk(dir, depth) {
89
119
  if (depth > MAX_DEPTH)
90
120
  return;
@@ -145,6 +175,14 @@ export async function listFiles(rootDir) {
145
175
  }
146
176
  else if (entry.isFile()) {
147
177
  const rel = path.relative(rootDir, abs);
178
+ // Honor .gitignore when we have it. Comparison uses POSIX-style
179
+ // separators because git ls-files always returns forward-slashed
180
+ // paths even on Windows.
181
+ if (allowed) {
182
+ const relPosixPath = rel.split(path.sep).join("/");
183
+ if (!allowed.has(relPosixPath))
184
+ continue;
185
+ }
148
186
  let size = 0;
149
187
  try {
150
188
  const st = await fs.stat(abs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shippingszn",
3
- "version": "0.7.1",
3
+ "version": "0.8.2",
4
4
  "description": "Read-only CLI scanner that checks a project for common pre-launch issues from the shippingszn.com launch checklist.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -45,6 +45,7 @@
45
45
  "prepublishOnly": "npm run clean && npm run build && npm test"
46
46
  },
47
47
  "devDependencies": {
48
+ "@workspace/launch-readiness": "workspace:*",
48
49
  "@types/node": "catalog:",
49
50
  "tsx": "catalog:",
50
51
  "typescript": "^5.6.3"