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) <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-22 10:53:40 +01:00
co-authored by Claude Opus 5
parent c856333e4a
commit 401afbc812
8 changed files with 503 additions and 34 deletions
+4
View File
@@ -5,3 +5,7 @@ Cargo.lock.bak
# Local git worktrees for in-flight branches (never tracked) # Local git worktrees for in-flight branches (never tracked)
worktrees/ worktrees/
# nix build output symlinks
/result
/result-*
+2
View File
@@ -57,6 +57,8 @@ crates/
├── 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)
flake.nix # Nix flake: `maestro`, `maestro-web`, `webFrontend`
nix/ # Home Manager module (`services.maestro`)
``` ```
See `docs/INSTALL.md` for end-user install instructions. See `docs/INSTALL.md` for end-user install instructions.
+51 -26
View File
@@ -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!`. //! `static_bundle.rs` embeds a directory via `include_dir!`, and the directory
//! This script guarantees that directory holds a freshly built bundle by //! it reads is whatever this script puts in `MAESTRO_WEB_BUNDLE_DIR`.
//! running `pnpm install --frozen-lockfile && pnpm build` in `web/`. pnpm and //!
//! node are HARD requirements — a missing toolchain fails the build with an //! Two modes:
//! actionable message rather than silently shipping the placeholder. //!
//! * `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::env;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command; use std::process::Command;
fn main() { 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 = let manifest_dir =
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR set by cargo")); PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR set by cargo"));
let web_dir = manifest_dir.join("../../web"); let web_dir = manifest_dir.join("../../web");
@@ -29,11 +66,11 @@ fn main() {
// Also watch the embedded OUTPUT directory. The input watches above decide // Also watch the embedded OUTPUT directory. The input watches above decide
// whether THIS script re-runs and regenerates `web/dist`; but `include_dir!` // 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 // in `static_bundle.rs` reads that directory, and cargo has no other
// dependency on those output files. Without this watch, an incremental build // declared dependency on those output files. Without this watch, an
// could regenerate `web/dist` yet keep the OLD bundle embedded. Tying the // incremental build could regenerate `web/dist` yet keep the OLD bundle
// crate's recompilation to the regenerated output makes the fresh assets // embedded. Tying the crate's recompilation to the regenerated output makes
// actually get re-embedded. // the fresh assets actually get re-embedded.
let dist_dir = web_dir.join("dist"); let dist_dir = web_dir.join("dist");
println!("cargo:rerun-if-changed={}", dist_dir.display()); println!("cargo:rerun-if-changed={}", dist_dir.display());
@@ -51,20 +88,7 @@ fn main() {
"pnpm build", "pnpm build",
); );
// Sanity-check that pnpm build actually produced the bundle `include_dir!` dist_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()
);
}
} }
fn require_tool(tool: &str) { fn require_tool(tool: &str) {
@@ -76,7 +100,8 @@ fn require_tool(tool: &str) {
if !ok { if !ok {
panic!( panic!(
"maestro-web requires `{tool}` to build its embedded SPA bundle. \ "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."
); );
} }
} }
+8 -8
View File
@@ -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 //! The bundle is consumed at compile time via `include_dir!`, reading whatever
//! requires the directory to exist. `build.rs` runs `pnpm build` before the //! directory `build.rs` resolved into `MAESTRO_WEB_BUNDLE_DIR` — either the
//! crate compiles, so `web/dist` is always present and populated — even on a //! `web/dist` it just built with pnpm, or a prebuilt bundle handed over in
//! fresh checkout (`web/dist` is gitignored and never committed). The //! `MAESTRO_WEB_DIST`. Either way `build.rs` fails loudly if that directory
//! placeholder below is therefore defensive only; a successful build always //! holds no `index.html`, so the placeholder below is defensive only; a
//! embeds the real bundle. //! successful build always embeds the real bundle.
use axum::body::Body; use axum::body::Body;
use axum::http::{header, StatusCode}; use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use include_dir::{include_dir, Dir}; 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 /// 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 /// hasn't been built yet. Used both for `GET /` and SPA-route fallthrough
+49
View File
@@ -49,6 +49,55 @@ The wizard:
The binary lands in `~/.cargo/bin/maestro` — make sure that's on your `$PATH`. 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 ## 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`). Single-role
Generated
+77
View File
@@ -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
}
+176
View File
@@ -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
];
};
};
};
}
+136
View File
@@ -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" ];
};
})
];
};
};
}