Compare commits

...
6 Commits
Author SHA1 Message Date
Miguel PalhasandClaude Opus 5 b163f24f63 fix(maestro): reinstall hooks when an older set is on disk
ci / web (push) Successful in 42s
ci / flake (push) Successful in 2m41s
ci / rust (push) Successful in 5m41s
The daemon's auto-install skipped on presence, so a hook installed
before #131 never gained UserPromptSubmit and no session was ever named
after its prompt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 21:28:26 +01:00
Miguel Palhas 0babde197d Merge branch 'fix/tui-daemon-url-resolution'
ci / web (push) Failing after 6s
ci / flake (push) Successful in 2m36s
ci / rust (push) Successful in 5m13s
2026-09-02 21:09:44 +01:00
Miguel PalhasandClaude Opus 5 9a48bf51f1 fix(tui): resolve daemon URL from config
The TUI arm of the umbrella binary hardcoded http://127.0.0.1:7070 instead
of going through MaestroConfigFile::resolve_daemon_url, which every CLI
subcommand uses. On a host whose [daemon].bind_addr is not the default port,
`maestro list` worked and `maestro tui` could not connect.

RunConfig.daemon_url is now the override rather than the final URL; the TUI
resolves it against the config file it already loads for its UI settings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 21:07:54 +01:00
Miguel Palhas 9dad3e6472 Merge remote-tracking branch 'origin/main' into animato
ci / web (push) Successful in 22s
ci / flake (push) Successful in 4m47s
ci / rust (push) Successful in 5m59s
# Conflicts:
#	crates/maestro-daemon/Cargo.toml
#	crates/maestro-daemon/src/local_api.rs
#	crates/maestro/Cargo.toml
2026-09-02 18:37:53 +01:00
Miguel PalhasandClaude Opus 5 9a8f666975 perf(maestro): disable Nagle on the daemon listener
Keystrokes and PTY echo are tiny frames. With Nagle on, each can sit up to
40ms waiting for an ACK before the kernel sends it — invisible over
loopback, plainly felt from another device on the LAN.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 18:23:28 +01:00
Miguel PalhasandClaude Opus 5 4fc90aabfb feat(maestro): serve the web UI from the daemon
The browser reached the daemon through maestro-web, a second binary that
served the SPA and reverse-proxied /api/* — two TCP connections and a
WebSocket frame re-encode on every keystroke and every byte of PTY output.
On one machine that hop buys nothing.

The daemon now embeds the bundle and serves it off its own listener, behind
a default-on `web` feature. `--no-default-features` builds without pnpm or
node; MAESTRO_WEB_DIST still takes a prebuilt bundle.

The router's fallback answers an unmatched /api/* with 404 rather than the
SPA shell, so a client expecting JSON gets a status it can read instead of
HTML.

Removes maestro-web and the [web] config section. An existing [web] block
is ignored, not rejected. The installer deletes a leftover
maestro-web.service on upgrade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 18:23:18 +01:00
42 changed files with 723 additions and 1632 deletions
-1
View File
@@ -201,7 +201,6 @@ jobs:
run: | run: |
nix --extra-experimental-features "nix-command flakes" build \ nix --extra-experimental-features "nix-command flakes" build \
.#maestro \ .#maestro \
.#maestro-web \
--no-link --no-link
# Saved only from a push, so a PR that built webFrontend alone can never # Saved only from a push, so a PR that built webFrontend alone can never
+6 -6
View File
@@ -8,7 +8,8 @@ persistence, WebSocket for transport.
## Architecture ## Architecture
``` ```
maestro daemon tmux wrapper + session API on 127.0.0.1:7070 maestro daemon tmux wrapper + session API + SPA on 127.0.0.1:7070
├── GET / browser SPA, embedded (feature `web`)
├── GET/POST /api/sessions list / spawn ├── GET/POST /api/sessions list / spawn
├── DELETE /api/sessions/:id kill ├── DELETE /api/sessions/:id kill
├── POST /api/sessions/:id/input type text into a session ├── POST /api/sessions/:id/input type text into a session
@@ -22,7 +23,7 @@ maestro daemon tmux wrapper + session API on 127.0.0.1:7070
maestro list/spawn/attach/kill/send CLI client maestro list/spawn/attach/kill/send CLI client
maestro tui ratatui client maestro tui ratatui client
maestro-web serves the SPA, reverse-proxies /api/* to the daemon browser loads the SPA from the daemon, same port
``` ```
**No authentication layer, no registry, no peer-to-peer.** Whoever can reach the **No authentication layer, no registry, no peer-to-peer.** Whoever can reach the
@@ -44,7 +45,7 @@ Linear project **Maestro** under team **Naps62**.
Cargo workspace. NAP-245 unified the per-binary crates behind a single Cargo workspace. NAP-245 unified the per-binary crates behind a single
`crates/maestro/` umbrella that ships the `maestro` binary with feature gates `crates/maestro/` umbrella that ships the `maestro` binary with feature gates
(`cli` / `daemon` / `tui`, default = all three). (`cli` / `daemon` / `tui` / `web`, default = all four).
``` ```
crates/ crates/
@@ -52,12 +53,11 @@ crates/
├── maestro-protocol/ # shared wire types (NAP-211) ├── maestro-protocol/ # shared wire types (NAP-211)
├── maestro-config/ # shared on-disk config schema (NAP-233) ├── maestro-config/ # shared on-disk config schema (NAP-233)
├── maestro-cli/ # `maestro list/spawn/attach/kill` dispatch (NAP-216) ├── maestro-cli/ # `maestro list/spawn/attach/kill` dispatch (NAP-216)
├── maestro-daemon/ # `maestro daemon` — tmux wrapper + HTTP/WS API (NAP-212, NAP-214) ├── maestro-daemon/ # `maestro daemon` — tmux wrapper + HTTP/WS API + SPA (NAP-212, NAP-214)
├── maestro-web/ # standalone `maestro-web` browser UI (NAP-241, NAP-249)
├── maestro-tui/ # `maestro tui` — ratatui session manager (NAP-275 epic) ├── maestro-tui/ # `maestro tui` — ratatui session manager (NAP-275 epic)
├── maestro-installer/ # `maestro-install` wizard: cargo install + systemd (NAP-225) ├── maestro-installer/ # `maestro-install` wizard: cargo install + systemd (NAP-225)
└── maestro-e2e/ # cross-process integration tests (NAP-217) └── maestro-e2e/ # cross-process integration tests (NAP-217)
web/ # browser SPA (NAP-218) web/ # browser SPA (NAP-218), embedded by the daemon's `web` feature
flake.nix # Nix flake: packages, checks, dev shell, `nix run .#dev` flake.nix # Nix flake: packages, checks, dev shell, `nix run .#dev`
nix/ # Home Manager module (`services.maestro`) nix/ # Home Manager module (`services.maestro`)
``` ```
Generated
+2 -25
View File
@@ -1101,11 +1101,13 @@ dependencies = [
"clap", "clap",
"dirs", "dirs",
"futures-util", "futures-util",
"include_dir",
"libc", "libc",
"maestro-config", "maestro-config",
"maestro-hooks", "maestro-hooks",
"maestro-names", "maestro-names",
"maestro-protocol", "maestro-protocol",
"mime_guess",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
@@ -1214,31 +1216,6 @@ dependencies = [
"vt100", "vt100",
] ]
[[package]]
name = "maestro-web"
version = "0.1.0"
dependencies = [
"axum",
"bytes",
"clap",
"dirs",
"futures-util",
"include_dir",
"maestro-config",
"maestro-protocol",
"maestro-testing",
"mime_guess",
"reqwest",
"serde_json",
"tempfile",
"thiserror 1.0.69",
"tokio",
"tokio-tungstenite 0.21.0",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]] [[package]]
name = "matchers" name = "matchers"
version = "0.2.0" version = "0.2.0"
+5 -5
View File
@@ -5,15 +5,15 @@
dev: dev:
nix run .#dev nix run .#dev
# Build the maestro-web binary together with the SPA bundle it embeds. # Build the maestro binary together with the SPA bundle its daemon embeds.
web: web:
pnpm -C web install --frozen-lockfile pnpm -C web install --frozen-lockfile
pnpm -C web build pnpm -C web build
cargo build -p maestro-web --release cargo build -p maestro --release
# Run the web bundle in dev mode against a local daemon — Vite serves the # Run the frontend in dev mode against a local daemon — Vite serves it with
# frontend with HMR, and `/api/*` is proxied directly to the daemon at # HMR, and `/api/*` is proxied directly to the daemon at 127.0.0.1:7070
# 127.0.0.1:7070 (see vite.config.ts). # (see vite.config.ts).
web-dev: web-dev:
pnpm -C web install pnpm -C web install
pnpm -C web dev pnpm -C web dev
+3 -24
View File
@@ -9,9 +9,6 @@
//! //!
//! [daemon] //! [daemon]
//! bind_addr = "127.0.0.1:7070" //! bind_addr = "127.0.0.1:7070"
//!
//! [web]
//! bind_addr = "127.0.0.1:8080"
//! ``` //! ```
//! //!
//! This crate owns the file schema, path resolution, and atomic save. Each //! This crate owns the file schema, path resolution, and atomic save. Each
@@ -60,8 +57,6 @@ pub struct MaestroConfigFile {
#[serde(default)] #[serde(default)]
pub daemon: DaemonSection, pub daemon: DaemonSection,
#[serde(default)] #[serde(default)]
pub web: WebSection,
#[serde(default)]
pub tui: TuiSection, pub tui: TuiSection,
#[serde(default)] #[serde(default)]
pub spawn: SpawnSection, pub spawn: SpawnSection,
@@ -131,20 +126,6 @@ pub struct DaemonSection {
pub spawn_history_days: Option<u32>, pub spawn_history_days: Option<u32>,
} }
/// `[web]` — `maestro-web` binary settings (NAP-241).
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct WebSection {
/// Address `maestro-web` binds. Default `127.0.0.1:8080`. Bind a LAN/VPN
/// address to share with your own devices on a trusted segment; there is no
/// auth layer, so never bind a public address.
#[serde(default)]
pub bind_addr: Option<SocketAddr>,
/// HTTP base URL of the co-located daemon `maestro-web` proxies to.
/// Default `http://127.0.0.1:7070`.
#[serde(default)]
pub daemon_url: Option<String>,
}
/// `[tui]` — `maestro tui` settings (NAP-275 epic, phase 2 NAP-277). /// `[tui]` — `maestro tui` settings (NAP-275 epic, phase 2 NAP-277).
/// ///
/// Theme, leader key, chord timeout, and per-mode keymap overrides. The TUI /// Theme, leader key, chord timeout, and per-mode keymap overrides. The TUI
@@ -414,8 +395,8 @@ pub fn default_config_path() -> Result<PathBuf, ConfigError> {
/// Parse a `SocketAddr` from a raw (usually env-sourced) string, tagging the /// Parse a `SocketAddr` from a raw (usually env-sourced) string, tagging the
/// failure with the originating env-var name so every binary surfaces the same /// failure with the originating env-var name so every binary surfaces the same
/// `invalid <ENV> (...)` diagnostic. Shared by the daemon and web `bind_addr` /// `invalid <ENV> (...)` diagnostic. Used by the daemon's `bind_addr`
/// resolution paths (NAP-273). /// resolution path (NAP-273).
pub fn parse_bind_addr(raw: &str, env: &'static str) -> Result<SocketAddr, ConfigError> { pub fn parse_bind_addr(raw: &str, env: &'static str) -> Result<SocketAddr, ConfigError> {
raw.parse().map_err(|source| ConfigError::InvalidBindAddr { raw.parse().map_err(|source| ConfigError::InvalidBindAddr {
env, env,
@@ -426,8 +407,7 @@ pub fn parse_bind_addr(raw: &str, env: &'static str) -> Result<SocketAddr, Confi
/// XDG state directory for a Maestro component, e.g. `~/.local/state/<subdir>` /// XDG state directory for a Maestro component, e.g. `~/.local/state/<subdir>`
/// (falling back to the data-local dir when `XDG_STATE_HOME`/`$HOME` are /// (falling back to the data-local dir when `XDG_STATE_HOME`/`$HOME` are
/// unset). The daemon passes `"maestro"`, `maestro-web` passes `"maestro-web"` /// unset). The daemon passes `"maestro"` (NAP-273).
/// (NAP-273).
pub fn default_state_dir(subdir: &str) -> Result<PathBuf, ConfigError> { pub fn default_state_dir(subdir: &str) -> Result<PathBuf, ConfigError> {
let base = dirs::state_dir() let base = dirs::state_dir()
.or_else(dirs::data_local_dir) .or_else(dirs::data_local_dir)
@@ -677,7 +657,6 @@ bind_addr = "127.0.0.1:9090"
.unwrap(); .unwrap();
let cfg = MaestroConfigFile::load(&path).unwrap(); let cfg = MaestroConfigFile::load(&path).unwrap();
assert_eq!(cfg.cli, CliSection::default()); assert_eq!(cfg.cli, CliSection::default());
assert_eq!(cfg.web, WebSection::default());
assert_eq!( assert_eq!(
cfg.daemon.bind_addr, cfg.daemon.bind_addr,
Some("127.0.0.1:9090".parse().unwrap()) Some("127.0.0.1:9090".parse().unwrap())
+6 -1
View File
@@ -11,7 +11,10 @@ description = "Per-host tmux wrapper + session orchestrator"
path = "src/lib.rs" path = "src/lib.rs"
[features] [features]
default = ["haiku-autoname"] default = ["web", "haiku-autoname"]
# Embed and serve the browser SPA off the daemon's own listener. Requires
# pnpm + node at build time (or a prebuilt bundle via MAESTRO_WEB_DIST).
web = ["dep:include_dir", "dep:mime_guess"]
# Name a session after its first prompt (#131): the `/api/sessions/:id/prompt` # Name a session after its first prompt (#131): the `/api/sessions/:id/prompt`
# route, the `claude -p` naming run behind it, and the agent hook that feeds # route, the `claude -p` naming run behind it, and the agent hook that feeds
# it. Off leaves sessions on the handle the daemon generates at spawn. # it. Off leaves sessions on the handle the daemon generates at spawn.
@@ -23,7 +26,9 @@ chrono.workspace = true
clap.workspace = true clap.workspace = true
dirs.workspace = true dirs.workspace = true
futures-util.workspace = true futures-util.workspace = true
include_dir = { workspace = true, optional = true }
libc.workspace = true libc.workspace = true
mime_guess = { workspace = true, optional = true }
maestro-config.workspace = true maestro-config.workspace = true
maestro-hooks = { workspace = true, default-features = false } maestro-hooks = { workspace = true, default-features = false }
maestro-names.workspace = true maestro-names.workspace = true
+133 -3
View File
@@ -1,7 +1,8 @@
//! Stamps the daemon binary with the commit it was built from, so //! Two build-time jobs for the daemon.
//! `GET /api/info` can tell clients which release they are talking to.
//! //!
//! Two sources, in order: //! **Build stamp.** Records the commit the binary was built from, so
//! `GET /api/info` can tell clients which release they are talking to. Two
//! sources, in order:
//! //!
//! 1. `MAESTRO_BUILD_COMMIT` / `MAESTRO_BUILD_DATE` / `MAESTRO_BUILD_DIRTY` in //! 1. `MAESTRO_BUILD_COMMIT` / `MAESTRO_BUILD_DATE` / `MAESTRO_BUILD_DIRTY` in
//! the environment. The Nix build has no `.git` in its source tree (crane //! the environment. The Nix build has no `.git` in its source tree (crane
@@ -10,11 +11,31 @@
//! //!
//! Neither available leaves the values empty; clients treat that as "unknown //! Neither available leaves the values empty; clients treat that as "unknown
//! release" rather than an error. //! release" rather than an error.
//!
//! **SPA bundle.** Under the `web` feature only, resolve the directory
//! `static_bundle.rs` embeds via `include_dir!` and publish it as
//! `MAESTRO_WEB_BUNDLE_DIR`. Two modes:
//!
//! * `MAESTRO_WEB_DIST` set — the bundle was built elsewhere (Nix, CI, a
//! release pipeline) and that directory is embedded as-is. No node, no pnpm.
//! * unset — the default developer path: run `pnpm install --frozen-lockfile
//! && pnpm build` in `web/` and embed `web/dist`. pnpm and node are HARD
//! requirements here; a missing toolchain fails with an actionable message
//! rather than silently shipping the placeholder.
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command; use std::process::Command;
fn main() { fn main() {
stamp_build();
bundle_spa();
}
// ---------------------------------------------------------------------------
// Build stamp
// ---------------------------------------------------------------------------
fn stamp_build() {
println!("cargo:rerun-if-env-changed=MAESTRO_BUILD_COMMIT"); println!("cargo:rerun-if-env-changed=MAESTRO_BUILD_COMMIT");
println!("cargo:rerun-if-env-changed=MAESTRO_BUILD_DATE"); println!("cargo:rerun-if-env-changed=MAESTRO_BUILD_DATE");
println!("cargo:rerun-if-env-changed=MAESTRO_BUILD_DIRTY"); println!("cargo:rerun-if-env-changed=MAESTRO_BUILD_DIRTY");
@@ -75,3 +96,112 @@ fn git_dir() -> Option<PathBuf> {
} }
Some(PathBuf::from(dir)) Some(PathBuf::from(dir))
} }
// ---------------------------------------------------------------------------
// SPA bundle
// ---------------------------------------------------------------------------
fn bundle_spa() {
println!("cargo:rerun-if-env-changed=MAESTRO_WEB_DIST");
// Cargo exports CARGO_FEATURE_<NAME> to build scripts for each enabled
// feature. Without `web` the bundle module isn't compiled, so building it
// would only cost every daemon build a pnpm run.
if std::env::var_os("CARGO_FEATURE_WEB").is_none() {
return;
}
let dist_dir = match std::env::var_os("MAESTRO_WEB_DIST") {
Some(prebuilt) => PathBuf::from(prebuilt),
None => build_with_pnpm(),
};
// Sanity-check that the directory really holds a bundle. A green build
// that embeds an empty/placeholder dir is worse than a loud failure here.
let index_html = dist_dir.join("index.html");
let produced = std::fs::metadata(&index_html)
.map(|m| m.is_file() && m.len() > 0)
.unwrap_or(false);
if !produced {
panic!(
"no SPA bundle at {} (missing or empty index.html) — \
check the vite `outDir`, or the MAESTRO_WEB_DIST override.",
index_html.display()
);
}
// `include_dir!` expands `$VAR` in its path argument against the env rustc
// is invoked with, which is what `cargo:rustc-env` sets.
println!(
"cargo:rustc-env=MAESTRO_WEB_BUNDLE_DIR={}",
dist_dir.display()
);
}
/// The default path: build `web/` in-tree with pnpm and return `web/dist`.
fn build_with_pnpm() -> PathBuf {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let web_dir = manifest_dir.join("../../web");
// Rebuild the bundle only when a frontend input changes — not on every
// unrelated Rust recompile.
for rel in [
"src",
"index.html",
"package.json",
"pnpm-lock.yaml",
"vite.config.ts",
] {
println!("cargo:rerun-if-changed={}", web_dir.join(rel).display());
}
// Also watch the embedded OUTPUT directory. The input watches above decide
// whether THIS script re-runs and regenerates `web/dist`; but `include_dir!`
// in `static_bundle.rs` reads that directory, and cargo has no other
// declared dependency on those output files. Without this watch, an
// incremental build could regenerate `web/dist` yet keep the OLD bundle
// embedded. Tying the crate's recompilation to the regenerated output makes
// the fresh assets actually get re-embedded.
let dist_dir = web_dir.join("dist");
println!("cargo:rerun-if-changed={}", dist_dir.display());
require_tool("pnpm");
require_tool("node");
run(
Command::new("pnpm")
.args(["install", "--frozen-lockfile"])
.current_dir(&web_dir),
"pnpm install --frozen-lockfile",
);
run(
Command::new("pnpm").arg("build").current_dir(&web_dir),
"pnpm build",
);
dist_dir
}
fn require_tool(tool: &str) {
let ok = Command::new(tool)
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !ok {
panic!(
"the `web` feature requires `{tool}` to build the embedded SPA bundle. \
Install pnpm + node (https://pnpm.io), point MAESTRO_WEB_DIST at a \
prebuilt bundle, or build with --no-default-features."
);
}
}
fn run(cmd: &mut Command, label: &str) {
let status = cmd
.status()
.unwrap_or_else(|e| panic!("failed to spawn `{label}`: {e}"));
if !status.success() {
panic!("`{label}` failed with {status}");
}
}
+4
View File
@@ -55,6 +55,9 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
crate::hooks::AutoInstallOutcome::Installed => { crate::hooks::AutoInstallOutcome::Installed => {
tracing::info!("installed claude-code activity hook (#106)"); tracing::info!("installed claude-code activity hook (#106)");
} }
crate::hooks::AutoInstallOutcome::Upgraded => {
tracing::info!("updated claude-code activity hook to this build (#106)");
}
crate::hooks::AutoInstallOutcome::Failed => { crate::hooks::AutoInstallOutcome::Failed => {
tracing::warn!("hooks auto-install failed; run `maestro hooks install` manually"); tracing::warn!("hooks auto-install failed; run `maestro hooks install` manually");
} }
@@ -114,6 +117,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
}; };
axum::serve(listener, router) axum::serve(listener, router)
.tcp_nodelay(true)
.with_graceful_shutdown(shutdown) .with_graceful_shutdown(shutdown)
.await?; .await?;
Ok(()) Ok(())
+42 -11
View File
@@ -1,26 +1,34 @@
//! First-start hook auto-install (#106). //! First-start hook auto-install (#106).
//! //!
//! The daemon runs [`auto_install`] once at startup: when the user's //! The daemon runs [`auto_install`] once at startup: unless the user's
//! `~/.claude/settings.json` carries no maestro hook yet (and //! `~/.claude/settings.json` already carries exactly what this build ships
//! `[hooks].auto_install` hasn't opted out — checked by the caller), the same //! (and `[hooks].auto_install` hasn't opted out — checked by the caller), the
//! installer `maestro hooks install` uses writes it. Best-effort: a failure is //! same installer `maestro hooks install` uses writes it. That covers
//! warned about and never blocks startup. The same global hook serves every //! upgrades as well as first starts: an install from a maestro that had fewer
//! Claude Code session on the machine, so nothing here targets repositories //! events — #131 added `UserPromptSubmit` to a hook #106 had already put in
//! or worktrees. //! everyone's settings — is rewritten rather than left half-wired. Best-effort:
//! a failure is warned about and never blocks startup. The same global hook
//! serves every Claude Code session on the machine, so nothing here targets
//! repositories or worktrees.
use std::path::Path; use std::path::Path;
/// Install the Claude Code activity hook for `home_dir` unless one is already /// Install the Claude Code activity hook for `home_dir` unless the current
/// present or the home dir is unresolvable (`""`). Returns what happened. /// one is already there or the home dir is unresolvable (`""`). Returns what
/// happened; an older install being brought up to date is
/// [`AutoInstallOutcome::Upgraded`], so the log can say so.
pub fn auto_install(home_dir: &str) -> AutoInstallOutcome { pub fn auto_install(home_dir: &str) -> AutoInstallOutcome {
if home_dir.is_empty() { if home_dir.is_empty() {
return AutoInstallOutcome::Skipped; return AutoInstallOutcome::Skipped;
} }
let settings = maestro_hooks::settings_path(Path::new(home_dir)); let settings = maestro_hooks::settings_path(Path::new(home_dir));
if maestro_hooks::is_installed(&settings) { let script = maestro_hooks::script_path(Path::new(home_dir));
if maestro_hooks::is_current(&settings, &script) {
return AutoInstallOutcome::AlreadyPresent; return AutoInstallOutcome::AlreadyPresent;
} }
let stale = maestro_hooks::is_installed(&settings);
match maestro_hooks::install_for_home(Path::new(home_dir)) { match maestro_hooks::install_for_home(Path::new(home_dir)) {
Ok(_) if stale => AutoInstallOutcome::Upgraded,
Ok(_) => AutoInstallOutcome::Installed, Ok(_) => AutoInstallOutcome::Installed,
Err(e) => { Err(e) => {
tracing::warn!(error = %e, "hooks auto-install failed (non-fatal)"); tracing::warn!(error = %e, "hooks auto-install failed (non-fatal)");
@@ -33,6 +41,8 @@ pub fn auto_install(home_dir: &str) -> AutoInstallOutcome {
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutoInstallOutcome { pub enum AutoInstallOutcome {
Installed, Installed,
/// An older maestro's hook was there and has been rewritten.
Upgraded,
AlreadyPresent, AlreadyPresent,
Failed, Failed,
Skipped, Skipped,
@@ -58,7 +68,7 @@ mod tests {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let home = dir.path().to_str().unwrap(); let home = dir.path().to_str().unwrap();
auto_install(home); auto_install(home);
// A foreign edit between starts survives; presence alone skips. // A current install is left exactly as it is.
let settings = dir.path().join(".claude/settings.json"); let settings = dir.path().join(".claude/settings.json");
let before = std::fs::read_to_string(&settings).unwrap(); let before = std::fs::read_to_string(&settings).unwrap();
assert_eq!(auto_install(home), AutoInstallOutcome::AlreadyPresent); assert_eq!(auto_install(home), AutoInstallOutcome::AlreadyPresent);
@@ -69,6 +79,27 @@ mod tests {
); );
} }
/// An install from an older maestro — present, but missing an event this
/// build ships — is upgraded rather than left alone (#131 shipped a new
/// event after #106 had already installed itself).
#[test]
fn a_stale_install_is_upgraded() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().to_str().unwrap();
auto_install(home);
let settings = dir.path().join(".claude/settings.json");
let script = maestro_hooks::script_path(dir.path());
let mut root: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
root["hooks"].as_object_mut().unwrap().remove("Stop");
std::fs::write(&settings, serde_json::to_string_pretty(&root).unwrap()).unwrap();
std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
assert_eq!(auto_install(home), AutoInstallOutcome::Upgraded);
assert!(maestro_hooks::is_current(&settings, &script));
}
#[test] #[test]
fn unresolvable_home_is_skipped() { fn unresolvable_home_is_skipped() {
assert_eq!(auto_install(""), AutoInstallOutcome::Skipped); assert_eq!(auto_install(""), AutoInstallOutcome::Skipped);
+2
View File
@@ -27,6 +27,8 @@ pub mod release;
pub mod session; pub mod session;
pub mod sidecar; pub mod sidecar;
pub mod spawn_history; pub mod spawn_history;
#[cfg(feature = "web")]
pub mod static_bundle;
pub mod tmux; pub mod tmux;
pub mod worktree; pub mod worktree;
pub mod worktree_probe; pub mod worktree_probe;
+28 -2
View File
@@ -1,7 +1,7 @@
//! Daemon-side HTTP API for clients (NAP-243). //! Daemon-side HTTP API for clients (NAP-243).
//! //!
//! These routes live on the daemon's `bind_addr` (default `127.0.0.1:7070`). //! These routes live on the daemon's `bind_addr` (default `127.0.0.1:7070`).
//! They're the contract `maestro-web`, the TUI, and the CLI call against. //! They're the contract the browser SPA, the TUI, and the CLI call against.
//! //!
//! ## Trust model //! ## Trust model
//! //!
@@ -65,7 +65,33 @@ pub fn router(state: LocalApiState) -> Router {
{ {
router = router.route(routes::patterns::SESSION_PROMPT, post(session_prompt)); router = router.route(routes::patterns::SESSION_PROMPT, post(session_prompt));
} }
router.with_state(state) with_web_ui(router.with_state(state))
}
/// Mount the embedded SPA under the same listener as the API, so the browser
/// talks to the daemon over one connection instead of through a proxy.
///
/// The fallback must not answer an unmatched `/api/...` path with the SPA
/// shell: a client asking for JSON would get HTML and a confusing parse error
/// instead of a 404.
#[cfg(feature = "web")]
fn with_web_ui(router: Router) -> Router {
use axum::http::Uri;
async fn serve_spa(uri: Uri) -> Response {
let path = uri.path();
if path == routes::API_PREFIX || path.starts_with(&format!("{}/", routes::API_PREFIX)) {
return StatusCode::NOT_FOUND.into_response();
}
crate::static_bundle::serve_path(path)
}
router.fallback(serve_spa)
}
#[cfg(not(feature = "web"))]
fn with_web_ui(router: Router) -> Router {
router
} }
/// `POST /api/sessions/:id/prompt` (#131): an agent reports a prompt it was /// `POST /api/sessions/:id/prompt` (#131): an agent reports a prompt it was
@@ -108,16 +108,16 @@ fn placeholder() -> Response {
const PLACEHOLDER_HTML: &str = r#"<!doctype html> const PLACEHOLDER_HTML: &str = r#"<!doctype html>
<meta charset="utf-8"> <meta charset="utf-8">
<title>maestro-web — bundle not built</title> <title>maestro — bundle not built</title>
<style> <style>
body { font-family: ui-sans-serif, system-ui, sans-serif; max-width: 40em; margin: 4em auto; padding: 0 1em; line-height: 1.5; } body { font-family: ui-sans-serif, system-ui, sans-serif; max-width: 40em; margin: 4em auto; padding: 0 1em; line-height: 1.5; }
code { background: #f3f3f3; padding: 0.1em 0.3em; border-radius: 3px; } code { background: #f3f3f3; padding: 0.1em 0.3em; border-radius: 3px; }
</style> </style>
<h1>maestro-web is running</h1> <h1>maestro is running</h1>
<p>but the frontend bundle hasn't been built yet. Run:</p> <p>but the frontend bundle hasn't been built yet. Run:</p>
<pre><code>pnpm -C web install <pre><code>pnpm -C web install
pnpm -C web build pnpm -C web build
cargo build -p maestro-web --release</code></pre> cargo build -p maestro --release</code></pre>
<p>or, from the repo root: <code>just web</code>.</p> <p>or, from the repo root: <code>just web</code>.</p>
"#; "#;
@@ -1,8 +1,10 @@
//! The compiled binary must embed the real SPA bundle, never the placeholder. //! The daemon binary must embed the real SPA bundle, never the placeholder.
//! `build.rs` guarantees `web/dist` is populated before `include_dir!` runs. //! `build.rs` guarantees `web/dist` is populated before `include_dir!` runs.
#![cfg(feature = "web")]
use axum::body::to_bytes; use axum::body::to_bytes;
use maestro_web::static_bundle::serve_index; use maestro_daemon::static_bundle::serve_index;
#[tokio::test] #[tokio::test]
async fn embedded_index_is_real_spa_not_placeholder() { async fn embedded_index_is_real_spa_not_placeholder() {
@@ -15,7 +17,7 @@ async fn embedded_index_is_real_spa_not_placeholder() {
"served index is missing the SPA mount point; got:\n{html}" "served index is missing the SPA mount point; got:\n{html}"
); );
assert!( assert!(
!html.contains("maestro-web is running"), !html.contains("maestro is running"),
"served index is the placeholder, not the built bundle" "served index is the placeholder, not the built bundle"
); );
// Stronger signal than `id="app"` (which appears in both source and built // Stronger signal than `id="app"` (which appears in both source and built
+106
View File
@@ -0,0 +1,106 @@
//! The daemon serves the browser SPA off its own listener, so the browser
//! talks to it directly instead of through a reverse proxy.
#![cfg(feature = "web")]
mod common;
use std::net::{Ipv4Addr, SocketAddr};
use common::DaemonFixture;
use maestro_daemon::local_api;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
struct Harness {
addr: SocketAddr,
_shutdown: oneshot::Sender<()>,
_fx: DaemonFixture,
}
async fn spawn() -> Harness {
let fx = DaemonFixture::new();
let router = local_api::router(local_api::state_for(
fx.manager.clone(),
"testhost",
"/home/test",
maestro_config::SpawnSection::default().resolve().unwrap(),
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, router)
.with_graceful_shutdown(async move {
let _ = rx.await;
})
.await;
});
Harness {
addr,
_shutdown: tx,
_fx: fx,
}
}
#[tokio::test]
async fn root_serves_the_spa_shell() {
let h = spawn().await;
let resp = reqwest::get(format!("http://{}/", h.addr)).await.unwrap();
assert_eq!(resp.status(), 200);
assert_eq!(
resp.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|v| v.starts_with("text/html")),
Some(true)
);
assert!(resp.text().await.unwrap().contains(r#"id="app""#));
}
#[tokio::test]
async fn client_side_route_falls_back_to_the_shell() {
let h = spawn().await;
let resp = reqwest::get(format!("http://{}/sessions/whatever", h.addr))
.await
.unwrap();
assert_eq!(resp.status(), 200);
assert!(resp.text().await.unwrap().contains(r#"id="app""#));
}
/// An unmatched API path must 404 rather than fall through to the SPA shell:
/// a client asking for JSON would otherwise get HTML and a parse error that
/// says nothing about the route being wrong.
#[tokio::test]
async fn unknown_api_route_404s_instead_of_serving_html() {
let h = spawn().await;
for path in ["/api", "/api/nope", "/api/sessions/not-a-uuid/bogus"] {
let resp = reqwest::get(format!("http://{}{path}", h.addr))
.await
.unwrap();
assert_eq!(resp.status(), 404, "{path}");
let ct = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_owned();
assert!(!ct.starts_with("text/html"), "{path} returned HTML: {ct}");
}
}
/// The API and the SPA share one listener; mounting the fallback must not
/// shadow a real route.
#[tokio::test]
async fn api_routes_still_answer() {
let h = spawn().await;
let resp = reqwest::get(format!("http://{}/api/info", h.addr))
.await
.unwrap();
assert_eq!(resp.status(), 200);
let info: maestro_protocol::DaemonInfo = resp.json().await.unwrap();
assert_eq!(info.hostname, "testhost");
}
+67 -4
View File
@@ -120,13 +120,75 @@ pub fn settings_path(home: &Path) -> std::path::PathBuf {
home.join(".claude/settings.json") home.join(".claude/settings.json")
} }
/// Whether `settings_path` already carries a maestro hook (#106). /// Whether `settings_path` already carries a maestro hook (#106). Presence
/// only: an install from an older release answers `true` even when it is
/// missing events this build ships — see [`is_current`].
pub fn is_installed(settings_path: &Path) -> bool { pub fn is_installed(settings_path: &Path) -> bool {
settings_json(settings_path) settings_json(settings_path)
.map(|root| find_maestro_commands(&root).next().is_some()) .map(|root| find_maestro_commands(&root).next().is_some())
.unwrap_or(false) .unwrap_or(false)
} }
/// Whether what is on disk is what this build ships: the script at
/// `script_path` byte-for-byte, and exactly one settings entry per event in
/// [`HOOK_EVENTS`] and [`PROMPT_EVENTS`], pointing at that script.
///
/// This is the upgrade question. A maestro that gains an event — #131 added
/// `UserPromptSubmit` long after #106 had installed itself — leaves every
/// existing user with a present but incomplete install, which
/// [`is_installed`] cannot tell apart from a current one.
pub fn is_current(settings_path: &Path, script_path: &Path) -> bool {
if std::fs::read_to_string(script_path).ok().as_deref() != Some(HOOK_SCRIPT) {
return false;
}
settings_json(settings_path)
.map(|root| maestro_entries(&root) == expected_entries(script_path))
.unwrap_or(false)
}
/// Every maestro entry in the file as `(event, command)`, sorted — so a
/// duplicate, a stale event or a wrong script path all show up as a
/// difference against [`expected_entries`].
fn maestro_entries(root: &serde_json::Value) -> Vec<(String, String)> {
let mut found: Vec<(String, String)> = root
.get("hooks")
.and_then(|h| h.as_object())
.into_iter()
.flatten()
.flat_map(|(event, groups)| {
groups
.as_array()
.into_iter()
.flatten()
.filter_map(|group| group.get("hooks"))
.filter_map(|inner| inner.as_array())
.flatten()
.filter_map(|hook| hook.get("command"))
.filter_map(|cmd| cmd.as_str())
.filter(|cmd| cmd.contains(HOOK_MARKER))
.map(move |cmd| (event.clone(), cmd.to_string()))
})
.collect();
found.sort();
found
}
/// What [`install`] writes for `script_path`, in the same shape.
fn expected_entries(script_path: &Path) -> Vec<(String, String)> {
let mut want: Vec<(String, String)> = HOOK_EVENTS
.iter()
.chain(PROMPT_EVENTS.iter())
.map(|(event, state)| {
(
(*event).to_string(),
format!("{} {state}", script_path.display()),
)
})
.collect();
want.sort();
want
}
/// Write [`HOOK_SCRIPT`] to `path`, executable. Parent directories are /// Write [`HOOK_SCRIPT`] to `path`, executable. Parent directories are
/// created as needed — this is always under the caller's chosen data dir, /// created as needed — this is always under the caller's chosen data dir,
/// never inside a repository. /// never inside a repository.
@@ -151,11 +213,12 @@ pub fn write_script(path: &Path) -> Result<(), HooksError> {
pub fn install_for_home(home: &Path) -> Result<bool, HooksError> { pub fn install_for_home(home: &Path) -> Result<bool, HooksError> {
let script = script_path(home); let script = script_path(home);
let settings = settings_path(home); let settings = settings_path(home);
let before = std::fs::read_to_string(&settings).unwrap_or_default(); // A script left over from an older release counts as a change even when
// the settings entries pointing at it are already right.
let changed = !is_current(&settings, &script);
write_script(&script)?; write_script(&script)?;
install(&settings, &script)?; install(&settings, &script)?;
let after = std::fs::read_to_string(&settings).unwrap_or_default(); Ok(changed)
Ok(before != after)
} }
/// Register the hook command for every event in [`HOOK_EVENTS`] in the /// Register the hook command for every event in [`HOOK_EVENTS`] in the
+56
View File
@@ -428,3 +428,59 @@ fn the_script_posts_a_prompt_payload_unchanged() {
"{argv}" "{argv}"
); );
} }
/// `is_current` answers the upgrade question `is_installed` cannot: an
/// install left over from an older maestro is present, but not what this
/// build ships (#131 — the prompt event arrived after #106 was installed).
#[test]
fn is_current_rejects_an_install_missing_an_event() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
install_for_home(home).unwrap();
assert!(is_current(&settings_path(home), &script_path(home)));
// Drop one event, as an older install would have left the file.
let mut root: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(settings_path(home)).unwrap()).unwrap();
root["hooks"].as_object_mut().unwrap().remove("Stop");
std::fs::write(
settings_path(home),
serde_json::to_string_pretty(&root).unwrap(),
)
.unwrap();
assert!(is_installed(&settings_path(home)));
assert!(!is_current(&settings_path(home), &script_path(home)));
}
/// A settings file that is complete but points at a script from an older
/// release is not current either — the entries are right, the script is not.
#[test]
fn is_current_rejects_a_stale_script() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
install_for_home(home).unwrap();
std::fs::write(script_path(home), "#!/bin/sh\nexit 0\n").unwrap();
assert!(!is_current(&settings_path(home), &script_path(home)));
}
/// Nothing installed at all is not current.
#[test]
fn is_current_rejects_a_missing_install() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
assert!(!is_current(&settings_path(home), &script_path(home)));
}
/// `install_for_home` reports a stale script as a change, even when the
/// settings entries pointing at it already read correctly.
#[test]
fn install_for_home_reports_a_stale_script_as_a_change() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
assert!(install_for_home(home).unwrap());
assert!(!install_for_home(home).unwrap());
std::fs::write(script_path(home), "#!/bin/sh\nexit 0\n").unwrap();
assert!(install_for_home(home).unwrap());
}
+16
View File
@@ -19,7 +19,9 @@ pub enum CargoSource {
pub enum SystemctlAction { pub enum SystemctlAction {
DaemonReload, DaemonReload,
Enable, Enable,
Disable,
Start, Start,
Stop,
Restart, Restart,
} }
@@ -28,7 +30,9 @@ impl SystemctlAction {
match self { match self {
Self::DaemonReload => "daemon-reload", Self::DaemonReload => "daemon-reload",
Self::Enable => "enable", Self::Enable => "enable",
Self::Disable => "disable",
Self::Start => "start", Self::Start => "start",
Self::Stop => "stop",
Self::Restart => "restart", Self::Restart => "restart",
} }
} }
@@ -191,6 +195,18 @@ mod tests {
assert_eq!(args, vec!["--user", "start", "maestro.service"]); assert_eq!(args, vec!["--user", "start", "maestro.service"]);
} }
#[test]
fn systemctl_stop_and_disable_include_unit() {
assert_eq!(
systemctl_user_args(SystemctlAction::Stop, "maestro-web.service"),
vec!["--user", "stop", "maestro-web.service"]
);
assert_eq!(
systemctl_user_args(SystemctlAction::Disable, "maestro-web.service"),
vec!["--user", "disable", "maestro-web.service"]
);
}
#[test] #[test]
fn systemctl_restart_includes_unit() { fn systemctl_restart_includes_unit() {
let args = systemctl_user_args(SystemctlAction::Restart, "maestro.service"); let args = systemctl_user_args(SystemctlAction::Restart, "maestro.service");
+2 -2
View File
@@ -16,6 +16,6 @@ pub mod systemd;
pub use install::{cargo_install_args, systemctl_user_args, CargoSource, SystemctlAction}; pub use install::{cargo_install_args, systemctl_user_args, CargoSource, SystemctlAction};
pub use systemd::{ pub use systemd::{
is_systemd_host, render_user_unit, render_web_unit, user_unit_path, user_unit_path_for, is_systemd_host, legacy_web_unit_path, render_user_unit, user_unit_path, user_unit_path_for,
user_web_unit_path, UNIT_FILE_NAME, WEB_UNIT_FILE_NAME, LEGACY_WEB_UNIT_FILE_NAME, UNIT_FILE_NAME,
}; };
+74 -198
View File
@@ -15,17 +15,17 @@ use std::process::{Command, ExitCode};
use clap::Parser; use clap::Parser;
use maestro_installer::install::cargo_bin; use maestro_installer::install::cargo_bin;
use maestro_installer::{ use maestro_installer::{
cargo_install_args, is_systemd_host, render_user_unit, render_web_unit, systemctl_user_args, cargo_install_args, is_systemd_host, legacy_web_unit_path, render_user_unit,
user_unit_path, user_web_unit_path, CargoSource, SystemctlAction, UNIT_FILE_NAME, systemctl_user_args, user_unit_path, CargoSource, SystemctlAction, LEGACY_WEB_UNIT_FILE_NAME,
WEB_UNIT_FILE_NAME, UNIT_FILE_NAME,
}; };
const DEFAULT_GIT_URL: &str = "https://git.naps.pt/naps62/maestro"; const DEFAULT_GIT_URL: &str = "https://git.naps.pt/naps62/maestro";
/// Unified `maestro` crate, built with the full host feature set. /// Unified `maestro` crate, built with the full host feature set.
const MAESTRO_CRATE: &str = "maestro"; const MAESTRO_CRATE: &str = "maestro";
/// Features installed for a host: CLI + per-host daemon + TUI. /// Features installed for a host: CLI + per-host daemon + TUI + the browser
const MAESTRO_HOST_FEATURES: &str = "cli,daemon,tui"; /// SPA the daemon serves.
const WEB_CRATE: &str = "maestro-web"; const MAESTRO_HOST_FEATURES: &str = "cli,daemon,tui,web";
const SYSTEMD_PROBE: &str = "/run/systemd/system"; const SYSTEMD_PROBE: &str = "/run/systemd/system";
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
@@ -64,16 +64,6 @@ struct Args {
/// Default: `~/.cargo/bin/maestro` (invoked as `maestro daemon`). /// Default: `~/.cargo/bin/maestro` (invoked as `maestro daemon`).
#[arg(long, value_name = "PATH")] #[arg(long, value_name = "PATH")]
exec_start: Option<PathBuf>, exec_start: Option<PathBuf>,
/// Skip the optional `maestro-web` (browser UI) install step.
/// Useful for headless hosts.
#[arg(long)]
no_web: bool,
/// Override the ExecStart path for the `maestro-web` systemd unit.
/// Default: `~/.cargo/bin/maestro-web`.
#[arg(long, value_name = "PATH")]
web_exec_start: Option<PathBuf>,
} }
fn main() -> ExitCode { fn main() -> ExitCode {
@@ -99,6 +89,7 @@ fn run(args: Args) -> Result<(), Box<dyn std::error::Error>> {
} }
// 1. Install the unified binary (NAP-245). // 1. Install the unified binary (NAP-245).
warn_if_web_build_prereqs_missing();
let locked = !args.no_locked; let locked = !args.no_locked;
install_crate(&source, MAESTRO_CRATE, locked, Some(MAESTRO_HOST_FEATURES))?; install_crate(&source, MAESTRO_CRATE, locked, Some(MAESTRO_HOST_FEATURES))?;
@@ -112,11 +103,10 @@ fn run(args: Args) -> Result<(), Box<dyn std::error::Error>> {
handle_systemd(&args, interactive)?; handle_systemd(&args, interactive)?;
} }
// 3. Optional: maestro-web (NAP-249). Re-runs auto-detect an existing // 3. Retire the standalone browser-UI service from older installs.
// install and refresh rather than re-prompting, matching the daemon flow. cleanup_legacy_web_unit()?;
let web_installed = handle_web(&args, interactive, systemd_detected)?;
print_summary(systemd_detected && !args.no_systemd, web_installed); print_summary(systemd_detected && !args.no_systemd);
Ok(()) Ok(())
} }
@@ -135,9 +125,8 @@ fn print_banner(source: &CargoSource) {
eprintln!("maestro installer"); eprintln!("maestro installer");
eprintln!(); eprintln!();
eprintln!("This wizard will:"); eprintln!("This wizard will:");
eprintln!(" 1. cargo install `{MAESTRO_CRATE}` (CLI + daemon + TUI)"); eprintln!(" 1. cargo install `{MAESTRO_CRATE}` (CLI + daemon + TUI + browser UI)");
eprintln!(" 2. (optional) install a systemd user unit and enable it"); eprintln!(" 2. (optional) install a systemd user unit and enable it");
eprintln!(" 3. (optional) install `{WEB_CRATE}` + its systemd unit (browser UI)");
eprintln!(); eprintln!();
match source { match source {
CargoSource::Git { url, branch } => { CargoSource::Git { url, branch } => {
@@ -247,33 +236,7 @@ fn default_exec_start() -> Result<PathBuf, Box<dyn std::error::Error>> {
.ok_or_else(|| "could not resolve $HOME (needed for ExecStart)".into()) .ok_or_else(|| "could not resolve $HOME (needed for ExecStart)".into())
} }
fn default_web_exec_start() -> Result<PathBuf, Box<dyn std::error::Error>> { /// True iff both `pnpm` and `node` respond to `--version` on PATH. The daemon's
maestro_installer::systemd::default_maestro_web_path()
.ok_or_else(|| "could not resolve $HOME (needed for web ExecStart)".into())
}
/// What to do at the (opt-in) web step given whether pnpm + node are present.
#[derive(Debug, PartialEq, Eq)]
enum WebPrereq {
/// Toolchain present — install maestro-web normally.
Ok,
/// Missing and we can ask: prompt the operator to abort or skip.
PromptMissing,
/// Missing and we can't prompt (non-interactive refresh): warn + skip.
SkipMissing,
}
fn classify_web_prereq(toolchain_present: bool, interactive: bool) -> WebPrereq {
if toolchain_present {
WebPrereq::Ok
} else if interactive {
WebPrereq::PromptMissing
} else {
WebPrereq::SkipMissing
}
}
/// True iff both `pnpm` and `node` respond to `--version` on PATH. maestro-web's
/// build.rs needs them to build the embedded SPA bundle. /// build.rs needs them to build the embedded SPA bundle.
fn frontend_toolchain_present() -> bool { fn frontend_toolchain_present() -> bool {
["pnpm", "node"].iter().all(|tool| { ["pnpm", "node"].iter().all(|tool| {
@@ -285,142 +248,54 @@ fn frontend_toolchain_present() -> bool {
}) })
} }
/// Optional step: install `maestro-web` + its systemd user unit (NAP-249). /// Whether to warn before `cargo install`. A prebuilt bundle pointed at by
/// Returns `true` iff the binary was installed (with or without systemd). /// `$MAESTRO_WEB_DIST` makes pnpm + node unnecessary.
fn handle_web( fn should_warn_web_prereqs(toolchain_present: bool, web_dist_set: bool) -> bool {
args: &Args, !toolchain_present && !web_dist_set
interactive: bool, }
systemd_detected: bool,
) -> Result<bool, Box<dyn std::error::Error>> {
if args.no_web {
eprintln!("\nSkipping web UI step (--no-web).");
return Ok(false);
}
let web_unit_path = user_web_unit_path();
let unit_present = web_unit_path.as_ref().map(|p| p.exists()).unwrap_or(false);
// On re-runs the unit is already there — refresh silently. On first run /// The `web` feature is on by default and its build.rs hard-fails without a
// ask interactively (default yes), or skip on --yes / non-TTY unless the /// bundle, so say so before cargo spends minutes getting there.
// unit is present (refresh path). fn warn_if_web_build_prereqs_missing() {
let want_web = if unit_present { let web_dist_set = std::env::var_os("MAESTRO_WEB_DIST").is_some();
eprintln!( if !should_warn_web_prereqs(frontend_toolchain_present(), web_dist_set) {
"\nDetected existing maestro-web unit at {} — refreshing.", return;
web_unit_path.as_ref().unwrap().display() }
); eprintln!(
true "\nWarning: pnpm + node are not on PATH. The daemon builds the browser \
} else if !interactive { UI from source, so the build will fail.\n \
// Non-interactive (--yes or piped) with no existing unit: skip by Install pnpm + node (https://pnpm.io), point MAESTRO_WEB_DIST at a \
// default. Operators opt in by re-running once interactively. prebuilt bundle, or drop `web` from the feature list to build without \
eprintln!( the browser UI."
"\nSkipping maestro-web install (non-interactive). \ );
Re-run without --yes to set it up, or pass --no-web to silence." }
);
return Ok(false); /// Remove the standalone `maestro-web.service` older installs left behind. The
} else { /// daemon serves the browser UI itself now, so the unit can only fail on start.
eprintln!( /// Best-effort: a failing systemctl call must not abort the install.
"\nOptional: install the browser UI (`maestro-web`)?\n \ fn cleanup_legacy_web_unit() -> Result<(), Box<dyn std::error::Error>> {
Reverse-proxies the local maestro daemon and serves the SPA at \ let Some(unit_path) = legacy_web_unit_path() else {
http://127.0.0.1:8080/ by default.\n \ return Ok(());
Edit [web].bind_addr in ~/.config/maestro/config.toml \
to expose it on your LAN/VPN address."
);
confirm("Install maestro-web?", true)?
}; };
if !want_web { if !unit_path.exists() {
return Ok(false); return Ok(());
} }
// maestro-web's build.rs requires pnpm + node. Check before cargo install so eprintln!(
// a missing toolchain surfaces a clear choice instead of a raw build panic. "\nRemoving the separate maestro-web service — the daemon now serves \
match classify_web_prereq(frontend_toolchain_present(), interactive) { the browser UI itself."
WebPrereq::Ok => {} );
WebPrereq::PromptMissing => { for action in [SystemctlAction::Stop, SystemctlAction::Disable] {
eprintln!("\nmaestro-web needs pnpm + node, which aren't installed."); if let Err(e) = run_systemctl(action, LEGACY_WEB_UNIT_FILE_NAME) {
if confirm("Abort the whole install? (No = skip web UI)", false)? { eprintln!("(ignored: {e})");
return Err("aborted: maestro-web prerequisites (pnpm + node) are missing".into());
}
eprintln!("Skipping maestro-web install.");
return Ok(false);
}
WebPrereq::SkipMissing => {
eprintln!(
"\nSkipping maestro-web refresh: pnpm + node not installed. \
Existing unit left in place."
);
return Ok(false);
} }
} }
std::fs::remove_file(&unit_path)?;
// Install the binary. Even if cargo-install fails we keep going so the eprintln!("removed {}", unit_path.display());
// operator can fix the cause and re-run; the existing daemon work if let Err(e) = run_systemctl(SystemctlAction::DaemonReload, LEGACY_WEB_UNIT_FILE_NAME) {
// doesn't need to be undone. eprintln!("(ignored: {e})");
let source = build_source(args);
let locked = !args.no_locked;
if let Err(e) = install_crate(&source, WEB_CRATE, locked, None) {
eprintln!("maestro-web install failed: {e}");
eprintln!("(skipping systemd unit; re-run after fixing the build.)");
return Ok(false);
} }
Ok(())
if args.no_systemd || !systemd_detected {
eprintln!(
"\nmaestro-web installed. Start it manually with `maestro-web` \
(no systemd unit written)."
);
return Ok(true);
}
let unit_path = web_unit_path.expect("user_web_unit_path resolves under config_dir");
let exec_start = match &args.web_exec_start {
Some(p) => p.clone(),
None => default_web_exec_start()?,
};
if let Some(parent) = unit_path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = render_web_unit(&exec_start);
std::fs::write(&unit_path, body)?;
eprintln!("wrote {}", unit_path.display());
run_systemctl(SystemctlAction::DaemonReload, WEB_UNIT_FILE_NAME)?;
if !unit_present {
let want_enable = if !interactive {
true
} else {
confirm("Enable maestro-web.service (start on login)?", true)?
};
if want_enable {
run_systemctl(SystemctlAction::Enable, WEB_UNIT_FILE_NAME)?;
}
}
// Update path → restart; first-install → start (always safe; web only
// needs the daemon's HTTP surface, which exists from NAP-243).
let (action, want) = if unit_present {
let want = if !interactive {
true
} else {
confirm(
"Restart maestro-web.service to pick up the new binary?",
true,
)?
};
(SystemctlAction::Restart, want)
} else {
let want = if !interactive {
true
} else {
confirm("Start maestro-web.service now?", true)?
};
(SystemctlAction::Start, want)
};
if want {
run_systemctl(action, WEB_UNIT_FILE_NAME)?;
}
Ok(true)
} }
fn run_systemctl(action: SystemctlAction, unit: &str) -> Result<(), Box<dyn std::error::Error>> { fn run_systemctl(action: SystemctlAction, unit: &str) -> Result<(), Box<dyn std::error::Error>> {
@@ -447,7 +322,7 @@ fn confirm(prompt: &str, default_yes: bool) -> io::Result<bool> {
}) })
} }
fn print_summary(systemd_enabled: bool, web_installed: bool) { fn print_summary(systemd_enabled: bool) {
eprintln!(); eprintln!();
eprintln!("Done."); eprintln!("Done.");
eprintln!(); eprintln!();
@@ -459,18 +334,12 @@ fn print_summary(systemd_enabled: bool, web_installed: bool) {
eprintln!(" • Start the daemon: `maestro daemon`"); eprintln!(" • Start the daemon: `maestro daemon`");
} }
eprintln!(" • Open the TUI: `maestro tui`"); eprintln!(" • Open the TUI: `maestro tui`");
if web_installed { eprintln!(
if systemd_enabled { " • Browser UI: served by the daemon at its own bind address \
eprintln!( (default http://127.0.0.1:7070/).\n \
" • Browser UI: open http://127.0.0.1:8080/ \ Set [daemon].bind_addr in ~/.config/maestro/config.toml to expose it \
(or your [web].bind_addr from config.toml)" on your LAN/VPN address."
); );
} else {
eprintln!(
" • Browser UI: start with `maestro-web` and open http://127.0.0.1:8080/"
);
}
}
} }
#[cfg(test)] #[cfg(test)]
@@ -478,18 +347,25 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn prereq_ok_when_toolchain_present() { fn no_warning_when_toolchain_present() {
assert_eq!(classify_web_prereq(true, true), WebPrereq::Ok); assert!(!should_warn_web_prereqs(true, false));
assert_eq!(classify_web_prereq(true, false), WebPrereq::Ok); assert!(!should_warn_web_prereqs(true, true));
} }
#[test] #[test]
fn prereq_prompts_when_missing_and_interactive() { fn no_warning_when_prebuilt_bundle_supplied() {
assert_eq!(classify_web_prereq(false, true), WebPrereq::PromptMissing); assert!(!should_warn_web_prereqs(false, true));
} }
#[test] #[test]
fn prereq_skips_when_missing_and_noninteractive() { fn warns_when_toolchain_missing_and_no_bundle() {
assert_eq!(classify_web_prereq(false, false), WebPrereq::SkipMissing); assert!(should_warn_web_prereqs(false, false));
}
#[test]
fn legacy_web_unit_is_the_only_web_service_touched() {
assert_eq!(LEGACY_WEB_UNIT_FILE_NAME, "maestro-web.service");
let p = legacy_web_unit_path().expect("config dir resolvable in test env");
assert!(p.ends_with("systemd/user/maestro-web.service"), "got {p:?}");
} }
} }
+9 -72
View File
@@ -12,8 +12,9 @@ pub const SYSTEMD_PROBE_PATH: &str = "/run/systemd/system";
/// renamed this from `maestrod.service`). /// renamed this from `maestrod.service`).
pub const UNIT_FILE_NAME: &str = "maestro.service"; pub const UNIT_FILE_NAME: &str = "maestro.service";
/// Filename for the optional `maestro-web` browser UI unit (NAP-249). /// Filename of the retired browser-UI unit. The daemon serves the SPA itself
pub const WEB_UNIT_FILE_NAME: &str = "maestro-web.service"; /// now, so the installer only ever removes this one — never writes it.
pub const LEGACY_WEB_UNIT_FILE_NAME: &str = "maestro-web.service";
/// `true` when the given path exists. Production callers should pass /// `true` when the given path exists. Production callers should pass
/// [`SYSTEMD_PROBE_PATH`]; tests inject a temp path so they're hermetic. /// [`SYSTEMD_PROBE_PATH`]; tests inject a temp path so they're hermetic.
@@ -34,13 +35,10 @@ pub fn user_unit_path() -> Option<PathBuf> {
user_unit_path_for(UNIT_FILE_NAME) user_unit_path_for(UNIT_FILE_NAME)
} }
/// Convenience: path for `maestro-web.service` (NAP-249). /// Convenience: path for the retired `maestro-web.service`, used only by the
pub fn user_web_unit_path() -> Option<PathBuf> { /// installer's cleanup step.
user_web_unit_path_for(WEB_UNIT_FILE_NAME) pub fn legacy_web_unit_path() -> Option<PathBuf> {
} user_unit_path_for(LEGACY_WEB_UNIT_FILE_NAME)
fn user_web_unit_path_for(unit_name: &str) -> Option<PathBuf> {
user_unit_path_for(unit_name)
} }
/// Render the daemon unit. `exec_start` is the absolute path to `maestro` — /// Render the daemon unit. `exec_start` is the absolute path to `maestro` —
@@ -71,46 +69,12 @@ pub fn render_user_unit(exec_start: &Path) -> String {
) )
} }
/// Render the `maestro-web` user unit (NAP-249). Orders after `maestro.service`
/// so the proxy doesn't return 502s on startup; `Wants=` keeps it best-effort
/// — operators with daemon-less hosts can still install web alone if they
/// later remove the dependency.
pub fn render_web_unit(exec_start: &Path) -> String {
format!(
"[Unit]\n\
Description=Maestro web UI (maestro-web)\n\
Documentation=https://git.naps.pt/naps62/maestro\n\
After=network-online.target maestro.service\n\
Wants=maestro.service\n\
\n\
[Service]\n\
Type=simple\n\
ExecStart={exec}\n\
Restart=on-failure\n\
RestartSec=5\n\
\n\
[Install]\n\
WantedBy=default.target\n",
exec = exec_start.display()
)
}
/// Default `ExecStart` target: `~/.cargo/bin/maestro`. `None` if `$HOME` is /// Default `ExecStart` target: `~/.cargo/bin/maestro`. `None` if `$HOME` is
/// unresolvable. /// unresolvable.
pub fn default_maestro_path() -> Option<PathBuf> { pub fn default_maestro_path() -> Option<PathBuf> {
Some(dirs::home_dir()?.join(".cargo").join("bin").join("maestro")) Some(dirs::home_dir()?.join(".cargo").join("bin").join("maestro"))
} }
/// Default `ExecStart` target for the web unit: `~/.cargo/bin/maestro-web`.
pub fn default_maestro_web_path() -> Option<PathBuf> {
Some(
dirs::home_dir()?
.join(".cargo")
.join("bin")
.join("maestro-web"),
)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -152,17 +116,6 @@ mod tests {
); );
} }
#[test]
fn web_unit_does_not_set_killmode() {
// The web unit spawns no tmux, so it has no cgroup-kill problem and
// shouldn't carry the override.
let body = render_web_unit(Path::new("/x/maestro-web"));
assert!(
!body.contains("KillMode"),
"web unit needs no KillMode; got:\n{body}"
);
}
#[test] #[test]
fn unit_has_no_execreload() { fn unit_has_no_execreload() {
// NAP-339 removed the daemon's SIGHUP handler (it only ever reloaded // NAP-339 removed the daemon's SIGHUP handler (it only ever reloaded
@@ -186,30 +139,14 @@ mod tests {
} }
#[test] #[test]
fn user_web_unit_path_points_under_config_dir() { fn legacy_web_unit_path_points_under_config_dir() {
let p = user_web_unit_path().expect("config dir resolvable in test env"); let p = legacy_web_unit_path().expect("config dir resolvable in test env");
assert!(p.ends_with("systemd/user/maestro-web.service"), "got {p:?}"); assert!(p.ends_with("systemd/user/maestro-web.service"), "got {p:?}");
} }
#[test]
fn web_unit_renders_with_dependency_on_daemon() {
let body = render_web_unit(Path::new("/home/alice/.cargo/bin/maestro-web"));
assert!(body.contains("ExecStart=/home/alice/.cargo/bin/maestro-web"));
assert!(body.contains("After=network-online.target maestro.service"));
assert!(body.contains("Wants=maestro.service"));
assert!(body.contains("WantedBy=default.target"));
assert!(body.ends_with('\n'));
}
#[test] #[test]
fn default_maestro_path_under_cargo_bin() { fn default_maestro_path_under_cargo_bin() {
let p = default_maestro_path().expect("HOME resolvable in test env"); let p = default_maestro_path().expect("HOME resolvable in test env");
assert!(p.ends_with(".cargo/bin/maestro"), "got {p:?}"); assert!(p.ends_with(".cargo/bin/maestro"), "got {p:?}");
} }
#[test]
fn default_maestro_web_path_under_cargo_bin() {
let p = default_maestro_web_path().expect("HOME resolvable in test env");
assert!(p.ends_with(".cargo/bin/maestro-web"), "got {p:?}");
}
} }
+3 -3
View File
@@ -9,9 +9,9 @@ description = "Shared wire types for Maestro daemon/client communication"
[features] [features]
default = [] default = []
# Opt-in `reqwest`-backed client. Off by default so `maestro-web`, which only # Opt-in `reqwest`-backed client. Off by default so `maestro-daemon`, which
# needs the wire types and route paths, does not pull an HTTP stack it has its # only needs the wire types and route paths, does not pull an HTTP stack it has
# own copy of. # no use for.
client = ["dep:reqwest"] client = ["dep:reqwest"]
[dependencies] [dependencies]
+1 -1
View File
@@ -1,7 +1,7 @@
//! Typed client for the daemon's `/api/*` surface. //! Typed client for the daemon's `/api/*` surface.
//! //!
//! Behind the `client` feature so consumers that only need the wire types and //! Behind the `client` feature so consumers that only need the wire types and
//! route paths — `maestro-web`, `maestro-config` — don't pull an HTTP stack. //! route paths — `maestro-daemon`, `maestro-config` — don't pull an HTTP stack.
//! //!
//! This is the one place that knows how to call the daemon: the CLI, the TUI and //! This is the one place that knows how to call the daemon: the CLI, the TUI and
//! the end-to-end harness all go through it. //! the end-to-end harness all go through it.
+2 -2
View File
@@ -447,8 +447,8 @@ pub struct ErrorInfo {
/// The envelope every `/api/*` failure is wrapped in: `{"error": {…}}`. /// The envelope every `/api/*` failure is wrapped in: `{"error": {…}}`.
/// ///
/// The daemon builds it, the CLI and TUI decode it to surface the daemon's own /// The daemon builds it, the CLI and TUI decode it to surface the daemon's own
/// message, and `maestro-web` uses it both to report proxy failures and to /// message, and the browser SPA uses it to recover the rejection reason from a
/// recover the rejection reason from an upstream WebSocket handshake. Before it /// failed request or WebSocket handshake. Before it
/// lived here that shape was hand-rolled in four places, one of them by /// lived here that shape was hand-rolled in four places, one of them by
/// escaping quotes into a format string. /// escaping quotes into a format string.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+2 -2
View File
@@ -5,8 +5,8 @@
use uuid::Uuid; use uuid::Uuid;
/// Prefix every daemon route shares. `maestro-web` uses it to decide whether a /// Prefix every daemon route shares. The daemon uses it to decide whether a
/// request is proxied to the daemon or served from the SPA bundle. /// request hits the API or is served from the SPA bundle.
pub const API_PREFIX: &str = "/api"; pub const API_PREFIX: &str = "/api";
/// `GET` list / `POST` spawn. /// `GET` list / `POST` spawn.
+3 -1
View File
@@ -2,7 +2,9 @@
#[derive(Debug, clap::Args)] #[derive(Debug, clap::Args)]
pub struct TuiArgs { pub struct TuiArgs {
/// Override the local daemon's HTTP base URL (default `http://127.0.0.1:7070`). /// Override the local daemon's HTTP base URL. Defaults to
/// `[cli].daemon_url`, then the local `[daemon].bind_addr`, then
/// `http://127.0.0.1:7070`.
#[arg(long, env = "MAESTRO_DAEMON_URL")] #[arg(long, env = "MAESTRO_DAEMON_URL")]
pub daemon_url: Option<String>, pub daemon_url: Option<String>,
} }
+24 -16
View File
@@ -40,9 +40,11 @@ use crate::daemon_link::DaemonLink;
/// `TuiArgs` plus any global flags. /// `TuiArgs` plus any global flags.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RunConfig { pub struct RunConfig {
/// HTTP base URL of the local daemon, e.g. `http://127.0.0.1:7070`. /// Explicit `--daemon-url` / `$MAESTRO_DAEMON_URL` override. When `None`
/// No trailing slash. /// the URL is resolved from the config file the same way the CLI resolves
pub daemon_url: String, /// it: `[cli].daemon_url`, then the local `[daemon].bind_addr`, then
/// `maestro_config::DEFAULT_DAEMON_URL`.
pub daemon_url: Option<String>,
/// Explicit `--config <path>` override from the umbrella binary's global /// Explicit `--config <path>` override from the umbrella binary's global
/// flag. When `None`, [`maestro_config::default_config_path`] picks the /// flag. When `None`, [`maestro_config::default_config_path`] picks the
/// default (`$MAESTRO_CONFIG` or `~/.config/maestro/config.toml`). /// default (`$MAESTRO_CONFIG` or `~/.config/maestro/config.toml`).
@@ -66,7 +68,25 @@ pub enum TuiError {
pub async fn run(cfg: RunConfig) -> Result<(), TuiError> { pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
terminal::install_panic_hook(); terminal::install_panic_hook();
let base_url = cfg.daemon_url.trim_end_matches('/').to_string(); // Load runtime config first: it carries the daemon URL as well as the UI
// settings. Explicit `--config <path>` wins, then `$MAESTRO_CONFIG`, then
// the platform default. Missing file is fine — we fall back to shipped
// defaults.
let cfg_path = cfg.config_path.clone().map(Ok).unwrap_or_else(|| {
maestro_config::default_config_path().map_err(|e| TuiError::Config(e.to_string()))
});
let cfg_file = match cfg_path {
Ok(path) => maestro_config::MaestroConfigFile::load(&path)
.map_err(|e| TuiError::Config(e.to_string()))?,
Err(_) => maestro_config::MaestroConfigFile::default(),
};
// Same resolution order the CLI uses, so a host that moved `bind_addr` off
// the default port stays reachable from the TUI without a second setting.
let base_url = cfg_file
.resolve_daemon_url(cfg.daemon_url.clone())
.trim_end_matches('/')
.to_string();
// Health check — fail fast if no daemon is reachable. // Health check — fail fast if no daemon is reachable.
health_check(&DaemonClient::with_timeouts( health_check(&DaemonClient::with_timeouts(
@@ -77,18 +97,6 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
.await?; .await?;
let daemon = DaemonClient::with_timeouts(&base_url, REQUEST_TIMEOUT, REQUEST_TIMEOUT); let daemon = DaemonClient::with_timeouts(&base_url, REQUEST_TIMEOUT, REQUEST_TIMEOUT);
// Load runtime config: explicit `--config <path>` wins, then
// `$MAESTRO_CONFIG`, then the platform default. Missing file is fine —
// we fall back to shipped defaults.
let cfg_path = cfg.config_path.clone().map(Ok).unwrap_or_else(|| {
maestro_config::default_config_path().map_err(|e| TuiError::Config(e.to_string()))
});
let cfg_file = match cfg_path {
Ok(path) => maestro_config::MaestroConfigFile::load(&path)
.map_err(|e| TuiError::Config(e.to_string()))?,
Err(_) => maestro_config::MaestroConfigFile::default(),
};
let tui_cfg = TuiConfig::load(&cfg_file.tui).map_err(|e| TuiError::Config(e.to_string()))?; let tui_cfg = TuiConfig::load(&cfg_file.tui).map_err(|e| TuiError::Config(e.to_string()))?;
palette::set_active(tui_cfg.palette); palette::set_active(tui_cfg.palette);
-40
View File
@@ -1,40 +0,0 @@
[package]
name = "maestro-web"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
description = "Standalone Maestro web UI: serves the SPA bundle and reverse-proxies to a co-located maestrod"
[lib]
path = "src/lib.rs"
[[bin]]
name = "maestro-web"
path = "src/main.rs"
[dependencies]
axum.workspace = true
bytes.workspace = true
clap.workspace = true
dirs.workspace = true
futures-util.workspace = true
include_dir.workspace = true
maestro-config.workspace = true
maestro-protocol.workspace = true
mime_guess.workspace = true
reqwest.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] }
tracing.workspace = true
tracing-subscriber.workspace = true
[dev-dependencies]
maestro-testing.workspace = true
uuid.workspace = true
reqwest.workspace = true
serde_json.workspace = true
tempfile.workspace = true
-82
View File
@@ -1,82 +0,0 @@
# maestro-web
Standalone HTTP server that hosts the Maestro browser UI and reverse-proxies
to a co-located `maestrod`.
```
browser ──HTTP──▶ maestro-web (:8080) ──HTTP/WS──▶ maestrod (:7070)
│
└── / serves the embedded SPA (web/dist)
/api/... proxied unmodified
/api/attach/:id, /api/events proxied as WebSocket
```
## Trust model
`maestro-web` does not authenticate anyone. Whoever can reach the bind address
can use it. Pick the bind accordingly:
| Bind | Reach | Use when |
| --------------------- | ---------------------------------- | --------------------------- |
| `127.0.0.1:8080` | Local users on this host | Default. Same as tmux. |
| `<wg-ip>:8080` | Devices on your WireGuard mesh | Phone/tablet over VPN. |
| `<lan-ip>:8080` | Devices on your trusted LAN | Home / lab networks. |
| Public IP | Don't | — add real auth first. |
## Config
The binary reads `~/.config/maestro/config.toml`. The `[web]` section:
```toml
[web]
bind_addr = "127.0.0.1:8080" # default
daemon_url = "http://127.0.0.1:7070" # default
```
Env-var overrides (all optional):
- `MAESTRO_WEB_BIND_ADDR`
- `MAESTRO_WEB_DAEMON_URL`
- `MAESTRO_WEB_DEV_PROXY_TARGET`
- `MAESTRO_CONFIG` — override the unified config-file path.
## Build
The SPA bundle lives in `../../web` and is embedded into the binary at compile
time via `include_dir!`. Build the frontend first:
```sh
pnpm -C web install
pnpm -C web build
cargo build -p maestro-web --release
```
Or, from the repo root:
```sh
just web
```
A fresh checkout compiles even without `pnpm build` (an empty `web/dist`
exists in-tree); the binary just serves a placeholder until the bundle is
built.
## Run
```sh
target/release/maestro-web
```
Then point a browser at `http://127.0.0.1:8080/`.
## Development workflow for the frontend
Use Vite directly so you get HMR. From `web/`:
```sh
pnpm dev
```
Vite serves on `:5173` and proxies `/api/*` straight to `maestrod` at
`127.0.0.1:7070` (override with `MAESTRO_DAEMON_URL=...`). You don't need to
rebuild the Rust binary while iterating on the frontend.
-116
View File
@@ -1,116 +0,0 @@
//! Resolve the SPA bundle that gets embedded at compile time.
//!
//! `static_bundle.rs` embeds a directory via `include_dir!`, and the directory
//! it reads is whatever this script puts in `MAESTRO_WEB_BUNDLE_DIR`.
//!
//! Two modes:
//!
//! * `MAESTRO_WEB_DIST` set — the bundle was built elsewhere (Nix, CI, a
//! release pipeline) and that directory is embedded as-is. No node, no pnpm.
//! * unset — the default developer path: run `pnpm install --frozen-lockfile
//! && pnpm build` in `web/` and embed `web/dist`. pnpm and node are HARD
//! requirements here; a missing toolchain fails with an actionable message
//! rather than silently shipping the placeholder.
use std::env;
use std::path::PathBuf;
use std::process::Command;
fn main() {
println!("cargo:rerun-if-env-changed=MAESTRO_WEB_DIST");
let dist_dir = match env::var_os("MAESTRO_WEB_DIST") {
Some(prebuilt) => PathBuf::from(prebuilt),
None => build_with_pnpm(),
};
// Sanity-check that the directory really holds a bundle. A green build
// that embeds an empty/placeholder dir is worse than a loud failure here.
let index_html = dist_dir.join("index.html");
let produced = std::fs::metadata(&index_html)
.map(|m| m.is_file() && m.len() > 0)
.unwrap_or(false);
if !produced {
panic!(
"no SPA bundle at {} (missing or empty index.html) — \
check the vite `outDir`, or the MAESTRO_WEB_DIST override.",
index_html.display()
);
}
// `include_dir!` expands `$VAR` in its path argument against the env rustc
// is invoked with, which is what `cargo:rustc-env` sets.
println!(
"cargo:rustc-env=MAESTRO_WEB_BUNDLE_DIR={}",
dist_dir.display()
);
}
/// The default path: build `web/` in-tree with pnpm and return `web/dist`.
fn build_with_pnpm() -> PathBuf {
let manifest_dir =
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR set by cargo"));
let web_dir = manifest_dir.join("../../web");
// Rebuild the bundle only when a frontend input changes — not on every
// unrelated Rust recompile.
for rel in [
"src",
"index.html",
"package.json",
"pnpm-lock.yaml",
"vite.config.ts",
] {
println!("cargo:rerun-if-changed={}", web_dir.join(rel).display());
}
// Also watch the embedded OUTPUT directory. The input watches above decide
// whether THIS script re-runs and regenerates `web/dist`; but `include_dir!`
// in `static_bundle.rs` reads that directory, and cargo has no other
// declared dependency on those output files. Without this watch, an
// incremental build could regenerate `web/dist` yet keep the OLD bundle
// embedded. Tying the crate's recompilation to the regenerated output makes
// the fresh assets actually get re-embedded.
let dist_dir = web_dir.join("dist");
println!("cargo:rerun-if-changed={}", dist_dir.display());
require_tool("pnpm");
require_tool("node");
run(
Command::new("pnpm")
.args(["install", "--frozen-lockfile"])
.current_dir(&web_dir),
"pnpm install --frozen-lockfile",
);
run(
Command::new("pnpm").arg("build").current_dir(&web_dir),
"pnpm build",
);
dist_dir
}
fn require_tool(tool: &str) {
let ok = Command::new(tool)
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !ok {
panic!(
"maestro-web requires `{tool}` to build its embedded SPA bundle. \
Install pnpm + node (https://pnpm.io), or point MAESTRO_WEB_DIST \
at a prebuilt bundle."
);
}
}
fn run(cmd: &mut Command, label: &str) {
let status = cmd
.status()
.unwrap_or_else(|e| panic!("failed to spawn `{label}`: {e}"));
if !status.success() {
panic!("`{label}` failed with {status}");
}
}
-148
View File
@@ -1,148 +0,0 @@
//! `maestro-web` view over the unified Maestro config.
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use maestro_config::MaestroConfigFile;
pub use maestro_config::default_config_path;
use maestro_config::WebSection;
/// Default bind address when neither env nor file specify one.
pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:8080";
/// Default daemon URL `maestro-web` reverse-proxies to.
pub const DEFAULT_DAEMON_URL: &str = "http://127.0.0.1:7070";
pub const ENV_WEB_BIND_ADDR: &str = "MAESTRO_WEB_BIND_ADDR";
pub const ENV_WEB_DAEMON_URL: &str = "MAESTRO_WEB_DAEMON_URL";
fn default_bind_addr() -> SocketAddr {
DEFAULT_BIND_ADDR.parse().expect("valid default bind_addr")
}
#[derive(Debug, thiserror::Error)]
pub enum WebConfigError {
#[error(transparent)]
Inner(#[from] maestro_config::ConfigError),
}
/// Resolved runtime configuration for the `maestro-web` binary.
#[derive(Debug, Clone)]
pub struct WebConfig {
pub bind_addr: SocketAddr,
/// Daemon HTTP base URL — e.g. `http://127.0.0.1:7070`. Always a base
/// without trailing slash and without a path.
pub daemon_url: String,
}
#[derive(Debug, Clone, Default)]
pub struct EnvOverrides {
pub bind_addr: Option<String>,
pub daemon_url: Option<String>,
}
impl EnvOverrides {
pub fn from_env() -> Self {
Self {
bind_addr: std::env::var(ENV_WEB_BIND_ADDR).ok(),
daemon_url: std::env::var(ENV_WEB_DAEMON_URL).ok(),
}
}
}
/// Resolve the runtime configuration. Missing-file is fine: every field has
/// a default and the binary is happy to run with no config.toml at all.
pub fn load_config(path: &Path, env: EnvOverrides) -> Result<WebConfig, WebConfigError> {
let file = MaestroConfigFile::load(path)?;
resolve_with(file.web, env)
}
fn resolve_with(web: WebSection, env: EnvOverrides) -> Result<WebConfig, WebConfigError> {
let bind_addr = match env.bind_addr {
Some(raw) => maestro_config::parse_bind_addr(&raw, ENV_WEB_BIND_ADDR)?,
None => web.bind_addr.unwrap_or_else(default_bind_addr),
};
let daemon_url = env
.daemon_url
.or(web.daemon_url)
.unwrap_or_else(|| DEFAULT_DAEMON_URL.to_string())
.trim_end_matches('/')
.to_string();
Ok(WebConfig {
bind_addr,
daemon_url,
})
}
/// Standard state dir (mostly so callers have a place to put logs — the
/// binary itself is stateless).
pub fn default_state_dir() -> Result<PathBuf, WebConfigError> {
Ok(maestro_config::default_state_dir("maestro-web")?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_when_file_missing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("does-not-exist.toml");
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.bind_addr, default_bind_addr());
assert_eq!(cfg.daemon_url, "http://127.0.0.1:7070");
}
#[test]
fn reads_web_section_from_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
[web]
bind_addr = "127.0.0.1:9090"
daemon_url = "http://127.0.0.1:9070"
"#,
)
.unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.bind_addr, "127.0.0.1:9090".parse().unwrap());
assert_eq!(cfg.daemon_url, "http://127.0.0.1:9070");
}
#[test]
fn env_beats_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
[web]
bind_addr = "127.0.0.1:9090"
daemon_url = "http://127.0.0.1:9070"
"#,
)
.unwrap();
let env = EnvOverrides {
bind_addr: Some("0.0.0.0:8080".into()),
daemon_url: Some("http://daemon:7070".into()),
};
let cfg = load_config(&path, env).unwrap();
assert_eq!(cfg.bind_addr, "0.0.0.0:8080".parse().unwrap());
assert_eq!(cfg.daemon_url, "http://daemon:7070");
}
#[test]
fn daemon_url_strips_trailing_slash() {
let env = EnvOverrides {
daemon_url: Some("http://daemon:7070/".into()),
..Default::default()
};
let cfg = resolve_with(WebSection::default(), env).unwrap();
assert_eq!(cfg.daemon_url, "http://daemon:7070");
}
}
-12
View File
@@ -1,12 +0,0 @@
//! `maestro-web` — standalone HTTP server that hosts the Maestro browser UI
//! and reverse-proxies API calls to a co-located `maestrod`.
#![deny(missing_debug_implementations)]
pub mod config;
pub mod proxy;
pub mod server;
pub mod static_bundle;
pub use config::{load_config, EnvOverrides, WebConfig, WebConfigError};
pub use server::router;
-62
View File
@@ -1,62 +0,0 @@
//! `maestro-web` binary entry point. See `lib.rs` for the architecture.
use std::error::Error;
use std::path::PathBuf;
use std::process::ExitCode;
use clap::Parser;
use maestro_web::config::{default_config_path, EnvOverrides};
use maestro_web::{load_config, router};
use tokio::net::TcpListener;
use tracing_subscriber::EnvFilter;
#[derive(Debug, Parser)]
#[command(
name = "maestro-web",
version,
about = "Serves the Maestro web UI and reverse-proxies to a co-located maestrod"
)]
struct Cli {
/// Override the unified Maestro config-file path.
#[arg(long, env = "MAESTRO_CONFIG")]
config: Option<PathBuf>,
}
#[tokio::main]
async fn main() -> ExitCode {
let cli = Cli::parse();
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::fmt().with_env_filter(filter).init();
match run(cli.config).await {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
tracing::error!(error = %e, "maestro-web failed");
eprintln!("maestro-web: {e}");
ExitCode::FAILURE
}
}
}
async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
let path = match config_override {
Some(p) => p,
None => default_config_path()?,
};
let cfg = load_config(&path, EnvOverrides::from_env())?;
tracing::info!(
bind = %cfg.bind_addr,
daemon = %cfg.daemon_url,
"loaded config",
);
let router = router(&cfg)?;
let listener = TcpListener::bind(cfg.bind_addr).await?;
tracing::info!(addr = %listener.local_addr()?, "serving");
axum::serve(listener, router)
.with_graceful_shutdown(async {
let _ = tokio::signal::ctrl_c().await;
tracing::info!("ctrl-c received — shutting down");
})
.await?;
Ok(())
}
-419
View File
@@ -1,419 +0,0 @@
//! Reverse-proxy primitives: small hand-written hyper-client handler for
//! `/api/...` HTTP, and an axum WebSocket pump for `/api/attach/:id` +
//! `/api/events`.
//!
//! Keeps dependencies minimal — reqwest for HTTP, tokio_tungstenite for the
//! upstream WS leg. ~150 LOC total.
use std::sync::Arc;
use axum::body::Body;
use axum::extract::ws::{Message as AxMessage, WebSocket};
use axum::extract::{Request, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::routes::{self, UrlError};
use maestro_protocol::ApiError;
use tokio_tungstenite::tungstenite::Message as WsMessage;
#[derive(Debug, Clone)]
pub struct UpstreamConfig {
/// HTTP base of the daemon — `http://127.0.0.1:7070`.
pub daemon_http_base: Arc<String>,
/// WS base of the daemon — derived from `daemon_http_base` by replacing
/// the scheme. Cached so we don't redo it per request.
pub daemon_ws_base: Arc<String>,
}
impl UpstreamConfig {
pub fn from_daemon_url(daemon_url: &str) -> Result<Self, UrlError> {
let http_base = routes::normalize_base(daemon_url).to_string();
let ws_base = routes::ws_base(&http_base)?;
Ok(Self {
daemon_http_base: Arc::new(http_base),
daemon_ws_base: Arc::new(ws_base),
})
}
}
/// Shared state plumbed into the proxy handlers.
#[derive(Debug, Clone)]
pub struct ProxyState {
pub upstream: UpstreamConfig,
pub client: reqwest::Client,
}
impl ProxyState {
pub fn new(upstream: UpstreamConfig) -> Self {
Self {
upstream,
client: reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("reqwest client"),
}
}
}
// ---------------------------------------------------------------------------
// HTTP proxy
// ---------------------------------------------------------------------------
/// Cap on the request body we'll buffer before forwarding upstream. The proxied
/// daemon endpoints (`/api/sessions` CRUD, etc.) all carry small JSON; 8 MiB is
/// far above anything legitimate. Without a cap, anyone reachable on the bind
/// address could POST a multi-GB body and OOM the process (NAP-257).
pub(crate) const MAX_REQUEST_BODY: usize = 8 * 1024 * 1024;
/// Forward an HTTP request to the upstream daemon and stream the response back.
pub async fn proxy_http(State(state): State<ProxyState>, req: Request) -> Response {
let (parts, body) = req.into_parts();
let upstream_uri = build_uri(&state.upstream.daemon_http_base, &parts.uri);
// Bound the buffered request body. `to_bytes` returns `Err` once the body
// exceeds the limit; treat that as 413 rather than buffering unboundedly.
let body_bytes = match axum::body::to_bytes(body, MAX_REQUEST_BODY).await {
Ok(b) => b,
Err(_) => return payload_too_large(),
};
let mut upstream_req = state
.client
.request(parts.method.clone(), upstream_uri.as_str())
.body(body_bytes.to_vec());
for (name, value) in filter_request_headers(&parts.headers).iter() {
upstream_req = upstream_req.header(name, value);
}
let upstream_resp = match upstream_req.send().await {
Ok(r) => r,
Err(e) => return bad_gateway(&format!("upstream send: {e}")),
};
let status = upstream_resp.status();
let headers = filter_response_headers(upstream_resp.headers());
// Stream the response body straight through instead of materializing it —
// a large upstream reply never gets fully buffered in RAM (NAP-257).
let mut resp = Response::builder().status(status);
for (name, value) in headers.iter() {
resp = resp.header(name, value);
}
resp.body(Body::from_stream(upstream_resp.bytes_stream()))
.unwrap_or_else(|e| bad_gateway(&format!("build response: {e}")))
}
fn build_uri(base: &str, original: &Uri) -> String {
let mut out = base.to_string();
out.push_str(original.path());
if let Some(q) = original.query() {
out.push('?');
out.push_str(q);
}
out
}
/// Headers we strip before forwarding upstream / downstream. `host` must be
/// dropped (reqwest will set it correctly for the upstream); `content-length`
/// is regenerated by the client; hop-by-hop headers per RFC 7230 §6.1 should
/// not be relayed.
const HOP_BY_HOP: &[&str] = &[
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
];
fn filter_request_headers(headers: &HeaderMap) -> HeaderMap {
let mut out = HeaderMap::new();
for (name, value) in headers {
let n = name.as_str();
if n.eq_ignore_ascii_case("host") || n.eq_ignore_ascii_case("content-length") {
continue;
}
if HOP_BY_HOP.iter().any(|h| n.eq_ignore_ascii_case(h)) {
continue;
}
out.append(name.clone(), value.clone());
}
out
}
fn filter_response_headers(headers: &reqwest::header::HeaderMap) -> HeaderMap {
let mut out = HeaderMap::new();
for (name, value) in headers {
let n = name.as_str();
if HOP_BY_HOP.iter().any(|h| n.eq_ignore_ascii_case(h)) {
continue;
}
if let (Ok(n), Ok(v)) = (
axum::http::HeaderName::from_bytes(name.as_str().as_bytes()),
HeaderValue::from_bytes(value.as_bytes()),
) {
out.append(n, v);
}
}
out
}
fn bad_gateway(msg: &str) -> Response {
tracing::warn!(error = msg, "proxy: bad gateway");
api_error(StatusCode::BAD_GATEWAY, "proxy_failed", msg)
}
fn payload_too_large() -> Response {
tracing::warn!(limit = MAX_REQUEST_BODY, "proxy: request body exceeds cap");
api_error(
StatusCode::PAYLOAD_TOO_LARGE,
"payload_too_large",
&format!("request body exceeds {MAX_REQUEST_BODY} bytes"),
)
}
/// Answer in the same envelope the daemon uses, so a client cannot tell whether
/// a failure came from the daemon or from this proxy.
fn api_error(status: StatusCode, code: &str, message: &str) -> Response {
(status, axum::Json(ApiError::new(code, message))).into_response()
}
// ---------------------------------------------------------------------------
// WebSocket proxy
// ---------------------------------------------------------------------------
/// Open an upstream WS connection at `daemon_ws_base + path?query` and pump
/// frames in both directions until either side closes. Used for
/// `/api/attach/:id` and `/api/events`.
pub async fn pump_ws(mut client: WebSocket, upstream_url: String) {
let (upstream, _) = match tokio_tungstenite::connect_async(&upstream_url).await {
Ok(pair) => pair,
Err(e) => {
tracing::warn!(url = %upstream_url, error = %e, "proxy: upstream WS connect failed");
// The daemon rejects an attach it can't serve (unknown session)
// with a structured HTTP error *before* the upgrade. We've already
// upgraded the browser socket, so a bare drop reaches it as an
// opaque 1006 close — the attach pane just flashes and vanishes with
// no reason. Relay the daemon's reason as an application close frame
// the UI can show (#39).
let (code, reason) = upstream_close_reason(&e);
let _ = client
.send(AxMessage::Close(Some(axum::extract::ws::CloseFrame {
code,
reason: reason.into(),
})))
.await;
return;
}
};
let (mut client_sink, mut client_stream) = client.split();
let (mut up_sink, mut up_stream) = upstream.split();
let client_to_up = async {
while let Some(msg) = client_stream.next().await {
let Ok(msg) = msg else { break };
let frame = match msg {
AxMessage::Text(t) => WsMessage::Text(t),
AxMessage::Binary(b) => WsMessage::Binary(b),
AxMessage::Ping(p) => WsMessage::Ping(p),
AxMessage::Pong(p) => WsMessage::Pong(p),
AxMessage::Close(c) => {
let _ = up_sink
.send(WsMessage::Close(c.map(|cf| {
tokio_tungstenite::tungstenite::protocol::CloseFrame {
code: cf.code.into(),
reason: cf.reason,
}
})))
.await;
break;
}
};
if up_sink.send(frame).await.is_err() {
break;
}
}
};
let up_to_client = async {
while let Some(msg) = up_stream.next().await {
let Ok(msg) = msg else { break };
let frame = match msg {
WsMessage::Text(t) => AxMessage::Text(t),
WsMessage::Binary(b) => AxMessage::Binary(b),
WsMessage::Ping(p) => AxMessage::Ping(p),
WsMessage::Pong(p) => AxMessage::Pong(p),
WsMessage::Close(c) => {
let _ = client_sink
.send(AxMessage::Close(c.map(|cf| {
axum::extract::ws::CloseFrame {
code: cf.code.into(),
reason: cf.reason.to_string().into(),
}
})))
.await;
break;
}
WsMessage::Frame(_) => continue,
};
if client_sink.send(frame).await.is_err() {
break;
}
}
};
// Stop as soon as either side hangs up.
tokio::select! {
_ = client_to_up => {}
_ = up_to_client => {}
}
}
/// Largest application close code we emit; the browser surfaces it as
/// `CloseEvent.code`. Codes in 4000-4999 are reserved for application use.
const CLOSE_UPSTREAM_REJECTED: u16 = 4502;
/// Turn a failed upstream WS handshake into a browser-facing close code +
/// reason. When the daemon rejected the upgrade with an [`ApiError`] body,
/// surface that message; otherwise fall back to a generic gateway reason. The
/// reason rides a WS control frame, so it's truncated to the 123-byte limit.
fn upstream_close_reason(e: &tokio_tungstenite::tungstenite::Error) -> (u16, String) {
use tokio_tungstenite::tungstenite::Error as WsError;
let msg = match e {
WsError::Http(resp) => resp
.body()
.as_ref()
.and_then(|b| serde_json::from_slice::<ApiError>(b).ok())
.map(|e| e.error.message)
.unwrap_or_else(|| "the session's daemon rejected the attach".to_owned()),
_ => "could not reach the session's daemon".to_owned(),
};
(CLOSE_UPSTREAM_REJECTED, truncate_reason(&msg))
}
/// Clamp a close reason to the WS control-frame budget (125 bytes total, 2 for
/// the code → 123 for the reason), respecting UTF-8 char boundaries.
fn truncate_reason(s: &str) -> String {
const MAX: usize = 123;
if s.len() <= MAX {
return s.to_owned();
}
let mut end = MAX;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
s[..end].to_owned()
}
/// Compose the upstream WS URL from the base + path + query of the inbound
/// request.
pub fn upstream_ws_url(state: &ProxyState, uri: &Uri) -> String {
let mut out = state.upstream.daemon_ws_base.as_str().to_string();
out.push_str(uri.path());
if let Some(q) = uri.query() {
out.push('?');
out.push_str(q);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ws_base_swaps_http_scheme() {
let u = UpstreamConfig::from_daemon_url("http://127.0.0.1:7070/").unwrap();
assert_eq!(&*u.daemon_http_base, "http://127.0.0.1:7070");
assert_eq!(&*u.daemon_ws_base, "ws://127.0.0.1:7070");
}
#[test]
fn ws_base_swaps_https_scheme() {
let u = UpstreamConfig::from_daemon_url("https://daemon.example/").unwrap();
assert_eq!(&*u.daemon_ws_base, "wss://daemon.example");
}
/// NAP-257: a request body over the cap is rejected with 413 before we ever
/// dial upstream — so an oversized POST can't OOM the proxy. The upstream is
/// a bogus unreachable address; a 413 (not a 502) proves we bailed early.
#[tokio::test]
async fn oversized_request_body_is_rejected_with_413() {
let state = ProxyState::new(UpstreamConfig::from_daemon_url("http://127.0.0.1:9").unwrap());
let big = vec![0u8; MAX_REQUEST_BODY + 1];
let req = Request::builder()
.method("POST")
.uri("/api/sessions")
.body(Body::from(big))
.unwrap();
let resp = proxy_http(State(state), req).await;
assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
/// A body at the cap still proceeds to the upstream send (which fails here
/// with 502 since nothing is listening) — i.e. the cap is exclusive of the
/// limit, not off-by-one against legitimate traffic.
#[tokio::test]
async fn body_at_cap_is_not_rejected_by_the_size_check() {
let state = ProxyState::new(UpstreamConfig::from_daemon_url("http://127.0.0.1:9").unwrap());
let body = vec![0u8; MAX_REQUEST_BODY];
let req = Request::builder()
.method("POST")
.uri("/api/sessions")
.body(Body::from(body))
.unwrap();
let resp = proxy_http(State(state), req).await;
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
}
/// #39: a daemon rejection carrying the structured error body surfaces its
/// `message` as the close reason, under an application close code, so the
/// browser can show *why* the attach failed instead of an opaque 1006.
#[test]
fn upstream_close_reason_extracts_daemon_message() {
use tokio_tungstenite::tungstenite::{http, Error as WsError};
let body =
br#"{"error":{"code":"unknown_session","message":"no such session on this daemon"}}"#
.to_vec();
let resp = http::Response::builder()
.status(404)
.body(Some(body))
.unwrap();
let (code, reason) = upstream_close_reason(&WsError::Http(resp));
assert_eq!(code, CLOSE_UPSTREAM_REJECTED);
assert_eq!(reason, "no such session on this daemon");
}
/// A non-HTTP failure (e.g. TCP refused — peer down) still yields a usable
/// generic reason rather than a panic or empty string.
#[test]
fn upstream_close_reason_falls_back_for_non_http_errors() {
use tokio_tungstenite::tungstenite::Error as WsError;
let e = WsError::Io(std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
"nope",
));
let (code, reason) = upstream_close_reason(&e);
assert_eq!(code, CLOSE_UPSTREAM_REJECTED);
assert!(!reason.is_empty());
}
/// Close reasons ride a control frame capped at 123 bytes; an over-long
/// message is truncated on a char boundary (no panic on multi-byte chars).
#[test]
fn truncate_reason_respects_control_frame_budget() {
let ascii = "x".repeat(200);
assert_eq!(truncate_reason(&ascii).len(), 123);
let multibyte = "é".repeat(100); // 2 bytes each
let out = truncate_reason(&multibyte);
assert!(out.len() <= 123);
assert!(out.is_char_boundary(out.len()));
}
}
-56
View File
@@ -1,56 +0,0 @@
//! HTTP server wiring — composes the static bundle, the HTTP proxy, and the
//! WS proxy into a single axum router.
use axum::extract::{Request, State, WebSocketUpgrade};
use axum::response::{IntoResponse, Response};
use axum::routing::{any, get};
use axum::Router;
use maestro_protocol::routes::{self, UrlError};
use crate::config::WebConfig;
use crate::proxy::{pump_ws, upstream_ws_url, ProxyState, UpstreamConfig};
use crate::static_bundle;
/// Build the public router. The returned router is ready to hand to
/// `axum::serve`.
pub fn router(config: &WebConfig) -> Result<Router, UrlError> {
let upstream = UpstreamConfig::from_daemon_url(&config.daemon_url)?;
let proxy_state = ProxyState::new(upstream);
// Mount each route at its real path (not under `nest`, which would strip
// the `/api` prefix before the proxy could forward it). WS routes go
// first so the HTTP fallback doesn't shadow them.
Ok(Router::new()
.route(routes::patterns::ATTACH, any(ws_proxy))
.route(routes::EVENTS, any(ws_proxy))
.route("/", get(serve_index))
.fallback(catch_all)
.with_state(proxy_state))
}
/// Default handler: `/api/*` goes to the daemon, anything else falls through
/// to the embedded SPA bundle (with SPA-style fallback to `index.html`).
async fn catch_all(State(state): State<ProxyState>, req: Request) -> Response {
if is_api_path(req.uri().path()) {
return crate::proxy::proxy_http(State(state), req).await;
}
static_bundle::serve_path(req.uri().path())
}
/// Split point between the proxied daemon surface and the SPA bundle. A daemon
/// route added outside [`routes::API_PREFIX`] would silently be answered with
/// `index.html` instead of being forwarded.
fn is_api_path(path: &str) -> bool {
path.starts_with(&format!("{}/", routes::API_PREFIX))
}
async fn ws_proxy(State(state): State<ProxyState>, ws: WebSocketUpgrade, req: Request) -> Response {
let upstream_url = upstream_ws_url(&state, req.uri());
ws.on_upgrade(move |socket| async move {
pump_ws(socket, upstream_url).await;
})
}
async fn serve_index() -> Response {
static_bundle::serve_index().into_response()
}
-138
View File
@@ -1,138 +0,0 @@
//! Integration tests for the maestro-web reverse-proxy.
//!
//! Points maestro-web at a `MockDaemon` and exercises both the HTTP and WS
//! proxy paths end-to-end.
use maestro_testing::MockDaemon;
use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use maestro_web::config::WebConfig;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio_tungstenite::tungstenite::Message as WsMessage;
async fn spawn_web(daemon_url: String) -> (SocketAddr, oneshot::Sender<()>) {
let cfg = WebConfig {
bind_addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
daemon_url,
};
let router = maestro_web::router(&cfg).expect("router");
let listener = TcpListener::bind(cfg.bind_addr).await.unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, router)
.with_graceful_shutdown(async move {
let _ = rx.await;
})
.await;
});
(addr, tx)
}
#[tokio::test]
async fn http_get_api_path_forwards_to_daemon() {
let daemon = MockDaemon::start().await;
let daemon_addr = daemon.addr();
let (web_addr, _w) = spawn_web(format!("http://{daemon_addr}")).await;
let body: serde_json::Value = reqwest::get(format!("http://{web_addr}/api/sessions"))
.await
.unwrap()
.json()
.await
.unwrap();
assert!(body.as_array().expect("array").is_empty());
}
#[tokio::test]
async fn http_get_unrelated_path_falls_back_to_spa_index() {
let daemon = MockDaemon::start().await;
let daemon_addr = daemon.addr();
let (web_addr, _w) = spawn_web(format!("http://{daemon_addr}")).await;
// The bundle is empty in test builds — the placeholder index.html serves.
let resp = reqwest::get(format!("http://{web_addr}/some/route"))
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::OK);
let ct = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(ct.starts_with("text/html"), "ct={ct}");
}
#[tokio::test]
async fn ws_api_attach_forwards_to_daemon_and_echoes() {
let daemon = MockDaemon::start().await;
let id = uuid::Uuid::from_u128(7);
daemon.add_session(maestro_testing::session(id, "/home/mock", "bash"));
let daemon_addr = daemon.addr();
let (web_addr, _w) = spawn_web(format!("http://{daemon_addr}")).await;
let url = format!("ws://{web_addr}/api/attach/{id}");
let (mut ws, _) = tokio_tungstenite::connect_async(url).await.unwrap();
ws.send(WsMessage::Binary(b"hello".to_vec())).await.unwrap();
let echoed = tokio::time::timeout(Duration::from_secs(1), ws.next())
.await
.unwrap();
match echoed {
Some(Ok(WsMessage::Binary(b))) => assert_eq!(b, b"hello"),
other => panic!("unexpected frame: {other:?}"),
}
}
/// The client is upgraded before the upstream connection is attempted, so an
/// upstream rejection has to come back as a close code. This is the end-to-end
/// counterpart to `upstream_close_reason`, which had only unit tests: the
/// daemon's own message must reach the browser rather than a generic failure.
#[tokio::test]
async fn ws_attach_to_an_unknown_session_closes_with_the_daemon_reason() {
let daemon = MockDaemon::start().await;
let (web_addr, _w) = spawn_web(format!("http://{}", daemon.addr())).await;
let url = format!("ws://{web_addr}/api/attach/{}", uuid::Uuid::new_v4());
let (mut ws, _) = tokio_tungstenite::connect_async(url)
.await
.expect("upgrade");
let frame = tokio::time::timeout(Duration::from_secs(2), ws.next())
.await
.expect("close frame");
match frame {
Some(Ok(WsMessage::Close(Some(cf)))) => {
assert_eq!(u16::from(cf.code), 4502);
assert!(
cf.reason.contains("no such session"),
"expected the daemon's message, got {:?}",
cf.reason
);
}
other => panic!("expected a Close frame with a reason, got {other:?}"),
}
}
#[tokio::test]
async fn proxy_returns_502_when_daemon_unreachable() {
// Bind a port, immediately drop the listener, hand the address to the
// proxy. Connections to it will be refused.
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
let dead_addr = listener.local_addr().unwrap();
drop(listener);
let (web_addr, _w) = spawn_web(format!("http://{dead_addr}")).await;
let resp = reqwest::get(format!("http://{web_addr}/api/sessions"))
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::BAD_GATEWAY);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["error"]["code"], "proxy_failed");
}
+3 -1
View File
@@ -12,10 +12,12 @@ name = "maestro"
path = "src/main.rs" path = "src/main.rs"
[features] [features]
default = ["cli", "daemon", "tui", "haiku-autoname"] default = ["cli", "daemon", "tui", "web", "haiku-autoname"]
cli = ["dep:maestro-cli"] cli = ["dep:maestro-cli"]
daemon = ["dep:maestro-daemon"] daemon = ["dep:maestro-daemon"]
tui = ["dep:maestro-tui"] tui = ["dep:maestro-tui"]
# The daemon serves the browser SPA itself; needs `daemon` to have any effect.
web = ["maestro-daemon?/web"]
# Name a session after its first prompt (#131). `?` so a build that leaves # Name a session after its first prompt (#131). `?` so a build that leaves
# the daemon or the CLI out does not pull it back in. # the daemon or the CLI out does not pull it back in.
haiku-autoname = ["maestro-cli?/haiku-autoname", "maestro-daemon?/haiku-autoname"] haiku-autoname = ["maestro-cli?/haiku-autoname", "maestro-daemon?/haiku-autoname"]
+1 -5
View File
@@ -169,12 +169,8 @@ async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
Command::Daemon { cmd } => maestro_daemon::cli::dispatch(cli.config, cmd).await?, Command::Daemon { cmd } => maestro_daemon::cli::dispatch(cli.config, cmd).await?,
#[cfg(feature = "tui")] #[cfg(feature = "tui")]
Command::Tui(args) => { Command::Tui(args) => {
let daemon_url = args
.daemon_url
.or(cli.daemon_url)
.unwrap_or_else(|| "http://127.0.0.1:7070".to_string());
maestro_tui::run(maestro_tui::RunConfig { maestro_tui::run(maestro_tui::RunConfig {
daemon_url, daemon_url: args.daemon_url.or(cli.daemon_url),
config_path: cli.config, config_path: cli.config,
}) })
.await? .await?
+41 -64
View File
@@ -51,22 +51,22 @@ The binary lands in `~/.cargo/bin/maestro` — make sure that's on your `$PATH`.
## Nix / NixOS ## Nix / NixOS
The repo is a flake. It exposes two packages and a Home Manager module: The repo is a flake. It exposes the `maestro` package, the SPA bundle it
embeds, and a Home Manager module:
```sh ```sh
nix run git+https://git.naps.pt/naps62/maestro -- list # the maestro binary nix run git+https://git.naps.pt/naps62/maestro -- list # the maestro binary
nix build git+https://git.naps.pt/naps62/maestro#maestro-web
``` ```
`packages.maestro` is the unified CLI + daemon + TUI binary, wrapped so `tmux` `packages.maestro` is the unified CLI + daemon + TUI binary, with the browser
and `git` are on its `PATH`. `packages.maestro-web` embeds a prebuilt SPA SPA embedded, wrapped so `tmux` and `git` are on its `PATH`. The Nix build
bundle — the Nix build points `MAESTRO_WEB_DIST` at `packages.webFrontend`, so points `MAESTRO_WEB_DIST` at `packages.webFrontend`, so `build.rs` skips pnpm
`build.rs` skips pnpm entirely and no network fetch happens mid-compile. Set entirely and no network fetch happens mid-compile. Set that variable yourself
that variable yourself to reuse a bundle built elsewhere; leave it unset and to reuse a bundle built elsewhere; leave it unset and the build falls back to
the build falls back to running `pnpm install && pnpm build` in `web/`. running `pnpm install && pnpm build` in `web/`.
Home Manager, with both services running as *user* units (same trust boundary Home Manager, with the daemon running as a *user* unit (same trust boundary as
as the tmux server they wrap): the tmux server it wraps):
```nix ```nix
{ {
@@ -76,20 +76,18 @@ as the tmux server they wrap):
imports = [ inputs.maestro.homeManagerModules.default ]; imports = [ inputs.maestro.homeManagerModules.default ];
services.maestro = { services.maestro = {
enable = true; # installs the binary, runs `maestro daemon` enable = true; # installs the binary, runs `maestro daemon`
web.enable = true; # runs `maestro-web`
settings = { settings = {
daemon.bind_addr = "127.0.0.1:7070"; daemon.bind_addr = "127.0.0.1:7070";
web.bind_addr = "0.0.0.0:8081";
}; };
}; };
} }
``` ```
`settings` is written verbatim to `~/.config/maestro/config.toml` in the schema `settings` is written verbatim to `~/.config/maestro/config.toml` in the schema
below; every field has a default, so `{}` writes no file at all. There is no below; every field has a default, so `{}` writes no file at all. The daemon
auth layer — bind the web UI to a LAN or VPN address only on a trusted segment, serves the browser UI off that same address, and there is no auth layer — bind
never a public IP. it to a LAN or VPN address only on a trusted segment, never a public IP.
`services.maestro.daemon.enable = false` installs the CLI and TUI without `services.maestro.daemon.enable = false` installs the CLI and TUI without
running a local daemon, for a host that only talks to another machine's. running a local daemon, for a host that only talks to another machine's.
@@ -100,8 +98,8 @@ session alive.
## Thin builds for single-role hosts ## Thin builds for single-role hosts
The default install pulls in everything (`cli` + `daemon` + `tui`). Single-role The default install pulls in everything (`cli` + `daemon` + `tui` + `web`).
hosts can opt out of unused dependencies: Single-role hosts can opt out of unused dependencies:
```sh ```sh
# Daemon-only host — no CLI or TUI: # Daemon-only host — no CLI or TUI:
@@ -113,6 +111,9 @@ cargo install --git https://git.naps.pt/naps62/maestro \
--no-default-features --features cli --locked maestro --no-default-features --features cli --locked maestro
``` ```
Neither of those builds the browser UI: `web` is left out, so pnpm and node
aren't needed. Add `--features daemon,web` to put it back.
The wizard always installs the default-feature build. The wizard always installs the default-feature build.
## The terminal UI: `maestro tui` ## The terminal UI: `maestro tui`
@@ -185,53 +186,31 @@ An unknown name is rejected as an `[tui].theme` configuration error.
The full action vocabulary is the `Action` enum in The full action vocabulary is the `Action` enum in
`crates/maestro-tui/src/action.rs`. `crates/maestro-tui/src/action.rs`.
## Adding the browser UI (optional) ## The browser UI
`maestro-web` is a separate binary that serves the SPA bundle and reverse- The daemon serves the SPA off its own listener. Open
proxies API calls to a co-located `maestro daemon`. `http://127.0.0.1:7070/` — or whatever `[daemon].bind_addr` is — and the
browser talks to the same process the CLI and TUI do. One binary, one port,
nothing extra to install or start.
**Interactive install (default path).** The wizard offers it as an optional **Build prerequisite.** The `web` cargo feature is on by default and embeds the
step right after the daemon flow: bundle at compile time: `build.rs` runs `pnpm install --frozen-lockfile &&
pnpm build` in `web/`, so pnpm and node must be on `PATH`. Two ways around
that — point `MAESTRO_WEB_DIST` at a bundle built elsewhere, or build with
`--no-default-features` and pick the features you want (see *Thin builds*
above).
```text **Trust model.** There is no authentication layer: anyone who can reach
Optional: install the browser UI (`maestro-web`)? `[daemon].bind_addr` can load the UI and drive every session on the host.
Install maestro-web? [Y/n] Pick `127.0.0.1` for local-only, a WireGuard/LAN address for one-segment
``` access, and never a public IP — the network perimeter is the only boundary
Maestro has.
Press `Y` and it `cargo install`s `maestro-web`, drops a **Upgrading from the two-binary install.** Re-running `./install.sh` removes
`~/.config/systemd/user/maestro-web.service` unit (ordered after the old `maestro-web.service` unit for you; nothing to undo by hand. A leftover
`maestro.service`), reloads systemd, and offers to enable + start it. Re-runs `[web]` block in `~/.config/maestro/config.toml` is ignored rather than
of `./install.sh` detect the existing unit and refresh + restart silently. rejected, but it no longer does anything — move the address you care about to
`[daemon].bind_addr`.
Pass `--no-web` to skip the prompt entirely (headless hosts), or `--yes`
with `--no-web` for non-interactive daemon-only installs. (Bare `--yes`
without `--no-web` *skips* the web step too — opt in by re-running once
interactively.)
**Frontend build prerequisite.** `maestro-web` embeds the SPA bundle at
compile time, so before running the installer:
```sh
pnpm -C web install --frozen-lockfile
pnpm -C web build
```
Or `just web` from a workspace checkout.
**Defaults.** `bind_addr = 127.0.0.1:8080`, `daemon_url =
http://127.0.0.1:7070`. The `[web]` section of
`~/.config/maestro/config.toml` overrides both — see
`crates/maestro-web/README.md`.
**Trust model.** There is no authentication layer: anyone who can reach the
bind address (of either the daemon or `maestro-web`) can drive every session on
the host. Pick `127.0.0.1` for local-only, a WireGuard/LAN address for
one-segment access, and never a public IP — the network perimeter is the only
boundary Maestro has.
Headless hosts (servers, dev VMs) skip the prompt with `--no-web`. Hosts
where you want browser access answer yes — both binaries end up in
`~/.cargo/bin`.
## Flags ## Flags
@@ -243,9 +222,7 @@ where you want browser access answer yes — both binaries end up in
| `--no-locked` | Drop `--locked` from `cargo install` (resolves a fresh dep tree). | | `--no-locked` | Drop `--locked` from `cargo install` (resolves a fresh dep tree). |
| `--exec-start <path>` | Override the systemd unit's `ExecStart` (default `~/.cargo/bin/maestro daemon`). | | `--exec-start <path>` | Override the systemd unit's `ExecStart` (default `~/.cargo/bin/maestro daemon`). |
| `--no-systemd` | Skip the systemd step even if detected. | | `--no-systemd` | Skip the systemd step even if detected. |
| `--no-web` | Skip the optional `maestro-web` install step. | | `-y`, `--yes` | Non-interactive: install the binary + drop systemd unit + reload + enable, but **don't** auto-start the unit. |
| `--web-exec-start <path>` | Override the web unit's `ExecStart` (default `~/.cargo/bin/maestro-web`). |
| `-y`, `--yes` | Non-interactive: install binaries + drop systemd unit + reload + enable, but **don't** auto-start the unit. Also skips the web step unless an existing `maestro-web.service` is being refreshed. |
## Where tmux sockets land ## Where tmux sockets land
+16 -27
View File
@@ -110,6 +110,9 @@
pname = "maestro"; pname = "maestro";
cargoExtraArgs = "--package maestro"; cargoExtraArgs = "--package maestro";
doCheck = false; doCheck = false;
# Makes `maestro-daemon/build.rs` embed this bundle as-is instead
# of shelling out to pnpm, which the sandbox has no network for.
MAESTRO_WEB_DIST = webFrontend;
nativeBuildInputs = commonArgs.nativeBuildInputs ++ [ pkgs.makeWrapper ]; nativeBuildInputs = commonArgs.nativeBuildInputs ++ [ pkgs.makeWrapper ];
postInstall = '' postInstall = ''
wrapProgram $out/bin/maestro \ wrapProgram $out/bin/maestro \
@@ -167,34 +170,15 @@
license = lib.licenses.mit; license = lib.licenses.mit;
}; };
}); });
maestro-web = craneLib.buildPackage (
commonArgs
// {
inherit cargoArtifacts;
pname = "maestro-web";
cargoExtraArgs = "--package maestro-web";
doCheck = false;
# Skips pnpm entirely in build.rs and embeds this bundle as-is.
MAESTRO_WEB_DIST = webFrontend;
meta = {
description = "Maestro web UI: serves the SPA and proxies to a co-located daemon";
homepage = "https://git.naps.pt/naps62/maestro";
license = lib.licenses.mit;
platforms = lib.platforms.linux;
mainProgram = "maestro-web";
};
}
);
in in
{ {
packages = { packages = {
default = maestro; default = maestro;
inherit maestro maestro-web webFrontend; inherit maestro webFrontend;
}; };
checks = { checks = {
inherit maestro maestro-web; inherit maestro;
maestro-fmt = craneLib.cargoFmt { inherit (commonArgs) src; }; maestro-fmt = craneLib.cargoFmt { inherit (commonArgs) src; };
@@ -203,11 +187,14 @@
// { // {
inherit cargoArtifacts; inherit cargoArtifacts;
pname = "maestro-clippy"; pname = "maestro-clippy";
# maestro-web is excluded: clippy on it would run build.rs, and # The `web` feature is off here: with it on, maestro-daemon's
# a workspace-wide MAESTRO_WEB_DIST would make every other # build.rs would run pnpm, and a workspace-wide MAESTRO_WEB_DIST
# crate's cache key depend on the frontend. `checks.maestro-web` # would make every other crate's cache key depend on the
# already compiles it. # frontend. `checks.maestro` already compiles it with web on.
cargoClippyExtraArgs = "--workspace --exclude maestro-web --all-targets -- --deny warnings"; # Every other default feature is named back explicitly —
# `--no-default-features` would otherwise drop them from the
# lint, leaving that code unchecked.
cargoClippyExtraArgs = "--workspace --all-targets --no-default-features --features cli,daemon,tui,haiku-autoname -- --deny warnings";
} }
); );
}; };
@@ -244,7 +231,9 @@
processes = { processes = {
# Built once, up front, so the daemon and Vite don't race a # Built once, up front, so the daemon and Vite don't race a
# cold `cargo build` and trip their own readiness probes. # cold `cargo build` and trip their own readiness probes.
build.command = "cargo build --bin maestro"; # `web` is off here: Vite serves the UI in dev, so embedding a
# pnpm-built bundle in the daemon would only slow startup.
build.command = "cargo build --bin maestro --no-default-features --features cli,daemon,tui";
daemon = { daemon = {
# Both values need a shell: `$PWD` is only known at run time, # Both values need a shell: `$PWD` is only known at run time,
+47 -68
View File
@@ -1,8 +1,8 @@
# Home Manager module: `services.maestro`. # Home Manager module: `services.maestro`.
# #
# Maestro owns the user's interactive shells and tmux sessions, so both units # Maestro owns the user's interactive shells and tmux sessions, so the daemon
# are *user* services, not system ones — same trust boundary as the tmux server # is a *user* service, not a system one — same trust boundary as the tmux
# they wrap. # server it wraps. It serves the browser UI too; there is no second unit.
{ self, ... }: { self, ... }:
{ {
flake.homeManagerModules.default = flake.homeManagerModules.default =
@@ -27,6 +27,18 @@
]; ];
in in
{ {
imports = [
(lib.mkRemovedOptionModule [ "services" "maestro" "web" "enable" ] ''
The daemon serves the browser UI off its own listener now, so there is
no second unit to enable. Point a browser at
`services.maestro.settings.daemon.bind_addr` instead.
'')
(lib.mkRemovedOptionModule [ "services" "maestro" "web" "package" ] ''
The `maestro-web` package is gone; the SPA is embedded in the
`maestro` binary under its default-on `web` cargo feature.
'')
];
options.services.maestro = { options.services.maestro = {
enable = lib.mkEnableOption "Maestro — installs the CLI/TUI and, unless disabled, runs the daemon"; enable = lib.mkEnableOption "Maestro — installs the CLI/TUI and, unless disabled, runs the daemon";
@@ -43,94 +55,61 @@
description = "Run `maestro daemon` as a systemd user service."; description = "Run `maestro daemon` as a systemd user service.";
}; };
web = {
enable = lib.mkEnableOption "the `maestro-web` browser UI as a systemd user service";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.stdenv.hostPlatform.system}.maestro-web;
defaultText = lib.literalExpression "maestro.packages.\${system}.maestro-web";
description = "The `maestro-web` binary, with the SPA bundle embedded.";
};
};
settings = lib.mkOption { settings = lib.mkOption {
type = tomlFormat.type; type = tomlFormat.type;
default = { }; default = { };
example = lib.literalExpression '' example = lib.literalExpression ''
{ {
daemon.bind_addr = "127.0.0.1:7070"; daemon.bind_addr = "127.0.0.1:7070";
web.bind_addr = "0.0.0.0:8081";
} }
''; '';
description = '' description = ''
Contents of `~/.config/maestro/config.toml`, in the schema Contents of `~/.config/maestro/config.toml`, in the schema
`maestro-config` defines. Every field has a default, so `{}` is `maestro-config` defines. Every field has a default, so `{}` is
valid and writes no file. There is no auth layer — bind the web UI valid and writes no file. `daemon.bind_addr` is also the address the
to a LAN/VPN address only on a trusted segment, never a public IP. browser UI is served on. There is no auth layer — bind it to a
LAN/VPN address only on a trusted segment, never a public IP.
''; '';
}; };
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
home.packages = [ cfg.package ] ++ lib.optional cfg.web.enable cfg.web.package; home.packages = [ cfg.package ];
xdg.configFile."maestro/config.toml" = lib.mkIf (cfg.settings != { }) { xdg.configFile."maestro/config.toml" = lib.mkIf (cfg.settings != { }) {
source = tomlFormat.generate "maestro-config.toml" cfg.settings; source = tomlFormat.generate "maestro-config.toml" cfg.settings;
}; };
systemd.user.services = lib.mkMerge [ systemd.user.services = lib.mkIf cfg.daemon.enable {
(lib.mkIf cfg.daemon.enable { maestro-daemon = {
maestro-daemon = { Unit = {
Unit = { Description = "maestro daemon — tmux wrapper + session API + browser UI";
Description = "maestro daemon — tmux wrapper + session API"; After = [ "network.target" ];
After = [ "network.target" ]; # MUST stay 0: a fast-crashing daemon would burn the default
# MUST stay 0: a fast-crashing daemon would burn the default # 5-starts-per-10s budget and park the unit in `failed` until a
# 5-starts-per-10s budget and park the unit in `failed` until a # manual `systemctl --user reset-failed`.
# manual `systemctl --user reset-failed`. StartLimitIntervalSec = 0;
StartLimitIntervalSec = 0;
};
Service = {
Type = "simple";
WorkingDirectory = "%h";
ExecStart = "${lib.getExe cfg.package} daemon run";
Environment = [
"PATH=${profilePath}"
# Keeps the daemon on the same tmux server the user's shell
# and TUI use, instead of a second one under /tmp.
"TMUX_TMPDIR=%t"
];
Restart = "always";
RestartSec = 2;
# If this unit boots before any shell, the shared tmux server
# lands in its cgroup; the default control-group kill would take
# every session down on restart.
KillMode = "process";
};
Install.WantedBy = [ "default.target" ];
}; };
}) Service = {
Type = "simple";
(lib.mkIf cfg.web.enable { WorkingDirectory = "%h";
maestro-web = { ExecStart = "${lib.getExe cfg.package} daemon run";
Unit = { Environment = [
Description = "maestro-web — browser UI for the local maestro daemon"; "PATH=${profilePath}"
After = [ "network.target" ] ++ lib.optional cfg.daemon.enable "maestro-daemon.service"; # Keeps the daemon on the same tmux server the user's shell
Wants = lib.optional cfg.daemon.enable "maestro-daemon.service"; # and TUI use, instead of a second one under /tmp.
StartLimitIntervalSec = 0; "TMUX_TMPDIR=%t"
}; ];
Service = { Restart = "always";
Type = "simple"; RestartSec = 2;
WorkingDirectory = "%h"; # If this unit boots before any shell, the shared tmux server
ExecStart = lib.getExe cfg.web.package; # lands in its cgroup; the default control-group kill would take
Environment = [ "PATH=${profilePath}" ]; # every session down on restart.
Restart = "always"; KillMode = "process";
RestartSec = 2;
};
Install.WantedBy = [ "default.target" ];
}; };
}) Install.WantedBy = [ "default.target" ];
]; };
};
}; };
}; };
} }
+9 -7
View File
@@ -1,7 +1,8 @@
# maestro-web # Maestro web UI
Browser UI for Maestro. Talks to a single local daemon over its `/api/*` Browser UI for Maestro. The daemon embeds this bundle and serves it off its own
HTTP/WS surface — session catalog, spawn/kill, and terminal I/O. listener, so the page talks back to the same origin over `/api/*` — session
catalog, spawn/kill, and terminal I/O.
## Framework choice: vanilla TypeScript ## Framework choice: vanilla TypeScript
@@ -56,7 +57,7 @@ attaches: open the same `#/attach/<id>` URL in another tab.
```sh ```sh
pnpm install pnpm install
pnpm dev # http://localhost:5173, proxies /api → http://127.0.0.1:7070 pnpm dev # http://localhost:5173, proxies /api → http://127.0.0.1:7070
pnpm build # → dist/, embedded into the maestro-web binary at cargo build time pnpm build # → dist/, embedded into the maestro binary at cargo build time
``` ```
The Vite dev server proxies `/api` and the WebSockets to a local daemon. The Vite dev server proxies `/api` and the WebSockets to a local daemon.
@@ -69,6 +70,7 @@ MAESTRO_DAEMON_URL=http://laptop.lan:7070 pnpm dev
## Production ## Production
`pnpm build` writes the bundle to `web/dist/`. The `maestro-web` crate embeds `pnpm build` writes the bundle to `web/dist/`. Under its default `web` feature
that directory at compile time and serves it from `/`, reverse-proxying `/api/*` the daemon embeds that directory at compile time and serves it from `/`,
to the daemon. See `crates/maestro-web/src/static_bundle.rs`. alongside the `/api/*` routes. See
`crates/maestro-daemon/src/static_bundle.rs`.
+2 -2
View File
@@ -7,8 +7,8 @@ import type { Session, Uuid } from "./types.js";
export const INITIAL_RECONNECT_MS = 500; export const INITIAL_RECONNECT_MS = 500;
export const MAX_RECONNECT_MS = 10_000; export const MAX_RECONNECT_MS = 10_000;
/** Close codes at or above this are application-defined; `maestro-web` uses /** Close codes at or above this are application-defined; the daemon uses
* them to relay a daemon rejection, which retrying would only loop on. */ * them to signal a rejection, which retrying would only loop on. */
const APPLICATION_CLOSE = 4000; const APPLICATION_CLOSE = 4000;
/** Normal Closure — the peer ended the stream deliberately. */ /** Normal Closure — the peer ended the stream deliberately. */