diff --git a/src/doctor.test.ts b/src/doctor.test.ts index 4d74579..ef1cb7e 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -86,8 +86,10 @@ describe('json token helpers', () => { }); describe('doctor report rendering', () => { + const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, ''); + it('renders OK-style report when tokens match', () => { - const text = renderBrowserDoctorReport({ + const text = strip(renderBrowserDoctorReport({ envToken: 'abc123', envFingerprint: 'fp1', extensionToken: 'abc123', @@ -98,14 +100,15 @@ describe('doctor report rendering', () => { recommendedFingerprint: 'fp1', warnings: [], issues: [], - }); + })); expect(text).toContain('[OK] Environment token: configured (fp1)'); - expect(text).toContain('[OK] MCP config /tmp/mcp.json: configured (fp1)'); + expect(text).toContain('[OK] /tmp/mcp.json'); + expect(text).toContain('configured (fp1)'); }); it('renders MISMATCH-style report when fingerprints differ', () => { - const text = renderBrowserDoctorReport({ + const text = strip(renderBrowserDoctorReport({ envToken: 'abc123', envFingerprint: 'fp1', extensionToken: null, @@ -116,10 +119,12 @@ describe('doctor report rendering', () => { recommendedFingerprint: 'fp1', warnings: [], issues: ['Detected inconsistent Playwright MCP tokens across env/config files.'], - }); + })); expect(text).toContain('[MISMATCH] Environment token: configured (fp1)'); - expect(text).toContain('[MISMATCH] Shell file /tmp/.zshrc: configured (fp2)'); + expect(text).toContain('[MISMATCH] /tmp/.zshrc'); + expect(text).toContain('configured (fp2)'); expect(text).toContain('[MISMATCH] Recommended token fingerprint: fp1'); }); }); + diff --git a/src/doctor.ts b/src/doctor.ts index 5ab39e7..c25607c 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -4,12 +4,13 @@ import * as path from 'node:path'; import { execSync } from 'node:child_process'; import { createInterface } from 'node:readline/promises'; import { stdin as input, stdout as output } from 'node:process'; +import chalk from 'chalk'; import type { IPage } from './types.js'; import { PlaywrightMCP, getTokenFingerprint } from './browser.js'; import { browserSession } from './runtime.js'; const PLAYWRIGHT_SERVER_NAME = 'playwright'; -const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN'; +export const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN'; const PLAYWRIGHT_EXTENSION_ID = 'mmlmfjhmonkocbjadbfplnigmagldckm'; const TOKEN_LINE_RE = /^(\s*export\s+PLAYWRIGHT_MCP_EXTENSION_TOKEN=)(['"]?)([^'"\\\n]+)\2\s*$/m; export type DoctorOptions = { @@ -56,17 +57,41 @@ export type DoctorReport = { type ReportStatus = 'OK' | 'MISSING' | 'MISMATCH' | 'WARN'; -function label(status: ReportStatus): string { - return `[${status}]`; +function colorLabel(status: ReportStatus): string { + switch (status) { + case 'OK': return chalk.green('[OK]'); + case 'MISSING': return chalk.red('[MISSING]'); + case 'MISMATCH': return chalk.yellow('[MISMATCH]'); + case 'WARN': return chalk.yellow('[WARN]'); + } } function statusLine(status: ReportStatus, text: string): string { - return `${label(status)} ${text}`; + return `${colorLabel(status)} ${text}`; } function tokenSummary(token: string | null, fingerprint: string | null): string { - if (!token) return 'missing'; - return `configured (${fingerprint})`; + if (!token) return chalk.dim('missing'); + return `configured ${chalk.dim(`(${fingerprint})`)}`; +} + +export function shortenPath(p: string): string { + const home = os.homedir(); + return home && p.startsWith(home) ? '~' + p.slice(home.length) : p; +} + +export function toolName(p: string): string { + if (p.includes('.codex/')) return 'Codex'; + if (p.includes('.cursor/')) return 'Cursor'; + if (p.includes('.claude.json')) return 'Claude Code'; + if (p.includes('antigravity')) return 'Antigravity'; + if (p.includes('.gemini/settings')) return 'Gemini CLI'; + if (p.includes('opencode')) return 'OpenCode'; + if (p.includes('Claude/claude_desktop')) return 'Claude Desktop'; + if (p.includes('.vscode/')) return 'VS Code'; + if (p.includes('.mcp.json')) return 'Project MCP'; + if (p.includes('.zshrc') || p.includes('.bashrc') || p.includes('.profile')) return 'Shell'; + return ''; } export function getDefaultShellRcPath(): string { @@ -177,7 +202,7 @@ export function upsertTomlConfigToken(content: string, token: string): string { return `${prefix}[mcp_servers.playwright]\ntype = "stdio"\ncommand = "npx"\nargs = ["-y", "@playwright/mcp@latest", "--extension"]\n\n[mcp_servers.playwright.env]\n${tokenLine}\n`; } -function fileExists(filePath: string): boolean { +export function fileExists(filePath: string): boolean { try { return fs.existsSync(filePath); } catch { @@ -429,7 +454,7 @@ export function renderBrowserDoctorReport(report: DoctorReport): string { ].filter((value): value is string => !!value); const uniqueFingerprints = [...new Set(tokenFingerprints)]; const hasMismatch = uniqueFingerprints.length > 1; - const lines = [`opencli v${report.cliVersion ?? 'unknown'} doctor`, '']; + const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), '']; const extStatus: ReportStatus = !report.extensionToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK'; lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken, report.extensionFingerprint)}`)); @@ -439,13 +464,15 @@ export function renderBrowserDoctorReport(report: DoctorReport): string { for (const shell of report.shellFiles) { const shellStatus: ReportStatus = !shell.token ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK'; - lines.push(statusLine(shellStatus, `Shell file ${shell.path}: ${tokenSummary(shell.token, shell.fingerprint)}`)); + const tool = toolName(shell.path); + const suffix = tool ? chalk.dim(` [${tool}]`) : ''; + lines.push(statusLine(shellStatus, `${shortenPath(shell.path)}${suffix}: ${tokenSummary(shell.token, shell.fingerprint)}`)); } const existingConfigs = report.configs.filter(config => config.exists); const missingConfigCount = report.configs.length - existingConfigs.length; if (existingConfigs.length > 0) { for (const config of existingConfigs) { - const parseSuffix = config.parseError ? ` (parse error: ${config.parseError})` : ''; + const parseSuffix = config.parseError ? chalk.red(` (parse error)`) : ''; const configStatus: ReportStatus = config.parseError ? 'WARN' : !config.token @@ -453,24 +480,26 @@ export function renderBrowserDoctorReport(report: DoctorReport): string { : hasMismatch ? 'MISMATCH' : 'OK'; - lines.push(statusLine(configStatus, `MCP config ${config.path}: ${tokenSummary(config.token, config.fingerprint)}${parseSuffix}`)); + const tool = toolName(config.path); + const suffix = tool ? chalk.dim(` [${tool}]`) : ''; + lines.push(statusLine(configStatus, `${shortenPath(config.path)}${suffix}: ${tokenSummary(config.token, config.fingerprint)}${parseSuffix}`)); } } else { - lines.push(statusLine('MISSING', 'MCP config: no existing config files found in scanned locations')); + lines.push(statusLine('MISSING', 'MCP config: no existing config files found')); } - if (missingConfigCount > 0) lines.push(` Other scanned config locations not present: ${missingConfigCount}`); + if (missingConfigCount > 0) lines.push(chalk.dim(` Other scanned config locations not present: ${missingConfigCount}`)); lines.push(''); lines.push(statusLine( hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN', `Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`, )); if (report.issues.length) { - lines.push('', 'Issues:'); - for (const issue of report.issues) lines.push(`- ${issue}`); + lines.push('', chalk.yellow('Issues:')); + for (const issue of report.issues) lines.push(chalk.dim(` • ${issue}`)); } if (report.warnings.length) { - lines.push('', 'Warnings:'); - for (const warning of report.warnings) lines.push(`- ${warning}`); + lines.push('', chalk.yellow('Warnings:')); + for (const warning of report.warnings) lines.push(chalk.dim(` • ${warning}`)); } return lines.join('\n'); } @@ -485,7 +514,7 @@ async function confirmPrompt(question: string): Promise { } } -function writeFileWithMkdir(filePath: string, content: string): void { +export function writeFileWithMkdir(filePath: string, content: string): void { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, content, 'utf-8'); } @@ -493,27 +522,38 @@ function writeFileWithMkdir(filePath: string, content: string): void { export async function applyBrowserDoctorFix(report: DoctorReport, opts: DoctorOptions = {}): Promise { const token = opts.token ?? report.recommendedToken; if (!token) throw new Error('No Playwright MCP token is available to write. Provide --token first.'); + const fp = getTokenFingerprint(token); const plannedWrites: string[] = []; const shellPath = opts.shellRc ?? report.shellFiles[0]?.path ?? getDefaultShellRcPath(); - plannedWrites.push(shellPath); + const shellStatus = report.shellFiles.find(s => s.path === shellPath); + if (shellStatus?.fingerprint !== fp) plannedWrites.push(shellPath); for (const config of report.configs) { if (!config.writable) continue; + if (config.fingerprint === fp) continue; // already correct plannedWrites.push(config.path); } + if (plannedWrites.length === 0) { + console.log(chalk.green('All config files are already up to date.')); + return []; + } + if (!opts.yes) { - const ok = await confirmPrompt(`Update ${plannedWrites.length} file(s) with Playwright MCP token fingerprint ${getTokenFingerprint(token)}?`); + const ok = await confirmPrompt(`Update ${plannedWrites.length} file(s) with Playwright MCP token fingerprint ${fp}?`); if (!ok) return []; } const written: string[] = []; - const shellBefore = fileExists(shellPath) ? fs.readFileSync(shellPath, 'utf-8') : ''; - writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token)); - written.push(shellPath); + if (plannedWrites.includes(shellPath)) { + const shellBefore = fileExists(shellPath) ? fs.readFileSync(shellPath, 'utf-8') : ''; + writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token)); + written.push(shellPath); + } for (const config of report.configs) { - if (!config.writable || config.parseError) continue; + if (!plannedWrites.includes(config.path)) continue; + if (config.parseError) continue; const before = fileExists(config.path) ? fs.readFileSync(config.path, 'utf-8') : ''; const next = config.format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token); writeFileWithMkdir(config.path, next); diff --git a/src/setup.ts b/src/setup.ts index 244282f..80cd7ed 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -10,47 +10,21 @@ import { createInterface } from 'node:readline/promises'; import { stdin as input, stdout as output } from 'node:process'; import { type DoctorReport, + PLAYWRIGHT_TOKEN_ENV, discoverExtensionToken, + fileExists, getDefaultShellRcPath, runBrowserDoctor, + shortenPath, + toolName, upsertJsonConfigToken, upsertShellToken, upsertTomlConfigToken, + writeFileWithMkdir, } from './doctor.js'; import { getTokenFingerprint } from './browser.js'; import { type CheckboxItem, checkboxPrompt } from './tui.js'; -const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN'; - -function fileExists(p: string): boolean { - try { return fs.statSync(p).isFile() || fs.statSync(p).isDirectory(); } catch { return false; } -} - -function writeFileWithMkdir(filePath: string, content: string) { - const dir = filePath.substring(0, filePath.lastIndexOf('/')); - if (dir && !fileExists(dir)) fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(filePath, content, 'utf-8'); -} - -function shortenPath(p: string): string { - const home = process.env.HOME || process.env.USERPROFILE || ''; - return home && p.startsWith(home) ? '~' + p.slice(home.length) : p; -} - -function toolName(p: string): string { - if (p.includes('.codex/')) return 'Codex'; - if (p.includes('.cursor/')) return 'Cursor'; - if (p.includes('.claude.json')) return 'Claude Code'; - if (p.includes('antigravity')) return 'Antigravity'; - if (p.includes('.gemini/settings')) return 'Gemini CLI'; - if (p.includes('opencode')) return 'OpenCode'; - if (p.includes('Claude/claude_desktop')) return 'Claude Desktop'; - if (p.includes('.vscode/')) return 'VS Code'; - if (p.includes('.mcp.json')) return 'Project MCP'; - if (p.includes('.zshrc') || p.includes('.bashrc') || p.includes('.profile')) return 'Shell'; - return ''; -} - export async function runSetup(opts: { cliVersion?: string; token?: string } = {}) { console.log(); console.log(chalk.bold(' opencli setup') + chalk.dim(' — Playwright MCP token configuration')); @@ -113,8 +87,9 @@ export async function runSetup(opts: { cliVersion?: string; token?: string } = { const shellStatus = report.shellFiles[0]; const shellFp = shellStatus?.fingerprint; const shellOk = shellFp === fingerprint; + const shellTool = toolName(shellPath) || 'Shell'; items.push({ - label: padRight(`${shortenPath(shellPath)}`, 50) + chalk.dim(` [${toolName(shellPath) || 'Shell'}]`), + label: padRight(shortenPath(shellPath), 50) + chalk.dim(` [${shellTool}]`), value: `shell:${shellPath}`, checked: !shellOk, status: shellOk ? `configured (${shellFp})` : shellFp ? `mismatch (${shellFp})` : 'missing', @@ -127,7 +102,7 @@ export async function runSetup(opts: { cliVersion?: string; token?: string } = { const ok = fp === fingerprint; const tool = toolName(config.path); items.push({ - label: padRight(`${shortenPath(config.path)}`, 50) + chalk.dim(tool ? ` [${tool}]` : ''), + label: padRight(shortenPath(config.path), 50) + chalk.dim(tool ? ` [${tool}]` : ''), value: `config:${config.path}`, checked: !ok, status: ok ? `configured (${fp})` : !config.exists ? 'will create' : fp ? `mismatch (${fp})` : 'missing', @@ -147,22 +122,24 @@ export async function runSetup(opts: { cliVersion?: string; token?: string } = { // Step 5: Apply changes const written: string[] = []; + let wroteShell = false; for (const sel of selected) { if (sel.startsWith('shell:')) { - const path = sel.slice('shell:'.length); - const before = fileExists(path) ? fs.readFileSync(path, 'utf-8') : ''; - writeFileWithMkdir(path, upsertShellToken(before, token)); - written.push(path); + const p = sel.slice('shell:'.length); + const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : ''; + writeFileWithMkdir(p, upsertShellToken(before, token)); + written.push(p); + wroteShell = true; } else if (sel.startsWith('config:')) { - const path = sel.slice('config:'.length); - const config = report.configs.find(c => c.path === path); + const p = sel.slice('config:'.length); + const config = report.configs.find(c => c.path === p); if (config && config.parseError) continue; - const before = fileExists(path) ? fs.readFileSync(path, 'utf-8') : ''; - const format = config?.format ?? (path.endsWith('.toml') ? 'toml' : 'json'); + const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : ''; + const format = config?.format ?? (p.endsWith('.toml') ? 'toml' : 'json'); const next = format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token); - writeFileWithMkdir(path, next); - written.push(path); + writeFileWithMkdir(p, next); + written.push(p); } } @@ -172,7 +149,12 @@ export async function runSetup(opts: { cliVersion?: string; token?: string } = { if (written.length > 0) { console.log(chalk.green.bold(` ✓ Updated ${written.length} file(s):`)); for (const p of written) { - console.log(` ${chalk.dim('•')} ${shortenPath(p)}`); + const tool = toolName(p); + console.log(` ${chalk.dim('•')} ${shortenPath(p)}${tool ? chalk.dim(` [${tool}]`) : ''}`); + } + if (wroteShell) { + console.log(); + console.log(chalk.cyan(` 💡 Run ${chalk.bold(`source ${shortenPath(shellPath)}`)} to apply token to current shell.`)); } } else { console.log(chalk.yellow(' No files were changed.')); @@ -181,7 +163,6 @@ export async function runSetup(opts: { cliVersion?: string; token?: string } = { } function padRight(s: string, n: number): string { - // Account for ANSI escape codes in length calculation const visible = s.replace(/\x1b\[[0-9;]*m/g, ''); return visible.length >= n ? s : s + ' '.repeat(n - visible.length); } diff --git a/src/tui.ts b/src/tui.ts index ff01932..713b72f 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -76,28 +76,28 @@ export async function checkboxPrompt( const wasRaw = stdin.isRaw; stdin.setRawMode(true); stdin.resume(); + stdout.write('\x1b[?25l'); // Hide cursor - let rendered = ''; + let firstDraw = true; function draw() { - // Clear previous render - if (rendered) { - const lines = rendered.split('\n').length; + // Clear previous render (skip on first draw) + if (!firstDraw) { + const lines = render().split('\n').length; stdout.write(`\x1b[${lines}A\x1b[J`); } - rendered = render(); - stdout.write(rendered); + firstDraw = false; + stdout.write(render()); } function cleanup() { stdin.setRawMode(wasRaw ?? false); stdin.pause(); stdin.removeListener('data', onData); - // Clear the TUI - if (rendered) { - const lines = rendered.split('\n').length; - stdout.write(`\x1b[${lines}A\x1b[J`); - } + // Clear the TUI and restore cursor + const lines = render().split('\n').length; + stdout.write(`\x1b[${lines}A\x1b[J`); + stdout.write('\x1b[?25h'); // Show cursor } function onData(data: Buffer) { @@ -150,13 +150,19 @@ export async function checkboxPrompt( return; } - // q / Esc / Ctrl+C — cancel - if (key === 'q' || key === '\x1b' || key === '\x03') { + // q / Esc — cancel + if (key === 'q' || key === '\x1b') { cleanup(); stdout.write(` ${chalk.yellow('✗')} ${chalk.dim('Cancelled')}\n\n`); resolve([]); return; } + + // Ctrl+C — exit process + if (key === '\x03') { + cleanup(); + process.exit(130); + } } stdin.on('data', onData);