Files
oh-my-pi/packages/coding-agent/src/discovery/ssh.ts
T
can1357 66a7255e1d feat(ssh): added automatic remote OS/shell detection with cross-platform support
- Added automatic remote host OS and shell detection with disk-based caching.
- Added compat mode option for SSH hosts to use POSIX shell compatibility wrapping.
- Added Windows shell support with PowerShell and CMD path escaping functions.
- Updated SSH tool description to display detected shell type per host.
- Simplified SSH tool documentation by removing OS-specific command examples.
2026-01-08 17:27:00 +01:00

163 lines
4.3 KiB
TypeScript

/**
* SSH JSON Provider
*
* Discovers SSH hosts from ssh.json or .ssh.json in the project root.
* Priority: 5 (low, project-level only)
*/
import { join } from "node:path";
import { registerProvider } from "../capability/index";
import { type SSHHost, sshCapability } from "../capability/ssh";
import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
import { createSourceMeta, expandEnvVarsDeep, parseJSON } from "./helpers";
const PROVIDER_ID = "ssh-json";
const DISPLAY_NAME = "SSH Config";
interface SSHConfigFile {
hosts?: Record<
string,
{
host?: string;
username?: string;
port?: number | string;
compat?: boolean | string;
key?: string;
keyPath?: string;
description?: string;
}
>;
}
function expandTilde(value: string, home: string): string {
if (value === "~") return home;
if (value.startsWith("~/") || value.startsWith("~\\")) {
return `${home}${value.slice(1)}`;
}
return value;
}
function parsePort(value: number | string | undefined): number | undefined {
if (value === undefined) return undefined;
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
const parsed = Number.parseInt(value, 10);
return Number.isNaN(parsed) ? undefined : parsed;
}
function parseCompat(value: boolean | string | undefined): boolean | undefined {
if (value === undefined) return undefined;
if (typeof value === "boolean") return value;
const normalized = value.trim().toLowerCase();
if (normalized === "true" || normalized === "1" || normalized === "yes") return true;
if (normalized === "false" || normalized === "0" || normalized === "no") return false;
return undefined;
}
function normalizeHost(
name: string,
raw: NonNullable<SSHConfigFile["hosts"]>[string],
source: SourceMeta,
home: string,
warnings: string[],
): SSHHost | null {
if (!raw.host) {
warnings.push(`Missing host for SSH entry: ${name}`);
return null;
}
const port = parsePort(raw.port);
if (raw.port !== undefined && port === undefined) {
warnings.push(`Invalid port for SSH entry ${name}: ${String(raw.port)}`);
}
const compat = parseCompat(raw.compat);
if (raw.compat !== undefined && compat === undefined) {
warnings.push(`Invalid compat flag for SSH entry ${name}: ${String(raw.compat)}`);
}
const keyValue = raw.keyPath ?? raw.key;
const keyPath = keyValue ? expandTilde(keyValue, home) : undefined;
return {
name,
host: raw.host,
username: raw.username,
port,
keyPath,
description: raw.description,
compat,
_source: source,
};
}
function loadSshJsonFile(ctx: LoadContext, path: string): LoadResult<SSHHost> {
const items: SSHHost[] = [];
const warnings: string[] = [];
if (!ctx.fs.isFile(path)) {
return { items, warnings };
}
const content = ctx.fs.readFile(path);
if (content === null) {
warnings.push(`Failed to read ${path}`);
return { items, warnings };
}
const parsed = parseJSON<SSHConfigFile>(content);
if (!parsed) {
warnings.push(`Failed to parse JSON in ${path}`);
return { items, warnings };
}
const config = expandEnvVarsDeep(parsed);
if (!config.hosts || typeof config.hosts !== "object") {
warnings.push(`Missing hosts in ${path}`);
return { items, warnings };
}
const source = createSourceMeta(PROVIDER_ID, path, "project");
for (const [name, rawHost] of Object.entries(config.hosts)) {
if (!name.trim()) {
warnings.push(`Invalid SSH host name in ${path}`);
continue;
}
if (!rawHost || typeof rawHost !== "object") {
warnings.push(`Invalid host entry in ${path}: ${name}`);
continue;
}
const host = normalizeHost(name, rawHost, source, ctx.home, warnings);
if (host) items.push(host);
}
return {
items,
warnings: warnings.length > 0 ? warnings : undefined,
};
}
function load(ctx: LoadContext): LoadResult<SSHHost> {
const allItems: SSHHost[] = [];
const allWarnings: string[] = [];
for (const filename of ["ssh.json", ".ssh.json"]) {
const path = join(ctx.cwd, filename);
const result = loadSshJsonFile(ctx, path);
allItems.push(...result.items);
if (result.warnings) allWarnings.push(...result.warnings);
}
return {
items: allItems,
warnings: allWarnings.length > 0 ? allWarnings : undefined,
};
}
registerProvider(sshCapability.id, {
id: PROVIDER_ID,
displayName: DISPLAY_NAME,
description: "Load SSH hosts from ssh.json or .ssh.json in the project root",
priority: 5,
load,
});