1import { useEffect, useRef, useState } from "react";
2import { Line, sleep, TermChrome, useSpinner } from "./_terminal";
3
4interface BootstrapResource {
5 id: string;
6 type: string;
7 /** Optional sub-line shown beneath the resource while creating, e.g. progress notes. */
8 note?: string;
9}
10
11const RESOURCES: BootstrapResource[] = [
12 {
13 id: "AlchemyStateStoreToken",
14 type: "Cloudflare.SecretsStore.Secret",
15 },
16 {
17 id: "Api",
18 type: "Cloudflare.Worker",
19 note: "Enabling workers.dev subdomain...",
20 },
21 { id: "StateStoreAuthTokenValue", type: "Alchemy.Random" },
22 {
23 id: "StateStoreEncryptionKey",
24 type: "Cloudflare.SecretsStore.Secret",
25 },
26 { id: "StateStoreEncryptionKeyValue", type: "Alchemy.Random" },
27 { id: "StateStoreSecrets", type: "Cloudflare.SecretsStore" },
28];
29
30type RowStatus = "pending" | "creating" | "created";
31interface Row extends BootstrapResource {
32 status: RowStatus;
33}
34
35type PromptState = "hidden" | "asking" | "confirmed";
36
37const ACCENT = "var(--alc-accent-bright)";
38
39export default function StateStoreBootstrap({
40 title = "~/my-app",
41 bare,
42 maxLines = 12,
43}: {
44 title?: string;
45 bare?: boolean;
46 maxLines?: number;
47}) {
48 const [cmd, setCmd] = useState("");
49 const [caret, setCaret] = useState(false);
50 const [prompt, setPrompt] = useState<PromptState>("hidden");
51 const [rows, setRows] = useState<Row[]>([]);
52
53 const cancelRef = useRef(false);
54
55 useEffect(() => {
56 cancelRef.current = false;
57 const aborted = () => cancelRef.current;
58
59 const typeCmd = async (text: string) => {
60 setCmd("");
61 setCaret(true);
62 for (let i = 1; i <= text.length; i++) {
63 if (aborted()) return;
64 setCmd(text.slice(0, i));
65 await sleep(36 + Math.random() * 24);
66 }
67 await sleep(220);
68 setCaret(false);
69 };
70
71 const updateRow = (id: string, patch: Partial<Row>) =>
72 setRows((rs) => rs.map((r) => (r.id === id ? { ...r, ...patch } : r)));
73
74 const reset = () => {
75 setCmd("");
76 setCaret(false);
77 setPrompt("hidden");
78 setRows([]);
79 };
80
81 const run = async () => {
82 while (!aborted()) {
83 reset();
84 await sleep(400);
85 await typeCmd("bun alchemy deploy");
86 if (aborted()) return;
87 await sleep(450);
88
89 setPrompt("asking");
90 await sleep(1100);
91 if (aborted()) return;
92 setPrompt("confirmed");
93 await sleep(550);
94 if (aborted()) return;
95
96 // Seed all rows as pending so the layout doesn't jump.
97 setRows(RESOURCES.map((r) => ({ ...r, status: "pending" })));
98 await sleep(280);
99
100 for (const r of RESOURCES) {
101 if (aborted()) return;
102 updateRow(r.id, { status: "creating" });
103 await sleep(r.note ? 1100 : 700);
104 if (aborted()) return;
105 updateRow(r.id, { status: "created" });
106 await sleep(180);
107 }
108
109 await sleep(3200);
110 }
111 };
112
113 run();
114 return () => {
115 cancelRef.current = true;
116 };
117 }, []);
118
119 const anyInFlight = rows.some((r) => r.status === "creating");
120 const spinner = useSpinner(anyInFlight);
121
122 const renderRow = (r: Row) => {
123 let icon: string;
124 let iconColor: string;
125
126 if (r.status === "pending") {
127 icon = "·";
128 iconColor = "var(--alc-code-comment)";
129 } else if (r.status === "creating") {
130 icon = spinner;
131 iconColor = ACCENT;
132 } else {
133 icon = "✓";
134 iconColor = ACCENT;
135 }
136
137 const labelColor =
138 r.status === "pending"
139 ? "var(--alc-code-comment)"
140 : "var(--alc-fg-invert)";
141 const labelWeight = r.status === "pending" ? 400 : 600;
142
143 return (
144 <div key={r.id}>
145 <div style={{ minHeight: "1.55em", whiteSpace: "pre" }}>
146 <span
147 style={{
148 color: iconColor,
149 width: "1.2em",
150 display: "inline-block",
151 transition: "color 200ms ease",
152 }}
153 >
154 {icon}
155 </span>
156 <span
157 style={{
158 color: labelColor,
159 fontWeight: labelWeight,
160 transition: "color 200ms ease",
161 }}
162 >
163 {r.id}
164 </span>
165 <span
166 style={{ color: "var(--alc-code-comment)" }}
167 >{` (${r.type})`}</span>
168 {r.status === "created" && (
169 <span style={{ color: ACCENT, marginLeft: 6 }}>created</span>
170 )}
171 </div>
172 {r.note && (r.status === "creating" || r.status === "created") && (
173 <div
174 style={{
175 minHeight: "1.55em",
176 whiteSpace: "pre",
177 color: "var(--alc-code-comment)",
178 }}
179 >
180 <span style={{ width: "1.2em", display: "inline-block" }}> </span>
181 <span>• {r.note}</span>
182 </div>
183 )}
184 </div>
185 );
186 };
187
188 return (
189 <TermChrome
190 title={title}
191 badge="DEPLOY"
192 badgeColor={ACCENT}
193 maxLines={maxLines}
194 bare={bare}
195 >
196 <Line>
197 <span style={{ color: ACCENT }}>$ </span>
198 {cmd}
199 {caret && <span style={{ color: "var(--alc-fg-invert)" }}>▍</span>}
200 </Line>
201 {prompt !== "hidden" && (
202 <>
203 <Line>
204 <span style={{ color: "var(--alc-code-comment)" }}>│</span>
205 </Line>
206 <Line>
207 <span style={{ color: ACCENT, marginRight: 4 }}>◇</span>
208 <span style={{ color: "var(--alc-fg-invert)" }}>
209 {" Cloudflare State Store not found. Do you want to deploy it?"}
210 </span>
211 </Line>
212 <Line>
213 <span style={{ color: "var(--alc-code-comment)" }}>│</span>
214 <span
215 style={{
216 marginLeft: 4,
217 color: prompt === "confirmed" ? ACCENT : "var(--alc-fg-invert)",
218 transition: "color 200ms ease",
219 }}
220 >
221 {" Yes"}
222 </span>
223 </Line>
224 </>
225 )}
226 {rows.length > 0 && (
227 <>
228 <Line> </Line>
229 {rows.map(renderRow)}
230 </>
231 )}
232 </TermChrome>
233 );
234}