dsh-plugin 1.1.2 → 1.2.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 (110) hide show
  1. package/client/client.js +4465 -1442
  2. package/client/client.js.map +1 -1
  3. package/lib/http/routes.js +438 -22
  4. package/lib/index.js +1 -1
  5. package/lib/services/install/install.js +4 -0
  6. package/lib/services/install/npm-check.js +55 -0
  7. package/lib/services/{npm-resolve.js → install/npm-resolve.js} +7 -5
  8. package/lib/services/{preflight.js → install/preflight.js} +15 -4
  9. package/lib/services/{task-queue.js → install/task-queue.js} +155 -66
  10. package/lib/services/loader.js +56 -1
  11. package/lib/services/log.js +138 -0
  12. package/lib/services/probe.js +157 -0
  13. package/lib/services/{profile.js → profile/profile.js} +29 -1
  14. package/lib/services/settings.js +60 -0
  15. package/lib/types/http/routes.d.ts +1 -1
  16. package/lib/types/index.d.ts +1 -1
  17. package/lib/types/services/{install-types.d.ts → install/install-types.d.ts} +3 -0
  18. package/lib/types/services/{install.d.ts → install/install.d.ts} +5 -5
  19. package/lib/types/services/install/npm-check.d.ts +12 -0
  20. package/lib/types/services/{npm-resolve.d.ts → install/npm-resolve.d.ts} +2 -1
  21. package/lib/types/services/{task-queue.d.ts → install/task-queue.d.ts} +5 -1
  22. package/lib/types/services/loader.d.ts +14 -8
  23. package/lib/types/services/log.d.ts +49 -0
  24. package/lib/types/services/probe.d.ts +30 -0
  25. package/lib/types/services/{pending-restart.d.ts → profile/pending-restart.d.ts} +1 -1
  26. package/lib/types/services/{profile.d.ts → profile/profile.d.ts} +2 -0
  27. package/lib/types/services/settings.d.ts +24 -0
  28. package/package.json +1 -1
  29. package/src/client/components/PluginHubSection.tsx +341 -120
  30. package/src/client/components/{CatalogList.tsx → catalog/CatalogList.tsx} +11 -7
  31. package/src/client/components/{PluginCard.tsx → catalog/PluginCard.tsx} +5 -4
  32. package/src/client/components/layout/CatalogControls.tsx +80 -0
  33. package/src/client/components/{CatalogHeader.tsx → layout/CatalogHeader.tsx} +4 -4
  34. package/src/client/components/{CategoryTabs.tsx → layout/CategoryTabs.tsx} +3 -3
  35. package/src/client/components/layout/SectionTabs.tsx +60 -0
  36. package/src/client/components/{AboutModal.tsx → modals/AboutModal.tsx} +7 -7
  37. package/src/client/components/{HubUpdateModal.tsx → modals/HubUpdateModal.tsx} +5 -5
  38. package/src/client/components/modals/InstalledDetailModal.tsx +193 -0
  39. package/src/client/components/modals/LogsModal.tsx +301 -0
  40. package/src/client/components/modals/LogsPathModal.tsx +161 -0
  41. package/src/client/components/{NotificationsModal.tsx → modals/NotificationsModal.tsx} +47 -20
  42. package/src/client/components/{ProgressView.tsx → modals/ProgressView.tsx} +2 -2
  43. package/src/client/components/{modals.tsx → modals/modals.tsx} +117 -40
  44. package/src/client/components/{Dropdown.tsx → ui/Dropdown.tsx} +14 -6
  45. package/src/client/components/ui/Toggle.tsx +28 -0
  46. package/src/client/components/ui/icons.tsx +343 -0
  47. package/src/client/components/views/CustomInstallView.tsx +250 -0
  48. package/src/client/components/views/DiagnosticsView.tsx +172 -0
  49. package/src/client/components/views/InstalledView.tsx +328 -0
  50. package/src/client/components/views/LogsView.tsx +150 -0
  51. package/src/client/components/views/MarketView.tsx +66 -0
  52. package/src/client/components/views/SettingsView.tsx +273 -0
  53. package/src/client/data/catalog.ts +38 -0
  54. package/src/client/data/host.ts +107 -0
  55. package/src/client/data/hub.ts +41 -0
  56. package/src/client/hooks/useCatalog.ts +71 -115
  57. package/src/client/hooks/useIncrementalList.ts +46 -0
  58. package/src/client/hooks/useLanguage.ts +40 -0
  59. package/src/client/hooks/useSettings.ts +90 -0
  60. package/src/client/hooks/useTaskQueue.ts +156 -39
  61. package/src/client/locales.ts +358 -38
  62. package/src/client/logic/constants.ts +86 -0
  63. package/src/client/{lib → logic}/failures.ts +95 -74
  64. package/src/client/logic/install-command.ts +56 -0
  65. package/src/client/logic/installed.ts +207 -0
  66. package/src/client/logic/normalize.ts +52 -0
  67. package/src/client/logic/urls.ts +125 -0
  68. package/src/client/styles/CustomInstallView.module.css +191 -0
  69. package/src/client/styles/DiagnosticsView.module.css +231 -0
  70. package/src/client/styles/Dropdown.module.css +42 -41
  71. package/src/client/styles/Header.module.css +100 -132
  72. package/src/client/styles/InstalledView.module.css +447 -0
  73. package/src/client/styles/List.module.css +6 -0
  74. package/src/client/styles/LogsView.module.css +503 -0
  75. package/src/client/styles/Modal.module.css +352 -70
  76. package/src/client/styles/SectionTabs.module.css +142 -0
  77. package/src/client/styles/SettingsView.module.css +232 -0
  78. package/src/client/styles/Toggle.module.css +60 -0
  79. package/src/client/styles/tokens.module.css +1 -0
  80. package/src/client/types.ts +23 -3
  81. package/src/server/http/routes.ts +414 -22
  82. package/src/server/index.ts +1 -1
  83. package/src/server/services/{install-types.ts → install/install-types.ts} +3 -1
  84. package/src/server/services/{install.ts → install/install.ts} +5 -5
  85. package/src/server/services/install/npm-check.ts +53 -0
  86. package/src/server/services/{npm-resolve.ts → install/npm-resolve.ts} +7 -5
  87. package/src/server/services/{preflight.ts → install/preflight.ts} +16 -4
  88. package/src/server/services/{task-queue.ts → install/task-queue.ts} +157 -63
  89. package/src/server/services/loader.ts +55 -1
  90. package/src/server/services/log.ts +167 -0
  91. package/src/server/services/probe.ts +157 -0
  92. package/src/server/services/{pending-restart.ts → profile/pending-restart.ts} +1 -1
  93. package/src/server/services/{profile.ts → profile/profile.ts} +31 -1
  94. package/src/server/services/settings.ts +79 -0
  95. package/lib/services/install.js +0 -4
  96. package/src/client/components/CatalogControls.tsx +0 -108
  97. package/src/client/components/icons.tsx +0 -129
  98. package/src/client/lib/catalog.ts +0 -293
  99. package/src/client/lib/env.ts +0 -20
  100. /package/lib/services/{install-types.js → install/install-types.js} +0 -0
  101. /package/lib/services/{installed-versions.js → profile/installed-versions.js} +0 -0
  102. /package/lib/services/{pending-restart.js → profile/pending-restart.js} +0 -0
  103. /package/lib/services/{progress.js → profile/progress.js} +0 -0
  104. /package/lib/types/services/{preflight.d.ts → install/preflight.d.ts} +0 -0
  105. /package/lib/types/services/{installed-versions.d.ts → profile/installed-versions.d.ts} +0 -0
  106. /package/lib/types/services/{progress.d.ts → profile/progress.d.ts} +0 -0
  107. /package/src/client/{lib → logic}/format.ts +0 -0
  108. /package/src/client/{lib → logic}/renderMarkdown.ts +0 -0
  109. /package/src/server/services/{installed-versions.ts → profile/installed-versions.ts} +0 -0
  110. /package/src/server/services/{progress.ts → profile/progress.ts} +0 -0
