dsh-remote 0.8.36 → 0.8.38

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/lib/client.js CHANGED
@@ -187,6 +187,45 @@ window.__ModuleLoader__.load({
187
187
  'settings.modeAuto': '自动',
188
188
  'settings.modeOff': '关闭',
189
189
  'settings.modeManual': '手动',
190
+ // ── 工作区备份(rw_backup / rw_restore)────────────────────────────
191
+ 'settings.backup': '工作区备份(.tar.gz)',
192
+ 'settings.backupHint': '把远程工作区打包成 .tar.gz。默认**全部收录**;需要更小的归档时再填排除项。归档可留在远端、拉到本机、或两边都留。',
193
+ 'settings.backupCreate': '立即备份',
194
+ 'settings.backupCreating': '备份中…',
195
+ 'settings.backupExcludes': '排除项(每行一个)',
196
+ 'settings.backupExcludesPlaceholder': 'node_modules\n/build/\n*.log',
197
+ 'settings.backupDest': '存放位置',
198
+ 'settings.backupDestRemote': '仅远端',
199
+ 'settings.backupDestLocal': '仅本机',
200
+ 'settings.backupDestBoth': '两边都留',
201
+ 'settings.backupDirLabel': '远端备份目录:{dir}',
202
+ 'settings.backupDirUnknown': '(无法确定远端备份目录)',
203
+ 'settings.backupNone': '还没有备份。点「立即备份」创建第一个。',
204
+ 'settings.backupCount': '{n} 个备份',
205
+ 'settings.backupWhereRemote': '远端',
206
+ 'settings.backupWhereLocal': '本机',
207
+ 'settings.backupRestore': '恢复',
208
+ 'settings.backupDownload': '下载',
209
+ 'settings.backupUpload': '传回远端',
210
+ 'settings.backupDelete': '删除',
211
+ 'settings.backupVerify': '校验',
212
+ 'settings.backupRestoreConfirm': '要用 {file} 覆盖恢复远程工作区吗?归档里没有的文件会被删除(合并模式不删)。',
213
+ 'settings.backupDeleteConfirm': '删除备份 {file}?此操作不可撤销。',
214
+ 'settings.backupModeReplace': '整树替换',
215
+ 'settings.backupModeMerge': '合并(不删除)',
216
+ 'settings.backupVerified': '校验通过:sha256 {sha}',
217
+ 'settings.backupVerifyMismatch': '校验失败:哈希与记录不一致(归档可能已损坏)',
218
+ 'settings.backupNoSidecar': '无 sidecar(早期归档,元数据未知)',
219
+ 'settings.backupRestored': '已恢复 {file} → {target}',
220
+ 'settings.backupCreated': '已创建 {file}',
221
+ 'settings.backupDownloaded': '已下载到本机:{path}',
222
+ 'settings.backupUploaded': '已传回远端:{path}',
223
+ 'settings.backupDeleted': '已删除 {file}',
224
+ 'settings.backupRefresh': '刷新列表',
225
+ 'settings.backupFailed': '备份操作失败:{msg}',
226
+ 'settings.backupOverCap': '归档超过搬运上限,未传输(可用 maxBackupTransferBytes 放宽)',
227
+ 'settings.backupMachine': '机器',
228
+ 'settings.backupPickMachine': '— 选择机器 —',
190
229
  'picker.title': '选择工作目录',
191
230
  'picker.close': '关闭 ✕',
192
231
  'picker.tabLocal': '本机',
@@ -445,6 +484,45 @@ window.__ModuleLoader__.load({
445
484
  'settings.modeAuto': 'Auto',
446
485
  'settings.modeOff': 'Off',
447
486
  'settings.modeManual': 'Manual',
487
+ // ── workspace backup (rw_backup / rw_restore) ──────────────────────
488
+ 'settings.backup': 'Workspace backup (.tar.gz)',
489
+ 'settings.backupHint': 'Archive the remote workspace to a .tar.gz. **Everything is included by default**; add excludes only when you want a smaller archive. The archive can stay on the remote, be pulled here, or both.',
490
+ 'settings.backupCreate': 'Back up now',
491
+ 'settings.backupCreating': 'Backing up…',
492
+ 'settings.backupExcludes': 'Excludes (one per line)',
493
+ 'settings.backupExcludesPlaceholder': 'node_modules\n/build/\n*.log',
494
+ 'settings.backupDest': 'Store in',
495
+ 'settings.backupDestRemote': 'Remote only',
496
+ 'settings.backupDestLocal': 'Local only',
497
+ 'settings.backupDestBoth': 'Both',
498
+ 'settings.backupDirLabel': 'Remote backup directory: {dir}',
499
+ 'settings.backupDirUnknown': '(cannot determine the remote backup directory)',
500
+ 'settings.backupNone': 'No backups yet. Click "Back up now" to create the first one.',
501
+ 'settings.backupCount': '{n} backup(s)',
502
+ 'settings.backupWhereRemote': 'Remote',
503
+ 'settings.backupWhereLocal': 'Local',
504
+ 'settings.backupRestore': 'Restore',
505
+ 'settings.backupDownload': 'Download',
506
+ 'settings.backupUpload': 'Upload',
507
+ 'settings.backupDelete': 'Delete',
508
+ 'settings.backupVerify': 'Verify',
509
+ 'settings.backupRestoreConfirm': 'Overwrite the remote workspace with {file}? Files not present in the archive will be removed (merge mode deletes nothing).',
510
+ 'settings.backupDeleteConfirm': 'Delete the backup {file}? This cannot be undone.',
511
+ 'settings.backupModeReplace': 'Replace tree',
512
+ 'settings.backupModeMerge': 'Merge (keep extra)',
513
+ 'settings.backupVerified': 'Verified: sha256 {sha}',
514
+ 'settings.backupVerifyMismatch': 'Verification FAILED: the digest does not match the recorded one (the archive may be corrupt)',
515
+ 'settings.backupNoSidecar': 'No sidecar (older archive; metadata unknown)',
516
+ 'settings.backupRestored': 'Restored {file} → {target}',
517
+ 'settings.backupCreated': 'Created {file}',
518
+ 'settings.backupDownloaded': 'Downloaded locally: {path}',
519
+ 'settings.backupUploaded': 'Uploaded to the remote: {path}',
520
+ 'settings.backupDeleted': 'Deleted {file}',
521
+ 'settings.backupRefresh': 'Refresh list',
522
+ 'settings.backupFailed': 'Backup action failed: {msg}',
523
+ 'settings.backupOverCap': 'The archive is over the transfer cap, so it was not transferred (raise maxBackupTransferBytes to allow it)',
524
+ 'settings.backupMachine': 'Machine',
525
+ 'settings.backupPickMachine': '— choose a machine —',
448
526
  'picker.title': 'Select working directory',
449
527
  'picker.close': 'Close ✕',
450
528
  'picker.tabLocal': 'Local',
@@ -860,6 +938,21 @@ window.__ModuleLoader__.load({
860
938
  const [deployBusy, setDeployBusy] = React.useState('')
861
939
  const [deployErr, setDeployErr] = React.useState('')
862
940
  const [deployOpen, setDeployOpen] = React.useState(false)
941
+ // ── workspace backup (rw_backup / rw_restore) ──────────────────────────
942
+ // Machine-scoped like the deploy card: `backupMachine` empty means "this
943
+ // session's machine", which the host resolves from the session binding.
944
+ const [backupOpen, setBackupOpen] = React.useState(false)
945
+ const [backupData, setBackupData] = React.useState(null)
946
+ const [backupBusy, setBackupBusy] = React.useState('')
947
+ const [backupMsg, setBackupMsg] = React.useState('')
948
+ const [backupErr, setBackupErr] = React.useState('')
949
+ const [backupExcludes, setBackupExcludes] = React.useState('')
950
+ const [backupDest, setBackupDest] = React.useState('remote')
951
+ // Restore mode is a panel-level choice rather than a per-row prompt: the
952
+ // two modes differ in whether files absent from the archive are DELETED,
953
+ // so it is worth showing which one is armed before clicking Restore.
954
+ const [backupRestoreMode, setBackupRestoreMode] = React.useState('replace')
955
+ const [backupMachine, setBackupMachine] = React.useState('')
863
956
  React.useEffect(() => { ensureChromeCss() }, [])
864
957
 
865
958
  const testConn = () => {
@@ -949,6 +1042,43 @@ window.__ModuleLoader__.load({
949
1042
  const refreshForwards = () => api('GET', '/dsh-remote/forwards').then((r) => setForwards(r.forwards || [])).catch(() => {})
950
1043
  React.useEffect(() => { refreshForwards(); api('GET', '/dsh-remote/audit?limit=30').then((r) => setAuditLines(r.lines || [])).catch(() => {}) }, [])
951
1044
 
1045
+ // ── 工作区备份 ────────────────────────────────────────────────────────
1046
+ // Reads/writes go through /dsh-remote/backup. The list carries BOTH the
1047
+ // remote archives and any pulled locally, so one refresh answers the panel.
1048
+ const backupQuery = (extra) => {
1049
+ const q = backupMachine ? 'machineId=' + encodeURIComponent(backupMachine) : ''
1050
+ return q ? (extra ? q + '&' + extra : q) : (extra || '')
1051
+ }
1052
+ const refreshBackups = () => api('GET', '/dsh-remote/backup' + (backupQuery('') ? '?' + backupQuery('') : ''))
1053
+ .then((r) => { setBackupData(r); setBackupErr('') })
1054
+ .catch((e) => setBackupErr(String((e && e.message) || e)))
1055
+ React.useEffect(() => { refreshBackups() }, [backupMachine])
1056
+ const backupAction = (action, body) => {
1057
+ setBackupBusy(action); setBackupMsg(''); setBackupErr('')
1058
+ return api('POST', '/dsh-remote/backup', Object.assign({ action }, backupMachine ? { machineId: backupMachine } : {}, body || {}))
1059
+ .then((r) => {
1060
+ if (r) setBackupData(r)
1061
+ if (action === 'create' && r && r.created) {
1062
+ setBackupMsg(tr('settings.backupCreated', { file: r.created.name }) + (r.pullError ? ' — ' + tr('settings.backupOverCap') + ' (' + r.pullError + ')' : ''))
1063
+ } else if (action === 'restore' && r && r.restored) {
1064
+ setBackupMsg(tr('settings.backupRestored', { file: r.restored.file, target: r.restored.target }))
1065
+ } else if (action === 'delete') {
1066
+ setBackupMsg(tr('settings.backupDeleted', { file: body.file }))
1067
+ } else if (action === 'download' && r && r.localPath) {
1068
+ setBackupMsg(tr('settings.backupDownloaded', { path: r.localPath }))
1069
+ } else if (action === 'upload' && r && r.remotePath) {
1070
+ setBackupMsg(tr('settings.backupUploaded', { path: r.remotePath }))
1071
+ } else if (action === 'verify' && r) {
1072
+ setBackupMsg(r.matches === false || r.verified === false
1073
+ ? tr('settings.backupVerifyMismatch')
1074
+ : tr('settings.backupVerified', { sha: String(r.sha256 || '').slice(0, 16) }))
1075
+ }
1076
+ return r
1077
+ })
1078
+ .catch((e) => { setBackupErr(tr('settings.backupFailed', { msg: String((e && e.message) || e) })); return null })
1079
+ .then((r) => { setBackupBusy(''); return r })
1080
+ }
1081
+
952
1082
  // ── 远程 DSH 界面(issue #46)─────────────────────────────────────────
953
1083
  // The remote never opens an inbound port; we SSH out and carry the remote
954
1084
  // loopback web UI back to a local port. One tunnel per machine.
@@ -1330,6 +1460,96 @@ window.__ModuleLoader__.load({
1330
1460
  deployErr ? React.createElement('div', { style: { color: T.danger, fontSize: 12, marginTop: 6, wordBreak: 'break-all' } }, deployErr) : null,
1331
1461
  ) : React.createElement('div', { style: { fontSize: 11, opacity: 0.55 } }, deploy && deploy.verdict ? (deploy.verdict.ok ? tr('settings.deployOk') : tr('settings.deployBlocked')) : ''),
1332
1462
  ),
1463
+ // 工作区备份(rw_backup / rw_backup_list / rw_restore)
1464
+ React.createElement('div', { style: boxS },
1465
+ sectionToggle(backupOpen, setBackupOpen, tr('settings.backup'),
1466
+ backupData ? React.createElement('span', { style: { marginLeft: 'auto', fontSize: 11, opacity: 0.6 } },
1467
+ tr('settings.backupCount', { n: (backupData.entries || []).length + ((backupData.local && backupData.local.entries) || []).length })) : null),
1468
+ backupOpen ? React.createElement(React.Fragment, null,
1469
+ React.createElement('div', { style: { opacity: 0.65, fontSize: 11, lineHeight: 1.5, marginBottom: 6 } }, tr('settings.backupHint')),
1470
+ React.createElement('div', { style: { fontSize: 11, opacity: 0.7, marginBottom: 6, wordBreak: 'break-all' } },
1471
+ backupData && backupData.backupDir ? tr('settings.backupDirLabel', { dir: backupData.backupDir }) : tr('settings.backupDirUnknown')),
1472
+ // Machine selector: empty means "this session's machine", which the
1473
+ // host resolves from the session binding (the panel is reachable from
1474
+ // a local session too, hence an explicit choice is always available).
1475
+ React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', marginBottom: 6, flexWrap: 'wrap' } },
1476
+ React.createElement('span', { style: { fontSize: 12, opacity: 0.8 } }, tr('settings.backupMachine') + ':'),
1477
+ React.createElement('select', { className: 'dsh-rw-input', value: backupMachine, onChange: (e) => setBackupMachine(e.target.value), style: { ...inputS, maxWidth: 200 } },
1478
+ React.createElement('option', { value: '' }, tr('settings.backupPickMachine')),
1479
+ machines.map((m) => React.createElement('option', { key: m.id, value: m.id }, `${m.name || m.host} (${m.username}@${m.host})`))),
1480
+ React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px' },
1481
+ disabled: !!backupBusy, onClick: () => refreshBackups() }, tr('settings.backupRefresh')),
1482
+ // Which restore mode the Restore button will use.
1483
+ React.createElement('span', { style: { fontSize: 12, opacity: 0.8, marginLeft: 4 } }, tr('settings.backupRestore') + ':'),
1484
+ React.createElement('select', {
1485
+ className: 'dsh-rw-input', value: backupRestoreMode,
1486
+ onChange: (e) => setBackupRestoreMode(e.target.value),
1487
+ style: { ...inputS, maxWidth: 170 },
1488
+ },
1489
+ React.createElement('option', { value: 'replace' }, tr('settings.backupModeReplace')),
1490
+ React.createElement('option', { value: 'merge' }, tr('settings.backupModeMerge')))),
1491
+ // Excludes + destination, then the create button.
1492
+ React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'flex-start', flexWrap: 'wrap', marginBottom: 6 } },
1493
+ React.createElement('div', { style: { flex: '1 1 260px', minWidth: 220 } },
1494
+ React.createElement('div', { style: { fontSize: 11, opacity: 0.75, marginBottom: 2 } }, tr('settings.backupExcludes')),
1495
+ React.createElement('textarea', {
1496
+ className: 'dsh-rw-input', value: backupExcludes,
1497
+ onChange: (e) => setBackupExcludes(e.target.value),
1498
+ placeholder: tr('settings.backupExcludesPlaceholder'),
1499
+ rows: 3, style: { ...inputS, width: '100%', fontFamily: 'monospace', resize: 'vertical' },
1500
+ })),
1501
+ React.createElement('div', null,
1502
+ React.createElement('div', { style: { fontSize: 11, opacity: 0.75, marginBottom: 2 } }, tr('settings.backupDest')),
1503
+ React.createElement('select', { className: 'dsh-rw-input', value: backupDest, onChange: (e) => setBackupDest(e.target.value), style: { ...inputS, maxWidth: 160 } },
1504
+ React.createElement('option', { value: 'remote' }, tr('settings.backupDestRemote')),
1505
+ React.createElement('option', { value: 'local' }, tr('settings.backupDestLocal')),
1506
+ React.createElement('option', { value: 'both' }, tr('settings.backupDestBoth'))),
1507
+ React.createElement('button', {
1508
+ type: 'button', className: 'dsh-rw-btn dsh-rw-primary',
1509
+ style: { ...primaryBtnS, marginTop: 6, display: 'block' },
1510
+ disabled: !!backupBusy,
1511
+ onClick: () => backupAction('create', { excludes: backupExcludes, dest: backupDest }),
1512
+ }, backupBusy === 'create' ? tr('settings.backupCreating') : tr('settings.backupCreate')))),
1513
+ // The list: remote archives then local copies. A sidecar-less archive
1514
+ // is labelled instead of showing invented metadata.
1515
+ (backupData && (backupData.entries || []).length) || (backupData && backupData.local && (backupData.local.entries || []).length)
1516
+ ? React.createElement('div', { style: { maxHeight: 280, overflowY: 'auto' } },
1517
+ (backupData.entries || []).map((e) => React.createElement('div', { key: 'r-' + e.name, className: 'dsh-rw-row', style: { display: 'flex', gap: 6, alignItems: 'center', padding: '5px 0', borderBottom: '1px solid ' + T.border, fontSize: 12, flexWrap: 'wrap' } },
1518
+ React.createElement('span', { style: { fontFamily: 'monospace', flex: '1 1 220px', wordBreak: 'break-all' } }, e.name),
1519
+ React.createElement('span', { style: { opacity: 0.7, minWidth: 60 } }, humanSize(e.bytes)),
1520
+ React.createElement('span', { style: { opacity: 0.7 } }, e.createdAt || e.mtime || ''),
1521
+ React.createElement('span', { style: { opacity: 0.55, fontSize: 11 } }, tr('settings.backupWhereRemote')),
1522
+ e.hasMeta ? null : React.createElement('span', { style: { color: T.warn, fontSize: 11 } }, tr('settings.backupNoSidecar')),
1523
+ React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 8px' }, disabled: !!backupBusy,
1524
+ onClick: () => backupAction('verify', { file: e.name }) }, tr('settings.backupVerify')),
1525
+ React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 8px' }, disabled: !!backupBusy,
1526
+ onClick: () => backupAction('download', { file: e.name }) }, tr('settings.backupDownload')),
1527
+ // Restore is destructive, so it asks first and sends the
1528
+ // explicit confirmation the host requires.
1529
+ React.createElement('button', { type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: { ...primaryBtnS, padding: '2px 8px' }, disabled: !!backupBusy,
1530
+ onClick: () => {
1531
+ if (!window.confirm(tr('settings.backupRestoreConfirm', { file: e.name }))) return
1532
+ backupAction('restore', { file: e.name, confirm: true, mode: backupRestoreMode })
1533
+ } }, tr('settings.backupRestore')),
1534
+ React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 8px', color: T.dangerText }, disabled: !!backupBusy,
1535
+ onClick: () => { if (window.confirm(tr('settings.backupDeleteConfirm', { file: e.name }))) backupAction('delete', { file: e.name }) } }, tr('settings.backupDelete')))),
1536
+ ((backupData.local && backupData.local.entries) || []).map((e) => React.createElement('div', { key: 'l-' + e.name, className: 'dsh-rw-row', style: { display: 'flex', gap: 6, alignItems: 'center', padding: '5px 0', borderBottom: '1px solid ' + T.border, fontSize: 12, opacity: 0.92, flexWrap: 'wrap' } },
1537
+ React.createElement('span', { style: { fontFamily: 'monospace', flex: '1 1 220px', wordBreak: 'break-all' } }, e.name),
1538
+ React.createElement('span', { style: { opacity: 0.7, minWidth: 60 } }, humanSize(e.bytes)),
1539
+ React.createElement('span', { style: { opacity: 0.7 } }, e.createdAt || e.mtime || ''),
1540
+ React.createElement('span', { style: { opacity: 0.55, fontSize: 11 } }, tr('settings.backupWhereLocal')),
1541
+ React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 8px' }, disabled: !!backupBusy,
1542
+ onClick: () => backupAction('verify', { file: e.name, where: 'local' }) }, tr('settings.backupVerify')),
1543
+ React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 8px' }, disabled: !!backupBusy,
1544
+ onClick: () => backupAction('upload', { file: e.name }) }, tr('settings.backupUpload')),
1545
+ React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 8px', color: T.dangerText }, disabled: !!backupBusy,
1546
+ onClick: () => { if (window.confirm(tr('settings.backupDeleteConfirm', { file: e.name }))) backupAction('delete', { file: e.name, where: 'local' }) } }, tr('settings.backupDelete')))))
1547
+ : React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, tr('settings.backupNone')),
1548
+ backupMsg ? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: T.ok, wordBreak: 'break-all' } }, backupMsg) : null,
1549
+ backupErr ? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: T.danger, wordBreak: 'break-all' } }, backupErr) : null,
1550
+ ) : backupData ? React.createElement('div', { style: { fontSize: 11, opacity: 0.55 } },
1551
+ tr('settings.backupCount', { n: (backupData.entries || []).length + ((backupData.local && backupData.local.entries) || []).length })) : null,
1552
+ ),
1333
1553
  // 端口转发面板
1334
1554
  React.createElement('div', { style: boxS },
1335
1555
  sectionToggle(fwdOpen, setFwdOpen, tr('settings.forwards')),
package/lib/errors.js CHANGED
@@ -66,6 +66,15 @@ export function classifyError(err, opts = {}) {
66
66
  raw: msg,
67
67
  }
68
68
  }
69
+ // ssh2 throws these SYNCHRONOUSLY from Client.connect (issue #48) — the most
70
+ // common one is an encrypted OpenSSH key without a passphrase.
71
+ if (/no passphrase given|cannot parse privatekey|does not contain a \(valid\) private key/i.test(msg)) {
72
+ return {
73
+ category: 'credentials',
74
+ hint: `私钥解析失败${where}:私钥已加密但未提供 passphrase,或私钥格式不受支持。请为该机器填写 passphrase,或改用未加密/受支持格式(OpenSSH/PEM)的私钥。`,
75
+ raw: msg,
76
+ }
77
+ }
69
78
  return { category: 'other', hint: msg, raw: msg }
70
79
  }
71
80