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 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>] [--pass-env] [--dry-run]\n'
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 会被点名;--pass-env 把它们的值写进单元文件(文件权限 600),终端里装时不给也会问一句\n'
220
- + ' 还没接任何聊天平台时不装(退出码 3):服务会一启动就退出 —— 先 sintbot ui 在控制台接平台\n'
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
- const uiHost = values['ui-host'] || '127.0.0.1'
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 的环境变量:点名配置里的 env: 引用;--pass-env 才把值写进单元文件。
1279
- // 终端里装又没给 --pass-env 时问一句(默认是):不带进去,服务里用这些 key 的模型 / 平台直接用不了。
1280
- // 配置里没写代理、终端里有代理变量时一起列进这一问(不另问):不带进去,服务里连 Claude / ChatGPT 会直连失败
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
- let passEnv = !!values['pass-env']
1288
- if (!passEnv && carry.length && process.stdin.isTTY) {
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 (inShell.length && !passEnv) process.stderr.write(yellow(`⚠ 配置引用的环境变量 ${inShell.join(' ')} 只在当前终端里有,服务里拿不到 —— 加 --pass-env 把值写进服务定义(文件权限 600),或把值直接写进配置(配置本身是 600)`) + '\n')
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']) { // 打印时遮掉 --pass-env 带进来的值(多半是 key)
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 用默认值)' : fromInstaller ? scope : T(' — ' + scope, ' —' + scope)) + '\n\n')
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 (await askYN(T(`现在安装 Claude Code(npm i -g ${installdeps.PACKAGES.claude})?`, `Install Claude Code now (npm i -g ${installdeps.PACKAGES.claude})?`), true, false)) claudeBin = installDep('claude', claudeName)
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
- process.stdout.write(` ${gray('○')} ${T('没找到 codex(可选:有 ChatGPT 账号时跑 GPT 群、生图)', 'codex not found (optional: with a ChatGPT account it runs GPT groups and image generation)')}\n`)
1474
- if (await askYN(T(`现在安装 Codex CLI(npm i -g ${installdeps.PACKAGES.codex})?有 ChatGPT 账号才用得上`, `Install Codex CLI now (npm i -g ${installdeps.PACKAGES.codex})? Only useful with a ChatGPT account`), false)) codexBin = installDep('codex', 'codex')
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
- // 与设计图对不上。缺哪套问一句一次装齐(默认装);没人回答(输入已结束)不装,doctor 会接着提示
1502
+ // 设计图标库(Lucide / Tabler / Phosphor / Pixelarticons):agent 出设计图时按名字取图标,缺了只能自己画。
1503
+ // 可选组件,不问(1.0.13 起;1.0.11–1.0.12 问一句代装):缺了给一行装法,doctor 也会接着提示
1486
1504
  {
1487
- const iconRoots = () => design.iconRoots({ base: path.join(__dirname, '..'), extra: (raw.design && raw.design.iconsDir) || null })
1488
- const missing = () => Object.entries(iconRoots()).filter(([, v]) => !v).map(([k]) => k)
1489
- const miss = missing()
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 libs = miss.map((k) => design.ICON_LIBS[k])
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(`设计图标库缺 ${libs.map((l) => l.label).join(' / ')}(出设计图时 agent 按名字取图标)`, `Design icon libraries missing: ${libs.map((l) => l.label).join(' / ')} (the agent picks icons by name when drawing designs)`)}\n`)
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(dim(T(` 配置文件:${cfgPath}`, ` Config file: ${cfgPath}`)) + '\n\n')
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
- if (route || readyKeyed().length) {
1612
- // 已经有能用的:不打扰,只问要不要再加一个(群里 /model 可切)
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) && await askYN(T(`用 ${dp} 试跑一轮,确认能用?`, `Do a test run with ${dp} to make sure it works?`), true)) {
1688
- process.stdout.write(dim(T(' 试跑中…', ' Running…')) + '\n')
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) for (const r of failed) process.stdout.write(`${red('✗')} ${r.label.padEnd(22)} ${gray(r.detail)}\n`)
1947
- else process.stdout.write(cyan('✓ ') + T('体检没有失败项', 'Health check: nothing failed') + '\n')
1948
- process.stdout.write(dim(T(` 完整体检:sintbot doctor${cfgArg}`, ` Full check: sintbot doctor${cfgArg}`)) + '\n')
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
- + (shellProxy ? '\n' + dim(T(` 建议:终端里设了代理(${shellProxy}),后台服务拿不到;装服务时会问要不要带进去,也可以在控制台「设置 → 机器人与网络」填。`, ` Tip: your terminal uses a proxy (${shellProxy}) that the background service can't see; service install offers to carry it over, or set it in the console under Settings → Bot & network.`)) + '\n' : ''))
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.12",
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 = run(bin, args); steps.push({ cmd: [bin, ...args].join(' '), ok: r.ok || !!tolerate, err: r.ok ? '' : r.err }); return 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
- step('launchctl', ['bootout', `gui/${uid()}/${LABEL}`], true) // 没加载过会报错,忽略
218
- step('launchctl', ['bootstrap', `gui/${uid()}`, file])
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 }
@@ -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
 
@@ -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
- return { ready: r.ready, why: r.why, publicUrl: publicUrl || '', redirectUri: publicUrl ? feishulogin.redirectUri(publicUrl) : '', appId, appSecret, admins }
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
- const loopbackBind = /^(127\.0\.0\.1|localhost|::1)$/i.test(String(host))
265
- const HOST_OK = loopbackBind
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 }