Initial Commit

This commit is contained in:
FanaticPythoner (Nathan Trudeau)
2026-04-27 15:56:43 -04:00
parent 81b23fb2b2
commit 0c159e91fb
25 changed files with 5729 additions and 0 deletions

31
.env.example Normal file
View File

@@ -0,0 +1,31 @@
# Operator environment template. Copy with `just init-env`; then set
# FORGE_GITEA_USERNAME. `.env` is gitignored.
# --- Gitea -----------------------------------------------------------
FORGE_GITEA_URL="https://gitea.cvgitea.ddns.net:6006"
FORGE_GITEA_ORG="codevalet"
FORGE_GITEA_USERNAME=""
# --- Orchestrator ----------------------------------------------------
FORGE_ORCHESTRATOR_REPO_URL="https://gitea.cvgitea.ddns.net:6006/codevalet/forge-stack-orchestrator.git"
# Leave empty for the repo's default branch.
FORGE_ORCHESTRATOR_BRANCH=""
# "." clones into ./forge-stack-orchestrator (gitignored by this repo).
FORGE_WORKSPACE_ROOT="."
# --- OAuth2 PKCE CLI app ---------------------------------------------
# Public client id; PKCE requires no secret.
FSDGG_CLI_CLIENT_ID="ba4ec9ec-8ae8-4450-9cec-fd532bbe63d5"
#
# FSDGG_CLI_REDIRECT_URI is NOT the Gitea URL. It is the local loopback
# listener this CLI binds to receive the OAuth2 authorization-code
# callback. RFC 8252 §7.3 (OAuth 2.0 for Native Apps) mandates
# http-scheme loopback: http://127.0.0.1:<port>/<path>,
# http://[::1]:<port>/<path>, or http://localhost:<port>/<path>.
# The value must also match the redirect URI registered in the Gitea
# OAuth app; change it only if the Gitea app registration was updated
# in sync.
FSDGG_CLI_REDIRECT_URI="http://127.0.0.1:38111/callback"
# Set to 1 to skip TLS verification (self-signed dev Gitea only).
# FORGE_INSECURE_TLS="0"

18
.gitignore vendored Normal file
View File

@@ -0,0 +1,18 @@
# Local-only, never pushed.
.env
.env.example.bak-*
# Working scratch created by recipes.
state/
build/
logs/
# Default in-place orchestrator clone (FORGE_WORKSPACE_ROOT=".").
/forge-stack-orchestrator/
# Python test cache.
__pycache__/
*.pyc
.pytest_cache/
.coverage
TODOS_LISTS/

175
Justfile Normal file
View File

@@ -0,0 +1,175 @@
# welcome-to-codevalet-as-a-platform-operator
#
# Recipe definitions for the platform operator onboarding flow.
# Implementation lives in scripts/; each script has corresponding
# tests under tests/.
set shell := ["bash", "-euo", "pipefail", "-c"]
set dotenv-load := true
set positional-arguments
# Default: show the onboarding plan and the recipe list.
default:
@just welcome
@echo ""
@just --list
# Full interactive onboarding. Accepts --headless / --no-browser / --deploy. Honours FORGE_SETUP_YES=1.
setup *args:
@bash scripts/setup.sh {{args}}
# Full onboarding + orchestrator operator-setup handoff.
deploy *args:
@bash scripts/setup.sh --deploy {{args}}
# Print the onboarding plan.
welcome:
@echo "================================================================"
@echo " Welcome to codevalet."
@echo "================================================================"
@echo ""
@echo " just deploy # full onboarding + operator-setup handoff"
@echo " just deploy --headless # same, without opening a browser"
@echo " just setup # onboarding only (stops after clone)"
@echo " just next-steps # print the orchestrator's operator-setup plan"
@echo " just run-next-steps # execute that plan (== just operator-setup"
@echo " # inside the orchestrator checkout)"
@echo " just relogin # switch Gitea user on the current machine"
@echo " just --list # every available recipe"
# Check prerequisites (tools + Python version + PATH hygiene).
doctor:
@bash scripts/doctor.sh
# Create .env from .env.example the first time. Never overwrites.
init-env:
@if [ -f .env ]; then \
echo "[init-env] .env already exists; leaving it alone."; \
else \
cp .env.example .env; \
echo "[init-env] wrote .env"; \
echo "[init-env] set FORGE_GITEA_USERNAME in .env before login."; \
fi
# Ping Gitea /api/v1/version to confirm FORGE_GITEA_URL is reachable.
check-gitea:
@test -n "${FORGE_GITEA_URL:-}" \
|| { echo "[error] FORGE_GITEA_URL unset: run 'just init-env' and edit .env"; exit 1; }
@echo "[check-gitea] GET $FORGE_GITEA_URL/api/v1/version"
@curl -fsS --max-time 10 ${FORGE_INSECURE_TLS:+-k} "$FORGE_GITEA_URL/api/v1/version" \
| python3 -c 'import json,sys; d=json.load(sys.stdin); print("[check-gitea] Gitea version:", d.get("version","?"))'
@echo "[check-gitea] OK. Gitea is reachable."
# Browser OAuth2 (PKCE) login. Reuses a live token; runs the flow otherwise.
login:
@bash scripts/forge_login.sh
# Like `just login`, but prints the URL instead of opening a browser.
login-headless:
@bash scripts/forge_login.sh --no-browser
# Clear stored Gitea tokens (keeps the credential helper installed and the orchestrator-gateway fields). Run `just login` afterwards to sign in as a different user.
logout:
@python3 scripts/forge_auth.py logout
# `just logout` + `just login` in one step. Equivalent to switching users.
relogin:
@bash scripts/forge_login.sh --force
# Open Gitea's "Authorized OAuth2 Applications" page to revoke a stale grant. Resolves the "different scope" failure mode (see docs/oauth-grant-scope-mismatch.md).
revoke-grant:
@bash scripts/revoke_grant.sh
# Force a token refresh (normally automatic inside the credential helper).
refresh:
@python3 scripts/forge_auth.py refresh --force
# Print the current OAuth state (paths, whether the token is live).
status:
@python3 scripts/forge_auth.py status
# git ls-remote against FORGE_ORCHESTRATOR_REPO_URL to confirm access.
check-access:
@test -n "${FORGE_ORCHESTRATOR_REPO_URL:-}" \
|| { echo "[error] FORGE_ORCHESTRATOR_REPO_URL unset: run 'just init-env' and edit .env"; exit 1; }
@echo "[check-access] git ls-remote $FORGE_ORCHESTRATOR_REPO_URL"
@GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=Never \
GIT_ASKPASS='' SSH_ASKPASS='' \
VSCODE_GIT_ASKPASS_MAIN='' VSCODE_GIT_ASKPASS_NODE='' \
VSCODE_GIT_ASKPASS_EXTRA_ARGS='' VSCODE_GIT_IPC_HANDLE='' \
DISPLAY='' WAYLAND_DISPLAY='' \
git ls-remote "$FORGE_ORCHESTRATOR_REPO_URL" HEAD >/dev/null 2>&1 \
&& { \
echo "[check-access] OK. Orchestrator access is available."; \
} || { \
echo "[check-access] FAILED. Likely causes:"; \
echo " - 'just login' has not completed"; \
echo " - the stored refresh token expired; run 'just relogin'"; \
echo " - the Gitea account is not yet in the org"; \
echo " - FORGE_ORCHESTRATOR_REPO_URL in .env is wrong"; \
exit 1; \
}
# Clone the orchestrator into FORGE_WORKSPACE_ROOT (idempotent).
clone-orchestrator:
@test -n "${FORGE_ORCHESTRATOR_REPO_URL:-}" \
|| { echo "[error] FORGE_ORCHESTRATOR_REPO_URL unset: run 'just init-env' and edit .env"; exit 1; }
@bash -c 'set -euo pipefail; \
root="${FORGE_WORKSPACE_ROOT:-.}"; \
mkdir -p "$root"; \
name="$(basename "$FORGE_ORCHESTRATOR_REPO_URL" .git)"; \
dest="$root/$name"; \
if [ -d "$dest/.git" ]; then \
echo "[clone-orchestrator] already cloned at: $dest"; \
else \
echo "[clone-orchestrator] cloning into: $dest"; \
export GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=Never; \
unset GIT_ASKPASS SSH_ASKPASS \
VSCODE_GIT_ASKPASS_MAIN VSCODE_GIT_ASKPASS_NODE \
VSCODE_GIT_ASKPASS_EXTRA_ARGS VSCODE_GIT_IPC_HANDLE \
DISPLAY WAYLAND_DISPLAY; \
if [ -n "${FORGE_ORCHESTRATOR_BRANCH:-}" ]; then \
git clone --branch "$FORGE_ORCHESTRATOR_BRANCH" "$FORGE_ORCHESTRATOR_REPO_URL" "$dest" \
|| { echo "[clone-orchestrator] FAILED. Run \"just login\" (or \"just relogin\" if expired) and retry." >&2; exit 1; }; \
else \
git clone "$FORGE_ORCHESTRATOR_REPO_URL" "$dest" \
|| { echo "[clone-orchestrator] FAILED. Run \"just login\" (or \"just relogin\" if expired) and retry." >&2; exit 1; }; \
fi; \
fi; \
echo; \
echo "[clone-orchestrator] orchestrator is at:"; \
echo " $dest"; \
echo; \
echo "Continue in that checkout via its README or \"just next-steps\"."'
# Print the platform operator onboarding plan from the orchestrator's manifest.
next-steps *args:
@bash scripts/next_steps.sh {{args}}
# Exec the orchestrator's `just operator-setup` (default; override via --recipe); forwards all flags.
run-next-steps *args:
@bash scripts/next_steps.sh --run {{args}}
# Remove the credential helper and welcome-managed fields from client-auth.json.
uninstall:
@bash scripts/uninstall.sh
# Run the full test suite.
test:
@echo "[test] running Python unit tests..."
@python3 -m unittest discover -t . -s tests -p 'test_*.py' -v
@echo ""
@echo "[test] running shell integration tests..."
@bash tests/test_forge_auth_integration.sh
@echo ""
@echo "[test] running setup.sh argument / headless-wiring test..."
@bash tests/test_setup_args.sh
@echo ""
@echo "[test] running doctor.sh fix-command test..."
@bash tests/test_doctor.sh
@echo ""
@echo "[test] running next_steps.sh manifest-driven test..."
@bash tests/test_next_steps.sh
@echo ""
@echo "[test] running setup.sh --deploy flag test..."
@bash tests/test_setup_deploy_flag.sh

266
README.md
View File

@@ -1,2 +1,268 @@
# welcome-to-codevalet-as-a-platform-operator
Onboarding for platform operators of the codevalet Gitea instance.
Produces an authenticated local checkout of
`forge-stack-orchestrator` with git operations against Gitea
authenticated silently through an OAuth2 (PKCE) token shared with the
orchestrator's devpi gateway, then hands off to the orchestrator's
operator-setup runner.
## Quick start
```bash
just deploy # interactive
just deploy --yes # auto-accept every prompt (session reuse, checkout reuse, handoff)
```
`just deploy` runs `doctor`, `init-env`, `check-gitea`, `login`,
`check-access`, `clone-orchestrator`, and then offers to execute the
orchestrator's `operator-setup` runner. It prompts only when a
decision requires a human (missing Gitea username, existing stored
session, existing orchestrator clone, final go-ahead for
`operator-setup`). Idempotent.
`just setup` is the same flow minus the `operator-setup` handoff:
it stops at `clone-orchestrator`, which is the contributor entry
point. Operators almost always want `just deploy`.
Individual recipes remain available; see `just --list`.
## Prerequisites
Linux, macOS, or Windows-via-WSL with:
- `git`, `bash`, `curl`
- Python 3.11+ (a `uv python install 3.11` interpreter counts)
- [`just`](https://github.com/casey/just#installation)
- [`uv`](https://docs.astral.sh/uv/#installation)
- graphical web browser (optional; see *Headless and SSH hosts*)
- `~/.local/bin` on `PATH`
`just doctor` verifies all of these and, for each miss, prints the
exact install command for the detected platform (`apt` / `dnf` /
`pacman` / `zypper` / `brew`) and a consolidated block at the end. No
recipe in this scaffold runs `sudo` or installs anything system-wide;
any additional host setup required by the orchestrator's runner is
handled inside the orchestrator checkout itself.
A Gitea account on `FORGE_GITEA_URL` with membership in the
`codevalet` organisation is also required.
## Configuring `.env`
`just init-env` copies `.env.example``.env`. The only required
edit is `FORGE_GITEA_USERNAME`. Defaults:
| Variable | Default | Change when |
| --- | --- | --- |
| `FORGE_GITEA_URL` | `https://gitea.cvgitea.ddns.net:6006` | different Gitea instance |
| `FORGE_GITEA_ORG` | `codevalet` | fork or sibling org |
| `FORGE_ORCHESTRATOR_REPO_URL` | `.../codevalet/forge-stack-orchestrator.git` | different fork |
| `FORGE_ORCHESTRATOR_BRANCH` | *(empty: default branch)* | branch pin |
| `FORGE_WORKSPACE_ROOT` | `.` (clone at `./forge-stack-orchestrator`, gitignored) | clone elsewhere |
| `FSDGG_CLI_CLIENT_ID` | registered PKCE CLI client | never |
| `FSDGG_CLI_REDIRECT_URI` | `http://127.0.0.1:38111/callback` | port conflict only; must stay `http://` + loopback per RFC 8252 §7.3 (`127.0.0.1`, `[::1]`, or `localhost`) and match the Gitea OAuth app registration |
**`FSDGG_CLI_REDIRECT_URI` is not the Gitea URL.** The Gitea server is
at `FORGE_GITEA_URL` (remote, HTTPS). The redirect URI is the local
loopback HTTP listener the CLI binds on the local machine so Gitea can
hand back the OAuth authorisation code; OAuth 2.0 for Native Apps
(RFC 8252 §7.3) prohibits any non-loopback / non-HTTP scheme here,
and no public CA will issue a cert for `127.0.0.1` so HTTPS on
loopback is not a meaningful option. On a shared or remote host,
SSH-forward the port (see *Headless and SSH hosts*) rather than
trying to publish the callback over the network.
`.env` is gitignored. OAuth client IDs are public by design; PKCE
requires no client secret.
## Recipes
| Recipe | Effect |
| --- | --- |
| `just deploy` | Full interactive onboarding + orchestrator `operator-setup` handoff. `FORGE_SETUP_YES=1` accepts every default. |
| `just deploy --headless` | Same, but skips `webbrowser.open`; alias `--no-browser`. |
| `just setup` | Onboarding only (stops at `clone-orchestrator`). |
| `just setup --deploy` | Equivalent to `just deploy`; flag-style spelling. |
| `just welcome` | Onboarding plan. |
| `just doctor` | Prerequisite checks with copy-pasteable fixes. |
| `just init-env` | Copies `.env.example``.env`. Never overwrites. |
| `just check-gitea` | Hits `FORGE_GITEA_URL/api/v1/version`. |
| `just login` | Browser PKCE OAuth2 flow; installs the git credential helper. |
| `just login-headless` | `just login` without opening a browser. |
| `just status` | Stored-token state. Exits non-zero when not live. |
| `just refresh` | Forces a token refresh (normally automatic). |
| `just logout` | Clears stored Gitea tokens; keeps the credential helper and orchestrator-gateway fields. |
| `just relogin` | `logout` + `login`. |
| `just check-access` | `git ls-remote` against the orchestrator. |
| `just clone-orchestrator` | Clones into `$FORGE_WORKSPACE_ROOT`. Idempotent. |
| `just next-steps` | Prints the orchestrator's operator-setup plan. |
| `just run-next-steps` | Executes that plan (== `just operator-setup` inside the orchestrator checkout). |
| `just uninstall` | Reverses `login` and `clone-orchestrator`. |
| `just test` | Full test suite. |
## Switching Gitea users
```bash
just relogin # or: just logout && just login
```
`just logout` leaves the credential helper installed and preserves
orchestrator-gateway fields in
`~/.forge-stack-devpi-gateway-gitea/client-auth.json`. Git operations
against `FORGE_GITEA_URL` fail loudly between `logout` and the next
`login`.
## Wrong-user guard
`just login` refuses to persist tokens that do not match
`FORGE_GITEA_USERNAME`:
1. The authorise URL carries `prompt=login` (OIDC Core §3.1.2.1),
forcing Gitea to re-display its login form even under an active
session; when `FORGE_GITEA_USERNAME` is set, `login_hint=<username>`
is also included.
2. After the token exchange, `userinfo.preferred_username` is compared
case-insensitively to `FORGE_GITEA_USERNAME`. On mismatch the token
is discarded, the auth file is left untouched, the Gitea logout
URL (`<FORGE_GITEA_URL>/user/logout?redirect_to=/user/login`) is
opened in the browser, and the CLI exits non-zero with recovery
steps.
## After `just deploy`
`just deploy` hands off to `just operator-setup` inside the
orchestrator checkout; the authoritative runbook for every operator
task lives there. `just setup` (without `--deploy`) stops after the
clone and leaves the handoff for manual execution:
```bash
cd ./forge-stack-orchestrator # or $FORGE_WORKSPACE_ROOT/<repo>
just operator-setup
```
`just login` already wrote the OAuth token to the shared auth file,
and the orchestrator's recipes reuse it without a second login. When the
orchestrator reports a missing gateway bearer, run `just repos-login`
inside the orchestrator checkout; it layers a gateway bearer on top
of the existing Gitea token without a second Gitea login.
## `just login` internals
1. Reads `FORGE_GITEA_URL`, `FSDGG_CLI_CLIENT_ID`, optional
`FORGE_GITEA_USERNAME` from `.env`.
2. GET `<gitea>/.well-known/openid-configuration`; no endpoints are
hardcoded.
3. PKCE verifier (`secrets.token_urlsafe(64)`) and S256 challenge
(RFC 7636).
4. HMAC-signed CSRF `state`; session key held in process memory only.
5. Authorise URL carries `prompt=login` and, if set,
`login_hint=<FORGE_GITEA_USERNAME>`.
6. Opens the default browser (or prints the URL under `--no-browser`)
and starts a one-shot loopback HTTP listener on the port from
`FSDGG_CLI_REDIRECT_URI`.
7. Verifies the returned state, POSTs the code + verifier to the token
endpoint, receives `access_token`, `refresh_token`, `expires_in`.
8. GET `/login/oauth/userinfo` for the authenticated username.
9. If `FORGE_GITEA_USERNAME` is set and does not match, discard the
token and open the Gitea logout URL (see *Wrong-user guard*).
10. Writes `~/.forge-stack-devpi-gateway-gitea/client-auth.json`
atomically (`.tmp` + `chmod 0600` + `rename`). Gateway-owned
fields are preserved.
11. Installs `git-credential-forge` + `forge_auth.py` under
`~/.local/bin/` and sets `git config --global
credential.<FORGE_GITEA_URL>.helper`. Scope is `FORGE_GITEA_URL`
only; other git hosts are untouched.
## Token refresh
Access-token lifetime is set by Gitea (typically one hour). When it
expires the credential helper calls the token endpoint with the
stored refresh token and retries the git operation once; no user
interaction. When the refresh token also expires (Gitea default: 30
days), the helper emits a one-line pointer at `just login` and the
git operation fails.
## Headless and SSH hosts
```bash
just deploy --headless # full onboarding + handoff, no webbrowser.open
just setup --headless # onboarding only, no webbrowser.open
just login-headless # login step only
```
Each prints the authorise URL on stderr. Paste it into any browser
that can reach the loopback callback port (`FSDGG_CLI_REDIRECT_URI`,
default `38111`). For a remote host:
```bash
ssh -L 38111:127.0.0.1:38111 <host>
# then, on <host>:
just deploy --headless
```
`--no-browser` is an alias for `--headless`.
`just deploy --headless` refreshes silently when the access token is
stale but the refresh token is still valid, avoiding the URL-paste
step entirely.
Combining `--headless` with `FORGE_SETUP_YES=1` while a fresh browser
OAuth flow is required is contradictory: the authorise URL must be
pasted into a browser, but `FORGE_SETUP_YES=1` forbids interaction.
`just deploy` (and `just setup`) detects this and exits immediately
rather than hanging. Either drop `FORGE_SETUP_YES=1`, or run
`just login` once on a host with a browser to populate a valid
refresh token before running `just deploy --headless` on the
headless host.
## Troubleshooting
| Symptom | Resolution |
| --- | --- |
| `just doctor` reports a missing tool | Run the `fix:` command printed beside it. |
| `~/.local/bin` not on `PATH` | Add `export PATH="$HOME/.local/bin:$PATH"` to the shell rc and reopen. |
| `just check-gitea` → connection refused | Verify `FORGE_GITEA_URL` and network access. |
| `just login` → browser does not open | Run `just login-headless`. |
| `just login` → timed out waiting for OAuth callback | Consent was not completed in the browser; re-run. |
| `just login` → cannot bind `127.0.0.1:38111` | Another `just login` is running; wait or kill it. |
| "Why is the redirect URI `http://127.0.0.1`? The gateway is remote and HTTPS." | `FSDGG_CLI_REDIRECT_URI` is the CLI's local loopback listener, not the Gitea or gateway URL. OAuth 2.0 for Native Apps (RFC 8252 §7.3) requires `http` on a loopback address. The Gitea server (`FORGE_GITEA_URL`) is the remote HTTPS endpoint, reached during the authorise step. On a remote host, SSH-forward the redirect port. |
| `just deploy --headless` → "cannot complete a fresh login under --headless + FORGE_SETUP_YES=1" | Drop `FORGE_SETUP_YES=1`, or run `just login` once on a host with a browser to populate a refresh token, then re-run `just deploy --headless`. |
| `just login` rejects with a username-mismatch error | Follow the logout link printed, sign in as `FORGE_GITEA_USERNAME`, re-run. |
| `just check-access``Repository not found` | Account not in the `codevalet` org yet. |
| `just check-access` → asks for a password | `just login` did not complete. Re-run. |
| Git prompts for a password on pull/push | Refresh token expired. Run `just relogin`. |
| `just status` shows `live: False` | Run `just refresh`; also happens automatically on the next git op. |
| `just clone-orchestrator` prints `already cloned` | Intended; idempotent. |
| `just deploy` runs fine through step 6 but the handoff fails | Open the orchestrator checkout and re-read its onboarding docs. This scaffold ends at `clone-orchestrator`; everything past it lives in the orchestrator. |
| `just login` or `just deploy` exits with `Gitea server_error: "a grant exists with different scope"` | Run `just revoke-grant` (opens `<FORGE_GITEA_URL>/user/settings/applications` and prints the matching `FSDGG_CLI_CLIENT_ID`). Revoke the matching app, then re-run the failed recipe. Required only once after a scope-set change. Full reference: `docs/oauth-grant-scope-mismatch.md`. |
| Want a clean slate | `just uninstall`. |
## Security properties
- Only a public OAuth client ID ships in `.env.example`; PKCE removes
the need for a client secret.
- `.env` holds configuration only. Tokens live in
`~/.forge-stack-devpi-gateway-gitea/client-auth.json` at mode `0600`.
- The credential helper is scoped to `FORGE_GITEA_URL`; requests for
other hosts flow through git's normal credential chain.
- The OAuth `state` is HMAC-signed with an in-memory session key; a
replayed state from another session does not verify.
- Writes to the auth file are atomic (`.tmp` + `rename`); a crash
during `just login` or `just refresh` leaves the previous valid
state intact.
- `just check-access` and `just clone-orchestrator` neuter
`GIT_TERMINAL_PROMPT`, `GIT_ASKPASS`, and the VSCode / X11 askpass
variables so auth failures surface loudly instead of triggering GUI
credential prompts.
- This scaffold never executes any privileged action on the host;
its sole output is an authenticated orchestrator checkout, after
which every operational task is the orchestrator's responsibility.
## Tests
```bash
just test
```
See `tests/README.md`.

116
scripts/common.sh Executable file
View File

@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# shellcheck shell=bash
#
# Shared helpers sourced by every script in this directory.
# . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh"
#
# Requires bash 4+ and coreutils.
set -euo pipefail
# ---- paths ----------------------------------------------------------
LOCAL_BIN="${HOME}/.local/bin"
CRED_HELPER="${LOCAL_BIN}/git-credential-forge"
# Canonical OAuth auth store: matches
# forge-stack-devpi-gateway-gitea/client_auth.auth_store_path() exactly.
FORGE_AUTH_DIR="${FSDGG_RUNTIME_DIR:-${HOME}/.forge-stack-devpi-gateway-gitea}"
FORGE_AUTH_FILE="${FSDGG_AUTH_STORE_PATH:-${FORGE_AUTH_DIR}/client-auth.json}"
# ---- colors / logging ----------------------------------------------
# ANSI enabled only when stderr is a TTY, NO_COLOR is unset, and
# TERM != "dumb"; empty strings otherwise (piped output byte-identical).
if [ -t 2 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != "dumb" ]; then
_FC_RESET=$'\e[0m'
_FC_BOLD=$'\e[1m'
_FC_DIM=$'\e[2m'
_FC_RED=$'\e[31m'
_FC_GREEN=$'\e[32m'
_FC_YELLOW=$'\e[33m'
_FC_CYAN=$'\e[36m'
_FC_MAGENTA=$'\e[35m'
else
_FC_RESET=''; _FC_BOLD=''; _FC_DIM=''
_FC_RED=''; _FC_GREEN=''; _FC_YELLOW=''
_FC_CYAN=''; _FC_MAGENTA=''
fi
# Width-6 tag inside ANSI envelope: pads "[ok]", "[err]" etc. to 6
# visible columns so subsequent %-wNs fields stay aligned.
_fc_tag() { printf '%s%-6s%s' "$1" "[$2]" "$_FC_RESET"; }
info() { printf '%s %s\n' "$(_fc_tag "$_FC_CYAN" info)" "$*" >&2; }
ok() { printf '%s %s\n' "$(_fc_tag "$_FC_GREEN" ok )" "$*" >&2; }
warn() { printf '%s %s\n' "$(_fc_tag "$_FC_YELLOW" warn)" "$*" >&2; }
err() { printf '%s %s\n' "$(_fc_tag "$_FC_RED" err )" "$*" >&2; }
step() { printf '\n%s==>%s %s%s%s\n' "$_FC_BOLD" "$_FC_RESET" "$_FC_BOLD" "$*" "$_FC_RESET" >&2; }
note() { printf '%s%s%s\n' "$_FC_DIM" "$*" "$_FC_RESET" >&2; }
die() { err "$@"; exit 1; }
# ---- repo locator ---------------------------------------------------
repo_root() {
local here
here="$(cd "$(dirname "${BASH_SOURCE[1]:-$0}")" && pwd -P)"
local d="$here"
for _ in 1 2 3; do
if [ -f "$d/Justfile" ]; then
printf '%s\n' "$d"
return 0
fi
d="$(dirname "$d")"
done
die "cannot locate repo root (Justfile) from $here"
}
# Load $repo/.env into the environment.
# Existing environment values take precedence (matches just dotenv-load).
load_env() {
local root env line key rest
root="$(repo_root)"
env="$root/.env"
[ -f "$env" ] || return 0
while IFS= read -r line || [ -n "$line" ]; do
# Strip CR from CRLF files and leading whitespace.
line="${line%$'\r'}"
line="${line#"${line%%[![:space:]]*}"}"
case "$line" in
''|'#'*) continue ;;
*=*) ;;
*) continue ;;
esac
# Optional leading `export `.
line="${line#export }"
key="${line%%=*}"
rest="${line#*=}"
# Skip malformed keys.
case "$key" in
*[!A-Za-z0-9_]*|'') continue ;;
esac
# Existing env values win.
if [ -n "${!key+x}" ]; then
continue
fi
# Strip matching surrounding single or double quotes.
case "$rest" in
\"*\") rest="${rest#\"}"; rest="${rest%\"}" ;;
\'*\') rest="${rest#\'}"; rest="${rest%\'}" ;;
esac
export "$key=$rest"
done < "$env"
}
# ---- prereq checks --------------------------------------------------
require_cmd() {
local cmd="$1" hint="${2:-}"
if ! command -v "$cmd" >/dev/null 2>&1; then
[ -n "$hint" ] && warn "install hint: $hint"
die "required command not found: $cmd"
fi
}
require_env() {
local name="$1"
if [ -z "${!name:-}" ]; then
die "environment variable '$name' is unset. Run 'just init-env' and set it in .env."
fi
}

