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
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
self-host-bootstrap.ts17.7 KB · 518 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
/**
 * BLOCK W — Self-host bootstrap.
 *
 * Mirror Gluecron's source from GitHub onto Gluecron itself, ONCE.
 *
 * Usage (run as root on the box, or anywhere DATABASE_URL + GIT_REPOS_PATH
 * resolve to the production values):
 *
 *   bun run scripts/self-host-bootstrap.ts \
 *     [--owner=ccantynz] \
 *     [--name=Gluecron.com] \
 *     [--source=https://github.com/ccantynz-alt/Gluecron.com.git] \
 *     [--dry-run]
 *
 * Idempotent — safe to re-run. Every step prints `v`/`x`/`!` and only
 * fails the script when a step truly cannot proceed.
 *
 * Pre-flight (operator's responsibility BEFORE invoking):
 *   - DATABASE_URL points at the live Neon db (the one the running site reads)
 *   - GIT_REPOS_PATH is set (or default /opt/gluecron/repos is writable)
 *   - `git` is on PATH
 *   - At least one user row exists in `users` (we pick the site-admin or oldest)
 *   - Disk has room for a mirror clone (~200 MB working set + ~50 MB bare repo)
 *
 * What it does:
 *   1. Read env
 *   2. Look up the operator (site_admins → oldest user fallback)
 *   3. INSERT the `repositories` row (skip if it exists)
 *   4. `git init --bare` the on-disk repo (skip if it exists)
 *   5. `git push --mirror` from a temp clone of the GitHub source
 *   6. Install the self-host post-receive hook on the bare repo
 *   7. Print cutover instructions
 *
 * The post-receive hook script just forwards to the runtime
 * `src/hooks/post-receive.ts` entry point via the routes/git.ts pathway —
 * we don't replicate any of the locked Block §4.2 logic. The deploy
 * trigger lives inside the existing hook (the additive SELF_HOST_REPO
 * block at the end of `onPostReceive`).
 */
/* eslint-disable @typescript-eslint/no-explicit-any */

import { and, eq, asc } from "drizzle-orm";
import { existsSync } from "fs";
import { mkdir, writeFile, chmod, rm } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";

// ── pretty printers (match scripts/bootstrap-hetzner.sh + install.sh) ──────
function say(msg: string): void {
  console.log("");
  console.log(`==> ${msg}`);
}
function ok(msg: string): void {
  console.log(`    v ${msg}`);
}
function warn(msg: string): void {
  console.log(`    ! ${msg}`);
}
function bad(msg: string): void {
  console.error(`    x ${msg}`);
}

// ── arg parse ──────────────────────────────────────────────────────────────
export interface BootstrapArgs {
  owner: string;
  name: string;
  source: string;
  dryRun: boolean;
}

export function parseArgs(argv: string[]): BootstrapArgs {
  const out: BootstrapArgs = {
    owner: "ccantynz",
    name: "Gluecron.com",
    source: "https://github.com/ccantynz-alt/Gluecron.com.git",
    dryRun: false,
  };
  for (const a of argv) {
    if (a === "--dry-run") {
      out.dryRun = true;
      continue;
    }
    const m = a.match(/^--([^=]+)=(.*)$/);
    if (!m) continue;
    const [, k, v] = m;
    if (k === "owner") out.owner = v!;
    else if (k === "name") out.name = v!;
    else if (k === "source") out.source = v!;
  }
  return out;
}

// ── shell helper (small wrapper around Bun.spawn) ──────────────────────────
export async function sh(
  cmd: string[],
  opts: { cwd?: string } = {}
): Promise<{ ok: boolean; stdout: string; stderr: string; exitCode: number }> {
  const proc = Bun.spawn(cmd, {
    cwd: opts.cwd,
    stdout: "pipe",
    stderr: "pipe",
  });
  const [stdout, stderr] = await Promise.all([
    new Response(proc.stdout).text(),
    new Response(proc.stderr).text(),
  ]);
  const exitCode = await proc.exited;
  return { ok: exitCode === 0, stdout, stderr, exitCode };
}

// ── DI seam for the orchestrator (tests inject fakes) ──────────────────────
export interface BootstrapDeps {
  db: any;
  schema: {
    users: any;
    repositories: any;
    siteAdmins: any;
  };
  reposPath: string;
  sh: typeof sh;
  fsExists: (p: string) => boolean;
  fsMkdir: (p: string, opts?: { recursive?: boolean }) => Promise<unknown>;
  fsWrite: (p: string, body: string) => Promise<unknown>;
  fsChmod: (p: string, mode: number) => Promise<unknown>;
  fsRm: (p: string, opts?: { recursive?: boolean; force?: boolean }) => Promise<unknown>;
  log: {
    say: (m: string) => void;
    ok: (m: string) => void;
    warn: (m: string) => void;
    bad: (m: string) => void;
    info: (m: string) => void;
  };
  tmpRoot: string;
}

