This commit is contained in:
2026-03-10 00:14:14 -05:00
commit 78ca7b4d8b
25 changed files with 1801 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
# Generated files
.env
.env.local
.env.*.local
# IDEs
.idea/
.vscode/
# macOS
.DS_Store
# Temporary files
*.swp
*.swo
*~

39
HELP Normal file
View File

@@ -0,0 +1,39 @@
generate-env.sh — Interactively generate a .env file from a .env.example template.
Usage:
./generate-env.sh [options] [input_file]
Arguments:
input_file Path to the .env.example file (default: .env.example)
Options:
-o <file> Output file path (default: .env)
-h Show this help message
Annotation syntax in .env.example:
# Description text for the variable
# @required
# @type <type>
VAR_NAME=default_value
Supported types:
string Plain text (default when @type is absent)
number Numeric value; validated on entry
bool Boolean; prompts y/n, stored as true/false
secret Hidden input with confirmation; stored as-is
url Text; warns if value doesn't start with http(s)://
email Text; validated as user@domain.tld
enum:a,b,c Presents a numbered menu of choices
Using as a git submodule:
Add to your project:
git submodule add https://code.garrettmills.dev/garrettmills/interactive-env
Run from your project root:
./interactive-env/generate-env.sh
Update to the latest version:
git submodule update --remote interactive-env
Clone a project that uses this submodule:
git clone --recurse-submodules <your-repo-url>

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Garrett Mills
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

9
README.md Normal file
View File

@@ -0,0 +1,9 @@
# `interactive-env`
Interactively generate a `.env` file from a `.env.example` template.
It is implemented as a portable bash script. Variables are annotated with structured comments (`@type`, `@required`) to drive prompts, validation, and type-specific input (booleans, enums, secrets, etc.).
See [`HELP`](./HELP) for full usage and annotation syntax.
This project is licensed under the [MIT License](./LICENSE).

30
example.env.example Normal file
View File

@@ -0,0 +1,30 @@
# The name of the application
# @required
APP_NAME=MyApp
# The environment to run in
# @type enum:development,staging,production
# @required
APP_ENV=development
# Port number the server listens on
# @type number
PORT=3000
# Enable debug logging
# @type bool
DEBUG=false
# Secret key for signing JWTs
# @type secret
# @required
JWT_SECRET=
# Database connection URL
# @type url
# @required
DATABASE_URL=postgres://localhost:5432/myapp
# Optional email address for admin notifications
# @type email
ADMIN_EMAIL=

77
generate-env.sh Executable file
View File

