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:
co-authored by
Claude Opus 5
parent
c856333e4a
commit
401afbc812
@@ -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-*
|
||||||
|
|||||||
@@ -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
@@ -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."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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" ];
|
||||||
|
};
|
||||||
|
})
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user