Ctrl K
ComunidadGuías…

scripts/lane-browser.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
/**
 * Run, attach to, seed, or export one QA lane's browser profile.
 *
 *   npm run lane:browser -- start  <lane> [--root DIR] [--executable PATH] [--wait-ms MS]
 *   npm run lane:browser -- status <lane|all> [--root DIR] [--json]
 *   npm run lane:browser -- stop   <lane> [--root DIR]
 *   npm run lane:browser -- attach <lane> [--root DIR] [--workspace DIR]... [--dry-run]
 *   npm run lane:browser -- serve  <lane> [--root DIR] [--executable PATH] [--headless|--no-headless] [--no-seed] [--dry-run]
 *   npm run lane:browser -- import <lane> [--root DIR] [--executable PATH] [--from-file PATH] [--replace]
 *   npm run lane:browser -- export <lane> --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-<lane>` 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-<lane>`: it
 * seeds the profile from CHICKPEA_LANE_COOKIES_<LANE> 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) <amber|cobalt|violet> [options]\n' +
  '  start   [--root DIR] [--executable PATH] [--wait-ms MS]   launch the lane daemon on its fixed port, or report it running\n' +
  '  status  <lane|all> [--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));
}