@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# generate-env.sh — Interactively generate a .env file from a .env.example template.
# Run with -h for usage information.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/colors.sh
source "${SCRIPT_DIR}/lib/colors.sh"
# shellcheck source=lib/parse.sh
source "${SCRIPT_DIR}/lib/parse.sh"
# shellcheck source=lib/validate.sh
source "${SCRIPT_DIR}/lib/validate.sh"
# shellcheck source=lib/prompt.sh
source "${SCRIPT_DIR}/lib/prompt.sh"
# shellcheck source=lib/output.sh
source "${SCRIPT_DIR}/lib/output.sh"
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
INPUT_FILE=".env.example"
OUTPUT_FILE=".env"
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
usage() {
cat "${SCRIPT_DIR}/HELP" >&2
exit 0
}
while getopts ":o:h" opt; do
case "$opt" in
o) OUTPUT_FILE="$OPTARG" ;;
h) usage ;;
:) printf "${RED}Error: option -%s requires an argument.${NC}\n" "$OPTARG" >&2; exit 1 ;;
\?) printf "${RED}Error: unknown option -%s${NC}\n" "$OPTARG" >&2; exit 1 ;;
esac
done
shift $((OPTIND - 1))
if [[ $# -gt 0 ]]; then
INPUT_FILE="$1"
fi
# ---------------------------------------------------------------------------
# Guard: don't silently overwrite an existing .env without confirmation
# ---------------------------------------------------------------------------
if [[ -f "$OUTPUT_FILE" ]]; then
printf "${YELLOW}Warning: %s already exists.${NC}\n" "$OUTPUT_FILE" >&2
read -r -p "Overwrite? [y/N]: " confirm >&2
case "${confirm,,}" in
y|yes) ;;
*) printf "Aborted.\n" >&2; exit 0 ;;
esac
fi
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
printf "${BOLD}generate-env${NC} %s → %s\n" "$INPUT_FILE" "$OUTPUT_FILE" >&2
printf "${DIM}Fill in each variable. Press Enter to accept the default value.${NC}\n" >&2
parse_env_example "$INPUT_FILE"
if [[ ${#ENV_VARS[@]} -eq 0 ]]; then
printf "${YELLOW}No variables found in %s.${NC}\n" "$INPUT_FILE" >&2
exit 0
fi
printf "${DIM}Found %d variable(s).${NC}\n" "${#ENV_VARS[@]}" >&2
prompt_all_variables
write_env_file "$OUTPUT_FILE"

20
lib/colors.sh Normal file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Terminal color and formatting constants.
# Source this file; do not execute it directly.
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
NC='\033[0m' # No Color / Reset
# Print a colored message to stderr
# Usage: color_echo <color_var> <message>
color_echo() {
local color="$1"
local msg="$2"
printf "${color}%s${NC}\n" "$msg" >&2
}

28
lib/output.sh Normal file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Writes collected ENV_VALUE_* variables to a .env file.
# Source this file; do not execute it directly.
# Requires colors.sh to be sourced first.
# write_env_file <output_file>
# Writes all populated ENV_VALUE_<NAME> entries to <output_file>.
write_env_file() {
local output_file="$1"
{
printf "# Generated by generate-env on %s\n" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
printf "\n"
local var_name
for var_name in "${ENV_VARS[@]}"; do
local desc_var="ENV_DESC_${var_name}"
local desc="${!desc_var}"
local value_var="ENV_VALUE_${var_name}"
local value="${!value_var}"
[[ -n "$desc" ]] && printf "# %s\n" "$desc"
printf "%s=%s\n" "$var_name" "$value"
printf "\n"
done
} > "$output_file"
printf "\n${GREEN}✓ Written to %s${NC}\n" "$output_file" >&2
}

87
lib/parse.sh Normal file
View File

@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Parses a .env.example file into global metadata arrays.
# Source this file; do not execute it directly.
#
# After calling parse_env_example, the following globals are populated:
# ENV_VARS - ordered array of variable names
# ENV_DESC_<NAME> - description string
# ENV_TYPE_<NAME> - type annotation (string if absent)
# ENV_REQUIRED_<NAME> - "true" or "false"
# ENV_DEFAULT_<NAME> - default value (may be empty)
# parse_env_example <file>
# Reads the given .env.example file and populates global metadata.
parse_env_example() {
local file="$1"
if [[ ! -f "$file" ]]; then
printf "Error: file not found: %s\n" "$file" >&2
return 1
fi
ENV_VARS=()
local desc=""
local type=""
local required="false"
local in_block=0 # 1 once we've started accumulating comments for a var
while IFS= read -r line || [[ -n "$line" ]]; do
# --- Empty line: flush pending comment block ---
if [[ -z "$line" ]]; then
if [[ $in_block -eq 1 ]]; then
desc=""
type=""
required="false"
in_block=0
fi
continue
fi
# --- Comment line ---
if [[ "$line" =~ ^[[:space:]]*#(.*) ]]; then
local comment="${BASH_REMATCH[1]}"
# Trim one leading space if present
comment="${comment# }"
in_block=1
if [[ "$comment" =~ ^@required([[:space:]]|$) ]]; then
required="true"
elif [[ "$comment" =~ ^@type[[:space:]]+(.+)$ ]]; then
type="${BASH_REMATCH[1]}"
# Trim trailing whitespace
type="${type%"${type##*[![:space:]]}"}"
else
# Regular description text — append
if [[ -n "$desc" ]]; then
desc="${desc} ${comment}"
else
desc="$comment"
fi
fi
continue
fi
# --- KEY=VALUE line ---
if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
local var_name="${BASH_REMATCH[1]}"
local default="${BASH_REMATCH[2]}"
ENV_VARS+=("$var_name")
printf -v "ENV_DESC_${var_name}" '%s' "$desc"
printf -v "ENV_TYPE_${var_name}" '%s' "${type:-string}"
printf -v "ENV_REQUIRED_${var_name}" '%s' "$required"
printf -v "ENV_DEFAULT_${var_name}" '%s' "$default"
# Reset for next block
desc=""
type=""
required="false"
in_block=0
continue
fi
done < "$file"
}

217
lib/prompt.sh Normal file
View File

@@ -0,0 +1,217 @@
#!/usr/bin/env bash
# Interactive per-variable prompting.
# Source this file; do not execute it directly.
# Requires colors.sh and validate.sh to be sourced first.
# _prompt_enum <var_name> <options_csv> <default>
# Prints a numbered menu and returns the chosen value via stdout.
_prompt_enum() {
local var_name="$1"
local options_csv="$2"
local default="$3"
local -a opts
local IFS=','
read -ra opts <<< "$options_csv"
unset IFS
local default_idx=0
local i
for i in "${!opts[@]}"; do
printf " %d) %s" $((i + 1)) "${opts[$i]}" >&2
if [[ "${opts[$i]}" == "$default" ]]; then
printf " ${DIM}(default)${NC}" >&2
default_idx=$((i + 1))
fi
printf "\n" >&2
done
local choice value
while true; do
if [[ $default_idx -gt 0 ]]; then
read -r -p " Choice [1-${#opts[@]}, default ${default_idx}]: " choice >&2
else
read -r -p " Choice [1-${#opts[@]}]: " choice >&2
fi
# Empty input → use default
if [[ -z "$choice" && $default_idx -gt 0 ]]; then
echo "${opts[$((default_idx - 1))]}"
return 0
fi
# Numeric selection
if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#opts[@]} )); then
echo "${opts[$((choice - 1))]}"
return 0
fi
# Direct string match
local opt
for opt in "${opts[@]}"; do
if [[ "$choice" == "$opt" ]]; then
echo "$opt"
return 0
fi
done
printf "${RED} Invalid choice. Enter a number between 1 and %d.${NC}\n" "${#opts[@]}" >&2
done
}
# _prompt_bool <default>
# Prompts y/n and returns canonical "true" or "false" via stdout.
_prompt_bool() {
local default="$1"
local hint
case "${default,,}" in
true|yes|1|y) hint="Y/n" ;;
false|no|0|n) hint="y/N" ;;
*) hint="y/n" ;;
esac
local input
while true; do
read -r -p " Enable? [${hint}]: " input >&2
if [[ -z "$input" && -n "$default" ]]; then
normalize_bool "$default"
return 0
fi
case "${input,,}" in
y|yes|true|1) echo "true"; return 0 ;;
n|no|false|0) echo "false"; return 0 ;;
*) printf "${RED} Enter y or n.${NC}\n" >&2 ;;
esac
done
}
# _prompt_secret <var_name> <default>
# Reads a secret (hidden) value, with optional confirmation, via stdout.
_prompt_secret() {
local var_name="$1"
local default="$2"
local value confirm
while true; do
if [[ -n "$default" ]]; then
read -r -s -p " Value (leave blank to keep default): " value >&2
else
read -r -s -p " Value: " value >&2
fi
printf "\n" >&2
if [[ -z "$value" && -n "$default" ]]; then
echo "$default"
return 0
fi
if [[ -n "$value" ]]; then
read -r -s -p " Confirm value: " confirm >&2
printf "\n" >&2
if [[ "$value" == "$confirm" ]]; then
echo "$value"
return 0
fi
printf "${RED} Values do not match. Try again.${NC}\n" >&2
else
printf "${RED} Value cannot be empty for a secret field with no default.${NC}\n" >&2
fi
done
}
# prompt_variable <var_name>
# Interactively prompts the user for a value and stores it in ENV_VALUE_<var_name>.
prompt_variable() {
local var_name="$1"
# Dereference metadata
local desc_var="ENV_DESC_${var_name}"
local type_var="ENV_TYPE_${var_name}"
local required_var="ENV_REQUIRED_${var_name}"
local default_var="ENV_DEFAULT_${var_name}"
local desc="${!desc_var}"
local type="${!type_var}"
local required="${!required_var}"
local default="${!default_var}"
# ---- Header ----
printf "\n" >&2
printf "${BOLD}${CYAN}%s${NC}" "$var_name" >&2
if [[ "$required" == "true" ]]; then
printf " ${RED}(required)${NC}" >&2
else
printf " ${DIM}(optional)${NC}" >&2
fi
if [[ -n "$type" && "$type" != "string" ]]; then
printf " ${DIM}[%s]${NC}" "$type" >&2
fi
printf "\n" >&2
if [[ -n "$desc" ]]; then
printf " ${DIM}%s${NC}\n" "$desc" >&2
fi
# ---- Type-specific prompt ----
local value
case "$type" in
bool)
value="$(_prompt_bool "$default")"
;;
secret)
value="$(_prompt_secret "$var_name" "$default")"
;;
enum:*)
local options="${type#enum:}"
printf " ${DIM}Options:${NC}\n" >&2
value="$(_prompt_enum "$var_name" "$options" "$default")"
;;
*)
# Generic text input (string, number, url, email)
local prompt_str
if [[ -n "$default" ]]; then
prompt_str=" Value (default: ${default}): "
else
prompt_str=" Value: "
fi
while true; do
read -r -p "$prompt_str" value >&2
if [[ -z "$value" ]]; then
value="$default"
fi
if validate_value "$var_name" "$value" "$type" "$required"; then
break
fi
done
;;
esac
# For bool/enum/secret we still run the required check after collecting value
if [[ "$type" == "bool" || "$type" == "secret" || "$type" == enum:* ]]; then
while ! validate_value "$var_name" "$value" "$type" "$required"; do
case "$type" in
bool) value="$(_prompt_bool "$default")" ;;
secret) value="$(_prompt_secret "$var_name" "$default")" ;;
enum:*) value="$(_prompt_enum "$var_name" "${type#enum:}" "$default")" ;;
esac
done
fi
printf -v "ENV_VALUE_${var_name}" '%s' "$value"
}
# prompt_all_variables
# Iterates over ENV_VARS and prompts for each one.
prompt_all_variables() {
local var_name
for var_name in "${ENV_VARS[@]}"; do
prompt_variable "$var_name"
done
}

90
lib/validate.sh Normal file
View File

