Skip to content

CLI

Terminal window
pnpm protobase <command> # from the repo root
pnpm --filter erp protobase <command> # from a project, config folder defaults to ./config

bin/protobase.mjs registers tsx and imports src/cli/index.ts, so the CLI runs straight from source with no build step. Node 22.16 does not strip types by default (process.features.typescript is false; it arrives in 22.18) and the sources use extensionless imports, which native type stripping rejects. tsx is a root dev dependency, and the bin field in package.json points at the same file.

The serve runtime is the exception: protobase build-serve bundles it into one JavaScript file for Bun, so production needs neither tsx nor node_modules (see serve).

The CLI reads the database with default_transaction_read_only = on, so it can never write.

Terminal window
protobase scaffold <connection string | --env DATABASE_URL> [target] [options]

target is all (default), schema, schema.table or schema.table.column. Tables are introspected from pg_catalog; config/<resource>/data.ts is created or extended.

Option Effect
--config <dir> config folder, default config
--yes accept every proposal, no prompts (required without a TTY)
--dry-run print a diff, write nothing
--ui also write ui.ts (.names() and .list())
--ui-list, --ui-section <names|list> write only those ui.ts sections
--exclude <schema[.table]> skip tables, repeatable

Interactively, each new column shows its proposal: accept, edit (name, type) or ignore (writes field: null; key columns cannot be ignored).

Database Field
int2, int4 / int8 f.integer() / f.bigint()
numeric(p,s) f.decimal({ precision, scale }) (unconstrained: 38, 9)
text, varchar, bpchar f.text()
bool, date, timestamp(tz), uuid, json(b) f.boolean(), f.date(), f.timestamp(), f.uuid(), f.json()
Postgres enum, or a CHECK (col IN (...)) on text f.enum([...])
foreign key f.relation('<resource>'), .columns([...]) for a composite key (on its first column)
anything else (ltree, arrays, …) f.text().readOnly()
  • NOT NULL absent: .optional(). Literal default: .default(v).
  • Computed defaults (function, sequence, expression, GENERATED BY DEFAULT identity): .dbDefault(). It also gets .readOnly() when the column is an identity column, a primary-key column, or an audit timestamp (created_at, inserted_at, updated_at, modified_at); other computed defaults, such as order_date DEFAULT current_date, stay editable. GENERATED ALWAYS identity and generated columns are only .readOnly().
  • Leading column of a non-partial index: .filterable(); of a btree: also .sortable(). Not applied to json.
  • Key: primary key (composite supported), else the first unique index with only NOT NULL columns, else the table is skipped with a warning.
  • deleted_at, removed_at, archived_at (nullable timestamp/date) become .softDelete; organization_id and tenant_id become .tenant.
  • Unique indexes are listed in the doc comment above the resource.
  • Field names are camelCase; .column() is written only when the schema builder’s default mapping would not give the column back (always for hr."EMP_MASTER").
  • Resource names are the camelCased table name, prefixed with the schema on collisions. Names already in config/ win.

Existing data.ts files are edited with ts-morph: only columns that are neither declared nor null are added, at the end of .fields({...}). Nothing else is rewritten, and key, soft delete and tenant calls are only written when a resource is created. ui.ts gets only missing sections. config/index.ts gets exports for resources created in the run (all resources when it does not exist yet); existing lines are never touched. A run with nothing new changes nothing. schema.table.column on an unscaffolded table scaffolds the whole table.

After each run, declared fields are compared with the database: a column that no longer exists, a type that no longer fits (a relation fits integer/bigint/uuid/text, currency and country fit text), or different enum values. Each entry carries a suggested replacement. Drift is reported, never applied.

Terminal window
protobase doctor <connection string | --env VAR> [--config <dir>]

Imports config/index.ts and checks every exported resource:

  • error: missing table or column, field type that does not fit the column, table without a usable key, configured key not backed by a unique index
  • warning: enum values differ, filterable field without an index (with the CREATE INDEX statement)

