refactor(doctor/setup): polish UX and dedup code

- Doctor: chalk-colored output ([OK] green, [MISSING] red, etc.)
- Doctor: paths shortened with ~ and tool labels ([Codex], etc.)
- Doctor --fix: skip already-configured files
- TUI: hide cursor during interaction, proper Ctrl+C exit
- Setup: source hint after shell write, dedup shared helpers
- Tests: strip ANSI for assertions
This commit is contained in:
jackwener committed 2026-03-16 14:02:02 +08:00
1 parent 6f629260e7
commit b308d5594a
4 files changed
+120 -88

No files matched your search

+11 -6
View File
@@ -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');
});
});
+64 -24
View File
@@ -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<boolean> {
}
}
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<string[]> {
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);
+26 -45
View File
@@ -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);
}
+19 -13
View File
@@ -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);