diff --git a/.agents/skills/fleet-onboarding/SKILL.md b/.agents/skills/fleet-onboarding/SKILL.md index 83425d6..62dbd44 100644 --- a/.agents/skills/fleet-onboarding/SKILL.md +++ b/.agents/skills/fleet-onboarding/SKILL.md @@ -65,6 +65,15 @@ missing or a `thurbox-cli` is below the manifest's floor. | recommended | a named capability degrades — `quota-axi` for fuel and `refuel`, `glab` for GitLab | | gate | only `./scripts/check.sh` needs it — `lua`, `shellcheck`, `rumdl`, `prek`, plus the git commit-signing configuration, which is not a tool | +The two authentication rows are the ones worth reading rather than skimming, +because neither CLI's own status command answers the question fleet has. +`gh auth` is decided **per account**, so one expired token among three reads +`3 of 4 accounts` and the broken login is named on stderr — not `missing`. +`glab auth` is decided **per host** and names the instance that answered: +`GITLAB_HOST` decides when it is set, and otherwise one working credential is +enough. An operator authenticated to their company's GitLab and not to +gitlab.com has a working setup, and this row says so. + `gh` is required even on a fleet whose work is entirely on GitLab: it is what builds the repo map from `registry/owners.txt`, which is a list of GITHUB owners. `quota-axi` is the one most often missed, and it is not decorative — @@ -189,9 +198,11 @@ so start from the tracked example rather than from memory: edits it later by hand. - Replace the two `# your-github-username` / `# your-org` placeholder lines with the confirmed owners, one per line, username first. -- On a **re-run** there are no placeholders left. Add only owners not already - present, and leave the existing order alone — the sync emits owners in this - file's order, so reshuffling it churns the generated map for nothing. +- On a **re-run** there are no placeholders left, and appending by hand is no + longer the way to do it: `./scripts/add-owner.sh ...` appends, leaves + the existing order alone — the sync emits owners in this file's order, so + reshuffling it churns the generated map for nothing — and refuses a + duplicate. **What the operator gains afterwards**, below, is the fuller path. Verify before moving on; the sync refuses a file with no active entries, and it is better to catch that here: @@ -464,6 +475,58 @@ So do not refuse on an already-configured clone. Detect it — extension healthy, `plugin check` green — say which parts are already in place, and offer to refresh the map rather than redoing everything. +### What the operator gains afterwards + +The thing that actually happens after a first run is not a re-run: the operator +gains an owner, a repository, or a whole `gh` or `glab` account, and the map and +the checks have to catch up. That is **one command**, and offering it is the +narrow thing this section exists for — not the seven steps again: + +```bash +./scripts/add-owner.sh # what is new; writes nothing +./scripts/add-owner.sh --all # add every new owner, then sync +./scripts/add-owner.sh [...] # add the ones they picked +``` + +The report groups owners **by the account that reaches them**, because after a +`gh auth login` that is the shape of the question: this account is now readable, +it reaches these owners, N of them are not in your map. `*` marks an owner +already in `registry/owners.txt`, `+` one that is not, and an owner already +there is never offered twice. A login's own namespace is an owner as well as its +orgs — a new account usually brings at least two. + +**ASK before you add.** Same rule as step 3 and the same reason: which owners +the map covers is the operator's call, not a consequence of which tokens happen +to be on the machine. + +- **Add all of them** — every owner marked `+` +- **A subset I name** — they pick from the `+` rows +- **None** — the report was the answer + +Both add forms append, keep the file's comment header and its order, refuse a +duplicate, and then sync and report **what moved** — owners added, repositories +gained or lost, the totals before and after — rather than printing the map back. + +Three things it does not do, each deliberate: + +- **It logs nobody in.** `gh auth login` and + `glab auth login --hostname ` are interactive and the operator's. Hand + the command over and let them run it; then run the report again. +- **A GitLab host never becomes an owner.** `registry/owners.txt` is read by + `gh`. Authenticating one changes two other things and the report says so: the + `glab auth` row in `preflight.sh` starts naming that host, and a task can + target a repository there through the forge seam in `scripts/lib/forge.py`. +- **It does not onboard a fresh clone.** With no `registry/owners.txt` it + refuses and points back at step 3, where the candidates come from three + sources rather than one. + +Two preflight rows answer the same incremental question, so re-read them rather +than the exit code alone when an operator says a credential is fine and fleet +disagrees. `gh auth` is decided **per account** — one expired token among three +is `3 of 4 accounts`, not a failed setup — and `glab auth` **per host**, naming +the instance that answered, because a self-hosted GitLab is the ordinary case +and gitlab.com is often one the operator has never used. + The one thing a re-run does **not** fix is a **rename**. thurbox names a session when it SPAWNS it and has no verb that renames one, and `ensure_extension` matches a declared session to a live one by NAME — so a manifest edit alone diff --git a/AGENTS.md b/AGENTS.md index 06ed6ed..c0ff62d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,9 +105,22 @@ names every path and the reason for each. EVERY `gh` ACCOUNT, not just the active one, there and in `scripts/sync-registry.sh` — a machine with several logins reaches a different set of repositories per login. `scripts/lib/gh-accounts.sh` is the - seam both go through and its header owns the mechanism; the one thing to - know here is that it reads each login's token BY NAME and never switches the - account the operator's `gh` is pointing at. + seam every reader goes through and its header owns the mechanism; the one + thing to know here is that it reads each login's token BY NAME and never + switches the account the operator's `gh` is pointing at. **Neither CLI's own status + command answers the question preflight has**, so both authentication rows go + through a seam instead: `gh auth` is decided per ACCOUNT, and `glab auth` per + HOST through `scripts/lib/glab-hosts.sh` — a bare `glab auth status` is + all-or-nothing across every instance glab has configured, so it called a + self-hosted-only setup broken, which the forge seam says is the ordinary one. +- `scripts/add-owner.sh` — the incremental half, for what the operator gains + AFTER a first run: an owner, a repo, or a whole account. It names the owners + the current `gh` accounts reach that `registry/owners.txt` does not list, + grouped by the account that reaches them; with `--all` or a named list it + APPENDS them — header and order kept, a duplicate refused — then syncs and + reports what moved rather than the whole map. It logs nobody in, and a GitLab + host is reported as evidence and never as an owner. The fleet-onboarding + skill's **Re-running** section owns the ask that goes with it. - `.agents/skills//SKILL.md` — agent skills, in one agent-agnostic tree. `.claude/skills` is a **symlink** to it, so Claude Code and opencode (which auto-discovers `.claude/skills`) both load the same copy. Never add a second diff --git a/README.md b/README.md index b35d587..64b8910 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,16 @@ fleet — it is what builds the repo map. That done, open the Mission Control session in thurbox and give it a goal. +Afterwards, when you gain an owner, a repository or a whole `gh` account, one +command says what your map does not cover yet and catches it up: + +```bash +./scripts/add-owner.sh # what is new, grouped by the account that reaches it +./scripts/add-owner.sh --all # add them, then sync and say what moved in the map +``` + +It logs nobody in and writes nothing until you ask it to. + ## Watching it **The queue pane** is the live view: the queue in a thurbox column, so you do diff --git a/scripts/add-owner.sh b/scripts/add-owner.sh new file mode 100755 index 0000000..6390050 --- /dev/null +++ b/scripts/add-owner.sh @@ -0,0 +1,366 @@ +#!/usr/bin/env bash +# What this machine can reach that the map does not cover yet — and the one +# command that fixes it. +# +# WHY THIS EXISTS. Onboarding is a seven-step FIRST RUN. Every step of it +# converges on a re-run, but the thing that actually happens later is not a +# re-run: the operator gains an owner, a repository, or a whole `gh` account, +# and the map has to catch up. That meant hand-editing registry/owners.txt and +# remembering which script to run afterwards — and nothing anywhere told them +# what a newly authenticated account even reaches. +# +# So this is the incremental half, and it answers ONE question in two moods: +# +# scripts/add-owner.sh what is new, and nothing is written +# scripts/add-owner.sh ... add those, then sync, then say what moved +# scripts/add-owner.sh --all add every owner above that is new +# +# The report groups owners BY THE ACCOUNT THAT REACHES THEM, because after a +# `gh auth login` that is the shape of the question: this account is now +# readable, it reaches these owners, this many are not in your map. +# +# IT LOGS NOBODY IN. `gh auth login` and `glab auth login` are interactive and +# the operator's. This reads what they have already done, offers, and syncs. +# +# GITLAB IS EVIDENCE, NEVER AN OWNER. An authenticated GitLab host changes what +# preflight reports and what a task can target through the forge seam in +# scripts/lib/forge.py — and contributes nothing to registry/owners.txt, which +# is a list of GITHUB owners read by `gh`. It is reported for exactly that +# reason: an operator who just authenticated one should be told what it did and +# did not change. +# +# THE FILE IS THE OPERATOR'S. Its comment header documents the format for +# whoever edits it by hand, and its ORDER is the order the generated map is +# emitted in — so a new owner is APPENDED and nothing is ever reshuffled. +# +# Options: +# --all add every owner the report marks new +# +# Exit: 0 when the report was printed or the owners were added, 1 when there was +# nothing this script could do — no owners file, no account that could be read, +# a duplicate, a name that is not a GitHub owner — and 2 on a usage error. + +set -uo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." || exit 2 +REPO_ROOT="$PWD" +OWNERS_FILE="$REPO_ROOT/registry/owners.txt" +MAP="$REPO_ROOT/registry/repos.generated.yaml" + +# The same two seams preflight reads, and for the same reason: every `gh` +# account rather than the active one, and GitLab per host rather than +# all-or-nothing. Each file's header owns its mechanism. +# shellcheck source=scripts/lib/gh-accounts.sh +. "$REPO_ROOT/scripts/lib/gh-accounts.sh" +# shellcheck source=scripts/lib/glab-hosts.sh +. "$REPO_ROOT/scripts/lib/glab-hosts.sh" + +ADD_ALL=0 +WANTED=() +while [ $# -gt 0 ]; do + case "$1" in + --all) ADD_ALL=1 ;; + -h | --help) + sed -n '2,41p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 + ;; + -*) + printf 'usage: %s [--all] [owner ...]\n' "$0" >&2 + exit 2 + ;; + *) WANTED+=("$1") ;; + esac + shift +done + +die() { + printf 'error: %s\n' "$1" >&2 + exit 1 +} + +# THE MACHINE THIS IS NOT FOR. A clone with no owners file has not been +# onboarded, and writing one from here would be a first run done badly — the +# candidates come from three sources, not one, and which of them the map should +# cover is a question somebody has to be asked. +[ -f "$OWNERS_FILE" ] || die "no $OWNERS_FILE yet — this is the path for a map that already exists. + A first run belongs to the fleet-onboarding skill, which asks: + ./scripts/discover-owners.sh + cp registry/owners.example.txt registry/owners.txt" + +# One owner per line; `#` starts a comment, blank lines are ignored. The same +# parse sync-registry.sh does, because these two read the same file. +declare -A CONFIGURED=() +while IFS= read -r line || [ -n "$line" ]; do + line="${line%%#*}" + line="$(printf '%s' "$line" | tr -d '[:space:]')" + [ -n "$line" ] && CONFIGURED["$line"]=1 +done <"$OWNERS_FILE" + +# --- what the accounts reach -------------------------------------------------- + +# GitHub, unless `GH_HOST` says otherwise — the variable `gh api` itself obeys, +# so enumerating logins for any other host would name tokens the calls below +# never use. scripts/sync-registry.sh and scripts/discover-owners.sh pick the +# host the same way. +GH_MAP_HOST="${GH_HOST:-github.com}" + +ACCOUNT_ORDER=() +declare -A ACCOUNT_OWNERS=() # login -> "owner owner owner" +NEW_ORDER=() +declare -A NEW_SEEN=() + +# One account's own login and its orgs, in that order. An EMPTY token means the +# active session, which is the fallback the seam documents. +ask_account() { + local token="$1" login orgs org owners="" + login="$(gh_api_as "$token" user --jq .login 2>/dev/null)" || login="" + [ -n "$login" ] || return 1 + owners="$login" + orgs="$(gh_api_as "$token" --paginate user/orgs --jq '.[].login' 2>/dev/null)" || orgs="" + if [ -n "$orgs" ]; then + while IFS= read -r org; do + [ -n "$org" ] && owners="$owners $org" + done <<<"$orgs" + fi + + ACCOUNT_ORDER+=("$login") + ACCOUNT_OWNERS["$login"]="$owners" + for org in $owners; do + if [ -z "${CONFIGURED[$org]+x}" ] && [ -z "${NEW_SEEN[$org]+x}" ]; then + NEW_SEEN["$org"]=1 + NEW_ORDER+=("$org") + fi + done + return 0 +} + +# `command -v gh` AND NOTHING MORE, for the reason discover-owners.sh argues: +# `gh auth status` exits 1 when an account on ANY host has trouble, so gating +# on it throws away every healthy login the moment one has lapsed — which is +# exactly the machine that gains accounts over time. +if command -v gh >/dev/null 2>&1; then + accounts=() + while IFS= read -r acct; do + [ -n "$acct" ] && accounts+=("$acct") + done < <(gh_accounts "$GH_MAP_HOST") + + if [ "${#accounts[@]}" -eq 0 ]; then + ask_account "" || true + else + for acct in "${accounts[@]}"; do + tok="$(gh_account_token "$GH_MAP_HOST" "$acct")" || tok="" + if [ -z "$tok" ]; then + printf "warning: no usable token for gh account '%s' — what it reaches is not below\n" "$acct" >&2 + continue + fi + ask_account "$tok" || + printf "warning: gh account '%s' could not say who it is — what it reaches is not below\n" "$acct" >&2 + done + fi +fi + +# --- the map, before and after ------------------------------------------------ + +# `owner/repo` for every repository in the generated map, in the map's own +# order. This is the only thing read out of it: the report is about what MOVED, +# and printing the map back is what this replaces. +map_pairs() { + [ -f "$MAP" ] || return 0 + awk '/^ - name: / { owner = $3; next } + /^ - name: / { if (owner != "") print owner "/" $3 }' "$MAP" +} + +# The two numbers under the map's `totals:` key, as one phrase. Four-space +# indents belong to a repository entry, so the two-space match reaches only the +# totals block. +map_totals() { + local repos owners + [ -f "$MAP" ] || { + printf 'no map' + return 0 + } + repos="$(sed -n 's/^ repos: \(.*\)$/\1/p' "$MAP" | tail -1)" + owners="$(sed -n 's/^ owners: \(.*\)$/\1/p' "$MAP" | tail -1)" + printf '%s repos across %s owners' "${repos:-?}" "${owners:-?}" +} + +# At most this many names, then a count. A hundred repositories arriving with a +# new employer is a line the operator scrolls past, not one they read. +SHOW=8 +list_some() { + local n=0 item out="" + while IFS= read -r item; do + [ -n "$item" ] || continue + n=$((n + 1)) + [ "$n" -le "$SHOW" ] && out="${out:+$out, }$item" + done + [ "$n" -eq 0 ] && return 0 + [ "$n" -gt "$SHOW" ] && out="$out, +$((n - SHOW)) more" + printf '%s' "$out" +} + +# Not one account could be read, so nothing below was compared against anything. +# Both moods say so, because the alternative is `--all` reporting a complete map +# on the strength of an empty answer. +no_accounts() { + printf 'No gh account answered, so there is nothing to compare your map against.\n\n' + printf ' gh auth login then run this again\n' + printf ' ./scripts/preflight.sh reads the same accounts and says which are broken\n' +} + +# --- report mode -------------------------------------------------------------- + +report() { + local login owners owner mark new_here line + + if [ "${#ACCOUNT_ORDER[@]}" -eq 0 ]; then + no_accounts + return + fi + + # The column is as wide as the widest login rather than a guessed number: + # a `gh` login can be long, and one that overflows a fixed column takes the + # alignment of every row with it. + local width=8 + for login in "${ACCOUNT_ORDER[@]}"; do + [ "${#login}" -gt "$width" ] && width="${#login}" + done + + printf 'ACCOUNTS gh HOLDS — and the GitHub owners each one reaches\n\n' + for login in "${ACCOUNT_ORDER[@]}"; do + owners="${ACCOUNT_OWNERS[$login]}" + line="" + new_here=0 + for owner in $owners; do + mark="*" + if [ -z "${CONFIGURED[$owner]+x}" ]; then + mark="+" + new_here=$((new_here + 1)) + fi + line="${line:+$line, }$mark $owner" + done + if [ "$new_here" -gt 0 ]; then + printf ' %-*s %s (%d new)\n' "$width" "$login" "$line" "$new_here" + else + printf ' %-*s %s\n' "$width" "$login" "$line" + fi + done + printf '\n * already in registry/owners.txt + not in it yet\n' + + # An authenticated GitLab instance is the other thing an operator just did, + # and the report has to say what it changed — which is what preflight + # reports and what a task can target, and NOT this file. + local hosts="" host + while IFS= read -r host; do + [ -n "$host" ] || continue + glab_host_ok "$host" && hosts="${hosts:+$hosts, }$host" + done < <(glab_hosts) + if [ -n "$hosts" ]; then + printf '\nGITLAB — evidence, never an owner\n\n' + printf ' authenticated: %s\n\n' "$hosts" + printf ' That changes what ./scripts/preflight.sh reports and what a task can target\n' + printf ' through the forge seam in scripts/lib/forge.py. It adds no owner here:\n' + printf ' registry/owners.txt is a list of GITHUB owners, read by gh.\n' + fi + + printf '\n' + if [ "${#NEW_ORDER[@]}" -eq 0 ]; then + printf 'Nothing new: every owner these accounts reach is already in registry/owners.txt.\n' + return + fi + + local plural="s" + [ "${#NEW_ORDER[@]}" -eq 1 ] && plural="" + printf '%d owner%s not in registry/owners.txt: %s\n\n' \ + "${#NEW_ORDER[@]}" "$plural" "$(printf '%s\n' "${NEW_ORDER[@]}" | list_some)" + printf ' ./scripts/add-owner.sh --all add every one of them, then sync\n' + printf ' ./scripts/add-owner.sh %-10s or name the ones you want\n' "${NEW_ORDER[0]}" +} + +if [ "$ADD_ALL" -eq 0 ] && [ "${#WANTED[@]}" -eq 0 ]; then + report + exit 0 +fi + +# --- add mode ----------------------------------------------------------------- + +if [ "$ADD_ALL" -eq 1 ]; then + if [ "${#ACCOUNT_ORDER[@]}" -eq 0 ]; then + no_accounts >&2 + exit 1 + fi + if [ "${#NEW_ORDER[@]}" -eq 0 ]; then + printf 'Nothing new: every owner these accounts reach is already in registry/owners.txt.\n' + exit 0 + fi + WANTED+=("${NEW_ORDER[@]}") +fi + +# EVERY name is checked before ANY is written. A run that appended two owners +# and then refused the third would leave the operator's file in a state they +# did not ask for and did not see. +declare -A ASKED=() +for owner in "${WANTED[@]}"; do + # A name given twice on one command line is the same duplicate as one + # already in the file, and appending it twice would put it in the map's + # order twice. + [ -z "${ASKED[$owner]+x}" ] || + die "'$owner' was named twice. Nothing was written." + ASKED["$owner"]=1 + # What a GitHub owner is, and nothing wider. A GitLab group path, a + # host-qualified name or a URL is not one, and a file that accepted one + # would produce `no accessible repos for owner ''` on every sync + # forever — the warning that means a typo, made to mean two things again. + case "$owner" in + *[!A-Za-z0-9-]*) + die "'$owner' is not a GitHub owner — it holds usernames and orgs, one per line. + A GitLab group is not one: a task targets a GitLab repository by host + and path, through the seam in scripts/lib/forge.py." + ;; + esac + [ -z "${CONFIGURED[$owner]+x}" ] || + die "'$owner' is already in registry/owners.txt. Nothing was written." +done + +# Appended, never inserted and never sorted: this file's order is the order +# sync-registry.sh emits owners in, so reshuffling it churns the generated map +# for nothing. A file whose last line has no newline would otherwise get the +# first new owner glued onto it. +[ -s "$OWNERS_FILE" ] && [ -n "$(tail -c 1 "$OWNERS_FILE")" ] && printf '\n' >>"$OWNERS_FILE" +printf '%s\n' "${WANTED[@]}" >>"$OWNERS_FILE" +printf 'added to registry/owners.txt: %s\n' "$(printf '%s\n' "${WANTED[@]}" | list_some)" + +# --- sync, and report what MOVED ---------------------------------------------- + +before_pairs="$(mktemp)" +after_pairs="$(mktemp)" +trap 'rm -f "$before_pairs" "$after_pairs"' EXIT +had_map=0 +[ -f "$MAP" ] && had_map=1 +map_pairs >"$before_pairs" +before_totals="$(map_totals)" + +printf '\n' +"$REPO_ROOT/scripts/sync-registry.sh" || die "the sync failed — registry/owners.txt keeps the owners just added, + so re-running ./scripts/sync-registry.sh is all that is left to do." + +map_pairs >"$after_pairs" + +printf '\nMAP CHANGED\n' +printf ' owners added %s\n' "$(printf '%s\n' "${WANTED[@]}" | list_some)" + +# `grep -Fxv -f` and not `comm`, because these are in the map's own order and +# sorting them to compare would cost that order for nothing. +gained="$(grep -Fxv -f "$before_pairs" "$after_pairs" | list_some)" +lost="$(grep -Fxv -f "$after_pairs" "$before_pairs" | list_some)" +[ -n "$gained" ] && printf ' repositories gained %s\n' "$gained" +[ -n "$lost" ] && printf ' repositories lost %s\n' "$lost" +[ -n "$gained" ] || [ -n "$lost" ] || printf ' repositories none gained, none lost\n' + +after_totals="$(map_totals)" +if [ "$had_map" -eq 1 ]; then + printf ' totals %s (was %s)\n' "$after_totals" "$before_totals" +else + printf ' totals %s (the map did not exist before this)\n' "$after_totals" +fi +printf '\nA repository that is in the map and still unexplained belongs in\nregistry/context/.md, which is where the judgement about a project lives.\n' diff --git a/scripts/check.sh b/scripts/check.sh index 60aa6a9..fbca65d 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -574,11 +574,12 @@ check_voice() { # place-pane — are the ones every operator runs once and never again, so a # regression in them is invisible to everyone who is already set up and total # for everyone who is not. `onboarding-selftest.sh` drives those three offline, -# and sync-registry.sh with them: that one is re-run often, but its read across -# every `gh` account needs a second login to exercise and so is unreachable on -# a machine with one. All of it runs against stubs on a PATH built from scratch -# and a copy of a stock layout, and its header argues each claim — including -# the two seams a grep here could only assert about source text: the pane's +# and sync-registry.sh and add-owner.sh with them: those two are re-run rather +# than run once, but each reads across every `gh` account, which needs a second +# login to exercise and so is unreachable on a machine with one. All of it runs +# against stubs on a PATH built from scratch and a copy of a stock layout, and +# its header argues each claim — including the two seams a grep here could +# only assert about source text: the pane's # slot has ONE spelling (§3c places a RENAMED pane and reads the slot back out # of the block) and the thurbox floor has one owner (§1c reads it from the # manifest and expects it in the remedy). diff --git a/scripts/lib/gh-accounts.sh b/scripts/lib/gh-accounts.sh index e272061..5a38c71 100644 --- a/scripts/lib/gh-accounts.sh +++ b/scripts/lib/gh-accounts.sh @@ -1,12 +1,14 @@ # shellcheck shell=bash # Every `gh` login this machine holds, not just the active one. # -# SOURCED, never executed. Two callers share it and want the same three +# SOURCED, never executed. Four callers share it and want the same three # things — the account list, one account's token, one `gh api` call as that -# account — because both of them ask GitHub who the operator is: +# account — because each of them asks GitHub who the operator is: # # scripts/sync-registry.sh what the repo map is built from # scripts/discover-owners.sh the one question onboarding asks the operator +# scripts/add-owner.sh what a newly authenticated login reaches +# scripts/preflight.sh the `gh auth` row, which is decided per ACCOUNT # # WHY THIS EXISTS. `gh api user/repos` and `gh api user/orgs` answer for # whichever account is ACTIVE. A machine with a personal login and an diff --git a/scripts/lib/glab-hosts.sh b/scripts/lib/glab-hosts.sh new file mode 100644 index 0000000..5bed053 --- /dev/null +++ b/scripts/lib/glab-hosts.sh @@ -0,0 +1,56 @@ +# shellcheck shell=bash +# Which GitLab instances this machine has a credential for, ONE HOST AT A TIME. +# +# SOURCED, never executed. The counterpart to gh-accounts.sh one forge over, +# and it exists for the same reason: the CLI's own status command is +# all-or-nothing, so the question fleet actually has — "is there a credential +# for the instance this work lives on" — cannot be read off its exit code. +# +# WHY THIS EXISTS. `glab auth status` exits non-zero when ANY configured +# instance fails. An operator authenticated to their company's GitLab, where +# their repositories live, and not to gitlab.com, which they have never used, +# therefore got +# +# missing glab auth reading a merge request needs a credential ... +# install: glab auth login +# +# from scripts/preflight.sh — a remedy they had already run. That contradicts +# the forge seam, where which hosts a CLI owns comes from that CLI's own +# variable and a SELF-HOSTED INSTANCE IS THE ORDINARY CASE rather than a +# special one; scripts/lib/forge.py's header owns that argument. +# +# Verified against glab 1.117.0, which is where these three shapes come from: +# `--hostname ` answers for one instance and exits 0/1 for it alone, +# `--all` lists every configured instance at column 0, and a bare `auth +# status` answers for `GITLAB_HOST` when that is set. +# +# IT READS AND NEVER WRITES. `glab auth login` is interactive and the +# operator's; nothing here logs anybody in or touches their glab config. + +# glab_hosts +# +# Every GitLab instance `glab` has configured, one per line. +# +# PRINTS NOTHING, successfully, when the list cannot be read — an older `glab` +# with no `--all`, or none installed. That is the documented fallback and not a +# failure: a caller that gets no lines asks the bare `glab auth status`, which +# is exactly what every caller did before it asked per host. +# +# Hosts are the only lines `--all` puts at column 0; everything else it prints +# — the per-instance findings, and the error banner when one of them failed — +# is indented. The `.` is what keeps a stray unindented word out: an instance +# is a hostname. +glab_hosts() { + command -v glab >/dev/null 2>&1 || return 0 + glab auth status --all 2>&1 | + sed -n 's/^\([A-Za-z0-9][A-Za-z0-9._-]*\.[A-Za-z0-9._-]*\)[[:space:]]*$/\1/p' || + true +} + +# glab_host_ok +# +# Whether that ONE instance authenticates. Exit status is the whole answer. +glab_host_ok() { + command -v glab >/dev/null 2>&1 || return 1 + glab auth status --hostname "$1" >/dev/null 2>&1 +} diff --git a/scripts/onboarding-selftest.sh b/scripts/onboarding-selftest.sh index 77612fb..182a771 100755 --- a/scripts/onboarding-selftest.sh +++ b/scripts/onboarding-selftest.sh @@ -25,12 +25,22 @@ # 5. PLACING THE PANE IS SAFE OR IT DOES NOT HAPPEN. Idempotent, backed up, # refused outright on a layout it cannot recognise, and the block it # writes carries the `panels.shown` guard and the slot the PANE declares. -# 6. EVERY `gh` ACCOUNT IS READ, in both places that ask GitHub who the -# operator is. A machine with several logins reaches a different set of +# 6. EVERY `gh` ACCOUNT IS READ, everywhere that asks GitHub who the operator +# is. A machine with several logins reaches a different set of # repositories per login, so asking only the active one described half the # machine — and in the map's case it did so with the same warning a # MISTYPED owner produces. Nothing switches the active account, and the # fallback to that one account alone stays the floor. +# 7. NEITHER AUTHENTICATION ROW IS AN EXIT CODE. `gh auth status` and +# `glab auth status` are all-or-nothing, so one lapsed login among three, +# and a gitlab.com the operator has never used, each reported a working +# setup as broken. The `gh auth` row is decided per ACCOUNT and the +# `glab auth` row per HOST, each naming what answered and each keeping the +# bare status command as the fallback its seam documents. +# 8. THE MAP CATCHES UP AFTER THE FIRST RUN. `add-owner.sh` names what the +# current accounts reach that the map does not, appends only what was +# asked for — the file's comment header and its ORDER kept, a duplicate +# refused — and then reports what MOVED rather than the whole map. # # HOW IT RUNS OFFLINE. Every probe is a stub on a sandboxed PATH — `gh`, # `thurbox-cli`, `quota-axi` — and the PATH is built from scratch so that a @@ -73,8 +83,11 @@ unset GIT_CONFIG_COUNT _i # `GITHUB_TOKEN` short-circuits `gh_accounts` — both scripts then take the # active-session path and the stub matches the operator's REAL token against # fixture names — and `GH_HOST` moves which host's logins are enumerated, so -# the fixture list under `github.com` comes back empty. Cleared ONCE, here. -unset GH_TOKEN GITHUB_TOKEN GH_HOST +# the fixture list under `github.com` comes back empty. `GITLAB_HOST` is the +# same kind of decider one forge over: §6's rows branch on it, so an operator +# who exports it would answer §6a and §6d for them. Cleared ONCE, here. +# §6b and §6c set GITLAB_HOST as a per-command prefix, which this does not touch. +unset GH_TOKEN GITHUB_TOKEN GH_HOST GITLAB_HOST nl=$'\n' failed=0 @@ -118,7 +131,7 @@ tmp="$(mktemp -d)" # never linked. BASE="$tmp/base" mkdir -p "$BASE" -for t in bash sed grep sort head tail cut tr find cat date mktemp cp rm rmdir mv ls dirname basename wc uname; do +for t in bash sed grep sort head tail cut tr awk find cat date mktemp cp rm rmdir mv ls dirname basename wc uname; do real="$(command -v "$t" 2>/dev/null)" && ln -sf "$real" "$BASE/$t" done @@ -838,6 +851,449 @@ expect_exit "5d a gh without --json still discovers" 0 $? expect "5d from the active session alone" "octo" "$out" expect "5d with its orgs" "acme-org" "$out" +printf '\n\033[1m§6 preflight — the glab row is per HOST, and a self-hosted instance is ordinary\033[0m\n' + +# `glab auth status` with no argument is ALL-OR-NOTHING across every instance +# glab has configured: an operator authenticated to their company's GitLab and +# not to gitlab.com — which they have never used — got a non-zero exit, and +# preflight printed `missing glab auth` with a remedy they had already run. +# +# That contradicts the forge seam, where which hosts a CLI owns comes from that +# CLI's own variable and a self-hosted instance is the ORDINARY case +# (scripts/lib/forge.py's header). So the row is decided per host, and the +# question it answers is "is there a credential fleet could use", not "is every +# instance glab has ever heard of healthy". +# +# The stub is glab 1.117.0's own shape, verified against it: `--hostname` +# answers for one instance, `--all` lists every configured one at column 0 and +# exits non-zero when ANY of them fails, and a bare `auth status` answers for +# GITLAB_HOST when that is set. +glab_stub='case "$1 $2" in +"auth status") + host=""; all=0 + while [ $# -gt 0 ]; do + case "$1" in + --hostname) host="$2"; shift ;; + --all | -a) all=1 ;; + esac + shift + done + if [ "$all" = 1 ]; then + bad=0 + for h in $GLAB_HOSTS; do + printf "%s\n" "$h" + case " $GLAB_OK " in + *" $h "*) echo " ✓ Logged in to $h as someone" ;; + *) echo " x $h: API call failed: 401"; bad=1 ;; + esac + done + exit "$bad" + fi + [ -n "$host" ] || host="${GITLAB_HOST:-gitlab.com}" + case " $GLAB_OK " in *" $host "*) exit 0 ;; esac + exit 1 + ;; +*) echo "glab 1.117.0" ;; +esac' + +glabbin="$tmp/bin-glab" +mkdir -p "$glabbin" +ln -sf "$full"/* "$glabbin/" 2>/dev/null +stub "$glabbin" glab "$glab_stub" + +# Colour codes out, so `missing glab auth` can be refuted as one string. The +# table writes the state and the name with an escape between them otherwise, +# and a refute against the plain words could never fire. +plain_preflight() { + PATH="$glabbin" "$REPO/scripts/preflight.sh" --tier recommended 2>&1 | + sed "s/${esc}\\[[0-9;]*m//g" +} + +# 6a. THE CASE FROM THE FIELD. Two instances configured, a credential for the +# self-hosted one and none for gitlab.com, and no GITLAB_HOST naming either. +out="$(GLAB_HOSTS="gitlab.com gitlab.metyis.technology" GLAB_OK="gitlab.metyis.technology" plain_preflight)" +refute "6a a credential on one configured host is not reported missing" \ + "missing glab auth" "$out" +expect "6a and the row names the host that answered" "gitlab.metyis.technology" "$out" + +# 6b. GITLAB_HOST is glab's own variable for which instance to talk to, so a +# host named there is the one that has to work — a credential for some OTHER +# instance is not the one the forge seam would reach for. +out="$(GITLAB_HOST=gitlab.com GLAB_HOSTS="gitlab.com gitlab.metyis.technology" \ + GLAB_OK="gitlab.metyis.technology" plain_preflight)" +expect "6b GITLAB_HOST naming a host with no credential is missing" \ + "missing glab auth" "$out" +expect "6b and the remedy names that host, not a bare login" \ + "glab auth login --hostname gitlab.com" "$out" + +# 6c. GITLAB_HOST naming the host that does work is the other half of 6b: the +# variable decides, in both directions. +out="$(GITLAB_HOST=gitlab.metyis.technology GLAB_HOSTS="gitlab.com gitlab.metyis.technology" \ + GLAB_OK="gitlab.metyis.technology" plain_preflight)" +refute "6c GITLAB_HOST naming the host that answers is not missing" \ + "missing glab auth" "$out" + +# 6d. No credential anywhere is still missing. Making the row per host must not +# make it unfailable. +out="$(GLAB_HOSTS="gitlab.com" GLAB_OK="" plain_preflight)" +expect "6d no host authenticated is missing" "missing glab auth" "$out" + +# 6e. glab is RECOMMENDED, so none of the above is ever fatal — a fleet whose +# work is entirely on GitHub needs no GitLab credential at all. +PATH="$glabbin" GLAB_HOSTS="gitlab.com" GLAB_OK="" "$REPO/scripts/preflight.sh" >/dev/null 2>&1 +expect_exit "6e a missing GitLab credential is reported, never fatal" 0 "$?" + +printf '\n\033[1m§7 preflight — the gh row is per ACCOUNT, and one expired token is not the answer\033[0m\n' + +# `gh auth status` has the same shape as glab's: it exits non-zero when an +# account on ANY host has authentication issues. With three logins on this +# machine, one lapsed token made preflight report `gh auth` MISSING while two +# working accounts sat there — and `gh auth` is REQUIRED, so that is a setup +# reported as unrunnable because one credential of three had expired. +# +# The per-account answer already exists: scripts/lib/gh-accounts.sh, which the +# map and discovery both read. This row asks it the same question rather than +# writing a second enumeration. +cat >"$tmp/hosts-7.json" <<'JSON' +{"hosts":{"github.com":[ + {"state":"success","active":true,"host":"github.com","login":"octo"}, + {"state":"success","active":false,"host":"github.com","login":"client"}, + {"state":"success","active":false,"host":"github.com","login":"worky"}, + {"state":"timeout","active":false,"host":"github.com","login":"expired"} +]}} +JSON + +ghbin="$tmp/bin-gh7" +mkdir -p "$ghbin" +ln -sf "$full"/* "$ghbin/" 2>/dev/null +real_jq="$(command -v jq 2>/dev/null)" && ln -sf "$real_jq" "$ghbin/jq" +# Plain `auth status` EXITS 1, which is what `gh` does when any account has +# issues and this fixture has an expired one. A row that gated on it would +# report every healthy login as missing, which is 7a. +stub "$ghbin" gh 'case "$1 $2" in +"--version ") echo "gh version 2.100.0" ;; +"auth status") + case "$*" in + *--json*) cat "$FIXTURES/hosts-7.json" ;; + *) echo "expired: authentication failed" >&2; exit 1 ;; + esac + ;; +"api user") echo octo ;; +*) echo "unexpected gh call: $*" >&2; exit 9 ;; +esac' + +out="$(FIXTURES="$tmp" PATH="$ghbin" "$REPO/scripts/preflight.sh" --tier required 2>&1 | + sed "s/${esc}\\[[0-9;]*m//g")" +code_out="$(FIXTURES="$tmp" PATH="$ghbin" "$REPO/scripts/preflight.sh" >/dev/null 2>&1; echo $?)" +expect_exit "7a one expired token among three working logins is not a failed preflight" 0 "$code_out" +refute "7a the gh auth row is not reported missing" "missing gh auth" "$out" +expect "7a it says how many accounts authenticated" "3 of 4 accounts" "$out" +expect "7a and names them" "octo" "$out" +expect "7b the login that did not is named, so a thinner answer is never silent" \ + "expired" "$out" + +# 7c. NO account working is the case the row still has to fail on: making it +# per account must not make `gh auth` unfailable, and it is REQUIRED. +cat >"$tmp/hosts-7-dead.json" <<'JSON' +{"hosts":{"github.com":[ + {"state":"timeout","active":true,"host":"github.com","login":"octo"}, + {"state":"timeout","active":false,"host":"github.com","login":"worky"} +]}} +JSON +deadgh="$tmp/bin-gh7-dead" +mkdir -p "$deadgh" +ln -sf "$ghbin"/* "$deadgh/" 2>/dev/null +stub "$deadgh" gh 'case "$1 $2" in +"--version ") echo "gh version 2.100.0" ;; +"auth status") + case "$*" in + *--json*) cat "$FIXTURES/hosts-7-dead.json" ;; + *) echo "not logged in" >&2; exit 1 ;; + esac + ;; +"api user") exit 1 ;; +*) echo "unexpected gh call: $*" >&2; exit 9 ;; +esac' +out="$(FIXTURES="$tmp" PATH="$deadgh" "$REPO/scripts/preflight.sh" --tier required 2>&1 | + sed "s/${esc}\\[[0-9;]*m//g")" +code_out="$(FIXTURES="$tmp" PATH="$deadgh" "$REPO/scripts/preflight.sh" >/dev/null 2>&1; echo $?)" +expect "7c not one account authenticating is still missing" "missing gh auth" "$out" +expect "7c with the remedy" "gh auth login" "$out" +expect_exit "7c and a required gap is still a non-zero exit" 1 "$code_out" + +# 7d. The fallback the seam documents, which is also every older `gh`: no +# account list means the ACTIVE session is asked alone, exactly as this row +# did before it asked more than one. +oldghbin="$tmp/bin-gh7-old" +mkdir -p "$oldghbin" +ln -sf "$ghbin"/* "$oldghbin/" 2>/dev/null +stub "$oldghbin" gh 'case "$1 $2" in +"--version ") echo "gh version 2.20.0" ;; +"auth status") + case "$*" in + *--json*) echo "unknown flag: --json" >&2; exit 1 ;; + *) exit 0 ;; + esac + ;; +"api user") echo octo ;; +*) echo "unexpected gh call: $*" >&2; exit 9 ;; +esac' +out="$(FIXTURES="$tmp" PATH="$oldghbin" "$REPO/scripts/preflight.sh" --tier required 2>&1 | + sed "s/${esc}\\[[0-9;]*m//g")" +code_out="$(FIXTURES="$tmp" PATH="$oldghbin" "$REPO/scripts/preflight.sh" >/dev/null 2>&1; echo $?)" +expect_exit "7d a gh too old for --json still passes on the active session" 0 "$code_out" +refute "7d and is not reported missing" "missing gh auth" "$out" +expect "7d naming the account that answered" "octo" "$out" + +printf '\n\033[1m§8 add-owner — the path for what the operator gains AFTER the first run\033[0m\n' + +# Onboarding is a first run and converges on a re-run, but the thing that +# actually happens later has no path at all: the operator gains an owner, a +# repository or a whole `gh` account, and the map has to catch up. That meant +# hand-editing registry/owners.txt and remembering which script to re-run, and +# nothing told them what a newly authenticated account even reaches. +# +# Everything below runs against a COPY in a sandbox root. `add-owner.sh` writes +# registry/owners.txt and runs `sync-registry.sh`, which OVERWRITES +# registry/repos.generated.yaml — driving the real ones here would rewrite the +# operator's own owners file and their map with fixture data. +inc="$tmp/incremental" +mkdir -p "$inc/scripts/lib" "$inc/registry" +cp scripts/add-owner.sh scripts/sync-registry.sh "$inc/scripts/" +cp scripts/lib/gh-accounts.sh scripts/lib/glab-hosts.sh "$inc/scripts/lib/" + +# The operator's file as it actually looks: a comment header that documents the +# format for whoever edits it by hand, then entries in the order the map is +# emitted in. Both survive every write below. +cat >"$inc/registry/owners.txt" <<'OWNERS' +# GitHub owners the map covers, one per line. +# `#` starts a comment; blank lines are ignored. +octo +acme-org +OWNERS + +{ + repo_json octo own-repo + repo_json octo second-repo + repo_json acme-org tool +} >"$tmp/repos-octo-8.json" +repo_json employer-org work-thing >"$tmp/repos-worky-8.json" + +# Only `octo` is authenticated to begin with — the machine before the operator +# runs `gh auth login` for the second account. +cat >"$tmp/hosts-8-before.json" <<'JSON' +{"hosts":{"github.com":[ + {"state":"success","active":true,"host":"github.com","login":"octo"} +]}} +JSON +# And after: `worky` is now readable, and it reaches an owner nothing in the +# map has ever heard of. This is the case the whole section is about. +cat >"$tmp/hosts-8-after.json" <<'JSON' +{"hosts":{"github.com":[ + {"state":"success","active":true,"host":"github.com","login":"octo"}, + {"state":"success","active":false,"host":"github.com","login":"worky"} +]}} +JSON + +incbin="$tmp/bin-incremental" +mkdir -p "$incbin" +ln -sf "$BASE"/* "$incbin/" 2>/dev/null +real_jq="$(command -v jq 2>/dev/null)" && ln -sf "$real_jq" "$incbin/jq" +stub "$incbin" gh 'case "$1 $2" in +"auth status") + case "$*" in + *--json*) cat "$FIXTURES/$HOSTS_FIXTURE" ;; + *) exit 0 ;; + esac + ;; +"auth token") + for a in "$@"; do + case "$a" in + octo | worky) echo "tok-$a"; exit 0 ;; + esac + done + exit 1 + ;; +"api user") + case "${GH_TOKEN:-tok-octo}" in + tok-octo) echo octo ;; + tok-worky) echo worky ;; + *) exit 1 ;; + esac + ;; +"api --paginate") + case "$3" in + user/orgs) + case "${GH_TOKEN:-tok-octo}" in + tok-octo) printf "acme-org\n" ;; + tok-worky) printf "employer-org\n" ;; + esac + ;; + *) + case "${GH_TOKEN:-tok-octo}" in + tok-octo) cat "$FIXTURES/repos-octo-8.json" ;; + tok-worky) cat "$FIXTURES/repos-worky-8.json" ;; + *) exit 1 ;; + esac + ;; + esac + ;; +*) echo "unexpected gh call: $*" >&2; exit 9 ;; +esac' +# A GitLab instance that IS authenticated, which must change what is reported +# and never what is written: registry/owners.txt is read by `gh`. +stub "$incbin" glab 'case "$1 $2" in +"auth status") + case "$*" in + *--all* | *-a*) echo "gitlab.example.com"; echo " ✓ Logged in to gitlab.example.com as someone" ;; + *) exit 0 ;; + esac + ;; +*) echo "glab 1.117.0" ;; +esac' + +run_inc() { + (cd "$inc" && FIXTURES="$tmp" HOSTS_FIXTURE="${HOSTS_FIXTURE:-hosts-8-after.json}" \ + PATH="$incbin" "$inc/scripts/add-owner.sh" "$@" 2>&1) +} + +# The map as it stands before any of this — the machine that already has owners +# and a map, which is the only machine this script is for. +HOSTS_FIXTURE=hosts-8-before.json \ + FIXTURES="$tmp" PATH="$incbin" "$inc/scripts/sync-registry.sh" >/dev/null 2>&1 +incmap="$inc/registry/repos.generated.yaml" +[ -f "$incmap" ] || fail "8 the sandbox starts from a machine that already has a map" "no $incmap" + +# --- 8a. what is new ---------------------------------------------------------- +out="$(run_inc)" +code=$? +expect_exit "8a asking what is new exits 0" 0 "$code" +expect "8a the newly authenticated account is named" "worky" "$out" +expect "8a with the owner it reaches that the map does not cover" "employer-org" "$out" +# A newly authenticated account's OWN login is an owner too, and one the map +# does not cover either — `discover-owners.sh` counts a login as a candidate +# for exactly the same reason. So the answer here is two, not one. +expect "8a and it says how many are not in the map" "2 owners" "$out" +expect "8a the report hands over the command that adds them" "add-owner.sh" "$out" + +# 8b. An owner already in the file is NOT offered again. The summary line is +# what an operator acts on, so it is asserted against that alone — the account +# listing above it names every owner each account reaches on purpose, marked. +summary="$(printf '%s\n' "$out" | sed -n '/not in registry\/owners.txt/p')" +expect "8b the summary names the new owner" "employer-org" "$summary" +expect "8b and the new account's own namespace, which is an owner as well" "worky" "$summary" +refute "8b an owner already in the file is not offered twice" "acme-org" "$summary" +refute "8b nor is the operator's own account" "octo" "$summary" + +# 8c. GITLAB IS EVIDENCE, NEVER AN OWNER. An authenticated GitLab host changes +# what preflight reports and what a task can target through the forge seam; a +# namespace of it reaching owners.txt would make the `gh` map silently thinner. +expect "8c an authenticated GitLab host is reported" "gitlab.example.com" "$out" +refute "8c but never as something to add" "gitlab.example.com" "$summary" + +# --- 8d. adding it ------------------------------------------------------------ +# A repository disappears at the same time — archived, deleted, transferred. +# The report is about what MOVED, so it has to see both directions. +{ + repo_json octo own-repo + repo_json acme-org tool +} >"$tmp/repos-octo-8.json" + +out="$(run_inc --all)" +code=$? +expect_exit "8d adding every new owner exits 0" 0 "$code" +expect "8d it says which owner it added" "employer-org" "$out" +expect "8d the map's own owner count moved" \ + "across 3 owners (was 3 repos across 2 owners)" "$out" +expect "8d a repository the new account reaches is gained" "employer-org/work-thing" "$out" +expect "8d and one that disappeared is reported lost" "octo/second-repo" "$out" +refute "8d the whole map is not printed back" "pushed_at" "$out" + +# 8e. The file is the operator's: the header that documents its format for +# whoever edits it by hand survives, and so does the ORDER, which is the order +# the generated map is emitted in. Appending is right; reshuffling is churn. +owners_now="$(cat "$inc/registry/owners.txt")" +expect "8e the comment header survives the write" "# GitHub owners the map covers" "$owners_now" +entries="$(grep -vE '^[[:space:]]*(#|$)' "$inc/registry/owners.txt" | tr '\n' ' ')" +if [ "$entries" = "octo acme-org worky employer-org " ]; then + pass "8e the new owner is APPENDED, and the existing order is untouched" +else + fail "8e the new owner is APPENDED, and the existing order is untouched" "got: $entries" +fi +expect "8e and the map now carries it" " - name: employer-org" "$(cat "$incmap")" + +# --- 8f. a second run has nothing to offer ------------------------------------ +out="$(run_inc)" +expect_exit "8f a re-run once everything is added exits 0" 0 "$?" +expect "8f and says there is nothing new rather than offering the same list" \ + "Nothing new" "$out" +refute "8f no owner is proposed a second time" "not in registry/owners.txt" "$out" + +# --- 8g. a duplicate is refused ----------------------------------------------- +before_dup="$(cat "$inc/registry/owners.txt")" +out="$(run_inc acme-org)" +code=$? +expect_exit "8g adding an owner already in the file is refused" 1 "$code" +expect "8g and says which one" "acme-org" "$out" +if [ "$before_dup" = "$(cat "$inc/registry/owners.txt")" ]; then + pass "8g the file is not touched" +else + fail "8g the file is not touched" "it changed" +fi + +# --- 8h. what counts as an owner is not widened ------------------------------- +# registry/owners.txt holds GITHUB owners, read by `gh`. A GitLab group path, a +# host-qualified name or a URL is not one, and a file that accepted one would +# produce a `no accessible repos` warning forever. +out="$(run_inc group/subgroup)" +code=$? +expect_exit "8h a path is not a GitHub owner" 1 "$code" +expect "8h and the refusal says what the file holds" "GitHub owner" "$out" +refute "8h nothing was written" "MAP CHANGED" "$out" + +out="$(run_inc 'gitlab.example.com/group')" +expect_exit "8h nor is a host-qualified name" 1 "$?" + +# --- 8i. the machine this script is NOT for ----------------------------------- +# A clone with no owners file has not been onboarded, and inventing one here +# would be a first run done badly. It names the skill that owns that instead. +noowners="$tmp/incremental-bare" +mkdir -p "$noowners/scripts/lib" "$noowners/registry" +cp scripts/add-owner.sh scripts/sync-registry.sh "$noowners/scripts/" +cp scripts/lib/gh-accounts.sh scripts/lib/glab-hosts.sh "$noowners/scripts/lib/" +out="$(cd "$noowners" && FIXTURES="$tmp" HOSTS_FIXTURE=hosts-8-after.json \ + PATH="$incbin" "$noowners/scripts/add-owner.sh" 2>&1)" +expect_exit "8i a clone with no owners file is refused, not onboarded from here" 1 "$?" +expect "8i and it points at what does own a first run" "discover-owners.sh" "$out" +# --- 8j. --all when not one account answered ---------------------------------- +# gh is installed and every credential is expired or logged out — exactly the +# machine this whole change is about. Nothing was compared against anything, so +# `--all` may not report the map complete: an empty answer is not agreement. +deadinc="$tmp/bin-incremental-dead" +mkdir -p "$deadinc" +ln -sf "$incbin"/* "$deadinc/" 2>/dev/null +stub "$deadinc" gh 'case "$1 $2" in +"auth status") + case "$*" in + *--json*) echo "{\"hosts\":{\"github.com\":[]}}" ;; + *) echo "not logged in" >&2; exit 1 ;; + esac + ;; +"api user") exit 1 ;; +*) echo "unexpected gh call: $*" >&2; exit 9 ;; +esac' +before_dead="$(cat "$inc/registry/owners.txt")" +out="$(cd "$inc" && FIXTURES="$tmp" PATH="$deadinc" "$inc/scripts/add-owner.sh" --all 2>&1)" +code=$? +expect_exit "8j --all with no account that answered is refused" 1 "$code" +expect "8j and says so rather than calling the map complete" "No gh account answered" "$out" +refute "8j it does not claim every owner is already covered" "Nothing new" "$out" +if [ "$before_dead" = "$(cat "$inc/registry/owners.txt")" ]; then + pass "8j the file is not touched" +else + fail "8j the file is not touched" "it changed" +fi + printf '\n' if [ "$failed" -eq 0 ]; then printf '\033[32monboarding selftest: everything passed\033[0m\n' diff --git a/scripts/preflight.sh b/scripts/preflight.sh index f39bfc0..5c56759 100755 --- a/scripts/preflight.sh +++ b/scripts/preflight.sh @@ -36,6 +36,14 @@ set -uo pipefail cd "$(dirname "${BASH_SOURCE[0]}")/.." || exit 2 +# The two authentication rows are PER ACCOUNT and PER HOST, and both seams are +# shared with the scripts that do the real reading rather than reimplemented +# here. See each row below for what its CLI's bare status command gets wrong. +# shellcheck source=scripts/lib/gh-accounts.sh +. scripts/lib/gh-accounts.sh +# shellcheck source=scripts/lib/glab-hosts.sh +. scripts/lib/glab-hosts.sh + MODE="text" TIERS="" while [ $# -gt 0 ]; do @@ -148,13 +156,51 @@ if have required gh \ # Authentication is a second, separate fact about the same tool: an # installed `gh` that cannot answer `user/repos` fails the registry sync # with an error that reads like a network problem. - if gh auth status >/dev/null 2>&1; then - record required "gh auth" ok "$(gh api user --jq .login 2>/dev/null)" \ - "the registry sync reads GitHub as you, with no PAT and no CI secret" "" + # + # ASKED PER ACCOUNT, because `gh auth status` is all-or-nothing across + # every login on every host: one lapsed token among three made this row + # report `missing` while two working accounts sat there — and `gh auth` + # is REQUIRED, so that is a whole setup called unrunnable because one + # credential of three had expired. The per-account answer already exists + # and the map and discovery both read it; this asks that same seam rather + # than writing a second enumeration. + gh_why="the registry sync reads GitHub as you — EVERY account, no PAT, no CI secret" + gh_warn="$(mktemp)" + gh_logins=() + while IFS= read -r acct; do + [ -n "$acct" ] && gh_logins+=("$acct") + done < <(gh_accounts "${GH_HOST:-github.com}" 2>"$gh_warn") + + # The seam's own words about a login it skipped, passed through verbatim. + # Counting them here and swallowing the lines would report how many + # accounts are broken without naming WHICH, and an account that goes + # missing in silence is the failure that whole file is about. + gh_skipped=0 + if [ -s "$gh_warn" ]; then + gh_skipped="$(grep -c '' "$gh_warn")" + cat "$gh_warn" >&2 + fi + rm -f "$gh_warn" + + if [ "${#gh_logins[@]}" -gt 0 ]; then + gh_detail="" + for login in "${gh_logins[@]}"; do + gh_detail="${gh_detail:+$gh_detail, }$login" + done + gh_total=$((${#gh_logins[@]} + gh_skipped)) + if [ "$gh_total" -gt 1 ]; then + gh_detail="${#gh_logins[@]} of $gh_total accounts — $gh_detail" + fi + record required "gh auth" ok "$gh_detail" "$gh_why" "" + elif gh auth status >/dev/null 2>&1; then + # The fallback the seam documents, which is also every machine with + # one login: no account list — an older `gh`, or a `GH_TOKEN` in the + # environment that overrides the stored ones anyway — means the ACTIVE + # session is asked alone, exactly as this row did before it asked more + # than one. That keeps the floor where it was. + record required "gh auth" ok "$(gh api user --jq .login 2>/dev/null)" "$gh_why" "" else - record required "gh auth" missing "" \ - "the registry sync reads GitHub as you, with no PAT and no CI secret" \ - "gh auth login" + record required "gh auth" missing "" "$gh_why" "gh auth login" fi fi @@ -206,13 +252,48 @@ glab_install="$(pkg_cmd glab glab glab glab)" if have recommended glab \ "fleet's GitLab forge adapter; nothing needs it until a task's repo lives on GitLab" \ "${glab_install:-see https://gitlab.com/gitlab-org/cli}"; then - if glab auth status >/dev/null 2>&1; then - record recommended "glab auth" ok "" \ - "reading a merge request needs a credential for the host it lives on" "" + # ASKED PER HOST, for the reason scripts/lib/glab-hosts.sh argues at + # length: `glab auth status` is all-or-nothing across every instance glab + # has configured, so an operator authenticated to their company's GitLab + # — where their repositories actually live — and not to gitlab.com, which + # they have never used, read `missing` beside a remedy they had already + # run. A self-hosted instance is the ORDINARY case for this row, not a + # footnote on the end of it. + glab_why="a merge request is read with a credential for ITS host, not for every host glab knows" + if [ -n "${GITLAB_HOST:-}" ]; then + # `GITLAB_HOST` is glab's own variable for which instance to talk to, + # so a host named there is the one that must work: it is where every + # glab call fleet makes would go, and a credential for some other + # instance is not one the forge seam would ever reach for. + if glab_host_ok "$GITLAB_HOST"; then + record recommended "glab auth" ok "$GITLAB_HOST" "$glab_why" "" + else + record recommended "glab auth" missing "" "$glab_why" \ + "glab auth login --hostname $GITLAB_HOST" + fi else - record recommended "glab auth" missing "" \ - "reading a merge request needs a credential for the host it lives on" \ - "glab auth login # GITLAB_HOST=... for a self-hosted instance" + glab_seen=0 + glab_ok="" + while IFS= read -r host; do + [ -n "$host" ] || continue + glab_seen=1 + glab_host_ok "$host" && glab_ok="${glab_ok:+$glab_ok, }$host" + done < <(glab_hosts) + + if [ -n "$glab_ok" ]; then + # One working credential is the whole question — fleet reaches a + # GitLab repository by HOST plus path, so an instance it will + # never be pointed at cannot make the ones it will unusable. + record recommended "glab auth" ok "$glab_ok" "$glab_why" "" + elif [ "$glab_seen" -eq 0 ] && glab auth status >/dev/null 2>&1; then + # The same fallback the gh row has, and for the same reason: an + # older `glab` with no `--all` enumerates nothing, and the bare + # status command is then the only answer there is. + record recommended "glab auth" ok "" "$glab_why" "" + else + record recommended "glab auth" missing "" "$glab_why" \ + "glab auth login # it asks which instance" + fi fi fi