dsh-remote 0.8.41 → 0.8.44
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 +5 -0
- package/README.md +5 -0
- package/lib/client.js +464 -18
- package/lib/deploy-tasks.js +142 -0
- package/lib/forwards.js +22 -0
- package/lib/index.js +339 -93
- package/lib/openssh.js +838 -0
- package/lib/pool.js +139 -54
- package/lib/registry.js +6 -0
- package/lib/sftp-surface.js +76 -0
- package/package.json +2 -1
|
@@ -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/forwards.js
CHANGED
|
@@ -155,6 +155,22 @@ export class ForwardManager {
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
_startReverse(d, client) {
|
|
158
|
+
// The system-OpenSSH backend (issue #51) cannot do ssh2's two-phase
|
|
159
|
+
// `forwardIn` + `tcpip`: the `ssh -R` CLI needs the final target as an
|
|
160
|
+
// argument, so it exposes `reverseForward()` instead. Prefer it when
|
|
161
|
+
// present — otherwise every reverse tunnel would fail on that backend.
|
|
162
|
+
if (typeof client.reverseForward === 'function') {
|
|
163
|
+
return client.reverseForward({
|
|
164
|
+
bindHost: '127.0.0.1',
|
|
165
|
+
listenPort: d.listenPort,
|
|
166
|
+
targetHost: d.targetHost,
|
|
167
|
+
targetPort: d.targetPort,
|
|
168
|
+
}).then((handle) => {
|
|
169
|
+
this.servers.set(d.id, { kind: 'reverse-cli', handle, client })
|
|
170
|
+
}).catch((err) => {
|
|
171
|
+
throw new Error(`远端监听 ${d.listenPort} 失败: ${(err && err.message) || err}`)
|
|
172
|
+
})
|
|
173
|
+
}
|
|
158
174
|
return new Promise((resolve, reject) => {
|
|
159
175
|
// ssh2 calls the remote TCP listen API `forwardIn` (NOT openssh_forwardIn,
|
|
160
176
|
// which in ssh2 >= 1.16 is stream-local only).
|
|
@@ -170,6 +186,9 @@ export class ForwardManager {
|
|
|
170
186
|
}
|
|
171
187
|
|
|
172
188
|
_handleTcpip(info, accept, reject) {
|
|
189
|
+
// Only the ssh2 backend reaches here: `ssh -R` handles the remote listener
|
|
190
|
+
// and its targets entirely inside the CLI, so a 'reverse-cli' entry never
|
|
191
|
+
// produces a 'tcpip' event.
|
|
173
192
|
for (const entry of this.servers.values()) {
|
|
174
193
|
if (entry.kind === 'reverse' && Number(info.destPort) === Number(this._defFor(entry).listenPort)) {
|
|
175
194
|
const channel = accept()
|
|
@@ -205,6 +224,9 @@ export class ForwardManager {
|
|
|
205
224
|
if (entry.kind === 'local') {
|
|
206
225
|
entry.server.close()
|
|
207
226
|
for (const s of entry.sockets) { try { s.destroy() } catch {} }
|
|
227
|
+
} else if (entry.kind === 'reverse-cli') {
|
|
228
|
+
// The `ssh -R` child IS the tunnel: killing it closes the remote listener.
|
|
229
|
+
if (entry.handle && typeof entry.handle.close === 'function') entry.handle.close()
|
|
208
230
|
} else if (entry.client && typeof entry.client.unforwardIn === 'function') {
|
|
209
231
|
const d = this._defFor(entry)
|
|
210
232
|
entry.client.unforwardIn('127.0.0.1', d.listenPort, () => {})
|