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

dev-env.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.

dev-env.tsxBlame809 lines · 1 contributor
9b3a183Claude1/**
2 * Cloud dev environment routes (migration 0072).
3 *
4 * GET /:owner/:repo/dev — UI; full-screen iframe when ready
5 * POST /api/v2/repos/:owner/:repo/dev/start
6 * POST /api/v2/repos/:owner/:repo/dev/stop
7 * GET /api/v2/repos/:owner/:repo/dev/status
8 *
9 * The UI page is server-rendered through the shared Layout. All custom CSS
10 * is scoped under `.dev-env-*` so we don't accidentally bleed into the
11 * locked layout / components / ui sheets.
12 *
13 * For non-ready statuses (cold / warming / failed) the page polls the
14 * status JSON every 2s and re-renders on transition. Ready collapses the
15 * page chrome and renders a single full-screen iframe pointing at the
16 * VS Code Server URL.
17 */
18
19import { Hono } from "hono";
20import { and, eq } from "drizzle-orm";
21import { db } from "../db";
22import { repositories, users } from "../db/schema";
23import { softAuth, requireAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25import { Layout } from "../views/layout";
26import {
27 buildDevEnvUrl,
28 devEnvStatusLabel,
29 getDevEnv,
30 getDevEnvForOwner,
31 normalizeMachineSize,
32 recordActivity,
33 startDevEnv,
34 stopDevEnv,
35 type DevEnvStatus,
36} from "../lib/dev-env";
37import type { DevEnv } from "../db/schema";
38
39const devEnvRoutes = new Hono<AuthEnv>();
40
41// ---------------------------------------------------------------------------
42// Scoped CSS — every class prefixed `.dev-env-*`
43// ---------------------------------------------------------------------------
44
45const devEnvStyles = `
46 .dev-env-wrap {
47 max-width: 880px;
48 margin: 0 auto;
49 padding: var(--space-6, 32px) var(--space-4, 24px);
50 }
51
52 .dev-env-card {
53 position: relative;
54 padding: clamp(28px, 4vw, 44px);
55 background: var(--bg-elevated);
56 border: 1px solid var(--border);
57 border-radius: 16px;
58 overflow: hidden;
59 }
60 .dev-env-card::before {
61 content: '';
62 position: absolute;
63 top: 0; left: 0; right: 0;
64 height: 2px;
65 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
66 opacity: 0.75;
67 pointer-events: none;
68 }
69 .dev-env-eyebrow {
70 display: inline-flex;
71 align-items: center;
72 gap: 8px;
73 font-family: var(--font-mono);
74 font-size: 11px;
75 letter-spacing: 0.18em;
76 text-transform: uppercase;
77 color: var(--text-muted);
78 font-weight: 600;
79 margin-bottom: 12px;
80 }
81 .dev-env-eyebrow-dot {
82 width: 8px; height: 8px;
83 border-radius: 9999px;
84 background: linear-gradient(135deg, #8c6dff, #36c5d6);
85 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
86 }
87 .dev-env-title {
88 font-family: var(--font-display);
89 font-size: clamp(24px, 4vw, 32px);
90 font-weight: 800;
91 letter-spacing: -0.022em;
92 line-height: 1.1;
93 margin: 0 0 var(--space-3);
94 color: var(--text-strong);
95 }
96 .dev-env-sub {
97 font-size: 15px;
98 color: var(--text-muted);
99 margin: 0 0 var(--space-4);
100 line-height: 1.55;
101 }
102 .dev-env-sub code {
103 font-family: var(--font-mono);
104 font-size: 12.5px;
105 background: var(--bg-secondary);
106 border: 1px solid var(--border);
107 border-radius: 5px;
108 padding: 1px 6px;
109 color: var(--text-strong);
110 }
111
112 .dev-env-pill {
113 display: inline-flex;
114 align-items: center;
115 gap: 8px;
116 padding: 4px 10px;
117 border-radius: 9999px;
118 font-family: var(--font-mono);
119 font-size: 12px;
120 font-weight: 600;
121 border: 1px solid var(--border);
122 background: var(--bg-secondary);
123 color: var(--text);
124 margin-bottom: var(--space-4);
125 }
126 .dev-env-pill .dot {
127 width: 8px; height: 8px;
128 border-radius: 9999px;
129 background: var(--text-muted);
130 }
131 .dev-env-pill.is-cold .dot { background: #64748b; }
132 .dev-env-pill.is-warming .dot {
133 background: linear-gradient(135deg, #8c6dff, #36c5d6);
134 animation: devEnvPulse 1.4s ease-in-out infinite;
135 }
136 .dev-env-pill.is-ready .dot { background: #22c55e; box-shadow: 0 0 8px rgba(34,197,94,0.6); }
137 .dev-env-pill.is-failed .dot { background: #f85149; box-shadow: 0 0 8px rgba(248,81,73,0.6); }
138 .dev-env-pill.is-stopped .dot { background: #94a3b8; }
139 @keyframes devEnvPulse {
140 0%, 100% { transform: scale(1); opacity: 1; }
141 50% { transform: scale(1.35); opacity: 0.6; }
142 }
143 @media (prefers-reduced-motion: reduce) {
144 .dev-env-pill.is-warming .dot { animation: none; }
145 }
146
147 .dev-env-progress {
148 position: relative;
149 width: 100%;
150 height: 6px;
151 background: var(--bg-secondary);
152 border-radius: 9999px;
153 overflow: hidden;
154 margin: var(--space-3) 0 var(--space-4);
155 }
156 .dev-env-progress-bar {
157 position: absolute;
158 inset: 0;
159 width: 35%;
160 background: linear-gradient(90deg, #8c6dff 0%, #36c5d6 100%);
161 border-radius: inherit;
162 animation: devEnvSlide 1.6s ease-in-out infinite;
163 }
164 @keyframes devEnvSlide {
165 0% { transform: translateX(-100%); }
166 100% { transform: translateX(286%); }
167 }
168 @media (prefers-reduced-motion: reduce) {
169 .dev-env-progress-bar { animation: none; width: 60%; }
170 }
171
172 .dev-env-actions {
173 display: flex;
174 gap: var(--space-2);
175 flex-wrap: wrap;
176 margin-top: var(--space-4);
177 }
178 .dev-env-cta {
179 appearance: none;
180 border: 1px solid rgba(140,109,255,0.45);
181 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
182 color: #fff;
183 padding: 11px 20px;
184 border-radius: 11px;
185 font-family: var(--font-display);
186 font-weight: 700;
187 font-size: 14px;
188 letter-spacing: -0.005em;
189 cursor: pointer;
190 text-decoration: none;
191 display: inline-flex;
192 align-items: center;
193 gap: 8px;
194 transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease;
195 }
196 .dev-env-cta:hover {
197 transform: translateY(-1px);
198 box-shadow: 0 12px 24px -10px rgba(140,109,255,0.55);
199 filter: brightness(1.06);
200 }
201 .dev-env-cta-secondary {
202 appearance: none;
203 background: transparent;
204 color: var(--text);
205 border: 1px solid var(--border);
206 padding: 10px 16px;
207 border-radius: 11px;
208 font-size: 14px;
209 font-family: inherit;
210 cursor: pointer;
211 text-decoration: none;
212 display: inline-flex; align-items: center;
213 }
214 .dev-env-cta-secondary:hover {
215 border-color: var(--accent);
216 color: var(--text-strong);
217 }
218
219 .dev-env-error {
220 position: relative;
221 padding: 14px 16px 14px 44px;
222 margin: var(--space-3) 0 var(--space-4);
223 border-radius: 12px;
224 border: 1px solid rgba(248, 81, 73, 0.32);
225 background: linear-gradient(180deg, rgba(248,81,73,0.06) 0%, var(--bg-elevated) 100%);
226 color: var(--text);
227 font-size: 14px;
228 line-height: 1.5;
229 word-break: break-word;
230 }
231 .dev-env-error::before {
232 content: '';
233 position: absolute;
234 left: 14px; top: 18px;
235 width: 14px; height: 14px;
236 border-radius: 50%;
237 background: radial-gradient(circle, #f85149 30%, transparent 70%);
238 box-shadow: 0 0 10px rgba(248,81,73,0.5);
239 }
240
241 .dev-env-meta {
242 display: grid;
243 grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
244 gap: var(--space-3);
245 margin-top: var(--space-4);
246 font-size: 13px;
247 }
248 .dev-env-meta-row {
249 display: flex;
250 flex-direction: column;
251 gap: 2px;
252 }
253 .dev-env-meta-label {
254 color: var(--text-muted);
255 font-size: 11px;
256 text-transform: uppercase;
257 letter-spacing: 0.10em;
258 font-family: var(--font-mono);
259 }
260 .dev-env-meta-value {
261 color: var(--text-strong);
262 font-weight: 600;
263 word-break: break-all;
264 }
265
266 /* Full-screen iframe shell for the ready state. */
267 .dev-env-shell {
268 position: fixed;
269 inset: 0;
270 background: var(--bg);
271 z-index: 9999;
272 display: flex;
273 flex-direction: column;
274 }
275 .dev-env-shell-bar {
276 display: flex;
277 align-items: center;
278 justify-content: space-between;
279 gap: var(--space-3);
280 padding: 8px 16px;
281 background: var(--bg-elevated);
282 border-bottom: 1px solid var(--border);
283 font-size: 12px;
284 color: var(--text-muted);
285 }
286 .dev-env-shell-bar .dev-env-shell-title {
287 display: inline-flex; align-items: center; gap: 8px;
288 color: var(--text-strong);
289 font-weight: 600;
290 font-family: var(--font-mono);
291 }
292 .dev-env-shell-iframe {
293 flex: 1;
294 width: 100%;
295 border: 0;
296 background: var(--bg);
297 }
298`;
299
300// ---------------------------------------------------------------------------
301// Helpers
302// ---------------------------------------------------------------------------
303
304async function resolveRepoForUser(
305 ownerName: string,
306 repoName: string
307): Promise<{
308 ownerId: string;
309 ownerName: string;
310 repoId: string;
311 repoName: string;
312 devEnabled: boolean;
313} | null> {
314 try {
315 const [owner] = await db
316 .select()
317 .from(users)
318 .where(eq(users.username, ownerName))
319 .limit(1);
320 if (!owner) return null;
321 const [repo] = await db
322 .select()
323 .from(repositories)
324 .where(
325 and(
326 eq(repositories.ownerId, owner.id),
327 eq(repositories.name, repoName)
328 )
329 )
330 .limit(1);
331 if (!repo) return null;
332 return {
333 ownerId: owner.id,
334 ownerName: owner.username,
335 repoId: repo.id,
336 repoName: repo.name,
337 devEnabled: !!(repo as { devEnvsEnabled?: boolean }).devEnvsEnabled,
338 };
339 } catch {
340 return null;
341 }
342}
343
344function jsonShape(row: DevEnv | null): Record<string, unknown> | null {
345 if (!row) return null;
346 return {
347 id: row.id,
348 status: row.status,
349 statusLabel: devEnvStatusLabel(row.status),
350 previewUrl: row.previewUrl || buildDevEnvUrl(row.id),
351 machineSize: row.machineSize,
352 idleMinutes: row.idleMinutes,
353 lastActiveAt: row.lastActiveAt?.toISOString?.() ?? null,
354 createdAt: row.createdAt?.toISOString?.() ?? null,
355 errorMessage: row.errorMessage,
356 };
357}
358
359// ---------------------------------------------------------------------------
360// Cold / warming / failed UI pieces (server-rendered JSX) — broken out so
361// the GET route stays readable.
362// ---------------------------------------------------------------------------
363
364function StatusPill({ status }: { status: string }) {
365 const cls = `dev-env-pill is-${status}`;
366 return (
367 <span class={cls}>
368 <span class="dot" aria-hidden="true" />
369 {devEnvStatusLabel(status)}
370 </span>
371 );
372}
373
374function MetaGrid({ env }: { env: DevEnv }) {
375 return (
376 <div class="dev-env-meta" aria-label="Environment metadata">
377 <div class="dev-env-meta-row">
378 <span class="dev-env-meta-label">Machine</span>
379 <span class="dev-env-meta-value">{env.machineSize}</span>
380 </div>
381 <div class="dev-env-meta-row">
382 <span class="dev-env-meta-label">Idle timeout</span>
383 <span class="dev-env-meta-value">{env.idleMinutes}m</span>
384 </div>
385 <div class="dev-env-meta-row">
386 <span class="dev-env-meta-label">URL</span>
387 <span class="dev-env-meta-value">
388 {(env.previewUrl || buildDevEnvUrl(env.id)).replace(
389 /^https?:\/\//,
390 ""
391 )}
392 </span>
393 </div>
394 </div>
395 );
396}
397
398// ---------------------------------------------------------------------------
399// GET /:owner/:repo/dev
400// ---------------------------------------------------------------------------
401
402devEnvRoutes.get("/:owner/:repo/dev", softAuth, async (c) => {
403 const ownerName = c.req.param("owner");
404 const repoName = c.req.param("repo");
405 const user = c.get("user");
406 const csrf = c.get("csrfToken") as string | undefined;
407
408 const resolved = await resolveRepoForUser(ownerName, repoName);
409 if (!resolved) return c.notFound();
410
411 // Render a "disabled" notice if the repo hasn't opted in. Owners get a
412 // direct link to flip the toggle.
413 if (!resolved.devEnabled) {
414 const isOwner = user && user.id === resolved.ownerId;
415 return c.html(
416 <Layout
417 title={`Dev env — ${resolved.ownerName}/${resolved.repoName}`}
418 user={user ?? null}
419 >
420 <style dangerouslySetInnerHTML={{ __html: devEnvStyles }} />
421 <div class="dev-env-wrap">
422 <div class="dev-env-card">
423 <div class="dev-env-eyebrow">
424 <span class="dev-env-eyebrow-dot" aria-hidden="true" />
425 Cloud dev env · disabled
426 </div>
427 <h1 class="dev-env-title">Dev environments are off for this repo</h1>
428 <p class="dev-env-sub">
429 {isOwner ? (
430 <>
431 Flip <code>dev_envs_enabled</code> on in repository
432 settings to get a hosted VS Code IDE in the browser for
433 this repo. Default is off because each environment burns
434 a container.
435 </>
436 ) : (
437 <>
438 The owner of <code>{resolved.ownerName}/{resolved.repoName}</code>{" "}
439 hasn&apos;t enabled cloud dev environments yet.
440 </>
441 )}
442 </p>
443 <div class="dev-env-actions">
444 {isOwner && (
445 <a
446 href={`/${resolved.ownerName}/${resolved.repoName}/settings#dev-envs`}
447 class="dev-env-cta"
448 >
449 Open repo settings &rarr;
450 </a>
451 )}
452 <a
453 href={`/${resolved.ownerName}/${resolved.repoName}`}
454 class="dev-env-cta-secondary"
455 >
456 Back to repo
457 </a>
458 </div>
459 </div>
460 </div>
461 </Layout>
462 );
463 }
464
465 // Login required to provision/use an env.
466 if (!user) {
467 return c.redirect(
468 `/login?next=${encodeURIComponent(`/${resolved.ownerName}/${resolved.repoName}/dev`)}`
469 );
470 }
471
472 const env = await getDevEnvForOwner(resolved.repoId, user.id);
473
474 // Record activity if there's a live row — every page hit counts.
475 if (env) {
476 void recordActivity(env.id);
477 }
478
479 // No env yet — render a start button.
480 if (!env) {
481 return c.html(
482 <Layout
483 title={`Dev env — ${resolved.ownerName}/${resolved.repoName}`}
484 user={user}
485 >
486 <style dangerouslySetInnerHTML={{ __html: devEnvStyles }} />
487 <div class="dev-env-wrap">
488 <div class="dev-env-card">
489 <div class="dev-env-eyebrow">
490 <span class="dev-env-eyebrow-dot" aria-hidden="true" />
491 Cloud dev env
492 </div>
493 <h1 class="dev-env-title">
494 Spin up a hosted VS Code for{" "}
495 {resolved.ownerName}/{resolved.repoName}
496 </h1>
497 <p class="dev-env-sub">
498 Get a full VS Code IDE in your browser, backed by a cold-start
499 container. We read <code>.gluecron/dev.yml</code> from your
500 repo for the image, install steps, and recommended
501 extensions. Idle envs stop themselves after{" "}
502 <strong>30 minutes</strong>.
503 </p>
504 <form
505 method="post"
506 action={`/api/v2/repos/${resolved.ownerName}/${resolved.repoName}/dev/start`}
507 >
508 {csrf && <input type="hidden" name="_csrf" value={csrf} />}
509 <div class="dev-env-actions">
510 <button type="submit" class="dev-env-cta">
511 Start dev env &rarr;
512 </button>
513 <a
514 href={`/${resolved.ownerName}/${resolved.repoName}`}
515 class="dev-env-cta-secondary"
516 >
517 Cancel
518 </a>
519 </div>
520 </form>
521 </div>
522 </div>
523 </Layout>
524 );
525 }
526
527 // Ready — render full-screen iframe.
528 if (env.status === "ready" && env.previewUrl) {
529 return c.html(
530 <Layout
531 title={`Dev — ${resolved.ownerName}/${resolved.repoName}`}
532 user={user}
533 >
534 <style dangerouslySetInnerHTML={{ __html: devEnvStyles }} />
535 <div class="dev-env-shell" role="region" aria-label="Cloud dev environment">
536 <div class="dev-env-shell-bar">
537 <span class="dev-env-shell-title">
538 <StatusPill status="ready" />
539 {resolved.ownerName}/{resolved.repoName}
540 </span>
541 <form
542 method="post"
543 action={`/api/v2/repos/${resolved.ownerName}/${resolved.repoName}/dev/stop`}
544 style="margin:0"
545 >
546 {csrf && <input type="hidden" name="_csrf" value={csrf} />}
547 <button type="submit" class="dev-env-cta-secondary">
548 Stop env
549 </button>
550 </form>
551 </div>
552 <iframe
553 class="dev-env-shell-iframe"
554 src={env.previewUrl}
555 title={`VS Code Server — ${resolved.ownerName}/${resolved.repoName}`}
556 allow="clipboard-read; clipboard-write; fullscreen"
557 />
558 </div>
559 </Layout>
560 );
561 }
562
563 // Warming / cold / failed / stopped — render status page with poll script.
564 const statusBody =
565 env.status === "failed" ? (
566 <>
567 <p class="dev-env-sub">
568 Something went wrong while spinning up the container. Hit{" "}
569 <strong>Retry</strong> below to start fresh.
570 </p>
571 {env.errorMessage && (
572 <div class="dev-env-error" role="alert">
573 {env.errorMessage}
574 </div>
575 )}
576 <form
577 method="post"
578 action={`/api/v2/repos/${resolved.ownerName}/${resolved.repoName}/dev/start`}
579 style="margin:0"
580 >
581 {csrf && <input type="hidden" name="_csrf" value={csrf} />}
582 <div class="dev-env-actions">
583 <button type="submit" class="dev-env-cta">
584 Retry &rarr;
585 </button>
586 <a
587 href={`/${resolved.ownerName}/${resolved.repoName}`}
588 class="dev-env-cta-secondary"
589 >
590 Back to repo
591 </a>
592 </div>
593 </form>
594 </>
595 ) : env.status === "stopped" ? (
596 <>
597 <p class="dev-env-sub">
598 Your environment is stopped. Click below to warm it back up — the
599 URL stays the same and your files persist.
600 </p>
601 <form
602 method="post"
603 action={`/api/v2/repos/${resolved.ownerName}/${resolved.repoName}/dev/start`}
604 style="margin:0"
605 >
606 {csrf && <input type="hidden" name="_csrf" value={csrf} />}
607 <div class="dev-env-actions">
608 <button type="submit" class="dev-env-cta">
609 Warm up &rarr;
610 </button>
611 </div>
612 </form>
613 </>
614 ) : (
615 <>
616 <p class="dev-env-sub">
617 Starting your dev env... we&apos;re pulling the image, installing
618 deps, and bringing VS Code Server online. Usually under a minute.
619 </p>
620 <div
621 class="dev-env-progress"
622 role="progressbar"
623 aria-label="Warming dev environment"
624 aria-valuetext="In progress"
625 >
626 <div class="dev-env-progress-bar" />
627 </div>
628 <noscript>
629 <p class="dev-env-sub">
630 Enable JavaScript for live updates, or refresh manually.
631 </p>
632 </noscript>
633 </>
634 );
635
636 const pollScript = `
637 (function(){
638 var url = "/api/v2/repos/${resolved.ownerName}/${resolved.repoName}/dev/status";
639 function tick(){
640 fetch(url, { credentials: "same-origin" })
641 .then(function(r){ return r.json(); })
642 .then(function(j){
643 if(j && j.env && (j.env.status === "ready" || j.env.status === "failed")){
644 window.location.reload();
645 }
646 })
647 .catch(function(){ /* ignore */ });
648 }
649 setInterval(tick, 2000);
650 })();
651 `;
652
653 return c.html(
654 <Layout
655 title={`Dev env — ${resolved.ownerName}/${resolved.repoName}`}
656 user={user}
657 >
658 <style dangerouslySetInnerHTML={{ __html: devEnvStyles }} />
659 <div class="dev-env-wrap">
660 <div class="dev-env-card">
661 <div class="dev-env-eyebrow">
662 <span class="dev-env-eyebrow-dot" aria-hidden="true" />
663 Cloud dev env
664 </div>
665 <h1 class="dev-env-title">
666 {resolved.ownerName}/{resolved.repoName}
667 </h1>
668 <StatusPill status={env.status} />
669 {statusBody}
670 <MetaGrid env={env} />
671 </div>
672 </div>
673 {env.status === "warming" || env.status === "cold" ? (
674 <script dangerouslySetInnerHTML={{ __html: pollScript }} />
675 ) : null}
676 </Layout>
677 );
678});
679
680// ---------------------------------------------------------------------------
681// API: POST /api/v2/repos/:owner/:repo/dev/start
682// ---------------------------------------------------------------------------
683
684devEnvRoutes.post(
685 "/api/v2/repos/:owner/:repo/dev/start",
686 softAuth,
687 requireAuth,
688 async (c) => {
689 const ownerName = c.req.param("owner");
690 const repoName = c.req.param("repo");
691 const user = c.get("user")!;
692 const resolved = await resolveRepoForUser(ownerName, repoName);
693 if (!resolved) return c.json({ ok: false, error: "Repo not found" }, 404);
694
695 // Parse machine_size from either form body or JSON body, leniently.
696 let machineSize: string | undefined;
697 try {
698 const ct = c.req.header("content-type") || "";
699 if (ct.includes("application/json")) {
700 const body = (await c.req.json().catch(() => null)) as
701 | { machineSize?: string; machine_size?: string }
702 | null;
703 machineSize = body?.machineSize ?? body?.machine_size;
704 } else {
705 const body = await c.req.parseBody().catch(() => ({}) as any);
706 machineSize = (body.machine_size || body.machineSize) as
707 | string
708 | undefined;
709 }
710 } catch {
711 /* ignore */
712 }
713
714 const result = await startDevEnv({
715 repositoryId: resolved.repoId,
716 ownerUserId: user.id,
717 machineSize: normalizeMachineSize(machineSize),
718 });
719
720 if (!result.ok) {
721 // For browser form posts, redirect back with an error in the URL;
722 // for API clients (Accept: application/json), return JSON.
723 const wantsJson =
724 (c.req.header("accept") || "").includes("application/json") ||
725 (c.req.header("content-type") || "").includes("application/json");
726 const status =
727 result.reason === "repo_not_found"
728 ? 404
729 : result.reason === "not_opted_in"
730 ? 403
731 : result.reason === "invalid_input"
732 ? 400
733 : 500;
734 if (wantsJson) {
735 return c.json({ ok: false, error: result.reason }, status);
736 }
737 return c.redirect(
738 `/${resolved.ownerName}/${resolved.repoName}/dev?error=${result.reason}`
739 );
740 }
741
742 const wantsJson =
743 (c.req.header("accept") || "").includes("application/json") ||
744 (c.req.header("content-type") || "").includes("application/json");
745 if (wantsJson) {
746 return c.json({ ok: true, env: jsonShape(result.env) });
747 }
748 return c.redirect(`/${resolved.ownerName}/${resolved.repoName}/dev`);
749 }
750);
751
752// ---------------------------------------------------------------------------
753// API: POST /api/v2/repos/:owner/:repo/dev/stop
754// ---------------------------------------------------------------------------
755
756devEnvRoutes.post(
757 "/api/v2/repos/:owner/:repo/dev/stop",
758 softAuth,
759 requireAuth,
760 async (c) => {
761 const ownerName = c.req.param("owner");
762 const repoName = c.req.param("repo");
763 const user = c.get("user")!;
764 const resolved = await resolveRepoForUser(ownerName, repoName);
765 if (!resolved) return c.json({ ok: false, error: "Repo not found" }, 404);
766
767 const env = await getDevEnvForOwner(resolved.repoId, user.id);
768 if (!env) {
769 const wantsJson =
770 (c.req.header("accept") || "").includes("application/json");
771 if (wantsJson) return c.json({ ok: true, env: null });
772 return c.redirect(`/${resolved.ownerName}/${resolved.repoName}/dev`);
773 }
774 await stopDevEnv(env.id);
775 const after = await getDevEnv(env.id);
776 const wantsJson = (c.req.header("accept") || "").includes(
777 "application/json"
778 );
779 if (wantsJson) return c.json({ ok: true, env: jsonShape(after) });
780 return c.redirect(`/${resolved.ownerName}/${resolved.repoName}/dev`);
781 }
782);
783
784// ---------------------------------------------------------------------------
785// API: GET /api/v2/repos/:owner/:repo/dev/status
786// ---------------------------------------------------------------------------
787
788devEnvRoutes.get(
789 "/api/v2/repos/:owner/:repo/dev/status",
790 softAuth,
791 requireAuth,
792 async (c) => {
793 const ownerName = c.req.param("owner");
794 const repoName = c.req.param("repo");
795 const user = c.get("user")!;
796 const resolved = await resolveRepoForUser(ownerName, repoName);
797 if (!resolved) return c.json({ ok: false, error: "Repo not found" }, 404);
798 const env = await getDevEnvForOwner(resolved.repoId, user.id);
799 if (env) {
800 void recordActivity(env.id);
801 }
802 return c.json({ ok: true, env: jsonShape(env) });
803 }
804);
805
806export default devEnvRoutes;
807
808// Re-export so tests can stub without the path string lottery.
809export type { DevEnvStatus };