How to Write a Bash Script on Windows 11
Turning your one-liners into reusable, production-grade CLI tools (WSL-first). Complete, battle-tested, and ready to ship.
Executive Quickstart
Install & Prepare (WSL)
winget install -e --id Canonical.Ubuntu
wsl --install -d Ubuntu
wsl -s Ubuntu
# In Ubuntu shell:
sudo apt update && sudo apt install -y bash jq awk sed coreutils findutils \
shellcheck shfmt bats build-essential dos2unix git
echo 'export PATH="$HOME/bin:$PATH"' >> ~/.bashrc
mkdir -p ~/bin && source ~/.bashrc
Windows-Friendly Settings
git config --global core.autocrlf false
git config --global core.eol lf
echo 'export LANG=C.UTF-8' >> ~/.bashrc
echo 'alias pbcopy="clip.exe"' >> ~/.bashrc
echo 'alias wt="wslpath -w"' >> ~/.bashrc
Keep heavy I/O in /home/<you>. Access Windows files via /mnt/c/.... Prefer LF line endings.
Contents
0) Windows 11 Reality: Where Bash Runs
- WSL (Ubuntu) is the primary target. Real GNU/Linux userspace, proper package manager, great perf.
- Git Bash/MSYS2/Cygwin work for light tasks; some tools differ or are missing. This doc stays WSL-first and notes fallbacks.
- Paths: Windows (
C:\...) ↔ WSL (/mnt/c/...) withwslpath. - LF endings everywhere; use
dos2unixif needed.
Performance principle: do heavy filesystem work under
/home/<you>, not on /mnt/c (NTFS metadata overhead).1) From One-Liner to Script: The Upgrade Path
One-liner = transient imperative spell. Script = repeatable product: args, validation, errors, logging, cleanup, exit codes, tests.
- Guardrails: strict mode (
set -Eeuo pipefail), traps, cleanup. - Observability: TTY-aware colors, debug toggles, timestamps, line numbers.
- Windows hooks: accept Windows paths and convert with
wslpath.
2) Professional Script Skeleton (Copy-Paste)
This is the baseline you’ll reuse for 90% of tools.
#!/usr/bin/env bash
# shellcheck shell=bash
set -Eeuo pipefail
shopt -s nullglob dotglob globstar
SCRIPT_NAME="${0##*/}"
VERSION="1.0.0"
# ----- Colors (disable if not TTY) -----
if [[ -t 2 ]]; then
bold=$'\e[1m'; dim=$'\e[2m'; red=$'\e[31m'; green=$'\e[32m'; yellow=$'\e[33m'; blue=$'\e[34m'; reset=$'\e[0m'
else
bold=""; dim=""; red=""; green=""; yellow=""; blue=""; reset=""
fi
log(){ printf '%s\n' "$*" >&2; }
info(){ printf '%b[INFO]%b %s\n' "$blue" "$reset" "$*" >&2; }
warn(){ printf '%b[WARN]%b %s\n' "$yellow" "$reset" "$*" >&2; }
error(){ printf '%b[ERROR]%b %s\n' "$red" "$reset" "$*" >&2; }
die(){ error "$*"; exit 1; }
debug(){ [[ "${DEBUG:-0}" == "1" ]] && printf '%b[DEBUG]%b %(%F %T)T %s\n' "$dim" "$reset" -1 "$*" >&2; }
export PS4='+ [%D{%F %T}] ${SCRIPT_NAME}:${LINENO}: '
usage(){
cat <<EOF
${bold}${SCRIPT_NAME}${reset} — Example professional Bash script.
Usage:
${SCRIPT_NAME} -i <input> [-o <output>] [--flag] [--retries N] [--windows-path PATH] [--] [args...]
Options:
-i, --input PATH Input file (required)
-o, --output PATH Output file (default: stdout)
--flag Example boolean option
--retries N Retries with exponential backoff (default: 3)
--windows-path PATH Convert a Windows path to WSL (requires WSL)
-h, --help Show help
-V, --version Show version
Env:
DEBUG=1 Enable debug logs
EOF
}
need(){ command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1"; }
cleanup(){
debug "cleanup"
[[ -n "${TMPDIR_CREATED:-}" ]] && [[ -d "$TMPDIR_CREATED" ]] && rm -rf -- "$TMPDIR_CREATED"
}
trap cleanup EXIT
mktempdir(){
local d; d="$(mktemp -d "${TMPDIR:-/tmp}/${SCRIPT_NAME}.XXXXXX")" || die "mktemp failed"
TMPDIR_CREATED="$d"; printf '%s\n' "$d"
}
retry(){
local attempts="$1"; shift; local n=1
until "$@"; do
(( n >= attempts )) && return 1
sleep $(( 2 ** (n-1) )); ((n++))
done
}
to_wsl(){
local p="$1"
command -v wslpath >/dev/null 2>&1 || die "wslpath not found"
wslpath -u "$p"
}
INPUT=""; OUTPUT=""; FLAG=0; RETRIES=3; WINPATH=""
parse_args(){
local argv=()
while (($#)); do
case "$1" in
--input) argv+=(-i "$2"); shift 2;;
--output) argv+=(-o "$2"); shift 2;;
--flag) argv+=(-f); shift;;
--retries) argv+=(-r "$2"); shift 2;;
--windows-path) argv+=(-w "$2"); shift 2;;
--help) argv+=(-h); shift;;
--version) argv+=(-V); shift;;
--) shift; argv+=("$@"); break;;
-*) argv+=("$1"); shift;;
*) argv+=("$1"); shift;;
esac
done
set -- "${argv[@]}"
while getopts ":i:o:r:w:hVf" opt; do
case "$opt" in
i) INPUT="$OPTARG" ;;
o) OUTPUT="$OPTARG" ;;
r) RETRIES="$OPTARG" ;;
w) WINPATH="$OPTARG" ;;
f) FLAG=1 ;;
h) usage; exit 0 ;;
V) printf '%s\n' "$VERSION"; exit 0 ;;
:) die "Option -$OPTARG requires an argument";;
\?) die "Unknown option: -$OPTARG";;
esac
done
shift $((OPTIND-1))
[[ -z "$INPUT" ]] && { usage; die "Missing required: --input"; }
[[ "$RETRIES" =~ ^[0-9]+$ ]] || die "--retries must be an integer"
}
main(){
parse_args "$@"
need awk; need sed
local input_path="$INPUT"
if [[ -n "$WINPATH" ]]; then
input_path="$(to_wsl "$WINPATH")"
info "Converted path: $input_path"
fi
[[ -r "$input_path" ]] || die "Input not readable: $input_path"
local workdir; workdir="$(mktempdir)"; debug "workdir=$workdir"
local out
if ! out="$(retry "$RETRIES" awk 'NF>0{print}' "$input_path")"; then
die "Failed after $RETRIES attempts"
fi
if (( FLAG )); then
out="$(sed 's/[[:space:]]\+$//' <<<"$out")"
fi
if [[ -n "$OUTPUT" ]]; then
printf '%s\n' "$out" > "$OUTPUT"; info "Wrote: $OUTPUT"
else
printf '%s\n' "$out"
fi
}
main "$@"
Save, then
chmod +x ~/bin/pro_skel.sh. Run: pro_skel.sh -i /etc/hosts --flag3) Turn Real One-Liners into Reusable Tools
3.1 Log Error Counter (text & JSON)
LC_ALL=C grep -Eho 'ERROR_[A-Z0-9_]+' app.log | awk '{c[$1]++} END{for(k in c) printf "%7d %s\n", c[k], k}' | sort -nr
Script version with options (streaming, JSON, exit codes)
#!/usr/bin/env bash
set -Eeuo pipefail; shopt -s nullglob
LC_ALL=C
usage(){ cat <<EOF
error_summarize — count error tokens like ERROR_FOO across files/stdin.
Usage: error_summarize [--pattern REGEX] [--json] [--top N] [FILES...]
Defaults: PATTERN='ERROR_[A-Z0-9_]+'; TOP=50
Exit codes: 0 ok, 2 no matches
EOF
}
PAT='ERROR_[A-Z0-9_]+'; JSON=0; TOP=50
argv=(); while (($#)); do
case "$1" in
--pattern) PAT="$2"; shift 2;;
--json) JSON=1; shift;;
--top) TOP="$2"; shift 2;;
-h|--help) usage; exit 0;;
--) shift; argv+=("$@"); break;;
*) argv+=("$1"); shift;;
esac
done; set -- "${argv[@]}"
files=("$@"); [[ ${#files[@]} -gt 0 ]] || files+=("-")
matches="$(grep -Eho -- "$PAT" "${files[@]}" || true)"
[[ -z "$matches" ]] && { echo "no matches" >&2; exit 2; }
if (( JSON )); then
awk '{c[$1]++} END{for(k in c) printf "%s %d\n", k, c[k]}' <<<"$matches" \
| sort -k2,2nr | head -n "$TOP" \
| awk '{printf "{\"key\":\"%s\",\"count\":%d}\n",$1,$2}' | jq -s '.'
else
awk '{c[$1]++} END{for(k in c) printf "%7d %s\n", c[k], k}' <<<"$matches" \
| sort -nr | head -n "$TOP"
fi
3.2 JSON ETL Filter (huge files, streaming)
jq -r '.items[] | select(.status=="active" and .size>1000) | [.id,.name,.created] | @csv' data.json
Script version with stream mode & resilience
#!/usr/bin/env bash
set -Eeuo pipefail
usage(){ echo "json_active_to_csv <data.json> > out.csv"; }
file="${1:-}"; [[ -n "$file" ]] || { usage; exit 2; }
# Stream for huge docs; map path/value; reassemble selectively.
jq -r --stream '
(paths(scalars) as $p | .) as $x
| . as $root
| if ($p|tostring|test("\\/items\\/\\d+$")) then empty else . end
' "$file" 2>/dev/null | jq -r '.items[]? | select(.status=="active" and (.size//0)>1000) | [(.id//""),(.name//""),(.created//"")] | @csv'
3.3 Safe, Fast Recursive Grep
grep -RIn --exclude-dir=node_modules --exclude-dir=.git --line-number "TODO" .
Script with presets, hidden files, NUL-safety
#!/usr/bin/env bash
set -Eeuo pipefail; shopt -s nullglob
PAT="${1:-TODO}"; ROOT="${2:-.}"; EX=("--exclude-dir=.git" "--exclude-dir=node_modules")
grep -RIn "${EX[@]}" --line-number -- "$PAT" "$ROOT"
4) Windows ⇄ Bash Integration (Wrappers)
4.1 PowerShell wrapper for WSL scripts
param([Parameter(Mandatory=$true)][string]$Script,[string[]]$Args)
$esc = ($Args | ForEach-Object { $_.Replace('"','\"') }) -join ' '
wsl.exe bash -lc "`"$Script`" $esc"
Usage: pwsh -File run.ps1 -Script /home/you/bin/error_summarize.sh -- --json --top 10 /mnt/c/logs/app.log
4.2 .bat shim
@echo off
set SCRIPT=/home/you/bin/pro_skel.sh
wsl.exe bash -lc "%SCRIPT% %*"
Tip: If you receive
C:\... paths from Windows, pass them as --windows-path and convert inside your script with wslpath.5) Input Safety, Performance, and Parallelism
NUL-safe file lists
find . -type f -print0 | xargs -0 -n1 -P"$(nproc)" bash -lc 'printf "%s\n" "$@"' _
Robust line iteration
while IFS= read -r line; do
do_stuff "$line"
done < <(some_command_producing_lines)
Fewer passes, more speed
awk '{c[$1]++} END{for(k in c) printf "%7d %s\n", c[k], k}' big.txt | sort -nr
Locale & memory mapping speedups
LC_ALL=C grep -F --binary-files=text --mmap "ERROR_CODE_42" /var/log/app.log
Critical section lock
exec 200>"/tmp/my.lock"
flock -w 10 200 || { echo "lock timeout" >&2; exit 1; }
# critical section here
6) Argument Parsing That Won’t Bite You
- Prefer POSIX
getopts(+ a long-opt shim) over distro-specificgetopt. - Return precise exit codes:
0ok,2usage/no matches,>=10domain errors. - Emit data to
stdout, diagnostics tostderr.
7) Strict Mode, Traps, and Observability
set -Eeuo pipefail
trap 'echo "[ERR] $BASH_SOURCE:$LINENO $BASH_COMMAND" >&2' ERR
export PS4='+ [%D{%F %T}] ${BASH_SOURCE##*/}:${LINENO}: '
DEBUG=1; [[ "${DEBUG:-0}" == 1 ]] && set -x
8) Windows-Specific Excellence
- Scheduling: Task Scheduler → Program:
wsl.exe, Args:bash -lc "/home/you/bin/job.sh --input /mnt/c/data". - Encoding: Prefer PowerShell Core (
pwsh) for UTF-8; in conhost,chcp 65001. - Inter-op: Keep artifacts under
/home, export to Windows viawslpath -wwhen needed.
9) Quality Gates: Lint, Format, Test
sudo apt update && sudo apt install -y shellcheck shfmt bats
shellcheck ~/bin/*.sh
shfmt -i 2 -ci -sr -w ~/bin
bats tests
Sample test (bats)
#!/usr/bin/env bats
@test "prints version" {
run ~/bin/pro_skel.sh --version
[ "$status" -eq 0 ]
[[ "$output" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
}
@test "fails on missing input" {
run ~/bin/pro_skel.sh
[ "$status" -ne 0 ]
}
10) “Onelinerize”: Park Any One-Liner Behind a CLI
Use this to instantly wrap any one-liner into a retryable, file/STDIN-aware tool (great for demos & glue).
#!/usr/bin/env bash
set -Eeuo pipefail; shopt -s nullglob
SCRIPT_NAME="${0##*/}"
usage(){ cat <<EOF
${SCRIPT_NAME} --cmd 'grep -Eo "[A-Z]+"' [--input FILE|-] [--out FILE] [--retries N] [--wpath WINPATH]
EOF
}
die(){ echo "ERROR: $*" >&2; exit 1; }
CMD=""; IN="-"; OUT=""; RETRIES=1; WINPATH=""
argv=(); while (($#)); do
case "$1" in
--cmd) argv+=(-c "$2"); shift 2;;
--input) argv+=(-i "$2"); shift 2;;
--out) argv+=(-o "$2"); shift 2;;
--retries) argv+=(-r "$2"); shift 2;;
--wpath) argv+=(-w "$2"); shift 2;;
-h|--help) argv+=(-h); shift;;
--) shift; argv+=("$@"); break;;
*) argv+=("$1"); shift;;
esac
done; set -- "${argv[@]}"
while getopts ":c:i:o:r:w:h" opt; do
case "$opt" in
c) CMD="$OPTARG" ;; i) IN="$OPTARG" ;; o) OUT="$OPTARG" ;;
r) RETRIES="$OPTARG" ;; w) WINPATH="$OPTARG" ;; h) usage; exit 0 ;;
:) die "Option -$OPTARG requires an argument";; \?) die "Unknown -$OPTARG";;
esac
done; shift $((OPTIND-1))
[[ -z "$CMD" ]] && { usage; die "Missing --cmd"; }
[[ "$RETRIES" =~ ^[0-9]+$ ]] || die "--retries must be int"
if [[ -n "$WINPATH" ]]; then command -v wslpath >/dev/null || die "wslpath required"; IN="$(wslpath -u "$WINPATH")"; fi
[[ "$IN" = "-" || -r "$IN" ]] || die "Input not readable: $IN"
run(){ if [[ "$IN" = "-" ]]; then bash -lc "$CMD"; else bash -lc "$CMD" < "$IN"; fi; }
n=1; until out="$(run 2>&1)"; do (( n >= RETRIES )) && { printf '%s' "$out" >&2; exit 1; }; sleep $((2 ** (n-1))); ((n++)); done
if [[ -n "$OUT" ]]; then printf '%s\n' "$out" > "$OUT"; else printf '%s\n' "$out"; fi
Example: onelinerize --cmd 'grep -Eo "[A-Z]+"' --input /etc/hosts
11) Elite Patterns & Pitfalls (Condensed)
- Always quote expansions:
"$var". Prefer arrays +mapfile -t. - Pipelines hide failures: you already have
pipefail. Keep it. - Process substitution:
diff <(sort a) <(sort b)avoids temp files. - Subcommands pattern:
cmd_help(){ echo help; } cmd_run(){ echo run; } case "${1:-help}" in help) cmd_help ;; run) shift; cmd_run "$@" ;; *) echo "bad"; exit 2;; esac - Locking with timeout: use
flock -w(see §5). - Speed knobs:
LC_ALL=C, single-passawk, mmap grep, parallel viaxargs -P.
12) Ship-Ready Checklist (Windows-First)
- Shebang
#!/usr/bin/env bash;chmod +x; strict mode; usage; examples. - Arg parsing with short+long; precise exit codes; data→stdout, logs→stderr.
- Temp cleanup on
EXIT; lock critical sections; retries for flaky ops. - Windows shim (
.bat/.ps1) verified;--windows-pathsupported or documented. - Lint (
shellcheck), format (shfmt), tests (bats) passing. - Examples + README; store scripts under
~/binand add toPATH.
Appendix: Copy-Ready Snippets (Grab-Bag)
Make ~/bin and include in PATH
mkdir -p ~/bin
echo 'export PATH="$HOME/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
Convert Windows path inside a script
to_wsl(){ command -v wslpath >/dev/null || { echo "wslpath missing" >&2; exit 1; }; wslpath -u "$1"; }
Parallel map with fallback
find . -name '*.log' -print0 | xargs -0 -n1 -P"$(nproc)" bash -lc 'gzip -9 "$1"' _
CSV emit (simple)
awk -v FPAT='([^,]*)|(\"[^\"]+\")' 'BEGIN{OFS=","} {print $1,$2,$3}' input.txt
Jittered retry function
retry_jitter(){ local n=1 max="${1:-5}"; shift
until "$@"; do (( n >= max )) && return 1
sleep "$(( (RANDOM%100)/100 )).$((RANDOM%900+100))"
sleep $((2**(n-1))); ((n++))
done
}
Comments
Post a Comment