bashdialogio-redirectioncommand-substitution

Bash dialog: chained command substitution if problem


I want to write a script based on dialog programbox (or others) and capture a command's stderr and the stdout passed to dialog into an array (if any), as well as the command's exit status (as last array element). For this, I'm using prepared command strings, command substitution and fd3 redirection.

Basically, this works nicely, as long as there're no chained commands with if involved - then I get error messages, like, in this case 'command not found'. Here's a (simplified) example:

# Prepare command strings
declare cmd0='if [[ -e . ]]; then echo TRUE; fi'
cmd1=($("$cmd0") 2>&1)
cmd2=(dialog --colors --no-collapse --programbox "\Zb\Z1List local folders..." 20 160)

# Redirect stdout and stderr to dialog, while executing cmd1 > cmd2, capturing cmd1 exit status
exec 3>&1
tmp=($(${cmd1[@]} 2>&1 | "${cmd2[@]}" 1>&3; echo ${PIPESTATUS[0]}))
exec 3>&-

# Print results
printf '%s\n' "${tmp[@]}"

The command declaration seems to require an array declaration, but then special chars like ';' will also add to the problem. This is what I tried; directly using c instead of cmd1 will print the whole line as error.

What needs to be changed, to make the command substituion for chained commands (including if) in cmd1 work?


