@eya46/dsh-plugin-info 0.0.3 → 0.1.1

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
@@ -6,6 +6,8 @@
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
 
@@ -29,13 +31,15 @@ dsh plugin --profile web add "file:./packages/dsh-plugin-info"
29
31
 
30
32
  `dsh.bundle` 会把 `@eya46/dsh-plugin-info` 写进该 Profile 的 bundle 列表。重启 `dsh web` 后,打开设置 → **插件信息**。
31
33
 
34
+ 兼容的 DSH 版本:`^0.1.5-rc.2`(0.1.x)与 `^0.2.0-rc.2`(0.2.x,含当前 Desktop / Web 发行版)。**0.1.0 只声明了 `^0.1.5-rc.2`**,装在 0.2.0-rc.2 上会被插件管理器的兼容性预检拒绝(提示「要求 @deepseek-ai/dsh-host-webserver ^0.1.5-rc.2」),请升级到 0.1.1 或更新版本。
35
+
32
36
  ## HTTP
33
37
 
34
38
  本机回环可用:
35
39
 
36
40
  | 端点 | 说明 |
37
41
  | --- | --- |
38
- | `GET /plugin-info/api/plugins` | 当前 Profile 用户插件 + 是否有更新 |
42
+ | `GET /plugin-info/api/plugins` | 当前 Profile 用户插件 + 是否有更新;含 `profileCli`,可更新的 registry 包还会带 `updateCommand` |
39
43
  | `GET /plugin-info/api/versions?name=<pkg>&limit=10` | 最近版本,默认 10 条 |
40
44
  | `GET /plugin-info/api/notes?name=<pkg>&version=<ver>` | 该版本的更新说明 |
41
45
 
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; }',
@@ -59,6 +69,7 @@
59
69
  '.dpi-sidebar .dpi-card .dpi-body { padding: 0 12px 12px; }',
60
70
  '.dpi-sidebar .dpi-actions { gap: 6px; }',
61
71
  '.dpi-sidebar .dpi-rowButton { height: 26px; padding: 0 8px; }',
72
+ '.dpi-sidebar .dpi-updateCopy { height: 26px; margin-right: 10px; padding: 0 8px; }',
62
73
  ].join('\n');
63
74
  const React = require('react');
64
75
  // Reuse DSH's shared untrusted-Markdown renderer instead of rolling our
@@ -67,6 +78,30 @@
67
78
  const uiPrimitives = require('@deepseek-ai/dsh-client-ui-primitives');
68
79
  const MarkdownText = uiPrimitives.MarkdownText;
69
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
+ }
70
105
  const ensureStyles = () => {
71
106
  if (document.querySelector('style[data-plugin="@eya46/dsh-plugin-info"]') !== null)
72
107
  return;
@@ -148,17 +183,18 @@
148
183
  const [notes, setNotes] = React.useState({});
149
184
  const [notesBusy, setNotesBusy] = React.useState('');
150
185
  const [copied, setCopied] = React.useState({});
151
- const copyName = React.useCallback((name) => {
186
+ const [profileCli, setProfileCli] = React.useState('web');
187
+ const copyText = React.useCallback((key, text) => {
152
188
  const write = () => {
153
189
  if (typeof navigator !== 'undefined' && navigator.clipboard !== undefined) {
154
- return navigator.clipboard.writeText(name);
190
+ return navigator.clipboard.writeText(text);
155
191
  }
156
192
  return Promise.reject(new Error('clipboard unavailable'));
157
193
  };
158
194
  write()
159
195
  .then(() => {
160
- setCopied((prev) => ({ ...prev, [name]: true }));
161
- window.setTimeout(() => setCopied((prev) => ({ ...prev, [name]: false })), 1200);
196
+ setCopied((prev) => ({ ...prev, [key]: true }));
197
+ window.setTimeout(() => setCopied((prev) => ({ ...prev, [key]: false })), 1200);
162
198
  })
163
199
  .catch(() => { });
164
200
  }, []);
@@ -170,6 +206,8 @@
170
206
  if (!Array.isArray(data.plugins))
171
207
  throw new Error(data.message ?? 'unexpected response');
172
208
  setRows(data.plugins);
209
+ if (typeof data.profileCli === 'string' && data.profileCli.length > 0)
210
+ setProfileCli(data.profileCli);
173
211
  setError('');
174
212
  })
175
213
  .catch((caught) => setError(String(caught)))
@@ -225,7 +263,7 @@
225
263
  const updateCount = rows.filter((row) => row.hasUpdate).length;
226
264
  const children = [
227
265
  h('h2', { key: 'title', className: 'dpi-title' }, '插件信息'),
228
- h('p', { key: 'intro', className: 'dpi-intro' }, '当前 Profile 里用户添加的插件(不含随发行版内置的 bundle)。展开后默认看最近 10 个版本,点版本可看更新说明。'),
266
+ h('p', { key: 'intro', className: 'dpi-intro' }, '当前 Profile(' + profileCli + ')里用户添加的插件(不含随发行版内置的 bundle)。有更新时可一键复制 dsh plugin 命令;展开后默认看最近 10 个版本,点版本可看更新说明。'),
229
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 + ' 个可更新' : ''))),
230
268
  ];
231
269
  if (error !== '')
