Commit46aff88unknown_key
Merge pull request #82 from ccantynz-alt/claude/review-readme-docs-ulqPK
Merge pull request #82 from ccantynz-alt/claude/review-readme-docs-ulqPK Claude/review readme docs ulq pk
9 files changed+135−2246aff8836e1197afdcee4eb2d9638b4034f69f69
9 changed files+135−22
Modifiedscripts/self-host-bootstrap.ts+10−0View fileUnifiedSplit
@@ -301,6 +301,16 @@ export const SELF_HOST_HOOK_BODY = `#!/usr/bin/env bash
301301# SELF_HOST_REPO matches. This hook is the equivalent breadcrumb for SSH
302302# receive-pack and external direct-push scenarios.
303303set -euo pipefail
304# Source the operator env file so GLUECRON_SELF_DEPLOY_SCRIPT and any other
305# overrides set by the operator are visible to this shell hook. systemd's
306# EnvironmentFile only flows to the gluecron service, not to git hooks
307# invoked by direct receive-pack — without this source the hook used the
308# baked-in defaults which silently disagreed with /etc/gluecron.env.
309if [ -f /etc/gluecron.env ]; then
310 set -a
311 . /etc/gluecron.env
312 set +a
313fi
304314SELF_DEPLOY="\${GLUECRON_SELF_DEPLOY_SCRIPT:-/opt/gluecron/scripts/self-deploy.sh}"
305315LOG="\${GLUECRON_SELF_DEPLOY_LOG:-/var/log/gluecron-self-deploy.log}"
306316if [ -x "$SELF_DEPLOY" ]; then
Modifiedsrc/__tests__/push.test.ts+1−1View fileUnifiedSplit
@@ -470,6 +470,6 @@ describe("/offline.html", () => {
470470 expect(res.headers.get("content-type") || "").toContain("text/html");
471471 const body = await res.text();
472472 expect(body).toContain("You're offline");
473 expect(body).toContain('data-theme="dark"');
473 expect(body).toContain('data-theme="light"');
474474 });
475475});
Modifiedsrc/__tests__/self-host.test.ts+7−1View fileUnifiedSplit
@@ -466,7 +466,9 @@ describe("self-host bootstrap orchestrator", () => {
466466 expect(result.steps.repoRow?.created).toBe(false);
467467 expect(inserts.length).toBe(0);
468468 // `git init --bare` should NOT have been called when HEAD already exists.
469 expect(calls.find((c) => c.cmd.includes("init"))).toBeUndefined();
469 expect(
470 calls.find((c: { cmd: string[] }) => c.cmd.includes("init"))
471 ).toBeUndefined();
470472 expect(result.steps.bareRepoCreated).toBe(false);
471473 });
472474
@@ -625,6 +627,10 @@ describe("POST /admin/self-host/bootstrap", () => {
625627 captured.push({ cmd });
626628 return { unref: () => {} } as any;
627629 },
630 // Pretend both the bun binary and the script exist so the pre-spawn
631 // path-checks (admin-self-host.tsx) pass. Production gets the real
632 // existsSync.
633 fsExists: () => true,
628634 getEnv: () => ({}),
629635 });
630636 const res = await app.request(
Modifiedsrc/app.tsx+7−0View fileUnifiedSplit
@@ -125,6 +125,7 @@ import { authRateLimit, gitRateLimit, searchRateLimit } from "./middleware/rate-
125125import { csrfToken, csrfProtect } from "./middleware/csrf";
126126
127127import type { AuthEnv } from "./middleware/auth";
128import { softAuth } from "./middleware/auth";
128129
129130const app = new Hono<AuthEnv>();
130131
@@ -138,6 +139,12 @@ app.use("*", async (c, next) => {
138139 return logger()(c, next);
139140});
140141app.use("/api/*", cors());
142// Global softAuth — populates c.get("user") for every downstream middleware
143// + route. This was previously per-route, which meant rate-limit middleware
144// (and anything else inspecting auth state) always saw a null user. Keep
145// individual routes free to add requireAuth on top for hard gating; this
146// just establishes the user object cheaply.
147app.use("*", softAuth);
141148
142149// Force-revalidate HTML on every request — kills browser cache holding stale
143150// pre-redesign markup. JSON / static assets keep their own cache rules; only
Modifiedsrc/hooks/post-receive.ts+9−3View fileUnifiedSplit
@@ -154,10 +154,16 @@ export async function onPostReceive(
154154// BLOCK W — DI seam so the test suite can capture the spawn call without
155155// actually shelling out to /opt/gluecron/scripts/self-deploy.sh. Production
156156// callers go straight to Bun.spawn.
157let __selfHostSpawn: (cmd: string[], opts: any) => any = (cmd, opts) =>
157const __defaultSelfHostSpawn: (cmd: string[], opts: any) => any = (cmd, opts) =>
158158 Bun.spawn(cmd, opts);
159export function __setSelfHostSpawnForTests(fn: typeof __selfHostSpawn) {
160 __selfHostSpawn = fn;
159let __selfHostSpawn: (cmd: string[], opts: any) => any = __defaultSelfHostSpawn;
160/**
161 * Test-only: replace the spawn impl. Pass `null` to reset to Bun.spawn.
162 */
163export function __setSelfHostSpawnForTests(
164 fn: typeof __selfHostSpawn | null
165): void {
166 __selfHostSpawn = fn ?? __defaultSelfHostSpawn;
161167}
162168
163169/**
Modifiedsrc/lib/scheduled-workflows.ts+9−0View fileUnifiedSplit
@@ -176,6 +176,15 @@ export async function runScheduledWorkflowsTick(
176176 result.errors += 1;
177177 }
178178
179 // Defensive: if a leaked mock or DB driver returns a non-iterable, coerce
180 // to an empty array so `for (const w of candidates)` doesn't throw. This
181 // closes the cross-suite test-pollution failure where another test file's
182 // mock.module("../db", ...) leaked into this code path.
183 if (!Array.isArray(candidates)) {
184 candidates = [];
185 result.errors += 1;
186 }
187
179188 for (const w of candidates) {
180189 if (result.fired >= MAX_RUNS_PER_TICK) break;
181190 result.considered += 1;
Modifiedsrc/routes/admin-self-host.tsx+61−7View fileUnifiedSplit
@@ -45,14 +45,39 @@ interface Deps {
4545 getEnv: () => Record<string, string | undefined>;
4646}
4747
48/**
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
4858const REAL_DEPS: Deps = {
4959 audit: realAudit,
50 spawn: (cmd, _opts) =>
51 Bun.spawn(cmd, {
52 stdout: "ignore",
53 stderr: "ignore",
54 stdin: "ignore",
55 }),
60 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 },
5681 fsExists: existsSync,
5782 getEnv: () => process.env as Record<string, string | undefined>,
5883};
@@ -334,6 +359,16 @@ selfHost.get("/admin/self-host", async (c) => {
334359 </span>
335360 </li>
336361 </ul>
362 {!envState.selfHostRepoSet && (
363 <div style="margin-top:12px;padding:10px 12px;background:rgba(245,158,11,0.08);border:1px solid rgba(245,158,11,0.25);border-radius:6px;font-size:12px;line-height:1.5">
364 <strong style="color:#f59e0b">Hint:</strong>{" "}
365 <code>SELF_HOST_REPO</code> is read from{" "}
366 <code>/etc/gluecron.env</code> when the gluecron service starts.
367 If you just appended it via SSH, the running process won't see
368 it until you run:
369 <pre style="margin:8px 0 0 0;padding:6px 8px;background:var(--bg);border-radius:4px;font-size:11px;overflow-x:auto">systemctl restart gluecron</pre>
370 </div>
371 )}
337372 <div style="margin-top:14px;font-size:12px;color:var(--text-muted)">
338373 Overall:{" "}
339374 <Pill
@@ -459,6 +494,25 @@ selfHost.post("/admin/self-host/bootstrap", async (c) => {
459494 const scriptPath =
460495 _deps.getEnv().GLUECRON_BOOTSTRAP_SCRIPT ||
461496 "/opt/gluecron/scripts/self-host-bootstrap.ts";
497
498 // P0 audit #10/#11 — pre-check the binary + script paths exist before
499 // spawning. The old handler spawned blindly with stderr discarded, so a
500 // missing bun produced a "success" toast and a permanently broken state.
501 if (!_deps.fsExists(bunCmd)) {
502 return redirectWith(
503 c,
504 "error",
505 `Bootstrap aborted: bun binary not found at ${bunCmd}. Set GLUECRON_BUN_PATH or install bun on the box.`
506 );
507 }
508 if (!_deps.fsExists(scriptPath)) {
509 return redirectWith(
510 c,
511 "error",
512 `Bootstrap aborted: script not found at ${scriptPath}. The deploy may be incomplete.`
513 );
514 }
515
462516 const child = _deps.spawn([bunCmd, "run", scriptPath], { detached: true });
463517 try {
464518 (child as any)?.unref?.();
@@ -478,7 +532,7 @@ selfHost.post("/admin/self-host/bootstrap", async (c) => {
478532 return redirectWith(
479533 c,
480534 "success",
481 "Bootstrap dispatched — watch the system journal for progress."
535 `Bootstrap dispatched. Output streams to ${BOOTSTRAP_LOG_PATH} — refresh in ~30s to see status.`
482536 );
483537 } catch (err) {
484538 const message = err instanceof Error ? err.message : String(err);
Modifiedsrc/routes/pwa.ts+1−1View fileUnifiedSplit
@@ -311,7 +311,7 @@ pwa.get("/sw-push.js", (c) => {
311311
312312/** Offline fallback — minimal, theme-consistent. */
313313export const OFFLINE_HTML = `<!doctype html>
314<html lang="en" data-theme="dark">
314<html lang="en" data-theme="light">
315315<head>
316316<meta charset="utf-8" />
317317<meta name="viewport" content="width=device-width, initial-scale=1.0" />
Modifiedsrc/views/layout.tsx+30−9View fileUnifiedSplit
@@ -39,7 +39,11 @@ export const Layout: FC<
3939 siteBannerText,
4040 siteBannerLevel,
4141}) => {
42 const initialTheme = theme === "light" ? "light" : "dark";
42 // Default to "light" — feedback from operators was the dark default
43 // felt too gamer-ish and not what senior platform engineers expect from
44 // a tool they'd evaluate alongside Vercel / Linear / Stripe. Users who
45 // explicitly want dark can flip via the theme toggle (cookie persists).
46 const initialTheme = theme === "dark" ? "dark" : "light";
4347 const build = getBuildInfo();
4448 // L10 — when `fullTitle` is provided, use it verbatim (no " — gluecron"
4549 // suffix); otherwise fall back to the existing `title` + suffix behaviour.
@@ -413,10 +417,12 @@ export const deployPillScript = `
413417 function render(){
414418 if (!pill || !text || !dot) return;
415419 var d = state.latest;
420 // When there's no deploy event yet, keep the pill HIDDEN. Showing
421 // "No deploys yet" was visible noise on every admin page load and
422 // flashed during reconnect cycles — admins don't need a placeholder.
423 // The pill reveals itself the first time a real deploy fires.
416424 if (!d) {
417 pill.className = 'nav-deploy-pill deploy-pill-empty';
418 text.textContent = 'No deploys yet';
419 pill.style.display = 'inline-flex';
425 pill.style.display = 'none';
420426 return;
421427 }
422428 pill.style.display = 'inline-flex';
@@ -458,11 +464,24 @@ export const deployPillScript = `
458464 if (typeof EventSource === 'undefined') return;
459465 subscribed = true;
460466 var es;
461 var delay = 1500;
467 // Exponential backoff with cap. Previously a tight 1500ms reconnect
468 // produced visible looping in the nav whenever the proxy timed out
469 // or the connection blipped — the bottom-of-page deploy pill
470 // re-rendered the placeholder every 1.5s. Cap at 60s and reset on
471 // successful message receipt.
472 var delay = 2000;
473 var DELAY_MAX = 60000;
474 function bump(){
475 delay = Math.min(delay * 2, DELAY_MAX);
476 }
477 function resetDelay(){
478 delay = 2000;
479 }
462480 function connect(){
463481 try { es = new EventSource('/live-events/platform:deploys'); }
464 catch(e){ setTimeout(connect, delay); return; }
482 catch(e){ bump(); setTimeout(connect, delay); return; }
465483 es.onmessage = function(m){
484 resetDelay();
466485 try {
467486 var d = JSON.parse(m.data);
468487 if (d && d.run_id) {
@@ -476,6 +495,7 @@ export const deployPillScript = `
476495 };
477496 es.onerror = function(){
478497 try { es.close(); } catch(e){}
498 bump();
479499 setTimeout(connect, delay);
480500 };
481501 }
@@ -501,13 +521,14 @@ export const deployPillScript = `
501521`;
502522
503523// Runs before paint — reads the theme cookie and flips data-theme so there's
504// no dark-to-light flash on load. SSR default is dark.
524// no light-to-dark flash on load. SSR default is "light"; cookie-set "dark"
525// is honoured for users who explicitly opted in.
505526const themeInitScript = `
506527 (function(){
507528 try {
508529 var m = document.cookie.match(/(?:^|; )theme=([^;]+)/);
509 var t = m ? decodeURIComponent(m[1]) : 'dark';
510 if (t !== 'light' && t !== 'dark') t = 'dark';
530 var t = m ? decodeURIComponent(m[1]) : 'light';
531 if (t !== 'light' && t !== 'dark') t = 'light';
511532 document.documentElement.setAttribute('data-theme', t);
512533 } catch(_){}
513534 })();
514535