dsh-remote 0.8.40 → 0.8.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -39,7 +39,7 @@ The native **"Add workspace"** flow — a centered modal with two tabs, opening
39
39
 
40
40
  ## Features
41
41
 
42
- ![Capabilities at a glance — multi-machine SSH, live alias resolution, the two-tab picker, three-way sync, remote @ completion, a remote DSH Web UI in your local browser, health check & one-click deploy, audit, port forwarding, sidebar editing, self-update, and the 24 rw_* tools](docs/shots/features.png)
42
+ ![Capabilities at a glance — multi-machine SSH, live alias resolution, the two-tab picker, three-way sync, remote @ completion, a remote DSH Web UI in your local browser, health check & one-click deploy, audit, port forwarding, sidebar editing, self-update, and the 22 rw_* tools](docs/shots/features.png)
43
43
 
44
44
  The image above is the overview. What follows is only what the image does not make obvious.
45
45
 
@@ -56,8 +56,7 @@ The image above is the overview. What follows is only what the image does not ma
56
56
 
57
57
  The rest:
58
58
 
59
- - **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
- - **Workspace backup / restore (two buttons, nothing to configure)** — the settings page offers just **Back up** and **Restore**: backing up archives **every remote workspace in the sidebar into ONE** `.tar.gz`, and restoring brings them all back from the point in time you pick. Dependency/build directories (`node_modules`, `.git`, `dist`, `build`, `target`, `__pycache__`, `.venv` … both top-level and nested) are excluded automatically. The model can do the same through `rw_backup` / `rw_backup_list` / `rw_restore`. **The archive is verified before anything is written — a corrupt or truncated archive is refused and nothing is touched**; each workspace's previous contents are moved aside first and restored automatically if the swap fails.
59
+ - **22 model tools** (listed so they can be copied or searched): `rw_info`, `rw_connect`, `rw_machines`, `rw_pick_workspace`, `rw_list_dir`, `rw_stat`, `rw_read_file`, `rw_write_file`, `rw_edit`, `rw_append`, `rw_mkdir`, `rw_remove`, `rw_move`, `rw_exec`, `rw_search`, `rw_download`, `rw_upload`, `rw_sync`, `rw_push`, `rw_forward`, `rw_disconnect`, `rw_deploy_probe`.
61
60
  - **Open a remote machine's DSH Web UI locally** (`0.8.36+`) — nothing has to be exposed on the remote: the plugin SSHes out, starts a `dsh web` there that binds **`127.0.0.1` only**, and tunnels its port back to a loopback port on this machine. The URL carries a one-time login token, consumed once by a redirect.
62
61
  - **One-click health check & deploy** (`0.8.36+`) — **a failed connection is usually the remote dsh version or environment, not the plugin.** The read-only check reports platform / node / npm / dsh version / **whether the native module can load** / proxy and npm registry, with a suggested fix; "Deploy & verify" installs into a remote **private directory** (no system directory, no PATH change, no replacing the version you use — deleting it is a complete rollback) and verifies step by step. The button **no longer requires running the check first** (`0.8.40+`) — the deploy re-checks internally. If that still fails, the built-in `dsh-remote-deploy` skill takes over.
63
62
  - **Cross-platform remotes** — all file access is SFTP-protocol-level (no POSIX shell), so Linux/macOS/Windows remotes all work.
@@ -264,9 +263,6 @@ Deploying to a product profile is a separate, explicit action (`./sync.sh`) for
264
263
  | `webInstallPrefix` | string | `''` | where an automatic deployment installs dsh; empty means `$HOME/.dsh-remote/dsh` (no system directory, no PATH change) |
265
264
  | `webInstallVersion` | string | `0.1.5-rc.2` | dsh version an automatic deployment installs; the default is the first that can start the web surface on Linux |
266
265
  | `webInstallRegistry` | string | `''` | npm registry for the install; **empty keeps the remote's own config**, so an internal mirror is never overridden |
