#!/usr/bin/env node /** * Run, attach to, seed, or export one QA lane's browser profile. * * npm run lane:browser -- start [--root DIR] [--executable PATH] [--wait-ms MS] * npm run lane:browser -- status [--root DIR] [--json] * npm run lane:browser -- stop [--root DIR] * npm run lane:browser -- attach [--root DIR] [--workspace DIR]... [--dry-run] * npm run lane:browser -- serve [--root DIR] [--executable PATH] [--headless|--no-headless] [--no-seed] [--dry-run] * npm run lane:browser -- import [--root DIR] [--executable PATH] [--from-file PATH] [--replace] * npm run lane:browser -- export --to-file PATH [--root DIR] [--executable PATH] [--host HOST]... [--replace] * * On a maintainer's machine each lane's Chrome is a daemon: `start` launches * the lane profile once on its fixed local debugging port (amber 9331, cobalt * 9332, violet 9333) and every session's `chrome-` server attaches to * it with `--browserUrl`, so no session holds the profile and there is * nothing to quit when a run ends. `attach` runs that server (or prints its * plan with --dry-run, which is the reference for the host MCP entries). * * `serve` is what the repository's .mcp.json runs for `chrome-`: it * seeds the profile from CHICKPEA_LANE_COOKIES_ when that payload is * new (cloud sessions), starts the lane daemon unless one already answers * (headless where Linux has no display), and attaches. A profile a browser * from the old launch-per-server mode still holds is reported, and the * server still attaches so the failure names the holder. Lane browsers are * opt-in: the root comes from --root or CHICKPEA_LANE_CHROME_ROOT, an * owner-only directory outside the repository holding one profile per lane. * Everything this script says goes to stderr, because stdout is the MCP * channel (status and dry-run JSON excepted), and no cookie value is ever * printed. See qa/live/operator/hosts.md. */ import { spawn } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { assertProfileFree, attachPlan, childEnvironment, daemonLaunchArguments, daemonStatus, DEFAULT_COOKIE_HOSTS, ensureOwnerOnlyDirectory, exportCookies, headlessDefault, LaneBrowserError, normalizeHost, resolveChromiumExecutable, resolveProfileRoot, ROOT_VARIABLE, seedProfile, startDaemon, stopDaemon, writeOwnerOnlyFile, } from './lib/lane-browser.mjs'; import { QA_LANES } from './lib/qa-lanes.mjs'; const COMMANDS = new Set(['start', 'status', 'stop', 'attach', 'serve', 'import', 'export']); // The daemon commands run from a verifier's shell, which may not export the // root variable; on a host that has the conventional root they default to it. // `serve`, `import` and `export` stay strictly opt-in. const DEFAULT_ROOT_COMMANDS = new Set(['start', 'status', 'stop', 'attach']); const USAGE = 'Usage: npm run lane:browser -- (start|status|stop|attach|serve|import|export) [options]\n' + ' start [--root DIR] [--executable PATH] [--wait-ms MS] launch the lane daemon on its fixed port, or report it running\n' + ' status [--root DIR] [--json] running, held by a per-session browser, or stopped\n' + ' stop [--root DIR] stop the daemon this root started; never another browser\n' + ' attach [--root DIR] [--workspace DIR]... [--dry-run] run chrome-devtools-mcp against the daemon (host MCP entries)\n' + ' serve [--root DIR] [--executable PATH] [--headless|--no-headless] [--no-seed] [--dry-run] cloud: seed, then launch\n' + ' import [--root DIR] [--executable PATH] [--from-file PATH] [--replace]\n' + ' export --to-file PATH [--root DIR] [--executable PATH] [--host HOST]... [--replace]\n'; export function parseArguments(argv) { const [command, lane, ...rest] = argv; if (!COMMANDS.has(command)) throw new Error(`Choose a command: ${[...COMMANDS].join(', ')}.`); const options = { command, lane, root: undefined, executable: undefined, headless: undefined, seed: true, dryRun: false, fromFile: undefined, toFile: undefined, hosts: [], replace: false, workspaces: [], json: false, waitMs: undefined, }; const value = (flag, index) => { const next = rest[index + 1]; if (next === undefined || next.startsWith('--')) throw new Error(`${flag} needs a value.`); return next; }; for (let index = 0; index < rest.length; index += 1) { const flag = rest[index]; if (flag === '--root') options.root = value(flag, index++); else if (flag === '--executable') options.executable = value(flag, index++); else if (flag === '--headless') options.headless = true; else if (flag === '--no-headless') options.headless = false; else if (flag === '--no-seed') options.seed = false; else if (flag === '--dry-run') options.dryRun = true; else if (flag === '--json') options.json = true; else if (flag === '--from-file') options.fromFile = value(flag, index++); else if (flag === '--to-file') options.toFile = value(flag, index++); else if (flag === '--host') options.hosts.push(normalizeHost(value(flag, index++))); else if (flag === '--workspace') options.workspaces.push(value(flag, index++)); else if (flag === '--wait-ms') { const ms = Number(value(flag, index++)); if (!Number.isInteger(ms) || ms < 1000 || ms > 120_000) throw new Error('--wait-ms must be 1000..120000.'); options.waitMs = ms; } else if (flag === '--replace') options.replace = true; else throw new Error(`Unknown argument "${flag}".`); } if (command === 'status' && lane !== 'all' && !QA_LANES.includes(lane)) throw new Error(`Choose a lane: ${QA_LANES.join(', ')}, or all.`); if (command === 'export' && !options.toFile) throw new Error('export needs --to-file PATH; the payload is never printed.'); for (const file of [options.fromFile, options.toFile, ...options.workspaces]) { if (file !== undefined && !path.isAbsolute(file)) throw new Error('--from-file, --to-file and --workspace must be absolute paths.'); } return options; } function describeSeed(result) { if (result.status === 'no_payload') return `no cookie payload (${result.variable} unset)`; if (result.status === 'skipped') return 'seeding skipped (--no-seed)'; if (result.status.startsWith('profile_')) return `seeding skipped: the profile is ${result.status.slice('profile_'.length)}`; const { marker } = result; const skipped = marker.skipped ? `; skipped ${marker.skipped.expired} expired, ${marker.skipped.sessionOnly} session-only` : ''; return `${result.status.replace('_', ' ')}: ${marker.cookieCount} cookies for ${marker.domains.join(', ')} (${marker.names.join(', ')})${skipped}; digest ${marker.payloadDigest}`; } function describeStatus(status) { if (status.state === 'running') { const since = status.record?.startedAt ? ` since ${status.record.startedAt} (pid ${status.record.pid})` : ' (started outside this tool)'; return `running: ${status.browser} on ${status.url}${since}`; } if (status.state === 'held') { return `held by pid ${status.holder.pid} (${status.holder.command.slice(0, 120) || 'unknown command'}), not answering on ${status.url}; ` + 'that browser belongs to the session that launched it'; } return `stopped; run \`npm run lane:browser -- start ${status.lane}\` to launch it on ${status.url}`; } function runServer(plan, env) { return new Promise((resolve) => { const child = spawn(plan.command, plan.args, { stdio: 'inherit', env: childEnvironment(env) }); const forward = (signal) => () => { if (child.exitCode === null && child.signalCode === null) child.kill(signal); }; const handlers = ['SIGINT', 'SIGTERM', 'SIGHUP'].map((signal) => [signal, forward(signal)]); for (const [signal, handler] of handlers) process.on(signal, handler); child.once('error', (error) => { for (const [signal, handler] of handlers) process.off(signal, handler); process.stderr.write(`chrome-${plan.lane}: could not start ${plan.command}: ${error.message}\n`); resolve(1); }); child.once('exit', (code, signal) => { for (const [signal, handler] of handlers) process.off(signal, handler); resolve(code ?? (signal ? 1 : 0)); }); }); } export async function main(argv, { env = process.env, stdout = process.stdout, stderr = process.stderr } = {}) { if (argv.length === 0 || argv.includes('--help')) { stderr.write(USAGE); return argv.length === 0 ? 2 : 0; } let options; try { options = parseArguments(argv); } catch (error) { stderr.write(`${error.message}\n${USAGE}`); return 2; } try { const conventionalRoot = path.join(homedir(), '.chickpea', 'browsers'); const rootOption = options.root ?? (DEFAULT_ROOT_COMMANDS.has(options.command) && !env[ROOT_VARIABLE]?.trim() && existsSync(conventionalRoot) ? conventionalRoot : undefined); const root = ensureOwnerOnlyDirectory(resolveProfileRoot({ root: rootOption, env })); const label = `chrome-${options.lane}`; if (options.command === 'status') { const lanes = options.lane === 'all' ? [...QA_LANES] : [options.lane]; const statuses = []; for (const lane of lanes) statuses.push(await daemonStatus({ lane, root, env })); if (options.json) stdout.write(`${JSON.stringify(options.lane === 'all' ? statuses : statuses[0], null, 2)}\n`); else for (const status of statuses) stdout.write(`chrome-${status.lane}: ${describeStatus(status)}\n`); return 0; } if (options.command === 'start') { const result = await startDaemon({ lane: options.lane, root, env, executable: options.executable, timeoutMs: options.waitMs }); stderr.write(`${label}: ${result.status === 'running' ? 'already running' : 'started'}: ${result.browser} on ${result.url}` + `${result.record?.pid ? ` (pid ${result.record.pid})` : ''}\n`); return 0; } if (options.command === 'stop') { const result = await stopDaemon({ lane: options.lane, root, env }); stderr.write(`${label}: ${result.status === 'stopped' ? `stopped pid ${result.pid}` : `pid ${result.pid} was not running; record removed`}\n`); return 0; } if (options.command === 'attach') { const plan = attachPlan({ lane: options.lane, root, env, workspaces: options.workspaces }); if (options.dryRun) { stdout.write(`${JSON.stringify(plan, null, 2)}\n`); return 0; } const status = await daemonStatus({ lane: options.lane, root, env }); stderr.write(`${label}: attaching to ${plan.url} (${describeStatus(status)})\n`); return await runServer(plan, env); } const profile = path.join(root, options.lane); if (options.command !== 'export') ensureOwnerOnlyDirectory(profile); if (options.command === 'import') { await assertProfileFree({ lane: options.lane, root, env }); const payloadText = options.fromFile === undefined ? undefined : readFileSync(options.fromFile, 'utf8'); const executable = resolveChromiumExecutable({ executable: options.executable, env, required: true }); const result = await seedProfile({ lane: options.lane, profile, env, replace: options.replace, payloadText, executable }); if (result.status === 'no_payload') throw new LaneBrowserError(`No cookie payload: set ${result.variable} or pass --from-file PATH.`); stderr.write(`${label}: ${describeSeed(result)}\n`); return 0; } if (options.command === 'export') { if (!options.replace && existsSync(options.toFile)) throw new LaneBrowserError(`${options.toFile} exists; pass --replace to overwrite it.`); await assertProfileFree({ lane: options.lane, root, env }); const executable = resolveChromiumExecutable({ executable: options.executable, env, required: true }); const hosts = [...new Set([...DEFAULT_COOKIE_HOSTS, ...options.hosts])]; const { text, summary } = await exportCookies({ lane: options.lane, profile, hosts, executable, env }); writeOwnerOnlyFile(options.toFile, `${text}\n`); stderr.write(`${label}: exported ${summary.cookieCount} cookies (${summary.persistent} persistent) for ${summary.domains.join(', ')} ` + `(${summary.names.join(', ')}) to ${options.toFile}; ${summary.bytes} base64 bytes; digest ${summary.digest}\n` + `Paste the file's content into the cloud environment variable CHICKPEA_LANE_COOKIES_${options.lane.toUpperCase()}, then delete the file.\n`); return 0; } // serve: seed when a payload is new and the profile is free, make sure the lane daemon runs, then attach. const executable = resolveChromiumExecutable({ executable: options.executable, env, required: true }); const before = await daemonStatus({ lane: options.lane, root, env }); const seed = !options.seed ? { status: 'skipped' } : before.state === 'stopped' ? await seedProfile({ lane: options.lane, profile, env, executable }) : { status: `profile_${before.state}` }; const headless = options.headless ?? headlessDefault({ env }); const plan = attachPlan({ lane: options.lane, root, env, workspaces: options.workspaces }); if (options.dryRun) { const status = await daemonStatus({ lane: options.lane, root, env }); stdout.write(`${JSON.stringify({ ...plan, executable, headless, daemon: { state: status.state, launchArgs: daemonLaunchArguments({ profile, port: plan.port, headless }) }, seed: seed.status, marker: seed.marker ?? null, }, null, 2)}\n`); return 0; } let daemon; try { daemon = await startDaemon({ lane: options.lane, root, env, executable, headless: options.headless }); } catch (error) { if (!(error instanceof LaneBrowserError)) throw error; daemon = { status: 'unavailable', message: error.message }; } stderr.write(`${label}: ${daemon.status === 'unavailable' ? daemon.message : `daemon ${daemon.status}: ${daemon.browser} on ${daemon.url}`}; ` + `${describeSeed(seed)}; attaching\n`); return await runServer(plan, env); } catch (error) { if (error instanceof LaneBrowserError) { stderr.write(`${error.message}\n`); return 1; } if (error?.code === 'ENOENT' && options.fromFile !== undefined) { stderr.write(`No payload file at ${options.fromFile}.\n`); return 1; } stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); return 1; } } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { process.exitCode = await main(process.argv.slice(2)); }