@fengye_nobug/dsh-qq-bot 0.2.4 → 0.2.6
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 +12 -1
- package/DESIGN.md +1 -1
- package/README.md +60 -30
- package/client/client.js +77 -11
- package/cordis.patch.yml +6 -3
- package/index.js +76 -22
- package/package.json +1 -1
- package/updates.js +1737 -25
package/updates.js
CHANGED
|
@@ -1,9 +1,41 @@
|
|
|
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
|
+
// 安装成功后,若 profile、home 或插件自己的 cordis.patch.yml 里 qq-bridge 的 name
|
|
11
|
+
// 仍是旧的无作用域名,会改成磁盘上 package.json 里的包名。不改 config,不改设置,不改 NapCat。
|
|
12
|
+
// 安装超过三分钟还没结束时,停掉这次拉起的进程组,并清掉已经退出的安装记录。
|
|
13
|
+
// 超时后若磁盘上已是目标版本,仍改包名,但不自动重启。
|
|
14
|
+
|
|
15
|
+
import { spawn } from 'node:child_process'
|
|
16
|
+
import { chmodSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { tmpdir } from 'node:os'
|
|
18
|
+
import { isAbsolute, join } from 'node:path'
|
|
19
|
+
import { gunzipSync, gzipSync } from 'node:zlib'
|
|
5
20
|
|
|
6
21
|
export const UPDATE_REPO = 'yunfengnobug/dsh-qq-bot'
|
|
22
|
+
/** npm 上的包名。无作用域的 dsh-qq-bot 发不出去(和已有的 dsh-qqbot 太像)。本地压缩包文件名仍不带作用域,避免路径里出现斜杠。 */
|
|
23
|
+
export const PACKAGE_NAME = '@fengye_nobug/dsh-qq-bot'
|
|
24
|
+
/** GitHub 上 v0.2.3 及更早标签的 package.json 仍是这个无作用域名。下载校验认它,不认别的作用域。 */
|
|
25
|
+
const LEGACY_UNSCOPED_NAME = 'dsh-qq-bot'
|
|
26
|
+
/** 没读到用户自己的 registry 时用官方源。不把第三方镜像写进默认值。 */
|
|
27
|
+
export const NPM_REGISTRY = 'https://registry.npmjs.org/'
|
|
28
|
+
/** 下载下来的压缩包上限。本仓库是源码包,远小于这个数。 */
|
|
29
|
+
const MAX_TARBALL_BYTES = 20 * 1024 * 1024
|
|
30
|
+
/** 解压后的上限,挡住异常膨胀的包。 */
|
|
31
|
+
const MAX_UNPACKED_BYTES = 40 * 1024 * 1024
|
|
32
|
+
const MAX_TAR_ENTRIES = 4000
|
|
33
|
+
const INSTALL_TIMEOUT_MS = 180000
|
|
34
|
+
/** 先劝退,再强制停。给子进程一点时间自己退出。 */
|
|
35
|
+
const KILL_GRACE_MS = 1500
|
|
36
|
+
const DOWNLOAD_TIMEOUT_MS = 60000
|
|
37
|
+
/** 先把 HTTP 响应发出去,再重启,避免面板还没回答就被掐掉。 */
|
|
38
|
+
const RESTART_DELAY_MS = 2500
|
|
7
39
|
|
|
8
40
|
const RELEASES_PER_PAGE = 30
|
|
9
41
|
|
|
@@ -84,6 +116,54 @@ export function installCommand(version, repo = UPDATE_REPO) {
|
|
|
84
116
|
return `dsh plugin --profile web add ${installSpec(version, repo)}`
|
|
85
117
|
}
|
|
86
118
|
|
|
119
|
+
/** 只接受 https、没有账号口令、没有路径的 registry。别的都丢掉。 */
|
|
120
|
+
export function normalizeRegistry(raw) {
|
|
121
|
+
const text = String(raw ?? '').trim()
|
|
122
|
+
if (text === '') return ''
|
|
123
|
+
let url
|
|
124
|
+
try {
|
|
125
|
+
url = new URL(text)
|
|
126
|
+
} catch {
|
|
127
|
+
return ''
|
|
128
|
+
}
|
|
129
|
+
if (url.protocol !== 'https:' || url.username !== '' || url.password !== '') return ''
|
|
130
|
+
if (url.pathname !== '/' && url.pathname !== '') return ''
|
|
131
|
+
if (url.search !== '' || url.hash !== '') return ''
|
|
132
|
+
return `${url.origin}/`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isOurNpmName(name) {
|
|
136
|
+
return name === PACKAGE_NAME
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** npm 上的安装说明。只拼这个作用域包的正式版,别的名字和预发布都不出命令。 */
|
|
140
|
+
export function npmInstallSpec(version, name = PACKAGE_NAME) {
|
|
141
|
+
const parsed = parseSemver(version)
|
|
142
|
+
if (!parsed || parsed.pre.length > 0) return ''
|
|
143
|
+
if (!isOurNpmName(name)) return ''
|
|
144
|
+
return `${PACKAGE_NAME}@${parsed.raw}`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function npmInstallCommand(version, name = PACKAGE_NAME) {
|
|
148
|
+
const spec = npmInstallSpec(version, name)
|
|
149
|
+
return spec === '' ? '' : `dsh plugin --profile web add ${spec}`
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** registry 路径把作用域里的斜杠编码成 %2F,@ 保留。 */
|
|
153
|
+
export function npmPackageUrl(registry = NPM_REGISTRY, name = PACKAGE_NAME) {
|
|
154
|
+
const base = normalizeRegistry(registry)
|
|
155
|
+
if (base === '' || !isOurNpmName(name)) return ''
|
|
156
|
+
return `${base}${name.replace('/', '%2F')}`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function registryPackagePath(pathname) {
|
|
160
|
+
try {
|
|
161
|
+
return decodeURIComponent(String(pathname ?? '')).replace(/\/+$/, '')
|
|
162
|
+
} catch {
|
|
163
|
+
return ''
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
87
167
|
export function releasePage(tag, repo = UPDATE_REPO) {
|
|
88
168
|
const parsed = parseSemver(tag)
|
|
89
169
|
const name = parsed ? `v${parsed.raw}` : String(tag ?? '').trim()
|
|
@@ -117,10 +197,29 @@ function safeReleaseUrl(rel, tag, repo) {
|
|
|
117
197
|
return releasePage(tag, repo)
|
|
118
198
|
}
|
|
119
199
|
|
|
200
|
+
function isSafeRepo(repo) {
|
|
201
|
+
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(repo ?? ''))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 正式版:跳过草稿、预发布和读不懂的标签。版本号从高到低。 */
|
|
205
|
+
export function stableReleases(releases) {
|
|
206
|
+
const stable = []
|
|
207
|
+
for (const rel of Array.isArray(releases) ? releases : []) {
|
|
208
|
+
if (!rel || typeof rel !== 'object') continue
|
|
209
|
+
if (rel.draft === true || rel.prerelease === true) continue
|
|
210
|
+
const tag = String(rel.tag_name ?? '')
|
|
211
|
+
const parsed = parseSemver(tag)
|
|
212
|
+
if (!parsed || parsed.pre.length > 0) continue
|
|
213
|
+
stable.push({ rel, parsed, tag })
|
|
214
|
+
}
|
|
215
|
+
stable.sort((left, right) => compareSemver(right.parsed, left.parsed) ?? 0)
|
|
216
|
+
return stable
|
|
217
|
+
}
|
|
218
|
+
|
|
120
219
|
/**
|
|
121
220
|
* @param {{ current: unknown, releases: unknown, repo?: string }} input
|
|
122
221
|
*/
|
|
123
|
-
export function assessUpdate({ current, releases, repo = UPDATE_REPO }) {
|
|
222
|
+
export function assessUpdate({ current, releases, repo = UPDATE_REPO, channel = 'github' }) {
|
|
124
223
|
const parsedCurrent = parseSemver(current)
|
|
125
224
|
if (!parsedCurrent) {
|
|
126
225
|
return {
|
|
@@ -131,15 +230,7 @@ export function assessUpdate({ current, releases, repo = UPDATE_REPO }) {
|
|
|
131
230
|
message: '当前版本号读不出来,没法对比。',
|
|
132
231
|
}
|
|
133
232
|
}
|
|
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
|
-
}
|
|
233
|
+
const stable = stableReleases(releases)
|
|
143
234
|
if (stable.length === 0) {
|
|
144
235
|
return {
|
|
145
236
|
ok: true,
|
|
@@ -152,8 +243,9 @@ export function assessUpdate({ current, releases, repo = UPDATE_REPO }) {
|
|
|
152
243
|
stable.sort((left, right) => compareSemver(right.parsed, left.parsed) ?? 0)
|
|
153
244
|
const latest = stable[0]
|
|
154
245
|
const summary = summarizeReleaseNotes(latest.rel.body)
|
|
155
|
-
const
|
|
156
|
-
const
|
|
246
|
+
const useNpm = channel === 'npm'
|
|
247
|
+
const spec = useNpm ? npmInstallSpec(latest.parsed.raw) : installSpec(latest.parsed.raw, repo)
|
|
248
|
+
const command = useNpm ? npmInstallCommand(latest.parsed.raw) : installCommand(latest.parsed.raw, repo)
|
|
157
249
|
const url = safeReleaseUrl(latest.rel, latest.tag, repo)
|
|
158
250
|
const compared = compareSemver(latest.parsed, parsedCurrent)
|
|
159
251
|
const base = {
|
|
@@ -171,7 +263,9 @@ export function assessUpdate({ current, releases, repo = UPDATE_REPO }) {
|
|
|
171
263
|
if (summary) lines.push(`说明:${summary}`)
|
|
172
264
|
lines.push(`升级命令:${command}`)
|
|
173
265
|
lines.push('装完请重启 dsh-web。')
|
|
174
|
-
lines.push(
|
|
266
|
+
if (useNpm) lines.push(`也可以在设置卡片里点「更新到 v${latest.parsed.raw}」,面板会从 npm 装上并重启。`)
|
|
267
|
+
else lines.push(`也可以在设置卡片里点「更新到 v${latest.parsed.raw}」,面板会自己装并重启。`)
|
|
268
|
+
if (!useNpm) lines.push(`发布页:${url}`)
|
|
175
269
|
return { ...base, kind: 'update', updateAvailable: true, message: lines.join('\n') }
|
|
176
270
|
}
|
|
177
271
|
if (compared < 0) {
|
|
@@ -255,18 +349,1636 @@ export async function queryGithubReleases(fetchImpl, options = {}) {
|
|
|
255
349
|
return { ok: true, releases: data }
|
|
256
350
|
}
|
|
257
351
|
|
|
352
|
+
function versionsToReleases(versions) {
|
|
353
|
+
return versions.map((version) => {
|
|
354
|
+
const parsed = parseSemver(version)
|
|
355
|
+
const tag = parsed ? `v${parsed.raw}` : String(version)
|
|
356
|
+
return { tag_name: tag, draft: false, prerelease: false, body: '' }
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function stableVersionList(data) {
|
|
361
|
+
const versions = data && typeof data === 'object' && !Array.isArray(data) ? data.versions : null
|
|
362
|
+
if (!versions || typeof versions !== 'object' || Array.isArray(versions)) return null
|
|
363
|
+
const stable = []
|
|
364
|
+
for (const key of Object.keys(versions)) {
|
|
365
|
+
const parsed = parseSemver(key)
|
|
366
|
+
if (!parsed || parsed.pre.length > 0) continue
|
|
367
|
+
stable.push(parsed.raw)
|
|
368
|
+
}
|
|
369
|
+
return stable
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* 读用户已经配好的 npm registry。只认 https 且没有路径的地址,读不到就用官方源。
|
|
374
|
+
* @param {typeof spawn} [spawnImpl]
|
|
375
|
+
* @param {string} [platform]
|
|
376
|
+
*/
|
|
377
|
+
export async function readNpmRegistry(spawnImpl = spawn, platform = process.platform) {
|
|
378
|
+
const fromEnv = normalizeRegistry(process.env.npm_config_registry || process.env.NPM_CONFIG_REGISTRY || '')
|
|
379
|
+
if (fromEnv !== '') return fromEnv
|
|
380
|
+
const npm = platform === 'win32' ? 'npm.cmd' : 'npm'
|
|
381
|
+
const ran = await runChild(spawnImpl, npm, ['config', 'get', 'registry'], { timeoutMs: 5000, capture: true })
|
|
382
|
+
const configured = normalizeRegistry(ran.stdout)
|
|
383
|
+
return configured === '' ? NPM_REGISTRY : configured
|
|
384
|
+
}
|
|
385
|
+
|
|
258
386
|
/**
|
|
259
|
-
*
|
|
387
|
+
* 向 registry 要这个包的正式版列表。404 表示还没发布。跳转只留在同一个源上。
|
|
388
|
+
* @param {{ registry?: string, name?: string, fetchImpl?: typeof fetch, timeoutMs?: number }} [input]
|
|
389
|
+
*/
|
|
390
|
+
export async function queryNpmVersions(input = {}) {
|
|
391
|
+
const registry = normalizeRegistry(input.registry || NPM_REGISTRY) || NPM_REGISTRY
|
|
392
|
+
const name = input.name || PACKAGE_NAME
|
|
393
|
+
const url = npmPackageUrl(registry, name)
|
|
394
|
+
if (url === '') return { ok: false, code: 'unpublished' }
|
|
395
|
+
const fetchFn = input.fetchImpl === undefined ? globalThis.fetch : input.fetchImpl
|
|
396
|
+
if (typeof fetchFn !== 'function') return { ok: false, code: 'network' }
|
|
397
|
+
const timeoutMs = input.timeoutMs ?? 15000
|
|
398
|
+
let current = url
|
|
399
|
+
const origin = new URL(url).origin
|
|
400
|
+
for (let hop = 0; hop < 3; hop++) {
|
|
401
|
+
let response
|
|
402
|
+
try {
|
|
403
|
+
response = await fetchFn(current, {
|
|
404
|
+
redirect: 'manual',
|
|
405
|
+
headers: { accept: 'application/json', 'user-agent': 'dsh-qq-bot' },
|
|
406
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
407
|
+
})
|
|
408
|
+
} catch {
|
|
409
|
+
return { ok: false, code: 'network' }
|
|
410
|
+
}
|
|
411
|
+
const status = Number(response?.status)
|
|
412
|
+
if (status >= 300 && status < 400) {
|
|
413
|
+
const location = headerValue(response, 'location')
|
|
414
|
+
let next
|
|
415
|
+
try {
|
|
416
|
+
next = new URL(location, current)
|
|
417
|
+
} catch {
|
|
418
|
+
return { ok: false, code: 'network' }
|
|
419
|
+
}
|
|
420
|
+
if (next.origin !== origin || registryPackagePath(next.pathname) !== `/${name}` || next.username !== '' || next.password !== '') {
|
|
421
|
+
return { ok: false, code: 'network' }
|
|
422
|
+
}
|
|
423
|
+
current = next.toString()
|
|
424
|
+
continue
|
|
425
|
+
}
|
|
426
|
+
if (status === 404) return { ok: false, code: 'unpublished' }
|
|
427
|
+
if (!response || response.ok !== true) return { ok: false, code: 'network' }
|
|
428
|
+
let data
|
|
429
|
+
try {
|
|
430
|
+
data = await response.json()
|
|
431
|
+
} catch {
|
|
432
|
+
return { ok: false, code: 'network' }
|
|
433
|
+
}
|
|
434
|
+
const versions = stableVersionList(data)
|
|
435
|
+
if (!versions) return { ok: false, code: 'unpublished' }
|
|
436
|
+
if (versions.length === 0) return { ok: false, code: 'unpublished' }
|
|
437
|
+
return { ok: true, versions, channel: 'npm' }
|
|
438
|
+
}
|
|
439
|
+
return { ok: false, code: 'network' }
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function noteGithubFallback(assessed) {
|
|
443
|
+
if (!assessed || assessed.updateAvailable !== true) return assessed
|
|
444
|
+
const message = String(assessed.message ?? '')
|
|
445
|
+
if (message.includes('还没发到 npm') || !message.includes('也可以在设置卡片里点')) return assessed
|
|
446
|
+
return {
|
|
447
|
+
...assessed,
|
|
448
|
+
message: message.replace(
|
|
449
|
+
'也可以在设置卡片里点',
|
|
450
|
+
'这一版还没发到 npm,一键更新会改从 GitHub 下载。\n也可以在设置卡片里点',
|
|
451
|
+
),
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function lookupFailure(npmCode, githubError, applying) {
|
|
456
|
+
const raw = String(githubError ?? '')
|
|
457
|
+
if (npmCode === 'network' && /连不上 GitHub/.test(raw)) {
|
|
458
|
+
return applying
|
|
459
|
+
? '连不上 npm 的源,也连不上 GitHub,这次没有把新版本下下来。稍后再试。'
|
|
460
|
+
: '连不上 npm 的源,也连不上 GitHub,这次没有检查到更新。稍后再试。'
|
|
461
|
+
}
|
|
462
|
+
return applying ? applyGithubError(raw) : raw
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* 先看 npm。有比当前新的正式版就用它,不再问 GitHub。
|
|
467
|
+
* npm 上没有更新时,再用很短的时间看 GitHub 有没有还没发出去的版本;连不上就沿用 npm 的结论。
|
|
468
|
+
* 包还没发布,或 npm 本身连不上时,改问 GitHub Releases。
|
|
469
|
+
* @param {{ current: unknown, version?: unknown, force?: boolean, repo?: string, fetchImpl?: typeof fetch, registry?: string, spawnImpl?: typeof spawn, platform?: string, timeoutMs?: number, applying?: boolean }} input
|
|
470
|
+
*/
|
|
471
|
+
async function findUpdate(input) {
|
|
472
|
+
const repo = input.repo || UPDATE_REPO
|
|
473
|
+
const registry = input.registry || (input.fetchImpl !== undefined ? NPM_REGISTRY : await readNpmRegistry(input.spawnImpl, input.platform))
|
|
474
|
+
const npm = await queryNpmVersions({ registry, fetchImpl: input.fetchImpl, timeoutMs: input.timeoutMs, name: PACKAGE_NAME })
|
|
475
|
+
const wanted = String(input.version ?? '').trim()
|
|
476
|
+
if (npm.ok) {
|
|
477
|
+
const releases = versionsToReleases(npm.versions)
|
|
478
|
+
const assessed = assessUpdate({ current: input.current, releases, repo, channel: 'npm' })
|
|
479
|
+
if (assessed.ok === false) return { ok: false, error: assessed.message }
|
|
480
|
+
const onNpm = wanted === '' || releases.some((rel) => {
|
|
481
|
+
const parsed = parseSemver(rel.tag_name)
|
|
482
|
+
const asked = parseSemver(wanted)
|
|
483
|
+
return parsed && asked && parsed.raw === asked.raw
|
|
484
|
+
})
|
|
485
|
+
if (wanted === '' && assessed.updateAvailable === true) return { ok: true, channel: 'npm', releases, assessed }
|
|
486
|
+
if (wanted !== '' && onNpm) return { ok: true, channel: 'npm', releases, assessed }
|
|
487
|
+
const peek = wanted === ''
|
|
488
|
+
const gh = await queryGithubReleases(input.fetchImpl, { repo, timeoutMs: peek ? (input.timeoutMs ?? 4000) : input.timeoutMs })
|
|
489
|
+
if (!gh.ok) {
|
|
490
|
+
if (wanted !== '') return { ok: false, error: '这次检查到的正式版里没有这个版本,没有安装。' }
|
|
491
|
+
return { ok: true, channel: 'npm', releases, assessed }
|
|
492
|
+
}
|
|
493
|
+
const ghAssessed = noteGithubFallback(assessUpdate({ current: input.current, releases: gh.releases, repo, channel: 'github' }))
|
|
494
|
+
if (ghAssessed.ok === false) return { ok: false, error: ghAssessed.message }
|
|
495
|
+
const npmLatest = assessed.latest || '0.0.0'
|
|
496
|
+
const githubNewer = ghAssessed.updateAvailable === true && (compareSemver(ghAssessed.latest, npmLatest) ?? 0) > 0
|
|
497
|
+
const githubHasWanted = wanted !== '' && stableReleases(gh.releases).some((item) => {
|
|
498
|
+
const asked = parseSemver(wanted)
|
|
499
|
+
return asked && item.parsed.raw === asked.raw
|
|
500
|
+
})
|
|
501
|
+
if ((wanted === '' && githubNewer) || githubHasWanted) {
|
|
502
|
+
return { ok: true, channel: 'github', releases: gh.releases, assessed: ghAssessed }
|
|
503
|
+
}
|
|
504
|
+
if (wanted !== '') return { ok: false, error: '这次检查到的正式版里没有这个版本,没有安装。' }
|
|
505
|
+
return { ok: true, channel: 'npm', releases, assessed }
|
|
506
|
+
}
|
|
507
|
+
const gh = await queryGithubReleases(input.fetchImpl, { repo, timeoutMs: input.timeoutMs })
|
|
508
|
+
if (!gh.ok) return { ok: false, error: lookupFailure(npm.code, gh.error, input.applying === true) }
|
|
509
|
+
const assessed = noteGithubFallback(assessUpdate({ current: input.current, releases: gh.releases, repo, channel: 'github' }))
|
|
510
|
+
if (assessed.ok === false) return { ok: false, error: assessed.message }
|
|
511
|
+
return { ok: true, channel: 'github', releases: gh.releases, assessed }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* @param {{ current: unknown, fetchImpl?: typeof fetch, repo?: string, registry?: string, spawnImpl?: typeof spawn, platform?: string, timeoutMs?: number }} [input]
|
|
260
516
|
*/
|
|
261
517
|
export async function checkPluginUpdate(input = {}) {
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
return { ok: false, current
|
|
518
|
+
const found = await findUpdate({ ...input, applying: false })
|
|
519
|
+
if (!found.ok) {
|
|
520
|
+
const current = String(input.current ?? '')
|
|
521
|
+
return { ok: false, current, error: found.error, message: found.error }
|
|
522
|
+
}
|
|
523
|
+
return found.assessed
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function stableTag(version, repo = UPDATE_REPO) {
|
|
527
|
+
const parsed = parseSemver(version)
|
|
528
|
+
if (!parsed || parsed.pre.length > 0 || !isSafeRepo(repo)) return ''
|
|
529
|
+
if (!/^[0-9.]+$/.test(parsed.raw)) return ''
|
|
530
|
+
return `v${parsed.raw}`
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Release 页面上的官方源码包。先请求这个地址,不采用正文里的链接,也不用第三方镜像。
|
|
535
|
+
* @param {unknown} version
|
|
536
|
+
* @param {string} [repo]
|
|
537
|
+
*/
|
|
538
|
+
export function githubArchiveUrl(version, repo = UPDATE_REPO) {
|
|
539
|
+
const tag = stableTag(version, repo)
|
|
540
|
+
if (tag === '') return ''
|
|
541
|
+
return `https://github.com/${repo}/archive/refs/tags/${tag}.tar.gz`
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* 同一标签在 codeload 上的地址。只在 github.com 连不上时作备用,仍然是 GitHub。
|
|
546
|
+
* @param {unknown} version
|
|
547
|
+
* @param {string} [repo]
|
|
548
|
+
*/
|
|
549
|
+
export function codeloadTarballUrl(version, repo = UPDATE_REPO) {
|
|
550
|
+
const tag = stableTag(version, repo)
|
|
551
|
+
if (tag === '') return ''
|
|
552
|
+
return `https://codeload.github.com/${repo}/tar.gz/refs/tags/${tag}`
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* 决定这次允许安装哪个标签。
|
|
557
|
+
* 没给版本就用本次检查里最新的正式版,而且必须比当前新。
|
|
558
|
+
* 给了版本就必须出现在本次正式版列表里,并且更新;force 为真时才允许重装同一个版本。
|
|
559
|
+
* 更旧的标签即使 force 也不装。
|
|
560
|
+
* @param {{ current: unknown, releases: unknown, version?: unknown, force?: boolean, repo?: string, channel?: 'npm' | 'github' }} input
|
|
561
|
+
*/
|
|
562
|
+
export function chooseApplyTarget(input) {
|
|
563
|
+
const repo = input.repo || UPDATE_REPO
|
|
564
|
+
const assessed = assessUpdate({ current: input.current, releases: input.releases, repo })
|
|
565
|
+
if (assessed.ok === false) return { ok: false, error: assessed.message }
|
|
566
|
+
const stable = stableReleases(input.releases)
|
|
567
|
+
const requested = String(input.version ?? '').trim()
|
|
568
|
+
/** @type {ReturnType<typeof parseSemver>} */
|
|
569
|
+
let parsed
|
|
570
|
+
if (requested === '') {
|
|
571
|
+
if (assessed.updateAvailable !== true) return { ok: false, error: assessed.message }
|
|
572
|
+
parsed = parseSemver(assessed.latest)
|
|
573
|
+
} else {
|
|
574
|
+
parsed = parseSemver(requested)
|
|
575
|
+
if (!parsed || parsed.pre.length > 0) return { ok: false, error: '版本号格式不对,没有安装。' }
|
|
576
|
+
}
|
|
577
|
+
if (!parsed) return { ok: false, error: '版本号格式不对,没有安装。' }
|
|
578
|
+
const match = stable.find((item) => item.parsed.raw === parsed.raw)
|
|
579
|
+
if (!match) return { ok: false, error: '这次检查到的正式版里没有这个版本,没有安装。' }
|
|
580
|
+
const compared = compareSemver(parsed, assessed.current)
|
|
581
|
+
if (compared == null) return { ok: false, error: '版本号对不上,没有安装。' }
|
|
582
|
+
if (compared < 0) return { ok: false, error: '不会安装比当前更旧的版本。' }
|
|
583
|
+
if (compared === 0 && input.force !== true) return { ok: false, error: '已经是这个版本,没有重装。' }
|
|
584
|
+
const channel = input.channel === 'npm' ? 'npm' : 'github'
|
|
585
|
+
const tarballUrl = githubArchiveUrl(parsed.raw, repo)
|
|
586
|
+
const fallbackUrl = codeloadTarballUrl(parsed.raw, repo)
|
|
587
|
+
const install = channel === 'npm' ? npmInstallSpec(parsed.raw) : installSpec(parsed.raw, repo)
|
|
588
|
+
const command = channel === 'npm' ? npmInstallCommand(parsed.raw) : installCommand(parsed.raw, repo)
|
|
589
|
+
if (tarballUrl === '' || fallbackUrl === '' || install === '' || command === '') {
|
|
590
|
+
return { ok: false, error: '这个版本的安装说明拼不出来,没有安装。' }
|
|
591
|
+
}
|
|
592
|
+
return {
|
|
593
|
+
ok: true,
|
|
594
|
+
version: parsed.raw,
|
|
595
|
+
tag: `v${parsed.raw}`,
|
|
596
|
+
channel,
|
|
597
|
+
install,
|
|
598
|
+
command,
|
|
599
|
+
tarballUrl,
|
|
600
|
+
fallbackUrl,
|
|
601
|
+
summary: summarizeReleaseNotes(match.rel.body),
|
|
602
|
+
url: safeReleaseUrl(match.rel, match.tag, repo),
|
|
603
|
+
current: assessed.current,
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function applyGithubError(text) {
|
|
608
|
+
const raw = String(text ?? '')
|
|
609
|
+
if (/限流/.test(raw)) return 'GitHub 限流了,这次没有更新。等一会儿再试。'
|
|
610
|
+
if (/没有查到/.test(raw)) return '没有查到这个仓库的 Release,没有安装。'
|
|
611
|
+
if (/读不懂/.test(raw)) return 'GitHub 返回的内容读不懂,没有安装。'
|
|
612
|
+
return '连不上 GitHub,这次没有把新版本下下来。稍后再试。'
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function downloadError(code) {
|
|
616
|
+
if (code === 'rate') return 'GitHub 限流了,这次没有把新版本下下来。等一会儿再试。'
|
|
617
|
+
if (code === 'missing') return '没有下到这个版本的安装包。'
|
|
618
|
+
if (code === 'huge') return '安装包太大,没有安装。'
|
|
619
|
+
return '连不上 GitHub,这次没有把新版本下下来。稍后再试。'
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function installError(code, command, extra = {}) {
|
|
623
|
+
const hint = command ? `也可以自己执行:${command}` : '也可以按卡片上的安装命令自己装。'
|
|
624
|
+
if (code === 'ENOENT') return `没找到 dsh 命令,没有安装。${hint}`
|
|
625
|
+
if (code === 'busy') {
|
|
626
|
+
const pid = Number(extra.pid)
|
|
627
|
+
const which = Number.isSafeInteger(pid) && pid > 1 ? `(进程 ${pid})` : ''
|
|
628
|
+
return `已经有一个安装进程还在跑${which},这次没有再启动。等它结束再点「更新」。如果确认它已经卡死,先停掉那个进程,再点一次。`
|
|
629
|
+
}
|
|
630
|
+
if (code === 'timeout' && extra.packageName) {
|
|
631
|
+
return `安装超时,已经停掉卡住的进程。新版本的文件已经在磁盘上,qq-bridge 的包名也改成了 ${extra.packageName}。请重启 dsh-web,设置页才会出来。`
|
|
632
|
+
}
|
|
633
|
+
if (code === 'timeout' && extra.leftover) {
|
|
634
|
+
return `安装超时,卡住的安装进程还没停干净。请先停掉 dsh 和 pnpm,再点一次「更新」。${hint}`
|
|
635
|
+
}
|
|
636
|
+
if (code === 'timeout') {
|
|
637
|
+
return `安装超时,已经停掉卡住的安装进程,并清掉这次留下的安装记录。没有确认装上。可以再点一次「更新」。${hint}`
|
|
638
|
+
}
|
|
639
|
+
if (code === 'wrong-name') return '下到的安装包不是这个插件,没有安装。'
|
|
640
|
+
if (code === 'wrong-version') return '下到的版本和发布标签对不上,没有安装。'
|
|
641
|
+
if (code === 'bad-package') return '下到的安装包读不懂,没有安装。'
|
|
642
|
+
const base = `安装没有成功。${hint}`
|
|
643
|
+
if (extra.packageName) return `${base}Cordis 里 qq-bridge 的包名已经改成实际安装的 ${extra.packageName}。请重启 dsh-web,设置页才会出来。`
|
|
644
|
+
return base
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function restartError() {
|
|
648
|
+
return '新版本已经装上,但面板没有重启。请自己重启 dsh-web,装好的才会生效。重启后请刷新页面,登录口令可能会变。'
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function headerValue(response, name) {
|
|
652
|
+
const headers = response?.headers
|
|
653
|
+
if (!headers) return ''
|
|
654
|
+
if (typeof headers.get === 'function') return String(headers.get(name) ?? '')
|
|
655
|
+
const key = String(name).toLowerCase()
|
|
656
|
+
if (typeof headers === 'object') {
|
|
657
|
+
for (const [candidate, value] of Object.entries(headers)) {
|
|
658
|
+
if (String(candidate).toLowerCase() === key) return String(value ?? '')
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return ''
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** 只认 GitHub 自己的三个主机,并且路径必须还是这个仓库的这个标签。 */
|
|
665
|
+
function officialDownloadKind(url, repo, tag) {
|
|
666
|
+
let parsed
|
|
667
|
+
try {
|
|
668
|
+
parsed = new URL(String(url))
|
|
669
|
+
} catch {
|
|
670
|
+
return ''
|
|
671
|
+
}
|
|
672
|
+
if (parsed.protocol !== 'https:' || parsed.username !== '' || parsed.password !== '') return ''
|
|
673
|
+
const path = decodeURIComponent(parsed.pathname).replace(/\/+$/, '')
|
|
674
|
+
if (parsed.hostname === 'github.com') {
|
|
675
|
+
if (path === `/${repo}/archive/refs/tags/${tag}.tar.gz` || path === `/${repo}/archive/${tag}.tar.gz`) return 'github'
|
|
676
|
+
return ''
|
|
677
|
+
}
|
|
678
|
+
if (parsed.hostname === 'api.github.com') {
|
|
679
|
+
if (path === `/repos/${repo}/tarball/${tag}` || path === `/repos/${repo}/tarball/refs/tags/${tag}`) return 'api'
|
|
680
|
+
return ''
|
|
681
|
+
}
|
|
682
|
+
if (parsed.hostname === 'codeload.github.com') {
|
|
683
|
+
if (path === `/${repo}/tar.gz/refs/tags/${tag}` || path === `/${repo}/legacy.tar.gz/refs/tags/${tag}`) return 'codeload'
|
|
684
|
+
return ''
|
|
685
|
+
}
|
|
686
|
+
return ''
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function assertReleaseDownloadUrl(url, repo, tag) {
|
|
690
|
+
if (officialDownloadKind(url, repo, tag) === '') {
|
|
691
|
+
const error = new Error('bad-host')
|
|
692
|
+
error.code = 'network'
|
|
693
|
+
throw error
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* 下载标签压缩包。先请求官方地址;只有连不上时才改试 codeload。
|
|
699
|
+
* 跳转只跟到同一标签的 GitHub 地址,第三方镜像直接丢掉。
|
|
700
|
+
* @param {{ url: string, fallbackUrl?: string, repo?: string, tag: string, fetchImpl?: typeof fetch, proxy?: string, timeoutMs?: number, maxBytes?: number, spawnImpl?: typeof spawn, platform?: string }} input
|
|
701
|
+
*/
|
|
702
|
+
export async function downloadReleaseTarball(input) {
|
|
703
|
+
try {
|
|
704
|
+
return await downloadReleaseOnce(input)
|
|
705
|
+
} catch (error) {
|
|
706
|
+
const fallback = String(input.fallbackUrl ?? '')
|
|
707
|
+
if (error?.code !== 'network' || fallback === '' || fallback === input.url) throw error
|
|
708
|
+
return await downloadReleaseOnce({ ...input, url: fallback })
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async function downloadReleaseOnce(input) {
|
|
713
|
+
const repo = input.repo || UPDATE_REPO
|
|
714
|
+
const proxy = String(input.proxy ?? '').trim()
|
|
715
|
+
if (proxy !== '') return downloadReleaseViaCurl(input, proxy)
|
|
716
|
+
const fetchFn = input.fetchImpl === undefined ? globalThis.fetch : input.fetchImpl
|
|
717
|
+
if (typeof fetchFn !== 'function') {
|
|
718
|
+
const error = new Error('no-fetch')
|
|
719
|
+
error.code = 'network'
|
|
720
|
+
throw error
|
|
721
|
+
}
|
|
722
|
+
const maxBytes = input.maxBytes ?? MAX_TARBALL_BYTES
|
|
723
|
+
const timeoutMs = input.timeoutMs ?? DOWNLOAD_TIMEOUT_MS
|
|
724
|
+
let current = input.url
|
|
725
|
+
for (let hop = 0; hop < 4; hop++) {
|
|
726
|
+
assertReleaseDownloadUrl(current, repo, input.tag)
|
|
727
|
+
let response
|
|
728
|
+
try {
|
|
729
|
+
response = await fetchFn(current, {
|
|
730
|
+
redirect: 'manual',
|
|
731
|
+
headers: { accept: 'application/octet-stream', 'user-agent': 'dsh-qq-bot' },
|
|
732
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
733
|
+
})
|
|
734
|
+
} catch {
|
|
735
|
+
const error = new Error('fetch')
|
|
736
|
+
error.code = 'network'
|
|
737
|
+
throw error
|
|
738
|
+
}
|
|
739
|
+
const status = Number(response?.status)
|
|
740
|
+
if (status >= 300 && status < 400) {
|
|
741
|
+
const location = headerValue(response, 'location')
|
|
742
|
+
if (location === '') {
|
|
743
|
+
const error = new Error('redirect')
|
|
744
|
+
error.code = 'network'
|
|
745
|
+
throw error
|
|
746
|
+
}
|
|
747
|
+
try {
|
|
748
|
+
current = new URL(location, current).toString()
|
|
749
|
+
} catch {
|
|
750
|
+
const error = new Error('redirect')
|
|
751
|
+
error.code = 'network'
|
|
752
|
+
throw error
|
|
753
|
+
}
|
|
754
|
+
continue
|
|
755
|
+
}
|
|
756
|
+
if (status === 403 || status === 429) {
|
|
757
|
+
const error = new Error('rate')
|
|
758
|
+
error.code = 'rate'
|
|
759
|
+
throw error
|
|
760
|
+
}
|
|
761
|
+
if (status === 404) {
|
|
762
|
+
const error = new Error('missing')
|
|
763
|
+
error.code = 'missing'
|
|
764
|
+
throw error
|
|
765
|
+
}
|
|
766
|
+
if (!response || response.ok !== true) {
|
|
767
|
+
const error = new Error('status')
|
|
768
|
+
error.code = 'network'
|
|
769
|
+
throw error
|
|
770
|
+
}
|
|
771
|
+
const declared = Number(headerValue(response, 'content-length'))
|
|
772
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
773
|
+
const error = new Error('huge')
|
|
774
|
+
error.code = 'huge'
|
|
775
|
+
throw error
|
|
776
|
+
}
|
|
777
|
+
let bytes
|
|
778
|
+
try {
|
|
779
|
+
bytes = Buffer.from(await response.arrayBuffer())
|
|
780
|
+
} catch {
|
|
781
|
+
const error = new Error('body')
|
|
782
|
+
error.code = 'network'
|
|
783
|
+
throw error
|
|
784
|
+
}
|
|
785
|
+
if (bytes.length > maxBytes) {
|
|
786
|
+
const error = new Error('huge')
|
|
787
|
+
error.code = 'huge'
|
|
788
|
+
throw error
|
|
789
|
+
}
|
|
790
|
+
return bytes
|
|
791
|
+
}
|
|
792
|
+
const error = new Error('redirect')
|
|
793
|
+
error.code = 'network'
|
|
794
|
+
throw error
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function splitCurlStatus(buffer) {
|
|
798
|
+
const marker = Buffer.from('\n__HTTP__')
|
|
799
|
+
const at = buffer.lastIndexOf(marker)
|
|
800
|
+
if (at < 0) return null
|
|
801
|
+
const meta = buffer.slice(at + marker.length).toString('utf8').trim()
|
|
802
|
+
const line = meta.split('\n')
|
|
803
|
+
const status = Number(line[0])
|
|
804
|
+
const redirectUrl = (line[1] ?? '').trim()
|
|
805
|
+
if (!Number.isFinite(status)) return null
|
|
806
|
+
return { status, redirectUrl, body: buffer.slice(0, at) }
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async function curlOnce(spawnImpl, curl, args, maxBytes) {
|
|
810
|
+
const chunks = []
|
|
811
|
+
let size = 0
|
|
812
|
+
let settled = false
|
|
813
|
+
return await new Promise((resolve) => {
|
|
814
|
+
let child
|
|
815
|
+
const finish = (value) => {
|
|
816
|
+
if (settled) return
|
|
817
|
+
settled = true
|
|
818
|
+
resolve(value)
|
|
819
|
+
}
|
|
820
|
+
try {
|
|
821
|
+
child = spawnImpl(curl, args, { shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
822
|
+
} catch {
|
|
823
|
+
finish({ ok: false, code: 'network' })
|
|
824
|
+
return
|
|
825
|
+
}
|
|
826
|
+
if (!child || typeof child.on !== 'function') {
|
|
827
|
+
finish({ ok: false, code: 'network' })
|
|
828
|
+
return
|
|
829
|
+
}
|
|
830
|
+
child.stdout?.on?.('data', (chunk) => {
|
|
831
|
+
size += chunk.length
|
|
832
|
+
if (size > maxBytes + 1024) {
|
|
833
|
+
try { child.kill('SIGTERM') } catch { /* 超限就停 */ }
|
|
834
|
+
finish({ ok: false, code: 'huge' })
|
|
835
|
+
return
|
|
836
|
+
}
|
|
837
|
+
chunks.push(Buffer.from(chunk))
|
|
838
|
+
})
|
|
839
|
+
child.stderr?.on?.('data', () => {})
|
|
840
|
+
child.on('error', () => finish({ ok: false, code: 'network' }))
|
|
841
|
+
child.on('close', (code) => {
|
|
842
|
+
if (code !== 0) finish({ ok: false, code: 'network' })
|
|
843
|
+
else finish({ ok: true, body: Buffer.concat(chunks) })
|
|
844
|
+
})
|
|
845
|
+
})
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async function downloadReleaseViaCurl(input, proxy) {
|
|
849
|
+
const spawnImpl = input.spawnImpl || spawn
|
|
850
|
+
const platform = input.platform || process.platform
|
|
851
|
+
const curl = platform === 'win32' ? 'curl.exe' : 'curl'
|
|
852
|
+
const timeoutSec = Math.max(1, Math.ceil((input.timeoutMs ?? DOWNLOAD_TIMEOUT_MS) / 1000))
|
|
853
|
+
const maxBytes = input.maxBytes ?? MAX_TARBALL_BYTES
|
|
854
|
+
const repo = input.repo || UPDATE_REPO
|
|
855
|
+
let current = input.url
|
|
856
|
+
for (let hop = 0; hop < 4; hop++) {
|
|
857
|
+
assertReleaseDownloadUrl(current, repo, input.tag)
|
|
858
|
+
const ran = await curlOnce(spawnImpl, curl, [
|
|
859
|
+
'-sS',
|
|
860
|
+
'--max-time',
|
|
861
|
+
String(timeoutSec),
|
|
862
|
+
'--retry',
|
|
863
|
+
'0',
|
|
864
|
+
'-A',
|
|
865
|
+
'dsh-qq-bot',
|
|
866
|
+
'-x',
|
|
867
|
+
proxy,
|
|
868
|
+
'-w',
|
|
869
|
+
'\n__HTTP__%{http_code}\n%{redirect_url}',
|
|
870
|
+
current,
|
|
871
|
+
], maxBytes)
|
|
872
|
+
if (!ran.ok) {
|
|
873
|
+
const error = new Error(String(ran.code || 'network'))
|
|
874
|
+
error.code = ran.code === 'huge' ? 'huge' : 'network'
|
|
875
|
+
throw error
|
|
876
|
+
}
|
|
877
|
+
const parsed = splitCurlStatus(ran.body)
|
|
878
|
+
if (!parsed) {
|
|
879
|
+
const error = new Error('curl-status')
|
|
880
|
+
error.code = 'network'
|
|
881
|
+
throw error
|
|
882
|
+
}
|
|
883
|
+
if (parsed.status >= 300 && parsed.status < 400) {
|
|
884
|
+
if (parsed.redirectUrl === '') {
|
|
885
|
+
const error = new Error('redirect')
|
|
886
|
+
error.code = 'network'
|
|
887
|
+
throw error
|
|
888
|
+
}
|
|
889
|
+
try {
|
|
890
|
+
current = new URL(parsed.redirectUrl, current).toString()
|
|
891
|
+
} catch {
|
|
892
|
+
const error = new Error('redirect')
|
|
893
|
+
error.code = 'network'
|
|
894
|
+
throw error
|
|
895
|
+
}
|
|
896
|
+
continue
|
|
897
|
+
}
|
|
898
|
+
if (parsed.status === 403 || parsed.status === 429) {
|
|
899
|
+
const error = new Error('rate')
|
|
900
|
+
error.code = 'rate'
|
|
901
|
+
throw error
|
|
902
|
+
}
|
|
903
|
+
if (parsed.status === 404) {
|
|
904
|
+
const error = new Error('missing')
|
|
905
|
+
error.code = 'missing'
|
|
906
|
+
throw error
|
|
907
|
+
}
|
|
908
|
+
if (parsed.status !== 200) {
|
|
909
|
+
const error = new Error('status')
|
|
910
|
+
error.code = 'network'
|
|
911
|
+
throw error
|
|
912
|
+
}
|
|
913
|
+
if (parsed.body.length > maxBytes) {
|
|
914
|
+
const error = new Error('huge')
|
|
915
|
+
error.code = 'huge'
|
|
916
|
+
throw error
|
|
917
|
+
}
|
|
918
|
+
return parsed.body
|
|
919
|
+
}
|
|
920
|
+
const error = new Error('redirect')
|
|
921
|
+
error.code = 'network'
|
|
922
|
+
throw error
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function parseOctal(field) {
|
|
926
|
+
const text = Buffer.from(field).toString('utf8').replace(/\0.*$/, '').trim()
|
|
927
|
+
if (text === '') return 0
|
|
928
|
+
if (!/^[0-7]+$/.test(text)) return null
|
|
929
|
+
return Number.parseInt(text, 8)
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function readTarEntries(raw) {
|
|
933
|
+
const entries = []
|
|
934
|
+
let offset = 0
|
|
935
|
+
let longName = ''
|
|
936
|
+
let unpacked = 0
|
|
937
|
+
while (offset + 512 <= raw.length) {
|
|
938
|
+
const header = raw.subarray(offset, offset + 512)
|
|
939
|
+
if (header.every((byte) => byte === 0)) break
|
|
940
|
+
if (entries.length > MAX_TAR_ENTRIES) {
|
|
941
|
+
const error = new Error('too-many')
|
|
942
|
+
error.code = 'bad-package'
|
|
943
|
+
throw error
|
|
944
|
+
}
|
|
945
|
+
let sum = 0
|
|
946
|
+
for (let i = 0; i < 512; i++) sum += i >= 148 && i < 156 ? 32 : header[i]
|
|
947
|
+
const stored = parseOctal(header.subarray(148, 156))
|
|
948
|
+
const size = parseOctal(header.subarray(124, 136))
|
|
949
|
+
if (stored == null || size == null || stored !== sum || size < 0) {
|
|
950
|
+
const error = new Error('checksum')
|
|
951
|
+
error.code = 'bad-package'
|
|
952
|
+
throw error
|
|
953
|
+
}
|
|
954
|
+
let name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/, '')
|
|
955
|
+
const prefix = header.subarray(345, 500).toString('utf8').replace(/\0.*$/, '')
|
|
956
|
+
if (longName !== '') {
|
|
957
|
+
name = longName
|
|
958
|
+
longName = ''
|
|
959
|
+
} else if (prefix !== '') {
|
|
960
|
+
name = `${prefix}/${name}`
|
|
961
|
+
}
|
|
962
|
+
const type = String.fromCharCode(header[156] || 0)
|
|
963
|
+
offset += 512
|
|
964
|
+
unpacked += size
|
|
965
|
+
if (unpacked > MAX_UNPACKED_BYTES) {
|
|
966
|
+
const error = new Error('unpacked')
|
|
967
|
+
error.code = 'bad-package'
|
|
968
|
+
throw error
|
|
969
|
+
}
|
|
970
|
+
const data = raw.subarray(offset, offset + size)
|
|
971
|
+
if (data.length !== size) {
|
|
972
|
+
const error = new Error('truncated')
|
|
973
|
+
error.code = 'bad-package'
|
|
974
|
+
throw error
|
|
975
|
+
}
|
|
976
|
+
offset += Math.ceil(size / 512) * 512
|
|
977
|
+
if (type === 'L') {
|
|
978
|
+
longName = data.toString('utf8').replace(/\0.*$/, '')
|
|
979
|
+
continue
|
|
980
|
+
}
|
|
981
|
+
if (type === '0' || type === '\0') entries.push({ name, data: Buffer.from(data) })
|
|
982
|
+
}
|
|
983
|
+
return entries
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function inflateTarball(bytes) {
|
|
987
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes ?? [])
|
|
988
|
+
if (buf.length >= 2 && buf[0] === 0x1f && buf[1] === 0x8b) {
|
|
989
|
+
try {
|
|
990
|
+
return gunzipSync(buf, { maxOutputLength: MAX_UNPACKED_BYTES })
|
|
991
|
+
} catch {
|
|
992
|
+
const error = new Error('gzip')
|
|
993
|
+
error.code = 'bad-package'
|
|
994
|
+
throw error
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
return buf
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function entryParts(name) {
|
|
1001
|
+
return String(name ?? '').replace(/^\.\//, '').split('/').filter((part) => part !== '' && part !== '.')
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function writeOctal(header, offset, value, length) {
|
|
1005
|
+
const text = Number(value).toString(8).padStart(length - 1, '0').slice(-(length - 1))
|
|
1006
|
+
header.write(text, offset, length - 1, 'ascii')
|
|
1007
|
+
header[offset + length - 1] = 0
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function tarHeader(name, size, typeflag = '0') {
|
|
1011
|
+
const header = Buffer.alloc(512, 0)
|
|
1012
|
+
header.write(name, 0, Math.min(Buffer.byteLength(name), 100), 'utf8')
|
|
1013
|
+
writeOctal(header, 100, 0o644, 8)
|
|
1014
|
+
writeOctal(header, 108, 0, 8)
|
|
1015
|
+
writeOctal(header, 116, 0, 8)
|
|
1016
|
+
writeOctal(header, 124, size, 12)
|
|
1017
|
+
writeOctal(header, 136, 0, 12)
|
|
1018
|
+
for (let i = 148; i < 156; i++) header[i] = 32
|
|
1019
|
+
header[156] = typeflag.charCodeAt(0)
|
|
1020
|
+
header.write('ustar\0', 257, 'ascii')
|
|
1021
|
+
header.write('00', 263, 'ascii')
|
|
1022
|
+
let sum = 0
|
|
1023
|
+
for (const byte of header) sum += byte
|
|
1024
|
+
const checksum = sum.toString(8).padStart(6, '0')
|
|
1025
|
+
header.write(checksum, 148, 6, 'ascii')
|
|
1026
|
+
header[154] = 0
|
|
1027
|
+
header[155] = 32
|
|
1028
|
+
return header
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function appendTarFile(chunks, name, data) {
|
|
1032
|
+
if (Buffer.byteLength(name) > 100) {
|
|
1033
|
+
const long = Buffer.from(`${name}\0`)
|
|
1034
|
+
chunks.push(tarHeader('././@LongLink', long.length, 'L'))
|
|
1035
|
+
chunks.push(long)
|
|
1036
|
+
const pad = (512 - (long.length % 512)) % 512
|
|
1037
|
+
if (pad > 0) chunks.push(Buffer.alloc(pad))
|
|
1038
|
+
chunks.push(tarHeader(name.slice(0, 100), data.length, '0'))
|
|
1039
|
+
} else {
|
|
1040
|
+
chunks.push(tarHeader(name, data.length, '0'))
|
|
1041
|
+
}
|
|
1042
|
+
chunks.push(data)
|
|
1043
|
+
const pad = (512 - (data.length % 512)) % 512
|
|
1044
|
+
if (pad > 0) chunks.push(Buffer.alloc(pad))
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* 核对包名和版本,并重打包成 `package/` 开头的 npm 压缩包。
|
|
1049
|
+
* `dsh plugin add` 认这种包,不认 git 仓库。
|
|
1050
|
+
* @param {Buffer} bytes
|
|
1051
|
+
* @param {{ name?: string, version: string }} expected
|
|
1052
|
+
*/
|
|
1053
|
+
export function repackReleaseTarball(bytes, expected) {
|
|
1054
|
+
let raw
|
|
1055
|
+
try {
|
|
1056
|
+
raw = inflateTarball(bytes)
|
|
1057
|
+
} catch (error) {
|
|
1058
|
+
if (error?.code === 'bad-package') throw error
|
|
1059
|
+
const wrapped = new Error('inflate')
|
|
1060
|
+
wrapped.code = 'bad-package'
|
|
1061
|
+
throw wrapped
|
|
1062
|
+
}
|
|
1063
|
+
let entries
|
|
1064
|
+
try {
|
|
1065
|
+
entries = readTarEntries(raw)
|
|
1066
|
+
} catch (error) {
|
|
1067
|
+
if (error?.code === 'bad-package') throw error
|
|
1068
|
+
const wrapped = new Error('tar')
|
|
1069
|
+
wrapped.code = 'bad-package'
|
|
1070
|
+
throw wrapped
|
|
1071
|
+
}
|
|
1072
|
+
const manifests = []
|
|
1073
|
+
for (const entry of entries) {
|
|
1074
|
+
const parts = entryParts(entry.name)
|
|
1075
|
+
if (parts.length === 2 && parts[1] === 'package.json' && !parts.some((part) => part === '..' || part.includes('\\') || part.includes('\0'))) {
|
|
1076
|
+
manifests.push({ root: parts[0], data: entry.data })
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
if (manifests.length !== 1) {
|
|
1080
|
+
const error = new Error('manifest-count')
|
|
1081
|
+
error.code = 'bad-package'
|
|
1082
|
+
throw error
|
|
1083
|
+
}
|
|
1084
|
+
let manifest
|
|
1085
|
+
try {
|
|
1086
|
+
manifest = JSON.parse(manifests[0].data.toString('utf8'))
|
|
1087
|
+
} catch {
|
|
1088
|
+
const error = new Error('json')
|
|
1089
|
+
error.code = 'bad-package'
|
|
1090
|
+
throw error
|
|
1091
|
+
}
|
|
1092
|
+
const wantName = expected.name || ''
|
|
1093
|
+
const nameOk = wantName !== ''
|
|
1094
|
+
? manifest?.name === wantName
|
|
1095
|
+
: manifest?.name === PACKAGE_NAME || manifest?.name === LEGACY_UNSCOPED_NAME
|
|
1096
|
+
if (!nameOk) {
|
|
1097
|
+
const error = new Error('name')
|
|
1098
|
+
error.code = 'wrong-name'
|
|
1099
|
+
throw error
|
|
1100
|
+
}
|
|
1101
|
+
if (compareSemver(manifest.version, expected.version) !== 0) {
|
|
1102
|
+
const error = new Error('version')
|
|
1103
|
+
error.code = 'wrong-version'
|
|
1104
|
+
throw error
|
|
1105
|
+
}
|
|
1106
|
+
const root = manifests[0].root
|
|
1107
|
+
const files = []
|
|
1108
|
+
for (const entry of entries) {
|
|
1109
|
+
const parts = entryParts(entry.name)
|
|
1110
|
+
if (parts.length < 2 || parts[0] !== root) continue
|
|
1111
|
+
if (parts.some((part) => part === '..' || part.includes('\\') || part.includes('\0') || part.startsWith('/'))) {
|
|
1112
|
+
const error = new Error('path')
|
|
1113
|
+
error.code = 'bad-package'
|
|
1114
|
+
throw error
|
|
1115
|
+
}
|
|
1116
|
+
const packedName = `package/${parts.slice(1).join('/')}`
|
|
1117
|
+
files.push({ name: packedName, data: entry.data })
|
|
1118
|
+
}
|
|
1119
|
+
if (!files.some((file) => file.name === 'package/package.json')) {
|
|
1120
|
+
const error = new Error('missing-manifest')
|
|
1121
|
+
error.code = 'bad-package'
|
|
1122
|
+
throw error
|
|
266
1123
|
}
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
1124
|
+
const chunks = []
|
|
1125
|
+
for (const file of files) appendTarFile(chunks, file.name, file.data)
|
|
1126
|
+
chunks.push(Buffer.alloc(1024))
|
|
1127
|
+
return gzipSync(Buffer.concat(chunks))
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/** 给测试造一个和 GitHub 标签包一样、根目录套了一层的 tar.gz。 */
|
|
1131
|
+
export function fixtureTarball(files, root = 'dsh-qq-bot-9.9.9') {
|
|
1132
|
+
const chunks = []
|
|
1133
|
+
for (const file of files) {
|
|
1134
|
+
const data = Buffer.isBuffer(file.data) ? file.data : Buffer.from(String(file.data))
|
|
1135
|
+
appendTarFile(chunks, `${root}/${file.name}`, data)
|
|
1136
|
+
}
|
|
1137
|
+
chunks.push(Buffer.alloc(1024))
|
|
1138
|
+
return gzipSync(Buffer.concat(chunks))
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function quoteCmdArg(arg) {
|
|
1142
|
+
const text = String(arg)
|
|
1143
|
+
if (text === '' || /["&|<>^%\n\r]/.test(text)) {
|
|
1144
|
+
const error = new Error('bad-path')
|
|
1145
|
+
error.code = 'error'
|
|
1146
|
+
throw error
|
|
1147
|
+
}
|
|
1148
|
+
return /\s/.test(text) ? `"${text}"` : text
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function isRegistryMiss(text) {
|
|
1152
|
+
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 ?? ''))
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* `dsh plugin --profile web add <包@版本>` 或本地压缩包。
|
|
1157
|
+
* Windows 用 cmd 调 dsh.cmd,避免直接敲到被拦截的 ps1。
|
|
1158
|
+
* @param {{ tarballPath?: string, packageSpec?: string, platform?: string, profile?: string, comSpec?: string }} spec
|
|
1159
|
+
*/
|
|
1160
|
+
export function dshPluginAddSpec(spec) {
|
|
1161
|
+
const profile = spec.profile || 'web'
|
|
1162
|
+
if (!/^[a-z][a-z0-9_-]{0,31}$/.test(profile)) {
|
|
1163
|
+
const error = new Error('profile')
|
|
1164
|
+
error.code = 'bad-package'
|
|
1165
|
+
throw error
|
|
1166
|
+
}
|
|
1167
|
+
const packageSpec = spec.packageSpec == null ? '' : String(spec.packageSpec)
|
|
1168
|
+
const tarballPath = spec.tarballPath == null ? '' : String(spec.tarballPath)
|
|
1169
|
+
if (packageSpec !== '') {
|
|
1170
|
+
const version = packageSpec.startsWith(`${PACKAGE_NAME}@`) ? packageSpec.slice(PACKAGE_NAME.length + 1) : ''
|
|
1171
|
+
if (npmInstallSpec(version) !== packageSpec) {
|
|
1172
|
+
const error = new Error('spec')
|
|
1173
|
+
error.code = 'bad-package'
|
|
1174
|
+
throw error
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
if (packageSpec === '' && tarballPath === '') {
|
|
1178
|
+
const error = new Error('target')
|
|
1179
|
+
error.code = 'bad-package'
|
|
1180
|
+
throw error
|
|
1181
|
+
}
|
|
1182
|
+
const pluginArgs = ['plugin', '--profile', profile, 'add', packageSpec !== '' ? packageSpec : tarballPath]
|
|
1183
|
+
const platform = spec.platform || process.platform
|
|
1184
|
+
if (platform === 'win32') {
|
|
1185
|
+
const line = ['dsh.cmd', ...pluginArgs].map(quoteCmdArg).join(' ')
|
|
1186
|
+
return { command: spec.comSpec || 'cmd.exe', args: ['/d', '/s', '/c', line], shell: false }
|
|
1187
|
+
}
|
|
1188
|
+
return { command: 'dsh', args: pluginArgs, shell: false }
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
function defaultAlive(pid) {
|
|
1192
|
+
try {
|
|
1193
|
+
process.kill(pid, 0)
|
|
1194
|
+
return true
|
|
1195
|
+
} catch (error) {
|
|
1196
|
+
return error?.code === 'EPERM'
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function defaultKill(target, signal, child) {
|
|
1201
|
+
if (typeof target === 'number' && target < 0) {
|
|
1202
|
+
process.kill(target, signal)
|
|
1203
|
+
return
|
|
1204
|
+
}
|
|
1205
|
+
if (child && typeof child.kill === 'function') {
|
|
1206
|
+
child.kill(signal)
|
|
1207
|
+
return
|
|
1208
|
+
}
|
|
1209
|
+
process.kill(target, signal)
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function defaultTaskkill(pid) {
|
|
1213
|
+
const killer = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' })
|
|
1214
|
+
killer.on?.('error', () => {})
|
|
1215
|
+
killer.unref?.()
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* 停掉安装进程,以及它在 POSIX 上拉起来的进程组。
|
|
1220
|
+
* 测试要传入 kill,避免对一个碰巧存在的 pid 发信号。
|
|
1221
|
+
* @param {{ pid?: number, kill?: (signal: string) => void }} child
|
|
1222
|
+
* @param {{ platform?: string, graceMs?: number, kill?: (target: number, signal: string) => void, taskkill?: (pid: number) => void, group?: boolean }} [options]
|
|
1223
|
+
*/
|
|
1224
|
+
export function stopProcessTree(child, options = {}) {
|
|
1225
|
+
const pid = Number(child?.pid)
|
|
1226
|
+
const known = Number.isSafeInteger(pid) && pid > 1
|
|
1227
|
+
const platform = options.platform || process.platform
|
|
1228
|
+
const graceMs = options.graceMs ?? KILL_GRACE_MS
|
|
1229
|
+
const group = options.group !== false && platform !== 'win32'
|
|
1230
|
+
const kill = options.kill || ((target, signal) => defaultKill(target, signal, child))
|
|
1231
|
+
const targets = []
|
|
1232
|
+
if (known && group) targets.push(-pid)
|
|
1233
|
+
if (known) targets.push(pid)
|
|
1234
|
+
const send = (signal) => {
|
|
1235
|
+
if (platform === 'win32' && signal === 'SIGKILL') {
|
|
1236
|
+
if (!known) return
|
|
1237
|
+
const taskkill = options.taskkill || defaultTaskkill
|
|
1238
|
+
try { taskkill(pid) } catch { /* 停不掉时由调用方再看进程还在不在 */ }
|
|
1239
|
+
return
|
|
1240
|
+
}
|
|
1241
|
+
for (const target of targets) {
|
|
1242
|
+
try { kill(target, signal) } catch { /* 进程已经退出 */ }
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
send('SIGTERM')
|
|
1246
|
+
const timer = setTimeout(() => send('SIGKILL'), graceMs)
|
|
1247
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
1248
|
+
return {
|
|
1249
|
+
pid: known ? pid : 0,
|
|
1250
|
+
stop() { clearTimeout(timer) },
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/** DSH 把正在跑的 pnpm 记在 profile 的这个文件里。 */
|
|
1255
|
+
export function runRecordPath(profileDir) {
|
|
1256
|
+
return join(profileDir, '.plugin-manager', 'run.json')
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/** 只认 `{ pid, grouped }`。pid 必须是大于 1 的整数,避免把 1 号进程当成安装进程。 */
|
|
1260
|
+
export function parseRunRecord(text) {
|
|
1261
|
+
let value
|
|
1262
|
+
try {
|
|
1263
|
+
value = JSON.parse(String(text ?? ''))
|
|
1264
|
+
} catch {
|
|
1265
|
+
return null
|
|
1266
|
+
}
|
|
1267
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
1268
|
+
const pid = value.pid
|
|
1269
|
+
if (!Number.isSafeInteger(pid) || pid <= 1 || typeof value.grouped !== 'boolean') return null
|
|
1270
|
+
return { pid, grouped: value.grouped }
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* 安装记录里的进程已经不在,或记录本身读不懂时,把文件删掉。
|
|
1275
|
+
* 进程还活着就不删,免得下一次安装和还在写目录的 pnpm 抢在一起。
|
|
1276
|
+
* 测试要传入 alive。默认探测会向真实 pid 发 0 信号。
|
|
1277
|
+
* @param {string} profileDir
|
|
1278
|
+
* @param {{ alive?: (pid: number) => boolean }} [options]
|
|
1279
|
+
*/
|
|
1280
|
+
export function clearStaleRunRecord(profileDir, options = {}) {
|
|
1281
|
+
const path = runRecordPath(profileDir)
|
|
1282
|
+
let text
|
|
1283
|
+
try {
|
|
1284
|
+
text = readFileSync(path, 'utf8')
|
|
1285
|
+
} catch (error) {
|
|
1286
|
+
if (error?.code === 'ENOENT') return { removed: false, alive: false, missing: true }
|
|
1287
|
+
throw error
|
|
1288
|
+
}
|
|
1289
|
+
const record = parseRunRecord(text)
|
|
1290
|
+
if (!record) {
|
|
1291
|
+
try {
|
|
1292
|
+
rmSync(path, { force: true })
|
|
1293
|
+
} catch {
|
|
1294
|
+
return { removed: false, invalid: true, alive: false }
|
|
1295
|
+
}
|
|
1296
|
+
return { removed: true, invalid: true, alive: false }
|
|
1297
|
+
}
|
|
1298
|
+
const probe = options.alive || defaultAlive
|
|
1299
|
+
let living = false
|
|
1300
|
+
try {
|
|
1301
|
+
living = probe(record.pid) === true
|
|
1302
|
+
} catch {
|
|
1303
|
+
// 看不出来就当成还在跑,避免删掉记录后再开一个安装。
|
|
1304
|
+
living = true
|
|
1305
|
+
}
|
|
1306
|
+
if (living) return { removed: false, alive: true, pid: record.pid, grouped: record.grouped }
|
|
1307
|
+
try {
|
|
1308
|
+
rmSync(path, { force: true })
|
|
1309
|
+
} catch {
|
|
1310
|
+
return { removed: false, alive: false, pid: record.pid }
|
|
1311
|
+
}
|
|
1312
|
+
return { removed: true, alive: false, pid: record.pid }
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function runChild(spawnImpl, command, args, options = {}) {
|
|
1316
|
+
return new Promise((resolve) => {
|
|
1317
|
+
let settled = false
|
|
1318
|
+
let timer
|
|
1319
|
+
let graceTimer
|
|
1320
|
+
let stopper = null
|
|
1321
|
+
let timedOut = false
|
|
1322
|
+
const finish = (value) => {
|
|
1323
|
+
if (settled) return
|
|
1324
|
+
settled = true
|
|
1325
|
+
if (timer) clearTimeout(timer)
|
|
1326
|
+
if (graceTimer) clearTimeout(graceTimer)
|
|
1327
|
+
stopper?.stop()
|
|
1328
|
+
resolve(value)
|
|
1329
|
+
}
|
|
1330
|
+
const platform = options.platform || process.platform
|
|
1331
|
+
const detached = options.detached === true && platform !== 'win32'
|
|
1332
|
+
let child
|
|
1333
|
+
try {
|
|
1334
|
+
child = spawnImpl(command, args, {
|
|
1335
|
+
cwd: options.cwd,
|
|
1336
|
+
env: options.env,
|
|
1337
|
+
windowsHide: true,
|
|
1338
|
+
shell: false,
|
|
1339
|
+
stdio: options.stdio || ['ignore', 'pipe', 'pipe'],
|
|
1340
|
+
detached,
|
|
1341
|
+
})
|
|
1342
|
+
} catch (error) {
|
|
1343
|
+
finish({ ok: false, code: error?.code === 'ENOENT' ? 'ENOENT' : 'error' })
|
|
1344
|
+
return
|
|
1345
|
+
}
|
|
1346
|
+
if (!child || typeof child.on !== 'function') {
|
|
1347
|
+
finish({ ok: false, code: 'error' })
|
|
1348
|
+
return
|
|
1349
|
+
}
|
|
1350
|
+
let stdout = ''
|
|
1351
|
+
let stderr = ''
|
|
1352
|
+
child.stdout?.on?.('data', (chunk) => {
|
|
1353
|
+
if (!options.capture || stdout.length >= 2000) return
|
|
1354
|
+
stdout += chunk.toString().slice(0, 2000 - stdout.length)
|
|
1355
|
+
})
|
|
1356
|
+
child.stderr?.on?.('data', (chunk) => {
|
|
1357
|
+
if (stderr.length >= 4000) return
|
|
1358
|
+
stderr += chunk.toString().slice(0, 4000 - stderr.length)
|
|
1359
|
+
})
|
|
1360
|
+
child.on('error', (error) => {
|
|
1361
|
+
if (timedOut) return
|
|
1362
|
+
finish({ ok: false, code: error?.code === 'ENOENT' ? 'ENOENT' : 'error', stdout, stderr })
|
|
1363
|
+
})
|
|
1364
|
+
const graceMs = options.killGraceMs ?? KILL_GRACE_MS
|
|
1365
|
+
timer = setTimeout(() => {
|
|
1366
|
+
timedOut = true
|
|
1367
|
+
stopper = stopProcessTree(child, {
|
|
1368
|
+
platform,
|
|
1369
|
+
graceMs,
|
|
1370
|
+
kill: options.kill,
|
|
1371
|
+
taskkill: options.taskkill,
|
|
1372
|
+
group: detached,
|
|
1373
|
+
})
|
|
1374
|
+
if (settled) {
|
|
1375
|
+
stopper.stop()
|
|
1376
|
+
return
|
|
1377
|
+
}
|
|
1378
|
+
graceTimer = setTimeout(() => {
|
|
1379
|
+
const pid = Number(child.pid)
|
|
1380
|
+
const known = Number.isSafeInteger(pid) && pid > 1
|
|
1381
|
+
let leftover = false
|
|
1382
|
+
if (known) {
|
|
1383
|
+
const probe = options.alive || defaultAlive
|
|
1384
|
+
try { leftover = probe(pid) === true } catch { leftover = true }
|
|
1385
|
+
}
|
|
1386
|
+
finish({ ok: false, code: 'timeout', leftover, pid: known ? pid : 0, stdout, stderr })
|
|
1387
|
+
}, graceMs + 50)
|
|
1388
|
+
}, options.timeoutMs ?? INSTALL_TIMEOUT_MS)
|
|
1389
|
+
child.on('close', (code) => {
|
|
1390
|
+
if (timedOut && code === 0) {
|
|
1391
|
+
finish({ ok: true, code: 0, stdout, stderr })
|
|
1392
|
+
return
|
|
1393
|
+
}
|
|
1394
|
+
if (timedOut) {
|
|
1395
|
+
finish({ ok: false, code: 'timeout', leftover: false, stdout, stderr, pid: Number.isSafeInteger(Number(child.pid)) ? Number(child.pid) : 0 })
|
|
1396
|
+
return
|
|
1397
|
+
}
|
|
1398
|
+
finish({ ok: code === 0, code: code === 0 ? 0 : 'exit', stdout, stderr })
|
|
1399
|
+
})
|
|
1400
|
+
})
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/**
|
|
1404
|
+
* 执行 `dsh plugin add`。开始前清掉已经退出的安装记录;记录里的进程还在就不再开一次。
|
|
1405
|
+
* 超时后停掉进程组。记录里如果是另一个还活着的 pid,也停掉,但活着的记录文件留着。
|
|
1406
|
+
* @param {{ packageSpec?: string, tarballPath?: string, platform?: string, profile?: string, profileDir?: string, cwd?: string, env?: NodeJS.ProcessEnv, spawnImpl?: typeof spawn, timeoutMs?: number, killGraceMs?: number, kill?: (target: number, signal: string) => void, alive?: (pid: number) => boolean, taskkill?: (pid: number) => void }} spec
|
|
1407
|
+
*/
|
|
1408
|
+
export async function runPluginAdd(spec) {
|
|
1409
|
+
const profileDir = typeof spec.profileDir === 'string' ? spec.profileDir : ''
|
|
1410
|
+
const platform = spec.platform || process.platform
|
|
1411
|
+
if (profileDir !== '') {
|
|
1412
|
+
let stale = null
|
|
1413
|
+
try {
|
|
1414
|
+
stale = clearStaleRunRecord(profileDir, { alive: spec.alive })
|
|
1415
|
+
} catch {
|
|
1416
|
+
stale = null
|
|
1417
|
+
}
|
|
1418
|
+
if (stale?.alive) return { ok: false, code: 'busy', pid: stale.pid }
|
|
1419
|
+
}
|
|
1420
|
+
const planned = dshPluginAddSpec(spec)
|
|
1421
|
+
const ran = await runChild(spec.spawnImpl || spawn, planned.command, planned.args, {
|
|
1422
|
+
cwd: spec.cwd || process.cwd(),
|
|
1423
|
+
env: spec.env || process.env,
|
|
1424
|
+
timeoutMs: spec.timeoutMs ?? INSTALL_TIMEOUT_MS,
|
|
1425
|
+
capture: true,
|
|
1426
|
+
detached: platform !== 'win32',
|
|
1427
|
+
platform,
|
|
1428
|
+
kill: spec.kill,
|
|
1429
|
+
alive: spec.alive,
|
|
1430
|
+
taskkill: spec.taskkill,
|
|
1431
|
+
killGraceMs: spec.killGraceMs,
|
|
1432
|
+
})
|
|
1433
|
+
if (ran.ok) return { ok: true, code: 0 }
|
|
1434
|
+
if (ran.code === 'timeout') {
|
|
1435
|
+
let leftover = ran.leftover === true
|
|
1436
|
+
let pid = ran.pid
|
|
1437
|
+
if (profileDir !== '') {
|
|
1438
|
+
try {
|
|
1439
|
+
const record = parseRunRecord(readFileSync(runRecordPath(profileDir), 'utf8'))
|
|
1440
|
+
if (record && record.pid !== ran.pid) {
|
|
1441
|
+
// 记录里的 grouped 为真时,这个 pid 才是进程组负责人。CLI 安装记的是 false,只停这个进程。
|
|
1442
|
+
stopProcessTree({ pid: record.pid }, {
|
|
1443
|
+
platform,
|
|
1444
|
+
graceMs: 0,
|
|
1445
|
+
kill: spec.kill,
|
|
1446
|
+
taskkill: spec.taskkill,
|
|
1447
|
+
group: record.grouped === true,
|
|
1448
|
+
})
|
|
1449
|
+
}
|
|
1450
|
+
} catch {
|
|
1451
|
+
// 没有留下记录,或记录读不懂。下面再按进程是否还在决定删不删。
|
|
1452
|
+
}
|
|
1453
|
+
try {
|
|
1454
|
+
const again = clearStaleRunRecord(profileDir, { alive: spec.alive })
|
|
1455
|
+
if (again.alive) {
|
|
1456
|
+
leftover = true
|
|
1457
|
+
pid = again.pid
|
|
1458
|
+
}
|
|
1459
|
+
} catch {
|
|
1460
|
+
// 清不掉就按超时说明,不把路径写进给用户的句子。
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
return { ok: false, code: 'timeout', leftover, pid }
|
|
1464
|
+
}
|
|
1465
|
+
if (ran.code === 'ENOENT') return { ok: false, code: 'ENOENT' }
|
|
1466
|
+
if (spec.packageSpec && isRegistryMiss(ran.stderr)) return { ok: false, code: 'unpublished' }
|
|
1467
|
+
return { ok: false, code: 'error' }
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
function defaultRunCommand(spec) {
|
|
1471
|
+
return runPluginAdd(spec)
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function readOwnCgroup() {
|
|
1475
|
+
try {
|
|
1476
|
+
return readFileSync('/proc/self/cgroup', 'utf8')
|
|
1477
|
+
} catch {
|
|
1478
|
+
return ''
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/** 从 cgroup 里取直接托管本进程的服务名。用户会话本身(user@1000.service)不算。 */
|
|
1483
|
+
export function systemdUnitFromCgroup(text) {
|
|
1484
|
+
const names = []
|
|
1485
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
1486
|
+
for (const part of line.split('/')) {
|
|
1487
|
+
const name = part.trim()
|
|
1488
|
+
if (/^[A-Za-z0-9][A-Za-z0-9:_.@+-]*\.service$/.test(name)) names.push(name)
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
const specific = names.filter((name) => !/^user@\d+\.service$/.test(name))
|
|
1492
|
+
return specific.length > 0 ? specific[specific.length - 1] : null
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* 脱离父进程之后再重启的小脚本。只杀给定 pid,再按原来的命令行拉起,不碰别的进程。
|
|
1497
|
+
* 用 node -e 跑,所以是 CommonJS。
|
|
1498
|
+
*/
|
|
1499
|
+
export const RESTART_HELPER = `
|
|
1500
|
+
const { spawn } = require('node:child_process')
|
|
1501
|
+
let spec = {}
|
|
1502
|
+
try { spec = JSON.parse(process.env.DSH_QQ_BOT_RESTART || '{}') } catch { spec = {} }
|
|
1503
|
+
const env = { ...process.env }
|
|
1504
|
+
delete env.DSH_QQ_BOT_RESTART
|
|
1505
|
+
const delay = Number(spec.delayMs) > 0 ? Number(spec.delayMs) : ${RESTART_DELAY_MS}
|
|
1506
|
+
function alive(pid) {
|
|
1507
|
+
try { process.kill(pid, 0); return true } catch { return false }
|
|
1508
|
+
}
|
|
1509
|
+
setTimeout(() => {
|
|
1510
|
+
const pid = Number(spec.pid)
|
|
1511
|
+
const known = Number.isInteger(pid) && pid > 1
|
|
1512
|
+
if (known) {
|
|
1513
|
+
try { process.kill(pid, 'SIGTERM') } catch {}
|
|
1514
|
+
}
|
|
1515
|
+
const started = Date.now()
|
|
1516
|
+
const timer = setInterval(() => {
|
|
1517
|
+
const still = known && alive(pid)
|
|
1518
|
+
if (!still || Date.now() - started > 20000) {
|
|
1519
|
+
clearInterval(timer)
|
|
1520
|
+
if (still) {
|
|
1521
|
+
try { process.kill(pid, 'SIGKILL') } catch {}
|
|
1522
|
+
}
|
|
1523
|
+
setTimeout(() => {
|
|
1524
|
+
if (typeof spec.execPath === 'string' && Array.isArray(spec.argv)) {
|
|
1525
|
+
const child = spawn(spec.execPath, spec.argv, {
|
|
1526
|
+
cwd: typeof spec.cwd === 'string' && spec.cwd !== '' ? spec.cwd : undefined,
|
|
1527
|
+
env,
|
|
1528
|
+
detached: true,
|
|
1529
|
+
stdio: 'ignore',
|
|
1530
|
+
windowsHide: true,
|
|
1531
|
+
})
|
|
1532
|
+
child.unref()
|
|
1533
|
+
}
|
|
1534
|
+
process.exit(0)
|
|
1535
|
+
}, still ? 400 : 200)
|
|
1536
|
+
}
|
|
1537
|
+
}, 200)
|
|
1538
|
+
}, delay)
|
|
1539
|
+
`.trim()
|
|
1540
|
+
|
|
1541
|
+
/**
|
|
1542
|
+
* 重启计划。systemd 里只重启 cgroup 里那个具体服务,用 systemd-run 把动作放到服务外面。
|
|
1543
|
+
* 认不出单独服务时不重启整个登录会话。其余情况等响应发出后,按当前进程的命令行再拉起一次。
|
|
1544
|
+
*/
|
|
1545
|
+
export function planPanelRestart(input = {}) {
|
|
1546
|
+
const platform = input.platform || process.platform
|
|
1547
|
+
const env = input.env || process.env
|
|
1548
|
+
const invocation = platform !== 'win32' && Boolean(env && (env.INVOCATION_ID || env.NOTIFY_SOCKET))
|
|
1549
|
+
const unit = invocation ? systemdUnitFromCgroup(input.cgroup ?? '') : null
|
|
1550
|
+
if (invocation && !unit) return { mode: 'manual' }
|
|
1551
|
+
if (unit) {
|
|
1552
|
+
return {
|
|
1553
|
+
mode: 'systemd',
|
|
1554
|
+
command: 'systemd-run',
|
|
1555
|
+
args: ['--quiet', '--collect', '--on-active=3s', '--timer-property=AccuracySec=1s', '--', 'systemctl', 'restart', unit],
|
|
1556
|
+
unit,
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
return {
|
|
1560
|
+
mode: 'reexec',
|
|
1561
|
+
execPath: input.execPath || process.execPath,
|
|
1562
|
+
argv: Array.isArray(input.argv) ? input.argv : process.argv.slice(1),
|
|
1563
|
+
pid: input.pid || process.pid,
|
|
1564
|
+
cwd: input.cwd || process.cwd(),
|
|
1565
|
+
delayMs: RESTART_DELAY_MS,
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/**
|
|
1570
|
+
* @param {{ plan?: ReturnType<typeof planPanelRestart>, spawnImpl?: typeof spawn, env?: NodeJS.ProcessEnv, platform?: string, cgroup?: string, execPath?: string, argv?: string[], pid?: number, cwd?: string }} input
|
|
1571
|
+
*/
|
|
1572
|
+
export async function schedulePanelRestart(input = {}) {
|
|
1573
|
+
const plan = input.plan || planPanelRestart(input)
|
|
1574
|
+
if (plan.mode === 'manual') return { ok: false, mode: 'manual', error: restartError() }
|
|
1575
|
+
const spawnImpl = input.spawnImpl || spawn
|
|
1576
|
+
if (plan.mode === 'systemd') {
|
|
1577
|
+
const ran = await runChild(spawnImpl, plan.command, plan.args, { timeoutMs: 20000, stdio: 'ignore' })
|
|
1578
|
+
if (!ran.ok) return { ok: false, mode: 'systemd', error: restartError() }
|
|
1579
|
+
return { ok: true, mode: 'systemd' }
|
|
1580
|
+
}
|
|
1581
|
+
const payload = JSON.stringify({
|
|
1582
|
+
pid: plan.pid,
|
|
1583
|
+
cwd: plan.cwd,
|
|
1584
|
+
execPath: plan.execPath,
|
|
1585
|
+
argv: plan.argv,
|
|
1586
|
+
delayMs: plan.delayMs,
|
|
1587
|
+
})
|
|
1588
|
+
const env = { ...(input.env || process.env) }
|
|
1589
|
+
delete env.DSH_QQ_BOT_RESTART
|
|
1590
|
+
env.DSH_QQ_BOT_RESTART = payload
|
|
1591
|
+
let child
|
|
1592
|
+
try {
|
|
1593
|
+
child = spawnImpl(plan.execPath, ['-e', RESTART_HELPER], {
|
|
1594
|
+
detached: true,
|
|
1595
|
+
stdio: 'ignore',
|
|
1596
|
+
windowsHide: true,
|
|
1597
|
+
shell: false,
|
|
1598
|
+
env,
|
|
1599
|
+
cwd: plan.cwd,
|
|
1600
|
+
})
|
|
1601
|
+
} catch {
|
|
1602
|
+
return { ok: false, mode: 'reexec', error: restartError() }
|
|
1603
|
+
}
|
|
1604
|
+
if (!child || typeof child.on !== 'function') return { ok: false, mode: 'reexec', error: restartError() }
|
|
1605
|
+
child.on('error', () => {})
|
|
1606
|
+
if (typeof child.unref === 'function') child.unref()
|
|
1607
|
+
return { ok: true, mode: 'reexec' }
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
const CORDIS_ID = 'qq-bridge'
|
|
1611
|
+
|
|
1612
|
+
function isWritablePackageName(name) {
|
|
1613
|
+
return name === PACKAGE_NAME || name === LEGACY_UNSCOPED_NAME || name === 'dsh-qq-bridge'
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
/** profile 目录。profile 名只接受 dsh 自己的那一档,避免把 `..` 拼进路径。 */
|
|
1617
|
+
export function profileDirectory(home, profile = 'web') {
|
|
1618
|
+
const root = String(home ?? '')
|
|
1619
|
+
if (root === '' || root.includes('\0')) return ''
|
|
1620
|
+
const name = String(profile || 'web')
|
|
1621
|
+
if (!/^[a-z][a-z0-9_-]{0,31}$/.test(name)) return ''
|
|
1622
|
+
return join(root, 'profiles', name)
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
function profileDirOf(input) {
|
|
1626
|
+
if (input && typeof input.profileDir === 'string' && input.profileDir !== '') return input.profileDir
|
|
1627
|
+
if (input && typeof input.home === 'string' && input.home !== '') return profileDirectory(input.home, input.profile || 'web')
|
|
1628
|
+
return ''
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
function bundlePatchPath(dir, manifest) {
|
|
1632
|
+
const raw = manifest?.dsh?.bundle?.patch
|
|
1633
|
+
const rel = typeof raw === 'string' && raw !== '' ? raw : 'cordis.patch.yml'
|
|
1634
|
+
if (isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) return ''
|
|
1635
|
+
return join(dir, rel)
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
function readInstalledManifest(dir) {
|
|
1639
|
+
let manifest
|
|
1640
|
+
try {
|
|
1641
|
+
manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
|
|
1642
|
+
} catch {
|
|
1643
|
+
return null
|
|
1644
|
+
}
|
|
1645
|
+
if (!manifest || typeof manifest !== 'object' || !isWritablePackageName(manifest.name)) return null
|
|
1646
|
+
return {
|
|
1647
|
+
dir,
|
|
1648
|
+
name: manifest.name,
|
|
1649
|
+
version: String(manifest.version ?? ''),
|
|
1650
|
+
patchFile: bundlePatchPath(dir, manifest),
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
/**
|
|
1655
|
+
* 只改 `qq-bridge` 这一行的 `name`。config 和别的插件保持原样,包括注释和空行。
|
|
1656
|
+
* 只认我们自己的三个包名;别人写的路径或别的包不动。
|
|
1657
|
+
* @param {string} text
|
|
1658
|
+
* @param {string} packageName
|
|
1659
|
+
* @param {string} [pluginId]
|
|
1660
|
+
*/
|
|
1661
|
+
export function rewriteCordisPackageName(text, packageName, pluginId = CORDIS_ID) {
|
|
1662
|
+
const source = String(text ?? '')
|
|
1663
|
+
if (!isWritablePackageName(packageName) || !/^[A-Za-z0-9_-]+$/.test(pluginId)) return { text: source, changed: false }
|
|
1664
|
+
const newline = source.includes('\r\n') ? '\r\n' : '\n'
|
|
1665
|
+
const endsWithNewline = /\n$/.test(source)
|
|
1666
|
+
const lines = source.split(/\r?\n/)
|
|
1667
|
+
if (endsWithNewline && lines[lines.length - 1] === '') lines.pop()
|
|
1668
|
+
let changed = false
|
|
1669
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1670
|
+
const idMatch = lines[i].match(/^(\s*)(?:-\s+)?id:\s*(['"]?)([A-Za-z0-9_-]+)\2\s*(#.*)?$/)
|
|
1671
|
+
if (!idMatch || idMatch[3] !== pluginId) continue
|
|
1672
|
+
const entryIndent = idMatch[1].length
|
|
1673
|
+
let keyIndent = -1
|
|
1674
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
1675
|
+
const line = lines[j]
|
|
1676
|
+
if (line.includes('\t')) break
|
|
1677
|
+
if (line.trim() === '' || /^\s*#/.test(line)) continue
|
|
1678
|
+
const indent = line.match(/^ */)?.[0].length ?? 0
|
|
1679
|
+
if (indent <= entryIndent) break
|
|
1680
|
+
if (keyIndent < 0) keyIndent = indent
|
|
1681
|
+
if (indent !== keyIndent) continue
|
|
1682
|
+
if (!/^\s*name:/.test(line)) continue
|
|
1683
|
+
const nameMatch = line.match(/^(\s*)name:\s*(['"]?)([^'"\s#]+)\2\s*(#.*)?$/)
|
|
1684
|
+
if (!nameMatch) break
|
|
1685
|
+
const current = nameMatch[3]
|
|
1686
|
+
if (isWritablePackageName(current) && current !== packageName) {
|
|
1687
|
+
const comment = nameMatch[4] ? ` ${nameMatch[4]}` : ''
|
|
1688
|
+
lines[j] = `${nameMatch[1]}name: '${packageName}'${comment}`
|
|
1689
|
+
changed = true
|
|
1690
|
+
}
|
|
1691
|
+
break
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
if (!changed) return { text: source, changed: false }
|
|
1695
|
+
let next = lines.join(newline)
|
|
1696
|
+
if (endsWithNewline) next += newline
|
|
1697
|
+
return { text: next, changed: true }
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
function writeTextAtomic(file, text) {
|
|
1701
|
+
const tmp = `${file}.tmp-${process.pid}`
|
|
1702
|
+
writeFileSync(tmp, text)
|
|
1703
|
+
try {
|
|
1704
|
+
chmodSync(tmp, statSync(file).mode)
|
|
1705
|
+
} catch {
|
|
1706
|
+
// 读不到原权限就按这次写入的默认权限留下。
|
|
1707
|
+
}
|
|
1708
|
+
try {
|
|
1709
|
+
renameSync(tmp, file)
|
|
1710
|
+
} catch (error) {
|
|
1711
|
+
try { rmSync(tmp, { force: true }) } catch { /* 临时文件清不掉时,仍把写入失败抛回去 */ }
|
|
1712
|
+
throw error
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
/**
|
|
1717
|
+
* 把 profile、home(有这份文件才改)和已安装插件自己的补丁里,
|
|
1718
|
+
* `qq-bridge` 的 `name` 改成磁盘上 package.json 的包名。
|
|
1719
|
+
* 传入 expectedVersion 时,只有版本对得上才改,避免把半截安装写成错误的包名。
|
|
1720
|
+
* @param {{ profileDir?: string, home?: string, expectedVersion?: string }} input
|
|
1721
|
+
*/
|
|
1722
|
+
export function alignCordisNames(input = {}) {
|
|
1723
|
+
const profileDir = String(input.profileDir ?? '')
|
|
1724
|
+
if (profileDir === '') return { ok: false, reason: 'no-profile', changed: [], name: '' }
|
|
1725
|
+
const scoped = readInstalledManifest(join(profileDir, 'node_modules', '@fengye_nobug', 'dsh-qq-bot'))
|
|
1726
|
+
const legacy = readInstalledManifest(join(profileDir, 'node_modules', 'dsh-qq-bot'))
|
|
1727
|
+
const candidates = [scoped, legacy].filter(Boolean)
|
|
1728
|
+
let installed = null
|
|
1729
|
+
if (input.expectedVersion) {
|
|
1730
|
+
installed = candidates.find((item) => compareSemver(item.version, input.expectedVersion) === 0) || null
|
|
1731
|
+
if (!installed) return { ok: false, reason: 'version', changed: [], name: '' }
|
|
1732
|
+
} else {
|
|
1733
|
+
installed = scoped || legacy
|
|
1734
|
+
}
|
|
1735
|
+
if (!installed) return { ok: false, reason: 'missing', changed: [], name: '' }
|
|
1736
|
+
const files = []
|
|
1737
|
+
if (installed.patchFile) files.push(installed.patchFile)
|
|
1738
|
+
files.push(join(profileDir, 'cordis.patch.yml'))
|
|
1739
|
+
if (typeof input.home === 'string' && input.home !== '') files.push(join(input.home, 'cordis.patch.yml'))
|
|
1740
|
+
const changed = []
|
|
1741
|
+
let failed = false
|
|
1742
|
+
for (const file of files) {
|
|
1743
|
+
let text
|
|
1744
|
+
try {
|
|
1745
|
+
text = readFileSync(file, 'utf8')
|
|
1746
|
+
} catch {
|
|
1747
|
+
continue
|
|
1748
|
+
}
|
|
1749
|
+
const next = rewriteCordisPackageName(text, installed.name)
|
|
1750
|
+
if (!next.changed) continue
|
|
1751
|
+
try {
|
|
1752
|
+
writeTextAtomic(file, next.text)
|
|
1753
|
+
changed.push(file)
|
|
1754
|
+
} catch {
|
|
1755
|
+
failed = true
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
return {
|
|
1759
|
+
ok: !failed,
|
|
1760
|
+
failed,
|
|
1761
|
+
reason: failed ? 'write' : 'ok',
|
|
1762
|
+
changed,
|
|
1763
|
+
name: installed.name,
|
|
1764
|
+
version: installed.version,
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
function alignNote(input, expectedVersion) {
|
|
1769
|
+
const profileDir = profileDirOf(input)
|
|
1770
|
+
if (profileDir === '') return ''
|
|
1771
|
+
try {
|
|
1772
|
+
const result = alignCordisNames({
|
|
1773
|
+
profileDir,
|
|
1774
|
+
home: typeof input?.home === 'string' ? input.home : '',
|
|
1775
|
+
expectedVersion,
|
|
1776
|
+
})
|
|
1777
|
+
if (result.failed) return '没能改 Cordis 的包名。若重启后设置页打不开,把 qq-bridge 的 name 改成实际安装的包名再重启。'
|
|
1778
|
+
if (result.changed.length > 0) return `Cordis 里 qq-bridge 的包名已经改成实际安装的 ${result.name}。`
|
|
1779
|
+
return ''
|
|
1780
|
+
} catch {
|
|
1781
|
+
return '没能改 Cordis 的包名。若重启后设置页打不开,把 qq-bridge 的 name 改成实际安装的包名再重启。'
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
function successMessage(tag, channel, note = '') {
|
|
1786
|
+
const via = channel === 'npm'
|
|
1787
|
+
? '这次是从 npm 装的。'
|
|
1788
|
+
: '这一版在 npm 上还没有,所以从 GitHub 装上。'
|
|
1789
|
+
const base = `已经装上 ${tag},面板即将重启。请刷新页面;登录口令可能会变,用新的面板地址再打开。${via}现有设置还在,没有改动 NapCat。`
|
|
1790
|
+
return note ? `${base}${note}` : base
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
function shownInstall(channel, version, repo) {
|
|
1794
|
+
if (channel === 'npm') return { install: npmInstallSpec(version), command: npmInstallCommand(version) }
|
|
1795
|
+
return { install: installSpec(version, repo), command: installCommand(version, repo) }
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
function installRunSpec(input, extra) {
|
|
1799
|
+
return {
|
|
1800
|
+
...extra,
|
|
1801
|
+
platform: input.platform,
|
|
1802
|
+
profile: input.profile || 'web',
|
|
1803
|
+
profileDir: profileDirOf(input),
|
|
1804
|
+
spawnImpl: input.spawnImpl,
|
|
1805
|
+
timeoutMs: input.timeoutMs,
|
|
1806
|
+
killGraceMs: input.killGraceMs,
|
|
1807
|
+
kill: input.kill,
|
|
1808
|
+
alive: input.alive,
|
|
1809
|
+
taskkill: input.taskkill,
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
/** 安装失败时,只有目标版本已经在磁盘上、并且进程已经停干净,才改包名。不在这里重启。 */
|
|
1814
|
+
function failInstall(input, decision, ran, command, install) {
|
|
1815
|
+
const code = ran?.code
|
|
1816
|
+
const leftover = ran?.leftover === true
|
|
1817
|
+
let packageName = ''
|
|
1818
|
+
const profileDir = profileDirOf(input)
|
|
1819
|
+
if (!leftover && (code === 'timeout' || code === 'exit' || code === 'error') && profileDir !== '') {
|
|
1820
|
+
try {
|
|
1821
|
+
const aligned = alignCordisNames({
|
|
1822
|
+
profileDir,
|
|
1823
|
+
home: typeof input.home === 'string' ? input.home : '',
|
|
1824
|
+
expectedVersion: decision.version,
|
|
1825
|
+
})
|
|
1826
|
+
if (aligned.changed.length > 0) packageName = aligned.name
|
|
1827
|
+
} catch {
|
|
1828
|
+
// 对不齐就只说明安装失败。
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
const message = installError(code, command, { packageName, leftover, pid: ran?.pid })
|
|
1832
|
+
return {
|
|
1833
|
+
ok: false,
|
|
1834
|
+
stage: 'install',
|
|
1835
|
+
current: decision.current,
|
|
1836
|
+
error: message,
|
|
1837
|
+
message,
|
|
1838
|
+
tag: decision.tag,
|
|
1839
|
+
version: decision.version,
|
|
1840
|
+
command,
|
|
1841
|
+
install: install || decision.install,
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
/**
|
|
1846
|
+
* 下载并安装一次检查允许的版本,然后安排重启。
|
|
1847
|
+
* 调用方要自己做登录校验。这里不读设置文件,也不碰 NapCat。
|
|
1848
|
+
* 先 `dsh plugin add @fengye_nobug/dsh-qq-bot@版本`。registry 里没有这个版本时,才下载 GitHub 上的同一标签。
|
|
1849
|
+
* @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
|
|
1850
|
+
*/
|
|
1851
|
+
export async function applyPluginUpdate(input = {}) {
|
|
1852
|
+
const repo = input.repo || UPDATE_REPO
|
|
1853
|
+
const found = await findUpdate({ ...input, applying: true })
|
|
1854
|
+
if (!found.ok) {
|
|
1855
|
+
return { ok: false, stage: 'github', current: String(input.current ?? ''), error: found.error, message: found.error }
|
|
1856
|
+
}
|
|
1857
|
+
const decision = chooseApplyTarget({
|
|
1858
|
+
current: input.current,
|
|
1859
|
+
releases: found.releases,
|
|
1860
|
+
version: input.version,
|
|
1861
|
+
force: input.force === true,
|
|
1862
|
+
repo,
|
|
1863
|
+
channel: found.channel,
|
|
1864
|
+
})
|
|
1865
|
+
if (!decision.ok) {
|
|
1866
|
+
return {
|
|
1867
|
+
ok: false,
|
|
1868
|
+
stage: 'refused',
|
|
1869
|
+
current: String(input.current ?? ''),
|
|
1870
|
+
error: decision.error,
|
|
1871
|
+
message: decision.error,
|
|
1872
|
+
updateAvailable: false,
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
const profileDir = profileDirOf(input)
|
|
1876
|
+
if (profileDir !== '') {
|
|
1877
|
+
try {
|
|
1878
|
+
const stale = clearStaleRunRecord(profileDir, { alive: input.alive })
|
|
1879
|
+
if (stale.alive) return failInstall(input, decision, { ok: false, code: 'busy', pid: stale.pid }, decision.command, decision.install)
|
|
1880
|
+
} catch {
|
|
1881
|
+
// 记录读不出来就继续,安装命令自己会再看一遍。
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
let channel = decision.channel
|
|
1885
|
+
if (channel === 'npm') {
|
|
1886
|
+
let ran
|
|
1887
|
+
try {
|
|
1888
|
+
const run = input.runCommand || defaultRunCommand
|
|
1889
|
+
ran = await run(installRunSpec(input, { packageSpec: decision.install }))
|
|
1890
|
+
} catch {
|
|
1891
|
+
ran = { ok: false, code: 'error' }
|
|
1892
|
+
}
|
|
1893
|
+
if (ran?.ok === true) return finishApply(input, decision, 'npm')
|
|
1894
|
+
if (ran?.code !== 'unpublished') return failInstall(input, decision, ran, decision.command, decision.install)
|
|
1895
|
+
channel = 'github'
|
|
1896
|
+
}
|
|
1897
|
+
const shown = shownInstall(channel, decision.version, repo)
|
|
1898
|
+
let bytes
|
|
1899
|
+
try {
|
|
1900
|
+
bytes = await downloadReleaseTarball({
|
|
1901
|
+
url: decision.tarballUrl,
|
|
1902
|
+
fallbackUrl: decision.fallbackUrl,
|
|
1903
|
+
repo,
|
|
1904
|
+
tag: decision.tag,
|
|
1905
|
+
fetchImpl: input.fetchImpl,
|
|
1906
|
+
proxy: input.proxy,
|
|
1907
|
+
timeoutMs: input.downloadTimeoutMs,
|
|
1908
|
+
maxBytes: input.maxBytes,
|
|
1909
|
+
spawnImpl: input.spawnImpl,
|
|
1910
|
+
platform: input.platform,
|
|
1911
|
+
})
|
|
1912
|
+
} catch (error) {
|
|
1913
|
+
const message = downloadError(error?.code)
|
|
1914
|
+
return { ok: false, stage: 'github', current: decision.current, error: message, message, tag: decision.tag, command: shown.command, install: shown.install }
|
|
1915
|
+
}
|
|
1916
|
+
let packed
|
|
1917
|
+
try {
|
|
1918
|
+
packed = repackReleaseTarball(bytes, { name: PACKAGE_NAME, version: decision.version })
|
|
1919
|
+
} catch (error) {
|
|
1920
|
+
const message = installError(error?.code, shown.command)
|
|
1921
|
+
return { ok: false, stage: 'install', current: decision.current, error: message, message, tag: decision.tag, command: shown.command, install: shown.install }
|
|
1922
|
+
}
|
|
1923
|
+
const dir = mkdtempSync(join(tmpdir(), 'dsh-qq-bot-update-'))
|
|
1924
|
+
const tarballPath = join(dir, `dsh-qq-bot-${decision.version}.tgz`)
|
|
1925
|
+
let ran
|
|
1926
|
+
try {
|
|
1927
|
+
writeFileSync(tarballPath, packed)
|
|
1928
|
+
const run = input.runCommand || defaultRunCommand
|
|
1929
|
+
ran = await run(installRunSpec(input, { tarballPath }))
|
|
1930
|
+
} catch {
|
|
1931
|
+
ran = { ok: false, code: 'error' }
|
|
1932
|
+
} finally {
|
|
1933
|
+
rmSync(dir, { recursive: true, force: true })
|
|
1934
|
+
}
|
|
1935
|
+
if (!ran || ran.ok !== true) {
|
|
1936
|
+
return failInstall(input, { ...decision, install: shown.install, command: shown.command }, ran, shown.command, shown.install)
|
|
1937
|
+
}
|
|
1938
|
+
return finishApply(input, { ...decision, install: shown.install, command: shown.command }, 'github')
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
async function finishApply(input, decision, channel) {
|
|
1942
|
+
const note = alignNote(input)
|
|
1943
|
+
let restart
|
|
1944
|
+
try {
|
|
1945
|
+
const schedule = input.scheduleRestart || schedulePanelRestart
|
|
1946
|
+
restart = await schedule({ spawnImpl: input.spawnImpl, platform: input.platform })
|
|
1947
|
+
} catch {
|
|
1948
|
+
restart = { ok: false, error: restartError() }
|
|
1949
|
+
}
|
|
1950
|
+
if (!restart || restart.ok !== true) {
|
|
1951
|
+
const message = `${restart?.error || restartError()}${note}`
|
|
1952
|
+
return {
|
|
1953
|
+
ok: false,
|
|
1954
|
+
stage: 'restart',
|
|
1955
|
+
installed: true,
|
|
1956
|
+
channel,
|
|
1957
|
+
current: decision.current,
|
|
1958
|
+
version: decision.version,
|
|
1959
|
+
tag: decision.tag,
|
|
1960
|
+
install: decision.install,
|
|
1961
|
+
command: decision.command,
|
|
1962
|
+
error: message,
|
|
1963
|
+
message,
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
const message = successMessage(decision.tag, channel, note)
|
|
1967
|
+
return {
|
|
1968
|
+
ok: true,
|
|
1969
|
+
applied: true,
|
|
1970
|
+
restarting: true,
|
|
1971
|
+
stage: 'restart',
|
|
1972
|
+
channel,
|
|
1973
|
+
current: decision.current,
|
|
1974
|
+
version: decision.version,
|
|
1975
|
+
latest: decision.version,
|
|
1976
|
+
tag: decision.tag,
|
|
1977
|
+
summary: decision.summary,
|
|
1978
|
+
install: decision.install,
|
|
1979
|
+
command: decision.command,
|
|
1980
|
+
url: decision.url,
|
|
1981
|
+
updateAvailable: false,
|
|
1982
|
+
message,
|
|
270
1983
|
}
|
|
271
|
-
return assessed
|
|
272
1984
|
}
|