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 +130 -8
- package/lib/deploy-tasks.js +142 -0
- package/lib/index.js +111 -63
- package/package.json +1 -1
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) =>
|
|
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(() => {
|
|
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')
|
|
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) => {
|
|
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:
|
|
1438
|
+
disabled: !webAttachMachine,
|
|
1321
1439
|
onClick: () => { if (window.confirm(tr('settings.deployConfirm'))) deployAction('install', webAttachMachine, { confirm: true }) } },
|
|
1322
|
-
|
|
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
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
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
|
-
|
|
3140
|
-
|
|
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
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
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.
|
|
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>",
|