#!/usr/bin/env node /** * generate.mjs — turn the cast definitions in `casts.mjs` into asciinema * `.cast` recordings under `public/casts/`, using autocast (ADR-website-001 * §2; driver chosen by the 2026-06-10 spike — autocast drives the full-screen * TUI correctly, asciinema-automation does not). * * Usage: * pnpm casts # regenerate every cast * pnpm casts quickstart # regenerate just one * * Requires `autocast` on PATH (cargo install autocast) and a built * `rdbms-playground` binary at ../target/debug (run `cargo build` at the repo * root first). The binary's dir is added to PATH for the autocast child so the * recording shows a clean `$ rdbms-playground` launch line. */ import { spawnSync } from 'node:child_process'; import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import os from 'node:os'; import { casts } from './casts.mjs'; const here = dirname(fileURLToPath(import.meta.url)); const websiteRoot = resolve(here, '..'); const repoRoot = resolve(websiteRoot, '..'); const binDir = resolve(repoRoot, 'target', 'debug'); const outDir = resolve(websiteRoot, 'public', 'casts'); const cargoBin = resolve(os.homedir(), '.cargo', 'bin'); /** YAML-escape a single character for a double-quoted scalar. */ function charKey(ch) { if (ch === '\\') return '"\\\\"'; if (ch === '"') return '"\\""'; return `"${ch}"`; } /** Named single keys → autocast control codes. */ const NAMED_KEYS = { Enter: '^M', Tab: '^I', // Ctrl-C quits the app (Action::Quit). Used to end a cast *invisibly* — no // `quit` command typed into the input — so the recording ends on the last // content frame rather than a dangling, payoff-less `quit`. CtrlC: '^C', }; /** Build the autocast `keys:` list (one entry per line) for a cast's steps. */ function keysFor(steps) { const keys = []; for (const step of steps) { if (step.wait != null) { keys.push(`${step.wait}ms`); continue; } if (step.key != null) { const code = NAMED_KEYS[step.key]; if (!code) throw new Error(`unknown key: ${step.key}`); keys.push(code); if (step.after != null) keys.push(`${step.after}ms`); continue; } if (step.type != null) { for (const ch of step.type) keys.push(charKey(ch)); if (step.enter !== false) keys.push('^M'); // Enter = CR (the TUI submits on \r) if (step.after != null) keys.push(`${step.after}ms`); continue; } throw new Error(`unrecognised step: ${JSON.stringify(step)}`); } return keys; } /** * Trim a recorded cast to just the in-app portion and (optionally) hold the * final frame. The app enters the alternate screen (`?1049h`) right after * launch and leaves it (`?1049l`) on quit, so keeping the events between those * drops the shell prompt, the `$ rdbms-playground` launch line, and the * return-to-shell frame — the cast starts with the app already running and * ends on the last in-app frame (no stray cursor-under-prompt frame). * `holdEnd` re-emits the final frame after a pause so a looping cast lingers * before restarting. */ function trimCast(outPath, { holdEnd = 1.5 } = {}) { const lines = readFileSync(outPath, 'utf8').split('\n').filter(Boolean); const header = JSON.parse(lines[0]); const events = lines.slice(1).map((l) => JSON.parse(l)); const isOut = (e) => Array.isArray(e) && e[1] === 'o' && typeof e[2] === 'string'; const start = events.findIndex((e) => isOut(e) && e[2].includes('?1049h')); let end = -1; for (let i = events.length - 1; i >= 0; i--) { if (isOut(events[i]) && events[i][2].includes('?1049l')) { end = i; break; } } const kept = events.slice(start < 0 ? 0 : start, end < 0 ? events.length : end); if (kept.length === 0) return; // nothing matched — leave the cast untouched const t0 = kept[0][0]; const rebased = kept.map((e) => [Number((e[0] - t0).toFixed(6)), e[1], e[2]]); if (holdEnd > 0) { const lastT = rebased[rebased.length - 1][0]; rebased.push([Number((lastT + holdEnd).toFixed(6)), 'o', '']); // hold final frame } delete header.duration; // let the player recompute from events writeFileSync( outPath, [JSON.stringify(header), ...rebased.map((e) => JSON.stringify(e))].join('\n') + '\n' ); } function yamlFor(cast, dataDir) { const keys = keysFor(cast.steps) .map((k) => ` - ${k}`) .join('\n'); // Build the launch command. `--demo` turns on the demonstration overlay // (ADR-0047): automatic keystroke badges (so otherwise-invisible keys like // `j` / Tab / Enter are shown) plus `Ctrl+]`-delimited step captions. // `--data-dir` isolates the recording from the user's real projects (and // keeps the load picker listing only this cast's projects). const cmd = ['rdbms-playground']; if (cast.demo !== false) cmd.push('--demo'); // on for all casts (opt out with demo:false) if (dataDir) cmd.push('--data-dir', dataDir); return [ 'settings:', ` width: ${cast.width ?? 90}`, ` height: ${cast.height ?? 26}`, ` title: ${JSON.stringify(cast.title ?? cast.name)}`, ` type_speed: ${cast.typeSpeed ?? '45ms'}`, ' timeout: 90s', ' prompt: "$ "', 'instructions:', ' - !Interactive', ` command: ${cmd.join(' ')}`, ' keys:', keys, '', ].join('\n'); } const only = process.argv[2]; const selected = only ? casts.filter((c) => c.name === only) : casts; if (only && selected.length === 0) { console.error(`no cast named "${only}". Known: ${casts.map((c) => c.name).join(', ')}`); process.exit(1); } mkdirSync(outDir, { recursive: true }); const env = { ...process.env, PATH: `${binDir}:${cargoBin}:${process.env.PATH}` }; let failures = 0; for (const cast of selected) { const yamlPath = resolve(os.tmpdir(), `autocast-${cast.name}.yaml`); const outPath = resolve(outDir, `${cast.name}.cast`); // Per-cast isolated data root (so the load picker lists only this cast's // projects and nothing touches the user's real data dir). Wiped fresh each // run for a deterministic picker order. let dataDir = null; if (cast.dataDir) { dataDir = resolve(os.tmpdir(), `rdbms-cast-data-${cast.name}`); rmSync(dataDir, { recursive: true, force: true }); mkdirSync(dataDir, { recursive: true }); } writeFileSync(yamlPath, yamlFor(cast, dataDir)); console.log(`▶ recording ${cast.name} → public/casts/${cast.name}.cast`); const res = spawnSync('autocast', ['--overwrite', yamlPath, outPath], { env, stdio: 'inherit', }); rmSync(yamlPath, { force: true }); if (res.status !== 0) { console.error(`✗ ${cast.name} failed (autocast exit ${res.status})`); failures += 1; } else { // Trim the shell launch/quit so the cast starts with the app already // running (the default; opt out with `keepShell: true` for casts that // deliberately document the CLI launch). if (!cast.keepShell) trimCast(outPath, { holdEnd: cast.holdEnd ?? 1.5 }); console.log(`✓ ${cast.name}`); } } if (failures) process.exit(1); console.log(`Done — ${selected.length} cast(s).`);