sintbot 1.0.11 → 1.0.13
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 +40 -0
- package/CHANGELOG.json +42 -0
- package/README.md +9 -9
- package/README.zh-CN.md +9 -9
- package/bin/sbot.js +423 -329
- package/package.json +1 -1
- package/src/clilang.js +31 -0
- package/src/config.js +4 -2
- package/src/core/proxy.js +1 -0
- package/src/core/verify.js +2 -1
- package/src/health.js +2 -2
- package/src/service.js +9 -3
- package/src/setupmodels.js +19 -14
- package/src/ui/console.html +69 -3
- package/src/ui/server.js +9 -1
package/bin/sbot.js
CHANGED
|
@@ -12,6 +12,8 @@ const { pathToFileURL } = require('node:url')
|
|
|
12
12
|
const path = require('node:path')
|
|
13
13
|
const readline = require('node:readline')
|
|
14
14
|
const { parseArgs } = require('node:util')
|
|
15
|
+
const { tr } = require('../src/clilang')
|
|
16
|
+
const { PROXY_ENV_KEYS } = require('../src/core/proxy')
|
|
15
17
|
const { loadConfig, findConfigPath, defaultConfigPath, applyProxyEnv, deriveNoProxy, resolveSecret, platformsConfigured, chatPlatform } = require('../src/config')
|
|
16
18
|
const { resolveProjectsRoots, withinRoots, describeRoots, parseRootsInput, expandHome, workdirInput, defaultWorkdir } = require('../src/projectsroot')
|
|
17
19
|
// Runner dispatch (keeps core/engine agnostic — it only sees `{ runTurn }`): a provider
|
|
@@ -124,9 +126,10 @@ const SUBHELP = {
|
|
|
124
126
|
providers: '用法: sintbot providers\n'
|
|
125
127
|
+ ' 列出配置里的模型 provider:型号 / 端点 / key 是否就位;* = 默认那个\n'
|
|
126
128
|
+ ' (改 provider 走控制台「模型」页或直接编辑 sbot.config.json;群里用 /model 切)',
|
|
127
|
-
setup: '用法: sintbot setup [--config <path>]\n'
|
|
128
|
-
+ '
|
|
129
|
-
+ '
|
|
129
|
+
setup: '用法: sintbot setup [--full] [--config <path>]\n'
|
|
130
|
+
+ ' 首次向导:环境检查 → 模型(先看 Claude / Codex 登录,都没有推荐 DeepSeek,填 key 当场试跑)。聊天平台、代理、项目根目录、绑群装完在控制台里配\n'
|
|
131
|
+
+ ' --full:另外在终端里问代理、机器人名与聊天平台凭据(飞书 / Slack / 企业微信)、项目根目录、绑一个群(给没有浏览器的服务器用)\n'
|
|
132
|
+
+ ' 回车即默认;已有配置只补缺项、不动已填的;新建默认写到 ~/.sbot/config.json(当前目录已有 sbot.config.json 就更新它;--config 另指),权限 600',
|
|
130
133
|
ui: '用法: sintbot ui [--port <n>] [--host <ip>] [--no-open] [--config <path>]\n'
|
|
131
134
|
+ ' sintbot ui --set-password\n'
|
|
132
135
|
+ ' 浏览器里的控制台(配置模式,不接 daemon):配置 / 健康 / provider 试跑 / 群绑定;默认 http://127.0.0.1:4600\n'
|
|
@@ -208,13 +211,13 @@ const SUBHELP = {
|
|
|
208
211
|
+ ' --send 直接把一张卡发进群(带当前页面截图 + 「接管浏览器」按钮 + 有效期;有人点开始接管卡片自动翻成「正在接管中」)—— 群里跑时就用它,别自己贴链接\n'
|
|
209
212
|
+ ' 对方在手机或电脑上打开就能直接操作这台机器的浏览器,登录态留在浏览器里,之后 agent 自己接着干\n'
|
|
210
213
|
+ ' 要守护进程带控制台在跑(sintbot start --ui);群里跑时控制台地址已注入,终端里可设 SBOT_UI_URL=http://127.0.0.1:4600',
|
|
211
|
-
service: '用法: sintbot service install [--ui-host <ip>] [--config <path>] [--
|
|
214
|
+
service: '用法: sintbot service install [--ui-host <ip>] [--config <path>] [--dry-run]\n'
|
|
212
215
|
+ ' sintbot service status | restart | logs [-n <行数>] | uninstall\n'
|
|
213
216
|
+ ' 把 sintbot start --ui 装成常驻服务:崩溃自动拉起、登录后自启(macOS = launchd,Linux = systemd --user)\n'
|
|
214
217
|
+ ' install 写单元文件并立即启动;服务里固定用当前的 node、sbot 与配置文件的绝对路径,PATH 取当前 shell 的\n'
|
|
215
218
|
+ ' --dry-run 只打印要写的单元文件,不动系统;--ui-host 0.0.0.0 对局域网开控制台(须先 sintbot ui --set-password,否则拒装)\n'
|
|
216
|
-
+ ' 服务拿不到当前 shell 的环境变量:配置里 env:VAR 引用的 key
|
|
217
|
-
+ '
|
|
219
|
+
+ ' 服务拿不到当前 shell 的环境变量:配置里 env:VAR 引用的 key、以及配置没写代理时 shell 里的代理变量,值直接写进单元文件(文件权限 600);换了要重跑 install\n'
|
|
220
|
+
+ ' 还没接任何聊天平台也照装:服务先只开控制台,在「设置」里接好平台后自己重启连上\n'
|
|
218
221
|
+ ' Windows 不自动装:打印 NSSM / pm2 的现成命令。日志在 ~/.sbot/logs/;同一份状态只许一个 daemon,第二个会直接报错退出',
|
|
219
222
|
version: '用法: sintbot version (也认 sbot --version / -v)\n'
|
|
220
223
|
+ ' 打印版本号',
|
|
@@ -425,6 +428,46 @@ async function cmdChat(rest) {
|
|
|
425
428
|
await platform.start((msg) => engine.handleMessage(msg, platform))
|
|
426
429
|
}
|
|
427
430
|
|
|
431
|
+
// 还没接聊天平台时的 `sbot start --ui`:只开配置模式的控制台(与 `sintbot ui` 同一套),等人在「设置」里接平台。
|
|
432
|
+
// 照拿单实例锁(和正式 daemon 同一把:之后在终端里 sintbot start 会被拦下,不会两个抢一个平台)。
|
|
433
|
+
// 服务托管(SBOT_SUPERVISED=1)时,配置里第一次出现配齐的平台就 3 秒后退出 —— launchd / systemd 拉起来就是正式 daemon、连上平台;
|
|
434
|
+
// 前台跑的只在日志里提示重启。
|
|
435
|
+
async function serveSetupConsole(cfg, { port, host, noOpen }) {
|
|
436
|
+
const configPath = cfg._path || path.join(process.cwd(), 'sbot.config.json')
|
|
437
|
+
if (!isLoopbackHost(host) && !((cfg.ui || {}).auth)) return die(`控制台对外(--ui-host ${host})需先设密码:sintbot ui --set-password`)
|
|
438
|
+
const supervised = process.env[require('../src/service').SUPERVISED_ENV] === '1'
|
|
439
|
+
const stateDir = path.join(os.homedir(), '.sbot')
|
|
440
|
+
try { secureDataDir(stateDir) } catch {}
|
|
441
|
+
const lock = require('../src/core/instancelock').acquire(path.join(stateDir, 'daemon.lock'))
|
|
442
|
+
if (!lock.ok) return die(`另一个 sbot 已在运行(pid ${lock.holder || '?'})—— 先停掉它(装了服务就 sintbot service uninstall / restart)`)
|
|
443
|
+
process.on('exit', lock.release)
|
|
444
|
+
const logTz = normalizeTz(cfg.timezone) // 与正式 daemon 同一口径:按产品时区打戳
|
|
445
|
+
const logStamp = () => fmtLogStamp(Date.now(), logTz)
|
|
446
|
+
const log = (m) => process.stderr.write(gray(`[${logStamp()}] · ` + m) + '\n')
|
|
447
|
+
const ui = createUiServer({ configPath, host, log, setupMode: { supervised } })
|
|
448
|
+
ui.server.on('error', (e) => die(e.code === 'EADDRINUSE' ? `控制台端口 ${port} 被占用 —— 加 --ui-port <其它> 换端口` : 'ui server error: ' + (e && e.message)))
|
|
449
|
+
ui.listen(port, host, () => {
|
|
450
|
+
const url = `http://127.0.0.1:${port}`
|
|
451
|
+
process.stderr.write('\n' + cyan(' sbot 控制台') + dim(' → ') + url + gray(' (还没接聊天平台:在「设置」里接飞书 / Slack / 企业微信)') + '\n\n')
|
|
452
|
+
log(`还没接聊天平台,只开控制台 ${url}` + (supervised ? ';接上平台后自动重启连上' : ''))
|
|
453
|
+
if (!noOpen) tryOpen(url)
|
|
454
|
+
})
|
|
455
|
+
let told = false
|
|
456
|
+
fs.watchFile(configPath, { interval: 1500 }, (cur, prev) => {
|
|
457
|
+
if (cur.mtimeMs === prev.mtimeMs || told) return
|
|
458
|
+
let now
|
|
459
|
+
try { now = platformsConfigured(loadConfig(configPath)) } catch { return } // 写到一半 / 改坏了:等下一次
|
|
460
|
+
const names = Object.keys(now).filter((k) => now[k])
|
|
461
|
+
if (!names.length) return
|
|
462
|
+
told = true
|
|
463
|
+
if (supervised) { log(`已接上 ${names.join(' / ')} —— 3 秒后重启守护进程连上`); setTimeout(() => process.exit(0), 3000) }
|
|
464
|
+
else log(`已接上 ${names.join(' / ')} —— 按 Ctrl-C 后重新运行 sintbot start --ui 连上`)
|
|
465
|
+
})
|
|
466
|
+
const bye = () => { try { ui.close() } catch {} process.exit(0) }
|
|
467
|
+
process.on('SIGINT', bye)
|
|
468
|
+
process.on('SIGTERM', bye)
|
|
469
|
+
}
|
|
470
|
+
|
|
428
471
|
// `sbot start` — the daemon: Feishu WS in, engine routes each group message to
|
|
429
472
|
// its bound project, agent runs, reply goes back to the group.
|
|
430
473
|
async function cmdStart(rest) {
|
|
@@ -443,6 +486,8 @@ async function cmdStart(rest) {
|
|
|
443
486
|
// 两个都配就两个都接,群项目各自带 platform 字段说明属于哪边(config.normalizeGroupPlatforms 归一,路由器按它分发)。
|
|
444
487
|
const plat = platformsConfigured(cfg)
|
|
445
488
|
if (!plat.feishu && !plat.wecom && !plat.slack) {
|
|
489
|
+
// 带 --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 })
|
|
446
491
|
if (cfg.wecom && (cfg.wecom.botId || cfg.wecom.secret)) return die('sbot.config.json 的 wecom 块缺 ' + (cfg.wecom.botId ? 'secret' : 'botId') + '(机器人 API 配置页「使用长连接」处)')
|
|
447
492
|
return die('没有配置任何聊天平台 — 在 sbot.config.json 里加 "feishu" 块(appId/appSecret)、"wecom" 块(botId/secret)或 "slack" 块(botToken/appToken),配几个接几个')
|
|
448
493
|
}
|
|
@@ -1214,10 +1259,10 @@ function printPreviewStatus() {
|
|
|
1214
1259
|
async function cmdService(rest) {
|
|
1215
1260
|
const svc = require('../src/service')
|
|
1216
1261
|
const action = rest[0] && !rest[0].startsWith('-') ? rest[0] : 'status'
|
|
1217
|
-
const { values } = parseArgs({ args: rest.slice(action === rest[0] ? 1 : 0), allowPositionals: true, options: { config: { type: 'string' }, 'ui-host': { type: 'string' }, 'dry-run': { type: 'boolean' }, 'pass-env': { type: 'boolean' }, n: { type: 'string' } } })
|
|
1262
|
+
const { values } = parseArgs({ args: rest.slice(action === rest[0] ? 1 : 0), allowPositionals: true, options: { config: { type: 'string' }, 'ui-host': { type: 'string' }, 'dry-run': { type: 'boolean' }, 'pass-env': { type: 'boolean' }, check: { type: 'boolean' }, n: { type: 'string' } } })
|
|
1218
1263
|
const platform = process.platform
|
|
1219
1264
|
const home = os.homedir()
|
|
1220
|
-
const nice = (st) => st.running ? cyan('● 运行中') + (st.pid ? dim(` pid ${st.pid}`) : '') : st.loaded ? yellow(`○ 已加载、未在运行(${st.state || '?'})`) : st.installed ? yellow('○ 已安装、未加载') : gray('○ 未安装')
|
|
1265
|
+
const nice = (st) => st.running ? cyan(tr('● 运行中', '● running')) + (st.pid ? dim(` pid ${st.pid}`) : '') : st.loaded ? yellow(tr(`○ 已加载、未在运行(${st.state || '?'})`, `○ loaded, not running (${st.state || '?'})`)) : st.installed ? yellow(tr('○ 已安装、未加载', '○ installed, not loaded')) : gray(tr('○ 未安装', '○ not installed'))
|
|
1221
1266
|
if (action === 'install') {
|
|
1222
1267
|
const configPath = findConfigPath(values.config)
|
|
1223
1268
|
if (!configPath) return die('没找到配置文件 —— 先跑 sintbot setup(或 --config 指定)')
|
|
@@ -1226,27 +1271,21 @@ async function cmdService(rest) {
|
|
|
1226
1271
|
let raw
|
|
1227
1272
|
try { raw = JSON.parse(fs.readFileSync(configPath, 'utf8')) } catch (e) { return die(`读不了配置 ${configPath}:${e.message}`) }
|
|
1228
1273
|
if (uiHost && !isLoopbackHost(uiHost) && !((raw.ui || {}).auth)) return die(`控制台对外(--ui-host ${uiHost})要先设密码:sintbot ui --set-password${values.config ? ' --config ' + values.config : ''} —— 否则服务一启动就退出、被反复拉起`)
|
|
1229
|
-
//
|
|
1230
|
-
//
|
|
1274
|
+
// 一个聊天平台都没接也照装(1.0.12 起):服务先只开控制台,在「设置」里接好平台后自己重启连上(cmdStart 的配置模式)。
|
|
1275
|
+
// 1.0.10–1.0.11 在这里拦下(退出码 3),结果装完没有任何地方可去 —— 控制台也打不开
|
|
1231
1276
|
const plat = platformsConfigured(loadConfig(configPath))
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
}
|
|
1237
|
-
// 服务进程拿不到当前 shell 的环境变量:点名配置里的 env: 引用;--pass-env 才把值写进单元文件。
|
|
1238
|
-
// 终端里装又没给 --pass-env 时问一句(默认是):不带进去,服务里用这些 key 的模型 / 平台直接用不了
|
|
1277
|
+
const noPlatform = !plat.feishu && !plat.wecom && !plat.slack
|
|
1278
|
+
// 服务进程拿不到当前 shell 的环境变量:配置里 env: 引用的 key、配置没写代理时 shell 里的代理变量,值直接写进单元文件(600)。
|
|
1279
|
+
// 1.0.10–1.0.12 先问一句;用户 10-28 定「安装过程要精简」⇒ 不问也不说(不带进去,服务里这些模型 / 平台、连 Claude / ChatGPT 都用不了)。
|
|
1280
|
+
// --pass-env 仍认(以前要它才写),现在是默认行为
|
|
1239
1281
|
const refs = svc.envRefs(raw)
|
|
1240
1282
|
const inShell = refs.filter((v) => process.env[v])
|
|
1241
1283
|
const nowhere = refs.filter((v) => !process.env[v])
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
passEnv = !/^n/i.test(String(a).trim())
|
|
1248
|
-
}
|
|
1249
|
-
const env = passEnv ? Object.fromEntries(inShell.map((v) => [v, process.env[v]])) : {}
|
|
1284
|
+
const shellProxy = PROXY_ENV_KEYS.filter((k) => process.env[k])
|
|
1285
|
+
const proxyVars = (raw.proxy && raw.proxy.http) || !shellProxy.some((k) => !/^no_proxy$/i.test(k)) ? [] : shellProxy // 只有 NO_PROXY 不算有代理
|
|
1286
|
+
const carry = [...inShell, ...proxyVars]
|
|
1287
|
+
const env = Object.fromEntries(carry.map((v) => [v, process.env[v]]))
|
|
1288
|
+
const quiet = process.env.SINTBOT_INSTALLER === '1' // install.sh 调起:只报结果一行,细节留给 sintbot service status
|
|
1250
1289
|
const tools = ['claude', 'codex'].map((n) => whichSync(n)).filter(Boolean)
|
|
1251
1290
|
const node = svc.stableNodeBin({ execPath: process.execPath, candidates: [whichSync('node')] })
|
|
1252
1291
|
const sbotBin = path.resolve(__dirname, 'sbot.js')
|
|
@@ -1261,23 +1300,31 @@ async function cmdService(rest) {
|
|
|
1261
1300
|
})
|
|
1262
1301
|
if (node.warn) process.stderr.write(yellow('⚠ ' + node.warn) + '\n')
|
|
1263
1302
|
if (svc.versionedPath(sbotBin)) process.stderr.write(yellow(`⚠ sbot 装在带版本号的 node 目录下(${sbotBin}),换 node 版本后要重装 sintbot 并重跑 sintbot service install`) + '\n')
|
|
1264
|
-
if (
|
|
1303
|
+
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')
|
|
1265
1304
|
if (nowhere.length) process.stderr.write(yellow(`⚠ 配置引用的环境变量 ${nowhere.join(' ')} 当前终端里也没有 —— 用到它们的模型 / 平台不会工作`) + '\n')
|
|
1266
1305
|
if (platform !== 'darwin' && platform !== 'linux') { process.stdout.write(svc.windowsHint(spec) + '\n'); return }
|
|
1267
1306
|
if (!tools.length) process.stderr.write(yellow('⚠ PATH 里没找到 claude / codex —— 服务能起,但群里的轮次会失败;装好后重跑 sintbot service install') + '\n')
|
|
1268
|
-
if (values['dry-run']) { //
|
|
1307
|
+
if (values['dry-run']) { // 打印时遮掉带进服务的值(多半是 key)
|
|
1269
1308
|
const shown = { ...spec, env: Object.fromEntries(Object.keys(spec.env).map((k) => [k, '<已隐藏>'])) }
|
|
1270
1309
|
process.stdout.write(dim(`# ${svc.unitPath(platform, home)}`) + '\n' + svc.render(platform, shown)); return
|
|
1271
1310
|
}
|
|
1272
1311
|
const r = svc.install(spec, { platform, home })
|
|
1273
|
-
|
|
1312
|
+
if (quiet) { // 安装脚本里:成功只一行;失败照常把每一步列出来
|
|
1313
|
+
await new Promise((res) => setTimeout(res, 1500))
|
|
1314
|
+
const st = svc.status({ platform, home })
|
|
1315
|
+
if (st.running && r.steps.every((s) => s.ok)) { process.stderr.write(` ${cyan('✓')} ${tr('后台服务已启动', 'Background service started')}\n`); return }
|
|
1316
|
+
}
|
|
1317
|
+
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')
|
|
1274
1318
|
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`)
|
|
1275
|
-
await new Promise((res) => setTimeout(res, 1500))
|
|
1276
|
-
process.stderr.write(
|
|
1319
|
+
if (!quiet) await new Promise((res) => setTimeout(res, 1500))
|
|
1320
|
+
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`))
|
|
1277
1321
|
if (platform === 'linux') process.stderr.write(dim('提示:要开机就起(不必先登录),跑一次 loginctl enable-linger $USER') + '\n')
|
|
1278
1322
|
if (r.steps.some((s) => !s.ok)) process.exit(1)
|
|
1279
1323
|
return
|
|
1280
1324
|
}
|
|
1325
|
+
// --check:只给退出码(0 在运行 / 3 装了但没在跑 / 4 没装或这个平台没有自动服务),install.sh 据此决定问不问装服务、要不要重启。
|
|
1326
|
+
// 不用 1、2:1.0.11 及以前不认 --check,报错退出就是 1,install.sh 得能把「不知道」和「没在跑」分开
|
|
1327
|
+
if (action === 'status' && values.check) { const st = svc.status({ platform, home }); process.exit(st.running ? 0 : st.installed || st.loaded ? 3 : 4) }
|
|
1281
1328
|
if (action === 'status') { const st = svc.status({ platform, home }); process.stdout.write(`${nice(st)}\n` + dim(st.file ? `单元文件:${st.file}${st.installed ? '' : '(不存在)'}\n` : '这个平台没有自动服务(见 sintbot service --help)\n')); return }
|
|
1282
1329
|
if (action === 'restart') { const r = svc.restart({ platform }); if (!r.ok) return die('重启失败:' + (r.err || '?')); process.stderr.write(cyan('✓ ') + '已重启\n'); return }
|
|
1283
1330
|
if (action === 'uninstall') { const r = svc.uninstall({ platform, home }); process.stderr.write((r.removed ? cyan('✓ ') + `已停止并删除 ${r.file}` : dim('没有已安装的服务')) + '\n' + dim('日志与配置都保留在 ~/.sbot/') + '\n'); return }
|
|
@@ -1301,19 +1348,25 @@ async function cmdService(rest) {
|
|
|
1301
1348
|
const SETUP_MAX_ASKS_AFTER_EOF = 60
|
|
1302
1349
|
|
|
1303
1350
|
async function cmdSetup(rest) {
|
|
1304
|
-
const { values } = parseArgs({ args: rest, allowPositionals: true, options: { config: { type: 'string' } } })
|
|
1351
|
+
const { values } = parseArgs({ args: rest, allowPositionals: true, options: { config: { type: 'string' }, full: { type: 'boolean' } } })
|
|
1305
1352
|
const cfgPath = defaultConfigPath(values.config)
|
|
1306
1353
|
const examplePath = path.join(__dirname, '..', 'sbot.config.example.json')
|
|
1354
|
+
// 1.0.12 起默认只问环境和模型(用户 10-28 定:是否在中国大陆、代理、项目根目录、聊天平台凭据都不在终端里要,装完在控制台配)。
|
|
1355
|
+
// --full = 旧的完整问答(平台凭据 / 代理 / 项目根目录 / 绑群),给没有浏览器的服务器用;那部分只有中文。
|
|
1356
|
+
const full = !!values.full
|
|
1357
|
+
const T = (zh, en) => tr(zh, en) // 中文 / 英文按 src/clilang.js
|
|
1358
|
+
const lang = tr('zh', 'en')
|
|
1359
|
+
const fromInstaller = process.env.SINTBOT_INSTALLER === '1' // install.sh 调起:不重复报 Node、不打自己的「下一步」
|
|
1307
1360
|
|
|
1308
1361
|
let raw
|
|
1309
1362
|
let seeded = false
|
|
1310
1363
|
if (fs.existsSync(cfgPath)) {
|
|
1311
1364
|
try { raw = JSON.parse(fs.readFileSync(cfgPath, 'utf8')) } catch (e) { return die(`existing config is invalid JSON: ${e.message}`) }
|
|
1312
|
-
process.stdout.write(dim(`更新已有配置 ${cfgPath}`) + '\n')
|
|
1365
|
+
if (!fromInstaller) process.stdout.write(dim(T(`更新已有配置 ${cfgPath}`, `Updating the existing config ${cfgPath}`)) + '\n')
|
|
1313
1366
|
} else if (fs.existsSync(examplePath)) {
|
|
1314
1367
|
raw = JSON.parse(fs.readFileSync(examplePath, 'utf8'))
|
|
1315
1368
|
seeded = true
|
|
1316
|
-
process.stdout.write(dim(
|
|
1369
|
+
if (!fromInstaller) process.stdout.write(dim(T(`新建配置,问完再写入 ${cfgPath}`, `New config; it is written to ${cfgPath} at the end`)) + '\n')
|
|
1317
1370
|
} else {
|
|
1318
1371
|
// claude 不钉 model(v0.11.1):写死会覆盖 CLI 自己的默认,每出一代新模型就落后一代(v0.11.0 拍板)
|
|
1319
1372
|
raw = { agent: { bin: 'claude', permissionMode: 'dontAsk', maxTurnTimeMins: 10, maxConcurrent: 2 }, defaultProvider: 'claude', providers: { claude: {} }, groups: {} }
|
|
@@ -1331,6 +1384,9 @@ async function cmdSetup(rest) {
|
|
|
1331
1384
|
// On EOF, pending asks resolve to null → the caller falls back to its default,
|
|
1332
1385
|
// so a short/redirected input just accepts defaults instead of hanging.
|
|
1333
1386
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
1387
|
+
// 问题交给 readline 当提示符写(setPrompt + prompt),不直接写 stdout:readline 一重绘这一行(改窗口大小、退格)
|
|
1388
|
+
// 就按它记的提示符重写 —— 默认是 `> `,1.0.11 实装里问题文字被吞成「> y」、试跑时冒出一个 `>`。问完和空闲时置空。
|
|
1389
|
+
rl.setPrompt('')
|
|
1334
1390
|
const queued = []
|
|
1335
1391
|
let waiter = null
|
|
1336
1392
|
let closed = false
|
|
@@ -1344,8 +1400,11 @@ async function cmdSetup(rest) {
|
|
|
1344
1400
|
// 另给一个「输入已结束」时的答案(endedDef);② 兜底:输入结束后还问了这么多次就中止,配置不落盘。
|
|
1345
1401
|
let askedAfterEnd = 0
|
|
1346
1402
|
const ask = async (q, def, endedDef = def) => {
|
|
1347
|
-
|
|
1403
|
+
const line = `${cyan('?')} ${q}${def ? dim(` [${def}]`) : ''} `
|
|
1404
|
+
if (closed) process.stdout.write(line)
|
|
1405
|
+
else { rl.setPrompt(line); rl.prompt() }
|
|
1348
1406
|
const a = await nextLine()
|
|
1407
|
+
if (!closed) rl.setPrompt('')
|
|
1349
1408
|
if (a == null) {
|
|
1350
1409
|
process.stdout.write('\n')
|
|
1351
1410
|
if (++askedAfterEnd > SETUP_MAX_ASKS_AFTER_EOF) throw new Error('输入已经结束,向导还在提问 —— 已中止,配置没有写入。请在终端里直接运行 sintbot setup')
|
|
@@ -1366,34 +1425,36 @@ async function cmdSetup(rest) {
|
|
|
1366
1425
|
}
|
|
1367
1426
|
|
|
1368
1427
|
try {
|
|
1369
|
-
|
|
1428
|
+
// 安装脚本调起时它自己已经说过「这里只配模型」,不再重复
|
|
1429
|
+
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)')
|
|
1430
|
+
if (!fromInstaller) process.stdout.write('\n' + cyan('sintbot setup') + dim(full ? ' — 完整配置(Enter 用默认值)' : T(' — ' + scope, ' —' + scope)) + '\n\n')
|
|
1370
1431
|
|
|
1371
1432
|
// 第 0 步:环境检查 —— 只报不拦(缺了照样能把配置写完,最后的体检会接着报)。
|
|
1372
1433
|
// 缺 claude / codex 时问一句替新用户装(npm i -g;官方源连不上自动换国内镜像,见 src/installdeps.js)。
|
|
1373
|
-
process.stdout.write(gray('— 环境 —') + '\n')
|
|
1434
|
+
process.stdout.write(gray(T('— 环境 —', '— Environment —')) + '\n')
|
|
1374
1435
|
const nodeOk = Number(process.versions.node.split('.')[0]) >= 22
|
|
1375
|
-
process.stdout.write(` ${nodeOk ? cyan('✓') : red('✗')} Node ${process.versions.node}${nodeOk ? '' : red('(需要 ≥ 22)')}\n`)
|
|
1436
|
+
if (!fromInstaller || !nodeOk) process.stdout.write(` ${nodeOk ? cyan('✓') : red('✗')} Node ${process.versions.node}${nodeOk ? '' : red(T('(需要 ≥ 22)', ' (needs 22 or later)'))}\n`)
|
|
1376
1437
|
const installDep = (name, binName) => {
|
|
1377
1438
|
const r = installdeps.installGlobal(name, { log: (l) => process.stdout.write(dim(' ' + l) + '\n') })
|
|
1378
1439
|
const bin = whichSync(binName)
|
|
1379
|
-
if (r.ok && bin) { process.stdout.write(` ${cyan('✓')} 已安装 ${binName} ${dim(bin)}\n`); return bin }
|
|
1440
|
+
if (r.ok && bin) { process.stdout.write(` ${cyan('✓')} ${T('已安装', 'Installed')} ${binName} ${dim(bin)}\n`); return bin }
|
|
1380
1441
|
if (r.ok) {
|
|
1381
1442
|
const pre = installdeps.globalPrefix()
|
|
1382
|
-
const dir = pre ? (process.platform === 'win32' ? pre : path.join(pre, 'bin')) : 'npm prefix -g 下的 bin 目录'
|
|
1383
|
-
process.stdout.write(` ${yellow('!')}
|
|
1443
|
+
const dir = pre ? (process.platform === 'win32' ? pre : path.join(pre, 'bin')) : T('npm prefix -g 下的 bin 目录', 'the bin folder under npm prefix -g')
|
|
1444
|
+
process.stdout.write(` ${yellow('!')} ${T(`装好了,但当前 PATH 里找不到 ${binName} —— 把 ${dir} 加进 PATH,重开终端再跑一次 sintbot setup`, `Installed, but ${binName} is not on PATH: add ${dir} to PATH, open a new terminal and run sintbot setup again`)}\n`)
|
|
1384
1445
|
return null
|
|
1385
1446
|
}
|
|
1386
|
-
process.stdout.write(` ${red('✗')}
|
|
1447
|
+
process.stdout.write(` ${red('✗')} ${T(`安装失败(退出码 ${r.code})—— 多半是 npm 全局目录没有写权限,做法见 https://github.com/sintlab/sintbot/blob/main/docs/zh-CN/install.md「一键安装」`, `Install failed (exit code ${r.code}), most likely the npm global folder is not writable; see https://github.com/sintlab/sintbot/blob/main/docs/install.md`)}\n`)
|
|
1387
1448
|
return null
|
|
1388
1449
|
}
|
|
1389
1450
|
const claudeName = (raw.agent && raw.agent.bin) || 'claude'
|
|
1390
1451
|
let claudeBin = whichSync(claudeName)
|
|
1391
1452
|
if (claudeBin) process.stdout.write(` ${cyan('✓')} claude ${dim(claudeBin)}\n`)
|
|
1392
1453
|
else {
|
|
1393
|
-
process.stdout.write(` ${red('✗')} 没找到 claude(Claude Code CLI)——
|
|
1394
|
-
//
|
|
1395
|
-
if (
|
|
1396
|
-
else process.stdout.write(dim(` 稍后自己装:npm i -g ${installdeps.PACKAGES.claude}`) + '\n')
|
|
1454
|
+
process.stdout.write(` ${red('✗')} ${T('没找到 claude(Claude Code CLI)—— 每一轮都靠它干活', 'claude (Claude Code CLI) not found; every turn runs on it')}\n`)
|
|
1455
|
+
// 必需组件:终端里直接装,不问(1.0.13,用户定「安装要精简」);不在终端里(管道、CI)不替他装
|
|
1456
|
+
if (process.stdin.isTTY) claudeBin = installDep('claude', claudeName)
|
|
1457
|
+
else process.stdout.write(dim(T(` 稍后自己装:npm i -g ${installdeps.PACKAGES.claude}`, ` Install it later: npm i -g ${installdeps.PACKAGES.claude}`)) + '\n')
|
|
1397
1458
|
}
|
|
1398
1459
|
// Claude 登录态留到模型步一起报,那里据此决定推荐走哪条路。官方非交互子命令,0.2s、不联网;
|
|
1399
1460
|
// 老版本 CLI 没有它(ok:false)⇒ null,当作没探到
|
|
@@ -1406,49 +1467,35 @@ async function cmdSetup(rest) {
|
|
|
1406
1467
|
let codexBin = whichSync('codex')
|
|
1407
1468
|
let codexLoggedIn = false
|
|
1408
1469
|
if (!codexBin) {
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
else process.stdout.write(dim(` 要用时再装:npm i -g ${installdeps.PACKAGES.codex},再 codex login`) + '\n')
|
|
1470
|
+
// 可选组件不问,一行给装法
|
|
1471
|
+
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`)
|
|
1412
1472
|
}
|
|
1413
1473
|
if (codexBin) {
|
|
1414
1474
|
// 只读本机登录态(不联网);没登录时,GPT 群、生图、claude 额度用完自动切到 codex 都不会成
|
|
1415
1475
|
const codexLogin = await codexstatus.loginStatus('codex').catch(() => null)
|
|
1416
1476
|
const codexOut = codexLogin && codexLogin.ok && codexLogin.loggedIn === false
|
|
1417
1477
|
codexLoggedIn = !!(codexLogin && codexLogin.ok && codexLogin.loggedIn)
|
|
1418
|
-
process.stdout.write(` ${cyan('✓')} codex ${dim(codexBin)}${codexOut ? ' ' + yellow('没登录 —— GPT 群、生图、额度用完时自动切到 codex 都要它:跑一次 codex login') : ''}\n`)
|
|
1478
|
+
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`)
|
|
1419
1479
|
}
|
|
1420
|
-
// 设计图标库(Lucide / Tabler / Phosphor / Pixelarticons):agent
|
|
1421
|
-
//
|
|
1480
|
+
// 设计图标库(Lucide / Tabler / Phosphor / Pixelarticons):agent 出设计图时按名字取图标,缺了只能自己画。
|
|
1481
|
+
// 可选组件,不问(1.0.13 起;1.0.11–1.0.12 问一句代装):缺了给一行装法,doctor 也会接着提示
|
|
1422
1482
|
{
|
|
1423
|
-
const
|
|
1424
|
-
const
|
|
1425
|
-
|
|
1426
|
-
if (!miss.length) process.stdout.write(` ${cyan('✓')} 设计图标库 ${dim(Object.values(design.ICON_LIBS).map((l) => l.label).join(' · '))}\n`)
|
|
1483
|
+
const roots = design.iconRoots({ base: path.join(__dirname, '..'), extra: (raw.design && raw.design.iconsDir) || null })
|
|
1484
|
+
const libs = Object.entries(roots).filter(([, v]) => !v).map(([k]) => design.ICON_LIBS[k])
|
|
1485
|
+
if (!libs.length) process.stdout.write(` ${cyan('✓')} ${T('设计图标库', 'Design icon libraries')} ${dim(Object.values(design.ICON_LIBS).map((l) => l.label).join(' · '))}\n`)
|
|
1427
1486
|
else {
|
|
1428
|
-
const
|
|
1429
|
-
const pkgs = libs.map((l) => l.pkg)
|
|
1487
|
+
const pkgs = libs.map((l) => l.pkg).join(' ')
|
|
1430
1488
|
const mb = Math.round(libs.reduce((n, l) => n + (l.mb || 0), 0))
|
|
1431
|
-
process.stdout.write(` ${gray('○')}
|
|
1432
|
-
if (await askYN(`现在安装(npm i -g ${pkgs.join(' ')},约 ${mb} MB)?`, true, false)) {
|
|
1433
|
-
const r = installdeps.installPackages(pkgs, { log: (l) => process.stdout.write(dim(' ' + l) + '\n') })
|
|
1434
|
-
const still = missing()
|
|
1435
|
-
if (r.ok && !still.length) process.stdout.write(` ${cyan('✓')} 设计图标库已装\n`)
|
|
1436
|
-
else if (r.ok) {
|
|
1437
|
-
const pre = installdeps.globalPrefix()
|
|
1438
|
-
const dir = pre ? (process.platform === 'win32' ? path.join(pre, 'node_modules') : path.join(pre, 'lib', 'node_modules')) : 'npm 全局目录下的 node_modules'
|
|
1439
|
-
process.stdout.write(` ${yellow('!')} 装好了,但 sbot 找不到 ${still.join(' / ')} —— 在配置里把 design.iconsDir 设成 ${dir}\n`)
|
|
1440
|
-
} else process.stdout.write(` ${red('✗')} 安装失败(退出码 ${r.code})—— 多半是 npm 全局目录没有写权限,之后可以自己装:npm i -g ${pkgs.join(' ')}\n`)
|
|
1441
|
-
} else process.stdout.write(dim(` 要用时再装:npm i -g ${pkgs.join(' ')}`) + '\n')
|
|
1489
|
+
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`)
|
|
1442
1490
|
}
|
|
1443
1491
|
}
|
|
1444
|
-
process.stdout.write(
|
|
1492
|
+
process.stdout.write('\n')
|
|
1445
1493
|
|
|
1446
|
-
//
|
|
1447
|
-
|
|
1448
|
-
const mainland = await askYN('这台机器在中国大陆吗?(决定模型连哪个站点、要不要代理、推荐哪个聊天平台)', setupmodels.looksMainland())
|
|
1494
|
+
// 不问「在不在中国大陆」(隐私,用户 10-28 定):只按时区猜,决定有国际站的厂商先试哪个站、--full 里推荐哪个平台;不写进配置
|
|
1495
|
+
const mainland = setupmodels.looksMainland()
|
|
1449
1496
|
|
|
1450
1497
|
// 模型:推荐顺序 Claude 订阅 → ChatGPT(Codex)→ 都没有推荐 DeepSeek(src/setupmodels.js)
|
|
1451
|
-
process.stdout.write(
|
|
1498
|
+
process.stdout.write(gray(T('— 模型 —', '— Model —')) + '\n')
|
|
1452
1499
|
let example = null
|
|
1453
1500
|
try { example = JSON.parse(fs.readFileSync(examplePath, 'utf8')) } catch {}
|
|
1454
1501
|
const presetOf = (name) => (example && example.providers && example.providers[name]) || null
|
|
@@ -1469,86 +1516,96 @@ async function cmdSetup(rest) {
|
|
|
1469
1516
|
const t0 = Date.now()
|
|
1470
1517
|
try {
|
|
1471
1518
|
const { result, aborted } = await runner.runTurn({
|
|
1472
|
-
bin: claudeBin || claudeName, prompt: verify.TRIAL_PROMPT, provider, model: provider.model,
|
|
1519
|
+
bin: claudeBin || claudeName, prompt: lang === 'en' ? verify.TRIAL_PROMPT_EN : verify.TRIAL_PROMPT, provider, model: provider.model,
|
|
1473
1520
|
workdir: os.tmpdir(), permissionMode: (raw.agent && raw.agent.permissionMode) || 'dontAsk', signal: ac.signal,
|
|
1474
1521
|
extraEnv: { CLAUDE_CODE_MAX_RETRIES: '0' },
|
|
1475
1522
|
})
|
|
1476
1523
|
const text = (result && result.text) || ''
|
|
1477
|
-
if (aborted || ac.signal.aborted) return { ok: false, text: `timeout
|
|
1524
|
+
if (aborted || ac.signal.aborted) return { ok: false, text: `timeout: ${T(`${Math.round((Date.now() - t0) / 1000)} 秒内没有回复`, `no reply within ${Math.round((Date.now() - t0) / 1000)} s`)}`, ms: Date.now() - t0 }
|
|
1478
1525
|
const ok = !!text && !(result && result.isError)
|
|
1479
1526
|
verify.recordTurn(provider, ok, text) // doctor / 控制台据此报「能用」或「key 不对」
|
|
1480
|
-
return { ok, text: text || '没有返回内容', ms: Date.now() - t0, model: provider.model }
|
|
1527
|
+
return { ok, text: text || T('没有返回内容', 'No output'), ms: Date.now() - t0, model: provider.model }
|
|
1481
1528
|
} catch (e) {
|
|
1482
1529
|
verify.recordTurn(provider, false, (e && e.message) || String(e))
|
|
1483
1530
|
return { ok: false, text: (e && e.message) || String(e), ms: Date.now() - t0 }
|
|
1484
1531
|
} finally { clearTimeout(timer) }
|
|
1485
1532
|
}
|
|
1486
1533
|
// 型号取配置里的(r.model),不靠模型自报:走 Claude Code 的第三方模型常自称 Claude,新用户会以为没切成
|
|
1487
|
-
const reportTurn = (label, r) => process.stdout.write(` ${cyan('✓')} ${label}${r.model ? ' · ' + r.model : ''}
|
|
1488
|
-
// 配一家用 API key
|
|
1534
|
+
const reportTurn = (label, r) => process.stdout.write(` ${cyan('✓')} ${label}${r.model ? ' · ' + r.model : ''} ${T(`能用(${(r.ms / 1000).toFixed(1)} 秒)`, `works (${(r.ms / 1000).toFixed(1)} s)`)}: ${r.text.replace(/\s+/g, ' ').slice(0, 80)}\n`)
|
|
1535
|
+
// 配一家用 API key 的:告诉去哪拿 key → 粘贴 → 当场试跑,失败说人话、可重试。
|
|
1536
|
+
// 有国际站的厂商(key 在哪个站开的就得连哪个站):不问人在哪 —— 先试一个站(已有配置按它现在的站,新配按时区),
|
|
1537
|
+
// 认证失败自动换另一个站再试;没装 claude 试跑不了时才问「这个 key 是哪个站开的」(问 key,不问人)
|
|
1489
1538
|
const configureKeyed = async (name) => {
|
|
1490
1539
|
const info = setupmodels.PROVIDER_INFO[name] || { label: name }
|
|
1540
|
+
const label = setupmodels.infoText(info, 'label', lang) || name
|
|
1541
|
+
const billing = setupmodels.infoText(info, 'billing', lang)
|
|
1491
1542
|
const p = ensurePreset(name)
|
|
1492
|
-
if (!p) { process.stdout.write(red(` 没有 ${name}
|
|
1493
|
-
|
|
1494
|
-
if (info.intl)
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
}
|
|
1499
|
-
if (site) process.stdout.write(dim(` 在 ${site} 注册 → 充值 → 创建 API key(${info.billing})`) + '\n')
|
|
1543
|
+
if (!p) { process.stdout.write(red(T(` 没有 ${name} 的预设`, ` No preset for ${name}`)) + '\n'); return false }
|
|
1544
|
+
const cnBase = (presetOf(name) && presetOf(name).baseUrl) || p.baseUrl
|
|
1545
|
+
const useSite = (k) => { if (info.intl) p.baseUrl = k === 'intl' ? info.intl.baseUrl : cnBase }
|
|
1546
|
+
const first = !info.intl ? 'cn' : p.baseUrl === info.intl.baseUrl ? 'intl' : setupmodels.keyPresent(p.apiKey) ? 'cn' : mainland ? 'cn' : 'intl'
|
|
1547
|
+
const sites = info.intl ? [first, first === 'cn' ? 'intl' : 'cn'] : ['cn']
|
|
1548
|
+
useSite(sites[0])
|
|
1549
|
+
if (info.intl) process.stdout.write(dim(T(` 在 ${info.site} 或国际站 ${info.intl.site} 注册 → 充值 → 创建 API key(${billing})`, ` Sign up at ${info.site} or the international site ${info.intl.site} → top up → create an API key (${billing})`)) + '\n')
|
|
1550
|
+
else if (info.site) process.stdout.write(dim(T(` 在 ${info.site} 注册 → 充值 → 创建 API key(${billing})`, ` Sign up at ${info.site} → top up → create an API key (${billing})`)) + '\n')
|
|
1500
1551
|
for (;;) {
|
|
1501
|
-
const
|
|
1502
|
-
const
|
|
1552
|
+
const envRef = setupmodels.keyPresent(p.apiKey) && String(p.apiKey).startsWith('env:') ? p.apiKey.slice(4) : ''
|
|
1553
|
+
const had = setupmodels.keyPresent(p.apiKey) ? (envRef ? T(`环境变量 ${envRef}`, `environment variable ${envRef}`) : T('已设置', 'set')) : ''
|
|
1554
|
+
const key = await askKey(T(`粘贴 ${label} 的 API key`, `Paste your ${label} API key`) + (had ? dim(T(`(当前:${had};留空=保持)`, ` (current: ${had}; empty = keep)`)) : T('(留空=跳过)', ' (empty = skip)')))
|
|
1503
1555
|
if (key) p.apiKey = key
|
|
1504
1556
|
if (!setupmodels.keyPresent(p.apiKey)) return false
|
|
1505
|
-
if (!claudeBin) {
|
|
1506
|
-
|
|
1507
|
-
|
|
1557
|
+
if (!claudeBin) {
|
|
1558
|
+
if (info.intl) useSite(await askYN(T(`这个 key 是在国际站 ${setupmodels.host(info.intl.site)} 开的吗?(否 = ${setupmodels.host(info.site)})`, `Was this key created on the international site ${setupmodels.host(info.intl.site)}? (no = ${setupmodels.host(info.site)})`), first === 'intl') ? 'intl' : 'cn')
|
|
1559
|
+
process.stdout.write(dim(T(' 没装 claude,先不试跑;装好后在控制台「模型」页点测试', ' claude is not installed, so no test run now; once it is, press Test on the console Models page')) + '\n')
|
|
1560
|
+
return true
|
|
1561
|
+
}
|
|
1562
|
+
let r = null
|
|
1563
|
+
for (const k of sites) {
|
|
1564
|
+
useSite(k)
|
|
1565
|
+
process.stdout.write(dim(T(` 用 ${label}${info.intl ? `(${setupmodels.host(p.baseUrl)})` : ''} 试跑一轮…`, ` Test run with ${label}${info.intl ? ` (${setupmodels.host(p.baseUrl)})` : ''}…`)) + '\n')
|
|
1566
|
+
r = await tryTurn(name)
|
|
1567
|
+
if (r.ok || setupmodels.explainTestFailure(r.text).kind !== 'auth') break // 只有认证失败才可能是站点不对
|
|
1568
|
+
}
|
|
1508
1569
|
tried.add(name)
|
|
1509
|
-
if (r.ok) { reportTurn(
|
|
1510
|
-
|
|
1570
|
+
if (r.ok) { reportTurn(label, r); verified.push(name); return true }
|
|
1571
|
+
useSite(sites[0])
|
|
1572
|
+
const why = setupmodels.explainTestFailure(r.text, { baseUrl: p.baseUrl, lang })
|
|
1511
1573
|
process.stdout.write(` ${red('✗')} ${why.text}\n`)
|
|
1512
|
-
if (!(await askYN('再试一次?(可以重新粘贴 key)', why.kind === 'auth' || why.kind === 'network', false))) {
|
|
1513
|
-
process.stdout.write(dim(' key 先保存;跑通之前这个模型的轮次会失败,之后可在控制台「模型」页再测') + '\n')
|
|
1574
|
+
if (!(await askYN(T('再试一次?(可以重新粘贴 key)', 'Try again? (you can paste the key again)'), why.kind === 'auth' || why.kind === 'network', false))) {
|
|
1575
|
+
process.stdout.write(dim(T(' key 先保存;跑通之前这个模型的轮次会失败,之后可在控制台「模型」页再测', ' The key is saved; turns on this model will fail until it works; test it again later on the console Models page')) + '\n')
|
|
1514
1576
|
return true
|
|
1515
1577
|
}
|
|
1516
1578
|
}
|
|
1517
1579
|
}
|
|
1518
1580
|
const others = Object.keys(setupmodels.PROVIDER_INFO).filter((n) => n !== setupmodels.RECOMMENDED)
|
|
1519
1581
|
const pickOthers = async () => {
|
|
1520
|
-
others.forEach((n, i) => process.stdout.write(` ${i + 1}) ${setupmodels.PROVIDER_INFO[n]
|
|
1521
|
-
const sel = await ask('选哪家?序号,可空格分隔多选(留空 = 返回)', '')
|
|
1582
|
+
others.forEach((n, i) => process.stdout.write(` ${i + 1}) ${setupmodels.infoText(setupmodels.PROVIDER_INFO[n], 'label', lang)} ${dim(setupmodels.infoText(setupmodels.PROVIDER_INFO[n], 'billing', lang))}\n`))
|
|
1583
|
+
const sel = await ask(T('选哪家?序号,可空格分隔多选(留空 = 返回)', 'Which one? Number(s), space-separated (empty = back)'), '')
|
|
1522
1584
|
const picked = [...new Set(sel.split(/[\s,]+/).map((x) => others[Number(x) - 1]).filter(Boolean))]
|
|
1523
1585
|
for (const n of picked) await configureKeyed(n)
|
|
1524
1586
|
return picked.length > 0
|
|
1525
1587
|
}
|
|
1526
1588
|
|
|
1527
1589
|
const codexReady = () => !!(codexBin && codexLoggedIn)
|
|
1528
|
-
|
|
1529
|
-
process.stdout.write(`
|
|
1530
|
-
|
|
1590
|
+
const col = (zh, en) => T(zh.padEnd(14 - (zh.length - zh.replace(/[^\x00-\xff]/g, '').length)), en.padEnd(20))
|
|
1591
|
+
process.stdout.write(` ${col('Claude 订阅', 'Claude subscription')} ${claudeLoggedIn ? cyan(T('✓ 已登录', '✓ logged in')) : gray(claudeBin ? T('○ 未登录', '○ not logged in') : T('○ 没装 claude', '○ claude not installed'))}\n`)
|
|
1592
|
+
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`)
|
|
1593
|
+
if (readyKeyed().length) process.stdout.write(` ${col('API key 模型', 'API key models')} ${cyan('✓')} ${readyKeyed().join(' ')}\n`)
|
|
1531
1594
|
let route = setupmodels.subscriptionRoute({ claudeLoggedIn, codexLoggedIn: codexReady() })
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
process.stdout.write(` 1) DeepSeek ${dim('(推荐,按量付费)')}\n 2) 其他:智谱 GLM / MiniMax / 火山方舟\n`)
|
|
1536
|
-
if ((await ask('选', '1')) === '2') await pickOthers()
|
|
1537
|
-
else await configureKeyed(setupmodels.RECOMMENDED)
|
|
1538
|
-
}
|
|
1539
|
-
} else {
|
|
1540
|
-
process.stdout.write('\n 没检测到 Claude 订阅或 Codex 登录。' + cyan('推荐用 DeepSeek') + ':注册充值就能用、按量付费,不需要 Claude 账号。\n')
|
|
1595
|
+
// 已经有能用的就不再问(1.0.13 起不问「再加一个」:加模型在控制台「模型 · 库」)
|
|
1596
|
+
if (!route && !readyKeyed().length) {
|
|
1597
|
+
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')
|
|
1541
1598
|
for (;;) {
|
|
1542
|
-
process.stdout.write(` 1) 用 DeepSeek ${dim('(推荐)')}—— 粘贴 API key\n`
|
|
1543
|
-
+
|
|
1544
|
-
+
|
|
1545
|
-
+ ` 4)
|
|
1546
|
-
+
|
|
1547
|
-
const a = await ask('选', '1', '5') // 输入已结束 ⇒ 先跳过(选 1 拿不到 key 会回到这里)
|
|
1599
|
+
process.stdout.write(` 1) ${T('用 DeepSeek', 'DeepSeek')} ${dim(T('(推荐)', '(recommended)'))}${T('—— 粘贴 API key', ': paste an API key')}\n`
|
|
1600
|
+
+ ` 2) ${T('用别家:智谱 GLM / MiniMax / 火山方舟', 'Another provider: Zhipu GLM / MiniMax / Volcano Ark')}\n`
|
|
1601
|
+
+ ` 3) ${T('用 Claude 订阅 —— 另开一个终端跑 claude auth login,登录好回到这里', 'Claude subscription: run claude auth login in another terminal, then come back here')}\n`
|
|
1602
|
+
+ ` 4) ${T(`用 ChatGPT(Codex)—— 另开一个终端跑 codex login(没装先 npm i -g ${installdeps.PACKAGES.codex}),登录好回到这里`, `ChatGPT (Codex): run codex login in another terminal (install first with npm i -g ${installdeps.PACKAGES.codex}), then come back here`)}\n`
|
|
1603
|
+
+ ` 5) ${T('先跳过(之后在控制台「模型」页添加)', 'Skip for now (add one later on the console Models page)')}\n`)
|
|
1604
|
+
const a = await ask(T('选', 'Choose'), '1', '5') // 输入已结束 ⇒ 先跳过(选 1 拿不到 key 会回到这里)
|
|
1548
1605
|
if (a === '1') { if (await configureKeyed(setupmodels.RECOMMENDED)) break; continue }
|
|
1549
1606
|
if (a === '2') { if (await pickOthers() && readyKeyed().length) break; continue }
|
|
1550
1607
|
if (a === '3' || a === '4') {
|
|
1551
|
-
await ask('登录好了按回车', '')
|
|
1608
|
+
await ask(T('登录好了按回车', 'Press Enter once logged in'), '')
|
|
1552
1609
|
if (a === '3') claudeLoggedIn = await claudeLogin()
|
|
1553
1610
|
else {
|
|
1554
1611
|
codexBin = codexBin || whichSync('codex')
|
|
@@ -1556,8 +1613,8 @@ async function cmdSetup(rest) {
|
|
|
1556
1613
|
codexLoggedIn = !!(st && st.ok && st.loggedIn)
|
|
1557
1614
|
}
|
|
1558
1615
|
route = setupmodels.subscriptionRoute({ claudeLoggedIn, codexLoggedIn: codexReady() })
|
|
1559
|
-
if (route) { process.stdout.write(` ${cyan('✓')} ${route === 'claude' ? 'Claude 订阅' : 'Codex'}
|
|
1560
|
-
process.stdout.write(` ${yellow('!')}
|
|
1616
|
+
if (route) { process.stdout.write(` ${cyan('✓')} ${route === 'claude' ? T('Claude 订阅', 'Claude subscription') : 'Codex'} ${T('已登录', 'logged in')}\n`); break }
|
|
1617
|
+
process.stdout.write(` ${yellow('!')} ${T('还是没检测到登录', 'Still no login found')}\n`)
|
|
1561
1618
|
continue
|
|
1562
1619
|
}
|
|
1563
1620
|
break // 5 或其他:先跳过
|
|
@@ -1593,28 +1650,23 @@ async function cmdSetup(rest) {
|
|
|
1593
1650
|
const okKeyed = readyKeyed().filter((n) => !tried.has(n) || verified.includes(n))
|
|
1594
1651
|
const auto = route || verified[0] || (okKeyed.includes(setupmodels.RECOMMENDED) ? setupmodels.RECOMMENDED : okKeyed[0]) || readyKeyed()[0] || raw.defaultProvider || 'claude'
|
|
1595
1652
|
const suggest = !seeded && raw.defaultProvider && usable(raw.defaultProvider) ? raw.defaultProvider : auto
|
|
1653
|
+
// 默认模型与试跑都不问(1.0.13,用户定「安装要精简」):按上面的顺序自动选,还没试跑过就直接试一轮;群里 /model 随时换
|
|
1654
|
+
raw.defaultProvider = suggest
|
|
1596
1655
|
const usableNames = Object.keys(raw.providers).filter(usable)
|
|
1597
|
-
if (usableNames.length > 1) {
|
|
1598
|
-
const def = await ask(`默认模型(${usableNames.join('/')};群里 /model 随时切)`, suggest)
|
|
1599
|
-
raw.defaultProvider = raw.providers[def] ? def : suggest
|
|
1600
|
-
if (!raw.providers[def]) process.stdout.write(red(` 没有叫 '${def}' 的模型,用 ${suggest}`) + '\n')
|
|
1601
|
-
} else {
|
|
1602
|
-
raw.defaultProvider = suggest
|
|
1603
|
-
if (usableNames.length) process.stdout.write(dim(` 默认模型:${suggest}`) + '\n')
|
|
1604
|
-
}
|
|
1605
|
-
// 默认模型还没在本次跑通过(订阅登录那条路):问一句当场试跑
|
|
1656
|
+
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')
|
|
1606
1657
|
const dp = raw.defaultProvider
|
|
1607
1658
|
const canTry = raw.providers[dp] && usable(dp) && (raw.providers[dp].runner === 'codex' ? codexBin : claudeBin)
|
|
1608
|
-
if (canTry && !tried.has(dp)
|
|
1609
|
-
process.stdout.write(dim(`
|
|
1659
|
+
if (canTry && !tried.has(dp)) {
|
|
1660
|
+
process.stdout.write(dim(T(` 用 ${dp} 试跑一轮…`, ` Test run with ${dp}…`)) + '\n')
|
|
1610
1661
|
const r = await tryTurn(dp)
|
|
1611
1662
|
if (r.ok) { reportTurn(dp, r); verified.push(dp) }
|
|
1612
|
-
else process.stdout.write(` ${red('✗')} ${setupmodels.explainTestFailure(r.text, { baseUrl: raw.providers[dp].baseUrl }).text}\n`)
|
|
1663
|
+
else process.stdout.write(` ${red('✗')} ${setupmodels.explainTestFailure(r.text, { baseUrl: raw.providers[dp].baseUrl, lang }).text}\n`)
|
|
1613
1664
|
}
|
|
1614
1665
|
|
|
1615
|
-
//
|
|
1666
|
+
// 代理:默认不问(用户 10-28 定;结尾只给一句建议,服务安装时可把终端里的代理带进去)。--full 里用到 Claude / ChatGPT 才问;
|
|
1667
|
+
// 国内模型端点自动直连(noProxy)
|
|
1616
1668
|
const officialInUse = !!(claudeLoggedIn || codexReady() || !(raw.providers[raw.defaultProvider] || {}).baseUrl)
|
|
1617
|
-
if (
|
|
1669
|
+
if (full && officialInUse) {
|
|
1618
1670
|
if (await askYN('配置代理?(Claude / ChatGPT 要走代理;国内模型端点与飞书会自动直连)', !!(raw.proxy && raw.proxy.http))) {
|
|
1619
1671
|
const http = await ask('HTTP 代理地址', (raw.proxy && raw.proxy.http) || 'http://127.0.0.1:7890')
|
|
1620
1672
|
const noProxy = deriveNoProxy(raw.providers)
|
|
@@ -1627,207 +1679,221 @@ async function cmdSetup(rest) {
|
|
|
1627
1679
|
delete raw.proxy // 示例里的代理是占位,没问就不写进新配置;已有配置的代理不动
|
|
1628
1680
|
}
|
|
1629
1681
|
|
|
1630
|
-
//
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
const
|
|
1665
|
-
|
|
1666
|
-
const
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1682
|
+
// 聊天平台 / 机器人名 / 项目根目录 / 绑群:只在 --full 里问。默认流程里这些都在控制台配(「设置」「群 · 项目」),已有配置一律不动
|
|
1683
|
+
if (full) {
|
|
1684
|
+
// 机器人在群里叫什么(自知清单的自称 + 升级播报;企业微信还用它剥正文里的 @)
|
|
1685
|
+
process.stdout.write('\n' + gray('— 聊天平台 —') + '\n')
|
|
1686
|
+
const brand = await ask('机器人在群里叫什么', raw.brand || 'SintBot')
|
|
1687
|
+
if (brand && brand !== 'SintBot') raw.brand = brand
|
|
1688
|
+
else delete raw.brand
|
|
1689
|
+
|
|
1690
|
+
// 接哪些平台:可以同时接;默认 = 已经配好的那些,一个都没有时按地区推荐:中国大陆飞书,其余 Slack。
|
|
1691
|
+
// 飞书只支持中国版(open.feishu.cn,SDK 默认域名);国际版 Lark(larksuite.com)是另一套开放平台,还没接,提示里写明。
|
|
1692
|
+
const realCreds = (v) => v && !String(v).startsWith('env:') && !/x{6,}|XXXX/.test(String(v))
|
|
1693
|
+
const had = [realCreds(raw.feishu && raw.feishu.appId) && 'feishu', realCreds(raw.slack && raw.slack.botToken) && 'slack', realCreds(raw.wecom && raw.wecom.botId) && 'wecom'].filter(Boolean)
|
|
1694
|
+
const platQ = mainland ? '接哪个聊天平台?国内推荐飞书 —— feishu / wecom / slack,空格分隔可多选(none = 先不接)'
|
|
1695
|
+
: '接哪些聊天平台?推荐 Slack —— slack / feishu(飞书中国版,国际版 Lark 还不支持)/ wecom,空格分隔可多选(none = 先不接)'
|
|
1696
|
+
const platIn = (await ask(platQ, had.join(' ') || (mainland ? 'feishu' : 'slack'))).toLowerCase()
|
|
1697
|
+
const plats = platIn === 'none' ? [] : [...new Set(platIn.split(/[\s,]+/).filter((x) => ['feishu', 'slack', 'wecom'].includes(x)))]
|
|
1698
|
+
if (platIn !== 'none' && !plats.length) process.stdout.write(red(' 没认出平台名(feishu / slack / wecom),先跳过 —— 之后可重跑 setup 或去控制台配') + '\n')
|
|
1699
|
+
|
|
1700
|
+
if (plats.includes('feishu')) {
|
|
1701
|
+
process.stdout.write(gray(' 飞书:开放平台建「企业自建应用」→ 加机器人能力 → 事件订阅选长连接;权限清单见 https://github.com/sintlab/sintbot/blob/main/docs/zh-CN/platforms/feishu.md') + '\n')
|
|
1702
|
+
raw.feishu = raw.feishu || { onlyWhenMentioned: false }
|
|
1703
|
+
// 从示例新建:示例里的 env: 占位不是用户的值 —— 当作没填,免得默认值显示成 env:FEISHU_APP_ID、还提示「留空=保持不变」
|
|
1704
|
+
if (seeded) for (const k of ['appId', 'appSecret']) if (String(raw.feishu[k] || '').startsWith('env:')) raw.feishu[k] = ''
|
|
1705
|
+
// 示例里的 botOpenId 占位同理 —— 预检失败时下面那句「没填才问」会把它当成已填,原样写进新配置
|
|
1706
|
+
if (/^ou_x/i.test(String(raw.feishu.botOpenId || ''))) raw.feishu.botOpenId = ''
|
|
1707
|
+
// Default = the CURRENT value, incl. an `env:VAR` ref — Enter keeps it. Previously an env:
|
|
1708
|
+
// ref defaulted to '' so a plain Enter wiped it (broke creds for env-managed deploys).
|
|
1709
|
+
raw.feishu.appId = await ask('飞书 appId', raw.feishu.appId || '')
|
|
1710
|
+
const existingSecret = raw.feishu.appSecret || '' // incl. env: ref — 回车=保持不变
|
|
1711
|
+
const secretIn = await askKey(`飞书 appSecret${existingSecret ? '(留空=保持不变)' : ''}`)
|
|
1712
|
+
raw.feishu.appSecret = secretIn || existingSecret
|
|
1713
|
+
if (raw.feishu.onlyWhenMentioned === undefined) raw.feishu.onlyWhenMentioned = false
|
|
1714
|
+
|
|
1715
|
+
// Precheck (同源 feishuPrecheck):探 bot 身份 + 权限,自动回填 botOpenId。
|
|
1716
|
+
const feUsable = raw.feishu.appId && raw.feishu.appSecret && !String(raw.feishu.appId).startsWith('env:') && !String(raw.feishu.appSecret).startsWith('env:')
|
|
1717
|
+
if (feUsable && await askYN('飞书预检?(探 bot 身份/权限,自动填 botOpenId)', true)) {
|
|
1718
|
+
const mark = { ok: cyan('✓'), warn: '\x1b[33m!\x1b[0m', fail: red('✗'), skip: gray('○') }
|
|
1719
|
+
try {
|
|
1720
|
+
const r = await feishuPrecheck(feishuClient(raw.feishu.appId, raw.feishu.appSecret), { appId: raw.feishu.appId })
|
|
1721
|
+
verify.recordPrecheck('feishu', raw.feishu, r.checks)
|
|
1722
|
+
if (r.botOpenId) { raw.feishu.botOpenId = r.botOpenId; process.stdout.write(cyan(' ✓ ') + `botOpenId = ${r.botOpenId}\n`) }
|
|
1723
|
+
if (r.identity && r.identity.appName) process.stdout.write(cyan(' ✓ ') + `应用 = ${r.identity.appName}${r.identity.activateText ? ` (${r.identity.activateText})` : ''}\n`)
|
|
1724
|
+
for (const c of r.checks) process.stdout.write(` ${mark[c.status] || '?'} ${c.label} ${gray('— ' + (c.detail || '').slice(0, 140))}\n`) // 1.0.4:提示是写好的整句(怎么修),别截在半句
|
|
1725
|
+
} catch (e) { process.stdout.write(red(` 预检失败(${(e && e.message) || e})`) + '\n') }
|
|
1726
|
+
if (!raw.feishu.botOpenId) raw.feishu.botOpenId = await ask('bot openId (可留空)', '')
|
|
1727
|
+
} else {
|
|
1728
|
+
const curOid = String(raw.feishu.botOpenId || '').startsWith('ou_x') ? '' : (raw.feishu.botOpenId || '')
|
|
1729
|
+
raw.feishu.botOpenId = await ask('bot openId (可留空,用于精确 @ 门控)', curOid)
|
|
1730
|
+
}
|
|
1677
1731
|
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1732
|
+
// Admins (可多个,建群自动拉入):从通讯录选(推荐)/ 邮箱·手机号 / ou_ openId。
|
|
1733
|
+
if (feUsable) {
|
|
1734
|
+
const cur = Array.isArray(raw.feishu.admins) ? raw.feishu.admins.slice() : (raw.feishu.adminOpenId ? [{ openId: raw.feishu.adminOpenId, name: raw.feishu.adminName || '' }] : [])
|
|
1735
|
+
if (await askYN(`配置管理员?(可多个,建群自动拉入${cur.length ? ';当前 ' + cur.length + ' 人' : ''})`, !cur.length)) {
|
|
1736
|
+
const admins = cur.slice()
|
|
1737
|
+
const addOne = (openId, name) => { if (openId && !admins.some((a) => a.openId === openId)) { admins.push({ openId, name: name || openId }); process.stdout.write(cyan(' + ') + (name || openId) + '\n') } }
|
|
1738
|
+
const cli = feishuClient(raw.feishu.appId, raw.feishu.appSecret)
|
|
1739
|
+
let users = []
|
|
1740
|
+
try { users = (await fetchOrgUsers(cli)).users } catch {}
|
|
1741
|
+
if (users.length) {
|
|
1742
|
+
process.stdout.write(gray(` 通讯录可见 ${users.length} 人:`) + '\n')
|
|
1743
|
+
users.slice(0, 50).forEach((u, i) => process.stdout.write(` ${cyan(String(i + 1))}) ${u.name}\n`))
|
|
1744
|
+
const pick = await ask('选管理员序号(空格分隔;或输邮箱/手机号/ou_;留空跳过)', '')
|
|
1745
|
+
if (/^[\d\s]+$/.test(pick) && pick.trim()) { for (const n of pick.split(/\s+/).filter(Boolean)) { const u = users[+n - 1]; if (u) addOne(u.openId, u.name) } }
|
|
1746
|
+
else if (pick.trim()) { await setupResolveAdmin(cli, pick.trim(), addOne) }
|
|
1747
|
+
} else {
|
|
1748
|
+
process.stdout.write(gray(' (通讯录取不到 — 需 contact scope + 数据授权范围;可手动输)') + '\n')
|
|
1749
|
+
const q = await ask('管理员 邮箱/手机号/ou_openId(留空跳过)', '')
|
|
1750
|
+
if (q) await setupResolveAdmin(cli, q, addOne)
|
|
1751
|
+
}
|
|
1752
|
+
raw.feishu.admins = admins
|
|
1753
|
+
delete raw.feishu.adminOpenId; delete raw.feishu.adminName
|
|
1754
|
+
process.stdout.write(dim(` 管理员共 ${admins.length} 人`) + '\n')
|
|
1697
1755
|
}
|
|
1698
|
-
raw.feishu.admins = admins
|
|
1699
|
-
delete raw.feishu.adminOpenId; delete raw.feishu.adminName
|
|
1700
|
-
process.stdout.write(dim(` 管理员共 ${admins.length} 人`) + '\n')
|
|
1701
1756
|
}
|
|
1757
|
+
} else if (seeded) {
|
|
1758
|
+
delete raw.feishu // drop the example placeholder so doctor doesn't flag it
|
|
1702
1759
|
}
|
|
1703
|
-
} else if (seeded) {
|
|
1704
|
-
delete raw.feishu // drop the example placeholder so doctor doesn't flag it
|
|
1705
|
-
}
|
|
1706
1760
|
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1761
|
+
if (plats.includes('slack')) {
|
|
1762
|
+
process.stdout.write(gray(' Slack:api.slack.com/apps → Create New App → From a manifest,粘贴 ' + path.join(__dirname, '..', 'docs', 'slack-app-manifest.yaml') + ' 的内容;') + '\n'
|
|
1763
|
+
+ gray(' 装进 workspace 后取 Bot Token(xoxb-),再在 Basic Information 建一个 App-Level Token(xapp-,勾 connections:write)') + '\n')
|
|
1764
|
+
raw.slack = raw.slack || {}
|
|
1765
|
+
raw.slack.botToken = (await askKey(`Slack Bot Token(xoxb-…)${raw.slack.botToken ? '(留空=保持不变)' : ''}`)) || raw.slack.botToken || ''
|
|
1766
|
+
raw.slack.appToken = (await askKey(`Slack App Token(xapp-…)${raw.slack.appToken ? '(留空=保持不变)' : ''}`)) || raw.slack.appToken || ''
|
|
1767
|
+
if (raw.slack.onlyWhenMentioned === undefined) raw.slack.onlyWhenMentioned = true
|
|
1768
|
+
const usable = realCreds(raw.slack.botToken) && realCreds(raw.slack.appToken)
|
|
1769
|
+
if (usable && await askYN('Slack 预检?(验凭据 / 权限 / 长连接 / 已加入的频道)', true)) {
|
|
1770
|
+
const mark = { ok: cyan('✓'), warn: '\x1b[33m!\x1b[0m', fail: red('✗') }
|
|
1771
|
+
try {
|
|
1772
|
+
const r = await require('../src/platform/slack').slackPrecheck(raw.slack.botToken, raw.slack.appToken)
|
|
1773
|
+
verify.recordPrecheck('slack', raw.slack, r.checks)
|
|
1774
|
+
if (r.botUserId) raw.slack.botUserId = r.botUserId
|
|
1775
|
+
if (r.identity) raw.slack.identity = r.identity
|
|
1776
|
+
for (const c of r.checks) process.stdout.write(` ${mark[c.status] || '?'} ${c.label} ${gray('— ' + (c.detail || '').slice(0, 140))}\n`)
|
|
1777
|
+
} catch (e) { process.stdout.write(red(` 预检失败(${(e && e.message) || e})`) + '\n') }
|
|
1778
|
+
}
|
|
1724
1779
|
}
|
|
1725
|
-
}
|
|
1726
1780
|
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1781
|
+
if (plats.includes('wecom')) {
|
|
1782
|
+
process.stdout.write(gray(' 企业微信:管理后台「智能机器人 → 新建 → API 模式 → 使用长连接」拿 Bot ID 与 Secret(见 https://github.com/sintlab/sintbot/blob/main/docs/zh-CN/platforms/wecom.md)') + '\n')
|
|
1783
|
+
raw.wecom = raw.wecom || {}
|
|
1784
|
+
raw.wecom.botId = await ask('企业微信 Bot ID', realCreds(raw.wecom.botId) ? raw.wecom.botId : '')
|
|
1785
|
+
raw.wecom.secret = (await askKey(`企业微信 Secret${raw.wecom.secret ? '(留空=保持不变)' : ''}`)) || raw.wecom.secret || ''
|
|
1786
|
+
raw.wecom.botName = await ask('机器人在企业微信里的名字(用来剥掉正文里的 @)', raw.wecom.botName || brand || 'SintBot')
|
|
1787
|
+
}
|
|
1734
1788
|
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1789
|
+
// 项目根目录:群项目都放这里,控制台选目录 / 保存绑定 / 风格提炼只认这里面的路径(src/projectsroot.js)。
|
|
1790
|
+
// 一行,回车即默认;多个用 ; 分隔;等于没围的值(/、家目录、/Users …)说原因重问;不存在问一句是否创建。
|
|
1791
|
+
// 回车接受默认时**不落盘**(默认值不写进配置,和别的默认项一样);再跑向导时默认值取现有配置。
|
|
1792
|
+
for (;;) {
|
|
1793
|
+
const cur = resolveProjectsRoots(raw)
|
|
1794
|
+
const defText = cur.roots.join(';')
|
|
1795
|
+
const ans = await ask('项目根目录(群项目都放这里;多个用 ; 分隔)', defText)
|
|
1796
|
+
const list = parseRootsInput(ans)
|
|
1797
|
+
const r = resolveProjectsRoots({ projectsRoots: list })
|
|
1798
|
+
if (r.dropped.length) { for (const d of r.dropped) process.stdout.write(red(` ✗ ${d.value}:${d.why}`) + '\n'); process.stdout.write(dim(' 请重输(留空回车 = 用默认)') + '\n'); continue }
|
|
1799
|
+
if (ans !== defText || cur.fromConfig) raw.projectsRoots = list
|
|
1800
|
+
for (const root of r.roots) {
|
|
1801
|
+
if (fs.existsSync(root)) continue
|
|
1802
|
+
if (await askYN(` ${root} 不存在,现在创建?`, true)) {
|
|
1803
|
+
try { fs.mkdirSync(root, { recursive: true }); process.stdout.write(cyan(' ✓ 已创建 ') + root + '\n') }
|
|
1804
|
+
catch (e) { process.stdout.write(red(` 创建失败:${(e && e.message) || e}`) + '\n') }
|
|
1805
|
+
}
|
|
1751
1806
|
}
|
|
1807
|
+
break
|
|
1752
1808
|
}
|
|
1753
|
-
break
|
|
1754
|
-
}
|
|
1755
1809
|
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1810
|
+
if (await askYN('绑定一个 群→项目?', false)) {
|
|
1811
|
+
let chatId = ''
|
|
1812
|
+
let groupName = '' // 从列表里选的群 / 频道名,用来给项目目录出默认值
|
|
1813
|
+
const fe = raw.feishu
|
|
1814
|
+
// 绑到哪个平台的群:只接了一个平台就是它,接了多个先问
|
|
1815
|
+
// 排序按地区(决定默认绑哪边):中国大陆飞书在前,其余 Slack 在前
|
|
1816
|
+
const live = (mainland ? ['feishu', 'wecom', 'slack'] : ['slack', 'feishu', 'wecom']).filter((x) => (x === 'feishu' ? realCreds(fe && fe.appId) : x === 'slack' ? realCreds(raw.slack && raw.slack.botToken) : realCreds(raw.wecom && raw.wecom.botId)))
|
|
1817
|
+
let bindPlat = live[0] || 'feishu'
|
|
1818
|
+
if (live.length > 1) { const a = (await ask(`这个群在哪个平台? (${live.join(' / ')})`, live[0])).toLowerCase(); if (live.includes(a)) bindPlat = a }
|
|
1819
|
+
const usableFeishu = bindPlat === 'feishu' && fe && fe.appId && fe.appSecret && !String(fe.appId).startsWith('env:') && !String(fe.appSecret).startsWith('env:')
|
|
1820
|
+
if (bindPlat === 'slack' && realCreds(raw.slack.botToken)) {
|
|
1821
|
+
try {
|
|
1822
|
+
const chans = await require('../src/platform/slack').fetchSlackChannels(raw.slack.botToken)
|
|
1823
|
+
if (chans.length) {
|
|
1824
|
+
process.stdout.write(gray(' bot 已加入的频道:') + '\n')
|
|
1825
|
+
chans.forEach((c, i) => process.stdout.write(` ${cyan(String(i + 1))}) #${c.name} ${dim(c.id)}\n`))
|
|
1826
|
+
const pick = await ask('选择要绑定的频道(序号,或直接粘贴频道 ID;留空跳过)', '')
|
|
1827
|
+
const hit = /^\d+$/.test(pick) && chans[+pick - 1]
|
|
1828
|
+
chatId = hit ? hit.id : pick
|
|
1829
|
+
if (hit) groupName = hit.name
|
|
1830
|
+
} else chatId = await ask('频道 ID(如 C0123ABCD;bot 还没进任何频道 —— 先在频道里 /invite 它)', '')
|
|
1831
|
+
} catch (e) {
|
|
1832
|
+
// 1.0.4:错误码翻成人话(凭据 / 权限 / 网络各自怎么修),不再只甩一个 invalid_auth
|
|
1833
|
+
process.stdout.write(red(` 拉频道列表失败:${require('../src/platform/slack').slackHint(e)}`) + '\n')
|
|
1834
|
+
chatId = await ask('频道 ID(如 C0123ABCD;修好上面的问题后重跑向导就能从列表里选)', '')
|
|
1835
|
+
}
|
|
1836
|
+
} else if (bindPlat === 'wecom') {
|
|
1837
|
+
chatId = await ask('企业微信群 ID(还不知道就留空:先跑起来,在群里 @ 机器人说一句,再到控制台绑定 —— 那里会列出 @ 过它的群)', '')
|
|
1838
|
+
} else if (usableFeishu) {
|
|
1839
|
+
try {
|
|
1840
|
+
const chats = await fetchChats(feishuClient(fe.appId, fe.appSecret))
|
|
1841
|
+
if (chats.length) {
|
|
1842
|
+
process.stdout.write(gray(' bot 所在的群:') + '\n')
|
|
1843
|
+
chats.forEach((c, i) => process.stdout.write(` ${cyan(String(i + 1))}) ${c.name} ${dim(c.chatId)}\n`))
|
|
1844
|
+
const pick = await ask('选择要绑定的群(序号,或直接粘贴 chatId;留空跳过)', '')
|
|
1845
|
+
const hit = /^\d+$/.test(pick) && chats[+pick - 1]
|
|
1846
|
+
chatId = hit ? hit.chatId : pick
|
|
1847
|
+
if (hit && hit.name !== '(未命名群)') groupName = hit.name
|
|
1848
|
+
} else {
|
|
1849
|
+
process.stdout.write(gray(' (bot 不在任何群里 — 先把 bot 拉进群再重试,或手动输入)') + '\n')
|
|
1850
|
+
chatId = await ask('群 chatId (如 oc_xxx)', '')
|
|
1851
|
+
}
|
|
1852
|
+
} catch (e) {
|
|
1853
|
+
process.stdout.write(red(` 拉群列表失败:${feishuHint(e, 'im:chat')}`) + '\n')
|
|
1796
1854
|
chatId = await ask('群 chatId (如 oc_xxx)', '')
|
|
1797
1855
|
}
|
|
1798
|
-
}
|
|
1799
|
-
|
|
1800
|
-
chatId = await ask('群 chatId (如 oc_xxx)', '')
|
|
1801
|
-
}
|
|
1802
|
-
} else {
|
|
1803
|
-
chatId = await ask('群 chatId (如 oc_xxx;配了飞书可自动列群供选)', '')
|
|
1804
|
-
}
|
|
1805
|
-
if (!chatId) {
|
|
1806
|
-
// 没有群就不往下问了(1.0.4 前照问目录和 provider,问完才说「未绑定」)
|
|
1807
|
-
process.stdout.write(gray(' 没有群 ID,先不绑定 —— 跑起来后在控制台「群 · 项目」页绑定') + '\n')
|
|
1808
|
-
} else {
|
|
1809
|
-
// 认 `~`、存展开后的绝对路径;写错了当场重问(1.0.2 及以前原样收下 `~/x`,在当前目录建出一个叫 `~` 的文件夹)。
|
|
1810
|
-
// 1.0.4 起有默认值:项目根目录下以群名命名的子目录,回车即用
|
|
1811
|
-
const pr = resolveProjectsRoots(raw)
|
|
1812
|
-
const def = defaultWorkdir(groupName, pr.roots)
|
|
1813
|
-
let workdir = ''
|
|
1814
|
-
for (;;) {
|
|
1815
|
-
const wd = workdirInput(await ask(`项目目录(在 ${describeRoots(pr.roots)} 下,可以用 ~ 开头;回车用默认)`, def))
|
|
1816
|
-
if (wd.path) { workdir = wd.path; break }
|
|
1817
|
-
process.stdout.write(red(' ' + wd.error) + '\n')
|
|
1856
|
+
} else {
|
|
1857
|
+
chatId = await ask('群 chatId (如 oc_xxx;配了飞书可自动列群供选)', '')
|
|
1818
1858
|
}
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1859
|
+
if (!chatId) {
|
|
1860
|
+
// 没有群就不往下问了(1.0.4 前照问目录和 provider,问完才说「未绑定」)
|
|
1861
|
+
process.stdout.write(gray(' 没有群 ID,先不绑定 —— 跑起来后在控制台「群 · 项目」页绑定') + '\n')
|
|
1862
|
+
} else {
|
|
1863
|
+
// 认 `~`、存展开后的绝对路径;写错了当场重问(1.0.2 及以前原样收下 `~/x`,在当前目录建出一个叫 `~` 的文件夹)。
|
|
1864
|
+
// 1.0.4 起有默认值:项目根目录下以群名命名的子目录,回车即用
|
|
1865
|
+
const pr = resolveProjectsRoots(raw)
|
|
1866
|
+
const def = defaultWorkdir(groupName, pr.roots)
|
|
1867
|
+
let workdir = ''
|
|
1868
|
+
for (;;) {
|
|
1869
|
+
const wd = workdirInput(await ask(`项目目录(在 ${describeRoots(pr.roots)} 下,可以用 ~ 开头;回车用默认)`, def))
|
|
1870
|
+
if (wd.path) { workdir = wd.path; break }
|
|
1871
|
+
process.stdout.write(red(' ' + wd.error) + '\n')
|
|
1872
|
+
}
|
|
1873
|
+
// 项目根目录围栏在向导里只提醒不拦:向导与手改配置文件是同一条 operator 路,但要让他知道
|
|
1874
|
+
// 控制台不会认这样的绑定、doctor 会一直提示(围栏判据与控制台同源,src/projectsroot.js)
|
|
1875
|
+
if (!withinRoots(workdir, pr.roots)) process.stdout.write(yellow(` ⚠ 不在项目根目录(${describeRoots(pr.roots)})下 —— 控制台不会允许这样的绑定,doctor 会提示;向导照记`) + '\n')
|
|
1876
|
+
if (!fs.existsSync(workdir) && await askYN(` ${workdir} 不存在,现在创建?`, true)) {
|
|
1877
|
+
try { fs.mkdirSync(workdir, { recursive: true }); process.stdout.write(cyan(' ✓ 已创建 ') + workdir + '\n') }
|
|
1878
|
+
catch (e) { process.stdout.write(red(` 创建失败:${(e && e.message) || e}`) + '\n') }
|
|
1879
|
+
}
|
|
1880
|
+
const gp = await ask('该群 provider', raw.defaultProvider)
|
|
1881
|
+
delete raw.groups.oc_example_chat_id
|
|
1882
|
+
raw.groups[chatId] = { workdir, provider: gp, ...(live.length > 1 || bindPlat !== 'feishu' ? { platform: bindPlat } : {}) }
|
|
1825
1883
|
}
|
|
1826
|
-
const gp = await ask('该群 provider', raw.defaultProvider)
|
|
1827
|
-
delete raw.groups.oc_example_chat_id
|
|
1828
|
-
raw.groups[chatId] = { workdir, provider: gp, ...(live.length > 1 || bindPlat !== 'feishu' ? { platform: bindPlat } : {}) }
|
|
1829
1884
|
}
|
|
1830
1885
|
}
|
|
1886
|
+
// 默认流程从示例新建:示例里的飞书块是 env: 占位(FEISHU_APP_ID …),当前终端里两个都有值才留(环境变量管凭据的部署),
|
|
1887
|
+
// 否则不写进配置 —— 不然体检一上来就报「feishu 块缺 appSecret」
|
|
1888
|
+
// 示例里的 projectsRoots 就是默认值(~/Projects):不落盘,和 --full 里回车接受默认一样 —— 写进去就成了「配置里指定的根」,
|
|
1889
|
+
// 目录还不存在时体检报失败;没写的默认根在控制台第一次浏览时自动创建
|
|
1890
|
+
if (!full && seeded && JSON.stringify(raw.projectsRoots) === JSON.stringify(['~/Projects'])) delete raw.projectsRoots
|
|
1891
|
+
if (!full && seeded && raw.feishu) {
|
|
1892
|
+
const fe = raw.feishu
|
|
1893
|
+
const envOk = (v) => String(v || '').startsWith('env:') && !!process.env[String(v).slice(4)]
|
|
1894
|
+
if (envOk(fe.appId) && envOk(fe.appSecret)) { if (/^ou_x/i.test(String(fe.botOpenId || ''))) fe.botOpenId = '' }
|
|
1895
|
+
else delete raw.feishu
|
|
1896
|
+
}
|
|
1831
1897
|
} finally {
|
|
1832
1898
|
rl.close()
|
|
1833
1899
|
}
|
|
@@ -1838,26 +1904,44 @@ async function cmdSetup(rest) {
|
|
|
1838
1904
|
const cfgDir = path.dirname(cfgPath)
|
|
1839
1905
|
if (posix && !fs.existsSync(cfgDir)) fs.mkdirSync(cfgDir, { recursive: true, mode: 0o700 })
|
|
1840
1906
|
atomicWriteJSON(cfgPath, raw, posix ? { mode: 0o600 } : {})
|
|
1841
|
-
process.stdout.write('\n' + cyan('✓ ') + `已保存 ${cfgPath}
|
|
1907
|
+
process.stdout.write('\n' + cyan('✓ ') + T(`已保存 ${cfgPath}`, `Saved ${cfgPath}`) + '\n')
|
|
1842
1908
|
|
|
1843
1909
|
const cfg = loadConfig(cfgPath)
|
|
1844
|
-
await runDoctor(cfg)
|
|
1845
1910
|
const cfgArg = cfgPath === path.join(os.homedir(), '.sbot', 'config.json') ? '' : ` --config ${cfgPath}`
|
|
1846
|
-
|
|
1911
|
+
if (full) {
|
|
1912
|
+
process.stdout.write('\n' + gray('— 体检 —') + '\n')
|
|
1913
|
+
await runDoctor(cfg)
|
|
1914
|
+
} else {
|
|
1915
|
+
// 新装时「没接平台 / 没绑群 / Claude 没登录」都是正常状态,整屏警告看着像出错:只报失败项,完整体检留给 sintbot doctor
|
|
1916
|
+
const { rows } = await checkHealth(cfg)
|
|
1917
|
+
const failed = rows.filter((r) => r.status === 'fail')
|
|
1918
|
+
if (failed.length) {
|
|
1919
|
+
for (const r of failed) process.stdout.write(`${red('✗')} ${r.label.padEnd(22)} ${gray(r.detail)}\n`)
|
|
1920
|
+
process.stdout.write(dim(T(` 完整体检:sintbot doctor${cfgArg}`, ` Full check: sintbot doctor${cfgArg}`)) + '\n')
|
|
1921
|
+
} else process.stdout.write(cyan('✓ ') + T('体检通过', 'Health check passed') + dim(T(`(完整:sintbot doctor${cfgArg})`, ` (full: sintbot doctor${cfgArg})`)) + '\n')
|
|
1922
|
+
}
|
|
1923
|
+
if (!((cfg.providers || {})[cfg.defaultProvider] || {}).baseUrl) {
|
|
1924
|
+
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')
|
|
1925
|
+
}
|
|
1926
|
+
if (fromInstaller) return // 后台服务、控制台地址、接下来去哪:install.sh 结尾统一给
|
|
1847
1927
|
const plat = platformsConfigured(cfg)
|
|
1848
1928
|
const noPlatform = !plat.feishu && !plat.wecom && !plat.slack
|
|
1849
|
-
process.stdout.write('\n' + gray('— 下一步 —') + '\n'
|
|
1850
|
-
+ ` sintbot
|
|
1851
|
-
+ (noPlatform
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1929
|
+
process.stdout.write('\n' + gray(T('— 下一步 —', '— Next —')) + '\n'
|
|
1930
|
+
+ ` 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`
|
|
1931
|
+
+ ` 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`
|
|
1932
|
+
+ ` sintbot chat${cfgArg} ${dim(T('不接平台也能先在终端里和它聊几句', 'chat with it in the terminal first, no platform needed'))}\n`
|
|
1933
|
+
)
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
// 能不能在这台机器上弹浏览器:SSH 进来的会话弹到的是对方屏幕(或根本没有屏幕),Linux 没有图形界面也不弹 —— 只打印地址
|
|
1937
|
+
function canOpenBrowser(env = process.env, platform = process.platform) {
|
|
1938
|
+
if (env.SSH_CONNECTION || env.SSH_TTY) return false
|
|
1939
|
+
if (platform === 'linux' && !env.DISPLAY && !env.WAYLAND_DISPLAY) return false
|
|
1940
|
+
return true
|
|
1858
1941
|
}
|
|
1859
1942
|
|
|
1860
1943
|
function tryOpen(url) {
|
|
1944
|
+
if (!canOpenBrowser()) return
|
|
1861
1945
|
const isWin = process.platform === 'win32'
|
|
1862
1946
|
const cmd = isWin ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open'
|
|
1863
1947
|
try { require('node:child_process').spawn(cmd, isWin ? ['', url] : [url], { shell: isWin, stdio: 'ignore', detached: true }).unref() } catch { /* just print the URL */ }
|
|
@@ -1899,7 +1983,17 @@ async function cmdUi(rest) {
|
|
|
1899
1983
|
}
|
|
1900
1984
|
const ui = createUiServer({ configPath, host, log: (m) => process.stderr.write(gray('· ' + m) + '\n') })
|
|
1901
1985
|
ui.server.on('error', (e) => {
|
|
1902
|
-
if (e.code === 'EADDRINUSE')
|
|
1986
|
+
if (e.code === 'EADDRINUSE') {
|
|
1987
|
+
// 后台服务在跑 = 控制台已经开着(1.0.12 起没平台也装服务):直接打开它,别报「端口被占用」
|
|
1988
|
+
const st = (() => { try { return require('../src/service').status({ platform: process.platform, home: os.homedir() }) } catch { return {} } })()
|
|
1989
|
+
if (st.running) {
|
|
1990
|
+
const url = `http://127.0.0.1:${port}`
|
|
1991
|
+
process.stderr.write(cyan(' ') + tr(`控制台已在后台服务里运行 → ${url}`, `The console is already running in the background service → ${url}`) + '\n')
|
|
1992
|
+
if (!noOpen) tryOpen(url)
|
|
1993
|
+
process.exit(0)
|
|
1994
|
+
}
|
|
1995
|
+
return die(`端口 ${port} 被占用 — 换端口:sintbot ui --port <其它>`)
|
|
1996
|
+
}
|
|
1903
1997
|
die('ui server error: ' + (e && e.message))
|
|
1904
1998
|
})
|
|
1905
1999
|
ui.listen(port, host, () => {
|