@mzzsfy/dsh-maintain 0.7.1 → 0.9.0
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/README.md +4 -3
- package/package.json +1 -1
- package/src/client.js +175 -30
- package/src/core.mjs +140 -20
- package/src/index.js +40 -9
- package/test/client-id.test.mjs +17 -0
- package/test/core.test.mjs +51 -0
- package/test/parity.test.mjs +128 -0
- package/test/release-notes.test.mjs +165 -0
- package/test/restart-ready.test.mjs +2 -1
- package/test/route.test.mjs +271 -22
package/README.md
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
# @mzzsfy/dsh-maintain
|
|
2
2
|
|
|
3
|
-
DeepSeek Harness 设置页插件:版本与进程运维一体化——监测 npm
|
|
3
|
+
DeepSeek Harness 设置页插件:版本与进程运维一体化——监测 npm 新版本、查看更新内容、一键安装(升级/重装/回退)、安全重启。
|
|
4
4
|
|
|
5
5
|
## 功能
|
|
6
6
|
|
|
7
7
|
- 版本监测:host 启动即检查一次,按轮询间隔重复检查(内存快照,不持久化);当前版本与追踪通道最新版对比,落后即提示。运行版本(宿主启动时缓存)与已装版本(磁盘实时)分开统计与展示,落后判定以运行版本为准,已装版本领先运行版本时提示"重启后生效"。定时轮询为软依赖:宿主 timer 服务不可用时仅停用自动轮询,面板显示降级提示,手动检查与升级能力不受影响。
|
|
8
|
+
- 版本更新内容:面板"更新内容"按钮按追踪通道最新版本拉取 GitHub Releases 发布说明(deepseek-ai/deepseek-harness,标签 `dsh-v<版本>`,npm 清单无 readme 字段故以官方 releases 为事实源;通道最新版就绪前按钮禁用)。数据源为 releases 页面而非 api.github.com(未认证 API 限流 60 次/小时/IP 且按 IP 共享,页面限流实际宽松),从 markdown-body 正文容器深度配对提取,渲染后 HTML 映射回行文本(标题→# 前缀、列表→- 前缀、块级闭合→换行、实体解码;标签剥离先于实体解码,产物只经 React 文本节点输出无 XSS 面),页面响应体上限 512KB 流式截断;页面 URL 自建,无需远端 URL 白名单。弹窗展示版本、发布时间与正文,附"在 GitHub 查看"源站链接,无发布说明时明确提示;host 进程内缓存(仅成功结果,上限 20 个版本,超限淘汰最早缓存的版本,GitHub Releases 视为不可变故不设过期),同一版本重复查看不重复请求。GitHub 不可达或该版本未发布 Release 时窗内提示错误并可重试,附 releases 页兜底链接;路由 `GET /api/maintain/release-notes` 取追踪通道最新版。
|
|
8
9
|
- 追踪通道:在 npm dist-tags 间切换(latest / next / alpha 等,选项由检查结果动态生成),切换后按新通道判定。
|
|
9
|
-
-
|
|
10
|
+
- 一键安装:面板"安装"按钮全版本状态可点(升级、重装修复、回退均从面板直达,不再有"已是最新"禁用与 409 拒绝),点击弹出确认窗,窗内展示版本变更与将执行的实际命令(`{tag}` 已替换为追踪通道)及"安装完成后自动重启宿主"勾选项(默认勾选,本次安装会话级生效,不持久化),确认后才触发。版本变更按安装意图自动表述:命令模板含精确版本号(如 `@0.1.4`)即按"安装指定版本"表述并附改回 `{tag}` 的指引;否则按通道目标与运行版本分类为升级/重装/回退,信息不足(通道最新版未知或版本无法比较)时窗内明示无法判定,仍可确认安装。通道目标等于运行版本即重装(用于本体损坏或异常安装的修复);需要回退时把模板中的 `@{tag}` 改为指定版本号(如 `@0.1.4`),回退完成后改回 `{tag}` 形态,否则后续安装持续钉在该版本。安装期间串行化(含跨进程锁文件:宿主被外部重启后,残留安装子进程的锁在有效期内仍拒绝新安装),单次尝试超时 10 分钟强杀;安装后台执行,触发后可离开页面,右下角浮条持续提示进度,完成或失败时替换为结果提示,长时间无进展转提示刷新,回到页面查看结果并按提示重启生效;继续运行路径下安装完成后自动重新检查版本。
|
|
10
11
|
- 失败分类与自动重试:升级命令失败按输出特征分类(文件锁 EBUSY/EPERM 等 Windows 全局目录占用、网络瞬断与 registry 5xx、命令未找到、超时强杀、其他未知),文件锁与网络类自动重试,最多 3 次尝试,退避序列文件锁 5s/15s、网络 3s/9s,重试时升级锁有效期顺延;命令未找到、超时与未知形态不重试直接落定。重试链的整体观察窗口与宿主侧重试上限同源锁定(parity),浮条不会在宿主仍在重试时抢跑转"状态未知"。
|
|
11
|
-
-
|
|
12
|
+
- 安装结果校验:安装命令成功且宿主继续运行(手动直跑、本次未勾选自动重启或宿主不具备自动退出能力)时复读磁盘版本,按安装意图判定是否达成——钉定版本(命令含精确版本号)时恰装到该版本即达成;通道语义下目标高于来版(升级)时要求版本前进且达通道目标,版本未前进(镜像滞后或静默失败)或未达目标即标注 stale;目标等于来版(重装)时版本不变属预期;目标低于来版(回退,如切通道或装旧版)时恰落到通道目标即达成,静默未动同样拦下。未达成时面板与浮条标注 stale 提示;复读失败不标 stale(版本状态由启动检查兜底),通道目标未知时仅豁免未达目标判定(版本未前进仍拦下)。自动重启路径不做复读与 stale 判定(安装落定钩子禁止网络请求与磁盘读取),版本变化由重启后的启动检查呈现。残留升级锁(宿主被外部重启后的无主锁,疑似安装子进程仍在写全局目录)在有效期内拒绝新安装(409),等待锁过期或手动删除锁文件后重试。
|
|
12
13
|
- 运行环境检测与升级后自动重启:host 启动时检测运行环境(pm2 / systemd / supervisord / Kubernetes / 容器,依据进程环境变量与 POSIX 容器特征,win32 跳过容器探测;stdin+stdout 双 TTY 判定为手动直跑;环境变量 DSH_MAINTAIN_RUNTIME_ENV=managed|manual 可强制指定,最高优先)。升级确认弹窗勾选自动重启且命令成功(退出码 0)时,托管环境下延迟 3 秒自动重启宿主——与面板"确认重启"共用同一退出调度入口(同一互斥、同一审计、同一延迟退出,仅审计行不同),落定钩子零网络请求零磁盘读取(npm 刚替换过宿主文件,避免半写状态故障);浮条提示宿主将自动重启并自动进入重启等待探测,页面随宿主恢复自动刷新;手动直跑环境不自动退出,浮条提示手动重启(弹窗内同步提示该环境约束);检测为未知形态按托管处理(维持自动重启,由重启等待总超时兜底提示)。升级失败或未勾选自动重启时不调度,后者升级完成仅提示,需在面板手动重启。
|
|
13
14
|
- 活跃工作门控:升级与重启前检测运行中的 agent 会话、后台 job 与终端(agents/jobs/terminals 服务全部软依赖方法面守卫;terminals 主路径逐 agent 的 realm 作用域解析,共享根探测仅兜底,无 agent 时服务面不可见属常态不计降级;降级按服务留痕限频一次)。部分降级时已实证的活跃计数仍生效(计数>0 照常拒绝);仅当完全不可用(计数为 0 且检测不可用)时放行,不因安全网缺失死锁操作。存在活跃工作时升级被拒绝(409,不可越过);重启被拒但确认态显示活跃计数,再次点击以 force 越过;概要随 status 提供给面板。
|
|
14
15
|
- 审计日志:升级/重启的触发、落定与拒绝各输出一行结构化日志(`audit endpoint=… outcome=…`),动态串单行化,便于事后排查。
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -23,9 +23,9 @@ window.__ModuleLoader__.load({
|
|
|
23
23
|
else console.warn('[dsh-maintain] ' + text)
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
// 导航图标声明:交给 dsh-settings-nav-icons
|
|
27
|
-
//
|
|
28
|
-
const NAV_ICON = { '版本与运维': 'wrench' }
|
|
26
|
+
// 导航图标声明:交给 dsh-settings-nav-icons 统一渲染;双键 = 分区 label + 市场
|
|
27
|
+
// 短名(发现页收录显示形态);该插件未就绪时入队,由其启动时排空
|
|
28
|
+
const NAV_ICON = { '版本与运维': 'wrench', 'dsh-maintain': 'wrench' }
|
|
29
29
|
if (window.__navicIcons !== undefined) window.__navicIcons.register(NAV_ICON)
|
|
30
30
|
else if (Array.isArray(window.__navicIconQueue)) window.__navicIconQueue.push(NAV_ICON)
|
|
31
31
|
else window.__navicIconQueue = [NAV_ICON]
|
|
@@ -90,6 +90,11 @@ const CSS = [
|
|
|
90
90
|
'.dm-dialog__hint { color:var(--dsw-alias-label-secondary); font-size:12px; }',
|
|
91
91
|
'.dm-dialog__section { display:flex; flex-direction:column; gap:6px; padding:8px 0; border-top:1px solid var(--dsw-alias-separator-primary, rgba(128,128,128,0.35)); }',
|
|
92
92
|
'.dm-dialog__outcome { color:var(--dsw-alias-label-secondary); font-size:12px; line-height:1.6; }',
|
|
93
|
+
// ---- 更新内容弹窗:markdown+HTML 混排正文的轻量行渲染 ----
|
|
94
|
+
'.dm-notes { font-size:12px; line-height:1.7; white-space:pre-wrap; word-break:break-word; color:inherit; }',
|
|
95
|
+
'.dm-notes__h { font-weight:600; margin-top:8px; }',
|
|
96
|
+
'.dm-notes__li { padding-left:12px; }',
|
|
97
|
+
'.dm-notes__gap { height:6px; }',
|
|
93
98
|
].join('\n')
|
|
94
99
|
|
|
95
100
|
const STATUS_URL = '/api/maintain/status'
|
|
@@ -100,6 +105,7 @@ const POLL_INTERVAL_URL = '/api/maintain/poll-interval'
|
|
|
100
105
|
const REGISTRY_BASE_URL = '/api/maintain/registry-base'
|
|
101
106
|
const UPGRADE_URL = '/api/maintain/upgrade'
|
|
102
107
|
const RESTART_URL = '/api/maintain/restart'
|
|
108
|
+
const RELEASE_NOTES_URL = '/api/maintain/release-notes'
|
|
103
109
|
const UPGRADE_POLL_MS = 2 * 1000
|
|
104
110
|
// 升级提示浮条挂 body,脱离 React 组件树,SPA 切页不消失
|
|
105
111
|
const UPGRADE_FLOAT_ID = 'dsh-maintain-upgrade-float'
|
|
@@ -122,6 +128,8 @@ const RESTART_SETTLE_DELAY_MS = 1 * 1000
|
|
|
122
128
|
// 页面就绪探测目标:与整页刷新后浏览器实际请求的主文档同资源
|
|
123
129
|
const INDEX_URL = '/'
|
|
124
130
|
const NPM_VERSIONS_URL = 'https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions'
|
|
131
|
+
// 更新内容事实源(release 上游失败时的兜底入口);host 侧 RELEASE_REPO 与此同源(parity 对拍)
|
|
132
|
+
const RELEASES_PAGE_URL = 'https://github.com/deepseek-ai/deepseek-harness/releases'
|
|
125
133
|
|
|
126
134
|
// abort-aware 睡眠:轮询顺序循环的拍间间隔,中止即提前唤醒
|
|
127
135
|
function restartSleep(ms, controller) {
|
|
@@ -151,11 +159,11 @@ function restartPostLost(error) {
|
|
|
151
159
|
// test/parity.test.mjs 按 const 名正则提取对拍,修改任一侧必须同步。
|
|
152
160
|
const VERDICT_OUTDATED = 'outdated'
|
|
153
161
|
const VERDICT_UP_TO_DATE = 'up-to-date'
|
|
162
|
+
const VERDICT_UNKNOWN = 'unknown'
|
|
154
163
|
// 与 host runtime.mjs RUNTIME_KINDS.MANUAL_START 镜像(parity 对拍,单侧改名即测试失败)
|
|
155
164
|
const RUNTIME_KIND_MANUAL = 'manual-start-likely'
|
|
156
165
|
// 与 host AUTO_RESTART_DELAY_MS 镜像(parity 对拍):弹窗文案引用,勿手抄数字
|
|
157
166
|
const AUTO_RESTART_DELAY_SEC = 3
|
|
158
|
-
const VERDICT_UNKNOWN = 'unknown'
|
|
159
167
|
|
|
160
168
|
// 非 2xx 应答抛带 status 与完整 payload 的错误对象:调用方据此区分"活宿主明确回绝"(有 status)
|
|
161
169
|
// 与"网络失联"(fetch reject TypeError/abort DOMException,无 status);payload 供活跃工作
|
|
@@ -281,6 +289,8 @@ function ensureUpgradeFloatStyle() {
|
|
|
281
289
|
if (document.getElementById(UPGRADE_FLOAT_STYLE_ID) !== null) return
|
|
282
290
|
const style = document.createElement('style')
|
|
283
291
|
style.id = UPGRADE_FLOAT_STYLE_ID
|
|
292
|
+
// 自带 data-plugin:缺失时宿主 claimStyles 会把它归属给后续材质化插件,其 HMR 重建即误删
|
|
293
|
+
style.setAttribute('data-plugin', '@mzzsfy/dsh-maintain')
|
|
284
294
|
style.textContent = [
|
|
285
295
|
'#' + UPGRADE_FLOAT_ID + ' { position:fixed; right:20px; bottom:20px; z-index:9999; display:flex; align-items:center; gap:10px;',
|
|
286
296
|
' max-width:340px; padding:10px 14px; border-radius:10px; border:1px solid rgba(128,128,128,0.35);',
|
|
@@ -295,13 +305,68 @@ function ensureUpgradeFloatStyle() {
|
|
|
295
305
|
// 其后按宿主是否自动重启分流指引
|
|
296
306
|
// LOGIC-BEGIN upgradeFinalText
|
|
297
307
|
function upgradeFinalText(state) {
|
|
298
|
-
if (state.stale === true) return '
|
|
308
|
+
if (state.stale === true) return '升级命令执行完成,但结果与安装意图不符,详情见版本与运维页'
|
|
299
309
|
if (state.requiresManualRestart === true) return '升级完成,当前为手动直跑环境,请手动重启宿主后生效'
|
|
300
310
|
if (state.autoRestartScheduled === true) return '升级完成,宿主将自动重启,页面恢复后可直接使用'
|
|
301
311
|
return '升级完成,重启宿主后生效(版本与运维页可重启)'
|
|
302
312
|
}
|
|
303
313
|
// LOGIC-END upgradeFinalText
|
|
304
314
|
|
|
315
|
+
// 从已解析安装命令提取钉定版本(@x.y.z 精确版本 token,取最后合法者)。
|
|
316
|
+
// 与 core extractPinnedVersion 镜像(parity 对拍):客户端无 parseSemver,
|
|
317
|
+
// token 合法性以内嵌 semver 语法判定,规则与 host SEMVER_PATTERN 同源
|
|
318
|
+
// LOGIC-BEGIN extractPinnedVersion
|
|
319
|
+
function extractPinnedVersion(command) {
|
|
320
|
+
const text = typeof command === 'string' ? command : ''
|
|
321
|
+
const pattern = /@([0-9A-Za-z][0-9A-Za-z.+-]*)/g
|
|
322
|
+
const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
|
|
323
|
+
let pinned = null
|
|
324
|
+
for (const match of text.matchAll(pattern)) {
|
|
325
|
+
if (semverPattern.test(match[1])) pinned = match[1]
|
|
326
|
+
}
|
|
327
|
+
return pinned
|
|
328
|
+
}
|
|
329
|
+
// LOGIC-END extractPinnedVersion
|
|
330
|
+
|
|
331
|
+
// 安装确认弹窗的版本变更说明:钉定版本最优先(意图=精确版本,附改回 {tag} 指引);
|
|
332
|
+
// 通道语义按 未知/重装(含已装将被替换提示)/升级/无法判定/回退 分支,
|
|
333
|
+
// 回退表述不断言方向(semver 等值但字面不同时同样成立),判等只用字符串相等
|
|
334
|
+
// LOGIC-BEGIN buildInstallChangeLine
|
|
335
|
+
function buildInstallChangeLine(status, command) {
|
|
336
|
+
// 与通道名重合的 token 是 {tag} 展开产物(通道名可为 semver 形态),不属钉定
|
|
337
|
+
const detected = extractPinnedVersion(command)
|
|
338
|
+
const pinned = detected !== null && detected !== status.channel ? detected : null
|
|
339
|
+
if (pinned !== null) {
|
|
340
|
+
return '将安装指定版本 ' + pinned + '(当前运行 ' + (status.runningVersion || '未知')
|
|
341
|
+
+ ');回退或修复完成后,请把命令模板改回含 {tag} 的原形态,否则后续安装持续钉在该版本。'
|
|
342
|
+
}
|
|
343
|
+
const target = status.channelLatest
|
|
344
|
+
const running = status.runningVersion
|
|
345
|
+
if (!target) {
|
|
346
|
+
return '通道最新版未知,将安装通道「' + status.channel + '」当前指向的版本。'
|
|
347
|
+
}
|
|
348
|
+
if (target === running) {
|
|
349
|
+
const pending = status.installedVersion && status.installedVersion !== running
|
|
350
|
+
? ',磁盘已装 ' + status.installedVersion + ' 将被替换' : ''
|
|
351
|
+
return '将重装 ' + target + ':运行版本不变' + pending + ',用于修复被破坏的本体或异常安装。'
|
|
352
|
+
}
|
|
353
|
+
if (status.verdict === VERDICT_OUTDATED) {
|
|
354
|
+
return '将升级:运行 ' + (running || '未知') + ' → ' + target + '。'
|
|
355
|
+
}
|
|
356
|
+
if (status.verdict === VERDICT_UNKNOWN) {
|
|
357
|
+
return '将安装到 ' + target + '(运行/目标版本无法比较,无法判定升降级)。'
|
|
358
|
+
}
|
|
359
|
+
return '通道目标 ' + target + ' 不高于当前运行 ' + (running || '未知') + ',宿主将回退/对齐到通道版本。'
|
|
360
|
+
}
|
|
361
|
+
// LOGIC-END buildInstallChangeLine
|
|
362
|
+
|
|
363
|
+
// 弹窗的当前版本行:运行/已装任一缺失回退"未知"
|
|
364
|
+
// LOGIC-BEGIN buildInstallVersionHint
|
|
365
|
+
function buildInstallVersionHint(status) {
|
|
366
|
+
return '版本变更:当前 运行 ' + (status.runningVersion || '未知') + ' / 已装 ' + (status.installedVersion || '未知')
|
|
367
|
+
}
|
|
368
|
+
// LOGIC-END buildInstallVersionHint
|
|
369
|
+
|
|
305
370
|
// state:null=进行中;对象=升级结果(last,ok 区分成败);'unknown'=观察超限状态未知
|
|
306
371
|
function showUpgradeFloat(state) {
|
|
307
372
|
ensureUpgradeFloatStyle()
|
|
@@ -429,14 +494,11 @@ function VersionCard(props) {
|
|
|
429
494
|
}, props.busy.refresh ? '检查中…' : '刷新'),
|
|
430
495
|
h('button', {
|
|
431
496
|
className: 'dm-btn',
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
disabled: props.busy.upgrade || props.restarting || (status.upgrade && status.upgrade.running)
|
|
435
|
-
|| status.upgradeLockHeld === true
|
|
436
|
-
|| status.verdict === VERDICT_UP_TO_DATE,
|
|
437
|
-
title: status.verdict === VERDICT_UP_TO_DATE ? '当前已是通道最新版,无需升级;重装请走命令行' : undefined,
|
|
497
|
+
// 安装入口全版本状态可点(重装修复/回退是显式意图):仅瞬态禁用(请求在途/升级进行中/重启等待);
|
|
498
|
+
// 残留锁与互斥保护在提交时由 host 409 拒绝,原因经 toast 提示
|
|
499
|
+
disabled: props.busy.upgrade || props.restarting || (status.upgrade && status.upgrade.running),
|
|
438
500
|
onClick: props.onUpgrade,
|
|
439
|
-
}, '
|
|
501
|
+
}, '安装'),
|
|
440
502
|
),
|
|
441
503
|
status.restartPending === true
|
|
442
504
|
? h('div', { className: 'dm-notice dm-notice--warn' },
|
|
@@ -460,6 +522,13 @@ function VersionCard(props) {
|
|
|
460
522
|
checked ? h('span', { className: 'dm-meta' }, '上次检查 ' + checked) : null,
|
|
461
523
|
status.checkError ? h('span', { className: 'dm-error' }, status.checkError) : null,
|
|
462
524
|
h('a', { className: 'dm-link', href: NPM_VERSIONS_URL, target: '_blank', rel: 'noreferrer' }, 'npm 版本页'),
|
|
525
|
+
// 更新内容入口:通道最新版未知(tags 未就绪)时禁用
|
|
526
|
+
h('button', {
|
|
527
|
+
className: 'dm-btn',
|
|
528
|
+
disabled: !status.channelLatest || props.restarting,
|
|
529
|
+
title: '查看 ' + (status.channelLatest || '') + ' 的更新内容',
|
|
530
|
+
onClick: props.onReleaseNotes,
|
|
531
|
+
}, '更新内容'),
|
|
463
532
|
),
|
|
464
533
|
h(EditRow, {
|
|
465
534
|
label: '升级命令',
|
|
@@ -518,7 +587,7 @@ function UpgradeCard(props) {
|
|
|
518
587
|
// stale(执行成功但版本未前进/未达目标)时成功行降格为中性表述,不引导重启,
|
|
519
588
|
// 与下方 stale 警告行自洽
|
|
520
589
|
last.ok ? h('span', { className: last.stale === true ? 'dm-warn' : 'dm-ok' },
|
|
521
|
-
last.stale === true ? '
|
|
590
|
+
last.stale === true ? '升级命令执行完成,但结果与安装意图不符' : '升级完成,重启宿主后生效')
|
|
522
591
|
: h('span', { className: 'dm-error' }, '升级失败'
|
|
523
592
|
+ (last.code !== null && last.code !== undefined ? '(退出码 ' + last.code + ')' : '')
|
|
524
593
|
+ (Array.isArray(last.attempts) && last.attempts.length > 1 ? '(已尝试 ' + last.attempts.length + ' 次)' : '')
|
|
@@ -534,7 +603,7 @@ function UpgradeCard(props) {
|
|
|
534
603
|
: null,
|
|
535
604
|
!upgrade.running && last && last.ok === true && last.stale === true
|
|
536
605
|
? h('div', { className: 'dm-row' },
|
|
537
|
-
h('span', { className: 'dm-warn' }, '
|
|
606
|
+
h('span', { className: 'dm-warn' }, '安装结果与意图不符: ' + (last.reason || '镜像可能滞后')))
|
|
538
607
|
: null,
|
|
539
608
|
!upgrade.running && last && !last.ok && last.stderrTail
|
|
540
609
|
? h('pre', { className: 'dm-pre' }, last.stderrTail)
|
|
@@ -569,10 +638,8 @@ function OpsCard(props) {
|
|
|
569
638
|
)
|
|
570
639
|
}
|
|
571
640
|
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
// 勾选=命令成功后宿主延迟 3 秒退出,由进程管理器拉起新版本,页面随宿主恢复自动刷新;
|
|
575
|
-
// 不勾选=宿主继续运行旧版本,面板提示"重启宿主后生效",需手动点重启按钮
|
|
641
|
+
// 安装确认弹窗:展示版本变更(从→到)、将执行的命令({tag} 已替换为追踪通道)与自动重启勾选。
|
|
642
|
+
// 重装修复(目标==运行版本)与回退(目标低于运行版本)是面板显式意图,窗内分类说明
|
|
576
643
|
function UpgradeDialog(props) {
|
|
577
644
|
// 每次打开都重新挂载(MaintainApp 条件渲染),默认勾选随挂载重置
|
|
578
645
|
const [autoRestart, setAutoRestart] = useState(true)
|
|
@@ -581,21 +648,24 @@ function UpgradeDialog(props) {
|
|
|
581
648
|
? status.upgradeTemplate
|
|
582
649
|
: DEFAULT_UPGRADE_TEMPLATE
|
|
583
650
|
const command = template.split('{tag}').join(status.channel)
|
|
651
|
+
const changeLine = buildInstallChangeLine(status, command)
|
|
584
652
|
return h('div', { className: 'dm-dialog__mask', onClick: props.onCancel },
|
|
585
653
|
h('div', { className: 'dm-dialog', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
|
|
586
|
-
h('div', { className: 'dm-card__title' }, '
|
|
587
|
-
h('div', { className: 'dm-dialog__hint' },
|
|
654
|
+
h('div', { className: 'dm-card__title' }, '确认安装'),
|
|
655
|
+
h('div', { className: 'dm-dialog__hint' }, buildInstallVersionHint(status)),
|
|
656
|
+
h('div', { className: 'dm-dialog__hint dm-warn' }, changeLine),
|
|
657
|
+
h('div', { className: 'dm-dialog__hint' }, '将执行以下命令(安装期间页面可离开,进度由右下角浮条提示):'),
|
|
588
658
|
h('pre', { className: 'dm-pre' }, command),
|
|
589
659
|
h('div', { className: 'dm-dialog__section' },
|
|
590
660
|
h(Switch, {
|
|
591
|
-
label: '
|
|
661
|
+
label: '安装完成后自动重启宿主',
|
|
592
662
|
checked: autoRestart,
|
|
593
663
|
onChange: setAutoRestart,
|
|
594
664
|
}),
|
|
595
665
|
h('div', { className: 'dm-dialog__outcome' },
|
|
596
666
|
autoRestart
|
|
597
|
-
? '
|
|
598
|
-
: '
|
|
667
|
+
? '已勾选:安装命令执行成功后,宿主将在 ' + AUTO_RESTART_DELAY_SEC + ' 秒后自动退出,由你的进程管理器(docker / pm2 / systemd 等)拉起,本页随宿主恢复自动刷新。安装落定后宿主不再执行任何网络请求与磁盘读取。'
|
|
668
|
+
: '未勾选:安装完成后宿主继续运行当前版本,面板提示「重启宿主后生效」,需要你手动点击重启按钮才切换到目标版本。'),
|
|
599
669
|
status.runtimeEnv && status.runtimeEnv.kind === RUNTIME_KIND_MANUAL
|
|
600
670
|
? h('div', { className: 'dm-dialog__outcome dm-warn' }, '当前为手动终端直跑环境:没有进程管理器拉起宿主,勾选自动重启也不会自动退出,完成后仍需手动重启。')
|
|
601
671
|
: null,
|
|
@@ -603,7 +673,75 @@ function UpgradeDialog(props) {
|
|
|
603
673
|
h('div', { className: 'dm-row' },
|
|
604
674
|
h('span', { className: 'dm-spacer' }),
|
|
605
675
|
h('button', { className: 'dm-btn', onClick: props.onCancel }, '取消'),
|
|
606
|
-
h('button', { className: 'dm-btn', onClick: () => props.onConfirm(autoRestart) }, '
|
|
676
|
+
h('button', { className: 'dm-btn', onClick: () => props.onConfirm(autoRestart) }, '开始安装'),
|
|
677
|
+
),
|
|
678
|
+
),
|
|
679
|
+
)
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// release 正文为 markdown+HTML 混排,轻量行渲染:剥 HTML 标签,标题加粗,列表缩进。
|
|
683
|
+
// 全部经 React 文本节点输出(自动转义),外部数据不进 innerHTML;key 用行序号,静态一次性渲染
|
|
684
|
+
function renderNotesBody(body) {
|
|
685
|
+
const nodes = []
|
|
686
|
+
let index = 0
|
|
687
|
+
for (const rawLine of String(body || '').split('\n')) {
|
|
688
|
+
const line = rawLine.replace(/<[^>]*>/g, '').replace(/\r$/, '')
|
|
689
|
+
const base = line.trim()
|
|
690
|
+
const key = 'n' + (index++)
|
|
691
|
+
if (base.length === 0) {
|
|
692
|
+
nodes.push(h('div', { className: 'dm-notes__gap', key }))
|
|
693
|
+
} else if (/^#{1,6}\s+/.test(base)) {
|
|
694
|
+
nodes.push(h('div', { className: 'dm-notes__h', key }, base.replace(/^#{1,6}\s+/, '')))
|
|
695
|
+
} else if (/^[-*]\s+/.test(base)) {
|
|
696
|
+
nodes.push(h('div', { className: 'dm-notes__li', key }, '• ' + base.replace(/^[-*]\s+/, '')))
|
|
697
|
+
} else {
|
|
698
|
+
nodes.push(h('div', { key }, line))
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
return nodes
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// 更新内容弹窗:按版本拉取 GitHub release 说明,展示版本、发布时间、正文与源站链接;
|
|
705
|
+
// 每次打开重新挂载(MaintainApp 条件渲染),拉取失败在窗内展示错误并提供重试与 releases 页兜底
|
|
706
|
+
function ReleaseNotesDialog(props) {
|
|
707
|
+
const [state, setState] = useState({ phase: 'loading', data: null, error: null })
|
|
708
|
+
const [attempt, setAttempt] = useState(0)
|
|
709
|
+
useEffect(() => {
|
|
710
|
+
let active = true
|
|
711
|
+
setState({ phase: 'loading', data: null, error: null })
|
|
712
|
+
api(RELEASE_NOTES_URL)
|
|
713
|
+
.then((data) => { if (active) setState({ phase: 'ok', data, error: null }) })
|
|
714
|
+
.catch((error) => { if (active) setState({ phase: 'error', data: null, error }) })
|
|
715
|
+
return () => { active = false }
|
|
716
|
+
}, [props.version, attempt])
|
|
717
|
+
const data = state.phase === 'ok' ? state.data : null
|
|
718
|
+
return h('div', { className: 'dm-dialog__mask', onClick: props.onClose },
|
|
719
|
+
h('div', { className: 'dm-dialog', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
|
|
720
|
+
h('div', { className: 'dm-card__title' }, '更新内容(' + (data ? data.version : props.version) + ')'),
|
|
721
|
+
state.phase === 'loading' ? h('div', { className: 'dm-meta' }, '加载中…') : null,
|
|
722
|
+
state.phase === 'error'
|
|
723
|
+
? h('div', { className: 'dm-notice dm-notice--error' },
|
|
724
|
+
'拉取失败:' + (state.error && state.error.message ? state.error.message : String(state.error)))
|
|
725
|
+
: null,
|
|
726
|
+
state.phase === 'error'
|
|
727
|
+
? h('div', { className: 'dm-row' },
|
|
728
|
+
h('button', { className: 'dm-btn', onClick: () => setAttempt(attempt + 1) }, '重试'),
|
|
729
|
+
h('a', { className: 'dm-link', href: RELEASES_PAGE_URL, target: '_blank', rel: 'noreferrer' }, 'GitHub 全部版本说明'))
|
|
730
|
+
: null,
|
|
731
|
+
data
|
|
732
|
+
? h('div', { className: 'dm-row' },
|
|
733
|
+
h('span', { className: 'dm-meta' }, '发布于 ' + (fmtTime(Date.parse(data.publishedAt)) || '未知时间')),
|
|
734
|
+
data.url ? h('a', { className: 'dm-link', href: data.url, target: '_blank', rel: 'noreferrer' }, '在 GitHub 查看') : null,
|
|
735
|
+
)
|
|
736
|
+
: null,
|
|
737
|
+
data
|
|
738
|
+
? (data.body.length > 0
|
|
739
|
+
? h('div', { className: 'dm-notes' }, renderNotesBody(data.body))
|
|
740
|
+
: h('div', { className: 'dm-meta' }, '该版本无发布说明'))
|
|
741
|
+
: null,
|
|
742
|
+
h('div', { className: 'dm-row' },
|
|
743
|
+
h('span', { className: 'dm-spacer' }),
|
|
744
|
+
h('button', { className: 'dm-btn', onClick: props.onClose }, '关闭'),
|
|
607
745
|
),
|
|
608
746
|
),
|
|
609
747
|
)
|
|
@@ -616,6 +754,8 @@ function MaintainApp() {
|
|
|
616
754
|
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false)
|
|
617
755
|
const [restartArmed, setRestartArmed] = useState(false)
|
|
618
756
|
const [restarting, setRestarting] = useState(false)
|
|
757
|
+
// 更新内容弹窗的版本:非 null 即打开;重启等待态随 upgradeDialogOpenLive 同款派生关闭
|
|
758
|
+
const [notesVersion, setNotesVersion] = useState(null)
|
|
619
759
|
// 重启探测的跨拍状态快照 {lost,pid,bootAt,readyStreak}:lost 与 readyStreak 随拍更新,
|
|
620
760
|
// pid/bootAt 保持确认时刻基线不可被拍结果覆盖,否则重启判定恒 false;null=未在等待态
|
|
621
761
|
const restartPrevRef = useRef(null)
|
|
@@ -906,23 +1046,23 @@ function MaintainApp() {
|
|
|
906
1046
|
|
|
907
1047
|
if (status === null) {
|
|
908
1048
|
return h('div', { className: 'dm-panel' },
|
|
909
|
-
h('style', { dangerouslySetInnerHTML: { __html: CSS } }),
|
|
1049
|
+
h('style', { 'data-plugin': '@mzzsfy/dsh-maintain', dangerouslySetInnerHTML: { __html: CSS } }),
|
|
910
1050
|
h('span', { className: 'dm-meta' }, error !== null ? '读取失败' : '加载中…'),
|
|
911
1051
|
error !== null ? h('div', { className: 'dm-notice dm-notice--error' }, error) : null)
|
|
912
1052
|
}
|
|
913
1053
|
|
|
914
1054
|
// 升级入口不可用即关闭确认弹窗:渲染时派生,不引入 effect。
|
|
915
|
-
//
|
|
1055
|
+
// 仅瞬态门(升级进行中/重启中);版本状态与残留锁全放行(重装/回退场景),
|
|
1056
|
+
// 提交时 host 的互斥/锁/活跃工作门控照常工作,拒绝原因经 toast 提示
|
|
916
1057
|
const upgradeUnavailable = restarting
|
|
917
|
-
|| (status !== null &&
|
|
918
|
-
|| status.upgradeLockHeld === true
|
|
919
|
-
|| status.verdict === VERDICT_UP_TO_DATE))
|
|
1058
|
+
|| (status !== null && status.upgrade && status.upgrade.running === true)
|
|
920
1059
|
const upgradeDialogOpenLive = upgradeDialogOpen && !upgradeUnavailable
|
|
921
1060
|
// 活跃工作计数:重启确认态显示计数并发 force 越过门控
|
|
922
1061
|
const activeWorkTotal = status !== null && status.activeWork ? status.activeWork.total : 0
|
|
1062
|
+
const notesOpenLive = notesVersion !== null && !restarting
|
|
923
1063
|
|
|
924
1064
|
return h('div', { className: 'dm-panel' },
|
|
925
|
-
h('style', { dangerouslySetInnerHTML: { __html: CSS } }),
|
|
1065
|
+
h('style', { 'data-plugin': '@mzzsfy/dsh-maintain', dangerouslySetInnerHTML: { __html: CSS } }),
|
|
926
1066
|
h('div', { className: 'dm-head' },
|
|
927
1067
|
h('span', { className: 'dm-head__title' }, '版本与运维'),
|
|
928
1068
|
h('span', { className: 'dm-head__hint' }, '追踪 npm 新版本,一键升级,安全重启'),
|
|
@@ -941,6 +1081,7 @@ function MaintainApp() {
|
|
|
941
1081
|
onChannel,
|
|
942
1082
|
onTemplateSave,
|
|
943
1083
|
onUpgrade: () => { setRestartArmed(false); setUpgradeDialogOpen(true) },
|
|
1084
|
+
onReleaseNotes: () => { setRestartArmed(false); setNotesVersion(status.channelLatest) },
|
|
944
1085
|
onRestart,
|
|
945
1086
|
}),
|
|
946
1087
|
h(SettingsCard, {
|
|
@@ -957,6 +1098,10 @@ function MaintainApp() {
|
|
|
957
1098
|
onCancel: () => setUpgradeDialogOpen(false),
|
|
958
1099
|
onConfirm: onUpgrade,
|
|
959
1100
|
}) : null,
|
|
1101
|
+
notesOpenLive ? h(ReleaseNotesDialog, {
|
|
1102
|
+
version: notesVersion,
|
|
1103
|
+
onClose: () => setNotesVersion(null),
|
|
1104
|
+
}) : null,
|
|
960
1105
|
)
|
|
961
1106
|
}
|
|
962
1107
|
|
package/src/core.mjs
CHANGED
|
@@ -91,12 +91,40 @@ export function isVersionPendingRestart({ runningVersion, installedVersion }) {
|
|
|
91
91
|
return Boolean(parseSemver(runningVersion) && parseSemver(installedVersion) && gtSemver(installedVersion, runningVersion))
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
//
|
|
94
|
+
// 从已解析安装命令提取钉定版本(形如 @x.y.z[-pre][+build] 的精确版本 token),
|
|
95
|
+
// 取最后一个合法者(包说明符通常在命令尾部);token 只在 semver 字符集内截取,
|
|
96
|
+
// shell 粘连(&&、;、>)不参与;通道名/@latest/非法形态不命中,无钉定返回 null。
|
|
97
|
+
// 约束:semver 形态的通道名({tag} 展开产物)不属钉定,调用方须排除与通道名重合的 token
|
|
98
|
+
const PINNED_TOKEN_PATTERN = /@([0-9A-Za-z][0-9A-Za-z.+-]*)/g
|
|
99
|
+
export function extractPinnedVersion(command) {
|
|
100
|
+
const text = typeof command === 'string' ? command : ''
|
|
101
|
+
let pinned = null
|
|
102
|
+
for (const match of text.matchAll(PINNED_TOKEN_PATTERN)) {
|
|
103
|
+
if (parseSemver(match[1]) !== null) pinned = match[1]
|
|
104
|
+
}
|
|
105
|
+
return pinned
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 升级后磁盘版本复读判定:stale=结果与安装意图不符。意图优先级:
|
|
109
|
+
// 钉定版本(命令含 @x.y.z)→ 恰装到该版本才算达成,通道目标不参与对拍;
|
|
110
|
+
// 通道语义:目标>来版=升级(未前进/未达皆 stale);目标<=来版=重装或回退,
|
|
111
|
+
// 恰落到通道目标即达成(重装版本不变是预期,回退方向不套"未前进"口径)。
|
|
95
112
|
// previous 未知只豁免"未前进"分支;目标缺失时无法证明未达标,宽松判 fresh 不误报。
|
|
96
|
-
export function judgeUpgradeFreshness({ previousVersion, installedVersion, channelLatest }) {
|
|
113
|
+
export function judgeUpgradeFreshness({ previousVersion, installedVersion, channelLatest, pinnedVersion }) {
|
|
97
114
|
if (!parseSemver(installedVersion)) {
|
|
98
115
|
return { stale: true, reason: '升级后磁盘版本解析失败' + (installedVersion ? ': ' + installedVersion : '(读取失败)') }
|
|
99
116
|
}
|
|
117
|
+
if (parseSemver(pinnedVersion)) {
|
|
118
|
+
return compareSemver(installedVersion, pinnedVersion) === 0
|
|
119
|
+
? { stale: false, reason: null }
|
|
120
|
+
: { stale: true, reason: '未安装到指定版本: ' + installedVersion + '(命令钉定 ' + pinnedVersion + ')' }
|
|
121
|
+
}
|
|
122
|
+
if (parseSemver(previousVersion) !== null && parseSemver(channelLatest) !== null
|
|
123
|
+
&& compareSemver(channelLatest, previousVersion) <= 0) {
|
|
124
|
+
return compareSemver(installedVersion, channelLatest) === 0
|
|
125
|
+
? { stale: false, reason: null }
|
|
126
|
+
: { stale: true, reason: '未落到通道目标: ' + installedVersion + '(通道目标 ' + channelLatest + ')' }
|
|
127
|
+
}
|
|
100
128
|
if (parseSemver(previousVersion) && !gtSemver(installedVersion, previousVersion)) {
|
|
101
129
|
return { stale: true, reason: '磁盘版本未前进,升级可能静默失败或镜像滞后: ' + installedVersion }
|
|
102
130
|
}
|
|
@@ -188,22 +216,12 @@ export function isValidRegistryBase(base) {
|
|
|
188
216
|
}
|
|
189
217
|
}
|
|
190
218
|
|
|
191
|
-
// dist-tags
|
|
192
|
-
const
|
|
219
|
+
// 上游响应体上限:dist-tags 正常响应远小于此;流式累计读取,超限即断
|
|
220
|
+
const UPSTREAM_BODY_MAX_BYTES = 64 * 1024
|
|
193
221
|
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
if (!isValidRegistryBase(registryBase)) throw new Error('registry 基地址无效: ' + registryBase)
|
|
198
|
-
if (!(Number.isFinite(timeoutMs) && timeoutMs > 0)) throw new Error('timeoutMs 必须为正数')
|
|
199
|
-
const base = registryBase.trim().replace(/\/+$/, '')
|
|
200
|
-
const response = await fetchImpl(base + DIST_TAGS_PATH, {
|
|
201
|
-
headers: { accept: 'application/json' },
|
|
202
|
-
redirect: 'error',
|
|
203
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
204
|
-
})
|
|
205
|
-
if (!response.ok) throw new Error('registry HTTP ' + response.status)
|
|
206
|
-
// 流式累计限量:恶意/异常源的超大响应体在传输中途即被断开,不整量入内存
|
|
222
|
+
// 上游响应体流式限量读取:恶意/异常源的超大响应体在传输中途即被断开,不整量入内存。
|
|
223
|
+
// json() 对 BOM 有容忍;text 路径显式剥除保持等价;maxBytes 按数据源传入(发布页更大)
|
|
224
|
+
async function readBodyTextLimited(response, maxBytes = UPSTREAM_BODY_MAX_BYTES) {
|
|
207
225
|
const reader = response.body.getReader()
|
|
208
226
|
const chunks = []
|
|
209
227
|
let total = 0
|
|
@@ -212,7 +230,7 @@ export async function fetchDistTags({ registryBase, fetchImpl = fetch, timeoutMs
|
|
|
212
230
|
const { done, value } = await reader.read()
|
|
213
231
|
if (done) break
|
|
214
232
|
total += value.byteLength
|
|
215
|
-
if (total >
|
|
233
|
+
if (total > maxBytes) throw new Error('响应超过上限')
|
|
216
234
|
chunks.push(value)
|
|
217
235
|
}
|
|
218
236
|
} finally {
|
|
@@ -224,8 +242,22 @@ export async function fetchDistTags({ registryBase, fetchImpl = fetch, timeoutMs
|
|
|
224
242
|
merged.set(chunk, offset)
|
|
225
243
|
offset += chunk.byteLength
|
|
226
244
|
}
|
|
227
|
-
|
|
228
|
-
|
|
245
|
+
return new TextDecoder().decode(merged).replace(/^\uFEFF/, '')
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 拉取 dist-tags 轻量端点;fetchImpl 注入便于单测,错误一律抛出由调用方决定保留上次结果。
|
|
249
|
+
// redirect 拒绝跟随:镜像 302 跳内网/他源属配置外行为,直接失败交调用方展示。
|
|
250
|
+
export async function fetchDistTags({ registryBase, fetchImpl = fetch, timeoutMs }) {
|
|
251
|
+
if (!isValidRegistryBase(registryBase)) throw new Error('registry 基地址无效: ' + registryBase)
|
|
252
|
+
if (!(Number.isFinite(timeoutMs) && timeoutMs > 0)) throw new Error('timeoutMs 必须为正数')
|
|
253
|
+
const base = registryBase.trim().replace(/\/+$/, '')
|
|
254
|
+
const response = await fetchImpl(base + DIST_TAGS_PATH, {
|
|
255
|
+
headers: { accept: 'application/json' },
|
|
256
|
+
redirect: 'error',
|
|
257
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
258
|
+
})
|
|
259
|
+
if (!response.ok) throw new Error('registry HTTP ' + response.status)
|
|
260
|
+
const text = await readBodyTextLimited(response)
|
|
229
261
|
let body
|
|
230
262
|
try {
|
|
231
263
|
body = JSON.parse(text)
|
|
@@ -283,3 +315,91 @@ export async function resolveHostVersion({ execPath, platform, argv1, readFileIm
|
|
|
283
315
|
}
|
|
284
316
|
return null
|
|
285
317
|
}
|
|
318
|
+
|
|
319
|
+
// 版本更新内容事实源:npm 清单无 readme 字段(实测),官方 GitHub Releases 承载,
|
|
320
|
+
// tag 命名 dsh-v<版本> 与 TARGET_PACKAGE 同为该包的固定事实,随包常量维护。
|
|
321
|
+
// 数据源为 releases 页面而非 api.github.com:未认证 API 限流 60 次/小时/IP 且按 IP 共享,
|
|
322
|
+
// 页面限流实际宽松(实测同机 API 403 时页面 200);页面 URL 自建,无需远端 URL 白名单。
|
|
323
|
+
export const RELEASE_SITE_BASE = 'https://github.com'
|
|
324
|
+
export const RELEASE_REPO = 'deepseek-ai/deepseek-harness'
|
|
325
|
+
export const RELEASE_TAG_PREFIX = 'dsh-v'
|
|
326
|
+
// 发布页响应体上限:实测页面约 256KB,留余量;流式截断纪律与 dist-tags 一致
|
|
327
|
+
const RELEASE_PAGE_MAX_BYTES = 512 * 1024
|
|
328
|
+
|
|
329
|
+
// 发布标签构建:版本必须为合法 semver(远端数据回流 URL 前的白名单校验),首尾空白归一
|
|
330
|
+
export function buildReleaseTag(version) {
|
|
331
|
+
const text = typeof version === 'string' ? version.trim() : ''
|
|
332
|
+
if (!parseSemver(text)) throw new Error('版本不是合法 semver: ' + version)
|
|
333
|
+
return RELEASE_TAG_PREFIX + text
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function buildReleaseNotesUrl(version) {
|
|
337
|
+
return RELEASE_SITE_BASE + '/' + RELEASE_REPO + '/releases/tag/' + encodeURIComponent(buildReleaseTag(version))
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 发布页正文提取:markdown-body 容器为锚点(GitHub 官方 test-selector,实测恰一处),
|
|
341
|
+
// div 令牌深度配对取完整内层,渲染后 HTML 映射回行结构文本(标题→# 前缀、列表→- 前缀、
|
|
342
|
+
// 块级闭合→换行、实体解码);标签剥离先于实体解码,解码产物只能是文本节点,经 React 转义无注入面
|
|
343
|
+
const RELEASE_TIME_PATTERN = /<relative-time[^>]*\bdatetime="([^"]+)"/
|
|
344
|
+
|
|
345
|
+
export function extractReleaseNotesFromHtml(html) {
|
|
346
|
+
const text = typeof html === 'string' ? html : ''
|
|
347
|
+
const time = RELEASE_TIME_PATTERN.exec(text)
|
|
348
|
+
const anchor = text.indexOf('markdown-body')
|
|
349
|
+
const openStart = anchor < 0 ? -1 : text.lastIndexOf('<div', anchor)
|
|
350
|
+
if (openStart < 0) throw new Error('release 页面结构无法解析')
|
|
351
|
+
const token = /<\/?div\b[^>]*>/g
|
|
352
|
+
token.lastIndex = openStart
|
|
353
|
+
let depth = 0
|
|
354
|
+
let end = -1
|
|
355
|
+
for (let m; (m = token.exec(text)) !== null;) {
|
|
356
|
+
depth += m[0][1] === '/' ? -1 : 1
|
|
357
|
+
if (depth === 0) {
|
|
358
|
+
end = m.index + m[0].length
|
|
359
|
+
break
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (end < 0) throw new Error('release 页面结构无法解析')
|
|
363
|
+
return { publishedAt: time ? time[1] : null, body: mapReleaseHtmlToText(text.slice(openStart, end)) }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function mapReleaseHtmlToText(inner) {
|
|
367
|
+
return inner
|
|
368
|
+
.replace(/>\s+</g, '><')
|
|
369
|
+
.replace(/<h([1-6])[^>]*>/g, (m, level) => '\n' + '#'.repeat(Number(level)) + ' ')
|
|
370
|
+
.replace(/<\/h[1-6]>/g, '\n')
|
|
371
|
+
.replace(/<li[^>]*>/g, '- ')
|
|
372
|
+
.replace(/<\/(p|li|ul|ol|pre|blockquote|tr|div)>/g, '\n')
|
|
373
|
+
.replace(/<br[^>]*>/g, '\n')
|
|
374
|
+
.replace(/<[^>]*>/g, '')
|
|
375
|
+
.replace(/</g, '<')
|
|
376
|
+
.replace(/>/g, '>')
|
|
377
|
+
.replace(/"/g, '"')
|
|
378
|
+
.replace(/'|'/g, "'")
|
|
379
|
+
.replace(/ /g, ' ')
|
|
380
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (m, hex) => String.fromCodePoint(parseInt(hex, 16)))
|
|
381
|
+
.replace(/&#(\d+);/g, (m, dec) => String.fromCodePoint(Number(dec)))
|
|
382
|
+
.replace(/&/g, '&')
|
|
383
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
384
|
+
.trim()
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// 按版本拉取 releases 页面更新内容;fetchImpl 注入便于单测,错误一律抛出由调用方归一。
|
|
388
|
+
// 显式 User-Agent 与 redirect 拒绝跟随同 fetchDistTags 纪律
|
|
389
|
+
export async function fetchReleaseNotes({ version, fetchImpl = fetch, timeoutMs }) {
|
|
390
|
+
if (!(Number.isFinite(timeoutMs) && timeoutMs > 0)) throw new Error('timeoutMs 必须为正数')
|
|
391
|
+
const url = buildReleaseNotesUrl(version)
|
|
392
|
+
const response = await fetchImpl(url, {
|
|
393
|
+
headers: { accept: 'text/html', 'user-agent': 'dsh-maintain' },
|
|
394
|
+
redirect: 'error',
|
|
395
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
396
|
+
})
|
|
397
|
+
if (!response.ok) {
|
|
398
|
+
throw new Error(response.status === 404
|
|
399
|
+
? '未找到该版本的发布说明(该版本可能未发布 Release)'
|
|
400
|
+
: 'GitHub HTTP ' + response.status)
|
|
401
|
+
}
|
|
402
|
+
const text = await readBodyTextLimited(response, RELEASE_PAGE_MAX_BYTES)
|
|
403
|
+
const { publishedAt, body } = extractReleaseNotesFromHtml(text)
|
|
404
|
+
return { url, publishedAt, body }
|
|
405
|
+
}
|