221
scripts/doctor.sh Executable file
View File

@@ -0,0 +1,221 @@
#!/usr/bin/env bash
#
# Verify prerequisites for every recipe. For each missing item, prints the
# EXACT command to install it on the detected platform. Exits non-zero if
# any hard requirement is missing; warns but continues for PATH hygiene
# and the web-browser check (headless systems can still use login-headless).
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
# shellcheck disable=SC1091
. "$here/common.sh"
# ---------------------------------------------------------------------------
# Platform detection
# ---------------------------------------------------------------------------
uname_s="$(uname -s 2>/dev/null || echo unknown)"
pm="" # one of: apt dnf pacman zypper brew ""
case "$uname_s" in
Linux)
if [ -r /etc/os-release ]; then
# shellcheck disable=SC1091
. /etc/os-release
id_all=" ${ID:-} ${ID_LIKE:-} "
case "$id_all" in
*' debian '*|*' ubuntu '*) pm=apt ;;
*' fedora '*|*' rhel '*|*' centos '*) pm=dnf ;;
*' arch '*) pm=pacman ;;
*' opensuse '*|*' suse '*) pm=zypper ;;
esac
fi
if [ -z "$pm" ]; then
for candidate in apt-get dnf pacman zypper; do
if command -v "$candidate" >/dev/null 2>&1; then
case "$candidate" in
apt-get) pm=apt ;;
dnf) pm=dnf ;;
pacman) pm=pacman ;;
zypper) pm=zypper ;;
esac
break
fi
done
fi
;;
Darwin)
pm=brew
;;
esac
# ---------------------------------------------------------------------------
# Render a package-install command for the current platform.
# pkg_cmd <apt> <dnf> <pacman> <brew> <upstream-url>
# ---------------------------------------------------------------------------
pkg_cmd() {
local apt_pkg="$1" dnf_pkg="$2" pac_pkg="$3" brew_pkg="$4" url="$5"
case "$pm" in
apt) echo "sudo apt-get update && sudo apt-get install -y $apt_pkg" ;;
dnf) echo "sudo dnf install -y $dnf_pkg" ;;
pacman) echo "sudo pacman -S --noconfirm $pac_pkg" ;;
zypper) echo "sudo zypper install -y $dnf_pkg" ;;
brew) echo "brew install $brew_pkg" ;;
*) echo "# install manually from: $url" ;;
esac
}
# ---------------------------------------------------------------------------
# Accumulators
# ---------------------------------------------------------------------------
missing=0
fix_commands=()
record_miss() {
local name="$1" detail="$2" fix="$3"
printf ' %s %-12s -> %s\n' "$(_fc_tag "$_FC_RED" miss)" "$name" "$detail"
printf ' fix: %s\n' "$fix"
fix_commands+=("$fix")
missing=$((missing + 1))
}
check_cmd() {
local cmd="$1" fix="$2"
if command -v "$cmd" >/dev/null 2>&1; then
printf ' %s %-12s -> %s\n' "$(_fc_tag "$_FC_GREEN" ok)" "$cmd" "$(command -v "$cmd")"
else
record_miss "$cmd" "not found on PATH" "$fix"
fi
}
info 'checking prerequisites...'
# ---------------------------------------------------------------------------
# Required tools
# ---------------------------------------------------------------------------
check_cmd git "$(pkg_cmd git git git git https://git-scm.com/)"
check_cmd bash "$(pkg_cmd bash bash bash bash https://www.gnu.org/software/bash/)"
check_cmd curl "$(pkg_cmd curl curl curl curl https://curl.se/)"
check_cmd install "$(pkg_cmd coreutils coreutils coreutils coreutils https://www.gnu.org/software/coreutils/)"
check_cmd python3 "$(pkg_cmd python3 python3 python python3 https://www.python.org/)"
# just and uv do not ship in most package managers, so prefer the upstream
# installers (no sudo, land binaries in ~/.local/bin / ~/.cargo/bin).
check_cmd just 'curl --proto "=https" --tlsv1.2 -LsSf https://just.systems/install.sh | bash -s -- --to "$HOME/.local/bin"'
check_cmd uv 'curl -LsSf https://astral.sh/uv/install.sh | sh'
# ---------------------------------------------------------------------------
# Web browser (warning only: headless is supported via login-headless)
# ---------------------------------------------------------------------------
if command -v xdg-open >/dev/null 2>&1 || command -v open >/dev/null 2>&1; then
printf ' %s %-12s -> (detected)\n' "$(_fc_tag "$_FC_GREEN" ok)" 'web browser'
else
printf ' %s %-12s -> no xdg-open/open on PATH.\n' "$(_fc_tag "$_FC_YELLOW" warn)" 'web browser'
printf ' if this is a headless machine, run: just login-headless\n'
case "$pm" in
apt) printf ' otherwise install: sudo apt-get install -y xdg-utils\n' ;;
dnf) printf ' otherwise install: sudo dnf install -y xdg-utils\n' ;;
pacman) printf ' otherwise install: sudo pacman -S --noconfirm xdg-utils\n' ;;
esac
fi
# ---------------------------------------------------------------------------
# Python 3.11+ : accepted via system python3 OR uv-managed interpreter
# ---------------------------------------------------------------------------
py_ok=0
py_via=""
if command -v python3 >/dev/null 2>&1; then
if python3 - <<'PY' >/dev/null 2>&1
import sys
sys.exit(0 if sys.version_info >= (3, 11) else 1)
PY
then
py_ok=1
py_via="$(python3 -V 2>&1)"
fi
fi
if [ "$py_ok" -eq 0 ] && command -v uv >/dev/null 2>&1; then
if uv_py_path="$(uv python find '>=3.11' 2>/dev/null)" && [ -n "$uv_py_path" ]; then
py_ok=1
py_via="$uv_py_path (via uv)"
fi
fi
if [ "$py_ok" -eq 1 ]; then
printf ' %s %-12s -> %s\n' "$(_fc_tag "$_FC_GREEN" ok)" 'python>=3.11' "$py_via"
else
detail='python3 missing'
if command -v python3 >/dev/null 2>&1; then
detail="python3 is $(python3 -V 2>&1 | awk '{print $2}'); need 3.11+"
fi
# Primary fix: uv-managed Python. Works on every platform, no sudo, and
# the orchestrator (a uv project) resolves it automatically via
# `uv run` / `uv sync`.
py_fix='uv python install 3.11'
# Per-distro alternative (commented so either command can be copied):
case "$pm" in
apt)
py_alt='sudo add-apt-repository -y ppa:deadsnakes/ppa && sudo apt-get update && sudo apt-get install -y python3.11 python3.11-venv'
;;
dnf)
py_alt='sudo dnf install -y python3.11'
;;
pacman)
py_alt='sudo pacman -S --noconfirm python'
;;
zypper)
py_alt='sudo zypper install -y python311'
;;
brew)
py_alt='brew install python@3.11'
;;
*)
py_alt=''
;;
esac
printf ' %s %-12s -> %s\n' "$(_fc_tag "$_FC_RED" miss)" 'python>=3.11' "$detail"
printf ' fix: %s\n' "$py_fix"
if [ -n "$py_alt" ]; then
printf ' alt: %s\n' "$py_alt"
fi
fix_commands+=("$py_fix")
missing=$((missing + 1))
fi
# ---------------------------------------------------------------------------
# PATH hygiene: warning, not a hard miss.
# ---------------------------------------------------------------------------
case ":$PATH:" in
*":$LOCAL_BIN:"*)
printf ' %s %-12s -> %s is on PATH\n' "$(_fc_tag "$_FC_GREEN" ok)" 'PATH' "$LOCAL_BIN"
;;
*)
rc='~/.bashrc'
[ -n "${ZSH_VERSION:-}" ] && rc='~/.zshrc'
printf ' %s %-12s -> %s is NOT on PATH.\n' "$(_fc_tag "$_FC_YELLOW" warn)" 'PATH' "$LOCAL_BIN"
# shellcheck disable=SC2016
printf " fix: echo 'export PATH=\"%s:\$PATH\"' >> %s && exec \$SHELL -l\n" "$LOCAL_BIN" "$rc"
;;
esac
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
if [ "$missing" -gt 0 ]; then
printf '\n'
warn "$missing prerequisite(s) missing. Run the following to fix them:"
printf '\n'
# De-duplicate while preserving order.
seen=''
for c in "${fix_commands[@]}"; do
case "$seen" in
*"<<$c>>"*) continue ;;
esac
seen="$seen<<$c>>"
printf ' %s\n' "$c"
done
printf '\n'
warn 're-run: just doctor'
exit 1
fi
info 'all prerequisites present'

1174
scripts/forge_auth.py Executable file

File diff suppressed because it is too large Load Diff

29
scripts/forge_login.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
#
# Run the interactive PKCE OAuth2 login against Gitea, then install
# the git credential helper so subsequent git operations against
# FORGE_GITEA_URL are silent. Idempotent.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
# shellcheck disable=SC1091
. "$here/common.sh"
load_env
require_env FORGE_GITEA_URL
require_env FSDGG_CLI_CLIENT_ID
require_cmd python3
# Forward every useful flag. --force re-runs PKCE even if an existing
# live token is present. --no-browser prints the URL but skips the
# webbrowser.open call (for headless / SSH sessions).
python3 "$here/forge_auth.py" login "$@"
# Install the credential helper. Safe to run every time.
"$here/install-git-credential-helper.sh"
ok "login complete"
note "git operations against the configured Gitea server now authenticate silently."
note "Test with:"
note " git ls-remote \"\$FORGE_ORCHESTRATOR_REPO_URL\""

222
scripts/git-credential-forge.py Executable file
View File

@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""Git credential helper backed by the codevalet OAuth auth file.
Git invokes credential helpers with one of three verbs on argv[1]:
get : read key=value fields from stdin; emit credentials on stdout
store : persist credentials (no-op here; tokens are OAuth-owned)
erase : forget credentials (no-op here; ``just logout`` owns that)
On every ``get`` the helper:
1. Reads the auth file (same path the gateway uses:
``~/.forge-stack-devpi-gateway-gitea/client-auth.json`` by default,
or whatever ``FSDGG_AUTH_STORE_PATH`` / ``FSDGG_RUNTIME_DIR`` point
at). If the file is missing, the helper passes git's input through
unchanged so the normal prompt chain continues.
2. Checks that the git request host matches the host recorded in
``_forge_gitea_base_url`` (or ``FORGE_GITEA_URL``). If not, passes
through. This prevents OAuth token disclosure to unrelated
hosts even if git mis-scopes its lookup.
3. If ``gitea_access_token`` is live, emits
``username=<stored-user>`` and ``password=<gitea_access_token>``.
4. If the token is expired and a refresh token is present, runs the
OAuth refresh flow (``forge_auth.run_refresh``) and retries once.
Refresh failures are surfaced by exiting non-zero; git then falls
through to the user's configured helper chain (normally a prompt).
Security notes
--------------
* The helper never writes to stdout except the credential key=value
block. Logs go to stderr.
* On OAuth refresh failure the helper exits **non-zero** rather than silently
returning stale credentials.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from urllib.parse import urlsplit
def _load_forge_auth_module():
"""Import ``forge_auth`` from the same directory as this script.
This works both when the helper is invoked directly from
``scripts/`` (development) and after installation under
``~/.local/bin`` (because the installer copies ``forge_auth.py``
alongside ``git-credential-forge``).
"""
here = Path(__file__).resolve().parent
if str(here) not in sys.path:
sys.path.insert(0, str(here))
import forge_auth # noqa: E402
return forge_auth
forge_auth = _load_forge_auth_module()
# --------------------------------------------------------------------
# git credential protocol I/O
# --------------------------------------------------------------------
def read_git_fields(stream) -> dict[str, str]:
fields: dict[str, str] = {}
for line in stream:
line = line.rstrip("\n")
if not line:
break
if "=" not in line:
raise ValueError(f"unexpected git credential input line: {line!r}")
key, _, value = line.partition("=")
fields[key] = value
return fields
def emit(fields: dict[str, str]) -> None:
for key, value in fields.items():
sys.stdout.write(f"{key}={value}\n")
# --------------------------------------------------------------------
# Host matching
# --------------------------------------------------------------------
def _configured_host() -> tuple[str, str, int | None] | None:
"""Return (scheme, host, port) of the host this helper is allowed
to produce credentials for.
Priority:
1. The ``_forge_gitea_base_url`` field inside the stored auth
file: that is the host recorded during authentication.
2. The ``FORGE_GITEA_URL`` env var (pre-login override).
Returns None if neither is set; the helper then passes through.
"""
store = forge_auth.auth_store_path()
state = forge_auth.AuthFile.read(store)
stored_url = str(state.raw.get("_forge_gitea_base_url") or "").strip()
if stored_url:
parsed = urlsplit(stored_url)
return parsed.scheme.lower(), (parsed.hostname or "").lower(), parsed.port
env_url = os.environ.get("FORGE_GITEA_URL", "").strip()
if env_url:
parsed = urlsplit(env_url)
return parsed.scheme.lower(), (parsed.hostname or "").lower(), parsed.port
return None
def _request_matches(
fields: dict[str, str], configured: tuple[str, str, int | None]
) -> bool:
scheme, host, port = configured
git_scheme = fields.get("protocol", "").lower()
git_host = fields.get("host", "").lower()
# git passes host:port as "host" when the URL carried a port.
if ":" in git_host:
host_part, _, port_str = git_host.partition(":")
try:
git_port = int(port_str)
except ValueError:
return False
git_host = host_part
else:
git_port = None
if git_scheme != scheme:
return False
if git_host != host:
return False
if port is not None and git_port != port:
return False
if port is None and git_port is not None:
# Stored URL had no explicit port (default 443) but request does;
# only match the default-HTTPS case.
if scheme == "https" and git_port != 443:
return False
if scheme == "http" and git_port != 80:
return False
return True
# --------------------------------------------------------------------
# Commands
# --------------------------------------------------------------------
def cmd_get(fields: dict[str, str]) -> int:
configured = _configured_host()
if configured is None:
emit(fields) # pass-through: helper scope is unknown
return 0
if not _request_matches(fields, configured):
emit(fields) # pass-through: request targets a different host
return 0
state = forge_auth.AuthFile.read(forge_auth.auth_store_path())
# Fast path: stored access token is still live.
if state.has_live_gitea_token():
_emit_credentials(fields, state)
return 0
# Slow path: try to refresh.
try:
config = forge_auth.ForgeAuthConfig.from_env()
except forge_auth.AuthError:
forge_auth.cli_warn(
"stored token is expired and FORGE_GITEA_URL / "
"FSDGG_CLI_CLIENT_ID are not set in the environment; "
"cannot refresh. Run 'just login' to re-authenticate."
)
emit(fields)
return 0
try:
refreshed = forge_auth.run_refresh(config, must_refresh=True)
except forge_auth.AuthError as exc:
forge_auth.cli_warn(
f"token refresh failed: {exc}. "
f"Run 'just login' to re-authenticate."
)
emit(fields)
return 0
_emit_credentials(fields, refreshed)
return 0
def _emit_credentials(fields: dict[str, str], state: forge_auth.AuthFile) -> None:
token = state.gitea_access_token
if not token:
emit(fields)
return
out = dict(fields)
out["username"] = state.username or fields.get("username") or "oauth"
out["password"] = token
emit(out)
def cmd_consume_noop(stream) -> int:
for _ in stream:
pass
return 0
def main(argv: list[str]) -> int:
if len(argv) < 2:
print("usage: git-credential-forge <get|store|erase>", file=sys.stderr)
return 2
action = argv[1]
if action == "get":
fields = read_git_fields(sys.stdin)
return cmd_get(fields)
if action in ("store", "erase"):
return cmd_consume_noop(sys.stdin)
print(f"git-credential-forge: unsupported action: {action}", file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main(sys.argv))

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
#
# Install ~/.local/bin/git-credential-forge (copies both
# git-credential-forge.py and its forge_auth.py companion module) and
# wire `git config --global credential.<FORGE_GITEA_URL>.*` so git
# calls the helper for every matching URL. Idempotent.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
# shellcheck disable=SC1091
. "$here/common.sh"
load_env
require_env FORGE_GITEA_URL
require_cmd git
require_cmd python3
helper_src="$here/git-credential-forge.py"
module_src="$here/forge_auth.py"
[ -f "$helper_src" ] || die "missing helper source: $helper_src"
[ -f "$module_src" ] || die "missing module source: $module_src"
mkdir -p "$LOCAL_BIN"
install -m 0755 "$helper_src" "$CRED_HELPER"
install -m 0644 "$module_src" "$LOCAL_BIN/forge_auth.py"
info "installed credential helper -> $CRED_HELPER"
info "installed module -> $LOCAL_BIN/forge_auth.py"
# Syntax check both installed artefacts before the first git invocation.
python3 -c 'import sys; compile(open(sys.argv[1]).read(), sys.argv[1], "exec")' "$CRED_HELPER"
python3 -c 'import sys; compile(open(sys.argv[1]).read(), sys.argv[1], "exec")' "$LOCAL_BIN/forge_auth.py"
# Scope to FORGE_GITEA_URL only. git-config(1): credential.<URL>.*
# applies only when git's resolved credential URL matches. Other hosts
# are not affected.
key="credential.${FORGE_GITEA_URL}.helper"
use_path_key="credential.${FORGE_GITEA_URL}.useHttpPath"
# Remove previous helper entries to avoid stacking duplicate helpers.
git config --global --unset-all "$key" 2>/dev/null || true
git config --global --unset-all "$use_path_key" 2>/dev/null || true
# Username is derived from the OAuth token at runtime by the helper.
# credential.<URL>.username remains unset to avoid pinning stale state.
# The helper emits username=<stored> on every `get` request.
git config --global --add "$key" "$CRED_HELPER"
git config --global "$use_path_key" true
info "git config --global $key -> $CRED_HELPER"
info "git config --global $use_path_key -> true"

