@eya46/dsh-plugin-info 0.0.2 → 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 CHANGED
@@ -2,15 +2,23 @@
2
2
 
3
3
  面向 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的插件信息页。包名 `@eya46/dsh-plugin-info`,挂到当前 Profile 后,设置里会出现「插件信息」分区。
4
4
 
5
- 它只看 **Profile 用户自己加的插件**(`profiles/<name>/package.json` 的 `dependencies`),不把 `@deepseek-ai/dsh-base` 这类随发行版带的 bundle 算进去。默认仅展示 `@eya46/` 命名空间下的插件(见下方 `allowScopes`),可按需放开。
5
+ 它只看 **Profile 用户自己加的插件**(`profiles/<name>/package.json` 的 `dependencies`),不把 `@deepseek-ai/dsh-base` 这类随发行版带的 bundle 算进去。默认展示全部用户插件;如需只看特定命名空间,可配置 `allowScopes`。
6
6
 
7
7
  - 列出已安装插件、当前版本、来源(npm / 本地 / git)
8
8
  - 对 registry 安装的包对照 `dist-tags.latest`,标出可更新
9
+ - 对 **GitHub Release 直装**的包(依赖是 `github.com/…/releases/download/<tag>/<file>.tgz` 链接),从依赖链接里解析出仓库和当前版本,对照该仓库的最新 Release 标出可更新——仓库与版本全部取自 profile 里的依赖信息和已安装的包信息,无需任何按插件配置;更新命令直接指向按新版本重写过的 tarball 链接,版本列表也会展示该仓库最近的 Release,点开可读对应 Release 说明
10
+ - 可更新时一键复制 `dsh plugin --profile <当前 profile> add <包>@latest`(profile 从当前运行的 Profile 清单解析,web 端通常是 `web`)
9
11
  - 展开后默认看最近 **10** 条版本
10
12
  - 点某个版本读更新说明:优先 GitHub Release,没有就回落到相邻版本的 compare / commit
11
13
 
