/** * Meta commands — tabs, server control, screenshots, chain, diff, snapshot */ import type { BrowserManager } from './browser-manager'; import { handleSnapshot } from './snapshot'; import { getCleanText } from './read-commands'; import * as Diff from 'diff'; import * as fs from 'fs'; import * as path from 'path'; // Security: Path validation to prevent path traversal attacks const SAFE_DIRECTORIES = ['/tmp', process.cwd()]; function validateOutputPath(filePath: string): void { const resolved = path.resolve(filePath); const isSafe = SAFE_DIRECTORIES.some(dir => resolved === dir || resolved.startsWith(dir + '/')); if (!isSafe) { throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`); } } // Command sets for chain routing (mirrors server.ts — kept local to avoid circular import) const CHAIN_READ = new Set([ 'text', 'html', 'links', 'forms', 'accessibility', 'js', 'eval', 'css', 'attrs', 'console', 'network', 'cookies', 'storage', 'perf', 'dialog', 'is', ]); const CHAIN_WRITE = new Set([ 'goto', 'back', 'forward', 'reload', 'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait', 'viewport', 'cookie', 'cookie-import', 'header', 'useragent', 'upload', 'dialog-accept', 'dialog-dismiss', 'cookie-import-browser', ]); const CHAIN_META = new Set([ 'tabs', 'tab', 'newtab', 'closetab', 'status', 'stop', 'restart', 'screenshot', 'pdf', 'responsive', 'chain', 'diff', 'url', 'snapshot', ]); export async function handleMetaCommand( command: string, args: string[], bm: BrowserManager, shutdown: () => Promise | void ): Promise { switch (command) { // ─── Tabs ────────────────────────────────────────── case 'tabs': { const tabs = await bm.getTabListWithTitles(); return tabs.map(t => `${t.active ? '→ ' : ' '}[${t.id}] ${t.title || '(untitled)'} — ${t.url}` ).join('\n'); } case 'tab': { const id = parseInt(args[0], 10); if (isNaN(id)) throw new Error('Usage: browse tab '); bm.switchTab(id); return `Switched to tab ${id}`; } case 'newtab': { const url = args[0]; const id = await bm.newTab(url); return `Opened tab ${id}${url ? ` → ${url}` : ''}`; } case 'closetab': { const id = args[0] ? parseInt(args[0], 10) : undefined; await bm.closeTab(id); return `Closed tab${id ? ` ${id}` : ''}`; } // ─── Server Control ──────────────────────────────── case 'status': { const page = bm.getPage(); const tabs = bm.getTabCount(); return [ `Status: healthy`, `URL: ${page.url()}`, `Tabs: ${tabs}`, `PID: ${process.pid}`, ].join('\n'); } case 'url': { return bm.getCurrentUrl(); } case 'stop': { await shutdown(); return 'Server stopped'; } case 'restart': { // Signal that we want a restart — the CLI will detect exit and restart console.log('[browse] Restart requested. Exiting for CLI to restart.'); await shutdown(); return 'Restarting...'; } // ─── Visual ──────────────────────────────────────── case 'screenshot': { // Parse priority: flags (--viewport, --clip) → selector (@ref, CSS) → output path const page = bm.getPage(); let outputPath = '/tmp/browse-screenshot.png'; let clipRect: { x: number; y: number; width: number; height: number } | undefined; let targetSelector: string | undefined; let viewportOnly = false; const remaining: string[] = []; for (let i = 0; i < args.length; i++) { if (args[i] === '--viewport') { viewportOnly = true; } else if (args[i] === '--clip') { const coords = args[++i]; if (!coords) throw new Error('Usage: screenshot --clip x,y,w,h [path]'); const parts = coords.split(',').map(Number); if (parts.length !== 4 || parts.some(isNaN)) throw new Error('Usage: screenshot --clip x,y,width,height — all must be numbers'); clipRect = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }; } else if (args[i].startsWith('--')) { throw new Error(`Unknown screenshot flag: ${args[i]}`); } else { remaining.push(args[i]); } } // Separate target (selector/@ref) from output path for (const arg of remaining) { if (arg.startsWith('@e') || arg.startsWith('@c') || arg.startsWith('.') || arg.startsWith('#') || arg.includes('[')) { targetSelector = arg; } else { outputPath = arg; } } validateOutputPath(outputPath); if (clipRect && targetSelector) { throw new Error('Cannot use --clip with a selector/ref — choose one'); } if (viewportOnly && clipRect) { throw new Error('Cannot use --viewport with --clip — choose one'); } if (targetSelector) { const resolved = bm.resolveRef(targetSelector); const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector); await locator.screenshot({ path: outputPath, timeout: 5000 }); return `Screenshot saved (element): ${outputPath}`; } if (clipRect) { await page.screenshot({ path: outputPath, clip: clipRect }); return `Screenshot saved (clip ${clipRect.x},${clipRect.y},${clipRect.width},${clipRect.height}): ${outputPath}`; } await page.screenshot({ path: outputPath, fullPage: !viewportOnly }); return `Screenshot saved${viewportOnly ? ' (viewport)' : ''}: ${outputPath}`; } case 'pdf': { const page = bm.getPage(); const pdfPath = args[0] || '/tmp/browse-page.pdf'; validateOutputPath(pdfPath); await page.pdf({ path: pdfPath, format: 'A4' }); return `PDF saved: ${pdfPath}`; } case 'responsive': { const page = bm.getPage(); const prefix = args[0] || '/tmp/browse-responsive'; validateOutputPath(prefix); const viewports = [ { name: 'mobile', width: 375, height: 812 }, { name: 'tablet', width: 768, height: 1024 }, { name: 'desktop', width: 1280, height: 720 }, ]; const originalViewport = page.viewportSize(); const results: string[] = []; for (const vp of viewports) { await page.setViewportSize({ width: vp.width, height: vp.height }); const path = `${prefix}-${vp.name}.png`; await page.screenshot({ path, fullPage: true }); results.push(`${vp.name} (${vp.width}x${vp.height}): ${path}`); } // Restore original viewport if (originalViewport) { await page.setViewportSize(originalViewport); } return results.join('\n'); } // ─── Chain ───────────────────────────────────────── case 'chain': { // Read JSON array from args[0] (if provided) or expect it was passed as body const jsonStr = args[0]; if (!jsonStr) throw new Error('Usage: echo \'[["goto","url"],["text"]]\' | browse chain'); let commands: string[][]; try { commands = JSON.parse(jsonStr); } catch { throw new Error('Invalid JSON. Expected: [["command", "arg1", "arg2"], ...]'); } if (!Array.isArray(commands)) throw new Error('Expected JSON array of commands'); const results: string[] = []; const { handleReadCommand } = await import('./read-commands'); const { handleWriteCommand } = await import('./write-commands'); for (const cmd of commands) { const [name, ...cmdArgs] = cmd; try { let result: string; if (CHAIN_WRITE.has(name)) result = await handleWriteCommand(name, cmdArgs, bm); else if (CHAIN_READ.has(name)) result = await handleReadCommand(name, cmdArgs, bm); else if (CHAIN_META.has(name)) result = await handleMetaCommand(name, cmdArgs, bm, shutdown); else throw new Error(`Unknown command: ${name}`); results.push(`[${name}] ${result}`); } catch (err: any) { results.push(`[${name}] ERROR: ${err.message}`); } } return results.join('\n\n'); } // ─── Diff ────────────────────────────────────────── case 'diff': { const [url1, url2] = args; if (!url1 || !url2) throw new Error('Usage: browse diff '); const page = bm.getPage(); await page.goto(url1, { waitUntil: 'domcontentloaded', timeout: 15000 }); const text1 = await getCleanText(page); await page.goto(url2, { waitUntil: 'domcontentloaded', timeout: 15000 }); const text2 = await getCleanText(page); const changes = Diff.diffLines(text1, text2); const output: string[] = [`--- ${url1}`, `+++ ${url2}`, '']; for (const part of changes) { const prefix = part.added ? '+' : part.removed ? '-' : ' '; const lines = part.value.split('\n').filter(l => l.length > 0); for (const line of lines) { output.push(`${prefix} ${line}`); } } return output.join('\n'); } // ─── Snapshot ───────────────────────────────────── case 'snapshot': { return await handleSnapshot(args, bm); } default: throw new Error(`Unknown meta command: ${command}`); } }