@everystack/cli 0.3.3 → 0.3.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.
@@ -0,0 +1,452 @@
1
+ /**
2
+ * `everystack bundle:analyze [dir]` — client web-bundle size + composition.
3
+ *
4
+ * Run after a web export with source maps:
5
+ * npx expo export -p web --source-maps --output-dir dist
6
+ * everystack bundle:analyze dist
7
+ *
8
+ * Config-free: the JS bundle is located by Expo convention, and composition is
9
+ * derived from the source map itself.
10
+ */
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import {
15
+ measureChunks,
16
+ composeSourceMap,
17
+ formatBundleReport,
18
+ type ChunkInput,
19
+ type Composition,
20
+ } from '../bundle.js';
21
+ import {
22
+ scanSecretShapes,
23
+ extractBundleUrls,
24
+ extractAssetUrls,
25
+ extractExtraEnvKeys,
26
+ classifyPrivateEnvKeys,
27
+ scanSecretValue,
28
+ summarize,
29
+ type AuditFinding,
30
+ } from '../bundle-audit.js';
31
+ import {
32
+ scanAssetWeights,
33
+ scanTotalWeight,
34
+ scanJsWeight,
35
+ scanSourceMapsPresent,
36
+ scanDependencyWeight,
37
+ analyzeChunkBloat,
38
+ largestFiles,
39
+ formatBytes,
40
+ resolveWeightBudget,
41
+ classifyAsset,
42
+ DEFAULT_WEIGHT_BUDGET,
43
+ type AssetFile,
44
+ type WeightBudget,
45
+ } from '../bundle-weight.js';
46
+ import { resolveConfig } from '../config.js';
47
+ import { fail, info, step, success, warn } from '../output.js';
48
+
49
+ // Expo Router web export puts client chunks under client/_expo/...; a plain
50
+ // `expo export` omits the client/ prefix. Convention, not config.
51
+ const JS_DIR_CANDIDATES = [
52
+ 'client/_expo/static/js/web',
53
+ '_expo/static/js/web',
54
+ ];
55
+
56
+ function resolveJsDir(dir: string): string | null {
57
+ for (const candidate of JS_DIR_CANDIDATES) {
58
+ const abs = path.join(dir, candidate);
59
+ try {
60
+ if (fs.statSync(abs).isDirectory()) return abs;
61
+ } catch {
62
+ // try next candidate
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+
68
+ export async function bundleAnalyzeCommand(
69
+ positional: string | undefined,
70
+ flags: Record<string, string>,
71
+ ): Promise<void> {
72
+ const dir = positional || flags.dir || 'dist';
73
+ const jsDir = resolveJsDir(dir);
74
+ if (!jsDir) {
75
+ fail(
76
+ `No client JS bundle found under ${dir} (looked for ${JS_DIR_CANDIDATES.join(', ')}).`,
77
+ );
78
+ info('Run `npx expo export -p web --source-maps --output-dir dist` first.');
79
+ process.exit(1);
80
+ }
81
+
82
+ step(`Analyzing client bundle in ${jsDir} ...`);
83
+ const files = fs.readdirSync(jsDir).filter((f) => f.endsWith('.js'));
84
+ if (files.length === 0) {
85
+ fail(`No .js chunks in ${jsDir}.`);
86
+ process.exit(1);
87
+ }
88
+
89
+ const chunks: ChunkInput[] = files.map((name) => ({
90
+ name,
91
+ buf: fs.readFileSync(path.join(jsDir, name)),
92
+ }));
93
+ const sized = measureChunks(chunks);
94
+
95
+ // Composition comes from the largest chunk's source map, when present.
96
+ let composition: Composition | null = null;
97
+ const mapPath = path.join(jsDir, sized[0].name + '.map');
98
+ if (fs.existsSync(mapPath)) {
99
+ try {
100
+ composition = composeSourceMap(JSON.parse(fs.readFileSync(mapPath, 'utf8')));
101
+ } catch (err: any) {
102
+ info(`(could not parse source map: ${err.message})`);
103
+ }
104
+ }
105
+
106
+ console.log('');
107
+ console.log(formatBundleReport(sized, composition).join('\n'));
108
+ process.exit(0);
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // bundle:audit — served-bundle secret-leak scan.
113
+ // ---------------------------------------------------------------------------
114
+
115
+ async function fetchText(
116
+ url: string,
117
+ ): Promise<{ ok: boolean; status: number; text: string; bytes: number }> {
118
+ const res = await fetch(url, { redirect: 'follow' });
119
+ const text = res.ok ? await res.text() : '';
120
+ const cl = Number(res.headers.get('content-length'));
121
+ const bytes = Number.isFinite(cl) && cl > 0 ? cl : Buffer.byteLength(text, 'utf8');
122
+ return { ok: res.ok, status: res.status, text, bytes };
123
+ }
124
+
125
+ /**
126
+ * Size of a remote asset via HEAD (Content-Length) — measures a large file
127
+ * (e.g. a 180MB JSON) without downloading it. Falls back to a ranged GET when
128
+ * the origin omits Content-Length on HEAD; null if neither works.
129
+ */
130
+ async function headSize(url: string): Promise<number | null> {
131
+ try {
132
+ const head = await fetch(url, { method: 'HEAD', redirect: 'follow' });
133
+ const cl = Number(head.headers.get('content-length'));
134
+ if (head.ok && Number.isFinite(cl) && cl > 0) return cl;
135
+ } catch {
136
+ // fall through to ranged GET
137
+ }
138
+ try {
139
+ const res = await fetch(url, { headers: { Range: 'bytes=0-0' }, redirect: 'follow' });
140
+ const range = res.headers.get('content-range'); // e.g. "bytes 0-0/184000000"
141
+ const total = range && range.includes('/') ? Number(range.split('/')[1]) : NaN;
142
+ if (Number.isFinite(total) && total > 0) return total;
143
+ const cl = Number(res.headers.get('content-length'));
144
+ if (Number.isFinite(cl) && cl > 0) return cl;
145
+ } catch {
146
+ // give up — best-effort
147
+ }
148
+ return null;
149
+ }
150
+
151
+ /** Parse KEY=VALUE dotenv lines (operator-produced via `everystack secrets export`). */
152
+ function parseDotenv(raw: string): { key: string; value: string }[] {
153
+ const out: { key: string; value: string }[] = [];
154
+ for (const line of raw.split('\n')) {
155
+ const m = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);
156
+ if (!m) continue;
157
+ let value = m[2].trim();
158
+ if (
159
+ (value.startsWith('"') && value.endsWith('"')) ||
160
+ (value.startsWith("'") && value.endsWith("'"))
161
+ ) {
162
+ value = value.slice(1, -1);
163
+ }
164
+ out.push({ key: m[1], value });
165
+ }
166
+ return out;
167
+ }
168
+
169
+ export interface BundleAuditResult {
170
+ findings: AuditFinding[];
171
+ chunkCount: number;
172
+ /** Biggest served/exported files, largest first — always shown, gate or not. */
173
+ largest?: AssetFile[];
174
+ }
175
+
176
+ /** Result-producing core, shared by the command wrapper and the `audit` capstone. */
177
+ export async function runBundleAudit(
178
+ base: string,
179
+ opts: { valuesFrom?: string; weight?: boolean; budget?: WeightBudget } = {},
180
+ ): Promise<BundleAuditResult> {
181
+ step(`Fetching ${base} ...`);
182
+ const root = await fetchText(base);
183
+ if (!root.ok) {
184
+ throw new Error(`${base} returned HTTP ${root.status}.`);
185
+ }
186
+
187
+ // Discover client chunks from the HTML, then follow lazy chunks referenced
188
+ // inside the downloaded JS (Expo code-splitting). Convention-located.
189
+ const seen = new Set<string>();
190
+ const queue = extractBundleUrls(root.text);
191
+ const artifacts: { source: string; text: string }[] = [{ source: 'index.html', text: root.text }];
192
+ // Served file → byte size, for the weight scan (HTML + JS measured from the GET).
193
+ const sizes = new Map<string, number>([['index.html', root.bytes]]);
194
+ while (queue.length) {
195
+ const rel = queue.shift()!;
196
+ if (seen.has(rel)) continue;
197
+ seen.add(rel);
198
+ const chunk = await fetchText(`${base}/${rel}`);
199
+ if (!chunk.ok) {
200
+ warn(`could not fetch ${rel} (HTTP ${chunk.status})`);
201
+ continue;
202
+ }
203
+ artifacts.push({ source: rel, text: chunk.text });
204
+ sizes.set(rel, chunk.bytes);
205
+ for (const next of extractBundleUrls(chunk.text)) {
206
+ if (!seen.has(next)) queue.push(next);
207
+ }
208
+ }
209
+ step(`Scanning ${artifacts.length} artifact(s) (1 HTML + ${seen.size} JS chunk(s)) ...`);
210
+
211
+ const findings: AuditFinding[] = [];
212
+
213
+ // 1. Source maps must not be publicly served.
214
+ const firstChunk = [...seen][0];
215
+ if (firstChunk) {
216
+ const map = await fetchText(`${base}/${firstChunk}.map`);
217
+ if (map.ok) {
218
+ findings.push({
219
+ severity: 'fail',
220
+ check: 'source-map',
221
+ source: `${firstChunk}.map`,
222
+ detail: 'source map is publicly served — exposes original source + comments',
223
+ });
224
+ }
225
+ }
226
+
227
+ // 2. Generic secret shapes + 3. private (non-EXPO_PUBLIC) env keys in extra.
228
+ for (const a of artifacts) {
229
+ findings.push(...scanSecretShapes(a.text, a.source));
230
+ findings.push(...classifyPrivateEnvKeys(extractExtraEnvKeys(a.text), a.source));
231
+ }
232
+
233
+ // 4. Operator-run value canary (reads secret values; never run by Claude).
234
+ if (opts.valuesFrom) {
235
+ step(`Value canary against secrets in ${opts.valuesFrom} ...`);
236
+ const secrets = parseDotenv(fs.readFileSync(path.resolve(opts.valuesFrom), 'utf8'));
237
+ for (const { key, value } of secrets) {
238
+ for (const a of artifacts) {
239
+ const hit = scanSecretValue(a.text, key, value, a.source);
240
+ if (hit) findings.push(hit);
241
+ }
242
+ }
243
+ }
244
+
245
+ // 5. Weight/size gate over the *served* bundle. Static assets (images, data
246
+ // files) are discovered from the crawled text and sized via HEAD — a 180MB
247
+ // data file is gated without downloading it.
248
+ let largest: AssetFile[] | undefined;
249
+ if (opts.weight !== false) {
250
+ const budget = { ...DEFAULT_WEIGHT_BUDGET, ...(opts.budget || {}) };
251
+ const assetRefs = new Set<string>();
252
+ for (const a of artifacts) for (const u of extractAssetUrls(a.text)) assetRefs.add(u);
253
+ step(`Weighing ${seen.size} JS chunk(s) + ${assetRefs.size} static asset(s) ...`);
254
+ for (const rel of assetRefs) {
255
+ const size = await headSize(`${base}/${rel}`);
256
+ if (size != null) sizes.set(rel, size);
257
+ }
258
+ const files: AssetFile[] = [...sizes].map(([p, size]) => ({ path: p, size }));
259
+ findings.push(...scanAssetWeights(files, budget));
260
+ findings.push(...scanTotalWeight(files, budget));
261
+ findings.push(...scanJsWeight(files, budget));
262
+ // Name the data inlined into JS chunks (the most useful diagnosis).
263
+ for (const a of artifacts) {
264
+ if (classifyAsset(a.source) === 'js') {
265
+ findings.push(...analyzeChunkBloat(a.text, a.source, budget));
266
+ }
267
+ }
268
+ largest = largestFiles(files);
269
+ }
270
+
271
+ return { findings, chunkCount: seen.size, largest };
272
+ }
273
+
274
+ /** Print bundle-audit findings; returns the number of failures (fail severity). */
275
+ export function reportBundleAudit(result: BundleAuditResult): number {
276
+ const { findings, chunkCount, largest } = result;
277
+ const { fail: fails, warn: warns } = summarize(findings);
278
+
279
+ // Always list the largest files — useful even when nothing trips a budget.
280
+ if (largest && largest.length) {
281
+ info(`Largest files (top ${largest.length}):`);
282
+ for (const f of largest) info(` ${formatBytes(f.size).padStart(10)} ${f.path}`);
283
+ console.log('');
284
+ }
285
+
286
+ if (findings.length === 0) {
287
+ success(`bundle:audit — clean. No leaks or weight issues across ${chunkCount} artifact(s).`);
288
+ return 0;
289
+ }
290
+ for (const f of findings) {
291
+ if (f.severity === 'fail') fail(`[${f.check}] ${f.source}: ${f.detail}`);
292
+ else warn(`[${f.check}] ${f.source}: ${f.detail}`);
293
+ }
294
+ console.log('');
295
+ const summary = `bundle:audit — ${fails} failure(s), ${warns} warning(s)`;
296
+ if (fails > 0) fail(summary);
297
+ else warn(summary);
298
+ return fails;
299
+ }
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // Filesystem path — scan a local Expo export (leak + weight together).
303
+ // ---------------------------------------------------------------------------
304
+
305
+ /** Recursively list every file under `root` as {path (relative), size}. */
306
+ export function walkExport(root: string): AssetFile[] {
307
+ const out: AssetFile[] = [];
308
+ const walk = (abs: string): void => {
309
+ for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
310
+ const childAbs = path.join(abs, entry.name);
311
+ if (entry.isDirectory()) {
312
+ walk(childAbs);
313
+ } else if (entry.isFile()) {
314
+ out.push({
315
+ path: path.relative(root, childAbs),
316
+ size: fs.statSync(childAbs).size,
317
+ });
318
+ }
319
+ }
320
+ };
321
+ walk(root);
322
+ return out;
323
+ }
324
+
325
+ /**
326
+ * Audit a local Expo export directory: weight/size gates over every file, plus
327
+ * the leak scanners over the on-disk HTML + JS text (no deploy/network needed).
328
+ * This is the gate the governance layer runs before publish.
329
+ */
330
+ export async function runBundleWeightAudit(
331
+ dir: string,
332
+ opts: { budget?: WeightBudget; valuesFrom?: string } = {},
333
+ ): Promise<BundleAuditResult> {
334
+ const root = path.resolve(dir);
335
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
336
+ throw new Error(`${dir} is not a directory — pass an Expo export output dir.`);
337
+ }
338
+ const budget = { ...DEFAULT_WEIGHT_BUDGET, ...(opts.budget || {}) };
339
+
340
+ const files = walkExport(root);
341
+ if (files.length === 0) throw new Error(`${dir} is empty — run \`npx expo export\` first.`);
342
+ step(`Scanning ${files.length} file(s) in ${dir} ...`);
343
+
344
+ const findings: AuditFinding[] = [];
345
+
346
+ // --- weight/size gates ---------------------------------------------------
347
+ findings.push(...scanAssetWeights(files, budget));
348
+ findings.push(...scanTotalWeight(files, budget));
349
+ findings.push(...scanJsWeight(files, budget));
350
+ findings.push(...scanSourceMapsPresent(files));
351
+
352
+ // --- leak gates over on-disk text (HTML + JS) ----------------------------
353
+ const textFiles = files.filter(
354
+ (f) => f.path.endsWith('.html') || classifyAsset(f.path) === 'js',
355
+ );
356
+ for (const f of textFiles) {
357
+ const text = fs.readFileSync(path.join(root, f.path), 'utf8');
358
+ findings.push(...scanSecretShapes(text, f.path));
359
+ findings.push(...classifyPrivateEnvKeys(extractExtraEnvKeys(text), f.path));
360
+ if (classifyAsset(f.path) === 'js') {
361
+ findings.push(...analyzeChunkBloat(text, f.path, budget));
362
+ }
363
+ if (opts.valuesFrom) {
364
+ const secrets = parseDotenv(fs.readFileSync(path.resolve(opts.valuesFrom), 'utf8'));
365
+ for (const { key, value } of secrets) {
366
+ const hit = scanSecretValue(text, key, value, f.path);
367
+ if (hit) findings.push(hit);
368
+ }
369
+ }
370
+ }
371
+
372
+ // --- dependency weight (best-effort, from the largest chunk's source map) -
373
+ const jsDir = resolveJsDir(root);
374
+ if (jsDir) {
375
+ const jsFiles = fs.readdirSync(jsDir).filter((f) => f.endsWith('.js'));
376
+ if (jsFiles.length) {
377
+ const sized = measureChunks(
378
+ jsFiles.map((name) => ({ name, buf: fs.readFileSync(path.join(jsDir, name)) })),
379
+ );
380
+ const mapPath = path.join(jsDir, sized[0].name + '.map');
381
+ if (fs.existsSync(mapPath)) {
382
+ try {
383
+ const composition = composeSourceMap(JSON.parse(fs.readFileSync(mapPath, 'utf8')));
384
+ findings.push(...scanDependencyWeight(composition, budget));
385
+ } catch {
386
+ // unparseable map — dep-weight is best-effort, skip silently
387
+ }
388
+ }
389
+ }
390
+ }
391
+
392
+ return { findings, chunkCount: files.length, largest: largestFiles(files) };
393
+ }
394
+
395
+ export async function bundleAuditCommand(
396
+ positional: string | undefined,
397
+ flags: Record<string, string>,
398
+ ): Promise<void> {
399
+ // Local-export mode: --dir <export> (or a positional that is a directory) runs
400
+ // leak + weight gates over the export on disk — no deploy needed.
401
+ const localDir =
402
+ flags.dir ||
403
+ (positional && fs.existsSync(positional) && fs.statSync(positional).isDirectory()
404
+ ? positional
405
+ : undefined);
406
+ if (localDir) {
407
+ let result: BundleAuditResult;
408
+ try {
409
+ result = await runBundleWeightAudit(localDir, {
410
+ budget: resolveWeightBudget(flags),
411
+ valuesFrom: flags['values-from'],
412
+ });
413
+ } catch (err: any) {
414
+ fail(err.message);
415
+ process.exit(1);
416
+ }
417
+ console.log('');
418
+ process.exit(reportBundleAudit(result) > 0 ? 1 : 0);
419
+ }
420
+
421
+ let target = positional || flags.url;
422
+ if (!target) {
423
+ try {
424
+ target = (await resolveConfig(flags.stage)).baseUrl;
425
+ } catch (err: any) {
426
+ fail(err.message);
427
+ info('Pass a URL positionally or use --stage <name>.');
428
+ process.exit(1);
429
+ }
430
+ }
431
+ const base = target.replace(/\/+$/, '');
432
+
433
+ if (!flags['values-from'] && flags.values) {
434
+ info('Value canary: run `everystack secrets export --stage <name> > .env` then re-run with --values-from .env');
435
+ info('(everystack never reads secret values itself; the export is your explicit step.)');
436
+ }
437
+
438
+ let result: BundleAuditResult;
439
+ try {
440
+ result = await runBundleAudit(base, {
441
+ valuesFrom: flags['values-from'],
442
+ weight: !flags['leak-only'],
443
+ budget: resolveWeightBudget(flags),
444
+ });
445
+ } catch (err: any) {
446
+ fail(err.message);
447
+ process.exit(1);
448
+ }
449
+
450
+ console.log('');
451
+ process.exit(reportBundleAudit(result) > 0 ? 1 : 0);
452
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * `everystack audit:lighthouse [--stage <name>] [--host <url>] [--runs N] [--path /]`
3
+ *
4
+ * Mobile Lighthouse audit (Perf / A11y / Best-Practices / SEO) reported as a
5
+ * distribution over N runs, because the Performance score swings run-to-run with
6
+ * LCP. Warms the CloudFront edge first, then measures the warm-HTML path.
7
+ *
8
+ * Config-free: the target URL is the deployed router URL from SST outputs
9
+ * (resolveConfig), or --host. Requires `npx lighthouse` and a headless Chrome.
10
+ */
11
+
12
+ import { execFileSync } from 'node:child_process';
13
+ import { readFileSync, mkdtempSync, writeFileSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import {
17
+ CATEGORIES,
18
+ extractRun,
19
+ aggregate,
20
+ formatLighthouseReport,
21
+ type ExtractedRun,
22
+ type LhReport,
23
+ } from '../lighthouse.js';
24
+ import { resolveConfig } from '../config.js';
25
+ import { step, info, fail, warn } from '../output.js';
26
+
27
+ function runLighthouse(url: string, outPath: string): LhReport {
28
+ // Warm the edge so we measure the real-user (cached-HTML) experience.
29
+ try {
30
+ execFileSync('curl', ['-s', '-o', '/dev/null', url], { timeout: 30_000 });
31
+ } catch {
32
+ // warming is best-effort
33
+ }
34
+ execFileSync(
35
+ 'npx',
36
+ [
37
+ 'lighthouse',
38
+ url,
39
+ `--only-categories=${CATEGORIES.join(',')}`,
40
+ '--chrome-flags=--headless=new --no-sandbox',
41
+ '--output=json',
42
+ `--output-path=${outPath}`,
43
+ '--quiet',
44
+ ],
45
+ { stdio: ['ignore', 'ignore', 'ignore'], timeout: 120_000 },
46
+ );
47
+ return JSON.parse(readFileSync(outPath, 'utf8'));
48
+ }
49
+
50
+ /** Run Lighthouse N times and return the per-run extracts. Shared with the
51
+ * `audit` capstone. */
52
+ export async function collectLighthouseRuns(url: string, runs: number): Promise<ExtractedRun[]> {
53
+ const tmp = mkdtempSync(join(tmpdir(), 'everystack-lh-'));
54
+ const extracts: ExtractedRun[] = [];
55
+ for (let i = 0; i < runs; i++) {
56
+ step(`Lighthouse run ${i + 1}/${runs} on ${url} ...`);
57
+ try {
58
+ const report = runLighthouse(url, join(tmp, `lh-${i}.json`));
59
+ extracts.push(extractRun(report));
60
+ } catch (err: any) {
61
+ warn(`run ${i + 1} failed: ${err.message?.split('\n')[0] ?? err}`);
62
+ }
63
+ }
64
+ return extracts;
65
+ }
66
+
67
+ export async function auditLighthouseCommand(flags: Record<string, string>): Promise<void> {
68
+ let host = flags.host;
69
+ if (!host) {
70
+ try {
71
+ const config = await resolveConfig(flags.stage);
72
+ host = config.baseUrl;
73
+ } catch (err: any) {
74
+ fail(err.message);
75
+ info('Pass --host <url> to audit a specific URL without resolving a stage.');
76
+ process.exit(1);
77
+ }
78
+ }
79
+ if (!host) {
80
+ fail('No URL to audit. Pass --host <url> or --stage <name>.');
81
+ process.exit(1);
82
+ }
83
+
84
+ const runs = flags.runs ? parseInt(flags.runs, 10) : 3;
85
+ const pathName = flags.path || '/';
86
+ const url = host.replace(/\/+$/, '') + (pathName.startsWith('/') ? pathName : '/' + pathName);
87
+
88
+ const extracts = await collectLighthouseRuns(url, runs);
89
+
90
+ if (extracts.length === 0) {
91
+ fail('All Lighthouse runs failed. Is `npx lighthouse` installed and Chrome available?');
92
+ process.exit(1);
93
+ }
94
+
95
+ const agg = aggregate(extracts);
96
+ console.log('');
97
+ console.log(formatLighthouseReport(url, extracts.length, agg).join('\n'));
98
+
99
+ if (flags.json) {
100
+ writeFileSync(flags.json, JSON.stringify(agg, null, 2));
101
+ info(`Raw results → ${flags.json}`);
102
+ }
103
+ process.exit(0);
104
+ }