dsh-remote 0.8.40 → 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 +2 -6
- package/README.md +2 -6
- package/lib/client.js +0 -183
- package/lib/index.js +0 -274
- package/lib/paths.js +10 -2
- package/lib/web-attach.js +12 -0
- package/package.json +2 -2
- package/lib/archive.js +0 -1005
- package/lib/backup.js +0 -711
- package/lib/routes-backup.js +0 -283
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
|
-

|
|
43
43
|
|
|
44
44
|
The image above is the overview. What follows is only what the image does not make obvious.
|
|
45
45
|
|
|
@@ -56,8 +56,7 @@ 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
|
-
- **
|
|
60
|
-
- **Workspace backup / restore (two buttons, nothing to configure)** — the settings page offers just **Back up** and **Restore**: backing up archives **every remote workspace in the sidebar into ONE** `.tar.gz`, and restoring brings them all back from the point in time you pick. Dependency/build directories (`node_modules`, `.git`, `dist`, `build`, `target`, `__pycache__`, `.venv` … both top-level and nested) are excluded automatically. The model can do the same through `rw_backup` / `rw_backup_list` / `rw_restore`. **The archive is verified before anything is written — a corrupt or truncated archive is refused and nothing is touched**; each workspace's previous contents are moved aside first and restored automatically if the swap fails.
|
|
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
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.
|
|
@@ -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[] | `[]` | Extra paths excluded **on top of the built-in set** (gitignore-style). No longer surfaced in the UI; the built-in set covers `node_modules`/`.git`/`dist`/`build`/`target`/`__pycache__`/`.venv` and friends, top-level and nested |
|
|
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,14 +39,13 @@ DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0.
|
|
|
39
39
|
|
|
40
40
|
## 功能
|
|
41
41
|
|
|
42
|
-

