diff --git a/lib/cli.sh b/lib/cli.sh index c70d574..6867ba9 100644 --- a/lib/cli.sh +++ b/lib/cli.sh @@ -20,18 +20,27 @@ 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 + # Initialize bucket arrays (local) host_flags=() control_flags=() script_command="" pass_through=() - + + # Initialize global CLI variables (must be set even if no args) + CLI_HOST_FLAGS=() + CLI_CONTROL_FLAGS=() + CLI_SCRIPT_COMMAND="" + CLI_PASS_THROUGH=() + + # Early return if no arguments + if [[ $# -eq 0 ]]; then + return 0 + fi + # Single parsing loop - each arg goes into exactly ONE bucket local found_script_command=false - - for arg in "${all_args[@]}"; do + + for arg in "$@"; do if [[ " ${HOST_ONLY_FLAGS[*]} " == *" $arg "* ]]; then # Bucket 1: Host-only flags host_flags+=("$arg") @@ -49,14 +58,20 @@ parse_cli_args() { done # Export results for use by main script - export CLI_HOST_FLAGS=("${host_flags[@]}") - export CLI_CONTROL_FLAGS=("${control_flags[@]}") - export CLI_SCRIPT_COMMAND="$script_command" - export CLI_PASS_THROUGH=("${pass_through[@]}") + # Arrays are always initialized above, so "${arr[@]}" is safe with set -u + CLI_HOST_FLAGS=("${host_flags[@]}") + CLI_CONTROL_FLAGS=("${control_flags[@]}") + CLI_SCRIPT_COMMAND="$script_command" + CLI_PASS_THROUGH=("${pass_through[@]}") } # Process host-only flags and set environment variables process_host_flags() { + # Handle empty array with set -u + if [[ ${#CLI_HOST_FLAGS[@]} -eq 0 ]]; then + return 0 + fi + for flag in "${CLI_HOST_FLAGS[@]}"; do case "$flag" in --verbose) @@ -125,11 +140,11 @@ requires_slot() { # Debug output for parsed arguments (only if VERBOSE=true) 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] Script command: ${CLI_SCRIPT_COMMAND}" >&2 - echo "[DEBUG] Pass-through: ${CLI_PASS_THROUGH[*]}" >&2 + printf '[DEBUG] CLI Parser Results:\n' >&2 + printf '[DEBUG] Host flags: %s\n' "${CLI_HOST_FLAGS[*]}" >&2 + printf '[DEBUG] Control flags: %s\n' "${CLI_CONTROL_FLAGS[*]}" >&2 + printf '[DEBUG] Script command: %s\n' "${CLI_SCRIPT_COMMAND}" >&2 + printf '[DEBUG] Pass-through: %s\n' "${CLI_PASS_THROUGH[*]}" >&2 fi } diff --git a/lib/commands.profile.sh b/lib/commands.profile.sh index 462d5d4..a11039f 100644 --- a/lib/commands.profile.sh +++ b/lib/commands.profile.sh @@ -32,12 +32,14 @@ _cmd_profiles() { 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 + 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} " diff --git a/lib/config.sh b/lib/config.sh index 640211a..cf802bb 100755 --- a/lib/config.sh +++ b/lib/config.sh @@ -130,7 +130,10 @@ read_profile_section() { done < <(sed -n "/^\[$section\]/,/^\[/p" "$profile_file" | tail -n +2 | grep -v '^\[') fi - printf '%s\n' "${result[@]}" + # Only print if array has elements + if [[ ${#result[@]} -gt 0 ]]; then + printf '%s\n' "${result[@]}" + fi } update_profile_section() { @@ -185,8 +188,11 @@ get_current_profiles() { [[ -n "$line" ]] && current_profiles+=("$line") done < <(read_profile_section "$profiles_file" "profiles") fi - - printf '%s\n' "${current_profiles[@]}" + + # Only print if array has elements + if [[ ${#current_profiles[@]} -gt 0 ]]; then + printf '%s\n' "${current_profiles[@]}" + fi } # -------- Profile installation functions for Docker builds ------------------- diff --git a/lib/docker.sh b/lib/docker.sh index 3e3fb50..71cb31a 100755 --- a/lib/docker.sh +++ b/lib/docker.sh @@ -296,11 +296,13 @@ 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 [[ ${#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 diff --git a/main.sh b/main.sh index 0246f35..a12e579 100755 --- a/main.sh +++ b/main.sh @@ -109,6 +109,7 @@ main() { if [[ ${#saved_flags[@]} -gt 0 ]]; then # Re-parse WITH saved flags, but the command structure is preserved # because the command was already identified from original args + # original_args is always initialized on line 79, so "${arr[@]}" is safe parse_cli_args "${original_args[@]}" "${saved_flags[@]}" process_host_flags @@ -137,6 +138,7 @@ main() { # If command doesn't need Docker, skip all Docker setup if [[ "$cmd_requirements" == "none" ]]; then # Dispatch the command directly and exit + # CLI arrays are always initialized by parse_cli_args, so "${arr[@]}" is safe dispatch_command "${CLI_SCRIPT_COMMAND}" "${CLI_PASS_THROUGH[@]}" "${CLI_CONTROL_FLAGS[@]}" exit $? fi @@ -352,7 +354,8 @@ main() { # Separate Python-only profiles from Docker-affecting profiles local docker_profiles=() local python_only_profiles=("python" "ml" "datascience") - + + 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 @@ -365,7 +368,8 @@ main() { docker_profiles+=("$profile") fi done - + fi + # Calculate hash only for Docker-affecting profiles local docker_profiles_hash="" if [[ ${#docker_profiles[@]} -gt 0 ]]; then @@ -455,9 +459,14 @@ main() { # Re-parse all arguments with saved flags included if [[ ${#saved_flags[@]} -gt 0 ]]; then - # Combine original args with saved flags - local all_args=("${original_args[@]}" "${saved_flags[@]}") - + # Combine original args with saved flags using append pattern + # to preserve argument boundaries safely + local all_args=() + if [[ ${#original_args[@]} -gt 0 ]]; then + all_args+=("${original_args[@]}") + fi + all_args+=("${saved_flags[@]}") + # Re-parse to properly sort flags parse_cli_args "${all_args[@]}" process_host_flags @@ -472,12 +481,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 @@ -535,35 +546,39 @@ build_docker_image() { while IFS= read -r line; do [[ -n "$line" ]] && current_profiles+=("$line") 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 - + 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 + + 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) @@ -586,22 +601,24 @@ 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 WHOLE lines that contain the placeholders (with optional spaces) + # Use environment variables for awk to handle multiline strings properly 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 } - # Otherwise, print the line unchanged - { print } - ' <<<"$base_dockerfile") || error "Failed to apply Dockerfile substitutions" + final_dockerfile=$( + PROFILE_INSTALLS="$profile_installations" \ + DOCKER_LABELS="$labels" \ + awk ' + # If the whole line is {{ PROFILE_INSTALLATIONS }}, print injected block and skip + /^[[:space:]]*\{\{[[:space:]]*PROFILE_INSTALLATIONS[[:space:]]*\}\}[[:space:]]*$/ { print ENVIRON["PROFILE_INSTALLS"]; next } + # If the whole line is {{ LABELS }}, print labels block and skip + /^[[:space:]]*\{\{[[:space:]]*LABELS[[:space:]]*\}\}[[:space:]]*$/ { print ENVIRON["DOCKER_LABELS"]; next } + # Otherwise, print the line unchanged + { print } + ' <<<"$base_dockerfile" + ) || error "Failed to apply Dockerfile substitutions" # 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"