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

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

deploy-targets.tsxBlame767 lines · 1 contributor
9aaa128Claude1/**
2 * Customer-facing deploy targets — /settings/deploy-targets
3 *
4 * Lets any authenticated user manage SSH deploy targets for their own repos.
5 * Private keys are encrypted at rest with AES-256-GCM using SERVER_TARGETS_KEY
6 * (same scheme as the admin surface in admin-server-targets.tsx).
7 *
8 * GET /settings/deploy-targets — list user's own targets
9 * POST /settings/deploy-targets — create a new target
10 * POST /settings/deploy-targets/:id/delete — delete (owner-only)
11 * POST /settings/deploy-targets/:id/test — test SSH connectivity
12 */
13
14import { Hono } from "hono";
15import { and, desc, eq } from "drizzle-orm";
16import { db } from "../db";
17import { serverTargets } from "../db/schema";
18import { Layout } from "../views/layout";
19import type { AuthEnv } from "../middleware/auth";
20import { requireAuth } from "../middleware/auth";
21import {
22 createTarget,
23 deleteTarget,
24 recordPin,
25} from "../lib/server-target-store";
26import { testConnection } from "../lib/server-targets";
27import { getMasterKey } from "../lib/server-targets-crypto";
28
29const deployTargets = new Hono<AuthEnv>();
30
31deployTargets.use("/settings/deploy-targets*", requireAuth);
32
33// ─── Scoped styles ────────────────────────────────────────────────────────────
34
35const styles = `
36 .dt-wrap {
37 max-width: 960px;
38 margin: 0 auto;
39 padding: var(--space-6) var(--space-4);
40 }
41
42 /* ─── Hero ─── */
43 .dt-hero {
44 position: relative;
45 margin-bottom: var(--space-6);
46 padding: var(--space-5) var(--space-6);
47 background: var(--bg-elevated);
48 border: 1px solid var(--border);
49 border-radius: 16px;
50 overflow: hidden;
51 }
52 .dt-hero::before {
53 content: '';
54 position: absolute;
55 top: 0; left: 0; right: 0;
56 height: 2px;
6fd5915Claude57 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
9aaa128Claude58 opacity: 0.7;
59 pointer-events: none;
60 }
61 .dt-hero-orb {
62 position: absolute;
63 inset: -20% -10% auto auto;
64 width: 360px; height: 360px;
6fd5915Claude65 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
9aaa128Claude66 filter: blur(80px);
67 opacity: 0.65;
68 pointer-events: none;
69 z-index: 0;
70 }
71 .dt-hero-inner {
72 position: relative;
73 z-index: 1;
74 max-width: 640px;
75 }
76 .dt-hero-eyebrow {
77 font-size: 13px;
78 color: var(--text-muted);
79 margin-bottom: var(--space-2);
80 }
81 .dt-hero-title {
82 font-size: clamp(26px, 3.5vw, 36px);
83 font-family: var(--font-display);
84 font-weight: 800;
85 letter-spacing: -0.028em;
86 line-height: 1.05;
87 margin: 0 0 var(--space-2);
88 color: var(--text-strong);
89 }
90 .dt-hero-title .gradient-text {
6fd5915Claude91 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
9aaa128Claude92 -webkit-background-clip: text;
93 background-clip: text;
94 -webkit-text-fill-color: transparent;
95 color: transparent;
96 }
97 .dt-hero-sub {
98 font-size: 14px;
99 color: var(--text-muted);
100 margin: 0;
101 line-height: 1.5;
102 }
103
104 /* ─── Subnav ─── */
105 .dt-subnav {
106 display: flex;
107 gap: 4px;
108 flex-wrap: wrap;
109 margin-bottom: var(--space-5);
110 padding: 4px;
111 background: var(--bg-elevated);
112 border: 1px solid var(--border);
113 border-radius: 9999px;
114 width: fit-content;
115 max-width: 100%;
116 overflow-x: auto;
117 }
118 .dt-subnav a {
119 display: inline-flex;
120 align-items: center;
121 gap: 6px;
122 padding: 6px 14px;
123 font-size: 13px;
124 font-weight: 500;
125 color: var(--text-muted);
126 border-radius: 9999px;
127 text-decoration: none;
128 white-space: nowrap;
129 transition: all 120ms ease;
130 }
131 .dt-subnav a:hover {
132 color: var(--text-strong);
133 background: var(--bg-hover);
134 }
135 .dt-subnav a.is-active {
136 color: var(--text-strong);
6fd5915Claude137 background: rgba(91,110,232,0.16);
138 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
9aaa128Claude139 }
140
141 /* ─── Banners ─── */
142 .dt-banner {
143 display: flex;
144 align-items: center;
145 gap: 10px;
146 padding: 12px 16px;
147 border-radius: 12px;
148 font-size: 13.5px;
149 margin-bottom: var(--space-4);
150 line-height: 1.5;
151 }
152 .dt-banner-success {
153 background: rgba(52,211,153,0.08);
154 color: #6ee7b7;
155 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
156 }
157 .dt-banner-error {
158 background: rgba(248,113,113,0.08);
159 color: #fca5a5;
160 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.30);
161 }
162 .dt-banner-icon {
163 width: 18px; height: 18px;
164 border-radius: 9999px;
165 flex-shrink: 0;
166 display: inline-flex;
167 align-items: center;
168 justify-content: center;
169 font-size: 12px;
170 font-weight: 700;
171 }
172 .dt-banner-success .dt-banner-icon { background: rgba(52,211,153,0.18); color: #34d399; }
173 .dt-banner-error .dt-banner-icon { background: rgba(248,113,113,0.18); color: #f87171; }
174
175 /* ─── Section cards ─── */
176 .dt-section {
177 background: var(--bg-elevated);
178 border: 1px solid var(--border);
179 border-radius: 14px;
180 margin-bottom: var(--space-5);
181 overflow: hidden;
182 }
183 .dt-section-head {
184 padding: var(--space-4) var(--space-5) var(--space-3);
185 border-bottom: 1px solid var(--border);
186 }
187 .dt-section-eyebrow {
188 font-size: 11px;
189 font-weight: 600;
190 letter-spacing: 0.08em;
191 text-transform: uppercase;
192 color: var(--accent);
193 margin-bottom: 6px;
194 }
195 .dt-section-title {
196 font-family: var(--font-display);
197 font-size: 18px;
198 font-weight: 700;
199 letter-spacing: -0.018em;
200 margin: 0 0 4px;
201 color: var(--text-strong);
202 }
203 .dt-section-desc {
204 font-size: 13.5px;
205 color: var(--text-muted);
206 margin: 0;
207 line-height: 1.5;
208 }
209 .dt-section-body { padding: var(--space-4) var(--space-5); }
210 .dt-section-foot {
211 padding: var(--space-3) var(--space-5);
212 border-top: 1px solid var(--border);
213 background: rgba(255,255,255,0.012);
214 display: flex;
215 justify-content: flex-end;
216 gap: var(--space-2);
217 align-items: center;
218 flex-wrap: wrap;
219 }
220
221 /* ─── Form fields ─── */
222 .dt-field { margin-bottom: var(--space-4); }
223 .dt-field:last-child { margin-bottom: 0; }
224 .dt-field-label {
225 display: block;
226 font-size: 13px;
227 font-weight: 600;
228 color: var(--text-strong);
229 margin-bottom: 6px;
230 letter-spacing: -0.005em;
231 }
232 .dt-field-hint {
233 font-size: 12.5px;
234 color: var(--text-muted);
235 margin-top: 6px;
236 line-height: 1.45;
237 }
238 .dt-input,
239 .dt-textarea {
240 width: 100%;
241 padding: 9px 12px;
242 font-size: 14px;
243 color: var(--text);
244 background: var(--bg);
245 border: 1px solid var(--border-strong);
246 border-radius: 8px;
247 outline: none;
248 transition: border-color 120ms ease, box-shadow 120ms ease;
249 font-family: var(--font-sans);
250 box-sizing: border-box;
251 }
252 .dt-textarea {
253 font-family: var(--font-mono);
254 font-size: 12.5px;
255 line-height: 1.5;
256 resize: vertical;
257 }
258 .dt-input:focus,
259 .dt-textarea:focus {
260 border-color: var(--border-focus);
6fd5915Claude261 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
9aaa128Claude262 }
263 .dt-row-2 {
264 display: grid;
265 grid-template-columns: 1fr 120px;
266 gap: var(--space-3);
267 }
268 @media (max-width: 560px) {
269 .dt-row-2 { grid-template-columns: 1fr; }
270 }
271
272 /* ─── Target cards ─── */
273 .dt-target-card {
274 display: flex;
275 justify-content: space-between;
276 align-items: flex-start;
277 gap: var(--space-3);
278 padding: 14px 16px;
279 border: 1px solid var(--border);
280 border-radius: 12px;
281 background: var(--bg-secondary);
282 margin-bottom: 10px;
283 transition: border-color 120ms ease, background 120ms ease;
284 }
285 .dt-target-card:last-child { margin-bottom: 0; }
286 .dt-target-card:hover {
287 border-color: var(--border-strong);
288 background: rgba(255,255,255,0.018);
289 }
290 .dt-target-name {
291 font-size: 14px;
292 font-weight: 600;
293 color: var(--text-strong);
294 margin: 0 0 4px;
295 }
296 .dt-target-host {
297 display: inline-block;
298 font-family: var(--font-mono);
299 font-size: 12px;
300 color: var(--text-muted);
301 background: var(--bg-tertiary);
302 padding: 2px 8px;
303 border-radius: 6px;
304 }
305 .dt-target-meta {
306 margin-top: 6px;
307 font-size: 12.5px;
308 color: var(--text-muted);
309 display: flex;
310 gap: 10px;
311 flex-wrap: wrap;
312 align-items: center;
313 }
314 .dt-status-pill {
315 display: inline-flex;
316 align-items: center;
317 gap: 4px;
318 padding: 2px 8px;
319 border-radius: 9999px;
320 font-size: 11px;
321 font-weight: 600;
322 letter-spacing: 0.02em;
323 }
324 .dt-status-verified {
325 background: rgba(52,211,153,0.12);
326 color: #6ee7b7;
327 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.28);
328 }
329 .dt-status-unverified {
330 background: rgba(234,179,8,0.10);
331 color: #fde047;
332 box-shadow: inset 0 0 0 1px rgba(234,179,8,0.24);
333 }
334 .dt-target-actions {
335 display: flex;
336 gap: 6px;
337 flex-shrink: 0;
338 flex-wrap: wrap;
339 align-items: flex-start;
340 }
341 .dt-empty {
342 padding: var(--space-5);
343 text-align: center;
344 border: 1px dashed var(--border);
345 border-radius: 12px;
346 background: var(--bg-secondary);
347 color: var(--text-muted);
348 font-size: 13.5px;
349 }
350
351 /* ─── Warning banner (missing key) ─── */
352 .dt-warn {
353 padding: 12px 16px;
354 border-radius: 10px;
355 background: rgba(234,179,8,0.08);
356 color: #fde047;
357 box-shadow: inset 0 0 0 1px rgba(234,179,8,0.24);
358 font-size: 13.5px;
359 margin-bottom: var(--space-4);
360 display: flex;
361 gap: 10px;
362 align-items: center;
363 }
364
365 @media (max-width: 640px) {
366 .dt-target-card { flex-direction: column; }
367 .dt-target-actions { flex-direction: row; }
368 }
369`;
370
371// ─── Shared subnav (mirrors settings.tsx pattern) ────────────────────────────
372
373function DeployTargetsSubnav() {
374 return (
375 <nav class="dt-subnav" aria-label="Settings sections">
376 <a href="/settings">Profile</a>
377 <a href="/settings/keys">SSH keys</a>
378 <a href="/settings/agents">Agents</a>
379 <a href="/settings/deploy-targets" class="is-active" aria-current="page">
380 Deploy targets
381 </a>
382 </nav>
383 );
384}
385
386function Banner(props: { kind: "success" | "error"; text: string }) {
387 return (
388 <div class={`dt-banner dt-banner-${props.kind}`} role="status">
389 <span class="dt-banner-icon" aria-hidden="true">
390 {props.kind === "success" ? "✓" : "!"}
391 </span>
392 <span>{props.text}</span>
393 </div>
394 );
395}
396
397// ─── GET /settings/deploy-targets ────────────────────────────────────────────
398
399deployTargets.get("/settings/deploy-targets", async (c) => {
400 const user = c.get("user")!;
401 const ok = c.req.query("ok") ?? undefined;
402 const err = c.req.query("err") ?? undefined;
403
404 // Fetch only targets belonging to this user
405 const targets = await db
406 .select()
407 .from(serverTargets)
408 .where(eq(serverTargets.createdBy, user.id))
409 .orderBy(desc(serverTargets.createdAt));
410
411 const keyConfigured = getMasterKey() !== null;
412
413 return c.html(
414 <Layout title="Deploy targets — settings" user={user}>
415 <style dangerouslySetInnerHTML={{ __html: styles }} />
416 <div class="dt-wrap">
417 {/* ─── Hero ─── */}
418 <div class="dt-hero">
419 <div class="dt-hero-orb" aria-hidden="true" />
420 <div class="dt-hero-inner">
421 <div class="dt-hero-eyebrow">
422 Your account · <span style="color:var(--accent);font-weight:600">{user.username}</span>
423 </div>
424 <h1 class="dt-hero-title">
425 Deploy <span class="gradient-text">targets</span>.
426 </h1>
427 <p class="dt-hero-sub">
428 SSH boxes that Gluecron can deploy to. Private keys are encrypted
429 at rest and never displayed again after saving.
430 </p>
431 </div>
432 </div>
433
434 <DeployTargetsSubnav />
435
436 {!keyConfigured && (
437 <div class="dt-warn" role="alert">
438 <span aria-hidden="true">⚠</span>
439 <span>
440 <strong>SERVER_TARGETS_KEY not set.</strong> Deploy targets require
441 this environment variable (a 64-character hex string / 32-byte AES key)
442 to encrypt private keys. Contact your administrator.
443 </span>
444 </div>
445 )}
446
447 {ok && <Banner kind="success" text={decodeURIComponent(ok)} />}
448 {err && <Banner kind="error" text={decodeURIComponent(err)} />}
449
450 {/* ─── Existing targets ─── */}
451 <section class="dt-section">
452 <div class="dt-section-head">
453 <div class="dt-section-eyebrow">SSH deploy targets</div>
454 <h2 class="dt-section-title">Your targets</h2>
455 <p class="dt-section-desc">
456 Boxes registered to your account. Each target can be linked to a
457 repo + branch so pushes trigger deploys automatically.
458 </p>
459 </div>
460 <div class="dt-section-body">
461 {targets.length === 0 ? (
462 <div class="dt-empty">
463 No deploy targets yet. Add your first box below.
464 </div>
465 ) : (
466 targets.map((t) => (
467 <div class="dt-target-card">
468 <div>
469 <div class="dt-target-name">{t.name}</div>
470 <code class="dt-target-host">
471 {t.sshUser}@{t.host}:{t.port}
472 </code>
473 <div class="dt-target-meta">
474 <span
475 class={
476 "dt-status-pill " +
477 (t.status === "verified"
478 ? "dt-status-verified"
479 : "dt-status-unverified")
480 }
481 >
482 {t.status}
483 </span>
484 {t.deployPath && (
485 <span style="font-family:var(--font-mono);font-size:12px">
486 {t.deployPath}
487 </span>
488 )}
489 {t.createdAt && (
490 <span>
491 Added {t.createdAt.toLocaleDateString()}
492 </span>
493 )}
494 </div>
495 </div>
496 <div class="dt-target-actions">
497 <form
498 method="post"
499 action={`/settings/deploy-targets/${t.id}/test`}
500 style="display:inline"
501 >
502 <button
503 type="submit"
504 class="btn btn-sm"
505 title="Test SSH connection"
506 >
507 Test
508 </button>
509 </form>
510 <form
511 method="post"
512 action={`/settings/deploy-targets/${t.id}/delete`}
513 style="display:inline"
514 onsubmit="return confirm('Delete this deploy target?')"
515 >
516 <button type="submit" class="btn btn-sm btn-danger">
517 Delete
518 </button>
519 </form>
520 </div>
521 </div>
522 ))
523 )}
524 </div>
525 </section>
526
527 {/* ─── Add new target form ─── */}
528 <section class="dt-section">
529 <div class="dt-section-head">
530 <div class="dt-section-eyebrow">Add target</div>
531 <h2 class="dt-section-title">New deploy target</h2>
532 <p class="dt-section-desc">
533 Enter your server's SSH credentials. The private key is encrypted
534 immediately and never stored in plaintext.
535 </p>
536 </div>
537 <form method="post" action="/settings/deploy-targets">
538 <div class="dt-section-body">
539 <div class="dt-field">
540 <label class="dt-field-label" for="dt-name">
541 Name
542 </label>
543 <input
544 class="dt-input"
545 id="dt-name"
546 name="name"
547 required
548 pattern="[a-z0-9-]+"
549 placeholder="my-prod-server"
550 autocomplete="off"
551 />
552 <div class="dt-field-hint">
553 Lowercase letters, numbers and hyphens only. Must be unique
554 across all targets on the platform.
555 </div>
556 </div>
557
558 <div class="dt-row-2">
559 <div class="dt-field">
560 <label class="dt-field-label" for="dt-host">Host</label>
561 <input
562 class="dt-input"
563 id="dt-host"
564 name="host"
565 required
566 placeholder="1.2.3.4 or example.com"
567 autocomplete="off"
568 />
569 </div>
570 <div class="dt-field">
571 <label class="dt-field-label" for="dt-port">Port</label>
572 <input
573 class="dt-input"
574 id="dt-port"
575 name="port"
576 type="number"
577 value="22"
578 min="1"
579 max="65535"
580 />
581 </div>
582 </div>
583
584 <div class="dt-field">
585 <label class="dt-field-label" for="dt-ssh-user">
586 SSH username
587 </label>
588 <input
589 class="dt-input"
590 id="dt-ssh-user"
591 name="ssh_user"
592 required
593 placeholder="deploy"
594 autocomplete="off"
595 />
596 </div>
597
598 <div class="dt-field">
599 <label class="dt-field-label" for="dt-private-key">
600 SSH private key
601 </label>
602 <textarea
603 class="dt-textarea"
604 id="dt-private-key"
605 name="private_key"
606 required
607 rows={8}
608 placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----"
609 />
610 <div class="dt-field-hint">
611 Paste your OpenSSH PEM private key. It is encrypted with
612 AES-256-GCM before being stored and will never be displayed
613 again after saving.
614 </div>
615 </div>
616
617 <div class="dt-field">
618 <label class="dt-field-label" for="dt-deploy-path">
619 Deploy path
620 </label>
621 <input
622 class="dt-input"
623 id="dt-deploy-path"
624 name="deploy_path"
625 placeholder="/var/www/app"
626 value="/var/www/app"
627 />
628 <div class="dt-field-hint">
629 Absolute path on the remote server where your app lives.
630 </div>
631 </div>
632 </div>
633 <div class="dt-section-foot">
634 <button
635 type="submit"
636 class="btn btn-primary"
637 disabled={!keyConfigured}
638 >
639 Add deploy target
640 </button>
641 </div>
642 </form>
643 </section>
644 </div>
645 </Layout>
646 );
647});
648
649// ─── POST /settings/deploy-targets ───────────────────────────────────────────
650
651deployTargets.post("/settings/deploy-targets", async (c) => {
652 const user = c.get("user")!;
653
654 if (getMasterKey() === null) {
655 return c.redirect(
656 "/settings/deploy-targets?err=SERVER_TARGETS_KEY+not+configured"
657 );
658 }
659
660 const form = await c.req.parseBody();
661 const name = String(form.name || "").trim();
662 const host = String(form.host || "").trim();
663 const port = Number(form.port || 22);
664 const sshUser = String(form.ssh_user || "").trim();
665 const privateKey = String(form.private_key || "");
666 const deployPath = String(form.deploy_path || "/var/www/app").trim();
667
668 if (!name || !host || !sshUser || !privateKey) {
669 return c.redirect(
670 "/settings/deploy-targets?err=Name%2C+host%2C+SSH+user+and+private+key+are+required"
671 );
672 }
673 if (!/^[a-z0-9-]+$/.test(name)) {
674 return c.redirect(
675 "/settings/deploy-targets?err=Name+must+be+lowercase+letters%2C+numbers+and+hyphens+only"
676 );
677 }
678 if (!privateKey.includes("PRIVATE KEY")) {
679 return c.redirect(
680 "/settings/deploy-targets?err=Private+key+does+not+look+like+a+valid+OpenSSH+PEM+key"
681 );
682 }
683
684 const out = await createTarget({
685 name,
686 host,
687 port: isNaN(port) ? 22 : port,
688 sshUser,
689 privateKey,
690 deployPath: deployPath || "/var/www/app",
691 deployScript: "bash deploy.sh",
692 watchedRepositoryId: null,
693 watchedBranch: null,
694 createdBy: user.id,
695 });
696
697 if (!out.ok) {
698 return c.redirect(
699 `/settings/deploy-targets?err=${encodeURIComponent(out.error)}`
700 );
701 }
702
703 return c.redirect(
704 `/settings/deploy-targets?ok=Deploy+target+%22${encodeURIComponent(name)}%22+created`
705 );
706});
707
708// ─── POST /settings/deploy-targets/:id/delete ────────────────────────────────
709
710deployTargets.post("/settings/deploy-targets/:id/delete", async (c) => {
711 const user = c.get("user")!;
712 const id = c.req.param("id");
713
714 // Verify ownership before deleting
715 const [target] = await db
716 .select()
717 .from(serverTargets)
718 .where(and(eq(serverTargets.id, id), eq(serverTargets.createdBy, user.id)))
719 .limit(1);
720
721 if (!target) {
722 return c.redirect(
723 "/settings/deploy-targets?err=Target+not+found+or+not+owned+by+you"
724 );
725 }
726
727 await deleteTarget(id, user.id);
728 return c.redirect(
729 `/settings/deploy-targets?ok=Target+%22${encodeURIComponent(target.name)}%22+deleted`
730 );
731});
732
733// ─── POST /settings/deploy-targets/:id/test ──────────────────────────────────
734
735deployTargets.post("/settings/deploy-targets/:id/test", async (c) => {
736 const user = c.get("user")!;
737 const id = c.req.param("id");
738
739 // Verify ownership
740 const [target] = await db
741 .select()
742 .from(serverTargets)
743 .where(and(eq(serverTargets.id, id), eq(serverTargets.createdBy, user.id)))
744 .limit(1);
745
746 if (!target) {
747 return c.redirect(
748 "/settings/deploy-targets?err=Target+not+found+or+not+owned+by+you"
749 );
750 }
751
752 const result = await testConnection(target);
753 if (result.ok) {
754 await recordPin(id, result.fingerprint, user.id);
755 return c.redirect(
756 `/settings/deploy-targets?ok=Connection+to+%22${encodeURIComponent(target.name)}%22+verified`
757 );
758 }
759
760 return c.redirect(
761 `/settings/deploy-targets?err=${encodeURIComponent(
762 `${result.stage}: ${result.error}`
763 )}`
764 );
765});
766
767export default deployTargets;