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

admin-self-host.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

admin-self-host.tsxBlame553 lines · 2 contributors
f2c00b4CC LABS App1/**
2 * BLOCK W — `/admin/self-host` site-admin self-host dashboard.
3 *
4 * One-page status of the Gluecron self-host migration:
5 * - is the Gluecron.com repo mirrored to Gluecron itself?
6 * - is the post-receive hook installed on the bare repo on disk?
7 * - is SELF_HOST_REPO set in the running process's env?
8 * - last 10 self-deploys (read from platform_deploys where source='self-deploy')
9 *
10 * POST /admin/self-host/bootstrap kicks off the bootstrap script. Same
11 * security model as /admin/ops: requireAuth + isSiteAdmin gate + audit log.
12 */
13/* eslint-disable @typescript-eslint/no-explicit-any */
14
15import { Hono } from "hono";
16import { and, desc, eq } from "drizzle-orm";
17import { existsSync } from "fs";
18import { join } from "path";
19import { db } from "../db";
20import { repositories, users } from "../db/schema";
21import { platformDeploys } from "../db/schema-deploys";
22import { Layout } from "../views/layout";
23import { softAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25import { isSiteAdmin } from "../lib/admin";
26import { audit as realAudit } from "../lib/notify";
27import { config } from "../lib/config";
28import { relativeTime, shortSha } from "./admin-deploys-page";
29
30// ---------------------------------------------------------------------------
31// DI seam — tests inject fakes so we never spawn the real bootstrap.
32// ---------------------------------------------------------------------------
33
34type AuditFn = typeof realAudit;
35type BootstrapSpawnFn = (
36 cmd: string[],
37 opts: { detached?: boolean }
38) => unknown;
39type FsExistsFn = (p: string) => boolean;
40
41interface Deps {
42 audit: AuditFn;
43 spawn: BootstrapSpawnFn;
44 fsExists: FsExistsFn;
45 getEnv: () => Record<string, string | undefined>;
46}
47
bf19c50Test User48/**
49 * Bootstrap log path. The POST handler redirects stdout + stderr here so the
50 * operator can `tail -f` it (or we can read the last N lines on the next
51 * page render to surface errors). Previously every output stream was set to
52 * "ignore", which meant the operator saw "Bootstrap dispatched" toast even
53 * when the script crashed with bun-not-found / DATABASE_URL-missing /
54 * GitHub-clone-failed. P0 from the May 15 audit.
55 */
56export const BOOTSTRAP_LOG_PATH = "/var/log/gluecron-bootstrap.log";
57
f2c00b4CC LABS App58const REAL_DEPS: Deps = {
59 audit: realAudit,
bf19c50Test User60 spawn: (cmd, _opts) => {
61 // Open the log file for append; if the open fails (perm issue, missing
62 // /var/log) fall back to inherit so output at least goes to journalctl.
63 let stdout: any = "inherit";
64 let stderr: any = "inherit";
65 try {
66 const log = Bun.file(BOOTSTRAP_LOG_PATH);
67 // Truncate the previous run's output so the operator sees only the
68 // current attempt. `Bun.write` is sync-ish and returns a promise we
69 // don't need to await — the spawn happens regardless.
70 void Bun.write(
71 BOOTSTRAP_LOG_PATH,
72 `[${new Date().toISOString()}] bootstrap dispatched: ${cmd.join(" ")}\n`
73 );
74 stdout = log.writer();
75 stderr = log.writer();
76 } catch {
77 // Fall back to inherit
78 }
79 return Bun.spawn(cmd, { stdout, stderr, stdin: "ignore" });
80 },
f2c00b4CC LABS App81 fsExists: existsSync,
82 getEnv: () => process.env as Record<string, string | undefined>,
83};
84
85let _deps: Deps = REAL_DEPS;
86
87/** Test-only: replace one or more collaborators. Pass `null` to reset. */
88export function __setSelfHostDepsForTests(d: Partial<Deps> | null): void {
89 _deps = d ? { ...REAL_DEPS, ..._deps, ...d } : REAL_DEPS;
90}
91
92// ---------------------------------------------------------------------------
93// Constants — the repo we self-host.
94// ---------------------------------------------------------------------------
95
96const SELF_HOST_OWNER = "ccantynz";
97const SELF_HOST_NAME = "Gluecron.com";
98const SELF_HOST_FULL = `${SELF_HOST_OWNER}/${SELF_HOST_NAME}`;
99const SELF_DEPLOY_SOURCE = "self-deploy";
100
101// ---------------------------------------------------------------------------
102// Status reads — every probe wrapped so a single failure doesn't 500 the page.
103// ---------------------------------------------------------------------------
104
105interface RepoState {
106 exists: boolean;
107 diskPath: string | null;
108}
109
110async function readRepoState(): Promise<RepoState> {
111 try {
112 const [ownerRow] = await db
113 .select({ id: users.id })
114 .from(users)
115 .where(eq(users.username, SELF_HOST_OWNER))
116 .limit(1);
117 if (!ownerRow) return { exists: false, diskPath: null };
118 const [repo] = await db
119 .select({ id: repositories.id, diskPath: repositories.diskPath })
120 .from(repositories)
121 .where(
122 and(
123 eq(repositories.ownerId, ownerRow.id),
124 eq(repositories.name, SELF_HOST_NAME)
125 )
126 )
127 .limit(1);
128 if (!repo) return { exists: false, diskPath: null };
129 return { exists: true, diskPath: repo.diskPath };
130 } catch (err) {
131 console.error("[admin-self-host] readRepoState:", err);
132 return { exists: false, diskPath: null };
133 }
134}
135
136interface HookState {
137 installed: boolean;
138 path: string;
139}
140
141function readHookState(diskPath: string | null): HookState {
142 // Where the bootstrap installs the hook. If we can't resolve the repo
143 // row we fall back to the conventional path so the operator can still
144 // see the expected location.
145 const base =
146 diskPath ||
147 join(config.gitReposPath, SELF_HOST_OWNER, `${SELF_HOST_NAME}.git`);
148 const path = join(base, "hooks", "post-receive");
149 try {
150 return { installed: _deps.fsExists(path), path };
151 } catch {
152 return { installed: false, path };
153 }
154}
155
156interface EnvState {
157 selfHostRepoSet: boolean;
158 selfHostRepo: string | null;
159 matchesExpected: boolean;
160}
161
162function readEnvState(): EnvState {
163 const env = _deps.getEnv();
164 const v = env.SELF_HOST_REPO || null;
165 return {
166 selfHostRepoSet: !!v,
167 selfHostRepo: v,
168 matchesExpected: v === SELF_HOST_FULL,
169 };
170}
171
172interface RecentDeploy {
173 id: string;
174 sha: string;
175 status: string;
176 startedAt: Date;
177 finishedAt: Date | null;
178 durationMs: number | null;
179}
180
181async function readRecentDeploys(): Promise<RecentDeploy[]> {
182 try {
183 const rows = await db
184 .select({
185 id: platformDeploys.id,
186 sha: platformDeploys.sha,
187 status: platformDeploys.status,
188 startedAt: platformDeploys.startedAt,
189 finishedAt: platformDeploys.finishedAt,
190 durationMs: platformDeploys.durationMs,
191 })
192 .from(platformDeploys)
193 .where(eq(platformDeploys.source, SELF_DEPLOY_SOURCE))
194 .orderBy(desc(platformDeploys.startedAt))
195 .limit(10);
196 return rows;
197 } catch (err) {
198 console.error("[admin-self-host] readRecentDeploys:", err);
199 return [];
200 }
201}
202
203// ---------------------------------------------------------------------------
204// Render helpers
205// ---------------------------------------------------------------------------
206
207function Card({ title, children }: { title: string; children: any }) {
208 return (
209 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:8px;padding:var(--space-4);margin-bottom:var(--space-4)">
210 <h3 style="margin:0 0 12px 0;font-size:14px;letter-spacing:0.04em;text-transform:uppercase;color:var(--text-muted)">
211 {title}
212 </h3>
213 {children}
214 </div>
215 );
216}
217
218function Pill({ ok, label }: { ok: boolean; label: string }) {
219 return (
220 <span
221 style={`display:inline-flex;align-items:center;gap:6px;padding:2px 10px;border-radius:999px;font-size:12px;font-weight:600;background:${
222 ok ? "rgba(52, 211, 153, 0.16)" : "rgba(248, 113, 113, 0.16)"
223 };color:${ok ? "#34d399" : "#f87171"}`}
224 >
225 <span aria-hidden="true">{ok ? "v" : "x"}</span>
226 <span>{label}</span>
227 </span>
228 );
229}
230
231// ---------------------------------------------------------------------------
232// Gating
233// ---------------------------------------------------------------------------
234
235const selfHost = new Hono<AuthEnv>();
236selfHost.use("*", softAuth);
237
238async function gate(c: any): Promise<{ user: any } | Response> {
239 const user = c.get("user");
240 if (!user) return c.redirect("/login?next=/admin/self-host");
241 if (!(await isSiteAdmin(user.id))) {
242 return c.html(
243 <Layout title="Forbidden" user={user}>
244 <div class="empty-state">
245 <h2>403 — Not a site admin</h2>
246 <p>You don't have permission to view this page.</p>
247 </div>
248 </Layout>,
249 403
250 );
251 }
252 return { user };
253}
254
255function redirectWith(c: any, kind: "success" | "error", msg: string): Response {
256 return c.redirect(`/admin/self-host?${kind}=${encodeURIComponent(msg)}`);
257}
258
259// ---------------------------------------------------------------------------
260// GET /admin/self-host
261// ---------------------------------------------------------------------------
262
263selfHost.get("/admin/self-host", async (c) => {
264 const g = await gate(c);
265 if (g instanceof Response) return g;
266 const { user } = g;
267
268 const success = c.req.query("success");
269 const error = c.req.query("error");
270
271 const [repoState, recent] = await Promise.all([
272 readRepoState(),
273 readRecentDeploys(),
274 ]);
275 const hookState = readHookState(repoState.diskPath);
276 const envState = readEnvState();
277
278 const allGreen =
279 repoState.exists && hookState.installed && envState.matchesExpected;
280
281 return c.html(
282 <Layout title="Self-host — admin" user={user}>
283 <div style="max-width:880px;margin:0 auto;padding:var(--space-6) var(--space-4)">
284 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:18px">
285 <h1 style="margin:0">Self-host</h1>
286 <a href="/admin" class="btn btn-sm">
287 Back to admin
288 </a>
289 </div>
290 <p style="color:var(--text-muted);margin-bottom:20px">
291 Status of the BLOCK W migration — Gluecron's own source hosted
292 on Gluecron itself. Once all three cards are green, every push
293 to <code>{SELF_HOST_FULL}</code> deploys via the local
294 post-receive hook in ~25 seconds.
295 </p>
296
297 {success && (
298 <div class="auth-success" style="margin-bottom:16px">
299 {decodeURIComponent(success)}
300 </div>
301 )}
302 {error && (
303 <div class="auth-error" style="margin-bottom:16px">
304 {decodeURIComponent(error)}
305 </div>
306 )}
307
308 <Card title="Status">
309 <ul style="list-style:none;padding:0;margin:0">
310 <li style="display:flex;align-items:center;gap:10px;padding:6px 0;font-size:13px">
311 <Pill
312 ok={repoState.exists}
313 label={repoState.exists ? "Mirrored" : "Not mirrored"}
314 />
315 <span>
316 Gluecron repo row exists ({SELF_HOST_FULL})
317 {repoState.diskPath && (
318 <code style="margin-left:8px;color:var(--text-muted);font-size:12px">
319 {repoState.diskPath}
320 </code>
321 )}
322 </span>
323 </li>
324 <li style="display:flex;align-items:center;gap:10px;padding:6px 0;font-size:13px">
325 <Pill
326 ok={hookState.installed}
327 label={hookState.installed ? "Installed" : "Missing"}
328 />
329 <span>
330 Bare-repo post-receive hook
331 <code style="margin-left:8px;color:var(--text-muted);font-size:12px">
332 {hookState.path}
333 </code>
334 </span>
335 </li>
336 <li style="display:flex;align-items:center;gap:10px;padding:6px 0;font-size:13px">
337 <Pill
338 ok={envState.matchesExpected}
339 label={
340 envState.matchesExpected
341 ? "Set"
342 : envState.selfHostRepoSet
343 ? "Mismatch"
344 : "Unset"
345 }
346 />
347 <span>
348 <code>SELF_HOST_REPO</code> env
349 {envState.selfHostRepo && (
350 <code style="margin-left:8px;color:var(--text-muted);font-size:12px">
351 = {envState.selfHostRepo}
352 </code>
353 )}
354 {!envState.selfHostRepoSet && (
355 <span style="margin-left:8px;color:var(--text-muted);font-size:12px">
356 expected <code>{SELF_HOST_FULL}</code>
357 </span>
358 )}
359 </span>
360 </li>
361 </ul>
bf19c50Test User362 {!envState.selfHostRepoSet && (
363 <div style="margin-top:12px;padding:10px 12px;background:rgba(245,158,11,0.08);border:1px solid rgba(245,158,11,0.25);border-radius:6px;font-size:12px;line-height:1.5">
364 <strong style="color:#f59e0b">Hint:</strong>{" "}
365 <code>SELF_HOST_REPO</code> is read from{" "}
366 <code>/etc/gluecron.env</code> when the gluecron service starts.
367 If you just appended it via SSH, the running process won't see
368 it until you run:
369 <pre style="margin:8px 0 0 0;padding:6px 8px;background:var(--bg);border-radius:4px;font-size:11px;overflow-x:auto">systemctl restart gluecron</pre>
370 </div>
371 )}
f2c00b4CC LABS App372 <div style="margin-top:14px;font-size:12px;color:var(--text-muted)">
373 Overall:{" "}
374 <Pill
375 ok={allGreen}
376 label={allGreen ? "Self-host ready" : "Setup incomplete"}
377 />
378 </div>
379 </Card>
380
381 <Card title="Bootstrap">
382 <p style="font-size:13px;color:var(--text-muted);margin:0 0 10px 0">
383 Mirror Gluecron's source from GitHub onto this Gluecron
384 instance. Idempotent — safe to re-run. See{" "}
385 <a href="/ccantynz/Gluecron.com/blob/main/docs/SELF_HOST.md">
386 docs/SELF_HOST.md
387 </a>{" "}
388 for the full runbook.
389 </p>
390 <div style="display:flex;gap:var(--space-2);align-items:center">
391 <form
392 method="post"
393 action="/admin/self-host/bootstrap"
394 style="margin:0"
395 onsubmit="return confirm('Run the self-host bootstrap on this box? Safe to re-run, but it will spawn a child process.')"
396 >
397 <button
398 type="submit"
399 class="btn btn-sm btn-primary"
400 disabled={repoState.exists && hookState.installed}
401 title={
402 repoState.exists && hookState.installed
403 ? "Bootstrap already applied"
404 : "Run scripts/self-host-bootstrap.ts"
405 }
406 >
407 {repoState.exists && hookState.installed
408 ? "Already bootstrapped"
409 : "Run bootstrap"}
410 </button>
411 </form>
412 <span style="font-size:12px;color:var(--text-muted)">
413 Runs <code>bun run scripts/self-host-bootstrap.ts</code>{" "}
414 detached. Watch the systemd journal or{" "}
415 <code>/var/log/gluecron-self-deploy.log</code>.
416 </span>
417 </div>
418 </Card>
419
420 <Card title="Last 10 self-deploys">
421 {recent.length === 0 ? (
422 <p style="color:var(--text-muted);font-size:13px;margin:0">
423 No self-deploys recorded yet. Push a commit to{" "}
424 <code>{SELF_HOST_FULL}</code> after completing the bootstrap.
425 </p>
426 ) : (
427 <table style="width:100%;border-collapse:collapse;font-size:13px">
428 <thead>
429 <tr style="text-align:left;color:var(--text-muted);font-size:12px;text-transform:uppercase;letter-spacing:0.04em">
430 <th style="padding:6px 4px;border-bottom:1px solid var(--border)">
431 SHA
432 </th>
433 <th style="padding:6px 4px;border-bottom:1px solid var(--border)">
434 Status
435 </th>
436 <th style="padding:6px 4px;border-bottom:1px solid var(--border)">
437 Started
438 </th>
439 <th style="padding:6px 4px;border-bottom:1px solid var(--border);text-align:right">
440 Duration
441 </th>
442 </tr>
443 </thead>
444 <tbody>
445 {recent.map((d) => (
446 <tr>
447 <td style="padding:6px 4px;border-bottom:1px solid var(--border)">
448 <code class="meta-mono">{shortSha(d.sha)}</code>
449 </td>
450 <td style="padding:6px 4px;border-bottom:1px solid var(--border)">
451 <Pill
452 ok={d.status === "succeeded"}
453 label={d.status}
454 />
455 </td>
456 <td
457 style="padding:6px 4px;border-bottom:1px solid var(--border)"
458 title={d.startedAt.toISOString()}
459 >
460 {relativeTime(d.startedAt)}
461 </td>
462 <td style="padding:6px 4px;border-bottom:1px solid var(--border);text-align:right">
463 {d.durationMs != null
464 ? `${(d.durationMs / 1000).toFixed(1)}s`
465 : "—"}
466 </td>
467 </tr>
468 ))}
469 </tbody>
470 </table>
471 )}
472 </Card>
473 </div>
474 </Layout>
475 );
476});
477
478// ---------------------------------------------------------------------------
479// POST /admin/self-host/bootstrap
480//
481// Spawns scripts/self-host-bootstrap.ts with the default args. Detached
482// so the request returns immediately. The operator watches the systemd
483// journal / log file for progress.
484// ---------------------------------------------------------------------------
485
486selfHost.post("/admin/self-host/bootstrap", async (c) => {
487 const g = await gate(c);
488 if (g instanceof Response) return g;
489 const { user } = g;
490
491 try {
492 const bunCmd =
493 _deps.getEnv().GLUECRON_BUN_PATH || "/root/.bun/bin/bun";
494 const scriptPath =
495 _deps.getEnv().GLUECRON_BOOTSTRAP_SCRIPT ||
496 "/opt/gluecron/scripts/self-host-bootstrap.ts";
bf19c50Test User497
498 // P0 audit #10/#11 — pre-check the binary + script paths exist before
499 // spawning. The old handler spawned blindly with stderr discarded, so a
500 // missing bun produced a "success" toast and a permanently broken state.
501 if (!_deps.fsExists(bunCmd)) {
502 return redirectWith(
503 c,
504 "error",
505 `Bootstrap aborted: bun binary not found at ${bunCmd}. Set GLUECRON_BUN_PATH or install bun on the box.`
506 );
507 }
508 if (!_deps.fsExists(scriptPath)) {
509 return redirectWith(
510 c,
511 "error",
512 `Bootstrap aborted: script not found at ${scriptPath}. The deploy may be incomplete.`
513 );
514 }
515
f2c00b4CC LABS App516 const child = _deps.spawn([bunCmd, "run", scriptPath], { detached: true });
517 try {
518 (child as any)?.unref?.();
519 } catch {
520 /* ignore */
521 }
522 try {
523 await _deps.audit({
524 userId: user.id,
525 action: "admin.self_host.bootstrap_triggered",
526 targetType: "repository",
527 metadata: { repo: SELF_HOST_FULL },
528 });
529 } catch {
530 /* audit failure is non-fatal */
531 }
532 return redirectWith(
533 c,
534 "success",
bf19c50Test User535 `Bootstrap dispatched. Output streams to ${BOOTSTRAP_LOG_PATH} — refresh in ~30s to see status.`
f2c00b4CC LABS App536 );
537 } catch (err) {
538 const message = err instanceof Error ? err.message : String(err);
539 return redirectWith(c, "error", `Bootstrap failed to spawn: ${message}`);
540 }
541});
542
543export const __test = {
544 readRepoState,
545 readHookState,
546 readEnvState,
547 readRecentDeploys,
548 SELF_HOST_OWNER,
549 SELF_HOST_NAME,
550 SELF_HOST_FULL,
551};
552
553export default selfHost;