dsh-plugin 1.6.11 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.en.md +1 -1
  2. package/README.md +1 -1
  3. package/client/client.js +705 -803
  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/package-entry.js +6 -11
  20. package/lib/services/install/registry-check.js +5 -16
  21. package/lib/services/install/release-target.js +8 -9
  22. package/lib/services/install/task-queue.js +74 -57
  23. package/lib/services/npm-release-age.js +119 -0
  24. package/lib/services/probe.js +4 -4
  25. package/lib/services/profile/installed-versions.js +15 -21
  26. package/lib/services/profile/profile.js +16 -44
  27. package/lib/services/profile/progress.js +8 -11
  28. package/lib/services/settings.js +2 -5
  29. package/lib/types/http/catalog.d.ts +1 -1
  30. package/lib/types/http/install.d.ts +2 -2
  31. package/lib/types/http/npm-release-age.d.ts +2 -0
  32. package/lib/types/http/routes.d.ts +10 -12
  33. package/lib/types/http/shared.d.ts +5 -6
  34. package/lib/types/index.d.ts +7 -8
  35. package/lib/types/services/install/install-types.d.ts +12 -13
  36. package/lib/types/services/install/install.d.ts +1 -1
  37. package/lib/types/services/install/npm-resolve.d.ts +4 -16
  38. package/lib/types/services/install/package-entry.d.ts +6 -11
  39. package/lib/types/services/install/release-target.d.ts +7 -8
  40. package/lib/types/services/install/task-queue.d.ts +32 -31
  41. package/lib/types/services/npm-release-age.d.ts +15 -0
  42. package/lib/types/services/probe.d.ts +4 -4
  43. package/lib/types/services/profile/installed-versions.d.ts +5 -7
  44. package/lib/types/services/profile/profile.d.ts +14 -18
  45. package/lib/types/services/profile/progress.d.ts +7 -10
  46. package/lib/types/services/settings.d.ts +0 -4
  47. package/package.json +1 -1
  48. package/src/client/components/PluginHubSection.tsx +23 -27
  49. package/src/client/components/catalog/CatalogList.tsx +2 -4
  50. package/src/client/components/catalog/PluginCard.tsx +2 -3
  51. package/src/client/components/layout/CatalogControls.tsx +5 -6
  52. package/src/client/components/layout/CatalogHeader.tsx +1 -2
  53. package/src/client/components/layout/CategoryTabs.tsx +2 -3
  54. package/src/client/components/layout/SectionTabs.tsx +3 -5
  55. package/src/client/components/modals/ConfirmDialog.tsx +4 -6
  56. package/src/client/components/modals/InstalledDetailModal.tsx +3 -6
  57. package/src/client/components/modals/LogsModal.tsx +7 -12
  58. package/src/client/components/modals/NotificationsModal.tsx +52 -24
  59. package/src/client/components/modals/ProgressView.tsx +3 -4
  60. package/src/client/components/modals/modals.tsx +58 -30
  61. package/src/client/components/ui/Dropdown.tsx +4 -6
  62. package/src/client/components/ui/Toggle.tsx +2 -3
  63. package/src/client/components/ui/icons.tsx +1 -2
  64. package/src/client/components/views/CustomInstallView.tsx +20 -178
  65. package/src/client/components/views/DiagnosticsView.tsx +3 -6
  66. package/src/client/components/views/InstalledView.tsx +6 -11
  67. package/src/client/components/views/MarketView.tsx +2 -4
  68. package/src/client/components/views/SettingsView.tsx +9 -32
  69. package/src/client/css-modules.d.ts +1 -1
  70. package/src/client/data/catalog.ts +2 -3
  71. package/src/client/data/host.ts +26 -3
  72. package/src/client/data/hub.ts +2 -3
  73. package/src/client/hooks/useCatalog.ts +79 -24
  74. package/src/client/hooks/useIncrementalList.ts +3 -5
  75. package/src/client/hooks/useLanguage.ts +4 -6
  76. package/src/client/hooks/useSettings.ts +4 -11
  77. package/src/client/hooks/useTaskQueue.ts +36 -42
  78. package/src/client/index.tsx +2 -3
  79. package/src/client/locales.ts +96 -123
  80. package/src/client/logic/constants.ts +1 -2
  81. package/src/client/logic/ensure-plugin-css.ts +8 -12
  82. package/src/client/logic/failures.ts +41 -28
  83. package/src/client/logic/format.ts +1 -1
  84. package/src/client/logic/install-command.ts +14 -35
  85. package/src/client/logic/installed.ts +15 -10
  86. package/src/client/logic/normalize.ts +3 -3
  87. package/src/client/logic/release-age.ts +19 -0
  88. package/src/client/logic/urls.ts +55 -50
  89. package/src/client/styles/CustomInstallView.module.css +3 -30
  90. package/src/client/styles/DiagnosticsView.module.css +3 -5
  91. package/src/client/styles/Dropdown.module.css +2 -4
  92. package/src/client/styles/Header.module.css +6 -7
  93. package/src/client/styles/InstalledView.module.css +4 -7
  94. package/src/client/styles/List.module.css +4 -5
  95. package/src/client/styles/LogsView.module.css +2 -3
  96. package/src/client/styles/Modal.module.css +4 -5
  97. package/src/client/styles/SectionTabs.module.css +2 -4
  98. package/src/client/styles/SettingsView.module.css +3 -5
  99. package/src/client/styles/Toggle.module.css +2 -4
  100. package/src/client/styles/tokens.module.css +14 -18
  101. package/src/client/types.ts +11 -11
  102. package/src/server/http/catalog.ts +2 -3
  103. package/src/server/http/diagnostics.ts +6 -7
  104. package/src/server/http/install.ts +47 -208
  105. package/src/server/http/logs.ts +1 -2
  106. package/src/server/http/npm-release-age.ts +42 -0
  107. package/src/server/http/routes.ts +12 -12
  108. package/src/server/http/settings.ts +2 -2
  109. package/src/server/http/shared.ts +5 -6
  110. package/src/server/http/task.ts +1 -2
  111. package/src/server/index.ts +7 -8
  112. package/src/server/services/install/install-types.ts +12 -13
  113. package/src/server/services/install/install.ts +0 -1
  114. package/src/server/services/install/npm-check.ts +3 -2
  115. package/src/server/services/install/npm-resolve.ts +9 -99
  116. package/src/server/services/install/package-entry.ts +6 -11
  117. package/src/server/services/install/registry-check.ts +5 -16
  118. package/src/server/services/install/release-target.ts +8 -9
  119. package/src/server/services/install/task-queue.ts +79 -63
  120. package/src/server/services/npm-release-age.ts +107 -0
  121. package/src/server/services/probe.ts +4 -4
  122. package/src/server/services/profile/installed-versions.ts +16 -22
  123. package/src/server/services/profile/profile.ts +16 -42
  124. package/src/server/services/profile/progress.ts +8 -11
  125. package/src/server/services/settings.ts +2 -9
  126. package/lib/services/install/preflight.js +0 -178
  127. package/lib/types/services/install/preflight.d.ts +0 -23
  128. package/src/server/services/install/preflight.ts +0 -184
