How to Write a Bash Script on Windows 11: From One-Liners to Reusable, Production-Grade CLI Tools

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/...) with wslpath.
  • LF endings everywhere; use dos2unix if 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 --flag

3) 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-specific getopt.
  • Return precise exit codes: 0 ok, 2 usage/no matches, >=10 domain errors.
  • Emit data to stdout, diagnostics to stderr.

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 via wslpath -w when 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-pass awk, mmap grep, parallel via xargs -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-path supported or documented.
  • Lint (shellcheck), format (shfmt), tests (bats) passing.
  • Examples + README; store scripts under ~/bin and add to PATH.

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 }

This document is self-contained: everything you need to go from one-liners to robust, Windows-friendly Bash tooling—no omissions. Save as bash-on-windows11-handbook.html, open locally, and start copying.

Comments

Popular posts from this blog