Skip to content

The recipe: splashdown.toml

The committed file. Its top-level sections are [project], [apps.*], [resources.*], [targets.*] (for mobile), [bootstrap], and [setup.*]. The scanner produces a working version.

[project]
workspace = "pnpm"             # single | pnpm | yarn | npm | cargo | gradle
loader    = "mise"             # mise | direnv | devbox | none
env_file  = ".env"             # default destination (omit for splashdown.env)

[apps.api]
path      = "apps/api"
profile   = "node-backend"     # astro | vite | angular | nuxt | nextjs |
                               # node-backend | deno | django | fastapi | flask |
                               # springboot | aspnetcore | rails | laravel |
                               # react-native | expo | flutter | ios-native |
                               # android-native | unknown
resources = ["PORT"]

[apps.web-admin]
path      = "apps/web-admin"
profile   = "vite"
resources = ["WEB_DEV_PORT", "API_DEV_PORT"]

[resources.PORT]
type  = "port"
range = [9081, 9100]           # globally-coordinated lowest-free

[resources.WEB_DEV_PORT]
type  = "port"
range = [5174, 5200]

[resources.API_DEV_PORT]
type     = "template"
template = "{{ PORT }}"        # Vite's /api proxy must hit the api's actual port

Resource types: port, uuid, template, cwd, cwd-slug, set. Template scope: cwd, cwd_abs, branch, repo, parent, basename, dirname, slug, lower, upper, truncate, uuid, hash, port_hash, plus prior resolved resources.

Each resource type has a small, strict shape:

Type Fields
port Required range = [LO, HI], where both values are integers and 1 <= LO <= HI <= 65535
template Required string template
set Optional string default
uuid, cwd, cwd-slug No type-specific fields

Every resource also accepts the optional writer field. Fields belonging to another resource type are errors.

Writers

[project] env_file names the file that receives every resource without its own writer. Omit it and the destination is splashdown.env, a file splashdown generates next to the recipe. splash init --env-file PATH writes the setting for you, and the selected loader is wired to read whatever that path names. The path is relative to the checkout and must stay inside it. It is stored in one canonical spelling, so ./.env and .env name the same destination rather than two.

Splashdown manages only the keys the recipe declares, in whatever destination they land in. That holds for splashdown.env exactly as it does for a shared .env, because a filename on its own does not make the file splashdown's. Unrelated lines, comments, blank lines, and the file's line endings survive every sync. An existing value for a declared key is replaced where that key already sits, so a key you grouped under a comment header stays there. A key assigned twice in the destination, or assigned in a shape splashdown does not rewrite such as PORT: 3000, is an error rather than a second competing definition, and so is a quoted value that is never closed, because nothing below it can be read reliably.

Drop a resource from the recipe and its line goes on the next sync. Splashdown remembers what it wrote for this checkout, so the value cannot linger after the port it named is handed to another checkout.

Splashdown creates a destination with owner-only permissions. A file that already exists keeps the mode you gave it.

A resource's own writer overrides the default for that resource only, and the resource is not also copied into the default file:

Writer Destination
omitted, or "splashdown-env" the [project] env_file destination
"envfile=RELATIVE/PATH" that file, key-scoped the same way
"envrc" .envrc.local, as export KEY=... lines
"stdout" printed by sync instead of written
"none" the registry only

Consumers of an exceptional destination keep their own integration. The loader follows the default file, not the per-resource overrides.

Any server that reads PORT from its environment needs nothing more than the [resources.PORT] block in the example above, with a range wide enough for the checkouts you run at once.

Templates are derived values and re-render on every sync. Referenced resource changes therefore propagate immediately. For a stable generated component, declare it separately as type = "uuid" and reference that resource from the template. Calling uuid() directly in a template creates a new value on every sync.

set resources hold manually supplied values:

[resources.API_TOKEN]
type = "set"
# default = "local-development-token"   # optional

