sintbot 1.0.12 → 1.0.14
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 +37 -0
- package/CHANGELOG.json +38 -0
- package/bin/sbot.js +71 -78
- package/package.json +1 -1
- package/src/service.js +76 -6
- package/src/ui/console.html +100 -1
- package/src/ui/dashboard.js +4 -1
- package/src/ui/server.js +137 -6
- package/src/updater.js +243 -0
package/CHANGELOG.en.json
CHANGED
|
@@ -1,4 +1,41 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.0.14",
|
|
4
|
+
"date": "2026-10-30",
|
|
5
|
+
"name": "Steadier service installs and better console controls",
|
|
6
|
+
"items": [
|
|
7
|
+
{
|
|
8
|
+
"t": "More reliable service reinstalls",
|
|
9
|
+
"d": "A reinstall now waits for the old background service to fully unload before starting the new one, reducing cases where the service is left unloaded."
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"t": "Software updates from the console",
|
|
13
|
+
"d": "Stable releases are checked at startup and daily, with a manual check available too. An administrator confirms an upgrade, which waits for every group task and queue to clear before installing the exact version."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"t": "Manage console access in Settings",
|
|
17
|
+
"d": "Set a password for LAN access and toggle LAN binding or public Feishu sign-in. Turning off public sign-in revokes existing Feishu sessions; a failed binding change restores the previous settings."
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"version": "1.0.13",
|
|
23
|
+
"name": "An even shorter install: almost nothing to answer",
|
|
24
|
+
"items": [
|
|
25
|
+
{
|
|
26
|
+
"t": "No questions for what can be done automatically",
|
|
27
|
+
"d": "The one-line install upgrades to the latest version directly. The wizard installs Claude Code when it is missing, picks the default model itself and runs a trial turn, and no longer asks about adding more models; Codex and the design icon libraries are optional and only get a one-line install hint. If you are already logged in to Claude or Codex, the whole install asks one thing: whether to install the background service."
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"t": "Keys and proxy go into the service automatically",
|
|
31
|
+
"d": "Installing the background service writes the environment-variable keys the config refers to, and your terminal's proxy, into the service definition (mode 600) without asking. Run sintbot service install again after changing a key or the proxy."
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"t": "Less output",
|
|
35
|
+
"d": "The install no longer shows npm progress or harmless warnings, and the background service reports a single line when it is up."
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
},
|
|
2
39
|
{
|
|
3
40
|
"version": "1.0.12",
|
|
4
41
|
"name": "Simpler install: the terminal sets up the model, the console does the rest",
|
package/CHANGELOG.json
CHANGED
|
@@ -1,4 +1,42 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.0.14",
|
|
4
|
+
"date": "2026-10-30",
|
|
5
|
+
"name": "服务重装与控制台管理更完善",
|
|
6
|
+
"items": [
|
|
7
|
+
{
|
|
8
|
+
"t": "服务重装更稳定",
|
|
9
|
+
"d": "重装时会等旧后台服务彻底退出后再启动,减少服务卡在未加载状态的情况。"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"t": "控制台可管理软件更新",
|
|
13
|
+
"d": "启动时及每天自动检查稳定版,也可手动检查。发现新版后由管理员确认,等所有群任务和队列空闲后再安装指定版本。"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"t": "访问地址可在控制台管理",
|
|
17
|
+
"d": "可以设置局域网访问密码、切换局域网监听和公网飞书登录。关闭公网登录会让现有飞书登录失效;切换监听失败时会恢复原设置。"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"version": "1.0.13",
|
|
23
|
+
"date": "2026-10-28",
|
|
24
|
+
"name": "安装再精简:几乎不用回答问题",
|
|
25
|
+
"items": [
|
|
26
|
+
{
|
|
27
|
+
"t": "能自动做的不再问",
|
|
28
|
+
"d": "一键安装直接升级到最新版;向导缺 Claude Code 直接装、自动选默认模型并试跑一轮,不再问要不要再加模型;Codex 和设计图标库是可选的,只给一行装法。已经登录 Claude 或 Codex 的话,整个安装只问一句要不要装后台服务。"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"t": "key 和代理自动带进后台服务",
|
|
32
|
+
"d": "装后台服务时,配置里引用的环境变量 key、以及终端里的代理,直接写进服务定义(文件权限 600),不再问。换了 key 或代理重跑一次 sintbot service install。"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"t": "输出更短",
|
|
36
|
+
"d": "安装过程不再打 npm 的进度和无害警告;后台服务装好只报一行。"
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
},
|
|
2
40
|
{
|
|
3
41
|
"version": "1.0.12",
|
|
4
42
|
"date": "2026-10-28",
|
package/bin/sbot.js
CHANGED
|
@@ -61,6 +61,7 @@ const feishulogin = require('../src/ui/feishulogin') // 公网地址归一(ui.pu
|
|
|
61
61
|
const { buildTakeoverCard } = require('../src/platform/takeovercard') // 「接管浏览器」的群内卡片(四态:等人来 / 接管中 / 已结束 / 已过期)
|
|
62
62
|
const cdp = require('../src/cdp') // 接管卡片上的页面截图:CLI 直接连常驻浏览器截一张
|
|
63
63
|
const { hashPassword } = require('../src/ui/auth')
|
|
64
|
+
const updateFeature = require('../src/updater')
|
|
64
65
|
|
|
65
66
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`
|
|
66
67
|
const cyan = (s) => `\x1b[36m${s}\x1b[0m`
|
|
@@ -211,13 +212,13 @@ const SUBHELP = {
|
|
|
211
212
|
+ ' --send 直接把一张卡发进群(带当前页面截图 + 「接管浏览器」按钮 + 有效期;有人点开始接管卡片自动翻成「正在接管中」)—— 群里跑时就用它,别自己贴链接\n'
|
|
212
213
|
+ ' 对方在手机或电脑上打开就能直接操作这台机器的浏览器,登录态留在浏览器里,之后 agent 自己接着干\n'
|
|
213
214
|
+ ' 要守护进程带控制台在跑(sintbot start --ui);群里跑时控制台地址已注入,终端里可设 SBOT_UI_URL=http://127.0.0.1:4600',
|
|
214
|
-
service: '用法: sintbot service install [--ui-host <ip>] [--config <path>] [--
|
|
215
|
+
service: '用法: sintbot service install [--ui-host <ip>] [--config <path>] [--dry-run]\n'
|
|
215
216
|
+ ' sintbot service status | restart | logs [-n <行数>] | uninstall\n'
|
|
216
217
|
+ ' 把 sintbot start --ui 装成常驻服务:崩溃自动拉起、登录后自启(macOS = launchd,Linux = systemd --user)\n'
|
|
217
218
|
+ ' install 写单元文件并立即启动;服务里固定用当前的 node、sbot 与配置文件的绝对路径,PATH 取当前 shell 的\n'
|
|
218
219
|
+ ' --dry-run 只打印要写的单元文件,不动系统;--ui-host 0.0.0.0 对局域网开控制台(须先 sintbot ui --set-password,否则拒装)\n'
|
|
219
|
-
+ ' 服务拿不到当前 shell 的环境变量:配置里 env:VAR 引用的 key
|
|
220
|
-
+ '
|
|
220
|
+
+ ' 服务拿不到当前 shell 的环境变量:配置里 env:VAR 引用的 key、以及配置没写代理时 shell 里的代理变量,值直接写进单元文件(文件权限 600);换了要重跑 install\n'
|
|
221
|
+
+ ' 还没接任何聊天平台也照装:服务先只开控制台,在「设置」里接好平台后自己重启连上\n'
|
|
221
222
|
+ ' Windows 不自动装:打印 NSSM / pm2 的现成命令。日志在 ~/.sbot/logs/;同一份状态只许一个 daemon,第二个会直接报错退出',
|
|
222
223
|
version: '用法: sintbot version (也认 sbot --version / -v)\n'
|
|
223
224
|
+ ' 打印版本号',
|
|
@@ -487,7 +488,7 @@ async function cmdStart(rest) {
|
|
|
487
488
|
const plat = platformsConfigured(cfg)
|
|
488
489
|
if (!plat.feishu && !plat.wecom && !plat.slack) {
|
|
489
490
|
// 带 --ui(后台服务就是这么起的):只开控制台,等人在「设置」里接平台 —— 1.0.12 起没平台也装服务,装完就有地方可去
|
|
490
|
-
if (wantUi) return serveSetupConsole(cfg, { port: Number(values['ui-port']) || 4600, host: values['ui-host'] || '127.0.0.1', noOpen })
|
|
491
|
+
if (wantUi) return serveSetupConsole(cfg, { port: Number(values['ui-port']) || 4600, host: (cfg.ui && cfg.ui.host) || values['ui-host'] || '127.0.0.1', noOpen })
|
|
491
492
|
if (cfg.wecom && (cfg.wecom.botId || cfg.wecom.secret)) return die('sbot.config.json 的 wecom 块缺 ' + (cfg.wecom.botId ? 'secret' : 'botId') + '(机器人 API 配置页「使用长连接」处)')
|
|
492
493
|
return die('没有配置任何聊天平台 — 在 sbot.config.json 里加 "feishu" 块(appId/appSecret)、"wecom" 块(botId/secret)或 "slack" 块(botToken/appToken),配几个接几个')
|
|
493
494
|
}
|
|
@@ -524,6 +525,7 @@ async function cmdStart(rest) {
|
|
|
524
525
|
const log = (m) => process.stderr.write(gray(`[${logStamp()}] · ` + m) + '\n')
|
|
525
526
|
if (tightened.length) log(`状态目录权限已收紧(目录 700、文件 600):${tightened.length} 项`)
|
|
526
527
|
let ui = null
|
|
528
|
+
let updaterManager = null
|
|
527
529
|
let platform = null // set below; the UI (created first) reads WS health through this ref(多平台路由器,src/platform/multi.js)
|
|
528
530
|
// 把一条消息交给引擎:**合成消息**(控制台插话 / 定时任务 / 续做 / 画布确认)没有真实适配器,按 chatId 查出它所属平台的
|
|
529
531
|
// 适配器再喂 —— 引擎读的 skillCmd / features / chatLabel 是适配器上的,喂路由器会让企微群的清单按飞书口径写。
|
|
@@ -709,7 +711,7 @@ async function cmdStart(rest) {
|
|
|
709
711
|
const reloadCfg = () => {
|
|
710
712
|
try {
|
|
711
713
|
const c = loadConfig(values.config); applyProxyEnv(c); engine.reload(c)
|
|
712
|
-
if (ui) engine.publicUrl = feishulogin.normalizePublicUrl((c.ui || {}).publicUrl) //
|
|
714
|
+
if (ui) engine.publicUrl = (c.ui || {}).publicEnabled === false ? null : feishulogin.normalizePublicUrl((c.ui || {}).publicUrl) // 公网入口关闭时不再向群内清单宣传
|
|
713
715
|
log('config reloaded (hot)')
|
|
714
716
|
} catch (e) { log('reload failed: ' + (e && e.message)) }
|
|
715
717
|
}
|
|
@@ -720,7 +722,8 @@ async function cmdStart(rest) {
|
|
|
720
722
|
if (wantUi) {
|
|
721
723
|
const configPath = cfg._path || path.join(process.cwd(), 'sbot.config.json')
|
|
722
724
|
const uiPort = Number(values['ui-port']) || 4600
|
|
723
|
-
|
|
725
|
+
// 控制台设置页可持久切换绑定地址;保存的 host 优先于服务单元里的旧参数。
|
|
726
|
+
const uiHost = (cfg.ui && cfg.ui.host) || values['ui-host'] || '127.0.0.1'
|
|
724
727
|
const uiLoopback = isLoopbackHost(uiHost)
|
|
725
728
|
// Advertise the console address in the agent's self-knowledge manifest (群项目基础能力).
|
|
726
729
|
// For LAN binding, use the first LAN IP so group members get a reachable URL, not 127.0.0.1.
|
|
@@ -729,9 +732,20 @@ async function cmdStart(rest) {
|
|
|
729
732
|
engine.uiLocalUrl = `http://127.0.0.1:${uiPort}`
|
|
730
733
|
// 公网地址(v0.11.72 起的入口):群里 /help 与清单给人的是它,不是局域网 IP —— 出了局域网 LAN 地址
|
|
731
734
|
// 打不开、还要密码,而公网入口只认飞书登录。归一判据与控制台「飞书登录」就绪判据同源(feishulogin)。
|
|
732
|
-
engine.publicUrl = feishulogin.normalizePublicUrl((cfg.ui || {}).publicUrl)
|
|
735
|
+
engine.publicUrl = (cfg.ui || {}).publicEnabled === false ? null : feishulogin.normalizePublicUrl((cfg.ui || {}).publicUrl)
|
|
733
736
|
// Fail-closed: LAN exposure requires a console password (set via `sbot ui --set-password`).
|
|
734
737
|
if (!uiLoopback && !((cfg.ui || {}).auth)) die(`控制台对外(--ui-host ${uiHost})需先设密码:sintbot ui --set-password`)
|
|
738
|
+
const service = require('../src/service')
|
|
739
|
+
updaterManager = updateFeature.createUpdater({
|
|
740
|
+
currentVersion: require('../package.json').version,
|
|
741
|
+
stateFile: path.join(os.homedir(), '.sbot', 'update.json'),
|
|
742
|
+
getLiveState: () => engine.liveState(),
|
|
743
|
+
fetchImpl: require('../src/core/proxy').getProxyFetch() || globalThis.fetch,
|
|
744
|
+
getManagedService: () => { const st = service.status(); return !!st.running && st.pid === process.pid },
|
|
745
|
+
restartManagedService: () => service.restartAsync(),
|
|
746
|
+
setAnnouncementBaseline: (version) => store.setLastVersion(version),
|
|
747
|
+
log,
|
|
748
|
+
})
|
|
735
749
|
// 接管卡片翻状态:有人兑换 ⇒「正在接管中」;点「结束」/ 到时 ⇒「接管已结束」;票过期没人来 ⇒「已过期」。boardClient 在下面才定义,hook 只在运行时才调,不踩 TDZ。
|
|
736
750
|
const flipTakeoverCard = (state) => (meta) => {
|
|
737
751
|
if (!meta || !meta.cardId || !boardClient) return
|
|
@@ -748,6 +762,13 @@ async function cmdStart(rest) {
|
|
|
748
762
|
getLiveState: () => engine.liveState(),
|
|
749
763
|
getConnHealth: () => (platform && platform.connHealth ? platform.connHealth() : null), // 各平台连接健康度({ feishu, wecom } 字典,路由器出)
|
|
750
764
|
onConfigChange: reloadCfg,
|
|
765
|
+
onHostChange: (boundHost, address) => {
|
|
766
|
+
const port = address && typeof address === 'object' ? address.port : uiPort
|
|
767
|
+
const loopback = isLoopbackHost(boundHost)
|
|
768
|
+
engine.uiUrl = `http://${loopback ? '127.0.0.1' : (lanIPv4()[0] || '127.0.0.1')}:${port}`
|
|
769
|
+
engine.uiLocalUrl = `http://127.0.0.1:${port}`
|
|
770
|
+
},
|
|
771
|
+
updater: updaterManager,
|
|
751
772
|
control: {
|
|
752
773
|
setProvider: (chatId, name) => store.setProvider(chatId, name), // 实时切模型(下一轮生效)
|
|
753
774
|
// 某群**实际在用**的模型(临时切换到备用时是备用,与 /status · 账本同口径)—— 控制台改 provider 型号时判「受影响群」
|
|
@@ -803,6 +824,7 @@ async function cmdStart(rest) {
|
|
|
803
824
|
}
|
|
804
825
|
process.stderr.write('\n')
|
|
805
826
|
if (!noOpen) tryOpen(url)
|
|
827
|
+
if (updaterManager) void updaterManager.start().catch((e) => log('update check: ' + (e && e.message)))
|
|
806
828
|
})
|
|
807
829
|
}
|
|
808
830
|
// 配了哪个平台就建哪个适配器;路由器按群分发(连接状态事件带 platform 名,控制台一行一个平台)。
|
|
@@ -1267,31 +1289,25 @@ async function cmdService(rest) {
|
|
|
1267
1289
|
const configPath = findConfigPath(values.config)
|
|
1268
1290
|
if (!configPath) return die('没找到配置文件 —— 先跑 sintbot setup(或 --config 指定)')
|
|
1269
1291
|
// 控制台对外又没设密码:daemon 一启动就会拒绝退出,装成服务就是每 15 秒拉起一次的死循环 ⇒ 装之前就拦
|
|
1270
|
-
const uiHost = values['ui-host']
|
|
1271
1292
|
let raw
|
|
1272
1293
|
try { raw = JSON.parse(fs.readFileSync(configPath, 'utf8')) } catch (e) { return die(`读不了配置 ${configPath}:${e.message}`) }
|
|
1294
|
+
const uiHost = values['ui-host'] || ((raw.ui || {}).host)
|
|
1273
1295
|
if (uiHost && !isLoopbackHost(uiHost) && !((raw.ui || {}).auth)) return die(`控制台对外(--ui-host ${uiHost})要先设密码:sintbot ui --set-password${values.config ? ' --config ' + values.config : ''} —— 否则服务一启动就退出、被反复拉起`)
|
|
1274
1296
|
// 一个聊天平台都没接也照装(1.0.12 起):服务先只开控制台,在「设置」里接好平台后自己重启连上(cmdStart 的配置模式)。
|
|
1275
1297
|
// 1.0.10–1.0.11 在这里拦下(退出码 3),结果装完没有任何地方可去 —— 控制台也打不开
|
|
1276
1298
|
const plat = platformsConfigured(loadConfig(configPath))
|
|
1277
1299
|
const noPlatform = !plat.feishu && !plat.wecom && !plat.slack
|
|
1278
|
-
// 服务进程拿不到当前 shell
|
|
1279
|
-
//
|
|
1280
|
-
//
|
|
1300
|
+
// 服务进程拿不到当前 shell 的环境变量:配置里 env: 引用的 key、配置没写代理时 shell 里的代理变量,值直接写进单元文件(600)。
|
|
1301
|
+
// 1.0.10–1.0.12 先问一句;用户 10-28 定「安装过程要精简」⇒ 不问也不说(不带进去,服务里这些模型 / 平台、连 Claude / ChatGPT 都用不了)。
|
|
1302
|
+
// --pass-env 仍认(以前要它才写),现在是默认行为
|
|
1281
1303
|
const refs = svc.envRefs(raw)
|
|
1282
1304
|
const inShell = refs.filter((v) => process.env[v])
|
|
1283
1305
|
const nowhere = refs.filter((v) => !process.env[v])
|
|
1284
1306
|
const shellProxy = PROXY_ENV_KEYS.filter((k) => process.env[k])
|
|
1285
1307
|
const proxyVars = (raw.proxy && raw.proxy.http) || !shellProxy.some((k) => !/^no_proxy$/i.test(k)) ? [] : shellProxy // 只有 NO_PROXY 不算有代理
|
|
1286
1308
|
const carry = [...inShell, ...proxyVars]
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
|
|
1290
|
-
const a = await new Promise((res) => rl.question(`${cyan('?')} ${tr(`终端里的环境变量 ${carry.join(' ')} 服务里拿不到 —— 把它们的值写进服务定义吗?(文件权限 600;以后改了要重跑这条命令)`, `The service can't see these variables from your terminal: ${carry.join(' ')}. Write their values into the service definition? (file mode 600; re-run this command after changing them)`)} ${dim('(Y/n)')} `, res))
|
|
1291
|
-
rl.close()
|
|
1292
|
-
passEnv = !/^n/i.test(String(a).trim())
|
|
1293
|
-
}
|
|
1294
|
-
const env = passEnv ? Object.fromEntries(carry.map((v) => [v, process.env[v]])) : {}
|
|
1309
|
+
const env = Object.fromEntries(carry.map((v) => [v, process.env[v]]))
|
|
1310
|
+
const quiet = process.env.SINTBOT_INSTALLER === '1' // install.sh 调起:只报结果一行,细节留给 sintbot service status
|
|
1295
1311
|
const tools = ['claude', 'codex'].map((n) => whichSync(n)).filter(Boolean)
|
|
1296
1312
|
const node = svc.stableNodeBin({ execPath: process.execPath, candidates: [whichSync('node')] })
|
|
1297
1313
|
const sbotBin = path.resolve(__dirname, 'sbot.js')
|
|
@@ -1306,20 +1322,23 @@ async function cmdService(rest) {
|
|
|
1306
1322
|
})
|
|
1307
1323
|
if (node.warn) process.stderr.write(yellow('⚠ ' + node.warn) + '\n')
|
|
1308
1324
|
if (svc.versionedPath(sbotBin)) process.stderr.write(yellow(`⚠ sbot 装在带版本号的 node 目录下(${sbotBin}),换 node 版本后要重装 sintbot 并重跑 sintbot service install`) + '\n')
|
|
1309
|
-
if (
|
|
1310
|
-
else if (proxyVars.length && !passEnv) process.stderr.write(dim(tr(` 终端里的代理(${proxyVars.join(' ')})没带进服务;服务里要用代理,在控制台「设置 → 机器人与网络」填`, ` Your terminal's proxy (${proxyVars.join(' ')}) was not carried into the service; to use a proxy there, set it in the console under Settings → Bot & network`)) + '\n')
|
|
1311
|
-
if (noPlatform) process.stderr.write(dim(tr(' 还没接聊天平台:服务先只开控制台,在「设置」里接好平台后会自动重启并连上', 'No chat platform yet: the service starts with just the console; once you connect one under Settings it restarts itself and connects')) + '\n')
|
|
1325
|
+
if (noPlatform && !quiet) process.stderr.write(dim(tr(' 还没接聊天平台:服务先只开控制台,在「设置」里接好平台后会自动重启并连上', 'No chat platform yet: the service starts with just the console; once you connect one under Settings it restarts itself and connects')) + '\n')
|
|
1312
1326
|
if (nowhere.length) process.stderr.write(yellow(`⚠ 配置引用的环境变量 ${nowhere.join(' ')} 当前终端里也没有 —— 用到它们的模型 / 平台不会工作`) + '\n')
|
|
1313
1327
|
if (platform !== 'darwin' && platform !== 'linux') { process.stdout.write(svc.windowsHint(spec) + '\n'); return }
|
|
1314
1328
|
if (!tools.length) process.stderr.write(yellow('⚠ PATH 里没找到 claude / codex —— 服务能起,但群里的轮次会失败;装好后重跑 sintbot service install') + '\n')
|
|
1315
|
-
if (values['dry-run']) { //
|
|
1329
|
+
if (values['dry-run']) { // 打印时遮掉带进服务的值(多半是 key)
|
|
1316
1330
|
const shown = { ...spec, env: Object.fromEntries(Object.keys(spec.env).map((k) => [k, '<已隐藏>'])) }
|
|
1317
1331
|
process.stdout.write(dim(`# ${svc.unitPath(platform, home)}`) + '\n' + svc.render(platform, shown)); return
|
|
1318
1332
|
}
|
|
1319
1333
|
const r = svc.install(spec, { platform, home })
|
|
1334
|
+
if (quiet) { // 安装脚本里:成功只一行;失败照常把每一步列出来
|
|
1335
|
+
await new Promise((res) => setTimeout(res, 1500))
|
|
1336
|
+
const st = svc.status({ platform, home })
|
|
1337
|
+
if (st.running && r.steps.every((s) => s.ok)) { process.stderr.write(` ${cyan('✓')} ${tr('后台服务已启动', 'Background service started')}\n`); return }
|
|
1338
|
+
}
|
|
1320
1339
|
process.stderr.write(cyan('✓ ') + tr(`已写入 ${r.file}`, `Wrote ${r.file}`) + (r.backup ? dim(tr(`(原文件备份为 ${path.basename(r.backup)})`, ` (previous file backed up as ${path.basename(r.backup)})`)) : '') + '\n')
|
|
1321
1340
|
for (const s of r.steps) process.stderr.write(`${s.ok ? cyan('✓') : red('✗')} ${dim(s.cmd)}${s.err && !s.ok ? ' ' + red(s.err.split('\n')[0]) : ''}\n`)
|
|
1322
|
-
await new Promise((res) => setTimeout(res, 1500))
|
|
1341
|
+
if (!quiet) await new Promise((res) => setTimeout(res, 1500))
|
|
1323
1342
|
process.stderr.write(`${tr('状态', 'Status')}:${nice(svc.status({ platform, home }))}\n` + dim(`${tr('配置', 'Config')}:${spec.args[spec.args.indexOf('--config') + 1]}\n${tr('日志', 'Log')}:${spec.errLog}\n`))
|
|
1324
1343
|
if (platform === 'linux') process.stderr.write(dim('提示:要开机就起(不必先登录),跑一次 loginctl enable-linger $USER') + '\n')
|
|
1325
1344
|
if (r.steps.some((s) => !s.ok)) process.exit(1)
|
|
@@ -1365,11 +1384,11 @@ async function cmdSetup(rest) {
|
|
|
1365
1384
|
let seeded = false
|
|
1366
1385
|
if (fs.existsSync(cfgPath)) {
|
|
1367
1386
|
try { raw = JSON.parse(fs.readFileSync(cfgPath, 'utf8')) } catch (e) { return die(`existing config is invalid JSON: ${e.message}`) }
|
|
1368
|
-
process.stdout.write(dim(T(`更新已有配置 ${cfgPath}`, `Updating the existing config ${cfgPath}`)) + '\n')
|
|
1387
|
+
if (!fromInstaller) process.stdout.write(dim(T(`更新已有配置 ${cfgPath}`, `Updating the existing config ${cfgPath}`)) + '\n')
|
|
1369
1388
|
} else if (fs.existsSync(examplePath)) {
|
|
1370
1389
|
raw = JSON.parse(fs.readFileSync(examplePath, 'utf8'))
|
|
1371
1390
|
seeded = true
|
|
1372
|
-
process.stdout.write(dim(T(`新建配置,问完再写入 ${cfgPath}`, `New config; it is written to ${cfgPath} at the end`)) + '\n')
|
|
1391
|
+
if (!fromInstaller) process.stdout.write(dim(T(`新建配置,问完再写入 ${cfgPath}`, `New config; it is written to ${cfgPath} at the end`)) + '\n')
|
|
1373
1392
|
} else {
|
|
1374
1393
|
// claude 不钉 model(v0.11.1):写死会覆盖 CLI 自己的默认,每出一代新模型就落后一代(v0.11.0 拍板)
|
|
1375
1394
|
raw = { agent: { bin: 'claude', permissionMode: 'dontAsk', maxTurnTimeMins: 10, maxConcurrent: 2 }, defaultProvider: 'claude', providers: { claude: {} }, groups: {} }
|
|
@@ -1430,7 +1449,7 @@ async function cmdSetup(rest) {
|
|
|
1430
1449
|
try {
|
|
1431
1450
|
// 安装脚本调起时它自己已经说过「这里只配模型」,不再重复
|
|
1432
1451
|
const scope = fromInstaller ? T('(Enter 用默认值)', ' (Enter accepts the default)') : T('配模型(聊天平台、代理、项目目录装完在控制台里配;Enter 用默认值)', ' model setup (chat platform, proxy and project folder are set in the console afterwards; Enter accepts the default)')
|
|
1433
|
-
process.stdout.write('\n' + cyan('sintbot setup') + dim(full ? ' — 完整配置(Enter 用默认值)' :
|
|
1452
|
+
if (!fromInstaller) process.stdout.write('\n' + cyan('sintbot setup') + dim(full ? ' — 完整配置(Enter 用默认值)' : T(' — ' + scope, ' —' + scope)) + '\n\n')
|
|
1434
1453
|
|
|
1435
1454
|
// 第 0 步:环境检查 —— 只报不拦(缺了照样能把配置写完,最后的体检会接着报)。
|
|
1436
1455
|
// 缺 claude / codex 时问一句替新用户装(npm i -g;官方源连不上自动换国内镜像,见 src/installdeps.js)。
|
|
@@ -1455,8 +1474,8 @@ async function cmdSetup(rest) {
|
|
|
1455
1474
|
if (claudeBin) process.stdout.write(` ${cyan('✓')} claude ${dim(claudeBin)}\n`)
|
|
1456
1475
|
else {
|
|
1457
1476
|
process.stdout.write(` ${red('✗')} ${T('没找到 claude(Claude Code CLI)—— 每一轮都靠它干活', 'claude (Claude Code CLI) not found; every turn runs on it')}\n`)
|
|
1458
|
-
//
|
|
1459
|
-
if (
|
|
1477
|
+
// 必需组件:终端里直接装,不问(1.0.13,用户定「安装要精简」);不在终端里(管道、CI)不替他装
|
|
1478
|
+
if (process.stdin.isTTY) claudeBin = installDep('claude', claudeName)
|
|
1460
1479
|
else process.stdout.write(dim(T(` 稍后自己装:npm i -g ${installdeps.PACKAGES.claude}`, ` Install it later: npm i -g ${installdeps.PACKAGES.claude}`)) + '\n')
|
|
1461
1480
|
}
|
|
1462
1481
|
// Claude 登录态留到模型步一起报,那里据此决定推荐走哪条路。官方非交互子命令,0.2s、不联网;
|
|
@@ -1470,9 +1489,8 @@ async function cmdSetup(rest) {
|
|
|
1470
1489
|
let codexBin = whichSync('codex')
|
|
1471
1490
|
let codexLoggedIn = false
|
|
1472
1491
|
if (!codexBin) {
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
else process.stdout.write(dim(T(` 要用时再装:npm i -g ${installdeps.PACKAGES.codex},再 codex login`, ` When you need it: npm i -g ${installdeps.PACKAGES.codex}, then codex login`)) + '\n')
|
|
1492
|
+
// 可选组件不问,一行给装法
|
|
1493
|
+
process.stdout.write(` ${gray('○')} ${T(`codex 没装(可选,有 ChatGPT 账号时用):npm i -g ${installdeps.PACKAGES.codex},再 codex login`, `codex not installed (optional, for a ChatGPT account): npm i -g ${installdeps.PACKAGES.codex}, then codex login`)}\n`)
|
|
1476
1494
|
}
|
|
1477
1495
|
if (codexBin) {
|
|
1478
1496
|
// 只读本机登录态(不联网);没登录时,GPT 群、生图、claude 额度用完自动切到 codex 都不会成
|
|
@@ -1481,31 +1499,19 @@ async function cmdSetup(rest) {
|
|
|
1481
1499
|
codexLoggedIn = !!(codexLogin && codexLogin.ok && codexLogin.loggedIn)
|
|
1482
1500
|
process.stdout.write(` ${cyan('✓')} codex ${dim(codexBin)}${codexOut ? ' ' + yellow(T('没登录 —— GPT 群、生图、额度用完时自动切到 codex 都要它:跑一次 codex login', 'not logged in; GPT groups, image generation and switching to codex when quota runs out all need it: run codex login')) : ''}\n`)
|
|
1483
1501
|
}
|
|
1484
|
-
// 设计图标库(Lucide / Tabler / Phosphor / Pixelarticons):agent
|
|
1485
|
-
//
|
|
1502
|
+
// 设计图标库(Lucide / Tabler / Phosphor / Pixelarticons):agent 出设计图时按名字取图标,缺了只能自己画。
|
|
1503
|
+
// 可选组件,不问(1.0.13 起;1.0.11–1.0.12 问一句代装):缺了给一行装法,doctor 也会接着提示
|
|
1486
1504
|
{
|
|
1487
|
-
const
|
|
1488
|
-
const
|
|
1489
|
-
|
|
1490
|
-
if (!miss.length) process.stdout.write(` ${cyan('✓')} ${T('设计图标库', 'Design icon libraries')} ${dim(Object.values(design.ICON_LIBS).map((l) => l.label).join(' · '))}\n`)
|
|
1505
|
+
const roots = design.iconRoots({ base: path.join(__dirname, '..'), extra: (raw.design && raw.design.iconsDir) || null })
|
|
1506
|
+
const libs = Object.entries(roots).filter(([, v]) => !v).map(([k]) => design.ICON_LIBS[k])
|
|
1507
|
+
if (!libs.length) process.stdout.write(` ${cyan('✓')} ${T('设计图标库', 'Design icon libraries')} ${dim(Object.values(design.ICON_LIBS).map((l) => l.label).join(' · '))}\n`)
|
|
1491
1508
|
else {
|
|
1492
|
-
const
|
|
1493
|
-
const pkgs = libs.map((l) => l.pkg)
|
|
1509
|
+
const pkgs = libs.map((l) => l.pkg).join(' ')
|
|
1494
1510
|
const mb = Math.round(libs.reduce((n, l) => n + (l.mb || 0), 0))
|
|
1495
|
-
process.stdout.write(` ${gray('○')} ${T(
|
|
1496
|
-
if (await askYN(T(`现在安装(npm i -g ${pkgs.join(' ')},约 ${mb} MB)?`, `Install them now (npm i -g ${pkgs.join(' ')}, about ${mb} MB)?`), true, false)) {
|
|
1497
|
-
const r = installdeps.installPackages(pkgs, { log: (l) => process.stdout.write(dim(' ' + l) + '\n') })
|
|
1498
|
-
const still = missing()
|
|
1499
|
-
if (r.ok && !still.length) process.stdout.write(` ${cyan('✓')} ${T('设计图标库已装', 'Design icon libraries installed')}\n`)
|
|
1500
|
-
else if (r.ok) {
|
|
1501
|
-
const pre = installdeps.globalPrefix()
|
|
1502
|
-
const dir = pre ? (process.platform === 'win32' ? path.join(pre, 'node_modules') : path.join(pre, 'lib', 'node_modules')) : 'npm 全局目录下的 node_modules'
|
|
1503
|
-
process.stdout.write(` ${yellow('!')} ${T(`装好了,但 sbot 找不到 ${still.join(' / ')} —— 在配置里把 design.iconsDir 设成 ${dir}`, `Installed, but sbot cannot find ${still.join(' / ')}; set design.iconsDir to ${dir} in the config`)}\n`)
|
|
1504
|
-
} else process.stdout.write(` ${red('✗')} ${T(`安装失败(退出码 ${r.code})—— 多半是 npm 全局目录没有写权限,之后可以自己装:npm i -g ${pkgs.join(' ')}`, `Install failed (exit code ${r.code}), most likely the npm global folder is not writable; install them later: npm i -g ${pkgs.join(' ')}`)}\n`)
|
|
1505
|
-
} else process.stdout.write(dim(T(` 要用时再装:npm i -g ${pkgs.join(' ')}`, ` When you need them: npm i -g ${pkgs.join(' ')}`)) + '\n')
|
|
1511
|
+
process.stdout.write(` ${gray('○')} ${T(`设计图标库没装(可选,出设计图时用;约 ${mb} MB):npm i -g ${pkgs}`, `Design icon libraries not installed (optional, used for designs; about ${mb} MB): npm i -g ${pkgs}`)}\n`)
|
|
1506
1512
|
}
|
|
1507
1513
|
}
|
|
1508
|
-
process.stdout.write(
|
|
1514
|
+
process.stdout.write('\n')
|
|
1509
1515
|
|
|
1510
1516
|
// 不问「在不在中国大陆」(隐私,用户 10-28 定):只按时区猜,决定有国际站的厂商先试哪个站、--full 里推荐哪个平台;不写进配置
|
|
1511
1517
|
const mainland = setupmodels.looksMainland()
|
|
@@ -1608,14 +1614,8 @@ async function cmdSetup(rest) {
|
|
|
1608
1614
|
process.stdout.write(` ${col('ChatGPT(Codex)', 'ChatGPT (Codex)')} ${codexReady() ? cyan(T('✓ 已登录', '✓ logged in')) : gray(codexBin ? T('○ 未登录', '○ not logged in') : T('○ 没装 codex', '○ codex not installed'))}\n`)
|
|
1609
1615
|
if (readyKeyed().length) process.stdout.write(` ${col('API key 模型', 'API key models')} ${cyan('✓')} ${readyKeyed().join(' ')}\n`)
|
|
1610
1616
|
let route = setupmodels.subscriptionRoute({ claudeLoggedIn, codexLoggedIn: codexReady() })
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
if (await askYN(T('再加一个用 API key 的模型吗?(DeepSeek 等,群里 /model 可切换)', 'Add another model that uses an API key? (DeepSeek etc.; switch in a group with /model)'), false)) {
|
|
1614
|
-
process.stdout.write(` 1) DeepSeek ${dim(T('(推荐,按量付费)', '(recommended, pay as you go)'))}\n 2) ${T('其他:智谱 GLM / MiniMax / 火山方舟', 'Others: Zhipu GLM / MiniMax / Volcano Ark')}\n`)
|
|
1615
|
-
if ((await ask(T('选', 'Choose'), '1')) === '2') await pickOthers()
|
|
1616
|
-
else await configureKeyed(setupmodels.RECOMMENDED)
|
|
1617
|
-
}
|
|
1618
|
-
} else {
|
|
1617
|
+
// 已经有能用的就不再问(1.0.13 起不问「再加一个」:加模型在控制台「模型 · 库」)
|
|
1618
|
+
if (!route && !readyKeyed().length) {
|
|
1619
1619
|
process.stdout.write('\n ' + T('没检测到 Claude 订阅或 Codex 登录。', 'No Claude subscription or Codex login found. ') + cyan(T('推荐用 DeepSeek', 'DeepSeek is recommended')) + T(':注册充值就能用、按量付费,不需要 Claude 账号。', ': sign up, top up and go, pay as you go, no Claude account needed.') + '\n')
|
|
1620
1620
|
for (;;) {
|
|
1621
1621
|
process.stdout.write(` 1) ${T('用 DeepSeek', 'DeepSeek')} ${dim(T('(推荐)', '(recommended)'))}${T('—— 粘贴 API key', ': paste an API key')}\n`
|
|
@@ -1672,20 +1672,14 @@ async function cmdSetup(rest) {
|
|
|
1672
1672
|
const okKeyed = readyKeyed().filter((n) => !tried.has(n) || verified.includes(n))
|
|
1673
1673
|
const auto = route || verified[0] || (okKeyed.includes(setupmodels.RECOMMENDED) ? setupmodels.RECOMMENDED : okKeyed[0]) || readyKeyed()[0] || raw.defaultProvider || 'claude'
|
|
1674
1674
|
const suggest = !seeded && raw.defaultProvider && usable(raw.defaultProvider) ? raw.defaultProvider : auto
|
|
1675
|
+
// 默认模型与试跑都不问(1.0.13,用户定「安装要精简」):按上面的顺序自动选,还没试跑过就直接试一轮;群里 /model 随时换
|
|
1676
|
+
raw.defaultProvider = suggest
|
|
1675
1677
|
const usableNames = Object.keys(raw.providers).filter(usable)
|
|
1676
|
-
if (usableNames.length > 1) {
|
|
1677
|
-
const def = await ask(T(`默认模型(${usableNames.join('/')};群里 /model 随时切)`, `Default model (${usableNames.join('/')}; switch any time in a group with /model)`), suggest)
|
|
1678
|
-
raw.defaultProvider = raw.providers[def] ? def : suggest
|
|
1679
|
-
if (!raw.providers[def]) process.stdout.write(red(T(` 没有叫 '${def}' 的模型,用 ${suggest}`, ` There is no model called '${def}'; using ${suggest}`)) + '\n')
|
|
1680
|
-
} else {
|
|
1681
|
-
raw.defaultProvider = suggest
|
|
1682
|
-
if (usableNames.length) process.stdout.write(dim(T(` 默认模型:${suggest}`, ` Default model: ${suggest}`)) + '\n')
|
|
1683
|
-
}
|
|
1684
|
-
// 默认模型还没在本次跑通过(订阅登录那条路):问一句当场试跑
|
|
1678
|
+
if (usableNames.length) process.stdout.write(dim(T(` 默认模型:${suggest}${usableNames.length > 1 ? '(群里 /model 可换)' : ''}`, ` Default model: ${suggest}${usableNames.length > 1 ? ' (switch in a group with /model)' : ''}`)) + '\n')
|
|
1685
1679
|
const dp = raw.defaultProvider
|
|
1686
1680
|
const canTry = raw.providers[dp] && usable(dp) && (raw.providers[dp].runner === 'codex' ? codexBin : claudeBin)
|
|
1687
|
-
if (canTry && !tried.has(dp)
|
|
1688
|
-
process.stdout.write(dim(T(
|
|
1681
|
+
if (canTry && !tried.has(dp)) {
|
|
1682
|
+
process.stdout.write(dim(T(` 用 ${dp} 试跑一轮…`, ` Test run with ${dp}…`)) + '\n')
|
|
1689
1683
|
const r = await tryTurn(dp)
|
|
1690
1684
|
if (r.ok) { reportTurn(dp, r); verified.push(dp) }
|
|
1691
1685
|
else process.stdout.write(` ${red('✗')} ${setupmodels.explainTestFailure(r.text, { baseUrl: raw.providers[dp].baseUrl, lang }).text}\n`)
|
|
@@ -1943,23 +1937,22 @@ async function cmdSetup(rest) {
|
|
|
1943
1937
|
// 新装时「没接平台 / 没绑群 / Claude 没登录」都是正常状态,整屏警告看着像出错:只报失败项,完整体检留给 sintbot doctor
|
|
1944
1938
|
const { rows } = await checkHealth(cfg)
|
|
1945
1939
|
const failed = rows.filter((r) => r.status === 'fail')
|
|
1946
|
-
if (failed.length)
|
|
1947
|
-
|
|
1948
|
-
|
|
1940
|
+
if (failed.length) {
|
|
1941
|
+
for (const r of failed) process.stdout.write(`${red('✗')} ${r.label.padEnd(22)} ${gray(r.detail)}\n`)
|
|
1942
|
+
process.stdout.write(dim(T(` 完整体检:sintbot doctor${cfgArg}`, ` Full check: sintbot doctor${cfgArg}`)) + '\n')
|
|
1943
|
+
} else process.stdout.write(cyan('✓ ') + T('体检通过', 'Health check passed') + dim(T(`(完整:sintbot doctor${cfgArg})`, ` (full: sintbot doctor${cfgArg})`)) + '\n')
|
|
1949
1944
|
}
|
|
1950
1945
|
if (!((cfg.providers || {})[cfg.defaultProvider] || {}).baseUrl) {
|
|
1951
1946
|
process.stdout.write('\n' + yellow(T('⚠ 订阅条款:', '⚠ Subscription terms: ')) + T('用个人的 Claude / ChatGPT 订阅登录来服务多人群聊,可能不符合提供方的使用条款;团队使用请改用 API key 或企业账号,并自行确认合规。', 'serving a multi-person group chat from a personal Claude / ChatGPT subscription may break the provider\'s terms; for team use switch to an API key or a business account and check compliance yourself.') + '\n')
|
|
1952
1947
|
}
|
|
1953
1948
|
if (fromInstaller) return // 后台服务、控制台地址、接下来去哪:install.sh 结尾统一给
|
|
1954
|
-
// 终端里有代理、配置里没有:只给建议(服务进程拿不到终端的环境变量)
|
|
1955
|
-
const shellProxy = !(raw.proxy && raw.proxy.http) && require('../src/core/proxy').proxyDescribe(process.env) // 带认证的只说「带认证」,不打印账号密码
|
|
1956
1949
|
const plat = platformsConfigured(cfg)
|
|
1957
1950
|
const noPlatform = !plat.feishu && !plat.wecom && !plat.slack
|
|
1958
1951
|
process.stdout.write('\n' + gray(T('— 下一步 —', '— Next —')) + '\n'
|
|
1959
1952
|
+ ` sintbot service install${cfgArg} ${dim(T('装成后台服务,控制台常开在 http://127.0.0.1:4600(登录后自启,崩溃自动拉起)', 'install the background service; the console stays at http://127.0.0.1:4600 (starts at login, restarts on crash)'))}\n`
|
|
1960
1953
|
+ ` sintbot ui${cfgArg} ${dim(noPlatform ? T('现在就打开控制台:在「设置」里接飞书 / Slack / 企业微信,在「群 · 项目」里绑群', 'open the console now: connect Slack, Feishu or WeCom under Settings, bind a group under Groups') : T('打开控制台:绑群、切模型、看实时活动', 'open the console: bind groups, switch models, watch live activity'))}\n`
|
|
1961
1954
|
+ ` sintbot chat${cfgArg} ${dim(T('不接平台也能先在终端里和它聊几句', 'chat with it in the terminal first, no platform needed'))}\n`
|
|
1962
|
-
|
|
1955
|
+
)
|
|
1963
1956
|
}
|
|
1964
1957
|
|
|
1965
1958
|
// 能不能在这台机器上弹浏览器:SSH 进来的会话弹到的是对方屏幕(或根本没有屏幕),Linux 没有图形界面也不弹 —— 只打印地址
|
|
@@ -2003,7 +1996,7 @@ async function cmdUi(rest) {
|
|
|
2003
1996
|
|
|
2004
1997
|
applyProxyEnv(cfg)
|
|
2005
1998
|
const port = Number(values.port) || 4600
|
|
2006
|
-
const host = values.host || '127.0.0.1'
|
|
1999
|
+
const host = values.host || (cfg.ui && cfg.ui.host) || '127.0.0.1'
|
|
2007
2000
|
const loopback = isLoopbackHost(host)
|
|
2008
2001
|
// Fail-closed: never expose the operator console to the LAN without a password.
|
|
2009
2002
|
if (!loopback) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sintbot",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.14",
|
|
4
4
|
"description": "Drive headless Claude Code (or Codex) from Slack / Feishu / WeCom group chats — one group, one project, swappable model brain. A standalone daemon, not a Claude Code plugin.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"sbot": "bin/sbot.js",
|
package/src/service.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
const fs = require('node:fs')
|
|
21
21
|
const os = require('node:os')
|
|
22
22
|
const path = require('node:path')
|
|
23
|
-
const { execFileSync } = require('node:child_process')
|
|
23
|
+
const { execFileSync, execFile } = require('node:child_process')
|
|
24
24
|
|
|
25
25
|
const LABEL = 'com.sbot.daemon' // launchd 标签(与早期手写 plist 同名 ⇒ 装新版直接替换旧的)
|
|
26
26
|
const UNIT = 'sbot.service' // systemd --user 单元名
|
|
@@ -176,6 +176,35 @@ function run(bin, args, opts = {}) {
|
|
|
176
176
|
|
|
177
177
|
const uid = () => (typeof process.getuid === 'function' ? process.getuid() : 0)
|
|
178
178
|
|
|
179
|
+
// launchctl bootout may return before the old job has fully left the GUI domain.
|
|
180
|
+
// Keep the wait synchronous and strictly bounded: service install is a CLI action,
|
|
181
|
+
// and a failed unload must never race straight into bootstrap.
|
|
182
|
+
function sleepSync(ms) {
|
|
183
|
+
if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function launchdServiceMissing(message) {
|
|
187
|
+
return /could not find service|service[^\n]*not found|no such service|not found in domain/i.test(String(message || ''))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function launchdBootstrapRace(result) {
|
|
191
|
+
return result && (result.code === 5 || /bootstrap failed:\s*5|input\/output error/i.test(String(result.err || '')))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// { ok:true, waitedMs } when unloaded; otherwise a bounded, actionable failure.
|
|
195
|
+
function waitForLaunchdUnload(target, { runCommand = run, wait = sleepSync, timeoutMs = 2000, intervalMs = 100 } = {}) {
|
|
196
|
+
const tries = Math.floor(Math.max(0, timeoutMs) / Math.max(1, intervalMs)) + 1
|
|
197
|
+
for (let i = 0; i < tries; i++) {
|
|
198
|
+
const r = runCommand('launchctl', ['print', target])
|
|
199
|
+
if (!r.ok) {
|
|
200
|
+
if (launchdServiceMissing(r.err)) return { ok: true, waitedMs: i * intervalMs }
|
|
201
|
+
return { ok: false, waitedMs: i * intervalMs, err: r.err || 'launchctl print 失败,无法确认服务状态' }
|
|
202
|
+
}
|
|
203
|
+
if (i < tries - 1) wait(intervalMs)
|
|
204
|
+
}
|
|
205
|
+
return { ok: false, waitedMs: (tries - 1) * intervalMs, err: `等待 launchd 卸载超时(${timeoutMs}ms),未尝试重新加载` }
|
|
206
|
+
}
|
|
207
|
+
|
|
179
208
|
// 状态(只读):{ installed, running, pid, detail }
|
|
180
209
|
function status({ platform = process.platform, home = os.homedir() } = {}) {
|
|
181
210
|
const file = unitPath(platform, home)
|
|
@@ -197,7 +226,7 @@ function status({ platform = process.platform, home = os.homedir() } = {}) {
|
|
|
197
226
|
}
|
|
198
227
|
|
|
199
228
|
// 写单元文件(已有且内容不同 ⇒ 先备份)并加载。返回 { file, backup, steps: [{cmd, ok, err}] }
|
|
200
|
-
function install(spec, { platform = process.platform, home = os.homedir() } = {}) {
|
|
229
|
+
function install(spec, { platform = process.platform, home = os.homedir(), runCommand = run, wait = sleepSync, launchdTimeoutMs = 2000, launchdPollMs = 100 } = {}) {
|
|
201
230
|
const file = unitPath(platform, home)
|
|
202
231
|
if (!file) throw new Error('这个平台不支持自动安装服务')
|
|
203
232
|
const text = render(platform, spec)
|
|
@@ -212,10 +241,38 @@ function install(spec, { platform = process.platform, home = os.homedir() } = {}
|
|
|
212
241
|
// 单元文件里写了环境变量的值(多半是 key)⇒ 只给自己读写;没写就是普通权限
|
|
213
242
|
if (Object.keys(spec.env || {}).length) fs.chmodSync(file, 0o600)
|
|
214
243
|
const steps = []
|
|
215
|
-
const step = (bin, args, tolerate) => { const r =
|
|
244
|
+
const step = (bin, args, tolerate) => { const r = runCommand(bin, args); steps.push({ cmd: [bin, ...args].join(' '), ok: r.ok || !!tolerate, err: r.ok ? '' : r.err }); return r }
|
|
216
245
|
if (platform === 'darwin') {
|
|
217
|
-
|
|
218
|
-
|
|
246
|
+
const target = `gui/${uid()}/${LABEL}`
|
|
247
|
+
const bootoutArgs = ['bootout', target]
|
|
248
|
+
const out = runCommand('launchctl', bootoutArgs)
|
|
249
|
+
const absentBefore = !out.ok && launchdServiceMissing(out.err)
|
|
250
|
+
steps.push({ cmd: ['launchctl', ...bootoutArgs].join(' '), ok: out.ok || absentBefore, err: out.ok || absentBefore ? '' : out.err })
|
|
251
|
+
// A missing job is normal on first install; any other bootout failure is not.
|
|
252
|
+
if (!out.ok && !absentBefore) return { file, backup, steps }
|
|
253
|
+
const unloaded = waitForLaunchdUnload(target, { runCommand, wait, timeoutMs: launchdTimeoutMs, intervalMs: launchdPollMs })
|
|
254
|
+
steps.push({ cmd: 'launchctl wait for bootout', ok: unloaded.ok, err: unloaded.ok ? '' : unloaded.err })
|
|
255
|
+
if (!unloaded.ok) return { file, backup, steps }
|
|
256
|
+
|
|
257
|
+
const bootstrapArgs = ['bootstrap', `gui/${uid()}`, file]
|
|
258
|
+
const first = runCommand('launchctl', bootstrapArgs)
|
|
259
|
+
if (first.ok) steps.push({ cmd: ['launchctl', ...bootstrapArgs].join(' '), ok: true, err: '' })
|
|
260
|
+
else if (launchdBootstrapRace(first)) {
|
|
261
|
+
// A race may report EIO even though launchd accepted the job. Since the
|
|
262
|
+
// previous job was confirmed absent, a now-loaded job is the new one.
|
|
263
|
+
const after = runCommand('launchctl', ['print', target])
|
|
264
|
+
if (after.ok) steps.push({ cmd: ['launchctl', ...bootstrapArgs].join(' '), ok: true, err: '' })
|
|
265
|
+
else if (launchdServiceMissing(after.err)) {
|
|
266
|
+
wait(100)
|
|
267
|
+
const retry = runCommand('launchctl', bootstrapArgs)
|
|
268
|
+
if (retry.ok) steps.push({ cmd: ['launchctl', ...bootstrapArgs].join(' '), ok: true, err: '' })
|
|
269
|
+
else {
|
|
270
|
+
const afterRetry = runCommand('launchctl', ['print', target])
|
|
271
|
+
const loaded = afterRetry.ok
|
|
272
|
+
steps.push({ cmd: ['launchctl', ...bootstrapArgs].join(' '), ok: loaded, err: loaded ? '' : retry.err })
|
|
273
|
+
}
|
|
274
|
+
} else steps.push({ cmd: ['launchctl', ...bootstrapArgs].join(' '), ok: false, err: after.err || first.err })
|
|
275
|
+
} else steps.push({ cmd: ['launchctl', ...bootstrapArgs].join(' '), ok: false, err: first.err })
|
|
219
276
|
} else {
|
|
220
277
|
step('systemctl', ['--user', 'daemon-reload'])
|
|
221
278
|
step('systemctl', ['--user', 'enable', UNIT])
|
|
@@ -230,6 +287,19 @@ function restart({ platform = process.platform } = {}) {
|
|
|
230
287
|
return { ok: false, err: '这个平台不支持' }
|
|
231
288
|
}
|
|
232
289
|
|
|
290
|
+
// Async form for a daemon restarting itself after an in-place package upgrade.
|
|
291
|
+
// systemctl restart waits for the old process to exit, so the daemon must keep
|
|
292
|
+
// its event loop free to handle SIGTERM while this command is in flight.
|
|
293
|
+
function restartAsync({ platform = process.platform } = {}) {
|
|
294
|
+
let bin, args
|
|
295
|
+
if (platform === 'darwin') { bin = 'launchctl'; args = ['kickstart', '-k', `gui/${uid()}/${LABEL}`] }
|
|
296
|
+
else if (platform === 'linux') { bin = 'systemctl'; args = ['--user', 'restart', UNIT] }
|
|
297
|
+
else return Promise.resolve({ ok: false, err: '这个平台不支持' })
|
|
298
|
+
return new Promise((resolve) => execFile(bin, args, { encoding: 'utf8', windowsHide: true }, (e, stdout, stderr) => {
|
|
299
|
+
resolve(e ? { ok: false, out: String(stdout || ''), err: String(stderr || e.message || e).trim(), code: e.code ?? e.status } : { ok: true, out: String(stdout || '') })
|
|
300
|
+
}))
|
|
301
|
+
}
|
|
302
|
+
|
|
233
303
|
function uninstall({ platform = process.platform, home = os.homedir() } = {}) {
|
|
234
304
|
const file = unitPath(platform, home)
|
|
235
305
|
const steps = []
|
|
@@ -244,4 +314,4 @@ function uninstall({ platform = process.platform, home = os.homedir() } = {}) {
|
|
|
244
314
|
return { file, removed, steps }
|
|
245
315
|
}
|
|
246
316
|
|
|
247
|
-
module.exports = { LABEL, UNIT, SUPERVISED_ENV, xmlEsc, sdQuote, buildPath, versionedPath, stableNodeBin, envRefs, serviceSpec, launchdPlist, systemdUnit, unitPath, render, windowsHint, status, install, restart, uninstall }
|
|
317
|
+
module.exports = { LABEL, UNIT, SUPERVISED_ENV, xmlEsc, sdQuote, buildPath, versionedPath, stableNodeBin, envRefs, serviceSpec, launchdPlist, systemdUnit, unitPath, render, windowsHint, status, install, restart, restartAsync, uninstall, launchdServiceMissing, launchdBootstrapRace, waitForLaunchdUnload }
|
package/src/ui/console.html
CHANGED
|
@@ -1164,6 +1164,7 @@ function inboxHtml(){
|
|
|
1164
1164
|
`<a class="iconbtn sm" href="/browser" target="_blank" rel="noopener" title="打开接管页" style="flex:none">${IC.ext}</a>`,
|
|
1165
1165
|
(it.pageTitle||it.pageUrl)?full(esc(it.pageTitle||it.pageUrl)):'');
|
|
1166
1166
|
if(it.kind==='issues')return line('反馈','--warn',`${it.count} 条反馈待处理`,go('ovIssues','滚到反馈本'));
|
|
1167
|
+
if(it.kind==='update')return line('更新','--ember',`发现新版 v${esc(it.to)}`,go('updateCard','打开版本更新'));
|
|
1167
1168
|
if(it.kind==='leftovers')return line('进程','--warn',`${it.count} 个后台进程还在跑`,go('ovLeftovers','滚到后台进程'));
|
|
1168
1169
|
if(it.kind==='preview')return line('预览','--fail','预览在服务整个项目目录',go('ovPreviews','滚到预览服务'));
|
|
1169
1170
|
// 每个检查项单独一个节点:英文界面按整段文字逐个翻译
|
|
@@ -1175,7 +1176,7 @@ function inboxHtml(){
|
|
|
1175
1176
|
<div class="card ovgroups" style="margin-bottom:22px">${items.map(row).join('')}</div>`;
|
|
1176
1177
|
}
|
|
1177
1178
|
function bindInbox(){
|
|
1178
|
-
$$('#overview [data-ibgo]').forEach(b=>b.onclick=()=>{const el=$('#'+b.dataset.ibgo);if(el)el.scrollIntoView({behavior:'smooth',block:'start'});});
|
|
1179
|
+
$$('#overview [data-ibgo]').forEach(b=>b.onclick=()=>{if(b.dataset.ibgo==='updateCard'){$$('nav button').forEach(x=>x.classList.toggle('active',x.dataset.tab==='settings'));$$('.tab').forEach(t=>t.classList.toggle('on',t.id==='settings'));}const el=$('#'+b.dataset.ibgo);if(el)requestAnimationFrame(()=>el.scrollIntoView({behavior:'smooth',block:'start'}));});
|
|
1179
1180
|
$$('#overview [data-ibdis]').forEach(b=>b.onclick=async()=>{
|
|
1180
1181
|
try{const r=await api('/api/inbox/dismiss','POST',{key:b.dataset.ibdis});S.inbox=r.inbox||[];paintInbox();toast('已忽略','ok');}catch(e){toast(e.message,'err');}});
|
|
1181
1182
|
}
|
|
@@ -3215,6 +3216,7 @@ async function pgSend(){
|
|
|
3215
3216
|
/* ── settings ── */
|
|
3216
3217
|
function renderSettings(){
|
|
3217
3218
|
const a=S.agent,fe=S.feishu,px=S.proxy,net=S.net||{};
|
|
3219
|
+
const upd=S.update||null;
|
|
3218
3220
|
// 项目根目录:控制台**只读**(产品决定 —— 它是围栏的策略,登上控制台就能改等于围栏只剩一次点击)。
|
|
3219
3221
|
// 这里只把现状说清:每条根在不在、配置里被忽略的值、哪些群在根外;改要走安装向导或机器上的配置文件。
|
|
3220
3222
|
const pri=S.projectsRootsInfo||{roots:[],dropped:[],outside:[],configured:null,fromConfig:false};
|
|
@@ -3235,6 +3237,14 @@ function renderSettings(){
|
|
|
3235
3237
|
<div class="hint" style="margin-top:9px">${net.lan
|
|
3236
3238
|
?`<b style="color:var(--risk)">已对局域网开放</b> · ${net.authOn?'需密码':'<b style="color:var(--fail)">未设密码(异常)</b>'} · HTTP 明文,仅可信网络用(最稳走 SSH 隧道)。`
|
|
3237
3239
|
:'仅本机可访问 · 要对外开放:先设访问密码,再把绑定地址改为 0.0.0.0。'}</div>
|
|
3240
|
+
<div class="inline" style="gap:14px;margin-top:13px;flex-wrap:wrap">
|
|
3241
|
+
<label class="inline" style="gap:6px;cursor:pointer"><input type="checkbox" id="netLan" ${net.lan?'checked':''} style="width:auto"/> <span class="dim">局域网访问</span></label>
|
|
3242
|
+
<label class="inline" style="gap:6px;cursor:pointer"><input type="checkbox" id="netPublic" ${net.publicEnabled?'checked':''} style="width:auto"/> <span class="dim">公网飞书登录</span></label>
|
|
3243
|
+
</div>
|
|
3244
|
+
${!net.authOn?`<div class="field" style="margin:10px 0 0;max-width:320px"><label class="f">访问密码</label><input id="netPass" type="password" autocomplete="new-password" placeholder="局域网开启时设置,至少 6 位"/><div class="hint" style="margin-top:4px">只保存 scrypt 哈希;开启局域网前必填</div></div>`:''}
|
|
3245
|
+
<div class="hint" style="margin-top:7px">${net.publicEnabled?(net.publicReady?'公网登录已启用':'公网登录待配置'): '公网登录已关闭'}${!net.publicReady&&net.feishuLogin&&net.feishuLogin.why?` · ${esc(net.feishuLogin.why)}`:''} · 开关只控制本程序入口,不会改动反向代理或隧道。</div>
|
|
3246
|
+
<button class="btn primary" id="netSave" style="margin-top:12px">保存访问设置</button>
|
|
3247
|
+
<div class="hint" style="margin-top:7px">局域网监听按当前端口即时切换;关闭后只允许本机访问。系统防火墙可能仍会拦截。</div>
|
|
3238
3248
|
${INJECTED_TOKEN?'':'<div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap"><button class="btn" id="logoutBtn" title="作废这次登录;其它设备上的登录不受影响">退出登录</button><button class="btn" id="logoutAllBtn" title="作废这台机器签发过的全部登录(所有设备都要重新登录),手机丢了用这个">退出所有设备</button></div>'}
|
|
3239
3249
|
${prSection}
|
|
3240
3250
|
</div>`;
|
|
@@ -3262,6 +3272,23 @@ function renderSettings(){
|
|
|
3262
3272
|
</div>`;
|
|
3263
3273
|
const verhistCard=`<div class="card" style="grid-column:1/-1" id="verhistCard"><div class="lbl">版本历史</div>
|
|
3264
3274
|
<div id="verhistBox"><div class="empty">加载中…</div></div></div>`;
|
|
3275
|
+
const updateJob=upd&&upd.job;
|
|
3276
|
+
const updateBusy=!!(updateJob&&['queued','installing','restarting','restart-needed'].includes(updateJob.state));
|
|
3277
|
+
const updateText=!upd?'后台版本检查不可用':updateJob?esc(updateJob.message||updateJob.state):upd.available?`检测到新版本 v${esc(upd.latestVersion)} · 当前 v${esc(upd.currentVersion)}`:upd.checkedAt?`当前为最新版本 v${esc(upd.currentVersion)}`:'尚未检查新版';
|
|
3278
|
+
const updateCard=`<div class="card" style="grid-column:1/-1" id="updateCard"><div class="lbl">软件更新</div>
|
|
3279
|
+
<div id="updateStatus" class="hint" style="margin:0 0 12px;color:var(--ink)">${updateText}</div>
|
|
3280
|
+
${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>`:''}
|
|
3281
|
+
<div class="inline" style="gap:14px;flex-wrap:wrap;margin-bottom:12px">
|
|
3282
|
+
<span class="dim">自动检查稳定版(启动时及每天)</span>
|
|
3283
|
+
<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>
|
|
3284
|
+
</div>
|
|
3285
|
+
<div class="inline" style="gap:8px;flex-wrap:wrap">
|
|
3286
|
+
<button class="btn" id="updCheck" ${!upd?'disabled':''}>检查新版</button>
|
|
3287
|
+
<button class="btn primary" id="updInstall" ${!upd||!upd.available||updateBusy?'disabled':''}>现在升级</button>
|
|
3288
|
+
<button class="btn" id="updIgnore" ${!upd||!upd.available?'disabled':''}>忽略此版本</button>
|
|
3289
|
+
</div>
|
|
3290
|
+
<div class="hint" style="margin-top:10px">只安装检测到的稳定版本;所有群任务与队列清空后才会安装。需要可写的全局安装目录,不会提权;后台服务会自动重启,前台运行需手动重启。</div>
|
|
3291
|
+
</div>`;
|
|
3265
3292
|
$('#settings').innerHTML=`<div class="grid cols2 stagger">
|
|
3266
3293
|
${netCard}
|
|
3267
3294
|
<div class="card"><div class="lbl">运行限制</div>
|
|
@@ -3284,6 +3311,7 @@ function renderSettings(){
|
|
|
3284
3311
|
${fxFeishuCard(fe)}
|
|
3285
3312
|
${fxSlackCard(S.slack||null)}
|
|
3286
3313
|
${fxWecomCard(S.wecom||null)}
|
|
3314
|
+
${updateCard}
|
|
3287
3315
|
${aboutCard}
|
|
3288
3316
|
${verhistCard}
|
|
3289
3317
|
</div>`;
|
|
@@ -3291,6 +3319,33 @@ function renderSettings(){
|
|
|
3291
3319
|
$('#bSave').onclick=async()=>{const brand=$('#bBrand').value.trim(),http=$('#pxHttp').value.trim();
|
|
3292
3320
|
if(http&&!/^https?:\/\/\S+$/i.test(http)){toast('代理地址要像 http://127.0.0.1:7890','err');return;}
|
|
3293
3321
|
try{await api('/api/agent','POST',{brand});S=await api('/api/proxy','POST',{enabled:!!http,http});toast('已保存','ok');renderSettings();}catch(e){toast(e.message,'err');}};
|
|
3322
|
+
$('#netSave').onclick=async()=>{
|
|
3323
|
+
const lanEnabled=$('#netLan').checked,publicEnabled=$('#netPublic').checked;
|
|
3324
|
+
const patch={lanEnabled,publicEnabled};
|
|
3325
|
+
const pw=$('#netPass');if(pw&&pw.value)patch.password=pw.value;
|
|
3326
|
+
if(lanEnabled&&!net.lan&&!net.authOn&&(!patch.password||patch.password.length<6)){toast('开启局域网访问前要设置至少 6 位访问密码','err');return;}
|
|
3327
|
+
if(lanEnabled!==!!net.lan&&!confirm(lanEnabled?'开启后,同一可信局域网内可访问登录页;继续?':'关闭后,远程设备将无法访问控制台;继续?'))return;
|
|
3328
|
+
if(publicEnabled!==!!net.publicEnabled&&!confirm(publicEnabled?'启用公网飞书登录入口?这不会修改反向代理或隧道。':'关闭公网入口并让现有飞书登录退出?'))return;
|
|
3329
|
+
const b=$('#netSave');b.disabled=true;
|
|
3330
|
+
try{const r=await api('/api/ui/network','POST',patch);
|
|
3331
|
+
if(r.rebinding){toast('访问地址正在切换;页面即将重载','ok');setTimeout(()=>location.reload(),1200);return;}
|
|
3332
|
+
S=r;toast('访问设置已保存','ok');renderSettings();
|
|
3333
|
+
}catch(e){toast(e.message,'err');b.disabled=false;}
|
|
3334
|
+
};
|
|
3335
|
+
const saveUpdatePrefs=async(patch)=>{try{S=await api('/api/ui/update-settings','POST',patch);renderSettings();}catch(e){toast(e.message,'err');}};
|
|
3336
|
+
$('#updAnnounce').onchange=()=>saveUpdatePrefs({announce:$('#updAnnounce').checked});
|
|
3337
|
+
$('#updCheck').onclick=async()=>{const b=$('#updCheck');b.disabled=true;b.textContent='检查中…';
|
|
3338
|
+
try{await api('/api/update/check','POST',{});S=await api('/api/state');await refreshInbox();renderSettings();renderOverview();toast('版本检查完成','ok');}
|
|
3339
|
+
catch(e){toast(e.message,'err');b.disabled=false;b.textContent='检查新版';}};
|
|
3340
|
+
$('#updInstall').onclick=async()=>{const u=S.update||{},version=u.latestVersion;if(!version)return;
|
|
3341
|
+
const announce=$('#updAnnounce').checked;
|
|
3342
|
+
if(!confirm(`将从 v${u.currentVersion} 升级到 v${version}。${announce?'安装完成后会向未暂停的群播报;':'本次不向群播报。'}所有群任务结束后才开始安装,后台服务会重启。继续?`))return;
|
|
3343
|
+
const b=$('#updInstall');b.disabled=true;
|
|
3344
|
+
try{const r=await api('/api/update/start','POST',{version,announce});S.update={...r,announce};await refreshInbox();renderSettings();renderOverview();toast('更新已排队,群任务空闲后开始','ok');}
|
|
3345
|
+
catch(e){toast(e.message,'err');b.disabled=false;}};
|
|
3346
|
+
$('#updIgnore').onclick=async()=>{const version=(S.update||{}).latestVersion;if(!version)return;
|
|
3347
|
+
try{const r=await api('/api/update/ignore','POST',{version});S.update={...r,announce:$('#updAnnounce').checked};await refreshInbox();renderSettings();renderOverview();toast('已忽略此版本','ok');}
|
|
3348
|
+
catch(e){toast(e.message,'err');}};
|
|
3294
3349
|
// 只开着控制台的守护进程(还没接平台):第一次接上平台时说一句接下来会发生什么(服务托管的会自己重启连上)
|
|
3295
3350
|
{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');}}
|
|
3296
3351
|
bindWecomCard();
|
|
@@ -4440,6 +4495,38 @@ const I18N_EN=[
|
|
|
4440
4495
|
[";改后即时生效,不用重启。","; changes apply immediately, no restart needed."],
|
|
4441
4496
|
["公网","Public"],
|
|
4442
4497
|
["局域网","LAN"],
|
|
4498
|
+
["局域网访问","LAN access"],
|
|
4499
|
+
["公网飞书登录","Public Feishu sign-in"],
|
|
4500
|
+
["访问密码","Access password"],
|
|
4501
|
+
["局域网开启时设置,至少 6 位","Set when enabling LAN access; at least 6 characters"],
|
|
4502
|
+
["只保存 scrypt 哈希;开启局域网前必填","Only a scrypt hash is stored; required before enabling LAN access"],
|
|
4503
|
+
["公网登录已启用","Public sign-in is enabled"],
|
|
4504
|
+
["公网登录待配置","Public sign-in needs configuration"],
|
|
4505
|
+
["公网登录已关闭","Public sign-in is off"],
|
|
4506
|
+
["保存访问设置","Save access settings"],
|
|
4507
|
+
["开关只控制本程序入口,不会改动反向代理或隧道。","These switches control this app only; they do not change the reverse proxy or tunnel."],
|
|
4508
|
+
["局域网监听按当前端口即时切换;关闭后只允许本机访问。系统防火墙可能仍会拦截。","LAN binding changes immediately on the current port; when off, only this machine can connect. The system firewall may still block access."],
|
|
4509
|
+
["软件更新","Software updates"],
|
|
4510
|
+
["后台版本检查不可用","Version checks are unavailable in this console"],
|
|
4511
|
+
["自动检查稳定版(启动时及每天)","Automatically checks for stable releases at startup and daily"],
|
|
4512
|
+
["升级后向群里播报(记住选择)","Announce upgrades in groups (remember this choice)"],
|
|
4513
|
+
["检查新版","Check for updates"],
|
|
4514
|
+
["现在升级","Upgrade now"],
|
|
4515
|
+
["忽略此版本","Ignore this version"],
|
|
4516
|
+
["版本差异与发布说明:","Changes and release notes: "],
|
|
4517
|
+
["尚未检查新版","No version check has run yet"],
|
|
4518
|
+
["当前为最新版本","This is the latest version"],
|
|
4519
|
+
["检测到新版本","A new version is available"],
|
|
4520
|
+
["更新已排队,群任务空闲后开始","Update queued; installation will start when all group tasks are idle"],
|
|
4521
|
+
["已忽略此版本","This version was ignored"],
|
|
4522
|
+
["版本检查完成","Version check complete"],
|
|
4523
|
+
["访问地址正在切换;页面即将重载","Changing the access address; this page will reload"],
|
|
4524
|
+
["访问设置已保存","Access settings saved"],
|
|
4525
|
+
["开启局域网访问前要设置至少 6 位访问密码","Set an access password of at least 6 characters before enabling LAN access"],
|
|
4526
|
+
["开启后,同一可信局域网内可访问登录页;继续?","Enabling lets devices on the trusted LAN reach the sign-in page. Continue?"],
|
|
4527
|
+
["关闭后,远程设备将无法访问控制台;继续?","Remote devices will lose console access. Continue?"],
|
|
4528
|
+
["启用公网飞书登录入口?这不会修改反向代理或隧道。","Enable public Feishu sign-in? This will not change the reverse proxy or tunnel."],
|
|
4529
|
+
["关闭公网入口并让现有飞书登录退出?","Disable public access and sign out existing Feishu sessions?"],
|
|
4443
4530
|
["访问地址","Access URLs"],
|
|
4444
4531
|
["已对外,未检测到局域网 IPv4","Open to the network, but no LAN IPv4 was detected"],
|
|
4445
4532
|
["已对局域网开放","Open on the LAN"],
|
|
@@ -5046,6 +5133,18 @@ const I18N_EN_RX=[
|
|
|
5046
5133
|
[/^✕ 加载失败:([\s\S]+)$/,"✕ Failed to load: $1"],
|
|
5047
5134
|
[/^加载失败:([\s\S]+)$/,"Failed to load: $1"],
|
|
5048
5135
|
[/^登录失败 \(HTTP (\d+)\)$/,"Sign-in failed (HTTP $1)"],
|
|
5136
|
+
[/^检测到新版本 v([\d.]+) · 当前 v([\d.]+)$/,"New version v$1 · current v$2"],
|
|
5137
|
+
[/^当前为最新版本 v([\d.]+)$/,"v$1 is the latest version"],
|
|
5138
|
+
[/^等待所有群任务结束后更新$/,"Waiting for all group tasks to finish"],
|
|
5139
|
+
[/^正在检查安装位置并安装指定版本$/,"Checking the install location and installing the selected version"],
|
|
5140
|
+
[/^已安装,正在重启后台服务$/,"Installed; restarting the background service"],
|
|
5141
|
+
[/^已安装新版;后台服务未能自动重启,请手动重启$/,"The new version is installed, but the background service did not restart; restart it manually"],
|
|
5142
|
+
[/^已安装新版;当前不是受托管服务,请手动重启 SintBot$/,"The new version is installed; restart SintBot manually"],
|
|
5143
|
+
[/^更新完成并已重启$/,"Update completed and restarted"],
|
|
5144
|
+
[/^更新后未能自动重启,请手动重启服务$/,"The update could not restart automatically; restart the service manually"],
|
|
5145
|
+
[/^等待群任务结束超时;可在任务空闲后重新发起$/,"Timed out waiting for group tasks; retry when they are idle"],
|
|
5146
|
+
[/^当前运行的是开发目录或非全局副本;请在终端手动更新$/,"This is a development or non-global install; update it from a terminal"],
|
|
5147
|
+
[/^将从 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;')}`],
|
|
5049
5148
|
];
|
|
5050
5149
|
/* I18N_DICT_END */
|
|
5051
5150
|
|
package/src/ui/dashboard.js
CHANGED
|
@@ -101,8 +101,11 @@ function failedTurns(rows, now) {
|
|
|
101
101
|
// leftovers 残留的后台进程(同上)
|
|
102
102
|
// preview 服务整个项目目录的预览(全部代码都在局域网上可读)
|
|
103
103
|
// health 健康检查没通过的项(一行汇总,排最前:它是「现在就坏着」,没有发生时刻可排)
|
|
104
|
-
function buildInbox({ ledgerRows = [], issues = null, previews = [], leftovers = null, health = null, takeovers = [], groups = {}, dismissed = new Set(), now = Date.now() } = {}) {
|
|
104
|
+
function buildInbox({ ledgerRows = [], issues = null, previews = [], leftovers = null, health = null, update = null, takeovers = [], groups = {}, dismissed = new Set(), now = Date.now() } = {}) {
|
|
105
105
|
const items = []
|
|
106
|
+
if (update && update.available && update.latestVersion) {
|
|
107
|
+
items.push({ key: `update:${update.latestVersion}`, kind: 'update', from: update.currentVersion || '', to: update.latestVersion, ts: update.checkedAt || now, dismissable: true })
|
|
108
|
+
}
|
|
106
109
|
for (const f of failedTurns(ledgerRows, now)) {
|
|
107
110
|
items.push({ key: `failed:${f.chatId}:${f.ts}`, kind: 'failed', chatId: f.chatId, group: groupLabel(groups, f.chatId), ts: f.ts,
|
|
108
111
|
sender: f.sender || '', text: f.text || '', err: f.err ? String(f.err) : '', dismissable: true })
|
package/src/ui/server.js
CHANGED
|
@@ -32,7 +32,7 @@ const { createIssues, summarize, fingerprint } = require('../core/issues') //
|
|
|
32
32
|
const leftover = require('../leftover') // 遗留进程:对话轮次结束后仍在跑的后台 shell —— 同一条道理,那条每 5 秒打线上接口的循环跑了 35 天就是因为没处看见(v0.11.108)
|
|
33
33
|
const { withFileLock } = require('../core/store') // 反馈本改状态要与 CLI/群命令共用同一把跨进程锁
|
|
34
34
|
const { redact } = require('../core/redact') // 出站脱敏:预览停止通知里含 agent 提供的 label
|
|
35
|
-
const { verifyPassword } = require('./auth')
|
|
35
|
+
const { hashPassword, verifyPassword } = require('./auth')
|
|
36
36
|
const feishulogin = require('./feishulogin') // 控制台「用飞书登录」:OAuth 授权码流程的纯逻辑(公网入口;只放行管理员名单)
|
|
37
37
|
const { createSessionStore } = require('./sessions') // 登录会话落盘 + 30 天过期(守护进程重启不再把人登出)
|
|
38
38
|
const { listWecomSessions, wecomAuthShow } = require('../platform/wecom')
|
|
@@ -208,7 +208,7 @@ function bindNoticeText(workdir, platform = 'feishu', cmd = '/help') {
|
|
|
208
208
|
return `✅ 本群已绑定项目「${path.basename(String(workdir || '')) || '(未命名)'}」\n${platform === 'wecom' ? '@我 派单(只有 @ 我的消息我才收得到)' : '直接发消息或 @我 派单'};${cmd} 查看命令`
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
function createUiServer({ configPath, getLiveState = null, getConnHealth = null, onConfigChange = null, control = null, host = '127.0.0.1', log = () => {}, issuesPath = null, sessionsPath = null, auditPath = null, ledgerPath = null, inboxDismissPath = null, fetchImpl = null, cdpImpl = null, onTakeoverClaim = null, onTakeoverExpire = null, onTakeoverEnd = null, takeoverNow = null, wecomExec = null, verifyFile = null, setupMode = null } = {}) {
|
|
211
|
+
function createUiServer({ configPath, getLiveState = null, getConnHealth = null, onConfigChange = null, onHostChange = null, control = null, updater = null, host = '127.0.0.1', log = () => {}, issuesPath = null, sessionsPath = null, auditPath = null, ledgerPath = null, inboxDismissPath = null, fetchImpl = null, cdpImpl = null, onTakeoverClaim = null, onTakeoverExpire = null, onTakeoverEnd = null, takeoverNow = null, wecomExec = null, verifyFile = null, setupMode = null } = {}) {
|
|
212
212
|
// wecomExec:企业微信命令行工具的执行器注入(只为可测;生产走默认 execFile)
|
|
213
213
|
// verifyFile:凭据验证记录的落点注入(只为可测;生产 = ~/.sbot/verify.json)
|
|
214
214
|
const vopts = verifyFile ? { file: verifyFile } : undefined
|
|
@@ -257,14 +257,18 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
257
257
|
const publicUrl = feishulogin.normalizePublicUrl(ui.publicUrl)
|
|
258
258
|
const appId = resolveRef(fe.appId), appSecret = resolveRef(fe.appSecret), admins = adminList(fe)
|
|
259
259
|
const r = feishulogin.feishuLoginReadiness({ publicUrl, appId, appSecret, admins })
|
|
260
|
-
|
|
260
|
+
const enabled = ui.publicEnabled !== false // 未出现这个字段的旧配置保持原行为
|
|
261
|
+
return { ready: enabled && r.ready, configured: r.ready, enabled, why: !enabled ? '公网登录已关闭' : r.why,
|
|
262
|
+
publicUrl: publicUrl || '', redirectUri: publicUrl ? feishulogin.redirectUri(publicUrl) : '', appId, appSecret, admins }
|
|
261
263
|
}
|
|
262
264
|
// Host 白名单:环回时只认 loopback(防 DNS-rebinding);对外(--host 非环回)额外放行私网段
|
|
263
265
|
// IPv4(10./192.168./172.16-31./169.254.)。攻击者域名不匹配私网 IP 模式 → rebinding 仍挡住。
|
|
264
|
-
|
|
265
|
-
|
|
266
|
+
let activeHost = String(host)
|
|
267
|
+
let loopbackBind = /^(127\.0\.0\.1|localhost|::1)$/i.test(activeHost)
|
|
268
|
+
const hostPattern = () => loopbackBind
|
|
266
269
|
? /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i
|
|
267
270
|
: /^(127\.0\.0\.1|localhost|\[::1\]|10(\.\d{1,3}){3}|192\.168(\.\d{1,3}){2}|172\.(1[6-9]|2\d|3[01])(\.\d{1,3}){2}|169\.254(\.\d{1,3}){2})(:\d+)?$/i
|
|
271
|
+
let HOST_OK = hostPattern()
|
|
268
272
|
const authed = (req, url) => { const t = req.headers['x-sbot-token'] || url.searchParams.get('token'); return (!!t && t === token) || sessions.has(t) }
|
|
269
273
|
// 是否已配访问密码(配了 ⇒ 页面不发 token,走登录)。config 读坏时的裁决见 authConfiguredFrom。
|
|
270
274
|
const authConfigured = () => authConfiguredFrom(() => (readRaw().ui || {}).auth, loopbackBind)
|
|
@@ -329,9 +333,11 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
329
333
|
// 「需要你处理」:从 state 各区取数(反馈本 / 预览 / 残留进程 / 健康)+ 账本 + 接管票,不再各算一遍
|
|
330
334
|
const buildInboxFor = ({ issues, previews, leftovers, health }, raw) => {
|
|
331
335
|
try {
|
|
336
|
+
const update = updater ? { ...updater.status(), announce: !raw || !raw.ui || raw.ui.announceUpdates !== false } : null
|
|
332
337
|
return dashboard.buildInbox({
|
|
333
338
|
ledgerRows: ledgerTail.rows(),
|
|
334
339
|
issues, previews, leftovers, health,
|
|
340
|
+
update,
|
|
335
341
|
takeovers: takeoverStore.pending(),
|
|
336
342
|
groups: (raw && raw.groups) || {},
|
|
337
343
|
dismissed: inboxDismiss.set(),
|
|
@@ -606,14 +612,18 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
606
612
|
const port = addr && typeof addr === 'object' ? addr.port : null
|
|
607
613
|
return {
|
|
608
614
|
lan: !loopbackBind,
|
|
615
|
+
host: activeHost,
|
|
609
616
|
port,
|
|
610
617
|
local: port ? `http://127.0.0.1:${port}` : null,
|
|
611
618
|
lanUrls: (!loopbackBind && port) ? lanIPv4().map((ip) => `http://${ip}:${port}`) : [],
|
|
612
619
|
authOn: authConfigured(),
|
|
613
620
|
publicUrl: fli.publicUrl, // 公网地址(飞书登录回调的根;设置页可填)
|
|
621
|
+
publicEnabled: fli.enabled,
|
|
622
|
+
publicReady: fli.configured,
|
|
614
623
|
feishuLogin: { ready: fli.ready, why: fli.why, redirectUri: fli.redirectUri },
|
|
615
624
|
}
|
|
616
625
|
})(),
|
|
626
|
+
update: updater ? { ...updater.status(), announce: !raw.ui || raw.ui.announceUpdates !== false } : null,
|
|
617
627
|
}
|
|
618
628
|
state.inbox = buildInboxFor(state, raw)
|
|
619
629
|
return state
|
|
@@ -806,6 +816,31 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
806
816
|
if (p === '/api/changelog' && req.method === 'GET') return sendJSON(res, 200, { changelog: readChangelog() })
|
|
807
817
|
// 同一份版本历史的英文本(界面切英文时才拉;缺的版本前端按 version 回退中文)
|
|
808
818
|
if (p === '/api/changelog-en' && req.method === 'GET') return sendJSON(res, 200, { changelog: readChangelogEn() })
|
|
819
|
+
if (p === '/api/update/check' && req.method === 'POST') {
|
|
820
|
+
if (!updater) return sendJSON(res, 503, { error: '版本检查暂不可用' })
|
|
821
|
+
return sendJSON(res, 200, await updater.check())
|
|
822
|
+
}
|
|
823
|
+
if (p === '/api/update/start' && req.method === 'POST') {
|
|
824
|
+
if (!updater) return sendJSON(res, 503, { error: '版本升级暂不可用' })
|
|
825
|
+
const { version, announce } = await body(req)
|
|
826
|
+
try { return sendJSON(res, 202, updater.startUpgrade({ version, announce })) }
|
|
827
|
+
catch (e) { return sendJSON(res, 409, { error: e.message, update: updater.status() }) }
|
|
828
|
+
}
|
|
829
|
+
if (p === '/api/update/ignore' && req.method === 'POST') {
|
|
830
|
+
if (!updater) return sendJSON(res, 503, { error: '版本升级暂不可用' })
|
|
831
|
+
const { version } = await body(req)
|
|
832
|
+
try { return sendJSON(res, 200, updater.ignore(version)) }
|
|
833
|
+
catch (e) { return sendJSON(res, 400, { error: e.message }) }
|
|
834
|
+
}
|
|
835
|
+
if (p === '/api/ui/update-settings' && req.method === 'POST') {
|
|
836
|
+
const patch = await body(req)
|
|
837
|
+
if (typeof patch.announce !== 'boolean') return sendJSON(res, 400, { error: '请提供升级播报偏好' })
|
|
838
|
+
const raw = readRaw(); raw.ui = raw.ui || {}
|
|
839
|
+
raw.ui.announceUpdates = patch.announce
|
|
840
|
+
delete raw.ui.autoUpdateCheck
|
|
841
|
+
writeRaw(raw)
|
|
842
|
+
return sendJSON(res, 200, await buildState())
|
|
843
|
+
}
|
|
809
844
|
// codex 可用模型(codex 面板「模型」下拉):app-server model/list 实时探测(当前
|
|
810
845
|
// 账号真实可用集,含各模型支持的推理强度),失败回退内置预设;探测要 spawn 进程
|
|
811
846
|
// +触网 → 10 分钟缓存。
|
|
@@ -1058,6 +1093,67 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
1058
1093
|
if (t) { const n = feishulogin.normalizePublicUrl(t); if (!n) return sendJSON(res, 400, { error: '公网地址要写成 https://域名(可带端口),不带路径' }); raw.ui.publicUrl = n } else delete raw.ui.publicUrl
|
|
1059
1094
|
writeRaw(raw); return sendJSON(res, 200, await buildState())
|
|
1060
1095
|
}
|
|
1096
|
+
if (p === '/api/ui/network' && req.method === 'POST') {
|
|
1097
|
+
const patch = await body(req); const raw = readRaw(); raw.ui = raw.ui || {}
|
|
1098
|
+
const previousHost = activeHost
|
|
1099
|
+
let nextHost = activeHost
|
|
1100
|
+
if (typeof patch.lanEnabled === 'boolean') {
|
|
1101
|
+
if (patch.lanEnabled && patch.password) {
|
|
1102
|
+
const password = String(patch.password)
|
|
1103
|
+
if (password.length < 6) return sendJSON(res, 400, { error: '访问密码至少 6 位' })
|
|
1104
|
+
raw.ui.auth = hashPassword(password)
|
|
1105
|
+
}
|
|
1106
|
+
if (patch.lanEnabled && !raw.ui.auth) return sendJSON(res, 400, { error: '开启局域网访问前要先设置访问密码' })
|
|
1107
|
+
if (patch.lanEnabled !== !loopbackBind) {
|
|
1108
|
+
nextHost = patch.lanEnabled ? '0.0.0.0' : '127.0.0.1'
|
|
1109
|
+
raw.ui.host = nextHost
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
if (typeof patch.publicEnabled === 'boolean') {
|
|
1113
|
+
const wasEnabled = raw.ui.publicEnabled !== false
|
|
1114
|
+
if (patch.publicEnabled) {
|
|
1115
|
+
const fe = raw.feishu || {}; const publicUrl = feishulogin.normalizePublicUrl(raw.ui.publicUrl)
|
|
1116
|
+
const ready = feishulogin.feishuLoginReadiness({ publicUrl, appId: resolveRef(fe.appId), appSecret: resolveRef(fe.appSecret), admins: adminList(fe) })
|
|
1117
|
+
if (!ready.ready) return sendJSON(res, 400, { error: `公网登录尚未就绪:${ready.why}` })
|
|
1118
|
+
}
|
|
1119
|
+
raw.ui.publicEnabled = patch.publicEnabled
|
|
1120
|
+
if (wasEnabled && !patch.publicEnabled) {
|
|
1121
|
+
const n = sessions.revokeWhere((session) => session && session.via === 'feishu')
|
|
1122
|
+
if (n) audit.append({ kind: 'ui-public-access-revoked', sessions: n, ts: Date.now() })
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
writeRaw(raw)
|
|
1126
|
+
audit.append({ kind: 'ui-network-settings', lan: nextHost !== '127.0.0.1', publicEnabled: raw.ui.publicEnabled !== false,
|
|
1127
|
+
hostChanged: nextHost !== previousHost, ts: Date.now() })
|
|
1128
|
+
if (nextHost !== previousHost) {
|
|
1129
|
+
const responseSocket = res.socket
|
|
1130
|
+
let rebindStarted = false
|
|
1131
|
+
const startRebind = () => {
|
|
1132
|
+
if (rebindStarted) return
|
|
1133
|
+
rebindStarted = true
|
|
1134
|
+
clearTimeout(rebindFallback)
|
|
1135
|
+
setImmediate(() => rebindHost(nextHost).catch((e) => {
|
|
1136
|
+
try {
|
|
1137
|
+
const latest = readRaw(); latest.ui = latest.ui || {}; latest.ui.host = previousHost
|
|
1138
|
+
writeRaw(latest)
|
|
1139
|
+
} catch (rollbackErr) { log('ui host rollback failed: ' + (rollbackErr && rollbackErr.message)) }
|
|
1140
|
+
audit.append({ kind: 'ui-network-rebind', ok: false, from: nextHost, to: previousHost, ts: Date.now() })
|
|
1141
|
+
log('ui host change failed: ' + (e && e.message))
|
|
1142
|
+
}))
|
|
1143
|
+
}
|
|
1144
|
+
// Wait for the switching response's connection to close before closing
|
|
1145
|
+
// the listener; a fixed delay can still reset the response under load.
|
|
1146
|
+
const rebindFallback = setTimeout(startRebind, 1000)
|
|
1147
|
+
if (rebindFallback.unref) rebindFallback.unref()
|
|
1148
|
+
if (responseSocket && !responseSocket.destroyed) responseSocket.once('close', startRebind)
|
|
1149
|
+
res.writeHead(202, { 'Content-Type': 'application/json; charset=utf-8', Connection: 'close' })
|
|
1150
|
+
res.end(JSON.stringify({ ok: true, rebinding: true, lan: nextHost !== '127.0.0.1' }), () => {
|
|
1151
|
+
if (!responseSocket || responseSocket.destroyed) startRebind()
|
|
1152
|
+
})
|
|
1153
|
+
return
|
|
1154
|
+
}
|
|
1155
|
+
return sendJSON(res, 200, await buildState())
|
|
1156
|
+
}
|
|
1061
1157
|
if (p === '/api/feishu' && req.method === 'POST') {
|
|
1062
1158
|
const { appId, appSecret, botOpenId, adminOpenId, adminName, onlyWhenMentioned } = await body(req); const raw = readRaw()
|
|
1063
1159
|
raw.feishu = raw.feishu || { onlyWhenMentioned: false }
|
|
@@ -2152,6 +2248,40 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
2152
2248
|
}
|
|
2153
2249
|
})
|
|
2154
2250
|
|
|
2251
|
+
function setActiveHost(value) {
|
|
2252
|
+
const changed = activeHost !== String(value)
|
|
2253
|
+
activeHost = String(value)
|
|
2254
|
+
loopbackBind = /^(127\.0\.0\.1|localhost|::1)$/i.test(activeHost)
|
|
2255
|
+
HOST_OK = hostPattern()
|
|
2256
|
+
if (changed && onHostChange) { try { onHostChange(activeHost, server.address()) } catch (e) { log('ui host callback: ' + (e && e.message)) } }
|
|
2257
|
+
}
|
|
2258
|
+
function listenAgain(bindHost, port) {
|
|
2259
|
+
return new Promise((resolve, reject) => {
|
|
2260
|
+
const onError = (e) => { server.removeListener('listening', onListening); reject(e) }
|
|
2261
|
+
const onListening = () => { server.removeListener('error', onError); setActiveHost(bindHost); resolve() }
|
|
2262
|
+
server.once('error', onError)
|
|
2263
|
+
server.once('listening', onListening)
|
|
2264
|
+
server.listen(port, bindHost)
|
|
2265
|
+
})
|
|
2266
|
+
}
|
|
2267
|
+
async function rebindHost(nextHost) {
|
|
2268
|
+
if (!['127.0.0.1', '0.0.0.0'].includes(nextHost)) throw new Error('只支持本机或全部局域网接口')
|
|
2269
|
+
const address = server.address()
|
|
2270
|
+
if (!address || typeof address !== 'object') throw new Error('控制台尚未监听端口')
|
|
2271
|
+
const oldHost = activeHost
|
|
2272
|
+
if (nextHost === oldHost) return
|
|
2273
|
+
for (const client of sseClients) { try { client.end() } catch {} }
|
|
2274
|
+
sseClients.clear()
|
|
2275
|
+
const stopped = new Promise((resolve, reject) => server.close((e) => e ? reject(e) : resolve()))
|
|
2276
|
+
if (typeof server.closeAllConnections === 'function') server.closeAllConnections()
|
|
2277
|
+
await stopped
|
|
2278
|
+
try { await listenAgain(nextHost, address.port) }
|
|
2279
|
+
catch (e) {
|
|
2280
|
+
try { await listenAgain(oldHost, address.port) } catch (restoreErr) { log('ui host restore listen failed: ' + (restoreErr && restoreErr.message)) }
|
|
2281
|
+
throw e
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2155
2285
|
// Broadcast live state + heartbeat over SSE (cheap; no health probe here).
|
|
2156
2286
|
const tick = setInterval(() => {
|
|
2157
2287
|
if (!sseClients.size) return
|
|
@@ -2164,6 +2294,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
2164
2294
|
return {
|
|
2165
2295
|
server,
|
|
2166
2296
|
listen(port, host, cb) { server.listen(port, host, cb) },
|
|
2297
|
+
rebind: rebindHost,
|
|
2167
2298
|
// let a daemon push turn events into the console's activity feed
|
|
2168
2299
|
pushEvent(type, data) {
|
|
2169
2300
|
if (type === 'turn') recordActivity(data) // also buffer it for replay (下一步 ②)
|
|
@@ -2175,7 +2306,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
|
|
|
2175
2306
|
}
|
|
2176
2307
|
for (const c of sseClients) { try { c.write(msg) } catch { sseClients.delete(c) } }
|
|
2177
2308
|
},
|
|
2178
|
-
close() { clearInterval(tick); for (const c of sseClients) { try { c.end() } catch {} } try { browserRoutes.close() } catch {} server.close() },
|
|
2309
|
+
close() { clearInterval(tick); for (const c of sseClients) { try { c.end() } catch {} } try { browserRoutes.close() } catch {} server.close(); if (typeof server.closeAllConnections === 'function') server.closeAllConnections(); if (updater) updater.close() },
|
|
2179
2310
|
_browserRoutes: browserRoutes, // 测试探 CDP 会话注册表用
|
|
2180
2311
|
}
|
|
2181
2312
|
}
|
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 }
|