# In-container forge (Forgejo) integration: the `tea` CLI login # oneshot, the `hive-forge` verb CLI on PATH, and the icon → forge # avatar sync. { pkgs, lib, config, ... }: let userName = config.hyperhive.user.name; homeDir = "/home/${userName}"; # Same 512×512 rasterization of the agent icon the matrix avatar # sync uses (./matrix.nix — identical derivation, same store path). # Only forced when an icon is configured (the avatar-sync unit below # is gated on `hyperhive.icon != null`). iconPng = pkgs.runCommand "hive-agent-icon.png" { nativeBuildInputs = [ pkgs.librsvg ]; } '' rsvg-convert -f png -w 512 -h 512 ${config.hyperhive.icon} -o $out ''; in { options.hyperhive.forge.url = lib.mkOption { type = lib.types.str; default = "http://localhost:3000"; example = "http://forge.internal:3000"; description = '' Base URL of the hyperhive-managed Forgejo. Used at container boot by a oneshot systemd unit that calls `tea login add --url --token "$(cat $HYPERHIVE_STATE_DIR/forge-token)"` (= `/agents//state/forge-token`) so the agent's claude can shell out to `tea` without an extra auth dance. No-op when the forge-token file is missing (i.e. hive-forge isn't running on the host). ''; }; config = { assertions = [ # hyperhive.forge.url must look like an HTTP URL when non-default. { assertion = config.hyperhive.forge.url == "" || lib.hasPrefix "http://" config.hyperhive.forge.url || lib.hasPrefix "https://" config.hyperhive.forge.url; message = "hyperhive.forge.url must be an http:// or https:// URL (got: \"${config.hyperhive.forge.url}\")"; } ]; environment.systemPackages = [ # tea: gitea/forgejo CLI client. Configured at boot by the # tea-login oneshot below if /state/forge-token is present, so # claude can `tea repos create`, `tea pulls create`, etc. pkgs.tea # hive-forge : CLI wrapping common Forgejo REST API operations # (view, pr, issue, comment, assign, close, labels, branches, etc.). # The per-bin split package — narrow closure, no hivectl/wireguard. config.hyperhive.packages.hive-forge ]; # One-shot: tea config.yml from the seeded forge token. Shape # contract (always exit 0, no set -e, skip-silently, re-runnable): # docs/conventions.md::Best-effort oneshot services. systemd.services.tea-login = { description = "configure tea CLI from hive-forge token (best-effort)"; wantedBy = [ "multi-user.target" ]; after = [ "local-fs.target" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; # Pin the journal identity (else it's the `script` store-path wrapper). SyslogIdentifier = "tea-login"; }; path = [ pkgs.curl pkgs.jq pkgs.coreutils ]; environment.HOME_DIR = homeDir; environment.AGENT_USER = userName; script = '' # No `set -e`: best-effort posture (see docs pointer above). FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url} # $HYPERHIVE_STATE_DIR is system-wide via the meta flake. TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token" if [ ! -f "$TOKEN_FILE" ]; then echo "tea-login: no forge-token at $TOKEN_FILE; skipping" exit 0 fi TOKEN=$(cat "$TOKEN_FILE") # Resolve the agent username from the forge API. USER=$(curl -sf --max-time 5 \ -H "Authorization: token $TOKEN" \ "$FORGE_URL/api/v1/user" \ | jq -r '.login // empty' 2>/dev/null || true) if [ -z "$USER" ]; then echo "tea-login: could not resolve username from forge API; skipping" exit 0 fi # Config under the agent user's home, chown'd to them; # service stays root-owned (see docs pointer above). CONFIG="$HOME_DIR/.config/tea/config.yml" mkdir -p "$(dirname "$CONFIG")" || true cat > "$CONFIG" << EOF logins: - name: forge url: $FORGE_URL token: $TOKEN default: true ssh_host: "" ssh_key: "" insecure: false ssh_agent: false user: $USER preferences: editor: false flag_defaults: remote: "" EOF chown -R "$AGENT_USER:$AGENT_USER" "$HOME_DIR/.config" 2>/dev/null || true echo "tea-login: configured for $FORGE_URL as $USER (config at $CONFIG)" ''; }; # Path-trigger sibling: re-fires forge-avatar-sync the moment # `/forge-token` appears. Mirrors the hive-matrix-daemon # token-watcher pattern — on first agent deployment the container # boots before hive-c0re has provisioned the forge-token, so the # service fires too early and exits with "no forge-token found". # Without this path unit, RemainAfterExit=true would prevent systemd # from ever re-running the service. See # docs/persistence.md::forge-avatar-sync. systemd.paths.forge-avatar-sync = lib.mkIf (config.hyperhive.icon != null) { description = "trigger forge-avatar-sync when forge-token appears"; wantedBy = [ "multi-user.target" ]; pathConfig.PathExistsGlob = "/agents/*/state/forge-token"; }; # One-shot: hyperhive.icon → Forgejo profile avatar. Shape contract: # docs/conventions.md::Best-effort oneshot services. # RemainAfterExit = false so the .path trigger above can re-fire # this unit when the forge-token arrives after boot. The PNG is # rasterized at build time (`iconPng`, shared shape with the matrix # avatar sync), so the unit only exists when an icon is configured # and needs no librsvg at runtime — Forgejo's Go image library # can't decode SVG, hence PNG. systemd.services.forge-avatar-sync = lib.mkIf (config.hyperhive.icon != null) { description = "sync agent icon to Forgejo user avatar (best-effort)"; wantedBy = [ "multi-user.target" ]; after = [ "tea-login.service" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = false; # Pin the journal identity (else it's the `script` store-path wrapper). SyslogIdentifier = "forge-avatar-sync"; }; path = [ pkgs.curl pkgs.coreutils pkgs.jq ]; script = '' FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url} # $HYPERHIVE_STATE_DIR is set system-wide by the meta flake # (systemd.globalEnvironment) to `/agents//state`. TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token" if [ ! -f "$TOKEN_FILE" ]; then echo "forge-avatar-sync: no forge-token found; skipping" exit 0 fi TOKEN=$(cat "$TOKEN_FILE") IMAGE=$(base64 -w 0 < ${iconPng}) # Forgejo POST /user/avatar expects {"image":""} — just the # raw base64 string, NOT a data URI (data:image/png;base64,...). # Use jq to build the payload so the large base64 value is safely quoted. PAYLOAD=$(jq -n --arg img "$IMAGE" '{image:$img}') RESP=$(curl -sf --max-time 10 \ -X POST "$FORGE_URL/api/v1/user/avatar" \ -H "Authorization: token $TOKEN" \ -H "Content-Type: application/json" \ -d "$PAYLOAD" \ -w "\n%{http_code}" 2>/dev/null || true) CODE=$(printf '%s' "$RESP" | tail -1) if [ "$CODE" = "204" ] || [ "$CODE" = "200" ]; then echo "forge-avatar-sync: avatar uploaded (HTTP $CODE)" else echo "forge-avatar-sync: upload returned HTTP $CODE — skipping (non-fatal)" fi ''; }; }; }