dsh-plugin 0.1.1 → 1.1.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 (67) hide show
  1. package/README.en.md +56 -28
  2. package/README.md +56 -28
  3. package/client/client.js +2282 -230
  4. package/client/client.js.map +1 -1
  5. package/lib/http/routes.js +432 -0
  6. package/lib/index.js +17 -8
  7. package/lib/services/install-types.js +11 -0
  8. package/lib/services/install.js +4 -0
  9. package/lib/services/installed-versions.js +66 -0
  10. package/lib/services/loader.js +98 -0
  11. package/lib/services/npm-resolve.js +67 -0
  12. package/lib/services/pending-restart.js +34 -0
  13. package/lib/services/preflight.js +94 -0
  14. package/lib/services/profile.js +88 -0
  15. package/lib/services/progress.js +50 -0
  16. package/lib/services/task-queue.js +457 -0
  17. package/lib/types/http/routes.d.ts +23 -0
  18. package/lib/types/index.d.ts +27 -0
  19. package/lib/types/services/install-types.d.ts +69 -0
  20. package/lib/types/services/install.d.ts +18 -0
  21. package/lib/types/services/installed-versions.d.ts +24 -0
  22. package/lib/types/services/loader.d.ts +43 -0
  23. package/lib/types/services/npm-resolve.d.ts +6 -0
  24. package/lib/types/services/pending-restart.d.ts +13 -0
  25. package/lib/types/services/preflight.d.ts +6 -0
  26. package/lib/types/services/profile.d.ts +24 -0
  27. package/lib/types/services/progress.d.ts +17 -0
  28. package/lib/types/services/task-queue.d.ts +82 -0
  29. package/package.json +27 -6
  30. package/src/client/components/CatalogControls.tsx +104 -0
  31. package/src/client/components/CatalogHeader.tsx +78 -0
  32. package/src/client/components/CatalogList.tsx +75 -0
  33. package/src/client/components/CategoryTabs.tsx +35 -0
  34. package/src/client/components/Dropdown.tsx +80 -0
  35. package/src/client/components/NotificationsModal.tsx +243 -0
  36. package/src/client/components/PluginCard.tsx +104 -0
  37. package/src/client/components/PluginHubSection.tsx +342 -0
  38. package/src/client/components/ProgressView.tsx +23 -0
  39. package/src/client/components/icons.tsx +125 -0
  40. package/src/client/components/modals.tsx +368 -0
  41. package/src/client/hooks/useCatalog.ts +202 -0
  42. package/src/client/hooks/useTaskQueue.ts +502 -0
  43. package/src/client/index.tsx +8 -422
  44. package/src/client/lib/catalog.ts +240 -0
  45. package/src/client/lib/env.ts +16 -0
  46. package/src/client/lib/failures.ts +165 -0
  47. package/src/client/lib/format.ts +22 -0
  48. package/src/client/locales.ts +188 -18
  49. package/src/client/styles/Dropdown.module.css +123 -0
  50. package/src/client/styles/Header.module.css +432 -0
  51. package/src/client/styles/List.module.css +324 -0
  52. package/src/client/styles/Modal.module.css +1006 -0
  53. package/src/client/styles/tokens.module.css +91 -0
  54. package/src/client/types.ts +80 -0
  55. package/src/server/http/routes.ts +432 -0
  56. package/src/server/index.ts +35 -0
  57. package/src/server/services/install-types.ts +69 -0
  58. package/src/server/services/install.ts +18 -0
  59. package/src/server/services/installed-versions.ts +84 -0
  60. package/src/server/services/loader.ts +110 -0
  61. package/src/server/services/npm-resolve.ts +67 -0
  62. package/src/server/services/pending-restart.ts +36 -0
  63. package/src/server/services/preflight.ts +97 -0
  64. package/src/server/services/profile.ts +89 -0
  65. package/src/server/services/progress.ts +48 -0
  66. package/src/server/services/task-queue.ts +492 -0
  67. package/src/client/Section.module.css +0 -589
