dsh-remote 0.8.39 → 0.8.41

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.en.md CHANGED
@@ -39,7 +39,7 @@ The native **"Add workspace"** flow — a centered modal with two tabs, opening
39
39
 
40
40
  ## Features
41
41
 
42
- ![Capabilities at a glance — multi-machine SSH, live alias resolution, the two-tab picker, three-way sync, remote @ completion, a remote DSH Web UI in your local browser, health check & one-click deploy, audit, port forwarding, sidebar editing, self-update, and the 24 rw_* tools](docs/shots/features.png)
42
+ ![Capabilities at a glance — multi-machine SSH, live alias resolution, the two-tab picker, three-way sync, remote @ completion, a remote DSH Web UI in your local browser, health check & one-click deploy, audit, port forwarding, sidebar editing, self-update, and the 22 rw_* tools](docs/shots/features.png)
43
43
 
44
44
  The image above is the overview. What follows is only what the image does not make obvious.
45
45
 
@@ -56,10 +56,9 @@ The image above is the overview. What follows is only what the image does not ma
56
56
 
57
57
  The rest:
58
58
 
59
- - **25 model tools** (listed so they can be copied or searched): `rw_info`, `rw_connect`, `rw_machines`, `rw_pick_workspace`, `rw_list_dir`, `rw_stat`, `rw_read_file`, `rw_write_file`, `rw_edit`, `rw_append`, `rw_mkdir`, `rw_remove`, `rw_move`, `rw_exec`, `rw_search`, `rw_download`, `rw_upload`, `rw_sync`, `rw_push`, `rw_backup`, `rw_backup_list`, `rw_restore`, `rw_forward`, `rw_disconnect`, `rw_deploy_probe`.
60
- - **Compressed workspace backup / restore** (`rw_backup` / `rw_backup_list` / `rw_restore`) — archive a remote workspace to `.tar.gz` (everything is included by default; pass excludes for `node_modules`, `/build/`, `*.log` and friends). The archive can stay on the remote under `$HOME/.dsh-remote/backups`, be pulled to this machine, or **both**. Before restoring, the archive is verified; **a corrupt archive is refused and the target is left untouched**. `mode=replace` (default) swaps the whole tree, `mode=merge` only overwrites without deleting. The settings page has a **Backup** card too (create / download / restore / delete).
59
+ - **22 model tools** (listed so they can be copied or searched): `rw_info`, `rw_connect`, `rw_machines`, `rw_pick_workspace`, `rw_list_dir`, `rw_stat`, `rw_read_file`, `rw_write_file`, `rw_edit`, `rw_append`, `rw_mkdir`, `rw_remove`, `rw_move`, `rw_exec`, `rw_search`, `rw_download`, `rw_upload`, `rw_sync`, `rw_push`, `rw_forward`, `rw_disconnect`, `rw_deploy_probe`.
61
60
  - **Open a remote machine's DSH Web UI locally** (`0.8.36+`) — nothing has to be exposed on the remote: the plugin SSHes out, starts a `dsh web` there that binds **`127.0.0.1` only**, and tunnels its port back to a loopback port on this machine. The URL carries a one-time login token, consumed once by a redirect.
62
- - **One-click health check & deploy** (`0.8.36+`) — **a failed connection is usually the remote dsh version or environment, not the plugin.** The read-only check reports platform / node / npm / dsh version / **whether the native module can load** / proxy and npm registry, with a suggested fix; "Deploy & verify" installs into a remote **private directory** (no system directory, no PATH change, no replacing the version you use — deleting it is a complete rollback) and verifies step by step. If that still fails, the built-in `dsh-remote-deploy` skill takes over.
61
+ - **One-click health check & deploy** (`0.8.36+`) — **a failed connection is usually the remote dsh version or environment, not the plugin.** The read-only check reports platform / node / npm / dsh version / **whether the native module can load** / proxy and npm registry, with a suggested fix; "Deploy & verify" installs into a remote **private directory** (no system directory, no PATH change, no replacing the version you use — deleting it is a complete rollback) and verifies step by step. The button **no longer requires running the check first** (`0.8.40+`) — the deploy re-checks internally. If that still fails, the built-in `dsh-remote-deploy` skill takes over.
63
62
  - **Cross-platform remotes** — all file access is SFTP-protocol-level (no POSIX shell), so Linux/macOS/Windows remotes all work.
