Command line interface — the os CLI reference
Complete guide for using the ObjectStack CLI to build metadata-driven applications
@objectstack/cli
Command Line Interface for building metadata-driven applications with the ObjectStack Protocol.
Installation
pnpm add -D @objectstack/cliThe CLI is available as objectstack or the shorter alias os. Installed as a dev dependency, the bins are project-local — invoke them as npx os … / pnpm exec os … or via your package scripts.
Your First App in 2 Minutes
Create a project
npm create objectstack@latest my-app
cd my-appThis scaffolds a working project with objectstack.config.ts, a sample object, and all dependencies installed — plus the AI skills bundle and an AGENTS.md for coding agents. (os init is the CLI's own scaffolder for metadata-package skeletons and bare configs — see below.)
Add more metadata
os generate object customer # Add a Customer object
os generate flow customer_changed --object customer # Add an automation flow on it
os generate action approve --object customer # Add an action on it that runs the flowEach command writes a file and its export line, and the starter's config already
wires every directory they write into, so all three are part of the stack. The
flow and the action bind to the object --object names, which is why the object
comes first: a scaffold whose object the stack does not declare is refused, with
nothing written. The action runs the stack's only flow.
Launch the dev server
os dev --uiOpen http://localhost:3000/_console/ — you'll see the Console UI with a data browser, metadata explorer, and API documentation. Sign in with the seeded dev admin (admin@objectos.ai / admin123) — os dev provisions it automatically on an empty database. The boot banner also prints the app's MCP endpoint (/api/v1/mcp) so a coding agent can connect to the running app.
Validate & Build
os validate # Check schema + CEL predicates + widget bindings (no artifact)
os compile # Build production artifact → dist/objectstack.jsonThese commands are the AI build loop. In day-to-day work, Claude Code writes the
metadata and runs two of them for you: os validate is the gate (it rejects
predicate/schema/binding mistakes that fail silently at runtime), and os dev --ui is
the human verify surface (the Console, where you confirm the app matches intent). See
Build with Claude Code for the full loop.
os dev --ui starts a dev server with the bundled Console UI, auto-loads ObjectQL, a SQLite database (a persistent project file by default, a throwaway one with --fresh), and the Hono HTTP server.
Commands
Development
| Command | Alias | Description |
|---|---|---|
os init [name] | Initialize a new ObjectStack project in the current directory | |
os dev [package] | Start development mode with hot reload | |
os serve [config] | Start the ObjectStack server with plugin auto-detection | |
os db clean | Reclaim SQLite free space with a one-time VACUUM (ADR-0057) |
os init
Scaffolds a new ObjectStack project with configuration, TypeScript setup, and initial metadata files.
Which scaffolder? Two questions.
1. Metadata, or kernel code? A metadata package is declarative — objects another stack loads, built by
objectstack compile. A kernel code plugin is TypeScript implementing the kernelPlugincontract, built bytsc.2. A whole new project, or an addition to a directory you already have?
| What you are building | Where it goes | Entry point | Why this one |
|---|---|---|---|
| An application — metadata you run | a brand-new project | npm create objectstack@latest <name> — equivalently npx create-objectstack <name> | Derives your namespace, pins the framework packages to the current release, and installs the AI skills bundle + AGENTS.md. Prefer it for anything green-field |
| An application — metadata you run | a directory you already have | os init — or os init -t empty for a bare config | Writes config, TypeScript setup and starter metadata in place. Derives no namespace and installs no skills bundle |
| A metadata package — declarative objects another stack loads, no kernel code | its own project | os init <name> -t plugin | The only scaffolder that emits a manifest declaring type: 'plugin' |
A kernel code plugin — TypeScript implementing the kernel Plugin contract | its own project, or --in-repo inside an ObjectStack monorepo checkout | os create plugin <name> | The only scaffolder that emits a Plugin for you to implement |
The CLI spells two different artifacts plugin; this page does not. The flag stays
-t plugin and the subcommand stays os create plugin — what differs is the noun. A
metadata package is what os init -t plugin writes; a kernel code plugin is what
os create plugin writes. Route by the artifact you want, not by the word.
The word plugin in | Names | What it emits | Built by | Publishable? | Read next |
|---|---|---|---|---|---|
os init <name> -t plugin | A metadata package — declarative objects another stack loads, no kernel code | objectstack.config.ts whose manifest declares type: 'plugin', plus src/objects/*.object.ts | objectstack compile (its build script) | No — the emitted package.json is private: true, and no flag lifts it | os init below, and Object Metadata for the objects it holds |
os create plugin <name> | A kernel code plugin — TypeScript implementing the kernel Plugin contract | src/index.ts exporting a Plugin with init / destroy | tsc (its build script) | Only with --in-repo — the default standalone emission is an unscoped plugin-<name> marked private: true; --in-repo emits a publishable @objectstack/plugin-<name> | os create below, then Plugin Anatomy and Plugin Development |
Every page under Plugins & Packages teaches the kernel code plugin, so
os create plugin is the scaffolder those pages mean — os init -t plugin will not give
you a Plugin to implement, and os create plugin will not give you declarative objects
to compile.
Why the two scaffolders are deliberately separate. Merging the os init and
os create command families was measured and ruled against in
#15531: the two commands
emit two different artifacts, so collapsing a metadata package and a kernel code plugin
under one command word would make this collision structural instead of merely
documented — teaching the wrong artifact to everyone, human or agent, who generates a
plugin from the CLI. The collision, and the misdirection this table replaces, are recorded
in #15817.
os init my-app # Create with default "app" template
os init my-package -t plugin # Create a metadata package project
os init blank -t empty # Minimal config only
os init my-app --no-install # Skip dependency installationOptions:
-t, --template <template>— Template:app(default),plugin(a metadata package),empty--no-install— Skip automatic dependency installation-p, --package-manager <npm|pnpm|yarn|bun>— Package manager to use (auto-detected from the environment)
Templates:
| Template | What it creates |
|---|---|
app | Full application with objects, barrel imports |
plugin | Metadata package: declarative objects, built by objectstack compile, private — not the kernel code plugin os create plugin emits |
empty | Minimal project with just objectstack.config.ts |
The app and plugin templates declare their starter object with
ObjectSchema.create({ … }) — the one authorised shape for a *.object.ts, and the
same one os generate object writes. The factory validates the declaration against
the object protocol when the file is evaluated, so a mistake surfaces in the file
where it was written. A project scaffolded by an earlier release carries a
Data.ServiceObject-annotated object literal instead; converting it is one mechanical
rewrite — wrap the literal in ObjectSchema.create( … ), drop the annotation, and
import ObjectSchema from @objectstack/spec/data.
os dev
Starts development mode. Three usage shapes:
- With local source (
objectstack.config.tsin cwd) — auto-compiles todist/objectstack.jsonif missing, then delegates toos serve --dev. - With a pre-built artifact (
--artifact <path|url>) — skips auto-compile and boots the artifact directly. Noobjectstack.config.tsneeded in cwd. Useful for trying out a published app in dev mode without cloning its source. - Monorepo root (cwd has
pnpm-workspace.yaml) — orchestratespnpm -r devacross packages.
os dev # Auto-compile cwd config, then start
os dev my-package # Workspace package (monorepo orchestration mode)
os dev --ui -v # Dev server with Console UI + verbose
# Boot a remote artifact in dev mode (no local config needed)
os dev --artifact https://raw.githubusercontent.com/<org>/<repo>/main/dist/objectstack.json
# Override storage / auth on the fly
os dev --database file:./data/test.db --auth-secret $(openssl rand -hex 32)Options (the runtime overrides mirror os start — each flag overrides the matching env var):
| Flag | Env equivalent | Purpose |
|---|---|---|
-a, --artifact <path|url> | OS_ARTIFACT_URL / OS_ARTIFACT_PATH | Boot a pre-built artifact directly; skips auto-compile |
-d, --database <url> | OS_DATABASE_URL | file:… / :memory: / libsql:// / postgres:// / mongodb:// |
--database-driver <kind> | OS_DATABASE_DRIVER | Force sqlite | sqlite-wasm | turso | postgres | mysql | mongodb |
--database-auth-token <t> | OS_DATABASE_AUTH_TOKEN | libsql/Turso token |
--auth-secret <s> | OS_AUTH_SECRET | Override the dev-fallback secret |
--environment-id <id> | OS_ENVIRONMENT_ID | Environment identifier (default env_local) |
-p, --port <n> | OS_PORT / PORT | Listen port (default 3000). In dev a busy port auto-hops to the next free one; the banner shows the actual port. |
--cert <path> | — | Path to a TLS certificate (PEM). With --key, terminate TLS in the dev process and serve https://localhost:<port>. Bring your own certificate — none is generated |
--key <path> | — | Path to the private key (PEM) for --cert. Required with --cert |
--ui | — | Force Console UI on (already on by default in dev) |
--compile | — | Force compiling objectstack.config.ts → dist/objectstack.json before starting (auto when the artifact is missing; ignored with --artifact) |
--fresh | — | Ephemeral OS_HOME in the OS tempdir (clean DB, uploads root, and other OS_HOME-keyed state), auto-deleted on exit; implies --seed-admin. See the scope note below |
--seed-admin / --no-seed-admin | — | Seed a dev admin (admin@objectos.ai / admin123) on an empty DB — default on; override with --admin-email / --admin-password |
-v, --verbose | — | Verbose output |
Serving dev over https
An interactive MCP client (and any OAuth client worth the name) refuses to open a
sign-in against a plain-http URL, so the self-serve identity path — interactive
clients just open a browser login — cannot be exercised against a dev server on
http://localhost. Hand os dev a certificate you already have and it
terminates TLS itself:
os dev --cert ./localhost.pem --key ./localhost-key.pemBoth flags are required together, and an unreadable file is refused rather than
quietly downgraded to http. With them, every address this boot advertises is
https://localhost:<port>: the two /.well-known/* discovery documents, the
CSRF allow-list, the ready banner's API: / MCP: rows, the 🤖 MCP server
connect hint, and the runtime state file a supervisor reads. Without them nothing
changes.
Only the built-in default follows the listener. OS_AUTH_URL (and
OS_BASE_URL) still win when set — they name where the deployment is reached,
which behind a proxy or a tunnel is a different address from the one this process
bound — so an explicit value is never rewritten, http:// ones included.
Where the certificate comes from, and which certificates your client or your machine accepts, is yours to decide: ObjectStack generates none and reads no store.
By default os dev keeps your data between restarts in a project-local SQLite
file at .objectstack/data/dev.db (created on first run). Pass --database,
set OS_DATABASE_URL, or use --fresh for a throwaway run.
What `--fresh` covers
--fresh isolates the state the CLI places for the run: everything keyed
off the ephemeral OS_HOME (the dev SQLite DB, the uploads root, plugin state
under OS_HOME) plus the env channels os dev publishes for it —
OS_DATABASE_URL and OS_STORAGE_LOCAL_ROOT. That tempdir is deleted on exit.
It does not relocate state your app reaches by a relative path it
declares itself — for example a datasource with
config: { filename: '.objectstack/data/my.db' }. Such a path is resolved by
its own consumer against the process working directory, which --fresh does
not change, so the file is written into your project tree and is still there
after the run ends. Declare an absolute path (or one derived from OS_HOME)
when you want a datasource to follow --fresh.
With a file-backed SQLite database, dev also provisions a sibling
<db>.telemetry.<ext> file registered as the telemetry datasource —
lifecycle-classed system data (activity streams, job runs, notifications,
audit) lands there instead of the business DB (ADR-0057). Opt out with
OS_TELEMETRY_DB=0, or point it elsewhere (any mode, including serve)
with OS_TELEMETRY_DB=<path>.
Waiting for the boot from a parent process
✓ Server is ready is true about the HTTP server, and deliberately says
nothing about the app's data. Seeding races a soft budget
(OS_INLINE_SEED_BUDGET_MS, default 8000); when it runs long the kernel
starts anyway and the rest of the seed finishes in the background — so the
banner, and anything that waits for it, can be a minute ahead of the seed's own
result. On a machine where the seed fits its budget the same command settles
before the banner. Both are normal, and which one you get depends on the box.
So a script that spawns a dev server and wants to act after the boot has come
to rest should not wait on the banner, and should not need to read the child's
output at all. Spawn with an ipc channel and wait for a message:
| Message | Sent by | Means |
|---|---|---|
objectstack:listening | os serve | The HTTP server is bound. Carries { port, url } — the port actually bound, which in dev may differ from the one requested. |
objectstack:seed-settled | os serve | Nothing is still seeding. Carries { ok, suppressed, sources }. Sent once per boot, always after objectstack:listening. |
import { spawn } from 'node:child_process';
const child = spawn('os', ['dev'], { stdio: ['inherit', 'inherit', 'inherit', 'ipc'] });
child.on('message', (msg) => {
if (msg?.type !== 'objectstack:seed-settled') return;
if (msg.suppressed.length > 0) {
console.log(`boot complete — seeds not run this boot (${msg.suppressed.join(', ')})`);
} else if (!msg.ok) {
console.log('boot complete — but some seed records did not land; see the log above');
} else {
console.log('boot complete — the app is ready to use');
}
});os dev spawns os serve, and the two channels are not symmetric. os dev
consumes objectstack:listening itself — it is how the ↪ server bound to port
line and the MCP connect hint learn the real port — and does not relay it.
It forwards objectstack:seed-settled to its own parent verbatim. Spawn
os serve directly if you need both messages in one place.
An ipc channel is optional: without one, both sends are no-ops and nothing
about the command changes. There is no polling to do — if you did not open the
channel, the messages simply are not sent.
What `objectstack:seed-settled` promises, and what it does not
It is sent when nothing is still writing — on success and on failure, since
a seed that failed has still come to rest. Read ok together with sources
rather than alone: ok is a verdict on the per-source counts the boot recorded,
and a source that finished by throwing may record no counts at all.
suppressed is non-empty when this boot registered a seed source and
deliberately never ran it — multi-tenant-replay (rows are written per
organization on sys_organization insert) or skip-seed-data (a planning boot
that runs no seed). Those sources never settle and no further signal is
coming for them, which is exactly why the message is sent anyway with the reason
attached: a consumer that waited for every source to finish would wait
forever.
os serve
Starts the ObjectStack server with automatic plugin discovery:
- Auto-loads ObjectQL Engine when objects are defined
- Auto-loads InMemory Driver in dev mode
- Auto-loads App Plugin for metadata
- Auto-loads Hono HTTP Server for REST APIs
- Auto-loads the
authtier plugins (@objectstack/plugin-auth,@objectstack/plugin-security,@objectstack/plugin-audit) when the preset includes theauthtier and the user did not pin them inobjectstack.config.ts
os serve # Default: port 3000
os serve -p 4000 # Custom port
os serve --dev # Development mode (pretty logs, devPlugins)
os serve --dev --ui # Dev mode with Console UI
os serve --no-server # Skip HTTP server (kernel only)
os serve --preset minimal # Skip auto-loaded auth/i18n/ui pluginsOptions:
-p, --port <port>— Server port (default: envOS_PORTor3000)--dev— Development mode (loads devPlugins, pretty logging)--ui / --no-ui— Toggle Console UI at/_console/(defaulton)--server / --no-server— Toggle HTTP server plugin--prebuilt— Skip esbuild /bundle-requireand load the config as native ESM (use this in production builds where the config is already pre-compiled)--preset minimal | default | full— Override the auto-registration tier (see below)
Probes. A served process exposes GET /api/v1/health (liveness — process
only, and deliberately blind to configuration and credentials, so a
configuration fault never restarts the pod) and GET /api/v1/ready (readiness —
the full request pipeline, answering 503 while booting, draining, or faulted).
Wire the first to Kubernetes' livenessProbe and the second to
readinessProbe, never the reverse — the field mapping and the reference
manifest live in
Health checks & orchestration.
Tier presets
os serve decides which optional plugins to auto-register from a tier
list. Any plugin already present in config.plugins always wins; tiers
only gate the automatic registration of optional plugins.
| Preset | Tiers | Auto-loaded optional plugins |
|---|---|---|
minimal | core | none |
default (default) | core, i18n, ui, ai, auth | i18n service, Console UI, AI service, Auth + Security + Audit |
full | core, i18n, ui, ai, auth | currently an alias of default — same tiers, no additional plugins |
The auth tier requires OS_AUTH_SECRET to be set; otherwise AuthPlugin
is skipped with a yellow warning and the /api/v1/auth/* endpoints will
return 404. (In --dev mode the CLI falls back to an insecure local secret so
login works out of the box.) To take full control, set tiers on the stack config:
import { defineStack } from '@objectstack/spec';
export default defineStack({
manifest: { /* ... */ },
tiers: ['core'], // disable all optional auto-registration
plugins: [
// ... only what you explicitly want
],
});os db clean
Reclaims SQLite free space with a one-time VACUUM (ADR-0057 §3.4). The
platform reclaims space incrementally (auto_vacuum=INCREMENTAL), but that
setting only takes effect on a fresh database — files created before it
stay pinned at their high-water mark until one full VACUUM rebuilds them.
Non-destructive: every row survives; free pages return to the OS. Cleans
the telemetry sibling too when one exists.
os db clean # default: the per-project dev DB
os db clean --database file:./data/app.db # explicit targetOptions:
-d, --database <url>— SQLite database URL/path (defaults to$OS_DATABASE_URL, then the per-project dev DB)
Console UI
Launch the development server with the Console UI:
os dev --ui # Default: port 3000
os serve --dev --ui -p 4000The Console UI is a metadata-driven admin interface that provides object exploration, package management, and runtime metadata diagnostics.
Architecture:
┌─────────────────────────────────────────┐
│ os dev --ui (:3000) │
├─────────────────────────────────────────┤
│ Hono Server │
│ ├─ /api/v1/* → ObjectStack API │
│ ├─ /_console/* → Console SPA │
│ └─ /* → custom routes │
└─────────────────────────────────────────┘The prebuilt Console bundle ships with the framework packages and is served at
/_console/ — no separate frontend install or build step is needed.
Production
os start
Boots a production server directly from a compiled objectstack.json artifact — no
objectstack.config.ts required. This is the canonical "deploy a built ObjectStack app"
command: hand a server one JSON file (or a URL pointing at one) and it runs. When the cwd
does contain an objectstack.config.ts and no artifact exists yet, os start
auto-compiles it first; with no config and no artifact at all it boots an empty kernel
with the Console + marketplace, so you can install apps interactively.
# Quick start — load ./dist/objectstack.json with sqlite at file:<home>/data/objectstack.db
os start
# Pick everything via flags (no env vars needed)
os start \
--artifact ./build/myapp.json \
--database file:./data/prod.db \
--auth-secret $(openssl rand -hex 32) \
--port 8080
# Remote artifact + Turso/libSQL backing store
# (needs the optional driver package: npm install @objectstack/driver-turso)
os start \
--artifact https://cdn.example.com/app.json \
--database libsql://my-db.turso.io \
--database-auth-token $TURSO_TOKEN
# Postgres
os start --database "postgres://user:pass@host:5432/mydb"
# Pure env-var style still works (Docker / Fly / k8s friendly)
OS_ARTIFACT_PATH=./build/myapp.json \
OS_DATABASE_URL=file:./data/prod.db \
OS_AUTH_SECRET=… \
os startOptions (all override the matching env var):
| Flag | Env equivalent | Purpose |
|---|---|---|
-a, --artifact <path|url> | OS_ARTIFACT_PATH | File path or http(s):// URL to the compiled artifact |
| — | OS_ARTIFACT_URL | Boot a published artifact by reference, optionally content-hash pinned via a #sha256= fragment. See Artifact-pinned boot |
-d, --database <url> | OS_DATABASE_URL | file:… / :memory: / libsql:// / postgres:// / mongodb:// |
--database-driver <kind> | OS_DATABASE_DRIVER | Force sqlite | sqlite-wasm | turso | postgres | mysql | mongodb when the URL is ambiguous |
--database-auth-token <token> | OS_DATABASE_AUTH_TOKEN | Auth token for libsql/Turso |
--auth-secret <secret> | OS_AUTH_SECRET / AUTH_SECRET | Secret for @objectstack/plugin-auth. If neither the flag nor the env var is set, os start auto-generates one and persists it at <home>/auth-secret |
--home <dir> | OS_HOME | Home directory for persistent state (default <cwd>/.objectstack when an objectstack.config.ts is present, otherwise ~/.objectstack) |
--environment-id <id> | OS_ENVIRONMENT_ID | Environment identifier (default env_local) |
-p, --port <port> | OS_PORT / PORT | Listen port (default 3000). Production fails loudly if the port is busy — see note below. |
--ui / --no-ui | — | Mount the Console portal at /_console/. Enabled by default (so you can install marketplace apps); pass --no-ui to disable it. |
-v, --verbose | — | Verbose output |
Port conflicts: production never auto-shifts. Unlike
os dev(which hops to the next free port for local convenience),os startexits with an error if its resolved port is in use. A silently drifted port would break your reverse-proxy upstream,OS_AUTH_URLcallbacks, andOS_TRUSTED_ORIGINS(CORS). Pin the port explicitly (OS_PORT=8080 os start) and keepOS_AUTH_URL/OS_TRUSTED_ORIGINSin sync when you change it.
Resolution priority (artifact): --artifact > OS_ARTIFACT_URL > OS_ARTIFACT_PATH > <cwd>/dist/objectstack.json > <home>/dist/objectstack.json > auto-compile from objectstack.config.ts (when present) > empty kernel.
Resolution priority (database): --database > OS_DATABASE_URL > DATABASE_URL (legacy) > file:<home>/data/objectstack.db.
A named artifact (--artifact or OS_ARTIFACT_PATH) does not participate
in that fall-through: it is used as given, and a local path that does not exist
fails the boot — naming the path and which of the two named it — instead of
quietly continuing down the list. You asked for a specific artifact, so booting
something else (or an empty kernel) would hide the typo behind a running server.
The fall-through applies to the conventional locations only. Remote
(http(s)://) sources cannot be checked up front and are validated when fetched.
The in-memory (mingo) engine is not a boot store. It refuses every
tenant-scoped read, so a server booted on it signed you in and then answered
503 to every data request. Every boot command — os dev, os start,
os serve and every os migrate subcommand — refuses it: the
--database-driver memory flag value (rejected while the flags parse),
OS_DATABASE_DRIVER=memory / mingo / in-memory, and a memory:// or
mingo:// database URL, each with a message naming the replacement. Use SQLite
instead: os dev --fresh for a throwaway database that is deleted on exit, or
--database :memory: (OS_DATABASE_URL=:memory:) for SQLite's own in-memory
database. A datasource you declare with driver: 'memory' still validates;
as the project's default datasource it is refused at boot the same way.
What it boots:
- Reads the artifact's
manifest,objects,views,flows, … - Auto-registers the platform services declared in
requires: [...](e.g.ai,automation,analytics,auth,ui). Declaring a service capability (automation,analytics,ai,audit, …) is a requirement: if its provider package isn't installed, boot fails fast with a clear error instead of silently starting without a capability you asked for. (authanduiare tier-gated with their own opt-in rules —auth's secret-gated skip is described below.) - Auto-detects the driver from the database URL scheme (
libsql:///https://*.turso.*→ Turso — via the optional@objectstack/driver-tursopackage, and a loud failure with the install command when it is missing rather than a fallback to sqlite —,postgres[ql]:///pg://→ pg,mongodb[+srv]://→ MongoDB, otherwise sqlite;:memory:is SQLite's own in-memory database).memory://andmingo://are refused — see the callout above. - Runs standalone boot mode with one active environment.
Authentication:
os start always resolves an auth secret — --auth-secret > OS_AUTH_SECRET /
AUTH_SECRET env > a secret auto-generated and persisted at <home>/auth-secret
on first run — so /api/v1/auth/* (login/register) and the Console's login flow work
out of the box, without any manual secret provisioning. Set the env var (or flag)
explicitly when you deploy across multiple nodes or want to rotate the secret.
os start vs os serve: os serve boots from objectstack.config.ts (TypeScript source).
os start boots from objectstack.json (compiled artifact) and falls back to the same
default-host path if you happen to run it without a config but with an artifact present.
The two commands ultimately go through the same kernel — they just differ in which input
shape they accept. See Source vs Artifact below.
os secret orphans
Reports the sys_secret rows no producer references any more, and — only behind
--delete — removes the ones it can prove are the settings subsystem's to remove.
Report-only by default: without --delete it writes no row and deletes nothing.
It is never run for you; nothing on any boot or upgrade path invokes it.
The report boots your app read-only: no schema change, no seed rows, and a SQLite file
that does not exist is not created. Its first connection can still change a SQLite
file's header; Data migrations says when. Pointed at a database
that lacks sys_secret (one that was never booted, or the wrong --database-url), it
does not read the table, since a table that does not exist holds no row: it reports
nothing to act on, names the tables it did not read, and exits 0. Any other read it
cannot make still refuses and exits 1 (under --json: "error": "scan_failed").
os secret orphans # report (writes no rows)
os secret orphans --json # the same report, machine-readable
os secret orphans --no-declared-datasources # state that this host declares none
os secret orphans --delete --export ./secrets-backup.json --no-declared-datasourcesOptions:
--delete— remove the deletable rows (default off)--export <path>— mandatory with--delete; refuses to overwrite an existing file--declared-datasources <path>— JSON file (an array, or{"datasources": [...]}) of the datasource artefacts this host declares in code--no-declared-datasources— state that this host declares none-y, --yes— skip the confirmation prompt--database-url <url>,--json
A rotation performed before the settings subsystem learned to reap left the previous ciphertext behind, and the reaping that fixed it only fires going forward. This command is how an operator clears that residue deliberately.
A row is removed only when it is attributable to a declared encrypted settings
specifier and named by no family of the complete reference union. sys_secret is
written by three producers and carries no producer column, so "unreferenced by
sys_setting" is not "unreferenced" — a live credential belonging to an object's
secret field or to a datasource can share a settings specifier's (namespace, key).
The command therefore reads all three holder families, and refuses to delete whenever
any of them could not be enumerated, naming the family. A host that says nothing about
its code-declared datasources leaves that family a gap; --no-declared-datasources is
how you state there are none. Rows nothing attributes are never deleted, nor are rows
whose setting still resolves through a legacy inline value, nor rows that were merely
re-wrapped in place (a re-wrap keeps the handle and is not a retirement).
This does not retire an exposed credential, and the export is not optional. On the pre-fix rotation path the handle was never repointed, so the value still in force is the oldest one — the credential the administrator believed they had replaced — while each orphan holds a value that never took effect. If the administrator also rotated at the provider, the newest orphan may be a credential that is still valid there. The audit trail records content digests rather than handles, so a row deleted in error cannot be named afterwards: the export carries the cipher material, is written owner-only, and is read back and checked before any row is removed. Keep it until you are certain.
os secret rewrap
Re-wraps the sys_secret ciphertext sealed before secrets were bound to the producer
that wrote them, so it carries the current binding (ADR-0128). Each row is re-sealed
under the scope of the producer whose holder references it: a setting, an object's
secret field, or a datasource credential. A dry run by default: without --apply
it writes no row. It is never run for you; nothing on any boot or upgrade path
invokes it.
os secret rewrap --no-declared-datasources # dry run (writes no rows)
os secret rewrap --json --no-declared-datasources # the same, machine-readable
os secret rewrap --declared-datasources ./datasources.json
os secret rewrap --apply --no-declared-datasources # write the re-wrapped rowsOptions:
--apply— write the re-wrapped rows (default off)--declared-datasources <path>— JSON file (an array, or{"datasources": [...]}) of the datasource artefacts this host declares in code--no-declared-datasources— state that this host declares none-y, --yes— skip the confirmation prompt--database-url <url>,--json
Run it with the data key the deployment seals with (OS_SECRET_KEY, or the persisted
key file). It never mints a key, and with none it refuses before opening any row.
What a run guarantees:
- The scope comes from the holder, never from a guess. A row that nothing
references, a row whose holders belong to different producers, and every row while a
holder family could not be read are left as they are and counted. Like
os secret orphans, it reads all three holder families, and a host that says nothing about its code-declared datasources leaves that family a gap.--applythen refuses and names the family. - Resumable. A row already sealed under the current binding is skipped as done. A run that stopped part-way finishes the rest when re-run, and a finished run writes nothing.
- Safe against a live deployment. Each row is written in one statement, and only if it still holds the ciphertext the run read. A row a producer changed during the run is not overwritten. It is counted, and a re-run picks it up.
- Fails closed. A row that does not open, or whose re-seal does not open to the same value under the same scope, is not written. The run finishes the rest and exits 1.
The dry run opens, re-seals and verifies every attributed row in memory, so its counts
are the ones --apply would produce. Output is classes and counts only: re-wrap,
done, left (orphan, conflicting scope, union incomplete), refused (unreadable, unknown
derivation, verify failed) and, under --apply, not written (changed during the run,
write failed). It never prints a value, a ciphertext or a row id.
Build & Validate
| Command | Description |
|---|---|
os compile [config] | Compile configuration to a JSON artifact (dist/objectstack.json) |
os build [config] | Alias for os compile (scaffolded projects wire it as npm run build) |
os validate [config] | Validate schema, CEL predicates, and widget bindings — the same gates as os compile/os build, no artifact emitted |
os info [config] | Display metadata summary (objects, fields, apps, agents, etc.) |
os compile
Bundles and validates your objectstack.config.ts against the ObjectStackDefinitionSchema, then outputs a deployable JSON artifact.
os compile # Default output: dist/objectstack.json
os compile -o build/stack.json # Custom output path
os compile --json # JSON output for CI pipelinesOptions:
-o, --output <path>— Output path (default:dist/objectstack.json)--json— Output compile result as JSON (for CI)
Output example:
◆ Compile
────────────────────────────────────────
→ Loading configuration...
Config: objectstack.config.ts
Load time: 57ms
→ Normalizing stack definition...
→ Lowering inline handlers...
→ Validating protocol compliance...
→ Running author-time rules (49)...
→ Checking that every required capability has a provider installable in this edition...
→ Collecting package docs (ADR-0046)... 0 collected
→ Writing artifact...
✓ Build complete (74ms)
Data: 2 Objects 6 Fields
UI: 1 Views 1 Actions
Runtime: 3 plugins
Artifact: dist/objectstack.json (7.6 KB)Those counts are from a sample project, named in full under os info
below — the same fixture backs both output examples. It ships no src/docs/
directory, which is why the docs step reads 0 collected.
The docs step reports what it collected, not what it attempted — a project whose
src/docs/ is empty prints 0 collected here instead of the same sentence a
successful collection prints.
Under an ADR-0130 multi-package layout, docs that moved into a package are
collected too: a <pkg>/docs/ directory whose <pkg> names one of the
artifact's declared packages[] entries is read into that package's own
body, and the step line says so:
12 collected (4 from 2 package directories).
The <pkg> directory is found from the packages the artifact registers, at
any depth under src/: src/<pkg>/docs/ and src/packages/<pkg>/docs/
are one case, not two. The search descends through every directory whose name
names none of the declared packages (never into node_modules) and stops
at the first directory that names any of them. That directory is a package
root and everything under it is that package's own source, so only its own
docs/ is read — a deeper src/<pkg>/components/docs/ is neither read nor
reported. A stack that declares no packages[] has nothing to search for: its
docs are read from src/docs/ alone, and a src/<dir>/docs/ one level down
is reported, not read.
<pkg> is matched against two spellings of the package: its
id, and the last dot-separated segment of that id. The display name is
⛔ not a directory key — it is free to be re-worded, and a docs binding a
re-wording can break is worse than one that never existed.
Docs the search finds but cannot attribute unambiguously are still not read:
each such docs/ directory is reported in a warning that names the files it
skipped, so it is never a silent 0 collected. Three cases lead there:
- a directory the search passes through,
src/packages/itself included, whose name matches none of the declared packages — the warning lists the declared packages; - a directory whose name matches more than one declared package — the warning names those packages;
- one package that answers to more than one directory with Markdown in its
docs/, such assrc/core/docs/besidesrc/packages/core/docs/— ⛔ neither is read and nothing is merged, and each warning names the others.
The resulting dist/objectstack.json is a portable, self-describing deployment unit —
you can hand it to os start (locally or on a server), publish it to a CDN, or fetch it
over HTTP from another runtime. See os start and
Source vs Artifact for details.
os validate
The fast, artifact-free verification gate. It runs the same structural and
semantic checks as os compile/os build but writes no dist/, so it is the
command to run after every metadata edit. Use it before reporting a change done.
os validate # Validate current directory
os validate --strict # Warnings as errors
os validate --json # JSON output for CI
os validate path/to/config # Validate specific fileGates run (each exits non-zero with a located, corrective message):
- Protocol schema — the stack conforms to
ObjectStackDefinitionSchema(@objectstack/spec). - CEL / predicate validation (ADR-0032) — every
visible/disabled/requiredWhen/ validation rule / flow condition / sharing rule is parsed for CEL syntax and checked that eachrecord.<field>reference exists on the target object. This catches a bare field ref (doneinstead ofrecord.done) that would otherwise evaluate tonulland silently hide an action on every record (#2183/#2185). - Widget-binding integrity (ADR-0021) — every dashboard widget's
dataset/dimensions/valuesresolves to a declared dataset/field, so a dangling binding fails here instead of rendering an empty chart.
…and every other author-time rule the three commands share — view shape,
name/action/filter references, page sources, approval approvers, security
posture, the autonumber and view-reference lints. All of them come from one
registry, so the list is the same on os build and os lint; see
The one gate, four doors
for the full matrix. Every failing rule is reported in a single run rather than
stopping at the first, so one pass shows the whole hole.
Options:
--strict— Treat warnings as errors (exit code 1)--json— Output results as JSON
Warnings checked (advisory, non-blocking unless --strict):
- Missing
manifest.id(required for deployment) - Missing
manifest.namespace(required for multi-app hosting) - No objects defined
- No apps or plugins defined
- Every advisory the rule registry raised (dangling
stageField/highlightFieldspointers, replay-unsafe seeds, ambiguous flow status, …)
os validate, os build and os lint share one rule registry, so a config that
passes any of them will not fail another on schema/predicate/binding grounds — a
CLI test fails the build if a rule that can gate runs on fewer than all three
(#4409). In a scaffolded project all three are wired as npm run validate,
npm run build and npm run lint — the three os init templates (app,
plugin, empty) and the create-objectstack blank template each declare all
three scripts; your AGENTS.md tells coding agents to run npm run validate
after editing metadata. See
Validating metadata.
os info
Displays a summary of your metadata without compilation or validation:
os info # Show metadata summary
os info --json # JSON output for toolingOutput example:
◆ Info
────────────────────────────────────────
My App v0.1.0
my-app
Namespace: my_app
Type: app
Data: 2 Objects 6 Fields
UI: 1 Views 1 Actions
Runtime: 3 plugins
Objects:
my_app_note (2 fields, user) — Note
my_app_ticket (4 fields, user) — Ticket
Loaded in 59msThe project behind these counts. The os compile and os info examples above were
run against one fixture, so the numbers can be rebuilt and checked rather than taken on
trust: the project this page scaffolds in
Your First App in 2 Minutes —
npm create objectstack@latest my-app, whose blank starter supplies the two-field
my_app_note object and the three connector plugins — plus the four-field ticket object,
the view and the action listed under
Build with Claude Code,
renamed out of that page's support_desk_ namespace into my_app_. That page's support
app is not added, and a zero count is never printed, which is why the UI: row reads
1 Views 1 Actions with no Apps. The walkthrough's os generate commands are not
part of the fixture — run those as well and the summary gains my_app_customer, a second
action and a Logic: row. Timings are machine identity, and the rule count and artifact size track the
CLI version; everything else is fixture identity and reproduces.
Schema migrations
The metadata→database sync is additive-only: on boot it creates missing
tables, adds new columns and creates missing indexes, but never alters or drops
existing ones. So a non-additive change to an object already backed by a
database — relaxing required (drop NOT NULL), changing a field's
type/length, removing a field, or re-scoping a unique constraint — silently
diverges from the live schema, and the physical column wins at write time.
os migrate reconciles the database to the metadata (the source of truth).
| Command | Description |
|---|---|
os migrate plan | Dry-run: show how the database has drifted from metadata, categorised safe / needs-confirm / destructive (no changes applied) |
os migrate apply | Reconcile the database to metadata. Applies loosening changes; destructive ones require --allow-destructive |
os migrate multi-value-columns | Migrate a stale varchar/text column to json where the field declares multiple: true — one of three drift ops apply never reconciles for you. Dry run by default; --apply runs the statement the finding prints |
os migrate unmapped-columns | Read the values of the columns plan reports as unmapped_column for one object, keyed by record id — the conversion route for a retired field's values before apply --allow-destructive drops its columns. Read-only, and operator-only: no runtime door serves these values |
os migrate plan # Preview drift (no changes)
os migrate apply # Apply safe (loosening) changes, with a confirm prompt
os migrate apply --yes # Skip the prompt (CI / scripts)
os migrate apply --allow-destructive --yes # Also drop orphaned columns, tighten NOT NULL, narrow types
os migrate apply --force # Migrate even though another process is using the database
os migrate plan --json # Machine-readable output
os migrate unmapped-columns --object contact --json # A retired field's stored values, keyed by record id (read-only)Nothing is applied before you confirm
Both commands boot your app to read its metadata. That boot writes no row and no
schema to the target database: the additive schema sync (create missing tables, add
missing columns) and the artifact's inline seed data are deferred, not
performed. So plan really is a dry run, and everything apply is about to do
— additive work included — is on screen before the [y/N] prompt:
New (additive — created when you apply)
+ crm_quote [create_table, 9 column(s)]
+ crm_contact [add_columns: nickname, region]
In place (existing rows converged when you apply)
~ crm_contact [normalize_datetime_storage: signed_at — 1,240 row update(s)]
Safe (loosening — applied without --allow-destructive)
✓ crm_contact.email [relax_not_null]Answering n leaves every table and row exactly as it was. The boot's first
connection can still change a SQLite file's header; Data migrations
says when.
The two upper sections differ in a way worth reading carefully. New is
purely additive — it creates tables and columns and never touches a row. In
place rewrites existing data: the storage-form convergence a Field.datetime
column needs when the database predates the canonical UTC storage (ADR-0053
addendum D-B1..D-B4). It carries a row count because that is the number
deciding whether to run it now; on MySQL it reads widen_datetime_columns and
is an ALTER … MODIFY table rebuild that holds a metadata lock for its
duration.
Both are safe to apply — the convergence preserves every stored instant and is idempotent — but only the second takes time proportional to your data.
Occupancy check (SQLite)
A running os dev / os serve holding the same SQLite file open is the usual
way a migration goes wrong: the migration itself is transactional and swaps
tables inside the file, but the live server keeps prepared statements and a
schema cookie that the migration invalidates, and its writes can collide as
SQLITE_BUSY. Before booting, os migrate checks two things:
- Which processes hold the file open (
/procon Linux,lsofon macOS). The signal that works in every journal mode, and the only one that names the process to go and stop. It is also the only one that sees an idle server on a rollback-journal database, where a lock lasts no longer than the transaction that took it. - A SQL lock probe (
PRAGMA locking_mode = EXCLUSIVEunderbusy_timeout = 0). ObjectStack keeps file-backed SQLite in WAL mode, where this catches any attached connection — idle or not — including the cases step 1 cannot reach: a platform without process inspection, or a database held by another user's process.
Either one firing counts as busy. Both are non-destructive: no row is read or written.
✗ .objectstack/data/standalone.db is in use — it is open in pid 12367 (node).
⚠ Stop the process using it (a running "os dev"/"os serve" is the usual one)
and re-run, or pass --force to migrate anyway.| Command | If the database is in use |
|---|---|
os migrate plan | Warns and continues — a plan writes no row and no schema either way |
os migrate apply | Refuses (exit 1, error: database_busy under --json). Stop the other process, or pass --force |
os migrate files-to-references --apply | Refuses likewise — it rewrites rows, so a concurrent writer is at least as dangerous |
os migrate meta --stored --apply | Refuses likewise — it rewrites sys_metadata rows, and a live process saving metadata is exactly the collision |
The check applies to SQLite only: Postgres and MySQL take their own server-side
locks. Only same-user processes are visible without elevated privileges, and a
-wal/-shm left behind by a crashed process is deliberately never treated as
occupancy on its own.
| Category | Examples | Applied by |
|---|---|---|
safe | relax NOT NULL → nullable, widen a varchar, create a declared index (a UNIQUE one only when its duplicate pre-flight comes back clean), replace a legacy installation-wide unique with its per-organization composite | os migrate apply (and dev auto-reconcile) |
needs_confirm | non-narrowing type change, rebuild a non-unique index whose columns changed | os migrate apply — except manual_column_type_change (only os migrate multi-value-columns --apply runs it), and manual_widen_varchar_to_text and unbuildable_index, which nothing applies |
destructive | drop an orphaned column or index, tighten NOT NULL, narrow a type, rebuild an index as UNIQUE, create a UNIQUE index existing rows already violate | os migrate apply --allow-destructive |
Index drift
plan covers indexes as well as columns:
| Op | What it means |
|---|---|
create_index | Metadata declares an index the database does not have. A UNIQUE one runs the same duplicate pre-flight probe recreate_index does: rows that already violate it block the op with a report naming the conflicting key groups and their row counts, instead of failing a boot on the database's own error, and the declared constraint stays unenforced until they are resolved |
replace_unique_index | A field's unique used to be enforced installation-wide, but metadata now scopes it per organization — the legacy single-column index is swapped for the NULL-safe (COALESCE(organization_id, '__global__'), field) composite. A pure relaxation: it creates before it drops, and cannot fail |
recreate_index | An index exists under the declared name but with different columns/uniqueness. The additive sync skips it by name, so it must be dropped and rebuilt. This is also how a per-organization unique becomes NULL-safe: a tightening, so it runs a duplicate pre-flight probe first — rows the old NULL-distinct index wrongly admitted block the op with a report instead of failing a boot, and the old index stays in place until they are resolved |
drop_index | An index carrying ObjectStack's generated naming (uniq_… / idx_…) that metadata no longer declares |
unbuildable_index | Metadata declares an index whose key column can never exist: the name is not a field of the object (a misspelling), or it is a virtual formula field. Report-only, category needs_confirm: severity error for a UNIQUE index (the declared uniqueness is not enforced), warning for a plain one. os migrate apply never performs it — it reports the entry skipped. Fix the metadata: make every column in the index's fields a stored field, or remove the index. A column that is merely not added yet is pending add_columns work and is not reported |
Orphan detection is deliberately limited to indexes ObjectStack itself
generated. A hand-rolled covering index you added in psql is never reported as
drift, and --allow-destructive will not delete it.
Dev self-heal. os dev runs the SQL driver with autoMigrate: 'safe', so
safe changes (you just made a field optional; a unique field became
organization-scoped) are applied to your existing dev database automatically on
restart — no os migrate needed, no data loss. Auto-reconcile is dev-only and
never destructive; it is force-disabled under NODE_ENV=production, where
every change is shown by os migrate plan before you apply it deliberately.
os migrate only sees objects in your compiled artifact — run os build
first. It never drops a table that is absent from your metadata, and on SQLite
it reconciles via a table rebuild (copy → swap) that preserves your data.
os migrate multi-value-columns
os migrate apply will never apply this drift op — and it isn't the only one: manual_widen_varchar_to_text (an unbounded text-family field left on a pre-existing varchar column) and unbuildable_index (see Index drift) are also never applied, and have no os migrate subcommand of their own. This section covers manual_column_type_change, the op that does.
A field that gains multiple: true over a database that already exists keeps
its old varchar / text column: the additive sync adds columns, and never
changes the type of one that is already there. The write path then stores an
array as the stringified literal '["a","b"]' and reads it back as a
string, so whatever consumes the value receives one opaque id instead of a
list — a hook copying it into a child record's single-value lookup writes the
whole string as one id. os migrate plan reports it as
manual_column_type_change, at severity error and category needs-confirm,
which is why it neither refuses your boot nor is ever reconciled automatically:
changing a column's type on a serving production database, unattended, is not
something the platform will do while you are not watching.
os migrate multi-value-columns # Dry run: the exact statements, executed NOT AT ALL
os migrate multi-value-columns --json # The same, machine-readable
os migrate multi-value-columns --apply # Run them (prompts)
os migrate multi-value-columns --apply --yes --json # CI / scripts
os migrate multi-value-columns --table crm_case # Restrict to one physical table (repeatable)
os migrate multi-value-columns --database-url postgres://…Take a backup first. The dry run is the default and writes no row and no schema
at all — not a probe, not a temporary table — so run it, read the statements it prints,
and only then re-run with --apply.
The statement is the one the drift finding itself prints, per dialect, and the command refuses to run anything else: if the finding no longer contains a statement the command recognises, it says so and tells you to apply the finding's statement by hand rather than falling back to SQL of its own.
| Dialect | What runs |
|---|---|
| PostgreSQL | One ALTER TABLE … ALTER COLUMN … TYPE json USING (CASE …). Legacy single values become one-element arrays (json_build_array, not to_json, which would produce a JSON scalar that is still not an array); an already-stringified array is cast through; NULL and '' both become NULL |
| MySQL | Three statements in order: UPDATE … JSON_ARRAY(…) over the legacy single values, UPDATE … SET … = NULL over the empty strings, then ALTER TABLE … MODIFY … json. MySQL will not cast text to json implicitly, so the rows have to move first or the ALTER dies on the first legacy value |
| SQLite | Nothing — and nothing is needed. SQLite's read path parses the value regardless of what the column calls itself, so the same stale column round-trips a real array. The finding is never raised there |
After a successful run the command re-runs detection and requires the finding to be gone; a run whose statements succeeded while the column is still reported exits non-zero rather than telling you it migrated something it did not.
Rollback. The conversion is not information-preserving: both NULL and the
empty string become NULL, so once it succeeds those two states cannot be told
apart again — restoring your backup is the only faithful rollback, which is
why there is no --undo.
Reverting only the column type (Postgres: ALTER TABLE … ALTER COLUMN … TYPE text USING …::text) leaves JSON text in a text column; metadata still declares
the field multi-value, so the finding returns on the next boot and the
corruption resumes on the next write. Treat it as an incident stopgap, not a
rollback.
On PostgreSQL the whole remedy is one statement: if it fails, the column is untouched and there is nothing to roll back. On MySQL it is three, and DDL there commits implicitly — a failure midway leaves the table partly converted. Re-run the command: each statement skips the rows a previous run already moved, so finishing an interrupted run is safe.
If the ALTER fails naming an index, drop the index on that column first (a
json column cannot carry a plain btree) and re-run, then recreate it in a shape
your dialect supports for json.
Rows corrupted before you migrate the column are yours to repair. This command converts the column and the values in it. A stringified array that a hook or an integration already copied into some other single-value column is not something it looks for, and it is deliberately not something it will grow into: that repair is specific to what your automations did with the value.
os migrate unmapped-columns
Retiring a field leaves its column in the table: the additive sync never drops
one, and os migrate plan reports it as unmapped_column until
os migrate apply --allow-destructive does. In between, the values are still
stored, and no runtime door serves them: a read or a write through the data API
returns the object's declared fields only, and naming the column in fields is
refused. When the values have to move into the field that replaced it, this is
the read:
os migrate unmapped-columns --object contact # The columns, and every record's values
os migrate unmapped-columns --object contact --json > out.json # The same, for a conversion script
os migrate unmapped-columns --object contact --max-records 1000000 --json
os migrate unmapped-columns --object contact --database-url postgres://…The conversion route is three steps: read the values with this command, write
them into the declared fields with your own script, then run
os migrate apply --allow-destructive to drop the columns.
- One column set. It reads exactly the columns
os migrate planreports asunmapped_columnfor that object's table: the same differ, the same boot. Columns the differ never reports are never read either, such as the driver's ownid,created_atandupdated_at. - Operator-only and read-only. It runs under the database credentials you
pass, and covers every organization's rows. There is no REST route or API
flag behind it. It boots the way
plandoes, so it writes no row and no schema, and it drops nothing. - Values as stored. An unmapped column has no declared type, so each value is
emitted as the database client returns it, with no field-type decoding: a
retired
jsonfield on SQLite reads as its stored text, a retiredbooleanas0or1, and a retireddatetimeon PostgreSQL arrives as a date and is emitted as its ISO 8601 text. A value JSON cannot carry as stored (binary bytes, abigint, or a non-finite number) is refused with exit 1, naming the column and the record id, and no record is emitted; read that column with the database's own client. - Empty work, exit 0, for an object with no unmapped column, or with no table
in this database yet.
--jsonprints one document:{ object, table, columns, count, records: [{ id, values }] }. - Refused, exit 1: an object name the deployment does not declare
(
OBJECT_NOT_FOUND); an objectplandoes not diff (federated, or bound to another datasource), whose empty answer would be unmeasured; a read that cannot be complete, such as one stopped by--max-records; and a value JSON cannot carry as stored, described above. A partial set is never emitted, because a conversion over part of a table, followed by the drop, loses the rest.
Data migrations
The commands above reconcile schema. A data migration rewrites rows, and whether it is done is a fact about your database, not about the platform version you installed — so each deployment runs it, and its result is recorded where the data lives.
| Command | Description |
|---|---|
os migrate files-to-references | Convert legacy file-field values to sys_file references, verify the ownership ledger, and record the deployment's migration flag |
os migrate value-shapes | Scan stored reference and structured-JSON field values against the platform's value contract, and record the deployment's migration flag when clean |
os migrate summary-nulls | Backfill roll-up count / sum columns still stored as NULL on parent rows created before the insert-time seed. Repairs values; no flag, nothing depends on it having run |
os migrate meta --stored | Replay the metadata conversion chain over this deployment's sys_metadata rows and rewrite the ones still carrying a pre-protocol shape. Hygiene, not a gate — nothing depends on it having run |
os migrate duplicates | Report business identifiers already minted twice across the organization partitions, and the rows blocking the boot-time NULL-safe index tightenings — a read-only inventory as JSON on stdout. Renumbers nothing and writes no row and no schema; run it before the boot-time tenancy repair, which overwrites part of the evidence |
The boot itself writes no row and no schema you did not ask for. Each of these commands boots
your app to read its metadata. Without --apply, that boot is read-only, the same boot
os migrate plan takes: the schema sync is held back, the app's inline seed data is not
loaded, and a SQLite file that does not exist is not created. With --apply, the boot
creates missing tables and columns so the migration has somewhere to write, but it
still loads no seed data: the only rows that change are the migration's own.
One edge follows from the read-only boot: it finds out which tables the database lacks
(a never-booted database, or the wrong --database-url) instead of creating them.
A read-only data command does not read a table it found missing, since that table
holds nothing, and answers with empty work and exit 0. os migrate value-shapes is
clean over zero records and names the objects it did not read. os migrate recorded-by has nothing to convert and os migrate resume no interrupted runs.
os migrate meta --stored has no stored metadata to examine, os migrate audit-metadata-bodies no audit copy to rewrite, and os migrate account-issuer no
account to collide. os secret orphans, os secret rewrap and os storage orphans
report no secret and no file. A read the command cannot avoid and that fails for any
other reason still refuses and exits 1. Point --database-url at the deployment's
database, or boot the deployment once first, to see what it holds.
The first connection to a SQLite file can change its header. SQLite keeps a file's journal mode in the file itself, and every ObjectStack connection switches a file still on a rollback journal to WAL, whether the boot behind it is read-only or not. That covers every command on this page that boots your app. On a SQLite file no ObjectStack process has opened before (one made by another tool, or before ObjectStack defaulted to WAL), the first connection converts its journal to WAL, whichever command makes it: the file's header changes, and no row or table does. After that, a read-only run leaves the file byte-identical.
--object narrows a run, and only a run over every object records a flag.
files-to-references, value-shapes, summary-nulls and duplicates take
--object to restrict the run to the objects you name. duplicates takes one name,
and the others are repeatable. A name your deployment does not declare is refused
with OBJECT_NOT_FOUND and exit 1 before anything is read or written. The error
names the unknown name and the declared objects, so a misspelling is never answered
as a clean run over nothing. A narrowed --apply applies its fixes to the named
objects. files-to-references and value-shapes then record no deployment flag,
because the flag is a claim about every object's stored data and a narrowed run read
only some. A narrowed files-to-references run does not move the media columns
either. The output says so, a flag that an earlier full run recorded is left as it
was, and --json carries filter: { objects }. Any --object narrows, even a list
that names every object, so run the command without --object to record the flag.
os migrate files-to-references # Dry run: full report, writes no rows
os migrate files-to-references --apply # Convert, verify, record the flag (prompts)
os migrate files-to-references --apply --yes --json # CI / scripts
os migrate files-to-references --object product # Restrict to one object (repeatable); records no flagA file / image / avatar / video / audio field value is an opaque
sys_file id that the platform owns. Values written before that (an inline
{url, name, …} blob, or a URL naming this platform's own
…/storage/files/:id resolver) are converted in place; external URLs are
reported, never re-hosted — re-hosting third-party content is a licensing and
privacy decision, and the right fix is usually to model the field as a url
field instead.
The run then reconciles what records actually hold against what sys_file
records as each file's owner. Zero blocking discrepancies is what records the
flag — and that flag, not the version number, is what enables behaviour that
depends on the data actually being migrated. Never running it is safe: files
are simply retained forever, and media values keep warning instead of failing.
Exit status is 0 only when the self-check passes, so CI can gate on it.
What the flag turns on
| Once verified | Effect |
|---|---|
| Media value shapes | A malformed file / image / avatar / video / audio value is rejected (400 invalid_type) instead of warned about. Set OS_ALLOW_LAX_MEDIA_VALUES=1 to re-open leniency while diagnosing. |
| Released-file collection | A field file whose one owning record lets go (the field is cleared or the record deleted) is tombstoned into the declared 30-day grace window; re-referencing the id within the window revives it, and after it the platform sweep reclaims the row and its bytes. Unverified deployments keep every released file forever. |
Other value classes are unaffected: a lookup or location value keeps its own
warn-first rollout until os migrate value-shapes (below) supplies their
evidence, because this migration is evidence about file values and says
nothing about theirs.
A dry run writes no row — not the conversions, and not the flag either,
even when the self-check would pass. --apply is the only writing mode. A
later run that fails its self-check clears the flag's verified state, so a
database that has drifted closes its own gate.
A running server reads the flag once; after migrating, restart it for enforcement (and release-time tombstoning) to take effect. The sweep's final delete check re-reads the flag fresh, so a later failing run stops collection without a restart.
os migrate value-shapes
The same gate for the non-media value classes — references (lookup,
master_detail, user, tree) and structured JSON (location, address,
composite, repeater, record, vector).
os migrate value-shapes # Scan: full report, writes no rows
os migrate value-shapes --apply # Scan, then record the flag if clean (prompts)
os migrate value-shapes --apply --yes --json # CI / scripts
os migrate value-shapes --object contact # Restrict to one object (repeatable); records no flagThis one converts nothing. Its sibling rewrites legacy file values because
the platform narrowed that storage form and therefore owes the conversion; a
location stored as {latitude, longitude} instead of {lat, lng} is
application data whose correct value only its author knows. So the run reports
— object, field, type, how many records, sample record ids, and the parse issue
— and you fix the values (or the code writing them) and re-run until it is
green. Because there is nothing to convert, the only row --apply writes is the
flag itself.
A scan that is truncated (by --max-records) or that cannot read an object
fails the gate even with zero violations found: "none in the part we read" is
not the claim the flag makes.
| Once verified | Effect |
|---|---|
| Reference + structured-JSON value shapes | A malformed value of those classes is rejected (400 invalid_type) instead of warned about. Set OS_ALLOW_LAX_VALUE_SHAPES=1 to re-open leniency while diagnosing. |
This flag is deliberately separate from the file migration's. That one
attests that file values were migrated and their ownership reconciled — it says
nothing about whether a lookup id or a location payload is well formed, so
it may not vouch for these classes. A deployment can legitimately have passed
either without the other.
OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1 turns on every value class at once,
regardless of which migrations this deployment has run. It is the "I already
know my data" lever, not the route to strictness — the route is running the
migration that produces the evidence.
Same writing rules as its sibling: a dry run writes no row, --apply is
the only writing mode, a later failing run clears the verified state, and a
running server reads the flag once — restart it after a successful apply.
os migrate summary-nulls
A roll-up summary field of function count or sum is 0 over an empty
child collection — zero children is zero, not "unknown" — and since #5749 a
parent row is created holding that value. Rows created before that are the
exception: nothing seeded them, and the recompute that maintains a roll-up runs
only when one of the parent's children is written, so a parent that has
never had a child keeps its NULL indefinitely. filter ["task_count", "=", 0]
then silently omits it, and so do sorting, GROUP BY and any formula reading
the column (null propagation).
os migrate summary-nulls # Dry run: full report, writes no rows
os migrate summary-nulls --apply # Recompute and write (prompts)
os migrate summary-nulls --apply --yes --json # CI / scripts
os migrate summary-nulls --object project # Restrict to one object (repeatable)
os migrate summary-nulls --apply --recompute-undefined-on-empty customer.last_follow_up_at
# Also fill a min/max/avg column you know was never computedEach affected row is recomputed, not set to 0. A pre-upgrade parent that
does have children is NULL too, and its correct value is the aggregate over
them — writing 0 there would replace a missing value with a wrong one, and the
next child write would change it back. The report separates the two: N NULL row(s), M with real child data.
min / max / avg are never touched by default. They are undefined on an
empty set, so a null there is the correct reading of "no child rows"; the
report lists them as deliberately skipped.
The one case that reading gets wrong is a summary field declared after its
parent rows already existed: nothing has ever computed it — the insert-time
seed is create-time, the recompute runs only on a child write — so every
pre-existing parent reads NULL whether or not it has children, and a flow
built on the column matches nothing. The migration cannot tell that NULL
from a legitimate one; the operator (or the publish path) who just declared
the column can. Name it with --recompute-undefined-on-empty object.field
(repeatable) and it is walked like a count: every NULL parent is recomputed
through the same aggregate the engine writes, a parent with no child rows keeps
NULL (that is the aggregate's own value, and it is neither counted nor
written), and the report lists the column under "recomputed on request". A
name that is not a roll-up this run walks — a typo, a plain field, or an object
--object left out — is refused before any row is read. Naming a count /
sum is accepted and changes nothing, so a caller can pass every column it
just declared.
Idempotent — every write turns a NULL into a number, so a second run finds
nothing and writes nothing. Re-running until the report says zero is the
verification, which is why this command records no flag: it repairs values and
changes no behaviour, so there is no posture for a flag to attest.
A deployment whose database was seeded fresh on this version has nothing to do here: its parents were created with the value already in place, and the run reports zero.
A database created by this version needs no migration
A deployment whose database the platform creates from empty records these flags at that moment, so it is enforcing from its first boot and never enters the warn regime at all. Nothing to run: the fact a migration would establish — no legacy value is stored here — is already settled by the store having no history.
The platform attests this only for a store it watched itself create: every table made by that first boot, none found already present. A database that existed before — an upgrade, a restore, a store shared with anything else — attests nothing and produces its evidence by running the command, because "found empty" and "created empty" are not the same claim.
Importing legacy values into such a deployment is rejected at the write path
rather than silently accepted. That is the intended outcome; if you must admit
them temporarily, OS_ALLOW_LAX_MEDIA_VALUES=1 (media) and
OS_ALLOW_LAX_VALUE_SHAPES=1 (references and structured JSON) re-open leniency
per class, and re-running the corresponding migration re-establishes its flag
from the data itself.
How you find out a gate is open
You are told, in the two places an upgrade actually looks.
os migrate meta --from 16 — the metadata half of the upgrade — ends by
naming the data migrations that remain, scoped to the field classes your
metadata declares. It reads no database, so it reports what is left to do,
never what this deployment has already done. The machine-readable output
carries the same list under dataMigrations.
The server, once per boot, logs one line per gate that is still open here
and the command that closes it. Only the lax posture announces itself: a
closed gate logs that it is enforcing, and an app that declares neither class
of field says nothing at all. So a running deployment always tells you the
state of its own data — which is the question os migrate meta cannot answer.
os migrate meta --stored
The two commands above are about application data. This one is about the
metadata itself, at rest: the sys_metadata rows Studio and the runtime
authoring APIs write.
Those rows already read correctly whatever protocol they were written under — every rehydration seam replays the full conversion chain, so a body from an older major is served in today's canonical shape and always will be. What the rows do not do is change: they keep their original bytes, the chain re-lowers them on every load, and each one logs a conversion notice once per boot. This command ends that for the deployment that runs it.
os migrate meta --stored # Preview: per-row report, writes no rows
os migrate meta --stored --apply # Rewrite the rows (prompts)
os migrate meta --stored --apply --yes --json # CI / scripts
os migrate meta --stored --type view --type object # Restrict to a type (repeatable)It walks active and draft rows across every organization (archived rows are
a record of what was and are never read), replays the same chain the read path
does, and re-saves each changed body through the normal write path — so a
rewritten row gets a sys_metadata_history entry, a fresh checksum, and the
mutation projectors, exactly like an author's save. The history entry's source
is migrate-stored, so a later diff shows which changes were an upgrade and
which were somebody's edit.
What it deliberately declines, and names in the report rather than
counting as done. This table is the operator-observable surface — what a
run can actually report, from the command above or the route below. The
function's own JSDoc in packages/metadata-protocol/src/protocol.ts documents
its full internal surface instead, and so lists one decline more: flow rows
skipped for want of a reachable automation engine, which neither operator door
can produce, because both supply a live one.
| Not rewritten | Why |
|---|---|
A row stored under a non-canonical metadata type (a plural or alias spelling, e.g. fields) | Canonicalizing bodies is an edit; rewriting a stored type spelling is an identity move — a new (org, type, name, package_id) key — which this pass is not ruled to make. Re-author the item under its canonical type and drop the old row |
Types with no repository write path (agent) | Their write path records no history and would force a draft live — a half-write is worse than leaving the row to the read path |
| Rows that still fail the current schema after conversion | That is a genuine contract violation, not chain-owned history. The write path's rejection is correct; fix the row in Studio |
| A flow whose rename the conflict guard refused | The old node-type token is a live name something else owns here. Rewriting would clobber that owner, so the row fails loudly naming the token — never a silent skip |
A site the conversion chain leaves as stored because no lossless rewrite exists — above all a page filter carrying $and / $or / $not | Flattening a combinator changes which rows the page selects, so it is never done. Each site is printed as a TODO line under its row — path, block, and what blocks the rewrite — whatever the row's outcome; a row with nothing but TODOs is reported skipped. It does not fail the run, since no run of this pass can clear it: rewrite each site by hand |
One thing it lists and never writes: decision nodes that changed meaning at
protocol 18. A stored decision with no config.conditions, no mode and two
or more out-edges carrying a condition took every out-edge whose condition held
before protocol 18, and takes only the first one now. A stored row keeps that new
meaning — the conversion that writes mode: 'inclusive' replays over authored
sources only, where you assert the source's age, and nothing asserts a row's — so
the report lists each such node under decisionModeReview (flow row, node id,
label and path) for you to review before and after the upgrade, on a preview and
an --apply run alike. The list changes no row, no count and no exit code. Where
a node meant every branch, declare mode: 'inclusive' on it; declaring mode
either way takes it off the list.
Flows are covered, and cost one extra plugin. Flow-node conversions carry an
open-namespace conflict guard that has to consult the live executor registry
to tell a rename from a clobber, so this run boots the automation engine — in an
inert mode that installs the node registry and then arms nothing: no flow
registered, no record trigger or scheduled job bound, no connector
materialized, no suspended run resumed. A migration process must not become a
second server. What gets written back for a flow is the conversion result plus
the condition envelopes the schema derives, and deliberately not the
schema's defaults (version, runAs, per-edge type) — persisting a default
the author never wrote would pin that row to today's value while untouched rows
follow tomorrow's, which is the drift this command exists to remove.
--apply is the only writing mode, and it rewrites metadata — each affected
row's checksum moves and each gets a history entry. Preview first. Like the
other row-rewriting migration, an apply run refuses to start while another
process holds the SQLite database (--force overrides).
Nothing gates on this having run. The read path is the guarantee, for every
deployment, whether or not anyone runs this — an operator-run migration is not
something the platform can depend on. What running it buys is hygiene (cleaner
diffs, exports and history from here on, and the recurring boot notices go
quiet) plus one thing that was previously unobtainable: you can assert it.
A run with nothing left to do exits 0; a deployment with rows still carrying
an old dialect this pass can convert exits 1. So "my metadata is on protocol N"
becomes a check rather than a belief.
Note the division of labour with the default mode: os migrate meta --from N
lists the edits an author's source needs — with --write, it also writes the
ones it can trace to one literal into the source files — and reads no database; --stored
rewrites one deployment's rows and reads no config. Same chain, opposite
ends of the contract — which is why the two modes are mutually exclusive.
Without shell access, use the route. This command needs to reach the deployment's database directly, which a hosted operator cannot do. The same pass is exposed over HTTP:
POST /api/v1/meta/_migrate-stored
Content-Type: application/json
{ "apply": true, "types": ["flow"] }or from the SDK:
const preview = await client.meta.migrateStored(); // writes nothing
const result = await client.meta.migrateStored({ apply: true });It returns the same report the CLI renders, and takes the same posture:
preview unless apply is literally true, types optional. It requires the
manage_metadata capability — it rewrites every eligible row in the deployment,
not one item — and answers 403 otherwise. Flows need no extra setup on this
path: the server already holds a live automation engine, so the run resolves the
executor registry the conflict guard needs from the process it is running in.
os migrate duplicates
The one command in this family that is not a migration: it writes no row and
no schema under any flag, and there is nothing to apply. It inventories business
identifiers the platform already handed out twice — one value held by rows in
more than one of the organization partitions a
unique: 'organization' index separates.
The gap it reports is a real one and predates the repair for it. A seeded row
written before any organization existed carries organization_id = NULL, an API
row carries the signed-in organization, and the partitioned unique index
(COALESCE(organization_id, '__global__'), field) does not bite across the two
— so each side allocated from its own autonumber counter and both could mint
CASE-00001.
os migrate duplicates # The report — JSON on stdout
os migrate duplicates > duplicates-2026-08-18.json # Archive it; the file is the deliverable
os migrate duplicates --object crm_case # Restrict the scan to one object
os migrate duplicates --database-url postgres://… # Inspect a database directlyOutput is always the JSON document. There is no --json flag and no
human-rendered mode — the report is the deliverable, you archive it, and a
second renderer would be a second contract to keep true. The boot behind it is
read-only: no DDL, no seed, and a missing SQLite file is not brought into
existence. A full run leaves the rows and the counters byte-identical, so
pointing it at production changes no data in production. A SQLite file
ObjectStack has already switched to WAL comes out byte-identical too; on a file
still on a rollback journal, the first connection changes the file's header, as
Data migrations describes.
Run it before the repair reaches this deployment. On its first boot after the upgrade, a single-organization deployment adopts those untenanted seed rows into its organization and merges the two counters — which is the same state this report reads. Half of what it can tell you does not survive that:
| Reported | Survives the repair? |
|---|---|
| The duplicates themselves — every value held across two partitions, with the id, organization and creation time of each holder | Yes. The repair deliberately refuses to adopt a row whose identifier is already taken in the destination partition, so those rows keep organization_id = NULL and stay visible |
| The live condition — an object still running a global counter beside an organization-scoped one, and therefore about to mint more duplicates | No. The repair merges the two counters and deletes the global one. Once that has happened, this line can never be produced again |
Running it afterwards is still worth doing — the inventory is what you act on, and it is complete either way. What you cannot recover is the forward-looking half.
A deployment holding more than one organization is skipped by that repair rather than guessed at: there is no derivable answer to which organization owns an untenanted row, so it logs the condition and the remedy and changes nothing. Its evidence therefore stays intact, and this report stays reproducible until somebody stamps those rows by hand.
Nothing is renumbered, here or by the repair. A business identifier that has already left the building — on an invoice, in a notification, in another system's idempotence key — is not the platform's to rewrite, so both sides report and stop. Deciding what a duplicate should become is yours.
The scan covers every organization-scoped object and, on it, every field that
is an identifier: type: 'autonumber', or carrying any unique spelling.
Platform objects are not filtered out — that filter is right for a repair
and wrong for a report, which must not silently omit a real duplicate. Anything
that could not be probed is listed under skipped with its reason, because
"found nothing" and "never looked" must not read the same. For the same reason
a driver with no raw SQL seam (memory, MongoDB) fails the whole run with
error: "no_sql_seam" rather than returning an empty inventory.
The kernel:ready index pre-flight
The report carries a second section, runtimeIndexPreflight, answering a
different question: will the next server start be able to finish tightening
the platform's own unique indexes?
Three migrations run at kernel:ready on a serving boot (os dev, os serve,
os start) and replace a declared UNIQUE index with the NULL-safe — and
sometimes active-rows-only — form it was always meant to have:
| Table | Index | What the tightening adds |
|---|---|---|
sys_metadata | overlay active and draft | package-less overlays stop being NULL-distinct |
sys_view_definition | idx_sys_view_def_active | shared and environment-level views stop being NULL-distinct, and only active rows are constrained |
sys_setting | the declared row identity | tenant- and global-scope rows stop being NULL-distinct on user_id |
Each is a tightening, so rows an installation already holds can block it.
When that happens the migration refuses — the previous index stays in place, no
row is touched, and the server keeps running — and reports it at error in the
boot log. Until this section existed that log line was the only channel: these
indexes are invisible to os migrate plan by construction, because the drift
reconciler deliberately excludes runtime-managed indexes (otherwise the next
boot would propose rebuilding away the guarantee it just created), and because
each migration reuses the declared index's name, so the reconciler's slot for
it reads as correctly filled whichever form is physically there.
So the pre-flight lives here instead, on the command that already boots read-only and repairs nothing. It runs the migrations' own duplicate-listing queries — the exact statements the boot log prints — and reports one entry per index:
status | Meaning |
|---|---|
blocked | Rows collide under the tightened key. groups lists each colliding key and how many rows hold it. The next serving boot will refuse this index |
clear | The probe ran and nothing collides |
table-absent | The table is not installed here. sys_setting, for instance, arrives with the optional settings service |
unreadable | The probe could not run; detail carries the driver's message |
summary.runtimeIndexesBlocked and summary.runtimeIndexBlockingRows are the
same finding counted at the head of the document.
Read blocked as work to do before the restart, not damage: nothing is
lost while an index stays untightened, but the guarantee it carries is not in
force until the listed rows are resolved — and only an operator can decide which
of two colliding rows survives, which is why the platform refuses rather than
picking one.
--object does not narrow this section. It is a fixed set of platform indexes
rather than a slice of your registry, and filter describes the object scan
only.
Scaffolding
| Command | Alias | Description |
|---|---|---|
os generate <type> <name> | os g | Generate metadata files |
os create <type> [name] | Scaffold a standalone kernel code plugin project |
os generate (alias: os g)
Generates properly typed metadata files with barrel index management.
<name> is held to the charset @objectstack/spec declares for an object
name — lowercase letters, digits and _, never starting with a digit. A name
outside it is refused before anything is derived or written, naming the value
and the rule; it is never rewritten into one that fits, so the name you
write is the name that lands in the file (os g object order_line, not
order-line). The one addition is the namespace prefix below.
Object names carry the project's namespace. When the project config
(objectstack.config.ts, .js or .mjs, the same files os validate
auto-detects) declares manifest.namespace, every object name a scaffold
writes starts with <namespace>_, because os validate refuses an object name
without that prefix. This covers the object scaffold's own name, and the
object that a view, action, flow or app scaffold binds to. Under
namespace: 'my_app', os g object order_line writes
name: 'my_app_order_line' into src/objects/order_line.object.ts and prints
the object name. A name that already carries the prefix
(os g object my_app_order_line) is written as typed, never doubled. A
platform-reserved sys_* name is exempt from the rule, so it is not prefixed
either. A name in the legacy <namespace>__<name> form (order__line) is
refused, because no prefix makes it one os validate accepts. The namespace is
read from the loaded config, the same place os validate reads it from. If a
config exists but does not load, the command refuses and writes nothing.
Without a namespace (or without a config), nothing is prefixed. Nothing else a
scaffold names (an action's, flow's or app's own name) is prefixed.
What a scaffold binds comes from you or from the stack, never from its
name. A view, action, flow or app scaffold refers to metadata the
project already has, and each reference is checked against the stack the
config loads before anything is written:
- A view is named after the object it binds:
os g view customerwrites the views of the objectos g object customerwrites, prefix included. The container carries nonameorlabelof its own (the server registers it under its object), and its list shows every field the object declares, under alist.labelthatos lintrequires. - A flow, action or app binds the object you name with
--object— as declared (my_app_customer) or without the namespace prefix (customer). Without--object, it binds the stack's only object. - An action runs the flow you name with
--flow, or the stack's only flow.
When the stack declares none, or several and you named none, or the one you
named is not declared, the command refuses, lists what the stack does declare,
and writes nothing: which object a scaffold acts on is yours to say. Outside a
project (no config) a scaffold that binds is refused too, because there is no
stack to check it against. --object and --flow on a type that takes neither
are refused rather than ignored.
Every scaffold reaches the stack, or the command says it does not. The
objectstack.config.ts that os init writes for the app and plugin
templates, and the one the npm create objectstack starter ships, wires every
directory in the table below: it imports each
src/<dir>/index.ts barrel and hands its exports to defineStack under the key
in the Collected as column, so a file os g writes there is part of the
stack with no edit to the config. It also declares
requires: ['automation', 'triggers'], which a flow needs to load and to run.
After writing, os g loads the config again and says which of these holds:
- Reached: the stack carries the new item, so
os validatecounts and checks it. - Not wired: the config loads and its stack does not carry the item, or
there is no config. This is what happens with a config that imports
./src/objectsalone, as projects thatos initandnpm create objectstackscaffolded in earlier releases do. The file is written, the config is left as it was, and the command prints the import and thedefineStackkey that wire the directory. - Refused: the config loaded before the command wrote anything and no
longer loads with the new file in place, because the stack refuses it — for
example a flow in a stack whose
requireslackstriggersorautomation(defineStackrefuses a record-change flow without both). The command removes what it wrote, so the project is as it was, and exits 1 with the stack's own reason. Declarerequires: ['automation', 'triggers']beforeos g flow.
os g never edits objectstack.config.ts: the config is yours, and the
command only loads it.
os g object customer # Generate a Customer object
os g view customer # Generate the Customer list view
os g flow customer_changed --object customer # Generate a flow that runs when a Customer changes
os g action approve --object customer # Generate an action on Customer records that runs the flow
os g dashboard sales # Generate a dashboard
os g app sales --object customer # Generate an app whose navigation opens Customer
os g skill lead_qual # Generate an AI skill
os g picklist industry # Generate a shared option list select fields name
os g action escalate --object customer --flow customer_changed_flow # Name the flow when there are several
os g object task -d lib/ # Override target directory
os g object task --dry-run # Preview without writingAvailable types:
| Type | Default Directory | Written as | Collected as | Description |
|---|---|---|---|---|
object | src/objects/ | NAME.object.ts | objects | Business data object with fields |
view | src/views/ | NAME.view.ts | views | List or form view definition |
action | src/actions/ | NAME.action.ts | actions | Button or batch action |
flow | src/flows/ | NAME.flow.ts | flows | Automation flow |
dashboard | src/dashboards/ | NAME.dashboard.ts | dashboards | Analytics dashboard |
app | src/apps/ | NAME.app.ts | apps | Application navigation |
skill | src/skills/ | NAME.skill.ts | skills | AI skill — the ADR-0063 extension primitive |
picklist | src/picklists/ | NAME.picklist.ts | picklists | Shared option list that select fields reference by name |
Why generated files carry a type infix
Every scaffold is written as NAME.TYPE.ts, and the infix is read from the
metadata type registry rather than chosen by the CLI: each type declares its
own file convention there (*.object.ts, *.view.ts, *.skill.ts, …), and
the metadata loader discovers files by globbing exactly those patterns.
A file matching none of them still type-checks, still passes os validate and
still publishes — and is then never loaded, with nothing at any step reporting
that it was skipped. This is also the shape the example apps already author in
(account.object.ts, lead.view.ts).
Files you generated earlier are unaffected. Nothing is renamed, and a
NAME.ts file that your app imports through its barrel index.ts keeps
loading exactly as it did. If you want it discovered by the loader's own glob
as well, rename it to match its type's pattern.
`os g agent` is retired
There is no agent type. Running os g agent <name> fails with a message
naming ADR-0063
and pointing at skills, rather than the generic "unknown type" listing.
Agents are platform-internal: the kernel ships exactly two (ask and
build), and the runtime catalog filters out every other agent record. A
scaffolded src/agents/*.ts therefore passed os validate, published without
complaint, and never appeared — silently. Skills (plus tools / MCP) are the
third-party extension primitive, authored as src/skills/<name>.skill.ts with
defineSkill; see AI Agents. Scaffold one with
os g skill <name>, which writes exactly that path.
`os generate schema` is retired
There is no schema type. Running os generate schema (or os g schema) fails
and writes nothing, not even objectstack.schema.json. The message says it was
retired by maintainer ruling and points at os validate and the
per-type schemas @objectstack/spec publishes, rather than the generic "unknown
type" listing. No replacement file is generated.
The file it wrote described only the shape of a stack. Every rule the platform
enforces beyond that shape (a non-blank string, a required one-of, a banned key)
was missing from it, so an editor showed a config as valid that the platform then
refused. objectstack.config.ts needs no JSON Schema: defineStack types it in
your editor. Check a project against the rules that actually run with
os validate. For JSON metadata, point your editor at
node_modules/@objectstack/spec/json-schema/<category>/<Type>.json, which states
the rules a JSON Schema can express and names the rest under
x-dropped-refinements.
Options:
-d, --dir <directory>— Override target directory--dry-run— Preview without writing files--object <object>— The object aflow,actionorappbinds; default: the stack's only object--flow <flow>— The flow anactionruns; default: the stack's only flow
What it does:
- For a type that names an object (
object,view,action,flow,app), readsmanifest.namespacefrom the project config (objectstack.config.ts,.jsor.mjs) and prefixes the object name with it, and resolves every object or flow the scaffold binds against the stack that config loads (see above) - Creates the TypeScript file — an
objectdeclared withObjectSchema.create({ … }), the same shape theos inittemplates write; askilldeclared withdefineSkill({ … }); apicklistdeclared withdefinePicklist({ … }), which a select field names withField.select({ picklist: 'NAME' })in place of its ownoptions, and which the server resolves into that field'soptions; the other types as typed literals (UI.View,UI.Action,Automation.Flow,UI.Dashboard,UI.App) - Creates or updates the barrel
index.tsin the target directory - Shows a hint to run
objectstack validate
os create
Scaffolds a standalone kernel code plugin project (the Plugin contract,
built by tsc; not the metadata package os init -t plugin emits — see
Which scaffolder?) into the current directory:
os create plugin analytics # Create ./plugin-analytics
cd plugin-analytics
pnpm install
pnpm build`os create example` is retired
Use os init to scaffold an application. os create example emitted a
subset of what os init writes plus one README, so it was withdrawn in
#16483 rather than kept as
a second, weaker way to do the same thing — with no alias and no deprecation window.
Running it now exits non-zero and names os init.
os init my-app # a full application project
os init my-app -t empty # config only, no src/objectsThe emitted package.json declares its @objectstack/* dependencies as
published semver ranges pinned to the version of the CLI that generated it, and
the emitted tsconfig.json is self-contained, so the project installs and
builds anywhere — a workspace around it is neither needed nor assumed.
That manifest is named plugin-<name> — unscoped — and carries
"private": true, because @objectstack is a scope the developer this command
scaffolds for cannot publish to: an accidental npm publish is refused loudly
instead of being aimed at a namespace you do not own. To publish it yourself,
rename it under a scope you control and drop that flag. Only --in-repo below
emits a scoped, publishable @objectstack/plugin-<name>, and it lands under
packages/plugins/ where every sibling genuinely carries that scope.
Options:
-d, --dir <directory>— Write the project here instead of./<name>--in-repo— Scaffold inside an ObjectStack monorepo checkout instead (packages/plugins/plugin-<name>), withworkspace:*dependencies and atsconfig.jsonthat extends the repository root config. For ObjectStack platform work only: the project it writes installs nowhere else, and the command refuses the flag when the current directory is not a pnpm workspace root.
Quality
| Command | Description |
|---|---|
os lint [config] | Every author-time gate validate/build run, plus style and convention checks |
os verify | The author-time rules validate runs, then boot the app in-process and verify it through the real HTTP stack |
os test [files] | Run Quality Protocol test scenarios against a running server |
os doctor | Check development environment health |
os lint
The cheapest of the three author-time commands. It runs the same rule registry
os validate and os build run — so anything that can fail a build fails here
too — and adds its own style rubric: naming, labels, namespace prefixes,
data-model conventions, translation coverage, with a 0-100 quality score.
os lint # Author-time rules + style / convention checks
os lint --score # Append a 0-100 metadata quality score (letter-graded)
os lint --fix # Show what would be fixed (dry-run)
os lint --strict # Warnings fail the run too (exit 1); suggestions stay advisory
os lint --json # JSON output for CI
os lint --skip-i18n # Skip the translation coverage checks entirely
os lint --include-platform # Audit the platform built-in i18n keys too
os lint --eval --eval-min 80 # Score the bundled generation corpus insteadOptions. Every flag the command declares, and its one positional:
| Flag | What it does |
|---|---|
config (positional) | Configuration file path. Omitted, the config is auto-detected — see Config File Auto-Detection |
--json | Output as JSON, for CI. The verdict fields are described below |
--fix | Show what would be fixed (dry-run). It prints the suggested fix; it never writes your sources |
--strict | Fail the run (exit 1) on warning-severity findings too, exactly as an error does; suggestions stay advisory. Without it only errors fail |
--score | Print a 0-100 metadata-quality score (the lint rubric) for this project |
--skip-i18n | Skip translation coverage checks |
--include-platform | Also report i18n coverage for the platform built-in metadata forms — hidden by default because the platform packages ship those translations. This is the flag the N i18n issue(s) hidden — rerun with --include-platform hint names |
--i18n-strict | Treat missing translations in non-default locales as errors |
--default-locale <code> | Default locale for i18n coverage (the one that must be 100% translated). Defaults to the config's i18n.defaultLocale, else en |
--eval | Run the metadata-generation eval over the bundled golden corpus and report scores — a different run, see below |
--eval-min <n> | Minimum passing score per eval case (default 75) |
--generator <path> | Path to a module that default-exports (prompt, id) => stack; enables live eval, scoring generated output instead of fixtures. Requires --eval |
--eval is a different run, not an extra check. It short-circuits the
project lint entirely and scores the bundled generation corpus, so it does not
combine with the style flags above. --generator applies only inside it:
passed without --eval the run exits 1 and says so on both faces, rather
than accepting a flag nothing outside eval mode reads (which is what it did
before #15550 — including for a generator path that did not exist).
It does not replace os validate: os lint never parses the stack against the
Zod schema (a schema error is os validate's verdict to give), and it emits no
artifact. What it does guarantee is the direction that matters for a pre-flight
— a green os lint is not followed by a red os build. That was not true
before #4409: os lint ran one gating rule neither other command ran and missed
six that both of them ran, so it disagreed with the build in both
directions.
What fails the run. By default only an error-severity finding fails
os lint (exit 1); warnings and suggestions are printed and the exit code stays
0. --strict makes a run with one or more warning-severity findings exit 1
exactly as an error does, and says why — N warning(s) fail this run under --strict — so an app can rely on the platform's warning-level rules as its gate
instead of re-implementing them locally; suggestions stay advisory either way,
and the default is unchanged by the flag's existence. On the --json face the
verdict is readable without re-deriving it from the counts: strict (whether
the flag was in effect), failing (the count the exit code was read from —
errors, or errors + warnings under --strict) and passed (failing is
0 — the same statement the exit code makes).
os verify
The done-bar: os verify green means the author-time rules pass and the
app behaves at runtime. It runs two stages, in order, and the second starts
only when the first passes.
os verify # Author-time rules, then CRUD round-trip fidelity
os verify --app ./objectstack.config.ts --rls # Also prove the cross-owner RLS invariant
os verify --rls --multi-tenant --json # Org-scoped boot, structured report- Author-time rules. The rule registry
os validateruns, over the stack prepared the wayos validateprepares it: normalized, inline handlers lowered, parsed against the protocol schema, and judged whole and then once per package of a multi-package artifact. A stack that does not parse, or that any gating rule refuses, fails the run here with those findings — the same onesos validatereports — and the app is never booted. Advisories (warningandinfofindings) never failos verify; the text face counts them, andos validateprints them. This stage is the rule registry, not all ofos validate: thedefineStackprovenance refusal (a config whose default exportdefineStackdid not build,STACK_PROVENANCE_MISSING), the package-docs lint, the capability-provider check, the picklist-reference and view-container-name checks and--strictstayos validate's, so run it too. - Runtime. Boot the app in-process and exercise it through the real HTTP
stack: for each object, a record derived from its fields is created, read
back and compared (CRUD round-trip fidelity), and a create, read or
fidelity failure fails the run. With
--rls, a second fresh boot proves the cross-owner invariant — a member must not write what it cannot read — for a probe persona and for one persona per declared position, and a hole fails the run.--multi-tenantboots org-scoped so tenant-isolation RLS policies apply; a walled tenancy posture (OS_TENANCY_POSTUREset toisolatedorgroup) asks for the same boot.
The config is the one --app names, or the auto-detected one — see
Config File Auto-Detection.
Exit status.
| Exit | Meaning |
|---|---|
0 | Both stages passed: no gating author-time finding, and no runtime failure |
1 | The author-time stage refused the stack (the runtime stage did not run), the runtime stage found failures, or the command could not run at all (no config found, a config that does not load, a boot failure) |
--json. stdout carries exactly one JSON document and nothing else, so
os verify --json > report.json leaves a file JSON.parse reads whole. Every
other line the run produces — the booted stack's log records, warnings
included, and its boot and shutdown lines — goes to stderr; none is dropped.
Without --json, those lines stay on stdout beside the text report. What the
document carries depends on where the run ended:
- Refused by the author-time stage:
{ "error": "<sentence>", "errors": [...] }, whereerrorscarries the findings in the shapeos validate --jsoncarries them undererrors— the gating findings (severity,rule,where,path,message,hint, pluspackagefor a finding raised inside one package of the artifact), or the schema issues when the stack does not parse. - Reached the runtime stage: the report —
app,config,multiTenant,crud,rls(with--rls) andhardFailures, the count the exit status is read from. - Could not run:
{ "error": "<sentence>" }, pluscodeandhttpStatuswhen the failure carries them.
os test
Runs Quality Protocol test scenarios (JSON-based BDD) against a running ObjectStack server.
os test # Default: qa/*.test.json
os test qa/my-test.json # Specific test file
os test --url http://localhost:4000 # Custom server URL
os test --token my-api-key # With authentication
os test 'qa/**/*.test.json' # Recursive — quote it, or the shell expands it first
os test --fail-on-empty # Matching no suite is a failure, not a pass
os test --tags smoke,critical # Only scenarios tagged smoke OR criticalThe pattern accepts * (one path segment) and ** (any number of segments);
every other character is matched literally. A wildcard never descends into
node_modules, .git, dist or build: a wildcard is a search of your own
sources, and a suite found in a dependency or in build output is one os test
would otherwise load and run against your server. Naming such a directory
still reaches it — packages/*/dist/*.test.json walks dist because you asked
for dist. Matches run in sorted order, so a suite runs in the same position on
every machine.
Each file is validated against TestSuiteSchema before it runs. A suite that
does not match is refused at load time, naming the file and every offending path,
and counts as one failed suite — the rest of the glob still runs. This is what
stops a malformed suite from reporting success: a misspelled steps key used to
produce a scenario that passed having executed nothing.
An assertion the runner cannot evaluate fails, it does not pass. contains
is defined over an array (membership) and a string (substring); point it at
anything else — most often a field path the response does not carry, because it
was misspelled or the shape moved — and it fails, naming the field, the operator
and the runtime type it actually found. Until #7256 that case fell out of the
switch and reported ✅, so a contains against a missing path was a test that
silently deleted itself. Assert absence with is_null; compare a scalar with
equals.
The report prints the names the suite author wrote. Each suite is headed by
its name and the file it was loaded from —
📄 Running suite: Accounts smoke (accounts.test.json) — and each scenario line
leads with the scenario's name and carries its id in brackets:
✅ Scenario: An account can be created [acct-create] (12ms), or the id alone when
the two are equal. A failed scenario also prints its description under its line,
before the error, so a red run says what the scenario was checking. A file that is
refused at load time is headed by its file name alone, because no suite name was
ever parsed from it.
--tags selects scenarios by their tags. Pass a comma-separated list: a
scenario runs when it carries at least one of the listed tags (any-of), so
--tags smoke,critical runs everything tagged smoke or critical. Matching is
exact and case-sensitive — a tag is a name, not a pattern — and while the flag is
given an untagged scenario is never selected. The scenarios the selection leaves
out are deselected: not run, counted on the summary
(--tags smoke selected 1 of 4 scenarios; 3 deselected (not run, not counted as passed).),
and never counted as passed. A listed tag that no loaded scenario carries is named
on the summary, since a typo narrows the run without failing it, and an empty entry
(--tags smoke,) is refused before anything runs. Without the flag every scenario
runs.
A scenario's requires is checked before its first step. Two keys, each
judged against something os test can observe:
requires.params— environment variables that must be set to a non-empty value in the process runningos test(not on the target server, which a suite cannot see):"params": ["BILLING_SANDBOX_KEY"].requires.services— service keys the target must declare in its discovery document asenabledwith statusavailable, read from the same discovery request the runner already makes once per run:"services": ["ai", "analytics"]. The keys are the discovery service names (auth,automation,storage, …), and a misspelled one is refused when the suite loads.
A scenario with an unmet entry is skipped: none of its steps runs — setup
included — and its line says why, naming every unmet entry and, for a service,
the services the target does declare available:
⏭️ Scenario: Summarise an account with the AI service [ai-summary] (skipped)
Skipped: requires.services 'ai' is not available on the target (enabled: false, status: unavailable). The target declares available: auth, data, metadata.A skipped scenario is counted on its own —
SUCCESS: 3 scenarios passed. 1 skipped (not run, not counted as passed). — and
is never counted as passed. Skips alone do not fail a run, but a run in which
every selected scenario was skipped proved nothing: it prints
No scenario ran: all 2 selected scenarios were skipped on unmet requirements.
instead of SUCCESS, exits 0, and exits 1 under --fail-on-empty. The
retired requires.plugins is refused when the suite loads, with the plugin →
service mapping in its message: no served surface lists loaded plugins, so it
was never checkable, and requires.services asks the question it stood for.
A pattern that matches no suite is not a failure by default. The run prints
Found 0 test suites. — the same machine-readable line a full run prints, so a
caller can tell "every suite passed" from "there were no suites" — and exits
0, because a project that legitimately ships no suites should not fail its
build. That is a posture, not an oversight, and it has the cost you would expect:
a CI step whose glob stops matching (a renamed directory, a moved suite) reports
success forever. Pass --fail-on-empty to opt into the strict reading, where
an empty match exits 1 (#7848).
A --tags selection that matches no scenario takes the same posture: it prints
No scenario matched --tags nightly. and exits 0, and --fail-on-empty
makes it exit 1 — a renamed tag in a CI step is the same trap as a renamed
directory.
The record-shaped action types — create_record, read_record,
update_record, delete_record, query_records — ask the server where the
Data Protocol is mounted instead of assuming it. Once per run, os test
fetches {apiBase}/discovery and addresses whatever routes.data advertises,
so a deployment that moves the mount with crud.dataPrefix is reached without
you telling it anything. When the probe cannot answer, the run falls back to the
convention {apiPath}{crud.dataPrefix} (/api/v1/data) and says so: a
warning naming the mount it will address and the probe that failed, and the same
statement appended to every 404 a record step gets — so a wrong mount reads as a
wrong mount, not as your own URL mistake.
One case survives that fallback by construction: setting api.apiPath moves
the discovery document itself out from under the probe, and no fixed-path
document reports the REST mount (/.well-known/objectstack advertises the
dispatcher's own prefix, not this one). Against such a host, write those steps as
api_call, which takes the path you give it. run_script has no adapter branch
at all and fails by name.
os doctor
Checks your development environment and reports issues:
os doctor # Check health
os doctor -v # Show fix suggestions for warningsChecks performed:
- Node.js version (≥18 required)
- pnpm installation
- TypeScript availability
- Dependencies installed
@objectstack/specbuild status- Git availability
Authentication
| Command | Description |
|---|---|
os register | Create an account and store local credentials |
os login | Sign in and store credentials in ~/.objectstack/credentials.json |
os whoami | Show the current authenticated user |
os logout | Revoke the server session and clear local credentials |
os cloud login | Sign in to ObjectStack Cloud (the hosted package registry) and store credentials in ~/.objectstack/cloud.json |
os register
Creates a user account and stores the returned token locally.
os register
os register --email user@example.com --name "Jane Doe" --password secret
os register --url https://api.example.comos login
In an interactive terminal, login uses a browser-based device flow by default: the CLI prints a one-time verification URL, opens the browser, and polls until you approve access in your browser.
os login
os login --url https://api.example.com
os login --no-browserIf a valid token already exists, os login exits successfully with
"Already logged in as <email>". Use os logout to switch users, or pass
--force to re-authenticate.
For CI and other non-interactive contexts, pass email/password directly:
os login --email user@example.com --password secretos login --json is NDJSON — the one exception
Every other ObjectStack command writes exactly one JSON document to stdout
under --json, so JSON.parse(<entire stdout>) is the way to read it.
os login is the single declared exception: its --json output is NDJSON,
one compact JSON document per line. Parse it line by line.
The reason is the device flow: it is two events at two points in time, and the verification URL is only useful to a script before the user authorizes. So the CLI emits it as its own record immediately, then a second record when the poll resolves:
$ os login --json --no-browser
{"device_code":"…","user_code":"WXYZ-1234","verification_uri":"https://…/activate","verification_uri_complete":"https://…/activate?user_code=WXYZ-1234","expires_in":600}
{"success":true,"email":"user@example.com","userId":"usr_01H…"}Read the first record, show the user the URL, then block on the next line:
os login --json --no-browser | while IFS= read -r line; do
echo "$line" | jq -r 'if .verification_uri_complete then "Approve at: \(.verification_uri_complete)" else "Signed in as \(.email)" end'
doneEvery record is one line, on every path — the --email/--password result and
the failure payload ({"success":false,"error":"…"}) included, since a failure
can arrive after the verification-URL record has already been written. Records
that report failure also set exit code 1.
Before this was declared, os login --json wrote a compact record followed by a
pretty-printed one, which parsed as neither a single document nor as NDJSON.
--json is non-interactive: it refuses rather than prompting
--json has one audience, a program, so it never asks a question. If a --json
run has no --email and no --password to work from and cannot use the device
flow, it does not fall back to a prompt — it emits one record and exits 1:
$ os login --json --url https://api.example.com < /dev/null
{"success":false,"error":"email and password are required in a non-interactive shell"}
$ echo $?
1The same applies when only one of the two is supplied, which is the usual shape
of the mistake: a CI step whose --password secret interpolated and whose
--email did not gets that record, not a Password: prompt.
Without --json, os login still prompts on a pipe as before. What changed for
that path is the ending: if stdin reaches end of input before a prompt is
answered, the command reports it and exits 1, rather than being torn down by
Node with an exit code the CLI does not define.
os logout
Logout calls POST /api/v1/auth/sign-out before deleting local credentials, so
the server-side session is revoked as well.
os logoutos cloud login
Signs you in to ObjectStack Cloud — the hosted package registry — rather
than to a runtime instance. It is the credential os package publish and the
marketplace commands use, and it lands in its own file
(~/.objectstack/cloud.json), separate from os login's
~/.objectstack/credentials.json.
os cloud login
os cloud login --no-browser
os cloud login --url https://cloud.example.com # self-hosted control plane
os cloud login --email me@acme.com --password secret # CILike os login, in an interactive terminal it uses the browser-based device
flow: it prints a one-time verification URL and polls until you approve. If
cloud credentials already exist it exits successfully with "Already logged in";
pass --force to re-authenticate.
os cloud login --json is NDJSON — the same exception as os login
Every other ObjectStack command writes exactly one JSON document to stdout
under --json, so JSON.parse(<entire stdout>) is the way to read it. The two
device-flow login commands — os login and os cloud login — are the declared
exceptions, and they are the same exception: --json output is NDJSON,
one compact JSON document per line. Parse it line by line.
The reason is the device flow: it is two events at two points in time, and the verification URL is only useful to a script before the user authorizes. So the CLI emits it as its own record immediately, then a second record when the poll resolves:
$ os cloud login --json --no-browser
{"device_code":"…","user_code":"WXYZ-1234","verification_uri":"https://…/activate","verification_uri_complete":"https://…/activate?user_code=WXYZ-1234","expires_in":600}
{"success":true,"email":"user@example.com","userId":"usr_01H…","url":"https://cloud.objectos.ai"}Read the first record, show the user the URL, then block on the next line:
os cloud login --json --no-browser | while IFS= read -r line; do
echo "$line" | jq -r 'if .verification_uri_complete then "Approve at: \(.verification_uri_complete)" else "Signed in as \(.email)" end'
doneEvery record is one line, on every path — the --email/--password result, the
"already logged in" notice, and the failure payload
({"success":false,"error":"…"}) included, since a failure can arrive after
the verification-URL record has already been written. Records that report a
login failure also set exit code 1.
Before this was declared, os cloud login --json emitted a single document and
never handed the verification URL to a consumer at all — formally valid
JSON that withheld the one thing device flow exists to give a script. The
device-authorization record's fields are spelled exactly as os login --json
spells them, so one consumer reads both commands.
Cloud Environments
| Command | Description |
|---|---|
os environments list | List environments visible to the current session |
os environments show <id> | Show one environment |
os environments create | Provision a new environment. With --activate (the default) the new environment becomes the active one, recorded in ~/.objectstack/cloud.json as well when the control plane it talked to is the one os cloud login recorded — so os package publish --install can install into it with no switch in between |
os environments switch <id> | Set the active environment for later CLI calls. Recorded in ~/.objectstack/cloud.json as well when the control plane it talked to is the one os cloud login recorded, so os package publish --install can use it |
os environments bind <id> | Bind a compiled local artifact to an existing environment |
Create an environment from a local artifact
Compile first, then create an environment and bind the generated
dist/objectstack.json in one call:
os compile
os environments create --org <org-id> --name CRM --artifact ./dist/objectstack.jsonThe server stores the absolute artifact path in environment metadata.
On environment-kernel boot, ObjectStack loads the JSON bundle, registers schemas, and
seeds records from the bundle's data arrays.
Bind an existing environment
os environments bind <environment-id> --artifact ./dist/objectstack.json
os environments bind <environment-id> --artifact ./dist/objectstack.json --build--build runs objectstack compile before updating the environment. --reseed
is reserved for the server-side reseed endpoint; use it only when that endpoint
is available in your deployment.
Packages
The two commands that move a compiled app onto a platform. They target different systems and authenticate as different identities: publish uploads to ObjectStack Cloud (the catalog), install registers an app into a running runtime.
| Command | Talks to | Description |
|---|---|---|
os package publish [artifact] | ObjectStack Cloud | Upload a compiled artifact as a versioned package in your organization |
os package install <package> | A running runtime | Install a package into a live kernel, from that runtime's catalog or from a local artifact |
For which one to reach for and the preview patterns around them, see Publish & preview. This section is the flag-level reference.
os package publish
Uploads a compiled artifact as a versioned package in your organization's
catalog. It ensures a sys_package row keyed by the manifest id, then snapshots
the artifact into a new sys_package_version. Publishing changes nothing that
is already running.
os compile
os package publish # dist/objectstack.json → your org
os package publish --manifest-id com.acme.crm --version 1.2.0
os package publish dist/objectstack.json --visibility org --note "first cut"
os package publish --env env_abc123 --install # publish, then install into an environment
os package publish --install # into the active environment (os environments switch)
OS_CLOUD_URL=http://localhost:4000 os package publish # against a local control planeThe credential is the cloud identity. Resolution order: --token, then
$OS_TOKEN, then ~/.objectstack/cloud.json (written by
os cloud login). It deliberately does not fall back to
~/.objectstack/credentials.json — that is the runtime identity
os login writes, and the two are different accounts. With no
token at all the command exits 1 and tells you to run os cloud login.
The install target follows the same split. --install with no --env uses
the environment os environments switch — or os environments create --activate — recorded in cloud.json, and that
id is only used when cloud.json's url is the control plane this publish is
POSTing to. It is never read out of credentials.json: the two files name
different servers (credentials.json defaults to http://localhost:3000), so
an id taken from there can belong to a different control plane — and the server
resolves an install target by bare id, with no name or short-id rescue. A value
written by an older CLI into credentials.json is copied across once, and only
when both files' urls agree.
Options:
| Flag | Env equivalent | Purpose |
|---|---|---|
artifact (positional) | — | Path to the compiled artifact (default dist/objectstack.json) |
-s, --server <url> | OS_CLOUD_URL | Control-plane URL. Default https://cloud.objectos.ai, or the URL recorded by os cloud login |
-t, --token <key> | OS_CLOUD_API_KEY | Bearer token; $OS_TOKEN and ~/.objectstack/cloud.json are the fallbacks |
--manifest-id <id> | OS_PACKAGE_MANIFEST_ID | Reverse-domain package id. Default: artifact.manifest.id, else local. + a slug of manifest.name, else local. + a slug of the artifact filename. A declared manifest.id is used or refused, never replaced by a derived one — see below |
-v, --version <semver> | — | Version to publish. Default: artifact.manifest.version, else 0.0.0-dev. + a timestamp |
--display-name <name> | — | Name shown in the Marketplace (default artifact.manifest.name) |
--description <text> | — | Short package description |
--category <slug> | — | Marketplace category slug (crm, hr, devtools, …) |
--visibility <level> | — | org (installable across your organization) · private (explicit grants only) · marketplace (public after review). Omitted: not sent, so the control plane decides — an existing package keeps its visibility, a new one gets the control plane's default (org on ObjectStack Cloud) |
--org <id> | OS_ORG_ID | owner_org_id. Required with a bearer key in service mode; ignored in user mode |
--env <id> | OS_ENVIRONMENT_ID | Environment to install the new version into. Defaults to the environment os environments switch — or os environments create --activate — recorded for this control plane in ~/.objectstack/cloud.json |
--install | — | Auto-install into --env after publishing. With no --env, no $OS_ENVIRONMENT_ID and no active environment for this control plane, it reports that and publishes without installing |
--seed-sample-data | — | Include sample data in that auto-install |
--pre-release | — | Mark the version as a pre-release (also inferred — see below) |
--submit | — | Submit the new version for marketplace review. Needs the package's visibility to be marketplace (already stored, or set with --visibility marketplace) and a complete listing |
--auto-approve | — | Platform admin only: skip the review queue and publish straight to the public catalog |
--readme <markdown> | — | Inline marketplace README. Mutually exclusive with --readme-file |
--readme-file <path> | — | README file, read at publish time. Mutually exclusive with --readme |
--icon-url <url> | — | Public http(s) icon URL. Mutually exclusive with --icon-file |
--icon-file <path> | — | Local PNG/JPEG/WebP/SVG (≤256 KB) uploaded to the icon CDN, which returns a stable URL and rewrites icon_url for you. Mutually exclusive with --icon-url |
--homepage-url <url> | — | Public project / docs URL, surfaced in the catalog |
--license <spdx> | — | SPDX identifier (Apache-2.0, MIT, …) |
-n, --note <markdown> | — | Release notes |
--timeout <ms> | OS_CLOUD_TIMEOUT_MS | HTTP timeout in milliseconds, default 120000. 0 disables it |
Every id is held to one rule, whichever source it came from. An explicit,
declared or derived id is parsed against PackageSchema.manifestId — the schema
for the very column publish writes: lowercase dot-separated segments of letters,
digits and inner hyphens, a segment never opening with a hyphen, no
underscores. A slug is lowercase letters, digits and inner hyphens, so a
derived local. id always satisfies it — a manifest named 2024 App derives
local.2024-app — and it is published exactly as derived, never normalised
into a different identifier: manifest_id is immutable once published, so an
id nobody wrote cannot be renamed afterwards. A declared manifest.id is used
or refused, never silently replaced by a derived one: an id that fails the rule
is refused before any network call, quoting the schema and naming which of
the three sources above the id came from. The remedy is the one the refusal
prints for that source — pass --manifest-id, set manifestId in
objectstack.manifest.json, or fix manifest.id in objectstack.config.ts
and rebuild.
objectstack.manifest.json supplies the listing fields. When that file is
present in the working directory, publish reads manifestId, displayName,
description, category, tagline, iconUrl, homepageUrl, license,
readmePath and a translations map from it, so a listing need not be retyped
as flags on every publish. CLI flags always win. A per-locale readme entry
may be inlined markdown or a path resolved against the manifest's own directory
(README.zh-CN.md). Publishing without the file is fully supported — it stays
flag-driven.
The namespace travels with the artifact and no flag overrides it.
manifest.namespace is read off the compiled artifact and sent with the publish
payload, because the publish-time exclusivity gate (ADR-0048 addendum §A.2)
must check the object-name prefix the package actually ships — a reservation
naming a different string than the artifact installs would be worse than none.
A malformed value is refused before any network call; to change it, edit
manifest.namespace in objectstack.config.ts and rebuild. An artifact that
declares no namespace publishes fine.
Pre-release is inferred as well as flagged. A version containing -alpha,
-beta, -rc, -dev, -preview, -staging or -pr is marked a pre-release
whether or not you pass --pre-release — so the generated
0.0.0-dev. + timestamp default never lands as a stable version.
A 422 on version publish is marketplace policy rejecting the listing. The
command prints each violation and names the flags that fix them, rather than
leaving them in the server log.
os package install
Installs a package into a running runtime through its local install endpoint (ADR-0008 Phase 3): the app is registered into the live kernel, and the manifest is cached on the runtime host so the install re-registers on every boot and survives restarts. This is the other half of publish — publish uploads to the cloud, install puts an app into a runtime.
Two modes, chosen by the shape of the argument:
# catalog mode — the TARGET runtime resolves the version from its own catalog
os package install com.acme.crm --version 1.2.0 --runtime https://app.example.com
# air-gapped mode — the artifact is read locally and sent inline; no catalog, works offline
os package install ./dist/objectstack.jsonThe argument is read as a file path when it ends in .json, starts with
./, ../ or /, or names something that exists in the working directory.
Anything else is a catalog id. The last clause is the one to know: a bare
catalog id that happens to match a file in the working directory is installed
from that file instead.
The credential is the runtime identity, not your cloud login. The target
runtime authenticates the call with its own session, so --email / --password
(or OS_RUNTIME_EMAIL / OS_RUNTIME_PASSWORD) name an account on that
runtime. A 401 means exactly that, and the command says so; os cloud login
credentials do not apply here.
Options:
| Flag | Env equivalent | Purpose |
|---|---|---|
package (positional, required) | — | Package manifest id (com.acme.crm) or a path to a compiled artifact JSON |
-r, --runtime <url> | OS_RUNTIME_URL | Base URL of the runtime to install into (default http://localhost:3000) |
-v, --version <semver> | — | Version to install in catalog mode (default latest). Air-gapped mode takes the version from the artifact |
--email <email> | OS_RUNTIME_EMAIL | Account email on the target runtime |
--password <password> | OS_RUNTIME_PASSWORD | Account password on the target runtime |
--confirm-global-uniques | — | Affirm this app's installation-wide unique constraints are genuinely platform-wide — see below |
--timeout <ms> | OS_CLOUD_TIMEOUT_MS | HTTP timeout in milliseconds, default 120000. 0 disables it |
--confirm-global-uniques answers a stop; it does not force one past.
Installing an app that declares installation-wide (unique: 'global')
constraints into a runtime whose tenancy posture is isolated stops with
UNIQUE_SCOPE_CONFIRMATION_REQUIRED, and the command prints the offending
constraints so you can decide per entry (ADR-0120 D5e). Passing the flag records
an affirmative fact — these constraints really are platform-wide — into the
install manifest, alongside the posture it was given under, a timestamp and the
confirming identity when the seam knows one; os doctor then stops
re-reporting the affirmed constraints, so the advisory does not become a
recurring nag. It is deliberately not called --force, and deliberately not
default-on. The other answer is to edit the app's metadata to
unique: 'organization' and rebuild.
A 404 means the target runtime does not mount MarketplaceInstallLocalPlugin
(from @objectstack/cloud-connection). The endpoint is opt-in, so a runtime
composed without it will not accept installs.
Configuration
The CLI looks for objectstack.config.ts (or .js, .mjs) in the current directory:
import { defineStack } from '@objectstack/spec';
import * as objects from './src/objects';
import * as actions from './src/actions';
export default defineStack({
manifest: {
id: 'com.example.my-app',
namespace: 'my_app',
version: '1.0.0',
type: 'app',
name: 'My App',
description: 'My ObjectStack application',
// Protocol major this app is authored against (ADR-0087 load-time check).
engines: { protocol: '^17' },
},
objects: Object.values(objects),
actions: Object.values(actions),
});What the config file may export
The config file is loaded as a module, and the whole module is the stack:
the default export is the base, and then every named export is merged onto it
as a top-level stack key, under the export's own name. onEnable and
functions are authored that way on purpose.
So a named export is legal only when its name is a key
ObjectStackDefinitionSchema declares. A helper exported beside the stack —
export const collectPackageDirs = … — arrives at the strict parse as a
top-level stack key of that name, and is refused:
$ printf '\nexport const ProbeNamedExport = [1, 2, 3];\n' >> objectstack.config.ts
$ os build
✗ Validation failed
unrecognized_keys: Unrecognized key(s) on this stack definition: `ProbeNamedExport`.Move helpers into a sibling module (objectstack.composition.ts, for instance)
and import them from the config. Two further shapes, documented so they are
recognisable rather than as patterns to use:
| The named export's name | What happens |
|---|---|
| not a declared stack key | the build fails, naming the key (above) |
| a declared stack key the default export does not carry | merged in and accepted — the onEnable / functions path |
| a key the default export already carries | the default's value wins and the exported one is dropped — the build still exits 0, and the drop is reported on stderr |
The last row is why every stack key belongs inside defineStack(): a second
copy beside it is not a second declaration, it is a value nothing reads.
$ printf '\nexport const objects = [myExtraObject];\n' >> objectstack.config.ts
$ os build
⚠ `objects` is a named export that was DROPPED — the default-exported stack already declares that keyThe advisory goes to stderr, so it reaches a --json run's operator without
putting anything but the envelope on stdout. It does not fail the build: the
stack it produces is valid, it is simply missing what the shadowed export
carried.
Config File Auto-Detection
The CLI searches for configuration files in this order:
objectstack.config.tsobjectstack.config.jsobjectstack.config.mjs
You can also specify a path explicitly:
os compile path/to/my-config.tsTypical Workflow
# 1. Create project
os init my-crm && cd my-crm
# 2. Define your data model
os g object account
os g object contact
os g object opportunity
# 3. Add business logic: a flow that runs when an opportunity changes
os g flow opportunity_changed --object opportunity
# 4. Validate everything: each file `os g` wrote is counted and checked
os validate
# 5. Start development with Console UI
os dev --ui
# 6. Build for production
os compile
# 7. Deploy: ship just the artifact
os start # locally
OS_ARTIFACT_PATH=https://cdn.you.com/app.json os start # remote artifactSource vs Artifact
ObjectStack treats objectstack.config.ts and objectstack.json as two forms of the same
schema — authoring source vs compiled artifact:
| Aspect | objectstack.config.ts | objectstack.json |
|---|---|---|
| Role | Authoring source | Deployable artifact |
| Format | TypeScript (defineStack({...})) | Pure JSON |
| May contain code | Yes (handler: async (ctx) => {...}) | No — handlers are lowered to a sibling objectstack-runtime.<hash>.mjs |
| Loaded by | os serve (via bundle-require) | os start (via loadArtifactBundle — file or http(s)://) |
| Schema | ObjectStackDefinitionSchema | Same schema, plus runtimeModule reference |
| Produced by | You (or os generate) | os compile / os build |
The artifact is fully self-describing: its requires: [...] field declares which platform
services (ai, automation, analytics, …) the runtime should auto-register, so os start
needs nothing other than the JSON itself to bring up a working server.
This is why an artifact is the canonical "portable deployment unit" — you can host it on S3 / GitHub raw / a CDN, and any ObjectStack runtime can fetch and execute it with no additional source code on the server.
Next Steps
- Examples Guide — Run the built-in example apps (Todo, CRM, BI)
- Developer Guide — Learn data modeling, security, and automation
- Protocol Reference — Complete schema documentation
CI/CD Integration
All commands that produce output support --json for machine-readable output:
# In CI pipeline
os validate --json --strict
os compile --json -o dist/objectstack.json
os info --jsonExample GitHub Actions step:
- name: Validate ObjectStack Config
run: npx objectstack validate --strict --json
- name: Build ObjectStack Artifact
run: npx objectstack compile --json