# cellos-ctl
`cellctl` is the kubectl-style CLI for CellOS. It is a thin client over
`cellos-server`: every subcommand corresponds to exactly one HTTP call,
there is no client-side cache, and exit codes are a stable contract
(0 success / 1 usage / 2 API / 3 validation).
Configuration lives in `~/.cellos/config` (TOML); the typical fields are
`server_url` and `api_token`. CLI flags and the `CELLCTL_SERVER` /
`CELLCTL_TOKEN` environment variables override the on-disk values.
## Commands
| `apply -f <file>` | Submit a formation YAML spec (POST `/v1/formations`). |
| `get formations` / `get cells` | List resources. |
| `describe formation <name>` / `describe cell <name>` | Show state + recent events. |
| `delete formation <name>` | Tear down a formation (and its cells). |
| `logs <cell> [-f]` | Stream CloudEvents for one cell. |
| `events [--formation N] [-f]` | Stream global / formation-scoped events. |
| `rollout status <name>` | Poll a formation to a terminal state. |
| `diff -f <file>` | Compare local YAML against the server-side formation. |
| `config show` / `config set-server <url>` / `config set-token <token>` | Read/write `~/.cellos/config`. |
| `version` | Print client + server versions. |
| `webui` | Spin up a localhost browser proxy for the web view. |
## Formation manifest shapes
`cellctl apply -f <file>` forwards the file's contents verbatim to
`POST /v1/formations`. The server accepts **either** of two shapes; pick
one per file (mixing them in a single document is rejected).
**Kubectl-style** — matches `contracts/schemas/formation-v1.schema.json`
and is the preferred form for new manifests. New operators who already
think in kubectl terms will recognise it immediately:
```yaml
apiVersion: cellos.dev/v1
kind: Formation
metadata:
name: demo
spec:
coordinator: coord
members:
- name: coord
- name: worker-a
authorizedBy: coord
```
**Flat** — the server's canonical internal form. Shorter; used by
older manifests and by the server's test fixtures:
```yaml
name: demo
coordinator: coord
members:
- id: coord
- id: worker-a
authorizedBy: coord
```
The mapping (kubectl → flat) is:
| kubectl path | flat path |
|---|---|
| `metadata.name` | `name` |
| `spec.coordinator` | `coordinator` |
| `spec.members[].name` | `members[].id` |
| `spec.members[].authorizedBy` | `members[].authorizedBy` |
`apiVersion` MUST be `cellos.dev/v1` and `kind` MUST be `Formation`;
anything else is rejected with `400 /problems/bad-request`. After the
adapter normalizes the document, the usual ADR-0010 admission checks
(`no-coordinator`, `multiple-coordinators`, `authority-not-narrowing`,
`cycle`, `duplicate-member-id`) run on the flat form and surface their
stable `/problems/formation/*` discriminants. The
`duplicate-member-id` type was split out from `multiple-coordinators`
so operators can tell a structural admission rejection ("two members
both named `coord` — pick one to be the coordinator") apart from a
typo ("two members share the same id — rename one").
## webui
`cellctl webui` is the only supported way to reach the CellOS browser
view (ADR-0017). It is a foreground process that runs a localhost
reverse proxy in front of `cellos-server`:
```
cellctl webui # default: bind BOTH loopback TCP and Unix socket
cellctl webui --open # also launch the system browser
cellctl webui --bind loopback # TCP loopback only (no Unix socket)
cellctl webui --bind unix # Unix socket only — no browser URL (Linux/macOS)
```
On startup the command:
1. Reads `~/.cellos/config` (or `$CELLCTL_SERVER` / `$CELLCTL_TOKEN`)
for the upstream URL and bearer token.
2. Binds one or both listeners according to `--bind`. The default
(`auto`) binds **both** a loopback TCP port (so the browser can
reach the proxy) and a Unix domain socket at
`${XDG_RUNTIME_DIR:-/tmp}/cellctl-webui-<pid>.sock` with mode
`0600` (so inter-process tooling can bypass loopback without
exposing a TCP port to anything else on the machine). Both URLs
are printed on stdout. The Unix socket is removed on graceful
shutdown. On Windows, `auto` degrades to loopback-only and
`--bind unix` errors.
3. Mints a 32-byte random session token and prints the launch URL with
the token in the URL **fragment**:
`http://127.0.0.1:<port>/#sess=<base64>`. Fragments are never
transmitted in HTTP requests, so the token does not appear in proxy
logs, server logs, or `Referer` headers.
4. Serves the Vite-built bundle from `crates/cellos-ctl/static/`. The
bundle reads `location.hash`, posts to `/auth/exchange`, receives an
`HttpOnly; SameSite=Strict` cookie, and clears the fragment.
5. Reverse-proxies `GET /v1/*` and `GET /ws/events` upstream, injecting
`Authorization: Bearer <token>` on every outbound request. The
bundle never sees the bearer token.
6. **Refuses any non-`GET` method with HTTP 405** (`Allow: GET`). This
is the structural enforcement of ADR-0016's read-only browser
boundary — even a compromised bundle cannot write through the
proxy.
7. Exits on `Ctrl-C`, printing `shutting down` on stderr.
### Security properties
- The bearer token in `~/.cellos/config` never leaves the machine; it
lives on the proxy's outbound socket and nowhere else.
- The browser sees a session cookie that is meaningless outside this
`cellctl webui` process. When the process exits, the cookie is dead.
- Browser writes are impossible: the proxy returns 405 on any non-GET
to anything other than `/auth/exchange` (the cookie-mint endpoint).
- The launch URL's token lives only in the fragment, so it cannot
appear in HTTP access logs, the `Referer` header, or anything else
that sees the path/query.
### Flags
| Flag | Default | Meaning |
|---|---|---|
| `--open` | off | After binding, launch the system browser at the URL. Ignored if there is no loopback URL (i.e. `--bind unix`). |
| `--bind <auto\|loopback\|unix>` | `auto` | `auto`: bind both loopback TCP and a Unix socket (Linux/macOS) or loopback-only (Windows). `loopback`: TCP only. `unix`: Unix socket only — no browser-reachable URL. |
### Bind modes in detail
| Mode | TCP loopback | Unix socket | Browser-reachable? | Use case |
|---|---|---|---|---|
| `auto` (default) | yes (random high port) | yes (`/tmp/cellctl-webui-<pid>.sock`, mode 0600) | yes | Normal desktop use. The browser uses the TCP URL; other local tools (forwarders, `curl --unix-socket`, `socat`) can use the UDS without exposing the proxy on a TCP port. |
| `loopback` | yes | no | yes | Same as today's behavior. Useful if you don't want a socket file in `$XDG_RUNTIME_DIR` / `/tmp`. |
| `unix` | no | yes | **no** | Inter-process forwarding only (e.g. `ssh -L 8080:/tmp/cellctl-webui-<pid>.sock` to expose the proxy to a remote browser through an SSH tunnel). `--open` is a no-op. |
Trade-offs:
- **`auto`** is the operator-friendly default. The cost is one extra
file in `$XDG_RUNTIME_DIR` (or `/tmp` if the env var is unset) for
the lifetime of the `cellctl webui` process. The file is mode `0600`
(owner read+write only) and is unlinked on `Ctrl-C`.
- **`loopback`** is for environments where you specifically don't want
a UDS — e.g. running inside a sandbox that disallows AF_UNIX, or
paranoia about leftover socket files surviving a crash. If you crash
with `auto`, the stale `.sock` file is harmless (a fresh run unlinks
and re-binds) but it's still clutter.
- **`unix`** is the highest-isolation mode: nothing on the loopback
interface, only filesystem permissions guard the proxy. The cost is
that the browser can't reach it directly — you need a forwarder.
Best used over an SSH tunnel into a remote operator host.
### Building the bundle
`cellctl webui` looks up `crates/cellos-ctl/static/` (set via
`CARGO_MANIFEST_DIR` at build time; override with
`$CELLCTL_WEBUI_BUNDLE_DIR` for development). If the directory is
missing, run the Vite build first:
```
npm --prefix web run build
```
See ADR-0017 for the full design rationale and ADR-0016 for the
read-only-browser invariant the 405-on-non-GET rule enforces.