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

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

claude-deploy.tsxBlame1346 lines · 1 contributor
ebbb527Claude1/**
2 * Claude-deploy wizard — paste a Claude tool-use loop, get a hosted endpoint.
3 *
4 * Routes
5 * GET /connect/claude/deploy wizard + list of user's loops
6 * GET /connect/claude/deploy/:id loop detail (history, edit, danger)
7 * POST /api/v2/claude-loops create
8 * POST /api/v2/claude-loops/:id/invoke owner-side synchronous invoke
9 * POST /api/v2/claude-loops/:id/pause pause
10 * POST /api/v2/claude-loops/:id/resume resume
11 * DELETE /api/v2/claude-loops/:id delete
12 * GET /api/v2/claude-loops/:id/runs run history
13 * POST /loops/:slug/invoke public sync invoke (when is_public)
14 *
15 * Hard rules
16 * - Do not modify shared layout/components/UI — CSS scoped to `.cldploy-*`.
17 * - Sandbox subprocess is wired in src/lib/hosted-claude-loop.ts; this file
18 * just renders the wizard and exposes the surface.
19 */
20
21import { Hono } from "hono";
22import { db } from "../db";
23import { hostedClaudeLoops } from "../db/schema";
24import { eq } from "drizzle-orm";
25import { Layout } from "../views/layout";
26import { config } from "../lib/config";
27import { requireAuth } from "../middleware/auth";
28import type { AuthEnv } from "../middleware/auth";
29import {
30 DEFAULT_LOOP_TEMPLATE,
31 createLoop,
32 deleteLoop,
33 getLoop,
34 getLoopByEndpointPath,
35 invokeLoop,
36 listLoopsForOwner,
37 listRunsForLoop,
38 pauseLoop,
39 resumeLoop,
40 updateLoop,
41} from "../lib/hosted-claude-loop";
42import { formatCents } from "../lib/ai-cost-tracker";
43
44const claudeDeploy = new Hono<AuthEnv>();
45
46// Wizard + detail pages require a logged-in user. Public /loops/:slug/invoke
47// is intentionally NOT gated — anyone can hit it (the loop's `is_public`
48// flag governs whether it actually runs).
49claudeDeploy.use("/connect/claude/deploy", requireAuth);
50claudeDeploy.use("/connect/claude/deploy/*", requireAuth);
51
52// API endpoints: every /api/v2/claude-loops/* POST/DELETE goes through
53// requireAuth so we get session cookies OR Bearer PATs.
54claudeDeploy.use("/api/v2/claude-loops", requireAuth);
55claudeDeploy.use("/api/v2/claude-loops/*", requireAuth);
56
57// ---------------------------------------------------------------------------
58// Tiny HTML escape — keeps `&` / `<` / `>` / `"` safe inside <pre> blocks.
59// ---------------------------------------------------------------------------
60function escapeHtml(s: string): string {
61 return (s || "")
62 .replace(/&/g, "&amp;")
63 .replace(/</g, "&lt;")
64 .replace(/>/g, "&gt;")
65 .replace(/"/g, "&quot;");
66}
67
68// ---------------------------------------------------------------------------
69// Budget plans surfaced in the wizard step 2.
70// ---------------------------------------------------------------------------
71const BUDGET_PLANS: Array<{
72 cents: number;
73 label: string;
74 blurb: string;
75}> = [
76 { cents: 500, label: "$5 / month", blurb: "Trial — ~5k tokens/day" },
77 { cents: 2500, label: "$25 / month", blurb: "Hobby — ~25k tokens/day" },
78 { cents: 10000, label: "$100 / month", blurb: "Production — high volume" },
79];
80
81// ---------------------------------------------------------------------------
82// CSS — scoped under .cldploy- prefix.
83// ---------------------------------------------------------------------------
84const styles = `
85 .cldploy-container { max-width: 1100px; margin: 0 auto; padding: 0 0 var(--space-6); }
86
87 /* ─── Hero ─── */
88 .cldploy-hero {
89 position: relative;
90 margin-bottom: var(--space-6);
91 padding: var(--space-5) var(--space-6);
92 background: var(--bg-elevated);
93 border: 1px solid var(--border);
94 border-radius: 18px;
95 overflow: hidden;
96 }
97 .cldploy-hero::before {
98 content: '';
99 position: absolute;
100 top: 0; left: 0; right: 0;
101 height: 2px;
102 background: linear-gradient(90deg, transparent 0%, #f78c4d 30%, #8c6dff 70%, transparent 100%);
103 opacity: 0.75;
104 pointer-events: none;
105 }
106 .cldploy-hero-orb {
107 position: absolute;
108 inset: -30% -10% auto auto;
109 width: 460px; height: 460px;
110 background: radial-gradient(circle, rgba(247,140,77,0.20), rgba(140,109,255,0.12) 45%, transparent 70%);
111 filter: blur(80px);
112 opacity: 0.65;
113 pointer-events: none;
114 animation: cldployOrb 16s ease-in-out infinite;
115 }
116 @keyframes cldployOrb {
117 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
118 50% { transform: scale(1.08) translate(-12px, 8px); opacity: 0.85; }
119 }
120 @media (prefers-reduced-motion: reduce) {
121 .cldploy-hero-orb { animation: none; }
122 }
123 .cldploy-hero-inner { position: relative; z-index: 1; max-width: 720px; }
124 .cldploy-eyebrow {
125 font-size: 13px;
126 color: var(--text-muted);
127 margin-bottom: var(--space-2);
128 letter-spacing: 0.02em;
129 }
130 .cldploy-eyebrow strong { color: #f78c4d; font-weight: 700; }
131 .cldploy-title {
132 font-size: clamp(28px, 4.8vw, 44px);
133 font-family: var(--font-display);
134 font-weight: 800;
135 letter-spacing: -0.03em;
136 line-height: 1.04;
137 margin: 0 0 var(--space-3);
138 color: var(--text-strong);
139 }
140 .cldploy-title .gradient {
141 background-image: linear-gradient(135deg, #f78c4d 0%, #8c6dff 60%, #36c5d6 100%);
142 -webkit-background-clip: text;
143 background-clip: text;
144 -webkit-text-fill-color: transparent;
145 color: transparent;
146 }
147 .cldploy-sub {
148 font-size: 16px;
149 color: var(--text-muted);
150 margin: 0;
151 line-height: 1.55;
152 }
153
154 /* ─── Section card ─── */
155 .cldploy-section {
156 margin-bottom: var(--space-5);
157 background: var(--bg-elevated);
158 border: 1px solid var(--border);
159 border-radius: 14px;
160 overflow: hidden;
161 }
162 .cldploy-section-head {
163 padding: var(--space-4) var(--space-5) var(--space-2);
164 }
165 .cldploy-step-row {
166 display: flex; align-items: center; gap: 12px; margin-bottom: 6px;
167 }
168 .cldploy-step-num {
169 display: inline-flex; align-items: center; justify-content: center;
170 width: 26px; height: 26px;
171 border-radius: 50%;
172 background: linear-gradient(135deg, rgba(247,140,77,0.22), rgba(140,109,255,0.14));
173 color: #ffc09f;
174 border: 1px solid rgba(247,140,77,0.45);
175 font-family: var(--font-display);
176 font-weight: 700;
177 font-size: 13px;
178 }
179 .cldploy-section-title {
180 font-family: var(--font-display);
181 font-size: 18px;
182 font-weight: 700;
183 letter-spacing: -0.012em;
184 color: var(--text-strong);
185 margin: 0;
186 }
187 .cldploy-section-desc {
188 margin: 0;
189 color: var(--text-muted);
190 font-size: 14px;
191 line-height: 1.5;
192 }
193 .cldploy-section-body { padding: var(--space-2) var(--space-5) var(--space-5); }
194
195 /* ─── Code editor (Step 1) ─── */
196 .cldploy-editor-wrap {
197 position: relative;
198 margin-top: var(--space-3);
199 border: 1px solid var(--border-subtle);
200 border-radius: 10px;
201 overflow: hidden;
202 background: var(--bg-tertiary, #0b0e16);
203 }
204 .cldploy-editor-toolbar {
205 display: flex; align-items: center; justify-content: space-between;
206 padding: 8px 12px;
207 background: rgba(255,255,255,0.02);
208 border-bottom: 1px solid var(--border-subtle);
209 font-family: var(--font-mono);
210 font-size: 12px;
211 color: var(--text-muted);
212 }
213 .cldploy-editor-toolbar .dot { width:10px; height:10px; border-radius:50%; background:#f78c4d; box-shadow:0 0 6px rgba(247,140,77,0.6); display:inline-block; margin-right:6px; }
214 .cldploy-editor-body {
215 display: grid;
216 grid-template-columns: 56px 1fr;
217 font-family: var(--font-mono);
218 font-size: 13px;
219 line-height: 1.55;
220 }
221 .cldploy-editor-gutter {
222 user-select: none;
223 padding: 12px 8px 12px 12px;
224 text-align: right;
225 color: var(--text-faint);
226 background: rgba(255,255,255,0.015);
227 border-right: 1px solid var(--border-subtle);
228 white-space: pre;
229 overflow: hidden;
230 }
231 .cldploy-editor-textarea {
232 width: 100%;
233 min-height: 360px;
234 padding: 12px 14px;
235 color: var(--text-strong);
236 background: transparent;
237 border: 0;
238 outline: 0;
239 resize: vertical;
240 font-family: inherit;
241 font-size: inherit;
242 line-height: inherit;
243 tab-size: 2;
244 white-space: pre;
245 overflow-wrap: normal;
246 overflow-x: auto;
247 }
248
249 /* ─── Form bits ─── */
250 .cldploy-field { margin-top: var(--space-3); }
251 .cldploy-label {
252 display: block;
253 font-size: 13px;
254 color: var(--text-muted);
255 margin-bottom: 6px;
256 font-weight: 500;
257 }
258 .cldploy-input {
259 width: 100%;
260 padding: 10px 12px;
261 border: 1px solid var(--border-subtle);
262 background: var(--bg-secondary);
263 color: var(--text-strong);
264 border-radius: 10px;
265 font-family: inherit;
266 font-size: 14px;
267 }
268 .cldploy-input:focus { border-color: var(--border-focus); outline: none; }
269
270 .cldploy-plans {
271 display: grid;
272 grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
273 gap: 12px;
274 margin-top: 10px;
275 }
276 .cldploy-plan {
277 position: relative;
278 padding: 14px;
279 background: var(--bg-secondary);
280 border: 1px solid var(--border-subtle);
281 border-radius: 10px;
282 cursor: pointer;
283 transition: border-color 150ms ease, transform 150ms ease;
284 }
285 .cldploy-plan:hover { border-color: var(--border-strong); transform: translateY(-1px); }
286 .cldploy-plan input { position: absolute; opacity: 0; pointer-events: none; }
287 .cldploy-plan-label {
288 font-family: var(--font-display);
289 font-weight: 700;
290 font-size: 16px;
291 color: var(--text-strong);
292 }
293 .cldploy-plan-blurb {
294 font-size: 12.5px;
295 color: var(--text-muted);
296 margin-top: 4px;
297 }
298 .cldploy-plan input:checked ~ .cldploy-plan-label,
299 .cldploy-plan:has(input:checked) {
300 border-color: #f78c4d;
301 background: linear-gradient(180deg, rgba(247,140,77,0.10), var(--bg-secondary) 70%);
302 }
303
304 /* ─── Buttons ─── */
305 .cldploy-btn {
306 appearance: none;
307 border: 1px solid var(--border-strong);
308 background: var(--bg-secondary);
309 color: var(--text);
310 padding: 10px 18px;
311 border-radius: 10px;
312 font-family: inherit;
313 font-size: 14px;
314 font-weight: 500;
315 cursor: pointer;
316 transition: border-color 150ms ease, background 150ms ease, transform 150ms ease;
317 text-decoration: none;
318 display: inline-flex; align-items: center; gap: 8px;
319 }
320 .cldploy-btn:hover { border-color: var(--border-focus); transform: translateY(-1px); }
321 .cldploy-btn-primary {
322 border-color: rgba(247,140,77,0.55);
323 background: linear-gradient(135deg, rgba(247,140,77,0.22), rgba(140,109,255,0.14));
324 color: var(--text-strong);
325 font-weight: 600;
326 }
327 .cldploy-btn-primary:hover { border-color: rgba(247,140,77,0.75); }
328 .cldploy-btn-danger {
329 border-color: rgba(248,81,73,0.45);
330 color: #f8c5c5;
331 }
332 .cldploy-btn-danger:hover { border-color: #f85149; color: #fff; background: rgba(248,81,73,0.12); }
333
334 /* ─── Result panel (Step 3 / after create) ─── */
335 .cldploy-result {
336 margin-top: var(--space-3);
337 padding: 14px;
338 background: var(--bg-secondary);
339 border: 1px solid var(--border-subtle);
340 border-radius: 10px;
341 display: none;
342 }
343 .cldploy-result.is-shown { display: block; }
344 .cldploy-result-row { margin-bottom: 10px; }
345 .cldploy-result-label {
346 font-size: 11px;
347 font-weight: 600;
348 letter-spacing: 0.08em;
349 text-transform: uppercase;
350 color: var(--text-faint);
351 margin-bottom: 4px;
352 }
353 .cldploy-code {
354 background: var(--bg-tertiary, #0b0e16);
355 border: 1px solid var(--border-subtle);
356 border-radius: 8px;
357 padding: 10px 12px;
358 font-family: var(--font-mono);
359 font-size: 12.5px;
360 color: var(--text-strong);
361 overflow-x: auto;
362 white-space: pre-wrap;
363 word-break: break-all;
364 }
365
366 /* ─── Loops list ─── */
367 .cldploy-list { display: flex; flex-direction: column; gap: 10px; }
368 .cldploy-row {
369 display: grid;
370 grid-template-columns: 1.4fr 0.7fr 0.7fr 0.7fr auto;
371 align-items: center;
372 gap: 12px;
373 padding: 12px 14px;
374 background: var(--bg-secondary);
375 border: 1px solid var(--border-subtle);
376 border-radius: 10px;
377 transition: border-color 150ms ease;
378 }
379 .cldploy-row:hover { border-color: var(--border-strong); }
380 .cldploy-row-name {
381 font-family: var(--font-display);
382 font-weight: 600;
383 font-size: 15px;
384 color: var(--text-strong);
385 text-decoration: none;
386 }
387 .cldploy-row-name:hover { color: #f78c4d; }
388 .cldploy-row-path {
389 font-family: var(--font-mono);
390 font-size: 11.5px;
391 color: var(--text-muted);
392 margin-top: 2px;
393 }
394 .cldploy-pill {
395 display: inline-flex; align-items: center; gap: 6px;
396 padding: 2px 10px;
397 border-radius: 999px;
398 font-size: 11.5px;
399 font-weight: 600;
400 border: 1px solid transparent;
401 }
402 .cldploy-pill.is-running { background: rgba(63,185,80,0.10); color: #4cce6a; border-color: rgba(63,185,80,0.30); }
403 .cldploy-pill.is-paused { background: rgba(140,109,255,0.10); color: #b5a4ff; border-color: rgba(140,109,255,0.30); }
404 .cldploy-pill.is-errored { background: rgba(248,81,73,0.10); color: #ff7e76; border-color: rgba(248,81,73,0.30); }
405 .cldploy-pill-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
406
407 .cldploy-empty {
408 padding: var(--space-4);
409 text-align: center;
410 color: var(--text-muted);
411 font-size: 14px;
412 background: var(--bg-secondary);
413 border: 1px dashed var(--border-subtle);
414 border-radius: 10px;
415 }
416
417 /* ─── Detail page ─── */
418 .cldploy-meta {
419 display: grid;
420 grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
421 gap: 12px;
422 margin-top: var(--space-3);
423 }
424 .cldploy-meta-cell {
425 padding: 12px;
426 background: var(--bg-secondary);
427 border: 1px solid var(--border-subtle);
428 border-radius: 10px;
429 }
430 .cldploy-meta-label {
431 font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em;
432 color: var(--text-faint); margin-bottom: 4px;
433 }
434 .cldploy-meta-value {
435 font-family: var(--font-display); font-size: 18px; font-weight: 700;
436 color: var(--text-strong);
437 }
438
439 .cldploy-run-list { display: flex; flex-direction: column; gap: 8px; margin-top: var(--space-3); }
440 .cldploy-run {
441 display: grid;
442 grid-template-columns: 160px 120px 120px 1fr;
443 gap: 10px;
444 align-items: center;
445 padding: 10px 12px;
446 background: var(--bg-secondary);
447 border: 1px solid var(--border-subtle);
448 border-radius: 8px;
449 font-size: 13px;
450 }
451 .cldploy-run-status { font-weight: 600; }
452 .cldploy-run-status.ok { color: #4cce6a; }
453 .cldploy-run-status.error { color: #ff7e76; }
454 .cldploy-run-status.budget_exceeded { color: #ffc09f; }
455 .cldploy-run-status.timeout { color: #f8c5c5; }
456 .cldploy-run-time { color: var(--text-muted); font-size: 12px; font-family: var(--font-mono); }
457
458 @media (max-width: 720px) {
459 .cldploy-row { grid-template-columns: 1fr; }
460 .cldploy-run { grid-template-columns: 1fr; }
461 }
462`;
463
464// ---------------------------------------------------------------------------
465// Build a curl example showing how to invoke the loop.
466// ---------------------------------------------------------------------------
467function curlExample(host: string, endpointPath: string): string {
468 return [
469 `curl -X POST ${host}${endpointPath} \\`,
470 ` -H "Content-Type: application/json" \\`,
471 ` -d '{"repo":"ccantynz-alt/Gluecron.com"}'`,
472 ].join("\n");
473}
474
475// ---------------------------------------------------------------------------
476// GET /connect/claude/deploy — wizard + list
477// ---------------------------------------------------------------------------
478claudeDeploy.get("/connect/claude/deploy", async (c) => {
479 const user = c.get("user")!;
480 const loops = await listLoopsForOwner(user.id);
481 const host = config.appBaseUrl || "https://gluecron.com";
482
483 const templateLines = DEFAULT_LOOP_TEMPLATE.split("\n");
484 const gutter = templateLines.map((_, i) => i + 1).join("\n");
485
486 return c.html(
487 <Layout title="Deploy Claude loop" user={user}>
488 <style dangerouslySetInnerHTML={{ __html: styles }} />
489 <div class="cldploy-container">
490 {/* ─── Hero ─── */}
491 <section class="cldploy-hero">
492 <div class="cldploy-hero-orb" aria-hidden="true" />
493 <div class="cldploy-hero-inner">
494 <div class="cldploy-eyebrow">
495 <strong>Claude · Hosted loops</strong> · @{user.username}
496 </div>
497 <h1 class="cldploy-title">
498 Ship your Claude loop in <span class="gradient">30 seconds</span>.
499 </h1>
500 <p class="cldploy-sub">
501 Paste a tool-use loop. We host the endpoint, run it on demand,
502 meter your Claude spend against a monthly budget cap, and stream
503 every invocation to a permanent run log. No infra to wire up.
504 </p>
505 </div>
506 </section>
507
508 {/* ─── Wizard form ─── */}
509 <form id="cldploy-create" autocomplete="off">
510 {/* Step 1 */}
511 <section class="cldploy-section">
512 <div class="cldploy-section-head">
513 <div class="cldploy-step-row">
514 <span class="cldploy-step-num">1</span>
515 <h2 class="cldploy-section-title">Paste your code</h2>
516 </div>
517 <p class="cldploy-section-desc">
518 A complete JavaScript / TypeScript snippet. The platform's{" "}
519 <code>ANTHROPIC_API_KEY</code> is injected at runtime. Read the
520 input payload from <code>process.env.INPUT</code>; print JSON
521 to stdout — we record it as the run output.
522 </p>
523 </div>
524 <div class="cldploy-section-body">
525 <div class="cldploy-editor-wrap">
526 <div class="cldploy-editor-toolbar">
527 <span><span class="dot" aria-hidden="true" />loop.mjs</span>
528 <span>Bun · 30s timeout · isolated subprocess</span>
529 </div>
530 <div class="cldploy-editor-body">
531 <pre
532 id="cldploy-gutter"
533 class="cldploy-editor-gutter"
534 aria-hidden="true"
535 dangerouslySetInnerHTML={{ __html: escapeHtml(gutter) }}
536 />
537 <textarea
538 id="cldploy-source"
539 name="sourceCode"
540 class="cldploy-editor-textarea"
541 spellcheck={false}
542 wrap="off"
543 >
544 {DEFAULT_LOOP_TEMPLATE}
545 </textarea>
546 </div>
547 </div>
548 </div>
549 </section>
550
551 {/* Step 2 */}
552 <section class="cldploy-section">
553 <div class="cldploy-section-head">
554 <div class="cldploy-step-row">
555 <span class="cldploy-step-num">2</span>
556 <h2 class="cldploy-section-title">Name it &amp; pick a budget</h2>
557 </div>
558 <p class="cldploy-section-desc">
559 The name becomes part of the public URL. The budget caps your
560 cumulative Claude spend — over-cap invocations return 402.
561 </p>
562 </div>
563 <div class="cldploy-section-body">
564 <div class="cldploy-field">
565 <label class="cldploy-label" for="cldploy-name">Name</label>
566 <input
567 id="cldploy-name"
568 class="cldploy-input"
569 type="text"
570 name="name"
571 placeholder="repo-summariser"
572 required
573 maxlength={80}
574 />
575 </div>
576 <div class="cldploy-field">
577 <label class="cldploy-label">Monthly budget cap</label>
578 <div class="cldploy-plans">
579 {BUDGET_PLANS.map((p, i) => (
580 <label class="cldploy-plan">
581 <input
582 type="radio"
583 name="monthlyBudgetCents"
584 value={String(p.cents)}
585 checked={i === 0}
586 />
587 <div class="cldploy-plan-label">{p.label}</div>
588 <div class="cldploy-plan-blurb">{p.blurb}</div>
589 </label>
590 ))}
591 </div>
592 </div>
593 <div class="cldploy-field">
594 <label
595 style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--text-muted)"
596 >
597 <input type="checkbox" name="isPublic" value="1" />
598 Make endpoint public (anyone with the URL can invoke)
599 </label>
600 </div>
601 </div>
602 </section>
603
604 {/* Step 3 */}
605 <section class="cldploy-section">
606 <div class="cldploy-section-head">
607 <div class="cldploy-step-row">
608 <span class="cldploy-step-num">3</span>
609 <h2 class="cldploy-section-title">Deploy &amp; get your endpoint</h2>
610 </div>
611 <p class="cldploy-section-desc">
612 One click. We mint an agent token, persist the snippet, and
613 give you back a hosted URL plus a copy-pasteable curl.
614 </p>
615 </div>
616 <div class="cldploy-section-body">
617 <button
618 id="cldploy-submit"
619 type="submit"
620 class="cldploy-btn cldploy-btn-primary"
621 >
622 Deploy loop
623 </button>
624 <div id="cldploy-result" class="cldploy-result">
625 <div class="cldploy-result-row">
626 <div class="cldploy-result-label">Endpoint</div>
627 <pre class="cldploy-code" id="cldploy-endpoint" />
628 </div>
629 <div class="cldploy-result-row">
630 <div class="cldploy-result-label">curl example</div>
631 <pre class="cldploy-code" id="cldploy-curl" />
632 </div>
633 <div class="cldploy-result-row">
634 <div class="cldploy-result-label">Agent token (shown once)</div>
635 <pre class="cldploy-code" id="cldploy-token" />
636 </div>
637 <a
638 id="cldploy-detail-link"
639 href="#"
640 class="cldploy-btn cldploy-btn-primary"
641 style="margin-top:8px"
642 >
643 Open loop dashboard →
644 </a>
645 </div>
646 </div>
647 </section>
648 </form>
649
650 {/* ─── Existing loops ─── */}
651 <section class="cldploy-section">
652 <div class="cldploy-section-head">
653 <h2 class="cldploy-section-title">Your loops</h2>
654 <p class="cldploy-section-desc">
655 {loops.length === 0
656 ? "Nothing deployed yet — ship your first loop above."
657 : `${loops.length} loop${loops.length === 1 ? "" : "s"} hosted.`}
658 </p>
659 </div>
660 <div class="cldploy-section-body">
661 {loops.length === 0 ? (
662 <div class="cldploy-empty">
663 Your loops will appear here once you deploy.
664 </div>
665 ) : (
666 <div class="cldploy-list">
667 {loops.map((loop) => (
668 <div class="cldploy-row">
669 <div>
670 <a
671 href={`/connect/claude/deploy/${loop.id}`}
672 class="cldploy-row-name"
673 >
674 {loop.name}
675 </a>
676 <div class="cldploy-row-path">{loop.endpointPath}</div>
677 </div>
678 <div>
679 <span
680 class={`cldploy-pill is-${loop.status}`}
681 >
682 <span class="cldploy-pill-dot" />
683 {loop.status}
684 </span>
685 </div>
686 <div style="font-size:12.5px;color:var(--text-muted)">
687 {loop.lastRunAt
688 ? new Date(loop.lastRunAt).toLocaleString()
689 : "never"}
690 </div>
691 <div style="font-family:var(--font-mono);font-size:12.5px;color:var(--text-strong)">
692 {formatCents(loop.totalCentsSpent)} ·{" "}
693 <span style="color:var(--text-muted)">
694 / {formatCents(loop.monthlyBudgetCents)}
695 </span>
696 </div>
697 <div style="display:flex;gap:6px">
698 <button
699 type="button"
700 class="cldploy-btn"
701 data-cldploy-invoke={loop.id}
702 >
703 Invoke
704 </button>
705 <a
706 href={`/connect/claude/deploy/${loop.id}`}
707 class="cldploy-btn"
708 >
709 Open
710 </a>
711 </div>
712 </div>
713 ))}
714 </div>
715 )}
716 </div>
717 </section>
718 </div>
719 <script
720 dangerouslySetInnerHTML={{
721 __html: wizardScript(host),
722 }}
723 />
724 </Layout>
725 );
726});
727
728function wizardScript(host: string): string {
729 return `
730 (function(){
731 const form = document.getElementById('cldploy-create');
732 const result = document.getElementById('cldploy-result');
733 const elEndpoint = document.getElementById('cldploy-endpoint');
734 const elCurl = document.getElementById('cldploy-curl');
735 const elToken = document.getElementById('cldploy-token');
736 const elDetail = document.getElementById('cldploy-detail-link');
737 const elGutter = document.getElementById('cldploy-gutter');
738 const elSource = document.getElementById('cldploy-source');
739 const HOST = ${JSON.stringify(host)};
740
741 // Sync line-number gutter with textarea content & scroll.
742 function syncGutter() {
743 if (!elSource || !elGutter) return;
744 const lines = (elSource.value || '').split('\\n').length;
745 let out = '';
746 for (let i = 1; i <= lines; i++) out += (i === 1 ? '' : '\\n') + i;
747 elGutter.textContent = out;
748 }
749 function syncScroll() {
750 if (elSource && elGutter) elGutter.scrollTop = elSource.scrollTop;
751 }
752 if (elSource) {
753 elSource.addEventListener('input', syncGutter);
754 elSource.addEventListener('scroll', syncScroll);
755 syncGutter();
756 }
757
758 if (form) {
759 form.addEventListener('submit', async function(ev) {
760 ev.preventDefault();
761 const fd = new FormData(form);
762 const body = {
763 name: String(fd.get('name') || ''),
764 sourceCode: String(fd.get('sourceCode') || ''),
765 monthlyBudgetCents: Number(fd.get('monthlyBudgetCents') || 500),
766 isPublic: fd.get('isPublic') === '1',
767 };
768 const btn = document.getElementById('cldploy-submit');
769 if (btn) { btn.setAttribute('disabled', '1'); btn.textContent = 'Deploying…'; }
770 try {
771 const res = await fetch('/api/v2/claude-loops', {
772 method: 'POST',
773 credentials: 'same-origin',
774 headers: { 'content-type': 'application/json' },
775 body: JSON.stringify(body),
776 });
777 const j = await res.json();
778 if (!res.ok) throw new Error(j.error || 'create failed');
779 if (result) result.classList.add('is-shown');
780 if (elEndpoint) elEndpoint.textContent = HOST + j.endpointPath;
781 if (elCurl) {
782 elCurl.textContent = 'curl -X POST ' + HOST + j.endpointPath + ' \\\\\\n' +
783 ' -H "Content-Type: application/json" \\\\\\n' +
784 ' -d \\'{"repo":"ccantynz-alt/Gluecron.com"}\\'';
785 }
786 if (elToken) elToken.textContent = j.agentToken || '(no token — agent session create failed)';
787 if (elDetail) elDetail.setAttribute('href', '/connect/claude/deploy/' + j.id);
788 if (btn) btn.textContent = 'Deployed ✓';
789 } catch (e) {
790 if (btn) { btn.removeAttribute('disabled'); btn.textContent = 'Try again'; }
791 console.error('[cldploy]', e);
792 }
793 });
794 }
795
796 // Invoke-from-list buttons.
797 document.querySelectorAll('[data-cldploy-invoke]').forEach(function(btn) {
798 btn.addEventListener('click', async function() {
799 const id = btn.getAttribute('data-cldploy-invoke');
800 if (!id) return;
801 const prev = btn.textContent;
802 btn.textContent = 'Invoking…';
803 btn.setAttribute('disabled', '1');
804 try {
805 const res = await fetch('/api/v2/claude-loops/' + id + '/invoke', {
806 method: 'POST',
807 credentials: 'same-origin',
808 headers: { 'content-type': 'application/json' },
809 body: '{}',
810 });
811 const j = await res.json();
812 btn.textContent = j.status === 'ok' ? 'Invoked ✓' : (j.status || 'error');
813 setTimeout(function() {
814 btn.textContent = prev;
815 btn.removeAttribute('disabled');
816 }, 1600);
817 } catch (e) {
818 btn.textContent = 'error';
819 setTimeout(function() {
820 btn.textContent = prev;
821 btn.removeAttribute('disabled');
822 }, 1600);
823 }
824 });
825 });
826 })();
827 `;
828}
829
830// ---------------------------------------------------------------------------
831// GET /connect/claude/deploy/:id — loop detail
832// ---------------------------------------------------------------------------
833claudeDeploy.get("/connect/claude/deploy/:id", async (c) => {
834 const user = c.get("user")!;
835 const id = c.req.param("id");
836 const loop = await getLoop(id);
837 if (!loop || loop.ownerUserId !== user.id) {
838 return c.html(
839 <Layout title="Loop not found" user={user}>
840 <div style="max-width:720px;margin:60px auto;padding:0 20px;text-align:center">
841 <h1>Loop not found</h1>
842 <p style="color:var(--text-muted)">
843 Either it doesn't exist or you don't have access.
844 </p>
845 <a href="/connect/claude/deploy" class="cldploy-btn">
846 Back to wizard
847 </a>
848 </div>
849 <style dangerouslySetInnerHTML={{ __html: styles }} />
850 </Layout>,
851 404
852 );
853 }
854 const runs = await listRunsForLoop(loop.id, 50);
855 const host = config.appBaseUrl || "https://gluecron.com";
856 const curl = curlExample(host, loop.endpointPath);
857
858 const templateLines = loop.sourceCode.split("\n");
859 const gutter = templateLines.map((_, i) => i + 1).join("\n");
860
861 return c.html(
862 <Layout title={`Loop · ${loop.name}`} user={user}>
863 <style dangerouslySetInnerHTML={{ __html: styles }} />
864 <div class="cldploy-container">
865 <section class="cldploy-hero">
866 <div class="cldploy-hero-orb" aria-hidden="true" />
867 <div class="cldploy-hero-inner">
868 <div class="cldploy-eyebrow">
869 <strong>Loop</strong> · @{user.username} ·{" "}
870 <span class={`cldploy-pill is-${loop.status}`}>
871 <span class="cldploy-pill-dot" />
872 {loop.status}
873 </span>
874 </div>
875 <h1 class="cldploy-title">{loop.name}</h1>
876 <p class="cldploy-sub">
877 Endpoint:{" "}
878 <code style="font-size:13px">{host}{loop.endpointPath}</code>
879 </p>
880 </div>
881 </section>
882
883 <section class="cldploy-section">
884 <div class="cldploy-section-head">
885 <h2 class="cldploy-section-title">Meter</h2>
886 <p class="cldploy-section-desc">
887 Lifetime spend vs monthly cap. Over-cap invocations return 402.
888 </p>
889 </div>
890 <div class="cldploy-section-body">
891 <div class="cldploy-meta">
892 <div class="cldploy-meta-cell">
893 <div class="cldploy-meta-label">Spent</div>
894 <div class="cldploy-meta-value">
895 {formatCents(loop.totalCentsSpent)}
896 </div>
897 </div>
898 <div class="cldploy-meta-cell">
899 <div class="cldploy-meta-label">Cap</div>
900 <div class="cldploy-meta-value">
901 {formatCents(loop.monthlyBudgetCents)}
902 </div>
903 </div>
904 <div class="cldploy-meta-cell">
905 <div class="cldploy-meta-label">Invocations</div>
906 <div class="cldploy-meta-value">{loop.totalInvocations}</div>
907 </div>
908 <div class="cldploy-meta-cell">
909 <div class="cldploy-meta-label">Last run</div>
910 <div class="cldploy-meta-value" style="font-size:13px">
911 {loop.lastRunAt
912 ? new Date(loop.lastRunAt).toLocaleString()
913 : "never"}
914 </div>
915 </div>
916 </div>
917 </div>
918 </section>
919
920 <section class="cldploy-section">
921 <div class="cldploy-section-head">
922 <h2 class="cldploy-section-title">Invoke</h2>
923 <p class="cldploy-section-desc">
924 POST any JSON to the endpoint. The body becomes the snippet's{" "}
925 <code>process.env.INPUT</code>.
926 </p>
927 </div>
928 <div class="cldploy-section-body">
929 <pre class="cldploy-code">{curl}</pre>
930 <div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">
931 <button
932 type="button"
933 class="cldploy-btn cldploy-btn-primary"
934 data-cldploy-invoke={loop.id}
935 >
936 Invoke now
937 </button>
938 {loop.status === "paused" ? (
939 <button
940 type="button"
941 class="cldploy-btn"
942 data-cldploy-resume={loop.id}
943 >
944 Resume
945 </button>
946 ) : (
947 <button
948 type="button"
949 class="cldploy-btn"
950 data-cldploy-pause={loop.id}
951 >
952 Pause
953 </button>
954 )}
955 <button
956 type="button"
957 class="cldploy-btn cldploy-btn-danger"
958 data-cldploy-delete={loop.id}
959 >
960 Delete
961 </button>
962 </div>
963 </div>
964 </section>
965
966 <section class="cldploy-section">
967 <div class="cldploy-section-head">
968 <h2 class="cldploy-section-title">Source</h2>
969 <p class="cldploy-section-desc">
970 Edit + save to roll out a new version. Existing runs are
971 preserved.
972 </p>
973 </div>
974 <div class="cldploy-section-body">
975 <form id="cldploy-edit" data-loop-id={loop.id}>
976 <div class="cldploy-editor-wrap">
977 <div class="cldploy-editor-toolbar">
978 <span><span class="dot" aria-hidden="true" />loop.mjs</span>
979 <span>{loop.sourceCode.length} bytes</span>
980 </div>
981 <div class="cldploy-editor-body">
982 <pre
983 id="cldploy-edit-gutter"
984 class="cldploy-editor-gutter"
985 aria-hidden="true"
986 dangerouslySetInnerHTML={{ __html: escapeHtml(gutter) }}
987 />
988 <textarea
989 id="cldploy-edit-source"
990 name="sourceCode"
991 class="cldploy-editor-textarea"
992 spellcheck={false}
993 wrap="off"
994 >
995 {loop.sourceCode}
996 </textarea>
997 </div>
998 </div>
999 <div style="margin-top:12px">
1000 <button
1001 type="submit"
1002 class="cldploy-btn cldploy-btn-primary"
1003 >
1004 Save source
1005 </button>
1006 </div>
1007 </form>
1008 </div>
1009 </section>
1010
1011 <section class="cldploy-section">
1012 <div class="cldploy-section-head">
1013 <h2 class="cldploy-section-title">
1014 Run history · {runs.length}
1015 </h2>
1016 </div>
1017 <div class="cldploy-section-body">
1018 {runs.length === 0 ? (
1019 <div class="cldploy-empty">
1020 No runs yet — invoke to see history populate here.
1021 </div>
1022 ) : (
1023 <div class="cldploy-run-list">
1024 {runs.map((r) => (
1025 <div class="cldploy-run">
1026 <div class="cldploy-run-time">
1027 {new Date(r.startedAt).toLocaleString()}
1028 </div>
1029 <div class={`cldploy-run-status ${r.status}`}>
1030 {r.status}
1031 </div>
1032 <div style="font-family:var(--font-mono);font-size:12.5px">
1033 {formatCents(r.centsEstimate)} ·{" "}
1034 {r.claudeInputTokens + r.claudeOutputTokens} toks
1035 </div>
1036 <div
1037 style="font-size:12.5px;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
1038 title={r.errorMessage || ""}
1039 >
1040 {r.errorMessage || (r.stdout || "").slice(0, 200)}
1041 </div>
1042 </div>
1043 ))}
1044 </div>
1045 )}
1046 </div>
1047 </section>
1048 </div>
1049 <script
1050 dangerouslySetInnerHTML={{ __html: detailScript(loop.id) }}
1051 />
1052 </Layout>
1053 );
1054});
1055
1056function detailScript(loopId: string): string {
1057 return `
1058 (function(){
1059 const ID = ${JSON.stringify(loopId)};
1060 const elSource = document.getElementById('cldploy-edit-source');
1061 const elGutter = document.getElementById('cldploy-edit-gutter');
1062 function syncGutter() {
1063 if (!elSource || !elGutter) return;
1064 const lines = (elSource.value || '').split('\\n').length;
1065 let out = '';
1066 for (let i = 1; i <= lines; i++) out += (i === 1 ? '' : '\\n') + i;
1067 elGutter.textContent = out;
1068 }
1069 if (elSource) {
1070 elSource.addEventListener('input', syncGutter);
1071 syncGutter();
1072 }
1073 const form = document.getElementById('cldploy-edit');
1074 if (form) {
1075 form.addEventListener('submit', async function(ev) {
1076 ev.preventDefault();
1077 const body = { sourceCode: (elSource && elSource.value) || '' };
1078 const res = await fetch('/api/v2/claude-loops/' + ID, {
1079 method: 'PATCH',
1080 credentials: 'same-origin',
1081 headers: { 'content-type': 'application/json' },
1082 body: JSON.stringify(body),
1083 });
1084 if (res.ok) {
1085 const btn = form.querySelector('button[type=submit]');
1086 if (btn) {
1087 const prev = btn.textContent;
1088 btn.textContent = 'Saved ✓';
1089 setTimeout(function(){ btn.textContent = prev; }, 1400);
1090 }
1091 }
1092 });
1093 }
1094 function buttonAction(attr, method, urlSuffix) {
1095 document.querySelectorAll('[data-cldploy-' + attr + ']').forEach(function(btn) {
1096 btn.addEventListener('click', async function() {
1097 const id = btn.getAttribute('data-cldploy-' + attr);
1098 if (!id) return;
1099 if (attr === 'delete' && !confirm('Delete this loop? This cannot be undone.')) return;
1100 btn.setAttribute('disabled', '1');
1101 const url = '/api/v2/claude-loops/' + id + (urlSuffix || '');
1102 try {
1103 const res = await fetch(url, { method, credentials: 'same-origin' });
1104 if (attr === 'delete' && res.ok) {
1105 window.location.href = '/connect/claude/deploy';
1106 return;
1107 }
1108 window.location.reload();
1109 } catch {
1110 btn.removeAttribute('disabled');
1111 }
1112 });
1113 });
1114 }
1115 buttonAction('invoke', 'POST', '/invoke');
1116 buttonAction('pause', 'POST', '/pause');
1117 buttonAction('resume', 'POST', '/resume');
1118 buttonAction('delete', 'DELETE', '');
1119 })();
1120 `;
1121}
1122
1123// ---------------------------------------------------------------------------
1124// POST /api/v2/claude-loops — create
1125// ---------------------------------------------------------------------------
1126claudeDeploy.post("/api/v2/claude-loops", async (c) => {
1127 const user = c.get("user")!;
1128 let body: Record<string, unknown> = {};
1129 try {
1130 body = (await c.req.json()) as Record<string, unknown>;
1131 } catch {
1132 body = {};
1133 }
1134 const name = typeof body.name === "string" ? body.name : "";
1135 const sourceCode = typeof body.sourceCode === "string" ? body.sourceCode : "";
1136 const monthlyBudgetCents =
1137 typeof body.monthlyBudgetCents === "number" ? body.monthlyBudgetCents : 500;
1138 const isPublic = Boolean(body.isPublic);
1139 if (!name.trim() || !sourceCode.trim()) {
1140 return c.json({ error: "name and sourceCode are required" }, 400);
1141 }
1142 const result = await createLoop({
1143 ownerUserId: user.id,
1144 name,
1145 sourceCode,
1146 monthlyBudgetCents,
1147 isPublic,
1148 });
1149 if (!result) {
1150 return c.json({ error: "could not create loop" }, 500);
1151 }
1152 return c.json(
1153 {
1154 id: result.loop.id,
1155 name: result.loop.name,
1156 endpointPath: result.loop.endpointPath,
1157 status: result.loop.status,
1158 monthlyBudgetCents: result.loop.monthlyBudgetCents,
1159 isPublic: result.loop.isPublic,
1160 agentToken: result.agentToken,
1161 },
1162 201
1163 );
1164});
1165
1166// ---------------------------------------------------------------------------
1167// POST /api/v2/claude-loops/:id/invoke — owner-side sync invoke
1168// ---------------------------------------------------------------------------
1169claudeDeploy.post("/api/v2/claude-loops/:id/invoke", async (c) => {
1170 const user = c.get("user")!;
1171 const id = c.req.param("id");
1172 const loop = await getLoop(id);
1173 if (!loop || loop.ownerUserId !== user.id) {
1174 return c.json({ error: "not found" }, 404);
1175 }
1176 let inputPayload: unknown = {};
1177 try {
1178 inputPayload = await c.req.json();
1179 } catch {
1180 inputPayload = {};
1181 }
1182 const result = await invokeLoop({
1183 loopId: loop.id,
1184 inputPayload,
1185 isPublicInvocation: false,
1186 });
1187 if (result.status === "budget_exceeded") {
1188 return c.json(
1189 {
1190 status: "budget_exceeded",
1191 error: "monthly budget cap reached",
1192 centsSpent: loop.totalCentsSpent,
1193 centsCap: loop.monthlyBudgetCents,
1194 },
1195 402
1196 );
1197 }
1198 return c.json(
1199 {
1200 status: result.status,
1201 output: result.output,
1202 stdout: result.stdout.slice(0, 16_000),
1203 stderr: result.stderr.slice(0, 16_000),
1204 centsCharged: result.centsCharged,
1205 runId: result.run?.id ?? null,
1206 },
1207 result.status === "ok" ? 200 : 502
1208 );
1209});
1210
1211// ---------------------------------------------------------------------------
1212// PATCH /api/v2/claude-loops/:id — update name/source/budget/visibility
1213// ---------------------------------------------------------------------------
1214claudeDeploy.patch("/api/v2/claude-loops/:id", async (c) => {
1215 const user = c.get("user")!;
1216 const id = c.req.param("id");
1217 let body: Record<string, unknown> = {};
1218 try {
1219 body = (await c.req.json()) as Record<string, unknown>;
1220 } catch {
1221 body = {};
1222 }
1223 const patch: {
1224 name?: string;
1225 sourceCode?: string;
1226 monthlyBudgetCents?: number;
1227 isPublic?: boolean;
1228 } = {};
1229 if (typeof body.name === "string") patch.name = body.name;
1230 if (typeof body.sourceCode === "string") patch.sourceCode = body.sourceCode;
1231 if (typeof body.monthlyBudgetCents === "number") {
1232 patch.monthlyBudgetCents = body.monthlyBudgetCents;
1233 }
1234 if (typeof body.isPublic === "boolean") patch.isPublic = body.isPublic;
1235 const row = await updateLoop(id, user.id, patch);
1236 if (!row) return c.json({ error: "not found" }, 404);
1237 return c.json({ id: row.id, name: row.name });
1238});
1239
1240// ---------------------------------------------------------------------------
1241// POST /api/v2/claude-loops/:id/pause + /resume + DELETE
1242// ---------------------------------------------------------------------------
1243claudeDeploy.post("/api/v2/claude-loops/:id/pause", async (c) => {
1244 const user = c.get("user")!;
1245 const ok = await pauseLoop(c.req.param("id"), user.id);
1246 return c.json({ ok }, ok ? 200 : 404);
1247});
1248
1249claudeDeploy.post("/api/v2/claude-loops/:id/resume", async (c) => {
1250 const user = c.get("user")!;
1251 const ok = await resumeLoop(c.req.param("id"), user.id);
1252 return c.json({ ok }, ok ? 200 : 404);
1253});
1254
1255claudeDeploy.delete("/api/v2/claude-loops/:id", async (c) => {
1256 const user = c.get("user")!;
1257 const ok = await deleteLoop(c.req.param("id"), user.id);
1258 return c.json({ ok }, ok ? 200 : 404);
1259});
1260
1261// ---------------------------------------------------------------------------
1262// GET /api/v2/claude-loops/:id/runs — paginated run history
1263// ---------------------------------------------------------------------------
1264claudeDeploy.get("/api/v2/claude-loops/:id/runs", async (c) => {
1265 const user = c.get("user")!;
1266 const id = c.req.param("id");
1267 const loop = await getLoop(id);
1268 if (!loop || loop.ownerUserId !== user.id) {
1269 return c.json({ error: "not found" }, 404);
1270 }
1271 const limit = Math.max(1, Math.min(500, Number(c.req.query("limit") || 50)));
1272 const runs = await listRunsForLoop(loop.id, limit);
1273 return c.json({
1274 loop: {
1275 id: loop.id,
1276 name: loop.name,
1277 status: loop.status,
1278 endpointPath: loop.endpointPath,
1279 },
1280 runs: runs.map((r) => ({
1281 id: r.id,
1282 status: r.status,
1283 startedAt: r.startedAt,
1284 finishedAt: r.finishedAt,
1285 centsEstimate: r.centsEstimate,
1286 claudeInputTokens: r.claudeInputTokens,
1287 claudeOutputTokens: r.claudeOutputTokens,
1288 exitCode: r.exitCode,
1289 errorMessage: r.errorMessage,
1290 })),
1291 });
1292});
1293
1294// ---------------------------------------------------------------------------
1295// POST /loops/:slug/invoke — public sync invoke (slug = full slug-suffix)
1296// ---------------------------------------------------------------------------
1297claudeDeploy.post("/loops/:slug/invoke", async (c) => {
1298 const slug = c.req.param("slug");
1299 const endpointPath = `/claude-loops/${slug}`;
1300 const loop = await getLoopByEndpointPath(endpointPath);
1301 if (!loop) return c.json({ error: "loop not found" }, 404);
1302 if (!loop.isPublic) {
1303 return c.json({ error: "loop is not public" }, 403);
1304 }
1305 if (loop.status !== "running") {
1306 return c.json({ error: "loop is not running" }, 409);
1307 }
1308 let inputPayload: unknown = {};
1309 try {
1310 inputPayload = await c.req.json();
1311 } catch {
1312 inputPayload = {};
1313 }
1314 const result = await invokeLoop({
1315 loopId: loop.id,
1316 inputPayload,
1317 isPublicInvocation: true,
1318 });
1319 if (result.status === "budget_exceeded") {
1320 return c.json(
1321 {
1322 status: "budget_exceeded",
1323 error: "monthly budget cap reached",
1324 },
1325 402
1326 );
1327 }
1328 return c.json(
1329 {
1330 status: result.status,
1331 output: result.output,
1332 centsCharged: result.centsCharged,
1333 },
1334 result.status === "ok" ? 200 : 502
1335 );
1336});
1337
1338// ---------------------------------------------------------------------------
1339// Suppress unused-import warning for `hostedClaudeLoops`/`eq`/`db` if the
1340// route never references them directly; left here for future query helpers.
1341// ---------------------------------------------------------------------------
1342void hostedClaudeLoops;
1343void db;
1344void eq;
1345
1346export default claudeDeploy;