@@ -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;
@@ -1,184 +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, readFileSync, 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.ts'
21
- import { githubReleaseTarget } from './release-target.ts'
22
- import { requiredDistributionFiles, resolveBundlePatchFiles } from './package-entry.ts'
23
-
24
- const execFileAsync = promisify(execFile)
25
-
26
- /** 单次网络操作超时(ms):git ls-remote / codeload 下载。网络不可达时不能无限挂起,
27
- * 否则 `/install` 请求永远不返回、任务不入队、前端进度卡 0%。超时后放行(交给装后校验)。 */
28
- const PREFLIGHT_TIMEOUT_MS = 15_000
29
-
30
- /** 预检结果:ok=false 表示分发改 package.json 声明的文件(入口或 bundle 补丁),
31
- * missing 为该文件在包内的相对路径;
32
- * name 为 git 分发包 package.json 声明的包名(非 git 目标/无法确定时为 null),
33
- * 供安装路由做「包名冲突」检测 —— 同名包名已被其他来源占用时,pnpm 装前必然撞车,
34
- * 需要把晦涩的 CLI 报错转成明确的拦截。 */
35
- export interface PreflightResult {
36
- ok: boolean
37
- missing: string | null
38
- name: string | null
39
- }
40
-
41
- export async function preflightTarget(target: string): Promise<PreflightResult> {
42
- // release 包自带包根:拿 monorepo HEAD 的源码分发去校验它没有意义,会把完整产物
43
- // 误判成缺入口文件而拦下。该包的真实性交给安装本身与装后 verifyInstalledEntry 兜底。
44
- if (githubReleaseTarget(target) !== null) return { ok: true, missing: null, name: null }
45
- const source = githubRepoOf(target)
46
- if (source === null) return { ok: true, missing: null, name: null }
47
- const [owner, repo] = source.split('/')
48
- // 显式 pin 的 Git ref 要在它自己的修订上预检:默认分支常是 monorepo 根,与 pin 的发布包
49
- // 形态不同,拿 HEAD 校验会把完整产物误判成缺入口文件(或反之)。无 pin 时仍查 HEAD。
50
- const commit = await remoteCommit(owner, repo, githubRefOf(target))
51
- if (commit === null) return { ok: true, missing: null, name: null }
52
- const dir = mkdtempSync(join(tmpdir(), 'dsh-preflight-'))
53
- try {
54
- const tar = join(dir, 'pkg.tar.gz')
55
- await download(`https://codeload.github.com/${owner}/${repo}/tar.gz/${commit}`, tar)
56
- const rootPackage = await rootPackageEntry(tar)
57
- if (rootPackage === null) return { ok: true, missing: null, name: null }
58
- const rootPrefix = rootPackage.slice(0, -'package.json'.length)
59
- const meta = await readTarJson(tar, rootPackage)
60
- if (meta === null) return { ok: true, missing: null, name: null }
61
- // 只校验作者声明过的文件:没声明入口的包(只挂技能的 bundle)没有可缺失的入口,
62
- // 它的分发是否完整由 dsh.bundle.patch 是否在包里决定;补丁若按本包名 mount 自己,
63
- // 入口又变成必须的(宿主会 import 本包)—— 所以判入口前先把补丁读出来。
64
- const patchTexts: string[] = []
65
- for (const file of resolveBundlePatchFiles(meta)) {
66
- const text = await readTarText(tar, `${rootPrefix}${file}`)
67
- if (text !== null) patchTexts.push(text)
68
- }
69
- const required = requiredDistributionFiles(meta, patchTexts).map((file) => `${rootPrefix}${file}`)
70
- const missing = missingDistributionFileOfTarList(await tarListing(tar), required)
71
- return { ok: missing === null, missing, name: typeof meta.name === 'string' ? meta.name : null }
72
- } catch {
73
- return { ok: true, missing: null, name: null }
74
- } finally {
75
- rmSync(dir, { recursive: true, force: true })
76
- }
77
- }
78
-
79
- /**
80
- * Resolve the commit to inspect: the pinned `ref` when given, otherwise the
81
- * repository default branch (`HEAD`). A full commit SHA passes through as-is;
82
- * annotated tags are peeled to their commit. Any git/network failure returns
83
- * null and the caller falls back to "no preflight".
84
- */
85
- async function remoteCommit(owner: string, repo: string, ref: string | null): Promise<string | null> {
86
- if (ref !== null && /^[0-9a-f]{40}$/i.test(ref)) return ref
87
- try {
88
- const { stdout } = await execFileAsync('git', ['ls-remote', `https://github.com/${owner}/${repo}.git`, ref ?? 'HEAD'], {
89
- timeout: PREFLIGHT_TIMEOUT_MS,
90
- maxBuffer: 1024 * 1024,
91
- })
92
- const lines = stdout.split(/\r?\n/).filter((line) => line !== '')
93
- // Annotated tags list the tag object and the peeled commit (`<ref>^{}`): prefer the peel.
94
- const chosen = lines.find((line) => line.endsWith('^{}')) ?? lines[lines.length - 1]
95
- return chosen?.split(/\s+/)[0] ?? null
96
- } catch {
97
- return null
98
- }
99
- }
100
-
101
- function download(url: string, dest: string): Promise<void> {
102
- return new Promise((resolve, reject) => {
103
- const req = get(url, { timeout: PREFLIGHT_TIMEOUT_MS }, (res) => {
104
- if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 303) {
105
- res.resume()
106
- if (res.headers.location) {
107
- void download(res.headers.location, dest).then(resolve, reject)
108
- return
109
- }
110
- reject(new Error('download failed: redirect without location'))
111
- return
112
- }
113
- if (res.statusCode !== 200) {
114
- res.resume()
115
- reject(new Error(`download failed: HTTP ${res.statusCode ?? '?'}`))
116
- return
117
- }
118
- void pipeline(res, createWriteStream(dest)).then(resolve, reject)
119
- })
120
- // 网络不可达时 connect/响应可能无限挂起:超时销毁,避免预检卡死(放行交给装后校验)
121
- req.on('timeout', () => req.destroy(new Error(`download timeout after ${PREFLIGHT_TIMEOUT_MS}ms`)))
122
- req.on('error', reject)
123
- })
124
- }
125
-
126
- /** GitHub codeload tarballs use a dynamic top-level directory (`repo-<sha>/`),
127
- * not npm's `package/` prefix. Pick only a package.json directly under that
128
- * single archive root; nested monorepo package manifests are intentionally
129
- * ignored. Exported for a small parser regression test. */
130
- export function rootPackageEntryOfTarList(listing: string): string | null {
131
- const candidates = listing
132
- .split(/\r?\n/)
133
- .filter((line) => /^[^/]+\/package\.json$/u.test(line))
134
- return candidates.length === 1 ? candidates[0] : null
135
- }
136
-
137
- /**
138
- * 清单里第一个缺失的必需文件(成员名带包根前缀,由调用方拼好),全部都在则为 null。
139
- * 清单不可读(tar 缺失/解压失败)时按第一个必需文件缺失处理:与逐文件查询时
140
- * 「查不到就是缺」的判定一致,宁可拦下一次可疑安装,也不放一个空包过去。
141
- * Exported for a small parser regression test.
142
- */
143
- export function missingDistributionFileOfTarList(listing: string | null, required: string[]): string | null {
144
- if (required.length === 0) return null
145
- if (listing === null) return required[0]
146
- const present = new Set(listing.split(/\r?\n/))
147
- return required.find((file) => !present.has(file)) ?? null
148
- }
149
-
150
- async function rootPackageEntry(tar: string): Promise<string | null> {
151
- const listing = await tarListing(tar)
152
- return listing === null ? null : rootPackageEntryOfTarList(listing)
153
- }
154
-
155
- /** tar 成员清单;`tar` 执行失败时返回 null(调用方按无法判定处理)。 */
156
- async function tarListing(tar: string): Promise<string | null> {
157
- try {
158
- const { stdout } = await execFileAsync('tar', ['-tzf', tar], { maxBuffer: 16 * 1024 * 1024 })
159
- return stdout
160
- } catch {
161
- return null
162
- }
163
- }
164
-
165
- /** 从 tarball 提取一个文件的内容;文件不存在或提取失败返回 null。 */
166
- async function readTarText(tar: string, entry: string): Promise<string | null> {
167
- try {
168
- const { stdout } = await execFileAsync('tar', ['-xzOf', tar, entry], { maxBuffer: 4 * 1024 * 1024 })
169
- return stdout
170
- } catch {
171
- return null
172
- }
173
- }
174
-
175
- /** 从 tarball 提取并解析一个 JSON 文件;文件不存在或解析失败返回 null。 */
176
- async function readTarJson(tar: string, entry: string): Promise<Record<string, unknown> | null> {
177
- const text = await readTarText(tar, entry)
178
- if (text === null) return null
179
- try {
180
- return JSON.parse(text) as Record<string, unknown>
181
- } catch {
182
- return null
183
- }
184
- }