dsh-plugin 1.0.0 → 1.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.
Files changed (39) hide show
  1. package/README.en.md +12 -7
  2. package/README.md +12 -7
  3. package/client/client.js +725 -223
  4. package/client/client.js.map +1 -1
  5. package/lib/http/routes.js +43 -7
  6. package/lib/services/installed-versions.js +16 -1
  7. package/lib/services/loader.js +12 -5
  8. package/lib/services/npm-resolve.js +67 -0
  9. package/lib/services/task-queue.js +55 -10
  10. package/lib/types/services/install-types.d.ts +8 -0
  11. package/lib/types/services/installed-versions.d.ts +10 -0
  12. package/lib/types/services/loader.d.ts +6 -4
  13. package/lib/types/services/npm-resolve.d.ts +6 -0
  14. package/lib/types/services/task-queue.d.ts +11 -5
  15. package/package.json +1 -1
  16. package/src/client/components/AboutModal.tsx +72 -0
  17. package/src/client/components/CatalogHeader.tsx +30 -5
  18. package/src/client/components/HubUpdateModal.tsx +84 -0
  19. package/src/client/components/NotificationsModal.tsx +84 -58
  20. package/src/client/components/PluginCard.tsx +3 -0
  21. package/src/client/components/PluginHubSection.tsx +73 -14
  22. package/src/client/components/modals.tsx +42 -18
  23. package/src/client/hooks/useCatalog.ts +96 -18
  24. package/src/client/hooks/useTaskQueue.ts +164 -54
  25. package/src/client/lib/catalog.ts +81 -13
  26. package/src/client/lib/failures.ts +14 -2
  27. package/src/client/lib/renderMarkdown.ts +116 -0
  28. package/src/client/locales.ts +47 -4
  29. package/src/client/styles/Header.module.css +83 -3
  30. package/src/client/styles/List.module.css +4 -4
  31. package/src/client/styles/Modal.module.css +320 -5
  32. package/src/client/styles/tokens.module.css +8 -3
  33. package/src/client/types.ts +3 -1
  34. package/src/server/http/routes.ts +42 -7
  35. package/src/server/services/install-types.ts +8 -0
  36. package/src/server/services/installed-versions.ts +20 -1
  37. package/src/server/services/loader.ts +12 -5
  38. package/src/server/services/npm-resolve.ts +67 -0
  39. package/src/server/services/task-queue.ts +57 -10
@@ -9,7 +9,8 @@ import { homedir, release } from 'node:os';
9
9
  import { dirname, join, resolve } from 'node:path';
10
10
  import { spawn } from 'node:child_process';
11
11
  import { activeTask, cancelTask, dumpLoaderEntries, getTask, githubTarget, hasQueuedTarget, listPendingRestarts, readProfileArg, startPluginMutation, validPackageName } from '../services/install.js';
12
- import { recordInstalledVersion, readInstalledVersions, removeInstalledVersion } from '../services/installed-versions.js';
12
+ import { recordInstalledVersion, recordResolvedNpmPackage, readInstalledVersions, removeInstalledVersion } from '../services/installed-versions.js';
13
+ import { resolveNpmPackage } from '../services/npm-resolve.js';
13
14
  import { preflightTarget } from '../services/preflight.js';
14
15
  const PROFILE_RE = /^[A-Za-z0-9_-]+$/;
15
16
  const BODY_LIMIT_BYTES = 4 * 1024;
@@ -158,17 +159,45 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
158
159
  const rawRepo = typeof body === 'object' && body !== null && typeof body.repo === 'string'
159
160
  ? body.repo.trim()
160
161
  : '';
162
+ // 前端决策的展示用仓库名(owner/repo):npm 安装时 body.repo 是包名,display 用于
163
+ // 待重启行 / 任务恢复时展示仓库名,保证用户无感知(安装通道变化不改变所见目标)
164
+ const displayRepo = typeof body === 'object' && body !== null && typeof body.display === 'string'
165
+ ? body.display.trim()
166
+ : rawRepo;
161
167
  // 目标语法两态:owner/repo → github 源(走装前预检);npm 包名(@scope/name 或 name)→ 信任 registry 直接安装
162
168
  const repoTarget = githubTarget(rawRepo);
163
- const target = repoTarget ?? (repoTarget === null && validPackageName(rawRepo) ? rawRepo : null);
169
+ let target = repoTarget ?? (repoTarget === null && validPackageName(rawRepo) ? rawRepo : null);
164
170
  if (target === null) {
165
171
  sendJson(response, 400, { error: 'unsupported install target' });
166
172
  return;
167
173
  }
