Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

self-host-bootstrap.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

self-host-bootstrap.tsBlame528 lines · 2 contributors
f2c00b4CC LABS App1/**
2 * BLOCK W — Self-host bootstrap.
3 *
4 * Mirror Gluecron's source from GitHub onto Gluecron itself, ONCE.
5 *
6 * Usage (run as root on the box, or anywhere DATABASE_URL + GIT_REPOS_PATH
7 * resolve to the production values):
8 *
9 * bun run scripts/self-host-bootstrap.ts \
10 * [--owner=ccantynz] \
11 * [--name=Gluecron.com] \
12 * [--source=https://github.com/ccantynz-alt/Gluecron.com.git] \
13 * [--dry-run]
14 *
15 * Idempotent — safe to re-run. Every step prints `v`/`x`/`!` and only
16 * fails the script when a step truly cannot proceed.
17 *
18 * Pre-flight (operator's responsibility BEFORE invoking):
19 * - DATABASE_URL points at the live Neon db (the one the running site reads)
20 * - GIT_REPOS_PATH is set (or default /opt/gluecron/repos is writable)
21 * - `git` is on PATH
22 * - At least one user row exists in `users` (we pick the site-admin or oldest)
23 * - Disk has room for a mirror clone (~200 MB working set + ~50 MB bare repo)
24 *
25 * What it does:
26 * 1. Read env
27 * 2. Look up the operator (site_admins → oldest user fallback)
28 * 3. INSERT the `repositories` row (skip if it exists)
29 * 4. `git init --bare` the on-disk repo (skip if it exists)
30 * 5. `git push --mirror` from a temp clone of the GitHub source
31 * 6. Install the self-host post-receive hook on the bare repo
32 * 7. Print cutover instructions
33 *
34 * The post-receive hook script just forwards to the runtime
35 * `src/hooks/post-receive.ts` entry point via the routes/git.ts pathway —
36 * we don't replicate any of the locked Block §4.2 logic. The deploy
37 * trigger lives inside the existing hook (the additive SELF_HOST_REPO
38 * block at the end of `onPostReceive`).
39 */
40/* eslint-disable @typescript-eslint/no-explicit-any */
41
42import { and, eq, asc } from "drizzle-orm";
43import { existsSync } from "fs";
44import { mkdir, writeFile, chmod, rm } from "fs/promises";
45import { join } from "path";
46import { tmpdir } from "os";
47
48// ── pretty printers (match scripts/bootstrap-hetzner.sh + install.sh) ──────
49function say(msg: string): void {
50 console.log("");
51 console.log(`==> ${msg}`);
52}
53function ok(msg: string): void {
54 console.log(` v ${msg}`);
55}
56function warn(msg: string): void {
57 console.log(` ! ${msg}`);
58}
59function bad(msg: string): void {
60 console.error(` x ${msg}`);
61}
62
63// ── arg parse ──────────────────────────────────────────────────────────────
64export interface BootstrapArgs {
65 owner: string;
66 name: string;
67 source: string;
68 dryRun: boolean;
69}
70
71export function parseArgs(argv: string[]): BootstrapArgs {
72 const out: BootstrapArgs = {
73 owner: "ccantynz",
74 name: "Gluecron.com",
75 source: "https://github.com/ccantynz-alt/Gluecron.com.git",
76 dryRun: false,
77 };
78 for (const a of argv) {
79 if (a === "--dry-run") {
80 out.dryRun = true;
81 continue;
82 }
83 const m = a.match(/^--([^=]+)=(.*)$/);
84 if (!m) continue;
85 const [, k, v] = m;
86 if (k === "owner") out.owner = v!;
87 else if (k === "name") out.name = v!;
88 else if (k === "source") out.source = v!;
89 }
90 return out;
91}
92
93// ── shell helper (small wrapper around Bun.spawn) ──────────────────────────
94export async function sh(
95 cmd: string[],
96 opts: { cwd?: string } = {}
97): Promise<{ ok: boolean; stdout: string; stderr: string; exitCode: number }> {
98 const proc = Bun.spawn(cmd, {
99 cwd: opts.cwd,
100 stdout: "pipe",
101 stderr: "pipe",
102 });
103 const [stdout, stderr] = await Promise.all([
104 new Response(proc.stdout).text(),
105 new Response(proc.stderr).text(),
106 ]);
107 const exitCode = await proc.exited;
108 return { ok: exitCode === 0, stdout, stderr, exitCode };
109}
110
111// ── DI seam for the orchestrator (tests inject fakes) ──────────────────────
112export interface BootstrapDeps {
113 db: any;
114 schema: {
115 users: any;
116 repositories: any;
117 siteAdmins: any;
118 };
119 reposPath: string;
120 sh: typeof sh;
121 fsExists: (p: string) => boolean;
122 fsMkdir: (p: string, opts?: { recursive?: boolean }) => Promise<unknown>;
123 fsWrite: (p: string, body: string) => Promise<unknown>;
124 fsChmod: (p: string, mode: number) => Promise<unknown>;
125 fsRm: (p: string, opts?: { recursive?: boolean; force?: boolean }) => Promise<unknown>;
126 log: {
127 say: (m: string) => void;
128 ok: (m: string) => void;
129 warn: (m: string) => void;
130 bad: (m: string) => void;
131 info: (m: string) => void;
132 };
133 tmpRoot: string;
134}
135
136export interface BootstrapResult {
137 ok: boolean;
138 steps: {
139 operator: { id: string; username: string } | null;
140 repoRow: { id: string; created: boolean } | null;
141 bareRepoCreated: boolean;
142 mirrored: boolean;
143 hookInstalled: boolean;
144 };
145 error?: string;
146}
147
148// ── 2. Find operator: site_admins LIMIT 1 → oldest user fallback ──────────
149export async function findOperator(deps: BootstrapDeps): Promise<{
150 id: string;
151 username: string;
152} | null> {
153 const { db, schema } = deps;
154 // Try site_admins first.
155 try {
156 const rows = await db
157 .select({ id: schema.users.id, username: schema.users.username })
158 .from(schema.siteAdmins)
159 .innerJoin(schema.users, eq(schema.siteAdmins.userId, schema.users.id))
160 .limit(1);
161 if (rows.length > 0 && rows[0]?.id) {
162 return { id: rows[0].id, username: rows[0].username };
163 }
164 } catch (err) {
165 deps.log.warn(
166 `site_admins lookup failed (${(err as Error).message}) — falling back to oldest user`
167 );
168 }
169 // Fallback: oldest user (the bootstrap rule, matches lib/admin.ts).
170 try {
171 const rows = await db
172 .select({ id: schema.users.id, username: schema.users.username })
173 .from(schema.users)
174 .orderBy(asc(schema.users.createdAt))
175 .limit(1);
176 if (rows.length > 0 && rows[0]?.id) {
177 return { id: rows[0].id, username: rows[0].username };
178 }
179 } catch (err) {
180 deps.log.warn(`users lookup failed: ${(err as Error).message}`);
181 }
182 return null;
183}
184
185// ── 3. Ensure repositories row ────────────────────────────────────────────
186export async function ensureRepoRow(
187 deps: BootstrapDeps,
188 args: { owner: string; name: string; ownerUserId: string; diskPath: string }
189): Promise<{ id: string; created: boolean } | null> {
190 const { db, schema } = deps;
191 try {
192 const existing = await db
193 .select({ id: schema.repositories.id })
194 .from(schema.repositories)
195 .where(
196 and(
197 eq(schema.repositories.ownerId, args.ownerUserId),
198 eq(schema.repositories.name, args.name)
199 )
200 )
201 .limit(1);
202 if (existing.length > 0 && existing[0]?.id) {
203 return { id: existing[0].id, created: false };
204 }
205 const inserted = await db
206 .insert(schema.repositories)
207 .values({
208 name: args.name,
209 ownerId: args.ownerUserId,
210 isPrivate: false,
211 defaultBranch: "main",
212 diskPath: args.diskPath,
213 description: "Gluecron itself — self-hosted on Gluecron.",
214 })
215 .returning({ id: schema.repositories.id });
216 return { id: inserted[0]?.id ?? "", created: true };
217 } catch (err) {
218 deps.log.bad(
219 `repositories insert/select failed: ${(err as Error).message}`
220 );
221 return null;
222 }
223}
224
225// ── 4. git init --bare (idempotent) ───────────────────────────────────────
226export async function ensureBareRepo(
227 deps: BootstrapDeps,
228 barePath: string
229): Promise<boolean> {
230 if (deps.fsExists(join(barePath, "HEAD"))) {
231 deps.log.ok(`bare repo already exists at ${barePath}`);
232 return false;
233 }
234 await deps.fsMkdir(barePath, { recursive: true });
235 const init = await deps.sh(["git", "init", "--bare", barePath]);
236 if (!init.ok) {
237 deps.log.bad(`git init --bare failed: ${init.stderr.trim()}`);
238 throw new Error("git init --bare failed");
239 }
240 // Default branch = main.
241 const sym = await deps.sh(
242 ["git", "symbolic-ref", "HEAD", "refs/heads/main"],
243 { cwd: barePath }
244 );
245 if (!sym.ok) deps.log.warn(`symbolic-ref main failed: ${sym.stderr.trim()}`);
246 deps.log.ok(`created bare repo at ${barePath}`);
247 return true;
248}
249
250// ── 5. Mirror from GitHub source ──────────────────────────────────────────
251export async function mirrorFromSource(
252 deps: BootstrapDeps,
253 source: string,
254 barePath: string
255): Promise<boolean> {
256 const stamp = Date.now().toString(36);
257 const tmp = join(deps.tmpRoot, `gluecron-mirror-${stamp}.git`);
258 try {
259 const clone = await deps.sh(["git", "clone", "--mirror", source, tmp]);
260 if (!clone.ok) {
261 deps.log.bad(`git clone --mirror failed: ${clone.stderr.trim()}`);
262 return false;
263 }
264 deps.log.ok(`cloned ${source} → ${tmp}`);
265 const push = await deps.sh(["git", "push", "--mirror", barePath], {
266 cwd: tmp,
267 });
268 if (!push.ok) {
269 deps.log.bad(`git push --mirror failed: ${push.stderr.trim()}`);
270 return false;
271 }
272 deps.log.ok(`mirrored every ref into ${barePath}`);
273 return true;
274 } finally {
275 // Best-effort cleanup.
276 try {
277 await deps.fsRm(tmp, { recursive: true, force: true });
278 } catch {
279 /* ignore */
280 }
281 }
282}
283
284// ── 6. Install post-receive hook on the bare repo ─────────────────────────
285//
286// The hook script forwards every push through the Gluecron HTTP receive-
287// pack pipeline. In practice the in-process post-receive logic fires from
288// `src/routes/git.ts` after `serviceRpc(...)`, not from this on-disk hook
289// — but we still install a hook here so external `git push` over SSH (the
290// future protocol) goes through the same intelligence path.
291//
292// For HTTP receive-pack today, this hook acts as a marker + diagnostic
293// breadcrumb: when present, the operator knows the bare repo is wired for
294// self-host. We log to /var/log/gluecron-self-deploy.log via the
295// scripts/self-deploy.sh helper.
296export const SELF_HOST_HOOK_BODY = `#!/usr/bin/env bash
297# Auto-installed by scripts/self-host-bootstrap.ts (BLOCK W).
298# Forwards post-receive notification to the Gluecron self-deploy script.
299# The Gluecron HTTP receive-pack path already invokes the in-process
300# onPostReceive() (src/hooks/post-receive.ts) and triggers self-deploy when
301# SELF_HOST_REPO matches. This hook is the equivalent breadcrumb for SSH
302# receive-pack and external direct-push scenarios.
303set -euo pipefail
bf19c50Test User304# Source the operator env file so GLUECRON_SELF_DEPLOY_SCRIPT and any other
305# overrides set by the operator are visible to this shell hook. systemd's
306# EnvironmentFile only flows to the gluecron service, not to git hooks
307# invoked by direct receive-pack — without this source the hook used the
308# baked-in defaults which silently disagreed with /etc/gluecron.env.
309if [ -f /etc/gluecron.env ]; then
310 set -a
311 . /etc/gluecron.env
312 set +a
313fi
f2c00b4CC LABS App314SELF_DEPLOY="\${GLUECRON_SELF_DEPLOY_SCRIPT:-/opt/gluecron/scripts/self-deploy.sh}"
315LOG="\${GLUECRON_SELF_DEPLOY_LOG:-/var/log/gluecron-self-deploy.log}"
316if [ -x "$SELF_DEPLOY" ]; then
317 # Read each pushed ref from stdin and only fire on main.
318 while read -r oldsha newsha refname; do
319 if [ "$refname" = "refs/heads/main" ]; then
320 # Detach so git push returns immediately.
321 nohup "$SELF_DEPLOY" "$oldsha" "$newsha" >>"$LOG" 2>&1 &
322 disown || true
323 echo "[self-host] dispatched $SELF_DEPLOY for $newsha" >&2
324 fi
325 done
326fi
327exit 0
328`;
329
330export async function installPostReceiveHook(
331 deps: BootstrapDeps,
332 barePath: string
333): Promise<boolean> {
334 const hookPath = join(barePath, "hooks", "post-receive");
335 try {
336 await deps.fsMkdir(join(barePath, "hooks"), { recursive: true });
337 await deps.fsWrite(hookPath, SELF_HOST_HOOK_BODY);
338 await deps.fsChmod(hookPath, 0o755);
339 deps.log.ok(`installed post-receive hook at ${hookPath}`);
340 return true;
341 } catch (err) {
342 deps.log.bad(`hook install failed: ${(err as Error).message}`);
343 return false;
344 }
345}
346
347// ── orchestrator (pure, DI'd) ─────────────────────────────────────────────
348export async function runBootstrap(
349 args: BootstrapArgs,
350 deps: BootstrapDeps
351): Promise<BootstrapResult> {
352 const result: BootstrapResult = {
353 ok: false,
354 steps: {
355 operator: null,
356 repoRow: null,
357 bareRepoCreated: false,
358 mirrored: false,
359 hookInstalled: false,
360 },
361 };
362
363 deps.log.say(`gluecron self-host bootstrap — ${args.owner}/${args.name}`);
364 deps.log.info(`source : ${args.source}`);
365 deps.log.info(`repos : ${deps.reposPath}`);
366 if (args.dryRun) deps.log.info("dry-run : no DB writes, no on-disk changes");
367
368 // 1. Operator
369 deps.log.say("[1/6] locating operator (site_admins → oldest user)");
370 const op = await findOperator(deps);
371 if (!op) {
372 result.error = "no users exist — register an account first";
373 deps.log.bad(result.error);
374 return result;
375 }
376 result.steps.operator = op;
377 deps.log.ok(`operator: ${op.username} (${op.id})`);
378
379 // 2. Compute disk path
380 const barePath = join(deps.reposPath, args.owner, `${args.name}.git`);
381 deps.log.info(`bare path: ${barePath}`);
382
383 // 3. Ensure repositories row
384 deps.log.say("[2/6] ensuring repositories row exists");
385 if (args.dryRun) {
386 deps.log.info(
387 `dry-run: would INSERT repositories(name=${args.name}, ownerId=${op.id})`
388 );
389 result.steps.repoRow = { id: "(dry-run)", created: false };
390 } else {
391 const row = await ensureRepoRow(deps, {
392 owner: args.owner,
393 name: args.name,
394 ownerUserId: op.id,
395 diskPath: barePath,
396 });
397 if (!row) {
398 result.error = "could not create repositories row — aborting";
399 return result;
400 }
401 result.steps.repoRow = row;
402 deps.log.ok(
403 row.created
404 ? `inserted repositories row id=${row.id}`
405 : `repositories row already exists id=${row.id}`
406 );
407 }
408
409 // 4. Bare repo
410 deps.log.say("[3/6] ensuring bare git repo on disk");
411 if (args.dryRun) {
412 deps.log.info(`dry-run: would git init --bare ${barePath}`);
413 } else {
414 try {
415 result.steps.bareRepoCreated = await ensureBareRepo(deps, barePath);
416 } catch (err) {
417 result.error = `bare repo init failed: ${(err as Error).message}`;
418 return result;
419 }
420 }
421
422 // 5. Mirror
423 deps.log.say("[4/6] mirroring source → bare repo");
424 if (args.dryRun) {
425 deps.log.info(`dry-run: would git clone --mirror ${args.source} | push --mirror`);
426 result.steps.mirrored = true;
427 } else {
428 result.steps.mirrored = await mirrorFromSource(deps, args.source, barePath);
429 if (!result.steps.mirrored) {
430 result.error = "mirror failed — see above";
431 return result;
432 }
433 }
434
435 // 6. Hook
436 deps.log.say("[5/6] installing self-host post-receive hook");
437 if (args.dryRun) {
438 deps.log.info(`dry-run: would write ${join(barePath, "hooks/post-receive")}`);
439 result.steps.hookInstalled = true;
440 } else {
441 result.steps.hookInstalled = await installPostReceiveHook(deps, barePath);
442 }
443
444 // 7. Cutover instructions
445 deps.log.say("[6/6] done — cutover instructions");
446 result.ok =
447 result.steps.repoRow !== null &&
448 result.steps.mirrored &&
449 result.steps.hookInstalled;
450 printCutover(args, deps);
451 return result;
452}
453
454export function printCutover(args: BootstrapArgs, deps: BootstrapDeps): void {
455 const repoUrl = `https://gluecron.com/${args.owner}/${args.name}.git`;
456 deps.log.info("");
457 deps.log.info("=============================================================");
458 deps.log.info(" Self-host complete. Next steps (in order):");
459 deps.log.info("=============================================================");
460 deps.log.info("");
461 deps.log.info(" 1. On your laptop (this terminal):");
462 deps.log.info(` git remote set-url origin ${repoUrl}`);
463 deps.log.info("");
464 deps.log.info(" 2. On the production box (same change in /opt/gluecron):");
465 deps.log.info(` cd /opt/gluecron && git remote set-url origin ${repoUrl}`);
466 deps.log.info("");
467 deps.log.info(" 3. Add to /etc/gluecron.env (so the post-receive hook fires):");
468 deps.log.info(` SELF_HOST_REPO=${args.owner}/${args.name}`);
469 deps.log.info("");
470 deps.log.info(" 4. Push a no-op change to verify end-to-end:");
471 deps.log.info(" git commit --allow-empty -m 'self-host smoke' && git push");
472 deps.log.info("");
473 deps.log.info(" From this moment, `git push` deploys directly via Gluecron's");
474 deps.log.info(" post-receive hook in ~25 seconds. No GitHub Actions in the");
475 deps.log.info(" middle. Watch /admin/deploys for the live timeline.");
476 deps.log.info("");
477}
478
479// ── CLI entrypoint ────────────────────────────────────────────────────────
480async function main(): Promise<void> {
481 const args = parseArgs(process.argv.slice(2));
482
483 if (!process.env.DATABASE_URL) {
484 console.error(
485 "FATAL: DATABASE_URL is not set. Source /etc/gluecron.env or export it manually."
486 );
487 process.exit(2);
488 }
489
490 // Lazy import the real DB only at CLI-entry time (same pattern as
491 // scripts/enable-auto-merge.ts) so unit tests can import this module
492 // without booting a Neon connection.
493 const { db } = await import("../src/db");
494 const schemaMod = await import("../src/db/schema");
495 const { config } = await import("../src/lib/config");
496
497 const deps: BootstrapDeps = {
498 db,
499 schema: {
500 users: schemaMod.users,
501 repositories: schemaMod.repositories,
502 siteAdmins: schemaMod.siteAdmins,
503 },
504 reposPath: config.gitReposPath,
505 sh,
506 fsExists: existsSync,
507 fsMkdir: mkdir,
508 fsWrite: (p, body) => writeFile(p, body, "utf8"),
509 fsChmod: chmod,
510 fsRm: rm,
511 log: { say, ok, warn, bad, info: (m) => console.log(` ${m}`) },
512 tmpRoot: tmpdir(),
513 };
514
515 const result = await runBootstrap(args, deps);
516 if (!result.ok) {
517 if (result.error) bad(result.error);
518 process.exit(1);
519 }
520}
521
522// Only run when invoked as a script (not when imported by tests).
523if (import.meta.main) {
524 main().catch((err) => {
525 console.error(err instanceof Error ? err.message : String(err));
526 process.exit(1);
527 });
528}