sintbot 1.4.1 → 1.4.3
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 +42 -0
- package/CHANGELOG.json +42 -0
- package/bin/sintbot-v2.js +33 -2
- package/docs/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/install.md +6 -4
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/zh-CN/install.md +6 -4
- package/install.sh +1 -1
- package/package.json +5 -1
- package/src/c2configcontrol.js +3 -2
- package/src/c2console.js +6 -3
- package/src/c2multigroupservice.js +19 -2
- package/src/c2updater.js +361 -0
- package/src/c2upgradeswitch.js +169 -0
- package/src/core/c2cardterminalpath.js +17 -3
- package/src/core/c2durableruntime.js +6 -0
- package/src/core/turnprogress.js +135 -0
- package/src/core/v2engine.js +141 -19
- package/src/ui/console.html +36 -15
- package/src/updater.js +243 -0
package/src/updater.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Stable-version checks and guarded in-place upgrades for the global package.
|
|
2
|
+
// Checking is read-only. Installation is explicit, exact-version only, waits for
|
|
3
|
+
// every group queue to drain, and never escalates privileges.
|
|
4
|
+
|
|
5
|
+
const fs = require('node:fs')
|
|
6
|
+
const path = require('node:path')
|
|
7
|
+
const { execFile } = require('node:child_process')
|
|
8
|
+
const { promisify } = require('node:util')
|
|
9
|
+
const { atomicWriteJSON } = require('./core/store')
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile)
|
|
12
|
+
const PACKAGE = 'sintbot'
|
|
13
|
+
const REGISTRIES = [
|
|
14
|
+
{ url: 'https://registry.npmjs.org/sintbot/latest', source: 'npm' },
|
|
15
|
+
{ url: 'https://registry.npmmirror.com/sintbot/latest', source: 'mirror' },
|
|
16
|
+
]
|
|
17
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
|
18
|
+
const CHECK_TIMEOUT_MS = 10_000
|
|
19
|
+
const QUEUE_TIMEOUT_MS = 24 * 60 * 60 * 1000
|
|
20
|
+
|
|
21
|
+
function stableVersion(value) {
|
|
22
|
+
const s = String(value || '').trim().replace(/^v/, '')
|
|
23
|
+
return /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(s) ? s : null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function compareVersion(a, b) {
|
|
27
|
+
const av = stableVersion(a); const bv = stableVersion(b)
|
|
28
|
+
if (!av || !bv) return 0
|
|
29
|
+
const aa = av.split('.').map(BigInt); const bb = bv.split('.').map(BigInt)
|
|
30
|
+
for (let i = 0; i < 3; i++) if (aa[i] !== bb[i]) return aa[i] > bb[i] ? 1 : -1
|
|
31
|
+
return 0
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hasPendingWork(live) {
|
|
35
|
+
// Updating while the engine is missing or its snapshot is incomplete is unsafe:
|
|
36
|
+
// a busy group could be hidden by a failed state read. Treat unknown as busy.
|
|
37
|
+
if (!live || live.running !== true || !Number.isFinite(Number(live.queue)) || !live.groups || typeof live.groups !== 'object') return true
|
|
38
|
+
if (Number(live.queue) > 0) return true
|
|
39
|
+
return Object.values(live.groups).some((g) => !g || g.busy || !Number.isFinite(Number(g.queue)) || Number(g.queue) > 0)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function fetchVersion({ fetchImpl = globalThis.fetch, url, timeoutMs = CHECK_TIMEOUT_MS } = {}) {
|
|
43
|
+
const ac = new AbortController()
|
|
44
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs)
|
|
45
|
+
try {
|
|
46
|
+
const res = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: ac.signal, redirect: 'error' })
|
|
47
|
+
if (!res || !res.ok) throw new Error(`HTTP ${res && res.status || 'error'}`)
|
|
48
|
+
const data = await res.json()
|
|
49
|
+
const version = stableVersion(data && (data.version || (data['dist-tags'] && data['dist-tags'].latest)))
|
|
50
|
+
if (!version) throw new Error('registry returned no stable version')
|
|
51
|
+
return version
|
|
52
|
+
} finally { clearTimeout(timer) }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readState(file) {
|
|
56
|
+
try {
|
|
57
|
+
const v = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
58
|
+
return v && typeof v === 'object' ? v : {}
|
|
59
|
+
} catch { return {} }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function createUpdater({
|
|
63
|
+
currentVersion,
|
|
64
|
+
stateFile,
|
|
65
|
+
getLiveState = () => null,
|
|
66
|
+
fetchImpl = globalThis.fetch,
|
|
67
|
+
registries = REGISTRIES,
|
|
68
|
+
now = Date.now,
|
|
69
|
+
waitMs = 5000,
|
|
70
|
+
queueTimeoutMs = QUEUE_TIMEOUT_MS,
|
|
71
|
+
checkIntervalMs = CHECK_INTERVAL_MS,
|
|
72
|
+
isGlobalInstall = defaultGlobalInstall,
|
|
73
|
+
installExact = defaultInstallExact,
|
|
74
|
+
readInstalledVersion = defaultReadInstalledVersion,
|
|
75
|
+
getManagedService = () => false,
|
|
76
|
+
restartManagedService = async () => ({ ok: false }),
|
|
77
|
+
setAnnouncementBaseline = () => {},
|
|
78
|
+
log = () => {},
|
|
79
|
+
} = {}) {
|
|
80
|
+
const file = stateFile || path.join(require('node:os').homedir(), '.sbot', 'update.json')
|
|
81
|
+
const current = stableVersion(currentVersion) || '0.0.0'
|
|
82
|
+
let data = readState(file)
|
|
83
|
+
let queueTimer = null
|
|
84
|
+
let checking = null
|
|
85
|
+
let closed = false
|
|
86
|
+
|
|
87
|
+
// If a previous daemon was replaced during the restart step, the new package
|
|
88
|
+
// version is the evidence that installation and restart both completed.
|
|
89
|
+
if (data.job && data.job.state === 'restarting') {
|
|
90
|
+
data.job = data.job.to === current
|
|
91
|
+
? { ...data.job, state: 'done', message: '更新完成并已重启', updatedAt: now() }
|
|
92
|
+
: { ...data.job, state: 'failed', message: '更新后未能自动重启,请手动重启服务', updatedAt: now() }
|
|
93
|
+
persist()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function persist() { try { atomicWriteJSON(file, data, { mode: 0o600 }) } catch (e) { log('update state: ' + (e && e.message)) } }
|
|
97
|
+
function status() {
|
|
98
|
+
const latest = stableVersion(data.latestVersion)
|
|
99
|
+
return {
|
|
100
|
+
currentVersion: current,
|
|
101
|
+
latestVersion: latest,
|
|
102
|
+
checkedAt: Number(data.checkedAt) || null,
|
|
103
|
+
source: data.source || '',
|
|
104
|
+
ignoredVersion: data.ignoredVersion || '',
|
|
105
|
+
available: !!latest && compareVersion(latest, current) > 0 && data.ignoredVersion !== latest,
|
|
106
|
+
job: data.job ? { ...data.job } : null,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function check() {
|
|
111
|
+
if (closed) return status()
|
|
112
|
+
if (checking) return checking
|
|
113
|
+
checking = (async () => {
|
|
114
|
+
let lastError = null
|
|
115
|
+
for (const r of registries) {
|
|
116
|
+
try {
|
|
117
|
+
const version = await fetchVersion({ fetchImpl, url: r.url })
|
|
118
|
+
data.latestVersion = version
|
|
119
|
+
data.checkedAt = now()
|
|
120
|
+
data.source = r.source || ''
|
|
121
|
+
if (data.ignoredVersion && data.ignoredVersion !== version) data.ignoredVersion = ''
|
|
122
|
+
persist()
|
|
123
|
+
return status()
|
|
124
|
+
} catch (e) { lastError = e }
|
|
125
|
+
}
|
|
126
|
+
// Network errors are deliberately quiet and never erase the last success.
|
|
127
|
+
if (lastError) log('update check unavailable: ' + (lastError && lastError.message || 'network error'))
|
|
128
|
+
return status()
|
|
129
|
+
})().finally(() => { checking = null })
|
|
130
|
+
return checking
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function ignore(version) {
|
|
134
|
+
const v = stableVersion(version)
|
|
135
|
+
if (!v || v !== stableVersion(data.latestVersion)) throw new Error('只能忽略当前检测到的版本')
|
|
136
|
+
data.ignoredVersion = v
|
|
137
|
+
persist()
|
|
138
|
+
return status()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function startUpgrade({ version, announce = true } = {}) {
|
|
142
|
+
const v = stableVersion(version)
|
|
143
|
+
if (!v || v !== stableVersion(data.latestVersion) || compareVersion(v, current) <= 0) throw new Error('版本信息已变化,请先重新检查')
|
|
144
|
+
if (data.job && ['queued', 'installing', 'restarting', 'restart-needed'].includes(data.job.state)) throw new Error('已有更新需要完成或重启后再试')
|
|
145
|
+
data.job = { id: `${now()}-${Math.random().toString(36).slice(2, 8)}`, state: 'queued', from: current, to: v,
|
|
146
|
+
announce: announce !== false, createdAt: now(), updatedAt: now(), message: '等待所有群任务结束后更新' }
|
|
147
|
+
persist()
|
|
148
|
+
void processQueue()
|
|
149
|
+
return status()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function scheduleQueueCheck() {
|
|
153
|
+
if (closed || queueTimer || !data.job || data.job.state !== 'queued') return
|
|
154
|
+
queueTimer = setTimeout(() => { queueTimer = null; void processQueue() }, waitMs)
|
|
155
|
+
if (queueTimer.unref) queueTimer.unref()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function processQueue() {
|
|
159
|
+
const job = data.job
|
|
160
|
+
if (closed || !job || job.state !== 'queued') return
|
|
161
|
+
if (now() - job.createdAt > queueTimeoutMs) {
|
|
162
|
+
data.job = { ...job, state: 'failed', message: '等待群任务结束超时;可在任务空闲后重新发起', updatedAt: now() }
|
|
163
|
+
persist(); return
|
|
164
|
+
}
|
|
165
|
+
let live
|
|
166
|
+
try { live = getLiveState() }
|
|
167
|
+
catch (e) { log('update queue: live state unavailable: ' + (e && e.message)); return scheduleQueueCheck() }
|
|
168
|
+
if (hasPendingWork(live)) return scheduleQueueCheck()
|
|
169
|
+
if (queueTimer) { clearInterval(queueTimer); queueTimer = null }
|
|
170
|
+
data.job = { ...job, state: 'installing', message: '正在检查安装位置并安装指定版本', updatedAt: now() }
|
|
171
|
+
persist()
|
|
172
|
+
try {
|
|
173
|
+
const checkResult = await isGlobalInstall()
|
|
174
|
+
if (!checkResult || !checkResult.ok) throw new Error((checkResult && checkResult.reason) || '当前运行位置不是可写的全局安装')
|
|
175
|
+
const result = await installExact(PACKAGE, job.to, checkResult)
|
|
176
|
+
if (!result || result.ok === false) throw new Error((result && result.reason) || '安装失败;检查包管理器日志后可手动重试')
|
|
177
|
+
const installed = await readInstalledVersion(checkResult)
|
|
178
|
+
if (stableVersion(installed) !== job.to) throw new Error('安装完成后的版本核对不符;请手动检查后重启')
|
|
179
|
+
if (!job.announce) { try { setAnnouncementBaseline(job.to) } catch (e) { log('update announcement baseline: ' + (e && e.message)) } }
|
|
180
|
+
const managed = await getManagedService()
|
|
181
|
+
if (managed) {
|
|
182
|
+
data.job = { ...job, state: 'restarting', message: '已安装,正在重启后台服务', updatedAt: now() }
|
|
183
|
+
persist()
|
|
184
|
+
const restarted = await restartManagedService()
|
|
185
|
+
if (restarted && restarted.ok) return status()
|
|
186
|
+
data.job = { ...job, state: 'restart-needed', message: '已安装新版;后台服务未能自动重启,请手动重启', updatedAt: now() }
|
|
187
|
+
} else {
|
|
188
|
+
data.job = { ...job, state: 'restart-needed', message: '已安装新版;当前不是受托管服务,请手动重启 SintBot', updatedAt: now() }
|
|
189
|
+
}
|
|
190
|
+
persist()
|
|
191
|
+
} catch (e) {
|
|
192
|
+
data.job = { ...job, state: 'failed', message: String(e && e.message || e).slice(0, 300), updatedAt: now() }
|
|
193
|
+
persist()
|
|
194
|
+
}
|
|
195
|
+
return status()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (data.job && data.job.state === 'queued') void processQueue()
|
|
199
|
+
const checkTimer = setInterval(() => { void check() }, checkIntervalMs)
|
|
200
|
+
if (checkTimer.unref) checkTimer.unref()
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
status,
|
|
204
|
+
check,
|
|
205
|
+
ignore,
|
|
206
|
+
startUpgrade,
|
|
207
|
+
start() { return check() },
|
|
208
|
+
close() { closed = true; clearInterval(checkTimer); if (queueTimer) clearTimeout(queueTimer); queueTimer = null },
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function defaultGlobalInstall() {
|
|
213
|
+
const { execFileSync } = require('node:child_process')
|
|
214
|
+
const rootOut = execFileSync('npm', ['root', '-g'], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'] }).trim()
|
|
215
|
+
const globalRoot = fs.realpathSync(rootOut)
|
|
216
|
+
const globalPackage = path.join(globalRoot, PACKAGE)
|
|
217
|
+
const runningPackage = fs.realpathSync(path.resolve(__dirname, '..'))
|
|
218
|
+
if (runningPackage !== fs.realpathSync(globalPackage)) return { ok: false, reason: '当前运行的是开发目录或非全局副本;请在终端手动更新' }
|
|
219
|
+
fs.accessSync(globalRoot, fs.constants.W_OK | fs.constants.X_OK)
|
|
220
|
+
fs.accessSync(path.dirname(globalPackage), fs.constants.W_OK | fs.constants.X_OK)
|
|
221
|
+
return { ok: true, globalRoot, globalPackage }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function defaultInstallExact(name, version) {
|
|
225
|
+
const env = {}
|
|
226
|
+
const allowed = ['PATH', 'HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'TEMP', 'TMP', 'SystemRoot', 'PATHEXT',
|
|
227
|
+
'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy', 'LANG', 'LC_ALL']
|
|
228
|
+
for (const k of allowed) if (process.env[k] !== undefined) env[k] = process.env[k]
|
|
229
|
+
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
|
230
|
+
try {
|
|
231
|
+
await execFileAsync(npm, ['install', '--global', `${name}@${version}`, '--omit=dev', '--no-audit', '--no-fund'],
|
|
232
|
+
{ timeout: 10 * 60 * 1000, maxBuffer: 2 * 1024 * 1024, windowsHide: true, env })
|
|
233
|
+
return { ok: true }
|
|
234
|
+
} catch (e) {
|
|
235
|
+
return { ok: false, reason: `安装失败(退出码 ${e && e.code != null ? e.code : '未知'});未提权,也未改用其他版本` }
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function defaultReadInstalledVersion(info) {
|
|
240
|
+
try { return JSON.parse(fs.readFileSync(path.join(info.globalPackage, 'package.json'), 'utf8')).version || null } catch { return null }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
module.exports = { PACKAGE, REGISTRIES, CHECK_INTERVAL_MS, stableVersion, compareVersion, hasPendingWork, fetchVersion, createUpdater, defaultGlobalInstall, defaultInstallExact, defaultReadInstalledVersion }
|