@gehennawu/dsh-service 0.13.1 → 0.14.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.en.md +3 -1
- package/README.md +3 -1
- package/client.js +348 -146
- package/index.js +168 -11
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -11,7 +11,7 @@ A service-control and operations plugin for self-hosted DSH Web. Provides safe r
|
|
|
11
11
|
### Version and updates
|
|
12
12
|
|
|
13
13
|
- Displays current DSH and plugin versions with links to GitHub Releases
|
|
14
|
-
- Automatically checks npm registry for stable and preview releases,
|
|
14
|
+
- Automatically checks npm registry for stable and preview releases; when a new version exists, the right-side status text (chevron + `New version`) is clickable to expand/collapse, showing the current/latest versions and both dist-tag versions inline, each with npmjs and npmmirror links (npmjs.com is blocked on some networks; npmmirror serves as the mirror entry)
|
|
15
15
|
- One-click plugin upgrade with automatic restart after completion
|
|
16
16
|
|
|
17
17
|
### Safe restart
|
|
@@ -19,6 +19,7 @@ A service-control and operations plugin for self-hosted DSH Web. Provides safe r
|
|
|
19
19
|
- Detects active agents, background jobs, and terminals before restart; lists them and requires explicit confirmation
|
|
20
20
|
- `/restart` command also available in conversations; automatically refuses when active work is detected
|
|
21
21
|
- Automatically probes for the new process after restart and reloads the page; manual reload available after 60 seconds
|
|
22
|
+
- Optional `Restart` entry at the bottom of the settings left navigation, enabled by a switch in the Restart tab (off by default), sharing the exact same confirmation flow as the Restart tab
|
|
22
23
|
|
|
23
24
|
### Health diagnostics
|
|
24
25
|
|
|
@@ -44,6 +45,7 @@ A service-control and operations plugin for self-hosted DSH Web. Provides safe r
|
|
|
44
45
|
### Task notifications
|
|
45
46
|
|
|
46
47
|
- Browser notification when a session finishes its turn, or when your approval, plan review, or answer is needed
|
|
48
|
+
- Clicking a notification focuses the DSH page and closes the popup
|
|
47
49
|
- Three toggle switches: master switch, task completion, and approvals & questions, each controlled independently
|
|
48
50
|
- Bell icon in the conversation input bar toggles the master switch quickly
|
|
49
51
|
- All toggles persist across page reloads
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
### 版本与更新
|
|
12
12
|
|
|
13
13
|
- 显示当前 DSH 和插件版本,版本号链接到 GitHub Releases
|
|
14
|
-
- 自动检查 npm registry
|
|
14
|
+
- 自动检查 npm registry 的正式版和预览版;有新版本时右侧「小三角 + 有新版本」状态文本可点击展开/收起,行内展示当前/最新版本与正式版/预览版双 tag,版本号分别带 npmjs 与 npmmirror 链接(部分网络会被 npmjs.com 拦截,npmmirror 作为镜像入口)
|
|
15
15
|
- 一键升级插件,升级后自动重启
|
|
16
16
|
|
|
17
17
|
### 安全重启
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
- 重启前检测活跃 Agent、后台任务和终端,展示清单并要求显式确认
|
|
20
20
|
- 对话中输入 `/restart` 也可触发,检测到运行中工作时自动拒绝
|
|
21
21
|
- 重启后自动探测新进程并刷新页面,60 秒未恢复时提供手动刷新
|
|
22
|
+
- 可在「重启」标签开启「设置页左列显示入口」开关(默认关闭),开启后在设置页左侧标签列底部显示「重启」快捷入口,与「重启」标签共用同一套确认流程
|
|
22
23
|
|
|
23
24
|
### 健康诊断
|
|
24
25
|
|
|
@@ -44,6 +45,7 @@
|
|
|
44
45
|
### 任务通知
|
|
45
46
|
|
|
46
47
|
- 会话完成一轮任务、或需要你授权/审阅计划/选择答案时发送浏览器通知
|
|
48
|
+
- 点击系统通知弹窗会聚焦到 DSH 页面并关闭该通知
|
|
47
49
|
- 三档开关条:总开关、任务结束通知、授权与提问通知,各自独立控制
|
|
48
50
|
- 对话栏内铃铛图标快速切换总开关
|
|
49
51
|
- 各开关在页面刷新后保持
|
package/client.js
CHANGED
|
@@ -10,6 +10,7 @@ window.__ModuleLoader__.load({
|
|
|
10
10
|
const NS = 'dsh-service'
|
|
11
11
|
const zh = {
|
|
12
12
|
'nav.label': '服务控制',
|
|
13
|
+
'nav.restart': '重启',
|
|
13
14
|
'overlay.label': '服务重启状态',
|
|
14
15
|
'recovery.waiting.title': '服务重启中…',
|
|
15
16
|
'recovery.waiting.body': '正在等待新的 DSH Web 进程启动,已等待 {seconds} 秒。',
|
|
@@ -52,6 +53,7 @@ window.__ModuleLoader__.load({
|
|
|
52
53
|
'health.detail.permissions.warning': '发现 {count} 个文件或目录权限异常',
|
|
53
54
|
'health.detail.generic': '{status}',
|
|
54
55
|
'tabs.overview': '概览',
|
|
56
|
+
'tabs.notify': '通知',
|
|
55
57
|
'tabs.health': '健康诊断',
|
|
56
58
|
'tabs.usage': '模型统计',
|
|
57
59
|
'overview.container': '容器信息',
|
|
@@ -106,6 +108,8 @@ window.__ModuleLoader__.load({
|
|
|
106
108
|
'update.checking': '检查中…',
|
|
107
109
|
'update.current': '已是最新版本',
|
|
108
110
|
'update.available': '有新版本:{version}',
|
|
111
|
+
'update.detailsButton': '查看详情',
|
|
112
|
+
'update.detailsHide': '收起',
|
|
109
113
|
'update.unavailable': '暂时无法检查最新版本',
|
|
110
114
|
'update.unpublished': '尚未发布可检查版本',
|
|
111
115
|
'health.recheck': '重新诊断',
|
|
@@ -113,13 +117,30 @@ window.__ModuleLoader__.load({
|
|
|
113
117
|
'update.details.title': 'DSH 更新可用',
|
|
114
118
|
'update.details.current': '当前版本:{version}',
|
|
115
119
|
'update.details.latest': '最新版本:{version}',
|
|
116
|
-
'update.
|
|
120
|
+
'update.channelStable': '正式版',
|
|
121
|
+
'update.channelPreview': '预览版',
|
|
117
122
|
'update.details.close': '关闭',
|
|
118
123
|
'update.upgrade': '升级插件',
|
|
119
124
|
'update.upgrading': '升级中…',
|
|
120
125
|
'update.upgradeError': '插件升级失败',
|
|
121
126
|
'update.upgradeErrorDetail': '插件升级失败({detail})',
|
|
122
127
|
'update.upgradeSuccess': '升级成功,服务重启中…',
|
|
128
|
+
'update.guardActiveWork': '有活动中的会话或后台任务,请处理完毕后再升级',
|
|
129
|
+
'update.guardLinkInstall': '插件通过 link: 安装(开发模式),不能从 registry 一键升级;请更新源码仓库后重启',
|
|
130
|
+
'update.guardFileInstall': '插件通过 file: 安装,不能从 registry 一键升级',
|
|
131
|
+
'update.guardNoNewer': 'registry 最新版不高于当前版本,已拒绝可能回退的升级',
|
|
132
|
+
'update.guardNoProfile': '没有找到安装了本插件的 profile,无法定位升级目标',
|
|
133
|
+
'update.guardAmbiguous': '多个 profile 都安装了本插件且无法确定当前加载的副本,已中止',
|
|
134
|
+
'update.guardStale': '命令报告成功但安装版本没有变化(可能被 pnpm 安全等待期拦下),保持当前版本不重启',
|
|
135
|
+
'update.guardUnreadable': '命令成功但无法确认安装后的版本,已中止重启',
|
|
136
|
+
'update.failPnpmMissing': '找不到 pnpm,无法执行升级;请先安装 pnpm 再重试',
|
|
137
|
+
'update.failNetwork': '拉取依赖时网络临时失败,请稍后重试',
|
|
138
|
+
'update.failFetchTimeout': '下载超时(网络较慢或安装包较大),请稍后重试',
|
|
139
|
+
'update.failReleaseAge': '新版本被 pnpm 安全等待期拦截,已自动放行重试仍失败',
|
|
140
|
+
'update.failHoist': 'profile 的 node_modules 由不同版本的 pnpm 创建,已重建重试仍失败',
|
|
141
|
+
'update.failAddingToRoot': 'pnpm 拒绝在 workspace 根目录安装(缺少 -w)',
|
|
142
|
+
'update.failNotWorkspace': 'profile 不是 pnpm workspace 却传入了 -w',
|
|
143
|
+
'update.failIgnoredBuilds': '依赖构建脚本被 pnpm 默认拦截,无法完成升级',
|
|
123
144
|
'restart.title': '服务重启',
|
|
124
145
|
'restart.description': '重启 dsh web 进程。运行中的工作会中断,持久化会话可恢复。也可在对话中输入 /restart。',
|
|
125
146
|
'restart.button': '重启 dsh web',
|
|
@@ -130,6 +151,8 @@ window.__ModuleLoader__.load({
|
|
|
130
151
|
'restart.sent': '重启指令已发出。',
|
|
131
152
|
'restart.sentHint': '页面连接即将断开,服务恢复后将自动刷新。',
|
|
132
153
|
'restart.idleHint': '当前没有检测到运行中的工作。确认后将断开连接,等待服务自动重启。',
|
|
154
|
+
'restart.navToggle': '设置页左列显示「重启」入口',
|
|
155
|
+
'restart.navToggleHint': '默认关闭;开启后在设置页左侧标签列底部显示快捷重启入口',
|
|
133
156
|
'activity.agent': 'Agent',
|
|
134
157
|
'activity.job': '后台任务',
|
|
135
158
|
'activity.terminal': '终端',
|
|
@@ -196,6 +219,7 @@ window.__ModuleLoader__.load({
|
|
|
196
219
|
}
|
|
197
220
|
const en = {
|
|
198
221
|
'nav.label': 'Service Control',
|
|
222
|
+
'nav.restart': 'Restart',
|
|
199
223
|
'overlay.label': 'Service restart status',
|
|
200
224
|
'recovery.waiting.title': 'Restarting service…',
|
|
201
225
|
'recovery.waiting.body': 'Waiting for a new DSH Web process. Elapsed: {seconds} seconds.',
|
|
@@ -238,6 +262,7 @@ window.__ModuleLoader__.load({
|
|
|
238
262
|
'health.detail.permissions.warning': 'Found {count} file or directory permission anomalies',
|
|
239
263
|
'health.detail.generic': '{status}',
|
|
240
264
|
'tabs.overview': 'Overview',
|
|
265
|
+
'tabs.notify': 'Notifications',
|
|
241
266
|
'tabs.health': 'Health',
|
|
242
267
|
'tabs.usage': 'Models',
|
|
243
268
|
'overview.container': 'Container info',
|
|
@@ -292,6 +317,8 @@ window.__ModuleLoader__.load({
|
|
|
292
317
|
'update.checking': 'Checking…',
|
|
293
318
|
'update.current': 'Up to date',
|
|
294
319
|
'update.available': 'New version: {version}',
|
|
320
|
+
'update.detailsButton': 'View details',
|
|
321
|
+
'update.detailsHide': 'Collapse',
|
|
295
322
|
'update.unavailable': 'Latest version is temporarily unavailable',
|
|
296
323
|
'update.unpublished': 'No published version is available to check',
|
|
297
324
|
'health.recheck': 'Run again',
|
|
@@ -299,13 +326,30 @@ window.__ModuleLoader__.load({
|
|
|
299
326
|
'update.details.title': 'DSH update available',
|
|
300
327
|
'update.details.current': 'Current version: {version}',
|
|
301
328
|
'update.details.latest': 'Latest version: {version}',
|
|
302
|
-
'update.
|
|
329
|
+
'update.channelStable': 'Stable',
|
|
330
|
+
'update.channelPreview': 'Preview',
|
|
303
331
|
'update.details.close': 'Close',
|
|
304
332
|
'update.upgrade': 'Upgrade plugin',
|
|
305
333
|
'update.upgrading': 'Upgrading…',
|
|
306
334
|
'update.upgradeError': 'Plugin upgrade failed',
|
|
307
335
|
'update.upgradeErrorDetail': 'Plugin upgrade failed ({detail})',
|
|
308
336
|
'update.upgradeSuccess': 'Upgrade successful, restarting…',
|
|
337
|
+
'update.guardActiveWork': 'Active sessions or background tasks are running; resolve them before upgrading',
|
|
338
|
+
'update.guardLinkInstall': 'Installed via link: (development mode) — cannot one-click upgrade from the registry; update the source checkout and restart',
|
|
339
|
+
'update.guardFileInstall': 'Installed via file: — cannot one-click upgrade from the registry',
|
|
340
|
+
'update.guardNoNewer': 'The registry latest is not higher than the current version; refused a possible downgrade',
|
|
341
|
+
'update.guardNoProfile': 'No profile with this plugin installed was found to target the upgrade at',
|
|
342
|
+
'update.guardAmbiguous': 'Several profiles install this plugin and the loaded copy could not be determined; aborted',
|
|
343
|
+
'update.guardStale': 'The command reported success but the installed version did not change (pnpm safety wait likely blocked it); keeping the current version without restarting',
|
|
344
|
+
'update.guardUnreadable': 'The command succeeded but the installed version could not be confirmed; the restart was cancelled',
|
|
345
|
+
'update.failPnpmMissing': 'pnpm was not found, so the upgrade cannot run; install pnpm first and retry',
|
|
346
|
+
'update.failNetwork': 'A transient network failure occurred while fetching dependencies; please retry shortly',
|
|
347
|
+
'update.failFetchTimeout': 'Download timed out (slow network or large package); please retry later',
|
|
348
|
+
'update.failReleaseAge': 'The new release is blocked by pnpm\'s fresh-release safety wait; one automatic bypass retry also failed',
|
|
349
|
+
'update.failHoist': 'This profile\'s node_modules was created by a different pnpm major; the rebuild retry also failed',
|
|
350
|
+
'update.failAddingToRoot': 'pnpm refused to add at the workspace root (missing -w)',
|
|
351
|
+
'update.failNotWorkspace': '-w was passed but the profile is not a pnpm workspace',
|
|
352
|
+
'update.failIgnoredBuilds': 'Dependency build scripts are blocked by pnpm by default, so the upgrade could not finish',
|
|
309
353
|
'restart.title': 'Service restart',
|
|
310
354
|
'restart.description': 'Restart the dsh web process. Active work will be interrupted; persisted sessions can be resumed. You can also type /restart in a conversation.',
|
|
311
355
|
'restart.button': 'Restart dsh web',
|
|
@@ -316,6 +360,8 @@ window.__ModuleLoader__.load({
|
|
|
316
360
|
'restart.sent': 'Restart request sent.',
|
|
317
361
|
'restart.sentHint': 'The connection will close shortly. The page will reload automatically after recovery.',
|
|
318
362
|
'restart.idleHint': 'No active work was detected. Confirm to disconnect and wait for the service to restart.',
|
|
363
|
+
'restart.navToggle': 'Show "Restart" entry in settings left nav',
|
|
364
|
+
'restart.navToggleHint': 'Off by default; when enabled, a quick-restart entry appears at the bottom of the settings left navigation',
|
|
319
365
|
'activity.agent': 'Agent',
|
|
320
366
|
'activity.job': 'Background job',
|
|
321
367
|
'activity.terminal': 'Terminal',
|
|
@@ -424,6 +470,36 @@ window.__ModuleLoader__.load({
|
|
|
424
470
|
}, [])
|
|
425
471
|
return { enabled, done, input, setEnabled: (v) => setNotifyEnabled(v), setDone: (v) => setNotifyDone(v), setInput: (v) => setNotifyInput(v) }
|
|
426
472
|
}
|
|
473
|
+
// 设置页左列「重启」入口显示开关:默认关闭,localStorage 持久化;
|
|
474
|
+
// 开启时才注册 settings.section 条目(导航列单元格由外壳渲染,开关不生效就不能用 null 内容占位)。
|
|
475
|
+
let restartNavEnabled = false
|
|
476
|
+
try { restartNavEnabled = localStorage.getItem('dsh-service-restart-nav') === 'true' } catch (_) {}
|
|
477
|
+
let restartNavDispose = null
|
|
478
|
+
const syncRestartNavEntry = () => {
|
|
479
|
+
if (restartNavDispose) { restartNavDispose(); restartNavDispose = null }
|
|
480
|
+
if (!restartNavEnabled) return
|
|
481
|
+
restartNavDispose = ctx.slots.register(
|
|
482
|
+
{ name: 'settings.section', id: 'dsh-service-restart', order: 499, label: () => t('nav.restart') },
|
|
483
|
+
() => React.createElement(RestartSection, null),
|
|
484
|
+
)
|
|
485
|
+
}
|
|
486
|
+
const restartNavListeners = new Set()
|
|
487
|
+
const setRestartNavEnabled = (value) => {
|
|
488
|
+
restartNavEnabled = value === true
|
|
489
|
+
try { localStorage.setItem('dsh-service-restart-nav', restartNavEnabled ? 'true' : 'false') } catch (_) {}
|
|
490
|
+
syncRestartNavEntry()
|
|
491
|
+
for (const listener of restartNavListeners) listener()
|
|
492
|
+
}
|
|
493
|
+
const useRestartNavEnabled = () => {
|
|
494
|
+
const [enabled, setEnabled] = useState(restartNavEnabled)
|
|
495
|
+
useEffect(() => {
|
|
496
|
+
const update = () => setEnabled(restartNavEnabled)
|
|
497
|
+
restartNavListeners.add(update)
|
|
498
|
+
setEnabled(restartNavEnabled)
|
|
499
|
+
return () => restartNavListeners.delete(update)
|
|
500
|
+
}, [])
|
|
501
|
+
return [enabled, setRestartNavEnabled]
|
|
502
|
+
}
|
|
427
503
|
// 会话边沿通知:running→idle 记一次任务结束;pendingInteraction 出现记一次需要确认。
|
|
428
504
|
// 数据源是客户端运行时的会话列表快照(订阅推送);首个快照只建立基线,重连后重建基线,二者都不响铃。
|
|
429
505
|
const NOTIFY_KIND_KEYS = {
|
|
@@ -434,7 +510,15 @@ window.__ModuleLoader__.load({
|
|
|
434
510
|
const notifyPermissionGranted = () => typeof Notification !== 'undefined' && Notification.permission === 'granted'
|
|
435
511
|
const fireNotification = (title, body) => {
|
|
436
512
|
if (!notifyPermissionGranted()) return
|
|
437
|
-
try {
|
|
513
|
+
try {
|
|
514
|
+
const notification = new Notification(title, { body })
|
|
515
|
+
// 点击系统通知弹窗:聚焦 DSH 页面并关闭该通知(chrome/ff 从通知点击回调
|
|
516
|
+
// 视为用户手势,window.focus() 可把标签页带到前台)
|
|
517
|
+
notification.onclick = () => {
|
|
518
|
+
try { window.focus() } catch (_) {}
|
|
519
|
+
try { notification.close() } catch (_) {}
|
|
520
|
+
}
|
|
521
|
+
} catch (_) {}
|
|
438
522
|
}
|
|
439
523
|
if (ctx.sessions && typeof ctx.sessions.list?.subscribe === 'function') {
|
|
440
524
|
const observed = new Map()
|
|
@@ -508,6 +592,60 @@ window.__ModuleLoader__.load({
|
|
|
508
592
|
return snapshot
|
|
509
593
|
}
|
|
510
594
|
|
|
595
|
+
// 重启流程共享状态:设置面板「重启」标签与专属设置页共用同一份 stage/activity/busy/error,
|
|
596
|
+
// 触发路径(两段式确认、强制重启、恢复浮层)完全一致。
|
|
597
|
+
const restartFlowListeners = new Set()
|
|
598
|
+
let restartFlow = { stage: 0, activity: null, busy: false, error: null }
|
|
599
|
+
const setRestartFlow = (next) => {
|
|
600
|
+
restartFlow = next
|
|
601
|
+
for (const listener of restartFlowListeners) listener(next)
|
|
602
|
+
}
|
|
603
|
+
const useRestartFlow = () => {
|
|
604
|
+
const [snapshot, setSnapshot] = useState(restartFlow)
|
|
605
|
+
useEffect(() => {
|
|
606
|
+
restartFlowListeners.add(setSnapshot)
|
|
607
|
+
setSnapshot(restartFlow)
|
|
608
|
+
return () => restartFlowListeners.delete(setSnapshot)
|
|
609
|
+
}, [])
|
|
610
|
+
return snapshot
|
|
611
|
+
}
|
|
612
|
+
const checkRestart = async () => {
|
|
613
|
+
setRestartFlow({ ...restartFlow, busy: true, error: null })
|
|
614
|
+
try {
|
|
615
|
+
const res = await ctx.connection.rpc.call('/dsh-service', 'activity', {})
|
|
616
|
+
if (res && res.ok === false) {
|
|
617
|
+
console.error('dsh-service: activity check failed', res.error)
|
|
618
|
+
throw new Error(t('error.activity'))
|
|
619
|
+
}
|
|
620
|
+
const nextActivity = res && res.value ? res.value : { hasActive: false, items: [] }
|
|
621
|
+
setRestartFlow({ ...restartFlow, activity: nextActivity, stage: nextActivity.hasActive ? 3 : 1, busy: false, error: null })
|
|
622
|
+
} catch (err) {
|
|
623
|
+
setRestartFlow({ ...restartFlow, error: err && err.message ? String(err.message) : String(err), stage: 0, busy: false })
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
const restartWeb = async (force) => {
|
|
627
|
+
setRestartFlow({ ...restartFlow, busy: true, error: null })
|
|
628
|
+
try {
|
|
629
|
+
const res = await ctx.connection.rpc.call('/dsh-service', 'web', { force: force === true })
|
|
630
|
+
if (res && res.ok === false) {
|
|
631
|
+
if (res.error === 'active-work' && res.value) {
|
|
632
|
+
setRestartFlow({ ...restartFlow, activity: res.value, stage: 3, busy: false, error: null })
|
|
633
|
+
return
|
|
634
|
+
}
|
|
635
|
+
console.error('dsh-service: restart failed', res.error)
|
|
636
|
+
throw new Error(t('error.restart'))
|
|
637
|
+
}
|
|
638
|
+
const previousInstanceId = res && res.value ? res.value.instanceId : undefined
|
|
639
|
+
if (typeof previousInstanceId !== 'string' || previousInstanceId.length === 0) {
|
|
640
|
+
throw new Error(t('error.instance'))
|
|
641
|
+
}
|
|
642
|
+
setRestartFlow({ ...restartFlow, stage: 2, busy: false, error: null })
|
|
643
|
+
startRecovery(previousInstanceId).catch((err) => console.error('dsh-service: recovery failed', err))
|
|
644
|
+
} catch (err) {
|
|
645
|
+
setRestartFlow({ ...restartFlow, error: err && err.message ? String(err.message) : String(err), stage: 0, busy: false })
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
511
649
|
const useRecoveryState = () => {
|
|
512
650
|
const [snapshot, setSnapshot] = useState(recoveryState)
|
|
513
651
|
useEffect(() => {
|
|
@@ -555,6 +693,7 @@ window.__ModuleLoader__.load({
|
|
|
555
693
|
ctx.effect(() => () => {
|
|
556
694
|
recoveryGeneration += 1
|
|
557
695
|
recoveryListeners.clear()
|
|
696
|
+
restartFlowListeners.clear()
|
|
558
697
|
updateListeners.clear()
|
|
559
698
|
}, 'dsh-service recovery')
|
|
560
699
|
|
|
@@ -569,6 +708,27 @@ window.__ModuleLoader__.load({
|
|
|
569
708
|
}, translate('update.badge'))
|
|
570
709
|
}
|
|
571
710
|
|
|
711
|
+
// 正式/预览通道行:版本号后跟 npmjs(版本页)与 npmmirror(镜像版本页)两个文字链接。
|
|
712
|
+
// 版本串嵌进 URL 前过安全字符集校验,不过校验的标签降级为纯文本。供更新详情浮层使用。
|
|
713
|
+
const NPM_DSH_PACKAGE = '@deepseek-ai/dsh'
|
|
714
|
+
const packageVersionHref = (base, version) => {
|
|
715
|
+
if (typeof version !== 'string' || version.length === 0 || !/^[0-9A-Za-z.+_-]+$/.test(version)) return null
|
|
716
|
+
return `${base}${version}`
|
|
717
|
+
}
|
|
718
|
+
const siteLabelLink = (kind, label, href) => {
|
|
719
|
+
const testid = `version-dsh-channel-${kind}-${label}`
|
|
720
|
+
if (!href) return React.createElement('span', { 'data-testid': testid, style: { marginLeft: '10px' } }, label)
|
|
721
|
+
return React.createElement('a', { 'data-testid': testid, href, target: '_blank', rel: 'noreferrer', style: { color: 'var(--dsw-alias-brand-primary)', textDecoration: 'underline', marginLeft: '10px' } }, label)
|
|
722
|
+
}
|
|
723
|
+
const channelLine = (translate, kind, version) => React.createElement('div', { style: { whiteSpace: 'nowrap', lineHeight: 1.7 } },
|
|
724
|
+
kind === 'latest' ? translate('update.channelStable') : translate('update.channelPreview'),
|
|
725
|
+
' ', React.createElement('span', { 'data-testid': `version-dsh-channel-${kind}`, style: { marginLeft: '4px' } }, version || '—'),
|
|
726
|
+
siteLabelLink(kind, 'npmjs', packageVersionHref(`https://www.npmjs.com/package/${NPM_DSH_PACKAGE}/v/`, version)),
|
|
727
|
+
siteLabelLink(kind, 'npmmirror', packageVersionHref(`https://www.npmmirror.com/package/${NPM_DSH_PACKAGE}/home?version=`, version)))
|
|
728
|
+
const channelLines = (translate, tags) => React.createElement('div', { style: { margin: '4px 0', fontSize: '12px', lineHeight: 1.7, color: 'var(--dsw-alias-label-secondary)', display: 'flex', flexDirection: 'column', gap: '6px' } },
|
|
729
|
+
channelLine(translate, 'latest', tags && tags.latest),
|
|
730
|
+
channelLine(translate, 'next', tags && tags.next))
|
|
731
|
+
|
|
572
732
|
function ServiceOverlay() {
|
|
573
733
|
const recovery = useRecoveryState()
|
|
574
734
|
const updateState = useUpdateState()
|
|
@@ -585,7 +745,7 @@ window.__ModuleLoader__.load({
|
|
|
585
745
|
React.createElement('div', { style: { fontSize: '18px', fontWeight: 700, marginBottom: '10px' } }, translate('update.details.title')),
|
|
586
746
|
React.createElement('p', { style: { margin: '4px 0', fontSize: '13px' } }, translate('update.details.current', { version: update.current })),
|
|
587
747
|
React.createElement('p', { style: { margin: '4px 0', fontSize: '13px' } }, translate('update.details.latest', { version: update.latest })),
|
|
588
|
-
|
|
748
|
+
channelLines(translate, update.tags),
|
|
589
749
|
React.createElement('button', { style: { marginTop: '16px', padding: '7px 16px', borderRadius: '6px', border: 0, background: 'var(--dsw-alias-brand-primary)', color: 'var(--dsw-alias-button-contrast-fill)', cursor: 'pointer' }, onClick: () => setUpdateDetailsOpen(false) }, translate('update.details.close'))))
|
|
590
750
|
}
|
|
591
751
|
|
|
@@ -634,6 +794,89 @@ window.__ModuleLoader__.load({
|
|
|
634
794
|
return React.createElement(ServiceOverlay, null)
|
|
635
795
|
}
|
|
636
796
|
|
|
797
|
+
// 重启区块:设置面板「重启」标签与设置页左列底部的专属入口共用,状态取自共享重启流。
|
|
798
|
+
// showNavToggle 仅「重启」标签传入:控制设置页左列入口是否显示(默认关闭)。
|
|
799
|
+
function RestartSection({ showNavToggle }) {
|
|
800
|
+
const translate = useTranslation()
|
|
801
|
+
const flow = useRestartFlow()
|
|
802
|
+
const [navEnabled, setNavEnabled] = useRestartNavEnabled()
|
|
803
|
+
const btn = { minHeight: '32px', padding: '6px 14px', borderRadius: '7px', border: '1px solid transparent', cursor: 'pointer', fontSize: '13px', fontWeight: 550, transition: 'border-color 120ms ease, color 120ms ease, background 120ms ease', lineHeight: '20px' }
|
|
804
|
+
const danger = { ...btn, background: 'var(--dsw-alias-state-error-primary)', color: '#fff', borderColor: 'var(--dsw-alias-state-error-primary)' }
|
|
805
|
+
const ghost = { ...btn, background: 'transparent', color: 'var(--dsw-alias-label-primary)', borderColor: 'var(--dsw-alias-border-l2)' }
|
|
806
|
+
const row = { display: 'flex', alignItems: 'center', gap: '8px', marginTop: '12px', flexWrap: 'wrap' }
|
|
807
|
+
const hint = { color: 'var(--dsw-alias-label-secondary)', fontSize: '12px', marginTop: '8px', lineHeight: 1.5 }
|
|
808
|
+
const card = { padding: '4px 0 14px', marginBottom: '12px', color: 'var(--dsw-alias-label-primary)' }
|
|
809
|
+
const displaySurface = { background: 'var(--dsh-svc-surface-bg)', color: 'var(--dsw-alias-label-primary)', border: '1px solid var(--dsw-alias-border-l1)', borderRadius: '8px', padding: '10px' }
|
|
810
|
+
const sectionTitle = { fontSize: '14px', fontWeight: 700, margin: '0 0 8px', color: 'var(--dsw-alias-label-primary)' }
|
|
811
|
+
|
|
812
|
+
// 重启后提示
|
|
813
|
+
if (flow.stage === 2) {
|
|
814
|
+
return React.createElement('div', { 'data-testid': 'restart-card', style: card },
|
|
815
|
+
React.createElement('div', { style: sectionTitle }, translate('restart.title')),
|
|
816
|
+
React.createElement('p', { style: { margin: 0, fontSize: '13px' } }, translate('restart.sent')),
|
|
817
|
+
React.createElement('p', { style: hint }, translate('restart.sentHint')))
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const activityLabels = {
|
|
821
|
+
agent: translate('activity.agent'),
|
|
822
|
+
job: translate('activity.job'),
|
|
823
|
+
terminal: translate('activity.terminal'),
|
|
824
|
+
}
|
|
825
|
+
const activityItems = flow.activity && Array.isArray(flow.activity.items) ? flow.activity.items : []
|
|
826
|
+
const activityWarning = flow.stage === 3
|
|
827
|
+
? React.createElement('div', { style: { marginTop: '12px', padding: '10px 12px', borderRadius: '6px', background: 'rgba(211,51,51,0.1)', border: '1px solid rgba(211,51,51,0.35)' } },
|
|
828
|
+
React.createElement('p', { style: { margin: '0 0 8px', color: 'var(--dsw-alias-state-error-primary)', fontSize: '13px', fontWeight: 600 } },
|
|
829
|
+
translate('activity.warning', { count: activityItems.length })),
|
|
830
|
+
React.createElement('ul', { style: { margin: 0, paddingLeft: '20px', fontSize: '12px', lineHeight: 1.7 } },
|
|
831
|
+
activityItems.map((item) => React.createElement('li', { key: item.type + ':' + item.id },
|
|
832
|
+
translate('activity.item', {
|
|
833
|
+
type: activityLabels[item.type] || item.type,
|
|
834
|
+
label: item.label,
|
|
835
|
+
status: item.status,
|
|
836
|
+
}))))
|
|
837
|
+
)
|
|
838
|
+
: null
|
|
839
|
+
|
|
840
|
+
// 重启按钮区块
|
|
841
|
+
return React.createElement('div', { 'data-testid': 'restart-card', style: card },
|
|
842
|
+
React.createElement('div', { style: sectionTitle }, translate('restart.title')),
|
|
843
|
+
React.createElement('div', { 'data-testid': 'restart-region', style: displaySurface },
|
|
844
|
+
React.createElement('p', { style: { margin: 0, fontSize: '13px' } }, translate('restart.description')),
|
|
845
|
+
activityWarning,
|
|
846
|
+
React.createElement('div', { style: row },
|
|
847
|
+
flow.stage === 0
|
|
848
|
+
? React.createElement('button', { style: danger, 'data-variant': 'danger', onClick: checkRestart, disabled: flow.busy }, translate(flow.busy ? 'update.checking' : 'restart.button'))
|
|
849
|
+
: flow.stage === 1
|
|
850
|
+
? [
|
|
851
|
+
React.createElement('button', { key: 'confirm', style: danger, onClick: () => restartWeb(false), disabled: flow.busy }, translate(flow.busy ? 'restart.sending' : 'restart.confirm')),
|
|
852
|
+
React.createElement('button', { key: 'cancel', style: ghost, onClick: () => setRestartFlow({ ...restartFlow, activity: null, stage: 0, busy: false, error: null }), disabled: flow.busy }, translate('restart.cancel')),
|
|
853
|
+
]
|
|
854
|
+
: flow.stage === 3
|
|
855
|
+
? [
|
|
856
|
+
React.createElement('button', { key: 'force', style: danger, onClick: () => restartWeb(true), disabled: flow.busy }, translate(flow.busy ? 'restart.sending' : 'restart.force')),
|
|
857
|
+
React.createElement('button', { key: 'cancel', style: ghost, onClick: () => setRestartFlow({ ...restartFlow, activity: null, stage: 0, busy: false, error: null }), disabled: flow.busy }, translate('restart.cancel')),
|
|
858
|
+
]
|
|
859
|
+
: null
|
|
860
|
+
),
|
|
861
|
+
flow.stage === 1 ? React.createElement('p', { style: hint }, translate('restart.idleHint')) : null,
|
|
862
|
+
flow.error ? React.createElement('p', { style: Object.assign({}, hint, { color: 'var(--dsw-alias-state-error-primary)' }) }, String(flow.error)) : null,
|
|
863
|
+
showNavToggle
|
|
864
|
+
? React.createElement('div', { style: { marginTop: '12px', paddingTop: '10px', borderTop: '1px solid var(--dsw-alias-border-l1)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px' } },
|
|
865
|
+
React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px' } },
|
|
866
|
+
React.createElement('span', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-primary)' } }, translate('restart.navToggle')),
|
|
867
|
+
React.createElement('span', { style: hint }, translate('restart.navToggleHint'))),
|
|
868
|
+
React.createElement('button', {
|
|
869
|
+
type: 'button',
|
|
870
|
+
role: 'switch',
|
|
871
|
+
'data-testid': 'restart-nav-switch',
|
|
872
|
+
'aria-checked': String(navEnabled),
|
|
873
|
+
onClick: () => setNavEnabled(!navEnabled),
|
|
874
|
+
style: { width: '34px', height: '20px', borderRadius: '10px', padding: 0, flexShrink: 0, position: 'relative', border: `1px solid ${navEnabled ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-border-l2)'}`, background: navEnabled ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-bg-layer-2)', cursor: 'pointer', lineHeight: 0 },
|
|
875
|
+
}, React.createElement('span', { style: { position: 'absolute', top: '1px', left: navEnabled ? '15px' : '1px', width: '16px', height: '16px', borderRadius: '50%', background: navEnabled ? '#fff' : 'var(--dsw-alias-label-tertiary)' } })))
|
|
876
|
+
: null)
|
|
877
|
+
)
|
|
878
|
+
}
|
|
879
|
+
|
|
637
880
|
function ServicePanel() {
|
|
638
881
|
const translate = useTranslation()
|
|
639
882
|
const [health, setHealth] = useState(null)
|
|
@@ -671,11 +914,10 @@ window.__ModuleLoader__.load({
|
|
|
671
914
|
const [toolErrorsOpen, setToolErrorsOpen] = useState(false)
|
|
672
915
|
const [modelsOpen, setModelsOpen] = useState(false)
|
|
673
916
|
const [activeTab, setActiveTab] = useState('overview')
|
|
674
|
-
//
|
|
675
|
-
const [
|
|
676
|
-
|
|
677
|
-
const
|
|
678
|
-
const [error, setError] = useState(null)
|
|
917
|
+
// 版本详情行内展开(不用浮层:弹层会被设置模态盖住)
|
|
918
|
+
const [channelOpen, setChannelOpen] = useState(false)
|
|
919
|
+
// 重启流程状态来自共享流(与设置页左列底部的专属入口同源)
|
|
920
|
+
const restartFlowState = useRestartFlow()
|
|
679
921
|
const usageRequestPayload = { timezoneOffsetMinutes: new Date().getTimezoneOffset() }
|
|
680
922
|
|
|
681
923
|
// 进入面板时拉取当前版本和健康快照;健康数据每 5 秒刷新,卸载即停止。
|
|
@@ -913,6 +1155,25 @@ window.__ModuleLoader__.load({
|
|
|
913
1155
|
reader.readAsArrayBuffer(file)
|
|
914
1156
|
}
|
|
915
1157
|
|
|
1158
|
+
// 宿主返回的稳定失败码 → 词典文案;未知错误详情(如 dsh-failed: …)透出原文。
|
|
1159
|
+
const UPGRADE_FAILURES = {
|
|
1160
|
+
'active-work': 'update.guardActiveWork',
|
|
1161
|
+
'link-install': 'update.guardLinkInstall',
|
|
1162
|
+
'file-install': 'update.guardFileInstall',
|
|
1163
|
+
'no-newer-version': 'update.guardNoNewer',
|
|
1164
|
+
'no-profile-found': 'update.guardNoProfile',
|
|
1165
|
+
'ambiguous-profile': 'update.guardAmbiguous',
|
|
1166
|
+
'upgrade-stale': 'update.guardStale',
|
|
1167
|
+
'installed-version-unreadable': 'update.guardUnreadable',
|
|
1168
|
+
'pnpm-missing': 'update.failPnpmMissing',
|
|
1169
|
+
'transient-network': 'update.failNetwork',
|
|
1170
|
+
'fetch-timeout': 'update.failFetchTimeout',
|
|
1171
|
+
'release-age-violation': 'update.failReleaseAge',
|
|
1172
|
+
'hoist-pattern-diff': 'update.failHoist',
|
|
1173
|
+
'adding-to-root': 'update.failAddingToRoot',
|
|
1174
|
+
'not-a-workspace': 'update.failNotWorkspace',
|
|
1175
|
+
'ignored-builds': 'update.failIgnoredBuilds',
|
|
1176
|
+
}
|
|
916
1177
|
const upgradePlugin = async () => {
|
|
917
1178
|
setUpgradeBusy(true)
|
|
918
1179
|
setUpgradeError(null)
|
|
@@ -920,10 +1181,16 @@ window.__ModuleLoader__.load({
|
|
|
920
1181
|
const versionRes = await ctx.connection.rpc.call('/dsh-service', 'version', {})
|
|
921
1182
|
const previousInstanceId = versionRes && versionRes.ok ? versionRes.value.instanceId : undefined
|
|
922
1183
|
const res = await ctx.connection.rpc.call('/dsh-service', 'upgrade', {})
|
|
923
|
-
// 宿主错误详情(如 npm-failed: …、npm was not found on PATH)随通用文案透出,便于排查。
|
|
924
1184
|
if (!res || res.ok === false) {
|
|
925
|
-
const
|
|
926
|
-
|
|
1185
|
+
const code = res && typeof res.error === 'string' ? res.error.trim() : ''
|
|
1186
|
+
const mapped = typeof code === 'string' && code.length > 0 ? UPGRADE_FAILURES[code] : undefined
|
|
1187
|
+
// 已知失败码走双语词典;其余(如 npm-failed: …、dsh-failed: …)随通用文案透出宿主错误详情。
|
|
1188
|
+
if (mapped !== undefined) {
|
|
1189
|
+
setUpgradeError(translate(mapped))
|
|
1190
|
+
} else {
|
|
1191
|
+
setUpgradeError(translate('update.upgradeErrorDetail', { detail: code || 'upgrade failed' }))
|
|
1192
|
+
}
|
|
1193
|
+
return
|
|
927
1194
|
}
|
|
928
1195
|
if (typeof previousInstanceId === 'string' && previousInstanceId.length > 0) {
|
|
929
1196
|
startRecovery(previousInstanceId).catch(() => {})
|
|
@@ -937,54 +1204,6 @@ window.__ModuleLoader__.load({
|
|
|
937
1204
|
}
|
|
938
1205
|
}
|
|
939
1206
|
|
|
940
|
-
const checkRestart = async () => {
|
|
941
|
-
setBusy(true)
|
|
942
|
-
setError(null)
|
|
943
|
-
try {
|
|
944
|
-
const res = await ctx.connection.rpc.call('/dsh-service', 'activity', {})
|
|
945
|
-
if (res && res.ok === false) {
|
|
946
|
-
console.error('dsh-service: activity check failed', res.error)
|
|
947
|
-
throw new Error(translate('error.activity'))
|
|
948
|
-
}
|
|
949
|
-
const nextActivity = res && res.value ? res.value : { hasActive: false, items: [] }
|
|
950
|
-
setActivity(nextActivity)
|
|
951
|
-
setStage(nextActivity.hasActive ? 3 : 1)
|
|
952
|
-
} catch (err) {
|
|
953
|
-
setError(err && err.message ? String(err.message) : String(err))
|
|
954
|
-
setStage(0)
|
|
955
|
-
} finally {
|
|
956
|
-
setBusy(false)
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
const restart = async (force) => {
|
|
961
|
-
setBusy(true)
|
|
962
|
-
setError(null)
|
|
963
|
-
try {
|
|
964
|
-
const res = await ctx.connection.rpc.call('/dsh-service', 'web', { force: force === true })
|
|
965
|
-
if (res && res.ok === false) {
|
|
966
|
-
if (res.error === 'active-work' && res.value) {
|
|
967
|
-
setActivity(res.value)
|
|
968
|
-
setStage(3)
|
|
969
|
-
return
|
|
970
|
-
}
|
|
971
|
-
console.error('dsh-service: restart failed', res.error)
|
|
972
|
-
throw new Error(translate('error.restart'))
|
|
973
|
-
}
|
|
974
|
-
setStage(2)
|
|
975
|
-
const previousInstanceId = res && res.value ? res.value.instanceId : undefined
|
|
976
|
-
if (typeof previousInstanceId !== 'string' || previousInstanceId.length === 0) {
|
|
977
|
-
throw new Error(translate('error.instance'))
|
|
978
|
-
}
|
|
979
|
-
startRecovery(previousInstanceId).catch((err) => console.error('dsh-service: recovery failed', err))
|
|
980
|
-
} catch (err) {
|
|
981
|
-
setError(err && err.message ? String(err.message) : String(err))
|
|
982
|
-
setStage(0)
|
|
983
|
-
} finally {
|
|
984
|
-
setBusy(false)
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
|
|
988
1207
|
// 样式
|
|
989
1208
|
const btn = { minHeight: '32px', padding: '6px 14px', borderRadius: '7px', border: '1px solid transparent', cursor: 'pointer', fontSize: '13px', fontWeight: 550, transition: 'border-color 120ms ease, color 120ms ease, background 120ms ease', lineHeight: '20px' }
|
|
990
1209
|
const primary = { ...btn, background: 'var(--dsw-alias-brand-primary)', color: 'var(--dsw-alias-button-contrast-fill)', borderColor: 'var(--dsw-alias-brand-primary)' }
|
|
@@ -1337,21 +1556,36 @@ window.__ModuleLoader__.load({
|
|
|
1337
1556
|
: null)))
|
|
1338
1557
|
: null))
|
|
1339
1558
|
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1559
|
+
// 正式/预览通道两行信息在版本卡内下拉展开(不弹浮层:弹层会被设置模态盖住)。
|
|
1560
|
+
// 有更新时状态文本本身可点击:小三角 + 「有新版本:…」整体切换展开/收起。
|
|
1561
|
+
const chevronIcon = (open) => React.createElement('svg', { xmlns: 'http://www.w3.org/2000/svg', width: 12, height: 12, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 3, strokeLinecap: 'round', strokeLinejoin: 'round', style: { display: 'block', transition: 'transform 150ms ease', transform: open ? 'rotate(90deg)' : 'none' } },
|
|
1562
|
+
React.createElement('path', { d: 'M9 6l6 6-6 6' }))
|
|
1563
|
+
const versionRow = (id, label, fallbackVersion, state, action, expandable, topBorder) => {
|
|
1564
|
+
const statusText = !state
|
|
1565
|
+
? (updateError || translate('update.checking'))
|
|
1566
|
+
: state.status === 'unpublished' ? translate('update.unpublished')
|
|
1567
|
+
: state.status === 'unavailable' ? translate('update.unavailable')
|
|
1568
|
+
: state.upToDate ? translate('update.current')
|
|
1569
|
+
: translate('update.available', { version: state.latest })
|
|
1570
|
+
const statusColor = !state ? 'var(--dsw-alias-label-secondary)' : state.upToDate ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-warn-primary)'
|
|
1571
|
+
const clickable = expandable && state && state.status !== 'unpublished' && state.status !== 'unavailable' && !state.upToDate
|
|
1572
|
+
const rightSide = clickable
|
|
1573
|
+
? React.createElement('button', { type: 'button', title: translate(channelOpen ? 'update.detailsHide' : 'update.detailsButton'), style: { background: 'transparent', border: 0, padding: 0, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '5px', fontSize: '12px', fontWeight: 600, color: statusColor }, onClick: () => setChannelOpen((value) => !value) },
|
|
1574
|
+
chevronIcon(channelOpen),
|
|
1575
|
+
React.createElement('span', null, statusText))
|
|
1576
|
+
: React.createElement('div', { style: { color: statusColor, fontWeight: 600 } }, statusText)
|
|
1577
|
+
return React.createElement('div', { key: id, style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '16px', padding: '10px 2px', borderTop: topBorder ? '1px solid var(--dsw-alias-border-l1)' : 0 } },
|
|
1578
|
+
React.createElement('div', { style: { whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: '8px' } },
|
|
1579
|
+
React.createElement('span', { style: { fontSize: '13px', fontWeight: 650 } }, `${label} `),
|
|
1580
|
+
state?.url
|
|
1581
|
+
? React.createElement('a', { 'data-testid': `version-${id}-link`, href: state.url, target: '_blank', rel: 'noreferrer', style: { color: 'var(--dsw-alias-label-primary)', textDecoration: 'underline', fontSize: '12px', whiteSpace: 'nowrap', marginLeft: '16px' } }, state.current || fallbackVersion || translate('version.loading'))
|
|
1582
|
+
: React.createElement('code', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-primary)', marginLeft: '16px' } }, state?.current || fallbackVersion || translate('version.loading'))),
|
|
1347
1583
|
action || null,
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
: state.upToDate ? translate('update.current') : translate('update.available', { version: state.latest }))))
|
|
1354
|
-
// 版本信息区块
|
|
1584
|
+
React.createElement('div', { style: { textAlign: 'right', fontSize: '12px' } }, rightSide))
|
|
1585
|
+
}
|
|
1586
|
+
// 版本信息区块:DSH 行在有更新时状态文本(小三角 + 有新版本)整体可点击,行内下拉展开
|
|
1587
|
+
const dshUpdate = updateInfo?.dsh
|
|
1588
|
+
const dshExpandable = dshUpdate && dshUpdate.status !== 'unpublished' && dshUpdate.status !== 'unavailable' && !dshUpdate.upToDate
|
|
1355
1589
|
const pluginUpdate = updateInfo?.plugin && !updateInfo.plugin.upToDate && updateInfo.plugin.status === 'available'
|
|
1356
1590
|
const pluginAction = pluginUpdate
|
|
1357
1591
|
? React.createElement('button', { style: Object.assign({}, neutral, { minHeight: '24px', padding: '2px 8px', fontSize: '11px' }), disabled: upgradeBusy, onClick: upgradePlugin }, translate(upgradeBusy ? 'update.upgrading' : 'update.upgrade'))
|
|
@@ -1359,67 +1593,18 @@ window.__ModuleLoader__.load({
|
|
|
1359
1593
|
const versionBlock = React.createElement('div', { key: 'version-card', 'data-testid': 'version-card', style: card },
|
|
1360
1594
|
React.createElement('div', { key: 'title', style: sectionTitle }, translate('version.title')),
|
|
1361
1595
|
React.createElement('div', { style: displaySurface },
|
|
1362
|
-
versionRow('
|
|
1363
|
-
versionRow('
|
|
1596
|
+
versionRow('plugin', 'dsh-service', pluginVersion, updateInfo?.plugin, pluginAction, false, false),
|
|
1597
|
+
versionRow('dsh', 'DSH', version, dshUpdate, null, dshExpandable === true, true),
|
|
1598
|
+
channelOpen
|
|
1599
|
+
? React.createElement('div', { 'data-testid': 'version-channel-details', style: { marginTop: '6px', paddingTop: '8px', borderTop: '1px solid var(--dsw-alias-border-l1)', fontSize: '12px', lineHeight: 1.7, color: 'var(--dsw-alias-label-secondary)', display: 'flex', flexDirection: 'column', gap: '6px' } },
|
|
1600
|
+
React.createElement('div', null, translate('update.details.current', { version: dshUpdate?.current || version || '—' })),
|
|
1601
|
+
React.createElement('div', null, translate('update.details.latest', { version: dshUpdate?.latest || '—' })),
|
|
1602
|
+
channelLines(translate, dshUpdate?.tags))
|
|
1603
|
+
: null,
|
|
1364
1604
|
upgradeError ? React.createElement('p', { style: Object.assign({}, hint, { color: 'var(--dsw-alias-state-error-primary)', margin: '4px 0 0' }) }, upgradeError) : null))
|
|
1365
1605
|
|
|
1366
|
-
//
|
|
1367
|
-
|
|
1368
|
-
return React.createElement('div', null,
|
|
1369
|
-
healthBlock,
|
|
1370
|
-
backupBlock,
|
|
1371
|
-
versionBlock,
|
|
1372
|
-
React.createElement('div', { key: 'restart-section' },
|
|
1373
|
-
React.createElement('div', { style: sectionTitle }, translate('restart.title')),
|
|
1374
|
-
React.createElement('p', { style: { margin: 0, fontSize: '13px' } }, translate('restart.sent')),
|
|
1375
|
-
React.createElement('p', { style: hint }, translate('restart.sentHint')))
|
|
1376
|
-
)
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
const activityLabels = {
|
|
1380
|
-
agent: translate('activity.agent'),
|
|
1381
|
-
job: translate('activity.job'),
|
|
1382
|
-
terminal: translate('activity.terminal'),
|
|
1383
|
-
}
|
|
1384
|
-
const activityItems = activity && Array.isArray(activity.items) ? activity.items : []
|
|
1385
|
-
const activityWarning = stage === 3
|
|
1386
|
-
? React.createElement('div', { style: { marginTop: '12px', padding: '10px 12px', borderRadius: '6px', background: 'rgba(211,51,51,0.1)', border: '1px solid rgba(211,51,51,0.35)' } },
|
|
1387
|
-
React.createElement('p', { style: { margin: '0 0 8px', color: 'var(--dsw-alias-state-error-primary)', fontSize: '13px', fontWeight: 600 } },
|
|
1388
|
-
translate('activity.warning', { count: activityItems.length })),
|
|
1389
|
-
React.createElement('ul', { style: { margin: 0, paddingLeft: '20px', fontSize: '12px', lineHeight: 1.7 } },
|
|
1390
|
-
activityItems.map((item) => React.createElement('li', { key: item.type + ':' + item.id },
|
|
1391
|
-
translate('activity.item', {
|
|
1392
|
-
type: activityLabels[item.type] || item.type,
|
|
1393
|
-
label: item.label,
|
|
1394
|
-
status: item.status,
|
|
1395
|
-
}))))
|
|
1396
|
-
)
|
|
1397
|
-
: null
|
|
1398
|
-
|
|
1399
|
-
// 重启按钮区块
|
|
1400
|
-
const restartBlock = React.createElement('div', { key: 'restart-section', 'data-testid': 'restart-card', style: card },
|
|
1401
|
-
React.createElement('div', { style: sectionTitle }, translate('restart.title')),
|
|
1402
|
-
React.createElement('div', { 'data-testid': 'restart-region', style: displaySurface },
|
|
1403
|
-
React.createElement('p', { style: { margin: 0, fontSize: '13px' } }, translate('restart.description')),
|
|
1404
|
-
activityWarning,
|
|
1405
|
-
React.createElement('div', { style: row },
|
|
1406
|
-
stage === 0
|
|
1407
|
-
? React.createElement('button', { style: danger, 'data-variant': 'danger', onClick: checkRestart, disabled: busy }, translate(busy ? 'update.checking' : 'restart.button'))
|
|
1408
|
-
: stage === 1
|
|
1409
|
-
? [
|
|
1410
|
-
React.createElement('button', { key: 'confirm', style: danger, onClick: () => restart(false), disabled: busy }, translate(busy ? 'restart.sending' : 'restart.confirm')),
|
|
1411
|
-
React.createElement('button', { key: 'cancel', style: ghost, onClick: () => { setActivity(null); setStage(0) }, disabled: busy }, translate('restart.cancel')),
|
|
1412
|
-
]
|
|
1413
|
-
: stage === 3
|
|
1414
|
-
? [
|
|
1415
|
-
React.createElement('button', { key: 'force', style: danger, onClick: () => restart(true), disabled: busy }, translate(busy ? 'restart.sending' : 'restart.force')),
|
|
1416
|
-
React.createElement('button', { key: 'cancel', style: ghost, onClick: () => { setActivity(null); setStage(0) }, disabled: busy }, translate('restart.cancel')),
|
|
1417
|
-
]
|
|
1418
|
-
: null
|
|
1419
|
-
),
|
|
1420
|
-
stage === 1 ? React.createElement('p', { style: hint }, translate('restart.idleHint')) : null,
|
|
1421
|
-
error ? React.createElement('p', { style: Object.assign({}, hint, { color: 'var(--dsw-alias-state-error-primary)' }) }, String(error)) : null)
|
|
1422
|
-
)
|
|
1606
|
+
// 重启区块复用共享组件(「重启」标签还承载设置页左列入口的显示开关;左侧入口默认关闭)
|
|
1607
|
+
const restartBlock = React.createElement(RestartSection, { showNavToggle: true })
|
|
1423
1608
|
|
|
1424
1609
|
const { enabled: notifyOn, done: notifyDoneOn, input: notifyInputOn, setEnabled: setNotifyOn, setDone: setNotifyDoneOn, setInput: setNotifyInputOn } = useNotifyState()
|
|
1425
1610
|
const notifSupported = typeof Notification !== 'undefined'
|
|
@@ -1455,18 +1640,22 @@ window.__ModuleLoader__.load({
|
|
|
1455
1640
|
notifyRow(translate('notification.master'), translate('notification.masterHint'), notifyOn, setNotifyOn, false),
|
|
1456
1641
|
notifyRow(translate('notification.done'), translate('notification.doneHint'), notifyDoneOn, setNotifyDoneOn, !notifyOn),
|
|
1457
1642
|
notifyRow(translate('notification.input'), translate('notification.inputHint'), notifyInputOn, setNotifyInputOn, !notifyOn))))
|
|
1458
|
-
const overviewBlock = React.createElement('div', null, versionBlock,
|
|
1643
|
+
const overviewBlock = React.createElement('div', null, versionBlock, containerInfoBlock, overviewErrorsBlock)
|
|
1644
|
+
// 任务通知独立成顶部标签(v0.14 起不再混在概览里)
|
|
1645
|
+
const notifyBlock = React.createElement('div', null, notificationBlock)
|
|
1459
1646
|
const maintenanceBlock = React.createElement('div', { key: 'maintenance-card', 'data-testid': 'maintenance-card', style: card }, backupBlock)
|
|
1460
1647
|
const diagnosticFailure = diagnostics?.checks?.some((check) => check.status === 'error' || (check.status === 'warning' && !(check.id === 'backup-storage' && String(check.detail || '').startsWith('0:')))) === true
|
|
1461
1648
|
const tabWarnings = {
|
|
1462
1649
|
overview: false,
|
|
1650
|
+
notify: false,
|
|
1463
1651
|
health: Boolean(healthError || permissionError || diagnosticFailure || permissionAbnormal > 0),
|
|
1464
1652
|
usage: Boolean(usageError),
|
|
1465
1653
|
backup: Boolean(backupError),
|
|
1466
|
-
restart: Boolean(error),
|
|
1654
|
+
restart: Boolean(restartFlowState.error),
|
|
1467
1655
|
}
|
|
1468
1656
|
const tabs = [
|
|
1469
1657
|
['overview', 'tabs.overview'],
|
|
1658
|
+
['notify', 'tabs.notify'],
|
|
1470
1659
|
['health', 'tabs.health'],
|
|
1471
1660
|
['usage', 'tabs.usage'],
|
|
1472
1661
|
['backup', 'tabs.backup'],
|
|
@@ -1475,13 +1664,15 @@ window.__ModuleLoader__.load({
|
|
|
1475
1664
|
const warningTabs = tabs.filter(([id]) => tabWarnings[id]).map(([, label]) => translate(label))
|
|
1476
1665
|
const tabContent = activeTab === 'overview'
|
|
1477
1666
|
? overviewBlock
|
|
1478
|
-
: activeTab === '
|
|
1479
|
-
?
|
|
1480
|
-
: activeTab === '
|
|
1481
|
-
?
|
|
1482
|
-
: activeTab === '
|
|
1483
|
-
?
|
|
1484
|
-
:
|
|
1667
|
+
: activeTab === 'notify'
|
|
1668
|
+
? notifyBlock
|
|
1669
|
+
: activeTab === 'health'
|
|
1670
|
+
? healthBlock
|
|
1671
|
+
: activeTab === 'usage'
|
|
1672
|
+
? usageBlock
|
|
1673
|
+
: activeTab === 'backup'
|
|
1674
|
+
? maintenanceBlock
|
|
1675
|
+
: restartBlock
|
|
1485
1676
|
return React.createElement('div', null,
|
|
1486
1677
|
warningTabs.length > 0 ? React.createElement('div', { style: { marginBottom: '12px', padding: '11px 13px', borderRadius: '8px', background: 'rgba(198,128,0,0.16)', border: '1px solid rgba(198,128,0,0.48)', boxShadow: '0 2px 8px rgba(0,0,0,0.08)' } },
|
|
1487
1678
|
React.createElement('div', { style: { fontSize: '13px', fontWeight: 700 } }, translate('tabs.alert.title')),
|
|
@@ -1524,10 +1715,21 @@ window.__ModuleLoader__.load({
|
|
|
1524
1715
|
{ name: 'sidebar.footer.action', id: 'dsh-service-update', order: 90, label: () => t('update.badge') },
|
|
1525
1716
|
() => React.createElement(UpdateBadge, null),
|
|
1526
1717
|
))
|
|
1527
|
-
ctx.slots.inject('settings.section', () =>
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1718
|
+
ctx.slots.inject('settings.section', () => {
|
|
1719
|
+
const disposePanel = ctx.slots.register(
|
|
1720
|
+
{ name: 'settings.section', id: 'dsh-service', order: 99, label: () => t('nav.label') },
|
|
1721
|
+
() => React.createElement(ServicePanel, null),
|
|
1722
|
+
)
|
|
1723
|
+
// 左列「重启」入口由「重启」标签内的开关控制,默认不注册
|
|
1724
|
+
syncRestartNavEntry()
|
|
1725
|
+
return () => {
|
|
1726
|
+
disposePanel()
|
|
1727
|
+
if (restartNavDispose) {
|
|
1728
|
+
restartNavDispose()
|
|
1729
|
+
restartNavDispose = null
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
})
|
|
1531
1733
|
ctx.slots.inject('shell.overlay', () => ctx.slots.register(
|
|
1532
1734
|
{ name: 'shell.overlay', id: 'dsh-service-restart', order: 100, label: () => t('overlay.label') },
|
|
1533
1735
|
() => React.createElement(RestartOverlay, null),
|
package/index.js
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
import { createHmac, randomBytes, randomUUID } from 'node:crypto'
|
|
4
4
|
import { Buffer } from 'node:buffer'
|
|
5
5
|
import { constants as fsConstants } from 'node:fs'
|
|
6
|
-
import { access, cp, lstat, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'
|
|
6
|
+
import { access, cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'
|
|
7
7
|
import { homedir } from 'node:os'
|
|
8
|
-
import { basename, join, relative, resolve, sep } from 'node:path'
|
|
8
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
|
9
9
|
import { createRequire } from 'node:module'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
10
11
|
import https from 'node:https'
|
|
11
12
|
|
|
12
13
|
const require = createRequire(import.meta.url)
|
|
@@ -23,6 +24,14 @@ const BACKUP_NAME = /^dsh-backup-\d{8}-\d{6}\.tar\.gz$/
|
|
|
23
24
|
const USAGE_INDEX_VERSION = 4
|
|
24
25
|
const USAGE_INDEX_FILE = 'dsh-service-usage-index.json'
|
|
25
26
|
|
|
27
|
+
// 升级目标白名单:命令与包名全部来自宿主常量,浏览器不传任何输入。
|
|
28
|
+
// TARGET_RE 与 dsh-market 同源:只放行「包名@版本」这一种形状的字符集。
|
|
29
|
+
const TARGET_RE = /^[A-Za-z0-9@:./_#+~^=-]+$/
|
|
30
|
+
const RELEASE_AGE_OVERRIDE = '--config.minimumReleaseAge=0'
|
|
31
|
+
const FETCH_TIMEOUT_OVERRIDE = '--config.fetchTimeout=600000'
|
|
32
|
+
// 当前正在运行的插件源码目录:定位「本插件由哪个 profile 挂载」时与磁盘副本做 realpath 匹配。
|
|
33
|
+
const loadedPluginDir = dirname(fileURLToPath(import.meta.url))
|
|
34
|
+
|
|
26
35
|
// 读取当前 dsh 版本。DSH 包由宿主安装,不作为插件依赖打包进来。
|
|
27
36
|
let dshVersion = 'unknown'
|
|
28
37
|
let pluginVersion = 'unknown'
|
|
@@ -476,6 +485,7 @@ function toolFailureCode(tool, code, message) {
|
|
|
476
485
|
if (/ABORT|CANCEL/i.test(value) || /aborted|cancelled|canceled/i.test(message)) return undefined
|
|
477
486
|
if (value && value !== 'UNKNOWN' && value !== 'Error') return value
|
|
478
487
|
if (/requires reading\b/i.test(message)) return 'FS_NOT_OBSERVED'
|
|
488
|
+
if (/cannot read[^\n]*as an image/i.test(message)) return 'IMAGE_NOT_SUPPORTED'
|
|
479
489
|
if (/old_string was not found/i.test(message)) return 'OLD_STRING_NOT_FOUND'
|
|
480
490
|
if (/no such file or directory|path[^\n]*not found/i.test(message)) return 'PATH_NOT_FOUND'
|
|
481
491
|
if (/file access denied|permission denied|EACCES/i.test(message)) return 'PERMISSION_DENIED'
|
|
@@ -487,6 +497,7 @@ function toolFailureCode(tool, code, message) {
|
|
|
487
497
|
|
|
488
498
|
function toolFailureMessage(tool, code) {
|
|
489
499
|
if (code === 'FS_NOT_OBSERVED') return `${tool} requires reading <path> first — read the file, then retry`
|
|
500
|
+
if (code === 'IMAGE_NOT_SUPPORTED') return `${tool} failed: the current model does not support image input; switch to an image-capable model`
|
|
490
501
|
if (code === 'OLD_STRING_NOT_FOUND') return `${tool}: old_string was not found in <path>`
|
|
491
502
|
if (code === 'PATH_NOT_FOUND') return `${tool} search failed: <path> not found`
|
|
492
503
|
if (code === 'PERMISSION_DENIED') return `${tool} failed: permission denied for <path>`
|
|
@@ -663,11 +674,19 @@ function modeString(mode) {
|
|
|
663
674
|
}
|
|
664
675
|
|
|
665
676
|
async function runFixedCommand(ctx, argv) {
|
|
677
|
+
const result = await runCommandResult(ctx, argv)
|
|
678
|
+
if (result.exitCode !== 0 || result.signal !== null) {
|
|
679
|
+
throw new Error(`${argv[0]}-failed: ${(result.stderr || '').trim() || result.signal || result.exitCode}`)
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// 非抛出的命令执行:返回退出码/信号/stdout/stderr,供升级流程做失败分类与一次性恢复。
|
|
684
|
+
async function runCommandResult(ctx, argv) {
|
|
666
685
|
const subprocess = ctx.get('subprocess')
|
|
667
686
|
if (subprocess === undefined) throw new Error('subprocess-unavailable')
|
|
668
687
|
const executable = await subprocess.resolveExecutable(argv[0])
|
|
669
688
|
let spawnArgv = [executable, ...argv.slice(1)]
|
|
670
|
-
// Windows 上白名单命令(如 npm)经 PATHEXT 解析为 .cmd/.bat 脚本;subprocess 服务的
|
|
689
|
+
// Windows 上白名单命令(如 npm/dsh)经 PATHEXT 解析为 .cmd/.bat 脚本;subprocess 服务的
|
|
671
690
|
// spawn 不带 shell,Node 对 .cmd/.bat 一律抛 EINVAL,无法直接执行。固定包一层
|
|
672
691
|
// cmd.exe /d /s /c + 已解析的绝对路径,全部参数仍是宿主白名单常量,无输入拼接。
|
|
673
692
|
if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(executable)) {
|
|
@@ -680,22 +699,160 @@ async function runFixedCommand(ctx, argv) {
|
|
|
680
699
|
cwd: process.platform === 'win32' ? (process.env.SystemRoot || 'C:\\Windows') : '/',
|
|
681
700
|
stdio: {
|
|
682
701
|
stdin: 'ignore',
|
|
683
|
-
stdout: { maxBytes:
|
|
684
|
-
stderr: { maxBytes:
|
|
702
|
+
stdout: { maxBytes: 64 * 1024 },
|
|
703
|
+
stderr: { maxBytes: 512 * 1024 },
|
|
685
704
|
},
|
|
686
705
|
graceMs: 5000,
|
|
687
706
|
})
|
|
688
707
|
const outcome = await handle.done
|
|
689
708
|
const stderr = handle.collected.stderr?.readFrom(0).text || ''
|
|
690
|
-
|
|
691
|
-
|
|
709
|
+
const stdout = handle.collected.stdout?.readFrom(0).text || ''
|
|
710
|
+
return { exitCode: outcome.exitCode ?? 1, signal: outcome.signal, stdout, stderr }
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// 定位「安装了本插件的 profile」。只读扫描 DSH_HOME/profiles/*/package.json,浏览器不传
|
|
714
|
+
// 任何路径/名字;多候选时优先匹配当前正在运行的插件源码目录(realpath),避免同秒竞态。
|
|
715
|
+
async function resolveUpgradeProfile(dshHome) {
|
|
716
|
+
const profilesDir = join(dshHome, 'profiles')
|
|
717
|
+
let entries
|
|
718
|
+
try {
|
|
719
|
+
entries = await readdir(profilesDir, { withFileTypes: true })
|
|
720
|
+
} catch (_) {
|
|
721
|
+
throw new Error('no-profile-found')
|
|
722
|
+
}
|
|
723
|
+
const candidates = []
|
|
724
|
+
for (const entry of entries) {
|
|
725
|
+
if (!entry.isDirectory() || entry.name === 'node_modules') continue
|
|
726
|
+
const dir = join(profilesDir, entry.name)
|
|
727
|
+
let manifest
|
|
728
|
+
try {
|
|
729
|
+
manifest = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8'))
|
|
730
|
+
} catch (_) {
|
|
731
|
+
continue
|
|
732
|
+
}
|
|
733
|
+
const spec = manifest?.dependencies?.[PLUGIN_PACKAGE]
|
|
734
|
+
if (typeof spec !== 'string') continue
|
|
735
|
+
candidates.push({ name: entry.name, dir, spec })
|
|
736
|
+
}
|
|
737
|
+
if (candidates.length === 0) throw new Error('no-profile-found')
|
|
738
|
+
let loadedReal = null
|
|
739
|
+
try { loadedReal = await realpath(loadedPluginDir) } catch (_) {}
|
|
740
|
+
const matches = []
|
|
741
|
+
if (loadedReal !== null) {
|
|
742
|
+
for (const candidate of candidates) {
|
|
743
|
+
let installedReal = null
|
|
744
|
+
try { installedReal = await realpath(join(candidate.dir, 'node_modules', PLUGIN_PACKAGE)) } catch (_) {}
|
|
745
|
+
if (installedReal === loadedReal) matches.push(candidate)
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
const selected = matches.length === 1 ? matches[0] : (candidates.length === 1 ? candidates[0] : null)
|
|
749
|
+
if (selected === null) throw new Error('ambiguous-profile')
|
|
750
|
+
let workspace = false
|
|
751
|
+
try { workspace = (await stat(join(selected.dir, 'pnpm-workspace.yaml'))).isFile() } catch (_) {}
|
|
752
|
+
return { name: selected.name, dir: selected.dir, spec: selected.spec, workspace }
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
async function readInstalledPluginVersion(profile) {
|
|
756
|
+
try {
|
|
757
|
+
const manifest = JSON.parse(await readFile(join(profile.dir, 'node_modules', PLUGIN_PACKAGE, 'package.json'), 'utf8'))
|
|
758
|
+
const version = typeof manifest?.version === 'string' ? manifest.version : ''
|
|
759
|
+
return version === '' ? null : version
|
|
760
|
+
} catch (_) {
|
|
761
|
+
return null
|
|
692
762
|
}
|
|
693
763
|
}
|
|
694
764
|
|
|
695
|
-
|
|
696
|
-
|
|
765
|
+
// pnpm 失败分类:dsh plugin 转发 pnpm 时只报「pnpm failed in profile directory」,不报原因;
|
|
766
|
+
// 必须按输出特征识别真实失败(踩坑见 KNOWLEDGE.md「pnpm 失败模式识别与自动恢复」)。
|
|
767
|
+
function classifyUpgradeFailure(output) {
|
|
768
|
+
const text = String(output || '')
|
|
769
|
+
if (text.includes('ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF')) {
|
|
770
|
+
return { code: 'hoist-pattern-diff', recoverable: true, message: 'hoist-pattern-diff' }
|
|
771
|
+
}
|
|
772
|
+
if (text.includes('ERR_PNPM_ADDING_TO_ROOT')) {
|
|
773
|
+
return { code: 'adding-to-root', recoverable: false, message: 'adding-to-root' }
|
|
774
|
+
}
|
|
775
|
+
if (/--workspace-root may only be used inside a workspace/i.test(text)) {
|
|
776
|
+
return { code: 'not-a-workspace', recoverable: false, message: 'not-a-workspace' }
|
|
777
|
+
}
|
|
778
|
+
if (text.includes('ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION') || text.includes('ERR_PNPM_NO_MATURE_MATCHING_VERSION')) {
|
|
779
|
+
return { code: 'release-age-violation', recoverable: true, message: 'release-age-violation' }
|
|
780
|
+
}
|
|
781
|
+
if (text.includes('ERR_PNPM_IGNORED_BUILDS')) {
|
|
782
|
+
return { code: 'ignored-builds', recoverable: false, message: 'ignored-builds' }
|
|
783
|
+
}
|
|
784
|
+
if (/ERR_PNPM_FETCH_5\d\d|ERR_PNPM_META_FETCH_FAIL|FetchError|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|socket hang up|network timeout/i.test(text)) {
|
|
785
|
+
return { code: 'transient-network', recoverable: true, message: 'transient-network' }
|
|
786
|
+
}
|
|
787
|
+
if (/operation was aborted due to timeout|TimeoutError|error \(23\)/i.test(text)) {
|
|
788
|
+
return { code: 'fetch-timeout', recoverable: true, message: 'fetch-timeout' }
|
|
789
|
+
}
|
|
790
|
+
if (text.includes('pnpm not found on PATH')) {
|
|
791
|
+
return { code: 'pnpm-missing', recoverable: false, message: 'pnpm-missing' }
|
|
792
|
+
}
|
|
793
|
+
return null
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// 一键升级:走「dsh plugin --profile <p> add <pkg>@<版本>」,落在 DSH 真正读取的 profile
|
|
797
|
+
// node_modules,替代旧的无升级语义的 npm install -g。命令、包名、版本全部宿主白名单常量。
|
|
798
|
+
// 安全教义守卫:活动工作拒用、link/file 拒绝、latest 不高于当前版本拒绝(防降级)一律先于网络。
|
|
799
|
+
async function upgradePlugin(ctx, dshHome) {
|
|
800
|
+
const activity = collectActiveWork(ctx)
|
|
801
|
+
if (activity.hasActive) throw new Error('active-work')
|
|
802
|
+
|
|
803
|
+
const profile = await resolveUpgradeProfile(dshHome)
|
|
804
|
+
if (profile.spec.startsWith('link:')) throw new Error('link-install')
|
|
805
|
+
if (profile.spec.startsWith('file:')) throw new Error('file-install')
|
|
806
|
+
|
|
807
|
+
const published = await fetchPublishedVersions(PLUGIN_PACKAGE)
|
|
808
|
+
const targetVersion = published.latest
|
|
809
|
+
if (parseSemver(targetVersion) !== null && parseSemver(pluginVersion) !== null && compareSemver(targetVersion, pluginVersion) <= 0) {
|
|
810
|
+
throw new Error('no-newer-version')
|
|
811
|
+
}
|
|
812
|
+
const target = `${PLUGIN_PACKAGE}@${targetVersion}`
|
|
813
|
+
if (!TARGET_RE.test(PLUGIN_PACKAGE) || !TARGET_RE.test(target) || !/^[A-Za-z0-9][A-Za-z0-9._+\-]*$/.test(targetVersion)) {
|
|
814
|
+
throw new Error('invalid-upgrade-target')
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const addArgs = profile.workspace ? ['add', '-w'] : ['add']
|
|
818
|
+
const dshArgs = ['dsh', 'plugin', '--profile', profile.name]
|
|
819
|
+
const run = (extra) => runCommandResult(ctx, [...dshArgs, ...extra])
|
|
820
|
+
const ok = (result) => result.exitCode === 0 && result.signal === null
|
|
821
|
+
|
|
822
|
+
// pnpm 中断(signal 非 null)表示进程被终止,不做自动恢复。
|
|
823
|
+
let result = await run([...addArgs, target])
|
|
824
|
+
if (!ok(result) && result.signal === null) {
|
|
825
|
+
const failure = classifyUpgradeFailure(`${result.stderr}\n${result.stdout}`)
|
|
826
|
+
if (failure !== null) {
|
|
827
|
+
if (failure.code === 'hoist-pattern-diff') {
|
|
828
|
+
const rebuild = await run(['install', '--no-frozen-lockfile'])
|
|
829
|
+
if (ok(rebuild)) result = await run([...addArgs, target])
|
|
830
|
+
} else if (failure.code === 'release-age-violation' || failure.code === 'fetch-timeout') {
|
|
831
|
+
const override = failure.code === 'release-age-violation' ? RELEASE_AGE_OVERRIDE : FETCH_TIMEOUT_OVERRIDE
|
|
832
|
+
if (!addArgs.includes(override)) result = await run([addArgs[0], override, ...addArgs.slice(1), target])
|
|
833
|
+
} else if (failure.code === 'transient-network') {
|
|
834
|
+
result = await run([...addArgs, target])
|
|
835
|
+
} else {
|
|
836
|
+
throw new Error(failure.code)
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
if (!ok(result)) {
|
|
842
|
+
const failure = classifyUpgradeFailure(`${result.stderr}\n${result.stdout}`)
|
|
843
|
+
throw new Error(failure !== null ? failure.code : `dsh-failed: ${(result.stderr || '').trim().slice(-400) || result.signal || result.exitCode}`)
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// pnpm 干净退出 ≠ 真升级:minimumReleaseAge 会静默保住旧版。重读磁盘安装版本确认变化,只在其进了才重启。
|
|
847
|
+
const installed = await readInstalledPluginVersion(profile)
|
|
848
|
+
if (installed === null) throw new Error('installed-version-unreadable')
|
|
849
|
+
const advanced = parseSemver(installed) !== null && parseSemver(pluginVersion) !== null
|
|
850
|
+
? compareSemver(installed, pluginVersion) > 0
|
|
851
|
+
: installed !== pluginVersion
|
|
852
|
+
if (!advanced) throw new Error('upgrade-stale')
|
|
853
|
+
|
|
697
854
|
scheduleRestart(ctx)
|
|
698
|
-
return {
|
|
855
|
+
return { result: 'upgraded', profile: profile.name, previous: pluginVersion, installed }
|
|
699
856
|
}
|
|
700
857
|
|
|
701
858
|
async function permissionSnapshot(ctx, dshHome, plans) {
|
|
@@ -1088,7 +1245,7 @@ function apply(ctx) {
|
|
|
1088
1245
|
|
|
1089
1246
|
if (endpoint === 'upgrade') {
|
|
1090
1247
|
try {
|
|
1091
|
-
return { ok: true, value: await upgradePlugin(ctx) }
|
|
1248
|
+
return { ok: true, value: await upgradePlugin(ctx, dshHome) }
|
|
1092
1249
|
} catch (error) {
|
|
1093
1250
|
return { ok: false, error: error?.message || String(error) }
|
|
1094
1251
|
}
|