Set one with splash env set API_TOKEN=VALUE. The command requires the resource to be declared as type = "set". It rejects missing or malformed recipes, undeclared keys, and generated or allocated resource types. Run splash sync afterward to materialize the new value in its configured writer destination. Without a default, sync exits 1 until a value is set. Manual values persist across syncs, including --force. splash env release API_TOKEN clears one.

A common pattern for consumers that need a stable short identifier (e.g. Docker Compose project names have a practical length limit):

[resources.COMPOSE_PROJECT_NAME]
type     = "template"
template = "myapp-test-{{ truncate(hash(cwd_abs), 8) }}"
# → "myapp-test-352e9e09", stable per checkout path, 8-char truncated SHA256

The same pattern gives every checkout its own database inside one shared Postgres container, with no new resource type. A database name needs no machine-wide coordination the way a port does, so a plain function of the checkout directory is enough and stays stable across reallocations:

[resources.DB_NAME]
type     = "template"
template = "myapp_{{ slug(cwd) }}_{{ truncate(hash(cwd_abs), 8) }}"
writer   = "envfile=apps/api/.env"

Three things to know before you add it:

  1. slug() lowercases and turns every non-alphanumeric run into a hyphen, so the readable part for a checkout named myapp.feat-x becomes myapp-feat-x. The truncated hash keeps identical path tails under different roots distinct. Mixing the literal underscore with the slug's hyphens is safe only if the database quotes the identifier. Use a different literal separator when it does not.
  2. The first sync takes over any hand-set DB_NAME= line already in apps/api/.env and replaces it with the computed value. Other keys in that file are left alone. Check the file before you add the resource.
  3. There is no per-checkout exception. The resource applies to every checkout including your primary one, so the base database from your compose file simply goes unused there. You cannot express "compute this only in worktrees".

Splashdown writes the name. Creating the database is your app's job, typically a CREATE DATABASE IF NOT EXISTS-style step on first connect, or a [setup.*] block.

To hand the app a whole connection string rather than a bare name, template the URL itself:

[resources.DATABASE_URL]
type     = "template"
template = "postgres://localhost:5432/myapp_{{ slug(cwd) }}_{{ truncate(hash(cwd_abs), 8) }}"

Moving the checkout changes cwd_abs, and with it the generated name. Sync writes the new name rather than migrating anything, so create or migrate the newly named database yourself. The old one stays untouched until you remove it.

Optional setup blocks run explicitly through splash sync --setup NAME:

[setup.dev]
run = [
  "docker compose up -d",
  "python manage.py migrate",
]

run accepts one non-empty command string or a non-empty array of non-empty strings. It is the only field accepted in a setup block. Commands run sequentially from the checkout root with resolved resources added to their environment. The requested setup name must exist. Execution stops at the first failed command and exits 1. Resource allocation and output-file writes happen before setup execution starts and are not rolled back if a command fails.

For commands that should run once when a trusted worktree is created, use a top-level [bootstrap] instead:

[bootstrap]
run = [
  "pnpm install --frozen-lockfile",
  "python manage.py migrate",
]

The command shape and failure behavior match setup, but bootstrap has a separate security and completion model. It never runs until the clone is authorized with splash trust, and then runs once per checkout through splash bootstrap or a qualifying worktree-creation hook. See Trusted worktree bootstrap.

To claim one configured physical device after a linked worktree is created, add the exact project policy:

[project.worktree]
claim_device = "android" # ios | android | any

project.worktree is a strict table containing only claim_device, whose value is exactly ios, android, or any. The policy runs only for a genuine linked-worktree creation event. It does not run for the primary checkout or ordinary branch and file checkouts. The hook attempts the claim after successful provisioning and trusted bootstrap, with a five-second discovery budget. No free device, unavailable platform tooling, or a discovery timeout is non-fatal. The hook prints a manual splash target claim --available PLATFORM retry.

