tabbridge-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +179 -0
- package/README.zh-CN.md +177 -0
- package/broker.js +306 -0
- package/main.js +599 -0
- package/package.json +21 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TabBridge contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# TabBridge
|
|
2
|
+
|
|
3
|
+
English · [简体中文](README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
TabBridge is a CLI tool and Skill designed for AI Agents: after user authorization, it allows AI agents to operate on **tabs you already have open** in your real browser through a browser extension—instead of launching a fresh browser instance like Playwright, Puppeteer, or Agent Browser.
|
|
8
|
+
|
|
9
|
+
## Core Positioning
|
|
10
|
+
|
|
11
|
+
> Don't open a new browser instance. Connect the agent to your already-open tabs and let AI seamlessly take over.
|
|
12
|
+
|
|
13
|
+
Traditional browser automation tools spin up a new browser process, which means:
|
|
14
|
+
|
|
15
|
+
- Already-logged-in accounts must log in again
|
|
16
|
+
- Cookies, localStorage, and session state are lost
|
|
17
|
+
- Additional authentication steps are required (SMS, 2FA, QR code scans)
|
|
18
|
+
|
|
19
|
+
TabBridge is different: it takes over **tabs that are already running in your real browser**, letting AI continue working within your existing session.
|
|
20
|
+
|
|
21
|
+
## Why TabBridge
|
|
22
|
+
|
|
23
|
+
| Capability | Playwright / Puppeteer / Agent Browser | TabBridge |
|
|
24
|
+
|------------|----------------------------------------|-----------|
|
|
25
|
+
| Target | Fresh browser instance | Your already-open tabs |
|
|
26
|
+
| Login state | Must re-authenticate | Inherits current session |
|
|
27
|
+
| Deployment | Needs browser driver or remote infra | Local CLI + browser extension |
|
|
28
|
+
| AI-friendliness | Raw HTML, high token cost | Structured Snapshot, token-efficient |
|
|
29
|
+
| Dangerous actions | Usually none | Coordinate actions require user confirmation |
|
|
30
|
+
|
|
31
|
+
## Key Features
|
|
32
|
+
|
|
33
|
+
- **Session takeover**: connect to the currently active tab or any already-open tab.
|
|
34
|
+
- **AI-friendly Snapshot**: converts a page into a structured semantic view with interactive element refs (`@e1`, `@e2`…), helping large models understand pages faster while consuming fewer tokens.
|
|
35
|
+
- **Ref-based actions**: perform `click`, `fill`, `type`, `select`, `check`, and more using semantic refs from the latest snapshot.
|
|
36
|
+
- **Fine-grained permissions**: explicit user authorization is required before reading or acting; grants expire after 30 minutes and re-authorization is needed when a tab navigates to a different origin. Approval requests shown in the extension popup expire after 5 minutes if not acted on.
|
|
37
|
+
- **High-risk action confirmation**: coordinate actions (`click-coordinates`, `drag-coordinates`) must be manually confirmed in the browser extension popup.
|
|
38
|
+
- **Local-first**: the CLI communicates with the browser extension through a local WebSocket broker, with no reliance on cloud browser farms.
|
|
39
|
+
|
|
40
|
+
## Architecture Overview
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
44
|
+
│ AI Agent (via Skill) │
|
|
45
|
+
│ invokes `tabbridge` commands │
|
|
46
|
+
└───────────────────────────┬─────────────────────────────────┘
|
|
47
|
+
│ process spawn
|
|
48
|
+
┌───────────────────────────▼─────────────────────────────────┐
|
|
49
|
+
│ TabBridge CLI (Node.js) │
|
|
50
|
+
└───────────────────────────┬─────────────────────────────────┘
|
|
51
|
+
│ WebSocket
|
|
52
|
+
┌───────────────────────────▼─────────────────────────────────┐
|
|
53
|
+
│ TabBridge Broker │
|
|
54
|
+
│ (local WebSocket server) │
|
|
55
|
+
└───────────────────────────┬─────────────────────────────────┘
|
|
56
|
+
│ WebSocket
|
|
57
|
+
┌───────────────────────────▼─────────────────────────────────┐
|
|
58
|
+
│ Chrome Extension │
|
|
59
|
+
│ (offscreen document + content script + popup) │
|
|
60
|
+
└───────────────────────────┬─────────────────────────────────┘
|
|
61
|
+
│
|
|
62
|
+
┌───────────────────────────▼─────────────────────────────────┐
|
|
63
|
+
│ User's Webpage │
|
|
64
|
+
│ (already-open tab) │
|
|
65
|
+
└─────────────────────────────────────────────────────────────┘
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The first `tabbridge` command automatically starts the local broker; you do not need to run it manually.
|
|
69
|
+
|
|
70
|
+
## Quick Start
|
|
71
|
+
|
|
72
|
+
### Prerequisites
|
|
73
|
+
|
|
74
|
+
- Node.js (LTS recommended)
|
|
75
|
+
- Chrome / Chromium 116+
|
|
76
|
+
- macOS, Windows, or Linux
|
|
77
|
+
|
|
78
|
+
### 1. Install the CLI
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npm install -g tabbridge-cli
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
After installation, the `tabbridge` command is available globally.
|
|
85
|
+
|
|
86
|
+
### 2. Install the browser extension
|
|
87
|
+
|
|
88
|
+
> The extension is not yet on the Chrome Web Store. Until it is published, you need to build and load it manually from source.
|
|
89
|
+
|
|
90
|
+
Clone the repository, install workspace dependencies from the monorepo root, build the extension with WXT, and load it into Chrome:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
pnpm install
|
|
94
|
+
pnpm --filter @tabbridge/chrome-extension build
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Then open `chrome://extensions`, enable **Developer mode**, click **Load unpacked**, and select `packages/chrome-extension/dist/chrome-mv3/` (the directory that contains `manifest.json`).
|
|
98
|
+
|
|
99
|
+
When you run `tabbridge tabs request-access`, approve the site in the extension popup. You can also revoke or manage access later in the extension details page.
|
|
100
|
+
|
|
101
|
+
Note: the extension popup UI is currently in Chinese. Approval buttons are 允许 (Allow), 允许一次 (Allow once), and 拒绝 (Deny).
|
|
102
|
+
|
|
103
|
+
For development, run `pnpm --filter @tabbridge/chrome-extension dev`; WXT outputs to `packages/chrome-extension/dist/chrome-mv3-dev/`.
|
|
104
|
+
|
|
105
|
+
### 3. Connect and operate a page
|
|
106
|
+
|
|
107
|
+
Make sure the active tab is a normal `http://` or `https://` page. Internal Chrome pages, extension pages, file URLs, and `about:` pages are not supported in the current version.
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
# Check connection status
|
|
111
|
+
tabbridge status --json
|
|
112
|
+
|
|
113
|
+
# List controllable tabs
|
|
114
|
+
tabbridge tabs list --json
|
|
115
|
+
|
|
116
|
+
# Connect to the currently active tab
|
|
117
|
+
tabbridge connect --json
|
|
118
|
+
|
|
119
|
+
# Request access to the page
|
|
120
|
+
tabbridge tabs request-access --tab <tabId> --reason "Read page content" --json
|
|
121
|
+
|
|
122
|
+
# Get an AI-friendly snapshot (`-i` is accepted for compatibility but ignored)
|
|
123
|
+
tabbridge snapshot -i --json
|
|
124
|
+
|
|
125
|
+
# Perform an action
|
|
126
|
+
tabbridge click @e1 --json
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Snapshot: A Page View Optimized for LLMs
|
|
130
|
+
|
|
131
|
+
`tabbridge snapshot` does not dump the entire DOM to the model. Instead, it generates a compact semantic representation:
|
|
132
|
+
|
|
133
|
+
```text
|
|
134
|
+
Page: Example
|
|
135
|
+
URL: https://example.com
|
|
136
|
+
|
|
137
|
+
@e1 [button] "Save"
|
|
138
|
+
@e2 [textbox] placeholder="Comment"
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Each `@eN` ref is a temporary label for the current snapshot. The AI can use it to precisely refer to elements without parsing verbose HTML or CSS selectors. Refs are volatile and reassigned on every snapshot, ensuring actions are always based on the latest page state.
|
|
142
|
+
|
|
143
|
+
## Security & Approval Boundaries
|
|
144
|
+
|
|
145
|
+
TabBridge puts the security of your browser context first:
|
|
146
|
+
|
|
147
|
+
- **Site authorization**: each origin requires separate user approval.
|
|
148
|
+
- **Session isolation**: no extraction of cookies, localStorage, credentials, or tokens.
|
|
149
|
+
- **No arbitrary code execution**: injecting arbitrary JavaScript or intercepting network traffic is not allowed.
|
|
150
|
+
- **High-risk confirmation**: coordinate actions require confirmation through the browser extension popup.
|
|
151
|
+
- **No silent fallback**: ref actions are never silently replaced by coordinate actions.
|
|
152
|
+
- **No secrets in argv**: never pass passwords, 2FA codes, payment details, credentials, or tokens as CLI arguments. Use `type --text-stdin` / `fill --text-stdin` for sensitive input.
|
|
153
|
+
|
|
154
|
+
## Project Structure
|
|
155
|
+
|
|
156
|
+
```text
|
|
157
|
+
tabbridge/
|
|
158
|
+
├── packages/
|
|
159
|
+
│ ├── cli/ # TabBridge CLI (the `tabbridge` command)
|
|
160
|
+
│ ├── chrome-extension/ # Chrome / Chromium browser extension
|
|
161
|
+
│ ├── broker/ # Local WebSocket bridge service
|
|
162
|
+
│ └── shared/ # Shared types and error codes
|
|
163
|
+
├── skills/
|
|
164
|
+
│ └── tabbridge/ # Claude Skill and reference docs
|
|
165
|
+
└── docs/ # Design documents
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Development
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
pnpm test # run the full test suite
|
|
172
|
+
pnpm typecheck # run TypeScript checks
|
|
173
|
+
pnpm lint # run lint / type checks across packages
|
|
174
|
+
pnpm clean # remove build artifacts
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
[MIT](LICENSE)
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# TabBridge
|
|
2
|
+
|
|
3
|
+
简体中文 · [English](README.md)
|
|
4
|
+
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
TabBridge 是一款为 AI Agent 设计的 CLI 工具与 Skill:在获得用户授权后,它能让 AI 直接操作你**当前已经打开的浏览器页面**,通过浏览器插件而不是像 Playwright、Puppeteer 或 Agent Browser 那样启动一个全新的浏览器实例。
|
|
9
|
+
|
|
10
|
+
## 核心定位
|
|
11
|
+
|
|
12
|
+
> 不是新开一个浏览器实例,而是连接 Agent 与你已打开的网页,让 AI 无缝接手。
|
|
13
|
+
|
|
14
|
+
传统的浏览器自动化工具会新开一个浏览器进程或实例,导致:
|
|
15
|
+
|
|
16
|
+
- 已经登录的账号需要重新登录
|
|
17
|
+
- Cookie、localStorage、会话状态全部丢失
|
|
18
|
+
- 需要额外的认证流程(短信、2FA、扫码)
|
|
19
|
+
|
|
20
|
+
TabBridge 不同:它接管你**真实浏览器中正在运行的标签页**,让 AI 在你的现有会话上继续工作。
|
|
21
|
+
|
|
22
|
+
## 为什么是 TabBridge
|
|
23
|
+
|
|
24
|
+
| 能力 | Playwright / Puppeteer / Agent Browser | TabBridge |
|
|
25
|
+
|------|----------------------------------------|-----------|
|
|
26
|
+
| 操作对象 | 新开的浏览器实例 | 用户已打开的页面 |
|
|
27
|
+
| 登录态 | 需要重新登录 | 继承当前会话 |
|
|
28
|
+
| 部署方式 | 需要浏览器驱动或远程环境 | 本地 CLI + 浏览器插件 |
|
|
29
|
+
| 对 AI 友好度 | 原始 HTML,Token 消耗大 | 结构化 Snapshot,更省 Token |
|
|
30
|
+
| 危险操作审核 | 通常无 | 坐标类等高危操作需用户确认 |
|
|
31
|
+
|
|
32
|
+
## 主要特性
|
|
33
|
+
|
|
34
|
+
- **会话接管**:连接当前激活的标签页,或指定任意已打开的标签页。
|
|
35
|
+
- **AI 友好的 Snapshot**:将网页转换为结构化的语义视图,包含可交互元素引用(`@e1`、`@e2`…),让大模型更快理解页面,显著减少 Token 消耗。
|
|
36
|
+
- **Ref-based 操作**:基于 Snapshot 中的语义引用执行 `click`、`fill`、`type`、`select`、`check` 等操作。
|
|
37
|
+
- **细粒度权限控制**:读取页面、执行动作前需要用户显式授权;授权在 30 分钟后过期,且网页切换 origin 后需重新授权。浏览器插件弹窗中的授权请求若 5 分钟内未处理将会过期。
|
|
38
|
+
- **高危操作确认**:坐标类操作(`click-coordinates`、`drag-coordinates`)必须通过浏览器插件弹窗手动确认。
|
|
39
|
+
- **本地优先**:CLI 经本地 Broker 与浏览器插件建立 WebSocket 通信,不依赖云端浏览器集群。
|
|
40
|
+
|
|
41
|
+
## 架构概览
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
45
|
+
│ AI Agent(via Skill) │
|
|
46
|
+
│ 调用 `tabbridge` 命令行 │
|
|
47
|
+
└───────────────────────────┬─────────────────────────────────┘
|
|
48
|
+
│ 进程调用
|
|
49
|
+
┌───────────────────────────▼─────────────────────────────────┐
|
|
50
|
+
│ TabBridge CLI(Node.js) │
|
|
51
|
+
└───────────────────────────┬─────────────────────────────────┘
|
|
52
|
+
│ WebSocket
|
|
53
|
+
┌───────────────────────────▼─────────────────────────────────┐
|
|
54
|
+
│ TabBridge Broker │
|
|
55
|
+
│ (本地 WebSocket 服务) │
|
|
56
|
+
└───────────────────────────┬─────────────────────────────────┘
|
|
57
|
+
│ WebSocket
|
|
58
|
+
┌───────────────────────────▼─────────────────────────────────┐
|
|
59
|
+
│ Chrome Extension │
|
|
60
|
+
│ (离屏文档 + 内容脚本 + 浏览器弹窗) │
|
|
61
|
+
└───────────────────────────┬─────────────────────────────────┘
|
|
62
|
+
│
|
|
63
|
+
┌───────────────────────────▼─────────────────────────────────┐
|
|
64
|
+
│ 用户已打开的网页 │
|
|
65
|
+
└─────────────────────────────────────────────────────────────┘
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
首次运行任意 `tabbridge` 命令会自动启动本地 Broker,无需手动启动。
|
|
69
|
+
|
|
70
|
+
## 快速开始
|
|
71
|
+
|
|
72
|
+
### 前置条件
|
|
73
|
+
|
|
74
|
+
- Node.js(建议使用 LTS)
|
|
75
|
+
- Chrome / Chromium 116+
|
|
76
|
+
- macOS、Windows 或 Linux
|
|
77
|
+
|
|
78
|
+
### 1. 安装 CLI
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npm install -g tabbridge-cli
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
安装完成后,`tabbridge` 命令即可全局使用。
|
|
85
|
+
|
|
86
|
+
### 2. 安装浏览器插件
|
|
87
|
+
|
|
88
|
+
> 扩展目前尚未上架 Chrome Web Store。在上架之前,你需要从源码构建并手动加载。
|
|
89
|
+
|
|
90
|
+
克隆仓库,在 monorepo 根目录安装 workspace 依赖,使用 WXT 构建并加载到 Chrome:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
pnpm install
|
|
94
|
+
pnpm --filter @tabbridge/chrome-extension build
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
然后打开 `chrome://extensions`,开启**开发者模式**,点击**加载已解压的扩展**,选择 `packages/chrome-extension/dist/chrome-mv3/`(包含 `manifest.json` 的目录)。
|
|
98
|
+
|
|
99
|
+
运行 `tabbridge tabs request-access` 后,在扩展弹窗中批准站点访问。也可以稍后在扩展详情页的站点访问中管理。
|
|
100
|
+
|
|
101
|
+
开发时请运行 `pnpm --filter @tabbridge/chrome-extension dev`;WXT 会输出到 `packages/chrome-extension/dist/chrome-mv3-dev/`。
|
|
102
|
+
|
|
103
|
+
### 3. 连接并操作页面
|
|
104
|
+
|
|
105
|
+
请确保当前标签页是正常的 `http://` 或 `https://` 页面。当前版本不支持 Chrome 内部页面、扩展页面、文件 URL 和 `about:` 页面。
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
# 检查连接状态
|
|
109
|
+
tabbridge status --json
|
|
110
|
+
|
|
111
|
+
# 列出可操作的标签页
|
|
112
|
+
tabbridge tabs list --json
|
|
113
|
+
|
|
114
|
+
# 连接到当前激活的标签页
|
|
115
|
+
tabbridge connect --json
|
|
116
|
+
|
|
117
|
+
# 请求页面访问授权
|
|
118
|
+
tabbridge tabs request-access --tab <tabId> --reason "读取页面内容" --json
|
|
119
|
+
|
|
120
|
+
# 获取 AI 友好的 Snapshot(`-i` 为兼容参数,可忽略)
|
|
121
|
+
tabbridge snapshot -i --json
|
|
122
|
+
|
|
123
|
+
# 执行操作
|
|
124
|
+
tabbridge click @e1 --json
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Snapshot:为 AI 大模型优化的页面视图
|
|
128
|
+
|
|
129
|
+
`tabbridge snapshot` 不会把整个 DOM dump 给模型,而是生成一个紧凑的语义表示:
|
|
130
|
+
|
|
131
|
+
```text
|
|
132
|
+
Page: Example
|
|
133
|
+
URL: https://example.com
|
|
134
|
+
|
|
135
|
+
@e1 [button] "Save"
|
|
136
|
+
@e2 [textbox] placeholder="Comment"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
每个 `@eN` 引用都是当前快照下的临时标签,AI 可以用它精确地指代元素,而无需处理冗长的 HTML 或 CSS 选择器。引用具有易变性,每次快照都会重新分配,确保操作始终基于最新页面状态。
|
|
140
|
+
|
|
141
|
+
## 安全与审批边界
|
|
142
|
+
|
|
143
|
+
TabBridge 将用户浏览器上下文的安全放在首位:
|
|
144
|
+
|
|
145
|
+
- **站点授权**:每个 origin 都需要用户单独授权。
|
|
146
|
+
- **会话隔离**:不提取 Cookie、localStorage、凭证或 Token。
|
|
147
|
+
- **无任意代码执行**:禁止注入任意 JavaScript 或拦截网络请求。
|
|
148
|
+
- **高危操作确认**:坐标类操作必须经浏览器插件弹窗确认后才能执行。
|
|
149
|
+
- **无静默降级**:不会把 ref 操作静默替换为坐标操作。
|
|
150
|
+
- **不在命令行传 secrets**:永远不要把密码、2FA 码、支付信息、凭证或 Token 放在 CLI 参数里。敏感输入请使用 `type --text-stdin` / `fill --text-stdin`。
|
|
151
|
+
|
|
152
|
+
## 项目结构
|
|
153
|
+
|
|
154
|
+
```text
|
|
155
|
+
tabbridge/
|
|
156
|
+
├── packages/
|
|
157
|
+
│ ├── cli/ # TabBridge CLI(tabbridge 命令)
|
|
158
|
+
│ ├── chrome-extension/ # Chrome / Chromium 浏览器插件
|
|
159
|
+
│ ├── broker/ # 本地 WebSocket 桥接服务
|
|
160
|
+
│ └── shared/ # 共享类型与错误码
|
|
161
|
+
├── skills/
|
|
162
|
+
│ └── tabbridge/ # Claude Skill 与使用参考
|
|
163
|
+
└── docs/ # 设计文档
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## 开发
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
pnpm test # 运行完整测试套件
|
|
170
|
+
pnpm typecheck # 运行 TypeScript 检查
|
|
171
|
+
pnpm lint # 运行各包的 lint / 类型检查
|
|
172
|
+
pnpm clean # 清理构建产物
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## 许可证
|
|
176
|
+
|
|
177
|
+
[MIT](LICENSE)
|
package/broker.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/runtime.ts
|
|
4
|
+
import crypto from "crypto";
|
|
5
|
+
import fs from "fs/promises";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import envPaths from "env-paths";
|
|
8
|
+
|
|
9
|
+
// ../shared/dist/index.js
|
|
10
|
+
var ERROR_CODES = [
|
|
11
|
+
"EXTENSION_NOT_CONNECTED",
|
|
12
|
+
"BRIDGE_SOCKET_UNAVAILABLE",
|
|
13
|
+
"BRIDGE_REQUEST_TIMEOUT",
|
|
14
|
+
"TAB_NOT_FOUND",
|
|
15
|
+
"TAB_NOT_AUTHORIZED",
|
|
16
|
+
"TAB_NOT_ACTIVE_FOR_SCREENSHOT",
|
|
17
|
+
"HOST_PERMISSION_DENIED",
|
|
18
|
+
"USER_APPROVAL_REQUIRED",
|
|
19
|
+
"APPROVAL_EXPIRED",
|
|
20
|
+
"APPROVAL_TIMEOUT",
|
|
21
|
+
"UNSUPPORTED_PAGE",
|
|
22
|
+
"FRAME_NOT_ACCESSIBLE",
|
|
23
|
+
"FRAME_ORIGIN_NOT_AUTHORIZED",
|
|
24
|
+
"SNAPSHOT_REQUIRED",
|
|
25
|
+
"REF_STALE",
|
|
26
|
+
"ELEMENT_NOT_VISIBLE",
|
|
27
|
+
"ELEMENT_DISABLED",
|
|
28
|
+
"ELEMENT_SCOPE_TOO_LARGE",
|
|
29
|
+
"ACTION_REQUIRES_CONFIRMATION",
|
|
30
|
+
"ACTION_NOT_SUPPORTED_IN_EXTENSION_MODE",
|
|
31
|
+
"USER_DENIED",
|
|
32
|
+
"MESSAGE_TOO_LARGE",
|
|
33
|
+
"PROTOCOL_VERSION_MISMATCH",
|
|
34
|
+
"BROWSER_COMMAND_TIMEOUT",
|
|
35
|
+
"EXTENSION_ID_MISMATCH"
|
|
36
|
+
];
|
|
37
|
+
var JSON_RPC_TABBRIDGE_ERROR_BASE = -32e3;
|
|
38
|
+
var TAB_BRIDGE_ERROR_CODE_INDEX = Object.fromEntries(
|
|
39
|
+
ERROR_CODES.map((code, index) => [code, index])
|
|
40
|
+
);
|
|
41
|
+
function tabBridgeErrorToJsonRpc(error) {
|
|
42
|
+
return {
|
|
43
|
+
code: JSON_RPC_TABBRIDGE_ERROR_BASE - TAB_BRIDGE_ERROR_CODE_INDEX[error.code],
|
|
44
|
+
message: error.code,
|
|
45
|
+
data: error
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
var JSON_RPC_VERSION = "2.0";
|
|
49
|
+
function createJsonRpcSuccess(id, result) {
|
|
50
|
+
return { jsonrpc: JSON_RPC_VERSION, id, result };
|
|
51
|
+
}
|
|
52
|
+
function createJsonRpcError(id, error) {
|
|
53
|
+
return { jsonrpc: JSON_RPC_VERSION, id, error };
|
|
54
|
+
}
|
|
55
|
+
var NATIVE_HOST_TO_CHROME_MAX_BYTES = 1024 * 1024;
|
|
56
|
+
var CHROME_TO_NATIVE_HOST_MAX_BYTES = 64 * 1024 * 1024;
|
|
57
|
+
var SNAPSHOT_DEFAULT_MAX_BYTES = 256 * 1024;
|
|
58
|
+
var TEXT_DEFAULT_MAX_BYTES = 128 * 1024;
|
|
59
|
+
var HTML_DEFAULT_MAX_BYTES = 64 * 1024;
|
|
60
|
+
var GRANT_TTL_MS = 30 * 6e4;
|
|
61
|
+
var BROKER_PORT = 9876;
|
|
62
|
+
|
|
63
|
+
// src/runtime.ts
|
|
64
|
+
function defaultSupportDir() {
|
|
65
|
+
return envPaths("tabbridge", { suffix: "" }).data;
|
|
66
|
+
}
|
|
67
|
+
function createRuntimePaths(supportDir) {
|
|
68
|
+
const dir = supportDir ?? defaultSupportDir();
|
|
69
|
+
return {
|
|
70
|
+
supportDir: dir,
|
|
71
|
+
tokenPath: path.join(dir, "broker-token"),
|
|
72
|
+
lockPath: path.join(dir, "broker.lock")
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
async function ensureSupportDir(paths) {
|
|
76
|
+
await fs.mkdir(paths.supportDir, { recursive: true, mode: 448 });
|
|
77
|
+
await fs.chmod(paths.supportDir, 448);
|
|
78
|
+
}
|
|
79
|
+
function generateToken() {
|
|
80
|
+
return crypto.randomBytes(32).toString("hex");
|
|
81
|
+
}
|
|
82
|
+
async function writeToken(paths, token) {
|
|
83
|
+
await fs.writeFile(paths.tokenPath, `${token}
|
|
84
|
+
`, { mode: 384 });
|
|
85
|
+
await fs.chmod(paths.tokenPath, 384);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// src/lock.ts
|
|
89
|
+
import lockfile from "proper-lockfile";
|
|
90
|
+
async function acquireBrokerLock(lockPath) {
|
|
91
|
+
return await lockfile.lock(lockPath, {
|
|
92
|
+
stale: 5e3,
|
|
93
|
+
update: 2e3,
|
|
94
|
+
retries: 0
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/server.ts
|
|
99
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
100
|
+
var STANDARD_ERRORS = {
|
|
101
|
+
parseError: { code: -32700, message: "Parse error" },
|
|
102
|
+
invalidRequest: { code: -32600, message: "Invalid Request" },
|
|
103
|
+
methodNotFound: { code: -32601, message: "Method not found" },
|
|
104
|
+
internalError: { code: -32603, message: "Internal error" },
|
|
105
|
+
duplicatePendingRequest: { code: -32600, message: "Duplicate pending request id" }
|
|
106
|
+
};
|
|
107
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
|
|
108
|
+
function isRecord(value) {
|
|
109
|
+
return typeof value === "object" && value !== null;
|
|
110
|
+
}
|
|
111
|
+
function isAuthMessage(value) {
|
|
112
|
+
return isRecord(value) && ("token" in value || "role" in value);
|
|
113
|
+
}
|
|
114
|
+
function looksLikeResponse(value) {
|
|
115
|
+
return typeof value.id === "string" && ("result" in value || "error" in value);
|
|
116
|
+
}
|
|
117
|
+
var BrokerServer = class {
|
|
118
|
+
constructor(options) {
|
|
119
|
+
this.options = options;
|
|
120
|
+
this.wss = new WebSocketServer({ port: options.port });
|
|
121
|
+
this.port = this.wss.address().port;
|
|
122
|
+
this.wss.on("connection", (socket, req) => this.handleConnection(socket, req));
|
|
123
|
+
}
|
|
124
|
+
options;
|
|
125
|
+
wss;
|
|
126
|
+
clients = /* @__PURE__ */ new Set();
|
|
127
|
+
extensionClient;
|
|
128
|
+
pending = /* @__PURE__ */ new Map();
|
|
129
|
+
hello;
|
|
130
|
+
port;
|
|
131
|
+
status() {
|
|
132
|
+
return {
|
|
133
|
+
cliConnected: [...this.clients].some((c) => c.role === "cli"),
|
|
134
|
+
extensionConnected: this.extensionClient !== void 0 && this.extensionClient.socket.readyState === WebSocket.OPEN,
|
|
135
|
+
extensionHello: this.hello
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
handleConnection(socket, req) {
|
|
139
|
+
const client = { socket, authenticated: false };
|
|
140
|
+
this.clients.add(client);
|
|
141
|
+
const authTimeout = setTimeout(() => {
|
|
142
|
+
if (!client.authenticated) socket.close();
|
|
143
|
+
}, 5e3);
|
|
144
|
+
socket.on("message", async (data) => {
|
|
145
|
+
try {
|
|
146
|
+
await this.handleMessage(client, data, req.headers.origin);
|
|
147
|
+
} catch {
|
|
148
|
+
socket.send(JSON.stringify(createJsonRpcError("unknown", STANDARD_ERRORS.internalError)));
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
socket.on("close", () => {
|
|
152
|
+
clearTimeout(authTimeout);
|
|
153
|
+
this.clients.delete(client);
|
|
154
|
+
if (this.extensionClient === client) {
|
|
155
|
+
this.extensionClient = void 0;
|
|
156
|
+
this.hello = void 0;
|
|
157
|
+
this.failAllPending(extensionNotConnectedError());
|
|
158
|
+
} else {
|
|
159
|
+
this.clearPendingForClient(client);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
async handleMessage(client, data, origin) {
|
|
164
|
+
let parsed;
|
|
165
|
+
try {
|
|
166
|
+
parsed = JSON.parse(data.toString("utf8"));
|
|
167
|
+
} catch {
|
|
168
|
+
client.socket.send(JSON.stringify(createJsonRpcError("unknown", STANDARD_ERRORS.parseError)));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (!client.authenticated) {
|
|
172
|
+
if (!isAuthMessage(parsed)) {
|
|
173
|
+
client.socket.close();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (parsed.token === this.options.token) {
|
|
177
|
+
client.authenticated = true;
|
|
178
|
+
client.role = "cli";
|
|
179
|
+
} else if (parsed.role === "extension" && typeof origin === "string" && origin.startsWith("chrome-extension://")) {
|
|
180
|
+
client.authenticated = true;
|
|
181
|
+
client.role = "extension";
|
|
182
|
+
this.extensionClient = client;
|
|
183
|
+
} else {
|
|
184
|
+
client.socket.close();
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (!isRecord(parsed)) return;
|
|
189
|
+
if (client.role === "extension") {
|
|
190
|
+
if (client !== this.extensionClient) return;
|
|
191
|
+
if (looksLikeResponse(parsed)) {
|
|
192
|
+
const pendingRequest = this.pending.get(parsed.id);
|
|
193
|
+
if (pendingRequest) {
|
|
194
|
+
this.clearPendingRequest(parsed.id, pendingRequest);
|
|
195
|
+
pendingRequest.client.socket.send(JSON.stringify(parsed));
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (parsed.method === "broker.hello") {
|
|
200
|
+
this.hello = parsed.params;
|
|
201
|
+
client.socket.send(JSON.stringify(createJsonRpcSuccess(String(parsed.id), { ok: true })));
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const request = parsed;
|
|
206
|
+
if (request.jsonrpc !== "2.0" || typeof request.method !== "string" || typeof request.id !== "string") {
|
|
207
|
+
client.socket.send(JSON.stringify(createJsonRpcError(String(request.id ?? "unknown"), STANDARD_ERRORS.invalidRequest)));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (!this.extensionClient || this.extensionClient.socket.readyState !== WebSocket.OPEN) {
|
|
211
|
+
this.sendJsonRpcError(client, request.id, tabBridgeErrorToJsonRpc(extensionNotConnectedError()));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (this.pending.has(request.id)) {
|
|
215
|
+
this.sendJsonRpcError(client, request.id, STANDARD_ERRORS.duplicatePendingRequest);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const requestId = request.id;
|
|
219
|
+
const timer = setTimeout(() => {
|
|
220
|
+
const pendingRequest = this.pending.get(requestId);
|
|
221
|
+
if (!pendingRequest) return;
|
|
222
|
+
this.clearPendingRequest(requestId, pendingRequest);
|
|
223
|
+
this.sendJsonRpcError(pendingRequest.client, requestId, tabBridgeErrorToJsonRpc(requestTimeoutError()));
|
|
224
|
+
}, this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
|
|
225
|
+
this.pending.set(requestId, { client, timer });
|
|
226
|
+
this.extensionClient.socket.send(JSON.stringify(request));
|
|
227
|
+
}
|
|
228
|
+
clearPendingRequest(id, pendingRequest) {
|
|
229
|
+
clearTimeout(pendingRequest.timer);
|
|
230
|
+
this.pending.delete(id);
|
|
231
|
+
}
|
|
232
|
+
clearPendingForClient(client) {
|
|
233
|
+
for (const [id, pendingRequest] of this.pending) {
|
|
234
|
+
if (pendingRequest.client === client) this.clearPendingRequest(id, pendingRequest);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
failAllPending(error) {
|
|
238
|
+
for (const [id, pendingRequest] of this.pending) {
|
|
239
|
+
this.clearPendingRequest(id, pendingRequest);
|
|
240
|
+
this.sendJsonRpcError(pendingRequest.client, id, tabBridgeErrorToJsonRpc(error));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
sendJsonRpcError(client, id, error) {
|
|
244
|
+
client.socket.send(JSON.stringify(createJsonRpcError(id, error)));
|
|
245
|
+
}
|
|
246
|
+
close() {
|
|
247
|
+
return new Promise((resolve) => {
|
|
248
|
+
for (const [id, pendingRequest] of this.pending) {
|
|
249
|
+
this.clearPendingRequest(id, pendingRequest);
|
|
250
|
+
}
|
|
251
|
+
this.wss.close(() => resolve());
|
|
252
|
+
for (const client of this.clients) {
|
|
253
|
+
client.socket.terminate();
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
function extensionNotConnectedError() {
|
|
259
|
+
return {
|
|
260
|
+
code: "EXTENSION_NOT_CONNECTED",
|
|
261
|
+
message: "The TabBridge extension is not connected.",
|
|
262
|
+
recoverable: true,
|
|
263
|
+
suggestedCommand: "Open Chrome and click the TabBridge extension icon, then run tabbridge status --json."
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function requestTimeoutError() {
|
|
267
|
+
return {
|
|
268
|
+
code: "BRIDGE_REQUEST_TIMEOUT",
|
|
269
|
+
message: "The TabBridge extension did not respond before the broker request timed out.",
|
|
270
|
+
recoverable: true,
|
|
271
|
+
suggestedCommand: "Retry the command. If it keeps timing out, open Chrome and restart the TabBridge extension."
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// src/run-broker.ts
|
|
276
|
+
import fs2 from "fs/promises";
|
|
277
|
+
async function runBroker(pathsArg, port = BROKER_PORT) {
|
|
278
|
+
const paths = pathsArg ?? createRuntimePaths();
|
|
279
|
+
await ensureSupportDir(paths);
|
|
280
|
+
await fs2.writeFile(paths.lockPath, "", { mode: 384 });
|
|
281
|
+
const release = await acquireBrokerLock(paths.lockPath);
|
|
282
|
+
const token = generateToken();
|
|
283
|
+
await writeToken(paths, token);
|
|
284
|
+
const server = new BrokerServer({ port, token });
|
|
285
|
+
return {
|
|
286
|
+
port: server.port,
|
|
287
|
+
close: async () => {
|
|
288
|
+
await server.close();
|
|
289
|
+
await release();
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// src/main.ts
|
|
295
|
+
import path2 from "path";
|
|
296
|
+
import { fileURLToPath } from "url";
|
|
297
|
+
function isExecutedEntrypoint() {
|
|
298
|
+
if (!process.argv[1]) return false;
|
|
299
|
+
return fileURLToPath(import.meta.url) === path2.resolve(process.argv[1]);
|
|
300
|
+
}
|
|
301
|
+
if (isExecutedEntrypoint()) {
|
|
302
|
+
runBroker().catch((error) => {
|
|
303
|
+
console.error(error);
|
|
304
|
+
process.exitCode = 1;
|
|
305
|
+
});
|
|
306
|
+
}
|
package/main.js
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ../shared/dist/index.js
|
|
3
|
+
var ERROR_CODES = [
|
|
4
|
+
"EXTENSION_NOT_CONNECTED",
|
|
5
|
+
"BRIDGE_SOCKET_UNAVAILABLE",
|
|
6
|
+
"BRIDGE_REQUEST_TIMEOUT",
|
|
7
|
+
"TAB_NOT_FOUND",
|
|
8
|
+
"TAB_NOT_AUTHORIZED",
|
|
9
|
+
"TAB_NOT_ACTIVE_FOR_SCREENSHOT",
|
|
10
|
+
"HOST_PERMISSION_DENIED",
|
|
11
|
+
"USER_APPROVAL_REQUIRED",
|
|
12
|
+
"APPROVAL_EXPIRED",
|
|
13
|
+
"APPROVAL_TIMEOUT",
|
|
14
|
+
"UNSUPPORTED_PAGE",
|
|
15
|
+
"FRAME_NOT_ACCESSIBLE",
|
|
16
|
+
"FRAME_ORIGIN_NOT_AUTHORIZED",
|
|
17
|
+
"SNAPSHOT_REQUIRED",
|
|
18
|
+
"REF_STALE",
|
|
19
|
+
"ELEMENT_NOT_VISIBLE",
|
|
20
|
+
"ELEMENT_DISABLED",
|
|
21
|
+
"ELEMENT_SCOPE_TOO_LARGE",
|
|
22
|
+
"ACTION_REQUIRES_CONFIRMATION",
|
|
23
|
+
"ACTION_NOT_SUPPORTED_IN_EXTENSION_MODE",
|
|
24
|
+
"USER_DENIED",
|
|
25
|
+
"MESSAGE_TOO_LARGE",
|
|
26
|
+
"PROTOCOL_VERSION_MISMATCH",
|
|
27
|
+
"BROWSER_COMMAND_TIMEOUT",
|
|
28
|
+
"EXTENSION_ID_MISMATCH"
|
|
29
|
+
];
|
|
30
|
+
function bridgeNotConnectedError(state) {
|
|
31
|
+
if (state === "chrome_closed") {
|
|
32
|
+
return {
|
|
33
|
+
code: "EXTENSION_NOT_CONNECTED",
|
|
34
|
+
message: "Chrome is not connected to the TabBridge broker.",
|
|
35
|
+
recoverable: true,
|
|
36
|
+
suggestedCommand: "Open Chrome, confirm the TabBridge extension is enabled, then run tabbridge status --json."
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
code: "EXTENSION_NOT_CONNECTED",
|
|
41
|
+
message: "The TabBridge extension service worker is not connected to the broker.",
|
|
42
|
+
recoverable: true,
|
|
43
|
+
suggestedCommand: "Open Chrome and click the TabBridge extension icon to start the broker, then run tabbridge status --json."
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
var TAB_BRIDGE_ERROR_CODE_INDEX = Object.fromEntries(
|
|
47
|
+
ERROR_CODES.map((code, index) => [code, index])
|
|
48
|
+
);
|
|
49
|
+
function jsonRpcErrorToTabBridgeError(error) {
|
|
50
|
+
const data = error.data;
|
|
51
|
+
if (typeof data !== "object" || data === null) return void 0;
|
|
52
|
+
const candidate = data;
|
|
53
|
+
if (typeof candidate.code === "string" && ERROR_CODES.includes(candidate.code) && typeof candidate.message === "string" && typeof candidate.recoverable === "boolean") {
|
|
54
|
+
return candidate;
|
|
55
|
+
}
|
|
56
|
+
return void 0;
|
|
57
|
+
}
|
|
58
|
+
var JSON_RPC_VERSION = "2.0";
|
|
59
|
+
function createJsonRpcRequest(id, method, params) {
|
|
60
|
+
const req = { jsonrpc: JSON_RPC_VERSION, id, method };
|
|
61
|
+
if (params !== void 0) {
|
|
62
|
+
req.params = params;
|
|
63
|
+
}
|
|
64
|
+
return req;
|
|
65
|
+
}
|
|
66
|
+
var NATIVE_HOST_TO_CHROME_MAX_BYTES = 1024 * 1024;
|
|
67
|
+
var CHROME_TO_NATIVE_HOST_MAX_BYTES = 64 * 1024 * 1024;
|
|
68
|
+
var SNAPSHOT_DEFAULT_MAX_BYTES = 256 * 1024;
|
|
69
|
+
var TEXT_DEFAULT_MAX_BYTES = 128 * 1024;
|
|
70
|
+
var HTML_DEFAULT_MAX_BYTES = 64 * 1024;
|
|
71
|
+
var GRANT_TTL_MS = 30 * 6e4;
|
|
72
|
+
var APPROVAL_WAIT_DEFAULT_TIMEOUT_MS = 3e4;
|
|
73
|
+
var BROKER_PORT = 9876;
|
|
74
|
+
function okEnvelope(data) {
|
|
75
|
+
return { ok: true, data };
|
|
76
|
+
}
|
|
77
|
+
function errorEnvelope(error) {
|
|
78
|
+
return { ok: false, error };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/broker-client.ts
|
|
82
|
+
import { WebSocket } from "ws";
|
|
83
|
+
function closedBeforeResponseError() {
|
|
84
|
+
return errorEnvelope({
|
|
85
|
+
code: "BRIDGE_SOCKET_UNAVAILABLE",
|
|
86
|
+
message: "Broker closed the connection before sending a complete response.",
|
|
87
|
+
recoverable: true,
|
|
88
|
+
suggestedCommand: "tabbridge status --json"
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
function timeoutError() {
|
|
92
|
+
return errorEnvelope({
|
|
93
|
+
code: "BRIDGE_REQUEST_TIMEOUT",
|
|
94
|
+
message: "Timed out waiting for the broker response.",
|
|
95
|
+
recoverable: true,
|
|
96
|
+
suggestedCommand: "tabbridge status --json"
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function protocolError(message) {
|
|
100
|
+
return errorEnvelope({
|
|
101
|
+
code: "PROTOCOL_VERSION_MISMATCH",
|
|
102
|
+
message,
|
|
103
|
+
recoverable: true,
|
|
104
|
+
suggestedCommand: "tabbridge status --json"
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
function parseResponse(request, raw) {
|
|
108
|
+
let parsed;
|
|
109
|
+
try {
|
|
110
|
+
parsed = JSON.parse(raw);
|
|
111
|
+
} catch {
|
|
112
|
+
return protocolError("Broker returned malformed JSON.");
|
|
113
|
+
}
|
|
114
|
+
if (parsed.id !== request.id) {
|
|
115
|
+
return protocolError(`Broker response id ${String(parsed.id)} did not match request id ${request.id}.`);
|
|
116
|
+
}
|
|
117
|
+
if ("result" in parsed && parsed.result !== void 0) {
|
|
118
|
+
return okEnvelope(parsed.result);
|
|
119
|
+
}
|
|
120
|
+
if ("error" in parsed && parsed.error !== void 0) {
|
|
121
|
+
const businessError = jsonRpcErrorToTabBridgeError(parsed.error);
|
|
122
|
+
if (businessError) return errorEnvelope(businessError);
|
|
123
|
+
const synthetic = {
|
|
124
|
+
code: "PROTOCOL_VERSION_MISMATCH",
|
|
125
|
+
message: parsed.error.message,
|
|
126
|
+
recoverable: true
|
|
127
|
+
};
|
|
128
|
+
return errorEnvelope(synthetic);
|
|
129
|
+
}
|
|
130
|
+
return protocolError("Broker returned an invalid JSON-RPC response.");
|
|
131
|
+
}
|
|
132
|
+
async function sendBrokerRequest(request, options) {
|
|
133
|
+
return await new Promise((resolve) => {
|
|
134
|
+
const ws = new WebSocket(options.url);
|
|
135
|
+
let settled = false;
|
|
136
|
+
const finish = (envelope) => {
|
|
137
|
+
if (settled) return;
|
|
138
|
+
settled = true;
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
ws.terminate();
|
|
141
|
+
resolve(envelope);
|
|
142
|
+
};
|
|
143
|
+
const timer = setTimeout(() => finish(timeoutError()), options.timeoutMs);
|
|
144
|
+
ws.once("open", () => {
|
|
145
|
+
ws.send(JSON.stringify({ type: "auth", token: options.token }));
|
|
146
|
+
ws.send(JSON.stringify(request));
|
|
147
|
+
});
|
|
148
|
+
ws.once("error", () => finish(errorEnvelope(bridgeNotConnectedError("extension_asleep"))));
|
|
149
|
+
ws.once("close", () => {
|
|
150
|
+
if (!settled) finish(closedBeforeResponseError());
|
|
151
|
+
});
|
|
152
|
+
ws.on("message", (data) => {
|
|
153
|
+
const text = data.toString("utf8");
|
|
154
|
+
try {
|
|
155
|
+
const probe = JSON.parse(text);
|
|
156
|
+
if (probe.type === "auth") return;
|
|
157
|
+
} catch {
|
|
158
|
+
}
|
|
159
|
+
finish(parseResponse(request, text));
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/cli.ts
|
|
165
|
+
function isValueFlag(token) {
|
|
166
|
+
return token.startsWith("--") && token !== "--json" && token !== "--current" && token !== "--text-stdin";
|
|
167
|
+
}
|
|
168
|
+
function readFlag(argv, flag) {
|
|
169
|
+
const index = argv.indexOf(flag);
|
|
170
|
+
if (index < 0) return void 0;
|
|
171
|
+
const value = argv[index + 1];
|
|
172
|
+
if (value === void 0 || value.startsWith("--") || value === "-i") throw new Error(`${flag} requires a value`);
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
function hasFlag(argv, flag) {
|
|
176
|
+
return argv.includes(flag);
|
|
177
|
+
}
|
|
178
|
+
function readNumberFlag(argv, flag) {
|
|
179
|
+
const value = readFlag(argv, flag);
|
|
180
|
+
if (value === void 0) return void 0;
|
|
181
|
+
const parsed = Number(value);
|
|
182
|
+
if (!Number.isInteger(parsed)) throw new Error(`${flag} must be an integer`);
|
|
183
|
+
return parsed;
|
|
184
|
+
}
|
|
185
|
+
function requireNumberFlag(argv, flag, command) {
|
|
186
|
+
const value = readNumberFlag(argv, flag);
|
|
187
|
+
if (value === void 0) throw new Error(`${command} requires ${flag}`);
|
|
188
|
+
return value;
|
|
189
|
+
}
|
|
190
|
+
function requireStringFlag(argv, flag, command) {
|
|
191
|
+
const value = readFlag(argv, flag);
|
|
192
|
+
if (!value) throw new Error(`${command} requires ${flag}`);
|
|
193
|
+
return value;
|
|
194
|
+
}
|
|
195
|
+
function positionalArgs(argv) {
|
|
196
|
+
const values = [];
|
|
197
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
198
|
+
const token = argv[index];
|
|
199
|
+
if (!token) continue;
|
|
200
|
+
if (token.startsWith("--")) {
|
|
201
|
+
if (isValueFlag(token)) index += 1;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (token === "-i") continue;
|
|
205
|
+
values.push(token);
|
|
206
|
+
}
|
|
207
|
+
return values;
|
|
208
|
+
}
|
|
209
|
+
function parseRefAction(first, argv, json) {
|
|
210
|
+
const [, ref, value] = positionalArgs(argv);
|
|
211
|
+
if (!ref) throw new Error(`${first} requires a ref like @e1`);
|
|
212
|
+
const payload = { ref };
|
|
213
|
+
if (first === "select") {
|
|
214
|
+
if (!value) throw new Error("select requires a value");
|
|
215
|
+
payload.value = value;
|
|
216
|
+
}
|
|
217
|
+
return { command: `action.${first}`, json, payload };
|
|
218
|
+
}
|
|
219
|
+
function parseTextAction(first, argv, json) {
|
|
220
|
+
const [, ref, positionalText] = positionalArgs(argv);
|
|
221
|
+
if (!ref) throw new Error(`${first} requires a ref like @e1`);
|
|
222
|
+
const textFromStdin = hasFlag(argv, "--text-stdin");
|
|
223
|
+
const flagText = readFlag(argv, "--text");
|
|
224
|
+
const text = flagText ?? positionalText;
|
|
225
|
+
if (!text && !textFromStdin) throw new Error(`${first} requires text or --text-stdin`);
|
|
226
|
+
return {
|
|
227
|
+
command: `action.${first}`,
|
|
228
|
+
json,
|
|
229
|
+
payload: textFromStdin ? { ref, textFromStdin: true } : { ref, text }
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function parseCli(argv) {
|
|
233
|
+
const json = hasFlag(argv, "--json");
|
|
234
|
+
const [first, second] = argv;
|
|
235
|
+
if (first === "navigate") {
|
|
236
|
+
throw new Error("navigate is not part of the TabBridge MVP command set");
|
|
237
|
+
}
|
|
238
|
+
if (first === "status") return { command: "status", json, payload: {} };
|
|
239
|
+
if (first === "doctor") return { command: "doctor", json, payload: {} };
|
|
240
|
+
if (first === "tabs" && second === "list") return { command: "tabs.list", json, payload: {} };
|
|
241
|
+
if (first === "tabs" && second === "current") return { command: "tabs.current", json, payload: {} };
|
|
242
|
+
if (first === "tabs" && second === "release") {
|
|
243
|
+
return { command: "tabs.release", json, payload: { tabId: requireNumberFlag(argv, "--tab", "tabs release") } };
|
|
244
|
+
}
|
|
245
|
+
if (first === "tabs" && second === "request-access") {
|
|
246
|
+
return {
|
|
247
|
+
command: "tabs.requestAccess",
|
|
248
|
+
json,
|
|
249
|
+
payload: {
|
|
250
|
+
tabId: requireNumberFlag(argv, "--tab", "tabs request-access"),
|
|
251
|
+
reason: requireStringFlag(argv, "--reason", "tabs request-access")
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
if (first === "approvals" && second === "status") {
|
|
256
|
+
return { command: "approvals.status", json, payload: { approvalId: requireStringFlag(argv, "--id", "approvals status") } };
|
|
257
|
+
}
|
|
258
|
+
if (first === "approvals" && second === "cancel") {
|
|
259
|
+
return { command: "approvals.cancel", json, payload: { approvalId: requireStringFlag(argv, "--id", "approvals cancel") } };
|
|
260
|
+
}
|
|
261
|
+
if (first === "approvals" && second === "wait") {
|
|
262
|
+
const timeout = readNumberFlag(argv, "--timeout");
|
|
263
|
+
return { command: "approvals.wait", json, payload: { approvalId: requireStringFlag(argv, "--id", "approvals wait"), timeoutMs: timeout } };
|
|
264
|
+
}
|
|
265
|
+
if (first === "connect") {
|
|
266
|
+
const tabId = readNumberFlag(argv, "--tab");
|
|
267
|
+
return { command: "session.connect", json, payload: tabId === void 0 ? { current: true } : { tabId } };
|
|
268
|
+
}
|
|
269
|
+
if (first === "session") return { command: "session.status", json, payload: {} };
|
|
270
|
+
if (first === "disconnect") return { command: "session.disconnect", json, payload: {} };
|
|
271
|
+
if (first === "snapshot") return { command: "snapshot", json, payload: { interactive: true } };
|
|
272
|
+
if (first === "text") return { command: "text", json, payload: { maxBytes: readNumberFlag(argv, "--max-bytes") } };
|
|
273
|
+
if (first === "html") {
|
|
274
|
+
return {
|
|
275
|
+
command: "html",
|
|
276
|
+
json,
|
|
277
|
+
payload: {
|
|
278
|
+
ref: requireStringFlag(argv, "--ref", "html"),
|
|
279
|
+
maxBytes: readNumberFlag(argv, "--max-bytes")
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (first === "screenshot") {
|
|
284
|
+
const [, path2] = positionalArgs(argv);
|
|
285
|
+
return { command: "screenshot", json, payload: path2 ? { path: path2 } : {} };
|
|
286
|
+
}
|
|
287
|
+
const refActions = /* @__PURE__ */ new Set(["click", "clear", "select", "check", "uncheck", "focus"]);
|
|
288
|
+
if (first && refActions.has(first)) return parseRefAction(first, argv, json);
|
|
289
|
+
if (first === "fill" || first === "type") return parseTextAction(first, argv, json);
|
|
290
|
+
if (first === "press") return { command: "action.press", json, payload: { key: requireStringFlag(argv, "--key", "press") } };
|
|
291
|
+
if (first === "scroll") return { command: "action.scroll", json, payload: { dx: readNumberFlag(argv, "--dx") ?? 0, dy: readNumberFlag(argv, "--dy") ?? 0 } };
|
|
292
|
+
if (first === "click-coordinates") return { command: "action.clickCoordinates", json, payload: { x: requireNumberFlag(argv, "--x", "click-coordinates"), y: requireNumberFlag(argv, "--y", "click-coordinates") } };
|
|
293
|
+
if (first === "drag-coordinates") return { command: "action.dragCoordinates", json, payload: { fromX: requireNumberFlag(argv, "--from-x", "drag-coordinates"), fromY: requireNumberFlag(argv, "--from-y", "drag-coordinates"), toX: requireNumberFlag(argv, "--to-x", "drag-coordinates"), toY: requireNumberFlag(argv, "--to-y", "drag-coordinates") } };
|
|
294
|
+
if (first === "wait") return { command: "wait", json, payload: { ms: requireNumberFlag(argv, "--ms", "wait") } };
|
|
295
|
+
if (first === "wait-for-text") return { command: "waitForText", json, payload: { text: requireStringFlag(argv, "--text", "wait-for-text"), timeoutMs: readNumberFlag(argv, "--timeout") } };
|
|
296
|
+
if (first === "reload") return { command: "navigation.reload", json, payload: {} };
|
|
297
|
+
if (first === "back") return { command: "navigation.back", json, payload: {} };
|
|
298
|
+
if (first === "forward") return { command: "navigation.forward", json, payload: {} };
|
|
299
|
+
throw new Error(`Unknown tabbridge command: ${argv.join(" ")}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// src/commands.ts
|
|
303
|
+
function mapCliToJsonRpcRequest(parsed, id) {
|
|
304
|
+
const payload = { ...parsed.payload };
|
|
305
|
+
if (parsed.command === "approvals.wait" && typeof payload.timeoutMs !== "number") {
|
|
306
|
+
payload.timeoutMs = APPROVAL_WAIT_DEFAULT_TIMEOUT_MS;
|
|
307
|
+
}
|
|
308
|
+
return createJsonRpcRequest(id, parsed.command, payload);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// src/doctor.ts
|
|
312
|
+
import fs2 from "fs/promises";
|
|
313
|
+
|
|
314
|
+
// ../broker/dist/index.js
|
|
315
|
+
import { WebSocket as WebSocket2, WebSocketServer } from "ws";
|
|
316
|
+
import fs from "fs/promises";
|
|
317
|
+
import path from "path";
|
|
318
|
+
import envPaths from "env-paths";
|
|
319
|
+
import lockfile from "proper-lockfile";
|
|
320
|
+
var ERROR_CODES2 = [
|
|
321
|
+
"EXTENSION_NOT_CONNECTED",
|
|
322
|
+
"BRIDGE_SOCKET_UNAVAILABLE",
|
|
323
|
+
"BRIDGE_REQUEST_TIMEOUT",
|
|
324
|
+
"TAB_NOT_FOUND",
|
|
325
|
+
"TAB_NOT_AUTHORIZED",
|
|
326
|
+
"TAB_NOT_ACTIVE_FOR_SCREENSHOT",
|
|
327
|
+
"HOST_PERMISSION_DENIED",
|
|
328
|
+
"USER_APPROVAL_REQUIRED",
|
|
329
|
+
"APPROVAL_EXPIRED",
|
|
330
|
+
"APPROVAL_TIMEOUT",
|
|
331
|
+
"UNSUPPORTED_PAGE",
|
|
332
|
+
"FRAME_NOT_ACCESSIBLE",
|
|
333
|
+
"FRAME_ORIGIN_NOT_AUTHORIZED",
|
|
334
|
+
"SNAPSHOT_REQUIRED",
|
|
335
|
+
"REF_STALE",
|
|
336
|
+
"ELEMENT_NOT_VISIBLE",
|
|
337
|
+
"ELEMENT_DISABLED",
|
|
338
|
+
"ELEMENT_SCOPE_TOO_LARGE",
|
|
339
|
+
"ACTION_REQUIRES_CONFIRMATION",
|
|
340
|
+
"ACTION_NOT_SUPPORTED_IN_EXTENSION_MODE",
|
|
341
|
+
"USER_DENIED",
|
|
342
|
+
"MESSAGE_TOO_LARGE",
|
|
343
|
+
"PROTOCOL_VERSION_MISMATCH",
|
|
344
|
+
"BROWSER_COMMAND_TIMEOUT",
|
|
345
|
+
"EXTENSION_ID_MISMATCH"
|
|
346
|
+
];
|
|
347
|
+
var TAB_BRIDGE_ERROR_CODE_INDEX2 = Object.fromEntries(
|
|
348
|
+
ERROR_CODES2.map((code, index) => [code, index])
|
|
349
|
+
);
|
|
350
|
+
var NATIVE_HOST_TO_CHROME_MAX_BYTES2 = 1024 * 1024;
|
|
351
|
+
var CHROME_TO_NATIVE_HOST_MAX_BYTES2 = 64 * 1024 * 1024;
|
|
352
|
+
var SNAPSHOT_DEFAULT_MAX_BYTES2 = 256 * 1024;
|
|
353
|
+
var TEXT_DEFAULT_MAX_BYTES2 = 128 * 1024;
|
|
354
|
+
var HTML_DEFAULT_MAX_BYTES2 = 64 * 1024;
|
|
355
|
+
var GRANT_TTL_MS2 = 30 * 6e4;
|
|
356
|
+
function defaultSupportDir() {
|
|
357
|
+
return envPaths("tabbridge", { suffix: "" }).data;
|
|
358
|
+
}
|
|
359
|
+
function createRuntimePaths(supportDir) {
|
|
360
|
+
const dir = supportDir ?? defaultSupportDir();
|
|
361
|
+
return {
|
|
362
|
+
supportDir: dir,
|
|
363
|
+
tokenPath: path.join(dir, "broker-token"),
|
|
364
|
+
lockPath: path.join(dir, "broker.lock")
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
async function readToken(paths) {
|
|
368
|
+
try {
|
|
369
|
+
return (await fs.readFile(paths.tokenPath, "utf8")).trim();
|
|
370
|
+
} catch (error) {
|
|
371
|
+
if (error.code === "ENOENT") return void 0;
|
|
372
|
+
throw error;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// src/ensure-broker.ts
|
|
377
|
+
import { spawn } from "child_process";
|
|
378
|
+
import { access } from "fs/promises";
|
|
379
|
+
import { fileURLToPath } from "url";
|
|
380
|
+
import { WebSocket as WebSocket3 } from "ws";
|
|
381
|
+
var DEFAULT_BROKER_URL = `ws://127.0.0.1:${BROKER_PORT}`;
|
|
382
|
+
async function isBrokerListening(url) {
|
|
383
|
+
return await new Promise((resolve) => {
|
|
384
|
+
const ws = new WebSocket3(url);
|
|
385
|
+
const timer = setTimeout(() => {
|
|
386
|
+
ws.terminate();
|
|
387
|
+
resolve(false);
|
|
388
|
+
}, 500);
|
|
389
|
+
ws.once("open", () => {
|
|
390
|
+
clearTimeout(timer);
|
|
391
|
+
ws.terminate();
|
|
392
|
+
resolve(true);
|
|
393
|
+
});
|
|
394
|
+
ws.once("error", () => {
|
|
395
|
+
clearTimeout(timer);
|
|
396
|
+
resolve(false);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
async function defaultBrokerEntryExists(path2) {
|
|
401
|
+
try {
|
|
402
|
+
await access(path2);
|
|
403
|
+
return true;
|
|
404
|
+
} catch {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
function brokerEntryPath() {
|
|
409
|
+
return fileURLToPath(new URL("./broker.js", import.meta.url));
|
|
410
|
+
}
|
|
411
|
+
async function brokerStartArgs(brokerEntryExists) {
|
|
412
|
+
const entryPath = brokerEntryPath();
|
|
413
|
+
if (!await brokerEntryExists(entryPath)) {
|
|
414
|
+
throw new Error(`BROKER_ENTRY_MISSING: expected built broker entry at ${entryPath}; please reinstall the tabbridge package`);
|
|
415
|
+
}
|
|
416
|
+
return [entryPath];
|
|
417
|
+
}
|
|
418
|
+
function waitForBroker(child, url, isListening, options) {
|
|
419
|
+
return new Promise((resolve, reject) => {
|
|
420
|
+
const start = Date.now();
|
|
421
|
+
const timer = setInterval(async () => {
|
|
422
|
+
try {
|
|
423
|
+
if (await isListening(url)) {
|
|
424
|
+
clearInterval(timer);
|
|
425
|
+
resolve();
|
|
426
|
+
} else if (Date.now() - start >= options.timeoutMs) {
|
|
427
|
+
clearInterval(timer);
|
|
428
|
+
reject(new Error("BROKER_START_FAILED: broker did not start in time"));
|
|
429
|
+
}
|
|
430
|
+
} catch {
|
|
431
|
+
if (Date.now() - start >= options.timeoutMs) {
|
|
432
|
+
clearInterval(timer);
|
|
433
|
+
reject(new Error("BROKER_START_FAILED: broker did not start in time"));
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}, options.intervalMs);
|
|
437
|
+
child.on("error", (error) => {
|
|
438
|
+
clearInterval(timer);
|
|
439
|
+
reject(new Error(`BROKER_START_FAILED: ${error.message}`));
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
async function ensureBroker(options = {}) {
|
|
444
|
+
const paths = options.paths ?? createRuntimePaths();
|
|
445
|
+
const url = DEFAULT_BROKER_URL;
|
|
446
|
+
const listening = options.isListening ?? isBrokerListening;
|
|
447
|
+
const readBrokerToken = options.readToken ?? readToken;
|
|
448
|
+
if (await listening(url)) {
|
|
449
|
+
const token2 = await readBrokerToken(paths);
|
|
450
|
+
if (!token2) {
|
|
451
|
+
throw new Error("BROKER_TOKEN_MISSING: broker is listening but the token file is missing; restart the broker");
|
|
452
|
+
}
|
|
453
|
+
return { url, token: token2 };
|
|
454
|
+
}
|
|
455
|
+
const child = (options.spawn ?? spawn)(process.execPath, await brokerStartArgs(options.brokerEntryExists ?? defaultBrokerEntryExists), {
|
|
456
|
+
detached: true,
|
|
457
|
+
stdio: "ignore",
|
|
458
|
+
windowsHide: true
|
|
459
|
+
});
|
|
460
|
+
child.unref();
|
|
461
|
+
await waitForBroker(
|
|
462
|
+
child,
|
|
463
|
+
url,
|
|
464
|
+
listening,
|
|
465
|
+
{ timeoutMs: options.waitTimeoutMs ?? 5e3, intervalMs: options.waitIntervalMs ?? 100 }
|
|
466
|
+
);
|
|
467
|
+
const token = await readBrokerToken(paths);
|
|
468
|
+
if (!token) {
|
|
469
|
+
throw new Error("BROKER_TOKEN_MISSING: broker started but the token file is missing; restart the broker");
|
|
470
|
+
}
|
|
471
|
+
return { url, token };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// src/doctor.ts
|
|
475
|
+
async function exists(filePath) {
|
|
476
|
+
try {
|
|
477
|
+
await fs2.access(filePath);
|
|
478
|
+
return true;
|
|
479
|
+
} catch {
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
async function fileMode(filePath) {
|
|
484
|
+
try {
|
|
485
|
+
return (await fs2.stat(filePath)).mode;
|
|
486
|
+
} catch {
|
|
487
|
+
return void 0;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
async function runDoctor(input = {}) {
|
|
491
|
+
const paths = createRuntimePaths(input.home);
|
|
492
|
+
const listening = await isBrokerListening(`ws://127.0.0.1:${BROKER_PORT}`);
|
|
493
|
+
const tokenExists = await exists(paths.tokenPath);
|
|
494
|
+
const tokenMode = await fileMode(paths.tokenPath);
|
|
495
|
+
const lockExists = await exists(paths.lockPath);
|
|
496
|
+
const checks = [
|
|
497
|
+
{ name: "broker is listening on port 9876", ok: listening },
|
|
498
|
+
{ name: "broker token file exists", ok: tokenExists },
|
|
499
|
+
{
|
|
500
|
+
name: "broker token file mode is 0600",
|
|
501
|
+
ok: process.platform === "win32" || tokenMode !== void 0 && (tokenMode & 511) === 384
|
|
502
|
+
},
|
|
503
|
+
{ name: "broker lock file exists", ok: lockExists }
|
|
504
|
+
];
|
|
505
|
+
if (!listening) {
|
|
506
|
+
return { ok: false, bridgeState: "extension_asleep", errorCode: "EXTENSION_NOT_CONNECTED", checks };
|
|
507
|
+
}
|
|
508
|
+
const ok = checks.every((check) => check.ok);
|
|
509
|
+
return { ok, bridgeState: "connected", checks };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// src/json-output.ts
|
|
513
|
+
function printJsonEnvelope(envelope, stdout) {
|
|
514
|
+
stdout.write(`${JSON.stringify(envelope)}
|
|
515
|
+
`);
|
|
516
|
+
}
|
|
517
|
+
function printCliError(error, json, stdout, stderr) {
|
|
518
|
+
const message = error instanceof Error ? error.message : "Unknown CLI error";
|
|
519
|
+
if (json) {
|
|
520
|
+
printJsonEnvelope(errorEnvelope({ code: "BRIDGE_SOCKET_UNAVAILABLE", message, recoverable: true }), stdout);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
stderr.write(`${message}
|
|
524
|
+
`);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// src/main.ts
|
|
528
|
+
async function readStdin(stdin) {
|
|
529
|
+
const chunks = [];
|
|
530
|
+
for await (const chunk of stdin) {
|
|
531
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
532
|
+
}
|
|
533
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
534
|
+
}
|
|
535
|
+
async function hydrateStdinPayload(parsed, stdin) {
|
|
536
|
+
if (!["action.type", "action.fill"].includes(parsed.command) || parsed.payload.textFromStdin !== true) return parsed;
|
|
537
|
+
const { textFromStdin: _textFromStdin, ...payloadWithoutMarker } = parsed.payload;
|
|
538
|
+
return {
|
|
539
|
+
...parsed,
|
|
540
|
+
payload: {
|
|
541
|
+
...payloadWithoutMarker,
|
|
542
|
+
text: await readStdin(stdin)
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
async function run(options = {}) {
|
|
547
|
+
const argv = options.argv ?? process.argv.slice(2);
|
|
548
|
+
const json = argv.includes("--json");
|
|
549
|
+
const stdout = options.stdout ?? process.stdout;
|
|
550
|
+
const stderr = options.stderr ?? process.stderr;
|
|
551
|
+
const stdin = options.stdin ?? process.stdin;
|
|
552
|
+
const requestId = options.requestId ?? (() => `req_${process.pid}_${Date.now()}`);
|
|
553
|
+
const ensureBroker2 = options.ensureBroker ?? ensureBroker;
|
|
554
|
+
const sendBrokerRequest2 = options.sendBrokerRequest ?? sendBrokerRequest;
|
|
555
|
+
const runDoctor2 = options.runDoctor ?? runDoctor;
|
|
556
|
+
try {
|
|
557
|
+
const parsed = await hydrateStdinPayload(parseCli(argv), stdin);
|
|
558
|
+
if (parsed.command === "doctor") {
|
|
559
|
+
const report = await runDoctor2();
|
|
560
|
+
printJsonEnvelope(okEnvelope(report), stdout);
|
|
561
|
+
return report.ok ? 0 : 1;
|
|
562
|
+
}
|
|
563
|
+
const broker = await ensureBroker2();
|
|
564
|
+
const request = mapCliToJsonRpcRequest(parsed, requestId());
|
|
565
|
+
const envelope = await sendBrokerRequest2(request, { url: broker.url, token: broker.token, timeoutMs: 3e4 });
|
|
566
|
+
if (parsed.json) {
|
|
567
|
+
printJsonEnvelope(envelope, stdout);
|
|
568
|
+
} else if (envelope.ok) {
|
|
569
|
+
if (parsed.command === "snapshot" && typeof envelope.data.text === "string") {
|
|
570
|
+
stdout.write(`${envelope.data.text}
|
|
571
|
+
`);
|
|
572
|
+
} else {
|
|
573
|
+
stdout.write(`${JSON.stringify(envelope.data, null, 2)}
|
|
574
|
+
`);
|
|
575
|
+
}
|
|
576
|
+
} else {
|
|
577
|
+
stderr.write(`${envelope.error.message}
|
|
578
|
+
`);
|
|
579
|
+
}
|
|
580
|
+
return envelope.ok ? 0 : 1;
|
|
581
|
+
} catch (error) {
|
|
582
|
+
printCliError(error, json, stdout, stderr);
|
|
583
|
+
return 1;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (process.env.VITEST !== "true") {
|
|
587
|
+
run().then(
|
|
588
|
+
(code) => {
|
|
589
|
+
process.exitCode = code;
|
|
590
|
+
},
|
|
591
|
+
(error) => {
|
|
592
|
+
console.error(error);
|
|
593
|
+
process.exitCode = 1;
|
|
594
|
+
}
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
export {
|
|
598
|
+
run
|
|
599
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tabbridge-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tabbridge": "main.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"main.js",
|
|
11
|
+
"broker.js",
|
|
12
|
+
"README.md",
|
|
13
|
+
"README.zh-CN.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"env-paths": "^4.0.0",
|
|
18
|
+
"proper-lockfile": "^4.1.2",
|
|
19
|
+
"ws": "^8.18.0"
|
|
20
|
+
}
|
|
21
|
+
}
|