dsh-remote 0.8.39 → 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 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
- - **Compressed workspace backup / restore** (`rw_backup` / `rw_backup_list` / `rw_restore`) — archive a remote workspace to `.tar.gz` (everything is included by default; pass excludes for `node_modules`, `/build/`, `*.log` and friends). The archive can stay on the remote under `$HOME/.dsh-remote/backups`, be pulled to this machine, or **both**. Before restoring, the archive is verified; **a corrupt archive is refused and the target is left untouched**. `mode=replace` (default) swaps the whole tree, `mode=merge` only overwrites without deleting. The settings page has a **Backup** card too (create / download / restore / delete).
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[] | `[]` | Paths excluded from backups by default (gitignore-style). **Empty by default**: silently omitting directories is how a restore becomes silent data loss |
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
- - **工作区压缩备份 / 恢复**(`rw_backup` / `rw_backup_list` / `rw_restore`)—— 把远程工作区打成 `.tar.gz`(默认全部收录,另可按参数排除 `node_modules`、`/build/`、`*.log` 等);归档可留在远端 `$HOME/.dsh-remote/backups`,也可拉到本机或**两边都留**。恢复前先校验归档可读,**归档损坏则拒绝执行、目标目录原封不动**;`mode=replace`(默认)整树替换、`mode=merge` 只覆盖不删除。设置页里也有「备份」卡片(新建 / 下载 / 恢复 / 删除)。
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、不覆盖你在用的版本,删目录即回滚)并逐步校验。失败还能交给内置的 `dsh-remote-deploy` 技能排查。
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[] | `[]` | 备份默认排除的路径(gitignore 风格)。**默认为空**:静默少打包目录正是「恢复即静默丢数据」的成因 |
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': '工作区备份(.tar.gz)',
192
- 'settings.backupHint': '把远程工作区打包成 .tar.gz。默认**全部收录**;需要更小的归档时再填排除项。归档可留在远端、拉到本机、或两边都留。',
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.backupExcludes': '排除项(每行一个)',
196
- 'settings.backupExcludesPlaceholder': 'node_modules\n/build/\n*.log',
197
- 'settings.backupDest': '存放位置',
198
- 'settings.backupDestRemote': '仅远端',
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.backupDirUnknown': '(无法确定远端备份目录)',
203
- 'settings.backupNone': '还没有备份。点「立即备份」创建第一个。',
200
+ 'settings.backupNone': '还没有备份。点「备份」创建第一个。',
204
201
  'settings.backupCount': '{n} 个备份',
