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