shippingszn 0.10.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +24 -14
  2. package/dist/index.js +145 -24
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -27,11 +27,13 @@ will be; the Fix Kit is how you fix what it finds.
27
27
  The CLI **never writes, modifies, or deletes** any files - it only reads. (The
28
28
  one exception is a tiny first-run marker under your config dir,
29
29
  `~/.config/shippingszn/seen`, used to show the telemetry notice once.) By
30
- default each run creates a scan handoff for checkout and sends one anonymous
31
- Wall report-card summary with score, severity counts, files scanned, scanner
32
- version, and safe stack tags. It never uploads source code, repo URLs, project
33
- names, secrets, handles, or emails. Pass `--no-telemetry` to run fully offline
34
- (zero network calls).
30
+ default each run makes two anonymous requests: a scan handoff for checkout that
31
+ carries finding-level detail (severity, checklist item, `file:line`, and a short
32
+ evidence snippet — secret values always redacted before upload), and an
33
+ aggregate Wall summary with score, severity counts, files scanned, scanner
34
+ version, and safe stack tags. Neither uploads full source files, repo URLs,
35
+ project names, unredacted secrets, handles, or emails. Pass `--no-telemetry` to
36
+ run fully offline (zero network calls). Details in [Telemetry](#telemetry).
35
37
 
36
38
  **The free scan is the full diagnosis.** Human output prints a verdict, a
37
39
  higher-is-better Readiness Score, severity counts, and **every finding grouped
@@ -76,15 +78,23 @@ exactly what's wrong; the Launch Fix Kit is how you fix it.
76
78
 
77
79
  ## Telemetry
78
80
 
79
- Plain `npx shippingszn@latest` sends one anonymous Wall summary automatically.
80
- The stored row is intentionally small: score, launch label, files scanned,
81
- finding counts by severity, detected stack tags, scanner version, and timestamp.
82
- It never includes code, file paths, filenames, project names, repo URLs,
83
- secrets, emails, or any finding-level detail.
84
-
85
- On the **first run on a machine**, the CLI prints the exact anonymous payload it
86
- would send (to stderr, so it never corrupts `--json` output) and a note that you
87
- can turn it off. Telemetry is default-on but fully transparent and opt-out-able:
81
+ Plain `npx shippingszn@latest` makes **two** anonymous requests per run:
82
+
83
+ 1. **Scan handoff** (`POST /api/scan-results`) — powers the `/fix-kit` link the
84
+ CLI prints. It carries finding-level detail: each finding's severity, the
85
+ checklist item it maps to, its `file:line` location, and a short evidence
86
+ snippet from the matched line. Secret values are always redacted to a
87
+ `abc123…x9z2` form before upload. It does not include your repo URL, project
88
+ name, or full source files.
89
+ 2. **Aggregate Wall summary** (`POST /api/wall`) — intentionally small: score,
90
+ launch label, files scanned, finding counts by severity, detected stack
91
+ tags, scanner version, and timestamp. No paths, no filenames, no
92
+ finding-level detail.
93
+
94
+ On the **first run on a machine**, the CLI prints a description of both requests
95
+ plus the exact aggregate payload (to stderr, so it never corrupts `--json`
96
+ output) and a note that you can turn it off. Telemetry is default-on but fully
97
+ transparent and opt-out-able:
88
98
 
89
99
  ```bash
90
100
  npx shippingszn@latest --no-telemetry # zero network calls, fully offline
package/dist/index.js CHANGED
@@ -99,22 +99,59 @@ var TEXT_EXT = /* @__PURE__ */ new Set([
99
99
  var MAX_FILE_BYTES = 512 * 1024;
100
100
  var MAX_DEPTH = 24;
101
101
  var MAX_FILES = 5e4;
102
- async function listFiles(rootDir) {
102
+ var ScanInputError = class extends Error {
103
+ constructor(message) {
104
+ super(message);
105
+ this.name = "ScanInputError";
106
+ }
107
+ };
108
+ var ScanLimitError = class extends Error {
109
+ constructor(message) {
110
+ super(message);
111
+ this.name = "ScanLimitError";
112
+ }
113
+ };
114
+ async function listFiles(rootDir, options = {}) {
115
+ const maxFiles = options.maxFiles ?? MAX_FILES;
116
+ const maxDepth = options.maxDepth ?? MAX_DEPTH;
117
+ if (!Number.isSafeInteger(maxFiles) || maxFiles <= 0) {
118
+ throw new ScanInputError("maxFiles must be a positive safe integer");
119
+ }
120
+ if (!Number.isSafeInteger(maxDepth) || maxDepth < 0) {
121
+ throw new ScanInputError("maxDepth must be a non-negative safe integer");
122
+ }
123
+ let rootStat;
124
+ try {
125
+ rootStat = await fs.stat(rootDir);
126
+ } catch {
127
+ throw new ScanInputError(
128
+ `Scan target does not exist or cannot be read: ${rootDir}`
129
+ );
130
+ }
131
+ if (!rootStat.isDirectory()) {
132
+ throw new ScanInputError(`Scan target is not a directory: ${rootDir}`);
133
+ }
103
134
  const out = [];
104
135
  const visited = /* @__PURE__ */ new Set();
105
- const rootResolved = path.resolve(rootDir);
136
+ const rootResolved = await fs.realpath(rootDir);
137
+ visited.add(rootResolved);
106
138
  const allowed = getNotIgnoredFiles(rootDir);
107
139
  async function walk(dir, depth) {
108
- if (depth > MAX_DEPTH) return;
109
- if (out.length >= MAX_FILES) return;
140
+ if (depth > maxDepth) {
141
+ throw new ScanLimitError(
142
+ `Scan stopped: directory depth exceeds the ${maxDepth}-level safety limit.`
143
+ );
144
+ }
110
145
  let entries;
111
146
  try {
112
147
  entries = await fs.readdir(dir, { withFileTypes: true });
113
148
  } catch {
149
+ if (depth === 0) {
150
+ throw new ScanInputError(`Scan target cannot be read: ${rootDir}`);
151
+ }
114
152
  return;
115
153
  }
116
154
  for (const entry of entries) {
117
- if (out.length >= MAX_FILES) return;
118
155
  if (entry.name.startsWith(".") && DEFAULT_IGNORES.has(entry.name))
119
156
  continue;
120
157
  if (DEFAULT_IGNORES.has(entry.name)) continue;
@@ -153,6 +190,11 @@ async function listFiles(rootDir) {
153
190
  } catch {
154
191
  continue;
155
192
  }
193
+ if (out.length >= maxFiles) {
194
+ throw new ScanLimitError(
195
+ `Scan stopped: project contains more than ${maxFiles.toLocaleString("en-US")} files, above the safety limit.`
196
+ );
197
+ }
156
198
  out.push({ absPath: abs, relPath: rel, size });
157
199
  }
158
200
  }
@@ -172,10 +214,27 @@ function isTextFile(file) {
172
214
  }
173
215
  async function readFileSafe(file) {
174
216
  if (file.size > MAX_FILE_BYTES) return null;
217
+ let handle = null;
175
218
  try {
176
- return await fs.readFile(file.absPath, "utf8");
219
+ handle = await fs.open(file.absPath, "r");
220
+ const buffer = Buffer.allocUnsafe(MAX_FILE_BYTES + 1);
221
+ let offset = 0;
222
+ while (offset < buffer.length) {
223
+ const { bytesRead } = await handle.read(
224
+ buffer,
225
+ offset,
226
+ buffer.length - offset,
227
+ offset
228
+ );
229
+ if (bytesRead === 0) break;
230
+ offset += bytesRead;
231
+ }
232
+ if (offset > MAX_FILE_BYTES) return null;
233
+ return buffer.subarray(0, offset).toString("utf8");
177
234
  } catch {
178
235
  return null;
236
+ } finally {
237
+ await handle?.close().catch(() => void 0);
179
238
  }
180
239
  }
181
240
  async function fileExists(p) {
@@ -4189,6 +4248,30 @@ var PKG_VERSION = (() => {
4189
4248
  const pkg = require2("../package.json");
4190
4249
  return pkg.version;
4191
4250
  })();
4251
+ var CliInputError = class extends Error {
4252
+ constructor(message) {
4253
+ super(message);
4254
+ this.name = "CliInputError";
4255
+ }
4256
+ };
4257
+ function normalizeBaseUrl(raw) {
4258
+ let parsed;
4259
+ try {
4260
+ parsed = new URL(raw);
4261
+ } catch {
4262
+ throw new CliInputError(`Invalid --base-url value: ${raw}`);
4263
+ }
4264
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
4265
+ throw new CliInputError("--base-url must use http:// or https://");
4266
+ }
4267
+ if (parsed.username || parsed.password) {
4268
+ throw new CliInputError("--base-url must not contain credentials");
4269
+ }
4270
+ parsed.hash = "";
4271
+ parsed.search = "";
4272
+ parsed.pathname = parsed.pathname.replace(/\/+$/, "") || "/";
4273
+ return parsed.toString().replace(/\/+$/, "");
4274
+ }
4192
4275
  function parseArgs(argv2) {
4193
4276
  const opts = {
4194
4277
  cwd: process.cwd(),
@@ -4200,8 +4283,32 @@ function parseArgs(argv2) {
4200
4283
  proof: true,
4201
4284
  telemetry: true
4202
4285
  };
4286
+ let targetWasSet = false;
4287
+ let positionalOnly = false;
4288
+ const requireValue = (flag, index) => {
4289
+ const value = argv2[index + 1];
4290
+ if (!value || value.startsWith("-")) {
4291
+ throw new CliInputError(`${flag} requires a value`);
4292
+ }
4293
+ return value;
4294
+ };
4295
+ const setTarget = (raw) => {
4296
+ if (targetWasSet) {
4297
+ throw new CliInputError("Specify exactly one scan target");
4298
+ }
4299
+ opts.cwd = path21.resolve(raw);
4300
+ targetWasSet = true;
4301
+ };
4203
4302
  for (let i = 0; i < argv2.length; i++) {
4204
4303
  const a = argv2[i];
4304
+ if (positionalOnly) {
4305
+ setTarget(a);
4306
+ continue;
4307
+ }
4308
+ if (a === "--") {
4309
+ positionalOnly = true;
4310
+ continue;
4311
+ }
4205
4312
  if (a === "--help" || a === "-h") opts.help = true;
4206
4313
  else if (a === "--version" || a === "-v") opts.version = true;
4207
4314
  else if (a === "--json") opts.json = true;
@@ -4209,10 +4316,19 @@ function parseArgs(argv2) {
4209
4316
  else if (a === "--no-color") opts.noColor = true;
4210
4317
  else if (a === "--no-telemetry" || a === "--no-wall" || a === "--no-publish")
4211
4318
  opts.telemetry = false;
4212
- else if (a === "--base-url") opts.baseUrl = argv2[++i] ?? opts.baseUrl;
4213
- else if (a === "--cwd") opts.cwd = path21.resolve(argv2[++i] ?? opts.cwd);
4214
- else if (!a.startsWith("-")) opts.cwd = path21.resolve(a);
4319
+ else if (a === "--base-url") {
4320
+ opts.baseUrl = requireValue(a, i);
4321
+ i += 1;
4322
+ } else if (a === "--cwd") {
4323
+ setTarget(requireValue(a, i));
4324
+ i += 1;
4325
+ } else if (a.startsWith("-")) {
4326
+ throw new CliInputError(`Unknown option: ${a}`);
4327
+ } else {
4328
+ setTarget(a);
4329
+ }
4215
4330
  }
4331
+ opts.baseUrl = normalizeBaseUrl(opts.baseUrl);
4216
4332
  return opts;
4217
4333
  }
4218
4334
  function color(enabled) {
@@ -4277,16 +4393,19 @@ function printTelemetryDisclosure(info) {
4277
4393
  const stackList = stack.length ? stack.join(", ") : "(none detected)";
4278
4394
  process.stderr.write(
4279
4395
  `
4280
- shippingszn sends one anonymous telemetry ping per run (this keeps the public
4281
- Wall and aggregate stats current). This run would send exactly:
4282
- - score: ${score}
4283
- - severity counts: ${totals.critical} critical, ${totals.high} high, ${totals.medium} medium, ${totals.lower} lower
4284
- - files scanned: ${filesScanned}
4285
- - scanner version: ${scannerVersion}
4286
- - stack tags: ${stackList}
4287
- It never includes your code, file paths, filenames, project name, repo URL,
4288
- secrets, emails, or any finding-level detail. Disable it anytime with
4289
- --no-telemetry (the CLI then runs fully offline). This notice shows once per machine.
4396
+ shippingszn sends two anonymous telemetry requests per run by default:
4397
+ 1. A scan handoff (creates your /fix-kit link): each finding's severity,
4398
+ checklist item, file:line location, and a short evidence snippet from the
4399
+ matched line. Secret values are always redacted before upload.
4400
+ 2. An aggregate Wall ping. This run's aggregate payload:
4401
+ - score: ${score}
4402
+ - severity counts: ${totals.critical} critical, ${totals.high} high, ${totals.medium} medium, ${totals.lower} lower
4403
+ - files scanned: ${filesScanned}
4404
+ - scanner version: ${scannerVersion}
4405
+ - stack tags: ${stackList}
4406
+ Neither request includes your repo URL, project name, full source files, or
4407
+ unredacted secret values. Disable both with --no-telemetry (the CLI then runs
4408
+ fully offline, zero network calls). This notice shows once per machine.
4290
4409
 
4291
4410
  `
4292
4411
  );
@@ -4320,11 +4439,13 @@ finding \u2014 severity, the checklist item it maps to, the file and line, and w
4320
4439
  wrong \u2014 plus a 0-100 readiness score. The $49 Launch Fix Kit is the REMEDIATION
4321
4440
  layer: per-finding fix instructions, prompts to paste straight into your AI
4322
4441
  builder, the 58-item launch workbook, unlimited re-scans, and launch monitoring.
4323
- Read-only on disk. By default each run sends one anonymous, transparent
4324
- telemetry ping (score, severity counts, file count, scanner version, stack tags
4325
- \u2014 never code, paths, project names, or finding-level detail) and creates a scan
4326
- handoff so checkout can carry this exact scan into the Fix Kit; pass
4327
- --no-telemetry to disable both. Exit code non-zero if any critical findings.
4442
+ Read-only on disk. By default each run sends two anonymous requests: a scan
4443
+ handoff carrying finding-level detail (severity, checklist item, file:line, and
4444
+ a short evidence snippet \u2014 secret values always redacted) so checkout can carry
4445
+ this exact scan into the Fix Kit, and an aggregate Wall ping (score, severity
4446
+ counts, file count, scanner version, stack tags). Neither includes your repo
4447
+ URL, project name, or full source files. Pass --no-telemetry to disable both.
4448
+ Exit code non-zero if any critical findings.
4328
4449
  `
4329
4450
  );
4330
4451
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shippingszn",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "Read-only launch inspector for AI-built apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",