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 | * | |
| 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 | ||
| 42 | import { and, eq, asc } from "drizzle-orm"; | |
| 43 | import { existsSync } from "fs"; | |
| 44 | import { mkdir, writeFile, chmod, rm } from "fs/promises"; | |
| 45 | import { join } from "path"; | |
| 46 | import { tmpdir } from "os"; | |
| 47 | ||
| 48 | // ── pretty printers (match scripts/bootstrap-hetzner.sh + install.sh) ────── | |
| 49 | function say(msg: string): void { | |
| 50 | console.log(""); | |
| 51 | console.log(`==> ${msg}`); | |
| 52 | } | |
| 53 | function ok(msg: string): void { | |
| 54 | console.log(` v ${msg}`); | |
| 55 | } | |
| 56 | function warn(msg: string): void { | |
| 57 | console.log(` ! ${msg}`); | |
| 58 | } | |
| 59 | function bad(msg: string): void { | |
| 60 | console.error(` x ${msg}`); | |
| 61 | } | |
| 62 | ||
| 63 | // ── arg parse ────────────────────────────────────────────────────────────── | |
| 64 | export interface BootstrapArgs { | |
| 65 | owner: string; | |
| 66 | name: string; | |
| 67 | source: string; | |
| 68 | dryRun: boolean; | |
| 69 | } | |
| 70 | ||
| 71 | export 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) ────────────────────────── | |
| 94 | export 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) ────────────────────── | |
| 112 | export 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 | ||
| 136 | export 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 ────────── | |
| 149 | export 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 ──────────────────────────────────────────── | |
| 186 | export 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) ─────────────────────────────────────── | |
| 226 | export 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 ────────────────────────────────────────── | |
| 251 | export 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. | |
| 296 | export 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. | |
| 303 | set -euo pipefail | |
| bf19c50 | 304 | # 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. | |
| 309 | if [ -f /etc/gluecron.env ]; then | |
| 310 | set -a | |
| 311 | . /etc/gluecron.env | |
| 312 | set +a | |
| 313 | fi | |
| f2c00b4 | 314 | SELF_DEPLOY="\${GLUECRON_SELF_DEPLOY_SCRIPT:-/opt/gluecron/scripts/self-deploy.sh}" |
| 315 | LOG="\${GLUECRON_SELF_DEPLOY_LOG:-/var/log/gluecron-self-deploy.log}" | |
| 316 | if [ -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 | |
| 326 | fi | |
| 327 | exit 0 | |
| 328 | `; | |
| 329 | ||
| 330 | export 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) ───────────────────────────────────────────── | |
| 348 | export 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 | ||
| 454 | export 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 ──────────────────────────────────────────────────────── | |
| 480 | async 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). | |
| 523 | if (import.meta.main) { | |
| 524 | main().catch((err) => { | |
| 525 | console.error(err instanceof Error ? err.message : String(err)); | |
| 526 | process.exit(1); | |
| 527 | }); | |
| 528 | } |