12
14
  实现方式对齐 [dsh-model-meta-autofill](https://github.com/QJAG1024/dsh-model-meta-autofill):Host 注册本机 HTTP API,Client half 通过 `dsh.client` 挂到 `settings.section`。
13
15
 
16
+ ### 可选:dsh-better-sidebar 标签页
17
+
18
+ 若 Profile 同时安装了 [`dsh-better-sidebar`](https://github.com/omdsh-dev/DSH-better-sidebar) (≥0.4.0),本插件会自动在侧边栏 `+` 菜单里多注册一个**插件信息**标签页(id `@eya46/dsh-plugin-info`,order 90,单实例),内容与设置页完全一致——侧边栏较窄,内部已做滚动/宽度适配。
19
+
20
+ 实现是**软依赖**:不写 `peerDependencies`,不 `import 'dsh-better-sidebar'`,只用 Cordis 的 `ctx.inject(['betterSidebar'], ...)` 开一个子 fiber——未安装时回调永不触发,其他功能不受影响;禁用 / HMR 任一插件时 disposer 随各自 fiber 清理,不会残留 `"already registered"`。
21
+
14
22
  ## 安装
15
23
 
16
24
  在仓库根目录构建后,把它加进当前 Profile:
@@ -29,7 +37,7 @@ dsh plugin --profile web add "file:./packages/dsh-plugin-info"
29
37
 
30
38
  | 端点 | 说明 |
31
39
  | --- | --- |
32
- | `GET /plugin-info/api/plugins` | 当前 Profile 用户插件 + 是否有更新 |
40
+ | `GET /plugin-info/api/plugins` | 当前 Profile 用户插件 + 是否有更新;含 `profileCli`,可更新的 registry 包还会带 `updateCommand` |
33
41
  | `GET /plugin-info/api/versions?name=<pkg>&limit=10` | 最近版本,默认 10 条 |
34
42
  | `GET /plugin-info/api/notes?name=<pkg>&version=<ver>` | 该版本的更新说明 |
35
43
 
@@ -41,7 +49,7 @@ dsh plugin --profile web add "file:./packages/dsh-plugin-info"
41
49
  | `defaultVersionLimit` | `10` | 默认版本条数 |
42
50
  | `timeoutMs` | `15000` | 注册表 / GitHub 超时 |
43
51
  | `cacheTtlMs` | `300000` | packument 与说明缓存 |
44
- | `allowScopes` | `['@eya46/']` | 名称前缀白名单;为空数组时展示全部用户插件 |
52
+ | `allowScopes` | 无(展示全部) | 名称前缀白名单;省略或为空数组时展示全部用户插件 |
45
53
 
46
54
  覆盖写在 Profile 的 `cordis.patch.yml`:
47
55
 
package/lib/client.js CHANGED
@@ -7,6 +7,11 @@
7
7
  *
8
8
  * @module @eya46/dsh-plugin-info/client
9
9
  */
10
+ /** Labels for the MarkdownText copy button / footnotes heading (settings UI is zh). */
11
+ const MARKDOWN_LABELS = {
12
+ code: { copyLabel: '复制代码', copiedLabel: '已复制' },
13
+ footnotes: '脚注',
14
+ };
10
15
  ;
11
16
  (() => {
12
17
  const loader = window.__ModuleLoader__;
@@ -25,12 +30,17 @@
25
30
  '.dpi-rowButton { height: 28px; padding: 0 10px; font-size: 12px; line-height: 18px; }',
26
31
  '.dpi-secondaryButton:hover:not(:disabled), .dpi-rowButton:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover-solid); }',
27
32
  '.dpi-secondaryButton:disabled, .dpi-rowButton:disabled { opacity: 0.4; cursor: default; }',
28
- '.dpi-secondaryButton:focus-visible, .dpi-rowButton:focus-visible, .dpi-head:focus-visible, .dpi-version:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--dsw-alias-border-l3); }',
33
+ '.dpi-secondaryButton:focus-visible, .dpi-rowButton:focus-visible, .dpi-head:focus-visible, .dpi-version:focus-visible, .dpi-updateCopy:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--dsw-alias-border-l3); }',
29
34
  '.dpi-status { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }',
30
35
  '.dpi-banner { margin: 0; font-size: 12px; line-height: 18px; color: var(--dsw-alias-state-error-primary); }',
31
36
  '.dpi-empty { color: var(--dsw-alias-label-tertiary); font-size: 14px; line-height: 22px; }',
32
37
  '.dpi-cards { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 10px; }',
33
38
  '.dpi-card { border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; overflow: hidden; }',
39
+ '.dpi-cardTop { display: flex; align-items: stretch; gap: 0; }',
40
+ '.dpi-cardTop .dpi-head { flex: 1; min-width: 0; }',
41
+ '.dpi-updateCopy { box-sizing: border-box; flex-shrink: 0; align-self: center; height: 28px; margin: 0 12px 0 0; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l3); border-radius: 14px; background: transparent; color: var(--dsw-alias-state-warn-label); font: inherit; font-size: 12px; line-height: 18px; cursor: pointer; }',
42
+ '.dpi-updateCopy:hover { background: var(--dsw-alias-interactive-bg-hover-solid); }',
43
+ '.dpi-command { margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); word-break: break-all; }',
34
44
  '.dpi-head { box-sizing: border-box; width: 100%; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 12px 14px; border: none; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }',
35
45
  '.dpi-head:hover { background: var(--dsw-alias-interactive-bg-hover-solid); }',
36
46
  '.dpi-name { font-size: 14px; line-height: 22px; font-weight: 500; }',
@@ -46,14 +56,52 @@
46
56
  '.dpi-version:hover { background: var(--dsw-alias-interactive-bg-hover-solid); }',
47
57
  '.dpi-version[data-open="1"] { background: var(--dsw-alias-interactive-bg-hover-solid); }',
48
58
  '.dpi-notes { display: flex; flex-direction: column; gap: 6px; padding: 8px 2px 2px; }',
49
- '.dpi-notesBody { margin: 0; white-space: pre-wrap; font-size: 13px; line-height: 20px; color: var(--dsw-alias-label-secondary); }',
50
59
  '.dpi-commits { margin: 0; padding-left: 18px; font-size: 13px; line-height: 20px; }',
51
60
  '.dpi-link { color: inherit; text-underline-offset: 3px; }',
52
61
  '.dpi-copyTag { box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l3); color: var(--dsw-alias-label-secondary); background: transparent; border-radius: 4px; padding: 1px 6px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; line-height: 16px; cursor: pointer; }',
53
62
  '.dpi-copyTag:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-border-l2); }',
63
+ // Sidebar tab wrapper: the panel sits in a narrow right-side column
64
+ // (default 420px), where PluginInfoPanel's 720px max-width and
65
+ // non-scrolling body would overflow. Force full width + own scroll.
66
+ '.dpi-sidebar { box-sizing: border-box; width: 100%; height: 100%; overflow-y: auto; overflow-x: hidden; padding: 12px 14px 16px; background: var(--dsw-alias-bg-base); }',
67
+ '.dpi-sidebar .dpi-section { max-width: none; gap: 10px; }',
68
+ '.dpi-sidebar .dpi-card .dpi-head { padding: 10px 12px; }',
69
+ '.dpi-sidebar .dpi-card .dpi-body { padding: 0 12px 12px; }',
70
+ '.dpi-sidebar .dpi-actions { gap: 6px; }',
71
+ '.dpi-sidebar .dpi-rowButton { height: 26px; padding: 0 8px; }',
72
+ '.dpi-sidebar .dpi-updateCopy { height: 26px; margin-right: 10px; padding: 0 8px; }',
54
73
  ].join('\n');
55
74
  const React = require('react');
75
+ // Reuse DSH's shared untrusted-Markdown renderer instead of rolling our
76
+ // own — it ships in the web shell's static module table and handles GFM,
77
+ // math, code fences, and sanitization (raw HTML / unsafe URLs stripped).
78
+ const uiPrimitives = require('@deepseek-ai/dsh-client-ui-primitives');
79
+ const MarkdownText = uiPrimitives.MarkdownText;
56
80
  const h = React.createElement.bind(React);
81
+ const ReactComponent = React.Component;
82
+ /**
83
+ * MarkdownText is an undocumented shell primitive whose render can throw
84
+ * (labels shape drift, markdown edge cases). Contain any failure to this
85
+ * one note body instead of crashing the whole settings section.
86
+ */
87
+ class SafeMarkdown extends ReactComponent {
88
+ constructor(props) {
89
+ super(props);
90
+ this.state = { failed: false };
91
+ }
92
+ static getDerivedStateFromError() {
93
+ return { failed: true };
94
+ }
95
+ componentDidCatch(error) {
96
+ console.warn('[dsh-plugin-info] MarkdownText failed; falling back to plain text', error);
97
+ }
98
+ render() {
99
+ if (this.state.failed === true) {
100
+ return h('pre', { className: 'dpi-id', style: { whiteSpace: 'pre-wrap', margin: 0 } }, this.props.text);
101
+ }
102
+ return h(MarkdownText, { text: this.props.text, labels: MARKDOWN_LABELS });
103
+ }
104
+ }
57
105
  const ensureStyles = () => {
58
106
  if (document.querySelector('style[data-plugin="@eya46/dsh-plugin-info"]') !== null)
59
107
  return;
@@ -135,17 +183,18 @@
135
183
  const [notes, setNotes] = React.useState({});
136
184
  const [notesBusy, setNotesBusy] = React.useState('');
137
185
  const [copied, setCopied] = React.useState({});
138
- const copyName = React.useCallback((name) => {
186
+ const [profileCli, setProfileCli] = React.useState('web');
187
+ const copyText = React.useCallback((key, text) => {
139
188
  const write = () => {
140
189
  if (typeof navigator !== 'undefined' && navigator.clipboard !== undefined) {
141
- return navigator.clipboard.writeText(name);
190
+ return navigator.clipboard.writeText(text);
142
191
  }
143
192
  return Promise.reject(new Error('clipboard unavailable'));
144
193
  };
145
194
  write()
146
195
  .then(() => {
147
- setCopied((prev) => ({ ...prev, [name]: true }));
148
- window.setTimeout(() => setCopied((prev) => ({ ...prev, [name]: false })), 1200);
196
+ setCopied((prev) => ({ ...prev, [key]: true }));
197
+ window.setTimeout(() => setCopied((prev) => ({ ...prev, [key]: false })), 1200);
149
198
  })
150
199
  .catch(() => { });
151
200
  }, []);
@@ -157,6 +206,8 @@
157
206
  if (!Array.isArray(data.plugins))
158
207
  throw new Error(data.message ?? 'unexpected response');
159
208
  setRows(data.plugins);
209
+ if (typeof data.profileCli === 'string' && data.profileCli.length > 0)
210
+ setProfileCli(data.profileCli);
160
211
  setError('');
161
212
  })
162
213
  .catch((caught) => setError(String(caught)))
@@ -212,7 +263,7 @@
212
263
  const updateCount = rows.filter((row) => row.hasUpdate).length;
213
264
  const children = [
214
265
  h('h2', { key: 'title', className: 'dpi-title' }, '插件信息'),
215
- h('p', { key: 'intro', className: 'dpi-intro' }, '当前 Profile 里 @eya46 命名空间下的插件。展开后默认看最近 10 个版本,点版本可看更新说明。'),
266
+ h('p', { key: 'intro', className: 'dpi-intro' }, '当前 Profile(' + profileCli + ')里用户添加的插件(不含随发行版内置的 bundle)。有更新时可一键复制 dsh plugin 命令;展开后默认看最近 10 个版本,点版本可看更新说明。'),
216
267
  h('div', { key: 'toolbar', className: 'dpi-toolbar' }, h('button', { className: 'dpi-secondaryButton', disabled: busy, onClick: () => { load(); } }, busy ? '刷新中…' : '刷新'), h('span', { className: 'dpi-status' }, '共 ' + rows.length + ' 个插件' + (updateCount > 0 ? ' · ' + updateCount + ' 个可更新' : ''))),
217
268
  ];
218
269
  if (error !== '')
@@ -232,14 +283,20 @@
232
283
  if (plugin.hasUpdate && plugin.latestVersion !== undefined) {
233
284
  head.push(h('span', { key: 'up', className: 'dpi-tag', 'data-tone': 'warn' }, '可更新 ' + plugin.latestVersion));
234
285
  }
235
- else if (plugin.source === 'registry' && plugin.latestVersion !== undefined) {
286
+ else if (!plugin.hasUpdate && plugin.latestVersion !== undefined) {
236
287
  head.push(h('span', { key: 'ok', className: 'dpi-tag', 'data-tone': 'ok' }, '已是最新'));
237
288
  }
289
+ const updateCommand = plugin.updateCommand
290
+ ?? ('dsh plugin --profile ' + profileCli + ' add ' + plugin.name + '@latest');
291
+ const updateCopied = copied['update:' + plugin.name] === true;
238
292
  const body = [];
239
293
  if (expanded) {
240
294
  if (plugin.description !== undefined)
241
295
  body.push(h('p', { key: 'desc', className: 'dpi-meta' }, plugin.description));
242
296
  body.push(h('p', { key: 'spec', className: 'dpi-meta' }, '依赖:', h('span', { className: 'dpi-id' }, plugin.spec), plugin.latestVersion === undefined ? null : ' · 最新 ' + plugin.latestVersion));
297
+ if (plugin.hasUpdate) {
298
+ body.push(h('p', { key: 'upd', className: 'dpi-command' }, updateCommand));
299
+ }
243
300
  if (plugin.error !== undefined)
244
301
  body.push(h('p', { key: 'perr', className: 'dpi-banner' }, plugin.error));
245
302
  const links = [
@@ -247,7 +304,7 @@
247
304
  key: 'copy',
248
305
  className: 'dpi-copyTag',
249
306
  title: '复制包名',
250
- onClick: () => { copyName(plugin.name); },
307
+ onClick: () => { copyText(plugin.name, plugin.name); },
251
308
  }, copied[plugin.name] === true ? '已复制' : plugin.name),
252
309
  h('button', {
253
310
  key: 'vers',
@@ -255,6 +312,14 @@
255
312
  onClick: () => { loadVersions(plugin.name); },
256
313
  }, versions[plugin.name] === undefined ? '加载版本' : '重新加载版本'),
257
314
  ];
315
+ if (plugin.hasUpdate) {
316
+ links.push(h('button', {
317
+ key: 'upd',
318
+ className: 'dpi-rowButton',
319
+ title: updateCommand,
320
+ onClick: () => { copyText('update:' + plugin.name, updateCommand); },
321
+ }, updateCopied ? '已复制更新命令' : '复制更新命令'));
322
+ }
258
323
  if (plugin.homepage !== undefined) {
259
324
  links.push(h('a', { key: 'home', className: 'dpi-rowButton dpi-link', href: plugin.homepage, target: '_blank', rel: 'noreferrer' }, '主页'));
260
325
  }
@@ -271,7 +336,7 @@
271
336
  body.push(h('p', { key: 'vload', className: 'dpi-meta' }, '正在加载最近 10 个版本…'));
272
337
  }
273
338
  else if (pluginVersions.length === 0) {
274
- body.push(h('p', { key: 'vempty', className: 'dpi-meta' }, plugin.source === 'registry' ? '注册表没有版本记录。' : '本地 / git 安装没有更多历史版本。'));
339
+ body.push(h('p', { key: 'vempty', className: 'dpi-meta' }, plugin.source === 'registry' ? '注册表没有版本记录。' : '没有可展示的历史版本。'));
275
340
  }
276
341
  else {
277
342
  const versionNodes = pluginVersions.map((row) => {
@@ -296,8 +361,9 @@
296
361
  else if (note.source === 'github-release') {
297
362
  if (note.title !== undefined)
298
363
  noteChildren.push(h('p', { key: 'nt', className: 'dpi-meta' }, note.title));
299
- if (note.body !== undefined)
300
- noteChildren.push(h('pre', { key: 'nb', className: 'dpi-notesBody' }, note.body));
364
+ if (note.body !== undefined && note.body !== '') {
365
+ noteChildren.push(h(SafeMarkdown, { key: 'nb', text: note.body }));
366
+ }
301
367
  if (note.url !== undefined) {
302
368
  noteChildren.push(h('a', { key: 'nu', className: 'dpi-link', href: note.url, target: '_blank', rel: 'noreferrer' }, '在 GitHub 查看 Release'));
303
369
  }
@@ -309,7 +375,10 @@
309
375
  }
310
376
  }
311
377
  else {
312
- noteChildren.push(h('p', { key: 'nnone', className: 'dpi-meta' }, note.body ?? '这个版本没有找到 GitHub Release 或提交说明。'));
378
+ const fallback = note.body ?? '这个版本没有找到 GitHub Release 或提交说明。';
379
+ // Error payloads and the "none" source are plain text; keep
380
+ // them literal rather than feeding them through Markdown.
381
+ noteChildren.push(h('p', { key: 'nnone', className: 'dpi-meta' }, fallback));
313
382
  }
314
383
  }
315
384
  return h('div', { key: row.version }, h('button', {
@@ -321,15 +390,66 @@
321
390
  body.push(h('div', { key: 'versions', className: 'dpi-versions' }, ...versionNodes));
322
391
  }
323
392
  }
324
- cards.push(h('li', { key: plugin.name, className: 'dpi-card' }, h('button', {
393
+ cards.push(h('li', { key: plugin.name, className: 'dpi-card' }, h('div', { className: 'dpi-cardTop' }, h('button', {
325
394
  className: 'dpi-head',
326
395
  'aria-expanded': expanded,
327
396
  onClick: () => { togglePlugin(plugin.name); },
328
- }, ...head), expanded ? h('div', { className: 'dpi-body' }, ...body) : null));
397
+ }, ...head), plugin.hasUpdate
398
+ ? h('button', {
399
+ className: 'dpi-updateCopy',
400
+ title: updateCommand,
401
+ onClick: (event) => {
402
+ event.stopPropagation?.();
403
+ copyText('update:' + plugin.name, updateCommand);
404
+ },
405
+ }, updateCopied ? '已复制' : '复制更新命令')
406
+ : null), expanded ? h('div', { className: 'dpi-body' }, ...body) : null));
329
407
  }
330
408
  children.push(h('ul', { key: 'cards', className: 'dpi-cards' }, ...cards));
331
409
  return h('div', { className: 'dpi-section' }, ...children);
332
410
  };
411
+ /**
412
+ * Sidebar-tab wrapper: same content as the settings panel, but hosted in
413
+ * the narrow right-side better-sidebar pane where it must manage its own
414
+ * vertical scroll and drop the 720px max-width.
415
+ */
416
+ const PluginInfoSidebarPanel = () => {
417
+ return h('div', { className: 'dpi-sidebar' }, h(PluginInfoPanel, null));
418
+ };
419
+ /** Small Cordis-plugin outline glyph (same geometry as the settings
420
+ * nav uses, suitable for the sidebar's 16px tab icon slot). */
421
+ const PluginIcon = (size) => {
422
+ return h('svg', {
423
+ width: size,
424
+ height: size,
425
+ viewBox: '0 0 14 14',
426
+ fill: 'none',
427
+ 'aria-hidden': true,
428
+ }, h('path', {
429
+ fill: 'currentColor',
430
+ d: 'M3.03426 5.66661L1.70084 7.00003L3.0315 8.33069L2.14762 9.21457L-0.0669245 7.00003L2.15038 4.78273L3.03426 5.66661ZM7 14.067L4.77924 11.8462L5.66313 10.9623L7 12.2992L8.33342 10.9658L9.2173 11.8496L7 14.067ZM11.8489 9.21803L10.965 8.33414L12.2992 7.00003L10.9623 5.66316L11.8462 4.77927L14.0669 7.00003L11.8489 9.21803ZM8.33066 3.03153L7 1.70087L5.66589 3.03498L4.782 2.1511L7 -0.0668945L9.21454 2.14765L8.33066 3.03153Z',
431
+ }), h('rect', { x: 5.98535, y: 5.98535, width: 2.02942, height: 2.02942, fill: 'currentColor' }));
432
+ };
433
+ /**
434
+ * Register the sidebar tab ONLY when dsh-better-sidebar is present. The
435
+ * child fiber waits for the `betterSidebar` service; if that plugin is
436
+ * not installed the callback never runs and the rest of this plugin is
437
+ * unaffected. The disposer returned by registerTab is attached to the
438
+ * child fiber, so disabling/HMR of either side cleans up cleanly.
439
+ */
440
+ const registerSidebarTab = (ctx) => {
441
+ ctx.inject(['betterSidebar'], ((sctx) => {
442
+ const service = sctx.betterSidebar;
443
+ sctx.effect(() => service.registerTab({
444
+ id: '@eya46/dsh-plugin-info',
445
+ title: '插件信息',
446
+ icon: (size) => PluginIcon(size),
447
+ order: 90,
448
+ single: true,
449
+ component: PluginInfoSidebarPanel,
450
+ }), '@eya46/dsh-plugin-info: better-sidebar tab');
451
+ }));
452
+ };
333
453
  return {
334
454
  name: 'dsh-plugin-info',
335
455
  inject: ['slots'],
@@ -338,6 +458,9 @@
338
458
  ctx.slots.inject('settings.section', () => {
339
459
  ctx.slots.register({ name: 'settings.section', id: 'plugin-info', order: 16, label: '插件信息' }, PluginInfoPanel);
340
460
  });
461
+ // Optional injection into dsh-better-sidebar: no-op when that
462
+ // plugin is absent from the profile.
463
+ registerSidebarTab(ctx);
341
464
  },
342
465
  };
343
466
  },
package/lib/index.js CHANGED
@@ -64,6 +64,11 @@ function compareParsed(left, right) {
64
64
  }
65
65
  return 0;
66
66
  }
