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