sintbot 1.4.1 → 1.4.2
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/CHANGELOG.en.json +19 -0
- package/CHANGELOG.json +19 -0
- package/bin/sintbot-v2.js +33 -2
- package/docs/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/install.md +4 -2
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/zh-CN/install.md +4 -2
- package/install.sh +1 -1
- package/package.json +4 -1
- package/src/c2configcontrol.js +3 -2
- package/src/c2console.js +6 -3
- package/src/c2multigroupservice.js +19 -2
- package/src/c2updater.js +361 -0
- package/src/c2upgradeswitch.js +169 -0
- package/src/ui/console.html +36 -15
- package/src/updater.js +243 -0
package/CHANGELOG.en.json
CHANGED
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.4.2",
|
|
4
|
+
"date": "2026-11-09",
|
|
5
|
+
"name": "Check and install new versions from the console",
|
|
6
|
+
"items": [
|
|
7
|
+
{
|
|
8
|
+
"t": "Version checks in the console",
|
|
9
|
+
"d": "Software updates in Settings checks for stable releases at startup and daily and shows release notes when a new version is available."
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"t": "One-click upgrade for background runs",
|
|
13
|
+
"d": "When a launchd agent runs SintBot, Upgrade now waits until every group is idle and settled, installs the new version into its own folder and switches to it; if it does not connect every group within 90 seconds, the previous version is restored. New messages wait about a minute during the switch and are asked to be resent."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"t": "Steps for foreground runs",
|
|
17
|
+
"d": "A foreground run shows how to stop, reinstall and start. Upgrades are not announced in groups."
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
},
|
|
2
21
|
{
|
|
3
22
|
"version": "1.4.1",
|
|
4
23
|
"date": "2026-11-09",
|
package/CHANGELOG.json
CHANGED
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.4.2",
|
|
4
|
+
"date": "2026-11-09",
|
|
5
|
+
"name": "在控制台查看和安装新版本",
|
|
6
|
+
"items": [
|
|
7
|
+
{
|
|
8
|
+
"t": "控制台检查新版本",
|
|
9
|
+
"d": "设置页的软件更新启动时和每天各检查一次正式版,有新版本时提示并附发布说明。"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"t": "后台运行时一键升级",
|
|
13
|
+
"d": "由 launchd 后台任务运行时,点「现在升级」会等所有群空闲、记录结清后,把新版本装到单独的目录再切换;新版本 90 秒内没连上全部群会自动换回旧版。切换期间约一分钟不处理新消息,会提示稍后重发。"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"t": "前台运行给出升级步骤",
|
|
17
|
+
"d": "前台运行时卡片说明如何停止、重新安装并启动。升级不在群里播报。"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
},
|
|
2
21
|
{
|
|
3
22
|
"version": "1.4.1",
|
|
4
23
|
"date": "2026-11-09",
|
package/bin/sintbot-v2.js
CHANGED
|
@@ -112,6 +112,7 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
|
|
|
112
112
|
let service
|
|
113
113
|
let ui
|
|
114
114
|
let configControl
|
|
115
|
+
let updater
|
|
115
116
|
let liveTransport
|
|
116
117
|
let onSigint, onSigterm
|
|
117
118
|
try {
|
|
@@ -157,6 +158,7 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
|
|
|
157
158
|
if (finishing) return
|
|
158
159
|
finishing = true
|
|
159
160
|
ui?.close()
|
|
161
|
+
updater?.close()
|
|
160
162
|
lock.release()
|
|
161
163
|
process.stderr.write(`C2 multi-group · ${result.reason}; platform stopped=${result.platformStopped}; runner drained=${result.drained}\n`,
|
|
162
164
|
() => process.exit(result.code))
|
|
@@ -255,8 +257,24 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
|
|
|
255
257
|
configControl = require('../src/c2configcontrol').createC2ConfigControl({
|
|
256
258
|
installation, getBindings: () => service.bindings, getHeld: () => service.held,
|
|
257
259
|
assertWriterLock, store,
|
|
258
|
-
activateRoute: route => service.addRoute(route),
|
|
260
|
+
activateRoute: route => service.addRoute(route), isFrozen: () => service.inMaintenance,
|
|
259
261
|
log: message => process.stderr.write(`C2 multi-group · ${message}\n`) })
|
|
262
|
+
if (!preflightOnly) {
|
|
263
|
+
// Console software updates. Only a launchd job that starts this exact
|
|
264
|
+
// entry may upgrade, and only while every group is idle and settled.
|
|
265
|
+
const c2updates = require('../src/c2updater')
|
|
266
|
+
const pkg = require('../package.json')
|
|
267
|
+
const upgradeGate = c2updates.createC2UpgradeGate({ isRunning: () => running,
|
|
268
|
+
service: () => service, capacity, assertJournalScope })
|
|
269
|
+
updater = c2updates.createC2Updater({ currentVersion: pkg.version,
|
|
270
|
+
runMode: c2updates.detectRunMode({ privateBuild: pkg.private === true, entry: __filename }),
|
|
271
|
+
stateFile: path.join(stateDir, 'update.json'), dataDir: stateDir, configPath: cfg._path,
|
|
272
|
+
gate: upgradeGate, beginMaintenance: () => service.beginMaintenance(),
|
|
273
|
+
endMaintenance: () => service.endMaintenance(), expectedGroups: () => service.bindings.size,
|
|
274
|
+
registries: c2updates.registriesFromEnv(),
|
|
275
|
+
fetchImpl: require('../src/core/proxy').getProxyFetch() || globalThis.fetch,
|
|
276
|
+
autoCheck: uiEnabled, log: message => process.stderr.write(`C2 update · ${message}\n`) })
|
|
277
|
+
}
|
|
260
278
|
const stop = reason => { void service.stop(reason, 0).then(finish) }
|
|
261
279
|
onSigint = () => stop('SIGINT')
|
|
262
280
|
onSigterm = () => stop('SIGTERM')
|
|
@@ -268,7 +286,7 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
|
|
|
268
286
|
getConfig: () => cfg, getBindings: () => service.bindings,
|
|
269
287
|
getHeld: () => service.held, getCapacity: () => capacity.snapshot(),
|
|
270
288
|
getConnHealth: () => liveTransport?.platform.connHealth?.() || {},
|
|
271
|
-
assertWriterLock, ...configControl,
|
|
289
|
+
assertWriterLock, ...configControl, updater,
|
|
272
290
|
log: message => process.stderr.write(`Console · ${message}\n`) })
|
|
273
291
|
await new Promise((resolve, reject) => {
|
|
274
292
|
ui.server.once('error', reject)
|
|
@@ -287,6 +305,10 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
|
|
|
287
305
|
for (const item of started.held || []) {
|
|
288
306
|
process.stderr.write(`SintBot group ${item.chatId} is held: ${item.reason}\n`)
|
|
289
307
|
}
|
|
308
|
+
// A console upgrade that switched to this version is done once every
|
|
309
|
+
// group is connected; then the daily version check starts.
|
|
310
|
+
if (!started.held?.length) updater?.confirmStarted()
|
|
311
|
+
void updater?.start()
|
|
290
312
|
} catch (error) {
|
|
291
313
|
ui?.close()
|
|
292
314
|
const stopped = await service?.stop('startup-failed', 1)
|
|
@@ -297,6 +319,7 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
|
|
|
297
319
|
throw error
|
|
298
320
|
} finally {
|
|
299
321
|
if (!running) {
|
|
322
|
+
updater?.close()
|
|
300
323
|
if (onSigint) process.off('SIGINT', onSigint)
|
|
301
324
|
if (onSigterm) process.off('SIGTERM', onSigterm)
|
|
302
325
|
capacity.close(); lock.release()
|
|
@@ -356,6 +379,14 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
356
379
|
process.stdout.write(HELP)
|
|
357
380
|
return
|
|
358
381
|
}
|
|
382
|
+
if (sub === 'upgrade-switch') {
|
|
383
|
+
// Internal: started detached by a console upgrade; never run it by hand.
|
|
384
|
+
const { values } = parseArgs({ args: rest, allowPositionals: false, options: { state: { type: 'string' } } })
|
|
385
|
+
if (!values.state || !path.isAbsolute(values.state)) throw new Error('upgrade-switch needs --state <absolute path>')
|
|
386
|
+
const outcome = await require('../src/c2upgradeswitch').runUpgradeSwitch({ stateFile: values.state })
|
|
387
|
+
process.exitCode = outcome === 'done' ? 0 : 1
|
|
388
|
+
return
|
|
389
|
+
}
|
|
359
390
|
if (!['init', 'start', 'incident'].includes(sub)) {
|
|
360
391
|
throw new Error(`${sub} is unavailable in this release; use sintbot help`)
|
|
361
392
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Install SintBot 1.4.
|
|
1
|
+
# Install SintBot 1.4.2 with an AI assistant
|
|
2
2
|
|
|
3
3
|
English · [简体中文](./zh-CN/AI-ASSISTED-INSTALL.md)
|
|
4
4
|
|
|
@@ -15,5 +15,5 @@ Use the terminal on the Mac that will run SintBot. A cloud shell cannot install
|
|
|
15
15
|
## Copyable request
|
|
16
16
|
|
|
17
17
|
```text
|
|
18
|
-
Install SintBot 1.4.
|
|
18
|
+
Install SintBot 1.4.2 on this Mac using the one-line installation guide. Preserve existing services, configuration, state and Codex or Claude Code account data. Do not expose credentials. Verify the actual installed executable and binary core, configure the Feishu groups with their runners, and initialize only clean new state. Run the foreground preflight and an authorized read-only group test, check the result, then verify clean stop/restart. Stop temporary processes when finished, and distinguish package installation from successful group acceptance.
|
|
19
19
|
```
|
package/docs/install.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Install SintBot 1.4.
|
|
1
|
+
# Install SintBot 1.4.2
|
|
2
2
|
|
|
3
3
|
English · [简体中文](./zh-CN/install.md)
|
|
4
4
|
|
|
@@ -10,7 +10,7 @@ You need a Mac, Node.js 22 or newer, and projects stored on that Mac. Install an
|
|
|
10
10
|
curl -fsSL https://sintbot.com/install.sh | bash
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
The installer checks the environment, installs `sintbot@1.4.
|
|
13
|
+
The installer checks the environment, installs `sintbot@1.4.2` into `~/.local/share/sintbot`, and verifies the entry, built-in core and Feishu module. Continue when you see `Installed and verified SintBot 1.4.2`. No sudo is needed.
|
|
14
14
|
|
|
15
15
|
To preview, append `-s -- --dry-run`. To choose another location, set `SINTBOT_INSTALL_DIR` and adjust the executable paths below.
|
|
16
16
|
|
|
@@ -104,6 +104,8 @@ Messages sent while a task runs are added to that task in Codex groups (`codexSt
|
|
|
104
104
|
|
|
105
105
|
With `--ui`, open `http://127.0.0.1:4600` on this Mac. The console shows each group (a paused group shows Needs attention), stops tasks, binds new groups, and switches the model or project folder of an idle group. For LAN access, set a console password in its settings first, then enable LAN access.
|
|
106
106
|
|
|
107
|
+
Settings → Software updates checks for new releases. In the foreground setup above it shows the upgrade steps: press Ctrl-C and wait for exit, rerun the one-line installer, then start again with the same command. If your own launchd agent starts SintBot, Upgrade now waits until every group is idle and settled, installs the new version into `~/.local/share/sintbot/releases/<version>` and switches the agent to it; if the new version does not connect every group within 90 seconds, the previous one is restored. Upgrades are not announced in groups.
|
|
108
|
+
|
|
107
109
|
You can also add a group without the console: add it to the configuration, stop the service and run `start` again; the new group is registered automatically. Bound groups cannot be removed or renamed by editing the configuration.
|
|
108
110
|
|
|
109
111
|
## Stop and restart
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# 用 AI 助手安装 SintBot 1.4.
|
|
1
|
+
# 用 AI 助手安装 SintBot 1.4.2
|
|
2
2
|
|
|
3
3
|
[English](../AI-ASSISTED-INSTALL.md) · 简体中文
|
|
4
4
|
|
|
@@ -15,5 +15,5 @@
|
|
|
15
15
|
## 可复制的请求
|
|
16
16
|
|
|
17
17
|
```text
|
|
18
|
-
按一行安装指南,在这台 Mac 安装 SintBot 1.4.
|
|
18
|
+
按一行安装指南,在这台 Mac 安装 SintBot 1.4.2。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
|
|
19
19
|
```
|
package/docs/zh-CN/install.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# 安装 SintBot 1.4.
|
|
1
|
+
# 安装 SintBot 1.4.2
|
|
2
2
|
|
|
3
3
|
[English](../install.md) · 简体中文
|
|
4
4
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
curl -fsSL https://sintbot.com/install.sh | bash
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
安装器会自动检查环境,把 `sintbot@1.4.
|
|
13
|
+
安装器会自动检查环境,把 `sintbot@1.4.2` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.2` 就可以继续。不需要 sudo。
|
|
14
14
|
|
|
15
15
|
可选预览:在命令末尾加 `-s -- --dry-run`。要自定义安装位置,设置 `SINTBOT_INSTALL_DIR` 环境变量,并同步修改后面的可执行文件路径。
|
|
16
16
|
|
|
@@ -104,6 +104,8 @@ SINTBOT="$HOME/.local/share/sintbot/bin/sintbot"
|
|
|
104
104
|
|
|
105
105
|
带 `--ui` 启动后,在本机浏览器打开 `http://127.0.0.1:4600`。控制台可以查看各群状态(暂停的群显示「需要处理」)、停止任务、绑定新群、给空闲的群切换模型或项目目录。要从局域网访问,先在控制台的设置里设访问密码,再开启局域网访问。
|
|
106
106
|
|
|
107
|
+
「设置 → 软件更新」会检查新版本。按上面前台方式运行时,卡片给出升级步骤:按 Ctrl-C 等待退出,重新运行一行安装命令,再用原来的命令启动。如果用你自己的 launchd 后台任务启动 SintBot,「现在升级」会等所有群空闲、记录结清后,把新版本装到 `~/.local/share/sintbot/releases/<版本>` 并切换过去;新版本 90 秒内没连上全部群会自动换回旧版。升级不在群里播报。
|
|
108
|
+
|
|
107
109
|
不用控制台也能加群:在配置里加上新群,停止后重新 `start` 即可,新群会自动登记。已绑定的群不能通过改配置删除或改名。
|
|
108
110
|
|
|
109
111
|
## 停止与重启
|
package/install.sh
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# The function is parsed in full before installation starts, including when piped.
|
|
3
3
|
sintbot_install() (
|
|
4
4
|
set -eu
|
|
5
|
-
version=1.4.
|
|
5
|
+
version=1.4.2
|
|
6
6
|
prefix="${SINTBOT_INSTALL_DIR:-$HOME/.local/share/sintbot}"
|
|
7
7
|
fail() { printf 'SintBot: %s\n' "$*" >&2; exit 1; }
|
|
8
8
|
case "${1:-}" in
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sintbot",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Drive your Mac projects from Feishu groups with Codex or Claude Code, using a built-in agent core.",
|
|
6
6
|
"bin": {
|
|
@@ -63,6 +63,8 @@
|
|
|
63
63
|
"src/c2migration.js",
|
|
64
64
|
"src/c2multigroupcapacity.js",
|
|
65
65
|
"src/c2multigroupservice.js",
|
|
66
|
+
"src/c2updater.js",
|
|
67
|
+
"src/c2upgradeswitch.js",
|
|
66
68
|
"src/cdp.js",
|
|
67
69
|
"src/config.js",
|
|
68
70
|
"src/core/c2cardeffectbridge.js",
|
|
@@ -129,6 +131,7 @@
|
|
|
129
131
|
"src/ui/serverbase.js",
|
|
130
132
|
"src/ui/sessions.js",
|
|
131
133
|
"src/ui/takeover.js",
|
|
134
|
+
"src/updater.js",
|
|
132
135
|
"src/usage.js"
|
|
133
136
|
],
|
|
134
137
|
"scripts": {
|
package/src/c2configcontrol.js
CHANGED
|
@@ -19,9 +19,9 @@ const RESET_KEYS = ['provider', 'model', 'workdir', 'codexApp']
|
|
|
19
19
|
// It never moves a journal, settles a receipt or reconnects the platform.
|
|
20
20
|
function createC2ConfigControl({ installation, getBindings, getHeld = () => new Map(),
|
|
21
21
|
assertWriterLock, store = null, activateRoute = null, probeRunner = assertRunnerExecutable,
|
|
22
|
-
log = () => {} } = {}) {
|
|
22
|
+
isFrozen = () => false, log = () => {} } = {}) {
|
|
23
23
|
if (!installation?.cfg?._path || typeof getBindings !== 'function' ||
|
|
24
|
-
typeof getHeld !== 'function' ||
|
|
24
|
+
typeof getHeld !== 'function' || typeof isFrozen !== 'function' ||
|
|
25
25
|
typeof assertWriterLock !== 'function' ||
|
|
26
26
|
store !== null && typeof store.resetGroupSession !== 'function' ||
|
|
27
27
|
activateRoute !== null && typeof activateRoute !== 'function' ||
|
|
@@ -47,6 +47,7 @@ function createC2ConfigControl({ installation, getBindings, getHeld = () => new
|
|
|
47
47
|
function validateConfig(raw) {
|
|
48
48
|
prepared = null
|
|
49
49
|
held()
|
|
50
|
+
if (isFrozen()) throw new Error('SintBot is upgrading; settings cannot change until it restarts')
|
|
50
51
|
const next = loadInstallationFromRaw(raw, cfg._path, { dataDir: stateDir,
|
|
51
52
|
allowAddedGroups: !!activateRoute && !!store })
|
|
52
53
|
const bindings = getBindings()
|
package/src/c2console.js
CHANGED
|
@@ -17,7 +17,10 @@ const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
|
|
|
17
17
|
'/api/groups/order', '/api/groups/policy', '/api/groups/redact',
|
|
18
18
|
'/api/groups/codex-steer', '/api/groups/pause', '/api/groups/stop',
|
|
19
19
|
'/api/agent', '/api/provider-settings', '/api/codex/verify', '/api/usage/refresh',
|
|
20
|
-
'/api/inbox/dismiss', '/api/issues/status'
|
|
20
|
+
'/api/inbox/dismiss', '/api/issues/status',
|
|
21
|
+
// Software updates: check and ignore anywhere; install only when launchd
|
|
22
|
+
// manages this exact entry (the updater refuses every other run mode).
|
|
23
|
+
'/api/update/check', '/api/update/start', '/api/update/ignore']
|
|
21
24
|
const SUPPORTED_RUNNERS = ['claude', 'codex']
|
|
22
25
|
|
|
23
26
|
function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld = () => new Map(),
|
|
@@ -32,7 +35,7 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
|
|
|
32
35
|
const startedAt = now()
|
|
33
36
|
const capabilities = Object.freeze({ mode: 'durable-v2',
|
|
34
37
|
reason: '此操作尚未接入当前版本的持久处理流程,未执行。',
|
|
35
|
-
summary: '
|
|
38
|
+
summary: '实时状态、认证管理、停止任务、绑定新群、空闲群的模型切换(Codex / Claude Code)和项目目录修改,以及后台运行时的软件升级已接入;该群有任务或未结记录时不能修改。开新会话、解除绑定、控制台插话、定时任务和画布暂不可用。',
|
|
36
39
|
allowed: { GET: READ_PATHS, POST: WRITE_PATHS } })
|
|
37
40
|
function held() {
|
|
38
41
|
if (assertWriterLock() !== true) throw new Error('服务写入权限已失效,操作未执行')
|
|
@@ -133,7 +136,7 @@ function createC2Console(options = {}) {
|
|
|
133
136
|
throw new TypeError('C2 console requires explicit installation paths')
|
|
134
137
|
}
|
|
135
138
|
const facade = createC2ConsoleFacade(options)
|
|
136
|
-
const ui = createUiServer({ configPath, host, log, getConnHealth,
|
|
139
|
+
const ui = createUiServer({ configPath, host, log, getConnHealth, updater: options.updater || null,
|
|
137
140
|
onHostChange: options.onHostChange,
|
|
138
141
|
getLiveState: facade.getLiveState, control: facade.control, runtimePolicy: facade,
|
|
139
142
|
issuesPath: path.join(stateDir, 'issues.jsonl'),
|
|
@@ -5,6 +5,7 @@ const { coordinateC2GlobalFault } = require('./core/c2globalfault')
|
|
|
5
5
|
|
|
6
6
|
const RECOVERING_NOTICE = '上一个任务出错,正在收尾,这条消息没有处理,请稍后重发。'
|
|
7
7
|
const HELD_NOTICE = '本群上一个任务结果不明,已暂停处理,等待人工确认;其他群不受影响。'
|
|
8
|
+
const UPGRADING_NOTICE = 'SintBot 正在升级,这条消息没有处理,请一分钟后重发。'
|
|
8
9
|
const reasonOf = error => String(error?.message || error || 'unknown').slice(0, 300)
|
|
9
10
|
|
|
10
11
|
// Turns a group binding still holds: a running turn, a non-idle handoff or a
|
|
@@ -58,6 +59,8 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
58
59
|
const bindings = new Map()
|
|
59
60
|
// chatId -> { reason, source: 'startup' | 'runtime', since, binding }
|
|
60
61
|
const suspended = new Map()
|
|
62
|
+
// Set while a console upgrade switches the service: no message is admitted.
|
|
63
|
+
let maintenance = false
|
|
61
64
|
let transport = null
|
|
62
65
|
let started = false
|
|
63
66
|
let starting = false
|
|
@@ -227,7 +230,7 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
227
230
|
if (bindings.has(route.chatId)) return Promise.resolve(bindings.get(route.chatId))
|
|
228
231
|
if (activating.has(route.chatId)) return activating.get(route.chatId)
|
|
229
232
|
const opening = (async () => {
|
|
230
|
-
if (!started || starting || stopPromise) throw new Error('C2 service is not serving')
|
|
233
|
+
if (!started || starting || stopPromise || maintenance) throw new Error('C2 service is not serving')
|
|
231
234
|
held()
|
|
232
235
|
const binding = await createBinding({ route, transport })
|
|
233
236
|
if (!started || stopPromise || !writerHeld() || suspended.has(route.chatId) ||
|
|
@@ -255,6 +258,10 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
255
258
|
// Foreign/paused group events are not faults and never get a receipt.
|
|
256
259
|
if (!allowed(msg?.chatId)) return { ignored: true, reason: 'unconfigured-or-paused' }
|
|
257
260
|
if (quarantine?.rejects(msg)) return { quarantined: true }
|
|
261
|
+
if (maintenance && started && !stopPromise) {
|
|
262
|
+
return answerC2InputRejection({ error: rejectC2Input('C2 service upgrading',
|
|
263
|
+
{ reason: 'upgrading', notice: UPGRADING_NOTICE }), msg, platform: sourceAdapter, log })
|
|
264
|
+
}
|
|
258
265
|
if (recovering.has(msg.chatId) && started && !stopPromise) {
|
|
259
266
|
// The group's failed turn is being closed; nothing is admitted meanwhile.
|
|
260
267
|
return answerC2InputRejection({ error: rejectC2Input('C2 group recovering',
|
|
@@ -319,6 +326,7 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
319
326
|
}
|
|
320
327
|
function recall(event) {
|
|
321
328
|
if (suspended.has(event?.chatId)) return { ignored: true, reason: 'group-held' }
|
|
329
|
+
if (maintenance) return { ignored: true, reason: 'upgrading' }
|
|
322
330
|
if (!allowed(event?.chatId)) return { ignored: true }
|
|
323
331
|
let binding
|
|
324
332
|
try {
|
|
@@ -409,7 +417,16 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
409
417
|
return [...suspended].map(([chatId, entry]) => Object.freeze({ chatId,
|
|
410
418
|
reason: entry.reason, source: entry.source, since: entry.since }))
|
|
411
419
|
}
|
|
412
|
-
|
|
420
|
+
function beginMaintenance() {
|
|
421
|
+
if (!maintenance) log('upgrade: admission closed for the service switch')
|
|
422
|
+
maintenance = true
|
|
423
|
+
}
|
|
424
|
+
function endMaintenance() {
|
|
425
|
+
if (maintenance) log('upgrade: admission reopened')
|
|
426
|
+
maintenance = false
|
|
427
|
+
}
|
|
428
|
+
return { start, stop, routeMessage, addRoute, heldGroups, beginMaintenance, endMaintenance,
|
|
429
|
+
get inMaintenance() { return maintenance },
|
|
413
430
|
get bindings() { return new Map(bindings) },
|
|
414
431
|
get held() { return new Map([...suspended].map(([chatId, entry]) =>
|
|
415
432
|
[chatId, { reason: entry.reason, source: entry.source, since: entry.since }])) } }
|
package/src/c2updater.js
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Software updates for the multi-group service. Checking is read-only. Only a
|
|
4
|
+
// launchd-managed installation upgrades from the console, and only while every
|
|
5
|
+
// group is idle and settled: the exact stable version is installed side by
|
|
6
|
+
// side under the releases directory, then a detached switch process replaces
|
|
7
|
+
// the service entry with the same steps as the deploy scripts and rolls back
|
|
8
|
+
// on any failure. Foreground and development installations get instructions.
|
|
9
|
+
const fs = require('node:fs')
|
|
10
|
+
const os = require('node:os')
|
|
11
|
+
const path = require('node:path')
|
|
12
|
+
const { execFile, execFileSync, spawn } = require('node:child_process')
|
|
13
|
+
const { promisify } = require('node:util')
|
|
14
|
+
const { createHash } = require('node:crypto')
|
|
15
|
+
const { stableVersion, compareVersion, fetchVersion, CHECK_INTERVAL_MS } = require('./updater')
|
|
16
|
+
const { atomicWriteJSON } = require('./core/store')
|
|
17
|
+
|
|
18
|
+
const PACKAGE = 'sintbot'
|
|
19
|
+
const REGISTRIES = Object.freeze([
|
|
20
|
+
{ registry: 'https://registry.npmjs.org', source: 'npm' },
|
|
21
|
+
{ registry: 'https://registry.npmmirror.com', source: 'mirror' },
|
|
22
|
+
])
|
|
23
|
+
const QUEUE_TIMEOUT_MS = 24 * 60 * 60 * 1000
|
|
24
|
+
const ACTIVE = Object.freeze(['queued', 'installing', 'switching'])
|
|
25
|
+
const LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
|
|
26
|
+
const execFileAsync = promisify(execFile)
|
|
27
|
+
|
|
28
|
+
function readJSON(file) {
|
|
29
|
+
try {
|
|
30
|
+
const value = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
31
|
+
return value && typeof value === 'object' ? value : {}
|
|
32
|
+
} catch { return {} }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readPlist(file, { exec = execFileSync } = {}) {
|
|
36
|
+
return JSON.parse(exec('/usr/bin/plutil', ['-convert', 'json', '-o', '-', file],
|
|
37
|
+
{ encoding: 'utf8', timeout: 10_000 }))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const real = file => { try { return fs.realpathSync(file) } catch { return null } }
|
|
41
|
+
|
|
42
|
+
// launchd names its job in XPC_SERVICE_NAME. The job counts as managing this
|
|
43
|
+
// process only when its plist starts this exact entry and launchd reports this
|
|
44
|
+
// exact PID; anything else is a foreground run.
|
|
45
|
+
function detectRunMode({ privateBuild, entry, env = process.env, pid = process.pid,
|
|
46
|
+
uid = process.getuid?.(), home = os.homedir(), exec = execFileSync } = {}) {
|
|
47
|
+
if (privateBuild === true) return Object.freeze({ mode: 'dev' })
|
|
48
|
+
const label = env.XPC_SERVICE_NAME
|
|
49
|
+
if (typeof label !== 'string' || !LABEL.test(label) || label === '0') {
|
|
50
|
+
return Object.freeze({ mode: 'foreground' })
|
|
51
|
+
}
|
|
52
|
+
const plist = path.join(home, 'Library', 'LaunchAgents', `${label}.plist`)
|
|
53
|
+
try {
|
|
54
|
+
const spec = readPlist(plist, { exec })
|
|
55
|
+
const args = spec.ProgramArguments
|
|
56
|
+
if (spec.Label !== label || !Array.isArray(args) || args.length < 3 ||
|
|
57
|
+
!args.every(arg => typeof arg === 'string') ||
|
|
58
|
+
real(args[1]) === null || real(args[1]) !== real(entry)) {
|
|
59
|
+
return Object.freeze({ mode: 'foreground', reason: 'launchd entry differs' })
|
|
60
|
+
}
|
|
61
|
+
const printed = exec('/bin/launchctl', ['print', `gui/${uid}/${label}`],
|
|
62
|
+
{ encoding: 'utf8', timeout: 10_000 })
|
|
63
|
+
if (!new RegExp(`^\\s*pid = ${pid}\\s*$`, 'm').test(printed)) {
|
|
64
|
+
return Object.freeze({ mode: 'foreground', reason: 'launchd pid differs' })
|
|
65
|
+
}
|
|
66
|
+
return Object.freeze({ mode: 'managed', label, plist, entry: args[1],
|
|
67
|
+
logFile: typeof spec.StandardErrorPath === 'string' ? spec.StandardErrorPath : null })
|
|
68
|
+
} catch {
|
|
69
|
+
return Object.freeze({ mode: 'foreground', reason: 'launchd job unreadable' })
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function registriesFromEnv(env = process.env) {
|
|
74
|
+
const custom = env.SINTBOT_UPDATE_REGISTRY
|
|
75
|
+
if (custom === undefined || custom === '') return REGISTRIES
|
|
76
|
+
const url = new URL(custom)
|
|
77
|
+
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('SINTBOT_UPDATE_REGISTRY must be an http(s) URL')
|
|
78
|
+
return Object.freeze([{ registry: url.href.replace(/\/$/, ''), source: 'custom' }])
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function defaultReleasesRoot(env = process.env, home = os.homedir()) {
|
|
82
|
+
const dir = env.SINTBOT_RELEASES_DIR
|
|
83
|
+
if (dir !== undefined && dir !== '') {
|
|
84
|
+
if (!path.isAbsolute(dir) || path.resolve(dir) !== dir) throw new Error('SINTBOT_RELEASES_DIR must be a normalized absolute path')
|
|
85
|
+
return dir
|
|
86
|
+
}
|
|
87
|
+
return path.join(home, '.local', 'share', 'sintbot', 'releases')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const entryOf = prefix => path.join(prefix, 'lib', 'node_modules', PACKAGE, 'bin', 'sintbot-v2.js')
|
|
91
|
+
|
|
92
|
+
function npmBin() {
|
|
93
|
+
const sibling = path.join(path.dirname(process.execPath), 'npm')
|
|
94
|
+
return fs.existsSync(sibling) ? sibling : 'npm'
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function defaultInstallVersion({ version, prefix, registry }) {
|
|
98
|
+
const env = {}
|
|
99
|
+
for (const key of ['PATH', 'HOME', 'USER', 'TMPDIR', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY',
|
|
100
|
+
'http_proxy', 'https_proxy', 'no_proxy', 'LANG', 'LC_ALL']) {
|
|
101
|
+
if (process.env[key] !== undefined) env[key] = process.env[key]
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
await execFileAsync(npmBin(), ['install', '--global', '--prefix', prefix, `--registry=${registry}`,
|
|
105
|
+
'--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund', `${PACKAGE}@${version}`],
|
|
106
|
+
{ timeout: 10 * 60_000, maxBuffer: 4 * 1024 * 1024, env })
|
|
107
|
+
} catch (error) {
|
|
108
|
+
throw new Error(`安装 v${version} 失败(退出码 ${error?.code ?? '未知'});没有提权,也没有改用其他版本`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The same verification as the public installer, in a child process so the
|
|
113
|
+
// service never loads modules of another version.
|
|
114
|
+
function defaultVerifyInstall({ version, prefix }) {
|
|
115
|
+
const root = path.join(prefix, 'lib', 'node_modules', PACKAGE)
|
|
116
|
+
// Only literal requires in this file: the package builder rejects computed ones.
|
|
117
|
+
const script = `
|
|
118
|
+
const fs = require('node:fs');
|
|
119
|
+
const path = require('node:path');
|
|
120
|
+
const { pathToFileURL } = require('node:url');
|
|
121
|
+
(async () => {
|
|
122
|
+
const root = process.argv[1];
|
|
123
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
124
|
+
if (pkg.name !== 'sintbot' || pkg.version !== process.argv[2] || pkg.private === true ||
|
|
125
|
+
pkg.bin?.sintbot !== 'bin/sintbot-v2.js') throw new Error('package identity differs');
|
|
126
|
+
await import(pathToFileURL(path.join(root, 'src/agent/privatecore.js')).href);
|
|
127
|
+
await import(pathToFileURL(path.join(root, 'src/platform/feishu.js')).href);
|
|
128
|
+
})().catch(() => process.exit(1));`
|
|
129
|
+
try {
|
|
130
|
+
execFileSync(process.execPath, ['-e', script, root, version], { timeout: 60_000, stdio: 'ignore', cwd: '/' })
|
|
131
|
+
const reported = execFileSync(process.execPath, [entryOf(prefix), 'version'],
|
|
132
|
+
{ encoding: 'utf8', timeout: 30_000, cwd: '/' }).trim()
|
|
133
|
+
if (reported !== version) throw new Error('version differs')
|
|
134
|
+
} catch {
|
|
135
|
+
throw new Error(`v${version} 安装后的核对没有通过;新目录保留在 ${prefix},服务没有切换`)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function defaultSpawnSwitch({ entry, stateFile }) {
|
|
140
|
+
const child = spawn(process.execPath, [entry, 'upgrade-switch', '--state', stateFile],
|
|
141
|
+
{ detached: true, stdio: 'ignore', cwd: '/', env: process.env })
|
|
142
|
+
child.unref()
|
|
143
|
+
return child.pid
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function journalHashes(dataDir) {
|
|
147
|
+
const out = {}
|
|
148
|
+
for (const name of fs.readdirSync(dataDir).filter(name => /^c2-v2.*\.log$/.test(name)).sort()) {
|
|
149
|
+
out[name] = createHash('sha256').update(fs.readFileSync(path.join(dataDir, name))).digest('hex')
|
|
150
|
+
}
|
|
151
|
+
return out
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function alive(pid) {
|
|
155
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false
|
|
156
|
+
try { process.kill(pid, 0); return true } catch (error) { return error.code === 'EPERM' }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function createC2Updater({ currentVersion, runMode, stateFile, dataDir, configPath,
|
|
160
|
+
gate, beginMaintenance = () => {}, endMaintenance = () => {}, expectedGroups = () => 0,
|
|
161
|
+
registries = REGISTRIES, fetchImpl = globalThis.fetch, releasesRoot = defaultReleasesRoot(),
|
|
162
|
+
installVersion = defaultInstallVersion, verifyInstall = defaultVerifyInstall,
|
|
163
|
+
spawnSwitch = defaultSpawnSwitch, hashJournals = journalHashes, isAlive = alive,
|
|
164
|
+
now = Date.now, waitMs = 5_000, queueTimeoutMs = QUEUE_TIMEOUT_MS,
|
|
165
|
+
checkIntervalMs = CHECK_INTERVAL_MS, autoCheck = true, log = () => {} } = {}) {
|
|
166
|
+
if (typeof currentVersion !== 'string' || !runMode || typeof runMode.mode !== 'string' ||
|
|
167
|
+
typeof stateFile !== 'string' || !path.isAbsolute(stateFile) || typeof gate !== 'function') {
|
|
168
|
+
throw new TypeError('C2 updater needs the version, run mode, state file and idle gate')
|
|
169
|
+
}
|
|
170
|
+
const current = stableVersion(currentVersion)
|
|
171
|
+
let data = readJSON(stateFile)
|
|
172
|
+
let timer = null
|
|
173
|
+
let checking = null
|
|
174
|
+
let closed = false
|
|
175
|
+
|
|
176
|
+
function persist() {
|
|
177
|
+
try { atomicWriteJSON(stateFile, data, { mode: 0o600 }) } catch (error) { log(`update state: ${error.message}`) }
|
|
178
|
+
}
|
|
179
|
+
function setJob(patch, message) {
|
|
180
|
+
data.job = { ...data.job, ...patch, ...(message ? { message } : {}), updatedAt: now() }
|
|
181
|
+
persist()
|
|
182
|
+
return data.job
|
|
183
|
+
}
|
|
184
|
+
function fail(message) {
|
|
185
|
+
setJob({ state: 'failed' }, String(message).slice(0, 300))
|
|
186
|
+
return status()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// A switch that this process did not finish is settled from the evidence:
|
|
190
|
+
// the new version reached here, the switch process is still working, or it
|
|
191
|
+
// stopped without writing a result.
|
|
192
|
+
if (data.job?.state === 'switching' && data.job.to !== current && !isAlive(data.job.helperPid)) {
|
|
193
|
+
fail('切换过程中断,现场已保留;请检查后重新发起')
|
|
194
|
+
} else if (data.job?.state === 'installing') {
|
|
195
|
+
fail('安装被中断,可重新发起升级')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// The switch process writes this file too; always act on its latest bytes.
|
|
199
|
+
function refresh() { data = readJSON(stateFile) }
|
|
200
|
+
function status() {
|
|
201
|
+
refresh()
|
|
202
|
+
const latest = stableVersion(data.latestVersion)
|
|
203
|
+
const upgradeSupported = runMode.mode === 'managed'
|
|
204
|
+
return {
|
|
205
|
+
currentVersion, latestVersion: latest, checkedAt: Number(data.checkedAt) || null,
|
|
206
|
+
source: data.source || '', ignoredVersion: data.ignoredVersion || '',
|
|
207
|
+
available: !!current && !!latest && compareVersion(latest, current) > 0 && data.ignoredVersion !== latest,
|
|
208
|
+
mode: runMode.mode, upgradeSupported, announceSupported: false,
|
|
209
|
+
job: data.job ? { ...data.job } : null,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function check() {
|
|
214
|
+
if (closed) return status()
|
|
215
|
+
if (checking) return checking
|
|
216
|
+
checking = (async () => {
|
|
217
|
+
let lastError = null
|
|
218
|
+
for (const item of registries) {
|
|
219
|
+
try {
|
|
220
|
+
const version = await fetchVersion({ fetchImpl, url: `${item.registry}/${PACKAGE}/latest` })
|
|
221
|
+
refresh()
|
|
222
|
+
data.latestVersion = version
|
|
223
|
+
data.checkedAt = now()
|
|
224
|
+
data.source = item.source
|
|
225
|
+
data.registry = item.registry
|
|
226
|
+
if (data.ignoredVersion && data.ignoredVersion !== version) data.ignoredVersion = ''
|
|
227
|
+
persist()
|
|
228
|
+
return status()
|
|
229
|
+
} catch (error) { lastError = error }
|
|
230
|
+
}
|
|
231
|
+
if (lastError) log(`update check unavailable: ${lastError.message || 'network error'}`)
|
|
232
|
+
return status()
|
|
233
|
+
})().finally(() => { checking = null })
|
|
234
|
+
return checking
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function ignore(version) {
|
|
238
|
+
refresh()
|
|
239
|
+
const v = stableVersion(version)
|
|
240
|
+
if (!v || v !== stableVersion(data.latestVersion)) throw new Error('只能忽略当前检测到的版本')
|
|
241
|
+
data.ignoredVersion = v
|
|
242
|
+
persist()
|
|
243
|
+
return status()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function startUpgrade({ version } = {}) {
|
|
247
|
+
refresh()
|
|
248
|
+
if (runMode.mode !== 'managed') {
|
|
249
|
+
throw new Error(runMode.mode === 'dev' ? '开发版请用部署脚本升级' : '前台运行请按卡片上的步骤升级')
|
|
250
|
+
}
|
|
251
|
+
const v = stableVersion(version)
|
|
252
|
+
if (!current || !v || v !== stableVersion(data.latestVersion) || compareVersion(v, current) <= 0) {
|
|
253
|
+
throw new Error('版本信息已变化,请先重新检查')
|
|
254
|
+
}
|
|
255
|
+
if (data.job && ACTIVE.includes(data.job.state)) throw new Error('已有升级正在进行')
|
|
256
|
+
if (gate().includes('held')) throw new Error('有暂停的群,先处理后再升级')
|
|
257
|
+
data.job = { id: `${now()}-${Math.random().toString(36).slice(2, 8)}`, state: 'queued',
|
|
258
|
+
from: current, to: v, registry: data.registry || registries[0].registry,
|
|
259
|
+
createdAt: now(), updatedAt: now(), message: '等所有群空闲、记录结清后升级' }
|
|
260
|
+
persist()
|
|
261
|
+
schedule(0)
|
|
262
|
+
return status()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function schedule(ms = waitMs) {
|
|
266
|
+
if (closed || timer || data.job?.state !== 'queued') return
|
|
267
|
+
timer = setTimeout(() => { timer = null; void processQueue() }, ms)
|
|
268
|
+
timer.unref?.()
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function processQueue() {
|
|
272
|
+
const job = data.job
|
|
273
|
+
if (closed || job?.state !== 'queued') return status()
|
|
274
|
+
if (now() - job.createdAt > queueTimeoutMs) return fail('等待群空闲超时;可在空闲后重新发起')
|
|
275
|
+
const blocked = gate()
|
|
276
|
+
if (blocked.includes('held')) return fail('有暂停的群,升级已取消;先处理暂停的群')
|
|
277
|
+
if (blocked.length) { schedule(); return status() }
|
|
278
|
+
const prefix = path.join(releasesRoot, job.to)
|
|
279
|
+
if (!job.installed) {
|
|
280
|
+
setJob({ state: 'installing', prefix }, `正在安装 v${job.to}`)
|
|
281
|
+
try {
|
|
282
|
+
if (fs.existsSync(prefix)) {
|
|
283
|
+
// A directory left by an earlier attempt or a manual install is reused
|
|
284
|
+
// only when it verifies as exactly this version.
|
|
285
|
+
await verifyInstall({ version: job.to, prefix })
|
|
286
|
+
} else {
|
|
287
|
+
fs.mkdirSync(releasesRoot, { recursive: true, mode: 0o700 })
|
|
288
|
+
await installVersion({ version: job.to, prefix, registry: job.registry })
|
|
289
|
+
await verifyInstall({ version: job.to, prefix })
|
|
290
|
+
}
|
|
291
|
+
} catch (error) { return fail(error.message) }
|
|
292
|
+
if (closed) return status()
|
|
293
|
+
setJob({ state: 'queued', installed: true }, `v${job.to} 已安装,等所有群空闲后切换`)
|
|
294
|
+
}
|
|
295
|
+
// Close admission first, then prove idleness once more: nothing can start
|
|
296
|
+
// between this check and the switch process stopping the service.
|
|
297
|
+
if (gate().length) { schedule(); return status() }
|
|
298
|
+
beginMaintenance()
|
|
299
|
+
const settled = gate()
|
|
300
|
+
if (settled.length) { endMaintenance(); schedule(); return status() }
|
|
301
|
+
let hashes
|
|
302
|
+
try { hashes = hashJournals(dataDir) } catch (error) { endMaintenance(); return fail(`无法读取群记录:${error.message}`) }
|
|
303
|
+
setJob({ state: 'switching', oldEntry: runMode.entry, newEntry: entryOf(prefix), label: runMode.label,
|
|
304
|
+
plist: runMode.plist, logFile: runMode.logFile, dataDir, configPath,
|
|
305
|
+
expectedGroups: expectedGroups(), journals: hashes, servicePid: process.pid,
|
|
306
|
+
backupDir: path.join(dataDir, 'upgrades', new Date(now()).toISOString().replace(/[:.]/g, '-')),
|
|
307
|
+
steps: [] }, `正在切换到 v${job.to};期间的消息会提示稍后重发`)
|
|
308
|
+
try {
|
|
309
|
+
const pid = spawnSwitch({ entry: runMode.entry, stateFile })
|
|
310
|
+
setJob({ helperPid: pid })
|
|
311
|
+
} catch (error) {
|
|
312
|
+
endMaintenance()
|
|
313
|
+
return fail(`切换进程没有启动:${error.message}`)
|
|
314
|
+
}
|
|
315
|
+
return status()
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// The new version marks the switch done only after every group connected.
|
|
319
|
+
function confirmStarted() {
|
|
320
|
+
refresh()
|
|
321
|
+
if (data.job?.state === 'switching' && data.job.to === current) {
|
|
322
|
+
setJob({ state: 'done', finishedAt: now() }, `已升级到 v${current}`)
|
|
323
|
+
}
|
|
324
|
+
return status()
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (data.job?.state === 'queued') schedule(0)
|
|
328
|
+
const checkTimer = autoCheck ? setInterval(() => { void check() }, checkIntervalMs) : null
|
|
329
|
+
checkTimer?.unref?.()
|
|
330
|
+
return { status, check, ignore, startUpgrade, processQueue, confirmStarted,
|
|
331
|
+
start() { return autoCheck ? check() : Promise.resolve(status()) },
|
|
332
|
+
close() { closed = true; if (checkTimer) clearInterval(checkTimer); if (timer) clearTimeout(timer); timer = null } }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// What keeps a console upgrade from starting: 'not-serving', 'held', 'busy'
|
|
336
|
+
// or 'unsettled'. The updater's own maintenance flag never blocks it, so the
|
|
337
|
+
// final check after admission closes can pass.
|
|
338
|
+
function createC2UpgradeGate({ isRunning, service, capacity, assertJournalScope }) {
|
|
339
|
+
return () => {
|
|
340
|
+
const reasons = new Set()
|
|
341
|
+
const live = service()
|
|
342
|
+
if (!isRunning() || !live) return ['not-serving']
|
|
343
|
+
if (live.held.size) reasons.add('held')
|
|
344
|
+
const shared = capacity.snapshot()
|
|
345
|
+
if (shared.closed) reasons.add('not-serving')
|
|
346
|
+
if (shared.active || shared.waiting) reasons.add('busy')
|
|
347
|
+
for (const [chatId, binding] of live.bindings) {
|
|
348
|
+
if (binding.gate.blocked) reasons.add('not-serving')
|
|
349
|
+
for (const rt of binding.engine.rts.values()) {
|
|
350
|
+
if (rt.v2NormalRouteActive || rt.v2AdmissionPending || rt.v2Controller ||
|
|
351
|
+
rt.v2QueueReceiptIds?.length) reasons.add('busy')
|
|
352
|
+
}
|
|
353
|
+
try { assertJournalScope(binding.journal, chatId, { requireSettled: true }) }
|
|
354
|
+
catch { reasons.add('unsettled') }
|
|
355
|
+
}
|
|
356
|
+
return [...reasons]
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
module.exports = { createC2Updater, createC2UpgradeGate, detectRunMode, registriesFromEnv, defaultReleasesRoot,
|
|
361
|
+
entryOf, journalHashes, readPlist, defaultVerifyInstall, REGISTRIES, PACKAGE }
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Detached switch process started by the console upgrade. It runs outside the
|
|
4
|
+
// service's process group, so stopping the launchd job does not stop it. The
|
|
5
|
+
// steps are those of the deploy scripts: stop the job, wait for the writer
|
|
6
|
+
// lock, prove the journals unchanged, back up data and plist, preflight the
|
|
7
|
+
// new entry with the job's environment, replace only the entry in the plist,
|
|
8
|
+
// start the job and wait for every group to connect. Any failure restores the
|
|
9
|
+
// original plist bytes and restarts the previous version.
|
|
10
|
+
const fs = require('node:fs')
|
|
11
|
+
const path = require('node:path')
|
|
12
|
+
const { execFileSync, spawnSync } = require('node:child_process')
|
|
13
|
+
const { isDeepStrictEqual } = require('node:util')
|
|
14
|
+
const { atomicWriteJSON } = require('./core/store')
|
|
15
|
+
const { journalHashes, readPlist } = require('./c2updater')
|
|
16
|
+
|
|
17
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
18
|
+
|
|
19
|
+
function defaultSystem() {
|
|
20
|
+
const uid = process.getuid()
|
|
21
|
+
const run = (file, args, options = {}) => spawnSync(file, args, { encoding: 'utf8', timeout: 60_000, ...options })
|
|
22
|
+
return {
|
|
23
|
+
bootout: label => run('/bin/launchctl', ['bootout', `gui/${uid}/${label}`]),
|
|
24
|
+
bootstrap: plist => run('/bin/launchctl', ['bootstrap', `gui/${uid}`, plist]),
|
|
25
|
+
jobPid: label => {
|
|
26
|
+
const out = run('/bin/launchctl', ['print', `gui/${uid}/${label}`])
|
|
27
|
+
if (out.status !== 0) return null
|
|
28
|
+
const match = /^\s*pid = (\d+)\s*$/m.exec(out.stdout || '')
|
|
29
|
+
return match ? Number(match[1]) : 0
|
|
30
|
+
},
|
|
31
|
+
alive: pid => { try { process.kill(pid, 0); return true } catch (error) { return error.code === 'EPERM' } },
|
|
32
|
+
readPlist: file => readPlist(file),
|
|
33
|
+
writePlist: (file, value) => {
|
|
34
|
+
const json = `${file}.upgrade.json`, tmp = `${file}.upgrade.tmp`
|
|
35
|
+
fs.writeFileSync(json, JSON.stringify(value), { mode: 0o600 })
|
|
36
|
+
try {
|
|
37
|
+
execFileSync('/usr/bin/plutil', ['-convert', 'xml1', '-o', tmp, json], { timeout: 10_000 })
|
|
38
|
+
fs.chmodSync(tmp, 0o600)
|
|
39
|
+
execFileSync('/usr/bin/plutil', ['-lint', tmp], { timeout: 10_000, stdio: 'ignore' })
|
|
40
|
+
fs.renameSync(tmp, file)
|
|
41
|
+
} finally { fs.rmSync(json, { force: true }); fs.rmSync(tmp, { force: true }) }
|
|
42
|
+
},
|
|
43
|
+
backupData: (dataDir, file) => {
|
|
44
|
+
const base = path.basename(dataDir)
|
|
45
|
+
const out = run('/usr/bin/tar', ['-czf', file, '--exclude', `${base}/upgrades`, '-C', path.dirname(dataDir), base])
|
|
46
|
+
if (out.status !== 0) throw new Error(`data backup failed: ${(out.stderr || '').trim().slice(0, 200)}`)
|
|
47
|
+
fs.chmodSync(file, 0o600)
|
|
48
|
+
},
|
|
49
|
+
preflight: (spec, entry) => {
|
|
50
|
+
const env = { HOME: process.env.HOME, USER: process.env.USER, ...(spec.EnvironmentVariables || {}) }
|
|
51
|
+
const args = [entry, ...spec.ProgramArguments.slice(2), '--preflight']
|
|
52
|
+
return run(spec.ProgramArguments[0], args, { env, cwd: '/', timeout: 180_000 })
|
|
53
|
+
},
|
|
54
|
+
logSize: file => { try { return fs.statSync(file).size } catch { return 0 } },
|
|
55
|
+
logSince: (file, offset) => {
|
|
56
|
+
try {
|
|
57
|
+
const fd = fs.openSync(file, 'r')
|
|
58
|
+
try {
|
|
59
|
+
const size = fs.fstatSync(fd).size
|
|
60
|
+
if (size <= offset) return ''
|
|
61
|
+
const buffer = Buffer.alloc(Math.min(size - offset, 4 * 1024 * 1024))
|
|
62
|
+
fs.readSync(fd, buffer, 0, buffer.length, offset)
|
|
63
|
+
return buffer.toString('utf8')
|
|
64
|
+
} finally { fs.closeSync(fd) }
|
|
65
|
+
} catch { return '' }
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function runUpgradeSwitch({ stateFile, system = defaultSystem(), hashJournals = journalHashes,
|
|
71
|
+
now = Date.now, wait = sleep, stopTimeoutMs = 60_000, connectTimeoutMs = 90_000, pollMs = 500 } = {}) {
|
|
72
|
+
const read = () => JSON.parse(fs.readFileSync(stateFile, 'utf8'))
|
|
73
|
+
const state = read()
|
|
74
|
+
const job = state.job
|
|
75
|
+
if (job?.state !== 'switching' || typeof job.label !== 'string' || typeof job.plist !== 'string' ||
|
|
76
|
+
typeof job.oldEntry !== 'string' || typeof job.newEntry !== 'string' ||
|
|
77
|
+
typeof job.dataDir !== 'string' || typeof job.backupDir !== 'string') {
|
|
78
|
+
throw new Error('No switching upgrade job to run')
|
|
79
|
+
}
|
|
80
|
+
const steps = []
|
|
81
|
+
const write = (patch, message) => {
|
|
82
|
+
const latest = read()
|
|
83
|
+
if (latest.job?.id !== job.id) throw new Error('The upgrade job changed during the switch')
|
|
84
|
+
latest.job = { ...latest.job, ...patch, ...(message ? { message } : {}), steps: [...steps], updatedAt: now() }
|
|
85
|
+
atomicWriteJSON(stateFile, latest, { mode: 0o600 })
|
|
86
|
+
return latest.job
|
|
87
|
+
}
|
|
88
|
+
const step = (name, ok = true, detail = '') => {
|
|
89
|
+
steps.push({ at: now(), step: name, ok, ...(detail ? { detail: String(detail).slice(0, 300) } : {}) })
|
|
90
|
+
try { write({}) } catch {}
|
|
91
|
+
}
|
|
92
|
+
const lockFile = path.join(job.dataDir, 'daemon.lock')
|
|
93
|
+
const until = async (predicate, ms) => {
|
|
94
|
+
const end = now() + ms
|
|
95
|
+
while (now() < end) { if (await predicate()) return true; await wait(pollMs) }
|
|
96
|
+
return !!(await predicate())
|
|
97
|
+
}
|
|
98
|
+
const stopped = () => until(() => {
|
|
99
|
+
const pid = system.jobPid(job.label)
|
|
100
|
+
return (pid === null || pid === 0) && !system.alive(job.servicePid) && !fs.existsSync(lockFile)
|
|
101
|
+
}, stopTimeoutMs)
|
|
102
|
+
const connected = (offset, expectDone) => until(() => {
|
|
103
|
+
const text = system.logSince(job.logFile, offset)
|
|
104
|
+
if (/ held \(|multi-group failure|startup cleanup was not confirmed/.test(text)) return false
|
|
105
|
+
if (job.expectedGroups > 0 &&
|
|
106
|
+
text.includes(`SintBot Feishu ingress connected for ${job.expectedGroups} groups`)) return true
|
|
107
|
+
try { return expectDone && read().job?.state === 'done' } catch { return false }
|
|
108
|
+
}, connectTimeoutMs)
|
|
109
|
+
|
|
110
|
+
fs.mkdirSync(job.backupDir, { recursive: true, mode: 0o700 })
|
|
111
|
+
const original = fs.readFileSync(job.plist)
|
|
112
|
+
fs.writeFileSync(path.join(job.backupDir, 'service.plist'), original, { mode: 0o600 })
|
|
113
|
+
const spec = system.readPlist(job.plist)
|
|
114
|
+
if (spec.Label !== job.label || spec.ProgramArguments?.[1] !== job.oldEntry) {
|
|
115
|
+
write({ state: 'failed' }, '服务配置和发起升级时不一致,没有切换')
|
|
116
|
+
return 'failed'
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Restore the exact original plist and start the previous version.
|
|
120
|
+
async function rollback(reason) {
|
|
121
|
+
step('rollback', true, reason)
|
|
122
|
+
system.bootout(job.label)
|
|
123
|
+
await stopped()
|
|
124
|
+
fs.writeFileSync(job.plist, original, { mode: 0o600 })
|
|
125
|
+
const offset = system.logSize(job.logFile)
|
|
126
|
+
const started = system.bootstrap(job.plist)
|
|
127
|
+
const back = started.status === 0 && await connected(offset, false)
|
|
128
|
+
step('previous version started', back)
|
|
129
|
+
write({ state: back ? 'rolled-back' : 'failed', finishedAt: now() }, back
|
|
130
|
+
? `没有切换到 v${job.to}:${reason};已换回 v${job.from}`
|
|
131
|
+
: `没有切换到 v${job.to}:${reason};换回 v${job.from} 后服务没有连上,请手动检查`)
|
|
132
|
+
return back ? 'rolled-back' : 'failed'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const out = system.bootout(job.label)
|
|
136
|
+
step('stop service', out.status === 0, out.stderr)
|
|
137
|
+
if (!await stopped()) return rollback('旧服务没有在规定时间内停止')
|
|
138
|
+
if (!isDeepStrictEqual(hashJournals(job.dataDir), job.journals)) {
|
|
139
|
+
// A journal changed after the idle check: never switch on top of it.
|
|
140
|
+
return rollback('停止过程中群记录发生了变化')
|
|
141
|
+
}
|
|
142
|
+
step('journals unchanged')
|
|
143
|
+
try { system.backupData(job.dataDir, path.join(job.backupDir, 'data.tgz')); step('data backed up') }
|
|
144
|
+
catch (error) { return rollback(error.message) }
|
|
145
|
+
const check = system.preflight(spec, job.newEntry)
|
|
146
|
+
step('new version preflight', check.status === 0, (check.stderr || '').trim().split('\n').pop())
|
|
147
|
+
if (check.status !== 0) return rollback('新版本预检没有通过')
|
|
148
|
+
const next = { ...spec, ProgramArguments: [...spec.ProgramArguments] }
|
|
149
|
+
next.ProgramArguments[1] = job.newEntry
|
|
150
|
+
try {
|
|
151
|
+
system.writePlist(job.plist, next)
|
|
152
|
+
const written = system.readPlist(job.plist)
|
|
153
|
+
const expected = { ...spec, ProgramArguments: next.ProgramArguments }
|
|
154
|
+
if (!isDeepStrictEqual(written, expected)) throw new Error('rewritten service configuration differs')
|
|
155
|
+
step('service entry replaced')
|
|
156
|
+
} catch (error) { return rollback(error.message) }
|
|
157
|
+
const offset = system.logSize(job.logFile)
|
|
158
|
+
const started = system.bootstrap(job.plist)
|
|
159
|
+
step('start new version', started.status === 0, started.stderr)
|
|
160
|
+
if (started.status !== 0 || !await connected(offset, true)) {
|
|
161
|
+
return rollback(`新版本启动后没有在 ${Math.round(connectTimeoutMs / 1000)} 秒内连上全部群`)
|
|
162
|
+
}
|
|
163
|
+
const latest = read().job
|
|
164
|
+
if (latest?.state !== 'done') write({ state: 'done', finishedAt: now() }, `已升级到 v${job.to}`)
|
|
165
|
+
else write({})
|
|
166
|
+
return 'done'
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = { runUpgradeSwitch, defaultSystem }
|
package/src/ui/console.html
CHANGED
|
@@ -2580,7 +2580,7 @@ function cronFullHtml(text){
|
|
|
2580
2580
|
}).join('');
|
|
2581
2581
|
}
|
|
2582
2582
|
const RUNTIME_UI_TEXT={
|
|
2583
|
-
"summary": "
|
|
2583
|
+
"summary": "当前支持多群消息、流式卡片回复、忙时跟进、停止任务、状态查看、认证管理、绑定新群、空闲群的模型(Codex / Claude Code)和项目目录切换,以及后台运行时的软件升级。新会话、解除绑定、控制台插话、定时任务、生图、画布与预览暂不可用。",
|
|
2584
2584
|
"groups": "可绑定新群,修改模型、项目目录、权限、数据输出和忙时跟进;该群须没有进行中的任务和未结记录。切模型或项目会开始新的一段对话。停止任务在「概览」。群里发 /model 也能切换模型。",
|
|
2585
2585
|
"skills": "当前支持飞书文字消息、忙时跟进、任务回复和停止。定时任务、生图、群消息管理、画布与预览暂未接入。",
|
|
2586
2586
|
"cron": "当前不执行定时任务;已有定义仅供查看。",
|
|
@@ -3485,21 +3485,27 @@ function renderSettings(){
|
|
|
3485
3485
|
<div class="hint">代理第一次填,新的对话轮次就生效;改已有的地址要重启后台服务(sintbot service restart)。</div></div>
|
|
3486
3486
|
</div>`;
|
|
3487
3487
|
const updateJob=upd&&upd.job;
|
|
3488
|
-
|
|
3489
|
-
const
|
|
3488
|
+
// 1.4.x 多群服务的升级器带 mode:后台托管才能一键升级,前台与开发版只给说明;不在群里播报。
|
|
3489
|
+
const updMode=upd&&upd.mode||'';
|
|
3490
|
+
const updateBusy=!!(updateJob&&['queued','installing','switching','restarting','restart-needed'].includes(updateJob.state));
|
|
3491
|
+
const updCanInstall=!!(upd&&(updMode?upd.upgradeSupported:true));
|
|
3492
|
+
const updateText=!upd?'后台版本检查不可用':updateJob&&updateJob.state!=='done'||updateJob&&!upd.available&&updMode?esc(updateJob.message||updateJob.state):upd.available?`检测到新版本 v${esc(upd.latestVersion)} · 当前 v${esc(upd.currentVersion)}`:updMode==='dev'&&upd.latestVersion?`最新正式版 v${esc(upd.latestVersion)} · 当前开发版 v${esc(upd.currentVersion)}`:upd.checkedAt&&upd.latestVersion?`当前为最新版本 v${esc(upd.currentVersion)}`:upd.checkedAt?'暂时无法确认最新版本':'尚未检查新版';
|
|
3493
|
+
const updHint=updMode==='managed'?'只安装检测到的稳定版本:等所有群空闲、记录结清后装到单独的目录,再切换后台服务;新版本没在 90 秒内连上全部群会自动换回。旧版本保留用于回滚,不提权,不在群里播报。'
|
|
3494
|
+
:updMode==='foreground'?'前台运行不能在控制台升级:在运行 SintBot 的终端按 Ctrl-C 等待退出,重新运行一行安装命令 curl -fsSL https://sintbot.com/install.sh | bash,再用原来的启动命令启动。'
|
|
3495
|
+
:updMode==='dev'?'开发版不在控制台升级,请用部署脚本。':'只安装检测到的稳定版本;所有群任务与队列清空后才会安装。需要可写的全局安装目录,不会提权;后台服务会自动重启,前台运行需手动重启。';
|
|
3490
3496
|
const updateCard=`<div class="card" style="grid-column:1/-1" id="updateCard"><div class="lbl">软件更新</div>
|
|
3491
3497
|
<div id="updateStatus" class="hint" style="margin:0 0 12px;color:var(--ink)">${updateText}</div>
|
|
3492
3498
|
${upd&&upd.available?`<div class="hint" style="margin:-4px 0 12px">版本差异与发布说明:<a href="https://github.com/sintlab/sintbot/releases/tag/v${encodeURIComponent(upd.latestVersion)}" target="_blank" rel="noopener" style="color:var(--ember-soft)">查看 v${esc(upd.latestVersion)} 发布说明</a></div>`:''}
|
|
3493
3499
|
<div class="inline" style="gap:14px;flex-wrap:wrap;margin-bottom:12px">
|
|
3494
3500
|
<span class="dim">自动检查稳定版(启动时及每天)</span>
|
|
3495
|
-
|
|
3501
|
+
${upd&&upd.announceSupported===false?'':`<label class="inline" style="gap:6px;cursor:pointer"><input type="checkbox" id="updAnnounce" ${upd&&upd.announce!==false?'checked':''} style="width:auto"/> <span class="dim">升级后向群里播报(记住选择)</span></label>`}
|
|
3496
3502
|
</div>
|
|
3497
3503
|
<div class="inline" style="gap:8px;flex-wrap:wrap">
|
|
3498
3504
|
<button class="btn" id="updCheck" ${!upd?'disabled':''}>检查新版</button>
|
|
3499
|
-
|
|
3505
|
+
${updCanInstall?`<button class="btn primary" id="updInstall" ${!upd||!upd.available||updateBusy?'disabled':''}>现在升级</button>`:''}
|
|
3500
3506
|
<button class="btn" id="updIgnore" ${!upd||!upd.available?'disabled':''}>忽略此版本</button>
|
|
3501
3507
|
</div>
|
|
3502
|
-
<div class="hint" style="margin-top:10px"
|
|
3508
|
+
<div class="hint" style="margin-top:10px">${updHint}</div>
|
|
3503
3509
|
<div class="update-history"><div class="subhead">版本历史</div>
|
|
3504
3510
|
<div id="verhistBox"><div class="empty">加载中…</div></div></div>
|
|
3505
3511
|
</div>`;
|
|
@@ -3547,18 +3553,19 @@ function renderSettings(){
|
|
|
3547
3553
|
}catch(e){toast(e.message,'err');b.disabled=false;}
|
|
3548
3554
|
};
|
|
3549
3555
|
const saveUpdatePrefs=async(patch)=>{try{S=await api('/api/ui/update-settings','POST',patch);renderSettings();}catch(e){toast(e.message,'err');}};
|
|
3550
|
-
$('#updAnnounce').onchange=()=>saveUpdatePrefs({announce:$('#updAnnounce').checked});
|
|
3556
|
+
if($('#updAnnounce'))$('#updAnnounce').onchange=()=>saveUpdatePrefs({announce:$('#updAnnounce').checked});
|
|
3551
3557
|
$('#updCheck').onclick=async()=>{const b=$('#updCheck');b.disabled=true;b.textContent='检查中…';
|
|
3552
3558
|
try{await api('/api/update/check','POST',{});S=await api('/api/state');await refreshInbox();renderSettings();renderOverview();toast('版本检查完成','ok');}
|
|
3553
3559
|
catch(e){toast(e.message,'err');b.disabled=false;b.textContent='检查新版';}};
|
|
3554
|
-
$('#updInstall').onclick=async()=>{const u=S.update||{},version=u.latestVersion;if(!version)return;
|
|
3555
|
-
const announce=$('#updAnnounce').checked;
|
|
3556
|
-
|
|
3560
|
+
if($('#updInstall'))$('#updInstall').onclick=async()=>{const u=S.update||{},version=u.latestVersion;if(!version)return;
|
|
3561
|
+
const announce=$('#updAnnounce')?$('#updAnnounce').checked:false;
|
|
3562
|
+
const what=u.mode==='managed'?'等所有群空闲、记录结清后装到单独的目录,再切换后台服务;切换期间约一分钟不处理新消息,新版本没连上全部群会自动换回。':`${announce?'安装完成后会向未暂停的群播报;':'本次不向群播报。'}所有群任务结束后才开始安装,后台服务会重启。`;
|
|
3563
|
+
if(!confirm(`将从 v${u.currentVersion} 升级到 v${version}。${what}继续?`))return;
|
|
3557
3564
|
const b=$('#updInstall');b.disabled=true;
|
|
3558
|
-
try{const r=await api('/api/update/start','POST',{version,announce});S.update={...r,announce};await refreshInbox();renderSettings();renderOverview();toast('更新已排队,群任务空闲后开始','ok');}
|
|
3565
|
+
try{const r=await api('/api/update/start','POST',{version,announce});S.update={...r,announce};await refreshInbox();renderSettings();renderOverview();toast(u.mode==='managed'?'升级已排队,所有群空闲后开始':'更新已排队,群任务空闲后开始','ok');}
|
|
3559
3566
|
catch(e){toast(e.message,'err');b.disabled=false;}};
|
|
3560
3567
|
$('#updIgnore').onclick=async()=>{const version=(S.update||{}).latestVersion;if(!version)return;
|
|
3561
|
-
try{const r=await api('/api/update/ignore','POST',{version});S.update={...r,announce:$('#updAnnounce').checked};await refreshInbox();renderSettings();renderOverview();toast('已忽略此版本','ok');}
|
|
3568
|
+
try{const r=await api('/api/update/ignore','POST',{version});S.update={...r,announce:$('#updAnnounce')?$('#updAnnounce').checked:false};await refreshInbox();renderSettings();renderOverview();toast('已忽略此版本','ok');}
|
|
3562
3569
|
catch(e){toast(e.message,'err');}};
|
|
3563
3570
|
// 只开着控制台的守护进程(还没接平台):第一次接上平台时说一句接下来会发生什么(服务托管的会自己重启连上)
|
|
3564
3571
|
{const pl=S.platforms||{};if(S.setupMode&&(pl.feishu||pl.slack||pl.wecom)&&!window._sbRestartToast){window._sbRestartToast=1;toast(S.setupMode.supervised?'已接上聊天平台 —— 后台服务几秒后自动重启并连上':'已接上聊天平台 —— 在终端里重启 sintbot start --ui 连上','ok');}}
|
|
@@ -4032,7 +4039,7 @@ updateThemeBtn();
|
|
|
4032
4039
|
const I18N_EN=[
|
|
4033
4040
|
["需要处理", "Needs attention"],
|
|
4034
4041
|
["当前可用操作", "Available controls"],
|
|
4035
|
-
["
|
|
4042
|
+
["当前支持多群消息、流式卡片回复、忙时跟进、停止任务、状态查看、认证管理、绑定新群、空闲群的模型(Codex / Claude Code)和项目目录切换,以及后台运行时的软件升级。新会话、解除绑定、控制台插话、定时任务、生图、画布与预览暂不可用。", "Available: group messages, streaming reply cards, busy-turn followups, stopping tasks, live status, authentication, binding new groups, switching the model (Codex / Claude Code) or project of an idle group, and software upgrades in a background run. New sessions, unbinding, console messages, schedules, image generation, canvas and previews are unavailable."],
|
|
4036
4043
|
["可绑定新群,修改模型、项目目录、权限、数据输出和忙时跟进;该群须没有进行中的任务和未结记录。切模型或项目会开始新的一段对话。停止任务在「概览」。群里发 /model 也能切换模型。", "Bind new groups and change the model, project folder, permissions, data display and busy-turn followups of a group with no running task or unsettled record. Changing the model or project starts a new conversation. Stop tasks in Overview. Members can also switch models with /model in the group."],
|
|
4037
4044
|
["当前支持飞书文字消息、忙时跟进、任务回复和停止。定时任务、生图、群消息管理、画布与预览暂未接入。", "Feishu text messages, busy-turn followups, task replies and stopping are supported. Schedules, image generation, group message management, canvas and previews are unavailable."],
|
|
4038
4045
|
["当前不执行定时任务;已有定义仅供查看。", "Schedules are not executed. Existing definitions are shown for reference only."],
|
|
@@ -4765,6 +4772,11 @@ const I18N_EN=[
|
|
|
4765
4772
|
["更新已排队,群任务空闲后开始","Update queued; installation will start when all group tasks are idle"],
|
|
4766
4773
|
["已忽略此版本","This version was ignored"],
|
|
4767
4774
|
["版本检查完成","Version check complete"],
|
|
4775
|
+
["暂时无法确认最新版本","The latest version could not be confirmed yet"],
|
|
4776
|
+
["升级已排队,所有群空闲后开始","Upgrade queued; it starts when every group is idle"],
|
|
4777
|
+
["只安装检测到的稳定版本:等所有群空闲、记录结清后装到单独的目录,再切换后台服务;新版本没在 90 秒内连上全部群会自动换回。旧版本保留用于回滚,不提权,不在群里播报。","Installs only the detected stable version: once every group is idle and settled, it goes into its own folder and the background service switches to it; if the new version does not connect every group within 90 seconds, the previous one is restored. The previous version is kept for rollback. No elevated privileges and no group announcements."],
|
|
4778
|
+
["前台运行不能在控制台升级:在运行 SintBot 的终端按 Ctrl-C 等待退出,重新运行一行安装命令 curl -fsSL https://sintbot.com/install.sh | bash,再用原来的启动命令启动。","A foreground run cannot upgrade from the console: press Ctrl-C in the terminal running SintBot and wait for it to exit, rerun the one-line installer curl -fsSL https://sintbot.com/install.sh | bash, then start with your usual command."],
|
|
4779
|
+
["开发版不在控制台升级,请用部署脚本。","Development builds are not upgraded from the console; use the deployment scripts."],
|
|
4768
4780
|
["访问地址正在切换;页面即将重载","Changing the access address; this page will reload"],
|
|
4769
4781
|
["访问设置已保存","Access settings saved"],
|
|
4770
4782
|
["开启局域网访问前要设置至少 6 位访问密码","Set an access password of at least 6 characters before enabling LAN access"],
|
|
@@ -5380,16 +5392,25 @@ const I18N_EN_RX=[
|
|
|
5380
5392
|
[/^登录失败 \(HTTP (\d+)\)$/,"Sign-in failed (HTTP $1)"],
|
|
5381
5393
|
[/^检测到新版本 v([\d.]+) · 当前 v([\d.]+)$/,"New version v$1 · current v$2"],
|
|
5382
5394
|
[/^当前为最新版本 v([\d.]+)$/,"v$1 is the latest version"],
|
|
5395
|
+
[/^最新正式版 v([\d.]+) · 当前开发版 v(\S+)$/,"Latest release v$1 · this development build v$2"],
|
|
5383
5396
|
[/^等待所有群任务结束后更新$/,"Waiting for all group tasks to finish"],
|
|
5384
5397
|
[/^正在检查安装位置并安装指定版本$/,"Checking the install location and installing the selected version"],
|
|
5385
5398
|
[/^已安装,正在重启后台服务$/,"Installed; restarting the background service"],
|
|
5386
5399
|
[/^已安装新版;后台服务未能自动重启,请手动重启$/,"The new version is installed, but the background service did not restart; restart it manually"],
|
|
5387
5400
|
[/^已安装新版;当前不是受托管服务,请手动重启 SintBot$/,"The new version is installed; restart SintBot manually"],
|
|
5388
5401
|
[/^更新完成并已重启$/,"Update completed and restarted"],
|
|
5402
|
+
[/^等所有群空闲、记录结清后升级$/,"Waiting until every group is idle and settled"],
|
|
5403
|
+
[/^正在安装 v([\d.]+)$/,"Installing v$1"],
|
|
5404
|
+
[/^v([\d.]+) 已安装,等所有群空闲后切换$/,"v$1 is installed; switching when every group is idle"],
|
|
5405
|
+
[/^正在切换到 v([\d.]+);期间的消息会提示稍后重发$/,"Switching to v$1; messages meanwhile are asked to be resent later"],
|
|
5406
|
+
[/^已升级到 v([\d.]+)$/,"Upgraded to v$1"],
|
|
5407
|
+
[/^没有切换到 v([\d.]+):([\s\S]*);已换回 v([\d.]+)$/,"Did not switch to v$1 ($2); v$3 was restored"],
|
|
5408
|
+
[/^等待群空闲超时;可在空闲后重新发起$/,"Timed out waiting for idle groups; retry when they are idle"],
|
|
5409
|
+
[/^有暂停的群,升级已取消;先处理暂停的群$/,"A group is paused, so the upgrade was cancelled; handle the paused group first"],
|
|
5389
5410
|
[/^更新后未能自动重启,请手动重启服务$/,"The update could not restart automatically; restart the service manually"],
|
|
5390
5411
|
[/^等待群任务结束超时;可在任务空闲后重新发起$/,"Timed out waiting for group tasks; retry when they are idle"],
|
|
5391
5412
|
[/^当前运行的是开发目录或非全局副本;请在终端手动更新$/,"This is a development or non-global install; update it from a terminal"],
|
|
5392
|
-
[/^将从 v([\d.]+) 升级到 v([\d.]+)。([\s\S]*)$/,(a,from,to,what)=>`Upgrade from v${from} to v${to}. ${what.replace('所有群任务结束后才开始安装,后台服务会重启。','Installation waits until all group tasks finish, then restarts the background service.').replace('本次不向群播报。','This update will not be announced in groups.').replace('安装完成后会向未暂停的群播报;','After installation, it will be announced in active groups;')}`],
|
|
5413
|
+
[/^将从 v([\d.]+) 升级到 v([\d.]+)。([\s\S]*)$/,(a,from,to,what)=>`Upgrade from v${from} to v${to}. ${what.replace('等所有群空闲、记录结清后装到单独的目录,再切换后台服务;切换期间约一分钟不处理新消息,新版本没连上全部群会自动换回。','Once every group is idle and settled, it installs into its own folder and switches the background service; new messages wait about a minute during the switch, and the previous version is restored if the new one does not connect every group. ').replace('所有群任务结束后才开始安装,后台服务会重启。','Installation waits until all group tasks finish, then restarts the background service.').replace('本次不向群播报。','This update will not be announced in groups.').replace('安装完成后会向未暂停的群播报;','After installation, it will be announced in active groups;')}`],
|
|
5393
5414
|
];
|
|
5394
5415
|
/* I18N_DICT_END */
|
|
5395
5416
|
|
|
@@ -5590,7 +5611,7 @@ async function fetchCodexModels(){if(CX_MODELS)return CX_MODELS;try{CX_MODELS=aw
|
|
|
5590
5611
|
function cxDefEffortLabel(model){const m=CX_MODELS&&(CX_MODELS.models||[]).find(x=>x.model===model);return '默认'+(m&&m.defEffort?'('+m.defEffort+')':'');}
|
|
5591
5612
|
/* 有 codex provider 时启动即预热模型列表(server 缓存 10min)→ 卡片默认档标注补齐 + 面板秒开 */
|
|
5592
5613
|
function warmCodexModels(){if(!CX_MODELS&&S&&(S.providers||[]).some(x=>x.runner==='codex'&&!x.hidden))fetchCodexModels().then(()=>renderProviders());}
|
|
5593
|
-
const RUNTIME_DISABLED_SELECTORS='[data-osay],[data-onew],.gpv,[data-rm],[data-gcfg],[data-cronnew],[data-cronadd],[data-crontgl],[data-cronrm],[data-reposave],[data-repotest],[data-iconuse],[data-iconsys],[data-iconstyle],[data-stylecollect],#mNewChat,#addModel,.pcard [data-act="edit"],.pcard [data-act="test"],#amSave,#amDel,#mNewGo,#pgSend,#pgNew,#fSave,#slSave,#wSave,#bSave,#styleNew,[data-styleedit],[data-styledup],[data-styledel],#nsSave,#nsExtract,#clSave
|
|
5614
|
+
const RUNTIME_DISABLED_SELECTORS='[data-osay],[data-onew],.gpv,[data-rm],[data-gcfg],[data-cronnew],[data-cronadd],[data-crontgl],[data-cronrm],[data-reposave],[data-repotest],[data-iconuse],[data-iconsys],[data-iconstyle],[data-stylecollect],#mNewChat,#addModel,.pcard [data-act="edit"],.pcard [data-act="test"],#amSave,#amDel,#mNewGo,#pgSend,#pgNew,#fSave,#slSave,#wSave,#bSave,#styleNew,[data-styleedit],[data-styledup],[data-styledel],#nsSave,#nsExtract,#clSave,[data-pvstop],[data-lvkill],[data-lvkillall]';
|
|
5594
5615
|
function disableRuntimeControl(el,reason){
|
|
5595
5616
|
el.disabled=true;el.title=trText(reason);el.dataset.runtimeDisabled='1';
|
|
5596
5617
|
el.setAttribute('aria-disabled','true');
|
package/src/updater.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Stable-version checks and guarded in-place upgrades for the global package.
|
|
2
|
+
// Checking is read-only. Installation is explicit, exact-version only, waits for
|
|
3
|
+
// every group queue to drain, and never escalates privileges.
|
|
4
|
+
|
|
5
|
+
const fs = require('node:fs')
|
|
6
|
+
const path = require('node:path')
|
|
7
|
+
const { execFile } = require('node:child_process')
|
|
8
|
+
const { promisify } = require('node:util')
|
|
9
|
+
const { atomicWriteJSON } = require('./core/store')
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile)
|
|
12
|
+
const PACKAGE = 'sintbot'
|
|
13
|
+
const REGISTRIES = [
|
|
14
|
+
{ url: 'https://registry.npmjs.org/sintbot/latest', source: 'npm' },
|
|
15
|
+
{ url: 'https://registry.npmmirror.com/sintbot/latest', source: 'mirror' },
|
|
16
|
+
]
|
|
17
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
|
18
|
+
const CHECK_TIMEOUT_MS = 10_000
|
|
19
|
+
const QUEUE_TIMEOUT_MS = 24 * 60 * 60 * 1000
|
|
20
|
+
|
|
21
|
+
function stableVersion(value) {
|
|
22
|
+
const s = String(value || '').trim().replace(/^v/, '')
|
|
23
|
+
return /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(s) ? s : null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function compareVersion(a, b) {
|
|
27
|
+
const av = stableVersion(a); const bv = stableVersion(b)
|
|
28
|
+
if (!av || !bv) return 0
|
|
29
|
+
const aa = av.split('.').map(BigInt); const bb = bv.split('.').map(BigInt)
|
|
30
|
+
for (let i = 0; i < 3; i++) if (aa[i] !== bb[i]) return aa[i] > bb[i] ? 1 : -1
|
|
31
|
+
return 0
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hasPendingWork(live) {
|
|
35
|
+
// Updating while the engine is missing or its snapshot is incomplete is unsafe:
|
|
36
|
+
// a busy group could be hidden by a failed state read. Treat unknown as busy.
|
|
37
|
+
if (!live || live.running !== true || !Number.isFinite(Number(live.queue)) || !live.groups || typeof live.groups !== 'object') return true
|
|
38
|
+
if (Number(live.queue) > 0) return true
|
|
39
|
+
return Object.values(live.groups).some((g) => !g || g.busy || !Number.isFinite(Number(g.queue)) || Number(g.queue) > 0)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function fetchVersion({ fetchImpl = globalThis.fetch, url, timeoutMs = CHECK_TIMEOUT_MS } = {}) {
|
|
43
|
+
const ac = new AbortController()
|
|
44
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs)
|
|
45
|
+
try {
|
|
46
|
+
const res = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: ac.signal, redirect: 'error' })
|
|
47
|
+
if (!res || !res.ok) throw new Error(`HTTP ${res && res.status || 'error'}`)
|
|
48
|
+
const data = await res.json()
|
|
49
|
+
const version = stableVersion(data && (data.version || (data['dist-tags'] && data['dist-tags'].latest)))
|
|
50
|
+
if (!version) throw new Error('registry returned no stable version')
|
|
51
|
+
return version
|
|
52
|
+
} finally { clearTimeout(timer) }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readState(file) {
|
|
56
|
+
try {
|
|
57
|
+
const v = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
58
|
+
return v && typeof v === 'object' ? v : {}
|
|
59
|
+
} catch { return {} }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function createUpdater({
|
|
63
|
+
currentVersion,
|
|
64
|
+
stateFile,
|
|
65
|
+
getLiveState = () => null,
|
|
66
|
+
fetchImpl = globalThis.fetch,
|
|
67
|
+
registries = REGISTRIES,
|
|
68
|
+
now = Date.now,
|
|
69
|
+
waitMs = 5000,
|
|
70
|
+
queueTimeoutMs = QUEUE_TIMEOUT_MS,
|
|
71
|
+
checkIntervalMs = CHECK_INTERVAL_MS,
|
|
72
|
+
isGlobalInstall = defaultGlobalInstall,
|
|
73
|
+
installExact = defaultInstallExact,
|
|
74
|
+
readInstalledVersion = defaultReadInstalledVersion,
|
|
75
|
+
getManagedService = () => false,
|
|
76
|
+
restartManagedService = async () => ({ ok: false }),
|
|
77
|
+
setAnnouncementBaseline = () => {},
|
|
78
|
+
log = () => {},
|
|
79
|
+
} = {}) {
|
|
80
|
+
const file = stateFile || path.join(require('node:os').homedir(), '.sbot', 'update.json')
|
|
81
|
+
const current = stableVersion(currentVersion) || '0.0.0'
|
|
82
|
+
let data = readState(file)
|
|
83
|
+
let queueTimer = null
|
|
84
|
+
let checking = null
|
|
85
|
+
let closed = false
|
|
86
|
+
|
|
87
|
+
// If a previous daemon was replaced during the restart step, the new package
|
|
88
|
+
// version is the evidence that installation and restart both completed.
|
|
89
|
+
if (data.job && data.job.state === 'restarting') {
|
|
90
|
+
data.job = data.job.to === current
|
|
91
|
+
? { ...data.job, state: 'done', message: '更新完成并已重启', updatedAt: now() }
|
|
92
|
+
: { ...data.job, state: 'failed', message: '更新后未能自动重启,请手动重启服务', updatedAt: now() }
|
|
93
|
+
persist()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function persist() { try { atomicWriteJSON(file, data, { mode: 0o600 }) } catch (e) { log('update state: ' + (e && e.message)) } }
|
|
97
|
+
function status() {
|
|
98
|
+
const latest = stableVersion(data.latestVersion)
|
|
99
|
+
return {
|
|
100
|
+
currentVersion: current,
|
|
101
|
+
latestVersion: latest,
|
|
102
|
+
checkedAt: Number(data.checkedAt) || null,
|
|
103
|
+
source: data.source || '',
|
|
104
|
+
ignoredVersion: data.ignoredVersion || '',
|
|
105
|
+
available: !!latest && compareVersion(latest, current) > 0 && data.ignoredVersion !== latest,
|
|
106
|
+
job: data.job ? { ...data.job } : null,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function check() {
|
|
111
|
+
if (closed) return status()
|
|
112
|
+
if (checking) return checking
|
|
113
|
+
checking = (async () => {
|
|
114
|
+
let lastError = null
|
|
115
|
+
for (const r of registries) {
|
|
116
|
+
try {
|
|
117
|
+
const version = await fetchVersion({ fetchImpl, url: r.url })
|
|
118
|
+
data.latestVersion = version
|
|
119
|
+
data.checkedAt = now()
|
|
120
|
+
data.source = r.source || ''
|
|
121
|
+
if (data.ignoredVersion && data.ignoredVersion !== version) data.ignoredVersion = ''
|
|
122
|
+
persist()
|
|
123
|
+
return status()
|
|
124
|
+
} catch (e) { lastError = e }
|
|
125
|
+
}
|
|
126
|
+
// Network errors are deliberately quiet and never erase the last success.
|
|
127
|
+
if (lastError) log('update check unavailable: ' + (lastError && lastError.message || 'network error'))
|
|
128
|
+
return status()
|
|
129
|
+
})().finally(() => { checking = null })
|
|
130
|
+
return checking
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function ignore(version) {
|
|
134
|
+
const v = stableVersion(version)
|
|
135
|
+
if (!v || v !== stableVersion(data.latestVersion)) throw new Error('只能忽略当前检测到的版本')
|
|
136
|
+
data.ignoredVersion = v
|
|
137
|
+
persist()
|
|
138
|
+
return status()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function startUpgrade({ version, announce = true } = {}) {
|
|
142
|
+
const v = stableVersion(version)
|
|
143
|
+
if (!v || v !== stableVersion(data.latestVersion) || compareVersion(v, current) <= 0) throw new Error('版本信息已变化,请先重新检查')
|
|
144
|
+
if (data.job && ['queued', 'installing', 'restarting', 'restart-needed'].includes(data.job.state)) throw new Error('已有更新需要完成或重启后再试')
|
|
145
|
+
data.job = { id: `${now()}-${Math.random().toString(36).slice(2, 8)}`, state: 'queued', from: current, to: v,
|
|
146
|
+
announce: announce !== false, createdAt: now(), updatedAt: now(), message: '等待所有群任务结束后更新' }
|
|
147
|
+
persist()
|
|
148
|
+
void processQueue()
|
|
149
|
+
return status()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function scheduleQueueCheck() {
|
|
153
|
+
if (closed || queueTimer || !data.job || data.job.state !== 'queued') return
|
|
154
|
+
queueTimer = setTimeout(() => { queueTimer = null; void processQueue() }, waitMs)
|
|
155
|
+
if (queueTimer.unref) queueTimer.unref()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function processQueue() {
|
|
159
|
+
const job = data.job
|
|
160
|
+
if (closed || !job || job.state !== 'queued') return
|
|
161
|
+
if (now() - job.createdAt > queueTimeoutMs) {
|
|
162
|
+
data.job = { ...job, state: 'failed', message: '等待群任务结束超时;可在任务空闲后重新发起', updatedAt: now() }
|
|
163
|
+
persist(); return
|
|
164
|
+
}
|
|
165
|
+
let live
|
|
166
|
+
try { live = getLiveState() }
|
|
167
|
+
catch (e) { log('update queue: live state unavailable: ' + (e && e.message)); return scheduleQueueCheck() }
|
|
168
|
+
if (hasPendingWork(live)) return scheduleQueueCheck()
|
|
169
|
+
if (queueTimer) { clearInterval(queueTimer); queueTimer = null }
|
|
170
|
+
data.job = { ...job, state: 'installing', message: '正在检查安装位置并安装指定版本', updatedAt: now() }
|
|
171
|
+
persist()
|
|
172
|
+
try {
|
|
173
|
+
const checkResult = await isGlobalInstall()
|
|
174
|
+
if (!checkResult || !checkResult.ok) throw new Error((checkResult && checkResult.reason) || '当前运行位置不是可写的全局安装')
|
|
175
|
+
const result = await installExact(PACKAGE, job.to, checkResult)
|
|
176
|
+
if (!result || result.ok === false) throw new Error((result && result.reason) || '安装失败;检查包管理器日志后可手动重试')
|
|
177
|
+
const installed = await readInstalledVersion(checkResult)
|
|
178
|
+
if (stableVersion(installed) !== job.to) throw new Error('安装完成后的版本核对不符;请手动检查后重启')
|
|
179
|
+
if (!job.announce) { try { setAnnouncementBaseline(job.to) } catch (e) { log('update announcement baseline: ' + (e && e.message)) } }
|
|
180
|
+
const managed = await getManagedService()
|
|
181
|
+
if (managed) {
|
|
182
|
+
data.job = { ...job, state: 'restarting', message: '已安装,正在重启后台服务', updatedAt: now() }
|
|
183
|
+
persist()
|
|
184
|
+
const restarted = await restartManagedService()
|
|
185
|
+
if (restarted && restarted.ok) return status()
|
|
186
|
+
data.job = { ...job, state: 'restart-needed', message: '已安装新版;后台服务未能自动重启,请手动重启', updatedAt: now() }
|
|
187
|
+
} else {
|
|
188
|
+
data.job = { ...job, state: 'restart-needed', message: '已安装新版;当前不是受托管服务,请手动重启 SintBot', updatedAt: now() }
|
|
189
|
+
}
|
|
190
|
+
persist()
|
|
191
|
+
} catch (e) {
|
|
192
|
+
data.job = { ...job, state: 'failed', message: String(e && e.message || e).slice(0, 300), updatedAt: now() }
|
|
193
|
+
persist()
|
|
194
|
+
}
|
|
195
|
+
return status()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (data.job && data.job.state === 'queued') void processQueue()
|
|
199
|
+
const checkTimer = setInterval(() => { void check() }, checkIntervalMs)
|
|
200
|
+
if (checkTimer.unref) checkTimer.unref()
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
status,
|
|
204
|
+
check,
|
|
205
|
+
ignore,
|
|
206
|
+
startUpgrade,
|
|
207
|
+
start() { return check() },
|
|
208
|
+
close() { closed = true; clearInterval(checkTimer); if (queueTimer) clearTimeout(queueTimer); queueTimer = null },
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function defaultGlobalInstall() {
|
|
213
|
+
const { execFileSync } = require('node:child_process')
|
|
214
|
+
const rootOut = execFileSync('npm', ['root', '-g'], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'] }).trim()
|
|
215
|
+
const globalRoot = fs.realpathSync(rootOut)
|
|
216
|
+
const globalPackage = path.join(globalRoot, PACKAGE)
|
|
217
|
+
const runningPackage = fs.realpathSync(path.resolve(__dirname, '..'))
|
|
218
|
+
if (runningPackage !== fs.realpathSync(globalPackage)) return { ok: false, reason: '当前运行的是开发目录或非全局副本;请在终端手动更新' }
|
|
219
|
+
fs.accessSync(globalRoot, fs.constants.W_OK | fs.constants.X_OK)
|
|
220
|
+
fs.accessSync(path.dirname(globalPackage), fs.constants.W_OK | fs.constants.X_OK)
|
|
221
|
+
return { ok: true, globalRoot, globalPackage }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function defaultInstallExact(name, version) {
|
|
225
|
+
const env = {}
|
|
226
|
+
const allowed = ['PATH', 'HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'TEMP', 'TMP', 'SystemRoot', 'PATHEXT',
|
|
227
|
+
'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy', 'LANG', 'LC_ALL']
|
|
228
|
+
for (const k of allowed) if (process.env[k] !== undefined) env[k] = process.env[k]
|
|
229
|
+
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
|
230
|
+
try {
|
|
231
|
+
await execFileAsync(npm, ['install', '--global', `${name}@${version}`, '--omit=dev', '--no-audit', '--no-fund'],
|
|
232
|
+
{ timeout: 10 * 60 * 1000, maxBuffer: 2 * 1024 * 1024, windowsHide: true, env })
|
|
233
|
+
return { ok: true }
|
|
234
|
+
} catch (e) {
|
|
235
|
+
return { ok: false, reason: `安装失败(退出码 ${e && e.code != null ? e.code : '未知'});未提权,也未改用其他版本` }
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function defaultReadInstalledVersion(info) {
|
|
240
|
+
try { return JSON.parse(fs.readFileSync(path.join(info.globalPackage, 'package.json'), 'utf8')).version || null } catch { return null }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
module.exports = { PACKAGE, REGISTRIES, CHECK_INTERVAL_MS, stableVersion, compareVersion, hasPendingWork, fetchVersion, createUpdater, defaultGlobalInstall, defaultInstallExact, defaultReadInstalledVersion }
|