#!/usr/bin/env bash # register-gitea-bot.sh — provision the Gitea access token the maintenance agent # uses to push branches and open pull requests as the `claude-code` user. # # git.semprini.me (Gitea) authenticates humans via Keycloak OIDC and has the # password sign-in form AND HTTP basic auth disabled. `claude-code` is an # OIDC-linked account that is already a push-capable collaborator on the repo; it # has no password, so — exactly like the Matrix bot's shared-secret registration # — we mint a standalone personal access token via the Gitea admin CLI inside the # running container and store it in config.json. # # git-over-HTTPS still accepts a personal access token as the password even with # basic auth disabled, and the API accepts it via the `Authorization: token` # header — so no SSH exposure or core-stack change is required. # # Idempotent: reuses the token already in config.json when it still validates; # otherwise mints a fresh one (Gitea reveals a token only at creation time, so a # lost token means a new one). Old tokens can be revoked in the Gitea UI. # # Requirements: run on the core-stack host with the gitea container running. # # Usage: # ./register-gitea-bot.sh # uses config.json next to this script # CONFIG=/path/config.json ./register-gitea-bot.sh set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONFIG="${CONFIG:-$DIR/config.json}" GITEA_CONTAINER="${GITEA_CONTAINER:-user-gitea}" # write:repository -> push + open PRs; read:user -> token self-validation. TOKEN_SCOPES="${TOKEN_SCOPES:-write:repository,read:user}" [ -f "$CONFIG" ] || { echo "ERROR: $CONFIG not found. Copy config.example.json to config.json first." exit 1; } # Read a python expression against the parsed config (c), with safe defaults so a # config.json that predates the gitea block still works. cfg_get() { python3 -c "import json; c=json.load(open('$CONFIG')); print($1)"; } API_BASE="$(cfg_get "c.get('gitea',{}).get('api_base','https://git.semprini.me/api/v1')")" GUSER="$(cfg_get "c.get('gitea',{}).get('user','claude-code')")" REPO="$(cfg_get "c.get('gitea',{}).get('repo','paul/semprini-core')")" echo "→ Gitea API : $API_BASE" echo "→ User : $GUSER" echo "→ Repo : $REPO" token_valid() { local t="$1" [ -n "$t" ] || return 1 case "$t" in FILLED_BY_*) return 1;; esac local login login="$(curl -s -m 15 -H "Authorization: token $t" "$API_BASE/user" \ | python3 -c 'import sys,json; print(json.load(sys.stdin).get("login",""))' \ 2>/dev/null || true)" [ "$login" = "$GUSER" ] } # 1. Reuse the stored token if it still validates; otherwise mint a fresh one. STORED="$(cfg_get "c.get('gitea',{}).get('token','')")" if token_valid "$STORED"; then echo "→ Existing token in config.json is valid; reusing it." TOKEN="$STORED" else NAME="semprini-maintainer-$(date +%s)" echo "→ Minting a new access token ($NAME; scopes: $TOKEN_SCOPES)…" TOKEN="$(docker exec -u git "$GITEA_CONTAINER" \ gitea admin user generate-access-token \ -u "$GUSER" -t "$NAME" --scopes "$TOKEN_SCOPES" --raw 2>&1 \ | tr -d '[:space:]')" echo "$TOKEN" | grep -qE '^[0-9a-f]{40}$' || { echo "ERROR: failed to mint token: $TOKEN"; exit 1; } fi # 2. Verify push access to the target repo (warn only — token is still usable). PUSH="$(curl -s -m 15 -H "Authorization: token $TOKEN" "$API_BASE/repos/$REPO" \ | python3 -c 'import sys,json; print("yes" if json.load(sys.stdin).get("permissions",{}).get("push") else "no")' \ 2>/dev/null || echo no)" if [ "$PUSH" = "yes" ]; then echo "→ $GUSER has push access to $REPO ✓" else echo "WARN: $GUSER cannot push to $REPO. Add it as a collaborator with Write" echo " permission in Gitea, or pushes/PRs will be rejected." fi # 3. Write the token back into config.json (creating the gitea block if absent). python3 - "$CONFIG" "$TOKEN" <<'PY' import json, sys cfg_path, token = sys.argv[1], sys.argv[2] cfg = json.load(open(cfg_path)) cfg.setdefault("gitea", {})["token"] = token json.dump(cfg, open(cfg_path, "w"), indent=2) print(f"→ Wrote gitea.token to {cfg_path}") PY echo echo "✓ Done. The maintenance agent can now push branches and open PRs as $GUSER."