UNPKG

firmament-yargs

Version:

Typescript classes for building CLI node applications

429 lines (409 loc) 16.5 kB
import {injectable, inject} from 'inversify'; import {FailureRetVal, Positive, SafeJson, Spawn} from '..'; import {ChildProcess} from 'child_process'; import {SpawnOptions2} from '../custom-typings'; import {ForceErrorImpl} from './force-error-impl'; import {CommandUtil} from '..'; import {ChildProcessSpawn} from '../interfaces/child-process-spawn'; import * as async from 'async'; import * as fs from 'fs'; //import * as path from 'path'; const readlineSync = require('readline-sync'); const inpathSync = require('inpath').sync; const psTree = require('ps-tree'); //noinspection JSUnusedGlobalSymbols @injectable() export class SpawnImpl extends ForceErrorImpl implements Spawn { private cachedPassword: string; constructor(@inject('CommandUtil') public commandUtil: CommandUtil, @inject('SafeJson') private safeJson: SafeJson, @inject('Positive') private positive: Positive, @inject('ChildProcessSpawn') public childProcessSpawn: ChildProcessSpawn) { super(); } installAptitudePackages(packageNames: string[], cb: (err: Error, result: string) => void): void; installAptitudePackages(packageNames: string[], withInteractiveConfirm: ((err: Error, result: string) => void)|boolean, cb?: (err: Error, result: string) => void): void { const me = this; if(me.positive.areYouSure( `Looks like 'sshpass' is not installed. Want me to try to install it (using apt-get)? [Y/n]`, 'Operation canceled.', true, FailureRetVal.TRUE)) { } me.sudoSpawnAsync( [ 'apt-get', 'install', '-y', 'sshpass' ], { suppressDiagnostics: true, suppressStdOut: false, suppressStdErr: false, cacheStdOut: false, cacheStdErr: false, sudoPassword: 'password', suppressResult: true, }, () => { }, (err: Error, result: string) => { cb(err, err ? err.message : `'sshpass' installed. Try operation again.`); }); } removeAptitudePackages(packageNames: string[], cb: (err: Error, result: string) => void): void; removeAptitudePackages(packageNames: string[], withInteractiveConfirm: ((err: Error, result: string) => void)|boolean, cb?: (err: Error, result: string) => void): void { } private validate_spawnShellCommandAsync_args(cmd: string[], options: SpawnOptions2, cbStatus: (err: Error, result: string) => void, cbFinal: (err: Error, result: string) => void, cbDiagnostic: (message: string) => void = null) { const me = this; cmd = cmd || []; cmd = cmd.slice(0); options = options || {}; options.preSpawnMessage = options.preSpawnMessage || ''; options.postSpawnMessage = options.postSpawnMessage || ''; options.sudoUser = options.sudoUser || ''; options.sudoPassword = options.sudoPassword || ''; options.stdio = options.stdio || 'pipe'; options.cwd = options.cwd || process.cwd(); cbStatus = me.checkCallback(cbStatus); cbFinal = me.checkCallback(cbFinal); cbDiagnostic = cbDiagnostic || (() => { }); return {cmd, options, cbStatus, cbFinal, cbDiagnostic}; } spawnShellCommandAsync(_cmd: string[], _options: SpawnOptions2, _cbStatus: (err: Error, result: string) => void, _cbFinal: (err: Error, result: string) => void, _cbDiagnostic: (message: string) => void = null) { const me = this; let {cmd, options, cbStatus, cbFinal, cbDiagnostic} = me.validate_spawnShellCommandAsync_args(_cmd, _options, _cbStatus, _cbFinal, _cbDiagnostic); if(me.checkForceError('spawnShellCommandAsync', cbFinal)) { return null; } if(!options.remoteHost && !options.remoteUser && !(options.remotePassword || options.remoteSshKeyPath)) { //Execute cmd locally return me._spawnShellCommandAsync(cmd, options, cbStatus, cbFinal, cbDiagnostic); } const msgBase = 'Not enough params for remote call:\n\n'; let msg = msgBase; if(!options.remoteHost) { msg += `* 'remoteHost' must be set to the remote server hostname\n`; } if(!options.remoteUser) { msg += `* 'remoteUser' must be set to the user on the remote host to execute the call as\n`; } if(!(options.remotePassword || options.remoteSshKeyPath)) { msg += `* either 'remotePassword' or 'remoteSshKeyPath' (or both, SshKey wins) must be specified`; } if(msg.length != msgBase.length) { cbFinal(new Error(msg), msg); return null; } options.remotePassword = options.remoteSshKeyPath ? undefined : options.remotePassword; //Construct calls to remote host const tmp = require('tmp'); tmp.file({discardDescriptor: true}, (err: Error, tmpSrcPath) => { if(err) { return cbFinal(err, 'Failed to create temporary file'); } const {remoteHost, remoteUser, remotePassword, remoteSshKeyPath, remoteSshPort} = options; const subShellOptions = { suppressDiagnostics: true, suppressStdOut: true, suppressStdErr: true, cacheStdOut: true, cacheStdErr: true, suppressResult: false, }; const envBashCmd = [ '/usr/bin/env', 'bash', '-c' ]; const sshpassCmd = [ 'sshpass', '-p', remotePassword ]; const sshKeyPathOptions = [ '-i', `${remoteSshKeyPath}` ]; const sshOptions = [ '-q', '-o', 'StrictHostKeyChecking=no' ]; const scpCmd = [ 'scp', ...sshOptions ]; const sshCmd = [ 'ssh', ...sshOptions ]; if(remoteSshPort) { scpCmd.push('-P', `${remoteSshPort}`); sshCmd.push('-p', `${remoteSshPort}`); } const finalScpCmd = remoteSshKeyPath ? scpCmd.concat(sshKeyPathOptions) : sshpassCmd.concat(scpCmd); const finalSshCmd = remoteSshKeyPath ? sshCmd.concat(sshKeyPathOptions) : sshpassCmd.concat(sshCmd); async.waterfall([ (cb) => { const tmpDstPath = `${tmp.tmpNameSync()}.tmp`; //Construct file to be executed on remote host const writeStream = fs.createWriteStream(tmpSrcPath); writeStream.on('close', () => { cb(null, tmpDstPath); }); const cmdString = cmd.join(' ') + `\nrm ${tmpDstPath}`; writeStream.write(cmdString, () => { writeStream.close(); }); }, (tmpDstPath, cb) => { //Copy file to be executed to remote host using 'scp' const cmd = finalScpCmd.concat([ tmpSrcPath, `${remoteUser}@${remoteHost}:${tmpDstPath}` ]); me._spawnShellCommandAsync( cmd, subShellOptions, () => { }, (err, result) => { cb(null, tmpDstPath, result); }); }, (tmpDstPath: string, result: string, cb) => { //If 'scp' (above) succeeded we feel pretty good about doing a remote 'ssh' call //const cmd = finalSshCmd.concat(`${remoteUser}@${remoteHost} "echo ${remotePassword} | sudo -S /usr/bin/env bash ${tmpDstPath}"`); const remoteScriptCmd = `/usr/bin/env bash ${tmpDstPath}`; const executeRemoteScriptCmd = remoteSshKeyPath ? remoteScriptCmd : `echo ${remotePassword} | sudo -S ${remoteScriptCmd}`; const cmd = finalSshCmd.concat(`${remoteUser}@${remoteHost} "${executeRemoteScriptCmd}"`); const _cmd = envBashCmd.concat(cmd.join(' ')); me._spawnShellCommandAsync( _cmd, subShellOptions, (err: Error, result: string) => { me.commandUtil.log(result); }, cb); } ], (outerErr: Error, result: any) => { if(outerErr) { me.safeJson.safeParse(outerErr.message, (err: Error, obj: any) => { try { switch(typeof obj.code) { case('object'): switch(obj.code.code) { case('ENOENT'): //TODO: Finish 'installAptitudePackages' method above to install sshpass me.commandUtil.processExitWithError(new Error(`Need to install 'sshpass': sudo apt-get install -y sshpass`)); break; default: return cbFinal(outerErr, outerErr.message); } break; case('number'): return cbFinal(outerErr, obj.stderrText); default: return cbFinal(outerErr, outerErr.message); } } catch(err) { cbFinal(err, `Original Error: ${outerErr.message}`); } }); } else { cbFinal(null, result); } }); }); } private _spawnShellCommandAsync(cmd: string[], options: SpawnOptions2, cbStatus: (err: Error, result: string) => void, cbFinal: (err: Error, result: string) => void, cbDiagnostic: (message: string) => void = null) { const me = this; let childProcess: ChildProcess; try { if(options.forceNullChildProcess) { // noinspection ExceptionCaughtLocallyJS throw new Error('error: forceNullChildProcess'); } let command = cmd[0]; let args = cmd.slice(1); let stdoutText = ''; let stderrText = ''; if(!options.suppressDiagnostics) { cbDiagnostic(`Running '${cmd}' @ '${options.cwd}'`); options.preSpawnMessage && cbDiagnostic(options.preSpawnMessage); } childProcess = me.childProcessSpawn.spawn(command, args, options); childProcess.stderr.on('data', (dataChunk: Uint8Array) => { if(options.suppressStdErr && !options.cacheStdErr) { return; } const text = dataChunk.toString(); !options.suppressStdErr && cbStatus(new Error(text), text); options.cacheStdErr && (stderrText += text); }); childProcess.stdout.on('data', (dataChunk: Uint8Array) => { if(options.suppressStdOut && !options.cacheStdOut) { return; } const text = dataChunk.toString(); !options.suppressStdOut && cbStatus(null, text); options.cacheStdOut && (stdoutText += text); }); childProcess.on('error', (code: number, signal: string) => { //console.error('error'); cbFinal = SpawnImpl.childCloseOrExit(code || 10, signal || '', stdoutText, stderrText, options, cbFinal, cbDiagnostic); }); //!!! //!!!BEWARE: Responding to the 'exit' event on a childProcess we the source of a *very* subtle bug having to do with the stdout pipe //!!!not being flushed when the our caller was called back. If you want the results of the call in stdout listen only to the 'close' //!!!to determine when the child process is finished //!!! childProcess.on('exit', (code: number, signal: string) => { //console.error('exit'); //We prefer to call cbFinal() from the 'close' event but sometimes (like when spawning 'sudo') you just never get the 'close' event. //Here we wait a second setTimeout(() => { cbFinal = SpawnImpl.childCloseOrExit(code, signal || '', stdoutText, stderrText, options, cbFinal, cbDiagnostic); }, 1000); }); childProcess.on('close', (code: number, signal: string) => { //console.error('close'); cbFinal = SpawnImpl.childCloseOrExit(code, signal || '', stdoutText, stderrText, options, cbFinal, cbDiagnostic); }); } catch(err) { cbFinal && cbFinal(err, null); } return childProcess; } private static childCloseOrExit(code: number, signal: string, stdoutText: string, stderrText: string, options: SpawnOptions2, cbFinal: (err: Error, result: string) => void, cbDiagnostic: (message: string) => void): (err: Error, result: string) => void { if(cbFinal) { !options.suppressDiagnostics && options.postSpawnMessage && cbDiagnostic(options.postSpawnMessage); const returnString = JSON.stringify({code, signal, stdoutText, stderrText}, undefined, 2); const error = (code !== null && code !== 0) ? new Error(returnString) : null; cbFinal(options.suppressFinalError ? null : error, options.suppressResult ? '' : returnString); } return null; } sudoSpawnAsync(_cmd: string[], _options: SpawnOptions2, _cbStatus: (err: Error, result: string) => void, _cbFinal: (err: Error, result: string) => void, _cbDiagnostic: (message: string) => void = null) { const me = this; let {cmd, options, cbStatus, cbFinal, cbDiagnostic} = me.validate_spawnShellCommandAsync_args(_cmd, _options, _cbStatus, _cbFinal, _cbDiagnostic); if(me.checkForceError('sudoSpawnAsync', cbFinal)) { return; } const prompt = '#login-prompt#'; const args = ['-S', '-p', prompt]; if(options.sudoUser) { args.unshift(`--user=${options.sudoUser}`); } [].push.apply(args, cmd); const path = process.env['PATH'].split(':'); const sudoBin = inpathSync('sudo', path); args.unshift(sudoBin); const childProcess: ChildProcess = me._spawnShellCommandAsync( args, options, (err, result) => { //sudo asks for password on stderr so if the prompt is on one of the lines don't call cbStatus //(caller doesn't care about feedback from sudo, only the program being run under sudo) if(err) { try { const lines = result.toString().trim().split('\n'); for(let i = 0; i < lines.length; ++i) { if(lines[i] === prompt) { return; } } } catch(err) { } } cbStatus(err, result); }, (err, result) => { if(result) { const regex = new RegExp(prompt, 'g'); result = result.replace(regex, ''); } cbFinal(err, result); }, cbDiagnostic); if(!childProcess) { //In this case spawnShellCommandAsync should handle the error callbacks return; } function waitForStartup(err, children: any[]) { if(err) { throw new Error(`Error spawning process`); } if(children && children.length) { childProcess.stderr.removeAllListeners(); } else { setTimeout(function() { psTree(childProcess.pid, waitForStartup); }, 100); } } psTree(childProcess.pid, waitForStartup); let prompts = 0; childProcess.stderr.on('data', function(data) { const lines = data.toString().trim().split('\n'); lines.forEach(function(line) { if(line === prompt) { if(++prompts > 1) { // The previous entry must have been incorrect, since sudo asks again. me.cachedPassword = null; } const username = require('username').sync(); if(!me.cachedPassword) { if(options.sudoPassword) { me.cachedPassword = options.sudoPassword; } else { try { //Try block needed for inappropriate ioctl (usually unit testing or other non-tty invocation) const loginMessage = (prompts > 1) ? `Sorry, try again.\n[sudo] password for ${username}: ` : `[sudo] password for ${username}: `; me.cachedPassword = readlineSync.question(loginMessage, {hideEchoBack: true}); } catch(err) { childProcess.kill(); return; } } } childProcess.stdin.write(me.cachedPassword + '\n'); } }); }); return childProcess; } }