2026-04-29 00:58:38 -04:00
|
|
|
|
#!/usr/bin/env node
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Capture stills + a demo GIF of the Nyx dashboard for the README/docs.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The demo source is embedded below (V1_SERVER + V2_SERVER) so the
|
|
|
|
|
|
* storyboard is reproducible from this file alone. V1 has 4 endpoints
|
|
|
|
|
|
* and yields ~6 findings (one of them a 5-hop CMDi taint flow that
|
|
|
|
|
|
* the GIF drills into); V2 keeps only that flow so scan #2 has fewer
|
|
|
|
|
|
* findings than scan #1 and the overview trend chart shows a
|
|
|
|
|
|
* downward slope.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Phases:
|
|
|
|
|
|
* 1. setup — write V1 to SCAN_ROOT, ensure server reachable
|
|
|
|
|
|
* 2. gif (opt) — record the storyboard against a fresh DB; this
|
|
|
|
|
|
* also drives scan #1 via the UI
|
|
|
|
|
|
* 3. scan #1 — if --gif didn't run, kick off scan #1 via API
|
|
|
|
|
|
* 4. stills/p1 — capture pages whose content benefits from many
|
|
|
|
|
|
* findings (findings list, finding detail)
|
|
|
|
|
|
* 5. patch+scan2 — overwrite SCAN_ROOT with V2 + run scan #2 via API
|
|
|
|
|
|
* 6. stills/p2 — capture pages whose content benefits from a
|
|
|
|
|
|
* two-scan history (overview trend, scans list,
|
|
|
|
|
|
* scan detail) plus the static-ish ones
|
|
|
|
|
|
* (triage, explorer, rules, config)
|
2026-05-05 19:02:47 -04:00
|
|
|
|
* 7. frame — composite the brand mint-cyan gradient around every
|
2026-04-29 00:58:38 -04:00
|
|
|
|
* captured PNG via scripts/frame-screenshots.py
|
|
|
|
|
|
*
|
|
|
|
|
|
* Prerequisites (script asserts each before starting):
|
|
|
|
|
|
* - playwright installed (npx playwright)
|
|
|
|
|
|
* - ffmpeg on PATH (palette-based GIF conversion)
|
|
|
|
|
|
* - python3 + Pillow on PATH (frame compositing)
|
|
|
|
|
|
* - nyx serve running on $NYX_URL (default http://127.0.0.1:9876)
|
|
|
|
|
|
* - the served scan root is empty of prior scans (system DB wiped)
|
|
|
|
|
|
*
|
|
|
|
|
|
* Usage:
|
|
|
|
|
|
* node scripts/capture-screenshots.mjs --stills # PNGs only
|
|
|
|
|
|
* node scripts/capture-screenshots.mjs --gif # GIF only
|
|
|
|
|
|
* node scripts/capture-screenshots.mjs --all # both, in one orchestrated run
|
|
|
|
|
|
*
|
|
|
|
|
|
* Output (under assets/screenshots/):
|
2026-05-05 18:17:53 -04:00
|
|
|
|
* demo.gif (~25–30s serve walkthrough)
|
|
|
|
|
|
* demo_raw.gif (unframed source — saved before compositing)
|
|
|
|
|
|
* cli-scan.gif (~15s CLI scan walkthrough — requires vhs on PATH)
|
|
|
|
|
|
* cli-scan_raw.gif (unframed source)
|
2026-04-29 00:58:38 -04:00
|
|
|
|
* overview.png (mirror of docs/serve-overview.png; used by README)
|
2026-05-05 18:17:53 -04:00
|
|
|
|
* *_raw.png / *_raw.gif (unframed originals for every captured asset)
|
2026-04-29 00:58:38 -04:00
|
|
|
|
* docs/serve-overview.png (overview after scan #2 — trend going down)
|
|
|
|
|
|
* docs/serve-findings-list.png (post-scan-#1 list with multiple highs)
|
|
|
|
|
|
* docs/serve-finding-detail.png (5-hop taint flow visualizer)
|
|
|
|
|
|
* docs/serve-triage.png
|
|
|
|
|
|
* docs/serve-explorer.png
|
|
|
|
|
|
* docs/serve-scans.png
|
|
|
|
|
|
* docs/serve-scan-detail.png
|
|
|
|
|
|
* docs/serve-rules.png
|
|
|
|
|
|
* docs/serve-config.png
|
|
|
|
|
|
*/
|
2026-05-06 04:38:04 -04:00
|
|
|
|
import { execFileSync, spawn } from 'node:child_process';
|
2026-04-29 00:58:38 -04:00
|
|
|
|
import {
|
|
|
|
|
|
copyFileSync,
|
|
|
|
|
|
existsSync,
|
|
|
|
|
|
mkdirSync,
|
2026-05-06 04:38:04 -04:00
|
|
|
|
readdirSync,
|
2026-04-29 00:58:38 -04:00
|
|
|
|
rmSync,
|
|
|
|
|
|
unlinkSync,
|
|
|
|
|
|
writeFileSync,
|
|
|
|
|
|
} from 'node:fs';
|
2026-05-05 18:17:53 -04:00
|
|
|
|
import { extname, join } from 'node:path';
|
2026-04-29 00:58:38 -04:00
|
|
|
|
import process from 'node:process';
|
|
|
|
|
|
|
|
|
|
|
|
const URL_BASE = process.env.NYX_URL || 'http://127.0.0.1:9876';
|
|
|
|
|
|
const SCAN_ROOT = process.env.SCAN_ROOT || '/tmp/nyx-demo-app';
|
|
|
|
|
|
const OUT_DIR = process.env.OUT_DIR || '/Users/elipeter/nyx/assets/screenshots';
|
|
|
|
|
|
const FRAMER = process.env.FRAMER || '/Users/elipeter/nyx/scripts/frame-screenshots.py';
|
|
|
|
|
|
const NYX_BIN = process.env.NYX_BIN || '/Users/elipeter/nyx/target/release/nyx';
|
2026-05-05 19:21:11 -04:00
|
|
|
|
// Sibling marketing site that mirrors a small subset of these assets.
|
|
|
|
|
|
// Set NYXSCAN_DIR=skip to disable the mirror step.
|
|
|
|
|
|
const NYXSCAN_DIR = process.env.NYXSCAN_DIR || '/Users/elipeter/nyxscan.dev/assets/screenshots';
|
2026-05-06 04:45:50 -04:00
|
|
|
|
const VIEW = { width: 1600, height: 992 };
|
2026-04-29 00:58:38 -04:00
|
|
|
|
const COLOR_SCHEME = 'light';
|
|
|
|
|
|
|
|
|
|
|
|
const args = new Set(process.argv.slice(2));
|
|
|
|
|
|
const wantStills = args.has('--stills') || args.has('--all');
|
|
|
|
|
|
const wantGif = args.has('--gif') || args.has('--all');
|
|
|
|
|
|
const wantCli = args.has('--cli') || args.has('--all');
|
2026-05-06 04:38:04 -04:00
|
|
|
|
const wantCombo = args.has('--combo') || args.has('--all');
|
|
|
|
|
|
if (!wantStills && !wantGif && !wantCli && !wantCombo) {
|
|
|
|
|
|
console.error('usage: capture-screenshots.mjs [--stills|--gif|--cli|--combo|--all]');
|
2026-04-29 00:58:38 -04:00
|
|
|
|
process.exit(2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
mkdirSync(join(OUT_DIR, 'docs'), { recursive: true });
|
|
|
|
|
|
|
|
|
|
|
|
// Demo source ----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
const V1_SERVER = `import express from 'express';
|
|
|
|
|
|
import { exec } from 'child_process';
|
|
|
|
|
|
import fs from 'fs';
|
|
|
|
|
|
|
|
|
|
|
|
const app = express();
|
|
|
|
|
|
app.use(express.json());
|
|
|
|
|
|
|
|
|
|
|
|
// Lookup endpoint. Multi-hop CMDi: req.params.user → trim → flag → cmd → exec.
|
|
|
|
|
|
app.get('/lookup/:user', (req, res) => {
|
|
|
|
|
|
const raw = req.params.user;
|
|
|
|
|
|
const cleaned = raw.trim();
|
|
|
|
|
|
const flag = \`--user=\${cleaned}\`;
|
|
|
|
|
|
const cmd = \`whois \${flag} --verbose\`;
|
|
|
|
|
|
exec(cmd, (err, stdout) => {
|
|
|
|
|
|
res.send(stdout);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// SSRF: req.query.url → fetch.
|
|
|
|
|
|
app.get('/proxy', async (req, res) => {
|
|
|
|
|
|
const target = req.query.url;
|
|
|
|
|
|
const response = await fetch(target);
|
|
|
|
|
|
const body = await response.text();
|
|
|
|
|
|
res.send(body);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Path traversal / unsafe file read.
|
|
|
|
|
|
app.get('/file', (req, res) => {
|
|
|
|
|
|
const requested = req.query.path;
|
|
|
|
|
|
const body = fs.readFileSync(requested, 'utf8');
|
|
|
|
|
|
res.send(body);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Login endpoint with weak (Math.random) session id.
|
|
|
|
|
|
app.post('/login', (req, res) => {
|
|
|
|
|
|
const sid = Math.random().toString(36).slice(2);
|
|
|
|
|
|
res.cookie('sid', sid).json({ ok: true });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
app.listen(3000);
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
const V2_SERVER = `import express from 'express';
|
|
|
|
|
|
import { exec } from 'child_process';
|
|
|
|
|
|
|
|
|
|
|
|
const app = express();
|
|
|
|
|
|
app.use(express.json());
|
|
|
|
|
|
|
|
|
|
|
|
// Lookup endpoint. Multi-hop CMDi: req.params.user → trim → flag → cmd → exec.
|
|
|
|
|
|
app.get('/lookup/:user', (req, res) => {
|
|
|
|
|
|
const raw = req.params.user;
|
|
|
|
|
|
const cleaned = raw.trim();
|
|
|
|
|
|
const flag = \`--user=\${cleaned}\`;
|
|
|
|
|
|
const cmd = \`whois \${flag} --verbose\`;
|
|
|
|
|
|
exec(cmd, (err, stdout) => {
|
|
|
|
|
|
res.send(stdout);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
app.listen(3000);
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
const PACKAGE_JSON = `{ "name": "nyx-demo-app", "version": "1.0.0", "type": "module", "main": "src/server.js" }
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
const AUTH_JS = `import jwt from 'jsonwebtoken';
|
|
|
|
|
|
const SECRET = 'super-secret-key';
|
|
|
|
|
|
export function sign(p) { return jwt.sign(p, SECRET); }
|
|
|
|
|
|
export function verify(t) { return jwt.verify(t, SECRET); }
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
function writeDemo(variant) {
|
|
|
|
|
|
mkdirSync(join(SCAN_ROOT, 'src'), { recursive: true });
|
|
|
|
|
|
writeFileSync(join(SCAN_ROOT, 'package.json'), PACKAGE_JSON);
|
|
|
|
|
|
writeFileSync(
|
|
|
|
|
|
join(SCAN_ROOT, 'src/server.js'),
|
|
|
|
|
|
variant === 'v2' ? V2_SERVER : V1_SERVER,
|
|
|
|
|
|
);
|
|
|
|
|
|
const authPath = join(SCAN_ROOT, 'src/auth.js');
|
|
|
|
|
|
if (variant === 'v1') writeFileSync(authPath, AUTH_JS);
|
|
|
|
|
|
if (variant === 'v2' && existsSync(authPath)) unlinkSync(authPath);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Server helpers -------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
|
|
|
|
|
|
|
|
async function fetchJson(path) {
|
|
|
|
|
|
const res = await fetch(URL_BASE + path);
|
|
|
|
|
|
if (!res.ok) throw new Error(`${path}: ${res.status}`);
|
|
|
|
|
|
return res.json();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function csrfToken() {
|
|
|
|
|
|
const r = await fetch(URL_BASE + '/api/session');
|
|
|
|
|
|
return (await r.json()).csrf_token;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function waitForServer() {
|
|
|
|
|
|
for (let i = 0; i < 30; i++) {
|
|
|
|
|
|
try { await fetchJson('/api/health'); return; } catch { await sleep(250); }
|
|
|
|
|
|
}
|
|
|
|
|
|
throw new Error(`nyx serve not reachable at ${URL_BASE}, start it first`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function startScanViaApi(token) {
|
|
|
|
|
|
const res = await fetch(URL_BASE + '/api/scans', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json', 'x-nyx-csrf': token },
|
|
|
|
|
|
body: '{}',
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!res.ok && res.status !== 409) {
|
|
|
|
|
|
throw new Error(`POST /api/scans: ${res.status} ${await res.text()}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function waitForScanComplete(prevScanId, timeoutMs = 90_000) {
|
|
|
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
|
|
|
while (Date.now() < deadline) {
|
|
|
|
|
|
const overview = await fetchJson('/api/overview').catch(() => null);
|
|
|
|
|
|
if (
|
|
|
|
|
|
overview?.latest_scan_id &&
|
|
|
|
|
|
overview.state !== 'empty' &&
|
|
|
|
|
|
overview.latest_scan_id !== prevScanId
|
|
|
|
|
|
) {
|
|
|
|
|
|
await sleep(400);
|
|
|
|
|
|
return overview;
|
|
|
|
|
|
}
|
|
|
|
|
|
await sleep(400);
|
|
|
|
|
|
}
|
|
|
|
|
|
throw new Error('scan did not complete within deadline');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function currentScanId() {
|
|
|
|
|
|
const overview = await fetchJson('/api/overview').catch(() => null);
|
|
|
|
|
|
return overview?.latest_scan_id ?? null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Storyboard helpers ---------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
async function findFirstTaintRow(page) {
|
|
|
|
|
|
return page.locator('tbody tr').filter({ hasText: 'taint-' }).first();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Stills ---------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
async function captureStillsAfterScan1(page) {
|
|
|
|
|
|
console.error('[stills/p1] findings list');
|
|
|
|
|
|
await page.goto(URL_BASE + '/findings');
|
|
|
|
|
|
await page.waitForSelector('tbody tr', { timeout: 15_000 });
|
|
|
|
|
|
await sleep(1500);
|
|
|
|
|
|
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-findings-list.png') });
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[stills/p1] finding detail (5-hop CMDi)');
|
|
|
|
|
|
const row = await findFirstTaintRow(page);
|
|
|
|
|
|
await row.click();
|
|
|
|
|
|
await page.waitForURL(/\/findings\/\d+/, { timeout: 10_000 });
|
|
|
|
|
|
await sleep(2200); // flow visualizer animation
|
|
|
|
|
|
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-finding-detail.png') });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function captureStillsAfterScan2(page) {
|
|
|
|
|
|
console.error('[stills/p2] overview (with 2-scan trend)');
|
|
|
|
|
|
await page.goto(URL_BASE + '/');
|
|
|
|
|
|
await page
|
|
|
|
|
|
.waitForSelector('.health-score-card, [class*="health"]', { timeout: 10_000 })
|
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
|
await sleep(1800);
|
|
|
|
|
|
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-overview.png') });
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[stills/p2] triage');
|
|
|
|
|
|
await page.goto(URL_BASE + '/triage');
|
|
|
|
|
|
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
|
|
|
|
await sleep(1500);
|
|
|
|
|
|
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-triage.png') });
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[stills/p2] explorer');
|
|
|
|
|
|
await page.goto(URL_BASE + '/explorer');
|
|
|
|
|
|
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
|
|
|
|
await page.waitForSelector('.tree-node', { timeout: 10_000 }).catch(() => {});
|
|
|
|
|
|
await sleep(1000);
|
|
|
|
|
|
// Expand the src/ folder, then click server.js so the screenshot
|
|
|
|
|
|
// includes the highlighted source view rather than a "Select a
|
|
|
|
|
|
// file" prompt. Best-effort: skip silently if selectors miss.
|
|
|
|
|
|
const srcDir = page.locator('.tree-node:has-text("src")').first();
|
|
|
|
|
|
if (await srcDir.count()) {
|
|
|
|
|
|
await srcDir.click().catch(() => {});
|
|
|
|
|
|
await sleep(700);
|
|
|
|
|
|
}
|
|
|
|
|
|
const serverFile = page.locator('.tree-node:has-text("server.js")').first();
|
|
|
|
|
|
if (await serverFile.count()) {
|
|
|
|
|
|
await serverFile.click().catch(() => {});
|
|
|
|
|
|
await sleep(1500);
|
|
|
|
|
|
}
|
|
|
|
|
|
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-explorer.png') });
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[stills/p2] scans list');
|
|
|
|
|
|
await page.goto(URL_BASE + '/scans');
|
|
|
|
|
|
await page.waitForSelector('tbody tr', { timeout: 10_000 }).catch(() => {});
|
|
|
|
|
|
await sleep(1500);
|
|
|
|
|
|
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-scans.png') });
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[stills/p2] scan detail');
|
|
|
|
|
|
const firstScan = page.locator('tbody tr').first();
|
|
|
|
|
|
if (await firstScan.count()) {
|
|
|
|
|
|
await firstScan.click();
|
|
|
|
|
|
await page.waitForURL(/\/scans\/\d+/, { timeout: 10_000 }).catch(() => {});
|
|
|
|
|
|
await sleep(1800);
|
|
|
|
|
|
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-scan-detail.png') });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[stills/p2] rules');
|
|
|
|
|
|
await page.goto(URL_BASE + '/rules');
|
|
|
|
|
|
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
|
|
|
|
await sleep(1800);
|
|
|
|
|
|
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-rules.png') });
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[stills/p2] config');
|
|
|
|
|
|
await page.goto(URL_BASE + '/config');
|
|
|
|
|
|
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
|
|
|
|
await sleep(1800);
|
|
|
|
|
|
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-config.png') });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GIF storyboard -------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
async function captureGifFrames(page) {
|
|
|
|
|
|
console.error('[gif] scene 1: empty dashboard');
|
|
|
|
|
|
await page.goto(URL_BASE + '/');
|
|
|
|
|
|
await page.waitForSelector('text=Run your first scan');
|
|
|
|
|
|
await sleep(2200);
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[gif] scene 2: open Start Scan modal');
|
|
|
|
|
|
await page.click('header button:has-text("Start Scan"), .header button:has-text("Start Scan"), button:has-text("Start Scan")');
|
|
|
|
|
|
await page.waitForSelector('.scan-modal');
|
|
|
|
|
|
await sleep(1200);
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[gif] scene 3: confirm scan');
|
|
|
|
|
|
await page.click('.scan-modal button.btn-primary');
|
|
|
|
|
|
await page.waitForURL('**/scans', { timeout: 10_000 }).catch(() => {});
|
|
|
|
|
|
await waitForScanComplete(null);
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[gif] scene 4: back to overview, scroll');
|
|
|
|
|
|
await page.goto(URL_BASE + '/');
|
|
|
|
|
|
await page
|
|
|
|
|
|
.waitForSelector('.health-score-card, [class*="health"]', { timeout: 10_000 })
|
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
|
await sleep(1800);
|
2026-05-06 04:38:04 -04:00
|
|
|
|
await page.evaluate(() => window.scrollBy({ top: 480, behavior: 'smooth' }));
|
2026-04-29 00:58:38 -04:00
|
|
|
|
await sleep(1500);
|
2026-05-06 04:38:04 -04:00
|
|
|
|
await page.evaluate(() => window.scrollBy({ top: 480, behavior: 'smooth' }));
|
2026-04-29 00:58:38 -04:00
|
|
|
|
await sleep(1500);
|
|
|
|
|
|
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'smooth' }));
|
|
|
|
|
|
await sleep(800);
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[gif] scene 5: navigate to Findings');
|
|
|
|
|
|
await page.click('a.nav-link:has-text("Findings"), .sidebar a:has-text("Findings")');
|
|
|
|
|
|
await page.waitForURL('**/findings', { timeout: 10_000 });
|
|
|
|
|
|
await page.waitForSelector('tbody tr', { timeout: 10_000 });
|
|
|
|
|
|
await sleep(1500);
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[gif] scene 6: click the 5-hop taint finding');
|
|
|
|
|
|
const taintRow = await findFirstTaintRow(page);
|
|
|
|
|
|
await taintRow.click();
|
|
|
|
|
|
await page.waitForURL(/\/findings\/\d+/, { timeout: 10_000 });
|
|
|
|
|
|
await sleep(2500);
|
|
|
|
|
|
// Scroll well into the page so the viewer can see the taint flow
|
|
|
|
|
|
// animate before the section toggles fire.
|
|
|
|
|
|
await page.evaluate(() => window.scrollBy({ top: 480, behavior: 'smooth' }));
|
|
|
|
|
|
await sleep(1600);
|
|
|
|
|
|
await page.evaluate(() => window.scrollBy({ top: 360, behavior: 'smooth' }));
|
|
|
|
|
|
await sleep(1600);
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[gif] scene 7: open the collapsed sections');
|
|
|
|
|
|
for (const title of ['Evidence', 'Analysis Notes', 'Confidence Reasoning']) {
|
|
|
|
|
|
const toggle = page.locator(`.section-toggle:has-text("${title}")`).first();
|
|
|
|
|
|
if (await toggle.count()) {
|
|
|
|
|
|
await toggle.scrollIntoViewIfNeeded();
|
|
|
|
|
|
await sleep(500);
|
|
|
|
|
|
await toggle.click();
|
|
|
|
|
|
await sleep(1100);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
await sleep(800);
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[gif] scene 8: mark Investigating');
|
|
|
|
|
|
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'smooth' }));
|
|
|
|
|
|
await sleep(900);
|
|
|
|
|
|
const statusBtn = page.locator('.status-trigger').first();
|
|
|
|
|
|
if (await statusBtn.count()) {
|
|
|
|
|
|
await statusBtn.click().catch(() => {});
|
|
|
|
|
|
await sleep(1100);
|
|
|
|
|
|
const investigating = page.locator('text=Investigating').first();
|
|
|
|
|
|
if (await investigating.count()) {
|
|
|
|
|
|
await investigating.click().catch(() => {});
|
|
|
|
|
|
await sleep(1200);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[gif] scene 9: triage page (closing visual)');
|
|
|
|
|
|
await page.goto(URL_BASE + '/triage');
|
|
|
|
|
|
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
|
|
|
|
await sleep(1500);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 04:38:04 -04:00
|
|
|
|
// Combo GIF browser storyboard — data already present from VHS scan phase -----
|
|
|
|
|
|
|
|
|
|
|
|
async function captureGifFramesCombo(page) {
|
|
|
|
|
|
console.error('[combo/gif] scene 1: overview with scan data');
|
|
|
|
|
|
await page.goto(URL_BASE + '/');
|
|
|
|
|
|
await page
|
|
|
|
|
|
.waitForSelector('.health-score-card, [class*="health"]', { timeout: 15_000 })
|
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
|
await sleep(2200);
|
|
|
|
|
|
await page.evaluate(() => window.scrollBy({ top: 480, behavior: 'smooth' }));
|
|
|
|
|
|
await sleep(1500);
|
|
|
|
|
|
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'smooth' }));
|
|
|
|
|
|
await sleep(900);
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[combo/gif] scene 2: findings list');
|
|
|
|
|
|
await page.click('a.nav-link:has-text("Findings"), .sidebar a:has-text("Findings")');
|
|
|
|
|
|
await page.waitForURL('**/findings', { timeout: 10_000 });
|
|
|
|
|
|
await page.waitForSelector('tbody tr', { timeout: 10_000 });
|
|
|
|
|
|
await sleep(1500);
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[combo/gif] scene 3: 5-hop taint finding detail');
|
|
|
|
|
|
const taintRow = await findFirstTaintRow(page);
|
|
|
|
|
|
await taintRow.click();
|
|
|
|
|
|
await page.waitForURL(/\/findings\/\d+/, { timeout: 10_000 });
|
|
|
|
|
|
await sleep(2500);
|
|
|
|
|
|
await page.evaluate(() => window.scrollBy({ top: 480, behavior: 'smooth' }));
|
|
|
|
|
|
await sleep(1600);
|
|
|
|
|
|
await page.evaluate(() => window.scrollBy({ top: 360, behavior: 'smooth' }));
|
|
|
|
|
|
await sleep(1600);
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[combo/gif] scene 4: open Evidence + Analysis Notes');
|
|
|
|
|
|
for (const title of ['Evidence', 'Analysis Notes']) {
|
|
|
|
|
|
const toggle = page.locator(`.section-toggle:has-text("${title}")`).first();
|
|
|
|
|
|
if (await toggle.count()) {
|
|
|
|
|
|
await toggle.scrollIntoViewIfNeeded();
|
|
|
|
|
|
await sleep(500);
|
|
|
|
|
|
await toggle.click();
|
|
|
|
|
|
await sleep(1000);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
await sleep(1200);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 00:58:38 -04:00
|
|
|
|
async function convertWebmToGif(webm, gifOut) {
|
|
|
|
|
|
const palette = '/tmp/nyx-demo-palette.png';
|
|
|
|
|
|
console.error('[gif] generating palette');
|
|
|
|
|
|
execFileSync('ffmpeg', [
|
|
|
|
|
|
'-y', '-ss', '1.0', '-i', webm,
|
2026-05-06 04:38:04 -04:00
|
|
|
|
'-vf', 'fps=15,scale=1280:-1:flags=lanczos,palettegen',
|
2026-04-29 00:58:38 -04:00
|
|
|
|
palette,
|
|
|
|
|
|
], { stdio: 'inherit' });
|
|
|
|
|
|
console.error('[gif] palette → gif');
|
|
|
|
|
|
execFileSync('ffmpeg', [
|
|
|
|
|
|
'-y', '-ss', '1.0', '-i', webm, '-i', palette,
|
2026-05-06 04:38:04 -04:00
|
|
|
|
'-lavfi', 'fps=15,scale=1280:-1:flags=lanczos [x]; [x][1:v] paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle',
|
2026-04-29 00:58:38 -04:00
|
|
|
|
gifOut,
|
|
|
|
|
|
], { stdio: 'inherit' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// CLI capture phase ----------------------------------------------------------
|
|
|
|
|
|
//
|
|
|
|
|
|
// `render-cli.py` orchestrates: force ANSI via `CLICOLOR_FORCE=1`,
|
|
|
|
|
|
// merge consecutive SGR escapes (freeze otherwise honors only the
|
|
|
|
|
|
// last one and drops fg/bg/dim), invoke freeze with the brand-
|
|
|
|
|
|
// consistent window chrome, then crop or pad to exactly 1600x992 so
|
|
|
|
|
|
// the framer never resamples the captured text.
|
|
|
|
|
|
|
|
|
|
|
|
const CLI_RENDERER = '/Users/elipeter/nyx/scripts/render-cli.py';
|
2026-05-05 18:17:53 -04:00
|
|
|
|
const VHS_BIN = process.env.VHS_BIN || 'vhs';
|
|
|
|
|
|
const CLI_GIF = join(OUT_DIR, 'cli-scan.gif');
|
2026-04-29 00:58:38 -04:00
|
|
|
|
|
|
|
|
|
|
function renderCli(shellCommand, outFile) {
|
|
|
|
|
|
execFileSync(
|
|
|
|
|
|
'python3',
|
|
|
|
|
|
[CLI_RENDERER, outFile, shellCommand],
|
|
|
|
|
|
{ stdio: ['ignore', 'inherit', 'inherit'] },
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Stage a temporary HOME with a sample nyx.local so that
|
|
|
|
|
|
// `nyx config show` (which now defaults to a diff view) has
|
|
|
|
|
|
// something to display. Without this the capture would be a one-
|
|
|
|
|
|
// line "No overrides" notice — accurate but not a useful screenshot.
|
|
|
|
|
|
//
|
|
|
|
|
|
// The `directories` crate on macOS resolves the config path through
|
|
|
|
|
|
// `$HOME/Library/Application Support/nyx`, so swapping HOME is
|
|
|
|
|
|
// enough to redirect both reads and writes for the wrapped command.
|
|
|
|
|
|
const DEMO_CONFIG_HOME = '/tmp/nyx-demo-config-home';
|
|
|
|
|
|
const DEMO_NYX_LOCAL = `[scanner]
|
|
|
|
|
|
mode = "taint"
|
|
|
|
|
|
min_severity = "Medium"
|
|
|
|
|
|
|
|
|
|
|
|
[output]
|
|
|
|
|
|
default_format = "json"
|
|
|
|
|
|
max_low = 5
|
|
|
|
|
|
|
|
|
|
|
|
[analysis.engine]
|
|
|
|
|
|
backwards_analysis = true
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
function stageDemoConfigHome() {
|
|
|
|
|
|
const cfgDir = join(DEMO_CONFIG_HOME, 'Library/Application Support/nyx');
|
|
|
|
|
|
rmSync(DEMO_CONFIG_HOME, { recursive: true, force: true });
|
|
|
|
|
|
mkdirSync(cfgDir, { recursive: true });
|
|
|
|
|
|
writeFileSync(join(cfgDir, 'nyx.local'), DEMO_NYX_LOCAL);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-05 18:17:53 -04:00
|
|
|
|
function captureCliGif() {
|
|
|
|
|
|
console.error('[cli-gif/setup] writing v1 demo');
|
|
|
|
|
|
writeDemo('v1');
|
|
|
|
|
|
|
|
|
|
|
|
const tapePath = '/tmp/nyx-cli-scan.tape';
|
|
|
|
|
|
const innerGif = '/tmp/nyx-cli-scan.gif';
|
|
|
|
|
|
|
|
|
|
|
|
// VHS tape: terminal set to exact inner dimensions so frame-screenshots.py
|
|
|
|
|
|
// fixed-mode doesn't need to resample — 1600x992 matches INNER_W x INNER_H.
|
|
|
|
|
|
const tape = [
|
|
|
|
|
|
`Output "${innerGif}"`,
|
|
|
|
|
|
'',
|
|
|
|
|
|
'Set Shell "bash"',
|
|
|
|
|
|
'Set FontSize 22',
|
|
|
|
|
|
'Set Width 1600',
|
|
|
|
|
|
'Set Height 992',
|
|
|
|
|
|
'Set Framerate 15',
|
|
|
|
|
|
'Env CLICOLOR_FORCE "1"',
|
|
|
|
|
|
'',
|
|
|
|
|
|
'Sleep 500ms',
|
|
|
|
|
|
`Type "${NYX_BIN} scan ${SCAN_ROOT}"`,
|
|
|
|
|
|
'Sleep 300ms',
|
|
|
|
|
|
'Enter',
|
|
|
|
|
|
'Sleep 12s',
|
|
|
|
|
|
'Sleep 3s',
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
|
|
|
|
|
|
writeFileSync(tapePath, tape);
|
|
|
|
|
|
console.error('[cli-gif] recording with vhs');
|
|
|
|
|
|
execFileSync(VHS_BIN, [tapePath], { stdio: 'inherit' });
|
|
|
|
|
|
copyFileSync(innerGif, CLI_GIF);
|
|
|
|
|
|
console.error(`[cli-gif] wrote ${CLI_GIF}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 00:58:38 -04:00
|
|
|
|
function captureCli() {
|
2026-05-05 18:17:53 -04:00
|
|
|
|
captureCliGif();
|
|
|
|
|
|
|
|
|
|
|
|
// Re-stage v1 so static cli-scan output shows the richer set of findings
|
|
|
|
|
|
// (captureCliGif already wrote v1; this is a safety re-stage in case
|
|
|
|
|
|
// the previous --stills phase patched the demo to v2).
|
2026-04-29 00:58:38 -04:00
|
|
|
|
console.error('[cli/setup] writing v1 demo');
|
|
|
|
|
|
writeDemo('v1');
|
|
|
|
|
|
|
|
|
|
|
|
const out = (name) => join(OUT_DIR, 'docs', name);
|
|
|
|
|
|
|
|
|
|
|
|
// README and quickstart both link to the same `nyx scan` capture —
|
|
|
|
|
|
// emit it once at the top level, no docs/cli-scan-quickstart copy.
|
|
|
|
|
|
console.error('[cli] cli-scan');
|
|
|
|
|
|
renderCli(`${NYX_BIN} scan ${SCAN_ROOT}`, join(OUT_DIR, 'cli-scan.png'));
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[cli] cli-failon');
|
|
|
|
|
|
// `; true` keeps the pipeline's exit code at 0 even when --fail-on
|
|
|
|
|
|
// trips. render-cli.py wraps the whole compound in `{ ...; }
|
|
|
|
|
|
// 2>/dev/null` so progress bars are suppressed for both halves.
|
|
|
|
|
|
renderCli(
|
|
|
|
|
|
`${NYX_BIN} scan ${SCAN_ROOT} --fail-on HIGH; true`,
|
|
|
|
|
|
out('cli-failon.png'),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[cli] cli-explain-engine');
|
|
|
|
|
|
renderCli(
|
|
|
|
|
|
`${NYX_BIN} scan ${SCAN_ROOT} --engine-profile deep --explain-engine`,
|
|
|
|
|
|
out('cli-explain-engine.png'),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[cli] cli-idxstatus');
|
|
|
|
|
|
renderCli(`${NYX_BIN} index status ${SCAN_ROOT}`, out('cli-idxstatus.png'));
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[cli] cli-configshow (with staged nyx.local)');
|
|
|
|
|
|
stageDemoConfigHome();
|
|
|
|
|
|
renderCli(
|
|
|
|
|
|
`HOME=${DEMO_CONFIG_HOME} ${NYX_BIN} config show`,
|
|
|
|
|
|
out('cli-configshow.png'),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// cli-rollup-tail.png is intentionally not regenerated. Its alt text
|
|
|
|
|
|
// describes a 57-issue rollup that the synthetic demo cannot produce
|
|
|
|
|
|
// without a much larger fixture; the existing image is left alone.
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 04:38:04 -04:00
|
|
|
|
// Combo GIF ------------------------------------------------------------------
|
|
|
|
|
|
// Single GIF: CLI scan (VHS terminal) → hard cut → serve UI (Playwright).
|
|
|
|
|
|
// The VHS portion is a visual recording only — nyx scan (standalone CLI)
|
|
|
|
|
|
// writes to a separate store that nyx serve does not read. After VHS we
|
|
|
|
|
|
// wipe state and trigger a real scan through the serve API so Playwright
|
|
|
|
|
|
// has live data to explore.
|
|
|
|
|
|
|
|
|
|
|
|
async function captureComboGif() {
|
|
|
|
|
|
function wipeState() {
|
|
|
|
|
|
rmSync(join(SCAN_ROOT, '.nyx'), { recursive: true, force: true });
|
|
|
|
|
|
const homeDir = process.env.HOME || '/Users/elipeter';
|
|
|
|
|
|
const sysDbBase = join(homeDir, 'Library/Application Support/nyx/nyx-demo-app.sqlite');
|
|
|
|
|
|
for (const suffix of ['', '-wal', '-shm']) {
|
|
|
|
|
|
try { unlinkSync(sysDbBase + suffix); } catch {}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 1. Clean state + write demo.
|
|
|
|
|
|
try { execFileSync('pkill', ['-f', 'nyx serve'], { stdio: 'ignore' }); } catch {}
|
|
|
|
|
|
await sleep(800);
|
|
|
|
|
|
wipeState();
|
|
|
|
|
|
writeDemo('v1');
|
|
|
|
|
|
|
|
|
|
|
|
// 2. VHS: scan → results pause → type nyx serve → see it start.
|
|
|
|
|
|
const cliGifPath = '/tmp/nyx-combo-cli.gif';
|
|
|
|
|
|
const tapePath = '/tmp/nyx-combo.tape';
|
|
|
|
|
|
const tape = [
|
|
|
|
|
|
`Output "${cliGifPath}"`,
|
|
|
|
|
|
'',
|
|
|
|
|
|
'Set Shell "bash"',
|
|
|
|
|
|
'Set FontSize 22',
|
2026-05-06 04:45:50 -04:00
|
|
|
|
'Set Width 1600',
|
|
|
|
|
|
'Set Height 992',
|
2026-05-06 04:38:04 -04:00
|
|
|
|
'Set Framerate 15',
|
|
|
|
|
|
'Env CLICOLOR_FORCE "1"',
|
|
|
|
|
|
'',
|
|
|
|
|
|
'Sleep 500ms',
|
|
|
|
|
|
`Type "${NYX_BIN} scan ${SCAN_ROOT}"`,
|
|
|
|
|
|
'Sleep 300ms',
|
|
|
|
|
|
'Enter',
|
|
|
|
|
|
'Sleep 1500ms',
|
|
|
|
|
|
`Type "${NYX_BIN} serve --port 9876 --no-browser ${SCAN_ROOT}"`,
|
|
|
|
|
|
'Sleep 300ms',
|
|
|
|
|
|
'Enter',
|
|
|
|
|
|
'Sleep 2000ms',
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
writeFileSync(tapePath, tape);
|
|
|
|
|
|
console.error('[combo] recording CLI portion with vhs');
|
|
|
|
|
|
execFileSync(VHS_BIN, [tapePath], { stdio: 'inherit' });
|
|
|
|
|
|
|
|
|
|
|
|
// 3. Wipe state again and start a fresh host serve. The VHS scan wrote
|
|
|
|
|
|
// to standalone storage that nyx serve doesn't read, so we drive a
|
|
|
|
|
|
// real scan through the serve API to populate the browser session.
|
|
|
|
|
|
try { execFileSync('pkill', ['-f', 'nyx serve'], { stdio: 'ignore' }); } catch {}
|
|
|
|
|
|
await sleep(800);
|
|
|
|
|
|
wipeState();
|
|
|
|
|
|
|
|
|
|
|
|
const serveProc = spawn(NYX_BIN, [
|
|
|
|
|
|
'serve', '--port', '9876', '--no-browser', SCAN_ROOT,
|
|
|
|
|
|
], { detached: false, stdio: 'ignore' });
|
|
|
|
|
|
serveProc.unref();
|
|
|
|
|
|
await waitForServer();
|
|
|
|
|
|
|
|
|
|
|
|
const comboToken = await csrfToken();
|
|
|
|
|
|
const comboBefore = await currentScanId();
|
|
|
|
|
|
await startScanViaApi(comboToken);
|
|
|
|
|
|
await waitForScanComplete(comboBefore);
|
|
|
|
|
|
|
|
|
|
|
|
// 4. Playwright: record browser walkthrough against the live scan data.
|
|
|
|
|
|
const videoDir = '/tmp/nyx-combo-video';
|
|
|
|
|
|
if (existsSync(videoDir)) rmSync(videoDir, { recursive: true });
|
|
|
|
|
|
mkdirSync(videoDir, { recursive: true });
|
|
|
|
|
|
const { chromium } = await import('playwright');
|
|
|
|
|
|
const browser = await chromium.launch({ headless: true });
|
|
|
|
|
|
try {
|
|
|
|
|
|
const ctx = await browser.newContext({
|
|
|
|
|
|
viewport: VIEW,
|
|
|
|
|
|
colorScheme: COLOR_SCHEME,
|
|
|
|
|
|
recordVideo: { dir: videoDir, size: VIEW },
|
|
|
|
|
|
});
|
|
|
|
|
|
await ctx.addInitScript(() => {
|
|
|
|
|
|
try { localStorage.setItem('theme', 'light'); } catch {}
|
|
|
|
|
|
});
|
|
|
|
|
|
const page = await ctx.newPage();
|
|
|
|
|
|
await captureGifFramesCombo(page);
|
|
|
|
|
|
await page.close();
|
|
|
|
|
|
await ctx.close();
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
await browser.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try { execFileSync('pkill', ['-f', 'nyx serve'], { stdio: 'ignore' }); } catch {}
|
|
|
|
|
|
|
|
|
|
|
|
// 5. Find Playwright webm.
|
|
|
|
|
|
const webms = readdirSync(videoDir).filter((f) => f.endsWith('.webm'));
|
|
|
|
|
|
if (!webms.length) throw new Error('[combo] no webm captured for browser portion');
|
|
|
|
|
|
const webmPath = join(videoDir, webms[0]);
|
|
|
|
|
|
|
|
|
|
|
|
// 6. ffmpeg: three-step to avoid OOM from single-pass concat+palettegen.
|
|
|
|
|
|
// Step A: concat VHS gif + browser webm → intermediate webm.
|
|
|
|
|
|
// Step B: generate global palette from intermediate.
|
|
|
|
|
|
// Step C: palette → final GIF.
|
|
|
|
|
|
const comboOut = join(OUT_DIR, 'demo-combo.gif');
|
|
|
|
|
|
const comboIntermediate = '/tmp/nyx-combo-intermediate.mp4';
|
|
|
|
|
|
const comboPalette = '/tmp/nyx-combo-palette.png';
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[combo] step A: concat → intermediate webm');
|
|
|
|
|
|
execFileSync('ffmpeg', [
|
|
|
|
|
|
'-y',
|
|
|
|
|
|
'-ignore_loop', '1', '-r', '15', '-i', cliGifPath,
|
|
|
|
|
|
'-ss', '1.0', '-r', '15', '-i', webmPath,
|
|
|
|
|
|
'-filter_complex',
|
2026-05-06 04:45:50 -04:00
|
|
|
|
'[0:v]scale=1600:992:flags=lanczos,fps=15[cli];' +
|
|
|
|
|
|
'[1:v]scale=1600:992:flags=lanczos,fps=15[bro];' +
|
2026-05-06 04:38:04 -04:00
|
|
|
|
'[cli][bro]concat=n=2:v=1:a=0[out]',
|
|
|
|
|
|
'-map', '[out]',
|
|
|
|
|
|
'-c:v', 'libx264', '-crf', '28', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p',
|
|
|
|
|
|
comboIntermediate,
|
|
|
|
|
|
], { stdio: 'inherit' });
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[combo] step B: generate palette');
|
|
|
|
|
|
execFileSync('ffmpeg', [
|
|
|
|
|
|
'-y', '-i', comboIntermediate,
|
|
|
|
|
|
'-vf', 'fps=15,palettegen',
|
|
|
|
|
|
'-update', '1', '-frames:v', '1',
|
|
|
|
|
|
comboPalette,
|
|
|
|
|
|
], { stdio: 'inherit' });
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[combo] step C: palette → gif');
|
|
|
|
|
|
execFileSync('ffmpeg', [
|
|
|
|
|
|
'-y', '-i', comboIntermediate, '-i', comboPalette,
|
|
|
|
|
|
'-lavfi', 'fps=15 [x]; [x][1:v] paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle',
|
|
|
|
|
|
comboOut,
|
|
|
|
|
|
], { stdio: 'inherit' });
|
|
|
|
|
|
console.error(`[combo] wrote ${comboOut}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 00:58:38 -04:00
|
|
|
|
// Frame phase ----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
const STILLS_PNGS = [
|
|
|
|
|
|
'docs/serve-overview.png',
|
|
|
|
|
|
'docs/serve-findings-list.png',
|
|
|
|
|
|
'docs/serve-finding-detail.png',
|
|
|
|
|
|
'docs/serve-triage.png',
|
|
|
|
|
|
'docs/serve-explorer.png',
|
|
|
|
|
|
'docs/serve-scans.png',
|
|
|
|
|
|
'docs/serve-scan-detail.png',
|
|
|
|
|
|
'docs/serve-rules.png',
|
|
|
|
|
|
'docs/serve-config.png',
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const CLI_PNGS = [
|
|
|
|
|
|
'cli-scan.png',
|
|
|
|
|
|
'docs/cli-failon.png',
|
|
|
|
|
|
'docs/cli-explain-engine.png',
|
|
|
|
|
|
'docs/cli-idxstatus.png',
|
|
|
|
|
|
'docs/cli-configshow.png',
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-05-05 18:17:53 -04:00
|
|
|
|
function saveRawCopies(paths) {
|
|
|
|
|
|
for (const p of paths) {
|
|
|
|
|
|
if (!existsSync(p)) continue;
|
|
|
|
|
|
const ext = extname(p);
|
|
|
|
|
|
const rawPath = p.slice(0, p.length - ext.length) + '_raw' + ext;
|
|
|
|
|
|
copyFileSync(p, rawPath);
|
|
|
|
|
|
console.error(`[raw] ${rawPath}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 00:58:38 -04:00
|
|
|
|
function applyFrames(captured, { natural = false } = {}) {
|
|
|
|
|
|
// Frame only paths captured this run. Re-framing a previously-
|
|
|
|
|
|
// framed PNG would treat the framed result as the next inner
|
|
|
|
|
|
// content and produce a frame inside a frame.
|
|
|
|
|
|
const paths = captured.filter((p) => existsSync(p));
|
|
|
|
|
|
if (paths.length === 0) return;
|
2026-05-05 18:17:53 -04:00
|
|
|
|
saveRawCopies(paths);
|
2026-04-29 00:58:38 -04:00
|
|
|
|
const label = natural ? 'natural-size' : 'fixed';
|
2026-05-05 19:02:47 -04:00
|
|
|
|
console.error(`[frame] applying mint-led four-corner frame (${label}) to ${paths.length} files`);
|
2026-04-29 00:58:38 -04:00
|
|
|
|
const args = natural ? ['--natural', ...paths] : paths;
|
|
|
|
|
|
execFileSync('python3', [FRAMER, ...args], { stdio: 'inherit' });
|
|
|
|
|
|
// Mirror the framed serve-overview.png to the top-level path the
|
|
|
|
|
|
// README links. Only do this when serve-overview was just
|
|
|
|
|
|
// captured this run; otherwise the existing top-level overview is
|
|
|
|
|
|
// already correct.
|
|
|
|
|
|
const ovSrc = join(OUT_DIR, 'docs/serve-overview.png');
|
|
|
|
|
|
const ovDst = join(OUT_DIR, 'overview.png');
|
|
|
|
|
|
if (paths.includes(ovSrc) && existsSync(ovSrc)) {
|
|
|
|
|
|
copyFileSync(ovSrc, ovDst);
|
|
|
|
|
|
console.error(`[frame] mirrored serve-overview.png → overview.png`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-05 19:21:11 -04:00
|
|
|
|
// Mirror the small subset of assets used by the nyxscan.dev landing site
|
|
|
|
|
|
// so its screenshots can't drift from the canonical ones in this repo.
|
|
|
|
|
|
// Mirrors the *_raw originals (unframed) — nyxscan.dev draws its own
|
|
|
|
|
|
// frame/hero treatment in CSS and does not want the in-repo brand frame.
|
|
|
|
|
|
// Regenerates webp variants for the PNGs (used by hero <picture>/image-set).
|
|
|
|
|
|
// Skips silently when NYXSCAN_DIR=skip, the dir is missing, or cwebp is
|
|
|
|
|
|
// not on PATH; this is a convenience step, not a hard requirement.
|
|
|
|
|
|
const NYXSCAN_MIRROR = [
|
|
|
|
|
|
['docs/serve-overview_raw.png', 'overview.png'],
|
|
|
|
|
|
['docs/serve-finding-detail_raw.png', 'finding-detail.png'],
|
|
|
|
|
|
['cli-scan_raw.gif', 'cli-scan.gif'],
|
2026-05-06 04:38:04 -04:00
|
|
|
|
['demo-combo.gif', 'demo-combo.gif'],
|
2026-05-05 19:21:11 -04:00
|
|
|
|
];
|
|
|
|
|
|
function syncNyxscanDev() {
|
|
|
|
|
|
if (NYXSCAN_DIR === 'skip') return;
|
|
|
|
|
|
if (!existsSync(NYXSCAN_DIR)) {
|
|
|
|
|
|
console.error(`[nyxscan] skip — ${NYXSCAN_DIR} does not exist`);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let cwebpAvailable = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
execFileSync('cwebp', ['-version'], { stdio: 'ignore' });
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
cwebpAvailable = false;
|
|
|
|
|
|
console.error('[nyxscan] cwebp not on PATH — copying PNGs only, webp will be stale');
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const [srcRel, dstName] of NYXSCAN_MIRROR) {
|
|
|
|
|
|
const src = join(OUT_DIR, srcRel);
|
|
|
|
|
|
if (!existsSync(src)) continue;
|
|
|
|
|
|
const dst = join(NYXSCAN_DIR, dstName);
|
|
|
|
|
|
copyFileSync(src, dst);
|
|
|
|
|
|
console.error(`[nyxscan] ${srcRel} -> ${dstName}`);
|
|
|
|
|
|
if (cwebpAvailable && dstName.endsWith('.png')) {
|
|
|
|
|
|
const webp = dst.slice(0, -4) + '.webp';
|
|
|
|
|
|
execFileSync('cwebp', ['-quiet', '-q', '82', dst, '-o', webp], { stdio: 'inherit' });
|
|
|
|
|
|
console.error(`[nyxscan] ${dstName.slice(0, -4)}.webp`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 00:58:38 -04:00
|
|
|
|
// Main -----------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
async function main() {
|
|
|
|
|
|
// Only the serve flows actually need nyx serve; --cli alone runs
|
|
|
|
|
|
// without it.
|
|
|
|
|
|
if (wantStills || wantGif) await waitForServer();
|
|
|
|
|
|
|
|
|
|
|
|
console.error('[setup] writing v1 demo to', SCAN_ROOT);
|
|
|
|
|
|
writeDemo('v1');
|
|
|
|
|
|
|
|
|
|
|
|
const needsBrowser = wantStills || wantGif;
|
|
|
|
|
|
let browser = null;
|
|
|
|
|
|
if (needsBrowser) {
|
|
|
|
|
|
const { chromium } = await import('playwright');
|
|
|
|
|
|
browser = await chromium.launch({ headless: true });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (wantGif) {
|
|
|
|
|
|
const videoDir = '/tmp/nyx-demo-video';
|
|
|
|
|
|
if (existsSync(videoDir)) rmSync(videoDir, { recursive: true });
|
|
|
|
|
|
mkdirSync(videoDir, { recursive: true });
|
|
|
|
|
|
|
|
|
|
|
|
const ctx = await browser.newContext({
|
|
|
|
|
|
viewport: VIEW,
|
|
|
|
|
|
colorScheme: COLOR_SCHEME,
|
|
|
|
|
|
recordVideo: { dir: videoDir, size: VIEW },
|
|
|
|
|
|
});
|
|
|
|
|
|
await ctx.addInitScript(() => {
|
|
|
|
|
|
try { localStorage.setItem('theme', 'light'); } catch {}
|
|
|
|
|
|
});
|
|
|
|
|
|
const page = await ctx.newPage();
|
|
|
|
|
|
await captureGifFrames(page);
|
|
|
|
|
|
await page.close();
|
|
|
|
|
|
await ctx.close();
|
|
|
|
|
|
|
|
|
|
|
|
const fs = await import('node:fs');
|
|
|
|
|
|
const files = fs.readdirSync(videoDir).filter((f) => f.endsWith('.webm'));
|
|
|
|
|
|
if (files.length === 0) throw new Error('no webm captured');
|
|
|
|
|
|
await convertWebmToGif(join(videoDir, files[0]), join(OUT_DIR, 'demo.gif'));
|
|
|
|
|
|
} else if (wantStills) {
|
|
|
|
|
|
// --stills only: GIF didn't run, so we drive scan #1 ourselves.
|
|
|
|
|
|
console.error('[setup] running scan #1 (v1) via API');
|
|
|
|
|
|
const token = await csrfToken();
|
|
|
|
|
|
const before = await currentScanId();
|
|
|
|
|
|
await startScanViaApi(token);
|
|
|
|
|
|
await waitForScanComplete(before);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (wantStills) {
|
|
|
|
|
|
const ctx = await browser.newContext({ viewport: VIEW, colorScheme: COLOR_SCHEME });
|
|
|
|
|
|
await ctx.addInitScript(() => {
|
|
|
|
|
|
try { localStorage.setItem('theme', 'light'); } catch {}
|
|
|
|
|
|
});
|
|
|
|
|
|
const page = await ctx.newPage();
|
|
|
|
|
|
|
|
|
|
|
|
// Phase 1: capture pages that benefit from many findings.
|
|
|
|
|
|
await captureStillsAfterScan1(page);
|
|
|
|
|
|
|
|
|
|
|
|
// Patch demo to v2 + run scan #2 silently to populate the
|
|
|
|
|
|
// trend chart with two data points (second one smaller).
|
|
|
|
|
|
console.error('[setup] patching demo to v2 + running scan #2 via API');
|
|
|
|
|
|
writeDemo('v2');
|
|
|
|
|
|
const token = await csrfToken();
|
|
|
|
|
|
const before = await currentScanId();
|
|
|
|
|
|
await startScanViaApi(token);
|
|
|
|
|
|
await waitForScanComplete(before);
|
|
|
|
|
|
|
|
|
|
|
|
// Phase 2: capture pages whose value depends on the trend or
|
|
|
|
|
|
// are independent of the scan history.
|
|
|
|
|
|
await captureStillsAfterScan2(page);
|
|
|
|
|
|
|
|
|
|
|
|
await ctx.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (wantCli) {
|
|
|
|
|
|
captureCli();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 04:38:04 -04:00
|
|
|
|
if (wantCombo) {
|
|
|
|
|
|
await captureComboGif();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (wantStills || wantCli || wantGif || wantCombo) {
|
2026-04-29 00:58:38 -04:00
|
|
|
|
// Frame phase — only frame what was captured this run so that
|
|
|
|
|
|
// already-framed PNGs from prior runs aren't framed again.
|
|
|
|
|
|
// Stills and the GIF use the fixed 1600x992 inner; CLI captures
|
|
|
|
|
|
// use --natural so each command keeps its own height.
|
|
|
|
|
|
const fixed = [];
|
|
|
|
|
|
if (wantStills) fixed.push(...STILLS_PNGS.map((p) => join(OUT_DIR, p)));
|
|
|
|
|
|
if (wantGif) fixed.push(join(OUT_DIR, 'demo.gif'));
|
2026-05-05 18:17:53 -04:00
|
|
|
|
if (wantCli) fixed.push(CLI_GIF);
|
2026-04-29 00:58:38 -04:00
|
|
|
|
if (fixed.length) applyFrames(fixed, { natural: false });
|
|
|
|
|
|
|
|
|
|
|
|
if (wantCli) {
|
|
|
|
|
|
const cli = CLI_PNGS.map((p) => join(OUT_DIR, p));
|
|
|
|
|
|
applyFrames(cli, { natural: true });
|
|
|
|
|
|
}
|
2026-05-05 19:21:11 -04:00
|
|
|
|
|
|
|
|
|
|
syncNyxscanDev();
|
2026-04-29 00:58:38 -04:00
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (browser) await browser.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.error('done');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
main().catch((e) => {
|
|
|
|
|
|
console.error('FAIL:', e);
|
|
|
|
|
|
process.exit(1);
|
|
|
|
|
|
});
|