feat(maestro): add a process-compose dev stack (#52)
ci / web (push) Successful in 28s
ci / rust (push) Successful in 2m34s

This commit was merged in pull request #52.
This commit is contained in:
2026-08-23 10:02:09 +01:00
parent a51809f25a
commit 53660407e3
6 changed files with 175 additions and 3 deletions
+6
View File
@@ -9,3 +9,9 @@ worktrees/
# nix build output symlinks
/result
/result-*
# direnv (`use flake`)
/.direnv/
# `nix run .#dev` scratch state (tmux socket)
/.dev/
+8 -1
View File
@@ -56,12 +56,19 @@ crates/
├── maestro-installer/ # `maestro-install` wizard: cargo install + systemd (NAP-225)
└── maestro-e2e/ # cross-process integration tests (NAP-217)
web/ # browser SPA (NAP-218)
flake.nix # Nix flake: `maestro`, `maestro-web`, `webFrontend`
flake.nix # Nix flake: packages, checks, dev shell, `nix run .#dev`
nix/ # Home Manager module (`services.maestro`)
```
See `docs/INSTALL.md` for end-user install instructions.
`nix run .#dev` (or `just dev`) brings up the whole local stack under
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
the UI is exposed on the network. The dev port is deliberately not the daemon's
own default 7070 (`devDaemonPort` in `flake.nix`), so the stack never fights a
real daemon on the same host.
## Conventions
- **TDD by default.** Failing test first, then minimal impl. Commit test + impl together.
+5
View File
@@ -1,5 +1,10 @@
# Common dev / build entry points. Run `just` with no args to list.
# Run the full local stack — daemon on 127.0.0.1:7071 plus Vite on :5173,
# ordered and health-gated by process-compose. Ctrl-C stops everything.
dev:
nix run .#dev
# Build the maestro-web binary together with the SPA bundle it embeds.
web:
pnpm -C web install --frozen-lockfile
+37
View File
@@ -76,6 +76,10 @@ impl Tmux {
if let Some(d) = &self.tmpdir {
c.env("TMUX_TMPDIR", d);
}
// A daemon started from inside a tmux pane inherits `TMUX`, and tmux
// resolves that ahead of `TMUX_TMPDIR` — so without this the daemon
// drives the developer's own server and `tmux_tmpdir` does nothing.
c.env_remove("TMUX");
c
}
@@ -463,6 +467,39 @@ mod tests {
assert_eq!(flat[zed - 1], "-e");
}
/// `TMUX` beats `TMUX_TMPDIR` when tmux looks for a server, so leaving an
/// inherited one in place would silently ignore the configured tmpdir.
#[test]
fn cmd_clears_an_inherited_tmux_and_sets_the_configured_tmpdir() {
let tmux = Tmux::new().with_tmpdir("/run/maestro-dev");
let cmd = tmux.cmd();
let envs: Vec<_> = cmd.get_envs().collect();
assert!(
envs.contains(&(OsStr::new("TMUX"), None)),
"TMUX must be removed, got {envs:?}"
);
assert!(
envs.contains(&(
OsStr::new("TMUX_TMPDIR"),
Some(OsStr::new("/run/maestro-dev"))
)),
"TMUX_TMPDIR must be set, got {envs:?}"
);
}
/// The removal is unconditional: a daemon manages its own server whether or
/// not a tmpdir was configured.
#[test]
fn cmd_clears_an_inherited_tmux_without_a_configured_tmpdir() {
let cmd = Tmux::new().cmd();
let envs: Vec<_> = cmd.get_envs().collect();
assert!(
envs.contains(&(OsStr::new("TMUX"), None)),
"TMUX must be removed, got {envs:?}"
);
}
#[test]
fn is_no_server_error_matches_every_known_phrasing() {
for phrase in NO_SERVER_PHRASES {
Generated
+17 -1
View File
@@ -64,11 +64,27 @@
"type": "github"
}
},
"process-compose-flake": {
"locked": {
"lastModified": 1782060835,
"narHash": "sha256-Q8HH2t1l3MITtWCWY4ytcH5F/Q7aAHo3Iq/QTMAKTe0=",
"owner": "Platonic-Systems",
"repo": "process-compose-flake",
"rev": "464ff6880737f063c3f0d3d2c7781fda9190868f",
"type": "github"
},
"original": {
"owner": "Platonic-Systems",
"repo": "process-compose-flake",
"type": "github"
}
},
"root": {
"inputs": {
"crane": "crane",
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs"
"nixpkgs": "nixpkgs",
"process-compose-flake": "process-compose-flake"
}
}
},
+102 -1
View File
@@ -5,6 +5,7 @@
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
crane.url = "github:ipetkov/crane";
process-compose-flake.url = "github:Platonic-Systems/process-compose-flake";
};
outputs =
@@ -15,7 +16,10 @@
"aarch64-linux"
];
imports = [ ./nix/home-manager-module.nix ];
imports = [
./nix/home-manager-module.nix
inputs.process-compose-flake.flakeModule
];
perSystem =
{
@@ -51,6 +55,34 @@
pkgs.git
];
# The dev daemon's port, shared by its bind address, the readiness
# probe, and the Vite proxy so they can never drift apart.
devDaemonPort = 7071;
# Everything `nix run .#dev` needs on PATH. Listed explicitly so the
# dev stack runs standalone, not only from inside `nix develop`.
devTools = [
# The base shell utilities. PATH is set explicitly below rather than
# inherited, and node wrapper scripts reach for sed/dirname/uname,
# which a NixOS host has nowhere near /usr/bin.
pkgs.bash
pkgs.coreutils
pkgs.gnused
pkgs.gnugrep
pkgs.findutils
pkgs.cargo
pkgs.rustc
pkgs.nodejs
pkgs.pnpm_10
# A personal ~/.cargo/config.toml commonly points the linker at
# clang+mold. Cargo reads it regardless of where the toolchain came
# from, so ship both rather than fail on a machine that has such a
# config and a nix shell that doesn't.
pkgs.clang
pkgs.mold
]
++ runtimeDeps;
maestro = craneLib.buildPackage (
commonArgs
// {
@@ -160,6 +192,75 @@
);
};
# `nix run .#dev` — the whole local stack under one supervisor:
# a debug-build daemon on 127.0.0.1:7071 (`devDaemonPort`, not the
# daemon's own default 7070) and Vite on :5173 with the SPA proxying
# `/api/*` back to that daemon (see web/vite.config.ts).
# Ctrl-C stops both.
process-compose.dev = {
# No HTTP control server: nothing here drives process-compose
# remotely, and its default :8080 collides with whatever else the
# developer happens to be running.
cli = {
environment.PC_NO_SERVER = true;
# The tmux socket the dev daemon uses (see the daemon process
# below) lives here.
preHook = ''mkdir -p "$PWD/.dev/tmux"'';
};
settings = {
# `nix run .#dev` works from a bare shell, not only from inside
# `nix develop`, so the toolchain is put on PATH here rather than
# inherited. `/usr/bin:/bin` stays last for anything else a
# spawned session reaches for.
environment = [
"PATH=${lib.makeBinPath devTools}:/usr/bin:/bin"
# Not :7070: that is the daemon's own default, and the dev
# stack must not fight a real daemon running on the same host.
"MAESTRO_DAEMON_BIND_ADDR=127.0.0.1:${toString devDaemonPort}"
"MAESTRO_DAEMON_URL=http://127.0.0.1:${toString devDaemonPort}"
];
processes = {
# Built once, up front, so the daemon and Vite don't race a
# cold `cargo build` and trip their own readiness probes.
build.command = "cargo build --bin maestro";
daemon = {
# Both values need a shell: `$PWD` is only known at run time,
# and process-compose does not expand anything in
# `environment`. A tmux socket of its own keeps dev sessions
# out of — and safe from — the tmux server belonging to
# whatever daemon the developer actually runs.
command = ''
MAESTRO_DAEMON_TMUX_TMPDIR="$PWD/.dev/tmux" \
RUST_LOG="''${RUST_LOG:-maestro=debug,info}" \
target/debug/maestro daemon
'';
depends_on.build.condition = "process_completed_successfully";
readiness_probe = {
http_get = {
host = "127.0.0.1";
port = devDaemonPort;
path = "/api/info";
};
initial_delay_seconds = 1;
period_seconds = 1;
failure_threshold = 30;
};
};
web = {
# `--host` binds 0.0.0.0: the daemon stays on loopback and only
# the UI is exposed, so the API is reachable solely through
# the proxy.
command = "pnpm -C web install && pnpm -C web dev --host";
depends_on.daemon.condition = "process_healthy";
};
};
};
};
devShells.default = craneLib.devShell {
inherit (self') checks;
packages = [