hermes-agent/apps/desktop/electron/ssh-config.ts
yoniebans faa3bce6dc style(desktop): satisfy merged eslint/prettier config
The SSH modules predate the stricter lint config that landed on main (curly, no-empty, perfectionist sorting, prettier). Mechanical lint:fix + fmt pass, empty catch blocks filled with the codebase's void-0 convention, and inline no-control-regex disables on the three deliberate control-char patterns (same pattern as lib/ansi.ts).
2026-07-20 23:01:49 +02:00

175 lines
3.7 KiB
TypeScript

/**
* ssh-config.ts
*
* Pure, electron-free helpers for reading the user's OpenSSH client config:
* `Host` aliases for the settings UI's suggestions, `Include` traversal
* (read-only), and `ssh -G` output parsing. No `import 'electron'` so it's
* unit-testable without Electron; main.ts wires the fs + `ssh -G` exec in.
*/
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
function parseSshConfigHosts(text) {
const hosts: string[] = []
const seen = new Set()
for (const rawLine of String(text || '').split('\n')) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) {
continue
}
const m = /^host\s+(.+)$/i.exec(line)
if (!m) {
continue
}
for (const pattern of m[1].split(/\s+/)) {
if (!pattern || pattern.includes('*') || pattern.includes('?') || pattern.startsWith('!')) {
continue
}
if (!seen.has(pattern)) {
seen.add(pattern)
hosts.push(pattern)
}
}
}
return hosts
}
function parseSshConfigIncludes(text) {
const includes: string[] = []
for (const rawLine of String(text || '').split('\n')) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) {
continue
}
const m = /^include\s+(.+)$/i.exec(line)
if (!m) {
continue
}
for (const token of m[1].split(/\s+/)) {
if (token) {
includes.push(token)
}
}
}
return includes
}
function collectSshConfigHosts(rootPath = '', deps: any = {}) {
const readFile =
deps.readFile ||
(p => {
try {
return fs.readFileSync(p, 'utf8')
} catch {
return null
}
})
const homeDir = deps.homeDir || os.homedir()
const root = rootPath || path.join(homeDir, '.ssh', 'config')
const sshDir = path.join(homeDir, '.ssh')
const out: string[] = []
const seen = new Set()
const visited = new Set()
const resolveIncludePath = token => {
if (token.startsWith('~/')) {
return path.join(homeDir, token.slice(2))
}
if (path.isAbsolute(token)) {
return token
}
return path.join(sshDir, token)
}
const walk = (filePath, depth) => {
if (depth > 8 || visited.has(filePath)) {
return
}
visited.add(filePath)
const text = readFile(filePath)
if (text == null) {
return
}
for (const host of parseSshConfigHosts(text)) {
if (!seen.has(host)) {
seen.add(host)
out.push(host)
}
}
for (const token of parseSshConfigIncludes(text)) {
const target = resolveIncludePath(token)
const expanded = deps.globSync ? deps.globSync(target) : [target]
for (const p of expanded) {
walk(p, depth + 1)
}
}
}
walk(root, 0)
return out
}
function parseSshGOutput(text) {
const out: { hostname: string | null; user: string | null; port: number | null; identityFile: string | null } = {
hostname: null,
user: null,
port: null,
identityFile: null
}
for (const rawLine of String(text || '').split('\n')) {
const line = rawLine.trim()
if (!line) {
continue
}
const sp = line.indexOf(' ')
if (sp === -1) {
continue
}
const key = line.slice(0, sp).toLowerCase()
const value = line.slice(sp + 1).trim()
if (key === 'hostname' && !out.hostname) {
out.hostname = value
} else if (key === 'user' && !out.user) {
out.user = value
} else if (key === 'port' && !out.port) {
out.port = Number.parseInt(value, 10) || null
} else if (key === 'identityfile' && !out.identityFile) {
out.identityFile = value
}
}
return out
}
export { collectSshConfigHosts, parseSshConfigHosts, parseSshConfigIncludes, parseSshGOutput }