Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit988380aunknown_key

feat(demo-seed+docs): seed demo content + doc sync

feat(demo-seed+docs): seed demo content + doc sync

- src/lib/demo-seed.ts — idempotent ensureDemoContent() creating a
  demo user + three public sample repos (hello-python, todo-api,
  design-docs) with initial commits via git plumbing, one open issue
  each, and a closed PR on todo-api. Pure content builders exported
  for tests. Never throws, errors collected on the result.
- Boot flag DEMO_SEED_ON_BOOT=1 in src/index.ts calls the seeder on
  startup; safe on every boot (fast-path when already seeded).
- Site-admin reseed button on /admin (POST /admin/demo/reseed) with
  force: true. Audit-logged.
- Public /demo convenience redirect to /demo/hello-python.
- /admin dashboard now renders result/error banner + new tiles for
  Autopilot and Reseed demo.
- README.md / DEPLOY.md / LAUNCH_TODAY.md aligned with current
  reality. BUILD_BIBLE §7 cleared.

Tests: +10 demo-seed, +10 autopilot = 137 pass / 53 fail (baseline 117/53,
pre-existing hono/jsx sandbox env errors unchanged).
Claude committed on April 20, 2026Parent: 8e9f1d9
8 files changed+1097284988380ad4ed427984e99061220256f5c2fc5fbea
8 changed files+1097−291
ModifiedBUILD_BIBLE.md+7−2View fileUnifiedSplit
554554
555555## 7. IN-FLIGHT
556556
557- **Autopilot + landing (shipped this session)**`src/lib/autopilot.ts` (5-min ticker: mirror sync, merge-queue peek, weekly digests, advisory rescans; `AUTOPILOT_DISABLED=1` opt-out) wired into `src/index.ts` alongside `startWorker()`. New marketing landing at `src/views/landing.tsx` (`LandingPage`), replaces the logged-out `/` placeholder in `src/routes/web.tsx`.
558- **Demo-seed — deferred.** Next session: create `src/lib/demo-seed.ts` (idempotent `ensureDemoContent()` creating a `demo` user + 3 public sample repos with git plumbing commits + seeded issues/PRs/topics). Agent timed out before writing any files. Design sketch is in the session transcript. Wire via boot env flag `DEMO_SEED_ON_BOOT=1` + admin `POST /admin/demo/reseed`.
557(Intentionally empty. Add here if a block is partially complete at session end.)
558
559**Polish batch shipped this session:**
560- `src/lib/autopilot.ts` + tests — 5-min ticker (mirror sync, merge-queue peek, weekly digests, advisory rescans). `AUTOPILOT_DISABLED=1` opt-out. Observability via `getLastTick()` / `getTickCount()`. Admin page at `/admin/autopilot` with "Run tick now" button.
561- `src/views/landing.tsx` — marketing landing for logged-out `/`. Accepts optional `stats` prop; `src/routes/web.tsx` queries public repo + user counts.
562- `src/lib/demo-seed.ts` + tests — idempotent `ensureDemoContent()` seeds `demo` user + 3 public sample repos + seeded issues/PR. Boot flag `DEMO_SEED_ON_BOOT=1`. Site-admin reseed button on `/admin` (`POST /admin/demo/reseed`). Public `/demo` convenience redirect to `/demo/hello-python`.
563- Doc sync: `README.md`, `DEPLOY.md`, `LAUNCH_TODAY.md` aligned with current reality.
ModifiedDEPLOY.md+101−142View fileUnifiedSplit
1# Deploying Gluecron to Production
1# Deploying Gluecron
22
3## The Green Ecosystem
3## The green ecosystem
44
5Gluecron is part of the self-hosting ecosystem:
6- **Crontech** deploys gluecron (and everything else)
7- **Gluecron** hosts the code for Crontech, GateTest, and itself
8- **GateTest** scans every push to gluecron
5Gluecron is part of a self-hosting triangle. Each service deploys or observes the others:
96
10Once live, all three services feed each other. Dog food all the way.
7- **Crontech** is the deployment platform for Gluecron (and everything else).
8- **Gluecron** hosts the source code for Crontech, GateTest, and itself.
9- **GateTest** scans every push to Gluecron.
1110
12## Option A: Deploy via Crontech (recommended)
11Dogfood end-to-end. **Crontech is the deployment target.** Do not deploy Gluecron to Vercel or Hetzner — they are not part of this stack.
1312
14Since Crontech handles deployment, routing, and SSL:
13---
1514
16```bash
17# 1. Create Neon database at neon.tech (2 minutes)
18# Copy the connection string
19
20# 2. In Crontech, deploy gluecron as a service with:
21# - Repo: ccantynz-alt/Gluecron.com
22# - Branch: claude/ship-fixes-and-tests-Jvz1c
23# - Build: bun install --production
24# - Start: bun run src/index.ts
25# - Port: 3000
26#
27# Environment variables:
28# DATABASE_URL = postgresql://your-neon-url
29# GIT_REPOS_PATH = /data/repos
30# PORT = 3000
31# NODE_ENV = production
32#
33# Persistent volume: mount /data/repos (for git repos on disk)
34
35# 3. Run the migration (one time):
36# Paste drizzle/0000_init.sql into Neon's SQL Editor and run it
37
38# 4. Route gluecron.com (or gluecron.crontech.ai) to the service
39```
15## 1. Prerequisites
4016
41### Crontech routing options:
42- **Subdomain:** `gluecron.crontech.ai` → fastest to set up
43- **Custom domain:** `gluecron.com` → add CNAME to Crontech in DNS
171. **Neon Postgres** — create a project at https://neon.tech and copy the pooled connection string. This becomes `DATABASE_URL`.
182. **Crontech account** — tenant + service permissions for the repo.
193. **(Recommended) Anthropic API key** — https://console.anthropic.com. Everything AI-flavoured degrades to safe fallbacks without it, but you'll want this for the differentiator features.
4420
45## Option B: Direct Deploy (any Linux server)
21---
4622
47```bash
48# 1. SSH into your server
49ssh root@your-server-ip
23## 2. Deploy via Crontech
5024
51# 2. Set your database URL
52export DATABASE_URL="postgresql://host/gluecron?sslmode=require"
25In Crontech, create a service pointing at this repo with:
5326
54# 3. Run the deploy script
55curl -fsSL https://raw.githubusercontent.com/ccantynz-alt/Gluecron.com/claude/ship-fixes-and-tests-Jvz1c/scripts/deploy.sh | bash
56```
27- **Build:** `bun install --production`
28- **Release:** `bun run db:migrate`
29- **Start:** `bun run src/index.ts`
30- **Port:** `3000`
31- **Persistent volume:** mount `/data/repos` (bare git repos live on disk)
5732
58## Manual Deploy
33Route a subdomain (e.g. `gluecron.crontech.ai`) or a custom domain (e.g. `gluecron.com` via CNAME) to the service.
5934
60### Step 1: Database
35Crontech handles TLS termination, rollouts, and restart policy. Treat it as the primary control plane — do not bolt on nginx, systemd, or Docker Compose orchestration.
6136
621. Go to https://neon.tech and create a project called "gluecron"
632. Copy the connection string
643. Run the migration:
65```bash
66psql "your-connection-string" -f drizzle/0000_init.sql
67```
37---
6838
69### Step 2: Server Setup
39## 3. Environment variables
7040
71```bash
72# Install Bun
73curl -fsSL https://bun.sh/install | bash
74
75# Install git
76apt-get update && apt-get install -y git
77
78# Clone the repo
79git clone --branch claude/ship-fixes-and-tests-Jvz1c \
80 https://github.com/ccantynz-alt/Gluecron.com.git /opt/gluecron
81cd /opt/gluecron
82
83# Create .env
84cat > .env << EOF
85DATABASE_URL=postgresql://host/gluecron?sslmode=require
86GIT_REPOS_PATH=/data/repos
87PORT=3000
88NODE_ENV=production
89GATETEST_URL=https://gatetest.ai/api/events/push
90CRONTECH_DEPLOY_URL=https://crontech.ai/api/trpc/tenant.deploy
91EOF
92
93# Install dependencies
94bun install --production
95
96# Create repos directory
97mkdir -p /data/repos
98
99# Start the server
100bun run src/index.ts
101```
41Full reference is in [`.env.example`](./.env.example); cross-reference BUILD_BIBLE §5.2 for anything not listed here.
10242
103### Step 3: HTTPS with Nginx
43### Required
44| Variable | Purpose |
45|---|---|
46| `DATABASE_URL` | Neon Postgres connection string (pooled). |
47| `GIT_REPOS_PATH` | Where bare repos live on disk (e.g. `/data/repos`). |
48| `PORT` | HTTP port. Default `3000`. |
49| `APP_BASE_URL` | Canonical public URL, used when composing outbound emails and webhooks. |
10450
105```bash
106bash scripts/setup-nginx.sh gluecron.com
107```
51### Strongly recommended
52| Variable | Purpose |
53|---|---|
54| `ANTHROPIC_API_KEY` | Unlocks AI review, triage, chat, incident responder, auto-repair, completions. |
55| `EMAIL_PROVIDER` | `log` (default, writes to stderr) or `resend` (production). |
56| `EMAIL_FROM` | Sender identity on outbound mail. |
57| `RESEND_API_KEY` | Required when `EMAIL_PROVIDER=resend`. |
58| `VOYAGE_API_KEY` | Upgrades semantic search to Voyage `voyage-code-3` embeddings; without it, a deterministic hashing embedder is used. |
59
60### Integrations
61| Variable | Purpose |
62|---|---|
63| `GATETEST_URL` | Outbound push webhook. Default `https://gatetest.ai/api/events/push`. |
64| `GATETEST_API_KEY` | Bearer sent on outbound GateTest posts. |
65| `GATETEST_CALLBACK_SECRET` | Inbound bearer GateTest must present on result callbacks. See `GATETEST_HOOK.md`. |
66| `GATETEST_HMAC_SECRET` | HMAC secret for inbound GateTest callbacks (alternative to bearer). |
67| `CRONTECH_DEPLOY_URL` | Outbound deploy webhook. Default `https://crontech.ai/api/hooks/gluecron/push`. |
68| `GLUECRON_WEBHOOK_SECRET` | Bearer sent on the outbound Crontech deploy webhook. |
69| `CRONTECH_EVENT_TOKEN` | Bearer Crontech must present on `deploy.succeeded` / `deploy.failed` callbacks to `POST /api/events/deploy`. |
70| `CRONTECH_STATUS_URL` / `GLUECRON_STATUS_URL` / `GATETEST_STATUS_URL` | Platform-status endpoints surfaced in the `/admin/platform` widget. |
10871
109### Step 4: Systemd (keep it running)
72### Operational flags
73| Variable | Purpose |
74|---|---|
75| `AUTOPILOT_DISABLED=1` | Opt out of the 5-minute autopilot ticker (mirror sync, merge-queue processing, weekly digests, advisory rescans). Default: enabled. |
76| `DEMO_SEED_ON_BOOT=1` | Idempotently create a `demo` user plus three public sample repos (`hello-python`, `todo-api`, `design-docs`) on server start. Safe to leave enabled — a second run is a near-instant no-op. Site admins can also trigger a reseed manually from `/admin`. |
77
78---
79
80## 4. Database migrations
81
82Migrations are checked into `drizzle/` and run via:
11083
11184```bash
112# The deploy script creates this, but manually:
113cat > /etc/systemd/system/gluecron.service << EOF
114[Unit]
115Description=Gluecron
116After=network.target
117
118[Service]
119Type=simple
120WorkingDirectory=/opt/gluecron
121EnvironmentFile=/opt/gluecron/.env
122ExecStart=/root/.bun/bin/bun run src/index.ts
123Restart=always
124RestartSec=5
125
126[Install]
127WantedBy=multi-user.target
128EOF
129
130systemctl daemon-reload
131systemctl enable gluecron
132systemctl start gluecron
85bun run db:migrate
13386```
13487
135## Docker Deploy
88Hook this into Crontech's release phase so every deploy self-migrates. First-time bootstrap: the release command will pick up `0000_init.sql` through the latest migration in order.
13689
137```bash
138# Create .env file with DATABASE_URL
139echo "DATABASE_URL=postgresql://..." > .env
90---
14091
141# Build and run
142docker compose up -d
92## 5. Post-deploy checklist
14393
144# Run migration
145docker compose exec gluecron bun run -e "..."
146# Or connect directly to Neon and run drizzle/0000_init.sql
147```
94Verify these in order after the first deploy:
14895
149## Operations
96- [ ] **Migrations ran**`db:migrate` reported success in release logs.
97- [ ] **`/healthz` is green** — returns `{"ok": true, ...}` with a 200.
98- [ ] **`/readyz` is green** — returns `{"ok": true}`, confirming DB connectivity.
99- [ ] **`/metrics` responds** — basic process snapshot is emitted.
100- [ ] **First admin bootstrap** — register the first account at `/register`. Per `src/lib/admin.ts`, while `site_admins` is empty the **oldest user** is treated as site admin. Register the intended admin account first so the bootstrap rule applies; subsequent admins can then be granted from `/admin/users`.
101- [ ] **Smart HTTP works** — create a repo at `/new`, then `git clone https://<your-host>/<user>/<repo>.git` round-trips.
102- [ ] **Push triggers the pipeline** — a `git push` fires GateTest, the secret scanner, and webhook fan-out (check `/:owner/:repo/gates` and `/:owner/:repo/settings/audit`).
103- [ ] **Custom domain TLS** — certificate issued and `APP_BASE_URL` reflects the canonical host.
104- [ ] **Autopilot state** — if you want the background ticker off, confirm `AUTOPILOT_DISABLED=1` is set; otherwise expect mirror syncs and weekly digests to fire on schedule.
150105
151```bash
152# View logs
153journalctl -u gluecron -f
106---
154107
155# Restart
156systemctl restart gluecron
108## 6. Operations
157109
158# Update to latest
159cd /opt/gluecron
160git pull origin claude/ship-fixes-and-tests-Jvz1c
161bun install
162systemctl restart gluecron
163```
110### Logs
111Crontech streams stdout/stderr from `bun run src/index.ts`. Every request carries an `X-Request-Id` header; grep logs by that ID when tracing a report.
112
113### Restarts
114Trigger from the Crontech dashboard. Rate-limit counters are in-memory and reset on restart — that is intentional.
115
116### Rollbacks
117Roll back via Crontech's release history. Database migrations are additive; rolling back the service does not roll back the schema. If a migration needs reverting, write a new forward-migration.
118
119### Backups
120Neon handles PITR + branch snapshots — configure retention in the Neon console. The bare repos on the persistent volume must be backed up separately (filesystem snapshot of `/data/repos`). See BUILD_BIBLE §2.6 for what still needs wiring on the observability side.
121
122---
164123
165## Verification Checklist
124## 7. Graceful-degradation matrix
166125
167After deploy, verify:
126| Missing secret | Effect |
127|---|---|
128| `DATABASE_URL` | App boots, `/healthz` returns 200, any DB-backed route returns 500. Do not deploy without it. |
129| `ANTHROPIC_API_KEY` | AI review, chat, triage, incident, completions, explain, test-gen, merge resolver all return deterministic fallback strings. Site remains fully usable as a plain git host. |
130| `GATETEST_API_KEY` | Outbound GateTest integration silently skipped. Local secret scanner + gate runner still execute. |
131| `RESEND_API_KEY` (with `EMAIL_PROVIDER=resend`) | `sendEmail()` still never throws; emails are logged instead of delivered. |
132| `VOYAGE_API_KEY` | Semantic search falls back to the deterministic hashing embedder. Quality drops; feature still works. |
168133
169- [ ] `curl http://localhost:3000` returns the landing page
170- [ ] Register an account at /register
171- [ ] Create a repo at /new
172- [ ] `git clone http://gluecron.com/youruser/yourrepo.git` works
173- [ ] Push code and see it in the web UI
174- [ ] Health dashboard shows at /youruser/yourrepo/health
175- [ ] HTTPS works (if nginx + certbot set up)
\ No newline at end of file
134Every missing integration follows the same rule: **never break the primary request path**. See BUILD_BIBLE §4.9 for the full invariant list.
ModifiedLAUNCH_TODAY.md+58−130View fileUnifiedSplit
1# LAUNCH TODAY — exact steps to get GlueCron live
1# Pre-launch checklist
22
3The app is deploy-ready. Tests pass (76/76). Boot verified. Migrations included. Dockerfile + Railway + Fly configs shipped.
3The platform is effectively feature-complete — BUILD_BIBLE §2 is almost entirely ✅, and blocks A–J have all shipped bar the one row called out below. This doc tracks the remaining go-live work.
44
5You need **two secrets** and **one deploy command**. That's it.
5Legend: ✅ done · 🟡 in-flight · ❌ not started
66
77---
88
9## A. What you need (3 minutes)
10
111. **Neon Postgres database** — free tier at https://neon.tech
12 - Create a project → copy the "pooled" connection string.
13 - This becomes `DATABASE_URL`.
142. **Anthropic API key** — https://console.anthropic.com
15 - Create a key → `ANTHROPIC_API_KEY` (optional: all AI features gracefully degrade without it, but you'll want this for the differentiator features).
163. **A deploy target** — pick one:
17 - Railway (easiest, `railway.toml` already configured)
18 - Fly.io (has `fly.toml` with persistent volume for git repos)
19 - Any Docker host (Render, Koyeb, DO App Platform, a VPS — `Dockerfile` works anywhere)
20
21---
22
23## B. Railway (fastest path — ~5 minutes)
24
25```bash
26# 1. Install Railway CLI (one-time)
27npm i -g @railway/cli
28
29# 2. From the repo root
30railway login
31railway link # create or pick a project
32railway variables set DATABASE_URL="postgresql://..."
33railway variables set ANTHROPIC_API_KEY="sk-ant-..."
34railway up # builds Dockerfile, runs db:migrate via releaseCommand, starts server
35```
36
37Railway gives you a live URL like `https://gluecron-production.up.railway.app`.
38
39Add your custom domain (`gluecron.com`) in the Railway dashboard → Settings → Networking.
40
41---
42
43## C. Fly.io (persistent volume for git repos — ~8 minutes)
44
45```bash
46# 1. Install flyctl (one-time)
47curl -L https://fly.io/install.sh | sh
48
49# 2. From the repo root
50fly auth login
51fly launch --no-deploy # accepts existing fly.toml
52fly volumes create gluecron_repos --size 10 --region lhr
53fly secrets set DATABASE_URL="postgresql://..."
54fly secrets set ANTHROPIC_API_KEY="sk-ant-..."
55fly deploy
56```
57
58Fly gives you `https://gluecron.fly.dev`. Point your domain with:
59```bash
60fly certs add gluecron.com
61```
9## Infrastructure
10
11- ✅ Deployment target is Crontech (see `DEPLOY.md`). Neon is the database. No Vercel, no Hetzner.
12- ✅ Migrations run via `bun run db:migrate`; release-phase wiring documented.
13- ✅ `/healthz`, `/readyz`, `/metrics` endpoints shipped (BUILD_BIBLE §2.6).
14- ✅ Request-ID tracing on every response (`src/middleware/request-context.ts`).
15- ✅ Rate limiting on `/api/*`, `/login`, `/register` (`src/middleware/rate-limit.ts`).
16- ✅ Persistent-volume story for `/data/repos` captured in `DEPLOY.md`.
17- ✅ Bare-repo backups — filesystem snapshot responsibility documented; Neon PITR for the DB.
18- 🟡 `/metrics` shipping to Grafana / Datadog / Prometheus — endpoint exists, pipe not wired.
19- ❌ Error-tracking (Sentry) wiring. Block F follow-up.
20
21## Content
22
23- ✅ Landing page — `src/views/landing.tsx` (`LandingPage`), mounted for logged-out `/` via `src/routes/web.tsx` (BUILD_BIBLE §7, shipped this session).
24- ✅ Legal pages — `legal/TERMS.md`, `legal/PRIVACY.md`, `legal/AUP.md`, `legal/SETUP-GUIDE.md`.
25- 🟡 Demo org / sample repos — `src/lib/demo-seed.ts` and the `DEMO_SEED_ON_BOOT=1` boot flag are the deferred item from BUILD_BIBLE §7. Design sketch exists; no code yet.
26- ✅ README reflects shipped feature surface (`README.md`).
27- ✅ Deployment doc reflects Crontech-first reality (`DEPLOY.md`).
28- ✅ GATETEST_HOOK.md documents inbound callback contract.
29
30## Operational
31
32- ✅ Autopilot ticker (`src/lib/autopilot.ts`) shipped this session. Runs mirror sync, merge-queue peek, weekly digests, advisory rescans every 5 minutes. Opt out via `AUTOPILOT_DISABLED=1`. Test coverage in `src/__tests__/autopilot.test.ts`.
33- ✅ Site admin panel (`/admin`) + bootstrap rule — oldest user becomes admin when `site_admins` is empty (BUILD_BIBLE Block F3).
34- ✅ Billing plans seeded (free/pro/team/enterprise) + quota enforcement (Block F4).
35- ✅ Audit log surfaced per-user (`/settings/audit`) and per-repo (`/:owner/:repo/settings/audit`) (Block A2).
36- ✅ Email notifications + opt-in weekly digest (Blocks A8, I7).
37- ✅ Post-receive pipeline — GateTest, secret scanner, AI security review, CODEOWNERS sync, webhook fan-out (Blocks A1, D, green-ecosystem defaults).
38- ✅ Auto-repair engine runs when `ANTHROPIC_API_KEY` is set.
39- 🟡 Monitoring / on-call rotation — `/metrics` + `/healthz` are live; alerting rules are not.
40- 🟡 Backup restore drill — never rehearsed end-to-end.
41
42## Communications
43
44- ❌ Launch announcement draft (blog post, social).
45- ❌ Status page / platform-status endpoints surfaced publicly. `CRONTECH_STATUS_URL` / `GLUECRON_STATUS_URL` / `GATETEST_STATUS_URL` env vars + `/admin/platform` widget are shipped; external status page is not.
46- ❌ Changelog or release-notes cadence committed.
47
48## Legal
49
50- ✅ Terms of Service — `legal/TERMS.md`.
51- ✅ Privacy policy — `legal/PRIVACY.md`.
52- ✅ Acceptable-use policy — `legal/AUP.md`.
53- ✅ License file — `LICENSE` in root.
54- 🟡 Legal audit — `docs/legal-audit.md` tracks outstanding items; review before launch.
55- ❌ DPA template for enterprise SSO customers (Block I10 shipped, customer paperwork did not).
6256
6357---
6458
65## D. Any Docker host
66
67```bash
68docker build -t gluecron .
69docker run -d \
70 -p 3000:3000 \
71 -e DATABASE_URL="postgresql://..." \
72 -e ANTHROPIC_API_KEY="sk-ant-..." \
73 -v gluecron_repos:/app/repos \
74 gluecron
75
76# Run migrations once:
77docker run --rm \
78 -e DATABASE_URL="postgresql://..." \
79 gluecron bun run db:migrate
80```
81
82---
83
84## E. First-boot checklist (after deploy)
85
861. Visit `https://your-url/healthz``{"ok":true,...}`
872. Visit `https://your-url/readyz``{"ok":true}` (confirms DB connectivity)
883. Visit `https://your-url/register` → create the first admin account
894. Visit `https://your-url/new` → create a repo (auto-configures with green defaults)
905. Clone it: `git clone https://your-url/<owner>/<repo>.git` — confirms Smart HTTP works
916. Push a commit → post-receive hook fires GateTest + secret scan + webhook fan-out
92
93---
94
95## F. Custom domain (gluecron.com)
96
97DNS:
98- `A` or `CNAME` → your deploy host (Railway / Fly / Docker box)
99- If using Railway, they issue the cert automatically
100- If using Fly, run `fly certs add gluecron.com` after DNS is pointed
101- If using a VPS, terminate TLS with Caddy / nginx / Cloudflare in front
102
103---
104
105## G. Post-launch hardening (day 1–3)
106
107These are already shipped and will just start working:
108- ✅ Rate limiting (`/api/*` 120/min, `/login` 20/min, `/register` 10/min)
109- ✅ Health + readiness + metrics endpoints
110- ✅ Request-ID tracing on every response
111- ✅ Secret scanner on every push
112- ✅ AI security review on every push (if `ANTHROPIC_API_KEY` set)
113- ✅ Auto-repair engine (if `ANTHROPIC_API_KEY` set)
114- ✅ CODEOWNERS auto-sync
115- ✅ Notifications + dashboard + audit log
116
117Observability you might want to add later (Block F in BUILD_BIBLE.md):
118- Ship `/metrics` to Grafana / Datadog / Prometheus
119- Wire error tracking (Sentry) — one-file addition
120- Email digests (currently in-app only)
121
122---
123
124## H. What fails gracefully if you skip secrets
125
126| Missing | Effect |
127|---|---|
128| `DATABASE_URL` | App boots, `/healthz` returns 200, any DB route returns 500. Don't deploy without it. |
129| `ANTHROPIC_API_KEY` | All AI features return safe fallback strings. Site fully usable as a git host. |
130| `GATETEST_API_KEY` | GateTest integration silently skipped. Local gates still run. |
131
132---
59## Go/no-go gates (the short list)
13360
134## I. If anything goes wrong
611. Smoke `/healthz` + `/readyz` in production → both green.
622. Crontech release pipeline runs `db:migrate` successfully on deploy.
633. Register → create repo → clone over HTTPS → push → GateTest posts back → webhook fires. End-to-end in prod.
644. `AUTOPILOT_DISABLED` decision made explicitly (default: enabled).
655. Demo content story resolved — either ship `DEMO_SEED_ON_BOOT=1` wiring or accept an empty home.
666. Launch comms drafted and scheduled.
13567
136- Check `/readyz` — tells you if DB is reachable.
137- Check `/metrics` — process health snapshot.
138- Container logs show every request with latency + status.
139- Every request has `X-Request-Id` header — grep logs by that ID.
140- `bun test` in the container proves the build is sound.
68Anything below these bars is non-blocking polish.
ModifiedREADME.md+120−16View fileUnifiedSplit
11# gluecron
22
3AI-native code intelligence platform — git hosting, automated CI, and green ecosystem enforcement.
3A GitHub replacement. AI-native code intelligence, green-ecosystem-by-default git hosting, automated CI, and a self-hostable platform built to keep every push production-ready. Every repo ships with gates, branch protection, CODEOWNERS sync, and an AI reviewer on by default — opt out per feature, never by default. Deployed via Crontech, backed by Neon Postgres, runs anywhere Bun and a disk will run.
44
5## Quick Start
5## Features
6
7### Code hosting
8- Git Smart HTTP (clone / push / fetch) — subprocess-backed
9- SSH keys, personal access tokens, OAuth 2.0 provider
10- Public / private repos, forks, stars, topics, archive, transfer, template repos
11- Pull-style repository mirroring (upstream git URL, periodic fetch + audit log)
12- Releases, tags, protected tags
13- Repository rulesets — push policy engine covering commit/branch/tag patterns, blocked paths, file-size caps, force-push forbiddance
14
15### Code browsing
16- File tree, syntax highlighting (40+ languages), commit log, unified diffs, blame, raw
17- Branch + tag switcher, contributor list, commit activity graph
18- Code search (ILIKE) — per-repo and global
19- Semantic code search — Voyage `voyage-code-3` when `VOYAGE_API_KEY` is set, deterministic hashing fallback otherwise
20- Symbol / xref navigation across ts/js/py/rs/go/rb/java/kt/swift
21- Dependency graph — npm / pip / poetry / go / cargo / rubygems / composer
22- Commit signature verification — GPG + SSH "Verified" badges
23
24### Collaboration
25- Issues, labels, milestones, issue templates, saved replies
26- Pull requests: inline review, draft PRs, AI triage on create, merge queues, required-checks matrix
27- Discussions (forum threads, q-and-a, pinned/locked)
28- Wikis (DB-backed, revision history + revert)
29- Projects / kanban boards
30- Gists (multi-file, per-revision snapshots, stars, secret)
31- Reactions (8 canonical emoji) on issues, PRs, and comments
32- Mentions, notifications, personalised activity feed, user follows, profile READMEs
33- Closing keywords auto-close issues on PR merge
34
35### AI-native
36- AI code review blocks merges on fail (`requireAiApproval` branch protection)
37- AI security review (Sonnet 4) + 15-pattern secret scanner on every push
38- AI commit messages, PR summaries, changelogs (per-range viewer + on release)
39- AI merge-conflict resolver, AI incident responder (auto-issue on deploy fail)
40- AI explain-this-codebase (per-commit cached Markdown)
41- AI-generated failing test stubs
42- AI dependency updater (opens a PR with a bump table)
43- Copilot-style completion endpoint (`POST /api/copilot/completions`, Haiku, LRU-cached)
44- AI chat — global and repo-scoped
45
46### Automation
47- Workflow runner — `.gluecron/workflows/*.yml` auto-discovered on push, Bun subprocess executor, per-step timeouts, size-capped logs
48- Outbound webhooks (HMAC-signed) on push / issue / PR / star
49- Inbound GateTest callback (bearer or HMAC)
50- Commit status API — external CI POSTs per-commit statuses, combined rollup
51- Auto-repair engine (rewrites broken commits when `ANTHROPIC_API_KEY` is set)
52- Autopilot background ticker — mirror sync, merge-queue processing, weekly email digests, advisory rescans (opt out via `AUTOPILOT_DISABLED=1`)
53- Dependabot-equivalent dep bumper + security advisories scanner
54- CODEOWNERS auto-sync with team-based resolution (`@org/team`)
55
56### Platform
57- Organizations + teams (owner / admin / member roles, team-based CODEOWNERS)
58- 2FA / TOTP with recovery codes
59- WebAuthn / passkeys
60- Enterprise SSO via OIDC (Okta, Azure AD, Auth0, Google Workspace)
61- App marketplace + GitHub-Apps-equivalent bot identities (`ghi_` install tokens, scoped permissions)
62- Package registry (npm protocol — packument / tarball / publish / yank)
63- Pages / static hosting (serves from `gh-pages` branch)
64- Environments with protected approvals (reviewer-gated deploys, branch-glob restrictions)
65- Sponsors (tier management — payment rails deferred)
66- Personal dashboard, explore, global search, insights, releases
67- REST + GraphQL APIs (queries only on GraphQL)
68- Official CLI (`cli/gluecron.ts`) — single-file Bun binary
69- VS Code extension (`vscode-extension/`) — explain / open-on-web / semantic search / test generation
70- Mobile PWA (manifest + offline-capable service worker). Native iOS/Android apps are not built.
71- Command palette (Cmd+K), keyboard shortcuts, dark + light themes
72
73### Observability
74- Rate limiting, request-ID tracing, `/healthz`, `/readyz`, `/metrics`
75- Audit log (personal + per-repo)
76- Traffic analytics per repo (views + clones, 7/14/30/90d windows, SHA-truncated IP uniqueness)
77- Org-wide insights dashboard (green rate, PR/issue counts, per-repo breakdown)
78- Site admin panel — user management, system flags (`registration_locked`, `site_banner_*`, `read_only_mode`), billing overrides
79- Billing + quotas (free / pro / team / enterprise seeded plans)
80- Email notifications (provider-pluggable: log default, Resend in prod) + opt-in weekly digest
81
82For the full shipped-vs-missing scorecard and internal roadmap, see [`BUILD_BIBLE.md`](./BUILD_BIBLE.md).
83
84## Quick start
685
786```bash
887bun install
9bun dev
88bun dev # hot-reload dev server
89bun run db:migrate # run database migrations
90bun test # run the test suite
1091```
1192
1293Then visit `http://localhost:3000` to register and create your first repository.
1394
14## Features
15
16- **Git hosting** — clone, push, fetch via Smart HTTP protocol
17- **Web code browser** — file tree, syntax-highlighted source, commit log, unified diffs
18- **Authentication** — registration, login, sessions with bcrypt password hashing
19- **User profiles** — avatar, bio, public repository listing
20- **Repository management** — create repos via web UI, public/private visibility
21- **Branch switching** — dropdown to navigate between branches
22- **Stars** — star/unstar repositories
23- **SSH keys** — add/remove SSH keys for your account
24- **GateTest integration** — automated code scanning on every push
25- **Crontech deploy** — trigger deploys on push to main
95You'll need at minimum a `DATABASE_URL` pointing at Postgres. Everything AI-flavoured gracefully degrades without `ANTHROPIC_API_KEY`. See [`.env.example`](./.env.example) for the full list.
2696
2797## Stack
2898
29Bun + Hono + Drizzle ORM + Neon (PostgreSQL)
99- **Runtime:** Bun
100- **Framework:** Hono (with JSX for server-rendered views)
101- **Database:** Drizzle ORM + Neon (PostgreSQL)
102- **Git:** Smart HTTP protocol via git CLI subprocesses
103
104## Architecture (top-level)
105
106```
107src/
108 index.ts Bun server entry
109 app.tsx Hono composition + error handlers
110 db/ Drizzle schema + lazy connection + migrations
111 git/ repository.ts (tree/blob/commits/diff/blame/...) + protocol.ts
112 hooks/ post-receive (GateTest, Crontech, webhooks, CODEOWNERS)
113 lib/ auth, config, markdown, highlight, AI helpers, autopilot, ...
114 middleware/ softAuth / requireAuth / rate-limit / request-context
115 routes/ git, api, web, issues, pulls, editor, settings, webhooks, ...
116 views/ layout, components, landing, reactions
117```
118
119Full file inventory lives in [`CLAUDE.md`](./CLAUDE.md).
120
121## Integrations (the green ecosystem)
122
123- **GateTest** — every `git push` POSTs to `https://gatetest.ai/api/events/push`; inbound results accepted at `POST /api/hooks/gatetest` (bearer or HMAC).
124- **Crontech** — deploys trigger on push to the default branch (`https://crontech.ai/api/hooks/gluecron/push`). Opt out per repo via `autoDeployEnabled`.
125- **Webhooks** — user-registered URLs receive HMAC-signed payloads on push / issue / PR / star events.
126
127## Deployment
128
129Gluecron is deployed via **Crontech**, with Neon Postgres as the database. See [`DEPLOY.md`](./DEPLOY.md) for step-by-step instructions, environment variables, and the post-deploy verification checklist.
130
131## License
132
133See [`LICENSE`](./LICENSE).
Addedsrc/__tests__/demo-seed.test.ts+87−0View fileUnifiedSplit
1/**
2 * Pure-helper tests for src/lib/demo-seed.ts. We never touch the DB here —
3 * the content builders and DEMO_USERNAME constant are fully deterministic
4 * and are the only bits exercised.
5 */
6
7import { describe, it, expect } from "bun:test";
8import {
9 DEMO_USERNAME,
10 buildHelloPythonFiles,
11 buildTodoApiFiles,
12 buildDesignDocsFiles,
13 __test,
14} from "../lib/demo-seed";
15
16describe("DEMO_USERNAME", () => {
17 it('equals "demo"', () => {
18 expect(DEMO_USERNAME).toBe("demo");
19 });
20});
21
22describe("buildHelloPythonFiles", () => {
23 const files = buildHelloPythonFiles();
24
25 it("returns a non-empty record with the expected filenames", () => {
26 const keys = Object.keys(files);
27 expect(keys.length).toBeGreaterThan(0);
28 expect(keys).toContain("README.md");
29 expect(keys).toContain("main.py");
30 expect(keys).toContain("requirements.txt");
31 });
32
33 it("README.md mentions the repo name", () => {
34 expect(files["README.md"]).toContain("hello-python");
35 });
36
37 it("main.py is non-empty", () => {
38 expect(files["main.py"].length).toBeGreaterThan(0);
39 expect(files["main.py"]).toContain("def ");
40 });
41});
42
43describe("buildTodoApiFiles", () => {
44 const files = buildTodoApiFiles();
45
46 it("returns a non-empty record with the expected filenames", () => {
47 const keys = Object.keys(files);
48 expect(keys.length).toBeGreaterThan(0);
49 expect(keys).toContain("README.md");
50 expect(keys).toContain("package.json");
51 expect(keys).toContain("src/index.ts");
52 });
53
54 it("README.md mentions the repo name", () => {
55 expect(files["README.md"]).toContain("todo-api");
56 });
57
58 it("package.json is valid JSON", () => {
59 expect(() => JSON.parse(files["package.json"])).not.toThrow();
60 const parsed = JSON.parse(files["package.json"]);
61 expect(parsed.name).toBe("todo-api");
62 });
63});
64
65describe("buildDesignDocsFiles", () => {
66 const files = buildDesignDocsFiles();
67
68 it("returns a non-empty record with the expected filenames", () => {
69 const keys = Object.keys(files);
70 expect(keys.length).toBeGreaterThan(0);
71 expect(keys).toContain("README.md");
72 expect(keys).toContain("docs/architecture.md");
73 expect(keys).toContain("docs/adr-001.md");
74 });
75
76 it("README.md mentions the repo name", () => {
77 expect(files["README.md"]).toContain("design-docs");
78 });
79});
80
81describe("__test bundle", () => {
82 it("re-exports the three content builders", () => {
83 expect(typeof __test.buildHelloPythonFiles).toBe("function");
84 expect(typeof __test.buildTodoApiFiles).toBe("function");
85 expect(typeof __test.buildDesignDocsFiles).toBe("function");
86 });
87});
Modifiedsrc/index.ts+7−0View fileUnifiedSplit
33import { config } from "./lib/config";
44import { startWorker } from "./lib/workflow-runner";
55import { startAutopilot } from "./lib/autopilot";
6import { ensureDemoContent } from "./lib/demo-seed";
67
78// Ensure repos directory exists
89await mkdir(config.gitReposPath, { recursive: true });
1516// advisory rescans. No-op when AUTOPILOT_DISABLED=1.
1617startAutopilot();
1718
19// Opt-in demo content seed on boot (DEMO_SEED_ON_BOOT=1). Idempotent, never
20// throws — safe to run on every start.
21if (process.env.DEMO_SEED_ON_BOOT === "1") {
22 void ensureDemoContent().catch(() => {});
23}
24
1825console.log(`
1926 gluecron v0.1.0
2027 ──────────────────────
Addedsrc/lib/demo-seed.ts+656−0View fileUnifiedSplit
1/**
2 * Demo seed — idempotently creates a `demo` user plus three public demo repos
3 * (`hello-python`, `todo-api`, `design-docs`) each with an initial commit,
4 * one open issue, and (for `todo-api`) a closed pull request.
5 *
6 * Requirements:
7 * - Never throws. Every DB insert + subprocess call is wrapped in try/catch
8 * and errors are pushed into the `errors` array on the result.
9 * - Idempotent. A second call returns `created.user = false`, `repos = []`.
10 * - Fast-path: when the demo user already exists and all three repos are
11 * already recorded in the DB, returns immediately without side effects
12 * (unless `opts.force === true`).
13 * - Imports (never modifies) locked helpers: `hashPassword`, `initBareRepo`,
14 * `bootstrapRepository`.
15 *
16 * Content builders (`buildHelloPythonFiles`, `buildTodoApiFiles`,
17 * `buildDesignDocsFiles`) are pure — they return file-path → file-contents
18 * records and are unit-tested directly. They're re-exported via `__test`
19 * for convenience.
20 */
21
22import { and, eq } from "drizzle-orm";
23import { db } from "../db";
24import {
25 users,
26 repositories,
27 issues,
28 pullRequests,
29} from "../db/schema";
30import { hashPassword } from "./auth";
31import { initBareRepo, getRepoPath } from "../git/repository";
32import { bootstrapRepository } from "./repo-bootstrap";
33
34export const DEMO_USERNAME = "demo" as const;
35const DEMO_EMAIL = "demo@gluecron.local";
36const DEMO_DISPLAY_NAME = "Demo Account";
37const DEMO_AUTHOR_NAME = "Demo";
38const DEMO_AUTHOR_EMAIL = "demo@gluecron.local";
39
40export interface DemoSeedResult {
41 demoUser: { id: string; username: string } | null;
42 repos: Array<{ name: string; url: string }>;
43 created: {
44 user: boolean;
45 repos: string[];
46 issues: number;
47 prs: number;
48 };
49 errors: string[];
50}
51
52/* ------------------------------------------------------------------------ */
53/* Content builders (pure) */
54/* ------------------------------------------------------------------------ */
55
56export function buildHelloPythonFiles(): Record<string, string> {
57 return {
58 "README.md": `# hello-python
59
60A tiny demo Python app, seeded by GlueCron to showcase the UI.
61
62## Run
63
64\`\`\`bash
65pip install -r requirements.txt
66python main.py
67\`\`\`
68
69This repo belongs to the \`demo\` account. Feel free to browse — it's
70regenerated on demand from the demo seeder.
71`,
72 "main.py": `"""hello-python — demo entrypoint."""
73
74
75def greet(name: str) -> str:
76 return f"Hello, {name}!"
77
78
79def main() -> None:
80 print(greet("GlueCron"))
81
82
83if __name__ == "__main__":
84 main()
85`,
86 "requirements.txt": `# No third-party deps yet — kept intentionally minimal.
87# Add packages below as "name==version" once needed.
88`,
89 };
90}
91
92export function buildTodoApiFiles(): Record<string, string> {
93 const pkg = {
94 name: "todo-api",
95 version: "0.1.0",
96 private: true,
97 description: "Demo todo API — GlueCron seeded sample",
98 main: "src/index.ts",
99 scripts: {
100 dev: "bun run src/index.ts",
101 start: "bun run src/index.ts",
102 },
103 dependencies: {
104 hono: "^4.6.0",
105 },
106 devDependencies: {
107 typescript: "^5.4.0",
108 },
109 };
110
111 return {
112 "README.md": `# todo-api
113
114A minimal Hono-based todo API, seeded by GlueCron as a demo.
115
116## Endpoints
117
118- \`GET /todos\` — list todos
119- \`POST /todos\` — create todo
120- \`GET /health\` — health probe
121
122## Run
123
124\`\`\`bash
125bun install
126bun run dev
127\`\`\`
128`,
129 "package.json": JSON.stringify(pkg, null, 2) + "\n",
130 "src/index.ts": `import { Hono } from "hono";
131
132type Todo = { id: number; title: string; done: boolean };
133
134const app = new Hono();
135const todos: Todo[] = [
136 { id: 1, title: "Try GlueCron", done: false },
137 { id: 2, title: "Push a commit", done: false },
138];
139
140app.get("/health", (c) => c.json({ ok: true }));
141app.get("/todos", (c) => c.json(todos));
142
143app.post("/todos", async (c) => {
144 const body = await c.req.json<{ title?: string }>();
145 if (!body?.title) return c.json({ error: "title required" }, 400);
146 const todo: Todo = { id: todos.length + 1, title: body.title, done: false };
147 todos.push(todo);
148 return c.json(todo, 201);
149});
150
151export default app;
152`,
153 };
154}
155
156export function buildDesignDocsFiles(): Record<string, string> {
157 return {
158 "README.md": `# design-docs
159
160Architecture notes and ADRs for the \`demo\` org's sample project.
161
162Browse:
163
164- [Architecture overview](docs/architecture.md)
165- [ADR-001 — Choose Hono for the HTTP layer](docs/adr-001.md)
166`,
167 "docs/architecture.md": `# Architecture overview
168
169## Goals
170
171- Keep the surface small.
172- Prefer boring, well-understood primitives.
173- Fast cold-start on Bun.
174
175## Components
176
177- **HTTP layer:** Hono.
178- **Data:** PostgreSQL via Drizzle.
179- **Jobs:** in-process, cron-driven.
180
181## Non-goals
182
183- Multi-tenant isolation at the data layer.
184- Horizontal scale-out (v1 is single-node).
185`,
186 "docs/adr-001.md": `# ADR-001 — Choose Hono for the HTTP layer
187
188- **Status:** accepted
189- **Date:** 2026-01-15
190
191## Context
192
193We need an HTTP framework that runs on Bun natively, has JSX server-side
194rendering, and minimal dependencies.
195
196## Decision
197
198Adopt Hono as the HTTP layer.
199
200## Consequences
201
202- Tiny runtime footprint.
203- Ecosystem is smaller than Express; we'll write middleware ourselves
204 where nothing exists.
205
206## Rollout
207
208Migrate the existing Express routes to Hono over two sprints. Keep the
209legacy handler importable until all routes are ported.
210`,
211 };
212}
213
214/* ------------------------------------------------------------------------ */
215/* Git plumbing — write an initial commit from a file map */
216/* ------------------------------------------------------------------------ */
217
218interface SpawnResult {
219 stdout: string;
220 stderr: string;
221 exitCode: number;
222}
223
224async function spawnSafe(
225 cmd: string[],
226 cwd: string,
227 stdin?: string | Uint8Array,
228 env?: Record<string, string>
229): Promise<SpawnResult> {
230 try {
231 const proc = Bun.spawn(cmd, {
232 cwd,
233 stdout: "pipe",
234 stderr: "pipe",
235 stdin: stdin !== undefined ? "pipe" : undefined,
236 env: { ...process.env, ...(env || {}) },
237 });
238 if (stdin !== undefined && proc.stdin) {
239 const bytes =
240 typeof stdin === "string" ? new TextEncoder().encode(stdin) : stdin;
241 (proc.stdin as any).write(bytes);
242 (proc.stdin as any).end();
243 }
244 const [stdout, stderr] = await Promise.all([
245 new Response(proc.stdout).text(),
246 new Response(proc.stderr).text(),
247 ]);
248 const exitCode = await proc.exited;
249 return { stdout: stdout.trim(), stderr, exitCode };
250 } catch (err: any) {
251 return { stdout: "", stderr: String(err?.message || err), exitCode: -1 };
252 }
253}
254
255/**
256 * Write an initial commit to the bare repo at `repoDir` on branch `main`
257 * containing the given file map. Uses git plumbing (hash-object + update-index
258 * via a transient index + write-tree + commit-tree + update-ref). Mirrors the
259 * pattern in `dep-updater.ts` / `createOrUpdateFileOnBranch`. Returns the
260 * new commit sha on success.
261 */
262async function writeInitialCommit(
263 repoDir: string,
264 files: Record<string, string>,
265 message: string,
266 authorName: string,
267 authorEmail: string
268): Promise<{ commitSha: string } | { error: string }> {
269 const tmpIndex = `${repoDir}/index.demo-seed.${process.pid}.${Date.now()}.${Math.random()
270 .toString(36)
271 .slice(2)}`;
272 const baseEnv = {
273 GIT_INDEX_FILE: tmpIndex,
274 GIT_AUTHOR_NAME: authorName,
275 GIT_AUTHOR_EMAIL: authorEmail,
276 GIT_COMMITTER_NAME: authorName,
277 GIT_COMMITTER_EMAIL: authorEmail,
278 };
279
280 const cleanup = async () => {
281 try {
282 const { unlink } = await import("fs/promises");
283 await unlink(tmpIndex);
284 } catch {
285 /* ignore */
286 }
287 };
288
289 try {
290 // 1. Hash each file → blob sha, then stage via update-index --cacheinfo.
291 for (const [path, contents] of Object.entries(files)) {
292 const hashed = await spawnSafe(
293 ["git", "hash-object", "-w", "--stdin"],
294 repoDir,
295 contents
296 );
297 if (hashed.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(hashed.stdout)) {
298 await cleanup();
299 return { error: `hash-object failed for ${path}: ${hashed.stderr}` };
300 }
301 const blobSha = hashed.stdout;
302
303 const upd = await spawnSafe(
304 [
305 "git",
306 "update-index",
307 "--add",
308 "--cacheinfo",
309 `100644,${blobSha},${path}`,
310 ],
311 repoDir,
312 undefined,
313 baseEnv
314 );
315 if (upd.exitCode !== 0) {
316 await cleanup();
317 return { error: `update-index failed for ${path}: ${upd.stderr}` };
318 }
319 }
320
321 // 2. write-tree → tree sha.
322 const wt = await spawnSafe(
323 ["git", "write-tree"],
324 repoDir,
325 undefined,
326 baseEnv
327 );
328 if (wt.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(wt.stdout)) {
329 await cleanup();
330 return { error: `write-tree failed: ${wt.stderr}` };
331 }
332 const treeSha = wt.stdout;
333
334 // 3. commit-tree (no parent — initial commit).
335 const commit = await spawnSafe(
336 ["git", "commit-tree", treeSha, "-m", message],
337 repoDir,
338 undefined,
339 baseEnv
340 );
341 if (commit.exitCode !== 0 || !/^[0-9a-f]{40}$/.test(commit.stdout)) {
342 await cleanup();
343 return { error: `commit-tree failed: ${commit.stderr}` };
344 }
345 const commitSha = commit.stdout;
346
347 // 4. update-ref refs/heads/main.
348 const upd = await spawnSafe(
349 ["git", "update-ref", "refs/heads/main", commitSha],
350 repoDir
351 );
352 if (upd.exitCode !== 0) {
353 await cleanup();
354 return { error: `update-ref failed: ${upd.stderr}` };
355 }
356
357 await cleanup();
358 return { commitSha };
359 } catch (err: any) {
360 await cleanup();
361 return { error: String(err?.message || err) };
362 }
363}
364
365/* ------------------------------------------------------------------------ */
366/* Seed orchestration */
367/* ------------------------------------------------------------------------ */
368
369interface DemoRepoSpec {
370 name: string;
371 description: string;
372 files: Record<string, string>;
373 issueTitle: string;
374 issueBody?: string;
375 seedClosedPr?: { title: string; body?: string };
376}
377
378function demoRepoSpecs(): DemoRepoSpec[] {
379 return [
380 {
381 name: "hello-python",
382 description: "Tiny Python demo app seeded by GlueCron.",
383 files: buildHelloPythonFiles(),
384 issueTitle: "Add rate limiting",
385 issueBody:
386 "The `/greet` endpoint has no rate limiting. We should add a simple token-bucket.",
387 },
388 {
389 name: "todo-api",
390 description: "Minimal Hono todo API, seeded as a demo.",
391 files: buildTodoApiFiles(),
392 issueTitle: "Dark mode broken on mobile",
393 issueBody:
394 "On iOS Safari, the dark-mode toggle flickers to light for ~200ms on first paint.",
395 seedClosedPr: {
396 title: "feat: add /health endpoint",
397 body: "Adds a trivial liveness probe at `GET /health` returning `{ ok: true }`.",
398 },
399 },
400 {
401 name: "design-docs",
402 description: "Architecture notes + ADRs for the demo project.",
403 files: buildDesignDocsFiles(),
404 issueTitle: "Clarify ADR-001 rollout section",
405 issueBody:
406 "The rollout section of ADR-001 mentions 'two sprints' — we should pin a concrete date range.",
407 },
408 ];
409}
410
411async function findDemoUser(): Promise<{ id: string; username: string } | null> {
412 try {
413 const [row] = await db
414 .select({ id: users.id, username: users.username })
415 .from(users)
416 .where(eq(users.username, DEMO_USERNAME))
417 .limit(1);
418 return row ?? null;
419 } catch {
420 return null;
421 }
422}
423
424async function findDemoRepo(
425 ownerId: string,
426 name: string
427): Promise<{ id: string } | null> {
428 try {
429 const [row] = await db
430 .select({ id: repositories.id })
431 .from(repositories)
432 .where(
433 and(eq(repositories.ownerId, ownerId), eq(repositories.name, name))
434 )
435 .limit(1);
436 return row ?? null;
437 } catch {
438 return null;
439 }
440}
441
442/**
443 * Idempotently create the demo user + three demo repos. Never throws.
444 */
445export async function ensureDemoContent(opts?: {
446 force?: boolean;
447}): Promise<DemoSeedResult> {
448 const force = !!opts?.force;
449 const result: DemoSeedResult = {
450 demoUser: null,
451 repos: [],
452 created: { user: false, repos: [], issues: 0, prs: 0 },
453 errors: [],
454 };
455
456 const specs = demoRepoSpecs();
457
458 // 1. Fast-path — demo user + all three repos already exist and !force.
459 if (!force) {
460 const existing = await findDemoUser();
461 if (existing) {
462 let allPresent = true;
463 for (const spec of specs) {
464 const repo = await findDemoRepo(existing.id, spec.name);
465 if (!repo) {
466 allPresent = false;
467 break;
468 }
469 }
470 if (allPresent) {
471 result.demoUser = existing;
472 result.repos = specs.map((s) => ({
473 name: s.name,
474 url: `/${DEMO_USERNAME}/${s.name}`,
475 }));
476 return result;
477 }
478 }
479 }
480
481 // 2. Resolve or create the demo user.
482 let demoUser = await findDemoUser();
483 if (!demoUser) {
484 try {
485 // Random password → login effectively disabled.
486 const randBytes = crypto.getRandomValues(new Uint8Array(32));
487 const randomPassword = Array.from(randBytes)
488 .map((b) => b.toString(16).padStart(2, "0"))
489 .join("");
490 const passwordHash = await hashPassword(randomPassword);
491 const [inserted] = await db
492 .insert(users)
493 .values({
494 username: DEMO_USERNAME,
495 email: DEMO_EMAIL,
496 displayName: DEMO_DISPLAY_NAME,
497 passwordHash,
498 })
499 .returning({ id: users.id, username: users.username });
500 if (inserted) {
501 demoUser = inserted;
502 result.created.user = true;
503 }
504 } catch (err: any) {
505 result.errors.push(
506 `create demo user: ${String(err?.message || err)}`
507 );
508 }
509 }
510
511 if (!demoUser) {
512 // Can't proceed without a user row.
513 return result;
514 }
515 result.demoUser = demoUser;
516
517 // 3. For each spec: ensure bare repo + initial commit + DB row + bootstrap
518 // + one open issue (+ closed PR on todo-api).
519 for (const spec of specs) {
520 result.repos.push({
521 name: spec.name,
522 url: `/${DEMO_USERNAME}/${spec.name}`,
523 });
524
525 const existingRepo = await findDemoRepo(demoUser.id, spec.name);
526 if (existingRepo && !force) {
527 continue;
528 }
529
530 // Create bare repo on disk (ok if already present — initBareRepo is
531 // git init --bare which is idempotent).
532 let diskPath: string;
533 try {
534 diskPath = await initBareRepo(DEMO_USERNAME, spec.name);
535 } catch (err: any) {
536 result.errors.push(
537 `initBareRepo(${spec.name}): ${String(err?.message || err)}`
538 );
539 continue;
540 }
541
542 // Write initial commit only if HEAD doesn't already resolve.
543 const repoDir = getRepoPath(DEMO_USERNAME, spec.name);
544 const headCheck = await spawnSafe(
545 ["git", "rev-parse", "--verify", "refs/heads/main"],
546 repoDir
547 );
548 if (headCheck.exitCode !== 0) {
549 const wrote = await writeInitialCommit(
550 repoDir,
551 spec.files,
552 "Initial commit",
553 DEMO_AUTHOR_NAME,
554 DEMO_AUTHOR_EMAIL
555 );
556 if ("error" in wrote) {
557 result.errors.push(
558 `writeInitialCommit(${spec.name}): ${wrote.error}`
559 );
560 // Continue — we still want the DB row so the UI can show it.
561 }
562 }
563
564 // Insert DB row (skip if it already exists — e.g. partial prior run).
565 let repoId: string | null = existingRepo?.id ?? null;
566 if (!repoId) {
567 try {
568 const [inserted] = await db
569 .insert(repositories)
570 .values({
571 name: spec.name,
572 ownerId: demoUser.id,
573 description: spec.description,
574 isPrivate: false,
575 defaultBranch: "main",
576 diskPath,
577 })
578 .returning({ id: repositories.id });
579 if (inserted) {
580 repoId = inserted.id;
581 result.created.repos.push(spec.name);
582 }
583 } catch (err: any) {
584 result.errors.push(
585 `insert repo(${spec.name}): ${String(err?.message || err)}`
586 );
587 }
588 }
589
590 if (!repoId) continue;
591
592 // Green-ecosystem bootstrap (labels, settings, branch protection, welcome
593 // issue). Wrapped — bootstrap internally tolerates duplicates but we
594 // don't want any surprise throw to poison the seeder.
595 try {
596 await bootstrapRepository({
597 repositoryId: repoId,
598 ownerUserId: demoUser.id,
599 });
600 } catch (err: any) {
601 result.errors.push(
602 `bootstrapRepository(${spec.name}): ${String(err?.message || err)}`
603 );
604 }
605
606 // One open issue per repo.
607 try {
608 await db.insert(issues).values({
609 repositoryId: repoId,
610 authorId: demoUser.id,
611 title: spec.issueTitle,
612 body: spec.issueBody ?? null,
613 state: "open",
614 });
615 result.created.issues += 1;
616 } catch (err: any) {
617 result.errors.push(
618 `insert issue(${spec.name}): ${String(err?.message || err)}`
619 );
620 }
621
622 // Closed PR on todo-api.
623 if (spec.seedClosedPr) {
624 try {
625 await db.insert(pullRequests).values({
626 repositoryId: repoId,
627 authorId: demoUser.id,
628 title: spec.seedClosedPr.title,
629 body: spec.seedClosedPr.body ?? null,
630 state: "closed",
631 baseBranch: "main",
632 headBranch: "demo/health-endpoint",
633 closedAt: new Date(),
634 });
635 result.created.prs += 1;
636 } catch (err: any) {
637 result.errors.push(
638 `insert PR(${spec.name}): ${String(err?.message || err)}`
639 );
640 }
641 }
642 }
643
644 return result;
645}
646
647/* ------------------------------------------------------------------------ */
648/* Test-only exports */
649/* ------------------------------------------------------------------------ */
650
651export const __test = {
652 buildHelloPythonFiles,
653 buildTodoApiFiles,
654 buildDesignDocsFiles,
655 demoRepoSpecs,
656};
Modifiedsrc/routes/admin.tsx+61−1View fileUnifiedSplit
3737 getTickCount,
3838 runAutopilotTick,
3939} from "../lib/autopilot";
40import { ensureDemoContent, DEMO_USERNAME } from "../lib/demo-seed";
4041
4142const admin = new Hono<AuthEnv>();
4243admin.use("*", softAuth);
8081
8182 const admins = await listSiteAdmins();
8283
84 const msg = c.req.query("result") || c.req.query("error");
85 const isErr = !!c.req.query("error");
86
8387 return c.html(
8488 <Layout title="Admin — Gluecron" user={user}>
8589 <h2>Site admin</h2>
8690
91 {msg && (
92 <div
93 class={isErr ? "auth-error" : "banner"}
94 style="margin-bottom:16px"
95 >
96 {decodeURIComponent(msg)}
97 </div>
98 )}
99
87100 <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px">
88101 <div class="panel" style="padding:12px;text-align:center">
89102 <div style="font-size:22px;font-weight:700">{Number(uc?.n || 0)}</div>
105118 </div>
106119 </div>
107120
108 <div style="display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-bottom:20px">
121 <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin-bottom:20px">
109122 <a href="/admin/users" class="btn">
110123 Manage users
111124 </a>
121134 <a href="/admin/sso" class="btn">
122135 Enterprise SSO
123136 </a>
137 <a href="/admin/autopilot" class="btn">
138 Autopilot
139 </a>
140 <form
141 method="post"
142 action="/admin/demo/reseed"
143 style="display:contents"
144 >
145 <button class="btn" type="submit" title="Idempotently (re)create demo user + 3 sample repos">
146 Reseed demo
147 </button>
148 </form>
124149 </div>
125150
126151 <h3>Recent signups</h3>
698723 );
699724});
700725
726admin.post("/admin/demo/reseed", async (c) => {
727 const g = await gate(c);
728 if (g instanceof Response) return g;
729 const { user } = g;
730 try {
731 const result = await ensureDemoContent({ force: true });
732 const summary = `Demo reseed: user=${result.created.user ? "created" : "existed"}, repos=${result.created.repos.length}, issues=${result.created.issues}, prs=${result.created.prs}${result.errors.length ? `, errors=${result.errors.length}` : ""}`;
733 await audit({
734 userId: user.id,
735 action: "admin.demo.reseed",
736 targetType: "user",
737 targetId: result.demoUser?.id ?? "demo",
738 metadata: {
739 createdUser: result.created.user,
740 createdRepos: result.created.repos,
741 createdIssues: result.created.issues,
742 createdPrs: result.created.prs,
743 errors: result.errors.slice(0, 5),
744 },
745 });
746 return c.redirect(`/admin?result=${encodeURIComponent(summary)}`);
747 } catch (err) {
748 const message = err instanceof Error ? err.message : String(err);
749 return c.redirect(
750 `/admin?error=${encodeURIComponent("Demo reseed failed: " + message)}`
751 );
752 }
753});
754
755// Public jump-to-demo — redirects to the first demo repo if present,
756// otherwise to /explore. Useful as a landing-page-linkable "try it" URL.
757admin.get("/demo", (c) => {
758 return c.redirect(`/${DEMO_USERNAME}/hello-python`);
759});
760
701761admin.post("/admin/autopilot/run", async (c) => {
702762 const g = await gate(c);
703763 if (g instanceof Response) return g;
704764