@metaobjectsdev/cli 0.5.0 → 0.6.0-rc.1

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.
Files changed (74) hide show
  1. package/README.md +20 -1
  2. package/dist/src/commands/init.d.ts +1 -0
  3. package/dist/src/commands/init.d.ts.map +1 -1
  4. package/dist/src/commands/init.js +34 -5
  5. package/dist/src/commands/init.js.map +1 -1
  6. package/dist/src/commands/migrate.d.ts +4 -1
  7. package/dist/src/commands/migrate.d.ts.map +1 -1
  8. package/dist/src/commands/migrate.js +233 -5
  9. package/dist/src/commands/migrate.js.map +1 -1
  10. package/dist/src/commands/prompt-snapshot.d.ts +2 -0
  11. package/dist/src/commands/prompt-snapshot.d.ts.map +1 -0
  12. package/dist/src/commands/prompt-snapshot.js +125 -0
  13. package/dist/src/commands/prompt-snapshot.js.map +1 -0
  14. package/dist/src/commands/verify.d.ts +2 -0
  15. package/dist/src/commands/verify.d.ts.map +1 -0
  16. package/dist/src/commands/verify.js +93 -0
  17. package/dist/src/commands/verify.js.map +1 -0
  18. package/dist/src/index.d.ts.map +1 -1
  19. package/dist/src/index.js +22 -1
  20. package/dist/src/index.js.map +1 -1
  21. package/dist/src/lib/args.d.ts +18 -1
  22. package/dist/src/lib/args.d.ts.map +1 -1
  23. package/dist/src/lib/args.js +39 -1
  24. package/dist/src/lib/args.js.map +1 -1
  25. package/dist/src/lib/config.d.ts +11 -1
  26. package/dist/src/lib/config.d.ts.map +1 -1
  27. package/dist/src/lib/config.js +10 -1
  28. package/dist/src/lib/config.js.map +1 -1
  29. package/dist/src/lib/file-provider.d.ts +7 -0
  30. package/dist/src/lib/file-provider.d.ts.map +1 -0
  31. package/dist/src/lib/file-provider.js +33 -0
  32. package/dist/src/lib/file-provider.js.map +1 -0
  33. package/dist/src/lib/kysely.d.ts +1 -1
  34. package/dist/src/lib/kysely.d.ts.map +1 -1
  35. package/dist/src/lib/kysely.js +3 -0
  36. package/dist/src/lib/kysely.js.map +1 -1
  37. package/dist/src/lib/load-metaobjects-config.d.ts.map +1 -1
  38. package/dist/src/lib/load-metaobjects-config.js +32 -8
  39. package/dist/src/lib/load-metaobjects-config.js.map +1 -1
  40. package/dist/src/lib/output.d.ts +3 -2
  41. package/dist/src/lib/output.d.ts.map +1 -1
  42. package/dist/src/lib/output.js.map +1 -1
  43. package/dist/src/lib/payload-field-tree.d.ts +9 -0
  44. package/dist/src/lib/payload-field-tree.d.ts.map +1 -0
  45. package/dist/src/lib/payload-field-tree.js +36 -0
  46. package/dist/src/lib/payload-field-tree.js.map +1 -0
  47. package/dist/src/lib/projection-migrations.d.ts +2 -1
  48. package/dist/src/lib/projection-migrations.d.ts.map +1 -1
  49. package/dist/src/lib/projection-migrations.js +4 -2
  50. package/dist/src/lib/projection-migrations.js.map +1 -1
  51. package/dist/src/lib/snapshot.d.ts +11 -0
  52. package/dist/src/lib/snapshot.d.ts.map +1 -0
  53. package/dist/src/lib/snapshot.js +36 -0
  54. package/dist/src/lib/snapshot.js.map +1 -0
  55. package/dist/src/lib/wrangler.d.ts +18 -0
  56. package/dist/src/lib/wrangler.d.ts.map +1 -0
  57. package/dist/src/lib/wrangler.js +30 -0
  58. package/dist/src/lib/wrangler.js.map +1 -0
  59. package/package.json +8 -7
  60. package/src/commands/init.ts +35 -5
  61. package/src/commands/migrate.ts +287 -5
  62. package/src/commands/prompt-snapshot.ts +142 -0
  63. package/src/commands/verify.ts +111 -0
  64. package/src/index.ts +22 -1
  65. package/src/lib/args.ts +67 -1
  66. package/src/lib/config.ts +23 -3
  67. package/src/lib/file-provider.ts +33 -0
  68. package/src/lib/kysely.ts +7 -1
  69. package/src/lib/load-metaobjects-config.ts +32 -8
  70. package/src/lib/output.ts +4 -2
  71. package/src/lib/payload-field-tree.ts +47 -0
  72. package/src/lib/projection-migrations.ts +6 -3
  73. package/src/lib/snapshot.ts +50 -0
  74. package/src/lib/wrangler.ts +45 -0
