1import { useEffect, useRef, useState } from "react";
2import { Line, sleep, TermChrome, useSpinner } from "./_terminal";
3
4const ACTION_ICON: Record<string, string> = {
5 create: "+",
6 update: "~",
7 delete: "-",
8 replace: "!",
9 noop: "•",
10};
11const ACTION_COLOR: Record<string, string> = {
12 create: "var(--alc-success)",
13 update: "var(--alc-warn)",
14 delete: "var(--alc-danger)",
15 replace: "#c4729a",
16};
17const MODE_ACCENT: Record<string, string> = {
18 idle: "var(--alc-code-comment)",
19 plan: "var(--alc-code-type)",
20 deploy: "var(--alc-accent-bright)",
21 destroy: "var(--alc-danger)",
22};
23const MODE_LABEL: Record<string, string> = {
24 plan: "PLAN",
25 deploy: "DEPLOY",
26 destroy: "DESTROY",
27};
28
29interface Resource {
30 id: string;
31 type: string;
32 bindings: string[];
33}
34const RESOURCES: Resource[] = [
35 { id: "Sessions", type: "Cloudflare.KV.Namespace", bindings: [] },
36 { id: "Api", type: "Cloudflare.Worker", bindings: ["Sessions"] },
37];
38
39type RowStatus = "ready" | "creating" | "deleting" | "created" | "deleted";
40interface Row extends Resource {
41 action: string;
42 status: RowStatus;
43}
44
45export default function HeroDeployTerminal({
46 title = "~/my-app",
47 bare,
48}: {
49 title?: string;
50 bare?: boolean;
51}) {
52 const [mode, setMode] = useState<"idle" | "plan" | "deploy" | "destroy">(
53 "idle",
54 );
55 const [cmd, setCmd] = useState("");
56 const [caret, setCaret] = useState(false);
57 const [header, setHeader] = useState<{
58 verb: string;
59 count: number;
60 action: string;
61 } | null>(null);
62 const [rows, setRows] = useState<Row[]>([]);
63 const [summary, setSummary] = useState<{
64 verb: string;
65 secs: string;
66 url?: string;
67 } | null>(null);
68 const [proceed, setProceed] = useState<null | "show" | "confirmed">(null);
69
70 const cancelRef = useRef(false);
71
72 useEffect(() => {
73 cancelRef.current = false;
74 const aborted = () => cancelRef.current;
75
76 const typeCmd = async (text: string) => {
77 setCmd("");
78 setCaret(true);
79 for (let i = 1; i <= text.length; i++) {
80 if (aborted()) return;
81 setCmd(text.slice(0, i));
82 await sleep(36 + Math.random() * 24);
83 }
84 await sleep(180);
85 setCaret(false);
86 };
87
88 const updateRow = (id: string, patch: Partial<Row>) =>
89 setRows((rs) => rs.map((r) => (r.id === id ? { ...r, ...patch } : r)));
90
91 const setAllRowsAction = (action: string, status: RowStatus = "ready") =>
92 setRows((rs) => rs.map((r) => ({ ...r, action, status })));
93
94 const revealRows = async (action: string, perRowMs = 130) => {
95 setRows([]);
96 for (const r of RESOURCES) {
97 if (aborted()) return;
98 setRows((rs) => [
99 ...rs,
100 { ...r, action, status: "ready" as RowStatus },
101 ]);
102 await sleep(perRowMs);
103 }
104 };
105
106 const startResource = async (id: string, status: RowStatus, ms: number) => {
107 if (aborted()) return;
108 updateRow(id, { status });
109 await sleep(ms);
110 if (aborted()) return;
111 const done: RowStatus = status === "creating" ? "created" : "deleted";
112 updateRow(id, { status: done });
113 };
114
115 const run = async () => {
116 while (!aborted()) {
117 setMode("plan");
118 setHeader(null);
119 setRows([]);
120 setSummary(null);
121 setProceed(null);
122 await typeCmd("alchemy plan");
123 if (aborted()) return;
124 await sleep(250);
125 setHeader({ verb: "Plan", count: RESOURCES.length, action: "create" });
126 await sleep(150);
127 await revealRows("create");
128 await sleep(2400);
129
130 if (aborted()) return;
131 setMode("deploy");
132 setSummary(null);
133 await typeCmd("alchemy deploy");
134 if (aborted()) return;
135 setHeader({ verb: "Apply", count: RESOURCES.length, action: "create" });
136 await sleep(300);
137 setProceed("show");
138 await sleep(900);
139 if (aborted()) return;
140 setProceed("confirmed");
141 await sleep(450);
142 if (aborted()) return;
143 setProceed(null);
144 const t0 = Date.now();
145 await startResource("Sessions", "creating", 900);
146 if (aborted()) return;
147 await startResource("Api", "creating", 1300);
148 if (aborted()) return;
149 const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
150 setSummary({
151 verb: "deployed",
152 secs: elapsed,
153 url: "https://api.my-app.workers.dev",
154 });
155 await sleep(2600);
156
157 if (aborted()) return;
158 setMode("destroy");
159 setSummary(null);
160 await typeCmd("alchemy destroy");
161 if (aborted()) return;
162 setHeader({ verb: "Apply", count: RESOURCES.length, action: "delete" });
163 setAllRowsAction("delete", "ready");
164 await sleep(350);
165 setProceed("show");
166 await sleep(900);
167 setProceed("confirmed");
168 await sleep(400);
169 setProceed(null);
170 const tD = Date.now();
171 await startResource("Api", "deleting", 900);
172 if (aborted()) return;
173 await startResource("Sessions", "deleting", 650);
174 if (aborted()) return;
175 const elapsedD = ((Date.now() - tD) / 1000).toFixed(1);
176 setSummary({ verb: "destroyed", secs: elapsedD });
177 await sleep(2800);
178 }
179 };
180
181 run();
182 return () => {
183 cancelRef.current = true;
184 };
185 }, []);
186
187 const anyInFlight = rows.some(
188 (r) => r.status === "creating" || r.status === "deleting",
189 );
190 const spinner = useSpinner(anyInFlight);
191 const accent = MODE_ACCENT[mode]!;
192
193 const renderRow = (r: Row) => {
194 const actionColor = ACTION_COLOR[r.action] ?? "var(--alc-code-comment)";
195 let icon: string,
196 iconColor: string,
197 statusWord: string | null = null,
198 statusColor = "";
199 if (r.status === "ready") {
200 icon = ACTION_ICON[r.action] ?? "•";
201 iconColor = actionColor;
202 } else if (r.status === "creating" || r.status === "deleting") {
203 icon = spinner;
204 iconColor = actionColor;
205 statusWord = r.status;
206 statusColor = actionColor;
207 } else if (r.status === "created" || r.status === "deleted") {
208 icon = "✓";
209 iconColor = actionColor;
210 statusWord = r.status;
211 statusColor = actionColor;
212 } else {
213 icon = " ";
214 iconColor = "transparent";
215 }
216
217 const bindingIcon = ACTION_ICON[r.action] ?? "+";
218 const bindingCount = r.bindings.length;
219
220 return (
221 <div key={r.id}>
222 <div
223 style={{
224 minHeight: "1.55em",
225 whiteSpace: "pre",
226 transition: "opacity 200ms ease",
227 }}
228 >
229 <span
230 style={{
231 color: iconColor,
232 width: "1.2em",
233 display: "inline-block",
234 transition: "color 200ms ease",
235 }}
236 >
237 {icon}
238 </span>
239 <span style={{ color: "var(--alc-fg-invert)", fontWeight: 600 }}>
240 {r.id}
241 </span>
242 <span
243 style={{ color: "var(--alc-code-comment)" }}
244 >{` (${r.type})`}</span>
245 {bindingCount > 0 && (
246 <span
247 style={{ color: "var(--alc-code-type)" }}
248 >{` (${bindingCount} binding${bindingCount === 1 ? "" : "s"})`}</span>
249 )}
250 {statusWord && (
251 <span
252 style={{
253 color: statusColor,
254 marginLeft: 6,
255 transition: "color 200ms ease",
256 }}
257 >
258 {statusWord}
259 </span>
260 )}
261 </div>
262 {bindingCount > 0 &&
263 r.bindings.map((b) => (
264 <div
265 key={`${r.id}-${b}`}
266 style={{ minHeight: "1.55em", whiteSpace: "pre" }}
267 >
268 <span style={{ width: "1.2em", display: "inline-block" }}> </span>
269 <span
270 style={{
271 color: actionColor,
272 width: "1.2em",
273 display: "inline-block",
274 transition: "color 200ms ease",
275 }}
276 >
277 {bindingIcon}
278 </span>
279 <span style={{ color: "var(--alc-code-type)" }}>{b}</span>
280 </div>
281 ))}
282 </div>
283 );
284 };
285
286 return (
287 <TermChrome
288 title={title}
289 badge={mode !== "idle" ? MODE_LABEL[mode] : undefined}
290 badgeColor={accent}
291 maxLines={10}
292 bare={bare}
293 >
294 <Line>
295 <span style={{ color: accent, transition: "color 280ms ease" }}>
296 ${" "}
297 </span>
298 {cmd}
299 {caret && <span style={{ color: "var(--alc-fg-invert)" }}>▍</span>}
300 </Line>
301 {header && (
302 <>
303 <Line> </Line>
304 <Line>
305 <span
306 style={{
307 textDecoration: "underline",
308 color: accent,
309 transition: "color 280ms ease",
310 fontWeight: 600,
311 }}
312 >
313 {header.verb}
314 </span>
315 <span>: </span>
316 <span style={{ color: ACTION_COLOR[header.action] }}>
317 {header.count} to {header.action}
318 </span>
319 </Line>
320 </>
321 )}
322 {rows.length > 0 && (
323 <>
324 <Line> </Line>
325 {rows.map(renderRow)}
326 </>
327 )}
328 {proceed && (
329 <>
330 <Line> </Line>
331 <Line>Proceed?</Line>
332 <Line>
333 {proceed === "confirmed" ? (
334 <>
335 <span style={{ color: accent, transition: "color 200ms ease" }}>
336 {"◉ Yes "}
337 </span>
338 <span style={{ color: "var(--alc-code-comment)" }}>
339 {"○ No"}
340 </span>
341 </>
342 ) : (
343 <>
344 <span style={{ color: "var(--alc-fg-invert)" }}>
345 {"◉ Yes "}
346 </span>
347 <span style={{ color: "var(--alc-code-comment)" }}>
348 {"○ No"}
349 </span>
350 </>
351 )}
352 </Line>
353 </>
354 )}
355 {summary && (
356 <>
357 <Line> </Line>
358 <Line>
359 <span style={{ color: accent, transition: "color 280ms ease" }}>
360 ✓{" "}
361 </span>
362 <span>{summary.verb} in </span>
363 <span style={{ color: "var(--alc-fg-invert)", fontWeight: 600 }}>
364 {summary.secs}s
365 </span>
366 </Line>
367 {summary.url && (
368 <Line>
369 <span style={{ color: "var(--alc-code-comment)" }}>{" → "}</span>
370 <span style={{ color: accent, transition: "color 280ms ease" }}>
371 {summary.url}
372 </span>
373 </Line>
374 )}
375 </>
376 )}
377 </TermChrome>
378 );
379}