launchprep 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,20 +1,84 @@
1
- # Launchprep
1
+ # launchprep
2
2
 
3
- **Launch readiness checker.** Reads your code and tells you what will break.
3
+ Reads your code and tells you what will break before your users find out.
4
4
 
5
- 288 checks — but it works out what you actually built before running any of
6
- them, and tells you which ones it skipped and why. You are not building
7
- Instagram; you will not hear about Instagram's problems.
5
+ ```bash
6
+ npx launchprep # the free checks runs here, uploads nothing
7
+ npx launchprep login # store the licence key from your email
8
+ npx launchprep deep # the paid scan — asks before sending anything
9
+ npx launchprep whoami # scans remaining
10
+ ```
11
+
12
+ No signup. No dashboard. Nothing added to your repo.
13
+
14
+ ## What it does
15
+
16
+ 288 checks — but it works out what you built **before** running any of them, so
17
+ you are not told about problems you cannot have. A static site is asked 48
18
+ questions; a multi-tenant SaaS with payments and AI is asked 158.
19
+
20
+ Every check ends in one of four states, and you are shown all four:
21
+
22
+ | | |
23
+ |---|---|
24
+ | **pass** | applicable, and satisfied |
25
+ | **fail** | applicable, and violated — a finding, with a file, a line and the fix |
26
+ | **skipped** | does not apply to you — **listed, with the reason** |
27
+ | **unknown** | cannot be determined from the code — becomes a question, never a CRITICAL |
28
+
29
+ That skipped list is the point. "47 checks skipped, you are not multi-tenant" is
30
+ what should make you trust the 12 that did fire.
31
+
32
+ ## The free scan cannot touch your code, and cannot phone home
33
+
34
+ The scanner reads. It has no ability to write files, run commands, or open a
35
+ network connection — and that is enforced by a build step rather than promised:
36
+
37
+ ```bash
38
+ node scripts/verify-readonly.mjs
39
+ ```
40
+
41
+ That script ships with the package. Run it yourself. It fails if anything under
42
+ `src/` gains the ability to write, execute, or reach the network — **and if
43
+ anything under `src/` so much as imports a file from outside it.**
44
+
45
+ That last part matters. `launchprep login` writes a key to disk and
46
+ `launchprep deep` uploads code. Both are real. Both live in `net/`, not `src/`,
47
+ so the scanner cannot borrow a capability it does not have. Without that check
48
+ this script could print "read-only verified" while the scanner was perfectly
49
+ able to upload your repository — a promise that passes its own test and is
50
+ false, which is worse than no promise at all.
8
51
 
9
- - Runs entirely on your machine. Reads your files, changes none of them, uploads nothing.
10
- - Covers Next.js, Express, Django, Rails, Laravel, Supabase, Stripe, and mobile.
11
- - Every finding carries a file, a line, and the fix.
52
+ So the difference is visible in the file tree:
12
53
 
13
54
  ```
14
- npx launchprep
55
+ src/ the free scanner reads. writes nothing. connects to nothing.
56
+ net/ the paid client writes your key. uploads a digest. asks first.
15
57
  ```
16
58
 
