dsh-remote 0.8.41 → 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/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': '钥匙串',
@@ -392,6 +400,14 @@ window.__ModuleLoader__.load({
392
400
  'settings.deployUseCommand': 'dsh that will be used',
393
401
  'settings.deploySteps': 'Install steps',
394
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',
395
411
  'settings.deployInvestigateStarted': 'A session was created — follow up there.',
396
412
  'settings.badgePwd': 'password',
397
413
  'settings.badgeKeychain': 'keychain',
@@ -860,6 +876,10 @@ window.__ModuleLoader__.load({
860
876
  const [deployBusy, setDeployBusy] = React.useState('')
861
877
  const [deployErr, setDeployErr] = React.useState('')
862
878
  const [deployOpen, setDeployOpen] = React.useState(false)
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)
863
883
  // session's machine", which the host resolves from the session binding.
864
884
  React.useEffect(() => { ensureChromeCss() }, [])
865
885
 
@@ -953,10 +973,31 @@ window.__ModuleLoader__.load({
953
973
  // ── 远程 DSH 界面(issue #46)─────────────────────────────────────────
954
974
  // The remote never opens an inbound port; we SSH out and carry the remote
955
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.
956
981
  const refreshWebAttaches = () => api('GET', '/dsh-remote/web-attach')
957
- .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
+ })
958
993
  .catch(() => {})
959
- 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
+ }, [])
960
1001
  const webAttachAction = (action, machineId, extra) => {
961
1002
  setWebAttachBusy(machineId + ':' + action); setWebAttachErr('')
962
1003
  return api('POST', '/dsh-remote/web-attach', { action, machineId, ...(extra || {}) })
@@ -968,23 +1009,60 @@ window.__ModuleLoader__.load({
968
1009
  // ── 远程部署:体检 / 部署并验证 / 让 AI 排查 ───────────────────────────
969
1010
  // The probe is read-only, so it is safe to run before the user agrees to
970
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
+
971
1040
  const deployAction = (action, machineId, extra) => {
972
- setDeployBusy(action); setDeployErr('')
1041
+ setDeployBusy(action); setDeployErr(''); setDeployMsg('')
973
1042
  return api('POST', '/dsh-remote/web-attach', { action, machineId, ...(extra || {}) })
974
1043
  .then((r) => {
975
1044
  if (action === 'probe') setDeploy({ kind: 'probe', ...r, at: Date.now() })
976
- 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
+ }
977
1051
  if (action === 'investigate') setDeploy({ kind: 'investigate', ...r, at: Date.now() })
978
1052
  return r
979
1053
  })
980
1054
  .catch((e) => {
981
1055
  const msg = String((e && e.message) || e)
982
1056
  setDeployErr(msg)
983
- // Keep the failing step list so the operator sees WHERE it stopped.
984
1057
  setDeploy({ kind: action, ok: false, error: msg, at: Date.now() })
985
1058
  return null
986
1059
  })
987
- .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
+ })
988
1066
  }
989
1067
 
990
1068
  const setF = (k) => (ev) => setForm({ ...form, [k]: ev.target.value })
@@ -1273,6 +1351,46 @@ window.__ModuleLoader__.load({
1273
1351
  disabled: !!deployBusy || !webAttachMachine,
1274
1352
  onClick: () => deployAction('probe', webAttachMachine) },
1275
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,
1276
1394
  // Verdict from the last probe/install, rendered as findings with the
1277
1395
  // severity the backend assigned — the UI never re-classifies them.
1278
1396
  deploy ? React.createElement('div', { style: { fontSize: 12, lineHeight: 1.6 } },
@@ -1317,9 +1435,13 @@ window.__ModuleLoader__.load({
1317
1435
  // reason to run. The host action re-probes internally anyway, so it is
1318
1436
  // safe to offer directly; clicking it is the explicit consent.
1319
1437
  React.createElement('button', { type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: { ...primaryBtnS, marginTop: 6 },
1320
- disabled: !!deployBusy || !webAttachMachine,
1438
+ disabled: !webAttachMachine,
1321
1439
  onClick: () => { if (window.confirm(tr('settings.deployConfirm'))) deployAction('install', webAttachMachine, { confirm: true }) } },
1322
- 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')),
1323
1445
  // The AI escape hatch: offered after a failure, and ONLY on click —
1324
1446
  // it costs a model call, so it is never automatic.
1325
1447
  (deployErr || (deploy && deploy.ok === false))
@@ -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
+ }
package/lib/index.js CHANGED
@@ -40,6 +40,7 @@ import { syncTree, pushTree, loadSyncState, saveSyncState, pushOneFile } from '.
40
40
  import { searchRemote } from './search.js'
41
41
  import { resolveMirror, poolKey, lookupSessionCwd, encodeSegmentSafe, readSessionHeaderCwd, requestSessionHint } from './binding.js'
42
42
  import { TaskManager } from './tasks.js'
43
+ import { DeployTasks } from './deploy-tasks.js'
43
44
  import { ForwardManager } from './forwards.js'
44
45
  import { WebAttach } from './web-attach.js'
45
46
  import {
@@ -849,6 +850,10 @@ export async function apply(ctx, config) {
849
850
  // starting a second remote server. Lifecycle is owned by the plugin fiber, so
850
851
  // unloading the plugin tears every tunnel down.
851
852
  const webAttaches = new Map() // machineId → WebAttach
853
+ // Deploy tasks run on the HOST, detached from any request: closing the browser
854
+ // window mid-install must not lose the progress, and a re-opened page must be
855
+ // able to re-attach and see the live state (high-availability requirement).
856
+ const deployTasks = new DeployTasks()
852
857
  ctx.effect(() => () => {
853
858
  for (const attach of webAttaches.values()) { try { attach.close() } catch { /* already gone */ } }
854
859
  webAttaches.clear()
@@ -3076,10 +3081,15 @@ export async function apply(ctx, config) {
3076
3081
  const q = new URL(req.url, 'http://localhost').searchParams
3077
3082
  const id = q.get('machineId') || ''
3078
3083
  const attach = id ? webAttaches.get(id) : undefined
3084
+ // Deploy tasks ride along so a REOPENED window sees an in-flight
3085
+ // install immediately, without a separate poll's first tick.
3086
+ const deploy = id ? deployTasks.forMachine(id) : null
3079
3087
  return sendJson(res, 200, {
3080
3088
  ok: true,
3081
3089
  attach: attach ? attach.describe() : null,
3082
3090
  attaches: [...webAttaches.values()].map((a) => a.describe()),
3091
+ deploys: deployTasks.list(),
3092
+ deploy: deploy ? deployTasks.describe(deploy) : null,
3083
3093
  })
3084
3094
  }
3085
3095
  if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
@@ -3124,75 +3134,113 @@ export async function apply(ctx, config) {
3124
3134
  })
3125
3135
  }
3126
3136
 
3127
- // install
3137
+ // install — runs as a HOST-SIDE BACKGROUND TASK.
3138
+ //
3139
+ // It used to be one long HTTP request; npm can legitimately take
3140
+ // minutes, and closing the tab mid-deploy lost the response AND
3141
+ // every trace of the progress — the user could not tell whether it
3142
+ // succeeded, and a second click started a CONCURRENT npm install
3143
+ // into the same prefix. Now the work runs detached from the request
3144
+ // lifecycle, every step's outcome is recorded as it happens, and
3145
+ // the UI re-attaches by polling install-status (or by clicking the
3146
+ // button again, which resumes rather than restarts).
3128
3147
  if (body.confirm !== true) {
3129
3148
  return sendJson(res, 400, { ok: false, error: '安装需要在请求里显式 confirm: true' })
3130
3149
  }
3131
- const pout = await target.exec(buildProbeCommand({ prefix, installedCommand, defaultPrefixFor: config.webInstallPrefix ? '' : 'home' }), { timeoutMs: 30000 })
3132
- const facts = parseProbe(pout && pout.stdout)
3133
- const verdict = judgeProbe(facts, { installedCommand })
3134
- if (!verdict.canAutoInstall) {
3135
- const why = verdict.findings.filter((x) => x.severity === 'blocker').map((x) => x.summary).join(';')
3136
- audit('web-deploy', `install ${machine.host} refused: ${why}`, 1, machine)
3137
- return sendJson(res, 400, { ok: false, error: `无法自动安装:${why || '远端缺少前提条件'}`, verdict })
3150
+ // RESUME: a running/queued task for this machine is returned as-is.
3151
+ // One deploy per machine at a time — two concurrent `npm --prefix`
3152
+ // into the same directory corrupt the tree.
3153
+ const already = deployTasks.forMachine(machineId)
3154
+ if (already && (already.status === 'queued' || already.status === 'running') && body.force !== true) {
3155
+ return sendJson(res, 200, { ok: true, resumed: true, task: deployTasks.describe(already) })
3138
3156
  }
3139
- const installPrefix = facts.resolvedPrefix || resolvePrefix(facts, { prefix: config.webInstallPrefix || '' })
3140
- // A registry the user set explicitly wins; otherwise the remote's own
3141
- // npm config is left alone, which is what an internal mirror needs.
3142
- // `--registry` is only added when configured — overriding a working
3143
- // corporate mirror with the public one would be a regression.
3144
- const registry = String(body.registry || config.webInstallRegistry || '').trim()
3145
- // Proxy comes from the remote's own environment; the operator may
3146
- // override it per-install when the inherited one is wrong.
3147
- const env = body.proxy && typeof body.proxy === 'object'
3148
- ? Object.fromEntries(Object.entries(body.proxy).filter(([, v]) => typeof v === 'string'))
3149
- : undefined
3150
- const plan = buildInstallPlan({
3151
- facts,
3152
- prefix: installPrefix,
3153
- version: String(body.version || config.webInstallVersion || DEFAULT_INSTALL_VERSION),
3154
- registry,
3155
- env,
3156
- })
3157
- const results = []
3158
- for (const step of plan.steps) {
3159
- let res2
3160
- try {
3161
- res2 = await target.exec(step.command, {
3162
- timeoutMs: step.timeoutMs || 120000,
3163
- // Per-step env exists for the proxy override (P4); pool.exec
3164
- // passes it through to the remote process.
3165
- ...(step.env ? { env: step.env } : {}),
3166
- })
3167
- } catch (err) {
3168
- results.push({ id: step.id, title: step.title, ok: false, code: -1, output: String((err && err.message) || err) })
3169
- break
3170
- }
3171
- const r = stepResult(step, res2)
3172
- results.push(r)
3173
- if (!r.ok) break
3174
- }
3175
- const ok = results.length === plan.steps.length && results.every((r) => r.ok)
3176
- if (ok) {
3177
- // Persist so the attach flow uses the working build from now on.
3178
- persistWebAttachCommand(machine, plan.command)
3179
- audit('web-deploy', `install ${machine.host} → ${plan.command}`, 0, machine)
3180
- return sendJson(res, 200, { ok: true, steps: results, command: plan.command, prefix: installPrefix })
3157
+ if (already && body.force !== true && already.status !== 'done' && already.status !== 'failed' && already.status !== 'cancelled') {
3158
+ return sendJson(res, 200, { ok: true, resumed: true, task: deployTasks.describe(already) })
3181
3159
  }
3182
- const failed = results.find((r) => !r.ok)
3183
- audit('web-deploy', `install ${machine.host} failed at ${failed && failed.id}`, 1, machine)
3184
- // A failure that looks like a network/registry problem gets the AI
3185
- // escape hatch suggested, since that is precisely the long tail the
3186
- // deterministic path cannot classify.
3187
- const looksNetworky = /ETIMEDOUT|ENOTFOUND|ECONNREFUSED|network|proxy|403|404/i.test(String(failed && failed.output))
3188
- return sendJson(res, 500, {
3189
- ok: false,
3190
- error: `安装失败于「${failed ? failed.title : '未知步骤'}」`,
3191
- steps: results,
3192
- prefix: installPrefix,
3193
- suggestInvestigate: true,
3194
- ...(looksNetworky ? { hint: '看起来是网络或源的问题,可以让 AI 排查。' } : {}),
3160
+ const { task } = deployTasks.start(machineId, `deploy ${machine.host}`, async (ctx) => {
3161
+ // Everything below is the SAME plan as before, but progress goes
3162
+ // to the task (visible while it runs) instead of one response.
3163
+ const pout = await target.exec(buildProbeCommand({ prefix, installedCommand, defaultPrefixFor: config.webInstallPrefix ? '' : 'home' }), { timeoutMs: 30000 })
3164
+ const facts = parseProbe(pout && pout.stdout)
3165
+ const verdict = judgeProbe(facts, { installedCommand })
3166
+ if (!verdict.canAutoInstall) {
3167
+ const why = verdict.findings.filter((x) => x.severity === 'blocker').map((x) => x.summary).join(';')
3168
+ audit('web-deploy', `install ${machine.host} refused: ${why}`, 1, machine)
3169
+ throw new Error(`无法自动安装:${why || '远端缺少前提条件'}`)
3170
+ }
3171
+ const installPrefix = facts.resolvedPrefix || resolvePrefix(facts, { prefix: config.webInstallPrefix || '' })
3172
+ // A registry the user set explicitly wins; otherwise the remote's
3173
+ // own npm config is left alone (internal mirrors keep working).
3174
+ const registry = String(body.registry || config.webInstallRegistry || '').trim()
3175
+ const env = body.proxy && typeof body.proxy === 'object'
3176
+ ? Object.fromEntries(Object.entries(body.proxy).filter(([, v]) => typeof v === 'string'))
3177
+ : undefined
3178
+ const plan = buildInstallPlan({
3179
+ facts,
3180
+ prefix: installPrefix,
3181
+ version: String(body.version || config.webInstallVersion || DEFAULT_INSTALL_VERSION),
3182
+ registry,
3183
+ env,
3184
+ })
3185
+ ctx.progress({ totalSteps: plan.steps.length, prefix: installPrefix })
3186
+ const results = []
3187
+ for (const step of plan.steps) {
3188
+ if (ctx.cancelled) break
3189
+ task.current = step.title
3190
+ ctx.progress({ doneSteps: results.length })
3191
+ let res2
3192
+ try {
3193
+ res2 = await target.exec(step.command, {
3194
+ timeoutMs: step.timeoutMs || 120000,
3195
+ ...(step.env ? { env: step.env } : {}),
3196
+ })
3197
+ } catch (err) {
3198
+ results.push({ id: step.id, title: step.title, ok: false, code: -1, output: String((err && err.message) || err) })
3199
+ break
3200
+ }
3201
+ const r = stepResult(step, res2)
3202
+ results.push(r)
3203
+ task.steps = [...results]
3204
+ if (!r.ok) break
3205
+ }
3206
+ task.steps = [...results]
3207
+ const ok = results.length === plan.steps.length && results.every((r) => r.ok)
3208
+ if (ok) {
3209
+ // Persist so the attach flow uses the working build from now on.
3210
+ persistWebAttachCommand(machine, plan.command)
3211
+ audit('web-deploy', `install ${machine.host} → ${plan.command}`, 0, machine)
3212
+ return { ok: true, steps: results, command: plan.command, prefix: installPrefix }
3213
+ }
3214
+ const failed = results.find((r) => !r.ok)
3215
+ audit('web-deploy', `install ${machine.host} failed at ${failed && failed.id}`, 1, machine)
3216
+ const looksNetworky = /ETIMEDOUT|ENOTFOUND|ECONNREFUSED|network|proxy|403|404/i.test(String(failed && failed.output))
3217
+ const err = new Error(`安装失败于「${failed ? failed.title : '未知步骤'}」`)
3218
+ err.detail = {
3219
+ steps: results,
3220
+ prefix: installPrefix,
3221
+ suggestInvestigate: true,
3222
+ ...(looksNetworky ? { hint: '看起来是网络或源的问题,可以让 AI 排查。' } : {}),
3223
+ }
3224
+ throw err
3195
3225
  })
3226
+ // Answer immediately: the task object IS the handle the UI polls.
3227
+ return sendJson(res, 200, { ok: true, started: true, task: deployTasks.describe(task) })
3228
+ }
3229
+
3230
+ // install-status: re-attach to a deploy at ANY time (window reopened,
3231
+ // page refreshed). Also carried by GET /dsh-remote/web-attach.
3232
+ if (action === 'install-status') {
3233
+ const t = deployTasks.forMachine(machineId)
3234
+ if (!t) return sendJson(res, 200, { ok: true, task: null })
3235
+ return sendJson(res, 200, { ok: true, task: deployTasks.describe(t) })
3236
+ }
3237
+
3238
+ // install-cancel: cooperative cancel between steps.
3239
+ if (action === 'install-cancel') {
3240
+ const t = deployTasks.forMachine(machineId)
3241
+ if (!t) return sendJson(res, 200, { ok: true, cancelled: false })
3242
+ const did = deployTasks.cancel(machineId)
3243
+ return sendJson(res, 200, { ok: true, cancelled: did, task: deployTasks.describe(t) })
3196
3244
  }
3197
3245
 
3198
3246
  if (action === 'open') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.8.41",
3
+ "version": "0.8.42",
4
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 22 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, one-click remote dsh deploy + web UI attach, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.",
5
5
  "license": "MIT",
6
6
  "author": "flymysql <flyphp@outlook.com>",