@@ -3,23 +3,25 @@
3
3
  * Website: https://dsh-plugin.org
4
4
  * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
5
  *
6
- * Persistent install/remove notifications.
6
+ * Failure classification, issue-body summarization and persistent
7
+ * install/remove notifications.
7
8
  *
8
- * Every settled task — success or failure — is appended to localStorage the
9
- * moment the backend reports it, so a result is never lost even when the
10
- * dialog was dismissed or nobody was watching the progress strip. Newest
11
- * first, capped at MAX entries. Failures keep their full output for the
12
- * issue-filing flow; successes are lightweight (no log).
9
+ * Every settled task is recorded two ways: the server-side system log
10
+ * (hub.log) and a client-side localStorage record that feeds the
11
+ * notification center — so a result is never lost even when the dialog
12
+ * was dismissed or nobody was watching the progress strip.
13
13
  */
14
14
  export interface NotificationRecord {
15
15
  id: number
16
- kind: 'install' | 'uninstall'
16
+ kind: 'install' | 'uninstall' | 'update'
17
17
  /** true = 成功(轻量记录);false = 失败(message 携带完整错误日志) */
18
18
  ok: boolean
19
19
  /** owner/repo(可能为空:请求层失败但拿不到仓库时) */
20
20
  repo: string
21
21
  /** 失败时的完整错误日志;成功记录为空串 */
22
22
  message: string
23
+ /** 更新提醒:目录里的新版本号(展示用;其余通知省略) */
24
+ version?: string
23
25
  /** 实际执行的安装/卸载命令(issue 预填时如实展示);历史记录可能缺失 */
24
26
  command?: string
25
27
  /** 尝试过的安装方式(npm registry 反查 + 实际执行命令,按先后顺序):失败提 Issue 时贴给作者,便于反推正确的 npm 包名 */
@@ -31,12 +33,84 @@ export interface NotificationRecord {
31
33
  const KEY = 'gro.ngilp-hsd.failure-records'
32
34
  const MAX = 50
33
35
 
34
- export type FailureKind = 'pnpmIgnoredBuild' | 'pluginPrepare' | 'repo'
36
+ /** 运行时 localStorage 访问:类型上不依赖 DOM lib(Node 测试环境也能编译),浏览器里取 window。 */
37
+ const storage = (): Storage | undefined =>
38
+ (globalThis as { localStorage?: Storage }).localStorage
39
+
40
+ /** 读取本地通知记录(损坏/不可用时返回空列表,不抛错)。列表最新在前,全部保留,只由「清空」按钮手动移除。 */
41
+ export function loadNotifications(): NotificationRecord[] {
42
+ try {
43
+ const raw = storage()?.getItem(KEY)
44
+ if (!raw) return []
45
+ const list = JSON.parse(raw) as unknown
46
+ if (!Array.isArray(list)) return []
47
+ return list
48
+ .filter((r): r is NotificationRecord =>
49
+ !!r && typeof r === 'object' && typeof (r as { message?: unknown }).message === 'string')
50
+ // ok 字段缺省时按失败渲染
51
+ .map((r) => ({ ...r, ok: r.ok === true }))
52
+ } catch {
53
+ return []
54
+ }
55
+ }
56
+
57
+ function save(list: NotificationRecord[]): void {
58
+ try {
59
+ storage()?.setItem(KEY, JSON.stringify(list))
60
+ } catch {
61
+ /* storage full / unavailable:仅保留内存态,界面仍可查看 */
62
+ }
63
+ }
64
+
65
+ /** 追加一条通知记录并持久化,返回更新后的完整列表(最新在前,超上限裁剪)。
66
+ * 每次成功/失败都各自留痕:同一插件的安装/卸载记录都完整保留、只由「清空」按钮手动移除,
67
+ * 不会因后续操作被自动覆盖清除。 */
68
+ export function addNotification(record: Omit<NotificationRecord, 'id' | 'at'>): NotificationRecord[] {
69
+ const prev = loadNotifications()
70
+ let id = Date.now()
71
+ while (prev.some((r) => r.id === id)) id += 1
72
+ const next = [{ ...record, id, at: id }, ...prev].slice(0, MAX)
73
+ save(next)
74
+ return next
75
+ }
76
+
77
+ /** 记录一次失败:携带完整错误日志,供通知中心查看/复制/提 Issue。 */
78
+ export function addFailure(record: Omit<NotificationRecord, 'id' | 'at' | 'ok'>): NotificationRecord[] {
79
+ return addNotification({ ...record, ok: false })
80
+ }
81
+
82
+ /** 记录一次成功:轻量记录,不带日志。 */
83
+ export function addSuccess(record: Omit<NotificationRecord, 'id' | 'at' | 'ok' | 'message'>): NotificationRecord[] {
84
+ return addNotification({ ...record, ok: true, message: '' })
85
+ }
86
+
87
+ /** 记录一条「发现新版本」更新提醒:成功类轻量记录,携带目录新版本号供通知中心展示。 */
88
+ export function addUpdateNotice(record: Omit<NotificationRecord, 'id' | 'at' | 'ok' | 'message'>): NotificationRecord[] {
89
+ return addNotification({ ...record, ok: true, message: '' })
90
+ }
91
+
92
+ /** 清空全部通知记录,返回空列表。 */
93
+ export function clearNotifications(): NotificationRecord[] {
94
+ save([])
95
+ return []
96
+ }
97
+
98
+ /** 按 id 删除单条通知记录,返回更新后的列表(仅移除该条,不影响其余记录)。 */
99
+ export function removeNotification(id: number): NotificationRecord[] {
100
+ const next = loadNotifications().filter((r) => r.id !== id)
101
+ save(next)
102
+ return next
103
+ }
104
+
105
+ export type FailureKind = 'npmTooOld' | 'pnpmIgnoredBuild' | 'pluginPrepare' | 'repo'
35
106
 
36
107
  /**
37
- * 失败归类,三态。无论底层机制如何(pnpm 白名单拦截 / 构建脚本被忽略 / prepare 失败),
108
+ * 失败归类,四态。无论底层机制如何(pnpm 白名单拦截 / 构建脚本被忽略 / prepare 失败),
38
109
  * 对用户而言结果都一样 —— 当前安装通道(npm 或 git)装不上,就是插件分发/依赖的问题,
39
- * 一律引导提 Issue:
110
+ * 一律引导提 Issue;唯一例外是本机 npm 自身版本过低导致的内部崩溃(见 npmTooOld):
111
+ * - npmTooOld:失败输出含 npm arborist 的 `edgesOut` 崩溃特征(build-ideal-tree.js 解 peer 依赖时
112
+ * 内部抛错,npm 11.6.0 前必现的已知缺陷,npm/cli#8261、#9787),或服务端已核实本机版本低于
113
+ * 阈值并打了 `[npm-too-low]` 标记 —— 是本机 npm 版本过低/自身缺陷,不是插件问题 → 引导升级 npm
40
114
  * - pnpmIgnoredBuild:插件依赖里的原生模块构建脚本被 pnpm 默认拦截(如 node-pty,
41
115
  * `ERR_PNPM_IGNORED_BUILDS`)。只影响带原生模块的插件,其他插件不受影响 —— 差异在
42
116
  * 插件的依赖选择,属插件依赖/打包问题 → 引导去仓库提 Issue(建议改用预编译版本)
@@ -45,6 +119,10 @@ export type FailureKind = 'pnpmIgnoredBuild' | 'pluginPrepare' | 'repo'
45
119
  * - repo:其余失败(含 git prepare 被 pnpm 白名单拦截等),默认按插件仓库问题引导提 Issue
46
120
  */
47
121
  export function classifyFailure(message: string): FailureKind {
122
+ // npm 内部崩溃(edgesOut)或服务端 [npm-too-low] 标记:是本机 npm 版本过低/自身缺陷,
123
+ // 不是插件问题 —— 必须最先判,否则该报错会被外层 ERR_PNPM_PREPARE_PACKAGE 吞成
124
+ // 「插件打包分发问题」,误导用户去提 Issue
125
+ if (/\[npm-too-low\]|edgesOut/i.test(message)) return 'npmTooOld'
48
126
  // 装后校验拦截(服务端 verifyInstalledEntry 标记):入口文件缺失 = git 分发缺构建产物,
49
127
  // 与 pluginPrepare 同类(插件打包/分发问题),引导去仓库提 Issue
50
128
  if (/\[packaging\]|entry file missing/i.test(message)) return 'pluginPrepare'
@@ -57,6 +135,13 @@ export function classifyFailure(message: string): FailureKind {
57
135
  return 'repo'
58
136
  }
59
137
 
138
+ /** 从服务端 `[npm-too-low]` 标记行提取本机 npm 版本(如 `[npm-too-low] npm@11.3.0` → "11.3.0");
139
+ * 历史记录无标记时返回 null,前端据此决定提示文案是否带具体版本。 */
140
+ export function npmTooLowVersion(message: string): string | null {
141
+ const m = message.match(/\[npm-too-low\]\s*npm@(\d+\.\d+\.\d+)/i)
142
+ return m ? m[1] : null
143
+ }
144
+
60
145
  /** 核心行特征:错误代码 / 生命周期脚本失败 / prepare 失败 / 描述性报错(子模块缺失、找不到等)/ 退出与宿主提示信息。 */
61
146
  const CORE_LINE_RE = /ERR_[A-Z_]+|ELIFECYCLE|Command failed|prepare-guard|Failed to prepare|exit code|\bprepare\b|pnpm failed in profile|git-hosted plugins build|submodule|not found|cannot find|no such|unable to|fatal|missing|error/i
62
147
  /** 提交 issue 时正文里错误摘要的上限字符数。GitHub 请求行上限 8192 字节,
@@ -103,67 +188,3 @@ export function coreErrorCode(message: string): string | null {
103
188
  const m = message.match(/\[?ERR_[A-Z_]+\]?/)
104
189
  return m ? m[0].replace(/^\[|\]$/g, '') : null
105
190
  }
106
-
107
- /** 运行时 localStorage 访问:类型上不依赖 DOM lib(Node 测试环境也能编译),浏览器里取 window。 */
108
- const storage = (): Storage | undefined =>
109
- (globalThis as { localStorage?: Storage }).localStorage
110
-
111
- /** 读取本地通知记录(损坏/不可用时返回空列表,不抛错)。列表最新在前,全部保留,只由「清空」按钮手动移除。 */
112
- export function loadNotifications(): NotificationRecord[] {
113
- try {
114
- const raw = storage()?.getItem(KEY)
115
- if (!raw) return []
116
- const list = JSON.parse(raw) as unknown
117
- if (!Array.isArray(list)) return []
118
- return list
119
- .filter((r): r is NotificationRecord =>
120
- !!r && typeof r === 'object' && typeof (r as { message?: unknown }).message === 'string')
121
- // ok 字段缺省时按失败渲染
122
- .map((r) => ({ ...r, ok: r.ok === true }))
123
- } catch {
124
- return []
125
- }
126
- }
127
-
128
- function save(list: NotificationRecord[]): void {
129
- try {
130
- storage()?.setItem(KEY, JSON.stringify(list))
131
- } catch {
132
- /* storage full / unavailable:仅保留内存态,界面仍可查看 */
133
- }
134
- }
135
-
136
- /** 追加一条通知记录并持久化,返回更新后的完整列表(最新在前,超上限裁剪)。
137
- * 每次成功/失败都各自留痕:同一插件的安装/卸载记录都完整保留、只由「清空」按钮手动移除,
138
- * 不会因后续操作被自动覆盖清除。 */
139
- export function addNotification(record: Omit<NotificationRecord, 'id' | 'at'>): NotificationRecord[] {
140
- const prev = loadNotifications()
141
- let id = Date.now()
142
- while (prev.some((r) => r.id === id)) id += 1
143
- const next = [{ ...record, id, at: id }, ...prev].slice(0, MAX)
144
- save(next)
145
- return next
146
- }
147
-
148
- /** 记录一次失败:携带完整错误日志,供通知中心查看/复制/提 Issue。 */
149
- export function addFailure(record: Omit<NotificationRecord, 'id' | 'at' | 'ok'>): NotificationRecord[] {
150
- return addNotification({ ...record, ok: false })
151
- }
152
-
153
- /** 记录一次成功:轻量记录,不带日志。 */
154
- export function addSuccess(record: Omit<NotificationRecord, 'id' | 'at' | 'ok' | 'message'>): NotificationRecord[] {
155
- return addNotification({ ...record, ok: true, message: '' })
156
- }
157
-
158
- /** 清空全部通知记录,返回空列表。 */
159
- export function clearNotifications(): NotificationRecord[] {
160
- save([])
161
- return []
162
- }
163
-
164
- /** 按 id 删除单条通知记录,返回更新后的列表(仅移除该条,不影响其余记录)。 */
165
- export function removeNotification(id: number): NotificationRecord[] {
166
- const next = loadNotifications().filter((r) => r.id !== id)
167
- save(next)
168
- return next
169
- }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * DSH-Plugin Hub — the community plugin marketplace for DeepSeek Harness.
3
+ * Website: https://dsh-plugin.org
4
+ * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
+ *
6
+ * Install target/command helpers: decide the install channel (npm package
7
+ * vs explicit-HTTPS GitHub), build the display command and normalize a raw
8
+ * install spec back to its owner/repo identity.
9
+ */
10
+ import type { HubPlugin } from '../types.ts'
11
+
12
+ /**
13
+ * 安装通道决策(用户无感知):目录探测到 npm 包名 → 用 npm 包名安装
14
+ * (走 npm registry tarball,更快、与 GitHub 网络无关);无 npm 包名 → git 直装。
15
+ * 返回值 target 即传给后端 /install 的安装目标(npm 包名 或 owner/repo)。
16
+ */
17
+ export function installTargetOf(p: HubPlugin): { target: string; via: 'npm' | 'github' } {
18
+ const pkg = (p.source?.npmPackage ?? '').trim()
19
+ const repo = (p.source?.repo ?? '').trim()
20
+ if (pkg && repo) return { target: pkg, via: 'npm' }
21
+ return { target: repo, via: 'github' }
22
+ }
23
+
24
+ /** 展示用安装命令(复制/弹窗):目录下发的权威命令优先(CLI-only 插件如 dsh-tui
25
+ * 需专属 profile(--profile dsh-tui),无法从 repo/npm 包名推断,必须用官方命令);
26
+ * 常规插件无目录命令时按通道回退生成:npm 显示包名,git 显示显式 HTTPS URL。 */
27
+ export function installCommandOf(p: HubPlugin, withProfile = false): string {
28
+ const { target, via } = installTargetOf(p)
29
+ if (via === 'npm') {
30
+ const cmd = p.install?.command
31
+ if (cmd) return cmd
32
+ } else {
33
+ const cmd = p.install?.githubCommand
34
+ if (cmd) return cmd
35
+ }
36
+ return via === 'npm'
37
+ ? `dsh plugin${withProfile ? ' --profile web' : ''} add ${target}`
38
+ : `dsh plugin${withProfile ? ' --profile web' : ''} add git+https://github.com/${target}.git`
39
+ }
40
+
41
+ /** Normalize a task/install target to its owner/repo display identity. */
42
+ export function repoFromInstallTarget(value: string): string {
43
+ const input = value.trim()
44
+ if (/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(input)) return input
45
+ const patterns = [
46
+ /^github:([^/]+)\/([^/]+)$/i,
47
+ /^(?:git\+)?https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:[?#].*)?$/i,
48
+ /^(?:git\+)?ssh:\/\/(?:git@)?github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:[?#].*)?$/i,
49
+ /^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?(?:[?#].*)?$/i,
50
+ ]
51
+ for (const pattern of patterns) {
52
+ const match = pattern.exec(input)
53
+ if (match !== null) return `${match[1]}/${match[2]}`
54
+ }
55
+ return value
56
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * DSH-Plugin Hub — the community plugin marketplace for DeepSeek Harness.
3
+ * Website: https://dsh-plugin.org
4
+ * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
+ *
6
+ * Installed-plugin model (pure logic, zero React/UI dependencies).
7
+ *
8
+ * Merges the local installed table (from the host's `/installed` route:
9
+ * npm name → manifest spec) with the catalog projection into a single
10
+ * `InstalledItem` view-model consumed by the Installed view. Also hosts the
11
+ * install matching / update detection used by the market view, so both
12
+ * views share one source of truth.
13
+ */
14
+ import type { HubPlugin } from '../types.ts'
15
+ import { HUB_REPO } from './constants.ts'
16
+ import { repoFromInstallTarget } from './install-command.ts'
17
+
18
+ /** 安装时记录的目录信号(versions 表项)。 */
19
+ export interface InstalledVersionSignal {
20
+ version: string
21
+ updatedAt: string
22
+ installedAt?: string
23
+ npmPackage?: string
24
+ }
25
+
26
+ /** 已安装项统一模型:目录元数据 + 宿主运行时信息的合并视图(驱动「已安装」tab)。 */
27
+ export interface InstalledItem {
28
+ /** npm 包名(profile 依赖 key) */
29
+ name: string
30
+ /** manifest spec(github:owner/repo、git+https://…、或 npm 版本号) */
31
+ spec: string
32
+ /** 目录里匹配到的插件;目录外/未收录的安装为 null */
33
+ plugin: HubPlugin | null
34
+ /** 仓库身份(owner/repo);纯 npm 安装无法识别时为 null */
35
+ repo: string | null
36
+ /** 安装到系统上的路径(profile/node_modules/<包名>);宿主未提供为 null */
37
+ installPath: string | null
38
+ /** 安装时记录的目录版本(无记录为 null) */
39
+ installedVersion: string | null
40
+ /** 安装时间(ISO,无记录为 null) */
41
+ installedAt: string | null
42
+ /** 目录当前最新版本(仓库无 release 为 null) */
43
+ catalogVersion: string | null
44
+ /** 是否有可用更新(有版本比版本,无版本比仓库更新时间) */
45
+ hasUpdate: boolean
46
+ /** 是否已加载进运行中 loader(官方 ctx.loader 对账);false = 已安装但未挂载(装完未重启) */
47
+ loaded: boolean
48
+ /** 是否真正的 dsh 插件(包内声明 dsh 配置 / 在 profile bundles 清单):
49
+ * 非 dsh 插件(如 GitHub 官方示例仓库)装上也不会被宿主加载,不提示「待重启」 */
50
+ dshCapable: boolean
51
+ }
52
+
53
+ /** 安装时记录的目录信号(按仓库小写 key 查表)。 */
54
+ function signalOf(repo: string | null, versions: Record<string, InstalledVersionSignal>): InstalledVersionSignal | null {
55
+ if (!repo) return null
56
+ return versions[repo.toLowerCase()] ?? null
57
+ }
58
+
59
+ /** 插件是否已安装:匹配 Git spec 中的 owner/repo,或 npm 通道安装的依赖包名;命中返回 npm 包名。 */
60
+ export function installedNameOf(
61
+ plugin: HubPlugin,
62
+ installed: Record<string, string>,
63
+ versions: Record<string, InstalledVersionSignal>,
64
+ ): string | null {
65
+ const repo = plugin.source?.repo
66
+ if (!repo) return null
67
+ const needle = repo.toLowerCase()
68
+ for (const [name, spec] of Object.entries(installed)) {
69
+ if (repoFromInstallTarget(spec).toLowerCase() === needle) return name
70
+ }
71
+ // npm 通道安装:profile 依赖 key 直接是 npm 包名。包名来源两处——目录数据(npmPackage),
72
+ // 或服务端 npm 优先反查持久化的映射(versions[repo].npmPackage,覆盖组织 scope 与 GitHub
73
+ // 用户名不一致、目录未下发包名的场景);命中任一并依赖 key 真实存在即视为已安装。
74
+ // 只认 npm 安装的依赖(spec 无法解析出仓库身份):git/本地安装的依赖身份由仓库匹配决定,
75
+ // 包名同名不代表同仓库——否则「git 装 A 仓库」会被「npm 包名同名但仓库不同」的目录条目
76
+ // 误认领成已安装(假阳性),installedItems 里出现同 key 双条目,排序一变化就渲染出幽灵行。
77
+ const pkg = ((plugin.source?.npmPackage || versions[repo.toLowerCase()]?.npmPackage) ?? '').toLowerCase()
78
+ if (pkg) {
79
+ for (const [name, spec] of Object.entries(installed)) {
80
+ if (isRepoLike(repoFromInstallTarget(spec))) continue
81
+ if (name.toLowerCase() === pkg) return name
82
+ }
83
+ }
84
+ return null
85
+ }
86
+
87
+ /** 该插件安装时记录的目录版本(无记录/未安装 → null)。 */
88
+ export function installedVersionOf(plugin: HubPlugin, versions: Record<string, InstalledVersionSignal>): string | null {
89
+ const repo = plugin.source?.repo
90
+ if (!repo) return null
91
+ return versions[repo.toLowerCase()]?.version ?? null
92
+ }
93
+
94
+ /**
95
+ * 是否有更新:仅对已安装插件有意义。
96
+ * 双信号判定——有 release 版本的比版本;无版本(repo 不打 tag)的比仓库最近更新时间
97
+ * (repoUpdatedAt,ISO 字符串字典序 = 时间序),更新时间变新说明有新提交。
98
+ */
99
+ export function hasUpdateOf(
100
+ plugin: HubPlugin,
101
+ installed: Record<string, string>,
102
+ versions: Record<string, InstalledVersionSignal>,
103
+ ): boolean {
104
+ if (installedNameOf(plugin, installed, versions) === null) return false
105
+ const repo = plugin.source?.repo
106
+ if (!repo) return false
107
+ const rec = versions[repo.toLowerCase()]
108
+ if (!rec) return false
109
+ if (plugin.version) return plugin.version !== rec.version
110
+ const current = plugin.dates?.repoUpdatedAt
111
+ return Boolean(current && rec.updatedAt && current > rec.updatedAt)
112
+ }
113
+
114
+ /** 依赖 spec 能否解析出仓库身份(owner/repo 形态才算)。 */
115
+ function isRepoLike(value: string): boolean {
116
+ return /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(value)
117
+ }
118
+
119
+ /** 已安装项 → 弹窗可用的伪插件对象:卸载确认弹窗只读名称/来源行,
120
+ * 自定义安装(目录外)没有完整目录数据,用它把 InstalledItem 适配成 HubPlugin。 */
121
+ export function pluginOfItem(item: InstalledItem): HubPlugin {
122
+ return {
123
+ slug: item.name,
124
+ displayName: item.plugin?.displayName ?? item.name,
125
+ source: item.repo ? { repo: item.repo } : undefined,
126
+ description: item.plugin?.description,
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Hub 自身(本插件 dsh-plugin)的识别:三维互证,杜绝误伤同名第三方包。
132
+ * 1) 依赖 key 固定为 `dsh-plugin`(本插件 npm 包名);
133
+ * 2) spec 为 `file:`/`link:` 本地链接(hub 以 file: 安装进宿主);
134
+ * 3) 或 spec 解析出的仓库身份就是自营仓库 `dshplugin/dsh-plugin-hub`。
135
+ * 命中即视为宿主内置本体:永不出现在「自定义安装」里,卸载接口也早已对
136
+ * 它 400 拒绝(服务端多维防线之一)——显示出来只会让用户误点卸载。
137
+ */
138
+ function isHubSelf(name: string, spec: string): boolean {
139
+ if (name !== 'dsh-plugin') return false
140
+ if (spec.startsWith('file:') || spec.startsWith('link:')) return true
141
+ const repo = repoFromInstallTarget(spec)
142
+ return isRepoLike(repo) && repo.toLowerCase() === HUB_REPO.toLowerCase()
143
+ }
144
+
145
+ /**
146
+ * 构建已安装项列表:目录插件按命中关系合并(拥有完整元数据),
147
+ * 剩余未认领的依赖 key 归为自定义安装(目录外,仅运行时信息)。
148
+ */
149
+ export function installedItemsOf(
150
+ plugins: HubPlugin[] | null,
151
+ installed: Record<string, string>,
152
+ versions: Record<string, InstalledVersionSignal>,
153
+ paths: Record<string, string> | null,
154
+ loadedNames: string[] | null,
155
+ dshCapableNames: string[] | null,
156
+ ): InstalledItem[] {
157
+ const items: InstalledItem[] = []
158
+ // 已被目录条目认领的依赖 key:同一依赖只归属一个目录条目(React key 必须唯一,
159
+ // 否则同名双条目排序一变化就渲染出幽灵行);剩余未认领的归自定义安装。
160
+ const claimed = new Set<string>()
161
+ // 目录匹配:遍历目录插件,命中 installedNameOf 即视为已安装并合并元数据
162
+ for (const p of plugins ?? []) {
163
+ const name = installedNameOf(p, installed, versions)
164
+ if (name === null || claimed.has(name)) continue
165
+ claimed.add(name)
166
+ const repo = p.source?.repo ?? null
167
+ const rec = signalOf(repo, versions)
168
+ items.push({
169
+ name,
170
+ spec: installed[name],
171
+ plugin: p,
172
+ repo,
173
+ installPath: paths?.[name] ?? null,
174
+ installedVersion: rec?.version ?? null,
175
+ installedAt: rec?.installedAt ?? null,
176
+ catalogVersion: p.version ?? null,
177
+ hasUpdate: hasUpdateOf(p, installed, versions),
178
+ loaded: loadedNames?.includes(name) ?? false,
179
+ dshCapable: dshCapableNames?.includes(name) ?? false,
180
+ })
181
+ }
182
+ // 自定义安装:依赖表里没被任何目录插件认领的条目(命令行安装的目录外插件)
183
+ for (const [name, spec] of Object.entries(installed)) {
184
+ if (claimed.has(name)) continue
185
+ // Hub 自身(本插件 dsh-plugin)由宿主内置加载,不是可管理的插件:永不进已安装列表,
186
+ // 避免出现在「自定义安装」里被误点卸载(服务端卸载接口对它的 400 拒绝是最后一道防线)
187
+ if (isHubSelf(name, spec)) continue
188
+ const parsed = repoFromInstallTarget(spec)
189
+ const repo = isRepoLike(parsed) ? parsed : null
190
+ const rec = signalOf(repo, versions)
191
+ items.push({
192
+ name,
193
+ spec,
194
+ plugin: null,
195
+ repo,
196
+ installPath: paths?.[name] ?? null,
197
+ installedVersion: rec?.version ?? null,
198
+ installedAt: rec?.installedAt ?? null,
199
+ catalogVersion: null,
200
+ // 目录外没有目录信号可比,无法判断是否有更新
201
+ hasUpdate: false,
202
+ loaded: loadedNames?.includes(name) ?? false,
203
+ dshCapable: dshCapableNames?.includes(name) ?? false,
204
+ })
205
+ }
206
+ return items
207
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * DSH-Plugin Hub — the community plugin marketplace for DeepSeek Harness.
3
+ * Website: https://dsh-plugin.org
4
+ * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
+ *
6
+ * Normalizer: maps the online API's short-key payload (s/o/n/vr/c/t/f/d/r/v/u/a/sg/fk)
7
+ * to HubPlugin so rendering only ever sees one shape.
8
+ */
9
+ import type { HubPlugin } from '../types.ts'
10
+
11
+ export function normalize(raw: Record<string, unknown>): HubPlugin {
12
+ if (typeof raw.s === 'string') {
13
+ return {
14
+ slug: raw.s,
15
+ ownerSlug: typeof raw.o === 'string' ? raw.o : undefined,
16
+ displayName: typeof raw.n === 'string' ? raw.n : undefined,
17
+ version: typeof raw.vr === 'string' ? raw.vr : undefined,
18
+ category: typeof raw.c === 'string' ? raw.c : undefined,
19
+ topics: Array.isArray(raw.t) ? (raw.t as string[]) : undefined,
20
+ features: Array.isArray(raw.f) ? (raw.f as string[]) : undefined,
21
+ description: typeof raw.d === 'string' ? raw.d : undefined,
22
+ // source 双形态:字符串 repo,或 { repo, npmPackage } 对象
23
+ source: typeof raw.r === 'string'
24
+ ? { repo: raw.r }
25
+ : raw.r !== null && typeof raw.r === 'object'
26
+ ? {
27
+ repo: typeof (raw.r as { repo?: unknown }).repo === 'string' ? (raw.r as { repo: string }).repo : undefined,
28
+ npmPackage: typeof (raw.r as { npmPackage?: unknown }).npmPackage === 'string' ? (raw.r as { npmPackage: string }).npmPackage : undefined,
29
+ }
30
+ : undefined,
31
+ // 安装信息:wi 仅在 false 时下发(缺省视为 true 可一键安装);
32
+ // ic/igc = 目录下发的权威安装命令(npm/git 通道各一条,CLI-only 插件如 dsh-tui 必须用)
33
+ install: raw.wi === false || typeof raw.ic === 'string' || typeof raw.igc === 'string'
34
+ ? {
35
+ webInstallable: raw.wi === false ? false : undefined,
36
+ command: typeof raw.ic === 'string' ? raw.ic : undefined,
37
+ githubCommand: typeof raw.igc === 'string' ? raw.igc : undefined,
38
+ }
39
+ : undefined,
40
+ compatibility: typeof raw.v === 'string' ? { status: raw.v } : undefined,
41
+ dates: {
42
+ repoUpdatedAt: typeof raw.u === 'string' ? raw.u : undefined,
43
+ addedAt: typeof raw.a === 'string' ? raw.a : undefined,
44
+ },
45
+ stats: {
46
+ stargazers_count: typeof raw.sg === 'number' ? raw.sg : undefined,
47
+ forks_count: typeof raw.fk === 'number' ? raw.fk : undefined,
48
+ },
49
+ }
50
+ }
51
+ return raw as unknown as HubPlugin
52
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * DSH-Plugin Hub — the community plugin marketplace for DeepSeek Harness.
3
+ * Website: https://dsh-plugin.org
4
+ * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
+ *
6
+ * URL builders: catalog detail pages, plugin site links and the pre-filled
7
+ * GitHub issue URL used by the failure modal. Pure functions — the issue
8
+ * URL stays within GitHub's 8192-byte request limit.
9
+ */
10
+ import type { EnvInfo, HubPlugin, LocaleId } from '../types.ts'
11
+ import { GITHUB_URL, PLUGIN_VERSION, SITE_URL } from './constants.ts'
12
+ import { MAX_CORE_CHARS, classifyFailure, coreErrorCode, summarizeError } from './failures.ts'
13
+
14
+ /** dsh-plugin.org 中文页挂在 /zh/ 前缀下,英文在根路径。 */
15
+ export function langPathOf(lang: LocaleId): string {
16
+ return lang === 'zh' ? 'zh/' : ''
17
+ }
18
+
19
+ /**
20
+ * 官网详情页两级路径:/plugins/{ownerSlug}/{slug};
21
+ * 缺 ownerSlug 时从 repo 推导(卡片详情按钮与弹窗来源行共用)。
22
+ */
23
+ export function pluginDetailUrl(plugin: HubPlugin, langPath: string): string {
24
+ const repo = plugin.source?.repo ?? ''
25
+ return `${SITE_URL}${langPath}plugins/${plugin.ownerSlug ?? repo.split('/')[0]?.toLowerCase() ?? ''}/${plugin.slug}`
26
+ }
27
+
28
+ /**
29
+ * 失败弹窗里仓库地址的跳转目标:官网详情页(含插件收录信息),
30
+ * 与 issue 正文的 Catalog 链接同一形式(单级 /plugins/{repo})。
31
+ */
32
+ export function pluginSiteUrl(repo: string): string {
33
+ return `${SITE_URL}plugins/${repo}`
34
+ }
35
+
36
+ /**
37
+ * 一键反馈 GitHub Issue 的预填链接:标题带「来自 dsh-plugin.org」标识,正文只带
38
+ * 核心信息 —— 原因判定 + 关键错误代码 + 尝试过的安装方式 + 宿主机器环境快照 +
39
+ * 错误核心摘要(完整日志太长,塞进 URL 会被 GitHub 以「request URL too long」拒绝,
40
+ * 故只收集重点)。错误弹窗、失败记录共用此逻辑。
41
+ * env 取不到时为 null,链接照常生成、只是少环境段。
42
+ * attempts(尝试过的安装方式,npm 反查 + 实际执行命令):作者看到我们查过/试过的命令,
43
+ * 组织 scope 与 GitHub 用户名不一致时也能直接指认正确的 npm 包名。
44
+ */
45
+ export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | null, command?: string, attempts?: string[]): string {
46
+ const title = `[dsh-plugin.org | dsh-plugin-hub] Install/Remove failed: ${repo}`
47
+ // 原因判定:按失败类型归类,便于作者判断问题是否出在插件侧。
48
+ // [packaging](预检/装后校验拦截)单独列:无论走 npm 还是 git 通道,都是分发物不完整
49
+ // (package.json 声明的入口文件在发布物里缺失),作者照此补齐即可。
50
+ const kind = classifyFailure(message)
51
+ const reason = /\[packaging\]/i.test(message)
52
+ ? 'plugin distribution is incomplete — the entry file declared in package.json is missing from the published package (github tarball or npm package); please commit build output or publish a complete package'
53
+ : kind === 'pluginPrepare'
54
+ ? 'plugin prepare/build script failed during install (packaging/distribution issue)'
55
+ : kind === 'pnpmIgnoredBuild'
56
+ ? 'plugin depends on a native module whose build script pnpm blocks by default (use a prebuilt variant)'
57
+ : 'plugin-side install failure'
58
+ const code = coreErrorCode(message)
59
+ // 按给定核心摘要预算构建完整预填 URL(含 URL 编码);预算可调,供超长时逐档缩小
60
+ const build = (coreChars: number): string => {
61
+ const body = [
62
+ '## Summary',
63
+ `- Cause: ${reason}`,
64
+ ...(code ? [`- Key error: \`${code}\``] : []),
65
+ '',
66
+ // 来源说明:标题(链官网)+ 一句来源(链仓库)+ 实际执行的安装命令(含 --profile)与执行结果,链接由常量动态拼接
67
+ `## [DSH-Plugin 插件中心](${SITE_URL}) · 安装 Plugin 失败错误信息`,
68
+ `本错误信息由 [dsh-plugin-hub](${GITHUB_URL}) 插件中心的安装程序自动生成,随本次安装失败一并提交。`,
69
+ `- 实际执行的安装命令:\`${command ?? `dsh plugin${env?.profile ? ` --profile ${env.profile}` : ''} add git+https://github.com/${repo}.git`}\``,
70
+ `- 执行结果:安装失败,未能安装该插件。`,
71
+ // 尝试过的安装方式(npm 反查 + 实际执行命令,按先后顺序):作者据此反推正确的
72
+ // npm 包名 —— 组织 scope 与 GitHub 用户名不一致时仅凭仓库名猜不到,作者看到我们
73
+ // 查过/试过的命令就能直接指认
74
+ ...(attempts && attempts.length > 0
75
+ ? ['', '## Attempted install channels(已尝试的安装方式)', ...attempts.map((a) => `- ${a}`)]
76
+ : []),
77
+ // [packaging] 场景:说明两种官方安装通道(npm 包 / git 直装),当前插件在对应通道下分发不完整
78
+ ...(/\[packaging\]/i.test(message)
79
+ ? [
80
+ '',
81
+ '## 安装方式说明',
82
+ 'DSH 插件支持两种官方安装通道:`dsh plugin add <npm-package>`(npm 分发,需发布完整构建产物)与 `dsh plugin add git+https://github.com/owner/repo.git`(Git 直装,仓库需提交构建产物或在 package.json 提供 `prepare` 脚本)。当前插件的分发物缺少 package.json 声明的入口文件,请按所用通道补齐后重新发布。',
83
+ ]
84
+ : []),
85
+ '',
86
+ '## Environment',
87
+ `- Plugin: \`${repo}\``,
88
+ // 宿主机器环境快照:便于作者本地复现;某字段缺失时如实写 unknown,不编造
89
+ ...(env
90
+ ? [
91
+ `- DSH: ${env.dshVersion ? `v${env.dshVersion}` : 'unknown'}`,
92
+ `- Plugin Hub: v${PLUGIN_VERSION}`,
93
+ `- Node: ${env.nodeVersion}`,
94
+ `- OS: ${env.platform} ${env.arch} (${env.release})`,
95
+ `- Profile: \`${env.profile}\``,
96
+ `- DSH Home: \`${env.dshHome}\``,
97
+ ]
98
+ : []),
99
+ // 该插件在插件中心的收录位置(详情页链接)
100
+ `- Catalog: [${pluginSiteUrl(repo)}](${pluginSiteUrl(repo)})`,
101
+ '',
102
+ '## Error (core)',
103
+ '',
104
+ '```',
105
+ // 核心错误收集器:只挑关键行(错误代码/构建失败/退出信息),去重截断,URL 才不至于超长
106
+ summarizeError(message, coreChars),
107
+ '```',
108
+ '',
109
+ '---',
110
+ // 底部署名:报错来自内置在 DeepSeek Harness 里的 dsh-plugin-hub 安装程序
111
+ `This error was produced by the [dsh-plugin-hub](${GITHUB_URL}) installer bundled with DeepSeek Harness.`,
112
+ ].join('\n')
113
+ return `https://github.com/${repo}/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`
114
+ }
115
+ // GitHub 请求行上限 8192 字节:先用宽松预算(最多 ~5K 核心错误)构建,超长再逐档缩小,保证最终 URL 不超限
116
+ const MAX_URL_CHARS = 7600
117
+ let url = build(MAX_CORE_CHARS)
118
+ if (url.length > MAX_URL_CHARS) {
119
+ for (const budget of [4000, 2500, 1400, 800, 400]) {
120
+ url = build(budget)
121
+ if (url.length <= MAX_URL_CHARS) break
122
+ }
123
+ }
124
+ return url
125
+ }