feat(core): add SSH data access proxy CLI and core managers
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
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, '\\$&');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user