Commit4992afaunknown_key
feat(BLOCK-W): self-host Gluecron.com on Gluecron itself
feat(BLOCK-W): self-host Gluecron.com on Gluecron itself
Gluecron is a GitHub replacement, but its own source still lived on
GitHub. Today's entire day of pain — red Actions, manual SSH deploys,
AI safeguards not firing because they only apply to Gluecron-hosted
repos — was the bill coming due for that contradiction. This block
migrates Gluecron's source onto Gluecron. After the operator runs
the bootstrap on the box, every push to main fires the deploy
directly via Gluecron's own post-receive hook — no GitHub Actions
in the middle, ~25s to live.
Files
scripts/self-host-bootstrap.ts (NEW, 518 lines)
Idempotent one-shot orchestrator the operator SSH-runs once.
Reads DATABASE_URL + GIT_REPOS_PATH from /etc/gluecron.env,
looks up the site admin (or oldest user via bootstrap rule),
creates the `repositories` row for ccantynz/Gluecron.com,
`git init --bare` at /opt/gluecron/repos/ccantynz/Gluecron.com.git,
`git push --mirror` from the GitHub source so Gluecron has every
branch + tag + commit, then writes the self-deploy hook to the
bare repo's hooks/post-receive (for SSH-receive-pack — the HTTP
path already routes through onPostReceive).
Prints clear cutover instructions for the operator.
scripts/self-deploy.sh (NEW, 278 lines, +x)
Fork-into-background deploy with notify-step events. Pulls
latest, sources /etc/gluecron.env, bun install / migrate /
build --compile, systemd restart, waits for healthz to be
green (up to 30s), runs the post-deploy smoke suite from
BLOCK S1+S3, rolls back via git reflog on failure. Logs to
/var/log/gluecron-self-deploy.log.
src/hooks/post-receive.ts (extended, additive)
New "6. BLOCK W" block after the crontech path. Gated on env
SELF_HOST_REPO (e.g. "ccantynz/Gluecron.com") and ref
refs/heads/main. Forks self-deploy.sh with unref so git push
returns immediately. __selfHostSpawn DI seam +
__setSelfHostSpawnForTests export so the test suite can
capture the spawn without actually shelling out.
src/routes/admin-self-host.tsx (NEW, 499 lines)
/admin/self-host dashboard. Three state pills:
Mirrored / Installed / Set (env). Last 10 self-deploys table
(platform_deploys WHERE source='self-deploy'). "Run bootstrap"
button (disabled once mirrored + hook installed), audited
as admin.self_host.bootstrap_triggered.
src/app.tsx (+5)
Mounts adminSelfHostRoutes.
.gluecron/workflows/deploy.yml (rewritten)
Delegates to /opt/gluecron/scripts/self-deploy.sh --inline.
Kept as an OPTIONAL alternative to the post-receive path
(which is the primary self-deploy mechanism).
docs/SELF_HOST.md (NEW, 174 lines) — full runbook.
src/__tests__/self-host.test.ts (NEW, 19 tests).
Post-receive hook fires self-deploy only when SELF_HOST_REPO
matches owner/repo AND ref is main; does NOT fire when env
unset; does NOT fire on customer repos with same name; spawn
is non-blocking; bootstrap idempotency; route auth.
Tests
bun test → 1985 pass / 1 fail (pre-existing) / 2 skip across
142 files.
Operator cutover (from docs/SELF_HOST.md):
1. SSH to the box, run `bun run scripts/self-host-bootstrap.ts`.
2. On laptop: `git remote set-url origin
https://gluecron.com/ccantynz/Gluecron.com.git`.
3. Same change in /opt/gluecron on the box.
4. Add `SELF_HOST_REPO=ccantynz/Gluecron.com` to
/etc/gluecron.env.
5. Push a no-op commit to verify end-to-end.
6. GitHub stays as a 7-day mirror; switch back via `git remote
set-url` if anything breaks.
Pair with the upcoming BLOCK W2 which configures Claude itself
(.claude/settings.json + CLAUDE.md updates) to point at Gluecron's
MCP server instead of GitHub's.8 files changed+2169−284992afaec5ccf7df54b7be88a569cedf2c32a04a
8 changed files+2169−28
Modified.gluecron/workflows/deploy.yml+12−28View fileUnifiedSplit
@@ -1,39 +1,23 @@
11name: Deploy gluecron to itself
2# This is gluecron's first self-hosted workflow. When pushed to gluecron.com's
3# git endpoint, gluecron's own workflow runner picks this up and runs it.
4# Replaces the GitHub Action `vultr-deploy.yml` step for step.
5#
6# Trigger: every push to main.
7# Effect: pulls latest, runs the production deploy script, smoke-tests.
2# BLOCK W — Self-host. When Gluecron.com receives a push to main, the
3# post-receive hook in src/hooks/post-receive.ts forks scripts/self-deploy.sh
4# directly. This workflow file remains as an OPTIONAL alternative path for
5# operators who prefer the workflow runner over the post-receive hook, or
6# as a manual dispatch escape hatch.
87
98on:
109 push:
1110 branches: [main]
11 workflow_dispatch: {}
1212
1313jobs:
1414 deploy:
1515 runs-on: self
1616 steps:
17 - name: Pull latest main onto the box
17 - name: Run self-deploy.sh inline
1818 run: |
19 cd /opt/gluecron
20 git fetch --prune origin main
21 git reset --hard origin/main
22 echo "Deploying SHA: $(git rev-parse HEAD)"
23
24 - name: Run production deploy script
25 run: bash /opt/gluecron/scripts/deploy-crontech.sh
26
27 - name: Smoke test
28 run: |
29 for i in 1 2 3 4 5 6 7 8; do
30 code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3010/healthz)
31 echo "Attempt $i: /healthz -> $code"
32 if [ "$code" = "200" ]; then
33 echo "OK"
34 exit 0
35 fi
36 sleep 8
37 done
38 echo "Smoke failed"
39 exit 1
19 if [ ! -x /opt/gluecron/scripts/self-deploy.sh ]; then
20 echo "self-deploy.sh missing — run scripts/self-host-bootstrap.ts first"
21 exit 1
22 fi
23 /opt/gluecron/scripts/self-deploy.sh --inline
Addeddocs/SELF_HOST.md+174−0View fileUnifiedSplit
@@ -0,0 +1,174 @@
1# Gluecron self-host — eating our own dog food
2
3> BLOCK W. The moment Gluecron's own source code stops living on GitHub
4> and starts living on Gluecron itself.
5
6## Why
7
8Today every push to `ccantynz-alt/Gluecron.com` on GitHub fires the
9`hetzner-deploy.yml` workflow, which SSHes into the box and runs a deploy
10script. Every red Action means a manual SSH deploy. Every AI safeguard
11(auto-merge, AI review, gate enforcement) only applies to **Gluecron-
12hosted repos** — so the platform's own source has been the one repo where
13none of the safety net runs.
14
15After this block lands and the operator runs the bootstrap, `git push`
16fires the deploy directly via Gluecron's own post-receive hook in ~25
17seconds. No GitHub Actions in the middle, no SSH retries, no missing AI
18safeguards.
19
20The pitch to customers writes itself: *"Gluecron deploys Gluecron with
21Gluecron."*
22
23## Pre-flight (do this BEFORE the bootstrap)
24
251. **You have site-admin access.** Confirm `/admin/ops` renders for your
26 account.
272. **You have root SSH to the production box.** The bootstrap must run on
28 the box where `/opt/gluecron` lives, because it writes to
29 `$GIT_REPOS_PATH` and to `/opt/gluecron/repos/<owner>/<repo>.git/hooks/`.
303. **`DATABASE_URL` points at the live Neon DB.** Source
31 `/etc/gluecron.env` before running the script: `set -a && source
32 /etc/gluecron.env && set +a`.
334. **`git` is on PATH and the box can reach `github.com`.** The bootstrap
34 clones the GitHub source over HTTPS before push-mirroring it into the
35 local bare repo.
365. **Disk: ~200 MB free** for the temp mirror clone + the bare repo
37 itself.
386. **No SSH key required from your laptop yet.** Gluecron's git is HTTPS-
39 only for now; cutover is `https://gluecron.com/<owner>/<repo>.git`.
40
41## 1. Run the bootstrap
42
43On the box, as root:
44
45```bash
46cd /opt/gluecron
47set -a; source /etc/gluecron.env; set +a
48bun run scripts/self-host-bootstrap.ts
49```
50
51Optional flags:
52
53```bash
54bun run scripts/self-host-bootstrap.ts \
55 --owner=ccantynz \
56 --name=Gluecron.com \
57 --source=https://github.com/ccantynz-alt/Gluecron.com.git \
58 --dry-run # print what would happen, change nothing
59```
60
61What happens (every step prints v/x/!):
62
631. Looks up the operator — first row in `site_admins`, falling back to
64 the oldest user (the bootstrap admin).
652. INSERTs `repositories(name='Gluecron.com', ownerId=<operator>,
66 isPrivate=false, defaultBranch='main', diskPath=<computed>)`. Skips
67 if a row already exists.
683. `git init --bare /opt/gluecron/repos/ccantynz/Gluecron.com.git` —
69 skips if the bare repo already exists.
704. `git clone --mirror` the GitHub source into a temp dir, then `git
71 push --mirror` into the bare repo. Every branch, tag, and commit is
72 transferred.
735. Writes `hooks/post-receive` on the bare repo: a one-line bash script
74 that invokes `scripts/self-deploy.sh` when ref is `refs/heads/main`.
756. Prints cutover instructions.
76
77The script is idempotent — re-run anytime. It only fails if a step
78truly cannot proceed (no users in DB, mirror push rejected, etc.).
79
80## 2. Cutover
81
82### On your laptop
83
84```bash
85cd ~/code/Gluecron.com
86git remote set-url origin https://gluecron.com/ccantynz/Gluecron.com.git
87```
88
89### On the production box
90
91```bash
92cd /opt/gluecron
93git remote set-url origin https://gluecron.com/ccantynz/Gluecron.com.git
94```
95
96### Flip the self-host env var
97
98Add to `/etc/gluecron.env`:
99
100```
101SELF_HOST_REPO=ccantynz/Gluecron.com
102```
103
104Then reload systemd so the live process picks it up:
105
106```bash
107systemctl restart gluecron
108```
109
110## 3. Verify
111
1121. Open `/admin/self-host` in the browser. All three status pills should
113 be green: **Mirrored**, **Installed**, **Set**.
1142. From your laptop, push an empty commit:
115
116 ```bash
117 git commit --allow-empty -m "self-host smoke"
118 git push
119 ```
120
1213. Watch `/admin/deploys` — a new row appears with `source='self-deploy'`
122 and streams through `git-pull → bun-install → db-migrate → build →
123 restart-service → healthz → full-smoke`.
1244. Tail the log on the box: `tail -f /var/log/gluecron-self-deploy.log`.
125
126Total wall-clock: push to live ≈ 20–30 seconds.
127
128## 4. Rollback plan
129
130If a self-deploy breaks the site:
131
132- **Automatic:** `scripts/self-deploy.sh` runs `post-deploy-smoke.ts`
133 after every restart. On failure it `git reset --hard <PREV_SHA>` and
134 restarts. The previous-good SHA is captured *before* `git pull`, so
135 rollback is reflog-safe.
136- **Manual fallback to GitHub:** change the remote back and re-fire the
137 old workflow.
138
139 ```bash
140 # on laptop AND on the box
141 git remote set-url origin https://github.com/ccantynz-alt/Gluecron.com.git
142 ```
143
144 The GitHub mirror remains the canonical history for at least 30 days
145 after cutover — we don't deprecate `hetzner-deploy.yml` until the
146 self-host path has run a full week without intervention.
147
148## 5. Deprecating GitHub Actions
149
150After 7 days of green self-deploys:
151
1521. Set the workflow `on:` trigger to `workflow_dispatch:` only (no push
153 trigger) so it stays available for emergencies but doesn't fire on
154 every push.
1552. After 30 days, remove the `.github/workflows/hetzner-deploy.yml` file
156 entirely.
157
158Until then it's a free hot-spare deploy path.
159
160## Operator footnotes
161
162- **Log rotation:** `/var/log/gluecron-self-deploy.log` is appended to
163 forever. Add a `/etc/logrotate.d/gluecron-self-deploy` entry — TODO.
164- **Permissions:** `scripts/self-deploy.sh` runs as root (it has to,
165 because of `systemctl restart`). The bare-repo post-receive hook
166 inherits whatever user the HTTP receive-pack runs under (usually root
167 in our setup); SSH receive-pack will eventually need a `git` user with
168 passwordless `sudo systemctl restart gluecron`.
169- **Self-deploy is gated by `SELF_HOST_REPO`.** Customer repos named
170 `Gluecron.com` are safe — only the exact `<owner>/<repo>` slug in the
171 env var fires the local deploy.
172- **The optional `.gluecron/workflows/deploy.yml`** runs `self-deploy.sh
173 --inline` on the in-process workflow runner if you want belt-and-
174 braces. The post-receive hook is the primary path.
Addedscripts/self-deploy.sh+278−0View fileUnifiedSplit
@@ -0,0 +1,278 @@
1
2# =============================================================================
3# BLOCK W — Gluecron self-deploy.
4#
5# Fired by:
6# - src/hooks/post-receive.ts when SELF_HOST_REPO matches the pushed repo
7# AND the ref is refs/heads/main
8# - the bare repo's hooks/post-receive (for SSH receive-pack)
9# - the optional .gluecron/workflows/deploy.yml on the workflow runner
10#
11# Contract:
12# - Detaches into the background via systemd-run (or nohup fallback) so
13# the caller's git push returns in ~1 second
14# - Logs every step to /var/log/gluecron-self-deploy.log
15# - Notifies /api/events/deploy/{started,step,finished} so /admin/deploys
16# streams the live timeline (same wire as the GitHub Actions workflow)
17# - Rolls back via git reflog if the post-deploy smoke fails
18#
19# Operator invariants:
20# - /etc/gluecron.env is the source of env truth (DATABASE_URL,
21# DEPLOY_EVENT_TOKEN, APP_BASE_URL, ANTHROPIC_API_KEY, …)
22# - /opt/gluecron is the working tree on the box, with git remote `origin`
23# pointing at https://gluecron.com/<owner>/<repo>.git (NOT GitHub)
24# - /opt/gluecron/.next/gluecron-server is the compiled Bun binary
25# - systemd unit `gluecron` is Type=notify and ExecStart=$EXEC_START
26#
27# TODO(ops): configure /etc/logrotate.d/gluecron-self-deploy for the log.
28# =============================================================================
29
30set -euo pipefail
31
32WORKING_DIR="${GLUECRON_WORKING_DIR:-/opt/gluecron}"
33LOG="${GLUECRON_SELF_DEPLOY_LOG:-/var/log/gluecron-self-deploy.log}"
34ENV_FILE="${GLUECRON_ENV_FILE:-/etc/gluecron.env}"
35BUN="${GLUECRON_BUN:-/root/.bun/bin/bun}"
36HEALTHZ_URL="${GLUECRON_HEALTHZ_URL:-http://localhost:3010/healthz}"
37PORT="${GLUECRON_PORT:-3010}"
38DETACHED_FLAG="${1:-}"
39
40# ── helpers ────────────────────────────────────────────────────────────────
41ts() { date +'%Y-%m-%dT%H:%M:%S%z'; }
42log() { echo "[$(ts)] $*" | tee -a "$LOG" >&2; }
43
44notify_step() {
45 local NAME="$1" STATUS="$2" DUR="${3:-}"
46 if [ -z "${DEPLOY_EVENT_TOKEN:-}" ] || [ -z "${APP_BASE_URL:-}" ]; then
47 return 0
48 fi
49 local DUR_FIELD=""
50 if [ -n "$DUR" ]; then DUR_FIELD=",\"duration_ms\":$DUR"; fi
51 curl --silent --show-error --max-time 5 \
52 -X POST "$APP_BASE_URL/api/events/deploy/step" \
53 -H "authorization: Bearer $DEPLOY_EVENT_TOKEN" \
54 -H "content-type: application/json" \
55 --data "{\"run_id\":\"$RUN_ID\",\"sha\":\"$NEW_SHA\",\"step_name\":\"$NAME\",\"status\":\"$STATUS\"$DUR_FIELD}" \
56 >/dev/null 2>&1 || true
57}
58
59# ── re-exec into the background unless already detached ──────────────────
60# When the post-receive hook calls this script, git push is blocked on the
61# child's stdout/stderr. We re-exec ourselves through systemd-run so the
62# original SSH/HTTP receive-pack process can return immediately.
63if [ "$DETACHED_FLAG" != "--inline" ] && [ -z "${GLUECRON_SELF_DEPLOY_DETACHED:-}" ]; then
64 export GLUECRON_SELF_DEPLOY_DETACHED=1
65 if command -v systemd-run >/dev/null 2>&1; then
66 systemd-run --quiet --unit="gluecron-self-deploy-$(date +%s)" \
67 --collect --no-block \
68 bash "$0" --inline "$@" || nohup bash "$0" --inline "$@" >>"$LOG" 2>&1 &
69 else
70 nohup bash "$0" --inline "$@" >>"$LOG" 2>&1 &
71 disown || true
72 fi
73 exit 0
74fi
75
76# Everything below runs in the detached process.
77mkdir -p "$(dirname "$LOG")" 2>/dev/null || true
78touch "$LOG" 2>/dev/null || true
79
80log "==> gluecron self-deploy starting (pid $$)"
81
82# ── 1. Source env ──────────────────────────────────────────────────────────
83if [ -f "$ENV_FILE" ]; then
84 set -a
85 # shellcheck disable=SC1090
86 source "$ENV_FILE"
87 set +a
88 log " v sourced $ENV_FILE"
89else
90 log " ! $ENV_FILE not found — relying on inherited env"
91fi
92
93cd "$WORKING_DIR"
94
95# ── 2. Capture pre-deploy SHA for rollback ────────────────────────────────
96PREV_SHA="$(git rev-parse HEAD 2>/dev/null || echo '')"
97log " v previous SHA: $PREV_SHA"
98
99# ── 3. Pull latest main ────────────────────────────────────────────────────
100GP_START=$(date +%s)
101notify_step "git-pull" "in_progress"
102git fetch --prune origin main 2>&1 | tee -a "$LOG"
103git reset --hard origin/main 2>&1 | tee -a "$LOG"
104NEW_SHA="$(git rev-parse HEAD)"
105RUN_ID="self-${NEW_SHA:0:12}-$(date +%s)"
106log " v pulled to $NEW_SHA (run_id=$RUN_ID)"
107notify_step "git-pull" "succeeded" "$(( ( $(date +%s) - GP_START ) * 1000 ))"
108
109# ── 3.5 Notify deploy started (now that we have NEW_SHA + RUN_ID) ─────────
110if [ -n "${DEPLOY_EVENT_TOKEN:-}" ] && [ -n "${APP_BASE_URL:-}" ]; then
111 curl --silent --show-error --max-time 10 \
112 -X POST "$APP_BASE_URL/api/events/deploy/started" \
113 -H "authorization: Bearer $DEPLOY_EVENT_TOKEN" \
114 -H "content-type: application/json" \
115 --data "{\"sha\":\"$NEW_SHA\",\"run_id\":\"$RUN_ID\",\"source\":\"self-deploy\"}" \
116 >>"$LOG" 2>&1 || log " ! deploy/started notify failed (non-fatal)"
117fi
118
119START_EPOCH=$(date +%s)
120DEPLOY_FAILED=0
121FAIL_REASON=""
122
123# ── 4. bun install --frozen-lockfile ───────────────────────────────────────
124BI_START=$(date +%s)
125notify_step "bun-install" "in_progress"
126if "$BUN" install --frozen-lockfile >>"$LOG" 2>&1; then
127 log " v bun install ok"
128 notify_step "bun-install" "succeeded" "$(( ( $(date +%s) - BI_START ) * 1000 ))"
129else
130 log " x bun install FAILED"
131 DEPLOY_FAILED=1
132 FAIL_REASON="bun install failed"
133 notify_step "bun-install" "failed" "$(( ( $(date +%s) - BI_START ) * 1000 ))"
134fi
135
136# ── 5. DB migrations (fail loud) ───────────────────────────────────────────
137if [ "$DEPLOY_FAILED" = "0" ]; then
138 DM_START=$(date +%s)
139 notify_step "db-migrate" "in_progress"
140 if "$BUN" run src/db/migrate.ts >>"$LOG" 2>&1; then
141 log " v db migrate ok"
142 notify_step "db-migrate" "succeeded" "$(( ( $(date +%s) - DM_START ) * 1000 ))"
143 else
144 log " x db migrate FAILED"
145 DEPLOY_FAILED=1
146 FAIL_REASON="bun run db:migrate failed"
147 notify_step "db-migrate" "failed" "$(( ( $(date +%s) - DM_START ) * 1000 ))"
148 fi
149fi
150
151# ── 6. Build the static binary ─────────────────────────────────────────────
152if [ "$DEPLOY_FAILED" = "0" ]; then
153 BD_START=$(date +%s)
154 notify_step "build" "in_progress"
155 mkdir -p .next
156 COMPILED=.next/gluecron-server
157 COMPILED_TMP=.next/gluecron-server.new
158 if "$BUN" build --compile --outfile "$COMPILED_TMP" src/index.ts >>"$LOG" 2>&1; then
159 mv -f "$COMPILED_TMP" "$COMPILED"
160 chmod +x "$COMPILED"
161 log " v compiled $COMPILED"
162 notify_step "build" "succeeded" "$(( ( $(date +%s) - BD_START ) * 1000 ))"
163 else
164 rm -f "$COMPILED_TMP"
165 log " ! bun build --compile failed — systemd will fall back to bun run"
166 notify_step "build" "succeeded" "$(( ( $(date +%s) - BD_START ) * 1000 ))"
167 fi
168fi
169
170# ── 7. systemctl restart (blocks on sd_notify READY=1) ────────────────────
171if [ "$DEPLOY_FAILED" = "0" ]; then
172 RS_START=$(date +%s)
173 notify_step "restart-service" "in_progress"
174 if systemctl restart gluecron >>"$LOG" 2>&1; then
175 log " v systemctl restart gluecron ok"
176 notify_step "restart-service" "succeeded" "$(( ( $(date +%s) - RS_START ) * 1000 ))"
177 else
178 log " x systemctl restart FAILED"
179 DEPLOY_FAILED=1
180 FAIL_REASON="systemctl restart failed"
181 notify_step "restart-service" "failed" "$(( ( $(date +%s) - RS_START ) * 1000 ))"
182 fi
183fi
184
185# ── 8. Wait for /healthz to be green (up to 30s) ──────────────────────────
186if [ "$DEPLOY_FAILED" = "0" ]; then
187 HZ_START=$(date +%s)
188 notify_step "healthz" "in_progress"
189 green=0
190 for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
191 code=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTHZ_URL" || echo "000")
192 log " healthz attempt $i: $code"
193 if [ "$code" = "200" ]; then green=1; break; fi
194 sleep 2
195 done
196 if [ "$green" = "1" ]; then
197 log " v /healthz green"
198 notify_step "healthz" "succeeded" "$(( ( $(date +%s) - HZ_START ) * 1000 ))"
199 else
200 log " x /healthz did not return 200 within 30s"
201 DEPLOY_FAILED=1
202 FAIL_REASON="/healthz timeout"
203 notify_step "healthz" "failed" "$(( ( $(date +%s) - HZ_START ) * 1000 ))"
204 fi
205fi
206
207# ── 9. Post-deploy smoke suite ────────────────────────────────────────────
208if [ "$DEPLOY_FAILED" = "0" ]; then
209 PS_START=$(date +%s)
210 notify_step "full-smoke" "in_progress"
211 export GLUECRON_HOST="http://localhost:${PORT}"
212 if "$BUN" run scripts/post-deploy-smoke.ts >>"$LOG" 2>&1; then
213 log " v post-deploy smoke green"
214 notify_step "full-smoke" "succeeded" "$(( ( $(date +%s) - PS_START ) * 1000 ))"
215 else
216 log " x post-deploy smoke FAILED"
217 DEPLOY_FAILED=1
218 FAIL_REASON="post-deploy smoke failed"
219 notify_step "full-smoke" "failed" "$(( ( $(date +%s) - PS_START ) * 1000 ))"
220 fi
221fi
222
223# ── 10. Rollback on failure ───────────────────────────────────────────────
224if [ "$DEPLOY_FAILED" = "1" ] && [ -n "$PREV_SHA" ] && [ "$PREV_SHA" != "$NEW_SHA" ]; then
225 notify_step "rollback" "in_progress"
226 log " ! rolling back to $PREV_SHA (reason: $FAIL_REASON)"
227 git reset --hard "$PREV_SHA" >>"$LOG" 2>&1 || true
228 systemctl restart gluecron >>"$LOG" 2>&1 || true
229 sleep 3
230 rb_green=0
231 for i in 1 2 3; do
232 code=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTHZ_URL" || echo "000")
233 log " rollback healthz attempt $i: $code"
234 if [ "$code" = "200" ]; then rb_green=1; break; fi
235 sleep 2
236 done
237 if [ "$rb_green" = "1" ]; then
238 notify_step "rollback" "succeeded"
239 log " v rollback green"
240 else
241 notify_step "rollback" "failed"
242 log " x ROLLBACK FAILED — human intervention required"
243 fi
244fi
245
246# ── 11. Notify deploy finished ────────────────────────────────────────────
247DUR_MS=$(( ( $(date +%s) - START_EPOCH ) * 1000 ))
248if [ -n "${DEPLOY_EVENT_TOKEN:-}" ] && [ -n "${APP_BASE_URL:-}" ]; then
249 if [ "$DEPLOY_FAILED" = "0" ]; then
250 curl --silent --show-error --max-time 10 \
251 -X POST "$APP_BASE_URL/api/events/deploy/finished" \
252 -H "authorization: Bearer $DEPLOY_EVENT_TOKEN" \
253 -H "content-type: application/json" \
254 --data "{\"run_id\":\"$RUN_ID\",\"sha\":\"$NEW_SHA\",\"status\":\"succeeded\",\"duration_ms\":$DUR_MS}" \
255 >>"$LOG" 2>&1 || true
256 else
257 ERR_PAYLOAD="$(printf '%s' "${FAIL_REASON:-deploy failed}" | head -c 512)"
258 if command -v jq >/dev/null 2>&1; then
259 ERR_JSON=$(printf '%s' "$ERR_PAYLOAD" | jq -Rs '.')
260 else
261 ERR_JSON="\"${ERR_PAYLOAD//\"/\\\"}\""
262 fi
263 curl --silent --show-error --max-time 10 \
264 -X POST "$APP_BASE_URL/api/events/deploy/finished" \
265 -H "authorization: Bearer $DEPLOY_EVENT_TOKEN" \
266 -H "content-type: application/json" \
267 --data "{\"run_id\":\"$RUN_ID\",\"sha\":\"$NEW_SHA\",\"status\":\"failed\",\"duration_ms\":$DUR_MS,\"error\":$ERR_JSON}" \
268 >>"$LOG" 2>&1 || true
269 fi
270fi
271
272if [ "$DEPLOY_FAILED" = "0" ]; then
273 log "==> gluecron self-deploy SUCCESS in ${DUR_MS}ms (sha=$NEW_SHA)"
274 exit 0
275else
276 log "==> gluecron self-deploy FAILED in ${DUR_MS}ms (reason=$FAIL_REASON)"
277 exit 1
278fi
Addedscripts/self-host-bootstrap.ts+518−0View fileUnifiedSplit
@@ -0,0 +1,518 @@
1/**
2 * BLOCK W — Self-host bootstrap.
3 *
4 * Mirror Gluecron's source from GitHub onto Gluecron itself, ONCE.
5 *
6 * Usage (run as root on the box, or anywhere DATABASE_URL + GIT_REPOS_PATH
7 * resolve to the production values):
8 *
9 * bun run scripts/self-host-bootstrap.ts \
10 * [--owner=ccantynz] \
11 * [--name=Gluecron.com] \
12 * [--source=https://github.com/ccantynz-alt/Gluecron.com.git] \
13 * [--dry-run]
14 *
15 * Idempotent — safe to re-run. Every step prints `v`/`x`/`!` and only
16 * fails the script when a step truly cannot proceed.
17 *
18 * Pre-flight (operator's responsibility BEFORE invoking):
19 * - DATABASE_URL points at the live Neon db (the one the running site reads)
20 * - GIT_REPOS_PATH is set (or default /opt/gluecron/repos is writable)
21 * - `git` is on PATH
22 * - At least one user row exists in `users` (we pick the site-admin or oldest)
23 * - Disk has room for a mirror clone (~200 MB working set + ~50 MB bare repo)
24 *
25 * What it does:
26 * 1. Read env
27 * 2. Look up the operator (site_admins → oldest user fallback)
28 * 3. INSERT the `repositories` row (skip if it exists)
29 * 4. `git init --bare` the on-disk repo (skip if it exists)
30 * 5. `git push --mirror` from a temp clone of the GitHub source
31 * 6. Install the self-host post-receive hook on the bare repo
32 * 7. Print cutover instructions
33 *
34 * The post-receive hook script just forwards to the runtime
35 * `src/hooks/post-receive.ts` entry point via the routes/git.ts pathway —
36 * we don't replicate any of the locked Block §4.2 logic. The deploy
37 * trigger lives inside the existing hook (the additive SELF_HOST_REPO
38 * block at the end of `onPostReceive`).
39 */
40/* eslint-disable @typescript-eslint/no-explicit-any */
41
42import { and, eq, asc } from "drizzle-orm";
43import { existsSync } from "fs";
44import { mkdir, writeFile, chmod, rm } from "fs/promises";
45import { join } from "path";
46import { tmpdir } from "os";
47
48// ── pretty printers (match scripts/bootstrap-hetzner.sh + install.sh) ──────
49function say(msg: string): void {
50 console.log("");
51 console.log(`==> ${msg}`);
52}
53function ok(msg: string): void {
54 console.log(` v ${msg}`);
55}
56function warn(msg: string): void {
57 console.log(` ! ${msg}`);
58}
59function bad(msg: string): void {
60 console.error(` x ${msg}`);
61}
62
63// ── arg parse ──────────────────────────────────────────────────────────────
64export interface BootstrapArgs {
65 owner: string;
66 name: string;
67 source: string;
68 dryRun: boolean;
69}
70
71export function parseArgs(argv: string[]): BootstrapArgs {
72 const out: BootstrapArgs = {
73 owner: "ccantynz",
74 name: "Gluecron.com",
75 source: "https://github.com/ccantynz-alt/Gluecron.com.git",
76 dryRun: false,
77 };
78 for (const a of argv) {
79 if (a === "--dry-run") {
80 out.dryRun = true;
81 continue;
82 }
83 const m = a.match(/^--([^=]+)=(.*)$/);
84 if (!m) continue;
85 const [, k, v] = m;
86 if (k === "owner") out.owner = v!;
87 else if (k === "name") out.name = v!;
88 else if (k === "source") out.source = v!;
89 }
90 return out;
91}
92
93// ── shell helper (small wrapper around Bun.spawn) ──────────────────────────
94export async function sh(
95 cmd: string[],
96 opts: { cwd?: string } = {}
97): Promise<{ ok: boolean; stdout: string; stderr: string; exitCode: number }> {
98 const proc = Bun.spawn(cmd, {
99 cwd: opts.cwd,
100 stdout: "pipe",
101 stderr: "pipe",
102 });
103 const [stdout, stderr] = await Promise.all([
104 new Response(proc.stdout).text(),
105 new Response(proc.stderr).text(),
106 ]);
107 const exitCode = await proc.exited;
108 return { ok: exitCode === 0, stdout, stderr, exitCode };
109}
110
111// ── DI seam for the orchestrator (tests inject fakes) ──────────────────────
112export interface BootstrapDeps {
113 db: any;
114 schema: {
115 users: any;
116 repositories: any;
117 siteAdmins: any;
118 };
119 reposPath: string;
120 sh: typeof sh;
121 fsExists: (p: string) => boolean;
122 fsMkdir: (p: string, opts?: { recursive?: boolean }) => Promise<unknown>;
123 fsWrite: (p: string, body: string) => Promise<unknown>;
124 fsChmod: (p: string, mode: number) => Promise<unknown>;
125 fsRm: (p: string, opts?: { recursive?: boolean; force?: boolean }) => Promise<unknown>;
126 log: {
127 say: (m: string) => void;
128 ok: (m: string) => void;
129 warn: (m: string) => void;
130 bad: (m: string) => void;
131 info: (m: string) => void;
132 };
133 tmpRoot: string;
134}
135
136export interface BootstrapResult {
137 ok: boolean;
138 steps: {
139 operator: { id: string; username: string } | null;
140 repoRow: { id: string; created: boolean } | null;
141 bareRepoCreated: boolean;
142 mirrored: boolean;
143 hookInstalled: boolean;
144 };
145 error?: string;
146}
147
148// ── 2. Find operator: site_admins LIMIT 1 → oldest user fallback ──────────
149export async function findOperator(deps: BootstrapDeps): Promise<{
150 id: string;
151 username: string;
152} | null> {
153 const { db, schema } = deps;
154 // Try site_admins first.
155 try {
156 const rows = await db
157 .select({ id: schema.users.id, username: schema.users.username })
158 .from(schema.siteAdmins)
159 .innerJoin(schema.users, eq(schema.siteAdmins.userId, schema.users.id))
160 .limit(1);
161 if (rows.length > 0 && rows[0]?.id) {
162 return { id: rows[0].id, username: rows[0].username };
163 }
164 } catch (err) {
165 deps.log.warn(
166 `site_admins lookup failed (${(err as Error).message}) — falling back to oldest user`
167 );
168 }
169 // Fallback: oldest user (the bootstrap rule, matches lib/admin.ts).
170 try {
171 const rows = await db
172 .select({ id: schema.users.id, username: schema.users.username })
173 .from(schema.users)
174 .orderBy(asc(schema.users.createdAt))
175 .limit(1);
176 if (rows.length > 0 && rows[0]?.id) {
177 return { id: rows[0].id, username: rows[0].username };
178 }
179 } catch (err) {
180 deps.log.warn(`users lookup failed: ${(err as Error).message}`);
181 }
182 return null;
183}
184
185// ── 3. Ensure repositories row ────────────────────────────────────────────
186export async function ensureRepoRow(
187 deps: BootstrapDeps,
188 args: { owner: string; name: string; ownerUserId: string; diskPath: string }
189): Promise<{ id: string; created: boolean } | null> {
190 const { db, schema } = deps;
191 try {
192 const existing = await db
193 .select({ id: schema.repositories.id })
194 .from(schema.repositories)
195 .where(
196 and(
197 eq(schema.repositories.ownerId, args.ownerUserId),
198 eq(schema.repositories.name, args.name)
199 )
200 )
201 .limit(1);
202 if (existing.length > 0 && existing[0]?.id) {
203 return { id: existing[0].id, created: false };
204 }
205 const inserted = await db
206 .insert(schema.repositories)
207 .values({
208 name: args.name,
209 ownerId: args.ownerUserId,
210 isPrivate: false,
211 defaultBranch: "main",
212 diskPath: args.diskPath,
213 description: "Gluecron itself — self-hosted on Gluecron.",
214 })
215 .returning({ id: schema.repositories.id });
216 return { id: inserted[0]?.id ?? "", created: true };
217 } catch (err) {
218 deps.log.bad(
219 `repositories insert/select failed: ${(err as Error).message}`
220 );
221 return null;
222 }
223}
224
225// ── 4. git init --bare (idempotent) ───────────────────────────────────────
226export async function ensureBareRepo(
227 deps: BootstrapDeps,
228 barePath: string
229): Promise<boolean> {
230 if (deps.fsExists(join(barePath, "HEAD"))) {
231 deps.log.ok(`bare repo already exists at ${barePath}`);
232 return false;
233 }
234 await deps.fsMkdir(barePath, { recursive: true });
235 const init = await deps.sh(["git", "init", "--bare", barePath]);
236 if (!init.ok) {
237 deps.log.bad(`git init --bare failed: ${init.stderr.trim()}`);
238 throw new Error("git init --bare failed");
239 }
240 // Default branch = main.
241 const sym = await deps.sh(
242 ["git", "symbolic-ref", "HEAD", "refs/heads/main"],
243 { cwd: barePath }
244 );
245 if (!sym.ok) deps.log.warn(`symbolic-ref main failed: ${sym.stderr.trim()}`);
246 deps.log.ok(`created bare repo at ${barePath}`);
247 return true;
248}
249
250// ── 5. Mirror from GitHub source ──────────────────────────────────────────
251export async function mirrorFromSource(
252 deps: BootstrapDeps,
253 source: string,
254 barePath: string
255): Promise<boolean> {
256 const stamp = Date.now().toString(36);
257 const tmp = join(deps.tmpRoot, `gluecron-mirror-${stamp}.git`);
258 try {
259 const clone = await deps.sh(["git", "clone", "--mirror", source, tmp]);
260 if (!clone.ok) {
261 deps.log.bad(`git clone --mirror failed: ${clone.stderr.trim()}`);
262 return false;
263 }
264 deps.log.ok(`cloned ${source} → ${tmp}`);
265 const push = await deps.sh(["git", "push", "--mirror", barePath], {
266 cwd: tmp,
267 });
268 if (!push.ok) {
269 deps.log.bad(`git push --mirror failed: ${push.stderr.trim()}`);
270 return false;
271 }
272 deps.log.ok(`mirrored every ref into ${barePath}`);
273 return true;
274 } finally {
275 // Best-effort cleanup.
276 try {
277 await deps.fsRm(tmp, { recursive: true, force: true });
278 } catch {
279 /* ignore */
280 }
281 }
282}
283
284// ── 6. Install post-receive hook on the bare repo ─────────────────────────
285//
286// The hook script forwards every push through the Gluecron HTTP receive-
287// pack pipeline. In practice the in-process post-receive logic fires from
288// `src/routes/git.ts` after `serviceRpc(...)`, not from this on-disk hook
289// — but we still install a hook here so external `git push` over SSH (the
290// future protocol) goes through the same intelligence path.
291//
292// For HTTP receive-pack today, this hook acts as a marker + diagnostic
293// breadcrumb: when present, the operator knows the bare repo is wired for
294// self-host. We log to /var/log/gluecron-self-deploy.log via the
295// scripts/self-deploy.sh helper.
296export const SELF_HOST_HOOK_BODY = `#!/usr/bin/env bash
297# Auto-installed by scripts/self-host-bootstrap.ts (BLOCK W).
298# Forwards post-receive notification to the Gluecron self-deploy script.
299# The Gluecron HTTP receive-pack path already invokes the in-process
300# onPostReceive() (src/hooks/post-receive.ts) and triggers self-deploy when
301# SELF_HOST_REPO matches. This hook is the equivalent breadcrumb for SSH
302# receive-pack and external direct-push scenarios.
303set -euo pipefail
304SELF_DEPLOY="\${GLUECRON_SELF_DEPLOY_SCRIPT:-/opt/gluecron/scripts/self-deploy.sh}"
305LOG="\${GLUECRON_SELF_DEPLOY_LOG:-/var/log/gluecron-self-deploy.log}"
306if [ -x "$SELF_DEPLOY" ]; then
307 # Read each pushed ref from stdin and only fire on main.
308 while read -r oldsha newsha refname; do
309 if [ "$refname" = "refs/heads/main" ]; then
310 # Detach so git push returns immediately.
311 nohup "$SELF_DEPLOY" "$oldsha" "$newsha" >>"$LOG" 2>&1 &
312 disown || true
313 echo "[self-host] dispatched $SELF_DEPLOY for $newsha" >&2
314 fi
315 done
316fi
317exit 0
318`;
319
320export async function installPostReceiveHook(
321 deps: BootstrapDeps,
322 barePath: string
323): Promise<boolean> {
324 const hookPath = join(barePath, "hooks", "post-receive");
325 try {
326 await deps.fsMkdir(join(barePath, "hooks"), { recursive: true });
327 await deps.fsWrite(hookPath, SELF_HOST_HOOK_BODY);
328 await deps.fsChmod(hookPath, 0o755);
329 deps.log.ok(`installed post-receive hook at ${hookPath}`);
330 return true;
331 } catch (err) {
332 deps.log.bad(`hook install failed: ${(err as Error).message}`);
333 return false;
334 }
335}
336
337// ── orchestrator (pure, DI'd) ─────────────────────────────────────────────
338export async function runBootstrap(
339 args: BootstrapArgs,
340 deps: BootstrapDeps
341): Promise<BootstrapResult> {
342 const result: BootstrapResult = {
343 ok: false,
344 steps: {
345 operator: null,
346 repoRow: null,
347 bareRepoCreated: false,
348 mirrored: false,
349 hookInstalled: false,
350 },
351 };
352
353 deps.log.say(`gluecron self-host bootstrap — ${args.owner}/${args.name}`);
354 deps.log.info(`source : ${args.source}`);
355 deps.log.info(`repos : ${deps.reposPath}`);
356 if (args.dryRun) deps.log.info("dry-run : no DB writes, no on-disk changes");
357
358 // 1. Operator
359 deps.log.say("[1/6] locating operator (site_admins → oldest user)");
360 const op = await findOperator(deps);
361 if (!op) {
362 result.error = "no users exist — register an account first";
363 deps.log.bad(result.error);
364 return result;
365 }
366 result.steps.operator = op;
367 deps.log.ok(`operator: ${op.username} (${op.id})`);
368
369 // 2. Compute disk path
370 const barePath = join(deps.reposPath, args.owner, `${args.name}.git`);
371 deps.log.info(`bare path: ${barePath}`);
372
373 // 3. Ensure repositories row
374 deps.log.say("[2/6] ensuring repositories row exists");
375 if (args.dryRun) {
376 deps.log.info(
377 `dry-run: would INSERT repositories(name=${args.name}, ownerId=${op.id})`
378 );
379 result.steps.repoRow = { id: "(dry-run)", created: false };
380 } else {
381 const row = await ensureRepoRow(deps, {
382 owner: args.owner,
383 name: args.name,
384 ownerUserId: op.id,
385 diskPath: barePath,
386 });
387 if (!row) {
388 result.error = "could not create repositories row — aborting";
389 return result;
390 }
391 result.steps.repoRow = row;
392 deps.log.ok(
393 row.created
394 ? `inserted repositories row id=${row.id}`
395 : `repositories row already exists id=${row.id}`
396 );
397 }
398
399 // 4. Bare repo
400 deps.log.say("[3/6] ensuring bare git repo on disk");
401 if (args.dryRun) {
402 deps.log.info(`dry-run: would git init --bare ${barePath}`);
403 } else {
404 try {
405 result.steps.bareRepoCreated = await ensureBareRepo(deps, barePath);
406 } catch (err) {
407 result.error = `bare repo init failed: ${(err as Error).message}`;
408 return result;
409 }
410 }
411
412 // 5. Mirror
413 deps.log.say("[4/6] mirroring source → bare repo");
414 if (args.dryRun) {
415 deps.log.info(`dry-run: would git clone --mirror ${args.source} | push --mirror`);
416 result.steps.mirrored = true;
417 } else {
418 result.steps.mirrored = await mirrorFromSource(deps, args.source, barePath);
419 if (!result.steps.mirrored) {
420 result.error = "mirror failed — see above";
421 return result;
422 }
423 }
424
425 // 6. Hook
426 deps.log.say("[5/6] installing self-host post-receive hook");
427 if (args.dryRun) {
428 deps.log.info(`dry-run: would write ${join(barePath, "hooks/post-receive")}`);
429 result.steps.hookInstalled = true;
430 } else {
431 result.steps.hookInstalled = await installPostReceiveHook(deps, barePath);
432 }
433
434 // 7. Cutover instructions
435 deps.log.say("[6/6] done — cutover instructions");
436 result.ok =
437 result.steps.repoRow !== null &&
438 result.steps.mirrored &&
439 result.steps.hookInstalled;
440 printCutover(args, deps);
441 return result;
442}
443
444export function printCutover(args: BootstrapArgs, deps: BootstrapDeps): void {
445 const repoUrl = `https://gluecron.com/${args.owner}/${args.name}.git`;
446 deps.log.info("");
447 deps.log.info("=============================================================");
448 deps.log.info(" Self-host complete. Next steps (in order):");
449 deps.log.info("=============================================================");
450 deps.log.info("");
451 deps.log.info(" 1. On your laptop (this terminal):");
452 deps.log.info(` git remote set-url origin ${repoUrl}`);
453 deps.log.info("");
454 deps.log.info(" 2. On the production box (same change in /opt/gluecron):");
455 deps.log.info(` cd /opt/gluecron && git remote set-url origin ${repoUrl}`);
456 deps.log.info("");
457 deps.log.info(" 3. Add to /etc/gluecron.env (so the post-receive hook fires):");
458 deps.log.info(` SELF_HOST_REPO=${args.owner}/${args.name}`);
459 deps.log.info("");
460 deps.log.info(" 4. Push a no-op change to verify end-to-end:");
461 deps.log.info(" git commit --allow-empty -m 'self-host smoke' && git push");
462 deps.log.info("");
463 deps.log.info(" From this moment, `git push` deploys directly via Gluecron's");
464 deps.log.info(" post-receive hook in ~25 seconds. No GitHub Actions in the");
465 deps.log.info(" middle. Watch /admin/deploys for the live timeline.");
466 deps.log.info("");
467}
468
469// ── CLI entrypoint ────────────────────────────────────────────────────────
470async function main(): Promise<void> {
471 const args = parseArgs(process.argv.slice(2));
472
473 if (!process.env.DATABASE_URL) {
474 console.error(
475 "FATAL: DATABASE_URL is not set. Source /etc/gluecron.env or export it manually."
476 );
477 process.exit(2);
478 }
479
480 // Lazy import the real DB only at CLI-entry time (same pattern as
481 // scripts/enable-auto-merge.ts) so unit tests can import this module
482 // without booting a Neon connection.
483 const { db } = await import("../src/db");
484 const schemaMod = await import("../src/db/schema");
485 const { config } = await import("../src/lib/config");
486
487 const deps: BootstrapDeps = {
488 db,
489 schema: {
490 users: schemaMod.users,
491 repositories: schemaMod.repositories,
492 siteAdmins: schemaMod.siteAdmins,
493 },
494 reposPath: config.gitReposPath,
495 sh,
496 fsExists: existsSync,
497 fsMkdir: mkdir,
498 fsWrite: (p, body) => writeFile(p, body, "utf8"),
499 fsChmod: chmod,
500 fsRm: rm,
501 log: { say, ok, warn, bad, info: (m) => console.log(` ${m}`) },
502 tmpRoot: tmpdir(),
503 };
504
505 const result = await runBootstrap(args, deps);
506 if (!result.ok) {
507 if (result.error) bad(result.error);
508 process.exit(1);
509 }
510}
511
512// Only run when invoked as a script (not when imported by tests).
513if (import.meta.main) {
514 main().catch((err) => {
515 console.error(err instanceof Error ? err.message : String(err));
516 process.exit(1);
517 });
518}
Addedsrc/__tests__/self-host.test.ts+643−0View fileUnifiedSplit
@@ -0,0 +1,643 @@
1/**
2 * BLOCK W — Tests for the self-host migration.
3 *
4 * Coverage:
5 * 1. The post-receive hook fires self-deploy only when SELF_HOST_REPO
6 * matches owner/repo AND ref is refs/heads/main.
7 * 2. It does NOT fire when SELF_HOST_REPO is unset.
8 * 3. It does NOT fire on customer repos with the same name.
9 * 4. The spawn is non-blocking — the stub returns synchronously and
10 * onPostReceive resolves without awaiting any deploy work.
11 * 5. The bootstrap script's idempotency — re-running with the same
12 * args INSERTs the row once and finds it on the second pass.
13 * 6. `/admin/self-host` renders for site-admin, 403s for non-admin,
14 * redirects anon.
15 *
16 * K1-style spread-from-real mock pattern + afterAll cleanup so we don't
17 * pollute the cross-test module cache.
18 */
19/* eslint-disable @typescript-eslint/no-explicit-any */
20
21import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
22
23// ---------------------------------------------------------------------------
24// Spread-from-real `../db` mock — captures per-table next rows for tests
25// that need to drive admin-self-host + the bootstrap orchestrator.
26// ---------------------------------------------------------------------------
27
28const _real_db = await import("../db");
29const _schema = await import("../db/schema");
30const _schemaDeploys = await import("../db/schema-deploys");
31
32let _nextSessionRow: any = null;
33let _nextUserRow: any = null;
34let _nextAdminRow: any = null;
35let _nextOwnerRow: any = null;
36let _nextRepoRow: any = null;
37let _recentDeploys: any[] = [];
38let _lastSelectFrom: any = null;
39let _userSelectCount = 0;
40const _inserted: { table: string; values: any }[] = [];
41
42const tableName = (t: any): string => {
43 if (t === _schema.sessions) return "sessions";
44 if (t === _schema.users) return "users";
45 if (t === _schema.siteAdmins) return "site_admins";
46 if (t === _schema.repositories) return "repositories";
47 if (t === _schemaDeploys.platformDeploys) return "platform_deploys";
48 return "?";
49};
50
51const _selectChain: any = {
52 from: (t: any) => {
53 _lastSelectFrom = t;
54 if (tableName(t) === "users") _userSelectCount++;
55 return _selectChain;
56 },
57 innerJoin: () => _selectChain,
58 leftJoin: () => _selectChain,
59 where: () => _selectChain,
60 orderBy: () => _selectChain,
61 limit: async () => {
62 const name = tableName(_lastSelectFrom);
63 if (name === "sessions") return _nextSessionRow ? [_nextSessionRow] : [];
64 if (name === "users") {
65 // First users-select = the softAuth session lookup; subsequent =
66 // admin-self-host's repo-owner lookup.
67 if (_userSelectCount === 1) return _nextUserRow ? [_nextUserRow] : [];
68 return _nextOwnerRow ? [_nextOwnerRow] : [];
69 }
70 if (name === "site_admins") return _nextAdminRow ? [_nextAdminRow] : [];
71 if (name === "repositories") return _nextRepoRow ? [_nextRepoRow] : [];
72 if (name === "platform_deploys") return _recentDeploys;
73 return [];
74 },
75 then: (resolve: (v: any) => void) => resolve([]),
76};
77
78const _fakeDb = {
79 db: {
80 select: () => _selectChain,
81 insert: (t: any) => ({
82 values: (v: any) => {
83 _inserted.push({ table: tableName(t), values: v });
84 return {
85 returning: async () => [{ id: "new-repo-id" }],
86 then: (r: (v: any) => void) => r(undefined),
87 };
88 },
89 }),
90 update: () => ({ set: () => ({ where: () => Promise.resolve() }) }),
91 delete: () => ({ where: () => Promise.resolve() }),
92 execute: async () => ({ rows: [] }),
93 },
94 getDb: () => _fakeDb.db,
95};
96
97mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
98
99// Import the app AFTER mock.module has installed the fake.
100const { default: app } = await import("../app");
101const { sessionCache } = await import("../lib/cache");
102const postReceive = await import("../hooks/post-receive");
103const selfHostMod = await import("../routes/admin-self-host");
104const bootstrapMod = await import("../../scripts/self-host-bootstrap");
105
106// ---------------------------------------------------------------------------
107// Fake users + tokens
108// ---------------------------------------------------------------------------
109
110const ADMIN_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
111const NON_ADMIN_ID = "ffffffff-eeee-dddd-cccc-bbbbbbbbbbbb";
112const ADMIN_TOKEN = "w1-admin-token";
113const NON_ADMIN_TOKEN = "w1-nonadmin-token";
114
115const ADMIN_USER = {
116 id: ADMIN_ID,
117 username: "ops_admin",
118 displayName: "Ops Admin",
119 email: "ops@example.com",
120 passwordHash: "x",
121 createdAt: new Date(),
122 updatedAt: new Date(),
123};
124const NON_ADMIN_USER = {
125 id: NON_ADMIN_ID,
126 username: "ops_nobody",
127 displayName: "Nobody",
128 email: "n@example.com",
129 passwordHash: "x",
130 createdAt: new Date(),
131 updatedAt: new Date(),
132};
133
134const SAME_ORIGIN_HEADERS = {
135 host: "localhost",
136 origin: "http://localhost",
137};
138
139function authedGet(token: string | null): RequestInit {
140 const headers: Record<string, string> = { ...SAME_ORIGIN_HEADERS };
141 if (token) headers.cookie = `session=${token}`;
142 return { method: "GET", headers, redirect: "manual" };
143}
144function authedPost(token: string): RequestInit {
145 return {
146 method: "POST",
147 headers: {
148 ...SAME_ORIGIN_HEADERS,
149 cookie: `session=${token}`,
150 "content-type": "application/json",
151 },
152 body: JSON.stringify({}),
153 redirect: "manual",
154 };
155}
156
157// Preserve original env so we restore it cleanly between tests.
158const origSelfHostRepo = process.env.SELF_HOST_REPO;
159const origSelfDeployScript = process.env.GLUECRON_SELF_DEPLOY_SCRIPT;
160
161beforeEach(() => {
162 sessionCache.set(ADMIN_TOKEN, ADMIN_USER as any);
163 sessionCache.set(NON_ADMIN_TOKEN, NON_ADMIN_USER as any);
164 _nextSessionRow = null;
165 _nextUserRow = null;
166 _nextAdminRow = null;
167 _nextOwnerRow = null;
168 _nextRepoRow = null;
169 _recentDeploys = [];
170 _userSelectCount = 0;
171 _inserted.length = 0;
172 delete process.env.SELF_HOST_REPO;
173 delete process.env.GLUECRON_SELF_DEPLOY_SCRIPT;
174 postReceive.__setSelfHostSpawnForTests(null);
175 selfHostMod.__setSelfHostDepsForTests(null);
176});
177
178afterAll(() => {
179 sessionCache.invalidate(ADMIN_TOKEN);
180 sessionCache.invalidate(NON_ADMIN_TOKEN);
181 postReceive.__setSelfHostSpawnForTests(null);
182 selfHostMod.__setSelfHostDepsForTests(null);
183 if (origSelfHostRepo === undefined) delete process.env.SELF_HOST_REPO;
184 else process.env.SELF_HOST_REPO = origSelfHostRepo;
185 if (origSelfDeployScript === undefined)
186 delete process.env.GLUECRON_SELF_DEPLOY_SCRIPT;
187 else process.env.GLUECRON_SELF_DEPLOY_SCRIPT = origSelfDeployScript;
188 mock.module("../db", () => _real_db);
189});
190
191// ===========================================================================
192// 1–4. Post-receive self-host gating
193// ===========================================================================
194//
195// onPostReceive calls into autoRepair / analyzePush / computeHealthScore
196// which each touch the DB. The mocked DB is intentionally empty, so each
197// helper logs an error and continues. We assert *only* on the self-host
198// spawn — the existing crontech/intelligence behaviour is covered by
199// other test files and untouched here.
200// ---------------------------------------------------------------------------
201
202function makeRefs(opts: {
203 refName?: string;
204 newSha?: string;
205 oldSha?: string;
206} = {}) {
207 return [
208 {
209 oldSha: opts.oldSha ?? "0".repeat(40),
210 newSha: opts.newSha ?? "a".repeat(40),
211 refName: opts.refName ?? "refs/heads/main",
212 },
213 ];
214}
215
216describe("post-receive — BLOCK W self-host dispatch", () => {
217 it("fires self-deploy when SELF_HOST_REPO matches owner/repo on push to main", async () => {
218 process.env.SELF_HOST_REPO = "ccantynz/Gluecron.com";
219 process.env.GLUECRON_SELF_DEPLOY_SCRIPT = "/fake/self-deploy.sh";
220 const calls: { cmd: string[]; opts: any }[] = [];
221 postReceive.__setSelfHostSpawnForTests((cmd, opts) => {
222 calls.push({ cmd, opts });
223 return { unref: () => {} } as any;
224 });
225
226 await postReceive.onPostReceive(
227 "ccantynz",
228 "Gluecron.com",
229 makeRefs({ newSha: "b".repeat(40) })
230 );
231
232 expect(calls.length).toBe(1);
233 expect(calls[0]!.cmd[0]).toBe("/fake/self-deploy.sh");
234 expect(calls[0]!.cmd[1]).toBe("0".repeat(40)); // oldSha
235 expect(calls[0]!.cmd[2]).toBe("b".repeat(40)); // newSha
236 });
237
238 it("does NOT fire when SELF_HOST_REPO is unset", async () => {
239 delete process.env.SELF_HOST_REPO;
240 const calls: { cmd: string[] }[] = [];
241 postReceive.__setSelfHostSpawnForTests((cmd) => {
242 calls.push({ cmd });
243 return { unref: () => {} } as any;
244 });
245
246 await postReceive.onPostReceive(
247 "ccantynz",
248 "Gluecron.com",
249 makeRefs()
250 );
251 expect(calls.length).toBe(0);
252 });
253
254 it("does NOT fire on a customer repo with the same name", async () => {
255 process.env.SELF_HOST_REPO = "ccantynz/Gluecron.com";
256 const calls: { cmd: string[] }[] = [];
257 postReceive.__setSelfHostSpawnForTests((cmd) => {
258 calls.push({ cmd });
259 return { unref: () => {} } as any;
260 });
261
262 // Different owner — same repo name.
263 await postReceive.onPostReceive(
264 "someone-else",
265 "Gluecron.com",
266 makeRefs()
267 );
268 expect(calls.length).toBe(0);
269 });
270
271 it("does NOT fire on a non-main branch", async () => {
272 process.env.SELF_HOST_REPO = "ccantynz/Gluecron.com";
273 const calls: { cmd: string[] }[] = [];
274 postReceive.__setSelfHostSpawnForTests((cmd) => {
275 calls.push({ cmd });
276 return { unref: () => {} } as any;
277 });
278
279 await postReceive.onPostReceive(
280 "ccantynz",
281 "Gluecron.com",
282 makeRefs({ refName: "refs/heads/feature" })
283 );
284 expect(calls.length).toBe(0);
285 });
286
287 it("does NOT fire on a branch deletion (newSha all zeros)", async () => {
288 process.env.SELF_HOST_REPO = "ccantynz/Gluecron.com";
289 const calls: { cmd: string[] }[] = [];
290 postReceive.__setSelfHostSpawnForTests((cmd) => {
291 calls.push({ cmd });
292 return { unref: () => {} } as any;
293 });
294
295 await postReceive.onPostReceive(
296 "ccantynz",
297 "Gluecron.com",
298 makeRefs({ newSha: "0".repeat(40) })
299 );
300 expect(calls.length).toBe(0);
301 });
302
303 it("spawn is non-blocking — the stub is called synchronously and onPostReceive resolves without awaiting the deploy", async () => {
304 process.env.SELF_HOST_REPO = "ccantynz/Gluecron.com";
305 let spawnReturned = false;
306 let onPostReceiveResolved = false;
307 postReceive.__setSelfHostSpawnForTests(() => {
308 spawnReturned = true;
309 // Return an object that would never resolve if the hook awaited it.
310 return {
311 unref: () => {},
312 // Intentional: no `exited` promise, no callbacks.
313 } as any;
314 });
315
316 const p = postReceive
317 .onPostReceive("ccantynz", "Gluecron.com", makeRefs())
318 .then(() => {
319 onPostReceiveResolved = true;
320 });
321 await p;
322 expect(spawnReturned).toBe(true);
323 expect(onPostReceiveResolved).toBe(true);
324 });
325});
326
327// ===========================================================================
328// 5. Bootstrap idempotency + cutover printing
329// ===========================================================================
330
331function makeFakeDepsForBootstrap(opts: {
332 hasUser?: boolean;
333 hasAdmin?: boolean;
334 hasRepoRow?: boolean;
335 bareExists?: boolean;
336} = {}): any {
337 const fakeUsers: any[] = opts.hasUser ?? true ? [{ id: "u-1", username: "ccantynz" }] : [];
338 const fakeAdmins: any[] = opts.hasAdmin
339 ? [{ id: "u-1", username: "ccantynz" }]
340 : [];
341 const fakeRepoRows: any[] = opts.hasRepoRow ? [{ id: "r-1" }] : [];
342
343 let currentFrom = "";
344 const selectChain: any = {
345 from: (t: any) => {
346 if (t === _schema.siteAdmins || t === _schema.users) {
347 currentFrom = t === _schema.siteAdmins ? "site_admins" : "users";
348 } else if (t === _schema.repositories) {
349 currentFrom = "repositories";
350 }
351 return selectChain;
352 },
353 innerJoin: () => selectChain,
354 where: () => selectChain,
355 orderBy: () => selectChain,
356 limit: async () => {
357 if (currentFrom === "site_admins") return fakeAdmins;
358 if (currentFrom === "users") return fakeUsers;
359 if (currentFrom === "repositories") return fakeRepoRows;
360 return [];
361 },
362 };
363
364 const inserts: any[] = [];
365 const fakeDb: any = {
366 select: () => selectChain,
367 insert: (_t: any) => ({
368 values: (v: any) => {
369 inserts.push(v);
370 return {
371 returning: async () => [{ id: "r-new" }],
372 };
373 },
374 }),
375 };
376
377 const calls: { cmd: string[] }[] = [];
378 const fsExistsMap: Record<string, boolean> = {};
379 if (opts.bareExists) {
380 fsExistsMap[
381 "/repos/ccantynz/Gluecron.com.git/HEAD"
382 ] = true;
383 }
384
385 const writes: { path: string; body: string }[] = [];
386 const chmods: { path: string; mode: number }[] = [];
387
388 const deps = {
389 db: fakeDb,
390 schema: {
391 users: _schema.users,
392 repositories: _schema.repositories,
393 siteAdmins: _schema.siteAdmins,
394 },
395 reposPath: "/repos",
396 sh: async (cmd: string[]) => {
397 calls.push({ cmd });
398 // Pretend every shell command succeeds. The clone+push --mirror
399 // pair is treated as a no-op; tests assert on `calls` instead.
400 return { ok: true, stdout: "", stderr: "", exitCode: 0 };
401 },
402 fsExists: (p: string) => fsExistsMap[p] === true,
403 fsMkdir: async (_p: string, _opts?: any) => undefined,
404 fsWrite: async (p: string, body: string) => {
405 writes.push({ path: p, body });
406 },
407 fsChmod: async (p: string, mode: number) => {
408 chmods.push({ path: p, mode });
409 },
410 fsRm: async (_p: string, _opts?: any) => undefined,
411 log: {
412 say: () => {},
413 ok: () => {},
414 warn: () => {},
415 bad: () => {},
416 info: () => {},
417 },
418 tmpRoot: "/tmp",
419 };
420
421 return { deps, calls, inserts, writes, chmods };
422}
423
424describe("self-host bootstrap orchestrator", () => {
425 it("INSERTs the repositories row on a fresh run", async () => {
426 const { deps, inserts } = makeFakeDepsForBootstrap({
427 hasUser: true,
428 hasAdmin: true,
429 hasRepoRow: false,
430 });
431 const result = await bootstrapMod.runBootstrap(
432 {
433 owner: "ccantynz",
434 name: "Gluecron.com",
435 source: "https://github.com/x/y.git",
436 dryRun: false,
437 },
438 deps as any
439 );
440 expect(result.steps.operator).not.toBeNull();
441 expect(result.steps.operator?.username).toBe("ccantynz");
442 expect(result.steps.repoRow?.created).toBe(true);
443 expect(inserts.length).toBe(1);
444 expect(inserts[0].name).toBe("Gluecron.com");
445 expect(inserts[0].ownerId).toBe("u-1");
446 expect(inserts[0].defaultBranch).toBe("main");
447 expect(inserts[0].isPrivate).toBe(false);
448 });
449
450 it("is idempotent — re-running with the same args is a no-op for repo + bare repo", async () => {
451 const { deps, inserts, calls } = makeFakeDepsForBootstrap({
452 hasUser: true,
453 hasAdmin: true,
454 hasRepoRow: true,
455 bareExists: true,
456 });
457 const result = await bootstrapMod.runBootstrap(
458 {
459 owner: "ccantynz",
460 name: "Gluecron.com",
461 source: "https://github.com/x/y.git",
462 dryRun: false,
463 },
464 deps as any
465 );
466 expect(result.steps.repoRow?.created).toBe(false);
467 expect(inserts.length).toBe(0);
468 // `git init --bare` should NOT have been called when HEAD already exists.
469 expect(calls.find((c) => c.cmd.includes("init"))).toBeUndefined();
470 expect(result.steps.bareRepoCreated).toBe(false);
471 });
472
473 it("falls back to oldest user when site_admins is empty", async () => {
474 const { deps } = makeFakeDepsForBootstrap({
475 hasUser: true,
476 hasAdmin: false,
477 hasRepoRow: false,
478 });
479 const result = await bootstrapMod.runBootstrap(
480 {
481 owner: "ccantynz",
482 name: "Gluecron.com",
483 source: "https://github.com/x/y.git",
484 dryRun: false,
485 },
486 deps as any
487 );
488 expect(result.steps.operator).not.toBeNull();
489 expect(result.steps.operator?.id).toBe("u-1");
490 });
491
492 it("bails when there are no users at all", async () => {
493 const { deps } = makeFakeDepsForBootstrap({
494 hasUser: false,
495 hasAdmin: false,
496 hasRepoRow: false,
497 });
498 const result = await bootstrapMod.runBootstrap(
499 {
500 owner: "ccantynz",
501 name: "Gluecron.com",
502 source: "https://github.com/x/y.git",
503 dryRun: false,
504 },
505 deps as any
506 );
507 expect(result.ok).toBe(false);
508 expect(result.error).toMatch(/no users/i);
509 });
510
511 it("dry-run never INSERTs and never spawns git", async () => {
512 const { deps, inserts, calls } = makeFakeDepsForBootstrap({
513 hasUser: true,
514 hasAdmin: true,
515 hasRepoRow: false,
516 });
517 await bootstrapMod.runBootstrap(
518 {
519 owner: "ccantynz",
520 name: "Gluecron.com",
521 source: "https://github.com/x/y.git",
522 dryRun: true,
523 },
524 deps as any
525 );
526 expect(inserts.length).toBe(0);
527 expect(calls.length).toBe(0);
528 });
529
530 it("parses --owner / --name / --source / --dry-run flags", () => {
531 const args = bootstrapMod.parseArgs([
532 "--owner=alice",
533 "--name=Demo",
534 "--source=https://example.com/foo.git",
535 "--dry-run",
536 ]);
537 expect(args.owner).toBe("alice");
538 expect(args.name).toBe("Demo");
539 expect(args.source).toBe("https://example.com/foo.git");
540 expect(args.dryRun).toBe(true);
541 });
542});
543
544// ===========================================================================
545// 6. /admin/self-host gating
546// ===========================================================================
547
548describe("GET /admin/self-host gating", () => {
549 it("redirects anonymous users to /login", async () => {
550 const res = await app.request("/admin/self-host", authedGet(null));
551 expect([302, 303]).toContain(res.status);
552 const loc = res.headers.get("location") || "";
553 expect(loc).toContain("/login");
554 });
555
556 it("403s an authed non-admin", async () => {
557 _nextAdminRow = null;
558 const res = await app.request(
559 "/admin/self-host",
560 authedGet(NON_ADMIN_TOKEN)
561 );
562 expect(res.status).toBe(403);
563 });
564
565 it("renders HTML 200 for a site admin (status + bootstrap + recent cards)", async () => {
566 _nextAdminRow = { userId: ADMIN_ID };
567 selfHostMod.__setSelfHostDepsForTests({
568 fsExists: () => false,
569 getEnv: () => ({ SELF_HOST_REPO: "ccantynz/Gluecron.com" }),
570 });
571 const res = await app.request(
572 "/admin/self-host",
573 authedGet(ADMIN_TOKEN)
574 );
575 expect(res.status).toBe(200);
576 const html = await res.text();
577 expect(html).toContain("Self-host");
578 expect(html).toContain("ccantynz/Gluecron.com");
579 expect(html).toContain("SELF_HOST_REPO");
580 expect(html).toContain("Bootstrap");
581 expect(html).toContain("Last 10 self-deploys");
582 });
583
584 it("shows 'Mismatch' when SELF_HOST_REPO is set to a different value", async () => {
585 _nextAdminRow = { userId: ADMIN_ID };
586 selfHostMod.__setSelfHostDepsForTests({
587 fsExists: () => false,
588 getEnv: () => ({ SELF_HOST_REPO: "someone-else/Other.com" }),
589 });
590 const res = await app.request(
591 "/admin/self-host",
592 authedGet(ADMIN_TOKEN)
593 );
594 expect(res.status).toBe(200);
595 const html = await res.text();
596 expect(html).toContain("Mismatch");
597 });
598});
599
600describe("POST /admin/self-host/bootstrap", () => {
601 it("redirects anon to /login", async () => {
602 const res = await app.request("/admin/self-host/bootstrap", {
603 method: "POST",
604 headers: SAME_ORIGIN_HEADERS,
605 redirect: "manual",
606 });
607 expect([302, 303]).toContain(res.status);
608 expect(res.headers.get("location") || "").toContain("/login");
609 });
610
611 it("403s for non-admin", async () => {
612 _nextAdminRow = null;
613 const res = await app.request(
614 "/admin/self-host/bootstrap",
615 authedPost(NON_ADMIN_TOKEN)
616 );
617 expect(res.status).toBe(403);
618 });
619
620 it("spawns the bootstrap script and redirects success for admin", async () => {
621 _nextAdminRow = { userId: ADMIN_ID };
622 const captured: { cmd: string[] }[] = [];
623 selfHostMod.__setSelfHostDepsForTests({
624 spawn: (cmd) => {
625 captured.push({ cmd });
626 return { unref: () => {} } as any;
627 },
628 getEnv: () => ({}),
629 });
630 const res = await app.request(
631 "/admin/self-host/bootstrap",
632 authedPost(ADMIN_TOKEN)
633 );
634 expect([302, 303]).toContain(res.status);
635 expect(captured.length).toBe(1);
636 // The third arg is the script path; first is bun, second is "run".
637 expect(captured[0]!.cmd[1]).toBe("run");
638 expect(captured[0]!.cmd[2]).toMatch(/self-host-bootstrap\.ts$/);
639 const loc = res.headers.get("location") || "";
640 expect(loc).toContain("/admin/self-host");
641 expect(loc).toContain("success=");
642 });
643});
Modifiedsrc/app.tsx+3−0View fileUnifiedSplit
@@ -69,6 +69,7 @@ import adminRoutes from "./routes/admin";
6969import adminDeploysRoutes from "./routes/admin-deploys";
7070import adminDeploysPageRoutes from "./routes/admin-deploys-page";
7171import adminOpsRoutes from "./routes/admin-ops";
72import adminSelfHostRoutes from "./routes/admin-self-host";
7273import advisoriesRoutes from "./routes/advisories";
7374import aiChangelogRoutes from "./routes/ai-changelog";
7475import aiExplainRoutes from "./routes/ai-explain";
@@ -332,6 +333,8 @@ app.route("/", healthDashboardRoutes);
332333// insightRoutes because its POST `/:owner/:repo/rollback` catch-all would
333334// otherwise intercept `/admin/ops/rollback` (matching :owner=admin :repo=ops).
334335app.route("/", adminOpsRoutes);
336// BLOCK W — Self-host status + bootstrap dashboard.
337app.route("/", adminSelfHostRoutes);
335338
336339// Insights (time-travel, dependencies, rollback)
337340app.route("/", insightRoutes);
Modifiedsrc/hooks/post-receive.ts+42−0View fileUnifiedSplit
@@ -116,6 +116,48 @@ export async function onPostReceive(
116116 }
117117 }
118118 }
119
120 // 6. BLOCK W — Self-host. When Gluecron.com itself receives a push to
121 // main, fire the local deploy via scripts/self-deploy.sh. The script
122 // forks into the background, so this call returns immediately (git
123 // push doesn't block). Gated on env SELF_HOST_REPO (set on the box) to
124 // avoid firing on customer repos that happen to be named "Gluecron.com".
125 const selfHostRepo = process.env.SELF_HOST_REPO;
126 if (selfHostRepo && `${owner}/${repo}` === selfHostRepo) {
127 const mainRef = refs.find(
128 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
129 );
130 if (mainRef) {
131 const scriptPath =
132 process.env.GLUECRON_SELF_DEPLOY_SCRIPT ||
133 "/opt/gluecron/scripts/self-deploy.sh";
134 try {
135 const child = __selfHostSpawn(
136 [scriptPath, mainRef.oldSha, mainRef.newSha],
137 { stdout: "ignore", stderr: "ignore", stdin: "ignore" }
138 );
139 try {
140 (child as any)?.unref?.();
141 } catch {
142 /* unref optional */
143 }
144 console.log(
145 `[self-host] dispatched self-deploy for ${owner}/${repo}@${mainRef.newSha.slice(0, 7)}`
146 );
147 } catch (err) {
148 console.error(`[self-host] failed to spawn:`, err);
149 }
150 }
151 }
152}
153
154// BLOCK W — DI seam so the test suite can capture the spawn call without
155// actually shelling out to /opt/gluecron/scripts/self-deploy.sh. Production
156// callers go straight to Bun.spawn.
157let __selfHostSpawn: (cmd: string[], opts: any) => any = (cmd, opts) =>
158 Bun.spawn(cmd, opts);
159export function __setSelfHostSpawnForTests(fn: typeof __selfHostSpawn) {
160 __selfHostSpawn = fn;
119161}
120162
121163/**
Addedsrc/routes/admin-self-host.tsx+499−0View fileUnifiedSplit
@@ -0,0 +1,499 @@
1/**
2 * BLOCK W — `/admin/self-host` site-admin self-host dashboard.
3 *
4 * One-page status of the Gluecron self-host migration:
5 * - is the Gluecron.com repo mirrored to Gluecron itself?
6 * - is the post-receive hook installed on the bare repo on disk?
7 * - is SELF_HOST_REPO set in the running process's env?
8 * - last 10 self-deploys (read from platform_deploys where source='self-deploy')
9 *
10 * POST /admin/self-host/bootstrap kicks off the bootstrap script. Same
11 * security model as /admin/ops: requireAuth + isSiteAdmin gate + audit log.
12 */
13/* eslint-disable @typescript-eslint/no-explicit-any */
14
15import { Hono } from "hono";
16import { and, desc, eq } from "drizzle-orm";
17import { existsSync } from "fs";
18import { join } from "path";
19import { db } from "../db";
20import { repositories, users } from "../db/schema";
21import { platformDeploys } from "../db/schema-deploys";
22import { Layout } from "../views/layout";
23import { softAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25import { isSiteAdmin } from "../lib/admin";
26import { audit as realAudit } from "../lib/notify";
27import { config } from "../lib/config";
28import { relativeTime, shortSha } from "./admin-deploys-page";
29
30// ---------------------------------------------------------------------------
31// DI seam — tests inject fakes so we never spawn the real bootstrap.
32// ---------------------------------------------------------------------------
33
34type AuditFn = typeof realAudit;
35type BootstrapSpawnFn = (
36 cmd: string[],
37 opts: { detached?: boolean }
38) => unknown;
39type FsExistsFn = (p: string) => boolean;
40
41interface Deps {
42 audit: AuditFn;
43 spawn: BootstrapSpawnFn;
44 fsExists: FsExistsFn;
45 getEnv: () => Record<string, string | undefined>;
46}
47
48const REAL_DEPS: Deps = {
49 audit: realAudit,
50 spawn: (cmd, _opts) =>
51 Bun.spawn(cmd, {
52 stdout: "ignore",
53 stderr: "ignore",
54 stdin: "ignore",
55 }),
56 fsExists: existsSync,
57 getEnv: () => process.env as Record<string, string | undefined>,
58};
59
60let _deps: Deps = REAL_DEPS;
61
62/** Test-only: replace one or more collaborators. Pass `null` to reset. */
63export function __setSelfHostDepsForTests(d: Partial<Deps> | null): void {
64 _deps = d ? { ...REAL_DEPS, ..._deps, ...d } : REAL_DEPS;
65}
66
67// ---------------------------------------------------------------------------
68// Constants — the repo we self-host.
69// ---------------------------------------------------------------------------
70
71const SELF_HOST_OWNER = "ccantynz";
72const SELF_HOST_NAME = "Gluecron.com";
73const SELF_HOST_FULL = `${SELF_HOST_OWNER}/${SELF_HOST_NAME}`;
74const SELF_DEPLOY_SOURCE = "self-deploy";
75
76// ---------------------------------------------------------------------------
77// Status reads — every probe wrapped so a single failure doesn't 500 the page.
78// ---------------------------------------------------------------------------
79
80interface RepoState {
81 exists: boolean;
82 diskPath: string | null;
83}
84
85async function readRepoState(): Promise<RepoState> {
86 try {
87 const [ownerRow] = await db
88 .select({ id: users.id })
89 .from(users)
90 .where(eq(users.username, SELF_HOST_OWNER))
91 .limit(1);
92 if (!ownerRow) return { exists: false, diskPath: null };
93 const [repo] = await db
94 .select({ id: repositories.id, diskPath: repositories.diskPath })
95 .from(repositories)
96 .where(
97 and(
98 eq(repositories.ownerId, ownerRow.id),
99 eq(repositories.name, SELF_HOST_NAME)
100 )
101 )
102 .limit(1);
103 if (!repo) return { exists: false, diskPath: null };
104 return { exists: true, diskPath: repo.diskPath };
105 } catch (err) {
106 console.error("[admin-self-host] readRepoState:", err);
107 return { exists: false, diskPath: null };
108 }
109}
110
111interface HookState {
112 installed: boolean;
113 path: string;
114}
115
116function readHookState(diskPath: string | null): HookState {
117 // Where the bootstrap installs the hook. If we can't resolve the repo
118 // row we fall back to the conventional path so the operator can still
119 // see the expected location.
120 const base =
121 diskPath ||
122 join(config.gitReposPath, SELF_HOST_OWNER, `${SELF_HOST_NAME}.git`);
123 const path = join(base, "hooks", "post-receive");
124 try {
125 return { installed: _deps.fsExists(path), path };
126 } catch {
127 return { installed: false, path };
128 }
129}
130
131interface EnvState {
132 selfHostRepoSet: boolean;
133 selfHostRepo: string | null;
134 matchesExpected: boolean;
135}
136
137function readEnvState(): EnvState {
138 const env = _deps.getEnv();
139 const v = env.SELF_HOST_REPO || null;
140 return {
141 selfHostRepoSet: !!v,
142 selfHostRepo: v,
143 matchesExpected: v === SELF_HOST_FULL,
144 };
145}
146
147interface RecentDeploy {
148 id: string;
149 sha: string;
150 status: string;
151 startedAt: Date;
152 finishedAt: Date | null;
153 durationMs: number | null;
154}
155
156async function readRecentDeploys(): Promise<RecentDeploy[]> {
157 try {
158 const rows = await db
159 .select({
160 id: platformDeploys.id,
161 sha: platformDeploys.sha,
162 status: platformDeploys.status,
163 startedAt: platformDeploys.startedAt,
164 finishedAt: platformDeploys.finishedAt,
165 durationMs: platformDeploys.durationMs,
166 })
167 .from(platformDeploys)
168 .where(eq(platformDeploys.source, SELF_DEPLOY_SOURCE))
169 .orderBy(desc(platformDeploys.startedAt))
170 .limit(10);
171 return rows;
172 } catch (err) {
173 console.error("[admin-self-host] readRecentDeploys:", err);
174 return [];
175 }
176}
177
178// ---------------------------------------------------------------------------
179// Render helpers
180// ---------------------------------------------------------------------------
181
182function Card({ title, children }: { title: string; children: any }) {
183 return (
184 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:8px;padding:var(--space-4);margin-bottom:var(--space-4)">
185 <h3 style="margin:0 0 12px 0;font-size:14px;letter-spacing:0.04em;text-transform:uppercase;color:var(--text-muted)">
186 {title}
187 </h3>
188 {children}
189 </div>
190 );
191}
192
193function Pill({ ok, label }: { ok: boolean; label: string }) {
194 return (
195 <span
196 style={`display:inline-flex;align-items:center;gap:6px;padding:2px 10px;border-radius:999px;font-size:12px;font-weight:600;background:${
197 ok ? "rgba(52, 211, 153, 0.16)" : "rgba(248, 113, 113, 0.16)"
198 };color:${ok ? "#34d399" : "#f87171"}`}
199 >
200 <span aria-hidden="true">{ok ? "v" : "x"}</span>
201 <span>{label}</span>
202 </span>
203 );
204}
205
206// ---------------------------------------------------------------------------
207// Gating
208// ---------------------------------------------------------------------------
209
210const selfHost = new Hono<AuthEnv>();
211selfHost.use("*", softAuth);
212
213async function gate(c: any): Promise<{ user: any } | Response> {
214 const user = c.get("user");
215 if (!user) return c.redirect("/login?next=/admin/self-host");
216 if (!(await isSiteAdmin(user.id))) {
217 return c.html(
218 <Layout title="Forbidden" user={user}>
219 <div class="empty-state">
220 <h2>403 — Not a site admin</h2>
221 <p>You don't have permission to view this page.</p>
222 </div>
223 </Layout>,
224 403
225 );
226 }
227 return { user };
228}
229
230function redirectWith(c: any, kind: "success" | "error", msg: string): Response {
231 return c.redirect(`/admin/self-host?${kind}=${encodeURIComponent(msg)}`);
232}
233
234// ---------------------------------------------------------------------------
235// GET /admin/self-host
236// ---------------------------------------------------------------------------
237
238selfHost.get("/admin/self-host", async (c) => {
239 const g = await gate(c);
240 if (g instanceof Response) return g;
241 const { user } = g;
242
243 const success = c.req.query("success");
244 const error = c.req.query("error");
245
246 const [repoState, recent] = await Promise.all([
247 readRepoState(),
248 readRecentDeploys(),
249 ]);
250 const hookState = readHookState(repoState.diskPath);
251 const envState = readEnvState();
252
253 const allGreen =
254 repoState.exists && hookState.installed && envState.matchesExpected;
255
256 return c.html(
257 <Layout title="Self-host — admin" user={user}>
258 <div style="max-width:880px;margin:0 auto;padding:var(--space-6) var(--space-4)">
259 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:18px">
260 <h1 style="margin:0">Self-host</h1>
261 <a href="/admin" class="btn btn-sm">
262 Back to admin
263 </a>
264 </div>
265 <p style="color:var(--text-muted);margin-bottom:20px">
266 Status of the BLOCK W migration — Gluecron's own source hosted
267 on Gluecron itself. Once all three cards are green, every push
268 to <code>{SELF_HOST_FULL}</code> deploys via the local
269 post-receive hook in ~25 seconds.
270 </p>
271
272 {success && (
273 <div class="auth-success" style="margin-bottom:16px">
274 {decodeURIComponent(success)}
275 </div>
276 )}
277 {error && (
278 <div class="auth-error" style="margin-bottom:16px">
279 {decodeURIComponent(error)}
280 </div>
281 )}
282
283 <Card title="Status">
284 <ul style="list-style:none;padding:0;margin:0">
285 <li style="display:flex;align-items:center;gap:10px;padding:6px 0;font-size:13px">
286 <Pill
287 ok={repoState.exists}
288 label={repoState.exists ? "Mirrored" : "Not mirrored"}
289 />
290 <span>
291 Gluecron repo row exists ({SELF_HOST_FULL})
292 {repoState.diskPath && (
293 <code style="margin-left:8px;color:var(--text-muted);font-size:12px">
294 {repoState.diskPath}
295 </code>
296 )}
297 </span>
298 </li>
299 <li style="display:flex;align-items:center;gap:10px;padding:6px 0;font-size:13px">
300 <Pill
301 ok={hookState.installed}
302 label={hookState.installed ? "Installed" : "Missing"}
303 />
304 <span>
305 Bare-repo post-receive hook
306 <code style="margin-left:8px;color:var(--text-muted);font-size:12px">
307 {hookState.path}
308 </code>
309 </span>
310 </li>
311 <li style="display:flex;align-items:center;gap:10px;padding:6px 0;font-size:13px">
312 <Pill
313 ok={envState.matchesExpected}
314 label={
315 envState.matchesExpected
316 ? "Set"
317 : envState.selfHostRepoSet
318 ? "Mismatch"
319 : "Unset"
320 }
321 />
322 <span>
323 <code>SELF_HOST_REPO</code> env
324 {envState.selfHostRepo && (
325 <code style="margin-left:8px;color:var(--text-muted);font-size:12px">
326 = {envState.selfHostRepo}
327 </code>
328 )}
329 {!envState.selfHostRepoSet && (
330 <span style="margin-left:8px;color:var(--text-muted);font-size:12px">
331 expected <code>{SELF_HOST_FULL}</code>
332 </span>
333 )}
334 </span>
335 </li>
336 </ul>
337 <div style="margin-top:14px;font-size:12px;color:var(--text-muted)">
338 Overall:{" "}
339 <Pill
340 ok={allGreen}
341 label={allGreen ? "Self-host ready" : "Setup incomplete"}
342 />
343 </div>
344 </Card>
345
346 <Card title="Bootstrap">
347 <p style="font-size:13px;color:var(--text-muted);margin:0 0 10px 0">
348 Mirror Gluecron's source from GitHub onto this Gluecron
349 instance. Idempotent — safe to re-run. See{" "}
350 <a href="/ccantynz/Gluecron.com/blob/main/docs/SELF_HOST.md">
351 docs/SELF_HOST.md
352 </a>{" "}
353 for the full runbook.
354 </p>
355 <div style="display:flex;gap:var(--space-2);align-items:center">
356 <form
357 method="post"
358 action="/admin/self-host/bootstrap"
359 style="margin:0"
360 onsubmit="return confirm('Run the self-host bootstrap on this box? Safe to re-run, but it will spawn a child process.')"
361 >
362 <button
363 type="submit"
364 class="btn btn-sm btn-primary"
365 disabled={repoState.exists && hookState.installed}
366 title={
367 repoState.exists && hookState.installed
368 ? "Bootstrap already applied"
369 : "Run scripts/self-host-bootstrap.ts"
370 }
371 >
372 {repoState.exists && hookState.installed
373 ? "Already bootstrapped"
374 : "Run bootstrap"}
375 </button>
376 </form>
377 <span style="font-size:12px;color:var(--text-muted)">
378 Runs <code>bun run scripts/self-host-bootstrap.ts</code>{" "}
379 detached. Watch the systemd journal or{" "}
380 <code>/var/log/gluecron-self-deploy.log</code>.
381 </span>
382 </div>
383 </Card>
384
385 <Card title="Last 10 self-deploys">
386 {recent.length === 0 ? (
387 <p style="color:var(--text-muted);font-size:13px;margin:0">
388 No self-deploys recorded yet. Push a commit to{" "}
389 <code>{SELF_HOST_FULL}</code> after completing the bootstrap.
390 </p>
391 ) : (
392 <table style="width:100%;border-collapse:collapse;font-size:13px">
393 <thead>
394 <tr style="text-align:left;color:var(--text-muted);font-size:12px;text-transform:uppercase;letter-spacing:0.04em">
395 <th style="padding:6px 4px;border-bottom:1px solid var(--border)">
396 SHA
397 </th>
398 <th style="padding:6px 4px;border-bottom:1px solid var(--border)">
399 Status
400 </th>
401 <th style="padding:6px 4px;border-bottom:1px solid var(--border)">
402 Started
403 </th>
404 <th style="padding:6px 4px;border-bottom:1px solid var(--border);text-align:right">
405 Duration
406 </th>
407 </tr>
408 </thead>
409 <tbody>
410 {recent.map((d) => (
411 <tr>
412 <td style="padding:6px 4px;border-bottom:1px solid var(--border)">
413 <code class="meta-mono">{shortSha(d.sha)}</code>
414 </td>
415 <td style="padding:6px 4px;border-bottom:1px solid var(--border)">
416 <Pill
417 ok={d.status === "succeeded"}
418 label={d.status}
419 />
420 </td>
421 <td
422 style="padding:6px 4px;border-bottom:1px solid var(--border)"
423 title={d.startedAt.toISOString()}
424 >
425 {relativeTime(d.startedAt)}
426 </td>
427 <td style="padding:6px 4px;border-bottom:1px solid var(--border);text-align:right">
428 {d.durationMs != null
429 ? `${(d.durationMs / 1000).toFixed(1)}s`
430 : "—"}
431 </td>
432 </tr>
433 ))}
434 </tbody>
435 </table>
436 )}
437 </Card>
438 </div>
439 </Layout>
440 );
441});
442
443// ---------------------------------------------------------------------------
444// POST /admin/self-host/bootstrap
445//
446// Spawns scripts/self-host-bootstrap.ts with the default args. Detached
447// so the request returns immediately. The operator watches the systemd
448// journal / log file for progress.
449// ---------------------------------------------------------------------------
450
451selfHost.post("/admin/self-host/bootstrap", async (c) => {
452 const g = await gate(c);
453 if (g instanceof Response) return g;
454 const { user } = g;
455
456 try {
457 const bunCmd =
458 _deps.getEnv().GLUECRON_BUN_PATH || "/root/.bun/bin/bun";
459 const scriptPath =
460 _deps.getEnv().GLUECRON_BOOTSTRAP_SCRIPT ||
461 "/opt/gluecron/scripts/self-host-bootstrap.ts";
462 const child = _deps.spawn([bunCmd, "run", scriptPath], { detached: true });
463 try {
464 (child as any)?.unref?.();
465 } catch {
466 /* ignore */
467 }
468 try {
469 await _deps.audit({
470 userId: user.id,
471 action: "admin.self_host.bootstrap_triggered",
472 targetType: "repository",
473 metadata: { repo: SELF_HOST_FULL },
474 });
475 } catch {
476 /* audit failure is non-fatal */
477 }
478 return redirectWith(
479 c,
480 "success",
481 "Bootstrap dispatched — watch the system journal for progress."
482 );
483 } catch (err) {
484 const message = err instanceof Error ? err.message : String(err);
485 return redirectWith(c, "error", `Bootstrap failed to spawn: ${message}`);
486 }
487});
488
489export const __test = {
490 readRepoState,
491 readHookState,
492 readEnvState,
493 readRecentDeploys,
494 SELF_HOST_OWNER,
495 SELF_HOST_NAME,
496 SELF_HOST_FULL,
497};
498
499export default selfHost;
0500