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.
| f2c00b4 | 1 | /** |
| 2 | * BLOCK W — Self-host bootstrap. | |
| 3 | * | |
| 8ee4e5d | 4 | * 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. | |
| f2c00b4 | 8 | * |
| 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] \ | |
| 8ee4e5d | 15 | * [--source=<git-url-to-reseed-from>] \ |
| f2c00b4 | 16 | * [--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) | |
| 8ee4e5d | 33 | * 5. If --source given, mirror it in; otherwise leave the bare repo empty |
| f2c00b4 | 34 | * 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 | ||
| 45 | import { and, eq, asc } from "drizzle-orm"; | |
| 46 | import { existsSync } from "fs"; | |
| 47 | import { mkdir, writeFile, chmod, rm } from "fs/promises"; | |
| 48 | import { join } from "path"; | |
| 49 | import { tmpdir } from "os"; | |
| 50 | ||
| 51 | // ── pretty printers (match scripts/bootstrap-hetzner.sh + install.sh) ────── | |
| 52 | function say(msg: string): void { | |
| 53 | console.log(""); | |
| 54 | console.log(`==> ${msg}`); | |
| 55 | } | |
| 56 | function ok(msg: string): void { | |
| 57 | console.log(` v ${msg}`); | |
| 58 | } | |
| 59 | function warn(msg: string): void { | |
| 60 | console.log(` ! ${msg}`); | |
| 61 | } | |
| 62 | function bad(msg: string): void { | |
| 63 | console.error(` x ${msg}`); | |
| 64 | } | |
| 65 | ||
| 66 | // ── arg parse ────────────────────────────────────────────────────────────── | |
| 67 | export interface BootstrapArgs { | |
| 68 | owner: string; | |
| 69 | name: string; | |
| 70 | source: string; | |
| 71 | dryRun: boolean; | |
| 72 | } | |
| 73 | ||
| 74 | export function parseArgs(argv: string[]): BootstrapArgs { | |
| 75 | const out: BootstrapArgs = { | |
| 76 | owner: "ccantynz", | |
| 77 | name: "Gluecron.com", | |
| 8ee4e5d | 78 | // 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: "", | |
| f2c00b4 | 81 | 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) ────────────────────────── | |
| 99 | export 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) ────────────────────── | |
| 117 | export 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 | ||
| 141 | export 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 ────────── | |
| 154 | export 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 ──────────────────────────────────────────── | |
| 191 | export 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) ─────────────────────────────────────── | |
| 231 | export 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 | ||
| 8ee4e5d | 255 | // ── 5. Mirror from an explicit seed source (re-seed only; not GitHub) ────── |
| f2c00b4 | 256 | export 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. | |
| 301 | export 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. | |
| 308 | set -euo pipefail | |
| bf19c50 | 309 | # 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. | |
| 314 | if [ -f /etc/gluecron.env ]; then | |
| 315 | set -a | |
| 316 | . /etc/gluecron.env | |
| 317 | set +a | |
| 318 | fi | |
| f2c00b4 | 319 | SELF_DEPLOY="\${GLUECRON_SELF_DEPLOY_SCRIPT:-/opt/gluecron/scripts/self-deploy.sh}" |
| 320 | LOG="\${GLUECRON_SELF_DEPLOY_LOG:-/var/log/gluecron-self-deploy.log}" | |
| 321 | if [ -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 | |
| 331 | fi | |
| 332 | exit 0 | |
| 333 | `; | |
| 334 | ||
| 335 | export 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) ───────────────────────────────────────────── | |
| 353 | export 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 | ||
| 8ee4e5d | 427 | // 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. | |
| f2c00b4 | 430 | deps.log.say("[4/6] mirroring source → bare repo"); |
| 8ee4e5d | 431 | 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) { | |
| f2c00b4 | 435 | 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 | ||
| 464 | export 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 ──────────────────────────────────────────────────────── | |
| 490 | async 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). | |
| 533 | if (import.meta.main) { | |
| 534 | main().catch((err) => { | |
| 535 | console.error(err instanceof Error ? err.message : String(err)); | |
| 536 | process.exit(1); | |
| 537 | }); | |
| 538 | } |