@@ -0,0 +1,492 @@
1
+ /**
2
+ * 后台安装/卸载任务队列:内存任务注册表 + FIFO 串行 worker + 子进程生命周期管理。
3
+ * 每个任务一次 `dsh plugin --profile <profile> <action> <target>` 子进程,
4
+ * 输出流式收集到任务上;卸载成功时尝试从运行中 loader 即时停用(见 loader.ts)。
5
+ */
6
+ import { spawn } from 'node:child_process'
7
+ import { readFileSync, statSync } from 'node:fs'
8
+ import { dirname, join, resolve } from 'node:path'
9
+ import type { InstallResult, InstallTask, Invocation, QueueItem } from './install-types.ts'
10
+ import { CAPTURE_LIMIT_BYTES, MAX_TASKS, MAX_TASK_LINES } from './install-types.ts'
11
+ import { cleanLine, estimateProgress } from './progress.ts'
12
+ import type { LoaderHandle } from './loader.ts'
13
+ import { removeLoadedEntry } from './loader.ts'
14
+ import { addPendingRestart, clearPendingRestart } from './pending-restart.ts'
15
+ import { addAllowBuildsKey, parseAllowBuildsKey, profileDirectory } from './profile.ts'
16
+
17
+ /** In-memory task registry, keyed by task id. */
18
+ const tasks = new Map<number, InstallTask>()
19
+ let nextTaskId = 1
20
+ /** Pending tasks awaiting their turn (FIFO). The queue worker runs at most one mutation at a time. */
21
+ const queue: QueueItem[] = []
22
+ /** Child processes of currently running tasks (for cancel). */
23
+ const runningChildren = new Map<number, ReturnType<typeof spawn>>()
24
+
25
+ /** Snapshot of a task (keeps the live object untouched by consumers). */
26
+ export function getTask(id: number): InstallTask | undefined {
27
+ const task = tasks.get(id)
28
+ if (!task) return undefined
29
+ return { ...task, lines: task.lines.slice(0, MAX_TASK_LINES) }
30
+ }
31
+
32
+ export interface ActiveTaskInfo {
33
+ id: number
34
+ target: string
35
+ action: 'add' | 'remove'
36
+ status: 'pending' | 'running'
37
+ progress: number
38
+ lines: string[]
39
+ /** 尝试过的安装方式(npm 反查 + 实际执行命令):失败时前端 issue 预填需要 */
40
+ attempts: string[]
41
+ }
42
+
43
+ /** All non-terminal tasks in queue order (running first, then pending). Lets the client resume a queue after a page reload. */
44
+ export function activeTask(): ActiveTaskInfo[] {
45
+ const found: ActiveTaskInfo[] = []
46
+ // 展示目标用 displayTarget(owner/repo):安装 target 可能被服务端切成 npm 包名
47
+ // (npm 反查命中),队列恢复/待重启展示对用户保持仓库名无感知
48
+ const show = (task: InstallTask): string => task.displayTarget ?? task.target
49
+ for (const item of queue) {
50
+ const task = item.task
51
+ if (task.status === 'pending') {
52
+ found.push({ id: task.id, target: show(task), action: task.action, status: 'pending', progress: task.progress, lines: task.lines.slice(0, MAX_TASK_LINES), attempts: task.attempts })
53
+ }
54
+ }
55
+ tasks.forEach((task) => {
56
+ if (task.status === 'running') {
57
+ found.unshift({ id: task.id, target: show(task), action: task.action, status: 'running', progress: task.progress, lines: task.lines.slice(0, MAX_TASK_LINES), attempts: task.attempts })
58
+ }
59
+ })
60
+ return found
61
+ }
62
+
63
+ /**
64
+ * True while a mutation child process is still alive. The concurrent slot is
65
+ * keyed off the live child set rather than task.status: cancelling a running
66
+ * task flips its status immediately, but the child may still be shutting down
67
+ * (the close event lands later) — the slot must stay held until then, otherwise
68
+ * a new task would start while the old pnpm still runs in the same profile dir.
69
+ */
70
+ export function hasRunningTask(): boolean {
71
+ return runningChildren.size > 0
72
+ }
73
+
74
+ /** Whether the target already has a non-terminal task in the queue (install/uninstall dedupe). */
75
+ export function hasQueuedTarget(target: string): boolean {
76
+ return queue.some((it) => it.task.target === target && (it.task.status === 'pending' || it.task.status === 'running'))
77
+ }
78
+
79
+ /**
80
+ * Cancel a queued or running task.
81
+ * - pending: removed from the queue immediately (never spawned).
82
+ * - running: kills the child process; the queue worker then picks the next one.
83
+ * Returns false when the task is unknown or already finished.
84
+ */
85
+ export function cancelTask(id: number): boolean {
86
+ const task = tasks.get(id)
87
+ if (!task) return false
88
+ if (task.status === 'pending') {
89
+ const index = queue.findIndex((it) => it.task === task)
90
+ if (index >= 0) queue.splice(index, 1)
91
+ task.status = 'cancelled'
92
+ pushLine(task, '[cancelled]')
93
+ return true
94
+ }
95
+ if (task.status === 'running') {
96
+ task.status = 'cancelled'
97
+ pushLine(task, '[cancelled]')
98
+ const child = runningChildren.get(id)
99
+ if (child) stopChild(child)
100
+ return true
101
+ }
102
+ return false
103
+ }
104
+
105
+ function pushLine(task: InstallTask, line: string): void {
106
+ const text = cleanLine(line)
107
+ // pnpm 的 `Progress:` 行用 \r 原地刷新同一行:内容一变化就追加,
108
+ // 日志像终端一样实时滚动(downloaded 0→1→2…);内容与最新一条完全相同则跳过,
109
+ // 避免同一进度反复刷新时无意义的重复堆积
110
+ if (text !== task.lines[0]) {
111
+ task.lines.unshift(text)
112
+ if (task.lines.length > MAX_TASK_LINES) task.lines.length = MAX_TASK_LINES
113
+ }
114
+ const estimate = estimateProgress(text)
115
+ if (estimate > 0) {
116
+ task.progress = Math.max(task.progress, estimate)
117
+ } else if (task.status === 'running' && task.progress < 85) {
118
+ // 兜底:行无解析格式也随输出量推进,保证进度条始终在动(不会卡在 0)
119
+ task.progress = Math.max(task.progress, 6 + Math.min(79, task.lines.length * 2))
120
+ }
121
+ }
122
+
123
+ function cliInvocation(): Invocation {
124
+ const entry = process.argv[1]
125
+ if (entry !== undefined && /[\\/](?:bin\.(?:js|ts)|dsh)$/.test(entry)) {
126
+ const absoluteEntry = resolve(entry)
127
+ return {
128
+ file: process.execPath,
129
+ prefixArgs: [...process.execArgv, absoluteEntry],
130
+ cwd: dirname(absoluteEntry),
131
+ useShell: false,
132
+ }
133
+ }
134
+ return { file: 'dsh', prefixArgs: [], cwd: process.cwd(), useShell: process.platform === 'win32' }
135
+ }
136
+
137
+ function stopChild(child: ReturnType<typeof spawn>): void {
138
+ if (process.platform === 'win32' && child.pid !== undefined) {
139
+ const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' })
140
+ killer.once('error', () => child.kill('SIGKILL'))
141
+ return
142
+ }
143
+ if (child.pid !== undefined) {
144
+ // POSIX:dsh 内部会 fork pnpm,只杀 dsh 会让 pnpm 变成孤儿继续攥着管道,
145
+ // close 事件永不到来、队列卡死。detached 让子进程成为进程组组长,
146
+ // 对整组 SIGKILL 才能把 dsh + pnpm(及后代)一起清掉,close 随即触发。
147
+ try {
148
+ process.kill(-child.pid, 'SIGKILL')
149
+ return
150
+ } catch { /* 进程已退出:忽略 */ }
151
+ }
152
+ child.kill('SIGKILL')
153
+ }
154
+
155
+ /**
156
+ * Enqueue a plugin mutation and return its task. Tasks run strictly serially:
157
+ * the queue worker starts the next one only after the previous finishes.
158
+ * Progress is visible through `getTask(id)` / `activeTask()` until done.
159
+ */
160
+ export function startPluginMutation(options: {
161
+ action: 'add' | 'remove'
162
+ profile: string
163
+ target: string
164
+ timeoutMs?: number
165
+ env?: NodeJS.ProcessEnv
166
+ /** 待重启行的展示目标(owner/repo):卸载时用于把 npm 包名映射回仓库名 */
167
+ displayTarget?: string
168
+ /** 运行中 loader:卸载成功后主动移除条目、立即生效(缺失时卸载仍需重启清理) */
169
+ uninstallLoader?: LoaderHandle
170
+ /** 入队前已尝试的安装方式(npm registry 反查等):失败提 Issue 时如实展示;实际执行的命令由 spawn 时追加 */
171
+ attempts?: string[]
172
+ }): InstallTask {
173
+ const task: InstallTask = { id: nextTaskId, target: options.target, displayTarget: options.displayTarget, action: options.action, status: 'pending', timedOut: false, exitCode: null, progress: 0, lines: [], attempts: options.attempts ?? [] }
174
+ nextTaskId = nextTaskId >= Number.MAX_SAFE_INTEGER ? 1 : nextTaskId + 1
175
+ tasks.set(task.id, task)
176
+ if (tasks.size > MAX_TASKS) {
177
+ let removed = false
178
+ tasks.forEach((candidate, id) => {
179
+ if (!removed && candidate.status !== 'running' && candidate.status !== 'pending') {
180
+ tasks.delete(id)
181
+ removed = true
182
+ }
183
+ })
184
+ }
185
+ queue.push({ task, options: { ...options } })
186
+ pumpQueue()
187
+ return task
188
+ }
189
+
190
+ /** Queue worker: at most one mutation runs at a time; pick the next pending task when the slot frees up. */
191
+ function pumpQueue(): void {
192
+ if (hasRunningTask()) return
193
+ const item = queue.find((it) => it.task.status === 'pending')
194
+ if (!item) return
195
+ const { task, options } = item
196
+ task.status = 'running'
197
+ void runPluginMutation({ ...options, task }).finally(() => {
198
+ runningChildren.delete(task.id)
199
+ // 已终态的任务移出队列:既防 queue 数组无限增长(内存泄漏),也让下一轮 pump 干净
200
+ const idx = queue.findIndex((it) => it.task === task)
201
+ if (idx >= 0) queue.splice(idx, 1)
202
+ pumpQueue()
203
+ })
204
+ }
205
+
206
+ /**
207
+ * Run one `dsh plugin --profile <profile> <action> <target>` spawn, stream
208
+ * its output onto the tracked task and resolve with the captured result.
209
+ * Never rejects; failures surface through the result / task state.
210
+ */
211
+ function spawnMutation(options: {
212
+ action: 'add' | 'remove'
213
+ profile: string
214
+ target: string
215
+ timeoutMs?: number
216
+ env?: NodeJS.ProcessEnv
217
+ task?: InstallTask
218
+ /** 待重启行的展示目标(owner/repo):与 mutation 目标解耦——卸载的 mutation 目标是 npm 包名,展示要回退到仓库名 */
219
+ displayTarget?: string
220
+ /** 运行中 loader:卸载成功后主动移除条目、立即生效(缺失时卸载仍需重启清理) */
221
+ uninstallLoader?: LoaderHandle
222
+ }): Promise<InstallResult> {
223
+ const { action, profile, target, timeoutMs = 5 * 60 * 1000, env, task, displayTarget, uninstallLoader } = options
224
+ const invocation = cliInvocation()
225
+ const args = [...invocation.prefixArgs, 'plugin', '--profile', profile, action, target]
226
+ // 记录实际执行的命令(npm 通道显示包名,git 通道显示 github: 源):失败提 Issue 时
227
+ // 「已尝试的安装方式」如实展示完整命令链,作者一眼看清我们跑过什么
228
+ const executed = `dsh plugin --profile ${profile} ${action} ${target}`
229
+ if (task && !task.attempts.includes(executed)) {
230
+ task.attempts.push(executed)
231
+ }
232
+ return new Promise((resolvePromise) => {
233
+ let stdout = ''
234
+ let stderr = ''
235
+ let timedOut = false
236
+ let settled = false
237
+ let child: ReturnType<typeof spawn>
238
+ try {
239
+ child = spawn(invocation.file, args, {
240
+ cwd: invocation.cwd,
241
+ env,
242
+ shell: invocation.useShell,
243
+ // detached 使子进程成为独立进程组组长:取消时能整组清理(见 stopChild),
244
+ // 且宿主进程崩溃后安装任务不会被连带误杀
245
+ detached: true,
246
+ stdio: ['ignore', 'pipe', 'pipe'],
247
+ })
248
+ } catch (error) {
249
+ if (task) {
250
+ task.status = 'failed'
251
+ task.exitCode = null
252
+ pushLine(task, `[error] ${error instanceof Error ? error.message : String(error)}`)
253
+ }
254
+ resolvePromise({
255
+ exitCode: null,
256
+ timedOut: false,
257
+ error: error instanceof Error ? error.message : String(error),
258
+ stdout,
259
+ stderr,
260
+ })
261
+ return
262
+ }
263
+ if (task) runningChildren.set(task.id, child)
264
+
265
+ // 兜底定时器句柄:整段超时 / exit 后 close 宽限 / 强制收尾
266
+ let timer: ReturnType<typeof setTimeout> | undefined
267
+ let progressTimer: ReturnType<typeof setInterval> | undefined
268
+ let exitWatch: ReturnType<typeof setTimeout> | undefined
269
+ let forceSettle: ReturnType<typeof setTimeout> | undefined
270
+
271
+ // 唯一收尾入口:close / error / 兜底超时 三者只放行一次,
272
+ // 保证 runPluginMutation 必然 resolve、队列 worker 必然继续推进
273
+ const settle = (result: InstallResult): void => {
274
+ if (settled) return
275
+ settled = true
276
+ if (timer !== undefined) clearTimeout(timer)
277
+ if (progressTimer !== undefined) clearInterval(progressTimer)
278
+ if (exitWatch !== undefined) clearTimeout(exitWatch)
279
+ if (forceSettle !== undefined) clearTimeout(forceSettle)
280
+ if (task) runningChildren.delete(task.id)
281
+ resolvePromise(result)
282
+ }
283
+
284
+ timer = setTimeout(() => {
285
+ timedOut = true
286
+ stopChild(child)
287
+ }, timeoutMs)
288
+ // 时间兜底:即使 CLI 静默不吐进度行,进度条也单调推进,不会卡在 0
289
+ progressTimer = setInterval(() => {
290
+ if (task && task.status === 'running' && task.progress < 85) task.progress += 1
291
+ }, 500)
292
+ const collect = (kind: 'stdout' | 'stderr', chunk: Buffer): void => {
293
+ const text = chunk.toString()
294
+ if (kind === 'stdout') stdout = (stdout + text).slice(-CAPTURE_LIMIT_BYTES)
295
+ else stderr = (stderr + text).slice(-CAPTURE_LIMIT_BYTES)
296
+ if (task) {
297
+ // 原生透传 pnpm 输出:stdout/stderr 原样入日志,不做格式化,保证报错信息完整可见
298
+ for (const line of text.split(/\r?\n|\r/)) {
299
+ if (line.trim() !== '') pushLine(task, line.trimEnd())
300
+ }
301
+ }
302
+ }
303
+ child.stdout?.on('data', (chunk: Buffer) => collect('stdout', chunk))
304
+ child.stderr?.on('data', (chunk: Buffer) => collect('stderr', chunk))
305
+
306
+ child.once('exit', () => {
307
+ // dsh 内部会 fork pnpm:pnpm 退出后仍攥着 stdout/stderr 管道时,
308
+ // close 事件永不到来(历史队列卡死根因——取消路径曾因此卡死,用整组 SIGKILL 解决;
309
+ // 自然失败路径没有兜底)。exit 后给 close 一段宽限,未按时触发则整组清理强制结束,
310
+ // 保证任务必然进入终态、后续排队任务自动启动。
311
+ exitWatch = setTimeout(() => {
312
+ if (settled) return
313
+ if (task && task.status !== 'cancelled') pushLine(task, '[stale pipe] force closing child')
314
+ stopChild(child)
315
+ // 清理后极端情况下 close 仍未触发(kill 失败):再兜底强制收尾
316
+ forceSettle = setTimeout(() => {
317
+ if (settled) return
318
+ if (task && task.status !== 'cancelled') {
319
+ task.status = 'failed'
320
+ task.exitCode = null
321
+ task.timedOut = true
322
+ pushLine(task, '[timed out]')
323
+ }
324
+ settle({ exitCode: null, timedOut: true, error: 'child failed to close', stdout, stderr })
325
+ }, 5_000)
326
+ }, 3_000)
327
+ })
328
+
329
+ child.once('error', (error) => {
330
+ if (task && task.status !== 'cancelled') {
331
+ task.status = 'failed'
332
+ task.exitCode = null
333
+ pushLine(task, `[error] ${error.message}`)
334
+ }
335
+ settle({ exitCode: null, timedOut: false, error: error.message, stdout, stderr })
336
+ })
337
+
338
+ child.once('close', async (code) => {
339
+ try {
340
+ if (task) {
341
+ task.exitCode = code
342
+ task.timedOut = timedOut
343
+ if (task.status !== 'cancelled') {
344
+ task.status = timedOut || code !== 0 ? 'failed' : 'done'
345
+ if (task.status === 'done') {
346
+ task.progress = 100
347
+ if (action === 'remove' && uninstallLoader) {
348
+ // 卸载成功 → 主动从运行中 loader 移除该包条目,立即生效、无需重启;
349
+ // 移除失败(或从未加载过但 loader 不可用)才登记「待重启清理」兜底
350
+ const removed = await removeLoadedEntry(uninstallLoader, target)
351
+ if (removed) clearPendingRestart(displayTarget ?? target)
352
+ else addPendingRestart(displayTarget ?? target, 'uninstall')
353
+ } else {
354
+ // 安装成功(或卸载但无 loader 引用)→ 登记待重启:
355
+ // 插件要宿主重启才会挂载/卸载干净,重启前一直提醒;展示目标用 displayTarget(owner/repo)
356
+ addPendingRestart(displayTarget ?? target, action === 'add' ? 'install' : 'uninstall')
357
+ }
358
+ }
359
+ pushLine(task, timedOut ? '[timed out]' : `[exit ${code ?? '?'}]`)
360
+ }
361
+ }
362
+ } finally {
363
+ settle({ exitCode: code, timedOut, error: null, stdout, stderr })
364
+ }
365
+ })
366
+ })
367
+ }
368
+
369
+ /**
370
+ * 安装后校验已装包的入口文件(package.json 的 main / exports["."].default)是否真实存在。
371
+ * git: 分发常不提交构建产物(lib/ 等),pnpm 装完没有报错,但宿主重启加载插件树时会
372
+ * ERR_MODULE_NOT_FOUND 直接崩溃、网页打不开 —— 这里在登记「待重启」前就把这类残缺包拦下。
373
+ * 返回 { name, missing }:name 为空表示无法定位目标包(跳过校验);missing 为缺失的入口路径。
374
+ */
375
+ export function verifyInstalledEntry(profile: string, target: string): { name: string | null; missing: string | null } {
376
+ let profilePkg: Record<string, unknown> | null = null
377
+ try {
378
+ profilePkg = JSON.parse(readFileSync(join(profileDirectory(profile), 'package.json'), 'utf8')) as Record<string, unknown>
379
+ } catch {
380
+ return { name: null, missing: null }
381
+ }
382
+ const deps = (profilePkg?.dependencies ?? {}) as Record<string, string>
383
+ // target 自身是依赖名(npm 包),或通过 github: specifier 反查依赖名
384
+ const name = deps[target] !== undefined ? target : Object.keys(deps).find((k) => deps[k] === target)
385
+ if (!name) return { name: null, missing: null }
386
+ const pkgDir = join(profileDirectory(profile), 'node_modules', name)
387
+ let entry: string | null = null
388
+ try {
389
+ const meta = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')) as {
390
+ main?: unknown
391
+ exports?: unknown
392
+ }
393
+ const dot = (meta.exports as Record<string, unknown> | undefined)?.['.']
394
+ const resolved = typeof dot === 'string' ? dot
395
+ : dot !== null && typeof dot === 'object'
396
+ ? (dot as Record<string, unknown>).default
397
+ : undefined
398
+ entry = typeof resolved === 'string' ? resolved : typeof meta.main === 'string' ? meta.main : 'index.js'
399
+ } catch {
400
+ return { name, missing: null }
401
+ }
402
+ try {
403
+ return { name, missing: statSync(join(pkgDir, entry)).isFile() ? null : entry }
404
+ } catch {
405
+ return { name, missing: entry }
406
+ }
407
+ }
408
+
409
+ /** 从 pnpm `Ignored build scripts:` 提示行解析被拦截的构建脚本 key(name@version,逗号分隔)。 */
410
+ function ignoredBuildKeys(output: string): string[] {
411
+ const m = /Ignored build scripts:\s*(.+)/i.exec(output)
412
+ if (m === null) return []
413
+ return m[1]
414
+ .split(',')
415
+ .map((s) => s.trim())
416
+ .filter((s) => s !== '')
417
+ }
418
+
419
+ /**
420
+ * Run a plugin mutation with one recovery path: when `add` fails because
421
+ * pnpm's allowBuilds gate blocks a build script — either the git-hosted
422
+ * package's own `prepare` (`ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`, key from
423
+ * the printed key@url hint) or a dependency's native build
424
+ * (`ERR_PNPM_IGNORED_BUILDS`, key = the listed name@version) — write the
425
+ * exact keys into the profile's pnpm-workspace.yaml and retry once.
426
+ * Other failures fall through to the single-spawn result.
427
+ */
428
+ export async function runPluginMutation(options: {
429
+ action: 'add' | 'remove'
430
+ profile: string
431
+ target: string
432
+ timeoutMs?: number
433
+ env?: NodeJS.ProcessEnv
434
+ task?: InstallTask
435
+ /** 待重启行的展示目标(owner/repo):卸载时用于把 npm 包名映射回仓库名 */
436
+ displayTarget?: string
437
+ /** 运行中 loader:卸载成功后主动移除条目、立即生效(缺失时卸载仍需重启清理) */
438
+ uninstallLoader?: LoaderHandle
439
+ }): Promise<InstallResult> {
440
+ const result = await spawnMutation(options)
441
+ if (options.action === 'add' && result.exitCode !== 0 && !result.timedOut) {
442
+ const output = `${result.stderr}\n${result.stdout}`
443
+ // git 源:pnpm 打印的 key@url 提示
444
+ if (output.includes('ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED')) {
445
+ const key = parseAllowBuildsKey(output)
446
+ if (key !== null) {
447
+ if (options.task) {
448
+ // First attempt already marked the task failed; reset it so the
449
+ // retry streams progress as a running task again.
450
+ options.task.status = 'running'
451
+ options.task.exitCode = null
452
+ pushLine(options.task, `[allow build] ${key}`)
453
+ }
454
+ addAllowBuildsKey(options.profile, key)
455
+ return spawnMutation(options)
456
+ }
457
+ }
458
+ // 依赖原生模块/构建脚本被 pnpm v11 拦截(ERR_PNPM_IGNORED_BUILDS):git 与 npm 源都会出现。
459
+ // allowBuilds 的 key 是输出里列出的 name@version(如 node-pty@1.1.0),逐个放行后重试一次;
460
+ // 输出解析不到时回退用目标名(插件自身构建脚本被拦的 npm 场景)。
461
+ if (output.includes('ERR_PNPM_IGNORED_BUILDS')) {
462
+ const keys = ignoredBuildKeys(output)
463
+ if (keys.length === 0 && !options.target.startsWith('github:')) keys.push(options.target)
464
+ if (keys.length > 0) {
465
+ if (options.task) {
466
+ options.task.status = 'running'
467
+ options.task.exitCode = null
468
+ pushLine(options.task, `[allow build] ${keys.join(', ')}`)
469
+ }
470
+ for (const key of keys) addAllowBuildsKey(options.profile, key)
471
+ return spawnMutation(options)
472
+ }
473
+ }
474
+ }
475
+ // pnpm 安装本身成功,但目标包入口文件缺失(git 分发缺构建产物)→ 判为失败:
476
+ // 撤销刚登记的待重启,避免用户重启时宿主加载残缺包直接崩溃
477
+ if (options.action === 'add' && result.exitCode === 0 && !result.timedOut) {
478
+ const verified = verifyInstalledEntry(options.profile, options.target)
479
+ if (verified.name !== null && verified.missing !== null) {
480
+ clearPendingRestart(options.displayTarget ?? options.target)
481
+ const detail = `[packaging] ${verified.name}: entry file missing: ${verified.missing} (git distribution lacks build output — install the npm version or report to the author)`
482
+ if (options.task && options.task.status !== 'cancelled') {
483
+ options.task.status = 'failed'
484
+ options.task.exitCode = null
485
+ options.task.progress = 0
486
+ pushLine(options.task, detail)
487
+ }
488
+ return { exitCode: 1, timedOut: false, error: `entry file missing: ${verified.missing}`, stdout: result.stdout, stderr: `${result.stderr}\n${detail}` }
489
+ }
490
+ }
491
+ return result
492
+ }