dsh-remote 0.8.72 → 0.8.73

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.
@@ -1,518 +1,542 @@
1
- /**
2
- * Remote terminal as a shell choice — the host terminal's own option list.
3
- *
4
- * The built-in terminal is a *local* pty whose program comes from
5
- * `dsh-api-terminal-controller`'s verified shell list (`shellCandidates`). A
6
- * plugin cannot hand that pty a command line, but it CAN contribute one entry
7
- * to that list: an absolute path to a launcher we generate. The pty then runs
8
- * our launcher as a local process, and the launcher execs `ssh` with inherited
9
- * stdio — which is exactly how the terminal should behave. Resizing, key
10
- * binding, scrollback and color all stay the host's job; nothing is re-implemented
11
- * here.
12
- *
13
- * The launcher itself is dumb (find node, run the child script). The child
14
- * (`remote-shell-child.cjs`) resolves the remote target from the checkout it is
15
- * started in and replaces itself with `ssh`.
16
- *
17
- * @module dsh-remote/remote-shell
18
- */
19
-
20
- import { chmodSync, existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
21
- import path from 'node:path'
22
-
23
- /** Label shown in the terminal's shell menu.
24
- *
25
- * Windows needs a real `.cmd` on disk (ConPTY cannot start an extensionless
26
- * file), but the label does not have to advertise it: the menu shows the
27
- * candidate's basename, and `shellCandidate` contributes the bare name so the
28
- * host resolves it through `PATH` + `PATHEXT`. The suffix is therefore an
29
- * implementation detail of the file on disk, not of the menu entry. */
30
- export const SHELL_OPTION_NAME = 'remote ssh'
31
-
32
- /** Absolute path of the generated launcher for this platform. */
33
- export function shellLauncherPath(dir, platform = process.platform) {
34
- return path.join(dir, platform === 'win32' ? SHELL_OPTION_NAME + '.cmd' : SHELL_OPTION_NAME)
35
- }
36
-
37
- /** Default `PATHEXT`, used when the environment does not carry one. */
38
- const DEFAULT_PATH_EXT = '.COM;.EXE;.BAT;.CMD'
39
-
40
- /**
41
- * The value to contribute to `shellCandidates`.
42
- *
43
- * The menu label is `executableName(candidate)` — the candidate's basename, with
44
- * no extension stripped — while the path that actually runs is whatever the host
45
- * resolves. Those are two different things, and the gap is the only way to get a
46
- * clean label on Windows.
47
- *
48
- * A file path would be shown as `remote ssh.cmd`, because the terminal has to
49
- * start a real `.cmd` and ConPTY refuses anything else (a file with no extension
50
- * dies with 193, `ERROR_BAD_EXE_FORMAT`). So on Windows the *bare* name is
51
- * contributed instead: the host resolves it through `PATH` + `PATHEXT`, lands on
52
- * `remote ssh.cmd`, and the label stays `remote ssh`. That is exactly the rule
53
- * `resolveExecutable` documents — an absolute path is checked as-is with no
54
- * extension guessing, a bare name is looked up per `PATHEXT`.
55
- *
56
- * Two consequences, both handled by the caller: the launcher's directory has to
57
- * be on `PATH` (see `appendPathEntry`), and the name must contain no dot, since
58
- * a bare name is only extended when `extname()` is empty.
59
- *
60
- * POSIX has no such rule — the file is directly executable — so the absolute
61
- * path is contributed as-is.
62
- *
63
- * @param {string} platform
64
- * @param {string} launcherPath
65
- * @returns {string} candidate value for the shell list
66
- */
67
- export function shellCandidate(platform, launcherPath) {
68
- if (platform !== 'win32') return launcherPath
69
- return path.basename(launcherPath).replace(/\.cmd$/i, '')
70
- }
71
-
72
- /**
73
- * Resolve a bare command name the way the host does, without spawning anything.
74
- *
75
- * A mirror of `subprocess-local`'s `executableCandidates` + `resolveExecutable`:
76
- * directory-major order over `PATH`, extension-minor over `PATHEXT`, and
77
- * extensions only when the name has none of its own. Used to decide whether the
78
- * bare-name contribution will actually resolve; if it will not, the caller falls
79
- * back to the absolute path and accepts the `.cmd` in the label.
80
- *
81
- * @param {string} name
82
- * @param {{ platform?: string, pathEnv?: string, pathExt?: string, cwd?: string,
83
- * fs?: { existsSync: (p: string) => boolean } }} [deps]
84
- * @returns {string} resolved path, or '' when nothing matches
85
- */
86
- export function resolveThroughPath(name, deps = {}) {
87
- const platform = deps.platform || process.platform
88
- const fs = deps.fs || { existsSync }
89
- const pathEnv = deps.pathEnv === undefined ? (process.env.PATH || '') : deps.pathEnv
90
- const pathExt = deps.pathExt === undefined
91
- ? (process.env.PATHEXT || DEFAULT_PATH_EXT)
92
- : deps.pathExt
93
- const cwd = deps.cwd || process.cwd()
94
- // The platform being MODELED owns the path semantics, not the platform this
95
- // process runs on: a win32 lookup splits PATH on ';' and understands drive
96
- // letters, which the runtime `path` only does when the host is Windows. On a
97
- // real host the two coincide, so this changes nothing outside tests.
98
- const pathApi = platform === 'win32' ? path.win32 : path.posix
99
- const extensions = platform === 'win32' && pathApi.extname(name) === ''
100
- ? pathExt.split(';').filter(Boolean)
101
- : ['']
102
- const delimiter = platform === 'win32' ? ';' : ':'
103
- for (const directory of pathEnv.split(delimiter)) {
104
- if (!directory) continue
105
- for (const extension of extensions) {
106
- const candidate = pathApi.resolve(cwd, directory, name + extension)
107
- try {
108
- if (fs.existsSync(candidate)) return candidate
109
- } catch { /* an unreadable directory is simply skipped */ }
110
- }
111
- }
112
- return ''
113
- }
114
-
115
- /** Per-environment ownership of the entries this module added. */
116
- const pathEntries = new WeakMap()
117
-
118
- /**
119
- * Append a directory to the process `PATH`, idempotently and ref-counted.
120
- *
121
- * Appended, never prepended: a plugin has no business shadowing a program the
122
- * user already has, and the launcher's name cannot collide by accident.
123
- *
124
- * The entry is ref-counted per environment object because the contribution can
125
- * be installed more than once in a process lifetime — a reload disposes the old
126
- * effect and installs a new one, and the two callbacks may interleave. A plain
127
- * "set/clear" pair would let the first disposal strip the entry out from under
128
- * the still-live registration, which only shows up much later as the menu entry
129
- * silently vanishing (the host cannot resolve a bare name that is no longer on
130
- * PATH).
131
- *
132
- * A directory that was already on `PATH` before we touched it is marked as
133
- * externally owned and is never removed by us.
134
- *
135
- * @param {string} dir - absolute directory to expose
136
- * @param {object} [env] - environment object; defaults to `process.env`
137
- * @returns {() => void} undo, safe to call more than once
138
- * @throws {Error} when the variable cannot be written
139
- */
140
- export function appendPathEntry(dir, env = process.env) {
141
- const absolute = path.resolve(dir)
142
- const key = Object.keys(env).find((name) => name.toUpperCase() === 'PATH') || 'PATH'
143
- let owned = pathEntries.get(env)
144
- if (!owned) {
145
- owned = new Map()
146
- pathEntries.set(env, owned)
147
- }
148
- const count = owned.get(absolute)
149
- if (count === undefined) {
150
- const current = env[key] || ''
151
- const parts = current.split(path.delimiter).filter(Boolean)
152
- if (parts.some((entry) => path.resolve(entry) === absolute)) {
153
- owned.set(absolute, 0) // pre-existing: ours to use, not ours to remove
154
- } else {
155
- env[key] = current === '' ? dir : current + path.delimiter + dir
156
- owned.set(absolute, 1)
157
- }
158
- } else if (count > 0) {
159
- owned.set(absolute, count + 1)
160
- }
161
- return () => {
162
- const live = owned.get(absolute)
163
- if (live === undefined || live <= 0) return
164
- if (live > 1) {
165
- owned.set(absolute, live - 1)
166
- return
167
- }
168
- owned.delete(absolute)
169
- const kept = (env[key] || '')
170
- .split(path.delimiter)
171
- .filter((entry) => entry !== '' && path.resolve(entry) !== absolute)
172
- env[key] = kept.join(path.delimiter)
173
- }
174
- }
175
-
176
- /**
177
- * Node interpreters worth baking into the launcher, most specific first.
178
- *
179
- * The desktop host does not put its bundled node on a child's PATH (by design),
180
- * so an absolute path is the only reliable option; `where node` is a last
181
- * resort. When no node is found the launcher falls back to re-entering the
182
- * Electron binary with `ELECTRON_RUN_AS_NODE`, which needs no second runtime.
183
- *
184
- * Electron puts its resources beside the executable on Windows and Linux, but
185
- * under `Contents/Resources` on macOS, so the path derived from `execPath` has
186
- * to follow the platform; the `resourcesPath` candidate already does.
187
- *
188
- * @param {{ execPath?: string, resourcesPath?: string, platform?: string, env?: object }} deps
189
- * @returns {string[]} existing interpreter candidates, in preference order
190
- */
191
- export function runtimeNodeCandidates(deps = {}) {
192
- const platform = deps.platform || process.platform
193
- const env = deps.env || process.env
194
- // `undefined` means "ask the process", `''` means "this host has no such
195
- // executable" — callers that model another platform rely on the difference.
196
- const execPath = deps.execPath === undefined ? (process.execPath || '') : deps.execPath
197
- const exe = platform === 'win32' ? 'node.exe' : 'node'
198
- const bundledNode = ['runtime', 'primary-runtime', 'dependencies', 'node', 'bin', exe]
199
- const out = []
200
- const push = (value) => { if (value && !out.includes(value)) out.push(value) }
201
- push(env.DSH_REMOTE_TERMINAL_NODE)
202
- if (deps.resourcesPath) push(path.join(deps.resourcesPath, ...bundledNode))
203
- if (execPath) {
204
- const here = path.dirname(execPath)
205
- push(path.join(platform === 'darwin' ? path.join(here, '..', 'Resources') : path.join(here, 'resources'), ...bundledNode))
206
- // A plain node install: execPath IS the interpreter.
207
- if (!deps.electron && path.basename(execPath).toLowerCase() === exe) push(execPath)
208
- }
209
- return out.filter((candidate) => {
210
- try { return existsSync(candidate) } catch { return false }
211
- })
212
- }
213
-
214
- /**
215
- * Body of the generated launcher. Pure so it can be asserted without touching
216
- * the filesystem.
217
- *
218
- * @param {{ platform?: string, nodePath?: string, electronPath?: string, childPath: string, machinesFile: string }} spec
219
- * @returns {string} launcher source, runnable as-is
220
- */
221
- export function launcherSource(spec) {
222
- const platform = spec.platform || process.platform
223
- const child = spec.childPath
224
- const machines = spec.machinesFile
225
- if (platform === 'win32') {
226
- const node = spec.nodePath || ''
227
- const electron = spec.electronPath || ''
228
- const lines = [
229
- '@echo off',
230
- 'rem Generated by dsh-remote - do not edit; rewritten on every load.',
231
- 'setlocal',
232
- `set "DSH_CHILD=${child}"`,
233
- `set "DSH_MACHINES=${machines}"`,
234
- `set "DSH_NODE=${node}"`,
235
- 'if defined DSH_NODE if exist "%DSH_NODE%" goto :run',
236
- 'set "DSH_NODE="',
237
- 'for /f "delims=" %%N in (\'where node 2^>nul\') do if not defined DSH_NODE set "DSH_NODE=%%N"',
238
- 'if defined DSH_NODE goto :run',
239
- ]
240
- // Re-entering the Electron binary as node only makes sense when this host
241
- // IS an Electron app; a plain node install has nothing to re-enter.
242
- if (electron) {
243
- lines.push(
244
- 'set "ELECTRON_RUN_AS_NODE=1"',
245
- `"${electron}" "%DSH_CHILD%" --machines "%DSH_MACHINES%"`,
246
- 'exit /b %ERRORLEVEL%',
247
- )
248
- }
249
- lines.push(
250
- 'echo [dsh-remote] no node interpreter found for the remote terminal. 1>&2',
251
- 'echo [dsh-remote] set DSH_REMOTE_TERMINAL_NODE to an absolute node.exe path, or reinstall dsh-remote. 1>&2',
252
- 'exit /b 127',
253
- ':run',
254
- '"%DSH_NODE%" "%DSH_CHILD%" --machines "%DSH_MACHINES%"',
255
- 'exit /b %ERRORLEVEL%',
256
- '',
257
- )
258
- return lines.join('\r\n')
259
- }
260
- const node = spec.nodePath || ''
261
- return [
262
- '#!/bin/sh',
263
- '# Generated by dsh-remote - do not edit; rewritten on every load.',
264
- `DSH_CHILD=${shqPosix(child)}`,
265
- `DSH_MACHINES=${shqPosix(machines)}`,
266
- node ? `DSH_NODE=${shqPosix(node)}` : 'DSH_NODE=',
267
- 'if [ -n "$DSH_NODE" ] && [ -x "$DSH_NODE" ]; then exec "$DSH_NODE" "$DSH_CHILD" --machines "$DSH_MACHINES"; fi',
268
- 'if command -v node >/dev/null 2>&1; then exec node "$DSH_CHILD" --machines "$DSH_MACHINES"; fi',
269
- 'echo "[dsh-remote] no node interpreter found for the remote terminal." >&2',
270
- 'exit 127',
271
- '',
272
- ].join('\n')
273
- }
274
-
275
- /** POSIX single-quote, for paths baked into a shell script. */
276
- export function shqPosix(value) {
277
- return "'" + String(value).replace(/'/g, "'\\''") + "'"
278
- }
279
-
280
- /**
281
- * Remove a launcher left on disk under another spelling of the same name.
282
- *
283
- * The menu label IS the file name, so renaming the option renames the file. On
284
- * a case-insensitive volume (NTFS, and APFS by default) writing the new spelling
285
- * lands in the OLD directory entry and the menu keeps showing the previous
286
- * name; on a case-sensitive one the old file would simply linger and, having
287
- * been registered before, could still appear. Deleting the stale entry first
288
- * makes a rename take effect on both.
289
- *
290
- * Best effort throughout: a launcher that cannot be cleaned up must not stop
291
- * the new one from being written.
292
- *
293
- * @param {string} dir
294
- * @param {string} keepName - basename that must survive
295
- * @param {object} fs
296
- */
297
- function removeStaleLauncher(dir, keepName, fs) {
298
- if (typeof fs.readdirSync !== 'function' || typeof fs.unlinkSync !== 'function') return
299
- let entries
300
- try {
301
- entries = fs.readdirSync(dir)
302
- } catch {
303
- return // first run: the directory does not exist yet
304
- }
305
- for (const entry of entries) {
306
- if (entry === keepName || entry.toLowerCase() !== keepName.toLowerCase()) continue
307
- try { fs.unlinkSync(path.join(dir, entry)) } catch { /* keep going */ }
308
- }
309
- }
310
-
311
- /**
312
- * Write (or refresh) the launcher. Idempotent and cheap: the content is a pure
313
- * function of this install's paths, so a plugin upgrade that moves the package
314
- * rewrites the same file with the new child path.
315
- *
316
- * @param {{ dir: string, platform?: string, childPath: string, machinesFile: string, nodePath?: string, electronPath?: string, fs?: object }} spec
317
- * @returns {{ path: string, source: string }}
318
- */
319
- export function writeLauncher(spec) {
320
- const platform = spec.platform || process.platform
321
- const fs = spec.fs || { mkdirSync, writeFileSync, chmodSync, readdirSync, unlinkSync }
322
- fs.mkdirSync(spec.dir, { recursive: true })
323
- const file = shellLauncherPath(spec.dir, platform)
324
- // Before writing: on a case-insensitive filesystem the new name would
325
- // otherwise be absorbed into the old entry.
326
- removeStaleLauncher(spec.dir, path.basename(file), fs)
327
- const source = launcherSource({ ...spec, platform })
328
- fs.writeFileSync(file, source, 'utf8')
329
- if (platform !== 'win32') {
330
- try { fs.chmodSync(file, 0o755) } catch { /* a no-op on filesystems without modes */ }
331
- }
332
- return { path: file, source }
333
- }
334
-
335
- /**
336
- * Add one entry to the host terminal's shell list.
337
- *
338
- * `shellCandidates` is the exact array `discoverShells` reads on every `shells()`
339
- * call, so appending to it is live: the next time the user opens the shell menu
340
- * the entry is there, and `spawn()` accepts the matching `shellPath` because it
341
- * looks the path up in that same list. Removing it on dispose keeps the menu
342
- * truthful when the plugin is unloaded.
343
- *
344
- * A host without the field (older line, or a controller rebuilt to hide the
345
- * array) is not an error: the plugin simply contributes no shell. A host that
346
- * HAS the field but refuses the write is a different story — that throws, so the
347
- * caller can say why the option never appeared instead of leaving the user to
348
- * guess.
349
- *
350
- * @param {object} controller - the `terminalController` service
351
- * @param {string} candidate - absolute launcher path
352
- * @returns {(() => void) | null} undo, or null when there is nothing to undo
353
- * @throws {TypeError} when the host exposes a shell list that cannot be written
354
- */
355
- export function addShellCandidate(controller, candidate) {
356
- const config = controller && controller.config
357
- const list = config && config.shellCandidates
358
- if (!Array.isArray(list)) return null
359
- if (list.includes(candidate)) return null
360
- list.push(candidate)
361
- return () => {
362
- const at = list.indexOf(candidate)
363
- if (at >= 0) list.splice(at, 1)
364
- }
365
- }
366
-
367
- /** Human-readable reason from anything thrown. */
368
- function reason(err) {
369
- return String((err && err.message) || err)
370
- }
371
-
372
- /** Read an environment variable case-insensitively, as Windows does. */
373
- function envValue(env, name) {
374
- if (!env) return undefined
375
- if (env[name] !== undefined) return env[name]
376
- const match = Object.keys(env).find((key) => key.toUpperCase() === name.toUpperCase())
377
- return match === undefined ? undefined : env[match]
378
- }
379
-
380
- /**
381
- * Write the launcher and put it in the terminal's shell menu.
382
- *
383
- * Kept out of the plugin entry so the whole contribution — write, publish,
384
- * withdraw — can be exercised without booting the host. Every way this can
385
- * fail to produce a visible menu entry is reported through `logger.warn`,
386
- * because the failure mode that matters is silent: a launcher on disk plus no
387
- * option in the menu is indistinguishable from "the feature was never built".
388
- *
389
- * @param {object} ctx - plugin context; only `effect`, `get('logger')` are used
390
- * @param {object} controller - the resolved `terminalController` *service*
391
- * @param {{ dir: string, childPath: string, machinesFile: string, platform?: string,
392
- * nodePath?: string, electronPath?: string, electron?: boolean, logger?: object,
393
- * env?: object, fs?: { existsSync: (p: string) => boolean } }} spec
394
- * @returns {boolean} whether the option is (or already was) in the menu
395
- */
396
- export function installTerminalShell(ctx, controller, spec = {}) {
397
- const logger = spec.logger !== undefined
398
- ? spec.logger
399
- : (ctx && typeof ctx.get === 'function' ? ctx.get('logger') : undefined)
400
- const warn = (message) => {
401
- try {
402
- if (logger && typeof logger.warn === 'function') logger.warn(`dsh-remote: ${message}`)
403
- } catch { /* a logger failure must not change the outcome */ }
404
- }
405
-
406
- const platform = spec.platform || process.platform
407
- const electron = spec.electron === undefined
408
- ? Boolean(process.versions && process.versions.electron)
409
- : spec.electron
410
- const nodePath = spec.nodePath === undefined
411
- ? (runtimeNodeCandidates({
412
- platform,
413
- resourcesPath: process.resourcesPath,
414
- execPath: process.execPath,
415
- env: process.env,
416
- electron,
417
- })[0] || '')
418
- : spec.nodePath
419
- const electronPath = spec.electronPath === undefined
420
- ? (electron ? process.execPath : '')
421
- : spec.electronPath
422
-
423
- let launcher
424
- try {
425
- launcher = writeLauncher({
426
- dir: spec.dir,
427
- platform,
428
- childPath: spec.childPath,
429
- machinesFile: spec.machinesFile,
430
- nodePath,
431
- electronPath,
432
- })
433
- } catch (err) {
434
- // A read-only DSH_HOME must not take the plugin down: the terminal just
435
- // keeps offering the shells it already had.
436
- warn(`cannot write the remote terminal launcher: ${reason(err)}`)
437
- return false
438
- }
439
-
440
- if (controller === undefined || controller === null) {
441
- warn('the terminal controller is unavailable; the remote ssh option is unavailable')
442
- return false
443
- }
444
- const list = controller.config && controller.config.shellCandidates
445
- if (!Array.isArray(list)) {
446
- // Either an older host, or the wrong object was passed in — the service is
447
- // reached with `ctx.get('terminalController')`, not with the inject callback
448
- // argument itself. Say which one it looks like.
449
- const looksLikeContext = typeof controller.get === 'function' || typeof controller.inject === 'function'
450
- warn(looksLikeContext
451
- ? 'the terminal controller was not resolved (a plugin context was passed instead); the remote ssh option is unavailable'
452
- : 'the terminal controller exposes no shellCandidates list; the remote ssh option is unavailable')
453
- return false
454
- }
455
-
456
- // On Windows the label is the candidate's basename, so contributing a file
457
- // path would show `remote ssh.cmd`. Contributing the bare name keeps the label
458
- // clean, at the price of exposing the launcher directory on PATH — and only
459
- // while the host can actually resolve that name.
460
- const bare = shellCandidate(platform, launcher.path)
461
- const needsPath = bare !== launcher.path
462
- const env = spec.env || process.env
463
- const fs = spec.fs || { existsSync }
464
-
465
- let installed = false
466
- const publish = () => {
467
- // The PATH entry lives inside the effect, not outside it: if the effect ever
468
- // re-runs, the new run must put the entry back that the previous disposal
469
- // took away. `appendPathEntry` ref-counts, so interleaved install/dispose
470
- // pairs cannot strand a live registration without PATH.
471
- let restorePath
472
- let candidate = launcher.path
473
- if (needsPath) {
474
- try {
475
- restorePath = appendPathEntry(path.dirname(launcher.path), env)
476
- const found = resolveThroughPath(bare, {
477
- platform,
478
- pathEnv: envValue(env, 'PATH') || '',
479
- pathExt: envValue(env, 'PATHEXT'),
480
- fs,
481
- })
482
- if (found) candidate = bare
483
- else {
484
- // `PATHEXT` without `.CMD`, or a PATH that refuses the write. The
485
- // option still works when contributed by absolute path; only its
486
- // label carries the suffix.
487
- restorePath()
488
- restorePath = undefined
489
- warn('the host cannot resolve the launcher by name; the remote ssh option keeps its .cmd suffix')
490
- }
491
- } catch (err) {
492
- candidate = launcher.path
493
- restorePath = undefined
494
- warn(`the launcher directory cannot be added to PATH (${reason(err)}); the remote ssh option keeps its .cmd suffix`)
495
- }
496
- }
497
- try {
498
- const undo = addShellCandidate(controller, candidate)
499
- installed = true
500
- return () => {
501
- if (undo) undo()
502
- if (restorePath) restorePath()
503
- }
504
- } catch (err) {
505
- // The host advertises a shell list but will not take a write.
506
- if (restorePath) restorePath()
507
- warn(`the terminal shell list rejected the remote shell (${reason(err)}); the remote ssh option is unavailable`)
508
- return undefined
509
- }
510
- }
511
-
512
- if (ctx && typeof ctx.effect === 'function') {
513
- ctx.effect(publish, 'dsh-remote.terminal-shell')
514
- } else {
515
- publish()
516
- }
517
- return installed
518
- }
1
+ /**
2
+ * Remote terminal as a shell choice — the host terminal's own option list.
3
+ *
4
+ * The built-in terminal is a *local* pty whose program comes from
5
+ * `dsh-api-terminal-controller`'s verified shell list (`shellCandidates`). A
6
+ * plugin cannot hand that pty a command line, but it CAN contribute one entry
7
+ * to that list: an absolute path to a launcher we generate. The pty then runs
8
+ * our launcher as a local process, and the launcher execs `ssh` with inherited
9
+ * stdio — which is exactly how the terminal should behave. Resizing, key
10
+ * binding, scrollback and color all stay the host's job; nothing is re-implemented
11
+ * here.
12
+ *
13
+ * The launcher itself is dumb (find node, run the child script). The child
14
+ * (`remote-shell-child.cjs`) resolves the remote target from the checkout it is
15
+ * started in and replaces itself with `ssh`.
16
+ *
17
+ * @module dsh-remote/remote-shell
18
+ */
19
+
20
+ import { chmodSync, existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
21
+ import path from 'node:path'
22
+
23
+ /** Label shown in the terminal's shell menu.
24
+ *
25
+ * Windows needs a real `.cmd` on disk (ConPTY cannot start an extensionless
26
+ * file), but the label does not have to advertise it: the menu shows the
27
+ * candidate's basename, and `shellCandidate` contributes the bare name so the
28
+ * host resolves it through `PATH` + `PATHEXT`. The suffix is therefore an
29
+ * implementation detail of the file on disk, not of the menu entry. */
30
+ export const SHELL_OPTION_NAME = 'remote ssh'
31
+
32
+ /** Absolute path of the generated launcher for this platform. */
33
+ export function shellLauncherPath(dir, platform = process.platform) {
34
+ return path.join(dir, platform === 'win32' ? SHELL_OPTION_NAME + '.cmd' : SHELL_OPTION_NAME)
35
+ }
36
+
37
+ /** Default `PATHEXT`, used when the environment does not carry one. */
38
+ const DEFAULT_PATH_EXT = '.COM;.EXE;.BAT;.CMD'
39
+
40
+ /**
41
+ * The value to contribute to `shellCandidates`.
42
+ *
43
+ * The menu label is `executableName(candidate)` — the candidate's basename, with
44
+ * no extension stripped — while the path that actually runs is whatever the host
45
+ * resolves. Those are two different things, and the gap is the only way to get a
46
+ * clean label on Windows.
47
+ *
48
+ * A file path would be shown as `remote ssh.cmd`, because the terminal has to
49
+ * start a real `.cmd` and ConPTY refuses anything else (a file with no extension
50
+ * dies with 193, `ERROR_BAD_EXE_FORMAT`). So on Windows the *bare* name is
51
+ * contributed instead: the host resolves it through `PATH` + `PATHEXT`, lands on
52
+ * `remote ssh.cmd`, and the label stays `remote ssh`. That is exactly the rule
53
+ * `resolveExecutable` documents — an absolute path is checked as-is with no
54
+ * extension guessing, a bare name is looked up per `PATHEXT`.
55
+ *
56
+ * Two consequences, both handled by the caller: the launcher's directory has to
57
+ * be on `PATH` (see `appendPathEntry`), and the name must contain no dot, since
58
+ * a bare name is only extended when `extname()` is empty.
59
+ *
60
+ * POSIX has no such rule — the file is directly executable — so the absolute
61
+ * path is contributed as-is.
62
+ *
63
+ * @param {string} platform
64
+ * @param {string} launcherPath
65
+ * @returns {string} candidate value for the shell list
66
+ */
67
+ export function shellCandidate(platform, launcherPath) {
68
+ if (platform !== 'win32') return launcherPath
69
+ return path.basename(launcherPath).replace(/\.cmd$/i, '')
70
+ }
71
+
72
+ /**
73
+ * Resolve a bare command name the way the host does, without spawning anything.
74
+ *
75
+ * A mirror of `subprocess-local`'s `executableCandidates` + `resolveExecutable`:
76
+ * directory-major order over `PATH`, extension-minor over `PATHEXT`, and
77
+ * extensions only when the name has none of its own. Used to decide whether the
78
+ * bare-name contribution will actually resolve; if it will not, the caller falls
79
+ * back to the absolute path and accepts the `.cmd` in the label.
80
+ *
81
+ * @param {string} name
82
+ * @param {{ platform?: string, pathEnv?: string, pathExt?: string, cwd?: string,
83
+ * fs?: { existsSync: (p: string) => boolean } }} [deps]
84
+ * @returns {string} resolved path, or '' when nothing matches
85
+ */
86
+ export function resolveThroughPath(name, deps = {}) {
87
+ const platform = deps.platform || process.platform
88
+ const fs = deps.fs || { existsSync }
89
+ const pathEnv = deps.pathEnv === undefined ? (process.env.PATH || '') : deps.pathEnv
90
+ const pathExt = deps.pathExt === undefined
91
+ ? (process.env.PATHEXT || DEFAULT_PATH_EXT)
92
+ : deps.pathExt
93
+ const cwd = deps.cwd || process.cwd()
94
+ // The platform being MODELED owns the path semantics, not the platform this
95
+ // process runs on: a win32 lookup splits PATH on ';' and understands drive
96
+ // letters, which the runtime `path` only does when the host is Windows. On a
97
+ // real host the two coincide, so this changes nothing outside tests.
98
+ const pathApi = platform === 'win32' ? path.win32 : path.posix
99
+ const extensions = platform === 'win32' && pathApi.extname(name) === ''
100
+ ? pathExt.split(';').filter(Boolean)
101
+ : ['']
102
+ const delimiter = platform === 'win32' ? ';' : ':'
103
+ for (const directory of pathEnv.split(delimiter)) {
104
+ if (!directory) continue
105
+ for (const extension of extensions) {
106
+ const candidate = pathApi.resolve(cwd, directory, name + extension)
107
+ try {
108
+ if (fs.existsSync(candidate)) return candidate
109
+ } catch { /* an unreadable directory is simply skipped */ }
110
+ }
111
+ }
112
+ return ''
113
+ }
114
+
115
+ /** Per-environment ownership of the entries this module added. */
116
+ const pathEntries = new WeakMap()
117
+
118
+ /**
119
+ * Append a directory to the process `PATH`, idempotently and ref-counted.
120
+ *
121
+ * Appended, never prepended: a plugin has no business shadowing a program the
122
+ * user already has, and the launcher's name cannot collide by accident.
123
+ *
124
+ * The entry is ref-counted per environment object because the contribution can
125
+ * be installed more than once in a process lifetime — a reload disposes the old
126
+ * effect and installs a new one, and the two callbacks may interleave. A plain
127
+ * "set/clear" pair would let the first disposal strip the entry out from under
128
+ * the still-live registration, which only shows up much later as the menu entry
129
+ * silently vanishing (the host cannot resolve a bare name that is no longer on
130
+ * PATH).
131
+ *
132
+ * A directory that was already on `PATH` before we touched it is marked as
133
+ * externally owned and is never removed by us.
134
+ *
135
+ * @param {string} dir - absolute directory to expose
136
+ * @param {object} [env] - environment object; defaults to `process.env`
137
+ * @returns {() => void} undo, safe to call more than once
138
+ * @throws {Error} when the variable cannot be written
139
+ */
140
+ export function appendPathEntry(dir, env = process.env) {
141
+ const absolute = path.resolve(dir)
142
+ const key = Object.keys(env).find((name) => name.toUpperCase() === 'PATH') || 'PATH'
143
+ let owned = pathEntries.get(env)
144
+ if (!owned) {
145
+ owned = new Map()
146
+ pathEntries.set(env, owned)
147
+ }
148
+ const count = owned.get(absolute)
149
+ if (count === undefined) {
150
+ const current = env[key] || ''
151
+ const parts = current.split(path.delimiter).filter(Boolean)
152
+ if (parts.some((entry) => path.resolve(entry) === absolute)) {
153
+ owned.set(absolute, 0) // pre-existing: ours to use, not ours to remove
154
+ } else {
155
+ env[key] = current === '' ? dir : current + path.delimiter + dir
156
+ owned.set(absolute, 1)
157
+ }
158
+ } else if (count > 0) {
159
+ owned.set(absolute, count + 1)
160
+ }
161
+ return () => {
162
+ const live = owned.get(absolute)
163
+ if (live === undefined || live <= 0) return
164
+ if (live > 1) {
165
+ owned.set(absolute, live - 1)
166
+ return
167
+ }
168
+ owned.delete(absolute)
169
+ const kept = (env[key] || '')
170
+ .split(path.delimiter)
171
+ .filter((entry) => entry !== '' && path.resolve(entry) !== absolute)
172
+ env[key] = kept.join(path.delimiter)
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Node interpreters worth baking into the launcher, most specific first.
178
+ *
179
+ * The desktop host does not put its bundled node on a child's PATH (by design),
180
+ * so an absolute path is the only reliable option; `where node` is a last
181
+ * resort. When no node is found the launcher falls back to re-entering the
182
+ * Electron binary with `ELECTRON_RUN_AS_NODE`, which needs no second runtime.
183
+ *
184
+ * Electron puts its resources beside the executable on Windows and Linux, but
185
+ * under `Contents/Resources` on macOS, so the path derived from `execPath` has
186
+ * to follow the platform; the `resourcesPath` candidate already does.
187
+ *
188
+ * The runtime directory itself moved once already: DSH NEXT 2.x nests it under
189
+ * an architecture segment (`resources/runtime/x64/primary-runtime/…`) where
190
+ * older builds kept it directly under `resources/runtime` (issue #91). The
191
+ * exact arch is tried first, the pre-2.x layout second, and any other
192
+ * single-level directory under `runtime/` last, so a renamed arch segment
193
+ * still resolves instead of falling through to the Electron masquerade.
194
+ *
195
+ * @param {{ execPath?: string, resourcesPath?: string, platform?: string, arch?: string,
196
+ * env?: object, electron?: boolean }} deps
197
+ * @returns {string[]} existing interpreter candidates, in preference order
198
+ */
199
+ export function runtimeNodeCandidates(deps = {}) {
200
+ const platform = deps.platform || process.platform
201
+ const env = deps.env || process.env
202
+ const arch = deps.arch || process.arch
203
+ // `undefined` means "ask the process", `''` means "this host has no such
204
+ // executable" — callers that model another platform rely on the difference.
205
+ const execPath = deps.execPath === undefined ? (process.execPath || '') : deps.execPath
206
+ const exe = platform === 'win32' ? 'node.exe' : 'node'
207
+ const suffix = ['primary-runtime', 'dependencies', 'node', 'bin', exe]
208
+ const out = []
209
+ const push = (value) => { if (value && !out.includes(value)) out.push(value) }
210
+ const pushFrom = (resourcesDir) => {
211
+ if (!resourcesDir) return
212
+ push(path.join(resourcesDir, 'runtime', arch, ...suffix))
213
+ push(path.join(resourcesDir, 'runtime', ...suffix))
214
+ let entries = []
215
+ try { entries = readdirSync(path.join(resourcesDir, 'runtime')) } catch { /* no runtime dir */ }
216
+ for (const entry of entries) {
217
+ if (entry !== arch) push(path.join(resourcesDir, 'runtime', entry, ...suffix))
218
+ }
219
+ }
220
+ push(env.DSH_REMOTE_TERMINAL_NODE)
221
+ pushFrom(deps.resourcesPath)
222
+ if (execPath) {
223
+ const here = path.dirname(execPath)
224
+ pushFrom(platform === 'darwin' ? path.join(here, '..', 'Resources') : path.join(here, 'resources'))
225
+ // A plain node install: execPath IS the interpreter.
226
+ if (!deps.electron && path.basename(execPath).toLowerCase() === exe) push(execPath)
227
+ }
228
+ return out.filter((candidate) => {
229
+ try { return existsSync(candidate) } catch { return false }
230
+ })
231
+ }
232
+
233
+ /**
234
+ * Body of the generated launcher. Pure so it can be asserted without touching
235
+ * the filesystem.
236
+ *
237
+ * @param {{ platform?: string, nodePath?: string, electronPath?: string, childPath: string, machinesFile: string }} spec
238
+ * @returns {string} launcher source, runnable as-is
239
+ */
240
+ export function launcherSource(spec) {
241
+ const platform = spec.platform || process.platform
242
+ const child = spec.childPath
243
+ const machines = spec.machinesFile
244
+ if (platform === 'win32') {
245
+ const node = spec.nodePath || ''
246
+ const electron = spec.electronPath || ''
247
+ const lines = [
248
+ '@echo off',
249
+ 'rem Generated by dsh-remote - do not edit; rewritten on every load.',
250
+ 'setlocal',
251
+ `set "DSH_CHILD=${child}"`,
252
+ `set "DSH_MACHINES=${machines}"`,
253
+ `set "DSH_NODE=${node}"`,
254
+ 'if defined DSH_NODE if exist "%DSH_NODE%" goto :run',
255
+ 'set "DSH_NODE="',
256
+ 'for /f "delims=" %%N in (\'where node 2^>nul\') do if not defined DSH_NODE set "DSH_NODE=%%N"',
257
+ 'if defined DSH_NODE goto :run',
258
+ ]
259
+ // Re-entering the Electron binary as node only makes sense when this host
260
+ // IS an Electron app; a plain node install has nothing to re-enter.
261
+ if (electron) {
262
+ lines.push(
263
+ // The hint must run BEFORE the fallback: anything after `exit /b` is
264
+ // dead code, which is exactly how the deaf-terminal hint used to be
265
+ // unreachable precisely in the configuration that needed it (issue #91).
266
+ 'echo [dsh-remote] no standalone node found; falling back to the bundled runtime. 1>&2',
267
+ 'echo [dsh-remote] if this terminal shows output but ignores input, set DSH_REMOTE_TERMINAL_NODE to an absolute node.exe path. 1>&2',
268
+ 'set "ELECTRON_RUN_AS_NODE=1"',
269
+ `"${electron}" "%DSH_CHILD%" --machines "%DSH_MACHINES%"`,
270
+ 'exit /b %ERRORLEVEL%',
271
+ )
272
+ }
273
+ lines.push(
274
+ 'echo [dsh-remote] no node interpreter found for the remote terminal. 1>&2',
275
+ 'echo [dsh-remote] set DSH_REMOTE_TERMINAL_NODE to an absolute node.exe path, or reinstall dsh-remote. 1>&2',
276
+ 'exit /b 127',
277
+ ':run',
278
+ '"%DSH_NODE%" "%DSH_CHILD%" --machines "%DSH_MACHINES%"',
279
+ 'exit /b %ERRORLEVEL%',
280
+ '',
281
+ )
282
+ return lines.join('\r\n')
283
+ }
284
+ const node = spec.nodePath || ''
285
+ return [
286
+ '#!/bin/sh',
287
+ '# Generated by dsh-remote - do not edit; rewritten on every load.',
288
+ `DSH_CHILD=${shqPosix(child)}`,
289
+ `DSH_MACHINES=${shqPosix(machines)}`,
290
+ node ? `DSH_NODE=${shqPosix(node)}` : 'DSH_NODE=',
291
+ 'if [ -n "$DSH_NODE" ] && [ -x "$DSH_NODE" ]; then exec "$DSH_NODE" "$DSH_CHILD" --machines "$DSH_MACHINES"; fi',
292
+ 'if command -v node >/dev/null 2>&1; then exec node "$DSH_CHILD" --machines "$DSH_MACHINES"; fi',
293
+ 'echo "[dsh-remote] no node interpreter found for the remote terminal." >&2',
294
+ 'exit 127',
295
+ '',
296
+ ].join('\n')
297
+ }
298
+
299
+ /** POSIX single-quote, for paths baked into a shell script. */
300
+ export function shqPosix(value) {
301
+ return "'" + String(value).replace(/'/g, "'\\''") + "'"
302
+ }
303
+
304
+ /**
305
+ * Remove a launcher left on disk under another spelling of the same name.
306
+ *
307
+ * The menu label IS the file name, so renaming the option renames the file. On
308
+ * a case-insensitive volume (NTFS, and APFS by default) writing the new spelling
309
+ * lands in the OLD directory entry and the menu keeps showing the previous
310
+ * name; on a case-sensitive one the old file would simply linger and, having
311
+ * been registered before, could still appear. Deleting the stale entry first
312
+ * makes a rename take effect on both.
313
+ *
314
+ * Best effort throughout: a launcher that cannot be cleaned up must not stop
315
+ * the new one from being written.
316
+ *
317
+ * @param {string} dir
318
+ * @param {string} keepName - basename that must survive
319
+ * @param {object} fs
320
+ */
321
+ function removeStaleLauncher(dir, keepName, fs) {
322
+ if (typeof fs.readdirSync !== 'function' || typeof fs.unlinkSync !== 'function') return
323
+ let entries
324
+ try {
325
+ entries = fs.readdirSync(dir)
326
+ } catch {
327
+ return // first run: the directory does not exist yet
328
+ }
329
+ for (const entry of entries) {
330
+ if (entry === keepName || entry.toLowerCase() !== keepName.toLowerCase()) continue
331
+ try { fs.unlinkSync(path.join(dir, entry)) } catch { /* keep going */ }
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Write (or refresh) the launcher. Idempotent and cheap: the content is a pure
337
+ * function of this install's paths, so a plugin upgrade that moves the package
338
+ * rewrites the same file with the new child path.
339
+ *
340
+ * @param {{ dir: string, platform?: string, childPath: string, machinesFile: string, nodePath?: string, electronPath?: string, fs?: object }} spec
341
+ * @returns {{ path: string, source: string }}
342
+ */
343
+ export function writeLauncher(spec) {
344
+ const platform = spec.platform || process.platform
345
+ const fs = spec.fs || { mkdirSync, writeFileSync, chmodSync, readdirSync, unlinkSync }
346
+ fs.mkdirSync(spec.dir, { recursive: true })
347
+ const file = shellLauncherPath(spec.dir, platform)
348
+ // Before writing: on a case-insensitive filesystem the new name would
349
+ // otherwise be absorbed into the old entry.
350
+ removeStaleLauncher(spec.dir, path.basename(file), fs)
351
+ const source = launcherSource({ ...spec, platform })
352
+ fs.writeFileSync(file, source, 'utf8')
353
+ if (platform !== 'win32') {
354
+ try { fs.chmodSync(file, 0o755) } catch { /* a no-op on filesystems without modes */ }
355
+ }
356
+ return { path: file, source }
357
+ }
358
+
359
+ /**
360
+ * Add one entry to the host terminal's shell list.
361
+ *
362
+ * `shellCandidates` is the exact array `discoverShells` reads on every `shells()`
363
+ * call, so appending to it is live: the next time the user opens the shell menu
364
+ * the entry is there, and `spawn()` accepts the matching `shellPath` because it
365
+ * looks the path up in that same list. Removing it on dispose keeps the menu
366
+ * truthful when the plugin is unloaded.
367
+ *
368
+ * A host without the field (older line, or a controller rebuilt to hide the
369
+ * array) is not an error: the plugin simply contributes no shell. A host that
370
+ * HAS the field but refuses the write is a different story — that throws, so the
371
+ * caller can say why the option never appeared instead of leaving the user to
372
+ * guess.
373
+ *
374
+ * @param {object} controller - the `terminalController` service
375
+ * @param {string} candidate - absolute launcher path
376
+ * @returns {(() => void) | null} undo, or null when there is nothing to undo
377
+ * @throws {TypeError} when the host exposes a shell list that cannot be written
378
+ */
379
+ export function addShellCandidate(controller, candidate) {
380
+ const config = controller && controller.config
381
+ const list = config && config.shellCandidates
382
+ if (!Array.isArray(list)) return null
383
+ if (list.includes(candidate)) return null
384
+ list.push(candidate)
385
+ return () => {
386
+ const at = list.indexOf(candidate)
387
+ if (at >= 0) list.splice(at, 1)
388
+ }
389
+ }
390
+
391
+ /** Human-readable reason from anything thrown. */
392
+ function reason(err) {
393
+ return String((err && err.message) || err)
394
+ }
395
+
396
+ /** Read an environment variable case-insensitively, as Windows does. */
397
+ function envValue(env, name) {
398
+ if (!env) return undefined
399
+ if (env[name] !== undefined) return env[name]
400
+ const match = Object.keys(env).find((key) => key.toUpperCase() === name.toUpperCase())
401
+ return match === undefined ? undefined : env[match]
402
+ }
403
+
404
+ /**
405
+ * Write the launcher and put it in the terminal's shell menu.
406
+ *
407
+ * Kept out of the plugin entry so the whole contribution — write, publish,
408
+ * withdraw — can be exercised without booting the host. Every way this can
409
+ * fail to produce a visible menu entry is reported through `logger.warn`,
410
+ * because the failure mode that matters is silent: a launcher on disk plus no
411
+ * option in the menu is indistinguishable from "the feature was never built".
412
+ *
413
+ * @param {object} ctx - plugin context; only `effect`, `get('logger')` are used
414
+ * @param {object} controller - the resolved `terminalController` *service*
415
+ * @param {{ dir: string, childPath: string, machinesFile: string, platform?: string,
416
+ * nodePath?: string, electronPath?: string, electron?: boolean, logger?: object,
417
+ * env?: object, fs?: { existsSync: (p: string) => boolean } }} spec
418
+ * @returns {boolean} whether the option is (or already was) in the menu
419
+ */
420
+ export function installTerminalShell(ctx, controller, spec = {}) {
421
+ const logger = spec.logger !== undefined
422
+ ? spec.logger
423
+ : (ctx && typeof ctx.get === 'function' ? ctx.get('logger') : undefined)
424
+ const warn = (message) => {
425
+ try {
426
+ if (logger && typeof logger.warn === 'function') logger.warn(`dsh-remote: ${message}`)
427
+ } catch { /* a logger failure must not change the outcome */ }
428
+ }
429
+
430
+ const platform = spec.platform || process.platform
431
+ const electron = spec.electron === undefined
432
+ ? Boolean(process.versions && process.versions.electron)
433
+ : spec.electron
434
+ const nodePath = spec.nodePath === undefined
435
+ ? (runtimeNodeCandidates({
436
+ platform,
437
+ resourcesPath: process.resourcesPath,
438
+ execPath: process.execPath,
439
+ env: process.env,
440
+ electron,
441
+ })[0] || '')
442
+ : spec.nodePath
443
+ const electronPath = spec.electronPath === undefined
444
+ ? (electron ? process.execPath : '')
445
+ : spec.electronPath
446
+
447
+ let launcher
448
+ try {
449
+ launcher = writeLauncher({
450
+ dir: spec.dir,
451
+ platform,
452
+ childPath: spec.childPath,
453
+ machinesFile: spec.machinesFile,
454
+ nodePath,
455
+ electronPath,
456
+ })
457
+ } catch (err) {
458
+ // A read-only DSH_HOME must not take the plugin down: the terminal just
459
+ // keeps offering the shells it already had.
460
+ warn(`cannot write the remote terminal launcher: ${reason(err)}`)
461
+ return false
462
+ }
463
+
464
+ if (controller === undefined || controller === null) {
465
+ warn('the terminal controller is unavailable; the remote ssh option is unavailable')
466
+ return false
467
+ }
468
+ const list = controller.config && controller.config.shellCandidates
469
+ if (!Array.isArray(list)) {
470
+ // Either an older host, or the wrong object was passed in — the service is
471
+ // reached with `ctx.get('terminalController')`, not with the inject callback
472
+ // argument itself. Say which one it looks like.
473
+ const looksLikeContext = typeof controller.get === 'function' || typeof controller.inject === 'function'
474
+ warn(looksLikeContext
475
+ ? 'the terminal controller was not resolved (a plugin context was passed instead); the remote ssh option is unavailable'
476
+ : 'the terminal controller exposes no shellCandidates list; the remote ssh option is unavailable')
477
+ return false
478
+ }
479
+
480
+ // On Windows the label is the candidate's basename, so contributing a file
481
+ // path would show `remote ssh.cmd`. Contributing the bare name keeps the label
482
+ // clean, at the price of exposing the launcher directory on PATH — and only
483
+ // while the host can actually resolve that name.
484
+ const bare = shellCandidate(platform, launcher.path)
485
+ const needsPath = bare !== launcher.path
486
+ const env = spec.env || process.env
487
+ const fs = spec.fs || { existsSync }
488
+
489
+ let installed = false
490
+ const publish = () => {
491
+ // The PATH entry lives inside the effect, not outside it: if the effect ever
492
+ // re-runs, the new run must put the entry back that the previous disposal
493
+ // took away. `appendPathEntry` ref-counts, so interleaved install/dispose
494
+ // pairs cannot strand a live registration without PATH.
495
+ let restorePath
496
+ let candidate = launcher.path
497
+ if (needsPath) {
498
+ try {
499
+ restorePath = appendPathEntry(path.dirname(launcher.path), env)
500
+ const found = resolveThroughPath(bare, {
501
+ platform,
502
+ pathEnv: envValue(env, 'PATH') || '',
503
+ pathExt: envValue(env, 'PATHEXT'),
504
+ fs,
505
+ })
506
+ if (found) candidate = bare
507
+ else {
508
+ // `PATHEXT` without `.CMD`, or a PATH that refuses the write. The
509
+ // option still works when contributed by absolute path; only its
510
+ // label carries the suffix.
511
+ restorePath()
512
+ restorePath = undefined
513
+ warn('the host cannot resolve the launcher by name; the remote ssh option keeps its .cmd suffix')
514
+ }
515
+ } catch (err) {
516
+ candidate = launcher.path
517
+ restorePath = undefined
518
+ warn(`the launcher directory cannot be added to PATH (${reason(err)}); the remote ssh option keeps its .cmd suffix`)
519
+ }
520
+ }
521
+ try {
522
+ const undo = addShellCandidate(controller, candidate)
523
+ installed = true
524
+ return () => {
525
+ if (undo) undo()
526
+ if (restorePath) restorePath()
527
+ }
528
+ } catch (err) {
529
+ // The host advertises a shell list but will not take a write.
530
+ if (restorePath) restorePath()
531
+ warn(`the terminal shell list rejected the remote shell (${reason(err)}); the remote ssh option is unavailable`)
532
+ return undefined
533
+ }
534
+ }
535
+
536
+ if (ctx && typeof ctx.effect === 'function') {
537
+ ctx.effect(publish, 'dsh-remote.terminal-shell')
538
+ } else {
539
+ publish()
540
+ }
541
+ return installed
542
+ }