fuzzyworld-tools / files / server / fw
fw
623239b7 2026-10-26 Pavel Safronov | 1 | #!/bin/bash |
| 2 | # fw: manage fuzzyworld git repos on DreamHost. | |
| 3 | # | |
| 4 | # fw list every repo with its visibility and last commit | |
| 5 | # fw new <name> [--public] [--description TEXT] | |
| 6 | # create ~/git/<name>.git (private unless --public) | |
| 7 | # fw public <name> publish source on code.fuzzyworld.net | |
| 8 | # fw private <name> unpublish: remove public clone copy and pages | |
| 9 | # fw describe <name> TEXT set the description shown on the index | |
| 10 | # fw publish <name> refresh public copy + pages (the push hook runs this) | |
| 11 | # fw publish-all publish every public repo, then the index | |
| 12 | # fw install-hooks point every repo at the shared hooks | |
f9ca0752 2026-10-27 Pavel Safronov | 13 | # fw apps-index rebuild the apps.fuzzyworld.net index |
ec9751fa 2026-10-27 Pavel Safronov | 14 | # fw content render the Markdown site pages (server/content/*.md, e.g. /why/) |
f9ca0752 2026-10-27 Pavel Safronov | 15 | # fw app-deployed <app> <repo> after `fw deploy`: apps index, plus the repo's code page |
1758548c 2026-10-27 Pavel Safronov | 16 | # fw ci <name> [branch] queue a CI run of a branch's latest commit (default: the default branch) |
| 17 | # fw ci-queue <name> <ref> <sha> queue a CI run (the push hook runs this) | |
| 18 | # fw ci-list queued and running CI jobs | |
a5a72f55 2026-10-27 Pavel Safronov | 19 | # fw ci-watch follow CI (all repos, private too) until nothing is queued or running |
| 20 | # fw ci-pages <name> rebuild a repo's CI pages and the dashboard (runner-api runs this) | |
| 21 | # fw ci-dashboard rebuild code.fuzzyworld.net/ci/ | |
e9fdc5db 2026-11-01 Pavel Safronov | 22 | # fw comments <cmd> moderate blog comments (list, show, approve, reject, ... ingest, imap-check) |
55e5fc3e 2026-11-04 Pavel Safronov | 23 | # fw issues <repo> public|reviewed|private who sees a repo's bug-report issue pages (default private) |
85702b14 2026-11-04 Pavel Safronov | 24 | # fw issues <cmd> moderate issues (list, show, record, approve, hide, close, reopen, publish, publish-all, ingest, imap-check) |
258a9da8 2026-11-06 Pavel Safronov | 25 | # fw deploys [--json] what is deployed where: the deploy ledger and each deployed app against its repo's HEAD |
623239b7 2026-10-26 Pavel Safronov | 26 | # |
| 27 | # Visibility is `git config fuzzyworld.visibility` inside each repo. Anything other | |
| 28 | # than exactly "public" is treated as private. | |
| 29 | set -euo pipefail | |
| 30 | ||
| 31 | GIT_ROOT=${FW_GIT_ROOT:-$HOME/git} | |
| 32 | SITE=${FW_CODE_SITE:-$HOME/code.fuzzyworld.net} | |
| 33 | TOOLS=${FW_TOOLS:-$HOME/fw/tools} | |
| 34 | STATE=${FW_STATE:-$HOME/fw/state} | |
| 35 | LOGS=${FW_LOGS:-$HOME/fw/logs} | |
1758548c 2026-10-27 Pavel Safronov | 36 | QUEUE=${FW_QUEUE:-$HOME/fw/queue} |
623239b7 2026-10-26 Pavel Safronov | 37 | HOOKS=$TOOLS/server/hooks |
| 38 | GEN=(python3 "$TOOLS/server/gen.py") | |
| 39 | ||
| 40 | die() { echo "fw: $*" >&2; exit 1; } | |
| 41 | ||
| 42 | valid_name() { | |
| 43 | [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] && [[ "$1" != *.git ]] || die "invalid repo name: '$1'" | |
| 44 | } | |
| 45 | ||
| 46 | repo_dir() { valid_name "$1"; echo "$GIT_ROOT/$1.git"; } | |
| 47 | ||
| 48 | need_repo() { [ -d "$(repo_dir "$1")" ] || die "no such repo: $1"; } | |
| 49 | ||
| 50 | visibility() { | |
| 51 | local v; v=$(git -C "$(repo_dir "$1")" config --get fuzzyworld.visibility || true) | |
| 52 | [ "$v" = public ] && echo public || echo private | |
| 53 | } | |
| 54 | ||
| 55 | # Serialise work on one repo (a push and a manual publish could overlap). | |
| 56 | with_lock() { | |
| 57 | local name=$1; shift | |
| 58 | mkdir -p "$STATE/locks" | |
| 59 | exec 9>"$STATE/locks/$name.lock" | |
| 60 | flock -w 600 9 || die "timed out waiting for lock on $name" | |
| 61 | "$@" | |
| 62 | } | |
| 63 | ||
| 64 | unpublish() { | |
| 65 | local name=$1 | |
| 66 | valid_name "$name" | |
| 67 | rm -rf -- "${SITE:?}/${name:?}" "${SITE:?}/${name:?}.git" | |
| 68 | rm -f -- "${STATE:?}/${name:?}.json" | |
| 69 | } | |
| 70 | ||
| 71 | publish_one() { | |
| 72 | local name=$1 src pub | |
| 73 | src=$(repo_dir "$name"); pub="$SITE/$name.git" | |
| 74 | if [ "$(visibility "$name")" != public ]; then | |
| 75 | unpublish "$name" | |
| 76 | echo "$name: private, not published" | |
| 77 | return | |
| 78 | fi | |
| 79 | # Public clone copy for dumb-HTTP `git clone https://code.fuzzyworld.net/<name>.git`. | |
| 80 | # Objects are hardlinked on first copy; later fetches add only new packs. | |
| 81 | if [ ! -d "$pub" ]; then | |
| 82 | git clone --quiet --mirror --local "$src" "$pub" | |
| 83 | else | |
| 84 | git -C "$pub" fetch --quiet --prune origin '+refs/*:refs/*' | |
| 85 | fi | |
| 86 | git -C "$pub" symbolic-ref HEAD "$(git -C "$src" symbolic-ref HEAD)" | |
| 87 | git -C "$pub" update-server-info | |
55e5fc3e 2026-11-04 Pavel Safronov | 88 | # Issue pages (bug reports) live in the repo's folder; rebuilt under the lock we already hold. |
| 89 | python3 "$TOOLS/server/issues.py" publish "$name" --locked || echo "fw: issues publish failed for $name" >&2 | |
623239b7 2026-10-26 Pavel Safronov | 90 | "${GEN[@]}" repo "$name" |
| 91 | } | |
| 92 | ||
| 93 | cmd_list() { | |
| 94 | local r name | |
| 95 | for r in "$GIT_ROOT"/*.git; do | |
| 96 | [ -d "$r" ] || continue | |
| 97 | name=$(basename "$r" .git) | |
| 98 | printf '%-32s %-8s %s\n' "$name" "$(visibility "$name")" \ | |
| 99 | "$(git -C "$r" log -1 --format='%cs %s' HEAD 2>/dev/null | cut -c1-60 || echo '(empty)')" | |
| 100 | done | |
| 101 | } | |
| 102 | ||
| 103 | cmd_new() { | |
| 104 | local name="" vis=private desc="" | |
| 105 | while [ $# -gt 0 ]; do | |
| 106 | case "$1" in | |
| 107 | --public) vis=public ;; | |
| 108 | --description) desc=${2:-}; shift ;; | |
| 109 | -*) die "unknown option $1" ;; | |
| 110 | *) [ -z "$name" ] && name=$1 || die "unexpected argument $1" ;; | |
| 111 | esac | |
| 112 | shift | |
| 113 | done | |
| 114 | [ -n "$name" ] || die "usage: fw new <name> [--public] [--description TEXT]" | |
| 115 | local r; r=$(repo_dir "$name") | |
| 116 | [ -e "$r" ] && die "$name already exists" | |
| 117 | git init --quiet --bare --initial-branch=main "$r" | |
| 118 | git -C "$r" config fuzzyworld.visibility "$vis" | |
| 119 | git -C "$r" config core.hooksPath "$HOOKS" | |
| 120 | [ -n "$desc" ] && git -C "$r" config fuzzyworld.description "$desc" | |
| 121 | echo "created $name ($vis)" | |
| 122 | echo " push to: dh:git/$name.git" | |
| 123 | [ "$vis" = public ] && echo " browse: https://code.fuzzyworld.net/$name/ (after the first push)" | |
| 124 | return 0 | |
| 125 | } | |
| 126 | ||
| 127 | cmd_set_visibility() { | |
| 128 | local vis=$1 name=${2:-} | |
| 129 | [ -n "$name" ] || die "usage: fw $vis <name>" | |
| 130 | need_repo "$name" | |
| 131 | git -C "$(repo_dir "$name")" config fuzzyworld.visibility "$vis" | |
| 132 | with_lock "$name" publish_one "$name" | |
| 133 | "${GEN[@]}" index | |
| 134 | } | |
| 135 | ||
| 136 | cmd_describe() { | |
| 137 | local name=${1:-}; shift || true | |
| 138 | [ -n "$name" ] && [ $# -gt 0 ] || die "usage: fw describe <name> TEXT" | |
| 139 | need_repo "$name" | |
| 140 | git -C "$(repo_dir "$name")" config fuzzyworld.description "$*" | |
| 141 | printf '%s\n' "$*" > "$(repo_dir "$name")/description" | |
| 142 | with_lock "$name" publish_one "$name" | |
| 143 | "${GEN[@]}" index | |
| 144 | } | |
| 145 | ||
| 146 | cmd_publish() { | |
| 147 | local name=${1:-} | |
| 148 | [ -n "$name" ] || die "usage: fw publish <name>" | |
| 149 | need_repo "$name" | |
| 150 | with_lock "$name" publish_one "$name" | |
| 151 | "${GEN[@]}" index | |
| 152 | } | |
| 153 | ||
| 154 | cmd_publish_all() { | |
0a08554d 2026-10-27 Pavel Safronov | 155 | local r name n=0 total started |
| 156 | total=$(ls -d "$GIT_ROOT"/*.git | wc -l | tr -d ' ') | |
623239b7 2026-10-26 Pavel Safronov | 157 | for r in "$GIT_ROOT"/*.git; do |
| 158 | name=$(basename "$r" .git) | |
0a08554d 2026-10-27 Pavel Safronov | 159 | n=$((n + 1)) |
| 160 | started=$SECONDS | |
623239b7 2026-10-26 Pavel Safronov | 161 | ( with_lock "$name" publish_one "$name" ) || echo "fw: publish failed for $name" >&2 |
0a08554d 2026-10-27 Pavel Safronov | 162 | echo "fw: [$n/$total] $name ($((SECONDS - started))s)" |
623239b7 2026-10-26 Pavel Safronov | 163 | done |
| 164 | "${GEN[@]}" index | |
0a08554d 2026-10-27 Pavel Safronov | 165 | "${GEN[@]}" ci-dashboard |
623239b7 2026-10-26 Pavel Safronov | 166 | } |
| 167 | ||
| 168 | cmd_install_hooks() { | |
| 169 | local r | |
| 170 | for r in "$GIT_ROOT"/*.git; do git -C "$r" config core.hooksPath "$HOOKS"; done | |
| 171 | echo "hooks installed on $(ls -d "$GIT_ROOT"/*.git | wc -l) repos" | |
| 172 | } | |
| 173 | ||
f9ca0752 2026-10-27 Pavel Safronov | 174 | cmd_app_deployed() { |
| 175 | local app=${1:-} name=${2:-} | |
| 176 | valid_name "$app" | |
| 177 | "${GEN[@]}" apps | |
| 178 | # The repo's summary page links to its app; refresh it if the source is public. | |
| 179 | if [ -n "$name" ] && [ -d "$(repo_dir "$name")" ] && [ "$(visibility "$name")" = public ]; then | |
| 180 | with_lock "$name" publish_one "$name" >/dev/null | |
| 181 | "${GEN[@]}" index >/dev/null | |
| 182 | fi | |
| 183 | } | |
| 184 | ||
1758548c 2026-10-27 Pavel Safronov | 185 | cmd_ci_queue() { |
| 186 | local name=${1:-} ref=${2:-} sha=${3:-} | |
| 187 | [ -n "$name" ] && [ -n "$ref" ] && [[ "$sha" =~ ^[0-9a-f]{40}$ ]] || die "usage: fw ci-queue <name> <ref> <sha>" | |
| 188 | need_repo "$name" | |
| 189 | mkdir -p "$QUEUE/pending" "$QUEUE/running" | |
| 190 | local id; id="$(date +%s%N | cut -c1-16)-$name-${sha:0:12}" | |
| 191 | local default; default=$(git -C "$(repo_dir "$name")" symbolic-ref --short HEAD 2>/dev/null || echo main) | |
| 192 | # Written beside the queue, then renamed in: a runner never sees a half-written job. | |
| 193 | python3 -c 'import json, sys, time | |
| 194 | id, name, ref, sha, default, out = sys.argv[1:] | |
| 195 | json.dump({"id": id, "repo": name, "ref": ref, "sha": sha, "default_branch": default, | |
| 196 | "queued": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}, open(out, "w"))' \ | |
| 197 | "$id" "$name" "$ref" "$sha" "$default" "$QUEUE/$id.json.tmp" | |
| 198 | mv "$QUEUE/$id.json.tmp" "$QUEUE/pending/$id.json" | |
| 199 | echo "fw: CI queued for $name ${ref#refs/*/} ${sha:0:12} ($id)" | |
a5a72f55 2026-10-27 Pavel Safronov | 200 | "${GEN[@]}" ci-dashboard || true |
1758548c 2026-10-27 Pavel Safronov | 201 | } |
| 202 | ||
| 203 | cmd_ci() { | |
| 204 | local name=${1:-} branch=${2:-} | |
| 205 | [ -n "$name" ] || die "usage: fw ci <name> [branch]" | |
| 206 | need_repo "$name" | |
| 207 | local r; r=$(repo_dir "$name") | |
| 208 | [ -n "$branch" ] || branch=$(git -C "$r" symbolic-ref --short HEAD) | |
| 209 | local sha; sha=$(git -C "$r" rev-parse -q --verify "refs/heads/$branch^{commit}") || die "no branch $branch in $name" | |
| 210 | cmd_ci_queue "$name" "refs/heads/$branch" "$sha" | |
| 211 | } | |
| 212 | ||
| 213 | cmd_ci_list() { | |
| 214 | local d f | |
| 215 | for d in pending running; do | |
| 216 | for f in "$QUEUE/$d"/*.json; do | |
| 217 | [ -e "$f" ] || continue | |
| 218 | printf '%-8s %s %s\n' "$d" "$(date -r "$f" '+%F %T')" "$(basename "$f" .json)" | |
| 219 | done | |
| 220 | done | |
| 221 | } | |
| 222 | ||
55e5fc3e 2026-11-04 Pavel Safronov | 223 | cmd_issues() { |
| 224 | case "${1:-}" in | |
85702b14 2026-11-04 Pavel Safronov | 225 | ingest|imap-check|list|show|record|approve|hide|close|reopen|publish|publish-all|help|-h|--help|"") |
55e5fc3e 2026-11-04 Pavel Safronov | 226 | python3 "$TOOLS/server/issues.py" "$@" ;; |
| 227 | *) | |
| 228 | # fw issues <repo> <mode> | |
| 229 | [ $# -eq 2 ] && [[ "$2" =~ ^(public|reviewed|private)$ ]] || die "usage: fw issues <repo> public|reviewed|private (or fw issues help)" | |
| 230 | need_repo "$1" | |
| 231 | git -C "$(repo_dir "$1")" config fuzzyworld.issues "$2" | |
| 232 | python3 "$TOOLS/server/issues.py" publish "$1" | |
| 233 | echo "$1: issues $2" ;; | |
| 234 | esac | |
| 235 | } | |
| 236 | ||
a5a72f55 2026-10-27 Pavel Safronov | 237 | cmd_ci_watch() { |
| 238 | local last="" now | |
| 239 | while true; do | |
| 240 | now=$("${GEN[@]}" ci-status) | |
| 241 | if [ "$now" != "$last" ]; then | |
| 242 | printf '\n%s\n%s\n' "$(date '+%T')" "$now" | |
| 243 | last=$now | |
| 244 | fi | |
| 245 | grep -qE '^(running|queued) ' <<< "$now" || { echo "(nothing queued or running)"; return; } | |
| 246 | sleep 10 | |
| 247 | done | |
| 248 | } | |
| 249 | ||
623239b7 2026-10-26 Pavel Safronov | 250 | mkdir -p "$STATE" "$LOGS" |
| 251 | case "${1:-}" in | |
f9ca0752 2026-10-27 Pavel Safronov | 252 | apps-index) "${GEN[@]}" apps ;; |
ec9751fa 2026-10-27 Pavel Safronov | 253 | content) "${GEN[@]}" content ;; |
f9ca0752 2026-10-27 Pavel Safronov | 254 | app-deployed) cmd_app_deployed "${2:-}" "${3:-}" ;; |
623239b7 2026-10-26 Pavel Safronov | 255 | list) cmd_list ;; |
| 256 | new) shift; cmd_new "$@" ;; | |
| 257 | public|private) cmd_set_visibility "$1" "${2:-}" ;; | |
| 258 | describe) shift; cmd_describe "$@" ;; | |
| 259 | publish) cmd_publish "${2:-}" ;; | |
| 260 | publish-all) cmd_publish_all ;; | |
| 261 | install-hooks) cmd_install_hooks ;; | |
1758548c 2026-10-27 Pavel Safronov | 262 | ci) cmd_ci "${2:-}" "${3:-}" ;; |
| 263 | ci-queue) cmd_ci_queue "${2:-}" "${3:-}" "${4:-}" ;; | |
| 264 | ci-list) cmd_ci_list ;; | |
a5a72f55 2026-10-27 Pavel Safronov | 265 | ci-watch) cmd_ci_watch ;; |
| 266 | ci-pages) need_repo "${2:-}"; with_lock "$2" "${GEN[@]}" ci "$2" ;; | |
| 267 | ci-dashboard) "${GEN[@]}" ci-dashboard ;; | |
e9fdc5db 2026-11-01 Pavel Safronov | 268 | comments) shift; python3 "$TOOLS/server/comments.py" "$@" ;; |
55e5fc3e 2026-11-04 Pavel Safronov | 269 | issues) shift; cmd_issues "$@" ;; |
258a9da8 2026-11-06 Pavel Safronov | 270 | deploys) shift; python3 "$TOOLS/server/deploys.py" "$@" ;; |
| 271 | ""|-h|--help|help) sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//' ;; | |
623239b7 2026-10-26 Pavel Safronov | 272 | *) die "unknown command: $1 (try: fw help)" ;; |
| 273 | esac |