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 { 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 ): Promise { 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): Promise { 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, changes: Partial ): 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): string[] { const optionMap = new Map(); 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(); 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 { 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 { 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 { 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, '\\$&'); } }