From 401afbc8125b1a17b339559e1f93d967269cd25c Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 10:53:40 +0100 Subject: [PATCH] feat(maestro): add a nix flake and Home Manager module Ships `maestro`, `maestro-web` and the SPA bundle as flake packages, plus a `services.maestro` Home Manager module that runs the daemon and the web UI as systemd user services. `maestro-web`'s build.rs now honours MAESTRO_WEB_DIST and hands the resolved bundle path to `include_dir!` through MAESTRO_WEB_BUNDLE_DIR. Without it the Nix build would have to run `pnpm install` mid-compile, which the sandbox has no network for. Unset, the pnpm path is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 4 + CLAUDE.md | 2 + crates/maestro-web/build.rs | 77 +++++++---- crates/maestro-web/src/static_bundle.rs | 16 +-- docs/INSTALL.md | 49 +++++++ flake.lock | 77 +++++++++++ flake.nix | 176 ++++++++++++++++++++++++ nix/home-manager-module.nix | 136 ++++++++++++++++++ 8 files changed, 503 insertions(+), 34 deletions(-) create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 nix/home-manager-module.nix diff --git a/.gitignore b/.gitignore index 88b236b..2ff9f6d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ Cargo.lock.bak # Local git worktrees for in-flight branches (never tracked) worktrees/ + +# nix build output symlinks +/result +/result-* diff --git a/CLAUDE.md b/CLAUDE.md index 806a325..f5388ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,8 @@ 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` +nix/ # Home Manager module (`services.maestro`) ``` See `docs/INSTALL.md` for end-user install instructions. diff --git a/crates/maestro-web/build.rs b/crates/maestro-web/build.rs index 61570cc..1b190cd 100644 --- a/crates/maestro-web/build.rs +++ b/crates/maestro-web/build.rs @@ -1,16 +1,53 @@ -//! Build the embedded SPA bundle before the crate compiles. +//! Resolve the SPA bundle that gets embedded at compile time. //! -//! `static_bundle.rs` embeds `web/dist` at compile time via `include_dir!`. -//! This script guarantees that directory holds a freshly built bundle by -//! running `pnpm install --frozen-lockfile && pnpm build` in `web/`. pnpm and -//! node are HARD requirements — a missing toolchain fails the build with an -//! actionable message rather than silently shipping the placeholder. +//! `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"); @@ -29,11 +66,11 @@ fn main() { // 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 `web/dist`, 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. + // 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()); @@ -51,20 +88,7 @@ fn main() { "pnpm build", ); - // Sanity-check that pnpm build actually produced the bundle `include_dir!` - // expects. 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!( - "`pnpm build` did not produce the expected bundle at {} \ - (missing or empty) — check the vite `outDir`.", - index_html.display() - ); - } + dist_dir } fn require_tool(tool: &str) { @@ -76,7 +100,8 @@ fn require_tool(tool: &str) { if !ok { panic!( "maestro-web requires `{tool}` to build its embedded SPA bundle. \ - Install pnpm + node (https://pnpm.io) or use a prebuilt binary." + Install pnpm + node (https://pnpm.io), or point MAESTRO_WEB_DIST \ + at a prebuilt bundle." ); } } diff --git a/crates/maestro-web/src/static_bundle.rs b/crates/maestro-web/src/static_bundle.rs index 0ef6b19..af635e9 100644 --- a/crates/maestro-web/src/static_bundle.rs +++ b/crates/maestro-web/src/static_bundle.rs @@ -1,18 +1,18 @@ -//! Embedded `web/dist` SPA bundle. +//! Embedded SPA bundle. //! -//! The build of `web/` is consumed at compile time via `include_dir!`, which -//! requires the directory to exist. `build.rs` runs `pnpm build` before the -//! crate compiles, so `web/dist` is always present and populated — even on a -//! fresh checkout (`web/dist` is gitignored and never committed). The -//! placeholder below is therefore defensive only; a successful build always -//! embeds the real bundle. +//! The bundle is consumed at compile time via `include_dir!`, reading whatever +//! directory `build.rs` resolved into `MAESTRO_WEB_BUNDLE_DIR` — either the +//! `web/dist` it just built with pnpm, or a prebuilt bundle handed over in +//! `MAESTRO_WEB_DIST`. Either way `build.rs` fails loudly if that directory +//! holds no `index.html`, so the placeholder below is defensive only; a +//! successful build always embeds the real bundle. use axum::body::Body; use axum::http::{header, StatusCode}; use axum::response::{IntoResponse, Response}; use include_dir::{include_dir, Dir}; -static BUNDLE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../web/dist"); +static BUNDLE: Dir<'_> = include_dir!("$MAESTRO_WEB_BUNDLE_DIR"); /// Serve `/index.html`, falling back to a tiny placeholder when the bundle /// hasn't been built yet. Used both for `GET /` and SPA-route fallthrough diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 2106eab..8056970 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -49,6 +49,55 @@ The wizard: The binary lands in `~/.cargo/bin/maestro` — make sure that's on your `$PATH`. +## Nix / NixOS + +The repo is a flake. It exposes two packages and a Home Manager module: + +```sh +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` +and `git` are on its `PATH`. `packages.maestro-web` embeds a prebuilt SPA +bundle — the Nix build points `MAESTRO_WEB_DIST` at `packages.webFrontend`, so +`build.rs` skips pnpm entirely and no network fetch happens mid-compile. Set +that variable yourself to reuse a bundle built elsewhere; leave it unset and +the build falls back to running `pnpm install && pnpm build` in `web/`. + +Home Manager, with both services running as *user* units (same trust boundary +as the tmux server they wrap): + +```nix +{ + inputs.maestro.url = "git+https://git.naps.pt/naps62/maestro"; + + # in your Home Manager configuration: + imports = [ inputs.maestro.homeManagerModules.default ]; + + services.maestro = { + enable = true; # installs the binary, runs `maestro daemon` + web.enable = true; # runs `maestro-web` + settings = { + 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 +below; every field has a default, so `{}` writes no file at all. There is no +auth layer — bind the web UI 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 +running a local daemon, for a host that only talks to another machine's. + +The daemon unit carries the same `KillMode=process` as the wizard-written one, +so `systemctl --user restart maestro-daemon` leaves the tmux server and every +session alive. + ## Thin builds for single-role hosts The default install pulls in everything (`cli` + `daemon` + `tui`). Single-role diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..34e9417 --- /dev/null +++ b/flake.lock @@ -0,0 +1,77 @@ +{ + "nodes": { + "crane": { + "locked": { + "lastModified": 1787326676, + "narHash": "sha256-lWhBbBvC05/xwivKBBiM2YNizpmgqCgyOIzomvRuwxs=", + "owner": "ipetkov", + "repo": "crane", + "rev": "692f7e9ef2ece8125b466f66f2af532b3edaed0d", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1785627969, + "narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1787135253, + "narHash": "sha256-RD2kNWCG+Bjo6h+JVjWVNntZs2GtRoeY2xHjts/FNkA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "ffb3c9b700e759be2ef13237c9d8f953b32a1e46", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1785031560, + "narHash": "sha256-OmshNvn2vupOFpYinLUu+1Dnpu4n7Q5N3ggGVNHpkUI=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "0e79af5e3d4dcfcd676ab5ba3f95d2e3352e078c", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "root": { + "inputs": { + "crane": "crane", + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d4f3e99 --- /dev/null +++ b/flake.nix @@ -0,0 +1,176 @@ +{ + description = "Maestro — terminal-session orchestrator for one machine"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-parts.url = "github:hercules-ci/flake-parts"; + crane.url = "github:ipetkov/crane"; + }; + + outputs = + inputs@{ flake-parts, ... }: + flake-parts.lib.mkFlake { inherit inputs; } { + systems = [ + "x86_64-linux" + "aarch64-linux" + ]; + + imports = [ ./nix/home-manager-module.nix ]; + + perSystem = + { + lib, + pkgs, + self', + ... + }: + let + craneLib = inputs.crane.mkLib pkgs; + + version = (lib.importTOML ./Cargo.toml).workspace.package.version; + + # Cargo source filtering keeps only *.rs, *.toml and Cargo.lock. The + # SPA is built separately (below) and handed to the Rust build as a + # path, so nothing under web/ needs to be in this source tree. + commonArgs = { + src = craneLib.cleanCargoSource ./.; + inherit version; + strictDeps = true; + # rustls everywhere (reqwest, tokio-tungstenite): no openssl, no + # pkg-config, nothing to link against. + buildInputs = [ ]; + nativeBuildInputs = [ ]; + }; + + cargoArtifacts = craneLib.buildDepsOnly (commonArgs // { pname = "maestro-workspace"; }); + + # Runtime dependencies the daemon shells out to. Suffixed onto PATH so + # a user's own tmux/git still wins inside spawned sessions. + runtimeDeps = [ + pkgs.tmux + pkgs.git + ]; + + maestro = craneLib.buildPackage ( + commonArgs + // { + inherit cargoArtifacts; + pname = "maestro"; + cargoExtraArgs = "--package maestro"; + doCheck = false; + nativeBuildInputs = commonArgs.nativeBuildInputs ++ [ pkgs.makeWrapper ]; + postInstall = '' + wrapProgram $out/bin/maestro \ + --suffix PATH : ${lib.makeBinPath runtimeDeps} + ''; + meta = { + description = "Unified maestro binary: CLI + per-host daemon + TUI"; + homepage = "https://git.naps.pt/naps62/maestro"; + license = lib.licenses.mit; + platforms = lib.platforms.linux; + mainProgram = "maestro"; + }; + } + ); + + # The SPA, built on its own so the Rust build never needs node or a + # network fetch. `pnpm.fetchDeps` pins the store from pnpm-lock.yaml; + # bump `hash` whenever that lockfile changes (set it to + # lib.fakeHash, build, copy the `got:` value). + webFrontend = pkgs.stdenv.mkDerivation (finalAttrs: { + pname = "maestro-web-frontend"; + inherit version; + src = ./web; + + nativeBuildInputs = [ + pkgs.nodejs + # pnpm 10, not the default 11: web/pnpm-lock.yaml is + # lockfileVersion 9.0, and package.json pins pnpm@9.15.4. + pkgs.pnpm_10 + (pkgs.pnpmConfigHook.override { pnpm = pkgs.pnpm_10; }) + ]; + + pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) { + inherit (finalAttrs) pname version src; + fetcherVersion = 3; + hash = "sha256-Lhi41AtCK69SM4+7zih19+Njf7o2hyXrAV0W6VO9YxU="; + }; + + buildPhase = '' + runHook preBuild + pnpm build + runHook postBuild + ''; + + # $out IS the bundle directory — that is what MAESTRO_WEB_DIST and + # `include_dir!` expect, so index.html sits at its root. + installPhase = '' + runHook preInstall + cp -r dist $out + runHook postInstall + ''; + + meta = { + description = "Maestro browser SPA bundle"; + 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 + { + packages = { + default = maestro; + inherit maestro maestro-web webFrontend; + }; + + checks = { + inherit maestro maestro-web; + + maestro-fmt = craneLib.cargoFmt { inherit (commonArgs) src; }; + + maestro-clippy = craneLib.cargoClippy ( + commonArgs + // { + inherit cargoArtifacts; + pname = "maestro-clippy"; + # maestro-web is excluded: clippy on it would run build.rs, and + # a workspace-wide MAESTRO_WEB_DIST would make every other + # crate's cache key depend on the frontend. `checks.maestro-web` + # already compiles it. + cargoClippyExtraArgs = "--workspace --exclude maestro-web --all-targets -- --deny warnings"; + } + ); + }; + + devShells.default = craneLib.devShell { + inherit (self') checks; + packages = [ + pkgs.rust-analyzer + pkgs.tmux + pkgs.git + pkgs.just + pkgs.nodejs + pkgs.pnpm_10 + ]; + }; + }; + }; +} diff --git a/nix/home-manager-module.nix b/nix/home-manager-module.nix new file mode 100644 index 0000000..14f3757 --- /dev/null +++ b/nix/home-manager-module.nix @@ -0,0 +1,136 @@ +# Home Manager module: `services.maestro`. +# +# Maestro owns the user's interactive shells and tmux sessions, so both units +# are *user* services, not system ones — same trust boundary as the tmux server +# they wrap. +{ self, ... }: +{ + flake.homeManagerModules.default = + { + config, + lib, + pkgs, + ... + }: + let + cfg = config.services.maestro; + tomlFormat = pkgs.formats.toml { }; + + # A user unit inherits almost no PATH. Give the daemon the profile dirs a + # login shell would have, so the sessions it spawns can find the user's + # own tools. + profilePath = lib.concatStringsSep ":" [ + "%h/.local/bin" + "%h/.nix-profile/bin" + "/etc/profiles/per-user/%u/bin" + "/run/current-system/sw/bin" + ]; + in + { + options.services.maestro = { + enable = lib.mkEnableOption "Maestro — installs the CLI/TUI and, unless disabled, runs the daemon"; + + package = lib.mkOption { + type = lib.types.package; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.maestro; + defaultText = lib.literalExpression "maestro.packages.\${system}.maestro"; + description = "The unified `maestro` binary (CLI + daemon + TUI)."; + }; + + daemon.enable = lib.mkOption { + type = lib.types.bool; + default = true; + 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 { + type = tomlFormat.type; + default = { }; + example = lib.literalExpression '' + { + daemon.bind_addr = "127.0.0.1:7070"; + web.bind_addr = "0.0.0.0:8081"; + } + ''; + description = '' + Contents of `~/.config/maestro/config.toml`, in the schema + `maestro-config` defines. Every field has a default, so `{}` is + valid and writes no file. There is no auth layer — bind the web UI + to a LAN/VPN address only on a trusted segment, never a public IP. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + home.packages = [ cfg.package ] ++ lib.optional cfg.web.enable cfg.web.package; + + xdg.configFile."maestro/config.toml" = lib.mkIf (cfg.settings != { }) { + source = tomlFormat.generate "maestro-config.toml" cfg.settings; + }; + + systemd.user.services = lib.mkMerge [ + (lib.mkIf cfg.daemon.enable { + maestro-daemon = { + Unit = { + Description = "maestro daemon — tmux wrapper + session API"; + After = [ "network.target" ]; + # MUST stay 0: a fast-crashing daemon would burn the default + # 5-starts-per-10s budget and park the unit in `failed` until a + # manual `systemctl --user reset-failed`. + 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" ]; + }; + }) + + (lib.mkIf cfg.web.enable { + maestro-web = { + Unit = { + Description = "maestro-web — browser UI for the local maestro daemon"; + After = [ "network.target" ] ++ lib.optional cfg.daemon.enable "maestro-daemon.service"; + Wants = lib.optional cfg.daemon.enable "maestro-daemon.service"; + StartLimitIntervalSec = 0; + }; + Service = { + Type = "simple"; + WorkingDirectory = "%h"; + ExecStart = lib.getExe cfg.web.package; + Environment = [ "PATH=${profilePath}" ]; + Restart = "always"; + RestartSec = 2; + }; + Install.WantedBy = [ "default.target" ]; + }; + }) + ]; + }; + }; +}