Commands
All commands run from the repository root with Bun. turbo runs the graph-aware, cached tasks
across every workspace that defines the script.
Development
Section titled “Development”| Command | What |
|---|---|
bun run dev |
every app with a dev script: api :3000, web :5173, site :4321, docs :4322. The web app stops if 5173 is taken rather than move to another port, whose pages the API would not trust |
bun run dev:app |
api + web only |
bun run dev:site · bun run dev:docs |
one Astro site: the marketing site, or your product’s docs |
bun run dev:manual |
the kit’s developer manual (these pages) on :4322, which no deploy publishes (DOCS_CONTENT=kit) |
bun run dev:desktop |
Tauri desktop shell around the dev server (needs Rust) |
bun run smoke:desktop |
build the desktop shell against a stub API on 127.0.0.1 and run it until its page has reached the API from the shell’s origin and loaded the sign-in page (needs Rust; a window opens for a few seconds; SMOKE_TIMEOUT_MS, default 60000; xvfb-run -a without a display). -- --server-choice builds and checks a shell that asks for its server instead |
Pipeline
Section titled “Pipeline”| Command | What |
|---|---|
bun run setup |
Create every local .env from the .env.example next to it, or complete an existing one: generated secrets (FORWARDED_CLIENT_SECRET shared by the root .env and apps/web/.env) and the sites’ local SITE_URL. Adds your product’s rows from the .env.product.example next to a file, generating each change-me one. Fills only absent or blank rows (and product placeholders), never prints a secret, and is safe to rerun |
bun run doctor |
Read-only local setup check: Bun, dependencies, app configuration, Docker CLI, Compose and daemon. -- --database=external skips Docker for your existing database; -- --build adds site/docs build configuration. -- --served[=<origin>] adds bun run serve’s: the origin, the served build, the .env rows it overrides, your product’s checks and hooks, its ports and a running one’s exposure; --logs <folder> with it finds a serve started with --logs, whose state there names its ports. When DATABASE_URL answers, it prints the server version, the database’s collation and shared_buffers, work_mem, effective_cache_size, max_connections and TimeZone, read in a read-only transaction, and whether its role may create databases, and warns when the collation is not C, C.UTF-8 or POSIX: an index probe on a text key costs about ten times as much under a language collation. It names the kit release kitVersion holds, a pre-release as one, notes rows of yours .env.product.example has and .env lacks, and what bun run setup would append for each, and warns about one still holding change-me, about a FORWARDED_CLIENT_SECRET that differs between .env and apps/web/.env, and about a role without CREATEDB. It applies no migration. A ! line is a warning that does not fail the run, such as .env rows that still name the product before a rename, and a · line names what it could not look at |
bun run serve <command> |
The whole app on one macOS or Linux machine without Docker, behind one origin on 127.0.0.1 (Serve from a laptop over Tailscale); not on Windows. build --origin <url> builds the web app into apps/web/build-serve.next/; preflight checks .env, the API’s own environment validation, the build and the ports, and changes nothing; start migrates and runs the API, the web server and the origin proxy, restarting any that exits; status; env prints the rows each process gets; launchd and systemd print a unit and its install commands; tailscale prints the tailscale serve command and an access policy. --port, --api-port, --web-port, --logs, --no-migrate, --no-keep-awake, --no-trust-proxy |
bun run verify |
lint, check and test, one after another: the Bun gates CI runs. The pre-push command. It replays cached results for unchanged packages and runs no database suite: before a merge or a release, TURBO_FORCE=1 bun run verify and bun run test:db:all. Add bun run build for the whole pipeline |
bun run check |
turbo check: svelte-check in the app and packages, astro check in the sites, then tsc -p scripts for the root’s own tooling. Each tsconfig’s include lists its package’s scripts/ beside src, so what is not listed is not typechecked |
bun run test |
test:scripts, the tests of the root scripts/ (every scripts/product*.test.ts of yours among them) and of infra/, then test:packages, turbo test --continue, so one failing suite does not cancel the others: bun test in the TS packages and the API, Vitest (unit + component in Chromium) in the app, and packages/ui’s contrast check, which recomputes every token pair’s ratio from theme.css. The component tests need Playwright’s Chromium; apps/web’s pretest installs it on first run. Vitest serves them on port 63315, or on VITEST_BROWSER_PORT when it is set, and then fails rather than move off a port that is taken. VITEST_BROWSERS names the browsers they run in (a test run’s only: vite dev and vite build ignore it): Chromium alone when unset, or a list such as chromium,webkit (Playwright’s webkit and firefox); a browser Playwright has not downloaded stops the run with the command that downloads it. apps/native runs its script tests. apps/docs builds your product docs and fails when a page under guides/ or reference/ is missing from the sidebar. Every TEST_DATA_* variable you set reaches the suites (and a run with one caches apart). It ends by smoke-running every benchmark (bun run bench -- --smoke). CI runs the three apart, beside the typecheck: test:packages with -- --only, without the typecheck turbo test runs first, and test:scripts and the smoke in another job. A command you add to test itself also needs a step in CI, or scripts/workspace.test.ts fails. On Windows, UPGRADING.md‘s blocks run in the Git Bash that comes with git, and the cases that run what only a Linux server runs (deploy.sh, the recovery and ops scripts, provision.sh, the demo reset, the workflows’ shell steps) or that need POSIX file modes or signals skip themselves; CI runs this gate on Windows too once the repository variable WINDOWS_CI is true |
bun run test:e2e |
Playwright against a production preview of apps/web, which it builds itself, and no API (includes axe, WCAG 2.2 AA). Only the web app is built. The preview listens on 4173, or E2E_WEB_PORT, which must be free; E2E_REUSE=1 tests a server already there. *.full.e2e.ts suites are left to test:e2e:full |
bun run test:e2e:full |
the *.full.e2e.ts Playwright suites, signed in, against the real API: a database created next to DATABASE_URL’s (or on E2E_POSTGRES_URL’s server, on this machine) and migrated, a verified user with your seed (productSeed, purpose: 'e2e'), the API and a preview of apps/web built for it on free ports (or E2E_API_PORT, E2E_WEB_PORT), then the database dropped, also after Ctrl-C. The role needs CREATEDB. E2E_PASS_ENV=NAME,… passes variables of yours to every process, and every TEST_DATA_* passes on its own. -- <args> go to Playwright. Builds into apps/web/build-e2e, never build/, and removes it. A failure prints the API log’s last lines and its path, and the run then removes that log with its temporary directory. A run killed outright leaves the database, which the next run drops, and $TMPDIR/starterdough-e2e-*, which nothing removes |
bun run test:db:all |
every database suite CI’s database job runs, one bun test at a time against the database DATABASE_URL names, migrated first: the kit’s suites with the flag each one’s CI step sets, then every *.db.test.ts that mentions RUN_DB_TESTS, with RUN_DB_TESTS=1: the tracked ones CI runs, and any that git does not ignore and is not committed yet. Ends with a pass or fail line per suite and exits 1 on any failure. A suite that exits 0 without running a test (every one skipped, or none defined, because its tests wait for a variable the run did not set) checked nothing: the closing summary names it instead of counting it as passed, and -- --strict exits 1 for it. -- <path> runs the suites whose path contains it; -- --list prints what would run. The suites write and create scratch databases, so point it at a development database you can throw away, whose role has CREATEDB. -- --scratch runs each suite on a database of its own, a copy of one migrated for the run with your database’s locale, dropped after the suite: none inherits another’s rows or statistics, and DATABASE_URL’s database is not written. A *.db.test.ts that reads a TEST_DATA_* variable the run did not set (process.env.TEST_DATA_…, not a comment that names it) is not run, and its line says which to set; that is no failure, --strict or not. A suite still running after 600 s is stopped, fails the run and its line says so, and the next one starts; -- --suite-timeout=<seconds> sets another limit |
bun run test:journey |
the customer journey (apps/api/src/customer-journey.db.test.ts), then every *.journey.db.test.ts of yours, each on a database of its own created next to JOURNEY_POSTGRES_URL’s (a /postgres URL on this machine) from template0 in the builtin C.UTF-8 locale, whatever the server’s default, migrated and dropped; a ✓/✗ line per journey, exit 1 on any failure. Each journey’s migrations and its test run get 120 s each. -- <path> runs those whose path contains it |
bun run bench |
every *.bench.ts (tracked, or not committed yet and not ignored), one bun <file> at a time from the repository root, then a line each: ✓, ✗ (a non-zero exit, or killed, or a process it left running on its output) or ! … skipped: <reason>; exits 1 on any ✗, and says “No benchmarks.” when there are none. -- --smoke sets BENCH_SMOKE=1 (each case once) and stops a file after 30 s, as bun run test runs it; -- <path> runs those whose path contains it; -- --list prints what would run |
bun run lint · bun run lint:fix |
Biome check / check and write (TS, JSON, CSS, Svelte, Astro; tabs, single quotes, 100 columns) |
bun run format |
Biome format only |
bun run build |
turbo build: every app |
bun run clean |
remove build outputs |
bun run licenses |
audit every dependency’s licence from the metadata an install left on disk. --all prints one line per package. --strict exits 1 on anything not already recorded in THIRD-PARTY.md or THIRD-PARTY.product.md (your decisions are scripts/product-licenses.ts), on an acknowledged package its notice file does not name, on an override that matches nothing installed or changes nothing it matches, and on a half of the scan (npm, python from the AI service’s virtualenv, or rust, the desktop shell’s crates, read through cargo metadata) it skipped because nothing was installed or no cargo could read it, unless --skip=<half> leaves that half out on purpose. CI runs --strict --skip=python in the lint job (npm and rust) and --strict --skip=npm,rust in the Python one |
bun run rename |
rename the kit to your product across the tracked text files: --name, --slug, --identifier, --scope. Dry run unless --write. LICENSE.md, THIRD-PARTY.md, CHANGELOG.md, UPGRADING.md and the script itself are left verbatim. It also rewrites the slug’s uppercase form in environment variable names (<SLUG>_STATE_DIR, a dash becoming an underscore), keeps every dependency map in a rewritten package.json in the order bun add keeps, and leaves “Starterdough kit” as it is. It renames the scope before a slash, before one a pattern escapes (/^@repo\/api-client/) and before a quote that ends a string ('@repo' + '/db'). It renames the shell’s crate in apps/native/src-tauri/Cargo.lock as a build would, moving its entry to where cargo sorts it, so the first build leaves the tracked lock alone. It prints a residue checklist of what a text substitution must not touch (Docker volumes, the dev database, .env, bun.lock, the kit’s own repository URL), where your domain goes, and what a deploy still publishes of the kit’s |
Database (Drizzle Kit, packages/db)
Section titled “Database (Drizzle Kit, packages/db)”| Command | What |
|---|---|
bun run db:up · bun run db:down |
Postgres 17 + pgvector (pgvector/pgvector:0.8.7-pg17) in Docker via infra/compose.dev.yml (host port POSTGRES_PORT, default 5433) |
bun run db:migrate |
apply the kit’s migrations (packages/db/drizzle), then yours (packages/db/drizzle-product), then verify nothing in either history is still pending and exit 1 if something is. A branch merge can interleave migration timestamps, which Drizzle’s migrator would otherwise skip; the message names the history, and UPGRADING.md (“Migrations”) says what to do. A migration whose leading lines hold -- no-transaction runs on its own, outside a transaction, so it can build an index CONCURRENTLY. It holds one statement, and db:migrate refuses, before it applies anything, a pending migration that could never run: a marked one of more statements, an unmarked CONCURRENTLY statement, a foreign key written before the unique key it needs. A second run waits for the first without holding up its index build. Its last line names the database it migrated and the migrations this run applied, each with its history, or says it applied none: ✓ migrations applied to 127.0.0.1:5432/acme (kit 28 of 28, product 2 of 2); this run applied 0002_notes (product). A DATABASE_URL set in your shell wins over the root .env |
bun run db:generate |
generate a migration of yours in packages/db/drizzle-product from the tables packages/db/src/schema/product.ts exports, then apply it with db:migrate. Every schema change goes through a migration, in development too: a migration can carry SQL the schema cannot describe (triggers, data fixes), and a database that skipped it no longer matches production. --custom --name=<name> writes an empty one for such SQL. --check writes nothing: it exits 1 and prints the SQL the next migration would hold when the schema needs one, and exits 0 with “No schema changes” otherwise. It needs no database, and bun run test makes the same check. After drizzle-kit writes a migration, a lone CREATE INDEX CONCURRENTLY (an index declared .concurrently()) gets the first line -- no-transaction, and a migration db:migrate would refuse is named and exits 1. --undo removes the newest migration of yours (its SQL, its snapshot and its journal entry) when git’s HEAD does not hold it (one committed on another branch alone counts as uncommitted) and the development database never ran it; it refuses otherwise, since a migration that may have run anywhere is fixed forward |
bun run db:kit-drift |
print the SQL your schema needs on the kit’s own tables (a Better Auth plugin or extra field, an index of yours) that no migration of yours holds yet. Put it in bun run db:generate --custom --name=<name>. Nothing is written; bun run test fails while any of it is outstanding |
bun run db:move-product-migrations |
once, when upgrading from a release before 1.1.0 with migrations of your own: moves them from the kit’s history to yours, keeping their SQL and timestamps, while the merge is still open (or later with --kit <release>), and names each moved table packages/db/src/schema/index.ts still exports instead of schema/product.ts. Each database’s next db:migrate moves their history rows |
bun run db:studio |
Drizzle Studio, over the kit’s tables and yours |
Auth and admin
Section titled “Auth and admin”| Command | What |
|---|---|
bun run auth:schema |
regenerate the Better Auth tables after changing plugins. It exits 1 until you put back the hand-added constraints it names (how); then put what db:kit-drift prints in db:generate --custom, check that it drops none of them, and db:migrate |
bun run admin:create -- --email … [--name …] [--yes] |
create or promote the first platform administrator. The password comes from ADMIN_PASSWORD or an interactive prompt, never from the command line (arguments land in shell history and ps). It runs through the kit’s own auth config with no bunx and no network, so it works inside the API container too. --yes is required to promote an account that already exists |
bun run seed |
demo data: demo@example.com / demo-password-1234 (SEED_PASSWORD overrides), one organization with a team, two workspaces, three documents, six finished jobs with their credit-ledger rows, and an audit trail, then runs your productSeed (packages/auth/scripts/product-seed.ts) for the demo owner, on every run. Idempotent for the kit’s rows. Refuses to run with NODE_ENV=production. The documents’ files go to the configured storage as uploads do, so they download and run jobs |
| Command | What |
|---|---|
bun run api:openapi |
write apps/api/openapi.json for non-TypeScript client generation |
bun run --cwd apps/api worker |
run the job worker as its own process (then WORKER_ENABLED=false on the API: bun run setup writes true into the root .env) |
Operations (infra/)
Section titled “Operations (infra/)”| Command | What |
|---|---|
docker compose up -d --build |
production stack from the repository root (COMPOSE_FILE in .env) |
COMPOSE_PROFILES=backup in .env, then docker compose up -d |
nightly backups (infra/backup); run --rm backup bun src/cli.ts backup|list|restore. Profiles live in .env, not on the command line: deploy.sh --remove-orphans drops containers of profiles it cannot see |
sudo REPO_URL=… EXPOSE=tailscale bash infra/scripts/provision.sh |
one-time VPS setup, as root (EXPOSE=public opens 80/443): Docker, Tailscale, deploy user, firewall, clone, .env. Or curl … | sudo … bash as in the runbook |
bash infra/scripts/deploy.sh |
pull IMAGE_TAG, run the pre-deploy hooks, take a release backup (RELEASE_BACKUP=off skips it), restart, wait healthy, hit /readyz, keep the three newest local builds, run the post-deploy hooks (what CI runs over SSH) |
docker compose run --rm backup bun src/cli.ts list --release |
the release sets deploys took; restore latest --release --drill checks the newest |
bash infra/scripts/recover-deployment.sh status|recover|abandon <revision> |
an interrupted deployment, on the box: what Actions → Recover deployment runs |
docker run --rm -i -e API_URL=… grafana/k6 run - < infra/loadtest/k6/smoke.js |
load test the API |
The Server status, Server logs, Server backup and Admin bootstrap workflows inspect the box, back it up and create the first administrator from Actions, without a shell. See Operations and Server access and break glass.
Per app
Section titled “Per app”These are each app’s own scripts, not root ones. Run them from that directory, or from the root
with bun run --cwd apps/<app> <script>.
| App | Commands |
|---|---|
apps/web |
build (node) · build:node · build:cloudflare · build:static · build:static:desktop (into build-static/, what the Tauri shell bundles) · preview · check · test · test:e2e · i18n:compile · i18n:add <key> --en … --de … [--to <catalogue>] (a message into every locale’s catalogue, at its sorted place) · deploy:cloudflare |
apps/api |
dev · worker · start · start:worker · build · bun test · openapi |
apps/site, apps/docs |
dev · build · preview · check · deploy:cloudflare (astro build && wrangler deploy) · test (apps/docs only: builds your product docs and checks the sidebar) |
apps/native |
dev:desktop · preview:desktop · smoke:desktop · test · build:desktop · `android:init |
packages/ui |
bunx shadcn-svelte@latest add <name> -c packages/ui |
infra/backup |
bun src/cli.ts backup|list|restore|schedule (image: infra/backup/Dockerfile) |
services/ai |
uv sync · uv run uvicorn ai.main:app --reload --port 8000 (reads the services/ai/.env that bun run setup writes) · uv run pytest · uv run ruff check . |
start runs the API as production does, on the environment it is given (no .env). build
compiles the API into one binary, dist/api (bun build --compile), which cannot carry a native
addon such as sharp: a server that imports one runs as TypeScript, as the Dockerfile does.
start:worker runs the worker the same way.
Environment flags
Section titled “Environment flags”| Variable | Effect |
|---|---|
ADAPTER=node|cloudflare|static |
selects the SvelteKit adapter for vite build in apps/web |
STATIC_OUT_DIR |
where ADAPTER=static writes (default build, the same directory the server adapters use). build:static:desktop sets it to build-static so a desktop build and a server build cannot overwrite each other |
NODE_OUT_DIR |
where ADAPTER=node writes, inside apps/web (default build). The adapter empties it first, so the name must start with build. bun run serve build sets build-serve.next, which start promotes to build-serve, so bun run build and test:e2e never replace a served build |
SKIP_ENV_VALIDATION=1 |
relax the API’s env validation for steps that never boot the app (type checks, CI builds). Never for dev or migrate. Values are still parsed, so they keep their real types. Only the cross-field production rules are skipped, DATABASE_URL / BETTER_AUTH_SECRET get placeholders, and a variable of your product’s with neither a default nor .optional() is left undefined |
ADMIN_PASSWORD |
password for admin:create, instead of the interactive prompt |
SEED_PASSWORD |
password for the account bun run seed creates (default demo-password-1234) |
E2E_WEB_PORT |
the port test:e2e’s preview listens on (default 4173), and test:e2e:full’s web app (default a free one) |
E2E_REUSE=1 |
test:e2e tests a server already listening on its port instead of stopping |
E2E_API_PORT |
the port test:e2e:full runs the API on (default a free one) |
E2E_POSTGRES_URL |
the /postgres database of a server on this machine that test:e2e:full creates its database on, in place of DATABASE_URL’s |
E2E_PASS_ENV |
comma-separated variables of yours that test:e2e:full passes to every process it starts; never one it sets itself |
TEST_DATA_<NAME> |
where a test reads data kept outside the repository; a test that reads one skips without it, bun run test passes every one to the suites, and test:db:all reports a database suite that reads one (not a comment that names it) as not run while it is unset |
BENCH_SMOKE=1 |
a benchmark runs each case once and exits within seconds; bun run bench -- --smoke (and bun run test) sets it, and a full bun run bench clears it |
SITE_URL |
canonical origin for the Astro builds. Each site has its own. astro build fails without it, and fails on an example.com placeholder; deploy:cloudflare also refuses a localhost one. astro dev and astro check fall back to localhost:4321 / :4322 |