| 1 | /** |
| 2 | * Scripted terminal demo of the Alchemy CLI, recorded with tcut |
| 3 | * (https://github.com/AmanVarshney01/tcut). |
| 4 | * |
| 5 | * tcut demo.video.ts # record + render out/alchemy-cli.mp4 |
| 6 | * tcut test demo.video.ts # replay fast, assertions only (no video) |
| 7 | * tcut render out/alchemy-cli.cast … # re-render the last recording, no shell |
| 8 | * |
| 9 | * The run never touches your real `~/.alchemy`: it starts from an EMPTY |
| 10 | * throwaway `ALCHEMY_HOME` and follows a brand-new user in order, one title |
| 11 | * card (`t.slide`, tcut ≥ 1.3) and mp4 chapter per step: connect Cloudflare |
| 12 | * through the `alchemy profile` TUI (OAuth, |
| 13 | * granted in tcut's browser pane), `alchemy dev`, `alchemy deploy`, an |
| 14 | * out-of-band edit caught and fixed by `alchemy drift`, and `alchemy destroy`. |
| 15 | * Stack state stays in this directory's `.alchemy/`; everything the demo |
| 16 | * deploys is destroyed at the end. |
| 17 | * |
| 18 | * Inputs (environment): |
| 19 | * CLOUDFLARE_ACCOUNT_NAME (env or ./.env, optional) |
| 20 | * The account to arrow down to in the picker when the login sees |
| 21 | * several accounts; defaults to the account id. |
| 22 | * CLOUDFLARE_LOGIN_EMAIL / CLOUDFLARE_LOGIN_PASSWORD (env or ./.env) |
| 23 | * Dashboard login for the Cloudflare account used on camera. If unset, |
| 24 | * the script waits (up to 4 min) for you to sign in by hand in the |
| 25 | * browser window; either way it clicks through the consent page. |
| 26 | * CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID |
| 27 | * API token for the OFF-camera pre-flight that destroys anything a |
| 28 | * previous take left behind. Falls back to the token stored in the |
| 29 | * real `testing` profile. Written to a gitignored `demo.env` that only |
| 30 | * `--env-file demo.env` reads, so the value never lands in the `.cast`. |
| 31 | * |
| 32 | * The CLI opens the OAuth URL with `open`; a shim on the recorded shell's |
| 33 | * PATH captures that URL for tcut's browser instead of your real browser. |
| 34 | * |
| 35 | * `alchemy dev` never returns on its own — the script drives it like a user |
| 36 | * would (edit a file, watch the reload) and then sends Ctrl+C. |
| 37 | */ |
| 38 | import { defineVideo } from "tcut"; |
| 39 | import { |
| 40 | chmodSync, |
| 41 | existsSync, |
| 42 | mkdirSync, |
| 43 | readdirSync, |
| 44 | readFileSync, |
| 45 | rmSync, |
| 46 | writeFileSync, |
| 47 | } from "node:fs"; |
| 48 | import os from "node:os"; |
| 49 | import path from "node:path"; |
| 50 | |
| 51 | const here = path.dirname(new URL(import.meta.url).pathname); |
| 52 | const realHome = path.join(os.homedir(), ".alchemy"); |
| 53 | const demoHome = path.join(os.tmpdir(), "alchemy-cli-demo-home"); |
| 54 | const shimDir = path.join(os.tmpdir(), "alchemy-cli-demo-bin"); |
| 55 | const oauthUrlFile = path.join(shimDir, "oauth-url"); |
| 56 | const apiFile = path.join(here, "src", "Api.ts"); |
| 57 | const envFile = path.join(here, "demo.env"); |
| 58 | /** |
| 59 | * `KEY=value` lines from this directory's gitignored `.env`, if present. |
| 60 | * |
| 61 | * Bun auto-loads the same file into `process.env`, but with shell semantics: |
| 62 | * `$WORD` gets expanded and `#` starts a comment, which silently mangles a |
| 63 | * password containing either. Values are read verbatim here and take |
| 64 | * precedence over `process.env` for exactly the keys the file defines. |
| 65 | */ |
| 66 | const dotenv: Record<string, string> = Object.fromEntries( |
| 67 | (existsSync(path.join(here, ".env")) |
| 68 | ? readFileSync(path.join(here, ".env"), "utf8").split("\n") |
| 69 | : [] |
| 70 | ) |
| 71 | .map((line) => line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/)) |
| 72 | .filter((m): m is RegExpMatchArray => m !== null) |
| 73 | .map(([, key, value]) => [key, value!.replace(/^(["'])(.*)\1$/, "$2")]), |
| 74 | ); |
| 75 | const env = (key: string) => dotenv[key] ?? process.env[key]; |
| 76 | const login = { |
| 77 | email: env("CLOUDFLARE_LOGIN_EMAIL"), |
| 78 | password: env("CLOUDFLARE_LOGIN_PASSWORD"), |
| 79 | }; |
| 80 | /** Matches the local provider's ready line; group 1 is the served URL. */ |
| 81 | const STARTED = /Started in \d+ms → (http:\/\/localhost:\d+\/)/; |
| 82 | |
| 83 | /** |
| 84 | * Resolves once `url` (our worker's favicon route) answers with the worker's |
| 85 | * own empty 404 three times in a row. Cloudflare's "not provisioned yet" page |
| 86 | * is also a 404, but carries an `error code: 1042` body. |
| 87 | */ |
| 88 | const waitForWorker = async (url: string, attempts = 90) => { |
| 89 | let streak = 0; |
| 90 | for (let i = 0; i < attempts; i++) { |
| 91 | const ok = await fetch(url, { signal: AbortSignal.timeout(5_000) }) |
| 92 | .then(async (r) => r.status === 404 && (await r.text()) === "") |
| 93 | .catch(() => false); |
| 94 | streak = ok ? streak + 1 : 0; |
| 95 | if (streak === 3) return; |
| 96 | await new Promise((resolve) => setTimeout(resolve, 1000)); |
| 97 | } |
| 98 | throw new Error(`${url} did not come up after ${attempts}s`); |
| 99 | }; |
| 100 | |
| 101 | /** |
| 102 | * Write `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` to `demo.env` |
| 103 | * (gitignored, removed after the take). The on-camera setup command reads |
| 104 | * them through `--env-file demo.env`, so the value is never typed and never |
| 105 | * lands in the `.cast`; no other command loads the file, so deploy/dev |
| 106 | * authenticate through the profile rather than the environment. |
| 107 | * |
| 108 | * Returns the credentials for the script's own out-of-band API call (the |
| 109 | * drift step). |
| 110 | */ |
| 111 | const writeCredentialEnvFile = () => { |
| 112 | let token = env("CLOUDFLARE_API_TOKEN"); |
| 113 | let accountId = env("CLOUDFLARE_ACCOUNT_ID"); |
| 114 | if (!token || !accountId) { |
| 115 | const stored = JSON.parse( |
| 116 | readFileSync( |
| 117 | path.join(realHome, "profiles", "testing", "cloudflare.json"), |
| 118 | "utf8", |
| 119 | ), |
| 120 | ).values as Record<string, string>; |
| 121 | if (stored.credentialType !== "apiToken") { |
| 122 | throw new Error( |
| 123 | "set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID (the 'testing' profile has no stored API token to fall back on)", |
| 124 | ); |
| 125 | } |
| 126 | token = stored.apiToken!; |
| 127 | accountId = stored.accountId!; |
| 128 | } |
| 129 | // The account id is also how the script picks the account if the OAuth |
| 130 | // login can see more than one; it is public enough (the dashboard prints it). |
| 131 | process.env.CLOUDFLARE_ACCOUNT_ID = accountId; |
| 132 | writeFileSync( |
| 133 | envFile, |
| 134 | `CLOUDFLARE_API_TOKEN=${token}\nCLOUDFLARE_ACCOUNT_ID=${accountId}\n`, |
| 135 | { mode: 0o600 }, |
| 136 | ); |
| 137 | return { token, accountId }; |
| 138 | }; |
| 139 | |
| 140 | const connectCloudflare = (profile: string) => |
| 141 | `alchemy profile edit --profile ${profile} --add Cloudflare --method stored --set apiToken=env:CLOUDFLARE_API_TOKEN --set accountId=env:CLOUDFLARE_ACCOUNT_ID --env-file demo.env`; |
| 142 | |
| 143 | /** |
| 144 | * The deployed `Visits` KV namespace, read from the stack's local state |
| 145 | * (`alchemy deploy` targets the `live_<user>` stage). |
| 146 | */ |
| 147 | const deployedVisitsNamespace = () => { |
| 148 | const stackDir = path.join(here, ".alchemy", "state", "Demo"); |
| 149 | const stage = readdirSync(stackDir).find((name) => name.startsWith("live_")); |
| 150 | if (stage === undefined) { |
| 151 | throw new Error(`no live stage under ${stackDir}`); |
| 152 | } |
| 153 | const state = JSON.parse( |
| 154 | readFileSync(path.join(stackDir, stage, "Visits.json"), "utf8"), |
| 155 | ) as { attr: { title: string; namespaceId: string; accountId: string } }; |
| 156 | return state.attr; |
| 157 | }; |
| 158 | |
| 159 | /** Direct Cloudflare API access to one KV namespace, bypassing alchemy. */ |
| 160 | const kvNamespaceApi = ( |
| 161 | credentials: { token: string; accountId: string }, |
| 162 | namespaceId: string, |
| 163 | ) => { |
| 164 | const call = async (init: RequestInit) => { |
| 165 | const response = await fetch( |
| 166 | `https://api.cloudflare.com/client/v4/accounts/${credentials.accountId}/storage/kv/namespaces/${namespaceId}`, |
| 167 | { |
| 168 | ...init, |
| 169 | headers: { |
| 170 | Authorization: `Bearer ${credentials.token}`, |
| 171 | "Content-Type": "application/json", |
| 172 | }, |
| 173 | }, |
| 174 | ); |
| 175 | const body = (await response.json()) as { result: { title: string } }; |
| 176 | if (!response.ok) { |
| 177 | throw new Error( |
| 178 | `KV namespace ${namespaceId}: ${init.method} failed with ${response.status} ${JSON.stringify(body)}`, |
| 179 | ); |
| 180 | } |
| 181 | return body.result; |
| 182 | }; |
| 183 | return { |
| 184 | title: async () => (await call({ method: "GET" })).title, |
| 185 | /** |
| 186 | * Rename the namespace behind alchemy's back — what a teammate clicking |
| 187 | * around the dashboard would do. This is the drift the demo detects and |
| 188 | * repairs. |
| 189 | */ |
| 190 | rename: (title: string) => |
| 191 | call({ method: "PUT", body: JSON.stringify({ title }) }), |
| 192 | }; |
| 193 | }; |
| 194 | |
| 195 | /** Fresh, empty `ALCHEMY_HOME`. */ |
| 196 | const seedDemoHome = () => { |
| 197 | rmSync(demoHome, { recursive: true, force: true }); |
| 198 | mkdirSync(demoHome, { recursive: true }); |
| 199 | }; |
| 200 | |
| 201 | /** |
| 202 | * `open` shim: instead of launching the system browser, record the URL so the |
| 203 | * script can hand it to tcut's browser pane. |
| 204 | */ |
| 205 | const seedOpenShim = () => { |
| 206 | rmSync(shimDir, { recursive: true, force: true }); |
| 207 | mkdirSync(shimDir, { recursive: true }); |
| 208 | const shim = path.join(shimDir, "open"); |
| 209 | writeFileSync(shim, `#!/bin/sh\nprintf '%s' "$1" > ${oauthUrlFile}\n`); |
| 210 | chmodSync(shim, 0o755); |
| 211 | }; |
| 212 | |
| 213 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); |
| 214 | |
| 215 | /** The authorization URL the CLI asked `open` to launch. */ |
| 216 | const waitForOAuthUrl = async (attempts = 240) => { |
| 217 | for (let i = 0; i < attempts; i++) { |
| 218 | if (existsSync(oauthUrlFile)) return readFileSync(oauthUrlFile, "utf8"); |
| 219 | await sleep(250); |
| 220 | } |
| 221 | throw new Error("the CLI never opened the OAuth URL"); |
| 222 | }; |
| 223 | |
| 224 | /** |
| 225 | * Cloudflare's login page is a React form; set the controlled inputs through |
| 226 | * the native value setter (so React sees the change) and submit. |
| 227 | */ |
| 228 | const signInScript = (email: string, password: string) => `(() => { |
| 229 | const set = (selector, value) => { |
| 230 | const el = document.querySelector(selector); |
| 231 | if (!el) return false; |
| 232 | Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(el, value); |
| 233 | el.dispatchEvent(new Event("input", { bubbles: true })); |
| 234 | el.dispatchEvent(new Event("change", { bubbles: true })); |
| 235 | return true; |
| 236 | }; |
| 237 | return set("#email", ${JSON.stringify(email)}) && set("#password", ${JSON.stringify(password)}); |
| 238 | })()`; |
| 239 | |
| 240 | /** True once both controlled inputs actually hold the values we set. */ |
| 241 | const signInFilledScript = (email: string, password: string) => |
| 242 | `document.querySelector("#email")?.value === ${JSON.stringify(email)} && |
| 243 | document.querySelector("#password")?.value === ${JSON.stringify(password)}`; |
| 244 | |
| 245 | /** |
| 246 | * True once the login page's Turnstile widget has produced its token. Without |
| 247 | * it Cloudflare answers a perfectly good password with "Incorrect email or |
| 248 | * password", so the submit has to wait for it. (`login_challenge` is a |
| 249 | * different, always-populated field — don't match on `*challenge*`.) |
| 250 | */ |
| 251 | const turnstileReadyScript = `[...document.querySelectorAll( |
| 252 | 'input[name="cf_challenge_response"], input[name="cf-turnstile-response"]' |
| 253 | )].some((el) => el.value.length > 0)`; |
| 254 | |
| 255 | /** |
| 256 | * The account row to pick on the consent screen's "Select account(s)" step: |
| 257 | * the configured account name, or whichever row comes first. |
| 258 | */ |
| 259 | const accountButtonPattern = (() => { |
| 260 | const name = env("CLOUDFLARE_ACCOUNT_NAME"); |
| 261 | return name |
| 262 | ? new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i") |
| 263 | : /^(?!Review permissions$|Cancel$).+/; |
| 264 | })(); |
| 265 | |
| 266 | /** Click the first button whose text matches (consent pages vary in markup). */ |
| 267 | const clickButtonScript = (pattern: RegExp) => `(() => { |
| 268 | const el = [...document.querySelectorAll("button, input[type=submit], a[role=button]")] |
| 269 | .find((b) => ${pattern}.test((b.innerText || b.value || "").trim())); |
| 270 | if (!el) return false; |
| 271 | el.click(); |
| 272 | return true; |
| 273 | })()`; |
| 274 | |
| 275 | /** Logical frame size (rendered at 2x); the 110x32 grid is centred inside. */ |
| 276 | const VIDEO = { width: 1368, height: 816 }; |
| 277 | |
| 278 | export default defineVideo( |
| 279 | { |
| 280 | output: ["out/alchemy-cli.mp4"], |
| 281 | shell: "zsh", |
| 282 | cols: 110, |
| 283 | rows: 32, |
| 284 | width: VIDEO.width, |
| 285 | height: VIDEO.height, |
| 286 | theme: "catppuccin-mocha", |
| 287 | typingSpeed: "15ms", |
| 288 | typingJitter: 0.3, |
| 289 | waitTimeout: "180s", |
| 290 | endPause: "2s", |
| 291 | requires: ["curl"], |
| 292 | // A real browser window, composited over the terminal only while |
| 293 | // focused — used for the OAuth grant and to show the local dev server. |
| 294 | // Sized to the video so it takes the whole frame when it is in front. |
| 295 | browser: { position: "overlay", ...VIDEO, offset: { x: 0, y: 0 } }, |
| 296 | }, |
| 297 | async (t) => { |
| 298 | seedDemoHome(); |
| 299 | seedOpenShim(); |
| 300 | const credentials = writeCredentialEnvFile(); |
| 301 | const originalApi = readFileSync(apiFile, "utf8"); |
| 302 | |
| 303 | /** |
| 304 | * A full-screen transition card between steps (`t.slide`, tcut ≥ 1.3), |
| 305 | * drawn at render time in real typography and faded in and out. It also |
| 306 | * records an mp4 chapter of the same name (`--chapters`, |
| 307 | * `--split-chapters`), and the screen is cleared behind the card so the |
| 308 | * next step starts fresh. |
| 309 | */ |
| 310 | let step = 0; |
| 311 | const slide = async (heading: string, subtitle: string) => { |
| 312 | step += 1; |
| 313 | await t.caption(null); |
| 314 | await t.slide(heading, { |
| 315 | eyebrow: String(step), |
| 316 | subtitle, |
| 317 | duration: "2.2s", |
| 318 | fade: "400ms", |
| 319 | during: async () => { |
| 320 | // `run` waits for a fresh prompt, which never comes when the screen |
| 321 | // is already just a prompt (clear redraws nothing new). |
| 322 | const lines = t |
| 323 | .screen() |
| 324 | .split("\n") |
| 325 | .filter((l) => l.trim()); |
| 326 | if (lines.length > 1) await t.run("clear"); |
| 327 | }, |
| 328 | }); |
| 329 | }; |
| 330 | |
| 331 | /** |
| 332 | * A subtitle over the bottom of the terminal (`t.caption`, tcut ≥ 1.4) for |
| 333 | * the moments where the screen alone doesn't say what is happening. Drawn |
| 334 | * at render time, never typed into the PTY; stays up until the next |
| 335 | * caption, `caption(null)`, or the next slide. |
| 336 | */ |
| 337 | const caption = (text: string, duration?: string) => |
| 338 | t.caption(text, { style: "classic", fontSize: 26, duration }); |
| 339 | |
| 340 | try { |
| 341 | // Off-camera: point the CLI at the empty home, put the `open` shim and |
| 342 | // the workspace `alchemy` bin on PATH, and make sure nothing is left |
| 343 | // from a previous take. |
| 344 | await t.hide(async () => { |
| 345 | await t.run( |
| 346 | `export ALCHEMY_HOME=${JSON.stringify(demoHome)} PATH="${shimDir}:$PWD/node_modules/.bin:$PATH"`, |
| 347 | ); |
| 348 | // The CLI falls back to plain output when it detects a coding agent |
| 349 | // (CURSOR_AGENT, CLAUDECODE, …) or CI in the environment, and drops |
| 350 | // colors under NO_COLOR; the demo is about the TUI, so force both on |
| 351 | // regardless of who launched tcut. |
| 352 | await t.run( |
| 353 | "unset CI CURSOR_AGENT CLAUDECODE CLAUDE_CODE NO_COLOR FORCE_COLOR; export ALCHEMY_TUI=1", |
| 354 | ); |
| 355 | // tcut's pty has no TERM_PROGRAM, so terminal-capability detection |
| 356 | // assumes OSC 8 hyperlinks are unsupported and the CLI falls back to |
| 357 | // printing every URL in full after its label (the OAuth URL is ~1KB). |
| 358 | // Real terminals (Ghostty, iTerm2, kitty, …) render the link inline. |
| 359 | await t.run("export FORCE_HYPERLINK=1"); |
| 360 | // A previous take may have left a live stack behind: tear it down with |
| 361 | // a temporary profile before the home is wiped for the on-camera setup. |
| 362 | await t.run(`${connectCloudflare("default")} --no-input`); |
| 363 | await t.run("alchemy destroy --yes --no-input >/dev/null 2>&1 || true"); |
| 364 | // Local state (including the dev stage's emulated resources) and the |
| 365 | // demo home start from scratch so every take plays out the same way. |
| 366 | const home = JSON.stringify(demoHome); |
| 367 | await t.run(`rm -rf .alchemy ${home} && mkdir -p ${home}`); |
| 368 | await t.run("clear"); |
| 369 | }); |
| 370 | |
| 371 | // ── 1. Connect to Cloudflare ───────────────────────────────────────── |
| 372 | await slide("Connect Cloudflare", "alchemy profile · sign in with OAuth"); |
| 373 | await t.type("alchemy profile"); |
| 374 | await t.enter(); |
| 375 | await t.wait(/e edit/, { scope: "screen" }); |
| 376 | await caption( |
| 377 | "A fresh machine: one empty profile, no providers connected yet", |
| 378 | ); |
| 379 | await t.sleep("2.5s"); |
| 380 | |
| 381 | // Nothing is connected yet — add Cloudflare from the edit screen. |
| 382 | await caption("e edits the profile — space marks a provider to add"); |
| 383 | await t.type("e"); |
| 384 | await t.wait(/esc back/, { scope: "screen" }); |
| 385 | await t.sleep("2s"); |
| 386 | await t.down(2); |
| 387 | await t.sleep("600ms"); |
| 388 | await t.type(" "); |
| 389 | await t.wait(/Cloudflare\s+add/, { scope: "screen" }); |
| 390 | await t.sleep("1.2s"); |
| 391 | await t.enter(); |
| 392 | |
| 393 | // OAuth (the recommended method), basic scopes. |
| 394 | await t.wait(/Cloudflare authentication method/, { scope: "screen" }); |
| 395 | await caption("OAuth is the recommended method — no API token to paste"); |
| 396 | await t.sleep("2s"); |
| 397 | await t.enter(); |
| 398 | await t.wait(/Cloudflare OAuth scopes/, { scope: "screen" }); |
| 399 | await caption("Pick the scopes the token should have"); |
| 400 | await t.sleep("1.5s"); |
| 401 | await t.down(); |
| 402 | await t.sleep("600ms"); |
| 403 | await t.enter(); |
| 404 | |
| 405 | // The CLI hands the grant URL to `open` — our shim catches it and the |
| 406 | // grant happens in the browser pane, exactly as it would in a real tab. |
| 407 | await t.wait(/waiting for browser authorization/, { scope: "screen" }); |
| 408 | await caption("The CLI opens Cloudflare's consent page in the browser"); |
| 409 | const oauthUrl = await waitForOAuthUrl().catch((error) => { |
| 410 | throw new Error(`${error.message}\n--- screen ---\n${t.screen()}`); |
| 411 | }); |
| 412 | await t.sleep("2s"); |
| 413 | await caption( |
| 414 | "Sign in and authorize — the grant is handed back to the CLI", |
| 415 | ); |
| 416 | await t.browser.goto(oauthUrl); |
| 417 | await t.focus("browser"); |
| 418 | const consentPage = /Select account\(s\)|Authorize/i; |
| 419 | await t.browser.waitFor( |
| 420 | new RegExp(`Sign in to Cloudflare|${consentPage.source}`, "i"), |
| 421 | { timeout: "60s" }, |
| 422 | ); |
| 423 | const pageText = async () => |
| 424 | String( |
| 425 | await t.browser.evaluate("document.body.innerText").catch(() => ""), |
| 426 | ); |
| 427 | if (/Sign in to Cloudflare/i.test(await pageText())) { |
| 428 | if (login.email && login.password) { |
| 429 | // The form is React-controlled and re-renders while it hydrates and |
| 430 | // while Turnstile runs, so a fill can be dropped: verify the inputs |
| 431 | // hold our values and the challenge token exists before submitting, |
| 432 | // and retry if Cloudflare rejects a half-filled submission. |
| 433 | for (let attempt = 1; attempt <= 3; attempt++) { |
| 434 | await t.sleep("1.5s"); |
| 435 | for (let i = 0; i < 10; i++) { |
| 436 | await t.browser.evaluate( |
| 437 | signInScript(login.email, login.password), |
| 438 | ); |
| 439 | await t.sleep("400ms"); |
| 440 | if ( |
| 441 | (await t.browser.evaluate( |
| 442 | signInFilledScript(login.email, login.password), |
| 443 | )) === true |
| 444 | ) |
| 445 | break; |
| 446 | } |
| 447 | let turnstile = false; |
| 448 | for (let i = 0; i < 60 && !turnstile; i++) { |
| 449 | turnstile = |
| 450 | (await t.browser.evaluate(turnstileReadyScript)) === true; |
| 451 | if (!turnstile) await t.sleep("500ms"); |
| 452 | } |
| 453 | if (!turnstile) |
| 454 | console.error( |
| 455 | "turnstile token never appeared; submitting anyway", |
| 456 | ); |
| 457 | await t.sleep("500ms"); |
| 458 | await t.browser.click('[data-testid="login-submit-button"]'); |
| 459 | let rejected = false; |
| 460 | for (let i = 0; i < 40; i++) { |
| 461 | await t.sleep("500ms"); |
| 462 | const text = await pageText(); |
| 463 | if (/Incorrect email or password/i.test(text)) { |
| 464 | rejected = true; |
| 465 | break; |
| 466 | } |
| 467 | if (text.trim() !== "" && !/Sign in to Cloudflare/i.test(text)) |
| 468 | break; |
| 469 | } |
| 470 | if (!rejected) break; |
| 471 | console.error( |
| 472 | `cloudflare rejected the sign-in (attempt ${attempt}); retrying`, |
| 473 | `\n url: ${t.browser.url}`, |
| 474 | `\n hidden inputs: ${await t.browser.evaluate( |
| 475 | `JSON.stringify([...document.querySelectorAll("input[type=hidden]")].map((i) => [i.name, i.value.length]))`, |
| 476 | )}`, |
| 477 | `\n page: ${(await pageText()).replace(/\s+/g, " ").slice(0, 400)}`, |
| 478 | ); |
| 479 | } |
| 480 | } |
| 481 | // Either the credentials just submitted, or a human signs in now. |
| 482 | await t.browser.waitFor(consentPage, { timeout: "240s" }); |
| 483 | } |
| 484 | // Cloudflare's consent has two steps when the login can see several |
| 485 | // accounts: pick the account the grant covers, review the permissions, |
| 486 | // then authorize. With a single account it opens on the review step. |
| 487 | if (/Select account\(s\)/i.test(await pageText())) { |
| 488 | await t.sleep("2s"); |
| 489 | await t.browser.evaluate(clickButtonScript(accountButtonPattern)); |
| 490 | await t.sleep("1.2s"); |
| 491 | await t.browser.evaluate(clickButtonScript(/^Review permissions$/i)); |
| 492 | await t.browser.waitFor(/^Authorize$/im, { timeout: "60s" }); |
| 493 | } |
| 494 | await t.sleep("2.5s"); |
| 495 | await t.browser.evaluate(clickButtonScript(/^Authorize$/i)); |
| 496 | // Cloudflare redirects to alchemy.run/auth/callback, which relays the |
| 497 | // code to the CLI's localhost listener and lands on the success page. |
| 498 | // The relay pings localhost with a cross-scheme fetch first; when the |
| 499 | // embedded browser refuses that, the page offers a plain link to the |
| 500 | // local callback instead — take it. |
| 501 | await t.browser.waitFor( |
| 502 | /Authentication Complete|Authentication Error|Couldn't connect to your local|Cloudflare did not authorize/i, |
| 503 | { timeout: "60s" }, |
| 504 | ); |
| 505 | if (/Couldn't connect to your local/i.test(await pageText())) { |
| 506 | await t.browser.click("#relay-local"); |
| 507 | await t.browser.waitFor( |
| 508 | /Authentication Complete|Authentication Error/i, |
| 509 | { |
| 510 | timeout: "60s", |
| 511 | }, |
| 512 | ); |
| 513 | } |
| 514 | await t.sleep("2s"); |
| 515 | await t.focus("terminal"); |
| 516 | |
| 517 | // Back in the terminal the callback has landed and the account is bound. |
| 518 | await t.wait(/Cloudflare added|Select a Cloudflare account/, { |
| 519 | scope: "screen", |
| 520 | }); |
| 521 | if (/Select a Cloudflare account/.test(t.screen())) { |
| 522 | await caption( |
| 523 | "The login can see several accounts — pick the one this profile is for", |
| 524 | ); |
| 525 | await t.sleep("1.5s"); |
| 526 | // Arrow down to the configured account (the focused row starts with |
| 527 | // ❯) instead of typing into the filter; the first row is the default. |
| 528 | const wanted = |
| 529 | env("CLOUDFLARE_ACCOUNT_NAME") ?? process.env.CLOUDFLARE_ACCOUNT_ID; |
| 530 | const focused = () => |
| 531 | t |
| 532 | .screen() |
| 533 | .split("\n") |
| 534 | .find((line) => /^\s*❯/.test(line)) ?? ""; |
| 535 | for (let i = 0; i < 8 && wanted && !focused().includes(wanted); i++) { |
| 536 | await t.down(); |
| 537 | await t.sleep("400ms"); |
| 538 | } |
| 539 | await t.sleep("600ms"); |
| 540 | await t.enter(); |
| 541 | await t.wait(/Cloudflare added/, { scope: "screen" }); |
| 542 | } |
| 543 | // Hold on the dashboard: one profile, one connected provider. |
| 544 | await caption( |
| 545 | "Connected — auth method, token expiry and account id, stored under ~/.alchemy/profiles", |
| 546 | ); |
| 547 | await t.sleep("3.5s"); |
| 548 | await t.type("q"); |
| 549 | await t.wait(); |
| 550 | await t.sleep("1s"); |
| 551 | |
| 552 | // ── 2. Launch alchemy dev ──────────────────────────────────────────── |
| 553 | await slide( |
| 554 | "Launch alchemy dev", |
| 555 | "local emulation · plan view · live reload", |
| 556 | ); |
| 557 | await t.type("alchemy dev"); |
| 558 | await t.enter(); |
| 559 | await t.wait(STARTED, { scope: "screen" }); |
| 560 | await caption( |
| 561 | "The Worker, KV and R2 all run locally — the widget shows the stack's outputs", |
| 562 | ); |
| 563 | await t.sleep("2s"); |
| 564 | |
| 565 | // The widget opens on the stack's output; ←/→ flips it to the plan |
| 566 | // that was just applied. Show the plan — three local resources, all |
| 567 | // created — then come back to the output. |
| 568 | const showDevPlan = async (pattern: RegExp, text: string) => { |
| 569 | await caption("→ flips the widget to the plan that was just applied"); |
| 570 | await t.sleep("1s"); |
| 571 | await t.right(); |
| 572 | await t.wait(/show output/, { scope: "screen" }); |
| 573 | await t.expect(pattern, { scope: "screen" }); |
| 574 | await caption(text); |
| 575 | await t.sleep("4s"); |
| 576 | await caption("← back to the outputs"); |
| 577 | await t.sleep("800ms"); |
| 578 | await t.left(); |
| 579 | await t.wait(/show plan/, { scope: "screen" }); |
| 580 | await t.sleep("1.5s"); |
| 581 | }; |
| 582 | await showDevPlan( |
| 583 | /Cloudflare\.KV\.Namespace\).*created/, |
| 584 | "Three resources created, all emulated locally", |
| 585 | ); |
| 586 | |
| 587 | // Hit the local worker in a browser — at the URL the CLI actually |
| 588 | // printed, never a guess (another dev server may own the default port). |
| 589 | const devUrl = t.screen().match(STARTED)?.[1]; |
| 590 | if (devUrl === undefined) { |
| 591 | throw new Error("local dev URL not found in the terminal output"); |
| 592 | } |
| 593 | await caption("Open the local URL"); |
| 594 | await t.sleep("800ms"); |
| 595 | await t.browser.goto(devUrl); |
| 596 | await t.focus("browser"); |
| 597 | await t.sleep("3s"); |
| 598 | await t.focus("terminal"); |
| 599 | await t.sleep("1s"); |
| 600 | |
| 601 | // Change the greeting while dev is running — the stack reloads and the |
| 602 | // worker restarts. |
| 603 | await caption( |
| 604 | "Editing src/Api.ts while dev runs — the stack reloads and the Worker restarts", |
| 605 | ); |
| 606 | await t.sleep("1.2s"); |
| 607 | writeFileSync( |
| 608 | apiFile, |
| 609 | originalApi.replace( |
| 610 | 'const GREETING = "Hello from Alchemy";', |
| 611 | 'const GREETING = "Hello from alchemy dev";', |
| 612 | ), |
| 613 | ); |
| 614 | await t.wait(/restarting instance/, { scope: "screen" }); |
| 615 | await t.wait(/Started in \d+ms[^]*Started in \d+ms/, { |
| 616 | scope: "screen", |
| 617 | }); |
| 618 | if (!t.screen().split("Started in").pop()!.includes(devUrl)) { |
| 619 | throw new Error("worker restarted on a different URL"); |
| 620 | } |
| 621 | await t.sleep("1.5s"); |
| 622 | // The reload's plan: only the Worker changed, KV and R2 untouched. |
| 623 | await showDevPlan( |
| 624 | /Cloudflare\.Worker\)/, |
| 625 | "Only the Worker was updated — KV and R2 untouched", |
| 626 | ); |
| 627 | |
| 628 | await caption("Reload the page: the new greeting"); |
| 629 | await t.sleep("800ms"); |
| 630 | await t.browser.reload(); |
| 631 | await t.focus("browser"); |
| 632 | await t.sleep("3s"); |
| 633 | await t.focus("terminal"); |
| 634 | await t.sleep("1.5s"); |
| 635 | |
| 636 | // alchemy dev parks forever — Ctrl+C shuts it down. |
| 637 | await caption("Ctrl+C stops dev and tears the local resources down"); |
| 638 | await t.sleep("800ms"); |
| 639 | await t.ctrl("c"); |
| 640 | await t.wait(); |
| 641 | // Put the source back so the deploy ships the original greeting. |
| 642 | writeFileSync(apiFile, originalApi); |
| 643 | await t.sleep("1s"); |
| 644 | |
| 645 | // ── 3. Deploy to the cloud ─────────────────────────────────────────── |
| 646 | await slide( |
| 647 | "Deploy to the cloud", |
| 648 | "alchemy deploy · review the plan · confirm", |
| 649 | ); |
| 650 | await t.type("alchemy deploy"); |
| 651 | await t.enter(); |
| 652 | await t.wait(/Deploy\?/, { scope: "screen" }); |
| 653 | await caption( |
| 654 | "The same stack, now against the real Cloudflare account — review the plan, Enter to confirm", |
| 655 | ); |
| 656 | await t.sleep("3.5s"); |
| 657 | await t.enter(); |
| 658 | await caption( |
| 659 | "Creating the Worker, the KV namespace and the R2 bucket for real", |
| 660 | ); |
| 661 | await t.wait(); |
| 662 | await t.expect(/Stack deployed/, { scope: "scrollback" }); |
| 663 | await caption( |
| 664 | "Deployed — the outputs are the live workers.dev URL and the bucket name", |
| 665 | ); |
| 666 | await t.sleep("2.5s"); |
| 667 | |
| 668 | const url = t |
| 669 | .scrollback() |
| 670 | .match(/https:\/\/[a-z0-9.-]+\.workers\.dev/)?.[0]; |
| 671 | if (url === undefined) { |
| 672 | throw new Error("deployed URL not found in the terminal output"); |
| 673 | } |
| 674 | // A fresh workers.dev subdomain takes a few seconds to start serving |
| 675 | // (Cloudflare error 1042 until then). Poll from the script — not the |
| 676 | // recorded shell — on a route that leaves the visit counter alone, so |
| 677 | // the visible curls read 1 and 2. |
| 678 | await waitForWorker(`${url}/favicon.ico`); |
| 679 | await caption("Hit the live Worker — the visit counter is backed by KV"); |
| 680 | await t.run(`curl -s ${url}`); |
| 681 | await t.expect(/"visits":1/); |
| 682 | await t.sleep("1s"); |
| 683 | await t.run(`curl -s ${url}`); |
| 684 | await t.expect(/"visits":2/); |
| 685 | await t.sleep("2s"); |
| 686 | |
| 687 | // ── 4. Detect and repair drift ─────────────────────────────────────── |
| 688 | // Off camera, "someone" renames the KV namespace in the dashboard. |
| 689 | const visits = deployedVisitsNamespace(); |
| 690 | const visitsApi = kvNamespaceApi(credentials, visits.namespaceId); |
| 691 | await visitsApi.rename("renamed-in-the-dashboard"); |
| 692 | await slide( |
| 693 | "Detect and repair drift", |
| 694 | "alchemy drift · a namespace renamed in the dashboard", |
| 695 | ); |
| 696 | await caption( |
| 697 | "Meanwhile, someone renamed the KV namespace in the Cloudflare dashboard…", |
| 698 | ); |
| 699 | await t.sleep("2.5s"); |
| 700 | await t.type("alchemy drift"); |
| 701 | await t.enter(); |
| 702 | await t.wait(/Drift detected/, { scope: "screen" }); |
| 703 | await t.expect(/renamed-in-the-dashboard/, { scope: "screen" }); |
| 704 | await caption( |
| 705 | "drift re-reads the cloud and diffs it against the stack: the title changed", |
| 706 | ); |
| 707 | await t.sleep("4s"); |
| 708 | // Cancel is preselected; ← moves to Repair. |
| 709 | await caption( |
| 710 | "Repair puts the resource back the way the code declares it", |
| 711 | ); |
| 712 | await t.left(); |
| 713 | await t.sleep("1s"); |
| 714 | await t.enter(); |
| 715 | await caption("Applying the repair"); |
| 716 | await t.wait(); |
| 717 | await t.expect(/Stack deployed/, { scope: "screen" }); |
| 718 | await caption("Repaired — one update, nothing else touched"); |
| 719 | await t.sleep("2.5s"); |
| 720 | if ((await visitsApi.title()) !== visits.title) { |
| 721 | throw new Error("drift repair did not restore the namespace title"); |
| 722 | } |
| 723 | |
| 724 | // ── 5. Tear down ───────────────────────────────────────────────────── |
| 725 | await slide("Tear it down", "alchemy destroy"); |
| 726 | await t.type("alchemy destroy"); |
| 727 | await t.enter(); |
| 728 | await t.wait(/Destroy\?/, { scope: "screen" }); |
| 729 | await caption( |
| 730 | "destroy shows everything it is about to delete before asking", |
| 731 | ); |
| 732 | await t.sleep("2.5s"); |
| 733 | await t.type("y"); |
| 734 | await caption("Deleting the Worker, the KV namespace and the R2 bucket"); |
| 735 | await t.wait(); |
| 736 | await t.expect(/Stack destroyed/, { scope: "scrollback" }); |
| 737 | await caption("All gone — the account is back to where it started"); |
| 738 | await t.sleep("2s"); |
| 739 | } finally { |
| 740 | writeFileSync(apiFile, originalApi); |
| 741 | rmSync(envFile, { force: true }); |
| 742 | rmSync(shimDir, { recursive: true, force: true }); |
| 743 | } |
| 744 | }, |
| 745 | ); |