feat(core): add SSH data access proxy CLI and core managers

This commit is contained in:
2026-05-30 10:02:08 +00:00
commit 47d9846c93
23 changed files with 10399 additions and 0 deletions
+476
View File
@@ -0,0 +1,476 @@
import * as plugins from './plugins.js';
import { HostManager } from './classes.hostmanager.js';
import { MountManager } from './classes.mountmanager.js';
import { PortProxy } from './classes.portproxy.js';
import { SessionBridge } from './classes.sessionbridge.js';
import { SshClient } from './classes.sshclient.js';
import type { IDoctorCheck, IHostDefinition, IParsedArgs, ISshConfigHost, TDapMountBackend } from './types.js';
export class DapCli {
private hostManager = new HostManager();
private mountManager = new MountManager();
private portProxy = new PortProxy();
private sshClient = new SshClient();
public async run(argv = process.argv.slice(2)): Promise<number> {
const parsedArgs = this.parseArgs(argv);
const [command, ...positionals] = parsedArgs.positional;
try {
switch (command) {
case undefined:
return this.runInteractiveDashboard();
case 'help':
case '--help':
case '-h':
this.printHelp();
return 0;
case 'list':
return this.runList();
case 'add':
return this.runAdd(positionals, parsedArgs);
case 'edit':
return this.runEdit(positionals, parsedArgs);
case 'ssh':
return this.runSsh(positionals, parsedArgs);
case 'proxy':
return this.runProxy(positionals, parsedArgs);
case 'mount':
return this.runMount(positionals, parsedArgs);
case 'unmount':
return this.runUnmount(positionals);
case 'doctor':
return this.runDoctor();
default:
console.error(`Unknown command: ${command}`);
this.printHelp();
return 1;
}
} catch (error) {
console.error((error as Error).message);
return 1;
}
}
public parseArgs(argv: string[]): IParsedArgs {
const positional: string[] = [];
const flags: Record<string, string | boolean | string[]> = {};
const passthroughIndex = argv.indexOf('--');
const dapArgs = passthroughIndex >= 0 ? argv.slice(0, passthroughIndex) : argv;
const passthrough = passthroughIndex >= 0 ? argv.slice(passthroughIndex + 1) : [];
for (let index = 0; index < dapArgs.length; index++) {
const arg = dapArgs[index];
if (!arg.startsWith('--')) {
positional.push(arg);
continue;
}
const flagBody = arg.slice(2);
const equalsIndex = flagBody.indexOf('=');
let flagName: string;
let flagValue: string | boolean;
if (equalsIndex >= 0) {
flagName = flagBody.slice(0, equalsIndex);
flagValue = flagBody.slice(equalsIndex + 1);
} else {
flagName = flagBody;
const nextArg = dapArgs[index + 1];
if (nextArg && !nextArg.startsWith('--')) {
flagValue = nextArg;
index++;
} else {
flagValue = true;
}
}
this.addFlag(flags, flagName, flagValue);
}
return { positional, flags, passthrough };
}
private async runInteractiveDashboard(): Promise<number> {
const rl = this.createReadline();
try {
while (true) {
console.log('');
console.log('dap');
console.log('1. List hosts');
console.log('2. Add host');
console.log('3. Edit host');
console.log('4. SSH into host');
console.log('5. Proxy port');
console.log('6. Mount remote path');
console.log('7. Doctor');
console.log('q. Quit');
const choice = (await rl.question('Choose: ')).trim();
if (choice === 'q' || choice === 'quit' || choice === 'exit') {
return 0;
}
if (choice === '1') await this.runList();
if (choice === '2') await this.runAdd([], { positional: [], flags: {}, passthrough: [] });
if (choice === '3') {
const alias = await rl.question('Host alias: ');
await this.runEdit([alias.trim()], { positional: [], flags: {}, passthrough: [] });
}
if (choice === '4') {
const alias = await rl.question('Host alias: ');
await this.runSsh([alias.trim()], { positional: [], flags: {}, passthrough: [] });
}
if (choice === '5') {
const alias = await rl.question('Host alias: ');
const localForward = await rl.question('Local forward (<localPort>:<remoteHost>:<remotePort>): ');
await this.runProxy([alias.trim()], {
positional: [],
flags: { local: localForward.trim() },
passthrough: [],
});
}
if (choice === '6') {
const remoteSpec = await rl.question('Remote spec (<host>:<remotePath>): ');
const localPath = await rl.question('Local mount path: ');
await this.runMount([remoteSpec.trim(), localPath.trim()], { positional: [], flags: {}, passthrough: [] });
}
if (choice === '7') await this.runDoctor();
}
} finally {
rl.close();
}
}
private async runList(): Promise<number> {
const hosts = await this.hostManager.listHosts();
if (!hosts.length) {
console.log('No SSH hosts found.');
return 0;
}
for (const host of hosts) {
this.printHost(host);
}
return 0;
}
private async runAdd(positionals: string[], parsedArgs: IParsedArgs): Promise<number> {
const hostDefinition = await this.collectHostDefinition(positionals[0], parsedArgs.flags);
const result = await this.hostManager.addHost(hostDefinition);
console.log(result.changed ? `Wrote ${result.filePath}` : `No changes in ${result.filePath}`);
if (result.backupPath) {
console.log(`Backup: ${result.backupPath}`);
}
return 0;
}
private async runEdit(positionals: string[], parsedArgs: IParsedArgs): Promise<number> {
const alias = positionals[0];
if (!alias) {
throw new Error('Usage: dap edit <host> [--hostname value] [--user value] [--port value] [--identity-file path] [--proxy-jump host] [--local-forward spec] [--remote-forward spec]');
}
const { alias: ignoredAlias, ...changes } = this.hostDefinitionFromFlags(alias, parsedArgs.flags);
const hasFlagChanges = Object.values(changes).some(Boolean);
const finalChanges = hasFlagChanges ? changes : await this.promptForHostChanges(alias);
const preview = await this.hostManager.previewEditHost(alias, finalChanges);
if (preview.before === preview.after) {
console.log('No changes.');
return 0;
}
if (!preview.dapManaged && !this.getBooleanFlag(parsedArgs.flags, 'yes')) {
console.log(preview.diff);
const confirmed = await this.confirm('Apply changes to existing non-DAP SSH config block?', false);
if (!confirmed) {
console.log('Aborted.');
return 1;
}
}
const result = await this.hostManager.editHost(alias, finalChanges);
console.log(result.changed ? `Wrote ${result.filePath}` : `No changes in ${result.filePath}`);
if (result.backupPath) {
console.log(`Backup: ${result.backupPath}`);
}
return 0;
}
private async runSsh(positionals: string[], parsedArgs: IParsedArgs): Promise<number> {
const host = positionals[0];
if (!host) {
throw new Error('Usage: dap ssh <host> [--no-bridge] [-- <ssh args>]');
}
const useBridge = !this.getBooleanFlag(parsedArgs.flags, 'no-bridge') && parsedArgs.passthrough.length === 0;
if (!useBridge) {
return this.sshClient.ssh(host, { extraArgs: parsedArgs.passthrough });
}
const bridge = new SessionBridge({ host, mountManager: this.mountManager });
await bridge.start();
try {
return await this.sshClient.ssh(host, {
extraArgs: ['-t'],
reverseForwards: [bridge.getReverseForwardSpec()],
remoteCommand: bridge.buildRemoteBootstrapCommand(),
exitOnForwardFailure: true,
});
} finally {
await bridge.stop();
}
}
private async runProxy(positionals: string[], parsedArgs: IParsedArgs): Promise<number> {
const host = positionals[0];
const localForward = this.getStringFlag(parsedArgs.flags, 'local') ?? positionals[1];
if (!host || !localForward) {
throw new Error('Usage: dap proxy <host> --local <localPort>:<remoteHost>:<remotePort>');
}
return this.portProxy.start({ host, localForward });
}
private async runMount(positionals: string[], parsedArgs: IParsedArgs): Promise<number> {
const remoteSpec = positionals[0];
const localPath = positionals[1];
if (!remoteSpec || !localPath) {
throw new Error('Usage: dap mount <host>:<remotePath> <localPath> [--backend sshfs|rclone]');
}
const parsedRemote = this.mountManager.parseRemoteSpec(remoteSpec);
const backend = this.getStringFlag(parsedArgs.flags, 'backend') as TDapMountBackend | undefined;
if (backend && backend !== 'sshfs' && backend !== 'rclone') {
throw new Error('Mount backend must be sshfs or rclone');
}
return this.mountManager.mount({
host: parsedRemote.host,
remotePath: parsedRemote.remotePath,
localPath,
backend,
});
}
private async runUnmount(positionals: string[]): Promise<number> {
const localPath = positionals[0];
if (!localPath) {
throw new Error('Usage: dap unmount <localPath>');
}
return this.mountManager.unmount(localPath);
}
private async runDoctor(): Promise<number> {
const checks: IDoctorCheck[] = [];
checks.push(await this.checkCommand('ssh', 'OpenSSH client'));
checks.push(await this.checkCommand('sshfs', 'Preferred mount backend'));
checks.push(await this.checkCommand('rclone', 'Fallback mount backend'));
checks.push(await this.checkSshConfig());
checks.push(await this.checkFuse());
for (const check of checks) {
console.log(`${check.ok ? 'ok' : 'missing'} ${check.name} - ${check.detail}`);
}
return checks.some((check) => !check.ok && check.name === 'ssh') ? 1 : 0;
}
private async collectHostDefinition(
aliasFromPosition: string | undefined,
flags: Record<string, string | boolean | Array<string | boolean>>
): Promise<IHostDefinition> {
const initialDefinition = this.hostDefinitionFromFlags(aliasFromPosition, flags);
if (initialDefinition.alias && initialDefinition.hostName) {
return {
...initialDefinition,
port: initialDefinition.port ?? '22',
};
}
const rl = this.createReadline();
try {
const alias = initialDefinition.alias || (await rl.question('Host alias: ')).trim();
const hostName = initialDefinition.hostName || (await rl.question('HostName: ')).trim();
const user = initialDefinition.user || (await rl.question('User: ')).trim();
const port = initialDefinition.port || (await this.questionWithDefault(rl, 'Port', '22'));
const identityFile = initialDefinition.identityFile || (await rl.question('IdentityFile (optional): ')).trim();
const proxyJump = initialDefinition.proxyJump || (await rl.question('ProxyJump (optional): ')).trim();
return {
alias,
hostName,
user,
port,
identityFile: identityFile || undefined,
proxyJump: proxyJump || undefined,
};
} finally {
rl.close();
}
}
private async promptForHostChanges(alias: string): Promise<Partial<IHostDefinition>> {
const host = await this.hostManager.getHost(alias);
const rl = this.createReadline();
try {
return {
hostName: await this.questionWithDefault(rl, 'HostName', host?.options.hostname?.[0] ?? ''),
user: await this.questionWithDefault(rl, 'User', host?.options.user?.[0] ?? ''),
port: await this.questionWithDefault(rl, 'Port', host?.options.port?.[0] ?? ''),
identityFile: await this.questionWithDefault(rl, 'IdentityFile', host?.options.identityfile?.[0] ?? ''),
proxyJump: await this.questionWithDefault(rl, 'ProxyJump', host?.options.proxyjump?.[0] ?? ''),
};
} finally {
rl.close();
}
}
private hostDefinitionFromFlags(
aliasFromPosition: string | undefined,
flags: Record<string, string | boolean | Array<string | boolean>>
): IHostDefinition {
return {
alias: this.getStringFlag(flags, 'alias') ?? this.getStringFlag(flags, 'host') ?? aliasFromPosition ?? '',
hostName: this.getStringFlag(flags, 'hostname') ?? this.getStringFlag(flags, 'host-name'),
user: this.getStringFlag(flags, 'user'),
port: this.getStringFlag(flags, 'port'),
identityFile: this.getStringFlag(flags, 'identity-file') ?? this.getStringFlag(flags, 'key'),
proxyJump: this.getStringFlag(flags, 'proxy-jump'),
localForwards: this.getStringArrayFlag(flags, 'local-forward'),
remoteForwards: this.getStringArrayFlag(flags, 'remote-forward'),
};
}
private async questionWithDefault(
rl: plugins.readline.Interface,
label: string,
defaultValue: string
): Promise<string> {
const suffix = defaultValue ? ` [${defaultValue}]` : '';
const answer = (await rl.question(`${label}${suffix}: `)).trim();
return answer || defaultValue;
}
private async confirm(question: string, defaultValue: boolean): Promise<boolean> {
const rl = this.createReadline();
try {
const suffix = defaultValue ? ' [Y/n]' : ' [y/N]';
const answer = (await rl.question(`${question}${suffix} `)).trim().toLowerCase();
if (!answer) {
return defaultValue;
}
return answer === 'y' || answer === 'yes';
} finally {
rl.close();
}
}
private printHost(host: ISshConfigHost): void {
const alias = host.patterns.join(', ');
const hostName = host.options.hostname?.[0] ?? '-';
const user = host.options.user?.[0] ?? '-';
const port = host.options.port?.[0] ?? '22';
const managed = host.dapManaged ? 'dap' : 'ssh';
console.log(`${alias}\t${user}@${hostName}:${port}\t${managed}\t${host.filePath}:${host.startLine}`);
}
private async checkCommand(command: string, detail: string): Promise<IDoctorCheck> {
const ok = await this.sshClient.commandExists(command);
return {
name: command,
ok,
detail: ok ? detail : `${detail} is not installed or not in PATH`,
};
}
private async checkSshConfig(): Promise<IDoctorCheck> {
const configPath = plugins.path.join(plugins.os.homedir(), '.ssh', 'config');
try {
await plugins.fs.access(configPath);
return { name: 'ssh config', ok: true, detail: configPath };
} catch {
return { name: 'ssh config', ok: false, detail: `${configPath} does not exist yet` };
}
}
private async checkFuse(): Promise<IDoctorCheck> {
if (process.platform === 'darwin') {
const macFusePath = '/Library/Filesystems/macfuse.fs';
return {
name: 'macFUSE',
ok: plugins.fsSync.existsSync(macFusePath),
detail: macFusePath,
};
}
if (process.platform === 'linux') {
return {
name: 'FUSE',
ok: plugins.fsSync.existsSync('/dev/fuse'),
detail: '/dev/fuse',
};
}
return { name: 'FUSE', ok: false, detail: `${process.platform} is not supported by dap yet` };
}
private createReadline(): plugins.readline.Interface {
return plugins.readline.createInterface({ input: process.stdin, output: process.stdout });
}
private getStringFlag(
flags: Record<string, string | boolean | Array<string | boolean>>,
flagName: string
): string | undefined {
const value = flags[flagName];
if (Array.isArray(value)) {
const lastValue = value[value.length - 1];
return typeof lastValue === 'string' ? lastValue : undefined;
}
return typeof value === 'string' ? value : undefined;
}
private getStringArrayFlag(
flags: Record<string, string | boolean | Array<string | boolean>>,
flagName: string
): string[] | undefined {
const value = flags[flagName];
if (Array.isArray(value)) {
return value.filter((entry): entry is string => typeof entry === 'string');
}
if (typeof value === 'string') {
return [value];
}
return undefined;
}
private getBooleanFlag(
flags: Record<string, string | boolean | Array<string | boolean>>,
flagName: string
): boolean {
return flags[flagName] === true;
}
private addFlag(
flags: Record<string, string | boolean | Array<string | boolean>>,
flagName: string,
flagValue: string | boolean
): void {
const existingValue = flags[flagName];
if (existingValue === undefined) {
flags[flagName] = flagValue;
return;
}
if (Array.isArray(existingValue)) {
existingValue.push(flagValue);
return;
}
flags[flagName] = [existingValue, flagValue];
}
private printHelp(): void {
console.log(`dap - data access proxy for SSH machines
Usage:
dap
dap list
dap add [alias] [--hostname value] [--user value] [--port 22] [--identity-file path] [--local-forward spec] [--remote-forward spec]
dap edit <host> [--hostname value] [--user value] [--port value] [--identity-file path] [--proxy-jump host] [--local-forward spec] [--remote-forward spec]
dap ssh <host> [--no-bridge] [-- <ssh args>]
dap proxy <host> --local <localPort>:<remoteHost>:<remotePort>
dap mount <host>:<remotePath> <localPath> [--backend sshfs|rclone]
dap unmount <localPath>
dap doctor
`);
}
}
+43
View File
@@ -0,0 +1,43 @@
import { SshConfig } from './classes.sshconfig.js';
import { SshConfigWriter } from './classes.sshconfigwriter.js';
import type {
IHostDefinition,
IHostUpdatePreview,
IHostWriteResult,
ISshConfigHost,
ISshConfigOptions,
} from './types.js';
export class HostManager {
private sshConfig: SshConfig;
private sshConfigWriter: SshConfigWriter;
constructor(options: ISshConfigOptions = {}) {
this.sshConfig = new SshConfig(options);
this.sshConfigWriter = new SshConfigWriter(options);
}
public async listHosts(): Promise<ISshConfigHost[]> {
const result = await this.sshConfig.read();
return this.sshConfig.getDisplayHosts(result);
}
public async getHost(alias: string): Promise<ISshConfigHost | undefined> {
return this.sshConfig.getHost(alias);
}
public async addHost(hostDefinition: IHostDefinition): Promise<IHostWriteResult> {
return this.sshConfigWriter.addOrReplaceManagedHost(hostDefinition);
}
public async previewEditHost(
alias: string,
changes: Partial<IHostDefinition>
): Promise<IHostUpdatePreview> {
return this.sshConfigWriter.previewUpdateHost(alias, changes);
}
public async editHost(alias: string, changes: Partial<IHostDefinition>): Promise<IHostWriteResult> {
return this.sshConfigWriter.updateHost(alias, changes);
}
}
+76
View File
@@ -0,0 +1,76 @@
import * as plugins from './plugins.js';
import { SshClient } from './classes.sshclient.js';
import type { IMountRequest, TDapMountBackend } from './types.js';
export class MountManager {
constructor(private sshClient = new SshClient()) {}
public parseRemoteSpec(remoteSpec: string): { host: string; remotePath: string } {
const separatorIndex = remoteSpec.indexOf(':');
if (separatorIndex <= 0) {
throw new Error('Remote mount spec must look like <host>:<remotePath>');
}
const host = remoteSpec.slice(0, separatorIndex);
const remotePath = remoteSpec.slice(separatorIndex + 1) || '.';
return { host, remotePath };
}
public buildSshfsArgs(request: IMountRequest): string[] {
return [`${request.host}:${request.remotePath}`, request.localPath];
}
public buildRcloneArgs(request: IMountRequest): string[] {
return [
'mount',
`:sftp:${request.remotePath}`,
request.localPath,
'--sftp-ssh',
`ssh ${request.host}`,
'--sftp-shell-type',
'none',
'--vfs-cache-mode',
'writes',
];
}
public async mount(request: IMountRequest): Promise<number> {
await plugins.fs.mkdir(request.localPath, { recursive: true });
const backend = request.backend ?? (await this.detectBackend());
if (backend === 'sshfs') {
return this.sshClient.spawnInteractive('sshfs', this.buildSshfsArgs(request));
}
return this.sshClient.spawnInteractive('rclone', this.buildRcloneArgs(request));
}
public async mountDetached(request: IMountRequest): Promise<number> {
await plugins.fs.mkdir(request.localPath, { recursive: true });
const backend = request.backend ?? (await this.detectBackend());
if (backend === 'sshfs') {
return this.sshClient.spawnInteractive('sshfs', this.buildSshfsArgs(request));
}
return this.sshClient.spawnDetached('rclone', this.buildRcloneArgs(request));
}
public async unmount(localPath: string): Promise<number> {
if (process.platform === 'darwin') {
return this.sshClient.spawnInteractive('umount', [localPath]);
}
if (await this.sshClient.commandExists('fusermount3')) {
return this.sshClient.spawnInteractive('fusermount3', ['-u', localPath]);
}
if (await this.sshClient.commandExists('fusermount')) {
return this.sshClient.spawnInteractive('fusermount', ['-u', localPath]);
}
return this.sshClient.spawnInteractive('umount', [localPath]);
}
public async detectBackend(): Promise<TDapMountBackend> {
if (await this.sshClient.commandExists('sshfs')) {
return 'sshfs';
}
if (await this.sshClient.commandExists('rclone')) {
return 'rclone';
}
throw new Error('No mount backend found. Install sshfs, or install rclone with FUSE/macFUSE support.');
}
}
+26
View File
@@ -0,0 +1,26 @@
import { SshClient } from './classes.sshclient.js';
import type { IProxyRequest } from './types.js';
export class PortProxy {
constructor(private sshClient = new SshClient()) {}
public buildArgs(request: IProxyRequest): string[] {
this.validateLocalForward(request.localForward);
return this.sshClient.buildSshArgs(request.host, {
localForwards: [request.localForward],
extraArgs: ['-N'],
exitOnForwardFailure: true,
});
}
public async start(request: IProxyRequest): Promise<number> {
return this.sshClient.spawnInteractive('ssh', this.buildArgs(request));
}
private validateLocalForward(localForward: string): void {
const parts = localForward.split(':');
if (parts.length < 3) {
throw new Error('Local forward must look like <localPort>:<remoteHost>:<remotePort>');
}
}
}
+232
View File
@@ -0,0 +1,232 @@
import * as plugins from './plugins.js';
import { MountManager } from './classes.mountmanager.js';
import { SshClient } from './classes.sshclient.js';
export interface ISessionBridgeOptions {
host: string;
cwd?: string;
mountManager?: MountManager;
}
export class SessionBridge {
public readonly host: string;
public readonly cwd: string;
public readonly token: string;
public readonly remotePort: number;
private server?: plugins.Server;
private localPort?: number;
private mountManager: MountManager;
constructor(options: ISessionBridgeOptions) {
this.host = options.host;
this.cwd = options.cwd ?? process.cwd();
this.token = plugins.crypto.randomBytes(32).toString('hex');
this.remotePort = plugins.crypto.randomInt(45000, 55000);
this.mountManager = options.mountManager ?? new MountManager();
}
public async start(): Promise<void> {
if (this.server) {
return;
}
this.server = plugins.http.createServer((request, response) => {
void this.handleRequest(request, response);
});
await new Promise<void>((resolve, reject) => {
this.server?.once('error', reject);
this.server?.listen(0, '127.0.0.1', () => {
const address = this.server?.address();
if (!address || typeof address === 'string') {
reject(new Error('Could not determine DAP session bridge port'));
return;
}
this.localPort = address.port;
resolve();
});
});
}
public async stop(): Promise<void> {
if (!this.server) {
return;
}
const serverToClose = this.server;
this.server = undefined;
await new Promise<void>((resolve, reject) => {
serverToClose.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
public getReverseForwardSpec(): string {
if (!this.localPort) {
throw new Error('Session bridge has not been started yet');
}
return `127.0.0.1:${this.remotePort}:127.0.0.1:${this.localPort}`;
}
public buildRemoteBootstrapCommand(): string {
const script = this.buildRemoteBootstrapScript();
const exports = [
`DAP_SESSION_TOKEN=${SshClient.quoteForSh(this.token)}`,
`DAP_SESSION_PORT=${SshClient.quoteForSh(String(this.remotePort))}`,
`DAP_SESSION_HOST=${SshClient.quoteForSh(this.host)}`,
].join(' ');
return `${exports} sh -lc ${SshClient.quoteForSh(script)}`;
}
private buildRemoteBootstrapScript(): string {
return `
tmpdir="$(mktemp -d "\${TMPDIR:-/tmp}/dap-session.XXXXXX")" || exit 1
cleanup() {
rm -rf "$tmpdir"
}
trap cleanup EXIT INT HUP TERM
cat > "$tmpdir/dap" <<'DAP_SHIM'
#!/bin/sh
set -u
command_name="\${1:-help}"
print_help() {
cat <<'DAP_HELP'
dap remote session commands:
dap info
dap mount <remotePath> <localPath>
The remote dap command exists only inside this SSH session.
DAP_HELP
}
case "$command_name" in
info|session)
echo "dap session host: \${DAP_SESSION_HOST:-unknown}"
echo "dap bridge: 127.0.0.1:\${DAP_SESSION_PORT:-unknown}"
;;
mount)
remote_path="\${2:-$PWD}"
local_path="\${3:-}"
if [ -z "\${DAP_SESSION_PORT:-}" ] || [ -z "\${DAP_SESSION_TOKEN:-}" ]; then
echo "dap session bridge is not available" >&2
exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
echo "curl is required on the remote host for bridged dap commands" >&2
exit 1
fi
{
printf '%s\n' "$remote_path"
printf '%s\n' "$local_path"
} | curl -fsS -X POST \
-H "Authorization: Bearer $DAP_SESSION_TOKEN" \
--data-binary @- \
"http://127.0.0.1:$DAP_SESSION_PORT/mount"
;;
help|--help|-h)
print_help
;;
*)
echo "Unknown remote dap command: $command_name" >&2
print_help >&2
exit 1
;;
esac
DAP_SHIM
chmod +x "$tmpdir/dap"
export PATH="$tmpdir:$PATH"
export DAP_SESSION_TOKEN DAP_SESSION_PORT DAP_SESSION_HOST
echo "dap session bridge active. Try: dap info"
"\${SHELL:-/bin/sh}" -l
status=$?
exit "$status"
`;
}
private async handleRequest(
request: plugins.IncomingMessage,
response: plugins.ServerResponse
): Promise<void> {
if (!this.isAuthorized(request)) {
this.writeResponse(response, 401, 'unauthorized\n');
return;
}
if (request.method === 'GET' && request.url === '/info') {
this.writeJson(response, 200, {
host: this.host,
remotePort: this.remotePort,
});
return;
}
if (request.method === 'POST' && request.url === '/mount') {
await this.handleMountRequest(request, response);
return;
}
this.writeResponse(response, 404, 'not found\n');
}
private async handleMountRequest(
request: plugins.IncomingMessage,
response: plugins.ServerResponse
): Promise<void> {
try {
const body = await this.readRequestBody(request);
const [remotePathLine, localPathLine] = body.split('\n');
const remotePath = remotePathLine?.trim() || '.';
const localPath = localPathLine?.trim() || this.defaultLocalMountPath(remotePath);
const exitCode = await this.mountManager.mountDetached({
host: this.host,
remotePath,
localPath,
});
if (exitCode === 0) {
this.writeResponse(response, 200, `mounted ${this.host}:${remotePath} at ${localPath}\n`);
return;
}
this.writeResponse(response, 500, `mount command exited with ${exitCode}\n`);
} catch (error) {
this.writeResponse(response, 500, `${(error as Error).message}\n`);
}
}
private defaultLocalMountPath(remotePath: string): string {
const cleanHost = this.host.replace(/[^a-zA-Z0-9._-]/g, '_');
const baseName = plugins.path.basename(remotePath === '.' ? this.host : remotePath) || 'root';
return plugins.path.resolve(this.cwd, 'dap-mounts', cleanHost, baseName);
}
private isAuthorized(request: plugins.IncomingMessage): boolean {
const authorizationHeader = request.headers.authorization;
return authorizationHeader === `Bearer ${this.token}`;
}
private async readRequestBody(request: plugins.IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
let totalLength = 0;
for await (const chunk of request) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
totalLength += buffer.byteLength;
if (totalLength > 1024 * 1024) {
throw new Error('Request body is too large');
}
chunks.push(buffer);
}
return Buffer.concat(chunks).toString('utf8');
}
private writeJson(response: plugins.ServerResponse, statusCode: number, data: unknown): void {
response.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
response.end(`${JSON.stringify(data)}\n`);
}
private writeResponse(response: plugins.ServerResponse, statusCode: number, body: string): void {
response.writeHead(statusCode, { 'content-type': 'text/plain; charset=utf-8' });
response.end(body);
}
}
+97
View File
@@ -0,0 +1,97 @@
import * as plugins from './plugins.js';
import type { ICommandResult } from './types.js';
export interface ISshRunOptions {
extraArgs?: string[];
localForwards?: string[];
reverseForwards?: string[];
remoteCommand?: string;
exitOnForwardFailure?: boolean;
}
export class SshClient {
public buildSshArgs(host: string, options: ISshRunOptions = {}): string[] {
const args: string[] = [];
if (options.exitOnForwardFailure || options.localForwards?.length || options.reverseForwards?.length) {
args.push('-o', 'ExitOnForwardFailure=yes');
}
for (const localForward of options.localForwards ?? []) {
args.push('-L', localForward);
}
for (const reverseForward of options.reverseForwards ?? []) {
args.push('-R', reverseForward);
}
args.push(...(options.extraArgs ?? []));
args.push(host);
if (options.remoteCommand) {
args.push(options.remoteCommand);
}
return args;
}
public async ssh(host: string, options: ISshRunOptions = {}): Promise<number> {
return this.spawnInteractive('ssh', this.buildSshArgs(host, options));
}
public async spawnInteractive(command: string, args: string[], options: plugins.SpawnOptions = {}): Promise<number> {
return new Promise((resolve, reject) => {
const child = plugins.childProcess.spawn(command, args, {
stdio: 'inherit',
shell: false,
...options,
});
child.once('error', reject);
child.once('exit', (code, signal) => {
if (signal) {
resolve(128);
return;
}
resolve(code ?? 0);
});
});
}
public async spawnDetached(command: string, args: string[], options: plugins.SpawnOptions = {}): Promise<number> {
const child = plugins.childProcess.spawn(command, args, {
stdio: 'ignore',
detached: true,
shell: false,
...options,
});
child.unref();
return child.pid ?? 0;
}
public async runCapture(command: string, args: string[], options: plugins.SpawnOptions = {}): Promise<ICommandResult> {
return new Promise((resolve, reject) => {
const child = plugins.childProcess.spawn(command, args, {
stdio: ['ignore', 'pipe', 'pipe'],
shell: false,
...options,
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
child.stdout?.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk)));
child.stderr?.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk)));
child.once('error', reject);
child.once('exit', (code) => {
resolve({
exitCode: code ?? 0,
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
stderr: Buffer.concat(stderrChunks).toString('utf8'),
});
});
});
}
public async commandExists(command: string): Promise<boolean> {
const result = await this.runCapture('/bin/sh', ['-lc', `command -v ${SshClient.quoteForSh(command)}`]);
return result.exitCode === 0;
}
public static quoteForSh(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
}
+305
View File
@@ -0,0 +1,305 @@
import * as plugins from './plugins.js';
import type {
ISshConfigFile,
ISshConfigHost,
ISshConfigOptions,
ISshConfigReadResult,
} from './types.js';
export class SshConfig {
public readonly homeDir: string;
public readonly mainConfigPath: string;
constructor(options: ISshConfigOptions = {}) {
this.homeDir = options.homeDir ?? plugins.os.homedir();
this.mainConfigPath = this.expandHome(options.mainConfigPath ?? plugins.path.join(this.homeDir, '.ssh', 'config'));
}
public async read(): Promise<ISshConfigReadResult> {
const files: ISshConfigFile[] = [];
const hosts: ISshConfigHost[] = [];
const visitedFiles = new Set<string>();
await this.readFileRecursive(this.mainConfigPath, visitedFiles, files, hosts, true);
return {
mainConfigPath: this.mainConfigPath,
files,
hosts,
};
}
public async getHost(alias: string): Promise<ISshConfigHost | undefined> {
const result = await this.read();
return result.hosts.find((host) => host.patterns.includes(alias));
}
public getDisplayHosts(result: ISshConfigReadResult): ISshConfigHost[] {
return result.hosts.filter((host) => host.patterns.some((pattern) => !this.patternHasWildcard(pattern)));
}
public getFirstOption(host: ISshConfigHost, optionName: string): string | undefined {
return host.options[optionName.toLowerCase()]?.[0];
}
public expandHome(filePath: string): string {
if (filePath === '~') {
return this.homeDir;
}
if (filePath.startsWith('~/')) {
return plugins.path.join(this.homeDir, filePath.slice(2));
}
return filePath;
}
private async readFileRecursive(
filePath: string,
visitedFiles: Set<string>,
files: ISshConfigFile[],
hosts: ISshConfigHost[],
required: boolean
): Promise<void> {
const resolvedPath = plugins.path.resolve(this.expandHome(filePath));
if (visitedFiles.has(resolvedPath)) {
return;
}
visitedFiles.add(resolvedPath);
let content = '';
try {
content = await plugins.fs.readFile(resolvedPath, 'utf8');
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === 'ENOENT' && !required) {
return;
}
if (nodeError.code === 'ENOENT' && required) {
files.push({ filePath: resolvedPath, lines: [] });
return;
}
throw error;
}
const lines = content.split(/\r?\n/);
files.push({ filePath: resolvedPath, lines });
const includePatterns: string[] = [];
this.parseHostsInFile(resolvedPath, lines, hosts, includePatterns);
for (const includePattern of includePatterns) {
const includeFiles = await this.expandIncludePattern(includePattern, resolvedPath);
for (const includeFile of includeFiles) {
await this.readFileRecursive(includeFile, visitedFiles, files, hosts, false);
}
}
}
private parseHostsInFile(
filePath: string,
lines: string[],
hosts: ISshConfigHost[],
includePatterns: string[]
): void {
let currentHost: ISshConfigHost | undefined;
const closeCurrentHost = (endLine: number) => {
if (!currentHost) {
return;
}
currentHost.endLine = endLine;
currentHost.rawLines = lines.slice(currentHost.startLine - 1, endLine);
hosts.push(currentHost);
currentHost = undefined;
};
lines.forEach((rawLine, index) => {
const lineNumber = index + 1;
const withoutComment = this.stripInlineComment(rawLine).trim();
if (!withoutComment) {
return;
}
const directiveMatch = withoutComment.match(/^([^\s]+)\s+(.*)$/);
if (!directiveMatch) {
return;
}
const directive = directiveMatch[1].toLowerCase();
const value = directiveMatch[2].trim();
if (directive === 'include') {
includePatterns.push(...this.shellSplit(value));
return;
}
if (directive === 'host' || directive === 'match') {
closeCurrentHost(lineNumber - 1);
}
if (directive === 'host') {
const previousLine = lines[index - 1] ?? '';
const dapBeginMatch = previousLine.match(/^\s*#\s*dap:begin\s+(.+)\s*$/i);
currentHost = {
patterns: this.shellSplit(value),
filePath,
startLine: lineNumber,
endLine: lineNumber,
options: {},
rawLines: [],
dapManaged: Boolean(dapBeginMatch),
dapManagedName: dapBeginMatch?.[1]?.trim(),
};
return;
}
if (currentHost) {
const normalizedDirective = directive.toLowerCase();
currentHost.options[normalizedDirective] = currentHost.options[normalizedDirective] ?? [];
currentHost.options[normalizedDirective].push(value);
}
});
closeCurrentHost(lines.length);
}
private stripInlineComment(line: string): string {
let quote: string | undefined;
let escaped = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (escaped) {
escaped = false;
continue;
}
if (char === '\\') {
escaped = true;
continue;
}
if ((char === '"' || char === "'") && !quote) {
quote = char;
continue;
}
if (char === quote) {
quote = undefined;
continue;
}
if (char === '#' && !quote && (i === 0 || /\s/.test(line[i - 1]))) {
return line.slice(0, i);
}
}
return line;
}
public shellSplit(input: string): string[] {
const tokens: string[] = [];
let current = '';
let quote: string | undefined;
let escaped = false;
for (const char of input) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === '\\') {
escaped = true;
continue;
}
if ((char === '"' || char === "'") && !quote) {
quote = char;
continue;
}
if (char === quote) {
quote = undefined;
continue;
}
if (/\s/.test(char) && !quote) {
if (current) {
tokens.push(current);
current = '';
}
continue;
}
current += char;
}
if (current) {
tokens.push(current);
}
return tokens;
}
private async expandIncludePattern(pattern: string, includingFilePath: string): Promise<string[]> {
const expandedPattern = this.expandHome(pattern);
const absolutePattern = plugins.path.isAbsolute(expandedPattern)
? expandedPattern
: plugins.path.join(plugins.path.dirname(includingFilePath), expandedPattern);
return this.expandGlob(absolutePattern);
}
private async expandGlob(pattern: string): Promise<string[]> {
const absolutePattern = plugins.path.resolve(pattern);
if (!this.pathHasWildcard(absolutePattern)) {
return plugins.fsSync.existsSync(absolutePattern) ? [absolutePattern] : [];
}
const parsedPath = plugins.path.parse(absolutePattern);
const segments = absolutePattern.slice(parsedPath.root.length).split(plugins.path.sep).filter(Boolean);
const matches: string[] = [];
const walk = async (currentPath: string, segmentIndex: number): Promise<void> => {
if (segmentIndex >= segments.length) {
try {
const stat = await plugins.fs.stat(currentPath);
if (stat.isFile()) {
matches.push(currentPath);
}
} catch {
return;
}
return;
}
const segment = segments[segmentIndex];
if (!this.pathHasWildcard(segment)) {
await walk(plugins.path.join(currentPath, segment), segmentIndex + 1);
return;
}
let entries: plugins.fsSync.Dirent[];
try {
entries = await plugins.fs.readdir(currentPath, { withFileTypes: true });
} catch {
return;
}
const matcher = this.globSegmentToRegExp(segment);
for (const entry of entries) {
if (!matcher.test(entry.name)) {
continue;
}
const nextPath = plugins.path.join(currentPath, entry.name);
if (segmentIndex === segments.length - 1 || entry.isDirectory()) {
await walk(nextPath, segmentIndex + 1);
}
}
};
await walk(parsedPath.root, 0);
return matches.sort();
}
private globSegmentToRegExp(segment: string): RegExp {
const source = segment
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '[^/]*')
.replace(/\?/g, '[^/]');
return new RegExp(`^${source}$`);
}
private patternHasWildcard(pattern: string): boolean {
return /[*?]/.test(pattern);
}
private pathHasWildcard(filePath: string): boolean {
return /[*?]/.test(filePath);
}
}
+293
View File
@@ -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, '\\$&');
}
}
+17
View File
@@ -0,0 +1,17 @@
import { DapCli } from './classes.dapcli.js';
export { DapCli } from './classes.dapcli.js';
export { HostManager } from './classes.hostmanager.js';
export { MountManager } from './classes.mountmanager.js';
export { PortProxy } from './classes.portproxy.js';
export { SessionBridge } from './classes.sessionbridge.js';
export { SshClient } from './classes.sshclient.js';
export { SshConfig } from './classes.sshconfig.js';
export { SshConfigWriter } from './classes.sshconfigwriter.js';
export type * from './types.js';
export const runCli = async (): Promise<void> => {
const cli = new DapCli();
const exitCode = await cli.run();
process.exitCode = exitCode;
};
+15
View File
@@ -0,0 +1,15 @@
// node native scope
import * as childProcess from 'node:child_process';
import * as crypto from 'node:crypto';
import * as fs from 'node:fs/promises';
import * as fsSync from 'node:fs';
import * as http from 'node:http';
import * as os from 'node:os';
import * as path from 'node:path';
import * as readline from 'node:readline/promises';
import * as stream from 'node:stream';
export { childProcess, crypto, fs, fsSync, http, os, path, readline, stream };
export type { ChildProcess, SpawnOptions } from 'node:child_process';
export type { IncomingMessage, Server, ServerResponse } from 'node:http';
+83
View File
@@ -0,0 +1,83 @@
export type TDapMountBackend = 'sshfs' | 'rclone';
export interface ISshConfigOptions {
homeDir?: string;
mainConfigPath?: string;
}
export interface ISshConfigFile {
filePath: string;
lines: string[];
}
export interface ISshConfigHost {
patterns: string[];
filePath: string;
startLine: number;
endLine: number;
options: Record<string, string[]>;
rawLines: string[];
dapManaged: boolean;
dapManagedName?: string;
}
export interface ISshConfigReadResult {
mainConfigPath: string;
files: ISshConfigFile[];
hosts: ISshConfigHost[];
}
export interface IHostDefinition {
alias: string;
hostName?: string;
user?: string;
port?: string;
identityFile?: string;
proxyJump?: string;
localForwards?: string[];
remoteForwards?: string[];
}
export interface IHostWriteResult {
changed: boolean;
backupPath?: string;
filePath: string;
}
export interface IHostUpdatePreview {
filePath: string;
before: string;
after: string;
diff: string;
dapManaged: boolean;
}
export interface ICommandResult {
exitCode: number;
stdout: string;
stderr: string;
}
export interface IMountRequest {
host: string;
remotePath: string;
localPath: string;
backend?: TDapMountBackend;
}
export interface IProxyRequest {
host: string;
localForward: string;
}
export interface IDoctorCheck {
name: string;
ok: boolean;
detail: string;
}
export interface IParsedArgs {
positional: string[];
flags: Record<string, string | boolean | Array<string | boolean>>;
passthrough: string[];
}