# @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: 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. ```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 }, ], defaultSelection: "web", }); ``` ```bash 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 ``` ## Selection is the whole CLI Every command that acts takes the same **selector**: process ids, suite names, `@pin` 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 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 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*: ```ts 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: ```ts { 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. ## 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 `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. ```bash 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. ## 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 and process-start identity. `nice-commander ports journey-backend --kill 12345` asks for confirmation before sending SIGTERM to that PID. Use `--yes` to consent without a prompt; `--force` explicitly chooses SIGKILL. In the UI, **inspect ports** is available on inactive rows, including startup failures, and in expanded process details. 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. Only the chosen PID is signalled; 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. 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 `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: ```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 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. ## The web UI `nice-commander ui` opens a dashboard showing the same state the CLI reads. 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 dependency order, the order a run starts them in. - **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. - **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 or last-run verdict, in dependency 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. ### 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. 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 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. ```bash 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. ## 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.