dsh_check_updates 0.1.0 → 0.2.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 CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  ## ✨ 功能一览
15
15
 
16
- - **🔍 版本检查**:对照 npm registry dist-tags,检查 DeepSeek Harness 本体(`@deepseek-ai/dsh`)与 profile 已装插件(如 `@dsh-external/dsh-vision-toolkit`)的当前版本与最新版本
16
+ - **🔍 版本检查**:对照 npm registry dist-tags,检查 DeepSeek Harness 本体(`@deepseek-ai/dsh`)与 profile 已装插件(如 `@anionex/dsh-vision-toolkit`)的当前版本与最新版本
17
17
  - **🤖 模型工具** `dsh_check_updates`:agent 可在任意会话主动查询版本状态(支持传入额外包名)
18
18
  - **🖥️ Settings 页面**:侧栏新增「更新检查」页——版本表格(包名/当前/最新/状态)、上次检查时间、「立即检查」按钮
19
19
  - **⏰ 周期复查**:间隔可调(30 秒 / 1 分钟 / 30 分钟 / 1 小时 / 12 小时 / 24 小时 / 关闭),默认 1 小时;启动 30 秒后自动首查
@@ -41,3 +41,27 @@ dsh plugin --profile web add <本仓库 / npm 包名>
41
41
  ## 📄 许可
42
42
 
43
43
  [MIT](./LICENSE)
44
+
45
+ ## 📝 更新日志
46
+
47
+ ### v0.2.0
48
+
49
+ **缺陷修复**
50
+
51
+ - **版本比较**:修正预发布版本(`-rc.1`)的比较逻辑——此前把整个版本串按 `.` 切分后逐段解析数字,`0.1.5-rc.1` 与 `0.1.2-rc.1` 会被判为相等,导致 DSH 本体长期误报「已是最新」
52
+ - **GitHub tag 解析**:修正带前缀的 release tag(如 `dsh-v0.1.5-rc.1`)的解析——此前只剥离 `v` 前缀,前缀残段被当作版本首段解析成 0,`upstream-new` 状态永不触发
53
+ - **自身跳过**:修正自动发现逻辑漏掉自身 npm 包名(`dsh_check_updates`)的问题,此前自身会出现在检查结果里
54
+ - **界面误报**:修复 Settings 页在端点返回错误时仍显示成功的问题
55
+
56
+ **健壮性与安全**
57
+
58
+ - 新增 npm 包名格式校验,杜绝经由工具参数 `packages` 的路径穿越与 URL 注入
59
+ - 并发刷新去重:定时器 / HTTP 端点 / 模型工具同时触发时复用同一在途检查,避免请求翻倍与结果错乱
60
+ - 多包检查改为并行发出,耗时不再随插件数量线性增长
61
+ - `intervalMinutes` 走白名单校验,非法值回落到默认 60 分钟
62
+ - 请求体超过 64 KB 时不再销毁连接,浏览器能正常收到 400 响应
63
+ - 解析 npm 全局根改为异步执行,不再阻塞宿主事件循环
64
+
65
+ **测试**
66
+
67
+ - 新增 17 个行为测试(host 13 + client 4),覆盖版本比较、输入校验、并发去重、HTTP 端点与插件卸载清理
package/README_EN.md CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  ## ✨ Features
15
15
 
16
- - **🔍 Version check**: compares installed versions against npm registry dist-tags for the DSH core (`@deepseek-ai/dsh`) and profile plugins (e.g. `@dsh-external/dsh-vision-toolkit`)
16
+ - **🔍 Version check**: compares installed versions against npm registry dist-tags for the DSH core (`@deepseek-ai/dsh`) and profile plugins (e.g. `@anionex/dsh-vision-toolkit`)
17
17
  - **🤖 Model tool** `dsh_check_updates`: agents can query version status in any session (optional extra package names)
18
18
  - **🖥️ Settings page**: a new "Update Check" section with a version table (name / current / latest / status), last-checked time, and a "Check now" button
19
19
  - **⏰ Periodic re-check**: configurable interval (30s / 1m / 30m / 1h / 12h / 24h / off), default 1 hour; first check runs 30s after startup
