dsh-version-status 0.1.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 ADDED
@@ -0,0 +1,153 @@
1
+ # dsh-version-status
2
+
3
+ > DeepSeek Harness (DSH) 版本检测与更新提醒插件:在 Web GUI 侧边栏常驻显示当前版本与更新徽标,检测到新版本时亮起提示,点击展开详情面板并提供一键复制升级命令。
4
+
5
+ ## 🌟 核心特性
6
+
7
+ 1. **版本精准比对(SemVer 2.0.0)**:
8
+ - 完整支持主版本、次版本、补丁版本以及先行版本号(Pre-release 如 `rc.1`, `beta.2`)的精准比较规则。
9
+ - 正确识别正式版高于 rc 版(如 `0.1.2` > `0.1.2-rc.1`)、升级补丁版(如 `0.1.3` > `0.1.2-rc.1`)。
10
+
11
+ 2. **双端协同架构(Host / Client 分工)**:
12
+ - **Host 端(Node.js / Cordis)**:实现多级本地版本获取(argv 回溯 -> npm/pnpm 全局目录 -> CLI 兜底)、国内镜像与官方 npm registry 双源竞速探测(带 3.5s 超时与内存缓存防雪崩),暴露 `GET /api/dsh-version` 接口。
13
+ - **Client 端(Web 前端)**:注入 `sidebar.footer.action` 侧边栏底部胶囊,支持宽/窄模式自适应;注入 `shell.overlay` 弹层卡片,提供版本状态、更新日志外链与一键复制升级指令。
14
+
15
+ 3. **网络弹性与离线容灾**:
16
+ - 超时严格限制在 3.5 秒内,离线或断网情况下不卡死 Web 界面,平滑降级并保留错误提示。
17
+ - 内置 15 分钟 TTL 内存缓存,避免频繁穿透请求 npm。
18
+ - 支持 `?force=1` 强制穿透刷新。
19
+
20
+ 4. **便捷调试与测试(Mock 支持)**:
21
+ - 接口支持 `?mockLatest=0.1.3` 查询参数或 `DSH_MOCK_LATEST_VERSION` 环境变量,方便测试验证有新版本时的 UI 交互与复制功能。
22
+
23
+ ---
24
+
25
+ ## 📦 目录结构
26
+
27
+ ```text
28
+ dsh-update-notifier/
29
+ ├── cordis.patch.yml # Cordis 服务插槽补丁
30
+ ├── package.json # 模块配置与 exports 映射
31
+ ├── README.md # 插件说明文档
32
+ ├── src/
33
+ │ ├── index.js # Host 端服务与版本比对逻辑
34
+ │ └── client.js # Client 端侧边栏胶囊与弹窗组件
35
+ └── test/
36
+ └── index.test.js # 自动化单元测试套件
37
+ ```
38
+
39
+ ---
40
+
41
+ ## 🚀 安装与启用
42
+
43
+ ### 方式一:本地链接开发测试(推荐)
44
+
45
+ 在 DSH 的 Web profile 目录下添加软链或直接安装:
46
+
47
+ ```sh
48
+ # 1. 注册本地插件至 web profile
49
+ dsh plugin --profile web add ./output/20260907-dsh-update-notifier/dsh-update-notifier
50
+
51
+ # 2. 重启 dsh web 实例生效
52
+ dsh web
53
+ ```
54
+
55
+ ### 方式二:npm 官方包安装(待发布后)
56
+
57
+ ```sh
58
+ dsh plugin --profile web add dsh-version-status
59
+ ```
60
+
61
+ ---
62
+
63
+ ## 🔌 Host 端 HTTP 接口规范
64
+
65
+ ### `GET /api/dsh-version`(或别名 `/api/dsh-update/status`)
66
+
67
+ #### 请求参数(Query)
68
+ | 参数 | 类型 | 必填 | 说明 |
69
+ |---|---|---|---|
70
+ | `force` | boolean | 否 | 传 `1` 时穿透 15 分钟缓存,立即重走 npm registry 探测 |
71
+ | `mockLatest` | string | 否 | 用于测试模拟,例如 `?mockLatest=0.1.3` 强制指定最新版本号 |
72
+
73
+ #### 响应示例(无新版本)
74
+ ```json
75
+ {
76
+ "ok": true,
77
+ "currentVersion": "0.1.2-rc.1",
78
+ "latestVersion": "0.1.2-rc.1",
79
+ "updateAvailable": false,
80
+ "hasUpdate": false,
81
+ "hasError": false,
82
+ "errorMessage": null,
83
+ "checkedAt": 1757234400000,
84
+ "checkedAtIso": "2026-09-07T08:50:00.000Z",
85
+ "sources": {
86
+ "local": "global-npm-win",
87
+ "registry": "https://registry.npmmirror.com/@deepseek-ai%2fdsh/latest"
88
+ },
89
+ "upgradeCommand": "npm install -g @deepseek-ai/dsh@latest",
90
+ "upgradeCommands": {
91
+ "npm": "npm install -g @deepseek-ai/dsh@latest",
92
+ "pnpm": "pnpm add -g @deepseek-ai/dsh@latest",
93
+ "yarn": "yarn global add @deepseek-ai/dsh@latest"
94
+ },
95
+ "releaseUrl": "https://github.com/deepseek-ai/deepseek-harness/releases",
96
+ "changelogUrl": "https://github.com/deepseek-ai/deepseek-harness/releases"
97
+ }
98
+ ```
99
+
100
+ #### 响应示例(发现新版本)
101
+ ```json
102
+ {
103
+ "ok": true,
104
+ "currentVersion": "0.1.2-rc.1",
105
+ "latestVersion": "0.1.3",
106
+ "updateAvailable": true,
107
+ "hasUpdate": true,
108
+ "hasError": false,
109
+ "errorMessage": null,
110
+ "checkedAt": 1757234400000,
111
+ "checkedAtIso": "2026-09-07T08:50:00.000Z",
112
+ "upgradeCommand": "npm install -g @deepseek-ai/dsh@latest",
113
+ "upgradeCommands": {
114
+ "npm": "npm install -g @deepseek-ai/dsh@latest",
115
+ "pnpm": "pnpm add -g @deepseek-ai/dsh@latest",
116
+ "yarn": "yarn global add @deepseek-ai/dsh@latest"
117
+ },
118
+ "releaseUrl": "https://github.com/deepseek-ai/deepseek-harness/releases"
119
+ }
120
+ ```
121
+
122
+ ---
123
+
124
+ ## 🧪 运行单元测试与校验
125
+
126
+ ```sh
127
+ cd output/20260907-dsh-update-notifier/dsh-update-notifier
128
+
129
+ # 运行全量沙箱内联测试套件(包含 SemVer 比对、本地探测、Registry 双源拉取、离线降级与 Client 槽位/剪贴板降级测试)
130
+ npm test
131
+ # 或直接运行:
132
+ node test/run-in-process.js
133
+ ```
134
+
135
+ ---
136
+
137
+ ## 🛡️ 健壮性与安全设计审查
138
+
139
+ 1. **零阻塞启动与渲染**:
140
+ - 本地版本探测采用纯文件系统(`package.json` 静态解析)+ 进程命令行递归回溯,不发起耗时慢速进程调用。
141
+ - npm 探测严格限制在 3.5 秒以内,双端竞速超时后静默降级,不阻塞 WebServer 启动或 Web 客户端首次渲染。
142
+ - 断网或镜像源不可达时,自动返回当前本地版本并标记 `hasError: true`,UI 保持稳定呈现,无未捕获异常。
143
+
144
+ 2. **剪贴板多重兼容兜底**:
145
+ - 优先使用现代 `navigator.clipboard.writeText` 异步接口;
146
+ - 在非 HTTPS/远程 IP 局域网访问或权限受限环境自动向下平滑降级至 `document.execCommand('copy')`;
147
+ - 复制成功后提供 2 秒「已复制 ✓」视觉反馈与样式高亮。
148
+
149
+ 3. **严格 SemVer 2.0.0 规范**:
150
+ - 核心数字主版本、次版本、补丁版本数值比对;
151
+ - 先行版本号(pre-release 如 `rc.1`)支持数字段与标识符分段比对(`0.1.2-rc.10` > `0.1.2-rc.1`);
152
+ - 正式版本高于先行版本(`0.1.2` > `0.1.2-rc.1`);
153
+ - 构建元数据(build metadata 如 `+build.1`)遵循规范不参与优先级判定。
@@ -0,0 +1,6 @@
1
+ # dsh-version-status bundle patch: adds the host version notifier service.
2
+ # The client half joins the browser graph automatically because package.json declares
3
+ # dsh.client and exports "./client".
4
+ - insert:
5
+ - id: version-status
6
+ name: dsh-version-status
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "dsh-version-status",
3
+ "version": "0.1.0",
4
+ "description": "DSH core version monitor & upgrade helper: sidebar status badge, npm release checker, and one-click update command",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "deepseek-harness",
9
+ "dsh",
10
+ "dsh-plugin",
11
+ "update-notifier",
12
+ "version-checker",
13
+ "upgrade-helper"
14
+ ],
15
+ "exports": {
16
+ ".": "./src/index.js",
17
+ "./client": "./src/client.js",
18
+ "./package.json": "./package.json"
19
+ },
20
+ "files": [
21
+ "src",
22
+ "cordis.patch.yml",
23
+ "README.md"
24
+ ],
25
+ "dsh": {
26
+ "bundle": {
27
+ "patch": "./cordis.patch.yml"
28
+ },
29
+ "client": {
30
+ "platform": "web",
31
+ "inject": []
32
+ }
33
+ },
34
+ "dependencies": {},
35
+ "engines": {
36
+ "node": ">=20"
37
+ },
38
+ "scripts": {
39
+ "test": "node test/run-in-process.js"
40
+ }
41
+ }
package/src/client.js ADDED
@@ -0,0 +1,757 @@
1
+ /**
2
+ * dsh-update-notifier — Client half (Web GUI).
3
+ *
4
+ * Hand-written __ModuleLoader__ bundle, zero build step.
5
+ * Slots:
6
+ * - sidebar.footer.action : Pill capsule in sidebar footer (status light, version text, update badge)
7
+ * - shell.overlay : Floating detail panel (version diff, one-click upgrade commands, changelog link)
8
+ */
9
+ window.__ModuleLoader__.load({
10
+ id: 'dsh-version-status',
11
+ factory: (require) => {
12
+ const module = { exports: {} }
13
+ const exports = module.exports
14
+ const React = require('react')
15
+ const h = React.createElement
16
+ const { useEffect, useState, useRef, useSyncExternalStore } = React
17
+
18
+ // ---------- Styles & Animations ----------
19
+ const CSS = `
20
+ div[class*="footerActions"] {
21
+ display: flex !important;
22
+ flex-direction: column !important;
23
+ gap: 4px !important;
24
+ width: 100% !important;
25
+ }
26
+ @keyframes dshUpdateFadeSlideUp {
27
+ from { opacity: 0; transform: translateY(8px) scale(0.98); }
28
+ to { opacity: 1; transform: translateY(0) scale(1); }
29
+ }
30
+ @keyframes dshUpdateFadeSlideDown {
31
+ from { opacity: 1; transform: translateY(0) scale(1); }
32
+ to { opacity: 0; transform: translateY(8px) scale(0.98); }
33
+ }
34
+ @keyframes dshUpdatePulse {
35
+ 0% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.55); }
36
+ 70% { box-shadow: 0 0 0 6px rgba(245, 158, 11, 0); }
37
+ 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0); }
38
+ }
39
+ @keyframes dshUpdateSpin {
40
+ to { transform: rotate(360deg); }
41
+ }
42
+ .dsh-update-panel-enter {
43
+ animation: dshUpdateFadeSlideUp 0.18s cubic-bezier(0.16, 1, 0.3, 1) both;
44
+ }
45
+ .dsh-update-panel-exit {
46
+ animation: dshUpdateFadeSlideDown 0.14s ease-in both;
47
+ pointer-events: none !important;
48
+ }
49
+ .dsh-update-dot-pulse {
50
+ animation: dshUpdatePulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
51
+ }
52
+ .dsh-update-spin {
53
+ display: inline-block;
54
+ animation: dshUpdateSpin 0.9s linear infinite;
55
+ }
56
+ .dsh-update-btn {
57
+ transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.1s ease;
58
+ }
59
+ .dsh-update-btn:hover:not(:disabled) {
60
+ background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,0.08)) !important;
61
+ }
62
+ .dsh-update-btn:active:not(:disabled) {
63
+ transform: scale(0.97);
64
+ }
65
+ .dsh-update-pill:hover {
66
+ background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, 0.06)) !important;
67
+ border-color: var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.15)) !important;
68
+ }
69
+ @media (prefers-reduced-motion: reduce) {
70
+ .dsh-update-panel-enter, .dsh-update-panel-exit, .dsh-update-dot-pulse, .dsh-update-spin {
71
+ animation: none !important;
72
+ }
73
+ }
74
+ `
75
+
76
+ // ---------- Theme Tokens ----------
77
+ const T = {
78
+ bg: 'var(--dsw-alias-bg-layer-2, #18181b)',
79
+ well: 'var(--dsw-alias-bg-layer-1, #27272a)',
80
+ border: 'var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.1))',
81
+ border2: 'var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.18))',
82
+ label: 'var(--dsw-alias-label-primary, #f4f4f5)',
83
+ secondary: 'var(--dsw-alias-label-secondary, #a1a1aa)',
84
+ brand: 'var(--dsw-alias-brand-primary, #3b82f6)',
85
+ ok: 'var(--dsw-alias-state-success-primary, #10b981)',
86
+ warn: 'var(--dsw-alias-state-warn-primary, #f59e0b)',
87
+ err: 'var(--dsw-alias-state-error-primary, #ef4444)',
88
+ }
89
+
90
+ // ---------- Global Store ----------
91
+ const store = {
92
+ state: {
93
+ open: false,
94
+ phase: 'closed', // 'closed' | 'open' | 'closing'
95
+ loading: false,
96
+ copiedKey: null,
97
+ selectedTab: 'npm', // 'npm' | 'pnpm' | 'yarn'
98
+ status: {
99
+ ok: true,
100
+ currentVersion: '...',
101
+ latestVersion: '...',
102
+ updateAvailable: false,
103
+ hasUpdate: false,
104
+ hasError: false,
105
+ errorMessage: null,
106
+ checkedAt: 0,
107
+ upgradeCommand: 'npm install -g @deepseek-ai/dsh@latest',
108
+ upgradeCommands: {
109
+ npm: 'npm install -g @deepseek-ai/dsh@latest',
110
+ pnpm: 'pnpm add -g @deepseek-ai/dsh@latest',
111
+ yarn: 'yarn global add @deepseek-ai/dsh@latest'
112
+ },
113
+ releaseUrl: 'https://github.com/deepseek-ai/deepseek-harness/releases'
114
+ }
115
+ },
116
+ listeners: new Set(),
117
+ set(patch) {
118
+ store.state = { ...store.state, ...patch }
119
+ for (const fn of store.listeners) fn()
120
+ },
121
+ subscribe(fn) {
122
+ store.listeners.add(fn)
123
+ return () => store.listeners.delete(fn)
124
+ }
125
+ }
126
+
127
+ function useStore() {
128
+ return useSyncExternalStore(store.subscribe, () => store.state)
129
+ }
130
+
131
+ // ---------- API Call ----------
132
+ async function fetchUpdateStatus(force = false) {
133
+ store.set({ loading: true })
134
+ try {
135
+ const url = `/api/dsh-version${force ? '?force=1' : ''}`
136
+ const res = await fetch(url, {
137
+ headers: { accept: 'application/json' },
138
+ cache: 'no-store'
139
+ })
140
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
141
+ const data = await res.json()
142
+ if (data && data.ok) {
143
+ store.set({ status: data, loading: false })
144
+ } else {
145
+ store.set({ loading: false })
146
+ }
147
+ } catch (err) {
148
+ store.set({
149
+ loading: false,
150
+ status: {
151
+ ...store.state.status,
152
+ hasError: true,
153
+ errorMessage: err.message || '网络检查异常'
154
+ }
155
+ })
156
+ }
157
+ }
158
+
159
+ // ---------- Helpers ----------
160
+ async function copyToClipboard(text, key) {
161
+ let copied = false
162
+ if (navigator?.clipboard?.writeText) {
163
+ try {
164
+ await navigator.clipboard.writeText(text)
165
+ copied = true
166
+ } catch (err) {
167
+ // Clipboard API rejected (e.g. non-secure HTTP context or lack of permission), fall through
168
+ }
169
+ }
170
+ if (!copied) {
171
+ try {
172
+ const ta = document.createElement('textarea')
173
+ ta.value = text
174
+ ta.style.position = 'fixed'
175
+ ta.style.opacity = '0'
176
+ document.body.appendChild(ta)
177
+ ta.select()
178
+ copied = document.execCommand('copy')
179
+ document.body.removeChild(ta)
180
+ } catch (e) {
181
+ console.error('Failed to copy command via fallback:', e)
182
+ }
183
+ }
184
+ if (copied) {
185
+ store.set({ copiedKey: key })
186
+ setTimeout(() => {
187
+ if (store.state.copiedKey === key) {
188
+ store.set({ copiedKey: null })
189
+ }
190
+ }, 2000)
191
+ }
192
+ }
193
+
194
+ function toggleOpen() {
195
+ const s = store.state
196
+ if (s.phase === 'open') {
197
+ store.set({ phase: 'closing' })
198
+ setTimeout(() => {
199
+ store.set({ open: false, phase: 'closed' })
200
+ }, 150)
201
+ } else {
202
+ store.set({ open: true, phase: 'open' })
203
+ // If not checked yet, fetch status
204
+ if (!s.status.checkedAt) {
205
+ fetchUpdateStatus(false)
206
+ }
207
+ }
208
+ }
209
+
210
+ function closePanel() {
211
+ if (store.state.phase === 'open') {
212
+ store.set({ phase: 'closing' })
213
+ setTimeout(() => {
214
+ store.set({ open: false, phase: 'closed' })
215
+ }, 150)
216
+ }
217
+ }
218
+
219
+ function formatTime(timestamp) {
220
+ if (!timestamp) return '未检测'
221
+ const d = new Date(timestamp)
222
+ const now = Date.now()
223
+ const diffSec = Math.floor((now - timestamp) / 1000)
224
+ if (diffSec < 60) return '刚刚'
225
+ if (diffSec < 3600) return `${Math.floor(diffSec / 60)} 分钟前`
226
+ const h = String(d.getHours()).padStart(2, '0')
227
+ const m = String(d.getMinutes()).padStart(2, '0')
228
+ return `今日 ${h}:${m}`
229
+ }
230
+
231
+ // ---------- SVG Icons ----------
232
+ const Icons = {
233
+ rocket: () =>
234
+ h('svg', { viewBox: '0 0 24 24', width: 14, height: 14, fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
235
+ h('path', { d: 'M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z' }),
236
+ h('path', { d: 'm12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z' }),
237
+ h('path', { d: 'M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0' }),
238
+ h('path', { d: 'M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5' })
239
+ ),
240
+ copy: () =>
241
+ h('svg', { viewBox: '0 0 24 24', width: 13, height: 13, fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
242
+ h('rect', { x: 9, y: 9, width: 13, height: 13, rx: 2, ry: 2 }),
243
+ h('path', { d: 'M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1' })
244
+ ),
245
+ check: () =>
246
+ h('svg', { viewBox: '0 0 24 24', width: 13, height: 13, fill: 'none', stroke: 'currentColor', strokeWidth: 2.5, strokeLinecap: 'round', strokeLinejoin: 'round' },
247
+ h('polyline', { points: '20 6 9 17 4 12' })
248
+ ),
249
+ refresh: () =>
250
+ h('svg', { viewBox: '0 0 24 24', width: 13, height: 13, fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
251
+ h('path', { d: 'M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67' })
252
+ ),
253
+ external: () =>
254
+ h('svg', { viewBox: '0 0 24 24', width: 12, height: 12, fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
255
+ h('path', { d: 'M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6' }),
256
+ h('polyline', { points: '15 3 21 3 21 9' }),
257
+ h('line', { x1: 10, y1: 14, x2: 21, y2: 3 })
258
+ ),
259
+ close: () =>
260
+ h('svg', { viewBox: '0 0 24 24', width: 14, height: 14, fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
261
+ h('line', { x1: 18, y1: 6, x2: 6, y2: 18 }),
262
+ h('line', { x1: 6, y1: 6, x2: 18, y2: 18 })
263
+ )
264
+ }
265
+
266
+ // ---------- Component: Footer Capsule Entry ----------
267
+ function FooterEntry(props) {
268
+ const state = useStore()
269
+ const { wide } = props
270
+ const st = state.status
271
+ const hasUpdate = st.updateAvailable || st.hasUpdate
272
+ const isFolded = wide === false
273
+
274
+ // On mount: fetch initial status if not yet loaded
275
+ useEffect(() => {
276
+ if (!st.checkedAt) {
277
+ fetchUpdateStatus(false)
278
+ }
279
+ }, [])
280
+
281
+ // Indicator color & pulse
282
+ const dotColor = state.loading
283
+ ? T.brand
284
+ : hasUpdate
285
+ ? T.warn
286
+ : st.hasError
287
+ ? T.err
288
+ : T.ok
289
+
290
+ const tooltip = hasUpdate
291
+ ? `发现新版本: v${st.latestVersion} (当前 v${st.currentVersion}),点击查看升级`
292
+ : `DSH v${st.currentVersion} (已是最新)`
293
+
294
+ // Folded Rail (Compact Icon) Mode
295
+ if (isFolded) {
296
+ return h(
297
+ 'button',
298
+ {
299
+ className: 'dsh-update-btn',
300
+ onClick: toggleOpen,
301
+ title: tooltip,
302
+ style: {
303
+ width: 32,
304
+ height: 32,
305
+ borderRadius: 8,
306
+ border: `1px solid ${hasUpdate ? T.warn : T.border}`,
307
+ background: hasUpdate ? 'rgba(245, 158, 11, 0.12)' : 'transparent',
308
+ color: hasUpdate ? T.warn : T.label,
309
+ display: 'flex',
310
+ alignItems: 'center',
311
+ justifyContent: 'center',
312
+ cursor: 'pointer',
313
+ position: 'relative',
314
+ margin: '0 auto',
315
+ padding: 0,
316
+ }
317
+ },
318
+ [
319
+ hasUpdate ? h(Icons.rocket) : h('span', {
320
+ style: {
321
+ width: 7,
322
+ height: 7,
323
+ borderRadius: '50%',
324
+ background: dotColor,
325
+ display: 'inline-block',
326
+ }
327
+ }),
328
+ hasUpdate ? h('span', {
329
+ className: 'dsh-update-dot-pulse',
330
+ style: {
331
+ position: 'absolute',
332
+ top: 4,
333
+ right: 4,
334
+ width: 6,
335
+ height: 6,
336
+ borderRadius: '50%',
337
+ background: T.warn,
338
+ }
339
+ }) : null
340
+ ]
341
+ )
342
+ }
343
+
344
+ // Expanded Sidebar (Full Pill Capsule) Mode
345
+ return h(
346
+ 'div',
347
+ {
348
+ className: 'dsh-update-pill dsh-update-btn',
349
+ onClick: toggleOpen,
350
+ title: tooltip,
351
+ style: {
352
+ display: 'flex',
353
+ alignItems: 'center',
354
+ height: 28,
355
+ width: '100%',
356
+ padding: '0 8px',
357
+ borderRadius: 7,
358
+ border: `1px solid ${hasUpdate ? 'rgba(245, 158, 11, 0.4)' : T.border}`,
359
+ background: hasUpdate ? 'rgba(245, 158, 11, 0.08)' : 'transparent',
360
+ cursor: 'pointer',
361
+ fontSize: 11,
362
+ userSelect: 'none',
363
+ boxSizing: 'border-box',
364
+ }
365
+ },
366
+ [
367
+ // Dot or spinner
368
+ state.loading
369
+ ? h('span', {
370
+ className: 'dsh-update-spin',
371
+ style: {
372
+ width: 8,
373
+ height: 8,
374
+ borderRadius: '50%',
375
+ border: `2px solid ${T.brand}`,
376
+ borderTopColor: 'transparent',
377
+ marginRight: 7,
378
+ flexShrink: 0
379
+ }
380
+ })
381
+ : h('span', {
382
+ className: hasUpdate ? 'dsh-update-dot-pulse' : '',
383
+ style: {
384
+ width: 7,
385
+ height: 7,
386
+ borderRadius: '50%',
387
+ background: dotColor,
388
+ marginRight: 7,
389
+ flexShrink: 0
390
+ }
391
+ }),
392
+
393
+ // Version text
394
+ h('span', {
395
+ style: {
396
+ color: hasUpdate ? T.label : T.secondary,
397
+ fontWeight: hasUpdate ? 600 : 400,
398
+ whiteSpace: 'nowrap',
399
+ overflow: 'hidden',
400
+ textOverflow: 'ellipsis',
401
+ flex: 1
402
+ }
403
+ }, hasUpdate ? `有新版 v${st.latestVersion}` : `DSH v${st.currentVersion}`),
404
+
405
+ // Right badge
406
+ hasUpdate
407
+ ? h('span', {
408
+ style: {
409
+ fontSize: 9,
410
+ fontWeight: 700,
411
+ color: '#fff',
412
+ background: T.warn,
413
+ padding: '1px 5px',
414
+ borderRadius: 4,
415
+ marginLeft: 4,
416
+ flexShrink: 0,
417
+ letterSpacing: '0.3px'
418
+ }
419
+ }, 'UPGRADE')
420
+ : h('span', {
421
+ style: {
422
+ fontSize: 10,
423
+ color: T.secondary,
424
+ opacity: 0.6,
425
+ marginLeft: 4,
426
+ flexShrink: 0
427
+ }
428
+ }, '✓')
429
+ ]
430
+ )
431
+ }
432
+
433
+ // ---------- Component: Overlay Panel ----------
434
+ function OverlayPanel() {
435
+ const state = useStore()
436
+ const panelRef = useRef(null)
437
+ const { phase, status: st, copiedKey, selectedTab, loading } = state
438
+ const hasUpdate = st.updateAvailable || st.hasUpdate
439
+
440
+ // Close on clicking outside
441
+ useEffect(() => {
442
+ if (phase !== 'open') return
443
+ function handleOutside(e) {
444
+ if (panelRef.current && !panelRef.current.contains(e.target)) {
445
+ // Check if clicked element was the footer entry trigger
446
+ const trigger = document.querySelector('.dsh-update-pill, .dsh-update-btn')
447
+ if (trigger && trigger.contains(e.target)) return
448
+ closePanel()
449
+ }
450
+ }
451
+ window.addEventListener('mousedown', handleOutside)
452
+ return () => window.removeEventListener('mousedown', handleOutside)
453
+ }, [phase])
454
+
455
+ if (phase === 'closed') return null
456
+
457
+ const tabs = ['npm', 'pnpm', 'yarn']
458
+ const activeCommand = (st.upgradeCommands && st.upgradeCommands[selectedTab]) || st.upgradeCommand
459
+
460
+ return h(
461
+ 'div',
462
+ {
463
+ ref: panelRef,
464
+ className: phase === 'closing' ? 'dsh-update-panel-exit' : 'dsh-update-panel-enter',
465
+ style: {
466
+ position: 'fixed',
467
+ left: 12,
468
+ bottom: 54,
469
+ width: 360,
470
+ maxWidth: 'calc(100vw - 24px)',
471
+ background: T.bg,
472
+ border: `1px solid ${T.border}`,
473
+ borderRadius: 14,
474
+ boxShadow: '0 12px 36px rgba(0, 0, 0, 0.45)',
475
+ display: 'flex',
476
+ flexDirection: 'column',
477
+ overflow: 'hidden',
478
+ pointerEvents: phase === 'closing' ? 'none' : 'auto',
479
+ zIndex: 70,
480
+ fontSize: 12,
481
+ lineHeight: 1.4,
482
+ }
483
+ },
484
+ [
485
+ // Header
486
+ h('div', {
487
+ style: {
488
+ display: 'flex',
489
+ alignItems: 'center',
490
+ padding: '12px 14px',
491
+ borderBottom: `1px solid ${T.border}`,
492
+ background: 'rgba(255, 255, 255, 0.02)'
493
+ }
494
+ }, [
495
+ h('span', { style: { display: 'flex', alignItems: 'center', gap: 6, fontWeight: 600, color: T.label } }, [
496
+ h(Icons.rocket),
497
+ 'DSH 版本检测'
498
+ ]),
499
+ // Status Tag
500
+ h('span', {
501
+ style: {
502
+ marginLeft: 8,
503
+ fontSize: 10,
504
+ padding: '2px 6px',
505
+ borderRadius: 4,
506
+ background: hasUpdate ? 'rgba(245, 158, 11, 0.15)' : 'rgba(16, 185, 129, 0.15)',
507
+ color: hasUpdate ? T.warn : T.ok,
508
+ fontWeight: 500,
509
+ }
510
+ }, hasUpdate ? '发现新版本' : '已是最新'),
511
+
512
+ // Close button
513
+ h('button', {
514
+ className: 'dsh-update-btn',
515
+ onClick: closePanel,
516
+ title: '关闭',
517
+ style: {
518
+ marginLeft: 'auto',
519
+ border: 'none',
520
+ background: 'transparent',
521
+ color: T.secondary,
522
+ cursor: 'pointer',
523
+ padding: '3px',
524
+ borderRadius: 4,
525
+ display: 'flex',
526
+ alignItems: 'center',
527
+ justifyContent: 'center'
528
+ }
529
+ }, h(Icons.close))
530
+ ]),
531
+
532
+ // Body Content
533
+ h('div', { style: { padding: '14px', display: 'flex', flexDirection: 'column', gap: 12 } }, [
534
+ // Version Comparison Card
535
+ h('div', {
536
+ style: {
537
+ background: T.well,
538
+ border: `1px solid ${T.border}`,
539
+ borderRadius: 10,
540
+ padding: '12px',
541
+ display: 'flex',
542
+ flexDirection: 'column',
543
+ gap: 8
544
+ }
545
+ }, [
546
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } }, [
547
+ // Local version box
548
+ h('div', { style: { display: 'flex', flexDirection: 'column' } }, [
549
+ h('span', { style: { fontSize: 10, color: T.secondary } }, '当前本地版本'),
550
+ h('span', { style: { fontSize: 13, fontWeight: 600, color: T.label, fontFamily: 'monospace' } }, `v${st.currentVersion}`)
551
+ ]),
552
+
553
+ // Arrow
554
+ h('span', { style: { fontSize: 14, color: hasUpdate ? T.warn : T.secondary, opacity: 0.8 } }, '➔'),
555
+
556
+ // Latest version box
557
+ h('div', { style: { display: 'flex', flexDirection: 'column', alignItems: 'flex-end' } }, [
558
+ h('span', { style: { fontSize: 10, color: T.secondary } }, 'npm 最新版本'),
559
+ h('span', {
560
+ style: {
561
+ fontSize: 13,
562
+ fontWeight: 600,
563
+ color: hasUpdate ? T.warn : T.ok,
564
+ fontFamily: 'monospace'
565
+ }
566
+ }, `v${st.latestVersion}`)
567
+ ])
568
+ ]),
569
+
570
+ // Comparison note
571
+ h('div', {
572
+ style: {
573
+ fontSize: 11,
574
+ color: hasUpdate ? T.warn : T.secondary,
575
+ borderTop: `1px dashed ${T.border}`,
576
+ paddingTop: 8,
577
+ marginTop: 2,
578
+ display: 'flex',
579
+ alignItems: 'center',
580
+ justifyContent: 'space-between'
581
+ }
582
+ }, [
583
+ h('span', null, hasUpdate ? '★ 建议升级以体验最新能力与修复' : '✓ 运行良好,无需更新'),
584
+ h('span', { style: { fontSize: 10, color: T.secondary, opacity: 0.75 } }, formatTime(st.checkedAt))
585
+ ])
586
+ ]),
587
+
588
+ // One-Click Upgrade Command Section
589
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } }, [
590
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } }, [
591
+ h('span', { style: { fontSize: 11, fontWeight: 500, color: T.label } }, '升级命令(一键复制):'),
592
+ // Package Manager Tabs
593
+ h('div', { style: { display: 'flex', gap: 4 } }, tabs.map(tab =>
594
+ h('button', {
595
+ key: tab,
596
+ className: 'dsh-update-btn',
597
+ onClick: () => store.set({ selectedTab: tab }),
598
+ style: {
599
+ border: 'none',
600
+ background: selectedTab === tab ? T.brand : 'rgba(255,255,255,0.06)',
601
+ color: selectedTab === tab ? '#fff' : T.secondary,
602
+ fontSize: 10,
603
+ padding: '2px 7px',
604
+ borderRadius: 4,
605
+ cursor: 'pointer',
606
+ fontWeight: selectedTab === tab ? 600 : 400
607
+ }
608
+ }, tab)
609
+ ))
610
+ ]),
611
+
612
+ // Code Box with Copy Button
613
+ h('div', {
614
+ style: {
615
+ background: 'rgba(0,0,0,0.3)',
616
+ border: `1px solid ${T.border}`,
617
+ borderRadius: 8,
618
+ padding: '8px 10px',
619
+ display: 'flex',
620
+ alignItems: 'center',
621
+ gap: 8,
622
+ boxSizing: 'border-box'
623
+ }
624
+ }, [
625
+ h('code', {
626
+ style: {
627
+ flex: 1,
628
+ fontFamily: 'Consolas, Menlo, monospace',
629
+ fontSize: 11,
630
+ color: T.label,
631
+ whiteSpace: 'nowrap',
632
+ overflow: 'hidden',
633
+ textOverflow: 'ellipsis',
634
+ userSelect: 'all'
635
+ }
636
+ }, activeCommand),
637
+
638
+ // Copy Action Button
639
+ h('button', {
640
+ className: 'dsh-update-btn',
641
+ onClick: () => copyToClipboard(activeCommand, selectedTab),
642
+ style: {
643
+ display: 'flex',
644
+ alignItems: 'center',
645
+ gap: 4,
646
+ flexShrink: 0,
647
+ border: `1px solid ${copiedKey === selectedTab ? T.ok : T.border2}`,
648
+ background: copiedKey === selectedTab ? 'rgba(16, 185, 129, 0.15)' : 'rgba(255, 255, 255, 0.08)',
649
+ color: copiedKey === selectedTab ? T.ok : T.label,
650
+ borderRadius: 6,
651
+ padding: '4px 8px',
652
+ fontSize: 10,
653
+ fontWeight: 500,
654
+ cursor: 'pointer'
655
+ }
656
+ }, [
657
+ copiedKey === selectedTab ? h(Icons.check) : h(Icons.copy),
658
+ copiedKey === selectedTab ? '已复制 ✓' : '复制'
659
+ ])
660
+ ])
661
+ ])
662
+ ]),
663
+
664
+ // Ops Footer Bar
665
+ h('div', {
666
+ style: {
667
+ display: 'flex',
668
+ alignItems: 'center',
669
+ justifyContent: 'space-between',
670
+ padding: '10px 14px',
671
+ borderTop: `1px solid ${T.border}`,
672
+ background: 'rgba(255, 255, 255, 0.02)'
673
+ }
674
+ }, [
675
+ // Changelog Link
676
+ h('a', {
677
+ href: st.releaseUrl || 'https://github.com/deepseek-ai/deepseek-harness/releases',
678
+ target: '_blank',
679
+ rel: 'noopener noreferrer',
680
+ style: {
681
+ display: 'flex',
682
+ alignItems: 'center',
683
+ gap: 4,
684
+ color: T.brand,
685
+ textDecoration: 'none',
686
+ fontSize: 11
687
+ }
688
+ }, [
689
+ '更新日志',
690
+ h(Icons.external)
691
+ ]),
692
+
693
+ // Check Now Button
694
+ h('button', {
695
+ className: 'dsh-update-btn',
696
+ disabled: loading,
697
+ onClick: () => fetchUpdateStatus(true),
698
+ style: {
699
+ display: 'flex',
700
+ alignItems: 'center',
701
+ gap: 5,
702
+ border: `1px solid ${T.border2}`,
703
+ background: 'rgba(255, 255, 255, 0.05)',
704
+ color: T.label,
705
+ padding: '4px 10px',
706
+ borderRadius: 6,
707
+ fontSize: 11,
708
+ cursor: loading ? 'default' : 'pointer'
709
+ }
710
+ }, [
711
+ loading
712
+ ? h('span', { className: 'dsh-update-spin' }, h(Icons.refresh))
713
+ : h(Icons.refresh),
714
+ loading ? '检查中...' : '检查更新'
715
+ ])
716
+ ])
717
+ ]
718
+ )
719
+ }
720
+
721
+ // ---------- Plugin Apply Entrypoint ----------
722
+ function apply(ctx) {
723
+ // 1. Inject Stylesheet into Document Head
724
+ ctx.effect(() => {
725
+ const el = document.createElement('style')
726
+ el.id = 'dsh-update-notifier-styles'
727
+ el.textContent = CSS
728
+ document.head.appendChild(el)
729
+ return () => el.remove()
730
+ })
731
+
732
+ // 2. Register Sidebar Footer Action Capsule
733
+ ctx.effect(() =>
734
+ ctx.slots.inject('sidebar.footer.action', () =>
735
+ ctx.slots.register(
736
+ { name: 'sidebar.footer.action', id: 'dsh-update-notifier', order: 20 },
737
+ (props) => h(FooterEntry, props)
738
+ )
739
+ )
740
+ )
741
+
742
+ // 3. Register Shell Overlay Floating Panel
743
+ ctx.effect(() =>
744
+ ctx.slots.inject('shell.overlay', () =>
745
+ ctx.slots.register(
746
+ { name: 'shell.overlay', id: 'dsh-update-panel', order: 20 },
747
+ () => h(OverlayPanel)
748
+ )
749
+ )
750
+ )
751
+ }
752
+
753
+ exports.apply = apply
754
+ exports.inject = ['slots']
755
+ return module.exports
756
+ }
757
+ })
package/src/index.js ADDED
@@ -0,0 +1,415 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { execSync } from 'node:child_process'
4
+
5
+ export const name = 'update-notifier'
6
+ export const inject = ['webServer']
7
+
8
+ const DSH_PACKAGE = '@deepseek-ai/dsh'
9
+ const DEFAULT_CACHE_TTL_MS = 15 * 60 * 1000 // 15 minutes
10
+ const REQUEST_TIMEOUT_MS = 3500
11
+
12
+ export const REGISTRY_ENDPOINTS = [
13
+ 'https://registry.npmmirror.com/@deepseek-ai%2fdsh/latest',
14
+ 'https://registry.npmjs.org/@deepseek-ai%2fdsh/latest'
15
+ ]
16
+
17
+ export const DEFAULT_RELEASE_URL = 'https://github.com/deepseek-ai/deepseek-harness/releases'
18
+
19
+ /**
20
+ * Parse a semver string into structured components.
21
+ * Supports standard semver 2.0: major.minor.patch[-prerelease][+build]
22
+ * @param {string} v
23
+ */
24
+ export function parseSemver(v) {
25
+ if (!v || typeof v !== 'string') return null
26
+ const cleaned = v.trim().replace(/^[vV]/, '')
27
+ const match = cleaned.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/)
28
+ if (!match) return null
29
+
30
+ return {
31
+ raw: v,
32
+ major: parseInt(match[1], 10),
33
+ minor: parseInt(match[2], 10),
34
+ patch: parseInt(match[3], 10),
35
+ prerelease: match[4] ? match[4].split('.') : [],
36
+ build: match[5] ? match[5].split('.') : []
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Compare two semver strings according to SemVer 2.0.0 rules.
42
+ * @param {string} v1
43
+ * @param {string} v2
44
+ * @returns {number} 1 if v1 > v2, -1 if v1 < v2, 0 if v1 === v2
45
+ */
46
+ export function compareSemver(v1, v2) {
47
+ const s1 = parseSemver(v1)
48
+ const s2 = parseSemver(v2)
49
+
50
+ if (!s1 || !s2) {
51
+ if (s1 && !s2) return 1
52
+ if (!s1 && s2) return -1
53
+ return String(v1).localeCompare(String(v2))
54
+ }
55
+
56
+ if (s1.major !== s2.major) return s1.major > s2.major ? 1 : -1
57
+ if (s1.minor !== s2.minor) return s1.minor > s2.minor ? 1 : -1
58
+ if (s1.patch !== s2.patch) return s1.patch > s2.patch ? 1 : -1
59
+
60
+ // Core versions match, compare prereleases
61
+ const p1 = s1.prerelease
62
+ const p2 = s2.prerelease
63
+
64
+ // Normal version has higher precedence than prerelease version
65
+ if (p1.length === 0 && p2.length > 0) return 1
66
+ if (p1.length > 0 && p2.length === 0) return -1
67
+ if (p1.length === 0 && p2.length === 0) return 0
68
+
69
+ // Compare prerelease identifiers left to right
70
+ const maxLen = Math.max(p1.length, p2.length)
71
+ for (let i = 0; i < maxLen; i++) {
72
+ const id1 = p1[i]
73
+ const id2 = p2[i]
74
+
75
+ if (id1 === undefined) return -1 // smaller set of prerelease identifiers
76
+ if (id2 === undefined) return 1
77
+ if (id1 === id2) continue
78
+
79
+ const isNum1 = /^\d+$/.test(id1)
80
+ const isNum2 = /^\d+$/.test(id2)
81
+
82
+ if (isNum1 && isNum2) {
83
+ const n1 = parseInt(id1, 10)
84
+ const n2 = parseInt(id2, 10)
85
+ if (n1 !== n2) return n1 > n2 ? 1 : -1
86
+ continue
87
+ }
88
+
89
+ // Numeric identifiers have lower precedence than non-numeric
90
+ if (isNum1 && !isNum2) return -1
91
+ if (!isNum1 && isNum2) return 1
92
+
93
+ // Both non-numeric: lexical comparison
94
+ const cmp = id1.localeCompare(id2)
95
+ if (cmp !== 0) return cmp > 0 ? 1 : -1
96
+ }
97
+
98
+ return 0
99
+ }
100
+
101
+ /**
102
+ * Safely reads and parses package.json if it exists at target directory.
103
+ * @param {string} dir
104
+ */
105
+ function readPackageJsonVersion(dir) {
106
+ try {
107
+ const file = path.join(dir, 'package.json')
108
+ if (!fs.existsSync(file)) return null
109
+ const content = fs.readFileSync(file, 'utf8')
110
+ const pkg = JSON.parse(content)
111
+ if (pkg.name === DSH_PACKAGE && typeof pkg.version === 'string') {
112
+ return { version: pkg.version, path: file }
113
+ }
114
+ return null
115
+ } catch {
116
+ return null
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Multi-tier detection of current local DSH version.
122
+ * @returns {{ version: string, source: string, packagePath?: string }}
123
+ */
124
+ export function detectLocalVersion() {
125
+ // Tier 1: Traverse upwards from process.argv[1] (typically .../dsh/lib/bin.js)
126
+ if (process.argv[1]) {
127
+ try {
128
+ let cur = path.resolve(path.dirname(process.argv[1]))
129
+ for (let i = 0; i < 6; i++) {
130
+ const res = readPackageJsonVersion(cur)
131
+ if (res) {
132
+ return { version: res.version, source: 'process-argv', packagePath: res.path }
133
+ }
134
+ const parent = path.dirname(cur)
135
+ if (parent === cur) break
136
+ cur = parent
137
+ }
138
+ } catch {}
139
+ }
140
+
141
+ // Tier 2: Check Windows global npm directory
142
+ if (process.env.APPDATA) {
143
+ const winGlobal = path.join(process.env.APPDATA, 'npm', 'node_modules', '@deepseek-ai', 'dsh')
144
+ const res = readPackageJsonVersion(winGlobal)
145
+ if (res) {
146
+ return { version: res.version, source: 'global-npm-win', packagePath: res.path }
147
+ }
148
+ }
149
+
150
+ // Tier 3: Check global pnpm directories
151
+ if (process.env.LOCALAPPDATA) {
152
+ const pnpmGlobal = path.join(process.env.LOCALAPPDATA, 'pnpm', 'global', '5', 'node_modules', '@deepseek-ai', 'dsh')
153
+ const res = readPackageJsonVersion(pnpmGlobal)
154
+ if (res) {
155
+ return { version: res.version, source: 'global-pnpm-win', packagePath: res.path }
156
+ }
157
+ }
158
+
159
+ // Tier 4: Check standard Unix / macOS paths
160
+ const unixPaths = [
161
+ '/usr/local/lib/node_modules/@deepseek-ai/dsh',
162
+ '/usr/lib/node_modules/@deepseek-ai/dsh'
163
+ ]
164
+ for (const up of unixPaths) {
165
+ const res = readPackageJsonVersion(up)
166
+ if (res) {
167
+ return { version: res.version, source: 'global-unix', packagePath: res.path }
168
+ }
169
+ }
170
+
171
+ // Tier 5: Fallback to running CLI `dsh --version`
172
+ try {
173
+ const stdout = execSync('dsh --version', { encoding: 'utf8', timeout: 2500, stdio: ['ignore', 'pipe', 'ignore'] }).trim()
174
+ if (stdout) {
175
+ const parsed = stdout.replace(/^[vV]/, '').split(/\s+/)[0]
176
+ if (parseSemver(parsed)) {
177
+ return { version: parsed, source: 'cli-exec' }
178
+ }
179
+ }
180
+ } catch {}
181
+
182
+ // Tier 6: Default fallback
183
+ return { version: '0.1.2-rc.1', source: 'fallback-default' }
184
+ }
185
+
186
+ /**
187
+ * Fetch the latest version from npm registry endpoints with dual-source fallback.
188
+ * @param {object} [options]
189
+ * @param {string[]} [options.endpoints]
190
+ * @param {number} [options.timeout]
191
+ * @returns {Promise<{ version: string, registryUrl: string }>}
192
+ */
193
+ export async function fetchLatestVersion(options = {}) {
194
+ const endpoints = options.endpoints || REGISTRY_ENDPOINTS
195
+ const timeout = options.timeout || REQUEST_TIMEOUT_MS
196
+ let lastError = null
197
+
198
+ for (const url of endpoints) {
199
+ try {
200
+ const res = await fetch(url, {
201
+ headers: {
202
+ accept: 'application/json',
203
+ 'user-agent': 'dsh-update-notifier/0.1.0'
204
+ },
205
+ signal: AbortSignal.timeout(timeout)
206
+ })
207
+
208
+ if (!res.ok) {
209
+ lastError = new Error(`Registry HTTP ${res.status}: ${res.statusText} (${url})`)
210
+ continue
211
+ }
212
+
213
+ const data = await res.json()
214
+ if (data && typeof data.version === 'string') {
215
+ return {
216
+ version: data.version.trim(),
217
+ registryUrl: url
218
+ }
219
+ }
220
+ lastError = new Error(`Invalid registry response payload from ${url}`)
221
+ } catch (err) {
222
+ lastError = err
223
+ }
224
+ }
225
+
226
+ throw lastError || new Error('Failed to fetch latest version from all registry endpoints')
227
+ }
228
+
229
+ /**
230
+ * Service class managing version checks, caching, and response assembly.
231
+ */
232
+ export class VersionService {
233
+ constructor(options = {}) {
234
+ this.cacheTtlMs = options.cacheTtlMs || DEFAULT_CACHE_TTL_MS
235
+ this.cachedStatus = null
236
+ this.inFlightPromise = null
237
+ }
238
+
239
+ /**
240
+ * Builds update status, utilizing cache unless force is specified.
241
+ * @param {object} [opts]
242
+ * @param {boolean} [opts.force]
243
+ * @param {string} [opts.mockLatest]
244
+ */
245
+ async getStatus(opts = {}) {
246
+ const { force = false, mockLatest } = opts
247
+ const now = Date.now()
248
+
249
+ // If mockLatest is provided, build instant synthetic result for testing
250
+ if (mockLatest) {
251
+ const local = detectLocalVersion()
252
+ const isAvailable = compareSemver(mockLatest, local.version) > 0
253
+ return this._formatResponse({
254
+ currentVersion: local.version,
255
+ latestVersion: mockLatest,
256
+ updateAvailable: isAvailable,
257
+ hasError: false,
258
+ errorMessage: null,
259
+ checkedAt: now,
260
+ sources: {
261
+ local: local.source,
262
+ registry: 'mock-override'
263
+ }
264
+ })
265
+ }
266
+
267
+ // Return unexpired cache if available and not forced
268
+ if (!force && this.cachedStatus && (now - this.cachedStatus.checkedAt < this.cacheTtlMs)) {
269
+ return this.cachedStatus
270
+ }
271
+
272
+ // Reuse in-flight fetch if multiple requests arrive simultaneously
273
+ if (this.inFlightPromise) {
274
+ return this.inFlightPromise
275
+ }
276
+
277
+ this.inFlightPromise = (async () => {
278
+ const local = detectLocalVersion()
279
+ let latestVersion = null
280
+ let registryUrl = null
281
+ let hasError = false
282
+ let errorMessage = null
283
+
284
+ try {
285
+ const remote = await fetchLatestVersion()
286
+ latestVersion = remote.version
287
+ registryUrl = remote.registryUrl
288
+ } catch (err) {
289
+ hasError = true
290
+ errorMessage = err instanceof Error ? err.message : String(err)
291
+
292
+ // If we have stale cache, preserve its latestVersion while noting error
293
+ if (this.cachedStatus && this.cachedStatus.latestVersion) {
294
+ latestVersion = this.cachedStatus.latestVersion
295
+ registryUrl = this.cachedStatus.sources?.registry || 'stale-cache'
296
+ }
297
+ }
298
+
299
+ const effectiveLatest = latestVersion || local.version
300
+ const updateAvailable = latestVersion ? compareSemver(latestVersion, local.version) > 0 : false
301
+
302
+ const result = this._formatResponse({
303
+ currentVersion: local.version,
304
+ latestVersion: effectiveLatest,
305
+ updateAvailable,
306
+ hasError,
307
+ errorMessage,
308
+ checkedAt: Date.now(),
309
+ sources: {
310
+ local: local.source,
311
+ registry: registryUrl || 'none'
312
+ }
313
+ })
314
+
315
+ if (!hasError || !this.cachedStatus) {
316
+ this.cachedStatus = result
317
+ }
318
+ return result
319
+ })().finally(() => {
320
+ this.inFlightPromise = null
321
+ })
322
+
323
+ return this.inFlightPromise
324
+ }
325
+
326
+ _formatResponse(data) {
327
+ const upgradeCommands = {
328
+ npm: 'npm install -g @deepseek-ai/dsh@latest',
329
+ pnpm: 'pnpm add -g @deepseek-ai/dsh@latest',
330
+ yarn: 'yarn global add @deepseek-ai/dsh@latest'
331
+ }
332
+
333
+ return {
334
+ ok: true,
335
+ currentVersion: data.currentVersion,
336
+ latestVersion: data.latestVersion,
337
+ updateAvailable: data.updateAvailable,
338
+ hasUpdate: data.updateAvailable,
339
+ hasError: data.hasError,
340
+ errorMessage: data.errorMessage,
341
+ checkedAt: data.checkedAt,
342
+ checkedAtIso: new Date(data.checkedAt).toISOString(),
343
+ sources: data.sources,
344
+ upgradeCommand: upgradeCommands.npm,
345
+ upgradeCommands,
346
+ releaseUrl: DEFAULT_RELEASE_URL,
347
+ changelogUrl: DEFAULT_RELEASE_URL
348
+ }
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Send JSON HTTP response with no-store cache control.
354
+ * @param {import('node:http').ServerResponse} res
355
+ * @param {number} status
356
+ * @param {any} body
357
+ */
358
+ export function sendJson(res, status, body) {
359
+ res.writeHead(status, {
360
+ 'content-type': 'application/json; charset=utf-8',
361
+ 'cache-control': 'no-store'
362
+ })
363
+ res.end(JSON.stringify(body, null, 2))
364
+ }
365
+
366
+ /**
367
+ * Cordis plugin apply entrypoint.
368
+ * Registers WebServer routes:
369
+ * - GET /api/dsh-version
370
+ * - GET /api/dsh-update/status
371
+ */
372
+ export function apply(ctx) {
373
+ const service = new VersionService()
374
+
375
+ async function handleStatusRequest(req, res) {
376
+ if (req.method !== 'GET') {
377
+ return sendJson(res, 405, { ok: false, error: { code: 'METHOD_NOT_ALLOWED', message: 'GET only' } })
378
+ }
379
+
380
+ try {
381
+ const url = new URL(req.url || '/', 'http://localhost')
382
+ const force = url.searchParams.has('force') || url.searchParams.has('refresh')
383
+ const mockLatest = url.searchParams.get('mockLatest') || process.env.DSH_MOCK_LATEST_VERSION || undefined
384
+
385
+ const status = await service.getStatus({ force, mockLatest })
386
+ sendJson(res, 200, status)
387
+ } catch (err) {
388
+ sendJson(res, 500, {
389
+ ok: false,
390
+ error: {
391
+ code: 'INTERNAL_ERROR',
392
+ message: err instanceof Error ? err.message : String(err)
393
+ }
394
+ })
395
+ }
396
+ }
397
+
398
+ // 1. Register GET /api/dsh-version (Primary contract)
399
+ ctx.effect(() =>
400
+ ctx.webServer.register({
401
+ kind: 'exact',
402
+ path: '/api/dsh-version',
403
+ handler: handleStatusRequest
404
+ })
405
+ )
406
+
407
+ // 2. Register GET /api/dsh-update/status (Alias for compatibility)
408
+ ctx.effect(() =>
409
+ ctx.webServer.register({
410
+ kind: 'exact',
411
+ path: '/api/dsh-update/status',
412
+ handler: handleStatusRequest
413
+ })
414
+ )
415
+ }