shippingszn 0.8.3 → 0.8.5

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 (3) hide show
  1. package/README.md +59 -58
  2. package/dist/index.js +378 -155
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -12,13 +12,14 @@ pnpm dlx shippingszn
12
12
  ```
13
13
 
14
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`.
15
+ By default it posts one anonymous Wall of Launches entry with score, severity
16
+ counts, files scanned, scanner version, and safe stack tags. It never uploads
17
+ source code, paths, filenames, repo URLs, project names, secrets, handles, or
18
+ emails. Use `--no-publish` for a private local run.
17
19
 
18
20
  Human output starts with a Launch Readiness Score, scope covered, the top next
19
21
  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
+ findings plus the Wall/report handoff fields:
22
23
 
23
24
  ```json
24
25
  {
@@ -32,24 +33,15 @@ fields:
32
33
  "coveragePenalty": 0,
33
34
  "coverageSummary": "CLI scan covered 4 readiness areas with 4 areas requiring owner verification.",
34
35
  "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
36
  "wallUrl": "https://shippingszn.com/wall",
41
- "reportUrl": "https://shippingszn.com/report?scanResultId=00000000-0000-4000-8000-000000000123"
37
+ "reportUrl": "https://shippingszn.com/report"
42
38
  },
43
39
  "findings": [
44
40
  {
45
41
  "severity": "critical",
46
42
  "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
- }
43
+ "whatFailed": "A secret-like token appears in a scanned file.",
44
+ "whyItBlocksLaunch": "A leaked key can spend money or expose customer data before you notice."
53
45
  }
54
46
  ]
55
47
  }
@@ -59,26 +51,20 @@ When Critical or High findings make paid confidence relevant, the summary also
59
51
  includes a `/report` link. Clean scans and lower-risk findings do not push the
60
52
  paid report CTA.
61
53
 
62
- ## Direct proof publishing
54
+ ## Wall publishing
63
55
 
64
- Use `--proof` when you want the current scan to become shareable launch proof:
56
+ Plain `npx shippingszn` feeds the Wall automatically. The public row is
57
+ anonymous and intentionally small: score, launch label, files scanned, finding
58
+ counts, detected stack tags, scanner version, and timestamp.
59
+
60
+ Use this when you want a completely private local run:
65
61
 
66
62
  ```bash
67
- npx shippingszn --json --proof > shippingszn-scan.json
63
+ npx shippingszn --no-publish
68
64
  ```
69
65
 
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`.
66
+ For a paid-report handoff, run `npx shippingszn --json > shippingszn-scan.json`
67
+ and paste or upload the file at `https://shippingszn.com/scan`.
82
68
 
83
69
  ## What gets checked
84
70
 
@@ -93,6 +79,9 @@ maps back to one of the items on the checklist.
93
79
  - Missing security-header middleware in common server configs.
94
80
  - Dangerous code patterns: unsafe HTML injection in React, runtime
95
81
  code-execution calls, wildcard CORS.
82
+ - OTP/auth readiness signals: phone normalization, resend/cooldown behavior,
83
+ anti-enumeration copy, mobile one-time-code input, delivery-smoke evidence,
84
+ recovery paths, and paid report access that depends on OTP.
96
85
  - Python: common debug-mode slip-ups, hardcoded framework secrets, missing
97
86
  env-var loading.
98
87
  - Ruby: unsafe string rendering, hardcoded Rails secrets.
@@ -104,6 +93,32 @@ maps back to one of the items on the checklist.
104
93
  Each finding is tagged Critical, High, Medium, or Lower and links back to the
105
94
  relevant checklist item on shippingszn.com.
106
95
 
96
+ ## Suppressing false positives
97
+
98
+ Two opt-out mechanisms, both off by default:
99
+
100
+ - **`.gitignore` is respected.** Files your repo gitignores (build output,
101
+ generated reports, local `.env`, vendored sub-projects) are skipped.
102
+ This works automatically inside any git repo; outside a git repo the
103
+ scanner falls back to walking the full directory.
104
+ - **Inline ignore markers.** For one-off cases where a file legitimately
105
+ contains a pattern the scanner detects (a regex literal, copy that
106
+ describes a placeholder, a test fixture), add one of:
107
+
108
+ ```ts
109
+ // shippingszn:ignore — placed on the same line as the match
110
+ const x = "lorem ipsum"; // shippingszn:ignore — fixture text
111
+
112
+ // shippingszn:ignore-next-line — placed on the line above the match
113
+ // shippingszn:ignore-next-line
114
+ const greeting = "hello placeholder";
115
+ ```
116
+
117
+ Markers apply to substring/regex checks (placeholder content, dangerous
118
+ patterns, language patterns). They deliberately do **not** apply to
119
+ hardcoded-secret detection — false positives there should be addressed
120
+ by removing the secret pattern, not by allowlisting.
121
+
107
122
  ## What does NOT get checked
108
123
 
109
124
  These are deliberately out of scope for v1:
@@ -127,12 +142,8 @@ shippingszn [path] [options]
127
142
 
128
143
  Options:
129
144
  --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.
145
+ --publish Legacy alias. Anonymous Wall stats publish by default.
146
+ --no-publish Disable the anonymous Wall post for this run.
136
147
  --base-url <url> Base URL used to build links back to checklist items.
137
148
  --cwd <path> Directory to scan. Default: current working directory.
138
149
  --no-color Disable ANSI colors in the human-readable report.
@@ -150,32 +161,22 @@ This makes the CLI suitable for CI:
150
161
 
151
162
  ```yaml
152
163
  # .github/workflows/launch-check.yml
153
- - run: npx shippingszn --json --proof > launch-check.json
164
+ - run: npx shippingszn --json > launch-check.json
154
165
  ```
155
166
 
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.
167
+ For PR scan signal, have GitHub Actions run the scanner and post the JSON
168
+ summary as a comment: score, severity counts, top next step, Wall URL, report
169
+ URL, and worst Critical/High findings.
161
170
 
162
171
  ## Privacy
163
172
 
164
- `shippingszn` reads files on your machine. It never uploads source code,
165
- makes outbound network calls, or phones home by default. No accounts.
166
- Inspect the source or audit `npm pack --dry-run` to confirm.
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.
173
+ `shippingszn` reads files on your machine. It never uploads source code. By
174
+ default it makes one best-effort outbound request to post anonymous Wall stats:
175
+ score, launch label, files scanned count, finding counts by severity, detected
176
+ stack tags, scanner version, and timestamp. It never uploads source code, file
177
+ paths, filenames, project names, repo URLs, secrets, emails, handles, or report
178
+ contents. Use `--no-publish` or `SHIPPINGSZN_DISABLE_PUBLISH=1` to disable that
179
+ request.
179
180
 
180
181
  ## License
181
182
 
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import * as path11 from "node:path";
4
+ import * as path12 from "node:path";
5
5
  import * as process2 from "node:process";
6
6
  import { createRequire } from "node:module";
7
7
 
