Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e2d6f5d7e | |||
| f6f50cf1f9 | |||
| bcaf6121b8 | |||
| 812f27a05e | |||
| ab0af2de5c | |||
| 5c655ee3c8 | |||
| 0b15561025 | |||
| f9857f8c7b | |||
| c9e29d9f22 | |||
| 75ddb6319b | |||
| 5ec34ebc53 | |||
| 959ec5fe1c | |||
| dbcfbc3bb6 | |||
| 5ae9658a21 | |||
| f415e829a9 | |||
| 210e6fabb7 | |||
| 73a8508972 | |||
| 0fc3bc2d85 | |||
| bc42c8b258 | |||
| c4e7a94bc4 | |||
| 3b0dcfddf0 | |||
| 4f13484aa3 | |||
| 5ab3f5d7d5 | |||
| 55c3259f28 | |||
| 70bd87b98c | |||
| ea0cf4d0b0 | |||
| 9919f03aed | |||
| 3834292871 | |||
| 53b06db53d | |||
| f79d5ab838 |
@@ -16,8 +16,8 @@ description: "Cross-project CLI command migration workflow for opencli. Use when
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- 熟悉 [CLI-EXPLORER.md](file:///Users/jakevin/code/opencli/CLI-EXPLORER.md)(adapter 开发决策树)
|
||||
- 熟悉 [SKILL.md](file:///Users/jakevin/code/opencli/SKILL.md)(命令参考 & 模板)
|
||||
- 熟悉 [CLI-EXPLORER.md](../../../CLI-EXPLORER.md)(adapter 开发决策树)
|
||||
- 熟悉 [SKILL.md](../../../SKILL.md)(命令参考 & 模板)
|
||||
|
||||
---
|
||||
|
||||
@@ -82,7 +82,7 @@ opencli list | grep <site> # 确认已注册命令
|
||||
## Phase 3: 批量实现
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 实现前必须查阅 [CLI-EXPLORER.md](file:///Users/jakevin/code/opencli/CLI-EXPLORER.md) 确认策略选择。
|
||||
> 实现前必须查阅 [CLI-EXPLORER.md](../../../CLI-EXPLORER.md) 确认策略选择。
|
||||
|
||||
### 3.1 选择实现方式
|
||||
|
||||
|
||||
@@ -39,13 +39,15 @@ jobs:
|
||||
run: npm run build
|
||||
|
||||
# ── Unit tests (vitest shard) ──
|
||||
# PR: ubuntu + Node 22 only (fast feedback, 2 jobs).
|
||||
# Push to main/dev: full matrix for cross-platform/cross-version coverage (12 jobs).
|
||||
unit-test:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
node-version: ['20', '22']
|
||||
os: ${{ (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && fromJSON('["ubuntu-latest","macos-latest","windows-latest"]') || fromJSON('["ubuntu-latest"]') }}
|
||||
node-version: ${{ (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && fromJSON('["20","22"]') || fromJSON('["22"]') }}
|
||||
shard: [1, 2]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -82,12 +84,9 @@ jobs:
|
||||
- name: Run unit tests under Bun
|
||||
run: bun vitest run --project unit --reporter=verbose
|
||||
|
||||
# Adapter tests are pure unit tests — OS doesn't affect results.
|
||||
adapter-test:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# OpenCLI
|
||||
|
||||
> **Make any website, Electron App, or Local Tool your CLI.**
|
||||
> **Make any website, Electron App, or Local Tool your CLI.**
|
||||
> Zero risk · Reuse Chrome login · AI-powered discovery · Universal CLI Hub
|
||||
|
||||
[](./README.zh-CN.md)
|
||||
@@ -10,17 +10,19 @@
|
||||
|
||||
A CLI tool that turns **any website**, **Electron app**, or **local CLI tool** into a command-line interface — Bilibili, Zhihu, 小红书, Twitter/X, Reddit, YouTube, Antigravity, `gh`, `docker`, and [many more](#built-in-commands) — powered by browser session reuse and AI-native discovery.
|
||||
|
||||
**Built for AI Agents**: Simply configure an instruction in your global `AGENT.md` or `.cursorrules` guiding the AI to execute `opencli list` via Bash to discover available tools. Register your favorite local CLIs (`opencli register mycli`), and the AI will automatically learn how to invoke all your tools perfectly!
|
||||
**Built for AI Agents** — Configure an instruction in your `AGENT.md` or `.cursorrules` to run `opencli list` via Bash. The AI will automatically discover and invoke all available tools.
|
||||
|
||||
**CLI All Electron Apps! The Most Powerful Update Has Arrived!**
|
||||
Turn ANY Electron application into a CLI tool! Recombine, script, and extend applications like Antigravity Ultra seamlessly. Now AI can control itself natively. Unlimited possibilities await!
|
||||
**CLI Hub** — Register any local CLI (`opencli register mycli`) so AI agents can discover and call it alongside built-in commands. Auto-installs missing tools via your package manager (e.g. if `gh` isn't installed, `opencli gh ...` runs `brew install gh` first then re-executes seamlessly).
|
||||
|
||||
**CLI for Electron Apps** — Turn any Electron application into a CLI tool. Recombine, script, and extend apps like Antigravity Ultra from the terminal. AI agents can now control other AI apps natively.
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively using cc/openclaw!
|
||||
- **CLI All Electron** — CLI-ify apps like Antigravity Ultra! Now AI can control itself natively.
|
||||
- **Account-safe** — Reuses Chrome's logged-in state; your credentials never leave the browser.
|
||||
- **Anti-detection built-in** — Patches `navigator.webdriver`, stubs `window.chrome`, fakes plugin lists, cleans ChromeDriver/Playwright globals, and strips CDP frames from Error stack traces. Extensive anti-fingerprinting and risk-control evasion measures baked in at every layer.
|
||||
- **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies.
|
||||
- **External CLI Hub** — Discover, auto-install, and passthrough commands to any external CLI (gh, obsidian, docker, etc). Zero setup.
|
||||
- **Self-healing setup** — `opencli doctor` diagnoses and auto-starts the daemon, extension, and live browser connectivity.
|
||||
@@ -47,83 +49,38 @@ There are many great browser automation tools. Here's when opencli is the right
|
||||
|
||||
> For a detailed comparison with Browser-Use, Crawl4AI, Firecrawl, and others, see the [Comparison Guide](./docs/comparison.md).
|
||||
|
||||
## Prerequisites
|
||||
---
|
||||
|
||||
- **Node.js**: >= 20.0.0 (or **Bun** >= 1.0 — see [Runtime Support](#runtime-support) below)
|
||||
- **Chrome** running **and logged into the target site** (e.g. bilibili.com, zhihu.com, xiaohongshu.com).
|
||||
## Quick Start
|
||||
|
||||
> **⚠️ Important**: Browser commands reuse your Chrome login session. You must be logged into the target website in Chrome before running commands. If you get empty data or errors, check your login status first.
|
||||
### 1. Install Browser Bridge Extension
|
||||
|
||||
### Runtime Support
|
||||
> OpenCLI connects to your browser through a lightweight **Browser Bridge** Chrome Extension + micro-daemon (zero config, auto-start).
|
||||
|
||||
OpenCLI works with both **Node.js** (≥ 20) and **Bun** (≥ 1.0). All commands and adapters are runtime-agnostic.
|
||||
|
||||
```bash
|
||||
# Development with Bun (faster startup)
|
||||
npm run dev:bun
|
||||
|
||||
# Run the built CLI with Bun
|
||||
npm run start:bun
|
||||
|
||||
# Run unit tests under Bun
|
||||
npm run test:bun
|
||||
|
||||
# Run E2E tests with Bun as the runtime
|
||||
OPENCLI_TEST_RUNTIME=bun npm run test:e2e
|
||||
```
|
||||
|
||||
Use `opencli doctor` to check your current runtime — it displays the active engine (e.g. `node v22.13.0` or `bun 1.1.42`).
|
||||
|
||||
OpenCLI connects to your browser through a lightweight **Browser Bridge** Chrome Extension + micro-daemon (zero config, auto-start).
|
||||
|
||||
### Browser Bridge Extension Setup
|
||||
|
||||
You can install the extension via either method:
|
||||
|
||||
**Method 1: Download Pre-built Release (Recommended)**
|
||||
1. Go to the GitHub [Releases page](https://github.com/jackwener/opencli/releases) and download the latest `opencli-extension.zip`.
|
||||
2. Unzip the file and open `chrome://extensions`, enable **Developer mode** (top-right toggle).
|
||||
3. Click **Load unpacked** and select the unzipped folder.
|
||||
|
||||
**Method 2: Load Source (For Developers)**
|
||||
1. Open `chrome://extensions` and enable **Developer mode**.
|
||||
2. Click **Load unpacked** and select the `extension/` directory from this repository.
|
||||
### 2. Install OpenCLI
|
||||
|
||||
That's it! The daemon auto-starts when you run any browser command. No tokens, no manual configuration.
|
||||
|
||||
> **Tip**: Use `opencli doctor` for ongoing diagnosis:
|
||||
> ```bash
|
||||
> opencli doctor # Check extension + daemon connectivity
|
||||
> ```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Install via npm (recommended)
|
||||
**Install via npm (recommended)**
|
||||
|
||||
```bash
|
||||
npm install -g @jackwener/opencli
|
||||
```
|
||||
|
||||
Then use directly:
|
||||
### 3. Verify & Try
|
||||
|
||||
```bash
|
||||
opencli list # See all commands
|
||||
opencli list -f yaml # List commands as YAML
|
||||
opencli hackernews top --limit 5 # Public API, no browser
|
||||
opencli bilibili hot --limit 5 # Browser command
|
||||
opencli zhihu hot -f json # JSON output
|
||||
opencli zhihu hot -f yaml # YAML output
|
||||
opencli doctor # Check extension + daemon connectivity
|
||||
```
|
||||
|
||||
### Install from source (for developers)
|
||||
**Try it out:**
|
||||
|
||||
```bash
|
||||
git clone git@github.com:jackwener/opencli.git
|
||||
cd opencli
|
||||
npm install
|
||||
npm run build
|
||||
npm link # Link binary globally
|
||||
opencli list # Now you can use it anywhere!
|
||||
opencli list # See all commands
|
||||
opencli hackernews top --limit 5 # Public API, no browser needed
|
||||
opencli bilibili hot --limit 5 # Browser command (requires Extension)
|
||||
```
|
||||
|
||||
### Update
|
||||
@@ -132,104 +89,64 @@ opencli list # Now you can use it anywhere!
|
||||
npm install -g @jackwener/opencli@latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### For Developers
|
||||
|
||||
**Install from source**
|
||||
|
||||
```bash
|
||||
git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && npm run build && npm link
|
||||
```
|
||||
|
||||
**Load Source Browser Bridge Extension**
|
||||
|
||||
1. Open `chrome://extensions` and enable **Developer mode** (top-right toggle).
|
||||
2. Click **Load unpacked** and select the `extension/` directory from this repository.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js**: >= 20.0.0 (or **Bun** >= 1.0)
|
||||
- **Chrome** running **and logged into the target site** (e.g. bilibili.com, zhihu.com, xiaohongshu.com).
|
||||
|
||||
> **⚠️ Important**: Browser commands reuse your Chrome login session. You must be logged into the target website in Chrome before running commands. If you get empty data or errors, check your login status first.
|
||||
|
||||
## Built-in Commands
|
||||
|
||||
Run `opencli list` for the live registry.
|
||||
| Site | Commands |
|
||||
|------|----------|
|
||||
| **xiaohongshu** | `search` `feed` `user` `download` `publish` `comments` `notifications` `creator-notes` `creator-notes-summary` `creator-note-detail` `creator-profile` `creator-stats` |
|
||||
| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `user-videos` |
|
||||
| **twitter** | `trending` `search` `timeline` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `user` `user-posts` `user-comments` `read` `save` `saved` `subscribe` `upvote` `upvoted` `comment` |
|
||||
|
||||
| Site | Commands | Mode |
|
||||
|------|----------|------|
|
||||
| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` `download` `accept` `reply-dm` `block` `unblock` `hide-reply` | Browser |
|
||||
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | Browser |
|
||||
| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | Desktop |
|
||||
| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | Browser |
|
||||
| **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `history` `export` | Desktop |
|
||||
| **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | Desktop |
|
||||
| **doubao** | `status` `new` `send` `read` `ask` | Browser |
|
||||
| **doubao-app** | `status` `new` `send` `read` `ask` `screenshot` `dump` | Desktop |
|
||||
| **notion** | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | Desktop |
|
||||
| **discord-app** | `status` `send` `read` `channels` `servers` `search` `members` | Desktop |
|
||||
| **v2ex** | `hot` `latest` `topic` `node` `user` `member` `replies` `nodes` `daily` `me` `notifications` | Public / Browser |
|
||||
| **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` `earnings-date` `fund-holdings` `fund-snapshot` | Browser |
|
||||
| **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | Desktop |
|
||||
| **chatgpt** | `status` `new` `send` `read` `ask` `model` | Desktop |
|
||||
| **xiaohongshu** | `search` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | Browser |
|
||||
| **apple-podcasts** | `search` `episodes` `top` | Public |
|
||||
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | Public |
|
||||
| **zhihu** | `hot` `search` `question` `download` | Browser |
|
||||
| **weixin** | `download` | Browser |
|
||||
| **youtube** | `search` `video` `transcript` | Browser |
|
||||
| **boss** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | Browser |
|
||||
| **coupang** | `search` `add-to-cart` | Browser |
|
||||
| **bbc** | `news` | Public |
|
||||
| **bloomberg** | `main` `markets` `economics` `industries` `tech` `politics` `businessweek` `opinions` `feeds` `news` | Public / Browser |
|
||||
| **ctrip** | `search` | Browser |
|
||||
| **devto** | `top` `tag` `user` | Public |
|
||||
| **dictionary** | `search` `synonyms` `examples` | Public |
|
||||
| **arxiv** | `search` `paper` | Public |
|
||||
| **paperreview** | `submit` `review` `feedback` | Public |
|
||||
| **wikipedia** | `search` `summary` `random` `trending` | Public |
|
||||
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | Public |
|
||||
| **jd** | `item` | Browser |
|
||||
| **linkedin** | `search` `timeline` | Browser |
|
||||
| **reuters** | `search` | Browser |
|
||||
| **smzdm** | `search` | Browser |
|
||||
| **web** | `read` | Browser |
|
||||
| **weibo** | `hot` `search` | Browser |
|
||||
| **yahoo-finance** | `quote` | Browser |
|
||||
| **sinafinance** | `news` | 🌐 Public |
|
||||
| **barchart** | `quote` `options` `greeks` `flow` | Browser |
|
||||
| **chaoxing** | `assignments` `exams` | Browser |
|
||||
| **grok** | `ask` | Browser |
|
||||
| **hf** | `top` | Public |
|
||||
| **jike** | `feed` `search` `create` `like` `comment` `repost` `notifications` `post` `topic` `user` | Browser |
|
||||
| **jimeng** | `generate` `history` | Browser |
|
||||
| **yollomi** | `generate` `video` `edit` `upload` `models` `remove-bg` `upscale` `face-swap` `restore` `try-on` `background` `object-remover` | Browser |
|
||||
| **linux-do** | `feed` `categories` `tags` `search` `topic` `user-topics` `user-posts` | Browser |
|
||||
| **stackoverflow** | `hot` `search` `bounties` `unanswered` | Public |
|
||||
| **steam** | `top-sellers` | Public |
|
||||
| **weread** | `shelf` `search` `book` `highlights` `notes` `notebooks` `ranking` | Browser |
|
||||
| **douban** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` | Browser |
|
||||
| **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | Browser |
|
||||
| **google** | `news` `search` `suggest` `trends` | Public |
|
||||
| **36kr** | `news` `hot` `search` `article` | Public / Browser |
|
||||
| **imdb** | `search` `title` `top` `trending` `person` `reviews` | Public |
|
||||
| **producthunt** | `posts` `today` `hot` `browse` | Public / Browser |
|
||||
| **instagram** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | Browser |
|
||||
| **lobsters** | `hot` `newest` `active` `tag` | Public |
|
||||
| **medium** | `feed` `search` `user` | Browser |
|
||||
| **sinablog** | `hot` `search` `article` `user` | Browser |
|
||||
| **substack** | `feed` `search` `publication` | Browser |
|
||||
| **pixiv** | `ranking` `search` `user` `illusts` `detail` `download` | Browser |
|
||||
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | Browser |
|
||||
66+ adapters in total — **[→ see all supported sites & commands](./docs/adapters/index.md)**
|
||||
|
||||
## CLI Hub
|
||||
|
||||
### External CLI Hub
|
||||
OpenCLI acts as a universal hub for your existing command-line tools — unified discovery, pure passthrough execution, and auto-install (if a tool isn't installed, OpenCLI runs `brew install <tool>` automatically before re-running the command).
|
||||
|
||||
OpenCLI acts as a universal hub for your existing command-line tools. It provides unified discovery, automatic installation, and pure passthrough execution.
|
||||
|
||||
| External CLI | Description | Commands Example |
|
||||
|--------------|-------------|------------------|
|
||||
| External CLI | Description | Example |
|
||||
|--------------|-------------|---------|
|
||||
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
|
||||
| **obsidian** | Obsidian vault management | `opencli obsidian search query="AI"` |
|
||||
| **docker** | Docker command-line interface | `opencli docker ps` |
|
||||
| **readwise** | Readwise & Reader CLI | `opencli readwise login` |
|
||||
| **gws** | Google Workspace CLI — Docs, Sheets, Drive, Gmail, Calendar | `opencli gws docs list` |
|
||||
| **docker** | Docker | `opencli docker ps` |
|
||||
| **lark-cli** | Lark/Feishu — messages, docs, calendar, tasks, 200+ commands | `opencli lark-cli calendar +agenda` |
|
||||
| **dingtalk** | DingTalk — cross-platform CLI for DingTalk's full suite, designed for humans and AI agents | `opencli dingtalk msg send --to user "hello"` |
|
||||
| **wecom** | WeCom/企业微信 — CLI for WeCom open platform, for humans and AI agents | `opencli wecom msg send --to user "hello"` |
|
||||
| **vercel** | Vercel — deploy projects, manage domains, env vars, logs | `opencli vercel deploy --prod` |
|
||||
|
||||
**Zero Configuration**: OpenCLI purely passes your inputs to the underlying binary via standard I/O streams. The external CLI works exactly as it naturally would, maintaining its standard output formats.
|
||||
**Register your own** — add any local CLI so AI agents can discover it via `opencli list`:
|
||||
|
||||
**Auto-Installation**: If you run `opencli gh ...` and `gh` is not installed on your system, OpenCLI will automatically try to install it using your system's package manager (e.g., `brew install gh`) before seamlessly re-running the command.
|
||||
|
||||
**Register Your Own**:
|
||||
Add any local CLI to your OpenCLI registry so AI agents can automatically discover it via the `opencli list` command.
|
||||
```bash
|
||||
opencli register mycli
|
||||
```
|
||||
|
||||
### Desktop App Adapters
|
||||
|
||||
Each desktop adapter has its own detailed documentation with commands reference, setup guide, and examples:
|
||||
|
||||
If you want to add support for a new Electron desktop app, start with [docs/guide/electron-app-cli.md](./docs/guide/electron-app-cli.md) and the deeper [Electron guide](./docs/advanced/electron.md).
|
||||
Control Electron desktop apps directly from the terminal. Each adapter has its own detailed documentation:
|
||||
|
||||
| App | Description | Doc |
|
||||
|-----|-------------|-----|
|
||||
@@ -242,93 +159,73 @@ If you want to add support for a new Electron desktop app, start with [docs/guid
|
||||
| **Discord** | Discord Desktop — messages, channels, servers | [Doc](./docs/adapters/desktop/discord.md) |
|
||||
| **Doubao** | Control Doubao AI desktop app via CDP | [Doc](./docs/adapters/desktop/doubao-app.md) |
|
||||
|
||||
To add a new Electron app, start with [docs/guide/electron-app-cli.md](./docs/guide/electron-app-cli.md).
|
||||
|
||||
## Download Support
|
||||
|
||||
OpenCLI supports downloading images, videos, and articles from supported platforms.
|
||||
|
||||
### Supported Platforms
|
||||
|
||||
| Platform | Content Types | Notes |
|
||||
|----------|---------------|-------|
|
||||
| **xiaohongshu** | Images, Videos | Downloads all media from a note |
|
||||
| **bilibili** | Videos | Requires `yt-dlp` installed |
|
||||
| **twitter** | Images, Videos | Downloads from user media tab or single tweet |
|
||||
| **douban** | Images | Downloads poster / still image lists from movie subjects |
|
||||
| **pixiv** | Images | Downloads original-quality illustrations, supports multi-page works |
|
||||
| **zhihu** | Articles (Markdown) | Exports articles with optional image download |
|
||||
| **weixin** | Articles (Markdown) | Exports WeChat Official Account articles |
|
||||
| **twitter** | Images, Videos | From user media tab or single tweet |
|
||||
| **douban** | Images | Poster / still image lists |
|
||||
| **pixiv** | Images | Original-quality illustrations, multi-page |
|
||||
| **zhihu** | Articles (Markdown) | Exports with optional image download |
|
||||
| **weixin** | Articles (Markdown) | WeChat Official Account articles |
|
||||
|
||||
### Prerequisites
|
||||
|
||||
For video downloads from streaming platforms, you need to install `yt-dlp`:
|
||||
For video downloads, install `yt-dlp` first: `brew install yt-dlp`
|
||||
|
||||
```bash
|
||||
# Install yt-dlp
|
||||
pip install yt-dlp
|
||||
# or
|
||||
brew install yt-dlp
|
||||
```
|
||||
|
||||
### Usage Examples
|
||||
|
||||
```bash
|
||||
# Download images/videos from Xiaohongshu note
|
||||
opencli xiaohongshu download abc123 --output ./xhs
|
||||
|
||||
# Download Bilibili video (requires yt-dlp)
|
||||
opencli bilibili download BV1xxx --output ./bilibili
|
||||
opencli bilibili download BV1xxx --quality 1080p # Specify quality
|
||||
|
||||
# Download Twitter media from user
|
||||
opencli twitter download elonmusk --limit 20 --output ./twitter
|
||||
|
||||
# Download single tweet media
|
||||
opencli twitter download --tweet-url "https://x.com/user/status/123" --output ./twitter
|
||||
|
||||
# Download Douban posters / stills
|
||||
opencli douban download 30382501 --output ./douban
|
||||
|
||||
# Export Zhihu article to Markdown
|
||||
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --output ./zhihu
|
||||
|
||||
# Export with local images
|
||||
opencli zhihu download "https://zhuanlan.zhihu.com/p/xxx" --download-images
|
||||
|
||||
# Export WeChat article to Markdown
|
||||
opencli weixin download --url "https://mp.weixin.qq.com/s/xxx" --output ./weixin
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Output Formats
|
||||
|
||||
All built-in commands support `--format` / `-f` with `table`, `json`, `yaml`, `md`, and `csv`.
|
||||
The `list` command supports the same format options, and keeps `--json` for backward compatibility.
|
||||
All built-in commands support `--format` / `-f` with `table` (default), `json`, `yaml`, `md`, and `csv`.
|
||||
|
||||
```bash
|
||||
opencli list -f yaml # Command registry as YAML
|
||||
opencli bilibili hot -f table # Default: rich terminal table
|
||||
opencli bilibili hot -f json # JSON (pipe to jq or LLMs)
|
||||
opencli bilibili hot -f yaml # YAML (human-readable structured output)
|
||||
opencli bilibili hot -f md # Markdown
|
||||
opencli bilibili hot -f csv # CSV
|
||||
opencli bilibili hot -f json # Pipe to jq or LLMs
|
||||
opencli bilibili hot -f csv # Spreadsheet-friendly
|
||||
opencli bilibili hot -v # Verbose: show pipeline debug steps
|
||||
```
|
||||
|
||||
## Exit Codes
|
||||
|
||||
opencli follows Unix `sysexits.h` conventions so it integrates naturally with shell pipelines and CI scripts:
|
||||
|
||||
| Code | Meaning | When |
|
||||
|------|---------|------|
|
||||
| `0` | Success | Command completed normally |
|
||||
| `1` | Generic error | Unexpected / unclassified failure |
|
||||
| `2` | Usage error | Bad arguments or unknown command |
|
||||
| `66` | Empty result | No data returned (`EX_NOINPUT`) |
|
||||
| `69` | Service unavailable | Browser Bridge not connected (`EX_UNAVAILABLE`) |
|
||||
| `75` | Temporary failure | Command timed out — retry (`EX_TEMPFAIL`) |
|
||||
| `77` | Auth required | Not logged in to target site (`EX_NOPERM`) |
|
||||
| `78` | Config error | Missing credentials or bad config (`EX_CONFIG`) |
|
||||
| `130` | Interrupted | Ctrl-C / SIGINT |
|
||||
|
||||
```bash
|
||||
opencli spotify status || echo "exit $?" # 69 if browser not running
|
||||
opencli github issues 2>/dev/null
|
||||
[ $? -eq 77 ] && opencli github auth # auto-auth if not logged in
|
||||
```
|
||||
|
||||
## Plugins
|
||||
|
||||
Extend OpenCLI with community-contributed adapters. Plugins use the same YAML/TS format as built-in commands and are automatically discovered at startup.
|
||||
Extend OpenCLI with community-contributed adapters:
|
||||
|
||||
```bash
|
||||
opencli plugin install github:user/opencli-plugin-my-tool # Install
|
||||
opencli plugin list # List installed
|
||||
opencli plugin update my-tool # Update to latest
|
||||
opencli plugin update --all # Update all installed plugins
|
||||
opencli plugin uninstall my-tool # Remove
|
||||
opencli plugin install github:user/opencli-plugin-my-tool
|
||||
opencli plugin list
|
||||
opencli plugin update --all
|
||||
opencli plugin uninstall my-tool
|
||||
```
|
||||
|
||||
`opencli plugin list` also shows the tracked short commit hash when a plugin version is recorded in `~/.opencli/plugins.lock.json`.
|
||||
|
||||
| Plugin | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| [opencli-plugin-github-trending](https://github.com/ByteYue/opencli-plugin-github-trending) | YAML | GitHub Trending repositories |
|
||||
@@ -339,53 +236,33 @@ See [Plugins Guide](./docs/guide/plugins.md) for creating your own plugin.
|
||||
|
||||
## For AI Agents (Developer Guide)
|
||||
|
||||
If you are an AI assistant tasked with creating a new command adapter for `opencli`, please follow the AI Agent workflow below:
|
||||
|
||||
> **Quick mode**: To generate a single command for a specific page URL, see [CLI-ONESHOT.md](./CLI-ONESHOT.md) — just a URL + one-line goal, 4 steps done.
|
||||
|
||||
> **Full mode**: Before writing any adapter code, read [CLI-EXPLORER.md](./CLI-EXPLORER.md). It contains the complete browser exploration workflow, the 5-tier authentication strategy decision tree, and debugging guide.
|
||||
|
||||
```bash
|
||||
# 1. Deep Explore — discover APIs, infer capabilities, detect framework
|
||||
opencli explore https://example.com --site mysite
|
||||
|
||||
# 2. Synthesize — generate YAML adapters from explore artifacts
|
||||
opencli synthesize mysite
|
||||
|
||||
# 3. Generate — one-shot: explore → synthesize → register
|
||||
opencli generate https://example.com --goal "hot"
|
||||
|
||||
# 4. Strategy Cascade — auto-probe: PUBLIC → COOKIE → HEADER
|
||||
opencli cascade https://api.example.com/data
|
||||
opencli explore https://example.com --site mysite # Discover APIs + capabilities
|
||||
opencli synthesize mysite # Generate YAML adapters
|
||||
opencli generate https://example.com --goal "hot" # One-shot: explore → synthesize → register
|
||||
opencli cascade https://api.example.com/data # Auto-probe: PUBLIC → COOKIE → HEADER
|
||||
```
|
||||
|
||||
Explore outputs to `.opencli/explore/<site>/` (manifest.json, endpoints.json, capabilities.json, auth.json).
|
||||
|
||||
## Testing
|
||||
|
||||
See **[TESTING.md](./TESTING.md)** for how to run and write tests.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Extension not connected"**
|
||||
- Ensure the opencli Browser Bridge extension is installed and **enabled** in `chrome://extensions`.
|
||||
- **"attach failed: Cannot access a chrome-extension:// URL"**
|
||||
- Another Chrome extension (e.g. youmind, New Tab Override, or AI assistant extensions) may be interfering. Try **disabling other extensions** temporarily, then retry.
|
||||
- **Empty data returns or 'Unauthorized' error**
|
||||
- Your login session in Chrome might have expired. Open a normal Chrome tab, navigate to the target site, and log in or refresh the page.
|
||||
- **Node API errors**
|
||||
- Make sure you are using Node.js >= 20. Some dependencies require modern Node APIs.
|
||||
- **Daemon issues**
|
||||
- Check daemon status: `curl localhost:19825/status`
|
||||
- View extension logs: `curl localhost:19825/logs`
|
||||
|
||||
- **"Extension not connected"** — Ensure the Browser Bridge extension is installed and **enabled** in `chrome://extensions`.
|
||||
- **"attach failed: Cannot access a chrome-extension:// URL"** — Another extension may be interfering. Try disabling other extensions temporarily.
|
||||
- **Empty data or 'Unauthorized' error** — Your Chrome login session may have expired. Navigate to the target site and log in again.
|
||||
- **Node API errors** — Ensure Node.js >= 20. Some dependencies require modern Node APIs.
|
||||
- **Daemon issues** — Check status: `curl localhost:19825/status` · View logs: `curl localhost:19825/logs`
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#jackwener/opencli&Date)
|
||||
|
||||
|
||||
|
||||
## License
|
||||
|
||||
[Apache-2.0](./LICENSE)
|
||||
|
||||
+32
-2
@@ -183,7 +183,10 @@ npm install -g @jackwener/opencli@latest
|
||||
| **substack** | `feed` `search` `publication` | 浏览器 |
|
||||
| **pixiv** | `ranking` `search` `user` `illusts` `detail` `download` | 浏览器 |
|
||||
| **tiktok** | `explore` `search` `profile` `user` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `live` `notifications` `friends` | 浏览器 |
|
||||
| **bluesky** | `search` `trending` `user` `profile` `thread` `feeds` `followers` `following` `starter-packs` | 公开 |
|
||||
| **douyin** | `videos` `publish` `drafts` `draft` `delete` `stats` `profile` `update` `hashtag` `location` `activities` `collections` | 浏览器 |
|
||||
|
||||
66+ 适配器 — **[→ 查看完整命令列表](./docs/adapters/index.md)**
|
||||
|
||||
### 外部 CLI 枢纽
|
||||
|
||||
@@ -194,8 +197,10 @@ OpenCLI 也可以作为你现有命令行工具的统一入口,负责发现、
|
||||
| **gh** | GitHub CLI | `opencli gh pr list --limit 5` |
|
||||
| **obsidian** | Obsidian 仓库管理 | `opencli obsidian search query="AI"` |
|
||||
| **docker** | Docker 命令行工具 | `opencli docker ps` |
|
||||
| **readwise** | Readwise / Reader CLI | `opencli readwise login` |
|
||||
| **gws** | Google Workspace CLI — Docs, Sheets, Drive, Gmail, Calendar | `opencli gws docs list` |
|
||||
| **lark-cli** | 飞书 CLI — 消息、文档、日历、任务,200+ 命令 | `opencli lark-cli calendar +agenda` |
|
||||
| **dingtalk** | 钉钉 CLI — 钉钉全套产品能力的跨平台命令行工具,支持人类和 AI Agent 使用 | `opencli dingtalk msg send --to user "hello"` |
|
||||
| **wecom** | 企业微信 CLI — 企业微信开放平台命令行工具,支持人类和 AI Agent 使用 | `opencli wecom msg send --to user "hello"` |
|
||||
| **vercel** | Vercel — 部署项目、管理域名、环境变量、日志 | `opencli vercel deploy --prod` |
|
||||
|
||||
**零配置透传**:OpenCLI 会把你的输入原样转发给底层二进制,保留原生 stdout / stderr 行为。
|
||||
|
||||
@@ -295,6 +300,31 @@ opencli bilibili hot -f csv # CSV
|
||||
opencli bilibili hot -v # 详细模式:展示管线执行步骤调试信息
|
||||
```
|
||||
|
||||
## 退出码
|
||||
|
||||
opencli 遵循 Unix `sysexits.h` 惯例,可无缝接入 shell 管道和 CI 脚本:
|
||||
|
||||
| 退出码 | 含义 | 触发场景 |
|
||||
|--------|------|----------|
|
||||
| `0` | 成功 | 命令正常完成 |
|
||||
| `1` | 通用错误 | 未分类的意外错误 |
|
||||
| `2` | 用法错误 | 参数错误或未知命令 |
|
||||
| `66` | 无数据 | 命令返回空结果(`EX_NOINPUT`) |
|
||||
| `69` | 服务不可用 | Browser Bridge 未连接(`EX_UNAVAILABLE`) |
|
||||
| `75` | 临时失败 | 命令超时,可重试(`EX_TEMPFAIL`) |
|
||||
| `77` | 需要认证 | 未登录目标网站(`EX_NOPERM`) |
|
||||
| `78` | 配置错误 | 凭证缺失或配置有误(`EX_CONFIG`) |
|
||||
| `130` | 中断 | Ctrl-C / SIGINT |
|
||||
|
||||
```bash
|
||||
opencli bilibili hot 2>/dev/null
|
||||
case $? in
|
||||
0) echo "ok" ;;
|
||||
69) echo "请先启动 Browser Bridge" ;;
|
||||
77) echo "请先登录 bilibili.com" ;;
|
||||
esac
|
||||
```
|
||||
|
||||
## 插件
|
||||
|
||||
通过社区贡献的插件扩展 OpenCLI。插件使用与内置命令相同的 YAML/TS 格式,启动时自动发现。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: opencli
|
||||
description: "OpenCLI — Make any website or Electron App your CLI. Zero risk, AI-powered, reuse Chrome login."
|
||||
version: 1.4.1
|
||||
version: 1.5.5
|
||||
author: jackwener
|
||||
tags: [cli, browser, web, chrome-extension, cdp, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, youtube, boss, coupang, yollomi, AI, agent]
|
||||
---
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
# 新浪财经 (Sina Finance)
|
||||
|
||||
**Mode**: 🌐 Public · **Domain**: `finance.sina.com.cn`
|
||||
**Mode**: 🌐 Public / 🔐 Browser · **Domain**: `finance.sina.com.cn`
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `opencli sinafinance news` | 新浪财经 7×24 小时实时快讯 |
|
||||
| Command | Description | Mode |
|
||||
|---------|-------------|------|
|
||||
| `opencli sinafinance news` | 新浪财经 7×24 小时实时快讯 | 🌐 Public |
|
||||
| `opencli sinafinance rolling-news` | 新浪财经滚动新闻 | 🔐 Browser |
|
||||
| `opencli sinafinance stock` | 新浪财经行情(A股/港股/美股) | 🌐 Public |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### news - 7×24 实时快讯
|
||||
|
||||
```bash
|
||||
# Latest financial news
|
||||
opencli sinafinance news --limit 20
|
||||
@@ -23,13 +27,59 @@ opencli sinafinance news --type 6 # 国际
|
||||
opencli sinafinance news -f json
|
||||
```
|
||||
|
||||
### Options
|
||||
### rolling-news - 滚动新闻
|
||||
|
||||
```bash
|
||||
# Rolling news feed
|
||||
opencli sinafinance rolling-news
|
||||
|
||||
# JSON output
|
||||
opencli sinafinance rolling-news -f json
|
||||
```
|
||||
|
||||
### stock - 股票行情
|
||||
|
||||
```bash
|
||||
# Search and view A-share stock
|
||||
opencli sinafinance stock 贵州茅台 --market cn
|
||||
|
||||
# Search and view HK stock
|
||||
opencli sinafinance stock 腾讯控股 --market hk
|
||||
|
||||
# Search and view US stock
|
||||
opencli sinafinance stock aapl --market us
|
||||
|
||||
# Auto-detect market (searches cn, hk, us in order)
|
||||
opencli sinafinance stock 招商证券
|
||||
|
||||
# JSON output
|
||||
opencli sinafinance stock 贵州茅台 -f json
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
### news
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--limit` | Max results, up to 50 (default: 20) |
|
||||
| `--type` | News type: `0`=全部, `1`=A股, `2`=宏观, `3`=公司, `4`=数据, `5`=市场, `6`=国际, `7`=观点, `8`=央行, `9`=其它 |
|
||||
|
||||
### stock
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--market` | Market: `cn`, `hk`, `us`, `auto` (default: auto). When `auto`, searches in cn, hk, us order |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- No browser required — uses public API
|
||||
- `news` & `stock`: No browser required — uses public API
|
||||
- `rolling-news`: Chrome running and **logged into** `finance.sina.com.cn`
|
||||
- For `rolling-news`: [Browser Bridge extension](/guide/browser-bridge) installed
|
||||
|
||||
## Notes
|
||||
|
||||
- `news` and `stock` use public APIs — no browser or login needed
|
||||
- `stock` supports Chinese names, Chinese codes, and ticker symbols; auto-detects market
|
||||
- Market priority for auto-detection: cn (A股) → hk (港股) → us (美股)
|
||||
- US stock `High`/`Low` columns show 52-week range; A股/港股 show today's range
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,170 @@
|
||||
# Performance: Smart Wait & INTERCEPT Fix
|
||||
|
||||
**Date**: 2026-03-28
|
||||
**Status**: Approved
|
||||
|
||||
## Problem
|
||||
|
||||
Three distinct performance/correctness issues:
|
||||
|
||||
1. **INTERCEPT strategy semantic bug**: After `installInterceptor()` + `goto()`, adapters call `wait(N)` — which now uses `waitForDomStableJs` and returns early when the DOM settles. But DOM-settle != network capture. The API response may arrive *after* DOM is stable, causing `getInterceptedRequests()` to return an empty array.
|
||||
|
||||
2. **Blind `wait(N)` in adapters**: ~30 high-traffic adapters (Twitter family, Medium, Substack, etc.) call `wait(5)` waiting for React/Vue to hydrate. These should wait for a specific DOM element to appear, not a fixed cap.
|
||||
|
||||
3. **Daemon cold-start polling**: Fixed 300ms poll loop means ~600ms before first successful `isExtensionConnected()` check, even though the daemon is typically ready in 500–800ms.
|
||||
|
||||
## Design
|
||||
|
||||
### Layer 1 — `waitForCapture()` (correctness fix + perf)
|
||||
|
||||
Add `waitForCapture(timeout?: number): Promise<void>` to `IPage`.
|
||||
|
||||
Polls `window.__opencli_xhr.length > 0` every 100ms inside the browser tab. Resolves as soon as ≥1 capture arrives; rejects after `timeout` seconds.
|
||||
|
||||
```typescript
|
||||
// dom-helpers.ts
|
||||
export function waitForCaptureJs(maxMs: number): string {
|
||||
return `
|
||||
new Promise((resolve, reject) => {
|
||||
const deadline = Date.now() + ${maxMs};
|
||||
const check = () => {
|
||||
if ((window.__opencli_xhr || []).length > 0) return resolve('captured');
|
||||
if (Date.now() > deadline) return reject(new Error('No capture within ${maxMs / 1000}s'));
|
||||
setTimeout(check, 100);
|
||||
};
|
||||
check();
|
||||
})
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
`page.ts` and `cdp.ts` implement `waitForCapture()` by calling `waitForCaptureJs`.
|
||||
|
||||
**All INTERCEPT adapters** replace `wait(N)` → `waitForCapture(N+2)` (slightly longer timeout as safety margin).
|
||||
|
||||
`stepIntercept` in `pipeline/steps/intercept.ts` replaces its internal `wait(timeout)` with `waitForCapture(timeout)`.
|
||||
|
||||
**Expected gain**: 36kr hot/search: 6s → ~1–2s. Twitter search/followers: 5–8s → ~1–3s.
|
||||
|
||||
### Layer 2 — `wait({ selector })` (semantic precision)
|
||||
|
||||
Extend `WaitOptions` with `selector?: string`.
|
||||
|
||||
Add `waitForSelectorJs(selector, timeoutMs)` to `dom-helpers.ts` — polls `document.querySelector(selector)` every 100ms, resolves on first match, rejects on timeout.
|
||||
|
||||
```typescript
|
||||
// types.ts
|
||||
export interface WaitOptions {
|
||||
text?: string;
|
||||
selector?: string; // NEW
|
||||
time?: number;
|
||||
timeout?: number;
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// dom-helpers.ts
|
||||
export function waitForSelectorJs(selector: string, timeoutMs: number): string {
|
||||
return `
|
||||
new Promise((resolve, reject) => {
|
||||
const deadline = Date.now() + ${timeoutMs};
|
||||
const check = () => {
|
||||
if (document.querySelector(${JSON.stringify(selector)})) return resolve('found');
|
||||
if (Date.now() > deadline) return reject(new Error('Selector not found: ' + ${JSON.stringify(selector)}));
|
||||
setTimeout(check, 100);
|
||||
};
|
||||
check();
|
||||
})
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
`page.ts` and `cdp.ts` handle `selector` branch in `wait()`.
|
||||
|
||||
**High-impact adapter changes**:
|
||||
|
||||
| Adapter | Old | New |
|
||||
|---------|-----|-----|
|
||||
| `twitter/*` (15 adapters) | `wait(5)` | `wait({ selector: '[data-testid="primaryColumn"]', timeout: 6 })` |
|
||||
| `twitter/reply.ts` | `wait(5)` | `wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 8 })` |
|
||||
| `medium/utils.ts` | `wait(5)` + inline 3s setTimeout | `wait({ selector: 'article', timeout: 8 })` + remove inline sleep |
|
||||
| `substack/utils.ts` | `wait(5)` × 2 | `wait({ selector: 'article', timeout: 8 })` |
|
||||
| `bloomberg/news.ts` | `wait(5)` | `wait({ selector: 'article', timeout: 6 })` |
|
||||
| `sinablog/utils.ts` | `wait(5)` | `wait({ selector: 'article, .article', timeout: 6 })` |
|
||||
| `producthunt` (already covered by layer 1) | — | — |
|
||||
|
||||
**Expected gain**: Twitter commands: 5s → ~0.5–2s. Medium: 8s → ~1–3s.
|
||||
|
||||
### Layer 3 — Daemon exponential backoff (cold-start)
|
||||
|
||||
Replace fixed 300ms poll in `_ensureDaemon()` (`browser/mcp.ts`) with exponential backoff:
|
||||
|
||||
```typescript
|
||||
// before
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
if (await isExtensionConnected()) return;
|
||||
}
|
||||
|
||||
// after
|
||||
const backoffs = [50, 100, 200, 400, 800, 1500, 3000];
|
||||
let i = 0;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, backoffs[Math.min(i++, backoffs.length - 1)]));
|
||||
if (await isExtensionConnected()) return;
|
||||
}
|
||||
```
|
||||
|
||||
**Expected gain**: First cold-start check succeeds at ~150ms instead of ~600ms.
|
||||
|
||||
## Files Changed
|
||||
|
||||
### New / Modified (framework)
|
||||
- `src/types.ts` — `WaitOptions.selector`, `IPage.waitForCapture()`
|
||||
- `src/browser/dom-helpers.ts` — `waitForCaptureJs()`, `waitForSelectorJs()`
|
||||
- `src/browser/page.ts` — `waitForCapture()`, `wait()` selector branch
|
||||
- `src/browser/cdp.ts` — `waitForCapture()`, `wait()` selector branch
|
||||
- `src/browser/mcp.ts` — exponential backoff in `_ensureDaemon()`
|
||||
- `src/pipeline/steps/intercept.ts` — use `waitForCapture()`
|
||||
|
||||
### Modified (adapters — Layer 1, INTERCEPT)
|
||||
- `src/clis/36kr/hot.ts`
|
||||
- `src/clis/36kr/search.ts`
|
||||
- `src/clis/twitter/search.ts`
|
||||
- `src/clis/twitter/followers.ts`
|
||||
- `src/clis/twitter/following.ts`
|
||||
- `src/clis/producthunt/hot.ts`
|
||||
- `src/clis/producthunt/browse.ts`
|
||||
|
||||
### Modified (adapters — Layer 2, selector)
|
||||
- `src/clis/twitter/reply.ts`
|
||||
- `src/clis/twitter/follow.ts`
|
||||
- `src/clis/twitter/unfollow.ts`
|
||||
- `src/clis/twitter/like.ts`
|
||||
- `src/clis/twitter/bookmark.ts`
|
||||
- `src/clis/twitter/unbookmark.ts`
|
||||
- `src/clis/twitter/block.ts`
|
||||
- `src/clis/twitter/unblock.ts`
|
||||
- `src/clis/twitter/hide-reply.ts`
|
||||
- `src/clis/twitter/notifications.ts`
|
||||
- `src/clis/twitter/profile.ts`
|
||||
- `src/clis/twitter/thread.ts`
|
||||
- `src/clis/twitter/timeline.ts`
|
||||
- `src/clis/twitter/delete.ts`
|
||||
- `src/clis/twitter/reply-dm.ts`
|
||||
- `src/clis/medium/utils.ts`
|
||||
- `src/clis/substack/utils.ts`
|
||||
- `src/clis/bloomberg/news.ts`
|
||||
- `src/clis/sinablog/utils.ts`
|
||||
|
||||
## Delivery Order
|
||||
|
||||
1. Layer 1 (`waitForCapture`) — correctness fix, highest ROI
|
||||
2. Layer 3 (backoff) — 3-line change, zero risk
|
||||
3. Layer 2 (`wait({ selector })`) — largest adapter surface, can be done per-site
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests: `waitForCaptureJs`, `waitForSelectorJs` exported and tested in `dom-helpers.test.ts` (if exists) or new test file
|
||||
- Adapter tests: existing tests must continue to pass (mock `page.wait` / `page.waitForCapture`)
|
||||
- Run: `npx vitest run --project unit --project adapter`
|
||||
Vendored
+12
-6
@@ -1,6 +1,7 @@
|
||||
const DAEMON_PORT = 19825;
|
||||
const DAEMON_HOST = "localhost";
|
||||
const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
|
||||
const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`;
|
||||
const WS_RECONNECT_BASE_DELAY = 2e3;
|
||||
const WS_RECONNECT_MAX_DELAY = 6e4;
|
||||
|
||||
@@ -149,8 +150,14 @@ console.error = (...args) => {
|
||||
_origError(...args);
|
||||
forwardLog("error", args);
|
||||
};
|
||||
function connect() {
|
||||
async function connect() {
|
||||
if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
|
||||
try {
|
||||
const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) });
|
||||
if (!res.ok) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ws = new WebSocket(DAEMON_WS_URL);
|
||||
} catch {
|
||||
@@ -192,7 +199,7 @@ function scheduleReconnect() {
|
||||
const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY);
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
void connect();
|
||||
}, delay);
|
||||
}
|
||||
const automationSessions = /* @__PURE__ */ new Map();
|
||||
@@ -231,8 +238,7 @@ async function getAutomationWindow(workspace) {
|
||||
focused: false,
|
||||
width: 1280,
|
||||
height: 900,
|
||||
type: "normal",
|
||||
state: "minimized"
|
||||
type: "normal"
|
||||
});
|
||||
const session = {
|
||||
windowId: win.id,
|
||||
@@ -260,7 +266,7 @@ function initialize() {
|
||||
initialized = true;
|
||||
chrome.alarms.create("keepalive", { periodInMinutes: 0.4 });
|
||||
registerListeners();
|
||||
connect();
|
||||
void connect();
|
||||
console.log("[opencli] OpenCLI extension initialized");
|
||||
}
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
@@ -270,7 +276,7 @@ chrome.runtime.onStartup.addListener(() => {
|
||||
initialize();
|
||||
});
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === "keepalive") connect();
|
||||
if (alarm.name === "keepalive") void connect();
|
||||
});
|
||||
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||
if (msg?.type === "getStatus") {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "OpenCLI",
|
||||
"version": "1.5.1",
|
||||
"version": "1.5.5",
|
||||
"description": "Browser automation bridge for the OpenCLI CLI tool. Executes commands in isolated Chrome windows via a local daemon.",
|
||||
"permissions": [
|
||||
"debugger",
|
||||
@@ -35,4 +35,4 @@
|
||||
"extension_pages": "script-src 'self'; object-src 'self'"
|
||||
},
|
||||
"homepage_url": "https://github.com/jackwener/opencli"
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "opencli-extension",
|
||||
"version": "1.5.1",
|
||||
"version": "1.5.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "opencli-extension",
|
||||
"version": "1.5.1",
|
||||
"version": "1.5.4",
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.0.287",
|
||||
"typescript": "^5.7.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opencli-extension",
|
||||
"version": "1.5.1",
|
||||
"version": "1.5.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { Command, Result } from './protocol';
|
||||
import { DAEMON_WS_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol';
|
||||
import { DAEMON_WS_URL, DAEMON_PING_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol';
|
||||
import * as executor from './cdp';
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
@@ -34,9 +34,23 @@ console.error = (...args: unknown[]) => { _origError(...args); forwardLog('error
|
||||
|
||||
// ─── WebSocket connection ────────────────────────────────────────────
|
||||
|
||||
function connect(): void {
|
||||
/**
|
||||
* Probe the daemon via its /ping HTTP endpoint before attempting a WebSocket
|
||||
* connection. fetch() failures are silently catchable; new WebSocket() is not
|
||||
* — Chrome logs ERR_CONNECTION_REFUSED to the extension error page before any
|
||||
* JS handler can intercept it. By keeping the probe inside connect() every
|
||||
* call site remains unchanged and the guard can never be accidentally skipped.
|
||||
*/
|
||||
async function connect(): Promise<void> {
|
||||
if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1000) });
|
||||
if (!res.ok) return; // unexpected response — not our daemon
|
||||
} catch {
|
||||
return; // daemon not running — skip WebSocket to avoid console noise
|
||||
}
|
||||
|
||||
try {
|
||||
ws = new WebSocket(DAEMON_WS_URL);
|
||||
} catch {
|
||||
@@ -90,7 +104,7 @@ function scheduleReconnect(): void {
|
||||
const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY);
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
void connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
@@ -146,13 +160,14 @@ async function getAutomationWindow(workspace: string): Promise<number> {
|
||||
|
||||
// Create a new window with a data: URI that New Tab Override extensions cannot intercept.
|
||||
// Using about:blank would be hijacked by extensions like "New Tab Override".
|
||||
// Note: Do NOT set `state` parameter here. Chrome 146+ rejects 'normal' as an invalid
|
||||
// state value for windows.create(). The window defaults to 'normal' state anyway.
|
||||
const win = await chrome.windows.create({
|
||||
url: BLANK_PAGE,
|
||||
focused: false,
|
||||
width: 1280,
|
||||
height: 900,
|
||||
type: 'normal',
|
||||
state: 'minimized',
|
||||
});
|
||||
const session: AutomationSession = {
|
||||
windowId: win.id!,
|
||||
@@ -187,7 +202,7 @@ function initialize(): void {
|
||||
initialized = true;
|
||||
chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); // ~24 seconds
|
||||
executor.registerListeners();
|
||||
connect();
|
||||
void connect();
|
||||
console.log('[opencli] OpenCLI extension initialized');
|
||||
}
|
||||
|
||||
@@ -200,7 +215,7 @@ chrome.runtime.onStartup.addListener(() => {
|
||||
});
|
||||
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === 'keepalive') connect();
|
||||
if (alarm.name === 'keepalive') void connect();
|
||||
});
|
||||
|
||||
// ─── Popup status API ───────────────────────────────────────────────
|
||||
|
||||
@@ -49,7 +49,8 @@ export interface Result {
|
||||
export const DAEMON_PORT = 19825;
|
||||
export const DAEMON_HOST = 'localhost';
|
||||
export const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
|
||||
export const DAEMON_HTTP_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
||||
/** Lightweight health-check endpoint — probed before each WebSocket attempt. */
|
||||
export const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`;
|
||||
|
||||
/** Base reconnect delay for extension WebSocket (ms) */
|
||||
export const WS_RECONNECT_BASE_DELAY = 2000;
|
||||
|
||||
Generated
+12
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@jackwener/opencli",
|
||||
"version": "1.5.1",
|
||||
"version": "1.5.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@jackwener/opencli",
|
||||
"version": "1.5.1",
|
||||
"version": "1.5.4",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -15,6 +15,7 @@
|
||||
"commander": "^14.0.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"turndown": "^7.2.2",
|
||||
"undici": "^7.24.6",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -3514,6 +3515,15 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz",
|
||||
"integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jackwener/opencli",
|
||||
"version": "1.5.1",
|
||||
"version": "1.5.5",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
@@ -58,6 +58,7 @@
|
||||
"commander": "^14.0.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"turndown": "^7.2.2",
|
||||
"undici": "^7.24.6",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+19
-9
@@ -1,10 +1,14 @@
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { BrowserBridge, __test__, generateStealthJs } from './browser/index.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { BrowserBridge, generateStealthJs } from './browser/index.js';
|
||||
import { extractTabEntries, diffTabIndexes, appendLimited } from './browser/tabs.js';
|
||||
import { withTimeoutMs } from './runtime.js';
|
||||
import { __test__ as cdpTest } from './browser/cdp.js';
|
||||
import { isRetryableSettleError } from './browser/page.js';
|
||||
import * as daemonClient from './browser/daemon-client.js';
|
||||
|
||||
describe('browser helpers', () => {
|
||||
it('extracts tab entries from string snapshots', () => {
|
||||
const entries = __test__.extractTabEntries('Tab 0 https://example.com\nTab 1 Chrome Extension');
|
||||
const entries = extractTabEntries('Tab 0 https://example.com\nTab 1 Chrome Extension');
|
||||
|
||||
expect(entries).toEqual([
|
||||
{ index: 0, identity: 'https://example.com' },
|
||||
@@ -13,7 +17,7 @@ describe('browser helpers', () => {
|
||||
});
|
||||
|
||||
it('extracts tab entries from MCP markdown format', () => {
|
||||
const entries = __test__.extractTabEntries(
|
||||
const entries = extractTabEntries(
|
||||
'- 0: (current) [Playwright MCP extension](chrome-extension://abc/connect.html)\n- 1: [知乎 - 首页](https://www.zhihu.com/)'
|
||||
);
|
||||
|
||||
@@ -24,7 +28,7 @@ describe('browser helpers', () => {
|
||||
});
|
||||
|
||||
it('closes only tabs that were opened during the session', () => {
|
||||
const tabsToClose = __test__.diffTabIndexes(
|
||||
const tabsToClose = diffTabIndexes(
|
||||
['https://example.com', 'Chrome Extension'],
|
||||
[
|
||||
{ index: 0, identity: 'https://example.com' },
|
||||
@@ -38,15 +42,21 @@ describe('browser helpers', () => {
|
||||
});
|
||||
|
||||
it('keeps only the tail of stderr buffers', () => {
|
||||
expect(__test__.appendLimited('12345', '67890', 8)).toBe('34567890');
|
||||
expect(appendLimited('12345', '67890', 8)).toBe('34567890');
|
||||
});
|
||||
|
||||
it('times out slow promises', async () => {
|
||||
await expect(__test__.withTimeoutMs(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
|
||||
await expect(withTimeoutMs(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
|
||||
});
|
||||
|
||||
it('retries settle only for target-invalidated errors', () => {
|
||||
expect(isRetryableSettleError(new Error('{"code":-32000,"message":"Inspected target navigated or closed"}'))).toBe(true);
|
||||
expect(isRetryableSettleError(new Error('attach failed: target no longer exists'))).toBe(false);
|
||||
expect(isRetryableSettleError(new Error('malformed exec payload'))).toBe(false);
|
||||
});
|
||||
|
||||
it('prefers the real Electron app target over DevTools and blank pages', () => {
|
||||
const target = __test__.selectCDPTarget([
|
||||
const target = cdpTest.selectCDPTarget([
|
||||
{
|
||||
type: 'page',
|
||||
title: 'DevTools - localhost:9224',
|
||||
@@ -73,7 +83,7 @@ describe('browser helpers', () => {
|
||||
it('honors OPENCLI_CDP_TARGET when multiple inspectable targets exist', () => {
|
||||
vi.stubEnv('OPENCLI_CDP_TARGET', 'codex');
|
||||
|
||||
const target = __test__.selectCDPTarget([
|
||||
const target = cdpTest.selectCDPTarget([
|
||||
{
|
||||
type: 'app',
|
||||
title: 'Cursor',
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
autoScrollJs,
|
||||
networkRequestsJs,
|
||||
waitForDomStableJs,
|
||||
waitForCaptureJs,
|
||||
waitForSelectorJs,
|
||||
} from './dom-helpers.js';
|
||||
import { isRecord, saveBase64ToFile } from '../utils.js';
|
||||
|
||||
@@ -247,6 +249,15 @@ class CDPPage implements IPage {
|
||||
|
||||
async wait(options: number | WaitOptions): Promise<void> {
|
||||
if (typeof options === 'number') {
|
||||
if (options >= 1) {
|
||||
try {
|
||||
const maxMs = options * 1000;
|
||||
await this.evaluate(waitForDomStableJs(maxMs, Math.min(500, maxMs)));
|
||||
return;
|
||||
} catch {
|
||||
// Fallback: fixed sleep
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, options * 1000));
|
||||
return;
|
||||
}
|
||||
@@ -255,6 +266,11 @@ class CDPPage implements IPage {
|
||||
await new Promise((resolve) => setTimeout(resolve, waitTime * 1000));
|
||||
return;
|
||||
}
|
||||
if (options.selector) {
|
||||
const timeout = (options.timeout ?? 10) * 1000;
|
||||
await this.evaluate(waitForSelectorJs(options.selector, timeout));
|
||||
return;
|
||||
}
|
||||
if (options.text) {
|
||||
const timeout = (options.timeout ?? 30) * 1000;
|
||||
await this.evaluate(waitForTextJs(options.text, timeout));
|
||||
@@ -326,6 +342,11 @@ class CDPPage implements IPage {
|
||||
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
async waitForCapture(timeout: number = 10): Promise<void> {
|
||||
const maxMs = timeout * 1000;
|
||||
await this.evaluate(waitForCaptureJs(maxMs));
|
||||
}
|
||||
}
|
||||
|
||||
function isCookie(value: unknown): value is BrowserCookie {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
||||
import type { BrowserSessionInfo } from '../types.js';
|
||||
import { sleep } from '../utils.js';
|
||||
|
||||
const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
|
||||
const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
|
||||
@@ -114,7 +115,7 @@ export async function sendCommand(
|
||||
|| errMsg.includes('no longer exists');
|
||||
if (isTransient && attempt < maxRetries) {
|
||||
// Longer delay for extension recovery (service worker restart)
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
await sleep(1500);
|
||||
continue;
|
||||
}
|
||||
throw new Error(result.error ?? 'Daemon command failed');
|
||||
@@ -125,7 +126,7 @@ export async function sendCommand(
|
||||
const isRetryable = err instanceof TypeError // fetch network error
|
||||
|| (err instanceof Error && err.name === 'AbortError');
|
||||
if (isRetryable && attempt < maxRetries) {
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
await sleep(500);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { waitForCaptureJs, waitForSelectorJs } from './dom-helpers.js';
|
||||
|
||||
describe('waitForCaptureJs', () => {
|
||||
it('returns a non-empty string', () => {
|
||||
const code = waitForCaptureJs(1000);
|
||||
expect(typeof code).toBe('string');
|
||||
expect(code.length).toBeGreaterThan(0);
|
||||
expect(code).toContain('__opencli_xhr');
|
||||
expect(code).toContain('resolve');
|
||||
expect(code).toContain('reject');
|
||||
});
|
||||
|
||||
it('resolves "captured" when __opencli_xhr is populated before deadline', async () => {
|
||||
const g = globalThis as any;
|
||||
g.__opencli_xhr = [];
|
||||
g.window = g; // stub window for Node eval
|
||||
const code = waitForCaptureJs(1000);
|
||||
const promise = eval(code) as Promise<string>;
|
||||
g.__opencli_xhr.push({ data: 'test' });
|
||||
await expect(promise).resolves.toBe('captured');
|
||||
delete g.__opencli_xhr;
|
||||
delete g.window;
|
||||
});
|
||||
|
||||
it('rejects when __opencli_xhr stays empty past deadline', async () => {
|
||||
const g = globalThis as any;
|
||||
g.__opencli_xhr = [];
|
||||
g.window = g;
|
||||
const code = waitForCaptureJs(50); // 50ms timeout
|
||||
const promise = eval(code) as Promise<string>;
|
||||
await expect(promise).rejects.toThrow('No network capture within 0.05s');
|
||||
delete g.__opencli_xhr;
|
||||
delete g.window;
|
||||
});
|
||||
|
||||
it('resolves immediately when __opencli_xhr already has data', async () => {
|
||||
const g = globalThis as any;
|
||||
g.__opencli_xhr = [{ data: 'already here' }];
|
||||
g.window = g;
|
||||
const code = waitForCaptureJs(1000);
|
||||
await expect(eval(code) as Promise<string>).resolves.toBe('captured');
|
||||
delete g.__opencli_xhr;
|
||||
delete g.window;
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitForSelectorJs', () => {
|
||||
it('returns a non-empty string', () => {
|
||||
const code = waitForSelectorJs('#app', 1000);
|
||||
expect(typeof code).toBe('string');
|
||||
expect(code).toContain('#app');
|
||||
expect(code).toContain('querySelector');
|
||||
expect(code).toContain('MutationObserver');
|
||||
});
|
||||
|
||||
it('resolves "found" immediately when selector already present', async () => {
|
||||
const g = globalThis as any;
|
||||
const fakeEl = { tagName: 'DIV' };
|
||||
g.document = { querySelector: (_: string) => fakeEl };
|
||||
const code = waitForSelectorJs('[data-testid="primaryColumn"]', 1000);
|
||||
await expect(eval(code) as Promise<string>).resolves.toBe('found');
|
||||
delete g.document;
|
||||
});
|
||||
|
||||
it('resolves "found" when selector appears after DOM mutation', async () => {
|
||||
const g = globalThis as any;
|
||||
let mutationCallback!: () => void;
|
||||
g.MutationObserver = class {
|
||||
constructor(cb: () => void) { mutationCallback = cb; }
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
};
|
||||
let calls = 0;
|
||||
g.document = {
|
||||
querySelector: (_: string) => (calls++ > 0 ? { tagName: 'DIV' } : null),
|
||||
body: {},
|
||||
};
|
||||
const code = waitForSelectorJs('#app', 1000);
|
||||
const promise = eval(code) as Promise<string>;
|
||||
mutationCallback(); // simulate DOM mutation
|
||||
await expect(promise).resolves.toBe('found');
|
||||
delete g.document;
|
||||
delete g.MutationObserver;
|
||||
});
|
||||
|
||||
it('rejects when selector never appears within timeout', async () => {
|
||||
const g = globalThis as any;
|
||||
g.MutationObserver = class {
|
||||
constructor(_cb: () => void) {}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
};
|
||||
g.document = { querySelector: (_: string) => null, body: {} };
|
||||
const code = waitForSelectorJs('#missing', 50);
|
||||
await expect(eval(code) as Promise<string>).rejects.toThrow('Selector not found: #missing');
|
||||
delete g.document;
|
||||
delete g.MutationObserver;
|
||||
});
|
||||
});
|
||||
@@ -179,3 +179,47 @@ export function waitForDomStableJs(maxMs: number, quietMs: number): string {
|
||||
})
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JS to wait until window.__opencli_xhr has ≥1 captured response.
|
||||
* Polls every 100ms. Resolves 'captured' on success; rejects after maxMs.
|
||||
* Used after installInterceptor() + goto() instead of a fixed sleep.
|
||||
*/
|
||||
export function waitForCaptureJs(maxMs: number): string {
|
||||
return `
|
||||
new Promise((resolve, reject) => {
|
||||
const deadline = Date.now() + ${maxMs};
|
||||
const check = () => {
|
||||
if ((window.__opencli_xhr || []).length > 0) return resolve('captured');
|
||||
if (Date.now() > deadline) return reject(new Error('No network capture within ${maxMs / 1000}s'));
|
||||
setTimeout(check, 100);
|
||||
};
|
||||
check();
|
||||
})
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JS to wait until document.querySelector(selector) returns a match.
|
||||
* Uses MutationObserver for near-instant resolution; falls back to reject after timeoutMs.
|
||||
*/
|
||||
export function waitForSelectorJs(selector: string, timeoutMs: number): string {
|
||||
return `
|
||||
new Promise((resolve, reject) => {
|
||||
const sel = ${JSON.stringify(selector)};
|
||||
if (document.querySelector(sel)) return resolve('found');
|
||||
const cap = setTimeout(() => {
|
||||
obs.disconnect();
|
||||
reject(new Error('Selector not found: ' + sel));
|
||||
}, ${timeoutMs});
|
||||
const obs = new MutationObserver(() => {
|
||||
if (document.querySelector(sel)) {
|
||||
clearTimeout(cap);
|
||||
obs.disconnect();
|
||||
resolve('found');
|
||||
}
|
||||
});
|
||||
obs.observe(document.body || document.documentElement, { childList: true, subtree: true });
|
||||
})
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -12,16 +12,3 @@ export { isDaemonRunning } from './daemon-client.js';
|
||||
export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
|
||||
export { generateStealthJs } from './stealth.js';
|
||||
export type { DomSnapshotOptions } from './dom-snapshot.js';
|
||||
|
||||
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
|
||||
import { __test__ as cdpTest } from './cdp.js';
|
||||
import { withTimeoutMs } from '../runtime.js';
|
||||
|
||||
export const __test__ = {
|
||||
extractTabEntries,
|
||||
diffTabIndexes,
|
||||
appendLimited,
|
||||
withTimeoutMs,
|
||||
selectCDPTarget: cdpTest.selectCDPTarget,
|
||||
scoreCDPTarget: cdpTest.scoreCDPTarget,
|
||||
};
|
||||
|
||||
+4
-3
@@ -96,10 +96,11 @@ export class BrowserBridge implements IBrowserFactory {
|
||||
});
|
||||
this._daemonProc.unref();
|
||||
|
||||
// Wait for daemon to be ready AND extension to connect
|
||||
// Wait for daemon to be ready AND extension to connect (exponential backoff)
|
||||
const backoffs = [50, 100, 200, 400, 800, 1500, 3000];
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
for (let i = 0; Date.now() < deadline; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, backoffs[Math.min(i, backoffs.length - 1)]));
|
||||
if (await isExtensionConnected()) return;
|
||||
}
|
||||
|
||||
|
||||
+41
-2
@@ -22,12 +22,20 @@ import {
|
||||
typeTextJs,
|
||||
pressKeyJs,
|
||||
waitForTextJs,
|
||||
waitForCaptureJs,
|
||||
waitForSelectorJs,
|
||||
scrollJs,
|
||||
autoScrollJs,
|
||||
networkRequestsJs,
|
||||
waitForDomStableJs,
|
||||
} from './dom-helpers.js';
|
||||
|
||||
export function isRetryableSettleError(err: unknown): boolean {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return message.includes('Inspected target navigated or closed')
|
||||
|| (message.includes('-32000') && message.toLowerCase().includes('target'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Page — implements IPage by talking to the daemon via HTTP.
|
||||
*/
|
||||
@@ -75,10 +83,27 @@ export class Page implements IPage {
|
||||
// settleMs is now a timeout cap (default 1000ms), not a fixed wait.
|
||||
if (options?.waitUntil !== 'none') {
|
||||
const maxMs = options?.settleMs ?? 1000;
|
||||
await sendCommand('exec', {
|
||||
const settleOpts = {
|
||||
code: waitForDomStableJs(maxMs, Math.min(500, maxMs)),
|
||||
...this._cmdOpts(),
|
||||
});
|
||||
};
|
||||
try {
|
||||
await sendCommand('exec', settleOpts);
|
||||
} catch (err) {
|
||||
if (!isRetryableSettleError(err)) throw err;
|
||||
// SPA client-side redirects can invalidate the CDP target after
|
||||
// chrome.tabs reports 'complete'. Wait briefly for the new document
|
||||
// to load, then retry the settle probe once.
|
||||
try {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
await sendCommand('exec', settleOpts);
|
||||
} catch (retryErr) {
|
||||
if (!isRetryableSettleError(retryErr)) throw retryErr;
|
||||
// Retry also failed — give up silently. Settle is best-effort
|
||||
// after successful navigation; the next real command will surface
|
||||
// any persistent target error immediately.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,6 +238,12 @@ export class Page implements IPage {
|
||||
await new Promise(resolve => setTimeout(resolve, options.time! * 1000));
|
||||
return;
|
||||
}
|
||||
if (options.selector) {
|
||||
const timeout = (options.timeout ?? 10) * 1000;
|
||||
const code = waitForSelectorJs(options.selector, timeout);
|
||||
await sendCommand('exec', { code, ...this._cmdOpts() });
|
||||
return;
|
||||
}
|
||||
if (options.text) {
|
||||
const timeout = (options.timeout ?? 30) * 1000;
|
||||
const code = waitForTextJs(options.text, timeout);
|
||||
@@ -307,6 +338,14 @@ export class Page implements IPage {
|
||||
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
async waitForCapture(timeout: number = 10): Promise<void> {
|
||||
const maxMs = timeout * 1000;
|
||||
await sendCommand('exec', {
|
||||
code: waitForCaptureJs(maxMs),
|
||||
...this._cmdOpts(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// (End of file)
|
||||
|
||||
+119
-90
@@ -2,69 +2,8 @@ import { afterEach, describe, expect, it } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { parseTsArgsBlock, scanTs, shouldReplaceManifestEntry } from './build-manifest.js';
|
||||
|
||||
describe('parseTsArgsBlock', () => {
|
||||
it('keeps args with nested choices arrays', () => {
|
||||
const args = parseTsArgsBlock(`
|
||||
{
|
||||
name: 'period',
|
||||
type: 'string',
|
||||
default: 'seven',
|
||||
help: 'Stats period: seven or thirty',
|
||||
choices: ['seven', 'thirty'],
|
||||
},
|
||||
`);
|
||||
|
||||
expect(args).toEqual([
|
||||
{
|
||||
name: 'period',
|
||||
type: 'string',
|
||||
default: 'seven',
|
||||
required: false,
|
||||
positional: undefined,
|
||||
help: 'Stats period: seven or thirty',
|
||||
choices: ['seven', 'thirty'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps hyphenated arg names from TS adapters', () => {
|
||||
const args = parseTsArgsBlock(`
|
||||
{
|
||||
name: 'tweet-url',
|
||||
help: 'Single tweet URL to download',
|
||||
},
|
||||
{
|
||||
name: 'download-images',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
help: 'Download images locally',
|
||||
},
|
||||
`);
|
||||
|
||||
expect(args).toEqual([
|
||||
{
|
||||
name: 'tweet-url',
|
||||
type: 'str',
|
||||
default: undefined,
|
||||
required: false,
|
||||
positional: undefined,
|
||||
help: 'Single tweet URL to download',
|
||||
choices: undefined,
|
||||
},
|
||||
{
|
||||
name: 'download-images',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
required: false,
|
||||
positional: undefined,
|
||||
help: 'Download images locally',
|
||||
choices: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
import { cli, getRegistry, Strategy } from './registry.js';
|
||||
import { loadTsManifestEntries, shouldReplaceManifestEntry } from './build-manifest.js';
|
||||
|
||||
describe('manifest helper rules', () => {
|
||||
const tempDirs: string[] = [];
|
||||
@@ -127,43 +66,133 @@ describe('manifest helper rules', () => {
|
||||
const file = path.join(dir, 'utils.ts');
|
||||
fs.writeFileSync(file, `export function helper() { return 'noop'; }`);
|
||||
|
||||
expect(scanTs(file, 'demo')).toBeNull();
|
||||
return expect(loadTsManifestEntries(file, 'demo', async () => ({}))).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps literal domain and navigateBefore for TS adapters', () => {
|
||||
const file = path.join(process.cwd(), 'src', 'clis', 'xueqiu', 'fund-holdings.ts');
|
||||
const entry = scanTs(file, 'xueqiu');
|
||||
|
||||
expect(entry).toMatchObject({
|
||||
site: 'xueqiu',
|
||||
name: 'fund-holdings',
|
||||
domain: 'danjuanfunds.com',
|
||||
navigateBefore: 'https://danjuanfunds.com/my-money',
|
||||
type: 'ts',
|
||||
modulePath: 'xueqiu/fund-holdings.js',
|
||||
});
|
||||
});
|
||||
|
||||
it('captures deprecated metadata for TS adapters', () => {
|
||||
it('builds TS manifest entries from exported runtime commands', async () => {
|
||||
const site = `manifest-hydrate-${Date.now()}`;
|
||||
const key = `${site}/dynamic`;
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-'));
|
||||
tempDirs.push(dir);
|
||||
const file = path.join(dir, 'legacy.ts');
|
||||
fs.writeFileSync(file, `
|
||||
import { cli } from '../../registry.js';
|
||||
const file = path.join(dir, `${site}.ts`);
|
||||
fs.writeFileSync(file, `export const command = cli({ site: '${site}', name: 'dynamic' });`);
|
||||
|
||||
const entries = await loadTsManifestEntries(file, site, async () => ({
|
||||
command: cli({
|
||||
site,
|
||||
name: 'dynamic',
|
||||
description: 'dynamic command',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{
|
||||
name: 'model',
|
||||
required: true,
|
||||
positional: true,
|
||||
help: 'Choose a model',
|
||||
choices: ['auto', 'thinking'],
|
||||
default: '30',
|
||||
},
|
||||
],
|
||||
domain: 'localhost',
|
||||
navigateBefore: 'https://example.com/session',
|
||||
deprecated: 'legacy command',
|
||||
replacedBy: 'opencli demo new',
|
||||
}),
|
||||
}));
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
site,
|
||||
name: 'dynamic',
|
||||
description: 'dynamic command',
|
||||
domain: 'localhost',
|
||||
strategy: 'public',
|
||||
browser: false,
|
||||
args: [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'str',
|
||||
required: true,
|
||||
positional: true,
|
||||
help: 'Choose a model',
|
||||
choices: ['auto', 'thinking'],
|
||||
default: '30',
|
||||
},
|
||||
],
|
||||
type: 'ts',
|
||||
modulePath: `${site}/${site}.js`,
|
||||
navigateBefore: 'https://example.com/session',
|
||||
deprecated: 'legacy command',
|
||||
replacedBy: 'opencli demo new',
|
||||
},
|
||||
]);
|
||||
|
||||
getRegistry().delete(key);
|
||||
});
|
||||
|
||||
it('falls back to registry delta for side-effect-only cli modules', async () => {
|
||||
const site = `manifest-side-effect-${Date.now()}`;
|
||||
const key = `${site}/legacy`;
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-'));
|
||||
tempDirs.push(dir);
|
||||
const file = path.join(dir, `${site}.ts`);
|
||||
fs.writeFileSync(file, `cli({ site: '${site}', name: 'legacy' });`);
|
||||
|
||||
const entries = await loadTsManifestEntries(file, site, async () => {
|
||||
cli({
|
||||
site: 'demo',
|
||||
site,
|
||||
name: 'legacy',
|
||||
description: 'legacy command',
|
||||
deprecated: 'legacy is deprecated',
|
||||
replacedBy: 'opencli demo new',
|
||||
});
|
||||
`);
|
||||
|
||||
expect(scanTs(file, 'demo')).toMatchObject({
|
||||
site: 'demo',
|
||||
name: 'legacy',
|
||||
deprecated: 'legacy is deprecated',
|
||||
replacedBy: 'opencli demo new',
|
||||
return {};
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
site,
|
||||
name: 'legacy',
|
||||
description: 'legacy command',
|
||||
strategy: 'cookie',
|
||||
browser: true,
|
||||
args: [],
|
||||
type: 'ts',
|
||||
modulePath: `${site}/${site}.js`,
|
||||
deprecated: 'legacy is deprecated',
|
||||
replacedBy: 'opencli demo new',
|
||||
},
|
||||
]);
|
||||
|
||||
getRegistry().delete(key);
|
||||
});
|
||||
|
||||
it('keeps every command a module exports instead of guessing by site', async () => {
|
||||
const site = `manifest-multi-${Date.now()}`;
|
||||
const screenKey = `${site}/screen`;
|
||||
const statusKey = `${site}/status`;
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-'));
|
||||
tempDirs.push(dir);
|
||||
const file = path.join(dir, `${site}.ts`);
|
||||
fs.writeFileSync(file, `export const screen = cli({ site: '${site}', name: 'screen' });`);
|
||||
|
||||
const entries = await loadTsManifestEntries(file, site, async () => ({
|
||||
screen: cli({
|
||||
site,
|
||||
name: 'screen',
|
||||
description: 'capture screen',
|
||||
}),
|
||||
status: cli({
|
||||
site,
|
||||
name: 'status',
|
||||
description: 'show status',
|
||||
}),
|
||||
}));
|
||||
|
||||
expect(entries.map(entry => entry.name)).toEqual(['screen', 'status']);
|
||||
|
||||
getRegistry().delete(screenKey);
|
||||
getRegistry().delete(statusKey);
|
||||
});
|
||||
});
|
||||
|
||||
+76
-175
@@ -14,6 +14,7 @@ import * as path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import yaml from 'js-yaml';
|
||||
import { getErrorMessage } from './errors.js';
|
||||
import { fullName, getRegistry, type CliCommand } from './registry.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CLIS_DIR = path.resolve(__dirname, 'clis');
|
||||
@@ -52,116 +53,50 @@ import { type YamlCliDefinition, parseYamlArgs } from './yaml-schema.js';
|
||||
|
||||
import { isRecord } from './utils.js';
|
||||
|
||||
const CLI_MODULE_PATTERN = /\bcli\s*\(/;
|
||||
|
||||
function extractBalancedBlock(
|
||||
source: string,
|
||||
startIndex: number,
|
||||
openChar: string,
|
||||
closeChar: string,
|
||||
): string | null {
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
let escaped = false;
|
||||
|
||||
for (let i = startIndex; i < source.length; i++) {
|
||||
const ch = source[i];
|
||||
|
||||
if (quote) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"' || ch === '\'' || ch === '`') {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === openChar) {
|
||||
depth++;
|
||||
} else if (ch === closeChar) {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
return source.slice(startIndex + 1, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
function toManifestArgs(args: CliCommand['args']): ManifestEntry['args'] {
|
||||
return args.map(arg => ({
|
||||
name: arg.name,
|
||||
type: arg.type ?? 'str',
|
||||
default: arg.default,
|
||||
required: !!arg.required,
|
||||
positional: arg.positional || undefined,
|
||||
help: arg.help ?? '',
|
||||
choices: arg.choices,
|
||||
}));
|
||||
}
|
||||
|
||||
function extractTsArgsBlock(source: string): string | null {
|
||||
const argsMatch = source.match(/args\s*:/);
|
||||
if (!argsMatch || argsMatch.index === undefined) return null;
|
||||
|
||||
const bracketIndex = source.indexOf('[', argsMatch.index);
|
||||
if (bracketIndex === -1) return null;
|
||||
|
||||
return extractBalancedBlock(source, bracketIndex, '[', ']');
|
||||
function toTsModulePath(filePath: string, site: string): string {
|
||||
const baseName = path.basename(filePath, path.extname(filePath));
|
||||
return `${site}/${baseName}.js`;
|
||||
}
|
||||
|
||||
function parseInlineChoices(body: string): string[] | undefined {
|
||||
const choicesMatch = body.match(/choices\s*:\s*\[([^\]]*)\]/);
|
||||
if (!choicesMatch) return undefined;
|
||||
|
||||
const values = choicesMatch[1]
|
||||
.split(',')
|
||||
.map(s => s.trim().replace(/^['"`]|['"`]$/g, ''))
|
||||
.filter(Boolean);
|
||||
|
||||
return values.length > 0 ? values : undefined;
|
||||
function isCliCommandValue(value: unknown, site: string): value is CliCommand {
|
||||
return isRecord(value)
|
||||
&& typeof value.site === 'string'
|
||||
&& value.site === site
|
||||
&& typeof value.name === 'string'
|
||||
&& Array.isArray(value.args);
|
||||
}
|
||||
|
||||
export function parseTsArgsBlock(argsBlock: string): ManifestEntry['args'] {
|
||||
const args: ManifestEntry['args'] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < argsBlock.length) {
|
||||
const nameMatch = argsBlock.slice(cursor).match(/\{\s*name\s*:\s*['"`]([^'"`]+)['"`]/);
|
||||
if (!nameMatch || nameMatch.index === undefined) break;
|
||||
|
||||
const objectStart = cursor + nameMatch.index;
|
||||
const body = extractBalancedBlock(argsBlock, objectStart, '{', '}');
|
||||
if (body == null) break;
|
||||
|
||||
const typeMatch = body.match(/type\s*:\s*['"`](\w+)['"`]/);
|
||||
const defaultMatch = body.match(/default\s*:\s*([^,}]+)/);
|
||||
const requiredMatch = body.match(/required\s*:\s*(true|false)/);
|
||||
const helpMatch = body.match(/help\s*:\s*['"`]([^'"`]*)['"`]/);
|
||||
const positionalMatch = body.match(/positional\s*:\s*(true|false)/);
|
||||
|
||||
let defaultVal: unknown = undefined;
|
||||
if (defaultMatch) {
|
||||
const raw = defaultMatch[1].trim();
|
||||
if (raw === 'true') defaultVal = true;
|
||||
else if (raw === 'false') defaultVal = false;
|
||||
else if (/^\d+$/.test(raw)) defaultVal = parseInt(raw, 10);
|
||||
else if (/^\d+\.\d+$/.test(raw)) defaultVal = parseFloat(raw);
|
||||
else defaultVal = raw.replace(/^['"`]|['"`]$/g, '');
|
||||
}
|
||||
|
||||
args.push({
|
||||
name: nameMatch[1],
|
||||
type: typeMatch?.[1] ?? 'str',
|
||||
default: defaultVal,
|
||||
required: requiredMatch?.[1] === 'true',
|
||||
positional: positionalMatch?.[1] === 'true' || undefined,
|
||||
help: helpMatch?.[1] ?? '',
|
||||
choices: parseInlineChoices(body),
|
||||
});
|
||||
|
||||
cursor = objectStart + body.length;
|
||||
if (cursor <= objectStart) break; // safety: prevent infinite loop
|
||||
}
|
||||
|
||||
return args;
|
||||
function toManifestEntry(cmd: CliCommand, modulePath: string): ManifestEntry {
|
||||
return {
|
||||
site: cmd.site,
|
||||
name: cmd.name,
|
||||
description: cmd.description ?? '',
|
||||
domain: cmd.domain,
|
||||
strategy: (cmd.strategy ?? 'public').toString().toLowerCase(),
|
||||
browser: cmd.browser ?? true,
|
||||
args: toManifestArgs(cmd.args),
|
||||
columns: cmd.columns,
|
||||
timeout: cmd.timeoutSeconds,
|
||||
deprecated: cmd.deprecated,
|
||||
replacedBy: cmd.replacedBy,
|
||||
type: 'ts',
|
||||
modulePath,
|
||||
navigateBefore: cmd.navigateBefore,
|
||||
};
|
||||
}
|
||||
|
||||
function scanYaml(filePath: string, site: string): ManifestEntry | null {
|
||||
@@ -199,83 +134,49 @@ function scanYaml(filePath: string, site: string): ManifestEntry | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function scanTs(filePath: string, site: string): ManifestEntry | null {
|
||||
// TS adapters self-register via cli() at import time.
|
||||
// We statically parse the source to extract metadata for the manifest stub.
|
||||
const baseName = path.basename(filePath, path.extname(filePath));
|
||||
const relativePath = `${site}/${baseName}.js`;
|
||||
|
||||
export async function loadTsManifestEntries(
|
||||
filePath: string,
|
||||
site: string,
|
||||
importer: (moduleHref: string) => Promise<unknown> = moduleHref => import(moduleHref),
|
||||
): Promise<ManifestEntry[]> {
|
||||
try {
|
||||
const src = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
// Helper/test modules should not appear as CLI commands in the manifest.
|
||||
if (!/\bcli\s*\(/.test(src)) return null;
|
||||
if (!CLI_MODULE_PATTERN.test(src)) return [];
|
||||
|
||||
const entry: ManifestEntry = {
|
||||
site,
|
||||
name: baseName,
|
||||
description: '',
|
||||
strategy: 'cookie',
|
||||
browser: true,
|
||||
args: [],
|
||||
type: 'ts',
|
||||
modulePath: relativePath,
|
||||
};
|
||||
const modulePath = toTsModulePath(filePath, site);
|
||||
const registry = getRegistry();
|
||||
const before = new Map(registry.entries());
|
||||
const mod = await importer(pathToFileURL(filePath).href);
|
||||
|
||||
// Extract description
|
||||
const descMatch = src.match(/description\s*:\s*['"`]([^'"`]*)['"`]/);
|
||||
if (descMatch) entry.description = descMatch[1];
|
||||
const exportedCommands = Object.values(isRecord(mod) ? mod : {})
|
||||
.filter(value => isCliCommandValue(value, site));
|
||||
|
||||
// Extract domain
|
||||
const domainMatch = src.match(/domain\s*:\s*['"`]([^'"`]*)['"`]/);
|
||||
if (domainMatch) entry.domain = domainMatch[1];
|
||||
const runtimeCommands = exportedCommands.length > 0
|
||||
? exportedCommands
|
||||
: [...registry.entries()]
|
||||
.filter(([key, cmd]) => {
|
||||
if (cmd.site !== site) return false;
|
||||
const previous = before.get(key);
|
||||
return !previous || previous !== cmd;
|
||||
})
|
||||
.map(([, cmd]) => cmd);
|
||||
|
||||
// Extract strategy
|
||||
const stratMatch = src.match(/strategy\s*:\s*Strategy\.(\w+)/);
|
||||
if (stratMatch) entry.strategy = stratMatch[1].toLowerCase();
|
||||
|
||||
// Extract browser: false (some adapters bypass browser entirely)
|
||||
const browserMatch = src.match(/browser\s*:\s*(true|false)/);
|
||||
if (browserMatch) entry.browser = browserMatch[1] === 'true';
|
||||
else entry.browser = entry.strategy !== 'public';
|
||||
|
||||
// Extract columns
|
||||
const colMatch = src.match(/columns\s*:\s*\[([^\]]*)\]/);
|
||||
if (colMatch) {
|
||||
entry.columns = colMatch[1].split(',').map(s => s.trim().replace(/^['"`]|['"`]$/g, '')).filter(Boolean);
|
||||
}
|
||||
|
||||
// Extract args array items: { name: '...', ... }
|
||||
const argsBlock = extractTsArgsBlock(src);
|
||||
if (argsBlock) {
|
||||
entry.args = parseTsArgsBlock(argsBlock);
|
||||
}
|
||||
|
||||
// Extract navigateBefore: false / true / 'https://...'
|
||||
const navBoolMatch = src.match(/navigateBefore\s*:\s*(true|false)/);
|
||||
if (navBoolMatch) {
|
||||
entry.navigateBefore = navBoolMatch[1] === 'true';
|
||||
} else {
|
||||
const navStringMatch = src.match(/navigateBefore\s*:\s*['"`]([^'"`]+)['"`]/);
|
||||
if (navStringMatch) entry.navigateBefore = navStringMatch[1];
|
||||
}
|
||||
|
||||
const deprecatedBoolMatch = src.match(/deprecated\s*:\s*(true|false)/);
|
||||
if (deprecatedBoolMatch) {
|
||||
entry.deprecated = deprecatedBoolMatch[1] === 'true';
|
||||
} else {
|
||||
const deprecatedStringMatch = src.match(/deprecated\s*:\s*['"`]([^'"`]+)['"`]/);
|
||||
if (deprecatedStringMatch) entry.deprecated = deprecatedStringMatch[1];
|
||||
}
|
||||
|
||||
const replacedByMatch = src.match(/replacedBy\s*:\s*['"`]([^'"`]+)['"`]/);
|
||||
if (replacedByMatch) entry.replacedBy = replacedByMatch[1];
|
||||
|
||||
return entry;
|
||||
const seen = new Set<string>();
|
||||
return runtimeCommands
|
||||
.filter((cmd) => {
|
||||
const key = fullName(cmd);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(cmd => toManifestEntry(cmd, modulePath));
|
||||
} catch (err) {
|
||||
// If parsing fails, log a warning (matching scanYaml behaviour) and skip the entry.
|
||||
process.stderr.write(`Warning: failed to scan ${filePath}: ${getErrorMessage(err)}\n`);
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,7 +189,7 @@ export function shouldReplaceManifestEntry(current: ManifestEntry, next: Manifes
|
||||
return current.type === 'yaml' && next.type === 'ts';
|
||||
}
|
||||
|
||||
export function buildManifest(): ManifestEntry[] {
|
||||
export async function buildManifest(): Promise<ManifestEntry[]> {
|
||||
const manifest = new Map<string, ManifestEntry>();
|
||||
|
||||
if (fs.existsSync(CLIS_DIR)) {
|
||||
@@ -313,8 +214,8 @@ export function buildManifest(): ManifestEntry[] {
|
||||
(file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts') && file !== 'index.ts') ||
|
||||
(file.endsWith('.js') && !file.endsWith('.d.js') && !file.endsWith('.test.js') && file !== 'index.js')
|
||||
) {
|
||||
const entry = scanTs(filePath, site);
|
||||
if (entry) {
|
||||
const entries = await loadTsManifestEntries(filePath, site);
|
||||
for (const entry of entries) {
|
||||
const key = `${entry.site}/${entry.name}`;
|
||||
const existing = manifest.get(key);
|
||||
if (!existing || shouldReplaceManifestEntry(existing, entry)) {
|
||||
@@ -332,8 +233,8 @@ export function buildManifest(): ManifestEntry[] {
|
||||
return [...manifest.values()];
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const manifest = buildManifest();
|
||||
async function main(): Promise<void> {
|
||||
const manifest = await buildManifest();
|
||||
fs.mkdirSync(path.dirname(OUTPUT), { recursive: true });
|
||||
fs.writeFileSync(OUTPUT, JSON.stringify(manifest, null, 2));
|
||||
|
||||
@@ -367,5 +268,5 @@ function main(): void {
|
||||
|
||||
const entrypoint = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null;
|
||||
if (entrypoint === import.meta.url) {
|
||||
main();
|
||||
void main();
|
||||
}
|
||||
|
||||
+14
-14
@@ -15,7 +15,7 @@ import { PKG_VERSION } from './version.js';
|
||||
import { printCompletionScript } from './completion.js';
|
||||
import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js';
|
||||
import { registerAllCommands } from './commanderAdapter.js';
|
||||
import { getErrorMessage } from './errors.js';
|
||||
import { EXIT_CODES, getErrorMessage } from './errors.js';
|
||||
|
||||
export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
const program = new Command();
|
||||
@@ -120,7 +120,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
const { verifyClis, renderVerifyReport } = await import('./verify.js');
|
||||
const r = await verifyClis({ builtinClis: BUILTIN_CLIS, userClis: USER_CLIS, target, smoke: opts.smoke });
|
||||
console.log(renderVerifyReport(r));
|
||||
process.exitCode = r.ok ? 0 : 1;
|
||||
process.exitCode = r.ok ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR;
|
||||
});
|
||||
|
||||
// ── Built-in: explore / synthesize / generate / cascade ───────────────────
|
||||
@@ -180,7 +180,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
workspace,
|
||||
});
|
||||
console.log(renderGenerateSummary(r));
|
||||
process.exitCode = r.ok ? 0 : 1;
|
||||
process.exitCode = r.ok ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR;
|
||||
});
|
||||
|
||||
// ── Built-in: record ─────────────────────────────────────────────────────
|
||||
@@ -204,7 +204,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
timeoutMs: parseInt(opts.timeout, 10),
|
||||
});
|
||||
console.log(renderRecordSummary(result));
|
||||
process.exitCode = result.candidateCount > 0 ? 0 : 1;
|
||||
process.exitCode = result.candidateCount > 0 ? EXIT_CODES.SUCCESS : EXIT_CODES.EMPTY_RESULT;
|
||||
});
|
||||
|
||||
program
|
||||
@@ -272,7 +272,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
|
||||
process.exitCode = 1;
|
||||
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -287,7 +287,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
console.log(chalk.green(`✅ Plugin "${name}" uninstalled.`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
|
||||
process.exitCode = 1;
|
||||
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -299,12 +299,12 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
.action(async (name: string | undefined, opts: { all?: boolean }) => {
|
||||
if (!name && !opts.all) {
|
||||
console.error(chalk.red('Error: Please specify a plugin name or use the --all flag.'));
|
||||
process.exitCode = 1;
|
||||
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
||||
return;
|
||||
}
|
||||
if (name && opts.all) {
|
||||
console.error(chalk.red('Error: Cannot specify both a plugin name and --all.'));
|
||||
process.exitCode = 1;
|
||||
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -335,7 +335,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
console.log();
|
||||
if (hasErrors) {
|
||||
console.error(chalk.red('Completed with some errors.'));
|
||||
process.exitCode = 1;
|
||||
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
||||
} else {
|
||||
console.log(chalk.green('✅ All plugins updated successfully.'));
|
||||
}
|
||||
@@ -348,7 +348,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
console.log(chalk.green(`✅ Plugin "${name}" updated successfully.`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
|
||||
process.exitCode = 1;
|
||||
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -438,7 +438,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
console.log(chalk.dim(` opencli ${name} hello`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
|
||||
process.exitCode = 1;
|
||||
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -454,7 +454,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
const ext = externalClis.find(e => e.name === name);
|
||||
if (!ext) {
|
||||
console.error(chalk.red(`External CLI '${name}' not found in registry.`));
|
||||
process.exitCode = 1;
|
||||
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
||||
return;
|
||||
}
|
||||
installExternalCli(ext);
|
||||
@@ -480,7 +480,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
executeExternalCli(name, args, externalClis);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
|
||||
process.exitCode = 1;
|
||||
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,7 +525,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
console.error(chalk.dim(` Tip: '${binary}' exists on your PATH. Use 'opencli register ${binary}' to add it as an external CLI.`));
|
||||
}
|
||||
program.outputHelp();
|
||||
process.exitCode = 1;
|
||||
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
||||
});
|
||||
|
||||
program.parse();
|
||||
|
||||
@@ -60,7 +60,7 @@ cli({
|
||||
|
||||
await page.installInterceptor('36kr.com/api');
|
||||
await page.goto(url);
|
||||
await page.wait(6);
|
||||
await page.waitForCapture(6);
|
||||
|
||||
// Scrape rendered article links from DOM (deduplicated)
|
||||
const domItems: any = await page.evaluate(`
|
||||
|
||||
@@ -24,7 +24,7 @@ cli({
|
||||
|
||||
await page.installInterceptor('36kr.com/api');
|
||||
await page.goto(`https://www.36kr.com/search/articles/${query}`);
|
||||
await page.wait(6);
|
||||
await page.waitForCapture(6);
|
||||
|
||||
const domItems: any = await page.evaluate(`
|
||||
(() => {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Shared utilities for CLI adapters.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Clamp a numeric value to [min, max].
|
||||
* Matches the signature of lodash.clamp and Rust's clamp.
|
||||
*/
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(value, max));
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { CDPBridge } from '../../browser/cdp.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { getErrorMessage } from '../../errors.js';
|
||||
import { EXIT_CODES, getErrorMessage } from '../../errors.js';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -594,7 +594,7 @@ export async function startServe(opts: { port?: number } = {}): Promise<void> {
|
||||
console.error('\n[serve] Shutting down...');
|
||||
cdp?.close().catch(() => {});
|
||||
server.close();
|
||||
process.exit(0);
|
||||
process.exit(EXIT_CODES.SUCCESS);
|
||||
};
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
@@ -38,14 +38,14 @@ describe('apple-podcasts search command', () => {
|
||||
'https://itunes.apple.com/search?term=machine%20learning&media=podcast&limit=5',
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
expect.objectContaining({
|
||||
id: 42,
|
||||
title: 'Machine Learning Guide',
|
||||
author: 'OpenCLI',
|
||||
episodes: 12,
|
||||
genre: 'Technology',
|
||||
url: '',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,30 @@ describe('apple-podcasts top command', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('adds a timeout signal to chart fetches', async () => {
|
||||
const cmd = getRegistry().get('apple-podcasts/top');
|
||||
expect(cmd?.func).toBeTypeOf('function');
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
feed: {
|
||||
results: [
|
||||
{ id: '100', name: 'Top Show', artistName: 'Host A' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await cmd!.func!(null as any, { country: 'US', limit: 1 });
|
||||
|
||||
const [, options] = fetchMock.mock.calls[0] ?? [];
|
||||
expect(options).toBeDefined();
|
||||
expect(options.signal).toBeDefined();
|
||||
expect(options.signal).toHaveProperty('aborted', false);
|
||||
});
|
||||
|
||||
it('uses the canonical Apple charts host and maps ranked results', async () => {
|
||||
const cmd = getRegistry().get('apple-podcasts/top');
|
||||
expect(cmd?.func).toBeTypeOf('function');
|
||||
@@ -76,6 +100,9 @@ describe('apple-podcasts top command', () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://rss.marketingtools.apple.com/api/v2/us/podcasts/top/2/podcasts.json',
|
||||
expect.objectContaining({
|
||||
signal: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{ rank: 1, title: 'Top Show', author: 'Host A', id: '100' },
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CliError } from '../../errors.js';
|
||||
|
||||
// Apple Marketing Tools RSS API — public, no key required
|
||||
const CHARTS_URL = 'https://rss.marketingtools.apple.com/api/v2';
|
||||
const CHARTS_TIMEOUT_MS = 15_000;
|
||||
|
||||
cli({
|
||||
site: 'apple-podcasts',
|
||||
@@ -21,7 +22,9 @@ cli({
|
||||
const url = `${CHARTS_URL}/${country}/podcasts/top/${limit}/podcasts.json`;
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(url);
|
||||
resp = await fetch(url, {
|
||||
signal: AbortSignal.timeout(CHARTS_TIMEOUT_MS),
|
||||
});
|
||||
} catch (error: any) {
|
||||
const reason = error?.cause?.code ?? error?.message ?? 'unknown network error';
|
||||
throw new CliError(
|
||||
|
||||
@@ -23,7 +23,7 @@ cli({
|
||||
|
||||
// Navigate and wait for the page to hydrate before extracting story data.
|
||||
await page.goto(url);
|
||||
await page.wait(5);
|
||||
await page.wait({ selector: 'article', timeout: 5 });
|
||||
|
||||
const loadStory = async () => page.evaluate(`(() => {
|
||||
const isRobot = /Are you a robot/i.test(document.title)
|
||||
|
||||
@@ -4,17 +4,13 @@
|
||||
|
||||
import { ArgumentError, CliError, EmptyResultError } from '../../errors.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { clamp } from '../_shared/common.js';
|
||||
|
||||
const DOUBAN_PHOTO_PAGE_SIZE = 30;
|
||||
const MAX_DOUBAN_PHOTOS = 500;
|
||||
|
||||
function clampLimit(limit: number): number {
|
||||
return Math.max(1, Math.min(limit || 20, 50));
|
||||
}
|
||||
|
||||
function clampPhotoLimit(limit: number): number {
|
||||
return Math.max(1, Math.min(limit || 120, MAX_DOUBAN_PHOTOS));
|
||||
}
|
||||
const clampLimit = (limit: number) => clamp(limit || 20, 1, 50);
|
||||
const clampPhotoLimit = (limit: number) => clamp(limit || 120, 1, MAX_DOUBAN_PHOTOS);
|
||||
|
||||
async function ensureDoubanReady(page: IPage): Promise<void> {
|
||||
const state = await page.evaluate(`
|
||||
|
||||
@@ -16,7 +16,7 @@ export function buildMediumUserUrl(username: string): string {
|
||||
export async function loadMediumPosts(page: IPage, url: string, limit: number): Promise<any[]> {
|
||||
if (!page) throw new CommandExecutionError('Browser session required for medium posts');
|
||||
await page.goto(url);
|
||||
await page.wait(5);
|
||||
await page.wait({ selector: 'article', timeout: 5 });
|
||||
const data = await page.evaluate(`
|
||||
(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
|
||||
@@ -32,7 +32,7 @@ cli({
|
||||
|
||||
await page.installInterceptor('producthunt.com');
|
||||
await page.goto(`https://www.producthunt.com/categories/${slug}`);
|
||||
await page.wait(5);
|
||||
await page.waitForCapture(5);
|
||||
|
||||
const domItems: any = await page.evaluate(`
|
||||
(() => {
|
||||
|
||||
@@ -23,7 +23,7 @@ cli({
|
||||
|
||||
await page.installInterceptor('producthunt.com');
|
||||
await page.goto('https://www.producthunt.com');
|
||||
await page.wait(5);
|
||||
await page.waitForCapture(5);
|
||||
|
||||
const domItems: any = await page.evaluate(`
|
||||
(() => {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { IPage } from '../../types.js';
|
||||
import { clamp } from '../_shared/common.js';
|
||||
|
||||
function clampLimit(limit: number): number {
|
||||
return Math.max(1, Math.min(limit || 20, 50));
|
||||
}
|
||||
const clampLimit = (limit: number) => clamp(limit || 20, 1, 50);
|
||||
|
||||
export function buildSinaBlogSearchUrl(keyword: string): string {
|
||||
return `https://search.sina.com.cn/search?q=${encodeURIComponent(keyword)}&tp=mix`;
|
||||
@@ -14,7 +13,7 @@ export function buildSinaBlogUserUrl(uid: string): string {
|
||||
|
||||
export async function loadSinaBlogArticle(page: IPage, url: string): Promise<any> {
|
||||
await page.goto(url);
|
||||
await page.wait(3);
|
||||
await page.wait({ selector: 'h1', timeout: 3 });
|
||||
return page.evaluate(`
|
||||
(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
@@ -52,7 +51,7 @@ export async function loadSinaBlogArticle(page: IPage, url: string): Promise<any
|
||||
export async function loadSinaBlogHot(page: IPage, limit: number): Promise<any[]> {
|
||||
const safeLimit = clampLimit(limit);
|
||||
await page.goto('https://blog.sina.com.cn/');
|
||||
await page.wait(3);
|
||||
await page.wait({ selector: 'h1', timeout: 3 });
|
||||
const data = await page.evaluate(`
|
||||
(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
@@ -122,7 +121,7 @@ export async function loadSinaBlogHot(page: IPage, limit: number): Promise<any[]
|
||||
export async function loadSinaBlogSearch(page: IPage, keyword: string, limit: number): Promise<any[]> {
|
||||
const safeLimit = clampLimit(limit);
|
||||
await page.goto(buildSinaBlogSearchUrl(keyword));
|
||||
await page.wait(5);
|
||||
await page.wait({ selector: '.result-item', timeout: 5 });
|
||||
const data = await page.evaluate(`
|
||||
(async () => {
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -159,7 +158,7 @@ export async function loadSinaBlogSearch(page: IPage, keyword: string, limit: nu
|
||||
export async function loadSinaBlogUser(page: IPage, uid: string, limit: number): Promise<any[]> {
|
||||
const safeLimit = clampLimit(limit);
|
||||
await page.goto(buildSinaBlogUserUrl(uid));
|
||||
await page.wait(3);
|
||||
await page.wait({ selector: 'h1', timeout: 3 });
|
||||
const data = await page.evaluate(`
|
||||
(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Sinafinance rolling news feed
|
||||
*/
|
||||
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
cli({
|
||||
site: 'sinafinance',
|
||||
name: 'rolling-news',
|
||||
description: '新浪财经滚动新闻',
|
||||
domain: 'finance.sina.com.cn/roll',
|
||||
strategy: Strategy.COOKIE,
|
||||
args: [],
|
||||
columns: ['column', 'title', 'date', 'url'],
|
||||
func: async (page, _args) => {
|
||||
await page.goto(`https://finance.sina.com.cn/roll/#pageid=384&lid=2519`);
|
||||
await page.wait({ selector: '.d_list_txt li', timeout: 10000 });
|
||||
|
||||
const payload = await page.evaluate(`
|
||||
(() => {
|
||||
const cleanText = (value) => (value || '').replace(/\\s+/g, ' ').trim();
|
||||
const results = [];
|
||||
document.querySelectorAll('.d_list_txt li').forEach(el => {
|
||||
const titleEl = el.querySelector('.c_tit a');
|
||||
const columnEl = el.querySelector('.c_chl');
|
||||
const dateEl = el.querySelector('.c_time');
|
||||
const url = titleEl?.getAttribute('href') || '';
|
||||
if (!url) return;
|
||||
results.push({
|
||||
title: cleanText(titleEl?.textContent || ''),
|
||||
column: cleanText(columnEl?.textContent || ''),
|
||||
date: cleanText(dateEl?.textContent || ''),
|
||||
url: url,
|
||||
});
|
||||
});
|
||||
return results;
|
||||
})()
|
||||
`);
|
||||
if (!Array.isArray(payload)) return [];
|
||||
return payload;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Sinafinance stock quote — A股 / 港股 / 美股
|
||||
*
|
||||
* Uses two public Sina APIs (no browser required):
|
||||
* suggest3.sinajs.cn — symbol search
|
||||
* hq.sinajs.cn — real-time quote
|
||||
*/
|
||||
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { CliError } from '../../errors.js';
|
||||
|
||||
const MARKET_CN = '11';
|
||||
const MARKET_HK = '31';
|
||||
const MARKET_US = '41';
|
||||
|
||||
async function fetchGBK(url: string): Promise<string> {
|
||||
const res = await fetch(url, { headers: { Referer: 'https://finance.sina.com.cn' } });
|
||||
if (!res.ok) throw new CliError('FETCH_ERROR', `Sina API HTTP ${res.status}`, 'Check your network');
|
||||
const buf = await res.arrayBuffer();
|
||||
return new TextDecoder('gbk').decode(buf);
|
||||
}
|
||||
|
||||
interface SuggestEntry { name: string; market: string; symbol: string; }
|
||||
|
||||
function parseSuggest(raw: string, markets: string[]): SuggestEntry[] {
|
||||
const m = raw.match(/suggestvalue="(.*)"/s);
|
||||
if (!m) return [];
|
||||
return m[1].split(';').filter(Boolean).map(s => {
|
||||
const p = s.split(',');
|
||||
return { name: p[4] || p[0] || '', market: p[1] || '', symbol: p[3] || '' };
|
||||
}).filter(e => markets.includes(e.market));
|
||||
}
|
||||
|
||||
function hqSymbol(e: SuggestEntry): string {
|
||||
if (e.market === MARKET_HK) return `hk${e.symbol}`;
|
||||
if (e.market === MARKET_US) return `gb_${e.symbol}`;
|
||||
return e.symbol; // A股: already "sh600519" / "sz300XXX"
|
||||
}
|
||||
|
||||
function parseHq(raw: string, sym: string): string[] {
|
||||
const escaped = sym.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const m = raw.match(new RegExp(`hq_str_${escaped}="([^"]*)"`));
|
||||
return m ? m[1].split(',') : [];
|
||||
}
|
||||
|
||||
function fmtMktCap(val: string): string {
|
||||
const n = parseFloat(val);
|
||||
if (!n) return '';
|
||||
if (n >= 1e12) return (n / 1e12).toFixed(2) + 'T';
|
||||
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
|
||||
return String(n);
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'sinafinance',
|
||||
name: 'stock',
|
||||
description: '新浪财经行情(A股/港股/美股)',
|
||||
domain: 'suggest3.sinajs.cn,hq.sinajs.cn',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{ name: 'key', type: 'string', required: true, positional: true, help: 'Stock name or code (e.g. 贵州茅台, 腾讯控股, AAPL)' },
|
||||
{ name: 'market', type: 'string', default: 'auto', help: 'Market: cn, hk, us, auto (default: auto searches cn → hk → us)' },
|
||||
],
|
||||
columns: ['Symbol', 'Name', 'Price', 'Change', 'ChangePercent', 'Open', 'High', 'Low', 'Volume', 'MarketCap'],
|
||||
func: async (_page, args) => {
|
||||
const key = String(args.key);
|
||||
const market = String(args.market);
|
||||
|
||||
const marketMap: Record<string, string[]> = {
|
||||
cn: [MARKET_CN], hk: [MARKET_HK], us: [MARKET_US],
|
||||
auto: [MARKET_CN, MARKET_HK, MARKET_US],
|
||||
};
|
||||
const targetMarkets = marketMap[market];
|
||||
if (!targetMarkets) {
|
||||
throw new CliError('INPUT_ERROR', `Invalid market: "${market}"`, 'Expected cn, hk, us, or auto');
|
||||
}
|
||||
|
||||
// 1. Search symbol — only request the markets we care about
|
||||
const suggestRaw = await fetchGBK(
|
||||
`https://suggest3.sinajs.cn/suggest/type=${targetMarkets.join(',')}&key=${encodeURIComponent(key)}`
|
||||
);
|
||||
const entries = parseSuggest(suggestRaw, targetMarkets);
|
||||
if (!entries.length) {
|
||||
throw new CliError('NOT_FOUND', `No stock found for "${key}"`, 'Try a different name, code, or --market');
|
||||
}
|
||||
|
||||
// Pick best match: score by name similarity, tiebreak by market priority
|
||||
const needle = key.toLowerCase();
|
||||
const score = (e: SuggestEntry): number => {
|
||||
const n = e.name.toLowerCase();
|
||||
if (n === needle) return 1;
|
||||
if (n.includes(needle)) return needle.length / n.length;
|
||||
return 0;
|
||||
};
|
||||
const best = entries.sort((a, b) => {
|
||||
const d = score(b) - score(a);
|
||||
return d !== 0 ? d : targetMarkets.indexOf(a.market) - targetMarkets.indexOf(b.market);
|
||||
})[0];
|
||||
|
||||
// 2. Fetch quote
|
||||
const sym = hqSymbol(best);
|
||||
const hqRaw = await fetchGBK(`https://hq.sinajs.cn/list=${sym}`);
|
||||
const f = parseHq(hqRaw, sym);
|
||||
|
||||
if (f.length < 2 || !f[0]) {
|
||||
throw new CliError('NOT_FOUND', `No quote data for "${key}"`, 'Market may be closed or data unavailable');
|
||||
}
|
||||
|
||||
if (best.market === MARKET_CN) {
|
||||
const price = parseFloat(f[3]);
|
||||
const prev = parseFloat(f[2]);
|
||||
const chg = (price - prev).toFixed(2);
|
||||
const chgPct = ((price - prev) / prev * 100).toFixed(2) + '%';
|
||||
return [{ Symbol: sym.toUpperCase(), Name: f[0], Price: f[3], Change: chg, ChangePercent: chgPct, Open: f[1], High: f[4], Low: f[5], Volume: f[8], MarketCap: '' }];
|
||||
}
|
||||
|
||||
if (best.market === MARKET_HK) {
|
||||
// [2]=price [4]=high [5]=low [6]=open [7]=change [8]=change% [11]=volume
|
||||
return [{ Symbol: best.symbol, Name: f[1], Price: f[2], Change: f[7], ChangePercent: f[8] + '%', Open: f[6], High: f[4], Low: f[5], Volume: f[11], MarketCap: '' }];
|
||||
}
|
||||
|
||||
// MARKET_US: [1]=price [2]=change% [4]=change [6]=open [7]=today_low [8]=52wH [9]=52wL [10]=volume [12]=mktcap
|
||||
return [{ Symbol: best.symbol.toUpperCase(), Name: f[0], Price: f[1], Change: f[4], ChangePercent: f[2] + '%', Open: f[6], High: f[8], Low: f[9], Volume: f[10], MarketCap: fmtMktCap(f[12]) }];
|
||||
},
|
||||
});
|
||||
@@ -10,7 +10,7 @@ export function buildSubstackBrowseUrl(category?: string): string {
|
||||
export async function loadSubstackFeed(page: IPage, url: string, limit: number): Promise<any[]> {
|
||||
if (!page) throw new CommandExecutionError('Browser session required for substack feed');
|
||||
await page.goto(url);
|
||||
await page.wait(5);
|
||||
await page.wait({ selector: 'article', timeout: 5 });
|
||||
const data = await page.evaluate(`
|
||||
(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
@@ -79,7 +79,7 @@ export async function loadSubstackFeed(page: IPage, url: string, limit: number):
|
||||
export async function loadSubstackArchive(page: IPage, baseUrl: string, limit: number): Promise<any[]> {
|
||||
if (!page) throw new CommandExecutionError('Browser session required for substack archive');
|
||||
await page.goto(`${baseUrl}/archive`);
|
||||
await page.wait(5);
|
||||
await page.wait({ selector: 'article', timeout: 5 });
|
||||
const data = await page.evaluate(`
|
||||
(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
|
||||
@@ -18,7 +18,7 @@ cli({
|
||||
const username = kwargs.username.replace(/^@/, '');
|
||||
|
||||
await page.goto(`https://x.com/${username}`);
|
||||
await page.wait(5);
|
||||
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
||||
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
|
||||
@@ -17,7 +17,7 @@ cli({
|
||||
if (!page) throw new CommandExecutionError('Browser session required for twitter bookmark');
|
||||
|
||||
await page.goto(kwargs.url);
|
||||
await page.wait(5);
|
||||
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
||||
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
|
||||
@@ -17,7 +17,7 @@ cli({
|
||||
if (!page) throw new CommandExecutionError('Browser session required for twitter delete');
|
||||
|
||||
await page.goto(kwargs.url);
|
||||
await page.wait(5); // Wait for tweet to load completely
|
||||
await page.wait({ selector: '[data-testid="primaryColumn"]' }); // Wait for tweet to load completely
|
||||
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
|
||||
@@ -18,7 +18,7 @@ cli({
|
||||
const username = kwargs.username.replace(/^@/, '');
|
||||
|
||||
await page.goto(`https://x.com/${username}`);
|
||||
await page.wait(5);
|
||||
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
||||
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
|
||||
@@ -19,7 +19,7 @@ cli({
|
||||
// If no user is specified, figure out the logged-in user's handle
|
||||
if (!targetUser) {
|
||||
await page.goto('https://x.com/home');
|
||||
await page.wait(5);
|
||||
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
||||
|
||||
const href = await page.evaluate(`() => {
|
||||
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
||||
@@ -58,7 +58,7 @@ cli({
|
||||
if (!clicked) {
|
||||
throw new SelectorError('Twitter followers link', 'Twitter may have changed the layout.');
|
||||
}
|
||||
await page.wait(5);
|
||||
await page.waitForCapture(5);
|
||||
|
||||
// 4. Scroll to trigger pagination API calls
|
||||
await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
|
||||
|
||||
@@ -19,7 +19,7 @@ cli({
|
||||
// If no user is specified, figure out the logged-in user's handle
|
||||
if (!targetUser) {
|
||||
await page.goto('https://x.com/home');
|
||||
await page.wait(5);
|
||||
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
||||
|
||||
const href = await page.evaluate(`() => {
|
||||
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
||||
@@ -51,7 +51,7 @@ cli({
|
||||
if (!clicked) {
|
||||
throw new SelectorError('Twitter following link', 'Twitter may have changed the layout.');
|
||||
}
|
||||
await page.wait(5);
|
||||
await page.waitForCapture(5);
|
||||
|
||||
// 4. Scroll to trigger pagination API calls
|
||||
await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
|
||||
|
||||
@@ -17,7 +17,7 @@ cli({
|
||||
if (!page) throw new CommandExecutionError('Browser session required for twitter hide-reply');
|
||||
|
||||
await page.goto(kwargs.url);
|
||||
await page.wait(5);
|
||||
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
||||
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
|
||||
@@ -17,7 +17,7 @@ cli({
|
||||
if (!page) throw new CommandExecutionError('Browser session required for twitter like');
|
||||
|
||||
await page.goto(kwargs.url);
|
||||
await page.wait(5); // Wait for tweet to load completely
|
||||
await page.wait({ selector: '[data-testid="primaryColumn"]' }); // Wait for tweet to load completely
|
||||
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
|
||||
@@ -25,7 +25,7 @@ cli({
|
||||
window.history.pushState({}, '', '/notifications');
|
||||
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
|
||||
}`);
|
||||
await page.wait(5);
|
||||
await page.waitForCapture(5);
|
||||
|
||||
// Verify SPA navigation succeeded
|
||||
const currentUrl = await page.evaluate('() => window.location.pathname');
|
||||
|
||||
@@ -21,7 +21,7 @@ cli({
|
||||
// If no username, detect the logged-in user
|
||||
if (!username) {
|
||||
await page.goto('https://x.com/home');
|
||||
await page.wait(5);
|
||||
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
||||
const href = await page.evaluate(`() => {
|
||||
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
||||
return link ? link.getAttribute('href') : null;
|
||||
|
||||
@@ -27,7 +27,7 @@ cli({
|
||||
|
||||
// Step 1: Navigate to messages to get conversation list
|
||||
await page.goto('https://x.com/messages');
|
||||
await page.wait(5);
|
||||
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
||||
|
||||
// Step 2: Collect conversations with scroll-to-load
|
||||
const needed = maxSend + 10; // extra buffer for skips
|
||||
|
||||
@@ -19,7 +19,7 @@ cli({
|
||||
|
||||
// 1. Navigate to the tweet page
|
||||
await page.goto(kwargs.url);
|
||||
await page.wait(5); // Wait for the react application to hydrate
|
||||
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
||||
|
||||
// 2. Automate typing the reply and clicking reply
|
||||
const result = await page.evaluate(`(async () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ async function navigateToSearch(page: Pick<IPage, 'evaluate' | 'wait'>, query: s
|
||||
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
|
||||
})()
|
||||
`);
|
||||
await page.wait(5);
|
||||
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
||||
|
||||
lastPath = String(await page.evaluate('() => window.location.pathname') || '');
|
||||
if (lastPath.startsWith('/search')) {
|
||||
|
||||
@@ -18,7 +18,7 @@ cli({
|
||||
const username = kwargs.username.replace(/^@/, '');
|
||||
|
||||
await page.goto(`https://x.com/${username}`);
|
||||
await page.wait(5);
|
||||
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
||||
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
|
||||
@@ -17,7 +17,7 @@ cli({
|
||||
if (!page) throw new CommandExecutionError('Browser session required for twitter unbookmark');
|
||||
|
||||
await page.goto(kwargs.url);
|
||||
await page.wait(5);
|
||||
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
||||
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
|
||||
@@ -18,7 +18,7 @@ cli({
|
||||
const username = kwargs.username.replace(/^@/, '');
|
||||
|
||||
await page.goto(`https://x.com/${username}`);
|
||||
await page.wait(5);
|
||||
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
||||
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
|
||||
+3
-17
@@ -3,7 +3,7 @@ name: hot
|
||||
description: V2EX 热门话题
|
||||
domain: www.v2ex.com
|
||||
strategy: public
|
||||
browser: true
|
||||
browser: false
|
||||
|
||||
args:
|
||||
limit:
|
||||
@@ -12,22 +12,8 @@ args:
|
||||
description: Number of topics
|
||||
|
||||
pipeline:
|
||||
- navigate: https://www.v2ex.com/
|
||||
|
||||
- evaluate: |
|
||||
(async () => {
|
||||
const response = await fetch('/api/topics/hot.json', {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
accept: 'application/json, text/plain, */*',
|
||||
'x-requested-with': 'XMLHttpRequest',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`V2EX hot API request failed: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
})()
|
||||
- fetch:
|
||||
url: https://www.v2ex.com/api/topics/hot.json
|
||||
|
||||
- map:
|
||||
rank: ${{ index + 1 }}
|
||||
|
||||
@@ -26,6 +26,7 @@ function createPageMock(evaluateResult: any): IPage {
|
||||
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
||||
getCookies: vi.fn().mockResolvedValue([]),
|
||||
screenshot: vi.fn().mockResolvedValue(''),
|
||||
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ function createPageMock(evaluateResult: any): IPage {
|
||||
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
||||
getCookies: vi.fn().mockResolvedValue([]),
|
||||
screenshot: vi.fn().mockResolvedValue(''),
|
||||
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ function createPageMock(evaluateResult: any, interceptedRequests: any[] = []): I
|
||||
getInterceptedRequests,
|
||||
getCookies: vi.fn().mockResolvedValue([]),
|
||||
screenshot: vi.fn().mockResolvedValue(''),
|
||||
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ function createPageMock(evaluateResults: any[]): IPage {
|
||||
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
||||
getCookies: vi.fn().mockResolvedValue([]),
|
||||
screenshot: vi.fn().mockResolvedValue(''),
|
||||
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ function createPageMock(evaluateResults: any[]): IPage {
|
||||
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
||||
getCookies: vi.fn().mockResolvedValue([]),
|
||||
screenshot: vi.fn().mockResolvedValue(''),
|
||||
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+25
-2
@@ -18,6 +18,7 @@ import { render as renderOutput } from './output.js';
|
||||
import { executeCommand } from './execution.js';
|
||||
import {
|
||||
CliError,
|
||||
EXIT_CODES,
|
||||
ERROR_ICONS,
|
||||
getErrorMessage,
|
||||
BrowserConnectError,
|
||||
@@ -40,7 +41,7 @@ export function normalizeArgValue(argType: string | undefined, value: unknown, n
|
||||
if (normalized === 'true') return true;
|
||||
if (normalized === 'false') return false;
|
||||
|
||||
throw new CliError('ARGUMENT', `"${name}" must be either "true" or "false".`);
|
||||
throw new ArgumentError(`"${name}" must be either "true" or "false".`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,11 +118,33 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
|
||||
});
|
||||
} catch (err) {
|
||||
await renderError(err, fullName(cmd), optionsRecord.verbose === true);
|
||||
process.exitCode = 1;
|
||||
process.exitCode = resolveExitCode(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Exit code resolution ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map any thrown value to a Unix process exit code.
|
||||
*
|
||||
* - CliError subclasses carry their own exitCode (set in errors.ts).
|
||||
* - Generic Error objects are classified by message pattern so that
|
||||
* un-typed auth / not-found errors from adapters still produce
|
||||
* meaningful exit codes for shell scripts.
|
||||
*/
|
||||
function resolveExitCode(err: unknown): number {
|
||||
if (err instanceof CliError) return err.exitCode;
|
||||
|
||||
// Pattern-based fallback for untyped errors thrown by third-party adapters.
|
||||
const msg = getErrorMessage(err);
|
||||
const kind = classifyGenericError(msg);
|
||||
if (kind === 'auth') return EXIT_CODES.NOPERM;
|
||||
if (kind === 'not-found') return EXIT_CODES.EMPTY_RESULT;
|
||||
if (kind === 'http') return EXIT_CODES.GENERIC_ERROR; // HTTP 4xx/5xx → generic; renderer shows details
|
||||
return EXIT_CODES.GENERIC_ERROR;
|
||||
}
|
||||
|
||||
// ── Error rendering ──────────────────────────────────────────────────────────
|
||||
|
||||
const ISSUES_URL = 'https://github.com/jackwener/opencli/issues';
|
||||
|
||||
+21
-8
@@ -22,6 +22,7 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { WebSocketServer, WebSocket, type RawData } from 'ws';
|
||||
import { DEFAULT_DAEMON_PORT } from './constants.js';
|
||||
import { EXIT_CODES } from './errors.js';
|
||||
|
||||
const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
|
||||
const IDLE_TIMEOUT = 5 * 60 * 1000; // 5 minutes
|
||||
@@ -53,7 +54,7 @@ function resetIdleTimer(): void {
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
console.error('[daemon] Idle timeout, shutting down');
|
||||
process.exit(0);
|
||||
process.exit(EXIT_CODES.SUCCESS);
|
||||
}, IDLE_TIMEOUT);
|
||||
}
|
||||
|
||||
@@ -102,7 +103,22 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
|
||||
return;
|
||||
}
|
||||
|
||||
// Require custom header on all HTTP requests. Browsers cannot attach
|
||||
const url = req.url ?? '/';
|
||||
const pathname = url.split('?')[0];
|
||||
|
||||
// Health-check endpoint — no X-OpenCLI header required.
|
||||
// Used by the extension to silently probe daemon reachability before
|
||||
// attempting a WebSocket connection (avoids uncatchable ERR_CONNECTION_REFUSED).
|
||||
// Security note: this endpoint is reachable by any client that passes the
|
||||
// origin check above (chrome-extension:// or no Origin header, e.g. curl).
|
||||
// Timing side-channels can reveal daemon presence to local processes, which
|
||||
// is an accepted risk given the daemon is loopback-only and short-lived.
|
||||
if (req.method === 'GET' && pathname === '/ping') {
|
||||
jsonResponse(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Require custom header on all other HTTP requests. Browsers cannot attach
|
||||
// custom headers in "simple" requests, and our preflight returns no
|
||||
// Access-Control-Allow-Headers, so scripted fetch() from web pages is
|
||||
// blocked even if Origin check is somehow bypassed.
|
||||
@@ -111,9 +127,6 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
|
||||
return;
|
||||
}
|
||||
|
||||
const url = req.url ?? '/';
|
||||
const pathname = url.split('?')[0];
|
||||
|
||||
if (req.method === 'GET' && pathname === '/status') {
|
||||
jsonResponse(res, 200, {
|
||||
ok: true,
|
||||
@@ -291,10 +304,10 @@ httpServer.listen(PORT, '127.0.0.1', () => {
|
||||
httpServer.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`[daemon] Port ${PORT} already in use — another daemon is likely running. Exiting.`);
|
||||
process.exit(1);
|
||||
process.exit(EXIT_CODES.SERVICE_UNAVAIL);
|
||||
}
|
||||
console.error('[daemon] Server error:', err.message);
|
||||
process.exit(1);
|
||||
process.exit(EXIT_CODES.GENERIC_ERROR);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
@@ -307,7 +320,7 @@ function shutdown(): void {
|
||||
pending.clear();
|
||||
if (extensionWs) extensionWs.close();
|
||||
httpServer.close();
|
||||
process.exit(0);
|
||||
process.exit(EXIT_CODES.SUCCESS);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
@@ -2,13 +2,14 @@ import * as fs from 'node:fs';
|
||||
import * as http from 'node:http';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { formatCookieHeader, httpDownload, resolveRedirectUrl } from './index.js';
|
||||
|
||||
const servers: http.Server[] = [];
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await Promise.all(servers.map((server) => new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
})));
|
||||
@@ -114,4 +115,21 @@ describe('download helpers', { retry: process.platform === 'win32' ? 2 : 0 }, ()
|
||||
expect(forwardedCookie).toBeUndefined();
|
||||
expect(fs.readFileSync(destPath, 'utf8')).toBe('ok');
|
||||
});
|
||||
|
||||
it('bypasses proxy settings for loopback downloads', async () => {
|
||||
vi.stubEnv('HTTP_PROXY', 'http://127.0.0.1:9');
|
||||
|
||||
const baseUrl = await startServer((_req, res) => {
|
||||
res.statusCode = 200;
|
||||
res.end('ok');
|
||||
});
|
||||
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-dl-'));
|
||||
tempDirs.push(tempDir);
|
||||
const destPath = path.join(tempDir, 'loopback.txt');
|
||||
const result = await httpDownload(`${baseUrl}/ok`, destPath);
|
||||
|
||||
expect(result).toEqual({ success: true, size: 2 });
|
||||
expect(fs.readFileSync(destPath, 'utf8')).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
+50
-41
@@ -5,16 +5,16 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as https from 'node:https';
|
||||
import * as http from 'node:http';
|
||||
import * as os from 'node:os';
|
||||
import { Transform } from 'node:stream';
|
||||
import { Readable, Transform } from 'node:stream';
|
||||
import type { ReadableStream as WebReadableStream } from 'node:stream/web';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { URL } from 'node:url';
|
||||
import type { ProgressBar } from './progress.js';
|
||||
import { isBinaryInstalled } from '../external.js';
|
||||
import type { BrowserCookie } from '../types.js';
|
||||
import { getErrorMessage } from '../errors.js';
|
||||
import { fetchWithNodeNetwork } from '../node-network.js';
|
||||
|
||||
export type { BrowserCookie } from '../types.js';
|
||||
|
||||
@@ -89,9 +89,6 @@ export async function httpDownload(
|
||||
const { cookies, headers = {}, timeout = 30000, onProgress, maxRedirects = 10 } = options;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const parsedUrl = new URL(url);
|
||||
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
|
||||
...headers,
|
||||
@@ -118,37 +115,52 @@ export async function httpDownload(
|
||||
}
|
||||
};
|
||||
|
||||
const request = protocol.get(url, { headers: requestHeaders, timeout }, (response) => {
|
||||
void (async () => {
|
||||
void (async () => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
try {
|
||||
const response = await fetchWithNodeNetwork(url, {
|
||||
headers: requestHeaders,
|
||||
signal: controller.signal,
|
||||
redirect: 'manual',
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
// Handle redirects before creating any file handles.
|
||||
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
||||
response.resume();
|
||||
if (redirectCount >= maxRedirects) {
|
||||
finish({ success: false, size: 0, error: `Too many redirects (> ${maxRedirects})` });
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get('location');
|
||||
if (location) {
|
||||
if (redirectCount >= maxRedirects) {
|
||||
finish({ success: false, size: 0, error: `Too many redirects (> ${maxRedirects})` });
|
||||
return;
|
||||
}
|
||||
const redirectUrl = resolveRedirectUrl(url, location);
|
||||
const originalHost = new URL(url).hostname;
|
||||
const redirectHost = new URL(redirectUrl).hostname;
|
||||
const redirectOptions = originalHost === redirectHost
|
||||
? options
|
||||
: { ...options, cookies: undefined, headers: stripCookieHeaders(options.headers) };
|
||||
finish(await httpDownload(
|
||||
redirectUrl,
|
||||
destPath,
|
||||
redirectOptions,
|
||||
redirectCount + 1,
|
||||
));
|
||||
return;
|
||||
}
|
||||
const redirectUrl = resolveRedirectUrl(url, response.headers.location);
|
||||
const originalHost = new URL(url).hostname;
|
||||
const redirectHost = new URL(redirectUrl).hostname;
|
||||
const redirectOptions = originalHost === redirectHost
|
||||
? options
|
||||
: { ...options, cookies: undefined, headers: stripCookieHeaders(options.headers) };
|
||||
finish(await httpDownload(
|
||||
redirectUrl,
|
||||
destPath,
|
||||
redirectOptions,
|
||||
redirectCount + 1,
|
||||
));
|
||||
}
|
||||
|
||||
if (response.status !== 200) {
|
||||
finish({ success: false, size: 0, error: `HTTP ${response.status}` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
response.resume();
|
||||
finish({ success: false, size: 0, error: `HTTP ${response.statusCode}` });
|
||||
if (!response.body) {
|
||||
finish({ success: false, size: 0, error: 'Empty response body' });
|
||||
return;
|
||||
}
|
||||
|
||||
const totalSize = parseInt(response.headers['content-length'] || '0', 10);
|
||||
const totalSize = parseInt(response.headers.get('content-length') || '0', 10);
|
||||
let received = 0;
|
||||
const progressStream = new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
@@ -160,26 +172,23 @@ export async function httpDownload(
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
|
||||
await pipeline(response, progressStream, fs.createWriteStream(tempPath));
|
||||
await pipeline(
|
||||
Readable.fromWeb(response.body as unknown as WebReadableStream),
|
||||
progressStream,
|
||||
fs.createWriteStream(tempPath),
|
||||
);
|
||||
await fs.promises.rename(tempPath, destPath);
|
||||
finish({ success: true, size: received });
|
||||
} catch (err) {
|
||||
await cleanupTempFile();
|
||||
finish({ success: false, size: 0, error: getErrorMessage(err) });
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
request.on('error', (err) => {
|
||||
void (async () => {
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
await cleanupTempFile();
|
||||
finish({ success: false, size: 0, error: err.message });
|
||||
})();
|
||||
});
|
||||
|
||||
request.on('timeout', () => {
|
||||
request.destroy(new Error('Timeout'));
|
||||
});
|
||||
finish({ success: false, size: 0, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+71
-10
@@ -4,48 +4,96 @@
|
||||
* All errors thrown by the framework should extend CliError so that
|
||||
* the top-level handler in commanderAdapter.ts can render consistent,
|
||||
* helpful output with emoji-coded severity and actionable hints.
|
||||
*
|
||||
* ## Exit codes
|
||||
*
|
||||
* opencli follows Unix conventions (sysexits.h) for process exit codes:
|
||||
*
|
||||
* 0 Success
|
||||
* 1 Generic / unexpected error
|
||||
* 2 Argument / usage error (ArgumentError)
|
||||
* 66 No input / empty result (EmptyResultError)
|
||||
* 69 Service unavailable (BrowserConnectError, AdapterLoadError)
|
||||
* 75 Temporary failure, retry later (TimeoutError) EX_TEMPFAIL
|
||||
* 77 Permission denied / auth needed (AuthRequiredError)
|
||||
* 78 Configuration error (ConfigError)
|
||||
* 130 Interrupted by Ctrl-C (set by tui.ts SIGINT handler)
|
||||
*/
|
||||
|
||||
// ── Exit code table ──────────────────────────────────────────────────────────
|
||||
|
||||
export const EXIT_CODES = {
|
||||
SUCCESS: 0,
|
||||
GENERIC_ERROR: 1,
|
||||
USAGE_ERROR: 2, // Bad arguments / command misuse
|
||||
EMPTY_RESULT: 66, // No data / not found (EX_NOINPUT)
|
||||
SERVICE_UNAVAIL:69, // Daemon / browser unavailable (EX_UNAVAILABLE)
|
||||
TEMPFAIL: 75, // Timeout — try again later (EX_TEMPFAIL)
|
||||
NOPERM: 77, // Auth required / permission (EX_NOPERM)
|
||||
CONFIG_ERROR: 78, // Missing / invalid config (EX_CONFIG)
|
||||
INTERRUPTED: 130, // Ctrl-C / SIGINT
|
||||
} as const;
|
||||
|
||||
export type ExitCode = typeof EXIT_CODES[keyof typeof EXIT_CODES];
|
||||
|
||||
// ── Base class ───────────────────────────────────────────────────────────────
|
||||
|
||||
export class CliError extends Error {
|
||||
/** Machine-readable error code (e.g. 'BROWSER_CONNECT', 'AUTH_REQUIRED') */
|
||||
readonly code: string;
|
||||
/** Human-readable hint on how to fix the problem */
|
||||
readonly hint?: string;
|
||||
/** Unix process exit code — defaults to 1 (generic error) */
|
||||
readonly exitCode: ExitCode;
|
||||
|
||||
constructor(code: string, message: string, hint?: string) {
|
||||
constructor(code: string, message: string, hint?: string, exitCode: ExitCode = EXIT_CODES.GENERIC_ERROR) {
|
||||
super(message);
|
||||
this.name = new.target.name;
|
||||
this.code = code;
|
||||
this.hint = hint;
|
||||
this.exitCode = exitCode;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Typed subclasses ─────────────────────────────────────────────────────────
|
||||
|
||||
export type BrowserConnectKind = 'daemon-not-running' | 'extension-not-connected' | 'command-failed' | 'unknown';
|
||||
|
||||
export class BrowserConnectError extends CliError {
|
||||
readonly kind: BrowserConnectKind;
|
||||
constructor(message: string, hint?: string, kind: BrowserConnectKind = 'unknown') {
|
||||
super('BROWSER_CONNECT', message, hint);
|
||||
super('BROWSER_CONNECT', message, hint, EXIT_CODES.SERVICE_UNAVAIL);
|
||||
this.kind = kind;
|
||||
}
|
||||
}
|
||||
|
||||
export class AdapterLoadError extends CliError {
|
||||
constructor(message: string, hint?: string) { super('ADAPTER_LOAD', message, hint); }
|
||||
constructor(message: string, hint?: string) {
|
||||
super('ADAPTER_LOAD', message, hint, EXIT_CODES.SERVICE_UNAVAIL);
|
||||
}
|
||||
}
|
||||
|
||||
export class CommandExecutionError extends CliError {
|
||||
constructor(message: string, hint?: string) { super('COMMAND_EXEC', message, hint); }
|
||||
constructor(message: string, hint?: string) {
|
||||
super('COMMAND_EXEC', message, hint, EXIT_CODES.GENERIC_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigError extends CliError {
|
||||
constructor(message: string, hint?: string) { super('CONFIG', message, hint); }
|
||||
constructor(message: string, hint?: string) {
|
||||
super('CONFIG', message, hint, EXIT_CODES.CONFIG_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthRequiredError extends CliError {
|
||||
readonly domain: string;
|
||||
constructor(domain: string, message?: string) {
|
||||
super('AUTH_REQUIRED', message ?? `Not logged in to ${domain}`, `Please open Chrome and log in to https://${domain}`);
|
||||
super(
|
||||
'AUTH_REQUIRED',
|
||||
message ?? `Not logged in to ${domain}`,
|
||||
`Please open Chrome and log in to https://${domain}`,
|
||||
EXIT_CODES.NOPERM,
|
||||
);
|
||||
this.domain = domain;
|
||||
}
|
||||
}
|
||||
@@ -56,27 +104,40 @@ export class TimeoutError extends CliError {
|
||||
'TIMEOUT',
|
||||
`${label} timed out after ${seconds}s`,
|
||||
hint ?? 'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var',
|
||||
EXIT_CODES.TEMPFAIL,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class ArgumentError extends CliError {
|
||||
constructor(message: string, hint?: string) { super('ARGUMENT', message, hint); }
|
||||
constructor(message: string, hint?: string) {
|
||||
super('ARGUMENT', message, hint, EXIT_CODES.USAGE_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
export class EmptyResultError extends CliError {
|
||||
constructor(command: string, hint?: string) {
|
||||
super('EMPTY_RESULT', `${command} returned no data`, hint ?? 'The page structure may have changed, or you may need to log in');
|
||||
super(
|
||||
'EMPTY_RESULT',
|
||||
`${command} returned no data`,
|
||||
hint ?? 'The page structure may have changed, or you may need to log in',
|
||||
EXIT_CODES.EMPTY_RESULT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class SelectorError extends CliError {
|
||||
constructor(selector: string, hint?: string) {
|
||||
super('SELECTOR', `Could not find element: ${selector}`, hint ?? 'The page UI may have changed. Please report this issue.');
|
||||
super(
|
||||
'SELECTOR',
|
||||
`Could not find element: ${selector}`,
|
||||
hint ?? 'The page UI may have changed. Please report this issue.',
|
||||
EXIT_CODES.GENERIC_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utilities ───────────────────────────────────────────────────────────
|
||||
// ── Utilities ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Extract a human-readable message from an unknown caught value. */
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
|
||||
+3
-2
@@ -19,6 +19,7 @@ import { shouldUseBrowserSession } from './capabilityRouting.js';
|
||||
import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
|
||||
import { emitHook, type HookContext } from './hooks.js';
|
||||
import { checkDaemonStatus } from './browser/discover.js';
|
||||
import { log } from './logger.js';
|
||||
|
||||
const _loadedModules = new Set<string>();
|
||||
|
||||
@@ -191,14 +192,14 @@ export async function executeCommand(
|
||||
if (preNavUrl) {
|
||||
const skip = await isAlreadyOnDomain(page, preNavUrl);
|
||||
if (skip) {
|
||||
if (debug) console.error(`[pre-nav] Already on target domain, skipping navigation`);
|
||||
if (debug) log.debug('[pre-nav] Already on target domain, skipping navigation');
|
||||
} else {
|
||||
try {
|
||||
// goto() already includes smart DOM-settle detection (waitForDomStable).
|
||||
// No additional fixed sleep needed.
|
||||
await page.goto(preNavUrl);
|
||||
} catch (err) {
|
||||
if (debug) console.error(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
|
||||
if (debug) log.debug(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,3 +21,19 @@
|
||||
tags: [docker, containers, devops]
|
||||
install:
|
||||
mac: "brew install --cask docker"
|
||||
|
||||
- name: lark-cli
|
||||
binary: lark-cli
|
||||
description: "Lark/Feishu CLI — messages, documents, spreadsheets, calendar, tasks and 200+ commands for AI agents"
|
||||
homepage: "https://github.com/larksuite/cli"
|
||||
tags: [lark, feishu, collaboration, productivity, ai-agent]
|
||||
install:
|
||||
default: "npm install -g @larksuite/cli"
|
||||
|
||||
- name: vercel
|
||||
binary: vercel
|
||||
description: "Vercel CLI — deploy projects, manage domains, env vars, logs and serverless functions"
|
||||
homepage: "https://vercel.com/docs/cli"
|
||||
tags: [vercel, deployment, serverless, frontend, devops]
|
||||
install:
|
||||
default: "npm install -g vercel"
|
||||
|
||||
+3
-3
@@ -6,7 +6,7 @@ import { spawnSync, execFileSync } from 'node:child_process';
|
||||
import yaml from 'js-yaml';
|
||||
import chalk from 'chalk';
|
||||
import { log } from './logger.js';
|
||||
import { getErrorMessage } from './errors.js';
|
||||
import { EXIT_CODES, getErrorMessage } from './errors.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -180,7 +180,7 @@ export function executeExternalCli(name: string, args: string[], preloaded?: Ext
|
||||
// 2. Try to auto install
|
||||
const success = installExternalCli(cli);
|
||||
if (!success) {
|
||||
process.exitCode = 1;
|
||||
process.exitCode = EXIT_CODES.SERVICE_UNAVAIL;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -189,7 +189,7 @@ export function executeExternalCli(name: string, args: string[], preloaded?: Ext
|
||||
const result = spawnSync(cli.binary, args, { stdio: 'inherit' });
|
||||
if (result.error) {
|
||||
console.error(chalk.red(`Failed to execute '${cli.binary}': ${result.error.message}`));
|
||||
process.exitCode = 1;
|
||||
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -20,7 +20,11 @@ import { discoverClis, discoverPlugins } from './discovery.js';
|
||||
import { getCompletions } from './completion.js';
|
||||
import { runCli } from './cli.js';
|
||||
import { emitHook } from './hooks.js';
|
||||
import { installNodeNetwork } from './node-network.js';
|
||||
import { registerUpdateNoticeOnExit, checkForUpdateBackground } from './update-check.js';
|
||||
import { EXIT_CODES } from './errors.js';
|
||||
|
||||
installNodeNetwork();
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -54,7 +58,7 @@ if (getCompIdx !== -1) {
|
||||
if (cursor === undefined) cursor = words.length;
|
||||
const candidates = getCompletions(words, cursor);
|
||||
process.stdout.write(candidates.join('\n') + '\n');
|
||||
process.exit(0);
|
||||
process.exit(EXIT_CODES.SUCCESS);
|
||||
}
|
||||
|
||||
await emitHook('onStartup', { command: '__startup__', args: {} });
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { decideProxy, hasProxyEnv } from './node-network.js';
|
||||
|
||||
describe('node network proxy decisions', () => {
|
||||
it('detects common proxy env variables', () => {
|
||||
expect(hasProxyEnv({ https_proxy: 'http://127.0.0.1:7897' })).toBe(true);
|
||||
expect(hasProxyEnv({ HTTP_PROXY: 'http://proxy.example:8080' })).toBe(true);
|
||||
expect(hasProxyEnv({})).toBe(false);
|
||||
});
|
||||
|
||||
it('routes external https traffic through https_proxy', () => {
|
||||
const decision = decideProxy(
|
||||
new URL('https://www.v2ex.com/api/topics/latest.json'),
|
||||
{ https_proxy: 'http://127.0.0.1:7897' },
|
||||
);
|
||||
|
||||
expect(decision).toEqual({
|
||||
mode: 'proxy',
|
||||
proxyUrl: 'http://127.0.0.1:7897',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to HTTP_PROXY for https traffic when HTTPS_PROXY is absent', () => {
|
||||
const decision = decideProxy(
|
||||
new URL('https://www.v2ex.com/api/topics/latest.json'),
|
||||
{ HTTP_PROXY: 'http://127.0.0.1:7897' },
|
||||
);
|
||||
|
||||
expect(decision).toEqual({
|
||||
mode: 'proxy',
|
||||
proxyUrl: 'http://127.0.0.1:7897',
|
||||
});
|
||||
});
|
||||
|
||||
it('bypasses proxies for loopback addresses', () => {
|
||||
const env = { https_proxy: 'http://127.0.0.1:7897', http_proxy: 'http://127.0.0.1:7897' };
|
||||
|
||||
expect(decideProxy(new URL('http://127.0.0.1:19825/status'), env)).toEqual({ mode: 'direct' });
|
||||
expect(decideProxy(new URL('http://localhost:19825/status'), env)).toEqual({ mode: 'direct' });
|
||||
expect(decideProxy(new URL('http://[::1]:19825/status'), env)).toEqual({ mode: 'direct' });
|
||||
});
|
||||
|
||||
it('honors NO_PROXY domain matches', () => {
|
||||
const decision = decideProxy(
|
||||
new URL('https://api.example.com/v1/items'),
|
||||
{
|
||||
https_proxy: 'http://127.0.0.1:7897',
|
||||
no_proxy: '.example.com',
|
||||
},
|
||||
);
|
||||
|
||||
expect(decision).toEqual({ mode: 'direct' });
|
||||
});
|
||||
|
||||
it('supports wildcard-style NO_PROXY subdomain entries', () => {
|
||||
const decision = decideProxy(
|
||||
new URL('https://api.example.com/v1/items'),
|
||||
{
|
||||
https_proxy: 'http://127.0.0.1:7897',
|
||||
no_proxy: '*.example.com',
|
||||
},
|
||||
);
|
||||
|
||||
expect(decision).toEqual({ mode: 'direct' });
|
||||
});
|
||||
|
||||
it('matches NO_PROXY entries that rely on the default URL port', () => {
|
||||
const env = { https_proxy: 'http://127.0.0.1:7897', http_proxy: 'http://127.0.0.1:7897' };
|
||||
|
||||
expect(decideProxy(
|
||||
new URL('https://example.com/'),
|
||||
{ ...env, NO_PROXY: 'example.com:443' },
|
||||
)).toEqual({ mode: 'direct' });
|
||||
|
||||
expect(decideProxy(
|
||||
new URL('http://example.com/health'),
|
||||
{ ...env, NO_PROXY: 'example.com:80' },
|
||||
)).toEqual({ mode: 'direct' });
|
||||
});
|
||||
|
||||
it('falls back to ALL_PROXY when protocol-specific settings are absent', () => {
|
||||
const decision = decideProxy(
|
||||
new URL('http://example.net/data'),
|
||||
{ ALL_PROXY: 'socks5://127.0.0.1:1080' },
|
||||
);
|
||||
|
||||
expect(decision).toEqual({
|
||||
mode: 'proxy',
|
||||
proxyUrl: 'socks5://127.0.0.1:1080',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { Agent, EnvHttpProxyAgent, fetch as undiciFetch, type Dispatcher } from 'undici';
|
||||
|
||||
const LOOPBACK_NO_PROXY_ENTRIES = ['127.0.0.1', 'localhost', '::1'];
|
||||
|
||||
type ProxyEnvKey =
|
||||
| 'http_proxy'
|
||||
| 'https_proxy'
|
||||
| 'all_proxy'
|
||||
| 'HTTP_PROXY'
|
||||
| 'HTTPS_PROXY'
|
||||
| 'ALL_PROXY';
|
||||
|
||||
const PROXY_ENV_BY_PROTOCOL: Record<'http:' | 'https:', ProxyEnvKey[]> = {
|
||||
'http:': ['http_proxy', 'HTTP_PROXY', 'all_proxy', 'ALL_PROXY'],
|
||||
'https:': ['https_proxy', 'HTTPS_PROXY', 'all_proxy', 'ALL_PROXY'],
|
||||
};
|
||||
const DEFAULT_PORT_BY_PROTOCOL: Record<'http:' | 'https:', string> = {
|
||||
'http:': '80',
|
||||
'https:': '443',
|
||||
};
|
||||
|
||||
export interface ProxyDecision {
|
||||
mode: 'direct' | 'proxy';
|
||||
proxyUrl?: string;
|
||||
}
|
||||
|
||||
interface NoProxyEntry {
|
||||
host: string;
|
||||
port?: string;
|
||||
}
|
||||
|
||||
interface ProxyConfig {
|
||||
httpProxy?: string;
|
||||
httpsProxy?: string;
|
||||
noProxy?: string;
|
||||
noProxyEntries: NoProxyEntry[];
|
||||
}
|
||||
|
||||
let installed = false;
|
||||
const directDispatcher = new Agent();
|
||||
const proxyDispatcherCache = new Map<string, Dispatcher>();
|
||||
const nativeFetch = globalThis.fetch.bind(globalThis);
|
||||
|
||||
function readEnv(env: NodeJS.ProcessEnv, lower: string, upper: string): string | undefined {
|
||||
const lowerValue = env[lower];
|
||||
if (typeof lowerValue === 'string' && lowerValue.trim() !== '') return lowerValue;
|
||||
const upperValue = env[upper];
|
||||
if (typeof upperValue === 'string' && upperValue.trim() !== '') return upperValue;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readProxyEnv(env: NodeJS.ProcessEnv, keys: ProxyEnvKey[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = env[key];
|
||||
if (typeof value === 'string' && value.trim() !== '') return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeHostname(hostname: string): string {
|
||||
return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
|
||||
}
|
||||
|
||||
function splitNoProxy(raw: string | undefined): string[] {
|
||||
return (raw ?? '')
|
||||
.split(/[,\s]+/)
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseNoProxyEntry(entry: string): NoProxyEntry {
|
||||
if (entry === '*') return { host: '*' };
|
||||
|
||||
const trimmed = entry.trim().replace(/^\*?\./, '');
|
||||
if (trimmed.startsWith('[')) {
|
||||
const end = trimmed.indexOf(']');
|
||||
if (end !== -1) {
|
||||
const host = trimmed.slice(1, end);
|
||||
const rest = trimmed.slice(end + 1);
|
||||
if (rest.startsWith(':')) return { host: normalizeHostname(host), port: rest.slice(1) };
|
||||
return { host: normalizeHostname(host) };
|
||||
}
|
||||
}
|
||||
|
||||
const colonCount = (trimmed.match(/:/g) ?? []).length;
|
||||
if (colonCount === 1) {
|
||||
const [host, port] = trimmed.split(':');
|
||||
return { host: normalizeHostname(host), port };
|
||||
}
|
||||
|
||||
return { host: normalizeHostname(trimmed) };
|
||||
}
|
||||
|
||||
function effectiveNoProxyEntries(env: NodeJS.ProcessEnv): NoProxyEntry[] {
|
||||
const raw = readEnv(env, 'no_proxy', 'NO_PROXY');
|
||||
const entries = splitNoProxy(raw).map(parseNoProxyEntry);
|
||||
const seen = new Set(entries.map((entry) => `${entry.host}:${entry.port ?? ''}`));
|
||||
for (const rawEntry of LOOPBACK_NO_PROXY_ENTRIES) {
|
||||
const entry = parseNoProxyEntry(rawEntry);
|
||||
const key = `${entry.host}:${entry.port ?? ''}`;
|
||||
if (seen.has(key)) continue;
|
||||
entries.push(entry);
|
||||
seen.add(key);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function serializeNoProxyEntry(entry: NoProxyEntry): string {
|
||||
if (entry.host === '*') return '*';
|
||||
|
||||
const host = entry.host.includes(':') ? `[${entry.host}]` : entry.host;
|
||||
return entry.port ? `${host}:${entry.port}` : host;
|
||||
}
|
||||
|
||||
function effectiveNoProxyValue(entries: NoProxyEntry[]): string | undefined {
|
||||
if (entries.length === 0) return undefined;
|
||||
|
||||
return entries.map(serializeNoProxyEntry).join(',');
|
||||
}
|
||||
|
||||
function matchesNoProxyEntry(url: URL, entry: NoProxyEntry): boolean {
|
||||
const { host, port } = entry;
|
||||
if (host === '*') return true;
|
||||
|
||||
const hostname = normalizeHostname(url.hostname);
|
||||
const urlPort = url.port || DEFAULT_PORT_BY_PROTOCOL[url.protocol as 'http:' | 'https:'] || undefined;
|
||||
if (port && port !== urlPort) return false;
|
||||
return hostname === host || hostname.endsWith(`.${host}`);
|
||||
}
|
||||
|
||||
function resolveProxyConfig(env: NodeJS.ProcessEnv = process.env): ProxyConfig {
|
||||
const noProxyEntries = effectiveNoProxyEntries(env);
|
||||
return {
|
||||
httpProxy: readProxyEnv(env, PROXY_ENV_BY_PROTOCOL['http:']),
|
||||
httpsProxy: readProxyEnv(env, [
|
||||
'https_proxy',
|
||||
'HTTPS_PROXY',
|
||||
'http_proxy',
|
||||
'HTTP_PROXY',
|
||||
'all_proxy',
|
||||
'ALL_PROXY',
|
||||
]),
|
||||
noProxy: effectiveNoProxyValue(noProxyEntries),
|
||||
noProxyEntries,
|
||||
};
|
||||
}
|
||||
|
||||
function createProxyDispatcher(config: ProxyConfig): Dispatcher {
|
||||
const cacheKey = JSON.stringify([
|
||||
config.httpProxy ?? '',
|
||||
config.httpsProxy ?? '',
|
||||
config.noProxy ?? '',
|
||||
]);
|
||||
const cached = proxyDispatcherCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
const dispatcher = new EnvHttpProxyAgent({
|
||||
httpProxy: config.httpProxy,
|
||||
httpsProxy: config.httpsProxy,
|
||||
noProxy: config.noProxy,
|
||||
});
|
||||
proxyDispatcherCache.set(cacheKey, dispatcher);
|
||||
return dispatcher;
|
||||
}
|
||||
|
||||
function resolveUrl(input: RequestInfo | URL): URL | null {
|
||||
if (typeof input === 'string') return new URL(input);
|
||||
if (input instanceof URL) return input;
|
||||
if (typeof Request !== 'undefined' && input instanceof Request) return new URL(input.url);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasProxyEnv(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const config = resolveProxyConfig(env);
|
||||
return Boolean(config.httpProxy || config.httpsProxy);
|
||||
}
|
||||
|
||||
export function decideProxy(url: URL, env: NodeJS.ProcessEnv = process.env): ProxyDecision {
|
||||
const config = resolveProxyConfig(env);
|
||||
if (config.noProxyEntries.some((entry) => matchesNoProxyEntry(url, entry))) {
|
||||
return { mode: 'direct' };
|
||||
}
|
||||
|
||||
const proxyUrl = url.protocol === 'https:' ? config.httpsProxy : config.httpProxy;
|
||||
if (!proxyUrl) return { mode: 'direct' };
|
||||
return { mode: 'proxy', proxyUrl };
|
||||
}
|
||||
|
||||
export function getDispatcherForUrl(url: URL, env: NodeJS.ProcessEnv = process.env): Dispatcher {
|
||||
const config = resolveProxyConfig(env);
|
||||
if (!config.httpProxy && !config.httpsProxy) return directDispatcher;
|
||||
return createProxyDispatcher(config);
|
||||
}
|
||||
|
||||
export async function fetchWithNodeNetwork(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
|
||||
const url = resolveUrl(input);
|
||||
if (!url || !hasProxyEnv()) {
|
||||
return nativeFetch(input, init);
|
||||
}
|
||||
|
||||
return (await undiciFetch(input as Parameters<typeof undiciFetch>[0], {
|
||||
...init,
|
||||
dispatcher: getDispatcherForUrl(url),
|
||||
} as Parameters<typeof undiciFetch>[1])) as unknown as Response;
|
||||
}
|
||||
|
||||
export function installNodeNetwork(): void {
|
||||
if (installed) return;
|
||||
|
||||
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => (
|
||||
fetchWithNodeNetwork(input, init)
|
||||
)) as typeof globalThis.fetch;
|
||||
installed = true;
|
||||
}
|
||||
@@ -31,6 +31,7 @@ function createMockPage(overrides: Partial<IPage> = {}): IPage {
|
||||
installInterceptor: vi.fn(),
|
||||
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
||||
screenshot: vi.fn().mockResolvedValue(''),
|
||||
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ function createMockPage(getCookies: IPage['getCookies']): IPage {
|
||||
installInterceptor: vi.fn(),
|
||||
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
||||
screenshot: vi.fn().mockResolvedValue(''),
|
||||
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
import type { IPage } from '../../types.js';
|
||||
import { render, normalizeEvaluateSource } from '../template.js';
|
||||
import { generateInterceptorJs, generateReadInterceptedJs } from '../../interceptor.js';
|
||||
|
||||
export async function stepIntercept(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
|
||||
const cfg = typeof params === 'object' ? params : {};
|
||||
@@ -16,7 +15,7 @@ export async function stepIntercept(page: IPage | null, params: any, data: any,
|
||||
if (!capturePattern) return data;
|
||||
|
||||
// Step 1: Inject fetch/XHR interceptor BEFORE trigger
|
||||
await page!.evaluate(generateInterceptorJs(JSON.stringify(capturePattern)));
|
||||
await page!.installInterceptor(capturePattern);
|
||||
|
||||
// Step 2: Execute the trigger action
|
||||
if (trigger.startsWith('navigate:')) {
|
||||
@@ -32,11 +31,11 @@ export async function stepIntercept(page: IPage | null, params: any, data: any,
|
||||
await page!.scroll('down');
|
||||
}
|
||||
|
||||
// Step 3: Wait a bit for network requests to fire
|
||||
await page!.wait(Math.min(timeout, 3));
|
||||
// Step 3: Wait for network capture (event-driven, not fixed sleep)
|
||||
await page!.waitForCapture(timeout);
|
||||
|
||||
// Step 4: Retrieve captured data
|
||||
const matchingResponses = await page!.evaluate(generateReadInterceptedJs());
|
||||
const matchingResponses = await page!.getInterceptedRequests();
|
||||
|
||||
// Step 5: Select from response if specified
|
||||
let result = matchingResponses.length === 1 ? matchingResponses[0] :
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { CliCommand } from './registry.js';
|
||||
import { Strategy } from './registry.js';
|
||||
import { formatRegistryHelpText } from './serialization.js';
|
||||
|
||||
describe('formatRegistryHelpText', () => {
|
||||
it('summarizes long choices lists so help text stays readable', () => {
|
||||
const cmd: CliCommand = {
|
||||
site: 'demo',
|
||||
name: 'dynamic',
|
||||
description: 'Demo command',
|
||||
strategy: Strategy.PUBLIC,
|
||||
browser: false,
|
||||
args: [
|
||||
{
|
||||
name: 'field',
|
||||
help: 'Field to use',
|
||||
choices: ['all-fields', 'topic', 'title', 'author', 'publication-titles', 'year-published', 'doi'],
|
||||
},
|
||||
],
|
||||
columns: ['field'],
|
||||
};
|
||||
|
||||
expect(formatRegistryHelpText(cmd)).toContain('--field: all-fields, topic, title, author, ... (+3 more)');
|
||||
});
|
||||
});
|
||||
@@ -62,6 +62,11 @@ export function formatArgSummary(args: Arg[]): string {
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function summarizeChoices(choices: string[]): string {
|
||||
if (choices.length <= 4) return choices.join(', ');
|
||||
return `${choices.slice(0, 4).join(', ')}, ... (+${choices.length - 4} more)`;
|
||||
}
|
||||
|
||||
/** Generate the --help appendix showing registry metadata not exposed by Commander. */
|
||||
export function formatRegistryHelpText(cmd: CliCommand): string {
|
||||
const lines: string[] = [];
|
||||
@@ -69,7 +74,7 @@ export function formatRegistryHelpText(cmd: CliCommand): string {
|
||||
for (const a of choicesArgs) {
|
||||
const prefix = a.positional ? `<${a.name}>` : `--${a.name}`;
|
||||
const def = a.default != null ? ` (default: ${a.default})` : '';
|
||||
lines.push(` ${prefix}: ${a.choices!.join(', ')}${def}`);
|
||||
lines.push(` ${prefix}: ${summarizeChoices(a.choices!)}${def}`);
|
||||
}
|
||||
const meta: string[] = [];
|
||||
meta.push(`Strategy: ${strategyLabel(cmd)}`);
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@
|
||||
* Uses raw stdin mode + ANSI escape codes for interactive prompts.
|
||||
*/
|
||||
import chalk from 'chalk';
|
||||
import { EXIT_CODES } from './errors.js';
|
||||
|
||||
export interface CheckboxItem {
|
||||
label: string;
|
||||
@@ -161,7 +162,7 @@ export async function checkboxPrompt(
|
||||
// Ctrl+C — exit process
|
||||
if (key === '\x03') {
|
||||
cleanup();
|
||||
process.exit(130);
|
||||
process.exit(EXIT_CODES.INTERRUPTED);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface SnapshotOptions {
|
||||
|
||||
export interface WaitOptions {
|
||||
text?: string;
|
||||
selector?: string; // wait until document.querySelector(selector) matches
|
||||
time?: number;
|
||||
timeout?: number;
|
||||
}
|
||||
@@ -64,6 +65,7 @@ export interface IPage {
|
||||
autoScroll(options?: { times?: number; delayMs?: number }): Promise<void>;
|
||||
installInterceptor(pattern: string): Promise<void>;
|
||||
getInterceptedRequests(): Promise<any[]>;
|
||||
waitForCapture(timeout?: number): Promise<void>;
|
||||
screenshot(options?: ScreenshotOptions): Promise<string>;
|
||||
closeWindow?(): Promise<void>;
|
||||
/** Returns the current page URL, or null if unavailable. */
|
||||
|
||||
@@ -31,6 +31,11 @@ export async function mapConcurrent<T, R>(
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Pause for the given number of milliseconds. */
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** Save a base64-encoded string to a file, creating parent directories as needed. */
|
||||
export async function saveBase64ToFile(base64: string, filePath: string): Promise<void> {
|
||||
const dir = path.dirname(filePath);
|
||||
|
||||
@@ -101,6 +101,6 @@ describe('management commands E2E', () => {
|
||||
// ── unknown command ──
|
||||
it('unknown command shows error', async () => {
|
||||
const { stderr, code } = await runCli(['nonexistent-command-xyz']);
|
||||
expect(code).toBe(1);
|
||||
expect(code).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* E2E tests for output format rendering.
|
||||
* Uses hackernews (public, fast) as a stable data source.
|
||||
* Uses the built-in list command so renderer coverage does not depend on
|
||||
* external network availability.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
@@ -10,19 +11,22 @@ const FORMATS = ['json', 'yaml', 'csv', 'md'] as const;
|
||||
|
||||
describe('output formats E2E', () => {
|
||||
for (const fmt of FORMATS) {
|
||||
it(`hackernews top -f ${fmt} produces valid output`, async () => {
|
||||
const { stdout, code } = await runCli(['hackernews', 'top', '--limit', '2', '-f', fmt]);
|
||||
it(`list -f ${fmt} produces valid output`, async () => {
|
||||
const { stdout, code } = await runCli(['list', '-f', fmt]);
|
||||
expect(code).toBe(0);
|
||||
expect(stdout.trim().length).toBeGreaterThan(0);
|
||||
|
||||
if (fmt === 'json') {
|
||||
const data = parseJsonOutput(stdout);
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
expect(data.length).toBe(2);
|
||||
expect(data.length).toBeGreaterThan(50);
|
||||
expect(data[0]).toHaveProperty('command');
|
||||
expect(data[0]).toHaveProperty('site');
|
||||
}
|
||||
|
||||
if (fmt === 'yaml') {
|
||||
expect(stdout).toContain('title:');
|
||||
expect(stdout).toContain('command:');
|
||||
expect(stdout).toContain('site:');
|
||||
}
|
||||
|
||||
if (fmt === 'csv') {
|
||||
@@ -33,16 +37,8 @@ describe('output formats E2E', () => {
|
||||
|
||||
if (fmt === 'md') {
|
||||
// Markdown table should have pipe characters
|
||||
expect(stdout).toContain('|');
|
||||
expect(stdout).toContain('| command |');
|
||||
}
|
||||
}, 30_000);
|
||||
}
|
||||
|
||||
it('list -f csv produces valid csv', async () => {
|
||||
const { stdout, code } = await runCli(['list', '-f', 'csv']);
|
||||
expect(code).toBe(0);
|
||||
const lines = stdout.trim().split('\n');
|
||||
// Header + many data lines
|
||||
expect(lines.length).toBeGreaterThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,7 +60,10 @@ describe('plugin management E2E', () => {
|
||||
const lock = JSON.parse(fs.readFileSync(LOCK_FILE, 'utf-8'));
|
||||
expect(lock[PLUGIN_NAME]).toBeDefined();
|
||||
expect(lock[PLUGIN_NAME].commitHash).toBeTruthy();
|
||||
expect(lock[PLUGIN_NAME].source).toContain('opencli-plugin-hot-digest');
|
||||
expect(lock[PLUGIN_NAME].source).toMatchObject({
|
||||
kind: 'git',
|
||||
});
|
||||
expect(lock[PLUGIN_NAME].source.url).toContain('opencli-plugin-hot-digest');
|
||||
expect(lock[PLUGIN_NAME].installedAt).toBeTruthy();
|
||||
}, 60_000);
|
||||
|
||||
@@ -131,7 +134,7 @@ describe('plugin management E2E', () => {
|
||||
|
||||
it('plugin update without name or --all shows error', async () => {
|
||||
const { stderr, code } = await runPluginCli(['plugin', 'update']);
|
||||
expect(code).toBe(1);
|
||||
expect(code).toBe(2);
|
||||
expect(stderr).toContain('specify a plugin name');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ function isExpectedChineseSiteRestriction(code: number, stderr: string): boolean
|
||||
|
||||
function isExpectedApplePodcastsRestriction(code: number, stderr: string): boolean {
|
||||
if (code === 0) return false;
|
||||
return /Error \[FETCH_ERROR\]: (Charts API HTTP \d+|Unable to reach Apple Podcasts charts)/.test(stderr)
|
||||
return /(?:Error \[FETCH_ERROR\]: )?(Charts API HTTP \d+|Unable to reach Apple Podcasts charts)/.test(stderr)
|
||||
|| stderr === ''; // timeout killed the process before any output
|
||||
}
|
||||
|
||||
@@ -34,6 +34,17 @@ function isExpectedGoogleRestriction(code: number, stderr: string): boolean {
|
||||
// Keep old name as alias for existing tests
|
||||
const isExpectedXiaoyuzhouRestriction = isExpectedChineseSiteRestriction;
|
||||
|
||||
describe('public command restriction detectors', () => {
|
||||
it('treats current Apple Podcasts CliError rendering as an expected restriction', () => {
|
||||
expect(
|
||||
isExpectedApplePodcastsRestriction(
|
||||
1,
|
||||
'⚠️ Unable to reach Apple Podcasts charts for US\n→ Apple charts may be temporarily unavailable (ECONNRESET). Try again later.\n',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public commands E2E', () => {
|
||||
// ── bloomberg (RSS-backed, browser: false) ──
|
||||
it('bloomberg main returns structured headline data', async () => {
|
||||
|
||||
Reference in New Issue
Block a user