# @nice-code/commander — documentation Your dev environment as one typed config: long-running processes, one-shot tasks, dependency ordering, declared env knobs, and a web UI — so a walkthrough cites an id instead of describing terminals. This file concatenates only the @nice-code/commander pages of https://nicecode.io — point a local AI coding assistant at it when you're working with just this package. For the full set (every nice-code package and how they fit together) use /llms.txt. Generated from src/content/docs — do not edit by hand. --- # Your Dev Environment, Declared Source: /nice-commander/dev-environments Description: 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. ```bash bun add -d @nice-code/commander ``` ```ts // 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"], }); ``` ```bash 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 ``` ## Selection is the whole CLI Every command that acts takes the same selector: process ids, group names, or `tag=value` terms. ```bash 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* `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 `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". ```bash 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. ## 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 `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: ```ts { 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 }, ], } ``` ```bash 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. ## The web UI `nice-commander ui` opens the dashboard over the same state the CLI reads: a left rail is the *index* of everything that can run (tags, groups, processes), while a floating timeline 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 paints its outcome — green `succeeded`, grey `stopped`, red `failed`/`crashed`, amber `timedOut` — and stays until you dismiss it with its × (or **clear ended**). A fresh run of a dismissed process resurfaces it. 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 over exactly them. The panel collapses to a pill when it's in the way. - **Tags** (above groups): your tags grouped under their key (`role`, `project`, …), each value a chip that toggles the `key=value` term. The selection ANDs across terms — the same rule the CLI resolves — and narrows the groups *and* the processes below it. - **Groups**: one row per group with its verdict, a dot per member, and its actions (`up`/`down`, or `run` for task groups with live `2/5 · type-check…` progress). Click a group and the list below narrows to its members in dependency order — the same order `up` spawns in. - **Processes** (bottom): one compact row per process — state, chips, uptime or last-run verdict. Hover a row's name to see its dependency tree (upstream *needs* and downstream *needed by*, each clickable). Hover for the quick actions; open **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 "hung or just slow" is a glance. The log pane tails one process, the merge over your current selection, or the merge 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". 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 is a *view* operation only: nothing is deleted, and search still covers the full history. `http(s)` links a process prints are clickable — subtly backed with a rounded tint, underlined on hover, opened in a new tab. Only `http(s)` ever becomes an anchor (printed output is untrusted), and a loopback address becomes a link only when the page itself is on loopback — the same SSH rule as the endpoint chips below. Keys: `/` focuses the filter, `1`–`9` toggle the nth visible process in the selection, `g` cycles the groups, `c` clears the log, `r`/`s`/`u` restart, stop and start the whole selection, `Escape` backs out (dialog, selection, group — in that order), and `?` opens the cheat sheet. The selection and filter ride the URL hash, so a reload or a copied link restores the same view. The header also carries the link's own health (`● 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? 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 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 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. ```bash 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. ## 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.