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