benchgo 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,25 @@
1
- # Temporary Holding Version
1
+ # benchgo
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
+ BenchGo 工作台命令。安装和管工作台,可以连接外部中继,不安装中继服务。
4
+
5
+ ```sh
6
+ npm install -g benchgo
7
+ benchgo install
8
+ benchgo server status
9
+ ```
10
+
11
+ 也可以:
12
+
13
+ ```sh
14
+ benchgo update
15
+ benchgo server <install|update|status|start|stop|restart>
16
+ ```
17
+
18
+ 中继服务器是另一个包:
19
+
20
+ ```sh
21
+ npm install -g benchgo-relay
22
+ benchgo-relay install --config /path/to/relay.json
23
+ ```
24
+
25
+ `benchgo install` 需要 Linux、Node.js 22+ 和 systemd 用户服务。资源目录不在当前工作区时,用 `BENCHGO_ROOT` 或 `--root`。
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.mjs';
3
+
4
+ await main(process.argv.slice(2));
package/package.json CHANGED
@@ -1,6 +1,25 @@
1
1
  {
2
2
  "name": "benchgo",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/zhuogo/BenchGo.git"
7
+ },
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "description": "BenchGo workbench CLI",
12
+ "license": "MIT",
13
+ "type": "module",
14
+ "bin": {
15
+ "benchgo": "./bin/benchgo.mjs"
16
+ },
17
+ "files": [
18
+ "bin",
19
+ "src",
20
+ "resources"
21
+ ],
22
+ "engines": {
23
+ "node": ">=22"
24
+ }
25
+ }
@@ -0,0 +1,147 @@
1
+ /** Run only through a verified SSH channel. Final stdout JSON is a private protocol response. */
2
+ import { createHash, randomUUID, X509Certificate } from 'node:crypto';
3
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { createServer } from 'node:net';
5
+ import { homedir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
+ import { spawn } from 'node:child_process';
9
+ import { request as httpsRequest } from 'node:https';
10
+ const source = process.argv[2] ?? 'mirror', operation = process.argv[3] ?? 'connect';
11
+ if (!['mirror', 'github'].includes(source) || !['connect', 'update'].includes(operation)) throw new Error('无效的服务器安装参数');
12
+ const assets = dirname(fileURLToPath(import.meta.url));
13
+ const manifest = JSON.parse(await readFile(join(assets, 'manifest.json'), 'utf8'));
14
+ if (manifest.schemaVersion !== 1 || !/^[0-9a-f]{64}$/.test(manifest.sha256) || !/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(manifest.harnessVersion) || !/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(manifest.pluginVersion)) throw new Error('服务器安装清单无效');
15
+ const archive = join(assets, 'server-seed.tar.gz');
16
+ if (createHash('sha256').update(await readFile(archive)).digest('hex') !== manifest.sha256) throw new Error('服务器插件包校验失败');
17
+ const base = join(homedir(), '.local/share/benchgo/server');
18
+ const stateFile = join(base, 'installation.json'), ownerFile = join(base, 'owner.json');
19
+ const registry = source === 'mirror' ? 'https://registry.npmmirror.com' : 'https://registry.npmjs.org';
20
+ const wait = ms => new Promise(r => setTimeout(r, ms));
21
+ const present = async path => { try { await readFile(path); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } };
22
+ async function run(command, args, options = {}) {
23
+ return new Promise((resolve, reject) => {
24
+ const child = spawn(command, args, { env: { ...process.env, DSH_TELEMETRY_DISABLED: '1', npm_config_registry: registry }, stdio: ['ignore', 'pipe', 'pipe'], ...options });
25
+ let output = '';
26
+ for (const stream of [child.stdout, child.stderr]) stream.on('data', data => { output = (output + data).slice(-12000); });
27
+ child.once('error', reject);
28
+ child.once('exit', code => {
29
+ if (code === 0) return resolve(output);
30
+ const detail = output.trim();
31
+ reject(new Error((command === 'systemctl' ? '服务器服务操作' : '安装依赖') + '失败(' + code + ')' + (detail ? ':\n' + detail : '')));
32
+ });
33
+ });
34
+ }
35
+ async function atomic(path, value) { const tmp = path + '.' + randomUUID() + '.tmp'; await writeFile(tmp, value, { mode: 0o600 }); await rename(tmp, path); }
36
+ async function freePort() { const server = createServer(); await new Promise((r, j) => { server.once('error', j); server.listen(0, '127.0.0.1', r); }); const port = server.address().port; await new Promise(r => server.close(r)); return port; }
37
+ await mkdir(base, { recursive: true, mode: 0o700 });
38
+ let installed; try { installed = JSON.parse(await readFile(stateFile, 'utf8')); } catch (e) { if (e.code !== 'ENOENT') throw e; }
39
+ const lock = join(base, 'install.lock');
40
+ try { await mkdir(lock, { mode: 0o700 }); } catch (e) { if (e.code === 'EEXIST') throw new Error('已有服务器安装正在进行,请稍后重试'); throw e; }
41
+ try {
42
+ if (!installed || operation === 'update') {
43
+ if (installed && installed.harnessVersion !== manifest.harnessVersion) throw new Error('此包需要升级服务器引擎,请由维护者处理,现有数据已保留');
44
+ const runtime = join(base, 'runtimes', manifest.harnessVersion);
45
+ if (!(await present(join(runtime, 'node_modules/@deepseek-ai/dsh/package.json')))) {
46
+ process.stderr.write('安装固定版本 Harness…\n');
47
+ await mkdir(runtime, { recursive: true, mode: 0o700 });
48
+ await atomic(join(runtime, 'package.json'), JSON.stringify({ private: true, dependencies: {
49
+ '@deepseek-ai/dsh': manifest.harnessVersion,
50
+ '@deepseek-ai/cordis': '4.0.5-alpha.2',
51
+ '@deepseek-ai/dsh-atomic-write': manifest.harnessVersion,
52
+ '@deepseek-ai/dsh-llm': manifest.harnessVersion
53
+ } }));
54
+ await run('npm', ['install', '--prefix', runtime, '--omit=dev', '--no-audit', '--no-fund', '--registry', registry]);
55
+ }
56
+ const incoming = join(base, '.seed-' + randomUUID()); await mkdir(incoming, { mode: 0o700 });
57
+ try {
58
+ await run('tar', ['-xzf', archive, '-C', incoming, '--no-same-owner', '--no-same-permissions']);
59
+ const helper = await import(pathToFileURL(join(incoming, 'node_modules/benchgo/shared/plugin-install.mjs')).href);
60
+ const profile = join(base, 'home/profiles/web');
61
+ if (!(await present(join(profile, 'package.json')))) {
62
+ const boot = await import(pathToFileURL(join(runtime, 'node_modules/@deepseek-ai/dsh-app-boot/lib/index.js')).href);
63
+ boot.initProfile(profile, boot.PROFILE_TEMPLATES.web.bundles);
64
+ }
65
+ const release = await helper.prepareRelease({ directory: incoming, root: join(profile, '.benchgo/releases'), version: manifest.pluginVersion, runtime });
66
+ const webPort = installed?.webPort ?? await freePort(), port = installed?.port ?? await freePort();
67
+ const dataDir = installed?.dataDir ?? join(base, 'data');
68
+ if (!installed) await atomic(join(profile, 'cordis.patch.yml'), ['- id: commander', ' config:', ' dataDir: ' + JSON.stringify(dataDir), ' link:', ' directory: ' + JSON.stringify(join(dataDir, 'link')), '- id: session-telemetry-otel', ' disabled: true', ''].join('\n'));
69
+ if (installed) {
70
+ const active = await authorize(installed, false);
71
+ const state = await linkJson(installed, '/link/api/rpc', { action: 'state' }, active.token);
72
+ await ownerAdmin(installed, { action: 'revoke', id: active.deviceId });
73
+ if (state.sessions?.some(session => session.running)) throw new Error('服务器有任务运行中,请结束后再升级');
74
+ }
75
+ await helper.activateRelease({ profile, release, enabled: true, appVersion: null, migrateLegacy: true });
76
+ installed = { schemaVersion: 1, harnessVersion: manifest.harnessVersion, pluginVersion: manifest.pluginVersion, runtime, profile, dataDir, webPort, port };
77
+ await atomic(stateFile, JSON.stringify(installed));
78
+ const launcher = '(' + launchServer.toString() + ')(' + [process.execPath, runtime, ownerFile, base, webPort].map(value => JSON.stringify(value)).join(',') + ');';
79
+ await atomic(join(base, 'start.mjs'), launcher);
80
+ const units = join(homedir(), '.config/systemd/user'); await mkdir(units, { recursive: true, mode: 0o700 });
81
+ const quote = value => JSON.stringify(String(value)).replaceAll('%', '%%');
82
+ const unit = ['[Unit]', 'Description=BenchGo server', 'After=network-online.target', '', '[Service]', 'Type=simple', 'UMask=0077', 'WorkingDirectory=' + base, 'ExecStart=' + quote(process.execPath) + ' ' + quote(join(base, 'start.mjs')), 'Environment=' + quote('PATH=' + process.env.PATH), 'Restart=on-failure', 'RestartSec=3', '', '[Install]', 'WantedBy=default.target', ''].join('\n');
83
+ await atomic(join(units, 'benchgo.service'), unit);
84
+ await run('systemctl', ['--user', 'daemon-reload']); await run('systemctl', ['--user', 'enable', 'benchgo.service']);
85
+ await rm(ownerFile, { force: true }); await run('systemctl', ['--user', 'restart', 'benchgo.service']);
86
+ } finally { await rm(incoming, { recursive: true, force: true }); }
87
+ } else { process.stderr.write('复用已安装的 BenchGo…\n'); await run('systemctl', ['--user', 'start', 'benchgo.service']); }
88
+ if (operation === 'update') {
89
+ let ready = false;
90
+ for (let i = 0; i < 100; i++) { try { const status = await ownerAdmin(installed, { action: 'status' }); if (status.enabled && status.port === installed.port && status.host === '127.0.0.1') { ready = true; break; } } catch {} await wait(500); }
91
+ if (!ready) throw new Error('服务器升级后未正常就绪,请查看服务日志');
92
+ }
93
+ const result = operation === 'update' ? { port: installed.port, pin: createHash('sha256').update(new X509Certificate(JSON.parse(await readFile(join(installed.dataDir, 'link/tls.json'), 'utf8')).cert).publicKey.export({ type: 'spki', format: 'der' })).digest('base64') } : await authorize(installed, true);
94
+ const linger = await run('loginctl', ['show-user', String(process.getuid()), '--property=Linger', '--value']).catch(() => 'unknown');
95
+ process.stdout.write(JSON.stringify({ ...result, version: installed.pluginVersion, persistent: linger.trim() === 'yes', ...(linger.trim() !== 'yes' ? { warning: '系统尚未启用用户常驻服务,退出所有 SSH 会话后服务可能停止;维护者可执行 loginctl enable-linger 用户名' } : {}) }) + '\n');
96
+ } finally { await rm(lock, { recursive: true, force: true }); }
97
+ async function ownerAdmin(state, input) {
98
+ const owner = JSON.parse(await readFile(ownerFile, 'utf8')); const url = new URL(owner.url);
99
+ if (url.hostname !== '127.0.0.1' || Number(url.port) !== state.webPort) throw new Error('服务器启动授权地址无效');
100
+ const login = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(5000) });
101
+ const cookie = login.headers.getSetCookie().map(value => value.split(';')[0]).join('; '); if (!cookie) throw new Error('服务器尚未准备好');
102
+ const response = await fetch(url.origin + '/api/commander-link', { method: 'POST', headers: { cookie, 'content-type': 'application/json', origin: url.origin }, body: JSON.stringify(input), signal: AbortSignal.timeout(10000) });
103
+ const value = await response.json(); if (!response.ok) throw new Error(value.error ?? '服务器初始化失败'); return value;
104
+ }
105
+ async function linkJson(state, path, value, token) {
106
+ const identity = JSON.parse(await readFile(join(state.dataDir, 'link/tls.json'), 'utf8'));
107
+ return new Promise((resolve, reject) => {
108
+ const request = httpsRequest('https://127.0.0.1:' + state.port + path, { method: 'POST', ca: identity.cert, headers: { 'content-type': 'application/json', ...(token ? { authorization: 'Bearer ' + token } : {}) }, timeout: 10000 }, response => {
109
+ let data = ''; response.on('data', chunk => { data += chunk; }); response.on('end', () => { try { const result = JSON.parse(data); response.statusCode < 400 ? resolve(result) : reject(new Error(result.error ?? '服务器授权失败')); } catch (error) { reject(error); } });
110
+ }); request.on('error', reject); request.on('timeout', () => request.destroy(new Error('服务器授权超时'))); request.end(JSON.stringify(value));
111
+ });
112
+ }
113
+ async function authorize(state, retry) {
114
+ let failure;
115
+ for (let i = 0; i < (retry ? 100 : 1); i++) {
116
+ try {
117
+ let status = await ownerAdmin(state, { action: 'status' });
118
+ if (!status.enabled) status = await ownerAdmin(state, { action: 'configure', enabled: true, host: '127.0.0.1', port: state.port, publicUrl: '' });
119
+ if (status.host !== '127.0.0.1' || status.port !== state.port) throw new Error('服务器 Link 监听配置已改变,请由维护者检查');
120
+ const pair = await ownerAdmin(state, { action: 'pairing', mode: 'lan' });
121
+ const pending = await linkJson(state, '/link/api/pair', { code: pair.code, name: 'SSH 设备-' + randomUUID().slice(0, 5) });
122
+ await ownerAdmin(state, { action: 'confirm', id: pending.requestId });
123
+ const approved = await linkJson(state, '/link/api/pair/status', pending);
124
+ const tls = JSON.parse(await readFile(join(state.dataDir, 'link/tls.json'), 'utf8'));
125
+ const pin = createHash('sha256').update(new X509Certificate(tls.cert).publicKey.export({ type: 'spki', format: 'der' })).digest('base64');
126
+ return { port: state.port, pin, token: approved.token, deviceId: approved.deviceId };
127
+ } catch (error) { failure = error; if (retry) await wait(500); }
128
+ }
129
+ throw new Error('服务器启动或授权失败:' + (failure?.message ?? '未知错误'));
130
+ }
131
+
132
+ function launchServer(node, runtime, ownerFile, base, webPort) {
133
+ Promise.all([import('node:child_process'), import('node:fs'), import('node:path')]).then(([{ spawn }, { writeFileSync, renameSync }, { join }]) => {
134
+ const child = spawn(node, [join(runtime, 'node_modules/@deepseek-ai/dsh/lib/bin.js'), '--profile', 'web', '--host', '127.0.0.1', '--port', String(webPort), '--no-open'], { env: { ...process.env, DSH_HOME: join(base, 'home'), DSH_TELEMETRY_DISABLED: '1' }, stdio: ['ignore', 'pipe', 'pipe'] });
135
+ let pending = '';
136
+ function log(data) {
137
+ pending = (pending + data.toString()).slice(-65536);
138
+ const match = pending.match(/dsh web: (http:\/\/127\.0\.0\.1:\d+\/\?token=[^\s]+)/);
139
+ if (match) { writeFileSync(ownerFile + '.tmp', JSON.stringify({ url: match[1] }), { mode: 0o600 }); renameSync(ownerFile + '.tmp', ownerFile); }
140
+ const lines = pending.split(/\r?\n/); pending = lines.pop();
141
+ for (const line of lines) process.stdout.write(line.replace(/([?&]token=)[^\s&]+/g, '$1[redacted]').replace(/Bearer\s+[A-Za-z0-9._-]+/g, 'Bearer [redacted]') + '\n');
142
+ }
143
+ child.stdout.on('data', log); child.stderr.on('data', log);
144
+ child.on('error', () => process.exit(1)); child.on('exit', code => process.exit(code ?? 1));
145
+ for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => child.kill(signal));
146
+ }).catch(() => process.exit(1));
147
+ }
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ umask 077
4
+ source_name=${1:-mirror}
5
+ operation=${2:-connect}
6
+ case "$source_name" in mirror|github) ;; *) echo '无效下载源' >&2; exit 1;; esac
7
+ case "$operation" in connect|update) ;; *) echo '无效安装操作' >&2; exit 1;; esac
8
+ case "$(uname -s)" in Linux) ;; *) echo '服务器首版仅支持 Linux' >&2; exit 1;; esac
9
+ case "$(uname -m)" in x86_64) node_arch=x64;; aarch64|arm64) node_arch=arm64;; *) echo '仅支持 x64 和 arm64' >&2; exit 1;; esac
10
+ if [ ! -f /etc/os-release ]; then echo '无法识别服务器发行版' >&2; exit 1; fi
11
+ . /etc/os-release
12
+ case " ${ID:-} ${ID_LIKE:-} " in *' debian '*|*' ubuntu '*) ;; *) echo '首版仅支持 Ubuntu / Debian 系统' >&2; exit 1;; esac
13
+ command -v systemctl >/dev/null || { echo '需要 systemd 用户服务' >&2; exit 1; }
14
+ export XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/$(id -u)}
15
+ export DBUS_SESSION_BUS_ADDRESS=${DBUS_SESSION_BUS_ADDRESS:-unix:path=$XDG_RUNTIME_DIR/bus}
16
+ systemctl --user show-environment >/dev/null 2>&1 || { echo 'systemd 用户服务不可用;请启用用户会话服务后重试' >&2; exit 1; }
17
+ node_root="$HOME/.local/share/benchgo/node"
18
+ node_binary=$(command -v node || true)
19
+ if [ -z "$node_binary" ] || ! "$node_binary" -e 'const [a,b,c]=process.versions.node.split(".").map(Number);process.exit(a>=24||(a===22&&(b>19||(b===19&&c>=0)))?0:1)' >/dev/null 2>&1; then
20
+ node_version=22.22.1
21
+ node_file="node-v$node_version-linux-$node_arch.tar.xz"
22
+ command -v curl >/dev/null || { echo '安装 Node.js 需要 curl' >&2; exit 1; }
23
+ command -v sha256sum >/dev/null || { echo '安装 Node.js 需要 sha256sum' >&2; exit 1; }
24
+ work=$(mktemp -d)
25
+ trap 'rm -rf -- "$work"' EXIT
26
+ node_url="https://nodejs.org/dist/v$node_version"
27
+ if [ "$source_name" = mirror ]; then node_url="https://npmmirror.com/mirrors/node/v$node_version"; fi
28
+ echo '下载并校验 Node.js…' >&2
29
+ curl --proto '=https' --tlsv1.2 -fsSL --connect-timeout 15 --max-time 300 "$node_url/$node_file" -o "$work/$node_file"
30
+ curl --proto '=https' --tlsv1.2 -fsSL --connect-timeout 15 --max-time 60 "https://nodejs.org/dist/v$node_version/SHASUMS256.txt" -o "$work/SHASUMS256.txt"
31
+ checksum=$(awk -v name="$node_file" '$2==name { print }' "$work/SHASUMS256.txt")
32
+ [ -n "$checksum" ] || { echo '未找到 Node.js 官方校验值' >&2; exit 1; }
33
+ (cd "$work" && echo "$checksum" | sha256sum --check --status)
34
+ mkdir -p -- "$node_root"
35
+ tar -xJf "$work/$node_file" -C "$node_root" --strip-components=1
36
+ node_binary="$node_root/bin/node"
37
+ fi
38
+ export PATH="$(dirname "$node_binary"):$PATH"
39
+ exec "$node_binary" "$(dirname "$0")/bootstrap.mjs" "$source_name" "$operation"
@@ -0,0 +1,8 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "harnessVersion": "0.2.1-alpha.3",
4
+ "harnessCommit": "571902028a75c64c1d767f4ae5fd099485f0ddf0",
5
+ "pluginVersion": "0.0.1",
6
+ "bundle": "@benchgo/desktop-ee51afda5e1e26e6",
7
+ "sha256": "8ab7a198e1f98b33ae1408a751d410220c76fd80839a3d158604e1b2da9b7ef3"
8
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,132 @@
1
+ import { access, readFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { spawn } from 'node:child_process';
6
+
7
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
8
+ const bundledRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
9
+ const cliVersion = '0.0.1';
10
+
11
+ function usage() {
12
+ return `BenchGo CLI ${cliVersion}
13
+
14
+ 用法:
15
+ benchgo install
16
+ benchgo update
17
+ benchgo server <install|update|status|start|stop|restart>
18
+
19
+ 全局选项:
20
+ --json 输出机器可读 JSON
21
+ --root PATH 指定源码或发行资源目录
22
+ --source mirror 或 github
23
+ --version 输出版本
24
+ --help 显示帮助
25
+
26
+ 说明:
27
+ 本命令安装和管工作台。工作台可以连接外部中继,但不安装中继服务。
28
+ 中继服务器使用独立包 benchgo-relay 的 benchgo-relay。`;
29
+ }
30
+
31
+ function parseArgs(argv) {
32
+ const options = { json: false, root: process.env.BENCHGO_ROOT || packageRoot };
33
+ const positionals = [];
34
+ for (let i = 0; i < argv.length; i += 1) {
35
+ const arg = argv[i];
36
+ if (arg === '--json') options.json = true;
37
+ else if (arg === '--help' || arg === '-h') options.help = true;
38
+ else if (arg === '--version' || arg === '-v') options.version = true;
39
+ else if (arg === '--root') options.root = argv[++i] ?? '';
40
+ else if (arg.startsWith('--root=')) options.root = arg.slice('--root='.length);
41
+ else if (arg === '--source') options.source = argv[++i] ?? '';
42
+ else if (arg.startsWith('--source=')) options.source = arg.slice('--source='.length);
43
+ else positionals.push(arg);
44
+ }
45
+ return { options, positionals };
46
+ }
47
+
48
+ function xdgPath(kind, name) {
49
+ const base = kind === 'data'
50
+ ? (process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'))
51
+ : (process.env.XDG_CONFIG_HOME || join(homedir(), '.config'));
52
+ return join(base, 'benchgo', name);
53
+ }
54
+
55
+ async function exists(path) {
56
+ try { await access(path); return true; } catch { return false; }
57
+ }
58
+
59
+ function command(bin, args, { capture = false, allowFailure = false } = {}) {
60
+ return new Promise((resolvePromise, reject) => {
61
+ const child = spawn(bin, args, { stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', env: { ...process.env, DSH_TELEMETRY_DISABLED: '1' } });
62
+ let stdout = '', stderr = '';
63
+ if (capture) {
64
+ child.stdout.on('data', data => { stdout += data; });
65
+ child.stderr.on('data', data => { stderr += data; });
66
+ }
67
+ child.once('error', error => allowFailure ? resolvePromise({ code: null, stdout, stderr, error }) : reject(error));
68
+ child.once('exit', code => {
69
+ const result = { code: code ?? 1, stdout, stderr };
70
+ if (result.code !== 0 && !allowFailure) reject(new Error(stderr.trim() || `${bin} 退出码 ${result.code}`));
71
+ else resolvePromise(result);
72
+ });
73
+ });
74
+ }
75
+
76
+ function serviceArgs(service, operation) {
77
+ return ['--user', operation, service];
78
+ }
79
+
80
+ async function serviceOperation(service, operation, { json }) {
81
+ const result = await command('systemctl', serviceArgs(service, operation), { capture: true, allowFailure: true });
82
+ if (json) return { service, operation, ok: result.code === 0, output: result.stdout.trim() || result.stderr.trim() };
83
+ if (result.code !== 0) throw new Error(result.stderr.trim() || `服务操作失败:${operation} ${service}`);
84
+ return result.stdout.trim() || `${service} ${operation} 完成`;
85
+ }
86
+
87
+ async function serverStatus({ json }) {
88
+ const stateFile = xdgPath('data', 'server/installation.json');
89
+ let installation = null;
90
+ try { installation = JSON.parse(await readFile(stateFile, 'utf8')); } catch {}
91
+ const service = await command('systemctl', serviceArgs('benchgo.service', 'is-active'), { capture: true, allowFailure: true });
92
+ const result = { installed: Boolean(installation), service: service.stdout.trim() || 'unknown', installation: installation ? { ...installation } : null };
93
+ return json ? result : `工作台:${result.installed ? '已安装' : '未安装'}\n服务:${result.service}`;
94
+ }
95
+
96
+ async function serverCommand(action, options) {
97
+ if (action === 'status') return serverStatus(options);
98
+ if (['start', 'stop', 'restart'].includes(action)) return serviceOperation('benchgo.service', action, options);
99
+ if (!['install', 'update'].includes(action)) throw new Error(`不支持的工作台操作:${action || '(空)'}`);
100
+ const script = await exists(resolve(options.root, 'scripts/server/bootstrap.sh'))
101
+ ? resolve(options.root, 'scripts/server/bootstrap.sh')
102
+ : resolve(bundledRoot, 'resources/server/bootstrap.sh');
103
+ if (!await exists(script)) throw new Error('未找到工作台安装器;请设置 BENCHGO_ROOT 指向 BenchGo 资源目录');
104
+ const source = options.source || 'mirror';
105
+ if (!['mirror', 'github'].includes(source)) throw new Error('下载源必须是 mirror 或 github');
106
+ const result = await command('bash', [script, source, action === 'update' ? 'update' : 'connect']);
107
+ return options.json ? { ok: result.code === 0, action } : `工作台${action === 'update' ? '更新' : '安装'}完成`;
108
+ }
109
+
110
+ export async function run(argv) {
111
+ const { options, positionals } = parseArgs(argv);
112
+ if (options.version) return cliVersion;
113
+ if (options.help || positionals.length === 0) return usage();
114
+ const [scope, action] = positionals;
115
+ if (scope === 'install' || scope === 'update') return serverCommand(scope, options);
116
+ if (scope === 'server') return serverCommand(action, options);
117
+ if (scope === 'relay') throw new Error('中继服务器不随工作台安装。请使用 npm install -g benchgo-relay,然后运行 benchgo-relay');
118
+ throw new Error(`未知命令:${scope}`);
119
+ }
120
+
121
+ export async function main(argv = process.argv.slice(2)) {
122
+ const { options } = parseArgs(argv);
123
+ try {
124
+ const result = await run(argv);
125
+ if (result !== undefined) process.stdout.write((options.json && typeof result !== 'string' ? JSON.stringify(result) : String(result)) + '\n');
126
+ } catch (error) {
127
+ process.stderr.write((error?.message || String(error)) + '\n');
128
+ process.exitCode = 1;
129
+ }
130
+ }
131
+
132
+ export { parseArgs, usage, xdgPath };