From 9673b35fcedbb1c81ff4065802a7e1598ba1e96e Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:04:21 +0530 Subject: [PATCH 1/2] fix: honor explicit application color modes --- docs/v2-api-contract.md | 2 +- lib/bash/app/README.md | 6 ++-- lib/bash/app/lib_app.sh | 1 + lib/bash/app/tests/lib_app.bats | 29 +++++++++++++++++ lib/bash/std/README.md | 7 ++-- lib/bash/std/lib_std.sh | 17 ++++++++-- tests/reference-apps.bats | 58 +++++++++++++++++++++++++++++++++ tests/vendor.bats | 11 +++++++ 8 files changed, 124 insertions(+), 7 deletions(-) diff --git a/docs/v2-api-contract.md b/docs/v2-api-contract.md index 739bd91..2a4cfc9 100644 --- a/docs/v2-api-contract.md +++ b/docs/v2-api-contract.md @@ -174,7 +174,7 @@ argv. This mapping is the v2 migration reference: | `--debug-wrapper` | Enables DEBUG logging and is removed from application argv. | | `--verbose-wrapper` | Preserves the deprecated VERBOSE compatibility level and is removed from application argv. | | `--utc-wrapper` | Exports UTC logging for the initialized runtime and is removed from application argv. | -| `--color` | Requests terminal colors and is removed from application argv. | +| `--color` | Requests automatic terminal colors (stderr TTY and `NO_COLOR` unset) and is removed from application argv. | | `base_init --` | Stops wrapper parsing; the separator and all following values remain literal application argv. (The launcher's own script-selection `--` is not forwarded.) | No other launcher option is implicitly forwarded. Applications that need an diff --git a/lib/bash/app/README.md b/lib/bash/app/README.md index 08264a0..d3fa403 100644 --- a/lib/bash/app/README.md +++ b/lib/bash/app/README.md @@ -55,8 +55,10 @@ zero. No configuration value is evaluated as shell code. `--config`, and `--user-config` options to a declarative CLI model. `--color` accepts `auto`, `always`, or `never`; the bare legacy launcher `--color` flag remains supported when it is not followed by one of those -modes. `--quiet` sets the default logger threshold to `WARN`, while -`--verbose` sets it to `DEBUG`; the options are mutually exclusive. +modes. `auto` colors only when stderr is a terminal and `NO_COLOR` is unset; +`always` forces ANSI colors even for captured stderr or a set `NO_COLOR`, and +`never` disables them. `--quiet` sets the default logger threshold to `WARN`, +while `--verbose` sets it to `DEBUG`; the options are mutually exclusive. `base_app_apply_standard_options` publishes the parsed policy in `BASE_BASH_LIBS_APP_*` globals and applies the logging/color policy. Applications should call diff --git a/lib/bash/app/lib_app.sh b/lib/bash/app/lib_app.sh index 59724fe..07d5adb 100644 --- a/lib/bash/app/lib_app.sh +++ b/lib/bash/app/lib_app.sh @@ -710,6 +710,7 @@ base_app_apply_standard_options() { BASE_BASH_LIBS_APP_NONINTERACTIVE="${BASE_BASH_LIBS_CLI_RESULT_OPTIONS[noninteractive]-0}" value="${BASE_BASH_LIBS_CLI_RESULT_OPTIONS[color]-auto}" BASE_BASH_LIBS_APP_COLOR="$value" + __base_bash_libs_std_color_mode="$value" if [[ -n "${BASE_BASH_LIBS_STD_LOG_LEVELS[INFO]+set}" ]]; then case "${BASE_BASH_LIBS_APP_QUIET}:${BASE_BASH_LIBS_APP_VERBOSE}" in 1:0) base_std_set_log_level WARN || return $? ;; diff --git a/lib/bash/app/tests/lib_app.bats b/lib/bash/app/tests/lib_app.bats index 4ece5f1..a224ab3 100644 --- a/lib/bash/app/tests/lib_app.bats +++ b/lib/bash/app/tests/lib_app.bats @@ -379,6 +379,35 @@ assert_demo_snapshot() { [ "${BASE_BASH_LIBS_STD_LOGGER_LEVELS[default]}" -eq 4 ] } +@test "standard color modes distinguish auto always and never" { + local stderr_file="$TEST_TMPDIR/color.stderr" + local -a init_args=() + + base_init init_args -- + base_cli_model_init color name=color + base_cli_command color run "Run" + base_app_init color_policy name=color + base_app_add_standard_options color run + + unset NO_COLOR + base_cli_parse color -- run --color auto + base_app_apply_standard_options color_policy + base_std_print_error auto 2>"$stderr_file" + [[ "$(<"$stderr_file")" != *$'\033['* ]] + + export NO_COLOR=1 + base_cli_parse color -- run --color always + base_app_apply_standard_options color_policy + base_std_print_error always 2>"$stderr_file" + [[ "$(<"$stderr_file")" == *$'\033['* ]] + + base_cli_parse color -- run --color never + base_app_apply_standard_options color_policy + base_std_print_error never 2>"$stderr_file" + [[ "$(<"$stderr_file")" != *$'\033['* ]] + unset NO_COLOR +} + @test "base_app_prompt validates usage and applies the prompt policy contract" { local prompt_calls=() base_app_init prompt diff --git a/lib/bash/std/README.md b/lib/bash/std/README.md index b8da776..1e78941 100644 --- a/lib/bash/std/README.md +++ b/lib/bash/std/README.md @@ -419,8 +419,11 @@ base_std_print_message "plain stdout message" `log_*`, `base_std_print_error`, `base_std_print_warn`, `base_std_print_info`, and `base_std_print_success` write to stderr. `base_std_print_bold` and `base_std_print_message` write to stdout. -Colors are only enabled for terminal stderr when `--color` is passed. Set -`NO_COLOR` to disable colored output even when `--color` is present. +The legacy `base_init --color` control requests automatic color and only enables +it when stderr is a terminal and `NO_COLOR` is unset. Applications using +`base_app_add_standard_options` can select `auto`, `always`, or `never`: +`always` explicitly forces ANSI output even when stderr is captured or +`NO_COLOR` is set; `never` disables it. ## Error Handling diff --git a/lib/bash/std/lib_std.sh b/lib/bash/std/lib_std.sh index e72a54f..70274d6 100644 --- a/lib/bash/std/lib_std.sh +++ b/lib/bash/std/lib_std.sh @@ -471,6 +471,7 @@ __base_bash_libs_std_initialize_runtime_state__() { __base_bash_libs_std_log_init__ declare -g BASE_BASH_LIBS_STD_COLOR_ENABLED=0 + declare -g __base_bash_libs_std_color_mode=auto declare -ga __base_bash_libs_std_cleanup_hooks=() declare -ga __base_bash_libs_std_cleanup_paths=() declare -ga __base_bash_libs_std_cleanup_entries=() @@ -1102,8 +1103,20 @@ __base_bash_libs_std_print_log_record__() { # This is called from base_init. # __base_bash_libs_std_init_colors__() { - # If --color was not passed, NO_COLOR is set, or the log stream is not a terminal, disable colors. - if [[ "$BASE_BASH_LIBS_STD_COLOR_ENABLED" != 1 || -n "${NO_COLOR+x}" || ! -t 2 ]]; then + local __base_bash_libs_std_colors_enabled=0 + + case "${__base_bash_libs_std_color_mode:-auto}" in + always) + __base_bash_libs_std_colors_enabled=1 + ;; + auto) + if [[ "$BASE_BASH_LIBS_STD_COLOR_ENABLED" == 1 && -z "${NO_COLOR+x}" && -t 2 ]]; then + __base_bash_libs_std_colors_enabled=1 + fi + ;; + never) ;; + esac + if ((__base_bash_libs_std_colors_enabled == 0)); then BASE_BASH_LIBS_STD_COLOR_BOLD="" BASE_BASH_LIBS_STD_COLOR_RED="" BASE_BASH_LIBS_STD_COLOR_GREEN="" diff --git a/tests/reference-apps.bats b/tests/reference-apps.bats index cd2c5e7..add0f7e 100644 --- a/tests/reference-apps.bats +++ b/tests/reference-apps.bats @@ -6,6 +6,27 @@ setup() { export BASE_BASH_LAUNCHER="$repo_root/bin/base-bash" } +capture_app_streams() { + local stdout_path="$1" stderr_path="$2" + shift 2 + if "$@" >"$stdout_path" 2>"$stderr_path"; then + status=0 + else + status=$? + fi +} + +run_tty_command() { + local command_line + command -v script >/dev/null 2>&1 || skip "The 'script' command is required for tty tests." + if script --version >/dev/null 2>&1; then + printf -v command_line '%q ' "$@" + run script -q -e -c "${command_line% }" /dev/null + else + run script -q /dev/null "$@" + fi +} + @test "reference application package passes launcher smoke" { run "$repo_root/examples/reference-apps/verify.sh" [ "$status" -eq 0 ] @@ -24,6 +45,43 @@ setup() { [[ "$output" == *"workspace="* ]] } +@test "reference app applies color and verbosity policy to real captured and PTY streams" { + local app="$repo_root/examples/reference-apps/ops-cli/bin/app" + local stdout_file="$BATS_TEST_TMPDIR/app.stdout" stderr_file="$BATS_TEST_TMPDIR/app.stderr" + local escaped + + capture_app_streams "$stdout_file" "$stderr_file" env NO_COLOR=1 \ + PATH="$repo_root/bin:$PATH" BASE_BASH_LIBS_DIR="$repo_root/lib/bash" \ + "$app" --color always --verbose status + [ "$status" -eq 0 ] + [[ "$(<"$stdout_file")" == *"workspace="* ]] + [[ "$(<"$stderr_file")" == *"DEBUG"* ]] + escaped=$'\033[' + [[ "$(<"$stderr_file")" == *"$escaped"* ]] + + capture_app_streams "$stdout_file" "$stderr_file" env -u NO_COLOR \ + PATH="$repo_root/bin:$PATH" BASE_BASH_LIBS_DIR="$repo_root/lib/bash" \ + "$app" --color auto --verbose status + [ "$status" -eq 0 ] + [[ "$(<"$stderr_file")" == *"DEBUG"* ]] + [[ "$(<"$stderr_file")" != *"$escaped"* ]] + + capture_app_streams "$stdout_file" "$stderr_file" env -u NO_COLOR \ + PATH="$repo_root/bin:$PATH" BASE_BASH_LIBS_DIR="$repo_root/lib/bash" \ + "$app" --quiet status + [ "$status" -eq 0 ] + [[ "$(<"$stderr_file")" != *"DEBUG"* && "$(<"$stderr_file")" != *"INFO"* ]] + + run_tty_command env -u NO_COLOR PATH="$repo_root/bin:$PATH" \ + BASE_BASH_LIBS_DIR="$repo_root/lib/bash" "$app" --color auto --verbose status + [ "$status" -eq 0 ] + [[ "$output" == *"$escaped"* ]] + + run env PATH="$repo_root/bin:$PATH" BASE_BASH_LIBS_DIR="$repo_root/lib/bash" \ + "$app" status -- --color always + [ "$status" -eq 2 ] +} + @test "reference application failure suites remain green" { for app in installer release-helper ops-cli; do run bats "$repo_root/examples/reference-apps/$app/tests/app.bats" diff --git a/tests/vendor.bats b/tests/vendor.bats index e83478b..585238d 100644 --- a/tests/vendor.bats +++ b/tests/vendor.bats @@ -82,6 +82,8 @@ SCRIPT } @test "standalone bundle contains its own launcher and vendored framework" { + local stdout_file="$TEST_TMPDIR/standalone.stdout" stderr_file="$TEST_TMPDIR/standalone.stderr" + bats_run "$BASE_REPO_ROOT/scripts/vendor" standalone "$application" "$framework_bundle" "$standalone" [ "$status" -eq 0 ] [ -x "$standalone/bin/base-bash" ] @@ -99,6 +101,15 @@ SCRIPT bats_run env PATH="$standalone/bin:$PATH" "$standalone/bin/app" run [ "$status" -eq 0 ] [[ "$output" == *"hello=world"* ]] + if env NO_COLOR=1 PATH="$standalone/bin:$PATH" "$standalone/bin/app" \ + run --color always --verbose >"$stdout_file" 2>"$stderr_file"; then + status=0 + else + status=$? + fi + [ "$status" -eq 0 ] + [[ "$(<"$stdout_file")" == *"hello=world"* ]] + [[ "$(<"$stderr_file")" == *$'\033['* ]] local expected_version expected_version="$(<"$BASE_REPO_ROOT/VERSION")" bats_run env PATH="$standalone/bin:$PATH" "$standalone/bin/base-bash" --version From 4c4978c7fcf51fbe9e0c8b9b255ad1744117f884 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:41:07 +0530 Subject: [PATCH 2/2] fix(std): enforce explicit color policy consistently --- base_api_manifest.yaml | 2 +- docs/v2-api-contract.md | 3 +- lib/bash/app/README.md | 6 ++- lib/bash/app/lib_app.sh | 33 +++++-------- lib/bash/app/tests/lib_app.bats | 38 +++++++++++++++ lib/bash/std/README.md | 7 ++- lib/bash/std/lib_std.sh | 82 +++++++++++++++++++++++++++------ lib/bash/std/tests/lib_std.bats | 50 +++++++++++++++++++- 8 files changed, 180 insertions(+), 41 deletions(-) diff --git a/base_api_manifest.yaml b/base_api_manifest.yaml index 8b9504e..a07a3fa 100644 --- a/base_api_manifest.yaml +++ b/base_api_manifest.yaml @@ -24,7 +24,7 @@ environment: - BASE_BASH_LIBS_LOG_DEBUG|caller-control|enable debug diagnostics - BASE_BASH_LIBS_LOG_UTC|caller-control|render log timestamps in UTC - BASE_BASH_LIBS_GIT_PULL_MAX_ATTEMPTS|caller-control|maximum git pull attempts - - NO_COLOR|caller-input|disable color output when set + - NO_COLOR|caller-input|disable automatic color output when set; explicit always mode overrides it - TMPDIR|caller-input|temporary directory policy - GH_TOKEN|caller-input|GitHub CLI authentication input - TZ|caller-input|caller-selected timezone for diagnostics diff --git a/docs/v2-api-contract.md b/docs/v2-api-contract.md index 2a4cfc9..b466aad 100644 --- a/docs/v2-api-contract.md +++ b/docs/v2-api-contract.md @@ -174,7 +174,8 @@ argv. This mapping is the v2 migration reference: | `--debug-wrapper` | Enables DEBUG logging and is removed from application argv. | | `--verbose-wrapper` | Preserves the deprecated VERBOSE compatibility level and is removed from application argv. | | `--utc-wrapper` | Exports UTC logging for the initialized runtime and is removed from application argv. | -| `--color` | Requests automatic terminal colors (stderr TTY and `NO_COLOR` unset) and is removed from application argv. | +| `--color` | Requests automatic terminal colors (stderr TTY and `NO_COLOR` unset) and is removed from application argv. A `--color MODE` pair remains available to the app's own parser. | +| `--color-mode MODE` | Selects `auto`, `always`, or `never` for wrappers that do not use the standard app-option model; removed from application argv and takes precedence over app-level `--color`. `always` overrides `NO_COLOR`. | | `base_init --` | Stops wrapper parsing; the separator and all following values remain literal application argv. (The launcher's own script-selection `--` is not forwarded.) | No other launcher option is implicitly forwarded. Applications that need an diff --git a/lib/bash/app/README.md b/lib/bash/app/README.md index d3fa403..5c0faed 100644 --- a/lib/bash/app/README.md +++ b/lib/bash/app/README.md @@ -54,8 +54,10 @@ zero. No configuration value is evaluated as shell code. `--verbose`, `--quiet`, `--color`, `--dry-run`, `--non-interactive`, `--config`, and `--user-config` options to a declarative CLI model. `--color` accepts `auto`, `always`, or `never`; the bare legacy launcher -`--color` flag remains supported when it is not followed by one of those -modes. `auto` colors only when stderr is a terminal and `NO_COLOR` is unset; +`--color` flag remains supported, and wrappers can use `--color-mode MODE` +when they need an explicit policy independent of the app's own CLI model. +An explicit wrapper mode takes precedence over the app's `--color` selection. +`auto` colors only when stderr is a terminal and `NO_COLOR` is unset; `always` forces ANSI colors even for captured stderr or a set `NO_COLOR`, and `never` disables them. `--quiet` sets the default logger threshold to `WARN`, while `--verbose` sets it to `DEBUG`; the options are mutually exclusive. diff --git a/lib/bash/app/lib_app.sh b/lib/bash/app/lib_app.sh index 07d5adb..a3e5efa 100644 --- a/lib/bash/app/lib_app.sh +++ b/lib/bash/app/lib_app.sh @@ -677,15 +677,16 @@ base_app_config_report() { # base_app_add_standard_options - Opts a CLI model into common policy options. base_app_add_standard_options() { - local cli_model="${1-}" path="${2-}" + local cli_model="${1-}" path="${2-}" color_modes (($# == 2)) || { __base_bash_libs_app_error__ 'base_app_add_standard_options: usage: base_app_add_standard_options CLI_MODEL COMMAND_PATH' return 2 } + color_modes="$(__base_bash_libs_std_color_modes__)" || return $? base_cli_option "$cli_model" "$path" verbose flag --verbose -v help='Enable verbose diagnostics' || return $? base_cli_option "$cli_model" "$path" quiet flag --quiet -q conflicts=verbose help='Suppress informational output' || return $? - base_cli_option "$cli_model" "$path" color value --color default=auto enum=auto,always,never metavar=MODE help='Color policy' || return $? + base_cli_option "$cli_model" "$path" color value --color default=auto enum="$color_modes" metavar=MODE help='Color policy' || return $? base_cli_option "$cli_model" "$path" dry_run flag --dry-run help='Plan without mutating' || return $? base_cli_option "$cli_model" "$path" noninteractive flag --non-interactive help='Never prompt' || return $? base_cli_option "$cli_model" "$path" config value --config metavar=FILE help='Use a project configuration file' || return $? @@ -694,13 +695,19 @@ base_app_add_standard_options() { # base_app_apply_standard_options - Publishes parsed common options as policy state. base_app_apply_standard_options() { - local model="${1-}" value + local model="${1-}" value cli_color (($# == 1)) || { __base_bash_libs_app_error__ 'base_app_apply_standard_options: usage: base_app_apply_standard_options MODEL' return 2 } __base_bash_libs_app_model_exists__ "$model" || return 1 + cli_color="${BASE_BASH_LIBS_CLI_RESULT_OPTIONS[color]-auto}" + if ! __base_bash_libs_std_color_mode_is_valid__ "$cli_color"; then + __base_bash_libs_app_error__ "base_app_apply_standard_options: invalid color mode '$cli_color'." + return 2 + fi + value="${__base_bash_libs_std_wrapper_color_mode:-$cli_color}" # These are caller-visible policy globals consumed by application code. # shellcheck disable=SC2034 BASE_BASH_LIBS_APP_VERBOSE="${BASE_BASH_LIBS_CLI_RESULT_OPTIONS[verbose]-0}" @@ -708,9 +715,6 @@ base_app_apply_standard_options() { BASE_BASH_LIBS_APP_QUIET="${BASE_BASH_LIBS_CLI_RESULT_OPTIONS[quiet]-0}" BASE_BASH_LIBS_APP_DRY_RUN="${BASE_BASH_LIBS_CLI_RESULT_OPTIONS[dry_run]-0}" BASE_BASH_LIBS_APP_NONINTERACTIVE="${BASE_BASH_LIBS_CLI_RESULT_OPTIONS[noninteractive]-0}" - value="${BASE_BASH_LIBS_CLI_RESULT_OPTIONS[color]-auto}" - BASE_BASH_LIBS_APP_COLOR="$value" - __base_bash_libs_std_color_mode="$value" if [[ -n "${BASE_BASH_LIBS_STD_LOG_LEVELS[INFO]+set}" ]]; then case "${BASE_BASH_LIBS_APP_QUIET}:${BASE_BASH_LIBS_APP_VERBOSE}" in 1:0) base_std_set_log_level WARN || return $? ;; @@ -725,21 +729,8 @@ base_app_apply_standard_options() { __base_bash_libs_app_error__ 'base_app_apply_standard_options: quiet and verbose cannot both be enabled.' return 2 fi - case "$value" in - auto | always) - # shellcheck disable=SC2034 - BASE_BASH_LIBS_STD_COLOR_ENABLED=1 - ;; - never) - # shellcheck disable=SC2034 - BASE_BASH_LIBS_STD_COLOR_ENABLED=0 - ;; - *) - __base_bash_libs_app_error__ "base_app_apply_standard_options: invalid color mode '$value'." - return 2 - ;; - esac - __base_bash_libs_std_init_colors__ + __base_bash_libs_std_apply_color_mode__ "$value" || return $? + BASE_BASH_LIBS_APP_COLOR="$value" BASE_BASH_LIBS_DRY_RUN="$BASE_BASH_LIBS_APP_DRY_RUN" export BASE_BASH_LIBS_APP_DRY_RUN BASE_BASH_LIBS_APP_NONINTERACTIVE BASE_BASH_LIBS_APP_COLOR export BASE_BASH_LIBS_DRY_RUN diff --git a/lib/bash/app/tests/lib_app.bats b/lib/bash/app/tests/lib_app.bats index a224ab3..74f23c6 100644 --- a/lib/bash/app/tests/lib_app.bats +++ b/lib/bash/app/tests/lib_app.bats @@ -408,6 +408,44 @@ assert_demo_snapshot() { unset NO_COLOR } +@test "invalid standard color policy fails before publishing color state" { + local status + + base_cli_model_init invalid_color name=invalid-color + base_cli_command invalid_color run "Run" + base_app_init invalid_color_policy + base_cli_parse invalid_color -- run + __base_bash_libs_std_color_mode=never + BASE_BASH_LIBS_APP_COLOR=never + BASE_BASH_LIBS_CLI_RESULT_OPTIONS[color]=unsupported + + if base_app_apply_standard_options invalid_color_policy >/dev/null 2>"$TEST_TMPDIR/color-error"; then + status=0 + else + status=$? + fi + + [ "$status" -eq 2 ] + [ "$__base_bash_libs_std_color_mode" = never ] + [ "$BASE_BASH_LIBS_APP_COLOR" = never ] + grep -F "invalid color mode 'unsupported'" "$TEST_TMPDIR/color-error" +} + +@test "explicit wrapper color mode overrides the app's standard color option" { + base_cli_model_init wrapper_color name=wrapper-color + base_cli_command wrapper_color run "Run" + base_app_init wrapper_color_policy + base_app_add_standard_options wrapper_color run + base_cli_parse wrapper_color -- run --color always + __base_bash_libs_std_wrapper_color_mode=never + + base_app_apply_standard_options wrapper_color_policy + + [ "$BASE_BASH_LIBS_APP_COLOR" = never ] + [ "$__base_bash_libs_std_color_mode" = never ] + [ -z "$BASE_BASH_LIBS_STD_COLOR_RED" ] +} + @test "base_app_prompt validates usage and applies the prompt policy contract" { local prompt_calls=() base_app_init prompt diff --git a/lib/bash/std/README.md b/lib/bash/std/README.md index 1e78941..7a498d8 100644 --- a/lib/bash/std/README.md +++ b/lib/bash/std/README.md @@ -420,7 +420,12 @@ base_std_print_message "plain stdout message" stderr. `base_std_print_bold` and `base_std_print_message` write to stdout. The legacy `base_init --color` control requests automatic color and only enables -it when stderr is a terminal and `NO_COLOR` is unset. Applications using +it when stderr is a terminal and `NO_COLOR` is unset. Wrappers can use +`base_init --color-mode auto|always|never` to select an explicit standard +color policy; this explicit wrapper setting takes precedence over an app-level +`--color MODE`. `always` overrides `NO_COLOR`, and `never` disables color. +The application's own `--color MODE` pair remains untouched for its parser. +Arguments after the application's separator remain application-owned. Applications using `base_app_add_standard_options` can select `auto`, `always`, or `never`: `always` explicitly forces ANSI output even when stderr is captured or `NO_COLOR` is set; `never` disables it. diff --git a/lib/bash/std/lib_std.sh b/lib/bash/std/lib_std.sh index 70274d6..66b006f 100644 --- a/lib/bash/std/lib_std.sh +++ b/lib/bash/std/lib_std.sh @@ -77,7 +77,7 @@ # Notes: # - Call base_init [--source