"use strict" ;
require ( "array.prototype.flat" ) . shim ( ) ;
const Promise = require ( "bluebird" ) ;
const util = require ( "util" ) ;
const execFileAsync = util . promisify ( require ( "child_process" ) . execFile ) ;
const debug = require ( "debug" ) ( "cvm:execBinary" ) ;
const asExpression = require ( "as-expression" ) ;
const splitFilterN = require ( "split-filter-n" ) ;
const { rethrowAs , chain } = require ( "error-chain" ) ;
const isPlainObj = require ( "is-plain-obj" ) ;
const concatArrays = require ( "concat-arrays" ) ;
const unreachable = require ( "@joepie91/unreachable" ) ( "cvm" ) ; // FIXME: Change on publish
const textParser = require ( "../text-parser" ) ;
const errors = require ( "./errors" ) ;
const OutputForbidden = Symbol ( "OutputForbidden" ) ;
/* FIXME: How to handle partial result parsing when an error is encountered in the parsing adapter? */
/* FIXME: Test that flag-dash prevention in arguments works */
// FIXME: Explicitly document that text parsers *should* allow for specifying arbitrary postprocessing JS in some way
function keyToFlagName ( key ) {
if ( key . startsWith ( "!" ) ) {
return key . slice ( 1 ) ;
} else if ( key . length === 1 ) {
return ` - ${ key } ` ;
} else {
return ` -- ${ key } ` ;
}
}
function flagValueToArgs ( key , value ) {
if ( value === true ) {
return [ key ] ;
} else if ( Array . isArray ( value ) ) {
return value . map ( ( item ) => {
return flagValueToArgs ( key , item ) ;
} ) . flat ( ) ;
} else {
return [ key , value ] ;
}
}
function flagsToArgs ( flags ) {
return Object . keys ( flags ) . map ( ( key ) => {
let value = flags [ key ] ;
let flagName = keyToFlagName ( key ) ;
return flagValueToArgs ( flagName , value ) ;
} ) . flat ( ) ;
}
function validateArguments ( args ) {
if ( args . some ( ( arg ) => arg == null ) ) {
throw new Error ( "One or more arguments were undefined or null; this is probably a mistake in how you're calling the command" ) ;
} else if ( args . some ( ( arg ) => arg [ 0 ] === "-" ) ) {
throw new Error ( "For security reasons, command arguments cannot start with a dash; use the 'withFlags' method if you want to specify flags" ) ;
}
}
function testExitCode ( exitCode , allowedExitCodes ) {
if ( allowedExitCodes === null ) {
// NOTE: The `===` here is intentional; *only* a null is considered to mean "any exit code allowed", so that when an `undefined` gets passed in accidentally, it doesn't silently do the wrong thing.
return true ;
} else {
return allowedExitCodes . includes ( exitCode ) ;
}
}
function tryExpectation ( expectation , channels ) {
let channelName = expectation . channel ;
let channel = channels [ channelName ] ;
let channelAsString = channel . toString ( ) ;
if ( channel != null ) {
if ( expectation . adapter === OutputForbidden && channelAsString . length > 0 ) {
throw new errors . UnexpectedOutput ( ` Encountered output on ' ${ channelName } ', but no output was supposed to be produced there ` , {
failedChannel : channelName
} ) ;
} else {
let result = asExpression ( ( ) => {
try {
return expectation . adapter . parse ( channelAsString ) ;
} catch ( error ) {
if ( error instanceof textParser . ParseError ) {
throw error ;
} else {
throw chain ( error , errors . OutputParsingFailed , ` An error occurred while parsing ' ${ channelName } ' ` , {
failedChannel : expectation . channel
} ) ;
}
}
} ) ;
if ( textParser . isErrorResult ( result ) ) {
result . throw ( ) ;
// } else if (result === undefined || isPlainObj(result)) { // NOTE: Currently broken, see https://github.com/sindresorhus/is-plain-obj/issues/11
} else if ( result === undefined || ( typeof result === "object" && ! Array . isArray ( result ) ) ) {
return result ;
} else {
throw new Error ( ` Output adapters may only return a plain object from their parse method (or nothing at all) ` ) ;
}
}
} else {
throw unreachable ( ` Encountered expectation for unexpected channel ' ${ channelName } ' ` ) ;
}
}
const NoResult = Symbol ( "NoResult" ) ;
function testExpectations ( expectations , channels ) {
return expectations
. map ( ( expectation ) => {
try {
return tryExpectation ( expectation , channels ) ;
} catch ( error ) {
if ( error instanceof textParser . ParseError ) {
if ( expectation . required !== true ) {
return NoResult ;
} else {
let channelName = expectation . channel ;
throw chain ( error , errors . ExpectedOutputMissing , ` A required parser failed to parse the output on ${ channelName } ` , {
failedChannel : channelName
} ) ;
}
} else {
throw error ;
}
}
} )
. filter ( ( result ) => result !== NoResult ) ;
}
// FIXME: Immutable-builder abstraction
// FIXME: validatem
// FIXME: Reconsider the exit code handling; should we always permit stderr parsing even if a non-zero exit code occurs?
module . exports = function createBinaryInvocation ( command , args = [ ] ) {
/* FIXME: The below disallows dashes in the args, but not in the command. Is that what we want? */
validateArguments ( args ) ;
return {
_settings : {
asRoot : false ,
expectations : [ ] ,
flags : { } ,
environment : { } ,
expectedExitCodes : [ 0 ] ,
resultRequired : false ,
resultMerger : function ( results ) {
return results . reduce ( ( merged , result ) => Object . assign ( merged , result ) , { } ) ;
}
} ,
_withSettings : function ( newSettings ) {
let newObject = Object . assign ( { } , this , {
_settings : Object . assign ( { } , this . _settings , newSettings )
} ) ;
return newObject ;
} ,
_withExpectation : function ( expectation ) {
return this . _withSettings ( {
expectations : this . _settings . expectations . concat ( [ expectation ] )
} ) ;
} ,
asRoot : function ( ) {
return this . _withSettings ( { asRoot : true } ) ;
} ,
withAllowedExitCodes : function ( allowedExitCodes ) {
return this . _withSettings ( { expectedExitCodes : allowedExitCodes } ) ;
} ,
withAnyExitCode : function ( ) {
return this . _withSettings ( { expectedExitCodes : null } ) ;
} ,
withFlags : function ( flags ) {
if ( flags != null ) {
return this . _withSettings ( {
flags : Object . assign ( { } , this . _settings . flags , flags )
} ) ;
} else {
return this ;
}
} ,
withEnvironment : function ( environment ) {
if ( environment != null ) {
return this . _withSettings ( {
environment : Object . assign ( { } , this . _settings . environment , environment )
} ) ;
} else {
return this ;
}
} ,
withModifier : function ( modifierFunction ) {
if ( modifierFunction != null ) {
return modifierFunction ( this ) ;
} else {
return this ;
}
} ,
expectOnStdout : function ( adapter ) {
return this . _withExpectation ( {
channel : "stdout" ,
adapter : adapter
} ) ;
} ,
requireOnStdout : function ( adapter ) {
return this . _withExpectation ( {
channel : "stdout" ,
adapter : adapter ,
required : true
} ) ;
} ,
// failOnStdout: function (adapter) {
// return this._withExpectation({
// channel: "stdout",
// adapter: adapter,
// disallowed: true
// });
// },
failOnAnyStdout : function ( ) {
return this . _withExpectation ( {
channel : "stdout" ,
adapter : OutputForbidden ,
disallowed : true
} ) ;
} ,
expectOnStderr : function ( adapter ) {
return this . _withExpectation ( {
channel : "stderr" ,
adapter : adapter
} ) ;
} ,
requireOnStderr : function ( adapter ) {
return this . _withExpectation ( {
channel : "stderr" ,
adapter : adapter ,
required : true
} ) ;
} ,
// failOnStderr: function (adapter) {
// return this._withExpectation({
// channel: "stderr",
// adapter: adapter,
// disallowed: true
// });
// },
failOnAnyStderr : function ( ) {
return this . _withExpectation ( {
channel : "stderr" ,
adapter : OutputForbidden ,
disallowed : true
} ) ;
} ,
requireResult : function ( ) {
// NOTE: This requires that *any* adapter produces a result, it doesn't matter which one.
// FIXME: Should this be inverted so that "requires result" is the default, and the user can opt out of that?
return this . _withSettings ( { requireResult : true } ) ;
} ,
then : function ( ) {
throw new Error ( "Attempted to use a command builder as a Promise; you probably forgot to call .execute" ) ;
} ,
execute : function ( ) {
return Promise . try ( ( ) => {
let effectiveCommand = command ;
let effectiveArgs = flagsToArgs ( this . _settings . flags ) . concat ( args ) ;
if ( this . _settings . asRoot ) {
effectiveCommand = "sudo" ;
effectiveArgs = [ command ] . concat ( effectiveArgs ) ;
}
// FIXME: Shouldn't we represent this in its original form, or at least an escaped form? And suffix 'Unsafe' to ensure it's not used in any actual execution code.
let effectiveCompleteCommand = [ effectiveCommand ] . concat ( effectiveArgs ) ;
return Promise . try ( ( ) => {
debug ( ` Running: ${ effectiveCommand } ${ effectiveArgs . map ( ( arg ) => ` " ${ arg } " ` ) . join ( " " ) } ` ) ;
return execFileAsync ( effectiveCommand , effectiveArgs , {
env : Object . assign ( { } , process . env , this . _settings . environment )
} ) ;
} ) . then ( ( { stdout , stderr } ) => {
return { stdout , stderr , exitCode : 0 } ;
} ) . catch ( ( error ) => {
let { stdout , stderr } = error ;
let exitCode = ( typeof error . code === "number" ) ? error . code : null ;
return { stdout , stderr , error , exitCode } ;
} ) . then ( ( { stdout , stderr , error , exitCode } ) => {
let { expectedExitCodes , expectations , resultMerger , resultRequired } = this . _settings ;
let expectationsByChannel = splitFilterN ( expectations , [ "stdout" , "stderr" ] , ( expectation ) => expectation . channel ) ;
let channels = { stdout , stderr } ;
// 1. process stderr expectations
// 2. throw on invalid exit code if there was no stderr match
// 3. only process stdout expectations if exit code was valid *and* there was no throw
try {
let hasValidExitCode = testExitCode ( exitCode , expectedExitCodes ) ;
let stderrResults = testExpectations ( expectationsByChannel . stderr , channels ) ;
// TODO: Add an option to validate the exit code *even* when there's stderr output
if ( stderrResults . length === 0 && ! hasValidExitCode ) {
// FIXME: Can we actually pass `error` to be chained onto here, when there's a case where `error` is undefined? Namely, when requiring a non-zero exit code, but the process exits with 0.
throw chain ( error , errors . NonZeroExitCode , ` Expected exit code to be one of ${ JSON . stringify ( expectedExitCodes ) } , but got ' ${ exitCode } ' ` , {
exitCode : exitCode ,
stdout : stdout ,
stderr : stderr
} ) ;
}
let stdoutResults = testExpectations ( expectationsByChannel . stdout , channels ) ;
let allResults = concatArrays ( stderrResults , stdoutResults ) ;
let mergedResults = asExpression ( ( ) => {
if ( allResults . length === 0 ) {
if ( ! resultRequired ) {
return { } ;
} else {
throw new errors . ExpectedOutputMissing ( ` At least one of the output parsers should have produced a result, but none of them did ` ) ;
}
} else if ( allResults . length === 1 ) {
return allResults [ 0 ] ;
} else {
// FIXME: Make merger explicitly configurable with a dedicated configuration method
return resultMerger ( allResults ) ;
}
} ) ;
return {
exitCode : exitCode ,
stdout : stdout ,
stderr : stderr ,
result : mergedResults
} ;
} catch ( error ) {
// FIXME: Use getAllContext
let message = ( error . failedChannel != null )
? ` Failed while processing ${ error . failedChannel } of command `
: "Failed while processing result of command execution" ;
throw chain ( error , errors . CommandExecutionFailed , message , {
exitCode : exitCode ,
stdout : stdout ,
stderr : stderr
} ) ;
}
} ) . catch ( rethrowAs ( errors . CommandExecutionFailed , ` An error occurred while executing ' ${ command } ' ` , {
command : effectiveCompleteCommand
} ) ) ;
} ) ;
}
} ;
} ;