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/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 }