67
+ /** Version embedded in a release tag: `v2.0.3`, `2.0.3`, or `panel-1.20.3-rc.1`. */
68
+ function versionFromTag(tag) {
69
+ if (parseVersion(tag) !== void 0) return normalizeVersion(tag);
70
+ return /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/.exec(tag)?.[1];
71
+ }
67
72
  /** True when `latest` is a newer published version than `installed`. */
68
73
  function hasNewerVersion(installed, latest) {
69
74
  if (installed === void 0 || latest === void 0) return false;
@@ -75,6 +80,28 @@ function hasNewerVersion(installed, latest) {
75
80
  }
76
81
  //#endregion
77
82
  //#region src/profile.ts
83
+ /** Prefix DSH writes onto a profile package.json `name`. */
84
+ const PROFILE_PACKAGE_PREFIX = "dsh-profile-";
85
+ /**
86
+ * CLI `--profile` argument for this profile directory.
87
+ *
88
+ * DSH names the manifest `dsh-profile-<dir>` and the CLI flag is the
89
+ * directory basename (`web`, `tui`, …). Prefer the manifest prefix so temp
90
+ * test dirs still resolve; fall back to the folder name, then `web`.
91
+ */
92
+ function profileCliName(manifestName, profileDir) {
93
+ if (typeof manifestName === "string" && manifestName.startsWith(PROFILE_PACKAGE_PREFIX)) {
94
+ const id = manifestName.slice(12).trim();
95
+ if (id.length > 0) return id;
96
+ }
97
+ const dirName = profileDir.replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? "";
98
+ if (dirName.length > 0 && dirName !== "." && dirName !== ".." && !/^[A-Za-z]:$/.test(dirName)) return dirName;
99
+ return "web";
100
+ }
101
+ /** `dsh plugin add …@latest` command for one registry package in this profile. */
102
+ function pluginUpdateCommand(profileCli, packageName) {
103
+ return `dsh plugin --profile ${profileCli} add ${packageName}@latest`;
104
+ }
78
105
  /** Classify a profile dependency spec. */
79
106
  function classifySpec(spec) {
80
107
  const trimmed = spec.trim();
@@ -154,6 +181,47 @@ function parseGithubRepo(input) {
154
181
  };
155
182
  }
156
183
  }
