@xynogen/pix-ssh 0.3.4 → 0.4.0
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 +179 -71
- 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
|
@@ -56,6 +56,8 @@ import {
|
|
|
56
56
|
controlPathFor,
|
|
57
57
|
detectSshFailure,
|
|
58
58
|
detectSudoFailure,
|
|
59
|
+
type HostAlias,
|
|
60
|
+
type HostInfo,
|
|
59
61
|
type HostSpec,
|
|
60
62
|
hostApproved,
|
|
61
63
|
hostTarget,
|
|
@@ -63,6 +65,8 @@ import {
|
|
|
63
65
|
MAX_OUTPUT_LINES,
|
|
64
66
|
parseHost,
|
|
65
67
|
probeKeyAuth,
|
|
68
|
+
readSshConfigAliases,
|
|
69
|
+
resolveHostInfo,
|
|
66
70
|
resolveSshHost,
|
|
67
71
|
runSsh,
|
|
68
72
|
runTransfer,
|
|
@@ -122,23 +126,19 @@ export interface SshResultDetails {
|
|
|
122
126
|
_render?: string;
|
|
123
127
|
}
|
|
124
128
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
destination: string;
|
|
139
|
-
recursive?: boolean;
|
|
140
|
-
reason?: string;
|
|
141
|
-
};
|
|
129
|
+
// Flat shape mirroring the single Type.Object schema. Conditional fields are
|
|
130
|
+
// optional here and validated at runtime by normalizeOperation.
|
|
131
|
+
type SshParams = {
|
|
132
|
+
action?: "command" | "file" | "info";
|
|
133
|
+
host?: string;
|
|
134
|
+
command?: string;
|
|
135
|
+
sudo?: boolean;
|
|
136
|
+
direction?: TransferDirection;
|
|
137
|
+
source?: string;
|
|
138
|
+
destination?: string;
|
|
139
|
+
recursive?: boolean;
|
|
140
|
+
reason?: string;
|
|
141
|
+
};
|
|
142
142
|
|
|
143
143
|
interface SshOperation {
|
|
144
144
|
action: "command" | "file";
|
|
@@ -153,11 +153,11 @@ interface SshOperation {
|
|
|
153
153
|
|
|
154
154
|
function normalizeOperation(params: SshParams): SshOperation {
|
|
155
155
|
if (params.action === "file") {
|
|
156
|
-
const source = params.source.trim();
|
|
157
|
-
const destination = params.destination.trim();
|
|
156
|
+
const source = (params.source ?? "").trim();
|
|
157
|
+
const destination = (params.destination ?? "").trim();
|
|
158
158
|
return {
|
|
159
159
|
action: "file",
|
|
160
|
-
command: `${params.direction} ${source || "(empty source)"} → ${destination || "(empty destination)"}`,
|
|
160
|
+
command: `${params.direction ?? ""} ${source || "(empty source)"} → ${destination || "(empty destination)"}`,
|
|
161
161
|
sudo: false,
|
|
162
162
|
reason: params.reason,
|
|
163
163
|
direction: params.direction,
|
|
@@ -168,7 +168,7 @@ function normalizeOperation(params: SshParams): SshOperation {
|
|
|
168
168
|
}
|
|
169
169
|
return {
|
|
170
170
|
action: "command",
|
|
171
|
-
command: params.command,
|
|
171
|
+
command: params.command ?? "",
|
|
172
172
|
sudo: params.sudo === true,
|
|
173
173
|
reason: params.reason,
|
|
174
174
|
source: "",
|
|
@@ -293,6 +293,67 @@ function cancelResult(
|
|
|
293
293
|
};
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
function formatHostInfo(host: string, info: HostInfo): string {
|
|
297
|
+
const rows = [
|
|
298
|
+
["HostName", info.hostname],
|
|
299
|
+
["User", info.user],
|
|
300
|
+
["Port", info.port],
|
|
301
|
+
["ProxyJump", info.proxyJump && info.proxyJump !== "none" ? info.proxyJump : undefined],
|
|
302
|
+
["IdentityFile", info.identityFile],
|
|
303
|
+
].filter((r): r is [string, string] => Boolean(r[1]));
|
|
304
|
+
if (rows.length === 0) return `No SSH config found for ${host}.`;
|
|
305
|
+
return [`Effective SSH config for ${host}:`, ...rows.map(([k, v]) => ` ${k} ${v}`)].join("\n");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function formatAliasList(aliases: HostAlias[]): string {
|
|
309
|
+
if (aliases.length === 0) {
|
|
310
|
+
return "No SSH host aliases found in ~/.ssh/config.";
|
|
311
|
+
}
|
|
312
|
+
// De-dupe by alias, first block wins (OpenSSH semantics).
|
|
313
|
+
const seen = new Set<string>();
|
|
314
|
+
const lines: string[] = [];
|
|
315
|
+
for (const a of aliases) {
|
|
316
|
+
if (seen.has(a.alias)) continue;
|
|
317
|
+
seen.add(a.alias);
|
|
318
|
+
let target = "";
|
|
319
|
+
if (a.hostname) {
|
|
320
|
+
const userPart = a.user ? `${a.user}@` : "";
|
|
321
|
+
const portPart = a.port ? `:${a.port}` : "";
|
|
322
|
+
target = `${userPart}${a.hostname}${portPart}`;
|
|
323
|
+
}
|
|
324
|
+
const via = a.proxyJump && a.proxyJump !== "none" ? ` via ${a.proxyJump}` : "";
|
|
325
|
+
lines.push(` ${a.alias}${target ? ` → ${target}` : ""}${via}`);
|
|
326
|
+
}
|
|
327
|
+
return [`SSH host aliases (${lines.length}):`, ...lines].join("\n");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Build the info-action result: per-host effective config when `host` is
|
|
331
|
+
* given, otherwise the alias inventory from ~/.ssh/config. Read-only.
|
|
332
|
+
* Details carry `_type: "sshInfo"` so renderResult falls to its generic
|
|
333
|
+
* plain-text branch (no collapse, no exit-code framing). */
|
|
334
|
+
async function infoResult(host: string | undefined, sig?: AbortSignal) {
|
|
335
|
+
const details = { _type: "sshInfo" as const };
|
|
336
|
+
if (host?.trim()) {
|
|
337
|
+
let spec: HostSpec;
|
|
338
|
+
try {
|
|
339
|
+
spec = parseHost(host);
|
|
340
|
+
} catch (err) {
|
|
341
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
342
|
+
return {
|
|
343
|
+
content: [{ type: "text" as const, text: `ssh_run failed: ${msg}` }],
|
|
344
|
+
details,
|
|
345
|
+
isError: true,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
const text = formatHostInfo(hostTarget(spec), await resolveHostInfo(spec, sig));
|
|
349
|
+
return { content: [{ type: "text" as const, text }], details };
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
content: [{ type: "text" as const, text: formatAliasList(readSshConfigAliases()) }],
|
|
353
|
+
details,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
296
357
|
// ── Extension entry point ─────────────────────────────────────────────────────
|
|
297
358
|
|
|
298
359
|
export default function (pi: ExtensionAPI): void {
|
|
@@ -300,67 +361,95 @@ export default function (pi: ExtensionAPI): void {
|
|
|
300
361
|
name: "ssh_run",
|
|
301
362
|
label: "Run over SSH",
|
|
302
363
|
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",
|
|
364
|
+
"Run a command or transfer files/directories on a REMOTE host over SSH. " +
|
|
365
|
+
"For the LOCAL machine use `bash` (or `sudo_run` for local root) instead — do not use ssh_run for local work. " +
|
|
366
|
+
"Requires a `host`. Windows/PowerShell shells are best-effort (elevation and PowerShell stream/encoding semantics unsupported). " +
|
|
367
|
+
"Set `sudo: true` to run the command as root on the remote machine. " +
|
|
368
|
+
'For transfer, set `action: "file"`, `direction`, `source`, `destination`, and optional `recursive` — ' +
|
|
369
|
+
"transfers may overwrite the destination. " +
|
|
370
|
+
'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). ' +
|
|
371
|
+
"Always provide a clear `reason`.",
|
|
372
|
+
promptSnippet: "Run a remote command, transfer files, or read SSH config over SSH",
|
|
314
373
|
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`.',
|
|
374
|
+
'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
375
|
],
|
|
317
376
|
|
|
318
377
|
renderShell: "self",
|
|
319
378
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
379
|
+
// Single Type.Object (root `type: "object"`) rather than Type.Union — a union
|
|
380
|
+
// serializes to `anyOf` with no root type, which strict OpenAI-compatible
|
|
381
|
+
// providers (e.g. DeepSeek) reject with `type: null`. Conditional fields are
|
|
382
|
+
// optional and normalized/validated at runtime via normalizeOperation.
|
|
383
|
+
parameters: Type.Object({
|
|
384
|
+
action: Type.Optional(
|
|
385
|
+
Type.Union([Type.Literal("command"), Type.Literal("file"), Type.Literal("info")], {
|
|
386
|
+
description:
|
|
387
|
+
'"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
388
|
}),
|
|
326
|
-
|
|
327
|
-
|
|
389
|
+
),
|
|
390
|
+
host: Type.Optional(
|
|
391
|
+
Type.String({
|
|
392
|
+
description:
|
|
393
|
+
"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
394
|
}),
|
|
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`).",
|
|
395
|
+
),
|
|
396
|
+
command: Type.Optional(
|
|
397
|
+
Type.String({
|
|
398
|
+
description:
|
|
399
|
+
'Command sent to the remote host\'s configured SSH shell. Required when action is "command".',
|
|
344
400
|
}),
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
401
|
+
),
|
|
402
|
+
sudo: Type.Optional(
|
|
403
|
+
Type.Boolean({
|
|
404
|
+
description:
|
|
405
|
+
"Run the command as root on the remote host via sudo. Default false. Command action only.",
|
|
348
406
|
}),
|
|
349
|
-
|
|
350
|
-
|
|
407
|
+
),
|
|
408
|
+
direction: Type.Optional(
|
|
409
|
+
Type.Union([Type.Literal("upload"), Type.Literal("download")], {
|
|
410
|
+
description: 'Transfer direction. Required when action is "file".',
|
|
351
411
|
}),
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
412
|
+
),
|
|
413
|
+
source: Type.Optional(
|
|
414
|
+
Type.String({
|
|
415
|
+
description:
|
|
416
|
+
'Source path (local for upload, remote for download). Required when action is "file".',
|
|
417
|
+
}),
|
|
418
|
+
),
|
|
419
|
+
destination: Type.Optional(
|
|
420
|
+
Type.String({
|
|
421
|
+
description:
|
|
422
|
+
'Destination path (remote for upload, local for download). Required when action is "file".',
|
|
423
|
+
}),
|
|
424
|
+
),
|
|
425
|
+
recursive: Type.Optional(
|
|
426
|
+
Type.Boolean({
|
|
427
|
+
description: "Copy a directory recursively. Default false. File action only.",
|
|
428
|
+
}),
|
|
429
|
+
),
|
|
430
|
+
reason: Type.Optional(
|
|
431
|
+
Type.String({
|
|
432
|
+
description: "Short plain-English explanation of intent, shown to the user.",
|
|
433
|
+
}),
|
|
434
|
+
),
|
|
435
|
+
}),
|
|
362
436
|
|
|
363
437
|
async execute(_toolCallId, params, sig, onUpdate, ctx) {
|
|
438
|
+
// info: read-only SSH config report (no connection, no approval).
|
|
439
|
+
if (params.action === "info") {
|
|
440
|
+
return infoResult(params.host, sig);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (!params.host?.trim()) {
|
|
444
|
+
return {
|
|
445
|
+
content: [{ type: "text", text: "ssh_run failed: host is required" }],
|
|
446
|
+
details: makeDetails("", "", false, params.reason, {
|
|
447
|
+
outcome: "error",
|
|
448
|
+
errorKind: "execution",
|
|
449
|
+
}),
|
|
450
|
+
isError: true,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
364
453
|
const operation = normalizeOperation(params);
|
|
365
454
|
const { action, command, destination, direction, reason, recursive, source, sudo } =
|
|
366
455
|
operation;
|
|
@@ -376,6 +465,17 @@ export default function (pi: ExtensionAPI): void {
|
|
|
376
465
|
};
|
|
377
466
|
}
|
|
378
467
|
|
|
468
|
+
if (action === "command" && !command.trim()) {
|
|
469
|
+
return {
|
|
470
|
+
content: [{ type: "text", text: "ssh_run failed: command is required" }],
|
|
471
|
+
details: makeDetails(command, params.host, sudo, reason, {
|
|
472
|
+
outcome: "error",
|
|
473
|
+
errorKind: "execution",
|
|
474
|
+
}),
|
|
475
|
+
isError: true,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
379
479
|
let spec: HostSpec;
|
|
380
480
|
try {
|
|
381
481
|
spec = parseHost(params.host);
|
|
@@ -713,9 +813,17 @@ export default function (pi: ExtensionAPI): void {
|
|
|
713
813
|
)
|
|
714
814
|
return text;
|
|
715
815
|
|
|
816
|
+
const host = safeOneLine(args.host ?? "");
|
|
817
|
+
if (args.action === "info") {
|
|
818
|
+
text.setText(
|
|
819
|
+
fillToolBackground(
|
|
820
|
+
`${theme.fg("toolTitle", theme.bold("ssh info"))} ${theme.fg("dim", host || "list aliases")}`,
|
|
821
|
+
),
|
|
822
|
+
);
|
|
823
|
+
return text;
|
|
824
|
+
}
|
|
716
825
|
const operation = normalizeOperation(args);
|
|
717
826
|
const command = safeOneLine(operation.command) || "(empty command)";
|
|
718
|
-
const host = safeOneLine(args.host);
|
|
719
827
|
const prefix = operation.sudo ? "sudo " : "";
|
|
720
828
|
text.setText(
|
|
721
829
|
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;
|