orbit-codex-bridge 0.0.0-stage → 0.1.1
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 +94 -2
- package/cli.mjs +324 -0
- package/lib/connector-service.mjs +259 -0
- package/lib/desktop.mjs +404 -0
- package/lib/device-auth.mjs +69 -0
- package/lib/rpc.mjs +203 -0
- package/lib/store.mjs +162 -0
- package/orbit +4 -0
- package/package.json +36 -4
- package/public/admin.css +67 -0
- package/public/admin.html +23 -0
- package/public/admin.js +186 -0
- package/public/app.js +1110 -0
- package/public/connect.html +56 -0
- package/public/connect.js +126 -0
- package/public/icon.svg +1 -0
- package/public/index.html +57 -0
- package/public/layout.css +73 -0
- package/public/manifest.webmanifest +1 -0
- package/public/style.css +40 -0
- package/relay-agent.mjs +218 -0
- package/relay-server.mjs +684 -0
- package/relay-store.mjs +368 -0
- package/server.mjs +491 -0
package/README.md
CHANGED
|
@@ -1,3 +1,95 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Orbit · 会话随行
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
从手机连接自己的电脑,查看和继续 Codex 桌面里的原会话。
|
|
4
|
+
|
|
5
|
+
Orbit 将电脑连接到 [Orbit 工作台](https://orbit.cubesugar.tech)。在网页中选择电脑和会话,即可查看历史、接收实时回复、发送指令、停止任务和处理审批。桌面已加载的会话会自动热接管,沿用原会话 ID 和执行上下文。
|
|
6
|
+
|
|
7
|
+
## 安装与连接
|
|
8
|
+
|
|
9
|
+
需要 Node.js 24+,并在电脑上安装、运行 Codex 桌面应用。
|
|
10
|
+
|
|
11
|
+
Windows PowerShell:
|
|
12
|
+
|
|
13
|
+
```powershell
|
|
14
|
+
npx.cmd --yes --package=orbit-codex-bridge@latest orbit login
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
macOS / Linux:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npx --yes --package=orbit-codex-bridge@latest orbit login
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
也可以全局安装,之后直接使用 `orbit` 命令:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npm install --global orbit-codex-bridge@latest
|
|
27
|
+
orbit login
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
1. 命令会打开电脑授权页面,登录或注册 Orbit 账号,确认添加这台电脑。
|
|
31
|
+
2. 手机打开 [orbit.cubesugar.tech](https://orbit.cubesugar.tech),登录同一账号。
|
|
32
|
+
3. 扫描终端显示的配对二维码,选择这台电脑,完成首次连接。
|
|
33
|
+
4. 选择原有会话,开始使用。尚未加载的会话可先在 Codex 桌面打开,再点“重新连接”。
|
|
34
|
+
|
|
35
|
+
授权信息保存在电脑本地,下次启动会自动复用。连接器默认在当前用户的后台运行,可以关闭安装终端;断网后自动重连,工作进程异常退出后自动恢复。电脑需要保持开机、联网,Codex 桌面需要保持运行。电脑重启后运行 `orbit start` 恢复连接。
|
|
36
|
+
|
|
37
|
+
## 常用命令
|
|
38
|
+
|
|
39
|
+
| 命令 | 用途 |
|
|
40
|
+
| --- | --- |
|
|
41
|
+
| `orbit login` | 首次授权,或复用已有授权启动后台连接器 |
|
|
42
|
+
| `orbit start` | 启动后台连接器 |
|
|
43
|
+
| `orbit status` | 查看后台运行状态、云端连接状态及日志路径 |
|
|
44
|
+
| `orbit stop` | 停止后台连接器,保留授权 |
|
|
45
|
+
| `orbit restart` | 重启后台连接器 |
|
|
46
|
+
| `orbit logout` | 停止连接并撤销这台电脑的授权 |
|
|
47
|
+
| `orbit rebind` | 清理旧绑定,重新打开网页授权这台电脑 |
|
|
48
|
+
| `orbit login --foreground` | 在当前终端运行,显示实时诊断信息 |
|
|
49
|
+
|
|
50
|
+
自建服务可在首次授权时指定地址:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
orbit login --server https://your-orbit-domain.com
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 更新
|
|
57
|
+
|
|
58
|
+
使用 npx 的用户重新执行安装命令即可获取最新版。全局安装的用户运行:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
npm install --global orbit-codex-bridge@latest
|
|
62
|
+
orbit login
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
连接器会更新后台运行时,复用已有电脑授权。
|
|
66
|
+
|
|
67
|
+
从早期需要保持终端打开的版本升级时,先在旧终端按 `Ctrl+C` 停止旧连接器,再运行新版命令。
|
|
68
|
+
|
|
69
|
+
## 移除与重新绑定
|
|
70
|
+
|
|
71
|
+
在工作台的电脑列表点击“移除电脑”,确认后立即撤销连接凭证,释放设备名额。需要再次绑定时,在原电脑执行:
|
|
72
|
+
|
|
73
|
+
```powershell
|
|
74
|
+
npx.cmd --yes --package=orbit-codex-bridge@latest orbit rebind
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
重新登录并确认电脑授权即可。移除或重绑不会删除电脑上的 Codex 项目和原有会话。
|
|
78
|
+
|
|
79
|
+
## 常见问题
|
|
80
|
+
|
|
81
|
+
**Windows 提示禁止运行 `npx.ps1`**
|
|
82
|
+
|
|
83
|
+
在 PowerShell 中使用 `npx.cmd`。全局安装时也可使用 `npm.cmd`,无需修改系统脚本执行策略。
|
|
84
|
+
|
|
85
|
+
**电脑显示离线**
|
|
86
|
+
|
|
87
|
+
先确认电脑开机并联网,运行 `orbit status` 查看状态。短暂断网会自动恢复;电脑重启后运行 `orbit start`。仍有问题时,按状态命令显示的路径查看 `connector.log`,或使用 `orbit login --foreground` 诊断。
|
|
88
|
+
|
|
89
|
+
**能看历史,但会话尚未连接**
|
|
90
|
+
|
|
91
|
+
在 Codex 桌面打开同一条会话,再点击网页中的“重新连接”。桌面热接管依赖 Codex 内部接口,桌面版本更新后可能需要更新 Orbit。
|
|
92
|
+
|
|
93
|
+
**配对二维码与密钥**
|
|
94
|
+
|
|
95
|
+
只在自己的设备上使用。每台电脑使用独立连接凭证,可在工作台移除电脑,或运行 `orbit logout` 撤销授权。连接中断时,Orbit 不会自动重发已提交的消息和审批。
|
package/cli.mjs
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
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
|
+
import {connectorStatus,installConnectorRuntime,startConnector,stopConnector,superviseConnector} from './lib/connector-service.mjs';
|
|
13
|
+
|
|
14
|
+
const defaultServerUrl = 'https://orbit.cubesugar.tech';
|
|
15
|
+
const packageDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
function configDirectory() {
|
|
18
|
+
if (process.env.ORBIT_HOME) return path.resolve(process.env.ORBIT_HOME);
|
|
19
|
+
if (process.platform === 'win32') return path.join(process.env.LOCALAPPDATA || os.homedir(), 'Orbit');
|
|
20
|
+
return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'orbit');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function configPath() {
|
|
24
|
+
return path.join(configDirectory(), 'device.json');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizedServer(value) {
|
|
28
|
+
const parsed = new URL(value || defaultServerUrl);
|
|
29
|
+
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.pathname !== '/'
|
|
30
|
+
|| parsed.search || parsed.hash) throw new Error('Orbit 服务器地址必须为 HTTPS 域名。');
|
|
31
|
+
return parsed.origin;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function saveConfig(config) {
|
|
35
|
+
const directory = configDirectory();
|
|
36
|
+
await fs.mkdir(directory, {recursive:true, mode:0o700});
|
|
37
|
+
const filename = configPath();
|
|
38
|
+
const temporary = `${filename}.${crypto.randomBytes(6).toString('hex')}.tmp`;
|
|
39
|
+
try {
|
|
40
|
+
await fs.writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, {encoding:'utf8', mode:0o600, flag:'wx'});
|
|
41
|
+
await fs.rename(temporary, filename);
|
|
42
|
+
try { await fs.chmod(filename, 0o600); } catch {}
|
|
43
|
+
} catch (error) {
|
|
44
|
+
try { await fs.unlink(temporary); } catch {}
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function loadConfig() {
|
|
50
|
+
const filename = configPath();
|
|
51
|
+
try {
|
|
52
|
+
const config = JSON.parse(await fs.readFile(filename, 'utf8'));
|
|
53
|
+
if (config.version !== 1 || !config.deviceId || !config.agentToken || !config.relayUrl || !config.publicUrl) {
|
|
54
|
+
throw new Error(`Orbit 配置无效,请先删除 ${filename} 后运行 orbit login。`);
|
|
55
|
+
}
|
|
56
|
+
return config;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error.code === 'ENOENT') return null;
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function openBrowser(url) {
|
|
64
|
+
const command = process.platform === 'win32' ? 'rundll32.exe'
|
|
65
|
+
: process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
66
|
+
const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
|
|
67
|
+
try {
|
|
68
|
+
const child = spawn(command, args, {detached:true, stdio:'ignore', windowsHide:true});
|
|
69
|
+
child.on('error', () => {});
|
|
70
|
+
child.unref();
|
|
71
|
+
} catch {}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function requestDeviceAuthorization(serverUrl, name) {
|
|
75
|
+
const response = await fetch(`${serverUrl}/api/cloud/device-auth/start`, {
|
|
76
|
+
method:'POST',
|
|
77
|
+
headers:{'Content-Type':'application/json'},
|
|
78
|
+
body:JSON.stringify({name})
|
|
79
|
+
});
|
|
80
|
+
const result = await response.json();
|
|
81
|
+
if (!response.ok) throw new Error(result.error || `无法发起设备连接 (${response.status})`);
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function waitForDeviceAuthorization(serverUrl, deviceCode, interval) {
|
|
86
|
+
const controller = new AbortController();
|
|
87
|
+
const stop = () => controller.abort();
|
|
88
|
+
process.once('SIGINT', stop);
|
|
89
|
+
process.once('SIGTERM', stop);
|
|
90
|
+
console.log('等待你在 Orbit 网页确认这台电脑。按 Ctrl+C 取消。');
|
|
91
|
+
try {
|
|
92
|
+
while (!controller.signal.aborted) {
|
|
93
|
+
const response = await fetch(`${serverUrl}/api/cloud/device-auth/poll`, {
|
|
94
|
+
method:'POST',
|
|
95
|
+
headers:{'Content-Type':'application/json'},
|
|
96
|
+
body:JSON.stringify({deviceCode}),
|
|
97
|
+
signal:controller.signal
|
|
98
|
+
});
|
|
99
|
+
const result = await response.json();
|
|
100
|
+
if (response.status === 200) return result;
|
|
101
|
+
if (!response.ok) throw new Error(result.error || `设备授权失败 (${response.status})`);
|
|
102
|
+
await new Promise((resolve, reject) => {
|
|
103
|
+
const finish = error => {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
controller.signal.removeEventListener('abort', onAbort);
|
|
106
|
+
error ? reject(error) : resolve();
|
|
107
|
+
};
|
|
108
|
+
const onAbort = () => finish(new DOMException('The operation was aborted.', 'AbortError'));
|
|
109
|
+
const timer = setTimeout(() => finish(), interval * 1000);
|
|
110
|
+
controller.signal.addEventListener('abort', onAbort, {once:true});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
} finally {
|
|
114
|
+
process.off('SIGINT', stop);
|
|
115
|
+
process.off('SIGTERM', stop);
|
|
116
|
+
}
|
|
117
|
+
throw new DOMException('The operation was aborted.', 'AbortError');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function authorizeComputer(serverUrl, name) {
|
|
121
|
+
const authorization = await requestDeviceAuthorization(serverUrl, name);
|
|
122
|
+
console.log(`\n在手机或浏览器打开:${authorization.verificationUri}`);
|
|
123
|
+
console.log(`登录 Orbit 后输入连接码:${authorization.userCode}`);
|
|
124
|
+
console.log(`连接码 ${Math.ceil(authorization.expiresIn / 60)} 分钟后失效。\n`);
|
|
125
|
+
openBrowser(authorization.verificationUri);
|
|
126
|
+
const issued = await waitForDeviceAuthorization(serverUrl, authorization.deviceCode, authorization.interval);
|
|
127
|
+
const config = {
|
|
128
|
+
version:1,
|
|
129
|
+
deviceId:issued.device.id,
|
|
130
|
+
deviceName:issued.device.name,
|
|
131
|
+
agentToken:issued.agentToken,
|
|
132
|
+
relayUrl:`${serverUrl.replace(/^https:/, 'wss:')}/agent`,
|
|
133
|
+
publicUrl:serverUrl,
|
|
134
|
+
port:4318
|
|
135
|
+
};
|
|
136
|
+
await saveConfig(config);
|
|
137
|
+
return config;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function existingOrbitUrl(port) {
|
|
141
|
+
const url=`http://127.0.0.1:${port}`;
|
|
142
|
+
try {
|
|
143
|
+
const response=await fetch(`${url}/healthz`,{signal:AbortSignal.timeout(1500),cache:'no-store'});
|
|
144
|
+
if(!response.ok)return null;
|
|
145
|
+
const health=await response.json();
|
|
146
|
+
return health.ok===true?url:null;
|
|
147
|
+
} catch { return null; }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function printPairing(pairingKey, publicUrl) {
|
|
151
|
+
const pairingUrl = `${publicUrl}/#pair=${encodeURIComponent(pairingKey)}`;
|
|
152
|
+
console.log('本机配对二维码和密钥仅供自己的手机使用,请勿公开。');
|
|
153
|
+
console.log(await QRCode.toString(pairingUrl,{type:'terminal',errorCorrectionLevel:'L',small:true}));
|
|
154
|
+
console.log(`本机配对密钥:${pairingKey}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function runComputer(config, background = false) {
|
|
158
|
+
process.env.ORBIT_HOST = '127.0.0.1';
|
|
159
|
+
process.env.ORBIT_PORT = String(config.port || 4318);
|
|
160
|
+
process.env.ORBIT_PUBLIC_URL = config.publicUrl;
|
|
161
|
+
let app=null;
|
|
162
|
+
let agent;
|
|
163
|
+
let localUrl=await existingOrbitUrl(config.port || 4318);
|
|
164
|
+
if(localUrl)console.log(`检测到本机 Orbit 已运行,将复用 ${localUrl} 并连接云端。`);
|
|
165
|
+
try {
|
|
166
|
+
if(!localUrl){
|
|
167
|
+
app=createOrbitServer({dataDir:configDirectory(),publicUrl:config.publicUrl});
|
|
168
|
+
const info=await app.listen();
|
|
169
|
+
localUrl=info.url;
|
|
170
|
+
console.log(`Orbit 电脑端运行中:${localUrl}`);
|
|
171
|
+
if (!background) await printPairing(app.pairingKey,config.publicUrl);
|
|
172
|
+
}
|
|
173
|
+
agent = createRelayAgent({
|
|
174
|
+
relayUrl:config.relayUrl,
|
|
175
|
+
localUrl,
|
|
176
|
+
deviceId:config.deviceId,
|
|
177
|
+
agentToken:config.agentToken,
|
|
178
|
+
onReady:() => console.log(`电脑“${config.deviceName}”已安全连接 Orbit。`),
|
|
179
|
+
onState:state => {
|
|
180
|
+
if (background && process.connected) process.send({type:'connector-state',state:{...state,workerReady:true}},() => {});
|
|
181
|
+
},
|
|
182
|
+
onError:error => console.error(`Orbit 连接暂时中断:${error.message}`)
|
|
183
|
+
});
|
|
184
|
+
agent.start();
|
|
185
|
+
if(!app)console.log(`复用本机 Orbit:${localUrl}`);
|
|
186
|
+
console.log(`手机工作台:${config.publicUrl}`);
|
|
187
|
+
if (!background) console.log('前台诊断模式;保持此窗口运行,按 Ctrl+C 停止。');
|
|
188
|
+
await new Promise(resolve => {
|
|
189
|
+
const stop = () => {
|
|
190
|
+
process.off('SIGINT',stop);process.off('SIGTERM',stop);process.off('message',message);process.off('disconnect',stop);
|
|
191
|
+
resolve();
|
|
192
|
+
};
|
|
193
|
+
const message = packet => { if (packet?.type === 'stop') stop(); };
|
|
194
|
+
process.once('SIGINT',stop);
|
|
195
|
+
process.once('SIGTERM',stop);
|
|
196
|
+
if (background) {process.on('message',message);process.once('disconnect',stop);}
|
|
197
|
+
});
|
|
198
|
+
} finally {
|
|
199
|
+
agent?.stop();
|
|
200
|
+
if (app) await app.close();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function runBackground(config) {
|
|
205
|
+
console.log('正在启动 Orbit 后台连接器…');
|
|
206
|
+
const runtime = await installConnectorRuntime(packageDirectory,configDirectory());
|
|
207
|
+
const service = await startConnector({home:configDirectory(),...runtime});
|
|
208
|
+
const label = service.status === 'online' ? '已连接云端' : service.status === 'blocked' ? service.reason : '正在自动连接云端';
|
|
209
|
+
console.log(`电脑“${config.deviceName}”后台运行中 · ${label}`);
|
|
210
|
+
console.log(`手机工作台:${config.publicUrl}`);
|
|
211
|
+
console.log('可以关闭此终端。断网自动重连,异常退出自动恢复;电脑休眠期间无法远程使用。');
|
|
212
|
+
console.log('停止:orbit stop;状态:orbit status;前台诊断:orbit login --foreground');
|
|
213
|
+
console.log(`诊断日志:${path.join(configDirectory(),'connector.log')}`);
|
|
214
|
+
try {
|
|
215
|
+
const key = (await fs.readFile(path.join(configDirectory(),'pairing-key'),'utf8')).trim();
|
|
216
|
+
if (key) await printPairing(key,config.publicUrl);
|
|
217
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function revokeComputer(config) {
|
|
221
|
+
const response = await fetch(`${config.publicUrl}/api/cloud/device-auth/revoke`, {
|
|
222
|
+
method:'POST',
|
|
223
|
+
headers:{'Authorization':`Bearer ${config.agentToken}`,'Content-Type':'application/json'},
|
|
224
|
+
body:JSON.stringify({deviceId:config.deviceId}),
|
|
225
|
+
signal:AbortSignal.timeout(15000)
|
|
226
|
+
});
|
|
227
|
+
if (!response.ok && response.status !== 404) {
|
|
228
|
+
const result = await response.json().catch(() => ({}));
|
|
229
|
+
throw new Error(result.error || `无法撤销电脑凭证 (${response.status})。请检查网络后重试。`);
|
|
230
|
+
}
|
|
231
|
+
await stopConnector(configDirectory());
|
|
232
|
+
await fs.unlink(configPath());
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function main() {
|
|
236
|
+
const [command = 'login', ...args] = process.argv.slice(2);
|
|
237
|
+
if (command === '--version' || command === '-v') {
|
|
238
|
+
console.log(JSON.parse(await fs.readFile(path.join(packageDirectory, 'package.json'), 'utf8')).version);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (command === '--help' || command === '-h' || command === 'help') {
|
|
242
|
+
console.log('Orbit computer connector\n\nUsage:\n orbit login [--name "My computer"] [--server https://orbit.example.com] [--foreground]\n orbit start\n orbit stop\n orbit restart\n orbit status\n orbit logout\n orbit rebind');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const namedIndex = args.indexOf('--name');
|
|
246
|
+
let name = namedIndex >= 0 ? args[namedIndex + 1] : os.hostname();
|
|
247
|
+
const serverIndex = args.indexOf('--server');
|
|
248
|
+
let serverUrl = normalizedServer(serverIndex >= 0 ? args[serverIndex + 1] : process.env.ORBIT_SERVER_URL);
|
|
249
|
+
let existing = await loadConfig();
|
|
250
|
+
if (command === '__service' || command === '__worker') {
|
|
251
|
+
if (!existing) throw new Error('电脑凭证不存在,请运行 orbit login。');
|
|
252
|
+
if (command === '__worker') {
|
|
253
|
+
if (!process.connected) throw new Error('工作进程只能由后台连接器启动。');
|
|
254
|
+
await runComputer(existing,true);
|
|
255
|
+
} else {
|
|
256
|
+
const version = JSON.parse(await fs.readFile(path.join(packageDirectory,'package.json'),'utf8')).version;
|
|
257
|
+
await superviseConnector({home:configDirectory(),config:existing,cliPath:fileURLToPath(import.meta.url),version});
|
|
258
|
+
}
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (command === 'status') {
|
|
263
|
+
const service = await connectorStatus(configDirectory());
|
|
264
|
+
const labels = {online:'在线',connecting:'连接中',reconnecting:'断线重连中',blocked:'连接被拒绝',starting:'启动中',restarting:'异常恢复中'};
|
|
265
|
+
console.log(existing
|
|
266
|
+
? `电脑:${existing.deviceName}\n工作台:${existing.publicUrl}\n后台服务:${service ? labels[service.status] || service.status : '未运行'}${service?.reason ? ` · ${service.reason}` : ''}\n配置文件:${configPath()}\n日志文件:${path.join(configDirectory(),'connector.log')}`
|
|
267
|
+
: '此电脑尚未连接 Orbit。运行 orbit login 完成连接。');
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (command === 'stop') {
|
|
271
|
+
console.log(await stopConnector(configDirectory()) ? 'Orbit 后台连接器已停止,授权已保留。' : 'Orbit 后台连接器未运行。');
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (command === 'logout') {
|
|
275
|
+
if (!existing) {
|
|
276
|
+
console.log('此电脑尚未连接 Orbit。');
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
await revokeComputer(existing);
|
|
280
|
+
console.log('电脑凭证已从服务器撤销,并从本机删除。');
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (!['login','start','restart','rebind'].includes(command)) throw new Error(`未知命令:${command}。运行 orbit --help 查看用法。`);
|
|
284
|
+
if (command === 'rebind' && existing) {
|
|
285
|
+
if (namedIndex < 0) name = existing.deviceName;
|
|
286
|
+
if (serverIndex < 0) serverUrl = normalizedServer(existing.publicUrl);
|
|
287
|
+
await revokeComputer(existing);
|
|
288
|
+
existing = null;
|
|
289
|
+
console.log('旧电脑授权已移除,正在重新绑定。');
|
|
290
|
+
}
|
|
291
|
+
if (command === 'restart') await stopConnector(configDirectory());
|
|
292
|
+
if (existing) {
|
|
293
|
+
if (args.includes('--foreground')) {
|
|
294
|
+
await stopConnector(configDirectory());
|
|
295
|
+
await runComputer(existing);
|
|
296
|
+
} else await runBackground(existing);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
console.log('正在为这台电脑申请 Orbit 连接授权…');
|
|
300
|
+
const config = await authorizeComputer(serverUrl, name);
|
|
301
|
+
console.log(`已保存电脑“${config.deviceName}”的专属凭证到 ${configPath()}。`);
|
|
302
|
+
if (args.includes('--foreground')) await runComputer(config);
|
|
303
|
+
else await runBackground(config);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export async function runCli() {
|
|
307
|
+
try {
|
|
308
|
+
await main();
|
|
309
|
+
} catch(error) {
|
|
310
|
+
if (error.name === 'AbortError') {
|
|
311
|
+
console.log('\n已取消 Orbit 连接。');
|
|
312
|
+
process.exitCode=130;
|
|
313
|
+
} else {
|
|
314
|
+
console.error(`Orbit:${error.message}`);
|
|
315
|
+
process.exitCode=1;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
321
|
+
await runCli();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export {configDirectory, normalizedServer};
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {spawn} from 'node:child_process';
|
|
6
|
+
import {createRequire} from 'node:module';
|
|
7
|
+
|
|
8
|
+
const stateFile = home => path.join(home,'service.json');
|
|
9
|
+
const pause = milliseconds => new Promise(resolve => setTimeout(resolve,milliseconds));
|
|
10
|
+
|
|
11
|
+
async function serviceRequest(home, action = 'status') {
|
|
12
|
+
let saved;
|
|
13
|
+
try { saved = JSON.parse(await fs.readFile(stateFile(home),'utf8')); }
|
|
14
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
15
|
+
if (!Number.isInteger(saved.port) || saved.port < 1 || saved.port > 65535 || !/^[a-f0-9]{64}$/.test(saved.token)) return null;
|
|
16
|
+
try {
|
|
17
|
+
const response = await fetch(`http://127.0.0.1:${saved.port}/${action}`,{
|
|
18
|
+
method:action === 'stop' ? 'POST' : 'GET',
|
|
19
|
+
headers:{Authorization:`Bearer ${saved.token}`},
|
|
20
|
+
signal:AbortSignal.timeout(1500)
|
|
21
|
+
});
|
|
22
|
+
if (!response.ok) return null;
|
|
23
|
+
const result = await response.json();
|
|
24
|
+
return result.product === 'Orbit connector' ? result : null;
|
|
25
|
+
} catch { return null; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const connectorStatus = home => serviceRequest(home);
|
|
29
|
+
|
|
30
|
+
export async function stopConnector(home) {
|
|
31
|
+
if (!await serviceRequest(home,'stop')) return false;
|
|
32
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
33
|
+
const locked = await fs.access(path.join(home,'connector.lock')).then(() => true,() => false);
|
|
34
|
+
if (!await connectorStatus(home) && !locked) return true;
|
|
35
|
+
await pause(100);
|
|
36
|
+
}
|
|
37
|
+
throw new Error('后台连接器尚未停止,请稍后重试。');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function packageRoot(resolver, name) {
|
|
41
|
+
let directory = path.dirname(resolver.resolve(name));
|
|
42
|
+
while (true) {
|
|
43
|
+
try {
|
|
44
|
+
const manifest = JSON.parse(await fs.readFile(path.join(directory,'package.json'),'utf8'));
|
|
45
|
+
if (manifest.name === name) return directory;
|
|
46
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
47
|
+
const parent = path.dirname(directory);
|
|
48
|
+
if (parent === directory) throw new Error(`找不到依赖:${name}`);
|
|
49
|
+
directory = parent;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function installConnectorRuntime(source, home) {
|
|
54
|
+
const manifest = JSON.parse(await fs.readFile(path.join(source,'package.json'),'utf8'));
|
|
55
|
+
const target = path.join(home,'runtime',manifest.version);
|
|
56
|
+
try { await fs.access(path.join(target,'ready')); return {cliPath:path.join(target,'cli.mjs'),version:manifest.version}; }
|
|
57
|
+
catch {}
|
|
58
|
+
const temporary = `${target}.${crypto.randomBytes(6).toString('hex')}.tmp`;
|
|
59
|
+
await fs.mkdir(temporary,{recursive:true,mode:0o700});
|
|
60
|
+
const copied = new Map();
|
|
61
|
+
async function copyDependency(name, resolver) {
|
|
62
|
+
const original = await packageRoot(resolver,name);
|
|
63
|
+
if (copied.has(name)) {
|
|
64
|
+
if (copied.get(name) !== original) throw new Error(`依赖 ${name} 存在多个版本,无法安装后台运行时。`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
copied.set(name,original);
|
|
68
|
+
const destination = path.join(temporary,'node_modules',name);
|
|
69
|
+
await fs.cp(original,destination,{recursive:true,filter:entry => path.basename(entry) !== 'node_modules'});
|
|
70
|
+
const dependency = JSON.parse(await fs.readFile(path.join(original,'package.json'),'utf8'));
|
|
71
|
+
for (const child of Object.keys(dependency.dependencies || {})) {
|
|
72
|
+
await copyDependency(child,createRequire(path.join(original,'package.json')));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
for (const filename of ['package.json',...manifest.files]) {
|
|
77
|
+
await fs.cp(path.join(source,filename),path.join(temporary,filename),{recursive:true});
|
|
78
|
+
}
|
|
79
|
+
for (const name of Object.keys(manifest.dependencies || {})) await copyDependency(name,createRequire(path.join(source,'package.json')));
|
|
80
|
+
await fs.writeFile(path.join(temporary,'ready'),manifest.version,{mode:0o600});
|
|
81
|
+
try { await fs.rename(temporary,target); }
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (!['EEXIST','ENOTEMPTY','EPERM'].includes(error.code)) throw error;
|
|
84
|
+
await fs.access(path.join(target,'ready'));
|
|
85
|
+
}
|
|
86
|
+
} finally {
|
|
87
|
+
const resolved = path.resolve(temporary);
|
|
88
|
+
if (path.dirname(resolved) !== path.join(path.resolve(home),'runtime') || !path.basename(resolved).endsWith('.tmp')) throw new Error('无效的临时运行时路径。');
|
|
89
|
+
await fs.rm(resolved,{recursive:true,force:true});
|
|
90
|
+
}
|
|
91
|
+
return {cliPath:path.join(target,'cli.mjs'),version:manifest.version};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function startConnector({home,cliPath,version}) {
|
|
95
|
+
const running = await connectorStatus(home);
|
|
96
|
+
if (running?.version === version && running.workerReady) return running;
|
|
97
|
+
if (running) await stopConnector(home);
|
|
98
|
+
await fs.mkdir(home,{recursive:true,mode:0o700});
|
|
99
|
+
const startupLog = path.join(home,'service-startup.log');
|
|
100
|
+
const diagnostic = await fs.open(startupLog,'w',0o600);
|
|
101
|
+
let child;
|
|
102
|
+
try {
|
|
103
|
+
child = spawn(process.execPath,[cliPath,'__service'],{
|
|
104
|
+
detached:true,windowsHide:true,stdio:['ignore',diagnostic.fd,diagnostic.fd],env:{...process.env,ORBIT_HOME:home}
|
|
105
|
+
});
|
|
106
|
+
await new Promise((resolve,reject) => {
|
|
107
|
+
child.once('spawn',resolve);
|
|
108
|
+
child.once('error',reject);
|
|
109
|
+
});
|
|
110
|
+
} finally {await diagnostic.close();}
|
|
111
|
+
let exited = false;
|
|
112
|
+
child.once('exit',() => {exited = true;});
|
|
113
|
+
child.unref();
|
|
114
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
115
|
+
const current = await connectorStatus(home);
|
|
116
|
+
if (current?.version === version && current.workerReady) return current;
|
|
117
|
+
if (exited) {
|
|
118
|
+
const diagnostic = (await fs.readFile(startupLog,'utf8').catch(() => '')).slice(-1500);
|
|
119
|
+
throw new Error(`后台连接器启动失败。${diagnostic}\n诊断日志:${startupLog}`);
|
|
120
|
+
}
|
|
121
|
+
await pause(150);
|
|
122
|
+
}
|
|
123
|
+
throw new Error(`后台连接器未就绪。诊断日志:${path.join(home,'connector.log')}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function superviseConnector(options) {
|
|
127
|
+
const {home} = options;
|
|
128
|
+
await fs.mkdir(home,{recursive:true,mode:0o700});
|
|
129
|
+
const lockfile = path.join(home,'connector.lock');
|
|
130
|
+
async function acquire() {
|
|
131
|
+
try {
|
|
132
|
+
const handle = await fs.open(lockfile,'wx',0o600);
|
|
133
|
+
try {await handle.writeFile(String(process.pid));}
|
|
134
|
+
finally {await handle.close();}
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error.code !== 'EEXIST') throw error;
|
|
137
|
+
const owner = Number(await fs.readFile(lockfile,'utf8'));
|
|
138
|
+
if (!Number.isInteger(owner) || owner < 1) throw new Error('另一个后台连接器正在启动,请稍后重试。');
|
|
139
|
+
try {process.kill(owner,0);}
|
|
140
|
+
catch (check) {
|
|
141
|
+
if (check.code !== 'ESRCH') throw check;
|
|
142
|
+
await fs.unlink(lockfile);
|
|
143
|
+
await acquire();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
throw new Error('Orbit 后台连接器已经运行。');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
await acquire();
|
|
150
|
+
try {await runSupervisor(options);}
|
|
151
|
+
finally {
|
|
152
|
+
if ((await fs.readFile(lockfile,'utf8').catch(() => '')) === String(process.pid)) await fs.unlink(lockfile);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function runSupervisor({home,cliPath,version,restartDelay = 1000}) {
|
|
157
|
+
await fs.mkdir(home,{recursive:true,mode:0o700});
|
|
158
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
159
|
+
const logfile = path.join(home,'connector.log');
|
|
160
|
+
let logBytes = (await fs.stat(logfile).catch(() => ({size:0}))).size;
|
|
161
|
+
let writes = Promise.resolve();
|
|
162
|
+
function log(text) {
|
|
163
|
+
writes = writes.then(async () => {
|
|
164
|
+
const line = `${new Date().toISOString()} ${text.toString().trimEnd()}\n`;
|
|
165
|
+
if (logBytes > 2 * 1024 * 1024) {
|
|
166
|
+
await fs.rename(logfile,`${logfile}.1`);
|
|
167
|
+
logBytes = 0;
|
|
168
|
+
}
|
|
169
|
+
await fs.appendFile(logfile,line,{mode:0o600});
|
|
170
|
+
logBytes += Buffer.byteLength(line);
|
|
171
|
+
}).catch(() => {});
|
|
172
|
+
}
|
|
173
|
+
let worker = null;
|
|
174
|
+
let stopped = false;
|
|
175
|
+
let restartTimer;
|
|
176
|
+
let failures = 0;
|
|
177
|
+
let state = {product:'Orbit connector',version,pid:process.pid,workerReady:false,status:'starting',startedAt:Date.now()};
|
|
178
|
+
const finished = Promise.withResolvers();
|
|
179
|
+
async function stop() {
|
|
180
|
+
if (stopped) return;
|
|
181
|
+
stopped = true;
|
|
182
|
+
clearTimeout(restartTimer);
|
|
183
|
+
if (worker) {
|
|
184
|
+
const previous = worker;
|
|
185
|
+
const exited = new Promise(resolve => previous.once('exit',resolve));
|
|
186
|
+
const force = setTimeout(() => previous.kill(),3000);
|
|
187
|
+
if (previous.connected) previous.send({type:'stop'},() => {});
|
|
188
|
+
else previous.kill();
|
|
189
|
+
await exited;
|
|
190
|
+
clearTimeout(force);
|
|
191
|
+
}
|
|
192
|
+
await new Promise(resolve => control.close(resolve));
|
|
193
|
+
try {
|
|
194
|
+
const saved = JSON.parse(await fs.readFile(stateFile(home),'utf8'));
|
|
195
|
+
if (saved.token === token) await fs.unlink(stateFile(home));
|
|
196
|
+
} catch {}
|
|
197
|
+
log('连接器已停止。');
|
|
198
|
+
await writes;
|
|
199
|
+
finished.resolve();
|
|
200
|
+
}
|
|
201
|
+
const control = http.createServer((request,response) => {
|
|
202
|
+
response.setHeader('Content-Type','application/json');
|
|
203
|
+
response.setHeader('Cache-Control','no-store');
|
|
204
|
+
if (request.headers.origin || request.headers.authorization !== `Bearer ${token}`) {
|
|
205
|
+
response.writeHead(403);response.end('{}');return;
|
|
206
|
+
}
|
|
207
|
+
if (request.method === 'GET' && request.url === '/status') return response.end(JSON.stringify(state));
|
|
208
|
+
if (request.method === 'POST' && request.url === '/stop') {
|
|
209
|
+
response.end(JSON.stringify({...state,stopping:true}));
|
|
210
|
+
void stop();return;
|
|
211
|
+
}
|
|
212
|
+
response.writeHead(404);response.end('{}');
|
|
213
|
+
});
|
|
214
|
+
control.requestTimeout = 5000;
|
|
215
|
+
control.headersTimeout = 5000;
|
|
216
|
+
control.keepAliveTimeout = 1000;
|
|
217
|
+
await new Promise((resolve,reject) => {
|
|
218
|
+
control.once('error',reject);
|
|
219
|
+
control.listen(0,'127.0.0.1',resolve);
|
|
220
|
+
});
|
|
221
|
+
try {
|
|
222
|
+
const temporary = `${stateFile(home)}.${token}.tmp`;
|
|
223
|
+
await fs.writeFile(temporary,JSON.stringify({port:control.address().port,token}),{mode:0o600});
|
|
224
|
+
await fs.rename(temporary,stateFile(home));
|
|
225
|
+
} catch (error) {
|
|
226
|
+
await new Promise(resolve => control.close(resolve));throw error;
|
|
227
|
+
}
|
|
228
|
+
function launchWorker() {
|
|
229
|
+
if (stopped) return;
|
|
230
|
+
const started = Date.now();
|
|
231
|
+
state = {...state,workerReady:false,status:'starting'};
|
|
232
|
+
worker = spawn(process.execPath,[cliPath,'__worker'],{
|
|
233
|
+
windowsHide:true,stdio:['ignore','pipe','pipe','ipc'],env:{...process.env,ORBIT_HOME:home}
|
|
234
|
+
});
|
|
235
|
+
state.workerPid = worker.pid;
|
|
236
|
+
worker.stdout.on('data',log);
|
|
237
|
+
worker.stderr.on('data',log);
|
|
238
|
+
worker.on('error',error => log(error.message));
|
|
239
|
+
worker.on('message',message => {
|
|
240
|
+
if (message?.type === 'connector-state') state = {...state,...message.state};
|
|
241
|
+
});
|
|
242
|
+
worker.once('exit',(code,signal) => {
|
|
243
|
+
worker = null;
|
|
244
|
+
state = {...state,workerReady:false,status:'restarting'};
|
|
245
|
+
if (stopped) return;
|
|
246
|
+
failures = Date.now() - started > 60000 ? 0 : failures + 1;
|
|
247
|
+
const delay = Math.min(30000,restartDelay * 2 ** Math.min(failures - 1,5));
|
|
248
|
+
log(`工作进程退出 (${code ?? signal}),${delay}ms 后自动恢复。`);
|
|
249
|
+
restartTimer = setTimeout(launchWorker,delay);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
process.once('SIGINT',stop);
|
|
253
|
+
process.once('SIGTERM',stop);
|
|
254
|
+
log(`Orbit ${version} 后台连接器启动。`);
|
|
255
|
+
launchWorker();
|
|
256
|
+
await finished.promise;
|
|
257
|
+
process.off('SIGINT',stop);
|
|
258
|
+
process.off('SIGTERM',stop);
|
|
259
|
+
}
|