162
scripts/next_steps.sh Executable file
View File

@@ -0,0 +1,162 @@
#!/usr/bin/env bash
# shellcheck shell=bash
#
# Render or execute a setup plan defined by one of the orchestrator's
# setup-steps manifests. The welcome repo does not hardcode step
# lists; every step is read from the manifest at runtime.
#
# Default manifest / recipe is the operator pair
# (operator_setup_steps.json / operator-setup). setup.sh in this
# scaffold always targets the operator pair; the --manifest and
# --recipe flags here are the single override point for callers that
# need the contributor pair (or any future pair).
#
# Modes:
# default : print the plan.
# --run : exec `just <recipe>` inside the orchestrator.
#
# Manifest + recipe selection:
# --manifest NAME json file under <orchestrator>/scripts/
# (default: operator_setup_steps.json).
# --recipe NAME just recipe to exec under --run
# (default: operator-setup).
#
# Flags (forwarded to the orchestrator under --run):
# --headless, --no-browser headless mode
# --yes non-interactive
# --dry-run, --plan-only plan-only execution
# --skip-optional skip optional steps
# --only ID[,ID...] run the listed step ids
# -h, --help usage
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
# shellcheck disable=SC1091
. "$here/common.sh"
load_env
MODE="print" # "print" or "run"
# Operator-first defaults. `just next-steps` prints the operator plan
# and `just run-next-steps` execs `just operator-setup`. The
# contributor pair is reachable only via explicit --manifest /
# --recipe overrides.
MANIFEST_FILE="operator_setup_steps.json"
RECIPE="operator-setup"
forward_args=()
usage() {
cat <<'USAGE'
Usage: just next-steps [--run] [flags]
just run-next-steps [flags]
Manifest + recipe:
--manifest NAME json file under <orchestrator>/scripts/
(default: operator_setup_steps.json)
--recipe NAME just recipe to exec under --run
(default: operator-setup)
Flags (forwarded): --headless, --no-browser, --yes, --dry-run,
--skip-optional, --only ID[,ID...]
Step list source: <orchestrator>/scripts/<MANIFEST_FILE>.
USAGE
}
while [ $# -gt 0 ]; do
case "$1" in
--run) MODE=run; shift ;;
--manifest) shift; [ $# -gt 0 ] || die "--manifest needs an argument"; MANIFEST_FILE="$1"; shift ;;
--manifest=*) MANIFEST_FILE="${1#--manifest=}"; shift ;;
--recipe) shift; [ $# -gt 0 ] || die "--recipe needs an argument"; RECIPE="$1"; shift ;;
--recipe=*) RECIPE="${1#--recipe=}"; shift ;;
--headless|--no-browser) forward_args+=("--headless"); shift ;;
--yes) forward_args+=("--yes"); shift ;;
--dry-run|--plan-only) forward_args+=("--dry-run"); shift ;;
--skip-optional) forward_args+=("--skip-optional"); shift ;;
--only) shift; [ $# -gt 0 ] || die "--only needs an argument"; forward_args+=("--only" "$1"); shift ;;
--only=*) forward_args+=("--only" "${1#--only=}"); shift ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;;
*) die "unexpected argument: $1 (try --help)" ;;
esac
done
# Locate the orchestrator checkout.
workspace_root="${FORGE_WORKSPACE_ROOT:-.}"
repo_url="${FORGE_ORCHESTRATOR_REPO_URL:-}"
if [ -z "$repo_url" ]; then
die "FORGE_ORCHESTRATOR_REPO_URL is unset; run 'just init-env' and edit .env"
fi
repo_name="$(basename "$repo_url" .git)"
orchestrator="$workspace_root/$repo_name"
if [ ! -d "$orchestrator/.git" ]; then
err "orchestrator checkout not found at $orchestrator"
note "run 'just clone-orchestrator' (or 'just setup') first."
exit 1
fi
manifest="$orchestrator/scripts/$MANIFEST_FILE"
if [ ! -f "$manifest" ]; then
warn "orchestrator checkout at $orchestrator does not ship"
warn " scripts/$MANIFEST_FILE (expected in newer versions)."
note "falling back to the orchestrator README; open:"
note " $orchestrator/README.md"
exit 1
fi
step "next steps (source: orchestrator manifest)"
note "source: $manifest"
note "orchestrator: $orchestrator"
printf '\n' >&2
python3 - "$manifest" >&2 <<'PY'
import json, sys, textwrap
path = sys.argv[1]
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if data.get("schema_version") != 1:
sys.stderr.write(
f"[err] unsupported setup-steps manifest schema in {path}: "
f"{data.get('schema_version')!r}\n"
)
sys.exit(2)
steps = data.get("steps", [])
total = len(steps)
for i, s in enumerate(steps, 1):
sid = s["id"]
title = s.get("title", sid)
desc = s.get("desc", "")
cmd = " ".join(s["cmd"])
opt = "" if "optional" in title.lower() else (" [optional]" if s.get("optional") else "")
sys.stderr.write(f" ({i}/{total}) {title}{opt}\n")
sys.stderr.write(f" id: {sid}\n")
if desc:
for line in textwrap.wrap(desc, width=76,
initial_indent=" ",
subsequent_indent=" "):
sys.stderr.write(line + "\n")
sys.stderr.write(f" run: {cmd}\n\n")
PY
if [ "$MODE" != "run" ]; then
# ``${arr:+...}`` on an empty array triggers set -u; build explicitly.
fwd_suffix=""
if [ "${#forward_args[@]}" -gt 0 ]; then
fwd_suffix=" ${forward_args[*]}"
fi
note "Execute via:"
note " just run-next-steps --manifest ${MANIFEST_FILE} --recipe ${RECIPE}${fwd_suffix}"
note "Execute inside the orchestrator:"
note " cd $orchestrator && just ${RECIPE}${fwd_suffix}"
exit 0
fi
step "exec just ${RECIPE} (cwd=$orchestrator)"
if ! command -v just >/dev/null 2>&1; then
die "just not on PATH; install via 'just doctor' fix hint"
fi
cd "$orchestrator"
exec just "${RECIPE}" "${forward_args[@]}"

51
scripts/revoke_grant.sh Executable file
View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
#
# revoke_grant.sh: open Gitea's "Authorized OAuth2 Applications" page so
# the operator can revoke a stale OAuth grant whose scope set no longer
# matches the unified scope set requested by this scaffold and the
# orchestrator's gateway. See docs/oauth-grant-scope-mismatch.md for
# the full failure mode and recovery procedure.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
# shellcheck disable=SC1091
. "$here/common.sh"
load_env
require_env FORGE_GITEA_URL
require_env FSDGG_CLI_CLIENT_ID
require_cmd python3
base="${FORGE_GITEA_URL%/}"
url="${base}/user/settings/applications"
cid="${FSDGG_CLI_CLIENT_ID}"
cat <<EOF
[revoke-grant] Authorized OAuth2 Applications:
${url}
[revoke-grant] Client ID to revoke:
${cid}
Procedure:
1. The browser opens the URL above.
2. Locate the row whose Client ID matches ${cid}.
3. Press "Revoke".
4. Return here and run 'just login' (or re-run 'just deploy').
EOF
if [ "${FORGE_REVOKE_NO_BROWSER:-0}" = "1" ]; then
info "FORGE_REVOKE_NO_BROWSER=1 set; skipping browser launch."
exit 0
fi
python3 - "$url" <<'PY'
import sys, webbrowser
url = sys.argv[1]
ok = webbrowser.open(url, new=1, autoraise=True)
print(
"[revoke-grant] "
+ ("opened in browser." if ok else "no browser launched; open the URL manually.")
)
PY

314
scripts/setup.sh Executable file
View File

@@ -0,0 +1,314 @@
#!/usr/bin/env bash
# shellcheck shell=bash
#
# Interactive onboarding driver. Sequences doctor, init-env,
# check-gitea, login, check-access, and clone-orchestrator, prompting
# only when a decision requires a human.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
# shellcheck disable=SC1091
. "$here/common.sh"
root="$(repo_root)"
cd "$root"
headless=0
deploy=0
usage() {
cat <<'USAGE'
Usage: just setup [--headless|--no-browser] [--deploy] [--yes|-y]
Options:
--headless Do not open the browser during login. Prints the
--no-browser authorisation URL to stderr instead; paste it into
any browser that can reach the loopback callback
port (typically via SSH port-forward, see README).
--deploy After cloning the orchestrator, prompt [Y/n]
(default Y) to exec the orchestrator's
`just operator-setup` runner (reads
operator_setup_steps.json). Equivalent to
`just deploy`. Without --deploy, setup prints the
operator plan and stops after the clone with no
prompt and no auto-run.
--yes, -y Auto-accept every prompt (session reuse, checkout
reuse, handoff confirmation) by setting
FORGE_SETUP_YES=1 for this run. Safe only when
FORGE_GITEA_USERNAME is already set in .env. Does
not relax the --headless guard (a fresh PKCE login
still requires either a browser or a pre-populated
refresh token).
-h, --help Show this message.
Environment:
FORGE_SETUP_YES=1 Same as --yes; honoured even when no flag is given.
USAGE
}
while [ $# -gt 0 ]; do
case "$1" in
--headless|--no-browser) headless=1; shift;;
--deploy) deploy=1; shift;;
--yes|-y) export FORGE_SETUP_YES=1; shift;;
-h|--help) usage; exit 0;;
--) shift; break;;
-*) die "unknown option: $1 (try 'just setup --help')";;
*) die "unexpected positional argument: $1 (try 'just setup --help')";;
esac
done
# info/ok/warn/err/step/note/die: see scripts/common.sh.
# Stdout = chosen reply; stderr = prompt/echo.
prompt_choice() {
local msg="$1" default="$2" reply
if [ "${FORGE_SETUP_YES:-0}" = "1" ] || [ ! -t 0 ]; then
printf '%s %s [%s] (auto: %s)\n' \
"$(_fc_tag "$_FC_MAGENTA" '?')" "$msg" "$default" "$default" >&2
reply="$default"
else
printf '%s %s [%s] ' \
"$(_fc_tag "$_FC_MAGENTA" '?')" "$msg" "$default" >/dev/tty
IFS= read -r reply </dev/tty || reply=""
[ -z "$reply" ] && reply="$default"
fi
printf '%s' "$reply"
}
prompt_line() {
local msg="$1" reply
if [ "${FORGE_SETUP_YES:-0}" = "1" ] || [ ! -t 0 ]; then
die "non-interactive mode requested ($msg) but no default is safe; run 'just setup' in a terminal"
fi
printf '%s %s: ' "$(_fc_tag "$_FC_MAGENTA" '?')" "$msg" >/dev/tty
IFS= read -r reply </dev/tty || reply=""
printf '%s' "$reply"
}
step "1/7 checking prerequisites (just doctor)"
bash "$here/doctor.sh"
step "2/7 preparing .env"
if [ ! -f "$root/.env" ]; then
cp "$root/.env.example" "$root/.env"
ok "wrote $root/.env from template"
else
ok ".env already present"
fi
load_env
if [ -z "${FORGE_GITEA_USERNAME:-}" ]; then
note "FORGE_GITEA_USERNAME is blank. Enter the Gitea username exactly as it appears in the profile URL."
new_user="$(prompt_line "Gitea username")"
if [ -z "$new_user" ]; then
die "no username entered: aborting. Edit .env by hand and re-run 'just setup'."
fi
python3 - "$root/.env" "$new_user" <<'PY'
import pathlib, sys
path = pathlib.Path(sys.argv[1])
value = sys.argv[2]
lines = path.read_text(encoding="utf-8").splitlines()
touched = False
for i, line in enumerate(lines):
stripped = line.lstrip()
if stripped.startswith("FORGE_GITEA_USERNAME=") or stripped.startswith("export FORGE_GITEA_USERNAME="):
prefix = "export " if stripped.startswith("export ") else ""
lines[i] = f'{prefix}FORGE_GITEA_USERNAME="{value}"'
touched = True
break
if not touched:
lines.append(f'FORGE_GITEA_USERNAME="{value}"')
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text("\n".join(lines) + "\n", encoding="utf-8")
tmp.replace(path)
PY
export FORGE_GITEA_USERNAME="$new_user"
ok "set FORGE_GITEA_USERNAME=$new_user in .env"
else
ok "FORGE_GITEA_USERNAME=$FORGE_GITEA_USERNAME"
fi
step "3/7 checking Gitea reachability (just check-gitea)"
curl -fsS --max-time 10 ${FORGE_INSECURE_TLS:+-k} \
"${FORGE_GITEA_URL}/api/v1/version" \
| python3 -c 'import json,sys; d=json.load(sys.stdin); print("[check-gitea] Gitea version:", d.get("version","?"))'
ok "reached $FORGE_GITEA_URL"
step "4/7 authenticating with Gitea (just login)"
existing_user=""
have_live=0
have_stored=0
if [ -f "$FORGE_AUTH_FILE" ]; then
have_stored=1
existing_user="$(python3 -c '
import json, sys
try:
d = json.load(open(sys.argv[1]))
print(d.get("username") or "")
except Exception:
print("")
' "$FORGE_AUTH_FILE" 2>/dev/null || true)"
if python3 "$here/forge_auth.py" status >/dev/null 2>&1; then
have_live=1
fi
fi
if [ "$have_stored" = "1" ] && [ "$have_live" = "0" ] \
&& [ -n "$existing_user" ] && [ -n "${FORGE_GITEA_USERNAME:-}" ] \
&& [ "${existing_user,,}" = "${FORGE_GITEA_USERNAME,,}" ]; then
note "stored session for '$existing_user' is not live; attempting silent refresh..."
if python3 "$here/forge_auth.py" refresh --force >/dev/null 2>&1; then
ok "refreshed stored session without a browser"
have_live=1
else
note "refresh failed (refresh token likely expired); a fresh login is required"
fi
fi
run_login=1
if [ "$have_live" = "1" ] && [ -n "$existing_user" ]; then
if [ -n "${FORGE_GITEA_USERNAME:-}" ] \
&& [ "${existing_user,,}" != "${FORGE_GITEA_USERNAME,,}" ]; then
note "stored token is for '$existing_user' but .env configures '$FORGE_GITEA_USERNAME'"
reply="$(prompt_choice "Log out and sign in as '$FORGE_GITEA_USERNAME'? [Y/n]" "Y")"
case "$reply" in
[Nn]*) run_login=0; note "keeping the existing '$existing_user' session (forge_login may still reject it)";;
*) python3 "$here/forge_auth.py" logout >/dev/null; ok "cleared stored Gitea tokens";;
esac
else
ok "stored token is live for '$existing_user'"
reply="$(prompt_choice "Reuse this session, or log out and re-authenticate? [R]euse / [L]ogout [R]" "R")"
case "$reply" in
[Ll]*) python3 "$here/forge_auth.py" logout >/dev/null; ok "cleared stored Gitea tokens";;
*) run_login=0; ok "reusing the stored session";;
esac
fi
elif [ "$have_stored" = "1" ] && [ -n "$existing_user" ]; then
note "stored session for '$existing_user' is stale and could not be refreshed silently; re-authenticating."
fi
if [ "$run_login" = "1" ]; then
if [ "$headless" = "1" ] && [ "${FORGE_SETUP_YES:-0}" = "1" ]; then
die "cannot complete a fresh login under --headless + FORGE_SETUP_YES=1:
a browser-based OAuth step is required but both flags
forbid any interaction. Recovery options:
1. Drop FORGE_SETUP_YES and re-run 'just setup --headless'
(setup prints the authorisation URL and waits for
browser completion).
2. Run 'just login' once on a machine with a browser
to populate the stored refresh token, then re-run
'just setup --headless' from the headless host.
3. Run 'just setup' (with a browser available) instead."
fi
if [ "$headless" = "1" ]; then
bash "$here/forge_login.sh" --no-browser
else
bash "$here/forge_login.sh"
fi
else
bash "$here/install-git-credential-helper.sh" >/dev/null
fi
step "5/7 confirming silent git access (just check-access)"
GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=Never \
GIT_ASKPASS="" SSH_ASKPASS="" \
VSCODE_GIT_ASKPASS_MAIN="" VSCODE_GIT_ASKPASS_NODE="" \
VSCODE_GIT_ASKPASS_EXTRA_ARGS="" VSCODE_GIT_IPC_HANDLE="" \
DISPLAY="" WAYLAND_DISPLAY="" \
git ls-remote "$FORGE_ORCHESTRATOR_REPO_URL" HEAD >/dev/null 2>&1 \
|| die "git ls-remote failed. The Gitea account may not yet be in the org, or FORGE_ORCHESTRATOR_REPO_URL is wrong."
ok "silent clone access confirmed"
step "6/7 cloning the orchestrator (just clone-orchestrator)"
workspace_root="${FORGE_WORKSPACE_ROOT:-.}"
repo_name="$(basename "$FORGE_ORCHESTRATOR_REPO_URL" .git)"
dest="$workspace_root/$repo_name"
if [ -d "$dest/.git" ]; then
note "orchestrator already present at $dest"
reply="$(prompt_choice "Reuse existing checkout, or wipe and re-clone? [R]euse / [W]ipe [R]" "R")"
case "$reply" in
[Ww]*)
note "removing $dest"
rm -rf "$dest"
;;
*)
ok "keeping existing checkout"
;;
esac
fi
if [ ! -d "$dest/.git" ]; then
mkdir -p "$workspace_root"
export GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=Never
unset GIT_ASKPASS SSH_ASKPASS \
VSCODE_GIT_ASKPASS_MAIN VSCODE_GIT_ASKPASS_NODE \
VSCODE_GIT_ASKPASS_EXTRA_ARGS VSCODE_GIT_IPC_HANDLE \
DISPLAY WAYLAND_DISPLAY
if [ -n "${FORGE_ORCHESTRATOR_BRANCH:-}" ]; then
git clone --branch "$FORGE_ORCHESTRATOR_BRANCH" \
"$FORGE_ORCHESTRATOR_REPO_URL" "$dest"
else
git clone "$FORGE_ORCHESTRATOR_REPO_URL" "$dest"
fi
ok "cloned into $dest"
fi
step "7/7 next steps (from the orchestrator's operator manifest)"
# The operator scaffold is pinned to the operator manifest/recipe pair;
# `--deploy` only controls whether to prompt for an automatic handoff.
# The contributor manifest is never referenced from this scaffold.
manifest_name="operator_setup_steps.json"
recipe_name="operator-setup"
manifest_path="$dest/scripts/$manifest_name"
if [ ! -f "$manifest_path" ]; then
warn "orchestrator at $dest does not ship $manifest_name"
warn "(older checkout?). See the orchestrator's README for onboarding:"
note " $dest/README.md"
exit 0
fi
# Authoritative step list: $manifest_path in the orchestrator checkout.
# This welcome repo never hardcodes the sequence.
bash "$here/next_steps.sh" --manifest "$manifest_name" --recipe "$recipe_name" || true
forward=()
[ "$headless" = "1" ] && forward+=("--headless")
[ "${FORGE_SETUP_YES:-0}" = "1" ] && forward+=("--yes")
if [ "$deploy" != "1" ]; then
# `just setup` (no --deploy) prints the plan and stops: no prompt,
# no auto-run. The hint below is informational only.
note "skipping auto-run (no --deploy). To execute the plan later:"
if [ "${#forward[@]}" -gt 0 ]; then
note " just run-next-steps --manifest $manifest_name --recipe $recipe_name ${forward[*]}"
else
note " just run-next-steps --manifest $manifest_name --recipe $recipe_name"
fi
note "or, inside the orchestrator checkout:"
note " cd $dest && just $recipe_name"
exit 0
fi
# --deploy path: prompt [Y/n] (default Y). FORGE_SETUP_YES=1 accepts Y.
reply="$(prompt_choice "Hand off to just $recipe_name now? [Y/n]" "Y")"
case "$reply" in
[Nn]*)
note "skipping auto-run. To execute later:"
if [ "${#forward[@]}" -gt 0 ]; then
note " just run-next-steps --manifest $manifest_name --recipe $recipe_name ${forward[*]}"
else
note " just run-next-steps --manifest $manifest_name --recipe $recipe_name"
fi
note "or, inside the orchestrator checkout:"
note " cd $dest && just $recipe_name"
;;
*)
step "handing off to the orchestrator"
exec bash "$here/next_steps.sh" --run --manifest "$manifest_name" --recipe "$recipe_name" "${forward[@]}"
;;
esac

