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 +98 -9
- package/dist/checks/helpers.js +32 -8
- package/dist/checks/public-assets.js +1 -1
- package/dist/index.js +149 -18
- package/dist/proof.js +161 -0
- package/dist/publish.js +7 -8
- package/dist/remediation.js +36 -0
- package/dist/scan.js +38 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
# shippingszn
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
the
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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": "[](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
|
|
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).
|
package/dist/checks/helpers.js
CHANGED
|
@@ -11,19 +11,38 @@ export function findLine(content, idx) {
|
|
|
11
11
|
return line;
|
|
12
12
|
}
|
|
13
13
|
/**
|
|
14
|
-
* Inline opt-out
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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
|
|
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
|
-
|
|
26
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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("
|
|
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
|
|
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
|
|
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 `[})](${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
|
|
3
|
-
* shippingszn.com.
|
|
4
|
-
*
|
|
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.
|
|
11
|
-
*
|
|
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["
|
|
19
|
-
return v
|
|
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.
|
|
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"
|