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
@@ -17,27 +17,26 @@ export interface InstallResult {
17
17
 
18
18
  export interface InstallTask {
19
19
  id: number
20
- /** 操作目标:显式 HTTPS Git URL(安装)或 npm 包名(卸载),冲突/恢复时需要展示给用户 */
20
+ /** 操作目标:npm 包名(安装、更新、卸载一致),冲突/恢复时需要展示给用户 */
21
21
  target: string
22
22
  /** 展示用目标(owner/repo):npm 安装时 target 是包名,前端恢复用此字段显示仓库名,保持用户无感知 */
23
23
  displayTarget?: string
24
- /** 尝试过的安装方式(npm registry 反查 + 实际执行的 CLI 命令,按先后顺序):
25
- * 失败提 Issue 时如实贴给作者,作者据此反推正确的 npm 包名(组织 scope 与 GitHub 用户名不一致时
26
- * 仅凭仓库名猜不到,作者看到我们查过/试过的命令就能直接指认)。 */
24
+ /** 尝试过的安装方式(实际执行过的 CLI 命令,按先后顺序):
25
+ * 失败提 Issue 时如实贴给作者,作者据此判断我们真正执行了什么。 */
27
26
  attempts: string[]
28
27
  /** 全局 npm 安装(官方 `npm install -g <pkgs>`):非空时任务执行全局 npm 安装,不进任何 profile */
29
28
  globalNpm?: string[]
30
29
  action: 'add' | 'remove' | 'update'
31
- /** 自定义安装入口渠道(客户端三张卡片:NPM 包 / GitHub 源码 / DSH 命令):
30
+ /** 自定义安装入口渠道(NPM 包卡片):
32
31
  * 任务输出行与系统日志据此溯源「从哪个入口发起」;目录插件安装无此字段。 */
33
- installChannel?: 'npm' | 'git' | 'dsh'
32
+ installChannel?: 'npm'
34
33
  /** 队列语义:pending 排队中 / running 执行中 / done / failed / cancelled(用户取消) */
35
34
  status: 'pending' | 'running' | 'done' | 'failed' | 'cancelled'
36
35
  timedOut: boolean
37
36
  exitCode: number | null
38
37
  /** 0-100 估算进度:解析 pnpm 的 `Progress: resolved…` 输出,阶段行兜底 */
39
38
  progress: number
40
- /** Newest output lines first (consumer shows the tail). */
39
+ /** 输出行最新在前(消费方展示尾部)。 */
41
40
  lines: string[]
42
41
  /** 完成后是否仍需宿主重启才生效:仅「热挂载失败 / 非 dsh 插件 / 更新(ESM 缓存取不到新代码)/
43
42
  * 无 loader」等刷新解决不了的兜底场景为 true(弹窗给「立即重启」);其余一律 false。 */
@@ -70,7 +69,7 @@ export interface QueueItem {
70
69
  timeoutMs?: number
71
70
  env?: NodeJS.ProcessEnv
72
71
  globalNpm?: string[]
73
- installChannel?: 'npm' | 'git' | 'dsh'
72
+ installChannel?: 'npm'
74
73
  }
75
74
  }
76
75
 
@@ -79,7 +78,7 @@ export interface QueueItem {
79
78
  * 它自然清空,正好等于「重启后提醒消失」的语义,无需任何重启探测。
80
79
  */
81
80
  export interface PendingRestart {
82
- /** Git 安装目标(与任务一致;卸载时为展示用的 owner/repo) */
81
+ /** 安装目标(与任务一致;卸载时为展示用的 owner/repo) */
83
82
  target: string
84
83
  /** 待重启语义:install 装完等挂载生效 / uninstall 卸完等清理 loader 残留 */
85
84
  kind: 'install' | 'uninstall'
@@ -87,13 +86,13 @@ export interface PendingRestart {
87
86
  at: number
88
87
  }
89
88
 
90
- /** Grammar of a catalog GitHub repository identity (`owner/repo`). */
89
+ /** 目录 GitHub 仓库身份(`owner/repo`)的语法。 */
91
90
  export const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/
92
- /** Grammar of an npm package name (used for uninstall targets). */
91
+ /** npm 包名的语法(用于卸载目标)。 */
93
92
  export const PACKAGE_RE = /^(?:@[a-z0-9._-]+\/)?[A-Za-z0-9._-]+$/
94
93
  /** 捕获输出上限:stdout/stderr 各保留尾部 N 字节。 */
95
94
  export const CAPTURE_LIMIT_BYTES = 64 * 1024
96
- /** Max output lines kept per task (newest wins). */
95
+ /** 每个任务保留的最大输出行数(最新优先)。 */
97
96
  export const MAX_TASK_LINES = 200
98
- /** Max tracked tasks; oldest finished tasks are dropped first. */
97
+ /** 最多跟踪的任务数;超出时先丢弃最早结束的任务。 */
99
98
  export const MAX_TASKS = 50
@@ -20,7 +20,6 @@ export { addPendingRestart, clearPendingRestart, listPendingRestarts } from '../
20
20
  export { dumpLoaderEntries, removeLoadedEntry } from '../loader.ts'
21
21
  export {
22
22
  addAllowBuildsKey,
23
- githubRefOf,
24
23
  githubRepoOf,
25
24
  githubTarget,
26
25
  globalNpmPackagesOf,
@@ -5,8 +5,9 @@
5
5
  *
6
6
  * 本机 npm 环境检测:判断一次安装失败是否源于「本机 npm 版本过低」。
7
7
  *
8
- * 背景:pnpm 在 git 源插件的 prepare 阶段会调 npm 装依赖,npm arborist 在解 peer 依赖时有
9
- * 已知内部崩溃 —— `Cannot read properties of null (reading 'edgesOut')`。这是 npm 自身缺陷,
8
+ * 背景:pnpm 在带 `prepare` 构建脚本的依赖上会调 npm 装依赖(npm 包同样可能带原生
9
+ * 依赖或 git 依赖),npm arborist 在解 peer 依赖时有已知内部崩溃 ——
10
+ * `Cannot read properties of null (reading 'edgesOut')`。这是 npm 自身缺陷,
10
11
  * 该报错会被 pnpm 包进 ERR_PNPM_PREPARE_PACKAGE 输出,不能按插件打包问题引导用户提 Issue。
11
12
  *
12
13
  * 这里的 `npmTooLowMarker` 是纯函数(输出文本 + 环境 → 标记行或 null),不触碰任务/队列,
@@ -3,50 +3,18 @@
3
3
  * Website: https://dsh-plugin.org
4
4
  * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
5
  *
6
- * npm 包反查:git 分发不完整(缺构建产物/子模块)导致安装失败时,用 npm
7
- * registry 的搜索接口按 repository 地址反查该仓库对应的官方 npm 包。
6
+ * npm 包校验:确认某个 npm 包确实属于该 GitHub 仓库、且声明为 DSH 插件
7
+ * (包元数据里的 repository 指向该仓库,并带 `dsh-plugin` 关键词或 `dsh` manifest)。
8
8
  *
9
9
  * 这是通用机制,不针对任何具体插件:只要作者发布了 repository 指向该
10
- * GitHub 仓库的 npm 包(scope 名与 GitHub 用户名是否一致无所谓),就能命中,
11
- * 从而把 git 分发不完整的插件切换到 npm 通道安装。命中结果带内存缓存,
12
- * 避免同一会话内重复查询 registry。
10
+ * GitHub 仓库的 npm 包(scope 名与 GitHub 用户名是否一致无所谓),就能命中。
13
11
  */
14
12
  import { get } from 'node:https'
15
13
  import { githubRepoOf } from '../profile/profile.ts'
16
14
  import { finalUrl } from '../probe.ts'
17
15
 
18
- /** 反查缓存:repo 小写 → npm 包名(null 表示已确认无对应 npm 包;网络异常不缓存)。 */
19
- const cache = new Map<string, string | null>()
20
-
21
- /** 单次 registry 查询超时(ms):慢网络下失败返回 null,不阻塞安装流程。 */
16
+ /** 单次 registry 查询超时(ms):慢网络下失败返回 false,不阻塞安装流程。 */
22
17
  const REQUEST_TIMEOUT_MS = 8000
23
- /** npm search 是相关性搜索而非 repository 精确索引;拉满单页结果后再本地做严格 repo 过滤。 */
24
- const SEARCH_RESULT_LIMIT = 250
25
-
26
- /**
27
- * 反查 repo(`owner/repo`)对应的官方 npm 包名;未命中、网络异常或超时返回 null。
28
- * 返回 null 不代表仓库一定没有 npm 包,只代表本次未能确认 —— 调用方应保留
29
- * 原有错误路径,反查只是额外的一次尝试。只有「已确认」的结果(包名,或 200
30
- * 响应下确认无匹配)会进缓存;网络异常/超时/解析失败不缓存,同一会话内下次
31
- * 安装仍会重试反查,避免一次瞬时故障把整个会话锁死在 git 通道。
32
- * registry 参数:npm 镜像源地址,空串 = 官方源;与安装通道吃同一 registry,
33
- * 保证「配置了镜像」时反查和安装走同一个源(镜像节点同步完整时结果一致)。
34
- * proxy 参数:GitHub 改名跳转探测走的代理(与安装/诊断同一口径)。
35
- * 慢网络下失败不阻塞安装。
36
- */
37
- export async function resolveNpmPackage(repo: string, registry = '', proxy = ''): Promise<string | null> {
38
- const key = repo.toLowerCase()
39
- if (cache.has(key)) return cache.get(key) ?? null
40
- let found = await searchRepo(repo, registry)
41
- // 仓库改名后 npm 包的 repository 已指向新名,按旧名反查不到:跟随一次 GitHub 的
42
- // 改名跳转拿到规范名再查一遍(旧地址本身仍可用,只是仓库名变了)。
43
- if (found === null) {
44
- const canonical = await canonicalRepo(repo, proxy)
45
- if (canonical !== '' && canonical.toLowerCase() !== key) found = await searchRepo(canonical, registry)
46
- }
47
- if (found !== undefined) cache.set(key, found)
48
- return found ?? null
49
- }
50
18
 
51
19
  type NpmSearchPackage = {
52
20
  name?: unknown
@@ -56,16 +24,7 @@ type NpmSearchPackage = {
56
24
  dsh?: unknown
57
25
  }
58
26
 
59
- type NpmSearchObject = { package?: NpmSearchPackage }
60
- type NpmSearchResponse = { objects?: NpmSearchObject[] }
61
-
62
- /**
63
- * Pick the installable npm package for one GitHub repository. npm search is a
64
- * relevance-ranked text search, not an exact repository index, so first filter
65
- * by repository URL. If a monorepo publishes both a CLI/helper package and a
66
- * DSH plugin, prefer the package that explicitly advertises the `dsh-plugin`
67
- * keyword instead of whichever result npm happened to rank first.
68
- */
27
+ /** 从包元数据取 repository 地址:字符串或 `{ url }` 对象,缺省回退 search 结果的 links.repository。 */
69
28
  function repositoryUrlOf(pkg: NpmSearchPackage): string {
70
29
  if (typeof pkg.repository === 'string') return pkg.repository
71
30
  if (pkg.repository !== null && typeof pkg.repository === 'object') {
@@ -83,6 +42,10 @@ export function isDshPackageMetadataForRepo(pkg: NpmSearchPackage, repo: string)
83
42
  return keywordMarked || manifestMarked
84
43
  }
85
44
 
45
+ /**
46
+ * 校验包名对应的 npm 包确实属于该仓库且声明为 DSH 插件:取 registry 上的 `latest`
47
+ * 元数据比对 repository 与插件标记。网络异常、超时、非 200 一律返回 false,不阻塞调用方。
48
+ */
86
49
  export function isDshNpmPackageForRepo(packageName: string, repo: string, registry = '', proxy = ''): Promise<boolean> {
87
50
  if (packageName === '' || repo === '') return Promise.resolve(false)
88
51
  const base = registry === '' ? 'https://registry.npmjs.org' : registry.replace(/\/+$/, '')
@@ -130,56 +93,3 @@ async function canonicalRepo(repo: string, proxy: string): Promise<string> {
130
93
  const canonical = final === null ? null : githubRepoOf(final)
131
94
  return canonical !== null && canonical.toLowerCase() !== key ? canonical : ''
132
95
  }
133
-
134
- function matchingPackagesForRepo(objects: NpmSearchObject[], repo: string): Array<{ name: string; isDshPlugin: boolean }> {
135
- const matches: Array<{ name: string; isDshPlugin: boolean }> = []
136
-
137
- for (const obj of objects) {
138
- const pkg = obj.package
139
- if (typeof pkg?.name !== 'string' || pkg.name === '') continue
140
- const repoUrl = repositoryUrlOf(pkg)
141
- if (githubRepoOf(repoUrl)?.toLowerCase() !== repo.toLowerCase()) continue
142
- const keywords = Array.isArray(pkg.keywords) ? pkg.keywords : []
143
- matches.push({
144
- name: pkg.name,
145
- isDshPlugin: keywords.some((keyword) => typeof keyword === 'string' && keyword.toLowerCase() === 'dsh-plugin'),
146
- })
147
- }
148
-
149
- return matches
150
- }
151
-
152
- export function selectNpmPackageForRepo(objects: NpmSearchObject[], repo: string): string | null {
153
- const matches = matchingPackagesForRepo(objects, repo)
154
- return matches.find((candidate) => candidate.isDshPlugin)?.name ?? matches[0]?.name ?? null
155
- }
156
-
157
- /** 用 npm search 接口按 repository 地址反查,并做铁证校验(包元数据必须指向该仓库)。
158
- * 返回:包名 = 命中;null = 200 响应下确认无匹配;undefined = 本次未能确认(网络/超时/解析失败)。 */
159
- function searchRepo(repo: string, registry: string): Promise<string | null | undefined> {
160
- const [owner, name] = repo.split('/')
161
- if (owner === undefined || name === undefined || name === '') return Promise.resolve(null)
162
- const base = registry === '' ? 'https://registry.npmjs.org' : registry.replace(/\/+$/, '')
163
- const url = `${base}/-/v1/search?text=${encodeURIComponent(`repository:${owner}/${name}`)}&size=${SEARCH_RESULT_LIMIT}`
164
- return new Promise((resolve) => {
165
- const req = get(url, { timeout: REQUEST_TIMEOUT_MS }, (res) => {
166
- if (res.statusCode !== 200) {
167
- res.resume()
168
- resolve(undefined)
169
- return
170
- }
171
- let body = ''
172
- res.on('data', (chunk: Buffer) => { body += chunk.toString() })
173
- res.on('end', () => {
174
- try {
175
- const data = JSON.parse(body) as NpmSearchResponse
176
- resolve(selectNpmPackageForRepo(data.objects ?? [], `${owner}/${name}`))
177
- } catch {
178
- resolve(undefined)
179
- }
180
- })
181
- })
182
- req.on('timeout', () => req.destroy())
183
- req.on('error', () => resolve(undefined))
184
- })
185
- }
@@ -10,7 +10,8 @@
10
10
  * 防止插件把任意路径打印进日志造成越权读取;任何异常都静默降级(返回 null),
11
11
  * 不改变原有失败处理。
12
12
  */
13
- import { readFileSync } from 'node:fs'
13
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
14
+ import { join } from 'node:path'
14
15
  import { CAPTURE_LIMIT_BYTES } from './install-types.ts'
15
16
 
16
17
  /**
@@ -44,3 +45,48 @@ export function backfillOperationLog(output: string): string | null {
44
45
  return null
45
46
  }
46
47
  }
48
+
49
+ /**
50
+ * 运行期抓取:宿主的 operation 日志只在命令运行期间存在,结束后可能被清理,
51
+ * 退出后再按路径读往往已经拿不到。这里在安装过程中轮询 profile 下最新的
52
+ * `.plugin-manager/logs/<operation>/pnpm.log`,用抓到的内容兜底。
53
+ * 只认本次运行产生的新文件(mtime 晚于启动基线),避免复述上一次安装的日志。
54
+ */
55
+ export function createOperationLogTap(profileDir: string): { stop: () => string } {
56
+ const logsDir = join(profileDir, '.plugin-manager', 'logs')
57
+ const newestLog = (): { file: string; mtimeMs: number } | null => {
58
+ let newest: { file: string; mtimeMs: number } | null = null
59
+ for (const entry of readdirSync(logsDir, { withFileTypes: true })) {
60
+ if (!entry.isDirectory()) continue
61
+ const file = join(logsDir, entry.name, 'pnpm.log')
62
+ try {
63
+ const mtimeMs = statSync(file).mtimeMs
64
+ if (newest === null || mtimeMs > newest.mtimeMs) newest = { file, mtimeMs }
65
+ } catch { /* 该 operation 还没有 pnpm.log */ }
66
+ }
67
+ return newest
68
+ }
69
+ let baselineMs = 0
70
+ try {
71
+ baselineMs = newestLog()?.mtimeMs ?? 0
72
+ } catch { /* logs 目录还不存在 */ }
73
+ let captured = ''
74
+ const tick = (): void => {
75
+ try {
76
+ const newest = newestLog()
77
+ if (newest === null || newest.mtimeMs <= baselineMs) return
78
+ const text = readFileSync(newest.file, 'utf8')
79
+ if (text.length > captured.length) captured = text
80
+ } catch { /* 读不到就放弃这一轮 */ }
81
+ }
82
+ const timer = setInterval(tick, 700)
83
+ timer.unref()
84
+ tick()
85
+ return {
86
+ stop: (): string => {
87
+ clearInterval(timer)
88
+ tick()
89
+ return captured.length > CAPTURE_LIMIT_BYTES ? captured.slice(-CAPTURE_LIMIT_BYTES) : captured
90
+ },
91
+ }
92
+ }
@@ -4,17 +4,12 @@
4
4
  * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
5
  *
6
6
  * 分发文件解析:从 package.json 推出装完后必须真实存在的文件 —— 作者声明的入口
7
- * (exports["."] / main)与 bundle 声明的补丁(dsh.bundle.patch)。git 通道(tar 预检)
8
- * 与 npm 通道(装后 statSync 校验)共用,保证两条通道对同一个包判定一致。
7
+ * (exports["."] / main)与 bundle 声明的补丁(dsh.bundle.patch),供装后校验使用。
9
8
  *
10
- * 这里不替作者补一个 index.js:宿主加载一个 bundle 读的是它的 package.json 和它声明的
11
- * 补丁文件(官方 bundleManifest 的注释即「Read bundle metadata without loading its
12
- * JavaScript」),从不 import 包本身。凭空要求一个作者没声明的入口,只会把完整的
13
- * 技能包判成分发残缺,并把一条假缺陷推到作者仓库(见 QingYunA/answer-me-with-html#209)。
14
- *
15
- * 例外是补丁自己 mount 了本包:dsh 的《打包与安装插件》里组合包的常规写法就是
16
- * `- insert: - name: <本包名>`,宿主随后按包名 import 本包,入口那时必须存在。
17
- * 所以只有补丁真的写了自己的包名,才把这个入口算进分发要求。
9
+ * 只在包自己声明了入口时才要求它存在:宿主加载 bundle 读的是它的 package.json 与声明的补丁,
10
+ * 从不 import 包本身。硬要一个作者没声明过的 index.js,会把完整的包判成分发残缺、把一条假缺陷
11
+ * 推到作者仓库。例外是补丁自己 mount 了本包(组合包的常规写法)—— 那时宿主按包名 import 本包,
12
+ * 入口必须存在,所以只有补丁真的写了自己的包名才把这个入口算进分发要求。
18
13
  */
19
14
 
20
15
  /** exports 条件对象里按优先级尝试的键;Node 语义下 default 最宽,import/require 次之。 */
@@ -53,7 +48,7 @@ export function resolvePackageEntry(meta: PackageManifest): string | null {
53
48
  /**
54
49
  * 解析 `dsh.bundle.patch` 声明的补丁文件(相对包根,不带 `./` 前缀)。
55
50
  * 形状不合规范(既不是字符串也不是字符串数组)在这里不判定:那是 dsh 启动时
56
- * bundlePatchFiles 的报错职责,预检只负责「声明了就要在包里存在」。
51
+ * bundlePatchFiles 的报错职责,装后校验只负责「声明了就要在包里存在」。
57
52
  */
58
53
  export function resolveBundlePatchFiles(meta: PackageManifest): string[] {
59
54
  const patch = (meta.dsh as { bundle?: { patch?: unknown } } | undefined)?.bundle?.patch
@@ -69,8 +69,8 @@ export async function registryMissingMarker(
69
69
  ): Promise<string | null> {
70
70
  const name = missingPackageOf(output)
71
71
  if (name === null) return null
72
- // ① profile 自己声明的残留依赖(#73):用户环境问题,交给既有的 pnpmMissingDep 提示(该提示就是
73
- // 教用户删 profile 里那条依赖),这里不再产出标记、不改归属
72
+ // 该包是 profile 自己声明的依赖(用户环境里留下的条目)→ 用户环境问题,交给既有的 pnpmMissingDep
73
+ // 提示处理(那条提示就是教用户删掉它),不产出标记、不改归属
74
74
  if (profileDependencyNames(opts.profileDir).some((dep) => dep.toLowerCase() === name.toLowerCase())) return null
75
75
  const exists = await existsOnOfficialRegistry(name, opts.fetchImpl ?? fetch, opts.timeoutMs ?? 8000)
76
76
  if (exists === false) {
@@ -3,14 +3,13 @@
3
3
  * Website: https://dsh-plugin.org
4
4
  * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
5
  *
6
- * 预构建 GitHub release 安装目标(目录权威命令 → 可安装直链)。
7
- * 部分插件(如 loopx)把构建产物发布成 GitHub release 的 .tgz,仓库 HEAD 反而是
8
- * monorepo 根、走 git 直装会因缺入口文件失败;目录下发的权威安装命令直接指向该
9
- * release 包。这里把「目录命令」解析成安全的 release 目标,供客户端选路与服务端
10
- * 校验共用(单一事实来源,避免两端口径漂移)。
6
+ * 预构建 release 直链的仓库身份解析。部分插件(如 loopx)当初以 GitHub release 的 .tgz
7
+ * 安装过,仓库 HEAD 反而是 monorepo 根、走 git 直装会因缺入口文件失败;老安装记录里留的
8
+ * 就是这种 release 直链。这里把「历史安装目标」还原成所属仓库身份,供已装态读回、队列
9
+ * 去重与展示复用(单一事实来源,避免两端口径漂移)。
11
10
  *
12
11
  * 只接受固定形态的 HTTPS `github.com/<owner>/<repo>/releases/download/<tag>/<asset>.tgz`:
13
- * 目录命令是展示数据、绝非 shell 代码,绝不执行命令文本;跨仓跳转、外部主机、凭据、
12
+ * 输入是记录/展示数据、绝非 shell 代码,绝不执行其文本;跨仓跳转、外部主机、凭据、
14
13
  * 移动的 latest 链接、query/fragment、多余 CLI 参数与 shell 片段一律拒绝。
15
14
  */
16
15
 
@@ -22,15 +21,15 @@ export interface GithubReleaseTarget {
22
21
  repo: string
23
22
  }
24
23
 
25
- /** 目录权威命令:`dsh plugin --profile <p> add|update <target>`(与 profile.ts installTargetOf 同口径)。 */
24
+ /** 完整命令前缀:`dsh plugin --profile <p> add|update <target>`(与 profile.ts installTargetOf 同口径)。 */
26
25
  const DSH_PLUGIN_CMD_RE = /^dsh[ \t]+plugin[ \t]+--profile(?:[ \t]+|=)[A-Za-z0-9_-]+[ \t]+(?:add|update)[ \t]+(.+)$/i
27
26
 
28
27
  /** 固定形态的 release .tgz 直链:主机、路径段与扩展名全部锁死。 */
29
28
  const RELEASE_URL_RE = /^https:\/\/github\.com\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/releases\/download\/([A-Za-z0-9._+-]+)\/([A-Za-z0-9._+-]+\.tgz)$/
30
29
 
31
30
  /**
32
- * 解析一个 release 安装目标。
33
- * @param value - 目录命令(`dsh plugin --profile web add <url>`)或裸直链。
31
+ * 解析一个历史安装目标里的 release 直链。
32
+ * @param value - 完整命令(`dsh plugin --profile web add <url>`)或裸直链。
34
33
  * @returns 命中返回 `{ target, repo }`,否则 null。
35
34
  */
36
35
  export function githubReleaseTarget(value: string): GithubReleaseTarget | null {
@@ -15,7 +15,7 @@ import type { InstallResult, InstallTask, Invocation, QueueItem } from './instal
15
15
  import { CAPTURE_LIMIT_BYTES, MAX_TASKS, MAX_TASK_LINES } from './install-types.ts'
16
16
  import { npmTooLowMarker } from './npm-check.ts'
17
17
  import { registryMissingMarker } from './registry-check.ts'
18
- import { backfillOperationLog } from './operation-log.ts'
18
+ import { backfillOperationLog, createOperationLogTap, operationLogPathOf } from './operation-log.ts'
19
19
  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'
@@ -23,15 +23,15 @@ import { addPendingRestart, clearPendingRestart } from '../profile/pending-resta
23
23
  import { addAllowBuildsKey, githubRepoOf, parseAllowBuildsKey, profileDirectory } from '../profile/profile.ts'
24
24
  import { appendLog } from '../log.ts'
25
25
 
26
- /** In-memory task registry, keyed by task id. */
26
+ /** 内存任务注册表,按任务 id 索引。 */
27
27
  const tasks = new Map<number, InstallTask>()
28
28
  let nextTaskId = 1
29
- /** Pending tasks awaiting their turn (FIFO). The queue worker runs at most one mutation at a time. */
29
+ /** 等待轮到的排队任务(FIFO)。队列 worker 同一时刻最多跑一个变更。 */
30
30
  const queue: QueueItem[] = []
31
- /** Child processes of currently running tasks (for cancel). */
31
+ /** 当前运行任务的子进程(用于取消)。 */
32
32
  const runningChildren = new Map<number, ReturnType<typeof spawn>>()
33
33
 
34
- /** Snapshot of a task (keeps the live object untouched by consumers). */
34
+ /** 任务快照(避免消费方改动实时对象)。 */
35
35
  export function getTask(id: number): InstallTask | undefined {
36
36
  const task = tasks.get(id)
37
37
  if (!task) return undefined
@@ -45,17 +45,17 @@ export interface ActiveTaskInfo {
45
45
  status: 'pending' | 'running'
46
46
  progress: number
47
47
  lines: string[]
48
- /** 尝试过的安装方式(npm 反查 + 实际执行命令):失败时前端 issue 预填需要 */
48
+ /** 尝试过的安装方式(实际执行过的命令):失败时前端 issue 预填需要 */
49
49
  attempts: string[]
50
- /** 自定义安装入口渠道(NPM 包 / GitHub 源码 / DSH 命令卡片):前端刷新恢复后仍能精准溯源 */
51
- installChannel?: 'npm' | 'git' | 'dsh'
50
+ /** 自定义安装入口渠道(NPM 包卡片):前端刷新恢复后仍能精准溯源 */
51
+ installChannel?: 'npm'
52
52
  }
53
53
 
54
- /** All non-terminal tasks in queue order (running first, then pending). Lets the client resume a queue after a page reload. */
54
+ /** 全部未终态任务,按队列顺序(running 在前、pending 在后)。供客户端刷新页面后恢复队列。 */
55
55
  export function activeTask(): ActiveTaskInfo[] {
56
56
  const found: ActiveTaskInfo[] = []
57
- // 展示目标用 displayTarget(owner/repo):安装 target 可能被服务端切成 npm 包名
58
- // (npm 反查命中),队列恢复/待重启展示对用户保持仓库名无感知
57
+ // 展示目标用 displayTarget(owner/repo):安装 target 是 npm 包名,
58
+ // 队列恢复/待重启展示对用户保持仓库名无感知
59
59
  const show = (task: InstallTask): string => task.displayTarget ?? task.target
60
60
  for (const item of queue) {
61
61
  const task = item.task
@@ -90,26 +90,24 @@ export function activeTask(): ActiveTaskInfo[] {
90
90
  }
91
91
 
92
92
  /**
93
- * True while a mutation child process is still alive. The concurrent slot is
94
- * keyed off the live child set rather than task.status: cancelling a running
95
- * task flips its status immediately, but the child may still be shutting down
96
- * (the close event lands later) — the slot must stay held until then, otherwise
97
- * a new task would start while the old pnpm still runs in the same profile dir.
93
+ * 变更子进程仍存活时为 true。并发占位以存活子进程集合为准,而非 task.status:
94
+ * 取消运行中任务会立即翻转其状态,但子进程可能仍在退出中(close 事件稍后才到)——
95
+ * 占位必须维持到那时,否则旧 pnpm 还在同一 profile 目录跑,新任务就开始了。
98
96
  */
99
97
  export function hasRunningTask(): boolean {
100
98
  return runningChildren.size > 0
101
99
  }
102
100
 
103
- /** Whether the target already has a non-terminal task in the queue (install/uninstall dedupe). */
101
+ /** 目标在队列里是否已有未终态任务(安装/卸载去重)。 */
104
102
  export function hasQueuedTarget(target: string): boolean {
105
103
  return queue.some((it) => it.task.target === target && (it.task.status === 'pending' || it.task.status === 'running'))
106
104
  }
107
105
 
108
106
  /**
109
- * Cancel a queued or running task.
110
- * - pending: removed from the queue immediately (never spawned).
111
- * - running: kills the child process; the queue worker then picks the next one.
112
- * Returns false when the task is unknown or already finished.
107
+ * 取消排队中或运行中的任务。
108
+ * - pending:立即移出队列(从未 spawn)。
109
+ * - running:杀掉子进程;队列 worker 随后取下一个。
110
+ * 任务不存在或已结束时返回 false。
113
111
  */
114
112
  export function cancelTask(id: number): boolean {
115
113
  const task = tasks.get(id)
@@ -260,17 +258,14 @@ function mutationVerb(action: 'add' | 'remove' | 'update'): string {
260
258
 
261
259
  /** 系统日志入口渠道后缀:自定义安装(带渠道)在日志里体现「从哪张卡片发起」,
262
260
  * 目录插件安装(无渠道)返回空串。日志中文语境,渠道名用中文口语。 */
263
- function channelLabel(channel: 'npm' | 'git' | 'dsh' | undefined): string {
264
- if (channel === 'git') return '(GitHub 源码入口)'
265
- if (channel === 'dsh') return '(DSH 命令入口)'
261
+ function channelLabel(channel: 'npm' | undefined): string {
266
262
  if (channel === 'npm') return '(NPM 包入口)'
267
263
  return ''
268
264
  }
269
265
 
270
266
  /**
271
- * Enqueue a plugin mutation and return its task. Tasks run strictly serially:
272
- * the queue worker starts the next one only after the previous finishes.
273
- * Progress is visible through `getTask(id)` / `activeTask()` until done.
267
+ * 插件变更入队并返回其任务。任务严格串行:队列 worker 只在上一个结束后才启动下一个。
268
+ * 完成前可通过 `getTask(id)` / `activeTask()` 查看进度。
274
269
  */
275
270
  export function startPluginMutation(options: {
276
271
  action: 'add' | 'remove' | 'update'
@@ -285,13 +280,13 @@ export function startPluginMutation(options: {
285
280
  /** 运行中 loader:安装成功后热挂新条目、卸载成功后停用旧条目,两类操作都尽量立即生效
286
281
  * (缺失时安装/卸载仍需重启) */
287
282
  loader?: LoaderHandle
288
- /** 入队前已尝试的安装方式(npm registry 反查等):失败提 Issue 时如实展示;实际执行的命令由 spawn 时追加 */
283
+ /** 入队前已尝试的安装方式:失败提 Issue 时如实展示;实际执行的命令由 spawn 时追加 */
289
284
  attempts?: string[]
290
285
  /** 更新已安装的 npm 包(mode=update):命令显式带 @latest,让 pnpm 真正解析最新版本 */
291
286
  updateNpm?: boolean
292
- /** 自定义安装入口渠道(客户端三张卡片:NPM 包 / GitHub 源码 / DSH 命令):
293
- * 任务输出行与系统日志据此溯源「从哪个入口发起」;目录插件安装不传 */
294
- installChannel?: 'npm' | 'git' | 'dsh'
287
+ /** 自定义安装入口渠道(NPM 包卡片):任务输出行与系统日志据此溯源「从哪个入口发起」;
288
+ * 目录插件安装不传 */
289
+ installChannel?: 'npm'
295
290
  }): InstallTask {
296
291
  // 卸载入队时先快照「该包是否带客户端 UI」:卸载完成后包已从磁盘删除,读不到 package.json,
297
292
  // 只能在此刻取;卸载成功且曾存活时据此决定是否要刷新页面摘除插件面板
@@ -341,7 +336,7 @@ export function startPluginMutation(options: {
341
336
  return task
342
337
  }
343
338
 
344
- /** Queue worker: at most one mutation runs at a time; pick the next pending task when the slot frees up. */
339
+ /** 队列 worker:同一时刻最多跑一个变更;占位空出时取下一个 pending 任务。 */
345
340
  function pumpQueue(): void {
346
341
  if (hasRunningTask()) return
347
342
  const item = queue.find((it) => it.task.status === 'pending')
@@ -399,9 +394,8 @@ function pumpQueue(): void {
399
394
  }
400
395
 
401
396
  /**
402
- * Run one `dsh plugin --profile <profile> <action> <target>` spawn, stream
403
- * its output onto the tracked task and resolve with the captured result.
404
- * Never rejects; failures surface through the result / task state.
397
+ * 执行一次 `dsh plugin --profile <profile> <action> <target>` spawn,把输出流式写到
398
+ * 跟踪的任务上,并以捕获结果 resolve。绝不 reject;失败通过结果/任务状态体现。
405
399
  */
406
400
  function spawnMutation(options: {
407
401
  action: 'add' | 'remove' | 'update'
@@ -438,8 +432,7 @@ function spawnMutation(options: {
438
432
  const args = isGlobalNpm
439
433
  ? ['install', '-g', ...(globalNpm ?? [])]
440
434
  : [...invocation.prefixArgs, 'plugin', '--profile', profile, cliAction, npmUpdateTarget]
441
- // 记录实际执行的命令(npm 通道显示包名,git 通道显示 HTTPS 源),
442
- // 失败提 Issue 时作为「已尝试的安装方式」随附
435
+ // 记录实际执行的命令(显示包名),失败提 Issue 时作为「已尝试的安装方式」随附
443
436
  const executed = isGlobalNpm
444
437
  ? `npm install -g ${(globalNpm ?? []).join(' ')}`
445
438
  : `dsh plugin --profile ${profile} ${cliAction} ${npmUpdateTarget}`
@@ -655,7 +648,7 @@ function spawnMutation(options: {
655
648
 
656
649
  /**
657
650
  * 安装后校验已装包里 package.json 声明的文件(main / exports["."].default 与
658
- * dsh.bundle.patch)是否真实存在。git: 分发常不提交构建产物(lib/ 等),pnpm 装完没有
651
+ * dsh.bundle.patch)是否真实存在。npm 包也可能漏发构建产物(lib/ 等),pnpm 装完没有
659
652
  * 报错,但宿主重启加载插件树时会 ERR_MODULE_NOT_FOUND 直接崩溃、网页打不开 —— 这里在
660
653
  * 登记「待重启」前就把这类残缺包拦下。没有声明过的文件不在这里追究:没声明入口的
661
654
  * bundle 包(只挂技能)本来就不需要入口,宿主读的是它的 package.json 与声明的补丁。
@@ -754,13 +747,11 @@ export function hostSpawnMarker(invocation: Invocation, error: unknown): string
754
747
  }
755
748
 
756
749
  /**
757
- * Run a plugin mutation with one recovery path: when `add` fails because
758
- * pnpm's allowBuilds gate blocks a build script — either the git-hosted
759
- * package's own `prepare` (`ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`, key from
760
- * the printed key@url hint) or a dependency's native build
761
- * (`ERR_PNPM_IGNORED_BUILDS`, key = the listed name@version) — write the
762
- * exact keys into the profile's pnpm-workspace.yaml and retry once.
763
- * Other failures fall through to the single-spawn result.
750
+ * 执行插件变更,带一条恢复路径:当 `add` 因 pnpm 的 allowBuilds 门禁拦下构建脚本而失败 ——
751
+ * 或是 git 源包自己的 `prepare`(`ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`,key 取自打印的
752
+ * key@url 提示),或是某依赖的原生构建(`ERR_PNPM_IGNORED_BUILDS`,key = 列出的 name@version)——
753
+ * 就把确切的 key 写进 profile 的 pnpm-workspace.yaml 并重试一次。
754
+ * 其它失败一律落到单次 spawn 的结果。
764
755
  */
765
756
  export async function runPluginMutation(options: {
766
757
  action: 'add' | 'remove' | 'update'
@@ -776,6 +767,8 @@ export async function runPluginMutation(options: {
776
767
  /** 全局 npm 安装(官方 `npm install -g <pkgs>`):非空时执行全局安装,不进任何 profile、无需宿主重启 */
777
768
  globalNpm?: string[]
778
769
  }): Promise<InstallResult> {
770
+ // operation 日志只在运行期存在(宿主结束后可能清掉),先开抓取,退出后按路径读作兜底
771
+ const logTap = createOperationLogTap(profileDirectory(options.profile))
779
772
  let result = await spawnMutation(options)
780
773
  // allowBuilds 放行 / 装后入口校验都是 profile 专属(pnpm 装进 profile 的场景);
781
774
  // 全局 npm 安装直接走 npm,无 pnpm allowBuilds 拦截、也不进 profile,跳过这两类恢复
@@ -787,8 +780,7 @@ export async function runPluginMutation(options: {
787
780
  const key = parseAllowBuildsKey(output)
788
781
  if (key !== null) {
789
782
  if (options.task) {
790
- // First attempt already marked the task failed; reset it so the
791
- // retry streams progress as a running task again.
783
+ // 首次尝试已把任务标为失败;重置它,让重试重新以 running 任务形式流式输出进度。
792
784
  options.task.status = 'running'
793
785
  options.task.exitCode = null
794
786
  pushLine(options.task, `[allow build] ${key}`)
@@ -815,14 +807,16 @@ export async function runPluginMutation(options: {
815
807
  }
816
808
  }
817
809
  }
818
- // pnpm 安装本身成功,但目标包声明分发的文件缺失(git 分发缺构建产物 / 缺声明的补丁)→ 判为失败:
810
+ // 重试会产生新的 operation 日志,运行期抓取到此为止
811
+ const tappedLog = logTap.stop()
812
+ // pnpm 安装本身成功,但目标包声明分发的文件缺失(分发物缺构建产物 / 缺声明的补丁)→ 判为失败:
819
813
  // 撤销刚登记的待重启,避免用户重启时宿主加载残缺包直接崩溃。
820
814
  // 全局 npm 安装不进 profile(profile deps 里没有这些包),跳过入口校验
821
815
  if (options.action !== 'remove' && result.exitCode === 0 && !result.timedOut && (options.globalNpm ?? []).length === 0) {
822
816
  const verified = verifyInstalledEntry(options.profile, options.target)
823
817
  if (verified.name !== null && verified.missing !== null) {
824
818
  clearPendingRestart(options.displayTarget ?? options.target)
825
- const detail = `[packaging] ${verified.name}: declared file missing: ${verified.missing} (git distribution lacks build output — install the npm version or report to the author)`
819
+ const detail = `[packaging] ${verified.name}: declared file missing: ${verified.missing} (the published package is missing this file — report it to the author)`
826
820
  if (options.task && options.task.status !== 'cancelled') {
827
821
  options.task.status = 'failed'
828
822
  options.task.exitCode = null
@@ -832,18 +826,21 @@ export async function runPluginMutation(options: {
832
826
  return { exitCode: 1, timedOut: false, error: `declared file missing: ${verified.missing}`, stdout: result.stdout, stderr: `${result.stderr}\n${detail}` }
833
827
  }
834
828
  }
835
- // DSH 只把一行摘要(`dsh: plugin command failed; diagnostics: <path>`)回显到控制台时,
836
- // 控制台里没有任何错误特征,前端只能把失败归为「未知」(issue #192)。这里按该路径回捞
837
- // operation 日志尾部补进失败信息:用户直接看到真实报错,自动归类也有据可依。
838
- // 只在本就不含任何错误码时回捞 —— pnpm 已把报错回显出来时无需多读一次文件。
829
+ // 宿主只把一行摘要(`dsh: plugin command failed; diagnostics: <path>`)回显到控制台、真正的报错
830
+ // 写进该日志文件时,前端拿不到任何错误特征、只能归为「未知」。按该路径回捞日志尾部补进失败信息。
831
+ // 只在输出本就不含错误码时回捞 —— pnpm 已回显报错时无需多读一次文件。
839
832
  if (result.exitCode !== 0 && !result.timedOut && !/\bERR_[A-Z0-9_]+\b/.test(`${result.stderr}\n${result.stdout}`)) {
840
- const backfilled = backfillOperationLog(`${result.stderr}\n${result.stdout}`)
833
+ const output = `${result.stderr}\n${result.stdout}`
834
+ const backfilled = tappedLog !== '' ? tappedLog : backfillOperationLog(output)
841
835
  if (backfilled !== null) {
842
836
  if (options.task && options.task.status !== 'cancelled') {
843
837
  pushLine(options.task, '[pnpm log] DSH did not echo pnpm output — recovered the operation log from disk')
844
838
  for (const line of backfilled.split(/\r?\n/)) if (line.trim() !== '') pushLine(options.task, line.trimEnd())
845
839
  }
846
840
  result = { ...result, stderr: `${result.stderr}\n${backfilled}` }
841
+ } else if (options.task && options.task.status !== 'cancelled' && operationLogPathOf(output) !== null) {
842
+ // 诊断路径在、日志却取不到:留一行证据,否则下一次同类上报又只剩宿主那句摘要、看不出根因
843
+ pushLine(options.task, '[pnpm log] the host-reported diagnostics log could not be read from disk (missing, empty, or unreadable)')
847
844
  }
848
845
  }
849
846
  // 包在 registry 不存在(404)→ 官方源复查,区分「作者的依赖缺失/包被删」与「本机镜像源滞后」