The browser reached the daemon through maestro-web, a second binary that served the SPA and reverse-proxied /api/* — two TCP connections and a WebSocket frame re-encode on every keystroke and every byte of PTY output. On one machine that hop buys nothing. The daemon now embeds the bundle and serves it off its own listener, behind a default-on `web` feature. `--no-default-features` builds without pnpm or node; MAESTRO_WEB_DIST still takes a prebuilt bundle. The router's fallback answers an unmatched /api/* with 404 rather than the SPA shell, so a client expecting JSON gets a status it can read instead of HTML. Removes maestro-web and the [web] config section. An existing [web] block is ignored, not rejected. The installer deletes a leftover maestro-web.service on upgrade. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
284 lines
11 KiB
Nix
284 lines
11 KiB
Nix
{
|
|
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";
|
|
process-compose-flake.url = "github:Platonic-Systems/process-compose-flake";
|
|
};
|
|
|
|
outputs =
|
|
inputs@{ flake-parts, ... }:
|
|
flake-parts.lib.mkFlake { inherit inputs; } {
|
|
systems = [
|
|
"x86_64-linux"
|
|
"aarch64-linux"
|
|
];
|
|
|
|
imports = [
|
|
./nix/home-manager-module.nix
|
|
inputs.process-compose-flake.flakeModule
|
|
];
|
|
|
|
perSystem =
|
|
{
|
|
lib,
|
|
pkgs,
|
|
self',
|
|
...
|
|
}:
|
|
let
|
|
craneLib = inputs.crane.mkLib pkgs;
|
|
|
|
version = (lib.importTOML ./Cargo.toml).workspace.package.version;
|
|
|
|
# Build provenance for `GET /api/info`. The Rust source tree crane
|
|
# hands to the compiler has no `.git`, so `maestro-daemon/build.rs`
|
|
# cannot ask git itself — the flake's own revision is passed instead.
|
|
# A dirty tree has no `rev`, and `dirtyRev` carries a `-dirty` suffix.
|
|
rev = inputs.self.rev or inputs.self.dirtyRev or "";
|
|
# `lastModifiedDate` is `YYYYMMDDhhmmss` in UTC; clients want RFC 3339.
|
|
ld = inputs.self.lastModifiedDate or "";
|
|
iso =
|
|
if ld == "" then
|
|
""
|
|
else
|
|
"${builtins.substring 0 4 ld}-${builtins.substring 4 2 ld}-${builtins.substring 6 2 ld}"
|
|
+ "T${builtins.substring 8 2 ld}:${builtins.substring 10 2 ld}:${builtins.substring 12 2 ld}Z";
|
|
buildStamp = {
|
|
MAESTRO_BUILD_COMMIT = builtins.substring 0 12 rev;
|
|
MAESTRO_BUILD_DATE = iso;
|
|
MAESTRO_BUILD_DIRTY = if inputs.self ? rev then "0" else "1";
|
|
};
|
|
|
|
# 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
|
|
];
|
|
|
|
# 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
|
|
// buildStamp
|
|
// {
|
|
inherit cargoArtifacts;
|
|
pname = "maestro";
|
|
cargoExtraArgs = "--package maestro";
|
|
doCheck = false;
|
|
# Makes `maestro-daemon/build.rs` embed this bundle as-is instead
|
|
# of shelling out to pnpm, which the sandbox has no network for.
|
|
MAESTRO_WEB_DIST = webFrontend;
|
|
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-AvIrpXipbcp7hRujh/L4UAc6tAJOxe67P/iji6L/oL0=";
|
|
};
|
|
|
|
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;
|
|
};
|
|
});
|
|
in
|
|
{
|
|
packages = {
|
|
default = maestro;
|
|
inherit maestro webFrontend;
|
|
};
|
|
|
|
checks = {
|
|
inherit maestro;
|
|
|
|
maestro-fmt = craneLib.cargoFmt { inherit (commonArgs) src; };
|
|
|
|
maestro-clippy = craneLib.cargoClippy (
|
|
commonArgs
|
|
// {
|
|
inherit cargoArtifacts;
|
|
pname = "maestro-clippy";
|
|
# The `web` feature is off here: with it on, maestro-daemon's
|
|
# build.rs would run pnpm, and a workspace-wide MAESTRO_WEB_DIST
|
|
# would make every other crate's cache key depend on the
|
|
# frontend. `checks.maestro` already compiles it with web on.
|
|
cargoClippyExtraArgs = "--workspace --all-targets --no-default-features --features cli,daemon,tui -- --deny warnings";
|
|
}
|
|
);
|
|
};
|
|
|
|
# `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.
|
|
# `web` is off here: Vite serves the UI in dev, so embedding a
|
|
# pnpm-built bundle in the daemon would only slow startup.
|
|
build.command = "cargo build --bin maestro --no-default-features --features cli,daemon,tui";
|
|
|
|
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 = [
|
|
pkgs.rust-analyzer
|
|
pkgs.tmux
|
|
pkgs.git
|
|
pkgs.just
|
|
pkgs.nodejs
|
|
pkgs.pnpm_10
|
|
];
|
|
};
|
|
};
|
|
};
|
|
}
|