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

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.'