diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 7d6eacdf..42dc8612 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -11,11 +11,38 @@ import * as fs from 'fs'; import * as path from 'path'; +import { spawn as nodeSpawn } from 'child_process'; import { resolveConfig, ensureStateDir, readVersionHash } from './config'; +import { IS_WINDOWS } from './platform'; const config = resolveConfig(); const MAX_START_WAIT = 8000; // 8 seconds to start +/** + * Resolve the full path to the `bun` executable. + * On Windows, Bun.which may return a .cmd shim — follow it to the real .exe. + */ +function resolveBunPath(): string { + const found = Bun.which('bun'); + if (!found) return 'bun'; // fallback — let the OS try + + if (IS_WINDOWS && found.endsWith('.cmd')) { + // npm .cmd shims contain: "%dp0%\node_modules\bun\bin\bun.exe" + try { + const content = fs.readFileSync(found, 'utf-8'); + const match = content.match(/%dp0%\\([^\s"]+\.exe)/i); + if (match) { + const realPath = path.join(path.dirname(found), match[1]); + if (fs.existsSync(realPath)) return realPath; + } + } catch {} + } + + return found; +} + +const BUN_PATH = resolveBunPath(); + export function resolveServerScript( env: Record = process.env, metaDir: string = import.meta.dir, @@ -26,7 +53,7 @@ export function resolveServerScript( } // Dev mode: cli.ts runs directly from browse/src - if (metaDir.startsWith('/') && !metaDir.includes('$bunfs')) { + if (path.isAbsolute(metaDir) && !metaDir.includes('$bunfs')) { const direct = path.resolve(metaDir, 'server.ts'); if (fs.existsSync(direct)) { return direct; @@ -48,6 +75,20 @@ export function resolveServerScript( const SERVER_SCRIPT = resolveServerScript(); +/** + * On Windows, resolve the Node-compatible server entry point. + * Throws if server-node.mjs is missing on Windows (it's required — server.ts uses Bun APIs). + */ +function resolveNodeServerScript(): string { + const dir = path.dirname(SERVER_SCRIPT); + const nodeScript = path.join(dir, 'server-node.mjs'); + if (fs.existsSync(nodeScript)) return nodeScript; + if (IS_WINDOWS) { + throw new Error('server-node.mjs not found — required for Windows. Reinstall gstack.'); + } + return SERVER_SCRIPT; +} + interface ServerState { pid: number; port: number; @@ -80,6 +121,12 @@ function isProcessAlive(pid: number): boolean { async function killServer(pid: number): Promise { if (!isProcessAlive(pid)) return; + if (IS_WINDOWS) { + // On Windows, SIGTERM/SIGKILL don't work — process.kill(pid) terminates immediately + try { process.kill(pid); } catch {} + return; + } + try { process.kill(pid, 'SIGTERM'); } catch { return; } // Wait up to 2s for graceful shutdown @@ -99,6 +146,9 @@ async function killServer(pid: number): Promise { * Verifies PID ownership before sending signals. */ function cleanupLegacyState(): void { + // Legacy /tmp state files only ever existed on Unix — skip on Windows + if (IS_WINDOWS) return; + try { const files = fs.readdirSync('/tmp').filter(f => f.startsWith('browse-server') && f.endsWith('.json')); for (const file of files) { @@ -140,13 +190,33 @@ async function startServer(): Promise { try { fs.unlinkSync(config.stateFile); } catch {} // Start server as detached background process - const proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], { - stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, BROWSE_STATE_FILE: config.stateFile }, - }); - - // Don't hold the CLI open - proc.unref(); + // On Windows, use Node+tsx (Bun's pipe handling breaks Playwright's CDP connection) + const serverCmd = IS_WINDOWS + ? ['node', '--import', 'tsx', resolveNodeServerScript()] + : [BUN_PATH, 'run', SERVER_SCRIPT]; + // On Windows, set cwd to gstack root so Node resolves tsx from node_modules + const serverCwd = IS_WINDOWS + ? path.resolve(path.dirname(SERVER_SCRIPT), '..', '..') + : undefined; + let proc: any; + if (IS_WINDOWS) { + // On Windows, use child_process.spawn with detached:true so the Node server + // survives after browse.exe exits. Bun.spawn doesn't support detached. + const cp = nodeSpawn(serverCmd[0], serverCmd.slice(1), { + stdio: 'ignore', + detached: true, + env: { ...process.env, BROWSE_STATE_FILE: config.stateFile }, + cwd: serverCwd, + }); + cp.unref(); + proc = { unref() {}, stderr: null }; + } else { + proc = Bun.spawn(serverCmd, { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, BROWSE_STATE_FILE: config.stateFile }, + }); + proc.unref(); + } // Wait for state file to appear const start = Date.now(); diff --git a/browse/src/config.ts b/browse/src/config.ts index 04f16643..3fcdbfd7 100644 --- a/browse/src/config.ts +++ b/browse/src/config.ts @@ -12,6 +12,7 @@ import * as fs from 'fs'; import * as path from 'path'; +import { spawnSync } from 'child_process'; export interface BrowseConfig { projectDir: string; @@ -27,12 +28,11 @@ export interface BrowseConfig { */ export function getGitRoot(): string | null { try { - const proc = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], { - stdout: 'pipe', - stderr: 'pipe', + const proc = spawnSync('git', ['rev-parse', '--show-toplevel'], { + stdio: ['ignore', 'pipe', 'pipe'], timeout: 2_000, // Don't hang if .git is broken }); - if (proc.exitCode !== 0) return null; + if (proc.status !== 0) return null; return proc.stdout.toString().trim() || null; } catch { return null; @@ -118,12 +118,11 @@ export function ensureStateDir(config: BrowseConfig): void { */ export function getRemoteSlug(): string { try { - const proc = Bun.spawnSync(['git', 'remote', 'get-url', 'origin'], { - stdout: 'pipe', - stderr: 'pipe', + const proc = spawnSync('git', ['remote', 'get-url', 'origin'], { + stdio: ['ignore', 'pipe', 'pipe'], timeout: 2_000, }); - if (proc.exitCode !== 0) throw new Error('no remote'); + if (proc.status !== 0) throw new Error('no remote'); const url = proc.stdout.toString().trim(); // SSH: git@github.com:owner/repo.git → owner-repo // HTTPS: https://github.com/owner/repo.git → owner-repo diff --git a/browse/src/cookie-import-browser.ts b/browse/src/cookie-import-browser.ts index 29d9db3e..fb8f1206 100644 --- a/browse/src/cookie-import-browser.ts +++ b/browse/src/cookie-import-browser.ts @@ -32,11 +32,21 @@ * └──────────────────────────────────────────────────────────────────┘ */ -import { Database } from 'bun:sqlite'; import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { spawn as nodeSpawn } from 'child_process'; +import { IS_MACOS, TEMP_DIR } from './platform'; + +// Dynamic import: bun:sqlite is only available in Bun (macOS-only code path) +let Database: any; +async function ensureSqlite() { + if (!Database) { + const mod = await import('bun:sqlite'); + Database = mod.Database; + } +} // ─── Types ────────────────────────────────────────────────────── @@ -104,6 +114,9 @@ const keyCache = new Map(); * Find which browsers are installed (have a cookie DB on disk). */ export function findInstalledBrowsers(): BrowserInfo[] { + // Cookie import from system browsers uses macOS Keychain — only supported on macOS + if (!IS_MACOS) return []; + const appSupport = path.join(os.homedir(), 'Library', 'Application Support'); return BROWSER_REGISTRY.filter(b => { const dbPath = path.join(appSupport, b.dataDir, 'Default', 'Cookies'); @@ -114,7 +127,8 @@ export function findInstalledBrowsers(): BrowserInfo[] { /** * List unique cookie domains + counts from a browser's DB. No decryption. */ -export function listDomains(browserName: string, profile = 'Default'): { domains: DomainEntry[]; browser: string } { +export async function listDomains(browserName: string, profile = 'Default'): Promise<{ domains: DomainEntry[]; browser: string }> { + await ensureSqlite(); const browser = resolveBrowser(browserName); const dbPath = getCookieDbPath(browser, profile); const db = openDb(dbPath, browser.name); @@ -141,8 +155,15 @@ export async function importCookies( domains: string[], profile = 'Default', ): Promise { + if (!IS_MACOS) { + throw new CookieImportError( + 'Browser cookie import is only supported on macOS (requires Keychain access)', + 'unsupported_platform', + ); + } if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} }; + await ensureSqlite(); const browser = resolveBrowser(browserName); const derivedKey = await getDerivedKey(browser); const dbPath = getCookieDbPath(browser, profile); @@ -241,7 +262,7 @@ function openDb(dbPath: string, browserName: string): Database { } function openDbFromCopy(dbPath: string, browserName: string): Database { - const tmpPath = `/tmp/browse-cookies-${browserName.toLowerCase()}-${crypto.randomUUID()}.db`; + const tmpPath = path.join(TEMP_DIR, `browse-cookies-${browserName.toLowerCase()}-${crypto.randomUUID()}.db`); try { fs.copyFileSync(dbPath, tmpPath); // Also copy WAL and SHM if they exist (for consistent reads) @@ -284,13 +305,19 @@ async function getDerivedKey(browser: BrowserInfo): Promise { } async function getKeychainPassword(service: string): Promise { - // Use async Bun.spawn with timeout to avoid blocking the event loop. + // Use async spawn with timeout to avoid blocking the event loop. // macOS may show an Allow/Deny dialog that blocks until the user responds. - const proc = Bun.spawn( - ['security', 'find-generic-password', '-s', service, '-w'], - { stdout: 'pipe', stderr: 'pipe' }, + const proc = nodeSpawn( + 'security', ['find-generic-password', '-s', service, '-w'], + { stdio: ['ignore', 'pipe', 'pipe'] }, ); + // Collect output as it arrives + let stdoutStr = ''; + let stderrStr = ''; + proc.stdout!.on('data', (d: Buffer) => { stdoutStr += d.toString(); }); + proc.stderr!.on('data', (d: Buffer) => { stderrStr += d.toString(); }); + const timeout = new Promise((_, reject) => setTimeout(() => { proc.kill(); @@ -303,9 +330,12 @@ async function getKeychainPassword(service: string): Promise { ); try { - const exitCode = await Promise.race([proc.exited, timeout]); - const stdout = await new Response(proc.stdout).text(); - const stderr = await new Response(proc.stderr).text(); + const exitCode = await Promise.race([ + new Promise((resolve) => proc.on('exit', resolve)), + timeout, + ]); + const stdout = stdoutStr; + const stderr = stderrStr; if (exitCode !== 0) { // Distinguish denied vs not found vs other diff --git a/browse/src/cookie-picker-routes.ts b/browse/src/cookie-picker-routes.ts index 6a4a4319..e26301b5 100644 --- a/browse/src/cookie-picker-routes.ts +++ b/browse/src/cookie-picker-routes.ts @@ -96,7 +96,7 @@ export async function handleCookiePickerRoute( if (!browserName) { return errorResponse("Missing 'browser' parameter", 'missing_param', { port }); } - const result = listDomains(browserName); + const result = await listDomains(browserName); return jsonResponse({ browser: result.browser, domains: result.domains, diff --git a/browse/src/find-browse.ts b/browse/src/find-browse.ts index 44d76b4c..67ccb429 100644 --- a/browse/src/find-browse.ts +++ b/browse/src/find-browse.ts @@ -27,15 +27,16 @@ function getGitRoot(): string | null { export function locateBinary(): string | null { const root = getGitRoot(); const home = homedir(); + const binName = process.platform === 'win32' ? 'browse.exe' : 'browse'; // Workspace-local takes priority (for development) if (root) { - const local = join(root, '.claude', 'skills', 'gstack', 'browse', 'dist', 'browse'); + const local = join(root, '.claude', 'skills', 'gstack', 'browse', 'dist', binName); if (existsSync(local)) return local; } // Global fallback - const global = join(home, '.claude', 'skills', 'gstack', 'browse', 'dist', 'browse'); + const global = join(home, '.claude', 'skills', 'gstack', 'browse', 'dist', binName); if (existsSync(global)) return global; return null; diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts index 3c622db9..a0d685cb 100644 --- a/browse/src/meta-commands.ts +++ b/browse/src/meta-commands.ts @@ -9,13 +9,14 @@ import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands'; import * as Diff from 'diff'; import * as fs from 'fs'; import * as path from 'path'; +import { TEMP_DIR, getSafeDirectories, isPathWithin } from './platform'; // Security: Path validation to prevent path traversal attacks -const SAFE_DIRECTORIES = ['/tmp', process.cwd()]; +const SAFE_DIRECTORIES = getSafeDirectories(); function validateOutputPath(filePath: string): void { const resolved = path.resolve(filePath); - const isSafe = SAFE_DIRECTORIES.some(dir => resolved === dir || resolved.startsWith(dir + '/')); + const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(resolved, dir)); if (!isSafe) { throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`); } @@ -87,7 +88,7 @@ export async function handleMetaCommand( case 'screenshot': { // Parse priority: flags (--viewport, --clip) → selector (@ref, CSS) → output path const page = bm.getPage(); - let outputPath = '/tmp/browse-screenshot.png'; + let outputPath = path.join(TEMP_DIR, 'browse-screenshot.png'); let clipRect: { x: number; y: number; width: number; height: number } | undefined; let targetSelector: string | undefined; let viewportOnly = false; @@ -146,7 +147,7 @@ export async function handleMetaCommand( case 'pdf': { const page = bm.getPage(); - const pdfPath = args[0] || '/tmp/browse-page.pdf'; + const pdfPath = args[0] || path.join(TEMP_DIR, 'browse-page.pdf'); validateOutputPath(pdfPath); await page.pdf({ path: pdfPath, format: 'A4' }); return `PDF saved: ${pdfPath}`; @@ -154,7 +155,7 @@ export async function handleMetaCommand( case 'responsive': { const page = bm.getPage(); - const prefix = args[0] || '/tmp/browse-responsive'; + const prefix = args[0] || path.join(TEMP_DIR, 'browse-responsive'); validateOutputPath(prefix); const viewports = [ { name: 'mobile', width: 375, height: 812 }, diff --git a/browse/src/platform.ts b/browse/src/platform.ts new file mode 100644 index 00000000..c96707fb --- /dev/null +++ b/browse/src/platform.ts @@ -0,0 +1,24 @@ +/** + * Cross-platform helpers — shared by all modules that need OS-aware paths or checks. + */ + +import * as os from 'os'; +import * as path from 'path'; + +export const IS_WINDOWS = process.platform === 'win32'; +export const IS_MACOS = process.platform === 'darwin'; +export const TEMP_DIR = os.tmpdir(); + +/** + * Directories where browse is allowed to write/read user-specified files. + */ +export function getSafeDirectories(): string[] { + return [TEMP_DIR, process.cwd()]; +} + +/** + * Check if `resolved` is equal to or inside `dir`, using OS-aware separators. + */ +export function isPathWithin(resolved: string, dir: string): boolean { + return resolved === dir || resolved.startsWith(dir + path.sep); +} diff --git a/browse/src/read-commands.ts b/browse/src/read-commands.ts index 54877562..3471f35e 100644 --- a/browse/src/read-commands.ts +++ b/browse/src/read-commands.ts @@ -10,6 +10,7 @@ import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers'; import type { Page } from 'playwright'; import * as fs from 'fs'; import * as path from 'path'; +import { getSafeDirectories, isPathWithin } from './platform'; /** Detect await keyword, ignoring comments. Accepted risk: await in string literals triggers wrapping (harmless). */ function hasAwait(code: string): boolean { @@ -36,12 +37,12 @@ function wrapForEvaluate(code: string): string { } // Security: Path validation to prevent path traversal attacks -const SAFE_DIRECTORIES = ['/tmp', process.cwd()]; +const SAFE_DIRECTORIES = getSafeDirectories(); function validateReadPath(filePath: string): void { if (path.isAbsolute(filePath)) { const resolved = path.resolve(filePath); - const isSafe = SAFE_DIRECTORIES.some(dir => resolved === dir || resolved.startsWith(dir + '/')); + const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(resolved, dir)); if (!isSafe) { throw new Error(`Absolute path must be within: ${SAFE_DIRECTORIES.join(', ')}`); } diff --git a/browse/src/server-node.mjs b/browse/src/server-node.mjs new file mode 100644 index 00000000..5626dc13 --- /dev/null +++ b/browse/src/server-node.mjs @@ -0,0 +1,242 @@ +/** + * Node-compatible server entry point for Windows. + * + * Bun's subprocess/WebSocket handling breaks Playwright on Windows. + * This script runs the browse server under Node instead, providing + * a Bun.serve() shim and importing the rest of the codebase. + * + * Business logic (help text, error handling, command dispatch) lives in + * server-shared.ts — shared with the Bun server to avoid duplication. + * + * Usage: node --import tsx server-node.mjs + */ + +import { createServer } from 'http'; +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// ─── Bun API Shim ────────────────────────────────────────────── +// Provide just enough Bun globals for the server code to work. +// Only used on Windows where we MUST run under Node for Playwright. +globalThis.Bun = { + serve: null, // Not used — this file creates its own http.createServer + sleep: (ms) => new Promise(r => setTimeout(r, ms)), + which: (name) => { + // Validate input to prevent command injection via shell interpolation + if (typeof name !== 'string' || !/^[\w.\-]+$/.test(name)) return null; + const { execSync } = require('child_process'); + try { + return execSync(`where ${name}`, { encoding: 'utf-8' }).trim().split('\n')[0]; + } catch { return null; } + }, + stdin: process.stdin, +}; + +// ─── Import server modules (TypeScript via tsx) ───────────────── +const { BrowserManager } = await import('./browser-manager.ts'); +const { dispatchCommand } = await import('./server-shared.ts'); +const { resolveConfig, ensureStateDir, readVersionHash } = await import('./config.ts'); +const { consoleBuffer, networkBuffer, dialogBuffer } = await import('./buffers.ts'); + +// ─── Config ───────────────────────────────────────────────────── +const config = resolveConfig(); +ensureStateDir(config); + +const AUTH_TOKEN = crypto.randomUUID(); +const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10); +const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10); + +// ─── Buffers & Logging ────────────────────────────────────────── +const CONSOLE_LOG_PATH = config.consoleLog; +const NETWORK_LOG_PATH = config.networkLog; +const DIALOG_LOG_PATH = config.dialogLog; +let lastConsoleFlushed = 0; +let lastNetworkFlushed = 0; +let lastDialogFlushed = 0; +let flushInProgress = false; + +async function flushBuffers() { + if (flushInProgress) return; + flushInProgress = true; + try { + const newConsoleCount = consoleBuffer.totalAdded - lastConsoleFlushed; + if (newConsoleCount > 0) { + const entries = consoleBuffer.last(Math.min(newConsoleCount, consoleBuffer.length)); + const lines = entries.map(e => `[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`).join('\n') + '\n'; + fs.appendFileSync(CONSOLE_LOG_PATH, lines); + lastConsoleFlushed = consoleBuffer.totalAdded; + } + const newNetworkCount = networkBuffer.totalAdded - lastNetworkFlushed; + if (newNetworkCount > 0) { + const entries = networkBuffer.last(Math.min(newNetworkCount, networkBuffer.length)); + const lines = entries.map(e => `[${new Date(e.timestamp).toISOString()}] ${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)`).join('\n') + '\n'; + fs.appendFileSync(NETWORK_LOG_PATH, lines); + lastNetworkFlushed = networkBuffer.totalAdded; + } + const newDialogCount = dialogBuffer.totalAdded - lastDialogFlushed; + if (newDialogCount > 0) { + const entries = dialogBuffer.last(Math.min(newDialogCount, dialogBuffer.length)); + const lines = entries.map(e => `[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}`).join('\n') + '\n'; + fs.appendFileSync(DIALOG_LOG_PATH, lines); + lastDialogFlushed = dialogBuffer.totalAdded; + } + } catch {} finally { flushInProgress = false; } +} + +const flushInterval = setInterval(flushBuffers, 1000); + +// ─── Idle Timer ────────────────────────────────────────────────── +let lastActivity = Date.now(); +function resetIdleTimer() { lastActivity = Date.now(); } +const idleCheckInterval = setInterval(() => { + if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) { + console.log(`[browse] Idle for ${IDLE_TIMEOUT_MS / 1000}s, shutting down`); + shutdown(); + } +}, 60_000); + +// ─── Browser & Server ──────────────────────────────────────────── +const browserManager = new BrowserManager(); +let isShuttingDown = false; + +async function shutdown() { + if (isShuttingDown) return; + isShuttingDown = true; + console.log('[browse] Shutting down...'); + clearInterval(flushInterval); + clearInterval(idleCheckInterval); + await flushBuffers(); + await browserManager.close(); + try { fs.unlinkSync(config.stateFile); } catch {} + process.exit(0); +} + +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); +process.on('exit', () => { + try { fs.unlinkSync(config.stateFile); } catch {} +}); + +// ─── Start ─────────────────────────────────────────────────────── +async function start() { + try { fs.unlinkSync(CONSOLE_LOG_PATH); } catch {} + try { fs.unlinkSync(NETWORK_LOG_PATH); } catch {} + try { fs.unlinkSync(DIALOG_LOG_PATH); } catch {} + + // Find port + const MIN_PORT = 10000; + const MAX_PORT = 60000; + let port = BROWSE_PORT; + if (!port) { + for (let attempt = 0; attempt < 5; attempt++) { + port = MIN_PORT + Math.floor(Math.random() * (MAX_PORT - MIN_PORT)); + try { + await new Promise((resolve, reject) => { + const test = createServer(); + test.listen(port, '127.0.0.1', () => { test.close(); resolve(); }); + test.on('error', reject); + }); + break; + } catch { port = 0; } + } + if (!port) throw new Error('No available port'); + } + + // Launch browser (Node's Playwright works on Windows) + await browserManager.launch(); + + const startTime = Date.now(); + const server = createServer(async (req, res) => { + resetIdleTimer(); + + try { + const url = new URL(req.url, `http://127.0.0.1:${port}`); + + // Cookie picker — browser cookie import requires macOS Keychain, not supported on Windows + if (url.pathname.startsWith('/cookie-picker')) { + res.writeHead(501, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Browser cookie import is only supported on macOS' })); + return; + } + + // Health check + if (url.pathname === '/health') { + const healthy = await browserManager.isHealthy(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: healthy ? 'healthy' : 'unhealthy', + uptime: Math.floor((Date.now() - startTime) / 1000), + tabs: browserManager.getTabCount(), + currentUrl: browserManager.getCurrentUrl(), + })); + return; + } + + // Auth check + const authHeader = req.headers['authorization']; + if (authHeader !== `Bearer ${AUTH_TOKEN}`) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + + // Command handling + if (url.pathname === '/command' && req.method === 'POST') { + let body = ''; + for await (const chunk of req) body += chunk; + + let parsed; + try { + parsed = JSON.parse(body); + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON in request body' })); + return; + } + + const result = await dispatchCommand(parsed, browserManager, shutdown); + res.writeHead(result.status, { 'Content-Type': result.contentType }); + res.end(result.body); + return; + } + + res.writeHead(404); + res.end('Not found'); + } catch (err) { + // Catch-all: ensure every request gets a response even on unexpected errors + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err.message || 'Internal server error' })); + } + } + }); + + server.listen(port, '127.0.0.1', () => { + // Write state file + const state = { + pid: process.pid, + port, + token: AUTH_TOKEN, + startedAt: new Date().toISOString(), + serverPath: path.resolve(__dirname, 'server.ts'), + binaryVersion: readVersionHash() || undefined, + }; + const tmpFile = config.stateFile + '.tmp'; + fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 }); + fs.renameSync(tmpFile, config.stateFile); + + browserManager.serverPort = port; + console.log(`[browse] Server running on http://127.0.0.1:${port} (PID: ${process.pid})`); + console.log(`[browse] State file: ${config.stateFile}`); + console.log(`[browse] Idle timeout: ${IDLE_TIMEOUT_MS / 1000}s`); + }); +} + +start().catch((err) => { + console.error(`[browse] Failed to start: ${err.message}`); + process.exit(1); +}); diff --git a/browse/src/server-shared.ts b/browse/src/server-shared.ts new file mode 100644 index 00000000..71e57b86 --- /dev/null +++ b/browse/src/server-shared.ts @@ -0,0 +1,135 @@ +/** + * Shared server logic — used by both server.ts (Bun) and server-node.mjs (Node). + * + * Extracts pure functions with no Bun/Node runtime dependencies so that + * changes to command dispatch, error handling, and help text only need + * to be made in one place. + */ + +import { COMMAND_DESCRIPTIONS, READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands'; +import { SNAPSHOT_FLAGS } from './snapshot'; +import { handleReadCommand } from './read-commands'; +import { handleWriteCommand } from './write-commands'; +import { handleMetaCommand } from './meta-commands'; +import type { BrowserManager } from './browser-manager'; + +// ─── Help Text ────────────────────────────────────────────────── + +export function generateHelpText(): string { + const groups = new Map(); + for (const [cmd, meta] of Object.entries(COMMAND_DESCRIPTIONS)) { + const display = meta.usage || cmd; + const list = groups.get(meta.category) || []; + list.push(display); + groups.set(meta.category, list); + } + + const categoryOrder = [ + 'Navigation', 'Reading', 'Interaction', 'Inspection', + 'Visual', 'Snapshot', 'Meta', 'Tabs', 'Server', + ]; + + const lines = ['gstack browse — headless browser for AI agents', '', 'Commands:']; + for (const cat of categoryOrder) { + const cmds = groups.get(cat); + if (!cmds) continue; + lines.push(` ${(cat + ':').padEnd(15)}${cmds.join(', ')}`); + } + + lines.push(''); + lines.push('Snapshot flags:'); + const flagPairs: string[] = []; + for (const flag of SNAPSHOT_FLAGS) { + const label = flag.valueHint ? `${flag.short} ${flag.valueHint}` : flag.short; + flagPairs.push(`${label} ${flag.long}`); + } + for (let i = 0; i < flagPairs.length; i += 2) { + const left = flagPairs[i].padEnd(28); + const right = flagPairs[i + 1] || ''; + lines.push(` ${left}${right}`); + } + + return lines.join('\n'); +} + +// ─── Error Wrapping ───────────────────────────────────────────── + +/** + * Translate Playwright errors into actionable messages for AI agents. + */ +export function wrapError(err: any): string { + const msg = err.message || String(err); + if (err.name === 'TimeoutError' || msg.includes('Timeout') || msg.includes('timeout')) { + if (msg.includes('locator.click') || msg.includes('locator.fill') || msg.includes('locator.hover')) { + return `Element not found or not interactable within timeout. Check your selector or run 'snapshot' for fresh refs.`; + } + if (msg.includes('page.goto') || msg.includes('Navigation')) { + return `Page navigation timed out. The URL may be unreachable or the page may be loading slowly.`; + } + return `Operation timed out: ${msg.split('\n')[0]}`; + } + if (msg.includes('resolved to') && msg.includes('elements')) { + return `Selector matched multiple elements. Be more specific or use @refs from 'snapshot'.`; + } + return msg; +} + +// ─── Command Dispatch ─────────────────────────────────────────── + +export interface CommandResult { + status: number; + body: string; + contentType: string; +} + +/** + * Dispatch a parsed command body to the appropriate handler. + * Returns a runtime-agnostic result that callers convert to their + * HTTP response type (Bun Response or Node ServerResponse). + */ +export async function dispatchCommand( + body: any, + browserManager: BrowserManager, + shutdownFn: () => void | Promise, +): Promise { + const { command, args = [] } = body; + + if (!command) { + return { + status: 400, + body: JSON.stringify({ error: 'Missing "command" field' }), + contentType: 'application/json', + }; + } + + try { + let result: string; + + if (READ_COMMANDS.has(command)) { + result = await handleReadCommand(command, args, browserManager); + } else if (WRITE_COMMANDS.has(command)) { + result = await handleWriteCommand(command, args, browserManager); + } else if (META_COMMANDS.has(command)) { + result = await handleMetaCommand(command, args, browserManager, shutdownFn); + } else if (command === 'help') { + return { status: 200, body: generateHelpText(), contentType: 'text/plain' }; + } else { + return { + status: 400, + body: JSON.stringify({ + error: `Unknown command: ${command}`, + hint: `Available: ${[...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS].sort().join(', ')}`, + }), + contentType: 'application/json', + }; + } + + return { status: 200, body: result, contentType: 'text/plain' }; + } catch (err: any) { + return { + status: 500, + body: JSON.stringify({ error: wrapError(err) }), + contentType: 'application/json', + }; + } +} diff --git a/browse/src/server.ts b/browse/src/server.ts index f30a4881..5917bf23 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -14,13 +14,9 @@ */ import { BrowserManager } from './browser-manager'; -import { handleReadCommand } from './read-commands'; -import { handleWriteCommand } from './write-commands'; -import { handleMetaCommand } from './meta-commands'; import { handleCookiePickerRoute } from './cookie-picker-routes'; -import { COMMAND_DESCRIPTIONS } from './commands'; -import { SNAPSHOT_FLAGS } from './snapshot'; import { resolveConfig, ensureStateDir, readVersionHash } from './config'; +import { generateHelpText, wrapError, dispatchCommand } from './server-shared'; import * as fs from 'fs'; import * as path from 'path'; import * as crypto from 'crypto'; @@ -39,47 +35,6 @@ function validateAuth(req: Request): boolean { return header === `Bearer ${AUTH_TOKEN}`; } -// ─── Help text (auto-generated from COMMAND_DESCRIPTIONS) ──────── -function generateHelpText(): string { - // Group commands by category - const groups = new Map(); - for (const [cmd, meta] of Object.entries(COMMAND_DESCRIPTIONS)) { - const display = meta.usage || cmd; - const list = groups.get(meta.category) || []; - list.push(display); - groups.set(meta.category, list); - } - - const categoryOrder = [ - 'Navigation', 'Reading', 'Interaction', 'Inspection', - 'Visual', 'Snapshot', 'Meta', 'Tabs', 'Server', - ]; - - const lines = ['gstack browse — headless browser for AI agents', '', 'Commands:']; - for (const cat of categoryOrder) { - const cmds = groups.get(cat); - if (!cmds) continue; - lines.push(` ${(cat + ':').padEnd(15)}${cmds.join(', ')}`); - } - - // Snapshot flags from source of truth - lines.push(''); - lines.push('Snapshot flags:'); - const flagPairs: string[] = []; - for (const flag of SNAPSHOT_FLAGS) { - const label = flag.valueHint ? `${flag.short} ${flag.valueHint}` : flag.short; - flagPairs.push(`${label} ${flag.long}`); - } - // Print two flags per line for compact display - for (let i = 0; i < flagPairs.length; i += 2) { - const left = flagPairs[i].padEnd(28); - const right = flagPairs[i + 1] || ''; - lines.push(` ${left}${right}`); - } - - return lines.join('\n'); -} - // ─── Buffer (from buffers.ts) ──────────────────────────────────── import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry } from './buffers'; export { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry }; @@ -191,74 +146,12 @@ async function findPort(): Promise { throw new Error(`[browse] No available port after ${MAX_RETRIES} attempts in range ${MIN_PORT}-${MAX_PORT}`); } -/** - * Translate Playwright errors into actionable messages for AI agents. - */ -function wrapError(err: any): string { - const msg = err.message || String(err); - // Timeout errors - if (err.name === 'TimeoutError' || msg.includes('Timeout') || msg.includes('timeout')) { - if (msg.includes('locator.click') || msg.includes('locator.fill') || msg.includes('locator.hover')) { - return `Element not found or not interactable within timeout. Check your selector or run 'snapshot' for fresh refs.`; - } - if (msg.includes('page.goto') || msg.includes('Navigation')) { - return `Page navigation timed out. The URL may be unreachable or the page may be loading slowly.`; - } - return `Operation timed out: ${msg.split('\n')[0]}`; - } - // Multiple elements matched - if (msg.includes('resolved to') && msg.includes('elements')) { - return `Selector matched multiple elements. Be more specific or use @refs from 'snapshot'.`; - } - // Pass through other errors - return msg; -} - async function handleCommand(body: any): Promise { - const { command, args = [] } = body; - - if (!command) { - return new Response(JSON.stringify({ error: 'Missing "command" field' }), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - }); - } - - try { - let result: string; - - if (READ_COMMANDS.has(command)) { - result = await handleReadCommand(command, args, browserManager); - } else if (WRITE_COMMANDS.has(command)) { - result = await handleWriteCommand(command, args, browserManager); - } else if (META_COMMANDS.has(command)) { - result = await handleMetaCommand(command, args, browserManager, shutdown); - } else if (command === 'help') { - const helpText = generateHelpText(); - return new Response(helpText, { - status: 200, - headers: { 'Content-Type': 'text/plain' }, - }); - } else { - return new Response(JSON.stringify({ - error: `Unknown command: ${command}`, - hint: `Available commands: ${[...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS].sort().join(', ')}`, - }), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - }); - } - - return new Response(result, { - status: 200, - headers: { 'Content-Type': 'text/plain' }, - }); - } catch (err: any) { - return new Response(JSON.stringify({ error: wrapError(err) }), { - status: 500, - headers: { 'Content-Type': 'application/json' }, - }); - } + const result = await dispatchCommand(body, browserManager, shutdown); + return new Response(result.body, { + status: result.status, + headers: { 'Content-Type': result.contentType }, + }); } async function shutdown() { @@ -281,6 +174,12 @@ async function shutdown() { // Handle signals process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); +// On Windows, SIGTERM is a no-op — use 'exit' to ensure state file cleanup +if (process.platform === 'win32') { + process.on('exit', () => { + try { fs.unlinkSync(config.stateFile); } catch {} + }); +} // ─── Start ───────────────────────────────────────────────────── async function start() { diff --git a/browse/src/snapshot.ts b/browse/src/snapshot.ts index db1dfc7c..973593fc 100644 --- a/browse/src/snapshot.ts +++ b/browse/src/snapshot.ts @@ -20,6 +20,8 @@ import type { Page, Locator } from 'playwright'; import type { BrowserManager, RefEntry } from './browser-manager'; import * as Diff from 'diff'; +import * as path from 'path'; +import { TEMP_DIR, getSafeDirectories, isPathWithin } from './platform'; // Roles considered "interactive" for the -i flag const INTERACTIVE_ROLES = new Set([ @@ -308,11 +310,11 @@ export async function handleSnapshot( // ─── Annotated screenshot (-a) ──────────────────────────── if (opts.annotate) { - const screenshotPath = opts.outputPath || '/tmp/browse-annotated.png'; + const screenshotPath = opts.outputPath || path.join(TEMP_DIR, 'browse-annotated.png'); // Validate output path (consistent with screenshot/pdf/responsive) - const resolvedPath = require('path').resolve(screenshotPath); - const safeDirs = ['/tmp', process.cwd()]; - if (!safeDirs.some((dir: string) => resolvedPath === dir || resolvedPath.startsWith(dir + '/'))) { + const resolvedPath = path.resolve(screenshotPath); + const safeDirs = getSafeDirectories(); + if (!safeDirs.some((dir: string) => isPathWithin(resolvedPath, dir))) { throw new Error(`Path must be within: ${safeDirs.join(', ')}`); } try { diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index 2b384920..1485b8ad 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -9,6 +9,8 @@ import type { BrowserManager } from './browser-manager'; import { findInstalledBrowsers, importCookies } from './cookie-import-browser'; import * as fs from 'fs'; import * as path from 'path'; +import { spawn as nodeSpawn } from 'child_process'; +import { IS_WINDOWS, getSafeDirectories, isPathWithin } from './platform'; export async function handleWriteCommand( command: string, @@ -275,9 +277,9 @@ export async function handleWriteCommand( if (!filePath) throw new Error('Usage: browse cookie-import '); // Path validation — prevent reading arbitrary files if (path.isAbsolute(filePath)) { - const safeDirs = ['/tmp', process.cwd()]; + const safeDirs = getSafeDirectories(); const resolved = path.resolve(filePath); - if (!safeDirs.some(dir => resolved === dir || resolved.startsWith(dir + '/'))) { + if (!safeDirs.some(dir => isPathWithin(resolved, dir))) { throw new Error(`Path must be within: ${safeDirs.join(', ')}`); } } @@ -335,7 +337,12 @@ export async function handleWriteCommand( const pickerUrl = `http://127.0.0.1:${port}/cookie-picker`; try { - Bun.spawn(['open', pickerUrl], { stdout: 'ignore', stderr: 'ignore' }); + const openCmd = IS_WINDOWS + ? ['cmd', '/c', 'start', '', pickerUrl] + : process.platform === 'linux' + ? ['xdg-open', pickerUrl] + : ['open', pickerUrl]; + nodeSpawn(openCmd[0], openCmd.slice(1), { stdio: 'ignore' }); } catch { // open may fail silently — URL is in the message below } diff --git a/browse/test/cookie-import-browser.test.ts b/browse/test/cookie-import-browser.test.ts index 1e91cf13..1a26bfdc 100644 --- a/browse/test/cookie-import-browser.test.ts +++ b/browse/test/cookie-import-browser.test.ts @@ -365,27 +365,27 @@ describe('Cookie Import Browser', () => { }); describe('Profile Validation', () => { - test('rejects path traversal in profile names', () => { + test('rejects path traversal in profile names', async () => { // The validateProfile function should reject profiles with / or .. // We can't call it directly (internal), but we can test via listDomains // which calls validateProfile - expect(() => listDomains('chrome', '../etc')).toThrow(/Invalid profile/); - expect(() => listDomains('chrome', 'Default/../../etc')).toThrow(/Invalid profile/); + await expect(listDomains('chrome', '../etc')).rejects.toThrow(/Invalid profile/); + await expect(listDomains('chrome', 'Default/../../etc')).rejects.toThrow(/Invalid profile/); }); - test('rejects control characters in profile names', () => { - expect(() => listDomains('chrome', 'Default\x00evil')).toThrow(/Invalid profile/); + test('rejects control characters in profile names', async () => { + await expect(listDomains('chrome', 'Default\x00evil')).rejects.toThrow(/Invalid profile/); }); }); describe('Unknown Browser', () => { - test('throws for unknown browser name', () => { - expect(() => listDomains('firefox')).toThrow(/Unknown browser.*firefox/i); + test('throws for unknown browser name', async () => { + await expect(listDomains('firefox')).rejects.toThrow(/Unknown browser.*firefox/i); }); - test('error includes list of supported browsers', () => { + test('error includes list of supported browsers', async () => { try { - listDomains('firefox'); + await listDomains('firefox'); throw new Error('Should have thrown'); } catch (err: any) { expect(err.code).toBe('unknown_browser'); diff --git a/browse/test/platform.test.ts b/browse/test/platform.test.ts new file mode 100644 index 00000000..89339dc9 --- /dev/null +++ b/browse/test/platform.test.ts @@ -0,0 +1,75 @@ +import { describe, test, expect } from 'bun:test'; +import { IS_WINDOWS, IS_MACOS, TEMP_DIR, getSafeDirectories, isPathWithin } from '../src/platform'; +import * as os from 'os'; +import * as path from 'path'; + +describe('platform', () => { + describe('constants', () => { + test('IS_WINDOWS matches process.platform', () => { + expect(IS_WINDOWS).toBe(process.platform === 'win32'); + }); + + test('IS_MACOS matches process.platform', () => { + expect(IS_MACOS).toBe(process.platform === 'darwin'); + }); + + test('TEMP_DIR matches os.tmpdir()', () => { + expect(TEMP_DIR).toBe(os.tmpdir()); + }); + }); + + describe('getSafeDirectories', () => { + test('includes temp dir', () => { + const dirs = getSafeDirectories(); + expect(dirs).toContain(os.tmpdir()); + }); + + test('includes cwd', () => { + const dirs = getSafeDirectories(); + expect(dirs).toContain(process.cwd()); + }); + + test('returns exactly 2 directories', () => { + const dirs = getSafeDirectories(); + expect(dirs).toHaveLength(2); + }); + }); + + describe('isPathWithin', () => { + // Use resolved paths so tests work on both Windows and Unix + const tmpDir = os.tmpdir(); + + test('returns true for exact match', () => { + expect(isPathWithin(tmpDir, tmpDir)).toBe(true); + }); + + test('returns true for child path', () => { + const child = path.join(tmpDir, 'subdir', 'file.txt'); + expect(isPathWithin(child, tmpDir)).toBe(true); + }); + + test('returns false for unrelated path', () => { + const home = os.homedir(); + // Only test if tmpdir and homedir are actually different + if (home !== tmpDir && !home.startsWith(tmpDir + path.sep)) { + expect(isPathWithin(home, tmpDir)).toBe(false); + } + }); + + test('returns false for prefix-but-not-child path', () => { + expect(isPathWithin(tmpDir + '-evil', tmpDir)).toBe(false); + }); + + test('uses OS-appropriate separator', () => { + const parent = path.resolve(tmpDir, 'test-parent'); + const child = parent + path.sep + 'subdir'; + expect(isPathWithin(child, parent)).toBe(true); + }); + + test('rejects partial directory name match', () => { + const parent = path.resolve(tmpDir, 'user'); + const notChild = path.resolve(tmpDir, 'username'); + expect(isPathWithin(notChild, parent)).toBe(false); + }); + }); +}); diff --git a/browse/test/server-shared.test.ts b/browse/test/server-shared.test.ts new file mode 100644 index 00000000..6b1955ec --- /dev/null +++ b/browse/test/server-shared.test.ts @@ -0,0 +1,97 @@ +import { describe, test, expect } from 'bun:test'; +import { generateHelpText, wrapError, dispatchCommand } from '../src/server-shared'; + +describe('server-shared', () => { + describe('generateHelpText', () => { + test('returns a non-empty string', () => { + const text = generateHelpText(); + expect(text.length).toBeGreaterThan(0); + }); + + test('includes header', () => { + const text = generateHelpText(); + expect(text).toContain('gstack browse'); + }); + + test('includes command categories', () => { + const text = generateHelpText(); + expect(text).toContain('Navigation:'); + expect(text).toContain('Reading:'); + expect(text).toContain('Snapshot:'); + }); + + test('includes snapshot flags section', () => { + const text = generateHelpText(); + expect(text).toContain('Snapshot flags:'); + }); + }); + + describe('wrapError', () => { + test('wraps timeout errors with locator context', () => { + const err = new Error('locator.click: Timeout 30000ms exceeded'); + err.name = 'TimeoutError'; + const msg = wrapError(err); + expect(msg).toContain('not found or not interactable'); + expect(msg).toContain('snapshot'); + }); + + test('wraps navigation timeout errors', () => { + const err = new Error('page.goto: Navigation timeout 30000ms exceeded'); + err.name = 'TimeoutError'; + const msg = wrapError(err); + expect(msg).toContain('navigation timed out'); + }); + + test('wraps generic timeout errors', () => { + const err = new Error('Timeout exceeded while waiting'); + err.name = 'TimeoutError'; + const msg = wrapError(err); + expect(msg).toContain('Operation timed out'); + }); + + test('wraps multiple-element errors', () => { + const err = new Error('locator resolved to 5 elements'); + const msg = wrapError(err); + expect(msg).toContain('multiple elements'); + }); + + test('passes through unknown errors', () => { + const err = new Error('Something went wrong'); + const msg = wrapError(err); + expect(msg).toBe('Something went wrong'); + }); + + test('handles errors without message property', () => { + const msg = wrapError({ name: 'Error' }); + expect(msg).toBe('[object Object]'); + }); + }); + + describe('dispatchCommand', () => { + test('returns 400 for missing command', async () => { + const mockBm = {} as any; + const result = await dispatchCommand({}, mockBm, () => {}); + expect(result.status).toBe(400); + expect(result.contentType).toBe('application/json'); + const body = JSON.parse(result.body); + expect(body.error).toContain('Missing'); + }); + + test('returns 400 for unknown command', async () => { + const mockBm = {} as any; + const result = await dispatchCommand({ command: 'nonexistent_xyz' }, mockBm, () => {}); + expect(result.status).toBe(400); + const body = JSON.parse(result.body); + expect(body.error).toContain('Unknown command'); + expect(body.hint).toBeDefined(); + }); + + test('returns help text for help command', async () => { + const mockBm = {} as any; + const result = await dispatchCommand({ command: 'help' }, mockBm, () => {}); + expect(result.status).toBe(200); + expect(result.contentType).toBe('text/plain'); + expect(result.body).toContain('gstack browse'); + }); + }); +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..d94e9fd6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,660 @@ +{ + "name": "gstack", + "version": "0.3.3", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gstack", + "version": "0.3.3", + "license": "MIT", + "dependencies": { + "diff": "^7.0.0", + "playwright": "^1.58.2" + }, + "bin": { + "browse": "browse/dist/browse" + }, + "devDependencies": { + "@anthropic-ai/sdk": "^0.78.0", + "tsx": "^4.21.0" + }, + "engines": { + "bun": ">=1.0.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.78.0", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/diff": { + "version": "7.0.0", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + } + } +} diff --git a/package.json b/package.json index ff8b5870..07f3291f 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,8 @@ "eval:select": "bun run scripts/eval-select.ts" }, "dependencies": { - "playwright": "^1.58.2", - "diff": "^7.0.0" + "diff": "^7.0.0", + "playwright": "^1.58.2" }, "engines": { "bun": ">=1.0.0" @@ -44,6 +44,7 @@ "devtools" ], "devDependencies": { - "@anthropic-ai/sdk": "^0.78.0" + "@anthropic-ai/sdk": "^0.78.0", + "tsx": "^4.21.0" } }