export interface BootstrapResult {
  ok: boolean;
  steps: {
    operator: { id: string; username: string } | null;
    repoRow: { id: string; created: boolean } | null;
    bareRepoCreated: boolean;
    mirrored: boolean;
    hookInstalled: boolean;
  };
  error?: string;
}

// ── 2. Find operator: site_admins LIMIT 1 → oldest user fallback ──────────
export async function findOperator(deps: BootstrapDeps): Promise<{
  id: string;
  username: string;
} | null> {
  const { db, schema } = deps;
  // Try site_admins first.
  try {
    const rows = await db
      .select({ id: schema.users.id, username: schema.users.username })
      .from(schema.siteAdmins)
      .innerJoin(schema.users, eq(schema.siteAdmins.userId, schema.users.id))
      .limit(1);
    if (rows.length > 0 && rows[0]?.id) {
      return { id: rows[0].id, username: rows[0].username };
    }
  } catch (err) {
    deps.log.warn(
      `site_admins lookup failed (${(err as Error).message}) — falling back to oldest user`
    );
  }
  // Fallback: oldest user (the bootstrap rule, matches lib/admin.ts).
  try {
    const rows = await db
      .select({ id: schema.users.id, username: schema.users.username })
      .from(schema.users)
      .orderBy(asc(schema.users.createdAt))
      .limit(1);
    if (rows.length > 0 && rows[0]?.id) {
      return { id: rows[0].id, username: rows[0].username };
    }
  } catch (err) {
    deps.log.warn(`users lookup failed: ${(err as Error).message}`);
  }
  return null;
}

// ── 3. Ensure repositories row ────────────────────────────────────────────
export async function ensureRepoRow(
  deps: BootstrapDeps,
  args: { owner: string; name: string; ownerUserId: string; diskPath: string }
): Promise<{ id: string; created: boolean } | null> {
  const { db, schema } = deps;
  try {
    const existing = await db
      .select({ id: schema.repositories.id })
      .from(schema.repositories)
      .where(
        and(
          eq(schema.repositories.ownerId, args.ownerUserId),
          eq(schema.repositories.name, args.name)
        )
      )
      .limit(1);
    if (existing.length > 0 && existing[0]?.id) {
      return { id: existing[0].id, created: false };
    }
    const inserted = await db
      .insert(schema.repositories)
      .values({
        name: args.name,
        ownerId: args.ownerUserId,
        isPrivate: false,
        defaultBranch: "main",
        diskPath: args.diskPath,
        description: "Gluecron itself — self-hosted on Gluecron.",
      })
      .returning({ id: schema.repositories.id });
    return { id: inserted[0]?.id ?? "", created: true };
  } catch (err) {
    deps.log.bad(
      `repositories insert/select failed: ${(err as Error).message}`
    );
    return null;
  }
}

// ── 4. git init --bare (idempotent) ───────────────────────────────────────
export async function ensureBareRepo(
  deps: BootstrapDeps,
  barePath: string
): Promise<boolean> {
  if (deps.fsExists(join(barePath, "HEAD"))) {
    deps.log.ok(`bare repo already exists at ${barePath}`);
    return false;
  }
  await deps.fsMkdir(barePath, { recursive: true });
  const init = await deps.sh(["git", "init", "--bare", barePath]);
  if (!init.ok) {
    deps.log.bad(`git init --bare failed: ${init.stderr.trim()}`);
    throw new Error("git init --bare failed");
  }
  // Default branch = main.
  const sym = await deps.sh(
    ["git", "symbolic-ref", "HEAD", "refs/heads/main"],
    { cwd: barePath }
  );
  if (!sym.ok) deps.log.warn(`symbolic-ref main failed: ${sym.stderr.trim()}`);
  deps.log.ok(`created bare repo at ${barePath}`);
  return true;
}

// ── 5. Mirror from GitHub source ──────────────────────────────────────────
export async function mirrorFromSource(
  deps: BootstrapDeps,
  source: string,
  barePath: string
): Promise<boolean> {
  const stamp = Date.now().toString(36);
  const tmp = join(deps.tmpRoot, `gluecron-mirror-${stamp}.git`);
  try {
    const clone = await deps.sh(["git", "clone", "--mirror", source, tmp]);
    if (!clone.ok) {
      deps.log.bad(`git clone --mirror failed: ${clone.stderr.trim()}`);
      return false;
    }
    deps.log.ok(`cloned ${source}${tmp}`);
    const push = await deps.sh(["git", "push", "--mirror", barePath], {
      cwd: tmp,
    });
    if (!push.ok) {
      deps.log.bad(`git push --mirror failed: ${push.stderr.trim()}`);
      return false;
    }
    deps.log.ok(`mirrored every ref into ${barePath}`);
    return true;
  } finally {
    // Best-effort cleanup.
    try {
      await deps.fsRm(tmp, { recursive: true, force: true });
    } catch {
      /* ignore */
    }
  }
}