40
scripts/uninstall.sh Executable file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
#
# Reverse every side effect `just login` produced. Safe to re-run.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
# shellcheck disable=SC1091
. "$here/common.sh"
load_env
# 1. Log out at the OAuth layer (clears welcome-managed fields from
# client-auth.json and keeps any fields owned by the orchestrator
# gateway untouched).
if [ -f "$here/forge_auth.py" ]; then
python3 "$here/forge_auth.py" logout || true
fi
# 2. Remove git credential helper + git config entries scoped to the
# Gitea URL.
if [ -n "${FORGE_GITEA_URL:-}" ]; then
for k in helper username useHttpPath; do
git config --global --unset-all "credential.${FORGE_GITEA_URL}.$k" 2>/dev/null || true
done
git config --global --remove-section "credential.${FORGE_GITEA_URL}" 2>/dev/null || true
info "cleared git credential.${FORGE_GITEA_URL}.*"
fi
# 3. Remove installed artefacts.
for f in "$CRED_HELPER" "$LOCAL_BIN/forge_auth.py"; do
if [ -e "$f" ]; then
rm -f "$f"
info "removed $f"
fi
done
info 'uninstall complete.'
info "note: $FORGE_AUTH_FILE is left in place if the orchestrator wrote it;"
info ' delete it manually when a completely clean state is required.'

91
tests/README.md Normal file
View File

@@ -0,0 +1,91 @@
# Tests
Deterministic and hermetic. Integration tests stand up a local mock
OIDC/OAuth2 server on an ephemeral port; no traffic to a real Gitea.
```bash
just test # full suite
python3 -m unittest discover -t . -s tests -p 'test_*.py' -v
bash tests/test_forge_auth_integration.sh
bash tests/test_setup_args.sh
bash tests/test_setup_deploy_flag.sh
bash tests/test_doctor.sh
bash tests/test_next_steps.sh
```
## Inventory
- **`test_forge_auth.py`**: `scripts/forge_auth.py` unit tests: PKCE
pair generation, HMAC state signing + CSRF rejection,
`ForgeAuthConfig.from_env` validation (loopback-only redirect,
missing port, missing env vars, `FORGE_GITEA_USERNAME`
propagation), `build_authorize_url` with `prompt=login` and
`login_hint`, `build_gitea_logout_url`, `AuthFile`
read/write/merge/`has_live_gitea_token`, `auth_store_path`
precedence, `run_logout`, `main()` dispatcher.
- **`test_git_credential_forge.py`**: `scripts/git-credential-forge.py`
unit tests: credential protocol I/O, host/scheme/port matching,
live-token fast-path, pass-through for missing store or non-matching
host, expired-token refresh, refresh-failure handling, `store` /
`erase` no-ops, `main()` dispatcher.
- **`test_forge_auth_integration.py`**: end-to-end Python integration
tests against `tests/mock_oidc_server.py`: full PKCE flow,
gateway-required schema on disk, idempotent re-login, refresh-token
rotation with server-side revocation, logout preserving
gateway-bearer fields.
- **`test_forge_auth_integration.sh`**: shell end-to-end: drives
`forge_auth.py login` against the mock server, installs the
credential helper into a sandboxed `$HOME`, and exercises
`git credential fill`. Covers URL matching, `github.com`
non-leakage, rotated-token pickup, `just logout` teardown, and the
username-mismatch guard (login fails, auth file untouched, Gitea
logout URL surfaced, authorise URL carries `prompt=login` +
`login_hint`).
- **`test_setup_args.sh`**: `scripts/setup.sh` coverage: argument
parsing, `--help`, `--headless` wiring to `forge_login.sh
--no-browser`, the `--headless + FORGE_SETUP_YES=1` hang guard,
live-token reuse, silent-refresh rescue, `prompt_choice` non-tty
stdout isolation.
- **`test_setup_deploy_flag.sh`**: `scripts/setup.sh --deploy`
coverage: `--deploy` is parsed and surfaced in `--help`,
`setup.sh` with `--deploy` swaps the manifest/recipe pair to
`operator_setup_steps.json` / `operator-setup`, non-interactive
runs either hand off or print the `just run-next-steps --manifest
operator_setup_steps.json --recipe operator-setup` follow-up
hint, and `next_steps.sh --run --manifest ... --recipe
operator-setup` execs the expected recipe in the orchestrator
checkout.
- **`test_doctor.sh`**: `scripts/doctor.sh`: miss-path under a
sandboxed PATH, asserts every `[miss]` line is followed by a `fix:`
line and the consolidated block is emitted.
- **`test_next_steps.sh`**: `scripts/next_steps.sh` contract:
operator manifest / recipe is the default,
`--manifest contributor_setup_steps.json --recipe contributor-setup`
swaps to the contributor runner, missing manifest warns and
points at the orchestrator README, `--headless` / `--yes` /
`--skip-optional` / `--only ID` forward verbatim to the selected
`just <recipe>`. Fixtures use generic step ids (the real manifests
ship with the orchestrator).
- **`mock_oidc_server.py`**: test fixture implementing
`/.well-known/openid-configuration`, `/login/oauth/authorize`,
`/login/oauth/access_token`, `/login/oauth/userinfo`. PKCE
verification on `authorization_code`; rotation + revocation on
`refresh_token`.
## Adding tests
- Python: drop `test_*.py` in this directory, use `unittest`, stdlib
only.
- Shell: executable, deterministic, non-interactive. Use ephemeral
ports via `python3 -c 'import socket; ...'` and sandbox `$HOME`
with `mktemp -d`. Stub external binaries (e.g. `just`, `git`) by
dropping a script into a temp `fakebin/` and prepending it to
`PATH`.

0
tests/__init__.py Executable file
View File

230
tests/mock_oidc_server.py Executable file
View File

@@ -0,0 +1,230 @@
"""Minimal OIDC + OAuth2 PKCE server used by the integration test.
Implements the subset of Gitea's `/.well-known/openid-configuration`,
`/login/oauth/authorize`, `/login/oauth/access_token`, and
`/login/oauth/userinfo` surface for the welcome-repo's `forge_auth.py`
to run an end-to-end login + refresh without touching a real Gitea.
Test fixture only. Binds to loopback, accepts any non-empty
`client_id`, and issues deterministic opaque tokens; it does not
model authentication or authorisation. Not suitable for any purpose
other than driving the welcome-repo client during tests.
"""
from __future__ import annotations
import base64
import hashlib
import json
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Callable, cast
from urllib.parse import parse_qs, urlencode, urlparse
class _State:
"""In-memory bookkeeping shared by every handler instance."""
def __init__(self, *, base_url: str, username: str) -> None:
self.base_url = base_url
self.username = username
# code -> {client_id, redirect_uri, code_challenge, used}
self.pending_codes: dict[str, dict[str, Any]] = {}
# refresh_token -> {client_id, revoked}
self.refresh_tokens: dict[str, dict[str, Any]] = {}
self.access_token_expires_in = 3600
self.access_token_counter = 0
self.refresh_token_counter = 0
def issue_access_token(self) -> str:
self.access_token_counter += 1
return f"access-{self.access_token_counter}"
def issue_refresh_token(self, *, client_id: str) -> str:
self.refresh_token_counter += 1
tok = f"refresh-{self.refresh_token_counter}"
self.refresh_tokens[tok] = {"client_id": client_id, "revoked": False}
return tok
def _verify_pkce(challenge: str, verifier: str) -> bool:
expected = (
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest())
.rstrip(b"=")
.decode("ascii")
)
return expected == challenge
class _Handler(BaseHTTPRequestHandler):
state: _State
def _send_json(self, code: int, body: dict) -> None:
data = json.dumps(body).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _read_form(self) -> dict[str, str]:
length = int(self.headers.get("Content-Length", "0") or "0")
raw = self.rfile.read(length).decode("ascii") if length else ""
return {k: v[0] for k, v in parse_qs(raw, keep_blank_values=True).items()}
def do_GET(self) -> None: # noqa: N802
parsed = urlparse(self.path)
path = parsed.path
query = {k: v[0] for k, v in parse_qs(parsed.query).items()}
if path == "/.well-known/openid-configuration":
base = self.state.base_url
self._send_json(200, {
"issuer": base,
"authorization_endpoint": f"{base}/login/oauth/authorize",
"token_endpoint": f"{base}/login/oauth/access_token",
"userinfo_endpoint": f"{base}/login/oauth/userinfo",
})
return
if path == "/login/oauth/authorize":
client_id = query.get("client_id", "")
redirect_uri = query.get("redirect_uri", "")
code_challenge = query.get("code_challenge", "")
state_value = query.get("state", "")
if not (client_id and redirect_uri and code_challenge and state_value):
self._send_json(400, {"error": "invalid_request"})
return
code = f"code-{len(self.state.pending_codes) + 1}"
self.state.pending_codes[code] = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"code_challenge": code_challenge,
"used": False,
}
sep = "&" if "?" in redirect_uri else "?"
location = (
f"{redirect_uri}{sep}"
f"{urlencode({'code': code, 'state': state_value})}"
)
self.send_response(302)
self.send_header("Location", location)
self.end_headers()
return
if path == "/login/oauth/userinfo":
auth = self.headers.get("Authorization", "")
if not auth.startswith("Bearer access-"):
self._send_json(401, {"error": "unauthorized"})
return
self._send_json(200, {
"sub": "1",
"preferred_username": self.state.username,
"name": self.state.username,
"email": f"{self.state.username}@example.test",
})
return
self._send_json(404, {"error": "not_found"})
def do_POST(self) -> None: # noqa: N802
if self.path != "/login/oauth/access_token":
self._send_json(404, {"error": "not_found"})
return
form = self._read_form()
grant = form.get("grant_type", "")
if grant == "authorization_code":
code = form.get("code", "")
verifier = form.get("code_verifier", "")
client_id = form.get("client_id", "")
entry = self.state.pending_codes.get(code)
if not entry or entry["used"]:
self._send_json(400, {"error": "invalid_grant",
"error_description": "code not found or already used"})
return
if entry["client_id"] != client_id:
self._send_json(400, {"error": "invalid_client"})
return
if not _verify_pkce(entry["code_challenge"], verifier):
self._send_json(400, {"error": "invalid_grant",
"error_description": "PKCE verification failed"})
return
entry["used"] = True
access = self.state.issue_access_token()
refresh = self.state.issue_refresh_token(client_id=client_id)
self._send_json(200, {
"access_token": access,
"refresh_token": refresh,
"expires_in": self.state.access_token_expires_in,
"token_type": "Bearer",
"scope": "openid profile email",
})
return
if grant == "refresh_token":
rt = form.get("refresh_token", "")
client_id = form.get("client_id", "")
entry = self.state.refresh_tokens.get(rt)
if not entry or entry["revoked"]:
self._send_json(400, {"error": "invalid_grant",
"error_description": "refresh token invalid or revoked"})
return
if entry["client_id"] != client_id:
self._send_json(400, {"error": "invalid_client"})
return
# Rotate: revoke the old refresh token, issue new pair.
entry["revoked"] = True
access = self.state.issue_access_token()
new_rt = self.state.issue_refresh_token(client_id=client_id)
self._send_json(200, {
"access_token": access,
"refresh_token": new_rt,
"expires_in": self.state.access_token_expires_in,
"token_type": "Bearer",
})
return
self._send_json(400, {"error": "unsupported_grant_type"})
def log_message(self, format: str, *args: Any) -> None: # noqa: A003
return
def make_server(*, username: str = "testuser") -> tuple[ThreadingHTTPServer, _State, str]:
# Bind to ephemeral port, then set base_url so the handler knows its URL.
server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
port = server.server_address[1]
base_url = f"http://127.0.0.1:{port}"
state = _State(base_url=base_url, username=username)
# Share the state across all handler instances via the class attr.
cast(type, _Handler).state = state # type: ignore[assignment]
return server, state, base_url
def serve_forever(server: ThreadingHTTPServer) -> threading.Thread:
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return thread
def main() -> int:
import os
username = os.environ.get("MOCK_OIDC_USERNAME", "testuser")
server, state, base_url = make_server(username=username)
serve_forever(server)
sys.stdout.write(f"{base_url}\n")
sys.stdout.flush()
try:
# Block until killed.
while True:
time.sleep(3600)
except KeyboardInterrupt:
server.shutdown()
return 0
if __name__ == "__main__":
sys.exit(main())

68
tests/test_doctor.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
#
# scripts/doctor.sh contract (miss path):
# 1. Exits non-zero.
# 2. Each miss is followed by a `fix: ...` line.
# 3. Emits a consolidated "Run the following to fix them" block.
# 4. No raw ANSI escapes leak into non-TTY stderr.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd -P)"
repo="$(cd "$here/.." && pwd -P)"
out="$(mktemp)"
trap 'rm -f "$out"' EXIT
# Strip tools from PATH so the miss branches execute.
# /usr/bin/python3 is 3.10.x on Ubuntu 22.04; this exercises the
# python>=3.11 miss branch and its fix+alt lines.
if env -i HOME="$HOME" PATH="/usr/bin:/bin" bash "$repo/scripts/doctor.sh" >"$out" 2>&1; then
echo "FAIL: doctor.sh exited 0 despite missing prerequisites"
cat "$out"
exit 1
fi
fail=0
must_contain() {
local needle="$1"
if ! grep -Fq -- "$needle" "$out"; then
echo "FAIL: expected to see: $needle"
fail=1
fi
}
must_contain '[miss] just'
must_contain '[miss] uv'
must_contain '[miss] python>=3.11'
must_contain 'fix: curl --proto "=https" --tlsv1.2 -LsSf https://just.systems/install.sh'
must_contain 'fix: curl -LsSf https://astral.sh/uv/install.sh | sh'
must_contain 'fix: uv python install 3.11'
must_contain 'Run the following to fix them'
# No raw ANSI sequences when stderr is redirected to a file.
if grep -q $'\033\\[' "$out"; then
echo "FAIL: raw ANSI escape leaked into non-TTY stderr"
fail=1
fi
# Each [miss] line is followed by a "fix:" line. `[miss]` is 6 chars
# so %-6s adds no padding; the literal " %-12s" supplies one sep space.
awk '
/^ \[miss\]/ { miss_line = NR; next }
miss_line && NR == miss_line + 1 {
if ($0 !~ /^ fix: /) {
printf "FAIL: [miss] at line %d not followed by fix: (got: %s)\n", miss_line, $0
exit 1
}
miss_line = 0
}
' "$out" || fail=1
if [ "$fail" -ne 0 ]; then
echo "---- doctor.sh output ----"
cat "$out"
exit 1
fi
echo "PASS: doctor.sh prints fix commands for every miss"

976
tests/test_forge_auth.py Executable file
View File