@@ -40,3 +40,27 @@ Tag your own plugins with <a href="https://github.com/topics/dsh-update-check"><
40
40
  ## 📄 License
41
41
 
42
42
  [MIT](./LICENSE)
43
+
44
+ ## 📝 Changelog
45
+
46
+ ### v0.2.0
47
+
48
+ **Bug fixes**
49
+
50
+ - **Version comparison**: fixed prerelease (`-rc.1`) handling — the whole version string used to be split on `.` and parsed numerically, so `0.1.5-rc.1` and `0.1.2-rc.1` compared equal and DSH was perpetually reported as up to date
51
+ - **GitHub tag parsing**: fixed prefixed release tags such as `dsh-v0.1.5-rc.1` — previously only the `v` prefix was stripped, so the leftover prefix parsed as segment 0 and the `upstream-new` state could never trigger
52
+ - **Self-skip**: auto-discovery now matches the npm package name (`dsh_check_updates`) as well as the directory name, so the plugin no longer lists itself
53
+ - **UI false positive**: the Settings page no longer reports success when the endpoint returns an error
54
+
55
+ **Robustness & security**
56
+
57
+ - Added npm package-name validation, closing path traversal and URL injection through the `packages` tool argument
58
+ - Concurrent refresh is deduplicated: timer, HTTP endpoint and model tool share a single in-flight check
59
+ - Multi-package checks now run in parallel instead of sequentially
60
+ - `intervalMinutes` is validated against the whitelist and falls back to 60 minutes
61
+ - Oversized request bodies no longer destroy the socket, so the browser receives a proper 400
62
+ - Resolving the npm global root is now asynchronous and no longer blocks the host event loop
63
+
64
+ **Tests**
65
+
66
+ - Added 17 behavioural tests (13 host + 4 client) covering version comparison, input validation, concurrency deduplication, HTTP endpoints and plugin teardown
package/lib/client.js CHANGED
@@ -194,7 +194,7 @@ function UpdateCheckSection() {
194
194
  : cell(entry && entry.latest);
195
195
  return react_1.createElement(
196
196
  'tr',
197
- { key: 'row-' + idx },
197
+ { key: 'row-' + (entry && entry.name ? entry.name : idx) },
198
198
  react_1.createElement('td', null, cell(entry && entry.name)),
199
199
  react_1.createElement('td', { className: 'upck-muted' }, cell(entry && entry.current)),
200
200
  react_1.createElement('td', null, latestText),
@@ -244,12 +244,14 @@ function apply(ctx) {
244
244
  styleEl.textContent = CSS;
245
245
  document.head.appendChild(styleEl);
246
246
 
247
- slots.inject('settings.section', () => slots.register(
247
+ const offSlot = slots.inject('settings.section', () => slots.register(
248
248
  { name: 'settings.section', id: 'dsh-update-check', order: 35, label: () => '更新检查' },
249
249
  () => react_1.createElement(UpdateCheckSection, null)
250
250
  ));
251
251
 
252
252
  ctx.effect(() => () => {
253
+ // slots.inject 返回 disposer,必须调用——否则插件卸载 / 热重载后设置页会残留条目
254
+ if (typeof offSlot === 'function') offSlot();
253
255
  if (styleEl.parentNode) styleEl.parentNode.removeChild(styleEl);
254
256
  }, NS + ':css');
255
257
  }
package/lib/index.js CHANGED
@@ -10,28 +10,56 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
10
10
  import { readFileSync } from 'node:fs';
11
11
  import { homedir } from 'node:os';
12
12
  import { join } from 'node:path';
13
- import { execSync } from 'node:child_process';
13
+ import { exec } from 'node:child_process';
14
+ import { promisify } from 'node:util';
15
+
16
+ const execAsync = promisify(exec);
14
17
 
15
18
  export const name = 'dsh-update-check';
16
19
  export const inject = ['timer', 'tools'];
17
20
 
18
21
  const ALLOWED_MINUTES = [0.5, 1, 30, 60, 720, 1440, 0];
22
+ const DEFAULT_INTERVAL_MINUTES = 60;
19
23
  const REGISTRY = 'https://registry.npmjs.org/';
20
24
  const GITHUB_CACHE_TTL_MS = 60 * 60 * 1000;
21
25
 
26
+ /**
27
+ * 合法 npm 包名(含 scope)。用于过滤模型可控的 packages 参数:该参数会拼进
28
+ * 文件路径与 registry URL,不加校验时可被 `../../..` 之类穿越出 node_modules。
29
+ */
30
+ const NPM_NAME_RE = /^(?:@[a-z0-9][a-z0-9-._]*\/)?[a-z0-9][a-z0-9-._]*$/i;
31
+
32
+ function isSafePackageName(name) {
33
+ return typeof name === 'string' && name.length > 0 && name.length <= 214 && NPM_NAME_RE.test(name);
34
+ }
35
+
36
+ /** 间隔归一化:不在白名单内一律回落到默认值。 */
37
+ function normalizeInterval(minutes) {
38
+ if (typeof minutes !== 'number' || Number.isNaN(minutes)) return DEFAULT_INTERVAL_MINUTES;
39
+ return ALLOWED_MINUTES.some((m) => Math.abs(m - minutes) < 1e-9) ? minutes : DEFAULT_INTERVAL_MINUTES;
40
+ }
41
+
22
42
  let globalRootCache = null;
43
+ let globalRootInflight = null;
23
44
  const githubCache = new Map();
24
45
 
25
- /** npm 全局根(`npm prefix -g`),惰性缓存。 */
26
- function globalRoot() {
46
+ /**
47
+ * npm 全局根(`npm prefix -g`),惰性缓存 + 异步执行 + 在途去重。
48
+ * 原实现用 execSync,会在首次检查时同步阻塞宿主事件循环(最坏到 10 秒超时上限)。
49
+ */
50
+ async function globalRoot() {
27
51
  if (globalRootCache !== null) return globalRootCache;
28
- try {
29
- const out = execSync('npm prefix -g', { encoding: 'utf8', timeout: 10000, windowsHide: true });
30
- globalRootCache = out.trim();
31
- } catch {
32
- globalRootCache = '';
33
- }
34
- return globalRootCache;
52
+ if (globalRootInflight) return globalRootInflight;
53
+ globalRootInflight = (async () => {
54
+ try {
55
+ const { stdout } = await execAsync('npm prefix -g', { encoding: 'utf8', timeout: 10000, windowsHide: true });
56
+ globalRootCache = String(stdout).trim();
57
+ } catch {
58
+ globalRootCache = '';
59
+ }
60
+ return globalRootCache;
61
+ })();
62
+ return globalRootInflight;
35
63
  }
36
64
 
37
65
  /** DSH 家目录(DSH_HOME 或 ~/.dsh)。 */
@@ -47,8 +75,12 @@ function discoverPackages(extra) {
47
75
  const profile = JSON.parse(readFileSync(profilePath, 'utf8'));
48
76
  const deps = profile && profile.dependencies ? Object.keys(profile.dependencies) : [];
49
77
  for (const key of deps) {
50
- if (key === 'dsh-update-check') continue; // 不检查自身
51
- if (key.startsWith('dsh-') || key.startsWith('@dsh-external/') || key.startsWith('@deepseek-ai/')) {
78
+ // 不检查自身:包目录名是 dsh-update-check,npm 包名却是 dsh_check_updates,
79
+ // 只比对其中一个会漏掉,导致自身一直出现在结果列表里。
80
+ if (key === 'dsh-update-check' || key === 'dsh_check_updates') continue;
81
+ // 覆盖官方 @deepseek-ai/* 与所有包名含 dsh 的插件(如 @anionex/dsh-vision-toolkit),避免包名/scope 变化后漏检
82
+ if (!isSafePackageName(key)) continue;
83
+ if (key.startsWith('@deepseek-ai/') || /dsh/i.test(key)) {
52
84
  if (!names.includes(key)) names.push(key);
53
85
  }
54
86
  }
@@ -57,7 +89,7 @@ function discoverPackages(extra) {
57
89
  }
58
90
  if (Array.isArray(extra)) {
59
91
  for (const nm of extra) {
60
- if (typeof nm === 'string' && nm && !names.includes(nm)) names.push(nm);
92
+ if (isSafePackageName(nm) && !names.includes(nm)) names.push(nm);
61
93
  }
62
94
  }
63
95
  return names;
@@ -77,12 +109,12 @@ function parseGithubRepo(repository) {
77
109
  }
78
110
 
79
111
  /** 读取本机已装版本:profile node_modules → npm 全局根 → APPDATA 兜底。 */
80
- function readLocalVersion(pkgName) {
112
+ async function readLocalVersion(pkgName) {
81
113
  const segments = pkgName.split('/');
82
114
  const candidates = [];
83
115
  const home = dshHome();
84
116
  candidates.push(join(home, 'profiles', 'web', 'node_modules', ...segments, 'package.json'));
85
- const gRoot = globalRoot();
117
+ const gRoot = await globalRoot();
86
118
  if (gRoot) candidates.push(join(gRoot, 'node_modules', ...segments, 'package.json'));
87
119
  if (process.env.APPDATA) candidates.push(join(process.env.APPDATA, 'npm', 'node_modules', ...segments, 'package.json'));
88
120
  for (const p of candidates) {
@@ -115,8 +147,15 @@ async function fetchLatestMeta(pkgName) {
115
147
 
116
148
  /** 读 GitHub latest release 的 tag(15s 超时 + 1h 缓存;失败返回 null 不阻断)。 */
117
149
  async function fetchGithubLatest(repo) {
150
+ const now = Date.now();
118
151
  const cached = githubCache.get(repo);
119
- if (cached && Date.now() - cached.ts < GITHUB_CACHE_TTL_MS) return cached.tag;
152
+ if (cached && now - cached.ts < GITHUB_CACHE_TTL_MS) return cached.tag;
153
+ // 顺手淘汰过期项,避免长期运行时缓存只增不减
154
+ if (githubCache.size >= 64) {
155
+ for (const [key, val] of githubCache) {
156
+ if (now - val.ts >= GITHUB_CACHE_TTL_MS) githubCache.delete(key);
157
+ }
158
+ }
120
159
  const url = 'https://api.github.com/repos/' + repo + '/releases/latest';
121
160
  const controller = new AbortController();
122
161
  const timer = setTimeout(() => controller.abort(), 15000);
@@ -137,28 +176,67 @@ async function fetchGithubLatest(repo) {
137
176
  }
138
177
  }
139
178
 
140
- /** 语义化版本比较(容忍 v 前缀与缺段):a>b 返回 1,相等 0,a<b 返回 -1。 */
179
+ /**
180
+ * 语义化版本比较(容忍 v 前缀、缺段与预发布后缀):a>b 返回 1,相等 0,a<b 返回 -1。
181
+ *
182
+ * 先把预发布后缀(-rc.1 之类)切下来再解析数字段。早期实现直接对整串 split('.')
183
+ * 后 map(Number),Number('2-rc') 得到 NaN,又被 `|| 0` 吞成 0,于是 0.1.2-rc.1 与
184
+ * 0.1.5-rc.1 被判为相等 —— DSH 本体因此长期误报 up-to-date。
185
+ */
141
186
  function semverCompare(a, b) {
142
- const pa = String(a).replace(/^v/i, '').split('.').map(Number);
143
- const pb = String(b).replace(/^v/i, '').split('.').map(Number);
144
- const len = Math.max(pa.length, pb.length);
187
+ const parse = (v) => {
188
+ // 剥离 tag 前缀:npm 版本无前缀,但 GitHub release tag 形如 dsh-v0.1.5-rc.1,
189
+ // 只去 v 会留下 "dsh-v" 被当作版本号首段解析成 0,导致比较恒为「更低」。
190
+ const s = String(v).replace(/^[^\d]*/, '');
191
+ const i = s.indexOf('-');
192
+ return {
193
+ nums: (i < 0 ? s : s.slice(0, i)).split('.').map((n) => Number(n) || 0),
194
+ pre: i < 0 ? '' : s.slice(i + 1),
195
+ };
196
+ };
197
+ const pa = parse(a);
198
+ const pb = parse(b);
199
+ const len = Math.max(pa.nums.length, pb.nums.length);
145
200
  for (let i = 0; i < len; i++) {
146
- const x = pa[i] || 0;
147
- const y = pb[i] || 0;
201
+ const x = pa.nums[i] || 0;
202
+ const y = pb.nums[i] || 0;
148
203
  if (x !== y) return x > y ? 1 : -1;
149
204
  }
205
+ if (pa.pre === pb.pre) return 0;
206
+ // 主版本相同:正式版 > 预发布版
207
+ if (pa.pre === '') return 1;
208
+ if (pb.pre === '') return -1;
209
+ const sa = pa.pre.split('.');
210
+ const sb = pb.pre.split('.');
211
+ const n = Math.max(sa.length, sb.length);
212
+ for (let i = 0; i < n; i++) {
213
+ if (sa[i] === undefined) return -1;
214
+ if (sb[i] === undefined) return 1;
215
+ const na = /^\d+$/.test(sa[i]);
216
+ const nb = /^\d+$/.test(sb[i]);
217
+ if (na && nb) {
218
+ const da = Number(sa[i]);
219
+ const db = Number(sb[i]);
220
+ if (da !== db) return da > db ? 1 : -1;
221
+ } else if (na !== nb) {
222
+ return na ? -1 : 1; // 数字段优先级低于字母段(semver §11)
223
+ } else if (sa[i] !== sb[i]) {
224
+ return sa[i] > sb[i] ? 1 : -1;
225
+ }
226
+ }
150
227
  return 0;
151
228
  }
152
229
 
153
230
  /** 收集版本表:自动发现 profile 插件;单包失败隔离为 unknown;GitHub release 超前 npm 时标记 upstream-new。 */
154
231
  async function collectVersions(extra) {
155
232
  const names = discoverPackages(extra);
156
- const entries = [];
157
- for (const pkgName of names) {
233
+ // 并行检查:每个包都要打 npm registry 与 GitHub 两个请求,串行会让总耗时随包数线性增长。
234
+ // Promise.all 保持输入顺序;单包失败在各自分支内降级为 unknown,不拖垮整表。
235
+ return Promise.all(names.map(async (pkgName) => {
158
236
  try {
159
237
  const meta = await fetchLatestMeta(pkgName);
160
238
  const latest = meta.version;
161
- const current = readLocalVersion(pkgName);
239
+ const current = await readLocalVersion(pkgName);
162
240
  const githubLatest = meta.githubRepo && !meta.unpublished ? await fetchGithubLatest(meta.githubRepo) : null;
163
241
  let status;
164
242
  if (meta.unpublished) {
@@ -177,12 +255,11 @@ async function collectVersions(extra) {
177
255
  const entry = { name: pkgName, current, latest, status };
178
256
  if (meta.unpublished) entry.reason = '未发布到 npm registry';
179
257
  if (githubLatest && semverCompare(githubLatest, latest) > 0) entry.githubLatest = githubLatest;
180
- entries.push(entry);
258
+ return entry;
181
259
  } catch (error) {
182
- entries.push({ name: pkgName, current: null, latest: null, status: 'unknown', reason: String((error && error.message) || error) });
260
+ return { name: pkgName, current: null, latest: null, status: 'unknown', reason: String((error && error.message) || error) };
183
261
  }
184
- }
185
- return entries;
262
+ }));
186
263
  }
187
264
 
188
265
  /** 收集 POST 请求的 JSON body。 */
@@ -190,23 +267,33 @@ function readJsonBody(req) {
190
267
  return new Promise((resolve, reject) => {
191
268
  const chunks = [];
192
269
  let size = 0;
270
+ let settled = false;
193
271
  req.on('data', (chunk) => {
272
+ if (settled) return;
194
273
  size += chunk.length;
195
274
  if (size > 64 * 1024) {
275
+ // 只拒绝、不销毁 socket:销毁会让随后写回的 400 响应无法送达,浏览器只能
276
+ // 看到连接被重置。剩余数据由流自行排空后丢弃(settled 已挡住后续累积)。
277
+ settled = true;
196
278
  reject(new Error('request body too large'));
197
- req.destroy();
198
279
  return;
199
280
  }
200
281
  chunks.push(chunk);
201
282
  });
202
283
  req.on('end', () => {
284
+ if (settled) return;
285
+ settled = true;
203
286
  try {
204
287
  resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {});
205
288
  } catch (e) {
206
289
  reject(new Error('invalid json body'));
207
290
  }
208
291
  });
209
- req.on('error', reject);
292
+ req.on('error', (e) => {
293
+ if (settled) return;
294
+ settled = true;
295
+ reject(e);
296
+ });
210
297
  });
211
298
  }
212
299
 
@@ -216,23 +303,25 @@ export async function apply(ctx, config = {}) {
216
303
  lastCheckedAt: null,
217
304
  entries: [],
218
305
  lastError: null,
219
- intervalMinutes: typeof config.intervalMinutes === 'number' ? config.intervalMinutes : 60,
306
+ intervalMinutes: normalizeInterval(config.intervalMinutes),
220
307
  };
221
308
  let intervalDisposer = null;
309
+ let inflightRefresh = null;
222
310
 
311
+ // 返回浅拷贝:调用方拿到的是快照,不与内部数组共享引用
223
312
  const snapshot = () => ({
224
313
  lastCheckedAt: state.lastCheckedAt,
225
- entries: state.entries,
314
+ entries: state.entries.map((e) => ({ ...e })),
226
315
  lastError: state.lastError,
227
316
  intervalMinutes: state.intervalMinutes,
228
317
  });
229
318
 
230
- async function refresh(extra) {
319
+ async function runRefresh(extra) {
231
320
  try {
232
321
  const fresh = await collectVersions(extra);
322
+ // 全部条目 unknown 即整体失败(unknown 本身已意味着本机版本读不到)
233
323
  const allUnknown = fresh.length > 0 && fresh.every((e) => e.status === 'unknown');
234
- const anyCurrent = fresh.some((e) => e.current !== null && e.current !== '');
235
- if (allUnknown && !anyCurrent) {
324
+ if (allUnknown) {
236
325
  if (state.entries.length > 0) {
237
326
  state.lastError = '整体检查失败:全部条目 unknown(registry 不可达或服务异常),保留上次成功快照';
238
327
  return snapshot();
@@ -251,6 +340,20 @@ export async function apply(ctx, config = {}) {
251
340
  return snapshot();
252
341
  }
253
342
 
343
+ /**
344
+ * 并发去重:定时器 / HTTP 端点 / 模型工具三个触发源可能同时到达。不加约束会并发
345
+ * 跑多轮完整检查(每轮对每个包打两次外部请求),且完成顺序不定,先到的结果可能被
346
+ * 后到的覆盖。复用同一在途 Promise 即可,既省请求也保证结果单调。
347
+ */
348
+ function refresh(extra) {
349
+ if (inflightRefresh) return inflightRefresh;
350
+ const p = runRefresh(extra);
351
+ inflightRefresh = p;
352
+ const clear = () => { if (inflightRefresh === p) inflightRefresh = null; };
353
+ p.then(clear, clear);
354
+ return p;
355
+ }
356
+
254
357
  function armInterval(minutes) {
255
358
  if (intervalDisposer) {
256
359
  intervalDisposer();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh_check_updates",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "DeepSeek Harness 更新检查插件:DSH 本体与 profile 插件版本可见性(模型工具 + Settings 页面 + 周期复查),只报告不执行升级。",
5
5
  "keywords": [
6
6
  "deepseek",
@@ -18,12 +18,17 @@
18
18
  "./package.json": "./package.json"
19
19
  },
20
20
  "files": [
21
- "lib",
21
+ "lib/index.js",
22
+ "lib/client.js",
22
23
  "cordis.patch.yml",
23
24
  "README.md",
24
25
  "README_EN.md",
25
26
  "dsh.plugin.json"
26
27
  ],
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/knlght/DSH-update-check.git"
31
+ },
27
32
  "engines": {
28
33
  "node": "^22.19.0 || >=24.0.0"
29
34
  },