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