@leiyi-works/dsh-cloud-sync 0.0.0-stage → 0.1.5

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/sync.js ADDED
@@ -0,0 +1,1548 @@
1
+ /**
2
+ * dsh-cloud-sync — 同步引擎(Host 侧,不依赖任何第三方包)。
3
+ *
4
+ * 设计要点
5
+ * ────────
6
+ * 1. 通道只用 SSH:复用用户已有的 jdcloud 私钥,服务器上不装服务、不开端口。
7
+ * 传输协议 = 自带实现的 tar 流(不经 Windows 自带 tar,避免 GBK 代码页把中文名
8
+ * 写坏;也不经 scp,少一个二进制依赖),直接管道进 `ssh ... tar -xf -`。
9
+ * 2. 增量:客户端扫一份清单(相对路径 + 大小 + mtime),与服务端 manifest.json 比,
10
+ * 只传变化的文件;mtime 容差 2 秒(tar 只存到秒)。
11
+ * 3. 删除传播:本地没有、服务端有的条目 → 服务端挪进 trash/<时间戳>/,保留 N 天
12
+ * 后自动清理。这样会话临时目录插件 7 天清理掉的老会话、老附件,云端不会越攒越多。
13
+ * 4. 覆盖留底:推送覆盖前把服务端旧版本拷进 versions/<时间戳>/,保留 N 天。
14
+ * 5. 恢复只补不删:拉取只写「本地没有」或「云端更新」的文件,覆盖前先备份到
15
+ * <DSH_HOME>/.cloud-sync/backup/<时间戳>/;最近 5 分钟内改过的本地文件一律跳过。
16
+ *
17
+ * 目录结构(服务端)
18
+ * /opt/dsh-sync/machines/<机器名>/manifest.json
19
+ * /opt/dsh-sync/machines/<机器名>/files/dsh/... ← ~/.dsh 里的同步内容
20
+ * /opt/dsh-sync/machines/<机器名>/files/plugins/<名字>/... ← 本地 link 插件源码
21
+ * /opt/dsh-sync/trash/<时间戳>/... ← 删除回收站
22
+ * /opt/dsh-sync/versions/<时间戳>/... ← 被覆盖的旧版本
23
+ */
24
+ import fs from 'node:fs'
25
+ import fsp from 'node:fs/promises'
26
+ import path from 'node:path'
27
+ import os from 'node:os'
28
+ import { spawn } from 'node:child_process'
29
+
30
+ /* ── 配置 ───────────────────────────────────────────────────────────────── */
31
+
32
+ /**
33
+ * 默认值。**包内不含任何服务器地址与密钥路径**:`host` / `identityFile` 必须由使用者在
34
+ * profile 的 `cordis.patch.yml` 里显式配置(或由 Agent 代填),没配置时插件不联网。
35
+ */
36
+ export const DEFAULTS = {
37
+ host: '',
38
+ port: 22,
39
+ identityFile: '',
40
+ remoteRoot: '/opt/dsh-sync',
41
+ connectionFile: '',
42
+ machineId: '',
43
+ localPluginRoot: 'D:/DSH工作区',
44
+ intervalMinutes: 5,
45
+ dailySync: false,
46
+ autoSync: true,
47
+ applyRemoteDeletes: true,
48
+ pauseWhenIdle: true,
49
+ idlePauseMinutes: 10,
50
+ restoreOnEmpty: true,
51
+ includeAttachments: true,
52
+ includeCredentials: true,
53
+ includeKeyruleSecrets: true,
54
+ includeLocalPlugins: true,
55
+ quietSeconds: 10,
56
+ trashDays: 14,
57
+ versionDays: 7,
58
+ backupDays: 7,
59
+ }
60
+
61
+ /**
62
+ * 读「连接信息」文件(默认 `<DSH_HOME>/keyrule-secrets/ssh.json`)。
63
+ * 这样服务器地址不用写死在插件里,跟着钥则目录一起同步即可。
64
+ * 格式:{"host":"root@1.2.3.4","identityFile":"C:/.../key.pem","port":22}
65
+ */
66
+ function readConnectionFile(file) {
67
+ try {
68
+ const raw = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')
69
+ const parsed = JSON.parse(raw)
70
+ return parsed !== null && typeof parsed === 'object' ? parsed : {}
71
+ } catch {
72
+ return {}
73
+ }
74
+ }
75
+
76
+ /** 把 Config 里的值归一化成一个纯对象(缺省值补齐、数字转正、字符串去空格)。 */
77
+ export function normalizeConfig(raw) {
78
+ const input = raw !== null && typeof raw === 'object' ? raw : {}
79
+ const pick = (key) => (input[key] === undefined || input[key] === null ? DEFAULTS[key] : input[key])
80
+ const num = (value, fallback, min) => {
81
+ const parsed = Number(value)
82
+ return Number.isFinite(parsed) && parsed >= min ? Math.round(parsed) : fallback
83
+ }
84
+ const text = (value, fallback) => {
85
+ const trimmed = typeof value === 'string' ? value.trim() : ''
86
+ return trimmed === '' ? fallback : trimmed
87
+ }
88
+ const flag = (value, fallback) => (typeof value === 'boolean' ? value : fallback)
89
+ const out = {
90
+ host: text(pick('host'), DEFAULTS.host),
91
+ port: num(pick('port'), DEFAULTS.port, 1),
92
+ identityFile: text(pick('identityFile'), DEFAULTS.identityFile).replace(/\\/g, '/'),
93
+ connectionFile: text(pick('connectionFile'), DEFAULTS.connectionFile),
94
+ remoteRoot: text(pick('remoteRoot'), DEFAULTS.remoteRoot).replace(/\/+$/, '') || DEFAULTS.remoteRoot,
95
+ machineId: sanitizeMachineId(text(pick('machineId'), '')),
96
+ localPluginRoot: text(pick('localPluginRoot'), DEFAULTS.localPluginRoot).replace(/\\/g, '/').replace(/\/+$/, ''),
97
+ intervalMinutes: num(pick('intervalMinutes'), DEFAULTS.intervalMinutes, 1),
98
+ dailySync: flag(pick('dailySync'), DEFAULTS.dailySync),
99
+ autoSync: flag(pick('autoSync'), DEFAULTS.autoSync),
100
+ applyRemoteDeletes: flag(pick('applyRemoteDeletes'), DEFAULTS.applyRemoteDeletes),
101
+ pauseWhenIdle: flag(pick('pauseWhenIdle'), DEFAULTS.pauseWhenIdle),
102
+ idlePauseMinutes: num(pick('idlePauseMinutes'), DEFAULTS.idlePauseMinutes, 1),
103
+ restoreOnEmpty: flag(pick('restoreOnEmpty'), DEFAULTS.restoreOnEmpty),
104
+ includeAttachments: flag(pick('includeAttachments'), DEFAULTS.includeAttachments),
105
+ includeCredentials: flag(pick('includeCredentials'), DEFAULTS.includeCredentials),
106
+ includeKeyruleSecrets: flag(pick('includeKeyruleSecrets'), DEFAULTS.includeKeyruleSecrets),
107
+ includeLocalPlugins: flag(pick('includeLocalPlugins'), DEFAULTS.includeLocalPlugins),
108
+ quietSeconds: num(pick('quietSeconds'), DEFAULTS.quietSeconds, 0),
109
+ trashDays: num(pick('trashDays'), DEFAULTS.trashDays, 1),
110
+ versionDays: num(pick('versionDays'), DEFAULTS.versionDays, 1),
111
+ backupDays: num(pick('backupDays'), DEFAULTS.backupDays, 1),
112
+ }
113
+ // 连接信息可以不写进配置:默认去钥则目录读 keyrule-secrets/ssh.json(该目录本身在同步范围内)
114
+ if (out.host === '') {
115
+ const file = out.connectionFile !== ''
116
+ ? out.connectionFile
117
+ : path.join(dshHome(), 'keyrule-secrets', 'ssh.json')
118
+ const saved = readConnectionFile(file)
119
+ if (typeof saved.host === 'string' && saved.host.trim() !== '') out.host = saved.host.trim()
120
+ if (out.identityFile === '' && typeof saved.identityFile === 'string' && saved.identityFile.trim() !== '') {
121
+ out.identityFile = saved.identityFile.trim().replace(/\\/g, '/')
122
+ }
123
+ if (saved.port !== undefined && Number.isFinite(Number(saved.port)) && Number(saved.port) > 0) {
124
+ out.port = Math.round(Number(saved.port))
125
+ }
126
+ }
127
+ return out
128
+ }
129
+
130
+ /** 是否已经配置好服务器(没配置时不联网、不报错,只提示去填)。 */
131
+ export function isConfigured(config) {
132
+ return typeof config?.host === 'string' && config.host.trim() !== ''
133
+ }
134
+
135
+ /** 机器名只用安全字符,避免拼进远程脚本时出事。 */
136
+ export function sanitizeMachineId(value) {
137
+ const cleaned = String(value ?? '').trim().replace(/[^A-Za-z0-9._-]/g, '-').replace(/^-+|-+$/g, '')
138
+ return cleaned.slice(0, 48)
139
+ }
140
+
141
+ /** 当前机器名(默认用主机名;用户可在 Config 里覆盖)。 */
142
+ export function defaultMachineId() {
143
+ try {
144
+ return sanitizeMachineId(os.hostname()) || 'machine'
145
+ } catch {
146
+ return 'machine'
147
+ }
148
+ }
149
+
150
+ /** DSH 主目录。 */
151
+ export function dshHome() {
152
+ return process.env.DSH_HOME !== undefined && process.env.DSH_HOME !== ''
153
+ ? process.env.DSH_HOME
154
+ : path.join(os.homedir(), '.dsh')
155
+ }
156
+
157
+ /** 当前 profile 名。 */
158
+ export function profileName() {
159
+ return process.env.DSH_PROFILE !== undefined && process.env.DSH_PROFILE !== '' ? process.env.DSH_PROFILE : 'desktop'
160
+ }
161
+
162
+ /** 当前 profile 目录。 */
163
+ export function profileDir(dshRoot = dshHome(), profile = profileName()) {
164
+ return process.env.DSH_PROFILE_DIR !== undefined && process.env.DSH_PROFILE_DIR !== ''
165
+ ? process.env.DSH_PROFILE_DIR
166
+ : path.join(dshRoot, 'profiles', profile)
167
+ }
168
+
169
+ /**
170
+ * 找到 dshRoot 对应的 profile 目录:只有 dshRoot 就是真正的 DSH 主目录时才用
171
+ * `DSH_PROFILE_DIR`(自测/恢复到一个临时目录时不能被骗)。
172
+ */
173
+ function resolveProfileDir(dshRoot, profile, explicit) {
174
+ if (explicit !== undefined && explicit !== '') return explicit
175
+ try {
176
+ if (path.resolve(dshRoot) === path.resolve(dshHome())) return profileDir(dshRoot, profile)
177
+ } catch { /* 比较失败就退回默认推导 */ }
178
+ return path.join(dshRoot, 'profiles', profile)
179
+ }
180
+
181
+ /* ── 本地记忆:删过的文件(墓碑)与本机拥有过的文件清单 ────────────────── */
182
+
183
+ function cloudStateDir(dshRoot) {
184
+ return path.join(dshRoot, '.cloud-sync')
185
+ }
186
+
187
+ function listDirSafe(dir) {
188
+ try {
189
+ return fs.readdirSync(dir, { withFileTypes: true })
190
+ } catch {
191
+ return []
192
+ }
193
+ }
194
+
195
+ function readJsonSafe(file, fallback) {
196
+ try {
197
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''))
198
+ return parsed !== null && typeof parsed === 'object' ? parsed : fallback
199
+ } catch {
200
+ return fallback
201
+ }
202
+ }
203
+
204
+ function writeJsonSafe(file, value) {
205
+ try {
206
+ fs.mkdirSync(path.dirname(file), { recursive: true })
207
+ const temp = `${file}.tmp`
208
+ fs.writeFileSync(temp, JSON.stringify(value), 'utf8')
209
+ fs.renameSync(temp, file)
210
+ } catch { /* 记忆文件写不进去不影响同步本身 */ }
211
+ }
212
+
213
+ /** 本机删掉过的文件(相对路径 → 删除时间)。恢复时遇到它就不拉回来。 */
214
+ export function readTombstones(dshRoot) {
215
+ return readJsonSafe(path.join(cloudStateDir(dshRoot), 'tombstones.json'), {})
216
+ }
217
+
218
+ function writeTombstones(dshRoot, map) {
219
+ const cutoff = Date.now() - 90 * 24 * 60 * 60 * 1000
220
+ const kept = {}
221
+ for (const [rel, at] of Object.entries(map ?? {})) {
222
+ if (Number(at) >= cutoff) kept[rel] = Number(at)
223
+ }
224
+ writeJsonSafe(path.join(cloudStateDir(dshRoot), 'tombstones.json'), kept)
225
+ }
226
+
227
+ /** 本机「确实拥有过」的文件(相对路径 → {s,m}):用来区分「本地删了」和「新机还没有」。 */
228
+ export function readKnown(dshRoot) {
229
+ return readJsonSafe(path.join(cloudStateDir(dshRoot), 'known.json'), {})
230
+ }
231
+
232
+ function writeKnown(dshRoot, map) {
233
+ writeJsonSafe(path.join(cloudStateDir(dshRoot), 'known.json'), map)
234
+ }
235
+
236
+ /**
237
+ * 清理本机 .cloud-sync 下的历史垃圾:过期的恢复备份目录、崩在半路的暂存目录。
238
+ * 只动插件自己建的 backup/ 与 staging/,不碰别的。
239
+ */
240
+ export function pruneLocalState(dshRoot, backupDays) {
241
+ const removed = { backups: 0, staging: 0, quarantined: 0 }
242
+ const cutoff = Date.now() - Math.max(1, backupDays) * 24 * 60 * 60 * 1000
243
+ for (const [key, dirName] of [['backups', 'backup'], ['quarantined', 'removed']]) {
244
+ const root = path.join(cloudStateDir(dshRoot), dirName)
245
+ for (const entry of listDirSafe(root)) {
246
+ if (!entry.isDirectory()) continue
247
+ const full = path.join(root, entry.name)
248
+ try {
249
+ if (fs.statSync(full).mtimeMs < cutoff) {
250
+ fs.rmSync(full, { recursive: true, force: true })
251
+ removed[key] += 1
252
+ }
253
+ } catch { /* 单个删不掉就留到下次 */ }
254
+ }
255
+ }
256
+ const stagingRoot = path.join(cloudStateDir(dshRoot), 'staging')
257
+ const stagingCutoff = Date.now() - 24 * 60 * 60 * 1000
258
+ for (const entry of listDirSafe(stagingRoot)) {
259
+ const full = path.join(stagingRoot, entry.name)
260
+ try {
261
+ if (fs.statSync(full).mtimeMs < stagingCutoff) {
262
+ fs.rmSync(full, { recursive: true, force: true })
263
+ removed.staging += 1
264
+ }
265
+ } catch { /* 同上 */ }
266
+ }
267
+ return removed
268
+ }
269
+
270
+ /* ── 跨机共享的删除记录(云端 shared/tombstones.json) ─────────────────── */
271
+
272
+ /** 合并多份删除记录:同一路径取最晚的删除时间。 */
273
+ export function mergeTombstones(...maps) {
274
+ const out = {}
275
+ for (const map of maps) {
276
+ for (const [rel, at] of Object.entries(map ?? {})) {
277
+ const ts = Number(at) || 0
278
+ if (ts > (out[rel] ?? 0)) out[rel] = ts
279
+ }
280
+ }
281
+ return out
282
+ }
283
+
284
+ async function readSharedTombstones(config) {
285
+ const file = `${config.remoteRoot}/shared/tombstones.json`
286
+ try {
287
+ const result = await runSsh(config, `cat ${shQuote(file)} 2>/dev/null || true`)
288
+ if (result.code !== 0 || result.stdout.trim() === '') return {}
289
+ const parsed = JSON.parse(result.stdout)
290
+ const out = {}
291
+ for (const [rel, at] of Object.entries(parsed ?? {})) {
292
+ if (!isSafeRel(rel)) continue
293
+ const ts = Number(at) || 0
294
+ if (ts > 0) out[rel] = ts
295
+ }
296
+ return out
297
+ } catch {
298
+ return {}
299
+ }
300
+ }
301
+
302
+ async function writeSharedTombstones(config, map) {
303
+ const cutoff = Date.now() - 180 * 24 * 60 * 60 * 1000
304
+ const kept = {}
305
+ for (const [rel, at] of Object.entries(map ?? {})) {
306
+ if (Number(at) >= cutoff) kept[rel] = Number(at)
307
+ }
308
+ const file = `${config.remoteRoot}/shared/tombstones.json`
309
+ await runSsh(config, `mkdir -p ${shQuote(`${config.remoteRoot}/shared`)} && cat > ${shQuote(file)}`, {
310
+ stdin: JSON.stringify(kept),
311
+ })
312
+ return kept
313
+ }
314
+
315
+ /** 把本机的删除记录并进云端共享文件(只增不减,按最晚时间合并);revived 里的条目会被撤掉。 */
316
+ async function syncSharedTombstones(config, tombstones, log, revived = []) {
317
+ try {
318
+ const sharedAfter = await readSharedTombstones(config)
319
+ for (const rel of revived) delete sharedAfter[rel]
320
+ const merged = mergeTombstones(sharedAfter, tombstones)
321
+ for (const rel of revived) delete merged[rel]
322
+ if (JSON.stringify(merged) === JSON.stringify(sharedAfter)) return
323
+ await writeSharedTombstones(config, merged)
324
+ } catch (error) {
325
+ log(`同步删除记录失败(不影响本次操作):${error instanceof Error ? error.message : String(error)}`)
326
+ }
327
+ }
328
+
329
+ /**
330
+ * 跨机删除的范围:**默认整个 ~/.dsh 都跟随**(会话、storages、data、附件,以及
331
+ * 插件在 ~/.dsh 下自己建的数据目录,如 h3-kit / llm-deepseek)。
332
+ * 只有这几类永远不跨机删除——别的机器可能还要用,删了会让那边登录失效或插件加载失败:
333
+ * - `plugins/`:本地 link 插件源码,靠 npm 版本管理;
334
+ * - `dsh/profiles/`:profile 配置,靠「覆盖更新」而不是删除来同步;
335
+ * - `dsh/.credentials.yaml`、`dsh/keyrule-secrets/`:凭据与密钥。
336
+ */
337
+ const CROSS_MACHINE_DELETE_EXCLUDE = ['plugins/', 'dsh/profiles/', 'dsh/.credentials.yaml', 'dsh/keyrule-secrets/']
338
+
339
+ /** 导出给自测/诊断用:这条相对路径会不会跟着别的机器一起删。 */
340
+ export function crossMachineDeletable(rel) {
341
+ return !CROSS_MACHINE_DELETE_EXCLUDE.some((prefix) => rel.startsWith(prefix))
342
+ }
343
+
344
+ /**
345
+ * 把共享删除记录落到本机:比删除记录旧的文件移到 `.cloud-sync/removed/<时间戳>/`(可找回)。
346
+ * 比记录新的文件视为「删掉后又被重建」,保留不动,由调用方把记录撤掉。
347
+ * @returns {Promise<{removed: string[], revived: string[]}>}
348
+ */
349
+ export async function applyTombstones(options) {
350
+ const config = options.config
351
+ const dshRoot = options.dshRoot ?? dshHome()
352
+ const log = options.log ?? (() => {})
353
+ const tombstones = options.tombstones ?? {}
354
+ const inventory = buildInventory({ config, dshHome: dshRoot, profile: options.profile })
355
+ const removed = []
356
+ const revived = []
357
+ const stamp = timestamp()
358
+ for (const [rel, at] of Object.entries(tombstones)) {
359
+ if (!crossMachineDeletable(rel)) continue
360
+ const entry = inventory.entries.get(rel)
361
+ if (entry === undefined) continue
362
+ if (entry.mtimeMs > Number(at) + 2000) {
363
+ revived.push(rel)
364
+ continue
365
+ }
366
+ const quarantine = path.join(dshRoot, '.cloud-sync', 'removed', stamp, ...rel.split('/'))
367
+ try {
368
+ await fsp.mkdir(path.dirname(quarantine), { recursive: true })
369
+ await fsp.rename(entry.abs, quarantine)
370
+ removed.push(rel)
371
+ } catch (error) {
372
+ log(`无法移除 ${rel}:${error instanceof Error ? error.message : String(error)}`)
373
+ }
374
+ }
375
+ return { removed, revived }
376
+ }
377
+
378
+ /* ── 命令执行 ───────────────────────────────────────────────────────────── */
379
+
380
+ /** Windows 自带 OpenSSH 的绝对路径(PATH 里没有时兜底)。 */
381
+ function sshBinary() {
382
+ if (process.platform !== 'win32') return 'ssh'
383
+ const candidates = [
384
+ process.env.DSH_CLOUD_SYNC_SSH,
385
+ path.join(process.env.WINDIR ?? 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe'),
386
+ ].filter((item) => typeof item === 'string' && item !== '')
387
+ for (const candidate of candidates) {
388
+ try {
389
+ if (fs.existsSync(candidate)) return candidate
390
+ } catch { /* 下一个候选 */ }
391
+ }
392
+ return 'ssh'
393
+ }
394
+
395
+ /** 远程命令里出现的字符串一律单引号包裹。 */
396
+ export function shQuote(value) {
397
+ return `'${String(value).replace(/'/g, `'\\''`)}'`
398
+ }
399
+
400
+ /** 拼出 ssh 的固定参数(不含目标与命令)。 */
401
+ function sshOptions(config) {
402
+ const args = ['-p', String(config.port), '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=15', '-o', 'ServerAliveInterval=20', '-o', 'StrictHostKeyChecking=accept-new']
403
+ if (config.identityFile !== '' && fs.existsSync(config.identityFile)) args.push('-i', config.identityFile)
404
+ return args
405
+ }
406
+
407
+ /** 打开一条 ssh 通道,返回子进程(stdin/stdout 均为管道)。 */
408
+ export function spawnSsh(config, remoteCommand) {
409
+ const args = [...sshOptions(config), config.host, remoteCommand]
410
+ return spawnChild(sshBinary(), args)
411
+ }
412
+
413
+ function spawnChild(bin, args) {
414
+ return spawn(bin, args, { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
415
+ }
416
+
417
+ /** 跑一条远程命令,收集输出;超时就杀掉,避免一个卡住的连接拖死整个任务。 */
418
+ export async function runSsh(config, remoteCommand, options = {}) {
419
+ const child = spawnSsh(config, remoteCommand)
420
+ const out = []
421
+ const err = []
422
+ child.stdout.on('data', (chunk) => out.push(chunk))
423
+ child.stderr.on('data', (chunk) => err.push(chunk))
424
+ if (options.stdin !== undefined) child.stdin.end(options.stdin)
425
+ else child.stdin.end()
426
+ const timeoutMs = options.timeoutMs ?? 60000
427
+ const timer = setTimeout(() => child.kill(), timeoutMs)
428
+ if (typeof timer.unref === 'function') timer.unref()
429
+ const code = await new Promise((resolve, reject) => {
430
+ child.on('error', reject)
431
+ child.on('close', resolve)
432
+ }).finally(() => clearTimeout(timer))
433
+ return {
434
+ code,
435
+ stdout: Buffer.concat(out).toString('utf8'),
436
+ stderr: Buffer.concat(err).toString('utf8'),
437
+ }
438
+ }
439
+
440
+ /* ── tar 流(自带实现) ─────────────────────────────────────────────────── */
441
+
442
+ const BLOCK = 512
443
+ /** 传输卡死判定:连续这么久没收到/发出数据就杀掉 ssh,让任务失败重试而不是永远挂着。 */
444
+ const STALL_MS = 120000
445
+
446
+ function octal(value, width) {
447
+ const text = Math.max(0, Math.floor(value)).toString(8)
448
+ return text.padStart(width - 1, '0').slice(-(width - 1)) + '\0'
449
+ }
450
+
451
+ /** ustar:把长路径拆成 prefix(155) + name(100);拆不开返回 undefined。 */
452
+ function ustarSplit(name) {
453
+ const bytes = Buffer.byteLength(name, 'utf8')
454
+ if (bytes <= 100) return { name, prefix: '' }
455
+ for (let index = name.lastIndexOf('/'); index > 0; index = name.lastIndexOf('/', index - 1)) {
456
+ const prefix = name.slice(0, index)
457
+ const rest = name.slice(index + 1)
458
+ if (Buffer.byteLength(prefix, 'utf8') <= 155 && Buffer.byteLength(rest, 'utf8') <= 100) {
459
+ return { prefix, name: rest }
460
+ }
461
+ }
462
+ if (bytes > 255) return undefined
463
+ const lastSlash = name.lastIndexOf('/')
464
+ return { name: name.slice(lastSlash + 1), prefix: name.slice(0, lastSlash) }
465
+ }
466
+
467
+ function tarHeader(fields) {
468
+ const header = Buffer.alloc(BLOCK, 0)
469
+ header.write(fields.name ?? '', 0, 100, 'utf8')
470
+ header.write(octal(fields.mode ?? 0o644, 8), 100, 8, 'ascii')
471
+ header.write(octal(0, 8), 108, 8, 'ascii')
472
+ header.write(octal(0, 8), 116, 8, 'ascii')
473
+ header.write(octal(fields.size ?? 0, 12), 124, 12, 'ascii')
474
+ header.write(octal(fields.mtime ?? 0, 12), 136, 12, 'ascii')
475
+ header.write(' ', 148, 8, 'ascii')
476
+ header.write(fields.typeflag ?? '0', 156, 1, 'ascii')
477
+ header.write(fields.linkname ?? '', 157, 100, 'utf8')
478
+ header.write('ustar\0', 257, 6, 'ascii')
479
+ header.write('00', 263, 2, 'ascii')
480
+ header.write('root', 265, 32, 'ascii')
481
+ header.write('root', 297, 32, 'ascii')
482
+ header.write(octal(0, 8), 329, 8, 'ascii')
483
+ header.write(octal(0, 8), 337, 8, 'ascii')
484
+ header.write(fields.prefix ?? '', 345, 155, 'utf8')
485
+ let sum = 0
486
+ for (let index = 0; index < BLOCK; index += 1) sum += header[index]
487
+ header.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'ascii')
488
+ return header
489
+ }
490
+
491
+ function padding(size) {
492
+ const rest = size % BLOCK
493
+ return rest === 0 ? 0 : BLOCK - rest
494
+ }
495
+
496
+ async function writeChunk(stream, buffer) {
497
+ if (buffer.length === 0) return
498
+ if (stream.destroyed || stream.writableEnded) throw new Error('传输通道已关闭')
499
+ if (stream.write(buffer)) return
500
+ await new Promise((resolve) => {
501
+ const cleanup = () => {
502
+ stream.off('drain', onDrain)
503
+ stream.off('close', onClose)
504
+ }
505
+ const onDrain = () => {
506
+ cleanup()
507
+ resolve()
508
+ }
509
+ const onClose = () => {
510
+ cleanup()
511
+ resolve()
512
+ }
513
+ stream.once('drain', onDrain)
514
+ stream.once('close', onClose)
515
+ })
516
+ }
517
+
518
+ /**
519
+ * 把一批文件写成 tar 流灌进目标流。
520
+ * @param {import('node:stream').Writable} stream - ssh 的 stdin。
521
+ * @param {{name: string, abs: string, size: number, mtimeMs: number, mode?: number}[]} entries
522
+ * @param {(done: number, total: number) => void} [onProgress]
523
+ */
524
+ export async function writeTar(stream, entries, onProgress) {
525
+ const total = entries.reduce((sum, entry) => sum + entry.size, 0)
526
+ let done = 0
527
+ for (const entry of entries) {
528
+ const split = ustarSplit(entry.name)
529
+ let headerName
530
+ let prefix = ''
531
+ if (split === undefined) {
532
+ const longName = Buffer.from(`${entry.name}\0`, 'utf8')
533
+ await writeChunk(stream, tarHeader({ name: '././@LongLink', mode: 0o644, size: longName.length, mtime: Math.floor(entry.mtimeMs / 1000), typeflag: 'L' }))
534
+ await writeChunk(stream, longName)
535
+ await writeChunk(stream, Buffer.alloc(padding(longName.length), 0))
536
+ headerName = entry.name.slice(entry.name.lastIndexOf('/') + 1).slice(0, 100)
537
+ } else {
538
+ headerName = split.name
539
+ prefix = split.prefix
540
+ }
541
+ await writeChunk(stream, tarHeader({
542
+ name: headerName,
543
+ prefix,
544
+ mode: entry.mode ?? 0o644,
545
+ size: entry.size,
546
+ mtime: Math.floor(entry.mtimeMs / 1000),
547
+ }))
548
+ const handle = await fsp.open(entry.abs, 'r')
549
+ try {
550
+ let remaining = entry.size
551
+ const buffer = Buffer.allocUnsafe(Math.min(1 << 20, Math.max(entry.size, 1)))
552
+ while (remaining > 0) {
553
+ const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, remaining), null)
554
+ if (bytesRead === 0) throw new Error(`文件在打包过程中变短:${entry.abs}`)
555
+ await writeChunk(stream, buffer.subarray(0, bytesRead))
556
+ remaining -= bytesRead
557
+ done += bytesRead
558
+ if (onProgress !== undefined) onProgress(done, total)
559
+ }
560
+ } finally {
561
+ await handle.close()
562
+ }
563
+ const rest = padding(entry.size)
564
+ if (rest > 0) await writeChunk(stream, Buffer.alloc(rest, 0))
565
+ }
566
+ await writeChunk(stream, Buffer.alloc(BLOCK * 2, 0))
567
+ }
568
+
569
+ /** 带缓冲的字节读取器:从 ssh stdout 里按 tar 头逐块取出内容。 */
570
+ export class ByteReader {
571
+ constructor(stream, onActivity) {
572
+ this.iterator = stream[Symbol.asyncIterator]()
573
+ this.chunk = null
574
+ this.offset = 0
575
+ this.ended = false
576
+ this.total = 0
577
+ this.onActivity = onActivity
578
+ }
579
+
580
+ async nextChunk() {
581
+ const { value, done } = await this.iterator.next()
582
+ if (done === true) {
583
+ this.ended = true
584
+ this.chunk = null
585
+ return false
586
+ }
587
+ if (this.onActivity !== undefined) this.onActivity()
588
+ this.chunk = value
589
+ this.offset = 0
590
+ return true
591
+ }
592
+
593
+ async readExact(size) {
594
+ const parts = []
595
+ let need = size
596
+ while (need > 0) {
597
+ if (this.chunk === null) {
598
+ if (this.ended || !(await this.nextChunk())) break
599
+ }
600
+ const available = this.chunk.length - this.offset
601
+ if (available <= 0) {
602
+ this.chunk = null
603
+ continue
604
+ }
605
+ const take = Math.min(available, need)
606
+ parts.push(this.chunk.subarray(this.offset, this.offset + take))
607
+ this.offset += take
608
+ need -= take
609
+ this.total += take
610
+ if (this.offset >= this.chunk.length) this.chunk = null
611
+ }
612
+ return parts.length === 0 ? Buffer.alloc(0) : parts.length === 1 ? parts[0] : Buffer.concat(parts, size - need)
613
+ }
614
+
615
+ async skip(size) {
616
+ let need = size
617
+ while (need > 0) {
618
+ const part = await this.readExact(Math.min(need, 1 << 20))
619
+ if (part.length === 0) return
620
+ need -= part.length
621
+ }
622
+ }
623
+ }
624
+
625
+ function readOctal(buffer, offset, length) {
626
+ const text = buffer.subarray(offset, offset + length).toString('ascii').replace(/\0.*$/, '').trim()
627
+ if (text === '') return 0
628
+ const value = Number.parseInt(text, 8)
629
+ return Number.isFinite(value) ? value : 0
630
+ }
631
+
632
+ function readString(buffer, offset, length) {
633
+ const slice = buffer.subarray(offset, offset + length)
634
+ const end = slice.indexOf(0)
635
+ return (end === -1 ? slice : slice.subarray(0, end)).toString('utf8')
636
+ }
637
+
638
+ function parsePax(buffer) {
639
+ const out = {}
640
+ let offset = 0
641
+ while (offset < buffer.length) {
642
+ const space = buffer.indexOf(0x20, offset)
643
+ if (space === -1) break
644
+ const length = Number.parseInt(buffer.subarray(offset, space).toString('ascii'), 10)
645
+ if (!Number.isFinite(length) || length <= 0) break
646
+ const record = buffer.subarray(space + 1, offset + length - 1)
647
+ const equals = record.indexOf(0x3d)
648
+ if (equals > 0) out[record.subarray(0, equals).toString('utf8')] = record.subarray(equals + 1).toString('utf8')
649
+ offset += length
650
+ }
651
+ return out
652
+ }
653
+
654
+ /**
655
+ * 读 tar 流。回调负责消费每个普通文件的内容。
656
+ * @param {ByteReader} reader
657
+ * @param {(file: {name: string, size: number, mtimeMs: number, mode: number, reader: ByteReader}) => Promise<void>} onFile
658
+ */
659
+ export async function readTar(reader, onFile) {
660
+ let longName
661
+ let pax = {}
662
+ let files = 0
663
+ for (;;) {
664
+ const header = await reader.readExact(BLOCK)
665
+ if (header.length < BLOCK) break
666
+ if (header.every((byte) => byte === 0)) break
667
+ const typeflag = header.subarray(156, 157).toString('ascii')
668
+ const size = readOctal(header, 124, 12)
669
+ const prefix = readString(header, 345, 155)
670
+ const baseName = readString(header, 0, 100)
671
+ const name = longName ?? (prefix === '' ? baseName : `${prefix}/${baseName}`)
672
+ const mtimeMs = readOctal(header, 136, 12) * 1000
673
+ const mode = readOctal(header, 100, 8)
674
+
675
+ if (typeflag === 'L') {
676
+ const content = await reader.readExact(size)
677
+ longName = content.subarray(0, Math.max(0, content.length - 1)).toString('utf8')
678
+ await reader.skip(padding(size))
679
+ continue
680
+ }
681
+ if (typeflag === 'x') {
682
+ const content = await reader.readExact(size)
683
+ pax = parsePax(content)
684
+ await reader.skip(padding(size))
685
+ continue
686
+ }
687
+ if (typeflag === 'g' || typeflag === 'K') {
688
+ await reader.skip(size + padding(size))
689
+ continue
690
+ }
691
+ longName = undefined
692
+ const effectiveName = pax.path !== undefined && pax.path !== '' ? pax.path : name
693
+ const effectiveMtime = pax.mtime !== undefined ? Math.round(Number.parseFloat(pax.mtime) * 1000) : mtimeMs
694
+ pax = {}
695
+ if (typeflag === '0' || typeflag === '\0' || typeflag === ' ' || typeflag === '') {
696
+ await onFile({ name: effectiveName, size, mtimeMs: effectiveMtime, mode, reader })
697
+ await reader.skip(padding(size))
698
+ files += 1
699
+ continue
700
+ }
701
+ // 目录/链接等:跳过内容
702
+ await reader.skip(size + padding(size))
703
+ }
704
+ return { files }
705
+ }
706
+
707
+ /* ── 清单扫描 ───────────────────────────────────────────────────────────── */
708
+
709
+ /** 通用排除:任何层级都不带(可重建 / 版本控制 / 日志 / 备份)。 */
710
+ const EXCLUDE_RE = /(?:^|\/)(?:node_modules|\.git|\.DS_Store|\.dsh-market|\.plugin-manager|\.cloud-sync|dsh-runtimes|cache|__pycache__)(?:\/|$)|\.(?:log|tmp|part|swp)$|\.bak(?:[-.]|$)/
711
+
712
+ /** 机器相关的小文件(用量统计、匿名 ID、旧凭据残片):不跨机。 */
713
+ const MACHINE_FILES = /^(?:\.dshw-|\.anonymous-user-id$|\.credentials\.yaml\.newmachine$)/
714
+
715
+ /**
716
+ * ~/.dsh 里哪些路径要同步:**黑名单制**——默认整目录都带,只排除
717
+ * 「可重建 / 机器相关 / 不能乱传」的那几类。
718
+ * 这样任何插件在 ~/.dsh 下新建的目录(h3-kit、llm-deepseek、…)都会自动进入同步,
719
+ * 不需要再来改代码加白名单。
720
+ */
721
+ function includeDshFile(rel, config, profile) {
722
+ if (rel === '') return false
723
+ if (EXCLUDE_RE.test(rel)) return false
724
+ const base = rel.slice(rel.lastIndexOf('/') + 1)
725
+ if (MACHINE_FILES.test(base)) return false
726
+ // 会话临时目录簿记:本机专用,换机没意义
727
+ if (rel.startsWith('data/') && rel.slice(5).startsWith('session-tmpdir-')) return false
728
+ // 三个开关关掉时,等同于把对应内容加进黑名单
729
+ if (!config.includeAttachments && rel.startsWith('attachments/')) return false
730
+ if (!config.includeCredentials && rel === '.credentials.yaml') return false
731
+ if (!config.includeKeyruleSecrets && rel.startsWith('keyrule-secrets/')) return false
732
+ return true
733
+ }
734
+
735
+ function includePluginFile(rel) {
736
+ if (rel === '') return false
737
+ if (EXCLUDE_RE.test(rel)) return false
738
+ if (rel.startsWith('_tmp/') || rel.startsWith('node_modules/')) return false
739
+ return true
740
+ }
741
+
742
+ /** 读 profile 的 package.json,取出所有 link: 插件(名字 + 绝对目录)。 */
743
+ export function readLinkPlugins(dir) {
744
+ const file = path.join(dir, 'package.json')
745
+ if (!fs.existsSync(file)) return []
746
+ let manifest
747
+ try {
748
+ manifest = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''))
749
+ } catch {
750
+ return []
751
+ }
752
+ const out = []
753
+ for (const [name, spec] of Object.entries(manifest.dependencies ?? {})) {
754
+ if (typeof spec !== 'string' || !spec.startsWith('link:')) continue
755
+ const target = spec.slice(5).replace(/\\/g, '/').replace(/\/+$/, '')
756
+ out.push({ name, target })
757
+ }
758
+ return out
759
+ }
760
+
761
+ function walkTree(root, prefix, include, out, errors) {
762
+ const stack = ['']
763
+ while (stack.length > 0) {
764
+ const rel = stack.pop()
765
+ const dir = rel === '' ? root : path.join(root, rel.split('/').join(path.sep))
766
+ let entries
767
+ try {
768
+ entries = fs.readdirSync(dir, { withFileTypes: true })
769
+ } catch (error) {
770
+ if (rel !== '') errors.push(`${prefix}/${rel}: ${error instanceof Error ? error.message : String(error)}`)
771
+ continue
772
+ }
773
+ for (const entry of entries) {
774
+ const childRel = rel === '' ? entry.name : `${rel}/${entry.name}`
775
+ const full = path.join(root, childRel.split('/').join(path.sep))
776
+ if (entry.isDirectory()) {
777
+ if (EXCLUDE_RE.test(`${childRel}/`)) continue
778
+ stack.push(childRel)
779
+ continue
780
+ }
781
+ if (!entry.isFile()) continue
782
+ if (!include(childRel)) continue
783
+ try {
784
+ const stat = fs.statSync(full)
785
+ out.set(`${prefix}/${childRel}`, {
786
+ abs: full,
787
+ size: stat.size,
788
+ mtimeMs: Math.round(stat.mtimeMs),
789
+ mode: (stat.mode & 0o111) !== 0 ? 0o755 : 0o644,
790
+ })
791
+ } catch (error) {
792
+ errors.push(`${prefix}/${childRel}: ${error instanceof Error ? error.message : String(error)}`)
793
+ }
794
+ }
795
+ }
796
+ }
797
+
798
+ /**
799
+ * 扫描本地同步集合。
800
+ * @returns {{entries: Map<string, {abs: string, size: number, mtimeMs: number, mode: number}>, errors: string[], plugins: {name: string, target: string, exists: boolean}[]}}
801
+ */
802
+ export function buildInventory(options) {
803
+ const config = options.config
804
+ const dshRoot = options.dshHome ?? dshHome()
805
+ const profile = options.profile ?? profileName()
806
+ const entries = new Map()
807
+ const errors = []
808
+ walkTree(dshRoot, 'dsh', (rel) => includeDshFile(rel, config, profile), entries, errors)
809
+
810
+ const plugins = []
811
+ if (config.includeLocalPlugins) {
812
+ const dir = resolveProfileDir(dshRoot, profile, options.profileDir)
813
+ for (const plugin of readLinkPlugins(dir)) {
814
+ const exists = fs.existsSync(plugin.target)
815
+ plugins.push({ ...plugin, exists })
816
+ if (!exists) {
817
+ errors.push(`插件 ${plugin.name} 的目录不存在:${plugin.target}`)
818
+ continue
819
+ }
820
+ walkTree(plugin.target, `plugins/${plugin.name}`, includePluginFile, entries, errors)
821
+ }
822
+ }
823
+ return { entries, errors, plugins }
824
+ }
825
+
826
+ /** 供状态面板估算本地同步集合大小,不触发任何传输。 */
827
+ export function localSummary(config) {
828
+ const inventory = buildInventory({ config, dshHome: dshHome(), profile: profileName() })
829
+ let bytes = 0
830
+ for (const entry of inventory.entries.values()) bytes += entry.size
831
+ return { files: inventory.entries.size, bytes, errors: inventory.errors.length, plugins: inventory.plugins.length }
832
+ }
833
+
834
+ /** 只算本地指纹(不联网):用来判断「这一轮到底有没有变化」。 */
835
+ export function localFingerprint(config, dshRoot = dshHome(), profile = profileName()) {
836
+ return inventoryFingerprint(buildInventory({ config, dshHome: dshRoot, profile }))
837
+ }
838
+
839
+ /* ── 远程操作 ───────────────────────────────────────────────────────────── */
840
+
841
+ function machineDir(config, machineId) {
842
+ return `${config.remoteRoot}/machines/${machineId}`
843
+ }
844
+
845
+ /** 确保服务端目录存在。 */
846
+ export async function ensureRemoteRoot(config) {
847
+ const script = [
848
+ `mkdir -p ${shQuote(`${config.remoteRoot}/machines`)} ${shQuote(`${config.remoteRoot}/trash`)} ${shQuote(`${config.remoteRoot}/versions`)}`,
849
+ 'echo READY',
850
+ ].join('\n')
851
+ const result = await runSsh(config, script)
852
+ if (result.code !== 0 || !result.stdout.includes('READY')) {
853
+ throw new Error(`无法在服务器上准备目录(退出码 ${result.code}):${result.stderr.trim() || result.stdout.trim()}`)
854
+ }
855
+ }
856
+
857
+ /** 拉取某台机器的 manifest(不存在时返回空清单)。 */
858
+ export async function readRemoteManifest(config, machineId) {
859
+ const file = `${machineDir(config, machineId)}/manifest.json`
860
+ const result = await runSsh(config, `cat ${shQuote(file)} 2>/dev/null || true`)
861
+ const text = result.stdout.trim()
862
+ if (text === '') return { version: 1, machine: machineId, updatedAt: '', entries: {} }
863
+ try {
864
+ const parsed = JSON.parse(text)
865
+ return {
866
+ version: 1,
867
+ machine: typeof parsed.machine === 'string' ? parsed.machine : machineId,
868
+ updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '',
869
+ entries: parsed.entries !== null && typeof parsed.entries === 'object' ? parsed.entries : {},
870
+ }
871
+ } catch {
872
+ throw new Error('服务端 manifest.json 解析失败(可能写坏,可在服务器上删掉它再全量推一次)')
873
+ }
874
+ }
875
+
876
+ /** 列出服务端已有的机器槽位。 */
877
+ export async function listMachines(config) {
878
+ const script = [
879
+ `cd ${shQuote(config.remoteRoot)} 2>/dev/null || { echo NO_ROOT; exit 0; }`,
880
+ 'echo ROOT',
881
+ 'for d in machines/*/; do',
882
+ ' [ -d "$d" ] || continue',
883
+ ' id=${d#machines/}; id=${id%/}',
884
+ ' if [ -f "machines/$id/manifest.json" ]; then',
885
+ ' echo "M $id $(stat -c %s "machines/$id/manifest.json" 2>/dev/null || echo 0) $(stat -c %Y "machines/$id/manifest.json" 2>/dev/null || echo 0) $(du -sb "machines/$id/files" 2>/dev/null | cut -f1)"',
886
+ ' else',
887
+ ' echo "M $id 0 0 0"',
888
+ ' fi',
889
+ 'done',
890
+ 'echo "DF $(df -B1 /opt 2>/dev/null | awk \'NR==2{print $4}\')"',
891
+ ].join('\n')
892
+ const result = await runSsh(config, script)
893
+ const machines = []
894
+ let freeBytes
895
+ for (const line of result.stdout.split(/\r?\n/)) {
896
+ if (line.startsWith('M ')) {
897
+ const [, id, manifestBytes, manifestMtime, filesBytes] = line.split(' ')
898
+ machines.push({
899
+ id,
900
+ manifestBytes: Number(manifestBytes) || 0,
901
+ manifestMtime: (Number(manifestMtime) || 0) * 1000,
902
+ filesBytes: Number(filesBytes) || 0,
903
+ })
904
+ } else if (line.startsWith('DF ')) {
905
+ freeBytes = Number(line.slice(3)) || undefined
906
+ }
907
+ }
908
+ return { ok: result.code === 0, machines, freeBytes, error: result.code === 0 ? undefined : result.stderr.trim() }
909
+ }
910
+
911
+ /* ── 推送 ───────────────────────────────────────────────────────────────── */
912
+
913
+ function groupOf(rel) {
914
+ const parts = rel.split('/')
915
+ if (parts[0] === 'plugins' && parts.length > 2) return `plugins/${parts[1]}`
916
+ return parts[0]
917
+ }
918
+
919
+ function memberOf(rel) {
920
+ const parts = rel.split('/')
921
+ if (parts[0] === 'plugins' && parts.length > 2) return parts.slice(2).join('/')
922
+ return parts.slice(1).join('/')
923
+ }
924
+
925
+ function rootOf(group, options) {
926
+ if (group === 'dsh') return options.dshHome
927
+ const plugin = options.plugins.find((item) => `plugins/${item.name}` === group)
928
+ return plugin?.target
929
+ }
930
+
931
+ /** 本地同步集合的「指纹」:文件数 + 总字节 + 最大 mtime。用来判断这一轮要不要连服务器。 */
932
+ function inventoryFingerprint(inventory) {
933
+ let bytes = 0
934
+ let maxMtime = 0
935
+ for (const entry of inventory.entries.values()) {
936
+ bytes += entry.size
937
+ if (entry.mtimeMs > maxMtime) maxMtime = entry.mtimeMs
938
+ }
939
+ return `${inventory.entries.size}:${bytes}:${maxMtime}`
940
+ }
941
+
942
+ /** 一次 ssh 连接里备好目录并取回共享删除记录(省一次握手)。 */
943
+ async function bootRemote(config) {
944
+ const script = [
945
+ `mkdir -p ${shQuote(`${config.remoteRoot}/machines`)} ${shQuote(`${config.remoteRoot}/trash`)} ${shQuote(`${config.remoteRoot}/versions`)} ${shQuote(`${config.remoteRoot}/shared`)}`,
946
+ 'echo ===DSH_TOMBSTONES===',
947
+ `cat ${shQuote(`${config.remoteRoot}/shared/tombstones.json`)} 2>/dev/null || true`,
948
+ ].join('\n')
949
+ const result = await runSsh(config, script)
950
+ if (result.code !== 0) {
951
+ throw new Error(`连不上服务器(退出码 ${result.code}):${result.stderr.trim() || result.stdout.trim()}`)
952
+ }
953
+ const [, tail = ''] = result.stdout.split('===DSH_TOMBSTONES===')
954
+ const tombstones = {}
955
+ try {
956
+ const parsed = JSON.parse(tail.trim() || '{}')
957
+ for (const [rel, at] of Object.entries(parsed ?? {})) {
958
+ if (!isSafeRel(rel)) continue
959
+ const ts = Number(at) || 0
960
+ if (ts > 0) tombstones[rel] = ts
961
+ }
962
+ } catch { /* 记录文件坏了就当空的,下一轮推送会重建 */ }
963
+ return tombstones
964
+ }
965
+
966
+ /**
967
+ * 增量推送。删除走服务端回收站,覆盖前留旧版本。
968
+ * @returns {Promise<object>} 结果摘要
969
+ */
970
+ export async function push(options) {
971
+ const config = options.config
972
+ if (!isConfigured(config)) throw new Error('未配置服务器:请在 profile 的 cordis.patch.yml 里设置 host(和 identityFile),或让 Agent 帮忙填')
973
+ const log = options.log ?? (() => {})
974
+ const machine = config.machineId !== '' ? config.machineId : defaultMachineId()
975
+ const dshRoot = options.dshHome ?? dshHome()
976
+ const profile = options.profile ?? profileName()
977
+
978
+ // 先按云端共享的删除记录清理本机:别的机器删掉的东西,这台也别留着、也别再传回去
979
+ const sharedBefore = await bootRemote(config)
980
+ let applied = { removed: [], revived: [] }
981
+ if (config.applyRemoteDeletes && Object.keys(sharedBefore).length > 0) {
982
+ applied = await applyTombstones({ config, dshRoot, profile, tombstones: sharedBefore, log })
983
+ if (applied.removed.length > 0) log(`按云端删除记录移除本机 ${applied.removed.length} 个文件(放进 .cloud-sync/removed/,可找回)`)
984
+ if (applied.revived.length > 0) log(`另有 ${applied.revived.length} 个文件比删除记录新,保留`)
985
+ }
986
+
987
+ const inventory = buildInventory({ config, dshHome: dshRoot, profile, profileDir: options.profileDir })
988
+ const fingerprint = inventoryFingerprint(inventory)
989
+
990
+ // 快速通道:本地文件和上一轮成功推送时一模一样、也没被云端删除记录改动过
991
+ // → 云端清单不可能变(这个槽位只有本机在写),不必再拉 179KB 的 manifest
992
+ const fpFile = path.join(cloudStateDir(dshRoot), 'fingerprint.json')
993
+ const previousFingerprint = readJsonSafe(fpFile, {})
994
+ if (previousFingerprint.value === fingerprint
995
+ && Number(previousFingerprint.deferred) === 0
996
+ && applied.removed.length === 0) {
997
+ writeTombstones(dshRoot, mergeTombstones(readTombstones(dshRoot), sharedBefore))
998
+ log(`本地无变化,跳过清单比对(${inventory.entries.size} 个文件)`)
999
+ return { ok: true, skipped: true, fastPath: true, machine, uploaded: 0, deleted: 0, deferred: 0, total: inventory.entries.size, bytes: 0 }
1000
+ }
1001
+
1002
+ log(`扫描完成:${inventory.entries.size} 个文件${inventory.errors.length === 0 ? '' : `(${inventory.errors.length} 个读取失败)`}`)
1003
+
1004
+ const remote = await readRemoteManifest(config, machine)
1005
+ const remoteCount = Object.keys(remote.entries).length
1006
+ // 本地记忆:删过的文件(墓碑,含云端共享的)与「确实拥有过」的清单。本地又出现的文件要清掉墓碑。
1007
+ const tombstones = mergeTombstones(readTombstones(dshRoot), sharedBefore)
1008
+ for (const rel of inventory.entries.keys()) delete tombstones[rel]
1009
+ // 跳过最近 quietSeconds 内还在写的文件:它们下一轮再传。真正的防撕裂靠后面的暂存核对。
1010
+ const quietMs = config.quietSeconds * 1000
1011
+ const cutoff = Date.now() - quietMs
1012
+
1013
+ const changed = []
1014
+ const deferred = []
1015
+ for (const [rel, entry] of inventory.entries) {
1016
+ const previous = remote.entries[rel]
1017
+ const same = previous !== undefined
1018
+ && Number(previous.s) === entry.size
1019
+ && Math.abs(Number(previous.m) - entry.mtimeMs) <= 2000
1020
+ if (same) continue
1021
+ if (quietMs > 0 && entry.mtimeMs > cutoff) {
1022
+ deferred.push(rel)
1023
+ continue
1024
+ }
1025
+ changed.push(rel)
1026
+ }
1027
+
1028
+ const deletions = []
1029
+ if (inventory.errors.length === 0) {
1030
+ for (const rel of Object.keys(remote.entries)) {
1031
+ if (inventory.entries.has(rel)) continue
1032
+ // 某个类别被关掉时,不要把它当成「本地删了」去清云端
1033
+ if (!config.includeLocalPlugins && rel.startsWith('plugins/')) continue
1034
+ if (!config.includeAttachments && rel.startsWith('dsh/attachments/')) continue
1035
+ if (!config.includeCredentials && rel === 'dsh/.credentials.yaml') continue
1036
+ if (!config.includeKeyruleSecrets && rel.startsWith('dsh/keyrule-secrets/')) continue
1037
+ deletions.push(rel)
1038
+ tombstones[rel] = Date.now()
1039
+ }
1040
+ } else {
1041
+ log(`有 ${inventory.errors.length} 个文件读不到,本次跳过删除传播(避免误删云端文件)`)
1042
+ }
1043
+
1044
+ log(`云端已有 ${remoteCount} 个文件;本次待传 ${changed.length} 个、待删 ${deletions.length} 个、留到下轮 ${deferred.length} 个`)
1045
+ if (changed.length === 0 && deletions.length === 0) {
1046
+ writeTombstones(dshRoot, tombstones)
1047
+ if (deferred.length === 0) writeJsonSafe(fpFile, { value: fingerprint, deferred: 0 })
1048
+ return { ok: true, skipped: true, machine, uploaded: 0, deleted: 0, total: inventory.entries.size, deferred: deferred.length, bytes: 0 }
1049
+ }
1050
+
1051
+ const mDir = machineDir(config, machine)
1052
+ // 每台机器用自己的 incoming 目录:多台同时推时不会互相清掉对方的上传
1053
+ const incomingDir = `${mDir}/incoming/${machine}`
1054
+ const setup = await runSsh(config, `rm -rf ${shQuote(incomingDir)} && mkdir -p ${shQuote(incomingDir)}`)
1055
+ if (setup.code !== 0) throw new Error(`服务端准备目录失败:${setup.stderr.trim()}`)
1056
+
1057
+ const groups = new Map()
1058
+ for (const rel of changed) {
1059
+ const group = groupOf(rel)
1060
+ const list = groups.get(group) ?? []
1061
+ list.push(rel)
1062
+ groups.set(group, list)
1063
+ }
1064
+
1065
+ let uploaded = 0
1066
+ const uploadedRels = new Set()
1067
+ // 本地暂存:会话日志/投影缓存是边跑边改的,直接从源文件打 tar 会撞上「读一半变了」。
1068
+ // 先复制到暂存并核对大小,再打包暂存副本;核对不过的文件留到下一轮。
1069
+ const stagingRoot = path.join(dshRoot, '.cloud-sync', 'staging', `${process.pid}-${Date.now()}`)
1070
+ try {
1071
+ for (const [group, rels] of groups) {
1072
+ const root = rootOf(group, { dshHome: dshRoot, plugins: inventory.plugins })
1073
+ if (root === undefined) {
1074
+ log(`跳过未知分组 ${group}`)
1075
+ continue
1076
+ }
1077
+ const entries = []
1078
+ for (const rel of rels) {
1079
+ const entry = inventory.entries.get(rel)
1080
+ const stagedPath = path.join(stagingRoot, ...group.split('/'), ...memberOf(rel).split('/'))
1081
+ try {
1082
+ await fsp.mkdir(path.dirname(stagedPath), { recursive: true })
1083
+ await fsp.copyFile(entry.abs, stagedPath)
1084
+ const [sourceAfter, stagedStat] = await Promise.all([fsp.stat(entry.abs), fsp.stat(stagedPath)])
1085
+ if (sourceAfter.size !== entry.size || stagedStat.size !== entry.size) {
1086
+ deferred.push(rel)
1087
+ await fsp.rm(stagedPath, { force: true })
1088
+ log(`文件在准备上传时又变了,留到下一轮:${rel}`)
1089
+ continue
1090
+ }
1091
+ entries.push({ name: memberOf(rel), abs: stagedPath, size: entry.size, mtimeMs: entry.mtimeMs, mode: entry.mode, rel })
1092
+ } catch (error) {
1093
+ deferred.push(rel)
1094
+ log(`读取失败,跳过:${rel}(${error instanceof Error ? error.message : String(error)})`)
1095
+ }
1096
+ }
1097
+ if (entries.length === 0) continue
1098
+ const groupBytes = entries.reduce((sum, item) => sum + item.size, 0)
1099
+ log(`上传 ${group}:${entries.length} 个文件 / ${(groupBytes / 1048576).toFixed(2)} MB`)
1100
+ const target = `${incomingDir}/${group}`
1101
+ const child = spawnSsh(config, `mkdir -p ${shQuote(target)} && tar -xf - -C ${shQuote(target)}`)
1102
+ const stderr = []
1103
+ child.stderr.on('data', (chunk) => stderr.push(chunk))
1104
+ child.stdin.on('error', () => {})
1105
+ const closed = new Promise((resolve) => {
1106
+ child.on('error', (error) => resolve({ code: -1, stderr: error.message }))
1107
+ child.on('close', (code) => resolve({ code, stderr: Buffer.concat(stderr).toString('utf8') }))
1108
+ })
1109
+ let lastActivity = Date.now()
1110
+ let stalled = false
1111
+ const stallTimer = setInterval(() => {
1112
+ if (Date.now() - lastActivity > STALL_MS) {
1113
+ stalled = true
1114
+ lastActivity = Date.now()
1115
+ child.kill()
1116
+ }
1117
+ }, 10000)
1118
+ if (typeof stallTimer.unref === 'function') stallTimer.unref()
1119
+ try {
1120
+ await writeTar(child.stdin, entries, (done, total) => {
1121
+ lastActivity = Date.now()
1122
+ if (options.onProgress !== undefined) options.onProgress({ phase: `上传 ${group}`, done, total })
1123
+ })
1124
+ child.stdin.end()
1125
+ } catch (error) {
1126
+ child.kill()
1127
+ throw error
1128
+ } finally {
1129
+ clearInterval(stallTimer)
1130
+ }
1131
+ const result = await closed
1132
+ if (stalled) {
1133
+ throw new Error(`上传 ${group} 时传输卡住(${Math.round(STALL_MS / 1000)} 秒没有进展),已断开;服务端只收到半截进入临时目录,不会污染现有备份,稍后重试即可`)
1134
+ }
1135
+ if (result.code !== 0) throw new Error(`上传 ${group} 失败(退出码 ${result.code}):${String(result.stderr).trim()}`)
1136
+ uploaded += entries.length
1137
+ for (const entry of entries) uploadedRels.add(entry.rel)
1138
+ }
1139
+ } finally {
1140
+ await fsp.rm(stagingRoot, { recursive: true, force: true }).catch(() => {})
1141
+ }
1142
+
1143
+ // 清单 = 云端原有条目 + 本次真正上传的条目 − 删除项(避免把「留到下轮」的文件误标为已存在)
1144
+ const mergedEntries = {}
1145
+ for (const [rel, meta] of Object.entries(remote.entries)) mergedEntries[rel] = meta
1146
+ for (const rel of uploadedRels) {
1147
+ const entry = inventory.entries.get(rel)
1148
+ if (entry !== undefined) mergedEntries[rel] = { s: entry.size, m: entry.mtimeMs }
1149
+ }
1150
+ for (const rel of deletions) delete mergedEntries[rel]
1151
+ const nextManifest = { version: 1, machine, updatedAt: new Date().toISOString(), entries: mergedEntries }
1152
+
1153
+ if (deletions.length > 0) {
1154
+ const payload = deletions.join('\n') + '\n'
1155
+ const write = await runSsh(config, `cat > ${shQuote(`${mDir}/deletions.txt`)}`, { stdin: payload })
1156
+ if (write.code !== 0) throw new Error('写入删除清单失败')
1157
+ }
1158
+ await writeRemoteManifest(config, machine, nextManifest, true)
1159
+
1160
+ const commit = await runSsh(config, commitScript(config, machine))
1161
+ if (commit.code !== 0) throw new Error(`服务端提交失败(退出码 ${commit.code}):${commit.stderr.trim() || commit.stdout.trim()}`)
1162
+
1163
+ const remoteBytes = Number((commit.stdout.match(/BYTES (\d+)/) ?? [])[1]) || 0
1164
+ log(`提交完成:${uploaded} 个文件上传,${deletions.length} 个删除,云端合计 ${(remoteBytes / 1048576).toFixed(1)} MB`)
1165
+ // 记下「哪些删掉了」「哪些本机确实有」:下次恢复据此判断,不会把本地删掉的会话又拉回来
1166
+ writeTombstones(dshRoot, tombstones)
1167
+ writeKnown(dshRoot, mergedEntries)
1168
+ writeJsonSafe(fpFile, { value: fingerprint, deferred: deferred.length })
1169
+ // 删除记录同步到云端共享文件,别的机器(或另一台上的 DSH)据此同样删掉
1170
+ await syncSharedTombstones(config, tombstones, log, applied.revived)
1171
+ const pruned = pruneLocalState(dshRoot, config.backupDays)
1172
+ if (pruned.backups > 0 || pruned.staging > 0 || pruned.quarantined > 0) {
1173
+ log(`顺手清理本机旧备份 ${pruned.backups} 份、隔离文件 ${pruned.quarantined} 份、残留暂存 ${pruned.staging} 份`)
1174
+ }
1175
+ return {
1176
+ ok: true,
1177
+ skipped: false,
1178
+ machine,
1179
+ uploaded,
1180
+ deleted: deletions.length,
1181
+ deferred: deferred.length,
1182
+ total: inventory.entries.size,
1183
+ bytes: remoteBytes,
1184
+ changedBytes: changed.reduce((sum, rel) => sum + (inventory.entries.get(rel)?.size ?? 0), 0),
1185
+ }
1186
+ }
1187
+
1188
+ async function writeRemoteManifest(config, machine, manifest, staging = false) {
1189
+ const file = `${machineDir(config, machine)}/${staging ? 'manifest.new.json' : 'manifest.json'}`
1190
+ const result = await runSsh(config, `mkdir -p ${shQuote(path.posix.dirname(file))} && cat > ${shQuote(file)}`, {
1191
+ stdin: JSON.stringify(manifest),
1192
+ })
1193
+ if (result.code !== 0) throw new Error(`写入 manifest 失败:${result.stderr.trim()}`)
1194
+ }
1195
+
1196
+ function commitScript(config, machine) {
1197
+ const mDir = machineDir(config, machine)
1198
+ const trash = `${config.remoteRoot}/trash`
1199
+ const versions = `${config.remoteRoot}/versions`
1200
+ const incoming = `incoming/${machine}`
1201
+ return [
1202
+ 'set -u',
1203
+ `cd ${shQuote(mDir)} || exit 3`,
1204
+ // 多台机器可能同时提交:用文件锁串行化,后到的等前一个提交完
1205
+ 'exec 9>.dsh-sync.lock',
1206
+ 'flock -w 120 9 || { echo "LOCK_TIMEOUT" >&2; exit 6; }',
1207
+ 'TS=$(date +%Y%m%d-%H%M%S)',
1208
+ 'if [ -s deletions.txt ]; then',
1209
+ ` T=${shQuote(trash)}/$TS`,
1210
+ ' mkdir -p "$T"',
1211
+ ' while IFS= read -r p; do',
1212
+ ' [ -n "$p" ] || continue',
1213
+ ' if [ -f "files/$p" ]; then',
1214
+ ' mkdir -p "$T/$(dirname "$p")" 2>/dev/null || true',
1215
+ ' mv -f "files/$p" "$T/$p" 2>/dev/null || true',
1216
+ ' fi',
1217
+ ' done < deletions.txt',
1218
+ 'fi',
1219
+ '# 删空的目录顺手收掉,别在云端越留越多空壳',
1220
+ 'find files -depth -type d -empty -delete 2>/dev/null || true',
1221
+ `if [ -d ${incoming} ]; then`,
1222
+ ` V=${shQuote(versions)}/$TS`,
1223
+ ` find ${incoming} -type f -print > .incoming-list 2>/dev/null || true`,
1224
+ ' if [ -s .incoming-list ]; then',
1225
+ ' while IFS= read -r f; do',
1226
+ ` rel=\${f#${incoming}/}`,
1227
+ ' if [ -f "files/$rel" ]; then',
1228
+ ' mkdir -p "$V/$(dirname "$rel")" 2>/dev/null || true',
1229
+ ' cp -p "files/$rel" "$V/$rel" 2>/dev/null || true',
1230
+ ' fi',
1231
+ ' done < .incoming-list',
1232
+ ' fi',
1233
+ ' rm -f .incoming-list',
1234
+ ' mkdir -p files',
1235
+ ` cp -a ${incoming}/. files/ || exit 4`,
1236
+ ` rm -rf ${incoming}`,
1237
+ ' find files -depth -type d -empty -delete 2>/dev/null || true',
1238
+ 'fi',
1239
+ 'if [ -f manifest.new.json ]; then',
1240
+ ' mv -f manifest.new.json manifest.json || exit 5',
1241
+ 'fi',
1242
+ 'rm -f deletions.txt',
1243
+ `find ${shQuote(trash)} ${shQuote(versions)} -mindepth 1 -maxdepth 1 -type d -mtime +${config.trashDays} -exec rm -rf {} + 2>/dev/null || true`,
1244
+ 'find incoming -mindepth 1 -maxdepth 1 -type d -empty -delete 2>/dev/null || true',
1245
+ 'echo "BYTES $(du -sb files 2>/dev/null | cut -f1)"',
1246
+ 'echo OK',
1247
+ ].join('\n')
1248
+ }
1249
+
1250
+ /* ── 恢复 ───────────────────────────────────────────────────────────────── */
1251
+
1252
+ function isSafeRel(rel) {
1253
+ if (typeof rel !== 'string' || rel === '') return false
1254
+ if (rel.includes('\0')) return false
1255
+ if (rel.startsWith('/') || /^[A-Za-z]:/.test(rel)) return false
1256
+ const parts = rel.split('/')
1257
+ return parts.every((part) => part !== '' && part !== '.' && part !== '..')
1258
+ }
1259
+
1260
+ /** 服务端 profile package.json 里的 link 插件目录(用于在新机上摆放插件源码)。 */
1261
+ async function remoteLinkPlugins(config, machine, profile) {
1262
+ const file = `${machineDir(config, machine)}/files/dsh/profiles/${profile}/package.json`
1263
+ const result = await runSsh(config, `cat ${shQuote(file)} 2>/dev/null || true`)
1264
+ const text = result.stdout.trim()
1265
+ if (text === '') return []
1266
+ try {
1267
+ const manifest = JSON.parse(text)
1268
+ const out = []
1269
+ for (const [name, spec] of Object.entries(manifest.dependencies ?? {})) {
1270
+ if (typeof spec !== 'string' || !spec.startsWith('link:')) continue
1271
+ out.push({ name, target: spec.slice(5).replace(/\\/g, '/').replace(/\/+$/, '') })
1272
+ }
1273
+ return out
1274
+ } catch {
1275
+ return []
1276
+ }
1277
+ }
1278
+
1279
+ /**
1280
+ * 从云端恢复:只补缺失/云端更新的文件,覆盖前先本地备份。
1281
+ * @param {{config: object, from?: string, force?: boolean, log?: Function, onProgress?: Function, dshHome?: string, profile?: string}} options
1282
+ */
1283
+ export async function pull(options) {
1284
+ const config = options.config
1285
+ if (!isConfigured(config)) throw new Error('未配置服务器:请在 profile 的 cordis.patch.yml 里设置 host(和 identityFile),或让 Agent 帮忙填')
1286
+ const log = options.log ?? (() => {})
1287
+ const dshRoot = options.dshHome ?? dshHome()
1288
+ const profile = options.profile ?? profileName()
1289
+ const localProfileDir = resolveProfileDir(dshRoot, profile, options.profileDir)
1290
+
1291
+ let from = options.from
1292
+ if (from === undefined || from === '') {
1293
+ const listing = await listMachines(config)
1294
+ const candidates = listing.machines.filter((item) => item.manifestBytes > 0)
1295
+ if (candidates.length === 1) from = candidates[0].id
1296
+ else if (candidates.length === 0) throw new Error('云端还没有任何备份(先在旧机器上推送一次)')
1297
+ else throw new Error(`云端有 ${candidates.length} 台机器的备份(${candidates.map((item) => item.id).join('、')}),请指定从哪台恢复`)
1298
+ }
1299
+ const remote = await readRemoteManifest(config, from)
1300
+ const remoteEntries = Object.keys(remote.entries)
1301
+ if (remoteEntries.length === 0) throw new Error(`机器 ${from} 在云端没有清单`)
1302
+
1303
+ // 插件源码落点:优先用服务端 package.json 里记录的 link 路径,本机没有就放到 localPluginRoot 下
1304
+ const pluginTargets = new Map()
1305
+ const remotePlugins = config.includeLocalPlugins ? await remoteLinkPlugins(config, from, profile) : []
1306
+ for (const plugin of remotePlugins) {
1307
+ const hasPrefix = remoteEntries.some((rel) => rel.startsWith(`plugins/${plugin.name}/`))
1308
+ if (!hasPrefix) continue
1309
+ let target = plugin.target
1310
+ if (!fs.existsSync(target)) {
1311
+ const fallback = `${config.localPluginRoot}/${plugin.name}`
1312
+ try {
1313
+ fs.mkdirSync(fallback, { recursive: true })
1314
+ target = fallback
1315
+ } catch (error) {
1316
+ log(`无法创建插件目录 ${fallback}:${error instanceof Error ? error.message : String(error)}`)
1317
+ continue
1318
+ }
1319
+ }
1320
+ pluginTargets.set(plugin.name, target)
1321
+ }
1322
+
1323
+ // 先按云端共享删除记录清理本机:别的机器删掉的东西,这台也删(放进 removed/,可找回)
1324
+ const shared = await readSharedTombstones(config)
1325
+ let applied = { removed: [], revived: [] }
1326
+ if (config.applyRemoteDeletes && Object.keys(shared).length > 0) {
1327
+ applied = await applyTombstones({ config, dshRoot, profile, tombstones: shared, log })
1328
+ if (applied.removed.length > 0) log(`按云端删除记录移除本机 ${applied.removed.length} 个文件(放进 .cloud-sync/removed/,可找回)`)
1329
+ if (applied.revived.length > 0) log(`另有 ${applied.revived.length} 个文件比删除记录新,保留`)
1330
+ }
1331
+ const inventory = buildInventory({ config, dshHome: dshRoot, profile, profileDir: localProfileDir })
1332
+ // 本地记忆:墓碑 = 本机删过的(含云端共享的);known = 本机确实拥有过的
1333
+ const tombstones = mergeTombstones(readTombstones(dshRoot), shared)
1334
+ const known = readKnown(dshRoot)
1335
+ for (const rel of inventory.entries.keys()) delete tombstones[rel]
1336
+ const now = Date.now()
1337
+ const wanted = []
1338
+ let skippedRecent = 0
1339
+ let skippedDeleted = 0
1340
+ for (const rel of remoteEntries) {
1341
+ if (!isSafeRel(rel)) {
1342
+ log(`忽略非法路径:${rel}`)
1343
+ continue
1344
+ }
1345
+ if (config.includeAttachments === false && rel.startsWith('dsh/attachments/')) continue
1346
+ const meta = remote.entries[rel] ?? {}
1347
+ const remoteMtime = Number(meta.m) || 0
1348
+ const tomb = Number(tombstones[rel]) || 0
1349
+ if (options.force !== true && tomb > 0 && tomb >= remoteMtime) {
1350
+ skippedDeleted += 1
1351
+ continue
1352
+ }
1353
+ const local = inventory.entries.get(rel)
1354
+ if (local === undefined) {
1355
+ if (options.force !== true && known[rel] !== undefined) {
1356
+ // 本机以前确实有、现在没了 = 本地删掉的,不拉回来
1357
+ tombstones[rel] = Date.now()
1358
+ skippedDeleted += 1
1359
+ continue
1360
+ }
1361
+ wanted.push({ rel, mtimeMs: remoteMtime })
1362
+ continue
1363
+ }
1364
+ if (remoteMtime <= local.mtimeMs + 2000) continue
1365
+ if (options.force !== true && now - local.mtimeMs < 5 * 60 * 1000) {
1366
+ skippedRecent += 1
1367
+ continue
1368
+ }
1369
+ wanted.push({ rel, mtimeMs: remoteMtime })
1370
+ }
1371
+ log(`云端 ${remoteEntries.length} 个文件,需要恢复 ${wanted.length} 个${skippedRecent === 0 ? '' : `(${skippedRecent} 个本地刚改过,跳过)`}${skippedDeleted === 0 ? '' : `(${skippedDeleted} 个本地删过,不拉回)`}`)
1372
+ if (wanted.length === 0) {
1373
+ writeTombstones(dshRoot, tombstones)
1374
+ await syncSharedTombstones(config, tombstones, log, applied.revived)
1375
+ pruneLocalState(dshRoot, config.backupDays)
1376
+ return { ok: true, from, restored: 0, bytes: 0, skipped: 0, plugins: pluginTargets.size, skippedRecent, skippedDeleted, removed: applied.removed.length }
1377
+ }
1378
+
1379
+ const backupDir = path.join(dshRoot, '.cloud-sync', 'backup', timestamp())
1380
+ const groups = new Map()
1381
+ for (const item of wanted) {
1382
+ const group = groupOf(item.rel)
1383
+ const list = groups.get(group) ?? []
1384
+ list.push(item)
1385
+ groups.set(group, list)
1386
+ }
1387
+
1388
+ let restored = 0
1389
+ let skipped = 0
1390
+ let bytes = 0
1391
+ const restoredRels = new Set()
1392
+
1393
+ for (const [group, items] of groups) {
1394
+ const mDir = machineDir(config, from)
1395
+ const list = items.map((item) => memberOf(item.rel)).join('\n') + '\n'
1396
+ const child = spawnSsh(config, `cd ${shQuote(`${mDir}/files/${group}`)} && tar -cf - -T -`)
1397
+ const stderr = []
1398
+ child.stderr.on('data', (chunk) => stderr.push(chunk))
1399
+ child.stdin.on('error', () => {})
1400
+ child.stdin.write(list)
1401
+ child.stdin.end()
1402
+ let lastActivity = Date.now()
1403
+ let stalled = false
1404
+ const reader = new ByteReader(child.stdout, () => { lastActivity = Date.now() })
1405
+ const stallTimer = setInterval(() => {
1406
+ if (Date.now() - lastActivity > STALL_MS) {
1407
+ stalled = true
1408
+ lastActivity = Date.now()
1409
+ child.kill()
1410
+ }
1411
+ }, 10000)
1412
+ if (typeof stallTimer.unref === 'function') stallTimer.unref()
1413
+ const closed = new Promise((resolve) => {
1414
+ child.on('error', (error) => resolve({ code: -1, stderr: error.message }))
1415
+ child.on('close', (code) => resolve({ code, stderr: Buffer.concat(stderr).toString('utf8') }))
1416
+ })
1417
+
1418
+ try {
1419
+ await readTar(reader, async (file) => {
1420
+ const rel = `${group}/${file.name}`
1421
+ if (!isSafeRel(rel)) {
1422
+ await file.reader.skip(file.size)
1423
+ return
1424
+ }
1425
+ const target = resolveTarget(rel, { dshHome: dshRoot, pluginTargets })
1426
+ if (target === undefined) {
1427
+ await file.reader.skip(file.size)
1428
+ skipped += 1
1429
+ return
1430
+ }
1431
+ await fsp.mkdir(path.dirname(target), { recursive: true })
1432
+ if (fs.existsSync(target)) {
1433
+ const backup = path.join(backupDir, rel.split('/').join(path.sep))
1434
+ await fsp.mkdir(path.dirname(backup), { recursive: true })
1435
+ await fsp.copyFile(target, backup).catch(() => {})
1436
+ }
1437
+ const temp = `${target}.dsh-sync-${process.pid}.tmp`
1438
+ const handle = await fsp.open(temp, 'w')
1439
+ try {
1440
+ let remaining = file.size
1441
+ while (remaining > 0) {
1442
+ const chunk = await file.reader.readExact(Math.min(remaining, 1 << 20))
1443
+ if (chunk.length === 0) break
1444
+ await handle.write(chunk)
1445
+ remaining -= chunk.length
1446
+ bytes += chunk.length
1447
+ if (options.onProgress !== undefined) options.onProgress({ phase: `恢复 ${group}`, done: bytes, total: undefined })
1448
+ }
1449
+ if (remaining > 0) throw new Error(`文件不完整:${rel}`)
1450
+ } finally {
1451
+ await handle.close()
1452
+ }
1453
+ await fsp.rename(temp, target)
1454
+ if (file.mtimeMs > 0) await fsp.utimes(target, new Date(file.mtimeMs), new Date(file.mtimeMs)).catch(() => {})
1455
+ restored += 1
1456
+ restoredRels.add(rel)
1457
+ })
1458
+ } finally {
1459
+ clearInterval(stallTimer)
1460
+ const result = await closed
1461
+ if (stalled) {
1462
+ throw new Error(`恢复 ${group} 时传输卡住(${Math.round(STALL_MS / 1000)} 秒没有数据),已断开;云端数据没动,稍后重试即可`)
1463
+ }
1464
+ if (result.code !== 0) {
1465
+ log(`恢复 ${group} 时远端退出码 ${result.code}:${String(result.stderr).trim()}`)
1466
+ }
1467
+ }
1468
+ }
1469
+
1470
+ log(`恢复完成:${restored} 个文件,${skipped} 个找不到落点而被跳过`)
1471
+ await rewriteProfileLinks(localProfileDir, pluginTargets, log)
1472
+
1473
+ // 记下「本机现在确实有哪些文件」:下次恢复时,本机拥有过但已经不在的,视为本地删除,不再拉回。
1474
+ const nextKnown = { ...known }
1475
+ for (const [rel, entry] of inventory.entries) nextKnown[rel] = { s: entry.size, m: entry.mtimeMs }
1476
+ for (const rel of restoredRels) {
1477
+ const meta = remote.entries[rel]
1478
+ if (meta !== undefined) nextKnown[rel] = { s: Number(meta.s) || 0, m: Number(meta.m) || 0 }
1479
+ }
1480
+ writeKnown(dshRoot, nextKnown)
1481
+ writeTombstones(dshRoot, tombstones)
1482
+ // force 恢复回来的文件视为「删掉后又被重建」:同时把共享删除记录撤掉,免得别的机器又删它
1483
+ await syncSharedTombstones(config, tombstones, log, options.force === true ? [...applied.revived, ...restoredRels] : applied.revived)
1484
+ const pruned = pruneLocalState(dshRoot, config.backupDays)
1485
+ if (pruned.backups > 0 || pruned.staging > 0) log(`顺手清理本机旧备份 ${pruned.backups} 份、残留暂存 ${pruned.staging} 份`)
1486
+ return { ok: true, from, restored, bytes, skipped, plugins: pluginTargets.size, skippedRecent, skippedDeleted, removed: applied.removed.length }
1487
+ }
1488
+
1489
+ function timestamp() {
1490
+ const now = new Date()
1491
+ const pad = (value) => String(value).padStart(2, '0')
1492
+ return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
1493
+ }
1494
+
1495
+ function resolveTarget(rel, context) {
1496
+ const parts = rel.split('/')
1497
+ if (parts[0] === 'dsh') return path.join(context.dshHome, ...parts.slice(1))
1498
+ if (parts[0] === 'plugins' && parts.length > 2) {
1499
+ const target = context.pluginTargets.get(parts[1])
1500
+ if (target === undefined) return undefined
1501
+ return path.join(target, ...parts.slice(2))
1502
+ }
1503
+ return undefined
1504
+ }
1505
+
1506
+ /** 恢复后把 profile package.json 里指向别处的 link: 路径改成本机实际目录。 */
1507
+ async function rewriteProfileLinks(profileDirPath, pluginTargets, log) {
1508
+ if (pluginTargets.size === 0) return
1509
+ const file = path.join(profileDirPath, 'package.json')
1510
+ let manifest
1511
+ try {
1512
+ manifest = JSON.parse(await fsp.readFile(file, 'utf8').then((text) => text.replace(/^\uFEFF/, '')))
1513
+ } catch {
1514
+ return
1515
+ }
1516
+ let changed = false
1517
+ for (const [name, target] of pluginTargets) {
1518
+ const spec = manifest.dependencies?.[name]
1519
+ if (typeof spec !== 'string' || !spec.startsWith('link:')) continue
1520
+ const current = spec.slice(5).replace(/\\/g, '/').replace(/\/+$/, '')
1521
+ if (current === target) continue
1522
+ manifest.dependencies[name] = `link:${target}`
1523
+ changed = true
1524
+ log(`插件 ${name} 的 link 路径改到 ${target}`)
1525
+ }
1526
+ if (!changed) return
1527
+ await fsp.writeFile(file, JSON.stringify(manifest, null, 2) + '\n', 'utf8')
1528
+ log('profile package.json 已更新:装好插件依赖后重启 DSH 生效')
1529
+ }
1530
+
1531
+ /* ── 连接自检 ───────────────────────────────────────────────────────────── */
1532
+
1533
+ /** 检查 ssh 是否可用(面板状态与推送前用)。 */
1534
+ export async function checkConnection(config) {
1535
+ const started = Date.now()
1536
+ try {
1537
+ const result = await runSsh(config, 'printf "PONG %s" "$(hostname)"')
1538
+ const ok = result.code === 0 && result.stdout.includes('PONG')
1539
+ return {
1540
+ ok,
1541
+ server: ok ? result.stdout.trim().replace(/^PONG\s*/, '') : undefined,
1542
+ ms: Date.now() - started,
1543
+ error: ok ? undefined : (result.stderr.trim() || `退出码 ${result.code}`),
1544
+ }
1545
+ } catch (error) {
1546
+ return { ok: false, ms: Date.now() - started, error: error instanceof Error ? error.message : String(error) }
1547
+ }
1548
+ }