dsh-remote 0.8.72 → 0.8.74
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/LICENSE +21 -21
- package/README.en.md +470 -389
- package/README.md +529 -462
- package/cordis.patch.yml +9 -9
- package/lib/binding.js +172 -172
- package/lib/client.js +4039 -4022
- package/lib/credential.js +263 -263
- package/lib/errors.js +141 -141
- package/lib/exec-job.js +235 -235
- package/lib/file-reference.js +457 -457
- package/lib/forwards.js +258 -258
- package/lib/host-api.js +808 -0
- package/lib/hostkey.js +108 -108
- package/lib/http-transport.js +196 -196
- package/lib/ignore.js +202 -202
- package/lib/index.js +4923 -4541
- package/lib/instructions-dynamic.js +415 -415
- package/lib/openssh.js +879 -879
- package/lib/paths.js +252 -252
- package/lib/pool.js +963 -893
- package/lib/registry.js +296 -293
- package/lib/remote-fs.js +173 -173
- package/lib/remote-sandbox.js +183 -183
- package/lib/remote-shell-child.cjs +344 -305
- package/lib/remote-shell.js +542 -518
- package/lib/routes-fs.js +245 -245
- package/lib/routes-term.js +169 -169
- package/lib/search.js +235 -235
- package/lib/sftp-surface.js +119 -119
- package/lib/sshconfig.js +779 -705
- package/lib/sync.js +275 -275
- package/lib/tasks.js +95 -95
- package/lib/telemetry.js +213 -213
- package/lib/terminal.js +205 -205
- package/lib/tui/flow.js +130 -0
- package/lib/tui/format.js +332 -0
- package/lib/tui/i18n.js +344 -0
- package/lib/tui/index.js +322 -0
- package/lib/tui/provider.js +327 -0
- package/lib/tui/scene.js +996 -0
- package/lib/update.js +472 -472
- package/lib/web-attach.js +850 -850
- package/lib/workspace-cleanup.js +337 -0
- package/package.json +100 -99
package/lib/terminal.js
CHANGED
|
@@ -1,205 +1,205 @@
|
|
|
1
|
-
// dsh-remote — embedded interactive terminal sessions.
|
|
2
|
-
//
|
|
3
|
-
// Why polling and not a WebSocket: the plugin registers plain HTTP routes on
|
|
4
|
-
// `webServer`, and the browser half is a DSH client UI that must not depend on a
|
|
5
|
-
// socket upgrade path the host may or may not expose. A long-lived SSH channel
|
|
6
|
-
// owned by the HOST, with the browser polling for output deltas, keeps the
|
|
7
|
-
// transport boring (any HTTP stack works, no upgrade negotiation, no proxy
|
|
8
|
-
// surprises) while the expensive part — the PTY session — stays open exactly
|
|
9
|
-
// like a real ssh session.
|
|
10
|
-
//
|
|
11
|
-
// The trade is latency, and it is bounded by the poll interval the client
|
|
12
|
-
// chooses (the UI uses ~120ms idle, which is below the threshold where typing
|
|
13
|
-
// feels laggy). Keystrokes are written immediately on their own request, so
|
|
14
|
-
// input never waits for the poll cadence; only OUTPUT is batched.
|
|
15
|
-
//
|
|
16
|
-
// Sessions are host-owned and per-MACHINE: closing the browser does not kill the
|
|
17
|
-
// remote shell (a half-finished `vim` survives a page reload), but every session
|
|
18
|
-
// is bounded by an idle timeout and by the plugin's own disposal, so nothing
|
|
19
|
-
// leaks past an unload.
|
|
20
|
-
|
|
21
|
-
import { randomUUID } from 'node:crypto'
|
|
22
|
-
|
|
23
|
-
/** Output retained per session (bytes). A terminal is a stream, not a file:
|
|
24
|
-
* past this the oldest bytes drop and the reader is told it went lossy. */
|
|
25
|
-
export const TERMINAL_RING_BYTES = 256 * 1024
|
|
26
|
-
|
|
27
|
-
/** A session with no attach/poll/write for this long is closed. */
|
|
28
|
-
export const TERMINAL_IDLE_MS = 30 * 60 * 1000
|
|
29
|
-
|
|
30
|
-
/** Hard cap on concurrent sessions per process (protects the remote host's
|
|
31
|
-
* MaxSessions as much as our own memory). */
|
|
32
|
-
export const TERMINAL_MAX_SESSIONS = 8
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* One PTY-backed remote shell plus its output ring.
|
|
36
|
-
*
|
|
37
|
-
* The ring keeps a monotonic byte counter (`total`) so a poller can ask for
|
|
38
|
-
* "everything after offset N" without the server tracking per-client cursors —
|
|
39
|
-
* that is what lets one session be observed by a reloaded page, or by two tabs.
|
|
40
|
-
*/
|
|
41
|
-
class TerminalSession {
|
|
42
|
-
constructor({ id, stream, machine, cols, rows }) {
|
|
43
|
-
this.id = id
|
|
44
|
-
this.stream = stream
|
|
45
|
-
this.machine = machine
|
|
46
|
-
this.cols = cols
|
|
47
|
-
this.rows = rows
|
|
48
|
-
this.createdAt = Date.now()
|
|
49
|
-
this.lastSeen = Date.now()
|
|
50
|
-
this.total = 0 // bytes ever produced
|
|
51
|
-
this.chunks = [] // [{ at, text }] within the retained window
|
|
52
|
-
this.retained = 0
|
|
53
|
-
this.closed = false
|
|
54
|
-
this.exitNote = ''
|
|
55
|
-
stream.on('data', (d) => this._push(d.toString('utf8')))
|
|
56
|
-
stream.stderr && stream.stderr.on('data', (d) => this._push(d.toString('utf8')))
|
|
57
|
-
stream.on('close', () => this._close('session closed'))
|
|
58
|
-
stream.on('error', (e) => this._close('session error: ' + ((e && e.message) || e)))
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
_push(text) {
|
|
62
|
-
if (!text) return
|
|
63
|
-
this.chunks.push({ at: this.total, text })
|
|
64
|
-
this.total += Buffer.byteLength(text, 'utf8')
|
|
65
|
-
this.retained += text.length
|
|
66
|
-
// Drop from the head until back inside the budget. A single huge chunk is
|
|
67
|
-
// kept whole (truncating a UTF-8 string mid-chunk would corrupt it); the
|
|
68
|
-
// lossy flag already tells the reader bytes are missing.
|
|
69
|
-
while (this.retained > TERMINAL_RING_BYTES && this.chunks.length > 1) {
|
|
70
|
-
const dropped = this.chunks.shift()
|
|
71
|
-
this.retained -= dropped.text.length
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
_close(note) {
|
|
76
|
-
if (this.closed) return
|
|
77
|
-
this.closed = true
|
|
78
|
-
this.exitNote = note || 'session closed'
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Everything produced after `from` (a byte offset from a previous read).
|
|
83
|
-
* @param {number} from
|
|
84
|
-
* @returns {{chunks: string[], next: number, lossy: boolean, closed: boolean, note: string}}
|
|
85
|
-
*/
|
|
86
|
-
readFrom(from) {
|
|
87
|
-
const start = Number(from) || 0
|
|
88
|
-
const oldest = this.chunks.length ? this.chunks[0].at : this.total
|
|
89
|
-
const lossy = start < oldest
|
|
90
|
-
const chunks = this.chunks.filter((c) => c.at + Buffer.byteLength(c.text, 'utf8') > start).map((c) => c.text)
|
|
91
|
-
return { chunks, next: this.total, lossy, closed: this.closed, note: this.exitNote }
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
write(data) {
|
|
95
|
-
if (this.closed) return false
|
|
96
|
-
try { this.stream.write(data) } catch { return false }
|
|
97
|
-
return true
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
resize(cols, rows) {
|
|
101
|
-
this.cols = Math.max(2, Math.min(Number(cols) || this.cols, 1000))
|
|
102
|
-
this.rows = Math.max(2, Math.min(Number(rows) || this.rows, 1000))
|
|
103
|
-
try { this.stream.setWindow(this.rows, this.cols, 0, 0) } catch { /* channel gone */ }
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
end() {
|
|
107
|
-
this._close('closed by request')
|
|
108
|
-
try { this.stream.end() } catch { /* already gone */ }
|
|
109
|
-
try { this.stream.close() } catch { /* already gone */ }
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Owns every terminal session in this process.
|
|
115
|
-
*
|
|
116
|
-
* Per-machine keying matters: a session belongs to a MACHINE identity, so a
|
|
117
|
-
* session bound to one host can never be attached from a request that resolves
|
|
118
|
-
* to a different one (the same isolation rule the file tools follow).
|
|
119
|
-
*/
|
|
120
|
-
export function createTerminalManager() {
|
|
121
|
-
/** @type {Map<string, TerminalSession>} */
|
|
122
|
-
const sessions = new Map()
|
|
123
|
-
let sweeper = null
|
|
124
|
-
|
|
125
|
-
const startSweeper = () => {
|
|
126
|
-
if (sweeper) return
|
|
127
|
-
// One timer for the whole manager; unref'd so it never holds the process
|
|
128
|
-
// open on its own.
|
|
129
|
-
sweeper = setInterval(() => {
|
|
130
|
-
const now = Date.now()
|
|
131
|
-
for (const [id, s] of sessions) {
|
|
132
|
-
if (now - s.lastSeen > TERMINAL_IDLE_MS) {
|
|
133
|
-
try { s.end() } catch { /* already gone */ }
|
|
134
|
-
sessions.delete(id)
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}, 60 * 1000)
|
|
138
|
-
if (typeof sweeper.unref === 'function') sweeper.unref()
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Open a new terminal session on a machine.
|
|
143
|
-
* @param {object} arg
|
|
144
|
-
* @param {object} arg.pool - the machine's SshPool (must expose shell()).
|
|
145
|
-
* @param {string} arg.machine - a stable identity string for the machine.
|
|
146
|
-
* @param {number} [arg.cols]
|
|
147
|
-
* @param {number} [arg.rows]
|
|
148
|
-
* @returns {Promise<{ok: boolean, id?: string, error?: string}>}
|
|
149
|
-
*/
|
|
150
|
-
const open = async ({ pool, machine, cols, rows }) => {
|
|
151
|
-
if (sessions.size >= TERMINAL_MAX_SESSIONS) {
|
|
152
|
-
return { ok: false, error: `too many terminal sessions (max ${TERMINAL_MAX_SESSIONS}) — close one first` }
|
|
153
|
-
}
|
|
154
|
-
if (!pool || typeof pool.shell !== 'function') {
|
|
155
|
-
return { ok: false, error: 'this machine cannot open an interactive shell (no shell() on its pool)' }
|
|
156
|
-
}
|
|
157
|
-
let stream
|
|
158
|
-
try {
|
|
159
|
-
stream = await pool.shell({ cols, rows })
|
|
160
|
-
} catch (err) {
|
|
161
|
-
return { ok: false, error: String((err && err.message) || err) }
|
|
162
|
-
}
|
|
163
|
-
const id = randomUUID()
|
|
164
|
-
sessions.set(id, new TerminalSession({ id, stream, machine: String(machine || ''), cols: Number(cols) || 80, rows: Number(rows) || 24 }))
|
|
165
|
-
startSweeper()
|
|
166
|
-
return { ok: true, id }
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Look up a session, enforcing the machine boundary.
|
|
171
|
-
* @param {string} id
|
|
172
|
-
* @param {string} machine
|
|
173
|
-
* @returns {{session?: TerminalSession, error?: string}}
|
|
174
|
-
*/
|
|
175
|
-
const owned = (id, machine) => {
|
|
176
|
-
const s = sessions.get(String(id || ''))
|
|
177
|
-
if (!s) return { error: 'no such terminal session (it may have exited or timed out)' }
|
|
178
|
-
if (String(machine || '') !== s.machine) return { error: 'this terminal belongs to a different machine' }
|
|
179
|
-
s.lastSeen = Date.now()
|
|
180
|
-
return { session: s }
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
return {
|
|
184
|
-
open,
|
|
185
|
-
owned,
|
|
186
|
-
has: (id) => sessions.has(String(id || '')),
|
|
187
|
-
list: () => [...sessions.values()].map((s) => ({
|
|
188
|
-
id: s.id, machine: s.machine, cols: s.cols, rows: s.rows, createdAt: s.createdAt, closed: s.closed,
|
|
189
|
-
})),
|
|
190
|
-
close: (id, machine) => {
|
|
191
|
-
const { session, error } = owned(id, machine)
|
|
192
|
-
if (error) return { ok: false, error }
|
|
193
|
-
session.end()
|
|
194
|
-
sessions.delete(session.id)
|
|
195
|
-
return { ok: true }
|
|
196
|
-
},
|
|
197
|
-
/** Close everything (plugin unload). */
|
|
198
|
-
dispose: () => {
|
|
199
|
-
for (const s of sessions.values()) { try { s.end() } catch { /* already gone */ } }
|
|
200
|
-
sessions.clear()
|
|
201
|
-
if (sweeper) { try { clearInterval(sweeper) } catch { /* gone */ } sweeper = null }
|
|
202
|
-
},
|
|
203
|
-
size: () => sessions.size,
|
|
204
|
-
}
|
|
205
|
-
}
|
|
1
|
+
// dsh-remote — embedded interactive terminal sessions.
|
|
2
|
+
//
|
|
3
|
+
// Why polling and not a WebSocket: the plugin registers plain HTTP routes on
|
|
4
|
+
// `webServer`, and the browser half is a DSH client UI that must not depend on a
|
|
5
|
+
// socket upgrade path the host may or may not expose. A long-lived SSH channel
|
|
6
|
+
// owned by the HOST, with the browser polling for output deltas, keeps the
|
|
7
|
+
// transport boring (any HTTP stack works, no upgrade negotiation, no proxy
|
|
8
|
+
// surprises) while the expensive part — the PTY session — stays open exactly
|
|
9
|
+
// like a real ssh session.
|
|
10
|
+
//
|
|
11
|
+
// The trade is latency, and it is bounded by the poll interval the client
|
|
12
|
+
// chooses (the UI uses ~120ms idle, which is below the threshold where typing
|
|
13
|
+
// feels laggy). Keystrokes are written immediately on their own request, so
|
|
14
|
+
// input never waits for the poll cadence; only OUTPUT is batched.
|
|
15
|
+
//
|
|
16
|
+
// Sessions are host-owned and per-MACHINE: closing the browser does not kill the
|
|
17
|
+
// remote shell (a half-finished `vim` survives a page reload), but every session
|
|
18
|
+
// is bounded by an idle timeout and by the plugin's own disposal, so nothing
|
|
19
|
+
// leaks past an unload.
|
|
20
|
+
|
|
21
|
+
import { randomUUID } from 'node:crypto'
|
|
22
|
+
|
|
23
|
+
/** Output retained per session (bytes). A terminal is a stream, not a file:
|
|
24
|
+
* past this the oldest bytes drop and the reader is told it went lossy. */
|
|
25
|
+
export const TERMINAL_RING_BYTES = 256 * 1024
|
|
26
|
+
|
|
27
|
+
/** A session with no attach/poll/write for this long is closed. */
|
|
28
|
+
export const TERMINAL_IDLE_MS = 30 * 60 * 1000
|
|
29
|
+
|
|
30
|
+
/** Hard cap on concurrent sessions per process (protects the remote host's
|
|
31
|
+
* MaxSessions as much as our own memory). */
|
|
32
|
+
export const TERMINAL_MAX_SESSIONS = 8
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One PTY-backed remote shell plus its output ring.
|
|
36
|
+
*
|
|
37
|
+
* The ring keeps a monotonic byte counter (`total`) so a poller can ask for
|
|
38
|
+
* "everything after offset N" without the server tracking per-client cursors —
|
|
39
|
+
* that is what lets one session be observed by a reloaded page, or by two tabs.
|
|
40
|
+
*/
|
|
41
|
+
class TerminalSession {
|
|
42
|
+
constructor({ id, stream, machine, cols, rows }) {
|
|
43
|
+
this.id = id
|
|
44
|
+
this.stream = stream
|
|
45
|
+
this.machine = machine
|
|
46
|
+
this.cols = cols
|
|
47
|
+
this.rows = rows
|
|
48
|
+
this.createdAt = Date.now()
|
|
49
|
+
this.lastSeen = Date.now()
|
|
50
|
+
this.total = 0 // bytes ever produced
|
|
51
|
+
this.chunks = [] // [{ at, text }] within the retained window
|
|
52
|
+
this.retained = 0
|
|
53
|
+
this.closed = false
|
|
54
|
+
this.exitNote = ''
|
|
55
|
+
stream.on('data', (d) => this._push(d.toString('utf8')))
|
|
56
|
+
stream.stderr && stream.stderr.on('data', (d) => this._push(d.toString('utf8')))
|
|
57
|
+
stream.on('close', () => this._close('session closed'))
|
|
58
|
+
stream.on('error', (e) => this._close('session error: ' + ((e && e.message) || e)))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
_push(text) {
|
|
62
|
+
if (!text) return
|
|
63
|
+
this.chunks.push({ at: this.total, text })
|
|
64
|
+
this.total += Buffer.byteLength(text, 'utf8')
|
|
65
|
+
this.retained += text.length
|
|
66
|
+
// Drop from the head until back inside the budget. A single huge chunk is
|
|
67
|
+
// kept whole (truncating a UTF-8 string mid-chunk would corrupt it); the
|
|
68
|
+
// lossy flag already tells the reader bytes are missing.
|
|
69
|
+
while (this.retained > TERMINAL_RING_BYTES && this.chunks.length > 1) {
|
|
70
|
+
const dropped = this.chunks.shift()
|
|
71
|
+
this.retained -= dropped.text.length
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_close(note) {
|
|
76
|
+
if (this.closed) return
|
|
77
|
+
this.closed = true
|
|
78
|
+
this.exitNote = note || 'session closed'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Everything produced after `from` (a byte offset from a previous read).
|
|
83
|
+
* @param {number} from
|
|
84
|
+
* @returns {{chunks: string[], next: number, lossy: boolean, closed: boolean, note: string}}
|
|
85
|
+
*/
|
|
86
|
+
readFrom(from) {
|
|
87
|
+
const start = Number(from) || 0
|
|
88
|
+
const oldest = this.chunks.length ? this.chunks[0].at : this.total
|
|
89
|
+
const lossy = start < oldest
|
|
90
|
+
const chunks = this.chunks.filter((c) => c.at + Buffer.byteLength(c.text, 'utf8') > start).map((c) => c.text)
|
|
91
|
+
return { chunks, next: this.total, lossy, closed: this.closed, note: this.exitNote }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
write(data) {
|
|
95
|
+
if (this.closed) return false
|
|
96
|
+
try { this.stream.write(data) } catch { return false }
|
|
97
|
+
return true
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
resize(cols, rows) {
|
|
101
|
+
this.cols = Math.max(2, Math.min(Number(cols) || this.cols, 1000))
|
|
102
|
+
this.rows = Math.max(2, Math.min(Number(rows) || this.rows, 1000))
|
|
103
|
+
try { this.stream.setWindow(this.rows, this.cols, 0, 0) } catch { /* channel gone */ }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
end() {
|
|
107
|
+
this._close('closed by request')
|
|
108
|
+
try { this.stream.end() } catch { /* already gone */ }
|
|
109
|
+
try { this.stream.close() } catch { /* already gone */ }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Owns every terminal session in this process.
|
|
115
|
+
*
|
|
116
|
+
* Per-machine keying matters: a session belongs to a MACHINE identity, so a
|
|
117
|
+
* session bound to one host can never be attached from a request that resolves
|
|
118
|
+
* to a different one (the same isolation rule the file tools follow).
|
|
119
|
+
*/
|
|
120
|
+
export function createTerminalManager() {
|
|
121
|
+
/** @type {Map<string, TerminalSession>} */
|
|
122
|
+
const sessions = new Map()
|
|
123
|
+
let sweeper = null
|
|
124
|
+
|
|
125
|
+
const startSweeper = () => {
|
|
126
|
+
if (sweeper) return
|
|
127
|
+
// One timer for the whole manager; unref'd so it never holds the process
|
|
128
|
+
// open on its own.
|
|
129
|
+
sweeper = setInterval(() => {
|
|
130
|
+
const now = Date.now()
|
|
131
|
+
for (const [id, s] of sessions) {
|
|
132
|
+
if (now - s.lastSeen > TERMINAL_IDLE_MS) {
|
|
133
|
+
try { s.end() } catch { /* already gone */ }
|
|
134
|
+
sessions.delete(id)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}, 60 * 1000)
|
|
138
|
+
if (typeof sweeper.unref === 'function') sweeper.unref()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Open a new terminal session on a machine.
|
|
143
|
+
* @param {object} arg
|
|
144
|
+
* @param {object} arg.pool - the machine's SshPool (must expose shell()).
|
|
145
|
+
* @param {string} arg.machine - a stable identity string for the machine.
|
|
146
|
+
* @param {number} [arg.cols]
|
|
147
|
+
* @param {number} [arg.rows]
|
|
148
|
+
* @returns {Promise<{ok: boolean, id?: string, error?: string}>}
|
|
149
|
+
*/
|
|
150
|
+
const open = async ({ pool, machine, cols, rows }) => {
|
|
151
|
+
if (sessions.size >= TERMINAL_MAX_SESSIONS) {
|
|
152
|
+
return { ok: false, error: `too many terminal sessions (max ${TERMINAL_MAX_SESSIONS}) — close one first` }
|
|
153
|
+
}
|
|
154
|
+
if (!pool || typeof pool.shell !== 'function') {
|
|
155
|
+
return { ok: false, error: 'this machine cannot open an interactive shell (no shell() on its pool)' }
|
|
156
|
+
}
|
|
157
|
+
let stream
|
|
158
|
+
try {
|
|
159
|
+
stream = await pool.shell({ cols, rows })
|
|
160
|
+
} catch (err) {
|
|
161
|
+
return { ok: false, error: String((err && err.message) || err) }
|
|
162
|
+
}
|
|
163
|
+
const id = randomUUID()
|
|
164
|
+
sessions.set(id, new TerminalSession({ id, stream, machine: String(machine || ''), cols: Number(cols) || 80, rows: Number(rows) || 24 }))
|
|
165
|
+
startSweeper()
|
|
166
|
+
return { ok: true, id }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Look up a session, enforcing the machine boundary.
|
|
171
|
+
* @param {string} id
|
|
172
|
+
* @param {string} machine
|
|
173
|
+
* @returns {{session?: TerminalSession, error?: string}}
|
|
174
|
+
*/
|
|
175
|
+
const owned = (id, machine) => {
|
|
176
|
+
const s = sessions.get(String(id || ''))
|
|
177
|
+
if (!s) return { error: 'no such terminal session (it may have exited or timed out)' }
|
|
178
|
+
if (String(machine || '') !== s.machine) return { error: 'this terminal belongs to a different machine' }
|
|
179
|
+
s.lastSeen = Date.now()
|
|
180
|
+
return { session: s }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
open,
|
|
185
|
+
owned,
|
|
186
|
+
has: (id) => sessions.has(String(id || '')),
|
|
187
|
+
list: () => [...sessions.values()].map((s) => ({
|
|
188
|
+
id: s.id, machine: s.machine, cols: s.cols, rows: s.rows, createdAt: s.createdAt, closed: s.closed,
|
|
189
|
+
})),
|
|
190
|
+
close: (id, machine) => {
|
|
191
|
+
const { session, error } = owned(id, machine)
|
|
192
|
+
if (error) return { ok: false, error }
|
|
193
|
+
session.end()
|
|
194
|
+
sessions.delete(session.id)
|
|
195
|
+
return { ok: true }
|
|
196
|
+
},
|
|
197
|
+
/** Close everything (plugin unload). */
|
|
198
|
+
dispose: () => {
|
|
199
|
+
for (const s of sessions.values()) { try { s.end() } catch { /* already gone */ } }
|
|
200
|
+
sessions.clear()
|
|
201
|
+
if (sweeper) { try { clearInterval(sweeper) } catch { /* gone */ } sweeper = null }
|
|
202
|
+
},
|
|
203
|
+
size: () => sessions.size,
|
|
204
|
+
}
|
|
205
|
+
}
|
package/lib/tui/flow.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// dsh-remote TUI — 没有全屏场景接缝时的对话框降级流程。
|
|
2
|
+
//
|
|
3
|
+
// 只使用 dsh-tui 的受管对话框(ctx.tuiDialogs 的 select / input / confirm,
|
|
4
|
+
// 永不抛错、Esc 一律解析为 undefined/false),因此这里不需要任何 React。
|
|
5
|
+
// 返回纯数据(CommandResult 形状),由 `lib/tui/index.js` 的命令 handler 直接返回。
|
|
6
|
+
//
|
|
7
|
+
// 说明:dialog.input 没有掩码输入,所以这条降级路径**不收集密码 / passphrase**
|
|
8
|
+
// (绝不把明文密码显示在对话里);需要填写凭据时请用 `/dsh-remote` 全屏场景,
|
|
9
|
+
// 或先保存机器再通过既有设置页补密码。
|
|
10
|
+
|
|
11
|
+
import { AUTH_MODES, validateMachineForm } from './format.js'
|
|
12
|
+
|
|
13
|
+
/** `select` 里“新增机器”选项的保留 id。 */
|
|
14
|
+
const ADD_ID = '__dsh_remote_add__'
|
|
15
|
+
|
|
16
|
+
/** 机器选项的一行文案。 */
|
|
17
|
+
function machineOption(t, machine) {
|
|
18
|
+
const name = String((machine && machine.name) || (machine && machine.host) || (machine && machine.id) || '')
|
|
19
|
+
const user = String((machine && machine.username) || '')
|
|
20
|
+
const host = String((machine && machine.host) || '')
|
|
21
|
+
const port = Number(machine && machine.port) || 22
|
|
22
|
+
const target = `${user ? user + '@' : ''}${host}:${port}`
|
|
23
|
+
return t('flow.machineOption', { name, target })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 无场景时的交互降级:选机器 → 连接;或新增 → 逐字段输入 → 保存。
|
|
28
|
+
*
|
|
29
|
+
* @param {object} deps
|
|
30
|
+
* @param {object} deps.api - 宿主进程内 API。
|
|
31
|
+
* @param {object} deps.dialogs - `ctx.get('tuiDialogs', false)`;不存在返回 undefined。
|
|
32
|
+
* @param {object} deps.i18n - createI18n 返回值。
|
|
33
|
+
* @param {object} [deps.channel] - 活会话 channel(当前流程未使用,保留给后续扩展)。
|
|
34
|
+
* @returns {Promise<{kind:'success'|'error', text?:string}|undefined>} 纯数据结果。
|
|
35
|
+
*/
|
|
36
|
+
export async function runDialogFlow({ api, dialogs, i18n, channel } = {}) {
|
|
37
|
+
if (!api || !dialogs) return undefined
|
|
38
|
+
const t = i18n && typeof i18n.t === 'function' ? i18n.t : (key) => String(key)
|
|
39
|
+
if (typeof dialogs.select !== 'function' || typeof dialogs.input !== 'function' || typeof dialogs.confirm !== 'function') {
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const state = await api.listMachines()
|
|
44
|
+
const machines = Array.isArray(state && state.machines) ? state.machines : []
|
|
45
|
+
const currentId = state && state.currentId !== undefined ? state.currentId : null
|
|
46
|
+
const options = machines.map((machine) => ({
|
|
47
|
+
id: machine.id,
|
|
48
|
+
label: machineOption(t, machine),
|
|
49
|
+
...(machine.id === currentId ? { description: t('list.current') } : {}),
|
|
50
|
+
}))
|
|
51
|
+
options.push({ id: ADD_ID, label: t('flow.add'), description: t('flow.addDescription') })
|
|
52
|
+
const picked = await dialogs.select({ title: t('flow.selectTitle'), options })
|
|
53
|
+
if (picked === undefined) return { kind: 'success' }
|
|
54
|
+
if (picked === ADD_ID) return await addMachine({ api, dialogs, t })
|
|
55
|
+
|
|
56
|
+
const machine = machines.find((entry) => entry.id === picked)
|
|
57
|
+
if (!machine) return { kind: 'error', text: t('flow.machineMissing') }
|
|
58
|
+
const confirmed = await dialogs.confirm({
|
|
59
|
+
title: t('flow.connectTitle', { name: machine.name || machine.host || machine.id }),
|
|
60
|
+
message: machineOption(t, machine),
|
|
61
|
+
confirmLabel: t('flow.connect'),
|
|
62
|
+
cancelLabel: t('flow.cancel'),
|
|
63
|
+
})
|
|
64
|
+
if (!confirmed) return { kind: 'success' }
|
|
65
|
+
await api.connect({ machineId: machine.id, persist: true })
|
|
66
|
+
return { kind: 'success' }
|
|
67
|
+
} catch (failure) {
|
|
68
|
+
return { kind: 'error', text: String((failure && failure.message) || failure) }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 逐字段输入一台新机器(不收集任何 secret,见文件头注释)。 */
|
|
73
|
+
async function addMachine({ api, dialogs, t }) {
|
|
74
|
+
const ask = async (title, placeholder, initial) => dialogs.input({
|
|
75
|
+
title,
|
|
76
|
+
...(placeholder ? { placeholder } : {}),
|
|
77
|
+
...(initial ? { initial } : {}),
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
let host = ''
|
|
81
|
+
for (;;) {
|
|
82
|
+
const answer = await ask(t('flow.hostTitle'), t('flow.hostPlaceholder'))
|
|
83
|
+
if (answer === undefined) return { kind: 'success' }
|
|
84
|
+
host = String(answer).trim()
|
|
85
|
+
if (host) break
|
|
86
|
+
}
|
|
87
|
+
const portAnswer = await ask(t('flow.portTitle'), t('flow.portPlaceholder'), '22')
|
|
88
|
+
if (portAnswer === undefined) return { kind: 'success' }
|
|
89
|
+
const userAnswer = await ask(t('flow.usernameTitle'), t('flow.usernamePlaceholder'), 'root')
|
|
90
|
+
if (userAnswer === undefined) return { kind: 'success' }
|
|
91
|
+
const nameAnswer = await ask(t('flow.nameTitle'), t('flow.namePlaceholder'), host)
|
|
92
|
+
if (nameAnswer === undefined) return { kind: 'success' }
|
|
93
|
+
const authAnswer = await dialogs.select({
|
|
94
|
+
title: t('flow.authTitle'),
|
|
95
|
+
options: AUTH_MODES.map((mode) => ({ id: mode, label: t('auth.' + mode) })),
|
|
96
|
+
})
|
|
97
|
+
if (authAnswer === undefined) return { kind: 'success' }
|
|
98
|
+
let privateKeyPath = ''
|
|
99
|
+
if (authAnswer === 'key') {
|
|
100
|
+
const keyAnswer = await ask(t('flow.keyPathTitle'), t('flow.keyPathPlaceholder'))
|
|
101
|
+
if (keyAnswer === undefined) return { kind: 'success' }
|
|
102
|
+
privateKeyPath = String(keyAnswer).trim()
|
|
103
|
+
}
|
|
104
|
+
const groupAnswer = await ask(t('flow.groupTitle'), t('flow.groupPlaceholder'))
|
|
105
|
+
if (groupAnswer === undefined) return { kind: 'success' }
|
|
106
|
+
|
|
107
|
+
const form = {
|
|
108
|
+
name: String(nameAnswer).trim(),
|
|
109
|
+
host,
|
|
110
|
+
port: String(portAnswer).trim(),
|
|
111
|
+
username: String(userAnswer).trim(),
|
|
112
|
+
useSshConfig: false,
|
|
113
|
+
auth: AUTH_MODES.includes(authAnswer) ? authAnswer : 'password',
|
|
114
|
+
password: '',
|
|
115
|
+
privateKeyPath,
|
|
116
|
+
passphrase: '',
|
|
117
|
+
group: String(groupAnswer).trim(),
|
|
118
|
+
}
|
|
119
|
+
const { errors, patch } = validateMachineForm(form, t)
|
|
120
|
+
if (errors.length) return { kind: 'error', text: errors.join('; ') }
|
|
121
|
+
const confirmed = await dialogs.confirm({
|
|
122
|
+
title: t('flow.saveConfirm'),
|
|
123
|
+
message: machineOption(t, { name: patch.name, username: patch.username, host: patch.host, port: patch.port }),
|
|
124
|
+
confirmLabel: t('flow.save'),
|
|
125
|
+
cancelLabel: t('flow.cancel'),
|
|
126
|
+
})
|
|
127
|
+
if (!confirmed) return { kind: 'success' }
|
|
128
|
+
await api.upsertMachine(patch)
|
|
129
|
+
return { kind: 'success' }
|
|
130
|
+
}
|