@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 +8 -1
- package/package.json +1 -1
- package/src/index.ts +186 -75
- package/src/lib.ts +139 -2
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
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
304
|
-
"
|
|
305
|
-
"
|
|
306
|
-
"
|
|
307
|
-
|
|
308
|
-
"
|
|
309
|
-
|
|
310
|
-
"
|
|
311
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
327
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
350
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
?
|
|
504
|
-
: "
|
|
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
|
-
?
|
|
532
|
-
: "
|
|
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 {
|
|
19
|
-
import {
|
|
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;
|