@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Value validation helpers, keyed by @type annotation.
# Source this file; do not execute it directly.
# Requires colors.sh to be sourced first.
# validate_value <var_name> <value> <type> <required>
# Returns 0 if valid, 1 if invalid (prints a message to stderr).
validate_value() {
local var_name="$1"
local value="$2"
local type="$3"
local required="$4"
# Required check
if [[ "$required" == "true" && -z "$value" ]]; then
printf "${RED} Error: %s is required and cannot be empty.${NC}\n" "$var_name" >&2
return 1
fi
# No further validation needed for empty optional values
if [[ -z "$value" ]]; then
return 0
fi
case "$type" in
number)
if ! [[ "$value" =~ ^-?[0-9]+(\.[0-9]+)?$ ]]; then
printf "${RED} Error: Must be a number (got: %s).${NC}\n" "$value" >&2
return 1
fi
;;
bool)
case "${value,,}" in
true|false|yes|no|1|0|y|n) ;;
*)
printf "${RED} Error: Must be a boolean value (true/false/yes/no).${NC}\n" >&2
return 1
;;
esac
;;
url)
if ! [[ "$value" =~ ^https?:// ]]; then
printf "${YELLOW} Warning: Value doesn't look like a URL (expected http:// or https://).${NC}\n" >&2
# Warn only — allow the user to proceed
fi
;;
email)
if ! [[ "$value" =~ ^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$ ]]; then
printf "${RED} Error: Must be a valid email address (got: %s).${NC}\n" "$value" >&2
return 1
fi
;;
enum:*)
local options="${type#enum:}"
local valid=false
local IFS=','
local opt
for opt in $options; do
if [[ "$value" == "$opt" ]]; then
valid=true
break
fi
done
if [[ "$valid" == "false" ]]; then
printf "${RED} Error: Must be one of: %s (got: %s).${NC}\n" "$options" "$value" >&2
return 1
fi
;;
secret|string|*)
# No structural validation for these types
;;
esac
return 0
}
# normalize_bool <value>
# Converts a truthy/falsy string to canonical "true" or "false".
normalize_bool() {
case "${1,,}" in
true|yes|1|y) echo "true" ;;
false|no|0|n) echo "false" ;;
*) echo "$1" ;; # pass-through for validation to catch
esac
}

21
tests/fixtures/all_types.env.example vendored Normal file
View File

@@ -0,0 +1,21 @@
# @type string
STR_VAR=hello
# @type number
NUM_VAR=42
# @type bool
BOOL_VAR=false
# @type secret
# @required
SECRET_VAR=
# @type url
URL_VAR=https://example.com
# @type email
EMAIL_VAR=user@example.com
# @type enum:a,b,c
ENUM_VAR=a

3
tests/fixtures/basic.env.example vendored Normal file
View File

@@ -0,0 +1,3 @@
# Description
# @required
BASIC_VAR=default

0
tests/fixtures/empty.env.example vendored Normal file
View File

View File

@@ -0,0 +1,3 @@
# First line
# Second line
MULTI_VAR=value

View File

@@ -0,0 +1,2 @@
PLAIN_VAR=value
ANOTHER_VAR=

View File

@@ -0,0 +1,4 @@
# This comment has no variable
# It should be ignored
REAL_VAR=value

View File

@@ -0,0 +1,3 @@
# A URL with query parameters (value contains = signs)
# @type url
DB_URL=postgres://host:5432/db?sslmode=require&connect_timeout=10

163
tests/framework.sh Normal file
View File

@@ -0,0 +1,163 @@
#!/usr/bin/env bash
# Minimal test framework. Source this file; do not execute it directly.
#
# Globals maintained per-process:
# _PASS — count of passing assertions
# _FAIL — count of failing assertions
_PASS=0
_FAIL=0
# Color codes (self-contained; do not rely on colors.sh)
_R='\033[0;31m' # red
_G='\033[0;32m' # green
_Y='\033[1;33m' # yellow
_B='\033[1m' # bold
_D='\033[2m' # dim
_N='\033[0m' # reset
# suite <name>
# Print a section header for a group of related tests.
suite() {
printf "\n${_B}%s${_N}\n" "$1"
}
# pass / fail — low-level counters; prefer assert_* helpers instead
pass() {
_PASS=$((_PASS + 1))
printf " ${_G}${_N} %s\n" "$1"
}
fail() {
_FAIL=$((_FAIL + 1))
printf " ${_R}${_N} %s\n" "$1"
[[ -n "${2:-}" ]] && printf " expected: %s\n" "$2"
[[ -n "${3:-}" ]] && printf " got: %s\n" "${3:-<empty>}"
}
# ---------------------------------------------------------------------------
# Assertion helpers
# ---------------------------------------------------------------------------
assert_eq() {
local expected="$1" actual="$2" msg="${3:-assert_eq}"
if [[ "$expected" == "$actual" ]]; then
pass "$msg"
else
fail "$msg" "$expected" "$actual"
fi
}
assert_not_eq() {
local unexpected="$1" actual="$2" msg="${3:-assert_not_eq}"
if [[ "$unexpected" != "$actual" ]]; then
pass "$msg"
else
fail "$msg (expected values to differ)" "$unexpected" "$actual"
fi
}
assert_empty() {
local actual="$1" msg="${2:-assert_empty}"
if [[ -z "$actual" ]]; then
pass "$msg"
else
fail "$msg (expected empty)" "" "$actual"
fi
}
assert_not_empty() {
local actual="$1" msg="${2:-assert_not_empty}"
if [[ -n "$actual" ]]; then
pass "$msg"
else
fail "$msg (expected non-empty)"
fi
}
assert_contains() {
local needle="$1" haystack="$2" msg="${3:-assert_contains}"
if [[ "$haystack" == *"$needle"* ]]; then
pass "$msg"
else
fail "$msg" "*${needle}*" "$haystack"
fi
}
# assert_success <msg> <cmd> [args...]
# Passes when the command exits 0.
assert_success() {
local msg="$1"; shift
local rc=0
"$@" 2>/dev/null || rc=$?
if [[ $rc -eq 0 ]]; then
pass "$msg"
else
fail "$msg (expected exit 0, got $rc)"
fi
}
# assert_failure <msg> <cmd> [args...]
# Passes when the command exits non-zero.
assert_failure() {
local msg="$1"; shift
local rc=0
"$@" 2>/dev/null || rc=$?
if [[ $rc -ne 0 ]]; then
pass "$msg"
else
fail "$msg (expected non-zero exit)"
fi
}
assert_file_exists() {
local file="$1" msg="${2:-assert_file_exists}"
if [[ -f "$file" ]]; then
pass "$msg"
else
fail "$msg" "<file: $file>" "(not found)"
fi
}
assert_file_contains() {
local pattern="$1" file="$2" msg="${3:-assert_file_contains}"
if [[ ! -f "$file" ]]; then
fail "$msg (file not found: $file)"
return
fi
if grep -qF "$pattern" "$file"; then
pass "$msg"
else
fail "$msg" "$pattern" "(not found in $file)"
fi
}
assert_file_not_contains() {
local pattern="$1" file="$2" msg="${3:-assert_file_not_contains}"
if [[ ! -f "$file" ]]; then
fail "$msg (file not found: $file)"
return
fi
if ! grep -qF "$pattern" "$file"; then
pass "$msg"
else
fail "$msg" "(should not contain $pattern)" "(found in $file)"
fi
}
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
# print_summary
# Print pass/fail totals and exit 1 if any tests failed.
print_summary() {
local total=$((_PASS + _FAIL))
printf "\n"
if [[ $_FAIL -eq 0 ]]; then
printf "${_G}%d/%d passed${_N}\n" "$_PASS" "$total"
else
printf "${_R}%d failed${_N}, %d passed, %d total\n" "$_FAIL" "$_PASS" "$total"
fi
[[ $_FAIL -eq 0 ]]
}

75
tests/run_tests.sh Executable file
View File

