@everystack/cli 0.3.0 → 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
+ }
@@ -243,14 +243,20 @@ export async function dbAuthzOwnerCommand(flags: Record<string, string>): Promis
243
243
  const schema = flags.schema || 'public';
244
244
 
245
245
  let probes: OwnerProbe[];
246
+ let publicReadTables = new Set<string>();
246
247
  let rows: any[];
247
248
  try {
248
249
  step(`Loading models from ${modelsPath}...`);
249
250
  const models = await loadModels(modelsPath);
250
- probes = models
251
- .map((m) => modelOwner(m, { schema }))
252
- .filter((o): o is NonNullable<typeof o> => o !== null)
253
- .map((o) => ({ table: o.table, ownerColumn: o.ownerColumn, claim: o.claim }));
251
+ const ownerModels = models
252
+ .map((m) => ({ m, owner: modelOwner(m, { schema }) }))
253
+ .filter((x): x is { m: (typeof models)[number]; owner: NonNullable<typeof x.owner> } => x.owner !== null);
254
+ // A table with a public read (`can('read')`, no owner/role/via) is intentionally not
255
+ // reader-isolated — flag it so the probe reports its cross-owner reads as expected, not IDOR.
256
+ const isPublicRead = (m: (typeof models)[number]): boolean =>
257
+ m.abilities.some((a) => a.action === 'read' && !a.condition.role && !a.condition.owner && !a.condition.via);
258
+ publicReadTables = new Set(ownerModels.filter(({ m }) => isPublicRead(m)).map(({ owner }) => owner.table));
259
+ probes = ownerModels.map(({ owner }) => ({ table: owner.table, ownerColumn: owner.ownerColumn, claim: owner.claim }));
254
260
  info(`${probes.length} owner-scoped model(s).`);
255
261
  if (probes.length === 0) {
256
262
  success('db:authz:owner — no owner-scoped models (no `can({ owner })`); nothing to probe.');
@@ -272,14 +278,16 @@ export async function dbAuthzOwnerCommand(flags: Record<string, string>): Promis
272
278
  process.exit(1);
273
279
  }
274
280
 
275
- const findings = evaluateOwnerProbe(rows.map(toOwnerProbeResult));
281
+ const findings = evaluateOwnerProbe(rows.map(toOwnerProbeResult), publicReadTables);
276
282
  const leaks = findings.filter((f) => f.severity === 'read-leak' || f.severity === 'write-leak');
277
283
  const vacuous = findings.filter((f) => f.severity === 'vacuous');
278
284
  const unprobed = findings.filter((f) => f.severity === 'unprobed');
285
+ const publicReads = findings.filter((f) => f.severity === 'public-read');
279
286
 
280
287
  console.log('');
281
288
  for (const f of leaks) fail(`[LEAK] ${f.detail}`);
282
289
  for (const f of vacuous) fail(`[VACUOUS] ${f.detail}`);
290
+ for (const f of publicReads) info(`[public] ${f.detail}`);
283
291
  for (const f of unprobed) info(`[unprobed] ${f.detail}`);
284
292
  console.log('');
285
293
 
@@ -20,8 +20,10 @@
20
20
  import fs from 'node:fs/promises';
21
21
  import path from 'node:path';
22
22
  import { pathToFileURL } from 'node:url';
23
- import type { ModelDescriptor } from '@everystack/model';
23
+ import type { ModelDescriptor, Module } from '@everystack/model';
24
24
  import { introspectSchema } from '../schema-introspect.js';
25
+ import { compileDrizzleSource } from '../schema-source.js';
26
+ import { compileModuleMigration } from '../migration-compile.js';
25
27
  import { generateMigrationSql, unmodeledTables, formatMigrationFile, planMigrationFile, HELD_DROP_PREFIX, type Journal } from '../migration-generate.js';
26
28
  import { introspectContract, type QueryRunner } from '../authz-contract.js';
27
29
  import { FUNCTIONS_SQL, catalogFunctionToDescriptor } from '../security-catalog.js';
@@ -31,6 +33,7 @@ import { step, success, fail, info, warn } from '../output.js';
31
33
 
32
34
  const DEFAULT_MODELS = 'models/index.ts';
33
35
  const DEFAULT_MIGRATIONS = 'drizzle';
36
+ const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
34
37
 
35
38
  /** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
36
39
  function lambdaRunner(region: string, fn: string): QueryRunner {
@@ -61,12 +64,82 @@ async function loadModels(modelsPath: string): Promise<ModelDescriptor[]> {
61
64
  return models;
62
65
  }
63
66
 
67
+ /**
68
+ * Import the app's barrel and return its `modules` array — the greenfield (`--init`) input. A
69
+ * Module carries the package's extensions + functions on top of its models, so it can emit the
70
+ * COMPLETE migration. Falls back to wrapping a bare `models` array in one module (an app on the
71
+ * older models-only barrel still gets a schema+authz init, just no package functions).
72
+ */
73
+ async function loadModules(modelsPath: string): Promise<Module[]> {
74
+ const abs = path.resolve(modelsPath);
75
+ let mod: any;
76
+ try {
77
+ mod = await import(pathToFileURL(abs).href);
78
+ } catch (err: any) {
79
+ throw new Error(`Could not load modules from ${modelsPath}: ${err.message}`);
80
+ }
81
+ if (Array.isArray(mod.modules)) return mod.modules;
82
+ const models = mod.models ?? mod.default;
83
+ if (Array.isArray(models)) return [{ models, extensions: [], functions: [] }];
84
+ throw new Error(`${modelsPath} must export a \`modules\` (or \`models\`) array.`);
85
+ }
86
+
87
+ /**
88
+ * `db:generate --init` — the greenfield generator. Compiles the app's Modules into ONE
89
+ * complete migration (extensions + schema + authz + package functions/triggers) with NO live
90
+ * database — the model IS the source. Writes it as the next migration in a (typically empty)
91
+ * `drizzle/`. Roles are excluded by design (they are `db:provision`). Use it to (re)initialize
92
+ * a clean migration history: clear `drizzle/`, then `db:generate --init`.
93
+ */
94
+ async function dbGenerateInit(modelsPath: string, migrationsDir: string, schemaOut: string, name: string): Promise<void> {
95
+ step(`Loading modules from ${modelsPath}...`);
96
+ const modules = await loadModules(modelsPath);
97
+ const models = modules.flatMap((m) => m.models);
98
+ info(`${modules.length} module(s), ${models.length} model(s).`);
99
+
100
+ step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
101
+ await fs.writeFile(schemaOut, compileDrizzleSource(models), 'utf8');
102
+
103
+ const statements = compileModuleMigration(modules);
104
+
105
+ const journalPath = path.join(migrationsDir, 'meta', '_journal.json');
106
+ let journal: Journal;
107
+ try {
108
+ journal = JSON.parse(await fs.readFile(journalPath, 'utf8'));
109
+ } catch {
110
+ journal = { version: '7', dialect: 'postgresql', entries: [] };
111
+ }
112
+ const { filename, journal: nextJournal } = planMigrationFile(journal, name, Date.now());
113
+ const filePath = path.join(migrationsDir, filename);
114
+ await fs.mkdir(path.join(migrationsDir, 'meta'), { recursive: true });
115
+ await fs.writeFile(filePath, formatMigrationFile(statements), 'utf8');
116
+ await fs.writeFile(journalPath, JSON.stringify(nextJournal, null, 2) + '\n', 'utf8');
117
+
118
+ console.log('');
119
+ success(`Wrote ${path.relative(process.cwd(), filePath)} (${statements.length} statement(s)) — the complete greenfield migration.`);
120
+ warn('Roles are NOT in this migration — run `everystack db:provision` (the role chain) before `db:migrate`.');
121
+ process.exit(0);
122
+ }
123
+
64
124
  export async function dbGenerateCommand(flags: Record<string, string>): Promise<void> {
65
125
  const modelsPath = flags.models || DEFAULT_MODELS;
66
126
  const migrationsDir = path.resolve(flags.dir || DEFAULT_MIGRATIONS);
127
+ const schemaOut = path.resolve(flags['schema-out'] || DEFAULT_SCHEMA_OUT);
67
128
  const name = flags.name || 'generated';
68
129
  const allowDrops = flags['allow-drops'] === 'true';
69
130
 
131
+ // --init: greenfield, no live DB — the model emits the whole migration (extensions + schema +
132
+ // authz + package functions). The brownfield path below introspects the deployed DB and diffs.
133
+ if (flags.init === 'true') {
134
+ try {
135
+ await dbGenerateInit(modelsPath, migrationsDir, schemaOut, flags.name || 'init');
136
+ } catch (err: any) {
137
+ fail(err.message);
138
+ process.exit(1);
139
+ }
140
+ return;
141
+ }
142
+
70
143
  let models: ModelDescriptor[];
71
144
  let current;
72
145
  let liveAuthz;
@@ -74,6 +147,11 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
74
147
  step(`Loading models from ${modelsPath}...`);
75
148
  models = await loadModels(modelsPath);
76
149
  info(`${models.length} model(s).`);
150
+ // The drizzle runtime schema is a derived artifact — refresh it from the models
151
+ // every run (it needs no DB), so a relation-only change with no DDL diff still
152
+ // updates it. The drift-guard test asserts this file matches the models.
153
+ step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
154
+ await fs.writeFile(schemaOut, compileDrizzleSource(models), 'utf8');
77
155
  step('Resolving deployed config...');
78
156
  const config = await resolveConfig(flags.stage);
79
157
  info(`Region: ${config.region}, Function: ${opsFunction(config)}`);