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