| name | devport |
|---|---|
| description | Run per-project dev services from a single `devport.toml` — one verb (`devport up`) allocates ports, forks per-service tmux sidecars, waits for health, then either blocks until Ctrl-C (foreground) or exits leaving sidecars alive (`--daemon`). Inspect via `devport ls` (JSONL over `.devport/`), tear down via `devport down`. Use when starting, stopping, or inspecting a project's dev stack; configuring a new project's dev spec; or debugging why a supervised service isn't healthy. |
Ground-up rewrite of devportv2 on top of the state-dir + dir-flock substrate from hayeah-go/supervisor. One verb, one spec, per-project state.
Status: v1 under construction. Foreground + daemon lifecycles work end-to-end. ls is JSONL over .devport/.
- Quick start —
devport.toml+devport up - CLI verbs —
up/down/lsand the--host/--daemonflags - devport.toml shape — TOML schema at a glance
# ./devport.toml
tmux_session = "devport-myapp"
# host = "127.0.0.1" # optional. Default "" binds all interfaces
# (Tailscale tailnet peers can reach the stack).
[[service]]
type = "proxy"
[[service.routes]]
name = "api"
path = "/api/"
strip_prefix = true
type = "exec"
command = ["./bin/api", "--port", "$${PORT}"]
[service.routes.health]
type = "tcp"
interval = "1s"
timeout = "30s"
[[service.routes]]
name = "web"
path = "/"
type = "static"
root = "./web/dist"$ devport up
devport up: stack is healthy
API http://127.0.0.1:23001
_proxy0 http://127.0.0.1:23000
# Ctrl-C tears everything down.$ devport up --daemon
devport up: stack is healthy
API http://127.0.0.1:23001
_proxy0 http://127.0.0.1:23000
# up has exited; services are supervised by per-pane sidecars in the tmux session.
# A one-shot summary is appended to .devport/up.log.
$ devport ls | jq -c '{key: .supervisor.key, alive, state: .state.state, port: .state.port, sidecar: .supervisor.pid}'
{"key":"api","alive":true,"state":"healthy","port":23001,"sidecar":72341}
{"key":"_proxy0","alive":true,"state":"healthy","port":23000,"sidecar":72342}
$ devport down
devport: stopped 2 supervisor(s)| Command | Purpose |
|---|---|
devport up [spec] |
Foreground (default). Blocks until SIGINT; first signal runs graceful teardown, second forces exit 130. |
devport up [spec] --daemon |
Spawn sidecars, wait for healthy, exit. Services survive in the tmux session. One-shot summary at .devport/up.log. |
devport up [spec] --host HOST |
Override the spec's host for proxy listeners. Default "" binds all interfaces. |
devport down [spec] |
SIGTERM every sidecar listed in the spec's .devport/. SIGKILL stragglers after timeout. |
devport ls [spec] |
JSONL of services in the spec's .devport/ (default ./.devport/). Pipe to duckql for interactive queries. |
devport _supervise |
Hidden. One per service — spawned by devport up inside a fresh tmux pane. Owns the flock, writes state.json, serves rpc.sock, and (for exec leaves) spawns the child inline; (for proxies) runs the HTTP router inline. |
Host precedence: --host flag > spec host > "" (all interfaces).
Deferred: logs (use tmux attach for now), status / doctor (redundant with ls | duckql), attach, freeport, ingress, include for cross-repo composition.
tmux_session = "devport-myapp" # required. Session name for sidecar windows.
host = "127.0.0.1" # optional. Bind host for proxy listeners.
# Default "" = all interfaces (tailnet-reachable).
[[service]] # one per top-level service. type = "exec" | "proxy".
type = "exec"
name = "worker"
command = ["./bin/worker", "--port", "$${PORT}"]
[service.health]
type = "tcp"; interval = "1s"; timeout = "30s"
[[service]]
type = "proxy"
[[service.routes]] # proxy children: exec / static / http.
name = "api"
path = "/api/"
strip_prefix = true
type = "exec"
command = ["./bin/api", "--port", "$${PORT}"]
[service.routes.health]
type = "tcp"; interval = "1s"; timeout = "30s"$${PORT}incommandis substituted with the service's allocated port at spawn.- State-dir keys follow the env-var prefix rule, lowercased:
APP_VITE_PORT↔.devport/app_vite/state.json, one-to-one. - Env fan-out to each service pane is shell-env forwarded via
tmux new-window -e KEY=VAL, filtered by theForwardableEnvdeny-list (e.g.PATH,SHELL,TMUX*are dropped).
Invariants the rest of the system falls out of:
- One flocked directory = one port = one live owner. Every supervised service (exec leaf or proxy) gets a state directory flocked by a per-service
devport _superviseprocess; killing a sidecar releases its flock. No SQLite, no global registry. State lives in./.devport/<key>/state.json. - Tmux is the coordination layer.
devport uplaunches each sidecar by callingtmux new-window -e KEY=VAL … devport _supervise …(env fan-out via argv, not a shared env file — no secrets on disk).__superviseruns inside the pane, so the child's stdio goes to the pane pty —tmux attachgives you the user-visible terminal. After health,upexits (or blocks on SIGINT in foreground mode) and plays no further role. - Proxies are supervised the same way as execs. Every
type = "proxy"gets its own_supervisepane. The HTTP router runs inline inside_supervise(no_serve-proxysubprocess) — exec routes are pre-resolved by reading upstream ports from sibling state.json files before binding. devport lsis JSONL verbatim. One line perstate.json, each wrapped in{ "dir", "alive", "supervisor", "state" }.stateis the service section, refreshed every health tick.
- No SQLite. Every port-owning entity is a flocked directory. The registry = walking
.devport/.ls,cat,jqare the debugger. - One CLI verb.
devport upsubsumes v2'srun/proxy/up. Ephemeral vs. daemonized is a flag. - Service tree. Proxies and their children live in one TOML tree, same shape for ephemeral and persistent. Inspired by devportv2's
docs/unified-design.md. - Supervised proxies. Each proxy has its own
_supervisepane — so it has a home in daemon mode (observable viatmux attach), its own flock, and the same kill path as exec leaves. The router runs inline inside_supervise(no extra subprocess).
spec.go TOML schema + validation + Phase-A env expansion
tree.go Walk Spec → Nodes with env-prefix + state-dir keys
env.go .env file writer driven by the tree
ports.go FreePort / isPortFree
health.go CheckHealth (tcp / http / process / none)
plugin.go ServiceState (the state section of state.json)
exec.go NewExecService + ExecService.Run (supervisor.Service impl for exec leaves)
proxy_service.go NewProxyService + ProxyService.Run (supervisor.Service impl for proxies)
env_forward.go ForwardableEnv deny-list filter for shell→pane env fan-out
supervise.go RunSupervise: assemble Service + NewTmuxPTY, call supervisor.New/.Run
proxy.go ProxyConfig + Router (served inline by ProxyService)
proxy_handlers.go StaticHandler + HTTPHandler + declineOn404Writer
registry.go WalkStateDir + WriteLs + UsedPorts
up.go Up orchestrator: allocate ports, spawn tmux panes, waitHealthy, signals
down.go Down (walk state dirs, SIGTERM every supervisor.pid, SIGKILL on timeout)
cli/devport/ stdlib-flag dispatcher: up / down / ls / _supervise
Development uses a go.work (gitignored) pointing at the local dotfiles/libs/hayeah-go checkout for the supervisor package.
# Unit tests
go test ./...
# Full e2e (requires tmux)
go test -v -timeout 120s ./...- hayeah-go/supervisor — the state-dir + dir-flock supervisor library devport builds on.
- duckql — pipe
devport lsJSONL through for interactive SQL queries. - devportv2 — the predecessor; see its
docs/unified-design.mdfor the service-tree motivation.