Exit code is 1 when there are errors.

Terminal window
protobase dev [--port 5173] [--env DATABASE_URL] [--cache-dir <dir>] [--allowed-hosts <hosts>] # run inside a project, e.g. pnpm --filter erp dev

One process, one port: Vite serves the admin app from src/ui/app/ (index.html, main.tsx; the command fails with a clear message when they are missing) and @hono/vite-dev-server mounts the Hono app from createAdmin under /api. Open http://localhost:<port>; the API reference is at /api/docs.

  • Database: the URL comes from the variable named by --env, read from the environment or the nearest .env at or above the project. The connection is checked before anything starts; if it fails, the command names host, port and reason (and pnpm --filter <project> db:up when the project has a db:up script). The project needs pg installed, because the API connects with Kysely over the project’s own pg.

  • Cache: Vite’s dependency cache is node_modules/.vite; two dev servers sharing it can disturb each other’s optimisation, so pass --cache-dir for a second one (the integration test does).

  • Login: real sign-in only, with the authenticate and auth exported by protobase.config.ts (see Login with Better Auth). A project without an authenticator fails at startup with a message pointing there; there is no development login. Create the first admin with protobase users create.

  • Tunnels and other hosts: Vite only answers localhost by default. --allowed-hosts .trycloudflare.com,app.example.com adds Host headers (a leading dot matches all subdomains). Hot reload needs no extra setting: the Vite client takes protocol, host and port from the page, so behind an HTTPS tunnel it connects over wss on 443 as long as the tunnel forwards WebSockets.

  • Project discovery, by convention: config/index.ts exports the resources (and views), and every config/*/ui.ts export that is not already exported by the index is added as a view. A protobase.config.ts in the project root replaces the convention; its default export may contain any of:

    import * as config from './config'
    export default {
    config, // module whose exports are resources and views
    db, // Kysely instance, instead of DATABASE_URL
    authenticate, // Authenticator, required
    auth, // Better Auth (AdminAuth); its routes are mounted under /api/auth
    options, // AdminOptions (scan guard, write hooks, ...)
    }
  • Project UI: the app imports protobase.ui.tsx from the project root, with hot reload, when it exists (see Custom components). Layout files are checked as they load, as protobase build checks them.

  • Hot reload: server code, data configs, ui.ts views, layouts and protobase.config.ts are loaded through Vite’s SSR module graph. A change inside the project (or in src/server, src/query, src/schema) rebuilds the API on the next request without a restart; /meta and X-Meta-Version change, so open tabs refetch. The database pool survives reloads. Newly added ui.ts files appear after the next change inside the project.

Terminal window
protobase build [--out dist] # run inside a project, e.g. pnpm --filter erp protobase build

Writes the project’s deploy bundle, the folder a deployment ships:

dist/
protobase.config.js the config module for serve
node_modules/ the packages it loads from disk, only when it imports native add-ons
public/ the admin UI
protobase.bundle.json the manifest

The output folder is not emptied; public/ and node_modules/ are replaced on every build. node_modules/ is deleted only when the previous build’s manifest names it, so a build into a folder with a project’s own node_modules fails instead. Nothing of the project runs at build time, so the build needs no secrets and no database.

protobase.bundle.json is the contract between a bundle and the host that serves it:

{
"version": 2,
"server": "protobase.config.js",
"public": "public",
"spa": "index.html",
"api": ["/api"],
"nodeModules": "node_modules"
}
  • server, public and nodeModules are relative to the bundle folder, spa to public.
  • nodeModules is present only when the config module imports native packages. It is always the node_modules folder beside server, where Bun and Node resolve the module’s imports from, and holds plain packages: JavaScript, package.json files and native binaries for linux x64 and arm64 (glibc), which the runtime loads with dlopen. A host keeps it next to the server module and must not mount it noexec (an add-on there then fails with failed to map segment from shared object). Its binaries link nothing but glibc, libstdc++.so.6, libgcc_s.so.1 and libraries in the bundle itself, so the run image must have those three. Without nodeModules, the server module is the only file the runtime reads.
  • api lists the path prefixes the server answers. A path is under a prefix when it equals it or continues it with / (/api covers /api/meta and /api/v1:batchWrite, not /apiary). It is always ["/api"]: a bundle assumes the API lives under /api, with the resources at the default basePath /api/v1, /api/meta and the docs beside them and Better Auth at /api/auth. The serve runtime refuses a config that sets another options.basePath.
  • Every other path is the UI’s. A GET or HEAD for a file in public gets that file; a folder gets its index.html. Otherwise a request whose Accept header contains text/html (a navigation) gets spa with status 200, so deep links such as /orders/42 load the app; anything else gets 404. Other methods get 405.
  • Files under public/assets/ are named by their content hash and never change: serve them with Cache-Control: public, max-age=31536000, immutable, and everything else with no-cache.
  • A host that does not know version must refuse the bundle. Version 2 replaced version 1 when the config module started to import every module the runtime supplies, not only protobase/* and kysely, and gained nodeModules. protobase serve refuses a version 1 bundle; rebuild it.

public/ is a production build of the admin app (src/ui/app): index.html, which loads /assets/index-<hash>.js and .css, and nothing for development (no Vite client, no React development build). It calls the API on its own origin under /api, so the same bundle works under any hostname. The only project code built in is protobase.ui.tsx from the project root, when there is one: the project’s custom components and action handlers. Nothing host-specific is: the resources, views, pages and roles come from /api/meta and sign-in from /api/auth at runtime.

protobase.config.js is one ES module. The entry is found as protobase dev finds it: the default export of protobase.config.ts, with config taken from the convention (config/index.ts plus config/*/ui.ts) when it exports none.

  • The first line is // @bun, which tells Bun the file is already plain JavaScript: Bun neither transpiles it nor writes its transpiler cache.

  • protobase and every package in its dependencies belong to the serve runtime: their imports stay import statements and the runtime supplies them, so the bundle carries no copy and shares the runtime’s. It supplies these modules:

    Modules
    protobase/schema, protobase/layout, protobase/jsx-runtime, protobase/query, protobase/server protobase’s server entry points, and what layout files compile to
    kysely, kysely/helpers/postgres, pg, postgres the database
    better-auth, hono, jose, zod, aip-parsers, @hono/node-server, @scalar/hono-api-reference the main entry of each other dependency protobase runs on the server

    Importing any other part of protobase or of its dependencies (protobase/ui, react, better-auth/plugins, zod/v4, …) fails the build with this list, also from inside an inlined package. So does a copy the project installs in another version than protobase’s (another major, or another minor below 1.0), which the runtime’s would replace. The list is src/cli/serve/host-module-ids.ts; the set of packages comes from protobase’s package.json.

  • Packages with a native add-on stay imports too, and the bundle carries them in node_modules/ (see Native packages). Node’s built-in modules stay imports.

  • Everything else (the project’s files and its other packages) is inlined. Nothing is minified.

  • Layout files (@jsxImportSource protobase) compile to calls of protobase/jsx-runtime, for production whatever NODE_ENV says. The build refuses one with a function, class, new value or hook in its JSX, naming the file, line and column (what a layout may hold).

  • The default export is the project config, with config always set:

    export default { config, authenticate, auth?, options?, db? } // as protobase.config.ts exports it

