{ 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-FEWOAtSFvOWHeCNR0RK8JQ/ssW31qmShu1YD2GuqioQ="; }; 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. # Every other default feature is named back explicitly — # `--no-default-features` would otherwise drop them from the # lint, leaving that code unchecked. cargoClippyExtraArgs = "--workspace --all-targets --no-default-features --features cli,daemon,tui,haiku-autoname -- --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. This covers the build and Vite; the daemon widens it # further for the sessions it spawns (see `daemon.command`). 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. # # A spawned session inherits the daemon's PATH, and the # toolchain PATH above holds build tools only — on NixOS the # trailing `/usr/bin:/bin` is all but empty, so every session # opened from the dev UI came up unable to find `claude`, # `zsh` or anything else the developer actually has. Append # the usual user profile directories so dev sessions behave # like real ones. Resolved by the shell at run time; the ones # that do not exist on a given host cost nothing. command = '' PATH="$PATH:$HOME/.nix-profile/bin:/etc/profiles/per-user/$(id -un)/bin:/run/current-system/sw/bin:/usr/local/bin" \ 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 ]; }; }; }; }