@qic-9821/dsh-plugin-sync 0.0.0-stage → 0.1.0

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