Skip to content

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.

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 },
],
defaultSelection: "web",
});
Terminal window
bunx nice-commander up # starts `web` → 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 web

Every command that acts takes the same selector: process ids, suite names, @pin 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
bunx nice-commander up role=backend role=worker --any # OR — only when you say so
bunx nice-commander restart web api
bunx nice-commander up @frontends # a tag group you pinned in the web UI
bunx nice-commander down --all

Multiple 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 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/restart always require a selection. Omitting it never means --all.
  • up --dry-run and explain print the expanded ids, the auto-included dependencies and the start order without touching anything. explain --env adds 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.

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.

Terminal window
bunx nice-commander run verify # the suite's own parallel / fail-fast
bunx nice-commander run role=check --parallel 4 --fail-fast

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

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 definition
  • up <sel> --replace-same (also on restart) stops the stray as a tree — root command first, so a bun run wrapper cannot respawn the listener — with the same SIGTERM → grace → SIGKILL ladder a stop uses, then starts the definition. Put replaceSame: true on a definition (or under defaults) 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 PID asks for confirmation before sending SIGTERM to that PID. --yes consents without a prompt; --force explicitly chooses SIGKILL.
  • doctor marks every owner of an occupied, inactive endpoint as a stray or not, and doctor --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 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 },
],
}
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 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. Pass secret: false to publish one deliberately. Values inherited from the daemon’s own environment are never shown either.

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, grey stopped, red failed/crashed, amber timedOut) 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 ▸ verify mark, 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 @name selects 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, all only 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 any mode the first tag group is always the all result, 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 blocked row 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 a timeoutMs shows 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.

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

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.

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.

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.

Terminal window
bunx nice-commander doctor

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

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.