For mobile, the recipe also declares a [targets.*] catalog: the simulator and emulator variants the team agrees this project supports. Sim instances are created lazily per checkout, named <parent>/<cwd>/<variant>-<path-hash>. With ios = "latest" (the default), the sim is auto-recreated whenever a newer iOS lands. Pin an explicit version like ios = "18.5" for fixed coverage.

[targets.simulator.default]
model = "iPhone 17"

[targets.simulator.lowest-supported]
model = "iPhone 12"
ios   = "17.0"

[targets.emulator.default]
device = "pixel_9"

For a plugged-in phone, declare a device target. Unlike sims and emulators, Splashdown does not create or own physical hardware. It discovers what is connected and hands the native ID to the launcher. Recipe, local, and global physical targets all participate in machine-wide claims. Undeclared discovered phones never participate. The fields are optional selectors, but the target declaration itself is required before a phone can be claimed or run.

[targets.device.default]
# platform = "ios"        # scope auto-pick to one platform: "ios" | "android"
# name     = "My iPhone"  # match by device name
# id       = "..."        # exact udid / adb serial

Target types and their compatible fields:

Type Optional fields
simulator model, ios, name
emulator device, image, name
device id, name, platform (ios or android)

All supplied target values must be non-empty strings. A field for the wrong type, such as image on a simulator, is an error.

Electron user-data isolation

Electron keeps its user data in one platform-specific directory per app, so two checkouts of the same project share logins, local storage, and the single-instance lock. Opt a project into per-checkout isolation by declaring a stable profile identifier:

[resources.ELECTRON_PROFILE_ID]
type     = "template"
template = "splashdown-{{ truncate(hash(cwd_abs), 12) }}"

The resource takes the project's default destination, so it lands wherever [project] env_file points. Electron reads the identifier from its process environment, so that destination has to be one your loader or launch command actually loads.

The value is a plain function of the checkout path, so it survives reallocation and is the same on every sync. Splashdown only supplies the identifier. The main process has to apply it, before requestSingleInstanceLock() and before any window is created:

import { mkdirSync } from "node:fs"

const profileId = process.env.ELECTRON_PROFILE_ID
if (profileId) {
  const userData = `${app.getPath("userData")}-${profileId}`
  mkdirSync(userData, { recursive: true })
  app.setPath("userData", userData)
}

This keeps each checkout's profile beside Electron's normal user-data directory rather than inside the checkout, so cleaning the working tree does not delete it. Without the main-process change the resource is inert and Electron keeps sharing one profile.

The profile id isolates user data only. A renderer dev server still needs a port of its own, declared like the [resources.PORT] block in the opening example.

A workspace with more than one Electron app needs a distinct identifier and a distinct name for each of them. See Electron alongside a renderer.

Validation

Splashdown validates the complete recipe whenever it loads it. Unknown sections or fields, wrong value types, unknown workspace/loader/profile names, malformed targets, and invalid resource definitions are hard errors. [apps.NAME] must contain path, profile, and a unique resources list whose entries are declared under [resources].

[project] accepts workspace, loader, framework, env_file, run, worktree, ios, and android. env_file is the default output destination, splashdown.env when absent. run is either one non-empty command string or a table containing ios and/or android commands. worktree has the strict shape shown above. The ios table accepts scheme, mode, configuration, workspace, and project. The android table accepts mode, module, variant, application_id, and launch_activity. All supplied leaf values are non-empty strings. App profiles use a built-in profile name or unknown. framework takes a built-in profile name, or "auto" to state auto-detection explicitly. Omitting the key auto-detects too, so "auto" is only worth writing when you want the intent visible in the file.

Templates are also checked up front: expressions must use the documented restricted syntax, every referenced name must exist, and resource dependency cycles are rejected. Validation finishes before registry allocation or generated-file updates, so a mistake anywhere in the document cannot leave a partially provisioned checkout. Errors identify the source and qualified field, for example splashdown.toml: [resources.PORT.range] ....

For per-checkout variants layered on top of this recipe, see Per-checkout overrides.