From 68840fc85caf93a5b05a53e40d06048a972d7aab Mon Sep 17 00:00:00 2001 From: jackwener Date: Tue, 17 Mar 2026 17:01:51 +0800 Subject: [PATCH] refactor: P0 architecture improvements - Split browser.ts (700 lines) into src/browser/ module (page, mcp, errors, discover, tabs, index) - Add unified error handling: CliError base class + logger module - Enable TypeScript strict mode, fix 12 type errors - Extract inline build scripts to scripts/clean-yaml.cjs and copy-yaml.cjs - All 178 unit tests pass, build produces 83 entries across 19 sites --- .../cross-project-adapter-migration/SKILL.md | 249 +++++++ .../cross-project-adapter-migration.md | 54 ++ package-lock.json | 4 +- package.json | 6 +- scripts/clean-yaml.cjs | 19 + scripts/copy-yaml.cjs | 21 + src/bilibili.ts | 2 +- src/browser.test.ts | 2 +- src/browser.ts | 698 ------------------ src/browser/discover.ts | 90 +++ src/browser/errors.ts | 89 +++ src/browser/index.ts | 26 + src/browser/mcp.ts | 305 ++++++++ src/browser/page.ts | 152 ++++ src/browser/tabs.ts | 76 ++ src/completion.ts | 4 +- src/doctor.ts | 2 +- src/engine.ts | 13 +- src/errors.ts | 48 ++ src/logger.ts | 57 ++ src/main.ts | 13 +- src/pipeline/executor.ts | 15 +- src/pipeline/steps/browser.ts | 36 +- src/pipeline/steps/intercept.ts | 16 +- src/pipeline/steps/tap.ts | 4 +- src/setup.ts | 2 +- tsconfig.json | 3 +- 27 files changed, 1253 insertions(+), 753 deletions(-) create mode 100644 .agents/skills/cross-project-adapter-migration/SKILL.md create mode 100644 .agents/workflows/cross-project-adapter-migration.md create mode 100644 scripts/clean-yaml.cjs create mode 100644 scripts/copy-yaml.cjs delete mode 100644 src/browser.ts create mode 100644 src/browser/discover.ts create mode 100644 src/browser/errors.ts create mode 100644 src/browser/index.ts create mode 100644 src/browser/mcp.ts create mode 100644 src/browser/page.ts create mode 100644 src/browser/tabs.ts create mode 100644 src/errors.ts create mode 100644 src/logger.ts diff --git a/.agents/skills/cross-project-adapter-migration/SKILL.md b/.agents/skills/cross-project-adapter-migration/SKILL.md new file mode 100644 index 0000000..3c63f5f --- /dev/null +++ b/.agents/skills/cross-project-adapter-migration/SKILL.md @@ -0,0 +1,249 @@ +--- +name: cross-project-adapter-migration +description: "Cross-project CLI command migration workflow for opencli. Use when importing commands from external CLI projects (python/node) like rdt-cli, twitter-cli, etc. Covers: source analysis → gap matrix → batch migration → README/SKILL.md update." +--- + +# Cross-Project Adapter Migration + +> 从外部 CLI 项目(Python/Node/Go 等)批量迁移命令到 opencli 的标准化流程。 + +## When to Use + +- 用户说"把 xxx-cli 的命令迁移过来" +- 用户说"看看 xxx 项目有什么可以借鉴的" +- 用户说"对齐 xxx-cli 的功能" +- 在为新平台扩展 opencli 时,发现已有第三方 CLI 工具 + +## Prerequisites + +- 熟悉 [CLI-EXPLORER.md](file:///Users/jakevin/code/opencli/CLI-EXPLORER.md)(adapter 开发决策树) +- 熟悉 [SKILL.md](file:///Users/jakevin/code/opencli/SKILL.md)(命令参考 & 模板) + +--- + +## Phase 1: 源项目分析 + +### 1.1 克隆 & 理解源项目 + +```bash +# 克隆源项目到 /tmp 做分析 +git clone /tmp/ +``` + +分析重点: +- **命令列表**:找到所有可用命令(查看 CLI 入口文件、help 输出或 README) +- **认证方式**:Cookie?API Key?OAuth?浏览器自动化? +- **数据源**:公开 API?GraphQL?页面抓取? +- **输出字段**:每个命令返回哪些数据字段 + +### 1.2 生成命令清单 + +列出源项目所有命令,包括: + +| 命令 | 类型 | API/方法 | 输出字段 | +|------|------|---------|---------| +| `xxx feed` | Read | `GET /api/feed` | title, author, time | +| `xxx post` | Write | `POST /api/tweet` | status, id | + +--- + +## Phase 2: 功能对比矩阵 + +### 2.1 查看 opencli 现有命令 + +```bash +ls src/clis// # 查看已有适配器 +opencli list | grep # 确认已注册命令 +``` + +### 2.2 生成对比矩阵 + +对每个源项目命令,标注三种状态: + +| 功能 | 源项目 | opencli 现有 | 行动 | +|------|--------|-------------|------| +| feed | ✅ `xxx feed` | ❌ 无 | ✅ **新增** | +| search | ✅ `xxx search` | ✅ `search.ts` | ❌ 已有,跳过 | +| hot | ✅ `xxx hot` | ⚠️ `hot.yaml`(不完整) | ✅ **增强** | +| like | ✅ `xxx like` | ✅ `like.ts` | ❌ 已有,跳过 | + +### 2.3 筛选迁移目标 + +去掉已有的、低价值的,保留高价值缺失命令,按 Read/Write 分类: + +**筛选原则**: +- ✅ 高使用频率的命令优先 +- ✅ 已有但不完整的命令标记为"增强" +- ❌ 源项目特有但 opencli 架构不支持的功能(如需要持久化存储的)跳过 +- ❌ 与现有功能完全重复的跳过 + +--- + +## Phase 3: 批量实现 + +> [!IMPORTANT] +> 实现前必须查阅 [CLI-EXPLORER.md](file:///Users/jakevin/code/opencli/CLI-EXPLORER.md) 确认策略选择。 + +### 3.1 选择实现方式 + +基于决策树分类: + +| 类别 | 方式 | 适用条件 | +|------|------|---------| +| **Read + 简单 API** | YAML pipeline | 纯 fetch/select/map,无复杂 JS | +| **Read + GraphQL/分页/签名** | TypeScript adapter | 需要 JS 逻辑 | +| **Write 操作** | TypeScript + `Strategy.UI` | 点击/输入等 DOM 操作 | +| **Write + API** | TypeScript + `Strategy.COOKIE/HEADER` | 直接 POST API | + +### 3.2 实现顺序 + +**先 Read 后 Write,先 YAML 后 TS**: + +1. **Phase A**: YAML Read 适配器(最快,通常每个 10-20 行) +2. **Phase B**: TS Read 适配器(需要 evaluate/intercept 的) +3. **Phase C**: TS Write 适配器(需 UI 自动化或 POST API) + +### 3.3 实现模板 + +#### YAML Read 适配器模板(Cookie 策略) + +```yaml +site: +name: +description: <描述> +domain: www..com +strategy: cookie +browser: true + +args: + limit: + type: int + default: 20 + +pipeline: + - navigate: https://www..com + - evaluate: | + (async () => { + const res = await fetch('', { credentials: 'include' }); + const d = await res.json(); + return (d.data?.items || []).map(item => ({ + title: item.title, + // ... map source fields + })); + })() + - map: + rank: ${{ index + 1 }} + title: ${{ item.title }} + - limit: ${{ args.limit }} + +columns: [rank, title] +``` + +#### TS Write 适配器模板(UI 策略) + +```typescript +import { cli, Strategy } from '../../registry.js'; + +cli({ + site: '', + name: '', + description: '<描述>', + strategy: Strategy.UI, + args: [{ name: 'target', required: true, help: '<参数说明>' }], + columns: ['status', 'message'], + func: async (page, kwargs) => { + await page.goto(`https://www..com/${kwargs.target}`); + await page.wait({ text: '', timeout: 10 }); + + // 获取 snapshot 找到目标按钮 + const snapshot = await page.accessibility.snapshot(); + // 点击按钮 ... + + return [{ status: 'success', message: ' completed' }]; + }, +}); +``` + +### 3.4 公共模式复用 + +迁移过程中如果发现多个适配器共享逻辑,考虑提取到 `src/.ts` 工具文件: + +```typescript +// src/.ts +export async function fetchWithAuth(page, url) { ... } +export function parseItem(raw) { ... } +``` + +--- + +## Phase 4: 验证 & 发布 + +### 4.1 构建验证 + +```bash +npx tsc --noEmit # TypeScript 编译检查 +opencli list | grep # 确认所有命令已注册 +``` + +### 4.2 运行验证(关键!) + +每个新命令必须实际运行: + +```bash +# Read 命令 +opencli --limit 3 -f json +opencli --limit 3 -v # verbose 查看 pipeline + +# Write 命令(谨慎!会实际操作) +opencli +``` + +### 4.3 更新文档 + +迁移完成后必须更新以下文件: + +1. **README.md** — 在对应平台区域添加新命令示例 +2. **SKILL.md** — 在 Commands Reference 中添加新命令 + +### 4.4 提交 & 推送 + +```bash +git add -A +git commit -m "feat(): migrate commands from + +- Phase A: YAML adapters (read operations) +- Phase B: TS adapters (write operations) +- Source: " +git push +``` + +--- + +## Checklist + +- [ ] 源项目命令清单已生成 +- [ ] 对比矩阵已确认,高价值缺失命令已筛选 +- [ ] 用户确认迁移范围 +- [ ] Phase A: YAML Read 适配器已完成 +- [ ] Phase B: TS Read 适配器已完成 +- [ ] Phase C: TS Write 适配器已完成 +- [ ] `npx tsc --noEmit` 编译通过 +- [ ] 所有新命令已实际运行验证 +- [ ] README.md 已更新 +- [ ] SKILL.md 已更新 +- [ ] 已 commit + push + +## 实战案例参考 + +### rdt-cli → opencli Reddit(2026-03-16) + +- **源项目**: `rdt-cli`(25 个 Python 命令) +- **筛选结果**: 13 个高价值命令 +- **实现**: 7 个 YAML(read) + 6 个 TS(write) +- **产出**: +11 文件,+767 行代码,Reddit 适配器从 4 → 15(+275%) + +### twitter-cli → opencli Twitter(2026-03-16) + +- **源项目**: `twitter-cli`(20+ Python 命令) +- **筛选结果**: 11 个待实现 +- **策略**: Read 用 `Strategy.COOKIE` + GraphQL fetch,Write 用 `Strategy.UI` diff --git a/.agents/workflows/cross-project-adapter-migration.md b/.agents/workflows/cross-project-adapter-migration.md new file mode 100644 index 0000000..b712a86 --- /dev/null +++ b/.agents/workflows/cross-project-adapter-migration.md @@ -0,0 +1,54 @@ +--- +description: Migrate commands from an external CLI project into opencli adapters +--- + +// turbo-all + +## Steps + +1. Clone the source CLI project for analysis: +```bash +git clone /tmp/ +``` + +2. Analyze source project: list all commands, auth method, API endpoints, and output fields. + +3. Check existing opencli adapters for the target site: +```bash +ls src/clis// +opencli list | grep +``` + +4. Generate a comparison matrix table (source commands vs opencli existing). Mark each as: ✅ **New** / ✅ **Enhance** / ❌ **Skip**. Ask user to confirm which commands to migrate. + +5. Implement YAML Read adapters first (highest ROI, 10-20 lines each). Place files in `src/clis//.yaml`. + +6. Implement TS Read adapters for complex cases (GraphQL, pagination, signing). Place files in `src/clis//.ts`. + +7. Implement TS Write adapters using `Strategy.UI` or `Strategy.COOKIE`. Place files in `src/clis//.ts`. + +8. Verify build: +```bash +npx tsc --noEmit +``` + +9. Verify all commands are registered: +```bash +opencli list | grep +``` + +10. Run each new command to verify it works: +```bash +opencli --limit 3 -f json +``` + +11. Update README.md with new command examples in the appropriate platform section. + +12. Update SKILL.md Commands Reference with new commands. + +13. Commit and push: +```bash +git add -A +git commit -m "feat(): migrate commands from " +git push +``` diff --git a/package-lock.json b/package-lock.json index 7388066..b988d96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jackwener/opencli", - "version": "0.7.6", + "version": "0.7.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jackwener/opencli", - "version": "0.7.6", + "version": "0.7.7", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 7035aca..54ec679 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jackwener/opencli", - "version": "0.7.6", + "version": "0.7.7", "publishConfig": { "access": "public" }, @@ -17,8 +17,8 @@ "dev": "tsx src/main.ts", "build": "tsc && npm run clean-yaml && npm run copy-yaml && npm run build-manifest", "build-manifest": "node dist/build-manifest.js", - "clean-yaml": "node -e \"const{readdirSync:r,rmSync:d,existsSync:e,statSync:s}=require('fs'),p=require('path');function w(dir){if(!e(dir))return;for(const f of r(dir)){const fp=p.join(dir,f);s(fp).isDirectory()?w(fp):/\\.ya?ml$/.test(f)&&d(fp)}}w('dist/clis')\"", - "copy-yaml": "node -e \"const{readdirSync:r,copyFileSync:c,mkdirSync:m,existsSync:e,statSync:s}=require('fs'),p=require('path');function w(src,dst){if(!e(src))return;for(const f of r(src)){const sp=p.join(src,f),dp=p.join(dst,f);s(sp).isDirectory()?w(sp,dp):/\\.ya?ml$/.test(f)&&(m(p.dirname(dp),{recursive:!0}),c(sp,dp))}}w('src/clis','dist/clis')\"", + "clean-yaml": "node scripts/clean-yaml.cjs", + "copy-yaml": "node scripts/copy-yaml.cjs", "start": "node dist/main.js", "postinstall": "node scripts/postinstall.js || true", "typecheck": "tsc --noEmit", diff --git a/scripts/clean-yaml.cjs b/scripts/clean-yaml.cjs new file mode 100644 index 0000000..48ca038 --- /dev/null +++ b/scripts/clean-yaml.cjs @@ -0,0 +1,19 @@ +/** + * Clean YAML files from dist/clis/ before copying fresh ones. + */ +const { readdirSync, rmSync, existsSync, statSync } = require('fs'); +const path = require('path'); + +function walk(dir) { + if (!existsSync(dir)) return; + for (const f of readdirSync(dir)) { + const fp = path.join(dir, f); + if (statSync(fp).isDirectory()) { + walk(fp); + } else if (/\.ya?ml$/.test(f)) { + rmSync(fp); + } + } +} + +walk('dist/clis'); diff --git a/scripts/copy-yaml.cjs b/scripts/copy-yaml.cjs new file mode 100644 index 0000000..8eef0bd --- /dev/null +++ b/scripts/copy-yaml.cjs @@ -0,0 +1,21 @@ +/** + * Copy YAML files from src/clis/ to dist/clis/. + */ +const { readdirSync, copyFileSync, mkdirSync, existsSync, statSync } = require('fs'); +const path = require('path'); + +function walk(src, dst) { + if (!existsSync(src)) return; + for (const f of readdirSync(src)) { + const sp = path.join(src, f); + const dp = path.join(dst, f); + if (statSync(sp).isDirectory()) { + walk(sp, dp); + } else if (/\.ya?ml$/.test(f)) { + mkdirSync(path.dirname(dp), { recursive: true }); + copyFileSync(sp, dp); + } + } +} + +walk('src/clis', 'dist/clis'); diff --git a/src/bilibili.ts b/src/bilibili.ts index d92c7c8..4aca0db 100644 --- a/src/bilibili.ts +++ b/src/bilibili.ts @@ -56,7 +56,7 @@ export async function wbiSign( const mixinKey = getMixinKey(imgKey, subKey); const wts = Math.floor(Date.now() / 1000); const sorted: Record = {}; - const allParams = { ...params, wts: String(wts) }; + const allParams: Record = { ...params, wts: String(wts) }; for (const key of Object.keys(allParams).sort()) { sorted[key] = String(allParams[key]).replace(/[!'()*]/g, ''); } diff --git a/src/browser.test.ts b/src/browser.test.ts index f699a72..e3ad022 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { PlaywrightMCP, __test__ } from './browser.js'; +import { PlaywrightMCP, __test__ } from './browser/index.js'; describe('browser helpers', () => { it('creates JSON-RPC requests with unique ids', () => { diff --git a/src/browser.ts b/src/browser.ts deleted file mode 100644 index 0930719..0000000 --- a/src/browser.ts +++ /dev/null @@ -1,698 +0,0 @@ -/** - * Browser interaction via Playwright MCP Bridge extension. - * Connects to an existing Chrome browser through the extension. - */ - -import { spawn, execSync, type ChildProcess } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import { fileURLToPath } from 'node:url'; -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { formatSnapshot } from './snapshotFormatter.js'; -import { PKG_VERSION } from './version.js'; -import { normalizeEvaluateSource } from './pipeline/template.js'; -import { generateInterceptorJs, generateReadInterceptedJs } from './interceptor.js'; -import { withTimeoutMs, DEFAULT_BROWSER_CONNECT_TIMEOUT } from './runtime.js'; -const STDERR_BUFFER_LIMIT = 16 * 1024; -const INITIAL_TABS_TIMEOUT_MS = 1500; -const TAB_CLEANUP_TIMEOUT_MS = 2000; -let _cachedMcpServerPath: string | null | undefined; - -type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'mcp-init' | 'process-exit' | 'unknown'; -type PlaywrightMCPState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed'; - -type ConnectFailureInput = { - kind: ConnectFailureKind; - timeout: number; - hasExtensionToken: boolean; - tokenFingerprint?: string | null; - stderr?: string; - exitCode?: number | null; - rawMessage?: string; -}; - -export function getTokenFingerprint(token: string | undefined): string | null { - if (!token) return null; - return createHash('sha256').update(token).digest('hex').slice(0, 8); -} - -export function formatBrowserConnectError(input: ConnectFailureInput): Error { - const stderr = input.stderr?.trim(); - const suffix = stderr ? `\n\nMCP stderr:\n${stderr}` : ''; - const tokenHint = input.tokenFingerprint ? ` Token fingerprint: ${input.tokenFingerprint}.` : ''; - - if (input.kind === 'missing-token') { - return new Error( - 'Failed to connect to Playwright MCP Bridge: PLAYWRIGHT_MCP_EXTENSION_TOKEN is not set.\n\n' + - 'Without this token, Chrome will show a manual approval dialog for every new MCP connection. ' + - 'Copy the token from the Playwright MCP Bridge extension and set it in BOTH your shell environment and MCP client config.' + - suffix, - ); - } - - if (input.kind === 'extension-not-installed') { - return new Error( - 'Failed to connect to Playwright MCP Bridge: the browser extension did not attach.\n\n' + - 'Make sure Chrome is running and the "Playwright MCP Bridge" extension is installed and enabled. ' + - 'If Chrome shows an approval dialog, click Allow.' + - suffix, - ); - } - - if (input.kind === 'extension-timeout') { - const likelyCause = input.hasExtensionToken - ? `The most likely cause is that PLAYWRIGHT_MCP_EXTENSION_TOKEN does not match the token currently shown by the browser extension.${tokenHint} Re-copy the token from the extension and update BOTH your shell environment and MCP client config.` - : 'PLAYWRIGHT_MCP_EXTENSION_TOKEN is not configured, so the extension may be waiting for manual approval.'; - return new Error( - `Timed out connecting to Playwright MCP Bridge (${input.timeout}s).\n\n` + - `${likelyCause} If a browser prompt is visible, click Allow.` + - suffix, - ); - } - - if (input.kind === 'mcp-init') { - return new Error(`Failed to initialize Playwright MCP: ${input.rawMessage ?? 'unknown error'}${suffix}`); - } - - if (input.kind === 'process-exit') { - return new Error( - `Playwright MCP process exited before the browser connection was established${input.exitCode == null ? '' : ` (code ${input.exitCode})`}.` + - suffix, - ); - } - - return new Error(input.rawMessage ?? 'Failed to connect to browser'); -} - -function inferConnectFailureKind(args: { - hasExtensionToken: boolean; - stderr: string; - rawMessage?: string; - exited?: boolean; -}): ConnectFailureKind { - const haystack = `${args.rawMessage ?? ''}\n${args.stderr}`.toLowerCase(); - - if (!args.hasExtensionToken) - return 'missing-token'; - if (haystack.includes('extension connection timeout') || haystack.includes('playwright mcp bridge')) - return 'extension-not-installed'; - if (args.rawMessage?.startsWith('MCP init failed:')) - return 'mcp-init'; - if (args.exited) - return 'process-exit'; - return 'extension-timeout'; -} - -// JSON-RPC helpers -let _nextId = 1; -function createJsonRpcRequest(method: string, params: Record = {}): { id: number; message: string } { - const id = _nextId++; - return { - id, - message: JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n', - }; -} - -import type { IPage } from './types.js'; - -/** - * Page abstraction wrapping JSON-RPC calls to Playwright MCP. - */ -export class Page implements IPage { - constructor(private _request: (method: string, params?: Record) => Promise) {} - - async call(method: string, params: Record = {}): Promise { - const resp = await this._request(method, params); - if (resp.error) throw new Error(`page.${method}: ${resp.error.message ?? JSON.stringify(resp.error)}`); - // Extract text content from MCP result - const result = resp.result; - if (result?.content) { - const textParts = result.content.filter((c: any) => c.type === 'text'); - if (textParts.length === 1) { - let text = textParts[0].text; - // MCP browser_evaluate returns: "[JSON]\n### Ran Playwright code\n```js\n...\n```" - // Strip the "### Ran Playwright code" suffix to get clean JSON - const codeMarker = text.indexOf('### Ran Playwright code'); - if (codeMarker !== -1) { - text = text.slice(0, codeMarker).trim(); - } - // Also handle "### Result\n[JSON]" format (some MCP versions) - const resultMarker = text.indexOf('### Result\n'); - if (resultMarker !== -1) { - text = text.slice(resultMarker + '### Result\n'.length).trim(); - } - try { return JSON.parse(text); } catch { return text; } - } - } - return result; - } - - // --- High-level methods --- - - async goto(url: string): Promise { - await this.call('tools/call', { name: 'browser_navigate', arguments: { url } }); - } - - async evaluate(js: string): Promise { - // Normalize IIFE format to function format expected by MCP browser_evaluate - const normalized = normalizeEvaluateSource(js); - return this.call('tools/call', { name: 'browser_evaluate', arguments: { function: normalized } }); - } - - async snapshot(opts: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean } = {}): Promise { - const raw = await this.call('tools/call', { name: 'browser_snapshot', arguments: {} }); - if (opts.raw) return raw; - if (typeof raw === 'string') return formatSnapshot(raw, opts); - return raw; - } - - async click(ref: string): Promise { - await this.call('tools/call', { name: 'browser_click', arguments: { element: 'click target', ref } }); - } - - async typeText(ref: string, text: string): Promise { - await this.call('tools/call', { name: 'browser_type', arguments: { element: 'type target', ref, text } }); - } - - async pressKey(key: string): Promise { - await this.call('tools/call', { name: 'browser_press_key', arguments: { key } }); - } - - async wait(options: number | { text?: string; time?: number; timeout?: number }): Promise { - if (typeof options === 'number') { - await this.call('tools/call', { name: 'browser_wait_for', arguments: { time: options } }); - } else { - // Pass directly to native wait_for, which supports natively awaiting text strings without heavy DOM polling - await this.call('tools/call', { name: 'browser_wait_for', arguments: options }); - } - } - - async tabs(): Promise { - return this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'list' } }); - } - - async closeTab(index?: number): Promise { - await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'close', ...(index !== undefined ? { index } : {}) } }); - } - - async newTab(): Promise { - await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'new' } }); - } - - async selectTab(index: number): Promise { - await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'select', index } }); - } - - async networkRequests(includeStatic: boolean = false): Promise { - return this.call('tools/call', { name: 'browser_network_requests', arguments: { includeStatic } }); - } - - async consoleMessages(level: string = 'info'): Promise { - return this.call('tools/call', { name: 'browser_console_messages', arguments: { level } }); - } - - async scroll(direction: string = 'down', _amount: number = 500): Promise { - await this.call('tools/call', { name: 'browser_press_key', arguments: { key: direction === 'down' ? 'PageDown' : 'PageUp' } }); - } - - async autoScroll(options: { times?: number; delayMs?: number } = {}): Promise { - const times = options.times ?? 3; - const delayMs = options.delayMs ?? 2000; - const js = ` - async () => { - const maxTimes = ${times}; - const maxWaitMs = ${delayMs}; - for (let i = 0; i < maxTimes; i++) { - const lastHeight = document.body.scrollHeight; - window.scrollTo(0, lastHeight); - await new Promise(resolve => { - let timeoutId; - const observer = new MutationObserver(() => { - if (document.body.scrollHeight > lastHeight) { - clearTimeout(timeoutId); - observer.disconnect(); - setTimeout(resolve, 100); // Small debounce for rendering - } - }); - observer.observe(document.body, { childList: true, subtree: true }); - timeoutId = setTimeout(() => { - observer.disconnect(); - resolve(null); - }, maxWaitMs); - }); - } - } - `; - await this.evaluate(js); - } - - async installInterceptor(pattern: string): Promise { - await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), { - arrayName: '__opencli_xhr', - patchGuard: '__opencli_interceptor_patched', - })); - } - - async getInterceptedRequests(): Promise { - const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr')); - return result || []; - } -} - -/** - * Playwright MCP process manager. - */ -export class PlaywrightMCP { - private static _activeInsts: Set = new Set(); - private static _cleanupRegistered = false; - - private static _registerGlobalCleanup() { - if (this._cleanupRegistered) return; - this._cleanupRegistered = true; - const cleanup = () => { - for (const inst of this._activeInsts) { - if (inst._proc && !inst._proc.killed) { - try { inst._proc.kill('SIGKILL'); } catch {} - } - } - }; - process.on('exit', cleanup); - process.on('SIGINT', () => { cleanup(); process.exit(130); }); - process.on('SIGTERM', () => { cleanup(); process.exit(143); }); - } - - private _proc: ChildProcess | null = null; - private _buffer = ''; - private _pending = new Map void; reject: (error: Error) => void }>(); - private _initialTabIdentities: string[] = []; - private _closingPromise: Promise | null = null; - private _state: PlaywrightMCPState = 'idle'; - - private _page: Page | null = null; - - get state(): PlaywrightMCPState { - return this._state; - } - - private _sendRequest(method: string, params: Record = {}): Promise { - return new Promise((resolve, reject) => { - if (!this._proc?.stdin?.writable) { - reject(new Error('Playwright MCP process is not writable')); - return; - } - const { id, message } = createJsonRpcRequest(method, params); - this._pending.set(id, { resolve, reject }); - this._proc.stdin.write(message, (err) => { - if (!err) return; - this._pending.delete(id); - reject(err); - }); - }); - } - - private _rejectPendingRequests(error: Error): void { - const pending = [...this._pending.values()]; - this._pending.clear(); - for (const waiter of pending) waiter.reject(error); - } - - private _resetAfterFailedConnect(): void { - const proc = this._proc; - this._page = null; - this._proc = null; - this._buffer = ''; - this._initialTabIdentities = []; - this._rejectPendingRequests(new Error('Playwright MCP connect failed')); - PlaywrightMCP._activeInsts.delete(this); - if (proc && !proc.killed) { - try { proc.kill('SIGKILL'); } catch {} - } - } - - async connect(opts: { timeout?: number } = {}): Promise { - if (this._state === 'connected' && this._page) return this._page; - if (this._state === 'connecting') throw new Error('Playwright MCP is already connecting'); - if (this._state === 'closing') throw new Error('Playwright MCP is closing'); - if (this._state === 'closed') throw new Error('Playwright MCP session is closed'); - - const mcpPath = findMcpServerPath(); - if (!mcpPath) throw new Error('Playwright MCP server not found. Install: npm install -D @playwright/mcp'); - - PlaywrightMCP._registerGlobalCleanup(); - PlaywrightMCP._activeInsts.add(this); - this._state = 'connecting'; - const timeout = opts.timeout ?? DEFAULT_BROWSER_CONNECT_TIMEOUT; - - return new Promise((resolve, reject) => { - const isDebug = process.env.DEBUG?.includes('opencli:mcp'); - const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`); - const useExtension = !!process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN; - const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN; - const tokenFingerprint = getTokenFingerprint(extensionToken); - let stderrBuffer = ''; - let settled = false; - - const settleError = (kind: ConnectFailureKind, extra: { rawMessage?: string; exitCode?: number | null } = {}) => { - if (settled) return; - settled = true; - this._state = 'idle'; - clearTimeout(timer); - this._resetAfterFailedConnect(); - reject(formatBrowserConnectError({ - kind, - timeout, - hasExtensionToken: !!extensionToken, - tokenFingerprint, - stderr: stderrBuffer, - exitCode: extra.exitCode, - rawMessage: extra.rawMessage, - })); - }; - - const settleSuccess = (pageToResolve: Page) => { - if (settled) return; - settled = true; - this._state = 'connected'; - clearTimeout(timer); - resolve(pageToResolve); - }; - - const timer = setTimeout(() => { - debugLog('Connection timed out'); - settleError(inferConnectFailureKind({ - hasExtensionToken: !!extensionToken, - stderr: stderrBuffer, - })); - }, timeout * 1000); - - const mcpArgs = buildMcpArgs({ - mcpPath, - executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH, - }); - if (process.env.OPENCLI_VERBOSE) { - console.error(`[opencli] Mode: ${useExtension ? 'extension' : 'standalone'}`); - if (useExtension) console.error(`[opencli] Extension token: fingerprint ${tokenFingerprint}`); - } - debugLog(`Spawning node ${mcpArgs.join(' ')}`); - - this._proc = spawn('node', mcpArgs, { - stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env }, - }); - - // Increase max listeners to avoid warnings - this._proc.setMaxListeners(20); - if (this._proc.stdout) this._proc.stdout.setMaxListeners(20); - - const page = new Page((method, params = {}) => this._sendRequest(method, params)); - this._page = page; - - this._proc.stdout?.on('data', (chunk: Buffer) => { - this._buffer += chunk.toString(); - const lines = this._buffer.split('\n'); - this._buffer = lines.pop() ?? ''; - for (const line of lines) { - if (!line.trim()) continue; - debugLog(`RECV: ${line}`); - try { - const parsed = JSON.parse(line); - if (typeof parsed?.id === 'number') { - const waiter = this._pending.get(parsed.id); - if (waiter) { - this._pending.delete(parsed.id); - waiter.resolve(parsed); - } - } - } catch (e) { - debugLog(`Parse error: ${e}`); - } - } - }); - - this._proc.stderr?.on('data', (chunk: Buffer) => { - const text = chunk.toString(); - stderrBuffer = appendLimited(stderrBuffer, text, STDERR_BUFFER_LIMIT); - debugLog(`STDERR: ${text}`); - }); - this._proc.on('error', (err) => { - debugLog(`Subprocess error: ${err.message}`); - this._rejectPendingRequests(new Error(`Playwright MCP process error: ${err.message}`)); - settleError('process-exit', { rawMessage: err.message }); - }); - this._proc.on('close', (code) => { - debugLog(`Subprocess closed with code ${code}`); - this._rejectPendingRequests(new Error(`Playwright MCP process exited before response${code == null ? '' : ` (code ${code})`}`)); - if (!settled) { - settleError(inferConnectFailureKind({ - hasExtensionToken: !!extensionToken, - stderr: stderrBuffer, - exited: true, - }), { exitCode: code }); - } - }); - - // Initialize: send initialize request - debugLog('Waiting for initialize response...'); - this._sendRequest('initialize', { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'opencli', version: PKG_VERSION }, - }).then((resp) => { - debugLog('Got initialize response'); - if (resp.error) { - settleError(inferConnectFailureKind({ - hasExtensionToken: !!extensionToken, - stderr: stderrBuffer, - rawMessage: `MCP init failed: ${resp.error.message}`, - }), { rawMessage: resp.error.message }); - return; - } - - const initializedMsg = JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n'; - debugLog(`SEND: ${initializedMsg.trim()}`); - this._proc?.stdin?.write(initializedMsg); - - // Use tabs as a readiness probe and for tab cleanup bookkeeping. - debugLog('Fetching initial tabs count...'); - withTimeoutMs(page.tabs(), INITIAL_TABS_TIMEOUT_MS, 'Timed out fetching initial tabs').then((tabs: any) => { - debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`); - this._initialTabIdentities = extractTabIdentities(tabs); - settleSuccess(page); - }).catch((err) => { - debugLog(`Tabs fetch error: ${err.message}`); - settleSuccess(page); - }); - }).catch((err) => { - debugLog(`Init promise rejected: ${err.message}`); - settleError('mcp-init', { rawMessage: err.message }); - }); - }); - } - - - async close(): Promise { - if (this._closingPromise) return this._closingPromise; - if (this._state === 'closed') return; - this._state = 'closing'; - this._closingPromise = (async () => { - try { - // Extension mode opens bridge/session tabs that we can clean up best-effort. - if (this._page && this._proc && !this._proc.killed) { - try { - const tabs = await withTimeoutMs(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup'); - const tabEntries = extractTabEntries(tabs); - const tabsToClose = diffTabIndexes(this._initialTabIdentities, tabEntries); - for (const index of tabsToClose) { - try { await this._page.closeTab(index); } catch {} - } - } catch {} - } - if (this._proc && !this._proc.killed) { - this._proc.kill('SIGTERM'); - const exited = await new Promise((res) => { - let done = false; - const finish = (value: boolean) => { - if (done) return; - done = true; - res(value); - }; - this._proc?.once('exit', () => finish(true)); - setTimeout(() => finish(false), 3000); - }); - if (!exited && this._proc && !this._proc.killed) { - try { this._proc.kill('SIGKILL'); } catch {} - } - } - } finally { - this._rejectPendingRequests(new Error('Playwright MCP session closed')); - this._page = null; - this._proc = null; - this._state = 'closed'; - PlaywrightMCP._activeInsts.delete(this); - } - })(); - return this._closingPromise; - } -} - -function extractTabEntries(raw: any): Array<{ index: number; identity: string }> { - if (Array.isArray(raw)) { - return raw.map((tab: any, index: number) => ({ - index, - identity: [ - tab?.id ?? '', - tab?.url ?? '', - tab?.title ?? '', - tab?.name ?? '', - ].join('|'), - })); - } - - if (typeof raw === 'string') { - return raw - .split('\n') - .map(line => line.trim()) - .filter(Boolean) - .map(line => { - // Match actual Playwright MCP format: "- 0: (current) [title](url)" or "- 1: [title](url)" - const mcpMatch = line.match(/^-\s+(\d+):\s*(.*)$/); - if (mcpMatch) { - return { - index: parseInt(mcpMatch[1], 10), - identity: mcpMatch[2].trim() || `tab-${mcpMatch[1]}`, - }; - } - // Legacy format: "Tab 0 ..." - const legacyMatch = line.match(/Tab\s+(\d+)\s*(.*)$/); - if (legacyMatch) { - return { - index: parseInt(legacyMatch[1], 10), - identity: legacyMatch[2].trim() || `tab-${legacyMatch[1]}`, - }; - } - return null; - }) - .filter((entry): entry is { index: number; identity: string } => entry !== null); - } - - return []; -} - -function extractTabIdentities(raw: any): string[] { - return extractTabEntries(raw).map(tab => tab.identity); -} - -function diffTabIndexes(initialIdentities: string[], currentTabs: Array<{ index: number; identity: string }>): number[] { - if (initialIdentities.length === 0 || currentTabs.length === 0) return []; - const remaining = new Map(); - for (const identity of initialIdentities) { - remaining.set(identity, (remaining.get(identity) ?? 0) + 1); - } - - const tabsToClose: number[] = []; - for (const tab of currentTabs) { - const count = remaining.get(tab.identity) ?? 0; - if (count > 0) { - remaining.set(tab.identity, count - 1); - continue; - } - tabsToClose.push(tab.index); - } - - return tabsToClose.sort((a, b) => b - a); -} - -function appendLimited(current: string, chunk: string, limit: number): string { - const next = current + chunk; - if (next.length <= limit) return next; - return next.slice(-limit); -} - -function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null }): string[] { - const args = [input.mcpPath]; - if (!process.env.CI) { - // Local: always connect to user's running Chrome via MCP Bridge extension - args.push('--extension'); - } - // CI: standalone mode — @playwright/mcp launches its own browser (headed by default). - // xvfb provides a virtual display for headed mode in GitHub Actions. - if (input.executablePath) { - args.push('--executable-path', input.executablePath); - } - return args; -} - -export const __test__ = { - createJsonRpcRequest, - extractTabEntries, - diffTabIndexes, - appendLimited, - buildMcpArgs, - withTimeoutMs, -}; - -function findMcpServerPath(): string | null { - if (_cachedMcpServerPath !== undefined) return _cachedMcpServerPath; - - const envMcp = process.env.OPENCLI_MCP_SERVER_PATH; - if (envMcp && fs.existsSync(envMcp)) { - _cachedMcpServerPath = envMcp; - return _cachedMcpServerPath; - } - - // Check local node_modules first (@playwright/mcp is the modern package) - const localMcp = path.resolve('node_modules', '@playwright', 'mcp', 'cli.js'); - if (fs.existsSync(localMcp)) { - _cachedMcpServerPath = localMcp; - return _cachedMcpServerPath; - } - - // Check project-relative path - const __dirname2 = path.dirname(fileURLToPath(import.meta.url)); - const projectMcp = path.resolve(__dirname2, '..', 'node_modules', '@playwright', 'mcp', 'cli.js'); - if (fs.existsSync(projectMcp)) { - _cachedMcpServerPath = projectMcp; - return _cachedMcpServerPath; - } - - // Check common locations - const candidates = [ - path.join(os.homedir(), '.npm', '_npx'), - path.join(os.homedir(), 'node_modules', '.bin'), - '/usr/local/lib/node_modules', - ]; - - // Try npx resolution (legacy package name) - try { - const result = execSync('npx -y --package=@playwright/mcp which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 10000 }).trim(); - if (result && fs.existsSync(result)) { - _cachedMcpServerPath = result; - return _cachedMcpServerPath; - } - } catch {} - - // Try which - try { - const result = execSync('which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }).trim(); - if (result && fs.existsSync(result)) { - _cachedMcpServerPath = result; - return _cachedMcpServerPath; - } - } catch {} - - // Search in common npx cache - for (const base of candidates) { - if (!fs.existsSync(base)) continue; - try { - const found = execSync(`find "${base}" -name "cli.js" -path "*playwright*mcp*" 2>/dev/null | head -1`, { encoding: 'utf-8', timeout: 5000 }).trim(); - if (found) { - _cachedMcpServerPath = found; - return _cachedMcpServerPath; - } - } catch {} - } - - _cachedMcpServerPath = null; - return _cachedMcpServerPath; -} diff --git a/src/browser/discover.ts b/src/browser/discover.ts new file mode 100644 index 0000000..56f8538 --- /dev/null +++ b/src/browser/discover.ts @@ -0,0 +1,90 @@ +/** + * MCP server path discovery and argument building. + */ + +import { execSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +let _cachedMcpServerPath: string | null | undefined; + +export function findMcpServerPath(): string | null { + if (_cachedMcpServerPath !== undefined) return _cachedMcpServerPath; + + const envMcp = process.env.OPENCLI_MCP_SERVER_PATH; + if (envMcp && fs.existsSync(envMcp)) { + _cachedMcpServerPath = envMcp; + return _cachedMcpServerPath; + } + + // Check local node_modules first (@playwright/mcp is the modern package) + const localMcp = path.resolve('node_modules', '@playwright', 'mcp', 'cli.js'); + if (fs.existsSync(localMcp)) { + _cachedMcpServerPath = localMcp; + return _cachedMcpServerPath; + } + + // Check project-relative path + const __dirname2 = path.dirname(fileURLToPath(import.meta.url)); + const projectMcp = path.resolve(__dirname2, '..', '..', 'node_modules', '@playwright', 'mcp', 'cli.js'); + if (fs.existsSync(projectMcp)) { + _cachedMcpServerPath = projectMcp; + return _cachedMcpServerPath; + } + + // Check common locations + const candidates = [ + path.join(os.homedir(), '.npm', '_npx'), + path.join(os.homedir(), 'node_modules', '.bin'), + '/usr/local/lib/node_modules', + ]; + + // Try npx resolution (legacy package name) + try { + const result = execSync('npx -y --package=@playwright/mcp which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 10000 }).trim(); + if (result && fs.existsSync(result)) { + _cachedMcpServerPath = result; + return _cachedMcpServerPath; + } + } catch {} + + // Try which + try { + const result = execSync('which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }).trim(); + if (result && fs.existsSync(result)) { + _cachedMcpServerPath = result; + return _cachedMcpServerPath; + } + } catch {} + + // Search in common npx cache + for (const base of candidates) { + if (!fs.existsSync(base)) continue; + try { + const found = execSync(`find "${base}" -name "cli.js" -path "*playwright*mcp*" 2>/dev/null | head -1`, { encoding: 'utf-8', timeout: 5000 }).trim(); + if (found) { + _cachedMcpServerPath = found; + return _cachedMcpServerPath; + } + } catch {} + } + + _cachedMcpServerPath = null; + return _cachedMcpServerPath; +} + +export function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null }): string[] { + const args = [input.mcpPath]; + if (!process.env.CI) { + // Local: always connect to user's running Chrome via MCP Bridge extension + args.push('--extension'); + } + // CI: standalone mode — @playwright/mcp launches its own browser (headed by default). + // xvfb provides a virtual display for headed mode in GitHub Actions. + if (input.executablePath) { + args.push('--executable-path', input.executablePath); + } + return args; +} diff --git a/src/browser/errors.ts b/src/browser/errors.ts new file mode 100644 index 0000000..c03e743 --- /dev/null +++ b/src/browser/errors.ts @@ -0,0 +1,89 @@ +/** + * Browser connection error classification and formatting. + */ + +import { createHash } from 'node:crypto'; + +export type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'mcp-init' | 'process-exit' | 'unknown'; + +export type ConnectFailureInput = { + kind: ConnectFailureKind; + timeout: number; + hasExtensionToken: boolean; + tokenFingerprint?: string | null; + stderr?: string; + exitCode?: number | null; + rawMessage?: string; +}; + +export function getTokenFingerprint(token: string | undefined): string | null { + if (!token) return null; + return createHash('sha256').update(token).digest('hex').slice(0, 8); +} + +export function formatBrowserConnectError(input: ConnectFailureInput): Error { + const stderr = input.stderr?.trim(); + const suffix = stderr ? `\n\nMCP stderr:\n${stderr}` : ''; + const tokenHint = input.tokenFingerprint ? ` Token fingerprint: ${input.tokenFingerprint}.` : ''; + + if (input.kind === 'missing-token') { + return new Error( + 'Failed to connect to Playwright MCP Bridge: PLAYWRIGHT_MCP_EXTENSION_TOKEN is not set.\n\n' + + 'Without this token, Chrome will show a manual approval dialog for every new MCP connection. ' + + 'Copy the token from the Playwright MCP Bridge extension and set it in BOTH your shell environment and MCP client config.' + + suffix, + ); + } + + if (input.kind === 'extension-not-installed') { + return new Error( + 'Failed to connect to Playwright MCP Bridge: the browser extension did not attach.\n\n' + + 'Make sure Chrome is running and the "Playwright MCP Bridge" extension is installed and enabled. ' + + 'If Chrome shows an approval dialog, click Allow.' + + suffix, + ); + } + + if (input.kind === 'extension-timeout') { + const likelyCause = input.hasExtensionToken + ? `The most likely cause is that PLAYWRIGHT_MCP_EXTENSION_TOKEN does not match the token currently shown by the browser extension.${tokenHint} Re-copy the token from the extension and update BOTH your shell environment and MCP client config.` + : 'PLAYWRIGHT_MCP_EXTENSION_TOKEN is not configured, so the extension may be waiting for manual approval.'; + return new Error( + `Timed out connecting to Playwright MCP Bridge (${input.timeout}s).\n\n` + + `${likelyCause} If a browser prompt is visible, click Allow.` + + suffix, + ); + } + + if (input.kind === 'mcp-init') { + return new Error(`Failed to initialize Playwright MCP: ${input.rawMessage ?? 'unknown error'}${suffix}`); + } + + if (input.kind === 'process-exit') { + return new Error( + `Playwright MCP process exited before the browser connection was established${input.exitCode == null ? '' : ` (code ${input.exitCode})`}.` + + suffix, + ); + } + + return new Error(input.rawMessage ?? 'Failed to connect to browser'); +} + +export function inferConnectFailureKind(args: { + hasExtensionToken: boolean; + stderr: string; + rawMessage?: string; + exited?: boolean; +}): ConnectFailureKind { + const haystack = `${args.rawMessage ?? ''}\n${args.stderr}`.toLowerCase(); + + if (!args.hasExtensionToken) + return 'missing-token'; + if (haystack.includes('extension connection timeout') || haystack.includes('playwright mcp bridge')) + return 'extension-not-installed'; + if (args.rawMessage?.startsWith('MCP init failed:')) + return 'mcp-init'; + if (args.exited) + return 'process-exit'; + return 'extension-timeout'; +} diff --git a/src/browser/index.ts b/src/browser/index.ts new file mode 100644 index 0000000..afe502d --- /dev/null +++ b/src/browser/index.ts @@ -0,0 +1,26 @@ +/** + * Browser module — public API re-exports. + * + * This barrel replaces the former monolithic browser.ts. + * External code should import from './browser/index.js' (or './browser.js' via Node resolution). + */ + +export { Page } from './page.js'; +export { PlaywrightMCP } from './mcp.js'; +export { getTokenFingerprint, formatBrowserConnectError } from './errors.js'; +export type { ConnectFailureKind, ConnectFailureInput } from './errors.js'; + +// Test-only helpers — exposed for unit tests +import { createJsonRpcRequest } from './mcp.js'; +import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js'; +import { buildMcpArgs } from './discover.js'; +import { withTimeoutMs } from '../runtime.js'; + +export const __test__ = { + createJsonRpcRequest, + extractTabEntries, + diffTabIndexes, + appendLimited, + buildMcpArgs, + withTimeoutMs, +}; diff --git a/src/browser/mcp.ts b/src/browser/mcp.ts new file mode 100644 index 0000000..c7ee855 --- /dev/null +++ b/src/browser/mcp.ts @@ -0,0 +1,305 @@ +/** + * Playwright MCP process manager. + * Handles lifecycle management, JSON-RPC communication, and browser session orchestration. + */ + +import { spawn, type ChildProcess } from 'node:child_process'; +import type { IPage } from '../types.js'; +import { withTimeoutMs, DEFAULT_BROWSER_CONNECT_TIMEOUT } from '../runtime.js'; +import { PKG_VERSION } from '../version.js'; +import { Page } from './page.js'; +import { getTokenFingerprint, formatBrowserConnectError, inferConnectFailureKind } from './errors.js'; +import { findMcpServerPath, buildMcpArgs } from './discover.js'; +import { extractTabIdentities, extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js'; + +const STDERR_BUFFER_LIMIT = 16 * 1024; +const INITIAL_TABS_TIMEOUT_MS = 1500; +const TAB_CLEANUP_TIMEOUT_MS = 2000; + +export type PlaywrightMCPState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed'; + +// JSON-RPC helpers +let _nextId = 1; +export function createJsonRpcRequest(method: string, params: Record = {}): { id: number; message: string } { + const id = _nextId++; + return { + id, + message: JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n', + }; +} + +/** + * Playwright MCP process manager. + */ +export class PlaywrightMCP { + private static _activeInsts: Set = new Set(); + private static _cleanupRegistered = false; + + private static _registerGlobalCleanup() { + if (this._cleanupRegistered) return; + this._cleanupRegistered = true; + const cleanup = () => { + for (const inst of this._activeInsts) { + if (inst._proc && !inst._proc.killed) { + try { inst._proc.kill('SIGKILL'); } catch {} + } + } + }; + process.on('exit', cleanup); + process.on('SIGINT', () => { cleanup(); process.exit(130); }); + process.on('SIGTERM', () => { cleanup(); process.exit(143); }); + } + + private _proc: ChildProcess | null = null; + private _buffer = ''; + private _pending = new Map void; reject: (error: Error) => void }>(); + private _initialTabIdentities: string[] = []; + private _closingPromise: Promise | null = null; + private _state: PlaywrightMCPState = 'idle'; + + private _page: Page | null = null; + + get state(): PlaywrightMCPState { + return this._state; + } + + private _sendRequest(method: string, params: Record = {}): Promise { + return new Promise((resolve, reject) => { + if (!this._proc?.stdin?.writable) { + reject(new Error('Playwright MCP process is not writable')); + return; + } + const { id, message } = createJsonRpcRequest(method, params); + this._pending.set(id, { resolve, reject }); + this._proc.stdin.write(message, (err) => { + if (!err) return; + this._pending.delete(id); + reject(err); + }); + }); + } + + private _rejectPendingRequests(error: Error): void { + const pending = [...this._pending.values()]; + this._pending.clear(); + for (const waiter of pending) waiter.reject(error); + } + + private _resetAfterFailedConnect(): void { + const proc = this._proc; + this._page = null; + this._proc = null; + this._buffer = ''; + this._initialTabIdentities = []; + this._rejectPendingRequests(new Error('Playwright MCP connect failed')); + PlaywrightMCP._activeInsts.delete(this); + if (proc && !proc.killed) { + try { proc.kill('SIGKILL'); } catch {} + } + } + + async connect(opts: { timeout?: number } = {}): Promise { + if (this._state === 'connected' && this._page) return this._page; + if (this._state === 'connecting') throw new Error('Playwright MCP is already connecting'); + if (this._state === 'closing') throw new Error('Playwright MCP is closing'); + if (this._state === 'closed') throw new Error('Playwright MCP session is closed'); + + const mcpPath = findMcpServerPath(); + if (!mcpPath) throw new Error('Playwright MCP server not found. Install: npm install -D @playwright/mcp'); + + PlaywrightMCP._registerGlobalCleanup(); + PlaywrightMCP._activeInsts.add(this); + this._state = 'connecting'; + const timeout = opts.timeout ?? DEFAULT_BROWSER_CONNECT_TIMEOUT; + + return new Promise((resolve, reject) => { + const isDebug = process.env.DEBUG?.includes('opencli:mcp'); + const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`); + const useExtension = !!process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN; + const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN; + const tokenFingerprint = getTokenFingerprint(extensionToken); + let stderrBuffer = ''; + let settled = false; + + const settleError = (kind: Parameters[0]['kind'], extra: { rawMessage?: string; exitCode?: number | null } = {}) => { + if (settled) return; + settled = true; + this._state = 'idle'; + clearTimeout(timer); + this._resetAfterFailedConnect(); + reject(formatBrowserConnectError({ + kind, + timeout, + hasExtensionToken: !!extensionToken, + tokenFingerprint, + stderr: stderrBuffer, + exitCode: extra.exitCode, + rawMessage: extra.rawMessage, + })); + }; + + const settleSuccess = (pageToResolve: Page) => { + if (settled) return; + settled = true; + this._state = 'connected'; + clearTimeout(timer); + resolve(pageToResolve); + }; + + const timer = setTimeout(() => { + debugLog('Connection timed out'); + settleError(inferConnectFailureKind({ + hasExtensionToken: !!extensionToken, + stderr: stderrBuffer, + })); + }, timeout * 1000); + + const mcpArgs = buildMcpArgs({ + mcpPath, + executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH, + }); + if (process.env.OPENCLI_VERBOSE) { + console.error(`[opencli] Mode: ${useExtension ? 'extension' : 'standalone'}`); + if (useExtension) console.error(`[opencli] Extension token: fingerprint ${tokenFingerprint}`); + } + debugLog(`Spawning node ${mcpArgs.join(' ')}`); + + this._proc = spawn('node', mcpArgs, { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env }, + }); + + // Increase max listeners to avoid warnings + this._proc.setMaxListeners(20); + if (this._proc.stdout) this._proc.stdout.setMaxListeners(20); + + const page = new Page((method, params = {}) => this._sendRequest(method, params)); + this._page = page; + + this._proc.stdout?.on('data', (chunk: Buffer) => { + this._buffer += chunk.toString(); + const lines = this._buffer.split('\n'); + this._buffer = lines.pop() ?? ''; + for (const line of lines) { + if (!line.trim()) continue; + debugLog(`RECV: ${line}`); + try { + const parsed = JSON.parse(line); + if (typeof parsed?.id === 'number') { + const waiter = this._pending.get(parsed.id); + if (waiter) { + this._pending.delete(parsed.id); + waiter.resolve(parsed); + } + } + } catch (e) { + debugLog(`Parse error: ${e}`); + } + } + }); + + this._proc.stderr?.on('data', (chunk: Buffer) => { + const text = chunk.toString(); + stderrBuffer = appendLimited(stderrBuffer, text, STDERR_BUFFER_LIMIT); + debugLog(`STDERR: ${text}`); + }); + this._proc.on('error', (err) => { + debugLog(`Subprocess error: ${err.message}`); + this._rejectPendingRequests(new Error(`Playwright MCP process error: ${err.message}`)); + settleError('process-exit', { rawMessage: err.message }); + }); + this._proc.on('close', (code) => { + debugLog(`Subprocess closed with code ${code}`); + this._rejectPendingRequests(new Error(`Playwright MCP process exited before response${code == null ? '' : ` (code ${code})`}`)); + if (!settled) { + settleError(inferConnectFailureKind({ + hasExtensionToken: !!extensionToken, + stderr: stderrBuffer, + exited: true, + }), { exitCode: code }); + } + }); + + // Initialize: send initialize request + debugLog('Waiting for initialize response...'); + this._sendRequest('initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'opencli', version: PKG_VERSION }, + }).then((resp: any) => { + debugLog('Got initialize response'); + if (resp.error) { + settleError(inferConnectFailureKind({ + hasExtensionToken: !!extensionToken, + stderr: stderrBuffer, + rawMessage: `MCP init failed: ${resp.error.message}`, + }), { rawMessage: resp.error.message }); + return; + } + + const initializedMsg = JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n'; + debugLog(`SEND: ${initializedMsg.trim()}`); + this._proc?.stdin?.write(initializedMsg); + + // Use tabs as a readiness probe and for tab cleanup bookkeeping. + debugLog('Fetching initial tabs count...'); + withTimeoutMs(page.tabs(), INITIAL_TABS_TIMEOUT_MS, 'Timed out fetching initial tabs').then((tabs: any) => { + debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`); + this._initialTabIdentities = extractTabIdentities(tabs); + settleSuccess(page); + }).catch((err: Error) => { + debugLog(`Tabs fetch error: ${err.message}`); + settleSuccess(page); + }); + }).catch((err: Error) => { + debugLog(`Init promise rejected: ${err.message}`); + settleError('mcp-init', { rawMessage: err.message }); + }); + }); + } + + + async close(): Promise { + if (this._closingPromise) return this._closingPromise; + if (this._state === 'closed') return; + this._state = 'closing'; + this._closingPromise = (async () => { + try { + // Extension mode opens bridge/session tabs that we can clean up best-effort. + if (this._page && this._proc && !this._proc.killed) { + try { + const tabs = await withTimeoutMs(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup'); + const tabEntries = extractTabEntries(tabs); + const tabsToClose = diffTabIndexes(this._initialTabIdentities, tabEntries); + for (const index of tabsToClose) { + try { await this._page.closeTab(index); } catch {} + } + } catch {} + } + if (this._proc && !this._proc.killed) { + this._proc.kill('SIGTERM'); + const exited = await new Promise((res) => { + let done = false; + const finish = (value: boolean) => { + if (done) return; + done = true; + res(value); + }; + this._proc?.once('exit', () => finish(true)); + setTimeout(() => finish(false), 3000); + }); + if (!exited && this._proc && !this._proc.killed) { + try { this._proc.kill('SIGKILL'); } catch {} + } + } + } finally { + this._rejectPendingRequests(new Error('Playwright MCP session closed')); + this._page = null; + this._proc = null; + this._state = 'closed'; + PlaywrightMCP._activeInsts.delete(this); + } + })(); + return this._closingPromise; + } +} diff --git a/src/browser/page.ts b/src/browser/page.ts new file mode 100644 index 0000000..00eb891 --- /dev/null +++ b/src/browser/page.ts @@ -0,0 +1,152 @@ +/** + * Page abstraction wrapping JSON-RPC calls to Playwright MCP. + */ + +import { formatSnapshot } from '../snapshotFormatter.js'; +import { normalizeEvaluateSource } from '../pipeline/template.js'; +import { generateInterceptorJs, generateReadInterceptedJs } from '../interceptor.js'; +import type { IPage } from '../types.js'; + +/** + * Page abstraction wrapping JSON-RPC calls to Playwright MCP. + */ +export class Page implements IPage { + constructor(private _request: (method: string, params?: Record) => Promise>) {} + + async call(method: string, params: Record = {}): Promise { + const resp = await this._request(method, params); + if (resp.error) throw new Error(`page.${method}: ${(resp.error as any).message ?? JSON.stringify(resp.error)}`); + // Extract text content from MCP result + const result = resp.result as any; + if (result?.content) { + const textParts = result.content.filter((c: any) => c.type === 'text'); + if (textParts.length === 1) { + let text = textParts[0].text; + // MCP browser_evaluate returns: "[JSON]\n### Ran Playwright code\n```js\n...\n```" + // Strip the "### Ran Playwright code" suffix to get clean JSON + const codeMarker = text.indexOf('### Ran Playwright code'); + if (codeMarker !== -1) { + text = text.slice(0, codeMarker).trim(); + } + // Also handle "### Result\n[JSON]" format (some MCP versions) + const resultMarker = text.indexOf('### Result\n'); + if (resultMarker !== -1) { + text = text.slice(resultMarker + '### Result\n'.length).trim(); + } + try { return JSON.parse(text); } catch { return text; } + } + } + return result; + } + + // --- High-level methods --- + + async goto(url: string): Promise { + await this.call('tools/call', { name: 'browser_navigate', arguments: { url } }); + } + + async evaluate(js: string): Promise { + // Normalize IIFE format to function format expected by MCP browser_evaluate + const normalized = normalizeEvaluateSource(js); + return this.call('tools/call', { name: 'browser_evaluate', arguments: { function: normalized } }); + } + + async snapshot(opts: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean } = {}): Promise { + const raw = await this.call('tools/call', { name: 'browser_snapshot', arguments: {} }); + if (opts.raw) return raw; + if (typeof raw === 'string') return formatSnapshot(raw, opts); + return raw; + } + + async click(ref: string): Promise { + await this.call('tools/call', { name: 'browser_click', arguments: { element: 'click target', ref } }); + } + + async typeText(ref: string, text: string): Promise { + await this.call('tools/call', { name: 'browser_type', arguments: { element: 'type target', ref, text } }); + } + + async pressKey(key: string): Promise { + await this.call('tools/call', { name: 'browser_press_key', arguments: { key } }); + } + + async wait(options: number | { text?: string; time?: number; timeout?: number }): Promise { + if (typeof options === 'number') { + await this.call('tools/call', { name: 'browser_wait_for', arguments: { time: options } }); + } else { + // Pass directly to native wait_for, which supports natively awaiting text strings without heavy DOM polling + await this.call('tools/call', { name: 'browser_wait_for', arguments: options }); + } + } + + async tabs(): Promise { + return this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'list' } }); + } + + async closeTab(index?: number): Promise { + await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'close', ...(index !== undefined ? { index } : {}) } }); + } + + async newTab(): Promise { + await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'new' } }); + } + + async selectTab(index: number): Promise { + await this.call('tools/call', { name: 'browser_tabs', arguments: { action: 'select', index } }); + } + + async networkRequests(includeStatic: boolean = false): Promise { + return this.call('tools/call', { name: 'browser_network_requests', arguments: { includeStatic } }); + } + + async consoleMessages(level: string = 'info'): Promise { + return this.call('tools/call', { name: 'browser_console_messages', arguments: { level } }); + } + + async scroll(direction: string = 'down', _amount: number = 500): Promise { + await this.call('tools/call', { name: 'browser_press_key', arguments: { key: direction === 'down' ? 'PageDown' : 'PageUp' } }); + } + + async autoScroll(options: { times?: number; delayMs?: number } = {}): Promise { + const times = options.times ?? 3; + const delayMs = options.delayMs ?? 2000; + const js = ` + async () => { + const maxTimes = ${times}; + const maxWaitMs = ${delayMs}; + for (let i = 0; i < maxTimes; i++) { + const lastHeight = document.body.scrollHeight; + window.scrollTo(0, lastHeight); + await new Promise(resolve => { + let timeoutId; + const observer = new MutationObserver(() => { + if (document.body.scrollHeight > lastHeight) { + clearTimeout(timeoutId); + observer.disconnect(); + setTimeout(resolve, 100); // Small debounce for rendering + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + timeoutId = setTimeout(() => { + observer.disconnect(); + resolve(null); + }, maxWaitMs); + }); + } + } + `; + await this.evaluate(js); + } + + async installInterceptor(pattern: string): Promise { + await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), { + arrayName: '__opencli_xhr', + patchGuard: '__opencli_interceptor_patched', + })); + } + + async getInterceptedRequests(): Promise { + const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr')); + return result || []; + } +} diff --git a/src/browser/tabs.ts b/src/browser/tabs.ts new file mode 100644 index 0000000..6880169 --- /dev/null +++ b/src/browser/tabs.ts @@ -0,0 +1,76 @@ +/** + * Browser tab management helpers: extract, diff, and cleanup tab state. + */ + +export function extractTabEntries(raw: unknown): Array<{ index: number; identity: string }> { + if (Array.isArray(raw)) { + return raw.map((tab: Record, index: number) => ({ + index, + identity: [ + tab?.id ?? '', + tab?.url ?? '', + tab?.title ?? '', + tab?.name ?? '', + ].join('|'), + })); + } + + if (typeof raw === 'string') { + return raw + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + .map(line => { + // Match actual Playwright MCP format: "- 0: (current) [title](url)" or "- 1: [title](url)" + const mcpMatch = line.match(/^-\s+(\d+):\s*(.*)$/); + if (mcpMatch) { + return { + index: parseInt(mcpMatch[1], 10), + identity: mcpMatch[2].trim() || `tab-${mcpMatch[1]}`, + }; + } + // Legacy format: "Tab 0 ..." + const legacyMatch = line.match(/Tab\s+(\d+)\s*(.*)$/); + if (legacyMatch) { + return { + index: parseInt(legacyMatch[1], 10), + identity: legacyMatch[2].trim() || `tab-${legacyMatch[1]}`, + }; + } + return null; + }) + .filter((entry): entry is { index: number; identity: string } => entry !== null); + } + + return []; +} + +export function extractTabIdentities(raw: unknown): string[] { + return extractTabEntries(raw).map(tab => tab.identity); +} + +export function diffTabIndexes(initialIdentities: string[], currentTabs: Array<{ index: number; identity: string }>): number[] { + if (initialIdentities.length === 0 || currentTabs.length === 0) return []; + const remaining = new Map(); + for (const identity of initialIdentities) { + remaining.set(identity, (remaining.get(identity) ?? 0) + 1); + } + + const tabsToClose: number[] = []; + for (const tab of currentTabs) { + const count = remaining.get(tab.identity) ?? 0; + if (count > 0) { + remaining.set(tab.identity, count - 1); + continue; + } + tabsToClose.push(tab.index); + } + + return tabsToClose.sort((a, b) => b - a); +} + +export function appendLimited(current: string, chunk: string, limit: number): string { + const next = current + chunk; + if (next.length <= limit) return next; + return next.slice(-limit); +} diff --git a/src/completion.ts b/src/completion.ts index bdfbecb..00c031a 100644 --- a/src/completion.ts +++ b/src/completion.ts @@ -7,6 +7,7 @@ */ import { getRegistry } from './registry.js'; +import { CliError } from './errors.js'; // ── Dynamic completion logic ─────────────────────────────────────────────── @@ -123,7 +124,6 @@ export function printCompletionScript(shell: string): void { process.stdout.write(fishCompletionScript()); break; default: - console.error(`Unsupported shell: ${shell}. Supported: bash, zsh, fish`); - process.exitCode = 1; + throw new CliError('UNSUPPORTED_SHELL', `Unsupported shell: ${shell}. Supported: bash, zsh, fish`); } } diff --git a/src/doctor.ts b/src/doctor.ts index e2e9c58..98066a2 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -6,7 +6,7 @@ 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 { PlaywrightMCP, getTokenFingerprint } from './browser/index.js'; import { browserSession } from './runtime.js'; const PLAYWRIGHT_SERVER_NAME = 'playwright'; diff --git a/src/engine.ts b/src/engine.ts index fdd2b8d..1ea8518 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -14,6 +14,8 @@ import yaml from 'js-yaml'; import { type CliCommand, type InternalCliCommand, type Arg, Strategy, registerCommand } from './registry.js'; import type { IPage } from './types.js'; import { executePipeline } from './pipeline.js'; +import { log } from './logger.js'; +import { AdapterLoadError } from './errors.js'; /** Set of TS module paths that have been loaded */ const _loadedModules = new Set(); @@ -84,7 +86,7 @@ function loadFromManifest(manifestPath: string, clisDir: string): void { } } } catch (err: any) { - process.stderr.write(`Warning: failed to load manifest ${manifestPath}: ${err.message}\n`); + log.warn(`Failed to load manifest ${manifestPath}: ${err.message}`); } } @@ -107,7 +109,7 @@ async function discoverClisFromFs(dir: string): Promise { ) { promises.push( import(`file://${filePath}`).catch((err: any) => { - process.stderr.write(`Warning: failed to load module ${filePath}: ${err.message}\n`); + log.warn(`Failed to load module ${filePath}: ${err.message}`); }) ); } @@ -158,7 +160,7 @@ function registerYamlCli(filePath: string, defaultSite: string): void { registerCommand(cmd); } catch (err: any) { - process.stderr.write(`Warning: failed to load ${filePath}: ${err.message}\n`); + log.warn(`Failed to load ${filePath}: ${err.message}`); } } @@ -180,7 +182,10 @@ export async function executeCommand( await import(`file://${modulePath}`); _loadedModules.add(modulePath); } catch (err: any) { - throw new Error(`Failed to load adapter module ${modulePath}: ${err.message}`); + throw new AdapterLoadError( + `Failed to load adapter module ${modulePath}: ${err.message}`, + 'Check that the adapter file exists and has no syntax errors.', + ); } } // After loading, the module's cli() call will have updated the registry diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..2a33ba7 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,48 @@ +/** + * Unified error types for opencli. + * + * All errors thrown by the framework should extend CliError so that + * the top-level handler in main.ts can render consistent, helpful output. + */ + +export class CliError extends Error { + /** Machine-readable error code (e.g. 'BROWSER_CONNECT', 'ADAPTER_LOAD') */ + readonly code: string; + /** Human-readable hint on how to fix the problem */ + readonly hint?: string; + + constructor(code: string, message: string, hint?: string) { + super(message); + this.name = 'CliError'; + this.code = code; + this.hint = hint; + } +} + +export class BrowserConnectError extends CliError { + constructor(message: string, hint?: string) { + super('BROWSER_CONNECT', message, hint); + this.name = 'BrowserConnectError'; + } +} + +export class AdapterLoadError extends CliError { + constructor(message: string, hint?: string) { + super('ADAPTER_LOAD', message, hint); + this.name = 'AdapterLoadError'; + } +} + +export class CommandExecutionError extends CliError { + constructor(message: string, hint?: string) { + super('COMMAND_EXEC', message, hint); + this.name = 'CommandExecutionError'; + } +} + +export class ConfigError extends CliError { + constructor(message: string, hint?: string) { + super('CONFIG', message, hint); + this.name = 'ConfigError'; + } +} diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..9810fe0 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,57 @@ +/** + * Unified logging for opencli. + * + * All framework output (warnings, debug info, errors) should go through + * this module so that verbosity levels are respected consistently. + */ + +import chalk from 'chalk'; + +function isVerbose(): boolean { + return !!process.env.OPENCLI_VERBOSE; +} + +function isDebug(): boolean { + return !!process.env.DEBUG?.includes('opencli'); +} + +export const log = { + /** Informational message (always shown) */ + info(msg: string): void { + process.stderr.write(`${chalk.blue('ℹ')} ${msg}\n`); + }, + + /** Warning (always shown) */ + warn(msg: string): void { + process.stderr.write(`${chalk.yellow('⚠')} ${msg}\n`); + }, + + /** Error (always shown) */ + error(msg: string): void { + process.stderr.write(`${chalk.red('✖')} ${msg}\n`); + }, + + /** Verbose output (only when OPENCLI_VERBOSE is set or -v flag) */ + verbose(msg: string): void { + if (isVerbose()) { + process.stderr.write(`${chalk.dim('[verbose]')} ${msg}\n`); + } + }, + + /** Debug output (only when DEBUG includes 'opencli') */ + debug(msg: string): void { + if (isDebug()) { + process.stderr.write(`${chalk.dim('[debug]')} ${msg}\n`); + } + }, + + /** Step-style debug (for pipeline steps, etc.) */ + step(stepNum: number, total: number, op: string, preview: string = ''): void { + process.stderr.write(` ${chalk.dim(`[${stepNum}/${total}]`)} ${chalk.bold.cyan(op)}${preview}\n`); + }, + + /** Step result summary */ + stepResult(summary: string): void { + process.stderr.write(` ${chalk.dim(`→ ${summary}`)}\n`); + }, +}; diff --git a/src/main.ts b/src/main.ts index 30c68fa..884eaeb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,10 +11,11 @@ import chalk from 'chalk'; import { discoverClis, executeCommand } from './engine.js'; import { type CliCommand, fullName, getRegistry, strategyLabel } from './registry.js'; import { render as renderOutput } from './output.js'; -import { PlaywrightMCP } from './browser.js'; +import { PlaywrightMCP } from './browser/index.js'; import { browserSession, DEFAULT_BROWSER_COMMAND_TIMEOUT, runWithTimeout } from './runtime.js'; import { PKG_VERSION } from './version.js'; import { getCompletions, printCompletionScript } from './completion.js'; +import { CliError } from './errors.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -212,8 +213,14 @@ for (const [, cmd] of registry) { } renderOutput(result, { fmt: actionOpts.format, columns: cmd.columns, title: `${cmd.site}/${cmd.name}`, elapsed: (Date.now() - startTime) / 1000, source: fullName(cmd) }); } catch (err: any) { - if (actionOpts.verbose && err.stack) { console.error(chalk.red(err.stack)); } - else { console.error(chalk.red(`Error: ${err.message ?? err}`)); } + if (err instanceof CliError) { + console.error(chalk.red(`Error [${err.code}]: ${err.message}`)); + if (err.hint) console.error(chalk.yellow(`Hint: ${err.hint}`)); + } else if (actionOpts.verbose && err.stack) { + console.error(chalk.red(err.stack)); + } else { + console.error(chalk.red(`Error: ${err.message ?? err}`)); + } process.exitCode = 1; } }); diff --git a/src/pipeline/executor.ts b/src/pipeline/executor.ts index 359f5a0..0865bec 100644 --- a/src/pipeline/executor.ts +++ b/src/pipeline/executor.ts @@ -9,6 +9,7 @@ import { stepFetch } from './steps/fetch.js'; import { stepSelect, stepMap, stepFilter, stepSort, stepLimit } from './steps/transform.js'; import { stepIntercept } from './steps/intercept.js'; import { stepTap } from './steps/tap.js'; +import { log } from '../logger.js'; export interface PipelineContext { args?: Record; @@ -57,7 +58,7 @@ export async function executePipeline( if (handler) { data = await handler(page, params, data, args); } else { - if (debug) process.stderr.write(` ${chalk.yellow('⚠')} Unknown step: ${op}\n`); + if (debug) log.warn(`Unknown step: ${op}`); } if (debug) debugStepResult(op, data); @@ -73,21 +74,21 @@ function debugStepStart(stepNum: number, total: number, op: string, params: any) } else if (params && typeof params === 'object' && !Array.isArray(params)) { preview = ` (${Object.keys(params).join(', ')})`; } - process.stderr.write(` ${chalk.dim(`[${stepNum}/${total}]`)} ${chalk.bold.cyan(op)}${preview}\n`); + log.step(stepNum, total, op, preview); } function debugStepResult(op: string, data: any): void { if (data === null || data === undefined) { - process.stderr.write(` ${chalk.dim('→ (no data)')}\n`); + log.stepResult('(no data)'); } else if (Array.isArray(data)) { - process.stderr.write(` ${chalk.dim(`→ ${data.length} items`)}\n`); + log.stepResult(`${data.length} items`); } else if (typeof data === 'object') { const keys = Object.keys(data).slice(0, 5); - process.stderr.write(` ${chalk.dim(`→ dict (${keys.join(', ')}${Object.keys(data).length > 5 ? '...' : ''})`)}\n`); + log.stepResult(`dict (${keys.join(', ')}${Object.keys(data).length > 5 ? '...' : ''})`); } else if (typeof data === 'string') { const p = data.slice(0, 60).replace(/\n/g, '\\n'); - process.stderr.write(` ${chalk.dim(`→ "${p}${data.length > 60 ? '...' : ''}"`)}\n`); + log.stepResult(`"${p}${data.length > 60 ? '...' : ''}"`); } else { - process.stderr.write(` ${chalk.dim(`→ ${typeof data}`)}\n`); + log.stepResult(`${typeof data}`); } } diff --git a/src/pipeline/steps/browser.ts b/src/pipeline/steps/browser.ts index e7b8dbf..21302f1 100644 --- a/src/pipeline/steps/browser.ts +++ b/src/pipeline/steps/browser.ts @@ -6,53 +6,53 @@ import type { IPage } from '../../types.js'; import { render, normalizeEvaluateSource } from '../template.js'; -export async function stepNavigate(page: IPage, params: any, data: any, args: Record): Promise { +export async function stepNavigate(page: IPage | null, params: any, data: any, args: Record): Promise { const url = render(params, { args, data }); - await page.goto(String(url)); + await page!.goto(String(url)); return data; } -export async function stepClick(page: IPage, params: any, data: any, args: Record): Promise { - await page.click(String(render(params, { args, data })).replace(/^@/, '')); +export async function stepClick(page: IPage | null, params: any, data: any, args: Record): Promise { + await page!.click(String(render(params, { args, data })).replace(/^@/, '')); return data; } -export async function stepType(page: IPage, params: any, data: any, args: Record): Promise { +export async function stepType(page: IPage | null, params: any, data: any, args: Record): Promise { if (typeof params === 'object' && params) { const ref = String(render(params.ref ?? '', { args, data })).replace(/^@/, ''); const text = String(render(params.text ?? '', { args, data })); - await page.typeText(ref, text); - if (params.submit) await page.pressKey('Enter'); + await page!.typeText(ref, text); + if (params.submit) await page!.pressKey('Enter'); } return data; } -export async function stepWait(page: IPage, params: any, data: any, args: Record): Promise { - if (typeof params === 'number') await page.wait(params); +export async function stepWait(page: IPage | null, params: any, data: any, args: Record): Promise { + if (typeof params === 'number') await page!.wait(params); else if (typeof params === 'object' && params) { if ('text' in params) { - await page.wait({ + await page!.wait({ text: String(render(params.text, { args, data })), timeout: params.timeout }); - } else if ('time' in params) await page.wait(Number(params.time)); - } else if (typeof params === 'string') await page.wait(Number(render(params, { args, data }))); + } else if ('time' in params) await page!.wait(Number(params.time)); + } else if (typeof params === 'string') await page!.wait(Number(render(params, { args, data }))); return data; } -export async function stepPress(page: IPage, params: any, data: any, args: Record): Promise { - await page.pressKey(String(render(params, { args, data }))); +export async function stepPress(page: IPage | null, params: any, data: any, args: Record): Promise { + await page!.pressKey(String(render(params, { args, data }))); return data; } -export async function stepSnapshot(page: IPage, params: any, _data: any, _args: Record): Promise { +export async function stepSnapshot(page: IPage | null, params: any, _data: any, _args: Record): Promise { const opts = (typeof params === 'object' && params) ? params : {}; - return page.snapshot({ interactive: opts.interactive ?? false, compact: opts.compact ?? false, maxDepth: opts.max_depth, raw: opts.raw ?? false }); + return page!.snapshot({ interactive: opts.interactive ?? false, compact: opts.compact ?? false, maxDepth: opts.max_depth, raw: opts.raw ?? false }); } -export async function stepEvaluate(page: IPage, params: any, data: any, args: Record): Promise { +export async function stepEvaluate(page: IPage | null, params: any, data: any, args: Record): Promise { const js = String(render(params, { args, data })); - let result = await page.evaluate(normalizeEvaluateSource(js)); + let result = await page!.evaluate(normalizeEvaluateSource(js)); // MCP may return JSON as a string — auto-parse it if (typeof result === 'string') { const trimmed = result.trim(); diff --git a/src/pipeline/steps/intercept.ts b/src/pipeline/steps/intercept.ts index eb5ce8b..b7ad0c7 100644 --- a/src/pipeline/steps/intercept.ts +++ b/src/pipeline/steps/intercept.ts @@ -6,7 +6,7 @@ import type { IPage } from '../../types.js'; import { render } from '../template.js'; import { generateInterceptorJs, generateReadInterceptedJs } from '../../interceptor.js'; -export async function stepIntercept(page: IPage, params: any, data: any, args: Record): Promise { +export async function stepIntercept(page: IPage | null, params: any, data: any, args: Record): Promise { const cfg = typeof params === 'object' ? params : {}; const trigger = cfg.trigger ?? ''; const capturePattern = cfg.capture ?? ''; @@ -16,28 +16,28 @@ export async function stepIntercept(page: IPage, params: any, data: any, args: R if (!capturePattern) return data; // Step 1: Inject fetch/XHR interceptor BEFORE trigger - await page.evaluate(generateInterceptorJs(JSON.stringify(capturePattern))); + await page!.evaluate(generateInterceptorJs(JSON.stringify(capturePattern))); // Step 2: Execute the trigger action if (trigger.startsWith('navigate:')) { const url = render(trigger.slice('navigate:'.length), { args, data }); - await page.goto(String(url)); + await page!.goto(String(url)); } else if (trigger.startsWith('evaluate:')) { const js = trigger.slice('evaluate:'.length); const { normalizeEvaluateSource } = await import('../template.js'); - await page.evaluate(normalizeEvaluateSource(render(js, { args, data }) as string)); + await page!.evaluate(normalizeEvaluateSource(render(js, { args, data }) as string)); } else if (trigger.startsWith('click:')) { const ref = render(trigger.slice('click:'.length), { args, data }); - await page.click(String(ref).replace(/^@/, '')); + await page!.click(String(ref).replace(/^@/, '')); } else if (trigger === 'scroll') { - await page.scroll('down'); + await page!.scroll('down'); } // Step 3: Wait a bit for network requests to fire - await page.wait(Math.min(timeout, 3)); + await page!.wait(Math.min(timeout, 3)); // Step 4: Retrieve captured data - const matchingResponses = await page.evaluate(generateReadInterceptedJs()); + const matchingResponses = await page!.evaluate(generateReadInterceptedJs()); // Step 5: Select from response if specified let result = matchingResponses.length === 1 ? matchingResponses[0] : diff --git a/src/pipeline/steps/tap.ts b/src/pipeline/steps/tap.ts index f73fc94..9a58a20 100644 --- a/src/pipeline/steps/tap.ts +++ b/src/pipeline/steps/tap.ts @@ -13,7 +13,7 @@ import type { IPage } from '../../types.js'; import { render } from '../template.js'; import { generateTapInterceptorJs } from '../../interceptor.js'; -export async function stepTap(page: IPage, params: any, data: any, args: Record): Promise { +export async function stepTap(page: IPage | null, params: any, data: any, args: Record): Promise { const cfg = typeof params === 'object' ? params : {}; const storeName = String(render(cfg.store ?? '', { args, data })); const actionName = String(render(cfg.action ?? '', { args, data })); @@ -96,5 +96,5 @@ export async function stepTap(page: IPage, params: any, data: any, args: Record< } `; - return page.evaluate(js); + return page!.evaluate(js); } diff --git a/src/setup.ts b/src/setup.ts index d03f522..f15830d 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -24,7 +24,7 @@ import { upsertTomlConfigToken, writeFileWithMkdir, } from './doctor.js'; -import { getTokenFingerprint } from './browser.js'; +import { getTokenFingerprint } from './browser/index.js'; import { type CheckboxItem, checkboxPrompt } from './tui.js'; export async function runSetup(opts: { cliVersion?: string; token?: string } = {}) { diff --git a/tsconfig.json b/tsconfig.json index ce7a3a7..78c9b74 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,8 +5,7 @@ "moduleResolution": "Node16", "outDir": "dist", "rootDir": "src", - "strict": false, - "strictNullChecks": true, + "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true,