@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/LICENSE +21 -0
- package/README.md +123 -2
- package/client.js +353 -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 +461 -0
- package/lib/http.js +378 -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/http.js
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { applyPlan } from './apply.js'
|
|
5
|
+
import {
|
|
6
|
+
CAPABILITY_LABELS,
|
|
7
|
+
DIRECTION_LABELS,
|
|
8
|
+
ensureStateDirs,
|
|
9
|
+
readState,
|
|
10
|
+
} from './config.js'
|
|
11
|
+
import { isExcluded } from './merge.js'
|
|
12
|
+
import { buildSnapshot, localDirOf } from './snapshot.js'
|
|
13
|
+
import { packDirectory, unpackDirectory } from './transfer.js'
|
|
14
|
+
|
|
15
|
+
const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8' }
|
|
16
|
+
|
|
17
|
+
export function createRoutes({ engine, ctx, origin = '' }) {
|
|
18
|
+
async function handle(req, res) {
|
|
19
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1')
|
|
20
|
+
const path = url.pathname
|
|
21
|
+
const cfg = engine.cfg()
|
|
22
|
+
const peerRequest = isPeerCall(req, cfg)
|
|
23
|
+
const localOk = isLocalCall(req, origin)
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
if (path === '/plugin-sync' || path === '/plugin-sync/') {
|
|
27
|
+
if (!localOk) return send(res, 403, { error: 'forbidden' })
|
|
28
|
+
return sendHtml(res, PAGE)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (path.startsWith('/plugin-sync/api/')) {
|
|
32
|
+
if (!localOk && !peerRequest) return send(res, 403, { error: 'forbidden' })
|
|
33
|
+
const action = path.slice('/plugin-sync/api/'.length)
|
|
34
|
+
if (action === 'state' && req.method === 'GET') {
|
|
35
|
+
return send(res, 200, await statePayload(engine, cfg))
|
|
36
|
+
}
|
|
37
|
+
if (action === 'config' && req.method === 'POST') {
|
|
38
|
+
const body = await readJson(req)
|
|
39
|
+
return send(res, 200, await saveConfig(engine, body))
|
|
40
|
+
}
|
|
41
|
+
if (action === 'sync' && req.method === 'POST') {
|
|
42
|
+
const body = await readJson(req)
|
|
43
|
+
const result = await engine.sync({
|
|
44
|
+
trigger: 'manual',
|
|
45
|
+
direction: typeof body.direction === 'string' ? body.direction : null,
|
|
46
|
+
dryRun: Boolean(body.dryRun),
|
|
47
|
+
})
|
|
48
|
+
return send(res, 200, { ...result, status: await engine.status() })
|
|
49
|
+
}
|
|
50
|
+
if (action === 'plan' && req.method === 'GET') {
|
|
51
|
+
return send(res, 200, await engine.plan({ direction: url.searchParams.get('direction') || null }))
|
|
52
|
+
}
|
|
53
|
+
if (action === 'snapshot' && req.method === 'GET') {
|
|
54
|
+
return send(res, 200, await engine.localSnapshot({ includeSources: cfg.capFiles }))
|
|
55
|
+
}
|
|
56
|
+
if (action === 'logs' && req.method === 'GET') {
|
|
57
|
+
return send(res, 200, { lines: engine.logs(Number(url.searchParams.get('limit')) || 80) })
|
|
58
|
+
}
|
|
59
|
+
return send(res, 404, { error: `unknown api: ${action}` })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (path.startsWith('/plugin-sync/peer/')) {
|
|
63
|
+
if (!peerRequest) return send(res, 403, { error: 'forbidden: peer token required' })
|
|
64
|
+
const action = path.slice('/plugin-sync/peer/'.length)
|
|
65
|
+
return await handlePeer({ action, req, res, engine, ctx, cfg })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return send(res, 404, { error: 'not found' })
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return send(res, 500, { error: error?.message || String(error) })
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { handle }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function handlePeer({ action, req, res, engine, ctx, cfg }) {
|
|
78
|
+
if (action === 'ping') {
|
|
79
|
+
return send(res, 200, { ok: true, node: engine.cfg().peerName || null, at: new Date().toISOString() })
|
|
80
|
+
}
|
|
81
|
+
if (action === 'snapshot') {
|
|
82
|
+
if (!cfg.capRemoteApply) return send(res, 403, { error: '本机未允许对端读取(允许对端改动本机未开启)' })
|
|
83
|
+
const body = req.method === 'POST' ? await readJson(req) : {}
|
|
84
|
+
const snapshot = await engine.localSnapshot({
|
|
85
|
+
includeConfig: body.includeConfig === undefined ? cfg.capConfig : Boolean(body.includeConfig),
|
|
86
|
+
includeSources: Boolean(body.includeSources),
|
|
87
|
+
})
|
|
88
|
+
return send(res, 200, snapshot)
|
|
89
|
+
}
|
|
90
|
+
if (action === 'apply') {
|
|
91
|
+
if (!cfg.capRemoteApply) return send(res, 403, { error: '本机未允许对端改动(允许对端改动本机未开启)' })
|
|
92
|
+
const body = await readJson(req)
|
|
93
|
+
const ops = engine.sanitizeOps(body.ops, cfg)
|
|
94
|
+
const result = await applyPlan({
|
|
95
|
+
ctx,
|
|
96
|
+
ops,
|
|
97
|
+
cfg,
|
|
98
|
+
profileDir: engine.profileDir(cfg),
|
|
99
|
+
log: (line) => engine.log(line),
|
|
100
|
+
})
|
|
101
|
+
engine.log(`peer applied ${result.applied}/${ops.length} ops (failed ${result.failed})`)
|
|
102
|
+
return send(res, 200, { ...result, received: Array.isArray(body.ops) ? body.ops.length : 0 })
|
|
103
|
+
}
|
|
104
|
+
if (action === 'transfer') {
|
|
105
|
+
if (!cfg.capFiles) return send(res, 403, { error: '本机未开启复制插件二进制' })
|
|
106
|
+
const bundle = decodeURIComponent(String(req.headers['x-sync-bundle'] || 'bundle'))
|
|
107
|
+
const buffer = await readBody(req, cfg.maxBundleBytes)
|
|
108
|
+
const incoming = join(ensureStateDirs(), 'incoming')
|
|
109
|
+
const path = await unpackDirectory(buffer, incoming, bundle)
|
|
110
|
+
engine.log(`peer transfer received ${bundle} (${buffer.length}B) → ${path}`)
|
|
111
|
+
return send(res, 200, { ok: true, path, bundle })
|
|
112
|
+
}
|
|
113
|
+
if (action === 'source') {
|
|
114
|
+
if (!cfg.capFiles) return send(res, 403, { error: '本机未开启复制插件二进制' })
|
|
115
|
+
const body = await readJson(req)
|
|
116
|
+
const snapshot = await engine.localSnapshot({ includeSources: true })
|
|
117
|
+
const bundle = String(body.bundle || '')
|
|
118
|
+
const dir = snapshot.sources?.[bundle]?.path || localDirOf(body.spec)
|
|
119
|
+
if (!dir || !existsSync(dir)) return send(res, 200, { error: `本机找不到 ${bundle} 的源码目录` })
|
|
120
|
+
const buffer = await packDirectory(dir, { maxBytes: Math.min(cfg.maxBundleBytes, 16 * 1024 * 1024) })
|
|
121
|
+
return send(res, 200, { name: bundle, tarball: buffer.toString('base64') })
|
|
122
|
+
}
|
|
123
|
+
return send(res, 404, { error: `unknown peer api: ${action}` })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function statePayload(engine, cfg) {
|
|
127
|
+
const status = await engine.status()
|
|
128
|
+
return {
|
|
129
|
+
status,
|
|
130
|
+
config: {
|
|
131
|
+
enabled: cfg.enabled,
|
|
132
|
+
autoStart: cfg.autoStart,
|
|
133
|
+
autoRestart: cfg.autoRestart,
|
|
134
|
+
intervalMinutes: cfg.intervalMinutes,
|
|
135
|
+
startupDelaySec: cfg.startupDelaySec,
|
|
136
|
+
direction: cfg.direction,
|
|
137
|
+
conflict: cfg.conflict,
|
|
138
|
+
peerMode: cfg.peerMode,
|
|
139
|
+
peerUrl: cfg.peerUrl,
|
|
140
|
+
peerName: cfg.peerName,
|
|
141
|
+
localName: cfg.localName,
|
|
142
|
+
peerHost: cfg.peerHost,
|
|
143
|
+
peerUser: cfg.peerUser,
|
|
144
|
+
peerPort: cfg.peerPort,
|
|
145
|
+
peerHttpPort: cfg.peerHttpPort,
|
|
146
|
+
peerKey: cfg.peerKey,
|
|
147
|
+
peerTokenSet: Boolean(cfg.peerToken),
|
|
148
|
+
capabilities: Object.fromEntries(
|
|
149
|
+
Object.keys(CAPABILITY_LABELS).map((key) => [key, Boolean(cfg[key])]),
|
|
150
|
+
),
|
|
151
|
+
include: cfg.include,
|
|
152
|
+
exclude: cfg.exclude,
|
|
153
|
+
},
|
|
154
|
+
capabilityCatalog: Object.entries(CAPABILITY_LABELS).map(([key, [label, desc, en]]) => ({
|
|
155
|
+
key,
|
|
156
|
+
label,
|
|
157
|
+
desc,
|
|
158
|
+
en,
|
|
159
|
+
on: Boolean(cfg[key]),
|
|
160
|
+
})),
|
|
161
|
+
directionCatalog: Object.entries(DIRECTION_LABELS).map(([key, [label, en]]) => ({ key, label, en })),
|
|
162
|
+
updatedAt: readState().updatedAt ?? null,
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function saveConfig(engine, body = {}) {
|
|
167
|
+
const patch = {}
|
|
168
|
+
const bools = ['enabled', 'autoStart', 'autoRestart', 'capPlugins', 'capConfig', 'capInstall', 'capFiles', 'capRemoteApply', 'capSecrets']
|
|
169
|
+
for (const key of bools) if (typeof body[key] === 'boolean') patch[key] = body[key]
|
|
170
|
+
for (const [key, value] of Object.entries(body.capabilities || {})) {
|
|
171
|
+
if (key in CAPABILITY_LABELS && typeof value === 'boolean') patch[key] = value
|
|
172
|
+
}
|
|
173
|
+
const numbers = ['intervalMinutes', 'startupDelaySec', 'peerPort', 'peerHttpPort']
|
|
174
|
+
for (const key of numbers) {
|
|
175
|
+
if (body[key] !== undefined && Number.isFinite(Number(body[key]))) patch[key] = Number(body[key])
|
|
176
|
+
}
|
|
177
|
+
const strings = ['direction', 'conflict', 'peerMode', 'peerUrl', 'peerName', 'localName', 'peerHost', 'peerUser', 'peerKey']
|
|
178
|
+
for (const key of strings) if (typeof body[key] === 'string') patch[key] = body[key].trim()
|
|
179
|
+
if (typeof body.peerToken === 'string' && body.peerToken.length > 0) patch.peerToken = body.peerToken
|
|
180
|
+
if (body.clearPeerToken === true) patch.peerToken = ''
|
|
181
|
+
if (Array.isArray(body.include)) patch.include = body.include.map(String).filter(Boolean)
|
|
182
|
+
if (Array.isArray(body.exclude)) patch.exclude = body.exclude.map(String).filter(Boolean)
|
|
183
|
+
engine.updateState(patch)
|
|
184
|
+
engine.schedule()
|
|
185
|
+
const cfg = engine.cfg()
|
|
186
|
+
return statePayload(engine, cfg)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function isPeerCall(req, cfg) {
|
|
190
|
+
const token = req.headers['x-plugin-sync-token']
|
|
191
|
+
if (!cfg.peerToken || typeof token !== 'string') return false
|
|
192
|
+
return token === cfg.peerToken
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function isLocalCall(req, origin) {
|
|
196
|
+
const requestOrigin = req.headers.origin
|
|
197
|
+
if (!requestOrigin) return true
|
|
198
|
+
if (origin && requestOrigin === origin) return true
|
|
199
|
+
try {
|
|
200
|
+
const host = req.headers.host
|
|
201
|
+
return new URL(requestOrigin).host === host
|
|
202
|
+
} catch {
|
|
203
|
+
return false
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function send(res, status, body) {
|
|
208
|
+
const text = `${JSON.stringify(body)}\n`
|
|
209
|
+
res.writeHead(status, { ...JSON_HEADERS, 'content-length': Buffer.byteLength(text) })
|
|
210
|
+
res.end(text)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function sendHtml(res, body) {
|
|
214
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
|
215
|
+
res.end(body)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function readJson(req) {
|
|
219
|
+
const buffer = await readBody(req, 32 * 1024 * 1024)
|
|
220
|
+
if (!buffer.length) return {}
|
|
221
|
+
try {
|
|
222
|
+
return JSON.parse(buffer.toString('utf8'))
|
|
223
|
+
} catch {
|
|
224
|
+
return {}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function readBody(req, limit) {
|
|
229
|
+
return new Promise((resolve, reject) => {
|
|
230
|
+
const chunks = []
|
|
231
|
+
let size = 0
|
|
232
|
+
req.on('data', (chunk) => {
|
|
233
|
+
size += chunk.length
|
|
234
|
+
if (size > limit * 1.5) {
|
|
235
|
+
reject(new Error('请求体过大'))
|
|
236
|
+
req.destroy()
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
chunks.push(chunk)
|
|
240
|
+
})
|
|
241
|
+
req.on('end', () => resolve(Buffer.concat(chunks)))
|
|
242
|
+
req.on('error', reject)
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Standalone panel: same data as the settings section, opened at /plugin-sync. */
|
|
247
|
+
const PAGE = `<!doctype html>
|
|
248
|
+
<html lang="zh-CN"><head>
|
|
249
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
250
|
+
<title>插件同步 · dsh-plugin-sync</title>
|
|
251
|
+
<style>
|
|
252
|
+
:root{color-scheme:light dark;--fg:#1c1c1e;--muted:#6b6b70;--bg:#f6f6f7;--card:#fff;--line:#e3e3e6;--accent:#3b82f6;--ok:#10a37f;--bad:#d9534f}
|
|
253
|
+
@media (prefers-color-scheme:dark){:root{--fg:#ececf1;--muted:#9a9aa2;--bg:#17171a;--card:#1f1f23;--line:#303036}}
|
|
254
|
+
*{box-sizing:border-box}
|
|
255
|
+
body{margin:0;padding:24px;background:var(--bg);color:var(--fg);font:14px/1.55 system-ui,-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
|
|
256
|
+
h1{font-size:18px;margin:0 0 4px}.sub{color:var(--muted);margin-bottom:18px}
|
|
257
|
+
.grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));max-width:1180px}
|
|
258
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px}
|
|
259
|
+
.card.wide{grid-column:span 2}
|
|
260
|
+
@media (max-width:760px){.card.wide{grid-column:1/-1}}
|
|
261
|
+
.card h2{font-size:14px;margin:0 0 12px;font-weight:600}
|
|
262
|
+
.row{display:flex;justify-content:space-between;gap:12px;padding:5px 0;border-bottom:1px dashed var(--line)}
|
|
263
|
+
.row:last-child{border-bottom:none}
|
|
264
|
+
.k{color:var(--muted)}.v{text-align:right;word-break:break-all}
|
|
265
|
+
button{font:inherit;padding:7px 14px;border-radius:8px;border:1px solid var(--line);background:transparent;color:var(--fg);cursor:pointer}
|
|
266
|
+
button.primary{background:var(--accent);border-color:var(--accent);color:#fff}
|
|
267
|
+
button:disabled{opacity:.5;cursor:default}
|
|
268
|
+
input,select{font:inherit;padding:6px 8px;border-radius:8px;border:1px solid var(--line);background:transparent;color:var(--fg);width:100%}
|
|
269
|
+
input[type=checkbox]{width:auto;flex:0 0 auto}
|
|
270
|
+
label.swap{display:flex;align-items:flex-start;gap:10px;padding:6px 0;border-bottom:1px dashed var(--line);cursor:pointer}
|
|
271
|
+
label.swap span{flex:1}
|
|
272
|
+
label.swap:last-child{border-bottom:none}
|
|
273
|
+
.muted{color:var(--muted);font-size:12.5px}
|
|
274
|
+
.ok{color:var(--ok)}.bad{color:var(--bad)}
|
|
275
|
+
pre{white-space:pre-wrap;word-break:break-word;background:rgba(127,127,127,.1);padding:10px;border-radius:8px;max-height:260px;overflow:auto;font-size:12px}
|
|
276
|
+
</style></head><body>
|
|
277
|
+
<h1>插件同步</h1>
|
|
278
|
+
<div class="sub">把插件清单、配置、启停状态、包安装与本地插件目录在两台机器之间对齐。设置页里也能改这里的一切。</div>
|
|
279
|
+
<div class="grid">
|
|
280
|
+
<div class="card"><h2>状态</h2><div id="status">读取中…</div>
|
|
281
|
+
<div style="display:flex;gap:8px;margin-top:12px">
|
|
282
|
+
<button class="primary" id="sync">立即同步</button>
|
|
283
|
+
<button id="plan">只看计划</button>
|
|
284
|
+
<button id="reload">刷新</button>
|
|
285
|
+
</div></div>
|
|
286
|
+
<div class="card"><h2>对端</h2>
|
|
287
|
+
<label class="muted">方式</label><select id="peerMode">
|
|
288
|
+
<option value="off">未配置</option><option value="http">HTTP</option><option value="ssh">SSH 隧道</option></select>
|
|
289
|
+
<div class="muted" style="margin-top:8px">HTTP 地址</div><input id="peerUrl" placeholder="http://127.0.0.1:18777">
|
|
290
|
+
<div class="muted" style="margin-top:8px">SSH 主机 / 用户 / 端口</div>
|
|
291
|
+
<div style="display:grid;grid-template-columns:2fr 1fr 80px;gap:6px"><input id="peerHost" placeholder="114.55.146.70"><input id="peerUser" placeholder="dsh"><input id="peerPort" placeholder="22"></div>
|
|
292
|
+
<div class="muted" style="margin-top:8px">对端 Web 端口 / 私钥 / 令牌</div>
|
|
293
|
+
<div style="display:grid;grid-template-columns:100px 1fr;gap:6px"><input id="peerHttpPort" placeholder="18777"><input id="peerKey" placeholder="C:\\Users\\QIC\\.ssh\\dsh_cloud_ed25519"></div>
|
|
294
|
+
<input id="peerToken" placeholder="对端令牌(留空不改)" style="margin-top:6px">
|
|
295
|
+
</div>
|
|
296
|
+
<div class="card"><h2>同步方式</h2>
|
|
297
|
+
<label class="muted">方向</label><select id="direction"></select>
|
|
298
|
+
<div class="muted" style="margin-top:8px">冲突策略(启停状态 / 版本)</div>
|
|
299
|
+
<select id="conflict"><option value="local">以发起方为准</option><option value="peer">以接收方为准</option><option value="newest">版本新者优先</option></select>
|
|
300
|
+
<div class="muted" style="margin-top:8px">时机</div>
|
|
301
|
+
<label class="swap"><input type="checkbox" id="autoStart"> 软件启动时同步</label>
|
|
302
|
+
<label class="swap"><input type="checkbox" id="autoRestart"> 服务器重启后同步</label>
|
|
303
|
+
<label class="swap"><input type="checkbox" id="enabled"> 总开关</label>
|
|
304
|
+
<div class="muted" style="margin-top:8px">每隔多少分钟(0 = 不按间隔)</div><input id="intervalMinutes" type="number" min="0" max="1440">
|
|
305
|
+
</div>
|
|
306
|
+
<div class="card wide"><h2>权限</h2><div id="caps"></div></div>
|
|
307
|
+
<div class="card" style="grid-column:1/-1"><h2>计划 / 日志</h2><pre id="out">—</pre></div>
|
|
308
|
+
</div>
|
|
309
|
+
<script>
|
|
310
|
+
const $ = (id) => document.getElementById(id)
|
|
311
|
+
let state = null
|
|
312
|
+
async function api(path, options) {
|
|
313
|
+
const res = await fetch('/plugin-sync/api/' + path, options)
|
|
314
|
+
const text = await res.text()
|
|
315
|
+
try { return JSON.parse(text) } catch { return { error: text } }
|
|
316
|
+
}
|
|
317
|
+
function render() {
|
|
318
|
+
if (!state) return
|
|
319
|
+
const s = state.status
|
|
320
|
+
$('status').innerHTML = [
|
|
321
|
+
['本机', s.local ? (s.local.node || '?') + ' · ' + (s.local.profile || '') + ' · ' + s.local.bundles + ' 包 / ' + s.local.plugins + ' 条目' : '读取失败'],
|
|
322
|
+
['profile', s.profileDir || '?'],
|
|
323
|
+
['对端', s.peer.describe],
|
|
324
|
+
['上次同步', s.lastRun ? (s.lastRun.at + ' · ' + (s.lastRun.ok ? '成功' : '失败:' + (s.lastRun.error || ''))) : '还没跑过'],
|
|
325
|
+
['当前', s.running ? '同步中…' : '空闲'],
|
|
326
|
+
].map(([k, v]) => '<div class="row"><span class="k">' + k + '</span><span class="v">' + escapeHtml(String(v)) + '</span></div>').join('')
|
|
327
|
+
const c = state.config
|
|
328
|
+
$('peerMode').value = c.peerMode
|
|
329
|
+
$('peerUrl').value = c.peerUrl || ''
|
|
330
|
+
$('peerHost').value = c.peerHost || ''
|
|
331
|
+
$('peerUser').value = c.peerUser || ''
|
|
332
|
+
$('peerPort').value = c.peerPort
|
|
333
|
+
$('peerHttpPort').value = c.peerHttpPort
|
|
334
|
+
$('peerKey').value = c.peerKey || ''
|
|
335
|
+
$('peerToken').placeholder = c.peerTokenSet ? '令牌已设置(留空不改)' : '对端令牌'
|
|
336
|
+
$('autoStart').checked = !!c.autoStart
|
|
337
|
+
$('autoRestart').checked = !!c.autoRestart
|
|
338
|
+
$('enabled').checked = !!c.enabled
|
|
339
|
+
$('intervalMinutes').value = c.intervalMinutes
|
|
340
|
+
$('conflict').value = c.conflict
|
|
341
|
+
$('direction').innerHTML = state.directionCatalog.map((d) => '<option value="' + d.key + '"' + (d.key === c.direction ? ' selected' : '') + '>' + d.label + '</option>').join('')
|
|
342
|
+
$('caps').innerHTML = state.capabilityCatalog.map((cap) =>
|
|
343
|
+
'<label class="swap"><input type="checkbox" data-cap="' + cap.key + '"' + (cap.on ? ' checked' : '') + '>' +
|
|
344
|
+
'<span><b>' + cap.label + '</b><br><span class="muted">' + cap.desc + '</span></span></label>').join('')
|
|
345
|
+
}
|
|
346
|
+
async function load() {
|
|
347
|
+
state = await api('state')
|
|
348
|
+
render()
|
|
349
|
+
}
|
|
350
|
+
async function save(patch) {
|
|
351
|
+
state = await api('config', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(patch) })
|
|
352
|
+
render()
|
|
353
|
+
}
|
|
354
|
+
function collect() {
|
|
355
|
+
const caps = {}
|
|
356
|
+
document.querySelectorAll('[data-cap]').forEach((el) => { caps[el.dataset.cap] = el.checked })
|
|
357
|
+
return {
|
|
358
|
+
peerMode: $('peerMode').value, peerUrl: $('peerUrl').value, peerHost: $('peerHost').value,
|
|
359
|
+
peerUser: $('peerUser').value, peerPort: Number($('peerPort').value) || 22,
|
|
360
|
+
peerHttpPort: Number($('peerHttpPort').value) || 18777, peerKey: $('peerKey').value,
|
|
361
|
+
peerToken: $('peerToken').value, autoStart: $('autoStart').checked, autoRestart: $('autoRestart').checked,
|
|
362
|
+
enabled: $('enabled').checked, intervalMinutes: Number($('intervalMinutes').value) || 0,
|
|
363
|
+
conflict: $('conflict').value, direction: $('direction').value, capabilities: caps,
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
document.addEventListener('change', (event) => {
|
|
367
|
+
const target = event.target
|
|
368
|
+
if (target.id === 'peerToken') return
|
|
369
|
+
if (target.matches('input,select')) void save(collect())
|
|
370
|
+
})
|
|
371
|
+
$('peerToken').addEventListener('blur', () => { if ($('peerToken').value) { void save(collect()); $('peerToken').value = '' } })
|
|
372
|
+
$('sync').addEventListener('click', async () => { $('out').textContent = '同步中…'; const r = await api('sync', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }); $('out').textContent = JSON.stringify(r, null, 2); state.status = r.status || state.status; render() })
|
|
373
|
+
$('plan').addEventListener('click', async () => { $('out').textContent = '计算中…'; $('out').textContent = JSON.stringify(await api('plan'), null, 2) })
|
|
374
|
+
$('reload').addEventListener('click', () => { void load() })
|
|
375
|
+
function escapeHtml(text) { return text.replace(/[&<>"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[ch])) }
|
|
376
|
+
void load()
|
|
377
|
+
</script>
|
|
378
|
+
</body></html>`
|
package/lib/merge.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/** Pure merge planner: computes the ops that make `target` look like `source`. */
|
|
2
|
+
|
|
3
|
+
export const PROTECTED_BUNDLES = [
|
|
4
|
+
'@deepseek-ai/dsh-base',
|
|
5
|
+
'@deepseek-ai/dsh-web-app',
|
|
6
|
+
'@qic-9821/dsh-plugin-sync',
|
|
7
|
+
'dsh-plugin-sync',
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
// Names this very plugin has shipped under; it never syncs (or removes) itself.
|
|
11
|
+
export const SELF_NAMES = ['@qic-9821/dsh-plugin-sync', 'dsh-plugin-sync']
|
|
12
|
+
|
|
13
|
+
// The host's own rows and bundles are platform- and build-specific (a Windows
|
|
14
|
+
// desktop and a Linux server legitimately run different sandboxes, telemetry
|
|
15
|
+
// and analytics rows). They are never synced unless the user names one in
|
|
16
|
+
// `include` on purpose.
|
|
17
|
+
export const CORE_PREFIX = '@deepseek-ai/'
|
|
18
|
+
|
|
19
|
+
export function compareVersions(left, right) {
|
|
20
|
+
const parse = (value) =>
|
|
21
|
+
String(value ?? '')
|
|
22
|
+
.replace(/^v/, '')
|
|
23
|
+
.split(/[.+-]/)
|
|
24
|
+
.slice(0, 3)
|
|
25
|
+
.map((part) => {
|
|
26
|
+
const n = Number.parseInt(part, 10)
|
|
27
|
+
return Number.isFinite(n) ? n : 0
|
|
28
|
+
})
|
|
29
|
+
const a = parse(left)
|
|
30
|
+
const b = parse(right)
|
|
31
|
+
for (let index = 0; index < 3; index += 1) {
|
|
32
|
+
const diff = (a[index] ?? 0) - (b[index] ?? 0)
|
|
33
|
+
if (diff !== 0) return diff > 0 ? 1 : -1
|
|
34
|
+
}
|
|
35
|
+
return 0
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isExcluded(name, options = {}) {
|
|
39
|
+
const patterns = [...(options.exclude || [])]
|
|
40
|
+
const include = [...(options.include || [])]
|
|
41
|
+
if (include.includes(name)) return false
|
|
42
|
+
const protectedNames = [...PROTECTED_BUNDLES, ...(options.protected || [])]
|
|
43
|
+
if (protectedNames.includes(name)) return true
|
|
44
|
+
if (SELF_NAMES.includes(name)) return true
|
|
45
|
+
if (options.selfName && options.selfName === name) return true
|
|
46
|
+
if (
|
|
47
|
+
String(name || '').startsWith(CORE_PREFIX) &&
|
|
48
|
+
!include.some((pattern) => pattern.endsWith('*') && String(name).startsWith(pattern.slice(0, -1)))
|
|
49
|
+
) {
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
return patterns.some((pattern) => {
|
|
53
|
+
const raw = String(pattern || '').trim()
|
|
54
|
+
if (!raw) return false
|
|
55
|
+
if (raw.endsWith('*')) return name.startsWith(raw.slice(0, -1))
|
|
56
|
+
return name === raw
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isSecretFreeConfig(value) {
|
|
61
|
+
return value && typeof value === 'object' && !('__raw' in value)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function planMerge(source, target, options = {}) {
|
|
65
|
+
const conflict = ['local', 'peer', 'newest'].includes(options.conflict) ? options.conflict : 'local'
|
|
66
|
+
const caps = {
|
|
67
|
+
plugins: options.capPlugins !== false,
|
|
68
|
+
install: options.capInstall === true,
|
|
69
|
+
config: options.capConfig === true,
|
|
70
|
+
files: options.capFiles === true,
|
|
71
|
+
}
|
|
72
|
+
const sourceBundles = new Map((source?.bundles || []).map((bundle) => [bundle.name, bundle]))
|
|
73
|
+
const targetBundles = new Map((target?.bundles || []).map((bundle) => [bundle.name, bundle]))
|
|
74
|
+
const sourcePlugins = new Map((source?.plugins || []).map((plugin) => [plugin.entryId, plugin]))
|
|
75
|
+
const targetPlugins = new Map((target?.plugins || []).map((plugin) => [plugin.entryId, plugin]))
|
|
76
|
+
const ops = []
|
|
77
|
+
const notes = []
|
|
78
|
+
|
|
79
|
+
for (const [name, bundle] of sourceBundles) {
|
|
80
|
+
if (isExcluded(name, options)) continue
|
|
81
|
+
if ((options.include || []).length > 0 && !(options.include || []).includes(name)) continue
|
|
82
|
+
const current = targetBundles.get(name)
|
|
83
|
+
if (!current) {
|
|
84
|
+
if (!caps.install) {
|
|
85
|
+
notes.push({ kind: 'skip', target: name, reason: '安装权限未开启(安装/升级/卸载)' })
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
ops.push({
|
|
89
|
+
kind: 'install',
|
|
90
|
+
name,
|
|
91
|
+
spec: bundle.spec,
|
|
92
|
+
version: bundle.version,
|
|
93
|
+
enabled: bundle.enabled,
|
|
94
|
+
source: bundle.spec && bundle.spec.startsWith('file:') ? 'copy' : 'remote',
|
|
95
|
+
})
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
if (bundle.version && current.version && bundle.version !== current.version) {
|
|
99
|
+
if (!caps.install) {
|
|
100
|
+
notes.push({ kind: 'skip', target: name, reason: `版本不同(${current.version} → ${bundle.version}),安装权限未开启` })
|
|
101
|
+
} else if (conflict === 'newest' && compareVersions(bundle.version, current.version) <= 0) {
|
|
102
|
+
notes.push({ kind: 'skip', target: name, reason: `对端版本更新(${current.version}),newest 策略不改` })
|
|
103
|
+
} else {
|
|
104
|
+
ops.push({ kind: 'upgrade', name, version: bundle.version, from: current.version })
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (Boolean(bundle.enabled) !== Boolean(current.enabled) && conflict !== 'peer') {
|
|
108
|
+
if (!caps.plugins) {
|
|
109
|
+
notes.push({ kind: 'skip', target: name, reason: '插件启停权限未开启' })
|
|
110
|
+
} else {
|
|
111
|
+
ops.push({ kind: bundle.enabled ? 'enable' : 'disable', name })
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (conflict === 'peer' && targetBundles.has(name)) {
|
|
115
|
+
// keep the receiving side's enable state: nothing to do
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (options.mirror === true) {
|
|
120
|
+
for (const name of targetBundles.keys()) {
|
|
121
|
+
if (sourceBundles.has(name)) continue
|
|
122
|
+
if (isExcluded(name, options)) continue
|
|
123
|
+
if (!caps.install) {
|
|
124
|
+
notes.push({ kind: 'skip', target: name, reason: '目标端多出的插件未删除(安装权限未开启)' })
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
ops.push({ kind: 'remove', name })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (caps.plugins && conflict !== 'peer') {
|
|
132
|
+
for (const [entryId, plugin] of sourcePlugins) {
|
|
133
|
+
const current = targetPlugins.get(entryId)
|
|
134
|
+
if (!current) continue
|
|
135
|
+
if (isExcluded(plugin.moduleName, options)) continue
|
|
136
|
+
if (Boolean(current.enabled) === Boolean(plugin.enabled)) continue
|
|
137
|
+
ops.push({ kind: plugin.enabled ? 'plugin-enable' : 'plugin-disable', entryId, moduleName: plugin.moduleName })
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (caps.config) {
|
|
142
|
+
const sourceConfigs = source?.configs || {}
|
|
143
|
+
const targetConfigs = target?.configs || {}
|
|
144
|
+
for (const [moduleName, entry] of Object.entries(sourceConfigs)) {
|
|
145
|
+
if (isExcluded(moduleName, options)) continue
|
|
146
|
+
if (!isSecretFreeConfig(entry?.config)) continue
|
|
147
|
+
const current = targetConfigs[moduleName]
|
|
148
|
+
if (current && deepEqual(current.config, entry.config)) continue
|
|
149
|
+
ops.push({ kind: 'config', name: moduleName, rowId: entry.rowId || null, config: entry.config })
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { ops, notes, stats: summarize(ops, notes) }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function summarize(ops, notes = []) {
|
|
157
|
+
const counts = {}
|
|
158
|
+
for (const op of ops) counts[op.kind] = (counts[op.kind] || 0) + 1
|
|
159
|
+
return { total: ops.length, counts, skipped: notes.length }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function deepEqual(left, right) {
|
|
163
|
+
return stableKey(left) === stableKey(right)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function stableKey(value) {
|
|
167
|
+
if (Array.isArray(value)) return `[${value.map(stableKey).join(',')}]`
|
|
168
|
+
if (value && typeof value === 'object') {
|
|
169
|
+
const keys = Object.keys(value).sort()
|
|
170
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableKey(value[key])}`).join(',')}}`
|
|
171
|
+
}
|
|
172
|
+
return JSON.stringify(value ?? null)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function describeOps(ops) {
|
|
176
|
+
const labels = {
|
|
177
|
+
install: '安装',
|
|
178
|
+
upgrade: '升级',
|
|
179
|
+
remove: '卸载',
|
|
180
|
+
enable: '启用',
|
|
181
|
+
disable: '停用',
|
|
182
|
+
'plugin-enable': '启用条目',
|
|
183
|
+
'plugin-disable': '停用条目',
|
|
184
|
+
config: '配置',
|
|
185
|
+
}
|
|
186
|
+
return ops.map((op) => {
|
|
187
|
+
const label = labels[op.kind] || op.kind
|
|
188
|
+
const target = op.name || op.moduleName || op.entryId || ''
|
|
189
|
+
const extra = op.kind === 'upgrade' ? ` ${op.from ?? '?'} → ${op.version ?? '?'}` : ''
|
|
190
|
+
return `${label} ${target}${extra}`.trim()
|
|
191
|
+
})
|
|
192
|
+
}
|