Cross-Platform Scripting
Scripts that run on more than one system: PowerShell 7 on Linux and macOS, Bash on Windows with WSL and Git Bash, portable POSIX sh, and choosing the right tool.
Why scripts cross platforms
Kestrel Energy runs Windows on the desktops and the domain controllers, Linux on the web and database servers, and a few Macs in the portal team. Ravi Kumar's deployment script is tested on his Mac, runs in a Linux container in the CI pipeline and is started by Amara from Windows. A script that only works on the machine it was written on causes "works for me" tickets. This page covers what changes between systems and how to write for more than one, or how to choose deliberately not to.
Two PowerShells
"PowerShell" names two different products, and much advice online does not say which one it means.
-
Windows PowerShell is
the original PowerShell, built on the Windows-only .NET Framework.
Its last version, 5.1, is part of Windows: it is started with
powershell.exe, is supported as long as the Windows version it ships with, and gets security fixes but no new features. -
PowerShell 7 is the
open-source, cross-platform successor, built on modern
.NET. It runs on Windows, Linux and macOS, is started with
pwsh, and is installed separately, next to 5.1. As of October 2026 the current long-term support (LTS) release is 7.6, supported until 14 November 2028; 7.4 (LTS) and 7.5 reach end of support on 10 November 2026.
| Windows PowerShell 5.1 | PowerShell 7 | |
|---|---|---|
| Executable | powershell.exe |
pwsh (pwsh.exe on Windows) |
| Runs on | Windows only | Windows, Linux, macOS |
$PSVersionTable.PSEdition |
Desktop |
Core |
| Default file encoding | Varies per cmdlet (ANSI, UTF-16) | UTF-8 without byte order mark everywhere |
| Language additions | — |
&& and || between
commands, ternary ? :, ?? and
??=, ForEach-Object -Parallel
|
| Not in 7 |
*-EventLog, Get-WmiObject,
workflows; the ISE does not support 7
|
Use Get-WinEvent, Get-CimInstance,
VS Code
|
| Error display | Long NormalView | Short ConciseView |
Most scripts run in both, but write down which one a script needs:
#Requires -Version 7.4 at the top makes 5.1 refuse it
with a clear message instead of failing halfway on a
??. Some Windows modules still only work in 5.1; for
those, PowerShell 7 offers a compatibility layer that loads them in
a hidden 5.1 session.
PowerShell on Linux and macOS
PowerShell 7 on Linux is the same language, but the system under it
is different. Scripts should ask where they run instead of assuming.
The automatic variables $IsWindows,
$IsLinux and $IsMacOS exist only in
PowerShell 6 and later; in Windows PowerShell 5.1 they are simply
empty, which is why the check below also tests the edition:
"Edition: $($PSVersionTable.PSEdition)"
"Version: $($PSVersionTable.PSVersion)"
"IsWindows: $IsWindows IsLinux: $IsLinux IsMacOS: $IsMacOS"
# Works in Windows PowerShell 5.1 too, where $IsWindows does not exist:
$onWindows = $PSVersionTable.PSEdition -eq 'Desktop' -or $IsWindows
$logDir = if ($onWindows) { Join-Path -Path $env:ProgramData -ChildPath 'Kestrel' } else { '/var/log/kestrel' }
"Log folder: $logDir"
"Separator: $([System.IO.Path]::DirectorySeparatorChar)"
"Joined: $(Join-Path -Path 'reports' -ChildPath 'meters.csv')"
Edition: Core Version: 7.6.6 IsWindows: False IsLinux: True IsMacOS: False Log folder: /var/log/kestrel Separator: / Joined: reports/meters.csv
Join-Path uses the right separator for the system, and
cmdlets accept both / and \ in paths on
every platform, so forward slashes are the portable choice in
scripts. Use $HOME rather than
$env:USERPROFILE (Windows only), and
[System.IO.Path]::GetTempPath() rather than
$env:TEMP.
Aliases that are not there
On Windows, ls, cat, sort,
rm and a dozen more are aliases for PowerShell cmdlets.
On Linux and macOS Microsoft removed them so the native programs can
run, so the same word does something else:
foreach ($name in 'ls', 'cat', 'sort', 'gci', 'select') {
$cmd = Get-Command -Name $name | Select-Object -First 1
'{0,-7} {1,-12} {2}' -f $name, $cmd.CommandType, $(if ($cmd.CommandType -eq 'Alias') { $cmd.Definition } else { $cmd.Source })
}
ls Application /usr/bin/ls cat Application /usr/bin/cat sort Application /usr/bin/sort gci Alias Get-ChildItem select Alias Select-Object
ls | Where-Object Length -gt 1MB works on Windows and
silently finds nothing on Linux, because /usr/bin/ls
sends text, not file objects. This is one more reason to write full
cmdlet names in scripts (see
PowerShell style).
Case-sensitive file names
Windows file systems ignore case; Linux file systems do not. PowerShell follows the file system, so a path must be spelled exactly. Wildcard patterns, on the other hand, stay case-insensitive:
Set-Content -Path ./Readings.csv -Value 'M-1042;4521'
"readings.csv exists: $(Test-Path -Path ./readings.csv)"
"Readings.csv exists: $(Test-Path -Path ./Readings.csv)"
"Wildcard *.CSV finds: $((Get-ChildItem -Path ./*.CSV).Name)"
readings.csv exists: False Readings.csv exists: True Wildcard *.CSV finds: Readings.csv
What does not exist off Windows
A whole class of administration is Windows-specific and has no
cmdlets on Linux or macOS: services (Get-Service,
Start-Service), the registry provider, CIM and WMI
(Get-CimInstance), the event log, Task Scheduler,
access control lists (Get-Acl) and
Enable-PSRemoting. Execution policy is ignored (Get-ExecutionPolicy
always says Unrestricted), and
SecureString values are not encrypted. A cross-platform
script calls the native tool on each side instead:
systemctl on Linux, Get-Service on
Windows.
Encoding is one thing that got easier: PowerShell 7 writes UTF-8 without a byte order mark and ends lines with the system's line ending, which is what Linux tools expect:
Set-Content -Path ./note.txt -Value 'Café'
'Bytes: ' + ((Get-Content -Path ./note.txt -AsByteStream | ForEach-Object { '{0:X2}' -f $_ }) -join ' ')
Bytes: 43 61 66 C3 A9 0A
C3 A9 is the é in UTF-8, 0A a Linux line
feed, and there is no EF BB BF at the start. Windows
PowerShell 5.1 would have written this file in the system's ANSI
code page.
Bash on Windows and macOS
Three common ways to run Bash scripts on a Windows machine:
| Option | What it is | Good for |
|---|---|---|
| WSL 2 | A real Linux distribution in a lightweight virtual machine | Anything that must behave exactly as on a Linux server |
| Git Bash | Bash and common tools from MSYS2, shipped with Git for Windows | Small scripts around Git; quick Unix tools on a Windows desktop |
| Cygwin | A larger POSIX compatibility layer with its own packages | Older setups; mostly replaced by WSL |
WSL (Windows Subsystem for Linux) runs
real Linux distributions on Windows. Version 2, the default, runs a
genuine Linux kernel in a lightweight
virtual machine
that starts in seconds, and integrates with Windows: the Windows
drives appear under /mnt/c, and Windows programs can be
started from Bash. One command installs it, with Ubuntu as the
default distribution:
wsl --install # as administrator; reboot when asked
wsl -d Ubuntu -- bash ./check-disk.sh 90
Git Bash is lighter: Git for Windows includes a Bash emulation based
on MSYS2 with the usual tools (grep,
sed, awk, ssh). It is fine
for small scripts, but it is not Linux: paths look like
/c/Users/amara, there is no package manager for system
software, and some tools behave differently.
On macOS, /bin/bash is version 3.2, first released in
2006 (newer versions are under the GPLv3 license, which Apple does
not ship), and the default login shell has been zsh since macOS
Catalina (10.15). Bash 3.2 lacks associative arrays (declare -A), mapfile and ${var,,}. Install a
current Bash with Homebrew and start scripts with
#!/usr/bin/env bash, which uses the first
bash in the PATH instead of the old one.
macOS also ships BSD versions of the core tools, which differ from
the GNU tools on Linux in details: sed -i '' instead of
sed -i, date -j -f instead of
date -d, and no grep -P on many systems
(see
quoting and portability).
Line endings
A line ending is the invisible
character sequence that ends each line of a text file: a line feed
(LF, \n) on Linux and macOS, a carriage return plus
line feed (CRLF, \r\n) by Windows convention. Bash
treats the carriage return as part of the line. A script saved with
CRLF on Windows and copied to a Linux server fails in confusing
ways: the shebang names an interpreter /bin/bash\r that
does not exist, and variables get an invisible \r at
the end:
printf '#!/bin/bash\r\nhost=web01\r\necho "checking [$host]"\r\n' > check.sh
chmod +x check.sh
./check.sh
bash check.sh | cat -A
shellcheck -f gcc check.sh | head -n 1
sed -i 's/\r$//' check.sh # convert CRLF to LF
./check.sh
example.sh: line 3: ./check.sh: cannot execute: required file not found checking [web01^M]^M$ check.sh:1:12: error: Literal carriage return. Run script through tr -d '\r' . [SC1017] checking [web01]
cat -A shows the carriage return as ^M.
Older Bash versions report the first error as
bad interpreter: No such file or directory, which
sounds like a missing Bash, not a line-ending problem. Convert with
sed -i 's/\r$//' or dos2unix, and prevent
it at the source: a .gitattributes file in the
repository tells Git to check shell scripts out with LF on every
system, whatever the user's core.autocrlf setting:
*.sh text eol=lf *.ps1 text
PowerShell reads scripts with either line ending, so
.ps1 files need no special rule. Editors such as VS
Code show the line ending in the status bar and can switch it.
Portable shell: POSIX sh
POSIX sh is the shell language
defined by the POSIX standard: the common core that every Unix-like
system's /bin/sh must understand. On Debian and Ubuntu
/bin/sh is dash, a small, fast shell with little beyond
that core; in Alpine Linux containers it is BusyBox ash; on RHEL and
macOS it is Bash in POSIX mode. A script starting with
#!/bin/sh therefore gets a different shell on different
systems, and only POSIX features are safe.
A bashism is a Bash-only feature used
in a script that claims to be POSIX sh: [[ ]], arrays,
echo -e, source,
function name, read -p,
{1..10}. In Bash it works; under dash it fails,
sometimes without stopping the script:
cat > meters.sh <<'END'
if [[ $1 == M-* ]]; then echo "meter $1"; fi
END
bash meters.sh M-1042
sh meters.sh M-1042
echo "sh exit status: $?"
meter M-1042 meters.sh: 1: [[: not found sh exit status: 0
dash looked for a command called [[, did not find it,
treated the condition as false and finished with status 0: the
script "succeeded" without doing its work. ShellCheck finds bashisms
when the shebang says sh, with the SC3xxx codes:
cat > bashisms.sh <<'END'
#!/bin/sh
servers=(web01 web02)
if [[ -n $1 ]]; then echo -e "checking\t${servers[0]}"; fi
END
shellcheck -f gcc bashisms.sh
bashisms.sh:2:9: warning: In POSIX sh, arrays are undefined. [SC3030] bashisms.sh:3:4: warning: In POSIX sh, [[ ]] is undefined. [SC3010] bashisms.sh:3:27: warning: In POSIX sh, echo flags are undefined. [SC3037] bashisms.sh:3:41: warning: In POSIX sh, array references are undefined. [SC3054]
The portable version uses case for pattern matching,
printf instead of echo -e, and runs the
same in both shells:
cat > portable.sh <<'END'
#!/bin/sh
case $1 in
M-[0-9][0-9][0-9][0-9]) printf 'meter %s\n' "$1" ;;
*) printf 'not a meter id: %s\n' "$1" >&2; exit 2 ;;
esac
END
sh portable.sh M-1042
bash portable.sh M-1042
shellcheck portable.sh && echo "shellcheck (POSIX sh): no findings"
meter M-1042 meter M-1042 shellcheck (POSIX sh): no findings
Write POSIX sh only when you must: for minimal containers without
Bash, for installers that run on unknown systems, or for very old or
embedded devices. Otherwise use #!/bin/bash (or
#!/usr/bin/env bash) honestly and enjoy arrays and
[[ ]]. The latest POSIX edition (2024) added
set -o pipefail and $'…' quoting, but
older shells may not support them yet.
Choosing the right tool
Bash and PowerShell are both available everywhere now, so the choice is about the job, not the operating system alone:
| Situation | Good choice | Why |
|---|---|---|
| Glue on Linux servers, containers, CI steps | Bash (sh in minimal images) | Always installed; the tools you call are Unix tools |
| Windows, Active Directory, Microsoft 365, Exchange, Azure | PowerShell | The official modules are PowerShell modules |
| Structured data (JSON, CSV, APIs) on any platform | PowerShell 7 or Python | Objects instead of text parsing |
| Long programs, complex logic, reusable libraries | Python | Better data structures, testing and packaging |
| The same change on many servers | Ansible or similar | Idempotent, inventory-driven (see fan-out) |
A useful rule from the Google Shell Style Guide: once a shell script
grows past about 100 lines or gets complicated control flow, rewrite
it in a more structured language. Mixing is fine, too: a short Bash
wrapper that calls pwsh -File or python3,
or a PowerShell script that calls wsl, uses each tool
where it is strongest.