diff --git a/build/docker-entrypoint b/build/docker-entrypoint index 547dfed..f643d68 100644 --- a/build/docker-entrypoint +++ b/build/docker-entrypoint @@ -83,7 +83,7 @@ if [ -n "${CLAUDEBOX_PROJECT_NAME:-}" ]; then runuser -u DOCKERUSER -- touch "$VENV_FLAG" else # Someone else is fixing it, wait for completion - local wait_count=0 + wait_count=0 while [ ! -f "$VENV_FLAG" ] && [ $wait_count -lt 60 ]; do sleep 0.5 ((wait_count++)) || true @@ -106,7 +106,7 @@ if [ -n "${CLAUDEBOX_PROJECT_NAME:-}" ]; then runuser -u DOCKERUSER -- touch "$VENV_FLAG" else # Someone else is creating it, wait for completion flag - local wait_count=0 + wait_count=0 while [ ! -f "$VENV_FLAG" ] && [ $wait_count -lt 60 ]; do sleep 0.5 ((wait_count++)) || true @@ -138,7 +138,7 @@ if [ -n "${CLAUDEBOX_PROJECT_NAME:-}" ]; then if [ -f "$CONFIG_FILE" ] && grep -qE 'python|ml|datascience' "$CONFIG_FILE"; then if [ ! -f "$PYDEV_FLAG" ] && [ -f "$VENV_FLAG" ] && [ -d "$VENV_DIR" ]; then # Deploy Python dev tools based on profile - local python_packages="" + python_packages="" # Base Python profile packages if grep -q 'python' "$CONFIG_FILE"; then diff --git a/lib/cli.sh b/lib/cli.sh index c70d574..f255b2b 100644 --- a/lib/cli.sh +++ b/lib/cli.sh @@ -20,8 +20,6 @@ readonly SCRIPT_COMMANDS=(shell create slot slots revoke profiles projects profi # pass_through: Array of args to pass to Claude in container # Note: Each argument goes into exactly ONE bucket - no duplication parse_cli_args() { - local all_args=("$@") - # Initialize bucket arrays host_flags=() control_flags=() @@ -31,7 +29,8 @@ parse_cli_args() { # Single parsing loop - each arg goes into exactly ONE bucket local found_script_command=false - for arg in "${all_args[@]}"; do + # Iterate directly over arguments (handles empty $@ with set -u) + for arg in "$@"; do if [[ " ${HOST_ONLY_FLAGS[*]} " == *" $arg "* ]]; then # Bucket 1: Host-only flags host_flags+=("$arg") @@ -48,28 +47,34 @@ parse_cli_args() { fi done - # Export results for use by main script - export CLI_HOST_FLAGS=("${host_flags[@]}") - export CLI_CONTROL_FLAGS=("${control_flags[@]}") + # Export results for use by main script (handle empty arrays with set -u) + CLI_HOST_FLAGS=("${host_flags[@]+"${host_flags[@]}"}") + export CLI_HOST_FLAGS + CLI_CONTROL_FLAGS=("${control_flags[@]+"${control_flags[@]}"}") + export CLI_CONTROL_FLAGS export CLI_SCRIPT_COMMAND="$script_command" - export CLI_PASS_THROUGH=("${pass_through[@]}") + CLI_PASS_THROUGH=("${pass_through[@]+"${pass_through[@]}"}") + export CLI_PASS_THROUGH } # Process host-only flags and set environment variables process_host_flags() { - for flag in "${CLI_HOST_FLAGS[@]}"; do - case "$flag" in - --verbose) - export VERBOSE=true - ;; - rebuild) - export REBUILD=true - ;; - tmux) - export CLAUDEBOX_WRAP_TMUX=true - ;; - esac - done + # Handle empty array with set -u compatibility + if [ ${#CLI_HOST_FLAGS[@]} -gt 0 ]; then + for flag in "${CLI_HOST_FLAGS[@]}"; do + case "$flag" in + --verbose) + export VERBOSE=true + ;; + rebuild) + export REBUILD=true + ;; + tmux) + export CLAUDEBOX_WRAP_TMUX=true + ;; + esac + done + fi } # Get command requirements - returns one of: @@ -126,10 +131,10 @@ requires_slot() { debug_parsed_args() { if [[ "${VERBOSE:-false}" == "true" ]]; then echo "[DEBUG] CLI Parser Results:" >&2 - echo "[DEBUG] Host flags: ${CLI_HOST_FLAGS[*]}" >&2 - echo "[DEBUG] Control flags: ${CLI_CONTROL_FLAGS[*]}" >&2 + echo "[DEBUG] Host flags: ${CLI_HOST_FLAGS[*]+"${CLI_HOST_FLAGS[*]}"}" >&2 + echo "[DEBUG] Control flags: ${CLI_CONTROL_FLAGS[*]+"${CLI_CONTROL_FLAGS[*]}"}" >&2 echo "[DEBUG] Script command: ${CLI_SCRIPT_COMMAND}" >&2 - echo "[DEBUG] Pass-through: ${CLI_PASS_THROUGH[*]}" >&2 + echo "[DEBUG] Pass-through: ${CLI_PASS_THROUGH[*]+"${CLI_PASS_THROUGH[*]}"}" >&2 fi } diff --git a/lib/commands.core.sh b/lib/commands.core.sh index 08d97bc..2edeaa1 100644 --- a/lib/commands.core.sh +++ b/lib/commands.core.sh @@ -113,11 +113,11 @@ _cmd_shell() { trap cleanup_admin EXIT if [[ "$VERBOSE" == "true" ]]; then - echo "[DEBUG] Running admin container with flags: ${shell_flags[*]}" >&2 + echo "[DEBUG] Running admin container with flags: ${shell_flags[*]+"${shell_flags[*]}"}" >&2 echo "[DEBUG] Remaining args after processing: $*" >&2 fi # Don't pass any remaining arguments - only shell and the flags - run_claudebox_container "$temp_container" "interactive" shell "${shell_flags[@]}" + run_claudebox_container "$temp_container" "interactive" shell "${shell_flags[@]+"${shell_flags[@]}"}" # Commit changes back to image fillbar @@ -127,7 +127,7 @@ _cmd_shell() { success "Changes saved to image!" else # Regular shell mode - just run without committing - run_claudebox_container "" "interactive" shell "${shell_flags[@]}" + run_claudebox_container "" "interactive" shell "${shell_flags[@]+"${shell_flags[@]}"}" fi exit 0 diff --git a/lib/commands.profile.sh b/lib/commands.profile.sh index 462d5d4..40477d3 100644 --- a/lib/commands.profile.sh +++ b/lib/commands.profile.sh @@ -31,13 +31,15 @@ _cmd_profiles() { for profile in $(get_all_profile_names | tr ' ' '\n' | sort); do local desc=$(get_profile_description "$profile") local is_enabled=false - # Check if profile is currently enabled - for enabled in "${current_profiles[@]}"; do - if [[ "$enabled" == "$profile" ]]; then - is_enabled=true - break - fi - done + # Check if profile is currently enabled (guard for empty array) + if [ ${#current_profiles[@]} -gt 0 ]; then + for enabled in "${current_profiles[@]}"; do + if [[ "$enabled" == "$profile" ]]; then + is_enabled=true + break + fi + done + fi printf " ${GREEN}%-15s${NC} " "$profile" if [[ "$is_enabled" == "true" ]]; then printf "${GREEN}✓${NC} " @@ -144,12 +146,14 @@ _cmd_add() { # Check if any Python-related profiles were added local python_profiles_added=false - for profile in "${selected[@]}"; do - if [[ "$profile" == "python" ]] || [[ "$profile" == "ml" ]] || [[ "$profile" == "datascience" ]]; then - python_profiles_added=true - break - fi - done + if [ ${#selected[@]} -gt 0 ]; then + for profile in "${selected[@]}"; do + if [[ "$profile" == "python" ]] || [[ "$profile" == "ml" ]] || [[ "$profile" == "datascience" ]]; then + python_profiles_added=true + break + fi + done + fi # If Python profiles were added, remove the pydev flag to trigger reinstall if [[ "$python_profiles_added" == "true" ]]; then @@ -162,12 +166,14 @@ _cmd_add() { # Only show rebuild message for non-Python profiles local needs_rebuild=false - for profile in "${selected[@]}"; do - if [[ "$profile" != "python" ]] && [[ "$profile" != "ml" ]] && [[ "$profile" != "datascience" ]]; then - needs_rebuild=true - break - fi - done + if [ ${#selected[@]} -gt 0 ]; then + for profile in "${selected[@]}"; do + if [[ "$profile" != "python" ]] && [[ "$profile" != "ml" ]] && [[ "$profile" != "datascience" ]]; then + needs_rebuild=true + break + fi + done + fi if [[ "$needs_rebuild" == "true" ]]; then warn "The Docker image will be rebuilt with new profiles on next run." @@ -229,29 +235,35 @@ _cmd_remove() { # Remove specified profiles local new_profiles=() local python_profiles_removed=false - for profile in "${current_profiles[@]}"; do - local keep=true - for remove in "${to_remove[@]}"; do - if [[ "$profile" == "$remove" ]]; then - keep=false - # Check if we're removing a Python-related profile - if [[ "$profile" == "python" ]] || [[ "$profile" == "ml" ]] || [[ "$profile" == "datascience" ]]; then - python_profiles_removed=true - fi - break + if [ ${#current_profiles[@]} -gt 0 ]; then + for profile in "${current_profiles[@]}"; do + local keep=true + if [ ${#to_remove[@]} -gt 0 ]; then + for remove in "${to_remove[@]}"; do + if [[ "$profile" == "$remove" ]]; then + keep=false + # Check if we're removing a Python-related profile + if [[ "$profile" == "python" ]] || [[ "$profile" == "ml" ]] || [[ "$profile" == "datascience" ]]; then + python_profiles_removed=true + fi + break + fi + done fi + [[ "$keep" == "true" ]] && new_profiles+=("$profile") done - [[ "$keep" == "true" ]] && new_profiles+=("$profile") - done + fi # Check if any Python-related profiles remain local has_python_profiles=false - for profile in "${new_profiles[@]}"; do - if [[ "$profile" == "python" ]] || [[ "$profile" == "ml" ]] || [[ "$profile" == "datascience" ]]; then - has_python_profiles=true - break - fi - done + if [ ${#new_profiles[@]} -gt 0 ]; then + for profile in "${new_profiles[@]}"; do + if [[ "$profile" == "python" ]] || [[ "$profile" == "ml" ]] || [[ "$profile" == "datascience" ]]; then + has_python_profiles=true + break + fi + done + fi # If we removed Python profiles and no Python profiles remain, clean up Python flags if [[ "$python_profiles_removed" == "true" ]] && [[ "$has_python_profiles" == "false" ]]; then @@ -275,9 +287,11 @@ _cmd_remove() { # Write back the filtered profiles { echo "[profiles]" - for profile in "${new_profiles[@]}"; do - echo "$profile" - done + if [ ${#new_profiles[@]} -gt 0 ]; then + for profile in "${new_profiles[@]+"${new_profiles[@]}"}"; do + echo "$profile" + done + fi echo "" # Preserve packages section if it exists diff --git a/lib/config.sh b/lib/config.sh index 640211a..dd2a133 100755 --- a/lib/config.sh +++ b/lib/config.sh @@ -130,7 +130,7 @@ read_profile_section() { done < <(sed -n "/^\[$section\]/,/^\[/p" "$profile_file" | tail -n +2 | grep -v '^\[') fi - printf '%s\n' "${result[@]}" + printf '%s\n' "${result[@]+"${result[@]}"}" } update_profile_section() { @@ -140,20 +140,29 @@ update_profile_section() { local new_items=("$@") local existing_items=() - readarray -t existing_items < <(read_profile_section "$profile_file" "$section") + # Bash 3.2 compatible - no readarray + while IFS= read -r line; do + existing_items+=("$line") + done < <(read_profile_section "$profile_file" "$section") local all_items=() - for item in "${existing_items[@]}"; do - [[ -n "$item" ]] && all_items+=("$item") - done + if [ ${#existing_items[@]} -gt 0 ]; then + for item in "${existing_items[@]+"${existing_items[@]}"}"; do + [[ -n "$item" ]] && all_items+=("$item") + done + fi - for item in "${new_items[@]}"; do - local found=false - for existing in "${all_items[@]}"; do - [[ "$existing" == "$item" ]] && found=true && break + if [ ${#new_items[@]} -gt 0 ]; then + for item in "${new_items[@]+"${new_items[@]}"}"; do + local found=false + if [ ${#all_items[@]} -gt 0 ]; then + for existing in "${all_items[@]+"${all_items[@]}"}"; do + [[ "$existing" == "$item" ]] && found=true && break + done + fi + [[ "$found" == "false" ]] && all_items+=("$item") done - [[ "$found" == "false" ]] && all_items+=("$item") - done + fi { if [[ -f "$profile_file" ]]; then @@ -169,9 +178,11 @@ update_profile_section() { fi echo "[$section]" - for item in "${all_items[@]}"; do - echo "$item" - done + if [ ${#all_items[@]} -gt 0 ]; then + for item in "${all_items[@]+"${all_items[@]}"}"; do + echo "$item" + done + fi echo "" } > "${profile_file}.tmp" && mv "${profile_file}.tmp" "$profile_file" } @@ -186,7 +197,7 @@ get_current_profiles() { done < <(read_profile_section "$profiles_file" "profiles") fi - printf '%s\n' "${current_profiles[@]}" + printf '%s\n' "${current_profiles[@]+"${current_profiles[@]}"}" } # -------- Profile installation functions for Docker builds ------------------- diff --git a/lib/docker.sh b/lib/docker.sh index 3e3fb50..d23afeb 100755 --- a/lib/docker.sh +++ b/lib/docker.sh @@ -296,15 +296,18 @@ run_claudebox_container() { # Set up cleanup trap for temporary MCP config files cleanup_mcp_files() { local file - for file in "${mcp_temp_files[@]}"; do - if [[ -f "$file" ]]; then - rm -f "$file" - fi - done - if [[ -n "$user_mcp_file" ]] && [[ -f "$user_mcp_file" ]]; then + # Check if array exists and has elements (set -u safe) + if [[ -n "${mcp_temp_files+set}" ]] && [ ${#mcp_temp_files[@]} -gt 0 ]; then + for file in "${mcp_temp_files[@]}"; do + if [[ -f "$file" ]]; then + rm -f "$file" + fi + done + fi + if [[ -n "${user_mcp_file:-}" ]] && [[ -f "$user_mcp_file" ]]; then rm -f "$user_mcp_file" fi - if [[ -n "$project_mcp_file" ]] && [[ -f "$project_mcp_file" ]]; then + if [[ -n "${project_mcp_file:-}" ]] && [[ -f "$project_mcp_file" ]]; then rm -f "$project_mcp_file" fi } diff --git a/main.sh b/main.sh index 0246f35..70d6977 100755 --- a/main.sh +++ b/main.sh @@ -137,7 +137,7 @@ main() { # If command doesn't need Docker, skip all Docker setup if [[ "$cmd_requirements" == "none" ]]; then # Dispatch the command directly and exit - dispatch_command "${CLI_SCRIPT_COMMAND}" "${CLI_PASS_THROUGH[@]}" "${CLI_CONTROL_FLAGS[@]}" + dispatch_command "${CLI_SCRIPT_COMMAND}" "${CLI_PASS_THROUGH[@]+"${CLI_PASS_THROUGH[@]}"}" "${CLI_CONTROL_FLAGS[@]+"${CLI_CONTROL_FLAGS[@]}"}" exit $? fi @@ -299,7 +299,7 @@ main() { local cmd_req=$(get_command_requirements "${CLI_SCRIPT_COMMAND}") # Only run pre-flight for commands that need Docker or image if [[ "$cmd_req" == "docker" ]] || [[ "$cmd_req" == "image" ]]; then - if ! preflight_check "${CLI_SCRIPT_COMMAND}" "${CLI_PASS_THROUGH[@]}"; then + if ! preflight_check "${CLI_SCRIPT_COMMAND}" "${CLI_PASS_THROUGH[@]+"${CLI_PASS_THROUGH[@]}"}"; then # Pre-flight check failed and printed error exit 1 fi @@ -353,18 +353,21 @@ main() { local docker_profiles=() local python_only_profiles=("python" "ml" "datascience") - for profile in "${current_profiles[@]}"; do - local is_python_only=false - for py_profile in "${python_only_profiles[@]}"; do - if [[ "$profile" == "$py_profile" ]]; then - is_python_only=true - break + # Guard against empty array with set -u + if [ ${#current_profiles[@]} -gt 0 ]; then + for profile in "${current_profiles[@]}"; do + local is_python_only=false + for py_profile in "${python_only_profiles[@]}"; do + if [[ "$profile" == "$py_profile" ]]; then + is_python_only=true + break + fi + done + if [[ "$is_python_only" == "false" ]]; then + docker_profiles+=("$profile") fi done - if [[ "$is_python_only" == "false" ]]; then - docker_profiles+=("$profile") - fi - done + fi # Calculate hash only for Docker-affecting profiles local docker_profiles_hash="" @@ -426,7 +429,7 @@ main() { if [[ -n "${CLI_SCRIPT_COMMAND}" ]]; then # Script command - dispatch on host # Pass control flags and pass-through args to dispatch_command - dispatch_command "${CLI_SCRIPT_COMMAND}" "${CLI_PASS_THROUGH[@]}" "${CLI_CONTROL_FLAGS[@]}" + dispatch_command "${CLI_SCRIPT_COMMAND}" "${CLI_PASS_THROUGH[@]+"${CLI_PASS_THROUGH[@]}"}" "${CLI_CONTROL_FLAGS[@]+"${CLI_CONTROL_FLAGS[@]}"}" exit $? else # No script command - running Claude interactively @@ -472,12 +475,14 @@ main() { # Check if stdin is not a terminal (i.e., we're receiving piped input) # and -p/--print flag isn't already present local has_print_flag=false - for arg in "${CLI_PASS_THROUGH[@]}"; do - if [[ "$arg" == "-p" ]] || [[ "$arg" == "--print" ]]; then - has_print_flag=true - break - fi - done + if [ ${#CLI_PASS_THROUGH[@]} -gt 0 ]; then + for arg in "${CLI_PASS_THROUGH[@]}"; do + if [[ "$arg" == "-p" ]] || [[ "$arg" == "--print" ]]; then + has_print_flag=true + break + fi + done + fi if [[ "$VERBOSE" == "true" ]]; then if [[ -t 0 ]]; then @@ -495,9 +500,9 @@ main() { fi local piped_input piped_input=$(cat) - run_claudebox_container "$container_name" "interactive" "${CLI_CONTROL_FLAGS[@]}" "-p" "$piped_input" "${CLI_PASS_THROUGH[@]}" + run_claudebox_container "$container_name" "interactive" "${CLI_CONTROL_FLAGS[@]+"${CLI_CONTROL_FLAGS[@]}"}" "-p" "$piped_input" "${CLI_PASS_THROUGH[@]+"${CLI_PASS_THROUGH[@]}"}" else - run_claudebox_container "$container_name" "interactive" "${CLI_CONTROL_FLAGS[@]}" "${CLI_PASS_THROUGH[@]}" + run_claudebox_container "$container_name" "interactive" "${CLI_CONTROL_FLAGS[@]+"${CLI_CONTROL_FLAGS[@]}"}" "${CLI_PASS_THROUGH[@]+"${CLI_PASS_THROUGH[@]}"}" fi else show_no_slots_menu @@ -537,33 +542,39 @@ build_docker_image() { done < <(read_profile_section "$profiles_file" "profiles") # Generate profile installations - for profile in "${current_profiles[@]}"; do - profile=$(echo "$profile" | tr -d '[:space:]') - [[ -z "$profile" ]] && continue - - # Convert hyphens to underscores for function names - local profile_fn="get_profile_${profile//-/_}" - if type -t "$profile_fn" >/dev/null; then - profile_installations+=$'\n'"$($profile_fn)" - fi - done + # Guard against empty array with set -u + if [ ${#current_profiles[@]} -gt 0 ]; then + for profile in "${current_profiles[@]}"; do + profile=$(echo "$profile" | tr -d '[:space:]') + [[ -z "$profile" ]] && continue + + # Convert hyphens to underscores for function names + local profile_fn="get_profile_${profile//-/_}" + if type -t "$profile_fn" >/dev/null; then + profile_installations+=$'\n'"$($profile_fn)" + fi + done + fi # Calculate hash only for Docker-affecting profiles local docker_profiles=() local python_only_profiles=("python" "ml" "datascience") - for profile in "${current_profiles[@]}"; do - local is_python_only=false - for py_profile in "${python_only_profiles[@]}"; do - if [[ "$profile" == "$py_profile" ]]; then - is_python_only=true - break + # Guard against empty array with set -u + if [ ${#current_profiles[@]} -gt 0 ]; then + for profile in "${current_profiles[@]}"; do + local is_python_only=false + for py_profile in "${python_only_profiles[@]}"; do + if [[ "$profile" == "$py_profile" ]]; then + is_python_only=true + break + fi + done + if [[ "$is_python_only" == "false" ]]; then + docker_profiles+=("$profile") fi done - if [[ "$is_python_only" == "false" ]]; then - docker_profiles+=("$profile") - fi - done + fi if [[ ${#docker_profiles[@]} -gt 0 ]]; then profile_hash=$(printf '%s\n' "${docker_profiles[@]}" | sort | cksum | cut -d' ' -f1) @@ -585,23 +596,43 @@ LABEL claudebox.profiles=\"$profile_hash\" LABEL claudebox.profiles.crc=\"$profiles_file_hash\" LABEL claudebox.project=\"$project_folder_name\"" - # Replace placeholders in the project template - local final_dockerfile="$base_dockerfile" + # Replace placeholders using temp files (Bash 3.2 compatible) + # awk -v doesn't handle multiline strings well in Bash 3.2 + local temp_pi temp_lbs + temp_pi=$(mktemp) || error "Failed to create temp file" + temp_lbs=$(mktemp) || error "Failed to create temp file" + + # Trap to ensure temp files are cleaned up on any exit + trap 'rm -f "$temp_pi" "$temp_lbs"' EXIT INT TERM + + printf '%s' "$profile_installations" > "$temp_pi" + printf '%s' "$labels" > "$temp_lbs" - # Replace WHOLE lines that contain the placeholders (with optional spaces) local final_dockerfile - final_dockerfile=$(awk -v pi="$profile_installations" -v lbs="$labels" ' - # If the whole line is {{ PROFILE_INSTALLATIONS }}, print injected block and skip - /^[[:space:]]*\{\{[[:space:]]*PROFILE_INSTALLATIONS[[:space:]]*\}\}[[:space:]]*$/ { print pi; next } - # If the whole line is {{ LABELS }}, print labels block and skip - /^[[:space:]]*\{\{[[:space:]]*LABELS[[:space:]]*\}\}[[:space:]]*$/ { print lbs; next } + final_dockerfile=$(awk -v pi_file="$temp_pi" -v lbs_file="$temp_lbs" ' + # If the whole line is {{ PROFILE_INSTALLATIONS }}, print file content and skip + /^[[:space:]]*\{\{[[:space:]]*PROFILE_INSTALLATIONS[[:space:]]*\}\}[[:space:]]*$/ { + while ((getline line < pi_file) > 0) print line + close(pi_file) + next + } + # If the whole line is {{ LABELS }}, print file content and skip + /^[[:space:]]*\{\{[[:space:]]*LABELS[[:space:]]*\}\}[[:space:]]*$/ { + while ((getline line < lbs_file) > 0) print line + close(lbs_file) + next + } # Otherwise, print the line unchanged { print } ' <<<"$base_dockerfile") || error "Failed to apply Dockerfile substitutions" + + # Clear trap and cleanup temp files + trap - EXIT INT TERM + rm -f "$temp_pi" "$temp_lbs" # Guard: ensure no unreplaced placeholders remain - if grep -q '{{PROFILE_INSTALLATIONS}}' <<<"$final_dockerfile" grep -q '{{LABELS}}' <<<"$final_dockerfile"; then - error "Unreplaced placeholders remain in generated Dockerfile" + if grep -q '{{PROFILE_INSTALLATIONS}}' <<<"$final_dockerfile" || grep -q '{{LABELS}}' <<<"$final_dockerfile"; then + error "Unreplaced placeholders remain in generated Dockerfile" fi printf '%s' "$final_dockerfile" > "$dockerfile"