@rindle/cli is the JS/TS developer toolchain for Rindle. It installs the rindle
CLI together with matching rindle-replicator, rindled, and rindle-dev-edge
binaries as prebuilt, per-platform npm artifacts — no Rust toolchain required for app
development.
Use it to:
- scaffold a local topology with
rindle init - own the complete local fleet + app lifecycle with
rindle dev - supervise only the local fleet with
rindle up - query or modify a database with
rindle sql - inspect local HCTree master and wal2 follower files with
rindle db - apply pure-DDL and pure-DML migrations with
rindle migrate - generate
@rindle/clientTypeScript schema withrindle schema gen - deploy, link, and migrate Rindle Cloud apps
Install
pnpm add -D @rindle/cli
# or npm i -D @rindle/cli
Run it through the package manager’s bin resolution:
npx rindle --help
pnpm exec rindle status
The package selects the platform artifacts it needs through optional dependencies. All
four binaries are versioned and installed together, so rindle dev can always find the
master, follower, and edge it was released with.
Supported platforms
Prebuilt binaries ship for macOS (Apple Silicon + Intel) and Linux (x86_64 + arm64, glibc + musl).
Windows: use WSL2. There is no native Windows build — Rindle’s storage engine is
Linux-only. Installing on Windows fails fast with a message pointing here rather
than silently half-working. WSL2 runs a real Linux kernel, so the normal Linux binaries
and the whole rindle dev loop work unmodified inside your distro. Your app and browser
stay on Windows: WSL2 forwards localhost, so a dev server started in the distro is
reachable from a Windows browser. The browser tier is wasm and doesn’t care what OS
it runs on.
One rule that matters:
Keep the database on the WSL filesystem (
~/), never under/mnt/c. Windows drives are exposed to WSL through a translation layer that can’t reliably back a memory-mapped database — the same reason not to put SQLite on a network share. Your source tree can sit wherever you like. The data directory can’t. It’ll also be dramatically faster on the Linux side.
WSL1 is not supported: it emulates the memory-mapping and file-locking calls the engine depends on, rather than implementing them.
Know where a command runs
The CLI separates project files, local processes, app connections, and Rindle Cloud. “Remote” is
not a mode: --url means a direct app ingress and can point to localhost, a self-hosted server, or
a managed app’s Connect URL. --cloud means the authenticated Rindle Cloud control-plane proxy.
| Command | Scope | App target selectors |
|---|---|---|
context |
explains available targets; contacts none | none |
init, render |
project files | none |
migrate list, migrate create |
local migration files | none |
indices suggest |
local shapes file; optionally compares live indexes | --local or --url; no target is valid |
up, dev, exec, ps, stop |
local fleet/process lifecycle | none |
db |
local HCTree/wal2 database file | none; choose a role, component, or path |
version, status, stats, health, schema |
running app | --local or --url |
sql, analyze query, restart, dematerialize, re-bootstrap |
running app | --local or --url |
migrate apply, migrate status |
running app | --local, --url, or --cloud |
login, logout, whoami, deploy, link |
Rindle Cloud control plane | none; these are always Cloud commands |
backup … |
backup store or downloaded object | none |
For a running-app command, choose exactly one target:
rindle status --local # this project's rendered/authored topology
rindle status --url https://example.test # one directly reachable ingress
rindle migrate status --cloud # app bound in .rindle/cloud.json
With no selector, RINDLE_URL wins. Otherwise the CLI discovers rindle.json or rindle.ncl in
the project. If neither exists, it fails with a target-selection error. There is deliberately no
implicit localhost:7600. --local forces topology discovery even when RINDLE_URL is set.
Run rindle context to see the direct connection environment, local read/write endpoints, Cloud
binding, and default selection without contacting any of them. rindle context --json is suitable
for scripts. --remote remains a deprecated alias for --cloud during migration.
Local dev loop
Most apps use one command for the complete local lifecycle:
npx rindle init
npx rindle dev --migrate --gen shared/schema.gen.ts -- vite dev
rindle dev evaluates rindle.ncl once, starts the write-master + follower + local fleet edge,
waits for every public read component, applies pending migrations/*.sql, waits for the follower
to replicate them, generates shared/schema.gen.ts, and only then starts the app. It injects the
unified RINDLE_URL + RINDLE_DATABASE_TOKEN connection, watches requested migration/schema
inputs, forwards signals, and tears down the app and fleet together.
Use rindle up when you deliberately want only the fleet. rindle exec remains a compatibility
adapter for running a one-shot command with topology-derived bindings, but normal development no
longer needs either a second supervisor or a readiness script.
Running several projects at once
Local ports are allocated per project, not fixed. Each project gets a 100-wide block, chosen
from the path of the directory holding rindle.ncl and remembered in ~/.rindle/ports.json. So two
Rindle projects — or two git worktrees of one project — can run at the same time without contending
for a port. Within the block:
| Component | Port |
|---|---|
follower i (private rindled) |
portBase + i*2 (control), portBase + 1 + i*2 (ws) |
| replicator (write-master) | portBase + 11 (control), portBase + 10 (fan-out ws) |
| fleet edge (the app-facing URL) | portBase + 50 |
You don’t need these numbers: rindle dev injects RINDLE_URL, rindle.json’s bindings
carries the resolved URLs, and rindle render / rindle up print them. To pin a block instead, set
portBase in rindle.ncl — portBase = 7600 reproduces the fixed ports used before allocation.
Each project also carries a fingerprint of its root path, which its daemons advertise on
GET /version and the CLI asserts on every control-plane call. If a command reaches a daemon from a
different project, it is refused with a 409 naming both — rather than, say, applying one app’s
migrations to another app’s database. This is why the fingerprint is derived from the path and not
from the app name: two worktrees of one repo share a name and differ only by where they live.
$ rindle migrate apply
daemon error 409: wrong project: this rindle-replicator write-master serves 'my-app'
(3b90321256fd), but the command came from a DIFFERENT checkout of 'my-app' (3b90621256fd).
Refusing: applying it would have written one project's migrations into the other's database.
Starting a fleet whose ports are already taken fails immediately and names the holder, instead of retrying in the background:
$ rindle up
✖ cannot start 'my-app' — 5 of its port(s) are already in use
127.0.0.1:20911 (replicator) held by rindle app 'other-app' (7b20e8449c31)
Another Rindle fleet ('other-app') is running and owns these ports.
The replication connection is fenced the same way: a follower stamps its fingerprint on the
subscribe it sends to the write-master, and a master serving a different project refuses it before
sending a single frame. Without that, a follower whose own master lost the port race replicates
the other project’s entire journal into its database. It then serves those rows as its own. The
refusal is terminal, not a retry — the follower halts that source and says so, since reconnecting
only re-asks the same wrong master.
Peers that send no identity — a browser client, @rindle/sql-client, curl, a BYO relay consumer —
are never fenced. Neither are deployed daemons, which carry no project identity at all. To drive
another project’s daemon on purpose, set RINDLE_PROJECT_ID to its fingerprint (or to the empty
string to assert nothing).
Upgrading from the fixed ports
Before this, every project rendered the same loopback ports: the follower on :7600/:7601, the
write-master on :7610/:7611, the fleet edge on :7650. Your fleet moves to its own block on the
next rindle up, so anything that hardcoded one of those numbers stops working. Two ways
forward:
- Recommended — stop hardcoding.
rindle devinjects the resolved URLs as environment (RINDLE_URL,RINDLE_DAEMON_URL,RINDLE_REPLICATOR_URL, …) andrindle.json’sbindingscarries them for anything that reads the manifest directly. Prefer a hard failure over a default: a literal?? "http://127.0.0.1:7600"no longer points at nothing, it points at whatever other project holds that port. An app-tier client sends no project identity, so nothing stops it reading the wrong database. - Pin the old block.
portBase = 7600inrindle.nclreproduces the previous ports exactly. Fine for a single-project machine. It gives up the “two checkouts at once” property.
Nothing about a deployed fleet changes: production renders carry no project identity and no
allocation, and rindle deploy is unaffected.
Inspect local database files
Stock sqlite3 cannot open Rindle’s HCTree master or wal2 follower files. rindle db uses the
same Bedrock SQLite build as the runtime and opens the selected file physically read-only:
# Resolve these roles through rindle.json and each component's generated config:
npx rindle db master
npx rindle db follower
npx rindle db follower-0 "SELECT * FROM issue LIMIT 10"
# An explicit path is also accepted; --json makes one-shot output machine-readable:
npx rindle db ./data/follower.db --json "SELECT * FROM _rindle_source_offsets"
With no statement and a terminal on stdin, the command opens an interactive shell. It supports
.tables, .schema [name], .indexes [table], .mode table|json, and .quit. SQL can instead
come from --file or piped stdin. Use --manifest <path> when the rendered manifest is not
./rindle.json. If a fleet has multiple followers, use the component name rather than the
ambiguous follower alias.
The command opens with SQLITE_OPEN_READ_ONLY, forbids ATTACH, and rejects any statement SQLite
marks writable. It never changes journal mode or checkpoints the source. A stopped wal2 database
whose shared-memory sidecar has already disappeared can refuse a strictly read-only connection.
Start its follower or inspect a restored portable base instead. Use rindle sql for authoritative
application reads and writes over the live ingress. rindle db is the local operator/debugging
surface.
Migrations and schema generation
Under rindle dev, migrations apply to the write-master and the schema regenerates from a follower
automatically. Local one-shot commands discover those roles from rindle.ncl, so they need neither
hard-coded ports nor a nested rindle exec:
npx rindle migrate create init
npx rindle migrate apply
npx rindle migrate status
npx rindle schema gen --out shared/schema.gen.ts
Each migration file is classified as pure DDL or pure DML. Mixed files fail before any
request. Destructive DDL (DROP TABLE, DROP COLUMN, and DROP INDEX) is accepted with a
[destructive] notice. The reviewed file is the consent. DML is evaluated once on the
master and followers receive captured row deltas, not the SQL text. migrate list and
migrate status show and verify each file’s kind and checksum.
For Rindle Cloud, deploy or link first records a non-secret app binding, then
--cloud sends migrations through the authenticated Cloud proxy:
npx rindle login
npx rindle deploy --migrate
# Or bind an SQL app created in the dashboard:
npx rindle link app_…
npx rindle migrate status --cloud
npx rindle migrate apply --cloud
For a directly reachable self-hosted ingress, use the same application connection as your server:
npx rindle schema gen \
--url "$RINDLE_URL" \
--token "$RINDLE_DATABASE_TOKEN" \
--out shared/schema.gen.ts
RINDLE_DATABASE_TOKEN is server/operator-only. Never ship it to the browser. Browser
clients call your API server, and authorized query leases return a public WebSocket
endpoint plus a placement ticket.
rindle dev vs. rindle up vs. rindled
rindled is the read-follower daemon. The rindle-replicator write-master feeds it.
rindle up is the local-development supervisor that renders your rindle.ncl, starts
the pair, watches it, applies migrations to the master, and generates schema from the
follower. rindle dev adds readiness gates, the application process, unified bindings, and one
signal/teardown boundary.
For application development, prefer rindle dev. For production, run the pair under your
real process supervisor or use Rindle Cloud:
- local dev:
npx rindle dev --migrate --gen shared/schema.gen.ts -- vite dev - self-hosted production: run the master, followers, and edge from release artifacts, container images, or your own supervisor, then use the unified ingress
- managed production:
rindle deploya Sync topology orrindle linka dashboard-created SQL app, then usemigrate --cloud.
Command reference
Running-app commands use the shared target selection described above. Aliases are in parentheses.
| Command | Scope | What it does |
|---|---|---|
context (ctx) |
explain only | show local, direct, and Cloud targets plus the default |
status · stats |
running app | up/down + commit + boot id + the live counters; repaint with --watch |
version |
running app | liveness + deployed commit (no token needed) |
health / ready |
running app | liveness (+ auth) probe with a meaningful exit code |
schema [show] |
running app | the deployed base-table shape |
schema gen (generate) |
running app | emit the @rindle/client schema TS — --out <file> (default stdout), --import-from <module>, --schema-const <name> |
migrate apply (up) |
running app | apply ordered pure-DDL or pure-DML *.sql; --cloud selects the bound Cloud app |
migrate list (ls) |
project files | list local migrations with kind and checksum |
migrate create <name> (new) |
project files | scaffold a new migration file |
migrate status |
running app | verify local kind/checksum against the applied journals; accepts --cloud |
sql [<statement>] |
running app | run SQL from an argument, --file, or stdin; multiple statements form one atomic batch |
db <target> [<statement>] |
local DB file | inspect an HCTree master or wal2 follower read-only; target a role, component, or path |
init · render [rindle.ncl] |
project files | scaffold or render the local topology |
dev -- <command> · up · exec -- <command> |
local runtime | own/supervise the local fleet or run with topology-derived bindings |
ps · stop (kill) |
local runtime | list / stop running Rindle processes (stop --all, or by pid) |
restart · dematerialize · re-bootstrap |
running app | lifecycle, cleanup, and recovery operations |
login · logout · whoami |
Cloud control | Rindle Cloud auth (browser device flow; $RINDLE_CLOUD_TOKEN overrides for CI) |
deploy · link <app-id> |
Cloud control | provision or bind an app and write .rindle/cloud.json |
rindle --help prints the full list, including the fleet/topology and recovery
commands (render, dematerialize, re-bootstrap).
Flags & environment
App-target precedence is explicit selector → RINDLE_URL → local topology → error.
| Flag | Env var | Default | Meaning |
|---|---|---|---|
--local |
— | off | force this project’s rindle.json/rindle.ncl target, ignoring RINDLE_URL |
--url <url> |
RINDLE_URL |
local topology, then error | connect directly to one Rindle ingress. There is no fixed-port default: ports are allocated per project, so a local fleet is reached through its derived URL |
--token <bearer> |
RINDLE_DATABASE_TOKEN |
(none) | unified server/operator bearer; legacy RINDLE_TOKEN / RINDLE_DAEMON_TOKEN remain fallbacks |
--cloud |
— | off | (migrate apply/status) use the bound app through Rindle Cloud |
--remote |
— | off | deprecated alias for --cloud |
--topology <file> |
— | rindle.json, then discovered rindle.ncl |
explicit local topology for a running-app command |
--cloud-url <url> |
RINDLE_CLOUD_URL |
https://cloud.rindle.sh |
Rindle Cloud control plane |
--dir <path> |
RINDLE_MIGRATIONS_DIR |
migrations |
the migrations directory (migrate *) |
--out <file> |
— | stdout | where schema gen writes |
--gen <out> |
— | — | (dev/up) regenerate the schema TS to <out> after --migrate — always takes the path |
--json |
— | off | machine-readable output |
--watch, -w |
— | off | repaint on an interval (status/stats); under up, re-apply/regen on change (dev watches requested inputs automatically) |
--interval <s> |
— | 2 |
the --watch repaint interval |
--timeout <s> |
— | 5 |
per-request timeout |
--manifest <file> |
— | rindle.json |
(db) rendered fleet manifest used to resolve master/follower component paths |
--daemon-bin <p> |
RINDLE_DAEMON_BIN |
sibling of rindle, then $PATH |
(dev/up) explicit path to the rindled binary |
| — | RINDLE_REPLICATOR_BIN |
sibling of rindle, then $PATH |
(dev/up) explicit path to the write-master binary |
| — | RINDLE_DEV_EDGE_BIN |
sibling of rindle, then $PATH |
(dev/up) explicit path to the native local fleet-edge binary |
| — | RINDLE_BIN_DIR |
the platform npm package | (npm wrapper) a directory of locally built binaries to use instead of the prebuilt ones |
Supervise from Node
If you need to supervise the npm-installed daemon from Node, import the package helpers:
import { rindledBinaryPath, spawnRindled } from "@rindle/cli";
const child = spawnRindled(["--config", "./follower.json"]);
console.log(rindledBinaryPath());
Package scripts
If you want a package script, make it forward to the CLI:
{
"scripts": {
"rindle": "rindle",
"dev": "rindle dev --migrate --gen shared/schema.gen.ts -- vite dev"
}
}
Then run:
npm run dev
# or
npm run rindle -- status
Next steps
- Scaffold with create-rindle - a TanStack Start app that uses this toolchain for its dev loop.
- Schema & migrations - the SQL-first migration and schema-gen workflow.
- Run the daemon - the daemon’s config, two network planes, and restart behavior.
- Synced-app quickstart - the manual app setup
that uses
@rindle/clidirectly.