184
+ const GITHUB_RELEASE_SPEC = /^https?:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/releases\/download\/([^/\s]+)\/([^/\s]+)$/i;
185
+ /**
186
+ * Parse a `github.com/<owner>/<repo>/releases/download/<tag>/<asset>`
187
+ * dependency spec into its repository, tag, and asset. Everything is derived
188
+ * from the spec itself — no per-plugin configuration.
189
+ */
190
+ function parseGithubReleaseSpec(spec) {
191
+ const match = GITHUB_RELEASE_SPEC.exec(spec.trim());
192
+ if (match === null) return void 0;
193
+ const [rawOwner, rawRepo, rawTag, rawAsset] = match.slice(1);
194
+ if (rawOwner === void 0 || rawRepo === void 0 || rawTag === void 0 || rawAsset === void 0) return void 0;
195
+ try {
196
+ return {
197
+ owner: decodeURIComponent(rawOwner),
198
+ repo: decodeURIComponent(rawRepo),
199
+ tag: decodeURIComponent(rawTag),
200
+ asset: decodeURIComponent(rawAsset)
201
+ };
202
+ } catch {
203
+ return {
204
+ owner: rawOwner,
205
+ repo: rawRepo,
206
+ tag: rawTag,
207
+ asset: rawAsset
208
+ };
209
+ }
210
+ }
211
+ /**
212
+ * Rewrite a release spec onto a newer tag. The asset file name keeps its
213
+ * shape: an embedded version token equal to `oldVersion` is replaced by
214
+ * `newVersion` (`dsh-skill-mcp-panel-2.0.3.tgz` → `…-2.0.4.tgz`) without
215
+ * touching longer version numbers (`12.0.3`) that merely contain it.
216
+ */
217
+ function releaseDownloadUrl(spec, newTag, newVersion, oldVersion) {
218
+ return `https://github.com/${spec.owner}/${spec.repo}/releases/download/${encodeURIComponent(newTag)}/${encodeURIComponent(replaceVersionToken(spec.asset, oldVersion, newVersion))}`;
219
+ }
220
+ function replaceVersionToken(asset, from, to) {
221
+ if (from.length === 0 || from === to) return asset;
222
+ const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
223
+ return asset.replace(new RegExp(`(?<![\\d.])${escaped}(?!\\d)`), to);
224
+ }
157
225
  //#endregion
