Forgejo rate-limits rapid port-22 handshakes. The bare per-target `git push --all` + `git push --tags` opened ~22 SSH connections in rapid succession; the server refused around the 6th (voyage's tag push) with "port 22: Connection refused" (TCP-level, not auth), deterministically, on every run. Export GIT_SSH_COMMAND with ControlMaster/ControlPath/ControlPersist so all git-over-SSH reuses ONE persistent master connection (one TCP handshake for the whole rollout). Verified: 8 rapid multiplexed connections all succeed where the 6th bare connection is refused; the full 11-target rollout then completed clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
225 lines
12 KiB
Bash
225 lines
12 KiB
Bash
#!/usr/bin/env bash
|
||
# run-operator-window.sh — turnkey execution of the polyrepo operator window (RUNBOOK §0–§4).
|
||
#
|
||
# RUN AS THE OPERATOR, via the chat `!` prefix: ! bash docs/marketplace-polyrepo-migration/migration/run-operator-window.sh
|
||
# NOT via Claude's Bash tool — creating public repos + bulk-pushing trees to new remotes is gated by the
|
||
# auto-mode safety classifier (by design). Running it yourself via `!` is the sanctioned path: it executes
|
||
# as you, so the classifier / permission layer / push-window hook are all out of the picture.
|
||
#
|
||
# Idempotent + stop-on-first-failure. Safe to re-run after a partial run (created repos → 409 ok, pushed
|
||
# refs → up-to-date, already-flipped catalog entries → skipped).
|
||
#
|
||
# DEFAULT (no args) — §0–§3, REVERSIBLE:
|
||
# For all 11 targets in RUNBOOK order: (a) validate the extract → (b) create the Forgejo repo
|
||
# (auto_init:false, public) → (c) push the extract over SSH → (d) verify HTTPS+ref resolution
|
||
# (the install-smoke PROXY — a real `/plugin install` still needs a fresh Claude Code session) →
|
||
# (e) flip marketplace.json to the external nested source + push the catalog.
|
||
# graceful-handoff runs first as the PILOT gate. Standing up the 11 repos is what unblocks per-plugin
|
||
# parallel work — thinning is NOT required for that. Every flip is `git revert`-able while ./plugins/<k>
|
||
# still exists, so this whole phase is reversible.
|
||
#
|
||
# --thin — §4, build + verify the thin-catalog preview (no apply).
|
||
# CONFIRM_THIN=1 ... --thin — §4 APPLY: git rm plugins/ shared/ sync-script, swap in CONVENTIONS/CLAUDE/
|
||
# README, commit, push, push the pre-polyrepo-archive tag. IRREVERSIBLE point of no easy return —
|
||
# run only after the real `/plugin install` smoke-tests for all 11 have passed.
|
||
#
|
||
# Prereqs: $FORGEJO_TOKEN exported (Keychain → ~/.zshenv); on branch main; the 11 extracts buildable in
|
||
# $WORK (the validate step self-extracts if missing — that self-heal also rebuilds the mirror via preflight).
|
||
set -uo pipefail
|
||
|
||
HOST="git.fromaitochitta.com"
|
||
API="https://$HOST/api/v1"
|
||
MIG="$(cd "$(dirname "$0")" && pwd)"
|
||
ROOT="$(cd "$MIG/../../.." && pwd)"
|
||
MAP="$MIG/plugin-map.json"
|
||
LIVE="$ROOT/.claude-plugin/marketplace.json"
|
||
WORK="${WORK:-/tmp/polyrepo-migration}"
|
||
ARCHIVE_TAG="pre-polyrepo-archive"
|
||
|
||
PILOT="graceful-handoff"
|
||
REST="playground-design-system voyage llm-security linkedin-studio ms-ai-architect config-audit okr ai-psychosis human-friendly-style claude-design"
|
||
# The shared design-system is a standalone repo (stood up so consumers can vendor from an upstream), but it
|
||
# is NOT a marketplace plugin: it has no .claude-plugin/plugin.json and no marketplace.json entry. Steps (d)
|
||
# and (e) special-case it (assert its DS root marker; no catalog flip — mirrors 60-rewrite --all).
|
||
DS_KEY="playground-design-system"
|
||
|
||
die() { printf '\n✖ %s\n' "$*" >&2; exit 1; }
|
||
say() { printf '%s\n' "$*"; }
|
||
|
||
# ---- preconditions ----
|
||
[ -n "${FORGEJO_TOKEN:-}" ] || die "FORGEJO_TOKEN not set — export it from Keychain via ~/.zshenv first."
|
||
command -v python3 >/dev/null 2>&1 || die "python3 not found"
|
||
command -v node >/dev/null 2>&1 || die "node not found"
|
||
command -v curl >/dev/null 2>&1 || die "curl not found"
|
||
[ -f "$MAP" ] || die "plugin-map.json missing at $MAP"
|
||
[ -f "$LIVE" ] || die "live marketplace.json missing at $LIVE"
|
||
BR="$(git -C "$ROOT" rev-parse --abbrev-ref HEAD)"
|
||
[ "$BR" = "main" ] || die "catalog repo not on main (on '$BR')"
|
||
|
||
# ---- SSH connection multiplexing (REQUIRED — Forgejo rate-limits rapid port-22 handshakes) ----
|
||
# The bare per-target `git push --all` + `git push --tags` opens 2 SSH connections per target (22 total)
|
||
# in rapid succession; Forgejo refuses around the 6th handshake ("ssh: connect to host ... port 22:
|
||
# Connection refused" — TCP-level, not auth), which killed voyage's tag push on every run. Route ALL
|
||
# git-over-SSH through ONE persistent master connection so the whole rollout costs a single TCP handshake.
|
||
# Verified: 8 rapid multiplexed connections all succeed where the 6th bare connection is refused.
|
||
# ControlPersist keeps the master alive across the inter-target validate/clone gaps.
|
||
export GIT_SSH_COMMAND="ssh -o ControlMaster=auto -o ControlPath=/tmp/polyrepo-ssh-%C -o ControlPersist=600"
|
||
|
||
mapget() { python3 -c "import json;print(json.load(open('$MAP'))['targets']['$1'].get('$2',''))"; }
|
||
|
||
entry_present() {
|
||
python3 -c "import json
|
||
print('yes' if any(x['name']=='$1' for x in json.load(open('$LIVE'))['plugins']) else 'no')"
|
||
}
|
||
|
||
entry_is_external() {
|
||
python3 -c "import json
|
||
p=[x for x in json.load(open('$LIVE'))['plugins'] if x['name']=='$1']
|
||
print('yes' if (p and isinstance(p[0].get('source'),dict)) else 'no')"
|
||
}
|
||
|
||
count_local() {
|
||
python3 -c "import json
|
||
print(sum(1 for x in json.load(open('$LIVE'))['plugins'] if isinstance(x.get('source'),str) and x['source'].startswith('./plugins/')))"
|
||
}
|
||
|
||
rollout_one() {
|
||
key="$1"
|
||
tag="$(mapget "$key" tag)"
|
||
[ -n "$tag" ] || die "no tag for $key in plugin-map.json"
|
||
say ""
|
||
say "==== $key (tag $tag) ===="
|
||
|
||
# (a) validate the extract — self-extracts if $WORK/$key is absent.
|
||
# SC2 is REGRESSION-RELATIVE (the contract the Step-11 dry-run validated): a target passes iff the
|
||
# extraction introduces NO NEW failure. Pre-existing in-repo red (voyage's 2 doc-consistency drifts,
|
||
# ai-psychosis's 1) is the plugin's own concern — 41-validate-or-regression.sh enforces that exact
|
||
# contract (strict 40 first, then standalone-failing ⊆ in-repo-failing) so the window does NOT STOP on
|
||
# red the rehearsal blessed. config-audit keeps its dedicated deterministic gate.
|
||
say " [a] validate extract…"
|
||
if [ "$key" = "config-audit" ]; then
|
||
WORK="$WORK" bash "$MIG/50-config-audit-sc2.sh" >/dev/null 2>&1 || die "$key SC2 gate FAILED — STOP"
|
||
else
|
||
sc2out="$(WORK="$WORK" bash "$MIG/41-validate-or-regression.sh" "$key" 2>&1)" \
|
||
|| die "$key standalone validation FAILED (incl. regression-relative SC2) — STOP${sc2out:+ :: $sc2out}"
|
||
case "$sc2out" in *pre-existing*) say " ${sc2out#*: }";; esac
|
||
fi
|
||
|
||
# (b) create the Forgejo repo (201 created | 409 already exists)
|
||
say " [b] create repo open/${key}…"
|
||
body="$(mktemp)"
|
||
code="$(curl -sS -o "$body" -w '%{http_code}' -X POST "$API/orgs/open/repos" \
|
||
-H "Authorization: token $FORGEJO_TOKEN" -H "Content-Type: application/json" \
|
||
-d "{\"name\":\"$key\",\"private\":false,\"auto_init\":false,\"default_branch\":\"main\"}")"
|
||
case "$code" in
|
||
201) say " created";;
|
||
409) say " already exists (ok, idempotent)";;
|
||
*) cat "$body" >&2; rm -f "$body"; die "$key repo-create unexpected HTTP $code — STOP";;
|
||
esac
|
||
rm -f "$body"
|
||
|
||
# (c) push the extract over SSH (proven auth; the marketplace source URL stays HTTPS)
|
||
say " [c] push extract over SSH…"
|
||
( cd "$WORK/$key" || exit 1
|
||
git remote remove origin >/dev/null 2>&1 || true
|
||
git remote add origin "ssh://git@$HOST/open/$key.git" || exit 1
|
||
git push origin --all || exit 1
|
||
git push origin --tags || exit 1
|
||
) || die "$key push FAILED — STOP"
|
||
|
||
# (d) install-smoke PROXY: HTTPS clone at the pinned tag (the resolution path users hit)
|
||
say " [d] verify HTTPS+ref resolution…"
|
||
sm="$(mktemp -d)"
|
||
git clone --quiet --branch "$tag" "https://$HOST/open/$key.git" "$sm/r" >/dev/null 2>&1 \
|
||
|| die "$key does NOT resolve over HTTPS at $tag — STOP, diagnose the Forgejo/HTTPS/ref chain"
|
||
if [ "$key" = "$DS_KEY" ]; then
|
||
# design-system: no plugin.json — assert its DS root marker instead (tokens.css, per plugin-map test_cmd)
|
||
[ -f "$sm/r/tokens.css" ] || die "$key clone missing tokens.css (DS root marker) — STOP"
|
||
else
|
||
[ -f "$sm/r/.claude-plugin/plugin.json" ] || die "$key clone missing .claude-plugin/plugin.json — STOP"
|
||
fi
|
||
say " resolves: https://$HOST/open/$key @ $tag ✓"
|
||
|
||
# (e) flip the catalog entry to the external nested source (idempotent) + push.
|
||
# The design-system has no marketplace entry (consumers vendor it) — nothing to flip; this mirrors
|
||
# 60-rewrite --all, which only touches the 10 plugins actually present in marketplace.json.
|
||
if [ "$(entry_present "$key")" = "no" ]; then
|
||
say " [e] $key has no marketplace entry (shared design-system, vendored by consumers) — no catalog flip"
|
||
elif [ "$(entry_is_external "$key")" = "yes" ]; then
|
||
say " [e] catalog already external for $key (skip)"
|
||
else
|
||
say " [e] flip catalog → external + push…"
|
||
node "$MIG/60-rewrite-marketplace.mjs" --only "$key" --in "$LIVE" --out /tmp/mp-rollout.json >/dev/null \
|
||
|| die "$key marketplace rewrite FAILED — STOP"
|
||
cp /tmp/mp-rollout.json "$LIVE" || die "$key cp marketplace.json FAILED"
|
||
git -C "$ROOT" add .claude-plugin/marketplace.json || die "$key git add FAILED"
|
||
git -C "$ROOT" commit -q -m "chore(marketplace): externalise $key" || die "$key catalog commit FAILED"
|
||
git -C "$ROOT" push origin main || die "$key catalog push FAILED — STOP"
|
||
say " flipped + pushed"
|
||
fi
|
||
say " ✓ $key DONE"
|
||
}
|
||
|
||
# ======================== §4 thinning ========================
|
||
do_thin() {
|
||
say "OPERATOR WINDOW — §4 thin catalog"
|
||
locals="$(count_local)"
|
||
[ "$locals" = "0" ] || die "$locals catalog entries still local — finish §0–§3 rollout before thinning"
|
||
|
||
ws="$(mktemp -d)"
|
||
bash "$MIG/70-thin-catalog.sh" --workspace "$ws" >/dev/null || die "thin-catalog preview FAILED"
|
||
[ ! -d "$ws/plugins" ] || die "thin preview still contains plugins/"
|
||
[ ! -d "$ws/shared" ] || die "thin preview still contains shared/"
|
||
[ -f "$ws/CONVENTIONS.md" ] || die "thin preview missing CONVENTIONS.md"
|
||
say " thin preview verified at $ws (no plugins/ shared/, CONVENTIONS.md present)"
|
||
|
||
if [ "${CONFIRM_THIN:-}" != "1" ]; then
|
||
say ""
|
||
say " PREVIEW ONLY (irreversible apply is gated). To APPLY:"
|
||
say " CONFIRM_THIN=1 bash $0 --thin"
|
||
exit 0
|
||
fi
|
||
|
||
say " APPLYING irreversible thin state to the live catalog…"
|
||
git -C "$ROOT" rm -r -q --ignore-unmatch plugins shared scripts/sync-design-system.mjs scripts/sync-design-system.test.mjs || true
|
||
cp "$ws/CONVENTIONS.md" "$ROOT/CONVENTIONS.md" || die "cp CONVENTIONS.md FAILED"
|
||
cp "$ws/CLAUDE.md" "$ROOT/CLAUDE.md" || die "cp CLAUDE.md FAILED"
|
||
cp "$ws/README.md" "$ROOT/README.md" || die "cp README.md FAILED"
|
||
git -C "$ROOT" add CONVENTIONS.md CLAUDE.md README.md || die "git add (thin docs) FAILED"
|
||
git -C "$ROOT" add -A plugins shared scripts 2>/dev/null || true
|
||
git -C "$ROOT" commit -q -m "chore(marketplace): thin catalog to manifest + docs (polyrepo migration complete)" \
|
||
|| die "thin commit FAILED"
|
||
git -C "$ROOT" push origin main || die "thin push FAILED"
|
||
git -C "$ROOT" push origin "$ARCHIVE_TAG" || die "archive-tag push FAILED"
|
||
say ""
|
||
say "✓ THINNING COMPLETE — catalog is manifest + docs only; archive tag $ARCHIVE_TAG pushed."
|
||
say " POLYREPO MIGRATION COMPLETE."
|
||
exit 0
|
||
}
|
||
|
||
# ======================== main ========================
|
||
if [ "${1:-}" = "--thin" ]; then
|
||
do_thin
|
||
fi
|
||
|
||
say "OPERATOR WINDOW — §0–§3 rollout (create + push + flip; REVERSIBLE)"
|
||
say "Targets in order: $PILOT (pilot) → $REST"
|
||
say ""
|
||
say "### PILOT: $PILOT — must pass before the other 10 ###"
|
||
rollout_one "$PILOT"
|
||
say ""
|
||
say "### PILOT PASSED — rolling out the remaining 10 ###"
|
||
for k in $REST; do rollout_one "$k"; done
|
||
|
||
say ""
|
||
say "──────────────────────────────────────────────────────────────"
|
||
say "✓ ROLLOUT COMPLETE — 11 repos created + pushed; marketplace.json all-external (nested HTTPS+ref)."
|
||
say " Each plugin now lives at https://$HOST/open/<name> — clone & work on them in parallel."
|
||
say ""
|
||
say " NEXT (recommended before thinning) — real install-smoke per plugin in a FRESH Claude Code session:"
|
||
say " /plugin marketplace update"
|
||
say " /plugin install <name>@ktg-plugin-marketplace # confirm commands/skills/agents load"
|
||
say " Reversible until thinning: 'git revert' the externalise commit and ./plugins/<name> resolves again."
|
||
say ""
|
||
say " When every install-smoke passes, run the irreversible cleanup:"
|
||
say " CONFIRM_THIN=1 bash $0 --thin"
|