The serve runtime reads the environment when it imports the module, on the host.

A package with a native add-on cannot be inlined: it loads a compiled .node file from its own folder at runtime. The build recognises one by a .node file or a binding.gyp in it, or by optional dependencies that carry the add-on per platform and name a linux one (@node-rs/argon2-linux-x64-gnu, sharp’s @img/sharp-linux-x64). For each such package the config module imports, directly or through an inlined package:

  • The import stays an import, which resolves at runtime to node_modules/<package> beside the config module.
  • The package and everything it needs at runtime (its dependencies and the optional ones that are installed, recursively) are copied to node_modules/ as one tree: each package at the top, a second version of one under the package that needs it. Files of a package are copied as they are, except native binaries.
  • Every native binary in them (ELF, Mach-O or PE, told by its headers, not its name) is copied only when it is for linux x64 or linux arm64 with glibc: the run image is Bun’s distroless image (Debian) for amd64 and arm64, and a bundle may run on either. Binaries for macOS, Windows, musl or other architectures stay behind, as do platform packages for them (by their os, cpu and libc fields).
  • Every add-on needs a .node binary for both, in its package or in its platform packages. Otherwise the build fails and names each package, the platforms it misses and the ones it has. It never ships a binary built for the wrong platform.
  • Every binary it copies may link only glibc’s libraries, the C++ runtime (libstdc++.so.6, libgcc_s.so.1) and libraries the bundle carries itself (sharp’s libvips); otherwise the build fails and names the binary and the library. Every add-on in common use links libgcc_s and, when written in C++, libstdc++. Bun’s distroless image has glibc only, so the run image adds those two (Debian’s libgcc-s1 and libstdc++6); without them an add-on fails to load (libstdc++.so.6: cannot open shared object file).

