Your Dev Environment, Declared
Describe your dev environment once in a typed config. One daemon starts it in dependency order, shows it in a browser, and stops it without leaving anything behind.
@nice-code/commander runs everything your project needs during development. It replaces the ten
terminal tabs, the tmux script, and the concurrently line that has grown a comment explaining
itself. You declare the processes once, in TypeScript, and get a daemon, a CLI, and a live web UI
that all share 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 }, ], defaultSelection: "web",});bunx nice-commander up # starts `web` → 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 webSelection is the whole CLI
Section titled “Selection is the whole CLI”Every command that acts takes the same selector: process ids, suite names, @pin names, or
tag=value terms.
bunx nice-commander up role=backend # every backend processbunx nice-commander up role=backend project=x # AND across termsbunx nice-commander up role=backend role=worker --any # OR — only when you say sobunx nice-commander restart web apibunx nice-commander up @frontends # a tag group you pinned in the web UIbunx nice-commander down --allMultiple tag= terms AND together. --any ORs them instead. It is a flag on purpose: a
selector that widened quietly is one you could not trust with restart.
Tags organise; suites run
Section titled “Tags organise; suites run”Tags are how you organise processes. A tag can hold several values:
tags: { project: ["api", "billing"] } matches both project=api and project=billing. A process
that belongs to two things says so on its own row. There is nothing else to declare. The web UI
sorts whatever you filter into tag groups (below), and you can run, pin and reopen any of them.
A suite is several processes you mean to start as one thing:
suites: { verify: { description: "Type-check and test everything before a push", where: [{ role: "types" }, { role: "test" }], // OR of AND-maps exclude: ["slow-e2e"], parallel: 4, failFast: true, }, dev: { include: ["web", "worker"] }, // ids and/or other suites},parallel and failFast belong to the suite, so run verify and the UI’s ▶ do the same thing
without anyone remembering flags. CLI flags still override. A suite must resolve to at least
two members. A suite of one is only a label, which is what tags are for. Validation says so and
names the id to use instead.
Some processes should never be swept up — a task that spends money, or one that needs a human:
{ id: "charge-test-card", kind: "task", optIn: true, run: [...] }Tag selections, a suite’s where, and --all all skip an optIn process. It starts only by exact
id, by a suite’s explicit include, or as a declared dependency. down --all and logs still
reach it, because anything running must stay stoppable.
Two rules keep an accident from becoming an outage:
- Bare
down/restartalways require a selection. Omitting it never means--all. up --dry-runandexplainprint the expanded ids, the auto-included dependencies and the start order without touching anything.explain --envadds each id’s spawn environment.
When up reports a service ready it prints where it is — ✓ web ready → http://localhost:3050
— from the endpoint the config already declares. up --json returns the generation it just ran
as data: each id’s outcome and reason, plus its state, pid, endpoints and URLs, so a script can
decide from one object instead of reading the log and then asking status --json. Every
command’s --help prints that command’s section alone.
Dependencies wait for ready, not for spawned
Section titled “Dependencies wait for ready, not for spawned”dependsOn starts the dependency first and waits until it is actually ready or, for a task, has
succeeded. If a dependency never becomes ready, its dependent is marked blocked with the reason.
The wait is bounded, so a command always returns instead of hanging.
If a dependency breaks later, its dependents are marked degraded but not stopped. 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 suite or tag group made entirely of tasks looks like a CI run in the UI:
2/5 · type-check…, then 5/5 passed. For a type-check you want to know whether it passed, not
whether it is running.
bunx nice-commander run verify # the suite's own parallel / fail-fastbunx nice-commander run role=check --parallel 4 --fail-fastHowever many run at once, a task never starts alongside a dependency it was ordered after. It
waits for that dependency to have succeeded, and is reported blocked if it did not.
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.
run shares the daemon’s ledger, its record of what is running, and refuses an id the daemon
already owns. A double-start is never silent.
Endpoints and port owners
Section titled “Endpoints and port owners”An occupied exclusive endpoint blocks startup. Commander only reaps orphans automatically when
they are verified ledger entries. To find a listener you started elsewhere, run
nice-commander ports [selection]. It shows PID, executable, parent PID, working directory,
process-start identity and a STRAY verdict.
A stray is your own definition running outside the daemon: the same command (argv) in the
same cwd, usually started by hand in a terminal that has since gone away. The match walks up the
listener’s parents, so bun run dev → vite → the socket still matches a definition of
bun run dev. Anything else is a stranger — possibly an unrelated application on the same
port — and is never stopped on the strength of the port alone.
A start that finds a stray refuses and names it, pid and fix included:
✗ backend-bun failed — exclusive endpoint "http" (127.0.0.1:4100) is held by a stray copy of this definition (pid 53832, the same command in the same cwd, started outside the daemon) — `up backend-bun --replace-same` replaces it, `ports backend-bun --kill-owner` stops it, or set replaceSame: true on the definitionup <sel> --replace-same(also onrestart) stops the stray as a tree — root command first, so abun runwrapper cannot respawn the listener — with the same SIGTERM → grace → SIGKILL ladder a stop uses, then starts the definition. PutreplaceSame: trueon a definition (or underdefaults) to make every start behave this way. A stranger is still named and refused.ports <sel> --kill-owner [--yes] [--force]stops whatever holds the selection’s exclusive endpoints, each after confirmation, without your having to copy a PID first. This one does stop a stranger, because you asked for exactly that listener.ports <sel> --kill PIDasks for confirmation before sending SIGTERM to that PID.--yesconsents without a prompt;--forceexplicitly chooses SIGKILL.doctormarks every owner of an occupied, inactive endpoint as a stray or not, anddoctor --reap-strays [--yes]stops the strays and leaves the strangers where they are.
In the UI, inspect ports is available on inactive rows, including startup failures, and in
expanded process details. Each listener carries its stray verdict, and a stray offers replace
stray and start — the same as --replace-same. Otherwise choose a listener, confirm stop
PID or force kill PID, then retry start after the port is released. The listener may be an unrelated application, so a
start failure never terminates anything automatically.
Commander rechecks the selected listener’s identity and endpoint before signalling. If the port
belongs to a process from your config that is currently running, ports refuses to kill it. Stop
that one the normal way, with stop or its stop button. A stray is signalled as its tree; a
stranger only as the chosen PID, so a parent watcher may restart it. Inspection works without a
daemon. It supports local exclusive TCP endpoints on macOS/Linux with lsof and ps, and reports
unavailable tools or identities. Windows owner inspection is not supported yet, so on Windows
every owner is a stranger.
A refused start stays readable afterwards: status shows start failed: … on the row until a
later start succeeds or the process is stopped. A dependent’s blocked: dependency "x" is not running note clears by itself once x becomes ready, so the table never contradicts the row two
lines above it.
The other two ownership values never block startup. Use shared when several of your processes
race for one port on purpose and whichever binds first serves the rest, such as a single relay that
three frontends each try to start. Every process that declares the port must mark it shared. Use
observed for a listener commander does not run, such as a database you started yourself.
Commander shows it and never claims or kills it.
Env vars you can actually turn
Section titled “Env vars you can actually turn”env pins a value nobody is meant to touch. A value you do want to adjust, a knob, goes in
envVars, which 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 anywhere, until you
clear it. 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, like
a config edit, and a restart applies it. doctor and env both list what is set,
so an override you forgot last week cannot quietly be the reason for a weird port.
Two guardrails:
- Only a declared name can be set. Someone with the UI open cannot add an environment variable
the config never declared. The daemon checks every change against
envVars. - Credential-shaped names are secret by default. The value of anything matching
*_KEY,*SECRET*,*TOKEN*,*PASSWORD*or*CREDENTIAL*is never 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 dashboard showing the same state the CLI reads. It attaches to the
daemon already running for the project, even one from an older build, because each daemon serves
the UI it was built with. nice-commander ui --fresh stops that daemon and everything it runs,
then starts one on the installed build. The left rail lists
everything that can run: suites, saved tag groups, tags and processes. A timeline floating over
the log pane tracks what is running.
- Timeline (floating over the log pane’s top-right): a rolling 30-minute window, “now”
pinned at the right edge, one bar per process run. A live run sweeps a liquid blue. An ended
run shows its outcome (green
succeeded, greystopped, redfailed/crashed, ambertimedOut) and stays until you dismiss it with its × (or clear ended). A new run of a dismissed process brings it back. Hover a bar for its start time, duration and verdict. Click a bar or label to toggle that process in the selection; select several and the log pane merges exactly those. The panel collapses to a pill when it’s in the way. - Suites (top of the rail): one filled card per suite. Each has a ▶ that runs it with the
suite’s own policy, one progress segment per member, the run’s duration against the previous
one, and rerun N failed after a red run. down mirrors the run: it stops the members
and the dependencies they brought up, except one another live process still needs, which
stays up and is named in the report. While a suite run is live its members’ rows carry a
▸ verifymark, so a suite run never reads as a handful of unrelated starts. Click a card and the list below narrows to its members, in the list’s current order. - Saved: tag groups you pinned (★) and the last few you ran (↺), each with live member dots,
so you see their status without opening them. Click one to restore the exact filter. They
are stored with the daemon’s state, not in the browser, so every tab and every later session has
them, and
nice-commander up @nameselects a pin from the CLI. - Tags: your tags grouped under their key (
role,project, …), each value a chip with the number of processes it would yield. any | all beside the heading chooses the match:any(the default) keeps every partial match,allonly processes carrying every selected tag. - Processes heading: the visible count, and — while no tag is selected — group by, which buckets the whole list under one tag key. The order menu at its right lists each tag group in dependency order (the default, the order a run starts them in) or recently started, which brings what you have been working on to the top. Only a start you asked for counts, never a crash-loop respawn. Start times are kept with the daemon’s state, so the order survives a daemon restart, and the browser remembers which order you picked.
- Tag groups (the process list): the filter’s results, bucketed by the exact set of selected
tags each process carries and ordered by how many it matches, so in
anymode the first tag group is always theallresult, with the partial matches beneath it. A heading names exactly what its actions touch:▶ run/▲ up(hover for the plan — what starts, what is already running, which dependencies come along),↻,▼, and☆to pin it. Bulk actions exist only on a heading or a suite card, never on “whatever the filter matches”. Click a heading to scope the log pane to that tag group. - Process rows: one compact row per process, showing state, chips, and uptime, last-run
verdict or when it was last started, in the chosen order within its tag group. Direct dependencies show as live dots on the
row (click one to jump to it). Hovering a row marks its upstream and downstream rows and dims the
rest, and the name’s popover holds the full tree. A
blockedrow links straight to what is blocking it. The icons at the right of a row are its actions, and only the ones that apply: ▶ start for a stopped process; ↻ restart, ■ stop and ■↳ stop with dependencies for a live one. The last also stops what the process depends on, except anything another live process still needs, and only appears when it would stop more than the process itself. ▾ opens detail for the full picture: what will actually run, its endpoints, and a field per declared env var. A running task with atimeoutMsshows its elapsed time against the cap, so you can tell hung from slow at a glance.
Starting several things at once always respects dependencies. They come up first and are waited on, and only a bounded number of tasks run at a time. Restarting a tag group stops only its members, so a dependency other processes are using is left alone.
The log pane tails one process, your current selection merged, or everything your current filter matches. Merged lines are ordered by the sequence in which the daemon received them, because wall clocks jump and per-process line numbers are not comparable. Search runs on the daemon over its whole on-disk history, so it finds the line that scrolled away an hour ago. Search is bounded and cancellable, and it tells you when it stopped early rather than reporting “no matches”.
The pane’s header toggles timestamps and line wrap, and clear empties the view while keeping the last 12 lines of each still-running process. Clearing only changes the view: nothing is deleted, and search still covers the full history.
http(s) links that a process prints are clickable. They get a subtle rounded tint, underline on
hover, and open in a new tab. Only http(s) ever becomes a link, because printed output is
untrusted. A loopback address becomes a link only when the page itself is on loopback, the same
SSH rule as the endpoint chips below.
Keys: ⌘K/Ctrl+K opens a go-to palette over processes, tags, suites and pins; / focuses the
filter, 1–9 toggle the nth visible process in the selection, a flips any/all, [ and ]
move the focus across tag groups, g cycles the suites, c clears the log, r/s/u restart,
stop and start the whole selection, Escape backs out (dialog, selection, suite — in that order),
and ? opens the cheat sheet. The selection and filter are kept in the URL hash, so a reload or a
copied link restores the same view. The header also shows whether the page’s own connection to the daemon is up (● live vs
reconnecting…) and a dark/light/system theme toggle.
The rail’s width is yours: drag the gutter beside the log pane (or focus it and use the arrow keys; double-click resets). The choice persists per browser.
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).
Mistakes are type errors
Section titled “Mistakes are type errors”defineCommanderConfig checks the config in your editor. An unknown or misspelled key is an error,
and so is a removed one like groups. So is a dependsOn, suite include/exclude,
defaultSelection or ready.endpoint that names a process, suite or endpoint the config never
declares.
Those reference checks need literal ids. If you build processes from a list, declare the list
as const. A list typed { id: string }[] turns every id into a string, and the checks quietly
switch off. A .map() callback is not contextually typed, so write as const on kind and
dependsOn inside one:
import { defineCommanderConfig } from "@nice-code/commander/config";
const checks = [ { id: "lint", script: "lint" }, { id: "test", script: "test" },] as const;
export default defineCommanderConfig({ name: "my-app", processes: [ { id: "build", kind: "task", run: ["bun", "run", "build"] }, ...checks.map((check) => ({ id: check.id, kind: "task" as const, run: ["bun", "run", check.script], dependsOn: ["build"] as const, })), ], suites: { verify: { include: ["lint", "test"] } },});What the types cannot see (a cwd that does not exist, a dependency cycle, a ready endpoint that
belongs to a different process) is caught when the daemon loads the file.
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.
If a config fails to parse or validate, the last good one stays live and the error is shown 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 runs per config, keyed on the config’s realpath. Its instance record (port, pid, token) lives under the OS data dir, and the CLI finds the daemon only through that record.
bunx nice-commander doctordoctor reports stale and quarantined ledger records and whatever is holding your endpoints. It
names the mechanism commander uses on this platform to stop a process together with its children:
process groups on macOS and Linux, job objects on Windows, or a weaker taskkill fallback. It
reports how long ago the daemon captured its environment, 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 reports 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: a restart that depended on whoever ran the last command would be far
worse. doctor reports it so it is no mystery.
Security
Section titled “Security”The daemon binds loopback only. The browser gets a capability from a same-origin bootstrap and holds it in memory, never in a URL, a cookie, or localStorage. A URL leaks through history and the Referer header, and a cookie would be sent by any page that can reach loopback. Each socket uses up a single-use ticket. Another service on another 127.0.0.1 port receives no authority here.
Process output is always rendered as text. ANSI colour is styled, but HTML in a child’s output stays literal. Any dependency can print anything, so it is the one input the UI never trusts.
The daemon shares process status with the CLI and the UI through a realm, a state tree every open
tab reads. Configured env values are never written to that realm, the ledger, or a log.