Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
56 changes: 56 additions & 0 deletions lib/bash/std/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down
194 changes: 194 additions & 0 deletions lib/bash/std/lib_std.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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] <seconds> 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] <seconds> 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 ############################################

#
Expand Down Expand Up @@ -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 <result_variable_name> <command_name>"
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.
#
Expand Down
Loading
Loading