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.tsxBlame772 lines · 3 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";
ae2a071ccanty labs19import { SettingsSubnav } from "./settings";
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 }} />
ae2a071ccanty labs420 <SettingsSubnav active="deploy-targets" />
9aaa128Claude421 <div class="dt-wrap">
422 {/* ─── Hero ─── */}
423 <div class="dt-hero">
424 <div class="dt-hero-orb" aria-hidden="true" />
425 <div class="dt-hero-inner">
426 <div class="dt-hero-eyebrow">
427 Your account · <span style="color:var(--accent);font-weight:600">{user.username}</span>
428 </div>
429 <h1 class="dt-hero-title">
430 Deploy <span class="gradient-text">targets</span>.
431 </h1>
432 <p class="dt-hero-sub">
433 SSH boxes that Gluecron can deploy to. Private keys are encrypted
434 at rest and never displayed again after saving.
435 </p>
436 </div>
437 </div>
438
439 <DeployTargetsSubnav />
440
441 {!keyConfigured && (
442 <div class="dt-warn" role="alert">
443 <span aria-hidden="true">⚠</span>
444 <span>
445 <strong>SERVER_TARGETS_KEY not set.</strong> Deploy targets require
446 this environment variable (a 64-character hex string / 32-byte AES key)
447 to encrypt private keys. Contact your administrator.
448 </span>
449 </div>
450 )}
451
452 {ok && <Banner kind="success" text={decodeURIComponent(ok)} />}
453 {err && <Banner kind="error" text={decodeURIComponent(err)} />}
454
455 {/* ─── Existing targets ─── */}
456 <section class="dt-section">
457 <div class="dt-section-head">
458 <div class="dt-section-eyebrow">SSH deploy targets</div>
459 <h2 class="dt-section-title">Your targets</h2>
460 <p class="dt-section-desc">
461 Boxes registered to your account. Each target can be linked to a
462 repo + branch so pushes trigger deploys automatically.
463 </p>
464 </div>
465 <div class="dt-section-body">
466 {targets.length === 0 ? (
467 <div class="dt-empty">
468 No deploy targets yet. Add your first box below.
469 </div>
470 ) : (
471 targets.map((t) => (
472 <div class="dt-target-card">
473 <div>
474 <div class="dt-target-name">{t.name}</div>
475 <code class="dt-target-host">
476 {t.sshUser}@{t.host}:{t.port}
477 </code>
478 <div class="dt-target-meta">
479 <span
480 class={
481 "dt-status-pill " +
482 (t.status === "verified"
483 ? "dt-status-verified"
484 : "dt-status-unverified")
485 }
486 >
487 {t.status}
488 </span>
489 {t.deployPath && (
490 <span style="font-family:var(--font-mono);font-size:12px">
491 {t.deployPath}
492 </span>
493 )}
494 {t.createdAt && (
495 <span>
496 Added {t.createdAt.toLocaleDateString()}
497 </span>
498 )}
499 </div>
500 </div>
501 <div class="dt-target-actions">
502 <form
503 method="post"
504 action={`/settings/deploy-targets/${t.id}/test`}
505 style="display:inline"
506 >
507 <button
508 type="submit"
509 class="btn btn-sm"
510 title="Test SSH connection"
511 >
512 Test
513 </button>
514 </form>
515 <form
516 method="post"
517 action={`/settings/deploy-targets/${t.id}/delete`}
518 style="display:inline"
519 onsubmit="return confirm('Delete this deploy target?')"
520 >
521 <button type="submit" class="btn btn-sm btn-danger">
522 Delete
523 </button>
524 </form>
525 </div>
526 </div>
527 ))
528 )}
529 </div>
530 </section>
531
532 {/* ─── Add new target form ─── */}
533 <section class="dt-section">
534 <div class="dt-section-head">
535 <div class="dt-section-eyebrow">Add target</div>
536 <h2 class="dt-section-title">New deploy target</h2>
537 <p class="dt-section-desc">
538 Enter your server's SSH credentials. The private key is encrypted
539 immediately and never stored in plaintext.
540 </p>
541 </div>
542 <form method="post" action="/settings/deploy-targets">
543 <div class="dt-section-body">
544 <div class="dt-field">
545 <label class="dt-field-label" for="dt-name">
546 Name
547 </label>
548 <input
549 class="dt-input"
550 id="dt-name"
551 name="name"
552 required
553 pattern="[a-z0-9-]+"
554 placeholder="my-prod-server"
555 autocomplete="off"
556 />
557 <div class="dt-field-hint">
558 Lowercase letters, numbers and hyphens only. Must be unique
559 across all targets on the platform.
560 </div>
561 </div>
562
563 <div class="dt-row-2">
564 <div class="dt-field">
565 <label class="dt-field-label" for="dt-host">Host</label>
566 <input
567 class="dt-input"
568 id="dt-host"
569 name="host"
570 required
571 placeholder="1.2.3.4 or example.com"
572 autocomplete="off"
573 />
574 </div>
575 <div class="dt-field">
576 <label class="dt-field-label" for="dt-port">Port</label>
577 <input
578 class="dt-input"
579 id="dt-port"
580 name="port"
581 type="number"
582 value="22"
583 min="1"
584 max="65535"
585 />
586 </div>
587 </div>
588
589 <div class="dt-field">
590 <label class="dt-field-label" for="dt-ssh-user">
591 SSH username
592 </label>
593 <input
594 class="dt-input"
595 id="dt-ssh-user"
596 name="ssh_user"
597 required
598 placeholder="deploy"
599 autocomplete="off"
600 />
601 </div>
602
603 <div class="dt-field">
604 <label class="dt-field-label" for="dt-private-key">
605 SSH private key
606 </label>
607 <textarea
608 class="dt-textarea"
609 id="dt-private-key"
610 name="private_key"
611 required
612 rows={8}
613 placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----"
614 />
615 <div class="dt-field-hint">
616 Paste your OpenSSH PEM private key. It is encrypted with
617 AES-256-GCM before being stored and will never be displayed
618 again after saving.
619 </div>
620 </div>
621
622 <div class="dt-field">
623 <label class="dt-field-label" for="dt-deploy-path">
624 Deploy path
625 </label>
626 <input
627 class="dt-input"
628 id="dt-deploy-path"
629 name="deploy_path"
630 placeholder="/var/www/app"
631 value="/var/www/app"
632 />
633 <div class="dt-field-hint">
634 Absolute path on the remote server where your app lives.
635 </div>
636 </div>
637 </div>
638 <div class="dt-section-foot">
639 <button
640 type="submit"
641 class="btn btn-primary"
642 disabled={!keyConfigured}
643 >
644 Add deploy target
645 </button>
646 </div>
647 </form>
648 </section>
649 </div>
650 </Layout>
651 );
652});
653
654// ─── POST /settings/deploy-targets ───────────────────────────────────────────
655
656deployTargets.post("/settings/deploy-targets", async (c) => {
657 const user = c.get("user")!;
658
659 if (getMasterKey() === null) {
660 return c.redirect(
661 "/settings/deploy-targets?err=SERVER_TARGETS_KEY+not+configured"
662 );
663 }
664
665 const form = await c.req.parseBody();
666 const name = String(form.name || "").trim();
667 const host = String(form.host || "").trim();
668 const port = Number(form.port || 22);
669 const sshUser = String(form.ssh_user || "").trim();
670 const privateKey = String(form.private_key || "");
671 const deployPath = String(form.deploy_path || "/var/www/app").trim();
672
673 if (!name || !host || !sshUser || !privateKey) {
674 return c.redirect(
675 "/settings/deploy-targets?err=Name%2C+host%2C+SSH+user+and+private+key+are+required"
676 );
677 }
678 if (!/^[a-z0-9-]+$/.test(name)) {
679 return c.redirect(
680 "/settings/deploy-targets?err=Name+must+be+lowercase+letters%2C+numbers+and+hyphens+only"
681 );
682 }
683 if (!privateKey.includes("PRIVATE KEY")) {
684 return c.redirect(
685 "/settings/deploy-targets?err=Private+key+does+not+look+like+a+valid+OpenSSH+PEM+key"
686 );
687 }
688
689 const out = await createTarget({
690 name,
691 host,
692 port: isNaN(port) ? 22 : port,
693 sshUser,
694 privateKey,
695 deployPath: deployPath || "/var/www/app",
696 deployScript: "bash deploy.sh",
697 watchedRepositoryId: null,
698 watchedBranch: null,
699 createdBy: user.id,
700 });
701
702 if (!out.ok) {
703 return c.redirect(
704 `/settings/deploy-targets?err=${encodeURIComponent(out.error)}`
705 );
706 }
707
708 return c.redirect(
709 `/settings/deploy-targets?ok=Deploy+target+%22${encodeURIComponent(name)}%22+created`
710 );
711});
712
713// ─── POST /settings/deploy-targets/:id/delete ────────────────────────────────
714
715deployTargets.post("/settings/deploy-targets/:id/delete", async (c) => {
716 const user = c.get("user")!;
717 const id = c.req.param("id");
718
719 // Verify ownership before deleting
720 const [target] = await db
721 .select()
722 .from(serverTargets)
723 .where(and(eq(serverTargets.id, id), eq(serverTargets.createdBy, user.id)))
724 .limit(1);
725
726 if (!target) {
727 return c.redirect(
728 "/settings/deploy-targets?err=Target+not+found+or+not+owned+by+you"
729 );
730 }
731
732 await deleteTarget(id, user.id);
733 return c.redirect(
734 `/settings/deploy-targets?ok=Target+%22${encodeURIComponent(target.name)}%22+deleted`
735 );
736});
737
738// ─── POST /settings/deploy-targets/:id/test ──────────────────────────────────
739
740deployTargets.post("/settings/deploy-targets/:id/test", async (c) => {
741 const user = c.get("user")!;
742 const id = c.req.param("id");
743
744 // Verify ownership
745 const [target] = await db
746 .select()
747 .from(serverTargets)
748 .where(and(eq(serverTargets.id, id), eq(serverTargets.createdBy, user.id)))
749 .limit(1);
750
751 if (!target) {
752 return c.redirect(
753 "/settings/deploy-targets?err=Target+not+found+or+not+owned+by+you"
754 );
755 }
756
757 const result = await testConnection(target);
758 if (result.ok) {
759 await recordPin(id, result.fingerprint, user.id);
760 return c.redirect(
761 `/settings/deploy-targets?ok=Connection+to+%22${encodeURIComponent(target.name)}%22+verified`
762 );
763 }
764
765 return c.redirect(
766 `/settings/deploy-targets?err=${encodeURIComponent(
767 `${result.stage}: ${result.error}`
768 )}`
769 );
770});
771
772export default deployTargets;