Files
dap/ts/classes.sshconfigwriter.ts
T

294 lines
11 KiB
TypeScript
Raw Normal View History

import * as plugins from './plugins.js';
import { SshConfig } from './classes.sshconfig.js';
import type {
IHostDefinition,
IHostUpdatePreview,
IHostWriteResult,
ISshConfigOptions,
} from './types.js';
export class SshConfigWriter {
public readonly sshConfig: SshConfig;
public readonly mainConfigPath: string;
constructor(options: ISshConfigOptions = {}) {
this.sshConfig = new SshConfig(options);
this.mainConfigPath = this.sshConfig.mainConfigPath;
}
public async addOrReplaceManagedHost(hostDefinition: IHostDefinition): Promise<IHostWriteResult> {
this.validateAlias(hostDefinition.alias);
const lines = await this.readMainLines();
const nextBlock = this.buildManagedHostBlock(hostDefinition);
const managedRange = this.findManagedRange(lines, hostDefinition.alias);
const nextLines = [...lines];
if (managedRange) {
nextLines.splice(managedRange.start, managedRange.end - managedRange.start + 1, ...nextBlock);
} else {
if (nextLines.length && nextLines[nextLines.length - 1].trim()) {
nextLines.push('');
}
nextLines.push(...nextBlock);
}
return this.writeIfChanged(lines, nextLines);
}
public async previewUpdateHost(
alias: string,
changes: Partial<IHostDefinition>
): Promise<IHostUpdatePreview> {
this.validateAlias(alias);
const lines = await this.readMainLines();
const managedRange = this.findManagedRange(lines, alias);
if (managedRange) {
const existingHost = await this.sshConfig.getHost(alias);
const mergedDefinition = this.mergeHostDefinition(alias, existingHost?.options ?? {}, changes);
const nextBlock = this.buildManagedHostBlock(mergedDefinition);
const nextLines = [...lines];
nextLines.splice(managedRange.start, managedRange.end - managedRange.start + 1, ...nextBlock);
return {
filePath: this.mainConfigPath,
before: lines.join('\n'),
after: nextLines.join('\n'),
diff: this.createLineDiff(lines, nextLines),
dapManaged: true,
};
}
const hostRange = this.findHostRange(lines, alias);
if (!hostRange) {
throw new Error(`Host "${alias}" was not found in ${this.mainConfigPath}`);
}
const nextLines = [...lines];
const block = lines.slice(hostRange.start, hostRange.end + 1);
const updatedBlock = this.applyChangesToHostBlock(block, changes);
nextLines.splice(hostRange.start, hostRange.end - hostRange.start + 1, ...updatedBlock);
return {
filePath: this.mainConfigPath,
before: lines.join('\n'),
after: nextLines.join('\n'),
diff: this.createLineDiff(lines, nextLines),
dapManaged: false,
};
}
public async updateHost(alias: string, changes: Partial<IHostDefinition>): Promise<IHostWriteResult> {
const preview = await this.previewUpdateHost(alias, changes);
return this.writeIfChanged(preview.before.split('\n'), preview.after.split('\n'));
}
public buildManagedHostBlock(hostDefinition: IHostDefinition): string[] {
this.validateAlias(hostDefinition.alias);
const lines = [`# dap:begin ${hostDefinition.alias}`, `Host ${hostDefinition.alias}`];
this.addOptionalLine(lines, 'HostName', hostDefinition.hostName);
this.addOptionalLine(lines, 'User', hostDefinition.user);
this.addOptionalLine(lines, 'Port', hostDefinition.port);
this.addOptionalLine(lines, 'IdentityFile', hostDefinition.identityFile);
this.addOptionalLine(lines, 'ProxyJump', hostDefinition.proxyJump);
for (const localForward of hostDefinition.localForwards ?? []) {
this.addOptionalLine(lines, 'LocalForward', localForward);
}
for (const remoteForward of hostDefinition.remoteForwards ?? []) {
this.addOptionalLine(lines, 'RemoteForward', remoteForward);
}
lines.push(`# dap:end ${hostDefinition.alias}`);
return lines;
}
private addOptionalLine(lines: string[], optionName: string, value: string | undefined): void {
if (!value) {
return;
}
lines.push(` ${optionName} ${this.formatSshValue(value)}`);
}
private formatSshValue(value: string): string {
if (!/\s/.test(value)) {
return value;
}
return `"${value.replace(/["\\]/g, '\\$&')}"`;
}
private mergeHostDefinition(
alias: string,
options: Record<string, string[]>,
changes: Partial<IHostDefinition>
): IHostDefinition {
return {
alias,
hostName: changes.hostName ?? options.hostname?.[0],
user: changes.user ?? options.user?.[0],
port: changes.port ?? options.port?.[0],
identityFile: changes.identityFile ?? options.identityfile?.[0],
proxyJump: changes.proxyJump ?? options.proxyjump?.[0],
localForwards: changes.localForwards ?? options.localforward,
remoteForwards: changes.remoteForwards ?? options.remoteforward,
};
}
private applyChangesToHostBlock(block: string[], changes: Partial<IHostDefinition>): string[] {
const optionMap = new Map<string, { name: string; values: string[] }>();
if (changes.hostName) optionMap.set('hostname', { name: 'HostName', values: [changes.hostName] });
if (changes.user) optionMap.set('user', { name: 'User', values: [changes.user] });
if (changes.port) optionMap.set('port', { name: 'Port', values: [changes.port] });
if (changes.identityFile) optionMap.set('identityfile', { name: 'IdentityFile', values: [changes.identityFile] });
if (changes.proxyJump) optionMap.set('proxyjump', { name: 'ProxyJump', values: [changes.proxyJump] });
if (changes.localForwards?.length) {
optionMap.set('localforward', { name: 'LocalForward', values: changes.localForwards });
}
if (changes.remoteForwards?.length) {
optionMap.set('remoteforward', { name: 'RemoteForward', values: changes.remoteForwards });
}
const seenOptions = new Set<string>();
const updatedBlock: string[] = [];
let indent = ' ';
block.forEach((line, index) => {
if (index > 0) {
const optionMatch = line.match(/^(\s*)([^\s#]+)\s+(.+)$/);
if (optionMatch) {
indent = optionMatch[1] || indent;
const normalizedOptionName = optionMatch[2].toLowerCase();
const nextOption = optionMap.get(normalizedOptionName);
if (nextOption) {
if (!seenOptions.has(normalizedOptionName)) {
for (const value of nextOption.values) {
updatedBlock.push(`${indent}${nextOption.name} ${this.formatSshValue(value)}`);
}
seenOptions.add(normalizedOptionName);
}
return;
}
}
}
updatedBlock.push(line);
});
const missingOptions = [...optionMap.entries()].filter(([optionName]) => !seenOptions.has(optionName));
if (missingOptions.length) {
const insertAt = Math.min(1, updatedBlock.length);
const missingLines = missingOptions.flatMap(([, option]) =>
option.values.map((value) => `${indent}${option.name} ${this.formatSshValue(value)}`)
);
updatedBlock.splice(insertAt, 0, ...missingLines);
}
return updatedBlock;
}
private findManagedRange(lines: string[], alias: string): { start: number; end: number } | undefined {
const beginMatcher = new RegExp(`^\\s*#\\s*dap:begin\\s+${this.escapeRegExp(alias)}\\s*$`, 'i');
const endMatcher = new RegExp(`^\\s*#\\s*dap:end\\s+${this.escapeRegExp(alias)}\\s*$`, 'i');
const start = lines.findIndex((line) => beginMatcher.test(line));
if (start < 0) {
return undefined;
}
const relativeEnd = lines.slice(start + 1).findIndex((line) => endMatcher.test(line));
if (relativeEnd < 0) {
throw new Error(`DAP-managed block for "${alias}" is missing its dap:end marker`);
}
return { start, end: start + 1 + relativeEnd };
}
private findHostRange(lines: string[], alias: string): { start: number; end: number } | undefined {
let start = -1;
const parser = new SshConfig({ mainConfigPath: this.mainConfigPath });
for (let index = 0; index < lines.length; index++) {
const trimmedLine = lines[index].trim();
const hostMatch = trimmedLine.match(/^Host\s+(.+)$/i);
const sectionBoundary = /^(Host|Match)\s+/i.test(trimmedLine);
if (sectionBoundary && start >= 0) {
return { start, end: index - 1 };
}
if (hostMatch) {
const patterns = parser.shellSplit(hostMatch[1]);
if (patterns.includes(alias)) {
start = index;
}
}
}
if (start >= 0) {
return { start, end: lines.length - 1 };
}
return undefined;
}
private async readMainLines(): Promise<string[]> {
try {
const content = await plugins.fs.readFile(this.mainConfigPath, 'utf8');
return content.replace(/\r\n/g, '\n').split('\n');
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === 'ENOENT') {
return [];
}
throw error;
}
}
private async writeIfChanged(beforeLines: string[], afterLines: string[]): Promise<IHostWriteResult> {
const before = beforeLines.join('\n');
const after = this.ensureTrailingNewline(afterLines.join('\n'));
if (this.ensureTrailingNewline(before) === after) {
return { changed: false, filePath: this.mainConfigPath };
}
await plugins.fs.mkdir(plugins.path.dirname(this.mainConfigPath), { recursive: true, mode: 0o700 });
const backupPath = await this.createBackupIfExisting();
await plugins.fs.writeFile(this.mainConfigPath, after, { mode: 0o600 });
return { changed: true, backupPath, filePath: this.mainConfigPath };
}
private async createBackupIfExisting(): Promise<string | undefined> {
if (!plugins.fsSync.existsSync(this.mainConfigPath)) {
return undefined;
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `${this.mainConfigPath}.dap-backup-${timestamp}`;
await plugins.fs.copyFile(this.mainConfigPath, backupPath);
return backupPath;
}
private ensureTrailingNewline(value: string): string {
return value.endsWith('\n') ? value : `${value}\n`;
}
private createLineDiff(beforeLines: string[], afterLines: string[]): string {
const diffLines = [`--- ${this.mainConfigPath}`, `+++ ${this.mainConfigPath}`];
const maxLength = Math.max(beforeLines.length, afterLines.length);
for (let index = 0; index < maxLength; index++) {
const beforeLine = beforeLines[index];
const afterLine = afterLines[index];
if (beforeLine === afterLine) {
continue;
}
if (beforeLine !== undefined) {
diffLines.push(`-${beforeLine}`);
}
if (afterLine !== undefined) {
diffLines.push(`+${afterLine}`);
}
}
return diffLines.join('\n');
}
private validateAlias(alias: string): void {
if (!alias || /\s/.test(alias)) {
throw new Error('SSH host alias must be a non-empty value without whitespace');
}
}
private escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
}