diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a660a17..fc305a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,18 +9,29 @@ on: - synchronize jobs: ci: - runs-on: ubuntu-latest + strategy: + matrix: + os: + - ubuntu-latest + - macos-latest + runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 1 - - uses: actions/setup-node@v1 + - uses: actions/setup-node@v4 with: node-version: 18.x + - name: Install macOS test dependencies + if: runner.os == 'macOS' + run: | + brew install gnu-sed coreutils - name: Install run: | - corepack yarn + corepack enable + corepack yarn install --immutable - name: Lint + if: runner.os == 'Linux' env: SHELLCHECK_SEVERITY: warning run: | @@ -28,3 +39,13 @@ jobs: - name: Test run: | corepack yarn test + + ci-bash3-docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + - name: Test In Docker (Bash 3.2.57) + run: | + test/bash3-docker.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 12afb68..d2c0c31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,14 +6,23 @@ Here's is a combined todo/done list. You can see what todos are planned for the Unplanned. -- [ ] Better style guide checking (#84) - ## main Released: TBA. [Diff](https://github.com/kvz/bash3boilerplate/compare/2.7.2...main). -- [ ] +- [x] Add shfmt formatter (`shfmt -i 2 -bn`) with CI-enforced lint gate (#84) +- [x] Fix long-option parser edge cases: unknown options, missing values, invalid `--flag=value` on booleans +- [x] Scope strict mode in `src/*.sh` libraries to function execution via subshell bodies +- [x] Refactor `parse_url.sh` to be strict-mode-safe without grep pipelines +- [x] Add Dockerized Bash 3.2.57 test lane for local and CI +- [x] Add release-ready gate (`yarn release:ready`) with branch, CI, and changelog checks +- [x] Add acceptance scenarios: long-option errors, logging contracts, parse_url/ini_val/templater robustness +- [x] Add `yarn test:fast` for quick contract-focused subset +- [x] Clean up `main.sh` readability: consolidated shellcheck disables, consistent formatting, improved comments +- [x] Trim README from 374 to ~190 lines; move contributor content to new CONTRIBUTING.md +- [x] Enrich FAQ with entrypoint-vs-library guidance and strict-mode scoping +- [x] Add Linux + macOS CI matrix with macOS dependency installation ## 2.7.2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..58dcb4f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,58 @@ +# Contributing to BASH3 Boilerplate + +Please fork this repository, create a branch containing your suggested changes, and submit a pull request based on the `main` branch of . + +## Testing + +Run the regular test suite: + +```bash +yarn test +``` + +Run a fast contract-focused subset: + +```bash +yarn test:fast +``` + +Run the Bash 3.2.57 compatibility suite in Docker: + +```bash +yarn test:bash3:docker +``` + +Run all checks used for release confidence: + +```bash +yarn test:all +``` + +CI runs on Linux (`ubuntu-latest`), macOS (`macos-latest`, Bash 3.2), and a Docker Bash 3.2.57 lane. The native and Docker lanes are complementary and catch different classes of portability issues. + +## Coding standards + +These rules are CI-enforced for contributions to b3bp itself: + +1. Format with [shfmt](https://github.com/mvdan/sh) (`shfmt -i 2 -bn`): two-space indent, binary operators may start a line. Run `yarn fix:shfmt` to auto-format. +1. Do not introduce trailing whitespace on lines. +1. Use a single equal sign when checking `if [[ "${NAME}" = "Kevin" ]]`. +1. Use the bash test operator (`[[ ... ]]`) rather than `[` or `test`. +1. Use braces around variable expansions: `${VAR}`. +1. Keep ShellCheck clean at `warning` severity or above. + +## Releasing + +Before running a release command (`yarn release:patch`, `yarn release:minor`, `yarn release:major`): + +1. Ensure you are on `main` with a clean working tree. +1. Ensure CI checks for `HEAD` on `main` are green. +1. Update `CHANGELOG.md` `## main` section with completed checklist entries (`- [x]`) and no remaining open checklist items (`- [ ]`). + +Automated gate: + +```bash +yarn release:ready +``` + +The `release` script runs this gate automatically before tagging/publishing. diff --git a/FAQ.md b/FAQ.md index fb38dc2..6565bb4 100644 --- a/FAQ.md +++ b/FAQ.md @@ -6,6 +6,8 @@ - [What is a CLI](#what-is-a-cli)? - [How do I incorporate BASH3 Boilerplate into my own project](#how-do-i-incorporate-bash3-boilerplate-into-my-own-project)? +- [What is the difference between an entrypoint script and a library script](#what-is-the-difference-between-an-entrypoint-script-and-a-library-script)? +- [When should I use export -f](#when-should-i-use-export--f)? - [How do I add a command-line flag](#how-do-i-add-a-command-line-flag)? - [How do I access the value of a command-line argument](#how-do-i-access-the-value-of-a-command-line-argument)? - [What is a magic variable](#what-is-a-magic-variable)? @@ -16,7 +18,7 @@ - [How do I do Operating System detection](#how-do-i-do-operating-system-detection)? - [How do I access a potentially unset (environment) variable](#how-do-i-access-a-potentially-unset-environment-variable)? - [How can I detect or trap CTRL-C and other signals](#how-can-i-detect-or-trap-ctrl-c-and-other-signals)? -- [How can I get the PID of my running script](how-can-i-get-the-pid-of-my-running-script)? +- [How can I get the PID of my running script](#how-can-i-get-the-pid-of-my-running-script)? @@ -30,28 +32,41 @@ A "CLI" is a [command-line interface](https://en.wikipedia.org/wiki/Command-line You can incorporate BASH3 Boilerplate into your project in one of two ways: -1. Copy the desired portions of [`main.sh`](http://bash3boilerplate.sh/main.sh) into your own script. -1. Download [`main.sh`](http://bash3boilerplate.sh/main.sh) and start pressing the delete-key to remove unwanted things +1. Copy the desired portions of [`main.sh`](https://bash3boilerplate.sh/main.sh) into your own script. +1. Download [`main.sh`](https://bash3boilerplate.sh/main.sh) and start pressing the delete-key to remove unwanted things Once the `main.sh` has been tailor-made for your project, you can either append your own script in the same file, or source it in the following ways: -1. Copy [`main.sh`](http://bash3boilerplate.sh/main.sh) into the same directory as your script and then edit and embed it into your script using Bash's `source` include feature, e.g.: +1. Copy [`main.sh`](https://bash3boilerplate.sh/main.sh) into the same directory as your script and then edit and embed it into your script using Bash's `source` include feature, e.g.: ```bash #!/usr/bin/env bash source main.sh ``` -1. Source [`main.sh`](http://bash3boilerplate.sh/main.sh) in your script or at the command line: +1. Source [`main.sh`](https://bash3boilerplate.sh/main.sh) in your script or at the command line: ```bash #!/usr/bin/env bash source main.sh ``` +## What is the difference between an entrypoint script and a library script? + +In b3bp, these are two different archetypes: + +1. **Entrypoint scripts** (`main.sh` style) own CLI parsing and process lifecycle. They may enable strict options (`set -o errexit`, etc.) at top level and may call `exit`. +1. **Library scripts** (`src/*.sh`) are safe to source and should avoid top-level side effects. They should scope strict mode to function execution (using a subshell body `()`) so parent shell options are not mutated, and communicate failures through return codes rather than `exit`. + +## When should I use export -f? + +Use `export -f` only when child Bash processes must inherit a function. + +If you only need functions in the current shell after sourcing, `export -f` is not required. + ## How do I add a command-line flag? -1. Copy the line from the `main.sh` [read block](https://github.com/kvz/bash3boilerplate/blob/v2.1.0/main.sh#L109-L115) that most resembles the desired behavior and paste the line into the same block. +1. Copy the line from the `__usage` block in `main.sh` that most resembles the desired behavior and paste the line into the same block. 1. Edit the single-character (e.g., `-d`) and, if present, the multi-character (e.g., `--debug`) versions of the flag in the copied line. 1. Omit the `[arg]` text in the copied line, if the desired flag takes no arguments. 1. Omit or edit the text after `Default=` to set or not set default values, respectively. @@ -74,7 +89,7 @@ __temp_file_name="${arg_t}" ## What is a magic variable? -The [magic variables](https://github.com/kvz/bash3boilerplate/blob/v2.1.0/main.sh#L26-L28) in `main.sh` are special in that they have a different value, depending on your environment. You can use `${__file}` to get a reference to your current script, and `${__dir}` to get a reference to the directory it lives in. This is not to be confused with the location of the calling script that might be sourcing the `${__file}`, which is accessible via `${0}`, or the current directory of the administrator running the script, accessible via `$(pwd)`. +The magic variables in `main.sh` are special in that they have a different value, depending on your environment. You can use `${__file}` to get a reference to your current script, and `${__dir}` to get a reference to the directory it lives in. This is not to be confused with the location of the calling script that might be sourcing the `${__file}`, which is accessible via `${0}`, or the current directory of the administrator running the script, accessible via `$(pwd)`. ## How do I submit an issue report? @@ -82,8 +97,7 @@ Please visit our [Issues](https://github.com/kvz/bash3boilerplate/issues) page. ## How can I contribute to this project? -Please fork this repository. After that, create a branch containing your suggested changes and submit a pull request based on the main branch -of . We are always more than happy to accept your contributions! +Please see [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines on testing, coding standards, and releasing. ## Why are you typing BASH in all caps? @@ -98,10 +112,10 @@ Somewhat inconsistent – but true to Unix ancestry – the abbreviation for our When we say _portable_, we mean across Bash versions. Bash is widespread and most systems offer at least version 3 of it. Make sure you have that available and b3bp will work for you. -We run automated tests to make sure that it will. Here is some proof for the following platforms: +We run automated tests to make sure that it will. Continuous integration currently runs on: -- [Linux](https://travis-ci.org/kvz/bash3boilerplate/jobs/109804166#L91-L94) `GNU bash, version 4.2.25(1)-release (x86_64-pc-linux-gnu)` -- [OSX](https://travis-ci.org/kvz/bash3boilerplate/jobs/109804167#L2453-L2455) `GNU bash, version 3.2.51(1)-release (x86_64-apple-darwin13)` +- Linux (`ubuntu-latest`, modern Bash) +- macOS (`macos-latest`, Bash `3.2.57`) This portability, however, does not mean that we try to be compatible with KornShell, Zsh, posh, yash, dash, or other shells. We allow syntax that would explode if @@ -114,7 +128,7 @@ to create a satisfactory abstraction that is not only correct, but also covers e while still having a relatively small footprint in `main.sh`. For simple OS detection, we recommend using the `${OSTYPE}` variable available in Bash as -is demoed in [this stackoverflow post](http://stackoverflow.com/a/8597411/151666): +is demoed in [this Stack Overflow post](https://stackoverflow.com/a/8597411/151666): ```bash if [[ "${OSTYPE}" = "linux-gnu" ]]; then @@ -149,7 +163,7 @@ echo ${NAME2:=Damian} # echos Damian, $NAME2 is set to Damian NAME3=${NAME3:-Damian}; echo ${NAME3} # echos Damian, $NAME3 is set to Damian ``` -This subject is briefly touched on as well in the [Safety and Portability section under point 5](README.md#safety-and-portability). b3bp currently uses [method 1](https://github.com/kvz/bash3boilerplate/blob/v2.1.0/main.sh#L252) when we want to access a variable that could be undeclared, and [method 3](https://github.com/kvz/bash3boilerplate/blob/v2.1.0/main.sh#L31) when we also want to set a default to an undeclared variable, because we feel it is more readable than method 2. We feel `:=` is easily overlooked, and not very beginner friendly. Method 3 seems more explicit in that regard in our humble opinion. +This subject is briefly touched on as well in the [Safety and Portability section under point 5](README.md#safety-and-portability). b3bp currently uses method 1 when we want to access a variable that could be undeclared, and method 3 when we also want to set a default to an undeclared variable, because we feel it is more readable than method 2. We feel `:=` is easily overlooked, and not very beginner friendly. Method 3 seems more explicit in that regard in our humble opinion. ## How can I detect or trap Ctrl-C and other signals? @@ -164,8 +178,8 @@ function ctrl_c() { } ``` -See http://mywiki.wooledge.org/SignalTrap for a list of signals, examples, and an in depth discussion. +See https://mywiki.wooledge.org/SignalTrap for a list of signals, examples, and an in depth discussion. ## How can I get the PID of my running script? -The PID of a running script is contained in the `${$}` variable. This is _not_ the pid of any subshells. With Bash 4 you can get the PID of your subshell with `${BASHPID}`. For a comprehensive list of Bash built in variables see, e.g., http://www.tldp.org/LDP/abs/html/internalvariables.html +The PID of a running script is contained in the `${$}` variable. This is _not_ the pid of any subshells. With Bash 4 you can get the PID of your subshell with `${BASHPID}`. For a comprehensive list of Bash built in variables see, e.g., https://tldp.org/LDP/abs/html/internalvariables.html diff --git a/README.md b/README.md index bd96bac..4eb2d86 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,9 @@ dependency. - Conventions that will make sure that all your scripts follow the same, battle-tested structure - Safe by default (break on error, pipefail, etc.) - Configuration by environment variables -- Simple command-line argument parsing that requires no external dependencies. Definitions are parsed from help info, ensuring there will be no duplication +- Simple command-line argument parsing that requires no external dependencies. Definitions are parsed from help info, ensuring there will be no duplication. Unknown options fail with a clear error, missing values fail fast, and `--` stops option parsing. - Helpful magic variables like `__file` and `__dir` -- Logging that supports colors and is compatible with [Syslog Severity levels](https://en.wikipedia.org/wiki/Syslog#Severity_levels), as well as the [twelve-factor](https://12factor.net/) guidelines +- Logging to STDERR, with colors and [Syslog Severity levels](https://en.wikipedia.org/wiki/Syslog#Severity_levels). Supports `NO_COLOR` and [twelve-factor](https://12factor.net/) guidelines. ## Installation @@ -89,20 +89,32 @@ Please see the [FAQ.md](./FAQ.md) file. ## Best practices -As of `v1.0.3`, b3bp offers some nice re-usable libraries in `./src`. In order to make the snippets in `./src` more useful, we recommend the following guidelines. +b3bp includes re-usable libraries in `./src`. We recommend the following guidelines for your scripts. ### Function packaging -It is nice to have a Bash package that can not only be used in the terminal, but also invoked as a command line function. In order to achieve this, the exporting of your functionality _should_ follow this pattern: +It is nice to have a Bash package that can not only be used in the terminal, but also invoked as a command line function. In order to achieve this, the exporting of your functionality should follow this pattern: ```bash -if [[ "${BASH_SOURCE[0]}" = "${0}" ]]; then - my_script "${@}" - exit $? +my_script() ( + set -o errexit + set -o errtrace + set -o nounset + set -o pipefail + + # function body +) + +if [[ "${BASH_SOURCE[0]:-}" != "${0}" ]]; then + export -f my_script +else + my_script "$@" + exit fi -export -f my_script ``` +Use `export -f` only when child Bash processes must inherit the function. + This allows a user to `source` your script or invoke it as a script. ```bash @@ -123,11 +135,12 @@ $ my_script some more args --blah ### Coding style -1. Use two spaces for tabs, do not use tab characters. -1. Do not introduce whitespace at the end of lines or on blank lines as they obfuscate version control diffs. -1. Use long options (`logger --priority` vs `logger -p`). If you are on the CLI, abbreviations make sense for efficiency. Nevertheless, when you are writing reusable scripts, a few extra keystrokes will pay off in readability and avoid ventures into man pages in the future, either by you or your collaborators. Similarly, we prefer `set -o nounset` over `set -u`. -1. Use a single equal sign when checking `if [[ "${NAME}" = "Kevin" ]]`; double or triple signs are not needed. -1. Use the new bash builtin test operator (`[[ ... ]]`) rather than the old single square bracket test operator or explicit call to `test`. +1. Format with [shfmt](https://github.com/mvdan/sh) (`shfmt -i 2 -bn`): two-space indent, binary operators may start a line. +1. Do not introduce trailing whitespace on lines. +1. Use a single equal sign when checking `if [[ "${NAME}" = "Kevin" ]]`. +1. Use the bash test operator (`[[ ... ]]`) rather than `[` or `test`. +1. Use braces around variable expansions: `${VAR}`. +1. Run [ShellCheck](https://www.shellcheck.net/) on your scripts. ### Safety and Portability diff --git a/example.sh b/example.sh index 009afaf..438b1a9 100755 --- a/example.sh +++ b/example.sh @@ -7,15 +7,14 @@ # # LOG_LEVEL=7 ./example.sh -f /tmp/x -d (change this for your script) # -# Based on a template by BASH3 Boilerplate v2.3.0 -# http://bash3boilerplate.sh/#authors +# Based on a template by BASH3 Boilerplate v2.7.2 +# https://bash3boilerplate.sh/#authors # # The MIT License (MIT) # Copyright (c) 2013 Kevin van Zonneveld and contributors # You are not obligated to bundle the LICENSE file with your b3bp projects as long # as you leave these references intact in the header comments of your source files. - ### BASH3 Boilerplate (b3bp) Header ############################################################################## @@ -50,26 +49,25 @@ EOF # shellcheck source=main.sh source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/main.sh" - -### Signal trapping and backtracing +### Overrides — these sections duplicate main.sh defaults intentionally, +### showing how a sourcing script can customize traps and argument handling. ############################################################################## -function __b3bp_cleanup_before_exit () { +function __b3bp_cleanup_before_exit() { info "Cleaning up. Done" } trap __b3bp_cleanup_before_exit EXIT # requires `set -o errtrace` __b3bp_err_report() { - local error_code=${?} - # shellcheck disable=SC2154 - error "Error in ${__file} in function ${1} on line ${2}" - exit ${error_code} + local error_code=${?} + # shellcheck disable=SC2154 + error "Error in ${__file} in function ${1} on line ${2}" + exit ${error_code} } # Uncomment the following line for always providing an error backtrace # trap '__b3bp_err_report "${FUNCNAME:-.}" ${LINENO}' ERR - ### Command-line argument switches (like -d for debugmode, -h for showing helppage) ############################################################################## @@ -94,18 +92,15 @@ fi # help mode if [[ "${arg_h:?}" = "1" ]]; then - # Help exists with code 1 help "Help using ${0}" fi - ### Validation. Error out if the things required for your script are not present ############################################################################## -[[ "${arg_f:-}" ]] || help "Setting a filename with -f or --file is required" +[[ "${arg_f:-}" ]] || help "Setting a filename with -f or --file is required" [[ "${LOG_LEVEL:-}" ]] || emergency "Cannot continue without LOG_LEVEL. " - ### Runtime ############################################################################## diff --git a/main.sh b/main.sh index 4fb4dfd..fe1634a 100755 --- a/main.sh +++ b/main.sh @@ -7,8 +7,8 @@ # # LOG_LEVEL=7 ./main.sh -f /tmp/x -d (change this for your script) # -# Based on a template by BASH3 Boilerplate vv2.7.2 -# http://bash3boilerplate.sh/#authors +# Based on a template by BASH3 Boilerplate v2.7.2 +# https://bash3boilerplate.sh/#authors # # The MIT License (MIT) # Copyright (c) 2013 Kevin van Zonneveld and contributors @@ -21,7 +21,7 @@ set -o errexit set -o errtrace # Do not allow use of undefined vars. Use ${VAR:-} to use an undefined VAR set -o nounset -# Catch the error in case mysqldump fails (but gzip succeeds) in `mysqldump |gzip` +# Catch the error in a pipe e.g. `mysqldump |gzip` returns mysqldump's exit code, not gzip's set -o pipefail # Turn on traces, useful while debugging but commented out by default # set -o xtrace @@ -54,40 +54,30 @@ __invocation="$(printf %q "${__file}")$( (($#)) && printf ' %q' "$@" || true)" LOG_LEVEL="${LOG_LEVEL:-6}" # 7 = debug -> 0 = emergency NO_COLOR="${NO_COLOR:-}" # true = disable color. otherwise autodetected - ### Functions ############################################################################## -function __b3bp_log () { +function __b3bp_log() { local log_level="${1}" shift # shellcheck disable=SC2034 - local color_debug="\\x1b[35m" - # shellcheck disable=SC2034 - local color_info="\\x1b[32m" - # shellcheck disable=SC2034 - local color_notice="\\x1b[34m" - # shellcheck disable=SC2034 - local color_warning="\\x1b[33m" - # shellcheck disable=SC2034 - local color_error="\\x1b[31m" - # shellcheck disable=SC2034 - local color_critical="\\x1b[1;31m" - # shellcheck disable=SC2034 - local color_alert="\\x1b[1;37;41m" - # shellcheck disable=SC2034 - local color_emergency="\\x1b[1;4;5;37;41m" + local color_debug="\\x1b[35m" color_info="\\x1b[32m" color_notice="\\x1b[34m" \ + color_warning="\\x1b[33m" color_error="\\x1b[31m" color_critical="\\x1b[1;31m" \ + color_alert="\\x1b[1;37;41m" color_emergency="\\x1b[1;4;5;37;41m" local colorvar="color_${log_level}" local color="${!colorvar:-${color_error}}" local color_reset="\\x1b[0m" - if [[ "${NO_COLOR:-}" = "true" ]] || { [[ "${TERM:-}" != "xterm"* ]] && [[ "${TERM:-}" != "screen"* ]]; } || [[ ! -t 2 ]]; then + if [[ "${NO_COLOR:-}" = "true" ]] \ + || { [[ "${TERM:-}" != "xterm"* ]] && [[ "${TERM:-}" != "screen"* ]]; } \ + || [[ ! -t 2 ]]; then if [[ "${NO_COLOR:-}" != "false" ]]; then # Don't use colors on pipes or non-recognized terminals - color=""; color_reset="" + color="" + color_reset="" fi fi @@ -96,19 +86,45 @@ function __b3bp_log () { while IFS=$'\n' read -r log_line; do echo -e "$(date -u +"%Y-%m-%d %H:%M:%S UTC") ${color}$(printf "[%9s]" "${log_level}")${color_reset} ${log_line}" 1>&2 - done <<< "${@:-}" + done <<<"${@:-}" } -function emergency () { __b3bp_log emergency "${@}"; exit 1; } -function alert () { [[ "${LOG_LEVEL:-0}" -ge 1 ]] && __b3bp_log alert "${@}"; true; } -function critical () { [[ "${LOG_LEVEL:-0}" -ge 2 ]] && __b3bp_log critical "${@}"; true; } -function error () { [[ "${LOG_LEVEL:-0}" -ge 3 ]] && __b3bp_log error "${@}"; true; } -function warning () { [[ "${LOG_LEVEL:-0}" -ge 4 ]] && __b3bp_log warning "${@}"; true; } -function notice () { [[ "${LOG_LEVEL:-0}" -ge 5 ]] && __b3bp_log notice "${@}"; true; } -function info () { [[ "${LOG_LEVEL:-0}" -ge 6 ]] && __b3bp_log info "${@}"; true; } -function debug () { [[ "${LOG_LEVEL:-0}" -ge 7 ]] && __b3bp_log debug "${@}"; true; } +# The `; true` ensures the function exits 0 even when the log level check +# short-circuits the && chain, preventing failures under `set -o errexit`. +function emergency() { + __b3bp_log emergency "${@}" + exit 1 +} +function alert() { + [[ "${LOG_LEVEL:-0}" -ge 1 ]] && __b3bp_log alert "${@}" + true +} +function critical() { + [[ "${LOG_LEVEL:-0}" -ge 2 ]] && __b3bp_log critical "${@}" + true +} +function error() { + [[ "${LOG_LEVEL:-0}" -ge 3 ]] && __b3bp_log error "${@}" + true +} +function warning() { + [[ "${LOG_LEVEL:-0}" -ge 4 ]] && __b3bp_log warning "${@}" + true +} +function notice() { + [[ "${LOG_LEVEL:-0}" -ge 5 ]] && __b3bp_log notice "${@}" + true +} +function info() { + [[ "${LOG_LEVEL:-0}" -ge 6 ]] && __b3bp_log info "${@}" + true +} +function debug() { + [[ "${LOG_LEVEL:-0}" -ge 7 ]] && __b3bp_log debug "${@}" + true +} -function help () { +function help() { echo "" 1>&2 echo " ${*}" 1>&2 echo "" 1>&2 @@ -123,6 +139,20 @@ function help () { exit 1 } +function __b3bp_set_opt_display_suffix() { + local __b3bp_tmp_short_opt="${1}" + local __b3bp_tmp_out_varname="${2}" + local __b3bp_tmp_varname="__b3bp_tmp_opt_short2long_${__b3bp_tmp_short_opt}" + local __b3bp_tmp_opt_long="" + + printf -v "__b3bp_tmp_opt_long" '%s' "${!__b3bp_tmp_varname:-}" + + if [[ "${__b3bp_tmp_opt_long:-}" ]]; then + printf -v "${__b3bp_tmp_out_varname}" ' (--%s)' "${__b3bp_tmp_opt_long//_/-}" + else + printf -v "${__b3bp_tmp_out_varname}" '%s' "" + fi +} ### Parse commandline options ############################################################################## @@ -177,11 +207,11 @@ while read -r __b3bp_tmp_line; do # check if option takes an argument if [[ "${__b3bp_tmp_line}" =~ \[.*\] ]]; then __b3bp_tmp_opt="${__b3bp_tmp_opt}:" # add : if opt has arg - __b3bp_tmp_init="" # it has an arg. init with "" + __b3bp_tmp_init="" # it has an arg. init with "" printf -v "__b3bp_tmp_has_arg_${__b3bp_tmp_opt:0:1}" '%s' "1" elif [[ "${__b3bp_tmp_line}" =~ \{.*\} ]]; then __b3bp_tmp_opt="${__b3bp_tmp_opt}:" # add : if opt has arg - __b3bp_tmp_init="" # it has an arg. init with "" + __b3bp_tmp_init="" # it has an arg. init with "" # remember that this option requires an argument printf -v "__b3bp_tmp_has_arg_${__b3bp_tmp_opt:0:1}" '%s' "2" else @@ -228,59 +258,90 @@ while read -r __b3bp_tmp_line; do # Init var with value unless it is an array / a repeatable __b3bp_tmp_varname="__b3bp_tmp_is_array_${__b3bp_tmp_opt:0:1}" [[ "${!__b3bp_tmp_varname}" = "0" ]] && printf -v "arg_${__b3bp_tmp_opt:0:1}" '%s' "${__b3bp_tmp_init}" -done <<< "${__usage:-}" +done <<<"${__usage:-}" # run getopts only if options were specified in __usage if [[ "${__b3bp_tmp_opts:-}" ]]; then # Allow long options like --this __b3bp_tmp_opts="${__b3bp_tmp_opts}-:" + __b3bp_tmp_opt_long_suffix="" # Reset in case getopts has been used previously in the shell. OPTIND=1 # start parsing command line - set +o nounset # unexpected arguments will cause unbound variables - # to be dereferenced + set +o nounset # getopts may reference unbound OPTARG / positional params # Overwrite $arg_ defaults with the actual CLI options while getopts "${__b3bp_tmp_opts}" __b3bp_tmp_opt; do [[ "${__b3bp_tmp_opt}" = "?" ]] && help "Invalid use of script: ${*} " if [[ "${__b3bp_tmp_opt}" = "-" ]]; then + __b3bp_tmp_optarg_has_explicit=0 + __b3bp_tmp_optarg_value="" + # OPTARG is long-option-name or long-option=value if [[ "${OPTARG}" =~ .*=.* ]]; then # --key=value format - __b3bp_tmp_long_opt=${OPTARG/=*/} - # Set opt to the short option corresponding to the long option - __b3bp_tmp_varname="__b3bp_tmp_opt_long2short_${__b3bp_tmp_long_opt//-/_}" - printf -v "__b3bp_tmp_opt" '%s' "${!__b3bp_tmp_varname}" - OPTARG=${OPTARG#*=} + __b3bp_tmp_long_opt="${OPTARG%%=*}" + __b3bp_tmp_optarg_value="${OPTARG#*=}" + __b3bp_tmp_optarg_has_explicit=1 else # --key value format - # Map long name to short version of option - __b3bp_tmp_varname="__b3bp_tmp_opt_long2short_${OPTARG//-/_}" - printf -v "__b3bp_tmp_opt" '%s' "${!__b3bp_tmp_varname}" - # Only assign OPTARG if option takes an argument - __b3bp_tmp_varname="__b3bp_tmp_has_arg_${__b3bp_tmp_opt}" - __b3bp_tmp_varvalue="${!__b3bp_tmp_varname}" - [[ "${__b3bp_tmp_varvalue}" != "0" ]] && __b3bp_tmp_varvalue="1" - printf -v "OPTARG" '%s' "${@:OPTIND:${__b3bp_tmp_varvalue}}" - # shift over the argument if argument is expected - ((OPTIND+=__b3bp_tmp_varvalue)) + __b3bp_tmp_long_opt="${OPTARG}" + fi + + if [[ ! "${__b3bp_tmp_long_opt}" =~ ^[a-zA-Z0-9][a-zA-Z0-9-]*$ ]]; then + help "Invalid use of script: --${__b3bp_tmp_long_opt}" + fi + + # Set opt to the short option corresponding to the long option + __b3bp_tmp_varname="__b3bp_tmp_opt_long2short_${__b3bp_tmp_long_opt//-/_}" + if [[ -z "${!__b3bp_tmp_varname:-}" ]]; then + help "Invalid use of script: --${__b3bp_tmp_long_opt}" + fi + printf -v "__b3bp_tmp_opt" '%s' "${!__b3bp_tmp_varname}" + + # Decide whether this long option may/must receive an argument. + __b3bp_tmp_varname="__b3bp_tmp_has_arg_${__b3bp_tmp_opt}" + __b3bp_tmp_varvalue="${!__b3bp_tmp_varname:-0}" + + if [[ "${__b3bp_tmp_varvalue}" = "0" ]]; then + # Flags are not allowed to receive a value in --flag=value syntax. + if [[ "${__b3bp_tmp_optarg_has_explicit}" = "1" ]]; then + __b3bp_set_opt_display_suffix "${__b3bp_tmp_opt}" "__b3bp_tmp_opt_long_suffix" + help "Option -${__b3bp_tmp_opt}${__b3bp_tmp_opt_long_suffix} does not take an argument" + fi + OPTARG="" + else + if [[ "${__b3bp_tmp_optarg_has_explicit}" = "1" ]]; then + OPTARG="${__b3bp_tmp_optarg_value}" + else + # --key value format: consume next token as-is, including empty or hyphen-prefixed values. + if [[ "${!OPTIND+x}" != "x" ]]; then + __b3bp_set_opt_display_suffix "${__b3bp_tmp_opt}" "__b3bp_tmp_opt_long_suffix" + help "Option -${__b3bp_tmp_opt}${__b3bp_tmp_opt_long_suffix} requires an argument" + fi + printf -v "__b3bp_tmp_optarg_value" '%s' "${!OPTIND-}" + OPTARG="${__b3bp_tmp_optarg_value}" + ((OPTIND += 1)) + fi fi # we have set opt/OPTARG to the short value and the argument as OPTARG if it exists fi __b3bp_tmp_value="${OPTARG}" + __b3bp_tmp_varname="__b3bp_tmp_has_arg_${__b3bp_tmp_opt:0:1}" + __b3bp_tmp_opt_has_arg="${!__b3bp_tmp_varname:-0}" __b3bp_tmp_varname="__b3bp_tmp_is_array_${__b3bp_tmp_opt:0:1}" if [[ "${!__b3bp_tmp_varname}" != "0" ]]; then # repeatables # shellcheck disable=SC2016 - if [[ -z "${OPTARG}" ]]; then - # repeatable flags, they increcemnt + if [[ "${__b3bp_tmp_opt_has_arg}" = "0" ]]; then + # repeatable flags, they increment __b3bp_tmp_varname="arg_${__b3bp_tmp_opt:0:1}" debug "cli arg ${__b3bp_tmp_varname} = (${__b3bp_tmp_default}) -> ${!__b3bp_tmp_varname}" - # shellcheck disable=SC2004 + # shellcheck disable=SC2004 __b3bp_tmp_value=$((${!__b3bp_tmp_varname} + 1)) printf -v "${__b3bp_tmp_varname}" '%s' "${__b3bp_tmp_value}" else @@ -294,7 +355,7 @@ if [[ "${__b3bp_tmp_opts:-}" ]]; then __b3bp_tmp_varname="arg_${__b3bp_tmp_opt:0:1}" __b3bp_tmp_default="${!__b3bp_tmp_varname}" - if [[ -z "${OPTARG}" ]]; then + if [[ "${__b3bp_tmp_opt_has_arg}" = "0" ]]; then __b3bp_tmp_value=$((__b3bp_tmp_default + 1)) fi @@ -305,14 +366,13 @@ if [[ "${__b3bp_tmp_opts:-}" ]]; then done set -o nounset # no more unbound variable references expected - shift $((OPTIND-1)) + shift $((OPTIND - 1)) - if [[ "${1:-}" = "--" ]] ; then + if [[ "${1:-}" = "--" ]]; then shift fi fi - ### Automatic validation of required option arguments ############################################################################## @@ -324,14 +384,10 @@ for __b3bp_tmp_varname in ${!__b3bp_tmp_has_arg_*}; do __b3bp_tmp_varname="arg_${__b3bp_tmp_opt_short}" [[ "${!__b3bp_tmp_varname}" ]] && continue - __b3bp_tmp_varname="__b3bp_tmp_opt_short2long_${__b3bp_tmp_opt_short}" - printf -v "__b3bp_tmp_opt_long" '%s' "${!__b3bp_tmp_varname}" - [[ "${__b3bp_tmp_opt_long:-}" ]] && __b3bp_tmp_opt_long=" (--${__b3bp_tmp_opt_long//_/-})" - - help "Option -${__b3bp_tmp_opt_short}${__b3bp_tmp_opt_long:-} requires an argument" + __b3bp_set_opt_display_suffix "${__b3bp_tmp_opt_short}" "__b3bp_tmp_opt_long_suffix" + help "Option -${__b3bp_tmp_opt_short}${__b3bp_tmp_opt_long_suffix} requires an argument" done - ### Cleanup Environment variables ############################################################################## @@ -341,7 +397,6 @@ done unset -v __tmp_varname - ### Externally supplied __usage. Nothing else to do here ############################################################################## @@ -350,25 +405,23 @@ if [[ "${__b3bp_external_usage:-}" = "true" ]]; then return fi - ### Signal trapping and backtracing ############################################################################## -function __b3bp_cleanup_before_exit () { +function __b3bp_cleanup_before_exit() { info "Cleaning up. Done" } trap __b3bp_cleanup_before_exit EXIT # requires `set -o errtrace` -__b3bp_err_report() { - local error_code=${?} - error "Error in ${__file} in function ${1} on line ${2}" - exit ${error_code} +function __b3bp_err_report() { + local error_code="${?}" + error "Error in ${__file} in function ${1} on line ${2}" + exit "${error_code}" } # Uncomment the following line for always providing an error backtrace # trap '__b3bp_err_report "${FUNCNAME:-.}" ${LINENO}' ERR - ### Command-line argument switches (like -d for debugmode, -h for showing helppage) ############################################################################## @@ -397,14 +450,12 @@ if [[ "${arg_h:?}" = "1" ]]; then help "Help using ${0}" fi - ### Validation. Error out if the things required for your script are not present ############################################################################## -[[ "${arg_f:-}" ]] || help "Setting a filename with -f or --file is required" +[[ "${arg_f:-}" ]] || help "Setting a filename with -f or --file is required" [[ "${LOG_LEVEL:-}" ]] || emergency "Cannot continue without LOG_LEVEL. " - ### Runtime ############################################################################## @@ -419,26 +470,16 @@ info "arg_d: ${arg_d}" info "arg_v: ${arg_v}" info "arg_h: ${arg_h}" -# shellcheck disable=SC2015 -if [[ -n "${arg_i:-}" ]] && declare -p arg_i 2> /dev/null | grep -q '^declare \-a'; then +if [[ -n "${arg_i:-}" ]]; then info "arg_i:" for input_file in "${arg_i[@]}"; do info " - ${input_file}" done -elif [[ -n "${arg_i:-}" ]]; then - info "arg_i: ${arg_i}" else info "arg_i: 0" fi -# shellcheck disable=SC2015 -if [[ -n "${arg_x:-}" ]] && declare -p arg_x 2> /dev/null | grep -q '^declare \-a'; then - info "arg_x: ${#arg_x[@]}" -elif [[ -n "${arg_x:-}" ]]; then - info "arg_x: ${arg_x}" -else - info "arg_x: 0" -fi +info "arg_x: ${arg_x:-0}" info "$(echo -e "multiple lines example - line #1\\nmultiple lines example - line #2\\nimagine logging the output of 'ls -al /path/'")" diff --git a/package.json b/package.json index 4583945..fca5f05 100644 --- a/package.json +++ b/package.json @@ -8,14 +8,20 @@ "yarn": "3.6.0" }, "scripts": { - "lint:shellcheck": "shellcheck --severity=${SHELLCHECK_SEVERITY:-info} $(find . -name '*.sh' -maxdepth 2)", - "lint:style": "test/style.pl $(find . -name '*.sh' -maxdepth 2)", + "lint:shellcheck": "shellcheck --severity=${SHELLCHECK_SEVERITY:-info} $(find . -type f -name '*.sh' -not -path './node_modules/*')", + "lint:shfmt": "test/shfmt.sh lint", + "lint:style": "test/style.pl $(find . -type f -name '*.sh' -not -path './node_modules/*')", "lint": "npm-run-all -l 'lint:**'", + "fix:shfmt": "test/shfmt.sh fix", "release:major": "env SEMANTIC=major yarn release", "release:minor": "env SEMANTIC=minor yarn release", "release:patch": "env SEMANTIC=patch yarn release", - "release": "npm version ${SEMANTIC:-patch} -m \"Release %s\" && yarn version:replace && git commit main.sh src/*.sh -m 'Update version' && git push && git push --tags -f && npm publish", + "release:ready": "test/release-ready.sh", + "release": "yarn release:ready && VERSION=\"$(npm version ${SEMANTIC:-patch} --no-git-tag-version | tail -n 1)\" && yarn version:replace && git add package.json main.sh src/*.sh && git commit -m \"Release ${VERSION}\" && git tag \"${VERSION}\" -m \"Release ${VERSION}\" && git push && git push --tags && npm publish", + "test:fast": "test/acceptance.sh main-longopt-errors && test/acceptance.sh main-logging-contracts && test/acceptance.sh parse_url-strict && test/acceptance.sh parse_url-robust && test/acceptance.sh ini_val-robust && test/acceptance.sh templater-robust", + "test:all": "yarn lint && yarn test && yarn test:bash3:docker", "test:debug:main:repeated": "env LOG_LEVEL=7 test/acceptance.sh main-repeated", + "test:bash3:docker": "test/bash3-docker.sh", "test:update": "env SAVE_FIXTURES=true yarn test", "test": "test/acceptance.sh", "version:current": "node -e 'console.log(require(\"./package.json\").version)'", diff --git a/repodocs/overhaul.md b/repodocs/overhaul.md new file mode 100644 index 0000000..116f833 --- /dev/null +++ b/repodocs/overhaul.md @@ -0,0 +1,14 @@ +# Overhaul Summary + +Branch `maintainer/overhaul-pass-1` (PR #172) addressed: + +1. **Parser correctness** — Long-option handling now rejects unknown options, fails fast on missing values, rejects `--flag=value` for flags, and respects `--` separator. +2. **Strict-mode safety** — All `src/*.sh` libraries use function-scoped strict mode via subshell execution `()`, preventing caller shell option mutation. +3. **`parse_url` rewrite** — Replaced `grep`-based command substitutions with pure Bash parameter expansion, eliminating strict-mode failures. +4. **CI expansion** — Added macOS lane and Docker Bash 3.2.57 lane alongside existing Ubuntu coverage. +5. **Test coverage** — Added contract scenarios for parser edge cases, library robustness, and logging behavior. +6. **Release governance** — Added `release-ready` gate enforcing branch, CI status, and changelog quality. Release script defers tag creation until after commit. +7. **Lint expansion** — Style checker now processes all shell files. ShellCheck runs at warning severity in CI. +8. **Documentation** — Design principles, FAQ updates, stale reference cleanup, function packaging examples updated to current patterns. + +Detailed iteration history is preserved in git log. diff --git a/src/ini_val.sh b/src/ini_val.sh index d811510..6a8edf0 100755 --- a/src/ini_val.sh +++ b/src/ini_val.sh @@ -19,15 +19,20 @@ # # ini_val.sh data.ini connection.host 127.0.0.1 "Host name or IP address" # -# Based on a template by BASH3 Boilerplate vv2.7.2 -# http://bash3boilerplate.sh/#authors +# Based on a template by BASH3 Boilerplate v2.7.2 +# https://bash3boilerplate.sh/#authors # # The MIT License (MIT) # Copyright (c) 2013 Kevin van Zonneveld and contributors # You are not obligated to bundle the LICENSE file with your b3bp projects as long # as you leave these references intact in the header comments of your source files. -function ini_val() { +function ini_val() ( + set -o errexit + set -o errtrace + set -o nounset + set -o pipefail + local file="${1:-}" local sectionkey="${2:-}" local val="${3:-}" @@ -37,6 +42,8 @@ function ini_val() { local section="" local key="" local current="" + local current_comment="" + local RET="" # add default section local section_default="default" @@ -46,7 +53,7 @@ function ini_val() { fi # Split on . for section. However, section is optional - IFS='.' read -r section key <<< "${sectionkey}" + IFS='.' read -r section key <<<"${sectionkey}" if [[ ! "${key}" ]]; then key="${section}" # default section if not given @@ -54,14 +61,14 @@ function ini_val() { fi # get current value (if exists) - current=$(sed -En "/^\[/{h;d;};G;s/^${key}([[:blank:]]*)${delim}(.*)\n\[${section}\]$/\2/p" "${file}"|awk '{$1=$1};1') + current=$(sed -En "/^\[/{h;d;};G;s/^${key}([[:blank:]]*)${delim}(.*)\n\[${section}\]$/\2/p" "${file}" | awk '{$1=$1};1') # get current comment (if exists) - current_comment=$(sed -En "/^\[${section}\]/,/^\[.*\]/ s|^(${comment_delim}\[${key}\])(.*)|\2|p" "${file}"|awk '{$1=$1};1') + current_comment=$(sed -En "/^\[${section}\]/,/^\[.*\]/ s|^(${comment_delim}\[${key}\])(.*)|\2|p" "${file}" | awk '{$1=$1};1') if ! grep -q "\[${section}\]" "${file}"; then # create section if not exists (empty line to seperate new section for better readability) - echo >> "${file}" - echo "[${section}]" >> "${file}" + echo >>"${file}" + echo "[${section}]" >>"${file}" fi if [[ ! "${val}" ]]; then @@ -100,14 +107,14 @@ ${comment_delim}[${key}] ${comment}\\ ${key}${delim}${val}" fi sed -i.bak -e "${RET}" "${file}" - # this .bak dance is done for BSD/GNU portability: http://stackoverflow.com/a/22084103/151666 + # this .bak dance is done for BSD/GNU portability: https://stackoverflow.com/a/22084103/151666 rm -f "${file}.bak" fi -} +) -if [[ "${BASH_SOURCE[0]}" != "${0}" ]]; then +if [[ "${BASH_SOURCE[0]:-}" != "${0}" ]]; then export -f ini_val else - ini_val "${@}" - exit ${?} + ini_val "$@" + exit fi diff --git a/src/megamount.sh b/src/megamount.sh index 148ff1c..b2e37d9 100644 --- a/src/megamount.sh +++ b/src/megamount.sh @@ -20,8 +20,8 @@ # # megamount.sh smb://janedoe:abc123@192.168.0.1/documents /mnt/documents # -# Based on a template by BASH3 Boilerplate vv2.7.2 -# http://bash3boilerplate.sh/#authors +# Based on a template by BASH3 Boilerplate v2.7.2 +# https://bash3boilerplate.sh/#authors # # The MIT License (MIT) # Copyright (c) 2013 Kevin van Zonneveld and contributors @@ -33,9 +33,14 @@ __dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=src/parse_url.sh source "${__dir}/parse_url.sh" -function megamount () { - local url="${1}" - local target="${2}" +function megamount() ( + set -o errexit + set -o errtrace + set -o nounset + set -o pipefail + + local url="${1:-}" + local target="${2:-}" local proto local user @@ -51,7 +56,7 @@ function megamount () { port=$(parse_url "${url}" "port") path=$(parse_url "${url}" "path") - (umount -lf "${target}" || umount -f "${target}") > /dev/null 2>&1 || true + (umount -lf "${target}" || umount -f "${target}") >/dev/null 2>&1 || true mkdir -p "${target}" if [[ "${proto}" = "smb://" ]]; then mount -t cifs --verbose -o "username=${user},password=${pass},hard" "//${host}/${path}" "${target}" @@ -68,11 +73,11 @@ function megamount () { # chmod 777 "${target}" ls -al "${target}/" -} +) -if [[ "${BASH_SOURCE[0]}" != "${0}" ]]; then +if [[ "${BASH_SOURCE[0]:-}" != "${0}" ]]; then export -f megamount else - megamount "${@}" - exit ${?} + megamount "$@" + exit fi diff --git a/src/parse_url.sh b/src/parse_url.sh index d9d2704..e6505a6 100644 --- a/src/parse_url.sh +++ b/src/parse_url.sh @@ -7,7 +7,7 @@ # # Based on: # -# - http://stackoverflow.com/a/6174447/151666 +# - https://stackoverflow.com/a/6174447/151666 # # Usage as a function: # @@ -18,49 +18,83 @@ # # parse_url.sh 'http://johndoe:abc123@example.com:8080/index.html' # -# Based on a template by BASH3 Boilerplate vv2.7.2 -# http://bash3boilerplate.sh/#authors +# Based on a template by BASH3 Boilerplate v2.7.2 +# https://bash3boilerplate.sh/#authors # # The MIT License (MIT) # Copyright (c) 2013 Kevin van Zonneveld and contributors # You are not obligated to bundle the LICENSE file with your b3bp projects as long # as you leave these references intact in the header comments of your source files. -function parse_url() { - local parse="${1}" +function parse_url() ( + set -o errexit + set -o errtrace + set -o nounset + set -o pipefail + + local parse="${1:-}" local need="${2:-}" - local proto + local proto="" local url - local userpass - local user - local pass + local userpass="" + local user="" + local pass="" local hostport - local host - local port - local path + local host="" + local port="" + local path="" + + url="${parse}" + + if [[ "${url}" = *"://"* ]]; then + proto="${url%%://*}://" + url="${url#*://}" + fi - proto="$(echo "${parse}" | grep :// | sed -e's,^\(.*://\).*,\1,g')" - url="${parse/${proto}/}" - userpass="$(echo "${url}" | grep @ | cut -d@ -f1)" - user="$(echo "${userpass}" | grep : | cut -d: -f1)" - pass="$(echo "${userpass}" | grep : | cut -d: -f2)" - hostport="$(echo "${url/${userpass}@/}" | cut -d/ -f1)" - host="$(echo "${hostport}" | grep : | cut -d: -f1)" - port="$(echo "${hostport}" | grep : | cut -d: -f2)" - path="$(echo "${url}" | grep / | cut -d/ -f2-)" + if [[ "${url}" = *"@"* ]]; then + userpass="${url%%@*}" + url="${url#*@}" + fi + + hostport="${url%%/*}" + if [[ "${url}" = */* ]]; then + path="${url#*/}" + fi + + if [[ "${userpass}" = *":"* ]]; then + user="${userpass%%:*}" + pass="${userpass#*:}" + else + user="${userpass}" + fi + + if [[ "${hostport}" = *":"* ]]; then + host="${hostport%%:*}" + port="${hostport#*:}" + else + host="${hostport}" + fi [[ ! "${user}" ]] && user="${userpass}" [[ ! "${host}" ]] && host="${hostport}" if [[ ! "${port}" ]]; then - [[ "${proto}" = "http://" ]] && port="80" + [[ "${proto}" = "http://" ]] && port="80" [[ "${proto}" = "https://" ]] && port="443" [[ "${proto}" = "mysql://" ]] && port="3306" [[ "${proto}" = "redis://" ]] && port="6379" fi if [[ "${need}" ]]; then - echo "${!need}" + case "${need}" in + proto | user | pass | host | port | path) + echo "${!need}" + ;; + *) + echo "parse_url: unknown field selector: ${need}" 1>&2 + return 1 + ;; + esac else echo "" echo " Use second argument to return just 1 variable." @@ -74,11 +108,11 @@ function parse_url() { echo " path: ${path}" echo "" fi -} +) -if [[ "${BASH_SOURCE[0]}" != "${0}" ]]; then +if [[ "${BASH_SOURCE[0]:-}" != "${0}" ]]; then export -f parse_url else - parse_url "${@}" - exit ${?} + parse_url "$@" + exit fi diff --git a/src/templater.sh b/src/templater.sh index 679048f..9de6d23 100755 --- a/src/templater.sh +++ b/src/templater.sh @@ -16,37 +16,42 @@ # # ALLOW_REMAINDERS=1 templater.sh input.cfg output.cfg # -# Based on a template by BASH3 Boilerplate vv2.7.2 -# http://bash3boilerplate.sh/#authors +# Based on a template by BASH3 Boilerplate v2.7.2 +# https://bash3boilerplate.sh/#authors # # The MIT License (MIT) # Copyright (c) 2013 Kevin van Zonneveld and contributors # You are not obligated to bundle the LICENSE file with your b3bp projects as long # as you leave these references intact in the header comments of your source files. -function templater() { - ALLOW_REMAINDERS="${ALLOW_REMAINDERS:-0}" +function templater() ( + set -o errexit + set -o errtrace + set -o nounset + set -o pipefail - templateSrc="${1:-}" - templateDst="${2:-}" + local ALLOW_REMAINDERS="${ALLOW_REMAINDERS:-0}" + local templateSrc="${1:-}" + local templateDst="${2:-}" + local var="" if [[ ! -f "${templateSrc}" ]]; then - echo "ERROR: Template source '${templateSrc}' needs to exist" + echo "ERROR: Template source '${templateSrc}' needs to exist" 1>&2 exit 1 fi if [[ ! "${templateDst}" ]]; then - echo "ERROR: Template destination '${templateDst}' needs to be specified" + echo "ERROR: Template destination '${templateDst}' needs to be specified" 1>&2 exit 1 fi if [[ "$(command -v perl)" ]]; then - perl -p -e 's/\$\{(\w+)\}/(exists $ENV{$1} ? $ENV{$1} : "\${$1}")/eg' < "${templateSrc}" > "${templateDst}" + perl -p -e 's/\$\{(\w+)\}/(exists $ENV{$1} ? $ENV{$1} : "\${$1}")/eg' <"${templateSrc}" >"${templateDst}" else cp -f "${templateSrc}" "${templateDst}" - for var in $(env |awk -F= '{print $1}' |grep -E '^(_[A-Z0-9_]+|[A-Z0-9][A-Z0-9_]*)$'); do + for var in $(env | awk -F= '{print $1}' | grep -E '^(_[A-Z0-9_]+|[A-Z0-9][A-Z0-9_]*)$'); do sed -i.bak -e "s#\${${var}}#${!var//#/\\#/}#g" "${templateDst}" - # this .bak dance is done for BSD/GNU portability: http://stackoverflow.com/a/22084103/151666 + # this .bak dance is done for BSD/GNU portability: https://stackoverflow.com/a/22084103/151666 rm -f "${templateDst}.bak" done fi @@ -58,11 +63,11 @@ function templater() { echo "ERROR: Unable to replace the above template vars" exit 1 fi -} +) -if [[ "${BASH_SOURCE[0]}" != "${0}" ]]; then +if [[ "${BASH_SOURCE[0]:-}" != "${0}" ]]; then export -f templater else - templater "${@}" - exit ${?} + templater "$@" + exit fi diff --git a/test/acceptance.sh b/test/acceptance.sh index 392d675..9adb940 100755 --- a/test/acceptance.sh +++ b/test/acceptance.sh @@ -7,10 +7,10 @@ # # Usage: # -# ./deploy.sh +# ./acceptance.sh # # Based on a template by BASH3 Boilerplate v2.0.0 -# http://bash3boilerplate.sh/#authors +# https://bash3boilerplate.sh/#authors # # The MIT License (MIT) # Copyright (c) 2013 Kevin van Zonneveld and contributors @@ -38,7 +38,7 @@ __sysTmpDir="${TMPDIR:-/tmp}" __sysTmpDir="${__sysTmpDir%/}" # <-- remove trailing slash on macosx __accptstTmpDir=$(mktemp -d "${__sysTmpDir}/${__base}.XXXXXX") -function cleanup_before_exit () { rm -r "${__accptstTmpDir:?}"; } +function cleanup_before_exit() { rm -r "${__accptstTmpDir:?}"; } trap cleanup_before_exit EXIT cmdSed="sed" @@ -78,118 +78,117 @@ while IFS=$'\n' read -r scenario; do [[ "${1:-}" ]] && [[ "${scenario}" != "${1}" ]] && continue echo "==> Scenario: ${scenario}" - pushd "${__dir}/scenario/${scenario}" > /dev/null - - # Run scenario - (${cmdTimeout} --kill-after=6m 5m bash ./run.sh \ - > "${__accptstTmpDir}/${scenario}.stdio" 2>&1; \ - echo "${?}" > "${__accptstTmpDir}/${scenario}.exitcode" \ - ) || true - - # Clear out environmental specifics - for typ in stdio exitcode; do - curFile="${__accptstTmpDir}/${scenario}.${typ}" - "${cmdSed}" -i \ - -e "s@${__node}@{node}@g" "${curFile}" \ - -e "s@${__root}@{root}@g" "${curFile}" \ - -e "s@${__sysTmpDir}@{tmpdir}@g" "${curFile}" \ - -e "s@/tmp@{tmpdir}@g" "${curFile}" \ - -e "s@${HOME:-/home/travis}@{home}@g" "${curFile}" \ - -e "s@${USER:-travis}@{user}@g" "${curFile}" \ - -e "s@travis@{user}@g" "${curFile}" \ - -e "s@kvz@{user}@g" "${curFile}" \ - -e "s@{root}/node_modules/\\.bin/node@{node}@g" "${curFile}" \ - -e "s@{home}/build/{user}/fre{node}@{node}@g" "${curFile}" \ - -e "s@${HOSTNAME}@{hostname}@g" "${curFile}" \ - -e "s@${__arch}@{arch}@g" "${curFile}" \ - -e "s@${OSTYPE}@{OSTYPE}@g" "${curFile}" \ - -e "s@OSX@{os}@g" "${curFile}" \ - -e "s@Linux@{os}@g" "${curFile}" \ + pushd "${__dir}/scenario/${scenario}" >/dev/null + + # Run scenario + ( + ${cmdTimeout} --kill-after=6m 5m bash ./run.sh \ + >"${__accptstTmpDir}/${scenario}.stdio" 2>&1 + echo "${?}" >"${__accptstTmpDir}/${scenario}.exitcode" + ) || true + + # Clear out environmental specifics + for typ in stdio exitcode; do + curFile="${__accptstTmpDir}/${scenario}.${typ}" + "${cmdSed}" -i \ + -e "s@${__node}@{node}@g" "${curFile}" \ + -e "s@${__root}@{root}@g" "${curFile}" \ + -e "s@${__sysTmpDir}@{tmpdir}@g" "${curFile}" \ + -e "s@/tmp@{tmpdir}@g" "${curFile}" \ + -e "s@${HOME:-/home/runner}@{home}@g" "${curFile}" \ + -e "s@${USER:-runner}@{user}@g" "${curFile}" \ + -e "s@kvz@{user}@g" "${curFile}" \ + -e "s@{root}/node_modules/\\.bin/node@{node}@g" "${curFile}" \ + -e "s@${HOSTNAME}@{hostname}@g" "${curFile}" \ + -e "s@${__arch}@{arch}@g" "${curFile}" \ + -e "s@${OSTYPE}@{OSTYPE}@g" "${curFile}" \ + -e "s@OSX@{os}@g" "${curFile}" \ + -e "s@Linux@{os}@g" "${curFile}" \ || false - if grep -q 'ACCPTST:STDIO_REPLACE_IPS' "${curFile}"; then - "${cmdSed}" -i \ - -r 's@[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}@{ip}@g' \ + if grep -q 'ACCPTST:STDIO_REPLACE_IPS' "${curFile}"; then + "${cmdSed}" -i \ + -r 's@[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}@{ip}@g' \ "${curFile}" - # IPs vary in length. Ansible uses padding. {ip} does not vary in length - # so kill the padding after it for consistent output - "${cmdSed}" -i \ - -r 's@\{ip\}\s+@{ip} @g' \ + # IPs vary in length. Ansible uses padding. {ip} does not vary in length + # so kill the padding after it for consistent output + "${cmdSed}" -i \ + -r 's@\{ip\}\s+@{ip} @g' \ "${curFile}" - fi - if grep -q 'ACCPTST:STDIO_REPLACE_UUIDS' "${curFile}"; then - "${cmdSed}" -i \ - -r 's@[0-9a-f\-]{32,40}@{uuid}@g' \ + fi + if grep -q 'ACCPTST:STDIO_REPLACE_UUIDS' "${curFile}"; then + "${cmdSed}" -i \ + -r 's@[0-9a-f\-]{32,40}@{uuid}@g' \ "${curFile}" - fi - if grep -q 'ACCPTST:STDIO_REPLACE_BIGINTS' "${curFile}"; then - # Such as: 3811298194 - "${cmdSed}" -i \ - -r 's@[0-9]{7,64}@{bigint}@g' \ + fi + if grep -q 'ACCPTST:STDIO_REPLACE_BIGINTS' "${curFile}"; then + # Such as: 3811298194 + "${cmdSed}" -i \ + -r 's@[0-9]{7,64}@{bigint}@g' \ "${curFile}" - fi - if grep -q 'ACCPTST:STDIO_REPLACE_DATETIMES' "${curFile}"; then - # Such as: 2016-02-10 15:38:44.420094 - "${cmdSed}" -i \ - -r 's@[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}@{datetime}@g' \ + fi + if grep -q 'ACCPTST:STDIO_REPLACE_DATETIMES' "${curFile}"; then + # Such as: 2016-02-10 15:38:44.420094 + "${cmdSed}" -i \ + -r 's@[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}@{datetime}@g' \ "${curFile}" - fi - if grep -q 'ACCPTST:STDIO_REPLACE_LONGTIMES' "${curFile}"; then - # Such as: 2016-02-10 15:38:44.420094 - "${cmdSed}" -i \ - -r 's@[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{6}@{longtime}@g' \ + fi + if grep -q 'ACCPTST:STDIO_REPLACE_LONGTIMES' "${curFile}"; then + # Such as: 2016-02-10 15:38:44.420094 + "${cmdSed}" -i \ + -r 's@[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{6}@{longtime}@g' \ "${curFile}" - fi - if grep -q 'ACCPTST:STDIO_REPLACE_DURATIONS' "${curFile}"; then - # Such as: 0:00:00.001991 - "${cmdSed}" -i \ - -r 's@[0-9]{1,2}:[0-9]{2}:[0-9]{2}.[0-9]{6}@{duration}@g' \ + fi + if grep -q 'ACCPTST:STDIO_REPLACE_DURATIONS' "${curFile}"; then + # Such as: 0:00:00.001991 + "${cmdSed}" -i \ + -r 's@[0-9]{1,2}:[0-9]{2}:[0-9]{2}.[0-9]{6}@{duration}@g' \ "${curFile}" - fi - if grep -q 'ACCPTST:STDIO_REPLACE_REMOTE_EXEC' "${curFile}"; then - grep -Ev 'remote-exec\): [ a-zA-Z]' "${curFile}" > "${__sysTmpDir}/accptst-filtered.txt" - mv "${__sysTmpDir}/accptst-filtered.txt" "${curFile}" - fi - done - - # Save these as new fixtures? - if [[ "${SAVE_FIXTURES:-}" = "true" ]]; then - for typ in stdio exitcode; do - curFile="${__accptstTmpDir}/${scenario}.${typ}" - cp -f \ - "${curFile}" \ - "${__dir}/fixture/${scenario}.${typ}" - done fi + if grep -q 'ACCPTST:STDIO_REPLACE_REMOTE_EXEC' "${curFile}"; then + grep -Ev 'remote-exec\): [ a-zA-Z]' "${curFile}" >"${__sysTmpDir}/accptst-filtered.txt" + mv "${__sysTmpDir}/accptst-filtered.txt" "${curFile}" + fi + done - # Compare + # Save these as new fixtures? + if [[ "${SAVE_FIXTURES:-}" = "true" ]]; then for typ in stdio exitcode; do curFile="${__accptstTmpDir}/${scenario}.${typ}" + cp -f \ + "${curFile}" \ + "${__dir}/fixture/${scenario}.${typ}" + done + fi - echo -n " comparing ${typ}.. " + # Compare + for typ in stdio exitcode; do + curFile="${__accptstTmpDir}/${scenario}.${typ}" - if [[ "${typ}" = "stdio" ]]; then - if grep -q 'ACCPTST:STDIO_SKIP_COMPARE' "${curFile}"; then - echo "skip" - continue - fi - fi + echo -n " comparing ${typ}.. " - if ! diff --strip-trailing-cr "${__dir}/fixture/${scenario}.${typ}" "${curFile}"; then - echo -e "\\n\\n==> MISMATCH OF: ${scenario}.${typ} ---^" - echo -e "\\n\\n==> EXPECTED STDIO: " - cat "${__dir}/fixture/${scenario}.stdio" || true - echo -e "\\n\\n==> ACTUAL STDIO: " - cat "${__accptstTmpDir}/${scenario}.stdio" || true - exit 1 + if [[ "${typ}" = "stdio" ]]; then + if grep -q 'ACCPTST:STDIO_SKIP_COMPARE' "${curFile}"; then + echo "skip" + continue fi + fi - echo "✓" - done + if ! diff --strip-trailing-cr "${__dir}/fixture/${scenario}.${typ}" "${curFile}"; then + echo -e "\\n\\n==> MISMATCH OF: ${scenario}.${typ} ---^" + echo -e "\\n\\n==> EXPECTED STDIO: " + cat "${__dir}/fixture/${scenario}.stdio" || true + echo -e "\\n\\n==> ACTUAL STDIO: " + cat "${__accptstTmpDir}/${scenario}.stdio" || true + exit 1 + fi + + echo "✓" + done - popd > /dev/null -done <<< "$(find "${__dir}/scenario" -type f -iname 'run.sh')" + popd >/dev/null +done <<<"$(find "${__dir}/scenario" -type f -iname 'run.sh')" [[ "${1:-}" ]] && exit 0 @@ -203,7 +202,7 @@ while IFS=$'\n' read -r bash; do fi # shellcheck disable=SC2016 echo "==> ${bash} -n $(${bash} -c 'echo "(${BASH_VERSION})"')" - pushd "${__root}" > /dev/null + pushd "${__root}" >/dev/null failed="false" @@ -212,21 +211,21 @@ while IFS=$'\n' read -r bash; do echo -n " ${file}.. " - if ! "${bash}" -n "${file}" 2>> "${__accptstTmpDir}/${bash//\//.}.err"; then + if ! "${bash}" -n "${file}" 2>>"${__accptstTmpDir}/${bash//\//.}.err"; then echo "✗" failed="true" continue fi echo "✓" - done <<< "$(find . -type f -iname '*.sh')" + done <<<"$(find . -type f -iname '*.sh')" - popd > /dev/null + popd >/dev/null if [[ "${failed}" = "true" ]]; then cat "${__accptstTmpDir}/${bash//\//.}.err" exit 1 fi -done <<< "$(which -a bash 2>/dev/null)" +done <<<"$(which -a bash 2>/dev/null)" exit 0 diff --git a/test/bash3-docker.sh b/test/bash3-docker.sh new file mode 100755 index 0000000..e252da7 --- /dev/null +++ b/test/bash3-docker.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -o errexit +set -o errtrace +set -o nounset +set -o pipefail + +__dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +__root="$(cd "$(dirname "${__dir}")" && pwd)" + +B3BP_BASH3_IMAGE="${B3BP_BASH3_IMAGE:-bash:3.2.57}" + +docker run --rm \ + --volume "${__root}:/workspace" \ + --workdir /workspace \ + --env NO_COLOR=true \ + "${B3BP_BASH3_IMAGE}" \ + sh -lc ' + set -o errexit + set -o nounset + set -o pipefail + + apk add --no-cache coreutils diffutils git nodejs perl >/dev/null + bash --version + test/acceptance.sh + ' diff --git a/test/fixture/ini_val-robust.exitcode b/test/fixture/ini_val-robust.exitcode new file mode 100644 index 0000000..573541a --- /dev/null +++ b/test/fixture/ini_val-robust.exitcode @@ -0,0 +1 @@ +0 diff --git a/test/fixture/ini_val-robust.stdio b/test/fixture/ini_val-robust.stdio new file mode 100644 index 0000000..a880d12 --- /dev/null +++ b/test/fixture/ini_val-robust.stdio @@ -0,0 +1,15 @@ +# command-mode-default-section +value-with-hyphen +# command-mode-comment-roundtrip +value-two +# source-mode-default-section +from-source +# final-ini + +[default] +source_only=from-source +orphan=value-with-hyphen + +[section] +;[key] section key comment +key=value-two diff --git a/test/fixture/main-logging-contracts.exitcode b/test/fixture/main-logging-contracts.exitcode new file mode 100644 index 0000000..573541a --- /dev/null +++ b/test/fixture/main-logging-contracts.exitcode @@ -0,0 +1 @@ +0 diff --git a/test/fixture/main-logging-contracts.stdio b/test/fixture/main-logging-contracts.stdio new file mode 100644 index 0000000..2eb4cd8 --- /dev/null +++ b/test/fixture/main-logging-contracts.stdio @@ -0,0 +1,5 @@ +exit=1 +stdout=empty +info=absent +warning=present +emergency=present diff --git a/test/fixture/main-longopt-errors.exitcode b/test/fixture/main-longopt-errors.exitcode new file mode 100644 index 0000000..573541a --- /dev/null +++ b/test/fixture/main-longopt-errors.exitcode @@ -0,0 +1 @@ +0 diff --git a/test/fixture/main-longopt-errors.stdio b/test/fixture/main-longopt-errors.stdio new file mode 100644 index 0000000..667bcf7 --- /dev/null +++ b/test/fixture/main-longopt-errors.stdio @@ -0,0 +1,25 @@ +ACCPTST:STDIO_REPLACE_DATETIMES +# unknown-long-option +exit: 1 + Invalid use of script: --unknown +# unknown-long-option-invalid-chars +exit: 1 + Invalid use of script: --invalid.option +# hyphen-prefixed-value +exit: 1 +{datetime} UTC [ info] arg_f: - +{datetime} UTC [ info] arg_d: 1 +# empty-string-value +exit: 1 +{datetime} UTC [ info] arg_f: {tmpdir}/x +{datetime} UTC [ info] arg_d: 1 +# missing-value-end-of-input +exit: 1 + Option -f (--file) requires an argument +# flag-assignment-on-boolean +exit: 1 + Option -d (--debug) does not take an argument +# double-dash-separator +exit: 1 +{datetime} UTC [ info] arg_f: {tmpdir}/x +{datetime} UTC [ info] arg_d: 0 diff --git a/test/fixture/parse_url-robust.exitcode b/test/fixture/parse_url-robust.exitcode new file mode 100644 index 0000000..573541a --- /dev/null +++ b/test/fixture/parse_url-robust.exitcode @@ -0,0 +1 @@ +0 diff --git a/test/fixture/parse_url-robust.stdio b/test/fixture/parse_url-robust.stdio new file mode 100644 index 0000000..2f25a7b --- /dev/null +++ b/test/fixture/parse_url-robust.stdio @@ -0,0 +1,18 @@ +# no-proto-with-path +host=example.com +path=path/to/resource +# https-default-port +port=443 +# redis-default-port +port=6379 +# user-without-pass +user=jane +pass= +# explicit-port-no-path +host=api.example.org +port=9000 +path= +# unknown-selector +exit=1 +stdout=empty +selector-guard=present diff --git a/test/fixture/parse_url-strict.exitcode b/test/fixture/parse_url-strict.exitcode new file mode 100644 index 0000000..573541a --- /dev/null +++ b/test/fixture/parse_url-strict.exitcode @@ -0,0 +1 @@ +0 diff --git a/test/fixture/parse_url-strict.stdio b/test/fixture/parse_url-strict.stdio new file mode 100644 index 0000000..255e712 --- /dev/null +++ b/test/fixture/parse_url-strict.stdio @@ -0,0 +1,8 @@ +# strict-http-host +example.com +# strict-https-port +443 +# strict-mysql-default-port +3306 +# strict-no-proto-host +example.com diff --git a/test/fixture/templater-robust.exitcode b/test/fixture/templater-robust.exitcode new file mode 100644 index 0000000..573541a --- /dev/null +++ b/test/fixture/templater-robust.exitcode @@ -0,0 +1 @@ +0 diff --git a/test/fixture/templater-robust.stdio b/test/fixture/templater-robust.stdio new file mode 100644 index 0000000..86c650e --- /dev/null +++ b/test/fixture/templater-robust.stdio @@ -0,0 +1,13 @@ +# command-mode-special-values +line3=${UNSET_VALUE} +line1=a&b/c#d +line2=value with spaces +line3=${UNSET_VALUE} +# source-mode-special-values +line3=${UNSET_VALUE} +line1=x-y-z +line2=another value +line3=${UNSET_VALUE} +# command-mode-missing-template +exit: 1 +ERROR: Template source './does-not-exist.template' needs to exist diff --git a/test/release-ready.sh b/test/release-ready.sh new file mode 100755 index 0000000..dcf77e4 --- /dev/null +++ b/test/release-ready.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -o errexit +set -o errtrace +set -o nounset +set -o pipefail + +function fail() { + echo "release-ready: $*" 1>&2 + exit 1 +} + +[[ -f CHANGELOG.md ]] || fail "CHANGELOG.md not found" + +branch="$(git rev-parse --abbrev-ref HEAD)" +[[ "${branch}" = "main" ]] || fail "must run from main branch (current: ${branch})" + +git diff --quiet || fail "working tree has unstaged changes" +git diff --cached --quiet || fail "working tree has staged but uncommitted changes" + +command -v gh >/dev/null || fail "GitHub CLI (gh) is required" +gh auth status >/dev/null 2>&1 || fail "gh is not authenticated" + +repo_slug="$(gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null || true)" +[[ -n "${repo_slug}" ]] || fail "unable to resolve repository slug via gh repo view" + +sha="$(git rev-parse HEAD)" +check_runs_total="$( + gh api "repos/${repo_slug}/commits/${sha}/check-runs" --jq '.total_count' 2>/dev/null || true +)" +[[ -n "${check_runs_total}" ]] || fail "unable to read check-runs for HEAD commit" +[[ "${check_runs_total}" != "0" ]] || fail "HEAD commit has no check-runs" + +check_runs_pending="$( + gh api "repos/${repo_slug}/commits/${sha}/check-runs" --jq '[.check_runs[] | select(.status != "completed")] | length' +)" +[[ "${check_runs_pending}" = "0" ]] || fail "HEAD commit has pending check-runs (${check_runs_pending})" + +check_runs_failing="$( + gh api "repos/${repo_slug}/commits/${sha}/check-runs" --jq '[.check_runs[] | select(.status == "completed" and (.conclusion != "success" and .conclusion != "neutral" and .conclusion != "skipped"))] | length' +)" +[[ "${check_runs_failing}" = "0" ]] || fail "HEAD commit has failing check-runs (${check_runs_failing})" + +main_section="$( + awk ' + /^## main$/ { in_main = 1; next } + /^## / && in_main { exit } + in_main { print } + ' CHANGELOG.md +)" + +[[ -n "${main_section}" ]] || fail "missing or empty ## main section in CHANGELOG.md" + +echo "${main_section}" | grep -q '\- \[x\]' || fail "CHANGELOG.md ## main section must contain at least one completed item (- [x])" +if echo "${main_section}" | grep -q '\- \[ \]'; then + fail "CHANGELOG.md ## main section still contains open checklist items (- [ ])" +fi + +echo "release-ready: ok" diff --git a/test/scenario/ini_val-robust/run.sh b/test/scenario/ini_val-robust/run.sh new file mode 100644 index 0000000..a2ab590 --- /dev/null +++ b/test/scenario/ini_val-robust/run.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -o pipefail +set -o errexit +set -o nounset +# set -o xtrace + +__dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +__root="$(cd "$(dirname "$(dirname "$(dirname "${__dir}")")")" && pwd)" + +__ini_tmp="$(mktemp "${TMPDIR:-/tmp}/ini-val-robust.XXXXXX")" +function cleanup_before_exit() { rm -f "${__ini_tmp:?}"; } +trap cleanup_before_exit EXIT + +echo "# command-mode-default-section" +bash "${__root}/src/ini_val.sh" "${__ini_tmp}" orphan "value-with-hyphen" +bash "${__root}/src/ini_val.sh" "${__ini_tmp}" orphan + +echo "# command-mode-comment-roundtrip" +bash "${__root}/src/ini_val.sh" "${__ini_tmp}" section.key "value-one" "section key comment" +bash "${__root}/src/ini_val.sh" "${__ini_tmp}" section.key "value-two" +bash "${__root}/src/ini_val.sh" "${__ini_tmp}" section.key + +echo "# source-mode-default-section" +# shellcheck source=src/ini_val.sh +source "${__root}/src/ini_val.sh" +ini_val "${__ini_tmp}" source_only "from-source" +ini_val "${__ini_tmp}" source_only + +echo "# final-ini" +cat "${__ini_tmp}" diff --git a/test/scenario/ini_val/run.sh b/test/scenario/ini_val/run.sh index efc4941..a55b220 100755 --- a/test/scenario/ini_val/run.sh +++ b/test/scenario/ini_val/run.sh @@ -10,7 +10,6 @@ __root="$(cd "$(dirname "$(dirname "$(dirname "${__dir}")")")" && pwd)" # echo "ACCPTST:STDIO_REPLACE_DATETIMES" - # Use as standalone: cp -f data.ini dummy.ini echo "--> command: Read 3 values" diff --git a/test/scenario/main-logging-contracts/run.sh b/test/scenario/main-logging-contracts/run.sh new file mode 100644 index 0000000..d7f5bf0 --- /dev/null +++ b/test/scenario/main-logging-contracts/run.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -o pipefail +set -o errexit +set -o nounset +# set -o xtrace + +__dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +__root="$(cd "$(dirname "$(dirname "$(dirname "${__dir}")")")" && pwd)" + +__stdout_tmp="$(mktemp "${TMPDIR:-/tmp}/main-logging-contracts-stdout.XXXXXX")" +__stderr_tmp="$(mktemp "${TMPDIR:-/tmp}/main-logging-contracts-stderr.XXXXXX")" + +function cleanup_before_exit() { + rm -f "${__stdout_tmp:?}" "${__stderr_tmp:?}" +} +trap cleanup_before_exit EXIT + +set +o errexit +env LOG_LEVEL=4 NO_COLOR=true bash "${__root}/main.sh" -f /tmp/x >"${__stdout_tmp}" 2>"${__stderr_tmp}" +__rc=$? +set -o errexit + +echo "exit=${__rc}" + +if [[ -s "${__stdout_tmp}" ]]; then + echo "stdout=not-empty" +else + echo "stdout=empty" +fi + +if grep -q '\[ info\]' "${__stderr_tmp}"; then + echo "info=present" +else + echo "info=absent" +fi + +if grep -q '\[ warning\]' "${__stderr_tmp}"; then + echo "warning=present" +else + echo "warning=absent" +fi + +if grep -q '\[emergency\]' "${__stderr_tmp}"; then + echo "emergency=present" +else + echo "emergency=absent" +fi diff --git a/test/scenario/main-longopt-errors/run.sh b/test/scenario/main-longopt-errors/run.sh new file mode 100644 index 0000000..02449f9 --- /dev/null +++ b/test/scenario/main-longopt-errors/run.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -o pipefail +set -o errexit +set -o nounset +# set -o xtrace + +__dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +__root="$(cd "$(dirname "$(dirname "$(dirname "${__dir}")")")" && pwd)" + +echo "ACCPTST:STDIO_REPLACE_DATETIMES" + +function run_case() { + local label="${1}" + shift + local output_file + output_file="$(mktemp "${TMPDIR:-/tmp}/main-longopt-errors.XXXXXX")" + + set +o errexit + "$@" >"${output_file}" 2>&1 + local rc="${?}" + set -o errexit + + echo "# ${label}" + echo "exit: ${rc}" + grep -E 'Invalid use of script: --|requires an argument|does not take an argument|arg_f:|arg_d:' "${output_file}" \ + | grep -Ev '^\+\(' || true + + rm -f "${output_file}" +} + +run_case "unknown-long-option" bash "${__root}/main.sh" --unknown -f /tmp/x +run_case "unknown-long-option-invalid-chars" bash "${__root}/main.sh" --invalid.option -f /tmp/x +run_case "hyphen-prefixed-value" bash "${__root}/main.sh" --file - --debug +run_case "empty-string-value" bash "${__root}/main.sh" -f /tmp/x --temp "" --debug +run_case "missing-value-end-of-input" bash "${__root}/main.sh" --file +run_case "flag-assignment-on-boolean" bash "${__root}/main.sh" -f /tmp/x --debug=true +run_case "double-dash-separator" bash "${__root}/main.sh" -f /tmp/x -- --debug diff --git a/test/scenario/main-longopt/run.sh b/test/scenario/main-longopt/run.sh index 3d2cc77..754d29f 100644 --- a/test/scenario/main-longopt/run.sh +++ b/test/scenario/main-longopt/run.sh @@ -10,7 +10,7 @@ __root="$(cd "$(dirname "$(dirname "$(dirname "${__dir}")")")" && pwd)" echo "ACCPTST:STDIO_REPLACE_DATETIMES" ( - env LOG_LEVEL=6 bash "${__root}/main.sh" --file /tmp/x; - env LOG_LEVEL=6 bash "${__root}/main.sh" --file=/tmp/x; + env LOG_LEVEL=6 bash "${__root}/main.sh" --file /tmp/x + env LOG_LEVEL=6 bash "${__root}/main.sh" --file=/tmp/x env LOG_LEVEL=6 bash "${__root}/main.sh" -f /tmp/x -) 2>&1 |grep arg_f +) 2>&1 | grep arg_f diff --git a/test/scenario/main-repeated/run.sh b/test/scenario/main-repeated/run.sh index b72b7c1..e782e3c 100644 --- a/test/scenario/main-repeated/run.sh +++ b/test/scenario/main-repeated/run.sh @@ -11,9 +11,9 @@ echo "ACCPTST:STDIO_REPLACE_DATETIMES" ( env LOG_LEVEL="${LOG_LEVEL:-6}" bash "${__root}/main.sh" -f dummy -i simple_input -i "input_in_quotes" -i "input with spaces" -i "input with \"quotes\"" -i last_input -) 2>&1 |grep arg_i -A 5 +) 2>&1 | grep arg_i -A 5 ( env LOG_LEVEL="${LOG_LEVEL:-6}" bash "${__root}/main.sh" -x -f dummy -x -x env LOG_LEVEL="${LOG_LEVEL:-6}" bash "${__root}/main.sh" -f dummy -xxxx -) 2>&1 |grep arg_x +) 2>&1 | grep arg_x diff --git a/test/scenario/parse_url-robust/run.sh b/test/scenario/parse_url-robust/run.sh new file mode 100644 index 0000000..214dcd4 --- /dev/null +++ b/test/scenario/parse_url-robust/run.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -o pipefail +set -o errexit +set -o nounset +# set -o xtrace + +__dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +__root="$(cd "$(dirname "$(dirname "$(dirname "${__dir}")")")" && pwd)" +__stdout_tmp="$(mktemp "${TMPDIR:-/tmp}/parse-url-robust-stdout.XXXXXX")" +__stderr_tmp="$(mktemp "${TMPDIR:-/tmp}/parse-url-robust-stderr.XXXXXX")" + +function cleanup_before_exit() { + rm -f "${__stdout_tmp:?}" "${__stderr_tmp:?}" +} +trap cleanup_before_exit EXIT + +# shellcheck source=src/parse_url.sh +source "${__root}/src/parse_url.sh" + +echo "# no-proto-with-path" +echo "host=$(parse_url 'example.com/path/to/resource' host)" +echo "path=$(parse_url 'example.com/path/to/resource' path)" + +echo "# https-default-port" +echo "port=$(parse_url 'https://example.com' port)" + +echo "# redis-default-port" +echo "port=$(parse_url 'redis://cache.internal' port)" + +echo "# user-without-pass" +echo "user=$(parse_url 'ssh://jane@example.org' user)" +echo "pass=$(parse_url 'ssh://jane@example.org' pass)" + +echo "# explicit-port-no-path" +echo "host=$(parse_url 'http://api.example.org:9000' host)" +echo "port=$(parse_url 'http://api.example.org:9000' port)" +echo "path=$(parse_url 'http://api.example.org:9000' path)" + +echo "# unknown-selector" +set +o errexit +parse_url 'https://example.org' bogus >"${__stdout_tmp}" 2>"${__stderr_tmp}" +__rc=$? +set -o errexit +echo "exit=${__rc}" +if [[ -s "${__stdout_tmp}" ]]; then + echo "stdout=not-empty" +else + echo "stdout=empty" +fi +if grep -q 'unknown field selector' "${__stderr_tmp}"; then + echo "selector-guard=present" +else + echo "selector-guard=absent" +fi diff --git a/test/scenario/parse_url-strict/run.sh b/test/scenario/parse_url-strict/run.sh new file mode 100644 index 0000000..ed374af --- /dev/null +++ b/test/scenario/parse_url-strict/run.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -o pipefail +set -o errexit +set -o nounset +# set -o xtrace + +__dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +__root="$(cd "$(dirname "$(dirname "$(dirname "${__dir}")")")" && pwd)" + +# shellcheck source=src/parse_url.sh +source "${__root}/src/parse_url.sh" + +echo "# strict-http-host" +parse_url 'http://example.com/index.html' host + +echo "# strict-https-port" +parse_url 'https://example.com' port + +echo "# strict-mysql-default-port" +parse_url 'mysql://db.internal' port + +echo "# strict-no-proto-host" +parse_url 'example.com/path' host diff --git a/test/scenario/templater-robust/run.sh b/test/scenario/templater-robust/run.sh new file mode 100644 index 0000000..1cff3db --- /dev/null +++ b/test/scenario/templater-robust/run.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -o pipefail +set -o errexit +set -o nounset +# set -o xtrace + +__dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +__root="$(cd "$(dirname "$(dirname "$(dirname "${__dir}")")")" && pwd)" + +__template_tmp="$(mktemp "${TMPDIR:-/tmp}/templater-robust-template.XXXXXX")" +__output_tmp="$(mktemp "${TMPDIR:-/tmp}/templater-robust-output.XXXXXX")" +__error_tmp="$(mktemp "${TMPDIR:-/tmp}/templater-robust-error.XXXXXX")" + +function cleanup_before_exit() { + rm -f "${__template_tmp:?}" "${__output_tmp:?}" "${__error_tmp:?}" +} +trap cleanup_before_exit EXIT + +cat >"${__template_tmp}" <<-'EOF' +line1=${VALUE_ONE} +line2=${VALUE_TWO} +line3=${UNSET_VALUE} +EOF + +echo "# command-mode-special-values" +env ALLOW_REMAINDERS=1 VALUE_ONE='a&b/c#d' VALUE_TWO='value with spaces' \ + bash "${__root}/src/templater.sh" "${__template_tmp}" "${__output_tmp}" +cat "${__output_tmp}" + +echo "# source-mode-special-values" +# shellcheck source=src/templater.sh +source "${__root}/src/templater.sh" +VALUE_ONE='x-y-z' VALUE_TWO='another value' ALLOW_REMAINDERS=1 templater "${__template_tmp}" "${__output_tmp}" +cat "${__output_tmp}" + +echo "# command-mode-missing-template" +set +o errexit +bash "${__root}/src/templater.sh" ./does-not-exist.template "${__output_tmp}" >"${__error_tmp}" 2>&1 +__rc=$? +set -o errexit +echo "exit: ${__rc}" +cat "${__error_tmp}" diff --git a/test/scenario/templater/run.sh b/test/scenario/templater/run.sh index da0abbf..fe9bd55 100644 --- a/test/scenario/templater/run.sh +++ b/test/scenario/templater/run.sh @@ -11,7 +11,7 @@ __base="$(basename "${__file}" .sh)" __root="$(cd "$(dirname "$(dirname "$(dirname "${__dir}")")")" && pwd)" __templaterTmpFile=$(mktemp "${TMPDIR:-/tmp}/${__base}.XXXXXX") -function cleanup_before_exit () { rm "${__templaterTmpFile:?}"; } +function cleanup_before_exit() { rm "${__templaterTmpFile:?}"; } trap cleanup_before_exit EXIT echo "--" diff --git a/test/shfmt.sh b/test/shfmt.sh new file mode 100755 index 0000000..2a57539 --- /dev/null +++ b/test/shfmt.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Lint or fix shell scripts with shfmt. +# +# Usage: +# test/shfmt.sh # lint (exit 1 on diff) +# test/shfmt.sh fix # fix in place +# +# Installs shfmt idempotently if the expected version is not on PATH. + +set -o errexit +set -o errtrace +set -o nounset +set -o pipefail + +SHFMT_VERSION="${SHFMT_VERSION:-3.12.0}" +SHFMT_INSTALL_DIR="${SHFMT_INSTALL_DIR:-${HOME}/.local/bin}" + +__dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +__root="$(cd "$(dirname "${__dir}")" && pwd)" + +### Ensure shfmt is available at the expected version +############################################################################## + +__shfmt_ensure() { + if command -v shfmt >/dev/null 2>&1; then + local current + current="$(shfmt --version 2>/dev/null || true)" + current="${current#v}" + if [[ "${current}" = "${SHFMT_VERSION}" ]]; then + return 0 + fi + echo "shfmt ${current} found, need ${SHFMT_VERSION} — installing" + fi + + local os arch + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + arch="$(uname -m)" + + case "${arch}" in + x86_64) arch="amd64" ;; + aarch64) arch="arm64" ;; + arm64) arch="arm64" ;; + *) + echo "Unsupported architecture: ${arch}" >&2 + exit 1 + ;; + esac + + local url="https://github.com/mvdan/sh/releases/download/v${SHFMT_VERSION}/shfmt_v${SHFMT_VERSION}_${os}_${arch}" + local target="${SHFMT_INSTALL_DIR}/shfmt" + + echo "Installing shfmt v${SHFMT_VERSION} (${os}/${arch}) to ${target}" + mkdir -p "${SHFMT_INSTALL_DIR}" + curl -fsSL "${url}" -o "${target}" + chmod +x "${target}" + + # make sure it's on PATH for the rest of this script + export PATH="${SHFMT_INSTALL_DIR}:${PATH}" +} + +### Collect shell files +############################################################################## + +__shfmt_files() { + find "${__root}" -type f -name '*.sh' -not -path '*/node_modules/*' +} + +### Main +############################################################################## + +__shfmt_ensure + +mode="${1:-lint}" + +case "${mode}" in +fix) + echo "Fixing with shfmt v${SHFMT_VERSION} (-i 2 -bn)" + __shfmt_files | xargs shfmt -w -i 2 -bn + echo "Done" + ;; +lint) + echo "Linting with shfmt v${SHFMT_VERSION} (-i 2 -bn)" + if ! __shfmt_files | xargs shfmt -d -i 2 -bn; then + echo "" + echo "Formatting issues found. Run 'yarn fix:shfmt' to fix." + exit 1 + fi + echo "OK" + ;; +*) + echo "Usage: ${0} [lint|fix]" >&2 + exit 1 + ;; +esac diff --git a/test/style.pl b/test/style.pl index 9dffcb5..f935e25 100755 --- a/test/style.pl +++ b/test/style.pl @@ -6,40 +6,40 @@ die "usage: $0 \n" if (not @ARGV); my $rc = 0; -my $file = shift; +for my $file (@ARGV) { + open(my $fh, '<', $file) or die "Cannot open \`$file' for read: $!\n"; + while (<$fh>) { + next if (/^\s*#/); -open(my $fh, '<', $file) or die "Cannot open \`$file' for read: $!\n"; -while (<$fh>) { - next if (/^\s*#/); + my $errors = 0; - my $errors = 0; + # remove everything between single quotes + # this will remove too much in case of: echo "var='$var'" + # and thus miss an opportunity to complain later on + # also it mangles the input line irreversible + s/'[^']+'/'___'/g; - # remove everything between single quotes - # this will remove too much in case of: echo "var='$var'" - # and thus miss an opportunity to complain later on - # also it mangles the input line irreversible - s/'[^']+'/'___'/g; + # highlight unbraced variables-- + # unless properly backslash'ed + $errors += s/((?:^|[^\\]))(((\\\\)+)?\$\w)/$1\033[31m$2\033[0m/g; - # highlight unbraced variables-- - # unless properly backslash'ed - $errors += s/((?:^|[^\\]))(((\\\\)+)?\$\w)/$1\033[31m$2\033[0m/g; + # highlight single square brackets + $errors += s/((?:^|\s+))\[([^\[].+[^\]])\](\s*(;|&&|\|\|))/$1\033[31m\[\033[0m$2\033[31m\]\033[0m$3/g; - # highlight single square brackets - $errors += s/((?:^|\s+))\[([^\[].+[^\]])\](\s*(;|&&|\|\|))/$1\033[31m\[\033[0m$2\033[31m\]\033[0m$3/g; + # highlight double equal sign + $errors += s/(\[\[.*)(==)(.*\]\])/$1\033[31m$2\033[0m$3/g; - # highlight double equal sign - $errors += s/(\[\[.*)(==)(.*\]\])/$1\033[31m$2\033[0m$3/g; + # highlight tabs mixed with whitespace at beginning of lines + $errors += s/^( *)(\t+ *)/\033[31m\[$2\]\033[0m/; - # highlight tabs mixed with whitespace at beginning of lines - $errors += s/^( *)(\t+ *)/\033[31m\[$2\]\033[0m/; + # highlight trailing whitespace + $errors += s/([ \t]+)$/\033[31m\[$1\]\033[0m/; - # highlight trailing whitespace - $errors += s/([ \t]+)$/\033[31m\[$1\]\033[0m/; - - next if (not $errors); - print "${file}[$.]: $_"; - $rc = 1; + next if (not $errors); + print "${file}[$.]: $_"; + $rc = 1; + } + close($fh); } -close($fh); exit $rc;