From 53660407e362babe5a1bc4852e63938b55c8addc Mon Sep 17 00:00:00 2001 From: "naps62-yolo (agent)" Date: Sun, 23 Aug 2026 10:02:09 +0100 Subject: [PATCH] feat(maestro): add a process-compose dev stack (#52) --- .gitignore | 6 ++ CLAUDE.md | 9 ++- Justfile | 5 ++ crates/maestro-daemon/src/tmux.rs | 37 +++++++++++ flake.lock | 18 +++++- flake.nix | 103 +++++++++++++++++++++++++++++- 6 files changed, 175 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 2ff9f6d..63d51bf 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,9 @@ worktrees/ # nix build output symlinks /result /result-* + +# direnv (`use flake`) +/.direnv/ + +# `nix run .#dev` scratch state (tmux socket) +/.dev/ diff --git a/CLAUDE.md b/CLAUDE.md index 3e8cb2a..cfa0ac5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/Justfile b/Justfile index 368911d..c50ba98 100644 --- a/Justfile +++ b/Justfile @@ -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 diff --git a/crates/maestro-daemon/src/tmux.rs b/crates/maestro-daemon/src/tmux.rs index 10030ef..e0f7720 100644 --- a/crates/maestro-daemon/src/tmux.rs +++ b/crates/maestro-daemon/src/tmux.rs @@ -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 { diff --git a/flake.lock b/flake.lock index 34e9417..5e1bc47 100644 --- a/flake.lock +++ b/flake.lock @@ -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" } } }, diff --git a/flake.nix b/flake.nix index d4f3e99..5f11588 100644 --- a/flake.nix +++ b/flake.nix @@ -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 = [