267
- | `backupDir` | string | `''` | Where backup archives are written **on the remote**; empty means `$HOME/.dsh-remote/backups` (resolved on the remote, never guessed from this machine's home) |
268
- | `backupExcludes` | string[] | `[]` | Extra paths excluded **on top of the built-in set** (gitignore-style). No longer surfaced in the UI; the built-in set covers `node_modules`/`.git`/`dist`/`build`/`target`/`__pycache__`/`.venv` and friends, top-level and nested |
269
- | `maxBackupTransferBytes` | int | `2147483648` | Cap (bytes) on moving an archive between this machine and the remote; **the remote archive is never truncated** — this only bounds the optional cross-machine copy. 0 = unlimited |
270
266
 
271
267
  > The authoritative list is the `Config` schema in `lib/index.js`; this table mirrors it.
272
268
 
package/README.md CHANGED
@@ -39,14 +39,13 @@ DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0.
39
39
 
40
40
  ## 功能
41
41
 
42
- ![核心能力总览 — 多机 SSH / 别名实时解析 / 双 tab 选择器 / 三路同步 / 远程 @ 补全 / 远端 DSH 界面挂到本机 / 体检与一键部署 / 安全审计 / 端口转发 / 侧栏编辑 / 自动更新,以及 25 个 rw_* 工具](docs/shots/features.png)
42
+ ![核心能力总览 — 多机 SSH / 别名实时解析 / 双 tab 选择器 / 三路同步 / 远程 @ 补全 / 远端 DSH 界面挂到本机 / 体检与一键部署 / 安全审计 / 端口转发 / 侧栏编辑 / 自动更新,以及 22 个 rw_* 工具](docs/shots/features.png)
43
43
 
44
44
  上图是能力总览;下面只列**上图没说清、但用起来需要知道**的部分。
45
45
 
46
46
  其余要点:
47
47
 
48
- - **25 个模型工具**(便于复制/检索):`rw_info`、`rw_connect`、`rw_machines`、`rw_pick_workspace`、`rw_list_dir`、`rw_stat`、`rw_read_file`、`rw_write_file`、`rw_edit`、`rw_append`、`rw_mkdir`、`rw_remove`、`rw_move`、`rw_exec`、`rw_search`、`rw_download`、`rw_upload`、`rw_sync`、`rw_push`、`rw_backup`、`rw_backup_list`、`rw_restore`、`rw_forward`、`rw_disconnect`、`rw_deploy_probe`。
49
- - **工作区备份 / 恢复(一个按钮就够)** —— 设置页里只有「备份」和「恢复」两个按钮:**备份**把侧边栏里的所有远程工作区打包成**一个** `.tar.gz`,**恢复**用选中的那个时间点把全部工作区还原。依赖/构建目录(`node_modules`、`.git`、`dist`、`build`、`target`、`__pycache__`、`.venv`… 顶层与嵌套)自动排除,不需要你配置。模型侧仍可用 `rw_backup` / `rw_backup_list` / `rw_restore` 做同样的事。**恢复前先校验归档可读,归档损坏或截断就拒绝执行、目标一个字节都不动**;每个工作区的旧目录会先移到一旁,换入失败会自动还原。
48
+ - **22 个模型工具**(便于复制/检索):`rw_info`、`rw_connect`、`rw_machines`、`rw_pick_workspace`、`rw_list_dir`、`rw_stat`、`rw_read_file`、`rw_write_file`、`rw_edit`、`rw_append`、`rw_mkdir`、`rw_remove`、`rw_move`、`rw_exec`、`rw_search`、`rw_download`、`rw_upload`、`rw_sync`、`rw_push`、`rw_forward`、`rw_disconnect`、`rw_deploy_probe`。
50
49
  - **把远端机器的 DSH 界面挂到本机**(`0.8.36+`)—— 不用在远端开任何端口:插件主动 SSH 连过去,在远端**只监听 `127.0.0.1`** 地起一个 `dsh web`,再把端口经隧道搬回本机的一个 loopback 端口。地址带一次性登录令牌,只在本机这次跳转里用一次。
51
50
  - **连不上时一键体检与部署**(`0.8.36+`)—— **连不上大多不是插件的问题,而是远端 dsh 的版本或环境不对**。体检(只读)会报出平台/node/npm/dsh 版本、**原生模块能否启动**、代理与 npm 源,并给出修复建议;「部署并验证」把它装到远端**私有目录**(不写系统目录、不改 PATH、不覆盖你在用的版本,删目录即回滚)并逐步校验。按钮**不再需要先体检**——直接点即可(`0.8.40+`),部署过程自己会重新体检。失败还能交给内置的 `dsh-remote-deploy` 技能排查。
52
51
  - **远端跨平台** —— 文件访问走 SFTP 协议层(不依赖 POSIX shell),Linux/macOS/Windows 远端都能列/读/写/搜索/同步。
@@ -246,9 +245,6 @@ scripts/dev-run.sh --status # 是否在运行
246
245
  | `webInstallPrefix` | string | `''` | 自动部署装到远端的哪个目录;留空用 `$HOME/.dsh-remote/dsh`(不写系统目录、不改 PATH) |
247
246
  | `webInstallVersion` | string | `0.1.5-rc.2` | 自动部署安装的 dsh 版本;默认值是第一个在 Linux 上能启动 web 的版本 |
248
247
  | `webInstallRegistry` | string | `''` | 安装用的 npm 源;**留空沿用远端自己的配置**,不会覆盖内网镜像 |
249
- | `backupDir` | string | `''` | 备份归档在**远端**的存放目录;留空用 `$HOME/.dsh-remote/backups`(在远端解析,不用本机 home 猜) |
250
- | `backupExcludes` | string[] | `[]` | 在**内置排除集之外**额外排除的路径(gitignore 风格)。设置页不再暴露此项;内置集已覆盖 `node_modules`/`.git`/`dist`/`build`/`target`/`__pycache__`/`.venv` 等(顶层与嵌套) |
251
- | `maxBackupTransferBytes` | int | `2147483648` | 归档在本机与远端之间搬运的大小上限(字节);**远端的归档从不截断**,只约束可选的对拷。0 = 不限 |
252
248
 
253
249
  > 权威清单是 `lib/index.js` 里的 `Config` schema,本表与之一致。
254
250
 
package/lib/client.js CHANGED
@@ -134,6 +134,14 @@ window.__ModuleLoader__.load({
134
134
  'settings.deployUseCommand': '将使用的 dsh',
135
135
  'settings.deploySteps': '安装步骤',
136
136
  'settings.deployDone': '部署完成,已记录该机器使用的 dsh',
137
+ 'settings.deployFailed': '部署失败',
138
+ 'settings.deployResumed': '已有部署任务在运行,已恢复显示其进度',
139
+ 'settings.deployTaskTitle': '部署任务',
140
+ 'settings.deployTaskRunning': '进行中',
141
+ 'settings.deployTaskDone': '已完成',
142
+ 'settings.deployTaskFailed': '失败',
143
+ 'settings.deployTaskCancelled': '已取消',
144
+ 'settings.deployCancel': '取消',
137
145
  'settings.deployInvestigateStarted': '已创建排查会话,请在会话里跟进。',
138
146
  'settings.badgePwd': '密码',
139
147
  'settings.badgeKeychain': '钥匙串',
@@ -187,33 +195,6 @@ window.__ModuleLoader__.load({
187
195
  'settings.modeAuto': '自动',
188
196
  'settings.modeOff': '关闭',
189
197
  'settings.modeManual': '手动',
190
- // ── 工作区备份(rw_backup / rw_restore)────────────────────────────
191
- 'settings.backup': '工作区备份',
192
- 'settings.backupHint': '一键把侧边栏里的所有远程工作区打包成一个 .tar.gz;恢复时用同一个归档把全部工作区还原。依赖/构建目录(node_modules、.git、dist、build…)会自动排除。',
193
- 'settings.backupCreate': '备份',
194
- 'settings.backupCreating': '备份中…',
195
- 'settings.backupRestore': '恢复',
196
- 'settings.backupRestoring': '恢复中…',
197
- 'settings.backupPickOne': '— 选择要恢复的备份时间点 —',
198
- 'settings.backupCovers': '本次将备份 {n} 个工作区:',
199
- 'settings.backupDirLabel': '远端备份目录:{dir}',
200
- 'settings.backupNone': '还没有备份。点「备份」创建第一个。',
201
- 'settings.backupCount': '{n} 个备份',
202
- 'settings.backupEntries': '{n} 个条目',
203
- 'settings.backupDownload': '下载到本机',
204
- 'settings.backupDelete': '删除',
205
- 'settings.backupVerify': '校验',
206
- 'settings.backupRestoreConfirm': '要用 {file} 恢复全部工作区吗?归档里没有的文件会被删除(原目录会先移到一旁,换入失败会自动还原)。',
207
- 'settings.backupDeleteConfirm': '删除备份 {file}?此操作不可撤销。',
208
- 'settings.backupVerified': '校验通过:sha256 {sha}',
209
- 'settings.backupVerifyMismatch': '校验失败:哈希与记录不一致(归档可能已损坏)',
210
- 'settings.backupRestored': '已从 {file} 恢复:{target}',
211
- 'settings.backupCreatedN': '已备份 {n} 个工作区 → {file}',
212
- 'settings.backupDownloaded': '已下载到本机:{path}',
213
- 'settings.backupDeleted': '已删除 {file}',
214
- 'settings.backupFailed': '备份操作失败:{msg}',
215
- 'settings.backupMachine': '机器',
216
- 'settings.backupPickMachine': '— 选择机器 —',
217
198
  'picker.title': '选择工作目录',
218
199
  'picker.close': '关闭 ✕',
219
200
  'picker.tabLocal': '本机',
@@ -419,6 +400,14 @@ window.__ModuleLoader__.load({
419
400
  'settings.deployUseCommand': 'dsh that will be used',
420
401
  'settings.deploySteps': 'Install steps',
421
402
  'settings.deployDone': 'Deployed; this machine now records the dsh it uses',
403
+ 'settings.deployFailed': 'Deploy failed',
404
+ 'settings.deployResumed': 'A deploy is already running — re-attached to its progress',
405
+ 'settings.deployTaskTitle': 'Deploy task',
406
+ 'settings.deployTaskRunning': 'running',
407
+ 'settings.deployTaskDone': 'done',
408
+ 'settings.deployTaskFailed': 'failed',
409
+ 'settings.deployTaskCancelled': 'cancelled',
410
+ 'settings.deployCancel': 'Cancel',
422
411
  'settings.deployInvestigateStarted': 'A session was created — follow up there.',
423
412
  'settings.badgePwd': 'password',
424
413
  'settings.badgeKeychain': 'keychain',
@@ -472,33 +461,6 @@ window.__ModuleLoader__.load({
472
461
  'settings.modeAuto': 'Auto',
473
462
  'settings.modeOff': 'Off',
474
463
  'settings.modeManual': 'Manual',
475
- // ── workspace backup (rw_backup / rw_restore) ──────────────────────
476
- 'settings.backup': 'Workspace backup',
477
- 'settings.backupHint': 'One click archives every remote workspace in the sidebar into a single .tar.gz; restore brings them all back from that archive. Dependency/build directories (node_modules, .git, dist, build…) are excluded automatically.',
478
- 'settings.backupCreate': 'Back up',
479
- 'settings.backupCreating': 'Backing up…',
480
- 'settings.backupRestore': 'Restore',
481
- 'settings.backupRestoring': 'Restoring…',
482
- 'settings.backupPickOne': '— choose a backup point in time —',
483
- 'settings.backupCovers': 'This will back up {n} workspace(s):',
484
- 'settings.backupDirLabel': 'Remote backup directory: {dir}',
485
- 'settings.backupNone': 'No backups yet. Click "Back up" to create the first one.',
486
- 'settings.backupCount': '{n} backup(s)',
487
- 'settings.backupEntries': '{n} entries',
488
- 'settings.backupDownload': 'Download',
489
- 'settings.backupDelete': 'Delete',
490
- 'settings.backupVerify': 'Verify',
491
- 'settings.backupRestoreConfirm': 'Restore ALL workspaces from {file}? Files not present in the archive will be removed (each target is moved aside first, and restored automatically if the swap fails).',
492
- 'settings.backupDeleteConfirm': 'Delete the backup {file}? This cannot be undone.',
493
- 'settings.backupVerified': 'Verified: sha256 {sha}',
494
- 'settings.backupVerifyMismatch': 'Verification FAILED: the digest does not match the recorded one (the archive may be corrupt)',
495
- 'settings.backupRestored': 'Restored from {file}: {target}',
496
- 'settings.backupCreatedN': 'Backed up {n} workspace(s) → {file}',
497
- 'settings.backupDownloaded': 'Downloaded locally: {path}',
498
- 'settings.backupDeleted': 'Deleted {file}',
499
- 'settings.backupFailed': 'Backup action failed: {msg}',
500
- 'settings.backupMachine': 'Machine',
501
- 'settings.backupPickMachine': '— choose a machine —',
502
464
  'picker.title': 'Select working directory',
503
465
  'picker.close': 'Close ✕',
504
466
  'picker.tabLocal': 'Local',
@@ -914,19 +876,11 @@ window.__ModuleLoader__.load({
914
876
  const [deployBusy, setDeployBusy] = React.useState('')
915
877
  const [deployErr, setDeployErr] = React.useState('')
916
878
  const [deployOpen, setDeployOpen] = React.useState(false)
917
- // ── workspace backup (rw_backup / rw_restore) ──────────────────────────
918
- // Machine-scoped like the deploy card: `backupMachine` empty means "this
879
+ // The HOST-SIDE deploy task (resumable across window close/reopen). This
880
+ // is the object the poll keeps fresh; it survives because the work runs
881
+ // on the host, not in this tab.
882
+ const [deployTask, setDeployTask] = React.useState(null)
919
883
  // session's machine", which the host resolves from the session binding.
920
- const [backupOpen, setBackupOpen] = React.useState(false)
921
- const [backupData, setBackupData] = React.useState(null)
922
- const [backupBusy, setBackupBusy] = React.useState('')
923
- const [backupMsg, setBackupMsg] = React.useState('')
924
- const [backupErr, setBackupErr] = React.useState('')
925
- // `backupPick` is the archive the Restore/Verify/Delete buttons act on. A
926
- // timestamp picker replaced the old per-row buttons: restoring is the rare
927
- // action, and one selector is all it needs.
928
- const [backupPick, setBackupPick] = React.useState('')
929
- const [backupMachine, setBackupMachine] = React.useState('')
930
884
  React.useEffect(() => { ensureChromeCss() }, [])
931
885
 
932
886
  const testConn = () => {
@@ -1016,56 +970,34 @@ window.__ModuleLoader__.load({
1016
970
  const refreshForwards = () => api('GET', '/dsh-remote/forwards').then((r) => setForwards(r.forwards || [])).catch(() => {})
1017
971
  React.useEffect(() => { refreshForwards(); api('GET', '/dsh-remote/audit?limit=30').then((r) => setAuditLines(r.lines || [])).catch(() => {}) }, [])
1018
972
 
1019
- // ── 工作区备份 ────────────────────────────────────────────────────────
1020
- // Reads/writes go through /dsh-remote/backup. The list carries BOTH the
1021
- // remote archives and any pulled locally, so one refresh answers the panel.
1022
- const backupQuery = (extra) => {
1023
- const q = backupMachine ? 'machineId=' + encodeURIComponent(backupMachine) : ''
1024
- return q ? (extra ? q + '&' + extra : q) : (extra || '')
1025
- }
1026
- const refreshBackups = () => api('GET', '/dsh-remote/backup' + (backupQuery('') ? '?' + backupQuery('') : ''))
1027
- .then((r) => { setBackupData(r); setBackupErr('') })
1028
- .catch((e) => setBackupErr(String((e && e.message) || e)))
1029
- React.useEffect(() => { refreshBackups() }, [backupMachine])
1030
- const backupAction = (action, body) => {
1031
- setBackupBusy(action); setBackupMsg(''); setBackupErr('')
1032
- return api('POST', '/dsh-remote/backup', Object.assign({ action }, backupMachine ? { machineId: backupMachine } : {}, body || {}))
1033
- .then((r) => {
1034
- if (r) {
1035
- setBackupData(r)
1036
- // Keep the picker pointing at something real after a list refresh
1037
- // (a delete/restore changes the set): prefer the current choice,
1038
- // else the newest archive.
1039
- const names = (r.entries || []).map((e) => e.name)
1040
- setBackupPick((cur) => (cur && names.includes(cur) ? cur : (names[0] || '')))
1041
- }
1042
- if (action === 'create' && r && r.created) {
1043
- setBackupMsg(tr('settings.backupCreatedN', { n: (r.created.workspaces || []).length, file: r.created.name }))
1044
- } else if (action === 'restore' && r && r.restored) {
1045
- setBackupMsg(tr('settings.backupRestored', { file: r.restored.file, target: (r.restored.targets || []).join('、') })
1046
- + (r.warning ? ' — ' + r.warning : ''))
1047
- } else if (action === 'delete') {
1048
- setBackupMsg(tr('settings.backupDeleted', { file: body.file }))
1049
- } else if (action === 'download' && r && (r.localPath || (r.restored && r.restored.file))) {
1050
- setBackupMsg(tr('settings.backupDownloaded', { path: r.localPath || body.file }))
1051
- } else if (action === 'verify' && r) {
1052
- setBackupMsg(r.matches === false || r.verified === false
1053
- ? tr('settings.backupVerifyMismatch')
1054
- : tr('settings.backupVerified', { sha: String(r.sha256 || '').slice(0, 16) }))
1055
- }
1056
- return r
1057
- })
1058
- .catch((e) => { setBackupErr(tr('settings.backupFailed', { msg: String((e && e.message) || e) })); return null })
1059
- .then((r) => { setBackupBusy(''); return r })
1060
- }
1061
-
1062
973
  // ── 远程 DSH 界面(issue #46)─────────────────────────────────────────
1063
974
  // The remote never opens an inbound port; we SSH out and carry the remote
1064
975
  // loopback web UI back to a local port. One tunnel per machine.
976
+ //
977
+ // This SAME poll carries the deploy-task state, so a REOPENED window picks
978
+ // up an in-flight install on the first tick (15s worst case — plus the
979
+ // install button itself resumes). When a deploy is running the interval
980
+ // tightens so step progress looks live.
1065
981
  const refreshWebAttaches = () => api('GET', '/dsh-remote/web-attach')
1066
- .then((r) => setWebAttaches(r.attaches || []))
982
+ .then((r) => {
983
+ setWebAttaches(r.attaches || [])
984
+ const t = (r.deploys || []).find((d) => d.machineId === webAttachMachine) || r.deploy || null
985
+ if (t && (t.status === 'running' || t.status === 'queued')) {
986
+ setDeployTask(t)
987
+ setDeployBusy('install')
988
+ } else if (t) {
989
+ setDeployTask(t)
990
+ }
991
+ return t
992
+ })
1067
993
  .catch(() => {})
1068
- React.useEffect(() => { refreshWebAttaches(); const t = setInterval(refreshWebAttaches, 15000); return () => clearInterval(t) }, [])
994
+ React.useEffect(() => {
995
+ refreshWebAttaches()
996
+ const t = setInterval(() => {
997
+ refreshWebAttaches()
998
+ }, 15000)
999
+ return () => clearInterval(t)
1000
+ }, [])
1069
1001
  const webAttachAction = (action, machineId, extra) => {
1070
1002
  setWebAttachBusy(machineId + ':' + action); setWebAttachErr('')
1071
1003
  return api('POST', '/dsh-remote/web-attach', { action, machineId, ...(extra || {}) })
@@ -1077,23 +1009,60 @@ window.__ModuleLoader__.load({
1077
1009
  // ── 远程部署:体检 / 部署并验证 / 让 AI 排查 ───────────────────────────
1078
1010
  // The probe is read-only, so it is safe to run before the user agrees to
1079
1011
  // anything; the install requires an explicit second click (confirm: true).
1012
+ //
1013
+ // Install is a HOST-SIDE BACKGROUND TASK: the call returns immediately
1014
+ // with a task handle, and progress arrives by polling. Closing the window
1015
+ // loses nothing — reopening picks the task back up from the periodic
1016
+ // web-attach poll (which carries the deploy state), and clicking 部署并验证
1017
+ // again while one runs RESUMES it rather than starting a second npm.
1018
+ const deployPoll = () => api('GET', '/dsh-remote/web-attach')
1019
+ .then((r) => {
1020
+ if (!r) return null
1021
+ // Refresh the attach list like before…
1022
+ setWebAttaches(r.attaches || [])
1023
+ // …and adopt any deploy task for the selected machine.
1024
+ const t = r.deploy || (r.deploys || []).find((d) => d.machineId === webAttachMachine) || null
1025
+ if (t) {
1026
+ setDeployTask(t)
1027
+ if (t.status === 'running' || t.status === 'queued') {
1028
+ setDeployBusy('install')
1029
+ } else if (deployBusy === 'install') {
1030
+ // The task settled since the last poll.
1031
+ setDeployBusy('')
1032
+ if (t.status === 'done') setDeployMsg(tr('settings.deployDone'))
1033
+ if (t.status === 'failed') setDeployErr(t.error || tr('settings.deployFailed'))
1034
+ }
1035
+ }
1036
+ return t
1037
+ })
1038
+ .catch(() => null)
1039
+
1080
1040
  const deployAction = (action, machineId, extra) => {
1081
- setDeployBusy(action); setDeployErr('')
1041
+ setDeployBusy(action); setDeployErr(''); setDeployMsg('')
1082
1042
  return api('POST', '/dsh-remote/web-attach', { action, machineId, ...(extra || {}) })
1083
1043
  .then((r) => {
1084
1044
  if (action === 'probe') setDeploy({ kind: 'probe', ...r, at: Date.now() })
1085
- if (action === 'install') setDeploy({ kind: 'install', ...r, at: Date.now() })
1045
+ if (action === 'install') {
1046
+ // The task is running on the host NOW. Adopt it immediately so the
1047
+ // steps appear without waiting for the first poll tick.
1048
+ if (r && r.task) setDeployTask(r.task)
1049
+ if (r && r.resumed) setDeployMsg(tr('settings.deployResumed'))
1050
+ }
1086
1051
  if (action === 'investigate') setDeploy({ kind: 'investigate', ...r, at: Date.now() })
1087
1052
  return r
1088
1053
  })
1089
1054
  .catch((e) => {
1090
1055
  const msg = String((e && e.message) || e)
1091
1056
  setDeployErr(msg)
1092
- // Keep the failing step list so the operator sees WHERE it stopped.
1093
1057
  setDeploy({ kind: action, ok: false, error: msg, at: Date.now() })
1094
1058
  return null
1095
1059
  })
1096
- .then((r) => { setDeployBusy(''); refreshWebAttaches(); return r })
1060
+ .then((r) => {
1061
+ if (action !== 'install') setDeployBusy('')
1062
+ // install keeps busy='install' until the task settles (poll clears it).
1063
+ refreshWebAttaches()
1064
+ return r
1065
+ })
1097
1066
  }
1098
1067
 
1099
1068
  const setF = (k) => (ev) => setForm({ ...form, [k]: ev.target.value })
@@ -1382,6 +1351,46 @@ window.__ModuleLoader__.load({
1382
1351
  disabled: !!deployBusy || !webAttachMachine,
1383
1352
  onClick: () => deployAction('probe', webAttachMachine) },
1384
1353
  deployBusy === 'probe' ? tr('settings.deployProbing') : tr('settings.deployProbe'))),
1354
+ // ── live deploy task (resumable; survives window close) ─────────
1355
+ deployTask
1356
+ ? React.createElement('div', { style: { marginTop: 6, fontSize: 12, lineHeight: 1.6, border: '1px solid ' + T.border, borderRadius: 8, padding: 8 } },
1357
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: 4 } },
1358
+ tr('settings.deployTaskTitle')
1359
+ + ' — ' + (deployTask.status === 'running' || deployTask.status === 'queued'
1360
+ ? tr('settings.deployTaskRunning')
1361
+ : deployTask.status === 'done' ? tr('settings.deployTaskDone')
1362
+ : deployTask.status === 'cancelled' ? tr('settings.deployTaskCancelled')
1363
+ : tr('settings.deployTaskFailed'))),
1364
+ // The in-flight step: the one thing a re-attached user most
1365
+ // wants to see ("现在装到哪了").
1366
+ deployTask.current && (deployTask.status === 'running')
1367
+ ? React.createElement('div', { style: { fontSize: 12, color: T.warn, marginBottom: 4 } }, '▶ ' + deployTask.current)
1368
+ : null,
1369
+ (deployTask.steps || []).length
1370
+ ? React.createElement('div', null,
1371
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: 2, fontSize: 11, opacity: 0.8 } }, tr('settings.deploySteps')),
1372
+ deployTask.steps.map((s) => React.createElement('div', { key: s.id, style: { fontSize: 11, fontFamily: 'monospace', display: 'flex', gap: 6 } },
1373
+ React.createElement('span', { style: { color: s.ok ? T.ok : T.danger } }, s.ok ? '✓' : '✗'),
1374
+ React.createElement('span', null, s.title),
1375
+ !s.ok && s.output ? React.createElement('span', { style: { opacity: 0.75, whiteSpace: 'pre-wrap', wordBreak: 'break-all' } }, ' — ' + String(s.output).slice(0, 300)) : null)))
1376
+ : null,
1377
+ deployTask.status === 'running' || deployTask.status === 'queued'
1378
+ ? React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 6 } },
1379
+ React.createElement('span', { style: { fontSize: 11, opacity: 0.7 } },
1380
+ (deployTask.progress && deployTask.progress.doneSteps || 0) + '/' + (deployTask.progress && deployTask.progress.totalSteps || '?')),
1381
+ React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px' },
1382
+ onClick: () => deployAction('install-cancel', webAttachMachine) }, tr('settings.deployCancel')))
1383
+ : null,
1384
+ // A finished task: continue the journey — connect & open.
1385
+ deployTask.status === 'done'
1386
+ ? React.createElement('div', { style: { marginTop: 6, color: T.ok, fontSize: 12 } },
1387
+ tr('settings.deployDone')
1388
+ + (deployTask.result && deployTask.result.command ? ' · ' + deployTask.result.command : ''))
1389
+ : null,
1390
+ deployTask.status === 'failed'
1391
+ ? React.createElement('div', { style: { marginTop: 6, color: T.danger, fontSize: 12, wordBreak: 'break-all' } }, deployTask.error || tr('settings.deployFailed'))
1392
+ : null)
1393
+ : null,
1385
1394
  // Verdict from the last probe/install, rendered as findings with the
1386
1395
  // severity the backend assigned — the UI never re-classifies them.
1387
1396
  deploy ? React.createElement('div', { style: { fontSize: 12, lineHeight: 1.6 } },
@@ -1426,9 +1435,13 @@ window.__ModuleLoader__.load({
1426
1435
  // reason to run. The host action re-probes internally anyway, so it is
1427
1436
  // safe to offer directly; clicking it is the explicit consent.
1428
1437
  React.createElement('button', { type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: { ...primaryBtnS, marginTop: 6 },
1429
- disabled: !!deployBusy || !webAttachMachine,
1438
+ disabled: !webAttachMachine,
1430
1439
  onClick: () => { if (window.confirm(tr('settings.deployConfirm'))) deployAction('install', webAttachMachine, { confirm: true }) } },
1431
- deployBusy === 'install' ? tr('settings.deployInstalling') : tr('settings.deployInstall')),
1440
+ // While a task runs, the button is the RESUME affordance (disabled
1441
+ // — the poll renders live state; a second click must not restart).
1442
+ (deployTask && (deployTask.status === 'running' || deployTask.status === 'queued'))
1443
+ ? tr('settings.deployInstalling')
1444
+ : deployBusy === 'install' ? tr('settings.deployInstalling') : tr('settings.deployInstall')),
1432
1445
  // The AI escape hatch: offered after a failure, and ONLY on click —
1433
1446
  // it costs a model call, so it is never automatic.
1434
1447
  (deployErr || (deploy && deploy.ok === false))
@@ -1444,80 +1457,6 @@ window.__ModuleLoader__.load({
1444
1457
  deployErr ? React.createElement('div', { style: { color: T.danger, fontSize: 12, marginTop: 6, wordBreak: 'break-all' } }, deployErr) : null,
1445
1458
  ) : React.createElement('div', { style: { fontSize: 11, opacity: 0.55 } }, deploy && deploy.verdict ? (deploy.verdict.ok ? tr('settings.deployOk') : tr('settings.deployBlocked')) : ''),
1446
1459
  ),
1447
- // 工作区备份(rw_backup / rw_backup_list / rw_restore)
1448
- //
1449
- // TWO buttons and a timestamp picker on purpose. The first version exposed
1450
- // excludes / destination / per-archive verify-download-upload-delete, which
1451
- // turned "keep a copy of my workspaces" into a configuration exercise.
1452
- // Everything that needed deciding now lives in code (the exclude set) or
1453
- // is derived (which workspaces = whatever the sidebar shows).
1454
- React.createElement('div', { style: boxS },
1455
- sectionToggle(backupOpen, setBackupOpen, tr('settings.backup'),
1456
- backupData ? React.createElement('span', { style: { marginLeft: 'auto', fontSize: 11, opacity: 0.6 } },
1457
- tr('settings.backupCount', { n: (backupData.entries || []).length })) : null),
1458
- backupOpen ? React.createElement(React.Fragment, null,
1459
- React.createElement('div', { style: { opacity: 0.65, fontSize: 11, lineHeight: 1.5, marginBottom: 6 } }, tr('settings.backupHint')),
1460
- // Say exactly what will be included, before the click.
1461
- backupData && (backupData.covers || []).length
1462
- ? React.createElement('div', { style: { fontSize: 11, opacity: 0.8, marginBottom: 6, wordBreak: 'break-all' } },
1463
- tr('settings.backupCovers', { n: (backupData.covers || []).length }) + ' ' + (backupData.covers || []).join('、'))
1464
- : null,
1465
- backupData && backupData.backupDir
1466
- ? React.createElement('div', { style: { fontSize: 11, opacity: 0.6, marginBottom: 8, wordBreak: 'break-all' } },
1467
- tr('settings.backupDirLabel', { dir: backupData.backupDir }))
1468
- : null,
1469
- // Machine selector: empty means "this session's machine". Kept because
1470
- // the panel is reachable from a local session, where there is no
1471
- // session binding to resolve.
1472
- React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', marginBottom: 8, flexWrap: 'wrap' } },
1473
- React.createElement('span', { style: { fontSize: 12, opacity: 0.8 } }, tr('settings.backupMachine') + ':'),
1474
- React.createElement('select', { className: 'dsh-rw-input', value: backupMachine, onChange: (e) => setBackupMachine(e.target.value), style: { ...inputS, maxWidth: 220 } },
1475
- React.createElement('option', { value: '' }, tr('settings.backupPickMachine')),
1476
- machines.map((m) => React.createElement('option', { key: m.id, value: m.id }, `${m.name || m.host} (${m.username}@${m.host})`)))),
1477
- // ── the two buttons ───────────────────────────────────────────────
1478
- React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
1479
- React.createElement('button', {
1480
- type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: { ...primaryBtnS },
1481
- disabled: !!backupBusy,
1482
- onClick: () => backupAction('create'),
1483
- }, backupBusy === 'create' ? tr('settings.backupCreating') : tr('settings.backupCreate')),
1484
- React.createElement('select', {
1485
- className: 'dsh-rw-input',
1486
- value: backupPick,
1487
- onChange: (e) => setBackupPick(e.target.value),
1488
- style: { ...inputS, flex: '1 1 260px', minWidth: 200, maxWidth: 380 },
1489
- },
1490
- React.createElement('option', { value: '' }, tr('settings.backupPickOne')),
1491
- (backupData && backupData.entries ? backupData.entries : []).map((e, i) => React.createElement('option', { key: e.name, value: e.name },
1492
- `${e.createdAt ? e.createdAt.replace('T', ' ').slice(0, 19) : e.mtime || ''} · ${humanSize(e.bytes)} · ${tr('settings.backupEntries', { n: e.members == null ? '?' : e.members })} · ${e.name}`))),
1493
- React.createElement('button', {
1494
- type: 'button', className: 'dsh-rw-btn',
1495
- style: { ...buttonS },
1496
- disabled: !!backupBusy || !backupPick,
1497
- onClick: () => {
1498
- if (!window.confirm(tr('settings.backupRestoreConfirm', { file: backupPick }))) return
1499
- backupAction('restore', { file: backupPick, confirm: true })
1500
- },
1501
- }, backupBusy === 'restore' ? tr('settings.backupRestoring') : tr('settings.backupRestore'))),
1502
- // Housekeeping stays available but out of the way: verify and delete
1503
- // whatever the picker has selected.
1504
- backupPick
1505
- ? React.createElement('div', { style: { marginTop: 6, display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
1506
- React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px' }, disabled: !!backupBusy,
1507
- onClick: () => backupAction('verify', { file: backupPick }) }, tr('settings.backupVerify')),
1508
- React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px' }, disabled: !!backupBusy,
1509
- onClick: () => backupAction('download', { file: backupPick }) }, tr('settings.backupDownload')),
1510
- React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px', color: T.dangerText }, disabled: !!backupBusy,
1511
- onClick: () => { if (window.confirm(tr('settings.backupDeleteConfirm', { file: backupPick }))) backupAction('delete', { file: backupPick }) } }, tr('settings.backupDelete')))
1512
- : null,
1513
- backupMsg ? React.createElement('div', { style: { marginTop: 8, fontSize: 12, color: T.ok, wordBreak: 'break-all' } }, backupMsg) : null,
1514
- backupErr ? React.createElement('div', { style: { marginTop: 8, fontSize: 12, color: T.danger, wordBreak: 'break-all' } }, backupErr) : null,
1515
- !backupData || !(backupData.entries || []).length
1516
- ? React.createElement('div', { style: { marginTop: 8, opacity: 0.6, fontSize: 12 } }, tr('settings.backupNone'))
1517
- : null,
1518
- ) : backupData ? React.createElement('div', { style: { fontSize: 11, opacity: 0.55 } },
1519
- tr('settings.backupCount', { n: (backupData.entries || []).length })) : null,
1520
- ),
1521
1460
  // 端口转发面板
1522
1461
  React.createElement('div', { style: boxS },
1523
1462
  sectionToggle(fwdOpen, setFwdOpen, tr('settings.forwards')),
@@ -0,0 +1,142 @@
1
+ // dsh-remote — host-side deploy task manager.
2
+ //
3
+ // WHY THIS EXISTS: `install` used to be a single long HTTP request (npm can
4
+ // legitimately take minutes). If the browser tab closed — or the fetch was
5
+ // cancelled — the RESPONSE was lost and, with it, every trace of what the
6
+ // deploy did: the user could not see the progress again, could not tell whether
7
+ // it succeeded, and clicking the button again would start a SECOND concurrent
8
+ // npm install into the same prefix. High availability here means:
9
+ //
10
+ // · the deploy runs on the HOST, not tied to any request lifecycle;
11
+ // · every step's outcome is recorded as it happens;
12
+ // · the UI can re-attach at any time and see the live state;
13
+ // · a second click while one is running RE-ATTACHES instead of restarting;
14
+ // · the result (including the machine's webAttachCommand) is recorded even
15
+ // if no client ever polls for it.
16
+ //
17
+ // One deploy per machine at a time — npm --prefix into the same directory from
18
+ // two processes corrupts the tree. A NEW request for a machine that already has
19
+ // a running or finished deploy returns the EXISTING task (resume semantics);
20
+ // `force: true` starts a fresh one after the previous is finished.
21
+
22
+ /** A deploy task's lifecycle: queued → running → done | failed | cancelled. */
23
+ export class DeployTasks {
24
+ constructor() {
25
+ /** @type {Map<string, object>} machineId → task */
26
+ this.byMachine = new Map()
27
+ /** @type {Map<string, object>} taskId → task (for stable polling) */
28
+ this.byId = new Map()
29
+ this.nextId = 1
30
+ }
31
+
32
+ /**
33
+ * The task for a machine, if one exists in ANY state.
34
+ * @returns {object|null}
35
+ */
36
+ forMachine(machineId) {
37
+ return this.byMachine.get(String(machineId || '')) || null
38
+ }
39
+
40
+ get(id) {
41
+ return this.byId.get(String(id || '')) || null
42
+ }
43
+
44
+ /**
45
+ * Start a deploy for a machine, unless one is already running.
46
+ *
47
+ * @param {string} machineId
48
+ * @param {Function} fn - async (ctx) => result. ctx = { progress(patch) }.
49
+ * @returns {{task: object, reused: boolean}} the task plus whether it already
50
+ * existed (caller reports "resumed" so the UI does not claim a fresh start).
51
+ */
52
+ start(machineId, label, fn) {
53
+ const mid = String(machineId || '')
54
+ const existing = this.byMachine.get(mid)
55
+ if (existing && (existing.status === 'queued' || existing.status === 'running')) {
56
+ return { task: existing, reused: true }
57
+ }
58
+ const id = `deploy-${this.nextId++}`
59
+ const task = {
60
+ id,
61
+ machineId: mid,
62
+ kind: 'deploy',
63
+ label: String(label || ''),
64
+ status: 'queued',
65
+ // Steps are appended as they finish; `steps` is what the UI renders.
66
+ steps: [],
67
+ // The current in-flight step, so a re-attached UI can show "正在安装…"
68
+ // rather than a stale list with no hint that more is coming.
69
+ current: '',
70
+ progress: {},
71
+ result: null,
72
+ error: null,
73
+ startedAt: null,
74
+ finishedAt: null,
75
+ cancelled: false,
76
+ }
77
+ this.byMachine.set(mid, task)
78
+ this.byId.set(id, task)
79
+ // Deliberately NOT queued through the shared TaskManager: that one is
80
+ // single-flight across ALL tasks (one sync at a time), while deploys to
81
+ // DIFFERENT machines must run in parallel.
82
+ task.status = 'running'
83
+ task.startedAt = new Date().toISOString()
84
+ const ctx = {
85
+ progress: (p) => { task.progress = { ...task.progress, ...p } },
86
+ get cancelled() { return !!task.cancelled },
87
+ }
88
+ Promise.resolve()
89
+ .then(() => fn(ctx))
90
+ .then((result) => {
91
+ task.result = result || null
92
+ task.status = task.cancelled ? 'cancelled' : 'done'
93
+ })
94
+ .catch((err) => {
95
+ task.error = String((err && err.message) || err)
96
+ // A step-level failure carries the step list (and hints) as structured
97
+ // data so the UI can render exactly what the old single-response flow
98
+ // rendered — re-attaching must lose nothing.
99
+ if (err && err.detail && typeof err.detail === 'object') task.detail = err.detail
100
+ task.status = task.cancelled ? 'cancelled' : 'failed'
101
+ })
102
+ .finally(() => {
103
+ task.current = ''
104
+ task.finishedAt = new Date().toISOString()
105
+ })
106
+ return { task, reused: false }
107
+ }
108
+
109
+ /** Cooperative cancel (a running npm step still finishes; later steps stop). */
110
+ cancel(machineId) {
111
+ const t = this.byMachine.get(String(machineId || ''))
112
+ if (!t) return false
113
+ if (t.status === 'queued' || t.status === 'running') {
114
+ t.cancelled = true
115
+ return true
116
+ }
117
+ return false
118
+ }
119
+
120
+ /** Serializable view for the UI. */
121
+ describe(t) {
122
+ if (!t) return null
123
+ return {
124
+ id: t.id,
125
+ machineId: t.machineId,
126
+ kind: t.kind,
127
+ status: t.status, // queued | running | done | failed | cancelled
128
+ steps: t.steps, // [{id,title,ok,code,output}]
129
+ current: t.current, // in-flight step title ('' when none)
130
+ progress: t.progress,
131
+ result: t.result,
132
+ error: t.error,
133
+ detail: t.detail || null,
134
+ startedAt: t.startedAt,
135
+ finishedAt: t.finishedAt,
136
+ }
137
+ }
138
+
139
+ list() {
140
+ return [...this.byMachine.values()].map((t) => this.describe(t))
141
+ }
142
+ }