@@ -1,7 +1,9 @@
1
- import { resolve } from "node:path";
1
+ import { resolve as resolvePath } from "node:path";
2
2
  import { mkdir } from "node:fs/promises";
3
+ import { spawn } from "node:child_process";
3
4
  import { parseMigrateArgs } from "../lib/args.js";
4
- import { resolveMigrateConfig } from "../lib/config.js";
5
+ import { resolveMigrateConfig, MIGRATE_DEFAULT_OUT_DIR } from "../lib/config.js";
6
+ import type { ResolvedMigrateConfig } from "../lib/config.js";
5
7
  import { formatMigrateResult, type BlockedEntry, type AmbiguousEntry } from "../lib/output.js";
6
8
  import { buildKyselyFromUrl } from "../lib/kysely.js";
7
9
  import { log } from "../lib/log.js";
@@ -14,12 +16,26 @@ import {
14
16
  emit,
15
17
  writeMigration,
16
18
  BlockedChangesError,
19
+ renderD1,
20
+ applyD1SafetyPass,
21
+ writeMigrationD1,
22
+ introspectD1,
23
+ findWranglerConfig,
24
+ parseWranglerConfig,
25
+ resolveD1Binding,
17
26
  type AllowOptions,
18
27
  type AmbiguousChange,
19
28
  type AmbiguousResolution,
20
29
  type Change,
30
+ type D1Binding,
21
31
  type EmitResult,
32
+ type D1Runner,
22
33
  } from "@metaobjectsdev/migrate-ts";
34
+ import {
35
+ buildWranglerExecuteArgs,
36
+ defaultWranglerRunner,
37
+ type WranglerRunner,
38
+ } from "../lib/wrangler.js";
23
39
  import {
24
40
  computeProjectionMigrations,
25
41
  computeProjectionViewDependencies,
@@ -114,7 +130,12 @@ function ambiguousToEntries(amb: AmbiguousChange[]): AmbiguousEntry[] {
114
130
  });
115
131
  }
116
132
 
