Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsxBlame499 lines · 1 contributor
4992afaTest User1/**
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
48const REAL_DEPS: Deps = {
49 audit: realAudit,
50 spawn: (cmd, _opts) =>
51 Bun.spawn(cmd, {
52 stdout: "ignore",
53 stderr: "ignore",
54 stdin: "ignore",
55 }),
56 fsExists: existsSync,
57 getEnv: () => process.env as Record<string, string | undefined>,
58};
59
60let _deps: Deps = REAL_DEPS;
61
62/** Test-only: replace one or more collaborators. Pass `null` to reset. */
63export function __setSelfHostDepsForTests(d: Partial<Deps> | null): void {
64 _deps = d ? { ...REAL_DEPS, ..._deps, ...d } : REAL_DEPS;
65}
66
67// ---------------------------------------------------------------------------
68// Constants — the repo we self-host.
69// ---------------------------------------------------------------------------
70
71const SELF_HOST_OWNER = "ccantynz";
72const SELF_HOST_NAME = "Gluecron.com";
73const SELF_HOST_FULL = `${SELF_HOST_OWNER}/${SELF_HOST_NAME}`;
74const SELF_DEPLOY_SOURCE = "self-deploy";
75
76// ---------------------------------------------------------------------------
77// Status reads — every probe wrapped so a single failure doesn't 500 the page.
78// ---------------------------------------------------------------------------
79
80interface RepoState {
81 exists: boolean;
82 diskPath: string | null;
83}
84
85async function readRepoState(): Promise<RepoState> {
86 try {
87 const [ownerRow] = await db
88 .select({ id: users.id })
89 .from(users)
90 .where(eq(users.username, SELF_HOST_OWNER))
91 .limit(1);
92 if (!ownerRow) return { exists: false, diskPath: null };
93 const [repo] = await db
94 .select({ id: repositories.id, diskPath: repositories.diskPath })
95 .from(repositories)
96 .where(
97 and(
98 eq(repositories.ownerId, ownerRow.id),
99 eq(repositories.name, SELF_HOST_NAME)
100 )
101 )
102 .limit(1);
103 if (!repo) return { exists: false, diskPath: null };
104 return { exists: true, diskPath: repo.diskPath };
105 } catch (err) {
106 console.error("[admin-self-host] readRepoState:", err);
107 return { exists: false, diskPath: null };
108 }
109}
110
111interface HookState {
112 installed: boolean;
113 path: string;
114}
115
116function readHookState(diskPath: string | null): HookState {
117 // Where the bootstrap installs the hook. If we can't resolve the repo
118 // row we fall back to the conventional path so the operator can still
119 // see the expected location.
120 const base =
121 diskPath ||
122 join(config.gitReposPath, SELF_HOST_OWNER, `${SELF_HOST_NAME}.git`);
123 const path = join(base, "hooks", "post-receive");
124 try {
125 return { installed: _deps.fsExists(path), path };
126 } catch {
127 return { installed: false, path };
128 }
129}
130
131interface EnvState {
132 selfHostRepoSet: boolean;
133 selfHostRepo: string | null;
134 matchesExpected: boolean;
135}
136
137function readEnvState(): EnvState {
138 const env = _deps.getEnv();
139 const v = env.SELF_HOST_REPO || null;
140 return {
141 selfHostRepoSet: !!v,
142 selfHostRepo: v,
143 matchesExpected: v === SELF_HOST_FULL,
144 };
145}
146
147interface RecentDeploy {
148 id: string;
149 sha: string;
150 status: string;
151 startedAt: Date;
152 finishedAt: Date | null;
153 durationMs: number | null;
154}
155
156async function readRecentDeploys(): Promise<RecentDeploy[]> {
157 try {
158 const rows = await db
159 .select({
160 id: platformDeploys.id,
161 sha: platformDeploys.sha,
162 status: platformDeploys.status,
163 startedAt: platformDeploys.startedAt,
164 finishedAt: platformDeploys.finishedAt,
165 durationMs: platformDeploys.durationMs,
166 })
167 .from(platformDeploys)
168 .where(eq(platformDeploys.source, SELF_DEPLOY_SOURCE))
169 .orderBy(desc(platformDeploys.startedAt))
170 .limit(10);
171 return rows;
172 } catch (err) {
173 console.error("[admin-self-host] readRecentDeploys:", err);
174 return [];
175 }
176}
177
178// ---------------------------------------------------------------------------
179// Render helpers
180// ---------------------------------------------------------------------------
181
182function Card({ title, children }: { title: string; children: any }) {
183 return (
184 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:8px;padding:var(--space-4);margin-bottom:var(--space-4)">
185 <h3 style="margin:0 0 12px 0;font-size:14px;letter-spacing:0.04em;text-transform:uppercase;color:var(--text-muted)">
186 {title}
187 </h3>
188 {children}
189 </div>
190 );
191}
192
193function Pill({ ok, label }: { ok: boolean; label: string }) {
194 return (
195 <span
196 style={`display:inline-flex;align-items:center;gap:6px;padding:2px 10px;border-radius:999px;font-size:12px;font-weight:600;background:${
197 ok ? "rgba(52, 211, 153, 0.16)" : "rgba(248, 113, 113, 0.16)"
198 };color:${ok ? "#34d399" : "#f87171"}`}
199 >
200 <span aria-hidden="true">{ok ? "v" : "x"}</span>
201 <span>{label}</span>
202 </span>
203 );
204}
205
206// ---------------------------------------------------------------------------
207// Gating
208// ---------------------------------------------------------------------------
209
210const selfHost = new Hono<AuthEnv>();
211selfHost.use("*", softAuth);
212
213async function gate(c: any): Promise<{ user: any } | Response> {
214 const user = c.get("user");
215 if (!user) return c.redirect("/login?next=/admin/self-host");
216 if (!(await isSiteAdmin(user.id))) {
217 return c.html(
218 <Layout title="Forbidden" user={user}>
219 <div class="empty-state">
220 <h2>403 — Not a site admin</h2>
221 <p>You don't have permission to view this page.</p>
222 </div>
223 </Layout>,
224 403
225 );
226 }
227 return { user };
228}
229
230function redirectWith(c: any, kind: "success" | "error", msg: string): Response {
231 return c.redirect(`/admin/self-host?${kind}=${encodeURIComponent(msg)}`);
232}
233
234// ---------------------------------------------------------------------------
235// GET /admin/self-host
236// ---------------------------------------------------------------------------
237
238selfHost.get("/admin/self-host", async (c) => {
239 const g = await gate(c);
240 if (g instanceof Response) return g;
241 const { user } = g;
242
243 const success = c.req.query("success");
244 const error = c.req.query("error");
245
246 const [repoState, recent] = await Promise.all([
247 readRepoState(),
248 readRecentDeploys(),
249 ]);
250 const hookState = readHookState(repoState.diskPath);
251 const envState = readEnvState();
252
253 const allGreen =
254 repoState.exists && hookState.installed && envState.matchesExpected;
255
256 return c.html(
257 <Layout title="Self-host — admin" user={user}>
258 <div style="max-width:880px;margin:0 auto;padding:var(--space-6) var(--space-4)">
259 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:18px">
260 <h1 style="margin:0">Self-host</h1>
261 <a href="/admin" class="btn btn-sm">
262 Back to admin
263 </a>
264 </div>
265 <p style="color:var(--text-muted);margin-bottom:20px">
266 Status of the BLOCK W migration — Gluecron's own source hosted
267 on Gluecron itself. Once all three cards are green, every push
268 to <code>{SELF_HOST_FULL}</code> deploys via the local
269 post-receive hook in ~25 seconds.
270 </p>
271
272 {success && (
273 <div class="auth-success" style="margin-bottom:16px">
274 {decodeURIComponent(success)}
275 </div>
276 )}
277 {error && (
278 <div class="auth-error" style="margin-bottom:16px">
279 {decodeURIComponent(error)}
280 </div>
281 )}
282
283 <Card title="Status">
284 <ul style="list-style:none;padding:0;margin:0">
285 <li style="display:flex;align-items:center;gap:10px;padding:6px 0;font-size:13px">
286 <Pill
287 ok={repoState.exists}
288 label={repoState.exists ? "Mirrored" : "Not mirrored"}
289 />
290 <span>
291 Gluecron repo row exists ({SELF_HOST_FULL})
292 {repoState.diskPath && (
293 <code style="margin-left:8px;color:var(--text-muted);font-size:12px">
294 {repoState.diskPath}
295 </code>
296 )}
297 </span>
298 </li>
299 <li style="display:flex;align-items:center;gap:10px;padding:6px 0;font-size:13px">
300 <Pill
301 ok={hookState.installed}
302 label={hookState.installed ? "Installed" : "Missing"}
303 />
304 <span>
305 Bare-repo post-receive hook
306 <code style="margin-left:8px;color:var(--text-muted);font-size:12px">
307 {hookState.path}
308 </code>
309 </span>
310 </li>
311 <li style="display:flex;align-items:center;gap:10px;padding:6px 0;font-size:13px">
312 <Pill
313 ok={envState.matchesExpected}
314 label={
315 envState.matchesExpected
316 ? "Set"
317 : envState.selfHostRepoSet
318 ? "Mismatch"
319 : "Unset"
320 }
321 />
322 <span>
323 <code>SELF_HOST_REPO</code> env
324 {envState.selfHostRepo && (
325 <code style="margin-left:8px;color:var(--text-muted);font-size:12px">
326 = {envState.selfHostRepo}
327 </code>
328 )}
329 {!envState.selfHostRepoSet && (
330 <span style="margin-left:8px;color:var(--text-muted);font-size:12px">
331 expected <code>{SELF_HOST_FULL}</code>
332 </span>
333 )}
334 </span>
335 </li>
336 </ul>
337 <div style="margin-top:14px;font-size:12px;color:var(--text-muted)">
338 Overall:{" "}
339 <Pill
340 ok={allGreen}
341 label={allGreen ? "Self-host ready" : "Setup incomplete"}
342 />
343 </div>
344 </Card>
345
346 <Card title="Bootstrap">
347 <p style="font-size:13px;color:var(--text-muted);margin:0 0 10px 0">
348 Mirror Gluecron's source from GitHub onto this Gluecron
349 instance. Idempotent — safe to re-run. See{" "}
350 <a href="/ccantynz/Gluecron.com/blob/main/docs/SELF_HOST.md">
351 docs/SELF_HOST.md
352 </a>{" "}
353 for the full runbook.
354 </p>
355 <div style="display:flex;gap:var(--space-2);align-items:center">
356 <form
357 method="post"
358 action="/admin/self-host/bootstrap"
359 style="margin:0"
360 onsubmit="return confirm('Run the self-host bootstrap on this box? Safe to re-run, but it will spawn a child process.')"
361 >
362 <button
363 type="submit"
364 class="btn btn-sm btn-primary"
365 disabled={repoState.exists && hookState.installed}
366 title={
367 repoState.exists && hookState.installed
368 ? "Bootstrap already applied"
369 : "Run scripts/self-host-bootstrap.ts"
370 }
371 >
372 {repoState.exists && hookState.installed
373 ? "Already bootstrapped"
374 : "Run bootstrap"}
375 </button>
376 </form>
377 <span style="font-size:12px;color:var(--text-muted)">
378 Runs <code>bun run scripts/self-host-bootstrap.ts</code>{" "}
379 detached. Watch the systemd journal or{" "}
380 <code>/var/log/gluecron-self-deploy.log</code>.
381 </span>
382 </div>
383 </Card>
384
385 <Card title="Last 10 self-deploys">
386 {recent.length === 0 ? (
387 <p style="color:var(--text-muted);font-size:13px;margin:0">
388 No self-deploys recorded yet. Push a commit to{" "}
389 <code>{SELF_HOST_FULL}</code> after completing the bootstrap.
390 </p>
391 ) : (
392 <table style="width:100%;border-collapse:collapse;font-size:13px">
393 <thead>
394 <tr style="text-align:left;color:var(--text-muted);font-size:12px;text-transform:uppercase;letter-spacing:0.04em">
395 <th style="padding:6px 4px;border-bottom:1px solid var(--border)">
396 SHA
397 </th>
398 <th style="padding:6px 4px;border-bottom:1px solid var(--border)">
399 Status
400 </th>
401 <th style="padding:6px 4px;border-bottom:1px solid var(--border)">
402 Started
403 </th>
404 <th style="padding:6px 4px;border-bottom:1px solid var(--border);text-align:right">
405 Duration
406 </th>
407 </tr>
408 </thead>
409 <tbody>
410 {recent.map((d) => (
411 <tr>
412 <td style="padding:6px 4px;border-bottom:1px solid var(--border)">
413 <code class="meta-mono">{shortSha(d.sha)}</code>
414 </td>
415 <td style="padding:6px 4px;border-bottom:1px solid var(--border)">
416 <Pill
417 ok={d.status === "succeeded"}
418 label={d.status}
419 />
420 </td>
421 <td
422 style="padding:6px 4px;border-bottom:1px solid var(--border)"
423 title={d.startedAt.toISOString()}
424 >
425 {relativeTime(d.startedAt)}
426 </td>
427 <td style="padding:6px 4px;border-bottom:1px solid var(--border);text-align:right">
428 {d.durationMs != null
429 ? `${(d.durationMs / 1000).toFixed(1)}s`
430 : "—"}
431 </td>
432 </tr>
433 ))}
434 </tbody>
435 </table>
436 )}
437 </Card>
438 </div>
439 </Layout>
440 );
441});
442
443// ---------------------------------------------------------------------------
444// POST /admin/self-host/bootstrap
445//
446// Spawns scripts/self-host-bootstrap.ts with the default args. Detached
447// so the request returns immediately. The operator watches the systemd
448// journal / log file for progress.
449// ---------------------------------------------------------------------------
450
451selfHost.post("/admin/self-host/bootstrap", async (c) => {
452 const g = await gate(c);
453 if (g instanceof Response) return g;
454 const { user } = g;
455
456 try {
457 const bunCmd =
458 _deps.getEnv().GLUECRON_BUN_PATH || "/root/.bun/bin/bun";
459 const scriptPath =
460 _deps.getEnv().GLUECRON_BOOTSTRAP_SCRIPT ||
461 "/opt/gluecron/scripts/self-host-bootstrap.ts";
462 const child = _deps.spawn([bunCmd, "run", scriptPath], { detached: true });
463 try {
464 (child as any)?.unref?.();
465 } catch {
466 /* ignore */
467 }
468 try {
469 await _deps.audit({
470 userId: user.id,
471 action: "admin.self_host.bootstrap_triggered",
472 targetType: "repository",
473 metadata: { repo: SELF_HOST_FULL },
474 });
475 } catch {
476 /* audit failure is non-fatal */
477 }
478 return redirectWith(
479 c,
480 "success",
481 "Bootstrap dispatched — watch the system journal for progress."
482 );
483 } catch (err) {
484 const message = err instanceof Error ? err.message : String(err);
485 return redirectWith(c, "error", `Bootstrap failed to spawn: ${message}`);
486 }
487});
488
489export const __test = {
490 readRepoState,
491 readHookState,
492 readEnvState,
493 readRecentDeploys,
494 SELF_HOST_OWNER,
495 SELF_HOST_NAME,
496 SELF_HOST_FULL,
497};
498
499export default selfHost;