@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/updates.js CHANGED
@@ -1,9 +1,41 @@
1
- // 检查 GitHub Release 上有没有比当前包更新的正式版。
1
+ // 检查有没有新版本,以及在用户明确要求时把新版本装进 DSH 的 web profile。
2
2
  //
3
- // 只比较版本、拼白话说明。不下载、不安装、不改配置。
4
- // 网络细节留在 checkPluginUpdate;界面和测试都吃这里的返回值。
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 spec = installSpec(latest.parsed.raw, repo)
156
- const command = installCommand(latest.parsed.raw, repo)
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(`发布页:${url}`)
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
- * @param {{ current: unknown, fetchImpl?: typeof fetch, repo?: string }} [input]
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 current = input.current
263
- const queried = await queryGithubReleases(input.fetchImpl, { repo: input.repo })
264
- if (!queried.ok) {
265
- return { ok: false, current: String(current ?? ''), error: queried.error, message: queried.error }
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 assessed = assessUpdate({ current, releases: queried.releases, repo: input.repo })
268
- if (assessed.ok === false) {
269
- return { ok: false, current: assessed.current, error: assessed.message, message: assessed.message }
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
  }