@leiyi-works/dsh-cloud-sync 0.1.11 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client.js +1 -51
- package/index.js +25 -0
- package/package.json +2 -2
- package/sync.js +2 -73
package/client.js
CHANGED
|
@@ -21,8 +21,6 @@ window.__ModuleLoader__.load({
|
|
|
21
21
|
'.csyCard{display:flex;align-items:center;gap:14px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.22));border-radius:12px;padding:16px}',
|
|
22
22
|
'.csyBig{appearance:none;cursor:pointer;border:none;border-radius:10px;padding:12px 26px;font-size:15px;font-weight:600;line-height:20px;background:var(--dsw-alias-state-business-primary,#3f7fd4);color:#fff}',
|
|
23
23
|
'.csyBig:disabled{opacity:.55;cursor:default}',
|
|
24
|
-
'.csySmall{appearance:none;cursor:pointer;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.35));border-radius:10px;padding:10px 14px;font-size:13px;background:transparent;color:inherit}',
|
|
25
|
-
'.csySmall:disabled{opacity:.55;cursor:default}',
|
|
26
24
|
'.csyCardText{display:flex;flex-direction:column;gap:3px;min-width:0}',
|
|
27
25
|
'.csyCardMain{font-size:14px;font-weight:600}',
|
|
28
26
|
'.csyNote{font-size:12px;color:var(--dsw-alias-label-secondary);word-break:break-all}',
|
|
@@ -61,11 +59,6 @@ window.__ModuleLoader__.load({
|
|
|
61
59
|
hint: '把聊天记录、工作区/定时任务、插件清单与本地插件源码、钥则、凭据增量推到自己的云服务器;换机时从设置里点这个面板就能拉回。',
|
|
62
60
|
syncNow: '立即同步',
|
|
63
61
|
syncing: '同步中…',
|
|
64
|
-
testConn: '测试连接',
|
|
65
|
-
restore: '从云端恢复',
|
|
66
|
-
connOk: '连接正常',
|
|
67
|
-
connFail: '连接失败',
|
|
68
|
-
restoreConfirm: '从云端恢复会覆盖本机较旧的同名文件(覆盖前自动备份)。继续?',
|
|
69
62
|
alreadyRunning: '已在同步中,稍候会自动刷新',
|
|
70
63
|
unconfigured: '未配置服务器:在 profile 的 cordis.patch.yml 里填 host,或在 keyrule-secrets/ssh.json 里写连接信息',
|
|
71
64
|
permanent: '环境不可用,已停止自动重试',
|
|
@@ -92,11 +85,6 @@ window.__ModuleLoader__.load({
|
|
|
92
85
|
hint: 'Push chat history, plugins, rules and credentials to your cloud server; open this panel on another machine to restore.',
|
|
93
86
|
syncNow: 'Sync now',
|
|
94
87
|
syncing: 'Syncing…',
|
|
95
|
-
testConn: 'Test connection',
|
|
96
|
-
restore: 'Restore from cloud',
|
|
97
|
-
connOk: 'Connection OK',
|
|
98
|
-
connFail: 'Connection failed',
|
|
99
|
-
restoreConfirm: 'Restoring overwrites older local files with cloud versions (backed up first). Continue?',
|
|
100
88
|
alreadyRunning: 'A sync is already running — this panel will refresh automatically',
|
|
101
89
|
unconfigured: 'No server configured: set host in the profile cordis.patch.yml, or put connection info in keyrule-secrets/ssh.json',
|
|
102
90
|
permanent: 'Environment unavailable — automatic retries stopped',
|
|
@@ -202,44 +190,8 @@ window.__ModuleLoader__.load({
|
|
|
202
190
|
return () => window.clearInterval(timer)
|
|
203
191
|
}, [running, refresh])
|
|
204
192
|
|
|
205
|
-
|
|
206
|
-
const testConnection = async () => {
|
|
193
|
+
const syncNow = async () => {
|
|
207
194
|
setStarting(true)
|
|
208
|
-
setError(null)
|
|
209
|
-
setNotice(null)
|
|
210
|
-
try {
|
|
211
|
-
const payload = await post('connect-test', {})
|
|
212
|
-
const conn = payload?.connection ?? {}
|
|
213
|
-
if (conn.ok === true) setNotice(`${t('connOk')}${conn.ms === undefined ? '' : ` · ${Math.round(Number(conn.ms))} ms`}`)
|
|
214
|
-
else setError(`${t('connFail')}${conn.error === undefined ? '' : `:${conn.error}`}`)
|
|
215
|
-
} catch (failure) {
|
|
216
|
-
setError(failure instanceof Error ? failure.message : String(failure))
|
|
217
|
-
} finally {
|
|
218
|
-
setStarting(false)
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/** 从云端恢复(需要确认)。 */
|
|
223
|
-
const restoreNow = async () => {
|
|
224
|
-
const confirmed = typeof window !== 'undefined' && typeof window.confirm === 'function'
|
|
225
|
-
? window.confirm(t('restoreConfirm'))
|
|
226
|
-
: true
|
|
227
|
-
if (!confirmed) return
|
|
228
|
-
setStarting(true)
|
|
229
|
-
setError(null)
|
|
230
|
-
setNotice(null)
|
|
231
|
-
try {
|
|
232
|
-
const payload = await post('pull', {})
|
|
233
|
-
if (payload?.alreadyRunning === true) setNotice(t('alreadyRunning'))
|
|
234
|
-
await refresh()
|
|
235
|
-
} catch (failure) {
|
|
236
|
-
setError(failure instanceof Error ? failure.message : String(failure))
|
|
237
|
-
} finally {
|
|
238
|
-
setStarting(false)
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const syncNow = async () => { setStarting(true)
|
|
243
195
|
setError(null)
|
|
244
196
|
setNotice(null)
|
|
245
197
|
try {
|
|
@@ -286,8 +238,6 @@ window.__ModuleLoader__.load({
|
|
|
286
238
|
h('div', { className: 'csyCard' },
|
|
287
239
|
h('button', { type: 'button', className: 'csyBig', disabled: busy, onClick: () => { void syncNow() } },
|
|
288
240
|
busy ? t('syncing') : t('syncNow')),
|
|
289
|
-
h('button', { type: 'button', className: 'csySmall', disabled: busy, onClick: () => { void testConnection() } }, t('testConn')),
|
|
290
|
-
h('button', { type: 'button', className: 'csySmall', disabled: busy, onClick: () => { void restoreNow() } }, t('restore')),
|
|
291
241
|
h('div', { className: 'csyCardText' },
|
|
292
242
|
h('span', { className: error !== null ? 'csyCardMain csyErr' : notice !== null ? 'csyCardMain csyNote' : 'csyCardMain' }, resultText),
|
|
293
243
|
percent === undefined ? null : h('div', { className: 'csyBar' }, h('div', { className: 'csyBarFill', style: { width: `${percent}%` } })))),
|
package/index.js
CHANGED
|
@@ -74,6 +74,8 @@ export const Config = z.object({
|
|
|
74
74
|
.description('传输停滞判定(毫秒):连续这么久没有数据就断开重试'),
|
|
75
75
|
deviceMode: z.boolean().default(DEFAULTS.deviceMode)
|
|
76
76
|
.description('设备模式:默认每天一次、不带附件/插件源码、不自动整份恢复(手机端建议开)'),
|
|
77
|
+
bidirectional: z.boolean().default(DEFAULTS.bidirectional)
|
|
78
|
+
.description('双向同步:每次同步先把其它机器的新改动拉下来,再推送本机改动(多机使用建议开)'),
|
|
77
79
|
remoteRoot: z.string().default(DEFAULTS.remoteRoot).description('服务器上的备份根目录'),
|
|
78
80
|
machineId: z.string().default('').description('这台机器的槽位名,留空 = 主机名'),
|
|
79
81
|
localPluginRoot: z.string().default(DEFAULTS.localPluginRoot)
|
|
@@ -209,9 +211,32 @@ function createRunner(config) {
|
|
|
209
211
|
}
|
|
210
212
|
current = (async () => {
|
|
211
213
|
try {
|
|
214
|
+
// 双向收敛:先把「其它机器」比本机新的文件拉下来,再推自己的
|
|
215
|
+
let merged = null
|
|
216
|
+
if (kind === 'push' && config.bidirectional !== false && options.pullOthers !== false) {
|
|
217
|
+
try {
|
|
218
|
+
const selfMachine = config.machineId !== '' ? config.machineId : defaultMachineId()
|
|
219
|
+
const listing = await listMachines(config)
|
|
220
|
+
const others = listing.machines.filter((item) => item.id !== selfMachine && item.manifestBytes > 0)
|
|
221
|
+
if (others.length > 0) {
|
|
222
|
+
merged = { machines: [], restored: 0 }
|
|
223
|
+
for (const other of others) {
|
|
224
|
+
const got = await pull({ config, log: lines, from: other.id })
|
|
225
|
+
const restored = Number(got?.restored) || 0
|
|
226
|
+
merged.machines.push(other.id)
|
|
227
|
+
merged.restored += restored
|
|
228
|
+
lines(`已合并 ${other.id} 的改动:${restored} 个文件`)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
} catch (error) {
|
|
232
|
+
merged = null
|
|
233
|
+
lines(`合并其它机器失败(继续推送本机改动):${error instanceof Error ? error.message : String(error)}`)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
212
236
|
const result = kind === 'push'
|
|
213
237
|
? await push({ config, log: lines, onProgress, auto: options.auto === true })
|
|
214
238
|
: await pull({ config, log: lines, onProgress, from: options.from, force: options.force === true })
|
|
239
|
+
if (merged !== null && kind === 'push') result.mergedFromOthers = merged
|
|
215
240
|
job.result = clean(result)
|
|
216
241
|
job.phase = 'done'
|
|
217
242
|
const next = {
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leiyi-works/dsh-cloud-sync",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"title": "云端同步",
|
|
6
|
-
"description": "
|
|
6
|
+
"description": "云端同步:双向收敛(同步=先拉其它机器新改动再推本机)、subprocess 可移植执行层(手机可跑)、设备模式、dropbear 兼容、永久错误停止重试。",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"author": "leiyi-works",
|
|
9
9
|
"keywords": [
|
package/sync.js
CHANGED
|
@@ -44,6 +44,7 @@ export const DEFAULTS = {
|
|
|
44
44
|
commandTimeoutMs: 60000,
|
|
45
45
|
stallMs: 120000,
|
|
46
46
|
deviceMode: false,
|
|
47
|
+
bidirectional: true,
|
|
47
48
|
remoteRoot: '/opt/dsh-sync',
|
|
48
49
|
connectionFile: '',
|
|
49
50
|
machineId: '',
|
|
@@ -114,6 +115,7 @@ export function normalizeConfig(raw) {
|
|
|
114
115
|
commandTimeoutMs: number('commandTimeoutMs', 240000, 1000),
|
|
115
116
|
stallMs: number('stallMs', 300000, 5000),
|
|
116
117
|
deviceMode: device,
|
|
118
|
+
bidirectional: flag(pick('bidirectional'), DEFAULTS.bidirectional),
|
|
117
119
|
connectionFile: text(pick('connectionFile'), DEFAULTS.connectionFile),
|
|
118
120
|
remoteRoot: text(pick('remoteRoot'), DEFAULTS.remoteRoot).replace(/\/+$/, '') || DEFAULTS.remoteRoot,
|
|
119
121
|
machineId: sanitizeMachineId(text(pick('machineId'), '')),
|
|
@@ -1524,7 +1526,6 @@ export async function pull(options) {
|
|
|
1524
1526
|
const wanted = []
|
|
1525
1527
|
let skippedRecent = 0
|
|
1526
1528
|
let skippedDeleted = 0
|
|
1527
|
-
let needWorkspaceMerge = false
|
|
1528
1529
|
for (const rel of remoteEntries) {
|
|
1529
1530
|
if (!isSafeRel(rel)) {
|
|
1530
1531
|
log(`忽略非法路径:${rel}`)
|
|
@@ -1549,12 +1550,6 @@ export async function pull(options) {
|
|
|
1549
1550
|
wanted.push({ rel, mtimeMs: remoteMtime })
|
|
1550
1551
|
continue
|
|
1551
1552
|
}
|
|
1552
|
-
if (rel === 'dsh/storages/workspace.json' && options.force !== true) {
|
|
1553
|
-
// 状态集合文件(工作区/登记/归档/置顶):总是取回云端版本用于合并,不按 mtime 跳过
|
|
1554
|
-
needWorkspaceMerge = true
|
|
1555
|
-
wanted.push({ rel, mtimeMs: remoteMtime })
|
|
1556
|
-
continue
|
|
1557
|
-
}
|
|
1558
1553
|
if (remoteMtime <= local.mtimeMs + 2000) continue
|
|
1559
1554
|
if (options.force !== true && now - local.mtimeMs < 5 * 60 * 1000) {
|
|
1560
1555
|
skippedRecent += 1
|
|
@@ -1661,15 +1656,6 @@ export async function pull(options) {
|
|
|
1661
1656
|
}
|
|
1662
1657
|
}
|
|
1663
1658
|
|
|
1664
|
-
if (needWorkspaceMerge) {
|
|
1665
|
-
const current = path.join(dshRoot, 'storages', 'workspace.json')
|
|
1666
|
-
const backup = path.join(backupDir, 'dsh', 'storages', 'workspace.json')
|
|
1667
|
-
if (fs.existsSync(current) && fs.existsSync(backup)) {
|
|
1668
|
-
const added = mergeWorkspaceState(current, backup, log)
|
|
1669
|
-
log(`工作区状态取并集(登记/归档/置顶):已与云端合并${added > 0 ? `,新增 ${added} 条会话登记` : ''};重启 DSH 后左侧列表才显示`)
|
|
1670
|
-
}
|
|
1671
|
-
}
|
|
1672
|
-
|
|
1673
1659
|
log(`恢复完成:${restored} 个文件,${skipped} 个找不到落点而被跳过`)
|
|
1674
1660
|
await rewriteProfileLinks(localProfileDir, pluginTargets, log)
|
|
1675
1661
|
|
|
@@ -1689,63 +1675,6 @@ export async function pull(options) {
|
|
|
1689
1675
|
return { ok: true, from, restored, bytes, skipped, plugins: pluginTargets.size, skippedRecent, skippedDeleted, removed: applied.removed.length }
|
|
1690
1676
|
}
|
|
1691
1677
|
|
|
1692
|
-
/**
|
|
1693
|
-
* `storages/workspace.json` 是「状态集合」文件(工作区列表 / 会话登记 / 归档 / 置顶)。
|
|
1694
|
-
* 两台机各改一半时,按 mtime 整文件覆盖只能留一份 → 这里改成取并集,
|
|
1695
|
-
* 避免「另一台登记的会话和归档状态没跟过来」。
|
|
1696
|
-
* @param {string} currentPath 刚恢复成云端版本的文件
|
|
1697
|
-
* @param {string} backupPath 覆盖前本机版本(.cloud-sync/backup/<时间戳>/ 下)
|
|
1698
|
-
* @returns {number} 新增的会话登记条数
|
|
1699
|
-
*/
|
|
1700
|
-
export function mergeWorkspaceState(currentPath, backupPath) {
|
|
1701
|
-
const readJson = (file) => {
|
|
1702
|
-
try {
|
|
1703
|
-
return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''))
|
|
1704
|
-
} catch {
|
|
1705
|
-
return null
|
|
1706
|
-
}
|
|
1707
|
-
}
|
|
1708
|
-
const mine = readJson(backupPath)
|
|
1709
|
-
const theirs = readJson(currentPath)
|
|
1710
|
-
if (mine === null || theirs === null) return 0
|
|
1711
|
-
const unionList = (a, b) => {
|
|
1712
|
-
const out = Array.isArray(a) ? [...a] : []
|
|
1713
|
-
for (const item of Array.isArray(b) ? b : []) if (!out.includes(item)) out.push(item)
|
|
1714
|
-
return out
|
|
1715
|
-
}
|
|
1716
|
-
const merged = { ...theirs }
|
|
1717
|
-
if (mine.unit !== undefined && theirs.unit === undefined) merged.unit = mine.unit
|
|
1718
|
-
const gTheirs = theirs.global !== null && typeof theirs.global === 'object' ? theirs.global : {}
|
|
1719
|
-
const gMine = mine.global !== null && typeof mine.global === 'object' ? mine.global : {}
|
|
1720
|
-
merged.global = { ...gMine, ...gTheirs }
|
|
1721
|
-
for (const key of ['workspaceIds', 'archivedSessionIds', 'pinnedSessionIds']) {
|
|
1722
|
-
if (Array.isArray(gMine[key]) || Array.isArray(gTheirs[key])) {
|
|
1723
|
-
merged.global[key] = unionList(gTheirs[key], gMine[key])
|
|
1724
|
-
}
|
|
1725
|
-
}
|
|
1726
|
-
const tTheirs = theirs.tables !== null && typeof theirs.tables === 'object' ? theirs.tables : {}
|
|
1727
|
-
const tMine = mine.tables !== null && typeof mine.tables === 'object' ? mine.tables : {}
|
|
1728
|
-
const wTheirs = tTheirs.workspaces !== null && typeof tTheirs.workspaces === 'object' ? tTheirs.workspaces : {}
|
|
1729
|
-
const wMine = tMine.workspaces !== null && typeof tMine.workspaces === 'object' ? tMine.workspaces : {}
|
|
1730
|
-
const workspaces = {}
|
|
1731
|
-
let added = 0
|
|
1732
|
-
for (const id of new Set([...Object.keys(wMine), ...Object.keys(wTheirs)])) {
|
|
1733
|
-
const a = wMine[id] !== null && typeof wMine[id] === 'object' ? wMine[id] : {}
|
|
1734
|
-
const b = wTheirs[id] !== null && typeof wTheirs[id] === 'object' ? wTheirs[id] : {}
|
|
1735
|
-
const before = Array.isArray(b.sessionIds) ? b.sessionIds.length : 0
|
|
1736
|
-
const sessionIds = unionList(b.sessionIds, a.sessionIds)
|
|
1737
|
-
added += Math.max(0, sessionIds.length - before)
|
|
1738
|
-
workspaces[id] = { ...a, ...b, sessionIds }
|
|
1739
|
-
}
|
|
1740
|
-
merged.tables = { ...tMine, ...tTheirs, workspaces }
|
|
1741
|
-
try {
|
|
1742
|
-
fs.writeFileSync(currentPath, JSON.stringify(merged, null, 2) + '\n', 'utf8')
|
|
1743
|
-
} catch {
|
|
1744
|
-
return 0
|
|
1745
|
-
}
|
|
1746
|
-
return added
|
|
1747
|
-
}
|
|
1748
|
-
|
|
1749
1678
|
function timestamp() {
|
|
1750
1679
|
const now = new Date()
|
|
1751
1680
|
const pad = (value) => String(value).padStart(2, '0')
|