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 +24 -2
- package/bin/benchgo.mjs +4 -0
- package/package.json +23 -4
- package/resources/server/bootstrap.mjs +147 -0
- package/resources/server/bootstrap.sh +39 -0
- package/resources/server/manifest.json +8 -0
- package/resources/server/server-seed.tar.gz +0 -0
- package/src/cli.mjs +132 -0
package/README.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
-
#
|
|
1
|
+
# benchgo
|
|
2
2
|
|
|
3
|
-
|
|
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`。
|
package/bin/benchgo.mjs
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "benchgo",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
|
|
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
|
+
}
|
|
Binary file
|
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 };
|