@fengye_nobug/dsh-qq-bot 0.2.4 → 0.2.5
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.md +7 -1
- package/DESIGN.md +1 -1
- package/README.md +52 -26
- package/client/client.js +77 -11
- package/index.js +74 -22
- package/package.json +1 -1
- package/updates.js +1286 -25
package/updates.js
CHANGED
|
@@ -1,9 +1,35 @@
|
|
|
1
|
-
//
|
|
1
|
+
// 检查有没有新版本,以及在用户明确要求时把新版本装进 DSH 的 web profile。
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// checkPluginUpdate 只比较版本、拼白话说明,不下载、不安装、不改配置。
|
|
4
|
+
// 启动时检查必须走这一条,不能调用 applyPluginUpdate。
|
|
5
|
+
// 有新版本时,applyPluginUpdate 先执行 `dsh plugin --profile web add @fengye_nobug/dsh-qq-bot@x.y.z`,
|
|
6
|
+
// 走这台机器 npm / pnpm 已经配好的 registry(可以是官方,也可以是用户自己的镜像)。
|
|
7
|
+
// 插件不写死第三方镜像。包还没发到 npm,或 registry 里没有这个版本时,才改从 GitHub 下载:
|
|
8
|
+
// 先要 github.com 上的官方源码包,连不上再试同一标签的 codeload。只跟随仍指向同一标签的
|
|
9
|
+
// github.com、api.github.com 或 codeload.github.com。不用 git 协议。
|
|
10
|
+
|
|
11
|
+
import { spawn } from 'node:child_process'
|
|
12
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { tmpdir } from 'node:os'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import { gunzipSync, gzipSync } from 'node:zlib'
|
|
5
16
|
|
|
6
17
|
export const UPDATE_REPO = 'yunfengnobug/dsh-qq-bot'
|
|
18
|
+
/** npm 上的包名。无作用域的 dsh-qq-bot 发不出去(和已有的 dsh-qqbot 太像)。本地压缩包文件名仍不带作用域,避免路径里出现斜杠。 */
|
|
19
|
+
export const PACKAGE_NAME = '@fengye_nobug/dsh-qq-bot'
|
|
20
|
+
/** GitHub 上 v0.2.3 及更早标签的 package.json 仍是这个无作用域名。下载校验认它,不认别的作用域。 */
|
|
21
|
+
const LEGACY_UNSCOPED_NAME = 'dsh-qq-bot'
|
|
22
|
+
/** 没读到用户自己的 registry 时用官方源。不把第三方镜像写进默认值。 */
|
|
23
|
+
export const NPM_REGISTRY = 'https://registry.npmjs.org/'
|
|
24
|
+
/** 下载下来的压缩包上限。本仓库是源码包,远小于这个数。 */
|
|
25
|
+
const MAX_TARBALL_BYTES = 20 * 1024 * 1024
|
|
26
|
+
/** 解压后的上限,挡住异常膨胀的包。 */
|
|
27
|
+
const MAX_UNPACKED_BYTES = 40 * 1024 * 1024
|
|
28
|
+
const MAX_TAR_ENTRIES = 4000
|
|
29
|
+
const INSTALL_TIMEOUT_MS = 180000
|
|
30
|
+
const DOWNLOAD_TIMEOUT_MS = 60000
|
|
31
|
+
/** 先把 HTTP 响应发出去,再重启,避免面板还没回答就被掐掉。 */
|
|
32
|
+
const RESTART_DELAY_MS = 2500
|
|
7
33
|
|
|
8
34
|
const RELEASES_PER_PAGE = 30
|
|
9
35
|
|
|
@@ -84,6 +110,54 @@ export function installCommand(version, repo = UPDATE_REPO) {
|
|
|
84
110
|
return `dsh plugin --profile web add ${installSpec(version, repo)}`
|
|
85
111
|
}
|
|
86
112
|
|
|
113
|
+
/** 只接受 https、没有账号口令、没有路径的 registry。别的都丢掉。 */
|
|
114
|
+
export function normalizeRegistry(raw) {
|
|
115
|
+
const text = String(raw ?? '').trim()
|
|
116
|
+
if (text === '') return ''
|
|
117
|
+
let url
|
|
118
|
+
try {
|
|
119
|
+
url = new URL(text)
|
|
120
|
+
} catch {
|
|
121
|
+
return ''
|
|
122
|
+
}
|
|
123
|
+
if (url.protocol !== 'https:' || url.username !== '' || url.password !== '') return ''
|
|
124
|
+
if (url.pathname !== '/' && url.pathname !== '') return ''
|
|
125
|
+
if (url.search !== '' || url.hash !== '') return ''
|
|
126
|
+
return `${url.origin}/`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isOurNpmName(name) {
|
|
130
|
+
return name === PACKAGE_NAME
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** npm 上的安装说明。只拼这个作用域包的正式版,别的名字和预发布都不出命令。 */
|
|
134
|
+
export function npmInstallSpec(version, name = PACKAGE_NAME) {
|
|
135
|
+
const parsed = parseSemver(version)
|
|
136
|
+
if (!parsed || parsed.pre.length > 0) return ''
|
|
137
|
+
if (!isOurNpmName(name)) return ''
|
|
138
|
+
return `${PACKAGE_NAME}@${parsed.raw}`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function npmInstallCommand(version, name = PACKAGE_NAME) {
|
|
142
|
+
const spec = npmInstallSpec(version, name)
|
|
143
|
+
return spec === '' ? '' : `dsh plugin --profile web add ${spec}`
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** registry 路径把作用域里的斜杠编码成 %2F,@ 保留。 */
|
|
147
|
+
export function npmPackageUrl(registry = NPM_REGISTRY, name = PACKAGE_NAME) {
|
|
148
|
+
const base = normalizeRegistry(registry)
|
|
149
|
+
if (base === '' || !isOurNpmName(name)) return ''
|
|
150
|
+
return `${base}${name.replace('/', '%2F')}`
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function registryPackagePath(pathname) {
|
|
154
|
+
try {
|
|
155
|
+
return decodeURIComponent(String(pathname ?? '')).replace(/\/+$/, '')
|
|
156
|
+
} catch {
|
|
157
|
+
return ''
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
87
161
|
export function releasePage(tag, repo = UPDATE_REPO) {
|
|
88
162
|
const parsed = parseSemver(tag)
|
|
89
163
|
const name = parsed ? `v${parsed.raw}` : String(tag ?? '').trim()
|
|
@@ -117,10 +191,29 @@ function safeReleaseUrl(rel, tag, repo) {
|
|
|
117
191
|
return releasePage(tag, repo)
|
|
118
192
|
}
|
|
119
193
|
|
|
194
|
+
function isSafeRepo(repo) {
|
|
195
|
+
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(repo ?? ''))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** 正式版:跳过草稿、预发布和读不懂的标签。版本号从高到低。 */
|
|
199
|
+
export function stableReleases(releases) {
|
|
200
|
+
const stable = []
|
|
201
|
+
for (const rel of Array.isArray(releases) ? releases : []) {
|
|
202
|
+
if (!rel || typeof rel !== 'object') continue
|
|
203
|
+
if (rel.draft === true || rel.prerelease === true) continue
|
|
204
|
+
const tag = String(rel.tag_name ?? '')
|
|
205
|
+
const parsed = parseSemver(tag)
|
|
206
|
+
if (!parsed || parsed.pre.length > 0) continue
|
|
207
|
+
stable.push({ rel, parsed, tag })
|
|
208
|
+
}
|
|
209
|
+
stable.sort((left, right) => compareSemver(right.parsed, left.parsed) ?? 0)
|
|
210
|
+
return stable
|
|
211
|
+
}
|
|
212
|
+
|
|
120
213
|
/**
|
|
121
214
|
* @param {{ current: unknown, releases: unknown, repo?: string }} input
|
|
122
215
|
*/
|
|
123
|
-
export function assessUpdate({ current, releases, repo = UPDATE_REPO }) {
|
|
216
|
+
export function assessUpdate({ current, releases, repo = UPDATE_REPO, channel = 'github' }) {
|
|
124
217
|
const parsedCurrent = parseSemver(current)
|
|
125
218
|
if (!parsedCurrent) {
|
|
126
219
|
return {
|
|
@@ -131,15 +224,7 @@ export function assessUpdate({ current, releases, repo = UPDATE_REPO }) {
|
|
|
131
224
|
message: '当前版本号读不出来,没法对比。',
|
|
132
225
|
}
|
|
133
226
|
}
|
|
134
|
-
const stable =
|
|
135
|
-
for (const rel of Array.isArray(releases) ? releases : []) {
|
|
136
|
-
if (!rel || typeof rel !== 'object') continue
|
|
137
|
-
if (rel.draft === true || rel.prerelease === true) continue
|
|
138
|
-
const tag = String(rel.tag_name ?? '')
|
|
139
|
-
const parsed = parseSemver(tag)
|
|
140
|
-
if (!parsed) continue
|
|
141
|
-
stable.push({ rel, parsed, tag })
|
|
142
|
-
}
|
|
227
|
+
const stable = stableReleases(releases)
|
|
143
228
|
if (stable.length === 0) {
|
|
144
229
|
return {
|
|
145
230
|
ok: true,
|
|
@@ -152,8 +237,9 @@ export function assessUpdate({ current, releases, repo = UPDATE_REPO }) {
|
|
|
152
237
|
stable.sort((left, right) => compareSemver(right.parsed, left.parsed) ?? 0)
|
|
153
238
|
const latest = stable[0]
|
|
154
239
|
const summary = summarizeReleaseNotes(latest.rel.body)
|
|
155
|
-
const
|
|
156
|
-
const
|
|
240
|
+
const useNpm = channel === 'npm'
|
|
241
|
+
const spec = useNpm ? npmInstallSpec(latest.parsed.raw) : installSpec(latest.parsed.raw, repo)
|
|
242
|
+
const command = useNpm ? npmInstallCommand(latest.parsed.raw) : installCommand(latest.parsed.raw, repo)
|
|
157
243
|
const url = safeReleaseUrl(latest.rel, latest.tag, repo)
|
|
158
244
|
const compared = compareSemver(latest.parsed, parsedCurrent)
|
|
159
245
|
const base = {
|
|
@@ -171,7 +257,9 @@ export function assessUpdate({ current, releases, repo = UPDATE_REPO }) {
|
|
|
171
257
|
if (summary) lines.push(`说明:${summary}`)
|
|
172
258
|
lines.push(`升级命令:${command}`)
|
|
173
259
|
lines.push('装完请重启 dsh-web。')
|
|
174
|
-
lines.push(
|
|
260
|
+
if (useNpm) lines.push(`也可以在设置卡片里点「更新到 v${latest.parsed.raw}」,面板会从 npm 装上并重启。`)
|
|
261
|
+
else lines.push(`也可以在设置卡片里点「更新到 v${latest.parsed.raw}」,面板会自己装并重启。`)
|
|
262
|
+
if (!useNpm) lines.push(`发布页:${url}`)
|
|
175
263
|
return { ...base, kind: 'update', updateAvailable: true, message: lines.join('\n') }
|
|
176
264
|
}
|
|
177
265
|
if (compared < 0) {
|
|
@@ -255,18 +343,1191 @@ export async function queryGithubReleases(fetchImpl, options = {}) {
|
|
|
255
343
|
return { ok: true, releases: data }
|
|
256
344
|
}
|
|
257
345
|
|
|
346
|
+
function versionsToReleases(versions) {
|
|
347
|
+
return versions.map((version) => {
|
|
348
|
+
const parsed = parseSemver(version)
|
|
349
|
+
const tag = parsed ? `v${parsed.raw}` : String(version)
|
|
350
|
+
return { tag_name: tag, draft: false, prerelease: false, body: '' }
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function stableVersionList(data) {
|
|
355
|
+
const versions = data && typeof data === 'object' && !Array.isArray(data) ? data.versions : null
|
|
356
|
+
if (!versions || typeof versions !== 'object' || Array.isArray(versions)) return null
|
|
357
|
+
const stable = []
|
|
358
|
+
for (const key of Object.keys(versions)) {
|
|
359
|
+
const parsed = parseSemver(key)
|
|
360
|
+
if (!parsed || parsed.pre.length > 0) continue
|
|
361
|
+
stable.push(parsed.raw)
|
|
362
|
+
}
|
|
363
|
+
return stable
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* 读用户已经配好的 npm registry。只认 https 且没有路径的地址,读不到就用官方源。
|
|
368
|
+
* @param {typeof spawn} [spawnImpl]
|
|
369
|
+
* @param {string} [platform]
|
|
370
|
+
*/
|
|
371
|
+
export async function readNpmRegistry(spawnImpl = spawn, platform = process.platform) {
|
|
372
|
+
const fromEnv = normalizeRegistry(process.env.npm_config_registry || process.env.NPM_CONFIG_REGISTRY || '')
|
|
373
|
+
if (fromEnv !== '') return fromEnv
|
|
374
|
+
const npm = platform === 'win32' ? 'npm.cmd' : 'npm'
|
|
375
|
+
const ran = await runChild(spawnImpl, npm, ['config', 'get', 'registry'], { timeoutMs: 5000, capture: true })
|
|
376
|
+
const configured = normalizeRegistry(ran.stdout)
|
|
377
|
+
return configured === '' ? NPM_REGISTRY : configured
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* 向 registry 要这个包的正式版列表。404 表示还没发布。跳转只留在同一个源上。
|
|
382
|
+
* @param {{ registry?: string, name?: string, fetchImpl?: typeof fetch, timeoutMs?: number }} [input]
|
|
383
|
+
*/
|
|
384
|
+
export async function queryNpmVersions(input = {}) {
|
|
385
|
+
const registry = normalizeRegistry(input.registry || NPM_REGISTRY) || NPM_REGISTRY
|
|
386
|
+
const name = input.name || PACKAGE_NAME
|
|
387
|
+
const url = npmPackageUrl(registry, name)
|
|
388
|
+
if (url === '') return { ok: false, code: 'unpublished' }
|
|
389
|
+
const fetchFn = input.fetchImpl === undefined ? globalThis.fetch : input.fetchImpl
|
|
390
|
+
if (typeof fetchFn !== 'function') return { ok: false, code: 'network' }
|
|
391
|
+
const timeoutMs = input.timeoutMs ?? 15000
|
|
392
|
+
let current = url
|
|
393
|
+
const origin = new URL(url).origin
|
|
394
|
+
for (let hop = 0; hop < 3; hop++) {
|
|
395
|
+
let response
|
|
396
|
+
try {
|
|
397
|
+
response = await fetchFn(current, {
|
|
398
|
+
redirect: 'manual',
|
|
399
|
+
headers: { accept: 'application/json', 'user-agent': 'dsh-qq-bot' },
|
|
400
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
401
|
+
})
|
|
402
|
+
} catch {
|
|
403
|
+
return { ok: false, code: 'network' }
|
|
404
|
+
}
|
|
405
|
+
const status = Number(response?.status)
|
|
406
|
+
if (status >= 300 && status < 400) {
|
|
407
|
+
const location = headerValue(response, 'location')
|
|
408
|
+
let next
|
|
409
|
+
try {
|
|
410
|
+
next = new URL(location, current)
|
|
411
|
+
} catch {
|
|
412
|
+
return { ok: false, code: 'network' }
|
|
413
|
+
}
|
|
414
|
+
if (next.origin !== origin || registryPackagePath(next.pathname) !== `/${name}` || next.username !== '' || next.password !== '') {
|
|
415
|
+
return { ok: false, code: 'network' }
|
|
416
|
+
}
|
|
417
|
+
current = next.toString()
|
|
418
|
+
continue
|
|
419
|
+
}
|
|
420
|
+
if (status === 404) return { ok: false, code: 'unpublished' }
|
|
421
|
+
if (!response || response.ok !== true) return { ok: false, code: 'network' }
|
|
422
|
+
let data
|
|
423
|
+
try {
|
|
424
|
+
data = await response.json()
|
|
425
|
+
} catch {
|
|
426
|
+
return { ok: false, code: 'network' }
|
|
427
|
+
}
|
|
428
|
+
const versions = stableVersionList(data)
|
|
429
|
+
if (!versions) return { ok: false, code: 'unpublished' }
|
|
430
|
+
if (versions.length === 0) return { ok: false, code: 'unpublished' }
|
|
431
|
+
return { ok: true, versions, channel: 'npm' }
|
|
432
|
+
}
|
|
433
|
+
return { ok: false, code: 'network' }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function noteGithubFallback(assessed) {
|
|
437
|
+
if (!assessed || assessed.updateAvailable !== true) return assessed
|
|
438
|
+
const message = String(assessed.message ?? '')
|
|
439
|
+
if (message.includes('还没发到 npm') || !message.includes('也可以在设置卡片里点')) return assessed
|
|
440
|
+
return {
|
|
441
|
+
...assessed,
|
|
442
|
+
message: message.replace(
|
|
443
|
+
'也可以在设置卡片里点',
|
|
444
|
+
'这一版还没发到 npm,一键更新会改从 GitHub 下载。\n也可以在设置卡片里点',
|
|
445
|
+
),
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function lookupFailure(npmCode, githubError, applying) {
|
|
450
|
+
const raw = String(githubError ?? '')
|
|
451
|
+
if (npmCode === 'network' && /连不上 GitHub/.test(raw)) {
|
|
452
|
+
return applying
|
|
453
|
+
? '连不上 npm 的源,也连不上 GitHub,这次没有把新版本下下来。稍后再试。'
|
|
454
|
+
: '连不上 npm 的源,也连不上 GitHub,这次没有检查到更新。稍后再试。'
|
|
455
|
+
}
|
|
456
|
+
return applying ? applyGithubError(raw) : raw
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* 先看 npm。有比当前新的正式版就用它,不再问 GitHub。
|
|
461
|
+
* npm 上没有更新时,再用很短的时间看 GitHub 有没有还没发出去的版本;连不上就沿用 npm 的结论。
|
|
462
|
+
* 包还没发布,或 npm 本身连不上时,改问 GitHub Releases。
|
|
463
|
+
* @param {{ current: unknown, version?: unknown, force?: boolean, repo?: string, fetchImpl?: typeof fetch, registry?: string, spawnImpl?: typeof spawn, platform?: string, timeoutMs?: number, applying?: boolean }} input
|
|
464
|
+
*/
|
|
465
|
+
async function findUpdate(input) {
|
|
466
|
+
const repo = input.repo || UPDATE_REPO
|
|
467
|
+
const registry = input.registry || (input.fetchImpl !== undefined ? NPM_REGISTRY : await readNpmRegistry(input.spawnImpl, input.platform))
|
|
468
|
+
const npm = await queryNpmVersions({ registry, fetchImpl: input.fetchImpl, timeoutMs: input.timeoutMs, name: PACKAGE_NAME })
|
|
469
|
+
const wanted = String(input.version ?? '').trim()
|
|
470
|
+
if (npm.ok) {
|
|
471
|
+
const releases = versionsToReleases(npm.versions)
|
|
472
|
+
const assessed = assessUpdate({ current: input.current, releases, repo, channel: 'npm' })
|
|
473
|
+
if (assessed.ok === false) return { ok: false, error: assessed.message }
|
|
474
|
+
const onNpm = wanted === '' || releases.some((rel) => {
|
|
475
|
+
const parsed = parseSemver(rel.tag_name)
|
|
476
|
+
const asked = parseSemver(wanted)
|
|
477
|
+
return parsed && asked && parsed.raw === asked.raw
|
|
478
|
+
})
|
|
479
|
+
if (wanted === '' && assessed.updateAvailable === true) return { ok: true, channel: 'npm', releases, assessed }
|
|
480
|
+
if (wanted !== '' && onNpm) return { ok: true, channel: 'npm', releases, assessed }
|
|
481
|
+
const peek = wanted === ''
|
|
482
|
+
const gh = await queryGithubReleases(input.fetchImpl, { repo, timeoutMs: peek ? (input.timeoutMs ?? 4000) : input.timeoutMs })
|
|
483
|
+
if (!gh.ok) {
|
|
484
|
+
if (wanted !== '') return { ok: false, error: '这次检查到的正式版里没有这个版本,没有安装。' }
|
|
485
|
+
return { ok: true, channel: 'npm', releases, assessed }
|
|
486
|
+
}
|
|
487
|
+
const ghAssessed = noteGithubFallback(assessUpdate({ current: input.current, releases: gh.releases, repo, channel: 'github' }))
|
|
488
|
+
if (ghAssessed.ok === false) return { ok: false, error: ghAssessed.message }
|
|
489
|
+
const npmLatest = assessed.latest || '0.0.0'
|
|
490
|
+
const githubNewer = ghAssessed.updateAvailable === true && (compareSemver(ghAssessed.latest, npmLatest) ?? 0) > 0
|
|
491
|
+
const githubHasWanted = wanted !== '' && stableReleases(gh.releases).some((item) => {
|
|
492
|
+
const asked = parseSemver(wanted)
|
|
493
|
+
return asked && item.parsed.raw === asked.raw
|
|
494
|
+
})
|
|
495
|
+
if ((wanted === '' && githubNewer) || githubHasWanted) {
|
|
496
|
+
return { ok: true, channel: 'github', releases: gh.releases, assessed: ghAssessed }
|
|
497
|
+
}
|
|
498
|
+
if (wanted !== '') return { ok: false, error: '这次检查到的正式版里没有这个版本,没有安装。' }
|
|
499
|
+
return { ok: true, channel: 'npm', releases, assessed }
|
|
500
|
+
}
|
|
501
|
+
const gh = await queryGithubReleases(input.fetchImpl, { repo, timeoutMs: input.timeoutMs })
|
|
502
|
+
if (!gh.ok) return { ok: false, error: lookupFailure(npm.code, gh.error, input.applying === true) }
|
|
503
|
+
const assessed = noteGithubFallback(assessUpdate({ current: input.current, releases: gh.releases, repo, channel: 'github' }))
|
|
504
|
+
if (assessed.ok === false) return { ok: false, error: assessed.message }
|
|
505
|
+
return { ok: true, channel: 'github', releases: gh.releases, assessed }
|
|
506
|
+
}
|
|
507
|
+
|
|
258
508
|
/**
|
|
259
|
-
* @param {{ current: unknown, fetchImpl?: typeof fetch, repo?: string }} [input]
|
|
509
|
+
* @param {{ current: unknown, fetchImpl?: typeof fetch, repo?: string, registry?: string, spawnImpl?: typeof spawn, platform?: string, timeoutMs?: number }} [input]
|
|
260
510
|
*/
|
|
261
511
|
export async function checkPluginUpdate(input = {}) {
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
return { ok: false, current
|
|
512
|
+
const found = await findUpdate({ ...input, applying: false })
|
|
513
|
+
if (!found.ok) {
|
|
514
|
+
const current = String(input.current ?? '')
|
|
515
|
+
return { ok: false, current, error: found.error, message: found.error }
|
|
516
|
+
}
|
|
517
|
+
return found.assessed
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function stableTag(version, repo = UPDATE_REPO) {
|
|
521
|
+
const parsed = parseSemver(version)
|
|
522
|
+
if (!parsed || parsed.pre.length > 0 || !isSafeRepo(repo)) return ''
|
|
523
|
+
if (!/^[0-9.]+$/.test(parsed.raw)) return ''
|
|
524
|
+
return `v${parsed.raw}`
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Release 页面上的官方源码包。先请求这个地址,不采用正文里的链接,也不用第三方镜像。
|
|
529
|
+
* @param {unknown} version
|
|
530
|
+
* @param {string} [repo]
|
|
531
|
+
*/
|
|
532
|
+
export function githubArchiveUrl(version, repo = UPDATE_REPO) {
|
|
533
|
+
const tag = stableTag(version, repo)
|
|
534
|
+
if (tag === '') return ''
|
|
535
|
+
return `https://github.com/${repo}/archive/refs/tags/${tag}.tar.gz`
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* 同一标签在 codeload 上的地址。只在 github.com 连不上时作备用,仍然是 GitHub。
|
|
540
|
+
* @param {unknown} version
|
|
541
|
+
* @param {string} [repo]
|
|
542
|
+
*/
|
|
543
|
+
export function codeloadTarballUrl(version, repo = UPDATE_REPO) {
|
|
544
|
+
const tag = stableTag(version, repo)
|
|
545
|
+
if (tag === '') return ''
|
|
546
|
+
return `https://codeload.github.com/${repo}/tar.gz/refs/tags/${tag}`
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* 决定这次允许安装哪个标签。
|
|
551
|
+
* 没给版本就用本次检查里最新的正式版,而且必须比当前新。
|
|
552
|
+
* 给了版本就必须出现在本次正式版列表里,并且更新;force 为真时才允许重装同一个版本。
|
|
553
|
+
* 更旧的标签即使 force 也不装。
|
|
554
|
+
* @param {{ current: unknown, releases: unknown, version?: unknown, force?: boolean, repo?: string, channel?: 'npm' | 'github' }} input
|
|
555
|
+
*/
|
|
556
|
+
export function chooseApplyTarget(input) {
|
|
557
|
+
const repo = input.repo || UPDATE_REPO
|
|
558
|
+
const assessed = assessUpdate({ current: input.current, releases: input.releases, repo })
|
|
559
|
+
if (assessed.ok === false) return { ok: false, error: assessed.message }
|
|
560
|
+
const stable = stableReleases(input.releases)
|
|
561
|
+
const requested = String(input.version ?? '').trim()
|
|
562
|
+
/** @type {ReturnType<typeof parseSemver>} */
|
|
563
|
+
let parsed
|
|
564
|
+
if (requested === '') {
|
|
565
|
+
if (assessed.updateAvailable !== true) return { ok: false, error: assessed.message }
|
|
566
|
+
parsed = parseSemver(assessed.latest)
|
|
567
|
+
} else {
|
|
568
|
+
parsed = parseSemver(requested)
|
|
569
|
+
if (!parsed || parsed.pre.length > 0) return { ok: false, error: '版本号格式不对,没有安装。' }
|
|
570
|
+
}
|
|
571
|
+
if (!parsed) return { ok: false, error: '版本号格式不对,没有安装。' }
|
|
572
|
+
const match = stable.find((item) => item.parsed.raw === parsed.raw)
|
|
573
|
+
if (!match) return { ok: false, error: '这次检查到的正式版里没有这个版本,没有安装。' }
|
|
574
|
+
const compared = compareSemver(parsed, assessed.current)
|
|
575
|
+
if (compared == null) return { ok: false, error: '版本号对不上,没有安装。' }
|
|
576
|
+
if (compared < 0) return { ok: false, error: '不会安装比当前更旧的版本。' }
|
|
577
|
+
if (compared === 0 && input.force !== true) return { ok: false, error: '已经是这个版本,没有重装。' }
|
|
578
|
+
const channel = input.channel === 'npm' ? 'npm' : 'github'
|
|
579
|
+
const tarballUrl = githubArchiveUrl(parsed.raw, repo)
|
|
580
|
+
const fallbackUrl = codeloadTarballUrl(parsed.raw, repo)
|
|
581
|
+
const install = channel === 'npm' ? npmInstallSpec(parsed.raw) : installSpec(parsed.raw, repo)
|
|
582
|
+
const command = channel === 'npm' ? npmInstallCommand(parsed.raw) : installCommand(parsed.raw, repo)
|
|
583
|
+
if (tarballUrl === '' || fallbackUrl === '' || install === '' || command === '') {
|
|
584
|
+
return { ok: false, error: '这个版本的安装说明拼不出来,没有安装。' }
|
|
585
|
+
}
|
|
586
|
+
return {
|
|
587
|
+
ok: true,
|
|
588
|
+
version: parsed.raw,
|
|
589
|
+
tag: `v${parsed.raw}`,
|
|
590
|
+
channel,
|
|
591
|
+
install,
|
|
592
|
+
command,
|
|
593
|
+
tarballUrl,
|
|
594
|
+
fallbackUrl,
|
|
595
|
+
summary: summarizeReleaseNotes(match.rel.body),
|
|
596
|
+
url: safeReleaseUrl(match.rel, match.tag, repo),
|
|
597
|
+
current: assessed.current,
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function applyGithubError(text) {
|
|
602
|
+
const raw = String(text ?? '')
|
|
603
|
+
if (/限流/.test(raw)) return 'GitHub 限流了,这次没有更新。等一会儿再试。'
|
|
604
|
+
if (/没有查到/.test(raw)) return '没有查到这个仓库的 Release,没有安装。'
|
|
605
|
+
if (/读不懂/.test(raw)) return 'GitHub 返回的内容读不懂,没有安装。'
|
|
606
|
+
return '连不上 GitHub,这次没有把新版本下下来。稍后再试。'
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function downloadError(code) {
|
|
610
|
+
if (code === 'rate') return 'GitHub 限流了,这次没有把新版本下下来。等一会儿再试。'
|
|
611
|
+
if (code === 'missing') return '没有下到这个版本的安装包。'
|
|
612
|
+
if (code === 'huge') return '安装包太大,没有安装。'
|
|
613
|
+
return '连不上 GitHub,这次没有把新版本下下来。稍后再试。'
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function installError(code, command) {
|
|
617
|
+
const hint = command ? `也可以自己执行:${command}` : '也可以按卡片上的安装命令自己装。'
|
|
618
|
+
if (code === 'ENOENT') return `没找到 dsh 命令,没有安装。${hint}`
|
|
619
|
+
if (code === 'timeout') return `安装超时,没有确认装上。${hint}`
|
|
620
|
+
if (code === 'wrong-name') return '下到的安装包不是这个插件,没有安装。'
|
|
621
|
+
if (code === 'wrong-version') return '下到的版本和发布标签对不上,没有安装。'
|
|
622
|
+
if (code === 'bad-package') return '下到的安装包读不懂,没有安装。'
|
|
623
|
+
return `安装没有成功。${hint}`
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function restartError() {
|
|
627
|
+
return '新版本已经装上,但面板没有重启。请自己重启 dsh-web,装好的才会生效。重启后请刷新页面,登录口令可能会变。'
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function headerValue(response, name) {
|
|
631
|
+
const headers = response?.headers
|
|
632
|
+
if (!headers) return ''
|
|
633
|
+
if (typeof headers.get === 'function') return String(headers.get(name) ?? '')
|
|
634
|
+
const key = String(name).toLowerCase()
|
|
635
|
+
if (typeof headers === 'object') {
|
|
636
|
+
for (const [candidate, value] of Object.entries(headers)) {
|
|
637
|
+
if (String(candidate).toLowerCase() === key) return String(value ?? '')
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return ''
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** 只认 GitHub 自己的三个主机,并且路径必须还是这个仓库的这个标签。 */
|
|
644
|
+
function officialDownloadKind(url, repo, tag) {
|
|
645
|
+
let parsed
|
|
646
|
+
try {
|
|
647
|
+
parsed = new URL(String(url))
|
|
648
|
+
} catch {
|
|
649
|
+
return ''
|
|
650
|
+
}
|
|
651
|
+
if (parsed.protocol !== 'https:' || parsed.username !== '' || parsed.password !== '') return ''
|
|
652
|
+
const path = decodeURIComponent(parsed.pathname).replace(/\/+$/, '')
|
|
653
|
+
if (parsed.hostname === 'github.com') {
|
|
654
|
+
if (path === `/${repo}/archive/refs/tags/${tag}.tar.gz` || path === `/${repo}/archive/${tag}.tar.gz`) return 'github'
|
|
655
|
+
return ''
|
|
656
|
+
}
|
|
657
|
+
if (parsed.hostname === 'api.github.com') {
|
|
658
|
+
if (path === `/repos/${repo}/tarball/${tag}` || path === `/repos/${repo}/tarball/refs/tags/${tag}`) return 'api'
|
|
659
|
+
return ''
|
|
660
|
+
}
|
|
661
|
+
if (parsed.hostname === 'codeload.github.com') {
|
|
662
|
+
if (path === `/${repo}/tar.gz/refs/tags/${tag}` || path === `/${repo}/legacy.tar.gz/refs/tags/${tag}`) return 'codeload'
|
|
663
|
+
return ''
|
|
664
|
+
}
|
|
665
|
+
return ''
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function assertReleaseDownloadUrl(url, repo, tag) {
|
|
669
|
+
if (officialDownloadKind(url, repo, tag) === '') {
|
|
670
|
+
const error = new Error('bad-host')
|
|
671
|
+
error.code = 'network'
|
|
672
|
+
throw error
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* 下载标签压缩包。先请求官方地址;只有连不上时才改试 codeload。
|
|
678
|
+
* 跳转只跟到同一标签的 GitHub 地址,第三方镜像直接丢掉。
|
|
679
|
+
* @param {{ url: string, fallbackUrl?: string, repo?: string, tag: string, fetchImpl?: typeof fetch, proxy?: string, timeoutMs?: number, maxBytes?: number, spawnImpl?: typeof spawn, platform?: string }} input
|
|
680
|
+
*/
|
|
681
|
+
export async function downloadReleaseTarball(input) {
|
|
682
|
+
try {
|
|
683
|
+
return await downloadReleaseOnce(input)
|
|
684
|
+
} catch (error) {
|
|
685
|
+
const fallback = String(input.fallbackUrl ?? '')
|
|
686
|
+
if (error?.code !== 'network' || fallback === '' || fallback === input.url) throw error
|
|
687
|
+
return await downloadReleaseOnce({ ...input, url: fallback })
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
async function downloadReleaseOnce(input) {
|
|
692
|
+
const repo = input.repo || UPDATE_REPO
|
|
693
|
+
const proxy = String(input.proxy ?? '').trim()
|
|
694
|
+
if (proxy !== '') return downloadReleaseViaCurl(input, proxy)
|
|
695
|
+
const fetchFn = input.fetchImpl === undefined ? globalThis.fetch : input.fetchImpl
|
|
696
|
+
if (typeof fetchFn !== 'function') {
|
|
697
|
+
const error = new Error('no-fetch')
|
|
698
|
+
error.code = 'network'
|
|
699
|
+
throw error
|
|
700
|
+
}
|
|
701
|
+
const maxBytes = input.maxBytes ?? MAX_TARBALL_BYTES
|
|
702
|
+
const timeoutMs = input.timeoutMs ?? DOWNLOAD_TIMEOUT_MS
|
|
703
|
+
let current = input.url
|
|
704
|
+
for (let hop = 0; hop < 4; hop++) {
|
|
705
|
+
assertReleaseDownloadUrl(current, repo, input.tag)
|
|
706
|
+
let response
|
|
707
|
+
try {
|
|
708
|
+
response = await fetchFn(current, {
|
|
709
|
+
redirect: 'manual',
|
|
710
|
+
headers: { accept: 'application/octet-stream', 'user-agent': 'dsh-qq-bot' },
|
|
711
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
712
|
+
})
|
|
713
|
+
} catch {
|
|
714
|
+
const error = new Error('fetch')
|
|
715
|
+
error.code = 'network'
|
|
716
|
+
throw error
|
|
717
|
+
}
|
|
718
|
+
const status = Number(response?.status)
|
|
719
|
+
if (status >= 300 && status < 400) {
|
|
720
|
+
const location = headerValue(response, 'location')
|
|
721
|
+
if (location === '') {
|
|
722
|
+
const error = new Error('redirect')
|
|
723
|
+
error.code = 'network'
|
|
724
|
+
throw error
|
|
725
|
+
}
|
|
726
|
+
try {
|
|
727
|
+
current = new URL(location, current).toString()
|
|
728
|
+
} catch {
|
|
729
|
+
const error = new Error('redirect')
|
|
730
|
+
error.code = 'network'
|
|
731
|
+
throw error
|
|
732
|
+
}
|
|
733
|
+
continue
|
|
734
|
+
}
|
|
735
|
+
if (status === 403 || status === 429) {
|
|
736
|
+
const error = new Error('rate')
|
|
737
|
+
error.code = 'rate'
|
|
738
|
+
throw error
|
|
739
|
+
}
|
|
740
|
+
if (status === 404) {
|
|
741
|
+
const error = new Error('missing')
|
|
742
|
+
error.code = 'missing'
|
|
743
|
+
throw error
|
|
744
|
+
}
|
|
745
|
+
if (!response || response.ok !== true) {
|
|
746
|
+
const error = new Error('status')
|
|
747
|
+
error.code = 'network'
|
|
748
|
+
throw error
|
|
749
|
+
}
|
|
750
|
+
const declared = Number(headerValue(response, 'content-length'))
|
|
751
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
752
|
+
const error = new Error('huge')
|
|
753
|
+
error.code = 'huge'
|
|
754
|
+
throw error
|
|
755
|
+
}
|
|
756
|
+
let bytes
|
|
757
|
+
try {
|
|
758
|
+
bytes = Buffer.from(await response.arrayBuffer())
|
|
759
|
+
} catch {
|
|
760
|
+
const error = new Error('body')
|
|
761
|
+
error.code = 'network'
|
|
762
|
+
throw error
|
|
763
|
+
}
|
|
764
|
+
if (bytes.length > maxBytes) {
|
|
765
|
+
const error = new Error('huge')
|
|
766
|
+
error.code = 'huge'
|
|
767
|
+
throw error
|
|
768
|
+
}
|
|
769
|
+
return bytes
|
|
770
|
+
}
|
|
771
|
+
const error = new Error('redirect')
|
|
772
|
+
error.code = 'network'
|
|
773
|
+
throw error
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function splitCurlStatus(buffer) {
|
|
777
|
+
const marker = Buffer.from('\n__HTTP__')
|
|
778
|
+
const at = buffer.lastIndexOf(marker)
|
|
779
|
+
if (at < 0) return null
|
|
780
|
+
const meta = buffer.slice(at + marker.length).toString('utf8').trim()
|
|
781
|
+
const line = meta.split('\n')
|
|
782
|
+
const status = Number(line[0])
|
|
783
|
+
const redirectUrl = (line[1] ?? '').trim()
|
|
784
|
+
if (!Number.isFinite(status)) return null
|
|
785
|
+
return { status, redirectUrl, body: buffer.slice(0, at) }
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async function curlOnce(spawnImpl, curl, args, maxBytes) {
|
|
789
|
+
const chunks = []
|
|
790
|
+
let size = 0
|
|
791
|
+
let settled = false
|
|
792
|
+
return await new Promise((resolve) => {
|
|
793
|
+
let child
|
|
794
|
+
const finish = (value) => {
|
|
795
|
+
if (settled) return
|
|
796
|
+
settled = true
|
|
797
|
+
resolve(value)
|
|
798
|
+
}
|
|
799
|
+
try {
|
|
800
|
+
child = spawnImpl(curl, args, { shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
801
|
+
} catch {
|
|
802
|
+
finish({ ok: false, code: 'network' })
|
|
803
|
+
return
|
|
804
|
+
}
|
|
805
|
+
if (!child || typeof child.on !== 'function') {
|
|
806
|
+
finish({ ok: false, code: 'network' })
|
|
807
|
+
return
|
|
808
|
+
}
|
|
809
|
+
child.stdout?.on?.('data', (chunk) => {
|
|
810
|
+
size += chunk.length
|
|
811
|
+
if (size > maxBytes + 1024) {
|
|
812
|
+
try { child.kill('SIGTERM') } catch { /* 超限就停 */ }
|
|
813
|
+
finish({ ok: false, code: 'huge' })
|
|
814
|
+
return
|
|
815
|
+
}
|
|
816
|
+
chunks.push(Buffer.from(chunk))
|
|
817
|
+
})
|
|
818
|
+
child.stderr?.on?.('data', () => {})
|
|
819
|
+
child.on('error', () => finish({ ok: false, code: 'network' }))
|
|
820
|
+
child.on('close', (code) => {
|
|
821
|
+
if (code !== 0) finish({ ok: false, code: 'network' })
|
|
822
|
+
else finish({ ok: true, body: Buffer.concat(chunks) })
|
|
823
|
+
})
|
|
824
|
+
})
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
async function downloadReleaseViaCurl(input, proxy) {
|
|
828
|
+
const spawnImpl = input.spawnImpl || spawn
|
|
829
|
+
const platform = input.platform || process.platform
|
|
830
|
+
const curl = platform === 'win32' ? 'curl.exe' : 'curl'
|
|
831
|
+
const timeoutSec = Math.max(1, Math.ceil((input.timeoutMs ?? DOWNLOAD_TIMEOUT_MS) / 1000))
|
|
832
|
+
const maxBytes = input.maxBytes ?? MAX_TARBALL_BYTES
|
|
833
|
+
const repo = input.repo || UPDATE_REPO
|
|
834
|
+
let current = input.url
|
|
835
|
+
for (let hop = 0; hop < 4; hop++) {
|
|
836
|
+
assertReleaseDownloadUrl(current, repo, input.tag)
|
|
837
|
+
const ran = await curlOnce(spawnImpl, curl, [
|
|
838
|
+
'-sS',
|
|
839
|
+
'--max-time',
|
|
840
|
+
String(timeoutSec),
|
|
841
|
+
'--retry',
|
|
842
|
+
'0',
|
|
843
|
+
'-A',
|
|
844
|
+
'dsh-qq-bot',
|
|
845
|
+
'-x',
|
|
846
|
+
proxy,
|
|
847
|
+
'-w',
|
|
848
|
+
'\n__HTTP__%{http_code}\n%{redirect_url}',
|
|
849
|
+
current,
|
|
850
|
+
], maxBytes)
|
|
851
|
+
if (!ran.ok) {
|
|
852
|
+
const error = new Error(String(ran.code || 'network'))
|
|
853
|
+
error.code = ran.code === 'huge' ? 'huge' : 'network'
|
|
854
|
+
throw error
|
|
855
|
+
}
|
|
856
|
+
const parsed = splitCurlStatus(ran.body)
|
|
857
|
+
if (!parsed) {
|
|
858
|
+
const error = new Error('curl-status')
|
|
859
|
+
error.code = 'network'
|
|
860
|
+
throw error
|
|
861
|
+
}
|
|
862
|
+
if (parsed.status >= 300 && parsed.status < 400) {
|
|
863
|
+
if (parsed.redirectUrl === '') {
|
|
864
|
+
const error = new Error('redirect')
|
|
865
|
+
error.code = 'network'
|
|
866
|
+
throw error
|
|
867
|
+
}
|
|
868
|
+
try {
|
|
869
|
+
current = new URL(parsed.redirectUrl, current).toString()
|
|
870
|
+
} catch {
|
|
871
|
+
const error = new Error('redirect')
|
|
872
|
+
error.code = 'network'
|
|
873
|
+
throw error
|
|
874
|
+
}
|
|
875
|
+
continue
|
|
876
|
+
}
|
|
877
|
+
if (parsed.status === 403 || parsed.status === 429) {
|
|
878
|
+
const error = new Error('rate')
|
|
879
|
+
error.code = 'rate'
|
|
880
|
+
throw error
|
|
881
|
+
}
|
|
882
|
+
if (parsed.status === 404) {
|
|
883
|
+
const error = new Error('missing')
|
|
884
|
+
error.code = 'missing'
|
|
885
|
+
throw error
|
|
886
|
+
}
|
|
887
|
+
if (parsed.status !== 200) {
|
|
888
|
+
const error = new Error('status')
|
|
889
|
+
error.code = 'network'
|
|
890
|
+
throw error
|
|
891
|
+
}
|
|
892
|
+
if (parsed.body.length > maxBytes) {
|
|
893
|
+
const error = new Error('huge')
|
|
894
|
+
error.code = 'huge'
|
|
895
|
+
throw error
|
|
896
|
+
}
|
|
897
|
+
return parsed.body
|
|
898
|
+
}
|
|
899
|
+
const error = new Error('redirect')
|
|
900
|
+
error.code = 'network'
|
|
901
|
+
throw error
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function parseOctal(field) {
|
|
905
|
+
const text = Buffer.from(field).toString('utf8').replace(/\0.*$/, '').trim()
|
|
906
|
+
if (text === '') return 0
|
|
907
|
+
if (!/^[0-7]+$/.test(text)) return null
|
|
908
|
+
return Number.parseInt(text, 8)
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function readTarEntries(raw) {
|
|
912
|
+
const entries = []
|
|
913
|
+
let offset = 0
|
|
914
|
+
let longName = ''
|
|
915
|
+
let unpacked = 0
|
|
916
|
+
while (offset + 512 <= raw.length) {
|
|
917
|
+
const header = raw.subarray(offset, offset + 512)
|
|
918
|
+
if (header.every((byte) => byte === 0)) break
|
|
919
|
+
if (entries.length > MAX_TAR_ENTRIES) {
|
|
920
|
+
const error = new Error('too-many')
|
|
921
|
+
error.code = 'bad-package'
|
|
922
|
+
throw error
|
|
923
|
+
}
|
|
924
|
+
let sum = 0
|
|
925
|
+
for (let i = 0; i < 512; i++) sum += i >= 148 && i < 156 ? 32 : header[i]
|
|
926
|
+
const stored = parseOctal(header.subarray(148, 156))
|
|
927
|
+
const size = parseOctal(header.subarray(124, 136))
|
|
928
|
+
if (stored == null || size == null || stored !== sum || size < 0) {
|
|
929
|
+
const error = new Error('checksum')
|
|
930
|
+
error.code = 'bad-package'
|
|
931
|
+
throw error
|
|
932
|
+
}
|
|
933
|
+
let name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/, '')
|
|
934
|
+
const prefix = header.subarray(345, 500).toString('utf8').replace(/\0.*$/, '')
|
|
935
|
+
if (longName !== '') {
|
|
936
|
+
name = longName
|
|
937
|
+
longName = ''
|
|
938
|
+
} else if (prefix !== '') {
|
|
939
|
+
name = `${prefix}/${name}`
|
|
940
|
+
}
|
|
941
|
+
const type = String.fromCharCode(header[156] || 0)
|
|
942
|
+
offset += 512
|
|
943
|
+
unpacked += size
|
|
944
|
+
if (unpacked > MAX_UNPACKED_BYTES) {
|
|
945
|
+
const error = new Error('unpacked')
|
|
946
|
+
error.code = 'bad-package'
|
|
947
|
+
throw error
|
|
948
|
+
}
|
|
949
|
+
const data = raw.subarray(offset, offset + size)
|
|
950
|
+
if (data.length !== size) {
|
|
951
|
+
const error = new Error('truncated')
|
|
952
|
+
error.code = 'bad-package'
|
|
953
|
+
throw error
|
|
954
|
+
}
|
|
955
|
+
offset += Math.ceil(size / 512) * 512
|
|
956
|
+
if (type === 'L') {
|
|
957
|
+
longName = data.toString('utf8').replace(/\0.*$/, '')
|
|
958
|
+
continue
|
|
959
|
+
}
|
|
960
|
+
if (type === '0' || type === '\0') entries.push({ name, data: Buffer.from(data) })
|
|
961
|
+
}
|
|
962
|
+
return entries
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function inflateTarball(bytes) {
|
|
966
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes ?? [])
|
|
967
|
+
if (buf.length >= 2 && buf[0] === 0x1f && buf[1] === 0x8b) {
|
|
968
|
+
try {
|
|
969
|
+
return gunzipSync(buf, { maxOutputLength: MAX_UNPACKED_BYTES })
|
|
970
|
+
} catch {
|
|
971
|
+
const error = new Error('gzip')
|
|
972
|
+
error.code = 'bad-package'
|
|
973
|
+
throw error
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
return buf
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function entryParts(name) {
|
|
980
|
+
return String(name ?? '').replace(/^\.\//, '').split('/').filter((part) => part !== '' && part !== '.')
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function writeOctal(header, offset, value, length) {
|
|
984
|
+
const text = Number(value).toString(8).padStart(length - 1, '0').slice(-(length - 1))
|
|
985
|
+
header.write(text, offset, length - 1, 'ascii')
|
|
986
|
+
header[offset + length - 1] = 0
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function tarHeader(name, size, typeflag = '0') {
|
|
990
|
+
const header = Buffer.alloc(512, 0)
|
|
991
|
+
header.write(name, 0, Math.min(Buffer.byteLength(name), 100), 'utf8')
|
|
992
|
+
writeOctal(header, 100, 0o644, 8)
|
|
993
|
+
writeOctal(header, 108, 0, 8)
|
|
994
|
+
writeOctal(header, 116, 0, 8)
|
|
995
|
+
writeOctal(header, 124, size, 12)
|
|
996
|
+
writeOctal(header, 136, 0, 12)
|
|
997
|
+
for (let i = 148; i < 156; i++) header[i] = 32
|
|
998
|
+
header[156] = typeflag.charCodeAt(0)
|
|
999
|
+
header.write('ustar\0', 257, 'ascii')
|
|
1000
|
+
header.write('00', 263, 'ascii')
|
|
1001
|
+
let sum = 0
|
|
1002
|
+
for (const byte of header) sum += byte
|
|
1003
|
+
const checksum = sum.toString(8).padStart(6, '0')
|
|
1004
|
+
header.write(checksum, 148, 6, 'ascii')
|
|
1005
|
+
header[154] = 0
|
|
1006
|
+
header[155] = 32
|
|
1007
|
+
return header
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function appendTarFile(chunks, name, data) {
|
|
1011
|
+
if (Buffer.byteLength(name) > 100) {
|
|
1012
|
+
const long = Buffer.from(`${name}\0`)
|
|
1013
|
+
chunks.push(tarHeader('././@LongLink', long.length, 'L'))
|
|
1014
|
+
chunks.push(long)
|
|
1015
|
+
const pad = (512 - (long.length % 512)) % 512
|
|
1016
|
+
if (pad > 0) chunks.push(Buffer.alloc(pad))
|
|
1017
|
+
chunks.push(tarHeader(name.slice(0, 100), data.length, '0'))
|
|
1018
|
+
} else {
|
|
1019
|
+
chunks.push(tarHeader(name, data.length, '0'))
|
|
1020
|
+
}
|
|
1021
|
+
chunks.push(data)
|
|
1022
|
+
const pad = (512 - (data.length % 512)) % 512
|
|
1023
|
+
if (pad > 0) chunks.push(Buffer.alloc(pad))
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* 核对包名和版本,并重打包成 `package/` 开头的 npm 压缩包。
|
|
1028
|
+
* `dsh plugin add` 认这种包,不认 git 仓库。
|
|
1029
|
+
* @param {Buffer} bytes
|
|
1030
|
+
* @param {{ name?: string, version: string }} expected
|
|
1031
|
+
*/
|
|
1032
|
+
export function repackReleaseTarball(bytes, expected) {
|
|
1033
|
+
let raw
|
|
1034
|
+
try {
|
|
1035
|
+
raw = inflateTarball(bytes)
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
if (error?.code === 'bad-package') throw error
|
|
1038
|
+
const wrapped = new Error('inflate')
|
|
1039
|
+
wrapped.code = 'bad-package'
|
|
1040
|
+
throw wrapped
|
|
1041
|
+
}
|
|
1042
|
+
let entries
|
|
1043
|
+
try {
|
|
1044
|
+
entries = readTarEntries(raw)
|
|
1045
|
+
} catch (error) {
|
|
1046
|
+
if (error?.code === 'bad-package') throw error
|
|
1047
|
+
const wrapped = new Error('tar')
|
|
1048
|
+
wrapped.code = 'bad-package'
|
|
1049
|
+
throw wrapped
|
|
1050
|
+
}
|
|
1051
|
+
const manifests = []
|
|
1052
|
+
for (const entry of entries) {
|
|
1053
|
+
const parts = entryParts(entry.name)
|
|
1054
|
+
if (parts.length === 2 && parts[1] === 'package.json' && !parts.some((part) => part === '..' || part.includes('\\') || part.includes('\0'))) {
|
|
1055
|
+
manifests.push({ root: parts[0], data: entry.data })
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
if (manifests.length !== 1) {
|
|
1059
|
+
const error = new Error('manifest-count')
|
|
1060
|
+
error.code = 'bad-package'
|
|
1061
|
+
throw error
|
|
1062
|
+
}
|
|
1063
|
+
let manifest
|
|
1064
|
+
try {
|
|
1065
|
+
manifest = JSON.parse(manifests[0].data.toString('utf8'))
|
|
1066
|
+
} catch {
|
|
1067
|
+
const error = new Error('json')
|
|
1068
|
+
error.code = 'bad-package'
|
|
1069
|
+
throw error
|
|
1070
|
+
}
|
|
1071
|
+
const wantName = expected.name || ''
|
|
1072
|
+
const nameOk = wantName !== ''
|
|
1073
|
+
? manifest?.name === wantName
|
|
1074
|
+
: manifest?.name === PACKAGE_NAME || manifest?.name === LEGACY_UNSCOPED_NAME
|
|
1075
|
+
if (!nameOk) {
|
|
1076
|
+
const error = new Error('name')
|
|
1077
|
+
error.code = 'wrong-name'
|
|
1078
|
+
throw error
|
|
1079
|
+
}
|
|
1080
|
+
if (compareSemver(manifest.version, expected.version) !== 0) {
|
|
1081
|
+
const error = new Error('version')
|
|
1082
|
+
error.code = 'wrong-version'
|
|
1083
|
+
throw error
|
|
1084
|
+
}
|
|
1085
|
+
const root = manifests[0].root
|
|
1086
|
+
const files = []
|
|
1087
|
+
for (const entry of entries) {
|
|
1088
|
+
const parts = entryParts(entry.name)
|
|
1089
|
+
if (parts.length < 2 || parts[0] !== root) continue
|
|
1090
|
+
if (parts.some((part) => part === '..' || part.includes('\\') || part.includes('\0') || part.startsWith('/'))) {
|
|
1091
|
+
const error = new Error('path')
|
|
1092
|
+
error.code = 'bad-package'
|
|
1093
|
+
throw error
|
|
1094
|
+
}
|
|
1095
|
+
const packedName = `package/${parts.slice(1).join('/')}`
|
|
1096
|
+
files.push({ name: packedName, data: entry.data })
|
|
1097
|
+
}
|
|
1098
|
+
if (!files.some((file) => file.name === 'package/package.json')) {
|
|
1099
|
+
const error = new Error('missing-manifest')
|
|
1100
|
+
error.code = 'bad-package'
|
|
1101
|
+
throw error
|
|
1102
|
+
}
|
|
1103
|
+
const chunks = []
|
|
1104
|
+
for (const file of files) appendTarFile(chunks, file.name, file.data)
|
|
1105
|
+
chunks.push(Buffer.alloc(1024))
|
|
1106
|
+
return gzipSync(Buffer.concat(chunks))
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/** 给测试造一个和 GitHub 标签包一样、根目录套了一层的 tar.gz。 */
|
|
1110
|
+
export function fixtureTarball(files, root = 'dsh-qq-bot-9.9.9') {
|
|
1111
|
+
const chunks = []
|
|
1112
|
+
for (const file of files) {
|
|
1113
|
+
const data = Buffer.isBuffer(file.data) ? file.data : Buffer.from(String(file.data))
|
|
1114
|
+
appendTarFile(chunks, `${root}/${file.name}`, data)
|
|
1115
|
+
}
|
|
1116
|
+
chunks.push(Buffer.alloc(1024))
|
|
1117
|
+
return gzipSync(Buffer.concat(chunks))
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function quoteCmdArg(arg) {
|
|
1121
|
+
const text = String(arg)
|
|
1122
|
+
if (text === '' || /["&|<>^%\n\r]/.test(text)) {
|
|
1123
|
+
const error = new Error('bad-path')
|
|
1124
|
+
error.code = 'error'
|
|
1125
|
+
throw error
|
|
1126
|
+
}
|
|
1127
|
+
return /\s/.test(text) ? `"${text}"` : text
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function isRegistryMiss(text) {
|
|
1131
|
+
return /ERR_PNPM_FETCH_404|ERR_PNPM_NO_MATCHING_VERSION|E404|404 Not Found|is not in this registry|No matching version|not found in the registry/i.test(String(text ?? ''))
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* `dsh plugin --profile web add <包@版本>` 或本地压缩包。
|
|
1136
|
+
* Windows 用 cmd 调 dsh.cmd,避免直接敲到被拦截的 ps1。
|
|
1137
|
+
* @param {{ tarballPath?: string, packageSpec?: string, platform?: string, profile?: string, comSpec?: string }} spec
|
|
1138
|
+
*/
|
|
1139
|
+
export function dshPluginAddSpec(spec) {
|
|
1140
|
+
const profile = spec.profile || 'web'
|
|
1141
|
+
if (!/^[a-z][a-z0-9_-]{0,31}$/.test(profile)) {
|
|
1142
|
+
const error = new Error('profile')
|
|
1143
|
+
error.code = 'bad-package'
|
|
1144
|
+
throw error
|
|
1145
|
+
}
|
|
1146
|
+
const packageSpec = spec.packageSpec == null ? '' : String(spec.packageSpec)
|
|
1147
|
+
const tarballPath = spec.tarballPath == null ? '' : String(spec.tarballPath)
|
|
1148
|
+
if (packageSpec !== '') {
|
|
1149
|
+
const version = packageSpec.startsWith(`${PACKAGE_NAME}@`) ? packageSpec.slice(PACKAGE_NAME.length + 1) : ''
|
|
1150
|
+
if (npmInstallSpec(version) !== packageSpec) {
|
|
1151
|
+
const error = new Error('spec')
|
|
1152
|
+
error.code = 'bad-package'
|
|
1153
|
+
throw error
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
if (packageSpec === '' && tarballPath === '') {
|
|
1157
|
+
const error = new Error('target')
|
|
1158
|
+
error.code = 'bad-package'
|
|
1159
|
+
throw error
|
|
1160
|
+
}
|
|
1161
|
+
const pluginArgs = ['plugin', '--profile', profile, 'add', packageSpec !== '' ? packageSpec : tarballPath]
|
|
1162
|
+
const platform = spec.platform || process.platform
|
|
1163
|
+
if (platform === 'win32') {
|
|
1164
|
+
const line = ['dsh.cmd', ...pluginArgs].map(quoteCmdArg).join(' ')
|
|
1165
|
+
return { command: spec.comSpec || 'cmd.exe', args: ['/d', '/s', '/c', line], shell: false }
|
|
1166
|
+
}
|
|
1167
|
+
return { command: 'dsh', args: pluginArgs, shell: false }
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function runChild(spawnImpl, command, args, options = {}) {
|
|
1171
|
+
return new Promise((resolve) => {
|
|
1172
|
+
let settled = false
|
|
1173
|
+
let timer
|
|
1174
|
+
const finish = (value) => {
|
|
1175
|
+
if (settled) return
|
|
1176
|
+
settled = true
|
|
1177
|
+
if (timer) clearTimeout(timer)
|
|
1178
|
+
resolve(value)
|
|
1179
|
+
}
|
|
1180
|
+
let child
|
|
1181
|
+
try {
|
|
1182
|
+
child = spawnImpl(command, args, {
|
|
1183
|
+
cwd: options.cwd,
|
|
1184
|
+
env: options.env,
|
|
1185
|
+
windowsHide: true,
|
|
1186
|
+
shell: false,
|
|
1187
|
+
stdio: options.stdio || ['ignore', 'pipe', 'pipe'],
|
|
1188
|
+
})
|
|
1189
|
+
} catch (error) {
|
|
1190
|
+
finish({ ok: false, code: error?.code === 'ENOENT' ? 'ENOENT' : 'error' })
|
|
1191
|
+
return
|
|
1192
|
+
}
|
|
1193
|
+
if (!child || typeof child.on !== 'function') {
|
|
1194
|
+
finish({ ok: false, code: 'error' })
|
|
1195
|
+
return
|
|
1196
|
+
}
|
|
1197
|
+
let stdout = ''
|
|
1198
|
+
let stderr = ''
|
|
1199
|
+
child.stdout?.on?.('data', (chunk) => {
|
|
1200
|
+
if (!options.capture || stdout.length >= 2000) return
|
|
1201
|
+
stdout += chunk.toString().slice(0, 2000 - stdout.length)
|
|
1202
|
+
})
|
|
1203
|
+
child.stderr?.on?.('data', (chunk) => {
|
|
1204
|
+
if (stderr.length >= 4000) return
|
|
1205
|
+
stderr += chunk.toString().slice(0, 4000 - stderr.length)
|
|
1206
|
+
})
|
|
1207
|
+
child.on('error', (error) => finish({ ok: false, code: error?.code === 'ENOENT' ? 'ENOENT' : 'error', stdout, stderr }))
|
|
1208
|
+
timer = setTimeout(() => {
|
|
1209
|
+
try { child.kill('SIGTERM') } catch { /* 超时就停 */ }
|
|
1210
|
+
finish({ ok: false, code: 'timeout' })
|
|
1211
|
+
}, options.timeoutMs ?? INSTALL_TIMEOUT_MS)
|
|
1212
|
+
child.on('close', (code) => finish({ ok: code === 0, code: code === 0 ? 0 : 'exit', stdout, stderr }))
|
|
1213
|
+
})
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
async function defaultRunCommand(spec) {
|
|
1217
|
+
const planned = dshPluginAddSpec(spec)
|
|
1218
|
+
const ran = await runChild(spec.spawnImpl || spawn, planned.command, planned.args, {
|
|
1219
|
+
cwd: spec.cwd || process.cwd(),
|
|
1220
|
+
env: spec.env || process.env,
|
|
1221
|
+
timeoutMs: spec.timeoutMs ?? INSTALL_TIMEOUT_MS,
|
|
1222
|
+
capture: true,
|
|
1223
|
+
})
|
|
1224
|
+
if (ran.ok) return { ok: true, code: 0 }
|
|
1225
|
+
if (spec.packageSpec && isRegistryMiss(ran.stderr)) return { ok: false, code: 'unpublished' }
|
|
1226
|
+
if (ran.code === 'ENOENT' || ran.code === 'timeout') return { ok: false, code: ran.code }
|
|
1227
|
+
return { ok: false, code: 'error' }
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function readOwnCgroup() {
|
|
1231
|
+
try {
|
|
1232
|
+
return readFileSync('/proc/self/cgroup', 'utf8')
|
|
1233
|
+
} catch {
|
|
1234
|
+
return ''
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/** 从 cgroup 里取直接托管本进程的服务名。用户会话本身(user@1000.service)不算。 */
|
|
1239
|
+
export function systemdUnitFromCgroup(text) {
|
|
1240
|
+
const names = []
|
|
1241
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
1242
|
+
for (const part of line.split('/')) {
|
|
1243
|
+
const name = part.trim()
|
|
1244
|
+
if (/^[A-Za-z0-9][A-Za-z0-9:_.@+-]*\.service$/.test(name)) names.push(name)
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
const specific = names.filter((name) => !/^user@\d+\.service$/.test(name))
|
|
1248
|
+
return specific.length > 0 ? specific[specific.length - 1] : null
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* 脱离父进程之后再重启的小脚本。只杀给定 pid,再按原来的命令行拉起,不碰别的进程。
|
|
1253
|
+
* 用 node -e 跑,所以是 CommonJS。
|
|
1254
|
+
*/
|
|
1255
|
+
export const RESTART_HELPER = `
|
|
1256
|
+
const { spawn } = require('node:child_process')
|
|
1257
|
+
let spec = {}
|
|
1258
|
+
try { spec = JSON.parse(process.env.DSH_QQ_BOT_RESTART || '{}') } catch { spec = {} }
|
|
1259
|
+
const env = { ...process.env }
|
|
1260
|
+
delete env.DSH_QQ_BOT_RESTART
|
|
1261
|
+
const delay = Number(spec.delayMs) > 0 ? Number(spec.delayMs) : ${RESTART_DELAY_MS}
|
|
1262
|
+
function alive(pid) {
|
|
1263
|
+
try { process.kill(pid, 0); return true } catch { return false }
|
|
1264
|
+
}
|
|
1265
|
+
setTimeout(() => {
|
|
1266
|
+
const pid = Number(spec.pid)
|
|
1267
|
+
const known = Number.isInteger(pid) && pid > 1
|
|
1268
|
+
if (known) {
|
|
1269
|
+
try { process.kill(pid, 'SIGTERM') } catch {}
|
|
1270
|
+
}
|
|
1271
|
+
const started = Date.now()
|
|
1272
|
+
const timer = setInterval(() => {
|
|
1273
|
+
const still = known && alive(pid)
|
|
1274
|
+
if (!still || Date.now() - started > 20000) {
|
|
1275
|
+
clearInterval(timer)
|
|
1276
|
+
if (still) {
|
|
1277
|
+
try { process.kill(pid, 'SIGKILL') } catch {}
|
|
1278
|
+
}
|
|
1279
|
+
setTimeout(() => {
|
|
1280
|
+
if (typeof spec.execPath === 'string' && Array.isArray(spec.argv)) {
|
|
1281
|
+
const child = spawn(spec.execPath, spec.argv, {
|
|
1282
|
+
cwd: typeof spec.cwd === 'string' && spec.cwd !== '' ? spec.cwd : undefined,
|
|
1283
|
+
env,
|
|
1284
|
+
detached: true,
|
|
1285
|
+
stdio: 'ignore',
|
|
1286
|
+
windowsHide: true,
|
|
1287
|
+
})
|
|
1288
|
+
child.unref()
|
|
1289
|
+
}
|
|
1290
|
+
process.exit(0)
|
|
1291
|
+
}, still ? 400 : 200)
|
|
1292
|
+
}
|
|
1293
|
+
}, 200)
|
|
1294
|
+
}, delay)
|
|
1295
|
+
`.trim()
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* 重启计划。systemd 里只重启 cgroup 里那个具体服务,用 systemd-run 把动作放到服务外面。
|
|
1299
|
+
* 认不出单独服务时不重启整个登录会话。其余情况等响应发出后,按当前进程的命令行再拉起一次。
|
|
1300
|
+
*/
|
|
1301
|
+
export function planPanelRestart(input = {}) {
|
|
1302
|
+
const platform = input.platform || process.platform
|
|
1303
|
+
const env = input.env || process.env
|
|
1304
|
+
const invocation = platform !== 'win32' && Boolean(env && (env.INVOCATION_ID || env.NOTIFY_SOCKET))
|
|
1305
|
+
const unit = invocation ? systemdUnitFromCgroup(input.cgroup ?? '') : null
|
|
1306
|
+
if (invocation && !unit) return { mode: 'manual' }
|
|
1307
|
+
if (unit) {
|
|
1308
|
+
return {
|
|
1309
|
+
mode: 'systemd',
|
|
1310
|
+
command: 'systemd-run',
|
|
1311
|
+
args: ['--quiet', '--collect', '--on-active=3s', '--timer-property=AccuracySec=1s', '--', 'systemctl', 'restart', unit],
|
|
1312
|
+
unit,
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
return {
|
|
1316
|
+
mode: 'reexec',
|
|
1317
|
+
execPath: input.execPath || process.execPath,
|
|
1318
|
+
argv: Array.isArray(input.argv) ? input.argv : process.argv.slice(1),
|
|
1319
|
+
pid: input.pid || process.pid,
|
|
1320
|
+
cwd: input.cwd || process.cwd(),
|
|
1321
|
+
delayMs: RESTART_DELAY_MS,
|
|
266
1322
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* @param {{ plan?: ReturnType<typeof planPanelRestart>, spawnImpl?: typeof spawn, env?: NodeJS.ProcessEnv, platform?: string, cgroup?: string, execPath?: string, argv?: string[], pid?: number, cwd?: string }} input
|
|
1327
|
+
*/
|
|
1328
|
+
export async function schedulePanelRestart(input = {}) {
|
|
1329
|
+
const plan = input.plan || planPanelRestart(input)
|
|
1330
|
+
if (plan.mode === 'manual') return { ok: false, mode: 'manual', error: restartError() }
|
|
1331
|
+
const spawnImpl = input.spawnImpl || spawn
|
|
1332
|
+
if (plan.mode === 'systemd') {
|
|
1333
|
+
const ran = await runChild(spawnImpl, plan.command, plan.args, { timeoutMs: 20000, stdio: 'ignore' })
|
|
1334
|
+
if (!ran.ok) return { ok: false, mode: 'systemd', error: restartError() }
|
|
1335
|
+
return { ok: true, mode: 'systemd' }
|
|
1336
|
+
}
|
|
1337
|
+
const payload = JSON.stringify({
|
|
1338
|
+
pid: plan.pid,
|
|
1339
|
+
cwd: plan.cwd,
|
|
1340
|
+
execPath: plan.execPath,
|
|
1341
|
+
argv: plan.argv,
|
|
1342
|
+
delayMs: plan.delayMs,
|
|
1343
|
+
})
|
|
1344
|
+
const env = { ...(input.env || process.env) }
|
|
1345
|
+
delete env.DSH_QQ_BOT_RESTART
|
|
1346
|
+
env.DSH_QQ_BOT_RESTART = payload
|
|
1347
|
+
let child
|
|
1348
|
+
try {
|
|
1349
|
+
child = spawnImpl(plan.execPath, ['-e', RESTART_HELPER], {
|
|
1350
|
+
detached: true,
|
|
1351
|
+
stdio: 'ignore',
|
|
1352
|
+
windowsHide: true,
|
|
1353
|
+
shell: false,
|
|
1354
|
+
env,
|
|
1355
|
+
cwd: plan.cwd,
|
|
1356
|
+
})
|
|
1357
|
+
} catch {
|
|
1358
|
+
return { ok: false, mode: 'reexec', error: restartError() }
|
|
1359
|
+
}
|
|
1360
|
+
if (!child || typeof child.on !== 'function') return { ok: false, mode: 'reexec', error: restartError() }
|
|
1361
|
+
child.on('error', () => {})
|
|
1362
|
+
if (typeof child.unref === 'function') child.unref()
|
|
1363
|
+
return { ok: true, mode: 'reexec' }
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
function successMessage(tag, channel) {
|
|
1367
|
+
const via = channel === 'npm'
|
|
1368
|
+
? '这次是从 npm 装的。'
|
|
1369
|
+
: '这一版在 npm 上还没有,所以从 GitHub 装上。'
|
|
1370
|
+
return `已经装上 ${tag},面板即将重启。请刷新页面;登录口令可能会变,用新的面板地址再打开。${via}现有设置还在,没有改动 NapCat。`
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
function shownInstall(channel, version, repo) {
|
|
1374
|
+
if (channel === 'npm') return { install: npmInstallSpec(version), command: npmInstallCommand(version) }
|
|
1375
|
+
return { install: installSpec(version, repo), command: installCommand(version, repo) }
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
/**
|
|
1379
|
+
* 下载并安装一次检查允许的版本,然后安排重启。
|
|
1380
|
+
* 调用方要自己做登录校验。这里不读设置文件,也不碰 NapCat。
|
|
1381
|
+
* 先 `dsh plugin add @fengye_nobug/dsh-qq-bot@版本`。registry 里没有这个版本时,才下载 GitHub 上的同一标签。
|
|
1382
|
+
* @param {{ current: unknown, version?: unknown, force?: boolean, repo?: string, fetchImpl?: typeof fetch, registry?: string, proxy?: string, platform?: string, profile?: string, runCommand?: (spec: object) => Promise<{ ok: boolean, code?: string | number }>, scheduleRestart?: (spec: object) => Promise<{ ok: boolean, error?: string }>, spawnImpl?: typeof spawn, timeoutMs?: number, downloadTimeoutMs?: number, maxBytes?: number }} input
|
|
1383
|
+
*/
|
|
1384
|
+
export async function applyPluginUpdate(input = {}) {
|
|
1385
|
+
const repo = input.repo || UPDATE_REPO
|
|
1386
|
+
const found = await findUpdate({ ...input, applying: true })
|
|
1387
|
+
if (!found.ok) {
|
|
1388
|
+
return { ok: false, stage: 'github', current: String(input.current ?? ''), error: found.error, message: found.error }
|
|
1389
|
+
}
|
|
1390
|
+
const decision = chooseApplyTarget({
|
|
1391
|
+
current: input.current,
|
|
1392
|
+
releases: found.releases,
|
|
1393
|
+
version: input.version,
|
|
1394
|
+
force: input.force === true,
|
|
1395
|
+
repo,
|
|
1396
|
+
channel: found.channel,
|
|
1397
|
+
})
|
|
1398
|
+
if (!decision.ok) {
|
|
1399
|
+
return {
|
|
1400
|
+
ok: false,
|
|
1401
|
+
stage: 'refused',
|
|
1402
|
+
current: String(input.current ?? ''),
|
|
1403
|
+
error: decision.error,
|
|
1404
|
+
message: decision.error,
|
|
1405
|
+
updateAvailable: false,
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
let channel = decision.channel
|
|
1409
|
+
if (channel === 'npm') {
|
|
1410
|
+
let ran
|
|
1411
|
+
try {
|
|
1412
|
+
const run = input.runCommand || defaultRunCommand
|
|
1413
|
+
ran = await run({
|
|
1414
|
+
packageSpec: decision.install,
|
|
1415
|
+
platform: input.platform,
|
|
1416
|
+
profile: 'web',
|
|
1417
|
+
spawnImpl: input.spawnImpl,
|
|
1418
|
+
timeoutMs: input.timeoutMs,
|
|
1419
|
+
})
|
|
1420
|
+
} catch {
|
|
1421
|
+
ran = { ok: false, code: 'error' }
|
|
1422
|
+
}
|
|
1423
|
+
if (ran?.ok === true) return finishApply(input, decision, 'npm')
|
|
1424
|
+
if (ran?.code !== 'unpublished') {
|
|
1425
|
+
const message = installError(ran?.code, decision.command)
|
|
1426
|
+
return {
|
|
1427
|
+
ok: false,
|
|
1428
|
+
stage: 'install',
|
|
1429
|
+
current: decision.current,
|
|
1430
|
+
error: message,
|
|
1431
|
+
message,
|
|
1432
|
+
tag: decision.tag,
|
|
1433
|
+
version: decision.version,
|
|
1434
|
+
command: decision.command,
|
|
1435
|
+
install: decision.install,
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
channel = 'github'
|
|
1439
|
+
}
|
|
1440
|
+
const shown = shownInstall(channel, decision.version, repo)
|
|
1441
|
+
let bytes
|
|
1442
|
+
try {
|
|
1443
|
+
bytes = await downloadReleaseTarball({
|
|
1444
|
+
url: decision.tarballUrl,
|
|
1445
|
+
fallbackUrl: decision.fallbackUrl,
|
|
1446
|
+
repo,
|
|
1447
|
+
tag: decision.tag,
|
|
1448
|
+
fetchImpl: input.fetchImpl,
|
|
1449
|
+
proxy: input.proxy,
|
|
1450
|
+
timeoutMs: input.downloadTimeoutMs,
|
|
1451
|
+
maxBytes: input.maxBytes,
|
|
1452
|
+
spawnImpl: input.spawnImpl,
|
|
1453
|
+
platform: input.platform,
|
|
1454
|
+
})
|
|
1455
|
+
} catch (error) {
|
|
1456
|
+
const message = downloadError(error?.code)
|
|
1457
|
+
return { ok: false, stage: 'github', current: decision.current, error: message, message, tag: decision.tag, command: shown.command, install: shown.install }
|
|
1458
|
+
}
|
|
1459
|
+
let packed
|
|
1460
|
+
try {
|
|
1461
|
+
packed = repackReleaseTarball(bytes, { name: PACKAGE_NAME, version: decision.version })
|
|
1462
|
+
} catch (error) {
|
|
1463
|
+
const message = installError(error?.code, shown.command)
|
|
1464
|
+
return { ok: false, stage: 'install', current: decision.current, error: message, message, tag: decision.tag, command: shown.command, install: shown.install }
|
|
1465
|
+
}
|
|
1466
|
+
const dir = mkdtempSync(join(tmpdir(), 'dsh-qq-bot-update-'))
|
|
1467
|
+
const tarballPath = join(dir, `dsh-qq-bot-${decision.version}.tgz`)
|
|
1468
|
+
let ran
|
|
1469
|
+
try {
|
|
1470
|
+
writeFileSync(tarballPath, packed)
|
|
1471
|
+
const run = input.runCommand || defaultRunCommand
|
|
1472
|
+
ran = await run({
|
|
1473
|
+
tarballPath,
|
|
1474
|
+
platform: input.platform,
|
|
1475
|
+
profile: 'web',
|
|
1476
|
+
spawnImpl: input.spawnImpl,
|
|
1477
|
+
timeoutMs: input.timeoutMs,
|
|
1478
|
+
})
|
|
1479
|
+
} catch {
|
|
1480
|
+
ran = { ok: false, code: 'error' }
|
|
1481
|
+
} finally {
|
|
1482
|
+
rmSync(dir, { recursive: true, force: true })
|
|
1483
|
+
}
|
|
1484
|
+
if (!ran || ran.ok !== true) {
|
|
1485
|
+
const message = installError(ran?.code, shown.command)
|
|
1486
|
+
return { ok: false, stage: 'install', current: decision.current, error: message, message, tag: decision.tag, version: decision.version, command: shown.command, install: shown.install }
|
|
1487
|
+
}
|
|
1488
|
+
return finishApply(input, { ...decision, install: shown.install, command: shown.command }, 'github')
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
async function finishApply(input, decision, channel) {
|
|
1492
|
+
let restart
|
|
1493
|
+
try {
|
|
1494
|
+
const schedule = input.scheduleRestart || schedulePanelRestart
|
|
1495
|
+
restart = await schedule({ spawnImpl: input.spawnImpl, platform: input.platform })
|
|
1496
|
+
} catch {
|
|
1497
|
+
restart = { ok: false, error: restartError() }
|
|
1498
|
+
}
|
|
1499
|
+
if (!restart || restart.ok !== true) {
|
|
1500
|
+
const message = restart?.error || restartError()
|
|
1501
|
+
return {
|
|
1502
|
+
ok: false,
|
|
1503
|
+
stage: 'restart',
|
|
1504
|
+
installed: true,
|
|
1505
|
+
channel,
|
|
1506
|
+
current: decision.current,
|
|
1507
|
+
version: decision.version,
|
|
1508
|
+
tag: decision.tag,
|
|
1509
|
+
install: decision.install,
|
|
1510
|
+
command: decision.command,
|
|
1511
|
+
error: message,
|
|
1512
|
+
message,
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
const message = successMessage(decision.tag, channel)
|
|
1516
|
+
return {
|
|
1517
|
+
ok: true,
|
|
1518
|
+
applied: true,
|
|
1519
|
+
restarting: true,
|
|
1520
|
+
stage: 'restart',
|
|
1521
|
+
channel,
|
|
1522
|
+
current: decision.current,
|
|
1523
|
+
version: decision.version,
|
|
1524
|
+
latest: decision.version,
|
|
1525
|
+
tag: decision.tag,
|
|
1526
|
+
summary: decision.summary,
|
|
1527
|
+
install: decision.install,
|
|
1528
|
+
command: decision.command,
|
|
1529
|
+
url: decision.url,
|
|
1530
|
+
updateAvailable: false,
|
|
1531
|
+
message,
|
|
270
1532
|
}
|
|
271
|
-
return assessed
|
|
272
1533
|
}
|