174
+ // npm 优先:git 目标先反查该仓库的官方 npm 包,命中则改走 npm 通道。
175
+ // git 分发常缺构建产物/子模块导致 prepare 必败,而 npm 包是作者发布的
176
+ // 完整产物,成功率高得多;未命中或查询失败保留 github 直装(不阻塞安装,
177
+ // 预检仍会兜底拦截 git 分发缺入口文件的情况)。通用机制,不针对具体插件。
178
+ // 反查本身计入「已尝试的安装方式」:组织 scope 与 GitHub 用户名不一致时仅凭
179
+ // 仓库名猜不到包名,失败提 Issue 时作者看到我们查过的命令就能直接指认正确包名。
180
+ const attempts = [];
181
+ if (repoTarget !== null) {
182
+ const npmName = await resolveNpmPackage(rawRepo);
183
+ if (npmName !== null) {
184
+ target = npmName;
185
+ attempts.push(`npm registry search: \`npm search repository:${rawRepo}\` → found \`${npmName}\``);
186
+ // 持久化 repo → npm 包名映射:目录数据未下发 npmPackage(组织 scope 与 GitHub
187
+ // 用户名不一致)时,客户端仍能通过 /installed 的 versions 把依赖 key 匹配回仓库,
188
+ // 列表立即显示「已安装」,避免安装成功后仍显示可安装
189
+ recordResolvedNpmPackage(profile, rawRepo, npmName);
190
+ }
191
+ else {
192
+ attempts.push(`npm registry search: \`npm search repository:${rawRepo}\` → no matching package found (falling back to git install)`);
193
+ }
194
+ }
168
195
  // 重复安装防护:非更新请求命中已安装目标时直接拒绝,不重复跑 CLI
169
196
  // (否则 CLI 会因「已存在」失败,且用户看到的是莫名其妙的报错);
170
- // 更新请求(mode: 'update')放行,CLI 的 add 对已存在依赖是 pnpm 原位覆盖重装
171
- const already = installedByRepo(readInstalled(profile), target);
197
+ // 更新请求(mode: 'update')放行,CLI 的 add 对已存在依赖是 pnpm 原位覆盖重装。
198
+ // 命中判定双通道:npm 包名按 dependencies 键直接命中,github: 目标按 spec 值匹配。
199
+ const installed = readInstalled(profile);
200
+ const already = installedByRepo(installed, target) ?? (installed[target] !== undefined ? target : null);
172
201
  if (already !== null && mode !== 'update') {
173
202
  sendJson(response, 409, { error: `already installed: ${already}` });
174
203
  return;
@@ -180,13 +209,16 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
180
209
  }
181
210
  // 安装前预检:仅新安装走预检(github 源分发改入口文件缺失时直接拦截)。
182
211
  // 更新是已信任目标的覆盖重装,跳过预检避免重复下载 tarball。
183
- // 只支持官方默认安装方式(dsh plugin add github:owner/repo),不做任何 npm 兜底降级;
184
- // [packaging] 前缀供客户端归类为「插件未适配官方默认安装方式」并引导去仓库提 Issue
212
+ // npm 包信任 registry 直接放行;github 源拦截时标记 [packaging] 前缀,
213
+ // 供客户端归类为「插件分发不完整」并引导去仓库提 Issue
185
214
  if (already === null) {
186
215
  const preflight = await preflightTarget(target);
187
216
  if (!preflight.ok) {
217
+ // 预检拦截(git 分发缺入口文件):同步 400 返回,附上已尝试的安装方式,
218
+ // 客户端失败弹窗提 Issue 时一并贴给作者
188
219
  sendJson(response, 400, {
189
- error: `[packaging] ${target}: does not support the official default install method — the git distribution lacks build output (the entry file ${preflight.missing ?? '?'} declared in package.json is not in the repository). Report to the author.`,
220
+ error: `[packaging] ${target}: plugin distribution is incomplete — the entry file ${preflight.missing ?? '?'} declared in package.json is not present in the git distribution (build output not committed). Report to the author.`,
221
+ attempts,
190
222
  });
191
223
  return;
192
224
  }
@@ -203,6 +235,10 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
203
235
  action: 'add',
204
236
  profile,
205
237
  target,
238
+ // npm 安装时 target 是包名:displayTarget 固定展示仓库名,待重启行/前端恢复不受通道变化影响
239
+ displayTarget: githubTarget(displayRepo) ?? undefined,
240
+ // 已尝试的安装方式(npm 反查):实际执行命令由 spawn 时追加,失败 issue 一并展示
241
+ attempts,
206
242
  timeoutMs: COMMAND_TIMEOUT_MS,
207
243
  env: { ...process.env, CI: 'true' },
208
244
  });
