@qic-9821/dsh-plugin-sync 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 +123 -2
- package/client.js +354 -0
- package/cordis.patch.yml +6 -0
- package/index.js +210 -0
- package/lib/apply.js +120 -0
- package/lib/config.js +228 -0
- package/lib/engine.js +462 -0
- package/lib/http.js +379 -0
- package/lib/merge.js +192 -0
- package/lib/patch.js +192 -0
- package/lib/snapshot.js +117 -0
- package/lib/transfer.js +85 -0
- package/lib/transport.js +168 -0
- package/package.json +57 -3
package/lib/engine.js
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { applyPlan } from './apply.js'
|
|
5
|
+
import {
|
|
6
|
+
CAPABILITY_KEYS,
|
|
7
|
+
PACKAGE_NAME,
|
|
8
|
+
backupDir,
|
|
9
|
+
effectiveConfig,
|
|
10
|
+
ensureStateDirs,
|
|
11
|
+
readState,
|
|
12
|
+
resolveProfileDir,
|
|
13
|
+
saveStatePatch,
|
|
14
|
+
writeState,
|
|
15
|
+
} from './config.js'
|
|
16
|
+
import { describeOps, isExcluded, planMerge } from './merge.js'
|
|
17
|
+
import { buildSnapshot, localDirOf, summarizeSnapshot } from './snapshot.js'
|
|
18
|
+
import { PeerLink } from './transport.js'
|
|
19
|
+
import { packDirectory } from './transfer.js'
|
|
20
|
+
|
|
21
|
+
const MAX_LOG = 200
|
|
22
|
+
|
|
23
|
+
/** Loader rows a peer is never allowed to switch off on this machine. */
|
|
24
|
+
const PROTECTED_ENTRIES = ['include:plugin-sync', 'include:plugin-manager', 'include:loader', 'include:hmr', 'include:web-server']
|
|
25
|
+
|
|
26
|
+
export class Engine {
|
|
27
|
+
constructor({ ctx, config = {}, env = process.env, logger = console } = {}) {
|
|
28
|
+
this.ctx = ctx
|
|
29
|
+
this.rawConfig = config
|
|
30
|
+
this.env = env
|
|
31
|
+
this.logger = logger
|
|
32
|
+
this.lines = []
|
|
33
|
+
this.timers = []
|
|
34
|
+
this.running = null
|
|
35
|
+
this.link = null
|
|
36
|
+
this.version = null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
log(message) {
|
|
40
|
+
const line = `${new Date().toISOString()} ${message}`
|
|
41
|
+
this.lines.push(line)
|
|
42
|
+
if (this.lines.length > MAX_LOG) this.lines.splice(0, this.lines.length - MAX_LOG)
|
|
43
|
+
try {
|
|
44
|
+
this.logger.info?.(`[plugin-sync] ${message}`)
|
|
45
|
+
} catch {
|
|
46
|
+
// ignore logger failures
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
logs(limit = 80) {
|
|
51
|
+
return this.lines.slice(-limit)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
cfg() {
|
|
55
|
+
const dir = ensureStateDirs(this.env)
|
|
56
|
+
const cfg = effectiveConfig({ ...this.rawConfig, __version: this.version }, this.env)
|
|
57
|
+
cfg.__backupDir = join(dir, 'backups')
|
|
58
|
+
cfg.__stateDir = dir
|
|
59
|
+
cfg.__incoming = join(dir, 'incoming')
|
|
60
|
+
return cfg
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
profileDir(cfg = this.cfg()) {
|
|
64
|
+
if (cfg.profileDir && existsSync(cfg.profileDir)) return cfg.profileDir
|
|
65
|
+
return resolveProfileDir(cfg, this.env)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get lastRun() {
|
|
69
|
+
return readState(this.env).lastRun ?? null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async status() {
|
|
73
|
+
const cfg = this.cfg()
|
|
74
|
+
const profileDir = this.profileDir(cfg)
|
|
75
|
+
let local = null
|
|
76
|
+
let localError = null
|
|
77
|
+
try {
|
|
78
|
+
local = summarizeSnapshot(await buildSnapshot({ ctx: this.ctx, cfg, profileDir, includeConfig: cfg.capConfig }))
|
|
79
|
+
} catch (error) {
|
|
80
|
+
localError = error?.message || String(error)
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
enabled: cfg.enabled,
|
|
84
|
+
profileDir,
|
|
85
|
+
peer: { mode: cfg.peerMode, describe: new PeerLink(cfg).describe(), configured: new PeerLink(cfg).configured },
|
|
86
|
+
schedule: {
|
|
87
|
+
autoStart: cfg.autoStart,
|
|
88
|
+
autoRestart: cfg.autoRestart,
|
|
89
|
+
intervalMinutes: cfg.intervalMinutes,
|
|
90
|
+
},
|
|
91
|
+
direction: cfg.direction,
|
|
92
|
+
conflict: cfg.conflict,
|
|
93
|
+
package: { name: PACKAGE_NAME, version: this.version || null },
|
|
94
|
+
capabilities: Object.fromEntries(CAPABILITY_KEYS.map((key) => [key, Boolean(cfg[key])])),
|
|
95
|
+
local,
|
|
96
|
+
localError,
|
|
97
|
+
lastRun: this.lastRun,
|
|
98
|
+
running: Boolean(this.running),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async localSnapshot({ includeConfig = null, includeSources = false } = {}) {
|
|
103
|
+
const cfg = this.cfg()
|
|
104
|
+
return buildSnapshot({
|
|
105
|
+
ctx: this.ctx,
|
|
106
|
+
cfg,
|
|
107
|
+
profileDir: this.profileDir(cfg),
|
|
108
|
+
includeConfig: includeConfig === null ? cfg.capConfig : includeConfig,
|
|
109
|
+
includeSources,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
linkFor(cfg) {
|
|
114
|
+
if (!this.link || this.link.cfg !== cfg) {
|
|
115
|
+
this.link?.dispose()
|
|
116
|
+
this.link = new PeerLink(cfg, { log: (line) => this.log(line) })
|
|
117
|
+
}
|
|
118
|
+
return this.link
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async peerSnapshot({ includeConfig = null, includeSources = false } = {}) {
|
|
122
|
+
const cfg = this.cfg()
|
|
123
|
+
const link = this.linkFor(cfg)
|
|
124
|
+
if (!link.configured) throw new Error('对端未配置')
|
|
125
|
+
return link.call('/plugin-sync/peer/snapshot', {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
body: {
|
|
128
|
+
includeConfig: includeConfig === null ? cfg.capConfig : includeConfig,
|
|
129
|
+
includeSources,
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
optionsFor(cfg) {
|
|
135
|
+
return {
|
|
136
|
+
conflict: cfg.conflict,
|
|
137
|
+
capPlugins: cfg.capPlugins,
|
|
138
|
+
capInstall: cfg.capInstall,
|
|
139
|
+
capConfig: cfg.capConfig,
|
|
140
|
+
capFiles: cfg.capFiles,
|
|
141
|
+
include: cfg.include,
|
|
142
|
+
exclude: cfg.exclude,
|
|
143
|
+
mirror: false,
|
|
144
|
+
selfName: PACKAGE_NAME,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async plan({ direction = null, peer = null } = {}) {
|
|
149
|
+
const cfg = this.cfg()
|
|
150
|
+
const want = direction || cfg.direction
|
|
151
|
+
const local = await this.localSnapshot({ includeSources: cfg.capFiles })
|
|
152
|
+
const remote = peer || (await this.peerSnapshot({ includeSources: cfg.capFiles }))
|
|
153
|
+
const options = this.optionsFor(cfg)
|
|
154
|
+
const out = { direction: want, local: summarizeSnapshot(local), peer: summarizeSnapshot(remote), pushes: null, pulls: null }
|
|
155
|
+
if (want === 'push' || want === 'both') {
|
|
156
|
+
out.pushes = labelPlan(planMerge(local, remote, options))
|
|
157
|
+
}
|
|
158
|
+
if (want === 'pull' || want === 'both') {
|
|
159
|
+
out.pulls = labelPlan(planMerge(remote, local, options))
|
|
160
|
+
}
|
|
161
|
+
return out
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async sync({ trigger = 'manual', direction = null, dryRun = false } = {}) {
|
|
165
|
+
if (this.running) return { ok: false, error: '已有一轮同步在跑', trigger }
|
|
166
|
+
this.running = this.runSync({ trigger, direction, dryRun })
|
|
167
|
+
try {
|
|
168
|
+
return await this.running
|
|
169
|
+
} finally {
|
|
170
|
+
this.running = null
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async runSync({ trigger, direction, dryRun }) {
|
|
175
|
+
const started = Date.now()
|
|
176
|
+
const cfg = this.cfg()
|
|
177
|
+
const result = { trigger, direction: direction || cfg.direction, dryRun, at: new Date().toISOString(), steps: [] }
|
|
178
|
+
if (!cfg.enabled) {
|
|
179
|
+
this.log(`skip (${trigger}): 插件同步已关闭`)
|
|
180
|
+
return { ...result, ok: true, skipped: 'disabled' }
|
|
181
|
+
}
|
|
182
|
+
const want = direction || cfg.direction
|
|
183
|
+
if (want === 'off') {
|
|
184
|
+
this.log(`skip (${trigger}): 只比较不改`)
|
|
185
|
+
return { ...result, ok: true, skipped: 'compare-only' }
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
if (!this.linkFor(cfg).configured) throw new Error('对端未配置(设置 → 插件同步 → 对端)')
|
|
189
|
+
const local = await this.localSnapshot({ includeSources: cfg.capFiles })
|
|
190
|
+
let remote = await this.peerSnapshot({ includeSources: cfg.capFiles })
|
|
191
|
+
const options = this.optionsFor(cfg)
|
|
192
|
+
|
|
193
|
+
if (want === 'push' || want === 'both') {
|
|
194
|
+
const plan = planMerge(local, remote, options)
|
|
195
|
+
this.log(`push plan: ${plan.ops.length} ops (${describeOps(plan.ops).join(', ') || '无'})`)
|
|
196
|
+
if (!dryRun && plan.ops.length > 0) {
|
|
197
|
+
const ready = await this.prepareCopies(cfg, plan.ops, local)
|
|
198
|
+
const applied = await this.linkFor(cfg).call('/plugin-sync/peer/apply', {
|
|
199
|
+
method: 'POST',
|
|
200
|
+
body: { ops: ready, conflict: cfg.conflict, requestedBy: local.node.name },
|
|
201
|
+
})
|
|
202
|
+
result.steps.push({ step: 'push', plan: { ops: plan.ops.length, notes: plan.notes }, applied })
|
|
203
|
+
} else {
|
|
204
|
+
result.steps.push({ step: 'push', plan: { ops: plan.ops.length, notes: plan.notes }, applied: null })
|
|
205
|
+
}
|
|
206
|
+
if (!dryRun && plan.ops.length > 0) remote = await this.peerSnapshot({ includeSources: cfg.capFiles })
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (want === 'pull' || want === 'both') {
|
|
210
|
+
if (!cfg.capRemoteApply && want === 'pull') {
|
|
211
|
+
throw new Error('本机未允许对端改动(同步权限里开启「允许对端改动本机」)')
|
|
212
|
+
}
|
|
213
|
+
const plan = planMerge(remote, local, options)
|
|
214
|
+
this.log(`pull plan: ${plan.ops.length} ops (${describeOps(plan.ops).join(', ') || '无'})`)
|
|
215
|
+
if (!dryRun && plan.ops.length > 0) {
|
|
216
|
+
const ready = await this.prepareCopiesFromPeer(cfg, plan.ops, remote)
|
|
217
|
+
const applied = await applyPlan({
|
|
218
|
+
ctx: this.ctx,
|
|
219
|
+
ops: ready,
|
|
220
|
+
cfg,
|
|
221
|
+
profileDir: this.profileDir(cfg),
|
|
222
|
+
log: (line) => this.log(line),
|
|
223
|
+
})
|
|
224
|
+
result.steps.push({ step: 'pull', plan: { ops: plan.ops.length, notes: plan.notes }, applied })
|
|
225
|
+
} else {
|
|
226
|
+
result.steps.push({ step: 'pull', plan: { ops: plan.ops.length, notes: plan.notes }, applied: null })
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const failedSteps = result.steps.filter((step) => step.applied && step.applied.ok === false)
|
|
231
|
+
const summary = {
|
|
232
|
+
...result,
|
|
233
|
+
ok: failedSteps.length === 0,
|
|
234
|
+
ms: Date.now() - started,
|
|
235
|
+
restartRequired: result.steps.some((step) => step.applied?.restartRequired),
|
|
236
|
+
}
|
|
237
|
+
this.recordRun(summary, cfg)
|
|
238
|
+
this.log(`done (${trigger}) in ${summary.ms}ms ok=${summary.ok}`)
|
|
239
|
+
return summary
|
|
240
|
+
} catch (error) {
|
|
241
|
+
const summary = {
|
|
242
|
+
...result,
|
|
243
|
+
ok: false,
|
|
244
|
+
error: error?.message || String(error),
|
|
245
|
+
ms: Date.now() - started,
|
|
246
|
+
}
|
|
247
|
+
this.recordRun(summary, cfg)
|
|
248
|
+
this.log(`failed (${trigger}): ${summary.error}`)
|
|
249
|
+
return summary
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
recordRun(summary, cfg) {
|
|
254
|
+
try {
|
|
255
|
+
const state = readState(this.env)
|
|
256
|
+
state.lastRun = {
|
|
257
|
+
at: summary.at,
|
|
258
|
+
trigger: summary.trigger,
|
|
259
|
+
ok: summary.ok,
|
|
260
|
+
error: summary.error ?? null,
|
|
261
|
+
ms: summary.ms ?? null,
|
|
262
|
+
direction: summary.direction,
|
|
263
|
+
steps: (summary.steps || []).map((step) => ({
|
|
264
|
+
step: step.step,
|
|
265
|
+
ops: step.plan?.ops ?? 0,
|
|
266
|
+
applied: step.applied?.applied ?? 0,
|
|
267
|
+
failed: step.applied?.failed ?? 0,
|
|
268
|
+
})),
|
|
269
|
+
}
|
|
270
|
+
state.updatedAt = new Date().toISOString()
|
|
271
|
+
void cfg
|
|
272
|
+
writeState(state, this.env)
|
|
273
|
+
} catch (error) {
|
|
274
|
+
this.log(`record failed: ${error?.message || error}`)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Copy local source dirs to the peer for `file:` installs. */
|
|
279
|
+
async prepareCopies(cfg, ops, local) {
|
|
280
|
+
const out = []
|
|
281
|
+
for (const op of ops) {
|
|
282
|
+
if (op.kind !== 'install' || op.source !== 'copy') {
|
|
283
|
+
out.push(op)
|
|
284
|
+
continue
|
|
285
|
+
}
|
|
286
|
+
if (!cfg.capFiles) {
|
|
287
|
+
out.push({ ...op, error: '复制插件二进制权限未开启,跳过安装' })
|
|
288
|
+
continue
|
|
289
|
+
}
|
|
290
|
+
const sourceDir = local.sources?.[op.name]?.path || localDirOf(op.spec)
|
|
291
|
+
if (!sourceDir) {
|
|
292
|
+
out.push(op)
|
|
293
|
+
continue
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
const buffer = await packDirectory(sourceDir, { maxBytes: cfg.maxBundleBytes })
|
|
297
|
+
const uploaded = await this.linkFor(cfg).call('/plugin-sync/peer/transfer', {
|
|
298
|
+
method: 'POST',
|
|
299
|
+
raw: buffer,
|
|
300
|
+
headers: {
|
|
301
|
+
'content-type': 'application/x-tar',
|
|
302
|
+
'x-sync-bundle': encodeURIComponent(op.name),
|
|
303
|
+
},
|
|
304
|
+
timeoutMs: Math.max(cfg.timeoutMs, 120000),
|
|
305
|
+
})
|
|
306
|
+
if (uploaded?.path) out.push({ ...op, spec: uploaded.path, source: 'copied' })
|
|
307
|
+
else out.push({ ...op, error: uploaded?.error || '对端没有返回解包路径' })
|
|
308
|
+
} catch (error) {
|
|
309
|
+
out.push({ ...op, error: error?.message || String(error) })
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return out
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Pull-direction: ask the peer to ship its source dir to us, then install. */
|
|
316
|
+
async prepareCopiesFromPeer(cfg, ops, remote) {
|
|
317
|
+
const out = []
|
|
318
|
+
const link = this.linkFor(cfg)
|
|
319
|
+
for (const op of ops) {
|
|
320
|
+
if (op.kind !== 'install' || op.source !== 'copy') {
|
|
321
|
+
out.push(op)
|
|
322
|
+
continue
|
|
323
|
+
}
|
|
324
|
+
if (!cfg.capFiles) {
|
|
325
|
+
out.push({ ...op, error: '复制插件二进制权限未开启,跳过安装' })
|
|
326
|
+
continue
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
const response = await link.call('/plugin-sync/peer/source', {
|
|
330
|
+
method: 'POST',
|
|
331
|
+
body: { bundle: op.name, spec: op.spec },
|
|
332
|
+
timeoutMs: Math.max(cfg.timeoutMs, 120000),
|
|
333
|
+
})
|
|
334
|
+
if (response?.tarball) {
|
|
335
|
+
const { unpackDirectory } = await import('./transfer.js')
|
|
336
|
+
const path = await unpackDirectory(Buffer.from(response.tarball, 'base64'), cfg.__incoming, op.name)
|
|
337
|
+
out.push({ ...op, spec: path })
|
|
338
|
+
} else if (response?.path) {
|
|
339
|
+
out.push({ ...op, spec: response.path })
|
|
340
|
+
} else {
|
|
341
|
+
out.push({ ...op, error: response?.error || '对端没有返回源码包' })
|
|
342
|
+
}
|
|
343
|
+
} catch (error) {
|
|
344
|
+
out.push({ ...op, error: error?.message || String(error) })
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return out
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
updateState(patch) {
|
|
351
|
+
const state = saveStatePatch(patch, this.env)
|
|
352
|
+
this.log(`state updated: ${Object.keys(patch).join(', ')}`)
|
|
353
|
+
return state
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
schedule() {
|
|
357
|
+
this.disposeTimers()
|
|
358
|
+
const cfg = this.cfg()
|
|
359
|
+
if (cfg.autoStart) {
|
|
360
|
+
const delay = Math.max(0, cfg.startupDelaySec) * 1000
|
|
361
|
+
const timer = setTimeout(() => {
|
|
362
|
+
void this.sync({ trigger: 'startup' })
|
|
363
|
+
}, delay)
|
|
364
|
+
timer.unref?.()
|
|
365
|
+
this.timers.push(timer)
|
|
366
|
+
this.log(`startup sync armed (+${Math.round(delay / 1000)}s)`)
|
|
367
|
+
}
|
|
368
|
+
if (cfg.autoRestart) {
|
|
369
|
+
// On a server the process restart *is* the startup; a second, later pass
|
|
370
|
+
// catches the case where the peer was still booting during the first one.
|
|
371
|
+
const timer = setTimeout(() => {
|
|
372
|
+
void this.sync({ trigger: 'restart' })
|
|
373
|
+
}, Math.max(120000, Math.max(0, cfg.startupDelaySec) * 1000 + 90000))
|
|
374
|
+
timer.unref?.()
|
|
375
|
+
this.timers.push(timer)
|
|
376
|
+
this.log('restart sync armed (+~2min)')
|
|
377
|
+
}
|
|
378
|
+
if (cfg.intervalMinutes > 0) {
|
|
379
|
+
const every = cfg.intervalMinutes * 60000
|
|
380
|
+
const timer = setInterval(() => {
|
|
381
|
+
void this.sync({ trigger: 'interval' })
|
|
382
|
+
}, every)
|
|
383
|
+
timer.unref?.()
|
|
384
|
+
this.timers.push(timer)
|
|
385
|
+
this.log(`interval sync every ${cfg.intervalMinutes}min`)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
disposeTimers() {
|
|
390
|
+
for (const timer of this.timers) {
|
|
391
|
+
try {
|
|
392
|
+
clearTimeout(timer)
|
|
393
|
+
clearInterval(timer)
|
|
394
|
+
} catch {
|
|
395
|
+
// ignore
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
this.timers = []
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
dispose() {
|
|
402
|
+
this.disposeTimers()
|
|
403
|
+
this.link?.dispose()
|
|
404
|
+
this.link = null
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Validate ops coming from a peer before they touch this machine. */
|
|
408
|
+
sanitizeOps(ops, cfg) {
|
|
409
|
+
const allowed = new Set(['install', 'upgrade', 'remove', 'enable', 'disable', 'plugin-enable', 'plugin-disable', 'config'])
|
|
410
|
+
const out = []
|
|
411
|
+
for (const op of Array.isArray(ops) ? ops.slice(0, 100) : []) {
|
|
412
|
+
if (!op || !allowed.has(op.kind)) continue
|
|
413
|
+
const name = op.name || op.moduleName || ''
|
|
414
|
+
if (name && isExcluded(name, { exclude: cfg.exclude, selfName: PACKAGE_NAME })) continue
|
|
415
|
+
if (op.kind === 'install' && typeof op.spec !== 'string') continue
|
|
416
|
+
if (op.error) continue
|
|
417
|
+
if ((op.kind === 'install' || op.kind === 'upgrade' || op.kind === 'remove') && !cfg.capInstall) continue
|
|
418
|
+
if (op.kind === 'config' && !cfg.capConfig) continue
|
|
419
|
+
if (op.kind === 'plugin-enable' || op.kind === 'plugin-disable') {
|
|
420
|
+
// A peer may not switch off the rows this machine is built on.
|
|
421
|
+
if (typeof op.entryId !== 'string' || !op.entryId) continue
|
|
422
|
+
if (PROTECTED_ENTRIES.some((entry) => op.entryId === entry || op.entryId.startsWith(`${entry}:`))) continue
|
|
423
|
+
}
|
|
424
|
+
if (!cfg.capPlugins && op.kind !== 'install' && op.kind !== 'upgrade' && op.kind !== 'remove' && op.kind !== 'config') continue
|
|
425
|
+
out.push(op)
|
|
426
|
+
}
|
|
427
|
+
return out
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function labelPlan(plan) {
|
|
432
|
+
return {
|
|
433
|
+
ops: plan.ops.map((op) => ({ ...op, label: describeOp(op) })),
|
|
434
|
+
notes: plan.notes,
|
|
435
|
+
stats: plan.stats,
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function describeOp(op) {
|
|
440
|
+
const labels = {
|
|
441
|
+
install: '安装',
|
|
442
|
+
upgrade: '升级',
|
|
443
|
+
remove: '卸载',
|
|
444
|
+
enable: '启用',
|
|
445
|
+
disable: '停用',
|
|
446
|
+
'plugin-enable': '启用条目',
|
|
447
|
+
'plugin-disable': '停用条目',
|
|
448
|
+
config: '写配置',
|
|
449
|
+
}
|
|
450
|
+
const name = op.name || op.moduleName || op.entryId || ''
|
|
451
|
+
const extra = op.kind === 'upgrade' ? ` ${op.from ?? ''} → ${op.version ?? ''}` : op.error ? ` ⚠ ${op.error}` : ''
|
|
452
|
+
return `${labels[op.kind] || op.kind} ${name}${extra}`.trim()
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function readVersion(url) {
|
|
456
|
+
try {
|
|
457
|
+
const text = readFileSync(new URL(url), 'utf8')
|
|
458
|
+
return JSON.parse(text).version ?? null
|
|
459
|
+
} catch {
|
|
460
|
+
return null
|
|
461
|
+
}
|
|
462
|
+
}
|