What that means for a package:

  • Prebuilt binaries for every platform inside the npm package (prebuildify and node-gyp-build: argon2, bcrypt 6, bufferutil): builds anywhere, macOS included.

  • Per-platform optional packages (napi-rs, such as @node-rs/argon2, and sharp): a package manager installs only the build machine’s, so the build fails until the linux ones are installed as well. With pnpm, in pnpm-workspace.yaml:

    supportedArchitectures:
    os: [current, linux]
    cpu: [current, x64, arm64]
    libc: [current, glibc]
  • Built or downloaded on install (node-gyp from source, prebuild-install such as better-sqlite3, node-pre-gyp): only the build machine’s binary exists, so the build fails, on a linux machine too, which has one architecture.

The bundle then runs on linux only: protobase serve of it on macOS cannot load the add-ons. Check it in a linux container of a run image with the C++ runtime, built from this Dockerfile (docker build -t protobase-run ., with --platform linux/amd64 for the other architecture):

FROM debian:trixie-slim AS libs
RUN d=/usr/lib/$(uname -m)-linux-gnu && mkdir -p /out$d && cp -a $d/libstdc++.so.6* $d/libgcc_s.so.1 /out$d/
FROM oven/bun:1.4.2-distroless
COPY --from=libs /out/ /
Terminal window
docker run --rm -p 8787:8787 -e DATABASE_URL -e BETTER_AUTH_SECRET \
-v "$PWD/dist:/app:ro" -v "$PWD/protobase-serve.js:/opt/protobase/protobase-serve.js:ro" \
protobase-run --no-install /opt/protobase/protobase-serve.js /app/protobase.config.js
Terminal window
protobase build-serve [--out dist/protobase-serve.js] # or, in this repository: pnpm build:serve

Bundles the serve runtime (src/cli/serve/main.ts) and all its dependencies into one file, about 3 MB, also starting with // @bun. A host needs Bun and this file, nothing else.

Terminal window
bun --no-install protobase-serve.js <bundle>/protobase.config.js # production, under Bun: the API
protobase serve <bundle>/protobase.config.js # the same, from source under Node
protobase serve <bundle> # the whole bundle as a host serves it, e.g. pnpm --filter erp serve

Serves the API of a bundle from protobase build and owns the process around it. Given the bundle folder, protobase serve also serves its UI by the manifest’s rules, so a bundle can be checked locally exactly as the host will serve it.

Variable
DATABASE_URL the API’s pool (Kysely over pg, 10 connections); not needed when the config exports db
PORT default 8787
REQUEST_LOG any non-empty value logs every request

