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 */
38import { defineVideo } from "tcut";
39import {
40 chmodSync,
41 existsSync,
42 mkdirSync,
43 readdirSync,
44 readFileSync,
45 rmSync,
46 writeFileSync,
47} from "node:fs";
48import os from "node:os";
49import path from "node:path";
50
51const here = path.dirname(new URL(import.meta.url).pathname);
52const realHome = path.join(os.homedir(), ".alchemy");
53const demoHome = path.join(os.tmpdir(), "alchemy-cli-demo-home");
54const shimDir = path.join(os.tmpdir(), "alchemy-cli-demo-bin");
55const oauthUrlFile = path.join(shimDir, "oauth-url");
56const apiFile = path.join(here, "src", "Api.ts");
57const 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 */
66const 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);
75const env = (key: string) => dotenv[key] ?? process.env[key];
76const 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. */
81const 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 */
88const 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 */
111const 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
140const 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 */
147const 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. */
160const 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`. */
196const 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 */
205const 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
213const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
214
215/** The authorization URL the CLI asked `open` to launch. */
216const 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 */
228const 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. */
241const 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 */
251const 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 */
259const 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). */
267const 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. */
276const VIDEO = { width: 1368, height: 816 };
277
278export 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);