205
- 'settings.backupWhereRemote': '远端',
206
- 'settings.backupWhereLocal': '本机',
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.backupNoSidecar': '无 sidecar(早期归档,元数据未知)',
219
- 'settings.backupRestored': '已恢复 {file} → {target}',
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 (.tar.gz)',
489
- 'settings.backupHint': 'Archive the remote workspace to a .tar.gz. **Everything is included by default**; add excludes only when you want a smaller archive. The archive can stay on the remote, be pulled here, or both.',
490
- 'settings.backupCreate': 'Back up now',
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.backupExcludes': 'Excludes (one per line)',
493
- 'settings.backupExcludesPlaceholder': 'node_modules\n/build/\n*.log',
494
- 'settings.backupDest': 'Store in',
495
- 'settings.backupDestRemote': 'Remote only',
496
- 'settings.backupDestLocal': 'Local only',
497
- 'settings.backupDestBoth': 'Both',
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.backupDirUnknown': '(cannot determine the remote backup directory)',
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.backupWhereRemote': 'Remote',
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': 'Overwrite the remote workspace with {file}? Files not present in the archive will be removed (merge mode deletes nothing).',
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.backupNoSidecar': 'No sidecar (older archive; metadata unknown)',
516
- 'settings.backupRestored': 'Restored {file} → {target}',
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
- const [backupExcludes, setBackupExcludes] = React.useState('')
950
- const [backupDest, setBackupDest] = React.useState('remote')
951
- // Restore mode is a panel-level choice rather than a per-row prompt: the
952
- // two modes differ in whether files absent from the archive are DELETED,
953
- // so it is worth showing which one is armed before clicking Restore.
954
- const [backupRestoreMode, setBackupRestoreMode] = React.useState('replace')
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) setBackupData(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.backupCreated', { file: r.created.name }) + (r.pullError ? ' — ' + tr('settings.backupOverCap') + ' (' + r.pullError + ')' : ''))
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.target }))
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
- // The install button only appears when a probe says it is both needed
1441
- // and possible; clicking it is the explicit consent (confirm: true).
1442
- deploy && deploy.verdict && deploy.verdict.canAutoInstall && !deploy.verdict.ok
1443
- ? React.createElement('button', { type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: { ...primaryBtnS, marginTop: 6 },
1444
- disabled: !!deployBusy,
1445
- onClick: () => { if (window.confirm(tr('settings.deployConfirm'))) deployAction('install', webAttachMachine, { confirm: true }) } },
1446
- deployBusy === 'install' ? tr('settings.deployInstalling') : tr('settings.deployInstall'))
1447
- : null,
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 + ((backupData.local && backupData.local.entries) || []).length })) : null),
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
- React.createElement('div', { style: { fontSize: 11, opacity: 0.7, marginBottom: 6, wordBreak: 'break-all' } },
1471
- backupData && backupData.backupDir ? tr('settings.backupDirLabel', { dir: backupData.backupDir }) : tr('settings.backupDirUnknown')),
1472
- // Machine selector: empty means "this session's machine", which the
1473
- // host resolves from the session binding (the panel is reachable from
1474
- // a local session too, hence an explicit choice is always available).
1475
- React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', marginBottom: 6, flexWrap: 'wrap' } },
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: 200 } },
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
- React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px' },
1481
- disabled: !!backupBusy, onClick: () => refreshBackups() }, tr('settings.backupRefresh')),
1482
- // Which restore mode the Restore button will use.
1483
- React.createElement('span', { style: { fontSize: 12, opacity: 0.8, marginLeft: 4 } }, tr('settings.backupRestore') + ':'),
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', value: backupRestoreMode,
1486
- onChange: (e) => setBackupRestoreMode(e.target.value),
1487
- style: { ...inputS, maxWidth: 170 },
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: 'replace' }, tr('settings.backupModeReplace')),
1490
- React.createElement('option', { value: 'merge' }, tr('settings.backupModeMerge')))),
1491
- // Excludes + destination, then the create button.
1492
- React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'flex-start', flexWrap: 'wrap', marginBottom: 6 } },
1493
- React.createElement('div', { style: { flex: '1 1 260px', minWidth: 220 } },
1494
- React.createElement('div', { style: { fontSize: 11, opacity: 0.75, marginBottom: 2 } }, tr('settings.backupExcludes')),
1495
- React.createElement('textarea', {
1496
- className: 'dsh-rw-input', value: backupExcludes,
1497
- onChange: (e) => setBackupExcludes(e.target.value),
1498
- placeholder: tr('settings.backupExcludesPlaceholder'),
1499
- rows: 3, style: { ...inputS, width: '100%', fontFamily: 'monospace', resize: 'vertical' },
1500
- })),
1501
- React.createElement('div', null,
1502
- React.createElement('div', { style: { fontSize: 11, opacity: 0.75, marginBottom: 2 } }, tr('settings.backupDest')),
1503
- React.createElement('select', { className: 'dsh-rw-input', value: backupDest, onChange: (e) => setBackupDest(e.target.value), style: { ...inputS, maxWidth: 160 } },
1504
- React.createElement('option', { value: 'remote' }, tr('settings.backupDestRemote')),
1505
- React.createElement('option', { value: 'local' }, tr('settings.backupDestLocal')),
1506
- React.createElement('option', { value: 'both' }, tr('settings.backupDestBoth'))),
1507
- React.createElement('button', {
1508
- type: 'button', className: 'dsh-rw-btn dsh-rw-primary',
1509
- style: { ...primaryBtnS, marginTop: 6, display: 'block' },
1510
- disabled: !!backupBusy,
1511
- onClick: () => backupAction('create', { excludes: backupExcludes, dest: backupDest }),
1512
- }, backupBusy === 'create' ? tr('settings.backupCreating') : tr('settings.backupCreate')))),
1513
- // The list: remote archives then local copies. A sidecar-less archive
1514
- // is labelled instead of showing invented metadata.
1515
- (backupData && (backupData.entries || []).length) || (backupData && backupData.local && (backupData.local.entries || []).length)
1516
- ? React.createElement('div', { style: { maxHeight: 280, overflowY: 'auto' } },
1517
- (backupData.entries || []).map((e) => React.createElement('div', { key: 'r-' + e.name, className: 'dsh-rw-row', style: { display: 'flex', gap: 6, alignItems: 'center', padding: '5px 0', borderBottom: '1px solid ' + T.border, fontSize: 12, flexWrap: 'wrap' } },
1518
- React.createElement('span', { style: { fontFamily: 'monospace', flex: '1 1 220px', wordBreak: 'break-all' } }, e.name),
1519
- React.createElement('span', { style: { opacity: 0.7, minWidth: 60 } }, humanSize(e.bytes)),
1520
- React.createElement('span', { style: { opacity: 0.7 } }, e.createdAt || e.mtime || ''),
1521
- React.createElement('span', { style: { opacity: 0.55, fontSize: 11 } }, tr('settings.backupWhereRemote')),
1522
- e.hasMeta ? null : React.createElement('span', { style: { color: T.warn, fontSize: 11 } }, tr('settings.backupNoSidecar')),
1523
- React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 8px' }, disabled: !!backupBusy,
1524
- onClick: () => backupAction('verify', { file: e.name }) }, tr('settings.backupVerify')),
1525
- React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 8px' }, disabled: !!backupBusy,
1526
- onClick: () => backupAction('download', { file: e.name }) }, tr('settings.backupDownload')),
1527
- // Restore is destructive, so it asks first and sends the
1528
- // explicit confirmation the host requires.
1529
- React.createElement('button', { type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: { ...primaryBtnS, padding: '2px 8px' }, disabled: !!backupBusy,
1530
- onClick: () => {
1531
- if (!window.confirm(tr('settings.backupRestoreConfirm', { file: e.name }))) return
1532
- backupAction('restore', { file: e.name, confirm: true, mode: backupRestoreMode })
1533
- } }, tr('settings.backupRestore')),
1534
- React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 8px', color: T.dangerText }, disabled: !!backupBusy,
1535
- onClick: () => { if (window.confirm(tr('settings.backupDeleteConfirm', { file: e.name }))) backupAction('delete', { file: e.name }) } }, tr('settings.backupDelete')))),
1536
- ((backupData.local && backupData.local.entries) || []).map((e) => React.createElement('div', { key: 'l-' + e.name, className: 'dsh-rw-row', style: { display: 'flex', gap: 6, alignItems: 'center', padding: '5px 0', borderBottom: '1px solid ' + T.border, fontSize: 12, opacity: 0.92, flexWrap: 'wrap' } },
1537
- React.createElement('span', { style: { fontFamily: 'monospace', flex: '1 1 220px', wordBreak: 'break-all' } }, e.name),
1538
- React.createElement('span', { style: { opacity: 0.7, minWidth: 60 } }, humanSize(e.bytes)),
1539
- React.createElement('span', { style: { opacity: 0.7 } }, e.createdAt || e.mtime || ''),
1540
- React.createElement('span', { style: { opacity: 0.55, fontSize: 11 } }, tr('settings.backupWhereLocal')),
1541
- React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 8px' }, disabled: !!backupBusy,
1542
- onClick: () => backupAction('verify', { file: e.name, where: 'local' }) }, tr('settings.backupVerify')),
1543
- React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 8px' }, disabled: !!backupBusy,
1544
- onClick: () => backupAction('upload', { file: e.name }) }, tr('settings.backupUpload')),
1545
- React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 8px', color: T.dangerText }, disabled: !!backupBusy,
1546
- onClick: () => { if (window.confirm(tr('settings.backupDeleteConfirm', { file: e.name }))) backupAction('delete', { file: e.name, where: 'local' }) } }, tr('settings.backupDelete')))))
1547
- : React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, tr('settings.backupNone')),
1548
- backupMsg ? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: T.ok, wordBreak: 'break-all' } }, backupMsg) : null,
1549
- backupErr ? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: T.danger, wordBreak: 'break-all' } }, backupErr) : null,
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 + ((backupData.local && backupData.local.entries) || []).length })) : null,
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
  {
@@ -1,21 +1,20 @@
1
- // Settings-panel routes for workspace compressed backup / restore.
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: this is the machine/session-scoped JSON API behind Settings → 备份.
9
- // The model-facing equivalents are the rw_backup / rw_backup_list / rw_restore
10
- // tools, which resolve their own session binding and share the same lib/backup.js
11
- // implementation — one behaviour, two entrances.
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
- createBackup, listBackups, verifyBackup, deleteBackup, restoreBackup,
14
- pullBackup, pushBackup, listLocalBackups, deleteLocalBackup, verifyLocalBackup,
15
- resolveRemoteHome,
13
+ listBackups, verifyBackup, deleteBackup, pullBackup, listLocalBackups,
14
+ deleteLocalBackup, verifyLocalBackup, resolveRemoteHome, describeCreate,
15
+ createAllBackup, restoreAllBackup,
16
16
  } from './backup.js'
