CLI
pnpm protobase <command> # from the repo rootpnpm --filter erp protobase <command> # from a project, config folder defaults to ./configHow it runs
Section titled “How it runs”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.
scaffold
Section titled “scaffold”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).
Inference
Section titled “Inference”| 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 NULLabsent:.optional(). Literal default:.default(v).- Computed defaults (function, sequence, expression,
GENERATED BY DEFAULTidentity):.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 asorder_date DEFAULT current_date, stay editable.GENERATED ALWAYSidentity 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 NULLcolumns, else the table is skipped with a warning. deleted_at,removed_at,archived_at(nullable timestamp/date) become.softDelete;organization_idandtenant_idbecome.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 forhr."EMP_MASTER"). - Resource names are the camelCased table name, prefixed with the schema on collisions. Names already in
config/win.
Re-running
Section titled “Re-running”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.
Drift report
Section titled “Drift report”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.
doctor
Section titled “doctor”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 INDEXstatement)
Exit code is 1 when there are errors.
protobase dev [--port 5173] [--env DATABASE_URL] [--cache-dir <dir>] [--allowed-hosts <hosts>] # run inside a project, e.g. pnpm --filter erp devOne 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.envat or above the project. The connection is checked before anything starts; if it fails, the command names host, port and reason (andpnpm --filter <project> db:upwhen the project has adb:upscript). The project needspginstalled, because the API connects with Kysely over the project’s ownpg. -
Cache: Vite’s dependency cache is
node_modules/.vite; two dev servers sharing it can disturb each other’s optimisation, so pass--cache-dirfor a second one (the integration test does). -
Login: real sign-in only, with the
authenticateandauthexported byprotobase.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 withprotobase users create. -
Tunnels and other hosts: Vite only answers
localhostby default.--allowed-hosts .trycloudflare.com,app.example.comadds 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 overwsson 443 as long as the tunnel forwards WebSockets. -
Project discovery, by convention:
config/index.tsexports the resources (and views), and everyconfig/*/ui.tsexport that is not already exported by the index is added as a view. Aprotobase.config.tsin 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 viewsdb, // Kysely instance, instead of DATABASE_URLauthenticate, // Authenticator, requiredauth, // Better Auth (AdminAuth); its routes are mounted under /api/authoptions, // AdminOptions (scan guard, write hooks, ...)} -
Project UI: the app imports
protobase.ui.tsxfrom the project root, with hot reload, when it exists (see Custom components). Layout files are checked as they load, asprotobase buildchecks them. -
Hot reload: server code, data configs,
ui.tsviews, layouts andprotobase.config.tsare loaded through Vite’s SSR module graph. A change inside the project (or insrc/server,src/query,src/schema) rebuilds the API on the next request without a restart;/metaandX-Meta-Versionchange, so open tabs refetch. The database pool survives reloads. Newly addedui.tsfiles appear after the next change inside the project.
protobase build [--out dist] # run inside a project, e.g. pnpm --filter erp protobase buildWrites 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 manifestThe 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.
The manifest
Section titled “The manifest”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,publicandnodeModulesare relative to the bundle folder,spatopublic.nodeModulesis present only when the config module imports native packages. It is always thenode_modulesfolder besideserver, where Bun and Node resolve the module’s imports from, and holds plain packages: JavaScript,package.jsonfiles and native binaries for linux x64 and arm64 (glibc), which the runtime loads withdlopen. A host keeps it next to the server module and must not mount itnoexec(an add-on there then fails withfailed to map segment from shared object). Its binaries link nothing but glibc,libstdc++.so.6,libgcc_s.so.1and libraries in the bundle itself, so the run image must have those three. WithoutnodeModules, the server module is the only file the runtime reads.apilists the path prefixes the server answers. A path is under a prefix when it equals it or continues it with/(/apicovers/api/metaand/api/v1:batchWrite, not/apiary). It is always["/api"]: a bundle assumes the API lives under/api, with the resources at the defaultbasePath/api/v1,/api/metaand the docs beside them and Better Auth at/api/auth. The serve runtime refuses a config that sets anotheroptions.basePath.- Every other path is the UI’s. A
GETorHEADfor a file inpublicgets that file; a folder gets itsindex.html. Otherwise a request whoseAcceptheader containstext/html(a navigation) getsspawith status 200, so deep links such as/orders/42load 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 withCache-Control: public, max-age=31536000, immutable, and everything else withno-cache. - A host that does not know
versionmust refuse the bundle. Version 2 replaced version 1 when the config module started to import every module the runtime supplies, not onlyprotobase/*andkysely, and gainednodeModules.protobase serverefuses a version 1 bundle; rebuild it.
The UI
Section titled “The UI”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.
The config module
Section titled “The config module”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
dependenciesbelong to the serve runtime: their imports stayimportstatements 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/serverprotobase’s server entry points, and what layout files compile to kysely,kysely/helpers/postgres,pg,postgresthe database better-auth,hono,jose,zod,aip-parsers,@hono/node-server,@scalar/hono-api-referencethe 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 issrc/cli/serve/host-module-ids.ts; the set of packages comes from protobase’spackage.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 ofprotobase/jsx-runtime, for production whateverNODE_ENVsays. The build refuses one with a function, class,newvalue or hook in its JSX, naming the file, line and column (what a layout may hold). -
The default export is the project config, with
configalways 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.
Native packages
Section titled “Native packages”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,cpuandlibcfields). - Every add-on needs a
.nodebinary 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 linkslibgcc_sand, when written in C++,libstdc++. Bun’s distroless image has glibc only, so the run image adds those two (Debian’slibgcc-s1andlibstdc++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,bcrypt6,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, inpnpm-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 libsRUN 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-distrolessCOPY --from=libs /out/ /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.jsbuild-serve
Section titled “build-serve”protobase build-serve [--out dist/protobase-serve.js] # or, in this repository: pnpm build:serveBundles 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.
bun --no-install protobase-serve.js <bundle>/protobase.config.js # production, under Bun: the APIprotobase serve <bundle>/protobase.config.js # the same, from source under Nodeprotobase serve <bundle> # the whole bundle as a host serves it, e.g. pnpm --filter erp serveServes 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/*, …) andGET /health, which answers{"status":"ok"}without a token and without touching the database. The runtime never serves the UI;protobase serve <bundle>servespublic/for every path outside the manifest’sapi, after/health. - Startup: a bundle without a default export,
configorauthenticate, or with anoptions.basePathother than/api/v1, stops startup with a one-line error and exit code 1, as does a missingDATABASE_URLor 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
servecreated (adbthe 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_modulesfor them. Under Node,protobase serveresolves them from thenode_modulesnext to the bundle, so keep the bundle inside the project. Native packages load from the bundle’s ownnode_modules/under both. - Read-only hosts: nothing is written: no install (
--no-install), no transpiling and so no transpiler cache. A bundle withnode_modules/needs its folder mounted withoutnoexec, since its add-ons are mapped as executable code.
protobase users create <email> [--name <name>] [--role <role>] [--password-stdin | --generate-password]protobase users listprotobase users rolesprotobase 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.
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:
curl -H "Authorization: Bearer $(protobase token me@x)" http://localhost:5173/api/v1/orders?page_size=1ERP example
Section titled “ERP example”pnpm --filter erp db:up && pnpm --filter erp db:migrate && pnpm --filter erp db:seed --scale smallpnpm --filter erp protobase scaffold postgres://protobase:protobase@localhost:55432/protobase \ --yes --ui --exclude auth --exclude public.schema_migrations --exclude public.seed_infopnpm --filter erp protobase doctor postgres://protobase:protobase@localhost:55432/protobaseAlways 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.