158
226
  //#region src/registry.ts
159
227
  /** Join a registry origin with a package name, encoding the scoped slash. */
@@ -208,6 +276,8 @@ var PluginInfoService = class {
208
276
  now;
209
277
  packuments = /* @__PURE__ */ new Map();
210
278
  notes = /* @__PURE__ */ new Map();
279
+ githubLatest = /* @__PURE__ */ new Map();
280
+ githubReleases = /* @__PURE__ */ new Map();
211
281
  constructor(config, fetchJson, now = () => Date.now()) {
212
282
  this.config = config;
213
283
  this.fetchJson = fetchJson;
@@ -216,10 +286,12 @@ var PluginInfoService = class {
216
286
  /** List user-added plugins for one profile directory. */
217
287
  async listPlugins(profileDir) {
218
288
  const manifest = readProfileManifest(profileDir);
289
+ const profileCli = profileCliName(manifest.name, profileDir);
219
290
  const listed = listUserPlugins(manifest).filter((item) => isAllowedScope(item.name, this.config.allowScopes));
220
- const plugins = await Promise.all(listed.map(async (item) => this.describePlugin(profileDir, item.name, item.spec)));
291
+ const plugins = await Promise.all(listed.map(async (item) => this.describePlugin(profileDir, profileCli, item.name, item.spec)));
221
292
  return {
222
293
  profile: manifest.name ?? profileDir,
294
+ profileCli,
223
295
  plugins
224
296
  };
225
297
  }
@@ -229,6 +301,10 @@ var PluginInfoService = class {
229
301
  const installed = readInstalledPackage(profileDir, name);
230
302
  if (plugin.source !== "registry") {
231
303
  const installedVersion = installed?.version;
304
+ const releaseSpec = parseGithubReleaseSpec(plugin.spec);
305
+ if (releaseSpec !== void 0) try {
306
+ return await this.githubReleaseVersions(name, installedVersion, releaseSpec, limit);
307
+ } catch {}
232
308
  return {
233
309
  name,
234
310
  ...installedVersion === void 0 ? {} : { installedVersion },
@@ -251,18 +327,18 @@ var PluginInfoService = class {
251
327
  }
252
328
  /** Update notes for one published version. */
253
329
  async versionNotes(profileDir, name, version) {
254
- findUserPlugin(profileDir, name, this.config.allowScopes);
330
+ const plugin = findUserPlugin(profileDir, name, this.config.allowScopes);
255
331
  const cacheKey = `${name}@${version}`;
256
332
  const cached = this.notes.get(cacheKey);
257
333
  if (cached !== void 0 && cached.expiresAt > this.now()) return cached.value;
258
- const notes = await this.loadNotes(profileDir, name, version);
334
+ const notes = await this.loadNotes(profileDir, name, version, plugin.spec);
259
335
  this.notes.set(cacheKey, {
260
336
  value: notes,
261
337
  expiresAt: this.now() + this.config.cacheTtlMs
262
338
  });
263
339
  return notes;
264
340
  }
265
- async describePlugin(profileDir, name, spec) {
341
+ async describePlugin(profileDir, profileCli, name, spec) {
266
342
  const source = classifySpec(spec);
267
343
  const installed = readInstalledPackage(profileDir, name);
268
344
  const installedRepository = repositoryUrlOf(installed?.repository);
@@ -276,17 +352,19 @@ var PluginInfoService = class {
276
352
  ...installed?.homepage === void 0 ? {} : { homepage: installed.homepage },
277
353
  ...installedRepository === void 0 ? {} : { repository: installedRepository }
278
354
  };
279
- if (source !== "registry") return summary;
355
+ if (source !== "registry") return this.describeReleaseSpecPlugin(profileCli, spec, summary);
280
356
  try {
281
357
  const packument = await this.loadPackument(name);
282
358
  const latestVersion = latestVersionOf(packument);
283
359
  const homepage = summary.homepage ?? packument.homepage;
284
360
  const repository = summary.repository ?? repositoryUrlOf(packument.repository);
361
+ const hasUpdate = hasNewerVersion(summary.installedVersion, latestVersion);
285
362
  return {
286
363
  ...summary,
287
364
  ...packument.description === void 0 || summary.description !== void 0 ? {} : { description: packument.description },
288
365
  ...latestVersion === void 0 ? {} : { latestVersion },
289
- hasUpdate: hasNewerVersion(summary.installedVersion, latestVersion),
366
+ hasUpdate,
367
+ ...hasUpdate ? { updateCommand: pluginUpdateCommand(profileCli, name) } : {},
290
368
  ...homepage === void 0 ? {} : { homepage },
291
369
  ...repository === void 0 ? {} : { repository }
292
370
  };
@@ -309,7 +387,106 @@ var PluginInfoService = class {
309
387
  });
310
388
  return packument;
311
389
  }
312
- async loadNotes(profileDir, name, version) {
390
+ /**
391
+ * The repo's latest published release (GitHub skips prereleases/drafts),
392
+ * or undefined when the repo has no releases. Negative answers are cached
393
+ * too so a repo without releases does not burn the API rate limit.
394
+ */
395
+ async loadGithubLatest(owner, repo) {
396
+ const key = `${owner}/${repo}`;
397
+ const cached = this.githubLatest.get(key);
398
+ if (cached !== void 0 && cached.expiresAt > this.now()) return cached.value;
399
+ let payload;
400
+ try {
401
+ payload = await this.fetchJson(`https://api.github.com/repos/${owner}/${repo}/releases/latest`, this.config.timeoutMs, githubHeaders());
402
+ } catch (error) {
403
+ if (error instanceof Error && error.message.startsWith("HTTP 404")) {
404
+ this.githubLatest.set(key, {
405
+ value: void 0,
406
+ expiresAt: this.now() + this.config.cacheTtlMs
407
+ });
408
+ return;
409
+ }
410
+ throw error;
411
+ }
412
+ const release = asGithubRelease(payload);
413
+ this.githubLatest.set(key, {
414
+ value: release,
415
+ expiresAt: this.now() + this.config.cacheTtlMs
416
+ });
417
+ return release;
418
+ }
419
+ /** Newest-first GitHub releases for one repo, cached per repo. */
420
+ async loadGithubReleases(owner, repo, limit) {
421
+ const key = `${owner}/${repo}`;
422
+ const cached = this.githubReleases.get(key);
423
+ if (cached !== void 0 && cached.expiresAt > this.now()) return cached.value;
424
+ const perPage = Math.min(100, Math.max(1, Math.trunc(limit)));
425
+ const payload = await this.fetchJson(`https://api.github.com/repos/${owner}/${repo}/releases?per_page=${perPage}`, this.config.timeoutMs, githubHeaders());
426
+ if (!Array.isArray(payload)) throw new Error(`GitHub returned a non-array releases payload for ${owner}/${repo}`);
427
+ const releases = payload.map((item) => asGithubRelease(item)).filter((item) => item !== void 0);
428
+ this.githubReleases.set(key, {
429
+ value: releases,
430
+ expiresAt: this.now() + this.config.cacheTtlMs
431
+ });
432
+ return releases;
433
+ }
434
+ /**
435
+ * Update detection for GitHub-release tarball installs: the dependency spec
436
+ * itself names the repository and the currently installed tag, so the
437
+ * latest release is looked up from that repo — nothing is hardcoded.
438
+ */
439
+ async describeReleaseSpecPlugin(profileCli, spec, summary) {
440
+ const releaseSpec = parseGithubReleaseSpec(spec);
441
+ if (releaseSpec === void 0) return summary;
442
+ const repository = `https://github.com/${releaseSpec.owner}/${releaseSpec.repo}`;
443
+ try {
444
+ const latest = await this.loadGithubLatest(releaseSpec.owner, releaseSpec.repo);
445
+ const latestVersion = latest === void 0 ? void 0 : versionFromTag(latest.tag);
446
+ const hasUpdate = hasNewerVersion(summary.installedVersion ?? versionFromTag(releaseSpec.tag), latestVersion);
447
+ const oldVersion = versionFromTag(releaseSpec.tag) ?? summary.installedVersion;
448
+ const updateUrl = latest !== void 0 && latestVersion !== void 0 && oldVersion !== void 0 && hasUpdate ? releaseDownloadUrl(releaseSpec, latest.tag, latestVersion, oldVersion) : void 0;
449
+ return {
450
+ ...summary,
451
+ ...summary.repository === void 0 ? { repository } : {},
452
+ ...latestVersion === void 0 ? {} : { latestVersion },
453
+ hasUpdate,
454
+ ...hasUpdate && updateUrl !== void 0 ? { updateCommand: `dsh plugin --profile ${profileCli} add "${updateUrl}"` } : {}
455
+ };
456
+ } catch (error) {
457
+ return {
458
+ ...summary,
459
+ repository,
460
+ error: error instanceof Error ? error.message : String(error)
461
+ };
462
+ }
463
+ }
464
+ /** GitHub release history reshaped into version rows for one URL install. */
465
+ async githubReleaseVersions(name, installedVersion, releaseSpec, limit) {
466
+ const releases = await this.loadGithubReleases(releaseSpec.owner, releaseSpec.repo, limit);
467
+ const normalizedInstalled = installedVersion === void 0 ? void 0 : normalizeVersion(installedVersion);
468
+ const published = releases.filter((release) => !release.draft && versionFromTag(release.tag) !== void 0).slice(0, Math.max(0, limit));
469
+ const rows = published.map((release) => {
470
+ const version = versionFromTag(release.tag);
471
+ return {
472
+ version,
473
+ ...release.publishedAt === void 0 ? {} : { publishedAt: release.publishedAt },
474
+ latest: false,
475
+ installed: normalizedInstalled !== void 0 && version === normalizedInstalled
476
+ };
477
+ });
478
+ const latestIndex = published.findIndex((release) => !release.prerelease);
479
+ const latestRow = (latestIndex === -1 ? rows[0] : rows[latestIndex]) ?? void 0;
480
+ if (latestRow !== void 0) latestRow.latest = true;
481
+ const latestVersion = latestRow?.version;
482
+ return {
483
+ name,
484
+ ...installedVersion === void 0 ? {} : { installedVersion },
485
+ ...latestVersion === void 0 ? {} : { latestVersion },
486
+ versions: rows
487
+ };
488
+ }
489
+ async loadNotes(profileDir, name, version, spec) {
313
490
  const installed = readInstalledPackage(profileDir, name);
314
491
  let packument;
315
492
  try {
@@ -317,8 +494,13 @@ var PluginInfoService = class {
317
494
  } catch {
318
495
  packument = void 0;
319
496
  }
497
+ const releaseSpec = parseGithubReleaseSpec(spec);
320
498
  const repository = repositoryUrlOf(installed?.repository) ?? repositoryUrlOf(packument?.repository);
321
- const github = repository === void 0 ? void 0 : parseGithubRepo(repository);
499
+ let github = repository === void 0 ? void 0 : parseGithubRepo(repository);
500
+ if (github === void 0 && releaseSpec !== void 0) github = {
501
+ owner: releaseSpec.owner,
502
+ repo: releaseSpec.repo
503
+ };
322
504
  const publishedAt = packument?.time?.[version];
323
505
  const base = {
324
506
  name,
@@ -327,7 +509,8 @@ var PluginInfoService = class {
327
509
  ...publishedAt === void 0 ? {} : { publishedAt }
328
510
  };
329
511
  if (github === void 0) return base;
330
- const release = await this.fetchGithubRelease(github.owner, github.repo, version);
512
+ const specTag = releaseSpec !== void 0 && versionFromTag(releaseSpec.tag) === normalizeVersion(version) ? releaseSpec.tag : void 0;
513
+ const release = await this.fetchGithubRelease(github.owner, github.repo, version, specTag === void 0 ? [] : [specTag]);
331
514
  if (release !== void 0) return {
332
515
  ...base,
333
516
  source: "github-release",
@@ -347,8 +530,12 @@ var PluginInfoService = class {
347
530
  commits: compare.commits
348
531
  };
349
532
  }
350
- async fetchGithubRelease(owner, repo, version) {
351
- const tags = unique([version, `v${version}`]);
533
+ async fetchGithubRelease(owner, repo, version, preferredTags = []) {
534
+ const tags = unique([
535
+ ...preferredTags,
536
+ version,
537
+ `v${version}`
538
+ ]);
352
539
  for (const tag of tags) try {
353
540
  const payload = await this.fetchJson(`https://api.github.com/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`, this.config.timeoutMs, githubHeaders());
354
541
  if (payload === null || typeof payload !== "object" || Array.isArray(payload)) continue;
@@ -410,6 +597,22 @@ function findUserPlugin(profileDir, name, allowScopes) {
410
597
  function unique(values) {
411
598
  return [...new Set(values.filter((value) => value.length > 0))];
412
599
  }
600
+ /** Extract the release fields the service needs from a GitHub API payload. */
601
+ function asGithubRelease(payload) {
602
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) return void 0;
603
+ const record = payload;
604
+ const tag = typeof record.tag_name === "string" && record.tag_name.length > 0 ? record.tag_name : void 0;
605
+ if (tag === void 0) return void 0;
606
+ const publishedAt = typeof record.published_at === "string" ? record.published_at : void 0;
607
+ const url = typeof record.html_url === "string" ? record.html_url : void 0;
608
+ return {
609
+ tag,
610
+ ...publishedAt === void 0 ? {} : { publishedAt },
611
+ ...url === void 0 ? {} : { url },
612
+ prerelease: record.prerelease === true,
613
+ draft: record.draft === true
614
+ };
615
+ }
413
616
  /** True when `name` matches one of the configured scope prefixes (empty list = allow all). */
414
617
  function isAllowedScope(name, allowScopes) {
415
618
  if (allowScopes === void 0 || allowScopes.length === 0) return true;
@@ -460,7 +663,7 @@ const Config = z.object({
460
663
  defaultVersionLimit: z.number().step(1).min(1).max(50).default(10),
461
664
  timeoutMs: z.number().step(1).min(1e3).default(15e3),
462
665
  cacheTtlMs: z.number().step(1).min(0).default(3e5),
463
- allowScopes: z.array(z.string()).default(["@eya46/"])
666
+ allowScopes: z.array(z.string())
464
667
  });
465
668
  /**
466
669
  * Register the plugin-info HTTP API when a web server is present.
@@ -540,4 +743,4 @@ function sendError(res, error) {
540
743
  });
541
744
  }
542
745
  //#endregion
543
- export { Config, DEFAULT_VERSION_LIMIT, PluginInfoService, apply, classifySpec, fetchJson, hasNewerVersion, latestVersionOf, listUserPlugins, name, packumentUrl, parseGithubRepo, parseVersion, profileDirFromBaseUrl, recentVersions, repositoryUrlOf, resolveVersionLimit };
746
+ export { Config, DEFAULT_VERSION_LIMIT, PluginInfoService, apply, classifySpec, fetchJson, hasNewerVersion, latestVersionOf, listUserPlugins, name, packumentUrl, parseGithubReleaseSpec, parseGithubRepo, parseVersion, pluginUpdateCommand, profileCliName, profileDirFromBaseUrl, recentVersions, releaseDownloadUrl, repositoryUrlOf, resolveVersionLimit, versionFromTag };
@@ -21,14 +21,51 @@ interface MinimalReact {
21
21
  useEffect(effect: () => void | (() => void), deps?: unknown[]): void;
22
22
  useCallback<T>(fn: T, deps: unknown[]): T;
23
23
  }
24
+ /**
25
+ * Subset of DSH's `MarkdownText` shared primitive (GFM + KaTeX, unsafe
26
+ * HTML/protocols stripped). `labels` is REQUIRED in practice: the fenced
27
+ * code-block renderer reads `labels.code.copyLabel` / `labels.code.copiedLabel`
28
+ * with no defaults, and the footnotes section reads `labels.footnotes` —
29
+ * passing only `text` crashes the whole settings section the moment a
30
+ * release note contains a fenced code block.
31
+ */
32
+ interface MarkdownLabels {
33
+ code: {
34
+ copyLabel: string;
35
+ copiedLabel: string;
36
+ };
37
+ footnotes: string;
38
+ }
39
+ type MarkdownTextComponent = (props: {
40
+ text: string;
41
+ labels?: MarkdownLabels;
42
+ }) => unknown;
43
+ /** Labels for the MarkdownText copy button / footnotes heading (settings UI is zh). */
44
+ declare const MARKDOWN_LABELS: MarkdownLabels;
24
45
  interface SlotsService {
25
46
  inject(key: string, register: () => void): void;
26
47
  register(options: Record<string, unknown>, component: unknown): void;
27
48
  }
49
+ /**
50
+ * Minimal structural mirror of dsh-better-sidebar's client service — the
51
+ * only method we call is `registerTab`. The plugin is an OPTIONAL soft
52
+ * dependency: this plugin does not value-import it, does not list it in
53
+ * peerDependencies, and reaches the service through `ctx.inject(...)` so
54
+ * the whole registration block is inert when better-sidebar is absent.
55
+ */
56
+ interface BetterSidebarServiceLike {
57
+ registerTab(descriptor: Record<string, unknown>): () => void;
58
+ }
28
59
  interface ClientContext {
29
60
  slots: SlotsService;
30
61
  /** Cordis fiber-lifetime effect: runs now, disposes on fiber unload. */
31
62
  effect(callback: () => (() => void) | void, label?: string): () => void;
63
+ /**
64
+ * Cordis child-fiber spawn: waits until all named services are available
65
+ * before running the callback. Used to register with better-sidebar only
66
+ * when that plugin is loaded (optional soft dependency).
67
+ */
68
+ inject(deps: string[], apply: (ctx: ClientContext) => void): unknown;
32
69
  }
33
70
  interface PluginRow {
34
71
  name: string;
@@ -38,6 +75,7 @@ interface PluginRow {
38
75
  installedVersion?: string;
39
76
  latestVersion?: string;
40
77
  hasUpdate: boolean;
78
+ updateCommand?: string;
41
79
  homepage?: string;
42
80
  repository?: string;
43
81
  error?: string;
@@ -6,11 +6,11 @@
6
6
  import type { Context } from '@deepseek-ai/cordis';
7
7
  import z from '@deepseek-ai/schemastery';
8
8
  import type { PluginConfig } from './types.ts';
9
- export { classifySpec, listUserPlugins, parseGithubRepo, repositoryUrlOf } from './profile.ts';
10
- export { hasNewerVersion, parseVersion } from './semver.ts';
9
+ export { classifySpec, listUserPlugins, parseGithubReleaseSpec, parseGithubRepo, pluginUpdateCommand, profileCliName, releaseDownloadUrl, repositoryUrlOf } from './profile.ts';
10
+ export { hasNewerVersion, parseVersion, versionFromTag } from './semver.ts';
11
11
  export { latestVersionOf, packumentUrl, recentVersions, resolveVersionLimit } from './registry.ts';
12
12
  export { fetchJson, PluginInfoService, profileDirFromBaseUrl } from './service.ts';
13
- export type { InstalledPackage, Packument, PluginConfig, PluginSource, PluginSummary, PluginVersionRow, ProfileManifest, VersionNotes, } from './types.ts';
13
+ export type { GithubReleaseSpec, InstalledPackage, Packument, PluginConfig, PluginSource, PluginSummary, PluginVersionRow, ProfileManifest, VersionNotes, } from './types.ts';
14
14
  /** Cordis plugin name used by Loader diagnostics. */
15
15
  export declare const name = "dsh-plugin-info";
16
16
  /** Default version window shown by the settings page. */
@@ -1,4 +1,14 @@
1
- import type { InstalledPackage, PluginSource, ProfileManifest } from './types.ts';
1
+ import type { GithubReleaseSpec, InstalledPackage, PluginSource, ProfileManifest } from './types.ts';
2
+ /**
3
+ * CLI `--profile` argument for this profile directory.
4
+ *
5
+ * DSH names the manifest `dsh-profile-<dir>` and the CLI flag is the
6
+ * directory basename (`web`, `tui`, …). Prefer the manifest prefix so temp
7
+ * test dirs still resolve; fall back to the folder name, then `web`.
8
+ */
9
+ export declare function profileCliName(manifestName: string | undefined, profileDir: string): string;
10
+ /** `dsh plugin add …@latest` command for one registry package in this profile. */
11
+ export declare function pluginUpdateCommand(profileCli: string, packageName: string): string;
2
12
  /** Classify a profile dependency spec. */
3
13
  export declare function classifySpec(spec: string): PluginSource;
4
14
  /** User-added plugins are the profile's own `dependencies`, not in-box bundles. */
@@ -18,4 +28,17 @@ export declare function parseGithubRepo(input: string): {
18
28
  owner: string;
19
29
  repo: string;
20
30
  } | undefined;
31
+ /**
32
+ * Parse a `github.com/<owner>/<repo>/releases/download/<tag>/<asset>`
33
+ * dependency spec into its repository, tag, and asset. Everything is derived
34
+ * from the spec itself — no per-plugin configuration.
35
+ */
36
+ export declare function parseGithubReleaseSpec(spec: string): GithubReleaseSpec | undefined;
37
+ /**
38
+ * Rewrite a release spec onto a newer tag. The asset file name keeps its
39
+ * shape: an embedded version token equal to `oldVersion` is replaced by
40
+ * `newVersion` (`dsh-skill-mcp-panel-2.0.3.tgz` → `…-2.0.4.tgz`) without
41
+ * touching longer version numbers (`12.0.3`) that merely contain it.
42
+ */
43
+ export declare function releaseDownloadUrl(spec: GithubReleaseSpec, newTag: string, newVersion: string, oldVersion: string): string;
21
44
  //# sourceMappingURL=profile.d.ts.map
@@ -13,6 +13,8 @@ export declare function parseVersion(input: string): ParsedVersion | undefined;
13
13
  export declare function compareParsed(left: ParsedVersion, right: ParsedVersion): number;
14
14
  /** Compare two version strings. Unparseable values compare as equal (0). */
15
15
  export declare function compareVersions(left: string, right: string): number;
16
+ /** Version embedded in a release tag: `v2.0.3`, `2.0.3`, or `panel-1.20.3-rc.1`. */
17
+ export declare function versionFromTag(tag: string): string | undefined;
16
18
  /** True when `latest` is a newer published version than `installed`. */
17
19
  export declare function hasNewerVersion(installed: string | undefined, latest: string | undefined): boolean;
18
20
  //# sourceMappingURL=semver.d.ts.map
@@ -8,10 +8,13 @@ export declare class PluginInfoService {
8
8
  private readonly now;
9
9
  private readonly packuments;
10
10
  private readonly notes;
11
+ private readonly githubLatest;
12
+ private readonly githubReleases;
11
13
  constructor(config: PluginConfig, fetchJson: JsonFetcher, now?: () => number);
12
14
  /** List user-added plugins for one profile directory. */
13
15
  listPlugins(profileDir: string): Promise<{
14
16
  profile: string;
17
+ profileCli: string;
15
18
  plugins: PluginSummary[];
16
19
  }>;
17
20
  /** Recent published versions for one user-added plugin. */
@@ -25,6 +28,22 @@ export declare class PluginInfoService {
25
28
  versionNotes(profileDir: string, name: string, version: string): Promise<VersionNotes>;
26
29
  private describePlugin;
27
30
  private loadPackument;
31
+ /**
32
+ * The repo's latest published release (GitHub skips prereleases/drafts),
33
+ * or undefined when the repo has no releases. Negative answers are cached
34
+ * too so a repo without releases does not burn the API rate limit.
35
+ */
36
+ private loadGithubLatest;
37
+ /** Newest-first GitHub releases for one repo, cached per repo. */
38
+ private loadGithubReleases;
39
+ /**
40
+ * Update detection for GitHub-release tarball installs: the dependency spec
41
+ * itself names the repository and the currently installed tag, so the
42
+ * latest release is looked up from that repo — nothing is hardcoded.
43
+ */
44
+ private describeReleaseSpecPlugin;
45
+ /** GitHub release history reshaped into version rows for one URL install. */
46
+ private githubReleaseVersions;
28
47
  private loadNotes;
29
48
  private fetchGithubRelease;
30
49
  private fetchGithubCompare;
@@ -1,5 +1,14 @@
1
1
  /** Where a profile dependency spec came from. */
2
2
  export type PluginSource = 'registry' | 'file' | 'link' | 'git' | 'other';
3
+ /** A profile dependency spec that installs a GitHub release tarball directly. */
4
+ export interface GithubReleaseSpec {
5
+ owner: string;
6
+ repo: string;
7
+ /** Release tag embedded in the download URL, e.g. `v2.0.3`. */
8
+ tag: string;
9
+ /** Tarball file name, e.g. `dsh-skill-mcp-panel-2.0.3.tgz`. */
10
+ asset: string;
11
+ }
3
12
  /** One user-added profile plugin as the UI lists it. */
4
13
  export interface PluginSummary {
5
14
  /** npm package name. */
@@ -16,6 +25,11 @@ export interface PluginSummary {
16
25
  latestVersion?: string;
17
26
  /** True when the registry latest is newer than the installed version. */
18
27
  hasUpdate: boolean;
28
+ /**
29
+ * `dsh plugin --profile <name> add <pkg>@latest` when this row has a
30
+ * registry update. Omitted for local / git installs and up-to-date packages.
31
+ */
32
+ updateCommand?: string;
19
33
  /** Package homepage. */
20
34
  homepage?: string;
21
35
  /** Normalized repository browse URL. */
@@ -102,10 +116,11 @@ export interface PluginConfig {
102
116
  /** How long packument and notes stay cached. */
103
117
  cacheTtlMs: number;
104
118
  /**
105
- * Optional allow-list: when non-empty, only plugins whose npm name starts
106
- * with one of these prefixes (case-insensitive, e.g. `@eya46/`) appear in
107
- * the settings page and the version/notes APIs. Set to `[]` to show all.
119
+ * Optional allow-list: when omitted or empty, all profile-added plugins
120
+ * appear in the settings page and the version/notes APIs. When non-empty,
121
+ * only plugins whose npm name starts with one of these prefixes appear
122
+ * (case-insensitive, e.g. `@eya46/`).
108
123
  */
109
- allowScopes: string[];
124
+ allowScopes?: string[];
110
125
  }
111
126
  //# sourceMappingURL=types.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@eya46/dsh-plugin-info",
3
3
  "description": "List profile-added DSH plugins, mark available updates, and show recent version notes",
4
- "version": "0.0.2",
4
+ "version": "0.1.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -49,8 +49,8 @@
49
49
  }
50
50
  },
51
51
  "peerDependencies": {
52
- "@deepseek-ai/cordis": "^4.0.1",
53
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.7"
52
+ "@deepseek-ai/cordis": "^4.0.2",
53
+ "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.2"
54
54
  },
55
55
  "peerDependenciesMeta": {
56
56
  "@deepseek-ai/dsh-host-webserver": {
@@ -58,12 +58,12 @@
58
58
  }
59
59
  },
60
60
  "dependencies": {
61
- "@deepseek-ai/schemastery": "^3.18.1"
61
+ "@deepseek-ai/schemastery": "^3.18.2"
62
62
  },
63
63
  "devDependencies": {
64
- "@deepseek-ai/cordis": "^4.0.1",
65
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.7",
66
- "@types/node": "^22.20.0",
64
+ "@deepseek-ai/cordis": "^4.0.2",
65
+ "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.2",
66
+ "@types/node": "^22.20.3",
67
67
  "tsdown": "^0.22.2",
68
68
  "typescript": "^5.9.3",
69
69
  "vitest": "^4.1.8"