@@ -0,0 +1,976 @@
"""Unit tests for scripts/forge_auth.py.
Pure-logic tests (no network, no browser). The full PKCE flow is
covered by tests/test_forge_auth_integration.sh, which stands up a
local mock Gitea OIDC server and drives the CLI end-to-end.
Run with: python3 -m unittest tests.test_forge_auth
"""
from __future__ import annotations
import base64
import hashlib
import io
import json
import os
import sys
import tempfile
import time
import unittest
from pathlib import Path
from unittest import mock
from urllib.parse import urlparse
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent
sys.path.insert(0, str(ROOT / "scripts"))
import forge_auth as fa # noqa: E402
# --------------------------------------------------------------------
# PKCE primitives
# --------------------------------------------------------------------
class PkcePairTests(unittest.TestCase):
def test_verifier_length_in_range(self) -> None:
v, _ = fa.pkce_pair()
# RFC 7636: 43 <= len(verifier) <= 128
self.assertGreaterEqual(len(v), 43)
self.assertLessEqual(len(v), 128)
def test_challenge_is_correct_s256_encoding(self) -> None:
v, c = fa.pkce_pair()
expected = (
base64.urlsafe_b64encode(hashlib.sha256(v.encode("ascii")).digest())
.rstrip(b"=")
.decode("ascii")
)
self.assertEqual(c, expected)
def test_pairs_are_unique(self) -> None:
pairs = {fa.pkce_pair() for _ in range(10)}
self.assertEqual(len(pairs), 10)
class StateSigningTests(unittest.TestCase):
def setUp(self) -> None:
self.key = b"\x01" * 32
def test_roundtrip(self) -> None:
nonce = "abc.123" # dot in nonce must still round-trip (rpartition)
signed = fa.sign_state(self.key, nonce)
self.assertEqual(fa.verify_state(self.key, signed), nonce)
def test_tampered_mac_rejected(self) -> None:
signed = fa.sign_state(self.key, "n1")
tampered = signed[:-1] + ("0" if signed[-1] != "0" else "1")
with self.assertRaises(fa.AuthError):
fa.verify_state(self.key, tampered)
def test_wrong_key_rejected(self) -> None:
signed = fa.sign_state(self.key, "n1")
with self.assertRaises(fa.AuthError):
fa.verify_state(b"\x02" * 32, signed)
def test_missing_separator_raises(self) -> None:
with self.assertRaises(fa.AuthError):
fa.verify_state(self.key, "nosignaturehere")
# --------------------------------------------------------------------
# ForgeAuthConfig.from_env
# --------------------------------------------------------------------
class ConfigFromEnvTests(unittest.TestCase):
def _env(self, **overrides: str) -> dict[str, str]:
env = {
"FORGE_GITEA_URL": "https://gitea.example.com",
"FSDGG_CLI_CLIENT_ID": "my-client-id",
"FSDGG_CLI_REDIRECT_URI": "http://127.0.0.1:38111/callback",
}
env.update(overrides)
return env
def test_happy_path(self) -> None:
with mock.patch.dict(os.environ, self._env(), clear=True):
cfg = fa.ForgeAuthConfig.from_env()
self.assertEqual(cfg.gitea_base_url, "https://gitea.example.com")
self.assertEqual(cfg.client_id, "my-client-id")
self.assertEqual(cfg.redirect_uri, "http://127.0.0.1:38111/callback")
self.assertFalse(cfg.insecure_tls)
def test_trailing_slash_stripped(self) -> None:
with mock.patch.dict(
os.environ,
self._env(FORGE_GITEA_URL="https://gitea.example.com/"),
clear=True,
):
cfg = fa.ForgeAuthConfig.from_env()
self.assertEqual(cfg.gitea_base_url, "https://gitea.example.com")
def test_localhost_redirect_allowed(self) -> None:
with mock.patch.dict(
os.environ,
self._env(FSDGG_CLI_REDIRECT_URI="http://localhost:45000/cb"),
clear=True,
):
cfg = fa.ForgeAuthConfig.from_env()
self.assertEqual(cfg.redirect_uri, "http://localhost:45000/cb")
def test_ipv6_loopback_redirect_allowed(self) -> None:
with mock.patch.dict(
os.environ,
self._env(FSDGG_CLI_REDIRECT_URI="http://[::1]:45000/cb"),
clear=True,
):
cfg = fa.ForgeAuthConfig.from_env()
self.assertEqual(cfg.redirect_uri, "http://[::1]:45000/cb")
def test_non_loopback_rejected(self) -> None:
with mock.patch.dict(
os.environ,
self._env(FSDGG_CLI_REDIRECT_URI="https://example.com/cb"),
clear=True,
):
with self.assertRaises(fa.AuthError) as ctx:
fa.ForgeAuthConfig.from_env()
self.assertIn("RFC 8252", str(ctx.exception))
def test_https_loopback_rejected(self) -> None:
with mock.patch.dict(
os.environ,
self._env(FSDGG_CLI_REDIRECT_URI="https://127.0.0.1:38111/cb"),
clear=True,
):
with self.assertRaises(fa.AuthError) as ctx:
fa.ForgeAuthConfig.from_env()
self.assertIn("RFC 8252", str(ctx.exception))
def test_missing_port_rejected(self) -> None:
with mock.patch.dict(
os.environ,
self._env(FSDGG_CLI_REDIRECT_URI="http://127.0.0.1/cb"),
clear=True,
):
with self.assertRaises(fa.AuthError):
fa.ForgeAuthConfig.from_env()
def test_missing_required_env_var_lists_all_missing(self) -> None:
env = self._env()
env["FORGE_GITEA_URL"] = ""
env["FSDGG_CLI_CLIENT_ID"] = ""
with mock.patch.dict(os.environ, env, clear=True):
with self.assertRaises(fa.AuthError) as ctx:
fa.ForgeAuthConfig.from_env()
msg = str(ctx.exception)
self.assertIn("FORGE_GITEA_URL", msg)
self.assertIn("FSDGG_CLI_CLIENT_ID", msg)
def test_expected_username_read_from_env(self) -> None:
with mock.patch.dict(
os.environ,
self._env(FORGE_GITEA_USERNAME="CVJMAllaire"),
clear=True,
):
cfg = fa.ForgeAuthConfig.from_env()
self.assertEqual(cfg.expected_username, "CVJMAllaire")
def test_expected_username_defaults_to_empty(self) -> None:
with mock.patch.dict(os.environ, self._env(), clear=True):
cfg = fa.ForgeAuthConfig.from_env()
self.assertEqual(cfg.expected_username, "")
def test_insecure_tls_flag(self) -> None:
with mock.patch.dict(
os.environ, self._env(FORGE_INSECURE_TLS="1"), clear=True
):
cfg = fa.ForgeAuthConfig.from_env()
self.assertTrue(cfg.insecure_tls)
# --------------------------------------------------------------------
# build_authorize_url
# --------------------------------------------------------------------
class BuildAuthorizeUrlTests(unittest.TestCase):
def setUp(self) -> None:
self.cfg = fa.ForgeAuthConfig(
gitea_base_url="https://g.example",
client_id="client-1",
redirect_uri="http://127.0.0.1:38111/callback",
)
self.endpoints = {
"authorization_endpoint": "https://g.example/login/oauth/authorize",
"token_endpoint": "https://g.example/login/oauth/access_token",
"userinfo_endpoint": "https://g.example/login/oauth/userinfo",
}
def test_url_contains_all_required_parameters(self) -> None:
url = fa.build_authorize_url(
self.cfg, self.endpoints, challenge="c123", state="s123"
)
self.assertTrue(url.startswith("https://g.example/login/oauth/authorize?"))
# All required PKCE + OAuth2 params
for token in (
"response_type=code",
"client_id=client-1",
"code_challenge=c123",
"code_challenge_method=S256",
"state=s123",
"redirect_uri=http%3A%2F%2F127.0.0.1%3A38111%2Fcallback",
):
self.assertIn(token, url)
def test_url_always_includes_prompt_login(self) -> None:
url = fa.build_authorize_url(
self.cfg, self.endpoints, challenge="c", state="s"
)
# prompt=login forces Gitea to display the login page even when a
# session is already active: prevents silent auth under the
# wrong identity.
self.assertIn("prompt=login", url)
def test_url_adds_login_hint_when_expected_username_set(self) -> None:
cfg = fa.ForgeAuthConfig(
gitea_base_url="https://g.example",
client_id="client-1",
redirect_uri="http://127.0.0.1:38111/callback",
expected_username="CVJMAllaire",
)
url = fa.build_authorize_url(
cfg, self.endpoints, challenge="c", state="s"
)
self.assertIn("login_hint=CVJMAllaire", url)
def test_url_omits_login_hint_when_expected_username_unset(self) -> None:
url = fa.build_authorize_url(
self.cfg, self.endpoints, challenge="c", state="s"
)
self.assertNotIn("login_hint=", url)
class BuildGiteaLogoutUrlTests(unittest.TestCase):
def test_composes_logout_url_with_redirect_to_login(self) -> None:
url = fa.build_gitea_logout_url("https://gitea.example.com")
self.assertEqual(
url,
"https://gitea.example.com/user/logout?redirect_to=%2Fuser%2Flogin",
)
def test_trailing_slash_is_stripped(self) -> None:
url = fa.build_gitea_logout_url("https://gitea.example.com/")
self.assertTrue(url.startswith("https://gitea.example.com/user/logout"))
self.assertNotIn("//user/logout", url)
# --------------------------------------------------------------------
# AuthFile (read / merge / write / has_live_gitea_token)
# --------------------------------------------------------------------
class AuthFileTests(unittest.TestCase):
def test_read_missing_file_returns_empty(self) -> None:
f = fa.AuthFile.read(Path("/nonexistent/path/client-auth.json"))
self.assertEqual(f.raw, {})
self.assertFalse(f.has_live_gitea_token())
def test_read_malformed_json_raises(self) -> None:
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "bad.json"
p.write_text("{ not json", encoding="utf-8")
with self.assertRaises(fa.AuthError):
fa.AuthFile.read(p)
def test_read_non_object_raises(self) -> None:
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "arr.json"
p.write_text("[1,2,3]", encoding="utf-8")
with self.assertRaises(fa.AuthError):
fa.AuthFile.read(p)
def test_has_live_gitea_token_requires_token(self) -> None:
f = fa.AuthFile(raw={"gitea_access_token": ""})
self.assertFalse(f.has_live_gitea_token())
def test_has_live_gitea_token_expired(self) -> None:
f = fa.AuthFile(raw={
"gitea_access_token": "t",
"gitea_token_expires_at": time.time() - 10,
})
self.assertFalse(f.has_live_gitea_token())
def test_has_live_gitea_token_live(self) -> None:
f = fa.AuthFile(raw={
"gitea_access_token": "t",
"gitea_token_expires_at": time.time() + 3600,
})
self.assertTrue(f.has_live_gitea_token())
def test_has_live_gitea_token_unknown_expiry_is_trusted(self) -> None:
f = fa.AuthFile(raw={"gitea_access_token": "t"})
self.assertTrue(f.has_live_gitea_token())
def test_merge_login_fills_required_gateway_fields(self) -> None:
f = fa.AuthFile()
f.merge_login(
username="alice",
gitea_access_token="AAA",
gitea_token_expires_at=time.time() + 3600,
refresh_token="RRR",
client_id="client-1",
gitea_base_url="https://g.example",
)
for required in (
"username", "access_token", "expires_in",
"issued_at", "public_base_url", "index_name",
):
self.assertIn(required, f.raw)
self.assertEqual(f.raw["username"], "alice")
self.assertEqual(f.raw["gitea_access_token"], "AAA")
self.assertEqual(f.raw["_forge_refresh_token"], "RRR")
self.assertEqual(f.raw["_forge_client_id"], "client-1")
self.assertEqual(f.raw["_forge_gitea_base_url"], "https://g.example")
def test_merge_login_preserves_gateway_bearer(self) -> None:
# Gateway bearer fields remain intact across a Gitea login write.
f = fa.AuthFile(raw={
"username": "old-alice",
"access_token": "GATEWAY-BEARER",
"expires_in": 7200,
"issued_at": 1000000.0,
"public_base_url": "https://gateway.example",
"index_name": "forge",
})
f.merge_login(
username="alice",
gitea_access_token="NEW-GITEA",
gitea_token_expires_at=time.time() + 3600,
refresh_token="NEW-RT",
client_id="client-1",
gitea_base_url="https://g.example",
)
self.assertEqual(f.raw["access_token"], "GATEWAY-BEARER")
self.assertEqual(f.raw["public_base_url"], "https://gateway.example")
self.assertEqual(f.raw["index_name"], "forge")
# And the Gitea fields got refreshed
self.assertEqual(f.raw["gitea_access_token"], "NEW-GITEA")
self.assertEqual(f.raw["username"], "alice")
def test_merge_refresh_only_touches_gitea_fields(self) -> None:
f = fa.AuthFile(raw={
"username": "alice",
"access_token": "GATEWAY-BEARER",
"public_base_url": "https://gateway.example",
})
f.merge_refresh(
gitea_access_token="FRESH",
gitea_token_expires_at=time.time() + 3600,
refresh_token="ROTATED",
)
self.assertEqual(f.raw["access_token"], "GATEWAY-BEARER")
self.assertEqual(f.raw["gitea_access_token"], "FRESH")
self.assertEqual(f.raw["_forge_refresh_token"], "ROTATED")
def test_merge_refresh_empty_refresh_token_keeps_existing(self) -> None:
f = fa.AuthFile(raw={"_forge_refresh_token": "KEEP"})
f.merge_refresh(
gitea_access_token="NEW", gitea_token_expires_at=None, refresh_token=""
)
self.assertEqual(f.raw["_forge_refresh_token"], "KEEP")
def test_write_is_atomic_and_0600(self) -> None:
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "sub" / "client-auth.json"
f = fa.AuthFile(raw={"username": "u", "gitea_access_token": "T"})
f.write(p)
self.assertTrue(p.is_file())
mode = p.stat().st_mode & 0o777
self.assertEqual(mode, 0o600)
# The tmp file must not still exist
self.assertFalse((p.parent / "client-auth.json.tmp").exists())
roundtrip = json.loads(p.read_text(encoding="utf-8"))
self.assertEqual(roundtrip["username"], "u")
def test_write_preserves_unknown_keys(self) -> None:
# Forward-compat: preserve unknown gateway fields verbatim.
raw = {"username": "u", "future_field": {"x": 1}, "access_token": "A"}
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "a.json"
fa.AuthFile(raw=dict(raw)).write(p)
roundtrip = json.loads(p.read_text(encoding="utf-8"))
self.assertEqual(roundtrip, raw)
# --------------------------------------------------------------------
# auth_store_path
# --------------------------------------------------------------------
class AuthStorePathTests(unittest.TestCase):
def test_explicit_env_var_wins(self) -> None:
with mock.patch.dict(
os.environ,
{"FSDGG_AUTH_STORE_PATH": "/tmp/somewhere/a.json", "FSDGG_RUNTIME_DIR": "/other"},
clear=True,
):
self.assertEqual(fa.auth_store_path(), Path("/tmp/somewhere/a.json"))
def test_runtime_dir_fallback(self) -> None:
with mock.patch.dict(
os.environ, {"FSDGG_RUNTIME_DIR": "/tmp/rt"}, clear=True
):
self.assertEqual(fa.auth_store_path(), Path("/tmp/rt/client-auth.json"))
def test_default_path(self) -> None:
with mock.patch.dict(os.environ, {}, clear=True):
self.assertEqual(fa.auth_store_path(), fa.DEFAULT_AUTH_FILE)
# --------------------------------------------------------------------
# run_logout
# --------------------------------------------------------------------
class RunLogoutTests(unittest.TestCase):
"""Invariant: every assertion runs *inside* the TemporaryDirectory
context. Tempdir teardown after the assertions would delete the
file under test and produce a false positive on
``assertFalse(is_file)``.
"""
def test_logout_no_file(self) -> None:
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "client-auth.json"
with mock.patch.dict(
os.environ, {"FSDGG_AUTH_STORE_PATH": str(p)}, clear=True
):
result = fa.run_logout()
self.assertIsNone(result)
self.assertFalse(p.is_file())
def test_logout_only_welcome_fields_removes_file(self) -> None:
payload = {
"username": "u", "access_token": "", "expires_in": 0,
"issued_at": 0.0, "public_base_url": "", "index_name": "",
"gitea_access_token": "A", "_forge_refresh_token": "R",
}
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "client-auth.json"
p.write_text(json.dumps(payload), encoding="utf-8")
with mock.patch.dict(
os.environ, {"FSDGG_AUTH_STORE_PATH": str(p)}, clear=True
):
result = fa.run_logout()
self.assertEqual(result, p)
self.assertFalse(p.is_file())
def test_logout_preserves_gateway_bearer(self) -> None:
payload = {
"username": "u",
"access_token": "GATEWAY-BEARER",
"expires_in": 3600,
"issued_at": 1000000.0,
"public_base_url": "https://gateway.example",
"index_name": "forge",
"gitea_access_token": "GITEA-A",
"gitea_token_expires_at": 2000000.0,
"_forge_refresh_token": "R",
"_forge_client_id": "c",
}
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "client-auth.json"
p.write_text(json.dumps(payload), encoding="utf-8")
with mock.patch.dict(
os.environ, {"FSDGG_AUTH_STORE_PATH": str(p)}, clear=True
):
fa.run_logout()
self.assertTrue(p.is_file())
remaining = json.loads(p.read_text(encoding="utf-8"))
self.assertEqual(remaining["access_token"], "GATEWAY-BEARER")
self.assertEqual(remaining["public_base_url"], "https://gateway.example")
self.assertNotIn("gitea_access_token", remaining)
self.assertNotIn("_forge_refresh_token", remaining)
# --------------------------------------------------------------------
# main() dispatcher
# --------------------------------------------------------------------
class MainDispatcherTests(unittest.TestCase):
def test_unknown_command_rc_2(self) -> None:
self.assertEqual(fa.main(["forge_auth.py", "nope"]), 2)
def test_no_args_rc_2(self) -> None:
self.assertEqual(fa.main(["forge_auth.py"]), 2)
def test_status_on_empty_store_rc_1(self) -> None:
with tempfile.TemporaryDirectory() as d:
with mock.patch.dict(
os.environ,
{"FSDGG_AUTH_STORE_PATH": str(Path(d) / "a.json")},
clear=True,
):
rc = fa.main(["forge_auth.py", "status"])
self.assertEqual(rc, 1)
def test_status_on_live_token_rc_0(self) -> None:
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "a.json"
p.write_text(json.dumps({
"username": "u",
"gitea_access_token": "LIVE",
"gitea_token_expires_at": time.time() + 3600,
}), encoding="utf-8")
with mock.patch.dict(
os.environ, {"FSDGG_AUTH_STORE_PATH": str(p)}, clear=True
):
rc = fa.main(["forge_auth.py", "status"])
self.assertEqual(rc, 0)
class HeadlessGuidanceTests(unittest.TestCase):
AUTH_URL = "https://gitea.example/login/oauth/authorize?x=1"
REDIRECT = urlparse("http://127.0.0.1:54321/callback")
def _capture(self, env_extra: dict[str, str] | None = None) -> str:
env = {"USER": "alice"}
if env_extra is not None:
for k in ("SSH_CONNECTION", "SSH_TTY"):
env.pop(k, None)
env.update(env_extra)
buf = io.StringIO()
with mock.patch.dict(os.environ, env, clear=True), \
mock.patch("forge_auth.sys.stderr", buf), \
mock.patch("forge_auth.socket.getfqdn", return_value="box.example"), \
mock.patch("forge_auth.socket.gethostname", return_value="box.example"):
fa._print_headless_guidance(self.AUTH_URL, self.REDIRECT)
return buf.getvalue()
def test_prints_authorize_url(self) -> None:
out = self._capture()
self.assertIn(self.AUTH_URL, out)
def test_prints_rfc_8252_reference(self) -> None:
self.assertIn("RFC 8252", self._capture())
def test_uses_configured_redirect_port(self) -> None:
out = self._capture()
self.assertIn("127.0.0.1:54321", out)
self.assertNotIn("38111", out)
def test_ssh_forward_template_uses_configured_port(self) -> None:
out = self._capture()
self.assertIn("ssh -L 54321:127.0.0.1:54321 alice@box.example", out)
def test_reachability_probe_included(self) -> None:
out = self._capture()
self.assertIn("curl -sS -m 2 http://127.0.0.1:54321/", out)
self.assertIn("Connection refused", out)
def test_ssh_branch_when_SSH_CONNECTION_set(self) -> None:
out = self._capture({"SSH_CONNECTION": "1.2.3.4 22 5.6.7.8 22", "USER": "alice"})
self.assertIn("running inside an SSH session", out)
def test_non_ssh_branch_when_no_ssh_env(self) -> None:
out = self._capture()
self.assertIn("if this machine is remote", out)
self.assertNotIn("running inside an SSH session", out)
class BuildAuthorizeErrorTests(unittest.TestCase):
"""Contract tests for ``_build_authorize_error``."""
BASE = "https://gitea.example.com"
CID = "ba4ec9ec-8ae8-4450-9cec-fd532bbe63d5"
SCOPES = "openid profile email read:user read:organization read:repository write:repository"
def _exc_different_scope(self, **overrides: str) -> fa.AuthError:
kw: dict[str, object] = {
"error": "server_error",
"error_description": "a grant exists with different scope",
"gitea_base_url": self.BASE,
"client_id": self.CID,
"scopes": self.SCOPES,
}
kw.update(overrides)
return fa._build_authorize_error(
str(kw["error"]),
str(kw["error_description"]) if kw["error_description"] else None,
str(kw["gitea_base_url"]),
client_id=str(kw["client_id"]),
scopes=str(kw["scopes"]),
)
def test_different_scope_returns_autherror(self) -> None:
self.assertIsInstance(self._exc_different_scope(), fa.AuthError)
def test_different_scope_message_is_five_lines_max(self) -> None:
# RULE #2 §D.3: user-facing error bodies cap at 5 lines.
s = str(self._exc_different_scope())
self.assertLessEqual(len(s.splitlines()), 5)
def test_different_scope_surfaces_gitea_phrasing(self) -> None:
self.assertIn("different scope", str(self._exc_different_scope()).lower())
def test_different_scope_cites_settings_url(self) -> None:
self.assertIn(
f"{self.BASE}/user/settings/applications",
str(self._exc_different_scope()),
)
def test_different_scope_disambiguates_authorized_section(self) -> None:
# Gitea's settings page has both "Authorized OAuth2 Applications"
# (user grants) and "Manage OAuth2 Applications" (app registrations);
# the message must point at the first.
self.assertIn(
'"Authorized OAuth2 Applications"',
str(self._exc_different_scope()),
)
def test_different_scope_includes_full_client_id(self) -> None:
self.assertIn(self.CID, str(self._exc_different_scope()))
def test_different_scope_includes_requested_scopes(self) -> None:
self.assertIn(self.SCOPES, str(self._exc_different_scope()))
def test_different_scope_links_to_remediation_doc(self) -> None:
# External rationale lives in the doc per RULE #2 §D/E.
self.assertIn(
"docs/oauth-grant-scope-mismatch.md",
str(self._exc_different_scope()),
)
def test_different_scope_without_base_url_uses_placeholder(self) -> None:
self.assertIn(
"<gitea-base-url>/user/settings/applications",
str(self._exc_different_scope(gitea_base_url="")),
)
def test_different_scope_without_client_id_uses_placeholder(self) -> None:
self.assertIn(
"<unknown-client-id>",
str(self._exc_different_scope(client_id="")),
)
def test_different_scope_without_scopes_uses_placeholder(self) -> None:
self.assertIn(
"<unknown-scopes>",
str(self._exc_different_scope(scopes="")),
)
def test_access_denied_branch(self) -> None:
exc = fa._build_authorize_error(
"access_denied",
"The resource owner or authorization server denied the request.",
self.BASE,
)
self.assertIn("access_denied", str(exc))
self.assertIn("just login", str(exc))
def test_fallback_preserves_raw_error_and_description(self) -> None:
exc = fa._build_authorize_error(
"invalid_request", "redirect_uri mismatch", self.BASE,
)
s = str(exc)
self.assertIn("invalid_request", s)
self.assertIn("redirect_uri mismatch", s)
def test_fallback_with_no_description_only_includes_error_code(self) -> None:
exc = fa._build_authorize_error("temporarily_unavailable", None, self.BASE)
self.assertIn("temporarily_unavailable", str(exc))
class AuthorizeUrlIsHeadlessInvariantTests(unittest.TestCase):
"""Pin the invariant: authorize URL is independent of ``--no-browser``."""
def setUp(self) -> None:
self.cfg = fa.ForgeAuthConfig(
gitea_base_url="https://gitea.example.com",
client_id="ba4ec9ec-8ae8-4450-9cec-fd532bbe63d5",
redirect_uri="http://127.0.0.1:38111/callback",
expected_username="alice",
)
self.endpoints = {
"authorization_endpoint": "https://gitea.example.com/login/oauth/authorize",
"token_endpoint": "https://gitea.example.com/login/oauth/access_token",
"userinfo_endpoint": "https://gitea.example.com/login/oauth/userinfo",
}
def test_build_authorize_url_signature_has_no_headless_parameter(self) -> None:
import inspect
sig = inspect.signature(fa.build_authorize_url)
self.assertNotIn("open_browser", sig.parameters)
self.assertNotIn("headless", sig.parameters)
self.assertNotIn("no_browser", sig.parameters)
def test_url_is_deterministic_for_fixed_challenge_and_state(self) -> None:
url_a = fa.build_authorize_url(
self.cfg, self.endpoints, challenge="ch_1", state="st_1"
)
url_b = fa.build_authorize_url(
self.cfg, self.endpoints, challenge="ch_1", state="st_1"
)
self.assertEqual(url_a, url_b)
def test_scope_parameter_matches_config_scopes_exactly(self) -> None:
from urllib.parse import parse_qs, urlparse
url = fa.build_authorize_url(
self.cfg, self.endpoints, challenge="c", state="s"
)
params = parse_qs(urlparse(url).query)
self.assertEqual(params["scope"], [self.cfg.scopes])
def test_default_url_carries_prompt_login(self) -> None:
from urllib.parse import parse_qs, urlparse
url = fa.build_authorize_url(
self.cfg, self.endpoints, challenge="c", state="s"
)
params = parse_qs(urlparse(url).query)
self.assertEqual(params["prompt"], ["login"])
def test_force_login_prompt_false_drops_prompt_param(self) -> None:
from urllib.parse import parse_qs, urlparse
url = fa.build_authorize_url(
self.cfg,
self.endpoints,
challenge="c",
state="s",
force_login_prompt=False,
)
params = parse_qs(urlparse(url).query)
self.assertNotIn("prompt", params)
# login_hint must still ride along; the retry path keeps it so
# Gitea can pre-fill the username field if a fresh login screen
# ever does appear (e.g., expired session cookie).
self.assertEqual(params["login_hint"], [self.cfg.expected_username])
class ScopeMismatchAuthErrorTests(unittest.TestCase):
"""Contract tests for the ``ScopeMismatchAuthError`` subclass.
The "different scope" branch of ``_build_authorize_error`` must
return a ``ScopeMismatchAuthError`` so callers can drive a
revoke-and-retry recovery flow instead of swallowing the error.
The class is also a subclass of ``AuthError`` so existing
``except AuthError`` handlers (e.g., the credential helper) keep
working unchanged.
"""
BASE = "https://gitea.example.com"
CID = "ba4ec9ec-8ae8-4450-9cec-fd532bbe63d5"
SCOPES = "openid profile email read:user read:organization read:repository write:repository"
def _build(self, **overrides):
kw = {
"error": "server_error",
"error_description": "a grant exists with different scope",
"gitea_base_url": self.BASE,
"client_id": self.CID,
"scopes": self.SCOPES,
}
kw.update(overrides)
return fa._build_authorize_error(
str(kw["error"]),
str(kw["error_description"]) if kw["error_description"] else None,
str(kw["gitea_base_url"]),
client_id=str(kw["client_id"]),
scopes=str(kw["scopes"]),
)
def test_different_scope_returns_subclass(self) -> None:
self.assertIsInstance(self._build(), fa.ScopeMismatchAuthError)
def test_subclass_inherits_from_autherror(self) -> None:
self.assertTrue(issubclass(fa.ScopeMismatchAuthError, fa.AuthError))
def test_attributes_carry_diagnostic_fields(self) -> None:
exc = self._build()
self.assertEqual(exc.gitea_base_url, self.BASE)
self.assertEqual(exc.client_id, self.CID)
self.assertEqual(exc.scopes, self.SCOPES)
def test_revoke_url_with_base(self) -> None:
exc = self._build()
self.assertEqual(
exc.revoke_url,
f"{self.BASE}/user/settings/applications",
)
def test_revoke_url_strips_trailing_slash(self) -> None:
exc = self._build(gitea_base_url=self.BASE + "/")
self.assertEqual(
exc.revoke_url,
f"{self.BASE}/user/settings/applications",
)
def test_revoke_url_without_base_uses_placeholder(self) -> None:
exc = self._build(gitea_base_url="")
self.assertEqual(
exc.revoke_url,
"<gitea-base-url>/user/settings/applications",
)
def test_access_denied_branch_is_plain_autherror(self) -> None:
# Negative case: only the scope-conflict branch upgrades to
# the subclass; access_denied stays a generic AuthError.
exc = fa._build_authorize_error(
"access_denied", "denied by user", self.BASE,
)
self.assertNotIsInstance(exc, fa.ScopeMismatchAuthError)
self.assertIsInstance(exc, fa.AuthError)
class CanPromptForRevokeTests(unittest.TestCase):
"""``_can_prompt_for_revoke`` gates the interactive retry path.
Returns False whenever the operator cannot reasonably be asked to
press Enter: explicit opt-out via ``FORGE_AUTO_REVOKE``,
non-interactive mode via ``FORGE_SETUP_YES``, or non-TTY stdio.
"""
def setUp(self) -> None:
self._env_keys = ("FORGE_AUTO_REVOKE", "FORGE_SETUP_YES")
self._env_backup = {k: os.environ.get(k) for k in self._env_keys}
for k in self._env_keys:
os.environ.pop(k, None)
def tearDown(self) -> None:
for k, v in self._env_backup.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
def _run(self, *, stderr_tty: bool = True, stdin_tty: bool = True) -> bool:
with mock.patch.object(sys.stderr, "isatty", lambda: stderr_tty), \
mock.patch.object(sys.stdin, "isatty", lambda: stdin_tty):
return fa._can_prompt_for_revoke()
def test_default_with_both_ttys_returns_true(self) -> None:
self.assertTrue(self._run())
def test_force_auto_revoke_off_disables(self) -> None:
for v in ("0", "no", "false", "FALSE", "No"):
with self.subTest(value=v):
os.environ["FORGE_AUTO_REVOKE"] = v
self.assertFalse(self._run())
os.environ.pop("FORGE_AUTO_REVOKE", None)
def test_setup_yes_disables(self) -> None:
os.environ["FORGE_SETUP_YES"] = "1"
self.assertFalse(self._run())
def test_no_stderr_tty_disables(self) -> None:
self.assertFalse(self._run(stderr_tty=False))
def test_no_stdin_tty_disables(self) -> None:
self.assertFalse(self._run(stdin_tty=False))
class CmdLoginRetryOnScopeMismatchTests(unittest.TestCase):
"""``_cmd_login`` auto-retries once after ``ScopeMismatchAuthError``
when the prompt path is enabled; otherwise exits 1 immediately.
The retry must call ``run_login`` with ``force=True`` and
``force_login_prompt=False`` so the cached live-token short-circuit
cannot mask a stale grant and Gitea can reuse the existing browser
session cookie (only consent screen on retry).
"""
def setUp(self) -> None:
# Capture stderr to keep cli_err/cli_ok/cli_info from polluting
# the test runner output for the entire class.
stderr_patch = mock.patch.object(sys, "stderr", new_callable=io.StringIO)
stderr_patch.start()
self.addCleanup(stderr_patch.stop)
self.fake_cfg = fa.ForgeAuthConfig(
gitea_base_url="https://gitea.example.com",
client_id="ba4ec9ec-8ae8-4450-9cec-fd532bbe63d5",
redirect_uri="http://127.0.0.1:38111/callback",
)
self.fake_state = mock.Mock(username="alice")
self.scope_exc = fa.ScopeMismatchAuthError(
"boom",
gitea_base_url=self.fake_cfg.gitea_base_url,
client_id=self.fake_cfg.client_id,
scopes=self.fake_cfg.scopes,
)
def _common_patches(self, *, run_login_side_effect):
return [
mock.patch.object(
fa.ForgeAuthConfig, "from_env", return_value=self.fake_cfg
),
mock.patch.object(
fa, "run_login", side_effect=run_login_side_effect
),
mock.patch.object(
fa, "auth_store_path", return_value=Path("/tmp/dummy.json")
),
]
def _start(self, patches):
for p in patches:
p.start()
self.addCleanup(lambda: [p.stop() for p in patches])
def test_retries_when_prompt_allowed(self) -> None:
patches = self._common_patches(
run_login_side_effect=[self.scope_exc, self.fake_state]
) + [
mock.patch.object(fa, "_can_prompt_for_revoke", return_value=True),
mock.patch.object(fa, "_prompt_revoke_and_wait", return_value=True),
]
self._start(patches)
rc = fa._cmd_login([])
self.assertEqual(rc, 0)
self.assertEqual(fa.run_login.call_count, 2)
_, kwargs = fa.run_login.call_args_list[1]
self.assertTrue(kwargs.get("force"))
# The retry must drop ``prompt=login`` so Gitea reuses the
# browser session cookie established by the failed first call.
self.assertIs(kwargs.get("force_login_prompt"), False)
def test_does_not_retry_when_prompt_disabled(self) -> None:
prompt_mock = mock.Mock(return_value=True)
patches = self._common_patches(
run_login_side_effect=[self.scope_exc]
) + [
mock.patch.object(fa, "_can_prompt_for_revoke", return_value=False),
mock.patch.object(fa, "_prompt_revoke_and_wait", new=prompt_mock),
]
self._start(patches)
rc = fa._cmd_login([])
self.assertEqual(rc, 1)
self.assertEqual(fa.run_login.call_count, 1)
prompt_mock.assert_not_called()
def test_does_not_retry_when_user_aborts(self) -> None:
patches = self._common_patches(
run_login_side_effect=[self.scope_exc]
) + [
mock.patch.object(fa, "_can_prompt_for_revoke", return_value=True),
mock.patch.object(fa, "_prompt_revoke_and_wait", return_value=False),
]
self._start(patches)
rc = fa._cmd_login([])
self.assertEqual(rc, 1)
self.assertEqual(fa.run_login.call_count, 1)
def test_unrelated_autherror_propagates(self) -> None:
patches = self._common_patches(
run_login_side_effect=[fa.AuthError("unrelated")]
) + [
mock.patch.object(fa, "_can_prompt_for_revoke", return_value=True),
mock.patch.object(fa, "_prompt_revoke_and_wait", return_value=True),
]
self._start(patches)
with self.assertRaises(fa.AuthError):
fa._cmd_login([])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,240 @@
"""End-to-end integration test for forge_auth.py against a mock OIDC server.
Covers the full PKCE login flow (authorize → callback → token
exchange → userinfo → persist), transparent refresh, logout, and
the idempotent "already authenticated" short-circuit.
No real network calls. No browser required: the test simulates the
browser by doing an HTTP GET to the authorize endpoint; the mock
server 302-redirects to the loopback callback, which
`forge_auth.run_login` is already listening on.
Run with: python3 -m unittest tests.test_forge_auth_integration
"""
from __future__ import annotations
import json
import os
import socket
import sys
import tempfile
import threading
import time
import unittest
import urllib.request
from http.client import HTTPResponse
from pathlib import Path
from unittest import mock
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent
sys.path.insert(0, str(ROOT / "scripts"))
sys.path.insert(0, str(HERE))
import forge_auth as fa # noqa: E402
import mock_oidc_server # noqa: E402
def _free_loopback_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class _MockBrowser:
"""Drive the authorize endpoint on a worker thread.
Delay allows `run_login` to bind the loopback callback server.
The GET then follows the mock server redirect to the callback.
"""
def __init__(self, authorize_url: str, delay_seconds: float = 0.2) -> None:
self.authorize_url = authorize_url
self.delay_seconds = delay_seconds
self.exc: BaseException | None = None
self._thread: threading.Thread | None = None
def start(self) -> None:
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def join(self, timeout: float = 10.0) -> None:
if self._thread is not None:
self._thread.join(timeout)
def _run(self) -> None:
try:
time.sleep(self.delay_seconds)
with urllib.request.urlopen(self.authorize_url, timeout=5) as resp:
resp.read()
except BaseException as exc: # noqa: BLE001
self.exc = exc
class ForgeAuthIntegrationTests(unittest.TestCase):
def setUp(self) -> None:
# Tempdir for auth store.
self._tmp_ctx = tempfile.TemporaryDirectory()
self.tmp = Path(self._tmp_ctx.name)
# Mock Gitea on an ephemeral port.
self.server, self.server_state, self.base_url = mock_oidc_server.make_server(
username="integration-user"
)
mock_oidc_server.serve_forever(self.server)
# Loopback callback port (separate from the mock server port).
self.loopback_port = _free_loopback_port()
self.redirect_uri = f"http://127.0.0.1:{self.loopback_port}/callback"
self.env = {
"FORGE_GITEA_URL": self.base_url,
"FSDGG_CLI_CLIENT_ID": "integration-client",
"FSDGG_CLI_REDIRECT_URI": self.redirect_uri,
"FSDGG_AUTH_STORE_PATH": str(self.tmp / "client-auth.json"),
"HOME": str(self.tmp),
}
def tearDown(self) -> None:
self.server.shutdown()
self.server.server_close()
self._tmp_ctx.cleanup()
# -----------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------
def _login(self) -> fa.AuthFile:
"""Run `run_login()` with a patched browser opener."""
with mock.patch.dict(os.environ, self.env, clear=True):
config = fa.ForgeAuthConfig.from_env()
# Start the mock browser from the patched opener so the GET
# occurs after URL construction and before callback wait.
browser_holder: dict[str, _MockBrowser] = {}
def fake_webbrowser_open(url: str, new: int = 0) -> bool:
browser = _MockBrowser(url)
browser.start()
browser_holder["b"] = browser
return True
with mock.patch("forge_auth.webbrowser.open", side_effect=fake_webbrowser_open):
state = fa.run_login(config, open_browser=True, force=False,
print_authorize_url=False)
if "b" in browser_holder:
browser_holder["b"].join(timeout=5)
if browser_holder["b"].exc is not None:
raise browser_holder["b"].exc
return state
# -----------------------------------------------------------------
# Tests
# -----------------------------------------------------------------
def test_login_persists_full_auth_file(self) -> None:
state = self._login()
self.assertEqual(state.username, "integration-user")
self.assertTrue(state.gitea_access_token.startswith("access-"))
self.assertTrue(state.refresh_token.startswith("refresh-"))
self.assertTrue(state.has_live_gitea_token())
# The file must be mode 0600.
store = Path(self.env["FSDGG_AUTH_STORE_PATH"])
self.assertTrue(store.is_file())
self.assertEqual(store.stat().st_mode & 0o777, 0o600)
# All gateway-required fields are populated (with defaults).
payload = json.loads(store.read_text(encoding="utf-8"))
for key in (
"username", "access_token", "expires_in",
"issued_at", "public_base_url", "index_name",
):
self.assertIn(key, payload)
# Welcome-managed fields are there too.
self.assertEqual(payload["_forge_client_id"], "integration-client")
self.assertEqual(payload["_forge_gitea_base_url"], self.base_url)
def test_login_is_idempotent_when_token_live(self) -> None:
first = self._login()
first_access = first.gitea_access_token
first_counter = self.server_state.access_token_counter
# Second call with force=False must NOT talk to the mock server.
with mock.patch.dict(os.environ, self.env, clear=True):
config = fa.ForgeAuthConfig.from_env()
with mock.patch("forge_auth.webbrowser.open") as wb:
second = fa.run_login(config, open_browser=True, force=False,
print_authorize_url=False)
wb.assert_not_called()
self.assertEqual(second.gitea_access_token, first_access)
self.assertEqual(self.server_state.access_token_counter, first_counter)
def test_refresh_rotates_tokens(self) -> None:
state = self._login()
original_access = state.gitea_access_token
original_refresh = state.refresh_token
# Force the refresh path.
with mock.patch.dict(os.environ, self.env, clear=True):
config = fa.ForgeAuthConfig.from_env()
refreshed = fa.run_refresh(config, must_refresh=True)
self.assertNotEqual(refreshed.gitea_access_token, original_access)
self.assertNotEqual(refreshed.refresh_token, original_refresh)
self.assertTrue(refreshed.has_live_gitea_token())
# Old refresh token is now revoked on the server; using it must fail.
with mock.patch.dict(os.environ, self.env, clear=True):
config = fa.ForgeAuthConfig.from_env()
endpoints = fa.discover_endpoints(config)
with self.assertRaises(fa.AuthError):
fa.refresh_access_token(
config, endpoints, refresh_token=original_refresh
)
def test_logout_after_login_removes_fields(self) -> None:
self._login()
store = Path(self.env["FSDGG_AUTH_STORE_PATH"])
self.assertTrue(store.is_file())
with mock.patch.dict(os.environ, self.env, clear=True):
fa.run_logout()
# File should be gone (gateway never wrote its bearer in this test).
self.assertFalse(store.is_file())
def test_logout_preserves_gateway_bearer(self) -> None:
self._login()
store = Path(self.env["FSDGG_AUTH_STORE_PATH"])
# Simulate the gateway subsequently writing its own bearer.
payload = json.loads(store.read_text(encoding="utf-8"))
payload.update({
"access_token": "GATEWAY-BEARER",
"expires_in": 7200,
"public_base_url": "https://gateway.example",
"index_name": "forge",
})
store.write_text(json.dumps(payload), encoding="utf-8")
with mock.patch.dict(os.environ, self.env, clear=True):
fa.run_logout()
self.assertTrue(store.is_file())
remaining = json.loads(store.read_text(encoding="utf-8"))
self.assertEqual(remaining["access_token"], "GATEWAY-BEARER")
self.assertEqual(remaining["public_base_url"], "https://gateway.example")
self.assertNotIn("gitea_access_token", remaining)
self.assertNotIn("_forge_refresh_token", remaining)
def test_callback_state_csrf_mismatch_raises(self) -> None:
"""Reject a tampered callback state via `verify_state()`."""
key = b"\x01" * 32
signed = fa.sign_state(key, "nonce")
with self.assertRaises(fa.AuthError):
fa.verify_state(b"\x02" * 32, signed)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,237 @@
#!/usr/bin/env bash
#
# End-to-end OAuth2 flow against tests/mock_oidc_server.py. Exercises
# forge_login.sh, forge_auth.py, the credential helper, and the
# username-mismatch guard in a sandboxed $HOME. Never touches a real
# Gitea.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd -P)"
root="$here/.."
# Disable every interactive credential fallback so git fails loudly
# rather than prompting (VSCode, X11/Wayland askpass, terminal).
export GIT_TERMINAL_PROMPT=0
export GCM_INTERACTIVE=Never
unset GIT_ASKPASS SSH_ASKPASS \
VSCODE_GIT_ASKPASS_MAIN VSCODE_GIT_ASKPASS_NODE \
VSCODE_GIT_ASKPASS_EXTRA_ARGS VSCODE_GIT_IPC_HANDLE \
DISPLAY WAYLAND_DISPLAY
tmp="$(mktemp -d)"
cleanup() {
[ -n "${mock_pid:-}" ] && kill "$mock_pid" 2>/dev/null || true
[ -n "${browser_pid:-}" ] && kill "$browser_pid" 2>/dev/null || true
[ -n "${login_pid:-}" ] && kill "$login_pid" 2>/dev/null || true
rm -rf "$tmp"
}
trap cleanup EXIT INT TERM
export HOME="$tmp/home"
mkdir -p "$HOME/.local/bin" "$HOME/.config"
export GIT_CONFIG_GLOBAL="$HOME/.gitconfig"
export GIT_CONFIG_SYSTEM=/dev/null
touch "$GIT_CONFIG_GLOBAL"
export FSDGG_AUTH_STORE_PATH="$HOME/.forge-stack-devpi-gateway-gitea/client-auth.json"
pass=0
fail=0
assert() {
local msg="$1"; shift
if "$@"; then
printf '[ok] %s\n' "$msg"
pass=$((pass + 1))
else
printf '[FAIL] %s\n' "$msg"
fail=$((fail + 1))
fi
}
# --- Start the mock Gitea ------------------------------------------
MOCK_OIDC_USERNAME=integration-user \
python3 -u "$here/mock_oidc_server.py" >"$tmp/mock.url" 2>"$tmp/mock.log" &
mock_pid=$!
# Wait for the server to announce its URL.
for _ in $(seq 1 40); do
mock_url="$(head -1 "$tmp/mock.url" 2>/dev/null || true)"
[ -n "$mock_url" ] && break
sleep 0.1
done
[ -n "$mock_url" ] || { cat "$tmp/mock.log"; echo 'mock server never came up' >&2; exit 1; }
printf '[info] mock Gitea: %s\n' "$mock_url"
# --- Pick an unused loopback port for the callback -----------------
loopback_port="$(python3 -c '
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
')"
redirect_uri="http://127.0.0.1:${loopback_port}/callback"
# --- Synthesize .env inside the welcome repo root ------------------
env_file="$tmp/env"
cat >"$env_file" <<EOF
FORGE_GITEA_URL=$mock_url
FORGE_GITEA_USERNAME=integration-user
FSDGG_CLI_CLIENT_ID=integration-client
FSDGG_CLI_REDIRECT_URI=$redirect_uri
FORGE_INSECURE_TLS=0
EOF
# Export mock env; scripts pick these up via load_env.
set -a
# shellcheck disable=SC1090
. "$env_file"
set +a
# Fake browser: once the loopback listener is up, GET the authorise
# URL printed to the login log; the mock 302-redirects to the callback.
(
# Wait for the loopback listener to come up, then drive it.
for _ in $(seq 1 50); do
if ss -tln 2>/dev/null | grep -q ":${loopback_port} " \
|| netstat -tln 2>/dev/null | grep -q ":${loopback_port} "; then
break
fi
sleep 0.1
done
url="$(cat "$tmp/authorize.url" 2>/dev/null || true)"
if [ -n "$url" ]; then
curl -fsSL --max-time 10 "$url" >/dev/null 2>"$tmp/browser.log" || true
fi
) &
browser_pid=$!
python3 "$root/scripts/forge_auth.py" login --no-browser 2> >(tee "$tmp/login.log" >&2) \
< <(printf '') &
login_pid=$!
for _ in $(seq 1 80); do
if grep -qE 'https?://.*authorize\?' "$tmp/login.log" 2>/dev/null; then
grep -oE 'https?://[^ ]+authorize\?[^ ]+' "$tmp/login.log" | head -1 >"$tmp/authorize.url"
break
fi
sleep 0.1
done
wait "$login_pid" || { echo 'forge_auth login failed'; cat "$tmp/login.log"; exit 1; }
wait "$browser_pid" 2>/dev/null || true
assert 'client-auth.json was written' test -f "$FSDGG_AUTH_STORE_PATH"
assert 'client-auth.json is mode 0600' \
bash -c '[ "$(stat -c %a "$FSDGG_AUTH_STORE_PATH" 2>/dev/null || stat -f %A "$FSDGG_AUTH_STORE_PATH")" = "600" ]'
user="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["username"])' "$FSDGG_AUTH_STORE_PATH")"
assert 'username captured from userinfo' test "$user" = 'integration-user'
token="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["gitea_access_token"])' "$FSDGG_AUTH_STORE_PATH")"
assert 'gitea_access_token captured' bash -c "[ -n \"$token\" ] && [[ '$token' == access-* ]]"
refresh="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["_forge_refresh_token"])' "$FSDGG_AUTH_STORE_PATH")"
assert 'refresh token captured' bash -c "[ -n \"$refresh\" ] && [[ '$refresh' == refresh-* ]]"
# --- Install the git credential helper -----------------------------
bash "$root/scripts/install-git-credential-helper.sh" >"$tmp/inst.log" 2>&1 \
|| { cat "$tmp/inst.log"; echo 'helper install failed' >&2; exit 1; }
mock_host="$(python3 -c 'import sys,urllib.parse as u; p=u.urlsplit(sys.argv[1]); print(f"{p.hostname}:{p.port}")' "$mock_url")"
# --- Drive `git credential fill` -----------------------------------
credfill_out="$(
printf 'protocol=http\nhost=%s\npath=someorg/somerepo.git\n\n' "$mock_host" \
| PATH="$HOME/.local/bin:$PATH" git credential fill 2>"$tmp/credfill.err"
)" || { echo 'git credential fill failed'; cat "$tmp/credfill.err"; exit 1; }
got_pass="$(printf '%s\n' "$credfill_out" | awk -F= '/^password=/ {print $2}')"
got_user="$(printf '%s\n' "$credfill_out" | awk -F= '/^username=/ {print $2}')"
assert 'git credential fill returns username from OAuth' test "$got_user" = 'integration-user'
assert 'git credential fill returns the OAuth access token as password' test "$got_pass" = "$token"
# --- Unrelated-host probe: must NOT leak credentials ---------------
other_out="$(
printf 'protocol=https\nhost=github.com\npath=x/y.git\n\n' \
| "$HOME/.local/bin/git-credential-forge" get
)"
assert 'unrelated host gets no username' bash -c "! printf '%s\n' \"$other_out\" | grep -q '^username='"
assert 'unrelated host gets no password' bash -c "! printf '%s\n' \"$other_out\" | grep -q '^password='"
# --- Refresh path: force a refresh and re-run credential fill ------
python3 "$root/scripts/forge_auth.py" refresh --force >"$tmp/refresh.log" 2>&1 \
|| { cat "$tmp/refresh.log"; echo 'refresh failed' >&2; exit 1; }
new_token="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["gitea_access_token"])' "$FSDGG_AUTH_STORE_PATH")"
assert 'refresh rotates the access token' bash -c "[ \"$new_token\" != \"$token\" ]"
credfill_out2="$(
printf 'protocol=http\nhost=%s\n\n' "$mock_host" \
| PATH="$HOME/.local/bin:$PATH" git credential fill 2>>"$tmp/credfill.err"
)"
got_pass2="$(printf '%s\n' "$credfill_out2" | awk -F= '/^password=/ {print $2}')"
assert 'git credential fill picks up the rotated token' test "$got_pass2" = "$new_token"
# --- Logout removes the file ---------------------------------------
python3 "$root/scripts/forge_auth.py" logout >"$tmp/logout.log" 2>&1
assert 'logout removes client-auth.json' bash -c "[ ! -f \"$FSDGG_AUTH_STORE_PATH\" ]"
# --- Username-mismatch guard ---------------------------------------
loopback_port2="$(python3 -c '
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
')"
export FSDGG_CLI_REDIRECT_URI="http://127.0.0.1:${loopback_port2}/callback"
export FORGE_GITEA_USERNAME="not-the-right-user"
rm -f "$tmp/authorize.url"
(
for _ in $(seq 1 50); do
if ss -tln 2>/dev/null | grep -q ":${loopback_port2} " \
|| netstat -tln 2>/dev/null | grep -q ":${loopback_port2} "; then
break
fi
sleep 0.1
done
url="$(cat "$tmp/authorize.url" 2>/dev/null || true)"
if [ -n "$url" ]; then
curl -fsSL --max-time 10 "$url" >/dev/null 2>"$tmp/browser2.log" || true
fi
) &
browser_pid=$!
set +e
python3 "$root/scripts/forge_auth.py" login --no-browser \
2> >(tee "$tmp/mismatch.log" >&2) &
mismatch_pid=$!
for _ in $(seq 1 80); do
if grep -qE 'https?://.*authorize\?' "$tmp/mismatch.log" 2>/dev/null; then
grep -oE 'https?://[^ ]+authorize\?[^ ]+' "$tmp/mismatch.log" | head -1 >"$tmp/authorize.url"
break
fi
sleep 0.1
done
wait "$mismatch_pid"
mismatch_rc=$?
wait "$browser_pid" 2>/dev/null || true
set -e
assert 'login fails when username mismatches FORGE_GITEA_USERNAME' bash -c "[ $mismatch_rc -ne 0 ]"
assert 'mismatch error references the actual Gitea username' \
grep -q 'integration-user' "$tmp/mismatch.log"
assert 'mismatch error references the expected username' \
grep -q 'not-the-right-user' "$tmp/mismatch.log"
assert 'mismatch error points at Gitea logout URL' \
grep -qE '/user/logout\?redirect_to=%2Fuser%2Flogin' "$tmp/mismatch.log"
assert 'mismatch does NOT create client-auth.json' \
bash -c "[ ! -f \"$FSDGG_AUTH_STORE_PATH\" ]"
auth_url="$(cat "$tmp/authorize.url" 2>/dev/null || true)"
assert 'authorize URL includes prompt=login' \
bash -c "printf '%s\n' \"$auth_url\" | grep -q 'prompt=login'"
assert 'authorize URL carries login_hint' \
bash -c "printf '%s\n' \"$auth_url\" | grep -q 'login_hint=not-the-right-user'"
printf '\n%d pass / %d fail\n' "$pass" "$fail"
[ "$fail" -eq 0 ]

