import type { DesktopDiscoveredSshHost } from "@t3tools/contracts"; import * as Config from "effect/Config";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Option from "effect/Option";import * as Path from "effect/Path";import * as PlatformError from "effect/PlatformError"; import { SshHostDiscoveryError } from "./errors.ts"; const NO_HOSTS: ReadonlyArray<string> = [] as const; function stripInlineComment(line: string): string { const hashIndex = line.indexOf("#"); return (hashIndex >= 0 ? line.slice(0, hashIndex) : line).trim();} function splitDirectiveArgs(value: string): ReadonlyArray<string> { const args: Array<string> = []; for (const rawEntry of value .replace(/=(?!=)/gu, " ") .trim() .split(/\s+/u)) { const entry = rawEntry.trim(); if (entry.length > 0) { args.push(entry); } } return args;} function expandHomePath(input: string, homeDir: string): string { return input.replace(/^~(?=$|\/|\\)/u, homeDir);} export const resolveSshConfigIncludePattern = Effect.fnUntraced(function* ( includePattern: string, _directory: string, homeDir: string,) { const path = yield* Path.Path; const expandedPattern = expandHomePath(includePattern, homeDir); return path.isAbsolute(expandedPattern) ? expandedPattern : path.resolve(path.join(homeDir, ".ssh"), expandedPattern);}); function hasSshPattern(value: string): boolean { return value.includes("*") || value.includes("?") || value.startsWith("!");} function escapeRegex(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");} function globToRegExp(pattern: string): RegExp { return new RegExp( `^${escapeRegex(pattern).replace(/\\\*/gu, ".*").replace(/\\\?/gu, ".")}$`, "u", );} const expandGlob = Effect.fnUntraced(function* (pattern: string) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; if (!pattern.includes("*") && !pattern.includes("?")) { return (yield* fs.exists(pattern)) ? [pattern] : NO_HOSTS; } const directory = path.dirname(pattern); const basePattern = path.basename(pattern); if (!(yield* fs.exists(directory))) { return NO_HOSTS; } const matcher = globToRegExp(basePattern); const entries = yield* fs.readDirectory(directory); const matchedPaths: string[] = []; for (const entry of entries) { if (!matcher.test(entry)) { continue; } const entryPath = path.join(directory, entry); if (yield* fs.exists(entryPath)) { matchedPaths.push(entryPath); } } return matchedPaths.toSorted((left, right) => left.localeCompare(right));}); export const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* ( filePath: string, visited = new Set<string>(), homeDir: string,): Effect.fn.Return< ReadonlyArray<string>, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const resolvedPath = path.resolve(filePath); if (visited.has(resolvedPath) || !(yield* fs.exists(resolvedPath))) { return NO_HOSTS; } visited.add(resolvedPath); const aliases = new Set<string>(); const directory = path.dirname(resolvedPath); const raw = yield* fs.readFileString(resolvedPath); for (const line of raw.split(/\r?\n/u)) { const stripped = stripInlineComment(line); if (stripped.length === 0) { continue; } const [directive = "", ...rawArgs] = splitDirectiveArgs(stripped); const normalizedDirective = directive.toLowerCase(); if (normalizedDirective === "include") { for (const includePattern of rawArgs) { const resolvedPattern = yield* resolveSshConfigIncludePattern( includePattern, directory, homeDir, ); const includedPaths = yield* expandGlob(resolvedPattern); for (const includedPath of includedPaths) { const includedAliases = yield* collectSshConfigAliasesFromFile( includedPath, visited, homeDir, ); for (const alias of includedAliases) { aliases.add(alias); } } } continue; } if (normalizedDirective !== "host") { continue; } for (const alias of rawArgs) { if (alias.length === 0 || hasSshPattern(alias)) { continue; } aliases.add(alias); } } return [...aliases].toSorted((left, right) => left.localeCompare(right));}); function normalizeKnownHostsHostname(rawHost: string): string { const bracketMatch = /^\[([^\]]+)\]:(\d+)$/u.exec(rawHost); if (bracketMatch?.[1]) { return bracketMatch[1]; } if (!rawHost.includes(":")) { return rawHost; } const firstColonIndex = rawHost.indexOf(":"); const lastColonIndex = rawHost.lastIndexOf(":"); return firstColonIndex === lastColonIndex ? rawHost.slice(0, lastColonIndex) : rawHost;} export function parseKnownHostsHostnames(raw: string): ReadonlyArray<string> { const hostnames = new Set<string>(); for (const line of raw.split(/\r?\n/u)) { const trimmed = line.trim(); if (trimmed.length === 0 || trimmed.startsWith("#")) { continue; } const withoutMarker = trimmed.startsWith("@") ? trimmed.split(/\s+/u).slice(1).join(" ") : trimmed; const [hostField = ""] = withoutMarker.split(/\s+/u); if (hostField.length === 0 || hostField.startsWith("|")) { continue; } for (const rawHost of hostField.split(",")) { const host = normalizeKnownHostsHostname(rawHost).trim(); if (host.length === 0 || hasSshPattern(host)) { continue; } hostnames.add(host); } } return [...hostnames].toSorted((left, right) => left.localeCompare(right));} const readKnownHostsHostnames = Effect.fnUntraced(function* (filePath: string) { const fs = yield* FileSystem.FileSystem; if (!(yield* fs.exists(filePath))) { return NO_HOSTS; } return parseKnownHostsHostnames(yield* fs.readFileString(filePath));}); export const discoverSshHosts = Effect.fnUntraced( function* (input: { readonly homeDir?: string }) { const path = yield* Path.Path; const env = yield* Config.all({ home: Config.string("HOME").pipe(Config.option), userProfile: Config.string("USERPROFILE").pipe(Config.option), }); const homeDir = input?.homeDir ?? Option.getOrUndefined(env.home) ?? Option.getOrUndefined(env.userProfile) ?? ""; if (homeDir.trim().length === 0) { return []; } const sshDirectory = path.join(homeDir, ".ssh"); const configAliases = yield* collectSshConfigAliasesFromFile( path.join(sshDirectory, "config"), new Set<string>(), homeDir, ); const knownHosts = yield* readKnownHostsHostnames(path.join(sshDirectory, "known_hosts")); const discovered = new Map<string, DesktopDiscoveredSshHost>(); for (const alias of configAliases) { discovered.set(alias, { alias, hostname: alias, username: null, port: null, source: "ssh-config", }); } for (const hostname of knownHosts) { if (discovered.has(hostname)) { continue; } discovered.set(hostname, { alias: hostname, hostname, username: null, port: null, source: "known-hosts", }); } return [...discovered.values()].toSorted((left, right) => left.alias.localeCompare(right.alias), ); }, Effect.mapError( (cause) => new SshHostDiscoveryError({ message: "Failed to discover SSH hosts.", cause, }), ),);
import type { DesktopDiscoveredSshHost } from "@t3tools/contracts";
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";
import { SshHostDiscoveryError } from "./errors.ts";
const NO_HOSTS: ReadonlyArray<string> = [] as const;
function stripInlineComment(line: string): string {
const hashIndex = line.indexOf("#");
return (hashIndex >= 0 ? line.slice(0, hashIndex) : line).trim();
}
function splitDirectiveArgs(value: string): ReadonlyArray<string> {
const args: Array<string> = [];
for (const rawEntry of value
.replace(/=(?!=)/gu, " ")
.trim()
.split(/\s+/u)) {
const entry = rawEntry.trim();
if (entry.length > 0) {
args.push(entry);
}
}
return args;
}
function expandHomePath(input: string, homeDir: string): string {
return input.replace(/^~(?=$|\/|\\)/u, homeDir);
}
export const resolveSshConfigIncludePattern = Effect.fnUntraced(function* (
includePattern: string,
_directory: string,
homeDir: string,
) {
const path = yield* Path.Path;
const expandedPattern = expandHomePath(includePattern, homeDir);
return path.isAbsolute(expandedPattern)
? expandedPattern
: path.resolve(path.join(homeDir, ".ssh"), expandedPattern);
});
function hasSshPattern(value: string): boolean {
return value.includes("*") || value.includes("?") || value.startsWith("!");
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
function globToRegExp(pattern: string): RegExp {
return new RegExp(
`^${escapeRegex(pattern).replace(/\\\*/gu, ".*").replace(/\\\?/gu, ".")}$`,
"u",
);
}
const expandGlob = Effect.fnUntraced(function* (pattern: string) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
if (!pattern.includes("*") && !pattern.includes("?")) {
return (yield* fs.exists(pattern)) ? [pattern] : NO_HOSTS;
}
const directory = path.dirname(pattern);
const basePattern = path.basename(pattern);
if (!(yield* fs.exists(directory))) {
return NO_HOSTS;
}
const matcher = globToRegExp(basePattern);
const entries = yield* fs.readDirectory(directory);
const matchedPaths: string[] = [];
for (const entry of entries) {
if (!matcher.test(entry)) {
continue;
}
const entryPath = path.join(directory, entry);
if (yield* fs.exists(entryPath)) {
matchedPaths.push(entryPath);
}
}
return matchedPaths.toSorted((left, right) => left.localeCompare(right));
});
export const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* (
filePath: string,
visited = new Set<string>(),
homeDir: string,
): Effect.fn.Return<
ReadonlyArray<string>,
PlatformError.PlatformError,
FileSystem.FileSystem | Path.Path
> {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const resolvedPath = path.resolve(filePath);
if (visited.has(resolvedPath) || !(yield* fs.exists(resolvedPath))) {
return NO_HOSTS;
}
visited.add(resolvedPath);
const aliases = new Set<string>();
const directory = path.dirname(resolvedPath);
const raw = yield* fs.readFileString(resolvedPath);
for (const line of raw.split(/\r?\n/u)) {
const stripped = stripInlineComment(line);
if (stripped.length === 0) {
continue;
}
const [directive = "", ...rawArgs] = splitDirectiveArgs(stripped);
const normalizedDirective = directive.toLowerCase();
if (normalizedDirective === "include") {
for (const includePattern of rawArgs) {
const resolvedPattern = yield* resolveSshConfigIncludePattern(
includePattern,
directory,
homeDir,
);
const includedPaths = yield* expandGlob(resolvedPattern);
for (const includedPath of includedPaths) {
const includedAliases = yield* collectSshConfigAliasesFromFile(
includedPath,
visited,
homeDir,
);
for (const alias of includedAliases) {
aliases.add(alias);
}
}
}
continue;
}
if (normalizedDirective !== "host") {
continue;
}
for (const alias of rawArgs) {
if (alias.length === 0 || hasSshPattern(alias)) {
continue;
}
aliases.add(alias);
}
}
return [...aliases].toSorted((left, right) => left.localeCompare(right));
});
function normalizeKnownHostsHostname(rawHost: string): string {
const bracketMatch = /^\[([^\]]+)\]:(\d+)$/u.exec(rawHost);
if (bracketMatch?.[1]) {
return bracketMatch[1];
}
if (!rawHost.includes(":")) {
return rawHost;
}
const firstColonIndex = rawHost.indexOf(":");
const lastColonIndex = rawHost.lastIndexOf(":");
return firstColonIndex === lastColonIndex ? rawHost.slice(0, lastColonIndex) : rawHost;
}
export function parseKnownHostsHostnames(raw: string): ReadonlyArray<string> {
const hostnames = new Set<string>();
for (const line of raw.split(/\r?\n/u)) {
const trimmed = line.trim();
if (trimmed.length === 0 || trimmed.startsWith("#")) {
continue;
}
const withoutMarker = trimmed.startsWith("@")
? trimmed.split(/\s+/u).slice(1).join(" ")
: trimmed;
const [hostField = ""] = withoutMarker.split(/\s+/u);
if (hostField.length === 0 || hostField.startsWith("|")) {
continue;
}
for (const rawHost of hostField.split(",")) {
const host = normalizeKnownHostsHostname(rawHost).trim();
if (host.length === 0 || hasSshPattern(host)) {
continue;
}
hostnames.add(host);
}
}
return [...hostnames].toSorted((left, right) => left.localeCompare(right));
}
const readKnownHostsHostnames = Effect.fnUntraced(function* (filePath: string) {
const fs = yield* FileSystem.FileSystem;
if (!(yield* fs.exists(filePath))) {
return NO_HOSTS;
}
return parseKnownHostsHostnames(yield* fs.readFileString(filePath));
});
export const discoverSshHosts = Effect.fnUntraced(
function* (input: { readonly homeDir?: string }) {
const path = yield* Path.Path;
const env = yield* Config.all({
home: Config.string("HOME").pipe(Config.option),
userProfile: Config.string("USERPROFILE").pipe(Config.option),
});
const homeDir =
input?.homeDir ??
Option.getOrUndefined(env.home) ??
Option.getOrUndefined(env.userProfile) ??
"";
if (homeDir.trim().length === 0) {
return [];
}
const sshDirectory = path.join(homeDir, ".ssh");
const configAliases = yield* collectSshConfigAliasesFromFile(
path.join(sshDirectory, "config"),
new Set<string>(),
homeDir,
);
const knownHosts = yield* readKnownHostsHostnames(path.join(sshDirectory, "known_hosts"));
const discovered = new Map<string, DesktopDiscoveredSshHost>();
for (const alias of configAliases) {
discovered.set(alias, {
alias,
hostname: alias,
username: null,
port: null,
source: "ssh-config",
});
}
for (const hostname of knownHosts) {
if (discovered.has(hostname)) {
continue;
}
discovered.set(hostname, {
alias: hostname,
hostname,
username: null,
port: null,
source: "known-hosts",
});
}
return [...discovered.values()].toSorted((left, right) =>
left.alias.localeCompare(right.alias),
);
},
Effect.mapError(
(cause) =>
new SshHostDiscoveryError({
message: "Failed to discover SSH hosts.",
cause,
}),
),
);