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