// ── 6. Install post-receive hook on the bare repo ─────────────────────────
//
// The hook script forwards every push through the Gluecron HTTP receive-
// pack pipeline. In practice the in-process post-receive logic fires from
// `src/routes/git.ts` after `serviceRpc(...)`, not from this on-disk hook
// — but we still install a hook here so external `git push` over SSH (the
// future protocol) goes through the same intelligence path.
//
// For HTTP receive-pack today, this hook acts as a marker + diagnostic
// breadcrumb: when present, the operator knows the bare repo is wired for
// self-host. We log to /var/log/gluecron-self-deploy.log via the
// scripts/self-deploy.sh helper.
export const SELF_HOST_HOOK_BODY = `#!/usr/bin/env bash
# Auto-installed by scripts/self-host-bootstrap.ts (BLOCK W).
# Forwards post-receive notification to the Gluecron self-deploy script.
# The Gluecron HTTP receive-pack path already invokes the in-process
# onPostReceive() (src/hooks/post-receive.ts) and triggers self-deploy when
# SELF_HOST_REPO matches. This hook is the equivalent breadcrumb for SSH
# receive-pack and external direct-push scenarios.
set -euo pipefail
SELF_DEPLOY="\${GLUECRON_SELF_DEPLOY_SCRIPT:-/opt/gluecron/scripts/self-deploy.sh}"
LOG="\${GLUECRON_SELF_DEPLOY_LOG:-/var/log/gluecron-self-deploy.log}"
if [ -x "$SELF_DEPLOY" ]; then
  # Read each pushed ref from stdin and only fire on main.
  while read -r oldsha newsha refname; do
    if [ "$refname" = "refs/heads/main" ]; then
      # Detach so git push returns immediately.
      nohup "$SELF_DEPLOY" "$oldsha" "$newsha" >>"$LOG" 2>&1 &
      disown || true
      echo "[self-host] dispatched $SELF_DEPLOY for $newsha" >&2
    fi
  done
fi
exit 0
`;

export async function installPostReceiveHook(
  deps: BootstrapDeps,
  barePath: string
): Promise<boolean> {
  const hookPath = join(barePath, "hooks", "post-receive");
  try {
    await deps.fsMkdir(join(barePath, "hooks"), { recursive: true });
    await deps.fsWrite(hookPath, SELF_HOST_HOOK_BODY);
    await deps.fsChmod(hookPath, 0o755);
    deps.log.ok(`installed post-receive hook at ${hookPath}`);
    return true;
  } catch (err) {
    deps.log.bad(`hook install failed: ${(err as Error).message}`);
    return false;
  }
}

