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.
- package/README.en.md +56 -28
- package/README.md +56 -28
- package/client/client.js +2282 -230
- package/client/client.js.map +1 -1
- package/lib/http/routes.js +432 -0
- package/lib/index.js +17 -8
- package/lib/services/install-types.js +11 -0
- package/lib/services/install.js +4 -0
- package/lib/services/installed-versions.js +66 -0
- package/lib/services/loader.js +98 -0
- package/lib/services/npm-resolve.js +67 -0
- package/lib/services/pending-restart.js +34 -0
- package/lib/services/preflight.js +94 -0
- package/lib/services/profile.js +88 -0
- package/lib/services/progress.js +50 -0
- package/lib/services/task-queue.js +457 -0
- package/lib/types/http/routes.d.ts +23 -0
- package/lib/types/index.d.ts +27 -0
- package/lib/types/services/install-types.d.ts +69 -0
- package/lib/types/services/install.d.ts +18 -0
- package/lib/types/services/installed-versions.d.ts +24 -0
- package/lib/types/services/loader.d.ts +43 -0
- package/lib/types/services/npm-resolve.d.ts +6 -0
- package/lib/types/services/pending-restart.d.ts +13 -0
- package/lib/types/services/preflight.d.ts +6 -0
- package/lib/types/services/profile.d.ts +24 -0
- package/lib/types/services/progress.d.ts +17 -0
- package/lib/types/services/task-queue.d.ts +82 -0
- package/package.json +27 -6
- package/src/client/components/CatalogControls.tsx +104 -0
- package/src/client/components/CatalogHeader.tsx +78 -0
- package/src/client/components/CatalogList.tsx +75 -0
- package/src/client/components/CategoryTabs.tsx +35 -0
- package/src/client/components/Dropdown.tsx +80 -0
- package/src/client/components/NotificationsModal.tsx +243 -0
- package/src/client/components/PluginCard.tsx +104 -0
- package/src/client/components/PluginHubSection.tsx +342 -0
- package/src/client/components/ProgressView.tsx +23 -0
- package/src/client/components/icons.tsx +125 -0
- package/src/client/components/modals.tsx +368 -0
- package/src/client/hooks/useCatalog.ts +202 -0
- package/src/client/hooks/useTaskQueue.ts +502 -0
- package/src/client/index.tsx +8 -422
- package/src/client/lib/catalog.ts +240 -0
- package/src/client/lib/env.ts +16 -0
- package/src/client/lib/failures.ts +165 -0
- package/src/client/lib/format.ts +22 -0
- package/src/client/locales.ts +188 -18
- package/src/client/styles/Dropdown.module.css +123 -0
- package/src/client/styles/Header.module.css +432 -0
- package/src/client/styles/List.module.css +324 -0
- package/src/client/styles/Modal.module.css +1006 -0
- package/src/client/styles/tokens.module.css +91 -0
- package/src/client/types.ts +80 -0
- package/src/server/http/routes.ts +432 -0
- package/src/server/index.ts +35 -0
- package/src/server/services/install-types.ts +69 -0
- package/src/server/services/install.ts +18 -0
- package/src/server/services/installed-versions.ts +84 -0
- package/src/server/services/loader.ts +110 -0
- package/src/server/services/npm-resolve.ts +67 -0
- package/src/server/services/pending-restart.ts +36 -0
- package/src/server/services/preflight.ts +97 -0
- package/src/server/services/profile.ts +89 -0
- package/src/server/services/progress.ts +48 -0
- package/src/server/services/task-queue.ts +492 -0
- 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
|
+
}
|