@leiyi-works/dsh-cloud-sync 0.0.0-stage → 0.1.6
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 +288 -0
- package/cordis.patch.yml +7 -0
- package/icon.svg +19 -0
- package/index.js +682 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +50 -3
- package/sync.js +1620 -0
package/index.js
ADDED
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-cloud-sync — Host 半。
|
|
3
|
+
*
|
|
4
|
+
* 把 DSH 的状态(聊天记录、工作区/定时任务、插件清单与本地插件源码、钥则、凭据)
|
|
5
|
+
* 增量推到云服务器(京东云 jdcloud,SSH 通道),换机或新机登录后一键拉回。
|
|
6
|
+
*
|
|
7
|
+
* 提供的入口:
|
|
8
|
+
* · 设置 → 云端同步(面板):看状态、改不了配置但能手动推/恢复
|
|
9
|
+
* · 工具 cloud_sync_status / cloud_sync_push / cloud_sync_pull
|
|
10
|
+
* · HTTP 前缀 /__dsh-cloud-sync/*(面板用)
|
|
11
|
+
* · 后台定时器:默认每 24 小时自动推一次;新机本地没有任何会话时首启自动恢复
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import fsp from 'node:fs/promises'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import z from '@deepseek-ai/schemastery'
|
|
17
|
+
import {
|
|
18
|
+
DEFAULTS,
|
|
19
|
+
buildInventory,
|
|
20
|
+
checkConnection,
|
|
21
|
+
dshHome,
|
|
22
|
+
listMachines,
|
|
23
|
+
localFingerprint,
|
|
24
|
+
localSummary,
|
|
25
|
+
normalizeConfig,
|
|
26
|
+
profileName,
|
|
27
|
+
pull,
|
|
28
|
+
push,
|
|
29
|
+
readRemoteManifest,
|
|
30
|
+
readLinkPlugins,
|
|
31
|
+
readTombstones,
|
|
32
|
+
defaultMachineId,
|
|
33
|
+
isConfigured,
|
|
34
|
+
} from './sync.js'
|
|
35
|
+
import { setSubprocessProvider } from './exec.js'
|
|
36
|
+
|
|
37
|
+
/** 这些错误是「环境不具备」,重试多少次都没用:停止自动重试,交给 UI 提示。 */
|
|
38
|
+
const PERMANENT_ERROR_RE = /ENOENT|EACCES|EPERM|not a function|私钥不存在|未配置服务器|Permission denied|no such identity|Cannot find module|spawn .* ENOENT/i
|
|
39
|
+
|
|
40
|
+
function isPermanentError(message) {
|
|
41
|
+
return PERMANENT_ERROR_RE.test(String(message ?? ''))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const name = 'dsh-cloud-sync'
|
|
45
|
+
export const inject = ['tools', 'webServer']
|
|
46
|
+
|
|
47
|
+
const ROUTE_PREFIX = '/__dsh-cloud-sync'
|
|
48
|
+
const LOG_TAG = 'dsh-cloud-sync'
|
|
49
|
+
const TICK_MS = 60 * 1000
|
|
50
|
+
const BOOT_DELAY_MS = 30 * 1000
|
|
51
|
+
const TOOL_WAIT_MS = 90 * 1000
|
|
52
|
+
|
|
53
|
+
/** 插件配置:保存后在设置页立即生效,无需重启。 */
|
|
54
|
+
export const Config = z.object({
|
|
55
|
+
host: z.string().default(DEFAULTS.host)
|
|
56
|
+
.description('云服务器 SSH 目标(user@host)。留空则从 connectionFile 读取'),
|
|
57
|
+
port: z.natural().default(DEFAULTS.port).description('SSH 端口'),
|
|
58
|
+
identityFile: z.string().default(DEFAULTS.identityFile)
|
|
59
|
+
.description('私钥路径;留空则从 connectionFile 读取'),
|
|
60
|
+
connectionFile: z.string().default(DEFAULTS.connectionFile)
|
|
61
|
+
.description('连接信息 json(默认 <DSH_HOME>/keyrule-secrets/ssh.json,随钥则目录一起同步)'),
|
|
62
|
+
identityFileGuest: z.string().default(DEFAULTS.identityFileGuest)
|
|
63
|
+
.description('guest/VM 世界里的私钥路径(手机端用;填了就不校验宿主路径)'),
|
|
64
|
+
sshBinary: z.string().default(DEFAULTS.sshBinary)
|
|
65
|
+
.description('ssh 可执行文件(留空 = 自动找;手机端可填 guest 里的绝对路径)'),
|
|
66
|
+
sshCompat: z.string().default(DEFAULTS.sshCompat)
|
|
67
|
+
.description('ssh 兼容模式:openssh(默认)/ dropbear(精简参数、-y 接受主机密钥)'),
|
|
68
|
+
connectTimeoutMs: z.natural().default(DEFAULTS.connectTimeoutMs)
|
|
69
|
+
.description('建连超时(毫秒;手机端软件模拟 SSH 很慢,建议 60000 以上)'),
|
|
70
|
+
commandTimeoutMs: z.natural().default(DEFAULTS.commandTimeoutMs)
|
|
71
|
+
.description('单条远程命令超时(毫秒)'),
|
|
72
|
+
stallMs: z.natural().default(DEFAULTS.stallMs)
|
|
73
|
+
.description('传输停滞判定(毫秒):连续这么久没有数据就断开重试'),
|
|
74
|
+
deviceMode: z.boolean().default(DEFAULTS.deviceMode)
|
|
75
|
+
.description('设备模式:默认每天一次、不带附件/插件源码、不自动整份恢复(手机端建议开)'),
|
|
76
|
+
remoteRoot: z.string().default(DEFAULTS.remoteRoot).description('服务器上的备份根目录'),
|
|
77
|
+
machineId: z.string().default('').description('这台机器的槽位名,留空 = 主机名'),
|
|
78
|
+
localPluginRoot: z.string().default(DEFAULTS.localPluginRoot)
|
|
79
|
+
.description('新机上本地插件源码放哪(原路径不存在时用)'),
|
|
80
|
+
intervalMinutes: z.natural().default(DEFAULTS.intervalMinutes).description('自动同步间隔(分钟);多台机器来回用时建议 5~10 分钟'),
|
|
81
|
+
dailySync: z.boolean().default(DEFAULTS.dailySync)
|
|
82
|
+
.description('改成「每天一次」:当天还没同步过就补一轮(打开 DSH 后很快检查;忽略 intervalMinutes)'),
|
|
83
|
+
pauseWhenIdle: z.boolean().default(DEFAULTS.pauseWhenIdle)
|
|
84
|
+
.description('没有会话在跑、也没有操作时暂停自动同步,等下次有动静再补一轮'),
|
|
85
|
+
idlePauseMinutes: z.natural().default(DEFAULTS.idlePauseMinutes)
|
|
86
|
+
.description('空闲多少分钟后暂停自动同步(pauseWhenIdle 打开时生效)'),
|
|
87
|
+
autoSync: z.boolean().default(DEFAULTS.autoSync).description('开启后台自动同步'),
|
|
88
|
+
applyRemoteDeletes: z.boolean().default(DEFAULTS.applyRemoteDeletes)
|
|
89
|
+
.description('别的机器删掉的文件,本机同步时也删(会先放进 .cloud-sync/removed/,可找回)'),
|
|
90
|
+
restoreOnEmpty: z.boolean().default(DEFAULTS.restoreOnEmpty).description('新机(本地没有会话)首次启动自动恢复'),
|
|
91
|
+
includeAttachments: z.boolean().default(DEFAULTS.includeAttachments).description('同步聊天图片附件(约几百 MB,一次性)'),
|
|
92
|
+
includeCredentials: z.boolean().default(DEFAULTS.includeCredentials).description('同步 .credentials.yaml(登录凭据)'),
|
|
93
|
+
includeKeyruleSecrets: z.boolean().default(DEFAULTS.includeKeyruleSecrets).description('同步 keyrule-secrets(SSH 私钥、npm token)'),
|
|
94
|
+
includeLocalPlugins: z.boolean().default(DEFAULTS.includeLocalPlugins).description('同步本地 link 插件源码'),
|
|
95
|
+
quietSeconds: z.natural().default(DEFAULTS.quietSeconds).description('推送时跳过最近 N 秒内改过的文件(这些文件下一轮再传,避免抓到写一半的内容)'),
|
|
96
|
+
trashDays: z.natural().default(DEFAULTS.trashDays).description('云端回收站保留天数(本地删掉的会话/附件)'),
|
|
97
|
+
versionDays: z.natural().default(DEFAULTS.versionDays).description('云端旧版本保留天数(被覆盖前的文件)'),
|
|
98
|
+
backupDays: z.natural().default(DEFAULTS.backupDays).description('本机恢复前备份的保留天数(超过就自动清,避免越攒越多)'),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
function normalizeSchema(schema) {
|
|
102
|
+
if (schema === undefined) return { type: 'object', additionalProperties: true }
|
|
103
|
+
if (schema.type === 'json') return { type: 'object', additionalProperties: true }
|
|
104
|
+
return schema
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function defineTool(spec) {
|
|
108
|
+
const parameters = spec.parameters ?? {}
|
|
109
|
+
const properties = {}
|
|
110
|
+
const required = []
|
|
111
|
+
for (const [key, definition] of Object.entries(parameters)) {
|
|
112
|
+
const { required: isRequired, ...rest } = definition
|
|
113
|
+
properties[key] = rest
|
|
114
|
+
if (isRequired === true) required.push(key)
|
|
115
|
+
}
|
|
116
|
+
const output = spec.output === undefined ? undefined : { ...spec.output, schema: normalizeSchema(spec.output.schema) }
|
|
117
|
+
return {
|
|
118
|
+
...spec,
|
|
119
|
+
...(output === undefined ? {} : { output }),
|
|
120
|
+
parameters: { type: 'object', properties, ...(required.length === 0 ? {} : { required }), additionalProperties: false },
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const clean = (value) => JSON.parse(JSON.stringify(value))
|
|
125
|
+
const stamp = () => {
|
|
126
|
+
const now = new Date()
|
|
127
|
+
const pad = (value) => String(value).padStart(2, '0')
|
|
128
|
+
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 两个时间戳是不是同一个本地日期(「每天一次」用)。 */
|
|
132
|
+
function sameLocalDay(a, b) {
|
|
133
|
+
const left = new Date(Number(a))
|
|
134
|
+
const right = new Date(Number(b))
|
|
135
|
+
return left.getFullYear() === right.getFullYear()
|
|
136
|
+
&& left.getMonth() === right.getMonth()
|
|
137
|
+
&& left.getDate() === right.getDate()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 明天 00:05 的时间戳(每天一次的「下次」时间)。 */
|
|
141
|
+
function nextLocalMidnight(now = Date.now()) {
|
|
142
|
+
const date = new Date(now)
|
|
143
|
+
date.setHours(24, 5, 0, 0)
|
|
144
|
+
return date.getTime()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* ── 本地状态文件 ──────────────────────────────────────────────────────── */
|
|
148
|
+
|
|
149
|
+
function stateFile() {
|
|
150
|
+
return path.join(dshHome(), '.cloud-sync', 'state.json')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function readState() {
|
|
154
|
+
try {
|
|
155
|
+
return JSON.parse(fs.readFileSync(stateFile(), 'utf8').replace(/^\uFEFF/, ''))
|
|
156
|
+
} catch {
|
|
157
|
+
return { version: 1 }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function writeState(next) {
|
|
162
|
+
const file = stateFile()
|
|
163
|
+
try {
|
|
164
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
165
|
+
const temp = `${file}.tmp`
|
|
166
|
+
fs.writeFileSync(temp, JSON.stringify(next, null, 2), 'utf8')
|
|
167
|
+
fs.renameSync(temp, file)
|
|
168
|
+
} catch { /* 状态写不进去不影响同步本身 */ }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function appendLog(line) {
|
|
172
|
+
try {
|
|
173
|
+
const file = path.join(dshHome(), '.cloud-sync', 'sync.log')
|
|
174
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
175
|
+
try {
|
|
176
|
+
if (fs.statSync(file).size > 2 * 1024 * 1024) fs.renameSync(file, `${file}.1`)
|
|
177
|
+
} catch { /* 文件不存在 */ }
|
|
178
|
+
fs.appendFileSync(file, `[${new Date().toISOString()}] ${line}\n`, 'utf8')
|
|
179
|
+
} catch { /* 日志失败不影响同步 */ }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* ── 运行器 ─────────────────────────────────────────────────────────────── */
|
|
183
|
+
|
|
184
|
+
function createRunner(config) {
|
|
185
|
+
const job = { phase: 'idle', kind: undefined, startedAt: 0, finishedAt: 0, log: [], progress: undefined, result: undefined, error: undefined }
|
|
186
|
+
let current
|
|
187
|
+
const state = readState()
|
|
188
|
+
|
|
189
|
+
const active = () => job.phase === 'running'
|
|
190
|
+
|
|
191
|
+
const run = (kind, options = {}) => {
|
|
192
|
+
if (active()) throw new Error(`已有${job.kind === 'pull' ? '恢复' : '推送'}任务在跑,请稍候`)
|
|
193
|
+
job.phase = 'running'
|
|
194
|
+
job.kind = kind
|
|
195
|
+
job.startedAt = Date.now()
|
|
196
|
+
job.finishedAt = 0
|
|
197
|
+
job.log = []
|
|
198
|
+
job.progress = undefined
|
|
199
|
+
job.result = undefined
|
|
200
|
+
job.error = undefined
|
|
201
|
+
const lines = (line) => {
|
|
202
|
+
job.log.push(line)
|
|
203
|
+
if (job.log.length > 400) job.log = job.log.slice(-400)
|
|
204
|
+
appendLog(`${kind} ${line}`)
|
|
205
|
+
}
|
|
206
|
+
const onProgress = (progress) => {
|
|
207
|
+
job.progress = { ...progress, at: Date.now() }
|
|
208
|
+
}
|
|
209
|
+
current = (async () => {
|
|
210
|
+
try {
|
|
211
|
+
const result = kind === 'push'
|
|
212
|
+
? await push({ config, log: lines, onProgress, auto: options.auto === true })
|
|
213
|
+
: await pull({ config, log: lines, onProgress, from: options.from, force: options.force === true })
|
|
214
|
+
job.result = clean(result)
|
|
215
|
+
job.phase = 'done'
|
|
216
|
+
const next = {
|
|
217
|
+
...readState(),
|
|
218
|
+
version: 1,
|
|
219
|
+
machine: result.machine ?? options.from ?? readState().machine,
|
|
220
|
+
lastKind: kind,
|
|
221
|
+
lastAt: new Date().toISOString(),
|
|
222
|
+
...(kind === 'push' ? { lastPush: clean(result), lastPushAt: Date.now() } : { lastPull: clean(result), lastPullAt: Date.now() }),
|
|
223
|
+
lastError: undefined,
|
|
224
|
+
}
|
|
225
|
+
if (kind === 'push' && result.deferred > 0) next.pendingRetryAt = Date.now() + Math.max(60 * 1000, config.quietSeconds * 1000 + 60 * 1000)
|
|
226
|
+
else delete next.pendingRetryAt
|
|
227
|
+
writeState(next)
|
|
228
|
+
return result
|
|
229
|
+
} catch (error) {
|
|
230
|
+
job.error = error instanceof Error ? error.message : String(error)
|
|
231
|
+
job.phase = 'failed'
|
|
232
|
+
const permanent = isPermanentError(job.error)
|
|
233
|
+
const next = {
|
|
234
|
+
...readState(),
|
|
235
|
+
version: 1,
|
|
236
|
+
lastKind: kind,
|
|
237
|
+
lastAt: new Date().toISOString(),
|
|
238
|
+
lastError: { kind, message: job.error, at: new Date().toISOString(), ...(permanent ? { permanent: true } : {}) },
|
|
239
|
+
...(permanent ? {} : { pendingRetryAt: Date.now() + 30 * 60 * 1000 }),
|
|
240
|
+
}
|
|
241
|
+
writeState(next)
|
|
242
|
+
appendLog(`${kind} 失败${permanent ? '(环境不可用,已停止自动重试)' : ''}:${job.error}`)
|
|
243
|
+
throw error
|
|
244
|
+
} finally {
|
|
245
|
+
job.finishedAt = Date.now()
|
|
246
|
+
current = undefined
|
|
247
|
+
invalidateCache()
|
|
248
|
+
}
|
|
249
|
+
})()
|
|
250
|
+
current.catch(() => {})
|
|
251
|
+
return current
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return { job, run, active, state: () => readState() }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/* ── 面板/工具共用的状态 ────────────────────────────────────────────────── */
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 面板每 4 秒拉一次状态,不能每次都真去 ssh 数一遍云端目录:远端清单缓存 60 秒,
|
|
261
|
+
* 本地扫描缓存 10 秒,手动操作(push/pull)完成时清缓存立即刷新。
|
|
262
|
+
*/
|
|
263
|
+
const cache = { machines: { at: 0, value: undefined }, local: { at: 0, value: undefined } }
|
|
264
|
+
const MACHINES_TTL = 60 * 1000
|
|
265
|
+
const LOCAL_TTL = 10 * 1000
|
|
266
|
+
|
|
267
|
+
/** 本机的「活动」状态:有几个会话在跑、最后一次有动静是什么时候。用来做空闲暂停。 */
|
|
268
|
+
const activity = {
|
|
269
|
+
running: new Set(),
|
|
270
|
+
lastAt: Date.now(),
|
|
271
|
+
pausedLogged: false,
|
|
272
|
+
idleFor() {
|
|
273
|
+
return { running: activity.running.size, idleMs: Date.now() - activity.lastAt }
|
|
274
|
+
},
|
|
275
|
+
}
|
|
276
|
+
function invalidateCache() {
|
|
277
|
+
cache.machines.at = 0
|
|
278
|
+
cache.local.at = 0
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function cachedMachines(config) {
|
|
282
|
+
const now = Date.now()
|
|
283
|
+
if (cache.machines.value !== undefined && now - cache.machines.at < MACHINES_TTL) return cache.machines.value
|
|
284
|
+
let value
|
|
285
|
+
try {
|
|
286
|
+
value = await listMachines(config)
|
|
287
|
+
} catch (error) {
|
|
288
|
+
value = { ok: false, machines: [], error: error instanceof Error ? error.message : String(error) }
|
|
289
|
+
}
|
|
290
|
+
cache.machines = { at: Date.now(), value }
|
|
291
|
+
return value
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function cachedLocal(config) {
|
|
295
|
+
const now = Date.now()
|
|
296
|
+
if (cache.local.value !== undefined && now - cache.local.at < LOCAL_TTL) return cache.local.value
|
|
297
|
+
let value
|
|
298
|
+
try {
|
|
299
|
+
value = localSummary(config)
|
|
300
|
+
} catch (error) {
|
|
301
|
+
value = { files: 0, bytes: 0, errors: 1, plugins: 0, message: error instanceof Error ? error.message : String(error) }
|
|
302
|
+
}
|
|
303
|
+
cache.local = { at: Date.now(), value }
|
|
304
|
+
return value
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function buildStatus(config, runner) {
|
|
308
|
+
const state = runner.state()
|
|
309
|
+
const configured = isConfigured(config)
|
|
310
|
+
const machine = config.machineId !== '' ? config.machineId : defaultMachineId()
|
|
311
|
+
const local = cachedLocal(config)
|
|
312
|
+
const remote = configured
|
|
313
|
+
? await cachedMachines(config)
|
|
314
|
+
: { ok: false, machines: [], error: '未配置服务器(host 为空)' }
|
|
315
|
+
let deletedLocally = 0
|
|
316
|
+
try {
|
|
317
|
+
deletedLocally = Object.keys(readTombstones(dshHome())).length
|
|
318
|
+
} catch { /* 记忆文件坏了就当没有 */ }
|
|
319
|
+
const intervalMs = config.intervalMinutes * 60 * 1000
|
|
320
|
+
const lastPushAt = Number(state.lastPushAt) || 0
|
|
321
|
+
const pendingRetryAt = Number(state.pendingRetryAt) || 0
|
|
322
|
+
const intervalDue = lastPushAt === 0 ? Date.now() : lastPushAt + intervalMs
|
|
323
|
+
const dailyDoneToday = lastPushAt > 0 && sameLocalDay(lastPushAt, Date.now())
|
|
324
|
+
const nextPushAt = config.dailySync
|
|
325
|
+
? (dailyDoneToday ? nextLocalMidnight() : Date.now())
|
|
326
|
+
: Math.max(intervalDue, pendingRetryAt > 0 ? pendingRetryAt : 0)
|
|
327
|
+
return {
|
|
328
|
+
ok: true,
|
|
329
|
+
machine,
|
|
330
|
+
config: {
|
|
331
|
+
host: config.host,
|
|
332
|
+
port: config.port,
|
|
333
|
+
identityFile: config.identityFile,
|
|
334
|
+
remoteRoot: config.remoteRoot,
|
|
335
|
+
localPluginRoot: config.localPluginRoot,
|
|
336
|
+
intervalMinutes: config.intervalMinutes,
|
|
337
|
+
dailySync: config.dailySync,
|
|
338
|
+
pauseWhenIdle: config.pauseWhenIdle,
|
|
339
|
+
idlePauseMinutes: config.idlePauseMinutes,
|
|
340
|
+
autoSync: config.autoSync,
|
|
341
|
+
restoreOnEmpty: config.restoreOnEmpty,
|
|
342
|
+
includeAttachments: config.includeAttachments,
|
|
343
|
+
includeCredentials: config.includeCredentials,
|
|
344
|
+
includeKeyruleSecrets: config.includeKeyruleSecrets,
|
|
345
|
+
includeLocalPlugins: config.includeLocalPlugins,
|
|
346
|
+
applyRemoteDeletes: config.applyRemoteDeletes,
|
|
347
|
+
},
|
|
348
|
+
local: { ...local, deletedLocally },
|
|
349
|
+
remote,
|
|
350
|
+
schedule: {
|
|
351
|
+
lastPushAt: lastPushAt || undefined,
|
|
352
|
+
nextPushAt: config.autoSync ? nextPushAt : undefined,
|
|
353
|
+
pendingRetryAt: state.pendingRetryAt,
|
|
354
|
+
},
|
|
355
|
+
idle: {
|
|
356
|
+
running: activity.running.size,
|
|
357
|
+
idleMs: Date.now() - activity.lastAt,
|
|
358
|
+
paused: activity.pausedLogged,
|
|
359
|
+
pauseWhenIdle: config.pauseWhenIdle,
|
|
360
|
+
idlePauseMinutes: config.idlePauseMinutes,
|
|
361
|
+
},
|
|
362
|
+
last: {
|
|
363
|
+
push: state.lastPush,
|
|
364
|
+
pull: state.lastPull,
|
|
365
|
+
error: state.lastError,
|
|
366
|
+
at: state.lastAt,
|
|
367
|
+
},
|
|
368
|
+
job: {
|
|
369
|
+
phase: runner.job.phase,
|
|
370
|
+
kind: runner.job.kind,
|
|
371
|
+
startedAt: runner.job.startedAt,
|
|
372
|
+
finishedAt: runner.job.finishedAt,
|
|
373
|
+
log: runner.job.log.slice(-60),
|
|
374
|
+
progress: runner.job.progress,
|
|
375
|
+
result: runner.job.result,
|
|
376
|
+
error: runner.job.error,
|
|
377
|
+
},
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function summarizeStatus(status) {
|
|
382
|
+
const lines = []
|
|
383
|
+
lines.push(`机器槽位:${status.machine}`)
|
|
384
|
+
if (status.remote?.ok === true) {
|
|
385
|
+
lines.push(`云端:${status.remote.machines.length} 台机器${status.remote.freeBytes === undefined ? '' : `,剩余空间 ${(status.remote.freeBytes / 1073741824).toFixed(1)} GB`}`)
|
|
386
|
+
for (const item of status.remote.machines) {
|
|
387
|
+
lines.push(` - ${item.id}:${(item.filesBytes / 1048576).toFixed(1)} MB${item.manifestMtime === 0 ? '' : `,更新于 ${new Date(item.manifestMtime).toLocaleString()}`}`)
|
|
388
|
+
}
|
|
389
|
+
} else {
|
|
390
|
+
lines.push(`云端:连不上(${status.remote?.error ?? '未知错误'})`)
|
|
391
|
+
}
|
|
392
|
+
lines.push(`本地待同步:${status.local.files} 个文件 / ${(status.local.bytes / 1048576).toFixed(1)} MB,本地插件 ${status.local.plugins} 个`)
|
|
393
|
+
if (Number(status.local.deletedLocally) > 0) lines.push(`本地删过 ${status.local.deletedLocally} 个文件:恢复时不会把它们拉回来(强制恢复可覆盖这条规则)`)
|
|
394
|
+
const lastPush = status.last.push
|
|
395
|
+
lines.push(`上次推送:${status.last.push !== undefined ? `${new Date(Number(status.schedule.lastPushAt) || 0).toLocaleString()}(上传 ${lastPush.uploaded} 个 / 删除 ${lastPush.deleted} 个)` : '还没有'}`)
|
|
396
|
+
lines.push(`上次恢复:${status.last.pull !== undefined ? `${status.last.at}(${status.last.pull.restored} 个文件)` : '还没有'}`)
|
|
397
|
+
if (status.last.error !== undefined) lines.push(`上次失败:${status.last.error.kind} — ${status.last.error.message}`)
|
|
398
|
+
if (status.config.autoSync) {
|
|
399
|
+
lines.push(status.config.dailySync === true
|
|
400
|
+
? `自动同步:每天一次(${status.schedule.lastPushAt !== undefined && sameLocalDay(status.schedule.lastPushAt, Date.now()) ? `今天 ${new Date(status.schedule.lastPushAt).toLocaleTimeString()} 已同步` : '今天还没同步,下次检查时会补一轮'};下次 ${new Date(status.schedule.nextPushAt).toLocaleString()})`
|
|
401
|
+
: `自动同步:每 ${status.config.intervalMinutes} 分钟一次(下次 ${new Date(status.schedule.nextPushAt).toLocaleString()})`)
|
|
402
|
+
}
|
|
403
|
+
else lines.push('自动推送:已关闭')
|
|
404
|
+
if (status.idle?.paused === true) {
|
|
405
|
+
lines.push(`当前空闲暂停中(没有会话在跑,已静默 ${Math.round(status.idle.idleMs / 60000)} 分钟);下次有操作会立即补一轮`)
|
|
406
|
+
} else if (Number(status.idle?.running) > 0) {
|
|
407
|
+
lines.push(`正在运行的会话:${status.idle.running} 个(有活动,不暂停)`)
|
|
408
|
+
}
|
|
409
|
+
if (status.job.phase === 'running') lines.push(`正在${status.job.kind === 'pull' ? '恢复' : '推送'}…(${status.job.log.slice(-1)[0] ?? ''})`)
|
|
410
|
+
return lines.join('\n')
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/* ── 插件入口 ───────────────────────────────────────────────────────────── */
|
|
414
|
+
|
|
415
|
+
export function apply(ctx, rawConfig) {
|
|
416
|
+
const config = normalizeConfig(rawConfig)
|
|
417
|
+
// 命令执行层:优先用 DSH 的可移植 subprocess 服务(手机/沙箱环境里只有它能起进程)
|
|
418
|
+
try {
|
|
419
|
+
const subprocess = typeof ctx.get === 'function' ? ctx.get('subprocess') : undefined
|
|
420
|
+
setSubprocessProvider(subprocess)
|
|
421
|
+
} catch {
|
|
422
|
+
setSubprocessProvider(undefined)
|
|
423
|
+
}
|
|
424
|
+
const runner = createRunner(config)
|
|
425
|
+
|
|
426
|
+
/* 记「有没有会话在跑」:空闲暂停靠它判断 */
|
|
427
|
+
activity.lastAt = Date.now()
|
|
428
|
+
activity.running.clear()
|
|
429
|
+
ctx.effect(() => ctx.on('api-session/status', (sessionId, running) => {
|
|
430
|
+
try {
|
|
431
|
+
const id = String(sessionId)
|
|
432
|
+
if (running === true) activity.running.add(id)
|
|
433
|
+
else activity.running.delete(id)
|
|
434
|
+
activity.lastAt = Date.now()
|
|
435
|
+
} catch { /* 事件结构变化不影响同步本身 */ }
|
|
436
|
+
}), 'dsh-cloud-sync: session activity')
|
|
437
|
+
|
|
438
|
+
const bodyOf = async (req) => {
|
|
439
|
+
const chunks = []
|
|
440
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
441
|
+
try {
|
|
442
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
|
|
443
|
+
} catch {
|
|
444
|
+
return {}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/* 工具 */
|
|
449
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
450
|
+
name: 'cloud_sync_status',
|
|
451
|
+
description: '查看云端同步状态:本机槽位、云端各机器备份大小与时间、上次推送/恢复结果、自动推送计划与最近日志。',
|
|
452
|
+
parameters: {},
|
|
453
|
+
output: { schema: { type: 'json' }, render: (_args, value) => [{ type: 'text', text: value?.summary ?? JSON.stringify(value, null, 2) }] },
|
|
454
|
+
async execute() {
|
|
455
|
+
const status = await buildStatus(config, runner)
|
|
456
|
+
return clean({ ...status, summary: summarizeStatus(status) })
|
|
457
|
+
},
|
|
458
|
+
})), 'dsh-cloud-sync: status tool')
|
|
459
|
+
|
|
460
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
461
|
+
name: 'cloud_sync_push',
|
|
462
|
+
description: '把本地 DSH 状态增量推到云服务器(聊天记录、工作区/定时任务、插件清单与本地插件源码、钥则、凭据、可选附件)。返回上传/删除数量;大文件首次上传可能要几分钟,超时未完成会返回 running=true,再用 cloud_sync_status 查进度。',
|
|
463
|
+
parameters: {
|
|
464
|
+
waitSeconds: { type: 'number', description: '最多等多少秒再返回(默认 90,首次全量约需 300+)' },
|
|
465
|
+
},
|
|
466
|
+
output: { schema: { type: 'json' }, render: (_args, value) => [{ type: 'text', text: value?.summary ?? JSON.stringify(value, null, 2) }] },
|
|
467
|
+
async execute(args) {
|
|
468
|
+
if (runner.active()) {
|
|
469
|
+
const status = await buildStatus(config, runner)
|
|
470
|
+
return clean({ ok: false, running: true, summary: `已有任务在跑:${status.job.kind}`, job: status.job })
|
|
471
|
+
}
|
|
472
|
+
const waitMs = Math.max(5, Math.min(600, Number(args?.waitSeconds) || TOOL_WAIT_MS / 1000)) * 1000
|
|
473
|
+
const promise = runner.run('push', {}).catch((error) => ({ ok: false, error: error instanceof Error ? error.message : String(error) }))
|
|
474
|
+
const result = await Promise.race([promise, new Promise((resolve) => setTimeout(() => resolve(undefined), waitMs))])
|
|
475
|
+
if (result === undefined) {
|
|
476
|
+
const status = await buildStatus(config, runner)
|
|
477
|
+
return clean({ ok: true, running: true, summary: `推送仍在进行中:${status.job.log.slice(-1)[0] ?? ''}`, job: status.job })
|
|
478
|
+
}
|
|
479
|
+
if (result.ok === false) return clean({ ok: false, error: result.error, summary: `推送失败:${result.error}` })
|
|
480
|
+
const summary = result.fastPath === true
|
|
481
|
+
? `本地没有变化,本轮跳过联网比对(本地 ${result.total} 个文件;仍是每 ${config.intervalMinutes} 分钟一轮)`
|
|
482
|
+
: result.skipped === true
|
|
483
|
+
? `没有变化,无需上传(云端已有 ${result.total} 个文件${result.deferred > 0 ? `,${result.deferred} 个刚改过的留到下轮` : ''})`
|
|
484
|
+
: `推送完成:上传 ${result.uploaded} 个文件,删除 ${result.deleted} 个,云端合计 ${(result.bytes / 1048576).toFixed(1)} MB`
|
|
485
|
+
return clean({ ...result, summary })
|
|
486
|
+
},
|
|
487
|
+
})), 'dsh-cloud-sync: push tool')
|
|
488
|
+
|
|
489
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
490
|
+
name: 'cloud_sync_pull',
|
|
491
|
+
description: '从云服务器恢复 DSH 状态到本机(新机/换机后使用):只补本地缺失或云端更新的文件,覆盖前自动本地备份,不会删除本地文件。',
|
|
492
|
+
parameters: {
|
|
493
|
+
from: { type: 'string', description: '从哪台机器的备份恢复;不填时若云端只有一台就自动用它' },
|
|
494
|
+
force: { type: 'boolean', description: '连最近 5 分钟内改过的本地文件也覆盖(默认 false)' },
|
|
495
|
+
waitSeconds: { type: 'number', description: '最多等多少秒再返回(默认 90)' },
|
|
496
|
+
},
|
|
497
|
+
output: { schema: { type: 'json' }, render: (_args, value) => [{ type: 'text', text: value?.summary ?? JSON.stringify(value, null, 2) }] },
|
|
498
|
+
async execute(args) {
|
|
499
|
+
if (runner.active()) {
|
|
500
|
+
const status = await buildStatus(config, runner)
|
|
501
|
+
return clean({ ok: false, running: true, summary: `已有任务在跑:${status.job.kind}`, job: status.job })
|
|
502
|
+
}
|
|
503
|
+
const waitMs = Math.max(5, Math.min(600, Number(args?.waitSeconds) || TOOL_WAIT_MS / 1000)) * 1000
|
|
504
|
+
const promise = runner.run('pull', { from: args?.from, force: args?.force === true })
|
|
505
|
+
.catch((error) => ({ ok: false, error: error instanceof Error ? error.message : String(error) }))
|
|
506
|
+
const result = await Promise.race([promise, new Promise((resolve) => setTimeout(() => resolve(undefined), waitMs))])
|
|
507
|
+
if (result === undefined) {
|
|
508
|
+
const status = await buildStatus(config, runner)
|
|
509
|
+
return clean({ ok: true, running: true, summary: `恢复仍在进行中:${status.job.log.slice(-1)[0] ?? ''}`, job: status.job })
|
|
510
|
+
}
|
|
511
|
+
if (result.ok === false) return clean({ ok: false, error: result.error, summary: `恢复失败:${result.error}` })
|
|
512
|
+
return clean({
|
|
513
|
+
...result,
|
|
514
|
+
summary: `恢复完成:从 ${result.from} 拉回 ${result.restored} 个文件(${(result.bytes / 1048576).toFixed(1)} MB)${result.skipped > 0 ? `,${result.skipped} 个找不到落点` : ''}${result.plugins > 0 ? `,插件源码 ${result.plugins} 个` : ''}${result.skippedDeleted > 0 ? `,${result.skippedDeleted} 个本地删过的不拉回` : ''}。若恢复了插件清单,需重装插件依赖并重启 DSH。`,
|
|
515
|
+
})
|
|
516
|
+
},
|
|
517
|
+
})), 'dsh-cloud-sync: pull tool')
|
|
518
|
+
|
|
519
|
+
/* HTTP 路由(面板) */
|
|
520
|
+
ctx.effect(() => ctx.webServer.register({
|
|
521
|
+
kind: 'prefix',
|
|
522
|
+
path: ROUTE_PREFIX,
|
|
523
|
+
handler: async (req, res) => {
|
|
524
|
+
const send = (status, payload) => {
|
|
525
|
+
res.statusCode = status
|
|
526
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
527
|
+
res.setHeader('cache-control', 'no-store')
|
|
528
|
+
res.end(JSON.stringify(payload))
|
|
529
|
+
}
|
|
530
|
+
const pathname = String(req.url ?? '').split('?')[0]
|
|
531
|
+
try {
|
|
532
|
+
if (pathname.endsWith('/status') && req.method === 'GET') {
|
|
533
|
+
send(200, await buildStatus(config, runner))
|
|
534
|
+
return
|
|
535
|
+
}
|
|
536
|
+
if (pathname.endsWith('/push') && req.method === 'POST') {
|
|
537
|
+
const body = await bodyOf(req)
|
|
538
|
+
if (runner.active()) {
|
|
539
|
+
// 已经有任务在跑:不当错误,告诉面板「已在同步中」即可
|
|
540
|
+
send(202, { ok: true, alreadyRunning: true, kind: runner.job.kind })
|
|
541
|
+
return
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
runner.run('push', { auto: body.auto === true })
|
|
545
|
+
send(202, { ok: true, phase: 'running' })
|
|
546
|
+
} catch (error) {
|
|
547
|
+
send(409, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
548
|
+
}
|
|
549
|
+
return
|
|
550
|
+
}
|
|
551
|
+
if (pathname.endsWith('/pull') && req.method === 'POST') {
|
|
552
|
+
const body = await bodyOf(req)
|
|
553
|
+
if (runner.active()) {
|
|
554
|
+
send(202, { ok: true, alreadyRunning: true, kind: runner.job.kind })
|
|
555
|
+
return
|
|
556
|
+
}
|
|
557
|
+
try {
|
|
558
|
+
runner.run('pull', { from: typeof body.from === 'string' && body.from !== '' ? body.from : undefined, force: body.force === true })
|
|
559
|
+
send(202, { ok: true, phase: 'running' })
|
|
560
|
+
} catch (error) {
|
|
561
|
+
send(409, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
562
|
+
}
|
|
563
|
+
return
|
|
564
|
+
}
|
|
565
|
+
if (pathname.endsWith('/connect-test') && req.method === 'POST') {
|
|
566
|
+
send(200, { ok: true, connection: await checkConnection(config) })
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
if (pathname.endsWith('/machines') && req.method === 'GET') {
|
|
570
|
+
send(200, { ok: true, ...(await listMachines(config)) })
|
|
571
|
+
return
|
|
572
|
+
}
|
|
573
|
+
send(404, { ok: false, error: 'unknown dsh-cloud-sync route' })
|
|
574
|
+
} catch (error) {
|
|
575
|
+
send(500, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
576
|
+
}
|
|
577
|
+
},
|
|
578
|
+
}), 'dsh-cloud-sync: routes')
|
|
579
|
+
|
|
580
|
+
/* 定时器:每分钟检查一次该不该同步 */
|
|
581
|
+
ctx.effect(() => {
|
|
582
|
+
const tick = () => {
|
|
583
|
+
if (!config.autoSync || runner.active()) return
|
|
584
|
+
const state = readState()
|
|
585
|
+
// 永久性错误(环境不可用:没有 ssh、没有私钥、未配置服务器…)→ 不再自动重试,等用户手动处理
|
|
586
|
+
if (state.lastError !== undefined && state.lastError !== null && state.lastError.permanent === true) return
|
|
587
|
+
const pendingAt = Number(state.pendingRetryAt) || 0
|
|
588
|
+
if (pendingAt > Date.now()) return
|
|
589
|
+
// 上一轮有「刚改过、留到下轮」的文件:到点就补一轮,不受间隔限制
|
|
590
|
+
const retryDue = pendingAt > 0 && pendingAt <= Date.now()
|
|
591
|
+
if (!retryDue) {
|
|
592
|
+
const lastPushAt = Number(state.lastPushAt) || 0
|
|
593
|
+
if (config.dailySync) {
|
|
594
|
+
// 每天一次:今天已经同步过就不再跑,等第二天
|
|
595
|
+
if (lastPushAt > 0 && sameLocalDay(lastPushAt, Date.now())) return
|
|
596
|
+
} else if (Date.now() - lastPushAt < config.intervalMinutes * 60 * 1000) {
|
|
597
|
+
return
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// 没有任何会话在跑、且最近一段时间没有任何操作 → 暂停联网,等下次有动静再补
|
|
601
|
+
const idle = activity.idleFor()
|
|
602
|
+
if (config.pauseWhenIdle && !retryDue && idle.running === 0 && idle.idleMs > config.idlePauseMinutes * 60 * 1000) {
|
|
603
|
+
// 事件可能漏报(例如别的进程写文件):再用本地指纹确认一次,确实没变才暂停
|
|
604
|
+
let changed = true
|
|
605
|
+
try {
|
|
606
|
+
const saved = JSON.parse(fs.readFileSync(path.join(dshHome(), '.cloud-sync', 'fingerprint.json'), 'utf8')).value
|
|
607
|
+
changed = localFingerprint(config) !== saved
|
|
608
|
+
} catch { /* 没有指纹记录就当有变化,宁可多跑一轮 */ }
|
|
609
|
+
if (!changed) {
|
|
610
|
+
if (!activity.pausedLogged) {
|
|
611
|
+
activity.pausedLogged = true
|
|
612
|
+
appendLog(`空闲 ${Math.round(idle.idleMs / 60000)} 分钟且本地无变化,暂停自动同步(下次有操作时立即补一轮)`)
|
|
613
|
+
}
|
|
614
|
+
return
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (activity.pausedLogged) {
|
|
618
|
+
activity.pausedLogged = false
|
|
619
|
+
appendLog('检测到新的操作,恢复自动同步')
|
|
620
|
+
}
|
|
621
|
+
runner.run('push', { auto: true }).catch((error) => {
|
|
622
|
+
appendLog(`自动推送失败:${error instanceof Error ? error.message : String(error)}`)
|
|
623
|
+
})
|
|
624
|
+
}
|
|
625
|
+
const handle = setInterval(tick, TICK_MS)
|
|
626
|
+
if (typeof handle.unref === 'function') handle.unref()
|
|
627
|
+
// 「每天第一次打开 DSH」尽快同步:启动 20 秒后先检查一次
|
|
628
|
+
const bootCheck = setTimeout(tick, 20 * 1000)
|
|
629
|
+
if (typeof bootCheck.unref === 'function') bootCheck.unref()
|
|
630
|
+
return () => {
|
|
631
|
+
clearInterval(handle)
|
|
632
|
+
clearTimeout(bootCheck)
|
|
633
|
+
}
|
|
634
|
+
}, 'dsh-cloud-sync: scheduler')
|
|
635
|
+
|
|
636
|
+
/* 启动检查:新机(本地没有任何会话)时自动恢复 */
|
|
637
|
+
ctx.effect(() => {
|
|
638
|
+
const timer = setTimeout(async () => {
|
|
639
|
+
try {
|
|
640
|
+
if (!config.restoreOnEmpty || runner.active()) return
|
|
641
|
+
const state = readState()
|
|
642
|
+
if (state.bootstrapDone === true) return
|
|
643
|
+
const sessions = path.join(dshHome(), 'sessions')
|
|
644
|
+
let hasSession = false
|
|
645
|
+
try {
|
|
646
|
+
for (const workspace of fs.readdirSync(sessions, { withFileTypes: true })) {
|
|
647
|
+
if (!workspace.isDirectory()) continue
|
|
648
|
+
const dir = path.join(sessions, workspace.name)
|
|
649
|
+
if (fs.readdirSync(dir).length > 0) {
|
|
650
|
+
hasSession = true
|
|
651
|
+
break
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
} catch { /* 目录不存在 = 空 */ }
|
|
655
|
+
if (hasSession) {
|
|
656
|
+
writeState({ ...readState(), version: 1, bootstrapDone: true })
|
|
657
|
+
return
|
|
658
|
+
}
|
|
659
|
+
const listing = await listMachines(config)
|
|
660
|
+
const candidates = listing.machines.filter((item) => item.manifestBytes > 0)
|
|
661
|
+
if (candidates.length === 0) return
|
|
662
|
+
if (candidates.length > 1) {
|
|
663
|
+
appendLog(`本机没有任何会话,但云端有 ${candidates.length} 台机器的备份(${candidates.map((item) => item.id).join('、')}),请在设置页选择从哪台恢复`)
|
|
664
|
+
return
|
|
665
|
+
}
|
|
666
|
+
appendLog(`本机没有任何会话,自动从 ${candidates[0].id} 恢复`)
|
|
667
|
+
await runner.run('pull', { from: candidates[0].id })
|
|
668
|
+
writeState({ ...readState(), version: 1, bootstrapDone: true })
|
|
669
|
+
} catch (error) {
|
|
670
|
+
appendLog(`启动恢复失败:${error instanceof Error ? error.message : String(error)}`)
|
|
671
|
+
}
|
|
672
|
+
}, BOOT_DELAY_MS)
|
|
673
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
674
|
+
return () => clearTimeout(timer)
|
|
675
|
+
}, 'dsh-cloud-sync: bootstrap')
|
|
676
|
+
|
|
677
|
+
/* 首次启动打个日志,方便排查(不阻塞) */
|
|
678
|
+
appendLog(`插件启动:槽位 ${config.machineId !== '' ? config.machineId : defaultMachineId()},远端 ${config.host}:${config.remoteRoot},自动同步 ${config.autoSync ? `每 ${config.intervalMinutes} 分钟` : '关闭'}`)
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** 供离线自测脚本使用。 */
|
|
682
|
+
export { buildInventory, readLinkPlugins, profileName }
|