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.
@@ -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, () => {})