Your Dev Environment, Declared
One typed config, one daemon, one control surface: start a whole dev environment in dependency order, watch it in a browser, and stop it without leaving anything behind.
@nice-code/commander runs the processes a project needs during development — the ten terminal
tabs, the tmux script, the concurrently line that has grown a comment explaining itself. You
declare them once, in TypeScript, and get a daemon, a CLI, and a live web UI over the same state.
bun add -d @nice-code/commanderimport { defineCommanderConfig } from "@nice-code/commander/config";
export default defineCommanderConfig({ name: "my-app", processes: [ { id: "api", run: ["bun", "run", "dev"], cwd: "./services/api", endpoints: [{ name: "http", protocol: "http", port: 8787, ownership: "exclusive" }], ready: { kind: "endpoint", endpoint: "http" }, tags: { role: "backend" }, }, { id: "web", run: ["bun", "run", "dev"], cwd: "./apps/web", dependsOn: ["api"], endpoints: [{ name: "http", protocol: "http", port: 5173, ownership: "exclusive" }], ready: { kind: "endpoint", endpoint: "http" }, tags: { role: "frontend" }, }, { id: "types", kind: "task", run: ["bunx", "tsc", "--noEmit"], timeoutMs: 120_000 }, ], groups: { dev: { include: ["web"] } }, defaultSelection: ["dev"],});bunx nice-commander up # starts `dev` → pulls in `api` first, waits for it to be READYbunx nice-commander status # a table, live off the daemonbunx nice-commander ui # opens the browserbunx nice-commander down devSelection is the whole CLI
Section titled “Selection is the whole CLI”Every command that acts takes the same selector: process ids, group names, or tag=value terms.
bunx nice-commander up role=backend # every backend processbunx nice-commander up role=backend project=x # AND across terms — never ORbunx nice-commander restart web apibunx nice-commander down --allMultiple tag= terms AND together. If you want OR, name a group — a selector that quietly
widened would be a selector you cannot trust with restart.
Two rules keep an accident from becoming an outage:
- Bare
down/restartalways require a selection. Omission never means--all. up --dry-runandexplainprint the expanded ids, the auto-included dependencies and the start order without touching anything.
Dependencies wait for ready, not for spawned
Section titled “Dependencies wait for ready, not for spawned”dependsOn starts the dependency first and waits for it to actually be ready (or, for a task, to
have succeeded). A dependency that never readies leaves the dependent blocked with the reason —
bounded, so an action always returns instead of hanging.
Later regressions mark dependents degraded without cascade-stopping them: your frontend does not get killed because the API blipped. Stopping a dependency warns you which live dependents it would affect, by exact id.
Tasks are not services
Section titled “Tasks are not services”kind: "task" is a one-shot: it succeeds or fails, has a timeoutMs, and never auto-restarts. A
group made entirely of tasks renders in the UI as a small CI panel — ✓ types ✗ lint · test —
because the question about a type-check is “did it pass”, not “is it running”.
bunx nice-commander run checks --parallel 4 --fail-fastrun is the foreground mode: no daemon, prefixed interleaved output, real exit codes. For a
single id, stdio is inherited raw, so an interactive process (a watch-mode key handler) works.
It coordinates on the same ledger as the daemon and refuses an id the daemon already owns — a
double-start is never silent.
Endpoints, and never killing a stranger
Section titled “Endpoints, and never killing a stranger”An exclusive endpoint that is already bound is a conflict, and commander tells you who holds
it. If the holder is a tree commander itself owns and has a ledger record for, it is reaped and
the start proceeds. If it is anything else, nothing is killed — an unknown pid on “our” port
is still someone else’s process.
Use shared for a deliberate first-wins singleton and observed for something external you only
want to watch.
Env vars you can actually turn
Section titled “Env vars you can actually turn”env pins a value the operator is not meant to touch. A knob goes in envVars, which is what
makes it settable from the CLI and the UI:
{ id: "api", run: ["bun", "run", "dev"], envVars: [ { name: "PORT", default: "8787", description: "HTTP listen port" }, { name: "LOG_LEVEL", default: "info", values: ["debug", "info", "warn"] }, { name: "STRIPE_SECRET_KEY", required: true }, ],}bunx nice-commander env # every process that declares somethingbunx nice-commander env api --set LOG_LEVEL=debugbunx nice-commander env api --unset LOG_LEVEL # back to the declared defaultbunx nice-commander env api --clear # drop every override on apiA value set this way is sticky: it applies to every later start, from any surface, until it is
cleared. It is stored per config beside the daemon’s other state, so up, restart, run and the
UI all agree about it.
Setting a value never touches a running process. The process is marked env changed — the same
shape as a config edit — and a restart applies it. doctor and env both list what is set, so a
sticky override you forgot last week can never be the silent explanation for a weird port.
Two guardrails are worth knowing:
- Only a declared name can be set. A viewer with the UI open cannot introduce an environment
variable the config never opted into — the daemon re-checks every mutation against
envVars. - Credential-shaped names are secret by default. Anything matching
*_KEY,*SECRET*,*TOKEN*,*PASSWORD*or*CREDENTIAL*never has its value sent to a browser, printed by the CLI, or written to a log — the UI shows only whether it is set. Passsecret: falseto publish one deliberately. Values inherited from the daemon’s own environment are never shown either.
The web UI
Section titled “The web UI”nice-commander ui opens a live grid over the same state the CLI reads. Cards show state, pid,
uptime, restarts, endpoint badges, and the chips a status dot cannot express — blocked by api,
unready: port closed, config changed, 4 restarts, tree ownership degraded. Opening a card’s
detail shows what will actually run, and a field per declared env var to set it.
The log pane tails one process, or shows the merged timeline across your current filter, ordered by the daemon’s own ingest sequence rather than by wall clocks (which jump) or per-process line numbers (which are not comparable between processes). Search runs on the daemon over its whole on-disk history, so it finds the line that scrolled away an hour ago — bounded and cancellable, and it tells you when it stopped early rather than reporting “no matches”.
Keys: / focuses the filter, Escape returns to the merged timeline, and r/s/u restart,
stop and start the selected process.
Reached over SSH?
Section titled “Reached over SSH?”An endpoint is only offered as a link when the page itself came from loopback. Over a
forwarded port your browser is not on the machine the process bound to, so localhost:5173 would
open whatever runs on your 5173. Off-loopback the address is shown as text, marked (remote).
Editing the config while it runs
Section titled “Editing the config while it runs”Save the file and the daemon reloads it. Processes whose definition changed are marked stale — they keep running, and a restart applies the change. Nothing restarts silently.
A config that fails to parse or validate keeps the last good one live and surfaces the error
in the UI and in status. A bad save can never take your dev environment down.
Where things live
Section titled “Where things live”One daemon per config, keyed on the config’s realpath. Its instance record — port, pid, token — lives under the OS data dir, and the CLI discovers the daemon only through that record.
bunx nice-commander doctordoctor reports stale and quarantined ledger records, the tree-ownership mechanism this platform
gives you, endpoint holders, the daemon’s env-snapshot age, and (on WSL) a warning if your repo
sits on /mnt/c, where watchers are slow enough to look like a commander bug.
One thing doctor will tell you that is worth knowing in advance: children inherit the env the
daemon booted with. Changing PATH in a fresh terminal does not reach them until the daemon
restarts. That is deliberate — restart semantics that depended on whoever ran the last command
would be far worse — but it is surfaced rather than left as a mystery.
Security
Section titled “Security”The daemon binds loopback only. The browser gets a capability from a same-origin bootstrap, held in memory: never in a URL, a cookie, or localStorage, because a URL leaks through history and the Referer header, and a cookie would be sent by any page that can reach loopback. Each socket burns a single-use ticket. Another service on another 127.0.0.1 port receives no authority here.
Process output is rendered as text, always. ANSI colour is styled; HTML in a child’s output stays literal — any dependency can print anything, so it is the one input the UI never trusts.
Configured env values are never written to the realm, the ledger, or a log.