dsh-plugin-rack 0.1.1 → 0.1.3

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
@@ -1,4 +1,4 @@
1
- # dsh-plugin-dashboard · 插件管理台
1
+ # dsh-plugin-rack · 插件管理台
2
2
 
3
3
  DSH(DeepSeek Harness)的插件面板:**所有插件一屏看完,一个开关直接开关,更新完不用重启。**
4
4
 
@@ -37,6 +37,7 @@ DSH(DeepSeek Harness)的插件面板:**所有插件一屏看完,一个
37
37
  ### 更新
38
38
 
39
39
  - 「检查更新」把 profile 里按精确版本锁定的依赖逐个查 registry 最新版,有新的直接在列表行里标出来:`⬆ v0.1.29 ➡ v0.1.30 npm`,**点一下只更新那一个插件**。
40
+ - **尊重你写的版本范围**:`package.json` 里锁死精确版本(`1.2.3`)时照旧只看那一个版本;写成 `^1.2.3` / `~1.2.3` / `1.2` / `1.x` / `>=1.2.3` / `>1.2.3` / `<2.0.0` / `1.2.3 - 2.0.0` / `^1 || ^2` 时,只有落在范围内的新版才算「可更新」。超出范围的新版会单独列在下方,标「超出声明范围」并附上你写的那段 spec —— 不会被批量更新悄悄顶到下一个大版本。`link:` / `file:` / `github:` / tarball 这些不可比的范围仍走「其他安装方式」,不参与版本更新。
40
41
  - 更新由宿主执行:**点完就可以关掉面板**,pnpm 照样跑完。重新打开面板时,顶部会显示「后台更新中 · N 个插件」,跑完了补一条结果提示。不用守着这个界面,也不用去翻日志。卡住了可以点横幅右边的「取消」。
41
42
  - **会话正忙时也不白点**:需要下载的更新会先把附件取下来、核对 sha256,然后排队;宿主每 10 秒看一眼,等所有会话都空了就自己装上(下载过的东西不会重下)。排队期间横幅显示「已排队…」。
42
43
  - **网络不用你配**:下载按这个顺序自动选路,`config.json` 里明确指定的代理 → 环境变量(`HTTPS_PROXY` / `ALL_PROXY`,`NO_PROXY` 会照规矩跳过)→ 系统代理(Windows 的 Internet 设置)→ 直连;一条走不通就换下一条,不会因为代理没开就整个更新失败。HTTP 代理和 SOCKS5 都支持,带用户名密码也行,查 GitHub 接口的部分同样走这些代理。
@@ -48,7 +49,7 @@ DSH(DeepSeek Harness)的插件面板:**所有插件一屏看完,一个
48
49
  - 能识别 **GitHub 直装 / npm 镜像 tar 包 / 本地文件包 / 本地软链** 这四种装法,并在同一个列表里标出各自来源;本地软链没有「升级」这回事,不会被勾选。
49
50
  - registry 取 profile 自己的配置(`pluginManager.registries()`),不强行走 npmjs;装了 `GITHUB_TOKEN` 的话查 GitHub 版本会用它提额。
50
51
 
51
- ### 安装与卸载
52
+ ## 安装与卸载
52
53
 
53
54
  - 输入包名先「检查」:**只做预检,什么都不动**。它会告诉你这是不是个 DSH 插件包、版本多少、干什么用的。像 `is-odd` 这种普通 npm 包会被拦下来,因为它没声明 `dsh.bundle`,装了也不会被加载。
54
55
  - 确认了再「安装」,走宿主官方的插件管理接口。
@@ -82,13 +83,13 @@ node test/self-check.mjs
82
83
  **① 从 GitHub 装(推荐,不依赖 npm)**
83
84
 
84
85
  ```bash
85
- dsh plugin add github:LZM-P/dsh-plugin-dashboard
86
+ dsh plugin add github:LZM-P/dsh-plugin-rack
86
87
  ```
87
88
 
88
89
  **② 从 npm 装(发布后可用)**
89
90
 
90
91
  ```bash
91
- dsh plugin add dsh-plugin-dashboard
92
+ dsh plugin add dsh-plugin-rack
92
93
  ```
