This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files

297 lines
12 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-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
];
};
};
};
}