03 · Cross-Platform PowerShell¶
PowerShell 7 runs on Windows, Linux, and macOS from the same executable — but "runs everywhere" isn't the same as "behaves identically everywhere." Paths, line endings, case sensitivity, and even which aliases exist all differ by platform, and scripts that don't account for that break the moment they leave the machine they were written on.
Detecting the platform¶
Major Minor Patch PreReleaseLabel BuildLabel
----- ----- ----- --------------- ----------
7 6 4
False
False
True
$IsWindows, $IsLinux, $IsMacOS are automatic boolean variables
available in PowerShell 7+ (they don't exist in Windows PowerShell
5.1, where everything is implicitly Windows) — the standard way to branch
platform-specific logic without shelling out to uname or parsing
$PSVersionTable.OS.
The trap: hardcoded backslash paths¶
$badPath = "C:\Users\me\data.txt" # breaks on Linux/macOS entirely
$goodPath = Join-Path (Join-Path $HOME "data") "data.txt"
"Good path: $goodPath"
Join-Path (and [System.IO.Path] methods generally) always emit the
correct separator for the platform actually running the script.
$HOME is populated on all three platforms — prefer it over
$env:USERPROFILE (Windows-only) or $env:HOME (Unix-only) directly.
Any literal \ or / typed into a path string is a portability bug
waiting to surface the first time the script runs somewhere else.
The trap: line endings¶
Windows-authored text files traditionally use \r\n; Linux/macOS use
\n. A file downloaded or generated on one platform and processed with a
line-ending assumption baked in (-split "n"alone, missing files with\r\n) silently mis-splits or leaves stray\rcharacters trailing each
line.-split '\r?\n'` handles both correctly regardless of where the text
came from — worth using by default for any text you didn't generate
yourself in the same script.
The trap: filesystem case sensitivity varies by OS and by filesystem¶
New-Item -ItemType Directory -Path "/tmp/psl/CaseTest" -Force | Out-Null
"Hello" | Out-File "/tmp/psl/CaseTest/File.txt"
Test-Path "/tmp/psl/CaseTest/file.txt"
That returned True on this machine (macOS, APFS, case-insensitive by
default) — the same script on a typical Linux filesystem (ext4,
case-sensitive) would return False for the same input, because
file.txt and File.txt are different files there. Windows (NTFS) is
case-insensitive like macOS by default. The trap: code that "works" on
your Mac or Windows box because the filesystem quietly tolerates a case
mismatch can fail the moment it runs on Linux CI or a Linux server —
always write paths with the exact case you mean, and never rely on the
filesystem to paper over a typo.
Native command exit codes: $LASTEXITCODE¶
$LASTEXITCODE reflects the exit code of the last native (external)
command run — not a PowerShell cmdlet's success/failure, which is
$?/exceptions instead. Any script that shells out to a native tool
(git, docker, curl, platform package managers) and needs to detect
failure has to check $LASTEXITCODE explicitly; PowerShell doesn't throw
automatically just because the external process returned nonzero.
The trap: aliases that only exist on some platforms¶
On this machine, ls resolves straight to the real Unix /bin/ls
binary — there's no PowerShell alias named ls shadowing it here. On
Windows PowerShell, ls is an alias for Get-ChildItem, which
accepts different parameters entirely (-Force means something different
to each). A script that calls ls -la assuming the Unix binary, or calls
ls -Recurse assuming the Get-ChildItem alias, is making a platform
assumption that silently breaks on the other kind of machine — this is
exactly why scripts meant to be portable should call Get-ChildItem
directly rather than ls, dir, or any alias whose target varies.
Cheat sheet¶
| Concern | Cross-platform fix |
|---|---|
| Which OS is this? | $IsWindows / $IsLinux / $IsMacOS |
| Building a path | Join-Path, never a literal \ or / |
| Home directory | $HOME (works everywhere; avoid $env:USERPROFILE/$env:HOME directly) |
| Splitting text into lines | -split '\r?\n', not just "n"or"rn"` |
| Filesystem case sensitivity | assume case-sensitive always, regardless of what your dev machine tolerates |
| Native command success/failure | check $LASTEXITCODE, not $? |
ls, dir, other aliases |
call Get-ChildItem directly in portable scripts |
| Environment variables | $env:PATH uses [System.IO.Path]::PathSeparator to split (: vs ;) |
How It Actually Works¶
pwsh achieves genuine cross-platform behavior because the engine itself
— parser, AST interpreter, object pipeline, type system — is written
entirely in managed .NET code with no Windows API dependency; the parts
that do differ by OS (file system semantics, path separators, process
launching) are isolated behind .NET's own cross-platform abstraction
layer (System.IO, System.Diagnostics.Process), not reimplemented per
platform inside PowerShell itself. This is why the core language behaves
identically everywhere, while a comparatively small set of cmdlets
(Get-WmiObject, Get-EventLog, most of ActiveDirectory) are
Windows-only: they wrap Windows-specific COM/WMI/registry APIs that
simply have no equivalent surface on macOS/Linux, not because the engine
treats those platforms as second-class.
Aliases that "only exist on some platforms" are a direct consequence of
pwsh's compatibility aliasing for interactive convenience: on
Windows, ls, cp, rm, cat are PowerShell aliases pointing at
Get-ChildItem/Copy-Item/Remove-Item/Get-Content — but on Linux/
macOS, pwsh deliberately does not register some of these aliases
(ls, cat, etc.) at startup specifically because a real Unix binary
of the same name already exists on PATH, and shadowing it would be
surprising for users mixing PowerShell with native shell tools. This
means a script relying on the alias ls behaves differently by platform
not due to any semantic difference in PowerShell itself, but due to a
startup-profile decision about which aliases to pre-register — using the
full cmdlet name sidesteps the whole issue.
Path handling differences (/ vs \, case sensitivity) trace back to the
underlying filesystem's own semantics exposed through .NET's Path/
FileSystemInfo APIs — NTFS is case-preserving-but-insensitive by
default while ext4/APFS (in typical configurations) are case-sensitive,
so Test-Path './Foo.txt' and Test-Path './foo.txt' can genuinely
return different answers on Linux for files that would be indistinguishable
on Windows; Join-Path/[System.IO.Path]::Combine normalizing separators
correctly for the current platform is what lets script logic stay
platform-agnostic despite this.
Exercise¶
Take a script that assumes Windows (uses $env:USERPROFILE, backslash
paths, and dir for listing files) and rewrite it to run identically on
Windows, Linux, and macOS: swap in $HOME, Join-Path, Get-ChildItem,
and an explicit $IsWindows/$IsLinux/$IsMacOS branch anywhere
platform-specific behavior is unavoidable (like a config directory path).
Run it with pwsh -File and confirm it produces sensible output on
whatever platform you have available.