View File

@@ -0,0 +1,298 @@
"""Unit tests for scripts/git-credential-forge.py.
Run with: python3 -m unittest tests.test_git_credential_forge
"""
from __future__ import annotations
import importlib.util
import io
import json
import os
import sys
import tempfile
import time
import unittest
from pathlib import Path
from unittest import mock
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent
sys.path.insert(0, str(ROOT / "scripts"))
# Load the helper as a module even though its filename has hyphens.
_helper_path = ROOT / "scripts" / "git-credential-forge.py"
_spec = importlib.util.spec_from_file_location("gcf", _helper_path)
assert _spec and _spec.loader
gcf = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(gcf)
import forge_auth as fa # noqa: E402 (imported after sys.path is extended)
def _write_store(tmp: Path, payload: dict) -> Path:
p = tmp / "client-auth.json"
p.write_text(json.dumps(payload), encoding="utf-8")
return p
# --------------------------------------------------------------------
# read_git_fields
# --------------------------------------------------------------------
class ReadGitFieldsTests(unittest.TestCase):
def test_full_block(self) -> None:
buf = io.StringIO("protocol=https\nhost=g.example:6006\npath=a/b.git\n\n")
self.assertEqual(
gcf.read_git_fields(buf),
{"protocol": "https", "host": "g.example:6006", "path": "a/b.git"},
)
def test_eof_without_blank_line(self) -> None:
self.assertEqual(
gcf.read_git_fields(io.StringIO("protocol=https\nhost=x\n")),
{"protocol": "https", "host": "x"},
)
def test_malformed_line_raises(self) -> None:
with self.assertRaises(ValueError):
gcf.read_git_fields(io.StringIO("notakeyvalue\n"))
# --------------------------------------------------------------------
# host matching
# --------------------------------------------------------------------
class RequestMatchesTests(unittest.TestCase):
def test_exact_match_including_port(self) -> None:
self.assertTrue(
gcf._request_matches(
{"protocol": "https", "host": "g.example:6006"},
("https", "g.example", 6006),
)
)
def test_wrong_scheme_no_match(self) -> None:
self.assertFalse(
gcf._request_matches(
{"protocol": "http", "host": "g.example:6006"},
("https", "g.example", 6006),
)
)
def test_wrong_host_no_match(self) -> None:
self.assertFalse(
gcf._request_matches(
{"protocol": "https", "host": "other.example:6006"},
("https", "g.example", 6006),
)
)
def test_wrong_port_no_match(self) -> None:
self.assertFalse(
gcf._request_matches(
{"protocol": "https", "host": "g.example:7000"},
("https", "g.example", 6006),
)
)
def test_stored_default_https_request_no_port(self) -> None:
# Stored URL had no explicit port → default 443 inferred.
# Request without port is OK.
self.assertTrue(
gcf._request_matches(
{"protocol": "https", "host": "g.example"},
("https", "g.example", None),
)
)
def test_stored_default_https_request_with_443(self) -> None:
self.assertTrue(
gcf._request_matches(
{"protocol": "https", "host": "g.example:443"},
("https", "g.example", None),
)
)
def test_stored_default_https_request_with_other_port_no_match(self) -> None:
self.assertFalse(
gcf._request_matches(
{"protocol": "https", "host": "g.example:6006"},
("https", "g.example", None),
)
)
# --------------------------------------------------------------------
# cmd_get (end-to-end, inside a sandboxed FSDGG_AUTH_STORE_PATH)
# --------------------------------------------------------------------
class CmdGetTests(unittest.TestCase):
def _run(
self,
*,
store_payload: dict | None,
stdin_text: str,
env_extra: dict[str, str] | None = None,
) -> tuple[int, str, str]:
with tempfile.TemporaryDirectory() as d:
tmp = Path(d)
env = {
"FSDGG_AUTH_STORE_PATH": str(tmp / "client-auth.json"),
"FORGE_GITEA_URL": "https://g.example:6006",
"FSDGG_CLI_CLIENT_ID": "client-1",
"FSDGG_CLI_REDIRECT_URI": "http://127.0.0.1:38111/callback",
"HOME": str(tmp),
}
if env_extra:
env.update(env_extra)
if store_payload is not None:
_write_store(tmp, store_payload)
fields = gcf.read_git_fields(io.StringIO(stdin_text))
buf_out, buf_err = io.StringIO(), io.StringIO()
real_out, real_err = sys.stdout, sys.stderr
sys.stdout, sys.stderr = buf_out, buf_err
try:
with mock.patch.dict(os.environ, env, clear=True):
rc = gcf.cmd_get(fields)
finally:
sys.stdout, sys.stderr = real_out, real_err
return rc, buf_out.getvalue(), buf_err.getvalue()
# --- matching host -------------------------------------------------
def test_match_live_token_returns_credentials(self) -> None:
payload = {
"username": "alice",
"gitea_access_token": "LIVETOKEN",
"gitea_token_expires_at": time.time() + 3600,
"_forge_gitea_base_url": "https://g.example:6006",
}
rc, out, _ = self._run(
store_payload=payload,
stdin_text="protocol=https\nhost=g.example:6006\npath=org/repo.git\n\n",
)
self.assertEqual(rc, 0)
parsed = dict(l.split("=", 1) for l in out.strip().splitlines())
self.assertEqual(parsed["username"], "alice")
self.assertEqual(parsed["password"], "LIVETOKEN")
self.assertEqual(parsed["host"], "g.example:6006")
def test_no_store_passes_through(self) -> None:
rc, out, _ = self._run(
store_payload=None,
stdin_text="protocol=https\nhost=g.example:6006\n\n",
)
self.assertEqual(rc, 0)
self.assertNotIn("password=", out)
self.assertIn("host=g.example:6006", out)
def test_non_matching_host_passes_through(self) -> None:
payload = {
"username": "alice",
"gitea_access_token": "LIVETOKEN",
"gitea_token_expires_at": time.time() + 3600,
"_forge_gitea_base_url": "https://g.example:6006",
}
rc, out, _ = self._run(
store_payload=payload,
stdin_text="protocol=https\nhost=github.com\n\n",
)
self.assertEqual(rc, 0)
self.assertNotIn("password=", out)
self.assertIn("host=github.com", out)
def test_match_but_no_token_passes_through(self) -> None:
payload = {
"username": "alice",
"gitea_access_token": "",
"_forge_gitea_base_url": "https://g.example:6006",
}
rc, out, _ = self._run(
store_payload=payload,
stdin_text="protocol=https\nhost=g.example:6006\n\n",
)
self.assertEqual(rc, 0)
self.assertNotIn("password=", out)
# --- expired token + refresh -------------------------------------
def test_expired_token_triggers_refresh(self) -> None:
payload = {
"username": "alice",
"gitea_access_token": "EXPIRED",
"gitea_token_expires_at": time.time() - 10,
"_forge_gitea_base_url": "https://g.example:6006",
"_forge_refresh_token": "REFRESH",
"_forge_client_id": "client-1",
}
# Patch the refresh path directly.
def fake_refresh(config, *, must_refresh=False):
f = fa.AuthFile.read(fa.auth_store_path())
f.merge_refresh(
gitea_access_token="ROTATED",
gitea_token_expires_at=time.time() + 3600,
refresh_token="NEW-RT",
)
f.write(fa.auth_store_path())
return f
with mock.patch.object(gcf.forge_auth, "run_refresh", side_effect=fake_refresh):
rc, out, _ = self._run(
store_payload=payload,
stdin_text="protocol=https\nhost=g.example:6006\n\n",
)
self.assertEqual(rc, 0)
parsed = dict(l.split("=", 1) for l in out.strip().splitlines())
self.assertEqual(parsed["password"], "ROTATED")
def test_refresh_failure_passes_through_with_stderr(self) -> None:
payload = {
"username": "alice",
"gitea_access_token": "EXPIRED",
"gitea_token_expires_at": time.time() - 10,
"_forge_gitea_base_url": "https://g.example:6006",
"_forge_refresh_token": "DEAD",
}
def fake_refresh(*_args, **_kwargs):
raise fa.AuthError("refresh token revoked")
with mock.patch.object(gcf.forge_auth, "run_refresh", side_effect=fake_refresh):
rc, out, err = self._run(
store_payload=payload,
stdin_text="protocol=https\nhost=g.example:6006\n\n",
)
self.assertEqual(rc, 0)
self.assertNotIn("password=", out)
self.assertIn("token refresh failed", err)
self.assertIn("just login", err)
# --------------------------------------------------------------------
# main() dispatcher
# --------------------------------------------------------------------
class MainDispatcherTests(unittest.TestCase):
def _main(self, argv: list[str], stdin_text: str = "") -> int:
real_stdin = sys.stdin
sys.stdin = io.StringIO(stdin_text)
try:
return gcf.main(argv)
finally:
sys.stdin = real_stdin
def test_store_is_noop(self) -> None:
self.assertEqual(self._main(["h", "store"], "username=x\npassword=y\n\n"), 0)
def test_erase_is_noop(self) -> None:
self.assertEqual(self._main(["h", "erase"], "username=x\npassword=y\n\n"), 0)
def test_no_action_rc_2(self) -> None:
self.assertEqual(self._main(["h"]), 2)
def test_unknown_action_rc_2(self) -> None:
self.assertEqual(self._main(["h", "bogus"]), 2)
if __name__ == "__main__":
unittest.main()

