@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,146 @@
1
+ /**
2
+ * Bundle analysis — pure, filesystem-free logic for `everystack bundle:analyze`.
3
+ *
4
+ * Measures the served client JS (raw / gzip / brotli — brotli is the real
5
+ * CloudFront-transfer number) and, from the largest chunk's source map, ranks
6
+ * the source-byte contributors per npm package / app workspace.
7
+ *
8
+ * Config-free by construction: the bundle layout is an Expo convention (located
9
+ * by the command), and the workspace bucketing is derived from the source paths
10
+ * themselves — nothing is hand-declared.
11
+ */
12
+
13
+ import zlib from 'node:zlib';
14
+
15
+ export interface ChunkInput {
16
+ name: string;
17
+ buf: Buffer;
18
+ }
19
+
20
+ export interface ChunkSize {
21
+ name: string;
22
+ raw: number;
23
+ gz: number;
24
+ br: number;
25
+ }
26
+
27
+ export interface SizeTotals {
28
+ raw: number;
29
+ gz: number;
30
+ br: number;
31
+ }
32
+
33
+ export interface SourceMap {
34
+ sources?: (string | null)[];
35
+ sourcesContent?: (string | null)[];
36
+ }
37
+
38
+ export interface PkgComposition {
39
+ pkg: string;
40
+ bytes: number;
41
+ share: number; // 0..1
42
+ }
43
+
44
+ export interface Composition {
45
+ total: number;
46
+ modules: number;
47
+ top: PkgComposition[];
48
+ }
49
+
50
+ /** Raw / gzip(9) / brotli byte sizes of a single chunk buffer. Deterministic. */
51
+ export function measureChunk(buf: Buffer): SizeTotals {
52
+ return {
53
+ raw: buf.length,
54
+ gz: zlib.gzipSync(buf, { level: 9 }).length,
55
+ br: zlib.brotliCompressSync(buf).length,
56
+ };
57
+ }
58
+
59
+ /** Measure every chunk, sorted largest-raw first. */
60
+ export function measureChunks(chunks: ChunkInput[]): ChunkSize[] {
61
+ return chunks
62
+ .map((c) => ({ name: c.name, ...measureChunk(c.buf) }))
63
+ .sort((a, b) => b.raw - a.raw);
64
+ }
65
+
66
+ export function totalSizes(chunks: ChunkSize[]): SizeTotals {
67
+ return chunks.reduce<SizeTotals>(
68
+ (acc, c) => ({ raw: acc.raw + c.raw, gz: acc.gz + c.gz, br: acc.br + c.br }),
69
+ { raw: 0, gz: 0, br: 0 },
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Map a source-map `sources` path to its owning bucket. Generic — no hardcoded
75
+ * app or package names. node_modules → the package (scope-aware); a monorepo
76
+ * `/packages/<name>/` or `/apps/<name>/` segment → that workspace; else `other`.
77
+ */
78
+ export function bucketSource(src: string): string {
79
+ const nm = src.lastIndexOf('node_modules/');
80
+ if (nm >= 0) {
81
+ const parts = src.slice(nm + 'node_modules/'.length).split('/');
82
+ return parts[0].startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0];
83
+ }
84
+ const pkg = src.match(/\/packages\/([^/]+)/);
85
+ if (pkg) return `APP:packages/${pkg[1]}`;
86
+ const app = src.match(/\/apps\/([^/]+)/);
87
+ if (app) return `APP:apps/${app[1]}`;
88
+ return 'APP:other';
89
+ }
90
+
91
+ /** Bucket the largest chunk's source bytes by owning package, top N by size. */
92
+ export function composeSourceMap(map: SourceMap, topN = 30): Composition {
93
+ const sources = map.sources || [];
94
+ const content = map.sourcesContent || [];
95
+ const byPkg: Record<string, number> = {};
96
+ let total = 0;
97
+ for (let i = 0; i < sources.length; i++) {
98
+ const src = sources[i] || '';
99
+ const len = (content[i] || '').length;
100
+ total += len;
101
+ const key = bucketSource(src);
102
+ byPkg[key] = (byPkg[key] || 0) + len;
103
+ }
104
+ const top = Object.entries(byPkg)
105
+ .map(([pkg, bytes]) => ({ pkg, bytes, share: total ? bytes / total : 0 }))
106
+ .sort((a, b) => b.bytes - a.bytes)
107
+ .slice(0, topN);
108
+ return { total, modules: sources.length, top };
109
+ }
110
+
111
+ const mb = (n: number): string => (n / 1048576).toFixed(2);
112
+ const kb = (n: number): string => (n / 1024).toFixed(0);
113
+
114
+ /** Render the size + composition report as lines (testable, IO-free). */
115
+ export function formatBundleReport(chunks: ChunkSize[], composition: Composition | null): string[] {
116
+ const totals = totalSizes(chunks);
117
+ const lines: string[] = [];
118
+ lines.push(`CLIENT JS: ${chunks.length} chunk(s)`);
119
+ lines.push(` raw ${mb(totals.raw)} MB`);
120
+ lines.push(` gzip ${mb(totals.gz)} MB`);
121
+ lines.push(` brotli ${mb(totals.br)} MB (CloudFront transfer)`);
122
+ lines.push('');
123
+ for (const c of chunks) {
124
+ lines.push(` ${kb(c.raw).padStart(7)} KB raw ${kb(c.gz).padStart(6)} KB gz ${c.name}`);
125
+ }
126
+
127
+ if (!composition) {
128
+ lines.push('');
129
+ lines.push('(no source map for composition — re-export with --source-maps)');
130
+ return lines;
131
+ }
132
+
133
+ lines.push('');
134
+ lines.push(
135
+ `largest chunk: ${mb(composition.total)} MB source / ${composition.modules} modules`,
136
+ );
137
+ lines.push(' rank src-KB share package');
138
+ composition.top.forEach((p, i) => {
139
+ lines.push(
140
+ ` ${String(i + 1).padStart(3)} ${kb(p.bytes).padStart(7)} ${(p.share * 100)
141
+ .toFixed(1)
142
+ .padStart(5)}% ${p.pkg}`,
143
+ );
144
+ });
145
+ return lines;
146
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `everystack audit --stage <name>` — the capstone. Runs the live deploy-checks
3
+ * against one stage and emits a combined report + a single exit code (non-zero
4
+ * if anything failed), so it works as a one-shot CI gate.
5
+ *
6
+ * Composes the shared result-producing cores (runBundleAudit, runSecurityProbe)
7
+ * so there's no orchestration duplication. The white-box `security:audit` and
8
+ * the heavier `audit:lighthouse` stay separate commands (run them explicitly).
9
+ */
10
+
11
+ import { resolveConfig, opsFunction } from '../config.js';
12
+ import { runBundleAudit, runBundleWeightAudit, reportBundleAudit } from './bundle.js';
13
+ import { resolveWeightBudget } from '../bundle-weight.js';
14
+ import { runSecurityProbe, reportProbeResults } from './security-probe.js';
15
+ import { auditDeployedSql, printReport, loadWaivers } from './security.js';
16
+ import { collectLighthouseRuns } from './lighthouse.js';
17
+ import { aggregate, formatLighthouseReport } from '../lighthouse.js';
18
+ import { step, info, fail, success, warn } from '../output.js';
19
+
20
+ export async function auditCommand(flags: Record<string, string>): Promise<void> {
21
+ step('Resolving deployed config...');
22
+ let config;
23
+ try {
24
+ config = await resolveConfig(flags.stage);
25
+ } catch (err: any) {
26
+ fail(err.message);
27
+ process.exit(1);
28
+ }
29
+ const base = (flags.host || config.baseUrl || '').replace(/\/+$/, '');
30
+ if (!base) {
31
+ fail('No router URL to audit. Pass --host or ensure the stage has a deployed URL.');
32
+ process.exit(1);
33
+ }
34
+ info(`Region: ${config.region}, URL: ${base}`);
35
+
36
+ let failures = 0;
37
+ const ran: string[] = [];
38
+
39
+ // --- security:audit (white-box: SECDEF / exposing-view SQL surface) -------
40
+ console.log('');
41
+ info('── security:audit ───────────────────────────────────────────');
42
+ try {
43
+ const waivers = await loadWaivers(flags.config);
44
+ const report = await auditDeployedSql(config.region, opsFunction(config), waivers);
45
+ console.log('');
46
+ printReport(report, 'catalog');
47
+ failures += report.red;
48
+ ran.push('security:audit');
49
+ } catch (err: any) {
50
+ warn(`security:audit skipped: ${err.message}`);
51
+ }
52
+
53
+ // --- security:probe (live RLS/grants introspection + surface probing) ----
54
+ console.log('');
55
+ info('── security:probe ───────────────────────────────────────────');
56
+ try {
57
+ const results = await runSecurityProbe({
58
+ region: config.region,
59
+ opsFn: opsFunction(config),
60
+ base,
61
+ });
62
+ console.log('');
63
+ failures += reportProbeResults(results);
64
+ ran.push('security:probe');
65
+ } catch (err: any) {
66
+ warn(`security:probe skipped: ${err.message}`);
67
+ }
68
+
69
+ // --- bundle:audit (leak + weight) ----------------------------------------
70
+ // --dir scans a local export for leaks AND weight (the 180MB gate); without
71
+ // it, the deployed bundle is scanned for leaks only (weight needs the export).
72
+ console.log('');
73
+ info('── bundle:audit ─────────────────────────────────────────────');
74
+ try {
75
+ const result = flags.dir
76
+ ? await runBundleWeightAudit(flags.dir, {
77
+ budget: resolveWeightBudget(flags),
78
+ valuesFrom: flags['values-from'],
79
+ })
80
+ : await runBundleAudit(base, {
81
+ valuesFrom: flags['values-from'],
82
+ weight: !flags['leak-only'],
83
+ budget: resolveWeightBudget(flags),
84
+ });
85
+ console.log('');
86
+ failures += reportBundleAudit(result);
87
+ ran.push('bundle:audit (leak + weight)');
88
+ } catch (err: any) {
89
+ warn(`bundle:audit skipped: ${err.message}`);
90
+ }
91
+
92
+ // --- audit:lighthouse (perf/SEO) — opt-in; heavy and informational -------
93
+ if (flags.lighthouse) {
94
+ console.log('');
95
+ info('── audit:lighthouse ─────────────────────────────────────────');
96
+ try {
97
+ const runs = flags.runs ? parseInt(flags.runs, 10) : 3;
98
+ const extracts = await collectLighthouseRuns(base + '/', runs);
99
+ if (extracts.length) {
100
+ console.log('');
101
+ console.log(formatLighthouseReport(base + '/', extracts.length, aggregate(extracts)).join('\n'));
102
+ ran.push('audit:lighthouse (informational)');
103
+ }
104
+ } catch (err: any) {
105
+ warn(`audit:lighthouse skipped: ${err.message}`);
106
+ }
107
+ }
108
+
109
+ console.log('');
110
+ const summary = `audit — ${failures} failing check(s) across ${ran.join(' + ') || 'no checks'}`;
111
+ if (failures > 0) {
112
+ fail(summary);
113
+ process.exit(1);
114
+ }
115
+ success(summary);
116
+ if (!flags.lighthouse) info('Add --lighthouse for perf/SEO (slower; informational, does not gate).');
117
+ process.exit(0);
118
+ }