Solution

  • With the hints in the comments above I could come up with a solution, albeit not completely satisfactory, due to the need of defining functions for each complex/nested command to parse/pass to dialog programbox.

    dialog_programbox can accept and parse simple commands via argument, or, for complex commands, process input from stdin instead:

    There may be a way to extract piped input into the captured result var (here: exitStatus), e. g. per usage of tee - if someone knows, how, please feel free to post a solution.

    For other dialog variants, which accept input from a var and which do not consume stdin, the output from dialog_programbox can be printed to stdout as newline-separated string, with exit/pipe status as the last or first element, for easy splitting from a captured result var. I'll probably extend the solution accordingly, at a later point.

    # Function example for complex commands to pipe to dialog_programbox()
    complexCmdFunc1() {
        # Requires explicit stderr redirection to stdout for each command
        # Could also split cmd execution and store exit status in var for reporting
        if ! git worktree remove -- "$1" 2>&1; then
            echo -e "\n\nREMOVAL BLOCKED:" "Worktree may not exist or is unlcean/locked (try force) - ABORTING."
            # Need to explicitly return exit status to report
            return 1
        fi
        echo -e "Successfully removed worktree:\n$1"
        return 0
    }
    
    dialog_programbox() {
    
        local -i height
        local -i width
        local -n cmd
        local cmdTmp
        local title
            
        for arg; do
            if [[ $arg == +([[:digit:]]) ]]; then
                if [[ $height -gt 0 ]]; then
                    width=$arg
                    continue
                fi
                height=$arg
            elif [[ $title ]]; then
                if [[ -R $arg ]]; then
                    cmd=$arg
                # For direct CMD arg injection, has bug
                elif ! declare -p "$arg" 2>&1 1>/dev/null; then
                    cmdTmp=$arg
                    cmd=cmdTmp
                elif [[ $(declare -p "$arg" 2>/dev/null) == 'declare -a'* ]]; then
                    cmd=$arg
                elif [[ -z $cmd ]]; then
                    cmd=$arg
                fi
            else
                title="$arg"
            fi  
        done
        
        [[ -z $height ]] && height=30
        [[ -z $width ]] && height=120
    
        # CMD ARG INJECTION
        if [[ $cmd ]]; then
            $( echo "${cmd[@]}" ) 2>&1 | dialog --title "$title" --colors --no-collapse --programbox $height $width 1>&3
            echo ${PIPESTATUS[0]}
            # stdin command substitution - would work but swallow pipe/exit status
            #dialog --title "$title" --colors --no-collapse --programbox $height $width 1>&3 < <($( echo ${cmd[@]} ) 2>&1)
        # PIPING STDIN
        else
            dialog --title "$title" --colors --no-collapse --programbox $height $width </dev/stdin 1>&3
            # Temp var would address empty stdin but waste real-time progress report
            #local tmp="$(grep . </dev/stdin)"
            # if [[ $tmp ]] ; then
                # dialog --title "$title" --colors --no-collapse --programbox $height $width <<<"$tmp"
            # else
                # echo 'Nothing to process.' | dialog --title "$title" --colors --no-collapse --programbox $height $width
            # fi
        fi
    }
    
    # COMPLEX CMD output piping usage - requires explicit echo of PIPESTATUS
    exitStatus1=$(complexCmdFunc1 'unreleased' | dialog_programbox '\Zb\Z1Deleting local worktree...' 20 160; echo ${PIPESTATUS[0]})
    
    # More efficient, since no additional subshell invocation is needed:
    complexCmdFunc1 'unreleased' | dialog_programbox '\Zb\Z1Deleting local worktree...' 20 160
    exitStatus2=${PIPESTATUS[0]}
    
    # Empty pipe, will render an empty dialog, of which addressing would require storing cmd result in temp var and losing real-time progress reporting
    #exitStatus=$(echo | dialog_programbox '\Zb\Z1Deleting local worktree...' 20 160; echo ${PIPESTATUS[0]})
    
    # CMD var injection examples (for simple/non-nested commands)
    #declare -a c=(ls foo bar)
    declare c='ls foo bar'
    exitStatus3=$(dialog_programbox '\Zb\Z1Deleting local worktree...' c 20 160)
    
    # direct CMD injection - HAS BUG
    #exitStatus=$(dialog_programbox '\Zb\Z1Deleting local worktree...' 'ls foo bar' 20 160)
    
    echo -e "EXIT_STATUS1: $exitStatus1"
    echo -e "EXIT_STATUS2: $exitStatus2"
    echo -e "EXIT_STATUS3: $exitStatus3"
    

    Another solution, based on this answer from Michał Górny (thanks), an example, on how to get the exit status AND the (displayed) output from a command defined in a function and processed by dialog programbox, involving coproc and global vars:

    # Result array for storing processed command's output
    declare -ga cmdOutput
    declare -g exitStatus
    worktreeName='unreleased'
    
    # Command to process and get exit status + results from
    gitRemoveWtCmd() {
        _IFS="$IFS"; IFS=$'\n'
        cmdOutput=( $(git worktree remove -- "$worktreeName" 2>&1) )
        exitStatus=$?
        IFS="$_IFS"
        if [[ $exitStatus -ne 0 ]]; then
            printf '%s\n' "${cmdOutput[@]}"
            echo -e "\nCannot remove unclean or non-existent worktree" 
        else
            echo -e "\nSuccessfully removed worktree: [$worktreeName]"; 
        fi
    }
    
    # Co-process with dialog to send command results to, redirected output to fd3
    coproc diaProgramboxCop { 
        dialog --colors --no-collapse --programbox '\Zb\Z1List local folders...' 20 160 1>&3
    }
    
    # Redirecting processed stderr to output and linking fd3 there
    { exec 3>&1
      gitRemoveWtCmd 2>&1
      exec 3>&-
    } >&${diaProgramboxCop[1]}
    
    exec {diaProgramboxCop[1]}>&-
    wait ${diaProgramboxCop_PID}
    
    printf '%s%s\n' "OUTPUT: " "${cmdOutput[@]}"
    echo -e "EXIT_STATUS: $exitStatus"
    

    A real drawback here is, that global vars are required for cmdOutput and exitStatus and the capturing of the command's output eradicates the real-time displaying of the command's progress, due to storing it via subshell into the var before actually sending it to the dialog.

    So, this approach would better be used with widgets like msgbox, which consume a pre-filled var.

    According to the suggestion here, using fd3, redirected from tee + cat in a pipe, can be used for channelling all command output to the var assignment (stdout and stderr) and together with redirection of the dialog output directly to /dev/tty, this is another way of capturing the processed command's output, while displaying it via dialog in real-time, provided, the commands can be hard-coded and it can even return the no direct pipe/exit status:

    worktreeName='unreleased'
    
    _IFS="$IFS"; IFS=$'\n'
    cmdOutput=( $({ gitRemoveWtCmd ${worktreeName@Q} 2>&1 | tee >(cat >&3) | dialog_programbox '\Zb\Z1Deleting local worktree...' 20 120 >/dev/tty;} 3>&1 ; echo -e ${PIPESTATUS[0]}) )
    printf '%s\n%s\n' "CMD_OUTPUT:" "${cmdOutput[@]:0:((${#cmdOutput[@]} - 1))}"
    echo -e "EXIT_STATUS: ${cmdOutput[@]: -1}"
    IFS="$_IFS"
    

    The PIPESTATUS can still be printed from within the command substitution, but needs to be split off the captured output. But it's even better, to just use ;exit ${PIPESTATUS[0]} from within the same process substitution curly braces, do without array splitting and simply use the exit status code from $? immediately in the next line.

    The final solution allows the application of varying commands to the dialog, per use of eval inside the function getDialogCmdResultAndExitStatus(), which accepts a flag to silence any tty or stdout output and a var or name reference for the command to process and the dialog command to use for displaying, while by default also passing all command output to stdout (for capturing into a var) and always returning the processed command's internal pipe status as exit status, to enable follow-up decisions in scripts:

    getDialogCmdResultAndExitStatus() {
    
        local quiet
        if [[ $1 =~ ^-[[:alnum:]] ]]; then
            if [[ ${1^^} != '-Q' ]]; then
                echo -e "Invalild argument: $1"
                exit 1
            fi
            quiet='TRUE'
            shift
        fi
        
        local -a com
        local -n cRef
        if ! declare -p "$1" &>/dev/null; then 
            com="$1"
        else
            cRef=$1
            com="${cRef[@]}"
        fi
        
        local -a diaCom
        local -n diaComRef
        if ! declare -p "$2" &>/dev/null; then 
            diaCom="$2"
        else
            diaComRef=$2
            diaCom="${diaComRef[@]}"
        fi
        
        if [[ $quiet ]]; then
            { eval "${com[@]@E} 2>&1" | eval "${diaCom[@]@E} >/dev/tty"; return ${PIPESTATUS[0]}; } 3>&1
        else
            { eval "${com[@]@E} 2>&1" | tee >(cat - >&3) | eval "${diaCom[@]@E} >/dev/tty"; return ${PIPESTATUS[0]}; } 3>&1
        fi
    }
    

    Used like this - using the processed command's exit status (internally pipe status) requires inline-usage together with if or capturing $? for further scripting decisions, right after the output capturing line:

    # Prepare commands (utilizing functions from examples above)
    declare command="gitRemoveWtCmd ${worktreeName@Q}"
    declare dialogCmd="dialog_programbox '\Zb\Z1Deleting local worktree...' 20 120"
    
    # Store and limit IFS to newlines
    _IFS="$IFS"; IFS=$'\n'
    # Capture command's stdout and stderr + real-time displaying with dialog
    cmdOutput=( $(getDialogCmdResultAndExitStatus command dialogCmd) )
    # First line after output capturing: get pipe/exit status of processed command
    echo -e "EXIT_STATUS: $?"
    # Restore IFS to defaults
    IFS="$_IFS"
    

    or

    if ! getDialogCmdResultAndExitStatus command dialogCmd; then
        # do sth else...
    fi