There were three answers to "how do I test against a daemon", and the CLI
— the thinnest client — had none, so its formatting and error handling
were exercised by compiling and forking the real binary plus tmux.
Adds crates/maestro-testing with a MockDaemon that serves the real /api/*
surface from maestro-protocol types, backed by a Vec<Session>. It replaces
two ad-hoc stubs: maestro-web's, which returned [{"id":"session-1"}] —
not a valid Session — and maestro-tui's scripted socket with a hardcoded
timeline.
maestro-cli gets its first tests against a daemon: six cases covering the
catalog, that DaemonInfo carries the version and presets its old private
copy dropped, that a structured failure surfaces the daemon's own message,
and that connection-refused stays distinguishable from an HTTP rejection.
maestro-web's proxy suite gains an end-to-end check that an upstream
rejection reaches the browser as a 4502 close carrying the daemon's
message — upstream_close_reason had only unit tests.
e2e keeps the tmux-shaped tests. cli_list_reads_from_daemon still runs the
shipped binary as a subprocess but now against MockDaemon, and
tui_integration.rs is deleted: the real daemon's /api/events contract is
covered by maestro-daemon/tests/local_api.rs and the client reducer by
maestro-tui/tests/catalog_ws.rs. The e2e suite drops from ~32s to ~20s.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
maestro-cli/src/config.rs was two pass-throughs, a newtype error, a
two-field projection of MaestroConfigFile, and one four-line or_else with
a single caller. `resolve_daemon_url` moves onto MaestroConfigFile, next
to the schema it reads, and the module goes; its tests move with it.
`[web].dev_proxy_target` is deleted end to end — schema, env var,
normalisation, three tests, README line, and the startup warning that
admitted the server ignores it.
MaestroConfigFile::save had no callers outside its own tests. Deleting it
also removes every skip_serializing_if, the five is_empty impls, and the
Serialize derives that existed only to make its output tidy.
BroadcastTuning was a struct that was never instantiated: all three uses
were `::DEFAULT.field` in const position and there is no [broadcast]
section. Now two plain consts.
$MAESTRO_CONFIG was honoured twice, by resolve_config_path and by clap's
`env =`. Every binary already gets it through clap, so resolve_config_path
and CONFIG_PATH_ENV go and the TUI falls back to default_config_path like
everything else.
Also collapses three layers of 1:1 forwarding: the umbrella's cli_dispatch
shim, and four near-identical 8-line functions in cli/dispatch.rs that
differed only in which run_* they called.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Restores the harness's deliberate 5s per-request budget, which the shared
client's 30s default had silently replaced, and converts the last two
hand-rolled daemon calls in e2e.rs (recent-dirs, gitroot). Drops reqwest
from maestro-e2e entirely.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CLI, TUI and end-to-end harness each carried their own client: three
reqwest builders with three timeout policies, three copies of the
success-or-body check, and route strings at every call site. Two of them
discarded the daemon's structured error and reported only a status code.
Deletes `maestro-cli/src/http.rs`, both `ws_base` copies in the TUI, the
CLI's partial `DaemonInfo` (it dropped `version` and `presets`), and the
e2e harness's `check_ok`. `maestro-web` gains a protocol dependency and
stops building the error envelope with a format string and reading it back
as untyped JSON; the daemon and the proxy now register routes from the same
constants.
`maestro_web::router` returns `Result` — an unusable daemon URL fails at
startup instead of at first connect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ships `maestro`, `maestro-web` and the SPA bundle as flake packages, plus a
`services.maestro` Home Manager module that runs the daemon and the web UI as
systemd user services.
`maestro-web`'s build.rs now honours MAESTRO_WEB_DIST and hands the resolved
bundle path to `include_dir!` through MAESTRO_WEB_BUNDLE_DIR. Without it the
Nix build would have to run `pnpm install` mid-compile, which the sandbox has
no network for. Unset, the pnpm path is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
They described the registry/peer-to-peer design and were kept only for
their superseded banners. CLAUDE.md is the current reference. Repoints
the doc comments that cited them, and fixes the maestro-web README,
which still named MAESTRO_REGISTRY as the Vite proxy override
(MAESTRO_DAEMON_URL since the refactor).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
load() and load_or_default() differed only in whether a missing file was
an error. Nothing wants the error: every field has a default. Keeps
load(), drops the MissingFile variant, whose message pointed at the
`maestro daemon configure` subcommand the single-host refactor deleted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Session tree nests by working directory only; the daemon chips in the
header become a session tally, and the spawn form loses its host picker.
API calls move from /api/daemons/:id/* to /api/dirs, /api/recent-dirs,
/api/gitroot, and /api/info.
Maestro is now single-host: one daemon owns the machine's sessions and
clients talk to it directly. Removes the registry crate, the daemon's
registry client/broker/catalog mirror, JWT attach auth, cookie auth, and
the perimeter (T2) transport config.
The daemon API is now unauthenticated on its bind address, same trust
model as tmux. Session and CreateSessionRequest lose daemon_id;
RegistryEvent becomes CatalogEvent with no daemon variants; attach moves
to WS /api/attach/:id with no token.
Cross-host (T2-brokered) attach fails when no shared perimeter is
configured: the daemon rejects the upgrade with a structured 502
(no_perimeter_candidate / no_local_perimeter / perimeter_mismatch /
peer_unreachable), but maestro-web's WS proxy had already upgraded the
browser socket and merely dropped it on the upstream failure — the
attach pane flashed and vanished with no reason shown.
- maestro-web proxy: on a failed upstream handshake, relay the daemon's
error message to the browser as an application close frame (4502 +
reason) instead of a bare drop; parse the structured JSON error body.
- web attach.ts: on a 4000+ close with a reason, show it in the pane
header and terminal body and keep the pane up, rather than auto-closing.
- daemon: WARN at startup when no perimeter_id/private_addr is configured,
since cross-host attach is then unavailable.
- docs/INSTALL.md: document the required matching perimeter_id + per-host
private_addr for cross-host attach.
Closes#39
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SPA fallback returned index.html (HTTP 200, text/html) for ANY unknown
path, including missing /assets/*. index.html was also served with no
Cache-Control, so browsers cached the shell. After a redeploy rotated Vite's
content hashes, a cached shell requested its old /assets/index-OLDHASH.js,
the server answered with the HTML shell, and the browser tried to execute
HTML as JS -> blank page (seen on Android Chrome while the installed PWA,
with its own warm cache, kept working).
- Missing paths that name a file (have an extension) now 404 instead of
falling through to index.html; only extensionless SPA routes fall back.
- index.html / sw.js / manifest / icons: Cache-Control: no-cache.
- Content-hashed assets/: public, max-age=31536000, immutable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `parse_bind_addr`, `default_state_dir(subdir)`, and an `InvalidBindAddr`
variant to `maestro-config`, and route the daemon, registry, and web config
loaders through them. Each crate's hand-rolled `InvalidBindAddr` variant and
duplicated XDG state-dir resolution are gone; their loaders now `?` the shared
helper, which converts into their existing transparent `Inner` variant.
Also adds the shared `BroadcastTuning` struct (used by the next commit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`proxy_http` buffered request bodies with `to_bytes(.., usize::MAX)` and
materialized full upstream responses with `.bytes()`. Anyone reachable on the
bind address could POST a multi-GB body (the cookie middleware admits any
signed cookie) and OOM the process; a large upstream reply did the same.
Cap buffered request bodies at 8 MiB and return 413 on overflow; stream the
upstream response straight through via `bytes_stream()` → `Body::from_stream`
so it's never fully held in RAM.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
build.rs now builds web/dist before include_dir! evaluates, so the
checked-in .gitkeep placeholder is no longer needed for a fresh checkout
to compile (verified by building with web/dist entirely absent). Ignore
dist/ wholesale and update the static_bundle doc comment.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Watch the embedded web/dist output (not just frontend inputs) so a
rebuilt bundle is actually re-embedded; assert pnpm build produced
web/dist/index.html; strengthen the embed test to require a vite-built
hashed asset reference.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
include_dir! now embeds a freshly built web/dist on every compile; pnpm +
node are hard build requirements. Fixes the placeholder served by
cargo install --git.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New crate crates/maestro-web/: axum server that
* embeds the web/dist SPA bundle via include_dir! (graceful placeholder when
pnpm build hasn't run yet — keeps fresh checkouts compiling)
* reverse-proxies /api/* HTTP to a co-located daemon (default
http://127.0.0.1:7070) via reqwest, stripping hop-by-hop headers
* reverse-proxies /api/attach/:id and /api/events WebSockets via
tokio_tungstenite::connect_async + a tokio task pumping both directions
* reads bind_addr / daemon_url / dev_proxy_target from the new [web]
config section; env-var overrides MAESTRO_WEB_BIND_ADDR / _DAEMON_URL /
_DEV_PROXY_TARGET
Integration tests spin up a mock daemon + maestro-web pair on random ports
and verify HTTP forward, SPA fallback, WS echo, and 502 on unreachable
daemon.
Adds a top-level Justfile (`just web` = pnpm install + pnpm build + cargo
build), crates/maestro-web/README.md (config + trust model + dev workflow),
and a docs/INSTALL.md section on optional web UI install.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Earlier today I picked option 1 from NAP-231's open question (run
`maestro-registry` locally on a different port). That was wrong: the
user-facing framing is "local web UI", and option 2 — a thin front-end
that serves the web bundle and reverse-proxies `/api/*` to whatever
registry the daemon is already registered with — is what actually
delivers that.
What the new crate does:
- `crates/maestro-web` ships a single binary `maestro-web`.
- It embeds the web bundle the same way `maestro-registry` does
(rust-embed against `web/dist/`).
- Mounts the bundle at `/` and a reverse proxy at `/api/*` (catch-all).
- HTTP proxy via `reqwest` — forwards method, headers (minus
hop-by-hop), body, and passes upstream response headers (including
`Set-Cookie`) back to the browser.
- WS proxy upgrades both sides and shuttles frames with
`tokio::select!`. Cookies on the initial `WebSocket` upgrade headers
are forwarded so the registry sees the same browser session.
- Reads `MAESTRO_REGISTRY` (upstream URL) and `MAESTRO_WEB_BIND_ADDR`
(default `127.0.0.1:8888`) from env.
- An integration test (`tests/proxy.rs`) stands up a mock upstream,
proxies an HTTP request with cookies through, and round-trips a text
WebSocket frame to verify the WS path.
Installer changes:
- Wizard now `cargo install`s `maestro-web` (not `maestro-registry`)
when the user opts into the web service.
- Unit template switched: `ExecStart=%h/.cargo/bin/maestro-web` plus
`Environment=MAESTRO_REGISTRY=<upstream>` +
`Environment=MAESTRO_WEB_BIND_ADDR=<bind>`.
- New `--web-registry <URL>` flag. Default in interactive mode: read
`registry_url` straight out of `~/.config/maestrod/config.toml`, so
the proxy lines up with what the daemon is using without further
prompting. Non-interactive runs without either of those error out
rather than guess.
Docs:
- `docs/INSTALL.md` updated unit body + flag table.
- `CLAUDE.md` repo-layout block now lists `maestro-web`.
148 tests pass workspace-wide (9 new in maestro-web, including the
proxy integration test; 15 in installer).