// ── orchestrator (pure, DI'd) ─────────────────────────────────────────────
export async function runBootstrap(
  args: BootstrapArgs,
  deps: BootstrapDeps
): Promise<BootstrapResult> {
  const result: BootstrapResult = {
    ok: false,
    steps: {
      operator: null,
      repoRow: null,
      bareRepoCreated: false,
      mirrored: false,
      hookInstalled: false,
    },
  };

  deps.log.say(`gluecron self-host bootstrap — ${args.owner}/${args.name}`);
  deps.log.info(`source : ${args.source}`);
  deps.log.info(`repos  : ${deps.reposPath}`);
  if (args.dryRun) deps.log.info("dry-run : no DB writes, no on-disk changes");

  // 1. Operator
  deps.log.say("[1/6] locating operator (site_admins → oldest user)");
  const op = await findOperator(deps);
  if (!op) {
    result.error = "no users exist — register an account first";
    deps.log.bad(result.error);
    return result;
  }
  result.steps.operator = op;
  deps.log.ok(`operator: ${op.username} (${op.id})`);

  // 2. Compute disk path
  const barePath = join(deps.reposPath, args.owner, `${args.name}.git`);
  deps.log.info(`bare path: ${barePath}`);

  // 3. Ensure repositories row
  deps.log.say("[2/6] ensuring repositories row exists");
  if (args.dryRun) {
    deps.log.info(
      `dry-run: would INSERT repositories(name=${args.name}, ownerId=${op.id})`
    );
    result.steps.repoRow = { id: "(dry-run)", created: false };
  } else {
    const row = await ensureRepoRow(deps, {
      owner: args.owner,
      name: args.name,
      ownerUserId: op.id,
      diskPath: barePath,
    });
    if (!row) {
      result.error = "could not create repositories row — aborting";
      return result;
    }
    result.steps.repoRow = row;
    deps.log.ok(
      row.created
        ? `inserted repositories row id=${row.id}`
        : `repositories row already exists id=${row.id}`
    );
  }

  // 4. Bare repo
  deps.log.say("[3/6] ensuring bare git repo on disk");
  if (args.dryRun) {
    deps.log.info(`dry-run: would git init --bare ${barePath}`);
  } else {
    try {
      result.steps.bareRepoCreated = await ensureBareRepo(deps, barePath);
    } catch (err) {
      result.error = `bare repo init failed: ${(err as Error).message}`;
      return result;
    }
  }

  // 5. Mirror
  deps.log.say("[4/6] mirroring source → bare repo");
  if (args.dryRun) {
    deps.log.info(`dry-run: would git clone --mirror ${args.source} | push --mirror`);
    result.steps.mirrored = true;
  } else {
    result.steps.mirrored = await mirrorFromSource(deps, args.source, barePath);
    if (!result.steps.mirrored) {
      result.error = "mirror failed — see above";
      return result;
    }
  }

  // 6. Hook
  deps.log.say("[5/6] installing self-host post-receive hook");
  if (args.dryRun) {
    deps.log.info(`dry-run: would write ${join(barePath, "hooks/post-receive")}`);
    result.steps.hookInstalled = true;
  } else {
    result.steps.hookInstalled = await installPostReceiveHook(deps, barePath);
  }

  // 7. Cutover instructions
  deps.log.say("[6/6] done — cutover instructions");
  result.ok =
    result.steps.repoRow !== null &&
    result.steps.mirrored &&
    result.steps.hookInstalled;
  printCutover(args, deps);
  return result;
}

export function printCutover(args: BootstrapArgs, deps: BootstrapDeps): void {
  const repoUrl = `https://gluecron.com/${args.owner}/${args.name}.git`;
  deps.log.info("");
  deps.log.info("=============================================================");
  deps.log.info("  Self-host complete. Next steps (in order):");
  deps.log.info("=============================================================");
  deps.log.info("");
  deps.log.info("  1. On your laptop (this terminal):");
  deps.log.info(`       git remote set-url origin ${repoUrl}`);
  deps.log.info("");
  deps.log.info("  2. On the production box (same change in /opt/gluecron):");
  deps.log.info(`       cd /opt/gluecron && git remote set-url origin ${repoUrl}`);
  deps.log.info("");
  deps.log.info("  3. Add to /etc/gluecron.env (so the post-receive hook fires):");
  deps.log.info(`       SELF_HOST_REPO=${args.owner}/${args.name}`);
  deps.log.info("");
  deps.log.info("  4. Push a no-op change to verify end-to-end:");
  deps.log.info("       git commit --allow-empty -m 'self-host smoke' && git push");
  deps.log.info("");
  deps.log.info("  From this moment, `git push` deploys directly via Gluecron's");
  deps.log.info("  post-receive hook in ~25 seconds. No GitHub Actions in the");
  deps.log.info("  middle. Watch /admin/deploys for the live timeline.");
  deps.log.info("");
}

// ── CLI entrypoint ────────────────────────────────────────────────────────
async function main(): Promise<void> {
  const args = parseArgs(process.argv.slice(2));

  if (!process.env.DATABASE_URL) {
    console.error(
      "FATAL: DATABASE_URL is not set. Source /etc/gluecron.env or export it manually."
    );
    process.exit(2);
  }

  // Lazy import the real DB only at CLI-entry time (same pattern as
  // scripts/enable-auto-merge.ts) so unit tests can import this module
  // without booting a Neon connection.
  const { db } = await import("../src/db");
  const schemaMod = await import("../src/db/schema");
  const { config } = await import("../src/lib/config");

  const deps: BootstrapDeps = {
    db,
    schema: {
      users: schemaMod.users,
      repositories: schemaMod.repositories,
      siteAdmins: schemaMod.siteAdmins,
    },
    reposPath: config.gitReposPath,
    sh,
    fsExists: existsSync,
    fsMkdir: mkdir,
    fsWrite: (p, body) => writeFile(p, body, "utf8"),
    fsChmod: chmod,
    fsRm: rm,
    log: { say, ok, warn, bad, info: (m) => console.log(`    ${m}`) },
    tmpRoot: tmpdir(),
  };

  const result = await runBootstrap(args, deps);
  if (!result.ok) {
    if (result.error) bad(result.error);
    process.exit(1);
  }
}

// Only run when invoked as a script (not when imported by tests).
if (import.meta.main) {
  main().catch((err) => {
    console.error(err instanceof Error ? err.message : String(err));
    process.exit(1);
  });
}