dsh-plugin 2.0.4 → 2.0.5

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.
@@ -26,9 +26,11 @@ export const zh = {
26
26
  loading: '正在加载插件数据…',
27
27
  failed: '插件数据加载失败',
28
28
  // 目录拉不出来时的精准提示:按「是否配置了代理」区分,引导排查网络 / 代理
29
- failedDescNoProxy: '你的网络似乎不通,无法获取插件目录数据。请检查网络连接后重试;或点击右上角按钮在浏览器中打开插件市场。',
30
- failedDescProxy: '你的网络似乎不通,或设置里的 HTTP 代理地址不可达,无法获取插件目录数据。请检查代理设置与网络连接后重试;或点击右上角按钮在浏览器中打开插件市场。',
29
+ failedDescNoProxy: '无法获取插件目录数据(有数 MB)。网络较慢时首次加载可能超时,可重试或点「强制刷新」跳过本地缓存重新下载;加载成功后会缓存 1 小时。若持续失败,请点「网络诊断」确认目录站点可达。',
30
+ failedDescProxy: '无法获取插件目录数据(有数 MB)。网络较慢,或设置里的 HTTP 代理不可达/链路抖动时,首次加载可能超时;可重试或点「强制刷新」跳过缓存重新下载。加载成功后会缓存 1 小时。',
31
31
  retry: '重试',
32
+ forceReload: '强制刷新',
33
+ forceReloadHint: '跳过本地缓存的插件目录,重新从服务器下载(缓存异常或弱网重试时用)',
32
34
  install: '安装',
33
35
  // 需在终端手动安装的插件(webInstallable=false):两种情形共用一句 —— 命令网页端代执行不了
34
36
  //(npx 安装器 / 本地 link),或命令能跑但装机过程要在终端完成(仓库带 prepare 构建钩子、
@@ -343,6 +345,7 @@ export const zh = {
343
345
  failPnpmMissingHint: '安装器找到了 dsh,但系统里没有 pnpm 命令(dsh 用它来管理 profile 插件)。请先安装 pnpm(如 npm install -g pnpm),确认 pnpm 已加入 PATH 后重试。这不是插件本身的问题。',
344
346
  failNpmMissingHint: '系统找不到 npm 命令(npm 未安装或未加入系统 PATH),无法执行全局安装。请先安装 Node.js(自带 npm),确认 npm 已加入 PATH 后重试。这不是插件本身的问题。',
345
347
  failBuildToolchainHint: '这个插件依赖需要在本机编译的原生模块,但机器上缺少可用的编译工具链。请按系统安装后重试:Windows 安装 Visual Studio Build Tools(含「使用 C++ 的桌面开发」);macOS 执行 xcode-select --install;Linux 安装 build-essential、python3 与 make。若装好工具链后仍然失败,再向插件作者反馈,请其提供预构建产物。',
348
+ bundleDeselectedHint: '注意:插件市场在当前 profile 的 bundle 列表中被取消勾选,重启宿主后它不会出现。请到「设置 → 插件」重新勾选 dsh-plugin。',
346
349
  failPnpmStoreHint: '安装器调用 pnpm 时报「store 位置不匹配」(ERR_PNPM_UNEXPECTED_STORE):你的 profile 目录里旧插件依赖是用另一个大版本的 pnpm 生成的,当前 pnpm 出于安全不认旧目录,导致任何插件装进该 profile 都会失败。请删除该 profile 目录下的 node_modules 与 pnpm-lock.yaml(可先到「系统日志」或 ~/.dsh/profiles/<profile>/hub.log 查看完整报错),再重新安装。这不是插件本身的问题。',
347
350
  failPnpmUnusedPatchHint: '安装器调用 pnpm 时报「有些补丁没有被使用」(ERR_PNPM_UNUSED_PATCH):你的 profile 里留着一条指向旧版本 dsh-plugin 的补丁声明(例如 dsh-plugin@1.4.2),而本次安装解析到的版本已经不是它,pnpm 出于安全直接中止整次安装,因此任何插件都装不进来。这不是插件本身的问题——请在 profile 目录(~/.dsh/profiles/<profile>)的 pnpm-workspace.yaml(pnpm 11 及以上)或 package.json 的 pnpm.patchedDependencies(pnpm 10)里删除该 dsh-plugin 补丁条目后重试。',
348
351
  // 404 依赖:点名 registry 上不存在的那个包(取不到包名时跳过这行),再给删除条目的指引
@@ -416,6 +419,7 @@ export const zh = {
416
419
  failFsUnavailableHint: '安装过程中 pnpm 无法把文件写进本机磁盘(这类报错与装哪个插件无关,任何插件装在同一目录都会失败,不是插件本身的问题)。请按报错对号入座:① 磁盘空间不足(ENOSPC / no space left on device,Windows 为 os error 112)——请清理 profile 所在磁盘的剩余空间后重试;② 磁盘或挂载点只读、带写保护(EROFS / read-only file system,Windows 为 os error 19)——请确认 profile 目录所在磁盘可写(外接盘/U 盘、带写保护的移动盘或只读挂载的分区都会触发),必要时把插件目录换到系统盘的可写位置;③ 进程可用的文件句柄被耗尽(EMFILE / ENFILE / too many open files)——请关闭占用大量文件的程序并重启 DeepSeek Harness 后重试。',
417
420
  failPnpmWorkspaceHint: '安装器调用 pnpm 时报「不能把依赖装到 workspace 根目录」(ERR_PNPM_ADDING_TO_ROOT):你的 profile 目录被 pnpm 视为 workspace 根目录,而本次安装命令没有声明在根目录操作,pnpm 直接拒绝执行,因此任何插件装进来都会失败。这不是插件本身的问题——可在 profile 目录(~/.dsh/profiles/<profile>)的 .npmrc 里加一行 ignore-workspace-root-check=true 后重试,或把 DeepSeek Harness 升级到最新版再试。',
418
421
  failPnpmTarballIntegrityHint: 'pnpm 报 ERR_PNPM_MISSING_TARBALL_INTEGRITY:锁文件中的 tarball 条目没有校验值,无法验证下载的包。先查看完整日志,完全退出 DeepSeek Harness,并检查、备份当前 profile 的 package.json、pnpm-lock.yaml 和 pnpm-workspace.yaml。确认依赖来源后,将 pnpm-lock.yaml 改名保留,再通过同一 DSH 安装器安装适配当前宿主版本的插件,重新生成锁文件。重建会重新解析 profile 依赖,请核对新旧版本;保持完整性校验开启。',
422
+ failPnpmTarballMismatchHint: 'pnpm 报 ERR_PNPM_TARBALL_INTEGRITY:profile 里某个从 GitHub 分支安装的依赖(例如 github:owner/repo#main)在远端更新了,tarball 内容变了,但 pnpm-lock.yaml 里存的还是旧校验和,于是整个 profile 的依赖安装都会卡在它上面。处理:打开 ~/.dsh/profiles/<profile>/pnpm-lock.yaml,删掉该 GitHub 依赖对应的条目(不确定是哪个就把整份锁文件改名备份),再重新安装;长期做法是把依赖固定到具体 commit(github:owner/repo#<commit>),分支再更新也不会改变校验和。',
419
423
  failPnpmPolicyHint: '安装器调用 pnpm 时被其供应链安全策略拦截——装任何插件都会撞墙,不是插件本身的问题。分两种情况处理:① 提示「Minimum release age」(ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION):包发布还不满 24 小时,pnpm 11 出于安全会拒收这种刚发布的新包。可在 pnpm 配置(pnpm-workspace.yaml)里加 minimumReleaseAge: 0 豁免,或等满 24 小时后重试。② 提示「untrusted origin」(ERR_PNPM_UNTRUSTED_ORIGIN):依赖来源不被本机 pnpm 信任,通常是该 profile 里之前用 github: 或非官方 npm 源装过插件,锁文件记录了不受信任的来源。请删除该 profile 目录下的 node_modules 与 pnpm-lock.yaml 后重新安装(可先到「系统日志」或 ~/.dsh/profiles/<profile>/hub.log 查看完整报错)。',
420
424
  // minimumReleaseAge 专属:从报错里算出确切的可重试时刻({time}),让用户知道「何时能再装」,
421
425
  // 而不是笼统地让等 24 小时
@@ -486,9 +490,11 @@ export const en = {
486
490
  loading: 'Loading plugin data…',
487
491
  failed: 'Failed to load plugin data',
488
492
  // 目录加载失败时的精准提示:按是否配置了代理区分
489
- failedDescNoProxy: 'Your network seems unreachable, so the plugin catalog could not be loaded. Please check your connection and retry; or open the hub in your browser with the button above.',
490
- failedDescProxy: 'Your network seems unreachable, or the HTTP proxy in Settings is unreachable, so the plugin catalog could not be loaded. Please check the proxy address and your connection, then retry; or open the hub in your browser with the button above.',
493
+ failedDescNoProxy: 'The plugin catalog could not be loaded (a few MB). The first load can time out on a slow connection — retry, or use "Force refresh" to skip the local cache and download it again. Once it succeeds the catalog is cached for an hour. If it keeps failing, run the network diagnostic.',
494
+ failedDescProxy: 'The plugin catalog could not be loaded (a few MB). A slow connection, or an unreachable/unstable HTTP proxy in Settings, can make the first load time out — retry, or use "Force refresh" to skip the cache. Once it succeeds the catalog is cached for an hour.',
491
495
  retry: 'Retry',
496
+ forceReload: 'Force refresh',
497
+ forceReloadHint: 'Skip the cached catalog and download it again from the server (use when the cache looks wrong or on a slow network)',
492
498
  install: 'Install',
493
499
  // 需在终端手动安装的插件(webInstallable=false):两种情形共用一套措辞 ——
494
500
  // 网页端完全跑不了该命令(npx 安装器 / 本地 link),或能跑但装机过程要在终端完成
@@ -803,6 +809,7 @@ export const en = {
803
809
  failPnpmMissingHint: 'The installer found dsh, but pnpm is missing on this machine (dsh uses it to manage profile plugins). Please install pnpm first (e.g. npm install -g pnpm), make sure it is on your PATH, then retry. This is not a problem with the plugin itself.',
804
810
  failNpmMissingHint: 'The npm command could not be found (npm is not installed or not on the system PATH), so a global install cannot run. Please install Node.js (which bundles npm), make sure npm is on your PATH, then retry. This is not a problem with the plugin itself.',
805
811
  failBuildToolchainHint: 'This plugin depends on a native module that must be compiled on your machine, but no usable build toolchain is available. Install one and retry: on Windows install Visual Studio Build Tools with the "Desktop development with C++" workload; on macOS run xcode-select --install; on Linux install build-essential, python3 and make. If it still fails after the toolchain is installed, report it to the plugin author and ask for a prebuilt package.',
812
+ bundleDeselectedHint: 'Note: the marketplace is unchecked in this profile\'s bundle list, so it will not appear after the host restarts. Re-check dsh-plugin under Settings → Plugins.',
806
813
  failPnpmStoreHint: 'pnpm reported an unexpected store location (ERR_PNPM_UNEXPECTED_STORE): the profile directory was previously set up with a different major version of pnpm, and the current pnpm refuses to reuse it — no plugin can be installed into that profile. Delete node_modules and pnpm-lock.yaml under the profile directory (see System Logs or ~/.dsh/profiles/<profile>/hub.log for the full output), then reinstall. This is not a problem with the plugin itself.',
807
814
  failPnpmUnusedPatchHint: 'pnpm reported that some patches were not used (ERR_PNPM_UNUSED_PATCH): your profile still holds a patch entry for an older dsh-plugin version (for example dsh-plugin@1.4.2), while this install resolved a different version, so pnpm aborts the whole install for safety — no plugin can be installed into that profile. This is not a problem with the plugin itself. Remove that dsh-plugin patch entry from pnpm-workspace.yaml (pnpm 11+) or from pnpm.patchedDependencies in package.json (pnpm 10) inside the profile directory (~/.dsh/profiles/<profile>), then retry.',
808
815
  // 缺失依赖:点名 404 的包(解析不出时跳过该行),再给删除指引
@@ -876,6 +883,7 @@ export const en = {
876
883
  failFsUnavailableHint: 'During install, pnpm could not write files to the local disk — this has nothing to do with which plugin you are installing: any plugin installed into the same directory would fail the same way, so it is not a problem with the plugin itself. Match the error you see: ① Out of space (ENOSPC / no space left on device, os error 112 on Windows) — free up space on the drive that holds the profile and retry. ② Read-only or write-protected volume (EROFS / read-only file system, os error 19 on Windows) — make sure the drive holding the profile directory is writable (external or USB drives, write-protected media, and read-only mounts all trigger this); move the plugin directory to a writable location on the system drive if needed. ③ File handles exhausted (EMFILE / ENFILE / too many open files) — close programs that hold many files open, restart DeepSeek Harness, then retry.',
877
884
  failPnpmWorkspaceHint: 'pnpm refused to add the dependency to the workspace root (ERR_PNPM_ADDING_TO_ROOT): the profile directory is treated as a pnpm workspace root, and this install command did not declare that it operates at the root, so pnpm rejected it outright — no plugin can be installed into that profile. This is not a problem with the plugin itself. Add `ignore-workspace-root-check=true` to the `.npmrc` inside the profile directory (~/.dsh/profiles/<profile>) and retry, or upgrade DeepSeek Harness and try again.',
878
885
  failPnpmTarballIntegrityHint: 'pnpm reported ERR_PNPM_MISSING_TARBALL_INTEGRITY: a tarball entry in the lockfile has no checksum to verify the downloaded package. Read the full log, fully quit DeepSeek Harness, and inspect and back up package.json, pnpm-lock.yaml and pnpm-workspace.yaml in the affected profile. Once the dependency sources are verified, rename pnpm-lock.yaml to preserve it, then use the same DSH installer to install a plugin version compatible with the host and regenerate the lockfile. This resolves profile dependencies again: compare the old and new versions, and keep integrity checks enabled.',
886
+ failPnpmTarballMismatchHint: 'pnpm reported ERR_PNPM_TARBALL_INTEGRITY: a dependency installed from a GitHub branch (for example github:owner/repo#main) changed upstream, so the tarball content no longer matches the checksum stored in pnpm-lock.yaml — and every install into that profile now stops on it. To fix: open ~/.dsh/profiles/<profile>/pnpm-lock.yaml, remove the entry for that GitHub dependency (or rename the whole lockfile as a backup if unsure which one it is), then reinstall. Long term, pin the dependency to a commit (github:owner/repo#<commit>) so branch updates no longer change its checksum.',
879
887
  failPnpmPolicyHint: 'The installer was blocked by pnpm supply-chain security policy — every plugin install would hit the same wall, so this is not a problem with the plugin itself. Two cases: ① "Minimum release age" (ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION): the package was published less than 24 hours ago, and pnpm 11 refuses freshly-published packages for safety. Add minimumReleaseAge: 0 to your pnpm config (pnpm-workspace.yaml) to opt out, or wait until the 24 hours have passed and retry. ② "untrusted origin" (ERR_PNPM_UNTRUSTED_ORIGIN): a dependency source is not trusted by this machine, usually because plugins were previously installed via github: or a non-official npm registry, so the lockfile records an untrusted source. Delete node_modules and pnpm-lock.yaml under the profile directory (see System Logs or ~/.dsh/profiles/<profile>/hub.log for the full output), then reinstall.',
880
888
  // 仅 minimumReleaseAge 场景:从报错算出确切的可重试时刻({time}),让用户知道何时能装,
881
889
  // 而不是笼统的「等 24 小时」
@@ -174,6 +174,13 @@ function missingTarballIntegrity(message: string): boolean {
174
174
  return /\bERR_PNPM_MISSING_TARBALL_INTEGRITY\b/.test(message)
175
175
  }
176
176
 
177
+ /** GitHub 分支 tarball 内容变了、锁文件里还是旧校验和(ERR_PNPM_TARBALL_INTEGRITY)。
178
+ * \b 保证不会命中 ERR_PNPM_MISSING_TARBALL_INTEGRITY:前者是有校验值但对不上,
179
+ * 后者是锁文件里压根没有校验值。 */
180
+ function mismatchedTarballIntegrity(message: string): boolean {
181
+ return /\bERR_PNPM_TARBALL_INTEGRITY\b/.test(message)
182
+ }
183
+
177
184
  /** pnpm 拒收「刚发布」包时,报错里带着发布时间;这里从报错文本算出「发布满 24h」的可重试时刻(UTC,分钟精度)。 */
178
185
  export function releaseAgeRetryAfter(message: string): string | null {
179
186
  const match = /published at (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)/.exec(message)
@@ -188,7 +195,9 @@ export function releaseAgeRetryAfter(message: string): string | null {
188
195
  * 可重试时刻时才走专属文案(给出确切的「何时可再试」),算不出时退回通用文案,
189
196
  * 避免出现「预计可重试时间:」后接空值的残缺句子。
190
197
  */
191
- export function pnpmPolicyHintOf(message: string): 'failPnpmTarballIntegrityHint' | 'failPnpmReleaseAgeHint' | 'failPnpmPolicyHint' {
198
+ export function pnpmPolicyHintOf(message: string): 'failPnpmTarballIntegrityHint' | 'failPnpmTarballMismatchHint' | 'failPnpmReleaseAgeHint' | 'failPnpmPolicyHint' {
199
+ // 校验和对不上(分支 tarball 变了):先于「缺校验值」,两者报错前缀不同但都叫 tarball integrity
200
+ if (mismatchedTarballIntegrity(message)) return 'failPnpmTarballMismatchHint'
192
201
  if (missingTarballIntegrity(message)) return 'failPnpmTarballIntegrityHint'
193
202
  if (/ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|ERR_PNPM_NO_MATURE_MATCHING_VERSION|Minimum release age|minimumReleaseAge/i.test(message)
194
203
  && releaseAgeRetryAfter(message) !== null) {
@@ -230,9 +239,9 @@ export function classifyFailure(message: string): FailureKind {
230
239
  // 本地服务来源校验拒绝(403 untrusted origin),请求没进安装流程。必须在 pnpmPolicy 之前
231
240
  // —— 后者同名裸文本会把这条误判成供应链策略。用「整行以它结尾」区隔 pnpm 真实报错
232
241
  if (/(^|\n)untrusted origin\s*$/.test(message)) return 'originRejected'
233
- // pnpm 供应链策略拦截(缺 tarball integrity / minimumReleaseAge / untrusted origin)。
242
+ // pnpm 供应链策略拦截(缺 tarball integrity / 校验和不匹配 / minimumReleaseAge / untrusted origin)。
234
243
  // 必须在 pnpmIgnoredBuild 之前
235
- if (missingTarballIntegrity(message) || /ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|ERR_PNPM_NO_MATURE_MATCHING_VERSION|Minimum release age|minimumReleaseAge|untrusted origin/i.test(message)) return 'pnpmPolicy'
244
+ if (missingTarballIntegrity(message) || mismatchedTarballIntegrity(message) || /ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|ERR_PNPM_NO_MATURE_MATCHING_VERSION|Minimum release age|minimumReleaseAge|untrusted origin/i.test(message)) return 'pnpmPolicy'
236
245
  // profile 残留旧版本 patch 声明,pnpm 中止安装。必须在兜底之前
237
246
  if (/ERR_PNPM_UNUSED_PATCH|patches were not used/i.test(message)) return 'pnpmUnusedPatch'
238
247
  // profile 文件被其他进程占用(os error 32 / EBUSY)。ASCII 特征免疫乱码
@@ -722,6 +722,15 @@
722
722
  padding: 12px 0 6px;
723
723
  }
724
724
 
725
+ /* 市场自更新后 bundle 被取消勾选:重启后市场不会出现,用警示色提示重新勾选 */
726
+ .bundleDeselectedHint {
727
+ padding: 12px 0 6px;
728
+ font-size: 12px;
729
+ line-height: 18px;
730
+ text-align: left;
731
+ color: var(--hub-warning);
732
+ }
733
+
725
734
  /* 重启选择按钮对:稍后(次级文字)+ 立即重启(主按钮) */
726
735
  .restartLater {
727
736
  font-size: 12px;
@@ -12,10 +12,16 @@
12
12
  flex-shrink: 0;
13
13
  padding: 0 2px;
14
14
  border-bottom: 1px solid var(--hub-border-1);
15
+ /* 以自身宽度做容器查询基准:面板变窄时 tab 只剩图标 */
16
+ container-type: inline-size;
15
17
  }
16
18
 
17
19
  .tab,
18
20
  .tabActive {
21
+ /* 标签不折行:窄容器下保持单行,避免中文被逐字竖排 */
22
+ white-space: nowrap;
23
+ /* 容器再窄也不压缩 tab:拒绝收缩,转为横向溢出而不是纵向下坠 */
24
+ flex-shrink: 0;
19
25
  position: relative;
20
26
  display: inline-flex;
21
27
  align-items: center;
@@ -44,6 +50,18 @@
44
50
  font-weight: 600;
45
51
  }
46
52
 
53
+ /* 容器够窄时只留图标:四个 tab 与通知入口保持单行、不横向溢出(名称仍由 aria-label 提供) */
54
+ @container (max-width: 440px) {
55
+ .tabLabel {
56
+ display: none;
57
+ }
58
+
59
+ .tab,
60
+ .tabActive {
61
+ padding: 0 2px;
62
+ }
63
+ }
64
+
47
65
  /* tab 图标:16px 随字号等宽对齐,SVG 继承 currentColor 自动跟随选中/悬浮色 */
48
66
  .tabIcon {
49
67
  flex-shrink: 0;
@@ -23,6 +23,24 @@ function catalogCacheFile(profile: string, key: string): string {
23
23
  return join(profileDirectory(profile), 'cache', `catalog-${key}.json`)
24
24
  }
25
25
 
26
+ /** 目录拉取超时:目录 JSON 约 3.6MB,20s 在弱网/代理链路上不够用;
27
+ * 客户端会在超时后重试,重试必须共享同一次拉取(见 fetchCatalogOnce)。 */
28
+ const CATALOG_FETCH_TIMEOUT_MS = 60_000
29
+
30
+ /** 并发拉取去重:客户端超时重试会并发打进来,共享同一次 curl,
31
+ * 避免多份 3.6MB 下载互相抢带宽、越重试越慢。 */
32
+ const inflightCatalog = new Map<string, ReturnType<typeof fetchViaCurl>>()
33
+
34
+ function fetchCatalogOnce(target: string, proxy: string): ReturnType<typeof fetchViaCurl> {
35
+ const key = `${target}\n${proxy}`
36
+ const running = inflightCatalog.get(key)
37
+ if (running !== undefined) return running
38
+ const task = fetchViaCurl(target, proxy, CATALOG_FETCH_TIMEOUT_MS)
39
+ .finally(() => { inflightCatalog.delete(key) })
40
+ inflightCatalog.set(key, task)
41
+ return task
42
+ }
43
+
26
44
  /** 读缓存:文件存在、JSON 合法、未超过 maxAgeMs → 返回原始响应字符串;否则 null(视为 miss)。
27
45
  * maxAgeMs 传 Infinity 即「任意年龄的缓存」,供实时拉取失败时兜底使用。 */
28
46
  function readCatalogCache(file: string, maxAgeMs: number): string | null {
@@ -103,9 +121,12 @@ export function registerCatalogRoutes(webServer: WebServerService, profile: stri
103
121
  const target = isStats
104
122
  ? 'https://api.dsh-plugin.org/stats.json'
105
123
  : `https://api.dsh-plugin.org/plugins.${lang}.json`
106
- // 1 小时本地缓存:命中直接返回(跨重启生效,插件市场秒开),未命中才走 curl 拉远程
124
+ // 1 小时本地缓存:命中直接返回(跨重启生效,插件市场秒开),未命中才走 curl 拉远程。
125
+ // refresh=1 跳过 TTL 缓存直接回源(「强制刷新」用);成功后照样覆盖缓存,
126
+ // 失败仍回退旧缓存,所以强制刷新不会把可用的旧数据弄丢。
107
127
  const cacheFile = catalogCacheFile(profile, isStats ? 'stats' : `plugins-${lang}`)
108
- const cached = readCatalogCache(cacheFile, CATALOG_CACHE_TTL_MS)
128
+ const forceRefresh = url.searchParams.get('refresh') === '1'
129
+ const cached = forceRefresh ? null : readCatalogCache(cacheFile, CATALOG_CACHE_TTL_MS)
109
130
  if (cached !== null) {
110
131
  try {
111
132
  sendJson(response, 200, JSON.parse(cached))
@@ -114,7 +135,7 @@ export function registerCatalogRoutes(webServer: WebServerService, profile: stri
114
135
  // 缓存正文损坏:忽略,走重新拉取并覆盖写盘
115
136
  }
116
137
  }
117
- const r = await fetchViaCurl(target, proxy, 20000)
138
+ const r = await fetchCatalogOnce(target, proxy)
118
139
  if (!r.ok || r.body === '') {
119
140
  // 实时拉取失败:退回任意年龄的本地缓存(上次成功数据),
120
141
  // 避免证书吊销受阻 / 网络抖动时整个插件市场打不开 —— 数据可能过期,但好过空白。
@@ -71,6 +71,29 @@ function toolVersion(cmd: string, args: string[]): string | null {
71
71
  }
72
72
  }
73
73
 
74
+ /** 本机实际生效的 npm registry:诊断与安装同口径 —— 设置里留空时读用户 npm 配置
75
+ * (含 profile 目录的 .npmrc),而不是硬探官方源,避免「能装但诊断说源不通」。
76
+ * 读不到(npm 缺失/超时/输出异常)时回退官方源。 */
77
+ function configuredNpmRegistry(profile: string): string {
78
+ const fromEnv = process.env.npm_config_registry ?? process.env.NPM_CONFIG_REGISTRY ?? ''
79
+ const valid = (v: string): string | null => (/^https?:\/\//.test(v) ? v.replace(/\/+$/, '') : null)
80
+ const viaEnv = valid(fromEnv)
81
+ if (viaEnv !== null) return viaEnv
82
+ try {
83
+ const cwd = join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'profiles', profile)
84
+ const r = spawnSync('npm', ['config', 'get', 'registry'], {
85
+ timeout: 3000,
86
+ encoding: 'utf8',
87
+ cwd,
88
+ shell: process.platform === 'win32',
89
+ })
90
+ const out = (r.stdout ?? '').trim().split(/\r?\n/).pop() ?? ''
91
+ return valid(out) ?? 'https://registry.npmjs.org'
92
+ } catch {
93
+ return 'https://registry.npmjs.org'
94
+ }
95
+ }
96
+
74
97
  /** 宿主环境快照:提交 bug 时随 issue 附上,便于作者复现(前端 /env 端点返回)。 */
75
98
  export function hostEnv(profile: string): Record<string, string | null> {
76
99
  return {
@@ -109,17 +132,20 @@ export function registerDiagnosticsRoutes(webServer: WebServerService, profile:
109
132
  }
110
133
  const settings = loadSettings(profile)
111
134
  // npm 通道:设置里配置了镜像源就探测该镜像(安装通道吃同一个 registry);
112
- // 未配置(空串)时探测官方源作为「跟随本机 npm 配置」的基础连通性参考。
113
- const registry = settings.npmRegistry.replace(/\/+$/, '') || 'https://registry.npmjs.org'
114
- // 连通性自检打各通道的轻量资源:npm 源拉包元数据、GitHub 行探小仓库、目录站探
115
- // 小文件,避免拉取目录全量 JSON。
116
- // npm 行 display:配置了镜像就显示镜像地址;未配置置空 —— 客户端显示「未配置(跟随本机 npm 配置)」。
117
- const allChecks: Array<{ key: string; url: string; display: string; cmd: string; proxy?: string; git?: boolean }> = [
118
- { key: 'npm', url: `${registry}/dsh-plugin`, display: settings.npmRegistry !== '' ? registry : '', cmd: `npm view dsh-plugin version --registry ${registry}` },
135
+ // 未配置(空串)时探测本机 npm 配置里实际生效的源 —— 与安装同口径。
136
+ const registry = settings.npmRegistry.replace(/\/+$/, '') || configuredNpmRegistry(profile)
137
+ // 连通性自检打各通道的轻量资源:npm 源拉包元数据、GitHub 行探小仓库、
138
+ // 目录站按 Range 取 1KB(探市场真实下载的 plugins.zh.json,但不整份下载)。
139
+ // npm 行 display:显式配置、或本机 npm 源不是官方源时显示实际地址,
140
+ // 让用户看到「诊断的到底是哪个源」;官方源且未配置时留空(显示「跟随本机 npm 配置」)。
141
+ const allChecks: Array<{ key: string; url: string; display: string; cmd: string; proxy?: string; git?: boolean; range?: string }> = [
142
+ { key: 'npm', url: `${registry}/dsh-plugin`, display: settings.npmRegistry !== '' || registry !== 'https://registry.npmjs.org' ? registry : '', cmd: `npm view dsh-plugin version --registry ${registry}` },
119
143
  // GitHub 行用真实 git 克隆握手(git ls-remote)打 dshplugin/hello-dsh 小仓库:
120
144
  // 「网页能打开」和「git 能克隆」是两码事,握手成功才算通道通;小仓库秒级完成,不打 17MB 的 dsh-plugin-hub。
121
145
  { key: 'github', url: 'https://github.com/dshplugin/hello-dsh', display: 'github.com', cmd: 'git ls-remote https://github.com/dshplugin/hello-dsh', git: true },
122
- { key: 'catalog', url: 'https://api.dsh-plugin.org/stats.json', display: '', cmd: 'curl -s https://api.dsh-plugin.org/stats.json' },
146
+ // 目录行探市场真正下载的那个文件(plugins.zh.json),只取前 1KB:
147
+ // 只探 stats.json 会出现「诊断绿、市场加载不出来」—— 两者端点与体积完全不同。
148
+ { key: 'catalog', url: 'https://api.dsh-plugin.org/plugins.zh.json', display: '', cmd: 'curl -r 0-1023 https://api.dsh-plugin.org/plugins.zh.json', range: '0-1023' },
123
149
  ]
124
150
  // 配置了 HTTP 代理:追加一行代理诊断 —— 用该代理打 github.com(安装通道真实访问的地址),
125
151
  // 验证「代理能不能把请求带出去」。与安装同口径:curl 子进程注入该代理 env。
@@ -140,7 +166,7 @@ export function registerDiagnosticsRoutes(webServer: WebServerService, profile:
140
166
  const effectiveProxy = settings.proxy !== ''
141
167
  ? settings.proxy
142
168
  : (systemProxy() ?? process.env.HTTPS_PROXY ?? process.env.https_proxy ?? '')
143
- const probe = (url: string, proxy?: string) => probeUrl(url, proxy ?? effectiveProxy, 6000)
169
+ const probe = (url: string, proxy?: string, range?: string) => probeUrl(url, proxy ?? effectiveProxy, 6000, range)
144
170
  // 逐路串行:一条命令一条结果地推送给终端,保持「程序在跑」的真实感
145
171
  const results: Array<{ key: string; ok: boolean }> = []
146
172
  for (const c of checks) {
@@ -148,7 +174,7 @@ export function registerDiagnosticsRoutes(webServer: WebServerService, profile:
148
174
  // git 行走真实克隆握手(gitLsRemote),其余走 curl 探测,两路都带超时兜底
149
175
  const r = c.git
150
176
  ? await gitLsRemote(c.url, c.proxy ?? effectiveProxy, 6000)
151
- : await probe(c.url, c.proxy)
177
+ : await probe(c.url, c.proxy, c.range)
152
178
  results.push({ key: c.key, ok: r.ok })
153
179
  // git 行的 status 是退出码,客户端 git 行只显示 OK / 不可达,不显示成 HTTP 码。
154
180
  // reason 透出失败细分原因(如 Windows 证书吊销受阻),客户端据此给对症提示而非笼统「不可达」。
@@ -41,9 +41,11 @@ export interface InstallTask {
41
41
  /** 完成后是否仍需宿主重启才生效:仅「热挂载失败 / 非 dsh 插件 / 更新(ESM 缓存取不到新代码)/
42
42
  * 无 loader」等刷新解决不了的兜底场景为 true(弹窗给「立即重启」);其余一律 false。 */
43
43
  needsRestart: boolean
44
- /** 完成后是否只需刷新页面即生效(插件已热挂进运行中 loader,仅剩客户端 UI 待页面重新加载,
44
+ /** 完成结果是否只需刷新页面即生效(插件已热挂进运行中 loader,仅剩客户端 UI 待页面重新加载,
45
45
  * 网页端与桌面端一致):true 时关闭结果弹窗即自动刷新页面,不给手动刷新按钮。 */
46
46
  needsReload?: boolean
47
+ /** 自更新成功、但该 profile 的 dsh.profile.bundles 已不含 dsh-plugin:重启后市场不会出现 */
48
+ bundleDeselected?: boolean
47
49
  /** 卸载入队时该包是否带客户端 UI(`dsh.client`):卸载后包已从磁盘删除,只能入队前快照,
48
50
  * 卸载成功且曾存活时据此决定是否需要刷新页面摘除插件面板。 */
49
51
  hadClientUi?: boolean
@@ -20,7 +20,7 @@ import { cleanLine, estimateProgress } from '../profile/progress.ts'
20
20
  import type { LoaderHandle } from '../loader.ts'
21
21
  import { hasClientUi, isDshPlugin, mountLoadedEntry, removeLoadedEntry } from '../loader.ts'
22
22
  import { addPendingRestart, clearPendingRestart } from '../profile/pending-restart.ts'
23
- import { addAllowBuildsKey, githubRepoOf, parseAllowBuildsKey, profileDirectory } from '../profile/profile.ts'
23
+ import { addAllowBuildsKey, bundleIncludes, githubRepoOf, parseAllowBuildsKey, profileDirectory } from '../profile/profile.ts'
24
24
  import { appendLog } from '../log.ts'
25
25
 
26
26
  /** 内存任务注册表,按任务 id 索引。 */
@@ -607,6 +607,11 @@ function spawnMutation(options: {
607
607
  task.status = result.timedOut || result.exitCode !== 0 ? 'failed' : 'done'
608
608
  if (task.status === 'done') {
609
609
  task.progress = 100
610
+ // 市场自更新:若 bundle 已被取消勾选,重启后市场不会出现,结果页要明确提示
611
+ if ((action === 'add' || action === 'update') && (target === 'dsh-plugin' || target.startsWith('dsh-plugin@'))
612
+ && !bundleIncludes(profile, 'dsh-plugin')) {
613
+ task.bundleDeselected = true
614
+ }
610
615
  if (isGlobalNpm) {
611
616
  // 全局 npm 安装:装的是本机 CLI 工具(如 dsh-tui),不进任何 profile,无需宿主重启
612
617
  task.needsRestart = false
@@ -187,13 +187,16 @@ async function curlWithTlsFallback(args: string[], env: NodeJS.ProcessEnv, timeo
187
187
  * 失败(curl 不存在 / 连接失败 / 超时)都归为不可达,全程不抛错。
188
188
  * 证书吊销检查受阻(Windows Schannel)另记 reason,供诊断给出对症提示。
189
189
  */
190
- async function curlProbe(url: string, proxy: string, timeoutMs: number): Promise<ProbeResult> {
190
+ async function curlProbe(url: string, proxy: string, timeoutMs: number, range?: string): Promise<ProbeResult> {
191
191
  const started = Date.now()
192
192
  const discard = process.platform === 'win32' ? 'NUL' : '/dev/null'
193
193
  // -sS(而非仅 -s):silent 会把连接/TLS 错误一并吞掉,而识别证书吊销受阻正需要 stderr;
194
194
  // 子进程 stderr 由我们收集、不会回显给用户,所以补 -S 不产生噪音。
195
+ // range:目录通道路由探真实的 plugins.zh.json,但只取前 1KB(-r 0-1023,返回 206 即视为可达),
196
+ // 避免诊断把 3.6MB 全量目录下载一遍。
195
197
  const r = await curlWithTlsFallback([
196
198
  '-sS', '-o', discard, '-w', '%{http_code}',
199
+ ...(range !== undefined ? ['-r', range] : []),
197
200
  '--max-time', String(Math.max(1, Math.round(timeoutMs / 1000))),
198
201
  url,
199
202
  ], proxyEnv(proxy), timeoutMs)
@@ -212,11 +215,12 @@ async function curlProbe(url: string, proxy: string, timeoutMs: number): Promise
212
215
  /**
213
216
  * 探测一个 HTTPS 目标:proxy 非空注入给 curl 子进程走代理,否则直连。
214
217
  * 供系统诊断 /diagnostics 使用;目标 URL 非法返回不可达。
218
+ * range(如 '0-1023')只取前 N 字节,用于探大文件端点而不下载全量。
215
219
  */
216
- export function probeUrl(url: string, proxy: string, timeoutMs: number): Promise<ProbeResult> {
220
+ export function probeUrl(url: string, proxy: string, timeoutMs: number, range?: string): Promise<ProbeResult> {
217
221
  let target: URL
218
222
  try { target = new URL(url) } catch { return Promise.resolve(probeFail()) }
219
- return curlProbe(target.href, proxy, timeoutMs)
223
+ return curlProbe(target.href, proxy, timeoutMs, range)
220
224
  }
221
225
 
222
226
  /**
@@ -284,8 +288,9 @@ export async function finalUrl(url: string, proxy: string, timeoutMs: number): P
284
288
  export function fetchViaCurl(
285
289
  url: string, proxy: string, timeoutMs: number,
286
290
  ): Promise<{ ok: boolean; body: string; reason: ProbeFailReason | null }> {
287
- // 同样用 -sS:正文之外还要 stderr 里的 TLS 失败线索(被 stderr 收集,不外泄给用户)
288
- const args = ['-sS', '--max-time', String(Math.max(1, Math.round(timeoutMs / 1000))), url]
291
+ // 同样用 -sS:正文之外还要 stderr 里的 TLS 失败线索(被 stderr 收集,不外泄给用户)。
292
+ // --compressed:目录 JSON 约 3.6MB,源站支持 gzip,压缩后弱网下也能在超时内拉完。
293
+ const args = ['-sS', '--compressed', '--max-time', String(Math.max(1, Math.round(timeoutMs / 1000))), url]
289
294
  return curlWithTlsFallback(args, proxyEnv(proxy), timeoutMs).then((r) => ({
290
295
  ok: r.code === 0,
291
296
  body: r.stdout,
@@ -49,6 +49,21 @@ export function profileDirectory(profile: string): string {
49
49
  return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'profiles', profile)
50
50
  }
51
51
 
52
+ /** profile 的 dsh.profile.bundles 是否仍包含指定包:取消勾选后 bundle patch 不再执行。
53
+ * 没有该清单或读取失败时返回 true —— 不归我们判断的情况不报警。 */
54
+ export function bundleIncludes(profile: string, name: string): boolean {
55
+ try {
56
+ const pkg = JSON.parse(readFileSync(join(profileDirectory(profile), 'package.json'), 'utf8')) as {
57
+ dsh?: { profile?: { bundles?: unknown } }
58
+ }
59
+ const bundles = pkg.dsh?.profile?.bundles
60
+ if (!Array.isArray(bundles)) return true
61
+ return bundles.includes(name)
62
+ } catch {
63
+ return true
64
+ }
65
+ }
66
+
52
67
  /** 把仓库身份转成规范的 GitHub 目标(git+https),仓库不合法时返回 null。
53
68
  * 带 ref 时按 Git ref(`#tag` / `#commit`)拼接;安装通道已不再使用 git 目标,此函数仅做身份规范化。 */
54
69
  export function githubTarget(repo: string, ref?: string | null): string | null {