From 137be720511870cf78c97088db18f1765bc61b9b Mon Sep 17 00:00:00 2001 From: Shantanu Date: Mon, 5 Aug 2024 15:20:48 -0700 Subject: [PATCH] cmd/init: separate bash-preexec and savvy setup Savvy relies on bash-preexec.sh to setup pre_cmd and pre_exec hooks in bash. Users may rely on multiple other tools(atuin, starship, etc) that install and setup bash-preexec.sh. In such cases, a user's bashrc may initialize bash-preexec before running: `eval $(savvy init bash)` In that case [bash-preexec.sh](https://github.com/getsavvyinc/savvy-cli/blob/8c6a834c5a140b83cc77c788e40738f510af7c12/cmd/setup/bash-preexec.sh#L52) avoids duplicate inclusion and returns early. As a result, none of Savvy's custom hooks are evaluated and user's get an error messasge "not configured to use Savvy" when trying to record/run runbooks. This commit fixes the above issue by separating initializing bash-preexec from adding savvy's shell hooks. Fixes #118 --- cmd/setup/bash-hooks.sh | 119 +++++++++++++++++++++++++++++++++++++ cmd/setup/bash-preexec.sh | 121 +------------------------------------- cmd/setup/bash.go | 78 ++++++++++++++++++++++-- 3 files changed, 192 insertions(+), 126 deletions(-) create mode 100644 cmd/setup/bash-hooks.sh diff --git a/cmd/setup/bash-hooks.sh b/cmd/setup/bash-hooks.sh new file mode 100644 index 0000000..8286cfd --- /dev/null +++ b/cmd/setup/bash-hooks.sh @@ -0,0 +1,119 @@ +#### SAVVY CUSTOMIZATIONS #### + +# Enable experimental subshell support +export __bp_enable_subshells="true" + + +SAVVY_INPUT_FILE=/tmp/savvy-socket + +# Save the original PS1 +orignal_ps1=$PS1 + +get_user_prompt() { + local user_prompt + # P expansion is only available in bash 4.4+ + if [[ "${BASH_VERSINFO[0]}" -gt 4 ]] || (( BASH_VERSINFO[0] > 3 && BASH_VERSINFO[1] > 4)); then + user_prompt=$(printf '%s' "${PS1@P}") + else + user_prompt="" + fi + echo "${user_prompt}" +} + +step_id="" +savvy_cmd_pre_exec() { + local expanded_command="" + local spaced_command=$(echo $1 | sed -e 's/\$(\([^)]*\))/$( \1 )/g' -e 's/`\(.*\)`/` \1 `/g') + local command_parts=( $spaced_command ) + for part in "${command_parts[@]}"; do + if [[ "$part" =~ ^[a-zA-Z0-9_]+$ && $(type -t "$part") == "alias" ]]; then + expanded_command+=$(alias "$part" | sed -e "s/^[[:space:]]*alias $part='//" -e "s/^$part='//" -e "s/'$//")" " + else + expanded_command+="$part " + fi + done + local cmd="${expanded_command}" + local prompt=$(get_user_prompt) + step_id="" + if [[ "${SAVVY_CONTEXT}" == "record" ]] ; then + step_id=$(SAVVY_SOCKET_PATH=${SAVVY_INPUT_FILE} savvy send --prompt="${prompt}" "$cmd") + fi +} + +savvy_cmd_pre_cmd() { + local exit_code=$? + + if [[ "${SAVVY_CONTEXT}" == "record" && "$PS1" != *'recording'* ]]; then + PS1+=$'\[\e[31m\]recording\[\e[0m\] \U1f60e ' + fi + + # if return code is not 0, send the return code to the server + if [[ "${SAVVY_CONTEXT}" == "record" && "${exit_code}" != "0" ]] ; then + SAVVY_SOCKET_PATH=${SAVVY_INPUT_FILE} savvy send --step-id="${step_id}" --exit-code="${exit_code}" + fi +} + +SAVVY_COMMANDS=() +SAVVY_RUN_CURR="" +SAVVY_NEXT_STEP=0 + +# Set up a function to run the next command in the runbook when the user presses C-n +savvy_runbook_runner() { + if [[ "${SAVVY_CONTEXT}" == "run" && "${SAVVY_NEXT_STEP}" -le "${#SAVVY_COMMANDS[@]}" ]] ; then + next_step=$(savvy internal current) + READLINE_LINE="${next_step}" + READLINE_POINT=${#READLINE_LINE} + fi +} + + +savvy_run_pre_exec() { + # we want the command as it was typed in. + local cmd=$1 + if [[ "${SAVVY_CONTEXT}" == "run" && "${SAVVY_NEXT_STEP}" -lt "${#SAVVY_COMMANDS[@]}" ]] ; then + SAVVY_NEXT_STEP=$(savvy internal next --cmd="${cmd}") + fi +} + +PROMPT_GREEN="\[$(tput setaf 2)\]" +PROMPT_BLUE="\[$(tput setaf 4)\]" +PROMPT_BOLD="\[$(tput bold)\]" +PROMPT_RED="\[$(tput setaf 1)\]" +PROMPT_RESET="\[$(tput sgr0)\]" + +savvy_run_pre_cmd() { + # transorm 0 based index to 1 based index + local display_step=$((SAVVY_NEXT_STEP+1)) + local size=${#SAVVY_COMMANDS[@]} + + if [[ "${SAVVY_CONTEXT}" == "run" && "${SAVVY_NEXT_STEP}" -lt "${size}" && "${size}" -gt 0 ]] ; then + PS1="${orignal_ps1}\n${PROMPT_GREEN}[ctrl+n:get next step]${PROMPT_RESET}(running ${PROMPT_BOLD}${SAVVY_RUN_CURR} ${display_step}/${size}${PROMPT_RESET}) " + fi + + if [[ "${SAVVY_CONTEXT}" == "run" && "${SAVVY_NEXT_STEP}" -ge "${size}" ]] ; then + # space at the end is important + PS1="${orignal_ps1}\n(${PROMPT_GREEN}done${PROMPT_RESET}"$' \U1f60e '"${PROMPT_BOLD}${SAVVY_RUN_CURR}${PROMPT_RESET})${PROMPT_GREEN}[exit/ctrl+d to exit]${PROMPT_RESET} " + fi + + if [[ "${SAVVY_CONTEXT}" == "run" && "${SAVVY_NEXT_STEP}" -lt "${size}" ]] ; then + savvy internal set-param + fi +} + + +if [[ "${SAVVY_CONTEXT}" == "run" ]] ; then + mapfile -t SAVVY_COMMANDS < <(awk -F'COMMA' '{ for(i=1;i<=NF;i++) print $i }' <<< $SAVVY_RUNBOOK_COMMANDS) + SAVVY_RUN_CURR="${SAVVY_RUNBOOK_ALIAS}" + + # Set up a keybinding to trigger the function + bind 'set keyseq-timeout 0' + bind -x '"\C-n":savvy_runbook_runner' + + precmd_functions+=(savvy_run_pre_cmd) + preexec_functions+=(savvy_run_pre_exec) +fi; + +preexec_functions+=(savvy_cmd_pre_exec) +# NOTE: If you change this function name, you must also change the corresponding check in shell/check_setup.go +# TODO: use templates to avoid the need to manually change shell checks +precmd_functions+=(savvy_cmd_pre_cmd) diff --git a/cmd/setup/bash-preexec.sh b/cmd/setup/bash-preexec.sh index 3b72add..2115f28 100644 --- a/cmd/setup/bash-preexec.sh +++ b/cmd/setup/bash-preexec.sh @@ -39,6 +39,7 @@ # Make sure this is bash that's running and return otherwise. # Use POSIX syntax for this line: + if [ -z "${BASH_VERSION-}" ]; then return 1; fi @@ -380,123 +381,3 @@ __bp_install_after_session_init() { if [[ -z "${__bp_delay_install:-}" ]]; then __bp_install_after_session_init fi; - -#### SAVVY CUSTOMIZATIONS #### - -# Enable experimental subshell support -export __bp_enable_subshells="true" - - -SAVVY_INPUT_FILE=/tmp/savvy-socket - -# Save the original PS1 -orignal_ps1=$PS1 - -get_user_prompt() { - local user_prompt - # P expansion is only available in bash 4.4+ - if [[ "${BASH_VERSINFO[0]}" -gt 4 ]] || (( BASH_VERSINFO[0] > 3 && BASH_VERSINFO[1] > 4)); then - user_prompt=$(printf '%s' "${PS1@P}") - else - user_prompt="" - fi - echo "${user_prompt}" -} - -step_id="" -savvy_cmd_pre_exec() { - local expanded_command="" - local spaced_command=$(echo $1 | sed -e 's/\$(\([^)]*\))/$( \1 )/g' -e 's/`\(.*\)`/` \1 `/g') - local command_parts=( $spaced_command ) - for part in "${command_parts[@]}"; do - if [[ "$part" =~ ^[a-zA-Z0-9_]+$ && $(type -t "$part") == "alias" ]]; then - expanded_command+=$(alias "$part" | sed -e "s/^[[:space:]]*alias $part='//" -e "s/^$part='//" -e "s/'$//")" " - else - expanded_command+="$part " - fi - done - local cmd="${expanded_command}" - local prompt=$(get_user_prompt) - step_id="" - if [[ "${SAVVY_CONTEXT}" == "record" ]] ; then - step_id=$(SAVVY_SOCKET_PATH=${SAVVY_INPUT_FILE} savvy send --prompt="${prompt}" "$cmd") - fi -} - -savvy_cmd_pre_cmd() { - local exit_code=$? - - if [[ "${SAVVY_CONTEXT}" == "record" && "$PS1" != *'recording'* ]]; then - PS1+=$'\[\e[31m\]recording\[\e[0m\] \U1f60e ' - fi - - # if return code is not 0, send the return code to the server - if [[ "${SAVVY_CONTEXT}" == "record" && "${exit_code}" != "0" ]] ; then - SAVVY_SOCKET_PATH=${SAVVY_INPUT_FILE} savvy send --step-id="${step_id}" --exit-code="${exit_code}" - fi -} - -SAVVY_COMMANDS=() -SAVVY_RUN_CURR="" -SAVVY_NEXT_STEP=0 - -# Set up a function to run the next command in the runbook when the user presses C-n -savvy_runbook_runner() { - if [[ "${SAVVY_CONTEXT}" == "run" && "${SAVVY_NEXT_STEP}" -le "${#SAVVY_COMMANDS[@]}" ]] ; then - next_step=$(savvy internal current) - READLINE_LINE="${next_step}" - READLINE_POINT=${#READLINE_LINE} - fi -} - - -savvy_run_pre_exec() { - # we want the command as it was typed in. - local cmd=$1 - if [[ "${SAVVY_CONTEXT}" == "run" && "${SAVVY_NEXT_STEP}" -lt "${#SAVVY_COMMANDS[@]}" ]] ; then - SAVVY_NEXT_STEP=$(savvy internal next --cmd="${cmd}") - fi -} - -PROMPT_GREEN="\[$(tput setaf 2)\]" -PROMPT_BLUE="\[$(tput setaf 4)\]" -PROMPT_BOLD="\[$(tput bold)\]" -PROMPT_RED="\[$(tput setaf 1)\]" -PROMPT_RESET="\[$(tput sgr0)\]" - -savvy_run_pre_cmd() { - # transorm 0 based index to 1 based index - local display_step=$((SAVVY_NEXT_STEP+1)) - local size=${#SAVVY_COMMANDS[@]} - - if [[ "${SAVVY_CONTEXT}" == "run" && "${SAVVY_NEXT_STEP}" -lt "${size}" && "${size}" -gt 0 ]] ; then - PS1="${orignal_ps1}\n${PROMPT_GREEN}[ctrl+n:get next step]${PROMPT_RESET}(running ${PROMPT_BOLD}${SAVVY_RUN_CURR} ${display_step}/${size}${PROMPT_RESET}) " - fi - - if [[ "${SAVVY_CONTEXT}" == "run" && "${SAVVY_NEXT_STEP}" -ge "${size}" ]] ; then - # space at the end is important - PS1="${orignal_ps1}\n(${PROMPT_GREEN}done${PROMPT_RESET}"$' \U1f60e '"${PROMPT_BOLD}${SAVVY_RUN_CURR}${PROMPT_RESET})${PROMPT_GREEN}[exit/ctrl+d to exit]${PROMPT_RESET} " - fi - - if [[ "${SAVVY_CONTEXT}" == "run" && "${SAVVY_NEXT_STEP}" -lt "${size}" ]] ; then - savvy internal set-param - fi -} - - -if [[ "${SAVVY_CONTEXT}" == "run" ]] ; then - mapfile -t SAVVY_COMMANDS < <(awk -F'COMMA' '{ for(i=1;i<=NF;i++) print $i }' <<< $SAVVY_RUNBOOK_COMMANDS) - SAVVY_RUN_CURR="${SAVVY_RUNBOOK_ALIAS}" - - # Set up a keybinding to trigger the function - bind 'set keyseq-timeout 0' - bind -x '"\C-n":savvy_runbook_runner' - - precmd_functions+=(savvy_run_pre_cmd) - preexec_functions+=(savvy_run_pre_exec) -fi; - -preexec_functions+=(savvy_cmd_pre_exec) -# NOTE: If you change this function name, you must also change the corresponding check in shell/check_setup.go -# TODO: use templates to avoid the need to manually change shell checks -precmd_functions+=(savvy_cmd_pre_cmd) diff --git a/cmd/setup/bash.go b/cmd/setup/bash.go index e375d2a..c23326b 100644 --- a/cmd/setup/bash.go +++ b/cmd/setup/bash.go @@ -3,14 +3,53 @@ package setup import ( "embed" "fmt" + "os" + "text/template" "github.com/spf13/cobra" ) -//go:embed bash-preexec.sh -var bashSetupScript embed.FS +//go:embed bash-preexec.sh bash-hooks.sh +var bashFiles embed.FS -const bashSetupScriptName = "bash-preexec.sh" +const bashSetupScript = ` +# Savvy initialization for bash + +# Function to source a file and check for errors +source_file() { + if [[ -n "$1" ]]; then + if ! source /dev/stdin <<< "$1"; then + echo "Error sourcing script" >&2 + return 1 + fi + else + echo "Empty script content" >&2 + return 1 + fi +} + +# Embed bash-preexec.sh content +read -r -d '' BASH_PREEXEC_CONTENT << 'EOF' +{{.BashPreexecContent}} +EOF + +# Embed bash-hooks.sh content +read -r -d '' BASH_HOOKS_CONTENT << 'EOF' +{{.BashHooksContent}} +EOF + +# Source bash-preexec.sh +if ! source_file "$BASH_PREEXEC_CONTENT"; then + echo "Failed to source bash-preexec.sh" >&2 + return 1 +fi + +# Source bash-hooks.sh +if ! source_file "$BASH_HOOKS_CONTENT"; then + echo "Failed to source bash-hooks.sh" >&2 + return 1 +fi +` // initCmd represents the init command var BashCmd = &cobra.Command{ @@ -21,11 +60,38 @@ var BashCmd = &cobra.Command{ } func runCmd(cmd *cobra.Command, args []string) error { - content, err := bashSetupScript.ReadFile(bashSetupScriptName) + + bashPreexecContent, err := bashFiles.ReadFile("bash-preexec.sh") + if err != nil { + return fmt.Errorf("failed to read bash-preexec.sh: %w", err) + } + + // Read the content of bash-hooks.sh + bashHooksContent, err := bashFiles.ReadFile("bash-hooks.sh") if err != nil { - return err + return fmt.Errorf("failed to read bash-hooks.sh: %w", err) + } + + // Prepare the template data + data := struct { + BashPreexecContent string + BashHooksContent string + }{ + BashPreexecContent: string(bashPreexecContent), + BashHooksContent: string(bashHooksContent), } - fmt.Println(string(content)) + + // Parse the template + tmpl, err := template.New("bash_setup").Parse(bashSetupScript) + if err != nil { + return fmt.Errorf("failed to parse template: %w", err) + } + + // Execute the template and write to stdout + if err := tmpl.Execute(os.Stdout, data); err != nil { + return fmt.Errorf("failed to execute template: %w", err) + } + return nil }