orbit-codex-bridge 0.0.0-stage → 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/README.md CHANGED
@@ -1,3 +1,190 @@
1
- # Temporary Holding Version
1
+ # Orbit · 会话随行
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 面向手机和桌面的原生 Codex 会话工作台。独立第三方预览版,不是 OpenAI 官方产品,也不是远程桌面。
4
+
5
+ > **实验性热接管已接入 Codex 桌面 IPC。** 仅当桌面 IPC 可用、目标会话已由 Codex 桌面加载且实时快照就绪时才开放;失败时不会切换到独立执行器。
6
+
7
+ ## 已验证的能力
8
+
9
+ | 能力 | 本版状态 |
10
+ | --- | --- |
11
+ | 列出当前 CODEX_HOME 的原生会话 | 已接入;本机验收读取 22 条,包含 2 条归档、1 条子代理记录 |
12
+ | 按项目归属 / 工作目录分组 | 已接入;本机验收 13 个分组,不代表 13 个独立代码仓库 |
13
+ | 搜索、置顶筛选、分页阅读历史 | 已接入;Markdown、命令输出、退出码、MCP 工具记录及文件 diff 可展开,消息和代码可复制;图片本体尚不展示 |
14
+ | 电脑新增的持久化消息同步到网页 | SQLite/WAL 版本检测 + WebSocket 通知,约 1.2 秒轮询,前端防抖 0.8 秒 |
15
+ | 原 ID 接续闲置历史会话 | 实验通路已实现;用真实 Codex 引擎 + 本机确定性模型桩完成两轮同 ID 集成测试 |
16
+ | Orbit 管理执行的流式输出、停止、审批 | 已实现协议通路;单元测试覆盖,未向用户现有真实会话发送测试消息 |
17
+ | 无侵入热接管桌面活动执行 | 已接入内部桌面 IPC;隔离桩测试覆盖,尚未对用户真实会话写入验证;桌面协议不稳定,暂属实验能力 |
18
+ | 选择模型、推理级别与上下文显示 | 模型目录从当前 Codex `model/list` 动态读取,逐会话授权后可更改下一轮参数;桌面模式经 owner 校验走内部 IPC,未对真实桌面会话执行模型切换验收;上下文仅展示执行器提供的最新一轮真实 token 用量和窗口,不用累计量冒充 |
19
+ | 手机外网连接、自建出站中继 | 已实现云端 Relay 和电脑出站 Agent,需自行部署域名/TLS;已通过两台隔离电脑的 HTTP/WS 集成验证,未部署到真实云服务器 |
20
+ | 多用户控制面与设备撤销 | 已实现自助注册/自动登录、添加自己的电脑/生成连接配置、套餐标签/设备额度/到期、永久撤销、审计及后台;单节点 SQLite 预览版 |
21
+ | 支付、组织角色、推送 | 未实现;不应直接作为完整商业 SaaS 上线 |
22
+
23
+ “全量”限定为当前本地 `CODEX_HOME` 中的数据,不包含其他电脑、其他用户目录或只存在于云端的会话。子代理可查看但不可独立接续。锁状态是保守探测,不等价于任务正在推理。
24
+
25
+ ## 快速启动
26
+
27
+ 环境:Node.js 24+、npm、已安装 Codex 桌面 / CLI,且本机已有原生会话数据库。
28
+
29
+ 在本目录运行:
30
+
31
+ ```powershell
32
+ .\start.ps1
33
+ ```
34
+
35
+ 或:
36
+
37
+ ```powershell
38
+ npm ci
39
+ npm start
40
+ ```
41
+
42
+ 默认地址:<http://127.0.0.1:4318>。默认只监听本机,不自动开防火墙,不安装系统服务,不修改全局 Codex 配置。
43
+
44
+ ### 通过 npm 连接云端电脑(待发布)
45
+
46
+ 发布到 npm 后,Node.js 24+ 用户可安装 Orbit 电脑连接器,不必下载整个源码仓库、手写 `.env.local` 或分别打开两个终端:
47
+
48
+ ```powershell
49
+ npm install --global orbit-codex-bridge
50
+ orbit login
51
+ ```
52
+
53
+ `orbit login` 会打开限时设备授权页面。登录 Orbit 账号并确认电脑名称后,电脑自动保存专属凭证,同时运行本机桥接和加密云端 Agent。终端会显示手机工作台地址、本机配对二维码和密钥。手机登录同一账号后扫描二维码、选择这台电脑即可自动配对;不必手动粘贴密钥。配对二维码长期有效,只能在自己的手机上使用,不要发布或转发。保持 `orbit login` 窗口运行即可。
54
+
55
+ - `orbit status` 查看连接状态和本机配置位置。
56
+ - `orbit logout` 撤销云端电脑凭证并删除本机配置。
57
+ - 首版只打包 Orbit 程序,不包含 Codex 会话、模型密钥或账号凭证。
58
+ - 自建 Relay 可通过 `orbit login --server https://你的域名` 指定。
59
+ - 命令行当前以前台方式运行;关闭窗口会停止服务。再次运行 `orbit login` 会复用电脑凭证。
60
+ - 当前包尚未发布到 npm registry;发布并完成云端域名部署后,这组命令才可供普通用户直接安装。
61
+
62
+ 首次访问需要本机配对密钥:
63
+
64
+ ```powershell
65
+ Get-Content .runtime\pairing-key
66
+ ```
67
+
68
+ 密钥在第一次启动时生成,只存于本项目 `.runtime`,不是模型 API Key。不要发到群里、截图公开或提交到代码仓库。
69
+
70
+ 启动脚本强制本机模式或显式 `-Lan`,可用 `-Port 4320` 改端口。生产环境自定义绑定请使用 `.env.local` + `npm start`。如果系统执行策略不允许运行脚本,使用 `npm start`,无需降低系统执行策略。
71
+
72
+ ## 手机同 Wi-Fi 体验
73
+
74
+ 1. 在运行服务的终端按 `Ctrl+C` 停止,改为运行 `./start.ps1 -Lan`。
75
+ 2. 打开电脑端“设备与连接”,使用显示的局域网地址,不是手机上的 `127.0.0.1`。
76
+ 3. 手机访问该地址,输入配对密钥;也可以在电脑页面主动展示配对二维码。
77
+ 4. 如果无法访问,确认两台设备在同一可信网络、没有 AP 隔离,以及 Windows 防火墙是否允许此专用网络端口。程序不会自行修改防火墙。
78
+
79
+ **局域网 HTTP / WS 没有传输加密,仅供可信 Wi-Fi 演示。不要转发此端口到公网。** 局域网功能已做代码覆盖,尚未用实体手机联网验收。浏览器手机视口 390×844 已检查,无水平溢出。
80
+
81
+ 二维码携带配对凭据(URL fragment,不进入 HTTP 请求),扫码者可以浏览本机会话。页面只在主动点击后生成,不向第三方二维码服务发送内容。
82
+
83
+ ## 原会话接续与热接管安全边界
84
+
85
+ - 浏览、搜索和刷新不会启动执行引擎,NativeStore 以 SQLite `readOnly` + `query_only` 打开数据库。
86
+ - 热接管需要逐条明确确认,授权 30 分钟后过期;订阅本身只读,不会授予控制权。桌面权限继承原 Codex 会话,不会扩大权限。
87
+ - 桌面热接管只路由到已发现的原会话 owner,保留原 conversation ID;任务追加、停止和审批均需匹配当前 owner / turn / request。
88
+ - 桌面 IPC 断线、owner 变化或状态补丁版本缺口会撤销控制授权;不会自动重发指令,也不会回退到 cold resume。恢复后需重新订阅和授权。
89
+ - 模型和推理级别仅能从当前执行器的可用模型目录选择;桌面模式只允许在任务空闲且已授权时更改下一轮设置,会校验原 owner、旧设置和确认结果,不改运行中的任务。独立执行模式在当前浏览器授权会话中保存下一轮覆盖参数,服务重启后会丢失。目录不兼容或无法读取时拒绝切换。
90
+ - 桌面任务运行时可以先选模型并将设置排入下一轮;Orbit 等到同一原会话 turn 空闲后,再用 owner 和旧设置复核并应用。排队设置保存在桥接进程内存,Orbit 服务重启、授权失效、owner 变化或桌面不确认时会撤销/丢弃,不写入真实 Codex 设置文件。
91
+ - 上下文进度采用桌面快照的 `latestTokenUsageInfo.last.totalTokens / modelContextWindow`;总历史用量 `total` 不是当前上下文。没有准确用量或窗口时显示“暂无用量”或只显示 token 数,不猜测百分比。
92
+ - 热接管依赖 Codex 桌面内部 IPC 协议,版本升级后可能失效。接入前要求 Codex 桌面运行并已加载目标会话;还没有对真实会话执行写操作的实机验证。
93
+ - 非桌面模式的历史会话接续使用 `thread/resume` 和 `turn/start`,不创建或 fork 新会话,并校验返回的原 ID。桌面热接管直接委托给原 Codex owner。
94
+ - 独立执行模式遇到原客户端持有 writer lock 时默认拒绝续接。**不要手动删除锁文件,不要让多个独立执行器同时写同一会话。**
95
+ - 桌面热接管要求 Codex 已加载目标会话;独立引擎模式则可能需要先从原客户端正常释放会话。
96
+ - 原生历史可读不代表桌面已加载该会话。遇到 `no-client-found` 时,Orbit 保留历史只读、显示逐会话的未连接原因;在 Codex 桌面打开同一条原会话后,点击提示中的“重新连接”或列表刷新按钮可重新订阅。重新连接不发送消息、不改模型、不自动恢复控制授权,仍需再次明确授权。
97
+ - owner 消失或操作返回 `no-client-found` 会使该会话的旧快照、审批、控制授权及排队模型设置失效;不会自动重试写操作,也不会退回独立执行器。桌面 IPC 本身在线时仍可能有个别会话未加载,界面分别显示这两种状态。
98
+ - 独立引擎沿用本机 `CODEX_HOME`,但桌面进程特有的运行时环境不保证被继承;实际中转配置、环境变量和模型可用性仍需逐机验证。
99
+ - 断线不会自动重放写操作;相同会话中的并发发送会被拒绝。请求 ID 去重窗口为 5 分钟,不能替代持久化事务。
100
+ - 扩大环境权限首版只能拒绝;未知宿主工具直接报错,不自动授权。
101
+ - 不提供私有 stdio 注入、进程内存修改、强制抢锁或自动重启桌面。
102
+
103
+ ## 配置
104
+
105
+ 复制 `.env.example` 为 `.env.local`,按需设置:
106
+
107
+ | 环境变量 | 说明 |
108
+ | --- | --- |
109
+ | `ORBIT_PORT` / `ORBIT_HOST` | 默认 `4318` / `127.0.0.1` |
110
+ | `ORBIT_CODEX_HOME` | 原生 Codex 数据目录;默认沿用 `CODEX_HOME` 或用户目录 `.codex` |
111
+ | `ORBIT_CODEX_BIN` | Codex 可执行文件;Windows 默认检测桌面安装目录 |
112
+ | `ORBIT_APP_SERVER_URL` | 可选目标 App Server 地址,远程只允许 `wss://`;本机允许 `ws://` |
113
+ | `ORBIT_APP_SERVER_TOKEN_FILE` | 可选目标 App Server Bearer token 文件,凭据不放入 URL |
114
+ | `ORBIT_PUBLIC_URL` | HTTPS 反向代理对外入口,不会启动隧道或公网中继 |
115
+
116
+ 仅配置 App Server URL **不等于接入桌面**。必须让桌面与 Orbit 确实连接同一个兼容执行实例,并验证 `thread/loaded/list`、事件流、审批和 writer lock 归属。目前这个共享部署方案仍待单独验证。
117
+
118
+ ### 自部署 HTTPS 入口(可选)
119
+
120
+ 可使用自己的 Caddy / Nginx,将 HTTPS 入口代理到本机网关并转发 `/socket` WebSocket。设置正确的 `ORBIT_PUBLIC_URL`,代理必须保留对外 `Host`。HTTPS 模式会设置 `Secure` cookie,因此应只通过该 HTTPS 地址配对,不要混用本机 HTTP 登录。
121
+
122
+ 这是一条自部署 HTTPS 入口。不要直接暴露 Node / Codex 端口;TLS 必须由已配置的反向代理实际终止。
123
+
124
+ ### 多用户云中继与管理后台
125
+
126
+ 云端控制面提供**自助注册、多用户账号、添加自己的电脑、套餐/设备额度、每台电脑独立凭证、在线状态、密码重置、账号停用、设备撤销和管理审计**。部署见 [CLOUD-DEPLOY.md](CLOUD-DEPLOY.md)。管理后台为 `/admin`,普通用户用独立账号登录。
127
+
128
+ 发布 npm 包后,用户访问云端首页注册账号;Windows 电脑复制一条命令运行,会检测 Node.js 24+ 并在缺失时尝试通过 winget 安装。随后在网页确认设备授权,CLI 自动配置并启动电脑连接器,手机扫描配对二维码、选择该电脑即可完成配对。macOS/Linux 先安装 Node.js 24+ 后运行对应命令。不用配置 `.env.local`、复制 Token 或开两个终端。npm 包尚未发布,完整部署说明见 [CLOUD-DEPLOY.md](CLOUD-DEPLOY.md)。
129
+
130
+ - `ORBIT_RELAY_ADMIN_TOKEN` 只留在服务器环境文件中,用于管理员登录。
131
+ - 每个用户单独创建,每台电脑签发独立 Agent Token;数据库只存 scrypt 密码哈希、设备 Token 的 SHA-256 哈希和审计元数据。
132
+ - 自助注册默认开启,新账号固定为 free 套餐/1 台设备;设置 `ORBIT_RELAY_REGISTRATION_ENABLED=false` 可关闭注册,已有用户仍可登录和管理自己的设备。当前不使用邮箱验证,忘记密码需管理员重置。
133
+ - 浏览器以 HttpOnly/SameSite cookie 登录。HTTP 和 WebSocket 都先验证用户,再验证目标电脑归属。
134
+ - 管理员停用账号、重置密码或撤销设备会断开相应访问;已撤销设备必须重新签发凭证。
135
+ - Relay 只转发会话协议,不保存 Codex 数据库或模型密钥;TLS 加密到 Relay,当前尚无端到端加密。
136
+
137
+ 当前实现是**单 Relay 节点 + SQLite 的多租户预览版**,还没有邮件验证/自助找回密码、MFA、组织 RBAC、支付账单或跨节点调度。商业上线前仍需安全审计、灾备与数据保留策略。
138
+
139
+ ### 撤销连接 / 轮换密钥
140
+
141
+ 本地模式中退出只撤销当前浏览器会话;更换 `.runtime\pairing-key` 会要求本地设备重新配对。云端用户可在 `/admin` 撤销单台电脑凭证、停用账号和重置密码。云数据库应纳入受保护的服务器备份。
142
+
143
+ ## 验证
144
+
145
+ ```powershell
146
+ npm run check
147
+ npm test
148
+ npm run test:native
149
+ ```
150
+
151
+ - 自动化测试覆盖只读查询、分页、Origin / cookie 校验、原 ID、未授权拒绝、桌面 owner / turn 校验、补丁失步撤权、审批、幂等和并发竞态;桌面 IPC 写入均由隔离桩验证。
152
+ - `test:native` 使用本机 Codex 可执行文件,创建隔离的临时 `CODEX_HOME` 和本机模型响应桩,完成首轮、关闭引擎、原 ID 恢复、第二轮;不会调用真实模型提供方,也不改动现有真实会话。临时目录保留用于诊断,路径由测试输出。
153
+ - 实测 Codex:`0.166.0-alpha.6`;本版读取 `state_5.sqlite` 和 `thread_history_1.sqlite`,这些是内部实现而不是稳定公开存储 API。升级 Codex 后需重新做兼容验证。
154
+ - 另已对本机真实 App Server 做 `initialize` / `thread/list` 只读协议测试;没有恢复或发送现有用户会话。
155
+ - 桌面和手机布局实测截图位于 `previews/desktop.png` / `previews/mobile.png`。默认 npm 镜像没有漏洞审计接口,改用 npm 官方 registry 完成 `npm audit --omit=dev`,结果为 0 个已知依赖漏洞;这不等于应用安全审计。
156
+
157
+ ## 结构
158
+
159
+ ```text
160
+ 手机 / 桌面网页
161
+ ├─ HTTP(S):配对、状态、列表、历史分页
162
+ └─ WS(S):变更通知、消息、流式事件、停止、审批
163
+ │
164
+ server.mjs:单用户本地网关
165
+ ├─ lib/store.mjs:原生历史只读适配器
166
+ ├─ lib/desktop.mjs:桌面 IPC、owner 发现、实时状态与原会话控制
167
+ └─ lib/rpc.mjs:按需独立 App Server / 可选显式目标端点
168
+ ```
169
+
170
+ 没有把真实会话复制进 Orbit 自建数据库,没有将模型密钥发送到手机端。历史检测采用轮询,而不是宣称已订阅桌面原始 token 流。只有经本桥接接入的执行才消费 App Server 流式事件。
171
+
172
+ ## 商业化前的关键工作
173
+
174
+ 1. **共享执行实例适配**:先解决桌面与手机使用同一个引擎,验证跨端消息、停止、审批、重连与单写者约束;这是区别于 HAPI 使用体验的核心,优先级高于计费。
175
+ 2. **云端扩展与可靠性**:现有账号、设备凭证、隔离路由和撤销继续补充限额、连接租约、审计保留与多节点调度。
176
+ 3. **安全与可靠性**:考虑端到端加密、最小权限、审计脱敏、密钥轮换、速率限制、持久化幂等、多设备控制权、故障恢复和安全评审。当前预览不具备 E2EE。
177
+ 4. **兼容与分发**:固定支持版本、协议能力协商、升级回归、桌面 agent 安装 / 更新 / 卸载、签名与跨平台测试。
178
+ 5. **产品与合规**:组织管理、套餐支付和推送;核对依赖许可证和模型服务条款,明确独立品牌与第三方关系。当前不打包转售 Codex 可执行文件。
179
+
180
+ ## 与 Hapi 的边界和分发选择
181
+
182
+ Hapi 的 Web/PWA、Hub/Runner、跨设备会话、完整工具输出、文件浏览、附件、终端、多代理和跨会话 `@` 提及是对照范围;Orbit 当前只针对本机 Codex 原生会话。新增的工具详情依旧只是历史只读呈现,不会代替 Hapi 的完整执行控制或跨端同步。
183
+
184
+ 原型阶段可以继续使用 Node + 本地页面,不必先做 EXE。对外分发可选择一个小型用户级安装器托管现有后台代理,并提供自动启动/更新/卸载;不需要改造成 Electron 大窗口。MCP 服务可以作为可选入口,将“列出会话、只读查看、受控发消息”等操作注册成 Codex 可调用的工具,但不能替代持久运行的本机代理、手机界面和安全的公网中继,也不会自然取得桌面应用的会话所有权。跨会话发送必须额外设置逐会话权限、精确目标及审计,不能把引用文本误当授权。
185
+
186
+ 仍未实现:跨会话提及/消息发送、完整附件及图片、终端、独立手机推送、支付和团队权限。桌面热接管依赖未公开 IPC,隔离测试不等于真实桌面写入验收。
187
+
188
+ 协议参考:
189
+ - <https://developers.openai.com/codex/app-server/>
190
+ - <https://github.com/openai/codex/blob/main/codex-rs/app-server-daemon/README.md>
package/cli.mjs ADDED
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import process from 'node:process';
7
+ import { spawn } from 'node:child_process';
8
+ import { fileURLToPath } from 'node:url';
9
+ import QRCode from 'qrcode';
10
+ import { createOrbitServer } from './server.mjs';
11
+ import { createRelayAgent } from './relay-agent.mjs';
12
+
13
+ const defaultServerUrl = 'https://orbit.cubesugar.tech';
14
+ const packageDirectory = path.dirname(fileURLToPath(import.meta.url));
15
+
16
+ function configDirectory() {
17
+ if (process.env.ORBIT_HOME) return path.resolve(process.env.ORBIT_HOME);
18
+ if (process.platform === 'win32') return path.join(process.env.LOCALAPPDATA || os.homedir(), 'Orbit');
19
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'orbit');
20
+ }
21
+
22
+ function configPath() {
23
+ return path.join(configDirectory(), 'device.json');
24
+ }
25
+
26
+ function normalizedServer(value) {
27
+ const parsed = new URL(value || defaultServerUrl);
28
+ if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.pathname !== '/'
29
+ || parsed.search || parsed.hash) throw new Error('Orbit 服务器地址必须为 HTTPS 域名。');
30
+ return parsed.origin;
31
+ }
32
+
33
+ async function saveConfig(config) {
34
+ const directory = configDirectory();
35
+ await fs.mkdir(directory, {recursive:true, mode:0o700});
36
+ const filename = configPath();
37
+ const temporary = `${filename}.${crypto.randomBytes(6).toString('hex')}.tmp`;
38
+ try {
39
+ await fs.writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, {encoding:'utf8', mode:0o600, flag:'wx'});
40
+ await fs.rename(temporary, filename);
41
+ try { await fs.chmod(filename, 0o600); } catch {}
42
+ } catch (error) {
43
+ try { await fs.unlink(temporary); } catch {}
44
+ throw error;
45
+ }
46
+ }
47
+
48
+ async function loadConfig() {
49
+ const filename = configPath();
50
+ try {
51
+ const config = JSON.parse(await fs.readFile(filename, 'utf8'));
52
+ if (config.version !== 1 || !config.deviceId || !config.agentToken || !config.relayUrl || !config.publicUrl) {
53
+ throw new Error(`Orbit 配置无效,请先删除 ${filename} 后运行 orbit login。`);
54
+ }
55
+ return config;
56
+ } catch (error) {
57
+ if (error.code === 'ENOENT') return null;
58
+ throw error;
59
+ }
60
+ }
61
+
62
+ function openBrowser(url) {
63
+ const command = process.platform === 'win32' ? 'rundll32.exe'
64
+ : process.platform === 'darwin' ? 'open' : 'xdg-open';
65
+ const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
66
+ try {
67
+ const child = spawn(command, args, {detached:true, stdio:'ignore', windowsHide:true});
68
+ child.on('error', () => {});
69
+ child.unref();
70
+ } catch {}
71
+ }
72
+
73
+ async function requestDeviceAuthorization(serverUrl, name) {
74
+ const response = await fetch(`${serverUrl}/api/cloud/device-auth/start`, {
75
+ method:'POST',
76
+ headers:{'Content-Type':'application/json'},
77
+ body:JSON.stringify({name})
78
+ });
79
+ const result = await response.json();
80
+ if (!response.ok) throw new Error(result.error || `无法发起设备连接 (${response.status})`);
81
+ return result;
82
+ }
83
+
84
+ async function waitForDeviceAuthorization(serverUrl, deviceCode, interval) {
85
+ const controller = new AbortController();
86
+ const stop = () => controller.abort();
87
+ process.once('SIGINT', stop);
88
+ process.once('SIGTERM', stop);
89
+ console.log('等待你在 Orbit 网页确认这台电脑。按 Ctrl+C 取消。');
90
+ try {
91
+ while (!controller.signal.aborted) {
92
+ const response = await fetch(`${serverUrl}/api/cloud/device-auth/poll`, {
93
+ method:'POST',
94
+ headers:{'Content-Type':'application/json'},
95
+ body:JSON.stringify({deviceCode}),
96
+ signal:controller.signal
97
+ });
98
+ const result = await response.json();
99
+ if (response.status === 200) return result;
100
+ if (!response.ok) throw new Error(result.error || `设备授权失败 (${response.status})`);
101
+ await new Promise((resolve, reject) => {
102
+ const finish = error => {
103
+ clearTimeout(timer);
104
+ controller.signal.removeEventListener('abort', onAbort);
105
+ error ? reject(error) : resolve();
106
+ };
107
+ const onAbort = () => finish(new DOMException('The operation was aborted.', 'AbortError'));
108
+ const timer = setTimeout(() => finish(), interval * 1000);
109
+ controller.signal.addEventListener('abort', onAbort, {once:true});
110
+ });
111
+ }
112
+ } finally {
113
+ process.off('SIGINT', stop);
114
+ process.off('SIGTERM', stop);
115
+ }
116
+ throw new DOMException('The operation was aborted.', 'AbortError');
117
+ }
118
+
119
+ async function authorizeComputer(serverUrl, name) {
120
+ const authorization = await requestDeviceAuthorization(serverUrl, name);
121
+ console.log(`\n在手机或浏览器打开:${authorization.verificationUri}`);
122
+ console.log(`登录 Orbit 后输入连接码:${authorization.userCode}`);
123
+ console.log(`连接码 ${Math.ceil(authorization.expiresIn / 60)} 分钟后失效。\n`);
124
+ openBrowser(authorization.verificationUri);
125
+ const issued = await waitForDeviceAuthorization(serverUrl, authorization.deviceCode, authorization.interval);
126
+ const config = {
127
+ version:1,
128
+ deviceId:issued.device.id,
129
+ deviceName:issued.device.name,
130
+ agentToken:issued.agentToken,
131
+ relayUrl:`${serverUrl.replace(/^https:/, 'wss:')}/agent`,
132
+ publicUrl:serverUrl,
133
+ port:4318
134
+ };
135
+ await saveConfig(config);
136
+ return config;
137
+ }
138
+
139
+ async function runComputer(config) {
140
+ process.env.ORBIT_HOST = '127.0.0.1';
141
+ process.env.ORBIT_PORT = String(config.port || 4318);
142
+ process.env.ORBIT_PUBLIC_URL = config.publicUrl;
143
+ const app = createOrbitServer({dataDir:configDirectory(), publicUrl:config.publicUrl});
144
+ let agent;
145
+ let listening = false;
146
+ try {
147
+ const info = await app.listen();
148
+ listening = true;
149
+ agent = createRelayAgent({
150
+ relayUrl:config.relayUrl,
151
+ localUrl:info.url,
152
+ deviceId:config.deviceId,
153
+ agentToken:config.agentToken,
154
+ onReady:() => console.log(`电脑“${config.deviceName}”已安全连接 Orbit。`),
155
+ onError:error => console.error(`Orbit 连接暂时中断:${error.message}`)
156
+ });
157
+ agent.start();
158
+ console.log(`Orbit 电脑端运行中:${info.url}`);
159
+ console.log(`手机工作台:${config.publicUrl}`);
160
+ console.log('保持此窗口运行;按 Ctrl+C 停止电脑端。');
161
+ const pairingUrl = `${config.publicUrl}/#pair=${encodeURIComponent(app.pairingKey)}`;
162
+ console.log('本机配对二维码(含长期本机密钥,仅用于自己的手机;不要发布或转发):');
163
+ console.log(await QRCode.toString(pairingUrl, {type:'terminal', errorCorrectionLevel:'L', small:true}));
164
+ console.log(`本机配对密钥:${app.pairingKey}`);
165
+ await new Promise(resolve => {
166
+ process.once('SIGINT', resolve);
167
+ process.once('SIGTERM', resolve);
168
+ });
169
+ } finally {
170
+ agent?.stop();
171
+ if (listening) await app.close();
172
+ }
173
+ }
174
+
175
+ async function main() {
176
+ const [command = 'login', ...args] = process.argv.slice(2);
177
+ if (command === '--version' || command === '-v') {
178
+ console.log(JSON.parse(await fs.readFile(path.join(packageDirectory, 'package.json'), 'utf8')).version);
179
+ return;
180
+ }
181
+ if (command === '--help' || command === '-h' || command === 'help') {
182
+ console.log('Orbit computer connector\n\nUsage:\n orbit login [--name "My computer"] [--server https://orbit.example.com]\n orbit status\n orbit logout');
183
+ return;
184
+ }
185
+ const namedIndex = args.indexOf('--name');
186
+ const name = namedIndex >= 0 ? args[namedIndex + 1] : os.hostname();
187
+ const serverIndex = args.indexOf('--server');
188
+ const serverUrl = normalizedServer(serverIndex >= 0 ? args[serverIndex + 1] : process.env.ORBIT_SERVER_URL);
189
+ const existing = await loadConfig();
190
+
191
+ if (command === 'status') {
192
+ console.log(existing
193
+ ? `电脑:${existing.deviceName}\n工作台:${existing.publicUrl}\n配置文件:${configPath()}`
194
+ : '此电脑尚未连接 Orbit。运行 orbit login 完成连接。');
195
+ return;
196
+ }
197
+ if (command === 'logout') {
198
+ if (!existing) {
199
+ console.log('此电脑尚未连接 Orbit。');
200
+ return;
201
+ }
202
+ const response = await fetch(`${existing.publicUrl}/api/cloud/device-auth/revoke`, {
203
+ method:'POST',
204
+ headers:{'Authorization':`Bearer ${existing.agentToken}`, 'Content-Type':'application/json'},
205
+ body:JSON.stringify({deviceId:existing.deviceId})
206
+ });
207
+ if (!response.ok && response.status !== 404) {
208
+ const result = await response.json().catch(() => ({}));
209
+ throw new Error(result.error || `无法撤销电脑凭证 (${response.status})。请检查网络后重试。`);
210
+ }
211
+ await fs.unlink(configPath());
212
+ console.log('电脑凭证已从服务器撤销,并从本机删除。');
213
+ return;
214
+ }
215
+ if (command !== 'login') throw new Error(`未知命令:${command}。运行 orbit --help 查看用法。`);
216
+ if (existing) {
217
+ await runComputer(existing);
218
+ return;
219
+ }
220
+ console.log('正在为这台电脑申请 Orbit 连接授权…');
221
+ const config = await authorizeComputer(serverUrl, name);
222
+ console.log(`已保存电脑“${config.deviceName}”的专属凭证到 ${configPath()}。`);
223
+ await runComputer(config);
224
+ }
225
+
226
+ export async function runCli() {
227
+ try {
228
+ await main();
229
+ } catch(error) {
230
+ if (error.name === 'AbortError') {
231
+ console.log('\n已取消 Orbit 连接。');
232
+ process.exitCode=130;
233
+ } else {
234
+ console.error(`Orbit:${error.message}`);
235
+ process.exitCode=1;
236
+ }
237
+ }
238
+ }
239
+
240
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
241
+ await runCli();
242
+ }
243
+
244
+ export {configDirectory, normalizedServer};