17
- import { parseExcludes, resolveBackupDir, isSafeArchiveName, formatBytes, DEFAULT_MAX_TRANSFER_BYTES } from './archive.js'
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
- const parsed = parseExcludes(body.excludes)
139
- // The UI may target a subdirectory of the workspace; it must stay
140
- // inside it, otherwise "backup" could archive the whole home dir.
141
- let dir = b.ws || ''
142
- const requested = String(body.path || '').trim()
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
- const result = await createBackup(b.pool, {
153
- dir,
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
- label: String(body.label || 'manual'),
156
- excludes: parsed.patterns,
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 ${dir} failed: ${result.error}`, 1, b)
161
- return sendJson(res, 500, { ok: false, error: result.error, droppedExcludes: parsed.dropped })
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
- dest,
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 mode = body.mode === 'merge' ? 'merge' : 'replace'
206
- const target = String(body.target || '').trim() || b.ws || ''
207
- if (!target || target === '/') return sendJson(res, 400, { ok: false, error: '没有恢复目标目录' })
208
- const where = body.where === 'local' ? 'local' : 'remote'
209
- let archive = ''
210
- let uploaded = null
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, mode: r.mode, target, members: r.members, from: where },
232
- uploaded,
233
- text: `已${r.mode === 'merge' ? '合并' : '替换'}恢复 ${file} → ${target}(${r.members} 个文件)`,
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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.8.39",
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, workspace backup/restore, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.",
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",