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