186
tests/test_next_steps.sh Executable file
View File

@@ -0,0 +1,186 @@
#!/usr/bin/env bash
#
# scripts/next_steps.sh contract (operator-first scaffold):
# 1. Default manifest is operator_setup_steps.json; default recipe is
# operator-setup. Steps are read from the manifest; no hardcoding.
# 2. --manifest contributor_setup_steps.json --recipe contributor-setup
# swaps to the contributor dev-env bootstrap without changing the
# runner logic.
# 3. Missing manifest prints [warn] and references the orchestrator README.
# 4. Flags (--headless, --yes, --skip-optional, --only) forward verbatim
# to whichever recipe is selected.
# 5. --run mode execs `just <recipe>` in the orchestrator cwd.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd -P)"
root="$(cd "$here/.." && pwd -P)"
pass=0
fail=0
assert() {
local msg="$1"; shift
if "$@"; then
printf '[ok] %s\n' "$msg"; pass=$((pass + 1))
else
printf '[FAIL] %s\n' "$msg"; fail=$((fail + 1))
fi
}
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
# --- Sandbox ---------------------------------------------------------
export FORGE_GITEA_URL="http://127.0.0.1:1"
export FORGE_GITEA_ORG="x"
export FORGE_GITEA_USERNAME="sandbox"
export FORGE_ORCHESTRATOR_REPO_URL="http://127.0.0.1:1/x/forge-stack-orchestrator.git"
export FORGE_WORKSPACE_ROOT="$tmp/workspace"
unset FORGE_ORCHESTRATOR_BRANCH FSDGG_AUTH_STORE_PATH FSDGG_RUNTIME_DIR
mkdir -p "$tmp/workspace" "$tmp/fakebin"
mkdir -p "$tmp/welcome/scripts"
cp "$root/scripts/common.sh" "$tmp/welcome/scripts/"
cp "$root/scripts/next_steps.sh" "$tmp/welcome/scripts/"
touch "$tmp/welcome/Justfile"
cat >"$tmp/welcome/.env" <<EOF
FORGE_GITEA_URL=$FORGE_GITEA_URL
FORGE_GITEA_ORG=$FORGE_GITEA_ORG
FORGE_GITEA_USERNAME=$FORGE_GITEA_USERNAME
FORGE_ORCHESTRATOR_REPO_URL=$FORGE_ORCHESTRATOR_REPO_URL
FORGE_WORKSPACE_ROOT=$FORGE_WORKSPACE_ROOT
EOF
mkdir -p "$tmp/workspace/forge-stack-orchestrator/.git"
mkdir -p "$tmp/workspace/forge-stack-orchestrator/scripts"
# --- Shared manifest fixtures ---------------------------------------
# Fixture step ids / commands are deliberately generic. The runner is
# manifest-agnostic; the real manifests live in the (private) orchestrator.
cat >"$tmp/workspace/forge-stack-orchestrator/scripts/operator_setup_steps.json" <<'JSON'
{
"schema_version": 1,
"description": "Operator manifest under test.",
"steps": [
{"id": "op-step-a", "title": "Operator step A", "cmd": ["bash", "-c", "true"], "desc": "Operator A desc."},
{"id": "op-step-b", "title": "Operator step B", "cmd": ["bash", "-c", "true"], "desc": "Operator B desc."},
{"id": "op-step-c", "title": "Operator step C", "cmd": ["bash", "-c", "true"], "desc": "Operator C desc.", "optional": true}
]
}
JSON
cat >"$tmp/workspace/forge-stack-orchestrator/scripts/contributor_setup_steps.json" <<'JSON'
{
"schema_version": 1,
"steps": [
{"id": "cstep-a", "title": "Contributor A", "cmd": ["bash", "-c", "true"], "desc": "Contributor A."},
{"id": "cstep-b", "title": "Contributor B", "cmd": ["bash", "-c", "true"], "desc": "Contributor B."}
]
}
JSON
# --- Case 1: default (no args) renders the OPERATOR plan ------------
set +e
bash -c "cd '$tmp/welcome' && bash scripts/next_steps.sh" \
>"$tmp/default.out" 2>"$tmp/default.err"
rc=$?
set -e
assert 'default: exits 0 when operator manifest present' bash -c "[ $rc -eq 0 ]"
assert 'default: operator_setup_steps.json is the source' \
grep -qF 'operator_setup_steps.json' "$tmp/default.err"
assert 'default: emits id: op-step-a from operator manifest' \
grep -qF 'id: op-step-a' "$tmp/default.err"
assert 'default: emits id: op-step-b from operator manifest' \
grep -qF 'id: op-step-b' "$tmp/default.err"
assert 'default: optional flag shown on third step' \
grep -qE '\(3/3\) Operator step C' "$tmp/default.err"
assert 'default: does NOT leak contributor ids into the operator plan' \
bash -c "! grep -qF 'id: cstep-a' '$tmp/default.err'"
assert 'default: "Execute via" pointer uses operator-setup recipe' \
grep -qE 'just run-next-steps --manifest operator_setup_steps.json --recipe operator-setup' "$tmp/default.err"
# --- Case 2: explicit contributor manifest works unchanged ----------
set +e
bash -c "cd '$tmp/welcome' && bash scripts/next_steps.sh --manifest contributor_setup_steps.json --recipe contributor-setup" \
>"$tmp/contrib.out" 2>"$tmp/contrib.err"
rc=$?
set -e
assert 'contrib override: exits 0' bash -c "[ $rc -eq 0 ]"
assert 'contrib override: manifest path mentions contributor_setup_steps.json' \
grep -qF 'contributor_setup_steps.json' "$tmp/contrib.err"
assert 'contrib override: emits contributor step ids' \
grep -qF 'id: cstep-a' "$tmp/contrib.err"
assert 'contrib override: operator ids do NOT appear' \
bash -c "! grep -qF 'id: op-step-a' '$tmp/contrib.err'"
# --- Case 3: missing operator manifest warns ------------------------
mv "$tmp/workspace/forge-stack-orchestrator/scripts/operator_setup_steps.json" \
"$tmp/workspace/forge-stack-orchestrator/scripts/operator_setup_steps.json.bak"
set +e
bash -c "cd '$tmp/welcome' && bash scripts/next_steps.sh" \
>"$tmp/missing.out" 2>"$tmp/missing.err"
rc=$?
set -e
mv "$tmp/workspace/forge-stack-orchestrator/scripts/operator_setup_steps.json.bak" \
"$tmp/workspace/forge-stack-orchestrator/scripts/operator_setup_steps.json"
assert 'missing operator manifest: exits non-zero' bash -c "[ $rc -ne 0 ]"
assert 'missing operator manifest: warns via [warn]' \
grep -qE '\[warn\]' "$tmp/missing.err"
assert 'missing operator manifest: points at the orchestrator README' \
grep -qF 'README.md' "$tmp/missing.err"
# --- Case 4: --run delegates to `just operator-setup` ---------------
cat >"$tmp/fakebin/just" <<EOF
#!/usr/bin/env bash
printf '%s\0' "\$@" > "$tmp/just.argv.nul"
pwd > "$tmp/just.cwd"
exit 0
EOF
chmod +x "$tmp/fakebin/just"
set +e
PATH="$tmp/fakebin:$PATH" bash -c "cd '$tmp/welcome' && bash scripts/next_steps.sh --run --headless --skip-optional --only op-step-a" \
>"$tmp/run.out" 2>"$tmp/run.err"
rc=$?
set -e
assert '--run: exits 0 on success' bash -c "[ $rc -eq 0 ]"
assert '--run: invokes just' test -f "$tmp/just.argv.nul"
assert '--run: cwd is the orchestrator checkout' \
bash -c "[ \"\$(cat '$tmp/just.cwd')\" = '$tmp/workspace/forge-stack-orchestrator' ]"
tr '\0' '\n' <"$tmp/just.argv.nul" >"$tmp/just.argv"
assert '--run: first arg is "operator-setup" (the default)' \
grep -qxF 'operator-setup' "$tmp/just.argv"
assert '--run: forwards --headless' \
grep -qxF -- '--headless' "$tmp/just.argv"
assert '--run: forwards --skip-optional' \
grep -qxF -- '--skip-optional' "$tmp/just.argv"
assert '--run: forwards --only op-step-a' \
bash -c "grep -qxF -- '--only' '$tmp/just.argv' && grep -qxF -- 'op-step-a' '$tmp/just.argv'"
# --- Case 5: --run --recipe contributor-setup swaps the recipe ------
set +e
PATH="$tmp/fakebin:$PATH" bash -c "cd '$tmp/welcome' && bash scripts/next_steps.sh --run --manifest contributor_setup_steps.json --recipe contributor-setup --yes" \
>"$tmp/run-contrib.out" 2>"$tmp/run-contrib.err"
rc=$?
set -e
assert '--run --recipe contributor-setup: exits 0' bash -c "[ $rc -eq 0 ]"
tr '\0' '\n' <"$tmp/just.argv.nul" >"$tmp/just.argv.contrib"
assert '--run --recipe contributor-setup: first arg is contributor-setup' \
grep -qxF 'contributor-setup' "$tmp/just.argv.contrib"
assert '--run --recipe contributor-setup: forwards --yes' \
grep -qxF -- '--yes' "$tmp/just.argv.contrib"
# --- Case 6: next_steps.sh rejects unknown args ---------------------
set +e
bash -c "cd '$tmp/welcome' && bash scripts/next_steps.sh --bogus" \
>"$tmp/bogus.out" 2>"$tmp/bogus.err"
rc=$?
set -e
assert 'unknown arg: exits non-zero' bash -c "[ $rc -ne 0 ]"
assert 'unknown arg: prints an error' \
grep -qE '\[err\] +unexpected argument' "$tmp/bogus.err"
printf '\n%d pass / %d fail\n' "$pass" "$fail"
[ "$fail" -eq 0 ]

268
tests/test_setup_args.sh Executable file
View File