@@ -35,7 +35,22 @@ export function readInstalledVersions(profile) {
35
35
  /** Record the catalog signals (version + repo updated time) present at install time for one repo. */
36
36
  export function recordInstalledVersion(profile, repo, version, updatedAt) {
37
37
  const table = readInstalledVersions(profile);
38
- table[repo.toLowerCase()] = { version, updatedAt, installedAt: new Date().toISOString() };
38
+ const key = repo.toLowerCase();
39
+ // 合并写入:保留已记录的 npmPackage(npm 优先通道的 repo → 包名映射),
40
+ // 客户端同步版本信号时不能把它覆盖掉
41
+ table[key] = { ...table[key], version, updatedAt, installedAt: new Date().toISOString() };
42
+ writeVersions(profile, table);
43
+ }
44
+ /**
45
+ * Record the npm package name resolved for a repo (npm-first install channel).
46
+ * Called at `/install` time so the mapping exists even before the task finishes;
47
+ * harmless when the repo is not (yet) installed — the client only treats a repo
48
+ * as installed when its dependency key actually exists.
49
+ */
50
+ export function recordResolvedNpmPackage(profile, repo, npmPackage) {
51
+ const table = readInstalledVersions(profile);
52
+ const key = repo.toLowerCase();
53
+ table[key] = { ...table[key], npmPackage };
39
54
  writeVersions(profile, table);
40
55
  }
41
56
  /** Forget the recorded version for one repo (called on uninstall). */
@@ -44,13 +44,15 @@ function nameMatches(entryName, target) {
44
44
  return false;
45
45
  }
46
46
  /**
47
- * 卸载成功后从运行中 loader 停用指定包的条目,使卸载立即生效(刷新页面不再
48
- * 加载已卸载的 client bundle,也无需重启)。做法:按 name 扫描
47
+ * 卸载成功后从运行中 loader 停用指定包的条目。做法:按 name 扫描
49
48
  * `loader.entries()`(含嵌套子树),对每个匹配条目
50
49
  * `entry.update({ disabled: true })` 做 live-disable —— fiber 被 dispose,
51
50
  * client-modules 对账后不再把它写进 `__DSH_BOOT__`,页面刷新即恢复。
52
- * 返回 true = 无需重启(条目已停用,或本就没加载过);false = 未能停用,
53
- * 需登记「待重启清理」兜底。宿主关键包(@deepseek-ai/* 与本插件自身)一律跳过。
51
+ * 返回 true = 无需重启(仅当宿主从未加载过它:磁盘已干净);
52
+ * 其余一律 false = 需要重启:要么 disable 失败需「待重启清理」兜底,
53
+ * 要么 disable 成功但该插件曾在 loader 中存活——带 UI 的插件(侧边栏面板等
54
+ * 宿主启动时渲染的槽位)disable 后不会主动摘除,不重启面板一直残留。
55
+ * 宿主关键包(@deepseek-ai/* 与本插件自身)一律跳过。
54
56
  */
55
57
  export async function removeLoadedEntry(loader, name) {
56
58
  if (name === 'dsh-plugin' || name.startsWith('@deepseek-ai/'))
@@ -83,8 +85,13 @@ export async function removeLoadedEntry(loader, name) {
83
85
  if (!found) {
84
86
  // 运行中 loader 无该条目:宿主从未加载过它(装完未重启),磁盘已干净,无需重启
85
87
  console.log(`[hub-uninstall] no live entry for ${name}; nothing to disable`);
88
+ return true;
86
89
  }
87
- return true;
90
+ // 找到并停用了 live entry:逻辑层已卸载(刷新页面不再加载其 client bundle),
91
+ // 但带 UI 的插件(侧边栏面板等)是宿主启动时渲染的槽位,disable 不会主动摘除,
92
+ // 不重启面板会一直残留 → 返回「需要重启」,弹窗给出「立即重启」,面板立即消失
93
+ console.log(`[hub-uninstall] disabled live entry for ${name}; UI may persist until restart`);
94
+ return false;
88
95
  })();
89
96
  const result = await Promise.race([
90
97
  removal.catch((error) => {
@@ -0,0 +1,67 @@
1
+ /**
2
+ * npm 包反查:git 分发不完整(缺构建产物/子模块)导致安装失败时,用 npm
3
+ * registry 的搜索接口按 repository 地址反查该仓库对应的官方 npm 包。
4
+ *
5
+ * 这是通用机制,不针对任何具体插件:只要作者发布了 repository 指向该
6
+ * GitHub 仓库的 npm 包(scope 名与 GitHub 用户名是否一致无所谓),就能命中,
7
+ * 从而把「git 直装必败」的插件切换到 npm 通道安装。命中结果带内存缓存,
8
+ * 避免同一会话内重复查询 registry。
9
+ */
10
+ import { get } from 'node:https';
11
+ /** 反查缓存:repo 小写 → npm 包名(null 表示已确认无对应 npm 包)。 */
12
+ const cache = new Map();
13
+ /** 单次 registry 查询超时(ms):慢网络下失败返回 null,不阻塞安装流程。 */
14
+ const REQUEST_TIMEOUT_MS = 8000;
15
+ /**
16
+ * 反查 repo(`owner/repo`)对应的官方 npm 包名;未命中、网络异常或超时返回 null。
17
+ * 返回 null 不代表仓库一定没有 npm 包,只代表本次未能确认 —— 调用方应保留
18
+ * 原有错误路径,反查只是额外的一次尝试。
19
+ */
20
+ export function resolveNpmPackage(repo) {
21
+ const key = repo.toLowerCase();
22
+ if (cache.has(key))
23
+ return Promise.resolve(cache.get(key) ?? null);
24
+ const name = searchRepo(repo);
25
+ name.then((found) => cache.set(key, found)).catch(() => cache.set(key, null));
26
+ return name;
27
+ }
28
+ /** 用 npm search 接口按 repository 地址反查,并做铁证校验(包元数据必须指向该仓库)。 */
29
+ function searchRepo(repo) {
30
+ const [owner, name] = repo.split('/');
31
+ if (owner === undefined || name === undefined || name === '')
32
+ return Promise.resolve(null);
33
+ const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(`repository:${owner}/${name}`)}&size=10`;
34
+ return new Promise((resolve) => {
35
+ const req = get(url, { timeout: REQUEST_TIMEOUT_MS }, (res) => {
36
+ if (res.statusCode !== 200) {
37
+ res.resume();
38
+ resolve(null);
39
+ return;
40
+ }
41
+ let body = '';
42
+ res.on('data', (chunk) => { body += chunk.toString(); });
43
+ res.on('end', () => {
44
+ try {
45
+ const data = JSON.parse(body);
46
+ const needle = `github.com/${owner}/${name}`.toLowerCase();
47
+ for (const obj of data.objects ?? []) {
48
+ const pkg = obj.package;
49
+ if (typeof pkg?.name !== 'string' || pkg.name === '')
50
+ continue;
51
+ const repoUrl = String(pkg.repository?.url ?? pkg.links?.repository ?? '');
52
+ if (repoUrl.toLowerCase().includes(needle)) {
53
+ resolve(pkg.name);
54
+ return;
55
+ }
56
+ }
57
+ resolve(null);
58
+ }
59
+ catch {
60
+ resolve(null);
61
+ }
62
+ });
63
+ });
64
+ req.on('timeout', () => req.destroy());
65
+ req.on('error', () => resolve(null));
66
+ });
67
+ }
@@ -28,15 +28,18 @@ export function getTask(id) {
28
28
  /** All non-terminal tasks in queue order (running first, then pending). Lets the client resume a queue after a page reload. */
29
29
  export function activeTask() {
30
30
  const found = [];
31
+ // 展示目标用 displayTarget(owner/repo):安装 target 可能被服务端切成 npm 包名
32
+ // (npm 反查命中),队列恢复/待重启展示对用户保持仓库名无感知
33
+ const show = (task) => task.displayTarget ?? task.target;
31
34
  for (const item of queue) {
32
35
  const task = item.task;
33
36
  if (task.status === 'pending') {
34
- found.push({ id: task.id, target: task.target, action: task.action, status: 'pending', progress: task.progress, lines: task.lines.slice(0, MAX_TASK_LINES) });
37
+ found.push({ id: task.id, target: show(task), action: task.action, status: 'pending', progress: task.progress, lines: task.lines.slice(0, MAX_TASK_LINES), attempts: task.attempts });
35
38
  }
36
39
  }
37
40
  tasks.forEach((task) => {
38
41
  if (task.status === 'running') {
39
- found.unshift({ id: task.id, target: task.target, action: task.action, status: 'running', progress: task.progress, lines: task.lines.slice(0, MAX_TASK_LINES) });
42
+ found.unshift({ id: task.id, target: show(task), action: task.action, status: 'running', progress: task.progress, lines: task.lines.slice(0, MAX_TASK_LINES), attempts: task.attempts });
40
43
  }
41
44
  });
42
45
  return found;
@@ -139,7 +142,7 @@ function stopChild(child) {
139
142
  * Progress is visible through `getTask(id)` / `activeTask()` until done.
140
143
  */
141
144
  export function startPluginMutation(options) {
142
- const task = { id: nextTaskId, target: options.target, action: options.action, status: 'pending', timedOut: false, exitCode: null, progress: 0, lines: [] };
145
+ const task = { id: nextTaskId, target: options.target, displayTarget: options.displayTarget, action: options.action, status: 'pending', timedOut: false, exitCode: null, progress: 0, lines: [], attempts: options.attempts ?? [], needsRestart: false };
143
146
  nextTaskId = nextTaskId >= Number.MAX_SAFE_INTEGER ? 1 : nextTaskId + 1;
144
147
  tasks.set(task.id, task);
145
148
  if (tasks.size > MAX_TASKS) {
@@ -182,6 +185,12 @@ function spawnMutation(options) {
182
185
  const { action, profile, target, timeoutMs = 5 * 60 * 1000, env, task, displayTarget, uninstallLoader } = options;
183
186
  const invocation = cliInvocation();
184
187
  const args = [...invocation.prefixArgs, 'plugin', '--profile', profile, action, target];
188
+ // 记录实际执行的命令(npm 通道显示包名,git 通道显示 github: 源):失败提 Issue 时
189
+ // 「已尝试的安装方式」如实展示完整命令链,作者一眼看清我们跑过什么
190
+ const executed = `dsh plugin --profile ${profile} ${action} ${target}`;
191
+ if (task && !task.attempts.includes(executed)) {
192
+ task.attempts.push(executed);
193
+ }
185
194
  return new Promise((resolvePromise) => {
186
195
  let stdout = '';
187
196
  let stderr = '';
@@ -307,9 +316,13 @@ function spawnMutation(options) {
307
316
  if (task.status === 'done') {
308
317
  task.progress = 100;
309
318
  if (action === 'remove' && uninstallLoader) {
310
- // 卸载成功 → 主动从运行中 loader 移除该包条目,立即生效、无需重启;
311
- // 移除失败(或从未加载过但 loader 不可用)才登记「待重启清理」兜底
319
+ // 卸载成功 → 主动从运行中 loader 停用该包条目;
320
+ // 仅当宿主从未加载过它(磁盘已干净)才判「无需重启」;
321
+ // 只要曾在 loader 中存活(停用过 live entry)就仍要求重启——
322
+ // 带 UI 的插件(侧边栏面板等宿主启动时渲染的槽位)disable 后不会
323
+ // 主动消失,重启才能立即摘除面板
312
324
  const removed = await removeLoadedEntry(uninstallLoader, target);
325
+ task.needsRestart = !removed;
313
326
  if (removed)
314
327
  clearPendingRestart(displayTarget ?? target);
315
328
  else
@@ -318,6 +331,7 @@ function spawnMutation(options) {
318
331
  else {
319
332
  // 安装成功(或卸载但无 loader 引用)→ 登记待重启:
320
333
  // 插件要宿主重启才会挂载/卸载干净,重启前一直提醒;展示目标用 displayTarget(owner/repo)
334
+ task.needsRestart = true;
321
335
  addPendingRestart(displayTarget ?? target, action === 'add' ? 'install' : 'uninstall');
322
336
  }
323
337
  }
@@ -371,17 +385,30 @@ export function verifyInstalledEntry(profile, target) {
371
385
  return { name, missing: entry };
372
386
  }
373
387
  }
388
+ /** 从 pnpm `Ignored build scripts:` 提示行解析被拦截的构建脚本 key(name@version,逗号分隔)。 */
389
+ function ignoredBuildKeys(output) {
390
+ const m = /Ignored build scripts:\s*(.+)/i.exec(output);
391
+ if (m === null)
392
+ return [];
393
+ return m[1]
394
+ .split(',')
395
+ .map((s) => s.trim())
396
+ .filter((s) => s !== '');
397
+ }
374
398
  /**
375
- * Run a plugin mutation with one recovery path: when `add` fails because a
376
- * git-hosted package's `prepare` script is blocked by pnpm's allowBuilds
377
- * gate (`ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`), read the exact allowlist
378
- * key pnpm printed, write it into the profile's pnpm-workspace.yaml, and
379
- * retry once. Other failures fall through to the single-spawn result.
399
+ * Run a plugin mutation with one recovery path: when `add` fails because
400
+ * pnpm's allowBuilds gate blocks a build script — either the git-hosted
401
+ * package's own `prepare` (`ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`, key from
402
+ * the printed key@url hint) or a dependency's native build
403
+ * (`ERR_PNPM_IGNORED_BUILDS`, key = the listed name@version) — write the
404
+ * exact keys into the profile's pnpm-workspace.yaml and retry once.
405
+ * Other failures fall through to the single-spawn result.
380
406
  */
381
407
  export async function runPluginMutation(options) {
382
408
  const result = await spawnMutation(options);
383
409
  if (options.action === 'add' && result.exitCode !== 0 && !result.timedOut) {
384
410
  const output = `${result.stderr}\n${result.stdout}`;
411
+ // git 源:pnpm 打印的 key@url 提示
385
412
  if (output.includes('ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED')) {
386
413
  const key = parseAllowBuildsKey(output);
387
414
  if (key !== null) {
@@ -396,6 +423,24 @@ export async function runPluginMutation(options) {
396
423
  return spawnMutation(options);
397
424
  }
398
425
  }
426
+ // 依赖原生模块/构建脚本被 pnpm v11 拦截(ERR_PNPM_IGNORED_BUILDS):git 与 npm 源都会出现。
427
+ // allowBuilds 的 key 是输出里列出的 name@version(如 node-pty@1.1.0),逐个放行后重试一次;
428
+ // 输出解析不到时回退用目标名(插件自身构建脚本被拦的 npm 场景)。
429
+ if (output.includes('ERR_PNPM_IGNORED_BUILDS')) {
430
+ const keys = ignoredBuildKeys(output);
431
+ if (keys.length === 0 && !options.target.startsWith('github:'))
432
+ keys.push(options.target);
433
+ if (keys.length > 0) {
434
+ if (options.task) {
435
+ options.task.status = 'running';
436
+ options.task.exitCode = null;
437
+ pushLine(options.task, `[allow build] ${keys.join(', ')}`);
438
+ }
439
+ for (const key of keys)
440
+ addAllowBuildsKey(options.profile, key);
441
+ return spawnMutation(options);
442
+ }
443
+ }
399
444
  }
400
445
  // pnpm 安装本身成功,但目标包入口文件缺失(git 分发缺构建产物)→ 判为失败:
401
446
  // 撤销刚登记的待重启,避免用户重启时宿主加载残缺包直接崩溃
@@ -11,6 +11,12 @@ export interface InstallTask {
11
11
  id: number;
12
12
  /** 操作目标:`github:<owner>/<repo>`(安装)或 npm 包名(卸载),冲突/恢复时需要展示给用户 */
13
13
  target: string;
14
+ /** 展示用目标(owner/repo):npm 安装时 target 是包名,前端恢复用此字段显示仓库名,保持用户无感知 */
15
+ displayTarget?: string;
16
+ /** 尝试过的安装方式(npm registry 反查 + 实际执行的 CLI 命令,按先后顺序):
17
+ * 失败提 Issue 时如实贴给作者,作者据此反推正确的 npm 包名(组织 scope 与 GitHub 用户名不一致时
18
+ * 仅凭仓库名猜不到,作者看到我们查过/试过的命令就能直接指认)。 */
19
+ attempts: string[];
14
20
  action: 'add' | 'remove';
15
21
  /** 队列语义:pending 排队中 / running 执行中 / done / failed / cancelled(用户取消) */
16
22
  status: 'pending' | 'running' | 'done' | 'failed' | 'cancelled';
@@ -20,6 +26,8 @@ export interface InstallTask {
20
26
  progress: number;
21
27
  /** Newest output lines first (consumer shows the tail). */
22
28
  lines: string[];
29
+ /** 完成后是否仍需宿主重启才生效:卸载时 loader 已即时移除 → false;否则 true(弹窗据此给重启选项) */
30
+ needsRestart: boolean;
23
31
  }
24
32
  /** 子进程启动参数(复用宿主 dsh 入口,或回退到 PATH 上的 `dsh`)。 */
25
33
  export interface Invocation {
@@ -4,11 +4,21 @@ export interface InstalledVersionRecord {
4
4
  /** 安装时的仓库最近更新时间(ISO,GitHub pushed_at);无 release 仓库的更新依据 */
5
5
  updatedAt: string;
6
6
  installedAt: string;
7
+ /** npm 优先通道反查命中的官方 npm 包名(repo → 包名映射):目录数据可能未下发
8
+ * npmPackage(组织 scope 与 GitHub 用户名不一致),客户端据此把依赖 key 匹配回仓库 */
9
+ npmPackage?: string;
7
10
  }
8
11
  export type InstalledVersionTable = Record<string, InstalledVersionRecord>;
9
12
  export declare function versionsFilePath(profile: string): string;
10
13
  export declare function readInstalledVersions(profile: string): InstalledVersionTable;
11
14
  /** Record the catalog signals (version + repo updated time) present at install time for one repo. */
12
15
  export declare function recordInstalledVersion(profile: string, repo: string, version: string, updatedAt: string): void;
16
+ /**
17
+ * Record the npm package name resolved for a repo (npm-first install channel).
18
+ * Called at `/install` time so the mapping exists even before the task finishes;
19
+ * harmless when the repo is not (yet) installed — the client only treats a repo
20
+ * as installed when its dependency key actually exists.
21
+ */
22
+ export declare function recordResolvedNpmPackage(profile: string, repo: string, npmPackage: string): void;
13
23
  /** Forget the recorded version for one repo (called on uninstall). */
14
24
  export declare function removeInstalledVersion(profile: string, repo: string): void;
@@ -32,12 +32,14 @@ export declare function dumpLoaderEntries(loader: LoaderHandle | undefined): Arr
32
32
  name?: string;
33
33
  }>;
34
34
  /**
35
- * 卸载成功后从运行中 loader 停用指定包的条目,使卸载立即生效(刷新页面不再
36
- * 加载已卸载的 client bundle,也无需重启)。做法:按 name 扫描
35
+ * 卸载成功后从运行中 loader 停用指定包的条目。做法:按 name 扫描
37
36
  * `loader.entries()`(含嵌套子树),对每个匹配条目
38
37
  * `entry.update({ disabled: true })` 做 live-disable —— fiber 被 dispose,
39
38
  * client-modules 对账后不再把它写进 `__DSH_BOOT__`,页面刷新即恢复。
40
- * 返回 true = 无需重启(条目已停用,或本就没加载过);false = 未能停用,
41
- * 需登记「待重启清理」兜底。宿主关键包(@deepseek-ai/* 与本插件自身)一律跳过。
39
+ * 返回 true = 无需重启(仅当宿主从未加载过它:磁盘已干净);
40
+ * 其余一律 false = 需要重启:要么 disable 失败需「待重启清理」兜底,
41
+ * 要么 disable 成功但该插件曾在 loader 中存活——带 UI 的插件(侧边栏面板等
42
+ * 宿主启动时渲染的槽位)disable 后不会主动摘除,不重启面板一直残留。
43
+ * 宿主关键包(@deepseek-ai/* 与本插件自身)一律跳过。
42
44
  */
43
45
  export declare function removeLoadedEntry(loader: LoaderHandle, name: string): Promise<boolean>;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * 反查 repo(`owner/repo`)对应的官方 npm 包名;未命中、网络异常或超时返回 null。
3
+ * 返回 null 不代表仓库一定没有 npm 包,只代表本次未能确认 —— 调用方应保留
4
+ * 原有错误路径,反查只是额外的一次尝试。
5
+ */
6
+ export declare function resolveNpmPackage(repo: string): Promise<string | null>;
@@ -9,6 +9,8 @@ export interface ActiveTaskInfo {
9
9
  status: 'pending' | 'running';
10
10
  progress: number;
11
11
  lines: string[];
12
+ /** 尝试过的安装方式(npm 反查 + 实际执行命令):失败时前端 issue 预填需要 */
13
+ attempts: string[];
12
14
  }
13
15
  /** All non-terminal tasks in queue order (running first, then pending). Lets the client resume a queue after a page reload. */
14
16
  export declare function activeTask(): ActiveTaskInfo[];
@@ -44,6 +46,8 @@ export declare function startPluginMutation(options: {
44
46
  displayTarget?: string;
45
47
  /** 运行中 loader:卸载成功后主动移除条目、立即生效(缺失时卸载仍需重启清理) */
46
48
  uninstallLoader?: LoaderHandle;
49
+ /** 入队前已尝试的安装方式(npm registry 反查等):失败提 Issue 时如实展示;实际执行的命令由 spawn 时追加 */
50
+ attempts?: string[];
47
51
  }): InstallTask;
48
52
  /**
49
53
  * 安装后校验已装包的入口文件(package.json 的 main / exports["."].default)是否真实存在。
@@ -56,11 +60,13 @@ export declare function verifyInstalledEntry(profile: string, target: string): {
56
60
  missing: string | null;
57
61
  };
58
62
  /**
59
- * Run a plugin mutation with one recovery path: when `add` fails because a
60
- * git-hosted package's `prepare` script is blocked by pnpm's allowBuilds
61
- * gate (`ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`), read the exact allowlist
62
- * key pnpm printed, write it into the profile's pnpm-workspace.yaml, and
63
- * retry once. Other failures fall through to the single-spawn result.
63
+ * Run a plugin mutation with one recovery path: when `add` fails because
64
+ * pnpm's allowBuilds gate blocks a build script — either the git-hosted
65
+ * package's own `prepare` (`ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`, key from
66
+ * the printed key@url hint) or a dependency's native build
67
+ * (`ERR_PNPM_IGNORED_BUILDS`, key = the listed name@version) — write the
68
+ * exact keys into the profile's pnpm-workspace.yaml and retry once.
69
+ * Other failures fall through to the single-spawn result.
64
70
  */
65
71
  export declare function runPluginMutation(options: {
66
72
  action: 'add' | 'remove';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "A community plugin marketplace for DeepSeek Harness, built to the official plugin spec — browse, search and install 4000+ human-curated community plugins without leaving the app. · DeepSeek Harness 社区插件市场(遵循官方开发规范):4000+ 人工精选社区插件,每日更新。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -0,0 +1,72 @@
1
+ /**
2
+ * 头部「关注我们」弹窗:介绍 DSH-Plugin Hub 是干什么的 + 用户反馈群二维码。
3
+ * 内容以 Markdown 形式由 dsh-update Worker 的 /about 接口下发(key = hub:about),
4
+ * 作者想怎么写就怎么写、随写随推,客户端只负责渲染 —— 非写死。
5
+ * 图片(反馈群二维码)经 renderMarkdown 的 ![](url) 语法嵌入,居中、最大高度受控。
6
+ */
7
+ import { createElement as h } from 'react'
8
+ import type { MouseEvent } from 'react'
9
+ import styles from '../styles/Modal.module.css'
10
+ import type { HubAboutInfo } from '../lib/catalog.ts'
11
+ import type { LocaleId, Translate } from '../types.ts'
12
+ import { renderMarkdown } from '../lib/renderMarkdown.ts'
13
+ import { CloseIcon } from './icons.tsx'
14
+
15
+ export function AboutModal({ info, lang, t, onClose }: {
16
+ info: HubAboutInfo | null
17
+ lang: LocaleId
18
+ t: Translate
19
+ onClose: () => void
20
+ }) {
21
+ // 内容:字符串直接使用;{zh,en} 对象按界面语言取,缺哪种补哪种,全缺则展示兜底文案。
22
+ // Worker 未推送(info 为 null / content 为空)时弹窗仍能打开,显示「暂未发布」而非报错。
23
+ const contentRaw = info
24
+ ? typeof info.content === 'string'
25
+ ? info.content
26
+ : info.content && typeof info.content === 'object'
27
+ ? lang === 'en'
28
+ ? (info.content.en ?? info.content.zh ?? '')
29
+ : (info.content.zh ?? info.content.en ?? '')
30
+ : ''
31
+ : ''
32
+ const contentHtml = contentRaw.trim() ? renderMarkdown(contentRaw) : null
33
+ // 更新时间:ISO 字符串转本地可读格式;非法值静默隐藏
34
+ let updated: string | null = null
35
+ if (info?.updatedAt) {
36
+ const d = new Date(info.updatedAt)
37
+ if (!Number.isNaN(d.getTime())) {
38
+ updated = d.toLocaleString(lang === 'en' ? 'en-US' : 'zh-CN')
39
+ }
40
+ }
41
+
42
+ return h('div', {
43
+ className: styles.overlay,
44
+ onClick: (e: MouseEvent<HTMLDivElement>) => {
45
+ if (e.target === e.currentTarget) onClose()
46
+ },
47
+ },
48
+ h('div', { className: `${styles.modal} ${styles.aboutModal}`, role: 'dialog', 'aria-modal': 'true' },
49
+ h('div', { className: styles.modalHead },
50
+ h('div', { className: styles.modalTitle }, t('aboutTitle')),
51
+ h('button', {
52
+ className: styles.modalClose,
53
+ 'aria-label': t('confirmCancel'),
54
+ onClick: () => onClose(),
55
+ }, h(CloseIcon)),
56
+ ),
57
+ h('div', { className: styles.modalDesc }, t('aboutDesc')),
58
+ contentHtml
59
+ ? h('div', {
60
+ className: styles.aboutContent,
61
+ dangerouslySetInnerHTML: { __html: contentHtml },
62
+ })
63
+ : h('div', { className: styles.aboutContent }, t('aboutEmpty')),
64
+ updated
65
+ ? h('div', { className: styles.aboutMeta }, `${t('aboutUpdated')} ${updated}`)
66
+ : null,
67
+ h('div', { className: styles.modalActions },
68
+ h('button', { className: styles.modalInstall, onClick: onClose }, t('doneBtn')),
69
+ ),
70
+ ),
71
+ )
72
+ }
@@ -8,12 +8,18 @@ import type { Translate } from '../types.ts'
8
8
  import { GITHUB_URL, PLUGIN_VERSION, SITE_URL } from '../lib/catalog.ts'
9
9
  import { GitHubIcon, LogoIcon } from './icons.tsx'
10
10
 
11
- export function CatalogHeader({ t, langPath, statsTotal, statsVerified, onToggleLang }: {
11
+ export function CatalogHeader({ t, langPath, statsTotal, statsVerified, onToggleLang, hubUpdate, onVersionClick, onAboutClick }: {
12
12
  t: Translate
13
13
  langPath: string
14
14
  statsTotal: number
15
15
  statsVerified: number
16
16
  onToggleLang: () => void
17
+ /** Hub 自身是否有可用更新:有则版本号后紧跟红色「可更新」徽标(整体一个可点入口) */
18
+ hubUpdate: boolean
19
+ /** 点击版本号(含红色徽标,同一入口):无论有无更新都打开更新记录弹窗 */
20
+ onVersionClick: () => void
21
+ /** 点击「关注我们」:打开平台介绍 + 用户反馈群二维码弹窗 */
22
+ onAboutClick: () => void
17
23
  }) {
18
24
  return h(Fragment, null,
19
25
  h('div', { className: styles.header },
@@ -28,10 +34,20 @@ export function CatalogHeader({ t, langPath, statsTotal, statsVerified, onToggle
28
34
  'aria-label': t('openHint'),
29
35
  },
30
36
  h(LogoIcon),
31
- h('h1', { className: styles.title },
32
- t('title'),
33
- h('span', { className: styles.version }, ` v${PLUGIN_VERSION}`),
34
- ),
37
+ h('h1', { className: styles.title }, t('title')),
38
+ ),
39
+ // 版本号:紧跟标题「DSH-Plugin Hub」之后,常驻可点入口(<a> 内不能嵌套按钮,故从官网链接里移出放在旁边)。
40
+ // 点击打开更新记录弹窗 —— 有更新看新版本内容,无更新看当前版本内容。
41
+ // 有更新时版本号后直接跟一个红色「可更新」徽标(同一按钮内,不分开成两个元素),静态显示不闪烁。
42
+ h('button', {
43
+ className: styles.versionBtn,
44
+ type: 'button',
45
+ onClick: onVersionClick,
46
+ title: t(hubUpdate ? 'hubUpdateHint' : 'versionHint'),
47
+ 'aria-label': t(hubUpdate ? 'hubUpdateHint' : 'versionHint'),
48
+ },
49
+ `v${PLUGIN_VERSION}`,
50
+ hubUpdate && h('span', { className: styles.hubUpdateBadge }, t('update')),
35
51
  ),
36
52
  // 右侧控件组:语言切换 + GitHub 源码图标(独立链接,brandTitle 是官网 <a> 不能嵌套),
37
53
  // 一起 space-between 顶到标题行最右
@@ -44,6 +60,15 @@ export function CatalogHeader({ t, langPath, statsTotal, statsVerified, onToggle
44
60
  title: t('toggleLangHint'),
45
61
  'aria-label': t('toggleLangHint'),
46
62
  }, langPath === 'zh/' ? 'EN' : '中文'),
63
+ // 「关注我们」:点击打开平台介绍 + 用户反馈群二维码弹窗
64
+ h('button', {
65
+ className: styles.aboutBtn,
66
+ type: 'button',
67
+ onClick: onAboutClick,
68
+ title: t('aboutDesc'),
69
+ 'aria-label': t('aboutTitle'),
70
+ }, t('followUs')),
71
+ // GitHub 源码图标:独立链接(brandTitle 是官网 <a> 不能嵌套),放在控件组最后
47
72
  h('a', {
48
73
  className: styles.githubLink,
49
74
  href: GITHUB_URL,