dsh-plugin 1.6.12 → 2.0.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 (131) hide show
  1. package/README.en.md +13 -13
  2. package/README.md +13 -13
  3. package/client/client.js +791 -761
  4. package/client/client.js.map +1 -1
  5. package/lib/http/catalog.js +2 -3
  6. package/lib/http/diagnostics.js +6 -7
  7. package/lib/http/install.js +47 -213
  8. package/lib/http/logs.js +1 -2
  9. package/lib/http/npm-release-age.js +42 -0
  10. package/lib/http/routes.js +8 -7
  11. package/lib/http/settings.js +2 -2
  12. package/lib/http/shared.js +2 -2
  13. package/lib/http/task.js +1 -2
  14. package/lib/index.js +5 -6
  15. package/lib/services/install/install-types.js +4 -4
  16. package/lib/services/install/install.js +1 -1
  17. package/lib/services/install/npm-check.js +3 -2
  18. package/lib/services/install/npm-resolve.js +9 -96
  19. package/lib/services/install/operation-log.js +53 -1
  20. package/lib/services/install/package-entry.js +6 -11
  21. package/lib/services/install/registry-check.js +2 -2
  22. package/lib/services/install/release-target.js +8 -9
  23. package/lib/services/install/task-queue.js +44 -48
  24. package/lib/services/npm-release-age.js +119 -0
  25. package/lib/services/probe.js +4 -4
  26. package/lib/services/profile/installed-versions.js +15 -21
  27. package/lib/services/profile/profile.js +16 -44
  28. package/lib/services/profile/progress.js +8 -11
  29. package/lib/services/settings.js +2 -5
  30. package/lib/types/http/catalog.d.ts +1 -1
  31. package/lib/types/http/install.d.ts +2 -2
  32. package/lib/types/http/npm-release-age.d.ts +2 -0
  33. package/lib/types/http/routes.d.ts +10 -12
  34. package/lib/types/http/shared.d.ts +5 -6
  35. package/lib/types/index.d.ts +7 -8
  36. package/lib/types/services/install/install-types.d.ts +12 -13
  37. package/lib/types/services/install/install.d.ts +1 -1
  38. package/lib/types/services/install/npm-resolve.d.ts +4 -16
  39. package/lib/types/services/install/operation-log.d.ts +9 -0
  40. package/lib/types/services/install/package-entry.d.ts +6 -11
  41. package/lib/types/services/install/release-target.d.ts +7 -8
  42. package/lib/types/services/install/task-queue.d.ts +25 -30
  43. package/lib/types/services/npm-release-age.d.ts +15 -0
  44. package/lib/types/services/probe.d.ts +4 -4
  45. package/lib/types/services/profile/installed-versions.d.ts +5 -7
  46. package/lib/types/services/profile/profile.d.ts +14 -18
  47. package/lib/types/services/profile/progress.d.ts +7 -10
  48. package/lib/types/services/settings.d.ts +0 -4
  49. package/package.json +1 -1
  50. package/src/client/components/PluginHubSection.tsx +26 -22
  51. package/src/client/components/catalog/CatalogList.tsx +2 -4
  52. package/src/client/components/catalog/PluginCard.tsx +2 -3
  53. package/src/client/components/layout/CatalogControls.tsx +5 -6
  54. package/src/client/components/layout/CatalogHeader.tsx +1 -2
  55. package/src/client/components/layout/CategoryTabs.tsx +2 -3
  56. package/src/client/components/layout/SectionTabs.tsx +3 -5
  57. package/src/client/components/modals/ConfirmDialog.tsx +4 -6
  58. package/src/client/components/modals/InstalledDetailModal.tsx +3 -6
  59. package/src/client/components/modals/LogsModal.tsx +7 -12
  60. package/src/client/components/modals/NotificationsModal.tsx +74 -24
  61. package/src/client/components/modals/ProgressView.tsx +3 -4
  62. package/src/client/components/modals/modals.tsx +75 -26
  63. package/src/client/components/ui/Dropdown.tsx +4 -6
  64. package/src/client/components/ui/Toggle.tsx +2 -3
  65. package/src/client/components/ui/icons.tsx +1 -2
  66. package/src/client/components/views/CustomInstallView.tsx +20 -178
  67. package/src/client/components/views/DiagnosticsView.tsx +3 -6
  68. package/src/client/components/views/InstalledView.tsx +6 -11
  69. package/src/client/components/views/MarketView.tsx +2 -4
  70. package/src/client/components/views/SettingsView.tsx +9 -32
  71. package/src/client/css-modules.d.ts +1 -1
  72. package/src/client/data/catalog.ts +2 -3
  73. package/src/client/data/host.ts +26 -3
  74. package/src/client/data/hub.ts +2 -3
  75. package/src/client/hooks/useCatalog.ts +79 -24
  76. package/src/client/hooks/useIncrementalList.ts +3 -5
  77. package/src/client/hooks/useLanguage.ts +4 -6
  78. package/src/client/hooks/useSettings.ts +4 -11
  79. package/src/client/hooks/useTaskQueue.ts +36 -42
  80. package/src/client/index.tsx +2 -3
  81. package/src/client/locales.ts +187 -114
  82. package/src/client/logic/constants.ts +1 -2
  83. package/src/client/logic/ensure-plugin-css.ts +8 -12
  84. package/src/client/logic/failures.ts +20 -19
  85. package/src/client/logic/format.ts +1 -1
  86. package/src/client/logic/install-command.ts +14 -35
  87. package/src/client/logic/installed.ts +15 -10
  88. package/src/client/logic/normalize.ts +3 -3
  89. package/src/client/logic/release-age.ts +19 -0
  90. package/src/client/logic/urls.ts +24 -21
  91. package/src/client/styles/CustomInstallView.module.css +3 -30
  92. package/src/client/styles/DiagnosticsView.module.css +3 -5
  93. package/src/client/styles/Dropdown.module.css +2 -4
  94. package/src/client/styles/Header.module.css +6 -7
  95. package/src/client/styles/InstalledView.module.css +4 -7
  96. package/src/client/styles/List.module.css +4 -5
  97. package/src/client/styles/LogsView.module.css +2 -3
  98. package/src/client/styles/Modal.module.css +46 -6
  99. package/src/client/styles/SectionTabs.module.css +2 -4
  100. package/src/client/styles/SettingsView.module.css +3 -5
  101. package/src/client/styles/Toggle.module.css +2 -4
  102. package/src/client/styles/tokens.module.css +14 -18
  103. package/src/client/types.ts +11 -11
  104. package/src/server/http/catalog.ts +2 -3
  105. package/src/server/http/diagnostics.ts +6 -7
  106. package/src/server/http/install.ts +47 -208
  107. package/src/server/http/logs.ts +1 -2
  108. package/src/server/http/npm-release-age.ts +42 -0
  109. package/src/server/http/routes.ts +12 -12
  110. package/src/server/http/settings.ts +2 -2
  111. package/src/server/http/shared.ts +5 -6
  112. package/src/server/http/task.ts +1 -2
  113. package/src/server/index.ts +7 -8
  114. package/src/server/services/install/install-types.ts +12 -13
  115. package/src/server/services/install/install.ts +0 -1
  116. package/src/server/services/install/npm-check.ts +3 -2
  117. package/src/server/services/install/npm-resolve.ts +9 -99
  118. package/src/server/services/install/operation-log.ts +47 -1
  119. package/src/server/services/install/package-entry.ts +6 -11
  120. package/src/server/services/install/registry-check.ts +2 -2
  121. package/src/server/services/install/release-target.ts +8 -9
  122. package/src/server/services/install/task-queue.ts +51 -54
  123. package/src/server/services/npm-release-age.ts +107 -0
  124. package/src/server/services/probe.ts +4 -4
  125. package/src/server/services/profile/installed-versions.ts +16 -22
  126. package/src/server/services/profile/profile.ts +16 -42
  127. package/src/server/services/profile/progress.ts +8 -11
  128. package/src/server/services/settings.ts +2 -9
  129. package/lib/services/install/preflight.js +0 -178
  130. package/lib/types/services/install/preflight.d.ts +0 -23
  131. package/src/server/services/install/preflight.ts +0 -184
