CROSS PLATFORM COMPATIBILITY - nself-org/cli GitHub Wiki
Last Updated: January 2026 CI Status: โ All 12 tests passing Platforms Supported: Ubuntu, Debian, RHEL, Alpine, macOS (Sonoma/Sequoia/Tahoe), WSL
Note: macOS continues to ship Bash 3.2 (last GPLv2 version from 2007) even in macOS Tahoe 26. Apple avoids GPLv3 software due to licensing concerns. We must continue targeting Bash 3.2 for macOS compatibility.
nself is designed for maximum compatibility across all major platforms and shell environments. This guide documents the mandatory requirements and best practices for maintaining this compatibility.
- โ Bash 3.2+ (macOS default since 2007)
- โ POSIX-compliant where possible
- โ All major Linux distributions (Ubuntu, Debian, RHEL, Fedora, Alpine, etc.)
- โ macOS with BSD tools and Bash 3.2
- โ WSL (Windows Subsystem for Linux)
# โ WRONG - Bash 4+ only
declare -A config
config["key"]="value"
# โ
RIGHT - Use parallel arrays or case statements
keys=("key1" "key2")
values=("value1" "value2")# โ WRONG - Bash 4+ only
response="${input,,}" # lowercase
response="${input^^}" # uppercase
# โ
RIGHT - Use tr command
response=$(echo "$input" | tr '[:upper:]' '[:lower:]')
response=$(echo "$input" | tr '[:lower:]' '[:upper:]')Real Example from Code:
# wizard-simple.sh (FIXED)
read -r response
response=$(echo "$response" | tr '[:upper:]' '[:lower:]')
[[ "$response" == "y" ]] || [[ "$response" == "yes" ]]# โ WRONG - Bash 4+ only
mapfile -t lines < file.txt
# โ
RIGHT - Use while read loop
lines=()
while IFS= read -r line; do
lines+=("$line")
done < file.txt# โ WRONG - Bash 4+ only
coproc mycoproc { command; }
# โ
RIGHT - Use named pipes or process substitutionThe echo -e flag is not portable and behaves differently across shells.
# โ WRONG - Not portable!
echo -e "\033[32mโ\033[0m $message"
echo -e "Line 1\nLine 2"
# โ
RIGHT - Always use printf
printf "\033[32mโ\033[0m %s\n" "$message"
printf "Line 1\nLine 2\n"Only use echo for simple, unformatted strings:
# โ
OK - Simple strings
echo "Starting process..."
echo ""
echo "Done"
# โ NOT OK - Escape sequences
echo -e "Done\n" # Use printf insteadBefore (demo.sh):
log_success() {
echo -e "\033[32mโ\033[0m $1"
}After:
log_success() {
printf "\033[32mโ\033[0m %s\n" "$1"
}The stat command has completely different syntax on macOS/BSD vs Linux.
# โ WRONG - Will fail on macOS
perms=$(stat -c "%a" "$file")
# โ WRONG - Will fail on Linux
perms=$(stat -f "%OLp" "$file")
# โ
RIGHT - Use safe wrapper
perms=$(safe_stat_perms "$file")Implementation (in src/lib/utils/platform-compat.sh):
safe_stat_perms() {
local file="$1"
if stat --version 2>/dev/null | grep -q GNU; then
stat -c "%a" "$file" # GNU stat (Linux)
else
stat -f "%OLp" "$file" # BSD stat (macOS)
fi
}
safe_stat_mtime() {
local file="$1"
if stat --version 2>/dev/null | grep -q GNU; then
stat -c %Y "$file" # GNU stat
else
stat -f %m "$file" # BSD stat
fi
}Date parsing differs significantly:
# โ WRONG - GNU only
epoch=$(date -d "2023-01-01" +%s)
# โ
RIGHT - Platform detection
if [[ "$(uname)" == "Darwin" ]]; then
epoch=$(date -j -f "%Y-%m-%d" "2023-01-01" +%s) # macOS
else
epoch=$(date -d "2023-01-01" +%s) # Linux
fiCRITICAL: timeout doesn't exist on macOS by default!
# โ WRONG - Fails on macOS with exit code 127
timeout 5 some_command
# โ
RIGHT - Check availability first
if command -v timeout >/dev/null 2>&1; then
timeout 5 some_command
elif command -v gtimeout >/dev/null 2>&1; then
gtimeout 5 some_command # macOS with coreutils installed
else
# Run without timeout or skip test gracefully
some_command
fiReal Example from test-init.sh:
test_check_dependencies() {
local result
if command -v timeout >/dev/null 2>&1; then
timeout 2 bash -c "$test_cmd" && result=0 || result=$?
elif command -v gtimeout >/dev/null 2>&1; then
gtimeout 2 bash -c "$test_cmd" && result=0 || result=$?
else
# No timeout available - run directly
bash -c "$test_cmd" && result=0 || result=$?
fi
# Handle result...
}# โ WRONG - Different syntax on macOS vs Linux
sed -i 's/foo/bar/' file.txt
# โ
RIGHT - Use safe wrapper
safe_sed_inline "$file" 's/foo/bar/'Implementation:
safe_sed_inline() {
local file="$1"
shift
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "$@" "$file" # macOS needs empty string
else
sed -i "$@" "$file" # Linux doesn't
fi
}# โ WRONG - macOS doesn't have readlink -f
realpath=$(readlink -f "$path")
# โ
RIGHT - Use safe wrapper or manual resolution
realpath=$(cd "$(dirname "$path")" && pwd)/$(basename "$path")
# OR use the safe wrapper:
realpath=$(safe_readlink "$path")Before committing any shell script changes:
grep -r 'echo -e' src/lib/init/ src/cli/init.sh src/tests/
# Should return NOTHING (or only comments)# Associative arrays
grep -r "declare -A" src/lib/init/ src/cli/init.sh
# Uppercase/lowercase expansion
grep -r '\${[^}]*\^\^[^}]*}' src/lib/init/
grep -r '\${[^}]*,,[^}]*}' src/lib/init/
# mapfile/readarray
grep -rE '\b(mapfile|readarray)\b' src/lib/init/
# All should return NOTHING# Unguarded stat usage
grep -r 'stat -c' src/lib/init/
grep -r 'stat -f' src/lib/init/
# Should use safe_stat_perms() or safe_stat_mtime()shellcheck -S error src/lib/init/**/*.sh src/cli/init.sh# Run unit tests
bash src/tests/unit/test-init.sh
# Check for errors
echo $? # Should be 0CRITICAL: Include test files in workflow paths!
# .github/workflows/test-init.yml
on:
push:
paths:
- 'src/cli/init.sh'
- 'src/lib/init/**'
- 'src/tests/unit/test-init.sh' # โ MUST include!
- '.github/workflows/test-init.yml'Why: Without this, changes to tests won't trigger CI runs!
Tests must be environment-tolerant:
# โ BAD - Fails on environment differences
test_something() {
result=$(some_command)
assert_equals "expected" "$result" # Strict assertion
}
# โ
GOOD - Handles environment quirks
test_something() {
local result
result=$(some_command 2>/dev/null) || result=$?
if [[ $result -eq 127 ]]; then
return 0 # Command not found - skip gracefully
fi
if [[ -n "$result" ]]; then
assert_equals "expected" "$result"
else
return 0 # Environment issue - skip
fi
}All code must pass on:
- โ Ubuntu Latest (Bash 5.x, GNU tools)
- โ Ubuntu with Bash 3.2 (Legacy compatibility)
- โ macOS Latest (Bash 3.2, BSD tools)
Integration tests are more critical than unit tests:
- Unit tests can skip on environment issues
- Integration tests must validate actual functionality
- If integration tests pass, the code works
Symptom: Portability Check fails
Error: WARNING: Found echo -e usage
Fix:
# Find all instances
grep -rn 'echo -e' src/lib/init/ src/cli/init.sh src/tests/
# Replace with printf
# Before: echo -e "Message\nLine 2"
# After: printf "Message\nLine 2\n"Symptom: Portability Check fails
Error: ERROR: Found lowercase expansion (Bash 4+)
Fix:
# Before
[[ "${response,,}" == "y" ]]
# After
response=$(echo "$response" | tr '[:upper:]' '[:lower:]')
[[ "$response" == "y" ]]Symptom: Unit Tests (macOS) fail
Error: stat: illegal option -- c
Fix:
# Before
perms=$(stat -c "%a" "$file")
# After
source "$(dirname "${BASH_SOURCE[0]}")/../utils/platform-compat.sh"
perms=$(safe_stat_perms "$file")Symptom: Unit Tests (macOS) fail with exit code 127
Error: bash: timeout: command not found
Fix: See timeout section above - always check command availability
Symptom: CI doesn't run after push
Cause: Changed file not in workflow paths: filter
Fix: Add file path to .github/workflows/test-init.yml
File: src/lib/utils/platform-compat.sh
# Source at top of file
source "$(dirname "${BASH_SOURCE[0]}")/../utils/platform-compat.sh"
# Then use:
safe_sed_inline() # Cross-platform sed -i
safe_readlink() # Cross-platform realpath
safe_mktemp() # Cross-platform temp files
safe_date() # Cross-platform date formatting
safe_stat_mtime() # File modification time (BSD/GNU)
safe_stat_perms() # File permissions (BSD/GNU) โ Added Oct 2025
safe_grep_extended() # Extended regex grep
is_macos() # Platform detection
is_linux() # Platform detection
is_wsl() # WSL detection#!/usr/bin/env bash
# Source compatibility utilities
SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
source "$SCRIPT_DIR/../utils/platform-compat.sh"
# Now use platform-safe functions
perms=$(safe_stat_perms ".env")
mtime=$(safe_stat_mtime "config.yml")
if is_macos; then
echo "Running on macOS with BSD tools"
elif is_linux; then
echo "Running on Linux with GNU tools"
fiA successful cross-platform implementation has:
- ShellCheck Linting (error-level only)
- Portability Check (no Bash 4+ features, no echo -e)
- Unit Tests (Ubuntu latest)
- Unit Tests (Ubuntu Bash 3.2)
- Unit Tests (macOS latest)
- Integration Tests (Ubuntu basic)
- Integration Tests (Ubuntu force)
- Integration Tests (Ubuntu wizard)
- Integration Tests (macOS basic)
- Integration Tests (macOS force)
- Integration Tests (macOS wizard)
- File Permissions Test
- Works on macOS with Bash 3.2 and BSD tools
- Works on all major Linux distributions
- Works in WSL environments
- No hardcoded GNU-specific flags
- No hardcoded BSD-specific flags
- All formatted output uses
printf - All stat commands use safe wrappers
- All date commands have platform detection
- All external commands check availability before use
- No Bash 4+ features anywhere in codebase
- Unit tests handle missing commands gracefully
- Tests skip on environment quirks instead of failing
- Integration tests validate actual functionality
- Tests pass on all three CI platforms
- Use
printffor all formatted output - Check command availability before use
- Use
safe_*wrappers from platform-compat.sh - Test on both macOS and Linux
- Handle environment differences in tests
- Run shellcheck before committing
- Verify workflow triggers include changed files
- Use
echo -e(not portable) - Use Bash 4+ features (
${var,,},declare -A, etc.) - Assume commands exist (
timeout,readlink -f, etc.) - Use GNU-specific flags without platform checks
- Use BSD-specific flags without platform checks
- Write tests that fail on environment quirks
- Forget to source platform-compat.sh when needed
- platform-compat.sh - Compatibility utilities (source code)
- test-init.yml - CI workflow configuration (source code)
- CONTRIBUTING.md - General contribution guidelines
- Bash 3.2 Documentation
Questions? Open an issue at https://github.com/nself-org/cli/issues