@metaobjectsdev/cli
The MetaObjects CLI — scaffolds metaobjects/ + .metaobjects/, runs codegen and migrations against MetaObjects metadata.
Status: v0.3. TS reference implementation with Projects D–G shipped end-to-end.
Install
bun add -D @metaobjectsdev/cliThe SQLite/Turso driver (@libsql/kysely-libsql) ships as a direct dependency — meta migrate against a sqlite/libsql URL works out of the box. Postgres remains an optional peer (install only if you target Postgres):
- Postgres:
bun add -D pg
If a required driver is ever missing, the CLI prints an install command matching your project's package manager (npm / pnpm / yarn / bun, detected from the lockfile).
Standalone binary (no Node/Bun toolchain to run)
The CLI can be compiled to a single-file native executable with bun build --compile. The Bun runtime is embedded in the binary, so the schema operations (meta migrate and meta verify --db) run on any machine with no Node or Bun installed — the same "schema is a standalone tool" packaging as Flyway or Atlas. This lets a non-TS backend (Java, C#, Python, Go, …) adopt MetaObjects' migration + drift-detection layer without bringing in a JS toolchain.
Build the host-target binary:
bun run build:binary # → dist/meta (compiled for the build host's OS/arch)
./dist/meta --helpThe build externalizes two optional Biome WASM backends (@biomejs/wasm-bundler, @biomejs/wasm-web) that the CLI never loads — only the native Biome backend is used.
Cross-compiling for other platforms
bun build --compile cross-compiles by passing --target. To build for a platform other than the host, append the target to the build:binary invocation:
bun build ./bin/meta.ts --compile --target=bun-linux-x64 --outfile dist/meta-linux-x64 --external @biomejs/wasm-bundler --external @biomejs/wasm-web
bun build ./bin/meta.ts --compile --target=bun-linux-arm64 --outfile dist/meta-linux-arm64 --external @biomejs/wasm-bundler --external @biomejs/wasm-web
bun build ./bin/meta.ts --compile --target=bun-darwin-arm64 --outfile dist/meta-darwin-arm64 --external @biomejs/wasm-bundler --external @biomejs/wasm-web
bun build ./bin/meta.ts --compile --target=bun-darwin-x64 --outfile dist/meta-darwin-x64 --external @biomejs/wasm-bundler --external @biomejs/wasm-web
bun build ./bin/meta.ts --compile --target=bun-windows-x64 --outfile dist/meta-windows-x64.exe --external @biomejs/wasm-bundler --external @biomejs/wasm-webSQLite driver inside the binary
The standalone binary uses Bun's built-in bun:sqlite for the sqlite dialect (it ships inside the embedded Bun runtime). The npm/Node distribution uses the bundled @libsql/kysely-libsql dependency. This is automatic — the same --db file:./app.db --dialect sqlite flags work in both. (Postgres in the standalone binary still requires the pg peer to be resolvable, because pg is a native module that --compile cannot embed; sqlite is the fully self-contained path.)
Run schema ops from the compiled binary:
./dist/meta migrate --db file:./app.db --dialect sqlite --slug initial --apply
./dist/meta verify --db file:./app.db --dialect sqlite # exit 0 when in sync, 1 on drift(dist/ is git-ignored — build the binary in CI/release, don't commit it.)
Quick start
# 1. Scaffold metaobjects/ + .metaobjects/ + codegen/generators/ + metaobjects.config.ts
meta init
# 2. Author entity metadata
$EDITOR metaobjects/meta.myapp.json # see .metaobjects/AGENTS.md for format
# 3. Generate TS code (config-driven via metaobjects.config.ts)
meta gen
# 4. Diff metadata against your DB and emit migration SQL
meta migrate --db file:./local.db --slug initialGlobal options
These apply to every command:
--cwd <path>,-C <path>— run as if launched from<path>(default: current directory)--format <toon|json|text>— output format. The default is TTY-aware: a human at a terminal gets human-readabletext, while a pipe or agent gets TOON (a compact, token-efficient structured format). Pass--format jsonfor plain JSON, or force--format textwhen piping but still wanting the human view. Currently honored bygenandmigrate.--help,-h— print help. Baremeta --helpprints the full command reference;meta <command> --helpprints focused usage for that command (e.g.meta migrate --help).--version,-v— print the CLI version.
Running meta with no arguments prints a concise status line (whether a metaobjects/ directory is present) plus the most relevant next-step commands, rather than the full manual.
Exit codes: 0 success (including idempotent no-op runs), 1 runtime error, 2 usage error (bad flag, missing required argument, invalid --format). For agent-friendliness, structured errors and next-step hints are emitted on stdout in the active --format (not stderr), so callers can parse them without scraping stderr.
Commands
meta init
Scaffolds metaobjects/ (visible entity declarations, with a placeholder meta.common.json), .metaobjects/ (hidden tool state: config.json, package.meta.json, AGENTS.md, CLAUDE.md, .gitignore, .gen-state/), the owned codegen generators at codegen/generators/{entity,queries,routes,barrel}.ts, and metaobjects.config.ts at the repo root.
The generators are copied from the codegen reference templates and are yours to edit (ADR-0034 scaffold-and-own); the scaffolded metaobjects.config.ts imports them locally, and meta gen runs from those local copies — not from the package. Each generator file is written only if absent, so re-running with --force never clobbers a hand-edited generator.
Flags:
--force— overwrite scaffold files (memory records preserved)--quiet— suppress next-steps output--print-only— show what would be created without writing--refresh-docs— refresh AGENTS.md + CLAUDE.md after a CLI upgrade
meta gen [<entity>...]
Generates TS code (Drizzle schema, Zod validators, query helpers, TanStack Query hooks, etc.) from metaobjects/ entity metadata. Generator wiring lives in metaobjects.config.ts; meta gen errors out if that file is missing.
Flags:
--dry-run— informational; files still written (true no-write planned)--no-antipatterns— suppress the advisory anti-pattern pass (see below)
Positional args filter entities by name. All other knobs (outDir, targets, dialect, dbImport, extStyle, apiPrefix, generator list) live in metaobjects.config.ts.
On a real write run (not --dry-run), meta gen also runs an advisory anti-pattern pass — the same "verify-as-teacher" scan as meta verify. It scans your authored source for hand-rolled aggregates, money-as-float, and CHECK (... IN (...)) enums and points you at the construct that models them (origin.aggregate / field.currency / field.enum). It emits warnings only and never changes the exit code. Opt out with --no-antipatterns or META_NO_ANTIPATTERNS=1.
meta types [<query>]
Searches the metadata vocabulary (types, subtypes, @attrs) without loading it all into context — apropos + kubectl explain over the live registry. Use it to find the declarative construct for a piece of data logic instead of hand-writing it.
meta types relationship # subtypes/attrs whose name matches "relationship"
meta types --all money # search names AND descriptions ("find by what it does")
meta types --type field --kind subtype # all field subtypes (terse)
meta types field.enum --detail # one construct: description + when-to-use + valid @attrs
meta types --type origin --json # machine-readable subtree
QUERY is a case-insensitive substring matched on the name (type.subType / @attr).
Flags:
--desc/--all— also matchQUERYagainst descriptions + when-to-use guidance (not just names)--kind <type|subtype|attr>— filter by category (comma-list ok)--type <name>— scope to one top-level type (e.g.--type field)--detail— drill in: full description, when-to-use, and valid@attrs--json— emit the matching registry subtree verbatim (stable-sorted)--limit <N>— cap results (default 20;0= unlimited)--no-headers— omit theN of M/match-count footer (parse-friendly)
Default output is one terse line per match.
meta migrate
Diffs metaobjects/ metadata against a live DB and emits paired migration SQL files (per-migration subdirectories with up.sql and down.sql).
Flags:
--db <url>(required or via$DATABASE_URLor.metaobjects/config.json)- Supported schemes:
file:,libsql:,postgres:,postgresql:
- Supported schemes:
--dialect sqlite|postgres|d1— auto-detected from URL scheme; used1for Cloudflare D1--out-dir <path>(default./.metaobjects/migrations)--slug <name>— required when changes are pending (e.g.,add-user-shipping)--allow <csv>— destructive-change permissions:drop-column,drop-table,type-change,drop-index,drop-fk,nullable-to-not-null--on-ambiguous abort|rename|drop-add(defaultabort) — non-interactive--dry-run— print SQL pair to stdout, write nothing--apply— after writing migration files, immediately apply all pending migrations against the DB (runsup.sqlfor each unapplied entry, tracked in the migration ledger). Mutually exclusive with--rollback. Postgres and SQLite only (D1 uses--applyto invokewrangler d1 migrations applyinstead).--rollback <version>— roll back applied migrations newer than<version>by running theirdown.sqlin reverse order, ledger-tracked. Pass an empty string (--rollback "") to roll back everything. Mutually exclusive with--apply. Postgres and SQLite only.
D1-specific flags (only relevant with --dialect d1):
--d1 <binding>— explicit D1 binding fromwrangler.toml(auto-detected when there's exactly one)--remote— target remote D1 (default: local)--apply— invokewrangler d1 migrations applyafter writing--yes— skip the--remote --apply2-second confirmation pause
Requirements for D1: wrangler >= 3 on PATH (npm i -D wrangler).
Configuration
Two config files, by design:
metaobjects.config.ts (at repo root) — generator wiring and codegen knobs, type-checked TS. The generators are imported from the owned local copies that meta init scaffolded into codegen/generators/ (ADR-0034 scaffold-and-own), not from the package:
import { defineConfig } from "@metaobjectsdev/cli";
import { entityFile } from "./codegen/generators/entity";
import { queriesFile } from "./codegen/generators/queries";
import { routesFile } from "./codegen/generators/routes";
import { barrel } from "./codegen/generators/barrel";
export default defineConfig({
outDir: "packages/database/src/generated",
extStyle: "none",
dbImport: "../index",
dialect: "sqlite",
apiPrefix: "/api",
generators: [entityFile(), queriesFile(), routesFile(), barrel()],
});Importing these generator factories from
@metaobjectsdev/codegen-ts/generatorsstill works but is deprecated (ADR-0034) — own a local copy instead. The package export will be removed in a future major.
Multiple output targets
By default every generator writes to outDir. To route each generator's output
to a different directory/package — so generated code lands with its runtime
concern — declare named targets and point generators at them with target:
import { defineConfig } from "@metaobjectsdev/cli";
// Owned generators scaffolded by `meta init` (ADR-0034 scaffold-and-own).
import { entityFile } from "./codegen/generators/entity";
import { queriesFile } from "./codegen/generators/queries";
import { routesFile } from "./codegen/generators/routes";
import { barrel } from "./codegen/generators/barrel";
import { formFile } from "@metaobjectsdev/codegen-ts-react";
import { tanstackQuery, tanstackGrid } from "@metaobjectsdev/codegen-ts-tanstack";
export default defineConfig({
// The top-level outDir is the implicit "default" target — it holds the entity
// modules (Drizzle + Zod), so it needs an importBase that other targets use to
// import them.
outDir: "packages/database/src/generated",
importBase: "@acme/database/generated",
dbImport: "../index",
dialect: "sqlite",
outputLayout: "package",
apiPrefix: "/api",
targets: {
// routes run on the server; import db from the database package
api: { outDir: "apps/api/src/generated", dbImport: "@acme/database" },
// hooks/forms/grids run in the browser
web: { outDir: "apps/web/src/generated" },
},
generators: [
entityFile(), queriesFile(), barrel(), // → default (entity-module) target
routesFile({ target: "api" }),
formFile({ target: "web" }),
tanstackQuery({ target: "web" }),
tanstackGrid({ target: "web" }),
],
});A target is { outDir, importBase?, outputLayout?, dbImport? }. outputLayout
and dbImport fall back to the top-level values; importBase does not inherit (it
is each target's own identity). Generators with no target use the implicit
default target (the top-level outDir).
How cross-target imports resolve. Every generated artifact imports the entity
module (queries, routes, hooks, columns, forms). When a generator's target differs
from the entity-module target (the one holding entityFile() output), that import
is emitted as an extension-less package path built from the entity-module
target's importBase — e.g. @acme/database/generated/acme/commerce/Program —
instead of a relative ./Program. Same-target imports stay relative and honor
extStyle. The entity-module target therefore must set importBase whenever
any generator routes elsewhere (the runner errors with a fix-it message otherwise),
and the package it lives in must expose those modules via its exports map (e.g.
"./generated/*": "./src/generated/*.ts").
With no targets and no per-generator target, output is byte-identical to a
single-outDir project.
.metaobjects/config.json — static project state parseable by non-TS tooling:
{
"schema_version": 1,
"pending_in_git": true,
"confidence_thresholds": { "pending_promote": 0.8, "drift_warn": 0.7 },
"sources": [],
"extract": {},
"migrate": {
"outDir": "./.metaobjects/migrations",
"databaseUrl": "file:./local.db",
"onAmbiguous": "abort",
"allow": []
}
}For D1 projects, the migrate block instead looks like:
{
"migrate": {
"dialect": "d1",
"d1": { "binding": "DB", "remote": false, "autoApply": false }
}
}Precedence for meta migrate: CLI flag > env var (DATABASE_URL only) > .metaobjects/config.json > built-in default.
Metadata format
See .metaobjects/AGENTS.md (scaffolded by meta init) for the metaobjects metamodel rules, attribute conventions, and worked examples. Deeper references:
@metaobjectsdev/metadataMETAMODEL.md — full metaobjects metamodel reference@metaobjectsdev/sdkFORGE-METADATA.md — MetaObjects metadata additions
Not yet shipped
meta gen --watch(dropped) — re-run on demand- True 3-way merge in
meta gen— codegen-ts has overwrite/skip-existing only - Module-reference DB connections — URL-only
- Reified SDK APIs for adding/promoting descriptive records — hand-edit JSON for now
License
Apache-2.0.