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 +153 -0
- package/cordis.patch.yml +6 -0
- package/package.json +41 -0
- package/src/client.js +757 -0
- package/src/index.js +415 -0
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`)遵循规范不参与优先级判定。
|
package/cordis.patch.yml
ADDED
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
|
+
}
|