64
63
  - **Windows remotes** — the platform is auto-detected and commands go through `bash -s` over stdin, so quoting and backslash escaping are never an issue (`config.shell` can pin a path or `native` disables wrapping); `C:\Users\dev` and `/c/Users/dev` are both accepted.
65
64
  - **Async long tasks** — `rw_sync`/`rw_push` with `async: true` return a `taskId` with progress/result/cancel.
@@ -264,9 +263,6 @@ Deploying to a product profile is a separate, explicit action (`./sync.sh`) for
264
263
  | `webInstallPrefix` | string | `''` | where an automatic deployment installs dsh; empty means `$HOME/.dsh-remote/dsh` (no system directory, no PATH change) |
265
264
  | `webInstallVersion` | string | `0.1.5-rc.2` | dsh version an automatic deployment installs; the default is the first that can start the web surface on Linux |
266
265
  | `webInstallRegistry` | string | `''` | npm registry for the install; **empty keeps the remote's own config**, so an internal mirror is never overridden |
267
- | `backupDir` | string | `''` | Where backup archives are written **on the remote**; empty means `$HOME/.dsh-remote/backups` (resolved on the remote, never guessed from this machine's home) |
268
- | `backupExcludes` | string[] | `[]` | Paths excluded from backups by default (gitignore-style). **Empty by default**: silently omitting directories is how a restore becomes silent data loss |
269
- | `maxBackupTransferBytes` | int | `2147483648` | Cap (bytes) on moving an archive between this machine and the remote; **the remote archive is never truncated** — this only bounds the optional cross-machine copy. 0 = unlimited |
270
266
 
271
267
  > The authoritative list is the `Config` schema in `lib/index.js`; this table mirrors it.
272
268
 
package/README.md CHANGED
@@ -39,16 +39,15 @@ DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0.
39
39
 
40
40
  ## 功能
41
41
 
42
- ![核心能力总览 — 多机 SSH / 别名实时解析 / 双 tab 选择器 / 三路同步 / 远程 @ 补全 / 远端 DSH 界面挂到本机 / 体检与一键部署 / 安全审计 / 端口转发 / 侧栏编辑 / 自动更新,以及 25 个 rw_* 工具](docs/shots/features.png)
42
+ ![核心能力总览 — 多机 SSH / 别名实时解析 / 双 tab 选择器 / 三路同步 / 远程 @ 补全 / 远端 DSH 界面挂到本机 / 体检与一键部署 / 安全审计 / 端口转发 / 侧栏编辑 / 自动更新,以及 22 个 rw_* 工具](docs/shots/features.png)
43
43
 
44
44
  上图是能力总览;下面只列**上图没说清、但用起来需要知道**的部分。
45
45
 
46
46
  其余要点:
47
47
 
48
- - **25 个模型工具**(便于复制/检索):`rw_info`、`rw_connect`、`rw_machines`、`rw_pick_workspace`、`rw_list_dir`、`rw_stat`、`rw_read_file`、`rw_write_file`、`rw_edit`、`rw_append`、`rw_mkdir`、`rw_remove`、`rw_move`、`rw_exec`、`rw_search`、`rw_download`、`rw_upload`、`rw_sync`、`rw_push`、`rw_backup`、`rw_backup_list`、`rw_restore`、`rw_forward`、`rw_disconnect`、`rw_deploy_probe`。
49
- - **工作区压缩备份 / 恢复**(`rw_backup` / `rw_backup_list` / `rw_restore`)—— 把远程工作区打成 `.tar.gz`(默认全部收录,另可按参数排除 `node_modules`、`/build/`、`*.log` 等);归档可留在远端 `$HOME/.dsh-remote/backups`,也可拉到本机或**两边都留**。恢复前先校验归档可读,**归档损坏则拒绝执行、目标目录原封不动**;`mode=replace`(默认)整树替换、`mode=merge` 只覆盖不删除。设置页里也有「备份」卡片(新建 / 下载 / 恢复 / 删除)。
48
+ - **22 个模型工具**(便于复制/检索):`rw_info`、`rw_connect`、`rw_machines`、`rw_pick_workspace`、`rw_list_dir`、`rw_stat`、`rw_read_file`、`rw_write_file`、`rw_edit`、`rw_append`、`rw_mkdir`、`rw_remove`、`rw_move`、`rw_exec`、`rw_search`、`rw_download`、`rw_upload`、`rw_sync`、`rw_push`、`rw_forward`、`rw_disconnect`、`rw_deploy_probe`。
50
49
  - **把远端机器的 DSH 界面挂到本机**(`0.8.36+`)—— 不用在远端开任何端口:插件主动 SSH 连过去,在远端**只监听 `127.0.0.1`** 地起一个 `dsh web`,再把端口经隧道搬回本机的一个 loopback 端口。地址带一次性登录令牌,只在本机这次跳转里用一次。
51
- - **连不上时一键体检与部署**(`0.8.36+`)—— **连不上大多不是插件的问题,而是远端 dsh 的版本或环境不对**。体检(只读)会报出平台/node/npm/dsh 版本、**原生模块能否启动**、代理与 npm 源,并给出修复建议;「部署并验证」把它装到远端**私有目录**(不写系统目录、不改 PATH、不覆盖你在用的版本,删目录即回滚)并逐步校验。失败还能交给内置的 `dsh-remote-deploy` 技能排查。
50
+ - **连不上时一键体检与部署**(`0.8.36+`)—— **连不上大多不是插件的问题,而是远端 dsh 的版本或环境不对**。体检(只读)会报出平台/node/npm/dsh 版本、**原生模块能否启动**、代理与 npm 源,并给出修复建议;「部署并验证」把它装到远端**私有目录**(不写系统目录、不改 PATH、不覆盖你在用的版本,删目录即回滚)并逐步校验。按钮**不再需要先体检**——直接点即可(`0.8.40+`),部署过程自己会重新体检。失败还能交给内置的 `dsh-remote-deploy` 技能排查。
52
51
  - **远端跨平台** —— 文件访问走 SFTP 协议层(不依赖 POSIX shell),Linux/macOS/Windows 远端都能列/读/写/搜索/同步。
53
52
  - **Windows 主机** —— 自动探测平台并定位 Git Bash,命令经 `bash -s` 走 stdin 执行,不受引号/反斜杠转义困扰(`config.shell` 可指定或设 `native` 关闭);`C:\Users\dev` 与 `/c/Users/dev` 两种写法都接受。
54
53
  - **长任务异步化** —— `rw_sync`/`rw_push` 传 `async: true` 返回 `taskId`,可查询进度/结果/取消。
@@ -246,9 +245,6 @@ scripts/dev-run.sh --status # 是否在运行
246
245
  | `webInstallPrefix` | string | `''` | 自动部署装到远端的哪个目录;留空用 `$HOME/.dsh-remote/dsh`(不写系统目录、不改 PATH) |
247
246
  | `webInstallVersion` | string | `0.1.5-rc.2` | 自动部署安装的 dsh 版本;默认值是第一个在 Linux 上能启动 web 的版本 |
248
247
  | `webInstallRegistry` | string | `''` | 安装用的 npm 源;**留空沿用远端自己的配置**,不会覆盖内网镜像 |
249
- | `backupDir` | string | `''` | 备份归档在**远端**的存放目录;留空用 `$HOME/.dsh-remote/backups`(在远端解析,不用本机 home 猜) |
250
- | `backupExcludes` | string[] | `[]` | 备份默认排除的路径(gitignore 风格)。**默认为空**:静默少打包目录正是「恢复即静默丢数据」的成因 |
251
- | `maxBackupTransferBytes` | int | `2147483648` | 归档在本机与远端之间搬运的大小上限(字节);**远端的归档从不截断**,只约束可选的对拷。0 = 不限 |
252
248
 
253
249
  > 权威清单是 `lib/index.js` 里的 `Config` schema,本表与之一致。
254
250
 
package/lib/client.js CHANGED
@@ -187,45 +187,6 @@ 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': '— 选择机器 —',
229
190
  'picker.title': '选择工作目录',
230
191
  'picker.close': '关闭 ✕',
231
192
  'picker.tabLocal': '本机',
@@ -484,45 +445,6 @@ window.__ModuleLoader__.load({
484
445
  'settings.modeAuto': 'Auto',
485
446
  'settings.modeOff': 'Off',
486
447
  '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 —',
526
448
  'picker.title': 'Select working directory',
527
449
  'picker.close': 'Close ✕',
528
450
  'picker.tabLocal': 'Local',
@@ -938,21 +860,7 @@ window.__ModuleLoader__.load({
938
860
  const [deployBusy, setDeployBusy] = React.useState('')
939
861
  const [deployErr, setDeployErr] = React.useState('')
940
862
  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
863
  // 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('')
956
864
  React.useEffect(() => { ensureChromeCss() }, [])
957
865
 
958
866
  const testConn = () => {
@@ -1042,43 +950,6 @@ window.__ModuleLoader__.load({
1042
950
  const refreshForwards = () => api('GET', '/dsh-remote/forwards').then((r) => setForwards(r.forwards || [])).catch(() => {})
1043
951
  React.useEffect(() => { refreshForwards(); api('GET', '/dsh-remote/audit?limit=30').then((r) => setAuditLines(r.lines || [])).catch(() => {}) }, [])
1044
952
 
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
-
1082
953
  // ── 远程 DSH 界面(issue #46)─────────────────────────────────────────
1083
954
  // The remote never opens an inbound port; we SSH out and carry the remote
1084
955
  // loopback web UI back to a local port. One tunnel per machine.
@@ -1437,14 +1308,18 @@ window.__ModuleLoader__.load({
1437
1308
  ? React.createElement('div', { style: { color: T.ok, marginTop: 4 } }, tr('settings.deployInvestigateStarted') + ' (' + deploy.sessionId + ')')
1438
1309
  : null,
1439
1310
  ) : null,
1440
- // The install button only appears when a probe says it is both needed
1441
- // and possible; clicking it is the explicit consent (confirm: true).
1442
- deploy && deploy.verdict && deploy.verdict.canAutoInstall && !deploy.verdict.ok
1443
- ? React.createElement('button', { type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: { ...primaryBtnS, marginTop: 6 },
1444
- disabled: !!deployBusy,
1445
- onClick: () => { if (window.confirm(tr('settings.deployConfirm'))) deployAction('install', webAttachMachine, { confirm: true }) } },
1446
- deployBusy === 'install' ? tr('settings.deployInstalling') : tr('settings.deployInstall'))
1447
- : null,
1311
+ // Deploy is ALWAYS available — not hidden behind a prior probe.
1312
+ //
1313
+ // It used to render only when a probe had already run AND reported
1314
+ // something wrong (`deploy.verdict.canAutoInstall && !deploy.verdict.ok`),
1315
+ // which meant a user who only ever saw 体检 could never deploy: the
1316
+ // button that does the work was waiting behind a check they had no
1317
+ // reason to run. The host action re-probes internally anyway, so it is
1318
+ // safe to offer directly; clicking it is the explicit consent.
1319
+ React.createElement('button', { type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: { ...primaryBtnS, marginTop: 6 },
1320
+ disabled: !!deployBusy || !webAttachMachine,
1321
+ onClick: () => { if (window.confirm(tr('settings.deployConfirm'))) deployAction('install', webAttachMachine, { confirm: true }) } },
1322
+ deployBusy === 'install' ? tr('settings.deployInstalling') : tr('settings.deployInstall')),
1448
1323
  // The AI escape hatch: offered after a failure, and ONLY on click —
1449
1324
  // it costs a model call, so it is never automatic.
1450
1325
  (deployErr || (deploy && deploy.ok === false))
@@ -1460,96 +1335,6 @@ window.__ModuleLoader__.load({
1460
1335
  deployErr ? React.createElement('div', { style: { color: T.danger, fontSize: 12, marginTop: 6, wordBreak: 'break-all' } }, deployErr) : null,
1461
1336
  ) : React.createElement('div', { style: { fontSize: 11, opacity: 0.55 } }, deploy && deploy.verdict ? (deploy.verdict.ok ? tr('settings.deployOk') : tr('settings.deployBlocked')) : ''),
1462
1337
  ),
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
- ),
1553
1338
  // 端口转发面板
1554
1339
  React.createElement('div', { style: boxS },
1555
1340
  sectionToggle(fwdOpen, setFwdOpen, tr('settings.forwards')),
package/lib/index.js CHANGED
@@ -53,13 +53,7 @@ import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeM
53
53
  import { registerHttpTransports } from './http-transport.js'
54
54
  import { SshPool } from './pool.js'
55
55
  import { createFsRoutes } from './routes-fs.js'
56
- import { createBackupRoutes } from './routes-backup.js'
57
56
  import { listDirStructured, removeRemoteTree } from './remote-fs.js'
58
- import {
59
- createBackup, listBackups, restoreBackup, describeCreate, localBackupDirFor, resolveRemoteHome,
60
- pullBackup, pushBackup, deleteBackup, listLocalBackups,
61
- } from './backup.js'
62
- import { parseExcludes, resolveBackupDir, isSafeArchiveName, formatBytes, DEFAULT_MAX_TRANSFER_BYTES } from './archive.js'
63
57
 
64
58
  export const name = 'dsh-remote'
65
59
 
@@ -198,19 +192,6 @@ export const Config = z.object({
198
192
  /** npm registry to install from. Empty uses the remote's own npm config, which
199
193
  * is what an internal mirror needs to stay in effect. */
200
194
  webInstallRegistry: z.string().default(''),
201
- /** Directory that workspace backups are written to on the REMOTE host. Empty
202
- * means `$HOME/.dsh-remote/backups`, resolved on the remote (so a remote
203
- * $HOME is never guessed from the local machine). */
204
- backupDir: z.string().default(''),
205
- /** Exclude patterns applied to backups by default (gitignore-ish, one per
206
- * line). Deliberately EMPTY: a backup that silently omits directories is how
207
- * a "restore" turns into silent data loss. Add patterns here (or per call)
208
- * when you want a smaller archive. */
209
- backupExcludes: z.array(z.string()).default([]),
210
- /** Cap (bytes) on copying an archive between the remote and this machine.
211
- * The archive on the remote is never truncated — this only bounds the
212
- * optional cross-machine transfer. 0 = unlimited. */
213
- maxBackupTransferBytes: z.number().step(1).min(0).default(DEFAULT_MAX_TRANSFER_BYTES),
214
195
  })
215
196
 
216
197
  // ── shell / path helpers (pure implementations in lib/paths.js) ───────────
@@ -1297,20 +1278,6 @@ export async function apply(ctx, config) {
1297
1278
  return normalizeRemotePath(b.ws ? joinRemoteRel(b.ws, rel.replace(/\\/g, '/')) : rel)
1298
1279
  }
1299
1280
 
1300
- // ── backup helpers (shared by the tools and the /dsh-remote/backup routes) ─
1301
- /** The remote directory backups land in: the configured override, else
1302
- * `$HOME/.dsh-remote/backups` resolved ON THE REMOTE (never guessed from the
1303
- * local machine, whose home has nothing to do with the remote one). */
1304
- const backupDirForBinding = async (b) => {
1305
- const override = String(config.backupDir || '').trim()
1306
- if (override) return resolveBackupDir('', override)
1307
- if (!b || !b.pool) return ''
1308
- const home = await resolveRemoteHome(b.pool)
1309
- return resolveBackupDir(home, '')
1310
- }
1311
- /** Local directory holding archives pulled from one machine. */
1312
- const localBackupRootFor = (b) => localBackupDirFor(mirrorRootFor(b.host, b.username, b.port))
1313
-
1314
1281
  // ── tools ─────────────────────────────────────────────────────────────────
1315
1282
 
1316
1283
  const textOut = {
@@ -2259,158 +2226,6 @@ export async function apply(ctx, config) {
2259
2226
  },
2260
2227
  }),
2261
2228
 
2262
- defineTool({
2263
- name: 'rw_backup',
2264
- description:
2265
- 'Create a compressed (.tar.gz) backup of the remote workspace (or a subdirectory of it) on the remote host, with an optional copy pulled to this machine. Archives are written under $HOME/.dsh-remote/backups (config backupDir) with a sidecar .meta.json recording the workspace, entry count and sha256. Add exclude patterns when you want a smaller archive — by default EVERYTHING is archived, because silently omitting files is how a restore becomes silent data loss. Use rw_backup_list to see what exists and rw_restore to restore one.',
2266
- parameters: {
2267
- path: { type: 'string', description: 'Directory to back up (default and normally: the current remote workspace). Must be the workspace or a subdirectory of it.' },
2268
- label: { type: 'string', description: 'Optional tag embedded in the archive filename (e.g. "before-refactor").' },
2269
- excludes: { type: 'string', description: 'Comma- or newline-separated extra exclude patterns (gitignore-ish: node_modules, /build/, *.log). Relative paths inside the workspace.' },
2270
- dest: { type: 'string', description: 'Where the archive ends up: remote (default), local (pulled here; the remote staging copy is removed), or both.' },
2271
- },
2272
- output: textOut,
2273
- async execute(args, exec) {
2274
- const b = requireBinding(exec, 'rw_backup')
2275
- const dir = resolveRemoteArg(b, args.path || '')
2276
- if (!dir || dir === '/') throw new Error('rw_backup: a directory to back up is required')
2277
- const root = normalizeRemotePath(b.ws || '')
2278
- if (root && dir !== root && !dir.startsWith(root.replace(/\/+$/, '') + '/')) {
2279
- throw new Error(`rw_backup: ${dir} is outside this session's remote workspace (${root}) — backups are limited to the workspace tree`)
2280
- }
2281
- // Config defaults first, then this call's own patterns, so a caller can
2282
- // extend the profile's excludes without restating them.
2283
- const parsed = parseExcludes([
2284
- ...(Array.isArray(config.backupExcludes) ? config.backupExcludes : []),
2285
- ...String(args.excludes || '').split(/[\r\n,]+/),
2286
- ])
2287
- const backupDir = await backupDirForBinding(b)
2288
- if (!backupDir) throw new Error('rw_backup: cannot determine the remote $HOME — set the backupDir config option')
2289
- const dest = ['remote', 'local', 'both'].includes(String(args.dest)) ? String(args.dest) : 'remote'
2290
- const result = await createBackup(b.pool, {
2291
- dir,
2292
- backupDir,
2293
- label: args.label ? String(args.label) : '',
2294
- excludes: parsed.patterns,
2295
- meta: { host: b.host, username: b.username, port: b.port, label: args.label ? String(args.label) : '' },
2296
- })
2297
- if (!result.ok) {
2298
- audit('backup', `backup ${dir} failed: ${result.error}`, 1, b)
2299
- throw new Error('rw_backup: ' + result.error)
2300
- }
2301
- audit('backup', `backup ${dir} → ${result.archive}`, 0, b)
2302
- const lines = [describeCreate(result, { dest })]
2303
- lines.push(`remote archive: ${result.archive}`)
2304
- if (result.meta && result.meta.excludes && result.meta.excludes.length) lines.push(`excludes: ${result.meta.excludes.join(', ')}`)
2305
- if (parsed.dropped.length) lines.push(`ignored (no usable pattern): ${parsed.dropped.join(', ')}`)
2306
- if (dest === 'local' || dest === 'both') {
2307
- const pulled = await pullBackup(b.pool, {
2308
- archive: result.archive,
2309
- localDir: localBackupDirFor(mirrorRootFor(b.host, b.username, b.port)),
2310
- maxTransferBytes: Number(config.maxBackupTransferBytes) || 0,
2311
- })
2312
- if (pulled.ok) {
2313
- lines.push(`local copy: ${pulled.localPath}`)
2314
- if (dest === 'local') {
2315
- const del = await deleteBackup(b.pool, { archive: result.archive })
2316
- lines.push(del.ok ? 'remote staging copy removed (dest=local)' : `could NOT remove the remote copy: ${del.error || 'unknown error'}`)
2317
- }
2318
- } else {
2319
- lines.push(`local copy FAILED: ${pulled.error}`)
2320
- }
2321
- }
2322
- return { text: lines.join('\n') }
2323
- },
2324
- }),
2325
-
2326
- defineTool({
2327
- name: 'rw_backup_list',
2328
- description:
2329
- "List the compressed backups of this session's remote workspace: name, size, entry count, and when it was taken. Shows both the archives stored on the remote host and any pulled into the local machine. Use the exact `name` with rw_restore.",
2330
- parameters: {
2331
- path: { type: 'string', description: "Directory whose backups to list (default: the workspace). Backups are named by workspace, so passing a subdirectory lists that subdirectory's backups." },
2332
- },
2333
- output: textOut,
2334
- async execute(args, exec) {
2335
- const b = requireBinding(exec, 'rw_backup_list')
2336
- const backupDir = await backupDirForBinding(b)
2337
- const remote = backupDir
2338
- ? await listBackups(b.pool, { dir: backupDir })
2339
- : { exists: false, dir: '', entries: [], error: '' }
2340
- const localDir = localBackupDirFor(mirrorRootFor(b.host, b.username, b.port))
2341
- const local = listLocalBackups(localDir)
2342
- const fmt = (e) => {
2343
- const bits = [e.name, formatBytes(e.bytes)]
2344
- if (e.members != null) bits.push(`${e.members} entries`)
2345
- if (e.createdAt || e.mtime) bits.push(e.createdAt || e.mtime)
2346
- if (!e.hasMeta) bits.push('NO SIDECAR (metadata unknown)')
2347
- return ' ' + bits.join(' | ')
2348
- }
2349
- const lines = []
2350
- if (!backupDir) {
2351
- lines.push('(cannot resolve the remote backup directory — set the backupDir config option)')
2352
- } else if (remote.error) {
2353
- lines.push(`remote listing failed: ${remote.error}`)
2354
- } else if (!remote.entries.length) {
2355
- lines.push(`no backups yet in ${backupDir} (create one with rw_backup)`)
2356
- } else {
2357
- lines.push(`${remote.entries.length} remote backup(s) in ${backupDir}:`)
2358
- lines.push(...remote.entries.map(fmt))
2359
- }
2360
- if (local.entries.length) {
2361
- lines.push('', `${local.entries.length} local copy(ies) in ${localDir}:`)
2362
- lines.push(...local.entries.map(fmt))
2363
- }
2364
- lines.push('', 'restore one with rw_restore(file=<name>).')
2365
- return { text: lines.join('\n') }
2366
- },
2367
- }),
2368
-
2369
- defineTool({
2370
- name: 'rw_restore',
2371
- description:
2372
- 'Restore a workspace from one of its compressed backups (list them with rw_backup_list). The archive is verified BEFORE anything is written; a corrupt or truncated archive is refused and the target is left untouched. mode=replace (default) swaps the target for the archive contents and removes files not present in the archive; mode=merge extracts over the existing tree and deletes nothing. Prefer replace for "give me back exactly the state I archived"; use merge only when the archive is known to be a partial overlay.',
2373
- parameters: {
2374
- file: { type: 'string', required: true, description: 'Backup archive filename from rw_backup_list (e.g. proj-abc-2026-10-08T11-22-33.tar.gz).' },
2375
- target: { type: 'string', description: 'Directory to restore into (default: the current remote workspace). Usually left unset.' },
2376
- mode: { type: 'string', description: 'replace (default) or merge.' },
2377
- where: { type: 'string', description: 'Which copy to restore from: remote (default) or local (a pulled copy; it is uploaded back first).' },
2378
- },
2379
- output: textOut,
2380
- async execute(args, exec) {
2381
- const b = requireBinding(exec, 'rw_restore')
2382
- const file = String(args.file || '').trim()
2383
- if (!file) throw new Error('rw_restore: file is required (list them with rw_backup_list)')
2384
- if (!isSafeArchiveName(file)) throw new Error(`rw_restore: ${file} is not a valid backup filename (expected a plain *.tar.gz basename)`)
2385
- const target = args.target ? resolveRemoteArg(b, args.target) : (b.ws || '')
2386
- if (!target || target === '/') throw new Error('rw_restore: no restore target — pick a remote workspace first')
2387
- const root = normalizeRemotePath(b.ws || '')
2388
- if (root && target !== root && !target.startsWith(root.replace(/\/+$/, '') + '/')) {
2389
- throw new Error(`rw_restore: refusing to restore into ${target} — it is outside this session's remote workspace (${root})`)
2390
- }
2391
- const mode = args.mode === 'merge' ? 'merge' : 'replace'
2392
- const backupDir = await backupDirForBinding(b)
2393
- if (!backupDir) throw new Error('rw_restore: cannot determine the remote backup directory — set the backupDir config option')
2394
- const where = args.where === 'local' ? 'local' : 'remote'
2395
- let archive = `${backupDir.replace(/\/+$/, '')}/${file}`
2396
- if (where === 'local') {
2397
- const localDir = localBackupDirFor(mirrorRootFor(b.host, b.username, b.port))
2398
- const up = await pushBackup(b.pool, {
2399
- localPath: path.join(localDir, file),
2400
- remoteDir: backupDir,
2401
- maxTransferBytes: Number(config.maxBackupTransferBytes) || 0,
2402
- })
2403
- if (!up.ok) throw new Error('rw_restore: could not upload the local copy — ' + up.error)
2404
- archive = up.remotePath
2405
- }
2406
- const r = await restoreBackup(b.pool, { archive, target, mode })
2407
- audit('restore', `${mode} restore ${file} → ${target}`, r.ok ? 0 : 1, b)
2408
- if (!r.ok) throw new Error('rw_restore: ' + (r.error || 'restore failed'))
2409
- return {
2410
- text: `${r.mode === 'merge' ? 'Merged into' : 'Replaced'} ${target} from ${file} (${r.members} file(s) now present; the archive was verified before anything was written)${where === 'local' ? ' [from the local copy, uploaded first]' : ''}.`,
2411
- }
2412
- },
2413
- }),
2414
2229
  ]
2415
2230
 
2416
2231
  for (const t of tools) {
@@ -2790,38 +2605,6 @@ export async function apply(ctx, config) {
2790
2605
  sendJson, readBody, resolveRequestBinding, decodeBuf, encodeText, audit, config, mirrorDirFor,
2791
2606
  }),
2792
2607
 
2793
- ...createBackupRoutes({
2794
- sendJson,
2795
- readBody,
2796
- config,
2797
- audit,
2798
- resolveRequestBinding,
2799
- // The settings panel may act on a machine that is not the current one; the
2800
- // id is resolved to a real pool so a backup never runs against whichever
2801
- // machine happened to be active.
2802
- resolveMachineBinding: async (machineId) => {
2803
- const m = machines.find((x) => x.id === machineId)
2804
- if (!m) {
2805
- const err = new Error(`机器不存在:${machineId}`)
2806
- err.httpStatus = 404
2807
- throw err
2808
- }
2809
- const target = poolForMachine(identityOf(m))
2810
- return {
2811
- pool: target,
2812
- ws: String(m.workspace || ''),
2813
- host: m.host,
2814
- username: m.username,
2815
- port: Number(m.port) || 22,
2816
- alias: aliasOf(m),
2817
- mirrorDir: m.workspace ? mirrorDirFor(m.workspace, m.host, m.username, m.port) : null,
2818
- bound: false,
2819
- local: false,
2820
- }
2821
- },
2822
- localRootFor: localBackupRootFor,
2823
- }),
2824
-
2825
2608
  {
2826
2609
  kind: 'exact',
2827
2610
  path: '/dsh-remote/workspace',