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/LICENSE +21 -0
- package/README.md +212 -2
- package/client.js +2867 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +11 -0
- package/index.js +4081 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +73 -4
- package/test/behaviour.mjs +549 -0
- package/test/download.mjs +221 -0
- package/test/job-background.mjs +205 -0
- package/test/pnpm-command.mjs +49 -0
- package/test/self-check.mjs +124 -0
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
|
+
}
|