#!/usr/bin/env node import fs from 'node:fs'; import path from 'node:path'; import util from 'node:util'; import { fileURLToPath } from 'node:url'; import { cac } from '@xmorse/cac'; import pc from 'picocolors'; // Prevent Buffers from dumping hex bytes in util.inspect output. Buffer.prototype[util.inspect.custom] = function () { return ``; }; import { killPortProcess } from './kill-port.js'; import { VERSION, LOG_FILE_PATH, LOG_CDP_FILE_PATH, parseRelayHost } from './utils.js'; import { ensureRelayServer, RELAY_PORT, waitForConnectedExtensions, getExtensionOutdatedWarning, getExtensionStatus, } from './relay-client.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const cliRelayEnv = { PLAYWRITER_AUTO_ENABLE: '1' }; const cli = cac('playwriter'); cli .command('', 'Start the MCP server or controls the browser with -e') .option('--host ', 'Remote relay server host to connect to (or use PLAYWRITER_HOST env var)') .option('--token ', 'Authentication token (or use PLAYWRITER_TOKEN env var)') .option('-s, --session ', 'Session ID (required for -e, get one with `playwriter session new`)') .option('-e, --eval ', 'Execute JavaScript code and exit, read https://playwriter.dev/SKILL.md for usage') .option('--timeout ', 'Execution timeout in milliseconds', { default: 10000 }) .action(async (options) => { // If -e flag is provided, execute code via relay server if (options.eval) { await executeCode({ code: options.eval, timeout: options.timeout || 10000, sessionId: options.session, host: options.host, token: options.token, }); return; } // Otherwise start the MCP server const { startMcp } = await import('./mcp.js'); await startMcp({ host: options.host, token: options.token, }); }); async function getServerUrl(host) { const serverHost = host || process.env.PLAYWRITER_HOST || '127.0.0.1'; const { httpBaseUrl } = parseRelayHost(serverHost, RELAY_PORT); return httpBaseUrl; } async function fetchExtensionsStatus(host) { try { const serverUrl = await getServerUrl(host); const response = await fetch(`${serverUrl}/extensions/status`, { signal: AbortSignal.timeout(2000), }); if (!response.ok) { const fallback = await fetch(`${serverUrl}/extension/status`, { signal: AbortSignal.timeout(2000), }); if (!fallback.ok) { return []; } const fallbackData = (await fallback.json()); if (!fallbackData?.connected) { return []; } return [ { extensionId: 'default', stableKey: undefined, browser: fallbackData?.browser, profile: fallbackData?.profile, activeTargets: fallbackData?.activeTargets, playwriterVersion: fallbackData?.playwriterVersion || null, }, ]; } const data = (await response.json()); return data?.extensions || []; } catch { return []; } } async function executeCode(options) { const { code, timeout, host, token } = options; const cwd = process.cwd(); const sessionId = options.sessionId ? String(options.sessionId) : process.env.PLAYWRITER_SESSION; // Session is required if (!sessionId) { console.error('Error: -s/--session is required.'); console.error('Always run `playwriter session new` first to get a session ID to use.'); process.exit(1); } const serverUrl = await getServerUrl(host); // Ensure relay server is running (only for local) if (!host && !process.env.PLAYWRITER_HOST) { const restarted = await ensureRelayServer({ logger: console, env: cliRelayEnv }); if (restarted) { const connectedExtensions = await waitForConnectedExtensions({ logger: console, timeoutMs: 10000, pollIntervalMs: 250, }); if (connectedExtensions.length === 0) { console.error('Warning: Extension not connected. Commands may fail.'); } } } // Warn once if extension is outdated const extensionStatus = await getExtensionStatus(); const outdatedWarning = getExtensionOutdatedWarning(extensionStatus?.playwriterVersion); if (outdatedWarning) { console.error(outdatedWarning); } // Build request URL with token if provided const executeUrl = `${serverUrl}/cli/execute`; try { const response = await fetch(executeUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token || process.env.PLAYWRITER_TOKEN ? { Authorization: `Bearer ${token || process.env.PLAYWRITER_TOKEN}` } : {}), }, body: JSON.stringify({ sessionId, code, timeout, cwd }), }); if (!response.ok) { const text = await response.text(); console.error(`Error: ${response.status} ${text}`); process.exit(1); } const result = (await response.json()); // Print output if (result.text) { if (result.isError) { console.error(result.text); } else { console.log(result.text); } } // Note: images are base64 encoded, we could save them to files if needed if (result.images && result.images.length > 0) { console.log(`\n${result.images.length} screenshot(s) captured`); } if (result.isError) { process.exit(1); } } catch (error) { if (error.cause?.code === 'ECONNREFUSED') { console.error('Error: Cannot connect to relay server.'); console.error('The Playwriter relay server should start automatically. Check logs at:'); console.error(` ${LOG_FILE_PATH}`); } else { console.error(`Error: ${error.message}`); } process.exit(1); } } // Session management commands cli .command('session new', 'Create a new session and print the session ID') .option('--host ', 'Remote relay server host') .option('--browser ', 'Stable browser key when multiple browsers are connected') .action(async (options) => { const isLocal = !options.host && !process.env.PLAYWRITER_HOST; let extensions = []; if (isLocal) { await ensureRelayServer({ logger: console, env: cliRelayEnv }); extensions = await waitForConnectedExtensions({ timeoutMs: 12000, pollIntervalMs: 250, logger: console, }); if (extensions.length === 0) { console.log(pc.dim('Waiting briefly for extension to reconnect...')); extensions = await waitForConnectedExtensions({ timeoutMs: 10000, pollIntervalMs: 250, logger: console, }); } } else { extensions = await fetchExtensionsStatus(options.host); } if (extensions.length === 0) { console.error('No connected browsers detected. Click the Playwriter extension icon.'); process.exit(1); } // Warn if any connected extension was built with an older playwriter version for (const ext of extensions) { const warning = getExtensionOutdatedWarning(ext.playwriterVersion); if (warning) { console.error(warning); break; } } let selectedExtension = null; if (extensions.length === 1) { selectedExtension = extensions[0]; } else if (!options.browser) { console.log('Multiple browsers detected:\n'); console.log('KEY BROWSER PROFILE'); console.log('----------------------- ------- -------'); for (const extension of extensions) { const label = extension.profile?.email || '(not signed in)'; const stableKey = extension.stableKey || '-'; console.log(`${stableKey.padEnd(23)} ${(extension.browser || 'Chrome').padEnd(7)} ${label}`); } console.log('\nRun again with --browser .'); process.exit(1); } else { const browserArg = options.browser; selectedExtension = extensions.find((extension) => extension.stableKey === browserArg) || null; if (!selectedExtension) { console.error(`Browser not found: ${browserArg}`); process.exit(1); } } if (!selectedExtension) { console.error('Unable to determine browser identity.'); process.exit(1); } try { const serverUrl = await getServerUrl(options.host); const extensionId = selectedExtension.extensionId === 'default' ? null : selectedExtension.stableKey || selectedExtension.extensionId; const cwd = process.cwd(); const response = await fetch(`${serverUrl}/cli/session/new`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ extensionId, cwd }), }); if (!response.ok) { const text = await response.text(); console.error(`Error: ${response.status} ${text}`); process.exit(1); } const result = (await response.json()); console.log(`Session ${result.id} created. Use with: playwriter -s ${result.id} -e "..."`); } catch (error) { console.error(`Error: ${error.message}`); process.exit(1); } }); cli .command('session list', 'List all active sessions') .option('--host ', 'Remote relay server host') .action(async (options) => { if (!options.host && !process.env.PLAYWRITER_HOST) { await ensureRelayServer({ logger: console, env: cliRelayEnv }); } const serverUrl = await getServerUrl(options.host); let sessions = []; try { const response = await fetch(`${serverUrl}/cli/sessions`, { signal: AbortSignal.timeout(2000), }); if (!response.ok) { console.error(`Error: ${response.status} ${await response.text()}`); process.exit(1); } const result = (await response.json()); sessions = result.sessions; } catch (error) { console.error(`Error: ${error.message}`); process.exit(1); } if (sessions.length === 0) { console.log('No active sessions'); return; } const idWidth = Math.max(2, ...sessions.map((session) => String(session.id).length)); const browserWidth = Math.max(7, ...sessions.map((session) => (session.browser || 'Chrome').length)); const profileWidth = Math.max(7, ...sessions.map((session) => (session.profile?.email || '').length || 1)); const extensionWidth = Math.max(2, ...sessions.map((session) => (session.extensionId || '').length || 1)); const stateWidth = Math.max(10, ...sessions.map((session) => session.stateKeys.join(', ').length || 1)); console.log('ID'.padEnd(idWidth) + ' ' + 'BROWSER'.padEnd(browserWidth) + ' ' + 'PROFILE'.padEnd(profileWidth) + ' ' + 'EXT'.padEnd(extensionWidth) + ' ' + 'STATE KEYS'); console.log('-'.repeat(idWidth + browserWidth + profileWidth + extensionWidth + stateWidth + 8)); for (const session of sessions) { const stateStr = session.stateKeys.length > 0 ? session.stateKeys.join(', ') : '-'; const profileLabel = session.profile?.email || '-'; console.log(String(session.id).padEnd(idWidth) + ' ' + (session.browser || 'Chrome').padEnd(browserWidth) + ' ' + profileLabel.padEnd(profileWidth) + ' ' + (session.extensionId || '-').padEnd(extensionWidth) + ' ' + stateStr); } }); cli .command('session delete ', 'Delete a session and clear its state') .option('--host ', 'Remote relay server host') .action(async (sessionId, options) => { const serverUrl = await getServerUrl(options.host); if (!options.host && !process.env.PLAYWRITER_HOST) { await ensureRelayServer({ logger: console, env: cliRelayEnv }); } try { const response = await fetch(`${serverUrl}/cli/session/delete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId }), }); if (!response.ok) { const result = (await response.json()); console.error(`Error: ${result.error}`); process.exit(1); } console.log(`Session ${sessionId} deleted.`); } catch (error) { console.error(`Error: ${error.message}`); process.exit(1); } }); cli .command('session reset ', 'Reset the browser connection for a session') .option('--host ', 'Remote relay server host') .action(async (sessionId, options) => { const cwd = process.cwd(); const serverUrl = await getServerUrl(options.host); if (!options.host && !process.env.PLAYWRITER_HOST) { await ensureRelayServer({ logger: console, env: cliRelayEnv }); } try { const response = await fetch(`${serverUrl}/cli/reset`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId, cwd }), }); if (!response.ok) { const text = await response.text(); console.error(`Error: ${response.status} ${text}`); process.exit(1); } const result = (await response.json()); console.log(`Connection reset successfully. ${result.pagesCount} page(s) available. Current page URL: ${result.pageUrl}`); } catch (error) { console.error(`Error: ${error.message}`); process.exit(1); } }); cli .command('serve', `Start the relay server on this machine (must be the same host where Chrome is running). Remote clients (Docker, other machines) connect via PLAYWRITER_HOST. Use --host localhost for Docker (no token needed) — containers reach it via host.docker.internal. Use --host 0.0.0.0 for LAN/internet access (requires --token).`) .option('--host ', 'Host to bind to (use "localhost" for Docker, "0.0.0.0" for remote access)', { default: '0.0.0.0' }) .option('--token ', 'Authentication token, required when --host is 0.0.0.0 (or use PLAYWRITER_TOKEN env var)') .option('--replace', 'Kill existing server if running') .action(async (options) => { const token = options.token || process.env.PLAYWRITER_TOKEN; const isPublicHost = options.host === '0.0.0.0' || options.host === '::'; if (isPublicHost && !token) { console.error('Error: Authentication token is required when binding to a public host.'); console.error('Provide --token or set PLAYWRITER_TOKEN environment variable.'); process.exit(1); } // Check if server is already running on the port const net = await import('node:net'); const isPortInUse = await new Promise((resolve) => { const socket = new net.Socket(); socket.setTimeout(500); socket.on('connect', () => { socket.destroy(); resolve(true); }); socket.on('timeout', () => { socket.destroy(); resolve(false); }); socket.on('error', () => { resolve(false); }); socket.connect(RELAY_PORT, '127.0.0.1'); }); if (isPortInUse) { if (!options.replace) { console.log(`Playwriter server is already running on port ${RELAY_PORT}`); console.log('Tip: Use --replace to kill the existing server and start a new one.'); process.exit(0); } // Kill existing process on the port console.log(`Killing existing server on port ${RELAY_PORT}...`); await killPortProcess({ port: RELAY_PORT }); } // Lazy-load heavy dependencies only when serve command is used const { createFileLogger } = await import('./create-logger.js'); const { startPlayWriterCDPRelayServer } = await import('./cdp-relay.js'); const logger = createFileLogger(); process.title = 'playwriter-serve'; process.on('uncaughtException', async (err) => { await logger.error('Uncaught Exception:', err); process.exit(1); }); process.on('unhandledRejection', async (reason) => { await logger.error('Unhandled Rejection:', reason); process.exit(1); }); const server = await startPlayWriterCDPRelayServer({ port: RELAY_PORT, host: options.host, token, logger, }); console.log('Playwriter CDP relay server started'); console.log(` Host: ${options.host}`); console.log(` Port: ${RELAY_PORT}`); console.log(` Token: ${token ? '(configured)' : '(none)'}`); console.log(` Logs: ${logger.logFilePath}`); console.log(` CDP Logs: ${LOG_CDP_FILE_PATH}`); console.log(''); console.log(`CDP endpoint: http://${options.host}:${RELAY_PORT}${token ? '?token=' : ''}`); console.log(''); console.log('Press Ctrl+C to stop.'); process.on('SIGINT', () => { console.log('\nShutting down...'); server.close(); process.exit(0); }); process.on('SIGTERM', () => { console.log('\nShutting down...'); server.close(); process.exit(0); }); }); cli.command('logfile', 'Print the path to the relay server log file').action(() => { console.log(`relay: ${LOG_FILE_PATH}`); console.log(`cdp: ${LOG_CDP_FILE_PATH}`); }); cli.command('skill', 'Print the full playwriter usage instructions').action(() => { const skillPath = path.join(__dirname, '..', 'src', 'skill.md'); const content = fs.readFileSync(skillPath, 'utf-8'); console.log(content); }); cli.help(); cli.version(VERSION); cli.parse(); //# sourceMappingURL=cli.js.map