776 lines
22 KiB
TypeScript
776 lines
22 KiB
TypeScript
import type { IIdeSshTarget, IRemoteProbeResult } from '@git.zone/ide-protocol';
|
|
import * as plugins from './plugins.js';
|
|
|
|
export interface ISshHostConfig {
|
|
alias: string;
|
|
patterns: string[];
|
|
hostName?: string;
|
|
user?: string;
|
|
port?: number;
|
|
identityFiles: string[];
|
|
proxyJump?: string;
|
|
forwardAgent?: boolean;
|
|
raw: Record<string, string[]>;
|
|
}
|
|
|
|
export interface ISshRunOptions {
|
|
executable?: string;
|
|
timeoutMs?: number;
|
|
batchMode?: boolean;
|
|
cwd?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
}
|
|
|
|
export interface ISshRunResult {
|
|
exitCode: number;
|
|
stdout: string;
|
|
stderr: string;
|
|
}
|
|
|
|
export interface ISshTunnelOptions extends ISshRunOptions {
|
|
localHost?: string;
|
|
localPort: number;
|
|
remoteHost?: string;
|
|
remotePort: number;
|
|
}
|
|
|
|
export interface ISshUploadOptions extends ISshRunOptions {
|
|
cleanRemote?: boolean;
|
|
onProgress?: (progress: ISshUploadProgress) => void;
|
|
}
|
|
|
|
export interface ISshUploadProgress {
|
|
bytesUploaded: number;
|
|
}
|
|
|
|
export interface ISshTunnelHandle {
|
|
readonly target: IIdeSshTarget;
|
|
readonly localHost: string;
|
|
readonly localPort: number;
|
|
readonly remoteHost: string;
|
|
readonly remotePort: number;
|
|
readonly processId: number | undefined;
|
|
dispose(signal?: NodeJS.Signals): Promise<void>;
|
|
}
|
|
|
|
export interface ISshHostSaveInput {
|
|
alias: string;
|
|
hostName: string;
|
|
user?: string;
|
|
port?: number;
|
|
identityFile?: string;
|
|
proxyJump?: string;
|
|
forwardAgent?: boolean;
|
|
}
|
|
|
|
const directiveAliases: Record<string, keyof ISshHostConfig | 'identityFiles'> = {
|
|
hostname: 'hostName',
|
|
user: 'user',
|
|
port: 'port',
|
|
identityfile: 'identityFiles',
|
|
proxyjump: 'proxyJump',
|
|
forwardagent: 'forwardAgent',
|
|
};
|
|
|
|
export const defaultSshConfigPath = () => plugins.path.join(plugins.os.homedir(), '.ssh', 'config');
|
|
|
|
export const expandHome = (filePath: string) => {
|
|
if (filePath === '~') {
|
|
return plugins.os.homedir();
|
|
}
|
|
|
|
if (filePath.startsWith('~/')) {
|
|
return plugins.path.join(plugins.os.homedir(), filePath.slice(2));
|
|
}
|
|
|
|
return filePath;
|
|
};
|
|
|
|
export const parseSshConfig = (configText: string): ISshHostConfig[] => {
|
|
const hosts: ISshHostConfig[] = [];
|
|
let currentHosts: ISshHostConfig[] = [];
|
|
|
|
for (const rawLine of configText.split(/\r?\n/)) {
|
|
const line = stripSshComment(rawLine).trim();
|
|
if (!line) {
|
|
continue;
|
|
}
|
|
|
|
const tokens = tokenizeSshConfigLine(line);
|
|
if (tokens.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
const key = tokens[0]!.toLowerCase();
|
|
const values = tokens.slice(1);
|
|
if (key === 'host') {
|
|
currentHosts = values.map((alias) => ({
|
|
alias,
|
|
patterns: values,
|
|
identityFiles: [],
|
|
raw: {},
|
|
}));
|
|
hosts.push(...currentHosts);
|
|
continue;
|
|
}
|
|
|
|
for (const host of currentHosts) {
|
|
host.raw[key] = [...(host.raw[key] ?? []), ...values];
|
|
applySshDirective(host, key, values);
|
|
}
|
|
}
|
|
|
|
return hosts;
|
|
};
|
|
|
|
export const readSshConfig = async (filePath = defaultSshConfigPath()) => {
|
|
return readSshConfigRecursive(filePath, new Set());
|
|
};
|
|
|
|
export const renderSshHostBlock = (input: ISshHostSaveInput) => {
|
|
const host = normalizeSshHostSaveInput(input);
|
|
const lines = [`Host ${host.alias}`, ` HostName ${host.hostName}`];
|
|
if (host.user) {
|
|
lines.push(` User ${host.user}`);
|
|
}
|
|
if (host.port) {
|
|
lines.push(` Port ${host.port}`);
|
|
}
|
|
if (host.identityFile) {
|
|
lines.push(` IdentityFile ${host.identityFile}`);
|
|
}
|
|
if (host.proxyJump) {
|
|
lines.push(` ProxyJump ${host.proxyJump}`);
|
|
}
|
|
if (host.forwardAgent !== undefined) {
|
|
lines.push(` ForwardAgent ${host.forwardAgent ? 'yes' : 'no'}`);
|
|
}
|
|
return `${lines.join('\n')}\n`;
|
|
};
|
|
|
|
export const saveSshHostConfig = async (
|
|
input: ISshHostSaveInput,
|
|
filePath = defaultSshConfigPath(),
|
|
) => {
|
|
const host = normalizeSshHostSaveInput(input);
|
|
const expandedPath = expandHome(filePath);
|
|
let configText = '';
|
|
try {
|
|
configText = await plugins.fs.readFile(expandedPath, 'utf8');
|
|
} catch (error) {
|
|
const nodeError = error as NodeJS.ErrnoException;
|
|
if (nodeError.code !== 'ENOENT') {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const nextConfigText = upsertSshHostBlock(configText, host);
|
|
await plugins.fs.mkdir(plugins.path.dirname(expandedPath), { recursive: true, mode: 0o700 });
|
|
await plugins.fs.writeFile(expandedPath, nextConfigText, { mode: 0o600 });
|
|
try {
|
|
await plugins.fs.chmod(expandedPath, 0o600);
|
|
} catch {}
|
|
|
|
return parseSshConfig(nextConfigText).find((parsedHost) => parsedHost.alias === host.alias)!;
|
|
};
|
|
|
|
const readSshConfigRecursive = async (filePath: string, visitedFiles: Set<string>) => {
|
|
const expandedPath = expandHome(filePath);
|
|
if (visitedFiles.has(expandedPath)) {
|
|
return [];
|
|
}
|
|
visitedFiles.add(expandedPath);
|
|
|
|
try {
|
|
const configText = await plugins.fs.readFile(expandHome(filePath), 'utf8');
|
|
const hosts = parseSshConfig(configText);
|
|
for (const includePath of await resolveSshIncludePaths(configText, plugins.path.dirname(expandedPath))) {
|
|
hosts.push(...await readSshConfigRecursive(includePath, visitedFiles));
|
|
}
|
|
return hosts;
|
|
} catch (error) {
|
|
const nodeError = error as NodeJS.ErrnoException;
|
|
if (nodeError.code === 'ENOENT') {
|
|
return [];
|
|
}
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
export const listConnectableHosts = (hosts: ISshHostConfig[]) => {
|
|
return hosts.filter((host) => !isWildcardHostPattern(host.alias) && !host.alias.startsWith('!'));
|
|
};
|
|
|
|
export const buildSshDestination = (target: Pick<IIdeSshTarget, 'hostAlias' | 'user'>) => {
|
|
return target.user ? `${target.user}@${target.hostAlias}` : target.hostAlias;
|
|
};
|
|
|
|
export const buildSshArgs = (
|
|
target: IIdeSshTarget,
|
|
remoteCommand?: string,
|
|
options: ISshRunOptions = {},
|
|
) => {
|
|
const args = buildSshOptionArgs(target, options);
|
|
args.push(buildSshDestination(target));
|
|
if (remoteCommand) {
|
|
args.push(remoteCommand);
|
|
}
|
|
return args;
|
|
};
|
|
|
|
export const runSshCommand = async (
|
|
target: IIdeSshTarget,
|
|
remoteCommand: string,
|
|
options: ISshRunOptions = {},
|
|
): Promise<ISshRunResult> => {
|
|
const executable = options.executable ?? 'ssh';
|
|
const args = buildSshArgs(target, remoteCommand, options);
|
|
const env = withSshAgentEnv(options.env ?? process.env);
|
|
|
|
return new Promise<ISshRunResult>((resolve, reject) => {
|
|
const child = plugins.childProcess.spawn(executable, args, {
|
|
cwd: options.cwd,
|
|
env,
|
|
shell: false,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
});
|
|
|
|
const stdout: Buffer[] = [];
|
|
const stderr: Buffer[] = [];
|
|
let finished = false;
|
|
const timeout = options.timeoutMs
|
|
? setTimeout(() => {
|
|
if (!finished) {
|
|
child.kill('SIGTERM');
|
|
}
|
|
}, options.timeoutMs)
|
|
: undefined;
|
|
|
|
child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk));
|
|
child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
|
|
child.on('error', (error) => {
|
|
finished = true;
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
}
|
|
reject(error);
|
|
});
|
|
child.on('close', (exitCode) => {
|
|
finished = true;
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
}
|
|
resolve({
|
|
exitCode: exitCode ?? 1,
|
|
stdout: Buffer.concat(stdout).toString('utf8'),
|
|
stderr: Buffer.concat(stderr).toString('utf8'),
|
|
});
|
|
});
|
|
});
|
|
};
|
|
|
|
export const findFreePort = async (host = '127.0.0.1') => {
|
|
return new Promise<number>((resolve, reject) => {
|
|
const server = plugins.net.createServer();
|
|
server.unref();
|
|
server.on('error', reject);
|
|
server.listen(0, host, () => {
|
|
const address = server.address();
|
|
server.close(() => {
|
|
if (typeof address === 'object' && address) {
|
|
resolve(address.port);
|
|
} else {
|
|
reject(new Error('Unable to allocate a free local port'));
|
|
}
|
|
});
|
|
});
|
|
});
|
|
};
|
|
|
|
export const startSshTunnel = (
|
|
target: IIdeSshTarget,
|
|
options: ISshTunnelOptions,
|
|
): ISshTunnelHandle => {
|
|
const executable = options.executable ?? 'ssh';
|
|
const localHost = options.localHost ?? '127.0.0.1';
|
|
const remoteHost = options.remoteHost ?? '127.0.0.1';
|
|
const forward = `${localHost}:${options.localPort}:${remoteHost}:${options.remotePort}`;
|
|
const args = [
|
|
...buildSshOptionArgs(target, options),
|
|
'-N',
|
|
'-L',
|
|
forward,
|
|
buildSshDestination(target),
|
|
];
|
|
const child = plugins.childProcess.spawn(executable, args, {
|
|
cwd: options.cwd,
|
|
env: withSshAgentEnv(options.env ?? process.env),
|
|
shell: false,
|
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
windowsHide: true,
|
|
});
|
|
|
|
return {
|
|
target,
|
|
localHost,
|
|
localPort: options.localPort,
|
|
remoteHost,
|
|
remotePort: options.remotePort,
|
|
processId: child.pid,
|
|
dispose: async (signal: NodeJS.Signals = 'SIGTERM') => {
|
|
if (child.exitCode !== null || child.killed) {
|
|
return;
|
|
}
|
|
|
|
await new Promise<void>((resolve) => {
|
|
const timer = setTimeout(() => {
|
|
if (!child.killed) {
|
|
child.kill('SIGKILL');
|
|
}
|
|
resolve();
|
|
}, 5000);
|
|
child.once('close', () => {
|
|
clearTimeout(timer);
|
|
resolve();
|
|
});
|
|
child.kill(signal);
|
|
});
|
|
},
|
|
};
|
|
};
|
|
|
|
export const uploadDirectoryToRemote = async (
|
|
target: IIdeSshTarget,
|
|
localDirectory: string,
|
|
remoteDirectory: string,
|
|
options: ISshUploadOptions = {},
|
|
): Promise<ISshRunResult> => {
|
|
const remoteCommand = [
|
|
'set -euo pipefail',
|
|
options.cleanRemote === false ? undefined : `rm -rf ${quoteShellArg(remoteDirectory)}`,
|
|
`mkdir -p ${quoteShellArg(remoteDirectory)}`,
|
|
`tar -xzf - -C ${quoteShellArg(remoteDirectory)}`,
|
|
].filter(Boolean).join('\n');
|
|
const sshArgs = buildSshArgs(target, remoteCommand, options);
|
|
const env = withSshAgentEnv(options.env ?? process.env);
|
|
|
|
return new Promise<ISshRunResult>((resolve, reject) => {
|
|
const tar = plugins.childProcess.spawn('tar', ['-czf', '-', '-C', localDirectory, '.'], {
|
|
shell: false,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
});
|
|
const ssh = plugins.childProcess.spawn(options.executable ?? 'ssh', sshArgs, {
|
|
cwd: options.cwd,
|
|
env,
|
|
shell: false,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
});
|
|
|
|
const stdout: Buffer[] = [];
|
|
const stderr: Buffer[] = [];
|
|
let tarStderr = '';
|
|
let bytesUploaded = 0;
|
|
let settled = false;
|
|
const timeout = options.timeoutMs
|
|
? setTimeout(() => {
|
|
tar.kill('SIGTERM');
|
|
ssh.kill('SIGTERM');
|
|
finish({ exitCode: 1, stdout: '', stderr: `remote upload timed out after ${options.timeoutMs}ms` });
|
|
}, options.timeoutMs)
|
|
: undefined;
|
|
const finish = (result: ISshRunResult) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
}
|
|
resolve(result);
|
|
};
|
|
|
|
const progressStream = new plugins.stream.Transform({
|
|
transform(chunk: Buffer, _encoding, callback) {
|
|
bytesUploaded += chunk.length;
|
|
options.onProgress?.({ bytesUploaded });
|
|
callback(undefined, chunk);
|
|
},
|
|
});
|
|
|
|
tar.stdout.pipe(progressStream).pipe(ssh.stdin);
|
|
tar.stderr.on('data', (chunk: Buffer) => {
|
|
tarStderr += chunk.toString('utf8');
|
|
});
|
|
tar.on('error', reject);
|
|
ssh.on('error', reject);
|
|
ssh.stdout.on('data', (chunk: Buffer) => stdout.push(chunk));
|
|
ssh.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
|
|
tar.on('close', (exitCode) => {
|
|
if (exitCode !== 0) {
|
|
ssh.kill('SIGTERM');
|
|
finish({ exitCode: exitCode ?? 1, stdout: '', stderr: tarStderr || `tar exited with code ${exitCode}` });
|
|
}
|
|
});
|
|
ssh.on('close', (exitCode) => {
|
|
finish({
|
|
exitCode: exitCode ?? 1,
|
|
stdout: Buffer.concat(stdout).toString('utf8'),
|
|
stderr: [tarStderr, Buffer.concat(stderr).toString('utf8')].filter(Boolean).join('\n'),
|
|
});
|
|
});
|
|
});
|
|
};
|
|
|
|
export const probeRemoteHost = async (
|
|
target: IIdeSshTarget,
|
|
options: ISshRunOptions = {},
|
|
): Promise<IRemoteProbeResult> => {
|
|
const command = [
|
|
`printf 'platform=%s\\n' "$(uname -s 2>/dev/null || printf unknown)"`,
|
|
`printf 'arch=%s\\n' "$(uname -m 2>/dev/null || printf unknown)"`,
|
|
`printf 'homeDir=%s\\n' "$HOME"`,
|
|
`printf 'shell=%s\\n' "$SHELL"`,
|
|
`printf 'nodeVersion=%s\\n' "$(node --version 2>/dev/null || true)"`,
|
|
`printf 'pnpmVersion=%s\\n' "$(pnpm --version 2>/dev/null || true)"`,
|
|
`printf 'gitVersion=%s\\n' "$(git --version 2>/dev/null || true)"`,
|
|
`printf 'opencodeVersion=%s\\n' "$(opencode --version 2>/dev/null || true)"`,
|
|
].join('; ');
|
|
|
|
const result = await runSshCommand(target, command, options);
|
|
const fields = parseKeyValueLines(result.stdout);
|
|
const errors: string[] = [];
|
|
if (result.exitCode !== 0) {
|
|
errors.push(result.stderr || `ssh exited with code ${result.exitCode}`);
|
|
}
|
|
|
|
return {
|
|
ok: result.exitCode === 0,
|
|
hostAlias: target.hostAlias,
|
|
platform: fields.platform,
|
|
arch: fields.arch,
|
|
homeDir: fields.homeDir,
|
|
shell: fields.shell,
|
|
nodeVersion: fields.nodeVersion,
|
|
pnpmVersion: fields.pnpmVersion,
|
|
gitVersion: fields.gitVersion,
|
|
opencodeVersion: fields.opencodeVersion,
|
|
errors,
|
|
};
|
|
};
|
|
|
|
const applySshDirective = (host: ISshHostConfig, key: string, values: string[]) => {
|
|
const targetKey = directiveAliases[key];
|
|
if (!targetKey || values.length === 0) {
|
|
return;
|
|
}
|
|
|
|
if (targetKey === 'identityFiles') {
|
|
host.identityFiles.push(...values.map(expandHome));
|
|
return;
|
|
}
|
|
|
|
if (targetKey === 'port') {
|
|
const port = Number(values[0]);
|
|
if (Number.isInteger(port) && port > 0) {
|
|
host.port = port;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (targetKey === 'forwardAgent') {
|
|
host.forwardAgent = /^(yes|true)$/i.test(values[0]!);
|
|
return;
|
|
}
|
|
|
|
if (targetKey === 'hostName') {
|
|
host.hostName = values.join(' ');
|
|
} else if (targetKey === 'user') {
|
|
host.user = values.join(' ');
|
|
} else if (targetKey === 'proxyJump') {
|
|
host.proxyJump = values.join(' ');
|
|
}
|
|
};
|
|
|
|
const buildSshOptionArgs = (target: IIdeSshTarget, options: ISshRunOptions = {}) => {
|
|
const args: string[] = [];
|
|
if (options.batchMode !== false) {
|
|
args.push('-o', 'BatchMode=yes');
|
|
}
|
|
args.push('-o', 'ServerAliveInterval=30');
|
|
args.push('-o', 'ServerAliveCountMax=3');
|
|
if (target.hostName && target.hostName !== target.hostAlias) {
|
|
args.push('-o', `HostName=${target.hostName}`);
|
|
}
|
|
if (target.port) {
|
|
args.push('-p', `${target.port}`);
|
|
}
|
|
return args;
|
|
};
|
|
|
|
const withSshAgentEnv = (env: NodeJS.ProcessEnv) => {
|
|
const nextEnv = { ...env };
|
|
if (isSocketPath(nextEnv.SSH_AUTH_SOCK)) {
|
|
return nextEnv;
|
|
}
|
|
|
|
const agentEnvSocket = readAgentEnvSocket();
|
|
if (isSocketPath(agentEnvSocket)) {
|
|
nextEnv.SSH_AUTH_SOCK = agentEnvSocket;
|
|
return nextEnv;
|
|
}
|
|
|
|
const tmpSocket = findTmpAgentSocket();
|
|
if (tmpSocket) {
|
|
nextEnv.SSH_AUTH_SOCK = tmpSocket;
|
|
}
|
|
return nextEnv;
|
|
};
|
|
|
|
const quoteShellArg = (value: string | number | boolean) => {
|
|
const stringValue = String(value);
|
|
if (stringValue.length === 0) {
|
|
return "''";
|
|
}
|
|
return `'${stringValue.replace(/'/g, `'"'"'`)}'`;
|
|
};
|
|
|
|
const readAgentEnvSocket = () => {
|
|
const agentEnvPath = plugins.path.join(plugins.os.homedir(), '.ssh', 'agent.env');
|
|
try {
|
|
const agentEnvText = plugins.fsSync.readFileSync(agentEnvPath, 'utf8');
|
|
return agentEnvText.match(/(?:^|\n)SSH_AUTH_SOCK=([^;\n]+)/)?.[1];
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
const findTmpAgentSocket = () => {
|
|
const candidates: Array<{ path: string; mtimeMs: number }> = [];
|
|
try {
|
|
for (const tmpEntry of plugins.fsSync.readdirSync('/tmp', { withFileTypes: true })) {
|
|
if (!tmpEntry.isDirectory() || !tmpEntry.name.startsWith('ssh-')) {
|
|
continue;
|
|
}
|
|
const directory = plugins.path.join('/tmp', tmpEntry.name);
|
|
try {
|
|
for (const socketEntry of plugins.fsSync.readdirSync(directory, { withFileTypes: true })) {
|
|
if (!socketEntry.name.startsWith('agent.')) {
|
|
continue;
|
|
}
|
|
const socketPath = plugins.path.join(directory, socketEntry.name);
|
|
if (isSocketPath(socketPath)) {
|
|
candidates.push({ path: socketPath, mtimeMs: plugins.fsSync.statSync(socketPath).mtimeMs });
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
} catch {}
|
|
|
|
return candidates.sort((a, b) => b.mtimeMs - a.mtimeMs)[0]?.path;
|
|
};
|
|
|
|
const isSocketPath = (filePath: string | undefined) => {
|
|
if (!filePath) {
|
|
return false;
|
|
}
|
|
try {
|
|
return plugins.fsSync.statSync(filePath).isSocket();
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const normalizeSshHostSaveInput = (input: ISshHostSaveInput): ISshHostSaveInput => {
|
|
const host: ISshHostSaveInput = {
|
|
alias: input.alias.trim(),
|
|
hostName: input.hostName.trim(),
|
|
user: input.user?.trim() || undefined,
|
|
port: input.port,
|
|
identityFile: input.identityFile?.trim() || undefined,
|
|
proxyJump: input.proxyJump?.trim() || undefined,
|
|
forwardAgent: input.forwardAgent,
|
|
};
|
|
|
|
validateSshToken(host.alias, 'Host alias');
|
|
validateSshToken(host.hostName, 'HostName');
|
|
if (host.user) {
|
|
validateSshToken(host.user, 'User');
|
|
}
|
|
if (host.identityFile) {
|
|
validateSshToken(host.identityFile, 'IdentityFile');
|
|
}
|
|
if (host.proxyJump) {
|
|
validateSshToken(host.proxyJump, 'ProxyJump');
|
|
}
|
|
if (host.port !== undefined && (!Number.isInteger(host.port) || host.port <= 0 || host.port > 65535)) {
|
|
throw new Error('Port must be a number from 1 to 65535.');
|
|
}
|
|
|
|
return host;
|
|
};
|
|
|
|
const validateSshToken = (value: string, label: string) => {
|
|
if (!value) {
|
|
throw new Error(`${label} is required.`);
|
|
}
|
|
if (/\s/.test(value)) {
|
|
throw new Error(`${label} must not contain whitespace.`);
|
|
}
|
|
};
|
|
|
|
const upsertSshHostBlock = (configText: string, host: ISshHostSaveInput) => {
|
|
const blockText = renderSshHostBlock(host).trimEnd();
|
|
const lines = configText.split(/\r?\n/);
|
|
const hostBlocks = findSshHostBlocks(lines);
|
|
const exactBlock = hostBlocks.find((block) => block.patterns.length === 1 && block.patterns[0] === host.alias);
|
|
if (exactBlock) {
|
|
lines.splice(exactBlock.start, exactBlock.end - exactBlock.start, ...blockText.split('\n'));
|
|
return `${trimTrailingBlankLines(lines).join('\n')}\n`;
|
|
}
|
|
|
|
const conflictingBlock = hostBlocks.find((block) => block.patterns.includes(host.alias));
|
|
if (conflictingBlock) {
|
|
throw new Error(`Host ${host.alias} is part of a multi-host block. Edit ${defaultSshConfigPath()} manually or choose a new alias.`);
|
|
}
|
|
|
|
const trimmedConfig = configText.trimEnd();
|
|
if (!trimmedConfig) {
|
|
return `${blockText}\n`;
|
|
}
|
|
return `${trimmedConfig}\n\n${blockText}\n`;
|
|
};
|
|
|
|
const findSshHostBlocks = (lines: string[]) => {
|
|
const blocks: Array<{ start: number; end: number; patterns: string[] }> = [];
|
|
for (let index = 0; index < lines.length; index++) {
|
|
const tokens = tokenizeSshConfigLine(stripSshComment(lines[index]!).trim());
|
|
if (tokens[0]?.toLowerCase() !== 'host') {
|
|
continue;
|
|
}
|
|
const nextHostIndex = findNextHostLine(lines, index + 1);
|
|
blocks.push({
|
|
start: index,
|
|
end: nextHostIndex === -1 ? lines.length : nextHostIndex,
|
|
patterns: tokens.slice(1),
|
|
});
|
|
}
|
|
return blocks;
|
|
};
|
|
|
|
const findNextHostLine = (lines: string[], startIndex: number) => {
|
|
for (let index = startIndex; index < lines.length; index++) {
|
|
const tokens = tokenizeSshConfigLine(stripSshComment(lines[index]!).trim());
|
|
if (tokens[0]?.toLowerCase() === 'host') {
|
|
return index;
|
|
}
|
|
}
|
|
return -1;
|
|
};
|
|
|
|
const trimTrailingBlankLines = (lines: string[]) => {
|
|
const nextLines = [...lines];
|
|
while (nextLines.length > 0 && !nextLines[nextLines.length - 1]!.trim()) {
|
|
nextLines.pop();
|
|
}
|
|
return nextLines;
|
|
};
|
|
|
|
const resolveSshIncludePaths = async (configText: string, baseDir: string) => {
|
|
const includePaths: string[] = [];
|
|
for (const rawLine of configText.split(/\r?\n/)) {
|
|
const tokens = tokenizeSshConfigLine(stripSshComment(rawLine).trim());
|
|
if (tokens[0]?.toLowerCase() !== 'include') {
|
|
continue;
|
|
}
|
|
for (const includePattern of tokens.slice(1)) {
|
|
includePaths.push(...await expandSshIncludePattern(includePattern, baseDir));
|
|
}
|
|
}
|
|
return includePaths;
|
|
};
|
|
|
|
const expandSshIncludePattern = async (includePattern: string, baseDir: string) => {
|
|
const expandedPattern = expandHome(includePattern);
|
|
const absolutePattern = plugins.path.isAbsolute(expandedPattern)
|
|
? expandedPattern
|
|
: plugins.path.resolve(baseDir, expandedPattern);
|
|
const directory = plugins.path.dirname(absolutePattern);
|
|
const basename = plugins.path.basename(absolutePattern);
|
|
if (!/[?*]/.test(basename)) {
|
|
return [absolutePattern];
|
|
}
|
|
|
|
try {
|
|
const entries = await plugins.fs.readdir(directory, { withFileTypes: true });
|
|
const matcher = wildcardToRegExp(basename);
|
|
return entries
|
|
.filter((entry) => entry.isFile() && matcher.test(entry.name))
|
|
.map((entry) => plugins.path.join(directory, entry.name))
|
|
.sort();
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const wildcardToRegExp = (pattern: string) => {
|
|
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
return new RegExp(`^${escaped.replace(/\*/g, '.*').replace(/\?/g, '.')}$`);
|
|
};
|
|
|
|
const stripSshComment = (line: string) => {
|
|
let quote: string | undefined;
|
|
for (let index = 0; index < line.length; index++) {
|
|
const character = line[index];
|
|
if ((character === '"' || character === "'") && line[index - 1] !== '\\') {
|
|
quote = quote === character ? undefined : character;
|
|
continue;
|
|
}
|
|
if (character === '#' && !quote) {
|
|
return line.slice(0, index);
|
|
}
|
|
}
|
|
return line;
|
|
};
|
|
|
|
const tokenizeSshConfigLine = (line: string) => {
|
|
const tokens: string[] = [];
|
|
let current = '';
|
|
let quote: string | undefined;
|
|
for (let index = 0; index < line.length; index++) {
|
|
const character = line[index]!;
|
|
if ((character === '"' || character === "'") && line[index - 1] !== '\\') {
|
|
quote = quote === character ? undefined : character;
|
|
continue;
|
|
}
|
|
if (/\s/.test(character) && !quote) {
|
|
if (current) {
|
|
tokens.push(current);
|
|
current = '';
|
|
}
|
|
continue;
|
|
}
|
|
current += character;
|
|
}
|
|
if (current) {
|
|
tokens.push(current);
|
|
}
|
|
return tokens;
|
|
};
|
|
|
|
const isWildcardHostPattern = (alias: string) => /[*?!]/.test(alias);
|
|
|
|
const parseKeyValueLines = (text: string) => {
|
|
const fields: Record<string, string> = {};
|
|
for (const line of text.split(/\r?\n/)) {
|
|
const separator = line.indexOf('=');
|
|
if (separator === -1) {
|
|
continue;
|
|
}
|
|
fields[line.slice(0, separator)] = line.slice(separator + 1);
|
|
}
|
|
return fields;
|
|
};
|