firmament-yargs
Version:
Typescript classes for building CLI node applications
429 lines (409 loc) • 16.5 kB
text/typescript
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
()
export class SpawnImpl extends ForceErrorImpl implements Spawn {
private cachedPassword: string;
constructor(('CommandUtil') public commandUtil: CommandUtil,
('SafeJson') private safeJson: SafeJson,
('Positive') private positive: Positive,
('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;
}
}