@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Discovers and runs all test_*.sh files, printing a combined summary.
#
# Usage:
# ./tests/run_tests.sh # run all suites
# ./tests/run_tests.sh [file] # run a specific suite
set -uo pipefail
TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ---- Colors (self-contained) ----
_R='\033[0;31m'; _G='\033[0;32m'; _B='\033[1m'; _D='\033[2m'; _N='\033[0m'
total_pass=0
total_fail=0
failed_suites=()
run_suite() {
local file="$1"
local name
name="$(basename "$file")"
printf "\n${_B}══════ %s ══════${_N}\n" "$name"
local output exit_code=0
output=$(bash "$file" 2>&1) || exit_code=$?
echo "$output"
# Count results — strip ANSI codes first so the unicode chars are findable
local stripped p f
stripped=$(printf '%s' "$output" | sed 's/\x1b\[[0-9;]*[mGKHF]//g')
p=$(printf '%s\n' "$stripped" | grep -c '✓' || true)
f=$(printf '%s\n' "$stripped" | grep -c '✗' || true)
total_pass=$((total_pass + p))
total_fail=$((total_fail + f))
if [[ $exit_code -ne 0 ]]; then
failed_suites+=("$name")
fi
}
# Collect files to run
if [[ $# -gt 0 ]]; then
files=("$@")
else
files=("$TESTS_DIR"/test_*.sh)
fi
for f in "${files[@]}"; do
[[ -f "$f" ]] || { printf "${_R}Not found: %s${_N}\n" "$f" >&2; continue; }
run_suite "$f"
done
# ---- Aggregate summary ----
total=$((total_pass + total_fail))
printf "\n${_B}══════ Summary ══════${_N}\n"
printf "Suites run: %d\n" "${#files[@]}"
printf "Tests: %d total, " "$total"
if [[ $total_fail -eq 0 ]]; then
printf "${_G}%d passed${_N}\n" "$total_pass"
printf "\n${_G}All tests passed.${_N}\n"
exit 0
else
printf "${_G}%d passed${_N}, ${_R}%d failed${_N}\n" "$total_pass" "$total_fail"
printf "\n${_R}Failed suites:${_N}\n"
for s in "${failed_suites[@]}"; do
printf " - %s\n" "$s"
done
exit 1
fi

242
tests/test_e2e.sh Executable file
View File

@@ -0,0 +1,242 @@
#!/usr/bin/env bash
# End-to-end tests for generate-env.sh.
#
# Each test pipes a crafted stdin to generate-env.sh and inspects the output
# .env file. Input lines are counted to match each variable's prompt exactly.
TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$TESTS_DIR")"
SCRIPT="$ROOT_DIR/generate-env.sh"
source "$TESTS_DIR/framework.sh"
TMPDIR_SELF="$(mktemp -d)"
trap 'rm -rf "$TMPDIR_SELF"' EXIT
# run_gen <input_string> <input_file> <output_file>
# Runs generate-env.sh, feeding <input_string> as stdin.
# Suppresses all terminal output. Removes <output_file> first so the
# overwrite guard never triggers.
run_gen() {
local input="$1"
local in_file="$2"
local out_file="$3"
rm -f "$out_file"
# -o must come before the positional input-file argument so getopts sees it
printf "%s" "$input" | bash "$SCRIPT" -o "$out_file" "$in_file" 2>/dev/null
}
# ---------------------------------------------------------------------------
suite "e2e: accept all defaults"
# ---------------------------------------------------------------------------
# Fixture: three optional vars with defaults, no required fields
IN="$TMPDIR_SELF/defaults.env.example"
OUT="$TMPDIR_SELF/defaults.env"
cat > "$IN" << 'EOF'
# App name
APP_NAME=MyApp
# Port
# @type number
PORT=3000
# Debug mode
# @type bool
DEBUG=false
EOF
# Input: three Enter presses (accept all defaults)
run_gen $'\n\n\n' "$IN" "$OUT"
assert_file_exists "$OUT" "output file created"
assert_file_contains "APP_NAME=MyApp" "$OUT" "string default accepted"
assert_file_contains "PORT=3000" "$OUT" "number default accepted"
assert_file_contains "DEBUG=false" "$OUT" "bool default accepted"
# ---------------------------------------------------------------------------
suite "e2e: override defaults"
# ---------------------------------------------------------------------------
IN="$TMPDIR_SELF/override.env.example"
OUT="$TMPDIR_SELF/override.env"
cat > "$IN" << 'EOF'
APP_NAME=MyApp
PORT=3000
EOF
# Override both
run_gen $'CustomApp\n8080\n' "$IN" "$OUT"
assert_file_contains "APP_NAME=CustomApp" "$OUT" "string default overridden"
assert_file_contains "PORT=8080" "$OUT" "number default overridden"
# ---------------------------------------------------------------------------
suite "e2e: required fields must be filled"
# ---------------------------------------------------------------------------
IN="$TMPDIR_SELF/required.env.example"
OUT="$TMPDIR_SELF/required.env"
cat > "$IN" << 'EOF'
# @required
MUST_HAVE=
EOF
# First send empty (should re-prompt), then a value
run_gen $'\nfilled\n' "$IN" "$OUT"
assert_file_contains "MUST_HAVE=filled" "$OUT" "required field filled on retry"
# ---------------------------------------------------------------------------
suite "e2e: bool values"
# ---------------------------------------------------------------------------
IN="$TMPDIR_SELF/bool.env.example"
OUT="$TMPDIR_SELF/bool.env"
cat > "$IN" << 'EOF'
# @type bool
FLAG_A=false
# @type bool
FLAG_B=true
EOF
# y for FLAG_A (-> true), Enter for FLAG_B (-> default true)
run_gen $'y\n\n' "$IN" "$OUT"
assert_file_contains "FLAG_A=true" "$OUT" "y -> true"
assert_file_contains "FLAG_B=true" "$OUT" "empty accepts true default"
# ---------------------------------------------------------------------------
suite "e2e: enum with numeric choice"
# ---------------------------------------------------------------------------
IN="$TMPDIR_SELF/enum.env.example"
OUT="$TMPDIR_SELF/enum.env"
cat > "$IN" << 'EOF'
# @type enum:development,staging,production
# @required
APP_ENV=development
EOF
# Choose option 3 (production)
run_gen $'3\n' "$IN" "$OUT"
assert_file_contains "APP_ENV=production" "$OUT" "enum numeric choice 3 -> production"
# ---------------------------------------------------------------------------
suite "e2e: enum with string choice"
# ---------------------------------------------------------------------------
OUT="$TMPDIR_SELF/enum_str.env"
run_gen $'staging\n' "$IN" "$OUT"
assert_file_contains "APP_ENV=staging" "$OUT" "enum string choice -> staging"
# ---------------------------------------------------------------------------
suite "e2e: enum accepts default"
# ---------------------------------------------------------------------------
OUT="$TMPDIR_SELF/enum_default.env"
run_gen $'\n' "$IN" "$OUT"
assert_file_contains "APP_ENV=development" "$OUT" "enum empty input uses default"
# ---------------------------------------------------------------------------
suite "e2e: secret with confirmation"
# ---------------------------------------------------------------------------
IN="$TMPDIR_SELF/secret.env.example"
OUT="$TMPDIR_SELF/secret.env"
cat > "$IN" << 'EOF'
# @type secret
# @required
JWT_SECRET=
EOF
# Enter value twice (entry + confirm)
run_gen $'supersecret\nsupersecret\n' "$IN" "$OUT"
assert_file_contains "JWT_SECRET=supersecret" "$OUT" "secret stored after confirmation"
# ---------------------------------------------------------------------------
suite "e2e: secret uses default on empty input"
# ---------------------------------------------------------------------------
IN="$TMPDIR_SELF/secret_default.env.example"
OUT="$TMPDIR_SELF/secret_default.env"
cat > "$IN" << 'EOF'
# @type secret
JWT_SECRET=existing_token
EOF
# Empty input -> keep default
run_gen $'\n' "$IN" "$OUT"
assert_file_contains "JWT_SECRET=existing_token" "$OUT" "secret empty input keeps default"
# ---------------------------------------------------------------------------
suite "e2e: -o flag sets output path"
# ---------------------------------------------------------------------------
IN="$TMPDIR_SELF/flag.env.example"
CUSTOM_OUT="$TMPDIR_SELF/custom_output.env"
cat > "$IN" << 'EOF'
KEY=val
EOF
rm -f "$CUSTOM_OUT"
printf "\n" | bash "$SCRIPT" -o "$CUSTOM_OUT" "$IN" 2>/dev/null
assert_file_exists "$CUSTOM_OUT" "custom output path created via -o flag"
assert_file_contains "KEY=val" "$CUSTOM_OUT" "content written to custom path"
# ---------------------------------------------------------------------------
suite "e2e: empty optional field written as KEY="
# ---------------------------------------------------------------------------
IN="$TMPDIR_SELF/optional.env.example"
OUT="$TMPDIR_SELF/optional.env"
cat > "$IN" << 'EOF'
# Optional field with no default
# @type email
ADMIN_EMAIL=
EOF
# Leave empty
run_gen $'\n' "$IN" "$OUT"
assert_file_contains "ADMIN_EMAIL=" "$OUT" "optional empty field written as KEY="
assert_file_not_contains "ADMIN_EMAIL=x" "$OUT" "no stray value"
# ---------------------------------------------------------------------------
suite "e2e: full example.env.example"
# ---------------------------------------------------------------------------
#
# Variable order and input count:
# 1. APP_NAME (string, required, default "MyApp") → "\n" accept default
# 2. APP_ENV (enum:…, required, default "development") → "\n" accept default (1)
# 3. PORT (number, optional, default "3000") → "\n" accept default
# 4. DEBUG (bool, optional, default "false") → "n\n" explicit n
# 5. JWT_SECRET(secret, required, no default) → "tok\ntok\n" entry+confirm
# 6. DATABASE_URL(url, required, has default) → "\n" accept default
# 7. ADMIN_EMAIL (email, optional, no default) → "\n" leave empty
OUT="$TMPDIR_SELF/full.env"
INPUT=$'\n\n\nn\ntok\ntok\n\n\n'
run_gen "$INPUT" "$ROOT_DIR/example.env.example" "$OUT"
assert_file_exists "$OUT" "output file created"
assert_file_contains "APP_NAME=MyApp" "$OUT" "APP_NAME default"
assert_file_contains "APP_ENV=development" "$OUT" "APP_ENV default"
assert_file_contains "PORT=3000" "$OUT" "PORT default"
assert_file_contains "DEBUG=false" "$OUT" "DEBUG=false (n)"
assert_file_contains "JWT_SECRET=tok" "$OUT" "JWT_SECRET value"
assert_file_contains "DATABASE_URL=postgres://localhost:5432/myapp" "$OUT" "DATABASE_URL default"
assert_file_contains "ADMIN_EMAIL=" "$OUT" "ADMIN_EMAIL empty"
assert_file_contains "# Generated by generate-env" "$OUT" "header present"
print_summary

192
tests/test_output.sh Executable file
View File

@@ -0,0 +1,192 @@
#!/usr/bin/env bash
# Unit tests for lib/output.sh
TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$TESTS_DIR")"
source "$TESTS_DIR/framework.sh"
source "$ROOT_DIR/lib/colors.sh"
source "$ROOT_DIR/lib/output.sh"
TMPDIR_SELF="$(mktemp -d)"
trap 'rm -rf "$TMPDIR_SELF"' EXIT
# ---------------------------------------------------------------------------
suite "output: basic file creation"
# ---------------------------------------------------------------------------
ENV_VARS=("FOO" "BAR")
ENV_DESC_FOO=""
ENV_DESC_BAR=""
ENV_VALUE_FOO="hello"
ENV_VALUE_BAR="world"
OUT="$TMPDIR_SELF/basic.env"
write_env_file "$OUT" 2>/dev/null
assert_file_exists "$OUT" "output file created"
assert_file_contains "FOO=hello" "$OUT" "FOO written correctly"
assert_file_contains "BAR=world" "$OUT" "BAR written correctly"
assert_file_contains "# Generated by generate-env" "$OUT" "header present"
# ---------------------------------------------------------------------------
suite "output: variable order matches ENV_VARS"
# ---------------------------------------------------------------------------
ENV_VARS=("ALPHA" "BETA" "GAMMA")
ENV_VALUE_ALPHA="1"
ENV_VALUE_BETA="2"
ENV_VALUE_GAMMA="3"
OUT="$TMPDIR_SELF/order.env"
write_env_file "$OUT" 2>/dev/null
# grep -n returns the line number; verify order
alpha_line=$(grep -n "ALPHA=" "$OUT" | cut -d: -f1)
beta_line=$(grep -n "BETA=" "$OUT" | cut -d: -f1)
gamma_line=$(grep -n "GAMMA=" "$OUT" | cut -d: -f1)
if (( alpha_line < beta_line && beta_line < gamma_line )); then
pass "variable order preserved"
else
fail "variable order preserved" "ALPHA < BETA < GAMMA" "lines $alpha_line, $beta_line, $gamma_line"
fi
# ---------------------------------------------------------------------------
suite "output: empty values written as KEY="
# ---------------------------------------------------------------------------
ENV_VARS=("WITH_VALUE" "NO_VALUE")
ENV_VALUE_WITH_VALUE="set"
ENV_VALUE_NO_VALUE=""
OUT="$TMPDIR_SELF/empty.env"
write_env_file "$OUT" 2>/dev/null
assert_file_contains "WITH_VALUE=set" "$OUT" "non-empty value written"
assert_file_contains "NO_VALUE=" "$OUT" "empty value written as KEY="
assert_file_not_contains "NO_VALUE=x" "$OUT" "no stray value for empty var"
# ---------------------------------------------------------------------------
suite "output: values containing special characters"
# ---------------------------------------------------------------------------
ENV_VARS=("SPECIAL")
ENV_VALUE_SPECIAL='postgres://user:p@ss!#$@host:5432/db?ssl=true'
OUT="$TMPDIR_SELF/special.env"
write_env_file "$OUT" 2>/dev/null
assert_file_contains "SPECIAL=postgres://user:p@ss" "$OUT" "special chars in value preserved"
# ---------------------------------------------------------------------------
suite "output: values containing equals signs"
# ---------------------------------------------------------------------------
ENV_VARS=("DB_URL")
ENV_VALUE_DB_URL="postgres://host/db?sslmode=require&timeout=10"
OUT="$TMPDIR_SELF/equals.env"
write_env_file "$OUT" 2>/dev/null
assert_file_contains "DB_URL=postgres://host/db?sslmode=require&timeout=10" \
"$OUT" "equals signs in value preserved"
# ---------------------------------------------------------------------------
suite "output: header contains ISO-8601 timestamp"
# ---------------------------------------------------------------------------
ENV_VARS=("X")
ENV_VALUE_X="1"
OUT="$TMPDIR_SELF/ts.env"
write_env_file "$OUT" 2>/dev/null
# Header should contain a UTC timestamp in the form YYYY-MM-DDTHH:MM:SSZ
if grep -qE '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z' "$OUT"; then
pass "header contains ISO-8601 UTC timestamp"
else
fail "header contains ISO-8601 UTC timestamp" \
"YYYY-MM-DDTHH:MM:SSZ" "(not found in header)"
fi
# ---------------------------------------------------------------------------
suite "output: single variable"
# ---------------------------------------------------------------------------
ENV_VARS=("SOLO")
ENV_VALUE_SOLO="only"
OUT="$TMPDIR_SELF/solo.env"
write_env_file "$OUT" 2>/dev/null
assert_file_exists "$OUT" "file created for single var"
assert_file_contains "SOLO=only" "$OUT" "single var written"
# ---------------------------------------------------------------------------
suite "output: description written as comment before KEY=VALUE"
# ---------------------------------------------------------------------------
ENV_VARS=("DESCRIBED" "NODESC")
ENV_DESC_DESCRIBED="Explains this variable"
ENV_DESC_NODESC=""
ENV_VALUE_DESCRIBED="val1"
ENV_VALUE_NODESC="val2"
OUT="$TMPDIR_SELF/desc.env"
write_env_file "$OUT" 2>/dev/null
assert_file_contains "# Explains this variable" "$OUT" "description written as comment"
assert_file_contains "DESCRIBED=val1" "$OUT" "KEY=VALUE follows comment"
# The comment line should immediately precede the variable line
desc_line=$(grep -n "# Explains this variable" "$OUT" | cut -d: -f1)
var_line=$(grep -n "^DESCRIBED=" "$OUT" | cut -d: -f1)
if (( var_line == desc_line + 1 )); then
pass "comment is on the line directly before KEY=VALUE"
else
fail "comment is on the line directly before KEY=VALUE" \
"$((desc_line + 1))" "$var_line"
fi
# Variable with no description should have no comment line immediately before it
nodesc_line=$(grep -n "^NODESC=" "$OUT" | cut -d: -f1)
prev_line_content=$(sed -n "$((nodesc_line - 1))p" "$OUT")
if [[ -z "$prev_line_content" ]]; then
pass "variable with no description has blank line before it (no comment)"
else
fail "variable with no description has blank line before it (no comment)" \
"" "$prev_line_content"
fi
# ---------------------------------------------------------------------------
suite "output: blank line separates each variable block"
# ---------------------------------------------------------------------------
ENV_VARS=("A" "B" "C")
ENV_DESC_A="First"
ENV_DESC_B=""
ENV_DESC_C="Third"
ENV_VALUE_A="1"
ENV_VALUE_B="2"
ENV_VALUE_C="3"
OUT="$TMPDIR_SELF/spacing.env"
write_env_file "$OUT" 2>/dev/null
# Each variable block ends with a blank line; verify A and C have their comments
assert_file_contains "# First" "$OUT" "first var description present"
assert_file_contains "# Third" "$OUT" "third var description present"
a_line=$(grep -n "^A=" "$OUT" | cut -d: -f1)
b_line=$(grep -n "^B=" "$OUT" | cut -d: -f1)
# There must be a blank line between A= and B=
between=$(sed -n "$((a_line + 1)),$((b_line - 1))p" "$OUT")
if [[ -z "$between" || "$between" =~ ^[[:space:]]*$ ]]; then
pass "blank line between variable blocks"
else
fail "blank line between variable blocks" "(blank)" "$between"
fi
print_summary

138
tests/test_parse.sh Executable file
View File

@@ -0,0 +1,138 @@
#!/usr/bin/env bash
# Unit tests for lib/parse.sh
TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$TESTS_DIR")"
FIXTURES="$TESTS_DIR/fixtures"
source "$TESTS_DIR/framework.sh"
source "$ROOT_DIR/lib/colors.sh"
source "$ROOT_DIR/lib/parse.sh"
# ---------------------------------------------------------------------------
suite "parse: basic variable"
# ---------------------------------------------------------------------------
parse_env_example "$FIXTURES/basic.env.example"
assert_eq "1" "${#ENV_VARS[@]}" "finds exactly one variable"
assert_eq "BASIC_VAR" "${ENV_VARS[0]}" "correct variable name"
assert_eq "default" "$ENV_DEFAULT_BASIC_VAR" "correct default value"
assert_eq "true" "$ENV_REQUIRED_BASIC_VAR" "required flag set"
assert_eq "Description" "$ENV_DESC_BASIC_VAR" "description captured"
assert_eq "string" "$ENV_TYPE_BASIC_VAR" "type defaults to string"
# ---------------------------------------------------------------------------
suite "parse: all type annotations"
# ---------------------------------------------------------------------------
parse_env_example "$FIXTURES/all_types.env.example"
assert_eq "string" "$ENV_TYPE_STR_VAR" "explicit string type"
assert_eq "number" "$ENV_TYPE_NUM_VAR" "number type"
assert_eq "bool" "$ENV_TYPE_BOOL_VAR" "bool type"
assert_eq "secret" "$ENV_TYPE_SECRET_VAR" "secret type"
assert_eq "url" "$ENV_TYPE_URL_VAR" "url type"
assert_eq "email" "$ENV_TYPE_EMAIL_VAR" "email type"
assert_eq "enum:a,b,c" "$ENV_TYPE_ENUM_VAR" "enum type with options"
assert_eq "true" "$ENV_REQUIRED_SECRET_VAR" "required annotation on secret"
assert_eq "false" "$ENV_REQUIRED_STR_VAR" "non-required defaults to false"
assert_eq "42" "$ENV_DEFAULT_NUM_VAR" "numeric default"
assert_eq "https://example.com" "$ENV_DEFAULT_URL_VAR" "URL default"
assert_empty "$ENV_DEFAULT_SECRET_VAR" "empty default preserved"
# ---------------------------------------------------------------------------
suite "parse: variable order preserved"
# ---------------------------------------------------------------------------
parse_env_example "$FIXTURES/all_types.env.example"
assert_eq "7" "${#ENV_VARS[@]}" "all seven variables found"
assert_eq "STR_VAR" "${ENV_VARS[0]}" "first variable"
assert_eq "NUM_VAR" "${ENV_VARS[1]}" "second variable"
assert_eq "BOOL_VAR" "${ENV_VARS[2]}" "third variable"
assert_eq "SECRET_VAR" "${ENV_VARS[3]}" "fourth variable"
assert_eq "URL_VAR" "${ENV_VARS[4]}" "fifth variable"
assert_eq "EMAIL_VAR" "${ENV_VARS[5]}" "sixth variable"
assert_eq "ENUM_VAR" "${ENV_VARS[6]}" "seventh variable"
# ---------------------------------------------------------------------------
suite "parse: variable with no annotations"
# ---------------------------------------------------------------------------
parse_env_example "$FIXTURES/no_annotations.env.example"
assert_eq "2" "${#ENV_VARS[@]}" "finds two variables"
assert_eq "string" "$ENV_TYPE_PLAIN_VAR" "type defaults to string"
assert_eq "false" "$ENV_REQUIRED_PLAIN_VAR" "required defaults to false"
assert_empty "$ENV_DESC_PLAIN_VAR" "no description"
assert_eq "value" "$ENV_DEFAULT_PLAIN_VAR" "correct default"
assert_empty "$ENV_DEFAULT_ANOTHER_VAR" "empty default preserved"
# ---------------------------------------------------------------------------
suite "parse: multi-line description"
# ---------------------------------------------------------------------------
parse_env_example "$FIXTURES/multiline_desc.env.example"
assert_contains "First line" "$ENV_DESC_MULTI_VAR" "first description line included"
assert_contains "Second line" "$ENV_DESC_MULTI_VAR" "second description line appended"
# ---------------------------------------------------------------------------
suite "parse: orphaned comment block (no variable follows)"
# ---------------------------------------------------------------------------
parse_env_example "$FIXTURES/orphaned_comments.env.example"
assert_eq "1" "${#ENV_VARS[@]}" "only one real variable found"
assert_eq "REAL_VAR" "${ENV_VARS[0]}" "correct variable captured"
assert_eq "value" "$ENV_DEFAULT_REAL_VAR" "correct default"
# ---------------------------------------------------------------------------
suite "parse: empty file"
# ---------------------------------------------------------------------------
parse_env_example "$FIXTURES/empty.env.example"
assert_eq "0" "${#ENV_VARS[@]}" "no variables in empty file"
# ---------------------------------------------------------------------------
suite "parse: value containing equals signs"
# ---------------------------------------------------------------------------
parse_env_example "$FIXTURES/value_with_equals.env.example"
assert_eq "1" "${#ENV_VARS[@]}" "one variable"
assert_contains "sslmode=require" "$ENV_DEFAULT_DB_URL" "first query param preserved"
assert_contains "connect_timeout=10" "$ENV_DEFAULT_DB_URL" "second query param preserved"
assert_eq "url" "$ENV_TYPE_DB_URL" "type annotation on same var"
# ---------------------------------------------------------------------------
suite "parse: missing file returns error"
# ---------------------------------------------------------------------------
assert_failure "non-existent file returns non-zero" \
parse_env_example "/nonexistent/path.env.example"
# ---------------------------------------------------------------------------
suite "parse: real-world example.env.example"
# ---------------------------------------------------------------------------
parse_env_example "$ROOT_DIR/example.env.example"
assert_eq "7" "${#ENV_VARS[@]}" "all 7 variables"
assert_eq "APP_NAME" "${ENV_VARS[0]}" "first var is APP_NAME"
assert_eq "APP_ENV" "${ENV_VARS[1]}" "second var is APP_ENV"
assert_eq "true" "$ENV_REQUIRED_APP_NAME" "APP_NAME required"
assert_eq "true" "$ENV_REQUIRED_APP_ENV" "APP_ENV required"
assert_eq "false" "$ENV_REQUIRED_PORT" "PORT optional"
assert_eq "number" "$ENV_TYPE_PORT" "PORT is number type"
assert_eq "bool" "$ENV_TYPE_DEBUG" "DEBUG is bool type"
assert_eq "secret" "$ENV_TYPE_JWT_SECRET" "JWT_SECRET is secret type"
assert_eq "url" "$ENV_TYPE_DATABASE_URL" "DATABASE_URL is url type"
assert_eq "email" "$ENV_TYPE_ADMIN_EMAIL" "ADMIN_EMAIL is email type"
assert_eq "enum:development,staging,production" "$ENV_TYPE_APP_ENV" "APP_ENV enum options"
print_summary

190
tests/test_prompt.sh Executable file
View File

@@ -0,0 +1,190 @@
#!/usr/bin/env bash
# Unit tests for prompt helper functions in lib/prompt.sh.
#
# All interactive functions write prompts to stderr and read from stdin.
# Tests pipe input via stdin and capture stdout, suppressing stderr.
TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$TESTS_DIR")"
source "$TESTS_DIR/framework.sh"
source "$ROOT_DIR/lib/colors.sh"
source "$ROOT_DIR/lib/validate.sh"
source "$ROOT_DIR/lib/prompt.sh"
# Helpers: feed stdin to a function and capture its stdout
feed() {
# feed <input_string> <function> [args...]
local input="$1"; shift
printf "%s" "$input" | "$@" 2>/dev/null
}
# ---------------------------------------------------------------------------
suite "_prompt_enum: numeric selection"
# ---------------------------------------------------------------------------
assert_eq "a" "$(feed "1"$'\n' _prompt_enum "V" "a,b,c" "a")" "choice 1 -> first option"
assert_eq "b" "$(feed "2"$'\n' _prompt_enum "V" "a,b,c" "a")" "choice 2 -> second option"
assert_eq "c" "$(feed "3"$'\n' _prompt_enum "V" "a,b,c" "a")" "choice 3 -> third option"
# ---------------------------------------------------------------------------
suite "_prompt_enum: string selection"
# ---------------------------------------------------------------------------
assert_eq "a" "$(feed "a"$'\n' _prompt_enum "V" "a,b,c" "")" "string 'a' selects a"
assert_eq "b" "$(feed "b"$'\n' _prompt_enum "V" "a,b,c" "")" "string 'b' selects b"
assert_eq "c" "$(feed "c"$'\n' _prompt_enum "V" "a,b,c" "")" "string 'c' selects c"
# ---------------------------------------------------------------------------
suite "_prompt_enum: empty input uses default"
# ---------------------------------------------------------------------------
assert_eq "a" "$(feed $'\n' _prompt_enum "V" "a,b,c" "a")" "empty -> first default"
assert_eq "b" "$(feed $'\n' _prompt_enum "V" "a,b,c" "b")" "empty -> middle default"
assert_eq "c" "$(feed $'\n' _prompt_enum "V" "a,b,c" "c")" "empty -> last default"
# ---------------------------------------------------------------------------
suite "_prompt_enum: invalid choice then valid"
# ---------------------------------------------------------------------------
# Send out-of-range number, then a valid number
assert_eq "b" "$(printf "99\n2\n" | _prompt_enum "V" "a,b,c" "a" 2>/dev/null)" \
"invalid index followed by valid index"
# Send unknown string, then valid string
assert_eq "c" "$(printf "zzz\nc\n" | _prompt_enum "V" "a,b,c" "a" 2>/dev/null)" \
"invalid string followed by valid string"
# ---------------------------------------------------------------------------
suite "_prompt_enum: real-world APP_ENV"
# ---------------------------------------------------------------------------
assert_eq "development" \
"$(feed $'\n' _prompt_enum "APP_ENV" "development,staging,production" "development")" \
"empty -> development (default)"
assert_eq "production" \
"$(feed "3"$'\n' _prompt_enum "APP_ENV" "development,staging,production" "development")" \
"choice 3 -> production"
assert_eq "staging" \
"$(feed "staging"$'\n' _prompt_enum "APP_ENV" "development,staging,production" "development")" \
"string 'staging' -> staging"
# ---------------------------------------------------------------------------
suite "_prompt_bool: explicit inputs"
# ---------------------------------------------------------------------------
assert_eq "true" "$(feed "y"$'\n' _prompt_bool "")" "y -> true"
assert_eq "true" "$(feed "yes"$'\n' _prompt_bool "")" "yes -> true"
assert_eq "true" "$(feed "true"$'\n' _prompt_bool "")" "true -> true"
assert_eq "true" "$(feed "1"$'\n' _prompt_bool "")" "1 -> true"
assert_eq "false" "$(feed "n"$'\n' _prompt_bool "")" "n -> false"
assert_eq "false" "$(feed "no"$'\n' _prompt_bool "")" "no -> false"
assert_eq "false" "$(feed "false"$'\n' _prompt_bool "")" "false -> false"
assert_eq "false" "$(feed "0"$'\n' _prompt_bool "")" "0 -> false"
# ---------------------------------------------------------------------------
suite "_prompt_bool: empty input uses default"
# ---------------------------------------------------------------------------
assert_eq "true" "$(feed $'\n' _prompt_bool "true")" "empty -> true default"
assert_eq "true" "$(feed $'\n' _prompt_bool "yes")" "empty -> yes default normalizes to true"
assert_eq "false" "$(feed $'\n' _prompt_bool "false")" "empty -> false default"
assert_eq "false" "$(feed $'\n' _prompt_bool "no")" "empty -> no default normalizes to false"
# ---------------------------------------------------------------------------
suite "_prompt_bool: invalid then valid"
# ---------------------------------------------------------------------------
assert_eq "true" "$(printf "banana\ny\n" | _prompt_bool "" 2>/dev/null)" \
"invalid input followed by y"
assert_eq "false" "$(printf "maybe\nn\n" | _prompt_bool "" 2>/dev/null)" \
"invalid input followed by n"
# ---------------------------------------------------------------------------
suite "_prompt_secret: matching values accepted"
# ---------------------------------------------------------------------------
assert_eq "mysecret" \
"$(printf "mysecret\nmysecret\n" | _prompt_secret "V" "" 2>/dev/null)" \
"matching entries return value"
assert_eq "p@ss!#\$word" \
"$(printf 'p@ss!#$word\np@ss!#$word\n' | _prompt_secret "V" "" 2>/dev/null)" \
"special characters in secret preserved"
# ---------------------------------------------------------------------------
suite "_prompt_secret: empty input uses default"
# ---------------------------------------------------------------------------
assert_eq "defaultsecret" \
"$(printf "\n" | _prompt_secret "V" "defaultsecret" 2>/dev/null)" \
"empty input returns default"
# ---------------------------------------------------------------------------
suite "_prompt_secret: mismatch retries"
# ---------------------------------------------------------------------------
# First pair mismatches, second pair matches
assert_eq "correct" \
"$(printf "wrong\nmismatch\ncorrect\ncorrect\n" | _prompt_secret "V" "" 2>/dev/null)" \
"mismatch retries until match"
# ---------------------------------------------------------------------------
suite "prompt_variable: string type stores value"
# ---------------------------------------------------------------------------
# Use process substitution (<(...)) so prompt_variable runs in the current
# shell and its variable assignments (ENV_VALUE_*) persist.
ENV_VARS=("MYVAR")
ENV_DESC_MYVAR="A test variable"
ENV_TYPE_MYVAR="string"
ENV_REQUIRED_MYVAR="false"
ENV_DEFAULT_MYVAR="default_val"
# Accept default
prompt_variable "MYVAR" < <(printf "\n") 2>/dev/null
assert_eq "default_val" "$ENV_VALUE_MYVAR" "empty input stores default"
# Provide a value
prompt_variable "MYVAR" < <(printf "custom\n") 2>/dev/null
assert_eq "custom" "$ENV_VALUE_MYVAR" "typed value stored"
# ---------------------------------------------------------------------------
suite "prompt_variable: number type re-prompts on invalid input"
# ---------------------------------------------------------------------------
ENV_TYPE_MYVAR="number"
ENV_DEFAULT_MYVAR="42"
prompt_variable "MYVAR" < <(printf "abc\n99\n") 2>/dev/null
assert_eq "99" "$ENV_VALUE_MYVAR" "invalid number re-prompts, then stores valid"
# ---------------------------------------------------------------------------
suite "prompt_variable: bool type"
# ---------------------------------------------------------------------------
ENV_TYPE_MYVAR="bool"
ENV_DEFAULT_MYVAR="false"
prompt_variable "MYVAR" < <(printf "y\n") 2>/dev/null
assert_eq "true" "$ENV_VALUE_MYVAR" "bool y stored as true"
prompt_variable "MYVAR" < <(printf "\n") 2>/dev/null
assert_eq "false" "$ENV_VALUE_MYVAR" "bool empty uses false default"
# ---------------------------------------------------------------------------
suite "prompt_variable: enum type"
# ---------------------------------------------------------------------------
ENV_TYPE_MYVAR="enum:x,y,z"
ENV_DEFAULT_MYVAR="x"
prompt_variable "MYVAR" < <(printf "2\n") 2>/dev/null
assert_eq "y" "$ENV_VALUE_MYVAR" "enum choice 2 stores y"
prompt_variable "MYVAR" < <(printf "\n") 2>/dev/null
assert_eq "x" "$ENV_VALUE_MYVAR" "enum empty uses default x"
print_summary

131
tests/test_validate.sh Executable file
View File

@@ -0,0 +1,131 @@
#!/usr/bin/env bash
# Unit tests for lib/validate.sh
TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$TESTS_DIR")"
source "$TESTS_DIR/framework.sh"
source "$ROOT_DIR/lib/colors.sh"
source "$ROOT_DIR/lib/validate.sh"
# Shorthand wrappers to reduce repetition
ok() { assert_success "$1" validate_value "VAR" "$2" "$3" "$4"; }
bad() { assert_failure "$1" validate_value "VAR" "$2" "$3" "$4"; }
# ---------------------------------------------------------------------------
suite "validate: required field"
# ---------------------------------------------------------------------------
bad "empty value is invalid when required" "" "string" "true"
ok "non-empty value is valid when required" "x" "string" "true"
ok "empty value is valid when optional" "" "string" "false"
# ---------------------------------------------------------------------------
suite "validate: number"
# ---------------------------------------------------------------------------
ok "positive integer" "42" "number" "false"
ok "zero" "0" "number" "false"
ok "negative integer" "-7" "number" "false"
ok "decimal" "3.14" "number" "false"
ok "negative decimal" "-0.5" "number" "false"
bad "plain string" "abc" "number" "false"
bad "mixed alphanumeric" "42abc" "number" "false"
bad "leading decimal" ".5" "number" "false"
bad "multiple dots" "1.2.3" "number" "false"
ok "empty optional number" "" "number" "false"
bad "empty required number" "" "number" "true"
# ---------------------------------------------------------------------------
suite "validate: bool"
# ---------------------------------------------------------------------------
ok "true" "true" "bool" "false"
ok "false" "false" "bool" "false"
ok "yes" "yes" "bool" "false"
ok "no" "no" "bool" "false"
ok "1" "1" "bool" "false"
ok "0" "0" "bool" "false"
ok "y" "y" "bool" "false"
ok "n" "n" "bool" "false"
ok "TRUE (uppercase)" "TRUE" "bool" "false"
ok "FALSE (uppercase)" "FALSE" "bool" "false"
bad "banana" "banana" "bool" "false"
bad "yes-no" "yes-no" "bool" "false"
ok "empty optional bool" "" "bool" "false"
bad "empty required bool" "" "bool" "true"
# ---------------------------------------------------------------------------
suite "validate: url"
# ---------------------------------------------------------------------------
# URL validation only warns — it always returns 0
ok "http URL passes" "http://example.com" "url" "false"
ok "https URL passes" "https://example.com" "url" "false"
ok "non-URL still passes (warn only)" "not-a-url" "url" "false"
ok "empty optional url" "" "url" "false"
bad "empty required url" "" "url" "true"
# ---------------------------------------------------------------------------
suite "validate: email"
# ---------------------------------------------------------------------------
ok "simple email" "user@example.com" "email" "false"
ok "subdomain email" "user@mail.example.com" "email" "false"
ok "plus addressing" "user+tag@example.com" "email" "false"
bad "missing @" "userexample.com" "email" "false"
bad "missing domain" "user@" "email" "false"
bad "missing TLD" "user@example" "email" "false"
bad "spaces in email" "user @example.com" "email" "false"
ok "empty optional email" "" "email" "false"
bad "empty required email" "" "email" "true"
# ---------------------------------------------------------------------------
suite "validate: enum"
# ---------------------------------------------------------------------------
ok "first option" "a" "enum:a,b,c" "false"
ok "middle option" "b" "enum:a,b,c" "false"
ok "last option" "c" "enum:a,b,c" "false"
bad "not in list" "d" "enum:a,b,c" "false"
bad "partial match" "ab" "enum:a,b,c" "false"
bad "case mismatch" "A" "enum:a,b,c" "false"
ok "single-option enum" "only" "enum:only" "false"
bad "not the single option" "other" "enum:only" "false"
ok "empty optional enum" "" "enum:a,b,c" "false"
bad "empty required enum" "" "enum:a,b,c" "true"
# Enum with real-world values
ok "development" "development" "enum:development,staging,production" "true"
ok "staging" "staging" "enum:development,staging,production" "true"
ok "production" "production" "enum:development,staging,production" "true"
bad "test env" "test" "enum:development,staging,production" "true"
# ---------------------------------------------------------------------------
suite "validate: string and secret (no structural validation)"
# ---------------------------------------------------------------------------
ok "any string value" "anything goes" "string" "false"
ok "string with specials" "f@o=bar!#$%" "string" "false"
ok "any secret value" "s3cr3t!@#" "secret" "false"
ok "empty optional string" "" "string" "false"
bad "empty required string" "" "string" "true"
# ---------------------------------------------------------------------------
suite "normalize_bool"
# ---------------------------------------------------------------------------
assert_eq "true" "$(normalize_bool "true")" "true -> true"
assert_eq "true" "$(normalize_bool "yes")" "yes -> true"
assert_eq "true" "$(normalize_bool "1")" "1 -> true"
assert_eq "true" "$(normalize_bool "y")" "y -> true"
assert_eq "true" "$(normalize_bool "TRUE")" "TRUE -> true (case insensitive)"
assert_eq "true" "$(normalize_bool "YES")" "YES -> true (case insensitive)"
assert_eq "false" "$(normalize_bool "false")" "false -> false"
assert_eq "false" "$(normalize_bool "no")" "no -> false"
assert_eq "false" "$(normalize_bool "0")" "0 -> false"
assert_eq "false" "$(normalize_bool "n")" "n -> false"
assert_eq "false" "$(normalize_bool "FALSE")" "FALSE -> false (case insensitive)"
assert_eq "banana" "$(normalize_bool "banana")" "unknown value passes through"
print_summary