launchprep 0.5.2 → 0.5.4

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "launchprep",
3
- "version": "0.5.2",
4
- "description": "Launch readiness checker. Reads your code and tells you what will break before your users find out.",
3
+ "version": "0.5.4",
4
+ "description": "Read-only CLI that scans your AI-built app and reports what will break before real users hit it — auth, tenant isolation, data, payments, GDPR. 117 checks run free on your machine.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "launchprep": "bin/launchprep.mjs"
@@ -17,18 +17,26 @@
17
17
  "scripts": {
18
18
  "verify": "node scripts/verify-readonly.mjs",
19
19
  "pretest": "node scripts/verify-readonly.mjs",
20
- "test": "node test/run.mjs && node test/gate.mjs && node test/redact.mjs && node test/git-history.mjs && node test/no-false-alarms.mjs && node test/answers.mjs && node test/standards.mjs && node test/interactive.mjs",
20
+ "test": "node test/run.mjs && node test/gate.mjs && node test/redact.mjs && node test/git-history.mjs && node test/no-false-alarms.mjs && node test/answers.mjs && node test/standards.mjs && node test/interactive.mjs && node test/detect.mjs",
21
21
  "prepack": "node scripts/prepare-publish.mjs",
22
22
  "postpack": "node scripts/restore-after-publish.mjs"
23
23
  },
24
24
  "keywords": [
25
+ "pre-launch",
26
+ "launch-readiness",
25
27
  "security",
26
- "audit",
27
- "readiness",
28
- "scanner",
29
- "prelaunch",
28
+ "owasp",
29
+ "gdpr",
30
+ "static-analysis",
31
+ "code-review",
32
+ "vibe-coding",
33
+ "ai",
34
+ "cursor",
35
+ "lovable",
36
+ "bolt",
37
+ "saas",
30
38
  "cli",
31
- "launch",
39
+ "scanner",
32
40
  "checklist"
33
41
  ],
34
42
  "homepage": "https://launchprep.dev",
@@ -274,7 +274,7 @@ export const BATCH2_CHECKS = [
274
274
  }},
275
275
 
276
276
  // ---- observability & legal ----------------------------------------------
