From fb5f9d701f17242b1503d357274432e36c62f3b1 Mon Sep 17 00:00:00 2001 From: Rice <85912192+Riceman2000@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:05:14 -0400 Subject: [PATCH] feat: Nix package and NixOS module for the release binaries (#1) * feat: add a Nix flake and NixOS module for the release binaries Package the official Linux tarballs (x86_64 and aarch64), put the dlopen'd libraries on the rpath, and add services.backplane with a TCG-friendly nixosTest. Co-authored-by: Rice * fix: keep the library path out of builtins.toFile Nix rejects a toFile script that mentions a derivation. Substitute @libPath@ at install time, and use the top-level libx11 names. Co-authored-by: Rice * fix: call testers.nixosTest on nixpkgs 26.05 pkgs.nixosTest was renamed. Give the default app a description so flake check does not warn. Co-authored-by: Rice * fix: merge eval-check options without dropping enable A shallow // replaced services.backplane, so the secret-path cases never enabled the module and the assertions were not checked. Co-authored-by: Rice * fix: run the NixOS test without requiring KVM QEMU still uses accel=kvm:tcg, so a builder with no KVM emulates instead of being refused the kvm system feature. Co-authored-by: Rice * fix: pass requiredFeatures through runNixOSTest The legacy nixosTest wrapper rejects requiredFeatures. The module interface can turn the kvm feature off so the test emulates. Co-authored-by: Rice * fix: wait for multi-user instead of the linger oneshot linger-users.service is Type=oneshot without RemainAfterExit, so a successful run is inactive and wait_for_unit treats it as a failure. The test checks the linger file instead. Co-authored-by: Rice * docs: move Nix notes out of the project README The Nix and NixOS write-up lives in nix/README.md. The project README only links to it. The service no longer filters the pairing token: the journal keeps the line the hub prints. Co-authored-by: Rice --------- Co-authored-by: Cursor Agent Co-authored-by: Rice --- README.md | 2 +- flake.lock | 27 +++ flake.nix | 65 ++++++ nix/README.md | 68 ++++++ nix/checks.nix | 246 ++++++++++++++++++++++ nix/module.nix | 549 ++++++++++++++++++++++++++++++++++++++++++++++++ nix/package.nix | 153 ++++++++++++++ nix/sources.nix | 19 ++ nix/update.sh | 100 +++++++++ 9 files changed, 1228 insertions(+), 1 deletion(-) create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 nix/README.md create mode 100644 nix/checks.nix create mode 100644 nix/module.nix create mode 100644 nix/package.nix create mode 100644 nix/sources.nix create mode 100755 nix/update.sh diff --git a/README.md b/README.md index 4f468c2eb0..ad9dce5085 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ curl -fsSL https://github.com/i2cjak/Backplane/releases/latest/download/install. backplane ``` -Linux (x64, arm64) and macOS (Apple silicon). It installs into `~/.local/share/backplane` and keeps itself up to date (`BACKPLANE_NO_UPDATE=1` turns that off). Releases also carry an AppImage, a `.deb`, an AUR `PKGBUILD` and the Android APK. You need at least one agent CLI (`claude`, `codex`, `grok` or `muse`; Antigravity installs from Settings) and KiCad; FreeCAD for mechanical work. `backplane --headless` runs it without a window. To pair a phone, scan the QR code in Settings (Pairing link, QR code) with the app, or paste the pairing link. +Linux (x64, arm64) and macOS (Apple silicon). It installs into `~/.local/share/backplane` and keeps itself up to date (`BACKPLANE_NO_UPDATE=1` turns that off). Releases also carry an AppImage, a `.deb`, an AUR `PKGBUILD` and the Android APK. Nix and NixOS installs are in [nix/README.md](nix/README.md). You need at least one agent CLI (`claude`, `codex`, `grok` or `muse`; Antigravity installs from Settings) and KiCad; FreeCAD for mechanical work. `backplane --headless` runs it without a window. To pair a phone, scan the QR code in Settings (Pairing link, QR code) with the app, or paste the pairing link. On a Mac there is no native window yet: `backplane` runs the hub and you use it in the browser at http://127.0.0.1:3787 (and from your phone, as anywhere). Add `~/.local/bin` to your `PATH` if the installer says so. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000000..caf5cd5762 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1791662279, + "narHash": "sha256-xI8L+UwJLIg75yVJwLPsY+AUEP1teCT3FyuvodyJu2Y=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "6fcc160353f63555b560f25588925133c8f61f2a", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-26.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000000..7d899475db --- /dev/null +++ b/flake.nix @@ -0,0 +1,65 @@ +{ + description = "Backplane, packaged from the official Linux release"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; + + outputs = { + self, + nixpkgs, + }: let + systems = ["x86_64-linux" "aarch64-linux"]; + forAllSystems = f: + nixpkgs.lib.genAttrs systems ( + system: + f ( + import nixpkgs { + inherit system; + overlays = [self.overlays.default]; + } + ) + ); + in { + overlays.default = final: _prev: { + backplane = final.callPackage ./nix/package.nix {}; + }; + + packages = forAllSystems (pkgs: { + default = pkgs.backplane; + inherit (pkgs) backplane; + }); + + apps = forAllSystems (pkgs: { + default = { + type = "app"; + program = "${pkgs.backplane}/bin/backplane"; + meta.description = "Run the Backplane window and hub"; + }; + }); + + # `nix fmt` with no arguments feeds stdin to a bare alejandra, which + # then errors. Format the flake root (or the paths nix forwards). + formatter = forAllSystems ( + pkgs: + pkgs.writeShellScriptBin "alejandra" '' + if [ "$#" -eq 0 ]; then + set -- "''${PRJ_ROOT:-.}" + fi + exec ${pkgs.alejandra}/bin/alejandra "$@" + '' + ); + + nixosModules.default = import ./nix/module.nix; + + checks = forAllSystems ( + pkgs: + import ./nix/checks.nix { + inherit pkgs; + inherit (nixpkgs) lib; + inherit nixpkgs; + module = self.nixosModules.default; + package = pkgs.backplane; + sources = import ./nix/sources.nix; + } + ); + }; +} diff --git a/nix/README.md b/nix/README.md new file mode 100644 index 0000000000..127ddde897 --- /dev/null +++ b/nix/README.md @@ -0,0 +1,68 @@ +# Nix / NixOS + +The flake packages the official Linux release binaries (`x86_64-linux` and `aarch64-linux`) from [i2cjak/Backplane](https://github.com/i2cjak/Backplane). It does not compile Backplane. nixpkgs is pinned to `nixos-26.05`. `packages.default` is `backplane`; `overlays.default` adds that package; `apps.default` runs it; `nix fmt` is alejandra. + +```sh +nix run . # the window binary; a hub starts with it +nix build +nix flake check +``` + +There is no `--version`. An unknown flag is ignored, and a hub would start on the default home. The version is the `backplane` field of `GET /hello`, which is CBOR, not a text banner. The flake's `version` check starts the packaged binary and reads that. + +A NixOS configuration: + +```nix +{ + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; + inputs.backplane.url = "github:Riceman2000/Backplane"; + + outputs = { nixpkgs, backplane, ... }: { + nixosConfigurations.box = nixpkgs.lib.nixosSystem { + modules = [ + backplane.nixosModules.default + { nixpkgs.overlays = [ backplane.overlays.default ]; } + ({ pkgs, ... }: { + services.backplane = { + enable = true; + # Loopback unless you mean to publish the hub. + host = "127.0.0.1"; + port = 3787; + openFirewall = false; + tailscale.enable = true; + extraPackages = [ pkgs.git pkgs.kicad pkgs.freecad ]; + # Strings, not path literals. A path would be copied into the + # Nix store, and the module rejects store paths. + environmentFile = "/run/secrets/backplane.env"; + credentials.CLAUDE_CODE_OAUTH_TOKEN = "/run/secrets/claude-token"; + # A tree containing skills/pcb/SKILL.md. Omit it and the hub + # downloads the pin itself (needs a network). + # kistack.source = ./kistack; + # kistack.readOnly = true; + }; + }) + ]; + }; + }; +} +``` + +The service runs `backplane-serve` (it stays in the foreground; `backplane --headless` without `--foreground` detaches). State is `--home` (`/var/lib/backplane` by default), owned by the `backplane` user. `XDG_CACHE_HOME` is `/var/cache/backplane`. The package and the unit set `BACKPLANE_NO_UPDATE=1`. The account lingers so upstream can put each agent in a `systemd-run --user` scope; without a user manager those agents run unconfined. + +`tailscale.enable` puts `tailscale` on `PATH` and orders the service after `tailscaled.service` and `tailscaled-autoconnect.service` with `Wants=`, so a machine without the autoconnect unit still boots. Upstream reads `tailscale ip -4` once at start. + +KiStack is hardcoded to `$HOME/.cache/backplane/kistack` (not `XDG_CACHE_HOME`). Upstream writes into that tree and, on a later start, prefers whatever `current` names, which a background refresh sets to GitHub HEAD. There is no upstream switch for a pin or a read-only tree. `kistack.source` is copied into that cache; `kistack.readOnly` (the default) then points `current` at `kistack.revision` and removes write permission. The refresh's failed writes are ignored. + +The pairing token is 128 hex characters in `/token` (mode 0600). Upstream prints it on stdout only when the tailnet listener binds (`pairing token `, and `#token=` with `--tailscale-https`). The service leaves that line in the journal. With `--no-tailscale` the token is not printed. + +`environmentFile` and `credentials` must be absolute paths outside `/nix/store`. Credential files are loaded with `LoadCredential` and exported under the attribute name. A `PathChanged` path unit (not `PathExists`) restarts the hub when one of those files changes. Agents inherit the hub environment from when they were spawned; upstream does not re-read it. + +To bump the pin, from a checkout: + +```sh +nix/update.sh # latest stable GitHub release +nix/update.sh v0.11.1 # one tag +nix fmt +``` + +`nix/update.sh` rewrites `nix/sources.nix` from that release's `SHA256SUMS` and from the tag's `KiStack.revision()` in `src/server/kicad.bend`. It needs `curl` and `python3`. diff --git a/nix/checks.nix b/nix/checks.nix new file mode 100644 index 0000000000..f49c42cbfc --- /dev/null +++ b/nix/checks.nix @@ -0,0 +1,246 @@ +# Checks for the flake. The hub has no --version flag: an unknown argument +# is ignored and a hub starts. `version` is the equivalent, GET /hello. +{ + pkgs, + lib, + nixpkgs, + module, + package, + sources, +}: let + version = sources.version; + revision = sources.kistackRevision; + fixture = pkgs.runCommand "kistack-fixture" {} '' + mkdir -p "$out/skills/pcb" "$out/.claude-plugin" + printf '%s\n' '# pcb' > "$out/skills/pcb/SKILL.md" + printf '%s\n' '{"name":"kistack","description":"fixture"}' > "$out/.claude-plugin/plugin.json" + ''; + # recursiveUpdate: a plain // replaces services.backplane and drops enable. + minimal = extra: + lib.recursiveUpdate + { + system.stateVersion = "26.05"; + boot.loader.grub.enable = false; + fileSystems."/" = { + device = "nodev"; + fsType = "tmpfs"; + }; + services.backplane.enable = true; + services.backplane.package = package; + } + extra; + eval = extra: + nixpkgs.lib.nixosSystem { + system = pkgs.stdenv.hostPlatform.system; + modules = [ + module + (minimal extra) + ]; + }; + good = eval {}; + badFile = eval {services.backplane.environmentFile = "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-secret";}; + badCred = eval {services.backplane.credentials.TOKEN = "/nix/store/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-token";}; + withTailscale = eval {services.backplane.tailscale.enable = true;}; + failed = system: lib.filter (a: !a.assertion) system.config.assertions; + mentions = system: needle: + lib.any (a: lib.hasInfix needle a.message) (failed system); +in { + # Evaluating the module must accept a normal config, reject store paths, + # and order the tailnet listener with Wants= rather than Requires=. + eval = + if !mentions good "backplane" && mentions badFile "/nix/store" && mentions badCred "/nix/store" + then let + svc = withTailscale.config.systemd.services.backplane; + in + if + lib.elem "tailscaled.service" svc.wants + && lib.elem "tailscaled-autoconnect.service" svc.wants + && lib.elem "tailscaled-autoconnect.service" svc.after + && !(lib.elem "tailscaled-autoconnect.service" (svc.requires or [])) + then pkgs.runCommand "backplane-eval" {} "touch $out" + else throw "tailscaled-autoconnect.service was not an optional (Wants=/After=) dependency" + else throw "Backplane module assertions did not reject /nix/store secrets (or a normal config was rejected)"; + + # Equivalent of `backplane --version`, which upstream does not implement. + version = + pkgs.runCommand "backplane-version-${version}" { + nativeBuildInputs = [pkgs.curl]; + } '' + set -eu + home=$(mktemp -d) + rev=${lib.escapeShellArg revision} + mkdir -p "$home/.cache/backplane/kistack/$rev/skills/pcb" + mkdir -p "$home/.cache/backplane/kistack/$rev/.claude-plugin" + printf '%s\n' '{"name":"kistack","description":"fixture"}' > "$home/.cache/backplane/kistack/$rev/.claude-plugin/plugin.json" + printf '%s\n' '# pcb' > "$home/.cache/backplane/kistack/$rev/skills/pcb/SKILL.md" + printf '%s\n' "$rev" > "$home/.cache/backplane/kistack/current" + # The wrapper uses set -u and must tolerate an unset LD_LIBRARY_PATH. + unset LD_LIBRARY_PATH || true + sh -n ${package}/bin/backplane + grep -q 'LD_LIBRARY_PATH:-' ${package}/bin/backplane + export HOME="$home" + # --gpu is a Bend runtime flag and has to come before --. + # --headless without --foreground detaches; the check must wait on this pid. + ${package}/bin/backplane --gpu off -- --headless --foreground --no-tailscale \ + --home "$home" --host 127.0.0.1 --port 37871 \ + --web ${package}/libexec/backplane/web \ + >"$home/log" 2>&1 & + pid=$! + cleanup() { + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + } + trap cleanup EXIT + ok=0 + i=0 + while [ "$i" -lt 90 ]; do + if curl -sf "http://127.0.0.1:37871/hello" -o "$home/hello"; then + ok=1 + break + fi + if ! kill -0 "$pid" 2>/dev/null; then + echo "backplane exited before /hello" >&2 + cat "$home/log" >&2 + exit 1 + fi + i=$((i + 1)) + sleep 1 + done + if [ "$ok" != 1 ]; then + echo "timed out waiting for /hello" >&2 + cat "$home/log" >&2 + exit 1 + fi + if ! grep -a -q ${lib.escapeShellArg version} "$home/hello"; then + echo "hello did not contain ${version}" >&2 + cat "$home/hello" >&2 || true + exit 1 + fi + if ! grep -a -q backplane "$home/hello"; then + echo "hello did not contain the backplane key" >&2 + cat "$home/hello" >&2 || true + exit 1 + fi + if [ ! -s "$home/token" ]; then + echo "pairing token file was not written" >&2 + exit 1 + fi + tok=$(tr -d '\n' < "$home/token") + if grep -a -F "$tok" "$home/log"; then + echo "pairing token was printed with --no-tailscale" >&2 + exit 1 + fi + mapped=0 + i=0 + while [ "$i" -lt 20 ]; do + if grep -q 'libfreetype.so.6' "/proc/$pid/maps" \ + && grep -q 'libfontconfig.so.1' "/proc/$pid/maps" \ + && grep -q 'libX11.so.6' "/proc/$pid/maps"; then + mapped=1 + break + fi + i=$((i + 1)) + sleep 1 + done + if [ "$mapped" != 1 ]; then + echo "dlopen/NEEDED libraries were not mapped" >&2 + grep -E 'libfreetype|libfontconfig|libX11' "/proc/$pid/maps" >&2 || true + cat "$home/log" >&2 + exit 1 + fi + printf '%s\n' "backplane ${version}" > "$out" + ''; + + # runNixOSTest is the module interface, so requiredFeatures.kvm can be + # turned off. testers.nixosTest's legacy makeTest does not accept it. + nixos = pkgs.testers.runNixOSTest { + name = "backplane"; + # Do not require the kvm system feature. QEMU is still invoked with + # accel=kvm:tcg, so a builder without KVM emulates. + requiredFeatures.kvm = false; + nodes.machine = { + imports = [module]; + system.stateVersion = "26.05"; + documentation.enable = false; + documentation.man.enable = false; + documentation.doc.enable = false; + documentation.nixos.enable = false; + + # Builders often have no usable KVM. forceAccel defaults false, so + # qemu is invoked with accel=kvm:tcg and falls back to TCG. + virtualisation.memorySize = 2048; + virtualisation.cores = 2; + virtualisation.qemu.forceAccel = false; + + environment.systemPackages = [ + pkgs.curl + pkgs.iproute2 + pkgs.procps + ]; + + services.backplane = { + enable = true; + package = package; + host = "127.0.0.1"; + port = 3787; + openFirewall = false; + tailscale.enable = false; + environmentFile = "/var/lib/backplane-secret/env"; + credentials.BACKPLANE_TEST_TOKEN = "/var/lib/backplane-secret/token"; + kistack.source = fixture; + kistack.revision = revision; + kistack.readOnly = true; + }; + + systemd.tmpfiles.rules = [ + "d /var/lib/backplane-secret 0700 root root - -" + "f /var/lib/backplane-secret/token 0400 root root - super-secret-token" + "f /var/lib/backplane-secret/env 0400 root root - BACKPLANE_FROM_FILE=from-file" + ]; + }; + + testScript = '' + start_all() + # linger-users is a oneshot without RemainAfterExit, so it is inactive + # after it succeeds. The linger file is the thing that matters. + machine.wait_for_unit("multi-user.target") + machine.wait_for_unit("backplane.service") + machine.wait_for_open_port(3787) + machine.succeed("curl -sf http://127.0.0.1:3787/hello -o /tmp/hello") + machine.succeed("grep -a -q ${version} /tmp/hello") + machine.succeed("grep -a -q backplane /tmp/hello") + + machine.succeed("ss -ltn > /tmp/ss.txt") + machine.succeed("grep -q '127.0.0.1:3787' /tmp/ss.txt") + machine.succeed("! grep -E '0\\.0\\.0\\.0:3787|\\[::\\]:3787|\\*:3787' /tmp/ss.txt") + + machine.succeed("test \"$(stat -c %U /var/lib/backplane)\" = backplane") + machine.succeed("test \"$(stat -c %U /var/cache/backplane)\" = backplane") + machine.succeed("test -f /var/lib/systemd/linger/backplane") + + # --no-tailscale: upstream does not print the token. With the tailnet + # listener on, the same line is left in the journal as printed. + machine.succeed("journalctl -u backplane -o cat --no-pager > /tmp/journal.txt") + machine.succeed("! grep -F \"$(cat /var/lib/backplane/token)\" /tmp/journal.txt") + + machine.succeed("grep -a -z -x -F 'BACKPLANE_TEST_TOKEN=super-secret-token' /proc/$(pidof -s backplane-serve)/environ") + machine.succeed("grep -a -z -x -F 'BACKPLANE_FROM_FILE=from-file' /proc/$(pidof -s backplane-serve)/environ") + machine.succeed("grep -a -z -x -F 'XDG_CACHE_HOME=/var/cache/backplane' /proc/$(pidof -s backplane-serve)/environ") + machine.succeed("grep -a -z -x -F 'HOME=/var/lib/backplane' /proc/$(pidof -s backplane-serve)/environ") + machine.succeed("grep -a -z -x -F 'BACKPLANE_NO_UPDATE=1' /proc/$(pidof -s backplane-serve)/environ") + + machine.succeed("systemctl cat backplane-secrets.path | grep -q 'PathChanged=/var/lib/backplane-secret/token'") + machine.succeed("systemctl cat backplane-secrets.path | grep -q 'PathChanged=/var/lib/backplane-secret/env'") + machine.succeed("! systemctl cat backplane-secrets.path | grep -q PathExists") + machine.succeed("! systemctl show backplane.service -p Wants | grep -q tailscaled") + + machine.succeed("test -f /var/lib/backplane/.cache/backplane/kistack/${revision}/skills/pcb/SKILL.md") + machine.succeed("test \"$(stat -c %U /var/lib/backplane/.cache/backplane/kistack)\" = root") + machine.succeed("su -s /bin/sh backplane -c 'test -r /var/lib/backplane/.cache/backplane/kistack/${revision}/skills/pcb/SKILL.md'") + machine.succeed("su -s /bin/sh backplane -c 'test ! -w /var/lib/backplane/.cache/backplane/kistack'") + + machine.succeed("systemctl --failed --no-legend --plain > /tmp/failed.txt") + machine.succeed("! grep -q . /tmp/failed.txt") + ''; + }; +} diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 0000000000..8e54f776e5 --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,549 @@ +# NixOS module for the Backplane hub. +# +# What this follows, checked against the sources (not guessed): +# - Flags: --home, --host (default 127.0.0.1), --port (default 3787), +# --web, --no-tailscale, --tailscale, --tailscale-https. An unknown flag +# is ignored. A non-numeric --port falls back to 3787. There is no +# --version; asking a running hub's GET /hello is how you read the version +# (CBOR {"backplane": "", "busy": N}). +# - BACKPLANE_NO_UPDATE (any non-empty value) or APPIMAGE turns the +# self-updater off. There is no home/tailscale-path/kistack env var. +# - The pairing token is 128 hex chars in /token (mode 0600). It is +# printed in full on stdout only when the tailnet listener binds: +# "pairing token " and, for --tailscale-https, "/#token=". +# The unit does not filter that line; the journal gets what the binary prints. +# - KiStack is hardcoded to $HOME/.cache/backplane/kistack, not XDG_CACHE_HOME. +# Startup writes .claude-plugin/plugin.json into the revision directory and +# then refreshes GitHub HEAD into a `current` file. The next start prefers +# `current`. There is no switch to keep a pin or to use a read-only tree. +# - `tailscale` is executed by name. The tailnet address is read once at +# start (`tailscale ip -4`); an empty answer stays "unavailable" until +# restart. Peer discovery retries every two minutes. +# - Agents are spawned by bare name (claude, codex, grok) with the hub's +# environment. `systemd-run --user` is used when `systemctl --user +# show-environment` works, otherwise the agent is unconfined. +# - backplane --headless detaches unless --foreground is also passed. +# backplane-serve stays in the foreground and treats a taken port as fatal. +# The service runs backplane-serve. +{ + config, + lib, + pkgs, + ... +}: let + sources = import ./sources.nix; + cfg = config.services.backplane; + secretPaths = + lib.optional (cfg.environmentFile != null) cfg.environmentFile ++ lib.attrValues cfg.credentials; + storeSecrets = lib.filter (p: lib.hasPrefix "/nix/store" p) secretPaths; + relativeSecrets = lib.filter (p: !lib.hasPrefix "/" p) secretPaths; + tailscalePackage = + if cfg.tailscale.package != null + then cfg.tailscale.package + else if config.services.tailscale.enable + then config.services.tailscale.package + else pkgs.tailscale; + basePackages = with pkgs; [ + bash + coreutils + curl + findutils + gawk + gnugrep + gnused + gnutar + gzip + procps + systemd + util-linux + ]; + serveArgs = + [ + "${cfg.package}/bin/backplane-serve" + "--home" + cfg.stateDir + "--host" + cfg.host + "--port" + (toString cfg.port) + "--web" + "${cfg.package}/libexec/backplane/web" + ] + ++ lib.optionals (!cfg.tailscale.enable) ["--no-tailscale"] + ++ lib.optionals (cfg.tailscale.enable && cfg.tailscale.https) ["--tailscale-https"] + ++ cfg.extraArgs; + runScript = pkgs.writeShellScript "backplane-run" '' + set -euo pipefail + cred_dir="''${CREDENTIALS_DIRECTORY:-}" + if [ -n "$cred_dir" ]; then + for f in "$cred_dir"/*; do + [ -f "$f" ] || continue + name=$(basename "$f") + case "$name" in + *[!A-Za-z0-9_]*) continue ;; + [0-9]*) continue ;; + esac + # A credential file is one variable's value. Trailing newlines are + # stripped, matching how a token file is usually written. + val=$(cat "$f") + export "$name=$val" + done + fi + exec ${lib.escapeShellArgs serveArgs} + ''; + seedScript = pkgs.writeShellScript "backplane-kistack-seed" '' + set -eu + home=${lib.escapeShellArg cfg.home} + rev=${lib.escapeShellArg cfg.kistack.revision} + source=${lib.escapeShellArg cfg.kistack.source} + user=${lib.escapeShellArg cfg.user} + group=${lib.escapeShellArg cfg.group} + readonly=${ + if cfg.kistack.readOnly + then "1" + else "0" + } + base="$home/.cache/backplane/kistack" + dest="$base/$rev" + stamp="$base/.source" + mkdir -p "$base" + if [ -f "$stamp" ] && [ "$(cat "$stamp")" = "$source" ] && [ -f "$dest/skills/pcb/SKILL.md" ]; then + : + else + chmod -R u+w "$base" 2>/dev/null || true + rm -rf "$dest" + mkdir -p "$dest" + cp -a "$source/." "$dest/" + printf '%s\n' "$source" > "$stamp" + fi + if [ ! -f "$dest/.claude-plugin/plugin.json" ]; then + chmod -R u+w "$dest" 2>/dev/null || true + mkdir -p "$dest/.claude-plugin" + printf '%s\n' '{"name":"kistack","description":"KiStack KiCad skills by American Embedded"}' > "$dest/.claude-plugin/plugin.json" + fi + if [ "$readonly" = 1 ]; then + # A later boot finds the tree mode a-w. Rewrite current only when it + # does not already name this revision, and only after making it writable. + cur="" + if [ -f "$base/current" ]; then + cur=$(cat "$base/current" 2>/dev/null || true) + fi + if [ "$cur" != "$rev" ]; then + chmod -R u+w "$base" 2>/dev/null || true + printf '%s\n' "$rev" > "$base/current" + fi + chown -R root:root "$base" + chmod -R a+rX "$base" + chmod -R a-w "$base" + else + if [ ! -f "$base/current" ]; then + printf '%s\n' "$rev" > "$base/current" + fi + chown -R "$user:$group" "$base" + fi + ''; + dirRule = path: mode: "d ${path} ${mode} ${cfg.user} ${cfg.group} - -"; +in { + options.services.backplane = { + enable = lib.mkEnableOption "the Backplane hub"; + + package = lib.mkOption { + type = lib.types.package; + default = + if pkgs ? backplane + then pkgs.backplane + else pkgs.callPackage ./package.nix {}; + defaultText = lib.literalExpression "pkgs.backplane"; + description = '' + Package that provides bin/backplane, bin/backplane-serve and + libexec/backplane/web. The service runs backplane-serve. + ''; + }; + + user = lib.mkOption { + type = lib.types.str; + default = "backplane"; + description = "Account the hub runs as."; + }; + + group = lib.mkOption { + type = lib.types.str; + default = cfg.user; + defaultText = lib.literalExpression "user"; + description = "Primary group of the hub account."; + }; + + createUser = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Create `user` and `group` as a system account. The account lingers + so a user manager is running and upstream can put each agent in a + `systemd-run --user` scope. Set false when the account already exists; + give that account linger yourself or agents run unconfined. + ''; + }; + + linger = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + When createUser is set, enable lingering for the account + (`loginctl enable-linger`). Upstream's memory cap and durable + `work_run` units need `systemctl --user`. + ''; + }; + + stateDir = lib.mkOption { + type = lib.types.str; + default = "/var/lib/backplane"; + description = '' + Passed as `--home`. The event log, pairing token and secrets live here. + ''; + }; + + home = lib.mkOption { + type = lib.types.str; + default = cfg.stateDir; + defaultText = lib.literalExpression "stateDir"; + description = '' + HOME for the process. KiStack is hardcoded to + `$HOME/.cache/backplane/kistack` (it does not read XDG_CACHE_HOME), + so the default keeps that tree on the state directory. + ''; + }; + + cacheDir = lib.mkOption { + type = lib.types.str; + default = "/var/cache/backplane"; + description = '' + XDG_CACHE_HOME. Upstream honors it for model and KiCad-discovery + caches (`$XDG_CACHE_HOME/backplane-bend/...`). KiStack does not. + ''; + }; + + host = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + example = "0.0.0.0"; + description = '' + Address passed as `--host`. The hub binds this one address. + `0.0.0.0` listens on every IPv4 interface. The tailnet listener, + when enabled, is a second socket on the tailscale address. + ''; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 3787; + description = '' + TCP port. The module passes a number; upstream would silently use + 3787 if `--port` were not numeric. + ''; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Open `port` in the firewall. With the default loopback host this + does not make the hub reachable from other machines. + ''; + }; + + extraPackages = lib.mkOption { + type = lib.types.listOf lib.types.package; + default = []; + example = lib.literalExpression "with pkgs; [ git claude-code kicad freecad codex ]"; + description = '' + Extra programs on the hub's PATH. Agents are started by bare name + (`claude`, `codex`, `grok`), KiCad via `kicad-cli` or + BACKPLANE_KICAD_CLI, FreeCAD via `freecadcmd` or BACKPLANE_FREECAD, + and git is used for thread worktrees. The module always adds the + tools the hub's own shell scripts call (curl, bash, coreutils, + grep, sed, awk, find, tar, gzip, ps, systemd-run). + ''; + }; + + extraArgs = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = []; + description = '' + Extra arguments after the managed ones. Unknown flags are ignored + by the hub. + ''; + }; + + environment = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = {}; + example = { + BACKPLANE_KICAD_CLI = "/run/current-system/sw/bin/kicad-cli"; + BACKPLANE_FREECAD = "/run/current-system/sw/bin/freecadcmd"; + }; + description = '' + Extra environment variables for the hub and, because agents inherit + the hub's environment, for agents. Not for secrets: a value written + here is copied into the system closure. Use environmentFile or + credentials for tokens. + ''; + }; + + environmentFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "/run/secrets/backplane.env"; + description = '' + Absolute path of a systemd EnvironmentFile (KEY=value lines). + Must not point into the Nix store. A path literal in a Nix file + would be copied into the store and then rejected. systemd refuses + to start the service if the file is missing. + ''; + }; + + credentials = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = {}; + example = { + CLAUDE_CODE_OAUTH_TOKEN = "/run/secrets/claude-token"; + OPENAI_API_KEY = "/run/secrets/openai-key"; + }; + description = '' + Credential files loaded with LoadCredential and exported to the hub + under the attribute name. Each path is absolute and outside the Nix + store. The file's contents are the variable's value, with trailing + newlines removed. Agents inherit the hub environment, and upstream + does not re-read it later, so a change restarts the service (a + path unit on PathChanged, not PathExists). + ''; + }; + + tailscale = { + enable = lib.mkEnableOption '' + the tailnet listener. Adds tailscale to PATH and orders the service + after tailscaled.service and, if it exists, tailscaled-autoconnect.service + (Wants=, so a missing autoconnect unit does not fail the boot). + Upstream decides the tailnet address once at start + ''; + + https = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Pass `--tailscale-https` (`tailscale serve` on 443). Implies the + tailnet listener. The pairing URL this prints contains the token, + and that line is written to the journal as the binary prints it. + ''; + }; + + package = lib.mkOption { + type = lib.types.nullOr lib.types.package; + default = null; + description = '' + tailscale package placed on PATH when the tailnet listener is on. + null uses services.tailscale.package when that service is enabled, + otherwise pkgs.tailscale. + ''; + }; + }; + + kistack = { + source = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + KiStack tree to seed (the repository root, containing + skills/pcb/SKILL.md). Upstream has no read-only mode and no + directory override: it uses `$HOME/.cache/backplane/kistack`, + writes `.claude-plugin/plugin.json` into the revision, and a + background refresh records GitHub HEAD in `current`, which the + next start prefers. The module copies this tree into that cache + (a copy, because the hub writes into the tree). Leave null and + the hub downloads the pin itself, which needs curl and a network. + ''; + }; + + revision = lib.mkOption { + type = lib.types.str; + default = sources.kistackRevision; + defaultText = lib.literalExpression "the KiStack.revision() pin for this Backplane version"; + description = '' + Directory name under the KiStack cache. Defaults to the revision + pinned by the Backplane sources this package was built against. + ''; + }; + + readOnly = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + After seeding, make the KiStack cache non-writable by the service + user and point `current` at `revision`. Upstream's refresh cannot + replace the pin; the failed writes are ignored. Set false to let + a later start follow GitHub HEAD. Has no effect when source is null. + ''; + }; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = builtins.length storeSecrets == 0; + message = "services.backplane refuses secret paths in /nix/store: ${lib.concatStringsSep ", " storeSecrets}"; + } + { + assertion = builtins.length relativeSecrets == 0; + message = "services.backplane secret paths must be absolute: ${lib.concatStringsSep ", " relativeSecrets}"; + } + ]; + + warnings = lib.optional (cfg.openFirewall && (cfg.host == "127.0.0.1" || cfg.host == "::1" || cfg.host == "localhost")) '' + services.backplane.openFirewall is set, but host ${cfg.host} is loopback, so other machines still cannot connect. + ''; + + users.groups.${cfg.group} = lib.mkIf cfg.createUser {}; + users.users.${cfg.user} = lib.mkIf cfg.createUser { + isSystemUser = true; + group = cfg.group; + home = cfg.home; + createHome = false; + linger = cfg.linger; + }; + + networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [cfg.port]; + + systemd.tmpfiles.rules = lib.unique [ + (dirRule cfg.stateDir "0750") + (dirRule cfg.cacheDir "0750") + (dirRule cfg.home "0750") + (dirRule "${cfg.home}/.cache" "0750") + (dirRule "${cfg.home}/.cache/backplane" "0750") + (dirRule "${cfg.home}/.cache/backplane/kistack" "0750") + (dirRule "${cfg.home}/.config" "0750") + (dirRule "${cfg.stateDir}/share" "0750") + (dirRule "${cfg.stateDir}/config" "0750") + (dirRule "${cfg.stateDir}/secrets" "0700") + ]; + + systemd.services.backplane-kistack = lib.mkIf (cfg.kistack.source != null) { + description = "Seed Backplane's KiStack cache"; + wantedBy = ["multi-user.target"]; + before = ["backplane.service"]; + requiredBy = ["backplane.service"]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = seedScript; + }; + }; + + systemd.paths.backplane-secrets = lib.mkIf (secretPaths != []) { + description = "Restart Backplane when a credential file changes"; + wantedBy = ["multi-user.target"]; + pathConfig.PathChanged = secretPaths; + }; + + systemd.services.backplane-secrets = lib.mkIf (secretPaths != []) { + description = "Restart Backplane when a credential file changes"; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${pkgs.systemd}/bin/systemctl restart backplane.service"; + }; + }; + + systemd.services.backplane = { + description = "Backplane hub"; + wantedBy = ["multi-user.target"]; + wants = + ["network-online.target"] + ++ lib.optionals cfg.tailscale.enable [ + "tailscaled.service" + "tailscaled-autoconnect.service" + ]; + after = + ["network-online.target"] + ++ lib.optionals cfg.tailscale.enable [ + "tailscaled.service" + "tailscaled-autoconnect.service" + ] + ++ lib.optional (cfg.kistack.source != null) "backplane-kistack.service" + ++ lib.optional (cfg.createUser && cfg.linger) "linger-users.service"; + requires = lib.optional (cfg.kistack.source != null) "backplane-kistack.service"; + + path = + cfg.extraPackages + ++ lib.optional cfg.tailscale.enable tailscalePackage + ++ basePackages + ++ [cfg.package]; + + environment = + { + HOME = cfg.home; + XDG_CACHE_HOME = cfg.cacheDir; + XDG_STATE_HOME = cfg.stateDir; + XDG_DATA_HOME = "${cfg.stateDir}/share"; + XDG_CONFIG_HOME = "${cfg.home}/.config"; + # %U is the uid of User=. The user manager's bus is what + # `systemctl --user` looks at for agent scopes. + XDG_RUNTIME_DIR = "/run/user/%U"; + DBUS_SESSION_BUS_ADDRESS = "unix:path=/run/user/%U/bus"; + BACKPLANE_NO_UPDATE = "1"; + } + // cfg.environment; + + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + WorkingDirectory = cfg.home; + ExecStart = runScript; + Restart = "on-failure"; + RestartSec = 3; + TimeoutStartSec = 180; + TimeoutStopSec = 30; + KillMode = "control-group"; + # The hub prints the pairing token when the tailnet listener binds. + # Leave that line in the journal; do not filter it. + StandardOutput = "journal"; + StandardError = "journal"; + EnvironmentFile = lib.mkIf (cfg.environmentFile != null) cfg.environmentFile; + LoadCredential = lib.mapAttrsToList (name: path: "${name}:${path}") cfg.credentials; + StateDirectory = lib.mkIf (cfg.stateDir == "/var/lib/backplane") "backplane"; + StateDirectoryMode = lib.mkIf (cfg.stateDir == "/var/lib/backplane") "0750"; + CacheDirectory = lib.mkIf (cfg.cacheDir == "/var/cache/backplane") "backplane"; + CacheDirectoryMode = lib.mkIf (cfg.cacheDir == "/var/cache/backplane") "0750"; + UMask = "0077"; + LimitNOFILE = 1048576; + TasksMax = "infinity"; + # Agents are arbitrary CLIs (JIT, network, project dirs, user scopes). + # ProtectHome would make project folders read-only. MemoryDenyWriteExecute + # would break their JITs. ProtectControlGroups and a tight syscall + # filter would break `systemd-run --user`. PrivateDevices stays off + # so KiCad/FreeCAD can see a GPU node when one exists. + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = false; + PrivateTmp = true; + PrivateDevices = false; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectClock = true; + ProtectHostname = true; + ProtectProc = "invisible"; + RestrictRealtime = true; + RestrictSUIDSGID = true; + LockPersonality = true; + SystemCallArchitectures = "native"; + ReadWritePaths = lib.unique [ + cfg.stateDir + cfg.home + cfg.cacheDir + "${cfg.home}/.cache" + "${cfg.home}/.config" + ]; + }; + }; + }; +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 0000000000..245b1e3f8e --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,153 @@ +# Official release binaries, not a from-source Bend build. +# The window binary NEEDs libX11 and also dlopens libfreetype.so.6, +# libfontconfig.so.1 and libX11.so.6 (src/gfx/effects/font.c, src/app/effects/win.c). +# autoPatchelf fixes NEEDED entries. runtimeDependencies plus an explicit +# rpath cover the dlopen names. The bin/ wrappers also export LD_LIBRARY_PATH +# without reading it unset (`set -u` safe) and set BACKPLANE_NO_UPDATE. +{ + lib, + stdenv, + fetchurl, + autoPatchelfHook, + libx11, + libxcb, + libxau, + libxdmcp, + freetype, + fontconfig, + sources ? import ./sources.nix, +}: let + system = stdenv.hostPlatform.system; + plat = + if builtins.hasAttr system sources.platforms + then sources.platforms.${system} + else throw "Backplane publishes no release binary for ${system}"; + runtimeLibs = [ + freetype + fontconfig + libx11 + libxcb + libxau + libxdmcp + ]; + libPath = lib.makeLibraryPath runtimeLibs; + # @out@ and @libPath@ are filled in during install. A toFile result must + # not mention a derivation, and ${LD_LIBRARY_PATH:-} must survive `set -u`. + wrapperFor = name: + builtins.toFile "backplane-${name}-wrapper" '' + #!/bin/sh + set -eu + export BACKPLANE_NO_UPDATE=1 + if [ -n "''${LD_LIBRARY_PATH:-}" ]; then + export LD_LIBRARY_PATH=@libPath@:$LD_LIBRARY_PATH + else + export LD_LIBRARY_PATH=@libPath@ + fi + exec @out@/libexec/backplane/${name} "$@" + ''; +in + stdenv.mkDerivation { + pname = "backplane"; + version = sources.version; + + src = fetchurl { + inherit (plat) url hash; + name = "backplane-${sources.version}-${plat.dir}.tar.gz"; + }; + sourceRoot = "backplane-${plat.dir}"; + + nativeBuildInputs = [autoPatchelfHook]; + buildInputs = runtimeLibs; + # dlopen() searches the binary's RUNPATH. These are not all NEEDED. + runtimeDependencies = runtimeLibs; + # The bun helpers list the dynamic linker as NEEDED. It is the interpreter, + # not a library autoPatchelf can add. + autoPatchelfIgnoreMissingDeps = [ + "ld-linux-x86-64.so.2" + "ld-linux-aarch64.so.1" + ]; + + inherit libPath; + + dontStrip = true; + dontConfigure = true; + dontBuild = true; + + installPhase = '' + runHook preInstall + libexec=$out/libexec/backplane + mkdir -p "$libexec" "$out/bin" \ + "$out/share/applications" \ + "$out/share/icons/hicolor/scalable/apps" \ + "$out/share/icons/hicolor/256x256/apps" \ + "$out/share/licenses/backplane" + + for b in backplane backplane-serve backplane-browser backplane-step2glb backplane-voice; do + if [ -e "$b" ]; then + cp -a "$b" "$libexec/$b" + chmod 755 "$libexec/$b" + fi + done + cp -a web "$libexec/web" + if [ -d licenses ]; then + cp -a licenses "$out/share/licenses/backplane/licenses" + fi + if [ -f LICENSE ]; then + cp -a LICENSE "$out/share/licenses/backplane/LICENSE" + fi + if [ -f backplane.desktop ]; then + sed "s|^Exec=.*|Exec=$out/bin/backplane|" backplane.desktop > "$out/share/applications/backplane.desktop" + fi + if [ -f icon/backplane.svg ]; then + cp -a icon/backplane.svg "$out/share/icons/hicolor/scalable/apps/backplane.svg" + fi + if [ -f icon/backplane-256.png ]; then + cp -a icon/backplane-256.png "$out/share/icons/hicolor/256x256/apps/backplane.png" + fi + + test -x "$libexec/backplane" + test -x "$libexec/backplane-serve" + substitute ${wrapperFor "backplane"} "$out/bin/backplane" --subst-var out --subst-var libPath + substitute ${wrapperFor "backplane-serve"} "$out/bin/backplane-serve" --subst-var out --subst-var libPath + chmod 755 "$out/bin/backplane" "$out/bin/backplane-serve" + runHook postInstall + ''; + + # autoPatchelf records NEEDED libraries. Append the dlopen directories + # as well so a RUNPATH lookup finds them even if the hook did not. + postFixup = '' + for b in backplane backplane-serve; do + bin="$out/libexec/backplane/$b" + [ -e "$bin" ] || continue + old=$(patchelf --print-rpath "$bin") + if [ -n "$old" ]; then + patchelf --set-rpath "$old:${libPath}" "$bin" + else + patchelf --set-rpath "${libPath}" "$bin" + fi + done + ''; + + passthru = { + updateScript = ./update.sh; + inherit sources; + }; + + meta = { + description = "Agentic hardware development, with live KiCad viewers"; + longDescription = '' + Prebuilt Backplane hub and window from the upstream GitHub release. + backplane-step2glb embeds OpenCascade (LGPL-2.1); its notices are in + share/licenses. The package sets BACKPLANE_NO_UPDATE so a packaged + install does not replace itself from GitHub. + ''; + homepage = "https://github.com/i2cjak/Backplane"; + license = with lib.licenses; [ + mit + lgpl21 + ]; + sourceProvenance = with lib.sourceTypes; [binaryNativeCode]; + platforms = builtins.attrNames sources.platforms; + mainProgram = "backplane"; + }; + } diff --git a/nix/sources.nix b/nix/sources.nix new file mode 100644 index 0000000000..831a72f2d0 --- /dev/null +++ b/nix/sources.nix @@ -0,0 +1,19 @@ +# Pins for the official release binaries. nix/update.sh rewrites this from +# GitHub's SHA256SUMS and from the tag's KiStack.revision(). +{ + version = "0.11.1"; + # src/server/kicad.bend KiStack.revision() at this tag. + kistackRevision = "ecf16c119c7ed610f5f251c479b03884b8d98b09"; + platforms = { + aarch64-linux = { + dir = "linux-arm64"; + url = "https://github.com/i2cjak/Backplane/releases/download/v0.11.1/backplane-v0.11.1-linux-arm64.tar.gz"; + hash = "sha256-AeplHZ3phngRHcRB12M3o5aMIj8r4KOsMl9D6vy9HF0="; + }; + x86_64-linux = { + dir = "linux-x64"; + url = "https://github.com/i2cjak/Backplane/releases/download/v0.11.1/backplane-v0.11.1-linux-x64.tar.gz"; + hash = "sha256-J7XBGkqq5N0aGTBs2nFDD9BsELtvLXbhuk25Lh4b6yk="; + }; + }; +} diff --git a/nix/update.sh b/nix/update.sh new file mode 100755 index 0000000000..928c65fe2a --- /dev/null +++ b/nix/update.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Rewrite nix/sources.nix from an official GitHub release. +# nix/update.sh # latest stable release +# nix/update.sh v0.11.1 # one tag +# Then run `nix fmt` (alejandra). +set -euo pipefail + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +tag=${1:-} + +if ! command -v python3 >/dev/null 2>&1; then + echo "update.sh needs python3" >&2 + exit 1 +fi +if ! command -v curl >/dev/null 2>&1; then + echo "update.sh needs curl" >&2 + exit 1 +fi + +if [ -z "$tag" ]; then + tag=$( + curl -fsSL https://api.github.com/repos/i2cjak/Backplane/releases/latest | + python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])' + ) +fi +case $tag in + v*) ;; + *) + echo "tag must look like v0.11.1 (got $tag)" >&2 + exit 1 + ;; +esac + +echo "pinning $tag" +sums=$(curl -fsSL "https://github.com/i2cjak/Backplane/releases/download/${tag}/SHA256SUMS") +kicad=$(curl -fsSL "https://raw.githubusercontent.com/i2cjak/Backplane/${tag}/src/server/kicad.bend") + +ROOT="$root" TAG="$tag" SUMS="$sums" KICAD="$kicad" python3 - <<'PY' +import base64, os, re, textwrap + +tag = os.environ["TAG"] +version = tag[1:] +sums = os.environ["SUMS"] +kicad = os.environ["KICAD"] +m = re.search(r'def KiStack\.revision\(\) -> String:\n "([0-9a-f]{40})"', kicad) +if not m: + raise SystemExit("could not find KiStack.revision() in that tag's kicad.bend") +rev = m.group(1) + +wanted = { + "x86_64-linux": ("linux-x64", f"backplane-{tag}-linux-x64.tar.gz"), + "aarch64-linux": ("linux-arm64", f"backplane-{tag}-linux-arm64.tar.gz"), +} +found = {} +for line in sums.splitlines(): + parts = line.split() + if len(parts) != 2: + continue + digest, name = parts + for system, (directory, filename) in wanted.items(): + if name == filename: + raw = bytes.fromhex(digest) + found[system] = (directory, filename, "sha256-" + base64.b64encode(raw).decode()) + +missing = [s for s in wanted if s not in found] +if missing: + raise SystemExit(f"{tag} is missing release archives for: {', '.join(missing)}") + +def block(system): + directory, filename, sri = found[system] + url = f"https://github.com/i2cjak/Backplane/releases/download/{tag}/{filename}" + return textwrap.dedent(f"""\ + {system} = {{ + dir = "{directory}"; + url = "{url}"; + hash = "{sri}"; + }}; + """).rstrip() + +body = "\n".join(block(s) for s in ("aarch64-linux", "x86_64-linux")) +text = f"""# Pins for the official release binaries. nix/update.sh rewrites this from +# GitHub's SHA256SUMS and from the tag's KiStack.revision(). +{{ + version = "{version}"; + # src/server/kicad.bend KiStack.revision() at this tag. + kistackRevision = "{rev}"; + platforms = {{ +{textwrap.indent(body, " ")} + }}; +}} +""" +path = os.environ["ROOT"] + "/nix/sources.nix" +with open(path, "w", encoding="utf-8") as f: + f.write(text) +print(f"wrote {path}") +print(f"version {version}") +print(f"kistack {rev}") +PY + +echo "run nix fmt to normalize nix/sources.nix"