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
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
admin-deploys.tsx4.5 KB · 145 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
/**
 * Block N4 — Admin deploy trigger.
 *
 *   POST /admin/deploys/trigger         — kick off the hetzner-deploy.yml
 *                                         workflow via GitHub workflow_dispatch.
 *
 * Reads `GITHUB_TOKEN` (operator-provided, repo+workflow scopes) from the
 * server environment. The CLI talks to GitHub directly; this route is the
 * "click a button on the admin page" equivalent so the operator never has to
 * leave Gluecron to ship a hot-fix.
 *
 * NOTE: The companion `/admin/deploys` page (Block N3) has not landed yet on
 * this branch. We ship the trigger endpoint now so the CLI + tests can land
 * cleanly; once N3 adds the page, its "Trigger deploy" button posts here.
 */

import { Hono } from "hono";
import { softAuth } from "../middleware/auth";
import type { AuthEnv } from "../middleware/auth";
import { isSiteAdmin } from "../lib/admin";
import { audit } from "../lib/notify";

const GH_API = "https://api.github.com";

/**
 * Dependency-injected fetcher so tests can drive the route without hitting
 * the real api.github.com.
 */
export type GithubFetch = (
  url: string,
  init?: { method?: string; headers?: Record<string, string>; body?: string }
) => Promise<{ status: number; ok: boolean; text: () => Promise<string> }>;

let _githubFetch: GithubFetch | null = null;
let _envOverride: { GITHUB_TOKEN?: string } | null = null;

/** Test-only: override the github fetcher. Pass `null` to restore default. */
export function __setGithubFetchForTests(f: GithubFetch | null): void {
  _githubFetch = f;
}

/** Test-only: override env reads. Pass `null` to fall back to process.env. */
export function __setEnvForTests(e: { GITHUB_TOKEN?: string } | null): void {
  _envOverride = e;
}

function ghToken(): string | undefined {
  if (_envOverride) return _envOverride.GITHUB_TOKEN;
  return process.env.GITHUB_TOKEN;
}

function ghFetch(): GithubFetch {
  return _githubFetch ?? ((fetch as unknown) as GithubFetch);
}

const admin = new Hono<AuthEnv>();
admin.use("*", softAuth);

async function gate(c: any): Promise<{ user: any } | Response> {
  const user = c.get("user");
  if (!user) return c.json({ error: "auth required" }, 401);
  if (!(await isSiteAdmin(user.id))) {
    return c.json({ error: "site admin required" }, 403);
  }
  return { user };
}

admin.post("/admin/deploys/trigger", async (c) => {
  const g = await gate(c);
  if (g instanceof Response) return g;
  const { user } = g;

  const token = ghToken();
  if (!token) {
    return c.json(
      {
        error:
          "GITHUB_TOKEN is not set on the server — configure GITHUB_TOKEN on the box first (e.g. /etc/gluecron.env).",
      },
      400
    );
  }

  // Body is optional — defaults are the hetzner deploy on main of this repo.
  let body: any = {};
  try {
    body = await c.req.json();
  } catch {
    body = {};
  }
  // GitHub repo for the deploy workflow. NOT the same as Gluecron's
  // canonical name (`ccantynz/Gluecron.com`) — GitHub knows the mirror
  // as `ccantynz-alt/Gluecron.com`. Default to the GitHub-side path so
  // the "Deploy" button doesn't 404. Override via the request body if
  // the mirror ever moves. (Env override available via GITHUB_DEPLOY_REPO.)
  const repo = String(
    body.repo || process.env.GITHUB_DEPLOY_REPO || "ccantynz-alt/Gluecron.com"
  );
  const workflow = String(body.workflow || "hetzner-deploy.yml");
  const ref = String(body.ref || "main");
  const [owner, name] = repo.split("/");
  if (!owner || !name) {
    return c.json({ error: "expected repo as owner/name" }, 400);
  }

  const url = `${GH_API}/repos/${owner}/${name}/actions/workflows/${encodeURIComponent(workflow)}/dispatches`;
  const res = await ghFetch()(url, {
    method: "POST",
    headers: {
      accept: "application/vnd.github+json",
      authorization: `Bearer ${token}`,
      "content-type": "application/json",
      "x-github-api-version": "2022-11-28",
      "user-agent": "gluecron-admin",
    },
    body: JSON.stringify({ ref }),
  });

  if (res.status !== 204) {
    const raw = await res.text();
    let msg = raw;
    try {
      const j = JSON.parse(raw);
      msg = j?.message || raw;
    } catch {
      // raw it is
    }
    return c.json(
      { error: `github responded ${res.status}: ${msg || "request failed"}` },
      502
    );
  }

  await audit({
    userId: user.id,
    action: "admin.deploy.triggered",
    targetType: "workflow",
    targetId: `${repo}:${workflow}@${ref}`,
    metadata: { repo, workflow, ref },
  });

  return c.json({ ok: true, repo, workflow, ref });
});

export default admin;