93
94
 
94
95
  **③ 在插件管理界面里点「安装」**,把上面任意一条的 spec 填进去。
@@ -96,7 +97,7 @@ dsh plugin add dsh-plugin-dashboard
96
97
  装好后侧边栏会出现「插件管理」。
97
98
 
98
99
  > 如果宿主版本不认 `github:` 前缀,改用 tarball 地址:
99
- > `dsh plugin add https://github.com/LZM-P/dsh-plugin-dashboard/archive/refs/heads/main.tar.gz`
100
+ > `dsh plugin add https://github.com/LZM-P/dsh-plugin-rack/archive/refs/heads/main.tar.gz`
100
101
 
101
102
  ## 数据放在哪
102
103
 
@@ -124,11 +125,21 @@ dsh plugin add dsh-plugin-dashboard
124
125
  | 文件 | 作用 |
125
126
  | --- | --- |
126
127
  | `index.js` | 宿主端:注册 `/api/dsh-plugin-rack` 前缀路由,负责读取插件状态、批量操作、更新与安装卸载 |
127
- | `client.js` | 客户端:侧边栏图标 + `main` 面板的界面(无需构建步骤,浏览器直接加载) |
128
- | `cordis.patch.yml` | bundle 补丁,插入 `plugin-dashboard` 这一行 |
128
+ | `client.js` | 客户端:侧边栏图标 + `main` 面板的界面。**生成文件**,由 `src/client/*.js` 拼装(`npm run build`) |
129
+ | `src/client/i18n.js` | 中英词条表与插值(纯函数,可单独测) |
130
+ | `src/client/view.js` | 面板的判定逻辑:失败归类、勾选分流、报告合并、行尾徽章、更新页状态 |
131
+ | `src/client/style.js` | 面板样式表 |
132
+ | `src/client/panel.js` | 面板组件本身(React `h`,无构建期依赖) |
133
+ | `scripts/build-client.mjs` | 把上面几块拼成 `client.js`;`--check` 供 CI 判断是否过期 |
134
+ | `lib/spec.js` | 版本范围算术(精确 / `^` / `~` / `>=` / `<` / 通配 / 连字符 / `||` 并集),纯函数,`index.js` 与测试共用同一份 |
135
+ | `cordis.patch.yml` | bundle 补丁,插入 `plugin-rack` 这一行 |
129
136
  | `locale/{en,zh}.json` | 插件名与描述(插件管理页展示用) |
130
137
  | `icon.svg` | 插件图标 |
131
138
  | `test/self-check.mjs` | 自检脚本:词条对照、路由对照、hook 顺序、调试残留 |
139
+ | `test/spec.mjs` | 范围算术单测:`^0.2.3` 不含 `0.3.0`、`~1.2.3` 不含 `1.3.0`、预发布版不穿越等边界 |
140
+ | `test/*.mjs` | 行为 / 后台作业 / 下载 / pnpm 启动四套测试 |
141
+ | `scripts/publish.mjs` | 发布脚本:token 只从 `NPM_TOKEN` 读,版本重复直接拒绝 |
142
+ | `eslint.config.js` / `tsconfig.json` | lint 与 `tsc --checkJs`(类型只覆盖 `lib/`) |
132
143
 
133
144
  ## 接口
134
145
 
@@ -178,7 +189,7 @@ POST /api/dsh-plugin-rack/uninstall { name }
178
189
 
179
190
  1. **网络暴露面**:API 只在 `127.0.0.1` / `localhost` / `::1` 上可达。宿主的 web server 本来就只绑 loopback,所以外网碰不到这个端口。
180
191
  2. **Host / Origin 校验**:每个请求都先过两道闸:Host 头必须指向 loopback,Origin 头(如果存在)也必须解析到 loopback。这挡的是同机浏览器里的恶意网页:它无法用 `<img>` 或 `fetch` 跨站调用你的 API(DNS rebinding / CSRF 都被这两道闸拦下)。