@@ -0,0 +1,107 @@
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
+ * npm 包最新版本的发布时间查询:为「已安装插件是否有更新」提供供应链门禁依据。
7
+ * pnpm 对发布不满 24h(dsh profile minimumReleaseAge=1440)的版本会静默回退到旧版本,
8
+ * 因此发布未满门槛的版本不该提示更新(否则用户点了也装不上)。
9
+ * 结果按包名内存缓存,同一次会话内不重复请求;失败/超时/响应过大一律返回 null,绝不抛错、绝不阻塞。
10
+ */
11
+ import { get as httpsGet } from 'node:https'
12
+ import { validPackageName } from './profile/profile.ts'
13
+ import { fetchViaCurl } from './probe.ts'
14
+
15
+ export interface NpmRelease {
16
+ version: string
17
+ publishedAt: string
18
+ }
19
+
20
+ /** 请求体包名数量上限:只为已安装插件按需查询,几十个足矣,封顶防滥用。 */
21
+ export const MAX_RELEASE_AGE_PACKAGES = 50
22
+ /** 单包查询超时(ms):慢网络下失败即返回 null,不拖住整个响应。 */
23
+ export const RELEASE_AGE_TIMEOUT_MS = 8000
24
+ /** 响应体上限:完整 packument 可能达数 MB,超限直接放弃,避免异常大包拖垮服务。 */
25
+ const MAX_PACKUMENT_BYTES = 10 * 1024 * 1024
26
+ export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org'
27
+
28
+ /** 会话内缓存(包名 → 版本与发布时间):只存成功结果,一次网络抖动不会把判断永久粘住。 */
29
+ const releaseCache = new Map<string, NpmRelease>()
30
+
31
+ /** 从完整 packument 取 dist-tags.latest 对应版本及其发布时间;缺任一字段返回 null。 */
32
+ export function latestReleaseOf(packument: unknown): NpmRelease | null {
33
+ if (packument === null || typeof packument !== 'object') return null
34
+ const doc = packument as { 'dist-tags'?: unknown; time?: unknown }
35
+ const tags = doc['dist-tags']
36
+ const version = tags !== null && typeof tags === 'object' ? (tags as { latest?: unknown }).latest : undefined
37
+ if (typeof version !== 'string' || version === '') return null
38
+ const time = doc.time
39
+ const publishedAt = time !== null && typeof time === 'object' ? (time as Record<string, unknown>)[version] : undefined
40
+ if (typeof publishedAt !== 'string' || publishedAt === '') return null
41
+ return { version, publishedAt }
42
+ }
43
+
44
+ /** 请求体 → 去重后的合法包名列表;非数组 / 超量 / 含非法包名一律 null(拒绝非法输入)。 */
45
+ export function parseReleaseAgeRequest(body: unknown): string[] | null {
46
+ if (body === null || typeof body !== 'object') return null
47
+ const raw = (body as { packages?: unknown }).packages
48
+ if (!Array.isArray(raw) || raw.length > MAX_RELEASE_AGE_PACKAGES) return null
49
+ const names = new Set<string>()
50
+ for (const item of raw) {
51
+ if (typeof item !== 'string' || item === '' || item.startsWith('-') || !validPackageName(item)) return null
52
+ names.add(item)
53
+ }
54
+ return [...names]
55
+ }
56
+
57
+ /** node:https 直连 GET,仅接受 200;超时/超限/网络失败返回 null。 */
58
+ function getJson(url: string, timeoutMs: number): Promise<string | null> {
59
+ return new Promise((resolve) => {
60
+ let done = false
61
+ const finish = (value: string | null) => { if (!done) { done = true; resolve(value) } }
62
+ const req = httpsGet(url, { timeout: timeoutMs, headers: { accept: 'application/json' } }, (res) => {
63
+ if (res.statusCode !== 200) { res.resume(); finish(null); return }
64
+ const declared = Number(res.headers['content-length'])
65
+ if (Number.isFinite(declared) && declared > MAX_PACKUMENT_BYTES) { res.destroy(); finish(null); return }
66
+ let body = ''
67
+ let bytes = 0
68
+ res.on('data', (chunk: Buffer) => {
69
+ bytes += chunk.length
70
+ if (bytes > MAX_PACKUMENT_BYTES) { res.destroy(); finish(null); return }
71
+ body += chunk.toString()
72
+ })
73
+ res.on('end', () => finish(body))
74
+ res.on('error', () => finish(null))
75
+ })
76
+ req.on('timeout', () => req.destroy())
77
+ req.on('error', () => finish(null))
78
+ })
79
+ }
80
+
81
+ /** 拉取一个包的完整 packument 正文;配了代理时复用与安装/目录同一套代理口径(curl 子进程注入 proxy env)。 */
82
+ async function fetchPackument(url: string, proxy: string, timeoutMs: number): Promise<string | null> {
83
+ if (proxy !== '') {
84
+ const r = await fetchViaCurl(url, proxy, timeoutMs)
85
+ return r.ok && r.body !== '' && Buffer.byteLength(r.body) <= MAX_PACKUMENT_BYTES ? r.body : null
86
+ }
87
+ return getJson(url, timeoutMs)
88
+ }
89
+
90
+ /** 查询一个包的最新版本与发布时间(成功结果缓存,同会话内同包不重复请求);registry 为空回退官方源,失败返回 null。 */
91
+ export async function lookupReleaseAge(
92
+ packageName: string,
93
+ registry: string,
94
+ proxy: string,
95
+ timeoutMs = RELEASE_AGE_TIMEOUT_MS,
96
+ ): Promise<NpmRelease | null> {
97
+ const cached = releaseCache.get(packageName)
98
+ if (cached !== undefined) return cached
99
+ const base = registry.replace(/\/+$/, '') || DEFAULT_NPM_REGISTRY
100
+ const body = await fetchPackument(`${base}/${encodeURIComponent(packageName)}`, proxy, timeoutMs)
101
+ if (body === null) return null
102
+ let parsed: unknown
103
+ try { parsed = JSON.parse(body) } catch { return null }
104
+ const release = latestReleaseOf(parsed)
105
+ if (release !== null) releaseCache.set(packageName, release)
106
+ return release
107
+ }
@@ -99,7 +99,7 @@ function macSystemProxy(): string | null {
99
99
  * `http=host:port;https=host:port`(按协议分别配置)。
100
100
  * 字符类必须同时排除 `/` 与 `=`:只排除 `=` 时,带 scheme 的值会让「主机名」吞掉 `//`,
101
101
  * 拼出 `http:////127.0.0.1:10793` 这种立刻失败的非法地址 —— 代理本来是通的,却把诊断、
102
- * 目录拉取、npm/git 预检全判成「不可达」。
102
+ * 目录拉取、安装前网络探测全判成「不可达」。
103
103
  */
104
104
  export function parseWinProxyServer(stdout: string): string | null {
105
105
  const m = /([^:\s=/]+):(\d+)/.exec(stdout)
@@ -220,13 +220,13 @@ export function probeUrl(url: string, proxy: string, timeoutMs: number): Promise
220
220
  }
221
221
 
222
222
  /**
223
- * git 通道真实克隆握手探测:spawn git ls-remote(https 传输,与 pnpm 克隆前的
223
+ * git 真实克隆握手探测:spawn git ls-remote(https 传输,与 pnpm 克隆前的
224
224
  * ref 握手一致),注入代理 env,GIT_TERMINAL_PROMPT=0 防凭据提示挂起。
225
225
  *
226
226
  * 为什么要真实 git 而非 curl 打网页:网页「能打开」和 git「能克隆」是两码事 ——
227
227
  * 防火墙/代理常按端口与协议区分,HTTP 页可达不代表 git 传输可达。这里测的就是
228
- * 克隆握手本身,回答「github:owner/repo 装不装得动」。
229
- * 供系统诊断 GitHub 通道使用;探测目标用 dshplugin/hello-dsh 小仓库(秒级完成,
228
+ * 克隆握手本身,回答「能不能连上 github.com」。
229
+ * 供系统诊断 GitHub 可达性使用;探测目标用 dshplugin/hello-dsh 小仓库(秒级完成,
230
230
  * 不打 17MB 的 dsh-plugin-hub 主页)。
231
231
  */
232
232
  export function gitLsRemote(url: string, proxy: string, timeoutMs: number): Promise<ProbeResult> {
@@ -3,23 +3,19 @@
3
3
  * Website: https://dsh-plugin.org
4
4
  * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
5
  *
6
- * Installed-plugin version tracking.
6
+ * 已安装插件版本追踪。
7
7
  *
8
- * The installed table (`readInstalled` in routes.ts) only knows the manifest
9
- * spec of each Git dependency, which carries no version.
10
- * To let the Plugin Hub tell "an update is available", we record the catalog
11
- * signal present at install time into a small per-profile JSON file, then
12
- * compare it against the catalog's current signal later.
8
+ * 已安装表(routes.ts 的 `readInstalled`)只知道每个 Git 依赖的 manifest spec,不含版本号。
9
+ * 为让插件中心能判断「有更新」,我们把安装时的目录信号记进一个 per-profile 的小 JSON 文件,
10
+ * 之后再与目录当前信号比对。
13
11
  *
14
- * Two signals cover every repo:
15
- * - version: the catalog's latest release tag (e.g. v1.2.3). Repos that
16
- * publish releases are compared on this.
17
- * - updatedAt: the repo's last push time (GitHub pushed_at, `repoUpdatedAt`).
18
- * Repos that never publish a release fall back to this: a newer push time
19
- * means new commits → an update is available.
12
+ * 两个信号覆盖所有仓库:
13
+ * - version:目录最新 release tag(如 v1.2.3),发 release 的仓库按它比对。
14
+ * - updatedAt:仓库最近 push 时间(GitHub pushed_at,即 `repoUpdatedAt`),
15
+ * 从不发 release 的仓库回退用它:push 时间变新即有新提交 → 有更新。
20
16
  *
21
- * Storage: `DSH_HOME/profiles/<profile>/gro.ngilp-hsd-versions.json`
22
- * { "<owner>/<repo>" (lowercase): { version: string, updatedAt: string, installedAt: string } }
17
+ * 存储:`DSH_HOME/profiles/<profile>/gro.ngilp-hsd-versions.json`
18
+ * { "<owner>/<repo>"(小写): { version, updatedAt, installedAt } }
23
19
  */
24
20
  import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
25
21
  import { homedir } from 'node:os'
@@ -31,7 +27,7 @@ export interface InstalledVersionRecord {
31
27
  /** 安装时的仓库最近更新时间(ISO,GitHub pushed_at);无 release 仓库的更新依据 */
32
28
  updatedAt: string
33
29
  installedAt: string
34
- /** npm 优先通道反查命中的官方 npm 包名(repo → 包名映射):目录数据可能未下发
30
+ /** 安装时记录在案的官方 npm 包名(repo → 包名映射):目录数据可能未下发
35
31
  * npmPackage(组织 scope 与 GitHub 用户名不一致),客户端据此把依赖 key 匹配回仓库 */
36
32
  npmPackage?: string
37
33
  }
@@ -51,21 +47,19 @@ export function readInstalledVersions(profile: string): InstalledVersionTable {
51
47
  }
52
48
  }
53
49
 
54
- /** Record the catalog signals (version + repo updated time) present at install time for one repo. */
50
+ /** 记录某仓库安装时的目录信号(版本 + 仓库更新时间)。 */
55
51
  export function recordInstalledVersion(profile: string, repo: string, version: string, updatedAt: string): void {
56
52
  const table = readInstalledVersions(profile)
57
53
  const key = repo.toLowerCase()
58
- // 合并写入:保留已记录的 npmPackage(npm 优先通道的 repo → 包名映射),
54
+ // 合并写入:保留已记录的 npmPackage(repo → 包名映射),
59
55
  // 客户端同步版本信号时不能把它覆盖掉
60
56
  table[key] = { ...table[key], version, updatedAt, installedAt: new Date().toISOString() }
61
57
  writeVersions(profile, table)
62
58
  }
63
59
 
64
60
  /**
65
- * Record the npm package name resolved for a repo (npm-first install channel).
66
- * Called at `/install` time so the mapping exists even before the task finishes;
67
- * harmless when the repo is not (yet) installed — the client only treats a repo
68
- * as installed when its dependency key actually exists.
61
+ * 记录某仓库解析出的 npm 包名。在 `/install` 时调用,让映射在任务完成前就存在;
62
+ * 仓库尚未安装时也无害 —— 客户端只在依赖 key 真实存在时才把仓库当作已安装。
69
63
  */
70
64
  export function recordResolvedNpmPackage(profile: string, repo: string, npmPackage: string): void {
71
65
  const table = readInstalledVersions(profile)
@@ -74,7 +68,7 @@ export function recordResolvedNpmPackage(profile: string, repo: string, npmPacka
74
68
  writeVersions(profile, table)
75
69
  }
76
70
 
77
- /** Forget the recorded version for one repo (called on uninstall). */
71
+ /** 清除某仓库记录的版本(卸载时调用)。 */
78
72
  export function removeInstalledVersion(profile: string, repo: string): void {
79
73
  const table = readInstalledVersions(profile)
80
74
  delete table[repo.toLowerCase()]
@@ -12,7 +12,7 @@ import { basename, dirname, join } from 'node:path'
12
12
  import { PACKAGE_RE, REPO_RE } from '../install/install-types.ts'
13
13
  import { githubReleaseTarget } from '../install/release-target.ts'
14
14
 
15
- /** Resolve the active profile from the booted CLI args, falling back to `web`. */
15
+ /** 从启动参数解析当前 profile,取不到落回 `web`。 */
16
16
  export function readProfileArg(fallback = 'web'): string {
17
17
  return profileFromArgv(process.argv, fallback)
18
18
  }
@@ -44,43 +44,19 @@ export function profileFromArgv(argv: readonly string[], fallback = 'web'): stri
44
44
  return fallback
45
45
  }
46
46
 
47
- /** Resolve a profile directory (`DSH_HOME` or `~/.dsh`). */
47
+ /** 解析 profile 目录(`DSH_HOME` 或 `~/.dsh`)。 */
48
48
  export function profileDirectory(profile: string): string {
49
49
  return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'profiles', profile)
50
50
  }
51
51
 
52
- /** Build a safe explicit HTTPS Git target, or null when the repo is unsafe.
53
- * When `ref` is given it is appended as a Git ref (`#tag` / `#commit`) so an
54
- * explicitly pinned revision reaches the installer instead of being dropped. */
52
+ /** 把仓库身份转成规范的 GitHub 目标(git+https),仓库不合法时返回 null。
53
+ * 带 ref 时按 Git ref(`#tag` / `#commit`)拼接;安装通道已不再使用 git 目标,此函数仅做身份规范化。 */
55
54
  export function githubTarget(repo: string, ref?: string | null): string | null {
56
55
  if (typeof repo !== 'string' || !REPO_RE.test(repo)) return null
57
56
  const url = `git+https://github.com/${repo}.git`
58
57
  return ref === undefined || ref === null || ref === '' ? url : `${url}#${ref}`
59
58
  }
60
59
 
61
- /**
62
- * Extract a validated explicit Git ref (`#<ref>`) from a GitHub spec, or null.
63
- * Only the fragment of a Git-style address qualifies — a pinned tag/commit is
64
- * user intent and must survive target normalization, whereas `#…` on a plain
65
- * web URL (`https://github.com/o/r#readme`) is a page anchor, not a ref.
66
- * Git-style = `git+…` / `github:` / `git@…` / `…​.git` / bare `owner/repo`.
67
- * Grammar mirrors a Git ref: no leading `.`/`-`, no `#`/whitespace.
68
- */
69
- const GIT_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/
70
-
71
- export function githubRefOf(value: string): string | null {
72
- if (typeof value !== 'string') return null
73
- const input = value.trim()
74
- const hash = input.indexOf('#')
75
- if (hash <= 0) return null
76
- const base = input.slice(0, hash)
77
- if (githubRepoOf(base) === null) return null
78
- const gitStyle = /^(?:git\+|github:|git@)/i.test(base) || /\.git$/i.test(base) || !base.includes('://')
79
- if (!gitStyle) return null
80
- const ref = input.slice(hash + 1)
81
- return GIT_REF_RE.test(ref) ? ref : null
82
- }
83
-
84
60
  /**
85
61
  * DSH 命令行前缀(官方唯一形式 `dsh plugin --profile <name> <add|update> <target>`)—— 提取其中的安装目标;
86
62
  * 非命令输入原样返回。兼容「直接粘贴完整命令」(防用户输错):--profile 必填且只有长选项
@@ -113,7 +89,7 @@ export function globalNpmPackagesOf(value: string): string[] | null {
113
89
  return packages
114
90
  }
115
91
 
116
- /** Extract an owner/repo identity from a catalog value or an installed Git spec. */
92
+ /** 从目录值或已装 Git spec 中提取 owner/repo 身份。 */
117
93
  export function githubRepoOf(value: string): string | null {
118
94
  if (typeof value !== 'string') return null
119
95
  const input = value.trim()
@@ -137,25 +113,24 @@ export function githubRepoOf(value: string): string | null {
137
113
  return null
138
114
  }
139
115
 
140
- /** Validate an npm package name (uninstall target grammar). */
116
+ /** 校验 npm 包名(卸载目标的语法)。 */
141
117
  export function validPackageName(name: string): boolean {
142
118
  return typeof name === 'string' && PACKAGE_RE.test(name)
143
119
  }
144
120
 
145
121
  /**
146
- * The exact `allowBuilds` key pnpm printed in its
147
- * `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED` hint. For Git installs pnpm
148
- * matches against `name@<resolved fetch URL>` — the URL it actually fetches,
149
- * e.g. `https://codeload.github.com/<owner>/<repo>/tar.gz/<commit>` — not the
150
- * `git+ssh://`/branch spec. A bare name or a `git+` spec does not match, so
151
- * the only reliable key is the one pnpm prints verbatim. Null when absent.
122
+ * 取 pnpm 在 `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED` 提示里原样打印的 `allowBuilds` key。
123
+ * Git 安装时 pnpm 按 `name@<实际 fetch URL>`(如
124
+ * `https://codeload.github.com/<owner>/<repo>/tar.gz/<commit>`)匹配,而不是 `git+ssh://`
125
+ * 或分支 spec —— 裸包名与 `git+` spec 都匹配不上,唯一可靠的 key 就是 pnpm 原样输出的那个。
126
+ * 无则返回 null。
152
127
  */
153
128
  export function parseAllowBuildsKey(output: string): string | null {
154
129
  const m = /^[ \t]*(\S+@\S+):[ \t]*true[ \t]*$/m.exec(output)
155
130
  return m === null ? null : m[1].trim()
156
131
  }
157
132
 
158
- /** Quote a YAML block-mapping key when a plain scalar would be invalid. */
133
+ /** 当裸标量非法时,为 YAML 块映射 key 加引号。 */
159
134
  function quoteYamlKey(key: string): string {
160
135
  if (/^[-?:,[\]{}#&*!|>'"%@`]/.test(key) || /:(\s|$)/.test(key)) {
161
136
  return `'${key.replace(/'/g, "''")}'`
@@ -164,15 +139,14 @@ function quoteYamlKey(key: string): string {
164
139
  }
165
140
 
166
141
  /**
167
- * Add one `allowBuilds` entry to the profile's pnpm-workspace.yaml, merging
168
- * with existing entries and leaving the rest of the yaml intact. Broken
169
- * placeholder entries (pnpm's failed-install bug writes a literal
170
- * `set this to true or false` value) are dropped on rewrite.
142
+ * 向 profile 的 pnpm-workspace.yaml 追加一条 `allowBuilds` 记录:与已有条目合并,
143
+ * 其余 yaml 保持不变;重写时丢弃损坏的占位条目(pnpm 安装失败的 bug 会写入
144
+ * 字面量 `set this to true or false`)。
171
145
  */
172
146
  export function addAllowBuildsKey(profile: string, key: string): void {
173
147
  const file = join(profileDirectory(profile), 'pnpm-workspace.yaml')
174
148
  let yaml = ''
175
- try { yaml = readFileSync(file, 'utf8') } catch { /* created below */ }
149
+ try { yaml = readFileSync(file, 'utf8') } catch { /* 文件不存在,下方创建 */ }
176
150
  const eol = /\r\n/.test(yaml) ? '\r\n' : '\n'
177
151
  const blockRe = /allowBuilds:[ \t]*\r?\n((?:[ \t]+[^\r\n]*\r?\n?)*)/g
178
152
  const map = new Map<string, string>()
@@ -3,24 +3,21 @@
3
3
  * Website: https://dsh-plugin.org
4
4
  * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
5
  *
6
- * Pure progress-estimation helpers shared by the install runner.
6
+ * 安装执行器共用的纯进度估算工具。
7
7
  *
8
- * The dsh CLI wraps pnpm, which refreshes its `Progress: …` line in place
9
- * with carriage returns, so raw output arrives as a stream of partial
10
- * fragments. These helpers normalize one fragment into a clean terminal
11
- * line and map it onto a 0-100 estimate; keeping them free of I/O makes the
12
- * estimation logic directly unit-testable (see tests/progress.test.ts).
8
+ * dsh CLI 包装了 pnpm,pnpm 用回车原地刷新 `Progress: …` 行,因此原始输出是一串碎片。
9
+ * 这些工具把单个碎片归一化成干净的终端行,并映射到 0-100 估算值;不碰 I/O
10
+ * 让估算逻辑可直接单测(见 tests/progress.test.ts)。
13
11
  */
14
12
 
15
- /** Strip the `[out]` / `[err]` source prefix and ANSI escape sequences. */
13
+ /** 剥掉 `[out]` / `[err]` 来源前缀与 ANSI 转义序列。 */
16
14
  export function cleanLine(raw: string): string {
17
15
  return raw.replace(/^\[(?:err|out)\]\s*/, '').replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').trim()
18
16
  }
19
17
 
20
18
  /**
21
- * Estimate 0-100 progress from one CLI output line. pnpm emits
22
- * `Progress: resolved N, reused X, downloaded Y, added Z` during the fetch
23
- * phase; resolution/install phase lines bump the estimate towards done.
19
+ * 从单行 CLI 输出估算 0-100 进度。pnpm 在 fetch 阶段输出
20
+ * `Progress: resolved N, reused X, downloaded Y, added Z`;解析/安装阶段的行把估算值推向完成。
24
21
  */
25
22
  export function estimateProgress(line: string): number {
26
23
  const progress = line.match(/Progress:\s*(.+)/)
@@ -28,7 +25,7 @@ export function estimateProgress(line: string): number {
28
25
  let resolved = 0
29
26
  let handled = 0
30
27
  for (const part of progress[1].split(',')) {
31
- // pnpm emits `label count` tokens, e.g. `resolved 100, reused 40`
28
+ // pnpm 输出 `label count` 形式的 token,如 `resolved 100, reused 40`
32
29
  const [label, raw] = part.trim().split(/\s+/)
33
30
  const value = Number(raw)
34
31
  if (label === 'resolved') resolved = value
@@ -3,9 +3,8 @@
3
3
  * Website: https://dsh-plugin.org
4
4
  * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
5
  *
6
- * Hub settings persistence: one `hub-settings.json` per active profile
7
- * (next to pnpm-workspace.yaml). All fields optional; missing keys fall
8
- * back to DEFAULT_SETTINGS so old files stay readable as new options land.
6
+ * 插件中心设置持久化:每个活跃 profile 一个 `hub-settings.json`(与 pnpm-workspace.yaml 同目录)。
7
+ * 所有字段可选;缺失的 key 回退到 DEFAULT_SETTINGS,新选项上线后旧文件仍可读。
9
8
  */
10
9
  import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
11
10
  import { dirname, join } from 'node:path'
@@ -20,10 +19,6 @@ export interface HubSettings {
20
19
  npmRegistry: string
21
20
  /** 启用命令行安装 NPM 包(默认开;关掉后输入 npm 包名安装被拦截) */
22
21
  enableNpmInstall: boolean
23
- /** 启用命令行安装 GitHub 源码(默认开;关掉后输入 GitHub 地址安装被拦截) */
24
- enableGitInstall: boolean
25
- /** 启用命令行粘贴 dsh plugin 命令安装(默认开;关掉后命令输入被禁用) */
26
- enableDshInstall: boolean
27
22
  /** 日志存放位置覆盖(空串 = 默认 ~/.dsh/profiles/<profile>/hub.log;可填目录或 .log 文件路径) */
28
23
  logPath: string
29
24
  }
@@ -33,8 +28,6 @@ export const DEFAULT_SETTINGS: HubSettings = {
33
28
  proxy: '',
34
29
  npmRegistry: '',
35
30
  enableNpmInstall: true,
36
- enableGitInstall: true,
37
- enableDshInstall: true,
38
31
  logPath: '',
39
32
  }
40
33
 
@@ -1,178 +0,0 @@
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
- * 安装前预检:对 GitHub 源的插件,检查其分发(codeload tarball)里是否真的包含
7
- * package.json 声明的文件(main / exports["."] / dsh.bundle.patch)。git 分发常不提交
8
- * 构建产物(lib/ 等),这类残缺包装完会让宿主重启加载插件树时 ERR_MODULE_NOT_FOUND
9
- * 直接崩溃 —— 预检在动手安装前就把它们拦下,避免白装一次。
10
- * npm 包信任 registry 的完整性,直接放行;任何无法预检的情况也放行,交给装后
11
- * 校验(verifyInstalledEntry)兜底,保证预检自身失败不会卡死安装流程。
12
- */
13
- import { execFile } from 'node:child_process';
14
- import { createWriteStream, mkdtempSync, rmSync } from 'node:fs';
15
- import { get } from 'node:https';
16
- import { tmpdir } from 'node:os';
17
- import { join } from 'node:path';
18
- import { pipeline } from 'node:stream/promises';
19
- import { promisify } from 'node:util';
20
- import { githubRefOf, githubRepoOf } from '../profile/profile.js';
21
- import { githubReleaseTarget } from './release-target.js';
22
- import { requiredDistributionFiles, resolveBundlePatchFiles } from './package-entry.js';
23
- const execFileAsync = promisify(execFile);
24
- /** 单次网络操作超时(ms):git ls-remote / codeload 下载。网络不可达时不能无限挂起,
25
- * 否则 `/install` 请求永远不返回、任务不入队、前端进度卡 0%。超时后放行(交给装后校验)。 */
26
- const PREFLIGHT_TIMEOUT_MS = 15_000;
27
- export async function preflightTarget(target) {
28
- // release 包自带包根:拿 monorepo HEAD 的源码分发去校验它没有意义,会把完整产物
29
- // 误判成缺入口文件而拦下。该包的真实性交给安装本身与装后 verifyInstalledEntry 兜底。
30
- if (githubReleaseTarget(target) !== null)
31
- return { ok: true, missing: null, name: null };
32
- const source = githubRepoOf(target);
33
- if (source === null)
34
- return { ok: true, missing: null, name: null };
35
- const [owner, repo] = source.split('/');
36
- // 显式 pin 的 Git ref 要在它自己的修订上预检:默认分支常是 monorepo 根,与 pin 的发布包
37
- // 形态不同,拿 HEAD 校验会把完整产物误判成缺入口文件(或反之)。无 pin 时仍查 HEAD。
38
- const commit = await remoteCommit(owner, repo, githubRefOf(target));
39
- if (commit === null)
40
- return { ok: true, missing: null, name: null };
41
- const dir = mkdtempSync(join(tmpdir(), 'dsh-preflight-'));
42
- try {
43
- const tar = join(dir, 'pkg.tar.gz');
44
- await download(`https://codeload.github.com/${owner}/${repo}/tar.gz/${commit}`, tar);
45
- const rootPackage = await rootPackageEntry(tar);
46
- if (rootPackage === null)
47
- return { ok: true, missing: null, name: null };
48
- const rootPrefix = rootPackage.slice(0, -'package.json'.length);
49
- const meta = await readTarJson(tar, rootPackage);
50
- if (meta === null)
51
- return { ok: true, missing: null, name: null };
52
- // 只校验作者声明过的文件:没声明入口的包(只挂技能的 bundle)没有可缺失的入口,
53
- // 它的分发是否完整由 dsh.bundle.patch 是否在包里决定;补丁若按本包名 mount 自己,
54
- // 入口又变成必须的(宿主会 import 本包)—— 所以判入口前先把补丁读出来。
55
- const patchTexts = [];
56
- for (const file of resolveBundlePatchFiles(meta)) {
57
- const text = await readTarText(tar, `${rootPrefix}${file}`);
58
- if (text !== null)
59
- patchTexts.push(text);
60
- }
61
- const required = requiredDistributionFiles(meta, patchTexts).map((file) => `${rootPrefix}${file}`);
62
- const missing = missingDistributionFileOfTarList(await tarListing(tar), required);
63
- return { ok: missing === null, missing, name: typeof meta.name === 'string' ? meta.name : null };
64
- }
65
- catch {
66
- return { ok: true, missing: null, name: null };
67
- }
68
- finally {
69
- rmSync(dir, { recursive: true, force: true });
70
- }
71
- }
72
- /**
73
- * Resolve the commit to inspect: the pinned `ref` when given, otherwise the
74
- * repository default branch (`HEAD`). A full commit SHA passes through as-is;
75
- * annotated tags are peeled to their commit. Any git/network failure returns
76
- * null and the caller falls back to "no preflight".
77
- */
78
- async function remoteCommit(owner, repo, ref) {
79
- if (ref !== null && /^[0-9a-f]{40}$/i.test(ref))
80
- return ref;
81
- try {
82
- const { stdout } = await execFileAsync('git', ['ls-remote', `https://github.com/${owner}/${repo}.git`, ref ?? 'HEAD'], {
83
- timeout: PREFLIGHT_TIMEOUT_MS,
84
- maxBuffer: 1024 * 1024,
85
- });
86
- const lines = stdout.split(/\r?\n/).filter((line) => line !== '');
87
- // Annotated tags list the tag object and the peeled commit (`<ref>^{}`): prefer the peel.
88
- const chosen = lines.find((line) => line.endsWith('^{}')) ?? lines[lines.length - 1];
89
- return chosen?.split(/\s+/)[0] ?? null;
90
- }
91
- catch {
92
- return null;
93
- }
94
- }
95
- function download(url, dest) {
96
- return new Promise((resolve, reject) => {
97
- const req = get(url, { timeout: PREFLIGHT_TIMEOUT_MS }, (res) => {
98
- if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 303) {
99
- res.resume();
100
- if (res.headers.location) {
101
- void download(res.headers.location, dest).then(resolve, reject);
102
- return;
103
- }
104
- reject(new Error('download failed: redirect without location'));
105
- return;
106
- }
107
- if (res.statusCode !== 200) {
108
- res.resume();
109
- reject(new Error(`download failed: HTTP ${res.statusCode ?? '?'}`));
110
- return;
111
- }
112
- void pipeline(res, createWriteStream(dest)).then(resolve, reject);
113
- });
114
- // 网络不可达时 connect/响应可能无限挂起:超时销毁,避免预检卡死(放行交给装后校验)
115
- req.on('timeout', () => req.destroy(new Error(`download timeout after ${PREFLIGHT_TIMEOUT_MS}ms`)));
116
- req.on('error', reject);
117
- });
118
- }
119
- /** GitHub codeload tarballs use a dynamic top-level directory (`repo-<sha>/`),
120
- * not npm's `package/` prefix. Pick only a package.json directly under that
121
- * single archive root; nested monorepo package manifests are intentionally
122
- * ignored. Exported for a small parser regression test. */
123
- export function rootPackageEntryOfTarList(listing) {
124
- const candidates = listing
125
- .split(/\r?\n/)
126
- .filter((line) => /^[^/]+\/package\.json$/u.test(line));
127
- return candidates.length === 1 ? candidates[0] : null;
128
- }
129
- /**
130
- * 清单里第一个缺失的必需文件(成员名带包根前缀,由调用方拼好),全部都在则为 null。
131
- * 清单不可读(tar 缺失/解压失败)时按第一个必需文件缺失处理:与逐文件查询时
132
- * 「查不到就是缺」的判定一致,宁可拦下一次可疑安装,也不放一个空包过去。
133
- * Exported for a small parser regression test.
134
- */
135
- export function missingDistributionFileOfTarList(listing, required) {
136
- if (required.length === 0)
137
- return null;
138
- if (listing === null)
139
- return required[0];
140
- const present = new Set(listing.split(/\r?\n/));
141
- return required.find((file) => !present.has(file)) ?? null;
142
- }
143
- async function rootPackageEntry(tar) {
144
- const listing = await tarListing(tar);
145
- return listing === null ? null : rootPackageEntryOfTarList(listing);
146
- }
147
- /** tar 成员清单;`tar` 执行失败时返回 null(调用方按无法判定处理)。 */
148
- async function tarListing(tar) {
149
- try {
150
- const { stdout } = await execFileAsync('tar', ['-tzf', tar], { maxBuffer: 16 * 1024 * 1024 });
151
- return stdout;
152
- }
153
- catch {
154
- return null;
155
- }
156
- }
157
- /** 从 tarball 提取一个文件的内容;文件不存在或提取失败返回 null。 */
158
- async function readTarText(tar, entry) {
159
- try {
160
- const { stdout } = await execFileAsync('tar', ['-xzOf', tar, entry], { maxBuffer: 4 * 1024 * 1024 });
161
- return stdout;
162
- }
163
- catch {
164
- return null;
165
- }
166
- }
167
- /** 从 tarball 提取并解析一个 JSON 文件;文件不存在或解析失败返回 null。 */
168
- async function readTarJson(tar, entry) {
169
- const text = await readTarText(tar, entry);
170
- if (text === null)
171
- return null;
172
- try {
173
- return JSON.parse(text);
174
- }
175
- catch {
176
- return null;
177
- }
178
- }
@@ -1,23 +0,0 @@
1
- /** 预检结果:ok=false 表示分发改 package.json 声明的文件(入口或 bundle 补丁),
2
- * missing 为该文件在包内的相对路径;
3
- * name 为 git 分发包 package.json 声明的包名(非 git 目标/无法确定时为 null),
4
- * 供安装路由做「包名冲突」检测 —— 同名包名已被其他来源占用时,pnpm 装前必然撞车,
5
- * 需要把晦涩的 CLI 报错转成明确的拦截。 */
6
- export interface PreflightResult {
7
- ok: boolean;
8
- missing: string | null;
9
- name: string | null;
10
- }
11
- export declare function preflightTarget(target: string): Promise<PreflightResult>;
12
- /** GitHub codeload tarballs use a dynamic top-level directory (`repo-<sha>/`),
13
- * not npm's `package/` prefix. Pick only a package.json directly under that
14
- * single archive root; nested monorepo package manifests are intentionally
15
- * ignored. Exported for a small parser regression test. */
16
- export declare function rootPackageEntryOfTarList(listing: string): string | null;
17
- /**
18
- * 清单里第一个缺失的必需文件(成员名带包根前缀,由调用方拼好),全部都在则为 null。
19
- * 清单不可读(tar 缺失/解压失败)时按第一个必需文件缺失处理:与逐文件查询时
20
- * 「查不到就是缺」的判定一致,宁可拦下一次可疑安装,也不放一个空包过去。
21
- * Exported for a small parser regression test.
22
- */
23
- export declare function missingDistributionFileOfTarList(listing: string | null, required: string[]): string | null;