dsh-remote 0.8.38 → 0.8.40
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 +3 -3
- package/README.md +3 -3
- package/lib/archive.js +325 -0
- package/lib/backup.js +192 -0
- package/lib/client.js +116 -148
- package/lib/index.js +57 -0
- package/lib/routes-backup.js +48 -78
- package/lib/update.js +9 -0
- package/package.json +2 -2
package/README.en.md
CHANGED
|
@@ -57,9 +57,9 @@ The image above is the overview. What follows is only what the image does not ma
|
|
|
57
57
|
The rest:
|
|
58
58
|
|
|
59
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
|
-
- **
|
|
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.
|
|
61
61
|
- **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.
|
|
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. 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
63
|
- **Cross-platform remotes** — all file access is SFTP-protocol-level (no POSIX shell), so Linux/macOS/Windows remotes all work.
|
|
64
64
|
- **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
65
|
- **Async long tasks** — `rw_sync`/`rw_push` with `async: true` return a `taskId` with progress/result/cancel.
|
|
@@ -265,7 +265,7 @@ Deploying to a product profile is a separate, explicit action (`./sync.sh`) for
|
|
|
265
265
|
| `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
266
|
| `webInstallRegistry` | string | `''` | npm registry for the install; **empty keeps the remote's own config**, so an internal mirror is never overridden |
|
|
267
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[] | `[]` |
|
|
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
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
270
|
|
|
271
271
|
> The authoritative list is the `Config` schema in `lib/index.js`; this table mirrors it.
|
package/README.md
CHANGED
|
@@ -46,9 +46,9 @@ DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0.
|
|
|
46
46
|
其余要点:
|
|
47
47
|
|
|
48
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
|
-
-
|
|
49
|
+
- **工作区备份 / 恢复(一个按钮就够)** —— 设置页里只有「备份」和「恢复」两个按钮:**备份**把侧边栏里的所有远程工作区打包成**一个** `.tar.gz`,**恢复**用选中的那个时间点把全部工作区还原。依赖/构建目录(`node_modules`、`.git`、`dist`、`build`、`target`、`__pycache__`、`.venv`… 顶层与嵌套)自动排除,不需要你配置。模型侧仍可用 `rw_backup` / `rw_backup_list` / `rw_restore` 做同样的事。**恢复前先校验归档可读,归档损坏或截断就拒绝执行、目标一个字节都不动**;每个工作区的旧目录会先移到一旁,换入失败会自动还原。
|
|
50
50
|
- **把远端机器的 DSH 界面挂到本机**(`0.8.36+`)—— 不用在远端开任何端口:插件主动 SSH 连过去,在远端**只监听 `127.0.0.1`** 地起一个 `dsh web`,再把端口经隧道搬回本机的一个 loopback 端口。地址带一次性登录令牌,只在本机这次跳转里用一次。
|
|
51
|
-
- **连不上时一键体检与部署**(`0.8.36+`)—— **连不上大多不是插件的问题,而是远端 dsh 的版本或环境不对**。体检(只读)会报出平台/node/npm/dsh 版本、**原生模块能否启动**、代理与 npm 源,并给出修复建议;「部署并验证」把它装到远端**私有目录**(不写系统目录、不改 PATH
|
|
51
|
+
- **连不上时一键体检与部署**(`0.8.36+`)—— **连不上大多不是插件的问题,而是远端 dsh 的版本或环境不对**。体检(只读)会报出平台/node/npm/dsh 版本、**原生模块能否启动**、代理与 npm 源,并给出修复建议;「部署并验证」把它装到远端**私有目录**(不写系统目录、不改 PATH、不覆盖你在用的版本,删目录即回滚)并逐步校验。按钮**不再需要先体检**——直接点即可(`0.8.40+`),部署过程自己会重新体检。失败还能交给内置的 `dsh-remote-deploy` 技能排查。
|
|
52
52
|
- **远端跨平台** —— 文件访问走 SFTP 协议层(不依赖 POSIX shell),Linux/macOS/Windows 远端都能列/读/写/搜索/同步。
|
|
53
53
|
- **Windows 主机** —— 自动探测平台并定位 Git Bash,命令经 `bash -s` 走 stdin 执行,不受引号/反斜杠转义困扰(`config.shell` 可指定或设 `native` 关闭);`C:\Users\dev` 与 `/c/Users/dev` 两种写法都接受。
|
|
54
54
|
- **长任务异步化** —— `rw_sync`/`rw_push` 传 `async: true` 返回 `taskId`,可查询进度/结果/取消。
|
|
@@ -247,7 +247,7 @@ scripts/dev-run.sh --status # 是否在运行
|
|
|
247
247
|
| `webInstallVersion` | string | `0.1.5-rc.2` | 自动部署安装的 dsh 版本;默认值是第一个在 Linux 上能启动 web 的版本 |
|
|
248
248
|
| `webInstallRegistry` | string | `''` | 安装用的 npm 源;**留空沿用远端自己的配置**,不会覆盖内网镜像 |
|
|
249
249
|
| `backupDir` | string | `''` | 备份归档在**远端**的存放目录;留空用 `$HOME/.dsh-remote/backups`(在远端解析,不用本机 home 猜) |
|
|
250
|
-
| `backupExcludes` | string[] | `[]` |
|
|
250
|
+
| `backupExcludes` | string[] | `[]` | 在**内置排除集之外**额外排除的路径(gitignore 风格)。设置页不再暴露此项;内置集已覆盖 `node_modules`/`.git`/`dist`/`build`/`target`/`__pycache__`/`.venv` 等(顶层与嵌套) |
|
|
251
251
|
| `maxBackupTransferBytes` | int | `2147483648` | 归档在本机与远端之间搬运的大小上限(字节);**远端的归档从不截断**,只约束可选的对拷。0 = 不限 |
|
|
252
252
|
|
|
253
253
|
> 权威清单是 `lib/index.js` 里的 `Config` schema,本表与之一致。
|
package/lib/archive.js
CHANGED
|
@@ -244,6 +244,273 @@ export function excludeFlags(patterns = []) {
|
|
|
244
244
|
return patterns.map((p) => shq(`--exclude=${p}`)).join(' ')
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
/** Marker value for the manifest directory inside a "all workspaces" archive. */
|
|
248
|
+
export const ALL_MANIFEST_DIR = 'dsh-remote-backup.json'
|
|
249
|
+
export const ALL_MANIFEST_FILE = 'manifest.json'
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Build the archive-creation script for SEVERAL workspaces at once.
|
|
253
|
+
*
|
|
254
|
+
* One archive, one restore button — the whole point is that the user does not
|
|
255
|
+
* have to think per-workspace. Members are stored by their ABSOLUTE path with
|
|
256
|
+
* the leading `/` stripped (`-C /`), next to a manifest listing each workspace's
|
|
257
|
+
* real absolute path. Restoring reads the manifest, so an archive made on one
|
|
258
|
+
* machine can be restored on another, where the same absolute path may not exist
|
|
259
|
+
* (the caller supplies the fallback location).
|
|
260
|
+
*
|
|
261
|
+
* Excludes are passed for the WHOLE set: `node_modules` must be skipped at any
|
|
262
|
+
* depth, so the caller passes both the bare form and the `*/…` form (GNU tar
|
|
263
|
+
* matches a bare pattern at any depth already, but a pattern that contains a `/`
|
|
264
|
+
* is anchored, so the `*/` variant is what keeps a NESTED `a/node_modules` out
|
|
265
|
+
* — measured, see the header note).
|
|
266
|
+
*
|
|
267
|
+
* @param {object} spec
|
|
268
|
+
* @param {Array<{path: string, member: string}>} spec.workspaces - absolute
|
|
269
|
+
* directory paths plus the archive member each one is stored as.
|
|
270
|
+
* @param {string} spec.archive - absolute destination for the .tar.gz.
|
|
271
|
+
* @param {string} spec.manifestJson - sidecar manifest text stored in the archive.
|
|
272
|
+
* @param {string} [spec.pathsTsv] - `member<TAB>path` lines the restore loop reads
|
|
273
|
+
* (the remote has no JSON parser we can rely on).
|
|
274
|
+
* @param {string[]} [spec.excludes] - NORMALIZED tar patterns (see parseExcludes).
|
|
275
|
+
* @returns {string} a POSIX shell script.
|
|
276
|
+
*/
|
|
277
|
+
export function buildCreateAllCommand({ workspaces = [], archive, manifestJson = '', pathsTsv = '', excludes = [] }) {
|
|
278
|
+
const seen = new Set()
|
|
279
|
+
const members = []
|
|
280
|
+
for (const w of workspaces) {
|
|
281
|
+
const p = String((w && w.path) || '')
|
|
282
|
+
const member = String((w && w.member) || '')
|
|
283
|
+
if (!p || p === '/' || seen.has(p)) continue
|
|
284
|
+
seen.add(p)
|
|
285
|
+
// Guard the member expression: a workspace the archive cannot address would
|
|
286
|
+
// silently land somewhere unexpected on restore.
|
|
287
|
+
if (!p.startsWith('/')) throw new Error(`dsh-remote: refusing to archive a non-absolute workspace path ${JSON.stringify(p)}`)
|
|
288
|
+
if (p.split('/').includes('..')) throw new Error(`dsh-remote: refusing to archive a workspace path containing '..': ${JSON.stringify(p)}`)
|
|
289
|
+
if (!member || member.startsWith('/') || member.split('/').includes('..')) {
|
|
290
|
+
throw new Error(`dsh-remote: refusing a bad archive member ${JSON.stringify(member)} for ${p}`)
|
|
291
|
+
}
|
|
292
|
+
members.push({ path: p, member })
|
|
293
|
+
}
|
|
294
|
+
if (!members.length) throw new Error('dsh-remote: no workspaces to archive')
|
|
295
|
+
for (const p of excludes) {
|
|
296
|
+
if (isUnsafeExclude(p) || p.startsWith('./') || p.startsWith('/')) {
|
|
297
|
+
throw new Error(`dsh-remote: refusing to run tar with unsafe/ineffective exclude pattern ${JSON.stringify(p)}`)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const flags = excludeFlags(excludes)
|
|
301
|
+
const manifestB64 = Buffer.from(String(manifestJson), 'utf8').toString('base64')
|
|
302
|
+
const pathsTsvB64 = Buffer.from(String(pathsTsv || ''), 'utf8').toString('base64')
|
|
303
|
+
return [
|
|
304
|
+
'set -u',
|
|
305
|
+
`ARCH_DST=${shq(archive)}`,
|
|
306
|
+
'ARCH_DIR=$(dirname "$ARCH_DST")',
|
|
307
|
+
'mkdir -p "$ARCH_DIR"',
|
|
308
|
+
'if [ ! -d "$ARCH_DIR" ]',
|
|
309
|
+
'then',
|
|
310
|
+
` echo "${MARK.ok}=0"`,
|
|
311
|
+
` echo "${MARK.error}=cannot create archive directory: $ARCH_DIR"`,
|
|
312
|
+
' exit 1',
|
|
313
|
+
'fi',
|
|
314
|
+
// Refuse BEFORE writing anything when a listed workspace is gone: a silent
|
|
315
|
+
// partial archive is exactly how "I backed it up" becomes false.
|
|
316
|
+
// The check uses the REAL absolute path, not the archive member — the two
|
|
317
|
+
// differ (`/allws/w1` vs `allws/w1`), and checking the member is what the
|
|
318
|
+
// first version did, failing every run with "these workspaces do not exist".
|
|
319
|
+
'ARCH_MISSING=""',
|
|
320
|
+
...members.map((m) => `if [ ! -d ${shq(m.path)} ]\nthen\n ARCH_MISSING="$ARCH_MISSING ${shq(m.path)}"\nfi`),
|
|
321
|
+
'if [ -n "$ARCH_MISSING" ]',
|
|
322
|
+
'then',
|
|
323
|
+
` echo "${MARK.ok}=0"`,
|
|
324
|
+
` echo "${MARK.error}=these workspaces do not exist:$ARCH_MISSING"`,
|
|
325
|
+
' exit 1',
|
|
326
|
+
'fi',
|
|
327
|
+
// The manifest is materialised into a staging dir and added with its OWN -C
|
|
328
|
+
// group, so one invocation writes the archive plus its manifest.
|
|
329
|
+
'ARCH_TMP="$ARCH_DST.stage.$$"',
|
|
330
|
+
'rm -rf "$ARCH_TMP"',
|
|
331
|
+
// The manifest lives in its OWN subdirectory inside the staging dir, because
|
|
332
|
+
// tar adds `"$ARCH_TMP"/dsh-remote-backup.json` as a member of that name.
|
|
333
|
+
// Writing the files flat into ARCH_TMP made tar fail with "Cannot stat" —
|
|
334
|
+
// caught by the real-shell run, not by a string assertion.
|
|
335
|
+
`mkdir -p "$ARCH_TMP/${ALL_MANIFEST_DIR}"`,
|
|
336
|
+
// Both manifest files are written HERE, before tar runs.
|
|
337
|
+
// Base64 avoids every quoting hazard in the text (quotes, tabs, newlines).
|
|
338
|
+
`printf '%s' ${shq(manifestB64)} | base64 -d > "$ARCH_TMP/${ALL_MANIFEST_DIR}/${ALL_MANIFEST_FILE}"`,
|
|
339
|
+
`printf '%s' ${shq(pathsTsvB64)} | base64 -d > "$ARCH_TMP/${ALL_MANIFEST_DIR}/paths.tsv"`,
|
|
340
|
+
'ARCH_STAGE="$ARCH_DST.stage.$$.tgz"',
|
|
341
|
+
'rm -f "$ARCH_STAGE"',
|
|
342
|
+
// `-C /` keeps member names absolute-relative (no leading slash), and `-C`
|
|
343
|
+
// again for the manifest dir; `-P` is never passed.
|
|
344
|
+
`tar -czf "$ARCH_STAGE" -C / ${flags ? flags + ' ' : ''}${members.map((m) => shq(m.member)).join(' ')} -C "$ARCH_TMP" ${shq(ALL_MANIFEST_DIR)} 2>&1`,
|
|
345
|
+
'ARCH_RC=$?',
|
|
346
|
+
'if [ "$ARCH_RC" -ne 0 ]',
|
|
347
|
+
'then',
|
|
348
|
+
` echo "${MARK.ok}=0"`,
|
|
349
|
+
` echo "${MARK.error}=tar failed with exit $ARCH_RC"`,
|
|
350
|
+
' rm -f "$ARCH_STAGE"',
|
|
351
|
+
' rm -rf "$ARCH_TMP"',
|
|
352
|
+
' exit 1',
|
|
353
|
+
'fi',
|
|
354
|
+
'if [ ! -s "$ARCH_STAGE" ]',
|
|
355
|
+
'then',
|
|
356
|
+
` echo "${MARK.ok}=0"`,
|
|
357
|
+
` echo "${MARK.error}=tar produced an empty archive"`,
|
|
358
|
+
' rm -f "$ARCH_STAGE"',
|
|
359
|
+
' rm -rf "$ARCH_TMP"',
|
|
360
|
+
' exit 1',
|
|
361
|
+
'fi',
|
|
362
|
+
'if tar -tzf "$ARCH_STAGE" >/dev/null 2>&1',
|
|
363
|
+
'then',
|
|
364
|
+
` echo "${MARK.verify}=ok"`,
|
|
365
|
+
'else',
|
|
366
|
+
` echo "${MARK.verify}=bad"`,
|
|
367
|
+
'fi',
|
|
368
|
+
'mv -f "$ARCH_STAGE" "$ARCH_DST"',
|
|
369
|
+
'rm -rf "$ARCH_TMP"',
|
|
370
|
+
'ARCH_BYTES=$(wc -c < "$ARCH_DST" | tr -d " ")',
|
|
371
|
+
'ARCH_MEMBERS=$(tar -tzf "$ARCH_DST" 2>/dev/null | wc -l | tr -d " ")',
|
|
372
|
+
'ARCH_SUM=""',
|
|
373
|
+
'if command -v sha256sum >/dev/null 2>&1',
|
|
374
|
+
'then',
|
|
375
|
+
' ARCH_SUM=$(sha256sum "$ARCH_DST" | awk \'{print $1}\')',
|
|
376
|
+
'elif command -v shasum >/dev/null 2>&1',
|
|
377
|
+
'then',
|
|
378
|
+
' ARCH_SUM=$(shasum -a 256 "$ARCH_DST" | awk \'{print $1}\')',
|
|
379
|
+
'elif command -v openssl >/dev/null 2>&1',
|
|
380
|
+
'then',
|
|
381
|
+
' ARCH_SUM=$(openssl dgst -sha256 "$ARCH_DST" | awk \'{print $NF}\')',
|
|
382
|
+
'fi',
|
|
383
|
+
`echo "${MARK.file}=$ARCH_DST"`,
|
|
384
|
+
`echo "${MARK.bytes}=$ARCH_BYTES"`,
|
|
385
|
+
`echo "${MARK.members}=$ARCH_MEMBERS"`,
|
|
386
|
+
`echo "${MARK.sha}=$ARCH_SUM"`,
|
|
387
|
+
`echo "${MARK.ok}=1"`,
|
|
388
|
+
].join('\n')
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Build the restore-all script.
|
|
393
|
+
*
|
|
394
|
+
* Order matters and is the safety property: verify the stream, extract to a
|
|
395
|
+
* staging dir, then relocate each workspace into place. Relocation is
|
|
396
|
+
* per-workspace `mv` of the EXTRACTED copy over the destination, moving the
|
|
397
|
+
* previous contents aside first and deleting them only after the new tree is in
|
|
398
|
+
* place — the same stage/swap discipline as the single-workspace restore.
|
|
399
|
+
*
|
|
400
|
+
* A workspace in the manifest whose absolute path cannot be created falls back
|
|
401
|
+
* to `<fallbackRoot>/<basename>`, so an archive restored on a different machine
|
|
402
|
+
* still lands somewhere sensible instead of failing outright.
|
|
403
|
+
*/
|
|
404
|
+
export function buildRestoreAllCommand({ archive, fallbackRoot = '/tmp' }) {
|
|
405
|
+
return [
|
|
406
|
+
'set -u',
|
|
407
|
+
`ARCH_F=${shq(archive)}`,
|
|
408
|
+
`ARCH_FALLBACK=${shq(fallbackRoot)}`,
|
|
409
|
+
'if [ ! -f "$ARCH_F" ]',
|
|
410
|
+
'then',
|
|
411
|
+
` echo "${MARK.ok}=0"`,
|
|
412
|
+
` echo "${MARK.error}=archive not found: $ARCH_F"`,
|
|
413
|
+
' exit 1',
|
|
414
|
+
'fi',
|
|
415
|
+
'if tar -tzf "$ARCH_F" >/dev/null 2>&1',
|
|
416
|
+
'then',
|
|
417
|
+
` echo "${MARK.verify}=ok"`,
|
|
418
|
+
'else',
|
|
419
|
+
` echo "${MARK.verify}=bad"`,
|
|
420
|
+
` echo "${MARK.ok}=0"`,
|
|
421
|
+
` echo "${MARK.error}=archive is corrupt or truncated; nothing was written"`,
|
|
422
|
+
' exit 1',
|
|
423
|
+
'fi',
|
|
424
|
+
'ARCH_STAGE="$ARCH_F.extract.$$"',
|
|
425
|
+
'rm -rf "$ARCH_STAGE"',
|
|
426
|
+
'mkdir -p "$ARCH_STAGE"',
|
|
427
|
+
'if tar -xzf "$ARCH_F" -C "$ARCH_STAGE" 2>&1',
|
|
428
|
+
'then',
|
|
429
|
+
' :',
|
|
430
|
+
'else',
|
|
431
|
+
' rm -rf "$ARCH_STAGE"',
|
|
432
|
+
` echo "${MARK.ok}=0"`,
|
|
433
|
+
` echo "${MARK.error}=extraction failed; nothing was written"`,
|
|
434
|
+
' exit 1',
|
|
435
|
+
'fi',
|
|
436
|
+
`if [ ! -f "$ARCH_STAGE/${ALL_MANIFEST_DIR}/${ALL_MANIFEST_FILE}" ]`,
|
|
437
|
+
'then',
|
|
438
|
+
' rm -rf "$ARCH_STAGE"',
|
|
439
|
+
` echo "${MARK.ok}=0"`,
|
|
440
|
+
` echo "${MARK.error}=archive has no manifest (${ALL_MANIFEST_DIR}/${ALL_MANIFEST_FILE})"`,
|
|
441
|
+
' exit 1',
|
|
442
|
+
'fi',
|
|
443
|
+
// Parse the manifest without jq: each workspace is one line of the form
|
|
444
|
+
// `MEMBER<TAB>PATH`, which the host emits into the manifest as an extra
|
|
445
|
+
// field for exactly this reason (no JSON parser on the remote).
|
|
446
|
+
`ARCH_LIST="$ARCH_STAGE/${ALL_MANIFEST_DIR}/paths.tsv"`,
|
|
447
|
+
'if [ ! -f "$ARCH_LIST" ]',
|
|
448
|
+
'then',
|
|
449
|
+
' rm -rf "$ARCH_STAGE"',
|
|
450
|
+
` echo "${MARK.ok}=0"`,
|
|
451
|
+
` echo "${MARK.error}=archive manifest has no paths.tsv"`,
|
|
452
|
+
' exit 1',
|
|
453
|
+
'fi',
|
|
454
|
+
'ARCH_RESTORED=0',
|
|
455
|
+
'ARCH_FAILED=""',
|
|
456
|
+
'while IFS="\t" read -r ARCH_MEMBER ARCH_TARGET',
|
|
457
|
+
'do',
|
|
458
|
+
' if [ -z "$ARCH_MEMBER" ] || [ -z "$ARCH_TARGET" ]',
|
|
459
|
+
' then',
|
|
460
|
+
' continue',
|
|
461
|
+
' fi',
|
|
462
|
+
' ARCH_SRC="$ARCH_STAGE/$ARCH_MEMBER"',
|
|
463
|
+
' if [ ! -e "$ARCH_SRC" ]',
|
|
464
|
+
' then',
|
|
465
|
+
' continue',
|
|
466
|
+
' fi',
|
|
467
|
+
// An unwritable original location falls back to <fallback>/<basename> rather
|
|
468
|
+
// than failing the whole restore half-way.
|
|
469
|
+
' ARCH_P=$(dirname "$ARCH_TARGET")',
|
|
470
|
+
' if ! mkdir -p "$ARCH_P" 2>/dev/null',
|
|
471
|
+
' then',
|
|
472
|
+
' ARCH_TARGET="$ARCH_FALLBACK/$(basename "$ARCH_TARGET")"',
|
|
473
|
+
' ARCH_P=$(dirname "$ARCH_TARGET")',
|
|
474
|
+
' mkdir -p "$ARCH_P" 2>/dev/null || { ARCH_FAILED="$ARCH_FAILED $ARCH_SRC"; continue; }',
|
|
475
|
+
' fi',
|
|
476
|
+
' ARCH_SWAP="$ARCH_TARGET.dsh-remote-bak.$$"',
|
|
477
|
+
' ARCH_HAD=0',
|
|
478
|
+
' if [ -e "$ARCH_TARGET" ]',
|
|
479
|
+
' then',
|
|
480
|
+
' ARCH_HAD=1',
|
|
481
|
+
' rm -rf "$ARCH_SWAP"',
|
|
482
|
+
' if ! mv "$ARCH_TARGET" "$ARCH_SWAP"',
|
|
483
|
+
' then',
|
|
484
|
+
' ARCH_FAILED="$ARCH_FAILED $ARCH_TARGET"',
|
|
485
|
+
' continue',
|
|
486
|
+
' fi',
|
|
487
|
+
' fi',
|
|
488
|
+
' if mv "$ARCH_SRC" "$ARCH_TARGET"',
|
|
489
|
+
' then',
|
|
490
|
+
' if [ "$ARCH_HAD" = "1" ]',
|
|
491
|
+
' then',
|
|
492
|
+
' rm -rf "$ARCH_SWAP"',
|
|
493
|
+
' fi',
|
|
494
|
+
' ARCH_RESTORED=$((ARCH_RESTORED+1))',
|
|
495
|
+
` echo "${MARK.entry}=$ARCH_TARGET"`,
|
|
496
|
+
' else',
|
|
497
|
+
' if [ "$ARCH_HAD" = "1" ]',
|
|
498
|
+
' then',
|
|
499
|
+
' mv "$ARCH_SWAP" "$ARCH_TARGET"',
|
|
500
|
+
' fi',
|
|
501
|
+
' ARCH_FAILED="$ARCH_FAILED $ARCH_TARGET"',
|
|
502
|
+
' fi',
|
|
503
|
+
`done < "$ARCH_LIST"`,
|
|
504
|
+
'rm -rf "$ARCH_STAGE"',
|
|
505
|
+
`echo "${MARK.members}=$ARCH_RESTORED"`,
|
|
506
|
+
'if [ -n "$ARCH_FAILED" ]',
|
|
507
|
+
'then',
|
|
508
|
+
` echo "${MARK.error}=could not restore:$ARCH_FAILED"`,
|
|
509
|
+
'fi',
|
|
510
|
+
`echo "${MARK.ok}=1"`,
|
|
511
|
+
].join('\n')
|
|
512
|
+
}
|
|
513
|
+
|
|
247
514
|
/**
|
|
248
515
|
* Build the archive-creation script.
|
|
249
516
|
*
|
|
@@ -642,6 +909,64 @@ export function parseStatus(stdout) {
|
|
|
642
909
|
}
|
|
643
910
|
}
|
|
644
911
|
|
|
912
|
+
/** Every `ARCH_ENTRY=` value from a restore-all run (one per relocated workspace). */
|
|
913
|
+
export function parseRestoredEntries(stdout) {
|
|
914
|
+
const out = []
|
|
915
|
+
const re = new RegExp(`^${MARK.entry}=(.*)$`, 'gm')
|
|
916
|
+
let m
|
|
917
|
+
while ((m = re.exec(String(stdout ?? ''))) !== null) {
|
|
918
|
+
const v = m[1].trim()
|
|
919
|
+
if (v) out.push(v)
|
|
920
|
+
}
|
|
921
|
+
return out
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* The default exclude set for an "all workspaces" archive.
|
|
926
|
+
*
|
|
927
|
+
* Deliberately narrow and predictable: dependency/build trees that are huge,
|
|
928
|
+
* reproducible from the manifest, and the main reason a naive full backup is
|
|
929
|
+
* unusably large. Anything NOT listed here is archived — silently dropping more
|
|
930
|
+
* than this is how a restore turns into data loss.
|
|
931
|
+
*
|
|
932
|
+
* Both the bare and the star-prefixed form of each directory pattern are emitted
|
|
933
|
+
* because GNU tar anchors a pattern that contains a slash: the bare form covers
|
|
934
|
+
* the top level and the star-slash form covers every nested occurrence
|
|
935
|
+
* (measured, see the header).
|
|
936
|
+
*/
|
|
937
|
+
/** Directory names whose dependency/build content is excluded from an "all
|
|
938
|
+
* workspaces" archive. Kept as bare names so the star-prefixed variants can be
|
|
939
|
+
* DERIVED instead of typed — a hand-typed `*/` once carried a zero-width space
|
|
940
|
+
* that made the pattern unusable, and a derivation cannot do that. */
|
|
941
|
+
export const ALL_BACKUP_EXCLUDE_DIRS = [
|
|
942
|
+
'node_modules', '.git', 'dist', 'build', 'target',
|
|
943
|
+
'__pycache__', '.venv', 'venv', '.tox', '.mypy_cache', '.pytest_cache',
|
|
944
|
+
]
|
|
945
|
+
|
|
946
|
+
/** Expand bare directory names into the tar patterns actually passed.
|
|
947
|
+
*
|
|
948
|
+
* Both forms are needed: GNU tar anchors a pattern containing a slash, so the
|
|
949
|
+
* bare name covers the top level while the star-prefixed one covers every nested
|
|
950
|
+
* occurrence (measured on GNU tar 1.35 — see the module header). */
|
|
951
|
+
export function expandExcludeDirs(dirs = ALL_BACKUP_EXCLUDE_DIRS) {
|
|
952
|
+
const out = []
|
|
953
|
+
for (const d of dirs) {
|
|
954
|
+
const name = String(d || '').trim()
|
|
955
|
+
if (!name) continue
|
|
956
|
+
out.push(name)
|
|
957
|
+
out.push(`*/${name}`)
|
|
958
|
+
}
|
|
959
|
+
return out
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** The default exclude set for an "all workspaces" archive.
|
|
963
|
+
*
|
|
964
|
+
* Deliberately narrow and predictable: dependency/build trees that are huge,
|
|
965
|
+
* reproducible from the manifest, and the main reason a naive full backup is
|
|
966
|
+
* unusably large. Anything NOT listed here is archived — silently dropping more
|
|
967
|
+
* than this is how a restore turns into data loss. */
|
|
968
|
+
export const ALL_BACKUP_EXCLUDES = expandExcludeDirs()
|
|
969
|
+
|
|
645
970
|
/**
|
|
646
971
|
* Reject an exclude that would escape the workspace. Kept separate so the tool
|
|
647
972
|
* can return a precise, actionable message instead of a generic failure, and so
|
package/lib/backup.js
CHANGED
|
@@ -34,6 +34,9 @@ import {
|
|
|
34
34
|
buildDeleteCommand, buildRestoreCommand, parseCreateResult, parseListResult,
|
|
35
35
|
parseStatus, resolveBackupDir, archiveName, archiveStem, metaNameFor,
|
|
36
36
|
isSafeArchiveName, excludeRejection, backupDirUnderRoot, formatBytes,
|
|
37
|
+
buildCreateAllCommand, buildRestoreAllCommand, parseRestoredEntries,
|
|
38
|
+
ALL_MANIFEST_DIR, ALL_MANIFEST_FILE, ALL_BACKUP_EXCLUDES, expandExcludeDirs,
|
|
39
|
+
isUnsafeExclude,
|
|
37
40
|
} from './archive.js'
|
|
38
41
|
|
|
39
42
|
/** Parse one `ARCH_*` marker without importing the whole marker map. */
|
|
@@ -517,3 +520,192 @@ export function describeCreate(result, { dest = 'remote' } = {}) {
|
|
|
517
520
|
|
|
518
521
|
/** Marker used by tests to assert the sidecar name derivation. */
|
|
519
522
|
export { META_SUFFIX, ARCHIVE_SUFFIX, META_BEGIN_MARK }
|
|
523
|
+
|
|
524
|
+
// ── "all workspaces" backup: ONE archive, ONE restore ───────────────────────
|
|
525
|
+
//
|
|
526
|
+
// The panel offers exactly two buttons, so this is the whole feature. Everything
|
|
527
|
+
// the single-workspace flow learned still applies (stage-then-publish, verify
|
|
528
|
+
// before writing, manifest for self-description); what changes is that the
|
|
529
|
+
// archive holds MANY unrelated absolute directories and describes itself, so a
|
|
530
|
+
// restore does not need the caller to remember which workspace it was for.
|
|
531
|
+
|
|
532
|
+
/** Filename stem for an all-workspaces archive: the machine identity plus a
|
|
533
|
+
* hash, so archives from different machines never share a stem. */
|
|
534
|
+
export function allArchiveStem(machine = {}) {
|
|
535
|
+
const who = `${machine.username || 'user'}-${machine.host || 'host'}`
|
|
536
|
+
const slug = who.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'machine'
|
|
537
|
+
return `workspaces-${slug}-${shortHashLocal(`${machine.username || ''}@${machine.host || ''}:${machine.port || 22}`)}`
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Local 32-bit base36 hash (kept here so this module has no extra import). */
|
|
541
|
+
function shortHashLocal(s) {
|
|
542
|
+
let h = 0
|
|
543
|
+
for (let i = 0; i < String(s).length; i++) h = ((h << 5) - h + String(s).charCodeAt(i)) | 0
|
|
544
|
+
return (h >>> 0).toString(36)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* The archive member for an absolute path: the path with its leading slash
|
|
549
|
+
* stripped, because members are added with `-C /`.
|
|
550
|
+
*
|
|
551
|
+
* It MUST be the full path, not a shortened display form. `-C /` resolves the
|
|
552
|
+
* member against the filesystem root, so a shortened member (`allws/w1`) makes
|
|
553
|
+
* tar look in `/allws/w1` while the real directory is `/tmp/allws/w1` — every
|
|
554
|
+
* run fails with "Cannot stat". (Measured on a real host; the shortening looked
|
|
555
|
+
* tidier and was simply wrong.)
|
|
556
|
+
*/
|
|
557
|
+
export function memberForPath(absPath) {
|
|
558
|
+
const p = String(absPath || '')
|
|
559
|
+
if (!p.startsWith('/') || p === '/') {
|
|
560
|
+
throw new Error(`dsh-remote: cannot archive a non-absolute path ${JSON.stringify(absPath)}`)
|
|
561
|
+
}
|
|
562
|
+
return p.replace(/^\/+/, '')
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Attach the archive member to each distinct path. Members are unique whenever
|
|
566
|
+
* the paths are, which is why no suffixing is needed (and why two different
|
|
567
|
+
* `/a/x/proj` and `/b/x/proj` stay distinct: their full paths differ). */
|
|
568
|
+
export function assignMembers(paths = []) {
|
|
569
|
+
const used = new Set()
|
|
570
|
+
const out = []
|
|
571
|
+
for (const p of paths) {
|
|
572
|
+
const path = String(p || '')
|
|
573
|
+
if (!path || path === '/' || used.has(path)) continue
|
|
574
|
+
used.add(path)
|
|
575
|
+
out.push({ path, member: memberForPath(path) })
|
|
576
|
+
}
|
|
577
|
+
return out
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Create ONE archive containing every given workspace.
|
|
582
|
+
*
|
|
583
|
+
* @param {object} pool
|
|
584
|
+
* @param {object} spec
|
|
585
|
+
* @param {string[]} spec.workspaces - absolute remote directories.
|
|
586
|
+
* @param {string} spec.backupDir - absolute remote directory for the archive.
|
|
587
|
+
* @param {object} [spec.machine] - {host, username, port} recorded in the manifest.
|
|
588
|
+
* @param {string[]} [spec.extraExcludes] - added to the default exclude set.
|
|
589
|
+
* @param {string} [spec.label]
|
|
590
|
+
* @param {Date} [spec.date]
|
|
591
|
+
* @returns {Promise<object>} the create result plus `workspaces` (the manifest).
|
|
592
|
+
*/
|
|
593
|
+
export async function createAllBackup(pool, spec = {}) {
|
|
594
|
+
const dirs = [...new Set((spec.workspaces || []).map((p) => String(p || '')).filter((p) => p && p !== '/'))]
|
|
595
|
+
if (!dirs.length) {
|
|
596
|
+
return { ok: false, error: 'no workspaces to back up — pick a remote workspace first', archive: '', name: '', bytes: 0, members: 0, sha256: '', verified: false, workspaces: [] }
|
|
597
|
+
}
|
|
598
|
+
const backupDir = String(spec.backupDir || '')
|
|
599
|
+
if (!backupDir) {
|
|
600
|
+
return { ok: false, error: 'cannot determine the remote backup directory — set backupDir', archive: '', name: '', bytes: 0, members: 0, sha256: '', verified: false, workspaces: [] }
|
|
601
|
+
}
|
|
602
|
+
const assigned = assignMembers(dirs)
|
|
603
|
+
const excludes = [...new Set([...ALL_BACKUP_EXCLUDES, ...expandExcludeDirs([]), ...(spec.extraExcludes || [])])]
|
|
604
|
+
// Never archive a previous backup of the same machine.
|
|
605
|
+
for (const d of dirs) {
|
|
606
|
+
const nested = backupDirUnderRoot(backupDir, d)
|
|
607
|
+
if (nested) excludes.push(nested)
|
|
608
|
+
}
|
|
609
|
+
for (const p of excludes) {
|
|
610
|
+
const bad = isUnsafeExclude(p) || p.startsWith('./') || p.startsWith('/')
|
|
611
|
+
if (bad) return { ok: false, error: `invalid exclude pattern: ${JSON.stringify(p)}`, archive: '', name: '', bytes: 0, members: 0, sha256: '', verified: false, workspaces: [] }
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const baseName = `${allArchiveStem(spec.machine || {})}-${archiveName(spec.date ? new Date(spec.date) : new Date(), { label: spec.label || 'all' })}`
|
|
615
|
+
const archive = await uniqueArchivePath(pool, backupDir, baseName)
|
|
616
|
+
const name = archive.slice(archive.lastIndexOf('/') + 1)
|
|
617
|
+
const metaPath = `${backupDir}/${metaNameFor(name)}`
|
|
618
|
+
|
|
619
|
+
const manifest = {
|
|
620
|
+
kind: 'dsh-remote/workspaces',
|
|
621
|
+
version: 1,
|
|
622
|
+
createdAt: new Date().toISOString(),
|
|
623
|
+
machine: {
|
|
624
|
+
host: String((spec.machine && spec.machine.host) || ''),
|
|
625
|
+
username: String((spec.machine && spec.machine.username) || ''),
|
|
626
|
+
port: Number((spec.machine && spec.machine.port) || 22),
|
|
627
|
+
},
|
|
628
|
+
excludes,
|
|
629
|
+
workspaces: assigned.map((a) => ({ path: a.path, member: a.member })),
|
|
630
|
+
}
|
|
631
|
+
// The remote has no JSON parser we can rely on, so the manifest ships BOTH the
|
|
632
|
+
// human/JSON form and a TAB-separated member↔path list the restore loop reads.
|
|
633
|
+
// Both are passed to the builder, which writes them BEFORE tar runs.
|
|
634
|
+
const pathsTsv = assigned.map((a) => `${a.member}\t${a.path}`).join('\n') + '\n'
|
|
635
|
+
const withTsv = buildCreateAllCommand({
|
|
636
|
+
workspaces: assigned,
|
|
637
|
+
archive,
|
|
638
|
+
manifestJson: JSON.stringify(manifest, null, 2),
|
|
639
|
+
pathsTsv,
|
|
640
|
+
excludes,
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
let out
|
|
644
|
+
try {
|
|
645
|
+
out = await pool.exec(withTsv, spec.timeoutMs ? { timeoutMs: spec.timeoutMs } : {})
|
|
646
|
+
} catch (err) {
|
|
647
|
+
return { ok: false, error: String((err && err.message) || err), archive, name, bytes: 0, members: 0, sha256: '', verified: false, workspaces: assigned }
|
|
648
|
+
}
|
|
649
|
+
const parsed = parseCreateResult(out && out.stdout)
|
|
650
|
+
const stderr = String((out && out.stderr) || '').trim()
|
|
651
|
+
if (!parsed.ok) {
|
|
652
|
+
const detail = [parsed.error, stderr].filter(Boolean).join(' — ')
|
|
653
|
+
return { ok: false, error: detail || 'backup failed', archive, name, bytes: 0, members: 0, sha256: '', verified: false, workspaces: assigned }
|
|
654
|
+
}
|
|
655
|
+
const meta = { name, kind: 'workspaces', ...manifest, bytes: parsed.bytes, members: parsed.members, sha256: parsed.sha256, verified: parsed.verified, tool: 'dsh-remote' }
|
|
656
|
+
const metaWritten = await writeRemoteMeta(pool, metaPath, meta)
|
|
657
|
+
return {
|
|
658
|
+
ok: true,
|
|
659
|
+
error: '',
|
|
660
|
+
archive,
|
|
661
|
+
name,
|
|
662
|
+
metaPath,
|
|
663
|
+
bytes: parsed.bytes,
|
|
664
|
+
members: parsed.members,
|
|
665
|
+
sha256: parsed.sha256,
|
|
666
|
+
verified: parsed.verified,
|
|
667
|
+
metaWritten,
|
|
668
|
+
meta,
|
|
669
|
+
workspaces: assigned,
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** POSIX single-quote (local copy; keeps this module's imports minimal). */
|
|
674
|
+
function shqLocal(s) {
|
|
675
|
+
return `'${String(s).replace(/'/g, `'\\''`)}'`
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Restore every workspace from an all-workspaces archive.
|
|
680
|
+
* @param {object} pool
|
|
681
|
+
* @param {object} spec
|
|
682
|
+
* @param {string} spec.archive - absolute archive path on the remote.
|
|
683
|
+
* @param {string} [spec.fallbackRoot] - where a workspace lands when its original
|
|
684
|
+
* absolute path cannot be created (e.g. restoring another machine's archive).
|
|
685
|
+
*/
|
|
686
|
+
export async function restoreAllBackup(pool, spec = {}) {
|
|
687
|
+
const archive = String(spec.archive || '')
|
|
688
|
+
if (!archive) return { ok: false, error: 'an archive is required', verified: false, entries: [], members: 0 }
|
|
689
|
+
const script = buildRestoreAllCommand({ archive, fallbackRoot: String(spec.fallbackRoot || '/tmp') })
|
|
690
|
+
let out
|
|
691
|
+
try {
|
|
692
|
+
out = await pool.exec(script, { timeoutMs: Number(spec.timeoutMs) || 0 })
|
|
693
|
+
} catch (err) {
|
|
694
|
+
return { ok: false, error: String((err && err.message) || err), verified: false, entries: [], members: 0 }
|
|
695
|
+
}
|
|
696
|
+
const status = parseStatus(out && out.stdout)
|
|
697
|
+
const entries = parseRestoredEntries(out && out.stdout)
|
|
698
|
+
const stderr = String((out && out.stderr) || '').trim()
|
|
699
|
+
if (!status.ok && !status.error) {
|
|
700
|
+
return { ok: false, error: stderr || 'restore failed before it could report a reason', verified: status.verified, entries, members: 0 }
|
|
701
|
+
}
|
|
702
|
+
return {
|
|
703
|
+
ok: status.ok,
|
|
704
|
+
// A partial restore still reports success for what moved, so the error text
|
|
705
|
+
// must survive alongside `ok` for the caller to show honestly.
|
|
706
|
+
error: status.error || (status.ok ? '' : stderr),
|
|
707
|
+
verified: status.verified,
|
|
708
|
+
entries,
|
|
709
|
+
members: status.members || entries.length,
|
|
710
|
+
}
|
|
711
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -188,42 +188,30 @@ window.__ModuleLoader__.load({
|
|
|
188
188
|
'settings.modeOff': '关闭',
|
|
189
189
|
'settings.modeManual': '手动',
|
|
190
190
|
// ── 工作区备份(rw_backup / rw_restore)────────────────────────────
|
|
191
|
-
'settings.backup': '
|
|
192
|
-
'settings.backupHint': '
|
|
193
|
-
'settings.backupCreate': '
|
|
191
|
+
'settings.backup': '工作区备份',
|
|
192
|
+
'settings.backupHint': '一键把侧边栏里的所有远程工作区打包成一个 .tar.gz;恢复时用同一个归档把全部工作区还原。依赖/构建目录(node_modules、.git、dist、build…)会自动排除。',
|
|
193
|
+
'settings.backupCreate': '备份',
|
|
194
194
|
'settings.backupCreating': '备份中…',
|
|
195
|
-
'settings.
|
|
196
|
-
'settings.
|
|
197
|
-
'settings.
|
|
198
|
-
'settings.
|
|
199
|
-
'settings.backupDestLocal': '仅本机',
|
|
200
|
-
'settings.backupDestBoth': '两边都留',
|
|
195
|
+
'settings.backupRestore': '恢复',
|
|
196
|
+
'settings.backupRestoring': '恢复中…',
|
|
197
|
+
'settings.backupPickOne': '— 选择要恢复的备份时间点 —',
|
|
198
|
+
'settings.backupCovers': '本次将备份 {n} 个工作区:',
|
|
201
199
|
'settings.backupDirLabel': '远端备份目录:{dir}',
|
|
202
|
-
'settings.
|
|
203
|
-
'settings.backupNone': '还没有备份。点「立即备份」创建第一个。',
|
|
200
|
+
'settings.backupNone': '还没有备份。点「备份」创建第一个。',
|
|
204
201
|
'settings.backupCount': '{n} 个备份',
|
|
205
|
-
'settings.
|
|
206
|
-
'settings.
|
|
207
|
-
'settings.backupRestore': '恢复',
|
|
208
|
-
'settings.backupDownload': '下载',
|
|
209
|
-
'settings.backupUpload': '传回远端',
|
|
202
|
+
'settings.backupEntries': '{n} 个条目',
|
|
203
|
+
'settings.backupDownload': '下载到本机',
|
|
210
204
|
'settings.backupDelete': '删除',
|
|
211
205
|
'settings.backupVerify': '校验',
|
|
212
|
-
'settings.backupRestoreConfirm': '要用 {file}
|
|
206
|
+
'settings.backupRestoreConfirm': '要用 {file} 恢复全部工作区吗?归档里没有的文件会被删除(原目录会先移到一旁,换入失败会自动还原)。',
|
|
213
207
|
'settings.backupDeleteConfirm': '删除备份 {file}?此操作不可撤销。',
|
|
214
|
-
'settings.backupModeReplace': '整树替换',
|
|
215
|
-
'settings.backupModeMerge': '合并(不删除)',
|
|
216
208
|
'settings.backupVerified': '校验通过:sha256 {sha}',
|
|
217
209
|
'settings.backupVerifyMismatch': '校验失败:哈希与记录不一致(归档可能已损坏)',
|
|
218
|
-
'settings.
|
|
219
|
-
'settings.
|
|
220
|
-
'settings.backupCreated': '已创建 {file}',
|
|
210
|
+
'settings.backupRestored': '已从 {file} 恢复:{target}',
|
|
211
|
+
'settings.backupCreatedN': '已备份 {n} 个工作区 → {file}',
|
|
221
212
|
'settings.backupDownloaded': '已下载到本机:{path}',
|
|
222
|
-
'settings.backupUploaded': '已传回远端:{path}',
|
|
223
213
|
'settings.backupDeleted': '已删除 {file}',
|
|
224
|
-
'settings.backupRefresh': '刷新列表',
|
|
225
214
|
'settings.backupFailed': '备份操作失败:{msg}',
|
|
226
|
-
'settings.backupOverCap': '归档超过搬运上限,未传输(可用 maxBackupTransferBytes 放宽)',
|
|
227
215
|
'settings.backupMachine': '机器',
|
|
228
216
|
'settings.backupPickMachine': '— 选择机器 —',
|
|
229
217
|
'picker.title': '选择工作目录',
|
|
@@ -485,42 +473,30 @@ window.__ModuleLoader__.load({
|
|
|
485
473
|
'settings.modeOff': 'Off',
|
|
486
474
|
'settings.modeManual': 'Manual',
|
|
487
475
|
// ── workspace backup (rw_backup / rw_restore) ──────────────────────
|
|
488
|
-
'settings.backup': 'Workspace backup
|
|
489
|
-
'settings.backupHint': '
|
|
490
|
-
'settings.backupCreate': 'Back up
|
|
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',
|
|
491
479
|
'settings.backupCreating': 'Backing up…',
|
|
492
|
-
'settings.
|
|
493
|
-
'settings.
|
|
494
|
-
'settings.
|
|
495
|
-
'settings.
|
|
496
|
-
'settings.backupDestLocal': 'Local only',
|
|
497
|
-
'settings.backupDestBoth': 'Both',
|
|
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):',
|
|
498
484
|
'settings.backupDirLabel': 'Remote backup directory: {dir}',
|
|
499
|
-
'settings.
|
|
500
|
-
'settings.backupNone': 'No backups yet. Click "Back up now" to create the first one.',
|
|
485
|
+
'settings.backupNone': 'No backups yet. Click "Back up" to create the first one.',
|
|
501
486
|
'settings.backupCount': '{n} backup(s)',
|
|
502
|
-
'settings.
|
|
503
|
-
'settings.backupWhereLocal': 'Local',
|
|
504
|
-
'settings.backupRestore': 'Restore',
|
|
487
|
+
'settings.backupEntries': '{n} entries',
|
|
505
488
|
'settings.backupDownload': 'Download',
|
|
506
|
-
'settings.backupUpload': 'Upload',
|
|
507
489
|
'settings.backupDelete': 'Delete',
|
|
508
490
|
'settings.backupVerify': 'Verify',
|
|
509
|
-
'settings.backupRestoreConfirm': '
|
|
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).',
|
|
510
492
|
'settings.backupDeleteConfirm': 'Delete the backup {file}? This cannot be undone.',
|
|
511
|
-
'settings.backupModeReplace': 'Replace tree',
|
|
512
|
-
'settings.backupModeMerge': 'Merge (keep extra)',
|
|
513
493
|
'settings.backupVerified': 'Verified: sha256 {sha}',
|
|
514
494
|
'settings.backupVerifyMismatch': 'Verification FAILED: the digest does not match the recorded one (the archive may be corrupt)',
|
|
515
|
-
'settings.
|
|
516
|
-
'settings.
|
|
517
|
-
'settings.backupCreated': 'Created {file}',
|
|
495
|
+
'settings.backupRestored': 'Restored from {file}: {target}',
|
|
496
|
+
'settings.backupCreatedN': 'Backed up {n} workspace(s) → {file}',
|
|
518
497
|
'settings.backupDownloaded': 'Downloaded locally: {path}',
|
|
519
|
-
'settings.backupUploaded': 'Uploaded to the remote: {path}',
|
|
520
498
|
'settings.backupDeleted': 'Deleted {file}',
|
|
521
|
-
'settings.backupRefresh': 'Refresh list',
|
|
522
499
|
'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
500
|
'settings.backupMachine': 'Machine',
|
|
525
501
|
'settings.backupPickMachine': '— choose a machine —',
|
|
526
502
|
'picker.title': 'Select working directory',
|
|
@@ -946,12 +922,10 @@ window.__ModuleLoader__.load({
|
|
|
946
922
|
const [backupBusy, setBackupBusy] = React.useState('')
|
|
947
923
|
const [backupMsg, setBackupMsg] = React.useState('')
|
|
948
924
|
const [backupErr, setBackupErr] = React.useState('')
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
//
|
|
952
|
-
|
|
953
|
-
// so it is worth showing which one is armed before clicking Restore.
|
|
954
|
-
const [backupRestoreMode, setBackupRestoreMode] = React.useState('replace')
|
|
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('')
|
|
955
929
|
const [backupMachine, setBackupMachine] = React.useState('')
|
|
956
930
|
React.useEffect(() => { ensureChromeCss() }, [])
|
|
957
931
|
|
|
@@ -1057,17 +1031,23 @@ window.__ModuleLoader__.load({
|
|
|
1057
1031
|
setBackupBusy(action); setBackupMsg(''); setBackupErr('')
|
|
1058
1032
|
return api('POST', '/dsh-remote/backup', Object.assign({ action }, backupMachine ? { machineId: backupMachine } : {}, body || {}))
|
|
1059
1033
|
.then((r) => {
|
|
1060
|
-
if (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
|
+
}
|
|
1061
1042
|
if (action === 'create' && r && r.created) {
|
|
1062
|
-
setBackupMsg(tr('settings.
|
|
1043
|
+
setBackupMsg(tr('settings.backupCreatedN', { n: (r.created.workspaces || []).length, file: r.created.name }))
|
|
1063
1044
|
} else if (action === 'restore' && r && r.restored) {
|
|
1064
|
-
setBackupMsg(tr('settings.backupRestored', { file: r.restored.file, target: r.restored.
|
|
1045
|
+
setBackupMsg(tr('settings.backupRestored', { file: r.restored.file, target: (r.restored.targets || []).join('、') })
|
|
1046
|
+
+ (r.warning ? ' — ' + r.warning : ''))
|
|
1065
1047
|
} else if (action === 'delete') {
|
|
1066
1048
|
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 }))
|
|
1049
|
+
} else if (action === 'download' && r && (r.localPath || (r.restored && r.restored.file))) {
|
|
1050
|
+
setBackupMsg(tr('settings.backupDownloaded', { path: r.localPath || body.file }))
|
|
1071
1051
|
} else if (action === 'verify' && r) {
|
|
1072
1052
|
setBackupMsg(r.matches === false || r.verified === false
|
|
1073
1053
|
? tr('settings.backupVerifyMismatch')
|
|
@@ -1437,14 +1417,18 @@ window.__ModuleLoader__.load({
|
|
|
1437
1417
|
? React.createElement('div', { style: { color: T.ok, marginTop: 4 } }, tr('settings.deployInvestigateStarted') + ' (' + deploy.sessionId + ')')
|
|
1438
1418
|
: null,
|
|
1439
1419
|
) : null,
|
|
1440
|
-
//
|
|
1441
|
-
//
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1420
|
+
// Deploy is ALWAYS available — not hidden behind a prior probe.
|
|
1421
|
+
//
|
|
1422
|
+
// It used to render only when a probe had already run AND reported
|
|
1423
|
+
// something wrong (`deploy.verdict.canAutoInstall && !deploy.verdict.ok`),
|
|
1424
|
+
// which meant a user who only ever saw 体检 could never deploy: the
|
|
1425
|
+
// button that does the work was waiting behind a check they had no
|
|
1426
|
+
// reason to run. The host action re-probes internally anyway, so it is
|
|
1427
|
+
// safe to offer directly; clicking it is the explicit consent.
|
|
1428
|
+
React.createElement('button', { type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: { ...primaryBtnS, marginTop: 6 },
|
|
1429
|
+
disabled: !!deployBusy || !webAttachMachine,
|
|
1430
|
+
onClick: () => { if (window.confirm(tr('settings.deployConfirm'))) deployAction('install', webAttachMachine, { confirm: true }) } },
|
|
1431
|
+
deployBusy === 'install' ? tr('settings.deployInstalling') : tr('settings.deployInstall')),
|
|
1448
1432
|
// The AI escape hatch: offered after a failure, and ONLY on click —
|
|
1449
1433
|
// it costs a model call, so it is never automatic.
|
|
1450
1434
|
(deployErr || (deploy && deploy.ok === false))
|
|
@@ -1461,94 +1445,78 @@ window.__ModuleLoader__.load({
|
|
|
1461
1445
|
) : React.createElement('div', { style: { fontSize: 11, opacity: 0.55 } }, deploy && deploy.verdict ? (deploy.verdict.ok ? tr('settings.deployOk') : tr('settings.deployBlocked')) : ''),
|
|
1462
1446
|
),
|
|
1463
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).
|
|
1464
1454
|
React.createElement('div', { style: boxS },
|
|
1465
1455
|
sectionToggle(backupOpen, setBackupOpen, tr('settings.backup'),
|
|
1466
1456
|
backupData ? React.createElement('span', { style: { marginLeft: 'auto', fontSize: 11, opacity: 0.6 } },
|
|
1467
|
-
tr('settings.backupCount', { n: (backupData.entries || []).length
|
|
1457
|
+
tr('settings.backupCount', { n: (backupData.entries || []).length })) : null),
|
|
1468
1458
|
backupOpen ? React.createElement(React.Fragment, null,
|
|
1469
1459
|
React.createElement('div', { style: { opacity: 0.65, fontSize: 11, lineHeight: 1.5, marginBottom: 6 } }, tr('settings.backupHint')),
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
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' } },
|
|
1476
1473
|
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:
|
|
1474
|
+
React.createElement('select', { className: 'dsh-rw-input', value: backupMachine, onChange: (e) => setBackupMachine(e.target.value), style: { ...inputS, maxWidth: 220 } },
|
|
1478
1475
|
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
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
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
1484
|
React.createElement('select', {
|
|
1485
|
-
className: 'dsh-rw-input',
|
|
1486
|
-
|
|
1487
|
-
|
|
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 },
|
|
1488
1489
|
},
|
|
1489
|
-
React.createElement('option', { value: '
|
|
1490
|
-
React.createElement('option', { value:
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
React.createElement('
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
style: { ...
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
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,
|
|
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,
|
|
1550
1518
|
) : backupData ? React.createElement('div', { style: { fontSize: 11, opacity: 0.55 } },
|
|
1551
|
-
tr('settings.backupCount', { n: (backupData.entries || []).length
|
|
1519
|
+
tr('settings.backupCount', { n: (backupData.entries || []).length })) : null,
|
|
1552
1520
|
),
|
|
1553
1521
|
// 端口转发面板
|
|
1554
1522
|
React.createElement('div', { style: boxS },
|
package/lib/index.js
CHANGED
|
@@ -1311,6 +1311,60 @@ export async function apply(ctx, config) {
|
|
|
1311
1311
|
/** Local directory holding archives pulled from one machine. */
|
|
1312
1312
|
const localBackupRootFor = (b) => localBackupDirFor(mirrorRootFor(b.host, b.username, b.port))
|
|
1313
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
|
+
|
|
1314
1368
|
// ── tools ─────────────────────────────────────────────────────────────────
|
|
1315
1369
|
|
|
1316
1370
|
const textOut = {
|
|
@@ -2820,6 +2874,9 @@ export async function apply(ctx, config) {
|
|
|
2820
2874
|
}
|
|
2821
2875
|
},
|
|
2822
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 }),
|
|
2823
2880
|
}),
|
|
2824
2881
|
|
|
2825
2882
|
{
|
package/lib/routes-backup.js
CHANGED
|
@@ -1,21 +1,20 @@
|
|
|
1
|
-
// Settings-panel routes for workspace
|
|
1
|
+
// Settings-panel routes for workspace backup / restore.
|
|
2
2
|
//
|
|
3
3
|
// Kept in its own module (like lib/routes-fs.js) so the route surface stays
|
|
4
4
|
// readable and the whole thing is unit-testable with an injected binding — the
|
|
5
5
|
// production wiring in lib/index.js supplies the real SSH pool, audit log and
|
|
6
6
|
// configuration.
|
|
7
7
|
//
|
|
8
|
-
// Scope:
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// Scope: the machine/session-scoped JSON API behind Settings → 工作区备份. The
|
|
9
|
+
// panel deliberately exposes only two actions (create / restore) plus
|
|
10
|
+
// list-verify-delete housekeeping; the model-facing equivalents are the
|
|
11
|
+
// rw_backup / rw_backup_list / rw_restore tools, which share lib/backup.js.
|
|
12
12
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
listBackups, verifyBackup, deleteBackup, pullBackup, listLocalBackups,
|
|
14
|
+
deleteLocalBackup, verifyLocalBackup, resolveRemoteHome, describeCreate,
|
|
15
|
+
createAllBackup, restoreAllBackup,
|
|
16
16
|
} from './backup.js'
|
|
17
|
-
import {
|
|
18
|
-
import { describeCreate } from './backup.js'
|
|
17
|
+
import { resolveBackupDir, isSafeArchiveName, DEFAULT_MAX_TRANSFER_BYTES } from './archive.js'
|
|
19
18
|
|
|
20
19
|
/** Default cap on a cross-machine archive copy; the archive itself is uncapped. */
|
|
21
20
|
export { DEFAULT_MAX_TRANSFER_BYTES }
|
|
@@ -29,10 +28,12 @@ export { DEFAULT_MAX_TRANSFER_BYTES }
|
|
|
29
28
|
* @param {Function} deps.resolveRequestBinding - (req, body) => binding (see routes-fs)
|
|
30
29
|
* @param {Function} deps.resolveMachineBinding - (machineId) => binding for an explicit machine
|
|
31
30
|
* @param {Function} deps.localRootFor - (binding) => local directory for pulled archives
|
|
31
|
+
* @param {Function} deps.workspacesFor - (binding) => string[] of REMOTE workspace paths
|
|
32
32
|
* @returns {Array<object>} route definitions
|
|
33
33
|
*/
|
|
34
34
|
export function createBackupRoutes({
|
|
35
35
|
sendJson, readBody, config, audit, resolveRequestBinding, resolveMachineBinding, localRootFor,
|
|
36
|
+
workspacesFor,
|
|
36
37
|
}) {
|
|
37
38
|
const maxTransfer = Number(config.maxBackupTransferBytes) > 0
|
|
38
39
|
? Number(config.maxBackupTransferBytes)
|
|
@@ -76,8 +77,15 @@ export function createBackupRoutes({
|
|
|
76
77
|
}
|
|
77
78
|
const localDir = localRootFor(b)
|
|
78
79
|
const local = listLocalBackups(localDir)
|
|
80
|
+
// Which remote workspaces this backup WOULD include — shown before the user
|
|
81
|
+
// clicks, so "back up all my workspaces" is never a surprise.
|
|
82
|
+
let covers = []
|
|
83
|
+
try {
|
|
84
|
+
covers = typeof workspacesFor === 'function' ? (workspacesFor(b) || []) : (b.ws ? [b.ws] : [])
|
|
85
|
+
} catch { covers = b.ws ? [b.ws] : [] }
|
|
79
86
|
return {
|
|
80
87
|
workspace: b.ws || '',
|
|
88
|
+
covers,
|
|
81
89
|
host: b.host,
|
|
82
90
|
username: b.username,
|
|
83
91
|
port: b.port,
|
|
@@ -135,47 +143,30 @@ export function createBackupRoutes({
|
|
|
135
143
|
|
|
136
144
|
try {
|
|
137
145
|
if (action === 'create') {
|
|
138
|
-
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (requested && b.ws && requested !== b.ws
|
|
144
|
-
&& !requested.startsWith(b.ws.replace(/\/+$/, '') + '/')) {
|
|
145
|
-
return sendJson(res, 400, { ok: false, error: '只允许备份当前工作区目录(或其子目录)' })
|
|
146
|
-
}
|
|
147
|
-
if (requested) dir = requested
|
|
148
|
-
if (!dir) return sendJson(res, 400, { ok: false, error: '没有远程工作区可备份——先选择一个远程工作区' })
|
|
149
|
-
const dest = ['remote', 'local', 'both'].includes(String(body.dest)) ? String(body.dest) : 'remote'
|
|
146
|
+
// ONE button: archive every remote workspace the sidebar knows about
|
|
147
|
+
// for this machine. There is no per-workspace choice, no exclude box
|
|
148
|
+
// and no destination picker on purpose — the default exclude set
|
|
149
|
+
// (dependency/build trees) is the only thing worth deciding, and it
|
|
150
|
+
// lives in code where it is reviewable.
|
|
150
151
|
const backupDir = await backupDirFor(b)
|
|
151
152
|
if (!backupDir) return sendJson(res, 400, { ok: false, error: '无法确定远端 $HOME,请配置 backupDir' })
|
|
152
|
-
|
|
153
|
-
|
|
153
|
+
let covers = []
|
|
154
|
+
try { covers = (typeof workspacesFor === 'function' ? workspacesFor(b) : []) || [] } catch { covers = [] }
|
|
155
|
+
if (!covers.length && b.ws) covers = [b.ws]
|
|
156
|
+
if (!covers.length) {
|
|
157
|
+
return sendJson(res, 400, { ok: false, error: '没有可备份的远程工作区——先在侧边栏里选一个远程工作区' })
|
|
158
|
+
}
|
|
159
|
+
const result = await createAllBackup(b.pool, {
|
|
160
|
+
workspaces: covers,
|
|
154
161
|
backupDir,
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
meta: { username: b.username, host: b.host, port: b.port },
|
|
162
|
+
machine: { host: b.host, username: b.username, port: b.port },
|
|
163
|
+
label: 'all',
|
|
158
164
|
})
|
|
159
165
|
if (!result.ok) {
|
|
160
|
-
audit('backup', `backup ${
|
|
161
|
-
return sendJson(res, 500, { ok: false, error: result.error
|
|
162
|
-
}
|
|
163
|
-
audit('backup', `backup ${dir} → ${result.archive}`, 0, b)
|
|
164
|
-
let pulled = null
|
|
165
|
-
let pullError = ''
|
|
166
|
-
if (dest === 'local' || dest === 'both') {
|
|
167
|
-
pulled = await pullBackup(b.pool, { archive: result.archive, localDir: localRootFor(b), maxTransferBytes: maxTransfer })
|
|
168
|
-
if (!pulled.ok) pullError = pulled.error
|
|
169
|
-
else audit('backup', `pull ${result.name} → ${pulled.localPath}`, 0, b)
|
|
170
|
-
}
|
|
171
|
-
// Local-only means the remote copy is a staging step: drop it so the
|
|
172
|
-
// "dest: local" promise is honoured (and say so if removal failed).
|
|
173
|
-
let remoteRemoved = null
|
|
174
|
-
if (dest === 'local' && result.ok) {
|
|
175
|
-
const del = await deleteBackup(b.pool, { archive: result.archive })
|
|
176
|
-
remoteRemoved = !!del.ok
|
|
177
|
-
if (del.ok) audit('backup', `removed remote staging copy ${result.name}`, 0, b)
|
|
166
|
+
audit('backup', `backup ${covers.length} workspace(s) failed: ${result.error}`, 1, b)
|
|
167
|
+
return sendJson(res, 500, { ok: false, error: result.error })
|
|
178
168
|
}
|
|
169
|
+
audit('backup', `backup ${covers.length} workspace(s) → ${result.archive}`, 0, b)
|
|
179
170
|
return sendJson(res, 200, {
|
|
180
171
|
ok: true,
|
|
181
172
|
created: {
|
|
@@ -185,13 +176,9 @@ export function createBackupRoutes({
|
|
|
185
176
|
members: result.members,
|
|
186
177
|
sha256: result.sha256,
|
|
187
178
|
verified: result.verified,
|
|
188
|
-
|
|
179
|
+
workspaces: covers,
|
|
189
180
|
},
|
|
190
|
-
summary: describeCreate(result, { dest }),
|
|
191
|
-
localPath: pulled && pulled.ok ? pulled.localPath : '',
|
|
192
|
-
pullError,
|
|
193
|
-
remoteRemoved,
|
|
194
|
-
droppedExcludes: parsed.dropped,
|
|
181
|
+
summary: describeCreate(result, { dest: 'remote' }),
|
|
195
182
|
...(await collect(b)),
|
|
196
183
|
})
|
|
197
184
|
}
|
|
@@ -202,35 +189,18 @@ export function createBackupRoutes({
|
|
|
202
189
|
if (body.confirm !== true) return sendJson(res, 400, { ok: false, error: '恢复需要显式确认(confirm: true)' })
|
|
203
190
|
const file = String(body.file || '')
|
|
204
191
|
if (!isSafeArchiveName(file)) return sendJson(res, 400, { ok: false, error: '无效的备份文件名' })
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if (where === 'local') {
|
|
212
|
-
// A pulled copy is restored by pushing it back first: the extract
|
|
213
|
-
// itself must happen where the workspace lives.
|
|
214
|
-
const localDir = localRootFor(b)
|
|
215
|
-
const up = await pushBackup(b.pool, {
|
|
216
|
-
localPath: `${localDir.replace(/\/+$/, '')}/${file}`,
|
|
217
|
-
remoteDir: await backupDirFor(b),
|
|
218
|
-
maxTransferBytes: maxTransfer,
|
|
219
|
-
})
|
|
220
|
-
if (!up.ok) return sendJson(res, 500, { ok: false, error: up.error })
|
|
221
|
-
uploaded = up.remotePath
|
|
222
|
-
archive = up.remotePath
|
|
223
|
-
} else {
|
|
224
|
-
archive = `${(await backupDirFor(b)).replace(/\/+$/, '')}/${file}`
|
|
225
|
-
}
|
|
226
|
-
const r = await restoreBackup(b.pool, { archive, target, mode })
|
|
227
|
-
audit('restore', `${mode} restore ${file} → ${target}`, r.ok ? 0 : 1, b)
|
|
228
|
-
if (!r.ok) return sendJson(res, 500, { ok: false, error: r.error, mode, target, uploaded })
|
|
192
|
+
const dir = await backupDirFor(b)
|
|
193
|
+
if (!dir) return sendJson(res, 400, { ok: false, error: '无法确定远端备份目录' })
|
|
194
|
+
const archive = `${dir.replace(/\/+$/, '')}/${file}`
|
|
195
|
+
const r = await restoreAllBackup(b.pool, { archive, fallbackRoot: '/tmp' })
|
|
196
|
+
audit('restore', `restore ${file} (${r.members} workspace(s))`, r.ok ? 0 : 1, b)
|
|
197
|
+
if (!r.ok) return sendJson(res, 500, { ok: false, error: r.error })
|
|
229
198
|
return sendJson(res, 200, {
|
|
230
199
|
ok: true,
|
|
231
|
-
restored: { file,
|
|
232
|
-
|
|
233
|
-
|
|
200
|
+
restored: { file, targets: r.entries, count: r.members },
|
|
201
|
+
text: `已恢复 ${r.members} 个工作区:${(r.entries || []).join('、')}`,
|
|
202
|
+
// A partial restore reports what moved AND what failed.
|
|
203
|
+
warning: r.error || '',
|
|
234
204
|
...(await collect(b)),
|
|
235
205
|
})
|
|
236
206
|
}
|
package/lib/update.js
CHANGED
|
@@ -395,10 +395,19 @@ export async function reloadSelf(loader) {
|
|
|
395
395
|
//
|
|
396
396
|
// 保留对 _dispose/dispose 的探测:那是宿主可能提供的扩展钩子,有则优先用,
|
|
397
397
|
// 没有就用 fiber.dispose()——两条路都必须能真正卸载,否则应如实报错。
|
|
398
|
+
// DSH 0.2 的 loader 在 fiber 被卸载时把 entry.options.disabled 写成 true
|
|
399
|
+
// 并写回配置树。客户端扫描会跳过禁用行,于是热切换之后宿主能起来、
|
|
400
|
+
// 设置页的 client 不再下发。这里记下卸载前的状态:只有 loader 在
|
|
401
|
+
// dispose 期间新打上的禁用才清掉。用户本来就禁用的行不要重新打开。
|
|
402
|
+
const disabledBefore = entry.options?.disabled === true
|
|
398
403
|
if (typeof entry._dispose === 'function') await entry._dispose()
|
|
399
404
|
else if (typeof entry.dispose === 'function') await entry.dispose()
|
|
400
405
|
else if (entry.fiber && typeof entry.fiber.dispose === 'function') await entry.fiber.dispose()
|
|
401
406
|
else return { ok: false, reason: 'entry exposes no dispose hook' }
|
|
407
|
+
if (!disabledBefore && entry.options?.disabled === true) {
|
|
408
|
+
delete entry.options.disabled
|
|
409
|
+
entry.parent?.tree?.write?.()
|
|
410
|
+
}
|
|
402
411
|
await entry.init()
|
|
403
412
|
return { ok: true, cleared }
|
|
404
413
|
} catch (err) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote",
|
|
3
|
-
"version": "0.8.
|
|
4
|
-
"description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with 25 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync,
|
|
3
|
+
"version": "0.8.40",
|
|
4
|
+
"description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with 25 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, one-click backup/restore of every sidebar workspace, remote dsh deploy, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "flymysql <flyphp@outlook.com>",
|
|
7
7
|
"readme": "README.md",
|