dsh-plugin-rack 0.0.0-stage → 0.1.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.
package/index.js ADDED
@@ -0,0 +1,4081 @@
1
+ /**
2
+ * Host half of the plugin dashboard.
3
+ *
4
+ * It owns every operation and serves one small same-origin JSON API that the
5
+ * Client panel calls. Keeping the operations here (instead of in the Client)
6
+ * means the UI and any future agent tool share a single implementation, and it
7
+ * avoids depending on the Client-side Remote access policy for `pluginManager`.
8
+ *
9
+ * GET /api/dsh-plugin-rack/ alias of /ping, so a bare URL answers
10
+ * GET /api/dsh-plugin-rack/ping liveness, touches no service
11
+ * GET /api/dsh-plugin-rack/state every plugin row + the roster
12
+ * GET /api/dsh-plugin-rack/detail ?entryId= one row + its package.json
13
+ * GET /api/dsh-plugin-rack/updates check every exact-pinned dep against its registry
14
+ * POST /api/dsh-plugin-rack/updates { names: [...] } install those exact versions
15
+ * GET /api/dsh-plugin-rack/extras deps that are not exact versions (link / github / tarball / file)
16
+ * POST /api/dsh-plugin-rack/extras { name, action: 'repull' | 'pin' | 'file-upgrade' }
17
+ * GET /api/dsh-plugin-rack/updatelog the last update attempt, as written to disk
18
+ * GET /api/dsh-plugin-rack/registries the registry the host resolves + whether GITHUB_TOKEN is set
19
+ * GET /api/dsh-plugin-rack/removable bundles the host will let us uninstall
20
+ * GET /api/dsh-plugin-rack/inspect ?spec= preview only, installs nothing
21
+ * GET /api/dsh-plugin-rack/fileplan ?name= what a local `file:` dep would upgrade to
22
+ * POST /api/dsh-plugin-rack/install { spec } through pluginManager.installBundle
23
+ * POST /api/dsh-plugin-rack/uninstall { name } through pluginManager.removeBundle
24
+ * POST /api/dsh-plugin-rack/plugin { entryId, enabled?, auto? }
25
+ * POST /api/dsh-plugin-rack/bulk { enabled, scope?, save?, cancel? }
26
+ * POST /api/dsh-plugin-rack/roster { mode: 'group', scope, auto }
27
+ * GET /api/dsh-plugin-rack/summary read-only rows for scripts and future agent tools
28
+ * GET /api/dsh-plugin-rack/job the install running now, or the last one that finished
29
+ * POST /api/dsh-plugin-rack/job { action: 'cancel' } stop the run that is executing now
30
+ *
31
+ * Bodies must be `application/json` (anything else answers 415), a known path
32
+ * with the wrong verb answers 405, and only one write operation runs at a time
33
+ * (install / uninstall / update / extras / bulk share one mutex), because two
34
+ * pnpm runs editing the same package.json + pnpm-lock.yaml can interleave into
35
+ * a profile that installs nothing.
36
+ *
37
+ * Every request must pass two gates before any of the above runs: the Host
38
+ * header has to name a loopback interface (127.0.0.1 / localhost / ::1), and
39
+ * if an Origin header is present it has to resolve to the same loopback set.
40
+ * Together they stop a malicious web page in the user's browser from driving
41
+ * the API — the web server itself only listens on loopback, so the only
42
+ * attack surface left is a same-machine browser tab.
43
+ *
44
+ * A bulk action always skips this plugin's own row: disabling it would tear
45
+ * down the very route answering the request. Core components are NOT skipped as
46
+ * a group — only 16 of the 204 @deepseek-ai/* rows are genuinely read-only, so
47
+ * `manageable` is the real gate, and the roster plus `scope` decide the rest.
48
+ *
49
+ * Enable/disable normally goes through the shipped `pluginManager` service. The
50
+ * one deliberate exception is `bulk` with `save: true`, which writes the
51
+ * profile's cordis.patch.yml itself — atomically, comments preserved, and only
52
+ * overrides this plugin created are ever removed — so the user can switch
53
+ * plugins off and restart once instead of waiting for a reload per plugin.
54
+ */
55
+ import { appendFileSync, createWriteStream, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
56
+ import { spawn, spawnSync } from 'node:child_process'
57
+ import { createHash } from 'node:crypto'
58
+ import { homedir } from 'node:os'
59
+ import { basename, dirname, join } from 'node:path'
60
+ import { fileURLToPath, pathToFileURL } from 'node:url'
61
+ import { request as httpRequest } from 'node:http'
62
+ import { request as httpsRequest } from 'node:https'
63
+ import { connect as netConnect } from 'node:net'
64
+ import { connect as tlsConnect } from 'node:tls'
65
+ import { createRequire } from 'node:module'
66
+
67
+ const ROUTE_PREFIX = '/api/dsh-plugin-rack'
68
+ const require = createRequire(import.meta.url)
69
+ const VERSION = '0.1.0'
70
+ // The package was called dsh-plugin-dashboard before the public release. Keep
71
+ // the old name recognised so a half-migrated install still treats the panel as
72
+ // itself — otherwise "turn everything off" would switch off the very route
73
+ // answering that request.
74
+ const SELF = 'dsh-plugin-rack'
75
+ const SELF_NAMES = [SELF, 'dsh-plugin-dashboard']
76
+
77
+ /** Known routes and their verbs, so a wrong verb answers 405 instead of 404. */
78
+ const ROUTES = {
79
+ '/': ['GET'],
80
+ '/ping': ['GET'],
81
+ '/state': ['GET'],
82
+ '/detail': ['GET'],
83
+ '/updates': ['GET', 'POST'],
84
+ '/extras': ['GET', 'POST'],
85
+ '/updatelog': ['GET'],
86
+ '/registries': ['GET'],
87
+ '/removable': ['GET'],
88
+ '/inspect': ['GET'],
89
+ '/fileplan': ['GET'],
90
+ '/install': ['POST'],
91
+ '/uninstall': ['POST'],
92
+ '/plugin': ['POST'],
93
+ '/bulk': ['POST'],
94
+ '/roster': ['POST'],
95
+ // 只读摘要,给脚本和未来的 agent tool 用。写操作刻意不暴露在这里——
96
+ // 让一个语言模型替人点「确认卸载」不是个好主意,那种通道要单独的权限设计。
97
+ '/summary': ['GET'],
98
+ // 后台更新任务:面板关掉之后 pnpm 还在跑,重新打开时用它把进度接回来。
99
+ '/job': ['GET', 'POST'],
100
+ }
101
+
102
+ export const name = 'plugin-rack'
103
+ export const inject = ['webServer', 'pluginManager']
104
+
105
+ /** @type {{version: number, seeded: boolean, auto: string[], created: string[]} | null} */
106
+ let rosterState = null
107
+ let storageMode = 'file'
108
+ let profileStateKey = null
109
+
110
+ function stateBase() {
111
+ const configured = typeof process.env.DSH_HOME === 'string' ? process.env.DSH_HOME.trim() : ''
112
+ const home = configured.length > 0 ? configured : join(homedir(), '.dsh')
113
+ return join(home, 'plugin-rack')
114
+ }
115
+
116
+ /**
117
+ * Where this profile keeps its roster, job and queue.
118
+ *
119
+ * One shared file meant the 自动 list of one profile drove the one-click buttons
120
+ * of another — the single most confusing way for a plugin manager to be wrong.
121
+ * The directory is keyed by the profile it belongs to; before apply() learns the
122
+ * profile, the old single directory is still used so nothing reads a half-set
123
+ * path.
124
+ */
125
+ function stateDir() {
126
+ return profileStateKey === null ? stateBase() : join(stateBase(), 'profiles', profileStateKey)
127
+ }
128
+
129
+ /**
130
+ * Key the state by the profile it belongs to, and carry the legacy single
131
+ * directory over once.
132
+ *
133
+ * The first version of this migration only moved `state.json`, which split one
134
+ * directory into two halves: the roster moved while job.json / queue.json /
135
+ * config.json / the update logs stayed behind. A user upgrading across that
136
+ * release lost a queued update (silently — nothing told them it was dropped),
137
+ * their configured download proxy, and the history of past runs. Everything
138
+ * this plugin owns lives in stateBase(), so everything moves together.
139
+ *
140
+ * Copy is per-file and non-destructive: the old files are left in place, so a
141
+ * machine that runs two profiles against an upgraded build still has one copy
142
+ * of the shared history to fall back on.
143
+ */
144
+ const LEGACY_CARRY_OVER = [
145
+ 'state.json',
146
+ 'job.json',
147
+ 'queue.json',
148
+ 'config.json',
149
+ 'last-update.log',
150
+ 'update-log.jsonl',
151
+ ]
152
+
153
+ function adoptProfile(ctx) {
154
+ let dir = null
155
+ try {
156
+ const facts = profileFacts(ctx)
157
+ dir = facts === null ? null : facts.dir
158
+ } catch {
159
+ dir = null
160
+ }
161
+ if (typeof dir !== 'string' || dir === '') return
162
+ const key = basename(dir).replace(/[^A-Za-z0-9._-]+/g, '-')
163
+ if (key === '' || key === '.' || key === '..') return
164
+ profileStateKey = key
165
+ for (const name of LEGACY_CARRY_OVER) {
166
+ try {
167
+ const target = join(stateDir(), name)
168
+ const legacy = join(stateBase(), name)
169
+ // Only ever fill a gap: a file that already exists in the profile's own
170
+ // directory is newer than the shared copy by definition.
171
+ if (existsSync(target)) continue
172
+ if (!existsSync(legacy)) continue
173
+ mkdirSync(stateDir(), { recursive: true })
174
+ writeFileSync(target, readFileSync(legacy))
175
+ } catch {
176
+ // Losing one carry-over file is survivable: the roster reseeds, the job
177
+ // page reports "no run", the queue starts empty and the proxy falls back
178
+ // to the environment. Losing the whole migration is survivable too.
179
+ }
180
+ }
181
+ }
182
+
183
+ function stateFile() {
184
+ return join(stateDir(), 'state.json')
185
+ }
186
+
187
+ /** Where the last update run is recorded, so a closed panel can pick it back up. */
188
+ function jobFile() {
189
+ return join(stateDir(), 'job.json')
190
+ }
191
+
192
+ /**
193
+ * Updates the host already fetched but could not install yet, because an Agent
194
+ * was mid-turn. The verified tarball sits on disk, so applying a queued entry
195
+ * later costs one `pnpm install` — no download, no waiting on GitHub again.
196
+ */
197
+ function queueFile() {
198
+ return join(stateDir(), 'queue.json')
199
+ }
200
+
201
+ function readQueue() {
202
+ try {
203
+ const parsed = JSON.parse(readFileSync(queueFile(), 'utf8'))
204
+ if (Array.isArray(parsed)) {
205
+ return parsed.filter(item => item !== null && typeof item === 'object' && typeof item.kind === 'string')
206
+ }
207
+ } catch (error) {
208
+ void error
209
+ }
210
+ return []
211
+ }
212
+
213
+ function writeQueue(list) {
214
+ try {
215
+ mkdirSync(stateDir(), { recursive: true })
216
+ writeFileSync(queueFile(), JSON.stringify(list, null, 2) + '\n', 'utf8')
217
+ } catch {
218
+ // Losing a queued entry only costs the user another click.
219
+ }
220
+ }
221
+
222
+ function queueKey(item) {
223
+ if (item.kind === 'updates') return 'updates:' + (Array.isArray(item.names) ? item.names.join(',') : '')
224
+ return 'extras:' + String(item.name ?? '')
225
+ }
226
+
227
+ function enqueueItem(item) {
228
+ const list = readQueue().filter(one => queueKey(one) !== queueKey(item))
229
+ list.push({ ...item, queuedAt: new Date().toISOString() })
230
+ writeQueue(list)
231
+ return list
232
+ }
233
+
234
+ function dequeueItem(item) {
235
+ writeQueue(readQueue().filter(one => queueKey(one) !== queueKey(item)))
236
+ }
237
+
238
+ /** The run this host process is executing right now, if any. */
239
+ let activeJob = null
240
+ /** Aborts the network work of the run in `activeJob` when the user cancels it. */
241
+ let activeJobAbort = null
242
+ /** Which step the run in `activeJob` is on: 'start', 'download' or 'install'. */
243
+ let activeJobStage = null
244
+ /** The pnpm child of the run in `activeJob`, so cancel can actually stop it. */
245
+ let activePnpmChild = null
246
+
247
+ function readJob() {
248
+ try {
249
+ const parsed = JSON.parse(readFileSync(jobFile(), 'utf8'))
250
+ if (parsed !== null && typeof parsed === 'object' && typeof parsed.id === 'string') return parsed
251
+ } catch (error) {
252
+ // 没有 job.json 就是还没更新过;坏文件当没有,下次写入会重建成好的。
253
+ void error
254
+ }
255
+ return null
256
+ }
257
+
258
+ function persistJob(job) {
259
+ try {
260
+ mkdirSync(stateDir(), { recursive: true })
261
+ writeFileSync(jobFile(), JSON.stringify(job, null, 2) + '\n', 'utf8')
262
+ } catch {
263
+ // 只读的 home 也能跑完这次更新,只是下次打开面板接不到它。
264
+ }
265
+ }
266
+
267
+ /**
268
+ * 登记一次装包任务。更新可能要跑 25 分钟,浏览器这时候完全可能被关掉,
269
+ * 所以任务的状态必须活在宿主里,而不是活在发起它的那个请求里。
270
+ */
271
+ function beginJob(kind, names) {
272
+ const job = {
273
+ id: Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8),
274
+ kind,
275
+ names: Array.isArray(names) ? names.filter(one => typeof one === 'string') : [],
276
+ startedAt: new Date().toISOString(),
277
+ finishedAt: null,
278
+ ok: null,
279
+ message: null,
280
+ restart: false,
281
+ updated: [],
282
+ failed: [],
283
+ }
284
+ activeJob = job
285
+ activeJobAbort = new AbortController()
286
+ activeJobStage = 'start'
287
+ persistJob(job)
288
+ return job
289
+ }
290
+
291
+ function endJob(job, result) {
292
+ const previousMessage = job.message
293
+ job.finishedAt = new Date().toISOString()
294
+ job.ok = result !== null && typeof result === 'object' && result.ok === true
295
+ job.message = result !== null && typeof result === 'object' && typeof result.message === 'string' ? result.message : null
296
+ // A cancelled run keeps saying so even when the dying pnpm reports its own
297
+ // failure a moment later.
298
+ if (job.cancelled === true && job.ok !== true) job.message = previousMessage
299
+ job.restart = result !== null && typeof result === 'object' && result.restart === true
300
+ job.updated = result !== null && typeof result === 'object' && Array.isArray(result.updated) ? result.updated : []
301
+ job.failed = result !== null && typeof result === 'object' && Array.isArray(result.failed) ? result.failed : []
302
+ if (activeJob !== null && activeJob.id === job.id) {
303
+ activeJob = null
304
+ activeJobAbort = null
305
+ activeJobStage = null
306
+ activePnpmChild = null
307
+ }
308
+ persistJob(job)
309
+ return job
310
+ }
311
+
312
+ /**
313
+ * 取消正在跑的任务。下载阶段的 fetch 会立刻中断;pnpm 已经起来的那种先杀掉进程树,
314
+ * 由失败分支回滚。回滚本身还是一次 pnpm 运行,所以任务在回滚完成前保持 running——
315
+ * 否则界面显示「已取消」,后台却还在改 node_modules,用户既看不见也停不下。
316
+ */
317
+ function cancelJob() {
318
+ const job = activeJob
319
+ if (job === null) return { ok: false, message: 'no update is running' }
320
+ // 回滚本身也是一次 pnpm 运行(install 重同步)。这时候再点取消会把它杀掉,
321
+ // 留下半同步的 node_modules —— 比不回滚更糟。回滚必须跑完。
322
+ if (job.stage === 'rollback') {
323
+ return { ok: true, job: jobStatus() }
324
+ }
325
+ job.cancelled = true
326
+ const killedPnpm = activePnpmChild !== null
327
+ if (activePnpmChild !== null) {
328
+ killTree(activePnpmChild)
329
+ activePnpmChild = null
330
+ }
331
+ // 先 abort 再决定是否 endJob:abort 必须在任何可能把 activeJobAbort 置空的
332
+ // 路径之前发生,否则还在跑的 applyUpdates 读到 null 会以为什么都没发生。
333
+ if (activeJobAbort !== null) activeJobAbort.abort(new Error('cancelled by the user'))
334
+ if (killedPnpm) {
335
+ // endJob runs from the failed install's rollback path; until then the job
336
+ // stays visible, with the cancel button gone and this message on screen.
337
+ job.stage = 'rollback'
338
+ job.message = '已取消:pnpm 进程被终止,正在回滚改动'
339
+ persistJob(job)
340
+ return { ok: true, job: jobStatus() }
341
+ }
342
+ const message = activeJobStage === 'download' ? '已取消:下载已中断,下好的部分留着下次续传' : '已取消'
343
+ endJob(job, { ok: false, message })
344
+ return { ok: true, job: jobStatus() }
345
+ }
346
+
347
+ /** 这个任务是否已被取消。job 对象是取消事实的唯一来源,全局量只是它的镜像。 */
348
+ function jobCancelled(job) {
349
+ return job !== null && job !== undefined && job.cancelled === true
350
+ }
351
+
352
+ /** 取消检查:任务被取消,或它自己的 abort 信号已触发,都算。 */
353
+ function assertNotCancelled(job) {
354
+ if (jobCancelled(job)) throw new Error('cancelled by the user')
355
+ if (activeJobAbort !== null && activeJobAbort.signal.aborted) throw new Error('cancelled by the user')
356
+ }
357
+
358
+ /**
359
+ * 给结果挂上「这次没有维护门」的事实。
360
+ *
361
+ * applyUpdates 有自己的 unguarded 处理;applyExtras / applyFileUpgrade 这些
362
+ * 走另一条路的入口也必须说同一句话,否则同一台机器上「批量更新会告诉你没等空闲、
363
+ * 单装一个却不说」—— 用户没法判断哪次是安全的。
364
+ */
365
+ function markUnguarded(result, unguarded) {
366
+ if (unguarded !== true) return result
367
+ if (result === null || typeof result !== 'object') return result
368
+ return Object.assign({}, result, {
369
+ unguarded: true,
370
+ message:
371
+ typeof result.message === 'string' && result.message.length > 0
372
+ ? result.message + '(宿主没有 agents 服务:这次没等会话空闲就改了 node_modules)'
373
+ : '宿主没有 agents 服务:这次没等会话空闲就改了 node_modules',
374
+ })
375
+ }
376
+
377
+ /** 正在跑的任务,或上一次的结果;finishedAt 为空但已不在跑 = 被宿主重启掐断了。 */
378
+ function jobStatus() {
379
+ const job = activeJob !== null ? activeJob : readJob()
380
+ if (job === null) return null
381
+ const running = activeJob !== null && activeJob.id === job.id
382
+ // 回滚期间不接受取消:那是唯一一段「后台必须跑完」的工作。
383
+ const cancelable = running && job.stage !== 'rollback'
384
+ return Object.assign({}, job, { running, cancelable, interrupted: !running && job.finishedAt === null })
385
+ }
386
+
387
+ function emptyRoster() {
388
+ return { version: 1, seeded: false, auto: [], created: [] }
389
+ }
390
+
391
+ function loadRoster() {
392
+ try {
393
+ const parsed = JSON.parse(readFileSync(stateFile(), 'utf8'))
394
+ if (parsed !== null && typeof parsed === 'object' && Array.isArray(parsed.auto)) {
395
+ return {
396
+ state: {
397
+ version: 1,
398
+ seeded: parsed.seeded === true,
399
+ auto: parsed.auto.filter(entry => typeof entry === 'string'),
400
+ // Overrides this panel wrote into the profile patch. Only those may
401
+ // be removed again; a pre-existing override belongs to the user.
402
+ created: Array.isArray(parsed.created)
403
+ ? parsed.created.filter(entry => typeof entry === 'string')
404
+ : [],
405
+ },
406
+ storage: 'file',
407
+ }
408
+ }
409
+ } catch (error) {
410
+ // ENOENT means "first run"; anything else means unreadable or corrupt.
411
+ // Both fall back to defaults, and the next write repairs the file.
412
+ void error
413
+ }
414
+ return { state: emptyRoster(), storage: 'file' }
415
+ }
416
+
417
+ function roster() {
418
+ if (rosterState === null) {
419
+ const loaded = loadRoster()
420
+ rosterState = loaded.state
421
+ storageMode = loaded.storage
422
+ }
423
+ return rosterState
424
+ }
425
+
426
+ function persistRoster() {
427
+ try {
428
+ const file = stateFile()
429
+ mkdirSync(dirname(file), { recursive: true })
430
+ writeFileSync(file, `${JSON.stringify(rosterState, null, 2)}\n`, 'utf8')
431
+ storageMode = 'file'
432
+ } catch {
433
+ // A read-only home still works for this session; the roster just lives in
434
+ // memory and the client shows that in the footer.
435
+ storageMode = 'memory'
436
+ }
437
+ }
438
+
439
+ /** A readable label for a module name, shortening `file:///...` install specs. */
440
+ /**
441
+ * Human-readable package name for a loader module specifier.
442
+ *
443
+ * A `file://` URL is a path inside some node_modules tree, so the package name
444
+ * is the segment right after the LAST `node_modules/` — everything before that
445
+ * is machine layout the user does not care about, and everything after it is
446
+ * the package's own internal entry file. Taking the last two path segments
447
+ * instead (the old behaviour) turned
448
+ * .../node_modules/cc-safety-net/dist/deepseek-harness/index.js
449
+ * into `deepseek-harness/index.js`. That is not a package name, so the row was
450
+ * listed under a name no user would recognise and did not sort with the rest.
451
+ *
452
+ * Scoped packages keep both segments (`@scope/name`). A path with no
453
+ * node_modules segment at all falls back to the file stem.
454
+ */
455
+ function displayName(moduleName) {
456
+ if (!moduleName.startsWith('file://')) return moduleName
457
+ const parts = moduleName.split('/').filter(Boolean)
458
+ // Walk from the right so a nested node_modules (pnpm links one store into
459
+ // every dependent) resolves to the package nearest the entry file.
460
+ for (let index = parts.length - 2; index >= 0; index -= 1) {
461
+ if (parts[index] !== 'node_modules') continue
462
+ const first = parts[index + 1]
463
+ if (first === undefined) break
464
+ if (first.startsWith('@')) {
465
+ const second = parts[index + 2]
466
+ // `@scope` with nothing after it is a malformed path; fall through.
467
+ if (second !== undefined && second.indexOf('.') !== 0) return `${first}/${second}`
468
+ break
469
+ }
470
+ return first
471
+ }
472
+ const file = parts[parts.length - 1] ?? moduleName
473
+ return file
474
+ }
475
+
476
+ /** The profile directory that owns `node_modules`, derived from a linked row. */
477
+ function profileDir(rows) {
478
+ for (const row of rows) {
479
+ if (!row.moduleName.startsWith('file://')) continue
480
+ let dir = dirname(fileURLToPath(row.moduleName))
481
+ for (let depth = 0; depth < 12; depth += 1) {
482
+ if (basename(dir) === 'node_modules') return dirname(dir)
483
+ const parent = dirname(dir)
484
+ if (parent === dir) break
485
+ dir = parent
486
+ }
487
+ }
488
+ return null
489
+ }
490
+
491
+ function firstString(value) {
492
+ return typeof value === 'string' && value.length > 0 ? value : null
493
+ }
494
+
495
+ /** Longest subtitle we keep; the row already ellipsizes, this keeps the payload small. */
496
+ const BRIEF_MAX = 60
497
+
498
+ /** Short Chinese name for each installed plugin; keep these to a few characters. */
499
+ const ZH_ALIAS = {
500
+ '@deepseek-ai/dsh-tool-subagent': '子智能体',
501
+ 'dsh-whale-widget': '余额挂件',
502
+ 'dsh-pocket': '手机访问',
503
+ 'dsh-m': '插件市场',
504
+ dshmarket: '插件商城',
505
+ '@tencent-connect/dsh-qqbot': 'QQ 机器人',
506
+ '@linxin666/dsh-client-ui-git-graph': '提交图谱',
507
+ '@linxin666/dsh-client-ui-task-board': '任务看板',
508
+ 'billion-context': '上下文压缩',
509
+ 'billion-context/dsh-compaction-gate': '压缩门控',
510
+ 'ds-bot/web-search': '机器人联网',
511
+ '@vectorize-io/hindsight-coding-agents/dsh': '记忆编码智能体',
512
+ 'dsh-blender-mcp-bridge': 'Blender 桥接',
513
+ 'dsh-blender-plugin': 'Blender 直连',
514
+ 'dsh-univer-office': '办公套件',
515
+ '@changfenhuang/dsh-genui': '内联 UI',
516
+ 'dsh-ops': 'Bash / Rust 工具',
517
+ '@liustack/modlens': '视觉读图',
518
+ '@michengai/dsh-archive-manager': '会话归档',
519
+ '@michengai/dsh-skills-manager': '技能管理',
520
+ '@nanmicoder/dsh-agent-teams': '多智能体',
521
+ 'dsh-our-free-model': '免费模型',
522
+ 'dsh-plugin-rack': '插件管理',
523
+ 'deepseek-harness/index.js': '命令安全网',
524
+ 'clearai-dsh': '智能体预设',
525
+ 'ds-bot': '机器人团队',
526
+ 'dsh-better-sidebar': '右侧边栏',
527
+ 'dsh-context': '上下文工具',
528
+ 'dsh-cost-meter': '费用计量',
529
+ 'dsh-ego-browser': 'Agent 浏览器',
530
+ 'dsh-free-search': '免费搜索',
531
+ 'dsh-pause-resume': '暂停 / 恢复',
532
+ 'dsh-unlag': '热重载',
533
+ 'lunheng-article-pipeline': '长文流水线',
534
+ }
535
+
536
+ /**
537
+ * Short Chinese label for the harness' own components. The core plugins have
538
+ * no manifest description, so without this the row would fall back to the raw
539
+ * entry id. Matching is by longest prefix so a family like
540
+ * `dsh-client-ui-settings-*` gets one label per family member.
541
+ */
542
+ const CORE_ALIAS = [
543
+ ['dsh-client-ui-settings-account', '账号设置'],
544
+ ['dsh-client-ui-settings-agent-loop', '智能体循环设置'],
545
+ ['dsh-client-ui-settings-general', '通用设置'],
546
+ ['dsh-client-ui-settings-models', '模型设置'],
547
+ ['dsh-client-ui-settings-plugin-inventory', '插件清单'],
548
+ ['dsh-client-ui-settings-plugins', '插件设置'],
549
+ ['dsh-client-ui-settings-session-log', '会话日志设置'],
550
+ ['dsh-client-ui-settings-shell', '外壳设置'],
551
+ ['dsh-client-ui-settings-subagent', '子智能体设置'],
552
+ ['dsh-client-ui-settings-web-search', '联网搜索设置'],
553
+ ['dsh-client-ui-agent-preset', '预设编辑器'],
554
+ ['dsh-client-ui-approval', '审批界面'],
555
+ ['dsh-client-ui-attachment', '附件'],
556
+ ['dsh-client-ui-brand-official', '官方品牌'],
557
+ ['dsh-client-ui-chat', '对话界面'],
558
+ ['dsh-client-ui-commands', '命令面板'],
559
+ ['dsh-client-ui-context-menu', '右键菜单'],
560
+ ['dsh-client-ui-conversation', '会话列表'],
561
+ ['dsh-client-ui-cordis', '插件内核'],
562
+ ['dsh-client-ui-deliverables', '交付物'],
563
+ ['dsh-client-ui-directory-picker-native', '目录选择'],
564
+ ['dsh-client-ui-entry-customization', '入口定制'],
565
+ ['dsh-client-ui-file-actions', '文件操作'],
566
+ ['dsh-client-ui-file-drag', '拖拽文件'],
567
+ ['dsh-client-ui-goal', '目标界面'],
568
+ ['dsh-client-ui-input-trigger', '输入触发'],
569
+ ['dsh-client-ui-jobs', '任务界面'],
570
+ ['dsh-client-ui-layout', '布局'],
571
+ ['dsh-client-ui-message-feedback', '消息反馈'],
572
+ ['dsh-client-ui-model-selection', '模型选择'],
573
+ ['dsh-client-ui-open-in-app', '应用内打开'],
574
+ ['dsh-client-ui-permission-presets', '权限预设'],
575
+ ['dsh-client-ui-plan', '计划界面'],
576
+ ['dsh-client-ui-plugin-manager', '插件管理'],
577
+ ['dsh-client-ui-reference', '引用'],
578
+ ['dsh-client-ui-renderer', '渲染器'],
579
+ ['dsh-client-ui-schedule', '定时任务界面'],
580
+ ['dsh-client-ui-secret-mask', '密钥遮罩'],
581
+ ['dsh-client-ui-session', '会话界面'],
582
+ ['dsh-client-ui-shortcuts', '快捷键'],
583
+ ['dsh-client-ui-sidebar-browser', '浏览器侧栏'],
584
+ ['dsh-client-ui-sidebar-documentpreview', '文档预览'],
585
+ ['dsh-client-ui-sidebar-files', '文件侧栏'],
586
+ ['dsh-client-ui-sidebar-right', '右侧栏'],
587
+ ['dsh-client-ui-sidebar-terminal', '终端侧栏'],
588
+ ['dsh-client-ui-skill', '技能界面'],
589
+ ['dsh-client-ui-subagent', '子智能体界面'],
590
+ ['dsh-client-ui-theme', '主题'],
591
+ ['dsh-client-ui-tool', '工具界面'],
592
+ ['dsh-client-ui-trajectory', '执行轨迹'],
593
+ ['dsh-client-ui-user-questions', '用户提问'],
594
+ ['dsh-client-ui-workflow-run', '工作流运行'],
595
+ ['dsh-client-ui-workspace', '工作区界面'],
596
+ ['dsh-client-ui-sidebar', '左侧栏'],
597
+ ['dsh-client-connection', '客户端连接'],
598
+ ['dsh-client-file-upload', '文件上传'],
599
+ ['dsh-client-hmr', '前端热更新'],
600
+ ['dsh-client-locale', '多语言'],
601
+ ['dsh-client-modules', '前端模块'],
602
+ ['dsh-client-product-analytics', '使用统计'],
603
+ ['dsh-client-resources', '前端资源'],
604
+ ['dsh-client-shortcuts', '快捷键'],
605
+ ['dsh-api-account-controller', '账号接口'],
606
+ ['dsh-api-gateway', 'API 网关'],
607
+ ['dsh-api-job-controller', '任务接口'],
608
+ ['dsh-api-remote-access', '远程访问接口'],
609
+ ['dsh-api-remotes', '远程连接'],
610
+ ['dsh-api-session-controller', '会话接口'],
611
+ ['dsh-api-settings-controller', '设置接口'],
612
+ ['dsh-api-terminal-controller', '终端接口'],
613
+ ['dsh-api-workspace-controller', '工作区接口'],
614
+ ['dsh-api-workspace-files', '工作区文件接口'],
615
+ ['dsh-agent-default-model', '默认模型'],
616
+ ['dsh-agent-instructions', '智能体指令'],
617
+ ['dsh-agent-loop', '智能体循环'],
618
+ ['dsh-agent-preset-registry', '预设注册表'],
619
+ ['dsh-agent-preset', '智能体预设'],
620
+ ['dsh-agent', '智能体'],
621
+ ['dsh-attachment-local', '本地附件'],
622
+ ['dsh-authorization', '授权'],
623
+ ['dsh-bash-sandbox', 'Bash 沙箱'],
624
+ ['dsh-compaction-basic', '基础压缩'],
625
+ ['dsh-compaction-image-offload', '图片外置'],
626
+ ['dsh-compaction-tool-result-pruner', '工具结果裁剪'],
627
+ ['dsh-command-compact', '压缩命令'],
628
+ ['dsh-command-feedback', '反馈命令'],
629
+ ['dsh-command-goal', '目标命令'],
630
+ ['dsh-commands', '命令集'],
631
+ ['dsh-config-editor', '配置编辑器'],
632
+ ['dsh-cordis-client-runner', '前端插件运行器'],
633
+ ['dsh-cordis-host-runner', '宿主插件运行器'],
634
+ ['dsh-credentials-local', '本地凭据'],
635
+ ['dsh-deepseek-account-platform', 'DeepSeek 账号平台'],
636
+ ['dsh-deepseek-llm-api-extensions', 'DeepSeek 接口扩展'],
637
+ ['dsh-deliverable-file-references', '交付文件引用'],
638
+ ['dsh-file-reference-local', '本地文件引用'],
639
+ ['dsh-fs-observation-policy', '文件观察策略'],
640
+ ['dsh-fs-sandbox', '文件沙箱'],
641
+ ['dsh-goal-round-driver', '目标轮次驱动'],
642
+ ['dsh-goal', '目标'],
643
+ ['dsh-harness-dev-context', '开发上下文'],
644
+ ['dsh-hmr', '热重载'],
645
+ ['dsh-host-directory-picker-auto', '自动目录选择'],
646
+ ['dsh-host-directory-picker-native', '原生目录选择'],
647
+ ['dsh-host-open-in-app', '应用内打开'],
648
+ ['dsh-host-plugin-inventory', '插件清单'],
649
+ ['dsh-host-product-telemetry-otel', '遥测'],
650
+ ['dsh-host-webserver', '内嵌服务器'],
651
+ ['dsh-jobs-local', '本地任务'],
652
+ ['dsh-llm-deepseek-account', 'DeepSeek 账号模型'],
653
+ ['dsh-llm-deepseek-api-key', 'DeepSeek 密钥模型'],
654
+ ['dsh-llm', '模型接入'],
655
+ ['dsh-otel', '链路追踪'],
656
+ ['dsh-permission-presets', '权限预设'],
657
+ ['dsh-plan-mode', '计划模式'],
658
+ ['dsh-plugin-manager', '插件管理'],
659
+ ['dsh-plugin-package-inventory-deepseek', '插件包清单'],
660
+ ['dsh-ptc-runtime-node', 'PTC 运行时'],
661
+ ['dsh-pwsh-sandbox', 'PowerShell 沙箱'],
662
+ ['dsh-repeat-tool-reminder', '重复工具提醒'],
663
+ ['dsh-sandbox-local', '本地沙箱'],
664
+ ['dsh-sandbox-policy', '沙箱策略'],
665
+ ['dsh-schedule', '定时任务'],
666
+ ['dsh-session-checkpoint-policy', '会话检查点'],
667
+ ['dsh-session-log-deepseek', '会话日志'],
668
+ ['dsh-session-log-export', '会话日志导出'],
669
+ ['dsh-session-persistence-jsonl', '会话持久化'],
670
+ ['dsh-session-projection-cache', '会话投影缓存'],
671
+ ['dsh-session-projection', '会话投影'],
672
+ ['dsh-session-query-sqlite', '会话检索'],
673
+ ['dsh-session-reference', '会话引用'],
674
+ ['dsh-session-stats', '会话统计'],
675
+ ['dsh-session-telemetry-otel', '会话遥测'],
676
+ ['dsh-session-title-first-prompt-llm', '会话标题'],
677
+ ['dsh-session-title', '会话标题'],
678
+ ['dsh-session-turn-outline', '回合大纲'],
679
+ ['dsh-session', '会话'],
680
+ ['dsh-settings', '设置'],
681
+ ['dsh-shell-env', '环境变量'],
682
+ ['dsh-skill-filesystem', '文件系统技能'],
683
+ ['dsh-skill', '技能'],
684
+ ['dsh-spill-local', '本地溢出存储'],
685
+ ['dsh-spill-policy', '溢出策略'],
686
+ ['dsh-storage-domain', '存储域'],
687
+ ['dsh-storage-json', 'JSON 存储'],
688
+ ['dsh-storage', '存储'],
689
+ ['dsh-subagent-fork-in-process', '子智能体内联分叉'],
690
+ ['dsh-subagent-spawn-in-process', '子智能体内联启动'],
691
+ ['dsh-subagent', '子智能体'],
692
+ ['dsh-subprocess-local', '本地子进程'],
693
+ ['dsh-system-prompt', '系统提示词'],
694
+ ['dsh-token-meter', 'Token 计量'],
695
+ ['dsh-tool-bash', 'Bash 工具'],
696
+ ['dsh-tool-call-timeout-policy', '工具超时策略'],
697
+ ['dsh-tool-cordis', '插件工具'],
698
+ ['dsh-tool-executor-native', '原生执行器'],
699
+ ['dsh-tool-executor-ptc', 'PTC 执行器'],
700
+ ['dsh-tool-fs-search', '文件搜索工具'],
701
+ ['dsh-tool-fs', '文件工具'],
702
+ ['dsh-tool-goal', '目标工具'],
703
+ ['dsh-tool-jobs', '任务工具'],
704
+ ['dsh-tool-pwsh', 'PowerShell 工具'],
705
+ ['dsh-tool-skill', '技能工具'],
706
+ ['dsh-tool-subagent-control', '子智能体控制'],
707
+ ['dsh-tool-subagent', '子智能体工具'],
708
+ ['dsh-tool-todo', '待办工具'],
709
+ ['dsh-tool-web', '联网工具'],
710
+ ['dsh-tool-workflow', '工作流工具'],
711
+ ['dsh-tool-working-directory', '工作目录工具'],
712
+ ['dsh-tools', '工具集'],
713
+ ['dsh-typert-loader', '类型加载'],
714
+ ['dsh-typert-registry', '类型注册表'],
715
+ ['dsh-user-approval', '用户审批'],
716
+ ['dsh-user-questions', '用户提问'],
717
+ ['dsh-web-app', '网页应用'],
718
+ ['dsh-web-deepseek-api', 'DeepSeek 网页接口'],
719
+ ['dsh-web-fetch-http', '网页抓取'],
720
+ ['dsh-web-search-deepseek', '联网搜索'],
721
+ ['dsh-web', '网页端'],
722
+ ['dsh-workflow-ptc', '工作流引擎'],
723
+ ['dsh-working-directory', '工作目录'],
724
+ ['dsh-workspace-changes', '工作区改动'],
725
+ ['dsh-workspace', '工作区'],
726
+ ['cordis-plugin-timer', '定时器'],
727
+ ['dsh-mcp-resources', 'MCP 资源'],
728
+ ['dsh-message-feedback', '消息反馈'],
729
+ ['dsh-message-quotes', '消息引用'],
730
+ ['dsh-office-to-pdf', 'Office 转 PDF'],
731
+ ['dsh-client-ui-settings', '设置界面'],
732
+ ]
733
+
734
+ /**
735
+ * Match a module name against the alias tables. Exact hits win; otherwise the
736
+ * longest matching prefix is used, so `dsh-client-ui-settings-plugins`
737
+ * resolves through the prefix rules rather than a generic fallback.
738
+ */
739
+ function aliasFor(moduleName) {
740
+ const direct = ZH_ALIAS[moduleName]
741
+ if (direct !== undefined) return direct
742
+ const bare = moduleName.replace(/^@deepseek-ai\//, '')
743
+ for (const [prefix, label] of CORE_ALIAS) {
744
+ if (bare === prefix) return label
745
+ }
746
+ for (const [prefix, label] of CORE_ALIAS) {
747
+ if (bare.startsWith(prefix + '-') || bare.startsWith(prefix + '/')) return label
748
+ }
749
+ return null
750
+ }
751
+
752
+ /** Collapse whitespace, keep the first clause, then cap the length. */
753
+ function brief(value) {
754
+ const text = value.replace(/\s+/g, ' ').trim()
755
+ if (text.length === 0) return null
756
+ const cut = text.split(/[。;;::—|]/)[0].trim()
757
+ const head = cut.length >= 10 ? cut : text
758
+ return head.length <= BRIEF_MAX ? head : head.slice(0, BRIEF_MAX - 1) + '…'
759
+ }
760
+
761
+ /** A plugin's own Chinese description, read from its locale directory. */
762
+ function zhDescription(moduleName, found) {
763
+ return cached(`zh:${moduleName}`, () => {
764
+ let text = null
765
+ if (found !== null) {
766
+ for (const file of ['zh.json', 'zh-CN.json', 'zh_CN.json']) {
767
+ const candidate = join(found.dir, 'locale', file)
768
+ if (!existsSync(candidate)) continue
769
+ try {
770
+ const parsed = JSON.parse(readFileSync(candidate, 'utf8'))
771
+ const meta = parsed === null || typeof parsed !== 'object' ? null : parsed.meta
772
+ const value = meta === null || typeof meta !== 'object' ? null : firstString(meta.description)
773
+ if (value !== null) {
774
+ text = value
775
+ break
776
+ }
777
+ } catch {
778
+ /* a broken locale file is not worth failing the row for */
779
+ }
780
+ }
781
+ }
782
+ return text
783
+ })
784
+ }
785
+
786
+ /** Row subtitle: hand-written Chinese, else the plugin's Chinese locale, else its own text. */
787
+ function describe(moduleName, name, found) {
788
+ const alias = aliasFor(moduleName) ?? aliasFor(name)
789
+ if (alias !== undefined && alias !== null) return alias
790
+ const zh = zhDescription(moduleName, found)
791
+ if (zh !== null) return brief(zh)
792
+ if (found === null) return null
793
+ const raw = firstString(found.manifest.description)
794
+ return raw === null ? null : brief(raw)
795
+ }
796
+
797
+ /** Locate the package.json that owns a module, walking up a few levels. */
798
+ function readManifest(moduleName, base) {
799
+ if (!moduleName.startsWith('file://')) {
800
+ if (base === null) return null
801
+ // A bare spec must stay inside node_modules; anything else is not a
802
+ // package this profile could have installed.
803
+ if (
804
+ moduleName.length === 0 ||
805
+ moduleName.includes('..') ||
806
+ moduleName.startsWith('/') ||
807
+ moduleName.startsWith('\\') ||
808
+ moduleName.includes('\\')
809
+ ) {
810
+ return null
811
+ }
812
+ const dir = join(base, 'node_modules', moduleName)
813
+ const candidate = join(dir, 'package.json')
814
+ if (!existsSync(candidate)) return null
815
+ try {
816
+ const parsed = JSON.parse(readFileSync(candidate, 'utf8'))
817
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
818
+ return { dir, manifest: parsed }
819
+ }
820
+ } catch {
821
+ return null
822
+ }
823
+ return null
824
+ }
825
+
826
+ let dir
827
+ try {
828
+ dir = dirname(fileURLToPath(moduleName))
829
+ } catch {
830
+ return null
831
+ }
832
+
833
+ for (let depth = 0; depth < 4; depth += 1) {
834
+ const candidate = join(dir, 'package.json')
835
+ if (existsSync(candidate)) {
836
+ try {
837
+ const parsed = JSON.parse(readFileSync(candidate, 'utf8'))
838
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
839
+ return { dir, manifest: parsed }
840
+ }
841
+ } catch {
842
+ return null
843
+ }
844
+ }
845
+ if (basename(dir) === 'node_modules') break
846
+ const parent = dirname(dir)
847
+ if (parent === dir) break
848
+ dir = parent
849
+ }
850
+ return null
851
+ }
852
+
853
+ /**
854
+ * Small TTL cache: manifests and locale files must not be cached forever, or an
855
+ * upgraded plugin keeps reporting its old version and description until the
856
+ * host restarts.
857
+ */
858
+ const CACHE_TTL = 30_000
859
+ const cache = new Map()
860
+
861
+ function cached(key, read) {
862
+ const now = Date.now()
863
+ const hit = cache.get(key)
864
+ if (hit !== undefined && now - hit.at < CACHE_TTL) return hit.value
865
+ const value = read()
866
+ cache.set(key, { value, at: now })
867
+ return value
868
+ }
869
+
870
+ /** Manifests are read once per module name per TTL; `/state` touches every row. */
871
+ function manifestFor(moduleName, base) {
872
+ return cached(`m:${moduleName}`, () => {
873
+ try {
874
+ return readManifest(moduleName, base)
875
+ } catch {
876
+ return null
877
+ }
878
+ })
879
+ }
880
+
881
+ function linksOf(manifest) {
882
+ const repository = manifest.repository
883
+ const raw =
884
+ typeof repository === 'string'
885
+ ? repository
886
+ : repository !== null && typeof repository === 'object'
887
+ ? firstString(repository.url)
888
+ : null
889
+ const homepage = firstString(manifest.homepage)
890
+
891
+ let github = null
892
+ for (const candidate of [raw, homepage]) {
893
+ if (candidate === null) continue
894
+ const cleaned = candidate
895
+ .replace(/^git\+/, '')
896
+ .replace(/^git:\/\//, 'https://')
897
+ .replace(/\.git$/, '')
898
+ const match = /github\.com[/:]([^/]+)\/([^/#]+)/.exec(cleaned)
899
+ if (match !== null) {
900
+ github = `https://github.com/${match[1]}/${match[2]}`
901
+ break
902
+ }
903
+ }
904
+ return { github, homepage: github === null ? homepage : null }
905
+ }
906
+
907
+ /** Everything the panel's expanded row shows, beyond what `/state` carries. */
908
+ async function detailFor(ctx, entryId) {
909
+ const rows = await readRows(ctx)
910
+ const row = rows.find(item => item.entryId === entryId)
911
+ if (row === undefined) throw new Error(`unknown entry id: ${entryId}`)
912
+
913
+ const found = manifestFor(row.moduleName, profileDir(rows))
914
+ const manifest = found === null ? null : found.manifest
915
+ const name = manifest === null ? null : firstString(manifest.name)
916
+ const links = manifest === null ? { github: null, homepage: null } : linksOf(manifest)
917
+ const rawAuthor = manifest === null ? null : manifest.author
918
+ const dependencies =
919
+ manifest === null || manifest.dependencies === null || typeof manifest.dependencies !== 'object'
920
+ ? []
921
+ : Object.keys(manifest.dependencies)
922
+
923
+ return {
924
+ row,
925
+ package: {
926
+ name,
927
+ version: manifest === null ? null : firstString(manifest.version),
928
+ description: manifest === null ? null : firstString(manifest.description),
929
+ license: manifest === null ? null : firstString(manifest.license),
930
+ author:
931
+ firstString(rawAuthor) ??
932
+ (rawAuthor !== null && typeof rawAuthor === 'object' ? firstString(rawAuthor.name) : null),
933
+ dir: found === null ? null : found.dir,
934
+ github: links.github,
935
+ homepage: links.homepage,
936
+ npmUrl: name === null ? null : `https://www.npmjs.com/package/${name}`,
937
+ dependencies,
938
+ },
939
+ }
940
+ }
941
+
942
+ /** True for this plugin's own row, whether it is mounted bare or by file spec. */
943
+ function isSelfRow(moduleName, patchId) {
944
+ if (typeof patchId === 'string' && SELF_NAMES.some(name => patchId === name || patchId === 'plugin-rack' || patchId === 'plugin-dashboard')) {
945
+ return true
946
+ }
947
+ for (const name of SELF_NAMES) {
948
+ if (moduleName === name) return true
949
+ if (moduleName.includes(`/${name}/`) || moduleName.endsWith(`/${name}`)) return true
950
+ }
951
+ return false
952
+ }
953
+
954
+ function toRow(entry, base) {
955
+ const moduleName = typeof entry.moduleName === 'string' ? entry.moduleName : String(entry.moduleName ?? '')
956
+ const patchId = typeof entry.patchId === 'string' ? entry.patchId : null
957
+ const found = manifestFor(moduleName, base)
958
+ const name = displayName(moduleName)
959
+ return {
960
+ entryId: String(entry.entryId),
961
+ patchId,
962
+ moduleName,
963
+ name: displayName(moduleName),
964
+ version: found === null ? null : firstString(found.manifest.version),
965
+ description: describe(moduleName, name, found),
966
+ enabled: entry.enabled === true,
967
+ phase: entry.fiberPhase === undefined ? null : entry.fiberPhase,
968
+ readOnlyReason: typeof entry.readOnlyReason === 'string' ? entry.readOnlyReason : null,
969
+ diagnostic: typeof entry.diagnostic === 'string' ? entry.diagnostic : null,
970
+ official: moduleName.startsWith('@deepseek-ai/'),
971
+ manageable: patchId !== null,
972
+ self: isSelfRow(moduleName, patchId),
973
+ }
974
+ }
975
+
976
+ /**
977
+ * First-run default: the plugins this profile installed, not the shipped core.
978
+ * Core rows stay 手动 so a one-click action can never strip the harness down.
979
+ */
980
+ function installedRoster(rows) {
981
+ return rows
982
+ .filter(row => row.manageable && !row.official && !row.self)
983
+ .map(row => row.patchId)
984
+ }
985
+
986
+ async function readRows(ctx) {
987
+ const entries = await ctx.pluginManager.listPlugins()
988
+ if (!Array.isArray(entries)) throw new Error('pluginManager.listPlugins() did not return a list')
989
+ const named = entries.map(entry => ({
990
+ moduleName: typeof entry.moduleName === 'string' ? entry.moduleName : '',
991
+ }))
992
+ const base = profileDir(named)
993
+ return entries.map(entry => toRow(entry, base))
994
+ }
995
+
996
+ async function snapshot(ctx) {
997
+ const rows = await readRows(ctx)
998
+ const state = roster()
999
+
1000
+ if (!state.seeded) {
1001
+ state.auto = installedRoster(rows)
1002
+ state.seeded = true
1003
+ persistRoster()
1004
+ }
1005
+
1006
+ // Drop roster entries whose row disappeared, so the count always matches.
1007
+ const known = new Set(rows.filter(row => row.manageable).map(row => row.patchId))
1008
+ const pruned = state.auto.filter(id => known.has(id))
1009
+ if (pruned.length !== state.auto.length) {
1010
+ state.auto = pruned
1011
+ persistRoster()
1012
+ }
1013
+
1014
+ return {
1015
+ ok: true,
1016
+ version: VERSION,
1017
+ rows,
1018
+ roster: state.auto.slice(),
1019
+ storage: storageMode,
1020
+ statePath: stateFile(),
1021
+ bulk: bulkSnapshot(),
1022
+ }
1023
+ }
1024
+
1025
+ function describeResult(result) {
1026
+ if (result === null || typeof result !== 'object') return 'no result'
1027
+ const parts = []
1028
+ if (result.error !== undefined && result.error !== null) {
1029
+ const message = result.error.message ?? result.error.code
1030
+ if (message !== undefined) parts.push(String(message))
1031
+ }
1032
+ if (Array.isArray(result.warnings) && result.warnings.length > 0) parts.push(result.warnings.join('; '))
1033
+ if (result.restartReason !== undefined) parts.push(`restart required (${String(result.restartReason)})`)
1034
+ if (parts.length === 0) parts.push(String(result.application ?? 'unknown'))
1035
+ return parts.join(' | ')
1036
+ }
1037
+
1038
+ async function setOne(ctx, entryId, enabled, known) {
1039
+ const row = known ?? (await readRows(ctx)).find(candidate => candidate.entryId === entryId)
1040
+ if (row === undefined) return { entryId, ok: false, message: 'plugin row not found' }
1041
+ // Disabling the panel from inside its own request kills the route that is
1042
+ // still answering it, so the request would hang until the browser gives up.
1043
+ if (row.self) {
1044
+ return { entryId, ok: false, message: 'the dashboard refuses to switch itself off from here' }
1045
+ }
1046
+ if (!row.manageable) {
1047
+ return { entryId, ok: false, message: `row is read-only (${row.readOnlyReason ?? 'unaddressable'})` }
1048
+ }
1049
+
1050
+ let result
1051
+ try {
1052
+ result = await ctx.pluginManager.setPluginEnabled(row.entryId, enabled)
1053
+ } catch (error) {
1054
+ const message = error instanceof Error ? error.message : String(error)
1055
+ return { entryId, ok: false, message }
1056
+ }
1057
+
1058
+ const application = result === null || typeof result !== 'object' ? undefined : result.application
1059
+ if (application === 'failed' || application === 'cancelled') {
1060
+ return { entryId, ok: false, application, message: describeResult(result) }
1061
+ }
1062
+ return {
1063
+ entryId,
1064
+ ok: true,
1065
+ application,
1066
+ // `overridden` and `restart-required` are saved-but-not-live outcomes.
1067
+ notice:
1068
+ application === 'applied' ? undefined : describeResult(result),
1069
+ }
1070
+ }
1071
+
1072
+ async function actPlugin(ctx, body) {
1073
+ const entryId = typeof body.entryId === 'string' ? body.entryId : ''
1074
+ if (entryId.length === 0) throw new Error('entryId is required')
1075
+
1076
+ const rows = await readRows(ctx)
1077
+ const row = rows.find(candidate => candidate.entryId === entryId)
1078
+ if (row === undefined) return { ok: false, result: { entryId, ok: false, message: 'plugin row not found' } }
1079
+
1080
+ let result
1081
+ if (typeof body.enabled === 'boolean') {
1082
+ result = await setOne(ctx, entryId, body.enabled, row)
1083
+ }
1084
+
1085
+ if (typeof body.auto === 'boolean') {
1086
+ const state = roster()
1087
+ if (row.patchId === null) {
1088
+ result = { entryId, ok: false, message: 'row cannot join the 自动 roster (read-only)' }
1089
+ } else if (body.auto) {
1090
+ if (!state.auto.includes(row.patchId)) state.auto.push(row.patchId)
1091
+ persistRoster()
1092
+ result = result ?? { entryId, ok: true, application: 'applied' }
1093
+ } else {
1094
+ state.auto = state.auto.filter(id => id !== row.patchId)
1095
+ persistRoster()
1096
+ result = result ?? { entryId, ok: true, application: 'applied' }
1097
+ }
1098
+ }
1099
+
1100
+ const settled = result ?? { entryId, ok: true }
1101
+ // `applied` means the change is live, so the touched row can be patched in
1102
+ // place. Anything else (read-only, failed, restart-required) needs the full
1103
+ // state so the panel never shows a switch that does not match reality.
1104
+ const applied =
1105
+ settled.ok === true && (settled.application === undefined || settled.application === 'applied')
1106
+ if (!applied) {
1107
+ return { ...(await snapshot(ctx)), result: settled }
1108
+ }
1109
+
1110
+ const patched = { ...row }
1111
+ if (typeof body.enabled === 'boolean') {
1112
+ patched.enabled = body.enabled
1113
+ patched.phase = body.enabled ? 'active' : null
1114
+ }
1115
+ return { ok: true, patch: true, row: patched, roster: roster().auto.slice(), result: settled }
1116
+ }
1117
+
1118
+ /** The profile's patch document plus the live entries it addresses. */
1119
+ async function patchContext(ctx) {
1120
+ const profile = ctx.get ? ctx.get('profileContext') : undefined
1121
+ if (profile === undefined || typeof profile.patchPath !== 'string') {
1122
+ throw new Error('profileContext is unavailable; cannot write the profile patch')
1123
+ }
1124
+ const resolved = require.resolve('yaml', { paths: [join(profile.dir, 'node_modules')] })
1125
+ const yaml = await import(pathToFileURL(resolved).href)
1126
+ return { profile, yaml }
1127
+ }
1128
+
1129
+ /**
1130
+ * Write `disabled` for several rows in one pass and never reload anything.
1131
+ *
1132
+ * `pluginManager.setPluginEnabled` reconciles the whole profile on every call,
1133
+ * which is what makes a bulk toggle take seconds per row. For the "turn things
1134
+ * off, update them, restart" workflow the reload is wasted work: the caller
1135
+ * restarts anyway, so persisting the intent is enough.
1136
+ *
1137
+ * The current state is read from the patch document, never from the runtime
1138
+ * rows: a save-mode run does not change what is running, so comparing against
1139
+ * `row.enabled` would treat an already-saved toggle as "nothing to do" and drop
1140
+ * the entry it should restore.
1141
+ */
1142
+ async function writeEnabledBatch(ctx, rows, enabled) {
1143
+ const { profile, yaml } = await patchContext(ctx)
1144
+ const { parseDocument } = yaml
1145
+ const filename = profile.patchPath
1146
+
1147
+ let text
1148
+ try {
1149
+ text = readFileSync(filename, 'utf8')
1150
+ } catch (error) {
1151
+ if (error.code !== 'ENOENT') throw error
1152
+ text = '[]\n'
1153
+ }
1154
+
1155
+ const document = parseDocument(text, {
1156
+ customTags: [{ tag: 'tag:yaml.org,2002:js', resolve: value => value }],
1157
+ })
1158
+ const broken = document.errors[0]
1159
+ if (broken !== undefined) throw new Error(`the profile patch is not valid YAML: ${broken.message}`)
1160
+ if (!Array.isArray(document.contents?.items)) {
1161
+ throw new Error('the profile patch is not a YAML sequence')
1162
+ }
1163
+
1164
+ const items = document.contents.items
1165
+ const want = enabled !== true
1166
+ const changed = []
1167
+ let unchanged = 0
1168
+ // Overrides this panel created. Only those are removed again on enable, so a
1169
+ // pre-existing `disabled: false` in the user's profile is never touched.
1170
+ const created = new Set(roster().created)
1171
+
1172
+ /** The index of the last id-matching override, the way the shipped writer finds it. */
1173
+ const locate = (id, name) => {
1174
+ for (let cursor = items.length - 1; cursor >= 0; cursor -= 1) {
1175
+ const item = items[cursor]
1176
+ if (item === null || typeof item.get !== 'function') continue
1177
+ if (item.get('insert') !== undefined) continue
1178
+ if (item.get('id') !== id) continue
1179
+ const declared = item.get('name')
1180
+ if (declared !== undefined && declared !== null && declared !== name) continue
1181
+ return cursor
1182
+ }
1183
+ return -1
1184
+ }
1185
+
1186
+ for (const row of rows) {
1187
+ const index = locate(row.patchId, row.moduleName)
1188
+ if (index === -1) {
1189
+ // No override yet, so the composition's value is whatever is running.
1190
+ // Only append when that differs from what was asked for.
1191
+ if (row.enabled === enabled) {
1192
+ unchanged += 1
1193
+ } else {
1194
+ document.add({ id: row.patchId, disabled: want })
1195
+ created.add(row.patchId)
1196
+ changed.push(row.patchId)
1197
+ }
1198
+ continue
1199
+ }
1200
+ // An override decides on its own; `undefined` means it inherits, so it must
1201
+ // be written even when the inherited value happens to match.
1202
+ const current = document.getIn([index, 'disabled'])
1203
+ if (current !== undefined && current === want) {
1204
+ unchanged += 1
1205
+ continue
1206
+ }
1207
+ if (enabled === true && current === true) {
1208
+ if (created.has(row.patchId)) {
1209
+ // The override exists only because this panel disabled the plugin, so
1210
+ // re-enabling makes it redundant. Drop the entry again so the user's
1211
+ // profile does not collect `disabled: false` litter.
1212
+ document.delete(index)
1213
+ created.delete(row.patchId)
1214
+ } else {
1215
+ // A pre-existing override belongs to the user: keep the entry and
1216
+ // write the value back explicitly, so the file ends up exactly as it
1217
+ // started instead of losing the key.
1218
+ document.setIn([index, 'disabled'], false)
1219
+ }
1220
+ changed.push(row.patchId)
1221
+ continue
1222
+ }
1223
+ document.setIn([index, 'disabled'], want)
1224
+ changed.push(row.patchId)
1225
+ }
1226
+
1227
+ if (changed.length === 0) return { changed, unchanged, patchPath: filename }
1228
+
1229
+ const temp = `${filename}.tmp-${process.pid}`
1230
+ writeFileSync(temp, String(document), { mode: 0o600 })
1231
+ renameSync(temp, filename)
1232
+ // Remember which overrides this panel owns, so a later enable can remove
1233
+ // exactly those and nothing else.
1234
+ const state = roster()
1235
+ state.created = [...created]
1236
+ persistRoster()
1237
+ return { changed, unchanged, patchPath: filename }
1238
+ }
1239
+ function inScope(row, scope) {
1240
+ if (!row.manageable) return false
1241
+ if (scope === 'failed') return row.phase === 'failed'
1242
+ // The panel's pages leave failed rows out of 已安装插件 and 核心组件, so the
1243
+ // server side must exclude them too or a bulk action hits more rows than the
1244
+ // button's own count promises.
1245
+ if (scope === 'installed') return !row.official && row.phase !== 'failed'
1246
+ if (scope === 'core') return row.official && row.phase !== 'failed'
1247
+ return false
1248
+ }
1249
+
1250
+ /**
1251
+ * A running bulk job. `setPluginEnabled` costs a file lock, an HMR exclusive
1252
+ * lock, two full listPlugins() calls and one reconcile each, so N rows always
1253
+ * costs N of those no matter how the calls are ordered. The panel therefore
1254
+ * accepts a job, answers at once, and reports progress while it runs.
1255
+ */
1256
+ let bulkJob = null
1257
+
1258
+ /**
1259
+ * One profile-mutating operation at a time.
1260
+ *
1261
+ * The host gives plugins no lock of their own, and two installs / uninstalls /
1262
+ * updates racing on the same package.json + pnpm-lock.yaml can interleave their
1263
+ * writes and leave a profile nobody can install into. A bulk run already
1264
+ * serializes itself through `bulkJob`; this flag extends the same rule to
1265
+ * install / uninstall / update / extras, and to a bulk run starting while one
1266
+ * of them is still in flight.
1267
+ */
1268
+ let mutationBusy = false
1269
+
1270
+ const BUSY_MESSAGE = '已经有一个安装 / 卸载 / 更新 / 批量操作在进行中,等它结束再试'
1271
+
1272
+ function beginMutation() {
1273
+ if (mutationBusy) return false
1274
+ if (bulkJob !== null && bulkJob.running) return false
1275
+ mutationBusy = true
1276
+ return true
1277
+ }
1278
+
1279
+ function endMutation() {
1280
+ mutationBusy = false
1281
+ }
1282
+
1283
+ function bulkSnapshot() {
1284
+ if (bulkJob === null) return null
1285
+ return {
1286
+ running: bulkJob.running,
1287
+ enabled: bulkJob.enabled,
1288
+ scope: bulkJob.scope,
1289
+ finished: bulkJob.finished,
1290
+ total: bulkJob.total,
1291
+ failed: bulkJob.failed,
1292
+ unchanged: bulkJob.unchanged,
1293
+ guarded: bulkJob.guarded,
1294
+ current: bulkJob.current,
1295
+ cancelled: bulkJob.cancelled === true,
1296
+ }
1297
+ }
1298
+
1299
+ /** Ask the running bulk job to stop after the row it is on. */
1300
+ function cancelBulk() {
1301
+ if (bulkJob === null || !bulkJob.running) return { ok: false, message: 'no bulk run is in progress' }
1302
+ bulkJob.cancelled = true
1303
+ return { ok: true, cancelled: true, bulk: bulkSnapshot() }
1304
+ }
1305
+
1306
+ async function actBulk(ctx, enabled, scope, mode) {
1307
+ if (bulkJob !== null && bulkJob.running) {
1308
+ return { ok: false, message: 'a bulk run is already in progress' }
1309
+ }
1310
+ if (mutationBusy) {
1311
+ return { ok: false, message: '一个安装或卸载还在进行中,等它结束再操作' }
1312
+ }
1313
+ // Claim the mutex before the first await. Checking here and locking later
1314
+ // let two bulk requests both pass the check, and the second one's finally
1315
+ // then released the first one's lock.
1316
+ if (!beginMutation()) return { ok: false, message: BUSY_MESSAGE }
1317
+
1318
+ let rows
1319
+ try {
1320
+ rows = await readRows(ctx)
1321
+ } catch (error) {
1322
+ endMutation()
1323
+ throw error
1324
+ }
1325
+ const state = roster()
1326
+ const auto = new Set(state.auto)
1327
+ const scoped = typeof scope === 'string' && scope.length > 0
1328
+
1329
+ // A row only follows 一键 when it is on the 自动 roster. The scope narrows
1330
+ // that set to the visible page; it never widens it. Marking a row 手动 has to
1331
+ // mean the bulk buttons leave it alone.
1332
+ const wanted = rows.filter(
1333
+ row =>
1334
+ row.manageable &&
1335
+ row.patchId !== null &&
1336
+ auto.has(row.patchId) &&
1337
+ (!scoped || inScope(row, scope)),
1338
+ )
1339
+
1340
+ // Hard guard, independent of the roster. The panel's own row is the one case
1341
+ // that must never be touched: disabling it inside this very request kills the
1342
+ // route that is still answering it.
1343
+ const guards = []
1344
+ const targets = []
1345
+ let unchanged = 0
1346
+ for (const row of wanted) {
1347
+ if (row.self) {
1348
+ guards.push({ entryId: row.entryId, name: row.name, reason: 'self' })
1349
+ } else if (mode !== 'save' && row.enabled === enabled) {
1350
+ // Already in the target state: a full unload/load cycle for nothing.
1351
+ // `save` must NOT take this shortcut: it compares against the FILE, and
1352
+ // a row that was save-disabled earlier still runs enabled, so the
1353
+ // runtime state would mask a pending `disabled: true` override forever.
1354
+ unchanged += 1
1355
+ } else {
1356
+ targets.push(row)
1357
+ }
1358
+ }
1359
+
1360
+ // `save` writes the patch and reloads nothing. It is the right mode for the
1361
+ // "stop everything, update it, restart" workflow, which restarts anyway.
1362
+ if (mode === 'save') {
1363
+ try {
1364
+ const written = await writeEnabledBatch(ctx, targets, enabled)
1365
+ return {
1366
+ ok: true,
1367
+ saved: true,
1368
+ total: written.changed.length,
1369
+ unchanged: unchanged + written.unchanged,
1370
+ guarded: guards.length,
1371
+ patchPath: written.patchPath,
1372
+ bulk: null,
1373
+ }
1374
+ } finally {
1375
+ endMutation()
1376
+ }
1377
+ }
1378
+
1379
+ bulkJob = {
1380
+ running: true,
1381
+ enabled,
1382
+ scope: scoped ? scope : 'auto',
1383
+ finished: 0,
1384
+ total: targets.length,
1385
+ failed: 0,
1386
+ unchanged,
1387
+ guarded: guards.length,
1388
+ current: null,
1389
+ cancelled: false,
1390
+ }
1391
+
1392
+ // Detached on purpose: the HTTP response returns immediately and the client
1393
+ // polls /state, which carries `bulk`. It keeps a local reference because a
1394
+ // later run replaces `bulkJob` while this one may still be finishing.
1395
+ const job = bulkJob
1396
+ // The mutex was claimed before the first await; the detached run owns it
1397
+ // until its finally hands it back.
1398
+ void (async () => {
1399
+ for (const row of targets) {
1400
+ if (job.cancelled) break
1401
+ job.current = row.name
1402
+ // Pass the row we already read: setOne would otherwise call readRows()
1403
+ // again, and that is a full pluginManager.listPlugins() over ~200 loader
1404
+ // entries on every single row of the batch. The row cannot have changed
1405
+ // under us either — the mutation mutex is held for the whole run.
1406
+ const outcome = await setOne(ctx, row.entryId, enabled, row)
1407
+ if (outcome.ok !== true) job.failed += 1
1408
+ job.finished += 1
1409
+ }
1410
+ })()
1411
+ .catch(() => {
1412
+ job.failed += 1
1413
+ })
1414
+ .finally(() => {
1415
+ job.current = null
1416
+ job.running = false
1417
+ endMutation()
1418
+ })
1419
+
1420
+ return {
1421
+ ok: true,
1422
+ accepted: true,
1423
+ total: targets.length,
1424
+ unchanged,
1425
+ guarded: guards.length,
1426
+ bulk: bulkSnapshot(),
1427
+ }
1428
+ }
1429
+
1430
+ async function actRoster(ctx, mode, scope, auto) {
1431
+ const rows = await readRows(ctx)
1432
+ const state = roster()
1433
+
1434
+ if (mode !== 'group') throw new Error(`unknown roster mode: ${mode}`)
1435
+
1436
+ const wanted = new Set(
1437
+ rows
1438
+ .filter(row => inScope(row, scope))
1439
+ .map(row => row.patchId),
1440
+ )
1441
+ if (auto === true) {
1442
+ for (const id of wanted) if (!state.auto.includes(id)) state.auto.push(id)
1443
+ } else {
1444
+ state.auto = state.auto.filter(id => !wanted.has(id))
1445
+ }
1446
+ state.seeded = true
1447
+ persistRoster()
1448
+ return await snapshot(ctx)
1449
+ }
1450
+
1451
+ function send(res, status, payload) {
1452
+ // 面板可能在一次 25 分钟的装包中途被关掉。结果写不回那个已经断掉的连接不算
1453
+ // 失败——任务本身记在 job.json 里,下次打开面板由 /job 接回去。
1454
+ if (res.writableEnded || res.destroyed) return
1455
+ res.once('error', () => {})
1456
+ const body = JSON.stringify(payload)
1457
+ try {
1458
+ res.statusCode = status
1459
+ res.setHeader('content-type', 'application/json; charset=utf-8')
1460
+ res.setHeader('cache-control', 'no-store')
1461
+ res.end(body)
1462
+ } catch (error) {
1463
+ // 连接已经没了:同上,任务照样跑完了。
1464
+ void error
1465
+ }
1466
+ }
1467
+
1468
+ const MAX_BODY = 1 << 20
1469
+
1470
+ /** An input problem the caller caused; answers its status instead of a 500. */
1471
+ class HttpError extends Error {
1472
+ constructor(status, message) {
1473
+ super(message)
1474
+ this.status = status
1475
+ }
1476
+ }
1477
+
1478
+ async function readJsonBody(req) {
1479
+ const chunks = []
1480
+ let size = 0
1481
+ for await (const chunk of req) {
1482
+ size += chunk.length
1483
+ if (size > MAX_BODY) throw new HttpError(413, 'request body is too large')
1484
+ chunks.push(chunk)
1485
+ }
1486
+ const text = Buffer.concat(chunks).toString('utf8').trim()
1487
+ if (text.length === 0) return {}
1488
+ let parsed
1489
+ try {
1490
+ parsed = JSON.parse(text)
1491
+ } catch {
1492
+ throw new HttpError(400, 'request body is not valid JSON')
1493
+ }
1494
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
1495
+ throw new HttpError(400, 'request body must be a JSON object')
1496
+ }
1497
+ return parsed
1498
+ }
1499
+
1500
+ /* ------------------------------ update checking ------------------------------ */
1501
+
1502
+ /** Packages the runtime itself provides; those are never fetched from npm. */
1503
+ const HOST_SCOPE = /^@deepseek-ai\//
1504
+ /** A version pinned exactly — the profile's own convention for dsh bundles. */
1505
+ const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/
1506
+ const REGISTRY_TIMEOUT = 12000
1507
+ const UPDATE_CONCURRENCY = 6
1508
+
1509
+ /** The profile's package.json and dependencies, or null when unreadable. */
1510
+ function profileFacts(ctx) {
1511
+ try {
1512
+ const profile = ctx.get === undefined ? undefined : ctx.get('profileContext')
1513
+ if (profile === undefined || profile === null || typeof profile.dir !== 'string') return null
1514
+ const file = join(profile.dir, 'package.json')
1515
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
1516
+ if (parsed === null || typeof parsed !== 'object') return null
1517
+ const deps = parsed.dependencies
1518
+ if (deps === null || typeof deps !== 'object') return null
1519
+ return { dir: profile.dir, file, deps, parsed }
1520
+ } catch {
1521
+ return null
1522
+ }
1523
+ }
1524
+
1525
+ function versionParts(value) {
1526
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/.exec(String(value).trim())
1527
+ if (match === null) return null
1528
+ return {
1529
+ numbers: [Number(match[1]), Number(match[2]), Number(match[3])],
1530
+ pre: match[4] === undefined ? null : match[4].split('.'),
1531
+ }
1532
+ }
1533
+
1534
+ /** Standard semver ordering; an unparsable side compares as equal. */
1535
+ function compareVersions(left, right) {
1536
+ const a = versionParts(left)
1537
+ const b = versionParts(right)
1538
+ if (a === null || b === null) return 0
1539
+ for (let index = 0; index < 3; index += 1) {
1540
+ if (a.numbers[index] !== b.numbers[index]) return a.numbers[index] < b.numbers[index] ? -1 : 1
1541
+ }
1542
+ if (a.pre === null && b.pre === null) return 0
1543
+ if (a.pre === null) return 1
1544
+ if (b.pre === null) return -1
1545
+ const length = Math.max(a.pre.length, b.pre.length)
1546
+ for (let index = 0; index < length; index += 1) {
1547
+ const x = a.pre[index]
1548
+ const y = b.pre[index]
1549
+ if (x === undefined) return -1
1550
+ if (y === undefined) return 1
1551
+ const xNumeric = /^\d+$/.test(x)
1552
+ const yNumeric = /^\d+$/.test(y)
1553
+ if (xNumeric && yNumeric) {
1554
+ if (Number(x) !== Number(y)) return Number(x) < Number(y) ? -1 : 1
1555
+ continue
1556
+ }
1557
+ if (xNumeric !== yNumeric) return xNumeric ? -1 : 1
1558
+ if (x !== y) return x < y ? -1 : 1
1559
+ }
1560
+ return 0
1561
+ }
1562
+
1563
+ /**
1564
+ * The registry answer for a package's `latest` dist-tag. The profile's own
1565
+ * registry is asked first — a user behind a mirror should not be forced through
1566
+ * npmjs.org — and npmjs.org is the fallback, because a mirror that lags or
1567
+ * lacks a package must not read as "no update".
1568
+ */
1569
+ async function npmLatest(name, base, signal) {
1570
+ const encoded = name.startsWith('@') ? name.replace('/', '%2f') : name
1571
+ const configured = typeof base === 'string' && base.trim().length > 0 ? base.trim() : null
1572
+ const roots = []
1573
+ if (configured !== null) roots.push(configured.replace(/\/+$/, '') + '/')
1574
+ if (roots[0] !== 'https://registry.npmjs.org/') roots.push('https://registry.npmjs.org/')
1575
+ const errors = []
1576
+ for (const root of roots) {
1577
+ if (signal !== undefined && signal !== null && signal.aborted) throw new Error('cancelled by the user')
1578
+ try {
1579
+ // The query is user-cancellable: cancelling used to do nothing while a
1580
+ // slow mirror was being asked, so the panel said "cancelled" and kept
1581
+ // waiting.
1582
+ const timeout = AbortSignal.timeout(REGISTRY_TIMEOUT)
1583
+ const response = await fetchAny(`${root}${encoded}/latest`, {
1584
+ signal: signal === undefined || signal === null ? timeout : AbortSignal.any([timeout, signal]),
1585
+ headers: { accept: 'application/json' },
1586
+ })
1587
+ if (!response.ok) throw new Error(`registry answered ${response.status}`)
1588
+ const body = await response.json()
1589
+ if (body === null || typeof body !== 'object' || typeof body.version !== 'string') {
1590
+ throw new Error('registry returned no version')
1591
+ }
1592
+ return body.version
1593
+ } catch (error) {
1594
+ errors.push(`${root} ${error instanceof Error ? error.message : String(error)}`)
1595
+ }
1596
+ }
1597
+ throw new Error(errors.join('; '))
1598
+ }
1599
+
1600
+ /** Dependencies this panel is willing to touch: not host-provided, pinned exactly. */
1601
+ function eligibleDeps(facts) {
1602
+ const out = []
1603
+ for (const [name, spec] of Object.entries(facts.deps)) {
1604
+ if (HOST_SCOPE.test(name)) continue
1605
+ if (typeof spec !== 'string') continue
1606
+ const pinned = spec.trim()
1607
+ if (!EXACT_VERSION.test(pinned)) continue
1608
+ out.push({ name, current: pinned })
1609
+ }
1610
+ return out
1611
+ }
1612
+
1613
+ async function checkUpdates(ctx) {
1614
+ const facts = profileFacts(ctx)
1615
+ if (facts === null) return { ok: false, message: 'profile package.json is unavailable' }
1616
+ const base = await configuredRegistry(ctx)
1617
+ const targets = eligibleDeps(facts)
1618
+ const rows = new Array(targets.length)
1619
+ let cursor = 0
1620
+ async function worker() {
1621
+ for (;;) {
1622
+ const index = cursor
1623
+ cursor += 1
1624
+ if (index >= targets.length) return
1625
+ const target = targets[index]
1626
+ try {
1627
+ const latest = await npmLatest(target.name, base, activeJobAbort === null ? undefined : activeJobAbort.signal)
1628
+ rows[index] = {
1629
+ name: target.name,
1630
+ current: target.current,
1631
+ latest,
1632
+ hasUpdate: compareVersions(latest, target.current) > 0,
1633
+ error: null,
1634
+ }
1635
+ } catch (error) {
1636
+ rows[index] = {
1637
+ name: target.name,
1638
+ current: target.current,
1639
+ latest: null,
1640
+ hasUpdate: false,
1641
+ error: error instanceof Error ? error.message : String(error),
1642
+ }
1643
+ }
1644
+ }
1645
+ }
1646
+ const workers = []
1647
+ for (let index = 0; index < Math.min(UPDATE_CONCURRENCY, targets.length); index += 1) workers.push(worker())
1648
+ await Promise.all(workers)
1649
+ const list = rows.filter(row => row !== undefined).sort((a, b) => a.name.localeCompare(b.name))
1650
+ return {
1651
+ ok: true,
1652
+ dir: facts.dir,
1653
+ registry: base,
1654
+ checked: list.length,
1655
+ updates: list.filter(row => row.hasUpdate).length,
1656
+ rows: list,
1657
+ }
1658
+ }
1659
+
1660
+ /**
1661
+ * Hold every live Agent at the host's public maintenance boundary.
1662
+ *
1663
+ * Replacing files under a running Agent mixes old in-memory modules with new
1664
+ * ones on disk, which is exactly how a marketplace update corrupts a profile.
1665
+ * `runMaintenance` claims a truly idle Agent synchronously and latches later
1666
+ * inbox work until the returned promise settles. A synchronous throw means an
1667
+ * Agent is mid-turn right now; the caller reports that instead of racing it.
1668
+ */
1669
+ /**
1670
+ * Hold every running agent against starting new work, so a pnpm run can rewrite
1671
+ * node_modules without a session loading code from it half-way.
1672
+ *
1673
+ * The returned function release()s every hold and carries a `gated` flag.
1674
+ *
1675
+ * When the host exposes no `agents` service there is nothing to hold, and the
1676
+ * caller proceeds **without** the idle guarantee. That used to be indistinguishable
1677
+ * from "we waited and every agent was idle": both produced a silently successful
1678
+ * run, so a host where the service is missing (an older release, a headless
1679
+ * profile, a renamed service) would rewrite node_modules under a live session
1680
+ * and report success. The flag lets the caller tell the user which one happened.
1681
+ */
1682
+ async function holdAgents(ctx) {
1683
+ const service = ctx.get === undefined ? undefined : ctx.get('agents')
1684
+ const available = service !== undefined && service !== null && typeof service.list === 'function'
1685
+ const listed = available ? service.list() : []
1686
+ const handles = []
1687
+ for (const agent of Array.isArray(listed) ? listed : []) {
1688
+ if (agent === null || typeof agent !== 'object') continue
1689
+ if (typeof agent.id !== 'string' || agent.id === '') continue
1690
+ if (typeof agent.whenIdle !== 'function' || typeof agent.runMaintenance !== 'function') continue
1691
+ handles.push(agent)
1692
+ }
1693
+ // No service at all: there is no gate to take, but the caller must know that
1694
+ // it is proceeding unguarded rather than after a successful wait.
1695
+ if (!available) {
1696
+ const releaseUngated = () => {}
1697
+ releaseUngated.gated = false
1698
+ releaseUngated.agents = 0
1699
+ return releaseUngated
1700
+ }
1701
+ const held = []
1702
+ const releaseAll = () => {
1703
+ while (held.length > 0) {
1704
+ const entry = held.pop()
1705
+ entry.controller.abort()
1706
+ entry.release()
1707
+ }
1708
+ }
1709
+ releaseAll.gated = true
1710
+ releaseAll.agents = handles.length
1711
+ try {
1712
+ for (const handle of handles) {
1713
+ let release = () => {}
1714
+ const gate = new Promise(resolve => {
1715
+ release = resolve
1716
+ })
1717
+ const controller = new AbortController()
1718
+ let running
1719
+ try {
1720
+ running = handle.runMaintenance(signal => {
1721
+ if (signal.aborted) {
1722
+ release()
1723
+ return gate
1724
+ }
1725
+ signal.addEventListener('abort', () => release(), { once: true })
1726
+ return gate
1727
+ })
1728
+ } catch (error) {
1729
+ release()
1730
+ throw error
1731
+ }
1732
+ if (running !== undefined && running !== null && typeof running.catch === 'function') {
1733
+ void running.catch(() => release())
1734
+ }
1735
+ held.push({ controller, release })
1736
+ }
1737
+ } catch (error) {
1738
+ releaseAll()
1739
+ throw error
1740
+ }
1741
+ return releaseAll
1742
+ }
1743
+
1744
+ /** The pnpm entry point installed next to the host, or null. */
1745
+ function pathEntries() {
1746
+ const raw = typeof process.env.PATH === 'string' ? process.env.PATH : ''
1747
+ const separator = process.platform === 'win32' ? ';' : ':'
1748
+ return raw
1749
+ .split(separator)
1750
+ .map(entry => entry.trim().replace(/^"|"$/g, ''))
1751
+ .filter(entry => entry !== '')
1752
+ }
1753
+
1754
+ /** Find `name`, with the platform's usual suffixes, on PATH. */
1755
+ function resolveOnPath(name) {
1756
+ const suffixes = process.platform === 'win32' ? ['', '.cmd', '.exe'] : ['']
1757
+ for (const dir of pathEntries()) {
1758
+ for (const suffix of suffixes) {
1759
+ const candidate = join(dir, name + suffix)
1760
+ if (existsSync(candidate)) return candidate
1761
+ }
1762
+ }
1763
+ return null
1764
+ }
1765
+
1766
+ /**
1767
+ * The pnpm entry script, or null when pnpm is not installed.
1768
+ *
1769
+ * A global install is a launcher: on Windows it sits under %APPDATA%\npm, on
1770
+ * macOS and Linux it is a symlink in a bin directory pointing at
1771
+ * `<prefix>/lib/node_modules/pnpm/bin/pnpm.cjs`. Resolving that symlink gives
1772
+ * a script `node` can run directly; the plugin never depends on a shell shim,
1773
+ * because a shim is what breaks first under a bare environment.
1774
+ */
1775
+ function pnpmScript() {
1776
+ const candidates = []
1777
+ if (process.platform === 'win32') {
1778
+ const appData = typeof process.env.APPDATA === 'string' ? process.env.APPDATA.trim() : ''
1779
+ if (appData !== '') candidates.push(join(appData, 'npm', 'node_modules', 'pnpm', 'bin', 'pnpm.mjs'))
1780
+ const localData = typeof process.env.LOCALAPPDATA === 'string' ? process.env.LOCALAPPDATA.trim() : ''
1781
+ if (localData !== '') candidates.push(join(localData, 'pnpm', 'node_modules', 'pnpm', 'bin', 'pnpm.mjs'))
1782
+ } else {
1783
+ const home = homedir()
1784
+ const pnpmHome = typeof process.env.PNPM_HOME === 'string' ? process.env.PNPM_HOME.trim() : ''
1785
+ if (pnpmHome !== '') candidates.push(join(pnpmHome, 'node_modules', 'pnpm', 'bin', 'pnpm.mjs'))
1786
+ candidates.push(
1787
+ join(home, '.local', 'share', 'pnpm', 'node_modules', 'pnpm', 'bin', 'pnpm.mjs'),
1788
+ join(home, 'Library', 'pnpm', 'node_modules', 'pnpm', 'bin', 'pnpm.mjs'),
1789
+ '/usr/local/lib/node_modules/pnpm/bin/pnpm.mjs',
1790
+ '/opt/homebrew/lib/node_modules/pnpm/bin/pnpm.mjs',
1791
+ '/usr/lib/node_modules/pnpm/bin/pnpm.mjs',
1792
+ )
1793
+ }
1794
+ for (const candidate of candidates) {
1795
+ if (existsSync(candidate)) return candidate
1796
+ }
1797
+ const shim = resolveOnPath('pnpm')
1798
+ if (shim !== null) {
1799
+ try {
1800
+ const real = realpathSync(shim)
1801
+ // A real script: node can run it directly.
1802
+ if (/\.(c|m)?js$/i.test(real) && existsSync(real)) return real
1803
+ // The standalone installer and a corepack shim both land on PATH as a
1804
+ // wrapper (pnpm.exe, pnpm.cmd) that node cannot execute. They still work
1805
+ // through their own launcher, so keep them instead of reporting "no pnpm".
1806
+ if (process.platform === 'win32' && /\.(exe|cmd|bat|ps1)$/i.test(real) && existsSync(real)) return real
1807
+ } catch {
1808
+ // Not a symlink (or not ours to resolve): fall through.
1809
+ }
1810
+ }
1811
+ return null
1812
+ }
1813
+
1814
+ /**
1815
+ * Pick the interpreter that runs pnpm.
1816
+ *
1817
+ * On Windows the host's own `process.execPath` is an Electron GUI binary. A GUI
1818
+ * process owns no console at all, so `windowsHide` has nothing to suppress: the
1819
+ * direct child gets no console, and every console descendant pnpm starts (git,
1820
+ * tar, build scripts) finds no console to inherit, so Windows allocates a fresh
1821
+ * one and a terminal window pops up on the user's desktop.
1822
+ *
1823
+ * Spawning a real console node instead gives the direct child a *hidden*
1824
+ * console, which all of its descendants inherit. Nothing is ever shown, and a
1825
+ * real node is also the interpreter pnpm actually expects.
1826
+ */
1827
+ let nodeRuntimeCache = null
1828
+
1829
+ /**
1830
+ * How to start pnpm, given whatever pnpmScript() resolved to.
1831
+ *
1832
+ * Exported for test/pnpm-command.mjs: the quoting rules here are the part that
1833
+ * cannot be checked by reading the code, and a wrong quote is a silent failure
1834
+ * that reads as "pnpm is broken".
1835
+ */
1836
+ export function pnpmInvocation(script, args, execPath) {
1837
+ const batch = /\.(cmd|bat)$/i.test(script)
1838
+ const powershell = /\.ps1$/i.test(script)
1839
+ const exe = /\.exe$/i.test(script)
1840
+ if (batch) {
1841
+ // cmd strips one outer pair of quotes under /s, so the whole command gets
1842
+ // wrapped once and every part is quoted again inside.
1843
+ const inner = [script, ...args].map(one => '"' + one + '"').join(' ')
1844
+ return { command: process.env.ComSpec || 'cmd.exe', commandArgs: ['/d', '/s', '/c', '"' + inner + '"'], electron: false }
1845
+ }
1846
+ if (powershell) {
1847
+ return {
1848
+ command: 'powershell.exe',
1849
+ commandArgs: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script, ...args],
1850
+ electron: false,
1851
+ }
1852
+ }
1853
+ if (exe) return { command: script, commandArgs: args, electron: false }
1854
+ return { command: execPath, commandArgs: [script, ...args], electron: execPath === process.execPath }
1855
+ }
1856
+
1857
+
1858
+ function nodeRuntime() {
1859
+ if (nodeRuntimeCache !== null) return nodeRuntimeCache
1860
+ const execPath = typeof process.execPath === 'string' ? process.execPath : ''
1861
+ nodeRuntimeCache = execPath
1862
+ const found = resolveOnPath('node')
1863
+ if (found !== null) {
1864
+ try {
1865
+ const real = realpathSync(found)
1866
+ if (real !== '' && existsSync(real)) {
1867
+ nodeRuntimeCache = real
1868
+ if (execPath === '' || real.toLowerCase() !== execPath.toLowerCase()) return nodeRuntimeCache
1869
+ }
1870
+ } catch {
1871
+ // Keep the Electron fallback below.
1872
+ }
1873
+ }
1874
+ if (process.platform !== 'win32') {
1875
+ for (const candidate of ['/usr/local/bin/node', '/opt/homebrew/bin/node', '/usr/bin/node']) {
1876
+ if (existsSync(candidate)) {
1877
+ nodeRuntimeCache = candidate
1878
+ break
1879
+ }
1880
+ }
1881
+ return nodeRuntimeCache
1882
+ }
1883
+ const seen = new Set()
1884
+ for (const dir of pathEntries()) {
1885
+ const key = dir.toLowerCase()
1886
+ if (seen.has(key)) continue
1887
+ seen.add(key)
1888
+ const candidate = join(dir, 'node.exe')
1889
+ if (!existsSync(candidate)) continue
1890
+ if (execPath !== '' && candidate.toLowerCase() === execPath.toLowerCase()) continue
1891
+ nodeRuntimeCache = candidate
1892
+ break
1893
+ }
1894
+ return nodeRuntimeCache
1895
+ }
1896
+
1897
+ /** Run pnpm headlessly: no shell, no console, and no window the user can see. */
1898
+ const PNPM_TIMEOUT = 10 * 60 * 1000
1899
+
1900
+ /**
1901
+ * Kill a spawned process and everything it spawned.
1902
+ *
1903
+ * `child.kill()` on Windows only terminates the direct child. pnpm forks
1904
+ * downloaders and build scripts, and those orphans keep writing into
1905
+ * node_modules after pnpm itself is gone — which is the one way a timed-out
1906
+ * update can still corrupt the profile. So kill the whole tree.
1907
+ */
1908
+ function killTree(child) {
1909
+ try {
1910
+ if (process.platform === 'win32' && typeof child.pid === 'number') {
1911
+ const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
1912
+ windowsHide: true,
1913
+ stdio: 'ignore',
1914
+ })
1915
+ killer.on('error', () => {
1916
+ try {
1917
+ child.kill()
1918
+ } catch {
1919
+ // The close event still fires and settles the promise.
1920
+ }
1921
+ })
1922
+ return
1923
+ }
1924
+ // POSIX: runPnpm makes the child a process-group leader, so a negative
1925
+ // pid reaches pnpm's own children too.
1926
+ if (process.platform !== 'win32' && typeof child.pid === 'number') {
1927
+ try {
1928
+ process.kill(-child.pid, 'SIGKILL')
1929
+ return
1930
+ } catch {
1931
+ // Fall through to the direct kill.
1932
+ }
1933
+ }
1934
+ child.kill('SIGKILL')
1935
+ } catch {
1936
+ // The close event still fires and settles the promise.
1937
+ }
1938
+ }
1939
+
1940
+ function runPnpm(script, args, cwd, timeoutMs = PNPM_TIMEOUT) {
1941
+ return new Promise(resolve => {
1942
+ const execPath = nodeRuntime()
1943
+ if (execPath === '') {
1944
+ resolve({ code: -1, output: 'no node runtime available for pnpm' })
1945
+ return
1946
+ }
1947
+ const env = { ...process.env }
1948
+ const invocation = pnpmInvocation(script, args, execPath)
1949
+ if (invocation.electron) env.ELECTRON_RUN_AS_NODE = '1'
1950
+ const child = spawn(invocation.command, invocation.commandArgs, {
1951
+ cwd,
1952
+ windowsHide: true,
1953
+ stdio: ['ignore', 'pipe', 'pipe'],
1954
+ env,
1955
+ // Own process group on POSIX, so killTree can reach the downloaders and
1956
+ // build scripts pnpm forks instead of orphaning them.
1957
+ detached: process.platform !== 'win32',
1958
+ })
1959
+ let output = ''
1960
+ activePnpmChild = child
1961
+ let settled = false
1962
+ const collect = chunk => {
1963
+ output += String(chunk)
1964
+ if (output.length > 40000) output = output.slice(-40000)
1965
+ }
1966
+ // A hung pnpm would hold every agent's maintenance gate forever; the
1967
+ // client-side fetch timeout only abandons the browser side. Kill the
1968
+ // child here so the hold is always released.
1969
+ const timer = setTimeout(() => {
1970
+ output += `\n[pnpm timed out after ${Math.round(timeoutMs / 1000)}s; killing the process tree]`
1971
+ killTree(child)
1972
+ }, timeoutMs)
1973
+ const finish = result => {
1974
+ if (settled) return
1975
+ settled = true
1976
+ clearTimeout(timer)
1977
+ if (activePnpmChild === child) activePnpmChild = null
1978
+ resolve(result)
1979
+ }
1980
+ child.stdout?.on('data', collect)
1981
+ child.stderr?.on('data', collect)
1982
+ child.on('error', error => {
1983
+ finish({ code: -1, output: `${output}\n${error instanceof Error ? error.message : String(error)}` })
1984
+ })
1985
+ child.on('close', code => {
1986
+ finish({ code: typeof code === 'number' ? code : -1, output })
1987
+ })
1988
+ })
1989
+ }
1990
+
1991
+ /** How long a batch update waits for every agent to go idle before giving up. */
1992
+ const IDLE_WAIT_MS = 3 * 60 * 1000
1993
+
1994
+ /** A batch install rewrites the whole profile store; give it room. */
1995
+ const BATCH_TIMEOUT = 25 * 60 * 1000
1996
+
1997
+ /**
1998
+ * Permit one exact version through pnpm's supply-chain age policy.
1999
+ *
2000
+ * The profile carries a `minimumReleaseAgeExclude` list in pnpm-workspace.yaml
2001
+ * and pnpm refuses any version younger than the configured minimum release age
2002
+ * unless that exact version appears in it. The marketplaces append to this list
2003
+ * on every install; an update that skips the step fails on policy, not on the
2004
+ * network — which is indistinguishable from "the update is broken" unless you
2005
+ * know to look here.
2006
+ *
2007
+ * Returns true when the document actually changed.
2008
+ */
2009
+ function allowVersion(document, name, version) {
2010
+ const key = 'minimumReleaseAgeExclude'
2011
+ const prefix = `${name}@`
2012
+ const list = document.get(key)
2013
+ if (list === undefined || list === null) {
2014
+ document.set(key, [`${prefix}${version}`])
2015
+ return true
2016
+ }
2017
+ if (typeof list.add !== 'function') return false
2018
+ for (const item of list.items ?? []) {
2019
+ const value = typeof item === 'string' ? item : String(item?.value ?? '')
2020
+ if (!value.startsWith(prefix)) continue
2021
+ const versions = value
2022
+ .slice(prefix.length)
2023
+ .split('||')
2024
+ .map(part => part.trim())
2025
+ .filter(part => part.length > 0)
2026
+ if (versions.includes(version)) return false
2027
+ versions.push(version)
2028
+ const next = `${prefix}${versions.join(' || ')}`
2029
+ if (typeof item === 'string') return false
2030
+ item.value = next
2031
+ return true
2032
+ }
2033
+ list.add(`${prefix}${version}`)
2034
+ return true
2035
+ }
2036
+
2037
+ /** Append the raw output of the last update run so a failure can be read later. */
2038
+ /**
2039
+ * Two logs, two jobs:
2040
+ * - `last-update.log` is the human-readable narrative of the most recent run,
2041
+ * shown verbatim in the panel when you click "查看日志".
2042
+ * - `update-log.jsonl` appends one JSON line per run, forever. It answers the
2043
+ * question "when did this package last fail to update?" without having to
2044
+ * remember which of the last-N runs it was. Rotate by hand if it ever
2045
+ * becomes huge; it is only appended, never read by this plugin.
2046
+ */
2047
+ function writeUpdateLog(title, text) {
2048
+ const file = join(stateDir(), 'last-update.log')
2049
+ const body = `# ${title} · ${new Date().toISOString()}\n\n${text}\n`
2050
+ try {
2051
+ mkdirSync(stateDir(), { recursive: true })
2052
+ writeFileSync(file, body, 'utf8')
2053
+ } catch {
2054
+ return null
2055
+ }
2056
+ try {
2057
+ const jsonl = join(stateDir(), 'update-log.jsonl')
2058
+ const entry = JSON.stringify({
2059
+ at: new Date().toISOString(),
2060
+ title,
2061
+ ok: !title.startsWith('FAILED'),
2062
+ })
2063
+ appendFileSync(jsonl, entry + '\n', 'utf8')
2064
+ } catch {
2065
+ // Append-only history is nice-to-have; the panel log already succeeded.
2066
+ }
2067
+ return file
2068
+ }
2069
+
2070
+ /**
2071
+ * Update every chosen package in ONE pnpm pass.
2072
+ *
2073
+ * One invocation instead of one per package matters: each `pnpm add` resolves
2074
+ * the whole importer graph and re-links the hoisted store (minutes each), so
2075
+ * twenty separate calls would take half an hour and multiply the windows in
2076
+ * which a failure can happen. A single call resolves and links once.
2077
+ *
2078
+ * Around it: the supply-chain whitelist is topped up first, the three files
2079
+ * pnpm may rewrite are backed up, the run waits for an idle agent instead of
2080
+ * refusing, and any failure restores the files and re-syncs node_modules — the
2081
+ * host loads from node_modules, so restoring manifests alone would leave the
2082
+ * "rolled back" update live.
2083
+ */
2084
+ /**
2085
+ * Load freshly installed code without restarting the application. The host's HMR
2086
+ * service re-compares every loaded profile module against disk inside its own
2087
+ * exclusive transaction and restarts the entries whose files changed — the same
2088
+ * path the plugin manager takes when a bundle is toggled.
2089
+ *
2090
+ * Must not be called from inside an HMR transaction (they cannot nest).
2091
+ * Client-half bundles are fetched by the page, so a browser refresh is still
2092
+ * needed for those; this only swaps the host-side modules.
2093
+ */
2094
+ async function hotSwapUpdated(ctx) {
2095
+ const hmr = ctx.get('hmr')
2096
+ if (hmr === undefined || hmr === null || typeof hmr.runReload !== 'function') {
2097
+ return { ok: false, reason: 'host HMR service is unavailable' }
2098
+ }
2099
+ try {
2100
+ await hmr.runReload(() => hmr.reconcileProfile())
2101
+ return { ok: true }
2102
+ } catch (error) {
2103
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) }
2104
+ }
2105
+ }
2106
+
2107
+ async function applyUpdates(ctx, names, pinned = null, job = null) {
2108
+ const facts = profileFacts(ctx)
2109
+ if (facts === null) return { ok: false, message: 'profile package.json is unavailable' }
2110
+ const script = pnpmScript()
2111
+ if (script === null) return { ok: false, message: 'pnpm was not found next to the host' }
2112
+
2113
+ const wanted = Array.isArray(names) ? names.filter(name => typeof name === 'string') : []
2114
+ // A queued update remembers the version the user actually agreed to.
2115
+ // Re-resolving `latest` minutes later would install a release they never
2116
+ // saw, which is exactly the drift this queue is supposed to avoid.
2117
+ const pinnedVersions = new Map()
2118
+ if (Array.isArray(pinned)) {
2119
+ for (const one of pinned) {
2120
+ if (one === null || typeof one !== 'object') continue
2121
+ if (typeof one.name !== 'string' || typeof one.version !== 'string') continue
2122
+ pinnedVersions.set(one.name, one.version)
2123
+ }
2124
+ }
2125
+ const byName = new Map(eligibleDeps(facts).map(entry => [entry.name, entry]))
2126
+ const targets = []
2127
+ for (const name of wanted) {
2128
+ const entry = byName.get(name)
2129
+ if (entry !== undefined && !targets.some(item => item.name === name)) targets.push(entry)
2130
+ }
2131
+ if (targets.length === 0) return { ok: false, message: 'no eligible package to update' }
2132
+
2133
+ const planned = []
2134
+ const registryBase = await configuredRegistry(ctx)
2135
+ for (const target of targets) {
2136
+ // 每查一个都先看有没有被取消:registry 查询 + 代理重试是弱网下最长的一段,
2137
+ // 用户在这段时间点取消,不该白等几十秒再发现后台照样在跑。
2138
+ assertNotCancelled(job)
2139
+ let latest = pinnedVersions.get(target.name)
2140
+ if (latest === undefined) {
2141
+ try {
2142
+ latest = await npmLatest(target.name, registryBase)
2143
+ } catch (error) {
2144
+ return { ok: false, message: `${target.name}: ${error instanceof Error ? error.message : String(error)}` }
2145
+ }
2146
+ }
2147
+ if (compareVersions(latest, target.current) <= 0) continue
2148
+ planned.push({ name: target.name, current: target.current, latest })
2149
+ }
2150
+ assertNotCancelled(job)
2151
+ if (planned.length === 0) return { ok: true, updated: [], message: 'already current' }
2152
+
2153
+ // Everything pnpm may touch, kept for the rollback below.
2154
+ const backup = new Map()
2155
+ for (const file of ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml']) {
2156
+ const full = join(facts.dir, file)
2157
+ try {
2158
+ backup.set(full, readFileSync(full, 'utf8'))
2159
+ } catch {
2160
+ // A missing lockfile or workspace file is normal on a fresh profile.
2161
+ }
2162
+ }
2163
+
2164
+ // Top up the supply-chain whitelist before pnpm ever sees the request.
2165
+ let allowed = 0
2166
+ let allowNote = null
2167
+ const workspaceFile = join(facts.dir, 'pnpm-workspace.yaml')
2168
+ try {
2169
+ const { yaml } = await patchContext(ctx)
2170
+ const document = yaml.parseDocument(readFileSync(workspaceFile, 'utf8'))
2171
+ for (const item of planned) if (allowVersion(document, item.name, item.latest)) allowed += 1
2172
+ if (allowed > 0) writeFileSync(workspaceFile, document.toString(), 'utf8')
2173
+ } catch (error) {
2174
+ allowNote = error instanceof Error ? error.message : String(error)
2175
+ }
2176
+
2177
+ /**
2178
+ * 取消/排队/失败三种提前退出都必须走这里:白名单已经写进 pnpm-workspace.yaml 了,
2179
+ * 光 return 会把条目留在用户文件里,下次更新时那些版本就永远被放行。
2180
+ * pnpm 一次都没跑过,所以只需要恢复这一份文件。
2181
+ */
2182
+ const abandonBeforePnpm = () => {
2183
+ if (allowed === 0) return
2184
+ try {
2185
+ for (const [file, text] of backup) writeFileSync(file, text, 'utf8')
2186
+ } catch {
2187
+ // 写不回去也只能认了:下次更新会重算白名单。
2188
+ }
2189
+ }
2190
+
2191
+ // Wait for idle instead of making the user retry by hand.
2192
+ let release = null
2193
+ const deadline = Date.now() + IDLE_WAIT_MS
2194
+ for (;;) {
2195
+ // 取消检查必须在 holdAgents 之前:用户点取消后这里直接退出,
2196
+ // 不能再去抢一次维护门。
2197
+ if (jobCancelled(job) || (activeJobAbort !== null && activeJobAbort.signal.aborted)) {
2198
+ abandonBeforePnpm()
2199
+ throw new Error('cancelled by the user')
2200
+ }
2201
+ try {
2202
+ release = await holdAgents(ctx)
2203
+ break
2204
+ } catch {
2205
+ if (Date.now() >= deadline) {
2206
+ abandonBeforePnpm()
2207
+ // Nothing was touched; remember the intent so the idle watcher retries
2208
+ // it without the user having to babysit the panel.
2209
+ enqueueItem({
2210
+ kind: 'updates',
2211
+ names: planned.map(item => item.name),
2212
+ targets: planned.map(item => ({ name: item.name, version: item.latest })),
2213
+ })
2214
+ return {
2215
+ ok: true,
2216
+ queued: true,
2217
+ message: '会话一直在忙,这次更新先排队;空闲后自动装上',
2218
+ }
2219
+ }
2220
+ await new Promise(resume => setTimeout(resume, 3000))
2221
+ }
2222
+ }
2223
+ // 宿主没有 agents 服务时维护门是空的:更新照样能跑,但**没有**「等所有会话空闲」
2224
+ // 这层保护。这件事必须让用户知道,不能和「等过了、都空闲」长得一模一样。
2225
+ const unguarded = release !== null && release.gated === false
2226
+
2227
+ // 抢到维护门之后、真正 spawn pnpm 之前,最后再看一次取消。
2228
+ if (jobCancelled(job) || (activeJobAbort !== null && activeJobAbort.signal.aborted)) {
2229
+ try {
2230
+ release()
2231
+ } finally {
2232
+ abandonBeforePnpm()
2233
+ }
2234
+ throw new Error('cancelled by the user')
2235
+ }
2236
+
2237
+ activeJobStage = 'install'
2238
+ const args = ['add', ...planned.map(item => `${item.name}@${item.latest}`), '--save-exact', '--prefer-offline']
2239
+ let result = { code: -1, output: 'pnpm did not run' }
2240
+ try {
2241
+ result = await runPnpm(script, args, facts.dir, BATCH_TIMEOUT)
2242
+ } finally {
2243
+ release()
2244
+ }
2245
+
2246
+ if (result.code !== 0) {
2247
+ for (const [file, text] of backup) writeFileSync(file, text, 'utf8')
2248
+ // node_modules still holds whatever did install, and the host loads from
2249
+ // there — restoring the manifests alone would leave the update live.
2250
+ // Rolling back is one more pnpm run and can take minutes; say so on the
2251
+ // job page instead of leaving it frozen on "install".
2252
+ activeJobStage = 'rollback'
2253
+ // job.stage 是界面的真实来源:client 靠它把取消按钮摘掉,
2254
+ // 否则用户能在回滚跑到一半时把回滚用的 pnpm 也杀掉。
2255
+ if (job !== null && typeof job === 'object') job.stage = 'rollback'
2256
+ let resync = null
2257
+ try {
2258
+ const releaseAgain = await holdAgents(ctx)
2259
+ try {
2260
+ resync = await runPnpm(script, ['install', '--prefer-offline'], facts.dir, BATCH_TIMEOUT)
2261
+ } finally {
2262
+ releaseAgain()
2263
+ }
2264
+ } catch {
2265
+ // A busy agent is fine: the manifests are restored and the next real
2266
+ // install converges node_modules anyway.
2267
+ }
2268
+ const resyncState = resync === null ? 'skipped' : resync.code === 0 ? 'ok' : 'failed'
2269
+ const log = writeUpdateLog(
2270
+ `FAILED ${planned.map(item => `${item.name}@${item.latest}`).join(' ')}`,
2271
+ [
2272
+ `plan: ${planned.map(item => `${item.name} ${item.current} -> ${item.latest}`).join(', ')}`,
2273
+ `whitelist entries added: ${allowed}${allowNote === null ? '' : ` (note: ${allowNote})`}`,
2274
+ `resync: ${resyncState}`,
2275
+ '',
2276
+ result.output,
2277
+ ].join('\n'),
2278
+ )
2279
+ return {
2280
+ ok: false,
2281
+ rolledBack: true,
2282
+ updated: [],
2283
+ failed: { name: `${planned.length} 个包`, output: result.output.slice(-1500) },
2284
+ resync: resyncState,
2285
+ log,
2286
+ message:
2287
+ `批量更新失败,三个文件已回滚(${planned.length} 个包一个都没改动)` +
2288
+ (resyncState === 'failed'
2289
+ ? ';node_modules 重同步未完成,重启前请再执行一次更新或手动 pnpm install'
2290
+ : ''),
2291
+ }
2292
+ }
2293
+
2294
+ cache.clear()
2295
+ // Swap the new code in now instead of asking for a restart. A fresh hold keeps
2296
+ // a session from starting while entries are being stopped and restarted.
2297
+ let swap = { ok: false, reason: 'no idle moment to swap code' }
2298
+ try {
2299
+ const releaseSwap = await holdAgents(ctx)
2300
+ try {
2301
+ swap = await hotSwapUpdated(ctx)
2302
+ } finally {
2303
+ releaseSwap()
2304
+ }
2305
+ } catch {
2306
+ // A busy agent leaves the restart advice in place.
2307
+ }
2308
+ const log = writeUpdateLog(
2309
+ `OK ${planned.length} package(s)`,
2310
+ [
2311
+ `updated: ${planned.map(item => `${item.name} ${item.current} -> ${item.latest}`).join(', ')}`,
2312
+ `whitelist entries added: ${allowed}`,
2313
+ `hot swap: ${swap.ok === true ? 'ok' : swap.reason}`,
2314
+ `idle gate: ${unguarded ? 'unavailable (host exposes no agents service)' : 'held'}`,
2315
+ '',
2316
+ result.output.slice(-4000),
2317
+ ].join('\n'),
2318
+ )
2319
+ return {
2320
+ ok: true,
2321
+ updated: planned.map(item => ({ name: item.name, from: item.current, to: item.latest })),
2322
+ hotSwap: swap,
2323
+ restart: swap.ok !== true,
2324
+ // 没有维护门时如实上报:用户有权知道这次是在会话可能正跑着的时候改了 node_modules。
2325
+ ...(unguarded
2326
+ ? { unguarded: true, message: '已完成,但宿主没有 agents 服务:这次没等所有会话空闲就改动了 node_modules' }
2327
+ : {}),
2328
+ log,
2329
+ }
2330
+ }
2331
+ /* ---------------------------- other install kinds ---------------------------- */
2332
+
2333
+ const GITHUB_SPEC = /^github:([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:#([^#]*))?$/
2334
+ const REMOTE_SPEC = /^https?:\/\//
2335
+
2336
+ function installedVersion(dir, name) {
2337
+ try {
2338
+ const parsed = JSON.parse(readFileSync(join(dir, 'node_modules', name, 'package.json'), 'utf8'))
2339
+ return typeof parsed.version === 'string' ? parsed.version : null
2340
+ } catch {
2341
+ return null
2342
+ }
2343
+ }
2344
+
2345
+ /** The commit pnpm resolved a `github:` dependency to, read back from the lockfile. */
2346
+ /**
2347
+ * 本地 pin 的 GitHub commit,读不到返回 null。
2348
+ *
2349
+ * pnpm 把 github 依赖写进 lockfile 的形状不止一种,之前只认
2350
+ * `name@https://codeload.github.com/.../tar.gz/<sha>:` 这一种,于是换了安装方式
2351
+ * 或 pnpm 版本就读不出来。读不出来的后果很实在:github 行的 hasUpdate 只能
2352
+ * 保守地给 false,那一行永远不进更新列表,重新拉取的分支也就一次都跑不到。
2353
+ *
2354
+ * 已知的几种写法都要认:
2355
+ * name@https://codeload.github.com/owner/repo/tar.gz/<sha>: (tarball 形式)
2356
+ * 'name@github:owner/repo#<sha>': (旧式 github spec)
2357
+ * name@github:owner/repo/<sha> (另一种分隔)
2358
+ * resolution: { commit: <sha>, ... } (结构化记录)
2359
+ */
2360
+ function installedCommit(dir, name) {
2361
+ let lock
2362
+ try {
2363
+ lock = readFileSync(join(dir, 'pnpm-lock.yaml'), 'utf8')
2364
+ } catch {
2365
+ return null
2366
+ }
2367
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
2368
+ const patterns = [
2369
+ // tarball 形式(最常见)
2370
+ new RegExp(`^\\s*${escaped}@https://codeload\\.github\\.com/[^/]+/[^/]+/tar\\.gz/([0-9a-f]{7,40}):`, 'm'),
2371
+ // github: 前缀,锚在 # 或 / 之后
2372
+ new RegExp(`^\\s*['"]?${escaped}@github:[^#\\s'"]+[#/]([0-9a-f]{7,40})['"]?:?`, 'm'),
2373
+ // 结构化记录:包名那一行的块里出现 resolution.commit。不能只搜
2374
+ // `resolution: { commit:` —— 那个没有绑定到任何包名,会把别的依赖的
2375
+ // commit 当成这个包的,判出完全错误的「已是最新」。
2376
+ new RegExp(
2377
+ `^\\s*['"]?${escaped}@[^\\n]*:\\s*\\n(?:[ \\t]+[^\\n]*\\n)*?[ \\t]+resolution:\\s*\\{[^}]*commit:\\s*['"]?([0-9a-f]{7,40})`,
2378
+ 'm',
2379
+ ),
2380
+ ]
2381
+ for (const pattern of patterns) {
2382
+ const match = pattern.exec(lock)
2383
+ if (match !== null && typeof match[1] === 'string' && match[1] !== '') return match[1]
2384
+ }
2385
+ return null
2386
+ }
2387
+
2388
+ /**
2389
+ * Headers for GitHub's REST API. An unauthenticated caller is capped at 60
2390
+ * requests an hour, which a full profile listing can exhaust, so a token from
2391
+ * the environment is used when one is present.
2392
+ */
2393
+ function githubHeaders() {
2394
+ const headers = { accept: 'application/vnd.github+json', 'user-agent': 'dsh-plugin-rack' }
2395
+ const token = typeof process.env.GITHUB_TOKEN === 'string' ? process.env.GITHUB_TOKEN.trim() : ''
2396
+ if (token.length > 0) headers.authorization = `Bearer ${token}`
2397
+ return headers
2398
+ }
2399
+
2400
+ async function githubHead(owner, repo) {
2401
+ const response = await fetchAny(`https://api.github.com/repos/${owner}/${repo}/commits?per_page=1`, {
2402
+ signal: AbortSignal.timeout(12000),
2403
+ headers: githubHeaders(),
2404
+ })
2405
+ if (!response.ok) throw new Error(`github answered ${response.status}`)
2406
+ const body = await response.json()
2407
+ const first = Array.isArray(body) ? body[0] : null
2408
+ const commit = first === null || typeof first !== 'object' ? null : first.commit
2409
+ if (commit === null || typeof commit !== 'object') throw new Error('github returned no commit')
2410
+ const message = typeof commit.message === 'string' ? commit.message.split('\n')[0].slice(0, 140) : null
2411
+ const date = commit.committer?.date ?? commit.author?.date ?? null
2412
+ return { sha: typeof first.sha === 'string' ? first.sha : null, date, message }
2413
+ }
2414
+
2415
+ /**
2416
+ * The latest published GitHub Release of a repository. Used for `file:`
2417
+ * installs, where the tarball has no registry entry to compare against, but
2418
+ * the project usually still publishes tagged releases we can read a version
2419
+ * from. Returns null when the repository publishes no releases at all.
2420
+ */
2421
+ async function githubLatestRelease(owner, repo) {
2422
+ const response = await fetchAny(`https://api.github.com/repos/${owner}/${repo}/releases/latest`, {
2423
+ signal: AbortSignal.timeout(12000),
2424
+ headers: githubHeaders(),
2425
+ })
2426
+ if (response.status === 404) return null
2427
+ if (!response.ok) throw new Error(`github answered ${response.status}`)
2428
+ const body = await response.json()
2429
+ if (body === null || typeof body !== 'object') return null
2430
+ const tag = typeof body.tag_name === 'string' ? body.tag_name.trim() : ''
2431
+ return {
2432
+ tag,
2433
+ version: tag.replace(/^v/, ''),
2434
+ url: typeof body.html_url === 'string' ? body.html_url : null,
2435
+ publishedAt: typeof body.published_at === 'string' ? body.published_at : null,
2436
+ }
2437
+ }
2438
+
2439
+ /**
2440
+ * The highest semver tag of a repository. Plenty of projects tag every release
2441
+ * without ever publishing a GitHub Release, so `/releases/latest` answers 404
2442
+ * for them and their updates would stay invisible; the tags API sees them.
2443
+ * Returns null when no tag parses as a version.
2444
+ */
2445
+ async function githubLatestTag(owner, repo) {
2446
+ const response = await fetchAny(`https://api.github.com/repos/${owner}/${repo}/tags?per_page=100`, {
2447
+ signal: AbortSignal.timeout(12000),
2448
+ headers: githubHeaders(),
2449
+ })
2450
+ if (response.status === 404) return null
2451
+ if (!response.ok) throw new Error(`github answered ${response.status}`)
2452
+ const body = await response.json()
2453
+ if (!Array.isArray(body)) return null
2454
+ let best = null
2455
+ for (const item of body) {
2456
+ const tag = typeof item?.name === 'string' ? item.name.trim() : ''
2457
+ const version = tag.replace(/^v/, '')
2458
+ if (!EXACT_VERSION.test(version)) continue
2459
+ if (best === null || compareVersions(version, best.version) > 0) best = { tag, version }
2460
+ }
2461
+ if (best === null) return null
2462
+ return {
2463
+ tag: best.tag,
2464
+ version: best.version,
2465
+ url: `https://github.com/${owner}/${repo}/releases/tag/${best.tag}`,
2466
+ publishedAt: null,
2467
+ }
2468
+ }
2469
+
2470
+ /**
2471
+ * The newest version a repository advertises, whichever way it advertises it:
2472
+ * a published Release, or a bare tag. The two are compared as versions rather
2473
+ * than trusted in order, because a project can tag ahead of its last Release.
2474
+ */
2475
+ async function githubBestVersion(owner, repo) {
2476
+ const found = []
2477
+ const failures = []
2478
+ for (const read of [githubLatestRelease, githubLatestTag]) {
2479
+ try {
2480
+ const one = await read(owner, repo)
2481
+ if (one !== null && one.version.length > 0) found.push(one)
2482
+ } catch (error) {
2483
+ failures.push(error instanceof Error ? error.message : String(error))
2484
+ }
2485
+ }
2486
+ if (found.length === 0) {
2487
+ if (failures.length > 0) throw new Error(failures.join('; '))
2488
+ return null
2489
+ }
2490
+ found.sort((left, right) => compareVersions(right.version, left.version))
2491
+ return found[0]
2492
+ }
2493
+
2494
+ /**
2495
+ * The assets attached to one GitHub Release. Tags without a Release have none,
2496
+ * and the API answers 404 for those, so an empty list is a normal answer.
2497
+ */
2498
+ async function githubReleaseAssets(owner, repo, tag) {
2499
+ const response = await fetchAny(
2500
+ `https://api.github.com/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`,
2501
+ { signal: AbortSignal.timeout(REGISTRY_TIMEOUT), headers: githubHeaders() },
2502
+ )
2503
+ if (response.status === 404) return []
2504
+ if (!response.ok) throw new Error(`github answered ${response.status}`)
2505
+ const body = await response.json()
2506
+ const list =
2507
+ body !== null && typeof body === 'object' && Array.isArray(body.assets) ? body.assets : []
2508
+ return list
2509
+ .filter(
2510
+ item =>
2511
+ item !== null &&
2512
+ typeof item === 'object' &&
2513
+ typeof item.name === 'string' &&
2514
+ typeof item.browser_download_url === 'string',
2515
+ )
2516
+ .map(item => ({
2517
+ name: item.name,
2518
+ size: typeof item.size === 'number' ? item.size : null,
2519
+ url: item.browser_download_url,
2520
+ }))
2521
+ }
2522
+
2523
+ /**
2524
+ * The tarball among a release's assets that belongs to this package. Matches by
2525
+ * prefix so `dsh-our-free-model` picks `dsh-our-free-model-2.8.3.tgz` and never
2526
+ * the macOS `.dmg` or the installer `.exe` sitting next to it.
2527
+ */
2528
+ function pickTarballAsset(assets, name) {
2529
+ const bare = name.includes('/') ? name.slice(name.lastIndexOf('/') + 1) : name
2530
+ for (const asset of assets) {
2531
+ if (asset.name.startsWith(`${bare}-`) && asset.name.endsWith('.tgz')) return asset
2532
+ }
2533
+ return null
2534
+ }
2535
+
2536
+ /** Read the hash out of a `SHA256SUMS` file for one asset. */
2537
+ function parseSha256Sums(text, assetName) {
2538
+ for (const line of String(text).split(/\r?\n/)) {
2539
+ const match = /^([0-9a-fA-F]{64})\s+\*?(.+?)\s*$/.exec(line)
2540
+ if (match !== null && match[2] === assetName) return match[1].toLowerCase()
2541
+ }
2542
+ return null
2543
+ }
2544
+
2545
+ function sha256OfFile(path) {
2546
+ return createHash('sha256').update(readFileSync(path)).digest('hex')
2547
+ }
2548
+
2549
+ /**
2550
+ * Download a release asset to a path. Buffered on purpose: release tarballs run
2551
+ * tens of megabytes, and a single `arrayBuffer()` avoids a stream wrapper that
2552
+ * would have to be kept in step with the hash check right after it.
2553
+ */
2554
+ /**
2555
+ * A release asset download is bounded, but generously: these tarballs run tens
2556
+ * of megabytes and a slow link really does need minutes (one measured run took
2557
+ * 12m for 30 MB). The stall timer is the real guard against a dead socket; this
2558
+ * one only stops an endless trickle.
2559
+ */
2560
+ // ---- download transport ---------------------------------------------------
2561
+ // The asset has to arrive over whatever network the user actually has: a plain
2562
+ // connection, a corporate HTTP proxy, a local SOCKS5 client (Clash / v2ray), or
2563
+ // one that comes and goes. So every attempt is resumable, the routes are tried
2564
+ // from most to least likely, and a route that cannot reach the host falls
2565
+ // through to the next one instead of failing the whole update.
2566
+
2567
+ const DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1000
2568
+ const DOWNLOAD_STALL_MS = 30 * 1000
2569
+ const DOWNLOAD_TRIES = 3
2570
+ const DOWNLOAD_REDIRECTS = 5
2571
+ const DOWNLOAD_CONNECT_MS = 20 * 1000
2572
+
2573
+ /** "host:port", "http://host:port", "socks5://user:pass@host:port" -> route. */
2574
+ function parseProxy(value) {
2575
+ if (typeof value !== 'string') return null
2576
+ let text = value.trim()
2577
+ if (text === '') return null
2578
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) text = 'http://' + text
2579
+ let parsed
2580
+ try {
2581
+ parsed = new URL(text)
2582
+ } catch (error) {
2583
+ void error
2584
+ return null
2585
+ }
2586
+ const protocol = parsed.protocol.toLowerCase()
2587
+ const kind =
2588
+ protocol === 'socks5:' || protocol === 'socks5h:' || protocol === 'socks4:'
2589
+ ? 'socks'
2590
+ : protocol === 'http:' || protocol === 'https:'
2591
+ ? 'http'
2592
+ : null
2593
+ if (kind === null) return null
2594
+ const port = Number(parsed.port === '' ? (kind === 'http' ? 80 : 1080) : parsed.port)
2595
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) return null
2596
+ if (parsed.hostname === '') return null
2597
+ const auth =
2598
+ parsed.username === ''
2599
+ ? null
2600
+ : { user: decodeURIComponent(parsed.username), pass: decodeURIComponent(parsed.password ?? '') }
2601
+ return { kind, protocol, host: parsed.hostname, port, auth, label: parsed.hostname + ':' + port }
2602
+ }
2603
+
2604
+ function noProxyMatch(host, raw) {
2605
+ if (typeof raw !== 'string' || raw.trim() === '') return false
2606
+ for (const entry of raw.split(',')) {
2607
+ const one = entry.trim().toLowerCase()
2608
+ if (one === '') continue
2609
+ if (one === '*') return true
2610
+ const bare = one.startsWith('.') ? one.slice(1) : one
2611
+ const name = host.toLowerCase()
2612
+ if (name === bare || name.endsWith('.' + bare)) return true
2613
+ }
2614
+ return false
2615
+ }
2616
+
2617
+ /** The proxy Windows itself is configured with, when there is one. */
2618
+ function systemProxyRoute() {
2619
+ if (process.platform !== 'win32') return null
2620
+ try {
2621
+ const key = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'
2622
+ const query = spawnSync('reg', ['query', key, '/v', 'ProxyServer'], {
2623
+ encoding: 'utf8',
2624
+ timeout: 3000,
2625
+ windowsHide: true,
2626
+ })
2627
+ if (query.status !== 0 || typeof query.stdout !== 'string') return null
2628
+ const match = /ProxyServer\s+REG_SZ\s+(.+)/.exec(query.stdout)
2629
+ if (match === null) return null
2630
+ const parts = match[1]
2631
+ .trim()
2632
+ .split(';')
2633
+ .map(one => one.trim())
2634
+ .filter(one => one !== '')
2635
+ const httpsPart = parts.find(one => one.toLowerCase().startsWith('https='))
2636
+ const httpPart = parts.find(one => one.toLowerCase().startsWith('http='))
2637
+ const chosen = (httpsPart ?? httpPart ?? parts[0] ?? '').replace(/^[a-z]+=/i, '')
2638
+ return parseProxy(chosen)
2639
+ } catch (error) {
2640
+ void error
2641
+ return null
2642
+ }
2643
+ }
2644
+
2645
+ /**
2646
+ * Where to try, in order: an explicit choice from config.json, then the
2647
+ * environment, then the system proxy, then the plain connection. NO_PROXY and an
2648
+ * unreachable proxy both degrade to the next entry instead of failing.
2649
+ */
2650
+ function downloadRoutes(url) {
2651
+ const target = url instanceof URL ? url : new URL(url)
2652
+ const routes = []
2653
+ const add = (label, route) => {
2654
+ if (route !== null && routes.some(one => one.route !== null && one.route.host === route.host && one.route.port === route.port)) return
2655
+ routes.push({ label, route })
2656
+ }
2657
+ let mode = 'auto'
2658
+ try {
2659
+ const parsed = JSON.parse(readFileSync(join(stateDir(), 'config.json'), 'utf8'))
2660
+ const download = parsed !== null && typeof parsed === 'object' && parsed.download !== null && typeof parsed.download === 'object' ? parsed.download : null
2661
+ if (download !== null && typeof download.proxy === 'string' && download.proxy.trim() !== '') mode = download.proxy.trim()
2662
+ } catch (error) {
2663
+ void error
2664
+ }
2665
+ if (mode === 'off' || mode === 'direct' || mode === 'none') {
2666
+ add('direct', null)
2667
+ return routes
2668
+ }
2669
+ // A host on this machine is not reachable through a proxy and a CONNECT to it
2670
+ // stalls. Only auto-detected routes are dropped: a proxy the user configured by
2671
+ // hand is honoured even for a local address (that is how the code is tested).
2672
+ const skip =
2673
+ (mode === 'auto' && isLocalHost(target.hostname)) ||
2674
+ noProxyMatch(target.hostname, process.env.NO_PROXY ?? process.env.no_proxy)
2675
+ if (mode !== 'auto') {
2676
+ const explicit = parseProxy(mode)
2677
+ if (explicit !== null && !skip) add(explicit.label, explicit)
2678
+ } else {
2679
+ const env =
2680
+ process.env.HTTPS_PROXY ??
2681
+ process.env.https_proxy ??
2682
+ process.env.ALL_PROXY ??
2683
+ process.env.all_proxy ??
2684
+ process.env.HTTP_PROXY ??
2685
+ process.env.http_proxy
2686
+ const fromEnv = parseProxy(env ?? '')
2687
+ if (fromEnv !== null && !skip) add(fromEnv.label, fromEnv)
2688
+ const system = systemProxyRoute()
2689
+ if (system !== null && !skip) add(system.label, system)
2690
+ }
2691
+ add('direct', null)
2692
+ return routes
2693
+ }
2694
+
2695
+ /** Hosts only this machine can reach — a proxy could only stall on them. */
2696
+ function isLocalHost(hostname) {
2697
+ const host = hostname.toLowerCase()
2698
+ if (host === 'localhost' || host === '::1' || host === '[::1]' || host.endsWith('.localhost')) return true
2699
+ const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host)
2700
+ if (v4 === null) return false
2701
+ const first = Number(v4[1])
2702
+ const second = Number(v4[2])
2703
+ if (first === 127 || first === 10) return true
2704
+ if (first === 192 && second === 168) return true
2705
+ if (first === 172 && second >= 16 && second <= 31) return true
2706
+ return first === 169 && second === 254
2707
+ }
2708
+
2709
+ function connectTcp(host, port, timeoutMs) {
2710
+ return new Promise((resolve, reject) => {
2711
+ const socket = netConnect({ host, port })
2712
+ const timer = setTimeout(() => {
2713
+ socket.destroy()
2714
+ reject(new Error('连接 ' + host + ':' + port + ' 超时'))
2715
+ }, timeoutMs)
2716
+ socket.once('error', error => {
2717
+ clearTimeout(timer)
2718
+ reject(error)
2719
+ })
2720
+ socket.once('connect', () => {
2721
+ clearTimeout(timer)
2722
+ socket.removeAllListeners('error')
2723
+ socket.on('error', () => {})
2724
+ resolve(socket)
2725
+ })
2726
+ })
2727
+ }
2728
+
2729
+ /** Read up to the blank line, leaving any extra bytes on the socket. */
2730
+ function readHead(socket, timeoutMs) {
2731
+ return new Promise((resolve, reject) => {
2732
+ let text = ''
2733
+ let done = false
2734
+ const finish = (error, head, rest) => {
2735
+ if (done) return
2736
+ done = true
2737
+ clearTimeout(timer)
2738
+ socket.removeListener('data', onData)
2739
+ socket.removeListener('error', onError)
2740
+ socket.removeListener('close', onClose)
2741
+ if (error !== null) {
2742
+ socket.destroy()
2743
+ reject(error)
2744
+ return
2745
+ }
2746
+ if (rest !== undefined && rest.length > 0) socket.unshift(rest)
2747
+ resolve(head)
2748
+ }
2749
+ const timer = setTimeout(() => finish(new Error('代理没有回应')), timeoutMs)
2750
+ const onData = chunk => {
2751
+ text += chunk.toString('utf8')
2752
+ const end = text.indexOf('\r\n\r\n')
2753
+ if (end < 0) {
2754
+ if (text.length > 65536) finish(new Error('代理响应异常'))
2755
+ return
2756
+ }
2757
+ finish(null, text.slice(0, end), Buffer.from(text.slice(end + 4), 'utf8'))
2758
+ }
2759
+ const onError = error => finish(error)
2760
+ const onClose = () => finish(new Error('代理断开了连接'))
2761
+ socket.on('data', onData)
2762
+ socket.once('error', onError)
2763
+ socket.once('close', onClose)
2764
+ })
2765
+ }
2766
+
2767
+ /** Read exactly `count` bytes of a SOCKS5 reply, leaving the rest on the socket. */
2768
+ function readSocks(socket, count, timeoutMs) {
2769
+ return new Promise((resolve, reject) => {
2770
+ let buffer = Buffer.alloc(0)
2771
+ let done = false
2772
+ const finish = (error, all) => {
2773
+ if (done) return
2774
+ done = true
2775
+ clearTimeout(timer)
2776
+ socket.removeListener('data', onData)
2777
+ socket.removeListener('error', onError)
2778
+ if (error !== null) {
2779
+ socket.destroy()
2780
+ reject(error)
2781
+ return
2782
+ }
2783
+ if (all.length > count) socket.unshift(all.subarray(count))
2784
+ resolve(all.subarray(0, count))
2785
+ }
2786
+ const timer = setTimeout(() => finish(new Error('SOCKS 代理没有回应')), timeoutMs)
2787
+ const onData = chunk => {
2788
+ buffer = Buffer.concat([buffer, chunk])
2789
+ if (buffer.length >= count) finish(null, buffer)
2790
+ }
2791
+ const onError = error => finish(error)
2792
+ socket.on('data', onData)
2793
+ socket.once('error', onError)
2794
+ })
2795
+ }
2796
+
2797
+ async function socksOpen(socket, route, host, port) {
2798
+ socket.write(route.auth === null ? Buffer.from([0x05, 0x01, 0x00]) : Buffer.from([0x05, 0x02, 0x00, 0x02]))
2799
+ const pick = await readSocks(socket, 2, DOWNLOAD_CONNECT_MS)
2800
+ if (pick[0] !== 0x05) throw new Error('代理不是 SOCKS5')
2801
+ if (pick[1] === 0xff) throw new Error('代理不接受可用的认证方式')
2802
+ if (pick[1] === 0x02) {
2803
+ if (route.auth === null) throw new Error('代理要求用户名和密码')
2804
+ const user = Buffer.from(route.auth.user, 'utf8')
2805
+ const pass = Buffer.from(route.auth.pass, 'utf8')
2806
+ socket.write(Buffer.concat([Buffer.from([0x01, user.length]), user, Buffer.from([pass.length]), pass]))
2807
+ const answer = await readSocks(socket, 2, DOWNLOAD_CONNECT_MS)
2808
+ if (answer[1] !== 0x00) throw new Error('代理拒绝了用户名和密码')
2809
+ }
2810
+ const name = Buffer.from(host, 'utf8')
2811
+ socket.write(
2812
+ Buffer.concat([Buffer.from([0x05, 0x01, 0x00, 0x03, name.length]), name, Buffer.from([port >> 8, port & 0xff])]),
2813
+ )
2814
+ const reply = await readSocks(socket, 4, DOWNLOAD_CONNECT_MS)
2815
+ if (reply[1] !== 0x00) throw new Error('代理拒绝连到目标主机 (' + reply[1] + ')')
2816
+ if (reply[3] === 0x01) await readSocks(socket, 6, DOWNLOAD_CONNECT_MS)
2817
+ else if (reply[3] === 0x04) await readSocks(socket, 18, DOWNLOAD_CONNECT_MS)
2818
+ else if (reply[3] === 0x03) {
2819
+ const length = await readSocks(socket, 1, DOWNLOAD_CONNECT_MS)
2820
+ await readSocks(socket, length[0] + 2, DOWNLOAD_CONNECT_MS)
2821
+ }
2822
+ }
2823
+
2824
+ /** A socket that already speaks to `host:port`, through the proxy or directly. */
2825
+ async function openTunnel(route, host, port) {
2826
+ const socket = await connectTcp(route.host, route.port, DOWNLOAD_CONNECT_MS)
2827
+ try {
2828
+ if (route.kind === 'socks') {
2829
+ await socksOpen(socket, route, host, port)
2830
+ return socket
2831
+ }
2832
+ const auth =
2833
+ route.auth === null
2834
+ ? ''
2835
+ : 'Proxy-Authorization: Basic ' +
2836
+ Buffer.from(route.auth.user + ':' + route.auth.pass).toString('base64') +
2837
+ '\r\n'
2838
+ socket.write(
2839
+ 'CONNECT ' + host + ':' + port + ' HTTP/1.1\r\nHost: ' + host + ':' + port + '\r\n' + auth + '\r\n',
2840
+ )
2841
+ const head = await readHead(socket, DOWNLOAD_CONNECT_MS)
2842
+ const status = Number((head.split('\r\n')[0] ?? '').split(' ')[1] ?? 0)
2843
+ if (status !== 200) throw new Error('代理拒绝建立隧道 (' + status + ')')
2844
+ return socket
2845
+ } catch (error) {
2846
+ socket.destroy()
2847
+ throw error
2848
+ }
2849
+ }
2850
+
2851
+ function openStream(route, url, offset, signal) {
2852
+ const isTls = url.protocol === 'https:'
2853
+ const port = Number(url.port === '' ? (isTls ? 443 : 80) : url.port)
2854
+ const headers = { ...githubHeaders(), 'accept-encoding': 'identity' }
2855
+ if (offset > 0) headers.range = 'bytes=' + offset + '-'
2856
+ return new Promise((resolve, reject) => {
2857
+ const start = async () => {
2858
+ let socket = null
2859
+ if (route !== null) socket = await openTunnel(route, url.hostname, port)
2860
+ const options = {
2861
+ method: 'GET',
2862
+ host: url.hostname,
2863
+ port,
2864
+ path: url.pathname + url.search,
2865
+ headers,
2866
+ }
2867
+ if (signal !== undefined && signal !== null) options.signal = signal
2868
+ if (socket !== null && isTls) {
2869
+ const secure = tlsConnect({ socket, servername: url.hostname })
2870
+ await new Promise((resolveHandshake, rejectHandshake) => {
2871
+ secure.once('secureConnect', resolveHandshake)
2872
+ secure.once('error', rejectHandshake)
2873
+ })
2874
+ options.createConnection = () => secure
2875
+ } else if (socket !== null) {
2876
+ options.createConnection = () => socket
2877
+ }
2878
+ const request = (isTls ? httpsRequest : httpRequest)(options, response => resolve(response))
2879
+ request.on('error', reject)
2880
+ request.end()
2881
+ }
2882
+ start().catch(reject)
2883
+ })
2884
+ }
2885
+
2886
+ async function pipeTo(part, response, signal, offset, deadline) {
2887
+ const stream = createWriteStream(part, { flags: offset > 0 ? 'a' : 'w' })
2888
+ // A write error used to reach nobody: the stream emitted 'error' with no
2889
+ // listener, an unhandled 'error' takes the whole process down, and this
2890
+ // plugin shares the host's process. Remember the failure here instead, and
2891
+ // keep the listener for as long as the stream can still emit.
2892
+ let failure = null
2893
+ stream.on('error', error => {
2894
+ if (failure === null) failure = error
2895
+ })
2896
+ const fail = error => {
2897
+ if (failure === null) failure = error
2898
+ }
2899
+
2900
+ let idle = null
2901
+ const arm = () => {
2902
+ if (idle !== null) clearTimeout(idle)
2903
+ idle = setTimeout(
2904
+ () => response.destroy(new Error('下载卡住:' + Math.round(DOWNLOAD_STALL_MS / 1000) + ' 秒没有收到数据')),
2905
+ DOWNLOAD_STALL_MS,
2906
+ )
2907
+ }
2908
+ const onAbort = () => response.destroy(new Error('cancelled'))
2909
+ // Wait for the write buffer to drain, but stop waiting on abort or error:
2910
+ // a stalled disk plus a cancel would otherwise sit here forever.
2911
+ const waitDrain = () =>
2912
+ new Promise((resolve, reject) => {
2913
+ const done = () => {
2914
+ clean()
2915
+ resolve(undefined)
2916
+ }
2917
+ const failDrain = error => {
2918
+ clean()
2919
+ reject(error)
2920
+ }
2921
+ const onSignal = () => {
2922
+ response.destroy(new Error('cancelled'))
2923
+ failDrain(new Error('cancelled'))
2924
+ }
2925
+ const clean = () => {
2926
+ stream.removeListener('drain', done)
2927
+ stream.removeListener('error', failDrain)
2928
+ if (signal !== undefined && signal !== null) signal.removeEventListener('abort', onSignal)
2929
+ }
2930
+ stream.once('drain', done)
2931
+ stream.once('error', failDrain)
2932
+ if (signal !== undefined && signal !== null) {
2933
+ if (signal.aborted) {
2934
+ onSignal()
2935
+ return
2936
+ }
2937
+ signal.addEventListener('abort', onSignal, { once: true })
2938
+ }
2939
+ })
2940
+ if (signal !== undefined && signal !== null) {
2941
+ if (signal.aborted) onAbort()
2942
+ else signal.addEventListener('abort', onAbort, { once: true })
2943
+ }
2944
+ try {
2945
+ arm()
2946
+ for await (const chunk of response) {
2947
+ if (failure !== null) throw failure
2948
+ if (Date.now() >= deadline) {
2949
+ const late = new Error('下载超过 ' + Math.round(DOWNLOAD_TIMEOUT_MS / 60000) + ' 分钟,已放弃')
2950
+ response.destroy(late)
2951
+ throw late
2952
+ }
2953
+ arm()
2954
+ // Honour backpressure: ignoring write()'s answer means the whole
2955
+ // attachment piles up in memory while the disk lags.
2956
+ if (!stream.write(chunk)) await waitDrain()
2957
+ if (failure !== null) throw failure
2958
+ }
2959
+ await new Promise((resolve, reject) => {
2960
+ stream.once('error', reject)
2961
+ stream.end(() => resolve(undefined))
2962
+ })
2963
+ } finally {
2964
+ if (idle !== null) clearTimeout(idle)
2965
+ if (signal !== undefined && signal !== null) signal.removeEventListener('abort', onAbort)
2966
+ // Only tear the stream down after a failure. On success end() has flushed
2967
+ // it already, and destroy() would race the last pending write callback
2968
+ // with ERR_STREAM_DESTROYED — the other half of the crash above.
2969
+ if (failure !== null && !stream.destroyed) stream.destroy()
2970
+ }
2971
+ }
2972
+
2973
+ async function fetchToFile(url, part, route, signal, deadline) {
2974
+ let target = url instanceof URL ? url : new URL(url)
2975
+ for (let hop = 0; hop <= DOWNLOAD_REDIRECTS; hop += 1) {
2976
+ const offset = existsSync(part) ? statSync(part).size : 0
2977
+ const response = await openStream(route, target, offset, signal)
2978
+ const status = response.statusCode ?? 0
2979
+ if (status >= 300 && status < 400 && typeof response.headers.location === 'string' && response.headers.location !== '') {
2980
+ response.resume()
2981
+ target = new URL(response.headers.location, target)
2982
+ continue
2983
+ }
2984
+ if (status !== 200 && status !== 206) {
2985
+ response.resume()
2986
+ throw new Error('下载被拒绝 (' + status + ')')
2987
+ }
2988
+ await pipeTo(part, response, signal, status === 206 ? offset : 0, deadline)
2989
+ return statSync(part).size
2990
+ }
2991
+ throw new Error('重定向次数过多')
2992
+ }
2993
+
2994
+ /**
2995
+ * Download `url` into `dest`, resuming whatever the last attempt left behind.
2996
+ *
2997
+ * `dest.part` is the working file: an interrupted run keeps it, so the next
2998
+ * attempt — or the next click, minutes later — asks for the remaining bytes with
2999
+ * a Range header instead of starting the whole thing over.
3000
+ */
3001
+ /** Exported for test/download.mjs; the host calls it through applyFileUpgrade. */
3002
+ /**
3003
+ * Fetch that survives a proxy-only network.
3004
+ *
3005
+ * Node's global fetch ignores every proxy setting, which is fine for a normal
3006
+ * connection and useless behind a corporate proxy or a local Clash/v2ray client.
3007
+ * So a failed fetch is retried over the same routes the asset downloader knows:
3008
+ * explicit config, environment, system proxy, then a direct socket.
3009
+ */
3010
+ async function readResponseLike(target, route, signal) {
3011
+ let current = target
3012
+ for (let hop = 0; hop <= 3; hop += 1) {
3013
+ const response = await openStream(route, current, 0, signal)
3014
+ const status = response.statusCode ?? 0
3015
+ if (status >= 300 && status < 400 && typeof response.headers.location === 'string' && response.headers.location !== '') {
3016
+ response.resume()
3017
+ current = new URL(response.headers.location, current)
3018
+ continue
3019
+ }
3020
+ const chunks = []
3021
+ let total = 0
3022
+ for await (const chunk of response) {
3023
+ total += chunk.length
3024
+ if (total > 8 * 1024 * 1024) {
3025
+ response.destroy()
3026
+ throw new Error('响应超过 8 MB')
3027
+ }
3028
+ chunks.push(chunk)
3029
+ }
3030
+ const body = Buffer.concat(chunks, total).toString('utf8')
3031
+ return {
3032
+ ok: status >= 200 && status < 300,
3033
+ status,
3034
+ text: async () => body,
3035
+ json: async () => JSON.parse(body),
3036
+ }
3037
+ }
3038
+ throw new Error('重定向次数过多')
3039
+ }
3040
+
3041
+ /** Exported for test/download.mjs, like downloadFile. */
3042
+ export async function fetchAny(url, init) {
3043
+ try {
3044
+ return await fetch(url, init)
3045
+ } catch (error) {
3046
+ const target = new URL(url)
3047
+ const signal = init !== undefined && init !== null ? init.signal : undefined
3048
+ const routes = downloadRoutes(target)
3049
+ const failures = []
3050
+ for (const route of routes) {
3051
+ try {
3052
+ return await readResponseLike(target, route.route, signal)
3053
+ } catch (next) {
3054
+ failures.push(route.label + ': ' + (next instanceof Error ? next.message : String(next)))
3055
+ }
3056
+ }
3057
+ const detail = failures.length === 0 ? '' : ` (tried ${failures.join('; ')})`
3058
+ throw new Error((error instanceof Error ? error.message : String(error)) + detail)
3059
+ }
3060
+ }
3061
+
3062
+ export async function downloadFile(url, dest, outer) {
3063
+ const part = dest + '.part'
3064
+ const deadline = Date.now() + DOWNLOAD_TIMEOUT_MS
3065
+ const routes = downloadRoutes(url)
3066
+ let lastError = null
3067
+ for (const route of routes) {
3068
+ for (let attempt = 1; attempt <= DOWNLOAD_TRIES; attempt += 1) {
3069
+ if (Date.now() >= deadline) throw new Error('下载超过 ' + Math.round(DOWNLOAD_TIMEOUT_MS / 60000) + ' 分钟,已放弃')
3070
+ if (outer !== undefined && outer !== null && outer.aborted) throw new Error('cancelled')
3071
+ try {
3072
+ const bytes = await fetchToFile(url, part, route.route, outer, deadline)
3073
+ if (bytes === 0) throw new Error('下载得到空文件')
3074
+ renameSync(part, dest)
3075
+ return bytes
3076
+ } catch (error) {
3077
+ if (outer !== undefined && outer !== null && outer.aborted) throw error
3078
+ lastError = new Error((route.label === 'direct' ? '' : route.label + ': ') + (error instanceof Error ? error.message : String(error)))
3079
+ // Nothing arrived over this route: no point retrying it, try the next.
3080
+ if (!existsSync(part) || statSync(part).size === 0) break
3081
+ await new Promise(resolve => setTimeout(resolve, 800 * attempt))
3082
+ }
3083
+ }
3084
+ }
3085
+ throw lastError ?? new Error('下载失败')
3086
+ }
3087
+ /**
3088
+ * owner/repo of an installed package, read from the `repository` field of the
3089
+ * package that is actually on disk. Works for any install kind, which is what
3090
+ * a `file:` dependency needs: its profile spec carries no repository at all.
3091
+ */
3092
+ function repoOf(dir, name) {
3093
+ const found = readManifest(name, dir)
3094
+ if (found === null) return null
3095
+ const links = linksOf(found.manifest)
3096
+ if (typeof links.github !== 'string') return null
3097
+ const match = /^https:\/\/github\.com\/([^/]+)\/([^/]+)$/.exec(links.github)
3098
+ if (match === null) return null
3099
+ return { owner: match[1], repo: match[2] }
3100
+ }
3101
+
3102
+ /**
3103
+ * Dependencies the exact-version updater deliberately ignores, with a preview
3104
+ * for each so the decision is informed: a GitHub install is compared commit to
3105
+ * commit, a registry tarball against the package's published latest.
3106
+ */
3107
+ async function listExtras(ctx) {
3108
+ const facts = profileFacts(ctx)
3109
+ if (facts === null) return { ok: false, message: 'profile package.json is unavailable' }
3110
+ const registryBase = await configuredRegistry(ctx)
3111
+ const rows = []
3112
+ for (const [name, raw] of Object.entries(facts.deps)) {
3113
+ if (HOST_SCOPE.test(name)) continue
3114
+ const spec = typeof raw === 'string' ? raw.trim() : ''
3115
+ if (EXACT_VERSION.test(spec)) continue
3116
+ const current = installedVersion(facts.dir, name)
3117
+ if (spec.startsWith('link:')) {
3118
+ rows.push({ name, kind: 'link', spec, current, note: '本地软链' })
3119
+ continue
3120
+ }
3121
+ if (spec.startsWith('github:')) {
3122
+ const match = GITHUB_SPEC.exec(spec)
3123
+ const installed = installedCommit(facts.dir, name)
3124
+ const row = {
3125
+ name,
3126
+ kind: 'github',
3127
+ spec,
3128
+ current,
3129
+ installedCommit: installed,
3130
+ head: null,
3131
+ error: null,
3132
+ // 客户端按 hasUpdate 决定一个包要不要进「全部更新」的勾选集。github 行
3133
+ // 之前没有这个字段,于是它永远进不了 picked,repull 分支一次都跑不到。
3134
+ // 判据和徽章按钮用的完全一致:远端 head 与本地 pin 的 commit 不同。
3135
+ hasUpdate: false,
3136
+ }
3137
+ if (match === null) row.error = '无法解析 github 地址'
3138
+ else {
3139
+ try {
3140
+ row.head = await githubHead(match[1], match[2])
3141
+ // 只有确实读到了本地 pin 的 commit 才谈得上「有没有更新」。installedCommit()
3142
+ // 的正则只认 codeload tarball 那一种 lockfile 写法,读到 null 是常见的
3143
+ // (换了安装方式、lockfile 写法不同)。这时候把 head 当作可更新依据,
3144
+ // 会让这一行每次都被判成"有新版",一次全选就重拉一遍。
3145
+ if (
3146
+ installed !== null &&
3147
+ row.head !== null &&
3148
+ typeof row.head.sha === 'string' &&
3149
+ row.head.sha !== ''
3150
+ ) {
3151
+ row.hasUpdate = installed !== row.head.sha
3152
+ }
3153
+ } catch (error) {
3154
+ row.error = error instanceof Error ? error.message : String(error)
3155
+ }
3156
+ }
3157
+ rows.push(row)
3158
+ continue
3159
+ }
3160
+ if (REMOTE_SPEC.test(spec)) {
3161
+ const row = { name, kind: 'tarball', spec, current, npmLatest: null, hasUpdate: false, error: null }
3162
+ try {
3163
+ row.npmLatest = await npmLatest(name, registryBase)
3164
+ row.hasUpdate = compareVersions(row.npmLatest, current ?? '0.0.0') > 0
3165
+ } catch (error) {
3166
+ row.error = error instanceof Error ? error.message : String(error)
3167
+ }
3168
+ rows.push(row)
3169
+ continue
3170
+ }
3171
+ if (spec.startsWith('file:')) {
3172
+ // 本地压缩包(例如下载好放进 vendor/ 的那个):profile 里没有远端地址,
3173
+ // 所以改从「已经装着的那个包自己的 repository 字段」找回仓库,再去读它的
3174
+ // GitHub 版本号来比对 —— 已发布的 Release 和裸 tag 都算,因为不少项目只打
3175
+ // tag 不发 Release。没有仓库、也读不到版本,就只报本地版本。
3176
+ const row = { name, kind: 'file', spec, current, latestRelease: null, hasUpdate: false, error: null }
3177
+ const repo = repoOf(facts.dir, name)
3178
+ if (repo !== null) {
3179
+ try {
3180
+ const release = await githubBestVersion(repo.owner, repo.repo)
3181
+ if (release !== null && release.version !== '') {
3182
+ row.latestRelease = release
3183
+ row.hasUpdate = compareVersions(release.version, current ?? '0.0.0') > 0
3184
+ }
3185
+ } catch (error) {
3186
+ row.error = error instanceof Error ? error.message : String(error)
3187
+ }
3188
+ }
3189
+ rows.push(row)
3190
+ continue
3191
+ }
3192
+ rows.push({ name, kind: 'other', spec, current })
3193
+ }
3194
+ return { ok: true, rows }
3195
+ }
3196
+
3197
+ /** Run one pnpm mutation under a maintenance hold, rolling back on failure. */
3198
+ async function guardedPnpm(ctx, facts, script, args) {
3199
+ const backup = new Map()
3200
+ for (const file of ['package.json', 'pnpm-lock.yaml']) {
3201
+ const full = join(facts.dir, file)
3202
+ try {
3203
+ backup.set(full, readFileSync(full, 'utf8'))
3204
+ } catch {
3205
+ // Absent lockfile is normal.
3206
+ }
3207
+ }
3208
+ activeJobStage = 'install'
3209
+ let release
3210
+ try {
3211
+ release = await holdAgents(ctx)
3212
+ } catch {
3213
+ return { ok: false, waiting: true, message: '有会话正在运行,等它结束后再试' }
3214
+ }
3215
+ let outcome
3216
+ try {
3217
+ outcome = await runPnpm(script, args, facts.dir)
3218
+ } finally {
3219
+ release()
3220
+ }
3221
+ // 宿主没有 agents 服务时维护门是空的:能装,但没等会话空闲。
3222
+ const unguarded = release !== null && release.gated === false
3223
+ if (outcome.code !== 0) {
3224
+ for (const [file, text] of backup) writeFileSync(file, text, 'utf8')
3225
+ // 失败分支也要标:用户看到"回滚了"时要能分清这次到底有没有等会话空闲,
3226
+ // 否则靠它判断"能不能安全地立刻重试"就少了一条信息。
3227
+ return markUnguarded(
3228
+ {
3229
+ ok: false,
3230
+ rolledBack: true,
3231
+ output: outcome.output.slice(-1500),
3232
+ message: '安装失败,package.json 与 pnpm-lock.yaml 已回滚',
3233
+ },
3234
+ unguarded,
3235
+ )
3236
+ }
3237
+ cache.clear()
3238
+ // The same swap the batch updater performs, so an extra install also goes live
3239
+ // now instead of waiting for a restart.
3240
+ let swap = { ok: false, reason: 'no idle moment to swap code' }
3241
+ try {
3242
+ const releaseSwap = await holdAgents(ctx)
3243
+ try {
3244
+ swap = await hotSwapUpdated(ctx)
3245
+ } finally {
3246
+ releaseSwap()
3247
+ }
3248
+ } catch {
3249
+ // A busy agent leaves the restart advice in place.
3250
+ }
3251
+ return markUnguarded(
3252
+ { ok: true, output: outcome.output.slice(-800), hotSwap: swap, restart: swap.ok !== true },
3253
+ unguarded,
3254
+ )
3255
+ }
3256
+
3257
+ /**
3258
+ * What a `file:` dependency would upgrade to — read-only, changes nothing.
3259
+ *
3260
+ * A local tarball has no registry to ask, so the answer comes from the repo the
3261
+ * installed package itself declares: its newest GitHub version, the tarball
3262
+ * asset attached to that release, and the official SHA256SUMS entry for it. The
3263
+ * hash is what makes this safe, so an upgrade with no published hash is refused
3264
+ * instead of installing a file nobody can verify.
3265
+ */
3266
+ async function planFileUpgrade(ctx, name) {
3267
+ const facts = profileFacts(ctx)
3268
+ if (facts === null) return { ok: false, message: 'profile package.json is unavailable' }
3269
+ const spec = typeof facts.deps[name] === 'string' ? facts.deps[name].trim() : ''
3270
+ if (!spec.startsWith('file:')) return { ok: false, message: '不是本地文件包' }
3271
+ const repo = repoOf(facts.dir, name)
3272
+ if (repo === null) return { ok: false, message: '这个包没有声明 GitHub 仓库,找不到官方发布页' }
3273
+ const release = await githubBestVersion(repo.owner, repo.repo)
3274
+ if (release === null || release.version === '') return { ok: false, message: '读不到官方版本号' }
3275
+ const current = installedVersion(facts.dir, name)
3276
+ if (current !== null && compareVersions(release.version, current) <= 0) {
3277
+ return {
3278
+ ok: false,
3279
+ message: `官方最新版本 ${release.version} 不高于已安装的 ${current},无需升级`,
3280
+ }
3281
+ }
3282
+ const assets = await githubReleaseAssets(repo.owner, repo.repo, release.tag)
3283
+ const tarball = pickTarballAsset(assets, name)
3284
+ if (tarball === null) {
3285
+ return {
3286
+ ok: false,
3287
+ message: `${release.tag} 里没有 ${name} 的压缩包附件(只打 tag 不发 Release 的项目无法自动升级)`,
3288
+ }
3289
+ }
3290
+ const sums = assets.find(asset => asset.name === 'SHA256SUMS')
3291
+ if (sums === undefined) {
3292
+ return { ok: false, message: `${release.tag} 没有提供 SHA256SUMS,无法校验,已拒绝自动升级` }
3293
+ }
3294
+ const response = await fetchAny(sums.url, {
3295
+ signal: AbortSignal.timeout(REGISTRY_TIMEOUT),
3296
+ headers: githubHeaders(),
3297
+ redirect: 'follow',
3298
+ })
3299
+ if (!response.ok) throw new Error(`github answered ${response.status}`)
3300
+ const sha256 = parseSha256Sums(await response.text(), tarball.name)
3301
+ if (sha256 === null) {
3302
+ return { ok: false, message: `SHA256SUMS 里没有 ${tarball.name} 这一条,已拒绝自动升级` }
3303
+ }
3304
+ return {
3305
+ ok: true,
3306
+ name,
3307
+ current,
3308
+ spec,
3309
+ nextSpec: `file:./vendor/${tarball.name}`,
3310
+ target: {
3311
+ tag: release.tag,
3312
+ version: release.version,
3313
+ releaseUrl: release.url,
3314
+ publishedAt: release.publishedAt ?? null,
3315
+ asset: { name: tarball.name, size: tarball.size, url: tarball.url },
3316
+ sha256,
3317
+ },
3318
+ }
3319
+ }
3320
+
3321
+ /**
3322
+ * Upgrade a `file:` dependency to the tarball on its newest official release.
3323
+ *
3324
+ * The file is verified before it is ever referenced: it lands as `<name>.part`,
3325
+ * the official SHA256SUMS entry is checked against it, and only then does it
3326
+ * take its real name and get written into package.json. Any failure after that
3327
+ * point restores both manifests and re-syncs node_modules, so "failed" never
3328
+ * means "half installed".
3329
+ */
3330
+ async function applyFileUpgrade(ctx, name) {
3331
+ const facts = profileFacts(ctx)
3332
+ if (facts === null) return { ok: false, message: 'profile package.json is unavailable' }
3333
+ const script = pnpmScript()
3334
+ if (script === null) return { ok: false, message: 'pnpm was not found next to the host' }
3335
+
3336
+ let manifest
3337
+ try {
3338
+ manifest = JSON.parse(readFileSync(facts.file, 'utf8'))
3339
+ } catch (error) {
3340
+ return { ok: false, message: error instanceof Error ? error.message : String(error) }
3341
+ }
3342
+ if (
3343
+ manifest === null ||
3344
+ typeof manifest !== 'object' ||
3345
+ manifest.dependencies === null ||
3346
+ typeof manifest.dependencies !== 'object'
3347
+ ) {
3348
+ return { ok: false, message: 'profile package.json 里没有 dependencies' }
3349
+ }
3350
+
3351
+ let plan
3352
+ try {
3353
+ plan = await planFileUpgrade(ctx, name)
3354
+ } catch (error) {
3355
+ return { ok: false, message: error instanceof Error ? error.message : String(error) }
3356
+ }
3357
+ if (plan.ok !== true) return plan
3358
+
3359
+ const vendor = join(facts.dir, 'vendor')
3360
+ try {
3361
+ mkdirSync(vendor, { recursive: true })
3362
+ } catch {
3363
+ // Already there, or the write below reports it.
3364
+ }
3365
+ const target = join(vendor, plan.target.asset.name)
3366
+ const partial = `${target}.part`
3367
+ const lockPath = join(facts.dir, 'pnpm-lock.yaml')
3368
+ const before = new Map()
3369
+ for (const file of [facts.file, lockPath]) {
3370
+ try {
3371
+ before.set(file, readFileSync(file))
3372
+ } catch {
3373
+ // A missing lockfile is fine: there is nothing to put back.
3374
+ }
3375
+ }
3376
+
3377
+ let bytes = 0
3378
+ activeJobStage = 'download'
3379
+ try {
3380
+ // A previous attempt may already have fetched and verified this very asset.
3381
+ // Reusing it turns a 12-minute retry into an instant one, and the sha256 of
3382
+ // the file on disk is the proof it is the official tarball.
3383
+ if (existsSync(target) && sha256OfFile(target) === plan.target.sha256) {
3384
+ bytes = statSync(target).size
3385
+ } else {
3386
+ bytes = await downloadFile(plan.target.asset.url, partial, activeJobAbort === null ? undefined : activeJobAbort.signal)
3387
+ const actual = sha256OfFile(partial)
3388
+ if (actual !== plan.target.sha256) {
3389
+ throw new Error(`官方 sha256 是 ${plan.target.sha256},下下来的是 ${actual}`)
3390
+ }
3391
+ renameSync(partial, target)
3392
+ }
3393
+ } catch (error) {
3394
+ // A cancelled run keeps the resume file: the next click finishes it instead
3395
+ // of fetching 30 MB again. Any other failure starts clean.
3396
+ const cancelled = error instanceof Error && /cancel/i.test(error.message)
3397
+ try {
3398
+ if (existsSync(partial)) unlinkSync(partial)
3399
+ const resume = partial + '.part'
3400
+ if (!cancelled && existsSync(resume)) unlinkSync(resume)
3401
+ } catch {
3402
+ // Best effort: a stray .part is inert.
3403
+ }
3404
+ return {
3405
+ ok: false,
3406
+ message: `下载或校验失败,什么都没改:${error instanceof Error ? error.message : String(error)}`,
3407
+ }
3408
+ }
3409
+
3410
+ activeJobStage = 'install'
3411
+ let release
3412
+ try {
3413
+ release = await holdAgents(ctx)
3414
+ } catch {
3415
+ // The verified tarball stays on disk and the entry joins the queue: the
3416
+ // idle watcher installs it as soon as every Agent is between turns.
3417
+ enqueueItem({ kind: 'extras', name, action: 'file-upgrade' })
3418
+ return {
3419
+ ok: true,
3420
+ queued: true,
3421
+ message: '附件已下载并校验,先排队;会话一空闲就自动装上',
3422
+ }
3423
+ }
3424
+ // 宿主没有 agents 服务时维护门是空的:能装,但没有「等会话空闲」这层保护。
3425
+ const unguarded = release !== null && release.gated === false
3426
+
3427
+ let outcome = null
3428
+ try {
3429
+ manifest.dependencies[name] = plan.nextSpec
3430
+ writeFileSync(facts.file, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
3431
+ outcome = await runPnpm(script, ['install', '--prefer-offline'], facts.dir, BATCH_TIMEOUT)
3432
+ } catch (error) {
3433
+ outcome = { code: -1, output: error instanceof Error ? error.message : String(error) }
3434
+ } finally {
3435
+ release()
3436
+ }
3437
+
3438
+ if (outcome === null || outcome.code !== 0) {
3439
+ for (const [file, content] of before) {
3440
+ try {
3441
+ writeFileSync(file, content)
3442
+ } catch {
3443
+ // Keep going: restoring the lockfile matters more than the manifest.
3444
+ }
3445
+ }
3446
+ let resync = null
3447
+ try {
3448
+ const releaseAgain = await holdAgents(ctx)
3449
+ try {
3450
+ resync = await runPnpm(script, ['install', '--prefer-offline'], facts.dir, BATCH_TIMEOUT)
3451
+ } finally {
3452
+ releaseAgain()
3453
+ }
3454
+ } catch {
3455
+ resync = null
3456
+ }
3457
+ try {
3458
+ if (existsSync(target)) unlinkSync(target)
3459
+ } catch {
3460
+ // Best effort.
3461
+ }
3462
+ const resyncState = resync === null ? 'skipped' : resync.code === 0 ? 'ok' : 'failed'
3463
+ writeUpdateLog(
3464
+ `FAILED ${name}`,
3465
+ `action: file-upgrade\nplan: ${plan.current} -> ${plan.target.version}\nresync: ${resyncState}\n\n${outcome === null ? '' : outcome.output}`,
3466
+ )
3467
+ return {
3468
+ ok: false,
3469
+ rolledBack: true,
3470
+ resync: resyncState,
3471
+ output: outcome === null ? '' : outcome.output.slice(-4000),
3472
+ message: '本地包升级失败,package.json 与 pnpm-lock.yaml 已回滚',
3473
+ }
3474
+ }
3475
+
3476
+ cache.clear()
3477
+ let swap = { ok: false, reason: 'no idle moment to swap code' }
3478
+ try {
3479
+ const releaseSwap = await holdAgents(ctx)
3480
+ try {
3481
+ swap = await hotSwapUpdated(ctx)
3482
+ } finally {
3483
+ releaseSwap()
3484
+ }
3485
+ } catch {
3486
+ // Keep the result above; the caller reports it as restart-required.
3487
+ }
3488
+ writeUpdateLog(
3489
+ `OK ${name}`,
3490
+ `action: file-upgrade\n${plan.current} -> ${plan.target.version}\nasset: ${plan.target.asset.name} (${bytes} bytes)\nsha256: ${plan.target.sha256}\nhot swap: ${swap.ok === true ? 'ok' : swap.reason}\nidle gate: ${unguarded ? 'unavailable' : 'held'}`,
3491
+ )
3492
+ return markUnguarded(
3493
+ {
3494
+ ok: true,
3495
+ action: 'file-upgrade',
3496
+ name,
3497
+ from: plan.current,
3498
+ to: plan.target.version,
3499
+ asset: plan.target.asset.name,
3500
+ bytes,
3501
+ sha256: plan.target.sha256,
3502
+ output: outcome.output.slice(-800),
3503
+ hotSwap: swap,
3504
+ restart: swap.ok !== true,
3505
+ },
3506
+ unguarded,
3507
+ )
3508
+ }
3509
+
3510
+ async function applyExtras(ctx, name, action) {
3511
+ const facts = profileFacts(ctx)
3512
+ if (facts === null) return { ok: false, message: 'profile package.json is unavailable' }
3513
+ const script = pnpmScript()
3514
+ if (script === null) return { ok: false, message: 'pnpm was not found next to the host' }
3515
+ const spec = typeof facts.deps[name] === 'string' ? facts.deps[name].trim() : ''
3516
+ if (spec === '') return { ok: false, message: 'unknown package' }
3517
+
3518
+ if (action === 'repull') {
3519
+ if (!spec.startsWith('github:')) return { ok: false, message: '不是 GitHub 直装的包' }
3520
+ const match = GITHUB_SPEC.exec(spec)
3521
+ if (match === null) return { ok: false, message: '无法解析 github 地址' }
3522
+ let head
3523
+ try {
3524
+ head = await githubHead(match[1], match[2])
3525
+ } catch (error) {
3526
+ return { ok: false, message: error instanceof Error ? error.message : String(error) }
3527
+ }
3528
+ if (head.sha === null || head.sha === '') return { ok: false, message: 'github returned no commit hash' }
3529
+ // Pin to the head commit at action time. Re-adding the same floating spec
3530
+ // lets pnpm treat the lockfile as already satisfied and silently reuse the
3531
+ // old pin, which would make 重新拉取 a no-op.
3532
+ const result = await guardedPnpm(ctx, facts, script, [
3533
+ 'add',
3534
+ `github:${match[1]}/${match[2]}#${head.sha}`,
3535
+ '--save-exact',
3536
+ ])
3537
+ return { ...result, restart: result.hotSwap?.ok !== true, action: 'repull', head }
3538
+ }
3539
+ if (action === 'pin') {
3540
+ if (!REMOTE_SPEC.test(spec)) return { ok: false, message: '不是压缩包安装的包' }
3541
+ let latest
3542
+ try {
3543
+ latest = await npmLatest(name, await configuredRegistry(ctx))
3544
+ } catch (error) {
3545
+ return { ok: false, message: error instanceof Error ? error.message : String(error) }
3546
+ }
3547
+ // Guard against downgrades: the npm latest can be OLDER than what the
3548
+ // tarball installed (dsh-our-free-model runs 2.8.0 while npm says 2.1.0).
3549
+ const current = installedVersion(facts.dir, name)
3550
+ if (current !== null && compareVersions(latest, current) <= 0) {
3551
+ return { ok: false, message: `npm 最新版本 ${latest} 不高于已安装的 ${current},已取消(防止降级)` }
3552
+ }
3553
+ const result = await guardedPnpm(ctx, facts, script, ['add', `${name}@${latest}`, '--save-exact'])
3554
+ return { ...result, restart: result.hotSwap?.ok !== true, action: 'pin', latest }
3555
+ }
3556
+ if (action === 'file-upgrade') return applyFileUpgrade(ctx, name)
3557
+ return { ok: false, message: 'unknown action' }
3558
+ }
3559
+
3560
+ /**
3561
+ * The registry this profile actually resolves packages from. pnpm's own answer
3562
+ * wins because it reflects the profile's configuration, so a mirror keeps
3563
+ * working and nothing here has to know what a mirror is.
3564
+ */
3565
+ async function configuredRegistry(ctx) {
3566
+ try {
3567
+ const info = await ctx.pluginManager.registries()
3568
+ if (info === null || typeof info !== 'object') return null
3569
+ if (typeof info.resolved === 'string' && info.resolved.length > 0) return info.resolved
3570
+ const fallbacks = Array.isArray(info.fallbackRegistries) ? info.fallbackRegistries : []
3571
+ if (fallbacks.length > 0 && typeof fallbacks[0] === 'string') return fallbacks[0]
3572
+ return typeof info.registry === 'string' && info.registry.length > 0 ? info.registry : null
3573
+ } catch {
3574
+ return null
3575
+ }
3576
+ }
3577
+
3578
+ /** The host's bundle record for a package, or null when nothing mounts it. */
3579
+ async function bundleFor(ctx, name) {
3580
+ const all = await ctx.pluginManager.listBundles()
3581
+ return all.find((item) => item.name === name) ?? null
3582
+ }
3583
+
3584
+ /** Every dependency the panel may remove: mounted here, and the host allows it. */
3585
+ async function listRemovable(ctx) {
3586
+ const all = await ctx.pluginManager.listBundles()
3587
+ return all
3588
+ .filter((item) => item.removable === true && item.availability === 'profile' && !SELF_NAMES.includes(item.name))
3589
+ .map((item) => ({ name: item.name, version: item.version ?? null, enabled: item.enabled === true, installed: item.installed === true }))
3590
+ }
3591
+
3592
+ /**
3593
+ * Uninstall a plugin through the host's own service. `removeBundle` owns the
3594
+ * profile write lock, unregisters the bundle, runs the package manager and
3595
+ * reloads in one transaction, and it refuses anything the host does not
3596
+ * consider removable — which is what keeps core packages safe. The manifest and
3597
+ * lockfile are copied first and restored when the removal throws.
3598
+ */
3599
+ async function actUninstall(ctx, name) {
3600
+ if (typeof name !== 'string' || name.trim().length === 0) throw new HttpError(400, 'name is required')
3601
+ const target = name.trim()
3602
+ if (SELF_NAMES.includes(target)) throw new Error('不能卸载正在运行的面板本身,请用宿主自带的插件管理器')
3603
+ const bundle = await bundleFor(ctx, target)
3604
+ if (bundle === null) throw new Error(`${target} 不是本 profile 挂载的 bundle,无法卸载`)
3605
+ if (bundle.removable !== true) throw new Error(`${target} 由宿主管理,不允许卸载`)
3606
+ const facts = profileFacts(ctx)
3607
+ const backup = new Map()
3608
+ if (facts !== null) {
3609
+ for (const full of [facts.file, join(facts.dir, 'pnpm-lock.yaml')]) {
3610
+ try {
3611
+ backup.set(full, readFileSync(full, 'utf8'))
3612
+ } catch {
3613
+ // A missing lockfile is not a reason to refuse the removal.
3614
+ }
3615
+ }
3616
+ }
3617
+ try {
3618
+ const result = await ctx.pluginManager.removeBundle(target)
3619
+ cache.clear()
3620
+ return {
3621
+ ok: true,
3622
+ name: target,
3623
+ application: result === null || result === undefined ? null : (result.application ?? null),
3624
+ output: String(result?.packageResult?.output ?? '').slice(-4000),
3625
+ }
3626
+ } catch (error) {
3627
+ for (const [file, text] of backup) {
3628
+ try {
3629
+ writeFileSync(file, text, 'utf8')
3630
+ } catch {
3631
+ // Nothing better to do than report the original failure.
3632
+ }
3633
+ }
3634
+ throw error
3635
+ }
3636
+ }
3637
+
3638
+ /**
3639
+ * Install a new bundle from a spec the host understands: an npm name, a
3640
+ * name@version, a directory, a git URL or a tarball URL. The spec is inspected
3641
+ * first, so a typo or a package that ships no bundle patch is refused before
3642
+ * anything is downloaded, and the install then goes through the host service —
3643
+ * which is what applies the compatibility check and the build-script policy
3644
+ * that a bare package manager call would skip.
3645
+ */
3646
+ async function actInstall(ctx, spec) {
3647
+ if (typeof spec !== 'string' || spec.trim().length === 0) throw new HttpError(400, 'spec is required')
3648
+ const wanted = spec.trim()
3649
+ const inspection = await ctx.pluginManager.inspect(wanted)
3650
+ if (inspection === null || typeof inspection !== 'object' || inspection.status !== 'accepted') {
3651
+ return {
3652
+ ok: false,
3653
+ stage: 'inspect',
3654
+ problem: inspection?.problem ?? 'unknown',
3655
+ message: inspection?.reason ?? '宿主拒绝了这个来源',
3656
+ }
3657
+ }
3658
+ const result = await ctx.pluginManager.installBundle(wanted, { enabled: true, saveExact: true })
3659
+ cache.clear()
3660
+ const pending = Array.isArray(result?.pendingBuilds) ? result.pendingBuilds : []
3661
+ if (pending.length > 0) {
3662
+ return { ok: false, stage: 'build-approval', pendingBuilds: pending, message: '这些包需要在运行构建脚本前得到批准' }
3663
+ }
3664
+ const swap = await hotSwapUpdated(ctx)
3665
+ // 冲突对账:装完后看 loader 里有没有出现重复 entry id 或 moduleName。
3666
+ // 新装的那个先禁用,把决定权留给用户,别让两个同名插件抢着挂载同一个 API。
3667
+ const conflicts = []
3668
+ let rowsAfter = null
3669
+ try {
3670
+ rowsAfter = await readRows(ctx)
3671
+ const seenByEntryId = new Map()
3672
+ const seenByModule = new Map()
3673
+ for (const row of rowsAfter) {
3674
+ const byId = seenByEntryId.get(row.entryId)
3675
+ if (byId !== undefined) {
3676
+ conflicts.push({ kind: 'entryId', key: row.entryId, rows: [byId.name, row.name] })
3677
+ } else {
3678
+ seenByEntryId.set(row.entryId, row)
3679
+ }
3680
+ if (row.moduleName !== '') {
3681
+ const byModule = seenByModule.get(row.moduleName)
3682
+ if (byModule !== undefined) {
3683
+ conflicts.push({ kind: 'moduleName', key: row.moduleName, rows: [byModule.name, row.name] })
3684
+ } else {
3685
+ seenByModule.set(row.moduleName, row)
3686
+ }
3687
+ }
3688
+ }
3689
+ // 自动禁用新装那个名字对应的行(只要能定位到)。
3690
+ if (conflicts.length > 0 && typeof inspection.name === 'string') {
3691
+ // inspection.name 是包名(patchId 概念),要转换成具体的 entryId 才能用 setPluginEnabled。
3692
+ const mine = rowsAfter.filter(row => row.name === inspection.name || row.patchId === inspection.name)
3693
+ for (const row of mine) {
3694
+ try {
3695
+ await ctx.pluginManager.setPluginEnabled(row.entryId, false)
3696
+ conflicts.forEach(conflict => {
3697
+ if (conflict.autoDisabled === undefined) conflict.autoDisabled = []
3698
+ conflict.autoDisabled.push(row.entryId)
3699
+ })
3700
+ } catch {
3701
+ // 禁用失败就把冲突留给用户看,不装作处理好了。
3702
+ }
3703
+ }
3704
+ }
3705
+ } catch {
3706
+ // readRows 失败不该让安装本身失败;冲突只是没检测,不是装坏了。
3707
+ }
3708
+ return {
3709
+ ok: true,
3710
+ installed: {
3711
+ name: typeof inspection.name === 'string' ? inspection.name : null,
3712
+ version: typeof inspection.version === 'string' ? inspection.version : null,
3713
+ kind: typeof inspection.kind === 'string' ? inspection.kind : null,
3714
+ description: typeof inspection.description === 'string' ? inspection.description : null,
3715
+ },
3716
+ application: result === null || result === undefined ? null : (result.application ?? null),
3717
+ hotSwap: swap,
3718
+ restart: swap.ok !== true,
3719
+ output: String(result?.packageResult?.output ?? result?.output ?? '').slice(-4000),
3720
+ conflicts: conflicts.length > 0 ? conflicts : undefined,
3721
+ }
3722
+ }
3723
+
3724
+ /** How often the idle watcher looks for queued work. */
3725
+ const QUEUE_TICK_MS = 10 * 1000
3726
+
3727
+ /**
3728
+ * Apply one queued update as soon as every Agent is between turns.
3729
+ *
3730
+ * The host refuses a file swap while a turn is running — exactly what
3731
+ * `holdAgents` prevents — so a queued entry is only taken when the agents can be
3732
+ * claimed for the length of one pnpm run. Nothing here downloads: a queued
3733
+ * file-upgrade already has its verified tarball on disk, and a queued npm update
3734
+ * re-resolves with pnpm.
3735
+ */
3736
+ async function pumpQueue(ctx) {
3737
+ const list = readQueue()
3738
+ if (list.length === 0) return
3739
+ if (activeJob !== null) return
3740
+ if (!beginMutation()) return
3741
+ const item = list[0]
3742
+ try {
3743
+ let release = null
3744
+ try {
3745
+ release = await holdAgents(ctx)
3746
+ } catch {
3747
+ return
3748
+ }
3749
+ release()
3750
+ const busy = result => result !== null && typeof result === 'object' && result.queued === true
3751
+ const failed = result => result === null || typeof result !== 'object' || result.ok !== true
3752
+ if (item.kind === 'updates') {
3753
+ const job = beginJob('updates', Array.isArray(item.names) ? item.names : [])
3754
+ const result = await applyUpdates(
3755
+ ctx,
3756
+ Array.isArray(item.names) ? item.names : [],
3757
+ Array.isArray(item.targets) ? item.targets : null,
3758
+ job,
3759
+ )
3760
+ endJob(job, result)
3761
+ if (busy(result)) return
3762
+ if (failed(result)) {
3763
+ dequeueItem(item)
3764
+ return
3765
+ }
3766
+ } else {
3767
+ const job = beginJob('extras', [item.name])
3768
+ const result = await applyExtras(ctx, item.name, typeof item.action === 'string' ? item.action : 'file-upgrade')
3769
+ endJob(job, result)
3770
+ if (busy(result)) return
3771
+ if (failed(result)) {
3772
+ dequeueItem(item)
3773
+ return
3774
+ }
3775
+ }
3776
+ dequeueItem(item)
3777
+ } catch (error) {
3778
+ void error
3779
+ } finally {
3780
+ endMutation()
3781
+ }
3782
+ }
3783
+
3784
+ /**
3785
+ * The name part of a Host header. `[::1]:18338` has to come out as `::1`,
3786
+ * and a bare IPv6 literal has no port to strip, only brackets.
3787
+ */
3788
+ function hostName(raw) {
3789
+ const text = String(raw ?? '').trim().toLowerCase()
3790
+ if (text === '') return ''
3791
+ if (text.startsWith('[')) {
3792
+ const end = text.indexOf(']')
3793
+ return end === -1 ? text : text.slice(1, end)
3794
+ }
3795
+ const colon = text.lastIndexOf(':')
3796
+ if (colon !== -1 && text.indexOf(':') === colon) return text.slice(0, colon)
3797
+ return text
3798
+ }
3799
+
3800
+ async function handle(ctx, req, res) {
3801
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1')
3802
+ const path = url.pathname.slice(ROUTE_PREFIX.length) || '/'
3803
+ const method = req.method ?? 'GET'
3804
+
3805
+ // 只接受来自 web GUI 本身的调用。两层防线:
3806
+ // 1. Host 必须是 127.0.0.1 / localhost / ::1 —— 拒绝 DNS rebinding(恶意域名解析到 127.0.0.1)。
3807
+ // 2. 有 Origin 就必须同源 —— 拒绝跨站 fetch。没 Origin 的是 curl / 同源导航,放过。
3808
+ const host = hostName(req.headers.host)
3809
+ const hostOk = host === '127.0.0.1' || host === 'localhost' || host === '::1'
3810
+ if (!hostOk) {
3811
+ return send(res, 403, { ok: false, message: 'host not allowed' })
3812
+ }
3813
+ const origin = req.headers.origin
3814
+ if (typeof origin === 'string' && origin !== '') {
3815
+ let originHost = ''
3816
+ try {
3817
+ originHost = new URL(origin).hostname.toLowerCase()
3818
+ } catch {
3819
+ return send(res, 403, { ok: false, message: 'bad origin' })
3820
+ }
3821
+ const originOk =
3822
+ originHost === '127.0.0.1' || originHost === 'localhost' || originHost === '::1' || originHost === '[::1]'
3823
+ if (!originOk) {
3824
+ return send(res, 403, { ok: false, message: 'origin not allowed' })
3825
+ }
3826
+ }
3827
+
3828
+ // Only JSON bodies are accepted, which also means a plain cross-origin form
3829
+ // post cannot reach an operation.
3830
+ if (method === 'POST') {
3831
+ const type = String(req.headers['content-type'] ?? '').toLowerCase()
3832
+ if (!type.startsWith('application/json')) {
3833
+ return send(res, 415, { ok: false, message: 'content-type must be application/json' })
3834
+ }
3835
+ // 写操作额外要求「你确实是从这个面板来的」。
3836
+ //
3837
+ // 前面两道闸(Host + Origin)只证明请求没被别的站点冒名,但对一个完全不带
3838
+ // Origin 的请求是放行的 —— `curl -X POST` 正好属于这类。这道门补上的是
3839
+ // 「声明意图」:挡住别处页面顺手打过来的盲发 POST、以及被 XSS 后的自动请求。
3840
+ //
3841
+ // 它**不是鉴权**。本机任何拿到这个端口的脚本,加一行请求头就能过;要真防住
3842
+ // 同机脚本,得引入 token/随机口令,目前没做。README 的「安全模型」章节说的
3843
+ // 是同一件事,两处不要各说各的。
3844
+ //
3845
+ // 判据:浏览器发的同源请求一定带 Origin 或 Sec-Fetch-Site;两者都没有的
3846
+ // 客户端必须显式带 `x-dsh-plugin-rack: 1`。
3847
+ const secFetchSite = String(req.headers['sec-fetch-site'] ?? '').toLowerCase()
3848
+ const explicit = String(req.headers['x-dsh-plugin-rack'] ?? '') === '1'
3849
+ const fromPage = origin !== undefined && origin !== null && String(origin) !== ''
3850
+ const sameSite = secFetchSite === 'same-origin' || secFetchSite === 'none'
3851
+ if (!fromPage && !sameSite && !explicit) {
3852
+ return send(res, 403, {
3853
+ ok: false,
3854
+ message:
3855
+ 'write requests need an Origin header, same-site Sec-Fetch-Site, or an explicit x-dsh-plugin-rack: 1 header',
3856
+ })
3857
+ }
3858
+ }
3859
+
3860
+ if (method === 'GET' && (path === '/ping' || path === '/')) {
3861
+ return send(res, 200, {
3862
+ ok: true,
3863
+ version: VERSION,
3864
+ statePath: stateFile(),
3865
+ // Which interpreter would run pnpm; a console node keeps it windowless.
3866
+ pnpmNode: nodeRuntime(),
3867
+ pnpmScript: pnpmScript(),
3868
+ })
3869
+ }
3870
+ if (method === 'GET' && path === '/state') {
3871
+ return send(res, 200, await snapshot(ctx))
3872
+ }
3873
+ // 上一次/正在跑的更新任务。面板可能在中途被关掉,回来时靠这个续上进度。
3874
+ if (method === 'GET' && path === '/job') {
3875
+ return send(res, 200, { ok: true, job: jobStatus(), queue: readQueue() })
3876
+ }
3877
+ // 取消正在跑的那一次。下载阶段能立刻停,pnpm 阶段只能等它退出。
3878
+ if (method === 'POST' && path === '/job') {
3879
+ const body = await readJsonBody(req)
3880
+ const action = body !== null && typeof body === 'object' ? body.action : null
3881
+ if (action !== 'cancel') return send(res, 400, { ok: false, message: "action must be 'cancel'" })
3882
+ const result = cancelJob()
3883
+ return send(res, result.ok === true ? 200 : 409, result)
3884
+ }
3885
+ // 给脚本/未来的 agent 工具用的精简快照:每行只保留名字、版本、开关、phase、
3886
+ // 是否自动、是否核心。比 /state 干净,不带详情缓存、不带 bulk 进度。
3887
+ if (method === 'GET' && path === '/summary') {
3888
+ const rows = await readRows(ctx)
3889
+ const state = roster()
3890
+ const auto = new Set(state.auto)
3891
+ return send(res, 200, {
3892
+ ok: true,
3893
+ rows: rows.map(row => ({
3894
+ name: row.name,
3895
+ version: row.version,
3896
+ enabled: row.enabled,
3897
+ phase: row.phase,
3898
+ official: row.official,
3899
+ manageable: row.manageable,
3900
+ auto: auto.has(row.patchId),
3901
+ description: row.description,
3902
+ })),
3903
+ })
3904
+ }
3905
+ if (method === 'GET' && path === '/detail') {
3906
+ const entryId = url.searchParams.get('entryId') ?? ''
3907
+ if (entryId.length === 0) throw new HttpError(400, 'entryId is required')
3908
+ return send(res, 200, { ok: true, ...(await detailFor(ctx, entryId)) })
3909
+ }
3910
+ if (method === 'GET' && path === '/updates') {
3911
+ return send(res, 200, await checkUpdates(ctx))
3912
+ }
3913
+ if (method === 'POST' && path === '/updates') {
3914
+ // 更新同样要动 package.json + pnpm-lock.yaml,必须和其它写操作互斥。
3915
+ if (!beginMutation()) {
3916
+ return send(res, 200, { ok: false, message: BUSY_MESSAGE })
3917
+ }
3918
+ try {
3919
+ const body = await readJsonBody(req)
3920
+ const names = Array.isArray(body.names) ? body.names : []
3921
+ // 任务先在宿主登记再开跑:请求断了(面板关了、标签页没了)它照样跑完,
3922
+ // 结果写进 job.json,下次打开面板时由 /job 接回来。
3923
+ const job = beginJob('updates', names)
3924
+ try {
3925
+ const result = await applyUpdates(ctx, names, null, job)
3926
+ endJob(job, result)
3927
+ return send(res, 200, Object.assign({}, result, { jobId: job.id }))
3928
+ } catch (error) {
3929
+ endJob(job, { ok: false, message: error instanceof Error ? error.message : String(error) })
3930
+ throw error
3931
+ }
3932
+ } finally {
3933
+ endMutation()
3934
+ }
3935
+ }
3936
+ if (method === 'GET' && path === '/updatelog') {
3937
+ const file = join(stateDir(), 'last-update.log')
3938
+ try {
3939
+ const text = readFileSync(file, 'utf8')
3940
+ send(res, 200, { ok: true, path: file, text })
3941
+ } catch {
3942
+ send(res, 200, { ok: true, path: file, text: null })
3943
+ }
3944
+ return
3945
+ }
3946
+
3947
+ if (method === 'GET' && path === '/extras') {
3948
+ return send(res, 200, await listExtras(ctx))
3949
+ }
3950
+ if (method === 'POST' && path === '/extras') {
3951
+ // 「重新拉取 / 改为按版本安装」也会跑 pnpm,同样进互斥区。
3952
+ if (!beginMutation()) {
3953
+ return send(res, 200, { ok: false, message: BUSY_MESSAGE })
3954
+ }
3955
+ try {
3956
+ const body = await readJsonBody(req)
3957
+ const action = String(body.action ?? '')
3958
+ const job = beginJob('extras', [String(body.name ?? '')])
3959
+ try {
3960
+ const result = await applyExtras(ctx, String(body.name ?? ''), action)
3961
+ endJob(job, result)
3962
+ return send(res, 200, Object.assign({}, result, { jobId: job.id }))
3963
+ } catch (error) {
3964
+ endJob(job, { ok: false, message: error instanceof Error ? error.message : String(error) })
3965
+ throw error
3966
+ }
3967
+ } finally {
3968
+ endMutation()
3969
+ }
3970
+ }
3971
+ if (method === 'GET' && path === '/registries') {
3972
+ const info = await ctx.pluginManager.registries()
3973
+ return send(res, 200, { ok: true, ...(info ?? {}), github: typeof process.env.GITHUB_TOKEN === 'string' && process.env.GITHUB_TOKEN.trim().length > 0 })
3974
+ }
3975
+ if (method === 'GET' && path === '/inspect') {
3976
+ // 只做预检,不装任何东西:把宿主 inspect 的结果原样交给界面,
3977
+ // 这样「安装」按钮点下去之前,用户已经能看到要装的到底是哪个包、什么版本。
3978
+ const spec = (url.searchParams.get('spec') ?? '').trim()
3979
+ if (spec === '') return send(res, 400, { ok: false, message: 'spec is required' })
3980
+ try {
3981
+ return send(res, 200, { ok: true, inspection: await ctx.pluginManager.inspect(spec) })
3982
+ } catch (error) {
3983
+ return send(res, 200, { ok: false, message: error instanceof Error ? error.message : String(error) })
3984
+ }
3985
+ }
3986
+ if (method === 'GET' && path === '/fileplan') {
3987
+ // 本地 file: 包的升级预览:只读,不下载、不改任何文件。
3988
+ const name = (url.searchParams.get('name') ?? '').trim()
3989
+ if (name === '') return send(res, 400, { ok: false, message: 'name is required' })
3990
+ try {
3991
+ return send(res, 200, await planFileUpgrade(ctx, name))
3992
+ } catch (error) {
3993
+ return send(res, 200, { ok: false, message: error instanceof Error ? error.message : String(error) })
3994
+ }
3995
+ }
3996
+ if (method === 'GET' && path === '/removable') {
3997
+ return send(res, 200, { ok: true, rows: await listRemovable(ctx) })
3998
+ }
3999
+ if (method === 'POST' && path === '/install') {
4000
+ if (!beginMutation()) {
4001
+ return send(res, 200, { ok: false, message: BUSY_MESSAGE })
4002
+ }
4003
+ try {
4004
+ const body = await readJsonBody(req)
4005
+ return send(res, 200, await actInstall(ctx, body.spec))
4006
+ } finally {
4007
+ endMutation()
4008
+ }
4009
+ }
4010
+ if (method === 'POST' && path === '/uninstall') {
4011
+ if (!beginMutation()) {
4012
+ return send(res, 200, { ok: false, message: BUSY_MESSAGE })
4013
+ }
4014
+ try {
4015
+ const body = await readJsonBody(req)
4016
+ return send(res, 200, await actUninstall(ctx, body.name))
4017
+ } finally {
4018
+ endMutation()
4019
+ }
4020
+ }
4021
+ if (method === 'POST' && path === '/plugin') {
4022
+ if (!beginMutation()) {
4023
+ return send(res, 200, { ok: false, message: BUSY_MESSAGE })
4024
+ }
4025
+ try {
4026
+ return send(res, 200, await actPlugin(ctx, await readJsonBody(req)))
4027
+ } finally {
4028
+ endMutation()
4029
+ }
4030
+ }
4031
+ if (method === 'POST' && path === '/bulk') {
4032
+ const body = await readJsonBody(req)
4033
+ if (body.cancel === true) return send(res, 200, cancelBulk())
4034
+ if (typeof body.enabled !== 'boolean') throw new HttpError(400, 'enabled must be a boolean')
4035
+ const mode = body.save === true ? 'save' : 'live'
4036
+ return send(res, 200, await actBulk(ctx, body.enabled, String(body.scope ?? ''), mode))
4037
+ }
4038
+ if (method === 'POST' && path === '/roster') {
4039
+ const body = await readJsonBody(req)
4040
+ return send(
4041
+ res,
4042
+ 200,
4043
+ await actRoster(ctx, String(body.mode ?? ''), String(body.scope ?? ''), body.auto === true),
4044
+ )
4045
+ }
4046
+ const allowed = ROUTES[path]
4047
+ if (allowed !== undefined) {
4048
+ res.setHeader('allow', allowed.join(', '))
4049
+ return send(res, 405, { ok: false, message: `method ${method} is not allowed on ${path}` })
4050
+ }
4051
+ return send(res, 404, { ok: false, message: `unknown route ${method} ${path}` })
4052
+ }
4053
+
4054
+ export function apply(ctx) {
4055
+ adoptProfile(ctx)
4056
+ ctx.effect(() =>
4057
+ ctx.webServer.register({
4058
+ kind: 'prefix',
4059
+ path: ROUTE_PREFIX,
4060
+ handler: (req, res) =>
4061
+ handle(ctx, req, res).catch(error => {
4062
+ const message = error instanceof Error ? error.message : String(error)
4063
+ const status = error instanceof HttpError ? error.status : 500
4064
+ if (!res.headersSent) send(res, status, { ok: false, message })
4065
+ else if (!res.writableEnded && !res.destroyed) res.end()
4066
+ else void 0
4067
+ }),
4068
+ }),
4069
+ )
4070
+
4071
+ // Queued updates must outlive the panel that asked for them: this timer is the
4072
+ // only thing that turns "downloaded, waiting for a free moment" into "installed".
4073
+ // It is unref'd so a test or a short-lived host never waits on the queue.
4074
+ ctx.effect(() => {
4075
+ const timer = setInterval(() => {
4076
+ pumpQueue(ctx).catch(() => {})
4077
+ }, QUEUE_TICK_MS)
4078
+ if (typeof timer.unref === 'function') timer.unref()
4079
+ return () => clearInterval(timer)
4080
+ })
4081
+ }