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.tsxBlame557 lines · 3 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
5c7fbd2Claude96// Env-overridable via SELF_HOST_REPO (format: "owner/name"). Defaults
97// to the canonical mainline (ccantynz-alt/Gluecron.com — the GitHub-
98// mirror-matching username the operator actually signed up with).
99const SELF_HOST_FULL = process.env.SELF_HOST_REPO || "ccantynz-alt/Gluecron.com";
100const [SELF_HOST_OWNER_PARSED, SELF_HOST_NAME_PARSED] = SELF_HOST_FULL.split("/");
101const SELF_HOST_OWNER = SELF_HOST_OWNER_PARSED || "ccantynz-alt";
102const SELF_HOST_NAME = SELF_HOST_NAME_PARSED || "Gluecron.com";
f2c00b4CC LABS App103const SELF_DEPLOY_SOURCE = "self-deploy";
104
105// ---------------------------------------------------------------------------
106// Status reads — every probe wrapped so a single failure doesn't 500 the page.
107// ---------------------------------------------------------------------------
108
109interface RepoState {
110 exists: boolean;
111 diskPath: string | null;
112}
113
114async function readRepoState(): Promise<RepoState> {
115 try {
116 const [ownerRow] = await db
117 .select({ id: users.id })
118 .from(users)
119 .where(eq(users.username, SELF_HOST_OWNER))
120 .limit(1);
121 if (!ownerRow) return { exists: false, diskPath: null };
122 const [repo] = await db
123 .select({ id: repositories.id, diskPath: repositories.diskPath })
124 .from(repositories)
125 .where(
126 and(
127 eq(repositories.ownerId, ownerRow.id),
128 eq(repositories.name, SELF_HOST_NAME)
129 )
130 )
131 .limit(1);
132 if (!repo) return { exists: false, diskPath: null };
133 return { exists: true, diskPath: repo.diskPath };
134 } catch (err) {
135 console.error("[admin-self-host] readRepoState:", err);
136 return { exists: false, diskPath: null };
137 }
138}
139
140interface HookState {
141 installed: boolean;
142 path: string;
143}
144
145function readHookState(diskPath: string | null): HookState {
146 // Where the bootstrap installs the hook. If we can't resolve the repo
147 // row we fall back to the conventional path so the operator can still
148 // see the expected location.
149 const base =
150 diskPath ||
151 join(config.gitReposPath, SELF_HOST_OWNER, `${SELF_HOST_NAME}.git`);
152 const path = join(base, "hooks", "post-receive");
153 try {
154 return { installed: _deps.fsExists(path), path };
155 } catch {
156 return { installed: false, path };
157 }
158}
159
160interface EnvState {
161 selfHostRepoSet: boolean;
162 selfHostRepo: string | null;
163 matchesExpected: boolean;
164}
165
166function readEnvState(): EnvState {
167 const env = _deps.getEnv();
168 const v = env.SELF_HOST_REPO || null;
169 return {
170 selfHostRepoSet: !!v,
171 selfHostRepo: v,
172 matchesExpected: v === SELF_HOST_FULL,
173 };
174}
175
176interface RecentDeploy {
177 id: string;
178 sha: string;
179 status: string;
180 startedAt: Date;
181 finishedAt: Date | null;
182 durationMs: number | null;
183}
184
185async function readRecentDeploys(): Promise<RecentDeploy[]> {
186 try {
187 const rows = await db
188 .select({
189 id: platformDeploys.id,
190 sha: platformDeploys.sha,
191 status: platformDeploys.status,
192 startedAt: platformDeploys.startedAt,
193 finishedAt: platformDeploys.finishedAt,
194 durationMs: platformDeploys.durationMs,
195 })
196 .from(platformDeploys)
197 .where(eq(platformDeploys.source, SELF_DEPLOY_SOURCE))
198 .orderBy(desc(platformDeploys.startedAt))
199 .limit(10);
200 return rows;
201 } catch (err) {
202 console.error("[admin-self-host] readRecentDeploys:", err);
203 return [];
204 }
205}
206
207// ---------------------------------------------------------------------------
208// Render helpers
209// ---------------------------------------------------------------------------
210
211function Card({ title, children }: { title: string; children: any }) {
212 return (
213 <div style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:8px;padding:var(--space-4);margin-bottom:var(--space-4)">
214 <h3 style="margin:0 0 12px 0;font-size:14px;letter-spacing:0.04em;text-transform:uppercase;color:var(--text-muted)">
215 {title}
216 </h3>
217 {children}
218 </div>
219 );
220}
221
222function Pill({ ok, label }: { ok: boolean; label: string }) {
223 return (
224 <span
225 style={`display:inline-flex;align-items:center;gap:6px;padding:2px 10px;border-radius:999px;font-size:12px;font-weight:600;background:${
226 ok ? "rgba(52, 211, 153, 0.16)" : "rgba(248, 113, 113, 0.16)"
227 };color:${ok ? "#34d399" : "#f87171"}`}
228 >
229 <span aria-hidden="true">{ok ? "v" : "x"}</span>
230 <span>{label}</span>
231 </span>
232 );
233}
234
235// ---------------------------------------------------------------------------
236// Gating
237// ---------------------------------------------------------------------------
238
239const selfHost = new Hono<AuthEnv>();
240selfHost.use("*", softAuth);
241
242async function gate(c: any): Promise<{ user: any } | Response> {
243 const user = c.get("user");
244 if (!user) return c.redirect("/login?next=/admin/self-host");
245 if (!(await isSiteAdmin(user.id))) {
246 return c.html(
247 <Layout title="Forbidden" user={user}>
248 <div class="empty-state">
249 <h2>403 — Not a site admin</h2>
250 <p>You don't have permission to view this page.</p>
251 </div>
252 </Layout>,
253 403
254 );
255 }
256 return { user };
257}
258
259function redirectWith(c: any, kind: "success" | "error", msg: string): Response {
260 return c.redirect(`/admin/self-host?${kind}=${encodeURIComponent(msg)}`);
261}
262
263// ---------------------------------------------------------------------------
264// GET /admin/self-host
265// ---------------------------------------------------------------------------
266
267selfHost.get("/admin/self-host", async (c) => {
268 const g = await gate(c);
269 if (g instanceof Response) return g;
270 const { user } = g;
271
272 const success = c.req.query("success");
273 const error = c.req.query("error");
274
275 const [repoState, recent] = await Promise.all([
276 readRepoState(),
277 readRecentDeploys(),
278 ]);
279 const hookState = readHookState(repoState.diskPath);
280 const envState = readEnvState();
281
282 const allGreen =
283 repoState.exists && hookState.installed && envState.matchesExpected;
284
285 return c.html(
286 <Layout title="Self-host — admin" user={user}>
287 <div style="max-width:880px;margin:0 auto;padding:var(--space-6) var(--space-4)">
288 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:18px">
289 <h1 style="margin:0">Self-host</h1>
290 <a href="/admin" class="btn btn-sm">
291 Back to admin
292 </a>
293 </div>
294 <p style="color:var(--text-muted);margin-bottom:20px">
295 Status of the BLOCK W migration — Gluecron's own source hosted
296 on Gluecron itself. Once all three cards are green, every push
297 to <code>{SELF_HOST_FULL}</code> deploys via the local
298 post-receive hook in ~25 seconds.
299 </p>
300
301 {success && (
302 <div class="auth-success" style="margin-bottom:16px">
303 {decodeURIComponent(success)}
304 </div>
305 )}
306 {error && (
307 <div class="auth-error" style="margin-bottom:16px">
308 {decodeURIComponent(error)}
309 </div>
310 )}
311
312 <Card title="Status">
313 <ul style="list-style:none;padding:0;margin:0">
314 <li style="display:flex;align-items:center;gap:10px;padding:6px 0;font-size:13px">
315 <Pill
316 ok={repoState.exists}
317 label={repoState.exists ? "Mirrored" : "Not mirrored"}
318 />
319 <span>
320 Gluecron repo row exists ({SELF_HOST_FULL})
321 {repoState.diskPath && (
322 <code style="margin-left:8px;color:var(--text-muted);font-size:12px">
323 {repoState.diskPath}
324 </code>
325 )}
326 </span>
327 </li>
328 <li style="display:flex;align-items:center;gap:10px;padding:6px 0;font-size:13px">
329 <Pill
330 ok={hookState.installed}
331 label={hookState.installed ? "Installed" : "Missing"}
332 />
333 <span>
334 Bare-repo post-receive hook
335 <code style="margin-left:8px;color:var(--text-muted);font-size:12px">
336 {hookState.path}
337 </code>
338 </span>
339 </li>
340 <li style="display:flex;align-items:center;gap:10px;padding:6px 0;font-size:13px">
341 <Pill
342 ok={envState.matchesExpected}
343 label={
344 envState.matchesExpected
345 ? "Set"
346 : envState.selfHostRepoSet
347 ? "Mismatch"
348 : "Unset"
349 }
350 />
351 <span>
352 <code>SELF_HOST_REPO</code> env
353 {envState.selfHostRepo && (
354 <code style="margin-left:8px;color:var(--text-muted);font-size:12px">
355 = {envState.selfHostRepo}
356 </code>
357 )}
358 {!envState.selfHostRepoSet && (
359 <span style="margin-left:8px;color:var(--text-muted);font-size:12px">
360 expected <code>{SELF_HOST_FULL}</code>
361 </span>
362 )}
363 </span>
364 </li>
365 </ul>
bf19c50Test User366 {!envState.selfHostRepoSet && (
367 <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">
368 <strong style="color:#f59e0b">Hint:</strong>{" "}
369 <code>SELF_HOST_REPO</code> is read from{" "}
370 <code>/etc/gluecron.env</code> when the gluecron service starts.
371 If you just appended it via SSH, the running process won't see
372 it until you run:
373 <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>
374 </div>
375 )}
f2c00b4CC LABS App376 <div style="margin-top:14px;font-size:12px;color:var(--text-muted)">
377 Overall:{" "}
378 <Pill
379 ok={allGreen}
380 label={allGreen ? "Self-host ready" : "Setup incomplete"}
381 />
382 </div>
383 </Card>
384
385 <Card title="Bootstrap">
386 <p style="font-size:13px;color:var(--text-muted);margin:0 0 10px 0">
387 Mirror Gluecron's source from GitHub onto this Gluecron
388 instance. Idempotent — safe to re-run. See{" "}
5c7fbd2Claude389 <a href={`/${SELF_HOST_OWNER}/${SELF_HOST_NAME}/blob/main/docs/SELF_HOST.md`}>
f2c00b4CC LABS App390 docs/SELF_HOST.md
391 </a>{" "}
392 for the full runbook.
393 </p>
394 <div style="display:flex;gap:var(--space-2);align-items:center">
395 <form
396 method="post"
397 action="/admin/self-host/bootstrap"
398 style="margin:0"
399 onsubmit="return confirm('Run the self-host bootstrap on this box? Safe to re-run, but it will spawn a child process.')"
400 >
401 <button
402 type="submit"
403 class="btn btn-sm btn-primary"
404 disabled={repoState.exists && hookState.installed}
405 title={
406 repoState.exists && hookState.installed
407 ? "Bootstrap already applied"
408 : "Run scripts/self-host-bootstrap.ts"
409 }
410 >
411 {repoState.exists && hookState.installed
412 ? "Already bootstrapped"
413 : "Run bootstrap"}
414 </button>
415 </form>
416 <span style="font-size:12px;color:var(--text-muted)">
417 Runs <code>bun run scripts/self-host-bootstrap.ts</code>{" "}
418 detached. Watch the systemd journal or{" "}
419 <code>/var/log/gluecron-self-deploy.log</code>.
420 </span>
421 </div>
422 </Card>
423
424 <Card title="Last 10 self-deploys">
425 {recent.length === 0 ? (
426 <p style="color:var(--text-muted);font-size:13px;margin:0">
427 No self-deploys recorded yet. Push a commit to{" "}
428 <code>{SELF_HOST_FULL}</code> after completing the bootstrap.
429 </p>
430 ) : (
431 <table style="width:100%;border-collapse:collapse;font-size:13px">
432 <thead>
433 <tr style="text-align:left;color:var(--text-muted);font-size:12px;text-transform:uppercase;letter-spacing:0.04em">
434 <th style="padding:6px 4px;border-bottom:1px solid var(--border)">
435 SHA
436 </th>
437 <th style="padding:6px 4px;border-bottom:1px solid var(--border)">
438 Status
439 </th>
440 <th style="padding:6px 4px;border-bottom:1px solid var(--border)">
441 Started
442 </th>
443 <th style="padding:6px 4px;border-bottom:1px solid var(--border);text-align:right">
444 Duration
445 </th>
446 </tr>
447 </thead>
448 <tbody>
449 {recent.map((d) => (
450 <tr>
451 <td style="padding:6px 4px;border-bottom:1px solid var(--border)">
452 <code class="meta-mono">{shortSha(d.sha)}</code>
453 </td>
454 <td style="padding:6px 4px;border-bottom:1px solid var(--border)">
455 <Pill
456 ok={d.status === "succeeded"}
457 label={d.status}
458 />
459 </td>
460 <td
461 style="padding:6px 4px;border-bottom:1px solid var(--border)"
462 title={d.startedAt.toISOString()}
463 >
464 {relativeTime(d.startedAt)}
465 </td>
466 <td style="padding:6px 4px;border-bottom:1px solid var(--border);text-align:right">
467 {d.durationMs != null
468 ? `${(d.durationMs / 1000).toFixed(1)}s`
469 : "—"}
470 </td>
471 </tr>
472 ))}
473 </tbody>
474 </table>
475 )}
476 </Card>
477 </div>
478 </Layout>
479 );
480});
481
482// ---------------------------------------------------------------------------
483// POST /admin/self-host/bootstrap
484//
485// Spawns scripts/self-host-bootstrap.ts with the default args. Detached
486// so the request returns immediately. The operator watches the systemd
487// journal / log file for progress.
488// ---------------------------------------------------------------------------
489
490selfHost.post("/admin/self-host/bootstrap", async (c) => {
491 const g = await gate(c);
492 if (g instanceof Response) return g;
493 const { user } = g;
494
495 try {
496 const bunCmd =
497 _deps.getEnv().GLUECRON_BUN_PATH || "/root/.bun/bin/bun";
498 const scriptPath =
499 _deps.getEnv().GLUECRON_BOOTSTRAP_SCRIPT ||
500 "/opt/gluecron/scripts/self-host-bootstrap.ts";
bf19c50Test User501
502 // P0 audit #10/#11 — pre-check the binary + script paths exist before
503 // spawning. The old handler spawned blindly with stderr discarded, so a
504 // missing bun produced a "success" toast and a permanently broken state.
505 if (!_deps.fsExists(bunCmd)) {
506 return redirectWith(
507 c,
508 "error",
509 `Bootstrap aborted: bun binary not found at ${bunCmd}. Set GLUECRON_BUN_PATH or install bun on the box.`
510 );
511 }
512 if (!_deps.fsExists(scriptPath)) {
513 return redirectWith(
514 c,
515 "error",
516 `Bootstrap aborted: script not found at ${scriptPath}. The deploy may be incomplete.`
517 );
518 }
519
f2c00b4CC LABS App520 const child = _deps.spawn([bunCmd, "run", scriptPath], { detached: true });
521 try {
522 (child as any)?.unref?.();
523 } catch {
524 /* ignore */
525 }
526 try {
527 await _deps.audit({
528 userId: user.id,
529 action: "admin.self_host.bootstrap_triggered",
530 targetType: "repository",
531 metadata: { repo: SELF_HOST_FULL },
532 });
533 } catch {
534 /* audit failure is non-fatal */
535 }
536 return redirectWith(
537 c,
538 "success",
bf19c50Test User539 `Bootstrap dispatched. Output streams to ${BOOTSTRAP_LOG_PATH} — refresh in ~30s to see status.`
f2c00b4CC LABS App540 );
541 } catch (err) {
542 const message = err instanceof Error ? err.message : String(err);
543 return redirectWith(c, "error", `Bootstrap failed to spawn: ${message}`);
544 }
545});
546
547export const __test = {
548 readRepoState,
549 readHookState,
550 readEnvState,
551 readRecentDeploys,
552 SELF_HOST_OWNER,
553 SELF_HOST_NAME,
554 SELF_HOST_FULL,
555};
556
557export default selfHost;