sintbot 1.0.16 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.en.json +99 -0
- package/CHANGELOG.json +99 -0
- package/README.md +20 -158
- package/README.zh-CN.md +20 -166
- package/THIRD-PARTY-NOTICES.md +394 -0
- package/bin/sintbot-v2.js +363 -0
- package/docs/AI-ASSISTED-INSTALL.md +11 -35
- package/docs/install.md +61 -167
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +11 -35
- package/docs/zh-CN/install.md +61 -165
- package/install.sh +84 -0
- package/package.json +73 -37
- package/src/agent/claudecode.js +149 -108
- package/src/agent/codexapp.js +458 -0
- package/src/agent/codexcode.js +210 -107
- package/src/agent/errtag.js +34 -0
- package/src/agent/privatecore.js +790 -0
- package/src/agent/privatecore.wasm +0 -0
- package/src/agent/runner.js +10 -8
- package/src/agent/winexec.js +35 -5
- package/src/c2install.js +117 -0
- package/src/config.js +2 -1
- package/src/core/c2cardeffectbridge.js +51 -0
- package/src/core/c2cardterminalpath.js +216 -0
- package/src/core/c2durableingress.js +148 -0
- package/src/core/c2durablejournal.js +1560 -0
- package/src/core/c2durableruntime.js +2361 -0
- package/src/core/c2eventidentity.js +90 -0
- package/src/core/c2globalfault.js +401 -0
- package/src/core/c2localeffectexecutor.js +193 -0
- package/src/core/c2payloadhandles.js +333 -0
- package/src/core/c2persistrelation.js +179 -0
- package/src/core/c2persiststate.js +342 -0
- package/src/core/c2platformrecoverysender.js +76 -0
- package/src/core/c2recoverygate.js +385 -0
- package/src/core/c2runnerinput.js +63 -0
- package/src/core/c2servicestartgate.js +103 -0
- package/src/core/c2sessionbridge.js +72 -0
- package/src/core/c2sourcequarantine.js +30 -0
- package/src/core/c2startuprecovery.js +105 -0
- package/src/core/c2successorbridge.js +151 -0
- package/src/core/c2terminaleffectbridge.js +154 -0
- package/src/core/c2terminalprune.js +108 -0
- package/src/core/c2turnprojection.js +177 -0
- package/src/core/enginecheckpoint.js +211 -0
- package/src/core/engineeffectcursor.js +394 -0
- package/src/core/enginestepcoordinator.js +183 -0
- package/src/core/store.js +52 -8
- package/src/core/v2engine.js +1469 -0
- package/src/platform/feishu.js +324 -67
- package/src/plugins.js +1 -9
- package/assets/design-styles/acid-pop.md +0 -155
- package/assets/design-styles/archive-index.md +0 -161
- package/assets/design-styles/art-deco.md +0 -164
- package/assets/design-styles/cinematic-noir.md +0 -178
- package/assets/design-styles/editorial.md +0 -121
- package/assets/design-styles/extra/README.md +0 -23
- package/assets/design-styles/extra/constructivism.md +0 -146
- package/assets/design-styles/extra/neo-brutalism.md +0 -175
- package/assets/design-styles/extra/new-wave.md +0 -166
- package/assets/design-styles/extra/nordic-material.md +0 -162
- package/assets/design-styles/extra/paris-couture.md +0 -162
- package/assets/design-styles/extra/tropical-modern.md +0 -168
- package/assets/design-styles/extra/ulm-functional.md +0 -182
- package/assets/design-styles/extra/wa-shikimen.md +0 -160
- package/assets/design-styles/extra/y2k-chrome.md +0 -172
- package/assets/design-styles/geometric-pop.md +0 -122
- package/assets/design-styles/hand-drawn.md +0 -117
- package/assets/design-styles/islamic-geometry.md +0 -155
- package/assets/design-styles/liquid-glass.md +0 -183
- package/assets/design-styles/neo-chinese.md +0 -136
- package/assets/design-styles/precision-mono.md +0 -156
- package/assets/design-styles/quiet-luxury.md +0 -163
- package/assets/design-styles/riso-print.md +0 -116
- package/assets/design-styles/soft-friendly.md +0 -174
- package/assets/design-styles/swiss-grid.md +0 -156
- package/assets/design-styles/tech-dark.md +0 -182
- package/assets/favicon.svg +0 -6
- package/assets/logo.svg +0 -24
- package/bin/sbot.js +0 -4716
- package/docs/slack-app-manifest.yaml +0 -104
- package/sbot.config.example.json +0 -235
- package/src/agent/askonce.js +0 -76
- package/src/agent/claudemodels.js +0 -60
- package/src/agent/claudestatus.js +0 -74
- package/src/agent/claudeusage.js +0 -200
- package/src/agent/codexstatus.js +0 -181
- package/src/agent/imagen.js +0 -458
- package/src/agent/quotaerr.js +0 -44
- package/src/agent/transienterr.js +0 -32
- package/src/cdp.js +0 -254
- package/src/cli-secret.js +0 -36
- package/src/clilang.js +0 -31
- package/src/core/cron.js +0 -182
- package/src/core/cronfire.js +0 -120
- package/src/core/cronrun.js +0 -116
- package/src/core/engine.js +0 -2758
- package/src/core/interfaces.js +0 -114
- package/src/core/issues.js +0 -185
- package/src/core/ledger.js +0 -30
- package/src/core/opsalert.js +0 -214
- package/src/core/pending.js +0 -123
- package/src/core/proxy.js +0 -424
- package/src/core/quotaalert.js +0 -79
- package/src/core/quotaerr.js +0 -55
- package/src/core/scheduler.js +0 -59
- package/src/design.js +0 -5161
- package/src/gitrepo.js +0 -150
- package/src/health.js +0 -353
- package/src/installdeps.js +0 -72
- package/src/leftover.js +0 -174
- package/src/platform/boardnotice.js +0 -77
- package/src/platform/boardops.js +0 -36
- package/src/platform/boardwatch.js +0 -447
- package/src/platform/cli.js +0 -45
- package/src/platform/slack.js +0 -1289
- package/src/platform/takeovercard.js +0 -37
- package/src/platform/wecom.js +0 -938
- package/src/platform/wecomcli.js +0 -128
- package/src/platform/wecomgate.js +0 -108
- package/src/platform/wecomname.js +0 -43
- package/src/platform/wecompoll.js +0 -241
- package/src/preview.js +0 -581
- package/src/projectmemory.js +0 -175
- package/src/service.js +0 -317
- package/src/ui/auth.js +0 -31
- package/src/ui/browser.html +0 -392
- package/src/ui/browserroutes.js +0 -234
- package/src/ui/console.html +0 -5526
- package/src/ui/dashboard.js +0 -168
- package/src/ui/feishulogin.js +0 -136
- package/src/ui/server.js +0 -2314
- package/src/ui/sessions.js +0 -78
- package/src/ui/takeover.js +0 -108
- package/src/updater.js +0 -243
- package/src/upgrade.js +0 -35
- package/src/usage.js +0 -144
package/src/gitrepo.js
DELETED
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
// 群项目 ↔ git 远端关联(控制台「仓库地址」字段)。
|
|
2
|
-
//
|
|
3
|
-
// 场景:项目代码已经在本地 workdir 里,operator 在代码平台上为它新建了一个**空**仓库,
|
|
4
|
-
// 填地址是为了之后把本地项目推上去。所以这里**不做 clone** —— 远端没东西可拉。控制台只
|
|
5
|
-
// 负责建立关联(git init + remote origin,幂等),真正的 add/commit/push 交给 agent
|
|
6
|
-
// (它有 workdir、有判断力,该决定 .gitignore / commit message / 推哪些文件)。
|
|
7
|
-
//
|
|
8
|
-
// 安全:仓库地址是控制台传来的自由文本,而 git 的部分 transport 会执行命令 ——
|
|
9
|
-
// `git ls-remote 'ext::sh -c <任意命令>'` 就是 RCE,`--upload-pack=` 同理。控制台虽已
|
|
10
|
-
// 鉴权,仍按 fail-closed 做协议白名单:只放行 SSH 两种写法,其余一律拒。所有 git 调用
|
|
11
|
-
// 走 execFile(数组参数、不经 shell),不存在拼接注入。
|
|
12
|
-
const { execFile } = require('node:child_process')
|
|
13
|
-
const fs = require('node:fs')
|
|
14
|
-
const path = require('node:path')
|
|
15
|
-
|
|
16
|
-
// scp 写法: git@host:org/repo.git · URL 写法: ssh://git@host[:port]/org/repo.git
|
|
17
|
-
const SSH_SCP = /^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:[A-Za-z0-9._~-][A-Za-z0-9._~/-]*$/
|
|
18
|
-
const SSH_URL = /^ssh:\/\/[A-Za-z0-9._-]+@[A-Za-z0-9._-]+(:\d{1,5})?\/[A-Za-z0-9._~/-]+$/
|
|
19
|
-
|
|
20
|
-
function validateRepoUrl(url) {
|
|
21
|
-
const u = String(url == null ? '' : url).trim()
|
|
22
|
-
if (!u) return { ok: false, error: '仓库地址为空' }
|
|
23
|
-
// 以 - 开头会被 git 当成选项 —— 挡在正则之前,错误信息才说得清
|
|
24
|
-
if (u.startsWith('-')) return { ok: false, error: '仓库地址不能以 - 开头' }
|
|
25
|
-
if (/^ext::/i.test(u)) return { ok: false, error: '拒绝 ext:: 地址(该 transport 会执行任意命令)' }
|
|
26
|
-
if (/^(file|git):/i.test(u)) return { ok: false, error: '只支持 SSH 地址,不支持 file:// / git:// ' }
|
|
27
|
-
if (/^https?:/i.test(u)) return { ok: false, error: '只支持 SSH 地址 — HTTPS 要把令牌写进 URL 或存明文凭据,易随 git remote -v / 日志泄露' }
|
|
28
|
-
if (!SSH_SCP.test(u) && !SSH_URL.test(u)) {
|
|
29
|
-
return { ok: false, error: '不是合法的 SSH 仓库地址,应形如 git@codeup.aliyun.com:org/repo.git' }
|
|
30
|
-
}
|
|
31
|
-
return { ok: true, url: u }
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// 只带 git 用得上的 env。BatchMode/GIT_TERMINAL_PROMPT 是硬要求:无头环境下任何交互
|
|
35
|
-
// 提示(输密码、确认 host key)都会把请求挂死 —— sbot 的头号纪律是绝不挂起。
|
|
36
|
-
function gitEnv() {
|
|
37
|
-
const SAFE = ['PATH', 'PATHEXT', 'SystemRoot', 'HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'LANG', 'LC_ALL', 'TEMP', 'TMP']
|
|
38
|
-
const env = {}
|
|
39
|
-
for (const k of SAFE) if (process.env[k] !== undefined) env[k] = process.env[k]
|
|
40
|
-
env.GIT_TERMINAL_PROMPT = '0'
|
|
41
|
-
env.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes -o StrictHostKeyChecking=yes -o ConnectTimeout=10'
|
|
42
|
-
return env
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function git(cwd, args, timeout = 20000) {
|
|
46
|
-
return new Promise((resolve) => {
|
|
47
|
-
execFile('git', args, { cwd, timeout, windowsHide: true, env: gitEnv(), maxBuffer: 2 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
48
|
-
const out = ((stdout || '') + (stderr ? (stdout ? '\n' : '') + stderr : '')).trim()
|
|
49
|
-
resolve({ ok: !err, out, code: err ? (err.code ?? null) : 0 })
|
|
50
|
-
})
|
|
51
|
-
})
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// 把 git/ssh 的英文错误翻成 operator 能照着做的一句话。凭据/主机密钥是这条链路最常见的两种挂法。
|
|
55
|
-
function humanizeGitError(out) {
|
|
56
|
-
const s = String(out || '')
|
|
57
|
-
if (/Permission denied \(publickey|Permission denied, please try again/i.test(s)) {
|
|
58
|
-
// ssh 对「有 key 但没被接受」和「压根没有 key 可提供」返回同一句 Permission denied (publickey),
|
|
59
|
-
// 但两者修法完全不同 ⇒ 两种都点出来,别让人以为一定是加错了账号。
|
|
60
|
-
return '凭据不通:这台机器没有能被该主机接受的 SSH key — 可能是①这台机器上还没配 key(~/.ssh 里没有对应私钥,或 ~/.ssh/config 没指向它),或②公钥没加到对应账号 / 该账号对本仓库无权限'
|
|
61
|
-
}
|
|
62
|
-
if (/Host key verification failed|No ED25519 host key is known|authenticity of host/i.test(s)) {
|
|
63
|
-
return '主机密钥未预置:在该机器上先跑 ssh-keyscan <host> >> ~/.ssh/known_hosts(无头环境无法交互确认)'
|
|
64
|
-
}
|
|
65
|
-
if (/Repository path not found|repository .* not found|does not appear to be a git repository|找不到代码库/i.test(s)) {
|
|
66
|
-
return '仓库不存在,或当前凭据对它无权限(远端对无权限的库通常也报「找不到」)'
|
|
67
|
-
}
|
|
68
|
-
if (/Could not resolve hostname|Name or service not known/i.test(s)) return '域名解析失败:检查网络 / 代理设置'
|
|
69
|
-
if (/Connection timed out|Operation timed out|timed out/i.test(s)) return '连接超时:检查网络 / 防火墙,或该主机的 SSH 端口不通'
|
|
70
|
-
if (/Connection refused/i.test(s)) return '连接被拒:该主机的 SSH 端口不可达'
|
|
71
|
-
return s.split('\n').filter(Boolean).slice(-2).join(' · ') || '未知错误'
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// 路径比对一律过 realpath:git 的 --show-toplevel 返回**解析过符号链接**的真实路径,而
|
|
75
|
-
// 配置里的 workdir 常是带软链的写法(macOS 的 /var→/private/var、或 ~/Projects 是软链)。
|
|
76
|
-
// 直接 resolve 比对会把「本目录自己的仓库」误判成「嵌套在别人的仓库里」。
|
|
77
|
-
const realpath = (p) => { try { return fs.realpathSync(p) } catch { return path.resolve(p) } }
|
|
78
|
-
|
|
79
|
-
// workdir 的 git 现状:是不是仓库、origin 指向哪、当前分支(空仓库也能拿到)。
|
|
80
|
-
async function repoStatus(workdir) {
|
|
81
|
-
if (!workdir || !fs.existsSync(workdir)) return { isRepo: false, origin: '', branch: '' }
|
|
82
|
-
// --git-dir 存在 ≠ 就是本目录的仓库(可能是父目录的);用 rev-parse 拿顶层再比对
|
|
83
|
-
const top = await git(workdir, ['rev-parse', '--show-toplevel'])
|
|
84
|
-
if (!top.ok) return { isRepo: false, origin: '', branch: '' }
|
|
85
|
-
const isSelf = realpath(top.out) === realpath(workdir)
|
|
86
|
-
if (!isSelf) return { isRepo: false, origin: '', branch: '', nestedIn: top.out }
|
|
87
|
-
const o = await git(workdir, ['remote', 'get-url', 'origin'])
|
|
88
|
-
const b = await git(workdir, ['symbolic-ref', '--short', 'HEAD']) // 空仓库(无 commit)也返回默认分支名
|
|
89
|
-
return { isRepo: true, origin: o.ok ? o.out : '', branch: b.ok ? b.out : '' }
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// 建立关联(幂等):不是仓库就 git init,然后 origin add/set-url。
|
|
93
|
-
// 分支不指定 —— 跟随该机器的 init.defaultBranch,不替 operator 拍板 main/master。
|
|
94
|
-
async function linkRepo(workdir, url) {
|
|
95
|
-
const v = validateRepoUrl(url)
|
|
96
|
-
if (!v.ok) return v
|
|
97
|
-
if (!workdir || !fs.existsSync(workdir)) return { ok: false, error: `workdir 不存在: ${workdir}` }
|
|
98
|
-
let st = await repoStatus(workdir)
|
|
99
|
-
if (st.nestedIn) return { ok: false, error: `该目录在另一个 git 仓库内(${st.nestedIn}),拒绝在此 git init` }
|
|
100
|
-
let initialized = false
|
|
101
|
-
if (!st.isRepo) {
|
|
102
|
-
const r = await git(workdir, ['init'])
|
|
103
|
-
if (!r.ok) return { ok: false, error: 'git init 失败:' + humanizeGitError(r.out) }
|
|
104
|
-
initialized = true
|
|
105
|
-
st = await repoStatus(workdir)
|
|
106
|
-
}
|
|
107
|
-
const r2 = st.origin
|
|
108
|
-
? await git(workdir, ['remote', 'set-url', 'origin', v.url])
|
|
109
|
-
: await git(workdir, ['remote', 'add', 'origin', v.url])
|
|
110
|
-
if (!r2.ok) return { ok: false, error: '设置 origin 失败:' + humanizeGitError(r2.out) }
|
|
111
|
-
const after = await repoStatus(workdir)
|
|
112
|
-
return { ok: true, url: v.url, initialized, replaced: !!st.origin && st.origin !== v.url, branch: after.branch }
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// —— 轻量状态(纯 fs,不 spawn git)——
|
|
116
|
-
// 群卡右上角的「仓库 ✓」标签要反映**真实**关联,不能只回显配置里的字符串(origin 被手改/删掉时
|
|
117
|
-
// 标签还绿着 = 骗人)。但 buildState 每个 API 响应都跑一遍、per-group spawn git 扛不住,所以
|
|
118
|
-
// 这里直接读 .git/config —— 一次小文件读,和 existsSync 同量级。
|
|
119
|
-
function readOriginFromGitConfig(workdir) {
|
|
120
|
-
try {
|
|
121
|
-
const gitPath = path.join(workdir, '.git')
|
|
122
|
-
const st = fs.statSync(gitPath)
|
|
123
|
-
if (!st.isDirectory()) return null // .git 是文件(worktree/submodule)—— 少见,不猜,交给慢路径
|
|
124
|
-
const text = fs.readFileSync(path.join(gitPath, 'config'), 'utf8')
|
|
125
|
-
const sec = text.match(/\[remote\s+"origin"\][^[]*/) // 该 section 到下一个 [ 之前
|
|
126
|
-
if (!sec) return ''
|
|
127
|
-
const u = sec[0].match(/^\s*url\s*=\s*(.+)$/m)
|
|
128
|
-
return u ? u[1].trim() : ''
|
|
129
|
-
} catch { return null } // 不是仓库 / 读不动
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// '' = 没配仓库(不显示标签) · ok = origin 与配置一致 · missing = 已不是 git 仓库 · drift = origin 对不上
|
|
133
|
-
function quickLinkState(workdir, configuredRepo) {
|
|
134
|
-
if (!configuredRepo) return ''
|
|
135
|
-
const origin = readOriginFromGitConfig(workdir)
|
|
136
|
-
if (origin === null) return 'missing'
|
|
137
|
-
return origin === configuredRepo ? 'ok' : 'drift'
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// 连通性 + 权限探测:ls-remote 不落地任何东西,是最轻的「这把凭据能不能用」的问法。
|
|
141
|
-
async function testRemote(url) {
|
|
142
|
-
const v = validateRepoUrl(url)
|
|
143
|
-
if (!v.ok) return v
|
|
144
|
-
const r = await git(process.cwd(), ['ls-remote', '--heads', v.url], 25000)
|
|
145
|
-
if (!r.ok) return { ok: false, error: humanizeGitError(r.out) }
|
|
146
|
-
const branches = r.out.split('\n').filter((l) => /refs\/heads\//.test(l)).length
|
|
147
|
-
return { ok: true, branches, empty: branches === 0 }
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
module.exports = { validateRepoUrl, repoStatus, linkRepo, testRemote, humanizeGitError, readOriginFromGitConfig, quickLinkState }
|
package/src/health.js
DELETED
|
@@ -1,353 +0,0 @@
|
|
|
1
|
-
// Shared health check — the data behind `sbot doctor` and the console's health
|
|
2
|
-
// pill. Returns structured rows so both the CLI (renders to stdout) and the UI
|
|
3
|
-
// server (serves as JSON) work off one source of truth.
|
|
4
|
-
|
|
5
|
-
const fs = require('node:fs')
|
|
6
|
-
const net = require('node:net')
|
|
7
|
-
const path = require('node:path')
|
|
8
|
-
const { probeVersion, supportsFlag } = require('./agent/claudecode')
|
|
9
|
-
const codex = require('./agent/codexcode')
|
|
10
|
-
const claudestatus = require('./agent/claudestatus')
|
|
11
|
-
const codexstatus = require('./agent/codexstatus')
|
|
12
|
-
const projectsroot = require('./projectsroot')
|
|
13
|
-
const verify = require('./core/verify') // 凭据最近一次验证的记录:模型 key、飞书、Slack 行据此区分「配了」与「能用」
|
|
14
|
-
const leftover = require('./leftover') // 遗留进程:agent 轮次结束后仍在跑的后台 shell(v0.11.108)
|
|
15
|
-
|
|
16
|
-
// A provider whose baseUrl is loopback depends on a locally-run translation router
|
|
17
|
-
// (claude-code-router / LiteLLM) to reach a non-Anthropic model like GPT — OpenAI has
|
|
18
|
-
// no Anthropic endpoint, so `claude` can't hit it directly. Return {host,port} for a
|
|
19
|
-
// loopback baseUrl (so doctor can probe the router), else null. (The runtime vision
|
|
20
|
-
// strip-proxy also uses a loopback base, but that's injected at spawn, never in config.)
|
|
21
|
-
function loopbackTarget(baseUrl) {
|
|
22
|
-
try {
|
|
23
|
-
const u = new URL(baseUrl)
|
|
24
|
-
const host = u.hostname
|
|
25
|
-
if (!['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(host)) return null
|
|
26
|
-
// 0.0.0.0 / :: are bind-all addresses, not connect targets — Windows rejects
|
|
27
|
-
// connecting to them (WSAEADDRNOTAVAIL). Dial loopback instead so the probe
|
|
28
|
-
// reflects reachability rather than a platform quirk.
|
|
29
|
-
const dial = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host
|
|
30
|
-
return { host: dial, port: Number(u.port) || (u.protocol === 'https:' ? 443 : 80) }
|
|
31
|
-
} catch { return null }
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// PATH 里找可执行文件(同步、零依赖;doctor 与控制台都调,不能起子进程)
|
|
35
|
-
function whichSync(name) {
|
|
36
|
-
if (!name) return null
|
|
37
|
-
if (name.includes('/') || name.includes('\\')) return fs.existsSync(name) ? name : null
|
|
38
|
-
const exts = process.platform === 'win32' ? (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';') : ['']
|
|
39
|
-
for (const dir of String(process.env.PATH || '').split(path.delimiter)) {
|
|
40
|
-
if (!dir) continue
|
|
41
|
-
for (const ext of exts) { const p = path.join(dir, name + ext); try { if (fs.statSync(p).isFile()) return p } catch {} }
|
|
42
|
-
}
|
|
43
|
-
return null
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Minimal zero-dep TCP reachability probe with a short timeout. Success just means
|
|
47
|
-
// the router is listening — we don't validate the translation, only that it's up, so
|
|
48
|
-
// doctor fails fast (NFR2) instead of the provider hanging mid-turn when it's down.
|
|
49
|
-
function probeTcp(host, port, timeoutMs = 1500) {
|
|
50
|
-
return new Promise((resolve) => {
|
|
51
|
-
const sock = net.connect({ host, port })
|
|
52
|
-
let done = false
|
|
53
|
-
const finish = (ok, error) => { if (done) return; done = true; try { sock.destroy() } catch {} resolve({ ok, error }) }
|
|
54
|
-
sock.setTimeout(timeoutMs)
|
|
55
|
-
sock.on('connect', () => finish(true))
|
|
56
|
-
sock.on('timeout', () => finish(false, 'timeout'))
|
|
57
|
-
sock.on('error', (e) => finish(false, e.code || e.message))
|
|
58
|
-
})
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async function checkHealth(cfg, opts = {}) {
|
|
62
|
-
const rows = []
|
|
63
|
-
const add = (status, label, detail) => rows.push({ status, label, detail })
|
|
64
|
-
|
|
65
|
-
const nodeOk = Number(process.versions.node.split('.')[0]) >= 22
|
|
66
|
-
add(nodeOk ? 'ok' : 'warn', 'node', process.version + (nodeOk ? '' : ' (need ≥ 22)'))
|
|
67
|
-
// 配置里有密钥:组 / 其他人能读就提醒(Windows 没有这套权限位,不查)
|
|
68
|
-
let cfgStatus = cfg._path ? 'ok' : 'warn'
|
|
69
|
-
let cfgDetail = cfg._path || '(built-in defaults — run setup)'
|
|
70
|
-
if (cfg._path && process.platform !== 'win32') {
|
|
71
|
-
try {
|
|
72
|
-
const m = fs.statSync(cfg._path).mode & 0o777
|
|
73
|
-
if (m & 0o077) { cfgStatus = 'warn'; cfgDetail += `(权限 ${m.toString(8)}:里面有密钥,建议 chmod 600)` }
|
|
74
|
-
} catch { /* 读不到就不评 */ }
|
|
75
|
-
}
|
|
76
|
-
add(cfgStatus, 'config', cfgDetail)
|
|
77
|
-
|
|
78
|
-
const v = await probeVersion(cfg.agent.bin)
|
|
79
|
-
add(v.ok ? 'ok' : 'fail', 'claude', v.ok ? v.version : v.error)
|
|
80
|
-
// 长会话自动压缩:老 CLI 不认 `--autocompact`,spawn 侧会自动不传(否则每轮必崩)。
|
|
81
|
-
// ⚠ 但**降级必须看得见** —— 不说的话症状是「会话越长越糊」,没人会想到是 CLI 版本(教训 26)。
|
|
82
|
-
// 阈值为 0 = operator 主动关掉,不必报。
|
|
83
|
-
if (v.ok && (cfg.agent.autocompactTokens || 0) > 0) {
|
|
84
|
-
const sup = await supportsFlag(cfg.agent.bin, '--autocompact')
|
|
85
|
-
add(sup === true ? 'ok' : 'warn', 'agent.autocompact',
|
|
86
|
-
sup === true ? `可用(阈值 ${cfg.agent.autocompactTokens})`
|
|
87
|
-
: sup === false ? '⚠ 该 claude 版本不认 --autocompact ⇒ 长会话自动压缩已停用,升级 claude 恢复'
|
|
88
|
-
: '⚠ 能力探测失败(--help 没跑通)⇒ 保守起见不传该旗子,长会话不会自动压缩')
|
|
89
|
-
}
|
|
90
|
-
add(cfg.agent.permissionMode === 'dontAsk' ? 'ok' : 'warn', 'permissionMode',
|
|
91
|
-
cfg.agent.permissionMode + (cfg.agent.permissionMode === 'dontAsk' ? '' : ' (recommend dontAsk)'))
|
|
92
|
-
|
|
93
|
-
if (cfg.proxy && cfg.proxy.http) add('ok', 'proxy', cfg.proxy.http)
|
|
94
|
-
|
|
95
|
-
// 常驻浏览器(docs/BROWSER.md):群 agent 的网页操作与控制台「接管浏览器」页都靠它。探到就报 ok;探不到时只有
|
|
96
|
-
// 显式配了 browser 段才 warn —— 没配又探不到,多半是这台机器本来就没有常驻浏览器(Windows 常见),不出行不吓人。
|
|
97
|
-
{
|
|
98
|
-
const cdp = require('./cdp'); const cdpUrl = String((cfg.browser || {}).cdpUrl || cdp.DEFAULT_CDP_URL).replace(/\/+$/, '')
|
|
99
|
-
let v = null; try { v = await cdp.versionInfo(cdpUrl, { timeoutMs: 1000 }) } catch { v = null } // 本机端口,1 秒探不到就当没有,别拖慢 doctor
|
|
100
|
-
if (v) add('ok', 'browser', `${(v && v.Browser) || '已连上'} · ${cdpUrl} · 接管页可用`)
|
|
101
|
-
else if (cfg.browser) add('warn', 'browser', `浏览器没连上(${cdpUrl}):群里的网页操作与「接管浏览器」页都用不了 —— 先把常驻浏览器起来`)
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// 遗留进程(src/leftover.js,v0.11.108):agent 轮次结束后仍活着的后台 shell。它们零 CPU、不报错、
|
|
105
|
-
// 不在任何日志里 —— 2026-10-09 翻出一条每 5 秒打线上云函数、跑了 35 天的循环,正是因为没有一处看得见。
|
|
106
|
-
// 有就 warn(列最老的几条),没有 ok,Windows 不支持就一行不占。opts.leftover 仅测试注入。
|
|
107
|
-
try {
|
|
108
|
-
const lf = await (opts.leftover || (() => leftover.cachedList()))()
|
|
109
|
-
const d = leftover.describe(lf)
|
|
110
|
-
if (d) add(d.status, 'leftover', d.text)
|
|
111
|
-
} catch {}
|
|
112
|
-
|
|
113
|
-
// 图标库(docs/ICONS.md):几个包合计几十 MB,不随 sbot 分发 ⇒ 机器上单独装。
|
|
114
|
-
// ⚠ **没装不是 fail** —— 教训 6:这类策略对半可信 agent 本质不可强制,硬拦只会挡住正当情况;
|
|
115
|
-
// 真防线是「正路存在 + 如实可见」。所以没装记 warn 并给装法,agent 会退回自创 path。
|
|
116
|
-
// ⚠ **判据是「缺了几个」不是「有没有一个」**:原先只要装了 lucide 就报 ok —— 而一个选了
|
|
117
|
-
// phosphor 却没装该包的项目会 doctor 全绿、控制台却挂着「库未安装」,**两个消费者两套口径**。
|
|
118
|
-
// 装法也要列**全部缺的包**,不能停在最早那两个(每加一套系统这行都得跟着长)。
|
|
119
|
-
{
|
|
120
|
-
const design = require('./design')
|
|
121
|
-
const roots = design.iconRoots({ base: require('node:path').join(__dirname, '..'), extra: (cfg.design && cfg.design.iconsDir) || null })
|
|
122
|
-
const has = Object.entries(roots).filter(([, v]) => v).map(([k]) => k)
|
|
123
|
-
const miss = Object.entries(roots).filter(([, v]) => !v).map(([k]) => k)
|
|
124
|
-
const pkgs = miss.map((k) => (design.ICON_LIBS[k] || {}).pkg).filter(Boolean).join(' ')
|
|
125
|
-
add(miss.length ? 'warn' : 'ok', 'design.icons',
|
|
126
|
-
miss.length
|
|
127
|
-
? `缺 ${miss.join(' / ')}${has.length ? `(已装 ${has.join(' + ')})` : ''} — \`npm i -g ${pkgs}\`;选了缺的那套时取不到图标,agent 只能自己画,与设计图对不上`
|
|
128
|
-
: has.join(' + ') + ' 已装(`sbot design icon` 可用)')
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// 运维告警群(alerts.chatId):模型账号异常 / 额度用完 / 自动切换发到这里。没配不算错,但要说清后果 ——
|
|
132
|
-
// 账号类异常此前只在出事的群里说一句,operator 不在那个群就一无所知(09-25 OAuth 过期 10 小时无人知)。
|
|
133
|
-
{
|
|
134
|
-
const a = cfg.alerts && cfg.alerts.chatId != null ? String(cfg.alerts.chatId).trim() : ''
|
|
135
|
-
if (!a) add('ok', 'alerts', '未配运维告警群(可选)— 模型账号异常 / 额度用完只会在出事的群里说')
|
|
136
|
-
else if (cfg.groups && cfg.groups[a]) add('warn', 'alerts', '运维告警群同时绑定了项目 — 它应保持未绑定,否则助手会在里面干活、告警被对话淹掉')
|
|
137
|
-
else add('ok', 'alerts', '运维告警群已配 — 模型账号异常 / 额度用完 / 自动切换 / 平台连接中断会通知到那里' + (require('./config').chatPlatform(cfg, a) === 'wecom' ? '(企业微信群:要先在群里 @ 过机器人,否则推不进去)' : ''))
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// codex 登录态:`codex login status` 只读本机状态(已登录退出码 0)。provider 行和指向 codex 的备用模型行
|
|
141
|
-
// 共用一次探测;opts.codexLogin 仅测试注入。
|
|
142
|
-
let codexLoginP = null
|
|
143
|
-
const codexLogin = () => codexLoginP || (codexLoginP = (opts.codexLogin || (() => codexstatus.cachedLoginStatus('codex')))().catch(() => null))
|
|
144
|
-
const codexLoggedOut = async () => { const s = await codexLogin(); return !!(s && s.ok && s.loggedIn === false) }
|
|
145
|
-
|
|
146
|
-
// 有没有人在用:默认模型、有群选它、或是谁的备用。没人用的模型出了问题只提醒不报错
|
|
147
|
-
// (只用 DeepSeek 的新手,配置里留着别的预设,不该让体检一上来就是红的)
|
|
148
|
-
const inUse = (name) => name === cfg.defaultProvider
|
|
149
|
-
|| Object.values(cfg.groups || {}).some((g) => g && g.provider === name)
|
|
150
|
-
|| Object.values(cfg.providers).some((q) => q && !q.hidden && q.fallback === name)
|
|
151
|
-
const verifyFile = opts.verifyFile || (opts.home ? path.join(opts.home, '.sbot', 'verify.json') : undefined)
|
|
152
|
-
const lookup = (fp) => verify.lookup(fp, verifyFile ? { file: verifyFile } : undefined)
|
|
153
|
-
for (const [name, p] of Object.entries(cfg.providers)) {
|
|
154
|
-
if (p.hidden) continue // operator 主动撤下 — 不进健康检查(与 UI 隐藏一致)
|
|
155
|
-
// 额度自动切换的备用模型(v0.11.107):写错名字 / 指到隐藏的 / 指到自己,运行时只是静默不切 —— 这里把它说出来
|
|
156
|
-
if (p.fallback != null) {
|
|
157
|
-
const to = String(p.fallback).trim()
|
|
158
|
-
const t = cfg.providers[to]
|
|
159
|
-
if (!to || to === name) add('warn', `fallback:${name}`, `备用模型不能是自己或空(${JSON.stringify(p.fallback)})`)
|
|
160
|
-
else if (!t) add('warn', `fallback:${name}`, `备用模型「${to}」不存在 — 额度用完时不会自动切换`)
|
|
161
|
-
else if (t.hidden) add('warn', `fallback:${name}`, `备用模型「${to}」已隐藏 — 额度用完时不会自动切换`)
|
|
162
|
-
else if (t.runner === 'codex' && await codexLoggedOut()) add('warn', `fallback:${name}`, `备用模型「${to}」的 codex 没登录 — 额度用完时切不过去;跑一次 codex login`)
|
|
163
|
-
else add('ok', `fallback:${name}`, `额度用完 / 账号不可用时自动切到 ${to}`)
|
|
164
|
-
}
|
|
165
|
-
if (p.baseUrl) {
|
|
166
|
-
// 配了 key 不等于能用:看最近一次验证(向导试跑、控制台测试、真实轮次都会记;换了 key 就回到「未验证」)
|
|
167
|
-
const v = p.apiKey ? lookup(verify.providerFp(p)) : null
|
|
168
|
-
if (!p.apiKey) add('warn', `provider:${name}`, '没配 apiKey')
|
|
169
|
-
else if (v && v.ok) add('ok', `provider:${name}`, `能用(${verify.when(v.at)} 验证通过)`)
|
|
170
|
-
else if (v) add(inUse(name) ? 'fail' : 'warn', `provider:${name}`, `上次验证失败(${verify.when(v.at)}):${v.reason || 'key 不对'}`)
|
|
171
|
-
else add('warn', `provider:${name}`, `已配 key,还没验证 — 控制台「模型」页点测试,或 sintbot agent run --provider ${name} "你好"`)
|
|
172
|
-
// Router-backed provider (loopback base = local translation router for GPT/非-Anthropic):
|
|
173
|
-
// probe it so doctor reports the router being down, rather than the turn hanging (NFR2).
|
|
174
|
-
const lb = loopbackTarget(p.baseUrl)
|
|
175
|
-
if (lb) {
|
|
176
|
-
const r = await probeTcp(lb.host, lb.port)
|
|
177
|
-
add(r.ok ? 'ok' : 'fail', `router:${name}`,
|
|
178
|
-
r.ok ? `在线 ${lb.host}:${lb.port}` : `连不上 ${lb.host}:${lb.port} — 先把翻译代理起来,否则这个模型的轮次会立刻失败`)
|
|
179
|
-
}
|
|
180
|
-
} else if (p.runner === 'codex') {
|
|
181
|
-
// codex provider 无 baseUrl/apiKey —— 认证是机器上 `codex login` 的 ChatGPT 订阅,跟 Claude 登录态无关。
|
|
182
|
-
// 探不出来(没装 / 超时)才标「未验证」;没装由下面的 codex 行报。
|
|
183
|
-
const s = await codexLogin()
|
|
184
|
-
if (s && s.ok && s.loggedIn) add('ok', `provider:${name}`, s.mode === 'apikey' ? 'codex 已登录(API key,按量计费)' : 'codex 订阅已登录')
|
|
185
|
-
else if (s && s.ok) add('warn', `provider:${name}`, '没登录 — 在这台机器上跑 codex login(没图形界面用 codex login --device-auth)')
|
|
186
|
-
else add('ok', `provider:${name}`, 'codex 订阅登录(未验证)')
|
|
187
|
-
} else {
|
|
188
|
-
// 无 baseUrl 的非 codex provider = **Claude 订阅登录**(不填端点/key 就是它的正确形态)。
|
|
189
|
-
// 此前这里只能写死「登录态(未验证)」—— 真没登录也照样绿,要到群里跑一轮才炸。
|
|
190
|
-
// `claude auth status --json` 是官方非交互子命令(0.2s、不吃订阅额度),据此报真状态。
|
|
191
|
-
// 只有 provider 真在用(未隐藏)才探:比如 claude 标了 hidden 的部署,上面已 continue,零噪音。
|
|
192
|
-
// 没登录但没人用(不是默认、没有群选它、也不是谁的备用)⇒ 只提醒不报错:只用 DeepSeek 等 API key 模型的
|
|
193
|
-
// 新手,配置里留着这一行,不该让体检一上来就是红的。opts.claudeLogin 仅测试注入。
|
|
194
|
-
const s = await (opts.claudeLogin ? opts.claudeLogin() : claudestatus.cachedLoginStatus(cfg.agent.bin))
|
|
195
|
-
const d = claudestatus.describeLogin(s)
|
|
196
|
-
if (d.level === 'fail' && !inUse(name)) add('warn', `provider:${name}`, '未登录 — 目前没有群在用;要用 Claude 订阅时在这台机器上跑 claude auth login')
|
|
197
|
-
else add(d.level, `provider:${name}`, d.text)
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Codex CLI backend (GPT): only probed when a provider actually uses it, so users
|
|
202
|
-
// without a codex provider don't see a spurious row. Verifies the binary; login is
|
|
203
|
-
// reported on the provider row above.
|
|
204
|
-
const hasCodex = Object.values(cfg.providers).some((p) => p && !p.hidden && p.runner === 'codex')
|
|
205
|
-
if (hasCodex) {
|
|
206
|
-
const cv = await codex.probeVersion()
|
|
207
|
-
add(cv.ok ? 'ok' : 'fail', 'codex', cv.ok ? cv.version : cv.error)
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// 常驻浏览器(chrome-devtools 类 MCP 的 --browserUrl):机器级 MCP 配置里指了就探活 ——
|
|
211
|
-
// 浏览器实例没起时,agent 要到轮中调工具才失败,doctor 先报(NFR2 同款思路,与 router 行
|
|
212
|
-
// 一致)。没配置则一行不加;两侧(claude/codex)常指同一实例,按 url 去重、来源合并展示。
|
|
213
|
-
// opts.home 仅测试注入 —— 生产读真实 home 下的 ~/.claude.json 与 ~/.codex/config.toml。
|
|
214
|
-
try {
|
|
215
|
-
const byUrl = new Map()
|
|
216
|
-
for (const t of require('./plugins').browserTargets(opts.home)) {
|
|
217
|
-
const e = byUrl.get(t.url) || { name: t.name, sources: [] }
|
|
218
|
-
if (!e.sources.includes(t.source)) e.sources.push(t.source)
|
|
219
|
-
byUrl.set(t.url, e)
|
|
220
|
-
}
|
|
221
|
-
for (const [url, e] of byUrl) {
|
|
222
|
-
let host = null, port = null
|
|
223
|
-
try {
|
|
224
|
-
const u = new URL(url)
|
|
225
|
-
host = u.hostname === '0.0.0.0' || u.hostname === '::' ? '127.0.0.1' : u.hostname
|
|
226
|
-
port = Number(u.port) || (u.protocol === 'https:' ? 443 : 80)
|
|
227
|
-
} catch {}
|
|
228
|
-
if (!host) { add('warn', `browser:${e.name}`, `无法解析 --browserUrl:${url}`); continue }
|
|
229
|
-
const r = await probeTcp(host, port)
|
|
230
|
-
add(r.ok ? 'ok' : 'fail', `browser:${e.name}`,
|
|
231
|
-
r.ok ? `up ${host}:${port}(${e.sources.join('+')})` : `DOWN ${host}:${port} — MCP 指向的常驻浏览器不可达,浏览器工具会失败(确认该 Chrome 实例在运行)`)
|
|
232
|
-
}
|
|
233
|
-
} catch {}
|
|
234
|
-
|
|
235
|
-
// MCP 可达性(2026-09-05):群里勾选的 MCP 连不上时,**每一轮 spawn 都要替它付一次启动失败**
|
|
236
|
-
// (实测一个没起的本地 http MCP:22 个流文件 129 处 transport 致命错,每次重试若干轮才放弃),
|
|
237
|
-
// 而此前 doctor / 控制台 / 群里没有任何一处说过它连不上 —— 勾选框看着一切正常。
|
|
238
|
-
// ⚠ **连不上是 warn 不是 fail**:库里躺着的多半是 operator 别的项目才起的服务,没起是常态,
|
|
239
|
-
// 不该让整机体检变红(真正要报的是「它被某个群勾着、正在每轮烧钱」,故顺带报勾选它的群数)。
|
|
240
|
-
// ⚠ unknown(公网直连没通)也只 warn 且措辞不同 —— daemon 直连不走代理,失败不代表 agent 用不了。
|
|
241
|
-
// opts.home 仅测试注入。
|
|
242
|
-
try {
|
|
243
|
-
const groups = Object.entries(cfg.groups || {}).filter(([id, g]) => g && id !== 'oc_example_chat_id').map(([, g]) => g)
|
|
244
|
-
const enabledBy = (name) => groups.filter((g) => g.plugins && Array.isArray(g.plugins.mcp) && g.plugins.mcp.includes(name)).length
|
|
245
|
-
// ⚠ 没配过插件的群是「默认加载」:claude 自己读用户级全部 MCP,**不经过跳过机制**(claudecode.js 只对配了
|
|
246
|
-
// plugins 的群走 buildMcpConfig)。它们才是每轮真在替死 MCP 付启动失败的那批 —— 对它们说「会跳过」是假话,
|
|
247
|
-
// 得分开说清。codex 群不读这份库,不算在内。
|
|
248
|
-
const isCodexGroup = (g) => { const p = (cfg.providers || {})[g.provider || cfg.defaultProvider]; return !!(p && p.runner === 'codex') }
|
|
249
|
-
const defaultLoaders = groups.filter((g) => !g.plugins && !isCodexGroup(g)).length
|
|
250
|
-
// opts.mcpProbe 仅测试注入探针(生产走默认的 TCP / 可执行文件探针)
|
|
251
|
-
for (const r of await require('./plugins').mcpReachability(opts.home, opts.mcpProbe || {})) {
|
|
252
|
-
if (r.status === 'unprobed') continue // 判不了的形态不占行(相对路径 / 非 http 端点)
|
|
253
|
-
const n = enabledBy(r.name)
|
|
254
|
-
const used = n ? `${n} 个群勾着它` : '当前没有群勾选'
|
|
255
|
-
const dl = defaultLoaders ? `;另有 ${defaultLoaders} 个群没配过插件、按默认加载全部 —— 这些群不会跳过,每轮都替它付一次启动失败,要跳过就在群卡保存一次勾选` : ''
|
|
256
|
-
add(r.status === 'ok' ? 'ok' : 'warn', `mcp:${r.name}`,
|
|
257
|
-
r.status === 'ok' ? r.detail
|
|
258
|
-
: r.status === 'down' ? `⚠ ${r.detail} — ${used};勾了它的群会跳过注入(否则每轮白付一次启动失败),服务起来后自动恢复${dl}`
|
|
259
|
-
: `⚠ 无法确认可达:${r.detail} — ${used};仍会照常注入(本机直连不走代理,探不通不等于不可用)`)
|
|
260
|
-
}
|
|
261
|
-
} catch {}
|
|
262
|
-
|
|
263
|
-
// 平台(双平台并存):配了哪个就报哪个 —— feishu 块 / wecom 块各按自己的配齐程度出 ok / fail;两个都没配只 warn
|
|
264
|
-
// 一行(doctor 在开发机上也跑,没配平台是常态,只影响 `sbot start`)。顶层 platform 字段已废弃:写着就提一句。
|
|
265
|
-
const { platformsConfigured, PLATFORMS } = require('./config')
|
|
266
|
-
const plat = platformsConfigured(cfg)
|
|
267
|
-
const PLN = { feishu: '飞书', wecom: '企业微信', slack: 'Slack' } // doctor 是 CLI 输出,平台中文名就写在这
|
|
268
|
-
const livePlats = PLATFORMS.filter((p) => plat[p])
|
|
269
|
-
if (cfg._deprecatedPlatform !== undefined) add('warn', 'platform', '顶层 platform 字段已废弃、被忽略 — 现在配了哪个平台就接哪个,每个群自己带 platform 字段;删掉这行即可')
|
|
270
|
-
if (cfg.feishu) {
|
|
271
|
-
const hasId = !!cfg.feishu.appId, hasSecret = !!cfg.feishu.appSecret
|
|
272
|
-
if (hasId && hasSecret) {
|
|
273
|
-
// 凭据能不能用看最近一次预检(向导、控制台、doctor 现场都会记;换了凭据就回到「未预检」)
|
|
274
|
-
const v = lookup(verify.feishuFp(cfg.feishu))
|
|
275
|
-
if (v && !v.ok) add('fail', 'feishu', `上次预检失败(${verify.when(v.at)}):${v.reason}`)
|
|
276
|
-
else add('ok', 'feishu', v ? `飞书应用能用(${verify.when(v.at)} 预检通过)` : '飞书应用已配(appId + appSecret),还没预检')
|
|
277
|
-
if (!cfg.feishu.botOpenId) add('warn', 'feishu.botOpenId', (cfg.feishu.onlyWhenMentioned
|
|
278
|
-
? '没填 — 开着「只在 @ 时回复」却认不出哪个 @ 是机器人,群里一条都不会回'
|
|
279
|
-
: '没填 — 分不清 @机器人 和 @别人,群里 @ 别人的消息也会接') + ';重跑 sintbot setup 选飞书预检可自动填')
|
|
280
|
-
} else if (!hasId && !hasSecret) { if (!plat.wecom && !plat.slack) add('warn', 'platforms', '还没接聊天平台 — 在控制台「设置」里接飞书 / Slack / 企业微信') }
|
|
281
|
-
else add('fail', 'feishu', hasId ? '缺 appSecret' : '缺 appId')
|
|
282
|
-
} else if (!plat.wecom && !plat.slack) {
|
|
283
|
-
add('warn', 'platforms', '还没接聊天平台 — 在控制台「设置」里接飞书 / Slack / 企业微信')
|
|
284
|
-
}
|
|
285
|
-
// 写了 wecom 块就要报:env: 引用解析成空串也算缺(真机踩到 —— 非交互 shell 没 source 凭据文件,doctor 一声不吭地把群报成「没配企微」)
|
|
286
|
-
if (cfg.wecom) {
|
|
287
|
-
const w = cfg.wecom
|
|
288
|
-
if (plat.wecom) add('ok', 'wecom', '企业微信机器人已配(长连接,免公网)' + (w.botName ? ` · ${w.botName}` : ''))
|
|
289
|
-
else add('fail', 'wecom', (w.botId ? '缺 wecom.secret' : w.secret ? '缺 wecom.botId' : '缺 wecom.botId 与 secret') + '(env: 引用的环境变量为空也算缺)')
|
|
290
|
-
// 官方命令行工具:发送者姓名、群内发图发文件都靠它;没装只降级(群里显示账号而非姓名、助手发不了图)
|
|
291
|
-
if (w.cli === false) { /* 明确关掉:不搜姓名、基础技能不用,不占行 */ }
|
|
292
|
-
else if (whichSync(w.cli || 'wecom-cli')) add('ok', 'wecom.cli', '企业微信命令行工具已装 — 群里显示成员姓名,助手可发图发文件')
|
|
293
|
-
else add('warn', 'wecom.cli', '企业微信命令行工具未装 — 群里只显示成员账号、助手不能发图发文件、「不 @ 也回复」不生效(装好后按机器人凭据授权一次即可)')
|
|
294
|
-
// 「不 @ 也回复」:轮询群历史(零 token)+ 规则门控;只报口径,让 operator 知道现在是哪一档
|
|
295
|
-
if (plat.wecom && w.cli !== false) {
|
|
296
|
-
const ps = w.pollSec === undefined || w.pollSec === null || w.pollSec === '' ? 0 : Number(w.pollSec) // 默认关(10-12 拍板)
|
|
297
|
-
const fs2 = w.followSec === undefined || w.followSec === null || w.followSec === '' ? 300 : Number(w.followSec)
|
|
298
|
-
add('ok', 'wecom.poll', ps > 0 ? `「不 @ 也回复」开:每 ${ps}s 看一次群历史;${fs2 > 0 ? `被 @ / 回复后 ${fs2}s 内的消息不 @ 也答,点名的随时答` : '⚠ 没设跟进窗口 —— 群里每一句都会喂模型,很烧额度'}` : '「不 @ 也回复」关(默认)— 群里 @ 机器人才答;开了要轮询群历史,官方使用规则对自动抓取有限制、大企业未开放')
|
|
299
|
-
// 第二道门:便宜模型门控。配了就要能用 —— 指的模型得有 baseUrl + apiKey(订阅登录 / codex 都不行),否则等于没配,必须出声
|
|
300
|
-
const gk = w.gatekeeper
|
|
301
|
-
if (ps > 0 && gk) {
|
|
302
|
-
const { askableProvider } = require('./agent/askonce')
|
|
303
|
-
const name = typeof gk === 'string' ? gk : (gk && gk.provider) || ''
|
|
304
|
-
const why = askableProvider((cfg.providers || {})[name], name, { model: gk && gk.model })
|
|
305
|
-
if (why) add('warn', 'wecom.gate', `便宜模型门控没生效:${why} —— 规则判不了的消息现在一律不答`)
|
|
306
|
-
else add('ok', 'wecom.gate', `便宜模型门控:${name}${gk && gk.model ? `(${gk.model})` : ''} — 没点名、也不在跟进窗口里的消息先问它要不要答,说要才喂真正的模型`)
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
// Slack 块:写了就报,与企微同口径(env: 引用解析成空串也算缺)
|
|
311
|
-
if (cfg.slack) {
|
|
312
|
-
const sl = cfg.slack
|
|
313
|
-
const v = plat.slack ? lookup(verify.slackFp(sl)) : null
|
|
314
|
-
if (v && !v.ok) add('fail', 'slack', `上次预检失败(${verify.when(v.at)}):${v.reason}`)
|
|
315
|
-
else if (plat.slack) add('ok', 'slack', (v ? `Slack 应用能用(${verify.when(v.at)} 预检通过)` : 'Slack 应用已配(Socket Mode 长连接,免公网),还没预检') + (sl.identity && sl.identity.botName ? ` · ${sl.identity.botName}` : ''))
|
|
316
|
-
else add('fail', 'slack', (sl.botToken ? '缺 slack.appToken' : sl.appToken ? '缺 slack.botToken' : '缺 slack.botToken 与 appToken') + '(env: 引用的环境变量为空也算缺)')
|
|
317
|
-
}
|
|
318
|
-
// 群级 platform:没写的已在 loadConfig 里按 id 形态推断(内存态),这里如实报推断了几个、哪些判不出(两个平台都配
|
|
319
|
-
// 且 id 认不出形态 ⇒ 运行时按飞书走,这多半是错的,warn 让人补字段);标成某平台却没配那个平台的群收不到消息 ⇒ warn。
|
|
320
|
-
const gp = cfg._groupPlatform || { inferred: [], ambiguous: [], invalid: [] }
|
|
321
|
-
const tail = (ids) => ids.map((id) => '…' + id.slice(-8)).join(' · ')
|
|
322
|
-
if (gp.invalid.length) add('warn', 'groups.platform', `${gp.invalid.length} 个群的 platform 写了认不得的值(只认 ${PLATFORMS.join(' / ')}),已当没写处理:${tail(gp.invalid)}`)
|
|
323
|
-
if (gp.ambiguous.length) add('warn', 'groups.platform', `${gp.ambiguous.length} 个群判不出属于${livePlats.map((p) => PLN[p]).join('还是')}(暂按飞书):${tail(gp.ambiguous)} — 在控制台重新保存该群,或在配置里给它写 platform`)
|
|
324
|
-
else if (gp.inferred.length && livePlats.length > 1) add('ok', 'groups.platform', `${gp.inferred.length} 个群没写 platform,已按群 id 形态推断;控制台重新保存该群时会自动补上`)
|
|
325
|
-
|
|
326
|
-
const groups = Object.entries(cfg.groups || {})
|
|
327
|
-
// 项目根目录(控制台绑群 / 目录浏览 / 风格提炼三处共用的围栏,src/projectsroot.js)。
|
|
328
|
-
// 配了却被忽略的条目必须报 —— 否则人以为围栏按他写的在跑;配置里指定的根不存在是 fail,
|
|
329
|
-
// 默认根不存在只是还没用过(首次在控制台浏览时自动建),warn 即可。
|
|
330
|
-
const pr = (cfg._projectsRoots && Array.isArray(cfg._projectsRoots.roots)) ? cfg._projectsRoots : projectsroot.resolveProjectsRoots(cfg)
|
|
331
|
-
for (const d of pr.dropped || []) add('warn', 'projectsRoots', `已忽略 ${d.value}(${d.why})`)
|
|
332
|
-
const missingRoots = pr.roots.filter((r) => !fs.existsSync(r))
|
|
333
|
-
if (!missingRoots.length) add('ok', 'projectsRoots', pr.roots.join(' · '))
|
|
334
|
-
else add(pr.fromConfig ? 'fail' : 'warn', 'projectsRoots', `不存在: ${missingRoots.join(' · ')}` + (pr.fromConfig ? '' : '(默认根,首次在控制台浏览时自动创建)'))
|
|
335
|
-
|
|
336
|
-
if (!groups.length) add('warn', 'groups', '还没绑定群 — 在控制台「群 · 项目」页绑定,或重跑 sintbot setup')
|
|
337
|
-
for (const [chatId, g] of groups) {
|
|
338
|
-
const placeholder = chatId === 'oc_example_chat_id' || (g.workdir && g.workdir.startsWith('/abs/path/to'))
|
|
339
|
-
const exists = g.workdir && fs.existsSync(g.workdir)
|
|
340
|
-
if (placeholder) add('warn', `group:…${chatId.slice(-8)}`, '示例占位,不是真的群 — 换成真实群 ID 与项目目录,或删掉')
|
|
341
|
-
// 标成某平台却没配那个平台:这个群一条消息都收不到,比目录越界更要紧,先报;一个平台都没配(开发机)时不报 —— 否则盖掉 workdir MISSING 的 fail
|
|
342
|
-
else if (g.platform && plat[g.platform] === false && livePlats.length) add('warn', `group:…${chatId.slice(-8)}`, `${g.workdir}(标为${PLN[g.platform] || g.platform}群,但没配${{ feishu: '飞书应用', wecom: '企业微信机器人', slack: 'Slack 应用' }[g.platform] || g.platform} — 这个群收不到消息)`)
|
|
343
|
-
// 越界的既有绑定照常运行(配置文件是 operator 手写的,fail-open),但要出声:控制台不会再允许新的这类绑定
|
|
344
|
-
else if (exists && !projectsroot.withinRoots(g.workdir, pr.roots)) add('warn', `group:…${chatId.slice(-8)}`, `${g.workdir}(在项目根目录之外;控制台不再允许新的这类绑定)`)
|
|
345
|
-
else add(exists ? 'ok' : 'fail', `group:…${chatId.slice(-8)}`, exists ? g.workdir : `项目目录不存在: ${g.workdir}`)
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
const fails = rows.filter((r) => r.status === 'fail').length
|
|
349
|
-
const warns = rows.filter((r) => r.status === 'warn').length
|
|
350
|
-
return { rows, fails, warns }
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
module.exports = { checkHealth, whichSync }
|
package/src/installdeps.js
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
// 安装向导替新用户装 Claude Code / Codex:`sbot setup` 第 0 步发现没装时问一句,答是就
|
|
2
|
-
// `npm i -g <包>`(继承终端,进度看得见)。一键安装脚本(install.sh)装完 sintbot 后也是走这里,
|
|
3
|
-
// 装依赖的逻辑只有这一份。
|
|
4
|
-
//
|
|
5
|
-
// 源的选择:用户自己配过 npm 源(如国内镜像)就照用,不加 --registry;还是官方源时先
|
|
6
|
-
// `npm ping` 一下(用 npm 自己的代理配置,和真正安装时走的路一致 —— Node 的 fetch 不读代理),
|
|
7
|
-
// 连不上就换 npmmirror,免得国内新用户卡在下载上。
|
|
8
|
-
const { spawnSync } = require('node:child_process')
|
|
9
|
-
const { resolveSpawn } = require('./agent/winexec')
|
|
10
|
-
|
|
11
|
-
const OFFICIAL = 'https://registry.npmjs.org/'
|
|
12
|
-
const MIRROR = 'https://registry.npmmirror.com/'
|
|
13
|
-
const PACKAGES = { claude: '@anthropic-ai/claude-code', codex: '@openai/codex' }
|
|
14
|
-
|
|
15
|
-
const isOfficial = (url) => /^https?:\/\/registry\.npmjs\.org\/?$/i.test(String(url || '').trim())
|
|
16
|
-
|
|
17
|
-
// 纯函数:已配源 + ping 结果 → 安装时要不要带 --registry、带哪个。
|
|
18
|
-
function chooseRegistry(configured, officialReachable) {
|
|
19
|
-
if (configured && !isOfficial(configured)) return { registry: null, reason: 'configured' }
|
|
20
|
-
if (officialReachable) return { registry: null, reason: 'official' }
|
|
21
|
-
return { registry: MIRROR, reason: 'mirror' }
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// pkg 可以是一个包名,也可以是一组(设计图标库四个包一条命令装齐)
|
|
25
|
-
function installArgs(pkg, registry) {
|
|
26
|
-
return ['install', '-g', ...[].concat(pkg), ...(registry ? ['--registry', registry] : [])]
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function runNpm(args, opts = {}) {
|
|
30
|
-
const { file, spawnArgs } = resolveSpawn('npm', args)
|
|
31
|
-
return spawnSync(file, spawnArgs, { encoding: 'utf8', ...opts })
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function configuredRegistry(run = runNpm) {
|
|
35
|
-
const r = run(['config', 'get', 'registry'], { timeout: 10_000 })
|
|
36
|
-
return r.status === 0 ? String(r.stdout || '').trim() : ''
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function pingOfficial(run = runNpm) {
|
|
40
|
-
const r = run(['ping', '--registry', OFFICIAL, '--fetch-timeout=6000', '--fetch-retries=0'], { timeout: 15_000 })
|
|
41
|
-
return r.status === 0
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function pickRegistry(run = runNpm) {
|
|
45
|
-
const configured = configuredRegistry(run)
|
|
46
|
-
if (configured && !isOfficial(configured)) return chooseRegistry(configured, false)
|
|
47
|
-
return chooseRegistry(configured, pingOfficial(run))
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function globalPrefix(run = runNpm) {
|
|
51
|
-
const r = run(['prefix', '-g'], { timeout: 10_000 })
|
|
52
|
-
return r.status === 0 ? String(r.stdout || '').trim() : ''
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// 装一个(name ∈ PACKAGES 的键)。stdio 继承终端;返回 { ok, pkg, registry, code }。
|
|
56
|
-
function installGlobal(name, { run = runNpm, log = () => {} } = {}) {
|
|
57
|
-
const pkg = PACKAGES[name]
|
|
58
|
-
if (!pkg) throw new Error(`unknown package: ${name}`)
|
|
59
|
-
return installPackages(pkg, { run, log })
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// 按包名装(一个或一组),选源同上。只给向导装确定的包用(Claude Code / Codex / 设计图标库),不接用户输入的名字
|
|
63
|
-
function installPackages(pkg, { run = runNpm, log = () => {} } = {}) {
|
|
64
|
-
const { registry, reason } = pickRegistry(run)
|
|
65
|
-
if (reason === 'mirror') log(`npm 官方源连不上,改用国内镜像 ${MIRROR}`)
|
|
66
|
-
const args = installArgs(pkg, registry)
|
|
67
|
-
log(`$ npm ${args.join(' ')}`)
|
|
68
|
-
const r = run(args, { stdio: 'inherit' })
|
|
69
|
-
return { ok: r.status === 0, pkg, registry, code: r.status }
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
module.exports = { OFFICIAL, MIRROR, PACKAGES, isOfficial, chooseRegistry, installArgs, pickRegistry, globalPrefix, installGlobal, installPackages }
|