117
- export async function migrateCommand(args: string[], cwd: string): Promise<number> {
133
+ export async function migrateCommand(
134
+ args: string[],
135
+ cwd: string,
136
+ /** Injectable wrangler runner — tests pass a mock; production uses the default. */
137
+ wranglerRunner?: WranglerRunner,
138
+ ): Promise<number> {
118
139
  let flags;
119
140
  try {
120
141
  flags = parseMigrateArgs(args);
@@ -126,6 +147,14 @@ export async function migrateCommand(args: string[], cwd: string): Promise<numbe
126
147
  const metaRoot = cwd;
127
148
  const config = await resolveMigrateConfig(flags, metaRoot);
128
149
 
150
+ if (config.dialect === "d1") {
151
+ if (config.databaseUrl !== undefined) {
152
+ log.error(`migrate: --db / DATABASE_URL is not used for dialect 'd1' — wrangler.toml owns connection`);
153
+ return 2;
154
+ }
155
+ return await runD1Migrate(config, metaRoot, wranglerRunner ?? defaultWranglerRunner);
156
+ }
157
+
129
158
  if (config.databaseUrl === undefined) {
130
159
  log.error(`migrate: --db <url> required (or set DATABASE_URL, or add migrate.databaseUrl to .metaobjects/config.json)`);
131
160
  return 2;
@@ -221,7 +250,8 @@ export async function migrateCommand(args: string[], cwd: string): Promise<numbe
221
250
 
222
251
  // Pull existing view CREATE SQL from the DB so unchanged views can be
223
252
  // skipped (no DROP+CREATE noise when the body hasn't changed).
224
- const existingViewSql = await readExistingViewSql(kysely.db, kysely.dialect);
253
+ // kysely.dialect is always "sqlite" | "postgres" here — d1 exits above.
254
+ const existingViewSql = await readExistingViewSql(kysely.db, kysely.dialect as "sqlite" | "postgres");
225
255
 
226
256
  // Compute view migrations (projections) independently of table changes.
227
257
  const viewResult = computeProjectionMigrations({
@@ -317,7 +347,7 @@ export async function migrateCommand(args: string[], cwd: string): Promise<numbe
317
347
  if (config.dryRun) {
318
348
  log.info(`-- UP --\n${combinedUp}\n\n-- DOWN --\n${combinedDown}`);
319
349
  } else {
320
- const outDir = resolve(metaRoot, config.outDir);
350
+ const outDir = resolvePath(metaRoot, config.outDir);
321
351
  await mkdir(outDir, { recursive: true });
322
352
  const res = await writeMigration(
323
353
  { up: combinedUp, down: combinedDown },
@@ -349,6 +379,258 @@ export async function migrateCommand(args: string[], cwd: string): Promise<numbe
349
379
  return exitCode;
350
380
  }
351
381
 
382
+ async function runD1Migrate(
383
+ config: ResolvedMigrateConfig,
384
+ metaRoot: string,
385
+ runner: WranglerRunner,
386
+ ): Promise<number> {
387
+ // 1. Resolve wrangler.toml + binding.
388
+ const wranglerConfigPath = config.d1.wranglerConfigPath
389
+ ? resolvePath(metaRoot, config.d1.wranglerConfigPath)
390
+ : findWranglerConfig(metaRoot);
391
+
392
+ if (wranglerConfigPath === undefined && config.d1.binding === undefined) {
393
+ log.error(`migrate: no wrangler.toml found in ${metaRoot} or parents; pass --d1 <binding> to bypass`);
394
+ return 2;
395
+ }
396
+
397
+ let binding: D1Binding;
398
+ if (wranglerConfigPath !== undefined) {
399
+ const parsed = parseWranglerConfig(wranglerConfigPath);
400
+ try {
401
+ binding = resolveD1Binding(parsed.d1Bindings, config.d1.binding);
402
+ } catch (err) {
403
+ log.error(`migrate: ${(err as Error).message}`);
404
+ return 2;
405
+ }
406
+ } else {
407
+ // No wrangler config but explicit binding — let wrangler discover the DB itself.
408
+ binding = { binding: config.d1.binding!, database_name: "", database_id: "", migrations_dir: undefined };
409
+ }
410
+
411
+ // 2. Build a D1Runner closure over the wrangler runner.
412
+ const d1Runner: D1Runner = async (sql) => {
413
+ const args = buildWranglerExecuteArgs({
414
+ binding: binding.binding,
415
+ remote: config.d1.remote,
416
+ command: sql,
417
+ configPath: wranglerConfigPath,
418
+ });
419
+ const { stdout } = await runner(args, metaRoot);
420
+ return stdout;
421
+ };
422
+
423
+ // 3. Load metadata.
424
+ let metadata;
425
+ try {
426
+ metadata = await loadMemory(metaRoot);
427
+ } catch (err) {
428
+ const msg = (err as Error).message;
429
+ if (msg.includes("ENOENT") || msg.includes("no such") || msg.includes("cannot read")) {
430
+ log.error(`no metaobjects/ found in ${metaRoot}; run 'meta init' to scaffold`);
431
+ } else {
432
+ log.error(`migrate: failed to load metadata: ${msg}`);
433
+ }
434
+ return 2;
435
+ }
436
+
437
+ // 4. Build expected schema + introspect actual.
438
+ const expected = buildExpectedSchema(metadata, { dialect: "d1" });
439
+ let actual;
440
+ try {
441
+ actual = await introspectD1({
442
+ runner: d1Runner,
443
+ binding: binding.binding,
444
+ remote: config.d1.remote,
445
+ configPath: wranglerConfigPath,
446
+ });
447
+ } catch (err) {
448
+ log.error(`migrate: failed to introspect D1: ${(err as Error).message}`);
449
+ return 2;
450
+ }
451
+
452
+ // 5. Diff.
453
+ const collectedAmbiguous: AmbiguousChange[] = [];
454
+ const onAmbiguousResolution = mapOnAmbiguous(config.onAmbiguous);
455
+ let diffResult;
456
+ try {
457
+ diffResult = await diff({
458
+ expected,
459
+ actual,
460
+ allow: tokensToAllowOptions(config.allow),
461
+ onAmbiguous: async (a) => {
462
+ collectedAmbiguous.push(a);
463
+ return onAmbiguousResolution;
464
+ },
465
+ });
466
+ } catch (err) {
467
+ if ((err as Error).message.includes("aborted by onAmbiguous")) {
468
+ const entries = ambiguousToEntries(collectedAmbiguous);
469
+ for (const e of entries) {
470
+ log.error(` ambiguous ${e.kind}: ${e.description}${e.hint ? ` [${e.hint}]` : ""}`);
471
+ }
472
+ log.error(`migrate: aborted on ambiguous change (re-run with --on-ambiguous rename|drop-add)`);
473
+ return 1;
474
+ }
475
+ throw err;
476
+ }
477
+
478
+ const changeCounts = summarizeChanges(diffResult.changes);
479
+
480
+ // Load metaobjects config to pick up columnNamingStrategy for view DDL emit.
481
+ // Defensive try/catch: if metaobjects.config.ts is absent, fall back to snake_case.
482
+ let columnNamingStrategy: "snake_case" | "literal" | "kebab-case" = "snake_case";
483
+ try {
484
+ const forgeConfig = await loadMetaobjectsConfig(metaRoot);
485
+ if (forgeConfig.columnNamingStrategy) {
486
+ columnNamingStrategy = forgeConfig.columnNamingStrategy;
487
+ }
488
+ } catch {
489
+ // metaobjects.config.ts absent or invalid — use default snake_case
490
+ }
491
+
492
+ // 5b. Compute view migrations (projections). D1 reuses the sqlite emitter.
493
+ // We intentionally skip readExistingViewSql for D1 in v1 — D1 has no
494
+ // introspection path for view bodies, so we accept over-eager DROP+CREATE.
495
+ const viewResult = computeProjectionMigrations({
496
+ metadata,
497
+ dialect: "d1",
498
+ allowBreaking: false,
499
+ columnNamingStrategy,
500
+ });
501
+ if (viewResult.errors.length > 0) {
502
+ for (const err of viewResult.errors) log.error(err);
503
+ return 1;
504
+ }
505
+
506
+ const hasTableChanges = diffResult.changes.length > 0;
507
+ const hasViewChanges = viewResult.migrations.length > 0;
508
+
509
+ if (!hasTableChanges && !hasViewChanges) {
510
+ log.info(`migrate: no schema changes for d1 binding '${binding.binding}'`);
511
+ return 0;
512
+ }
513
+
514
+ if (config.slug === undefined) {
515
+ log.error(`migrate: --slug <name> required when there are changes`);
516
+ return 2;
517
+ }
518
+
519
+ // 6. Emit (with D1 safety pass) + write Wrangler migration files.
520
+ let emitResult;
521
+ try {
522
+ emitResult = renderD1(diffResult.changes, expected, actual.meta);
523
+ } catch (err) {
524
+ if (err instanceof BlockedChangesError) {
525
+ const entries = blockedToEntries(err);
526
+ for (const e of entries) {
527
+ log.error(`migrate: blocked '${e.kind}' on ${e.description} (allow with --allow ${e.allowFlag})`);
528
+ }
529
+ return 1;
530
+ }
531
+ throw err;
532
+ }
533
+
534
+ // Combine view SQL with table SQL.
535
+ // Extract view names from CREATE VIEW statements for pre-drop + down SQL.
536
+ const viewNames = viewResult.migrations
537
+ .map((s) => {
538
+ const m = /CREATE(?:\s+OR\s+REPLACE)?\s+VIEW\s+(\S+)/i.exec(s);
539
+ return m ? m[1] : undefined;
540
+ })
541
+ .filter((n): n is string => Boolean(n));
542
+
543
+ // Pre-drop views whose source tables are being recreated (same logic as kysely path).
544
+ const recreatedTables = emitResult.recreatedTables ?? new Set<string>();
545
+ const viewPreDropSql = recreatedTables.size > 0 && viewNames.length > 0
546
+ ? (() => {
547
+ const deps = computeProjectionViewDependencies({ metadata, columnNamingStrategy });
548
+ const affected = viewNames.filter((n) => {
549
+ const sources = deps.get(n);
550
+ if (!sources) return false;
551
+ for (const t of sources) if (recreatedTables.has(t)) return true;
552
+ return false;
553
+ });
554
+ return affected.length > 0
555
+ ? affected.map((n) => `DROP VIEW IF EXISTS ${n};`).join("\n")
556
+ : "";
557
+ })()
558
+ : "";
559
+
560
+ const viewUpSql = viewResult.migrations.join("\n\n");
561
+ const viewDownSql = viewNames.map((n) => `DROP VIEW IF EXISTS ${n};`).join("\n");
562
+
563
+ // Combine and apply safety pass to the full combined SQL so view DDL
564
+ // (which uses BEGIN/COMMIT from emitSqliteViewMigration) is stripped too.
565
+ const rawUp = [viewPreDropSql, emitResult.up, viewUpSql].filter(Boolean).join("\n\n");
566
+ const rawDown = [viewDownSql, emitResult.down].filter(Boolean).join("\n\n");
567
+ const combinedUp = applyD1SafetyPass(rawUp);
568
+ const combinedDown = applyD1SafetyPass(rawDown);
569
+
570
+ // Migration dir resolution: --out-dir > wrangler.toml's migrations_dir > "migrations".
571
+ // The default outDir (./.metaobjects/migrations) is the Kysely-path default; for D1
572
+ // we fall back to wrangler conventions when the caller hasn't overridden it.
573
+ const isDefaultOutDir = config.outDir === MIGRATE_DEFAULT_OUT_DIR;
574
+ const migrationsDir = resolvePath(
575
+ metaRoot,
576
+ isDefaultOutDir ? (binding.migrations_dir ?? "migrations") : config.outDir,
577
+ );
578
+
579
+ if (config.dryRun) {
580
+ log.info(`-- UP --\n${combinedUp}\n\n-- DOWN --\n${combinedDown}`);
581
+ return 0;
582
+ }
583
+
584
+ const writeResult = await writeMigrationD1(
585
+ { up: combinedUp, down: combinedDown },
586
+ { dir: migrationsDir, slug: config.slug },
587
+ );
588
+ log.info(`migrate: wrote ${writeResult.upPath}`);
589
+ log.info(`migrate: wrote ${writeResult.downPath}`);
590
+ for (const [kind, count] of Object.entries(changeCounts)) {
591
+ log.info(` ${kind}: ${count}`);
592
+ }
593
+
594
+ // 7. Optional --apply: run `wrangler d1 migrations apply`.
595
+ if (config.d1.autoApply) {
596
+ return await runWranglerApply(
597
+ binding.binding,
598
+ binding.database_name,
599
+ config.d1.remote,
600
+ wranglerConfigPath,
601
+ config.yes,
602
+ );
603
+ }
604
+
605
+ return 0;
606
+ }
607
+
608
+ async function runWranglerApply(
609
+ bindingName: string,
610
+ databaseName: string,
611
+ remote: boolean,
612
+ wranglerConfigPath: string | undefined,
613
+ yes: boolean,
614
+ ): Promise<number> {
615
+ if (remote && !yes) {
616
+ log.info(
617
+ `Applying to remote D1 '${databaseName}' (binding=${bindingName}) in 2s — Ctrl+C to abort or pass --yes to skip this pause.`,
618
+ );
619
+ await new Promise<void>((r) => setTimeout(r, 2000));
620
+ }
621
+ const applyArgs = ["d1", "migrations", "apply", bindingName, remote ? "--remote" : "--local"];
622
+ if (wranglerConfigPath !== undefined) applyArgs.push("--config", wranglerConfigPath);
623
+
624
+ return await new Promise<number>((resolve) => {
625
+ const child = spawn("wrangler", applyArgs, { stdio: "inherit" });
626
+ child.on("error", (err) => {
627
+ log.error(`migrate: failed to run wrangler: ${(err as Error).message}`);
628
+ resolve(2);
629
+ });
630
+ child.on("close", (code) => resolve(code ?? 1));
631
+ });
632
+ }
633
+
352
634
  /**
353
635
  * Read existing view CREATE SQL from the DB. Returns an empty map on any
354
636
  * introspection failure — the worst case is over-eager DROP+CREATE
@@ -0,0 +1,142 @@
1
+ // `meta prompt-snapshot` — deterministic rendered-prompt goldens (FR-004 #4).
2
+ //
3
+ // For each template.* node with a committed fixture payload, render its @textRef
4
+ // text against that payload (same engine, provider, and @format escaping prod
5
+ // uses) and snapshot the byte-exact output under .metaobjects/snapshots/<name>/.
6
+ // Write mode (default) overwrites output.snap; --check compares against the
7
+ // committed golden and exits 1 on drift (never writes). Closes the gap the
8
+ // template's own git history misses: a shared partial or payload-shape change
9
+ // that silently alters the rendered prompt.
10
+
11
+ import { join } from "node:path";
12
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
13
+ import { parsePromptSnapshotArgs } from "../lib/args.js";
14
+ import { log } from "../lib/log.js";
15
+ import { FileProvider } from "../lib/file-provider.js";
16
+ import { snapshotPaths, unifiedDiff } from "../lib/snapshot.js";
17
+ import { loadMemory } from "@metaobjectsdev/sdk";
18
+ import { TYPE_TEMPLATE, TEMPLATE_ATTR_TEXT_REF, TEMPLATE_ATTR_FORMAT } from "@metaobjectsdev/metadata";
19
+ import { render, ESCAPERS, type RenderFormat } from "@metaobjectsdev/render";
20
+
21
+ const DEFAULT_PROMPTS_DIR = "prompts";
22
+
23
+ export async function promptSnapshotCommand(args: string[], cwd: string): Promise<number> {
24
+ let flags;
25
+ try {
26
+ flags = parsePromptSnapshotArgs(args);
27
+ } catch (err) {
28
+ log.error((err as Error).message);
29
+ return 2;
30
+ }
31
+
32
+ let root;
33
+ try {
34
+ root = await loadMemory(cwd);
35
+ } catch (err) {
36
+ const msg = (err as Error).message;
37
+ if (msg.includes("ENOENT") || msg.includes("no such") || msg.includes("cannot read")) {
38
+ log.error(`no metaobjects/ found in ${cwd}; run 'meta init' to scaffold`);
39
+ return 2;
40
+ }
41
+ log.error(`failed to load metadata: ${msg}`);
42
+ return 1;
43
+ }
44
+
45
+ const promptsDir = join(cwd, flags.prompts ?? DEFAULT_PROMPTS_DIR);
46
+ const provider = new FileProvider(promptsDir);
47
+
48
+ const templates = root.ownChildren().filter((c) => c.type === TYPE_TEMPLATE);
49
+ if (templates.length === 0) {
50
+ log.info("meta prompt-snapshot — no template.* nodes found; nothing to snapshot.");
51
+ return 0;
52
+ }
53
+
54
+ let errorCount = 0;
55
+ let driftCount = 0;
56
+ let wrote = 0;
57
+ let checked = 0;
58
+ let skipped = 0;
59
+
60
+ for (const tmpl of templates) {
61
+ const textRef = tmpl.ownAttr(TEMPLATE_ATTR_TEXT_REF);
62
+ // Absent/typeless required attrs are a loader-schema concern, not ours.
63
+ if (typeof textRef !== "string") continue;
64
+
65
+ const { dir, payloadPath, snapPath } = snapshotPaths(cwd, tmpl.name);
66
+ if (!existsSync(payloadPath)) {
67
+ log.info(`[${tmpl.name}] skipped — no payload at ${payloadPath}`);
68
+ skipped++;
69
+ continue;
70
+ }
71
+
72
+ let payload: unknown;
73
+ try {
74
+ payload = JSON.parse(readFileSync(payloadPath, "utf8"));
75
+ } catch (err) {
76
+ log.error(`[${tmpl.name}] invalid payload.json: ${(err as Error).message}`);
77
+ errorCount++;
78
+ continue;
79
+ }
80
+
81
+ // @format is loader-validated against the template format vocabulary; narrow
82
+ // against the render engine's own escaper registry so the RenderFormat cast is
83
+ // a checked narrowing (never a TypeError on an unknown format), and omit the
84
+ // key entirely when absent (exactOptionalPropertyTypes forbids `format: undefined`).
85
+ const fmtAttr = tmpl.ownAttr(TEMPLATE_ATTR_FORMAT);
86
+ const format =
87
+ typeof fmtAttr === "string" && fmtAttr in ESCAPERS ? (fmtAttr as RenderFormat) : undefined;
88
+
89
+ let rendered: string;
90
+ try {
91
+ rendered = render({ ref: textRef, payload, provider, ...(format ? { format } : {}) });
92
+ } catch (err) {
93
+ log.error(`[${tmpl.name}] render failed: ${(err as Error).message}`);
94
+ errorCount++;
95
+ continue;
96
+ }
97
+
98
+ if (flags.check) {
99
+ checked++;
100
+ if (!existsSync(snapPath)) {
101
+ log.error(
102
+ `[${tmpl.name}] no committed snapshot at ${snapPath}; run 'meta prompt-snapshot' to create it`,
103
+ );
104
+ driftCount++;
105
+ continue;
106
+ }
107
+ const golden = readFileSync(snapPath, "utf8");
108
+ if (golden !== rendered) {
109
+ log.error(`[${tmpl.name}] snapshot drift:\n${unifiedDiff(golden, rendered)}`);
110
+ log.error(`[${tmpl.name}] run 'meta prompt-snapshot' to accept the change`);
111
+ driftCount++;
112
+ }
113
+ } else {
114
+ mkdirSync(dir, { recursive: true });
115
+ writeFileSync(snapPath, rendered, "utf8");
116
+ log.info(`[${tmpl.name}] wrote ${snapPath}`);
117
+ wrote++;
118
+ }
119
+ }
120
+
121
+ if (flags.check) {
122
+ if (errorCount > 0 || driftCount > 0) {
123
+ log.error(
124
+ `meta prompt-snapshot --check — ${driftCount} drifted, ${errorCount} error(s) across ${checked} checked.`,
125
+ );
126
+ return 1;
127
+ }
128
+ log.info(
129
+ `meta prompt-snapshot --check — ${checked} snapshot(s) clean${skipped > 0 ? `, ${skipped} skipped` : ""}.`,
130
+ );
131
+ return 0;
132
+ }
133
+
134
+ if (errorCount > 0) {
135
+ log.error(`meta prompt-snapshot — ${errorCount} error(s); ${wrote} snapshot(s) written.`);
136
+ return 1;
137
+ }
138
+ log.info(
139
+ `meta prompt-snapshot — ${wrote} snapshot(s) written${skipped > 0 ? `, ${skipped} skipped` : ""}.`,
140
+ );
141
+ return 0;
142
+ }
@@ -0,0 +1,111 @@
1
+ // `meta verify` — the build-time drift gate (FR-004 Plan #3, T6).
2
+ //
3
+ // Loads metadata + a filesystem provider; for each template.* node resolves its
4
+ // @textRef text, derives its @payloadRef view-object field tree, and runs the
5
+ // render engine's `verify` (template variable ↔ payload field drift). Exits
6
+ // non-zero on any drift error so CI fails loud — the "a renamed field can't
7
+ // silently break a prompt" guarantee, enforced at the last fixed point before
8
+ // the text ships. Required-slot misses are warnings (don't fail the build).
9
+
10
+ import { join } from "node:path";
11
+ import { parseVerifyArgs } from "../lib/args.js";
12
+ import { log } from "../lib/log.js";
13
+ import { FileProvider } from "../lib/file-provider.js";
14
+ import { derivePayloadFieldTree } from "../lib/payload-field-tree.js";
15
+ import { loadMemory } from "@metaobjectsdev/sdk";
16
+ import {
17
+ TYPE_TEMPLATE,
18
+ TEMPLATE_ATTR_PAYLOAD_REF,
19
+ TEMPLATE_ATTR_TEXT_REF,
20
+ TEMPLATE_ATTR_REQUIRED_SLOTS,
21
+ TEMPLATE_ATTR_REQUIRED_TAGS,
22
+ } from "@metaobjectsdev/metadata";
23
+ import { verify, ERR_REQUIRED_SLOT_UNUSED, ERR_PARTIAL_UNRESOLVED } from "@metaobjectsdev/render";
24
+
25
+ const DEFAULT_PROMPTS_DIR = "prompts";
26
+
27
+ /** Coerce a string-array attr (array, or a single string) into a string[]. */
28
+ function attrAsStringArray(attr: unknown): string[] {
29
+ if (Array.isArray(attr)) return attr.filter((s): s is string => typeof s === "string");
30
+ if (typeof attr === "string") return [attr];
31
+ return [];
32
+ }
33
+
34
+ export async function verifyCommand(args: string[], cwd: string): Promise<number> {
35
+ let flags;
36
+ try {
37
+ flags = parseVerifyArgs(args);
38
+ } catch (err) {
39
+ log.error((err as Error).message);
40
+ return 2;
41
+ }
42
+
43
+ let root;
44
+ try {
45
+ root = await loadMemory(cwd);
46
+ } catch (err) {
47
+ const msg = (err as Error).message;
48
+ if (msg.includes("ENOENT") || msg.includes("no such") || msg.includes("cannot read")) {
49
+ log.error(`no metaobjects/ found in ${cwd}; run 'meta init' to scaffold`);
50
+ return 2;
51
+ }
52
+ log.error(`failed to load metadata: ${msg}`);
53
+ return 1;
54
+ }
55
+
56
+ const promptsDir = join(cwd, flags.prompts ?? DEFAULT_PROMPTS_DIR);
57
+ const provider = new FileProvider(promptsDir);
58
+
59
+ const templates = root.ownChildren().filter((c) => c.type === TYPE_TEMPLATE);
60
+ if (templates.length === 0) {
61
+ log.info("meta verify — no template.* nodes found; nothing to check.");
62
+ return 0;
63
+ }
64
+
65
+ let errorCount = 0;
66
+ let warnCount = 0;
67
+ let checked = 0;
68
+
69
+ for (const tmpl of templates) {
70
+ const textRef = tmpl.ownAttr(TEMPLATE_ATTR_TEXT_REF);
71
+ const payloadRef = tmpl.ownAttr(TEMPLATE_ATTR_PAYLOAD_REF);
72
+ // Absent/typeless required attrs are a loader-schema concern, not verify's.
73
+ if (typeof textRef !== "string" || typeof payloadRef !== "string") continue;
74
+
75
+ const text = provider.resolve(textRef);
76
+ if (text === undefined) {
77
+ log.error(
78
+ `[${tmpl.name}] ${ERR_PARTIAL_UNRESOLVED}: @textRef "${textRef}" did not resolve under ${promptsDir}`,
79
+ );
80
+ errorCount++;
81
+ continue;
82
+ }
83
+
84
+ const fieldTree = derivePayloadFieldTree(root, payloadRef);
85
+ const requiredSlots = attrAsStringArray(tmpl.ownAttr(TEMPLATE_ATTR_REQUIRED_SLOTS));
86
+ const requiredTags = attrAsStringArray(tmpl.ownAttr(TEMPLATE_ATTR_REQUIRED_TAGS));
87
+
88
+ const drift = verify(text, fieldTree, { provider, requiredSlots, requiredTags });
89
+ checked++;
90
+ for (const e of drift) {
91
+ if (e.code === ERR_REQUIRED_SLOT_UNUSED) {
92
+ log.warn(`[${tmpl.name}] ${e.code}: ${e.path}`);
93
+ warnCount++;
94
+ } else {
95
+ log.error(`[${tmpl.name}] ${e.code}: ${e.path}`);
96
+ errorCount++;
97
+ }
98
+ }
99
+ }
100
+
101
+ if (errorCount > 0) {
102
+ log.error(
103
+ `meta verify — ${errorCount} drift error(s) across ${templates.length} template(s).`,
104
+ );
105
+ return 1;
106
+ }
107
+ log.info(
108
+ `meta verify — ${checked} template(s) clean${warnCount > 0 ? ` (${warnCount} warning(s))` : ""}.`,
109
+ );
110
+ return 0;
111
+ }
package/src/index.ts CHANGED
@@ -39,6 +39,8 @@ COMMANDS:
39
39
  init --refresh-docs Refresh .metaobjects/AGENTS.md + CLAUDE.md after CLI upgrades
40
40
  gen [<entity>...] Codegen TS targets from metaobjects/ entities
41
41
  export Flatten loaded metadata to one canonical JSON artifact
42
+ verify Check template.* text against its payload (drift gate)
43
+ prompt-snapshot Snapshot rendered template.* output; --check gates drift
42
44
  migrate Diff metadata vs live DB; emit migration SQL files
43
45
  --version, -v Print version
44
46
  --help, -h Print this help
@@ -54,15 +56,26 @@ GEN FLAGS:
54
56
  EXPORT FLAGS:
55
57
  --out <file> Write output to a file (default: stdout)
56
58
 
59
+ VERIFY FLAGS:
60
+ --prompts <dir> Directory of provider-resolved template text (default: prompts)
61
+
62
+ PROMPT-SNAPSHOT FLAGS:
63
+ --check Compare against committed snapshots; exit 1 on drift (CI gate)
64
+ --prompts <dir> Directory of provider-resolved template text (default: prompts)
65
+
57
66
  MIGRATE FLAGS:
58
67
  --db <url> DB connection URL (required, or set DATABASE_URL or config)
59
68
  Supports: file:, libsql:, postgres:, postgresql:
60
- --dialect sqlite|postgres Optional override (auto-detected from URL scheme)
69
+ --dialect sqlite|postgres|d1 Optional override (auto-detected from URL scheme)
61
70
  --out-dir <path> Migration directory (default: ./.metaobjects/migrations)
62
71
  --slug <name> Required when changes are present (e.g., add-user-shipping)
63
72
  --allow <csv> Comma-separated destructive-change permissions:
64
73
  drop-column,drop-table,type-change,drop-index,drop-fk,nullable-to-not-null
65
74
  --on-ambiguous abort|rename|drop-add Default abort
75
+ --d1 <binding> D1 binding name from wrangler.toml (only with --dialect d1)
76
+ --remote Target remote D1 instead of local (only with --dialect d1)
77
+ --apply Run 'wrangler d1 migrations apply' after writing files
78
+ --yes Skip the --remote --apply confirmation pause
66
79
  --dry-run Print SQL to stdout, don't write
67
80
 
68
81
  Other commands (ingest, mcp, serve, install-hooks, audit, capture, promote)
@@ -121,6 +134,14 @@ export async function run(argv: string[]): Promise<number> {
121
134
  const { exportCommand } = await import("./commands/export.js");
122
135
  return exportCommand(rest, cwd);
123
136
  }
137
+ case "verify": {
138
+ const { verifyCommand } = await import("./commands/verify.js");
139
+ return verifyCommand(rest, cwd);
140
+ }
141
+ case "prompt-snapshot": {
142
+ const { promptSnapshotCommand } = await import("./commands/prompt-snapshot.js");
143
+ return promptSnapshotCommand(rest, cwd);
144
+ }
124
145
  case "migrate": {
125
146
  const { migrateCommand } = await import("./commands/migrate.js");
126
147
  return migrateCommand(rest, cwd);