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 { 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 = {}; 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 { 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 (::): '); await this.runProxy([alias.trim()], { positional: [], flags: { local: localForward.trim() }, passthrough: [], }); } if (choice === '6') { const remoteSpec = await rl.question('Remote spec (:): '); 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 { 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 { 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 { const alias = positionals[0]; if (!alias) { throw new Error('Usage: dap edit [--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 { const host = positionals[0]; if (!host) { throw new Error('Usage: dap ssh [--no-bridge] [-- ]'); } 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 { const host = positionals[0]; const localForward = this.getStringFlag(parsedArgs.flags, 'local') ?? positionals[1]; if (!host || !localForward) { throw new Error('Usage: dap proxy --local ::'); } return this.portProxy.start({ host, localForward }); } private async runMount(positionals: string[], parsedArgs: IParsedArgs): Promise { const remoteSpec = positionals[0]; const localPath = positionals[1]; if (!remoteSpec || !localPath) { throw new Error('Usage: dap mount : [--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 { const localPath = positionals[0]; if (!localPath) { throw new Error('Usage: dap unmount '); } return this.mountManager.unmount(localPath); } private async runDoctor(): Promise { 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> ): Promise { 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> { 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> ): 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 { const suffix = defaultValue ? ` [${defaultValue}]` : ''; const answer = (await rl.question(`${label}${suffix}: `)).trim(); return answer || defaultValue; } private async confirm(question: string, defaultValue: boolean): Promise { 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 { 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 { 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 { 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>, 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>, 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>, flagName: string ): boolean { return flags[flagName] === true; } private addFlag( flags: Record>, 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 [--hostname value] [--user value] [--port value] [--identity-file path] [--proxy-jump host] [--local-forward spec] [--remote-forward spec] dap ssh [--no-bridge] [-- ] dap proxy --local :: dap mount : [--backend sshfs|rclone] dap unmount dap doctor `); } }