Files
dap/ts/classes.dapcli.ts

477 lines
17 KiB
TypeScript

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
`);
}
}