@@ -0,0 +1,268 @@
#!/usr/bin/env bash
#
# scripts/setup.sh: argument parsing, --headless wiring, detection
# ladder, and prompt_choice regression. Runs hermetically against a
# sandboxed $HOME with stubbed scripts; no network, no real Gitea.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd -P)"
root="$here/.."
pass=0
fail=0
assert() {
local msg="$1"; shift
if "$@"; then
printf '[ok] %s\n' "$msg"
pass=$((pass + 1))
else
printf '[FAIL] %s\n' "$msg"
fail=$((fail + 1))
fi
}
assert 'setup.sh parses as valid bash' bash -n "$root/scripts/setup.sh"
help_out="$(bash "$root/scripts/setup.sh" --help 2>&1)"
assert '--help prints the Usage header' \
bash -c "printf '%s' \"$help_out\" | grep -q '^Usage: just setup'"
assert '--help documents --headless' \
bash -c "printf '%s' \"$help_out\" | grep -q -- '--headless'"
assert '--help documents FORGE_SETUP_YES' \
bash -c "printf '%s' \"$help_out\" | grep -q 'FORGE_SETUP_YES'"
set +e
bash "$root/scripts/setup.sh" --not-a-flag >/dev/null 2>"$here/.bad.err"
rc=$?
set -e
assert 'unknown option exits non-zero' bash -c "[ $rc -ne 0 ]"
assert 'unknown option prints a clear error' \
grep -q 'unknown option' "$here/.bad.err"
rm -f "$here/.bad.err"
# --- Sandbox with stubbed dependencies ------------------------------
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/scripts" "$tmp/home" "$tmp/tokens"
touch "$tmp/Justfile"
cp "$root/scripts/setup.sh" "$tmp/scripts/setup.sh"
cp "$root/scripts/common.sh" "$tmp/scripts/common.sh"
cat >"$tmp/.env.example" <<'EOF'
FORGE_GITEA_URL="http://127.0.0.1:1"
FORGE_GITEA_ORG="x"
FORGE_GITEA_USERNAME="sandbox-user"
FORGE_ORCHESTRATOR_REPO_URL="http://127.0.0.1:1/x/y.git"
FORGE_WORKSPACE_ROOT="."
FSDGG_CLI_CLIENT_ID="sandbox-client"
FSDGG_CLI_REDIRECT_URI="http://127.0.0.1:38111/callback"
EOF
cp "$tmp/.env.example" "$tmp/.env"
cat >"$tmp/scripts/doctor.sh" <<'EOF'
#!/usr/bin/env bash
echo "[doctor] stubbed"
EOF
chmod +x "$tmp/scripts/doctor.sh"
cat >"$tmp/scripts/forge_login.sh" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$@" >"$tmp/forge_login.args"
mkdir -p "$tmp/tokens"
cat >"$tmp/tokens/client-auth.json" <<JSON
{"username":"sandbox-user","gitea_access_token":"t","_forge_refresh_token":"r"}
JSON
chmod 0600 "$tmp/tokens/client-auth.json"
echo "[forge_login stub] ok"
EOF
chmod +x "$tmp/scripts/forge_login.sh"
cat >"$tmp/scripts/install-git-credential-helper.sh" <<'EOF'
#!/usr/bin/env bash
echo "[install-helper stub] ok"
EOF
chmod +x "$tmp/scripts/install-git-credential-helper.sh"
cat >"$tmp/scripts/forge_auth.py" <<'EOF'
#!/usr/bin/env python3
import sys
if len(sys.argv) >= 2 and sys.argv[1] == "status":
sys.exit(1)
sys.exit(0)
EOF
chmod +x "$tmp/scripts/forge_auth.py"
mkdir -p "$tmp/fakebin"
cat >"$tmp/fakebin/curl" <<'EOF'
#!/usr/bin/env bash
for arg in "$@"; do
case "$arg" in
*"/api/v1/version")
echo '{"version":"0.0.0-stub"}'
exit 0;;
esac
done
exec /usr/bin/curl "$@"
EOF
chmod +x "$tmp/fakebin/curl"
cat >"$tmp/fakebin/git" <<EOF
#!/usr/bin/env bash
case "\$1" in
ls-remote) exit 0;;
clone)
dest=""
while [ \$# -gt 0 ]; do
case "\$1" in
--branch) shift; shift;;
-*) shift;;
*) if [ -z "\$dest" ]; then url="\$1"; shift; dest="\$1"; shift; else shift; fi;;
esac
done
mkdir -p "\$dest/.git"
exit 0;;
esac
exec /usr/bin/git "\$@"
EOF
chmod +x "$tmp/fakebin/git"
# Overriding FORGE_* explicitly is required: `just test` pre-loads the
# real repo's .env into this process, and common.sh::load_env honours
# existing env over the file.
export HOME="$tmp/home"
export PATH="$tmp/fakebin:/usr/bin:/bin"
export FSDGG_AUTH_STORE_PATH="$tmp/tokens/client-auth.json"
export FORGE_SETUP_YES=1
export FORGE_GITEA_URL="http://127.0.0.1:1"
export FORGE_GITEA_ORG="x"
export FORGE_GITEA_USERNAME="sandbox-user"
export FORGE_ORCHESTRATOR_REPO_URL="http://127.0.0.1:1/x/y.git"
export FORGE_ORCHESTRATOR_BRANCH=""
export FORGE_WORKSPACE_ROOT="."
export FSDGG_CLI_CLIENT_ID="sandbox-client"
export FSDGG_CLI_REDIRECT_URI="http://127.0.0.1:38111/callback"
# --- --headless + FORGE_SETUP_YES=1 + no stored session: guard fires
set +e
FORGE_SETUP_YES=1 bash "$tmp/scripts/setup.sh" --headless \
>"$tmp/setup_guard.out" 2>"$tmp/setup_guard.err"
rc=$?
set -e
assert 'headless + FORGE_SETUP_YES + no session -> exits non-zero (no hang)' \
bash -c "[ $rc -ne 0 ]"
assert 'headless + FORGE_SETUP_YES guard message is actionable' \
grep -q 'cannot complete a fresh login under --headless + FORGE_SETUP_YES=1' \
"$tmp/setup_guard.err"
assert 'headless + FORGE_SETUP_YES guard does NOT invoke forge_login.sh' \
bash -c "[ ! -f \"$tmp/forge_login.args\" ]"
# --- --headless (interactive) + no stored session: forge_login.sh --no-browser
rm -f "$tmp/forge_login.args"
set +e
env -u FORGE_SETUP_YES bash "$tmp/scripts/setup.sh" --headless \
>"$tmp/setup_headless.out" 2>"$tmp/setup_headless.err"
rc=$?
set -e
assert 'headless (interactive) exits 0 in sandbox' bash -c "[ $rc -eq 0 ]"
assert 'headless (interactive) invokes forge_login.sh' \
test -f "$tmp/forge_login.args"
assert 'headless (interactive) forwards --no-browser' \
grep -qxF -- '--no-browser' "$tmp/forge_login.args"
# --- Default (browser) must NOT forward --no-browser
rm -f "$tmp/forge_login.args" "$tmp/tokens/client-auth.json"
set +e
env -u FORGE_SETUP_YES bash "$tmp/scripts/setup.sh" \
>"$tmp/setup_browser.out" 2>"$tmp/setup_browser.err"
rc=$?
set -e
assert 'default (browser) invocation exits 0' bash -c "[ $rc -eq 0 ]"
assert 'default (browser) invocation does NOT pass --no-browser' \
bash -c "! grep -qxF -- '--no-browser' \"$tmp/forge_login.args\""
# --- Live token + --headless: reuse without invoking forge_login.sh
rm -f "$tmp/forge_login.args"
cat >"$tmp/tokens/client-auth.json" <<'JSON'
{"username":"sandbox-user","gitea_access_token":"live-token",
"_forge_refresh_token":"r"}
JSON
chmod 0600 "$tmp/tokens/client-auth.json"
cat >"$tmp/scripts/forge_auth.py" <<'EOF'
#!/usr/bin/env python3
import sys
sys.exit(0)
EOF
chmod +x "$tmp/scripts/forge_auth.py"
set +e
FORGE_SETUP_YES=1 bash "$tmp/scripts/setup.sh" --headless \
>"$tmp/setup_live.out" 2>"$tmp/setup_live.err"
rc=$?
set -e
assert 'live + --headless exits 0' bash -c "[ $rc -eq 0 ]"
assert 'live + --headless does NOT invoke forge_login.sh' \
bash -c "[ ! -f \"$tmp/forge_login.args\" ]"
assert 'live + --headless reports reuse (on stderr, where logs belong)' \
grep -q 'reusing the stored session' "$tmp/setup_live.err"
# --- Stale token, silent-refresh rescue: forge_login.sh still skipped
rm -f "$tmp/forge_login.args" "$tmp/tokens/client-auth.json" \
"$tmp/forge_auth.calls"
cat >"$tmp/tokens/client-auth.json" <<'JSON'
{"username":"sandbox-user","_forge_refresh_token":"r",
"gitea_access_token":""}
JSON
chmod 0600 "$tmp/tokens/client-auth.json"
cat >"$tmp/scripts/forge_auth.py" <<EOF
#!/usr/bin/env python3
import sys, os
with open(os.environ["FORGE_AUTH_CALLS_LOG"], "a") as f:
f.write(" ".join(sys.argv[1:]) + "\n")
if len(sys.argv) >= 2 and sys.argv[1] == "status":
sys.exit(1)
if len(sys.argv) >= 2 and sys.argv[1] == "refresh":
import json, time
p = "$tmp/tokens/client-auth.json"
d = json.load(open(p))
d["gitea_access_token"] = "refreshed-token"
d["gitea_token_expires_at"] = time.time() + 3600
open(p, "w").write(json.dumps(d) + "\n")
sys.exit(0)
sys.exit(0)
EOF
chmod +x "$tmp/scripts/forge_auth.py"
export FORGE_AUTH_CALLS_LOG="$tmp/forge_auth.calls"
set +e
FORGE_SETUP_YES=1 bash "$tmp/scripts/setup.sh" --headless \
>"$tmp/setup_refresh.out" 2>"$tmp/setup_refresh.err"
rc=$?
set -e
assert 'silent-refresh rescue exits 0' bash -c "[ $rc -eq 0 ]"
assert 'silent-refresh rescue skips forge_login.sh' \
bash -c "[ ! -f \"$tmp/forge_login.args\" ]"
assert 'silent-refresh rescue called forge_auth.py refresh --force' \
grep -qF 'refresh --force' "$tmp/forge_auth.calls"
assert 'silent-refresh rescue reports success (on stderr)' \
grep -q 'refreshed stored session without a browser' "$tmp/setup_refresh.err"
# --- prompt_choice regression: non-tty branch must not contaminate stdout
fake=$(bash -c "
prompt_choice() {
local msg=\"\$1\" default=\"\$2\" reply
if [ \"\${FORGE_SETUP_YES:-0}\" = '1' ] || [ ! -t 0 ]; then
printf '%s [%s] (auto: %s)\n' \"\$msg\" \"\$default\" \"\$default\" >&2
reply=\"\$default\"
fi
printf '%s' \"\$reply\"
}
FORGE_SETUP_YES=1
printf '%s' \"\$(prompt_choice 'Pick one?' 'R')\"
")
assert 'prompt_choice non-tty branch returns only the default character' \
bash -c "[ \"$fake\" = 'R' ]"
printf '\n%d pass / %d fail\n' "$pass" "$fail"
[ "$fail" -eq 0 ]

View File

@@ -0,0 +1,265 @@
#!/usr/bin/env bash
#
# scripts/setup.sh --deploy flag + scripts/next_steps.sh --manifest /
# --recipe flags. Runs hermetically against a sandboxed $HOME with
# stubbed scripts and a stubbed orchestrator checkout carrying both
# contributor_setup_steps.json and operator_setup_steps.json.
#
# Operator welcome scaffold dispatch contract:
# - `just setup` (no --deploy) ALWAYS uses operator_setup_steps.json
# and operator-setup. It STOPS after the clone (prints the plan as
# informational text; does NOT prompt; does NOT invoke any recipe).
# Operators who want the handoff use `just deploy`.
# - `just setup --deploy` (== `just deploy`) ALWAYS uses operator_*
# and prompts [Y/n] (default Y) before auto-handing off to
# `just operator-setup` inside the orchestrator checkout.
# - The contributor manifest never appears in the operator scaffold's
# dispatch surface; the bug fixed here was setup.sh defaulting to
# contributor_setup_steps.json when --deploy was absent.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd -P)"
root="$here/.."
pass=0
fail=0
assert() {
local msg="$1"; shift
if "$@"; then
printf '[ok] %s\n' "$msg"
pass=$((pass + 1))
else
printf '[FAIL] %s\n' "$msg"
fail=$((fail + 1))
fi
}
# -- syntax ---------------------------------------------------------------
assert 'setup.sh parses as valid bash' bash -n "$root/scripts/setup.sh"
assert 'next_steps.sh parses as valid bash' bash -n "$root/scripts/next_steps.sh"
# -- --help documents --deploy and --manifest/--recipe --------------------
# NOTE: capture help output to a file; inlining it into `bash -c` would
# expose backticks in the text (e.g. `just operator-setup`) to command
# substitution. Use files or here-strings instead.
setup_help_file="$(mktemp)"
bash "$root/scripts/setup.sh" --help >"$setup_help_file" 2>&1
assert 'setup --help documents --deploy' \
grep -q -- '--deploy' "$setup_help_file"
assert 'setup --help mentions operator-setup' \
grep -q 'operator-setup' "$setup_help_file"
assert 'setup --help mentions operator_setup_steps.json' \
grep -q 'operator_setup_steps.json' "$setup_help_file"
rm -f "$setup_help_file"
next_help_file="$(mktemp)"
bash "$root/scripts/next_steps.sh" --help >"$next_help_file" 2>&1
assert 'next_steps --help documents --manifest' \
grep -q -- '--manifest' "$next_help_file"
assert 'next_steps --help documents --recipe' \
grep -q -- '--recipe' "$next_help_file"
rm -f "$next_help_file"
# -- sandbox --------------------------------------------------------------
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/scripts" "$tmp/home" "$tmp/tokens" "$tmp/workspace"
touch "$tmp/Justfile"
cp "$root/scripts/setup.sh" "$tmp/scripts/setup.sh"
cp "$root/scripts/next_steps.sh" "$tmp/scripts/next_steps.sh"
cp "$root/scripts/common.sh" "$tmp/scripts/common.sh"
cat >"$tmp/.env.example" <<'EOF'
FORGE_GITEA_URL="http://127.0.0.1:1"
FORGE_GITEA_ORG="x"
FORGE_GITEA_USERNAME="sandbox-user"
FORGE_ORCHESTRATOR_REPO_URL="http://127.0.0.1:1/x/y.git"
FORGE_WORKSPACE_ROOT="./workspace"
FSDGG_CLI_CLIENT_ID="sandbox-client"
FSDGG_CLI_REDIRECT_URI="http://127.0.0.1:38111/callback"
EOF
cp "$tmp/.env.example" "$tmp/.env"
# stub doctor/login/helper
for name in doctor.sh forge_login.sh install-git-credential-helper.sh; do
cat >"$tmp/scripts/$name" <<EOF
#!/usr/bin/env bash
echo "[$name stub] ok"
EOF
chmod +x "$tmp/scripts/$name"
done
# forge_auth.py stub: live token after first status call
cat >"$tmp/scripts/forge_auth.py" <<'EOF'
#!/usr/bin/env python3
import sys
sys.exit(0)
EOF
chmod +x "$tmp/scripts/forge_auth.py"
# curl stub
mkdir -p "$tmp/fakebin"
cat >"$tmp/fakebin/curl" <<'EOF'
#!/usr/bin/env bash
for arg in "$@"; do
case "$arg" in
*"/api/v1/version")
echo '{"version":"0.0.0-stub"}'
exit 0;;
esac
done
exec /usr/bin/curl "$@"
EOF
chmod +x "$tmp/fakebin/curl"
# git stub: ls-remote OK; clone → seed the fake orchestrator with both manifests
cat >"$tmp/fakebin/git" <<EOF
#!/usr/bin/env bash
case "\$1" in
ls-remote) exit 0;;
clone)
dest=""
url=""
while [ \$# -gt 0 ]; do
case "\$1" in
--branch) shift; shift;;
-*) shift;;
*)
if [ -z "\$url" ]; then url="\$1"; shift
else dest="\$1"; shift
fi;;
esac
done
mkdir -p "\$dest/.git" "\$dest/scripts"
# Contributor manifest (plain echo of one step)
cat >"\$dest/scripts/contributor_setup_steps.json" <<'JSON'
{"schema_version":1,"steps":[{"id":"noop","title":"Contributor noop","cmd":["true"]}]}
JSON
# Operator manifest (plain echo of one step)
cat >"\$dest/scripts/operator_setup_steps.json" <<'JSON'
{"schema_version":1,"steps":[{"id":"op-step","title":"Operator noop","cmd":["true"]}]}
JSON
# Fake Justfile exposing both recipes.
# The test records invocations to verify the selected recipe.
cat >"\$dest/Justfile" <<'JUST'
contributor-setup *args:
@echo "[stub-orchestrator] called: contributor-setup \$@" >>.invocations
operator-setup *args:
@echo "[stub-orchestrator] called: operator-setup \$@" >>.invocations
JUST
exit 0;;
esac
exec /usr/bin/git "\$@"
EOF
chmod +x "$tmp/fakebin/git"
# setup.sh deploy path calls `exec bash next_steps.sh --run --manifest ... --recipe ...`.
# That path reaches `exec just <recipe>`. The stub keeps the test
# deterministic and avoids a dependency on the sandboxed orchestrator.
cat >"$tmp/fakebin/just" <<EOF
#!/usr/bin/env bash
# Log the invocation with cwd to confirm the exec target directory.
printf 'just %s (cwd=%s)\n' "\$*" "\$(pwd)" >>"$tmp/just.calls"
exit 0
EOF
chmod +x "$tmp/fakebin/just"
export HOME="$tmp/home"
export PATH="$tmp/fakebin:/usr/bin:/bin"
export FSDGG_AUTH_STORE_PATH="$tmp/tokens/client-auth.json"
export FORGE_SETUP_YES=1
export FORGE_GITEA_URL="http://127.0.0.1:1"
export FORGE_GITEA_ORG="x"
export FORGE_GITEA_USERNAME="sandbox-user"
export FORGE_ORCHESTRATOR_REPO_URL="http://127.0.0.1:1/x/y.git"
export FORGE_ORCHESTRATOR_BRANCH=""
export FORGE_WORKSPACE_ROOT="$tmp/workspace"
export FSDGG_CLI_CLIENT_ID="sandbox-client"
export FSDGG_CLI_REDIRECT_URI="http://127.0.0.1:38111/callback"
# Live stored token so setup.sh skips the login step (FORGE_SETUP_YES+headless
# would otherwise trip the guard).
mkdir -p "$tmp/tokens"
cat >"$tmp/tokens/client-auth.json" <<'JSON'
{"username":"sandbox-user","gitea_access_token":"live","_forge_refresh_token":"r",
"gitea_token_expires_at":32503680000}
JSON
chmod 0600 "$tmp/tokens/client-auth.json"
# -- A) default path: operator manifest, no auto-run --------------------
# Without --deploy the operator scaffold ALWAYS uses the operator
# manifest and STOPS after the clone. The contributor manifest must
# never be referenced; no recipe must be invoked.
rm -f "$tmp/just.calls"
set +e
FORGE_SETUP_YES=1 bash "$tmp/scripts/setup.sh" --headless \
>"$tmp/out_default.out" 2>"$tmp/out_default.err"
rc=$?
set -e
assert 'default (no --deploy) exits 0' bash -c "[ $rc -eq 0 ]"
assert 'default path renders operator_setup_steps.json' \
grep -qF 'operator_setup_steps.json' "$tmp/out_default.err"
assert 'default path mentions just operator-setup in the handoff hint' \
grep -qF 'just operator-setup' "$tmp/out_default.err"
assert 'default path does NOT reference contributor_setup_steps.json' \
bash -c "! grep -qF 'contributor_setup_steps.json' \"$tmp/out_default.err\""
assert 'default path does NOT mention contributor-setup' \
bash -c "! grep -qF 'contributor-setup' \"$tmp/out_default.err\""
assert 'default path does NOT prompt for an operator-setup handoff' \
bash -c "! grep -qF 'Hand off to' \"$tmp/out_default.err\""
assert 'default path does NOT invoke any just recipe (no auto-run)' \
bash -c "! [ -f \"$tmp/just.calls\" ]"
# -- B) --deploy path: operator manifest, default-Y prompt, hands off ---
# Under FORGE_SETUP_YES=1 the [Y/n] prompt's default Y is taken, so the
# stubbed `just operator-setup` is invoked inside the orchestrator cwd.
rm -f "$tmp/just.calls"
set +e
FORGE_SETUP_YES=1 bash "$tmp/scripts/setup.sh" --headless --deploy \
>"$tmp/out_deploy.out" 2>"$tmp/out_deploy.err"
rc=$?
set -e
assert '--deploy exits 0' bash -c "[ $rc -eq 0 ]"
assert '--deploy renders operator_setup_steps.json' \
grep -qF 'operator_setup_steps.json' "$tmp/out_deploy.err"
assert '--deploy prompt names operator-setup' \
grep -qF 'operator-setup' "$tmp/out_deploy.err"
assert '--deploy prompt is [Y/n] (default Y, handoff is the default)' \
grep -qE 'Hand off to.*\[Y/n\]' "$tmp/out_deploy.err"
assert '--deploy invokes just operator-setup (FORGE_SETUP_YES=1 takes the default Y)' \
bash -c "[ -f \"$tmp/just.calls\" ] && grep -qE '^just operator-setup' \"$tmp/just.calls\""
assert '--deploy does NOT invoke just contributor-setup' \
bash -c "! { [ -f \"$tmp/just.calls\" ] && grep -qE '^just contributor-setup' \"$tmp/just.calls\"; }"
assert '--deploy hands off in the cloned orchestrator cwd (basename of FORGE_ORCHESTRATOR_REPO_URL)' \
grep -qF "(cwd=$tmp/workspace/y)" "$tmp/just.calls"
# -- C) run-mode wiring: next_steps.sh --run --manifest --recipe -----
# Invoke next_steps.sh directly with --run to prove the runner execs
# `just operator-setup` (not `just contributor-setup`) when --recipe is set.
rm -f "$tmp/just.calls"
set +e
bash "$tmp/scripts/next_steps.sh" --run --manifest operator_setup_steps.json --recipe operator-setup \
>"$tmp/out_run_deploy.out" 2>"$tmp/out_run_deploy.err"
rc=$?
set -e
assert 'next_steps --run --recipe operator-setup exits 0' bash -c "[ $rc -eq 0 ]"
assert 'next_steps --run invoked `just operator-setup`' \
grep -q '^just operator-setup' "$tmp/just.calls"
assert 'next_steps --run did NOT invoke `just contributor-setup`' \
bash -c "! grep -q '^just contributor-setup' \"$tmp/just.calls\""
# -- D) print mode shows updated skip-autorun hints ---------------------
set +e
bash "$tmp/scripts/next_steps.sh" --manifest operator_setup_steps.json --recipe operator-setup \
>"$tmp/out_print.out" 2>"$tmp/out_print.err"
rc=$?
set -e
assert 'next_steps print mode exits 0' bash -c "[ $rc -eq 0 ]"
assert 'print hint mentions operator-setup recipe' \
grep -q "just operator-setup" "$tmp/out_print.err"
assert 'print hint mentions --recipe flag' \
grep -q "run-next-steps --manifest operator_setup_steps.json --recipe operator-setup" "$tmp/out_print.err"
printf '\n%d pass / %d fail\n' "$pass" "$fail"
[ "$fail" -eq 0 ]