|
|
43
43
|
|
|
44
44
|
上图是能力总览;下面只列**上图没说清、但用起来需要知道**的部分。
|
|
45
45
|
|
|
46
46
|
其余要点:
|
|
47
47
|
|
|
48
|
-
- **
|
|
49
|
-
- **工作区备份 / 恢复(一个按钮就够)** —— 设置页里只有「备份」和「恢复」两个按钮:**备份**把侧边栏里的所有远程工作区打包成**一个** `.tar.gz`,**恢复**用选中的那个时间点把全部工作区还原。依赖/构建目录(`node_modules`、`.git`、`dist`、`build`、`target`、`__pycache__`、`.venv`… 顶层与嵌套)自动排除,不需要你配置。模型侧仍可用 `rw_backup` / `rw_backup_list` / `rw_restore` 做同样的事。**恢复前先校验归档可读,归档损坏或截断就拒绝执行、目标一个字节都不动**;每个工作区的旧目录会先移到一旁,换入失败会自动还原。
|
|
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
50
|
- **连不上时一键体检与部署**(`0.8.36+`)—— **连不上大多不是插件的问题,而是远端 dsh 的版本或环境不对**。体检(只读)会报出平台/node/npm/dsh 版本、**原生模块能否启动**、代理与 npm 源,并给出修复建议;「部署并验证」把它装到远端**私有目录**(不写系统目录、不改 PATH、不覆盖你在用的版本,删目录即回滚)并逐步校验。按钮**不再需要先体检**——直接点即可(`0.8.40+`),部署过程自己会重新体检。失败还能交给内置的 `dsh-remote-deploy` 技能排查。
|
|
52
51
|
- **远端跨平台** —— 文件访问走 SFTP 协议层(不依赖 POSIX shell),Linux/macOS/Windows 远端都能列/读/写/搜索/同步。
|
|
@@ -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 风格)。设置页不再暴露此项;内置集已覆盖 `node_modules`/`.git`/`dist`/`build`/`target`/`__pycache__`/`.venv` 等(顶层与嵌套) |
|
|
251
|
-
| `maxBackupTransferBytes` | int | `2147483648` | 归档在本机与远端之间搬运的大小上限(字节);**远端的归档从不截断**,只约束可选的对拷。0 = 不限 |
|
|
252
248
|
|
|
253
249
|
> 权威清单是 `lib/index.js` 里的 `Config` schema,本表与之一致。
|
|
254
250
|
|
package/lib/client.js
CHANGED
|
@@ -187,33 +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': '工作区备份',
|
|
192
|
-
'settings.backupHint': '一键把侧边栏里的所有远程工作区打包成一个 .tar.gz;恢复时用同一个归档把全部工作区还原。依赖/构建目录(node_modules、.git、dist、build…)会自动排除。',
|
|
193
|
-
'settings.backupCreate': '备份',
|
|
194
|
-
'settings.backupCreating': '备份中…',
|
|
195
|
-
'settings.backupRestore': '恢复',
|
|
196
|
-
'settings.backupRestoring': '恢复中…',
|
|
197
|
-
'settings.backupPickOne': '— 选择要恢复的备份时间点 —',
|
|
198
|
-
'settings.backupCovers': '本次将备份 {n} 个工作区:',
|
|
199
|
-
'settings.backupDirLabel': '远端备份目录:{dir}',
|
|
200
|
-
'settings.backupNone': '还没有备份。点「备份」创建第一个。',
|
|
201
|
-
'settings.backupCount': '{n} 个备份',
|
|
202
|
-
'settings.backupEntries': '{n} 个条目',
|
|
203
|
-
'settings.backupDownload': '下载到本机',
|
|
204
|
-
'settings.backupDelete': '删除',
|
|
205
|
-
'settings.backupVerify': '校验',
|
|
206
|
-
'settings.backupRestoreConfirm': '要用 {file} 恢复全部工作区吗?归档里没有的文件会被删除(原目录会先移到一旁,换入失败会自动还原)。',
|
|
207
|
-
'settings.backupDeleteConfirm': '删除备份 {file}?此操作不可撤销。',
|
|
208
|
-
'settings.backupVerified': '校验通过:sha256 {sha}',
|
|
209
|
-
'settings.backupVerifyMismatch': '校验失败:哈希与记录不一致(归档可能已损坏)',
|
|
210
|
-
'settings.backupRestored': '已从 {file} 恢复:{target}',
|
|
211
|
-
'settings.backupCreatedN': '已备份 {n} 个工作区 → {file}',
|
|
212
|
-
'settings.backupDownloaded': '已下载到本机:{path}',
|
|
213
|
-
'settings.backupDeleted': '已删除 {file}',
|
|
214
|
-
'settings.backupFailed': '备份操作失败:{msg}',
|
|
215
|
-
'settings.backupMachine': '机器',
|
|
216
|
-
'settings.backupPickMachine': '— 选择机器 —',
|
|
217
190
|
'picker.title': '选择工作目录',
|
|
218
191
|
'picker.close': '关闭 ✕',
|
|
219
192
|
'picker.tabLocal': '本机',
|
|
@@ -472,33 +445,6 @@ window.__ModuleLoader__.load({
|
|
|
472
445
|
'settings.modeAuto': 'Auto',
|
|
473
446
|
'settings.modeOff': 'Off',
|
|
474
447
|
'settings.modeManual': 'Manual',
|
|
475
|
-
// ── workspace backup (rw_backup / rw_restore) ──────────────────────
|
|
476
|
-
'settings.backup': 'Workspace backup',
|
|
477
|
-
'settings.backupHint': 'One click archives every remote workspace in the sidebar into a single .tar.gz; restore brings them all back from that archive. Dependency/build directories (node_modules, .git, dist, build…) are excluded automatically.',
|
|
478
|
-
'settings.backupCreate': 'Back up',
|
|
479
|
-
'settings.backupCreating': 'Backing up…',
|
|
480
|
-
'settings.backupRestore': 'Restore',
|
|
481
|
-
'settings.backupRestoring': 'Restoring…',
|
|
482
|
-
'settings.backupPickOne': '— choose a backup point in time —',
|
|
483
|
-
'settings.backupCovers': 'This will back up {n} workspace(s):',
|
|
484
|
-
'settings.backupDirLabel': 'Remote backup directory: {dir}',
|
|
485
|
-
'settings.backupNone': 'No backups yet. Click "Back up" to create the first one.',
|
|
486
|
-
'settings.backupCount': '{n} backup(s)',
|
|
487
|
-
'settings.backupEntries': '{n} entries',
|
|
488
|
-
'settings.backupDownload': 'Download',
|
|
489
|
-
'settings.backupDelete': 'Delete',
|
|
490
|
-
'settings.backupVerify': 'Verify',
|
|
491
|
-
'settings.backupRestoreConfirm': 'Restore ALL workspaces from {file}? Files not present in the archive will be removed (each target is moved aside first, and restored automatically if the swap fails).',
|
|
492
|
-
'settings.backupDeleteConfirm': 'Delete the backup {file}? This cannot be undone.',
|
|
493
|
-
'settings.backupVerified': 'Verified: sha256 {sha}',
|
|
494
|
-
'settings.backupVerifyMismatch': 'Verification FAILED: the digest does not match the recorded one (the archive may be corrupt)',
|
|
495
|
-
'settings.backupRestored': 'Restored from {file}: {target}',
|
|
496
|
-
'settings.backupCreatedN': 'Backed up {n} workspace(s) → {file}',
|
|
497
|
-
'settings.backupDownloaded': 'Downloaded locally: {path}',
|
|
498
|
-
'settings.backupDeleted': 'Deleted {file}',
|
|
499
|
-
'settings.backupFailed': 'Backup action failed: {msg}',
|
|
500
|
-
'settings.backupMachine': 'Machine',
|
|
501
|
-
'settings.backupPickMachine': '— choose a machine —',
|
|
502
448
|
'picker.title': 'Select working directory',
|
|
503
449
|
'picker.close': 'Close ✕',
|
|
504
450
|
'picker.tabLocal': 'Local',
|
|
@@ -914,19 +860,7 @@ window.__ModuleLoader__.load({
|
|
|
914
860
|
const [deployBusy, setDeployBusy] = React.useState('')
|
|
915
861
|
const [deployErr, setDeployErr] = React.useState('')
|
|
916
862
|
const [deployOpen, setDeployOpen] = React.useState(false)
|
|
917
|
-
// ── workspace backup (rw_backup / rw_restore) ──────────────────────────
|
|
918
|
-
// Machine-scoped like the deploy card: `backupMachine` empty means "this
|
|
919
863
|
// session's machine", which the host resolves from the session binding.
|
|
920
|
-
const [backupOpen, setBackupOpen] = React.useState(false)
|
|
921
|
-
const [backupData, setBackupData] = React.useState(null)
|
|
922
|
-
const [backupBusy, setBackupBusy] = React.useState('')
|
|
923
|
-
const [backupMsg, setBackupMsg] = React.useState('')
|
|
924
|
-
const [backupErr, setBackupErr] = React.useState('')
|
|
925
|
-
// `backupPick` is the archive the Restore/Verify/Delete buttons act on. A
|
|
926
|
-
// timestamp picker replaced the old per-row buttons: restoring is the rare
|
|
927
|
-
// action, and one selector is all it needs.
|
|
928
|
-
const [backupPick, setBackupPick] = React.useState('')
|
|
929
|
-
const [backupMachine, setBackupMachine] = React.useState('')
|
|
930
864
|
React.useEffect(() => { ensureChromeCss() }, [])
|
|
931
865
|
|
|
932
866
|
const testConn = () => {
|
|
@@ -1016,49 +950,6 @@ window.__ModuleLoader__.load({
|
|
|
1016
950
|
const refreshForwards = () => api('GET', '/dsh-remote/forwards').then((r) => setForwards(r.forwards || [])).catch(() => {})
|
|
1017
951
|
React.useEffect(() => { refreshForwards(); api('GET', '/dsh-remote/audit?limit=30').then((r) => setAuditLines(r.lines || [])).catch(() => {}) }, [])
|
|
1018
952
|
|
|
1019
|
-
// ── 工作区备份 ────────────────────────────────────────────────────────
|
|
1020
|
-
// Reads/writes go through /dsh-remote/backup. The list carries BOTH the
|
|
1021
|
-
// remote archives and any pulled locally, so one refresh answers the panel.
|
|
1022
|
-
const backupQuery = (extra) => {
|
|
1023
|
-
const q = backupMachine ? 'machineId=' + encodeURIComponent(backupMachine) : ''
|
|
1024
|
-
return q ? (extra ? q + '&' + extra : q) : (extra || '')
|
|
1025
|
-
}
|
|
1026
|
-
const refreshBackups = () => api('GET', '/dsh-remote/backup' + (backupQuery('') ? '?' + backupQuery('') : ''))
|
|
1027
|
-
.then((r) => { setBackupData(r); setBackupErr('') })
|
|
1028
|
-
.catch((e) => setBackupErr(String((e && e.message) || e)))
|
|
1029
|
-
React.useEffect(() => { refreshBackups() }, [backupMachine])
|
|
1030
|
-
const backupAction = (action, body) => {
|
|
1031
|
-
setBackupBusy(action); setBackupMsg(''); setBackupErr('')
|
|
1032
|
-
return api('POST', '/dsh-remote/backup', Object.assign({ action }, backupMachine ? { machineId: backupMachine } : {}, body || {}))
|
|
1033
|
-
.then((r) => {
|
|
1034
|
-
if (r) {
|
|
1035
|
-
setBackupData(r)
|
|
1036
|
-
// Keep the picker pointing at something real after a list refresh
|
|
1037
|
-
// (a delete/restore changes the set): prefer the current choice,
|
|
1038
|
-
// else the newest archive.
|
|
1039
|
-
const names = (r.entries || []).map((e) => e.name)
|
|
1040
|
-
setBackupPick((cur) => (cur && names.includes(cur) ? cur : (names[0] || '')))
|
|
1041
|
-
}
|
|
1042
|
-
if (action === 'create' && r && r.created) {
|
|
1043
|
-
setBackupMsg(tr('settings.backupCreatedN', { n: (r.created.workspaces || []).length, file: r.created.name }))
|
|
1044
|
-
} else if (action === 'restore' && r && r.restored) {
|
|
1045
|
-
setBackupMsg(tr('settings.backupRestored', { file: r.restored.file, target: (r.restored.targets || []).join('、') })
|
|
1046
|
-
+ (r.warning ? ' — ' + r.warning : ''))
|
|
1047
|
-
} else if (action === 'delete') {
|
|
1048
|
-
setBackupMsg(tr('settings.backupDeleted', { file: body.file }))
|
|
1049
|
-
} else if (action === 'download' && r && (r.localPath || (r.restored && r.restored.file))) {
|
|
1050
|
-
setBackupMsg(tr('settings.backupDownloaded', { path: r.localPath || body.file }))
|
|
1051
|
-
} else if (action === 'verify' && r) {
|
|
1052
|
-
setBackupMsg(r.matches === false || r.verified === false
|
|
1053
|
-
? tr('settings.backupVerifyMismatch')
|
|
1054
|
-
: tr('settings.backupVerified', { sha: String(r.sha256 || '').slice(0, 16) }))
|
|
1055
|
-
}
|
|
1056
|
-
return r
|
|
1057
|
-
})
|
|
1058
|
-
.catch((e) => { setBackupErr(tr('settings.backupFailed', { msg: String((e && e.message) || e) })); return null })
|
|
1059
|
-
.then((r) => { setBackupBusy(''); return r })
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
953
|
// ── 远程 DSH 界面(issue #46)─────────────────────────────────────────
|
|
1063
954
|
// The remote never opens an inbound port; we SSH out and carry the remote
|
|
1064
955
|
// loopback web UI back to a local port. One tunnel per machine.
|
|
@@ -1444,80 +1335,6 @@ window.__ModuleLoader__.load({
|
|
|
1444
1335
|
deployErr ? React.createElement('div', { style: { color: T.danger, fontSize: 12, marginTop: 6, wordBreak: 'break-all' } }, deployErr) : null,
|
|
1445
1336
|
) : React.createElement('div', { style: { fontSize: 11, opacity: 0.55 } }, deploy && deploy.verdict ? (deploy.verdict.ok ? tr('settings.deployOk') : tr('settings.deployBlocked')) : ''),
|
|
1446
1337
|
),
|
|
1447
|
-
// 工作区备份(rw_backup / rw_backup_list / rw_restore)
|
|
1448
|
-
//
|
|
1449
|
-
// TWO buttons and a timestamp picker on purpose. The first version exposed
|
|
1450
|
-
// excludes / destination / per-archive verify-download-upload-delete, which
|
|
1451
|
-
// turned "keep a copy of my workspaces" into a configuration exercise.
|
|
1452
|
-
// Everything that needed deciding now lives in code (the exclude set) or
|
|
1453
|
-
// is derived (which workspaces = whatever the sidebar shows).
|
|
1454
|
-
React.createElement('div', { style: boxS },
|
|
1455
|
-
sectionToggle(backupOpen, setBackupOpen, tr('settings.backup'),
|
|
1456
|
-
backupData ? React.createElement('span', { style: { marginLeft: 'auto', fontSize: 11, opacity: 0.6 } },
|
|
1457
|
-
tr('settings.backupCount', { n: (backupData.entries || []).length })) : null),
|
|
1458
|
-
backupOpen ? React.createElement(React.Fragment, null,
|
|
1459
|
-
React.createElement('div', { style: { opacity: 0.65, fontSize: 11, lineHeight: 1.5, marginBottom: 6 } }, tr('settings.backupHint')),
|
|
1460
|
-
// Say exactly what will be included, before the click.
|
|
1461
|
-
backupData && (backupData.covers || []).length
|
|
1462
|
-
? React.createElement('div', { style: { fontSize: 11, opacity: 0.8, marginBottom: 6, wordBreak: 'break-all' } },
|
|
1463
|
-
tr('settings.backupCovers', { n: (backupData.covers || []).length }) + ' ' + (backupData.covers || []).join('、'))
|
|
1464
|
-
: null,
|
|
1465
|
-
backupData && backupData.backupDir
|
|
1466
|
-
? React.createElement('div', { style: { fontSize: 11, opacity: 0.6, marginBottom: 8, wordBreak: 'break-all' } },
|
|
1467
|
-
tr('settings.backupDirLabel', { dir: backupData.backupDir }))
|
|
1468
|
-
: null,
|
|
1469
|
-
// Machine selector: empty means "this session's machine". Kept because
|
|
1470
|
-
// the panel is reachable from a local session, where there is no
|
|
1471
|
-
// session binding to resolve.
|
|
1472
|
-
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', marginBottom: 8, flexWrap: 'wrap' } },
|
|
1473
|
-
React.createElement('span', { style: { fontSize: 12, opacity: 0.8 } }, tr('settings.backupMachine') + ':'),
|
|
1474
|
-
React.createElement('select', { className: 'dsh-rw-input', value: backupMachine, onChange: (e) => setBackupMachine(e.target.value), style: { ...inputS, maxWidth: 220 } },
|
|
1475
|
-
React.createElement('option', { value: '' }, tr('settings.backupPickMachine')),
|
|
1476
|
-
machines.map((m) => React.createElement('option', { key: m.id, value: m.id }, `${m.name || m.host} (${m.username}@${m.host})`)))),
|
|
1477
|
-
// ── the two buttons ───────────────────────────────────────────────
|
|
1478
|
-
React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
|
|
1479
|
-
React.createElement('button', {
|
|
1480
|
-
type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: { ...primaryBtnS },
|
|
1481
|
-
disabled: !!backupBusy,
|
|
1482
|
-
onClick: () => backupAction('create'),
|
|
1483
|
-
}, backupBusy === 'create' ? tr('settings.backupCreating') : tr('settings.backupCreate')),
|
|
1484
|
-
React.createElement('select', {
|
|
1485
|
-
className: 'dsh-rw-input',
|
|
1486
|
-
value: backupPick,
|
|
1487
|
-
onChange: (e) => setBackupPick(e.target.value),
|
|
1488
|
-
style: { ...inputS, flex: '1 1 260px', minWidth: 200, maxWidth: 380 },
|
|
1489
|
-
},
|
|
1490
|
-
React.createElement('option', { value: '' }, tr('settings.backupPickOne')),
|
|
1491
|
-
(backupData && backupData.entries ? backupData.entries : []).map((e, i) => React.createElement('option', { key: e.name, value: e.name },
|
|
1492
|
-
`${e.createdAt ? e.createdAt.replace('T', ' ').slice(0, 19) : e.mtime || ''} · ${humanSize(e.bytes)} · ${tr('settings.backupEntries', { n: e.members == null ? '?' : e.members })} · ${e.name}`))),
|
|
1493
|
-
React.createElement('button', {
|
|
1494
|
-
type: 'button', className: 'dsh-rw-btn',
|
|
1495
|
-
style: { ...buttonS },
|
|
1496
|
-
disabled: !!backupBusy || !backupPick,
|
|
1497
|
-
onClick: () => {
|
|
1498
|
-
if (!window.confirm(tr('settings.backupRestoreConfirm', { file: backupPick }))) return
|
|
1499
|
-
backupAction('restore', { file: backupPick, confirm: true })
|
|
1500
|
-
},
|
|
1501
|
-
}, backupBusy === 'restore' ? tr('settings.backupRestoring') : tr('settings.backupRestore'))),
|
|
1502
|
-
// Housekeeping stays available but out of the way: verify and delete
|
|
1503
|
-
// whatever the picker has selected.
|
|
1504
|
-
backupPick
|
|
1505
|
-
? React.createElement('div', { style: { marginTop: 6, display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
|
|
1506
|
-
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px' }, disabled: !!backupBusy,
|
|
1507
|
-
onClick: () => backupAction('verify', { file: backupPick }) }, tr('settings.backupVerify')),
|
|
1508
|
-
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px' }, disabled: !!backupBusy,
|
|
1509
|
-
onClick: () => backupAction('download', { file: backupPick }) }, tr('settings.backupDownload')),
|
|
1510
|
-
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px', color: T.dangerText }, disabled: !!backupBusy,
|
|
1511
|
-
onClick: () => { if (window.confirm(tr('settings.backupDeleteConfirm', { file: backupPick }))) backupAction('delete', { file: backupPick }) } }, tr('settings.backupDelete')))
|
|
1512
|
-
: null,
|
|
1513
|
-
backupMsg ? React.createElement('div', { style: { marginTop: 8, fontSize: 12, color: T.ok, wordBreak: 'break-all' } }, backupMsg) : null,
|
|
1514
|
-
backupErr ? React.createElement('div', { style: { marginTop: 8, fontSize: 12, color: T.danger, wordBreak: 'break-all' } }, backupErr) : null,
|
|
1515
|
-
!backupData || !(backupData.entries || []).length
|
|
1516
|
-
? React.createElement('div', { style: { marginTop: 8, opacity: 0.6, fontSize: 12 } }, tr('settings.backupNone'))
|
|
1517
|
-
: null,
|
|
1518
|
-
) : backupData ? React.createElement('div', { style: { fontSize: 11, opacity: 0.55 } },
|
|
1519
|
-
tr('settings.backupCount', { n: (backupData.entries || []).length })) : null,
|
|
1520
|
-
),
|
|
1521
1338
|
// 端口转发面板
|
|
1522
1339
|
React.createElement('div', { style: boxS },
|
|
1523
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,74 +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
|
-
/**
|
|
1315
|
-
* Every REMOTE workspace the sidebar knows about for one machine.
|
|
1316
|
-
*
|
|
1317
|
-
* "Back up all my workspaces" has to mean what the user sees in the sidebar, so
|
|
1318
|
-
* this walks the mirror registry rather than the machine's recent-workspaces
|
|
1319
|
-
* list: each mirror dir carries `.dsh-remote-meta.json` with the remote origin,
|
|
1320
|
-
* which is authoritative and survives the sidebar's own storage being reset.
|
|
1321
|
-
* The machine's remembered workspaces are unioned in as a fallback for
|
|
1322
|
-
* workspaces that have never been mirrored on this machine.
|
|
1323
|
-
*
|
|
1324
|
-
* @param {{host: string, username: string, port: number}} machine
|
|
1325
|
-
* @returns {string[]} absolute REMOTE directory paths, de-duplicated.
|
|
1326
|
-
*/
|
|
1327
|
-
const remoteWorkspacesFor = (machine) => {
|
|
1328
|
-
const out = new Set()
|
|
1329
|
-
const host = String((machine && machine.host) || '')
|
|
1330
|
-
const username = String((machine && machine.username) || '')
|
|
1331
|
-
const port = Number((machine && machine.port) || 22)
|
|
1332
|
-
if (!host) return []
|
|
1333
|
-
// 1) Mirror dirs for THIS machine (the sidebar's remote entries).
|
|
1334
|
-
try {
|
|
1335
|
-
const hostDir = mirrorRootFor(host, username, port)
|
|
1336
|
-
if (existsSync(hostDir)) {
|
|
1337
|
-
for (const entry of readdirSync(hostDir)) {
|
|
1338
|
-
const metaPath = path.join(hostDir, entry, '.dsh-remote-meta.json')
|
|
1339
|
-
if (!existsSync(metaPath)) continue
|
|
1340
|
-
try {
|
|
1341
|
-
const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
|
|
1342
|
-
if (!meta || !meta.remotePath) continue
|
|
1343
|
-
// Only mirrors that unambiguously belong to this machine.
|
|
1344
|
-
if (String(meta.host || '') !== host) continue
|
|
1345
|
-
if (Number(meta.port || 22) !== port) continue
|
|
1346
|
-
if (username && String(meta.username || '') !== username) continue
|
|
1347
|
-
out.add(normalizeRemotePath(meta.remotePath))
|
|
1348
|
-
} catch { /* unreadable meta → not a usable workspace */ }
|
|
1349
|
-
}
|
|
1350
|
-
}
|
|
1351
|
-
} catch { /* registry unreadable */ }
|
|
1352
|
-
// 2) Workspaces the registry remembers for this machine, even if unmirrored.
|
|
1353
|
-
const rec = machines.find((m) => m.host === host
|
|
1354
|
-
&& Number(m.port || 22) === port
|
|
1355
|
-
&& (!username || m.username === username))
|
|
1356
|
-
const remembered = [rec && rec.workspace, ...((rec && rec.recentWorkspaces) || [])]
|
|
1357
|
-
for (const p of remembered) {
|
|
1358
|
-
const norm = p ? normalizeRemotePath(String(p)) : ''
|
|
1359
|
-
if (norm && norm !== '/') out.add(norm)
|
|
1360
|
-
}
|
|
1361
|
-
// 3) The mirror the calling session is bound to, if any (a session can be
|
|
1362
|
-
// bound to a workspace the registry has not recorded yet).
|
|
1363
|
-
const bound = wsPath()
|
|
1364
|
-
if (bound) out.add(normalizeRemotePath(bound))
|
|
1365
|
-
return [...out]
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
1281
|
// ── tools ─────────────────────────────────────────────────────────────────
|
|
1369
1282
|
|
|
1370
1283
|
const textOut = {
|
|
@@ -2313,158 +2226,6 @@ export async function apply(ctx, config) {
|
|
|
2313
2226
|
},
|
|
2314
2227
|
}),
|
|
2315
2228
|
|
|
2316
|
-
defineTool({
|
|
2317
|
-
name: 'rw_backup',
|
|
2318
|
-
description:
|
|
2319
|
-
'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.',
|
|
2320
|
-
parameters: {
|
|
2321
|
-
path: { type: 'string', description: 'Directory to back up (default and normally: the current remote workspace). Must be the workspace or a subdirectory of it.' },
|
|
2322
|
-
label: { type: 'string', description: 'Optional tag embedded in the archive filename (e.g. "before-refactor").' },
|
|
2323
|
-
excludes: { type: 'string', description: 'Comma- or newline-separated extra exclude patterns (gitignore-ish: node_modules, /build/, *.log). Relative paths inside the workspace.' },
|
|
2324
|
-
dest: { type: 'string', description: 'Where the archive ends up: remote (default), local (pulled here; the remote staging copy is removed), or both.' },
|
|
2325
|
-
},
|
|
2326
|
-
output: textOut,
|
|
2327
|
-
async execute(args, exec) {
|
|
2328
|
-
const b = requireBinding(exec, 'rw_backup')
|
|
2329
|
-
const dir = resolveRemoteArg(b, args.path || '')
|
|
2330
|
-
if (!dir || dir === '/') throw new Error('rw_backup: a directory to back up is required')
|
|
2331
|
-
const root = normalizeRemotePath(b.ws || '')
|
|
2332
|
-
if (root && dir !== root && !dir.startsWith(root.replace(/\/+$/, '') + '/')) {
|
|
2333
|
-
throw new Error(`rw_backup: ${dir} is outside this session's remote workspace (${root}) — backups are limited to the workspace tree`)
|
|
2334
|
-
}
|
|
2335
|
-
// Config defaults first, then this call's own patterns, so a caller can
|
|
2336
|
-
// extend the profile's excludes without restating them.
|
|
2337
|
-
const parsed = parseExcludes([
|
|
2338
|
-
...(Array.isArray(config.backupExcludes) ? config.backupExcludes : []),
|
|
2339
|
-
...String(args.excludes || '').split(/[\r\n,]+/),
|
|
2340
|
-
])
|
|
2341
|
-
const backupDir = await backupDirForBinding(b)
|
|
2342
|
-
if (!backupDir) throw new Error('rw_backup: cannot determine the remote $HOME — set the backupDir config option')
|
|
2343
|
-
const dest = ['remote', 'local', 'both'].includes(String(args.dest)) ? String(args.dest) : 'remote'
|
|
2344
|
-
const result = await createBackup(b.pool, {
|
|
2345
|
-
dir,
|
|
2346
|
-
backupDir,
|
|
2347
|
-
label: args.label ? String(args.label) : '',
|
|
2348
|
-
excludes: parsed.patterns,
|
|
2349
|
-
meta: { host: b.host, username: b.username, port: b.port, label: args.label ? String(args.label) : '' },
|
|
2350
|
-
})
|
|
2351
|
-
if (!result.ok) {
|
|
2352
|
-
audit('backup', `backup ${dir} failed: ${result.error}`, 1, b)
|
|
2353
|
-
throw new Error('rw_backup: ' + result.error)
|
|
2354
|
-
}
|
|
2355
|
-
audit('backup', `backup ${dir} → ${result.archive}`, 0, b)
|
|
2356
|
-
const lines = [describeCreate(result, { dest })]
|
|
2357
|
-
lines.push(`remote archive: ${result.archive}`)
|
|
2358
|
-
if (result.meta && result.meta.excludes && result.meta.excludes.length) lines.push(`excludes: ${result.meta.excludes.join(', ')}`)
|
|
2359
|
-
if (parsed.dropped.length) lines.push(`ignored (no usable pattern): ${parsed.dropped.join(', ')}`)
|
|
2360
|
-
if (dest === 'local' || dest === 'both') {
|
|
2361
|
-
const pulled = await pullBackup(b.pool, {
|
|
2362
|
-
archive: result.archive,
|
|
2363
|
-
localDir: localBackupDirFor(mirrorRootFor(b.host, b.username, b.port)),
|
|
2364
|
-
maxTransferBytes: Number(config.maxBackupTransferBytes) || 0,
|
|
2365
|
-
})
|
|
2366
|
-
if (pulled.ok) {
|
|
2367
|
-
lines.push(`local copy: ${pulled.localPath}`)
|
|
2368
|
-
if (dest === 'local') {
|
|
2369
|
-
const del = await deleteBackup(b.pool, { archive: result.archive })
|
|
2370
|
-
lines.push(del.ok ? 'remote staging copy removed (dest=local)' : `could NOT remove the remote copy: ${del.error || 'unknown error'}`)
|
|
2371
|
-
}
|
|
2372
|
-
} else {
|
|
2373
|
-
lines.push(`local copy FAILED: ${pulled.error}`)
|
|
2374
|
-
}
|
|
2375
|
-
}
|
|
2376
|
-
return { text: lines.join('\n') }
|
|
2377
|
-
},
|
|
2378
|
-
}),
|
|
2379
|
-
|
|
2380
|
-
defineTool({
|
|
2381
|
-
name: 'rw_backup_list',
|
|
2382
|
-
description:
|
|
2383
|
-
"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.",
|
|
2384
|
-
parameters: {
|
|
2385
|
-
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." },
|
|
2386
|
-
},
|
|
2387
|
-
output: textOut,
|
|
2388
|
-
async execute(args, exec) {
|
|
2389
|
-
const b = requireBinding(exec, 'rw_backup_list')
|
|
2390
|
-
const backupDir = await backupDirForBinding(b)
|
|
2391
|
-
const remote = backupDir
|
|
2392
|
-
? await listBackups(b.pool, { dir: backupDir })
|
|
2393
|
-
: { exists: false, dir: '', entries: [], error: '' }
|
|
2394
|
-
const localDir = localBackupDirFor(mirrorRootFor(b.host, b.username, b.port))
|
|
2395
|
-
const local = listLocalBackups(localDir)
|
|
2396
|
-
const fmt = (e) => {
|
|
2397
|
-
const bits = [e.name, formatBytes(e.bytes)]
|
|
2398
|
-
if (e.members != null) bits.push(`${e.members} entries`)
|
|
2399
|
-
if (e.createdAt || e.mtime) bits.push(e.createdAt || e.mtime)
|
|
2400
|
-
if (!e.hasMeta) bits.push('NO SIDECAR (metadata unknown)')
|
|
2401
|
-
return ' ' + bits.join(' | ')
|
|
2402
|
-
}
|
|
2403
|
-
const lines = []
|
|
2404
|
-
if (!backupDir) {
|
|
2405
|
-
lines.push('(cannot resolve the remote backup directory — set the backupDir config option)')
|
|
2406
|
-
} else if (remote.error) {
|
|
2407
|
-
lines.push(`remote listing failed: ${remote.error}`)
|
|
2408
|
-
} else if (!remote.entries.length) {
|
|
2409
|
-
lines.push(`no backups yet in ${backupDir} (create one with rw_backup)`)
|
|
2410
|
-
} else {
|
|
2411
|
-
lines.push(`${remote.entries.length} remote backup(s) in ${backupDir}:`)
|
|
2412
|
-
lines.push(...remote.entries.map(fmt))
|
|
2413
|
-
}
|
|
2414
|
-
if (local.entries.length) {
|
|
2415
|
-
lines.push('', `${local.entries.length} local copy(ies) in ${localDir}:`)
|
|
2416
|
-
lines.push(...local.entries.map(fmt))
|
|
2417
|
-
}
|
|
2418
|
-
lines.push('', 'restore one with rw_restore(file=<name>).')
|
|
2419
|
-
return { text: lines.join('\n') }
|
|
2420
|
-
},
|
|
2421
|
-
}),
|
|
2422
|
-
|
|
2423
|
-
defineTool({
|
|
2424
|
-
name: 'rw_restore',
|
|
2425
|
-
description:
|
|
2426
|
-
'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.',
|
|
2427
|
-
parameters: {
|
|
2428
|
-
file: { type: 'string', required: true, description: 'Backup archive filename from rw_backup_list (e.g. proj-abc-2026-10-08T11-22-33.tar.gz).' },
|
|
2429
|
-
target: { type: 'string', description: 'Directory to restore into (default: the current remote workspace). Usually left unset.' },
|
|
2430
|
-
mode: { type: 'string', description: 'replace (default) or merge.' },
|
|
2431
|
-
where: { type: 'string', description: 'Which copy to restore from: remote (default) or local (a pulled copy; it is uploaded back first).' },
|
|
2432
|
-
},
|
|
2433
|
-
output: textOut,
|
|
2434
|
-
async execute(args, exec) {
|
|
2435
|
-
const b = requireBinding(exec, 'rw_restore')
|
|
2436
|
-
const file = String(args.file || '').trim()
|
|
2437
|
-
if (!file) throw new Error('rw_restore: file is required (list them with rw_backup_list)')
|
|
2438
|
-
if (!isSafeArchiveName(file)) throw new Error(`rw_restore: ${file} is not a valid backup filename (expected a plain *.tar.gz basename)`)
|
|
2439
|
-
const target = args.target ? resolveRemoteArg(b, args.target) : (b.ws || '')
|
|
2440
|
-
if (!target || target === '/') throw new Error('rw_restore: no restore target — pick a remote workspace first')
|
|
2441
|
-
const root = normalizeRemotePath(b.ws || '')
|
|
2442
|
-
if (root && target !== root && !target.startsWith(root.replace(/\/+$/, '') + '/')) {
|
|
2443
|
-
throw new Error(`rw_restore: refusing to restore into ${target} — it is outside this session's remote workspace (${root})`)
|
|
2444
|
-
}
|
|
2445
|
-
const mode = args.mode === 'merge' ? 'merge' : 'replace'
|
|
2446
|
-
const backupDir = await backupDirForBinding(b)
|
|
2447
|
-
if (!backupDir) throw new Error('rw_restore: cannot determine the remote backup directory — set the backupDir config option')
|
|
2448
|
-
const where = args.where === 'local' ? 'local' : 'remote'
|
|
2449
|
-
let archive = `${backupDir.replace(/\/+$/, '')}/${file}`
|
|
2450
|
-
if (where === 'local') {
|
|
2451
|
-
const localDir = localBackupDirFor(mirrorRootFor(b.host, b.username, b.port))
|
|
2452
|
-
const up = await pushBackup(b.pool, {
|
|
2453
|
-
localPath: path.join(localDir, file),
|
|
2454
|
-
remoteDir: backupDir,
|
|
2455
|
-
maxTransferBytes: Number(config.maxBackupTransferBytes) || 0,
|
|
2456
|
-
})
|
|
2457
|
-
if (!up.ok) throw new Error('rw_restore: could not upload the local copy — ' + up.error)
|
|
2458
|
-
archive = up.remotePath
|
|
2459
|
-
}
|
|
2460
|
-
const r = await restoreBackup(b.pool, { archive, target, mode })
|
|
2461
|
-
audit('restore', `${mode} restore ${file} → ${target}`, r.ok ? 0 : 1, b)
|
|
2462
|
-
if (!r.ok) throw new Error('rw_restore: ' + (r.error || 'restore failed'))
|
|
2463
|
-
return {
|
|
2464
|
-
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]' : ''}.`,
|
|
2465
|
-
}
|
|
2466
|
-
},
|
|
2467
|
-
}),
|
|
2468
2229
|
]
|
|
2469
2230
|
|
|
2470
2231
|
for (const t of tools) {
|
|
@@ -2844,41 +2605,6 @@ export async function apply(ctx, config) {
|
|
|
2844
2605
|
sendJson, readBody, resolveRequestBinding, decodeBuf, encodeText, audit, config, mirrorDirFor,
|
|
2845
2606
|
}),
|
|
2846
2607
|
|
|
2847
|
-
...createBackupRoutes({
|
|
2848
|
-
sendJson,
|
|
2849
|
-
readBody,
|
|
2850
|
-
config,
|
|
2851
|
-
audit,
|
|
2852
|
-
resolveRequestBinding,
|
|
2853
|
-
// The settings panel may act on a machine that is not the current one; the
|
|
2854
|
-
// id is resolved to a real pool so a backup never runs against whichever
|
|
2855
|
-
// machine happened to be active.
|
|
2856
|
-
resolveMachineBinding: async (machineId) => {
|
|
2857
|
-
const m = machines.find((x) => x.id === machineId)
|
|
2858
|
-
if (!m) {
|
|
2859
|
-
const err = new Error(`机器不存在:${machineId}`)
|
|
2860
|
-
err.httpStatus = 404
|
|
2861
|
-
throw err
|
|
2862
|
-
}
|
|
2863
|
-
const target = poolForMachine(identityOf(m))
|
|
2864
|
-
return {
|
|
2865
|
-
pool: target,
|
|
2866
|
-
ws: String(m.workspace || ''),
|
|
2867
|
-
host: m.host,
|
|
2868
|
-
username: m.username,
|
|
2869
|
-
port: Number(m.port) || 22,
|
|
2870
|
-
alias: aliasOf(m),
|
|
2871
|
-
mirrorDir: m.workspace ? mirrorDirFor(m.workspace, m.host, m.username, m.port) : null,
|
|
2872
|
-
bound: false,
|
|
2873
|
-
local: false,
|
|
2874
|
-
}
|
|
2875
|
-
},
|
|
2876
|
-
localRootFor: localBackupRootFor,
|
|
2877
|
-
// "Back up all my workspaces" means what the SIDEBAR shows, so the route
|
|
2878
|
-
// asks the same collection the panel displays.
|
|
2879
|
-
workspacesFor: (b) => remoteWorkspacesFor({ host: b.host, username: b.username, port: b.port }),
|
|
2880
|
-
}),
|
|
2881
|
-
|
|
2882
2608
|
{
|
|
2883
2609
|
kind: 'exact',
|
|
2884
2610
|
path: '/dsh-remote/workspace',
|
package/lib/paths.js
CHANGED
|
@@ -203,8 +203,16 @@ export function relPathUnder(root, p) {
|
|
|
203
203
|
}
|
|
204
204
|
|
|
205
205
|
export function truncate(s, max) {
|
|
206
|
-
|
|
207
|
-
|
|
206
|
+
// A missing/NaN cap is a CONFIG BUG (measured: a bare `new SshPool({...})`
|
|
207
|
+
// with no maxOutputChars turned EVERY exec result into a 29-char truncation
|
|
208
|
+
// marker — `'x'.length <= undefined` is false, so the "cap" branch always ran
|
|
209
|
+
// and `s.length - undefined` printed NaN — silently destroying every remote
|
|
210
|
+
// output, including the web-attach startup token). A bad cap must disable
|
|
211
|
+
// truncation, not destroy the string.
|
|
212
|
+
const limit = Number(max)
|
|
213
|
+
if (!Number.isFinite(limit) || limit <= 0) return s
|
|
214
|
+
if (s.length <= limit) return s
|
|
215
|
+
return s.slice(0, limit) + `\n…[truncated: ${s.length - limit} more chars]`
|
|
208
216
|
}
|
|
209
217
|
|
|
210
218
|
/** 32-bit string hash rendered in base36 (short, collision-safe enough). */
|