Scaffolding CLI & Multi-Client Distribution
A second binary joins twill-bench and engine-server: twilldb, a project scaffolder that generates a ready-to-run starter app for a chosen client. This page captures its design and, more importantly, the distribution strategy it anchors — how the CLI binary reaches users (a Homebrew tap, cargo, binary downloads) versus how the language clients reach theirs (each ecosystem's own registry), all riding the one frozen C ABI.
Why a scaffolding CLI
The embedded payoff — a persistent, in-process database with zero infrastructure — is only one open() call away, but a new user still has to assemble a project around it: a package.json that depends on the client, a connection string for the right backend, a first table, and a runnable script. twilldb new collapses that into one command, emitting a working starter that mirrors the verified examples under clients/bun/examples/.
The name was deliberately chosen for distribution-cleanliness
The bare name twill is already taken across the channels that matter: it is a mature CLI on PyPI (so the command name collides on any machine that pip-installed it), and a registered package on npm and crates.io. twilldb is free on npm, crates.io, and Homebrew, and matches the @twilldb npm scope the project already owns. The binary, the crate (twilldb-cli), and the tap are all named twilldb.
Design principles
- MUST be a dependency-free Rust binary in its own crate (
crates/cli), hand-rolling argument parsing and placeholder substitution like the rest of the workspace — noclap, no template engine. It only writes files; it never links the engine. - MUST embed its templates at compile time (
include_str!), so the binary is self-contained and works offline — no network fetch, no version skew between binary and templates. - MUST be language-neutral: the scaffolder generates projects for multiple client ecosystems, so it is not itself published into any one of them as the primary channel.
- MUST NOT overwrite existing files;
newrefuses a non-empty target directory and both subcommands refuse to clobber an individual file. - SHOULD mirror the dispatch shape of
twill-bench— a thinmain.rsover a testablerun_cli(&[String]) -> i32core — so exit codes are asserted without spawning a process. - SHOULD recognise not-yet-shipped clients and answer with a roadmap-aware message, not "unknown value".
- SHOULD be interactive on a terminal but never block automation: prompt only for fields omitted on the command line, and skip prompting entirely when stdin is not a TTY (falling back to defaults). The wizard is pure over its read/write streams so it is testable without a pseudo-terminal.
Command structure
twilldb new <name> [options] # create a new project in ./<name>
twilldb init [options] # scaffold into the current directory
twilldb version # print the version
twilldb help # usage
# options
-c, --client <bun> # client ecosystem (default: bun)
-b, --backend <file|s3> # storage backend / connection string (default: file)
--vector # include a vector-search (HNSW) starter
-y, --yes # accept defaults; never prompt
# examples
twilldb new # full interactive wizard (on a terminal)
twilldb new notes
twilldb new search --vector
twilldb new app --backend s3
twilldb init
On a terminal, any option not given as a flag is filled by an interactive wizard — it prompts only for the unspecified fields (name, client, backend, vector), shows a summary, and confirms before writing. The trigger is TTY-aware: a non-terminal stdin (a pipe, or CI) never enters the wizard, so automation can't hang — it takes the defaults for omitted options (and still errors if new has no name). --yes forces that non-interactive behaviour on a terminal too. The wizard logic is pure over its read/write streams, so it is unit-tested with canned input rather than a pseudo-terminal.
The generated Bun starter is a working @twilldb/bun project: package.json (pinned to the workspace version), tsconfig.json, .gitignore, an app.ts derived from examples/notes.ts, and a README.md. --backend only changes the connection string written into app.ts (file://./<name>.db vs an s3://your-bucket/<name> placeholder) — the engine selects the backend purely by URL scheme, so nothing else differs. --vector adds a vectors.ts starter (a vector(3) column, an HNSW index, a top-k query) and wires a vectors script.
| Exit code | Meaning |
|---|---|
| 0 | success |
| 1 | generation failure (I/O, or a refused overwrite) |
| 2 | usage error (bad flags, unknown subcommand, unavailable client, unsafe name) |
The C ABI is the universal client seam
Only the Bun starter ships today; node, php, and rust are recognised by --client but answer with a roadmap message rather than generating. That is not a placeholder accident — it reflects the architecture. Every language client is a thin FFI shim over the same frozen C ABI (crates/engine/include/engine.h) and the same prebuilt libengine binaries; none reimplements the engine. The Bun client (bun:ffi over the header, with per-platform @twilldb/engine-* binary packages as optional dependencies) is the reference shape each future client copies.
| Client | Binding mechanism | Ships via | Status |
|---|---|---|---|
| Bun | bun:ffi over engine.h | npm @twilldb/bun | SHIPPED (reference impl) |
| Node | koffi (FFI) — reuse the Bun ergonomic wrapper, swap the loader | npm @twilldb/node | SHIPPED (see 20) |
| PHP | built-in FFI extension (FFI::cdef over engine.h) | Composer / Packagist | SHIPPED (see 20) |
| Python / Ruby / Go | ctypes·cffi / Fiddle·ffi / cgo | PyPI / RubyGems / go modules | ROADMAP |
| Rust | native (the engine crate) | crates.io | ROADMAP |
Adding a client is additive — it never touches the engine or the seam
A new language binding is a new clients/<lang>/ shim plus a new --client template; it requires no engine change, no ABI bump, and no storage-seam movement. The single extension point in the CLI is the template set behind --client.
Distribution: two different problems
"How do we distribute this?" has two separate answers, and conflating them is the common mistake. The CLI / server binaries are standalone executables; the client libraries are language packages. They travel different roads.
The CLI binary → a Homebrew tap (and friends)
The CLI is distributed primarily through a Homebrew tap — a self-hosted formula repo (bihaviour/homebrew-twilldb). A tap has no notability/maturity gate and needs no registration, unlike homebrew-core (which also would not accept the project's BUSL-1.1 license, a source-available license rather than an OSI-approved one). Users run:
brew tap bihaviour/twilldb # → github.com/bihaviour/homebrew-twilldb
brew install twilldb
# or the fully-qualified one-liner (Homebrew needs three parts: user/repo/formula)
brew install bihaviour/twilldb/twilldbThe formula builds from source via cargo (depends_on "rust" => :build), the simplest robust option for v1 — no per-platform binary matrix to maintain. .github/workflows/release-cli.yml keeps the tap current: on every v* tag it downloads the source tarball, computes its sha256, renders Formula/twilldb.rb, and pushes it to the tap repo (using a HOMEBREW_TAP_TOKEN PAT). The canonical formula and setup steps live in packaging/homebrew/.
Same binary, more channels — all gate-free
Because the CLI is a plain Rust binary, the same release also feeds cargo install twilldb-cli, a Scoop bucket (Windows), and a curl | sh install script over GitHub-Release binaries. The "own tap / own bucket / own flake / AUR" variants of every native package manager have no maturity gate — that gate exists only for the central repos (homebrew-core, winget, official distro archives). Those extra channels are intentionally out of scope for the first pass; if build-from-source time becomes a concern, the tap switches to prebuilt bottles (a binary matrix + bottle do … end blocks).
The client libraries → each ecosystem's own registry
Homebrew is irrelevant for the language clients. A Bun/Node developer installs from npm; a PHP developer from Composer; a Python developer from PyPI. Each client ships through the registry its users already install from — and none of those have a maturity gate either.
The shared release pipeline (the scaling lever)
The piece that makes a polyglot client matrix tractable is building libengine once per platform and fanning the same artifacts out. The project already does the npm half (the @twilldb/engine-{os}-{arch} per-platform packages that @twilldb/bun depends on); every other ecosystem reuses those same binaries.
build libengine.{so,dylib,dll} × {linux,darwin,win} × {x64,arm64}
├── npm: @twilldb/engine-{os}-{arch} (Bun + Node depend on these)
├── Packagist: PHP wrapper loads the matching binary via FFI
├── PyPI: wheels bundling the binary
└── Homebrew tap / Scoop / cargo / curl|sh: the CLI + server binariesVerification — what ships today
| Capability | Status | Notes |
|---|---|---|
twilldb new / init, Bun starter | SHIPPED | 5-file Bun project (6 with --vector); name/version/backend substitution; mirrors examples/notes.ts |
--backend file|s3, --vector | SHIPPED | backend changes the connection string only; vector adds vectors.ts + script + README section |
| Safety (no clobber), exit-code contract | SHIPPED | refuses non-empty dir / existing files; 0 / 1 / 2 contract pinned by crates/cli/tests/scaffold.rs |
Interactive wizard (TTY-aware), --yes | SHIPPED | prompts only unspecified fields; non-TTY/--yes falls back to defaults; pure over streams, unit-tested in crates/cli/tests/wizard.rs |
| Homebrew tap formula + release workflow | SHIPPED | packaging/homebrew/twilldb.rb + release-cli.yml (builds from source; auto-bumps url+sha256 on tag) |
| Node & PHP starters | SHIPPED | embedded starters via --client node|php (@twilldb/node / twilldb/twilldb); per-client install/run steps; see 20 — Client Runtimes |
| Python / Ruby / Go / Rust clients | ROADMAP | recognised by --client; thin FFI shims over the same C ABI when built |
cargo install / Scoop / curl|sh / bottles | ROADMAP | same release artifacts; gate-free; deferred from the first pass |
--server starter variant | ROADMAP | a pgwire-connected starter (vs embedded); deferred |