195 lines
7.7 KiB
Nix
195 lines
7.7 KiB
Nix
# 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}";
|
|
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 <this> --token "$(cat $HYPERHIVE_STATE_DIR/forge-token)"`
|
|
(= `/agents/<name>/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 <verb>: 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.python3
|
|
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" \
|
|
| python3 -c 'import sys,json; print(json.load(sys.stdin).get("login",""))' \
|
|
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
|
|
# `<state>/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 = {
|
|
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.
|
|
systemd.services.forge-avatar-sync = {
|
|
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
|
|
pkgs.librsvg
|
|
];
|
|
script = ''
|
|
ICON=/etc/hyperhive/icon.svg
|
|
if [ ! -f "$ICON" ]; then
|
|
echo "forge-avatar-sync: no icon configured; skipping"
|
|
exit 0
|
|
fi
|
|
FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url}
|
|
# $HYPERHIVE_STATE_DIR is set system-wide by the meta flake
|
|
# (systemd.globalEnvironment) to `/agents/<name>/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")
|
|
# Rasterize SVG → PNG (Forgejo's Go image library can't decode SVG).
|
|
PNG=$(mktemp --suffix=.png)
|
|
if ! rsvg-convert -f png -w 512 -h 512 "$ICON" -o "$PNG" 2>/dev/null; then
|
|
echo "forge-avatar-sync: rsvg-convert failed; skipping"
|
|
rm -f "$PNG"
|
|
exit 0
|
|
fi
|
|
IMAGE=$(base64 -w 0 < "$PNG")
|
|
rm -f "$PNG"
|
|
# Forgejo POST /user/avatar expects {"image":"<base64>"} — 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
|
|
'';
|
|
};
|
|
};
|
|
}
|