@metaobjectsdev/cli
v1.0.9
Published
CLI for MetaObjects: scaffold, codegen, migrate, and drift-detection commands.
Maintainers
Readme
@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
# Codegen is OPT-IN: the scaffolded selection is empty.
meta init
# 2. Author entity metadata
$EDITOR metaobjects/meta.myapp.json # see .metaobjects/AGENTS.md for format
# 3. Choose your generators. --probe runs each one against YOUR model and reports
# how many files it would emit, so you can see what your metadata already asks for.
meta gen --list --probe
# 4. Take the ones you want. Prints the import, the entry to wire, and what to install.
meta eject entity queries routes barrel
# Need an output none of them emits (OpenAPI, JSON Schema, a client, docs)? Write your
# own: this scaffolds a working, commented generator and wires it into the config.
meta generator new openapi --scope model
# 5. 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, AGENTS.md, CLAUDE.md, .gitignore, .gen-state/), an empty codegen/generators/ plus its tsconfig.codegen.json, and metaobjects.config.ts at the repo root.
It copies no generators and declares no dependencies. Codegen is opt-in (ADR-0034 Amendment 2): the scaffolded config carries generators: [] and a comment pointing at the catalog. meta gen --list --probe shows what each generator would emit for your model; meta eject <name>... copies the ones you choose into codegen/generators/ — yours to edit — and prints the import line, the entry to add, the install command and any config keys those generators read. meta gen runs your local copies, never the package's.
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— preview only: renders and reports what would be written, touching nothing on disk (no output files, no.gen-state/snapshot)--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 generator new <name>
Writes a generator of your own — the primary way to get an output MetaObjects does not ship (OpenAPI, JSON Schema, a client, a service layer, docs). codegen/generators/<name>.ts is a small, commented generator that already runs: it emits one JSON file per unit describing the model, read through the resolving accessors. The command also adds the import and the <camelName>Generator() entry to metaobjects.config.ts when the config has one literal generators: [...], and otherwise prints the two lines to add. meta gen runs it and meta verify --codegen gates it with nothing to register; change the emit from there.
Flags:
--scope entity|package|model— one file per object (default), per metadata package, or for the whole model--force— overwrite an existingcodegen/generators/<name>.ts--no-wire— write the file only; print the import and entry to add
A reference generator's name is refused — eject that one instead. Every port's shape, and JSON Schema / OpenAPI 3.1 examples to copy, are in the repository guide docs/recipes/write-your-own-generator.md.
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 subtreeQUERY 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,drop-check,drop-view,drop-view-cascade,adopt-view,nullable-to-not-null,drop-identity-default--on-ambiguous abort|rename|drop-add(defaultabort) — non-interactive--rename-table [schema.]old=new/--rename-column [schema.]table.old=new— declare a rename (repeatable), so the migration isRENAMEinstead of drop+add whatever the rename heuristic makes of the names. Refused if nothing matches. See Renames and populated tables.--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. It starts with generators: []; each import below appears when you meta eject that generator, which copies it into codegen/generators/ (ADR-0034 scaffold-and-own) and prints the line to add:
import { defineConfig } from "@metaobjectsdev/cli";
import { entityFile } from "./codegen/generators/entity";
import { queriesFile } from "./codegen/generators/queries";
import { routesFile } from "./codegen/generators/routes";
import { namesFile } from "./codegen/generators/names";
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(), namesFile(), 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 — copied in by `meta eject` (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 sources (sources)
sources is the single authority on where metadata lives. Every read command
(gen, migrate, verify, docs, export) resolves it. When the key is absent or
empty — which is what meta init scaffolds — it takes its default value, the
metaobjects/ directory beside the config, so existing projects are unaffected.
"sources": [
{ "path": "metaobjects" }, // a directory, relative to this config
{ "path": "../model/src/main/resources/metadata" }, // a sibling module — read IN PLACE
{ "path": "vendor/model/meta.catalog.json" } // a single file
]- A relative
pathresolves against the directory holding this.metaobjects/folder, never against the ambient cwd. A directory is walked recursively for.json/.yaml/.yml, skipping_pending/. sourcesis a set — reordering it cannot change what resolves, and two entries may overlap.- A
paththat does not exist is an error (ERR_SOURCE_UNRESOLVED), never a silent skip. { "resource": "…" }and{ "package": "…" }parse but do not resolve yet (ERR_SOURCE_KIND_UNSUPPORTED). The file is validated strictly, so a misspelled key is a load error rather than a silently ignored extra.
Discovery. The CLI walks up from the working directory (--cwd moves the
start) for the nearest .metaobjects/config.json — nearest wins — and stops after a
directory containing .git, so a checkout never adopts a parent checkout's config.
Output scope (scope)
"scope": {
"include": ["acme::billing::**", "acme::common::*"],
"exclude": ["acme::billing::internal::**"]
}Patterns match an object's fully-qualified name (<package>::<Name>). * matches
within one segment and never crosses ::; a segment that is exactly ** matches
one or more whole segments; everything else is literal; absent/empty include
means everything; exclude applies after include; matching is case-sensitive.
The collection always loads in full — scope filters output, never input. It
applies to meta gen and to meta verify --codegen (which regenerates under the
same scope, so a scoped gen is not reported as drift). meta docs and meta export
are deliberately not scoped — they inspect the loaded collection. meta gen
<Entity> arguments intersect with scope; both must pass.
The TypeScript-only per-generator filter function is unchanged and remains the
escape hatch for predicate-shaped filtering.
Per-command scope (migrate.scope)
"migrate": {
"outDir": "./.metaobjects/migrations",
"scope": ["acme::billing::**"]
}An include-only array of the same patterns, for a database this project shares with
another owner. Tables and views whose declaring object falls outside it are neither
created nor dropped — they leave the expected schema and are suppressed on the
actual side — and meta migrate prints how many it left alone. meta verify --db
reports them as out-of-scope instead of as drift. meta migrate baseline is
deliberately unscoped: a --from-db baseline has no metadata provenance to scope by.
Declare a migrate block only in the project that holds .metaobjects/migrations/
and the schema snapshot — that project owns the schema. Run meta migrate from that
directory (or point --cwd at it): migrate.scope comes from the discovered config,
but the rest of the block is read from .metaobjects/config.json in the directory the
command runs in.
Full adopter guide, including vendoring for airgapped builds and a worked polyglot example: docs/features/metadata-sources.md.
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.