17
- **This package is a placeholder while the tool is finished.**
18
- See [launchprep.dev](https://launchprep.dev).
59
+ ## The deep scan does send code and tells you before it does
60
+
61
+ `launchprep deep` sends a digest of the security-relevant files to
62
+ launchprep.dev, and from there to Anthropic, so a model can read them. Before a
63
+ single byte leaves it shows you how many files, roughly how many tokens, and
64
+ where they are going — and offers to list every file by name.
65
+
66
+ Your code is held in memory for the scan and discarded. It is never written to
67
+ disk, never logged, and never stored in a database. The findings are kept, so
68
+ the report link keeps working, and that link expires.
69
+
70
+ If there is no terminal — CI, a script — it refuses rather than assuming yes.
71
+
72
+ ## Free and paid
73
+
74
+ Everything above is free, unlimited, forever — every check a program can make on
75
+ its own.
76
+
77
+ The paid tier ($29, five deep scans) is the 173 checks that need a model to
78
+ reason about the code: whether one customer can reach another's data, whether
79
+ your tenants are really separated, what happens to information you promised to
80
+ delete. See <https://launchprep.dev>.
81
+
82
+ ## Licence
19
83
 
20
- MIT licensed.
84
+ MIT
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ // The entry point, and a deliberately thin one.
3
+ //
4
+ // `launchprep` with no subcommand is the free scanner: src/, which cannot
5
+ // write, execute, or open a connection, and a build step proves it. Nothing in
6
+ // src/ may even import from out here - the guard checks that too, because a
7
+ // promise that passes its own test and is false is worse than no promise.
8
+ //
9
+ // login / logout / deep are the paid half. They write a key to disk and upload
10
+ // a digest, and they live in net/ so that difference is visible in the file
11
+ // tree rather than buried in a privacy policy.
12
+ import { BRAND } from '../src/brand.mjs';
13
+
14
+ const argv = process.argv.slice(2);
15
+ const cmd = argv[0];
16
+ const KNOWN = new Set(['login', 'logout', 'deep', 'whoami', 'help', '--help', '-h']);
17
+
18
+ // Anything that reaches here uncaught would otherwise print a Node stack
19
+ // trace at someone who paid $29 and cannot read one. A network that is down
20
+ // is not a bug in their project, and it should not look like one.
21
+ try {
22
+ if (!KNOWN.has(cmd)) {
23
+ // free scan - src/ only, nothing from net/ is loaded at all
24
+ await import('../src/index.mjs');
25
+ } else {
26
+ const { run } = await import('../net/commands.mjs');
27
+ await run(cmd, argv.slice(1));
28
+ }
29
+ } catch (e) {
30
+ const msg = e?.message || String(e);
31
+ process.stderr.write(`\n \x1b[31m${msg}\x1b[0m\n`);
32
+ if (/could not reach|did not answer/i.test(msg)) {
33
+ process.stderr.write(` \x1b[2mThe free checks do not need the network. Run \x1b[0mnpx ${BRAND.slug}\x1b[2m on its own.\x1b[0m\n`);
34
+ process.stderr.write(` \x1b[2mIf this keeps happening, mail ${BRAND.email} — your scans are not spent unless a scan runs.\x1b[0m\n`);
35
+ }
36
+ process.stderr.write('\n');
37
+ process.exit(1);
38
+ }
package/net/client.mjs ADDED
@@ -0,0 +1,41 @@
1
+ // Talking to launchprep.dev. The only file in the CLI that opens a connection.
2
+ import { BRAND } from '../src/brand.mjs';
3
+ const BASE = process.env.LAUNCHPREP_API || BRAND.api;
4
+
5
+ async function call(path, { key, body, method = 'POST' } = {}) {
6
+ let res;
7
+ try {
8
+ res = await fetch(BASE + path, {
9
+ method,
10
+ headers: {
11
+ ...(key ? { authorization: `Bearer ${key}` } : {}),
12
+ ...(body ? { 'content-type': 'application/json' } : {}),
13
+ },
14
+ body: body ? JSON.stringify(body) : undefined,
15
+ // A deep scan is minutes of model time, not seconds. But it is not
16
+ // forever either - API-010, our own rule: nothing outbound should be able
17
+ // to hang indefinitely.
18
+ signal: AbortSignal.timeout(Number(process.env.LAUNCHPREP_TIMEOUT_MS || 900_000)),
19
+ });
20
+ } catch (e) {
21
+ if (e.name === 'TimeoutError') throw new Error('launchprep.dev did not answer in time');
22
+ throw new Error(`could not reach ${BASE} — ${e.message}`);
23
+ }
24
+ const text = await res.text();
25
+ let data = null;
26
+ try { data = text ? JSON.parse(text) : null; } catch {}
27
+ return { status: res.status, ok: res.ok, data, raw: text };
28
+ }
29
+
30
+ export const validate = (key) => call('/v1/validate', { key });
31
+
32
+ export const deepScan = ({ key, digest, profile, ruleIds, appName, idempotencyKey }) =>
33
+ call('/v1/scan', { key, body: {
34
+ digest: digest.text,
35
+ profile,
36
+ rule_ids: ruleIds,
37
+ app_name: appName,
38
+ idempotency_key: idempotencyKey,
39
+ }});
40
+
41
+ export { BASE };
@@ -0,0 +1,166 @@
1
+ // login · logout · whoami · deep
2
+ import { createInterface } from 'node:readline';
3
+ import { randomUUID, createHash } from 'node:crypto';
4
+ import { basename, resolve } from 'node:path';
5
+
6
+ import { BRAND } from '../src/brand.mjs';
7
+ import { scanRepo } from '../src/fs-scan.mjs';
8
+ import { detectProfile, toGateProfile } from '../src/detect.mjs';
9
+ import { gate } from '../src/gate.mjs';
10
+ import { buildDigest } from '../src/digest.mjs';
11
+ import { save, load, clear, FILE } from './credentials.mjs';
12
+ import { confirmUpload } from './consent.mjs';
13
+ import { validate, deepScan, BASE } from './client.mjs';
14
+
15
+ const C = { b:'\x1b[1m', d:'\x1b[2m', g:'\x1b[90m', o:'\x1b[38;5;209m',
16
+ grn:'\x1b[32m', red:'\x1b[31m', yel:'\x1b[33m', off:'\x1b[0m' };
17
+ const SEV = { critical:[C.red,'CRITICAL'], high:[C.yel,'HIGH'], medium:['\x1b[34m','MEDIUM'], low:[C.g,'LOW'] };
18
+ const flag = (a, n) => a.includes('--' + n);
19
+
20
+ const prompt = (q) => new Promise((r) => {
21
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
22
+ rl.question(q, (a) => { rl.close(); r(a.trim()); });
23
+ });
24
+
25
+ async function login(args) {
26
+ let key = args.find(a => a.startsWith('lp_'));
27
+ if (!key) {
28
+ if (!process.stdin.isTTY) { console.error(' pass the key as an argument, or set LAUNCHPREP_KEY'); process.exit(1); }
29
+ console.log(`\n${C.b}${BRAND.name}${C.off} ${C.d}— paste the key from your email${C.off}\n`);
30
+ key = await prompt(' key: ');
31
+ }
32
+ if (!/^lp_/.test(key)) { console.error(`\n ${C.red}That does not look like a licence key.${C.off} They start with lp_\n`); process.exit(1); }
33
+
34
+ const r = await validate(key);
35
+ if (r.status === 429) { console.error(`\n ${C.red}Too many requests.${C.off} ${C.d}Try again in ${backoff(r)}.${C.off}\n`); process.exit(1); }
36
+ if (r.status === 401) { console.error(`\n ${C.red}That key is not recognised.${C.off}\n`); process.exit(1); }
37
+ if (r.status === 403) { console.error(`\n ${C.red}That key has been revoked.${C.off}\n`); process.exit(1); }
38
+ if (!r.ok) { console.error(`\n ${C.red}Could not check the key${C.off} ${C.d}(${r.status})${C.off}\n`); process.exit(1); }
39
+
40
+ const path = save({ key, email: r.data.email });
41
+ console.log(`\n ${C.grn}Signed in${C.off} as ${r.data.email}`);
42
+ console.log(` ${C.d}${r.data.scansRemaining} of ${r.data.scansLimit} deep scans left${C.off}`);
43
+ console.log(` ${C.d}key stored in ${path}, readable only by you${C.off}\n`);
44
+ }
45
+
46
+ function logout() {
47
+ console.log(clear() ? `\n ${C.grn}Signed out.${C.off} ${C.d}${FILE} removed${C.off}\n`
48
+ : `\n ${C.d}You were not signed in.${C.off}\n`);
49
+ }
50
+
51
+ async function whoami() {
52
+ const c = load();
53
+ if (!c) { console.log(`\n ${C.d}Not signed in. Run: ${C.off}npx ${BRAND.slug} login\n`); return; }
54
+ const r = await validate(c.key);
55
+ if (!r.ok) { console.log(`\n ${C.red}The stored key is no longer valid.${C.off}\n`); return; }
56
+ console.log(`\n ${r.data.email}`);
57
+ console.log(` ${C.d}${r.data.scansRemaining} of ${r.data.scansLimit} deep scans left · key from ${c.source}${C.off}\n`);
58
+ }
59
+
60
+ async function deep(args) {
61
+ const target = resolve(args.find(a => !a.startsWith('-')) || process.cwd());
62
+ const c = load();
63
+ if (!c) {
64
+ console.error(`\n ${C.red}Not signed in.${C.off}`);
65
+ console.error(` ${C.d}Buy a key at https://${BRAND.slug}.dev, then: ${C.off}npx ${BRAND.slug} login\n`);
66
+ process.exit(1);
67
+ }
68
+
69
+ console.log(`\n${C.b}${BRAND.name.toUpperCase()} DEEP${C.off} ${C.d}${target}${C.off}\n`);
70
+
71
+ const repo = scanRepo(target, { maxFiles: 12000 });
72
+ const profile = toGateProfile(detectProfile(repo));
73
+ const g = gate(profile);
74
+ const tier2 = g.evaluated.filter(r => r.tier === 2);
75
+ if (!tier2.length) {
76
+ console.log(` ${C.grn}No deep checks apply to this project.${C.off}`);
77
+ console.log(` ${C.d}Nothing was uploaded and no scan was used.${C.off}\n`);
78
+ return;
79
+ }
80
+ const digest = buildDigest(repo, { maxTokens: 150_000, profile });
81
+
82
+ console.log(` ${C.g}what it is ${C.off}${profile.surface} · ${[profile.stack.framework, profile.stack.database, profile.stack.host].filter(Boolean).join(' · ') || '—'}`);
83
+ console.log(` ${C.g}deep checks ${C.off}${tier2.length} of ${g.evaluated.length + g.skipped.length} apply`);
84
+
85
+ const okToSend = await confirmUpload({ digest, host: new URL(BASE).host, assumeYes: flag(args, 'yes') });
86
+ if (!okToSend) process.exit(1);
87
+
88
+ // Same repo, same rules, same content = same request. If the connection drops
89
+ // and this is run again, the server recognises it and does not charge a
90
+ // second scan.
91
+ const idem = createHash('sha256')
92
+ .update(digest.text).update(tier2.map(r => r.id).join(',')).digest('hex').slice(0, 32);
93
+
94
+ console.log(` ${C.d}scanning — this takes a few minutes${C.off}`);
95
+ const r = await deepScan({ key: c.key, digest, profile,
96
+ ruleIds: tier2.map(x => x.id), appName: basename(target), idempotencyKey: idem });
97
+
98
+ if (r.status === 429) {
99
+ console.error(`\n ${C.red}Too many requests.${C.off} ${C.d}Try again in ${backoff(r)}. No scan was used.${C.off}\n`);
100
+ process.exit(1);
101
+ }
102
+ if (r.status === 402) {
103
+ console.error(`\n ${C.red}No deep scans left${C.off} ${C.d}(${r.data?.used}/${r.data?.limit} used)${C.off}\n`);
104
+ process.exit(1);
105
+ }
106
+ if (r.status === 413) {
107
+ console.error(`\n ${C.red}This project is too large for one scan.${C.off}`);
108
+ console.error(` ${C.d}${r.data?.tokens?.toLocaleString()} tokens, limit ${r.data?.limit?.toLocaleString()}. No scan was used.${C.off}\n`);
109
+ process.exit(1);
110
+ }
111
+ if (!r.ok) {
112
+ console.error(`\n ${C.red}The scan did not run${C.off} ${C.d}(${r.status})${C.off} ${r.data?.detail || r.data?.error || ''}`);
113
+ if (r.data?.refunded) console.error(` ${C.grn}Your scan was not used.${C.off}`);
114
+ console.error('');
115
+ process.exit(1);
116
+ }
117
+
118
+ const findings = r.data.findings || [];
119
+ const rank = { critical:0, high:1, medium:2, low:3 };
120
+ findings.sort((a, b) => rank[a.severity] - rank[b.severity]);
121
+
122
+ console.log(`\n${C.b}Findings${C.off} ${C.d}${findings.length}${C.off}\n`);
123
+ for (const f of findings.slice(0, 25)) {
124
+ const [col, label] = SEV[f.severity] || SEV.low;
125
+ console.log(` ${col}${C.b}${label}${C.off} ${f.title}`);
126
+ console.log(` ${C.g}${f.file}:${f.line}${C.off}`);
127
+ console.log(` ${f.detail}`);
128
+ console.log(` ${C.grn}Fix${C.off} ${f.fix}\n`);
129
+ }
130
+ if (r.data.report?.url) {
131
+ console.log(`${C.b}Your report${C.off}`);
132
+ console.log(` ${C.o}${r.data.report.url}${C.off}`);
133
+ console.log(` ${C.d}dated, shareable, and it expires in ${r.data.report.expiresInDays} days${C.off}\n`);
134
+ }
135
+ console.log(` ${C.d}${r.data.scansRemaining} deep scan${r.data.scansRemaining === 1 ? '' : 's'} left${C.off}\n`);
136
+ }
137
+
138
+ function help() {
139
+ console.log(`
140
+ ${C.b}${BRAND.name}${C.off} ${C.d}${BRAND.tagline}${C.off}
141
+
142
+ ${C.b}npx ${BRAND.slug}${C.off} ${C.d}[path]${C.off} the free checks — runs here, uploads nothing
143
+ ${C.b}npx ${BRAND.slug} login${C.off} store the licence key from your email
144
+ ${C.b}npx ${BRAND.slug} whoami${C.off} who you are signed in as, and scans left
145
+ ${C.b}npx ${BRAND.slug} logout${C.off} forget the key
146
+ ${C.b}npx ${BRAND.slug} deep${C.off} ${C.d}[path]${C.off} the paid scan — asks before sending anything
147
+
148
+ ${C.d}--yes${C.off} skip the upload confirmation ${C.d}(for CI)${C.off}
149
+ ${C.d}--json${C.off} machine readable ${C.d}(free scan only)${C.off}
150
+
151
+ ${C.d}https://${BRAND.slug}.dev${C.off}
152
+ `);
153
+ }
154
+
155
+ const backoff = (r) => {
156
+ const s = Number(r.data?.retryAfter) || 60;
157
+ return s < 90 ? `${s} seconds` : `${Math.ceil(s / 60)} minutes`;
158
+ };
159
+
160
+ export async function run(cmd, args) {
161
+ if (cmd === 'login') return login(args);
162
+ if (cmd === 'logout') return logout();
163
+ if (cmd === 'whoami') return whoami();
164
+ if (cmd === 'deep') return deep(args);
165
+ return help();
166
+ }
@@ -0,0 +1,57 @@
1
+ // Nobody's code leaves their machine without them being told what and where.
2
+ //
3
+ // Every other AI tool uploads first and explains in a privacy policy. This is
4
+ // the same principle the rest of the product runs on - shown, so you can
5
+ // correct it - applied to the one moment it matters most.
6
+ import { createInterface } from 'node:readline';
7
+
8
+ const C = { b:'\x1b[1m', d:'\x1b[2m', o:'\x1b[38;5;209m', g:'\x1b[90m', off:'\x1b[0m' };
9
+
10
+ function ask(question) {
11
+ return new Promise((resolve) => {
12
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
13
+ rl.question(question, (a) => { rl.close(); resolve(a.trim().toLowerCase()); });
14
+ });
15
+ }
16
+
17
+ export async function confirmUpload({ digest, host, assumeYes = false, showFiles = false }) {
18
+ const files = digest.included.length;
19
+ const tokens = digest.tokens.toLocaleString();
20
+
21
+ console.log(`\n${C.b}Before this runs${C.off}\n`);
22
+ console.log(` This sends ${C.b}${files} files${C.off} of your code ${C.d}(about ${tokens} tokens)${C.off}`);
23
+ console.log(` to ${C.b}${host}${C.off}, and from there to Anthropic, so a model can read them.\n`);
24
+ console.log(` ${C.g}Your code is not stored.${C.off} It is held in memory for the scan and discarded.`);
25
+ console.log(` ${C.g}The findings are kept${C.off} — file names, line numbers, and what to fix —`);
26
+ console.log(` so the report link keeps working. That link expires.\n`);
27
+ console.log(` ${C.d}The free checks never do any of this. They run entirely on this machine.${C.off}\n`);
28
+
29
+ if (showFiles) {
30
+ console.log(`${C.b}What would be sent${C.off}`);
31
+ for (const f of digest.included.slice(0, 200))
32
+ console.log(` ${C.g}${String(f.tokens).padStart(6)}${C.off} ${f.path}`);
33
+ if (digest.included.length > 200)
34
+ console.log(` ${C.d}… and ${digest.included.length - 200} more${C.off}`);
35
+ console.log('');
36
+ }
37
+
38
+ if (assumeYes) { console.log(` ${C.d}--yes given, continuing.${C.off}\n`); return true; }
39
+
40
+ // No terminal (CI, a pipe) and no --yes: refuse rather than guess. Uploading
41
+ // someone's source because nobody was there to say no is not a default.
42
+ if (!process.stdin.isTTY) {
43
+ console.log(` ${C.o}Not a terminal, so there is nobody to ask.${C.off}`);
44
+ console.log(` ${C.d}Re-run with --yes if you meant this to be automatic.${C.off}\n`);
45
+ return false;
46
+ }
47
+
48
+ if (!showFiles) {
49
+ const list = await ask(` List the files first? ${C.d}[y/N]${C.off} `);
50
+ if (list === 'y' || list === 'yes')
51
+ return confirmUpload({ digest, host, assumeYes, showFiles: true });
52
+ }
53
+ const go = await ask(` Send them and run the deep scan? ${C.d}[y/N]${C.off} `);
54
+ const yes = go === 'y' || go === 'yes';
55
+ console.log(yes ? '' : `\n ${C.d}Nothing was sent.${C.off}\n`);
56
+ return yes;
57
+ }
@@ -0,0 +1,33 @@
1
+ // Where the licence key lives.
2
+ //
3
+ // This file WRITES, which is why it is not in src/. The scanner has no such
4
+ // ability and a build step proves it; these two commands do, and say so.
5
+ import { readFileSync, writeFileSync, mkdirSync, rmSync, chmodSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+
9
+ export const DIR = join(homedir(), '.launchprep');
10
+ export const FILE = join(DIR, 'credentials');
11
+
12
+ export function save({ key, email }) {
13
+ // 0700 / 0600. The key is a bearer token: anyone who can read the file can
14
+ // spend the scans. Other users on a shared machine should not be able to.
15
+ mkdirSync(DIR, { recursive: true, mode: 0o700 });
16
+ writeFileSync(FILE, JSON.stringify({ key, email, savedAt: new Date().toISOString() }, null, 2) + '\n',
17
+ { mode: 0o600 });
18
+ try { chmodSync(DIR, 0o700); chmodSync(FILE, 0o600); } catch {}
19
+ return FILE;
20
+ }
21
+
22
+ export function load() {
23
+ // An env var wins, so CI never needs a file on disk.
24
+ if (process.env.LAUNCHPREP_KEY) return { key: process.env.LAUNCHPREP_KEY.trim(), source: 'env' };
25
+ try {
26
+ const c = JSON.parse(readFileSync(FILE, 'utf8'));
27
+ return c.key ? { ...c, source: 'file' } : null;
28
+ } catch { return null; }
29
+ }
30
+
31
+ export function clear() {
32
+ try { rmSync(FILE); return true; } catch { return false; }
33
+ }
package/package.json CHANGED
@@ -1,13 +1,22 @@
1
1
  {
2
2
  "name": "launchprep",
3
- "version": "0.0.1",
4
- "description": "Launch readiness checker. Reads your code and tells you what will break. Coming soon.",
3
+ "version": "0.2.0",
4
+ "description": "Launch readiness checker. Reads your code and tells you what will break before your users find out.",
5
+ "type": "module",
5
6
  "bin": {
6
- "launchprep": "bin/cli.js"
7
+ "launchprep": "./bin/launchprep.mjs"
7
8
  },
8
- "type": "module",
9
- "engines": {
10
- "node": ">=18"
9
+ "files": [
10
+ "src",
11
+ "net",
12
+ "bin",
13
+ "scripts/verify-readonly.mjs",
14
+ "README.md"
15
+ ],
16
+ "scripts": {
17
+ "verify": "node scripts/verify-readonly.mjs",
18
+ "pretest": "node scripts/verify-readonly.mjs",
19
+ "test": "node test/run.mjs"
11
20
  },
12
21
  "keywords": [
13
22
  "security",
@@ -15,12 +24,18 @@
15
24
  "readiness",
16
25
  "scanner",
17
26
  "prelaunch",
18
- "cli"
27
+ "cli",
28
+ "launch",
29
+ "checklist"
19
30
  ],
20
31
  "homepage": "https://launchprep.dev",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/312labs/launchprep-app.git",
35
+ "directory": "cli"
36
+ },
21
37
  "license": "MIT",
22
- "files": [
23
- "bin",
24
- "README.md"
25
- ]
26
- }
38
+ "engines": {
39
+ "node": ">=18"
40
+ }
41
+ }
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ // Fails the build if anything under src/ gains the ability to write, execute,
3
+ // or phone home. Launchprep reads the user's codebase; it must never touch it.
4
+ import { readdirSync, readFileSync } from 'node:fs';
5
+ import { join, extname } from 'node:path';
6
+
7
+ const SRC = new URL('../src/', import.meta.url).pathname;
8
+
9
+ const FORBIDDEN = [
10
+ [/\bwriteFileSync\b|\bappendFileSync\b|\bwriteSync\b|\bcreateWriteStream\b/, 'file write'],
11
+ [/\bmkdirSync\b|\brmSync\b|\brmdirSync\b|\bunlinkSync\b|\btruncateSync\b/, 'file/dir mutation'],
12
+ [/\brenameSync\b|\bcopyFileSync\b|\bchmodSync\b|\bchownSync\b|\bsymlinkSync\b/, 'file metadata change'],
13
+ [/from\s+['"]node:child_process['"]|require\(['"]child_process['"]\)/, 'process execution'],
14
+ [/\bexecSync\b|\bspawnSync\b|\bexecFileSync\b/, 'process execution'],
15
+ [/from\s+['"]node:(net|http|https|dgram|tls)['"]/, 'network'],
16
+ [/\bfetch\s*\(/, 'network'],
17
+ ];
18
+
19
+ const files = [];
20
+ (function walk(d) {
21
+ for (const e of readdirSync(d, { withFileTypes: true })) {
22
+ const p = join(d, e.name);
23
+ if (e.isDirectory()) walk(p);
24
+ else if (['.mjs', '.js', '.ts'].includes(extname(e.name))) files.push(p);
25
+ }
26
+ })(SRC);
27
+
28
+ let bad = 0;
29
+ // A forbidden name appearing INSIDE a regex or a string is a pattern to search
30
+ // for, not a call. Strip those before matching, or the scanner cannot contain a
31
+ // check that looks for dangerous code.
32
+ function stripLiterals(line) {
33
+ return line
34
+ .replace(/\/(?![*/])(?:\\.|\[[^\]]*\]|[^\\/\n])+\/[gimsuy]*/g, ' RE ')
35
+ .replace(/'(?:\\.|[^'\\])*'/g, " '' ")
36
+ .replace(/"(?:\\.|[^"\\])*"/g, ' "" ')
37
+ .replace(/`(?:\\.|[^`\\])*`/g, ' `` ');
38
+ }
39
+
40
+ for (const f of files) {
41
+ const text = readFileSync(f, 'utf8');
42
+ text.split('\n').forEach((raw, i) => {
43
+ if (raw.trimStart().startsWith('//')) return; // comments describe, they don't run
44
+ const line = stripLiterals(raw);
45
+ for (const [re, label] of FORBIDDEN) {
46
+ if (re.test(line)) {
47
+ console.error(` ${f.replace(SRC, 'src/')}:${i + 1} ${label}\n ${raw.trim()}`);
48
+ bad++;
49
+ }
50
+ }
51
+ });
52
+ }
53
+
54
+ // A capability src/ does not have, it must not be able to BORROW either.
55
+ // `login` writes a key to disk and `deep` uploads a digest - both real, both
56
+ // necessary, both living in net/. If src/ could import them, this script would
57
+ // print "read-only verified" while the scanner was able to upload the user's
58
+ // repository. A promise that passes its own test and is false is worse than no
59
+ // promise. So: src/ may import only from src/.
60
+ for (const f of files) {
61
+ const text = readFileSync(f, 'utf8');
62
+ text.split('\n').forEach((raw, i) => {
63
+ if (raw.trimStart().startsWith('//')) return;
64
+ const m = raw.match(/^\s*(?:import|export)[^'"]*from\s+['"]([^'"]+)['"]/)
65
+ || raw.match(/\bimport\s*\(\s*['"]([^'"]+)['"]/);
66
+ if (!m) return;
67
+ const spec = m[1];
68
+ if (!spec.startsWith('.')) return; // node: builtins are covered above
69
+ if (/^\.\.\/(?!src\/)/.test(spec) || spec.startsWith('../../')) {
70
+ console.error(` ${f.replace(SRC, 'src/')}:${i + 1} reaches outside src/\n ${raw.trim()}`);
71
+ bad++;
72
+ }
73
+ });
74
+ }
75
+
76
+ if (bad) {
77
+ console.error(`\nFAIL — ${bad} capability violation(s). The Launchprep scanner must stay read-only.`);
78
+ process.exit(1);
79
+ }
80
+ console.log(`read-only verified across ${files.length} source file(s) — no writes, no exec, no network, and nothing imported from outside src/`);
package/src/brand.mjs ADDED
@@ -0,0 +1,15 @@
1
+ // The product name lives here and nowhere else.
2
+ //
3
+ // Renaming is a one-line change until the day this is published. After that,
4
+ // the npm package name and the plugin id are claimed by users who installed
5
+ // them, and a rename breaks their setup - so decide the real name before the
6
+ // first public release, not after.
7
+ export const BRAND = {
8
+ name: 'Launchprep', // shown to humans
9
+ slug: 'launchprep', // npm package, plugin id, slash command
10
+ tagline: 'readiness scan',
11
+ email: 'hello@launchprep.dev', // the address that actually receives
12
+ api: 'https://api.launchprep.dev',
13
+ };
14
+
15
+ export const CMD = '/' + BRAND.slug;