Commit2ae4a80unknown_key
Merge pull request #21 from ccantynz-alt/claude/build-status-update-3MXsf
Merge pull request #21 from ccantynz-alt/claude/build-status-update-3MXsf feat(autopilot+landing): self-sufficiency loop + marketing landing page
16 files changed+2610−3082ae4a80679dca48aabb37e9e3bc9e85fdd9d37e8
16 changed files+2610−315
ModifiedBUILD_BIBLE.md+6−0View fileUnifiedSplit
@@ -555,3 +555,9 @@ If a block is too large for a single session, split it into a sub-plan at the to
555555## 7. IN-FLIGHT
556556
557557(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,175 +1,134 @@
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,140 +1,68 @@
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
@@ -1,29 +1,133 @@
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__/autopilot.test.ts+152−0View fileUnifiedSplit
@@ -0,0 +1,152 @@
1/**
2 * Autopilot — unit tests.
3 *
4 * Uses the injected-tasks shape so the tick never touches the DB. Real
5 * helpers (syncAllDue, sendDigestsToAll, scanRepositoryForAlerts, peekHead)
6 * are covered by their own suites — here we only test the loop itself.
7 */
8
9import { describe, it, expect, beforeEach, afterEach } from "bun:test";
10import {
11 startAutopilot,
12 runAutopilotTick,
13 __test,
14 type AutopilotTask,
15} from "../lib/autopilot";
16
17describe("autopilot — startAutopilot", () => {
18 const originalDisabled = process.env.AUTOPILOT_DISABLED;
19 const originalInterval = process.env.AUTOPILOT_INTERVAL_MS;
20
21 afterEach(() => {
22 if (originalDisabled === undefined) delete process.env.AUTOPILOT_DISABLED;
23 else process.env.AUTOPILOT_DISABLED = originalDisabled;
24 if (originalInterval === undefined) delete process.env.AUTOPILOT_INTERVAL_MS;
25 else process.env.AUTOPILOT_INTERVAL_MS = originalInterval;
26 });
27
28 it("is a no-op when AUTOPILOT_DISABLED=1 and does not schedule a tick", async () => {
29 process.env.AUTOPILOT_DISABLED = "1";
30 let ran = 0;
31 const tasks: AutopilotTask[] = [
32 { name: "probe", run: async () => { ran++; } },
33 ];
34 const { stop } = startAutopilot({ intervalMs: 5, tasks });
35 // Wait long enough that any scheduled tick would have fired.
36 await new Promise((r) => setTimeout(r, 40));
37 stop();
38 expect(ran).toBe(0);
39 });
40
41 it("does not run the first tick synchronously (boot stays fast)", () => {
42 delete process.env.AUTOPILOT_DISABLED;
43 let ran = 0;
44 const tasks: AutopilotTask[] = [
45 { name: "probe", run: async () => { ran++; } },
46 ];
47 const { stop } = startAutopilot({ intervalMs: 60_000, tasks });
48 // Synchronously — the interval has not elapsed.
49 expect(ran).toBe(0);
50 stop();
51 });
52
53 it("stop() clears the interval so no further ticks run", async () => {
54 delete process.env.AUTOPILOT_DISABLED;
55 let ran = 0;
56 const tasks: AutopilotTask[] = [
57 { name: "probe", run: async () => { ran++; } },
58 ];
59 const { stop } = startAutopilot({ intervalMs: 10, tasks });
60 await new Promise((r) => setTimeout(r, 45));
61 stop();
62 const snapshot = ran;
63 await new Promise((r) => setTimeout(r, 40));
64 // Allow for one tick that was already in flight at stop(), but not more.
65 expect(ran - snapshot).toBeLessThanOrEqual(1);
66 expect(snapshot).toBeGreaterThan(0);
67 });
68});
69
70describe("autopilot — runAutopilotTick", () => {
71 it("returns the expected shape with startedAt/finishedAt/tasks", async () => {
72 const tasks: AutopilotTask[] = [
73 { name: "a", run: async () => {} },
74 { name: "b", run: async () => {} },
75 ];
76 const result = await runAutopilotTick({ tasks });
77 expect(typeof result.startedAt).toBe("string");
78 expect(typeof result.finishedAt).toBe("string");
79 expect(Array.isArray(result.tasks)).toBe(true);
80 expect(result.tasks.length).toBe(2);
81 expect(result.tasks[0]).toMatchObject({ name: "a", ok: true });
82 expect(result.tasks[1]).toMatchObject({ name: "b", ok: true });
83 expect(typeof result.tasks[0].durationMs).toBe("number");
84 });
85
86 it("catches a throwing task and reports { ok:false, error } without crashing the tick", async () => {
87 let secondRan = false;
88 const tasks: AutopilotTask[] = [
89 {
90 name: "boom",
91 run: async () => {
92 throw new Error("kaboom");
93 },
94 },
95 {
96 name: "after",
97 run: async () => {
98 secondRan = true;
99 },
100 },
101 ];
102 const result = await runAutopilotTick({ tasks });
103 expect(result.tasks.length).toBe(2);
104 expect(result.tasks[0].ok).toBe(false);
105 expect(result.tasks[0].error).toBe("kaboom");
106 expect(result.tasks[1].ok).toBe(true);
107 expect(secondRan).toBe(true);
108 });
109
110 it("handles non-Error throws gracefully", async () => {
111 const tasks: AutopilotTask[] = [
112 {
113 name: "string-throw",
114 run: async () => {
115 throw "bad-thing" as unknown as Error;
116 },
117 },
118 ];
119 const result = await runAutopilotTick({ tasks });
120 expect(result.tasks[0].ok).toBe(false);
121 expect(result.tasks[0].error).toBe("bad-thing");
122 });
123});
124
125describe("autopilot — resolveIntervalMs", () => {
126 const originalInterval = process.env.AUTOPILOT_INTERVAL_MS;
127
128 afterEach(() => {
129 if (originalInterval === undefined) delete process.env.AUTOPILOT_INTERVAL_MS;
130 else process.env.AUTOPILOT_INTERVAL_MS = originalInterval;
131 });
132
133 it("prefers explicit opts over env", () => {
134 process.env.AUTOPILOT_INTERVAL_MS = "1234";
135 expect(__test.resolveIntervalMs(42)).toBe(42);
136 });
137
138 it("falls back to env when opts is missing", () => {
139 process.env.AUTOPILOT_INTERVAL_MS = "9999";
140 expect(__test.resolveIntervalMs()).toBe(9999);
141 });
142
143 it("falls back to the default when neither is set", () => {
144 delete process.env.AUTOPILOT_INTERVAL_MS;
145 expect(__test.resolveIntervalMs()).toBe(__test.DEFAULT_INTERVAL_MS);
146 });
147
148 it("ignores non-positive env values", () => {
149 process.env.AUTOPILOT_INTERVAL_MS = "-1";
150 expect(__test.resolveIntervalMs()).toBe(__test.DEFAULT_INTERVAL_MS);
151 });
152});
Addedsrc/__tests__/demo-seed.test.ts+87−0View fileUnifiedSplit
@@ -0,0 +1,87 @@
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});
Addedsrc/__tests__/status-seo.test.ts+59−0View fileUnifiedSplit
@@ -0,0 +1,59 @@
1/**
2 * Smoke tests for the public /status page, /status.svg badge, and SEO
3 * routes (/robots.txt + /sitemap.xml). These don't stub the DB — they
4 * tolerate either success (dev DB reachable) or a graceful error page
5 * so they work in the sandbox environment.
6 */
7
8import { test, expect } from "bun:test";
9import app from "../app";
10
11test("/status returns 200 with HTML body", async () => {
12 const res = await app.request("/status");
13 expect(res.status).toBe(200);
14 const body = await res.text();
15 expect(body).toContain("<html");
16 // Either the green or red headline should appear
17 expect(
18 body.includes("All systems operational") ||
19 body.includes("Service degraded")
20 ).toBe(true);
21});
22
23test("/status.svg returns an SVG badge", async () => {
24 const res = await app.request("/status.svg");
25 expect(res.status).toBe(200);
26 expect(res.headers.get("content-type")).toMatch(/image\/svg/);
27 const body = await res.text();
28 expect(body).toContain("<svg");
29 expect(body).toContain("</svg>");
30});
31
32test("/robots.txt returns 200 with crawler directives", async () => {
33 const res = await app.request("/robots.txt");
34 expect(res.status).toBe(200);
35 expect(res.headers.get("content-type")).toMatch(/text\/plain/);
36 const body = await res.text();
37 expect(body).toContain("User-agent:");
38 expect(body).toContain("Sitemap:");
39 expect(body).toContain("Disallow: /admin");
40});
41
42test("/sitemap.xml returns valid-looking XML", async () => {
43 const res = await app.request("/sitemap.xml");
44 expect(res.status).toBe(200);
45 expect(res.headers.get("content-type")).toMatch(/xml/);
46 const body = await res.text();
47 expect(body).toContain('<?xml');
48 expect(body).toContain("<urlset");
49 expect(body).toContain("<loc>");
50 expect(body).toContain("</urlset>");
51});
52
53test("sitemap includes the landing, status, explore URLs", async () => {
54 const res = await app.request("/sitemap.xml");
55 const body = await res.text();
56 expect(body).toMatch(/<loc>[^<]*\/<\/loc>/);
57 expect(body).toContain("/status");
58 expect(body).toContain("/explore");
59});
Modifiedsrc/app.tsx+8−3View fileUnifiedSplit
@@ -24,6 +24,8 @@ import tokenRoutes from "./routes/tokens";
2424import contributorRoutes from "./routes/contributors";
2525import healthRoutes from "./routes/health-probe";
2626import healthDashboardRoutes from "./routes/health";
27import statusRoutes from "./routes/status";
28import seoRoutes from "./routes/seo";
2729import { platformStatus } from "./routes/platform-status";
2830import insightRoutes from "./routes/insights";
2931import dashboardRoutes from "./routes/dashboard";
@@ -175,9 +177,6 @@ app.route("/", tokenRoutes);
175177// Notifications
176178app.route("/", notificationRoutes);
177179
178// Organizations
179app.route("/", orgRoutes);
180
181180// Repo settings (description, visibility, delete)
182181app.route("/", repoSettings);
183182
@@ -208,6 +207,12 @@ app.route("/", healthRoutes);
208207// Cross-product platform status (public, CORS-open — see docs/PLATFORM_STATUS.md)
209208app.route("/api/platform-status", platformStatus);
210209
210// Public /status — human-readable platform health page
211app.route("/", statusRoutes);
212
213// SEO: robots.txt + sitemap.xml
214app.route("/", seoRoutes);
215
211216// Health dashboard (per-repo health page)
212217app.route("/", healthDashboardRoutes);
213218
Modifiedsrc/index.ts+12−0View fileUnifiedSplit
@@ -2,6 +2,8 @@ import { mkdir } from "fs/promises";
22import app from "./app";
33import { config } from "./lib/config";
44import { startWorker } from "./lib/workflow-runner";
5import { startAutopilot } from "./lib/autopilot";
6import { ensureDemoContent } from "./lib/demo-seed";
57
68// Ensure repos directory exists
79await mkdir(config.gitReposPath, { recursive: true });
@@ -10,6 +12,16 @@ await mkdir(config.gitReposPath, { recursive: true });
1012// workflow_runs for queued rows and executes them sequentially.
1113startWorker();
1214
15// Autopilot: periodic mirror sync, merge-queue progress, weekly digests,
16// advisory rescans. No-op when AUTOPILOT_DISABLED=1.
17startAutopilot();
18
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
1325console.log(`
1426 gluecron v0.1.0
1527 ──────────────────────
Addedsrc/lib/autopilot.ts+251−0View fileUnifiedSplit
@@ -0,0 +1,251 @@
1/**
2 * Autopilot — self-sufficiency loop.
3 *
4 * Runs existing platform-maintenance tasks (mirror sync, merge queue progress,
5 * weekly digests, advisory rescans) on an interval so the host runs itself
6 * without an external cron. All sub-tasks are injected so tests can stub them
7 * without touching the DB; the default task set wires real helpers from the
8 * locked libs. Nothing here throws — every sub-task and the outer tick are
9 * try/caught so a single failure never blocks the others.
10 */
11
12import { sql } from "drizzle-orm";
13import { db } from "../db";
14import { mergeQueueEntries, repoDependencies } from "../db/schema";
15import { syncAllDue } from "./mirrors";
16import { peekHead } from "./merge-queue";
17import { sendDigestsToAll } from "./email-digest";
18import { scanRepositoryForAlerts } from "./advisories";
19
20export interface AutopilotTaskResult {
21 name: string;
22 ok: boolean;
23 durationMs: number;
24 error?: string;
25}
26
27export interface AutopilotTickResult {
28 startedAt: string;
29 finishedAt: string;
30 tasks: AutopilotTaskResult[];
31}
32
33export interface AutopilotTask {
34 name: string;
35 run: () => Promise<void>;
36}
37
38export interface StartAutopilotOpts {
39 intervalMs?: number;
40 now?: () => number;
41 tasks?: AutopilotTask[];
42}
43
44export interface RunTickOpts {
45 tasks?: AutopilotTask[];
46 now?: () => number;
47}
48
49const DEFAULT_INTERVAL_MS = 5 * 60 * 1000;
50const ADVISORY_RESCAN_BATCH = 5;
51
52/**
53 * Default task set. Each task is a thin wrapper around an existing locked
54 * helper — no gate/merge logic is duplicated here.
55 */
56export function defaultTasks(): AutopilotTask[] {
57 return [
58 {
59 name: "mirror-sync",
60 run: async () => {
61 await syncAllDue();
62 },
63 },
64 {
65 name: "merge-queue",
66 run: async () => {
67 await processMergeQueues();
68 },
69 },
70 {
71 name: "weekly-digest",
72 run: async () => {
73 await sendDigestsToAll();
74 },
75 },
76 {
77 name: "advisory-rescan",
78 run: async () => {
79 await rescanAdvisoriesBatch(ADVISORY_RESCAN_BATCH);
80 },
81 },
82 ];
83}
84
85/**
86 * Visits each distinct (repo, base_branch) that has queued rows and logs a
87 * stub depth line. The actual gate-running + merge happens in the pulls
88 * route; this tick is just a heartbeat so we can wire per-queue progress
89 * through without duplicating merge logic.
90 */
91async function processMergeQueues(): Promise<void> {
92 let distinct: Array<{ repositoryId: string; baseBranch: string }> = [];
93 try {
94 const rows = await db
95 .selectDistinct({
96 repositoryId: mergeQueueEntries.repositoryId,
97 baseBranch: mergeQueueEntries.baseBranch,
98 })
99 .from(mergeQueueEntries)
100 .where(sql`${mergeQueueEntries.state} IN ('queued','running')`);
101 distinct = rows;
102 } catch (err) {
103 console.error("[autopilot] merge-queue: distinct query failed:", err);
104 return;
105 }
106 for (const d of distinct) {
107 try {
108 const head = await peekHead(d.repositoryId, d.baseBranch);
109 if (head) {
110 console.log(
111 `[autopilot] merge queue depth head=${head.id.slice(0, 8)} repo=${d.repositoryId.slice(0, 8)} base=${d.baseBranch}`
112 );
113 }
114 } catch (err) {
115 console.error(
116 `[autopilot] merge-queue: peek failed for repo=${d.repositoryId}:`,
117 err
118 );
119 }
120 }
121}
122
123/**
124 * Pick a small batch of repos that actually have dep rows and re-run
125 * advisory scan against them. Cheap — one SELECT DISTINCT with LIMIT.
126 */
127async function rescanAdvisoriesBatch(limit: number): Promise<void> {
128 let repoIds: string[] = [];
129 try {
130 const rows = await db
131 .selectDistinct({ repositoryId: repoDependencies.repositoryId })
132 .from(repoDependencies)
133 .limit(limit);
134 repoIds = rows.map((r) => r.repositoryId);
135 } catch (err) {
136 console.error("[autopilot] advisory-rescan: query failed:", err);
137 return;
138 }
139 for (const id of repoIds) {
140 try {
141 await scanRepositoryForAlerts(id);
142 } catch (err) {
143 console.error(
144 `[autopilot] advisory-rescan: scan failed for repo=${id}:`,
145 err
146 );
147 }
148 }
149}
150
151/** Resolve the tick interval from env → opts → default. */
152function resolveIntervalMs(optsMs?: number): number {
153 if (typeof optsMs === "number" && optsMs > 0) return optsMs;
154 const raw = process.env.AUTOPILOT_INTERVAL_MS;
155 if (raw) {
156 const parsed = Number(raw);
157 if (Number.isFinite(parsed) && parsed > 0) return parsed;
158 }
159 return DEFAULT_INTERVAL_MS;
160}
161
162/**
163 * Start the recurring autopilot loop. No-op when AUTOPILOT_DISABLED=1.
164 * The first tick fires after `intervalMs`, not immediately, to keep boot
165 * fast. Returns a `stop()` that clears the interval.
166 */
167export function startAutopilot(opts?: StartAutopilotOpts): { stop: () => void } {
168 if (process.env.AUTOPILOT_DISABLED === "1") {
169 return { stop: () => {} };
170 }
171 const intervalMs = resolveIntervalMs(opts?.intervalMs);
172 const tasks = opts?.tasks ?? defaultTasks();
173 let running = false;
174 const handle = setInterval(() => {
175 if (running) return;
176 running = true;
177 void runAutopilotTick({ tasks, now: opts?.now })
178 .catch(() => {
179 // runAutopilotTick already never throws, but belt-and-braces.
180 })
181 .finally(() => {
182 running = false;
183 });
184 }, intervalMs);
185 return {
186 stop: () => clearInterval(handle),
187 };
188}
189
190/** Last tick snapshot for observability. Module-level, swap-on-complete. */
191let lastTick: AutopilotTickResult | null = null;
192let tickCount = 0;
193
194/** Return the most recent completed tick, or null if autopilot hasn't run yet. */
195export function getLastTick(): AutopilotTickResult | null {
196 return lastTick;
197}
198
199/** Return the total number of completed ticks in this process. */
200export function getTickCount(): number {
201 return tickCount;
202}
203
204/**
205 * Run one tick: invokes every sub-task with its own try/catch, records a
206 * per-task result, and emits a single summary line. Never throws.
207 */
208export async function runAutopilotTick(
209 opts?: RunTickOpts
210): Promise<AutopilotTickResult> {
211 const now = opts?.now ?? Date.now;
212 const tasks = opts?.tasks ?? defaultTasks();
213 const startedAt = new Date(now()).toISOString();
214 const results: AutopilotTaskResult[] = [];
215 for (const t of tasks) {
216 const t0 = now();
217 try {
218 await t.run();
219 results.push({ name: t.name, ok: true, durationMs: now() - t0 });
220 } catch (err) {
221 const message =
222 err instanceof Error ? err.message : String(err ?? "unknown error");
223 console.error(`[autopilot] ${t.name}: ${message}`);
224 results.push({
225 name: t.name,
226 ok: false,
227 durationMs: now() - t0,
228 error: message,
229 });
230 }
231 }
232 const finishedAt = new Date(now()).toISOString();
233 const totalMs = results.reduce((a, r) => a + r.durationMs, 0);
234 const okCount = results.filter((r) => r.ok).length;
235 console.log(
236 `[autopilot] tick ok tasks=${okCount}/${results.length} ms=${totalMs}`
237 );
238 const result: AutopilotTickResult = { startedAt, finishedAt, tasks: results };
239 lastTick = result;
240 tickCount += 1;
241 return result;
242}
243
244/** Exposed for unit tests. */
245export const __test = {
246 resolveIntervalMs,
247 processMergeQueues,
248 rescanAdvisoriesBatch,
249 DEFAULT_INTERVAL_MS,
250 ADVISORY_RESCAN_BATCH,
251};
Addedsrc/lib/demo-seed.ts+656−0View fileUnifiedSplit
@@ -0,0 +1,656 @@
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+208−4View fileUnifiedSplit
@@ -19,7 +19,7 @@ import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
1919import { db } from "../db";
2020import { repositories, users } from "../db/schema";
2121import { Layout } from "../views/layout";
22import { softAuth, requireAuth } from "../middleware/auth";
22import { softAuth } from "../middleware/auth";
2323import type { AuthEnv } from "../middleware/auth";
2424import {
2525 grantSiteAdmin,
@@ -32,6 +32,12 @@ import {
3232} from "../lib/admin";
3333import { audit } from "../lib/notify";
3434import { sendDigestsToAll, sendDigestForUser } from "../lib/email-digest";
35import {
36 getLastTick,
37 getTickCount,
38 runAutopilotTick,
39} from "../lib/autopilot";
40import { ensureDemoContent, DEMO_USERNAME } from "../lib/demo-seed";
3541
3642const admin = new Hono<AuthEnv>();
3743admin.use("*", softAuth);
@@ -75,10 +81,22 @@ admin.get("/admin", async (c) => {
7581
7682 const admins = await listSiteAdmins();
7783
84 const msg = c.req.query("result") || c.req.query("error");
85 const isErr = !!c.req.query("error");
86
7887 return c.html(
7988 <Layout title="Admin — Gluecron" user={user}>
8089 <h2>Site admin</h2>
8190
91 {msg && (
92 <div
93 class={isErr ? "auth-error" : "banner"}
94 style="margin-bottom:16px"
95 >
96 {decodeURIComponent(msg)}
97 </div>
98 )}
99
82100 <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px">
83101 <div class="panel" style="padding:12px;text-align:center">
84102 <div style="font-size:22px;font-weight:700">{Number(uc?.n || 0)}</div>
@@ -100,7 +118,7 @@ admin.get("/admin", async (c) => {
100118 </div>
101119 </div>
102120
103 <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">
104122 <a href="/admin/users" class="btn">
105123 Manage users
106124 </a>
@@ -116,6 +134,18 @@ admin.get("/admin", async (c) => {
116134 <a href="/admin/sso" class="btn">
117135 Enterprise SSO
118136 </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>
119149 </div>
120150
121151 <h3>Recent signups</h3>
@@ -578,7 +608,181 @@ admin.post("/admin/digests/preview", async (c) => {
578608 );
579609});
580610
581// Keep requireAuth import used even if some routes don't reference it here.
582void requireAuth;
611admin.get("/admin/autopilot", async (c) => {
612 const g = await gate(c);
613 if (g instanceof Response) return g;
614 const { user } = g;
615 const tick = getLastTick();
616 const total = getTickCount();
617 const disabled = process.env.AUTOPILOT_DISABLED === "1";
618 const intervalRaw = process.env.AUTOPILOT_INTERVAL_MS;
619 const intervalMs =
620 intervalRaw && Number.isFinite(Number(intervalRaw)) && Number(intervalRaw) > 0
621 ? Number(intervalRaw)
622 : 5 * 60 * 1000;
623 const msg = c.req.query("result") || c.req.query("error");
624 const isErr = !!c.req.query("error");
625 return c.html(
626 <Layout title="Autopilot — admin" user={user}>
627 <div style="max-width: 960px; margin: 0 auto; padding: 24px 16px">
628 <h1 style="margin-bottom: 8px">Autopilot</h1>
629 <p style="color: var(--text-muted); margin-bottom: 24px">
630 Periodic platform-maintenance loop — mirror sync, merge-queue
631 progress, weekly digests, advisory rescans.
632 </p>
633 {msg && (
634 <div
635 class={isErr ? "auth-error" : "banner"}
636 style="margin-bottom: 16px"
637 >
638 {decodeURIComponent(msg)}
639 </div>
640 )}
641 <div
642 style="display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px"
643 >
644 <div class="stat-card">
645 <div class="stat-label">Status</div>
646 <div class="stat-value">
647 {disabled ? "disabled" : "running"}
648 </div>
649 </div>
650 <div class="stat-card">
651 <div class="stat-label">Interval</div>
652 <div class="stat-value">{Math.round(intervalMs / 1000)}s</div>
653 </div>
654 <div class="stat-card">
655 <div class="stat-label">Ticks this process</div>
656 <div class="stat-value">{total}</div>
657 </div>
658 <div class="stat-card">
659 <div class="stat-label">Last tick</div>
660 <div class="stat-value" style="font-size: 14px">
661 {tick ? tick.finishedAt : "never"}
662 </div>
663 </div>
664 </div>
665 <form
666 method="post"
667 action="/admin/autopilot/run"
668 style="margin-bottom: 24px"
669 >
670 <button class="btn btn-primary" type="submit">
671 Run tick now
672 </button>
673 <span style="color: var(--text-muted); margin-left: 12px; font-size: 13px">
674 Executes all sub-tasks synchronously and records the result.
675 </span>
676 </form>
677 <h2 style="margin-bottom: 12px">Last tick tasks</h2>
678 {tick ? (
679 <table class="table" style="width: 100%">
680 <thead>
681 <tr>
682 <th style="text-align: left">Task</th>
683 <th style="text-align: left">Status</th>
684 <th style="text-align: right">Duration</th>
685 <th style="text-align: left">Error</th>
686 </tr>
687 </thead>
688 <tbody>
689 {tick.tasks.map((t) => (
690 <tr>
691 <td>
692 <code>{t.name}</code>
693 </td>
694 <td
695 style={
696 t.ok
697 ? "color: var(--green)"
698 : "color: var(--red)"
699 }
700 >
701 {t.ok ? "ok" : "failed"}
702 </td>
703 <td style="text-align: right">{t.durationMs}ms</td>
704 <td style="color: var(--text-muted); font-size: 13px">
705 {t.error || ""}
706 </td>
707 </tr>
708 ))}
709 </tbody>
710 </table>
711 ) : (
712 <p style="color: var(--text-muted)">
713 No ticks have run yet. The first tick fires after the interval
714 elapses. Click "Run tick now" to fire one immediately.
715 </p>
716 )}
717 <p style="margin-top: 32px; color: var(--text-muted); font-size: 13px">
718 Opt out with env <code>AUTOPILOT_DISABLED=1</code>. Adjust cadence
719 with <code>AUTOPILOT_INTERVAL_MS</code> (milliseconds).
720 </p>
721 </div>
722 </Layout>
723 );
724});
725
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
761admin.post("/admin/autopilot/run", async (c) => {
762 const g = await gate(c);
763 if (g instanceof Response) return g;
764 const { user } = g;
765 let summary = "";
766 try {
767 const result = await runAutopilotTick();
768 const ok = result.tasks.filter((t) => t.ok).length;
769 summary = `Tick complete: ${ok}/${result.tasks.length} tasks ok.`;
770 await audit({
771 userId: user.id,
772 action: "admin.autopilot.run",
773 targetType: "system",
774 targetId: "autopilot",
775 metadata: { ok, total: result.tasks.length },
776 });
777 return c.redirect(
778 `/admin/autopilot?result=${encodeURIComponent(summary)}`
779 );
780 } catch (err) {
781 const message = err instanceof Error ? err.message : String(err);
782 return c.redirect(
783 `/admin/autopilot?error=${encodeURIComponent("Tick failed: " + message)}`
784 );
785 }
786});
583787
584788export default admin;
Addedsrc/routes/seo.ts+65−0View fileUnifiedSplit
@@ -0,0 +1,65 @@
1/**
2 * SEO routes: robots.txt + sitemap.xml.
3 *
4 * robots.txt — allow crawlers on public surfaces, disallow admin/settings
5 * and API write paths.
6 * sitemap.xml — lists the stable public URLs (landing, explore, status,
7 * marketplace, graphql explorer, shortcuts, terms). Public repo URLs are
8 * not enumerated here — those live behind /explore which crawlers follow.
9 */
10
11import { Hono } from "hono";
12
13const seo = new Hono();
14
15const ROBOTS = `User-agent: *
16Allow: /
17Disallow: /admin
18Disallow: /settings
19Disallow: /api/
20Disallow: /login
21Disallow: /register
22Disallow: /logout
23Disallow: /oauth/
24Disallow: /*/settings
25Disallow: /*.git/
26
27Sitemap: /sitemap.xml
28`;
29
30seo.get("/robots.txt", (c) => {
31 c.header("Content-Type", "text/plain; charset=utf-8");
32 c.header("Cache-Control", "public, max-age=3600");
33 return c.body(ROBOTS);
34});
35
36const STATIC_PATHS = [
37 "/",
38 "/explore",
39 "/marketplace",
40 "/status",
41 "/shortcuts",
42 "/api/graphql",
43 "/terms",
44 "/privacy",
45 "/acceptable-use",
46];
47
48seo.get("/sitemap.xml", (c) => {
49 const origin = new URL(c.req.url).origin;
50 const lastmod = new Date().toISOString().slice(0, 10);
51 const urls = STATIC_PATHS.map(
52 (p) =>
53 `<url><loc>${origin}${p}</loc><lastmod>${lastmod}</lastmod></url>`
54 ).join("\n ");
55 const body = `<?xml version="1.0" encoding="UTF-8"?>
56<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
57 ${urls}
58</urlset>
59`;
60 c.header("Content-Type", "application/xml; charset=utf-8");
61 c.header("Cache-Control", "public, max-age=3600");
62 return c.body(body);
63});
64
65export default seo;
Addedsrc/routes/status.tsx+324−0View fileUnifiedSplit
@@ -0,0 +1,324 @@
1/**
2 * Public /status — human-readable platform health dashboard.
3 *
4 * Unlike /healthz (LB liveness JSON) and /readyz (DB readiness JSON),
5 * /status renders a full HTML page anyone can load. Shows DB reachability,
6 * autopilot state, totals (users/repos/gate runs), and the most recent
7 * autopilot tick's task breakdown.
8 *
9 * Accessible without auth. Uses softAuth so the nav bar renders correctly
10 * for logged-in visitors.
11 */
12
13import { Hono } from "hono";
14import { sql } from "drizzle-orm";
15import { db } from "../db";
16import { users, repositories, gateRuns } from "../db/schema";
17import { Layout } from "../views/layout";
18import { softAuth } from "../middleware/auth";
19import type { AuthEnv } from "../middleware/auth";
20import { getLastTick, getTickCount } from "../lib/autopilot";
21
22const status = new Hono<AuthEnv>();
23status.use("*", softAuth);
24
25const started = Date.now();
26
27function fmtUptime(ms: number): string {
28 const s = Math.floor(ms / 1000);
29 const d = Math.floor(s / 86400);
30 const h = Math.floor((s % 86400) / 3600);
31 const m = Math.floor((s % 3600) / 60);
32 if (d > 0) return `${d}d ${h}h`;
33 if (h > 0) return `${h}h ${m}m`;
34 return `${m}m`;
35}
36
37status.get("/status", async (c) => {
38 const user = c.get("user");
39
40 let dbOk = false;
41 try {
42 await db.execute(sql`SELECT 1`);
43 dbOk = true;
44 } catch {
45 dbOk = false;
46 }
47
48 let userCount = 0;
49 let repoCount = 0;
50 let publicRepoCount = 0;
51 let gateRunCount = 0;
52 let greenRate: number | null = null;
53 try {
54 const [u] = await db.select({ n: sql<number>`count(*)::int` }).from(users);
55 userCount = Number(u?.n ?? 0);
56 const [r] = await db
57 .select({ n: sql<number>`count(*)::int` })
58 .from(repositories);
59 repoCount = Number(r?.n ?? 0);
60 const [pr] = await db
61 .select({ n: sql<number>`count(*)::int` })
62 .from(repositories)
63 .where(sql`${repositories.isPrivate} = false`);
64 publicRepoCount = Number(pr?.n ?? 0);
65 const [gr] = await db
66 .select({ n: sql<number>`count(*)::int` })
67 .from(gateRuns);
68 gateRunCount = Number(gr?.n ?? 0);
69 if (gateRunCount > 0) {
70 const [g] = await db
71 .select({ n: sql<number>`count(*)::int` })
72 .from(gateRuns)
73 .where(sql`${gateRuns.status} IN ('passed','repaired')`);
74 greenRate = (Number(g?.n ?? 0) / gateRunCount) * 100;
75 }
76 } catch {
77 // counts stay 0
78 }
79
80 const tick = getLastTick();
81 const ticks = getTickCount();
82 const autopilotDisabled = process.env.AUTOPILOT_DISABLED === "1";
83 const uptimeMs = Date.now() - started;
84
85 const overallOk = dbOk;
86
87 return c.html(
88 <Layout title="Status — gluecron" user={user}>
89 <div style="max-width: 960px; margin: 0 auto; padding: 24px 16px">
90 <div style="display: flex; align-items: center; gap: 12px; margin-bottom: 8px">
91 <span
92 style={`display: inline-block; width: 14px; height: 14px; border-radius: 50%; background: ${overallOk ? "var(--green, #2da44e)" : "var(--red, #cf222e)"}`}
93 />
94 <h1 style="margin: 0; font-size: 28px">
95 {overallOk ? "All systems operational" : "Service degraded"}
96 </h1>
97 </div>
98 <p style="color: var(--text-muted); margin-bottom: 32px">
99 Live platform status. Reloads on refresh; no client-side polling.
100 </p>
101
102 <h2 style="margin-bottom: 12px; font-size: 18px">Components</h2>
103 <div class="panel" style="margin-bottom: 24px">
104 <div
105 class="panel-item"
106 style="justify-content: space-between; align-items: center"
107 >
108 <div>
109 <strong>Database</strong>
110 <div style="font-size: 12px; color: var(--text-muted)">
111 Neon PostgreSQL
112 </div>
113 </div>
114 <span
115 style={`padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; background: ${dbOk ? "rgba(45, 164, 78, 0.15)" : "rgba(207, 34, 46, 0.15)"}; color: ${dbOk ? "var(--green, #2da44e)" : "var(--red, #cf222e)"}`}
116 >
117 {dbOk ? "operational" : "down"}
118 </span>
119 </div>
120 <div
121 class="panel-item"
122 style="justify-content: space-between; align-items: center"
123 >
124 <div>
125 <strong>Autopilot</strong>
126 <div style="font-size: 12px; color: var(--text-muted)">
127 Periodic platform-maintenance loop
128 </div>
129 </div>
130 <span
131 style={`padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; background: ${autopilotDisabled ? "rgba(150, 150, 150, 0.15)" : "rgba(45, 164, 78, 0.15)"}; color: ${autopilotDisabled ? "var(--text-muted)" : "var(--green, #2da44e)"}`}
132 >
133 {autopilotDisabled ? "disabled" : "running"}
134 </span>
135 </div>
136 <div
137 class="panel-item"
138 style="justify-content: space-between; align-items: center"
139 >
140 <div>
141 <strong>Git Smart HTTP</strong>
142 <div style="font-size: 12px; color: var(--text-muted)">
143 Clone, fetch, push
144 </div>
145 </div>
146 <span style="padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; background: rgba(45, 164, 78, 0.15); color: var(--green, #2da44e)">
147 operational
148 </span>
149 </div>
150 </div>
151
152 <h2 style="margin-bottom: 12px; font-size: 18px">Platform stats</h2>
153 <div
154 style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 24px"
155 >
156 <div class="panel" style="padding: 14px; text-align: center">
157 <div style="font-size: 24px; font-weight: 700">
158 {userCount.toLocaleString()}
159 </div>
160 <div
161 style="font-size: 11px; color: var(--text-muted); text-transform: uppercase"
162 >
163 Developers
164 </div>
165 </div>
166 <div class="panel" style="padding: 14px; text-align: center">
167 <div style="font-size: 24px; font-weight: 700">
168 {repoCount.toLocaleString()}
169 </div>
170 <div
171 style="font-size: 11px; color: var(--text-muted); text-transform: uppercase"
172 >
173 Repositories
174 </div>
175 </div>
176 <div class="panel" style="padding: 14px; text-align: center">
177 <div style="font-size: 24px; font-weight: 700">
178 {publicRepoCount.toLocaleString()}
179 </div>
180 <div
181 style="font-size: 11px; color: var(--text-muted); text-transform: uppercase"
182 >
183 Public repos
184 </div>
185 </div>
186 <div class="panel" style="padding: 14px; text-align: center">
187 <div style="font-size: 24px; font-weight: 700">
188 {gateRunCount.toLocaleString()}
189 </div>
190 <div
191 style="font-size: 11px; color: var(--text-muted); text-transform: uppercase"
192 >
193 Gate runs
194 </div>
195 </div>
196 <div class="panel" style="padding: 14px; text-align: center">
197 <div style="font-size: 24px; font-weight: 700">
198 {greenRate === null ? "—" : `${greenRate.toFixed(1)}%`}
199 </div>
200 <div
201 style="font-size: 11px; color: var(--text-muted); text-transform: uppercase"
202 >
203 Green rate
204 </div>
205 </div>
206 <div class="panel" style="padding: 14px; text-align: center">
207 <div style="font-size: 24px; font-weight: 700">
208 {fmtUptime(uptimeMs)}
209 </div>
210 <div
211 style="font-size: 11px; color: var(--text-muted); text-transform: uppercase"
212 >
213 Uptime
214 </div>
215 </div>
216 </div>
217
218 <h2 style="margin-bottom: 12px; font-size: 18px">
219 Latest autopilot tick
220 </h2>
221 {tick ? (
222 <div class="panel" style="margin-bottom: 24px">
223 <div
224 class="panel-item"
225 style="justify-content: space-between; font-size: 13px"
226 >
227 <span>Finished</span>
228 <code>{tick.finishedAt}</code>
229 </div>
230 <div
231 class="panel-item"
232 style="justify-content: space-between; font-size: 13px"
233 >
234 <span>Total ticks this process</span>
235 <code>{ticks}</code>
236 </div>
237 {tick.tasks.map((t) => (
238 <div
239 class="panel-item"
240 style="justify-content: space-between; font-size: 13px"
241 >
242 <code>{t.name}</code>
243 <span
244 style={
245 t.ok
246 ? "color: var(--green, #2da44e)"
247 : "color: var(--red, #cf222e)"
248 }
249 >
250 {t.ok ? "ok" : `failed: ${t.error || "unknown"}`}
251 <span
252 style="color: var(--text-muted); margin-left: 8px"
253 >
254 {t.durationMs}ms
255 </span>
256 </span>
257 </div>
258 ))}
259 </div>
260 ) : (
261 <p
262 style="color: var(--text-muted); margin-bottom: 24px; font-size: 14px"
263 >
264 {autopilotDisabled
265 ? "Autopilot is disabled via AUTOPILOT_DISABLED=1."
266 : "No ticks have completed yet. Check back after the first 5-minute interval elapses."}
267 </p>
268 )}
269
270 <p
271 style="color: var(--text-muted); font-size: 12px; margin-top: 32px; padding-top: 16px; border-top: 1px solid var(--border)"
272 >
273 Liveness: <a href="/healthz">/healthz</a> · Readiness:{" "}
274 <a href="/readyz">/readyz</a> · Metrics:{" "}
275 <a href="/metrics">/metrics</a> · Platform JSON:{" "}
276 <a href="/api/platform-status">/api/platform-status</a>
277 </p>
278 </div>
279 </Layout>
280 );
281});
282
283/**
284 * Shields-style status badge. Reads the latest autopilot tick + DB
285 * reachability and returns an SVG. Embed in READMEs with:
286 * 
287 */
288status.get("/status.svg", async (c) => {
289 let dbOk = false;
290 try {
291 await db.execute(sql`SELECT 1`);
292 dbOk = true;
293 } catch {
294 dbOk = false;
295 }
296 const tick = getLastTick();
297 const lastOk = tick ? tick.tasks.every((t) => t.ok) : true;
298 const overall = dbOk && lastOk;
299 const label = "gluecron";
300 const value = overall ? "operational" : "degraded";
301 const fill = overall ? "#2da44e" : "#cf222e";
302
303 const labelW = 70;
304 const valueW = overall ? 78 : 68;
305 const totalW = labelW + valueW;
306 const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${totalW}" height="20" role="img" aria-label="${label}: ${value}">
307 <linearGradient id="s" x2="0" y2="100%">
308 <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
309 <stop offset="1" stop-opacity=".1"/>
310 </linearGradient>
311 <rect width="${totalW}" height="20" rx="3" fill="#555"/>
312 <rect x="${labelW}" width="${valueW}" height="20" rx="3" fill="${fill}"/>
313 <rect width="${totalW}" height="20" rx="3" fill="url(#s)"/>
314 <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,sans-serif" font-size="11">
315 <text x="${labelW / 2}" y="15">${label}</text>
316 <text x="${labelW + valueW / 2}" y="15">${value}</text>
317 </g>
318</svg>`;
319 c.header("Content-Type", "image/svg+xml; charset=utf-8");
320 c.header("Cache-Control", "no-cache, max-age=0");
321 return c.body(svg);
322});
323
324export default status;
Modifiedsrc/routes/web.tsx+20−20View fileUnifiedSplit
@@ -5,7 +5,7 @@
55
66import { Hono } from "hono";
77import { html } from "hono/html";
8import { eq, and, desc, inArray } from "drizzle-orm";
8import { eq, and, desc, inArray, sql } from "drizzle-orm";
99import { db } from "../db";
1010import {
1111 users,
@@ -47,6 +47,7 @@ import { highlightCode } from "../lib/highlight";
4747import { softAuth, requireAuth } from "../middleware/auth";
4848import type { AuthEnv } from "../middleware/auth";
4949import { trackByName } from "../lib/traffic";
50import { LandingPage } from "../views/landing";
5051
5152const web = new Hono<AuthEnv>();
5253
@@ -61,27 +62,26 @@ web.get("/", async (c) => {
6162 return c.redirect("/dashboard");
6263 }
6364
65 let stats: { publicRepos?: number; users?: number } | undefined;
66 try {
67 const [repoRow] = await db
68 .select({ n: sql<number>`count(*)::int` })
69 .from(repositories)
70 .where(eq(repositories.isPrivate, false));
71 const [userRow] = await db
72 .select({ n: sql<number>`count(*)::int` })
73 .from(users);
74 stats = {
75 publicRepos: Number(repoRow?.n ?? 0),
76 users: Number(userRow?.n ?? 0),
77 };
78 } catch {
79 stats = undefined;
80 }
81
6482 return c.html(
6583 <Layout user={null}>
66 <div class="empty-state">
67 <h2>gluecron</h2>
68 <p>AI-native code intelligence platform</p>
69 <div style="margin-top: 24px; display: flex; gap: 12px; justify-content: center">
70 <a href="/register" class="btn btn-primary">
71 Get started
72 </a>
73 <a href="/login" class="btn">
74 Sign in
75 </a>
76 </div>
77 <pre style="margin-top: 32px">{`# Quick start
78curl -X POST http://localhost:3000/api/setup \\
79 -H 'Content-Type: application/json' \\
80 -d '{"username":"you","email":"you@dev.com","repoName":"hello"}'
81
82git remote add gluecron http://localhost:3000/you/hello.git
83git push gluecron main`}</pre>
84 </div>
84 <LandingPage stats={stats} />
8585 </Layout>
8686 );
8787});
Addedsrc/views/landing.tsx+483−0View fileUnifiedSplit
@@ -0,0 +1,483 @@
1/**
2 * Marketing landing page for logged-out visitors.
3 *
4 * Pure presentational component — no props required. Drops into an existing
5 * <Layout user={null}> as a single fragment. All styles are scoped under the
6 * `landing-` class prefix so they don't leak into the rest of the app.
7 *
8 * Tone: confident, technical, specific. No "revolutionary", no "game-changing".
9 */
10
11import type { FC } from "hono/jsx";
12
13export interface LandingPageProps {
14 stats?: {
15 publicRepos?: number;
16 users?: number;
17 };
18}
19
20export const LandingPage: FC<LandingPageProps> = ({ stats } = {}) => {
21 const hasStats =
22 stats &&
23 ((stats.publicRepos !== undefined && stats.publicRepos > 0) ||
24 (stats.users !== undefined && stats.users > 0));
25 return (
26 <>
27 <style>{landingCss}</style>
28
29 {/* ---------- Hero ---------- */}
30 <section class="landing-hero">
31 <h1 class="landing-hero-title">
32 GitHub, but the AI actually ships the code.
33 </h1>
34 <p class="landing-hero-sub">
35 gluecron is a self-hostable code platform with AI review, dependency
36 updates, semantic search, and a workflow runner built in. No plugins.
37 No bolt-ons. One binary.
38 </p>
39 <div class="landing-hero-ctas">
40 <a href="/register" class="btn btn-primary landing-cta-primary">
41 Start free
42 </a>
43 <a href="/explore" class="btn landing-cta-secondary">
44 Explore public repos
45 </a>
46 </div>
47 <p class="landing-trust">
48 Self-hostable · AI built in · Open source mindset
49 </p>
50 {hasStats && (
51 <p class="landing-stats">
52 {stats!.publicRepos !== undefined && stats!.publicRepos > 0 && (
53 <span>
54 <strong>{stats!.publicRepos.toLocaleString()}</strong> public
55 {stats!.publicRepos === 1 ? " repo" : " repos"}
56 </span>
57 )}
58 {stats!.publicRepos !== undefined &&
59 stats!.publicRepos > 0 &&
60 stats!.users !== undefined &&
61 stats!.users > 0 && <span class="landing-stats-sep"> · </span>}
62 {stats!.users !== undefined && stats!.users > 0 && (
63 <span>
64 <strong>{stats!.users.toLocaleString()}</strong>
65 {stats!.users === 1 ? " developer" : " developers"}
66 </span>
67 )}
68 </p>
69 )}
70 </section>
71
72 {/* ---------- Features grid ---------- */}
73 <section class="landing-section">
74 <h2 class="landing-section-title">Everything in one binary</h2>
75 <p class="landing-section-sub">
76 The features GitHub sells as separate products (Dependabot, Copilot,
77 Actions, Advanced Security) ship with gluecron by default.
78 </p>
79 <div class="landing-grid">
80 <FeatureCard
81 icon="\u2728"
82 title="AI code review"
83 desc="Claude Sonnet reviews every PR with inline comments. Can block merges when configured in branch protection."
84 />
85 <FeatureCard
86 icon="\u21BB"
87 title="AI dependency updates"
88 desc="Scans package.json, opens PRs with bump tables, writes the branch via git plumbing. Dependabot without the setup."
89 />
90 <FeatureCard
91 icon="\u2315"
92 title="Semantic code search"
93 desc="voyage-code-3 embeddings over every chunk, with lexical fallback. Finds code by intent, not just text."
94 />
95 <FeatureCard
96 icon="\u{1F4D6}"
97 title="Explain this codebase"
98 desc="One click gets you a per-commit cached Markdown tour of the repo. Onboarding that writes itself."
99 />
100 <FeatureCard
101 icon="\u2713"
102 title="Signed commit verification"
103 desc="GPG and SSH signatures, Issuer Fingerprint extraction, a green Verified badge on the commit list."
104 />
105 <FeatureCard
106 icon="\u21C4"
107 title="Merge queues + required checks"
108 desc="Serialised merges with re-test against the latest base. Per-branch required check matrix, enforced at merge."
109 />
110 <FeatureCard
111 icon="\u2699"
112 title="Self-hosted workflow runner"
113 desc="Actions-equivalent runner reads .gluecron/workflows/*.yml. Bun subprocesses, size-capped logs, per-step timeouts."
114 />
115 <FeatureCard
116 icon="\u{1F4E6}"
117 title="npm-protocol package registry"
118 desc="Publish, install, yank over the real npm protocol. PAT-auth via .npmrc. No separate service to run."
119 />
120 <FeatureCard
121 icon="\u{1F510}"
122 title="Enterprise SSO (OIDC)"
123 desc="Okta, Azure AD, Auth0, Google Workspace. Auth-code flow, state+nonce, optional email-domain allow-list."
124 />
125 </div>
126 </section>
127
128 {/* ---------- vs GitHub ---------- */}
129 <section class="landing-section landing-compare">
130 <h2 class="landing-section-title">gluecron vs GitHub</h2>
131 <p class="landing-section-sub">
132 Honest comparison. GitHub is excellent at what it does. gluecron is
133 built for teams that want AI and CI in one place, on infrastructure
134 they control.
135 </p>
136 <div class="landing-compare-grid">
137 <div class="landing-compare-col landing-compare-us">
138 <h3>gluecron</h3>
139 <ul>
140 <li><span class="landing-check">{"\u2713"}</span> Self-host on your own box</li>
141 <li><span class="landing-check">{"\u2713"}</span> AI review and completion included</li>
142 <li><span class="landing-check">{"\u2713"}</span> Dependency updater included</li>
143 <li><span class="landing-check">{"\u2713"}</span> Workflow runner included</li>
144 <li><span class="landing-check">{"\u2713"}</span> Semantic search included</li>
145 <li><span class="landing-check">{"\u2713"}</span> Green-by-default (gates, protection, codeowners)</li>
146 <li><span class="landing-check">{"\u2713"}</span> One binary. One database. One deploy.</li>
147 </ul>
148 </div>
149 <div class="landing-compare-col landing-compare-them">
150 <h3>GitHub</h3>
151 <ul>
152 <li><span class="landing-dash">{"\u2013"}</span> SaaS-first (Enterprise Server is separate)</li>
153 <li><span class="landing-dash">{"\u2013"}</span> Copilot billed per seat</li>
154 <li><span class="landing-dash">{"\u2013"}</span> Dependabot configured per repo</li>
155 <li><span class="landing-dash">{"\u2013"}</span> Actions minutes metered</li>
156 <li><span class="landing-dash">{"\u2013"}</span> Code search is lexical by default</li>
157 <li><span class="landing-dash">{"\u2013"}</span> Advanced Security is an add-on</li>
158 <li><span class="landing-dash">{"\u2013"}</span> Many moving pieces to wire together</li>
159 </ul>
160 </div>
161 </div>
162 </section>
163
164 {/* ---------- How it works ---------- */}
165 <section class="landing-section">
166 <h2 class="landing-section-title">How it works</h2>
167 <div class="landing-steps">
168 <div class="landing-step">
169 <div class="landing-step-num">1</div>
170 <h3>Push code</h3>
171 <p>
172 <code>git push</code> to gluecron. Standard Smart HTTP. No agent
173 to install.
174 </p>
175 </div>
176 <div class="landing-step">
177 <div class="landing-step-num">2</div>
178 <h3>Gates + AI review run</h3>
179 <p>
180 Secret scanner, security gate, AI reviewer, and your workflows
181 fire automatically. Rulesets enforce push policy.
182 </p>
183 </div>
184 <div class="landing-step">
185 <div class="landing-step-num">3</div>
186 <h3>Green pushes auto-deploy</h3>
187 <p>
188 Default-branch commits that pass all gates deploy through
189 Crontech. Failed deploys open an AI-authored incident issue.
190 </p>
191 </div>
192 </div>
193 </section>
194
195 {/* ---------- Final CTA band ---------- */}
196 <section class="landing-cta-band">
197 <h2>Ready to push?</h2>
198 <p>
199 Create an account, push a repo, watch the gates run. No credit card,
200 no trial clock.
201 </p>
202 <div class="landing-hero-ctas">
203 <a href="/register" class="btn btn-primary landing-cta-primary">
204 Start free
205 </a>
206 <a href="/explore" class="btn landing-cta-secondary">
207 Browse public repos
208 </a>
209 </div>
210 </section>
211
212 {/* ---------- Footer row ---------- */}
213 <section class="landing-foot">
214 <a href="/explore">Explore</a>
215 <span class="landing-foot-sep">·</span>
216 <a href="/marketplace">Marketplace</a>
217 <span class="landing-foot-sep">·</span>
218 <a href="/api/graphql">GraphQL API</a>
219 <span class="landing-foot-sep">·</span>
220 <a href="/shortcuts">Keyboard shortcuts</a>
221 <span class="landing-foot-sep">·</span>
222 <a href="/status">Status</a>
223 <span class="landing-foot-sep">·</span>
224 <a href="/demo">Try the demo</a>
225 <span class="landing-foot-sep">·</span>
226 <a href="/terms">Terms</a>
227 </section>
228 </>
229 );
230};
231
232const FeatureCard: FC<{ icon: string; title: string; desc: string }> = ({
233 icon,
234 title,
235 desc,
236}) => (
237 <div class="landing-feature">
238 <div class="landing-feature-icon" aria-hidden="true">
239 {icon}
240 </div>
241 <h3 class="landing-feature-title">{title}</h3>
242 <p class="landing-feature-desc">{desc}</p>
243 </div>
244);
245
246const landingCss = `
247 /* ---------- Hero ---------- */
248 .landing-hero {
249 padding: 72px 16px 56px;
250 text-align: center;
251 max-width: 860px;
252 margin: 0 auto;
253 border-bottom: 1px solid var(--border);
254 }
255 .landing-hero-title {
256 font-size: 44px;
257 line-height: 1.1;
258 letter-spacing: -0.02em;
259 margin: 0 0 20px;
260 color: var(--text);
261 font-weight: 700;
262 }
263 .landing-hero-sub {
264 font-size: 18px;
265 color: var(--text-muted);
266 margin: 0 auto 32px;
267 max-width: 640px;
268 line-height: 1.5;
269 }
270 .landing-hero-ctas {
271 display: flex;
272 gap: 12px;
273 justify-content: center;
274 flex-wrap: wrap;
275 margin-bottom: 16px;
276 }
277 .landing-cta-primary,
278 .landing-cta-secondary {
279 padding: 10px 20px;
280 font-size: 15px;
281 font-weight: 600;
282 }
283 .landing-trust {
284 font-size: 13px;
285 color: var(--text-muted);
286 margin-top: 12px;
287 letter-spacing: 0.02em;
288 }
289 .landing-stats {
290 margin-top: 20px;
291 font-size: 14px;
292 color: var(--text-muted);
293 }
294 .landing-stats strong {
295 color: var(--fg);
296 font-weight: 600;
297 }
298 .landing-stats-sep {
299 opacity: 0.6;
300 }
301
302 /* ---------- Section scaffolding ---------- */
303 .landing-section {
304 padding: 64px 16px;
305 max-width: 1080px;
306 margin: 0 auto;
307 border-bottom: 1px solid var(--border);
308 }
309 .landing-section-title {
310 font-size: 28px;
311 font-weight: 700;
312 margin: 0 0 12px;
313 color: var(--text);
314 letter-spacing: -0.01em;
315 }
316 .landing-section-sub {
317 font-size: 15px;
318 color: var(--text-muted);
319 margin: 0 0 36px;
320 max-width: 680px;
321 line-height: 1.6;
322 }
323
324 /* ---------- Features grid ---------- */
325 .landing-grid {
326 display: grid;
327 grid-template-columns: repeat(3, 1fr);
328 gap: 16px;
329 }
330 .landing-feature {
331 border: 1px solid var(--border);
332 border-radius: var(--radius);
333 padding: 20px;
334 background: var(--bg-secondary);
335 transition: border-color 0.15s;
336 }
337 .landing-feature:hover { border-color: var(--text-muted); }
338 .landing-feature-icon {
339 font-size: 22px;
340 margin-bottom: 10px;
341 line-height: 1;
342 }
343 .landing-feature-title {
344 font-size: 15px;
345 font-weight: 600;
346 margin: 0 0 6px;
347 color: var(--text);
348 }
349 .landing-feature-desc {
350 font-size: 13px;
351 color: var(--text-muted);
352 line-height: 1.55;
353 margin: 0;
354 }
355
356 /* ---------- Compare strip ---------- */
357 .landing-compare-grid {
358 display: grid;
359 grid-template-columns: 1fr 1fr;
360 gap: 16px;
361 }
362 .landing-compare-col {
363 border: 1px solid var(--border);
364 border-radius: var(--radius);
365 padding: 24px;
366 background: var(--bg-secondary);
367 }
368 .landing-compare-us { border-color: var(--accent); }
369 .landing-compare-col h3 {
370 font-size: 18px;
371 font-weight: 700;
372 margin: 0 0 16px;
373 color: var(--text);
374 }
375 .landing-compare-col ul {
376 list-style: none;
377 margin: 0;
378 padding: 0;
379 }
380 .landing-compare-col li {
381 font-size: 14px;
382 color: var(--text);
383 padding: 6px 0;
384 line-height: 1.5;
385 }
386 .landing-check { color: var(--green); font-weight: 700; margin-right: 8px; }
387 .landing-dash { color: var(--text-muted); font-weight: 700; margin-right: 8px; }
388 .landing-compare-them li { color: var(--text-muted); }
389
390 /* ---------- How it works ---------- */
391 .landing-steps {
392 display: grid;
393 grid-template-columns: repeat(3, 1fr);
394 gap: 16px;
395 }
396 .landing-step {
397 border: 1px solid var(--border);
398 border-radius: var(--radius);
399 padding: 24px;
400 background: var(--bg-secondary);
401 }
402 .landing-step-num {
403 display: inline-flex;
404 align-items: center;
405 justify-content: center;
406 width: 28px;
407 height: 28px;
408 border-radius: 50%;
409 background: var(--accent);
410 color: #fff;
411 font-weight: 700;
412 font-size: 14px;
413 margin-bottom: 12px;
414 }
415 .landing-step h3 {
416 font-size: 16px;
417 font-weight: 600;
418 margin: 0 0 6px;
419 color: var(--text);
420 }
421 .landing-step p {
422 font-size: 13px;
423 color: var(--text-muted);
424 line-height: 1.55;
425 margin: 0;
426 }
427 .landing-step code {
428 font-family: var(--font-mono);
429 font-size: 12px;
430 background: var(--bg-tertiary);
431 padding: 1px 6px;
432 border-radius: 3px;
433 color: var(--text);
434 }
435
436 /* ---------- Final CTA band ---------- */
437 .landing-cta-band {
438 padding: 72px 16px;
439 text-align: center;
440 max-width: 860px;
441 margin: 0 auto;
442 border-bottom: 1px solid var(--border);
443 }
444 .landing-cta-band h2 {
445 font-size: 32px;
446 font-weight: 700;
447 margin: 0 0 12px;
448 color: var(--text);
449 }
450 .landing-cta-band p {
451 font-size: 15px;
452 color: var(--text-muted);
453 margin: 0 auto 28px;
454 max-width: 560px;
455 }
456
457 /* ---------- Foot row ---------- */
458 .landing-foot {
459 padding: 32px 16px 48px;
460 text-align: center;
461 font-size: 13px;
462 color: var(--text-muted);
463 }
464 .landing-foot a { color: var(--text-muted); }
465 .landing-foot a:hover { color: var(--text); text-decoration: none; }
466 .landing-foot-sep { margin: 0 10px; color: var(--border); }
467
468 /* ---------- Responsive ---------- */
469 @media (max-width: 820px) {
470 .landing-hero { padding: 48px 16px 40px; }
471 .landing-hero-title { font-size: 32px; }
472 .landing-hero-sub { font-size: 16px; }
473 .landing-section { padding: 48px 16px; }
474 .landing-section-title { font-size: 24px; }
475 .landing-grid { grid-template-columns: 1fr; }
476 .landing-compare-grid { grid-template-columns: 1fr; }
477 .landing-steps { grid-template-columns: 1fr; }
478 .landing-cta-band { padding: 48px 16px; }
479 .landing-cta-band h2 { font-size: 24px; }
480 }
481`;
482
483export default LandingPage;
0484