dsh-remote-workspaces 0.2.0 → 0.3.0
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/package.json +10 -3
- package/src/client.js +540 -23
- package/src/index.js +136 -3
- package/src/local-browse.js +168 -0
- package/src/registry.js +20 -11
- package/src/routing-fs.js +54 -7
- package/src/shell-exec.js +13 -1
- package/src/shell-sessions.js +180 -0
- package/src/transport.js +124 -7
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-process registry of interactive UI shell sessions (local + remote).
|
|
5
|
+
*
|
|
6
|
+
* Every `openLocal` allocates an INDEPENDENT PTY process through the harness's
|
|
7
|
+
* `ctx.subprocess.spawnTerminal` seam; every `openRemote` opens an INDEPENDENT
|
|
8
|
+
* ssh2 shell channel through `SshClient.openShell`. Sessions never share a
|
|
9
|
+
* process/connection, cwd, stdin/stdout, or buffer (the §3.1 isolation model).
|
|
10
|
+
* Lifecycle is process-local (matches ctx.terminals semantics): no persistence,
|
|
11
|
+
* no cross-process recovery.
|
|
12
|
+
*
|
|
13
|
+
* A session keeps TWO buffers over the same stream:
|
|
14
|
+
* - `unread` — drained by `read` (incremental polling);
|
|
15
|
+
* - `history` — the full (capped) output, returned on ATTACH so a re-mounted
|
|
16
|
+
* tab replays its scrollback instead of coming back blank.
|
|
17
|
+
*
|
|
18
|
+
* Attach: an `open*` carrying a stable `key` REUSES a still-live session under
|
|
19
|
+
* that key instead of spawning a new one — this is what keeps a Shell tab's
|
|
20
|
+
* terminal alive across the client unmounting it on a DSH-session switch.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const MAX_UNREAD = 256 * 1024
|
|
24
|
+
const MAX_HISTORY = 512 * 1024
|
|
25
|
+
|
|
26
|
+
export function createShellSessions({ getSubprocess, openRemote: openRemoteChannel, sweepMs = 10 * 60 * 1000 }) {
|
|
27
|
+
const sessions = new Map()
|
|
28
|
+
|
|
29
|
+
// Reap sessions no client has read from or written to within `sweepMs`: a
|
|
30
|
+
// shell whose tab was lost to a refresh (or an archived session) has nobody
|
|
31
|
+
// left to close it. An on-screen tab polls continuously, so only abandoned
|
|
32
|
+
// shells — or ones hidden longer than the threshold — are collected.
|
|
33
|
+
function sweep(now = Date.now()) {
|
|
34
|
+
for (const [id, session] of [...sessions]) {
|
|
35
|
+
if (!session.ended && now - session.lastActivityAt > sweepMs) {
|
|
36
|
+
sessions.delete(id)
|
|
37
|
+
void session.handle.terminate()
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const sweepTimer = setInterval(sweep, 60 * 1000)
|
|
42
|
+
if (typeof sweepTimer.unref === 'function') sweepTimer.unref()
|
|
43
|
+
|
|
44
|
+
function liveSession(key) {
|
|
45
|
+
if (typeof key !== 'string' || key === '') return undefined
|
|
46
|
+
const existing = sessions.get(key)
|
|
47
|
+
return existing !== undefined && !existing.ended ? existing : undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function pushCapped(bag, cap, buf) {
|
|
51
|
+
bag.chunks.push(buf)
|
|
52
|
+
bag.bytes += buf.length
|
|
53
|
+
while (bag.bytes > cap && bag.chunks.length > 1) {
|
|
54
|
+
bag.bytes -= bag.chunks.shift().length
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function register(handle, meta, key) {
|
|
59
|
+
const id = typeof key === 'string' && key !== '' ? key : randomUUID()
|
|
60
|
+
const session = {
|
|
61
|
+
id,
|
|
62
|
+
handle,
|
|
63
|
+
meta,
|
|
64
|
+
unread: { chunks: [], bytes: 0 },
|
|
65
|
+
history: { chunks: [], bytes: 0 },
|
|
66
|
+
ended: false,
|
|
67
|
+
lastActivityAt: Date.now(),
|
|
68
|
+
}
|
|
69
|
+
handle.output.on('data', (chunk) => {
|
|
70
|
+
const buf = Buffer.from(chunk)
|
|
71
|
+
pushCapped(session.unread, MAX_UNREAD, buf)
|
|
72
|
+
pushCapped(session.history, MAX_HISTORY, buf)
|
|
73
|
+
})
|
|
74
|
+
handle.output.on('end', () => { session.ended = true })
|
|
75
|
+
handle.output.on('close', () => { session.ended = true })
|
|
76
|
+
sessions.set(id, session)
|
|
77
|
+
return session
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function historyText(session) {
|
|
81
|
+
return Buffer.concat(session.history.chunks).toString('utf8')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function openLocal(opts = {}) {
|
|
85
|
+
const key = typeof opts.key === 'string' && opts.key !== '' ? opts.key : undefined
|
|
86
|
+
const existing = liveSession(key)
|
|
87
|
+
if (existing !== undefined) {
|
|
88
|
+
const history = historyText(existing)
|
|
89
|
+
// The attach hands the client the full scrollback, so the unread tail is
|
|
90
|
+
// now covered — start incremental reads fresh to avoid double replay.
|
|
91
|
+
existing.unread.chunks.length = 0
|
|
92
|
+
existing.unread.bytes = 0
|
|
93
|
+
return { id: existing.id, pid: existing.handle.pid, kind: 'local', attached: true, history }
|
|
94
|
+
}
|
|
95
|
+
const subprocess = getSubprocess()
|
|
96
|
+
if (subprocess === undefined || typeof subprocess.spawnTerminal !== 'function') {
|
|
97
|
+
throw new Error('subprocess service unavailable (no spawnTerminal)')
|
|
98
|
+
}
|
|
99
|
+
const win = process.platform === 'win32'
|
|
100
|
+
const argv = win ? ['powershell.exe', '-NoLogo'] : ['bash', '-i']
|
|
101
|
+
const cwd = typeof opts.cwd === 'string' && opts.cwd !== '' ? opts.cwd : process.cwd()
|
|
102
|
+
const rows = Number.isInteger(opts.rows) && opts.rows > 0 ? opts.rows : 24
|
|
103
|
+
const cols = Number.isInteger(opts.cols) && opts.cols > 0 ? opts.cols : 80
|
|
104
|
+
const handle = await subprocess.spawnTerminal({ argv, cwd, rows, cols, graceMs: 3000 })
|
|
105
|
+
const session = register(handle, { kind: 'local', label: win ? 'PowerShell' : 'bash' }, key)
|
|
106
|
+
return { id: session.id, pid: handle.pid, kind: 'local', attached: false }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function openRemote(machine, opts = {}) {
|
|
110
|
+
const key = typeof opts.key === 'string' && opts.key !== '' ? opts.key : undefined
|
|
111
|
+
const existing = liveSession(key)
|
|
112
|
+
if (existing !== undefined) {
|
|
113
|
+
const history = historyText(existing)
|
|
114
|
+
existing.unread.chunks.length = 0
|
|
115
|
+
existing.unread.bytes = 0
|
|
116
|
+
return { id: existing.id, pid: null, kind: 'remote', attached: true, history }
|
|
117
|
+
}
|
|
118
|
+
if (typeof openRemoteChannel !== 'function') throw new Error('remote shell unavailable (no openShell)')
|
|
119
|
+
const rows = Number.isInteger(opts.rows) && opts.rows > 0 ? opts.rows : 24
|
|
120
|
+
const cols = Number.isInteger(opts.cols) && opts.cols > 0 ? opts.cols : 80
|
|
121
|
+
const cwd = typeof opts.cwd === 'string' && opts.cwd !== '' ? opts.cwd : undefined
|
|
122
|
+
const handle = await openRemoteChannel(machine ?? {}, { rows, cols, ...(cwd ? { cwd } : {}) })
|
|
123
|
+
const session = register(handle, {
|
|
124
|
+
kind: 'remote',
|
|
125
|
+
label: (machine && (machine.alias || machine.host)) || 'remote',
|
|
126
|
+
}, key)
|
|
127
|
+
return { id: session.id, pid: null, kind: 'remote', attached: false }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function requireSession(id) {
|
|
131
|
+
const session = sessions.get(id)
|
|
132
|
+
if (session === undefined) throw new Error(`shell session not found: ${id}`)
|
|
133
|
+
return session
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function write(id, data) {
|
|
137
|
+
const session = requireSession(id)
|
|
138
|
+
session.lastActivityAt = Date.now()
|
|
139
|
+
if (!session.ended && typeof data === 'string' && data !== '') await session.handle.write(data)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function read(id) {
|
|
143
|
+
const session = requireSession(id)
|
|
144
|
+
session.lastActivityAt = Date.now()
|
|
145
|
+
const text = Buffer.concat(session.unread.chunks).toString('utf8')
|
|
146
|
+
session.unread.chunks.length = 0
|
|
147
|
+
session.unread.bytes = 0
|
|
148
|
+
return { text, eof: session.ended }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function resize(id, rows, cols) {
|
|
152
|
+
const session = requireSession(id)
|
|
153
|
+
if (session.ended) return { resized: false }
|
|
154
|
+
if (typeof session.handle.resize === 'function') {
|
|
155
|
+
session.handle.resize(rows, cols)
|
|
156
|
+
return { resized: true }
|
|
157
|
+
}
|
|
158
|
+
return { resized: false }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function close(id) {
|
|
162
|
+
const session = sessions.get(id)
|
|
163
|
+
if (session === undefined) return { closed: false }
|
|
164
|
+
sessions.delete(id)
|
|
165
|
+
await session.handle.terminate()
|
|
166
|
+
return { closed: true }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function list() {
|
|
170
|
+
return [...sessions.values()].map((session) => ({
|
|
171
|
+
id: session.id,
|
|
172
|
+
pid: session.handle.pid ?? null,
|
|
173
|
+
kind: session.meta.kind,
|
|
174
|
+
label: session.meta.label,
|
|
175
|
+
ended: session.ended,
|
|
176
|
+
}))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { openLocal, openRemote, write, read, resize, close, list, sweep }
|
|
180
|
+
}
|
package/src/transport.js
CHANGED
|
@@ -29,6 +29,22 @@ export function psQuote(value) {
|
|
|
29
29
|
return `'${String(value).replace(/'/g, "''")}'`
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* First-input `cd` command that lands an interactive shell channel in `cwd`,
|
|
34
|
+
* dialect-aware: POSIX single-quotes, PowerShell (cd = Set-Location) single-
|
|
35
|
+
* quotes a forward-slash win path, and cmd.exe — where single quotes are
|
|
36
|
+
* literal — uses double quotes + backslashes with `/d` to switch drive too.
|
|
37
|
+
*/
|
|
38
|
+
export function shellCdCommand(profile, cwd) {
|
|
39
|
+
const p = profile ?? { family: 'unknown' }
|
|
40
|
+
if (p.family === 'windows') {
|
|
41
|
+
const winPath = toWinPath(cwd)
|
|
42
|
+
if (p.shell === 'cmd') return `cd /d "${winPath.replace(/\//g, '\\')}"\r`
|
|
43
|
+
return `cd ${psQuote(winPath)}\r`
|
|
44
|
+
}
|
|
45
|
+
return `cd ${shellQuote(cwd)}\r`
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
/**
|
|
33
49
|
* Strip PowerShell progress records from a stderr capture. When a nested
|
|
34
50
|
* powershell is launched by a Windows OpenSSH exec channel (e.g. the cmd
|
|
@@ -113,9 +129,15 @@ export function profileCacheKey({ host, user, port }) {
|
|
|
113
129
|
|
|
114
130
|
/**
|
|
115
131
|
* Detect a target's OS family and default exec shell without parsing
|
|
116
|
-
* localized output: `uname -s` proves a POSIX shell;
|
|
117
|
-
*
|
|
132
|
+
* localized output: `uname -s` proves a POSIX shell; the bare `ver` builtin
|
|
133
|
+
* proves a cmd-default Windows host; a quoted nested `cmd /c "ver"` proves
|
|
134
|
+
* Windows under a PowerShell default (PowerShell itself has no `ver`); a
|
|
118
135
|
* `$PSVersionTable` expression then tells cmd from PowerShell as the default.
|
|
136
|
+
*
|
|
137
|
+
* Quoting matters on real cmd-default Windows hosts: OpenSSH wraps the exec
|
|
138
|
+
* payload for cmd.exe in a way that mangles inner unquoted spaces — `cmd /c
|
|
139
|
+
* ver` arrives as `ver"` and fails, while a bare single token (`ver`) and a
|
|
140
|
+
* quoted inner command (`cmd /c "ver"`) both pass (verified on a real host).
|
|
119
141
|
*/
|
|
120
142
|
export async function probeRemoteProfile(client) {
|
|
121
143
|
const uname = await client.run('uname -s')
|
|
@@ -123,16 +145,30 @@ export async function probeRemoteProfile(client) {
|
|
|
123
145
|
const os = (uname.stdout ?? '').trim().toLowerCase()
|
|
124
146
|
return { family: 'posix', os: os.startsWith('darwin') ? 'darwin' : 'linux', shell: 'posix' }
|
|
125
147
|
}
|
|
126
|
-
const ver = await client.run('
|
|
127
|
-
if (ver.ok
|
|
148
|
+
const ver = await client.run('ver')
|
|
149
|
+
if (ver.ok) return { family: 'windows', os: 'windows', shell: 'cmd' }
|
|
150
|
+
const nested = await client.run('cmd /c "ver"')
|
|
151
|
+
if (nested.ok && /windows/i.test(nested.stdout ?? '')) {
|
|
128
152
|
const ps = await client.run('$PSVersionTable.PSVersion.ToString()')
|
|
129
153
|
return ps.ok
|
|
130
154
|
? { family: 'windows', os: 'windows', shell: 'powershell' }
|
|
131
155
|
: { family: 'windows', os: 'windows', shell: 'cmd' }
|
|
132
156
|
}
|
|
157
|
+
const ps = await client.run('$PSVersionTable.PSVersion.ToString()')
|
|
158
|
+
if (ps.ok) return { family: 'windows', os: 'windows', shell: 'powershell' }
|
|
133
159
|
return { family: 'unknown', os: 'unknown', shell: 'unknown' }
|
|
134
160
|
}
|
|
135
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Error text used whenever a command cannot run because the remote profile
|
|
164
|
+
* probe failed (family 'unknown'). Explicit instead of guessing a dialect:
|
|
165
|
+
* sending a POSIX `cd 'x' || exit 1` script to a Windows/cmd host produces the
|
|
166
|
+
* misleading "文件名、目录名或卷标语法不正确" (Win32 123) failure, and the
|
|
167
|
+
* other way around is equally wrong — so callers refuse loudly.
|
|
168
|
+
*/
|
|
169
|
+
export const PROBE_UNKNOWN_MSG =
|
|
170
|
+
'cannot determine the remote shell type (exec probe failed); command not executed. Check the host side: the account home/profile directory must exist, the OpenSSH DefaultShell must be valid, and the exec channel itself must be usable.'
|
|
171
|
+
|
|
136
172
|
/** Encode a PowerShell script for quote-safe transport through cmd.exe.
|
|
137
173
|
*
|
|
138
174
|
* `powershell -EncodedCommand` would be ideal (no quoting at all), but a
|
|
@@ -162,7 +198,15 @@ export function psCommandEnvelope(script) {
|
|
|
162
198
|
* text (no CLIXML records).
|
|
163
199
|
*/
|
|
164
200
|
export function buildExecScript(profile, command, cwd) {
|
|
165
|
-
if (profile === undefined
|
|
201
|
+
if (profile === undefined) {
|
|
202
|
+
return cwd ? `cd ${shellQuote(cwd)} || exit 1\n${command}` : command
|
|
203
|
+
}
|
|
204
|
+
if (profile.family === 'unknown') {
|
|
205
|
+
// Never guess: a POSIX script against a Windows/cmd host dies on the first
|
|
206
|
+
// line with Win32 123-style errors and never reaches the command.
|
|
207
|
+
throw new Error(PROBE_UNKNOWN_MSG)
|
|
208
|
+
}
|
|
209
|
+
if (profile.family !== 'windows') {
|
|
166
210
|
return cwd ? `cd ${shellQuote(cwd)} || exit 1\n${command}` : command
|
|
167
211
|
}
|
|
168
212
|
const script = [
|
|
@@ -362,6 +406,9 @@ export class SshClient {
|
|
|
362
406
|
*/
|
|
363
407
|
async execShell(command, { cwd, timeoutMs = 60000, stdoutMaxBytes = 64000, stderrMaxBytes = 64000, stdin, signal } = {}) {
|
|
364
408
|
const profile = await this.profile()
|
|
409
|
+
if (profile.family === 'unknown') {
|
|
410
|
+
return { ok: false, error: PROBE_UNKNOWN_MSG }
|
|
411
|
+
}
|
|
365
412
|
const script = buildExecScript(profile, command, cwd)
|
|
366
413
|
return await new Promise((resolve) => {
|
|
367
414
|
const conn = new Client()
|
|
@@ -466,6 +513,63 @@ export class SshClient {
|
|
|
466
513
|
})
|
|
467
514
|
}
|
|
468
515
|
|
|
516
|
+
/**
|
|
517
|
+
* Open an INTERACTIVE shell channel with a PTY — the remote half of the UI
|
|
518
|
+
* Shell tool. ssh2 `conn.shell` + a pty is the same mechanism on POSIX and
|
|
519
|
+
* Windows OpenSSH (ConPTY) remotes; the target's DefaultShell decides whether
|
|
520
|
+
* the session is a login shell, cmd, or PowerShell, and its OS decides the
|
|
521
|
+
* line endings. The channel merges stderr into stdout (one data stream) and
|
|
522
|
+
* the bytes are passed through RAW — a PTY already emits terminal-ready CRLF
|
|
523
|
+
* that xterm renders directly, so the exec-channel CRLF folding must NOT be
|
|
524
|
+
* applied here.
|
|
525
|
+
*
|
|
526
|
+
* When `cwd` is given, the shell is chdir'd there as its first input (a
|
|
527
|
+
* dialect-aware `cd`, probed once per target). The shell still starts at the
|
|
528
|
+
* remote account's home; the `cd` is emitted into the PTY so the prompt and
|
|
529
|
+
* every later command land in the requested directory.
|
|
530
|
+
*
|
|
531
|
+
* Resolves a handle compatible with the local PTY one:
|
|
532
|
+
* { output, write, terminate, resize, pid } where `output` is the ssh2
|
|
533
|
+
* stream (an EventEmitter emitting 'data' and 'close'), `resize` maps to
|
|
534
|
+
* `setWindow` (REACHABLE here, unlike the local seam — the S0 finding), and
|
|
535
|
+
* `terminate` closes the channel then ends the connection.
|
|
536
|
+
*/
|
|
537
|
+
openShell({ rows = 24, cols = 80, term = 'xterm-256color', env, cwd } = {}) {
|
|
538
|
+
const chdirPromise = cwd !== undefined && cwd !== null && cwd !== ''
|
|
539
|
+
? this.profile().then((profile) => shellCdCommand(profile, cwd)).catch(() => '')
|
|
540
|
+
: Promise.resolve('')
|
|
541
|
+
return chdirPromise.then((chdir) => new Promise((resolve, reject) => {
|
|
542
|
+
const conn = new Client()
|
|
543
|
+
let settled = false
|
|
544
|
+
const fail = (error) => {
|
|
545
|
+
if (settled) return
|
|
546
|
+
settled = true
|
|
547
|
+
try { conn.end() } catch {}
|
|
548
|
+
reject(error instanceof Error ? error : new Error(String(error)))
|
|
549
|
+
}
|
|
550
|
+
const timer = setTimeout(() => fail(new Error('SSH shell 连接超时')), this.readyTimeoutMs)
|
|
551
|
+
conn.on('ready', () => {
|
|
552
|
+
conn.shell({ term, rows, cols, ...(env ? { env } : {}) }, (err, stream) => {
|
|
553
|
+
if (err) { clearTimeout(timer); fail(err); return }
|
|
554
|
+
clearTimeout(timer)
|
|
555
|
+
settled = true
|
|
556
|
+
let closed = false
|
|
557
|
+
stream.on('close', () => { closed = true })
|
|
558
|
+
if (chdir !== '') stream.write(chdir)
|
|
559
|
+
resolve({
|
|
560
|
+
output: stream,
|
|
561
|
+
pid: null,
|
|
562
|
+
write(data) { if (!closed) { try { stream.write(data) } catch {} } },
|
|
563
|
+
terminate() { try { stream.close() } catch {} try { conn.end() } catch {} },
|
|
564
|
+
resize(r, c) { if (!closed) { try { stream.setWindow(r, c) } catch {} } },
|
|
565
|
+
})
|
|
566
|
+
})
|
|
567
|
+
})
|
|
568
|
+
conn.on('error', (connError) => { clearTimeout(timer); fail(connError) })
|
|
569
|
+
try { conn.connect(this.connectConfig()) } catch (connectError) { clearTimeout(timer); fail(connectError) }
|
|
570
|
+
}))
|
|
571
|
+
}
|
|
572
|
+
|
|
469
573
|
/**
|
|
470
574
|
* Open a LONG-LIVED exec channel for streaming (used by background/start
|
|
471
575
|
* jobs on Windows remotes, where no nohup-style detach exists and closing
|
|
@@ -480,6 +584,15 @@ export class SshClient {
|
|
|
480
584
|
*/
|
|
481
585
|
async execStream(command, { cwd, signal, stdoutMaxBytes = 16 * 1024 * 1024, stderrMaxBytes = 16 * 1024 * 1024 } = {}) {
|
|
482
586
|
const profile = await this.profile()
|
|
587
|
+
if (profile.family === 'unknown') {
|
|
588
|
+
// Same explicit refusal as execShell: no dialect to build the script in.
|
|
589
|
+
return {
|
|
590
|
+
readOut: () => ({ delta: '', lossy: false }),
|
|
591
|
+
readErr: () => ({ delta: '', lossy: false }),
|
|
592
|
+
exit: Promise.resolve({ error: PROBE_UNKNOWN_MSG }),
|
|
593
|
+
terminate() {},
|
|
594
|
+
}
|
|
595
|
+
}
|
|
483
596
|
const windows = profile.family === 'windows'
|
|
484
597
|
const ssh = this
|
|
485
598
|
// The marker runs FIRST inside the same remote shell that owns the exec
|
|
@@ -604,14 +717,18 @@ export class SshClient {
|
|
|
604
717
|
|
|
605
718
|
/**
|
|
606
719
|
* Cached remote execution profile ({ family, os, shell }); probed once per
|
|
607
|
-
* target and reused for the process lifetime.
|
|
720
|
+
* target and reused for the process lifetime. FAILED probes (family
|
|
721
|
+
* 'unknown') are never cached, so the next call re-probes: a transient
|
|
722
|
+
* failure or a host-side fix is picked up without restarting the process.
|
|
608
723
|
*/
|
|
609
724
|
async profile() {
|
|
610
725
|
const key = profileCacheKey(this)
|
|
611
726
|
let profile = REMOTE_PROFILE_CACHE.get(key)
|
|
612
727
|
if (profile === undefined) {
|
|
613
728
|
profile = await probeRemoteProfile(this)
|
|
614
|
-
|
|
729
|
+
if (profile.family === 'posix' || profile.family === 'windows') {
|
|
730
|
+
REMOTE_PROFILE_CACHE.set(key, profile)
|
|
731
|
+
}
|
|
615
732
|
}
|
|
616
733
|
return profile
|
|
617
734
|
}
|