@@ -245,14 +283,20 @@
245
283
  if (plugin.hasUpdate && plugin.latestVersion !== undefined) {
246
284
  head.push(h('span', { key: 'up', className: 'dpi-tag', 'data-tone': 'warn' }, '可更新 ' + plugin.latestVersion));
247
285
  }
248
- else if (plugin.source === 'registry' && plugin.latestVersion !== undefined) {
286
+ else if (!plugin.hasUpdate && plugin.latestVersion !== undefined) {
249
287
  head.push(h('span', { key: 'ok', className: 'dpi-tag', 'data-tone': 'ok' }, '已是最新'));
250
288
  }
289
+ const updateCommand = plugin.updateCommand
290
+ ?? ('dsh plugin --profile ' + profileCli + ' add ' + plugin.name + '@latest');
291
+ const updateCopied = copied['update:' + plugin.name] === true;
251
292
  const body = [];
252
293
  if (expanded) {
253
294
  if (plugin.description !== undefined)
254
295
  body.push(h('p', { key: 'desc', className: 'dpi-meta' }, plugin.description));
255
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
+ }
256
300
  if (plugin.error !== undefined)
257
301
  body.push(h('p', { key: 'perr', className: 'dpi-banner' }, plugin.error));
258
302
  const links = [
@@ -260,7 +304,7 @@
260
304
  key: 'copy',
261
305
  className: 'dpi-copyTag',
262
306
  title: '复制包名',
263
- onClick: () => { copyName(plugin.name); },
307
+ onClick: () => { copyText(plugin.name, plugin.name); },
264
308
  }, copied[plugin.name] === true ? '已复制' : plugin.name),
265
309
  h('button', {
266
310
  key: 'vers',
@@ -268,6 +312,14 @@
268
312
  onClick: () => { loadVersions(plugin.name); },
269
313
  }, versions[plugin.name] === undefined ? '加载版本' : '重新加载版本'),
270
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
+ }
271
323
  if (plugin.homepage !== undefined) {
272
324
  links.push(h('a', { key: 'home', className: 'dpi-rowButton dpi-link', href: plugin.homepage, target: '_blank', rel: 'noreferrer' }, '主页'));
273
325
  }
@@ -284,7 +336,7 @@
284
336
  body.push(h('p', { key: 'vload', className: 'dpi-meta' }, '正在加载最近 10 个版本…'));
285
337
  }
286
338
  else if (pluginVersions.length === 0) {
287
- 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' ? '注册表没有版本记录。' : '没有可展示的历史版本。'));
288
340
  }
289
341
  else {
290
342
  const versionNodes = pluginVersions.map((row) => {
@@ -310,7 +362,7 @@
310
362
  if (note.title !== undefined)
311
363
  noteChildren.push(h('p', { key: 'nt', className: 'dpi-meta' }, note.title));
312
364
  if (note.body !== undefined && note.body !== '') {
313
- noteChildren.push(h(MarkdownText, { key: 'nb', text: note.body }));
365
+ noteChildren.push(h(SafeMarkdown, { key: 'nb', text: note.body }));
314
366
  }
315
367
  if (note.url !== undefined) {
316
368
  noteChildren.push(h('a', { key: 'nu', className: 'dpi-link', href: note.url, target: '_blank', rel: 'noreferrer' }, '在 GitHub 查看 Release'));
@@ -338,11 +390,20 @@
338
390
  body.push(h('div', { key: 'versions', className: 'dpi-versions' }, ...versionNodes));
339
391
  }
340
392
  }
341
- 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', {
342
394
  className: 'dpi-head',
343
395
  'aria-expanded': expanded,
344
396
  onClick: () => { togglePlugin(plugin.name); },
345
- }, ...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));
346
407
  }
347
408
  children.push(h('ul', { key: 'cards', className: 'dpi-cards' }, ...cards));
348
409
  return h('div', { className: 'dpi-section' }, ...children);
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;
@@ -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 };
@@ -22,13 +22,26 @@ interface MinimalReact {
22
22
  useCallback<T>(fn: T, deps: unknown[]): T;
23
23
  }
24
24
  /**
25
- * Subset of DSH's `MarkdownText` shared primitive. The real component is
26
- * GFM + KaTeX with unsafe HTML/protocols stripped; we only need the `text`
27
- * prop for rendering release-note bodies.
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.
28
31
  */
32
+ interface MarkdownLabels {
33
+ code: {
34
+ copyLabel: string;
35
+ copiedLabel: string;
36
+ };
37
+ footnotes: string;
38
+ }
29
39
  type MarkdownTextComponent = (props: {
30
40
  text: string;
41
+ labels?: MarkdownLabels;
31
42
  }) => unknown;
43
+ /** Labels for the MarkdownText copy button / footnotes heading (settings UI is zh). */
44
+ declare const MARKDOWN_LABELS: MarkdownLabels;
32
45
  interface SlotsService {
33
46
  inject(key: string, register: () => void): void;
34
47
  register(options: Record<string, unknown>, component: unknown): void;
@@ -62,6 +75,7 @@ interface PluginRow {
62
75
  installedVersion?: string;
63
76
  latestVersion?: string;
64
77
  hasUpdate: boolean;
78
+ updateCommand?: string;
65
79
  homepage?: string;
66
80
  repository?: string;
67
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. */
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.3",
4
+ "version": "0.1.1",
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.4",
53
+ "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.2 || ^0.2.0-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.4"
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.4",
65
+ "@deepseek-ai/dsh-host-webserver": "0.2.0-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"