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:
1 parent
6f629260e7
commit
b308d5594a
4 files changed
+120
-88
No files matched your search
+11
-6
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user