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

connect-claude.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.

connect-claude.tsxBlame1139 lines · 1 contributor
662ce86Claude1/**
2 * Connect Claude — user-facing one-click MCP setup page.
3 *
4 * Routes
5 * GET /connect/claude site-wide auth-gated; hero + token mint +
6 * three install-path cards + live tools grid
7 * + connection status panel.
8 * GET /settings/claude alias → redirect to /connect/claude
9 * (kept for symmetry with /settings/* nav).
10 * POST /connect/claude/token session-cookie mint of a fresh `glc_` PAT
11 * (internally calls the same logic as the
12 * existing /api/v2/auth/install-token route,
13 * so the audit trail and scope rules stay
14 * consistent).
15 * GET /connect/claude/dxt mint-on-download — returns a .dxt zip
16 * bundle with the caller's freshly-minted
17 * PAT embedded in `manifest.json` so
18 * installing requires zero typing.
19 * GET /api/connect/status JSON poll endpoint for the live
20 * "last called" + "total calls" panel.
21 *
22 * Hard rules (per the build task brief):
23 * - DO NOT modify any shared view file or any of the MCP server files.
24 * - The marketing /gluecron.dxt remains untouched (served by src/routes/dxt.ts);
25 * this file exposes the personalized variant at a different URL.
26 * - Re-use the existing PAT mechanism — never invent a parallel mint flow.
27 *
28 * Design language follows commits a004c46 (dashboard hero gradient), 98eb360
29 * (settings card polish), and 7a99d47 (numbered import option cards). All CSS
30 * is scoped under `.connect-claude-` so it cannot bleed.
31 */
32
33import { Hono } from "hono";
34import { and, desc, eq } from "drizzle-orm";
35import { deflateRawSync } from "node:zlib";
36import { db } from "../db";
37import { apiTokens, auditLog } from "../db/schema";
38import type { AuthEnv } from "../middleware/auth";
39import { requireAuth } from "../middleware/auth";
40import { Layout } from "../views/layout";
41import { audit } from "../lib/notify";
42import { config } from "../lib/config";
43import { defaultTools } from "../lib/mcp-tools";
44
45const connectClaude = new Hono<AuthEnv>();
46
47// ─── Auth guard ────────────────────────────────────────────────────────────
48// Every user-facing route here demands a logged-in user. Public 404 visitors
49// will get the standard /login redirect from requireAuth.
50connectClaude.use("/connect/claude", requireAuth);
51connectClaude.use("/connect/claude/*", requireAuth);
52connectClaude.use("/settings/claude", requireAuth);
53connectClaude.use("/api/connect/status", requireAuth);
54
55// ─── PAT-mint helpers ──────────────────────────────────────────────────────
56// We deliberately duplicate the tiny pieces of /api/v2/auth/install-token
57// rather than importing its handler, because the install-token handler does
58// its own session-cookie gating (we already have the user from requireAuth)
59// and HTTP body parsing (we want a programmatic mint). The on-disk shape
60// (token format, hash, audit row) is identical.
61
62function generateInstallToken(): string {
63 const bytes = crypto.getRandomValues(new Uint8Array(32));
64 return (
65 "glc_" +
66 Array.from(bytes)
67 .map((b) => b.toString(16).padStart(2, "0"))
68 .join("")
69 );
70}
71
72async function hashInstallToken(token: string): Promise<string> {
73 const data = new TextEncoder().encode(token);
74 const hash = await crypto.subtle.digest("SHA-256", data);
75 return Array.from(new Uint8Array(hash))
76 .map((b) => b.toString(16).padStart(2, "0"))
77 .join("");
78}
79
80async function mintConnectPat(opts: {
81 userId: string;
82 ip?: string;
83 userAgent?: string;
84 source: "ui" | "dxt";
85}): Promise<{ token: string; id: string; name: string }> {
86 const stamp = Math.floor(Date.now() / 1000)
87 .toString(36)
88 .slice(-6);
89 const name =
90 opts.source === "dxt"
91 ? `gluecron-claude-dxt-${stamp}`
92 : `gluecron-claude-ui-${stamp}`;
93 const token = generateInstallToken();
94 const tokenHash = await hashInstallToken(token);
95 const tokenPrefix = token.slice(0, 12);
96
97 const [row] = await db
98 .insert(apiTokens)
99 .values({
100 userId: opts.userId,
101 name,
102 tokenHash,
103 tokenPrefix,
104 // Same defaults the install-token endpoint uses for `scope=admin`.
105 scopes: "admin,repo,user",
106 })
107 .returning();
108
109 await audit({
110 userId: opts.userId,
111 action:
112 opts.source === "dxt"
113 ? "mcp.dxt.minted"
114 : "auth.install_token.created",
115 targetType: "api_token",
116 targetId: row?.id,
117 ip: opts.ip,
118 userAgent: opts.userAgent,
119 metadata: { name, scope: "admin", prefix: tokenPrefix, source: opts.source },
120 });
121
122 return { token, id: row!.id, name };
123}
124
125// ─── Minimal in-process .zip writer ────────────────────────────────────────
126// `.dxt` is a plain zip of (manifest.json, icon.png, …). We rebuild the
127// archive on-the-fly with the user's PAT baked into manifest.json. Using
128// `node:zlib.deflateRawSync` keeps the bundle small without a userland
129// dependency. Format: PKZIP 2.0 (no zip64, no encryption).
130
131function crc32(buf: Uint8Array): number {
132 let c = 0xffffffff;
133 for (let i = 0; i < buf.length; i++) {
134 c ^= buf[i]!;
135 for (let k = 0; k < 8; k++) {
136 c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
137 }
138 }
139 return (c ^ 0xffffffff) >>> 0;
140}
141
142type ZipEntry = { name: string; data: Uint8Array };
143
144function buildZip(entries: ZipEntry[]): Uint8Array {
145 const localParts: Uint8Array[] = [];
146 const centralParts: Uint8Array[] = [];
147 let offset = 0;
148
149 for (const entry of entries) {
150 const nameBytes = new TextEncoder().encode(entry.name);
151 const crc = crc32(entry.data);
152 const uncompressedSize = entry.data.length;
153
154 // Try deflate; fall back to STORED if it'd inflate the payload.
155 let method = 8;
156 let compressed: Uint8Array;
157 try {
158 const out = deflateRawSync(entry.data);
159 compressed = new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
160 if (compressed.length >= uncompressedSize) {
161 method = 0;
162 compressed = entry.data;
163 }
164 } catch {
165 method = 0;
166 compressed = entry.data;
167 }
168 const compressedSize = compressed.length;
169
170 // Local file header.
171 const local = new Uint8Array(30 + nameBytes.length + compressedSize);
172 const lv = new DataView(local.buffer);
173 lv.setUint32(0, 0x04034b50, true); // local file sig
174 lv.setUint16(4, 20, true); // version
175 lv.setUint16(6, 0, true); // flags
176 lv.setUint16(8, method, true);
177 lv.setUint16(10, 0, true); // mod time
178 lv.setUint16(12, 0, true); // mod date
179 lv.setUint32(14, crc, true);
180 lv.setUint32(18, compressedSize, true);
181 lv.setUint32(22, uncompressedSize, true);
182 lv.setUint16(26, nameBytes.length, true);
183 lv.setUint16(28, 0, true); // extra
184 local.set(nameBytes, 30);
185 local.set(compressed, 30 + nameBytes.length);
186 localParts.push(local);
187
188 // Central directory record.
189 const central = new Uint8Array(46 + nameBytes.length);
190 const cv = new DataView(central.buffer);
191 cv.setUint32(0, 0x02014b50, true);
192 cv.setUint16(4, 20, true); // version made by
193 cv.setUint16(6, 20, true); // version needed
194 cv.setUint16(8, 0, true); // flags
195 cv.setUint16(10, method, true);
196 cv.setUint16(12, 0, true); // mod time
197 cv.setUint16(14, 0, true); // mod date
198 cv.setUint32(16, crc, true);
199 cv.setUint32(20, compressedSize, true);
200 cv.setUint32(24, uncompressedSize, true);
201 cv.setUint16(28, nameBytes.length, true);
202 cv.setUint16(30, 0, true); // extra
203 cv.setUint16(32, 0, true); // comment
204 cv.setUint16(34, 0, true); // disk
205 cv.setUint16(36, 0, true); // internal attrs
206 cv.setUint32(38, 0, true); // external attrs
207 cv.setUint32(42, offset, true); // local header offset
208 central.set(nameBytes, 46);
209 centralParts.push(central);
210
211 offset += local.length;
212 }
213
214 const centralSize = centralParts.reduce((n, p) => n + p.length, 0);
215 const centralOffset = offset;
216
217 // End of central directory record.
218 const end = new Uint8Array(22);
219 const ev = new DataView(end.buffer);
220 ev.setUint32(0, 0x06054b50, true);
221 ev.setUint16(4, 0, true);
222 ev.setUint16(6, 0, true);
223 ev.setUint16(8, entries.length, true);
224 ev.setUint16(10, entries.length, true);
225 ev.setUint32(12, centralSize, true);
226 ev.setUint32(16, centralOffset, true);
227 ev.setUint16(20, 0, true);
228
229 const total =
230 localParts.reduce((n, p) => n + p.length, 0) + centralSize + end.length;
231 const out = new Uint8Array(total);
232 let pos = 0;
233 for (const p of localParts) {
234 out.set(p, pos);
235 pos += p.length;
236 }
237 for (const p of centralParts) {
238 out.set(p, pos);
239 pos += p.length;
240 }
241 out.set(end, pos);
242 return out;
243}
244
245// ─── Personalized .dxt manifest ───────────────────────────────────────────
246// We embed the freshly-minted PAT directly in the manifest. The user only has
247// to double-click the file and Claude Desktop picks up everything else.
248
249function buildPersonalizedManifest(opts: {
250 username: string;
251 token: string;
252 host: string;
253}): string {
254 const tools = Object.values(defaultTools()).map((h) => ({
255 name: h.tool.name,
256 description: h.tool.description.split(".")[0] + ".",
257 }));
258 const manifest = {
259 dxt_version: "0.1",
260 name: "gluecron",
261 display_name: "Gluecron",
262 version: "1.0.0",
263 description:
264 "AI-native git hosting. Claude can open PRs, review code, merge, manage issues, and ship — all on Gluecron.",
265 long_description: `Personalized for ${opts.username}. Drop into Claude Desktop and you are connected — no further configuration required.`,
266 author: { name: "Gluecron", url: "https://gluecron.com" },
267 homepage: opts.host,
268 documentation: `${opts.host}/help#mcp`,
269 support: `${opts.host}/help`,
270 server: {
271 type: "http",
272 endpoint: `${opts.host}/mcp`,
273 headers: {
274 Authorization: `Bearer ${opts.token}`,
275 },
276 },
277 user_config: {
278 gluecron_host: {
279 type: "string",
280 title: "Gluecron host",
281 description: "URL of your Gluecron instance.",
282 default: opts.host,
283 required: false,
284 },
285 },
286 tools,
287 compatibility: {
288 claude_desktop: ">=0.10.0",
289 platforms: ["darwin", "win32", "linux"],
290 },
291 personalized_for: opts.username,
292 generated_at: new Date().toISOString(),
293 };
294 return JSON.stringify(manifest, null, 2);
295}
296
297// ─── CSS ───────────────────────────────────────────────────────────────────
298const styles = `
eed4684Claude299 .connect-claude-container { max-width: 1320px; margin: 0 auto; padding: 0 0 var(--space-6); }
662ce86Claude300
301 /* ─── Hero ─── */
302 .connect-claude-hero {
303 position: relative;
304 margin-bottom: var(--space-6);
305 padding: var(--space-5) var(--space-6);
306 background: var(--bg-elevated);
307 border: 1px solid var(--border);
308 border-radius: 18px;
309 overflow: hidden;
310 }
311 .connect-claude-hero::before {
312 content: '';
313 position: absolute;
314 top: 0; left: 0; right: 0;
315 height: 2px;
6fd5915Claude316 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
662ce86Claude317 opacity: 0.75;
318 pointer-events: none;
319 }
320 .connect-claude-hero-orb {
321 position: absolute;
322 inset: -30% -10% auto auto;
323 width: 460px; height: 460px;
6fd5915Claude324 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
662ce86Claude325 filter: blur(80px);
326 opacity: 0.65;
327 pointer-events: none;
328 animation: connectClaudeOrb 16s ease-in-out infinite;
329 }
330 @keyframes connectClaudeOrb {
331 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
332 50% { transform: scale(1.08) translate(-12px, 8px); opacity: 0.85; }
333 }
334 @media (prefers-reduced-motion: reduce) {
335 .connect-claude-hero-orb { animation: none; }
336 }
337 .connect-claude-hero-inner { position: relative; z-index: 1; max-width: 680px; }
338 .connect-claude-eyebrow {
339 font-size: 13px;
340 color: var(--text-muted);
341 margin-bottom: var(--space-2);
342 }
343 .connect-claude-eyebrow strong {
344 color: var(--accent);
345 font-weight: 600;
346 }
347 .connect-claude-title {
348 font-size: clamp(32px, 5vw, 48px);
349 font-family: var(--font-display);
350 font-weight: 800;
351 letter-spacing: -0.03em;
352 line-height: 1.04;
353 margin: 0 0 var(--space-3);
354 color: var(--text-strong);
355 }
356 .connect-claude-title .gradient {
6fd5915Claude357 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
662ce86Claude358 -webkit-background-clip: text;
359 background-clip: text;
360 -webkit-text-fill-color: transparent;
361 color: transparent;
362 }
363 .connect-claude-sub {
364 font-size: 16px;
365 color: var(--text-muted);
366 margin: 0;
367 line-height: 1.55;
368 }
369
370 /* ─── Section ─── */
371 .connect-claude-section {
372 position: relative;
373 margin-bottom: var(--space-5);
374 background: var(--bg-elevated);
375 border: 1px solid var(--border);
376 border-radius: 14px;
377 overflow: hidden;
378 }
379 .connect-claude-section-head {
380 padding: var(--space-4) var(--space-5) var(--space-2);
381 }
382 .connect-claude-step-row {
383 display: flex;
384 align-items: center;
385 gap: 12px;
386 margin-bottom: 6px;
387 }
388 .connect-claude-step-num {
389 display: inline-flex;
390 align-items: center;
391 justify-content: center;
392 width: 26px; height: 26px;
393 border-radius: 50%;
6fd5915Claude394 background: linear-gradient(135deg, rgba(91,110,232,0.20), rgba(95,143,160,0.14));
662ce86Claude395 color: #c5b3ff;
6fd5915Claude396 border: 1px solid rgba(91,110,232,0.40);
662ce86Claude397 font-family: var(--font-display);
398 font-weight: 700;
399 font-size: 13px;
400 }
401 .connect-claude-section-title {
402 font-family: var(--font-display);
403 font-size: 18px;
404 font-weight: 700;
405 letter-spacing: -0.012em;
406 color: var(--text-strong);
407 margin: 0;
408 }
409 .connect-claude-section-desc {
410 margin: 0;
411 color: var(--text-muted);
412 font-size: 14px;
413 line-height: 1.5;
414 }
415 .connect-claude-section-body {
416 padding: var(--space-2) var(--space-5) var(--space-5);
417 }
418
419 /* ─── Token block ─── */
420 .connect-claude-token-row {
421 display: flex;
422 gap: 10px;
423 align-items: stretch;
424 flex-wrap: wrap;
425 margin-top: var(--space-3);
426 }
427 .connect-claude-btn {
428 appearance: none;
429 border: 1px solid var(--border-strong);
430 background: var(--bg-secondary);
431 color: var(--text);
432 padding: 10px 16px;
433 border-radius: 10px;
434 font-family: inherit;
435 font-size: 14px;
436 font-weight: 500;
437 cursor: pointer;
438 transition: border-color 150ms ease, background 150ms ease, transform 150ms ease;
439 text-decoration: none;
440 display: inline-flex; align-items: center; gap: 8px;
441 }
442 .connect-claude-btn:hover {
443 border-color: var(--border-focus);
444 background: rgba(255,255,255,0.03);
445 transform: translateY(-1px);
446 }
447 .connect-claude-btn-primary {
6fd5915Claude448 border-color: rgba(91,110,232,0.45);
449 background: linear-gradient(135deg, rgba(91,110,232,0.18), rgba(95,143,160,0.12));
662ce86Claude450 color: var(--text-strong);
451 }
452 .connect-claude-btn-primary:hover {
6fd5915Claude453 border-color: rgba(91,110,232,0.65);
454 background: linear-gradient(135deg, rgba(91,110,232,0.26), rgba(95,143,160,0.18));
662ce86Claude455 }
456 .connect-claude-token-display {
457 flex: 1;
458 min-width: 220px;
459 display: flex;
460 align-items: center;
461 gap: 10px;
462 padding: 10px 12px;
463 background: var(--bg-secondary);
464 border: 1px solid var(--border-subtle);
465 border-radius: 10px;
466 font-family: var(--font-mono);
467 font-size: 12.5px;
468 color: var(--text-strong);
469 word-break: break-all;
470 }
471 .connect-claude-token-display .dot {
472 width: 8px; height: 8px;
473 border-radius: 50%;
474 background: #3fb950;
475 box-shadow: 0 0 8px rgba(63,185,80,0.6);
476 flex-shrink: 0;
477 }
478 .connect-claude-token-empty {
479 color: var(--text-faint);
480 font-style: italic;
481 }
482 .connect-claude-token-note {
483 margin-top: 10px;
484 font-size: 12.5px;
485 color: var(--text-muted);
486 }
487
488 /* ─── Three option cards (Step 2) ─── */
489 .connect-claude-options {
490 display: grid;
491 grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
492 gap: var(--space-3);
493 }
494 .connect-claude-option {
495 position: relative;
496 padding: var(--space-4);
497 background: var(--bg-secondary);
498 border: 1px solid var(--border-subtle);
499 border-radius: 12px;
500 display: flex;
501 flex-direction: column;
502 gap: 10px;
503 transition: border-color 150ms ease, transform 150ms ease;
504 }
505 .connect-claude-option:hover {
506 border-color: var(--border-strong);
507 transform: translateY(-1px);
508 }
509 .connect-claude-option.is-recommended {
6fd5915Claude510 border-color: rgba(91,110,232,0.45);
511 background: linear-gradient(180deg, rgba(91,110,232,0.06), var(--bg-secondary) 60%);
662ce86Claude512 }
513 .connect-claude-option-badge {
514 position: absolute;
515 top: -10px; right: 12px;
516 padding: 2px 8px;
6fd5915Claude517 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
662ce86Claude518 color: #fff;
519 border-radius: 999px;
520 font-size: 11px;
521 font-weight: 600;
522 letter-spacing: 0.02em;
523 text-transform: uppercase;
524 }
525 .connect-claude-option-title {
526 font-family: var(--font-display);
527 font-weight: 700;
528 font-size: 15px;
529 color: var(--text-strong);
530 margin: 0;
531 }
532 .connect-claude-option-desc {
533 font-size: 13px;
534 color: var(--text-muted);
535 margin: 0;
536 line-height: 1.5;
537 }
538 .connect-claude-code {
539 position: relative;
540 background: var(--bg-tertiary, #0d1018);
541 border: 1px solid var(--border-subtle);
542 border-radius: 8px;
543 padding: 10px 12px;
544 font-family: var(--font-mono);
545 font-size: 12px;
546 color: var(--text-strong);
547 overflow-x: auto;
548 white-space: pre;
549 line-height: 1.5;
550 }
551 .connect-claude-copy {
552 appearance: none;
553 border: 1px solid var(--border-subtle);
554 background: var(--bg-elevated);
555 color: var(--text-muted);
556 padding: 6px 10px;
557 border-radius: 8px;
558 font-family: inherit;
559 font-size: 12px;
560 cursor: pointer;
561 align-self: flex-start;
562 }
563 .connect-claude-copy:hover {
564 color: var(--text-strong);
565 border-color: var(--border-strong);
566 }
567
568 /* ─── Tools grid (Step 3) ─── */
569 .connect-claude-tools-group {
570 margin-top: var(--space-3);
571 }
572 .connect-claude-tools-group-label {
573 font-size: 11px;
574 font-weight: 600;
575 letter-spacing: 0.08em;
576 text-transform: uppercase;
577 color: var(--text-faint);
578 margin-bottom: 8px;
579 }
580 .connect-claude-tools {
581 display: grid;
582 grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
583 gap: 10px;
584 }
585 .connect-claude-tool {
586 padding: 12px 14px;
587 background: var(--bg-secondary);
588 border: 1px solid var(--border-subtle);
589 border-radius: 10px;
590 display: flex;
591 flex-direction: column;
592 gap: 4px;
593 }
594 .connect-claude-tool.is-write {
595 border-color: rgba(248, 197, 81, 0.30);
596 background: linear-gradient(180deg, rgba(248,197,81,0.04), var(--bg-secondary) 60%);
597 }
598 .connect-claude-tool-name {
599 font-family: var(--font-mono);
600 font-size: 12.5px;
601 font-weight: 600;
602 color: var(--text-strong);
603 }
604 .connect-claude-tool-desc {
605 font-size: 12px;
606 color: var(--text-muted);
607 line-height: 1.45;
608 }
609
610 /* ─── Status panel (Step 4) ─── */
611 .connect-claude-status {
612 display: flex;
613 align-items: center;
614 gap: 14px;
615 padding: var(--space-3);
616 background: var(--bg-secondary);
617 border: 1px solid var(--border-subtle);
618 border-radius: 10px;
619 }
620 .connect-claude-status-dot {
621 width: 10px; height: 10px;
622 border-radius: 50%;
623 background: var(--text-faint);
624 flex-shrink: 0;
625 }
626 .connect-claude-status.is-live .connect-claude-status-dot {
627 background: #3fb950;
628 box-shadow: 0 0 8px rgba(63,185,80,0.6);
629 }
630 .connect-claude-status-main { flex: 1; }
631 .connect-claude-status-title {
632 font-weight: 600;
633 color: var(--text-strong);
634 font-size: 14px;
635 }
636 .connect-claude-status-sub {
637 font-size: 12.5px;
638 color: var(--text-muted);
639 margin-top: 2px;
640 }
641
642 @media (max-width: 600px) {
643 .connect-claude-options { grid-template-columns: 1fr; }
644 }
645`;
646
647// ─── Tools-by-mode partitioning ────────────────────────────────────────────
648// Anything whose name contains a verb like create/comment/close/reopen/merge
649// is a write tool. Everything else is read-only. Keeps the surface honest
650// without having to extend the McpTool shape.
651function isWriteTool(name: string): boolean {
652 return /(create|comment|close|reopen|merge)/.test(name);
653}
654
655// ─── Pre-rendered server data ──────────────────────────────────────────────
656function buildToolsView(): {
657 read: Array<{ name: string; description: string }>;
658 write: Array<{ name: string; description: string }>;
659} {
660 const handlers = defaultTools();
661 const read: Array<{ name: string; description: string }> = [];
662 const write: Array<{ name: string; description: string }> = [];
663 for (const h of Object.values(handlers)) {
664 const entry = { name: h.tool.name, description: h.tool.description };
665 if (isWriteTool(entry.name)) write.push(entry);
666 else read.push(entry);
667 }
668 return { read, write };
669}
670
671function clientScript() {
672 // Inline page state machine: token mint via fetch → reveal → snippets &
673 // download link refreshed in-place. No framework, just DOM.
674 return `
675 (function(){
676 const elGen = document.getElementById('cc-generate');
677 const elDisplay = document.getElementById('cc-token-display');
678 const elDxt = document.getElementById('cc-dxt-link');
679 const elCliCode = document.getElementById('cc-cli-code');
680 const elJsonCode = document.getElementById('cc-json-code');
681 const host = ${JSON.stringify(config.appBaseUrl || "https://gluecron.com")};
682
683 function applyToken(tok) {
684 if (elDisplay) {
685 elDisplay.innerHTML = '<span class="dot" aria-hidden="true"></span><span>'+tok+'</span>';
686 elDisplay.classList.remove('connect-claude-token-empty');
687 }
688 if (elCliCode) {
689 elCliCode.textContent = 'claude mcp add gluecron ' + host + '/mcp --header "Authorization: Bearer ' + tok + '"';
690 }
691 if (elJsonCode) {
692 const cfg = {
693 mcpServers: {
694 gluecron: {
695 transport: 'http',
696 url: host + '/mcp',
697 headers: { Authorization: 'Bearer ' + tok }
698 }
699 }
700 };
701 elJsonCode.textContent = JSON.stringify(cfg, null, 2);
702 }
703 }
704
705 if (elGen) {
706 elGen.addEventListener('click', async function() {
707 elGen.disabled = true;
708 elGen.textContent = 'Generating…';
709 try {
710 const res = await fetch('/connect/claude/token', {
711 method: 'POST',
712 credentials: 'same-origin',
713 headers: { 'content-type': 'application/json' },
714 body: '{}'
715 });
716 if (!res.ok) throw new Error('mint failed: ' + res.status);
717 const j = await res.json();
718 applyToken(j.token);
719 elGen.textContent = 'Token generated ✓';
720 } catch (e) {
721 elGen.disabled = false;
722 elGen.textContent = 'Try again';
723 console.error(e);
724 }
725 });
726 }
727
728 // Copy buttons
729 document.querySelectorAll('[data-cc-copy]').forEach(function(btn){
730 btn.addEventListener('click', function(){
731 const target = document.getElementById(btn.getAttribute('data-cc-copy'));
732 if (!target) return;
733 const text = target.textContent || '';
734 if (navigator.clipboard && navigator.clipboard.writeText) {
735 navigator.clipboard.writeText(text).then(function(){
736 const prev = btn.textContent;
737 btn.textContent = 'Copied ✓';
738 setTimeout(function(){ btn.textContent = prev; }, 1400);
739 });
740 }
741 });
742 });
743
744 // Optional live-status poll (every 30s). Best-effort — failures stay silent.
745 const statusEl = document.getElementById('cc-status');
746 if (statusEl) {
747 async function refresh() {
748 try {
749 const res = await fetch('/api/connect/status', { credentials: 'same-origin' });
750 if (!res.ok) return;
751 const j = await res.json();
752 if (!j) return;
753 const title = statusEl.querySelector('.connect-claude-status-title');
754 const sub = statusEl.querySelector('.connect-claude-status-sub');
755 if (j.totalCalls > 0) {
756 statusEl.classList.add('is-live');
757 if (title) title.textContent = 'Connected · ' + j.totalCalls + ' MCP call' + (j.totalCalls === 1 ? '' : 's');
758 if (sub && j.lastCalledAt) sub.textContent = 'Last call ' + new Date(j.lastCalledAt).toLocaleString();
759 }
760 } catch (_) { /* ignore */ }
761 }
762 refresh();
763 setInterval(refresh, 30000);
764 }
765 })();
766 `;
767}
768
769// ─── GET /connect/claude ───────────────────────────────────────────────────
770connectClaude.get("/connect/claude", async (c) => {
771 const user = c.get("user")!;
772 const tools = buildToolsView();
773 const host = config.appBaseUrl || "https://gluecron.com";
774
775 // Best-effort: look up the most recent MCP audit row for this user.
776 let lastCalledAt: string | null = null;
777 let totalCalls = 0;
778 try {
779 const rows = await db
780 .select({ createdAt: auditLog.createdAt })
781 .from(auditLog)
782 .where(
783 and(eq(auditLog.userId, user.id), eq(auditLog.action, "mcp.tool.called"))
784 )
785 .orderBy(desc(auditLog.createdAt))
786 .limit(1);
787 if (rows.length > 0 && rows[0]) {
788 lastCalledAt = rows[0].createdAt.toISOString();
789 }
790 // Cheap count: pull up to 500 rows and use the length. Good enough for
791 // a status badge; we don't need exact for-large-N numbers here.
792 const counted = await db
793 .select({ id: auditLog.id })
794 .from(auditLog)
795 .where(
796 and(eq(auditLog.userId, user.id), eq(auditLog.action, "mcp.tool.called"))
797 )
798 .limit(500);
799 totalCalls = counted.length;
800 } catch {
801 // audit_log may not exist in some test envs — render the empty state.
802 }
803
804 const placeholderCli = `claude mcp add gluecron ${host}/mcp --header "Authorization: Bearer <YOUR TOKEN>"`;
805 const placeholderJson = JSON.stringify(
806 {
807 mcpServers: {
808 gluecron: {
809 transport: "http",
810 url: `${host}/mcp`,
811 headers: { Authorization: "Bearer <YOUR TOKEN>" },
812 },
813 },
814 },
815 null,
816 2
817 );
818
819 return c.html(
820 <Layout title="Connect Claude" user={user}>
821 <style dangerouslySetInnerHTML={{ __html: styles }} />
822 <div class="connect-claude-container">
823 {/* ─── Hero ─── */}
824 <section class="connect-claude-hero">
825 <div class="connect-claude-hero-orb" aria-hidden="true" />
826 <div class="connect-claude-hero-inner">
827 <div class="connect-claude-eyebrow">
828 Connect Claude · <strong>@{user.username}</strong>
829 </div>
830 <h1 class="connect-claude-title">
831 Drive gluecron from <span class="gradient">Claude</span>.
832 </h1>
833 <p class="connect-claude-sub">
834 One-click setup. Your Claude Desktop, Claude Code, or any
835 MCP-aware client gets full access to your repos — search code,
836 read files, open issues, ship pull requests.
837 </p>
838 </div>
839 </section>
840
841 {/* ─── Step 1: Generate token ─── */}
842 <section class="connect-claude-section">
843 <div class="connect-claude-section-head">
844 <div class="connect-claude-step-row">
845 <span class="connect-claude-step-num">1</span>
846 <h2 class="connect-claude-section-title">
847 Generate your Claude token
848 </h2>
849 </div>
850 <p class="connect-claude-section-desc">
851 A fresh personal access token, scoped to <code>admin,repo,user</code>{" "}
852 — same shape as the existing install script mints. Shown once;
853 we don't store the plaintext.
854 </p>
855 </div>
856 <div class="connect-claude-section-body">
857 <div class="connect-claude-token-row">
858 <button
859 id="cc-generate"
860 type="button"
861 class="connect-claude-btn connect-claude-btn-primary"
862 >
863 Generate token
864 </button>
865 <div
866 id="cc-token-display"
867 class="connect-claude-token-display connect-claude-token-empty"
868 >
869 <span>No token yet — click Generate.</span>
870 </div>
871 </div>
872 <p class="connect-claude-token-note">
873 Already have a PAT? Skip to Step 2 — every install path accepts
874 tokens minted at <a href="/settings/tokens">/settings/tokens</a>.
875 </p>
876 </div>
877 </section>
878
879 {/* ─── Step 2: Install path ─── */}
880 <section class="connect-claude-section">
881 <div class="connect-claude-section-head">
882 <div class="connect-claude-step-row">
883 <span class="connect-claude-step-num">2</span>
884 <h2 class="connect-claude-section-title">Pick your install path</h2>
885 </div>
886 <p class="connect-claude-section-desc">
887 Three ways to wire Claude up. Generate the token in Step 1 first
888 so the snippets below auto-fill.
889 </p>
890 </div>
891 <div class="connect-claude-section-body">
892 <div class="connect-claude-options">
893 {/* Desktop */}
894 <div class="connect-claude-option is-recommended">
895 <span class="connect-claude-option-badge">Recommended</span>
896 <h3 class="connect-claude-option-title">Claude Desktop</h3>
897 <p class="connect-claude-option-desc">
898 Download a personalized <code>.dxt</code> bundle with your
899 token already embedded. Double-click to install in Claude
900 Desktop.
901 </p>
902 <a
903 id="cc-dxt-link"
904 href="/connect/claude/dxt"
905 class="connect-claude-btn connect-claude-btn-primary"
906 download={`gluecron-${user.username}.dxt`}
907 >
908 Download personalized .dxt
909 </a>
910 </div>
911
912 {/* CLI */}
913 <div class="connect-claude-option">
914 <h3 class="connect-claude-option-title">Claude Code (CLI)</h3>
915 <p class="connect-claude-option-desc">
916 Wire Claude Code up over MCP with a single command:
917 </p>
918 <pre id="cc-cli-code" class="connect-claude-code">{placeholderCli}</pre>
919 <button
920 type="button"
921 class="connect-claude-copy"
922 data-cc-copy="cc-cli-code"
923 >
924 Copy command
925 </button>
926 </div>
927
928 {/* Manual */}
929 <div class="connect-claude-option">
930 <h3 class="connect-claude-option-title">Manual config</h3>
931 <p class="connect-claude-option-desc">
932 Paste this into your <code>claude_desktop_config.json</code>:
933 </p>
934 <pre id="cc-json-code" class="connect-claude-code">{placeholderJson}</pre>
935 <button
936 type="button"
937 class="connect-claude-copy"
938 data-cc-copy="cc-json-code"
939 >
940 Copy JSON
941 </button>
942 </div>
943 </div>
944 </div>
945 </section>
946
947 {/* ─── Step 3: Tools grid ─── */}
948 <section class="connect-claude-section">
949 <div class="connect-claude-section-head">
950 <div class="connect-claude-step-row">
951 <span class="connect-claude-step-num">3</span>
952 <h2 class="connect-claude-section-title">
953 Tools your Claude will have
954 </h2>
955 </div>
956 <p class="connect-claude-section-desc">
957 {tools.read.length + tools.write.length} MCP tools, live from the
958 server. Read-only first; write tools require <code>admin</code> scope.
959 </p>
960 </div>
961 <div class="connect-claude-section-body">
962 <div class="connect-claude-tools-group">
963 <div class="connect-claude-tools-group-label">
964 Read · {tools.read.length}
965 </div>
966 <div class="connect-claude-tools">
967 {tools.read.map((t) => (
968 <div class="connect-claude-tool">
969 <span class="connect-claude-tool-name">{t.name}</span>
970 <span class="connect-claude-tool-desc">{t.description}</span>
971 </div>
972 ))}
973 </div>
974 </div>
975 <div class="connect-claude-tools-group">
976 <div class="connect-claude-tools-group-label">
977 Write · {tools.write.length}
978 </div>
979 <div class="connect-claude-tools">
980 {tools.write.map((t) => (
981 <div class="connect-claude-tool is-write">
982 <span class="connect-claude-tool-name">{t.name}</span>
983 <span class="connect-claude-tool-desc">{t.description}</span>
984 </div>
985 ))}
986 </div>
987 </div>
988 </div>
989 </section>
990
ebbb527Claude991 {/* ─── Hosted Claude loops CTA ─── */}
992 <section class="connect-claude-section">
993 <div class="connect-claude-section-head">
994 <div class="connect-claude-step-row">
995 <span class="connect-claude-step-num">⚡</span>
996 <h2 class="connect-claude-section-title">
997 Host a Claude tool-use loop
998 </h2>
999 </div>
1000 <p class="connect-claude-section-desc">
1001 Paste a Claude loop, get a hosted endpoint with a built-in budget
1002 meter. Your code runs in a 30s sandboxed Bun subprocess —
1003 no infra to wire up.
1004 </p>
1005 </div>
1006 <div class="connect-claude-section-body">
1007 <a
1008 href="/connect/claude/deploy"
1009 class="connect-claude-btn connect-claude-btn-primary"
1010 >
1011 Open Claude loops deploy wizard →
1012 </a>
1013 </div>
1014 </section>
1015
662ce86Claude1016 {/* ─── Step 4: Status ─── */}
1017 <section class="connect-claude-section">
1018 <div class="connect-claude-section-head">
1019 <div class="connect-claude-step-row">
1020 <span class="connect-claude-step-num">4</span>
1021 <h2 class="connect-claude-section-title">Connection status</h2>
1022 </div>
1023 <p class="connect-claude-section-desc">
1024 We watch the MCP audit log for tool calls signed by your tokens.
1025 First call shows up within seconds.
1026 </p>
1027 </div>
1028 <div class="connect-claude-section-body">
1029 <div
1030 id="cc-status"
1031 class={`connect-claude-status${
1032 totalCalls > 0 ? " is-live" : ""
1033 }`}
1034 >
1035 <span class="connect-claude-status-dot" aria-hidden="true" />
1036 <div class="connect-claude-status-main">
1037 <div class="connect-claude-status-title">
1038 {totalCalls > 0
1039 ? `Connected · ${totalCalls} MCP call${totalCalls === 1 ? "" : "s"}`
1040 : "Not yet connected"}
1041 </div>
1042 <div class="connect-claude-status-sub">
1043 {lastCalledAt
1044 ? `Last call ${new Date(lastCalledAt).toLocaleString()}`
1045 : "Generate a token, install Claude, and your first call will appear here."}
1046 </div>
1047 </div>
1048 </div>
1049 </div>
1050 </section>
1051 </div>
1052 <script dangerouslySetInnerHTML={{ __html: clientScript() }} />
1053 </Layout>
1054 );
1055});
1056
1057// ─── Alias: /settings/claude → /connect/claude ─────────────────────────────
1058connectClaude.get("/settings/claude", (c) => c.redirect("/connect/claude"));
1059
1060// ─── POST /connect/claude/token — session-cookie mint via fetch ───────────
1061connectClaude.post("/connect/claude/token", async (c) => {
1062 const user = c.get("user")!;
1063 const { token, id, name } = await mintConnectPat({
1064 userId: user.id,
1065 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || undefined,
1066 userAgent: c.req.header("user-agent") || undefined,
1067 source: "ui",
1068 });
1069 return c.json({ token, id, name, scope: "admin" }, 201);
1070});
1071
1072// ─── GET /connect/claude/dxt — personalized .dxt download ─────────────────
1073connectClaude.get("/connect/claude/dxt", async (c) => {
1074 const user = c.get("user")!;
1075 const { token } = await mintConnectPat({
1076 userId: user.id,
1077 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || undefined,
1078 userAgent: c.req.header("user-agent") || undefined,
1079 source: "dxt",
1080 });
1081
1082 const host = config.appBaseUrl || "https://gluecron.com";
1083 const manifestJson = buildPersonalizedManifest({
1084 username: user.username,
1085 token,
1086 host,
1087 });
1088
1089 const entries: ZipEntry[] = [
1090 { name: "manifest.json", data: new TextEncoder().encode(manifestJson) },
1091 {
1092 name: "README.md",
1093 data: new TextEncoder().encode(
1094 `# Gluecron · personalized for ${user.username}\n\n` +
1095 `This .dxt was minted on ${new Date().toISOString()} and includes a\n` +
1096 `personal access token. Treat it like a password — anyone with this\n` +
1097 `file can act as you on ${host}.\n\n` +
1098 `Revoke at: ${host}/settings/tokens\n`
1099 ),
1100 },
1101 ];
1102
1103 const bundle = buildZip(entries);
1104
1105 return new Response(bundle as unknown as BodyInit, {
1106 headers: {
1107 "Content-Type": "application/octet-stream",
1108 "Content-Disposition": `attachment; filename="gluecron-${user.username}.dxt"`,
1109 "Content-Length": String(bundle.length),
1110 // Personalized — never cache.
1111 "Cache-Control": "private, no-store",
1112 },
1113 });
1114});
1115
1116// ─── GET /api/connect/status — JSON poll for the status panel ─────────────
1117connectClaude.get("/api/connect/status", async (c) => {
1118 const user = c.get("user")!;
1119 try {
1120 const rows = await db
1121 .select({ createdAt: auditLog.createdAt, id: auditLog.id })
1122 .from(auditLog)
1123 .where(
1124 and(eq(auditLog.userId, user.id), eq(auditLog.action, "mcp.tool.called"))
1125 )
1126 .orderBy(desc(auditLog.createdAt))
1127 .limit(500);
1128 const totalCalls = rows.length;
1129 const lastCalledAt =
1130 totalCalls > 0 && rows[0]
1131 ? rows[0].createdAt.toISOString()
1132 : null;
1133 return c.json({ totalCalls, lastCalledAt });
1134 } catch {
1135 return c.json({ totalCalls: 0, lastCalledAt: null });
1136 }
1137});
1138
1139export default connectClaude;