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
|
@@ -1,305 +1,344 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* The program the host terminal runs when the user picks the "Remote SSH" shell.
|
|
4
|
-
*
|
|
5
|
-
* It is started by a generated launcher (see `remote-shell.js`) inside the
|
|
6
|
-
* session's working directory, resolves which remote checkout that directory
|
|
7
|
-
* belongs to, and then replaces itself with `ssh`. Inheriting stdio is the whole
|
|
8
|
-
* point: the local pty stays in charge of the screen, so resize and key handling
|
|
9
|
-
* keep working without this script knowing anything about terminals.
|
|
10
|
-
*
|
|
11
|
-
* Plain CommonJS on purpose: it is spawned as a standalone script, and it must
|
|
12
|
-
* not depend on how the plugin package itself is loaded.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
'use strict'
|
|
16
|
-
|
|
17
|
-
const { spawn } = require('node:child_process')
|
|
18
|
-
const { existsSync, readFileSync } = require('node:fs')
|
|
19
|
-
const path = require('node:path')
|
|
20
|
-
|
|
21
|
-
/** Name of the marker the host half writes into every local mirror root. */
|
|
22
|
-
const META_FILE = '.dsh-remote-meta.json'
|
|
23
|
-
/** How far up from the working directory a marker may be found. */
|
|
24
|
-
const MAX_WALK_UP = 12
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Read and parse a JSON file, returning undefined for anything unusable.
|
|
28
|
-
* @param {string} file
|
|
29
|
-
* @returns {any}
|
|
30
|
-
*/
|
|
31
|
-
function readJson(file) {
|
|
32
|
-
try {
|
|
33
|
-
return JSON.parse(readFileSync(file, 'utf8'))
|
|
34
|
-
} catch {
|
|
35
|
-
return undefined
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Find the mirror root that contains `cwd` by walking upwards for the marker.
|
|
41
|
-
* @param {string} cwd - session working directory
|
|
42
|
-
* @param {{ exists?: (p: string) => boolean, readJsonFile?: (p: string) => any, maxUp?: number }} deps
|
|
43
|
-
* @returns {{ dir: string, meta: object } | null}
|
|
44
|
-
*/
|
|
45
|
-
function findMirror(cwd, deps = {}) {
|
|
46
|
-
const exists = deps.exists || ((p) => existsSync(p))
|
|
47
|
-
const read = deps.readJsonFile || readJson
|
|
48
|
-
const maxUp = deps.maxUp || MAX_WALK_UP
|
|
49
|
-
let dir = path.resolve(cwd || '.')
|
|
50
|
-
for (let depth = 0; depth <= maxUp; depth += 1) {
|
|
51
|
-
const marker = path.join(dir, META_FILE)
|
|
52
|
-
if (exists(marker)) {
|
|
53
|
-
const meta = read(marker)
|
|
54
|
-
if (meta && typeof meta === 'object' && meta.host) return { dir, meta }
|
|
55
|
-
}
|
|
56
|
-
const parent = path.dirname(dir)
|
|
57
|
-
if (parent === dir) break
|
|
58
|
-
dir = parent
|
|
59
|
-
}
|
|
60
|
-
return null
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Pick the saved connection that owns a mirror.
|
|
65
|
-
*
|
|
66
|
-
* The marker records the SSH alias when the machine was saved from
|
|
67
|
-
* `~/.ssh/config` (issue #38), so the alias is the most precise key; otherwise
|
|
68
|
-
* host + user + port identify it. A missing entry is not fatal — the marker
|
|
69
|
-
* alone is enough to connect, and ssh will use its own defaults for the key.
|
|
70
|
-
*
|
|
71
|
-
* @param {object} meta - mirror marker
|
|
72
|
-
* @param {{ list?: object[] }} registry - parsed `machines.json`
|
|
73
|
-
* @returns {object | undefined}
|
|
74
|
-
*/
|
|
75
|
-
function matchMachine(meta, registry) {
|
|
76
|
-
const list = registry && Array.isArray(registry.list) ? registry.list : []
|
|
77
|
-
const port = Number(meta.port || 22)
|
|
78
|
-
if (meta.alias) {
|
|
79
|
-
const byAlias = list.find((m) => m && m.host === meta.alias)
|
|
80
|
-
if (byAlias) return byAlias
|
|
81
|
-
}
|
|
82
|
-
return list.find((m) => m && m.host === meta.host
|
|
83
|
-
&& String(m.username || '') === String(meta.username || '')
|
|
84
|
-
&& Number(m.port || 22) === port)
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Quote one path for a POSIX shell, single quotes only.
|
|
89
|
-
* @param {string} value
|
|
90
|
-
* @returns {string}
|
|
91
|
-
*/
|
|
92
|
-
function quotePosix(value) {
|
|
93
|
-
return "'" + String(value).replace(/'/g, "'\\''") + "'"
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Build the `cd` half of the remote command, keeping `~` for the *remote* shell
|
|
98
|
-
* to expand: the local home is not the remote home, so expanding it here would
|
|
99
|
-
* silently land in the wrong directory.
|
|
100
|
-
* @param {string} remotePath
|
|
101
|
-
* @returns {string}
|
|
102
|
-
*/
|
|
103
|
-
function remoteCd(remotePath) {
|
|
104
|
-
const raw = String(remotePath || '').trim()
|
|
105
|
-
if (raw === '' || raw === '~') return 'cd "$HOME"'
|
|
106
|
-
if (raw === '~/') return 'cd "$HOME"'
|
|
107
|
-
if (raw.startsWith('~/')) return 'cd "$HOME"/' + quotePosix(raw.slice(2))
|
|
108
|
-
return 'cd ' + quotePosix(raw)
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* The remote command: land in the workspace, then hand over to a login shell.
|
|
113
|
-
*
|
|
114
|
-
* `exec` keeps the pty attached to a single interactive shell, so the exit code
|
|
115
|
-
* the terminal sees is the shell's own, and a deleted workspace falls back to
|
|
116
|
-
* `$HOME` instead of leaving a broken terminal.
|
|
117
|
-
*
|
|
118
|
-
* @param {string} remotePath
|
|
119
|
-
* @returns {string}
|
|
120
|
-
*/
|
|
121
|
-
function remoteCommand(remotePath) {
|
|
122
|
-
return `${remoteCd(remotePath)} 2>/dev/null || cd "$HOME"; exec "\${SHELL:-/bin/bash}" -l`
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Absolute Windows path: `D:\x`, `D:/x`, or a `\\server\share` UNC path.
|
|
126
|
-
* These cannot be the operand of a POSIX `cd`. */
|
|
127
|
-
const WINDOWS_REMOTE_PATH = /^(?:[a-zA-Z]:[\\/]|\\\\)/
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Whether a remote workspace path is a Windows one.
|
|
131
|
-
*
|
|
132
|
-
* A Windows remote speaks cmd or PowerShell in its default shell, so the POSIX
|
|
133
|
-
* `cd … || …; exec "$SHELL"` line would fail outright there — `2>/dev/null`,
|
|
134
|
-
* `||` and `exec` are not cmd syntax at all. The marker records no remote
|
|
135
|
-
* platform, but the path form is unambiguous: `/srv/x` and `~/x` are POSIX,
|
|
136
|
-
* while `D:\x` and `\\server\share` are not valid POSIX paths.
|
|
137
|
-
*
|
|
138
|
-
* @param {string} p - `remotePath` from the mirror marker
|
|
139
|
-
* @returns {boolean}
|
|
140
|
-
*/
|
|
141
|
-
function isWindowsRemotePath(p) {
|
|
142
|
-
return WINDOWS_REMOTE_PATH.test(String(p || '').trim())
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Argument list (without the ssh binary) for one connection.
|
|
147
|
-
*
|
|
148
|
-
* `-tt` is mandatory: the terminal needs a tty even though a command is given,
|
|
149
|
-
* otherwise the shell comes up non-interactive and every key press is ignored.
|
|
150
|
-
* Options are pushed in order and never spliced between an `-o` and its value —
|
|
151
|
-
* an inserted `-J` there once reached ssh as the value of `-o`, which aborted
|
|
152
|
-
* every connection that used a jump host (issue #21).
|
|
153
|
-
*
|
|
154
|
-
* @param {object} target - `{ host, port, username, alias, remotePath }`
|
|
155
|
-
* @param {object} [machine] - saved connection, for the key and transport hints
|
|
156
|
-
* @returns {string[]}
|
|
157
|
-
*/
|
|
158
|
-
function buildSshArgs(target, machine) {
|
|
159
|
-
const args = ['-tt']
|
|
160
|
-
const alias = target.alias || (machine && machine.useSshConfig ? machine.host : '')
|
|
161
|
-
if (alias) {
|
|
162
|
-
args.push(String(alias))
|
|
163
|
-
} else {
|
|
164
|
-
const port = Number(target.port || (machine && machine.port) || 22)
|
|
165
|
-
if (port !== 22) args.push('-p', String(port))
|
|
166
|
-
const key = machine && machine.privateKeyPath
|
|
167
|
-
if (key && !machine.useAgent) args.push('-i', String(key))
|
|
168
|
-
const user = target.username || (machine && machine.username) || ''
|
|
169
|
-
args.push(user ? `${user}@${target.host}` : String(target.host))
|
|
170
|
-
}
|
|
171
|
-
// A Windows workspace cannot be reached with the POSIX line: the remote's
|
|
172
|
-
// default shell may be cmd or PowerShell, and picking one would be a guess.
|
|
173
|
-
// Opening ssh with no command leaves the remote's own default shell in charge,
|
|
174
|
-
// so the terminal still works; only the automatic `cd` is given up.
|
|
175
|
-
if (!isWindowsRemotePath(target.remotePath)) args.push(remoteCommand(target.remotePath))
|
|
176
|
-
return args
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Prefer the OS OpenSSH binary on Windows. A PATH lookup there can reach an
|
|
181
|
-
* MSYS2/git-bash `ssh`, which resolves `~/.ssh` against a different home and
|
|
182
|
-
* makes keys saved by the host half look missing.
|
|
183
|
-
* @param {string} [platform]
|
|
184
|
-
* @param {object} [env]
|
|
185
|
-
* @param {(p: string) => boolean} [exists]
|
|
186
|
-
* @returns {string}
|
|
187
|
-
*/
|
|
188
|
-
function sshBinary(platform = process.platform, env = process.env, exists = existsSync) {
|
|
189
|
-
if (platform === 'win32') {
|
|
190
|
-
const system = env.SystemRoot || env.windir || 'C:\\Windows'
|
|
191
|
-
const native = path.join(system, 'System32', 'OpenSSH', 'ssh.exe')
|
|
192
|
-
if (exists(native)) return native
|
|
193
|
-
}
|
|
194
|
-
return 'ssh'
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* The shell to fall back to when this directory is not a remote checkout.
|
|
199
|
-
* @param {string} [platform]
|
|
200
|
-
* @param {object} [env]
|
|
201
|
-
* @returns {string}
|
|
202
|
-
*/
|
|
203
|
-
function localShell(platform = process.platform, env = process.env) {
|
|
204
|
-
if (platform === 'win32') return env.ComSpec || 'cmd.exe'
|
|
205
|
-
return env.SHELL || '/bin/sh'
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Parse the arguments the launcher passes.
|
|
210
|
-
* @param {string[]} argv
|
|
211
|
-
* @returns {{ machines?: string }}
|
|
212
|
-
*/
|
|
213
|
-
function parseArgs(argv) {
|
|
214
|
-
const out = {}
|
|
215
|
-
for (let i = 0; i < argv.length; i += 1) {
|
|
216
|
-
if (argv[i] === '--machines' && argv[i + 1]) {
|
|
217
|
-
out.machines = argv[i + 1]
|
|
218
|
-
i += 1
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
return out
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Resolve everything needed to connect from a working directory.
|
|
226
|
-
* @param {{ cwd?: string, machines?: string, exists?: Function, readJsonFile?: Function }} input
|
|
227
|
-
* @returns {{ target?: object, machine?: object, found: boolean }}
|
|
228
|
-
*/
|
|
229
|
-
function resolveTarget(input = {}) {
|
|
230
|
-
const cwd = input.cwd || process.cwd()
|
|
231
|
-
const mirror = findMirror(cwd, input)
|
|
232
|
-
if (!mirror) return { found: false }
|
|
233
|
-
const registry = input.machines ? (input.readJsonFile || readJson)(input.machines) : undefined
|
|
234
|
-
const machine = matchMachine(mirror.meta, registry)
|
|
235
|
-
const target = {
|
|
236
|
-
host: mirror.meta.host,
|
|
237
|
-
port: mirror.meta.port || (machine && machine.port) || 22,
|
|
238
|
-
username: mirror.meta.username || (machine && machine.username) || '',
|
|
239
|
-
alias: mirror.meta.alias || '',
|
|
240
|
-
remotePath: mirror.meta.remotePath || (machine && machine.workspace) || '',
|
|
241
|
-
}
|
|
242
|
-
return { found: true, target, machine, dir: mirror.dir }
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The program the host terminal runs when the user picks the "Remote SSH" shell.
|
|
4
|
+
*
|
|
5
|
+
* It is started by a generated launcher (see `remote-shell.js`) inside the
|
|
6
|
+
* session's working directory, resolves which remote checkout that directory
|
|
7
|
+
* belongs to, and then replaces itself with `ssh`. Inheriting stdio is the whole
|
|
8
|
+
* point: the local pty stays in charge of the screen, so resize and key handling
|
|
9
|
+
* keep working without this script knowing anything about terminals.
|
|
10
|
+
*
|
|
11
|
+
* Plain CommonJS on purpose: it is spawned as a standalone script, and it must
|
|
12
|
+
* not depend on how the plugin package itself is loaded.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict'
|
|
16
|
+
|
|
17
|
+
const { spawn } = require('node:child_process')
|
|
18
|
+
const { existsSync, fstatSync, readFileSync } = require('node:fs')
|
|
19
|
+
const path = require('node:path')
|
|
20
|
+
|
|
21
|
+
/** Name of the marker the host half writes into every local mirror root. */
|
|
22
|
+
const META_FILE = '.dsh-remote-meta.json'
|
|
23
|
+
/** How far up from the working directory a marker may be found. */
|
|
24
|
+
const MAX_WALK_UP = 12
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Read and parse a JSON file, returning undefined for anything unusable.
|
|
28
|
+
* @param {string} file
|
|
29
|
+
* @returns {any}
|
|
30
|
+
*/
|
|
31
|
+
function readJson(file) {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Find the mirror root that contains `cwd` by walking upwards for the marker.
|
|
41
|
+
* @param {string} cwd - session working directory
|
|
42
|
+
* @param {{ exists?: (p: string) => boolean, readJsonFile?: (p: string) => any, maxUp?: number }} deps
|
|
43
|
+
* @returns {{ dir: string, meta: object } | null}
|
|
44
|
+
*/
|
|
45
|
+
function findMirror(cwd, deps = {}) {
|
|
46
|
+
const exists = deps.exists || ((p) => existsSync(p))
|
|
47
|
+
const read = deps.readJsonFile || readJson
|
|
48
|
+
const maxUp = deps.maxUp || MAX_WALK_UP
|
|
49
|
+
let dir = path.resolve(cwd || '.')
|
|
50
|
+
for (let depth = 0; depth <= maxUp; depth += 1) {
|
|
51
|
+
const marker = path.join(dir, META_FILE)
|
|
52
|
+
if (exists(marker)) {
|
|
53
|
+
const meta = read(marker)
|
|
54
|
+
if (meta && typeof meta === 'object' && meta.host) return { dir, meta }
|
|
55
|
+
}
|
|
56
|
+
const parent = path.dirname(dir)
|
|
57
|
+
if (parent === dir) break
|
|
58
|
+
dir = parent
|
|
59
|
+
}
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Pick the saved connection that owns a mirror.
|
|
65
|
+
*
|
|
66
|
+
* The marker records the SSH alias when the machine was saved from
|
|
67
|
+
* `~/.ssh/config` (issue #38), so the alias is the most precise key; otherwise
|
|
68
|
+
* host + user + port identify it. A missing entry is not fatal — the marker
|
|
69
|
+
* alone is enough to connect, and ssh will use its own defaults for the key.
|
|
70
|
+
*
|
|
71
|
+
* @param {object} meta - mirror marker
|
|
72
|
+
* @param {{ list?: object[] }} registry - parsed `machines.json`
|
|
73
|
+
* @returns {object | undefined}
|
|
74
|
+
*/
|
|
75
|
+
function matchMachine(meta, registry) {
|
|
76
|
+
const list = registry && Array.isArray(registry.list) ? registry.list : []
|
|
77
|
+
const port = Number(meta.port || 22)
|
|
78
|
+
if (meta.alias) {
|
|
79
|
+
const byAlias = list.find((m) => m && m.host === meta.alias)
|
|
80
|
+
if (byAlias) return byAlias
|
|
81
|
+
}
|
|
82
|
+
return list.find((m) => m && m.host === meta.host
|
|
83
|
+
&& String(m.username || '') === String(meta.username || '')
|
|
84
|
+
&& Number(m.port || 22) === port)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Quote one path for a POSIX shell, single quotes only.
|
|
89
|
+
* @param {string} value
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
function quotePosix(value) {
|
|
93
|
+
return "'" + String(value).replace(/'/g, "'\\''") + "'"
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build the `cd` half of the remote command, keeping `~` for the *remote* shell
|
|
98
|
+
* to expand: the local home is not the remote home, so expanding it here would
|
|
99
|
+
* silently land in the wrong directory.
|
|
100
|
+
* @param {string} remotePath
|
|
101
|
+
* @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
function remoteCd(remotePath) {
|
|
104
|
+
const raw = String(remotePath || '').trim()
|
|
105
|
+
if (raw === '' || raw === '~') return 'cd "$HOME"'
|
|
106
|
+
if (raw === '~/') return 'cd "$HOME"'
|
|
107
|
+
if (raw.startsWith('~/')) return 'cd "$HOME"/' + quotePosix(raw.slice(2))
|
|
108
|
+
return 'cd ' + quotePosix(raw)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The remote command: land in the workspace, then hand over to a login shell.
|
|
113
|
+
*
|
|
114
|
+
* `exec` keeps the pty attached to a single interactive shell, so the exit code
|
|
115
|
+
* the terminal sees is the shell's own, and a deleted workspace falls back to
|
|
116
|
+
* `$HOME` instead of leaving a broken terminal.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} remotePath
|
|
119
|
+
* @returns {string}
|
|
120
|
+
*/
|
|
121
|
+
function remoteCommand(remotePath) {
|
|
122
|
+
return `${remoteCd(remotePath)} 2>/dev/null || cd "$HOME"; exec "\${SHELL:-/bin/bash}" -l`
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Absolute Windows path: `D:\x`, `D:/x`, or a `\\server\share` UNC path.
|
|
126
|
+
* These cannot be the operand of a POSIX `cd`. */
|
|
127
|
+
const WINDOWS_REMOTE_PATH = /^(?:[a-zA-Z]:[\\/]|\\\\)/
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Whether a remote workspace path is a Windows one.
|
|
131
|
+
*
|
|
132
|
+
* A Windows remote speaks cmd or PowerShell in its default shell, so the POSIX
|
|
133
|
+
* `cd … || …; exec "$SHELL"` line would fail outright there — `2>/dev/null`,
|
|
134
|
+
* `||` and `exec` are not cmd syntax at all. The marker records no remote
|
|
135
|
+
* platform, but the path form is unambiguous: `/srv/x` and `~/x` are POSIX,
|
|
136
|
+
* while `D:\x` and `\\server\share` are not valid POSIX paths.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} p - `remotePath` from the mirror marker
|
|
139
|
+
* @returns {boolean}
|
|
140
|
+
*/
|
|
141
|
+
function isWindowsRemotePath(p) {
|
|
142
|
+
return WINDOWS_REMOTE_PATH.test(String(p || '').trim())
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Argument list (without the ssh binary) for one connection.
|
|
147
|
+
*
|
|
148
|
+
* `-tt` is mandatory: the terminal needs a tty even though a command is given,
|
|
149
|
+
* otherwise the shell comes up non-interactive and every key press is ignored.
|
|
150
|
+
* Options are pushed in order and never spliced between an `-o` and its value —
|
|
151
|
+
* an inserted `-J` there once reached ssh as the value of `-o`, which aborted
|
|
152
|
+
* every connection that used a jump host (issue #21).
|
|
153
|
+
*
|
|
154
|
+
* @param {object} target - `{ host, port, username, alias, remotePath }`
|
|
155
|
+
* @param {object} [machine] - saved connection, for the key and transport hints
|
|
156
|
+
* @returns {string[]}
|
|
157
|
+
*/
|
|
158
|
+
function buildSshArgs(target, machine) {
|
|
159
|
+
const args = ['-tt']
|
|
160
|
+
const alias = target.alias || (machine && machine.useSshConfig ? machine.host : '')
|
|
161
|
+
if (alias) {
|
|
162
|
+
args.push(String(alias))
|
|
163
|
+
} else {
|
|
164
|
+
const port = Number(target.port || (machine && machine.port) || 22)
|
|
165
|
+
if (port !== 22) args.push('-p', String(port))
|
|
166
|
+
const key = machine && machine.privateKeyPath
|
|
167
|
+
if (key && !machine.useAgent) args.push('-i', String(key))
|
|
168
|
+
const user = target.username || (machine && machine.username) || ''
|
|
169
|
+
args.push(user ? `${user}@${target.host}` : String(target.host))
|
|
170
|
+
}
|
|
171
|
+
// A Windows workspace cannot be reached with the POSIX line: the remote's
|
|
172
|
+
// default shell may be cmd or PowerShell, and picking one would be a guess.
|
|
173
|
+
// Opening ssh with no command leaves the remote's own default shell in charge,
|
|
174
|
+
// so the terminal still works; only the automatic `cd` is given up.
|
|
175
|
+
if (!isWindowsRemotePath(target.remotePath)) args.push(remoteCommand(target.remotePath))
|
|
176
|
+
return args
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Prefer the OS OpenSSH binary on Windows. A PATH lookup there can reach an
|
|
181
|
+
* MSYS2/git-bash `ssh`, which resolves `~/.ssh` against a different home and
|
|
182
|
+
* makes keys saved by the host half look missing.
|
|
183
|
+
* @param {string} [platform]
|
|
184
|
+
* @param {object} [env]
|
|
185
|
+
* @param {(p: string) => boolean} [exists]
|
|
186
|
+
* @returns {string}
|
|
187
|
+
*/
|
|
188
|
+
function sshBinary(platform = process.platform, env = process.env, exists = existsSync) {
|
|
189
|
+
if (platform === 'win32') {
|
|
190
|
+
const system = env.SystemRoot || env.windir || 'C:\\Windows'
|
|
191
|
+
const native = path.join(system, 'System32', 'OpenSSH', 'ssh.exe')
|
|
192
|
+
if (exists(native)) return native
|
|
193
|
+
}
|
|
194
|
+
return 'ssh'
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The shell to fall back to when this directory is not a remote checkout.
|
|
199
|
+
* @param {string} [platform]
|
|
200
|
+
* @param {object} [env]
|
|
201
|
+
* @returns {string}
|
|
202
|
+
*/
|
|
203
|
+
function localShell(platform = process.platform, env = process.env) {
|
|
204
|
+
if (platform === 'win32') return env.ComSpec || 'cmd.exe'
|
|
205
|
+
return env.SHELL || '/bin/sh'
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Parse the arguments the launcher passes.
|
|
210
|
+
* @param {string[]} argv
|
|
211
|
+
* @returns {{ machines?: string }}
|
|
212
|
+
*/
|
|
213
|
+
function parseArgs(argv) {
|
|
214
|
+
const out = {}
|
|
215
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
216
|
+
if (argv[i] === '--machines' && argv[i + 1]) {
|
|
217
|
+
out.machines = argv[i + 1]
|
|
218
|
+
i += 1
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return out
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Resolve everything needed to connect from a working directory.
|
|
226
|
+
* @param {{ cwd?: string, machines?: string, exists?: Function, readJsonFile?: Function }} input
|
|
227
|
+
* @returns {{ target?: object, machine?: object, found: boolean }}
|
|
228
|
+
*/
|
|
229
|
+
function resolveTarget(input = {}) {
|
|
230
|
+
const cwd = input.cwd || process.cwd()
|
|
231
|
+
const mirror = findMirror(cwd, input)
|
|
232
|
+
if (!mirror) return { found: false }
|
|
233
|
+
const registry = input.machines ? (input.readJsonFile || readJson)(input.machines) : undefined
|
|
234
|
+
const machine = matchMachine(mirror.meta, registry)
|
|
235
|
+
const target = {
|
|
236
|
+
host: mirror.meta.host,
|
|
237
|
+
port: mirror.meta.port || (machine && machine.port) || 22,
|
|
238
|
+
username: mirror.meta.username || (machine && machine.username) || '',
|
|
239
|
+
alias: mirror.meta.alias || '',
|
|
240
|
+
remotePath: mirror.meta.remotePath || (machine && machine.workspace) || '',
|
|
241
|
+
}
|
|
242
|
+
return { found: true, target, machine, dir: mirror.dir }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Whether stdin is a console this process can actually receive keys from.
|
|
247
|
+
*
|
|
248
|
+
* A terminal that inherits stdio from `ELECTRON_RUN_AS_NODE` gets pipes, not
|
|
249
|
+
* the ConPTY console: output flows while every keystroke is dropped, leaving a
|
|
250
|
+
* tab that looks alive and accepts nothing (issue #91). Detecting that here
|
|
251
|
+
* turns a silent dead end into an explicit, actionable refusal. `isTTY` is the
|
|
252
|
+
* cheap check; `fstat(0)` catches runtimes that never set the flag.
|
|
253
|
+
*
|
|
254
|
+
* @param {{ stdin?: object, fstat?: (fd: number) => any }} [deps]
|
|
255
|
+
* @returns {boolean}
|
|
256
|
+
*/
|
|
257
|
+
function stdinIsInteractive(deps = {}) {
|
|
258
|
+
const stdin = deps.stdin === undefined ? process.stdin : deps.stdin
|
|
259
|
+
const fstat = deps.fstat || fstatSync
|
|
260
|
+
if (stdin && stdin.isTTY === true) return true
|
|
261
|
+
try {
|
|
262
|
+
const stat = fstat(0)
|
|
263
|
+
return Boolean(stat && typeof stat.isCharacterDevice === 'function' && stat.isCharacterDevice())
|
|
264
|
+
} catch {
|
|
265
|
+
return false
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Hand the current process over to `file args…`, propagating the exit code.
|
|
271
|
+
* @param {string} file
|
|
272
|
+
* @param {string[]} args
|
|
273
|
+
* @returns {import('node:child_process').ChildProcess}
|
|
274
|
+
*/
|
|
275
|
+
function handover(file, args) {
|
|
276
|
+
const child = spawn(file, args, { stdio: 'inherit' })
|
|
277
|
+
child.on('error', (error) => {
|
|
278
|
+
process.stderr.write(`[dsh-remote] cannot run ${file}: ${error && error.message}\n`)
|
|
279
|
+
process.exit(127)
|
|
280
|
+
})
|
|
281
|
+
child.on('exit', (code, signal) => {
|
|
282
|
+
if (signal) process.kill(process.pid, signal)
|
|
283
|
+
else process.exit(code == null ? 0 : code)
|
|
284
|
+
})
|
|
285
|
+
return child
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Entry point: connect, or explain why a local shell was used instead.
|
|
290
|
+
* @param {string[]} [argv]
|
|
291
|
+
* @param {{ cwd?: string, platform?: string, env?: object,
|
|
292
|
+
* stdin?: object, fstat?: (fd: number) => any,
|
|
293
|
+
* write?: (line: string) => void, exit?: (code: number) => any }} [deps]
|
|
294
|
+
*/
|
|
295
|
+
function main(argv = process.argv.slice(2), deps = {}) {
|
|
296
|
+
const platform = deps.platform || process.platform
|
|
297
|
+
const env = deps.env || process.env
|
|
298
|
+
const write = deps.write || ((line) => process.stderr.write(line))
|
|
299
|
+
const exit = deps.exit || ((code) => process.exit(code))
|
|
300
|
+
if (!stdinIsInteractive(deps)) {
|
|
301
|
+
// Anything we could hand over to — ssh, cmd, sh — reads stdin the same
|
|
302
|
+
// way, so there is no useful fallback here: the tab would render the
|
|
303
|
+
// remote prompt and ignore every key. Refuse loudly instead (issue #91).
|
|
304
|
+
write('[dsh-remote] this terminal cannot receive input: stdin is not a real console.\n')
|
|
305
|
+
write('[dsh-remote] point DSH_REMOTE_TERMINAL_NODE at an absolute node.exe path and restart, so the launcher does not have to reuse the app runtime.\n')
|
|
306
|
+
write('[dsh-remote] 终端无法接收输入(stdin 不是真实控制台)。请把 DSH_REMOTE_TERMINAL_NODE 指向 node.exe 的绝对路径后重启。\n')
|
|
307
|
+
exit(126)
|
|
308
|
+
return undefined
|
|
309
|
+
}
|
|
310
|
+
const options = parseArgs(argv)
|
|
311
|
+
const resolved = resolveTarget({ cwd: deps.cwd || process.cwd(), machines: options.machines })
|
|
312
|
+
if (!resolved.found) {
|
|
313
|
+
process.stderr.write('[dsh-remote] this directory is not a remote workspace; opening a local shell.\n')
|
|
314
|
+
process.stderr.write('[dsh-remote] 当前目录不是远程工作区,已打开本地终端。\n')
|
|
315
|
+
return handover(localShell(platform, env), platform === 'win32' ? [] : ['-l'])
|
|
316
|
+
}
|
|
317
|
+
const ssh = sshBinary(platform, env)
|
|
318
|
+
const args = buildSshArgs(resolved.target, resolved.machine)
|
|
319
|
+
const user = resolved.target.username || (resolved.machine && resolved.machine.username) || ''
|
|
320
|
+
process.stderr.write(`[dsh-remote] connecting to ${user ? user + '@' : ''}${resolved.target.host}…\n`)
|
|
321
|
+
if (isWindowsRemotePath(resolved.target.remotePath)) {
|
|
322
|
+
process.stderr.write('[dsh-remote] the remote workspace is a Windows path; opening the remote default shell without changing directory.\n')
|
|
323
|
+
process.stderr.write('[dsh-remote] 远端工作区是 Windows 路径,已打开默认 shell(未自动切换目录)。\n')
|
|
324
|
+
}
|
|
325
|
+
return handover(ssh, args)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
module.exports = {
|
|
329
|
+
META_FILE,
|
|
330
|
+
buildSshArgs,
|
|
331
|
+
findMirror,
|
|
332
|
+
isWindowsRemotePath,
|
|
333
|
+
localShell,
|
|
334
|
+
main,
|
|
335
|
+
matchMachine,
|
|
336
|
+
parseArgs,
|
|
337
|
+
remoteCd,
|
|
338
|
+
remoteCommand,
|
|
339
|
+
resolveTarget,
|
|
340
|
+
sshBinary,
|
|
341
|
+
stdinIsInteractive,
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (require.main === module) main()
|