181
- 2b. **写操作还要求"你确实是从面板来的"**:`POST` 除了要 `application/json`,还必须带 `Origin`、或 `Sec-Fetch-Site: same-origin`、或显式加上 `x-dsh-plugin-dashboard: 1` 请求头。
192
+ 2b. **写操作还要求"你确实是从面板来的"**:`POST` 除了要 `application/json`,还必须带 `Origin`、或 `Sec-Fetch-Site: same-origin`、或显式加上 `x-dsh-plugin-rack: 1` 请求头。
182
193
 
183
194
  这道门挡的是**无意的跨站 POST**(比如别处一个表单顺手打过来、或者某个页面被 XSS 后盲发请求),它对「完全不带 Origin」的请求本来是不设防的,而 `curl -X POST` 正好属于这一类。
184
195
 
@@ -186,7 +197,7 @@ POST /api/dsh-plugin-rack/uninstall { name }
186
197
  ```bash
187
198
  curl -X POST http://127.0.0.1:9546/api/dsh-plugin-rack/bulk \
188
199
  -H 'content-type: application/json' \
189
- -H 'x-dsh-plugin-dashboard: 1' \
200
+ -H 'x-dsh-plugin-rack: 1' \
190
201
  -d '{"enabled":false,"scope":"installed"}'
191
202
  ```
192
203
  3. **卸载白名单**:界面上的「卸载」按钮不是对所有行都显示。只有宿主 `pluginManager` 服务明确标记为 `removable` 的包才会出现按钮。核心组件、面板自己、harness 命脉都**永远不会**出现在可卸载列表里,所以误点卸载也不会把系统拆坏。
@@ -196,10 +207,25 @@ POST /api/dsh-plugin-rack/uninstall { name }
196
207
 
197
208
  ## 兼容性
198
209
 
210
+ - DSH `>=0.2.1-rc.2 <0.2.3-0`。已实测 `0.2.1-rc.2`、`0.2.1-rc.5`、`0.2.2-rc.1`、`0.2.2-rc.2`、`0.2.2-rc.3`;
211
+ 名单之外的版本会明说不确定,不会假装兼容。
199
212
  - DSH 的 Web 界面(`dsh.client.platform = web`)。
200
213
  - 需要宿主提供 `pluginManager` 服务;没有它会明确报错,而不是假装成功。
201
214
  - 热替换依赖宿主的 HMR 服务;拿不到时退回提示「重启一次生效」,**不会骗你说已经生效了**。
202
215
 
216
+ ## 开发
217
+
218
+ ```bash
219
+ npm ci # 只装 eslint + typescript(开发依赖,发布包不含 node_modules)
220
+ npm test # 六套:自检 / 行为 / 范围算术 / 后台作业 / 下载 / pnpm 启动
221
+ npm run lint # eslint(no-dupe-keys、no-undef 在这套配置下抓出过两处真 bug)
222
+ npm run types # tsc --checkJs,只覆盖 lib/(JSDoc 当类型,不引入构建步骤)
223
+ ```
224
+
225
+ - 范围算术只有一份实现:`lib/spec.js`。`index.js` import 它,`test/spec.mjs` 直接跑它,所以「测试通过的是一套逻辑、线上跑的是另一套」不会发生。
226
+ - CI 在 Windows 上跑 Node 20 与 22;另有一格把 `npm pack` 出来的 tarball 解压再跑一遍测试——`files` 里漏了哪个文件,本地是发现不了的。
227
+ - 发布:`NPM_TOKEN=… npm run publish`。token 只从环境变量读,版本已存在会直接拒绝,不会落进任何文件。
228
+
203
229
  ## English
204
230
 
205
231
  A plugin dashboard panel for DSH (DeepSeek Harness): every plugin in one list, a power switch per plugin,
package/client.js CHANGED
@@ -1,20 +1,13 @@
1
1
  /**
2
- * Client half of the plugin dashboard.
2
+ * Client half of the plugin rack.
3
3
  *
4
- * Registers a sidebar entry and the matching main panel. Every write goes to
5
- * the Host half over the same-origin JSON route, so nothing here depends on the
6
- * Client-side Remote access policy.
4
+ * GENERATED FILE — edit src/client/*.js and run `node scripts/build-client.mjs`.
5
+ * The module loader takes one plain script, so the parts are concatenated here
6
+ * instead of being imported at runtime.
7
7
  */
