| 1 | import { useEffect, useRef, useState } from "react"; |
| 2 | import { Line, sleep, TermChrome, useSpinner } from "./_terminal"; |
| 3 | |
| 4 | interface 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 | |
| 11 | const 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 | |
| 30 | type RowStatus = "pending" | "creating" | "created"; |
| 31 | interface Row extends BootstrapResource { |
| 32 | status: RowStatus; |
| 33 | } |
| 34 | |
| 35 | type PromptState = "hidden" | "asking" | "confirmed"; |
| 36 | |
| 37 | const ACCENT = "var(--alc-accent-bright)"; |
| 38 | |
| 39 | export 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 | } |