Skip to content

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.

Terminal window
bun add -d @nice-code/commander
commander.config.ts
import { 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"],
});
Terminal window
bunx nice-commander up # starts `dev` → pulls in `api` first, waits for it to be READY
bunx nice-commander status # a table, live off the daemon
bunx nice-commander ui # opens the browser
bunx nice-commander down dev

Every command that acts takes the same selector: process ids, group names, or tag=value terms.

Terminal window
bunx nice-commander up role=backend # every backend process
bunx nice-commander up role=backend project=x # AND across terms — never OR
bunx nice-commander restart web api
bunx nice-commander down --all

Multiple 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/restart always require a selection. Omission never means --all.
  • up --dry-run and explain print 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.

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”.

Terminal window
bunx nice-commander run checks --parallel 4 --fail-fast

run 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.

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 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 },
],
}
Terminal window
bunx nice-commander env # every process that declares something
bunx nice-commander env api --set LOG_LEVEL=debug
bunx nice-commander env api --unset LOG_LEVEL # back to the declared default
bunx nice-commander env api --clear # drop every override on api

A 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. Pass secret: false to publish one deliberately. Values inherited from the daemon’s own environment are never shown either.

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.

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).

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.

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.

Terminal window
bunx nice-commander doctor

doctor 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.

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.