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.tsxBlame1241 lines · 4 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.
b1f2895Claude12 *
13 * Visual recipe (2026 polish — mirrors admin-integrations / admin-ops /
14 * admin-deploys-page):
15 * - Gradient hairline strip across the top of the hero (purple→cyan, 2px)
16 * - Soft radial orb in the corner of the hero
17 * - Eyebrow with pill icon + actor name
18 * - Display headline with gradient-text on the verb ("Self-host.")
19 * - State-aware overall status pill in the hero
20 * - Each probe rendered as a section card
21 * - Recent deploys reuses the `.selfhost-row` pattern from admin-deploys
22 *
23 * Scoped CSS — every class prefixed `.selfhost-` so this surface can't
24 * bleed into the wider admin panel.
f2c00b4CC LABS App25 */
26/* eslint-disable @typescript-eslint/no-explicit-any */
27
28import { Hono } from "hono";
29import { and, desc, eq } from "drizzle-orm";
30import { existsSync } from "fs";
31import { join } from "path";
32import { db } from "../db";
33import { repositories, users } from "../db/schema";
34import { platformDeploys } from "../db/schema-deploys";
35import { Layout } from "../views/layout";
f4a1547ccantynz-alt36import { AdminShell } from "../views/admin-shell";
f2c00b4CC LABS App37import { softAuth } from "../middleware/auth";
38import type { AuthEnv } from "../middleware/auth";
39import { isSiteAdmin } from "../lib/admin";
40import { audit as realAudit } from "../lib/notify";
41import { config } from "../lib/config";
b1f2895Claude42import { relativeTime, shortSha, formatDuration } from "./admin-deploys-page";
f2c00b4CC LABS App43
44// ---------------------------------------------------------------------------
45// DI seam — tests inject fakes so we never spawn the real bootstrap.
46// ---------------------------------------------------------------------------
47
48type AuditFn = typeof realAudit;
49type BootstrapSpawnFn = (
50 cmd: string[],
51 opts: { detached?: boolean }
52) => unknown;
53type FsExistsFn = (p: string) => boolean;
54
55interface Deps {
56 audit: AuditFn;
57 spawn: BootstrapSpawnFn;
58 fsExists: FsExistsFn;
59 getEnv: () => Record<string, string | undefined>;
60}
61
bf19c50Test User62/**
63 * Bootstrap log path. The POST handler redirects stdout + stderr here so the
64 * operator can `tail -f` it (or we can read the last N lines on the next
65 * page render to surface errors). Previously every output stream was set to
66 * "ignore", which meant the operator saw "Bootstrap dispatched" toast even
67 * when the script crashed with bun-not-found / DATABASE_URL-missing /
68 * GitHub-clone-failed. P0 from the May 15 audit.
69 */
70export const BOOTSTRAP_LOG_PATH = "/var/log/gluecron-bootstrap.log";
71
f2c00b4CC LABS App72const REAL_DEPS: Deps = {
73 audit: realAudit,
bf19c50Test User74 spawn: (cmd, _opts) => {
75 // Open the log file for append; if the open fails (perm issue, missing
76 // /var/log) fall back to inherit so output at least goes to journalctl.
77 let stdout: any = "inherit";
78 let stderr: any = "inherit";
79 try {
80 const log = Bun.file(BOOTSTRAP_LOG_PATH);
81 // Truncate the previous run's output so the operator sees only the
82 // current attempt. `Bun.write` is sync-ish and returns a promise we
83 // don't need to await — the spawn happens regardless.
84 void Bun.write(
85 BOOTSTRAP_LOG_PATH,
86 `[${new Date().toISOString()}] bootstrap dispatched: ${cmd.join(" ")}\n`
87 );
88 stdout = log.writer();
89 stderr = log.writer();
90 } catch {
91 // Fall back to inherit
92 }
93 return Bun.spawn(cmd, { stdout, stderr, stdin: "ignore" });
94 },
f2c00b4CC LABS App95 fsExists: existsSync,
96 getEnv: () => process.env as Record<string, string | undefined>,
97};
98
99let _deps: Deps = REAL_DEPS;
100
101/** Test-only: replace one or more collaborators. Pass `null` to reset. */
102export function __setSelfHostDepsForTests(d: Partial<Deps> | null): void {
103 _deps = d ? { ...REAL_DEPS, ..._deps, ...d } : REAL_DEPS;
104}
105
106// ---------------------------------------------------------------------------
107// Constants — the repo we self-host.
108// ---------------------------------------------------------------------------
109
5c7fbd2Claude110// Env-overridable via SELF_HOST_REPO (format: "owner/name"). Defaults
111// to the canonical mainline (ccantynz-alt/Gluecron.com — the GitHub-
112// mirror-matching username the operator actually signed up with).
113const SELF_HOST_FULL = process.env.SELF_HOST_REPO || "ccantynz-alt/Gluecron.com";
114const [SELF_HOST_OWNER_PARSED, SELF_HOST_NAME_PARSED] = SELF_HOST_FULL.split("/");
115const SELF_HOST_OWNER = SELF_HOST_OWNER_PARSED || "ccantynz-alt";
116const SELF_HOST_NAME = SELF_HOST_NAME_PARSED || "Gluecron.com";
f2c00b4CC LABS App117const SELF_DEPLOY_SOURCE = "self-deploy";
118
119// ---------------------------------------------------------------------------
120// Status reads — every probe wrapped so a single failure doesn't 500 the page.
121// ---------------------------------------------------------------------------
122
123interface RepoState {
124 exists: boolean;
125 diskPath: string | null;
126}
127
128async function readRepoState(): Promise<RepoState> {
129 try {
130 const [ownerRow] = await db
131 .select({ id: users.id })
132 .from(users)
133 .where(eq(users.username, SELF_HOST_OWNER))
134 .limit(1);
135 if (!ownerRow) return { exists: false, diskPath: null };
136 const [repo] = await db
137 .select({ id: repositories.id, diskPath: repositories.diskPath })
138 .from(repositories)
139 .where(
140 and(
141 eq(repositories.ownerId, ownerRow.id),
142 eq(repositories.name, SELF_HOST_NAME)
143 )
144 )
145 .limit(1);
146 if (!repo) return { exists: false, diskPath: null };
147 return { exists: true, diskPath: repo.diskPath };
148 } catch (err) {
149 console.error("[admin-self-host] readRepoState:", err);
150 return { exists: false, diskPath: null };
151 }
152}
153
154interface HookState {
155 installed: boolean;
156 path: string;
157}
158
159function readHookState(diskPath: string | null): HookState {
160 // Where the bootstrap installs the hook. If we can't resolve the repo
161 // row we fall back to the conventional path so the operator can still
162 // see the expected location.
163 const base =
164 diskPath ||
165 join(config.gitReposPath, SELF_HOST_OWNER, `${SELF_HOST_NAME}.git`);
166 const path = join(base, "hooks", "post-receive");
167 try {
168 return { installed: _deps.fsExists(path), path };
169 } catch {
170 return { installed: false, path };
171 }
172}
173
174interface EnvState {
175 selfHostRepoSet: boolean;
176 selfHostRepo: string | null;
177 matchesExpected: boolean;
178}
179
180function readEnvState(): EnvState {
181 const env = _deps.getEnv();
182 const v = env.SELF_HOST_REPO || null;
183 return {
184 selfHostRepoSet: !!v,
185 selfHostRepo: v,
186 matchesExpected: v === SELF_HOST_FULL,
187 };
188}
189
190interface RecentDeploy {
191 id: string;
192 sha: string;
193 status: string;
194 startedAt: Date;
195 finishedAt: Date | null;
196 durationMs: number | null;
197}
198
199async function readRecentDeploys(): Promise<RecentDeploy[]> {
200 try {
201 const rows = await db
202 .select({
203 id: platformDeploys.id,
204 sha: platformDeploys.sha,
205 status: platformDeploys.status,
206 startedAt: platformDeploys.startedAt,
207 finishedAt: platformDeploys.finishedAt,
208 durationMs: platformDeploys.durationMs,
209 })
210 .from(platformDeploys)
211 .where(eq(platformDeploys.source, SELF_DEPLOY_SOURCE))
212 .orderBy(desc(platformDeploys.startedAt))
213 .limit(10);
214 return rows;
215 } catch (err) {
216 console.error("[admin-self-host] readRecentDeploys:", err);
217 return [];
218 }
219}
220
221// ---------------------------------------------------------------------------
b1f2895Claude222// Scoped CSS — every class prefixed `.selfhost-` so this surface can't bleed
223// into other admin pages. Mirrors the gradient-hairline hero + card patterns
224// from admin-integrations.tsx, admin-ops.tsx, and admin-deploys-page.tsx.
f2c00b4CC LABS App225// ---------------------------------------------------------------------------
226
b1f2895Claude227const SELFHOST_CSS = `
228 .selfhost-wrap {
229 max-width: 1100px;
230 margin: 0 auto;
231 padding: var(--space-6) var(--space-4) var(--space-12);
232 }
233
234 /* ─── Hero ─── */
235 .selfhost-hero {
236 position: relative;
237 margin-bottom: var(--space-5);
238 padding: clamp(28px, 4vw, 44px) clamp(24px, 4vw, 44px);
239 background: var(--bg-elevated);
240 border: 1px solid var(--border);
241 border-radius: 18px;
242 overflow: hidden;
243 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 18px 44px -16px rgba(0,0,0,0.42);
244 }
245 .selfhost-hero::before {
246 content: '';
247 position: absolute;
248 top: 0; left: 0; right: 0;
249 height: 2px;
6fd5915Claude250 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
b1f2895Claude251 opacity: 0.75;
252 pointer-events: none;
253 }
254 .selfhost-hero-orb {
255 position: absolute;
256 inset: -30% -10% auto auto;
257 width: 460px; height: 460px;
6fd5915Claude258 background: radial-gradient(circle, rgba(91,110,232,0.22), rgba(95,143,160,0.10) 45%, transparent 70%);
b1f2895Claude259 filter: blur(80px);
260 opacity: 0.7;
261 pointer-events: none;
262 z-index: 0;
263 }
264 .selfhost-hero-inner { position: relative; z-index: 1; }
265 .selfhost-hero-top {
266 display: flex;
267 align-items: flex-start;
268 justify-content: space-between;
269 gap: var(--space-4);
270 flex-wrap: wrap;
271 }
272 .selfhost-hero-text { flex: 1; min-width: 280px; }
273 .selfhost-eyebrow {
274 display: inline-flex;
275 align-items: center;
276 gap: 8px;
277 font-size: 12px;
278 color: var(--text-muted);
279 margin-bottom: 14px;
280 letter-spacing: 0.02em;
281 }
282 .selfhost-eyebrow-pill {
283 display: inline-flex;
284 align-items: center;
285 justify-content: center;
286 width: 18px; height: 18px;
287 border-radius: 6px;
6fd5915Claude288 background: rgba(91,110,232,0.14);
e589f77ccantynz-alt289 color: var(--accent);
6fd5915Claude290 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
b1f2895Claude291 }
292 .selfhost-eyebrow .selfhost-who { color: var(--accent); font-weight: 600; }
293 .selfhost-title {
294 font-family: var(--font-display);
295 font-size: clamp(28px, 4vw, 40px);
296 font-weight: 800;
297 letter-spacing: -0.028em;
298 line-height: 1.05;
299 margin: 0 0 var(--space-2);
300 color: var(--text-strong);
301 }
302 .selfhost-title-grad {
6fd5915Claude303 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
b1f2895Claude304 -webkit-background-clip: text;
305 background-clip: text;
306 -webkit-text-fill-color: transparent;
307 color: transparent;
308 }
309 .selfhost-sub {
310 font-size: 14.5px;
311 color: var(--text-muted);
312 margin: 0;
313 line-height: 1.55;
314 max-width: 640px;
315 }
316 .selfhost-sub code {
317 font-family: var(--font-mono);
318 font-size: 12.5px;
319 background: rgba(255,255,255,0.04);
320 border: 1px solid var(--border);
321 padding: 1px 6px;
322 border-radius: 5px;
323 color: var(--text);
324 }
325 .selfhost-hero-back {
326 display: inline-flex;
327 align-items: center;
328 gap: 6px;
329 padding: 7px 12px;
330 font-size: 12.5px;
331 color: var(--text-muted);
332 background: rgba(255,255,255,0.02);
333 border: 1px solid var(--border);
334 border-radius: 8px;
335 text-decoration: none;
336 font-weight: 500;
337 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
338 flex-shrink: 0;
339 }
340 .selfhost-hero-back:hover {
341 border-color: var(--border-strong);
342 color: var(--text-strong);
343 background: rgba(255,255,255,0.04);
344 text-decoration: none;
345 }
346
347 /* ─── Overall status pill (in the hero) ─── */
348 .selfhost-hero-status {
349 margin-top: var(--space-4);
350 padding-top: var(--space-4);
351 border-top: 1px solid var(--border);
352 display: flex;
353 align-items: center;
354 gap: 10px;
355 flex-wrap: wrap;
356 font-size: 13px;
357 color: var(--text-muted);
358 }
359 .selfhost-status-pill {
360 display: inline-flex;
361 align-items: center;
362 gap: 8px;
363 padding: 5px 12px;
364 border-radius: 9999px;
365 font-size: 12px;
366 font-weight: 700;
367 text-transform: uppercase;
368 letter-spacing: 0.06em;
369 }
370 .selfhost-status-pill.is-ok {
371 background: rgba(52,211,153,0.14);
e589f77ccantynz-alt372 color: var(--green);
b1f2895Claude373 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.36);
374 }
375 .selfhost-status-pill.is-bad {
376 background: rgba(248,113,113,0.10);
e589f77ccantynz-alt377 color: var(--red);
b1f2895Claude378 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.36);
379 }
380 .selfhost-status-pill .dot {
381 width: 7px; height: 7px;
382 border-radius: 9999px;
383 background: currentColor;
384 }
385 .selfhost-status-pill.is-ok .dot {
386 box-shadow: 0 0 6px rgba(52,211,153,0.55);
387 }
388 .selfhost-status-pill.is-bad .dot {
389 animation: selfhost-pulse 1.6s ease-in-out infinite;
390 }
391 @keyframes selfhost-pulse {
392 0%, 100% { opacity: 1; }
393 50% { opacity: 0.45; }
394 }
395 @media (prefers-reduced-motion: reduce) {
396 .selfhost-status-pill.is-bad .dot { animation: none; }
397 }
398
399 /* ─── Banners ─── */
400 .selfhost-banner {
401 margin-bottom: var(--space-4);
402 padding: 10px 14px;
403 border-radius: 10px;
404 font-size: 13.5px;
405 border: 1px solid var(--border);
406 background: rgba(255,255,255,0.025);
407 color: var(--text);
408 display: flex;
409 align-items: center;
410 gap: 10px;
411 }
412 .selfhost-banner.is-ok {
413 border-color: rgba(52,211,153,0.40);
414 background: rgba(52,211,153,0.08);
415 color: #bbf7d0;
416 }
417 .selfhost-banner.is-error {
418 border-color: rgba(248,113,113,0.40);
419 background: rgba(248,113,113,0.08);
420 color: #fecaca;
421 }
422 .selfhost-banner-dot {
423 width: 8px; height: 8px;
424 border-radius: 9999px;
425 background: currentColor;
426 flex-shrink: 0;
427 }
428
429 /* ─── Section cards ─── */
430 .selfhost-section {
431 margin-bottom: var(--space-5);
432 background: var(--bg-elevated);
433 border: 1px solid var(--border);
434 border-radius: 14px;
435 overflow: hidden;
436 }
437 .selfhost-section-head {
438 padding: var(--space-4) var(--space-5);
439 border-bottom: 1px solid var(--border);
440 display: flex;
441 align-items: flex-start;
442 justify-content: space-between;
443 gap: var(--space-3);
444 flex-wrap: wrap;
445 }
446 .selfhost-section-head-text { flex: 1; min-width: 240px; }
447 .selfhost-section-title {
448 margin: 0;
449 font-family: var(--font-display);
450 font-size: 17px;
451 font-weight: 700;
452 letter-spacing: -0.018em;
453 color: var(--text-strong);
454 display: flex;
455 align-items: center;
456 gap: 10px;
457 }
458 .selfhost-section-title-icon {
459 display: inline-flex;
460 align-items: center;
461 justify-content: center;
462 width: 26px; height: 26px;
463 border-radius: 8px;
6fd5915Claude464 background: rgba(91,110,232,0.12);
e589f77ccantynz-alt465 color: var(--accent);
6fd5915Claude466 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
b1f2895Claude467 flex-shrink: 0;
468 }
469 .selfhost-section-sub {
470 margin: 6px 0 0 36px;
471 font-size: 12.5px;
472 color: var(--text-muted);
473 line-height: 1.45;
474 }
475 .selfhost-section-body { padding: var(--space-4) var(--space-5); }
476
477 /* ─── Probe rows (Repo path / Hook / Env) ─── */
478 .selfhost-probe {
479 display: flex;
480 align-items: center;
481 gap: 12px;
482 padding: 8px 0;
483 font-size: 13px;
484 border-bottom: 1px solid var(--border);
485 }
486 .selfhost-probe:last-child { border-bottom: none; }
487 .selfhost-probe-text { flex: 1; min-width: 0; }
488 .selfhost-probe-label {
489 color: var(--text);
490 font-weight: 500;
491 }
492 .selfhost-probe-label code {
493 font-family: var(--font-mono);
494 font-size: 12px;
495 color: var(--text-strong);
496 background: rgba(255,255,255,0.04);
497 border: 1px solid var(--border);
498 padding: 1px 6px;
499 border-radius: 5px;
500 margin-left: 6px;
501 word-break: break-all;
502 overflow-wrap: anywhere;
503 }
504 .selfhost-probe-hint {
505 margin-top: 4px;
506 font-size: 12px;
507 color: var(--text-muted);
508 }
509 .selfhost-probe-hint code {
510 font-family: var(--font-mono);
511 font-size: 11.5px;
512 background: rgba(255,255,255,0.04);
513 border: 1px solid var(--border);
514 padding: 1px 5px;
515 border-radius: 4px;
516 }
517
518 .selfhost-pill {
519 display: inline-flex;
520 align-items: center;
521 gap: 6px;
522 padding: 3px 10px;
523 border-radius: 9999px;
524 font-size: 11.5px;
525 font-weight: 600;
526 flex-shrink: 0;
527 }
528 .selfhost-pill.is-ok {
529 background: rgba(52,211,153,0.14);
e589f77ccantynz-alt530 color: var(--green);
b1f2895Claude531 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
532 }
533 .selfhost-pill.is-bad {
534 background: rgba(248,113,113,0.12);
e589f77ccantynz-alt535 color: var(--red);
b1f2895Claude536 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32);
537 }
538 .selfhost-pill.is-warn {
539 background: rgba(251,191,36,0.10);
540 color: #fde68a;
541 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
542 }
543 .selfhost-pill .dot {
544 width: 6px; height: 6px;
545 border-radius: 9999px;
546 background: currentColor;
547 }
548
549 /* ─── Restart hint callout (env not set) ─── */
550 .selfhost-hint {
551 margin-top: 12px;
552 padding: 10px 12px;
553 background: rgba(251,191,36,0.08);
554 border: 1px solid rgba(251,191,36,0.35);
555 border-radius: 10px;
556 font-size: 12.5px;
557 color: #fde68a;
558 line-height: 1.5;
559 }
560 .selfhost-hint strong { color: #fbbf24; }
561 .selfhost-hint pre {
562 margin: 8px 0 0;
563 padding: 8px 10px;
564 background: rgba(0,0,0,0.32);
565 border-radius: 6px;
566 font-family: var(--font-mono);
567 font-size: 11.5px;
568 color: var(--text);
569 overflow-x: auto;
570 }
571
572 /* ─── Bootstrap actions ─── */
573 .selfhost-actions {
574 display: flex;
575 align-items: center;
576 gap: var(--space-3);
577 flex-wrap: wrap;
578 }
579 .selfhost-actions form { margin: 0; }
580 .selfhost-action-hint {
581 font-size: 12px;
582 color: var(--text-muted);
583 line-height: 1.5;
584 max-width: 460px;
585 }
586 .selfhost-action-hint code {
587 font-family: var(--font-mono);
588 font-size: 11.5px;
589 background: rgba(255,255,255,0.04);
590 border: 1px solid var(--border);
591 padding: 1px 5px;
592 border-radius: 4px;
593 }
594 .selfhost-btn {
595 display: inline-flex;
596 align-items: center;
597 gap: 6px;
598 padding: 9px 16px;
599 border-radius: 10px;
600 font-size: 13px;
601 font-weight: 600;
602 text-decoration: none;
603 border: 1px solid transparent;
604 cursor: pointer;
605 font: inherit;
606 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease;
607 line-height: 1;
608 }
609 .selfhost-btn-primary {
6fd5915Claude610 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
b1f2895Claude611 color: #ffffff;
6fd5915Claude612 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
b1f2895Claude613 }
614 .selfhost-btn-primary:hover:not(:disabled) {
615 transform: translateY(-1px);
6fd5915Claude616 box-shadow: 0 10px 24px -8px rgba(91,110,232,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
b1f2895Claude617 }
618 .selfhost-btn-primary:disabled {
619 cursor: not-allowed;
620 opacity: 0.55;
621 box-shadow: none;
622 transform: none;
623 background: rgba(255,255,255,0.05);
624 color: var(--text-muted);
625 border-color: var(--border);
626 }
627
628 /* ─── Recent deploys (reuses the admin-deploys row pattern) ─── */
629 .selfhost-empty {
630 padding: 18px 4px;
631 color: var(--text-muted);
632 font-size: 13px;
633 text-align: center;
634 }
635 .selfhost-empty code {
636 font-family: var(--font-mono);
637 background: rgba(255,255,255,0.04);
638 border: 1px solid var(--border);
639 padding: 1px 6px;
640 border-radius: 5px;
641 }
642 .selfhost-list {
643 list-style: none;
644 margin: 0;
645 padding: 0;
646 }
647 .selfhost-row {
648 position: relative;
649 padding: 12px 0;
650 border-bottom: 1px solid var(--border);
651 }
652 .selfhost-row:last-child { border-bottom: 0; }
653 .selfhost-row-head {
654 display: flex;
655 align-items: center;
656 gap: 12px;
657 flex-wrap: wrap;
658 }
659 .selfhost-sha-pill {
660 display: inline-flex;
661 align-items: center;
662 gap: 6px;
663 padding: 3px 9px;
664 background: transparent;
665 border: 1px solid rgba(255,255,255,0.10);
666 border-radius: 9999px;
667 font-size: 12.5px;
668 color: var(--text-strong);
669 }
670 .selfhost-sha-pill code {
671 font-family: var(--font-mono);
672 font-size: 12px;
673 color: inherit;
674 background: transparent;
675 padding: 0;
676 }
677 .selfhost-sha-dot {
678 width: 7px; height: 7px;
679 border-radius: 9999px;
680 flex-shrink: 0;
681 }
e589f77ccantynz-alt682 .selfhost-sha-dot.is-green { background: var(--green); box-shadow: 0 0 0 2px rgba(52,211,153,0.18); }
683 .selfhost-sha-dot.is-failed { background: var(--red); box-shadow: 0 0 0 2px rgba(248,113,113,0.20); }
b1f2895Claude684 .selfhost-sha-dot.is-rolling {
685 background: #fbbf24;
686 box-shadow: 0 0 0 2px rgba(251,191,36,0.22);
687 }
688 .selfhost-row-when {
689 font-size: 13px;
690 color: var(--text);
691 }
692 .selfhost-row-spacer { flex: 1; }
693 .selfhost-row-duration {
694 font-family: var(--font-mono);
695 font-size: 12.5px;
696 color: var(--text-strong);
697 background: rgba(255,255,255,0.04);
698 padding: 3px 10px;
699 border-radius: 9999px;
700 border: 1px solid var(--border);
701 font-variant-numeric: tabular-nums;
702 }
703 .selfhost-row-status {
704 display: inline-flex;
705 align-items: center;
706 gap: 6px;
707 padding: 2px 10px;
708 border-radius: 9999px;
709 font-size: 11.5px;
710 font-weight: 600;
711 }
e589f77ccantynz-alt712 .selfhost-row-status.is-ok { background: rgba(52,211,153,0.14); color: var(--green); }
713 .selfhost-row-status.is-bad { background: rgba(248,113,113,0.16); color: var(--red); }
b1f2895Claude714 .selfhost-row-status .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
715
716 /* ─── 403 fallback ─── */
717 .selfhost-403 {
718 max-width: 540px;
719 margin: var(--space-12) auto;
720 padding: var(--space-6);
721 text-align: center;
722 background: var(--bg-elevated);
723 border: 1px solid var(--border);
724 border-radius: 16px;
725 }
726 .selfhost-403 h2 {
727 font-family: var(--font-display);
728 font-size: 22px;
729 margin: 0 0 8px;
730 color: var(--text-strong);
731 }
732 .selfhost-403 p { color: var(--text-muted); margin: 0; font-size: 14px; }
733
734 @media (max-width: 640px) {
735 .selfhost-wrap { padding: var(--space-4) var(--space-3) var(--space-8); }
736 .selfhost-section-sub { margin-left: 0; }
737 .selfhost-row-spacer { display: none; }
738 }
739`;
740
741/* Inline SVG icons. */
742function IconArrowLeft() {
f2c00b4CC LABS App743 return (
b1f2895Claude744 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
745 <line x1="19" y1="12" x2="5" y2="12" />
746 <polyline points="12 19 5 12 12 5" />
747 </svg>
f2c00b4CC LABS App748 );
749}
b1f2895Claude750function IconServer() {
751 return (
752 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
753 <rect x="2" y="2" width="20" height="8" rx="2" ry="2" />
754 <rect x="2" y="14" width="20" height="8" rx="2" ry="2" />
755 <line x1="6" y1="6" x2="6.01" y2="6" />
756 <line x1="6" y1="18" x2="6.01" y2="18" />
757 </svg>
758 );
759}
760function IconFolder() {
761 return (
762 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
763 <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
764 </svg>
765 );
766}
767function IconHook() {
768 return (
769 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
770 <path d="M18 8A6 6 0 0 0 6 8c0 7 -3 9 -3 9h18s-3 -2 -3 -9" />
771 <path d="M13.73 21a2 2 0 0 1-3.46 0" />
772 </svg>
773 );
774}
775function IconCog() {
f2c00b4CC LABS App776 return (
b1f2895Claude777 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
778 <circle cx="12" cy="12" r="3" />
779 <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
780 </svg>
781 );
782}
783function IconPlay() {
784 return (
785 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
786 <polygon points="5 3 19 12 5 21 5 3" />
787 </svg>
788 );
789}
790function IconHistory() {
791 return (
792 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
793 <polyline points="1 4 1 10 7 10" />
794 <path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
795 </svg>
f2c00b4CC LABS App796 );
797}
798
799// ---------------------------------------------------------------------------
800// Gating
801// ---------------------------------------------------------------------------
802
803const selfHost = new Hono<AuthEnv>();
804selfHost.use("*", softAuth);
805
806async function gate(c: any): Promise<{ user: any } | Response> {
807 const user = c.get("user");
808 if (!user) return c.redirect("/login?next=/admin/self-host");
809 if (!(await isSiteAdmin(user.id))) {
810 return c.html(
811 <Layout title="Forbidden" user={user}>
b1f2895Claude812 <div class="selfhost-403">
f2c00b4CC LABS App813 <h2>403 — Not a site admin</h2>
814 <p>You don't have permission to view this page.</p>
815 </div>
b1f2895Claude816 <style dangerouslySetInnerHTML={{ __html: SELFHOST_CSS }} />
f2c00b4CC LABS App817 </Layout>,
818 403
819 );
820 }
821 return { user };
822}
823
824function redirectWith(c: any, kind: "success" | "error", msg: string): Response {
825 return c.redirect(`/admin/self-host?${kind}=${encodeURIComponent(msg)}`);
826}
827
828// ---------------------------------------------------------------------------
829// GET /admin/self-host
830// ---------------------------------------------------------------------------
831
832selfHost.get("/admin/self-host", async (c) => {
833 const g = await gate(c);
834 if (g instanceof Response) return g;
835 const { user } = g;
836
837 const success = c.req.query("success");
838 const error = c.req.query("error");
839
840 const [repoState, recent] = await Promise.all([
841 readRepoState(),
842 readRecentDeploys(),
843 ]);
844 const hookState = readHookState(repoState.diskPath);
845 const envState = readEnvState();
846
847 const allGreen =
848 repoState.exists && hookState.installed && envState.matchesExpected;
849
850 return c.html(
f4a1547ccantynz-alt851 <AdminShell active="self-host" title="Self-host" user={user}>
b1f2895Claude852 <div class="selfhost-wrap">
853 {/* ─── Hero ─── */}
854 <section class="selfhost-hero">
855 <div class="selfhost-hero-orb" aria-hidden="true" />
856 <div class="selfhost-hero-inner">
857 <div class="selfhost-hero-top">
858 <div class="selfhost-hero-text">
859 <div class="selfhost-eyebrow">
860 <span class="selfhost-eyebrow-pill" aria-hidden="true">
861 <IconServer />
862 </span>
863 Self-host migration · Site admin · <span class="selfhost-who">{user.username}</span>
864 </div>
865 <h1 class="selfhost-title">
866 <span class="selfhost-title-grad">Self-host.</span>
867 </h1>
868 <p class="selfhost-sub">
869 Status of the BLOCK W migration — Gluecron's own source hosted
870 on Gluecron itself. Once all three probes are green, every push
871 to <code>{SELF_HOST_FULL}</code> deploys via the local
872 post-receive hook in ~25 seconds.
873 </p>
874 </div>
875 <a href="/admin" class="selfhost-hero-back">
876 <IconArrowLeft />
877 Back to admin
878 </a>
879 </div>
880 <div class="selfhost-hero-status" role="status">
881 <span class="selfhost-eyebrow" style="margin-bottom:0">
882 <span style="text-transform:uppercase;letter-spacing:0.12em;font-weight:700;font-family:var(--font-mono);font-size:11px">Overall</span>
883 </span>
884 <span class={"selfhost-status-pill " + (allGreen ? "is-ok" : "is-bad")}>
885 <span class="dot" aria-hidden="true" />
886 {allGreen ? "Self-host ready" : "Setup incomplete"}
887 </span>
888 <span style="color:var(--text-muted);font-size:12.5px">
889 {allGreen
890 ? "All three probes are green — pushes auto-deploy."
891 : "Fix the red probes below to enable self-deploy."}
892 </span>
893 </div>
894 </div>
895 </section>
f2c00b4CC LABS App896
897 {success && (
b1f2895Claude898 <div class="selfhost-banner is-ok" role="status">
899 <span class="selfhost-banner-dot" aria-hidden="true" />
f2c00b4CC LABS App900 {decodeURIComponent(success)}
901 </div>
902 )}
903 {error && (
b1f2895Claude904 <div class="selfhost-banner is-error" role="alert">
905 <span class="selfhost-banner-dot" aria-hidden="true" />
f2c00b4CC LABS App906 {decodeURIComponent(error)}
907 </div>
908 )}
909
b1f2895Claude910 {/* ─── Repo path probe ─── */}
911 <section class="selfhost-section">
912 <header class="selfhost-section-head">
913 <div class="selfhost-section-head-text">
914 <h3 class="selfhost-section-title">
915 <span class="selfhost-section-title-icon" aria-hidden="true">
916 <IconFolder />
917 </span>
918 Repo path
919 </h3>
920 <p class="selfhost-section-sub">
921 The Gluecron <code>repositories</code> row must exist so the
922 git frontend can resolve the bare repo on disk.
923 </p>
924 </div>
925 <span class={"selfhost-pill " + (repoState.exists ? "is-ok" : "is-bad")}>
926 <span class="dot" aria-hidden="true" />
927 {repoState.exists ? "Mirrored" : "Not mirrored"}
928 </span>
929 </header>
930 <div class="selfhost-section-body">
931 <div class="selfhost-probe">
932 <div class="selfhost-probe-text">
933 <div class="selfhost-probe-label">
934 Repository <code>{SELF_HOST_FULL}</code>
935 </div>
936 {repoState.diskPath ? (
937 <div class="selfhost-probe-hint">
938 Disk path <code>{repoState.diskPath}</code>
939 </div>
940 ) : (
941 <div class="selfhost-probe-hint">
942 No <code>repositories</code> row yet — run the bootstrap below.
943 </div>
f2c00b4CC LABS App944 )}
b1f2895Claude945 </div>
946 </div>
947 </div>
948 </section>
949
950 {/* ─── Hook probe ─── */}
951 <section class="selfhost-section">
952 <header class="selfhost-section-head">
953 <div class="selfhost-section-head-text">
954 <h3 class="selfhost-section-title">
955 <span class="selfhost-section-title-icon" aria-hidden="true">
956 <IconHook />
957 </span>
958 Hook installed?
959 </h3>
960 <p class="selfhost-section-sub">
961 The bare repo's <code>hooks/post-receive</code> script is what
962 fires <code>self-deploy.sh</code> on every push.
963 </p>
964 </div>
965 <span class={"selfhost-pill " + (hookState.installed ? "is-ok" : "is-bad")}>
966 <span class="dot" aria-hidden="true" />
967 {hookState.installed ? "Installed" : "Missing"}
968 </span>
969 </header>
970 <div class="selfhost-section-body">
971 <div class="selfhost-probe">
972 <div class="selfhost-probe-text">
973 <div class="selfhost-probe-label">
974 Hook path <code>{hookState.path}</code>
975 </div>
976 <div class="selfhost-probe-hint">
977 {hookState.installed
978 ? "Executable on disk — pushes will dispatch the self-deploy pipeline."
979 : "Not on disk — run the bootstrap to install it (idempotent)."}
980 </div>
981 </div>
bf19c50Test User982 </div>
f2c00b4CC LABS App983 </div>
b1f2895Claude984 </section>
985
986 {/* ─── Env probe ─── */}
987 <section class="selfhost-section">
988 <header class="selfhost-section-head">
989 <div class="selfhost-section-head-text">
990 <h3 class="selfhost-section-title">
991 <span class="selfhost-section-title-icon" aria-hidden="true">
992 <IconCog />
993 </span>
994 Env vars
995 </h3>
996 <p class="selfhost-section-sub">
997 The running process needs <code>SELF_HOST_REPO</code> so the
998 post-receive hook only fires for our own repo.
999 </p>
1000 </div>
1001 <span
1002 class={
1003 "selfhost-pill " +
1004 (envState.matchesExpected
1005 ? "is-ok"
1006 : envState.selfHostRepoSet
1007 ? "is-warn"
1008 : "is-bad")
1009 }
f2c00b4CC LABS App1010 >
b1f2895Claude1011 <span class="dot" aria-hidden="true" />
1012 {envState.matchesExpected
1013 ? "Set"
1014 : envState.selfHostRepoSet
1015 ? "Mismatch"
1016 : "Unset"}
f2c00b4CC LABS App1017 </span>
b1f2895Claude1018 </header>
1019 <div class="selfhost-section-body">
1020 <div class="selfhost-probe">
1021 <div class="selfhost-probe-text">
1022 <div class="selfhost-probe-label">
1023 <code>SELF_HOST_REPO</code>
1024 {envState.selfHostRepo && (
1025 <code style="margin-left:6px">= {envState.selfHostRepo}</code>
1026 )}
1027 </div>
1028 <div class="selfhost-probe-hint">
1029 Expected <code>{SELF_HOST_FULL}</code>
1030 </div>
1031 </div>
1032 </div>
1033 {!envState.selfHostRepoSet && (
1034 <div class="selfhost-hint">
1035 <strong>Hint:</strong> <code>SELF_HOST_REPO</code> is read from{" "}
1036 <code>/etc/gluecron.env</code> when the gluecron service starts.
1037 If you just appended it via SSH, the running process won't see
1038 it until you run:
1039 <pre>systemctl restart gluecron</pre>
1040 </div>
1041 )}
1042 </div>
1043 </section>
1044
1045 {/* ─── Bootstrap section ─── */}
1046 <section class="selfhost-section">
1047 <header class="selfhost-section-head">
1048 <div class="selfhost-section-head-text">
1049 <h3 class="selfhost-section-title">
1050 <span class="selfhost-section-title-icon" aria-hidden="true">
1051 <IconPlay />
1052 </span>
1053 Bootstrap
1054 </h3>
1055 <p class="selfhost-section-sub">
1056 Mirror Gluecron's source from GitHub onto this Gluecron
1057 instance. Idempotent — safe to re-run. See{" "}
1058 <a
1059 href={`/${SELF_HOST_OWNER}/${SELF_HOST_NAME}/blob/main/docs/SELF_HOST.md`}
1060 style="color:var(--accent);text-decoration:none"
1061 >
1062 docs/SELF_HOST.md
1063 </a>{" "}
1064 for the full runbook.
1065 </p>
1066 </div>
1067 </header>
1068 <div class="selfhost-section-body">
1069 <div class="selfhost-actions">
1070 <form
1071 method="post"
1072 action="/admin/self-host/bootstrap"
1073 onsubmit="return confirm('Run the self-host bootstrap on this box? Safe to re-run, but it will spawn a child process.')"
1074 >
1075 <button
1076 type="submit"
1077 class="selfhost-btn selfhost-btn-primary"
1078 disabled={repoState.exists && hookState.installed}
1079 title={
1080 repoState.exists && hookState.installed
1081 ? "Bootstrap already applied"
1082 : "Run scripts/self-host-bootstrap.ts"
1083 }
1084 >
1085 <IconPlay />
1086 {repoState.exists && hookState.installed
1087 ? "Already bootstrapped"
1088 : "Run bootstrap"}
1089 </button>
1090 </form>
1091 <span class="selfhost-action-hint">
1092 Runs <code>bun run scripts/self-host-bootstrap.ts</code>{" "}
1093 detached. Output streams to{" "}
1094 <code>/var/log/gluecron-self-deploy.log</code>.
1095 </span>
1096 </div>
1097 </div>
1098 </section>
1099
1100 {/* ─── Recent self-deploys ─── */}
1101 <section class="selfhost-section">
1102 <header class="selfhost-section-head">
1103 <div class="selfhost-section-head-text">
1104 <h3 class="selfhost-section-title">
1105 <span class="selfhost-section-title-icon" aria-hidden="true">
1106 <IconHistory />
1107 </span>
1108 Last 10 self-deploys
1109 </h3>
1110 <p class="selfhost-section-sub">
1111 Rows from <code>platform_deploys</code> where{" "}
1112 <code>source = 'self-deploy'</code>.
1113 </p>
1114 </div>
1115 </header>
1116 <div class="selfhost-section-body">
1117 {recent.length === 0 ? (
1118 <div class="selfhost-empty">
1119 No self-deploys recorded yet. Push a commit to{" "}
1120 <code>{SELF_HOST_FULL}</code> after completing the bootstrap.
1121 </div>
1122 ) : (
1123 <ol class="selfhost-list" aria-label="Recent self-deploys">
1124 {recent.map((d) => {
1125 const ok = d.status === "succeeded";
1126 const dotClass = ok
1127 ? "is-green"
1128 : d.status === "failed"
1129 ? "is-failed"
1130 : "is-rolling";
1131 return (
1132 <li class="selfhost-row">
1133 <div class="selfhost-row-head">
1134 <span class="selfhost-sha-pill">
1135 <span class={`selfhost-sha-dot ${dotClass}`} aria-hidden="true" />
1136 <code class="meta-mono">{shortSha(d.sha)}</code>
1137 </span>
1138 <span class={"selfhost-row-status " + (ok ? "is-ok" : "is-bad")}>
1139 <span class="dot" aria-hidden="true" />
1140 {d.status}
1141 </span>
1142 <span
1143 class="selfhost-row-when"
1144 title={d.startedAt.toISOString()}
1145 >
1146 {relativeTime(d.startedAt)}
1147 </span>
1148 <span class="selfhost-row-spacer" />
1149 <span class="selfhost-row-duration">
1150 {formatDuration(d.durationMs)}
1151 </span>
1152 </div>
1153 </li>
1154 );
1155 })}
1156 </ol>
1157 )}
f2c00b4CC LABS App1158 </div>
b1f2895Claude1159 </section>
f2c00b4CC LABS App1160 </div>
b1f2895Claude1161 <style dangerouslySetInnerHTML={{ __html: SELFHOST_CSS }} />
f4a1547ccantynz-alt1162 </AdminShell>
f2c00b4CC LABS App1163 );
1164});
1165
1166// ---------------------------------------------------------------------------
1167// POST /admin/self-host/bootstrap
1168//
1169// Spawns scripts/self-host-bootstrap.ts with the default args. Detached
1170// so the request returns immediately. The operator watches the systemd
1171// journal / log file for progress.
1172// ---------------------------------------------------------------------------
1173
1174selfHost.post("/admin/self-host/bootstrap", async (c) => {
1175 const g = await gate(c);
1176 if (g instanceof Response) return g;
1177 const { user } = g;
1178
1179 try {
1180 const bunCmd =
1181 _deps.getEnv().GLUECRON_BUN_PATH || "/root/.bun/bin/bun";
1182 const scriptPath =
1183 _deps.getEnv().GLUECRON_BOOTSTRAP_SCRIPT ||
1184 "/opt/gluecron/scripts/self-host-bootstrap.ts";
bf19c50Test User1185
1186 // P0 audit #10/#11 — pre-check the binary + script paths exist before
1187 // spawning. The old handler spawned blindly with stderr discarded, so a
1188 // missing bun produced a "success" toast and a permanently broken state.
1189 if (!_deps.fsExists(bunCmd)) {
1190 return redirectWith(
1191 c,
1192 "error",
1193 `Bootstrap aborted: bun binary not found at ${bunCmd}. Set GLUECRON_BUN_PATH or install bun on the box.`
1194 );
1195 }
1196 if (!_deps.fsExists(scriptPath)) {
1197 return redirectWith(
1198 c,
1199 "error",
1200 `Bootstrap aborted: script not found at ${scriptPath}. The deploy may be incomplete.`
1201 );
1202 }
1203
f2c00b4CC LABS App1204 const child = _deps.spawn([bunCmd, "run", scriptPath], { detached: true });
1205 try {
1206 (child as any)?.unref?.();
1207 } catch {
1208 /* ignore */
1209 }
1210 try {
1211 await _deps.audit({
1212 userId: user.id,
1213 action: "admin.self_host.bootstrap_triggered",
1214 targetType: "repository",
1215 metadata: { repo: SELF_HOST_FULL },
1216 });
1217 } catch {
1218 /* audit failure is non-fatal */
1219 }
1220 return redirectWith(
1221 c,
1222 "success",
bf19c50Test User1223 `Bootstrap dispatched. Output streams to ${BOOTSTRAP_LOG_PATH} — refresh in ~30s to see status.`
f2c00b4CC LABS App1224 );
1225 } catch (err) {
1226 const message = err instanceof Error ? err.message : String(err);
1227 return redirectWith(c, "error", `Bootstrap failed to spawn: ${message}`);
1228 }
1229});
1230
1231export const __test = {
1232 readRepoState,
1233 readHookState,
1234 readEnvState,
1235 readRecentDeploys,
1236 SELF_HOST_OWNER,
1237 SELF_HOST_NAME,
1238 SELF_HOST_FULL,
1239};
1240
1241export default selfHost;