@@ -246,6 +246,7 @@ var PATTERN_DEFINITION_FILES = /* @__PURE__ */ new Set([
246
246
  "tools/cli/src/checks/dangerous.ts",
247
247
  "tools/cli/src/checks/quality.ts",
248
248
  "tools/cli/src/checks/language.ts",
249
+ "tools/cli/src/checks/otp-auth.ts",
249
250
  "tools/cli/README.md",
250
251
  // Test fixture for the redaction module: contains intentional fake
251
252
  // secret patterns whose whole purpose is to verify the redactor scrubs
@@ -1164,6 +1165,258 @@ async function checkPlaceholderContent(ctx) {
1164
1165
  return findings.concat(placeholderHits.slice(0, 25));
1165
1166
  }
1166
1167
 
1168
+ // src/checks/otp-auth.ts
1169
+ import * as path9 from "node:path";
1170
+ var SCANNABLE_EXTENSIONS = /* @__PURE__ */ new Set([
1171
+ ".ts",
1172
+ ".tsx",
1173
+ ".js",
1174
+ ".jsx",
1175
+ ".mjs",
1176
+ ".cjs",
1177
+ ".md",
1178
+ ".mdx",
1179
+ ".html",
1180
+ ".htm",
1181
+ ".vue",
1182
+ ".svelte",
1183
+ ".astro",
1184
+ ".py",
1185
+ ".rb",
1186
+ ".go",
1187
+ ".php",
1188
+ ".java",
1189
+ ".kt",
1190
+ ".swift",
1191
+ ".cs"
1192
+ ]);
1193
+ var OTP_CONTEXT = /\b(otp|one[-\s]?time(?: password| code)?|verification code|verify code|login code|magic code|passcode|2fa|mfa|two[-\s]?factor)\b/i;
1194
+ var AUTH_CONTEXT = /\b(auth|sign[-\s]?in|signin|login|session|protected route|private route)\b/i;
1195
+ var SMS_OR_PHONE_CONTEXT = /\b(sms|text message|twilio|phone|mobile number|mobile phone|PhoneInput)\b/i;
1196
+ var PAID_REPORT_CONTEXT = /\b(paid report|report access|checkout handoff|checkoutHandoff|Stripe checkout|checkout email|checkout phone|checkout mobile|retrieve your report|account\/purchases|report session)\b/i;
1197
+ var FRONTEND_PATH_CONTEXT = /(^|\/)(login|signin|sign-in|auth|account|report|checkout)[^/]*\.(tsx|jsx|html|vue|svelte|astro)$/i;
1198
+ var PHONE_NORMALIZATION = /\b(normalizePhone|libphonenumber|parsePhoneNumber|isValidPhoneNumber|PhoneInput|E\.164|e164|react-phone-number-input|AsYouType)\b/i;
1199
+ var RESEND_BEHAVIOR = /\b(resend|send another|send a new code|request another|retry-after|try again in|wait a minute|cooldown|backoff)\b/i;
1200
+ var RATE_LIMIT = /\b(rateLimit|rate limit|Too many requests|retry-after|429|throttl|attempt limit|cooldown)\b/i;
1201
+ var ANTI_ENUMERATION = /\b(anti[-\s]?enumeration|success[-\s]?shaped|generic response|do not reveal|without revealing|hasPaidPurchase|no paid purchase|paid purchases|return\s+\{?\s*ok:\s*true|res\.json\(\s*\{\s*ok:\s*true)\b/i;
1202
+ var ENUMERATION_LEAK = /\b(?:user|account|email|phone|checkout|purchase|report)\s+(?:not\s+found|does\s+not\s+exist|not\s+recognized|not\s+registered|has no paid|has no purchase|not paid)|\bno\s+(?:account|user|purchase|paid checkout)\b/i;
1203
+ var MOBILE_OTP_INPUT = /\b(inputMode|inputmode|one-time-code|autocomplete=["']one-time-code|maxLength\s*=\s*\{?\s*6|type=["']tel|pattern=["'][^"']*\\d|InputOTP|numeric)\b/i;
1204
+ var CLEAR_COPY = /\b(6[-\s]?digit|six[-\s]?digit|verification code|one[-\s]?time code|code expires|expires? in|latest code|checkout email|checkout mobile|same email|same mobile|SMS code|Email code|Stripe receipt)\b/i;
1205
+ var RECOVERY_PATH = /\b(support|receipt|fallback|email fallback|alternate channel|try email|try sms|contact us|restart checkout|fulfillment|purchase history|account\/purchases|returnTo|different contact)\b/i;
1206
+ var DELIVERY_VERIFICATION = /\b(deliverability|delivery|delivered|arrives?|smoke|gateway|Twilio Verify|Email OTP Gateway|verifyGateway|requestGateway|mail[-\s]?tester|SPF|DKIM|DMARC|branded)\b/i;
1207
+ function shouldScan(file) {
1208
+ if (!isTextFile(file)) return false;
1209
+ if (isScanExempt(file.relPath)) return false;
1210
+ const ext = path9.extname(file.relPath).toLowerCase();
1211
+ return SCANNABLE_EXTENSIONS.has(ext);
1212
+ }
1213
+ function firstHit(file, content, regex) {
1214
+ const match = regex.exec(content);
1215
+ if (!match) return void 0;
1216
+ if (lineContainsIgnoreMarker(content, match.index)) return void 0;
1217
+ return {
1218
+ file: relPosix(file.relPath),
1219
+ line: findLine(content, match.index),
1220
+ text: match[0]
1221
+ };
1222
+ }
1223
+ function pick(existing, next) {
1224
+ return existing ?? next;
1225
+ }
1226
+ function evidence(hit, fallback) {
1227
+ if (!hit) return fallback;
1228
+ return `${hit.file}:${hit.line} matched "${hit.text}".`;
1229
+ }
1230
+ function finding(input) {
1231
+ return {
1232
+ checkId: input.checkId,
1233
+ itemId: "secure-auth",
1234
+ severity: input.severity,
1235
+ message: input.message,
1236
+ evidence: input.evidence,
1237
+ ...input.hit ? { file: input.hit.file, line: input.hit.line } : {}
1238
+ };
1239
+ }
1240
+ async function collectOtpAuthSignals(ctx) {
1241
+ const signals = {
1242
+ hasOtp: false,
1243
+ hasSmsOrPhone: false,
1244
+ hasPaidReportOtp: false,
1245
+ hasFrontendOtp: false
1246
+ };
1247
+ for (const file of ctx.files) {
1248
+ if (!shouldScan(file)) continue;
1249
+ const content = await readFileSafe(file);
1250
+ if (!content) continue;
1251
+ const rel = relPosix(file.relPath);
1252
+ const otpHit = firstHit(file, content, OTP_CONTEXT);
1253
+ const authHit = firstHit(file, content, AUTH_CONTEXT);
1254
+ const smsHit = firstHit(file, content, SMS_OR_PHONE_CONTEXT);
1255
+ const paidHit = firstHit(file, content, PAID_REPORT_CONTEXT);
1256
+ const frontendContext = FRONTEND_PATH_CONTEXT.test(rel);
1257
+ signals.hasOtp = signals.hasOtp || Boolean(otpHit);
1258
+ signals.hasSmsOrPhone = signals.hasSmsOrPhone || Boolean(smsHit);
1259
+ signals.hasPaidReportOtp = signals.hasPaidReportOtp || Boolean(paidHit && (otpHit || authHit));
1260
+ signals.hasFrontendOtp = signals.hasFrontendOtp || Boolean(frontendContext && (otpHit || authHit));
1261
+ signals.contextHit = pick(
1262
+ signals.contextHit,
1263
+ otpHit ?? paidHit ?? authHit ?? smsHit
1264
+ );
1265
+ signals.phoneNormalization = pick(
1266
+ signals.phoneNormalization,
1267
+ firstHit(file, content, PHONE_NORMALIZATION)
1268
+ );
1269
+ signals.resendBehavior = pick(
1270
+ signals.resendBehavior,
1271
+ firstHit(file, content, RESEND_BEHAVIOR)
1272
+ );
1273
+ signals.rateLimit = pick(signals.rateLimit, firstHit(file, content, RATE_LIMIT));
1274
+ signals.antiEnumeration = pick(
1275
+ signals.antiEnumeration,
1276
+ firstHit(file, content, ANTI_ENUMERATION)
1277
+ );
1278
+ signals.enumerationLeak = pick(
1279
+ signals.enumerationLeak,
1280
+ firstHit(file, content, ENUMERATION_LEAK)
1281
+ );
1282
+ signals.mobileOtpInput = pick(
1283
+ signals.mobileOtpInput,
1284
+ firstHit(file, content, MOBILE_OTP_INPUT)
1285
+ );
1286
+ signals.clearCopy = pick(
1287
+ signals.clearCopy,
1288
+ firstHit(file, content, CLEAR_COPY)
1289
+ );
1290
+ signals.recoveryPath = pick(
1291
+ signals.recoveryPath,
1292
+ firstHit(file, content, RECOVERY_PATH)
1293
+ );
1294
+ signals.deliveryVerification = pick(
1295
+ signals.deliveryVerification,
1296
+ firstHit(file, content, DELIVERY_VERIFICATION)
1297
+ );
1298
+ }
1299
+ return signals;
1300
+ }
1301
+ async function checkOtpAuthReadiness(ctx) {
1302
+ const signals = await collectOtpAuthSignals(ctx);
1303
+ if (!signals.hasOtp && !signals.hasPaidReportOtp) return [];
1304
+ const findings = [];
1305
+ if (signals.hasSmsOrPhone && !signals.phoneNormalization) {
1306
+ findings.push(
1307
+ finding({
1308
+ checkId: "otp-phone-normalization-missing",
1309
+ severity: "high",
1310
+ hit: signals.contextHit,
1311
+ message: "OTP/auth flow mentions SMS or phone numbers but no phone normalization signal was found. Normalize to E.164 before lookup, delivery, and paid report access.",
1312
+ evidence: evidence(
1313
+ signals.contextHit,
1314
+ "OTP/SMS context was found without a phone normalization signal."
1315
+ )
1316
+ })
1317
+ );
1318
+ }
1319
+ if (signals.hasOtp && !signals.rateLimit) {
1320
+ findings.push(
1321
+ finding({
1322
+ checkId: "otp-rate-limit-missing",
1323
+ severity: "high",
1324
+ hit: signals.contextHit,
1325
+ message: "OTP/auth flow does not show a rate limit, retry-after, or throttling signal. Launching without this invites brute-force code guessing and SMS/email abuse.",
1326
+ evidence: evidence(
1327
+ signals.contextHit,
1328
+ "OTP context was found without a rate-limit signal."
1329
+ )
1330
+ })
1331
+ );
1332
+ }
1333
+ if (signals.enumerationLeak && !signals.antiEnumeration) {
1334
+ findings.push(
1335
+ finding({
1336
+ checkId: "otp-enumeration-leak",
1337
+ severity: "high",
1338
+ hit: signals.enumerationLeak,
1339
+ message: "OTP/auth copy appears to reveal whether a user, purchase, or report exists. Use success-shaped start responses and generic failure copy before launch.",
1340
+ evidence: evidence(
1341
+ signals.enumerationLeak,
1342
+ "Potential enumeration copy was found."
1343
+ )
1344
+ })
1345
+ );
1346
+ }
1347
+ if (signals.hasOtp && !signals.resendBehavior) {
1348
+ findings.push(
1349
+ finding({
1350
+ checkId: "otp-resend-behavior-missing",
1351
+ severity: "medium",
1352
+ hit: signals.contextHit,
1353
+ message: "OTP/auth flow does not show resend, cooldown, or retry copy. Users need a clear path when an email/SMS code is delayed.",
1354
+ evidence: evidence(
1355
+ signals.contextHit,
1356
+ "OTP context was found without resend or cooldown evidence."
1357
+ )
1358
+ })
1359
+ );
1360
+ }
1361
+ if (signals.hasFrontendOtp && !signals.mobileOtpInput) {
1362
+ findings.push(
1363
+ finding({
1364
+ checkId: "otp-mobile-input-missing",
1365
+ severity: "medium",
1366
+ hit: signals.contextHit,
1367
+ message: "Frontend OTP/auth flow does not show mobile-friendly one-time-code input behavior. Use six numeric slots or inputMode/autocomplete support before launch.",
1368
+ evidence: evidence(
1369
+ signals.contextHit,
1370
+ "Frontend OTP context was found without mobile one-time-code input evidence."
1371
+ )
1372
+ })
1373
+ );
1374
+ }
1375
+ if (signals.hasOtp && !signals.clearCopy) {
1376
+ findings.push(
1377
+ finding({
1378
+ checkId: "otp-copy-clarity-missing",
1379
+ severity: "medium",
1380
+ hit: signals.contextHit,
1381
+ message: "OTP/auth flow does not show clear code copy. Say which contact receives the code, the expected code format, and what to do if the latest code fails.",
1382
+ evidence: evidence(
1383
+ signals.contextHit,
1384
+ "OTP context was found without clear verification-code copy."
1385
+ )
1386
+ })
1387
+ );
1388
+ }
1389
+ if (signals.hasPaidReportOtp && !signals.recoveryPath) {
1390
+ findings.push(
1391
+ finding({
1392
+ checkId: "otp-paid-report-recovery-missing",
1393
+ severity: "high",
1394
+ hit: signals.contextHit,
1395
+ message: "Paid report access appears to depend on OTP, but no recovery path was found. Add alternate contact, receipt/support, or purchase-history recovery before launch.",
1396
+ evidence: evidence(
1397
+ signals.contextHit,
1398
+ "Paid report OTP context was found without recovery-path evidence."
1399
+ )
1400
+ })
1401
+ );
1402
+ }
1403
+ if (signals.hasOtp && !signals.deliveryVerification) {
1404
+ findings.push(
1405
+ finding({
1406
+ checkId: "otp-delivery-proof-missing",
1407
+ severity: "medium",
1408
+ hit: signals.contextHit,
1409
+ message: "OTP/auth flow does not show delivery verification evidence. Treat gateway success as a start signal, then smoke a real delivered email/SMS code in the target environment.",
1410
+ evidence: evidence(
1411
+ signals.contextHit,
1412
+ "OTP context was found without delivery smoke or gateway evidence."
1413
+ )
1414
+ })
1415
+ );
1416
+ }
1417
+ return findings;
1418
+ }
1419
+
1167
1420
  // src/checks/index.ts
1168
1421
  var ALL_CHECKS = [
1169
1422
  { id: "hardcoded-secrets", run: checkHardcodedSecrets },
@@ -1181,7 +1434,8 @@ var ALL_CHECKS = [
1181
1434
  { id: "language-patterns", run: checkLanguagePatterns },
1182
1435
  { id: "python-secret-key-env", run: checkPythonSecretKeyEnv },
1183
1436
  { id: "ruby-secret-key-base-env", run: checkRubySecretKeyBaseEnv },
1184
- { id: "placeholder-content", run: checkPlaceholderContent }
1437
+ { id: "placeholder-content", run: checkPlaceholderContent },
1438
+ { id: "otp-auth-readiness", run: checkOtpAuthReadiness }
1185
1439
  ];
1186
1440
 
1187
1441
  // src/items.ts
@@ -1206,6 +1460,11 @@ var CHECKLIST_ITEMS = {
1206
1460
  title: "Keep your test data away from real users",
1207
1461
  priority: "critical"
1208
1462
  },
1463
+ "secure-auth": {
1464
+ id: "secure-auth",
1465
+ title: "Prove auth, OTP, and report access before launch",
1466
+ priority: "critical"
1467
+ },
1209
1468
  github: {
1210
1469
  id: "github",
1211
1470
  title: "Get your code into GitHub safely",
@@ -1244,17 +1503,13 @@ function permalinkFor(itemId, baseUrl) {
1244
1503
 
1245
1504
  // src/publish.ts
1246
1505
  import { promises as fs2 } from "node:fs";
1247
- import * as path9 from "node:path";
1506
+ import * as path10 from "node:path";
1248
1507
  var DEFAULT_BASE_URL = "https://shippingszn.com";
1249
1508
  var PUBLISH_TIMEOUT_MS = 3e3;
1250
- function shouldPublish() {
1251
- const v = process.env["SHIPPINGSZN_PUBLISH"] ?? "";
1252
- return v === "1" || v.toLowerCase() === "true" || v === "yes";
1253
- }
1254
1509
  async function detectStack(cwd2) {
1255
1510
  const tags = /* @__PURE__ */ new Set();
1256
1511
  try {
1257
- const raw = await fs2.readFile(path9.join(cwd2, "package.json"), "utf8");
1512
+ const raw = await fs2.readFile(path10.join(cwd2, "package.json"), "utf8");
1258
1513
  const pkg = JSON.parse(raw);
1259
1514
  const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
1260
1515
  const has = (n) => n in deps;
@@ -1289,15 +1544,18 @@ async function detectStack(cwd2) {
1289
1544
  ];
1290
1545
  for (const [file, tag] of checks) {
1291
1546
  try {
1292
- await fs2.access(path9.join(cwd2, file));
1547
+ await fs2.access(path10.join(cwd2, file));
1293
1548
  tags.add(tag);
1294
1549
  } catch {
1295
1550
  }
1296
1551
  }
1297
1552
  return [...tags].slice(0, 12);
1298
1553
  }
1299
- function buildPayload(totals, filesScanned, stack, scannerVersion) {
1554
+ function buildPayload(totals, filesScanned, stack, scannerVersion, score, label) {
1300
1555
  const out = {
1556
+ source: "cli",
1557
+ score,
1558
+ label,
1301
1559
  filesScanned,
1302
1560
  findingsCritical: totals.critical ?? 0,
1303
1561
  findingsHigh: totals.high ?? 0,
@@ -1309,14 +1567,15 @@ function buildPayload(totals, filesScanned, stack, scannerVersion) {
1309
1567
  return out;
1310
1568
  }
1311
1569
  async function publishScan(totals, filesScanned, opts) {
1312
- if (!shouldPublish()) return "skipped";
1313
1570
  const baseUrl = opts.baseUrl ?? DEFAULT_BASE_URL;
1314
1571
  const stack = await detectStack(opts.cwd);
1315
1572
  const payload = buildPayload(
1316
1573
  totals,
1317
1574
  filesScanned,
1318
1575
  stack,
1319
- opts.scannerVersion
1576
+ opts.scannerVersion,
1577
+ opts.score,
1578
+ opts.label
1320
1579
  );
1321
1580
  const controller = new AbortController();
1322
1581
  const timer = setTimeout(() => controller.abort(), PUBLISH_TIMEOUT_MS);
@@ -1336,7 +1595,7 @@ async function publishScan(totals, filesScanned, opts) {
1336
1595
  }
1337
1596
 
1338
1597
  // src/proof.ts
1339
- import * as path10 from "node:path";
1598
+ import * as path11 from "node:path";
1340
1599
  var PROOF_TIMEOUT_MS = 5e3;
1341
1600
  var MAX_FINDINGS = 100;
1342
1601
  function shouldUploadProof() {
@@ -1347,9 +1606,9 @@ function clampText(value, max, fallback = "") {
1347
1606
  const text = value ?? fallback;
1348
1607
  return text.length > max ? text.slice(0, max - 3) + "..." : text;
1349
1608
  }
1350
- function locationFromFinding(finding) {
1351
- if (!finding.file) return void 0;
1352
- return finding.line ? `${finding.file}:${finding.line}` : finding.file;
1609
+ function locationFromFinding(finding2) {
1610
+ if (!finding2.file) return void 0;
1611
+ return finding2.line ? `${finding2.file}:${finding2.line}` : finding2.file;
1353
1612
  }
1354
1613
  function buildBadgeMarkdown(baseUrl, id, score) {
1355
1614
  const params = new URLSearchParams({
@@ -1357,7 +1616,7 @@ function buildBadgeMarkdown(baseUrl, id, score) {
1357
1616
  theme: "dark"
1358
1617
  });
1359
1618
  const proofUrl = `${baseUrl}/proof/${encodeURIComponent(id)}`;
1360
- return `[![Launch readiness proof: ${score}%](${baseUrl}/api/badge.svg?${params.toString()})](${proofUrl})`;
1619
+ return `[![shippingszn scan proof: ${score}%](${baseUrl}/api/badge.svg?${params.toString()})](${proofUrl})`;
1361
1620
  }
1362
1621
  function buildProofPayload(report, scannerVersion) {
1363
1622
  const counts = {
@@ -1370,7 +1629,7 @@ function buildProofPayload(report, scannerVersion) {
1370
1629
  version: 1,
1371
1630
  source: report.source ?? "cli",
1372
1631
  scanner: "shippingszn",
1373
- targetName: path10.basename(report.cwd) || "CLI scan",
1632
+ targetName: path11.basename(report.cwd) || "CLI scan",
1374
1633
  score: report.launchReadiness.score,
1375
1634
  label: report.launchReadiness.label,
1376
1635
  decision: report.launchReadiness.decision,
@@ -1379,22 +1638,17 @@ function buildProofPayload(report, scannerVersion) {
1379
1638
  confidence: report.launchReadiness.confidence,
1380
1639
  checkedAt: report.generatedAt,
1381
1640
  counts,
1382
- findings: report.findings.slice(0, MAX_FINDINGS).map((finding) => ({
1383
- severity: finding.severity,
1384
- title: clampText(finding.itemTitle || finding.checkId, 160),
1385
- body: clampText(finding.message, 3e3),
1386
- whatFailed: clampText(finding.whatFailed, 3e3),
1387
- whyItBlocksLaunch: clampText(finding.whyItBlocksLaunch, 3e3),
1388
- fixInstructions: clampText(finding.fixInstructions, 4e3),
1389
- aiBuilderPrompt: clampText(finding.aiBuilderPrompt, 4e3),
1390
- evidence: clampText(finding.evidence, 3e3),
1391
- confidence: finding.confidence,
1392
- fixPrompt: clampText(finding.aiBuilderPrompt, 4e3),
1393
- verify: clampText(finding.verificationStep, 3e3),
1394
- verificationStep: clampText(finding.verificationStep, 3e3),
1395
- ...locationFromFinding(finding) ? { location: clampText(locationFromFinding(finding), 500) } : {},
1396
- permalink: finding.permalink,
1397
- itemTitle: clampText(finding.itemTitle, 160)
1641
+ findings: report.findings.slice(0, MAX_FINDINGS).map((finding2) => ({
1642
+ severity: finding2.severity,
1643
+ title: clampText(finding2.itemTitle || finding2.checkId, 160),
1644
+ body: clampText(finding2.message, 3e3),
1645
+ whatFailed: clampText(finding2.whatFailed, 3e3),
1646
+ whyItBlocksLaunch: clampText(finding2.whyItBlocksLaunch, 3e3),
1647
+ evidence: clampText(finding2.evidence, 3e3),
1648
+ confidence: finding2.confidence,
1649
+ ...locationFromFinding(finding2) ? { location: clampText(locationFromFinding(finding2), 500) } : {},
1650
+ permalink: finding2.permalink,
1651
+ itemTitle: clampText(finding2.itemTitle, 160)
1398
1652
  })),
1399
1653
  filesScanned: report.filesScanned,
1400
1654
  topNextStep: clampText(report.launchReadiness.topNextStep, 1e3),
@@ -1502,52 +1756,6 @@ async function uploadProof(report, opts) {
1502
1756
  }
1503
1757
  }
1504
1758
 
1505
- // src/remediation.ts
1506
- var SEVERITY_INTENT = {
1507
- critical: "Treat this as launch-blocking until fixed.",
1508
- high: "Fix this before public traffic or paid acquisition.",
1509
- medium: "Fix this before announcing or indexing the launch.",
1510
- lower: "Fix this when polishing the launch artifact."
1511
- };
1512
- var ESCALATION = {
1513
- critical: "Escalate before launch if this touches secrets, auth, payments, customer data, production infrastructure, or anything already exposed publicly.",
1514
- high: "Escalate if the fix affects auth, payments, deploy config, browser security, or a user-visible production path.",
1515
- medium: "Escalate only if the fix changes routing, indexing, analytics, or public launch messaging.",
1516
- lower: "Escalation is usually unnecessary unless this blocks installability, branding, or a promised launch surface."
1517
- };
1518
- function locationForPrompt(finding) {
1519
- if (!finding.file) return "No specific file was attached to the finding.";
1520
- const line = finding.line ? `:${finding.line}` : "";
1521
- return `Likely location: ${finding.file}${line}.`;
1522
- }
1523
- function proofStepForSeverity(severity, proofCreatePath, reportUrl) {
1524
- const proofStep = `After the fix verifies clean, run npx shippingszn --json and paste or upload the JSON at ${proofCreatePath} to create launch proof.`;
1525
- if (severity === "critical" || severity === "high") {
1526
- 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}.`;
1527
- }
1528
- return proofStep;
1529
- }
1530
- function buildRemediationPrompt({
1531
- finding,
1532
- item,
1533
- proofCreatePath,
1534
- reportUrl
1535
- }) {
1536
- const itemTitle = item?.title ?? finding.itemId;
1537
- const location = locationForPrompt(finding);
1538
- const intent = SEVERITY_INTENT[finding.severity];
1539
- return {
1540
- 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.`,
1541
- 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.",
1542
- escalation: ESCALATION[finding.severity],
1543
- proofNextStep: proofStepForSeverity(
1544
- finding.severity,
1545
- proofCreatePath,
1546
- reportUrl
1547
- )
1548
- };
1549
- }
1550
-
1551
1759
  // ../../lib/launch-readiness/src/index.ts
1552
1760
  var SEVERITY_WEIGHTS = {
1553
1761
  critical: 35,
@@ -1584,7 +1792,7 @@ var SCORE_BANDS = [
1584
1792
  id: "launchable",
1585
1793
  minScore: 90,
1586
1794
  maxScore: 100,
1587
- label: "Launchable with proof",
1795
+ label: "Launchable",
1588
1796
  decision: "go",
1589
1797
  goNoGoLabel: "Go"
1590
1798
  }
@@ -1599,7 +1807,7 @@ var COVERAGE_LABELS = {
1599
1807
  public_surface: "Public launch surface",
1600
1808
  repo_static: "Repository static scan",
1601
1809
  secrets: "Secrets and config exposure",
1602
- auth: "Auth and private surfaces",
1810
+ auth: "Auth, OTP, and private surfaces",
1603
1811
  paid_api: "Paid API and abuse risk",
1604
1812
  deployment: "Deployment and runtime config",
1605
1813
  content: "Launch content and metadata",
@@ -1644,8 +1852,8 @@ function normalizeLaunchCounts(counts) {
1644
1852
  }
1645
1853
  function countLaunchFindings(findings) {
1646
1854
  const counts = emptyLaunchReadinessCounts();
1647
- for (const finding of findings) {
1648
- counts[canonicalSeverity(finding.severity)] += 1;
1855
+ for (const finding2 of findings) {
1856
+ counts[canonicalSeverity(finding2.severity)] += 1;
1649
1857
  }
1650
1858
  return counts;
1651
1859
  }
@@ -1663,12 +1871,12 @@ function sourceLabel(source) {
1663
1871
  if (source === "url") return "public URL scan";
1664
1872
  return "manual intake";
1665
1873
  }
1666
- function coverageArea(id, status, evidence, confidence) {
1874
+ function coverageArea(id, status, evidence2, confidence) {
1667
1875
  return {
1668
1876
  id,
1669
1877
  label: COVERAGE_LABELS[id],
1670
1878
  status,
1671
- evidence,
1879
+ evidence: evidence2,
1672
1880
  confidence: confidence ?? (status === "checked" ? "high" : status === "partial" ? "medium" : "low")
1673
1881
  };
1674
1882
  }
@@ -1694,7 +1902,7 @@ function coverageForSource(source) {
1694
1902
  coverageArea(
1695
1903
  "auth",
1696
1904
  "not_checked",
1697
- "Private routes and role guards cannot be proven from a public unauthenticated fetch."
1905
+ "Private routes, role guards, OTP delivery, phone normalization, resend behavior, and recovery paths cannot be proven from a public unauthenticated fetch."
1698
1906
  ),
1699
1907
  coverageArea(
1700
1908
  "paid_api",
@@ -1741,7 +1949,7 @@ function coverageForSource(source) {
1741
1949
  coverageArea(
1742
1950
  "auth",
1743
1951
  "partial",
1744
- "Static auth-risk signals were checked; runtime role behavior still needs verification."
1952
+ "Static auth and OTP-risk signals were checked; runtime role behavior and real email/SMS delivery still need verification."
1745
1953
  ),
1746
1954
  coverageArea(
1747
1955
  "paid_api",
@@ -1787,7 +1995,7 @@ function coverageForSource(source) {
1787
1995
  coverageArea(
1788
1996
  "auth",
1789
1997
  "partial",
1790
- "Manual evidence can mention auth risk; runtime verification is still required.",
1998
+ "Manual evidence can mention auth and OTP risk; runtime verification and delivered-code smoke are still required.",
1791
1999
  "low"
1792
2000
  ),
1793
2001
  coverageArea(
@@ -1837,10 +2045,10 @@ function defaultWhy(severity, source) {
1837
2045
  }
1838
2046
  return `This is lower-priority readiness polish, but it still belongs in the fix queue before the launch proof is treated as clean.`;
1839
2047
  }
1840
- function locationLine(finding) {
1841
- if (finding.location?.trim()) return finding.location.trim();
1842
- if (!finding.file?.trim()) return "";
1843
- return finding.line ? `${finding.file.trim()}:${finding.line}` : finding.file.trim();
2048
+ function locationLine(finding2) {
2049
+ if (finding2.location?.trim()) return finding2.location.trim();
2050
+ if (!finding2.file?.trim()) return "";
2051
+ return finding2.line ? `${finding2.file.trim()}:${finding2.line}` : finding2.file.trim();
1844
2052
  }
1845
2053
  function defaultPrompt(input) {
1846
2054
  const where = input.location ? `Likely location: ${input.location}.` : "Inspect the relevant app, deploy, and configuration files before editing.";
@@ -1856,37 +2064,37 @@ function defaultPrompt(input) {
1856
2064
  "Make the smallest production-safe change, preserve existing behavior, list files changed, and do not suppress the scanner unless you prove a false positive."
1857
2065
  ].join(" ");
1858
2066
  }
1859
- function normalizeLaunchFinding(finding, source = "manual") {
1860
- const severity = canonicalSeverity(finding.severity);
2067
+ function normalizeLaunchFinding(finding2, source = "manual") {
2068
+ const severity = canonicalSeverity(finding2.severity);
1861
2069
  const title = clampText2(
1862
- finding.title ?? finding.itemTitle,
2070
+ finding2.title ?? finding2.itemTitle,
1863
2071
  "Launch-readiness finding",
1864
2072
  160
1865
2073
  );
1866
2074
  const body = clampText2(
1867
- finding.body ?? finding.message,
2075
+ finding2.body ?? finding2.message,
1868
2076
  "The scan flagged this as a launch-readiness risk.",
1869
2077
  3e3
1870
2078
  );
1871
- const whatFailed = clampText2(finding.whatFailed, body, 3e3);
2079
+ const whatFailed = clampText2(finding2.whatFailed, body, 3e3);
1872
2080
  const whyItBlocksLaunch = clampText2(
1873
- finding.whyItBlocksLaunch,
2081
+ finding2.whyItBlocksLaunch,
1874
2082
  body || defaultWhy(severity, source),
1875
2083
  3e3
1876
2084
  );
1877
2085
  const fixInstructions = clampText2(
1878
- finding.fixInstructions ?? finding.fixPrompt,
2086
+ finding2.fixInstructions ?? finding2.fixPrompt,
1879
2087
  `Fix the underlying ${severity} launch-readiness risk and keep the app behavior intact.`,
1880
2088
  4e3
1881
2089
  );
1882
2090
  const verificationStep = clampText2(
1883
- finding.verificationStep ?? finding.verify,
2091
+ finding2.verificationStep ?? finding2.verify,
1884
2092
  "Run the same scan again and confirm this finding is gone before launch.",
1885
2093
  3e3
1886
2094
  );
1887
- const location = locationLine(finding);
2095
+ const location = locationLine(finding2);
1888
2096
  const aiBuilderPrompt = clampText2(
1889
- finding.aiBuilderPrompt,
2097
+ finding2.aiBuilderPrompt,
1890
2098
  defaultPrompt({
1891
2099
  severity,
1892
2100
  title,
@@ -1898,13 +2106,13 @@ function normalizeLaunchFinding(finding, source = "manual") {
1898
2106
  }),
1899
2107
  4e3
1900
2108
  );
1901
- const evidence = clampText2(
1902
- finding.evidence,
2109
+ const evidence2 = clampText2(
2110
+ finding2.evidence,
1903
2111
  location ? `Observed at ${location}.` : body,
1904
2112
  3e3
1905
2113
  );
1906
2114
  const confidence = normalizeConfidence(
1907
- finding.confidence,
2115
+ finding2.confidence,
1908
2116
  confidenceForSource(source)
1909
2117
  );
1910
2118
  return {
@@ -1915,21 +2123,21 @@ function normalizeLaunchFinding(finding, source = "manual") {
1915
2123
  fixInstructions,
1916
2124
  aiBuilderPrompt,
1917
2125
  verificationStep,
1918
- evidence,
2126
+ evidence: evidence2,
1919
2127
  confidence,
1920
2128
  body,
1921
2129
  message: body,
1922
2130
  fixPrompt: aiBuilderPrompt,
1923
2131
  verify: verificationStep,
1924
2132
  ...location ? { location } : {},
1925
- ...finding.file ? { file: finding.file } : {},
1926
- ...finding.line ? { line: finding.line } : {},
1927
- ...finding.permalink ? { permalink: finding.permalink } : {},
1928
- ...finding.itemTitle ? { itemTitle: finding.itemTitle } : {}
2133
+ ...finding2.file ? { file: finding2.file } : {},
2134
+ ...finding2.line ? { line: finding2.line } : {},
2135
+ ...finding2.permalink ? { permalink: finding2.permalink } : {},
2136
+ ...finding2.itemTitle ? { itemTitle: finding2.itemTitle } : {}
1929
2137
  };
1930
2138
  }
1931
2139
  function prioritizeLaunchFindings(findings, source = "manual") {
1932
- return findings.map((finding) => normalizeLaunchFinding(finding, source)).sort((a, b) => {
2140
+ return findings.map((finding2) => normalizeLaunchFinding(finding2, source)).sort((a, b) => {
1933
2141
  const bySeverity = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
1934
2142
  if (bySeverity !== 0) return bySeverity;
1935
2143
  return a.title.localeCompare(b.title);
@@ -1965,12 +2173,12 @@ function decisionText(band, counts, source) {
1965
2173
  return `Fix the high-risk launch gaps before a real campaign, customer demo, or paid traffic.`;
1966
2174
  }
1967
2175
  if (counts.medium > 0 || counts.lower > 0) {
1968
- return `No critical or high blocker was detected, but the remaining readiness gaps should be fixed and verified before treating the ${sourceLabel(source)} as clean proof.`;
2176
+ return `No critical or high blocker was detected, but the remaining readiness gaps should be fixed and verified before treating the ${sourceLabel(source)} as launchable.`;
1969
2177
  }
1970
2178
  if (source === "url") {
1971
2179
  return "No public-page blockers were found. Run the CLI or GitHub Action before calling the app launch-ready.";
1972
2180
  }
1973
- return `${band.goNoGoLabel}: no scan-detected blockers were found. Keep the proof attached to the launch record.`;
2181
+ return `${band.goNoGoLabel}: no scan-detected blockers were found. Monitor first production traffic.`;
1974
2182
  }
1975
2183
  function topNextStep(blockers, counts, source) {
1976
2184
  const top = blockers[0];
@@ -1981,12 +2189,12 @@ function topNextStep(blockers, counts, source) {
1981
2189
  return "Run the repo scan next so secrets, auth, API routes, and generated-code risks are checked before launch.";
1982
2190
  }
1983
2191
  if (counts.medium > 0 || counts.lower > 0) {
1984
- return "Fix the remaining readiness gaps and rescan before publishing proof.";
2192
+ return "Fix the remaining readiness gaps and rescan before launch.";
1985
2193
  }
1986
- return "Attach this clean scan proof to the launch record and keep monitoring the first production traffic.";
2194
+ return "No obvious launch blockers caught. Monitor first production traffic and keep the paid report for higher-stakes launches.";
1987
2195
  }
1988
2196
  function aggregateConfidence(source, findings) {
1989
- if (findings.some((finding) => finding.confidence === "low")) return "low";
2197
+ if (findings.some((finding2) => finding2.confidence === "low")) return "low";
1990
2198
  if (source === "url") return "medium";
1991
2199
  return "high";
1992
2200
  }
@@ -2003,7 +2211,7 @@ function assessLaunchReadiness(input) {
2003
2211
  const checkedAreas = coverage.filter((area) => area.status === "checked");
2004
2212
  const uncheckedAreas = coverage.filter((area) => area.status !== "checked");
2005
2213
  const blockers = findings.filter(
2006
- (finding) => finding.severity === "critical" || finding.severity === "high"
2214
+ (finding2) => finding2.severity === "critical" || finding2.severity === "high"
2007
2215
  );
2008
2216
  const decision = decisionText(band, counts, source);
2009
2217
  const reportRecommended = counts.critical > 0 || counts.high > 0 || coveragePenalty > 0;
@@ -2068,7 +2276,7 @@ function parseArgs(argv2) {
2068
2276
  help: false,
2069
2277
  version: false,
2070
2278
  noColor: !!process2.env.NO_COLOR,
2071
- publish: false,
2279
+ publish: !publishDisabledByEnv(),
2072
2280
  proof: shouldUploadProof()
2073
2281
  };
2074
2282
  for (let i = 0; i < argv2.length; i++) {
@@ -2077,14 +2285,30 @@ function parseArgs(argv2) {
2077
2285
  else if (a === "--version" || a === "-v") opts.version = true;
2078
2286
  else if (a === "--json") opts.json = true;
2079
2287
  else if (a === "--publish") opts.publish = true;
2288
+ else if (a === "--no-publish") opts.publish = false;
2080
2289
  else if (a === "--proof") opts.proof = true;
2081
2290
  else if (a === "--no-color") opts.noColor = true;
2082
2291
  else if (a === "--base-url") opts.baseUrl = argv2[++i] ?? opts.baseUrl;
2083
- else if (a === "--cwd") opts.cwd = path11.resolve(argv2[++i] ?? opts.cwd);
2084
- else if (!a.startsWith("-")) opts.cwd = path11.resolve(a);
2292
+ else if (a === "--cwd") opts.cwd = path12.resolve(argv2[++i] ?? opts.cwd);
2293
+ else if (!a.startsWith("-")) opts.cwd = path12.resolve(a);
2085
2294
  }
2086
2295
  return opts;
2087
2296
  }
2297
+ function envTruthy(value) {
2298
+ if (!value) return false;
2299
+ const normalized = value.trim().toLowerCase();
2300
+ return normalized === "1" || normalized === "true" || normalized === "yes";
2301
+ }
2302
+ function envFalsey(value) {
2303
+ if (!value) return false;
2304
+ const normalized = value.trim().toLowerCase();
2305
+ return normalized === "0" || normalized === "false" || normalized === "no";
2306
+ }
2307
+ function publishDisabledByEnv() {
2308
+ if (envTruthy(process2.env.SHIPPINGSZN_DISABLE_PUBLISH)) return true;
2309
+ if (envFalsey(process2.env.SHIPPINGSZN_PUBLISH)) return true;
2310
+ return false;
2311
+ }
2088
2312
  function color(enabled) {
2089
2313
  const wrap = (codes) => (s) => enabled ? `\x1B[${codes}m${s}\x1B[0m` : s;
2090
2314
  return {
@@ -2118,12 +2342,8 @@ Usage:
2118
2342
 
2119
2343
  Options:
2120
2344
  --json Output a machine-readable JSON report.
2121
- --publish Opt in to posting an anonymous summary to the public
2122
- Wall of Launches. Never uploads source code, paths,
2123
- filenames, or project names.
2124
- --proof Opt in to posting this scan to /api/scan-results and
2125
- printing a proof URL, Wall URL, report URL, and badge.
2126
- Can also be enabled with SHIPPINGSZN_PROOF=1.
2345
+ --publish Legacy alias. Anonymous Wall stats publish by default.
2346
+ --no-publish Disable the anonymous Wall post for this run.
2127
2347
  --base-url <url> Base URL used to build links back to checklist items.
2128
2348
  (default: ${DEFAULT_BASE_URL2})
2129
2349
  --cwd <path> Directory to scan. Default: current working directory.
@@ -2131,8 +2351,9 @@ Options:
2131
2351
  -h, --help Show this help.
2132
2352
  -v, --version Print version.
2133
2353
 
2134
- The scanner only reads files. It never writes, modifies, deletes, or makes
2135
- network requests unless you explicitly opt in with --publish or --proof.
2354
+ The scanner only reads files. It never writes, modifies, or deletes. It posts
2355
+ anonymous aggregate Wall stats by default: no source code, paths, filenames,
2356
+ project names, secrets, handles, emails, or repo URLs.
2136
2357
  Exit code is non-zero if any Critical findings are detected.
2137
2358
  `
2138
2359
  );
@@ -2173,18 +2394,13 @@ async function run() {
2173
2394
  const trimmedBaseUrl = opts.baseUrl.replace(/\/$/, "");
2174
2395
  const reportUrl = `${trimmedBaseUrl}/report`;
2175
2396
  const proofCreatePath = `${trimmedBaseUrl}/scan`;
2176
- 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.";
2397
+ const proofUploadHint = "Run `npx shippingszn --json > shippingszn-scan.json`, then paste or upload that JSON on shippingszn.com/scan if you want to attach the local scan to a paid report handoff.";
2177
2398
  const tracked_aware = applyTrackingAwareSeverity(all, tracked);
2178
2399
  const source = scanSource();
2179
2400
  const enriched = tracked_aware.map((f) => {
2180
2401
  const item = CHECKLIST_ITEMS[f.itemId];
2181
- const remediation = buildRemediationPrompt({
2182
- finding: f,
2183
- item,
2184
- proofCreatePath,
2185
- reportUrl
2186
- });
2187
2402
  const itemTitle = item?.title ?? f.itemId;
2403
+ const permalink = permalinkFor(f.itemId, opts.baseUrl);
2188
2404
  const normalized = normalizeLaunchFinding(
2189
2405
  {
2190
2406
  severity: f.severity,
@@ -2194,22 +2410,26 @@ async function run() {
2194
2410
  evidence: f.evidence,
2195
2411
  file: f.file,
2196
2412
  line: f.line,
2197
- permalink: permalinkFor(f.itemId, opts.baseUrl),
2198
- itemTitle,
2199
- fixInstructions: remediation.fixPrompt,
2200
- aiBuilderPrompt: remediation.fixPrompt,
2201
- verificationStep: remediation.verify,
2202
- fixPrompt: remediation.fixPrompt,
2203
- verify: remediation.verify
2413
+ permalink,
2414
+ itemTitle
2204
2415
  },
2205
2416
  source
2206
2417
  );
2207
2418
  return {
2208
2419
  ...f,
2209
- ...normalized,
2420
+ severity: normalized.severity,
2421
+ title: normalized.title,
2422
+ body: normalized.body,
2423
+ message: normalized.message,
2424
+ whatFailed: normalized.whatFailed,
2425
+ whyItBlocksLaunch: normalized.whyItBlocksLaunch,
2426
+ evidence: normalized.evidence,
2427
+ confidence: normalized.confidence,
2428
+ ...normalized.location ? { location: normalized.location } : {},
2429
+ ...normalized.file ? { file: normalized.file } : {},
2430
+ ...normalized.line ? { line: normalized.line } : {},
2210
2431
  itemTitle,
2211
- permalink: permalinkFor(f.itemId, opts.baseUrl),
2212
- remediation
2432
+ permalink
2213
2433
  };
2214
2434
  });
2215
2435
  enriched.sort((a, b) => {
@@ -2276,16 +2496,18 @@ async function run() {
2276
2496
  }
2277
2497
  let publishResult = "skipped";
2278
2498
  try {
2279
- if (opts.publish) {
2280
- process2.env["SHIPPINGSZN_PUBLISH"] = "1";
2281
- }
2282
2499
  const proofAlreadyPublishedWall = proofResult.status === "uploaded" && !!proofResult.wallUrl;
2283
- if (!proofAlreadyPublishedWall) {
2500
+ if (opts.publish && !proofAlreadyPublishedWall) {
2284
2501
  publishResult = await publishScan(totals, files.length, {
2285
2502
  cwd: opts.cwd,
2286
2503
  baseUrl: opts.baseUrl,
2287
- scannerVersion: PKG_VERSION
2504
+ scannerVersion: PKG_VERSION,
2505
+ score: launchReadiness.score,
2506
+ label: launchReadiness.label
2288
2507
  });
2508
+ if (publishResult === "published") {
2509
+ report.launchReadiness.wallUrl = `${opts.baseUrl.replace(/\/$/, "")}/wall`;
2510
+ }
2289
2511
  }
2290
2512
  } catch {
2291
2513
  }
@@ -2323,12 +2545,12 @@ ${c.bold("shippingszn")} ${c.dim(`v${PKG_VERSION}`)}
2323
2545
  `
2324
2546
  );
2325
2547
  process2.stdout.write(
2326
- `${c.bold("Fix prompts:")} ${c.dim("Run with --json for copy/paste AI-builder remediation prompts.")}
2548
+ `${c.bold("Action plan:")} ${c.dim("Exact AI-builder prompts and verification steps are in the paid report.")}
2327
2549
  `
2328
2550
  );
2329
2551
  if (launchReadiness.reportUrl) {
2330
2552
  process2.stdout.write(
2331
- `${c.bold("Need paid confidence?")} ${c.dim(`Get a launch-readiness report: ${launchReadiness.reportUrl}`)}
2553
+ `${c.bold("Need a written launch decision?")} ${c.dim(`Get the $49 report: ${launchReadiness.reportUrl}`)}
2332
2554
  `
2333
2555
  );
2334
2556
  }
@@ -2363,8 +2585,9 @@ ${c.bold("shippingszn")} ${c.dim(`v${PKG_VERSION}`)}
2363
2585
  `
2364
2586
  );
2365
2587
  } else {
2588
+ const wallLine = publishResult === "published" ? `Posted anonymous scan stats to ${opts.baseUrl.replace(/\/$/, "")}/wall.` : opts.publish ? "Anonymous Wall stats post by default. If the network is blocked, the local scan still finishes." : "Private local run: Wall publish disabled.";
2366
2589
  process2.stdout.write(
2367
- `${c.bold("Share proof:")} ${c.dim("Run with --proof to publish this scan to a public proof URL.")}
2590
+ `${c.bold("Wall signal:")} ${c.dim(wallLine)}
2368
2591
  `
2369
2592
  );
2370
2593
  }
@@ -2372,7 +2595,7 @@ ${c.bold("shippingszn")} ${c.dim(`v${PKG_VERSION}`)}
2372
2595
  if (enriched.length === 0) {
2373
2596
  process2.stdout.write(
2374
2597
  c.green(
2375
- "No findings. Attach the clean scan proof to the launch record before shipping.\n\n"
2598
+ "No findings. The scanner did not catch obvious launch blockers in this pass.\n\n"
2376
2599
  )
2377
2600
  );
2378
2601
  if (publishResult === "published") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shippingszn",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
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",