diff --git a/CHANGELOG.md b/CHANGELOG.md index 84cca27..747c227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ and versions are tracked in the repo-root `VERSION` file. `EXIT` trap. - Added portable stdlib temporary file and directory helpers with default exit cleanup. +- Added stdlib command path and function introspection helpers. +- Added `std_run_with_timeout` for bounded command execution with macOS/Linux + fallback behavior. ## [0.2.1] - 2026-06-18 diff --git a/lib/bash/std/README.md b/lib/bash/std/README.md index 8645dd4..527c0d1 100644 --- a/lib/bash/std/README.md +++ b/lib/bash/std/README.md @@ -19,12 +19,16 @@ The library improves Bash-based scripting in a few practical ways: instead of a mysterious non-zero exit. - **Safe command execution**: `std_run` preserves argument boundaries, supports dry-run mode, and can either exit or return a status. +- **Bounded command execution**: `std_run_with_timeout` applies the same command + runner conventions with a timeout. - **Shared dry-run behavior**: scripts do not need to reimplement "print what would happen" logic. - **Composable cleanup**: scripts can register exit cleanup without replacing an already-installed `EXIT` trap. - **Portable temp state**: scripts can create temp files or directories under `TMPDIR` and register them for cleanup in one call. +- **Non-fatal introspection**: scripts can resolve command paths and check + function availability without turning every probe into a hard exit. - **Simple library imports**: scripts can import helpers relative to their own source directory. - **Predictable PATH edits**: PATH additions avoid duplicates and can prepend or @@ -203,6 +207,26 @@ in the calling script so the code remains clear. code should use `std_run` to avoid collisions with test frameworks and other Bash libraries that define their own `run` helper. +Use `std_run_with_timeout` when a command must finish within a bounded number of +seconds: + +```bash +std_run_with_timeout 30 curl -fsSL "$health_url" +``` + +It accepts the same initial `--no-exit` and `--quiet` options as `std_run`: + +```bash +if ! std_run_with_timeout --no-exit --quiet 5 nc -z localhost 5432; then + log_warn "database port did not open within 5 seconds" +fi +``` + +Timeouts return status `124`. The helper prefers `timeout` or `gtimeout` when +available and otherwise uses a Bash fallback so scripts work on macOS and Linux. +As with `std_run`, command arguments are executed as an argument array and +dry-run mode logs without running the command. + ## Importing Other Bash Libraries Use `import` to source helper libraries: @@ -322,6 +346,36 @@ The optional prefix is a filename prefix, not a directory path. It must be non-empty and must not contain `/`. Set `TMPDIR` before calling the helper when the temp root should be somewhere other than `/tmp`. +## Introspection Helpers + +Use `std_command_path` when a script needs the path to an external command but +wants to decide what to do if it is absent: + +```bash +if std_command_path git_path git; then + std_run "$git_path" status --short +else + log_warn "git is not available; skipping repository status." +fi +``` + +The helper stores an executable path in the named result variable and returns +nonzero with an empty result when the command is not found. + +Use `std_function_exists` for predicate-style checks: + +```bash +if std_function_exists cleanup_workspace; then + std_register_cleanup_hook cleanup_workspace +fi +``` + +Use `assert_function_exists` when missing functions should be fatal: + +```bash +assert_function_exists main cleanup_workspace +``` + ## Validation Helpers Use assertions near the top of functions to make assumptions explicit: @@ -332,6 +386,7 @@ assert_not_null BASE_HOME project_name assert_integer retry_count assert_integer_range retry_count 0 5 assert_command_exists git brew +assert_function_exists main cleanup_workspace assert_file_exists "$manifest_path" assert_executable "$project_root/bin/build" assert_dir_exists "$project_root" @@ -422,6 +477,7 @@ main "$@" - simple filesystem safety wrappers - exit cleanup registration - temporary file and directory creation +- command and function introspection Domain-specific behavior should live in other libraries or command modules. For example, Git helpers belong in a Git library, file editing helpers belong in a diff --git a/lib/bash/std/lib_std.sh b/lib/bash/std/lib_std.sh index 29afd27..008bcf0 100644 --- a/lib/bash/std/lib_std.sh +++ b/lib/bash/std/lib_std.sh @@ -30,12 +30,16 @@ # Core helpers: # std_run [--no-exit] [--quiet] cmd ... # # Safe command runner with dry-run & failure handling. +# std_run_with_timeout [opts] seconds cmd ... +# # Safe command runner with a timeout. # exit_if_error rc msg... # Log + exit when rc != 0 (preserves original status). # fatal_error msg... # Convenience wrapper: exit with last status or 1. # std_register_cleanup_hook fn # Run a cleanup function from the shared EXIT trap. # std_register_cleanup_path p # Remove files/directories from the shared EXIT trap. # std_make_temp_file var [pfx] # Create a temp file and store its path in var. # std_make_temp_dir var [pfx] # Create a temp directory and store its path in var. +# std_command_path var cmd # Resolve an external command path without exiting. +# std_function_exists fn # Predicate for defined Bash functions. # add_to_path [-n] [-p] dir # Append/prepend unique PATH entries. # set_log_level [LEVEL] # Adjust default logger (FATAL..VERBOSE). # log_info/debug/... msgs # Structured logging (color in interactive shells). @@ -824,6 +828,127 @@ run() { __std_run_impl__ run "$@" } +__std_sleep_interval__() { + if [[ -x /bin/sleep ]]; then + /bin/sleep "$1" + else + sleep "$1" + fi +} + +__std_run_with_timeout_fallback__() { + local timeout_seconds="$1" + shift + local timeout_marker command_pid timer_pid command_status + + timeout_marker="$(mktemp "${TMPDIR:-/tmp}/base-bash-libs-timeout.XXXXXXXXXX" 2>/dev/null)" || return 127 + + "$@" & + command_pid=$! + + ( + __std_sleep_interval__ "$timeout_seconds" + printf '1' > "$timeout_marker" + kill -TERM "$command_pid" 2>/dev/null || true + ) & + timer_pid=$! + + wait "$command_pid" + command_status=$? + + if kill -0 "$timer_pid" 2>/dev/null; then + kill "$timer_pid" 2>/dev/null || true + fi + wait "$timer_pid" 2>/dev/null || true + + if [[ -s "$timeout_marker" ]]; then + command_status=124 + fi + rm -f -- "$timeout_marker" + + return "$command_status" +} + +# +# std_run_with_timeout - Safely executes a command with a timeout. +# +# This helper mirrors `std_run` option handling while bounding the command +# runtime. It prefers `timeout` or `gtimeout` when available and otherwise uses +# a Bash fallback so callers have portable behavior on macOS and Linux. +# +# Usage: +# std_run_with_timeout [--no-exit] [--quiet] command [arg1] ... +# +std_run_with_timeout() { + local exit_on_failure=1 quiet=0 timeout_seconds timeout_path="" exit_code printable_command message + + while (($#)); do + case "${1-}" in + --no-exit) + exit_on_failure=0 + shift + ;; + --quiet) + quiet=1 + shift + ;; + --) + shift + break + ;; + *) + break + ;; + esac + done + + if (($# < 2)); then + log_error "std_run_with_timeout: usage: std_run_with_timeout [--no-exit] [--quiet] command [arg1] ..." + return 1 + fi + + timeout_seconds="$1" + shift + if [[ ! "$timeout_seconds" =~ ^[1-9][0-9]*$ ]]; then + log_error "std_run_with_timeout: timeout seconds must be a positive integer." + return 1 + fi + + printf -v printable_command "%q " "$@" + printable_command="${printable_command% }" + + if is_dry_run; then + log_info "[DRY-RUN] Would run with ${timeout_seconds}s timeout: ${printable_command}" + return 0 + fi + + if std_command_path timeout_path timeout || std_command_path timeout_path gtimeout; then + "$timeout_path" "$timeout_seconds" "$@" + else + __std_run_with_timeout_fallback__ "$timeout_seconds" "$@" + fi + exit_code=$? + + if ((exit_code)); then + if ((exit_code == 124)); then + message="Command timed out after ${timeout_seconds}s: ${printable_command}" + else + message="Command failed (exit $exit_code): ${printable_command}" + fi + + if ((exit_on_failure)); then + exit_if_error "$exit_code" "$message" + else + if ((! quiet)); then + log_warn "$message (continuing)." + fi + return "$exit_code" + fi + fi + + return 0 +} + ############################################## FILE AND DIRECTORY HANDLING ############################################ # @@ -1199,6 +1324,75 @@ __is_valid_variable_name__() { [[ "$var_name" =~ $var_name_re ]] } +##################################################### INTROSPECTION ################################################### + +# +# std_command_path - Resolves an external command path without exiting the caller. +# +# Usage: +# if std_command_path git_path git; then +# std_run "$git_path" status --short +# fi +# +std_command_path() { + local result_name="${1-}" command_name="${2-}" resolved_path="" + + if (($# != 2)); then + log_error "std_command_path: usage: std_command_path " + return 1 + fi + if ! __is_valid_variable_name__ "$result_name"; then + log_error "std_command_path: result variable name must be a valid Bash variable name." + return 1 + fi + + if [[ -n "$command_name" ]]; then + resolved_path="$(type -P "$command_name" 2>/dev/null || true)" + fi + printf -v "$result_name" '%s' "$resolved_path" + [[ -n "$resolved_path" ]] +} + +# +# std_function_exists - Checks whether a Bash function is currently defined. +# +std_function_exists() { + local function_name="${1-}" + + (($# == 1)) || return 1 + __is_valid_variable_name__ "$function_name" || return 1 + declare -F "$function_name" >/dev/null +} + +# +# assert_function_exists - Verifies that one or more Bash functions are defined. +# +# Usage: +# assert_function_exists main cleanup_workspace +# +assert_function_exists() { + local missing_functions=() function_name + + if (($# == 0)); then + fatal_error "assert_function_exists: No function names provided for validation." + fi + + for function_name in "$@"; do + if ! __is_valid_variable_name__ "$function_name"; then + fatal_error "assert_function_exists expects function names; one or more arguments are not valid Bash function names." + fi + if ! std_function_exists "$function_name"; then + missing_functions+=("$function_name") + fi + done + + if ((${#missing_functions[@]} > 0)); then + fatal_error "Required functions are not defined: ${missing_functions[*]}" + fi + + return 0 +} + # # assert_not_null - Checks that one or more variables are not empty. # diff --git a/lib/bash/std/tests/lib_std.bats b/lib/bash/std/tests/lib_std.bats index 3f7aa4c..fa62fe1 100644 --- a/lib/bash/std/tests/lib_std.bats +++ b/lib/bash/std/tests/lib_std.bats @@ -888,6 +888,91 @@ EOF [[ "$output" != *"after"* ]] } +@test "std_run_with_timeout runs commands and preserves arguments" { + local output_file="$TEST_TMPDIR/timeout-output.txt" + + std_run_with_timeout 5 bash -c 'printf "%s\n" "$1" > "$2"' _ "hello world" "$output_file" + + [ "$(cat "$output_file")" = "hello world" ] +} + +@test "std_run_with_timeout --no-exit returns 124 when the command times out" { + local stderr_file="$TEST_TMPDIR/timeout.err" + local rc + + if std_run_with_timeout --no-exit --quiet 1 sleep 2 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + + [ "$rc" -eq 124 ] + [ ! -s "$stderr_file" ] +} + +@test "std_run_with_timeout exits on command failure by default" { + local script="$TEST_TMPDIR/timeout-fail.sh" + + create_script "$script" < "$output_file" +EOF + + bats_run bash "$script" + + [ "$status" -eq 0 ] + [ "$(cat "$output_file")" = "fallback" ] +} + +@test "std_run_with_timeout rejects invalid timeouts" { + local stderr_file="$TEST_TMPDIR/timeout-invalid.err" + local rc + + if std_run_with_timeout --no-exit nope bash -c 'exit 0' 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + + [ "$rc" -eq 1 ] + [[ "$(cat "$stderr_file")" == *"std_run_with_timeout: timeout seconds must be a positive integer."* ]] +} + @test "run compatibility wrapper delegates to std_run behavior" { local stderr_file="$TEST_TMPDIR/run-compat.err" local rc @@ -1187,6 +1272,78 @@ EOF [[ "$(cat "$stderr_file")" == *"std_make_temp_dir: result variable name must be a valid Bash variable name."* ]] } +@test "std_command_path stores executable paths and returns nonzero for missing commands" { + local command_path="" + + std_command_path command_path bash + + [ -n "$command_path" ] + [ -x "$command_path" ] + + if std_command_path command_path "__base_missing_command__$RANDOM"; then + return 1 + fi + + [ "$command_path" = "" ] +} + +@test "std_command_path rejects invalid result variable names" { + local stderr_file="$TEST_TMPDIR/command-path.err" + local rc + + if std_command_path "not-valid" bash 2>"$stderr_file"; then + rc=0 + else + rc=$? + fi + + [ "$rc" -eq 1 ] + [[ "$(cat "$stderr_file")" == *"std_command_path: result variable name must be a valid Bash variable name."* ]] +} + +@test "std_function_exists checks defined Bash functions" { + local missing_name="__missing_function__$RANDOM" + + sample_introspection_function() { return 0; } + + std_function_exists sample_introspection_function + ! std_function_exists "$missing_name" + ! std_function_exists "not-valid" +} + +@test "assert_function_exists accepts defined functions and exits for missing ones" { + local script="$TEST_TMPDIR/assert-function-exists.sh" + + create_script "$script" <