@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/README.md +131 -2
- package/client.js +284 -0
- package/cordis.patch.yml +7 -0
- package/icon.svg +19 -0
- package/index.js +650 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +50 -3
- package/sync.js +1548 -0
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
|
+
}
|