#!/usr/bin/env bun // Terminal shots for the website and the README: runs real storebound CLI commands and saves, per command, // site/img/term/.txt the cleaned output (first line "$ "; no ANSI, no clock prefix, no "(Ns)" timer, // temp / repo / home paths made short and relative) // site/img/term/.webp that text drawn as the site's terminal component (site/style.css .term, dark theme), // rendered by headless Chromium (cli/shots/browser.ts) and encoded with cwebp // Long outputs are cut for the image only, with a dim last line that says exactly how much was left out; the .txt // keeps the whole output. Store-reading commands run in a throwaway project (storebound init in a temp folder) whose // store answers come from the teaser store double (tests/helpers/teaser-store-mock.ts, preloaded through bunfig.toml), // with placeholder keys (generated here, sb@example.com): nothing reaches the network. Fails when a command's output // is missing its expected marker, or a command or the browser hangs past its timeout. // bun tools/site-term-shots.ts all shots // bun tools/site-term-shots.ts --text only the .txt files (no browser) // bun tools/site-term-shots.ts scan check only these shots import { generateKeyPairSync } from 'node:crypto'; import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; import { closeBrowser, launch } from '../cli/shots/browser'; const ROOT = join(import.meta.dir, '..'); const CLI = join(ROOT, 'cli', 'index.ts'); const MOCK = join(ROOT, 'tests', 'helpers', 'teaser-store-mock.ts'); const OUT = join(ROOT, 'site', 'img', 'term'); const DEMO = join(ROOT, 'examples', 'demo-game'); const CMD_TIMEOUT_MS = 90_000; const BROWSER_TIMEOUT_MS = 30_000; const MAX_WEBP = 120 * 1024; const log = (m: string): void => console.log(`${new Date().toISOString().slice(11, 19)} [term-shots] ${m}`); /** Rejects after `ms` with a message naming what hung. */ async function timed(what: string, ms: number, p: Promise): Promise { let t: ReturnType | undefined; try { return await Promise.race([p, new Promise((_, rej) => { t = setTimeout(() => rej(new Error(`${what}: no answer after ${ms / 1000} s`)), ms); })]); } finally { clearTimeout(t); } } /** Runs `bun cli/index.ts ` with NO_COLOR and empty store keys (plus `env`), killed after CMD_TIMEOUT_MS. */ async function run(argv: string[], cwd: string, env: Record = {}): Promise<{ code: number; out: string }> { const started = Date.now(); const p = Bun.spawn(['bun', CLI, ...argv], { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0', ASC_KEY_ID: '', ASC_ISSUER_ID: '', ASC_P8: '', PLAY_SERVICE_ACCOUNT: '', ...env }, }); const beat = setInterval(() => log(` still running: storebound ${argv.join(' ')} (${Math.round((Date.now() - started) / 1000)} s)`), 10_000); const kill = setTimeout(() => p.kill('SIGKILL'), CMD_TIMEOUT_MS); try { const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited]); if (p.signalCode === 'SIGKILL') throw new Error(`storebound ${argv.join(' ')}: killed after ${CMD_TIMEOUT_MS / 1000} s. Output so far:\n${out}${err}`); return { code, out: out + err }; } finally { clearInterval(beat); clearTimeout(kill); } } // --- the throwaway project ------------------------------------------------------------------------------------------- interface Project { dir: string; keys: Record } async function makeProject(): Promise { const dir = mkdtempSync(join(tmpdir(), 'sb-site-term-')); const init = await run(['init'], dir); if (!existsSync(join(dir, 'storebound.config.ts'))) throw new Error(`storebound init wrote no config:\n${init.out}`); writeFileSync(join(dir, 'bunfig.toml'), `preload = [${JSON.stringify(MOCK)}]\n`); const ec = generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); const rsa = generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); writeFileSync(join(dir, 'k.p8'), ec); writeFileSync(join(dir, 'sa.json'), JSON.stringify({ client_email: 'sb@example.com', private_key: rsa, token_uri: 'https://oauth2.googleapis.com/token' })); return { dir, keys: { ASC_KEY_ID: 'ABCDE12345', ASC_ISSUER_ID: '00000000-0000-0000-0000-000000000000', ASC_P8: join(dir, 'k.p8'), PLAY_SERVICE_ACCOUNT: join(dir, 'sa.json') } }; } /** The two products the teaser test puts into the config (tests/teaser.test.ts), before `dataSafety`. */ const PRODUCTS = ` products: [ { id: 'com.example.app.no_ads', type: 'non_consumable', usd: 2.99, listings: { en: { title: 'No Ads', description: 'Removes every ad.' }, tr: { title: 'Reklamsız', description: 'Tüm reklamları kaldırır.' } } }, { id: 'com.example.app.gems_100', type: 'consumable', usd: 0.99, listings: { en: { title: '100 Gems', description: 'A pouch of 100 gems.' }, tr: { title: '100 Mücevher', description: '100 mücevherlik bir kese.' } } }, ], dataSafety: {`; /** What the owner adds to a fresh `storebound init` project before the first release, so that `check` passes: * the icon artwork (examples/demo-game's real icon files), the App Store name with a suffix, the Play App content * answers (the batch `console plan play-app-content` asks for) and the App Review demo account in * .storebound/credentials.json (placeholder values). Returns a function that puts the project back as init wrote it. */ export function makeReady(dir: string): () => void { const cfg = join(dir, 'storebound.config.ts'), creds = join(dir, '.storebound', 'credentials.json'); const src = readFileSync(cfg, 'utf8'), credSrc = readFileSync(creds, 'utf8'); for (const anchor of [" listing: 'store/appstore.md',\n", ' renders: ']) if (!src.includes(anchor)) throw new Error(`the init config has no ${JSON.stringify(anchor)} line`); writeFileSync(cfg, src .replace(" listing: 'store/appstore.md',\n", " listing: 'store/appstore.md',\n name: 'Example App: Lists & Notes',\n") .replace(' renders: ', `${READY_CONSOLE}\n renders: `)); const c = JSON.parse(credSrc) as Record; writeFileSync(creds, JSON.stringify({ ...c, reviewDemoAccount: { username: 'demo@example.com', password: 'placeholder-demo-password' } }, null, 2)); const icons = join(dir, 'store', 'icon'); mkdirSync(icons, { recursive: true }); for (const f of ['icon.png', 'foreground.png', 'background.png']) copyFileSync(join(DEMO, 'store', 'icon', f), join(icons, f)); return () => { writeFileSync(cfg, src); writeFileSync(creds, credSrc); rmSync(icons, { recursive: true, force: true }); }; } /** The Play App content answers of a plain utility app without ads, for everyone 18+ (config `console`). */ const READY_CONSOLE = ` console: { contentRating: { category: 'other', answers: { violence: false, fear: false, sexuality: false, language: false, controlledSubstances: false, crudeHumor: false, gambling: false, simulatedGambling: false, lootBoxes: false, userInteraction: false, sharesLocation: false, digitalPurchases: false, unrestrictedInternet: false }, details: { miscellaneous: { naziSymbols: false, koreaHistory: false, terrorism: false, crimes: false } }, }, targetAudience: { ages: ['18-over'], appealsToChildren: false, containsAds: false }, appContent: { government: false, financialFeatures: false, healthFeatures: false }, }, `; // --- cleaning -------------------------------------------------------------------------------------------------------- const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g; /** Every spelling of a folder the output may use (macOS: /var is /private/var). */ const spellings = (p: string): string[] => [...new Set([p, realpathSync(p)])].sort((a, b) => b.length - a.length); /** Output lines without ANSI, the clock prefix and the "(Ns)" timer; `paths` folders become short relative names. */ export function clean(out: string, paths: [string, string][]): string[] { const subs = paths.flatMap(([abs, short]) => spellings(abs).map(s => [s, short] as const)); return out.replace(ANSI, '').split('\n').map(l => { let x = l.replace(/^\d\d:\d\d:\d\d /, '').replace(/ \(\d+s\)$/, '').trimEnd(); for (const [abs, short] of subs) x = x.split(`${abs}/`).join(short ? `${short}/` : '').split(abs).join(short || '.'); return x; }).filter((l, i, a) => !(l === '' && (i === 0 || a[i - 1] === '' || i === a.length - 1))); } // --- the shots ------------------------------------------------------------------------------------------------------- interface Ran { code: number; lines: string[] } interface Shot { name: string; /** The command as a reader types it. */ cmd: string; /** The term-bar title (the project folder). */ title: string; /** Exit codes that are the expected result. */ codes: number[]; /** Every one must be in the output (string: a line contains it; RegExp: a line matches). */ markers: (string | RegExp)[]; run(p: Project): Promise<{ code: number; out: string; paths: [string, string][] }>; /** The lines for the .txt (default: all). */ text?(lines: string[]): string[]; /** The lines for the image; a line starting with "…" is drawn dim. */ image(lines: string[]): string[]; } /** First `n` lines, then a dim line with the exact number left out. */ /** A line cut to what fits one row of the 960 px terminal (the .txt keeps it whole). */ const short = (l: string): string => l.length > 112 ? `${l.slice(0, 111).trimEnd()}…` : l; const head = (lines: string[], n: number, what: string): string[] => lines.length <= n ? lines : [...lines.slice(0, n), `… ${lines.length - n} more ${what}`]; const SHOTS: Shot[] = [ { name: 'release-plan', cmd: 'storebound release plan 1.0.0', title: 'my-app', codes: [0], markers: [/^\s+1\. /, /^\s+2\. /], run: async p => ({ ...await run(['release', 'plan', '1.0.0'], p.dir, { ...p.keys, TEASER_STORE: 'new' }), paths: [[p.dir, '']] }), // the step headlines (each step also has a "->" line with the fix command), cut after whole steps image: lines => { const heads = lines.filter(l => !/^\s+->/.test(l) && l.trim()); const arrows = lines.filter(l => /^\s+->/.test(l)).length; const isStep = (l: string) => /^\s+\d+\. /.test(l); const total = heads.filter(isStep).length; const shown = heads.slice(0, 24); while (shown.length && !isStep(shown.at(-1)!)) shown.pop(); const left = total - shown.filter(isStep).length; return [...shown.map(short), `… ${left > 0 ? `${left} more steps; ` : ''}each of the ${total} steps also has a -> line with the command that fixes it (${arrows} lines)`]; }, }, { name: 'scan', cmd: 'storebound scan', title: 'tests/fixtures/scan/teaser', codes: [1], markers: ['Rejection risks:', 'BLOCKER', /^\s+1\. /], run: async () => { const r = await run(['scan', '--dir', 'tests/fixtures/scan/teaser'], ROOT, { REVIEW_DEMO_USER: '', REVIEW_DEMO_PASSWORD: '' }); return { ...r, paths: [[join(ROOT, 'tests/fixtures/scan/teaser'), ''], [ROOT, '']] }; }, // the findings part: everything up to the declarations proposal text: lines => { const end = lines.findIndex(l => l.startsWith('Declarations proposal')); if (end < 0) return lines; const rest = lines.slice(end).filter(l => l.trim()).length; return [...lines.slice(0, end), `… ${rest} more lines: the declarations proposal (Data safety, App Privacy, ATT, privacy manifest) and the config diff`]; }, image: lines => { const from = lines.findIndex(l => l.startsWith('Rejection risks:')); const body = lines.slice(from).filter(l => !l.startsWith('…') && l.trim()); const shown = body.filter(l => !/^\s+(fix|skill):/.test(l)); const hidden = body.length - shown.length; return [...lines.slice(0, from), ...shown.map(short), `… plus a fix: and a skill: line per finding (${hidden} lines)`]; }, }, { name: 'products', cmd: 'storebound play products --dry', title: 'my-app', codes: [0], markers: ['com.example.app.no_ads', 'com.example.app.gems_100'], run: async p => { const cfg = join(p.dir, 'storebound.config.ts'); const src = readFileSync(cfg, 'utf8'); if (!src.includes(' dataSafety: {')) throw new Error('the init config has no " dataSafety: {" line to put the products before'); writeFileSync(cfg, src.replace(' dataSafety: {', PRODUCTS)); try { return { ...await run(['play', 'products', '--dry'], p.dir, { ...p.keys, TEASER_STORE: 'new' }), paths: [[p.dir, '']] }; } finally { writeFileSync(cfg, src); } }, image: lines => head(lines.map(short), 24, 'lines'), }, { name: 'check', cmd: 'storebound check', title: 'my-app', codes: [0], markers: ['config ok', 'all good'], run: async p => { const restore = makeReady(p.dir); try { return { ...await run(['check'], p.dir, { REVIEW_DEMO_USER: '', REVIEW_DEMO_PASSWORD: '' }), paths: [[p.dir, '']] }; } finally { restore(); } }, image: lines => head(lines.map(short), 24, 'lines'), }, { name: 'console-plan', cmd: 'storebound console plan play-content-rating', title: 'examples/demo-game', codes: [0], markers: ['start: https://play.google.com/console', 'Steps:', 'Answer sheet:', 'Never press:'], run: async () => ({ ...await run(['console', 'plan', 'play-content-rating'], DEMO), paths: [[DEMO, ''], [ROOT, '']] }), image: lines => { // the head: title, start URL, the steps (first line of each), a sample of the answer sheet, the never-press list const at = (s: string) => lines.findIndex(l => l.startsWith(s)); const steps = at('Steps:'), sheet = at('Answer sheet:'), never = at('Never press:'), facts = at('UI facts:'); const stepLines = lines.slice(steps + 1, sheet).filter(l => /^\s+\d+\. /.test(l)); const sheetRows = lines.slice(sheet + 1, never).filter(l => /^\s+\[/.test(l)); const sample = [sheetRows.find(l => l.includes('[ok]')), sheetRows.find(l => l.includes('[you]')), sheetRows.find(l => l.includes('[ask]'))].filter((l): l is string => !!l); const after = lines.slice(facts).filter(l => l.trim()).length; return [ ...lines.slice(0, steps).map(short), 'Steps:', ...stepLines.slice(0, 3).map(short), `… ${stepLines.length - 3} more steps`, '', 'Answer sheet:', ...sample.map(short), `… ${sheetRows.length - sample.length} more answers (${sheetRows.filter(l => l.includes('[ask]')).length} of all ${sheetRows.length} are "ask the owner")`, '', ...lines.slice(never, facts).filter(l => l.trim()).map(l => l.startsWith('Never press:') ? l : short(l)), `… UI facts and driving notes (${after} lines)`, ].filter((l, i, a) => !(l === '' && a[i - 1] === '')); }, }, ]; // --- drawing --------------------------------------------------------------------------------------------------------- const esc = (s: string): string => s.replace(/&/g, '&').replace(//g, '>'); /** One output line as HTML: dim elisions, red blockers and errors, amber warnings, green ok and planned creates. */ export function lineHtml(l: string): string { if (l.startsWith('$ ')) return `$ ${esc(l.slice(2))}`; if (l.trimStart().startsWith('…')) return `${esc(l)}`; let h = esc(l); h = h.replace(/\b(BLOCKER|HIGH|error|ERROR|FAIL|failed|missing)\b|\[(you|ask)\]/g, m => `${m}`); h = h.replace(/\b(WARN|warn|MEDIUM|ask owner)\b/g, m => `${m}`); // green: "[ok]", "config ok", "all good", and a dry run's planned creates ("would create", the table's create column) h = h.replace(/\[ok\]|\b(ok|all good|would create)\b|(?<= {2})create(?= {2})/g, m => `${m}`); return h; } /** The page: site/style.css's .term in its dark tokens (--code-bg, --code-ink, --code-muted, --mono). */ const page = (title: string, lines: string[]): string => `
${esc(title)}
${lines.map(lineHtml).join('\n')}
`; /** cwebp at 1600 px wide: lossy q80 and lossless, the smaller one; when that is over MAX_WEBP, lossy at q76, q72, q68. */ async function toWebp(png: string, out: string): Promise<{ bytes: number; mode: string }> { const encode = async (mode: string, args: string[]): Promise<{ bytes: number; mode: string; file: string }> => { const file = `${png}.${mode}.webp`; const p = Bun.spawn(['cwebp', '-quiet', ...args, '-resize', '1600', '0', png, '-o', file], { stdout: 'ignore', stderr: 'pipe' }); const code = await timed(`cwebp ${mode}`, 60_000, p.exited); if (code !== 0) throw new Error(`cwebp ${mode} failed: ${await new Response(p.stderr).text()}`); return { bytes: statSync(file).size, mode, file }; }; const tried = [await encode('q80', ['-q', '80', '-m', '6']), await encode('lossless', ['-lossless', '-z', '9'])]; let best = tried.reduce((a, b) => (b.bytes < a.bytes ? b : a)); for (const q of [76, 72, 68]) { if (best.bytes <= MAX_WEBP) break; const t = await encode(`q${q}`, ['-q', String(q), '-m', '6']); tried.push(t); if (t.bytes < best.bytes) best = t; } writeFileSync(out, readFileSync(best.file)); for (const t of tried) rmSync(t.file, { force: true }); return { bytes: best.bytes, mode: best.mode }; } // --- main ------------------------------------------------------------------------------------------------------------ async function main(): Promise { const args = process.argv.slice(2); const textOnly = args.includes('--text'); const only = args.filter(a => !a.startsWith('--')); const shots = only.length ? SHOTS.filter(s => only.includes(s.name)) : SHOTS; if (only.length && shots.length !== only.length) throw new Error(`unknown shot(s); known: ${SHOTS.map(s => s.name).join(', ')}`); mkdirSync(OUT, { recursive: true }); const project = await makeProject(); const home = homedir(); const done: { shot: Shot; image: string[] }[] = []; try { for (const shot of shots) { log(`run: ${shot.cmd}`); const r = await shot.run(project); const all = clean(r.out, [...r.paths, [project.dir, ''], [home, '~']]); if (!shot.codes.includes(r.code)) throw new Error(`${shot.cmd}: exit ${r.code}, expected ${shot.codes.join(' or ')}:\n${all.join('\n')}`); for (const m of shot.markers) { const hit = typeof m === 'string' ? all.some(l => l.includes(m)) : all.some(l => m.test(l)); if (!hit) throw new Error(`${shot.cmd}: the output has no ${String(m)}:\n${all.join('\n')}`); } for (const bad of [project.dir, home, ROOT]) if (all.some(l => l.includes(bad))) throw new Error(`${shot.cmd}: an absolute path is left in the output (${bad})`); const text = [`$ ${shot.cmd}`, ...(shot.text ? shot.text(all) : all)]; writeFileSync(join(OUT, `${shot.name}.txt`), `${text.join('\n')}\n`); const image = [`$ ${shot.cmd}`, ...shot.image(shot.text ? shot.text(all) : all)]; log(` ${shot.name}.txt: ${text.length} lines (image: ${image.length})`); done.push({ shot, image }); } } finally { rmSync(project.dir, { recursive: true, force: true }); } if (textOnly) return; const browser = await timed('browser launch', BROWSER_TIMEOUT_MS, launch(ROOT, { headless: true })); try { const ctx = await browser.newContext({ viewport: { width: 1008, height: 800 }, deviceScaleFactor: 2, colorScheme: 'dark' }); const tab = await ctx.newPage(); tab.setDefaultTimeout(BROWSER_TIMEOUT_MS); for (const { shot, image } of done) { await timed(`render ${shot.name}`, BROWSER_TIMEOUT_MS, tab.setContent(page(shot.title, image), { waitUntil: 'load' })); const png = join(tmpdir(), `sb-term-${shot.name}-${process.pid}.png`); await timed(`screenshot ${shot.name}`, BROWSER_TIMEOUT_MS, tab.locator('.term').screenshot({ path: png })); const out = join(OUT, `${shot.name}.webp`); const w = await toWebp(png, out); rmSync(png, { force: true }); log(` ${shot.name}.webp: ${(w.bytes / 1024).toFixed(1)} KB (${w.mode})`); if (w.bytes > MAX_WEBP) throw new Error(`${shot.name}.webp is ${w.bytes} bytes, over ${MAX_WEBP}`); } } finally { await closeBrowser(browser); } } if (import.meta.main) await main();