8
8
  window.__ModuleLoader__.load({
9
9
  id: 'dsh-plugin-rack',
10
10
  factory(require) {
11
- const React = require('react');
12
- const h = React.createElement;
13
-
14
- const PANEL_ID = 'plugin-rack';
15
- const API = '/api/dsh-plugin-rack';
16
- const NS = 'plugin-rack';
17
-
18
11
  const DICT = {
19
12
  en: {
20
13
  title: 'Plugin rack',
@@ -145,6 +138,8 @@ window.__ModuleLoader__.load({
145
138
  updateRestart: 'Restart DSH once to load the new versions',
146
139
  updatePartialSwap: 'Some packages are live, some need a restart',
147
140
  updateSwapped: 'New versions are live; refresh the page for client-side plugins',
141
+ updateOutOfRange: 'outside the declared range',
142
+ updateOutOfRangeHint: 'Newest {v} does not satisfy "{spec}" — not updated by default',
148
143
  hasNewVersion: 'Update available',
149
144
  updateNow: 'Update just this plugin',
150
145
  jobRunning: 'Host is updating in the background · {n} plugins',
@@ -199,8 +194,6 @@ window.__ModuleLoader__.load({
199
194
  groupAllAuto: '整组已设为自动',
200
195
  groupAllManual: '整组已设为手动',
201
196
  colName: '插件名称',
202
- colPower: '开启 / 关闭',
203
- colMode: '自动 / 手动',
204
197
  modeAuto: '自动',
205
198
  modeManual: '手动',
206
199
  filterAll: '全部',
@@ -305,6 +298,8 @@ window.__ModuleLoader__.load({
305
298
  updateRestart: '重启一次 DSH 让新版本生效',
306
299
  updatePartialSwap: '部分包已生效,部分需要重启',
307
300
  updateSwapped: '新版本已即时生效;界面类插件刷新页面即可',
301
+ updateOutOfRange: '超出声明范围',
302
+ updateOutOfRangeHint: '最新 {v} 不满足「{spec}」,默认不更新',
308
303
  hasNewVersion: '有新版',
309
304
  updateNow: '只更新这一个插件',
310
305
  jobRunning: '宿主正在后台更新 · {n} 个插件',
@@ -416,57 +411,6 @@ window.__ModuleLoader__.load({
416
411
  // working — otherwise the UI reports a failure for a run that succeeds.
417
412
  const WRITE_TIMEOUT_MS = 60 * 60 * 1000;
418
413
 
419
- async function request(path, body, options) {
420
- const timeoutMs =
421
- options && typeof options.timeoutMs === 'number' ? options.timeoutMs : DEFAULT_TIMEOUT_MS;
422
- const init =
423
- body === undefined
424
- ? { method: 'GET', headers: { accept: 'application/json' } }
425
- : {
426
- method: 'POST',
427
- headers: {
428
- 'content-type': 'application/json',
429
- accept: 'application/json',
430
- // 和宿主端的写操作闸门配对:面板自己永远带这个头,所以即使
431
- // 某个环境下浏览器没发 Origin / Sec-Fetch-Site 也照样放行。
432
- 'x-dsh-plugin-rack': '1',
433
- },
434
- body: JSON.stringify(body),
435
- };
436
- // Without this a stuck Host call pins busy/pending forever and the only
437
- // way out is reloading the page.
438
- const controller = new AbortController();
439
- init.signal = controller.signal;
440
- const timer = setTimeout(() => controller.abort(), timeoutMs);
441
- let response;
442
- try {
443
- response = await fetch(API + path, init);
444
- } catch (error) {
445
- void error;
446
- throw new Error(controller.signal.aborted ? 'timeout' : 'unreachable');
447
- } finally {
448
- clearTimeout(timer);
449
- }
450
- let data = null;
451
- try {
452
- data = await response.json();
453
- } catch (error) {
454
- void error;
455
- data = null;
456
- }
457
- // 服务端失败时常常带着细节(回滚那段 pnpm 输出在 data.failed.output 里),
458
- // 之前只取 message 就抛,于是「为什么失败」的信息到不了界面。把整个 data
459
- // 挂到 error 上,调用方要用就有。
460
- if (!response.ok || data === null || data.ok !== true) {
461
- const message =
462
- (data && data.message) || (response.ok ? 'malformed response' : response.status + ' ' + response.statusText);
463
- const error = new Error(message);
464
- if (data !== null && typeof data === 'object') error.data = data;
465
- throw error;
466
- }
467
- return data;
468
- }
469
-
470
414
  /**
471
415
  * 把勾选的包名按更新通道分组。
472
416
  *
@@ -699,6 +643,53 @@ window.__ModuleLoader__.load({
699
643
  });
700
644
  }
701
645
 
646
+
647
+ /**
648
+ * What to show at the right end of an installed plugin row.
649
+ *
650
+ * The cases are deliberately distinct: a plain npm install gets no label at all
651
+ * (it is the default — a label there is pure noise), a row installed some other
652
+ * way gets a small tag naming the source, and a row with an update gets a button
653
+ * — unless the release sits outside the version range the user wrote, in which
654
+ * case a button would be a lie, so it becomes a warning tag instead.
655
+ *
656
+ * @param {object|undefined} info the check result for this row, if any
657
+ * @param {string|undefined} src how the row itself was installed
658
+ * @returns {{kind: 'none'}|{kind: 'src', label: string}|{kind: 'outOfRange', label: string, spec: string}|{kind: 'update', label: string, source: string}}
659
+ */
660
+ function badgeOf(info, src) {
661
+ if (info === undefined || info === null) {
662
+ return src === undefined || src === 'npm' ? { kind: 'none' } : { kind: 'src', label: src };
663
+ }
664
+ if (info.outOfRange === true) {
665
+ return { kind: 'outOfRange', label: info.label ?? '', spec: info.spec ?? '' };
666
+ }
667
+ return { kind: 'update', label: info.label ?? '', source: info.source ?? 'npm' };
668
+ }
669
+
670
+ /**
671
+ * Which of the update page's states to render.
672
+ *
673
+ * 'none' used to be decided on hasUpdate alone, which made the page claim there
674
+ * was nothing to do while listing out-of-range releases underneath. Both counts
675
+ * decide it now.
676
+ *
677
+ * @param {{ok?: boolean, message?: string, rows?: Array<{hasUpdate?: boolean, outOfRange?: boolean}>}|null} updates
678
+ * @param {boolean} checking
679
+ * @returns {{kind: 'idle'|'checking'|'error'|'none'|'list', message?: string, updatable?: Array<object>, outOfRange?: Array<object>}}
680
+ */
681
+ function updatePageState(updates, checking) {
682
+ if (updates === null || updates === undefined) {
683
+ return { kind: checking === true ? 'checking' : 'idle' };
684
+ }
685
+ if (updates.ok !== true) return { kind: 'error', message: String(updates.message ?? 'error') };
686
+ const rows = Array.isArray(updates.rows) ? updates.rows : [];
687
+ const updatable = rows.filter((row) => row.hasUpdate === true);
688
+ const outOfRange = rows.filter((row) => row.outOfRange === true);
689
+ if (updatable.length === 0 && outOfRange.length === 0) return { kind: 'none' };
690
+ return { kind: 'list', updatable: updatable, outOfRange: outOfRange };
691
+ }
692
+
702
693
  const CSS = [
703
694
  '.pd-root{display:flex;flex-direction:column;height:100%;min-height:0;box-sizing:border-box;',
704
695
  'background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary)}',
@@ -832,6 +823,14 @@ window.__ModuleLoader__.load({
832
823
  '.pd-upbtn-open{text-decoration:none}',
833
824
  '.pd-src{flex:none;font-size:10px;line-height:16px;padding:0 6px;border-radius:6px;white-space:nowrap;',
834
825
  'border:1px solid var(--dsw-alias-border-l3);color:var(--dsw-alias-label-tertiary)}',
826
+ '.pd-src-warn{border-color:var(--dsw-alias-state-warn-primary);',
827
+ 'color:var(--dsw-alias-state-warn-label);background:var(--dsw-alias-state-warn-tertiary)}',
828
+ '.pd-up-section{margin-top:10px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary)}',
829
+ '.pd-up-oob{display:flex;flex-direction:column;gap:2px;margin-top:6px}',
830
+ '.pd-up-oob-row{display:flex;gap:8px;align-items:baseline;font-size:11px;line-height:18px;',
831
+ 'color:var(--dsw-alias-label-tertiary)}',
832
+ '.pd-up-oob-row b{font-weight:600;color:var(--dsw-alias-label-secondary)}',
833
+ '.pd-up-oob-row code{font-size:10px;opacity:.85}',
835
834
  '.pd-upbtn{display:inline-flex;align-items:center;gap:5px;flex:none;font-family:inherit;',
836
835
  'font-size:11px;line-height:16px;padding:1px 7px;border-radius:8px;white-space:nowrap;',
837
836
  'font-variant-numeric:tabular-nums;cursor:pointer;',
@@ -929,6 +928,8 @@ window.__ModuleLoader__.load({
929
928
  '.pd-update-row:hover{background:var(--dsw-alias-interactive-bg-hover)}',
930
929
  '.pd-update-name{flex:1;min-width:0;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
931
930
  '.pd-update-ver{flex:none;font-size:12px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}',
931
+ '.pd-update-spec{flex:none;font-family:inherit;font-size:10px;line-height:16px;padding:0 5px;border-radius:5px;',
932
+ 'border:1px solid var(--dsw-alias-border-l3);color:var(--dsw-alias-label-tertiary)}',
932
933
  '.pd-check{flex:none;width:15px;height:15px;margin:0;accent-color:var(--dsw-alias-state-business-primary);cursor:pointer}',
933
934
  '.pd-update-log{padding:10px 12px;border-radius:9px;font-size:12px;line-height:18px;background:var(--dsw-alias-bg-layer-2)}',
934
935
  '.pd-update-log-ok{border:1px solid var(--dsw-alias-state-success-primary)}',
@@ -938,6 +939,63 @@ window.__ModuleLoader__.load({
938
939
  '.pd-update-log-out{margin-top:6px;padding-top:6px;border-top:1px solid var(--dsw-alias-border-l3);font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary);white-space:pre-wrap;word-break:break-all;max-height:140px;overflow:auto}',
939
940
  ].join('');
940
941
 
942
+ const React = require('react');
943
+ const h = React.createElement;
944
+
945
+ const PANEL_ID = 'plugin-rack';
946
+ const API = '/api/dsh-plugin-rack';
947
+ const NS = 'plugin-rack';
948
+ async function request(path, body, options) {
949
+ const timeoutMs =
950
+ options && typeof options.timeoutMs === 'number' ? options.timeoutMs : DEFAULT_TIMEOUT_MS;
951
+ const init =
952
+ body === undefined
953
+ ? { method: 'GET', headers: { accept: 'application/json' } }
954
+ : {
955
+ method: 'POST',
956
+ headers: {
957
+ 'content-type': 'application/json',
958
+ accept: 'application/json',
959
+ // 和宿主端的写操作闸门配对:面板自己永远带这个头,所以即使
960
+ // 某个环境下浏览器没发 Origin / Sec-Fetch-Site 也照样放行。
961
+ 'x-dsh-plugin-rack': '1',
962
+ },
963
+ body: JSON.stringify(body),
964
+ };
965
+ // Without this a stuck Host call pins busy/pending forever and the only
966
+ // way out is reloading the page.
967
+ const controller = new AbortController();
968
+ init.signal = controller.signal;
969
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
970
+ let response;
971
+ try {
972
+ response = await fetch(API + path, init);
973
+ } catch (error) {
974
+ void error;
975
+ throw new Error(controller.signal.aborted ? 'timeout' : 'unreachable');
976
+ } finally {
977
+ clearTimeout(timer);
978
+ }
979
+ let data = null;
980
+ try {
981
+ data = await response.json();
982
+ } catch (error) {
983
+ void error;
984
+ data = null;
985
+ }
986
+ // 服务端失败时常常带着细节(回滚那段 pnpm 输出在 data.failed.output 里),
987
+ // 之前只取 message 就抛,于是「为什么失败」的信息到不了界面。把整个 data
988
+ // 挂到 error 上,调用方要用就有。
989
+ if (!response.ok || data === null || data.ok !== true) {
990
+ const message =
991
+ (data && data.message) || (response.ok ? 'malformed response' : response.status + ' ' + response.statusText);
992
+ const error = new Error(message);
993
+ if (data !== null && typeof data === 'object') error.data = data;
994
+ throw error;
995
+ }
996
+ return data;
997
+ }
998
+
941
999
  function SidebarIcon(props) {
942
1000
  const size = props && typeof props.size === 'number' ? props.size : 18;
943
1001
  return h(
@@ -1333,7 +1391,6 @@ window.__ModuleLoader__.load({
1333
1391
  setExtrasBusy('check');
1334
1392
  try {
1335
1393
  setExtras(await request('/extras'));
1336
- setExtraLog(null);
1337
1394
  } catch (error) {
1338
1395
  // 进面板那一次自动检查失败时静默,别一打开就弹错。
1339
1396
  if (quiet !== true) setExtras({ ok: false, message: String(error && error.message) });
@@ -1553,7 +1610,7 @@ window.__ModuleLoader__.load({
1553
1610
  const doBulk = async (ask) => {
1554
1611
  if (ask === null) return;
1555
1612
  setBulkAsk(null);
1556
- const { enabled, scope, total, save } = ask;
1613
+ const { enabled, scope, save } = ask;
1557
1614
  let accepted;
1558
1615
  try {
1559
1616
  accepted = await request('/bulk', {
@@ -1856,7 +1913,24 @@ window.__ModuleLoader__.load({
1856
1913
  if (typeof item.name !== 'string') continue;
1857
1914
  srcOf.set(item.name, 'npm');
1858
1915
  if (item.hasUpdate === true && typeof item.latest === 'string') {
1859
- updateOf.set(item.name, { label: 'v' + item.latest, source: 'npm', action: 'npm' });
1916
+ updateOf.set(item.name, {
1917
+ label: 'v' + item.latest,
1918
+ source: 'npm',
1919
+ action: 'npm',
1920
+ spec: typeof item.spec === 'string' ? item.spec : '',
1921
+ ranged: item.kind !== 'exact',
1922
+ });
1923
+ } else if (item.outOfRange === true && typeof item.latest === 'string') {
1924
+ // 服务端已经判定这个新版本不满足 package.json 里写的范围,
1925
+ // 批量更新也不会动它。主列表仍然要让人看见「有新版但不升」,
1926
+ // 否则用户只会觉得「检查更新坏了」。
1927
+ updateOf.set(item.name, {
1928
+ label: 'v' + item.latest,
1929
+ source: 'npm',
1930
+ action: 'npm',
1931
+ outOfRange: true,
1932
+ spec: typeof item.spec === 'string' ? item.spec : '',
1933
+ });
1860
1934
  }
1861
1935
  }
1862
1936
  }
@@ -2027,13 +2101,22 @@ window.__ModuleLoader__.load({
2027
2101
  { className: 'pd-name-act', onClick: (event) => event.stopPropagation() },
2028
2102
  (() => {
2029
2103
  const info = updateOf.get(row.name);
2030
- if (info === undefined) {
2031
- const src = srcOf.get(row.name);
2032
- // 普通 npm 装的是默认情况,标出来只是噪音;只有「不是按常规方式
2033
- // 装的」才值得在主列表里露一个标签。
2034
- return src === undefined || src === 'npm' ? null : h('span', { className: 'pd-src' }, src);
2035
- }
2104
+ const badge = badgeOf(info, srcOf.get(row.name));
2105
+ if (badge.kind === 'none') return null;
2106
+ if (badge.kind === 'src') return h('span', { className: 'pd-src' }, badge.label);
2036
2107
  const updating = pending['update:' + row.entryId] === true;
2108
+ if (badge.kind === 'outOfRange') {
2109
+ // 有新版、但不在 package.json 声明的范围内:这里不能给按钮,
2110
+ // 点了也不会升,只会让人以为按钮失灵。
2111
+ return h(
2112
+ 'span',
2113
+ {
2114
+ className: 'pd-src pd-src-warn',
2115
+ title: t('updateOutOfRangeHint', { v: badge.label, spec: badge.spec }),
2116
+ },
2117
+ t('updateOutOfRange'),
2118
+ );
2119
+ }
2037
2120
  const parts = [
2038
2121
  updating ? h('span', { key: 'spin', className: 'pd-spin pd-upbtn-spin' }) : null,
2039
2122
  'v' + (row.version ?? '?'),
@@ -2404,6 +2487,9 @@ window.__ModuleLoader__.load({
2404
2487
  }),
2405
2488
  h('span', { className: 'pd-update-name' }, row.name),
2406
2489
  h('span', { className: 'pd-update-ver' }, row.current + ' → ' + row.latest),
2490
+ typeof row.spec === 'string' && row.kind !== 'exact'
2491
+ ? h('code', { className: 'pd-update-spec', title: row.spec }, row.spec)
2492
+ : null,
2407
2493
  );
2408
2494
 
2409
2495
  const installPanel = h(
@@ -2529,17 +2615,39 @@ window.__ModuleLoader__.load({
2529
2615
  : String(logText.text).slice(-4000),
2530
2616
  ),
2531
2617
  ),
2532
- updates === null
2533
- ? h(
2618
+ (() => {
2619
+ const state = updatePageState(updates, updatesBusy === 'check');
2620
+ if (state.kind === 'list') return state.updatable.map(updateRowNode);
2621
+ if (state.kind === 'error') return h('div', { className: 'pd-empty' }, state.message);
2622
+ if (state.kind === 'none') return h('div', { className: 'pd-empty' }, t('updateNone'));
2623
+ const key = state.kind === 'checking' ? 'updateChecking' : 'updateIdle';
2624
+ return h('div', { className: 'pd-empty' }, t(key));
2625
+ })(),
2626
+ (() => {
2627
+ // 「有新版但超出范围」单独列一段:它不是错误,也不该被批量更新
2628
+ // 静默吞掉,放在主列表下面说明原因最省解释成本。
2629
+ const state = updatePageState(updates, false);
2630
+ const outOfRange = state.kind === 'list' ? state.outOfRange : [];
2631
+ if (outOfRange.length === 0) return null;
2632
+ return h(
2633
+ 'div',
2634
+ { className: 'pd-up-section' },
2635
+ h('div', null, t('updateOutOfRange') + ' (' + outOfRange.length + ')'),
2636
+ h(
2534
2637
  'div',
2535
- { className: 'pd-empty' },
2536
- updatesBusy === 'check' ? t('updateChecking') : t('updateIdle'),
2537
- )
2538
- : updates.ok !== true
2539
- ? h('div', { className: 'pd-empty' }, String(updates.message || 'error'))
2540
- : updates.rows.filter((row) => row.hasUpdate).length === 0
2541
- ? h('div', { className: 'pd-empty' }, t('updateNone'))
2542
- : updates.rows.filter((row) => row.hasUpdate).map(updateRowNode),
2638
+ { className: 'pd-up-oob' },
2639
+ outOfRange.map((row) =>
2640
+ h(
2641
+ 'div',
2642
+ { key: row.name, className: 'pd-up-oob-row' },
2643
+ h('b', null, row.name),
2644
+ h('span', null, row.current + ' → ' + row.latest),
2645
+ typeof row.spec === 'string' ? h('code', null, row.spec) : null,
2646
+ ),
2647
+ ),
2648
+ ),
2649
+ );
2650
+ })(),
2543
2651
  updateLog === null
2544
2652
  ? null
2545
2653
  : h(
@@ -2837,7 +2945,6 @@ window.__ModuleLoader__.load({
2837
2945
 
2838
2946
  return Dashboard;
2839
2947
  }
2840
-
2841
2948
  return {
2842
2949
  inject: ['slots', 'locale', 'layout'],
2843
2950
  apply(ctx) {