@xynogen/pix-ssh 0.3.4 → 0.4.1

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/README.md CHANGED
@@ -14,7 +14,14 @@ Registers the `ssh_run` tool — run a command through the configured SSH shell
14
14
 
15
15
  Output is truncated to 50 KB / 2000 lines. Non-interactive (RPC/JSON) mode blocks the tool immediately.
16
16
 
17
- **Parameters:** `host` as `[user@]host[:port]` (e.g. `deploy@10.0.0.5:2222`), `command`, optional `sudo` (run through POSIX `sudo` as root), optional `reason`.
17
+ **Parameters:** `action` (`"command"` default · `"file"` · `"info"`), `host` as `[user@]host[:port]` (e.g. `deploy@10.0.0.5:2222`), `command`, optional `sudo` (run through POSIX `sudo` as root), optional `reason`.
18
+
19
+ ### `action: "info"` — SSH config, no connection
20
+
21
+ Lets the agent discover hosts without reading `~/.ssh/config` itself. Read-only: no connection, no approval dialog, no password.
22
+
23
+ - **No `host`** → lists configured host aliases from `~/.ssh/config` and every file it pulls in via `Include` (glob + relative paths resolved OpenSSH-style, cycle-guarded). Wildcard-only patterns (`Host *`) are skipped. Each row shows `alias → user@hostname:port` plus `via <ProxyJump>` when set.
24
+ - **With `host`** → runs `ssh -G <host>` and reports the effective `HostName`/`User`/`Port`/`ProxyJump`/`IdentityFile` for that target (alias resolution, `Match`, and `Include` all handled by OpenSSH). No packets are sent to the remote.
18
25
 
19
26
  ### Authentication
20
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-ssh",
3
- "version": "0.3.4",
3
+ "version": "0.4.1",
4
4
  "description": "Pi tool — ssh_run: run remote commands over SSH with password/key auth and remote sudo",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ import { Text } from "@earendil-works/pi-tui";
30
30
  import { FG_DIM, RST, resolveBaseBackground } from "@xynogen/pix-pretty/ansi";
31
31
  import { MAX_PREVIEW_LINES } from "@xynogen/pix-pretty/config";
32
32
  import { type OverlayResult, showOverlay } from "@xynogen/pix-pretty/gate-overlay";
33
+ import { icon } from "@xynogen/pix-pretty/icon-catalog";
33
34
  import { renderBashOutput } from "@xynogen/pix-pretty/renderers";
34
35
  import type { RenderContextLike, ThemeLike, ToolResultLike } from "@xynogen/pix-pretty/types";
