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.
Files changed (44) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +470 -389
  3. package/README.md +529 -462
  4. package/cordis.patch.yml +9 -9
  5. package/lib/binding.js +172 -172
  6. package/lib/client.js +4039 -4022
  7. package/lib/credential.js +263 -263
  8. package/lib/errors.js +141 -141
  9. package/lib/exec-job.js +235 -235
  10. package/lib/file-reference.js +457 -457
  11. package/lib/forwards.js +258 -258
  12. package/lib/host-api.js +808 -0
  13. package/lib/hostkey.js +108 -108
  14. package/lib/http-transport.js +196 -196
  15. package/lib/ignore.js +202 -202
  16. package/lib/index.js +4923 -4541
  17. package/lib/instructions-dynamic.js +415 -415
  18. package/lib/openssh.js +879 -879
  19. package/lib/paths.js +252 -252
  20. package/lib/pool.js +963 -893
  21. package/lib/registry.js +296 -293
  22. package/lib/remote-fs.js +173 -173
  23. package/lib/remote-sandbox.js +183 -183
  24. package/lib/remote-shell-child.cjs +344 -305
  25. package/lib/remote-shell.js +542 -518
  26. package/lib/routes-fs.js +245 -245
  27. package/lib/routes-term.js +169 -169
  28. package/lib/search.js +235 -235
  29. package/lib/sftp-surface.js +119 -119
  30. package/lib/sshconfig.js +779 -705
  31. package/lib/sync.js +275 -275
  32. package/lib/tasks.js +95 -95
  33. package/lib/telemetry.js +213 -213
  34. package/lib/terminal.js +205 -205
  35. package/lib/tui/flow.js +130 -0
  36. package/lib/tui/format.js +332 -0
  37. package/lib/tui/i18n.js +344 -0
  38. package/lib/tui/index.js +322 -0
  39. package/lib/tui/provider.js +327 -0
  40. package/lib/tui/scene.js +996 -0
  41. package/lib/update.js +472 -472
  42. package/lib/web-attach.js +850 -850
  43. package/lib/workspace-cleanup.js +337 -0
  44. 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
- * Hand the current process over to `file args…`, propagating the exit code.
247
- * @param {string} file
248
- * @param {string[]} args
249
- * @returns {import('node:child_process').ChildProcess}
250
- */
251
- function handover(file, args) {
252
- const child = spawn(file, args, { stdio: 'inherit' })
253
- child.on('error', (error) => {
254
- process.stderr.write(`[dsh-remote] cannot run ${file}: ${error && error.message}\n`)
255
- process.exit(127)
256
- })
257
- child.on('exit', (code, signal) => {
258
- if (signal) process.kill(process.pid, signal)
259
- else process.exit(code == null ? 0 : code)
260
- })
261
- return child
262
- }
263
-
264
- /**
265
- * Entry point: connect, or explain why a local shell was used instead.
266
- * @param {string[]} [argv]
267
- * @param {{ cwd?: string, platform?: string, env?: object }} [deps]
268
- */
269
- function main(argv = process.argv.slice(2), deps = {}) {
270
- const platform = deps.platform || process.platform
271
- const env = deps.env || process.env
272
- const options = parseArgs(argv)
273
- const resolved = resolveTarget({ cwd: deps.cwd || process.cwd(), machines: options.machines })
274
- if (!resolved.found) {
275
- process.stderr.write('[dsh-remote] this directory is not a remote workspace; opening a local shell.\n')
276
- process.stderr.write('[dsh-remote] 当前目录不是远程工作区,已打开本地终端。\n')
277
- return handover(localShell(platform, env), platform === 'win32' ? [] : ['-l'])
278
- }
279
- const ssh = sshBinary(platform, env)
280
- const args = buildSshArgs(resolved.target, resolved.machine)
281
- const user = resolved.target.username || (resolved.machine && resolved.machine.username) || ''
282
- process.stderr.write(`[dsh-remote] connecting to ${user ? user + '@' : ''}${resolved.target.host}…\n`)
283
- if (isWindowsRemotePath(resolved.target.remotePath)) {
284
- process.stderr.write('[dsh-remote] the remote workspace is a Windows path; opening the remote default shell without changing directory.\n')
285
- process.stderr.write('[dsh-remote] 远端工作区是 Windows 路径,已打开默认 shell(未自动切换目录)。\n')
286
- }
287
- return handover(ssh, args)
288
- }
289
-
290
- module.exports = {
291
- META_FILE,
292
- buildSshArgs,
293
- findMirror,
294
- isWindowsRemotePath,
295
- localShell,
296
- main,
297
- matchMachine,
298
- parseArgs,
299
- remoteCd,
300
- remoteCommand,
301
- resolveTarget,
302
- sshBinary,
303
- }
304
-
305
- if (require.main === module) main()
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()