Better Auth and other settings are the project’s own variables (BETTER_AUTH_SECRET, BETTER_AUTH_URL, …), read by its config. protobase serve also loads the nearest .env, like dev; the runtime takes the environment as it is.

  • Routes: createAdmin (/api/v1, /api/meta, /api/auth/*, …) and GET /health, which answers {"status":"ok"} without a token and without touching the database. The runtime never serves the UI; protobase serve <bundle> serves public/ for every path outside the manifest’s api, after /health.
  • Startup: a bundle without a default export, config or authenticate, or with an options.basePath other than /api/v1, stops startup with a one-line error and exit code 1, as does a missing DATABASE_URL or a taken port. protobase serve listening on port <port> means it is ready.
  • Shutdown: the first SIGTERM or SIGINT stops accepting connections, waits for open requests, drains the pool serve created (a db the config exports is the project’s to close) and exits with 0.
  • Host modules: under Bun, the runtime registers the modules it supplies as virtual modules, so the bundle needs no node_modules for them. Under Node, protobase serve resolves them from the node_modules next to the bundle, so keep the bundle inside the project. Native packages load from the bundle’s own node_modules/ under both.
  • Read-only hosts: nothing is written: no install (--no-install), no transpiling and so no transpiler cache. A bundle with node_modules/ needs its folder mounted without noexec, since its add-ons are mapped as executable code.
Terminal window
protobase users create <email> [--name <name>] [--role <role>] [--password-stdin | --generate-password]
protobase users list
protobase users roles
protobase users set-role <email> <role>
protobase users disable <email> # and: enable <email>
protobase users delete <email> [--yes]

Run inside a project whose protobase.config.ts exports auth (Better Auth). There is no sign-up and no web setup page: the first admin is created here. The first user is always an admin, whatever --role says; later users get --role, or the project’s default role (createAuth({ roles, defaultRole })). protobase users roles lists the roles of the project; anything else is refused with that list. set-role also takes comma separated roles (sales,accountant).

The password is typed at a hidden prompt with confirmation, read from stdin with --password-stdin (for scripts, e.g. pass show erp | protobase users create me@example.com --password-stdin), or generated with --generate-password and printed once. A password is never accepted as an argument, since it would end up in shell history. users list prints email, role and creation date (and disabled for banned users), never credentials.

delete asks you to type the email back (or pass --yes), and needs a terminal otherwise. delete, set-role and disable refuse to touch the last admin, so the project cannot lock itself out. disable bans the user and revokes their sessions; tokens already issued stop working within their lifetime (15 minutes by default), and protobase token refuses disabled users. enable lifts the ban. protobase dev prints “No users yet. Create the first admin with: protobase users create you@example.com” at startup while the user table is empty.

Terminal window
protobase token <email> [--ttl 15m]

Prints a bearer token (JWT, signed with the project’s Better Auth keys, same claims as a browser token) for an existing user. The lifetime is 30s, 15m, 24h, … up to 24 hours, default 15 minutes. Only the token goes to stdout and errors go to stderr, so it composes in scripts:

Terminal window
curl -H "Authorization: Bearer $(protobase token me@x)" http://localhost:5173/api/v1/orders?page_size=1
Terminal window
pnpm --filter erp db:up && pnpm --filter erp db:migrate && pnpm --filter erp db:seed --scale small
pnpm --filter erp protobase scaffold postgres://protobase:protobase@localhost:55432/protobase \
--yes --ui --exclude auth --exclude public.schema_migrations --exclude public.seed_info
pnpm --filter erp protobase doctor postgres://protobase:protobase@localhost:55432/protobase

Always pass --exclude auth: the ERP keeps Better Auth’s users, sessions and signing keys in the auth schema of the same database, and scaffolding them would expose credentials through the admin API.

The integration test (src/cli/scaffold/scaffold.integration.test.ts) scaffolds into a temp folder and is skipped when the database is unreachable.