diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 0ac06b162f..094138afac 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -114,6 +114,8 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" . "$SCRIPT_DIR/fm-x-lib.sh" # shellcheck source=bin/fm-backend.sh disable=SC1091 . "$SCRIPT_DIR/fm-backend.sh" +# shellcheck source=bin/fm-forge-lib.sh disable=SC1091 +. "$SCRIPT_DIR/fm-forge-lib.sh" fleet_sync_origin_backed_project_count() { local count proj @@ -527,7 +529,14 @@ missing_tool_diagnostic() { # fm_backend_required_tools (bin/fm-backend.sh). So a herdr/zellij/cmux home is # never told tmux is missing, and only orca drops treehouse. A backend value with # no verified dependency set is reported before the universal checks continue. -COMMON_TOOLS="node git gh no-mistakes gh-axi chrome-devtools-axi lavish-axi tasks-axi quota-axi" +COMMON_TOOLS="node git no-mistakes chrome-devtools-axi lavish-axi tasks-axi quota-axi" +FORGE_PROVIDERS_SEEN=$(fm_forge_scan_registered_projects "$PROJECTS" | awk '{print $2}' | sort -u) +if printf '%s\n' "$FORGE_PROVIDERS_SEEN" | grep -qx github; then + COMMON_TOOLS="$COMMON_TOOLS gh gh-axi" +fi +if printf '%s\n' "$FORGE_PROVIDERS_SEEN" | grep -qx gitlab; then + COMMON_TOOLS="$COMMON_TOOLS glab" +fi BACKEND=$(fm_backend_name) BACKEND_VALID=1 if ! BACKEND_TOOLS=$(fm_backend_required_tools "$BACKEND"); then @@ -881,7 +890,20 @@ fi if command -v tasks-axi >/dev/null 2>&1 && ! fm_tasks_axi_compatible; then echo "MISSING: tasks-axi (install: $(install_cmd tasks-axi))" fi -gh auth status >/dev/null 2>&1 || echo "NEEDS_GH_AUTH" +FORGE_AUTH_CHECKED="" +while read -r _proj_id _proj_provider _proj_host; do + [ -n "${_proj_provider:-}" ] || continue + case "$_proj_provider" in + github|gitlab) : ;; + *) continue ;; + esac + _pair="$_proj_provider:${_proj_host:-}" + case " $FORGE_AUTH_CHECKED " in + *" $_pair "*) continue ;; + esac + FORGE_AUTH_CHECKED="$FORGE_AUTH_CHECKED $_pair" + fm_forge_check_auth "$_proj_provider" "${_proj_host:-}" +done <<< "$(fm_forge_scan_registered_projects "$PROJECTS")" # Worktree-tangle check: the firstmate primary checkout (FM_ROOT) must sit on its # default branch, not a feature branch (see fm-tangle-lib.sh). Scoped to the # primary only; detached-HEAD worktrees and secondmate homes never trip it. diff --git a/bin/fm-forge-lib.sh b/bin/fm-forge-lib.sh new file mode 100755 index 0000000000..640a3199f0 --- /dev/null +++ b/bin/fm-forge-lib.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +# fm-forge-lib.sh — thin Firstmate forge provider boundary (bootstrap slice). +# +# Derives bootstrap CLI/auth requirements from registered project checkouts +# so GitLab-only homes do not require gh/gh-axi/gh auth, and GitHub-only +# homes do not require glab. Deliberately does NOT implement merge, teardown, +# head-SHA, or review-diff parity — no-mistakes owns those for no-mistakes +# delivery mode via glab/gh. +# +# +# Exit contract (subset actually used by the operations below): +# 0 = success, normalized output on stdout +# 1 = provider CLI / auth / network failure — no positive state may be inferred +# 2 = invalid/unsafe input — caller error +# 5 = capability unsupported by this provider/topology (used for "local"/"unknown") + +set -u + +# fm_forge_detect_provider +# Resolves a project checkout's `origin` remote to a provider. Does not +# guess: unknown/missing remotes are reported as "unknown", never silently +# defaulted to github or gitlab (matches the plan's boundary rule). +# +# Output: one line, one of: github gitlab local unknown +# Exit: 0 always (the classification itself is the result; "unknown" is not +# a script failure) +fm_forge_gitlab_hosts() { + local host + printf '%s\n' "${FM_GITLAB_HOSTS:-gitlab.com}" \ + | tr ',' '\n' \ + | while IFS= read -r host; do + host=$(printf '%s' "$host" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') + [ -n "$host" ] && printf '%s\n' "$host" + done +} + +fm_forge_host_is_gitlab() { + local host=${1:-} + [ -n "$host" ] || return 1 + fm_forge_gitlab_hosts | grep -F -x -q -- "$host" +} + +fm_forge_checkout_remote() { + local checkout=${1:-} remote + [ -d "$checkout" ] || return 1 + remote=$(git -C "$checkout" remote get-url origin 2>/dev/null) && { + printf '%s\n' "$remote" + return 0 + } + while IFS= read -r remote; do + [ -n "$remote" ] || continue + git -C "$checkout" remote get-url "$remote" 2>/dev/null && return 0 + done < <(git -C "$checkout" remote 2>/dev/null) + return 1 +} + +fm_forge_detect_provider() { + local checkout=${1:-} remote_url host + + if [ -z "$checkout" ] || [ ! -d "$checkout" ]; then + echo "unknown" + return 0 + fi + if ! git -C "$checkout" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "local" + return 0 + fi + remote_url=$(fm_forge_checkout_remote "$checkout") || { + echo "local" + return 0 + } + host=$(fm_forge_resolve_host "$remote_url" 2>/dev/null) || host="" + case "$host" in + github.com) echo "github" ;; + gitlab.com) echo "gitlab" ;; + *) + if fm_forge_host_is_gitlab "$host"; then + echo "gitlab" + else + echo "unknown" + fi + ;; + esac +} + +# fm_forge_resolve_host +# Resolves a git remote URL (scp-like SSH syntax, ssh:// URL, or https:// URL) +# to its real hostname, following `~/.ssh/config` Host aliases via `ssh -G` +# (needed because this stack's GitLab remotes use an alias, `gitlab-becoming`, +# not a literal hostname — see DAILY_OPERATIONS_GUIDE.md project setup). +# +# Output: one line, the resolved lowercase hostname, or empty on failure +# Exit: 0 if resolved, 1 if the remote URL could not be parsed at all +fm_forge_resolve_host() { + local remote_url=${1:-} host="" + + [ -n "$remote_url" ] || return 1 + + case "$remote_url" in + git@*:*|*@*:*) + # scp-like syntax: user@host:path (host may be an ssh config alias) + host=${remote_url#*@} + host=${host%%:*} + ;; + ssh://*) + host=${remote_url#ssh://} + host=${host#*@} + host=${host%%/*} + host=${host%%:*} + ;; + https://*|http://*) + host=${remote_url#*://} + host=${host%%/*} + host=${host%%@*} + ;; + *) + return 1 + ;; + esac + + [ -n "$host" ] || return 1 + + # Follow SSH config aliases when possible. If ssh cannot resolve the alias, + # retain the parsed host; provider classification will fail closed unless + # it is explicitly listed in FM_GITLAB_HOSTS. + local resolved + if command -v ssh >/dev/null 2>&1; then + resolved=$(ssh -G -- "$host" 2>/dev/null | awk '$1=="hostname"{print $2; exit}') + [ -n "$resolved" ] && host=$resolved + fi + + printf '%s\n' "$host" | tr '[:upper:]' '[:lower:]' + return 0 +} + +# fm_forge_require_cli +# Validates that the CLI tools required for this provider are on PATH. +# Does not check auth (see fm_forge_check_auth) — only binary presence. +# +# Output: one MISSING line per absent tool (same shape as fm-bootstrap.sh's +# existing missing_tool_diagnostic lines), nothing when all present +# Exit: 0 if all required tools present, 1 if any are missing +fm_forge_require_cli() { + local provider=${1:-} missing=0 tool + + case "$provider" in + github) + for tool in gh gh-axi; do + command -v "$tool" >/dev/null 2>&1 || { echo "MISSING: $tool"; missing=1; } + done + ;; + gitlab) + for tool in glab; do + command -v "$tool" >/dev/null 2>&1 || { echo "MISSING: $tool"; missing=1; } + done + ;; + local|unknown) + : # no forge CLI required + ;; + *) + return 2 + ;; + esac + + return "$missing" +} + +# fm_forge_check_auth +# Checks forge authentication, scoped to the specific host (never an +# unscoped "--all" check — a single stale unrelated credential must not +# fail an otherwise-healthy host, per the plan's explicit warning). +# +# Output: nothing on success; one diagnostic line on failure, matching the +# existing NEEDS_GH_AUTH convention so fm-session-start.sh's existing +# consumers keep working without modification +# Exit: 0 authenticated, 1 not authenticated / check failed +fm_forge_check_auth() { + local provider=${1:-} host=${2:-} + + case "$provider" in + github) + if [ -n "$host" ]; then + gh auth status --hostname "$host" >/dev/null 2>&1 || { echo "NEEDS_GH_AUTH"; return 1; } + else + gh auth status >/dev/null 2>&1 || { echo "NEEDS_GH_AUTH"; return 1; } + fi + ;; + gitlab) + # Missing CLI is already reported by fm-bootstrap.sh; do not emit a + # misleading auth diagnostic for an unavailable binary. + command -v glab >/dev/null 2>&1 || return 1 + if [ -n "$host" ]; then + glab auth status --hostname "$host" >/dev/null 2>&1 || { echo "NEEDS_GLAB_AUTH: $host"; return 1; } + else + glab auth status >/dev/null 2>&1 || { echo "NEEDS_GLAB_AUTH"; return 1; } + fi + ;; + local|unknown) + return 0 + ;; + *) + return 2 + ;; + esac + + return 0 +} + +# fm_forge_scan_registered_projects +# Scans every symlinked/real project checkout directly under the given +# projects directory (matches $FM_HOME/projects layout) and prints one +# " " line per entry. Used by fm-bootstrap.sh +# to compute the required tool/auth union across the actual registry +# instead of the previous unconditional GitHub-only assumption. +# +# Output: " " per line (host empty for +# local/unknown) +# Exit: 0 always (per-project detection failures are reported inline via +# provider=unknown, not a fatal scan error) +fm_forge_scan_registered_projects() { + local projects_dir=${1:-} entry project_id provider remote_url host + + [ -n "$projects_dir" ] && [ -d "$projects_dir" ] || return 0 + + for entry in "$projects_dir"/*/; do + [ -e "$entry" ] || continue + project_id=$(basename "$entry") + provider=$(fm_forge_detect_provider "$entry") + host="" + if [ "$provider" = "github" ] || [ "$provider" = "gitlab" ]; then + remote_url=$(fm_forge_checkout_remote "$entry" 2>/dev/null) || remote_url="" + [ -n "$remote_url" ] && host=$(fm_forge_resolve_host "$remote_url" 2>/dev/null || true) + fi + printf '%s %s %s\n' "$project_id" "$provider" "${host:-}" + done +}