277
- { id: 'OBS-001', run(repo, profile) {
277
+ { id: 'OBS-001', scope: 'root', run(repo, profile) {
278
278
  if (profile.stage !== 'production') return [];
279
279
  if (profile.surface === 'library') return [];
280
280
  const has = repo.grep(/@sentry|bugsnag|rollbar|honeybadger|datadog|new relic|appsignal|@opentelemetry/i,
@@ -288,7 +288,7 @@ export const BATCH2_CHECKS = [
288
288
  'Add an error reporter. Sentry\'s free tier covers a small product entirely.')];
289
289
  }},
290
290
 
291
- { id: 'LEG-001', run(repo, profile) {
291
+ { id: 'LEG-001', scope: 'root', run(repo, profile) {
292
292
  if (profile.data_sensitivity === 'none' || !profile.is_public) return [];
293
293
  if (!profile.has_accounts) return []; // a real product with users, not a template
294
294
  const has = repo.has(/privacy[-_]?policy|privacy\.(html|tsx|jsx|md|erb|vue)|\/privacy\//i) ||
@@ -233,7 +233,7 @@ export const BATCH3_CHECKS = [
233
233
  'Set an expiry, and refresh it while the user is active.')];
234
234
  }},
235
235
 
236
- { id: 'RU-010', run(repo, profile) {
236
+ { id: 'RU-010', scope: 'root', run(repo, profile) {
237
237
  if (!profile.is_public || profile.stage !== 'production') return [];
238
238
  if (profile.surface === 'library') return [];
239
239
  const has = repo.grep(/mailto:|support@|help@|contact@|\/contact\b/i,
@@ -313,7 +313,12 @@ export const BATCH3_CHECKS = [
313
313
  'Add og:image and twitter:image tags with a 1200x630 image.')];
314
314
  }},
315
315
 
316
- { id: 'INC-004', run(repo, profile) {
316
+ { id: 'INC-004', scope: 'root', run(repo, profile) {
317
+ // scope:'root'. A security contact is ONE thing per repository - a
318
+ // SECURITY.md and a /.well-known/security.txt at the root. Run per
319
+ // workspace it fired on every package of a monorepo that has the file at
320
+ // the root (997 times across the corpus), because each workspace view
321
+ // cannot see the root. Root-scoped, it looks once, at the whole repo.
317
322
  if (!profile.is_public || profile.stage !== 'production') return [];
318
323
  if (repo.has(/security\.txt$|SECURITY\.md$/i)) return [];
319
324
  if (repo.grep(/security@/i).length) return [];
@@ -294,6 +294,14 @@ export const BATCH4_CHECKS = [
294
294
  const line = lineAt(t, m.index);
295
295
  if (/console\.|logger\.|log\(|logging\.|capture|Sentry/.test(line)) continue; // logging is right
296
296
  if (!/(res\.|Response\.json|json\(|send\(|return\s*\{|jsonify|content\s*:|message\s*:)/.test(line)) continue;
297
+ // The caught error must be from an AI call, not any error in a file that
298
+ // merely mentions an AI SDK. On real code this fired on a Wildberries
299
+ // connection-error handler because the module imported the AI client
300
+ // elsewhere. Tie the leak to an actual model call in the try block this
301
+ // catch belongs to, or the rationale ("names the model, your org id,
302
+ // your rate limits") is simply not true of this error.
303
+ const beforeCatch = t.slice(Math.max(0, m.index - 600), m.index);
304
+ if (!/messages\.create|chat\.completions|\.(responses|embeddings)\.create|\banthropic\.|\bopenai\.|new (Anthropic|OpenAI)/i.test(beforeCatch)) continue;
297
305
  out.push(finding('AIOP-016', 'The provider\'s error text is shown to the user', 'medium',
298
306
  f.path, lineOf(t, m.index),
299
307
  'Provider errors name the model, your organisation id, your rate limits and sometimes your account tier. Your user sees your bill, and a stranger learns which vendor to attack.',
@@ -309,7 +317,12 @@ export const BATCH4_CHECKS = [
309
317
  const out = [];
310
318
  for (const f of repo.find(/app\/.*\/route\.(ts|js)$|pages\/api\/.*\.(ts|js)$/)) {
311
319
  const t = f.text; if (!t) continue;
312
- if (!/messages\.create|chat\.completions|generateText|streamText|anthropic|openai/i.test(t)) continue;
320
+ // Require an actual model INVOCATION, not just the library name. Matching
321
+ // the bare words "openai"/"anthropic" fired on every route that merely
322
+ // imported the SDK or constructed a client - `import OpenAI from 'openai'`,
323
+ // `new OpenAI()` - with no call at all. That was 66 false positives across
324
+ // two SDK example monorepos. Read the call, not the identifier.
325
+ if (!/\.(?:messages|chat\.completions|completions|responses)\.(?:create|stream)\s*\(|\b(?:generateText|streamText|generateObject|streamObject)\s*\(/i.test(t)) continue;
313
326
  // Two routers, two spellings. App Router exports maxDuration/runtime at the
314
327
  // top level; Pages Router puts them inside `export const config = {...}`.
315
328
  // Only handling the first flagged 77 supabase routes that WERE configured.
@@ -331,8 +344,22 @@ export const BATCH4_CHECKS = [
331
344
  const t = f.text; if (!t) continue;
332
345
  if (!/export\s+default\s+async\s+function|await\s+(?:fetch|db|prisma|sql|supabase)/.test(t)) continue;
333
346
  if (/<Suspense/.test(t)) continue;
334
- const dir = f.path.replace(/[^/]+$/, '');
335
- if (repo.files.some(x => x.path.startsWith(dir) && /loading\.(tsx|jsx|ts|js)$/.test(x.path))) continue;
347
+ // Next App Router inheritance: a loading.tsx covers its own segment AND
348
+ // every nested segment that does not define its own. The old check looked
349
+ // only in the page's OWN directory, so one app/(app)/loading.tsx that
350
+ // legitimately covers a dozen nested pages was missed and every one flagged
351
+ // - a false positive on real code. Walk from the page's directory up to the
352
+ // app/ root and accept a loading file at any level.
353
+ const segs = f.path.split('/');
354
+ const appIdx = segs.indexOf('app');
355
+ if (appIdx < 0) continue;
356
+ let covered = false;
357
+ for (let len = segs.length - 1; len >= appIdx + 1; len--) {
358
+ const prefix = segs.slice(0, len).join('/') + '/';
359
+ if (repo.files.some(x => x.path.startsWith(prefix) &&
360
+ /^loading\.(tsx|jsx|ts|js)$/.test(x.path.slice(prefix.length)))) { covered = true; break; }
361
+ }
362
+ if (covered) continue;
336
363
  out.push(finding('NEXT-012', 'Nothing shown while this page loads its data', 'low',
337
364
  f.path, 1,
338
365
  'The browser stays on the previous page with no sign anything happened. On a slow connection people press the link again, or decide it is broken and leave.',
@@ -408,7 +435,7 @@ export const BATCH4_CHECKS = [
408
435
  }},
409
436
 
410
437
  // ── LEG-003 ─────────────────────────────────────────────────────────────────
411
- { id: 'LEG-003', run(repo, profile) {
438
+ { id: 'LEG-003', scope: 'root', run(repo, profile) {
412
439
  if (!profile.is_public || !profile.has_accounts) return [];
413
440
  if (repo.has(/terms|\/tos[./]|conditions/i)) return [];
414
441
  if (repo.grep(/terms of (?:service|use)/i, /\.(tsx|jsx|html|erb|md|vue|svelte)$/).length) return [];
@@ -431,6 +458,12 @@ export const BATCH4_CHECKS = [
431
458
  while ((m = re.exec(t))) {
432
459
  const tag = m[0];
433
460
  if (/\balt\s*=/.test(tag) || /\baria-hidden\s*=\s*["'{]?true/.test(tag)) continue;
461
+ // Not every <img> in source is a UI image. Skip XSS test payloads a
462
+ // sanitiser keeps inline (<img src=x onerror=alert(...)>) and empty
463
+ // placeholder tags with no image at all - neither is a screen-reader
464
+ // concern, and both were false positives in the corpus.
465
+ if (/\bon(?:error|load)\s*=\s*["']?\s*(?:alert|eval|javascript:)/i.test(tag)) continue;
466
+ if (!/\bsrc\s*=\s*["'{]?\s*\S/.test(tag)) continue;
434
467
  out.push(finding('LEG-006', 'Image with no alt attribute', 'medium',
435
468
  f.path, lineOf(t, m.index),
436
469
  'A screen reader reads the file name instead, which tells a blind visitor nothing. In the US and Australia this is the single most commonly litigated accessibility failure, and the fix is one attribute.',
package/src/detect.mjs CHANGED
@@ -125,14 +125,23 @@ function detectAuth(repo, deps) {
125
125
  return fact('none','low',[]);
126
126
  }
127
127
 
128
- function detectHost(repo, deps) {
129
- if (repo.exists('vercel.json') || deps['@vercel/analytics']) return fact('vercel','high',['vercel.json']);
130
- if (repo.exists('netlify.toml')) return fact('netlify','high',['netlify.toml']);
131
- if (repo.exists('wrangler.toml')) return fact('cloudflare','high',['wrangler.toml']);
132
- if (repo.exists('fly.toml')) return fact('fly','high',['fly.toml']);
133
- if (repo.exists('railway.json')) return fact('railway','high',['railway.json']);
134
- if (repo.exists('render.yaml')) return fact('render','high',['render.yaml']);
135
- if (repo.has(/^Dockerfile$|docker-compose\.ya?ml$/)) return fact('vps','low',['Dockerfile — host inferred']);
128
+ function detectHost(repo, deps, root = null) {
129
+ // Deploy config lives at the REPO ROOT. In a monorepo the app being profiled
130
+ // is a workspace (apps/web), which does not contain railway.json / fly.toml /
131
+ // vercel.json - those sit one level up. Checking the workspace only reported
132
+ // "no host", which made detectStage below call a live app a prototype and
133
+ // skip every production check. Look at the root too, exactly as detectDatabase
134
+ // already does.
135
+ const has = (f) => repo.exists(f) || Boolean(root?.exists(f));
136
+ if (has('vercel.json') || deps['@vercel/analytics']) return fact('vercel','high',['vercel.json']);
137
+ if (has('netlify.toml')) return fact('netlify','high',['netlify.toml']);
138
+ if (has('wrangler.toml')) return fact('cloudflare','high',['wrangler.toml']);
139
+ if (has('fly.toml')) return fact('fly','high',['fly.toml']);
140
+ if (has('railway.json') || has('railway.toml')) return fact('railway','high',['railway.json']);
141
+ if (has('render.yaml')) return fact('render','high',['render.yaml']);
142
+ if (has('Procfile')) return fact('heroku','low',['Procfile']);
143
+ if (repo.has(/^Dockerfile$|docker-compose\.ya?ml$/) || root?.has(/(^|\/)Dockerfile$|docker-compose\.ya?ml$/))
144
+ return fact('vps','low',['Dockerfile — host inferred']);
136
145
  return fact(null,'low',[]);
137
146
  }
138
147
 
@@ -166,6 +175,13 @@ const PII = /\b(email|phone|first_?name|last_?name|address|date_?of_?birth|do
166
175
  const FIN = /\b(card_?number|iban|invoice|amount_cents|stripe_customer|tax_id|vat)\b/i;
167
176
  const HEALTH = /\b(diagnosis|patient|medical_record|prescription|icd_?10)\b/i;
168
177
  const CHILD = /\b(parental_consent|guardian|is_minor|age_gate)\b/i;
178
+ // Third-party credentials stored at rest: an app holding a user's WB token, a
179
+ // bot token or an API key in its own database is a far bigger target than one
180
+ // holding an email - if that database leaks, the attacker gets into the user's
181
+ // OTHER accounts. Matches encrypted-token columns (…tokenEnc, …secretEnc) and
182
+ // the common credential field names, but NOT a bare "token" (a session or reset
183
+ // token is the app's own auth, not a stored third-party secret).
184
+ const CRED = /\b(\w+(?:token|key|secret)_?enc|api_?key|access_?token|refresh_?token|client_?secret|bot_?token|webhook_?secret|oauth_?token)\b/i;
169
185
  // only count matches inside actual schema declarations, with comments removed
170
186
  function schemaHits(repo, re) {
171
187
  const files = repo.files.filter(f => f.text && /\.(sql|prisma|ts|py|rb)$/.test(f.path));
@@ -187,7 +203,11 @@ function detectSensitivity(repo) {
187
203
  const health = schemaHits(repo, HEALTH), child = schemaHits(repo, CHILD);
188
204
  if (terms(health).length >= 2) return fact('health','high',[`clinical fields: ${terms(health).join(', ')}`]);
189
205
  if (terms(child).length >= 2) return fact('children','high',[`minor/guardian fields: ${terms(child).join(', ')}`]);
190
- const fin = schemaHits(repo, FIN), pii = schemaHits(repo, PII);
206
+ const cred = schemaHits(repo, CRED), fin = schemaHits(repo, FIN), pii = schemaHits(repo, PII);
207
+ // Stored third-party credentials carry the same at-rest duties as financial
208
+ // data - encryption, access audit - so they map to the value those rules gate
209
+ // on, with evidence that says plainly what was found.
210
+ if (terms(cred).length >= 1) return fact('financial','high',[`credentials stored at rest: ${terms(cred).slice(0,4).join(', ')}`]);
191
211
  if (terms(fin).length >= 1) return fact('financial','high',[`financial fields: ${terms(fin).slice(0,4).join(', ')}`]);
192
212
  if (terms(pii).length >= 2) return fact('pii','high',[`personal fields: ${terms(pii).slice(0,4).join(', ')}`]);
193
213
  if (terms(pii).length === 1) return fact('pii','low',[`single personal field: ${terms(pii)[0]}`]);
@@ -244,11 +264,16 @@ function detectSurface(repo, framework, deps) {
244
264
  return fact('web-app','low',[]);
245
265
  }
246
266
 
247
- function detectStage(repo, host) {
267
+ function detectStage(repo, host, root = null) {
268
+ const has = (f) => repo.exists(f) || Boolean(root?.exists(f));
269
+ const hasRe = (re) => repo.has(re) || Boolean(root?.has(re));
248
270
  const signals = [];
249
271
  if (host.value) signals.push(`host config (${host.value})`);
250
- if (repo.has(/^\.github\/workflows\//)) signals.push('CI workflows');
251
- if (repo.exists('.env.production') || repo.exists('.env.prod')) signals.push('production env file');
272
+ if (hasRe(/(^|\/)\.github\/workflows\//)) signals.push('CI workflows');
273
+ if (has('.env.production') || has('.env.prod')) signals.push('production env file');
274
+ // A start script or Procfile is a "this is meant to be deployed and run"
275
+ // signal a prototype rarely has - scripts/start.sh, a Procfile, bin/start.
276
+ if (has('scripts/start.sh') || has('Procfile') || has('bin/start')) signals.push('start script');
252
277
  if (signals.length >= 2) return fact('production','low',signals);
253
278
  if (signals.length === 1) return fact('pre-launch','low',signals);
254
279
  return fact('prototype','low',['no deploy signals']);
@@ -263,7 +288,7 @@ export function detectProfile(repo, opts = {}) {
263
288
  const framework = detectFramework(repo, deps);
264
289
  const database = detectDatabase(repo, deps, root);
265
290
  const auth = detectAuth(repo, deps);
266
- const host = detectHost(repo, deps);
291
+ const host = detectHost(repo, deps, root);
267
292
  const llm = detectLlm(repo, deps);
268
293
  const accounts = detectAccounts(repo, deps, auth);
269
294
 
@@ -279,10 +304,10 @@ export function detectProfile(repo, opts = {}) {
279
304
  tenancy: (() => { const l = detectTenancy(repo, accounts.value);
280
305
  return (['none','single-user'].includes(l.value) && root)
281
306
  ? detectTenancy(root, accounts.value) : l; })(),
282
- stage: detectStage(repo, host),
307
+ stage: detectStage(repo, host, root),
283
308
  // cheap extras that gate real rules
284
309
  has_migrations: repo.has(/migrations?\//) ? fact(true,'high',['migrations dir']) : fact(false,'low',[]),
285
- has_ci: repo.has(/^\.github\/workflows\//) ? fact(true,'high',['github workflows']) : fact(false,'high',[]),
310
+ has_ci: (repo.has(/^\.github\/workflows\//) || root?.has(/(^|\/)\.github\/workflows\//)) ? fact(true,'high',['github workflows']) : fact(false,'high',[]),
286
311
  sends_email: dep(deps,'resend','@sendgrid/mail','nodemailer','postmark','@aws-sdk/client-ses')
287
312
  ? fact(true,'high',['email sdk']) : fact(false,'low',[]),
288
313
  handles_payments: dep(deps,'stripe','@stripe/stripe-js') ? fact('stripe','high',['stripe sdk'])
package/src/index.mjs CHANGED
@@ -5,7 +5,7 @@ import { detectProfile, toGateProfile } from './detect.mjs';
5
5
  import { gate, missingFacts } from './gate.mjs';
6
6
  import { runChecks, runRootChecks, CHECKS } from './checks.mjs';
7
7
  import { render } from './report.mjs';
8
- import { askQuestions, confirm } from './interactive.mjs';
8
+ import { askQuestions } from './interactive.mjs';
9
9
  import { questionFor } from './questions.mjs';
10
10
  import { existsSync, statSync } from 'node:fs';
11
11
 
@@ -153,28 +153,36 @@ if (asJson) {
153
153
  shallow: r.shallow.map(s => ({ id: s.id, title: s.title, severity: s.severity })),
154
154
  }, null, 2));
155
155
  } else {
156
- process.stdout.write(render({ repo: target, ...r, stated }));
157
-
158
- // The Q&A. Only in a real terminal, only when there is something to unlock,
159
- // and never when the caller opted out - so a pipe, --json or a CI job is
160
- // untouched. Answers are held in memory and merged; no file is written.
161
- if (!noInteractive && process.stdin.isTTY && process.stdout.isTTY && r.questions.length) {
156
+ // Will we ask the questions in the terminal? Only in a real TTY, only when
157
+ // there is something to unlock, and never on --json / --yes / --no-interactive
158
+ // or a pipe. Decided BEFORE rendering so the report can drop the "write
159
+ // launchprep.json" instructions it would otherwise duplicate.
160
+ const willPrompt = !noInteractive && process.stdin.isTTY && process.stdout.isTTY && r.questions.length > 0;
161
+
162
+ process.stdout.write(render({ repo: target, ...r, stated, interactive: willPrompt }));
163
+
164
+ if (willPrompt) {
165
+ // No yes/no gate: a user typed their answers into the old [Y/n] and it read
166
+ // the whole sentence as "no". Go straight to the questions instead, each one
167
+ // clearly skippable with Enter, so there is only ever one thing to type.
162
168
  const unlocks = r.questions.reduce((n, [, c]) => n + c, 0);
163
- const yes = await confirm(
164
- `\n \x1b[36mAnswer ${r.questions.length} quick question${r.questions.length === 1 ? '' : 's'}` +
165
- ` to unlock up to ${unlocks} more checks?\x1b[0m \x1b[2m[Y/n]\x1b[0m `);
166
- if (yes) {
167
- const answers = await askQuestions(r.questions.map(([fact]) => fact));
168
- if (Object.keys(answers).length) {
169
- const before = new Set(r.findings.map(keyOf));
170
- const ranBefore = r.coverage.ran;
171
- stated = { ...(fileStated || {}), ...answers };
172
- r = analyze(stated);
173
- const fresh = r.findings.filter(f => !before.has(keyOf(f)));
174
- printDelta(fresh, r.coverage.ran - ranBefore);
175
- } else {
176
- process.stdout.write(`\n \x1b[2mNothing answered — the report above is unchanged.\x1b[0m\n\n`);
177
- }
169
+ process.stdout.write(
170
+ `\n \x1b[36m${r.questions.length} question${r.questions.length === 1 ? '' : 's'} unlock up to ` +
171
+ `${unlocks} more checks\x1b[0m \x1b[2m they can't be read from your code. ` +
172
+ `Type a number for each, or press Enter to skip.\x1b[0m\n`);
173
+
174
+ const answers = await askQuestions(r.questions.map(([fact]) => fact));
175
+ if (Object.keys(answers).length) {
176
+ const before = new Set(r.findings.map(keyOf));
177
+ const ranBefore = r.coverage.ran;
178
+ stated = { ...(fileStated || {}), ...answers };
179
+ r = analyze(stated);
180
+ const fresh = r.findings.filter(f => !before.has(keyOf(f)));
181
+ printDelta(fresh, r.coverage.ran - ranBefore);
182
+ } else {
183
+ process.stdout.write(
184
+ `\n \x1b[2mSkipped — the report above is unchanged. You can also put the answers in\x1b[0m` +
185
+ ` launchprep.json \x1b[2mand run again.\x1b[0m\n\n`);
178
186
  }
179
187
  }
180
188
  }
@@ -194,6 +202,15 @@ function printDelta(fresh, moreRan) {
194
202
  const SEV = { critical: '\x1b[31m', high: '\x1b[33m', medium: '\x1b[34m', low: '\x1b[90m' };
195
203
  const p = (s = '') => process.stdout.write(s + '\n');
196
204
 
205
+ // Nothing new RAN is different from checks running and passing. When an answer
206
+ // points away from a regime - "other" means no specific privacy law, "no"
207
+ // means the AI cannot act - the checks it would unlock simply do not apply, or
208
+ // they need the deep scan. Saying "they all passed" there is a small lie.
209
+ if (moreRan === 0) {
210
+ p(`\n ${C.d}Your answers unlocked no more free checks here — the ones they point to`);
211
+ p(` either don't apply to what you said, or need the deep scan.${C.o}\n`);
212
+ return;
213
+ }
197
214
  p(`\n ${C.grn}${moreRan} more check${moreRan === 1 ? '' : 's'} ran with your answers.${C.o}`);
198
215
  if (!fresh.length) {
199
216
  p(` ${C.d}They all passed — nothing new to fix.${C.o}\n`);
@@ -72,7 +72,7 @@ export async function askQuestions(facts, { input = process.stdin, output = proc
72
72
  // is worse than being asked again.
73
73
  let value = null;
74
74
  while (value === null) {
75
- const raw = (await ask(` ${C.b}›${C.o} `)).trim().toLowerCase();
75
+ const raw = (await ask(` ${C.d}number, or Enter to skip${C.o} ${C.b}›${C.o} `)).trim().toLowerCase();
76
76
  if (raw === '') break; // skip this one
77
77
  const n = Number(raw);
78
78
  if (Number.isInteger(n) && n >= 1 && n <= shape.choices.length) value = shape.choices[n - 1];
@@ -86,15 +86,3 @@ export async function askQuestions(facts, { input = process.stdin, output = proc
86
86
  }
87
87
  return stated;
88
88
  }
89
-
90
- // The yes/no gate before any of it. Returns true only on an explicit yes.
91
- export async function confirm(prompt, { input = process.stdin, output = process.stdout, readLine } = {}) {
92
- const rl = readLine ? null : createInterface({ input, output });
93
- const ask = readLine || ((q) => new Promise(res => rl.question(q, res)));
94
- try {
95
- const raw = (await ask(prompt)).trim().toLowerCase();
96
- return raw === '' || raw === 'y' || raw === 'yes'; // default yes
97
- } finally {
98
- rl?.close();
99
- }
100
- }
package/src/report.mjs CHANGED
@@ -8,12 +8,18 @@ const SEV = { critical:[C.red,'CRITICAL'], high:[C.yel,'HIGH'], medium:[C.blu,'M
8
8
  // The wording lives in questions.mjs — 33 facts, not the 5 that had text.
9
9
  // A customer used to read "? llm_tools_enabled" as the last line of a scan.
10
10
 
11
- export function render({ repo, scanned, lead, findings, questions, shallow = [], coverage = null, stated = null }) {
11
+ export function render({ repo, scanned, lead, findings, questions, shallow = [], coverage = null, stated = null, interactive = false }) {
12
12
  const L = [];
13
13
  const p = (s = '') => L.push(s);
14
14
  const pr = lead.profile;
15
15
 
16
- p(`\n${C.bold}${BRAND.name.toUpperCase()}${C.off} ${C.dim}${repo}${C.off}\n`);
16
+ // Collapse the home directory to ~, so the header does not print the user's
17
+ // OS username and full folder layout. It is only shown in their own terminal,
18
+ // but a screenshot or a pasted report would otherwise carry it. process.env
19
+ // (not node:os) so nothing new is imported and the read-only guard is happy.
20
+ const home = process.env.HOME || process.env.USERPROFILE || '';
21
+ const shownPath = home && repo.startsWith(home + '/') ? '~' + repo.slice(home.length) : repo;
22
+ p(`\n${C.bold}${BRAND.name.toUpperCase()}${C.off} ${C.dim}${shownPath}${C.off}\n`);
17
23
 
18
24
  // ---------- what this app is ----------
19
25
  p(`${C.bold}What we found${C.off}`);
@@ -130,7 +136,7 @@ export function render({ repo, scanned, lead, findings, questions, shallow = [],
130
136
  p(` ${C.dim}${String(n).padStart(3)} because ${f} is ${JSON.stringify(lead.profile[f] ?? lead.profile.stack?.[f.split('.').pop()])}${C.off}`);
131
137
 
132
138
  // ---------- the three questions ----------
133
- if (questions.length) {
139
+ if (questions.length && !interactive) {
134
140
  p(`\n${C.bold}${g.unknown.length} more checks need ${questions.length} answer${questions.length === 1 ? '' : 's'}${C.off}`);
135
141
  for (const [factName, n] of questions) {
136
142
  const q = questionFor(factName);
@@ -142,9 +148,9 @@ export function render({ repo, scanned, lead, findings, questions, shallow = [],
142
148
  // This used to say "Correct it and the checks adjust" with no way on earth to
143
149
  // do so — no flag, no file, no prompt. It was the last line of every scan and
144
150
  // it invited an action that did not exist. Now it names the file that works.
145
- if (questions.length)
151
+ if (questions.length && !interactive)
146
152
  p(`\n ${C.dim}Put those in ${C.off}launchprep.json${C.dim} at the top of your project and run again — the answers unlock the checks above.${C.off}\n`);
147
- else
153
+ else if (!interactive)
148
154
  p(`\n ${C.dim}Wrong about your app? Correct it in ${C.off}launchprep.json${C.dim} and run again.${C.off}\n`);
149
155
  return L.join('\n');
150
156
  }