@leiyi-works/dsh-cloud-sync 0.1.5 → 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.
Files changed (4) hide show
  1. package/client.js +5 -1
  2. package/index.js +35 -3
  3. package/package.json +2 -2
  4. package/sync.js +104 -32
package/client.js CHANGED
@@ -61,6 +61,7 @@ window.__ModuleLoader__.load({
61
61
  syncing: '同步中…',
62
62
  alreadyRunning: '已在同步中,稍候会自动刷新',
63
63
  unconfigured: '未配置服务器:在 profile 的 cordis.patch.yml 里填 host,或在 keyrule-secrets/ssh.json 里写连接信息',
64
+ permanent: '环境不可用,已停止自动重试',
64
65
  lastSync: '上次同步',
65
66
  cloudSize: '云端占用',
66
67
  autoPlan: '自动同步',
@@ -85,6 +86,7 @@ window.__ModuleLoader__.load({
85
86
  syncing: 'Syncing…',
86
87
  alreadyRunning: 'A sync is already running — this panel will refresh automatically',
87
88
  unconfigured: 'No server configured: set host in the profile cordis.patch.yml, or put connection info in keyrule-secrets/ssh.json',
89
+ permanent: 'Environment unavailable — automatic retries stopped',
88
90
  lastSync: 'Last sync',
89
91
  cloudSize: 'Cloud usage',
90
92
  autoPlan: 'Auto sync',
@@ -221,7 +223,9 @@ window.__ModuleLoader__.load({
221
223
  ? `${t('running')}:${job.log?.slice(-1)[0] ?? ''}`
222
224
  : job?.phase === 'failed'
223
225
  ? `${t('failed')}:${job.error ?? ''}`
224
- : lastPushAt !== undefined
226
+ : status?.last?.error?.permanent === true
227
+ ? `${t('permanent')}:${status.last.error.message ?? ''}`
228
+ : lastPushAt !== undefined
225
229
  ? `${when(lastPushAt)} · ${status?.last?.push?.uploaded ?? 0} ${t('files')}`
226
230
  : t('never')
227
231
 
package/index.js CHANGED
@@ -32,6 +32,14 @@ import {
32
32
  defaultMachineId,
33
33
  isConfigured,
34
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
+ }
35
43
 
36
44
  export const name = 'dsh-cloud-sync'
37
45
  export const inject = ['tools', 'webServer']
@@ -51,6 +59,20 @@ export const Config = z.object({
51
59
  .description('私钥路径;留空则从 connectionFile 读取'),
52
60
  connectionFile: z.string().default(DEFAULTS.connectionFile)
53
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('设备模式:默认每天一次、不带附件/插件源码、不自动整份恢复(手机端建议开)'),
54
76
  remoteRoot: z.string().default(DEFAULTS.remoteRoot).description('服务器上的备份根目录'),
55
77
  machineId: z.string().default('').description('这台机器的槽位名,留空 = 主机名'),
56
78
  localPluginRoot: z.string().default(DEFAULTS.localPluginRoot)
@@ -207,16 +229,17 @@ function createRunner(config) {
207
229
  } catch (error) {
208
230
  job.error = error instanceof Error ? error.message : String(error)
209
231
  job.phase = 'failed'
232
+ const permanent = isPermanentError(job.error)
210
233
  const next = {
211
234
  ...readState(),
212
235
  version: 1,
213
236
  lastKind: kind,
214
237
  lastAt: new Date().toISOString(),
215
- lastError: { kind, message: job.error, at: new Date().toISOString() },
216
- pendingRetryAt: Date.now() + 30 * 60 * 1000,
238
+ lastError: { kind, message: job.error, at: new Date().toISOString(), ...(permanent ? { permanent: true } : {}) },
239
+ ...(permanent ? {} : { pendingRetryAt: Date.now() + 30 * 60 * 1000 }),
217
240
  }
218
241
  writeState(next)
219
- appendLog(`${kind} 失败:${job.error}`)
242
+ appendLog(`${kind} 失败${permanent ? '(环境不可用,已停止自动重试)' : ''}:${job.error}`)
220
243
  throw error
221
244
  } finally {
222
245
  job.finishedAt = Date.now()
@@ -391,6 +414,13 @@ function summarizeStatus(status) {
391
414
 
392
415
  export function apply(ctx, rawConfig) {
393
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
+ }
394
424
  const runner = createRunner(config)
395
425
 
396
426
  /* 记「有没有会话在跑」:空闲暂停靠它判断 */
@@ -552,6 +582,8 @@ export function apply(ctx, rawConfig) {
552
582
  const tick = () => {
553
583
  if (!config.autoSync || runner.active()) return
554
584
  const state = readState()
585
+ // 永久性错误(环境不可用:没有 ssh、没有私钥、未配置服务器…)→ 不再自动重试,等用户手动处理
586
+ if (state.lastError !== undefined && state.lastError !== null && state.lastError.permanent === true) return
555
587
  const pendingAt = Number(state.pendingRetryAt) || 0
556
588
  if (pendingAt > Date.now()) return
557
589
  // 上一轮有「刚改过、留到下轮」的文件:到点就补一轮,不受间隔限制
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@leiyi-works/dsh-cloud-sync",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "title": "云端同步",
6
- "description": "云端同步(不内置服务器地址:连接信息从 keyrule-secrets/ssh.json 或配置读取;黑名单制同步整个 ~/.dsh;跨机删除;每天一次)。",
6
+ "description": "云端同步:执行层走 DSH 可移植 subprocess(手机/沙箱也能跑)、ssh 兼容 dropbear、超时与停滞可配置、设备模式、永久错误停止重试;连接信息在 keyrule-secrets/ssh.json。",
7
7
  "license": "MIT",
8
8
  "author": "leiyi-works",
9
9
  "keywords": [
package/sync.js CHANGED
@@ -25,7 +25,7 @@ import fs from 'node:fs'
25
25
  import fsp from 'node:fs/promises'
26
26
  import path from 'node:path'
27
27
  import os from 'node:os'
28
- import { spawn } from 'node:child_process'
28
+ import { spawnManaged } from './exec.js'
29
29
 
30
30
  /* ── 配置 ───────────────────────────────────────────────────────────────── */
31
31
 
@@ -37,10 +37,17 @@ export const DEFAULTS = {
37
37
  host: '',
38
38
  port: 22,
39
39
  identityFile: '',
40
+ identityFileGuest: '',
41
+ sshBinary: '',
42
+ sshCompat: 'openssh',
43
+ connectTimeoutMs: 15000,
44
+ commandTimeoutMs: 60000,
45
+ stallMs: 120000,
46
+ deviceMode: false,
40
47
  remoteRoot: '/opt/dsh-sync',
41
48
  connectionFile: '',
42
49
  machineId: '',
43
- localPluginRoot: 'D:/DSH工作区',
50
+ localPluginRoot: '',
44
51
  intervalMinutes: 5,
45
52
  dailySync: false,
46
53
  autoSync: true,
@@ -86,25 +93,41 @@ export function normalizeConfig(raw) {
86
93
  return trimmed === '' ? fallback : trimmed
87
94
  }
88
95
  const flag = (value, fallback) => (typeof value === 'boolean' ? value : fallback)
96
+ // 设备模式(手机/嵌入式):默认每天一次、不带附件和插件源码、不自动整份恢复
97
+ const device = flag(pick('deviceMode'), DEFAULTS.deviceMode)
98
+ /** 布尔项:显式配置优先;没配时用 deviceMode 的默认值;再退回普通默认值。 */
99
+ const bool = (key, deviceDefault) => (input[key] === undefined || input[key] === null
100
+ ? (device ? deviceDefault : DEFAULTS[key])
101
+ : flag(input[key], DEFAULTS[key]))
89
102
  const out = {
90
103
  host: text(pick('host'), DEFAULTS.host),
91
104
  port: num(pick('port'), DEFAULTS.port, 1),
92
105
  identityFile: text(pick('identityFile'), DEFAULTS.identityFile).replace(/\\/g, '/'),
106
+ identityFileGuest: text(pick('identityFileGuest'), DEFAULTS.identityFileGuest),
107
+ sshBinary: text(pick('sshBinary'), DEFAULTS.sshBinary),
108
+ sshCompat: text(pick('sshCompat'), DEFAULTS.sshCompat),
109
+ connectTimeoutMs: num(pick('connectTimeoutMs'), DEFAULTS.connectTimeoutMs, 1000),
110
+ commandTimeoutMs: num(pick('commandTimeoutMs'), DEFAULTS.commandTimeoutMs, 1000),
111
+ stallMs: num(pick('stallMs'), DEFAULTS.stallMs, 5000),
112
+ deviceMode: device,
93
113
  connectionFile: text(pick('connectionFile'), DEFAULTS.connectionFile),
94
114
  remoteRoot: text(pick('remoteRoot'), DEFAULTS.remoteRoot).replace(/\/+$/, '') || DEFAULTS.remoteRoot,
95
115
  machineId: sanitizeMachineId(text(pick('machineId'), '')),
96
- localPluginRoot: text(pick('localPluginRoot'), DEFAULTS.localPluginRoot).replace(/\\/g, '/').replace(/\/+$/, ''),
116
+ localPluginRoot: text(
117
+ pick('localPluginRoot'),
118
+ process.platform === 'win32' ? 'D:/DSH工作区' : '',
119
+ ).replace(/\\/g, '/').replace(/\/+$/, ''),
97
120
  intervalMinutes: num(pick('intervalMinutes'), DEFAULTS.intervalMinutes, 1),
98
- dailySync: flag(pick('dailySync'), DEFAULTS.dailySync),
121
+ dailySync: bool('dailySync', true),
99
122
  autoSync: flag(pick('autoSync'), DEFAULTS.autoSync),
100
123
  applyRemoteDeletes: flag(pick('applyRemoteDeletes'), DEFAULTS.applyRemoteDeletes),
101
124
  pauseWhenIdle: flag(pick('pauseWhenIdle'), DEFAULTS.pauseWhenIdle),
102
125
  idlePauseMinutes: num(pick('idlePauseMinutes'), DEFAULTS.idlePauseMinutes, 1),
103
- restoreOnEmpty: flag(pick('restoreOnEmpty'), DEFAULTS.restoreOnEmpty),
104
- includeAttachments: flag(pick('includeAttachments'), DEFAULTS.includeAttachments),
126
+ restoreOnEmpty: bool('restoreOnEmpty', false),
127
+ includeAttachments: bool('includeAttachments', false),
105
128
  includeCredentials: flag(pick('includeCredentials'), DEFAULTS.includeCredentials),
106
129
  includeKeyruleSecrets: flag(pick('includeKeyruleSecrets'), DEFAULTS.includeKeyruleSecrets),
107
- includeLocalPlugins: flag(pick('includeLocalPlugins'), DEFAULTS.includeLocalPlugins),
130
+ includeLocalPlugins: bool('includeLocalPlugins', false),
108
131
  quietSeconds: num(pick('quietSeconds'), DEFAULTS.quietSeconds, 0),
109
132
  trashDays: num(pick('trashDays'), DEFAULTS.trashDays, 1),
110
133
  versionDays: num(pick('versionDays'), DEFAULTS.versionDays, 1),
@@ -141,7 +164,12 @@ export function sanitizeMachineId(value) {
141
164
  /** 当前机器名(默认用主机名;用户可在 Config 里覆盖)。 */
142
165
  export function defaultMachineId() {
143
166
  try {
144
- return sanitizeMachineId(os.hostname()) || 'machine'
167
+ const host = sanitizeMachineId(os.hostname())
168
+ // 手机/容器里 hostname 常是 localhost 或空,多台设备会撞同一槽位 → 加平台后缀
169
+ if (host === '' || host === 'localhost' || host === '127-0-0-1') {
170
+ return sanitizeMachineId(`${host || 'device'}-${process.platform}`) || 'machine'
171
+ }
172
+ return host
145
173
  } catch {
146
174
  return 'machine'
147
175
  }
@@ -154,9 +182,16 @@ export function dshHome() {
154
182
  : path.join(os.homedir(), '.dsh')
155
183
  }
156
184
 
157
- /** 当前 profile 名。 */
185
+ /** 当前 profile 名:环境变量 → profiles 下唯一子目录 → desktop。 */
158
186
  export function profileName() {
159
- return process.env.DSH_PROFILE !== undefined && process.env.DSH_PROFILE !== '' ? process.env.DSH_PROFILE : 'desktop'
187
+ const fromEnv = process.env.DSH_PROFILE
188
+ if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return fromEnv.trim()
189
+ try {
190
+ const root = path.join(dshHome(), 'profiles')
191
+ const entries = fs.readdirSync(root, { withFileTypes: true }).filter((item) => item.isDirectory())
192
+ if (entries.length === 1) return entries[0].name
193
+ } catch { /* 读不到就用默认 */ }
194
+ return 'desktop'
160
195
  }
161
196
 
162
197
  /** 当前 profile 目录。 */
@@ -377,13 +412,24 @@ export async function applyTombstones(options) {
377
412
 
378
413
  /* ── 命令执行 ───────────────────────────────────────────────────────────── */
379
414
 
380
- /** Windows 自带 OpenSSH 的绝对路径(PATH 里没有时兜底)。 */
381
- function sshBinary() {
415
+ /** 展开 `~` 与 `%VAR%`(配置里写相对/环境变量路径时用)。 */
416
+ export function expandPath(value) {
417
+ if (typeof value !== 'string' || value.trim() === '') return ''
418
+ let out = value.trim()
419
+ if (out === '~') out = os.homedir()
420
+ else if (out.startsWith('~/') || out.startsWith('~\\')) out = path.join(os.homedir(), out.slice(2))
421
+ out = out.replace(/%([A-Za-z_][A-Za-z0-9_]*)%/g, (all, name) => process.env[name] ?? all)
422
+ return out
423
+ }
424
+
425
+ /** ssh 可执行文件:配置优先 → 环境变量 → Windows 自带路径 → PATH 里的 ssh。 */
426
+ function sshBinary(config) {
427
+ const configured = typeof config.sshBinary === 'string' ? expandPath(config.sshBinary) : ''
428
+ if (configured !== '') return configured
429
+ const fromEnv = typeof process.env.DSH_CLOUD_SYNC_SSH === 'string' ? process.env.DSH_CLOUD_SYNC_SSH : ''
430
+ if (fromEnv !== '') return fromEnv
382
431
  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 !== '')
432
+ const candidates = [path.join(process.env.WINDIR ?? 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe')]
387
433
  for (const candidate of candidates) {
388
434
  try {
389
435
  if (fs.existsSync(candidate)) return candidate
@@ -392,26 +438,52 @@ function sshBinary() {
392
438
  return 'ssh'
393
439
  }
394
440
 
441
+ /**
442
+ * 私钥路径:`~` 展开;显式配置了却不存在 → 明确报错(不要静默退化成默认密钥,
443
+ * 那样只会得到 Permission denied,看不出真实原因)。
444
+ * `identityFileGuest` 非空时优先用它——那是 guest/VM 世界里的路径,宿主侧无法校验。
445
+ */
446
+ function identityPath(config) {
447
+ const guest = expandPath(config.identityFileGuest)
448
+ if (guest !== '') return guest
449
+ const raw = expandPath(config.identityFile)
450
+ if (raw === '') return ''
451
+ if (!fs.existsSync(raw)) {
452
+ throw new Error(`私钥不存在:${raw}(检查 ssh.json / identityFile;手机端要填 guest 内可见的路径,可用 identityFileGuest)`)
453
+ }
454
+ return raw
455
+ }
456
+
395
457
  /** 远程命令里出现的字符串一律单引号包裹。 */
396
458
  export function shQuote(value) {
397
459
  return `'${String(value).replace(/'/g, `'\\''`)}'`
398
460
  }
399
461
 
400
- /** 拼出 ssh 的固定参数(不含目标与命令)。 */
462
+ /**
463
+ * 拼出 ssh 的固定参数(不含目标与命令)。
464
+ * `sshCompat`:openssh(默认)/ dropbear(精简 -o,改用 -y 接受主机密钥)。
465
+ */
401
466
  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)
467
+ const args = ['-p', String(config.port)]
468
+ if (config.sshCompat === 'dropbear') {
469
+ args.push('-y')
470
+ } else {
471
+ args.push(
472
+ '-o', 'BatchMode=yes',
473
+ '-o', `ConnectTimeout=${Math.max(1, Math.round(config.connectTimeoutMs / 1000))}`,
474
+ '-o', 'ServerAliveInterval=20',
475
+ '-o', 'StrictHostKeyChecking=accept-new',
476
+ )
477
+ }
478
+ const identity = identityPath(config)
479
+ if (identity !== '') args.push('-i', identity)
404
480
  return args
405
481
  }
406
482
 
407
- /** 打开一条 ssh 通道,返回子进程(stdin/stdout 均为管道)。 */
483
+ /** 打开一条 ssh 通道(stdin/stdout 均为管道,可流式传 tar)。 */
408
484
  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'] })
485
+ const argv = [sshBinary(config), ...sshOptions(config), config.host, remoteCommand]
486
+ return spawnManaged(argv)
415
487
  }
416
488
 
417
489
  /** 跑一条远程命令,收集输出;超时就杀掉,避免一个卡住的连接拖死整个任务。 */
@@ -423,7 +495,7 @@ export async function runSsh(config, remoteCommand, options = {}) {
423
495
  child.stderr.on('data', (chunk) => err.push(chunk))
424
496
  if (options.stdin !== undefined) child.stdin.end(options.stdin)
425
497
  else child.stdin.end()
426
- const timeoutMs = options.timeoutMs ?? 60000
498
+ const timeoutMs = options.timeoutMs ?? config.commandTimeoutMs ?? 60000
427
499
  const timer = setTimeout(() => child.kill(), timeoutMs)
428
500
  if (typeof timer.unref === 'function') timer.unref()
429
501
  const code = await new Promise((resolve, reject) => {
@@ -441,7 +513,7 @@ export async function runSsh(config, remoteCommand, options = {}) {
441
513
 
442
514
  const BLOCK = 512
443
515
  /** 传输卡死判定:连续这么久没收到/发出数据就杀掉 ssh,让任务失败重试而不是永远挂着。 */
444
- const STALL_MS = 120000
516
+ const STALL_MS = 120000 // 兜底值;实际用 config.stallMs
445
517
 
446
518
  function octal(value, width) {
447
519
  const text = Math.max(0, Math.floor(value)).toString(8)
@@ -1109,7 +1181,7 @@ export async function push(options) {
1109
1181
  let lastActivity = Date.now()
1110
1182
  let stalled = false
1111
1183
  const stallTimer = setInterval(() => {
1112
- if (Date.now() - lastActivity > STALL_MS) {
1184
+ if (Date.now() - lastActivity > config.stallMs) {
1113
1185
  stalled = true
1114
1186
  lastActivity = Date.now()
1115
1187
  child.kill()
@@ -1130,7 +1202,7 @@ export async function push(options) {
1130
1202
  }
1131
1203
  const result = await closed
1132
1204
  if (stalled) {
1133
- throw new Error(`上传 ${group} 时传输卡住(${Math.round(STALL_MS / 1000)} 秒没有进展),已断开;服务端只收到半截进入临时目录,不会污染现有备份,稍后重试即可`)
1205
+ throw new Error(`上传 ${group} 时传输卡住(${Math.round(config.stallMs / 1000)} 秒没有进展),已断开;服务端只收到半截进入临时目录,不会污染现有备份,稍后重试即可`)
1134
1206
  }
1135
1207
  if (result.code !== 0) throw new Error(`上传 ${group} 失败(退出码 ${result.code}):${String(result.stderr).trim()}`)
1136
1208
  uploaded += entries.length
@@ -1403,7 +1475,7 @@ export async function pull(options) {
1403
1475
  let stalled = false
1404
1476
  const reader = new ByteReader(child.stdout, () => { lastActivity = Date.now() })
1405
1477
  const stallTimer = setInterval(() => {
1406
- if (Date.now() - lastActivity > STALL_MS) {
1478
+ if (Date.now() - lastActivity > config.stallMs) {
1407
1479
  stalled = true
1408
1480
  lastActivity = Date.now()
1409
1481
  child.kill()
@@ -1459,7 +1531,7 @@ export async function pull(options) {
1459
1531
  clearInterval(stallTimer)
1460
1532
  const result = await closed
1461
1533
  if (stalled) {
1462
- throw new Error(`恢复 ${group} 时传输卡住(${Math.round(STALL_MS / 1000)} 秒没有数据),已断开;云端数据没动,稍后重试即可`)
1534
+ throw new Error(`恢复 ${group} 时传输卡住(${Math.round(config.stallMs / 1000)} 秒没有数据),已断开;云端数据没动,稍后重试即可`)
1463
1535
  }
1464
1536
  if (result.code !== 0) {
1465
1537
  log(`恢复 ${group} 时远端退出码 ${result.code}:${String(result.stderr).trim()}`)