6f32a206b4
Keeps provider credentials local while executing OpenCode shell and file tools against the selected remote workspace over SSH.
780 lines
22 KiB
TypeScript
780 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;
|
|
stdin?: string | Buffer;
|
|
}
|
|
|
|
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: options.stdin === undefined ? ['ignore', 'pipe', 'pipe'] : ['pipe', '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));
|
|
if (options.stdin !== undefined) {
|
|
child.stdin!.end(options.stdin);
|
|
}
|
|
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;
|
|
};
|