35
36
  import {
@@ -56,6 +57,8 @@ import {
56
57
  controlPathFor,
57
58
  detectSshFailure,
58
59
  detectSudoFailure,
60
+ type HostAlias,
61
+ type HostInfo,
59
62
  type HostSpec,
60
63
  hostApproved,
61
64
  hostTarget,
@@ -63,6 +66,8 @@ import {
63
66
  MAX_OUTPUT_LINES,
64
67
  parseHost,
65
68
  probeKeyAuth,
69
+ readSshConfigAliases,
70
+ resolveHostInfo,
66
71
  resolveSshHost,
67
72
  runSsh,
68
73
  runTransfer,
@@ -122,23 +127,19 @@ export interface SshResultDetails {
122
127
  _render?: string;
123
128
  }
124
129
 
125
- type SshParams =
126
- | {
127
- action?: "command";
128
- host: string;
129
- command: string;
130
- sudo?: boolean;
131
- reason?: string;
132
- }
133
- | {
134
- action: "file";
135
- host: string;
136
- direction: TransferDirection;
137
- source: string;
138
- destination: string;
139
- recursive?: boolean;
140
- reason?: string;
141
- };
130
+ // Flat shape mirroring the single Type.Object schema. Conditional fields are
131
+ // optional here and validated at runtime by normalizeOperation.
132
+ type SshParams = {
133
+ action?: "command" | "file" | "info";
134
+ host?: string;
135
+ command?: string;
136
+ sudo?: boolean;
137
+ direction?: TransferDirection;
138
+ source?: string;
139
+ destination?: string;
140
+ recursive?: boolean;
141
+ reason?: string;
142
+ };
142
143
 
143
144
  interface SshOperation {
144
145
  action: "command" | "file";
@@ -153,11 +154,11 @@ interface SshOperation {
153
154
 
154
155
  function normalizeOperation(params: SshParams): SshOperation {
155
156
  if (params.action === "file") {
156
- const source = params.source.trim();
157
- const destination = params.destination.trim();
157
+ const source = (params.source ?? "").trim();
158
+ const destination = (params.destination ?? "").trim();
158
159
  return {
159
160
  action: "file",
160
- command: `${params.direction} ${source || "(empty source)"} → ${destination || "(empty destination)"}`,
161
+ command: `${params.direction ?? ""} ${source || "(empty source)"} → ${destination || "(empty destination)"}`,
161
162
  sudo: false,
162
163
  reason: params.reason,
163
164
  direction: params.direction,
@@ -168,7 +169,7 @@ function normalizeOperation(params: SshParams): SshOperation {
168
169
  }
169
170
  return {
170
171
  action: "command",
171
- command: params.command,
172
+ command: params.command ?? "",
172
173
  sudo: params.sudo === true,
173
174
  reason: params.reason,
174
175
  source: "",
@@ -293,6 +294,67 @@ function cancelResult(
293
294
  };
294
295
  }
295
296
 
297
+ function formatHostInfo(host: string, info: HostInfo): string {
298
+ const rows = [
299
+ ["HostName", info.hostname],
300
+ ["User", info.user],
301
+ ["Port", info.port],
302
+ ["ProxyJump", info.proxyJump && info.proxyJump !== "none" ? info.proxyJump : undefined],
303
+ ["IdentityFile", info.identityFile],
304
+ ].filter((r): r is [string, string] => Boolean(r[1]));
305
+ if (rows.length === 0) return `No SSH config found for ${host}.`;
306
+ return [`Effective SSH config for ${host}:`, ...rows.map(([k, v]) => ` ${k} ${v}`)].join("\n");
307
+ }
308
+
309
+ function formatAliasList(aliases: HostAlias[]): string {
310
+ if (aliases.length === 0) {
311
+ return "No SSH host aliases found in ~/.ssh/config.";
312
+ }
313
+ // De-dupe by alias, first block wins (OpenSSH semantics).
314
+ const seen = new Set<string>();
315
+ const lines: string[] = [];
316
+ for (const a of aliases) {
317
+ if (seen.has(a.alias)) continue;
318
+ seen.add(a.alias);
319
+ let target = "";
320
+ if (a.hostname) {
321
+ const userPart = a.user ? `${a.user}@` : "";
322
+ const portPart = a.port ? `:${a.port}` : "";
323
+ target = `${userPart}${a.hostname}${portPart}`;
324
+ }
325
+ const via = a.proxyJump && a.proxyJump !== "none" ? ` via ${a.proxyJump}` : "";
326
+ lines.push(` ${a.alias}${target ? ` → ${target}` : ""}${via}`);
327
+ }
328
+ return [`SSH host aliases (${lines.length}):`, ...lines].join("\n");
329
+ }
330
+
331
+ /** Build the info-action result: per-host effective config when `host` is
332
+ * given, otherwise the alias inventory from ~/.ssh/config. Read-only.
333
+ * Details carry `_type: "sshInfo"` so renderResult falls to its generic
334
+ * plain-text branch (no collapse, no exit-code framing). */
335
+ async function infoResult(host: string | undefined, sig?: AbortSignal) {
336
+ const details = { _type: "sshInfo" as const };
337
+ if (host?.trim()) {
338
+ let spec: HostSpec;
339
+ try {
340
+ spec = parseHost(host);
341
+ } catch (err) {
342
+ const msg = err instanceof Error ? err.message : String(err);
343
+ return {
344
+ content: [{ type: "text" as const, text: `ssh_run failed: ${msg}` }],
345
+ details,
346
+ isError: true,
347
+ };
348
+ }
349
+ const text = formatHostInfo(hostTarget(spec), await resolveHostInfo(spec, sig));
350
+ return { content: [{ type: "text" as const, text }], details };
351
+ }
352
+ return {
353
+ content: [{ type: "text" as const, text: formatAliasList(readSshConfigAliases()) }],
354
+ details,
355
+ };
356
+ }
357
+
296
358
  // ── Extension entry point ─────────────────────────────────────────────────────
297
359
 
298
360
  export default function (pi: ExtensionAPI): void {
@@ -300,67 +362,95 @@ export default function (pi: ExtensionAPI): void {
300
362
  name: "ssh_run",
301
363
  label: "Run over SSH",
302
364
  description:
303
- "Run a command or transfer files/directories through SSH. Commands may optionally use POSIX sudo. " +
304
- "Basic cmd/PowerShell/pwsh commands may work, but Windows shells are best-effort: shell selection, " +
305
- "quoting, PowerShell error/stream/encoding semantics, interactive prompts, and Windows " +
306
- "administrator/UAC elevation are not supported. Back away and tell the user when correctness " +
307
- "depends on one of those limits. Handles connection and password entry through a permission dialog. " +
308
- "A configured approval window or YOLO mode may auto-approve non-privileged commands when no password is missing. " +
309
- "SSH auth tries key/agent first, then prompts for a login password if needed. " +
310
- "Set `sudo: true` to run the command as root on the remote machine (prompts for the " +
311
- 'remote sudo password). For transfer, set `action: "file"`, `direction`, `source`, ' +
312
- "`destination`, and optional `recursive`. Transfers may overwrite the destination. Always provide a clear `reason`.",
313
- promptSnippet: "Run a remote command or transfer files over SSH",
365
+ "Run a command or transfer files/directories on a REMOTE host over SSH. " +
366
+ "For the LOCAL machine use `bash` (or `sudo_run` for local root) instead — do not use ssh_run for local work. " +
367
+ "Requires a `host`. Windows/PowerShell shells are best-effort (elevation and PowerShell stream/encoding semantics unsupported). " +
368
+ "Set `sudo: true` to run the command as root on the remote machine. " +
369
+ 'For transfer, set `action: "file"`, `direction`, `source`, `destination`, and optional `recursive` — ' +
370
+ "transfers may overwrite the destination. " +
371
+ 'To discover hosts without reading `~/.ssh/config`, use `action: "info"` — omit `host` to list configured aliases, or pass a `host` to get its effective config (no connection). ' +
372
+ "Always provide a clear `reason`.",
373
+ promptSnippet: "Run a remote command, transfer files, or read SSH config over SSH",
314
374
  promptGuidelines: [
315
- 'ssh_run: `host` as `[user@]host[:port]`; `sudo` covers POSIX sudo only. For transfer use `action: "file"` with `direction: "upload"|"download"`, `source`, `destination`, optional `recursive` (may overwrite). Always set `reason`.',
375
+ 'ssh_run: REMOTE host only — use `bash`/`sudo_run` for the local machine. `host` as `[user@]host[:port]`; `sudo` covers remote POSIX sudo only. For transfer use `action: "file"` with `direction: "upload"|"download"`, `source`, `destination`, optional `recursive` (may overwrite). Use `action: "info"` (no `host` = list aliases, with `host` = its effective config) instead of reading `~/.ssh/config` yourself. Always set `reason`.',
316
376
  ],
317
377
 
318
378
  renderShell: "self",
319
379
 
320
- parameters: Type.Union([
321
- Type.Object({
322
- action: Type.Optional(Type.Literal("command")),
323
- host: Type.String({
324
- description: "Remote target as `[user@]host[:port]` (e.g. `deploy@10.0.0.5:2222`).",
380
+ // Single Type.Object (root `type: "object"`) rather than Type.Union — a union
381
+ // serializes to `anyOf` with no root type, which strict OpenAI-compatible
382
+ // providers (e.g. DeepSeek) reject with `type: null`. Conditional fields are
383
+ // optional and normalized/validated at runtime via normalizeOperation.
384
+ parameters: Type.Object({
385
+ action: Type.Optional(
386
+ Type.Union([Type.Literal("command"), Type.Literal("file"), Type.Literal("info")], {
387
+ description:
388
+ '"command" (default) runs a remote command; "file" transfers a file/directory; "info" reports SSH config (no connection) — list configured host aliases, or resolve one host\'s effective config when `host` is given.',
325
389
  }),
326
- command: Type.String({
327
- description: "Command sent to the remote host's configured SSH shell.",
390
+ ),
391
+ host: Type.Optional(
392
+ Type.String({
393
+ description:
394
+ "Remote target as `[user@]host[:port]` (e.g. `deploy@10.0.0.5:2222`). Required for command/file; for info, omit to list all aliases or set it to resolve one host.",
328
395
  }),
329
- sudo: Type.Optional(
330
- Type.Boolean({
331
- description: "Run the command as root on the remote host via sudo. Default false.",
332
- }),
333
- ),
334
- reason: Type.Optional(
335
- Type.String({
336
- description: "Short plain-English explanation of intent, shown to the user.",
337
- }),
338
- ),
339
- }),
340
- Type.Object({
341
- action: Type.Literal("file"),
342
- host: Type.String({
343
- description: "Remote target as `[user@]host[:port]` (e.g. `deploy@10.0.0.5:2222`).",
396
+ ),
397
+ command: Type.Optional(
398
+ Type.String({
399
+ description:
400
+ 'Command sent to the remote host\'s configured SSH shell. Required when action is "command".',
344
401
  }),
345
- direction: Type.Union([Type.Literal("upload"), Type.Literal("download")]),
346
- source: Type.String({
347
- description: "Source path. Local for upload; remote for download.",
402
+ ),
403
+ sudo: Type.Optional(
404
+ Type.Boolean({
405
+ description:
406
+ "Run the command as root on the remote host via sudo. Default false. Command action only.",
348
407
  }),
349
- destination: Type.String({
350
- description: "Destination path. Remote for upload; local for download.",
408
+ ),
409
+ direction: Type.Optional(
410
+ Type.Union([Type.Literal("upload"), Type.Literal("download")], {
411
+ description: 'Transfer direction. Required when action is "file".',
351
412
  }),
352
- recursive: Type.Optional(
353
- Type.Boolean({ description: "Copy a directory recursively. Default false." }),
354
- ),
355
- reason: Type.Optional(
356
- Type.String({
357
- description: "Short plain-English explanation of intent, shown to the user.",
358
- }),
359
- ),
360
- }),
361
- ]),
413
+ ),
414
+ source: Type.Optional(
415
+ Type.String({
416
+ description:
417
+ 'Source path (local for upload, remote for download). Required when action is "file".',
418
+ }),
419
+ ),
420
+ destination: Type.Optional(
421
+ Type.String({
422
+ description:
423
+ 'Destination path (remote for upload, local for download). Required when action is "file".',
424
+ }),
425
+ ),
426
+ recursive: Type.Optional(
427
+ Type.Boolean({
428
+ description: "Copy a directory recursively. Default false. File action only.",
429
+ }),
430
+ ),
431
+ reason: Type.Optional(
432
+ Type.String({
433
+ description: "Short plain-English explanation of intent, shown to the user.",
434
+ }),
435
+ ),
436
+ }),
362
437
 
363
438
  async execute(_toolCallId, params, sig, onUpdate, ctx) {
439
+ // info: read-only SSH config report (no connection, no approval).
440
+ if (params.action === "info") {
441
+ return infoResult(params.host, sig);
442
+ }
443
+
444
+ if (!params.host?.trim()) {
445
+ return {
446
+ content: [{ type: "text", text: "ssh_run failed: host is required" }],
447
+ details: makeDetails("", "", false, params.reason, {
448
+ outcome: "error",
449
+ errorKind: "execution",
450
+ }),
451
+ isError: true,
452
+ };
453
+ }
364
454
  const operation = normalizeOperation(params);
365
455
  const { action, command, destination, direction, reason, recursive, source, sudo } =
366
456
  operation;
@@ -376,6 +466,17 @@ export default function (pi: ExtensionAPI): void {
376
466
  };
377
467
  }
378
468
 
469
+ if (action === "command" && !command.trim()) {
470
+ return {
471
+ content: [{ type: "text", text: "ssh_run failed: command is required" }],
472
+ details: makeDetails(command, params.host, sudo, reason, {
473
+ outcome: "error",
474
+ errorKind: "execution",
475
+ }),
476
+ isError: true,
477
+ };
478
+ }
479
+
379
480
  let spec: HostSpec;
380
481
  try {
381
482
  spec = parseHost(params.host);
@@ -498,10 +599,11 @@ export default function (pi: ExtensionAPI): void {
498
599
  if (promptFor.length === 0) {
499
600
  return showOverlay(ctx.ui, {
500
601
  mode: "confirm",
602
+ icon: action === "file" ? icon("warn") : icon("lock"),
501
603
  title:
502
604
  action === "file"
503
- ? `⚠ SSH ${direction === "download" ? "DOWNLOAD" : "UPLOAD"}`
504
- : "🔐 SSH COMMAND REQUEST",
605
+ ? `SSH ${direction === "download" ? "Download" : "Upload"}`
606
+ : "SSH Command Request",
505
607
  body,
506
608
  accent: sudo ? "error" : action === "file" ? "warning" : "accent",
507
609
  timeoutMs: PROMPT_TIMEOUT_MS,
@@ -526,10 +628,11 @@ export default function (pi: ExtensionAPI): void {
526
628
  const label = stage === "login" ? "SSH login password" : "Remote sudo password";
527
629
  last = await showOverlay(ctx.ui, {
528
630
  mode: "sudo",
631
+ icon: action === "file" ? icon("warn") : icon("lock"),
529
632
  title:
530
633
  action === "file"
531
- ? `⚠ SSH ${direction === "download" ? "DOWNLOAD" : "UPLOAD"}`
532
- : "🔐 SSH COMMAND REQUEST",
634
+ ? `SSH ${direction === "download" ? "Download" : "Upload"}`
635
+ : "SSH Command Request",
533
636
  body: [...body, `Enter: ${label}`],
534
637
  accent: sudo ? "error" : action === "file" ? "warning" : "accent",
535
638
  timeoutMs: PROMPT_TIMEOUT_MS,
@@ -713,9 +816,17 @@ export default function (pi: ExtensionAPI): void {
713
816
  )
714
817
  return text;
715
818
 
819
+ const host = safeOneLine(args.host ?? "");
820
+ if (args.action === "info") {
821
+ text.setText(
822
+ fillToolBackground(
823
+ `${theme.fg("toolTitle", theme.bold("ssh info"))} ${theme.fg("dim", host || "list aliases")}`,
824
+ ),
825
+ );
826
+ return text;
827
+ }
716
828
  const operation = normalizeOperation(args);
717
829
  const command = safeOneLine(operation.command) || "(empty command)";
718
- const host = safeOneLine(args.host);
719
830
  const prefix = operation.sudo ? "sudo " : "";
720
831
  text.setText(
721
832
  fillToolBackground(
package/src/lib.ts CHANGED
@@ -15,8 +15,9 @@
15
15
 
16
16
  import { spawn } from "node:child_process";
17
17
  import { createHash } from "node:crypto";
18
- import { tmpdir } from "node:os";
19
- import { join } from "node:path";
18
+ import { globSync, readFileSync } from "node:fs";
19
+ import { homedir, tmpdir } from "node:os";
20
+ import { isAbsolute, join, resolve as resolvePath } from "node:path";
20
21
 
21
22
  export const MAX_OUTPUT_BYTES = 50 * 1024;
22
23
  export const MAX_OUTPUT_LINES = 2000;
@@ -153,6 +154,142 @@ export function resolveSshHost(spec: HostSpec, signal?: AbortSignal): Promise<Ho
153
154
  });
154
155
  }
155
156
 
157
+ // ── Host inventory (info action) ─────────────────────────────────────────────
158
+
159
+ export interface HostAlias {
160
+ alias: string;
161
+ hostname?: string;
162
+ user?: string;
163
+ port?: string;
164
+ proxyJump?: string;
165
+ identityFile?: string;
166
+ }
167
+
168
+ export interface HostInfo {
169
+ hostname?: string;
170
+ user?: string;
171
+ port?: string;
172
+ proxyJump?: string;
173
+ identityFile?: string;
174
+ }
175
+
176
+ /** Fields we surface from an ssh config Host block or `ssh -G` output. */
177
+ const INFO_KEYS: Record<string, keyof HostInfo> = {
178
+ hostname: "hostname",
179
+ user: "user",
180
+ port: "port",
181
+ proxyjump: "proxyJump",
182
+ identityfile: "identityFile",
183
+ };
184
+
185
+ /**
186
+ * Parse `Host` blocks out of an ssh_config text. Returns one entry per alias
187
+ * token (a single `Host a b` line yields two aliases). Wildcard-only patterns
188
+ * (`*`, `?`, `!`) are skipped since they aren't connectable targets. Keys are
189
+ * matched case-insensitively; the first value for a key within a block wins
190
+ * (OpenSSH "first obtained value" semantics).
191
+ */
192
+ export function parseHostAliases(text: string): HostAlias[] {
193
+ const out: HostAlias[] = [];
194
+ let current: HostAlias[] = [];
195
+ for (const raw of text.split("\n")) {
196
+ const line = raw.replace(/#.*$/, "").trim();
197
+ if (!line) continue;
198
+ const [keyRaw, ...rest] = line.split(/\s+/);
199
+ const key = (keyRaw ?? "").toLowerCase();
200
+ const value = rest.join(" ");
201
+ if (key === "host") {
202
+ current = rest.filter((p) => !/[*?!]/.test(p)).map((alias) => ({ alias }));
203
+ out.push(...current);
204
+ } else if (current.length > 0) {
205
+ const field = INFO_KEYS[key];
206
+ if (field && value) {
207
+ for (const entry of current) {
208
+ if (entry[field] === undefined) entry[field] = value;
209
+ }
210
+ }
211
+ }
212
+ }
213
+ return out;
214
+ }
215
+
216
+ /** Expand a Path with a leading `~` and resolve relative Includes against
217
+ * the containing config's directory (OpenSSH semantics). */
218
+ function expandConfigPath(pattern: string, baseDir: string): string {
219
+ let p = pattern;
220
+ if (p.startsWith("~/")) p = join(homedir(), p.slice(2));
221
+ else if (p === "~") p = homedir();
222
+ return isAbsolute(p) ? p : resolvePath(baseDir, p);
223
+ }
224
+
225
+ /**
226
+ * Read an ssh_config file and every file it pulls in via `Include`, returning
227
+ * the concatenated Host aliases. Missing files and glob misses are ignored
228
+ * (OpenSSH tolerates them). `seen` guards against Include cycles.
229
+ */
230
+ export function readSshConfigAliases(
231
+ path = join(homedir(), ".ssh", "config"),
232
+ seen = new Set<string>(),
233
+ ): HostAlias[] {
234
+ if (seen.has(path)) return [];
235
+ seen.add(path);
236
+ let text: string;
237
+ try {
238
+ text = readFileSync(path, "utf8");
239
+ } catch {
240
+ return [];
241
+ }
242
+ const baseDir = join(homedir(), ".ssh");
243
+ const out: HostAlias[] = [];
244
+ for (const raw of text.split("\n")) {
245
+ const line = raw.replace(/#.*$/, "").trim();
246
+ const [keyRaw, ...rest] = line.split(/\s+/);
247
+ if ((keyRaw ?? "").toLowerCase() === "include") {
248
+ for (const pattern of rest) {
249
+ const expanded = expandConfigPath(pattern, baseDir);
250
+ let matches: string[] = [];
251
+ try {
252
+ matches = globSync(expanded);
253
+ } catch {
254
+ matches = [];
255
+ }
256
+ for (const file of matches.sort()) out.push(...readSshConfigAliases(file, seen));
257
+ }
258
+ }
259
+ }
260
+ out.push(...parseHostAliases(text));
261
+ return out;
262
+ }
263
+
264
+ /** Full effective config for one host via `ssh -G` (no connection made). */
265
+ export function parseHostInfo(output: string): HostInfo {
266
+ const info: HostInfo = {};
267
+ for (const line of output.split("\n")) {
268
+ const [keyRaw, ...rest] = line.trim().split(/\s+/);
269
+ const field = INFO_KEYS[(keyRaw ?? "").toLowerCase()];
270
+ const value = rest.join(" ");
271
+ if (field && value && info[field] === undefined) info[field] = value;
272
+ }
273
+ return info;
274
+ }
275
+
276
+ /** Run `ssh -G <host>` and return its effective config. Never connects. */
277
+ export function resolveHostInfo(spec: HostSpec, signal?: AbortSignal): Promise<HostInfo> {
278
+ const args = ["-G"];
279
+ if (spec.port !== undefined) args.push("-p", String(spec.port));
280
+ args.push(hostTarget(spec));
281
+ return new Promise((resolve) => {
282
+ let stdout = "";
283
+ const proc = spawn("ssh", args, { stdio: ["ignore", "pipe", "ignore"] });
284
+ proc.stdout.on("data", (c: Buffer) => {
285
+ stdout += c.toString();
286
+ });
287
+ proc.on("error", () => resolve({}));
288
+ proc.on("close", (code) => resolve(code === 0 ? parseHostInfo(stdout) : {}));
289
+ signal?.addEventListener("abort", () => proc.kill("SIGTERM"), { once: true });
290
+ });
291
+ }
292
+
156
293
  /** Canonical `[user@]host` target string for ssh argv. */
157
294
  export function hostTarget(spec: HostSpec): string {
158
295
  return spec.user ? `${spec.user}@${spec.host}` : spec.host;