@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.
Files changed (4) hide show
  1. package/client.js +1 -51
  2. package/index.js +25 -0
  3. package/package.json +2 -2
  4. 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
- /** 测一次 SSH 连接(服务器、耗时、错误)。 */
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.1.11",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "title": "云端同步",
6
- "description": "云端同步:工作区状态(登记/归档/置顶)恢复时取并集、面板加测试连接与从云端恢复、subprocess/shell 包装、设备模式。",
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')