I just spent a month updating one of our PowerShell modules to support Linux and MacOS. I learned a lot that I wanted to share with the community as cross-platform support becomes more and more important.
Use "Environment" Class Properties Instead of "env:" Drive
Environment variables are different between the different operating systems. All of them have
PATH, but not much else. Windows and MacOS both have variables for the temp directory, but they have different names.
Instead of using environment variables like
$env:USERNAME, use the static properties on the Environment class instead. They return the correct values across operating systems.
Instead Of Use ---------- --- $env:USERNAME [Environment]::UserName $env:COMPUTERNAME [Environment]::MachineNamen [Environment]::NewLiner`n [Environment]::NewLine $env:TEMP [IO.Path]::GetTempDirectory()
The
Environmentclass also has neat properties like
Is64BitProcess,
Is64BitOperatingSystem, and
UserInteractive, which aren't exposed in the
env:drive.
Use the Same Case When Reading/Setting Environment Variables
Environment variable names are case-sensitive on MacOS and Linux, regardless of how you access them. So,
$env:Path [Environment]::GetEnvironmentVariable('Path')
would return nothing on MacOS or Linux, because the path environment variable is
PATHon those platforms. Since environment variable names are case-insensitive on Windows, you should prefer the case from Linux/MacOS.
Always Use "Join-Path" to Create Path Strings
When the Path Originates in Your Code
Never, ever put paths together with strings, e.g.
"BasePath\ChildPath". That path won't work on Linux or MacOS because their file systems see the
\character as an escape character, not a directory separator. Instead, use
Join-Path. Not only does it use the correct directory separator, but it converts directory separators to the directory separator for the current platform.
For example,
Join-Path -Path '\usr\bin' -ChildPath 'dotnet'
returns
/usr/bin/dotneton Linux/MacOS and
\usr\bin\dotneton Windows.
When the Path Comes from the User
In one situation, our module took in a path from the user via a configuration file. Normally, we would use
Resolve-Pathto get the full path to the file, which normalizes the directory separator, but in this situation, the path may be to a file the user wants us to create and
Resolve-Pathrequires that the path exists. Here's how we got our paths normalized:
# If the user didn't give us an absolute path, # resolve it from the current directory. if( -not [IO.Path]::IsPathRooted($archivePath) ) { $archivePath = Join-Path -Path (Get-Location).Path -ChildPath $archivePath } $archivePath = Join-Path -Path $archivePath -ChildPath '.' $archivePath = [IO.Path]::GetFullPath($archivePath)
This trick relies on:
-
Join-Path
normalizing our directory separators (line 7) and - The
GetFullPath
method on theIO.Path
object replacing..
and.
characters to the parent/current item name, respectively (line 8).
This way we don't have to use regular expressions. We let .NET Core/PowerShell do that work for us.
Use "[IO.Path]::DirectorySeparatorChar" When You Can't Use "Join-Path"
If for some reason you can't use
Join-Pathto create a path or our strategy above, instead of hard-coding the directory separator character, use the
[IO.Path]::DirectorySeparatorCharproperty to get the correct separator for the current operating system. For example,
'ParentPath{0}ChildPath' -f [IO.Path]::DirectorySeparatorChar
Don't Use the "-Qualifier" Switch on "Split-Path"
In some of our tests, we want to create a path on the current drive:
$drive = Split-Path -Qualifier -Path $PSScriptRoot $path = Join-Path -Path $drive -ChildPath 'SomePath'
This doesn't work on Linux/MacOS because "Qualifier" is synonomous with "Drive" and only Windows has the concept of a drive. Instead, use the
PSDriveproperty on the
FileInfoobject for the current file (or whatever file whose root path you want) to get the root path:
$root = (Get-Item -Path $PSScriptRoot).PSDrive.Root $path = Join-Path -Path $root -ChildPath 'SomePath'
The above code returns
/SomePathon Linux/MacOS and
C:\SomePathon Windows (assuming the current script is on the C: drive).
Use the Same Case for Hashtable Keys
On Linux, hashtable keys are case-sensitive. On Windows and MacOS, they aren't. So,
$ht = @{ 'Key' = 'Value' } $ht['KEY']
returns
Valueon Windows and MacOS, and
$nullon Linux.
Don't Use Aliases
Don't use PowerShell's aliases in your scripts. They are different between operating systems. Many of the aliases on Windows were originally added to help non-Windows users find familiar commands, e.g.
lsmapping to
Get-ChildItem. We had one test fixture that was using
scinstead of
Set-Content. Those tests failed when run under Linux.
Use "[IO.Path]::PathSeparator" for "PATH" Environment Variable
Windows uses a different path separator than Linux/MacOS for paths in the
PATHenvironment variable. Windows uses
;. Linux/MacOS use
:. Instead of hard-coding those characters, use the
[IO.Path]::PathSeparatorproperty to use the correct separator for the current operating system. For example, this code shows how to split/join the
PATHenvironment variable in a cross-platform way:
# Get each path in the PATH environment variable. $env:PATH -split [IO.Path]::PathSeparator # Add a path to the current session's PATH environment variable $env:PATH = '{0}{1}{2}' -f $env:PATH,[IO.Path]::PathSeparator,$NewPath
Warning: Windows Executables Run Under the Windows Subsystem for Linux
The Windows Subsytem for Linux is great. We used it a lot to get our module working under Linux instead of spinning up an entire VM. Even though it's running Linux, it's still on Windows, so Windows executables can still run. This is awesome but be mindful of the trade-off: if you have tests or code that run Windows executables, they'll appear to run fine under WSL, but fail when actually run on a Linux machine.
Omit the Extension When Searching for or Running Executables
On Windows, executable files have the
.exeextension. On Linux/MacOS, an executable has file system permissions that mark a file as executable. If you're searching for or running a command that could exist on all operating systems, omit the extension from the name. On Windows, PowerShell will implicitly add the
.exeextension for you (it actually uses the extensions in the
PATHEXTenvironment variable to look for commands). For example, this code will return the path to the .NET Core and Node.js executables, if they exist in your
PATH:
# Finding commands Get-Command -Name 'dotnet' -ErrorAction Ignore Get-Command -Name 'node' -ErrorAction Ignore # Running commands dotnet --version node --version
If your commands exists outside a directory in your
PATHenvironment variable, consider adding that directory to your
PATHeither permanently or temporarily so you don't have to build the logic of cross-platform executable naming yourself.
Supporting Windows PowerShell and PowerShell Core
Some changes we encountered between operating systems weren't because of the operating systems but because we use PowerShell 5.1 on Windows. PowerShell 6 behaves differently from PowerShell 5.1 in some ways.
Use the "FullName" Property on "FileInfo" and "DirectoryInfo" Objects
In some situations converting
FileInfoand
DirectoryInfoobjects to strings (i.e. the objects returned by using
Get-ChildItemagainst the file system) behave differently. On Windows PowerShell, you'll get just the file's name. On PowerShell Core, you'll get the item's full name.
For example, this snippet returns each item's name on Windows PowerShell and each item's full name on PowerShell Core:
Get-ChildItem | ForEach-Object { [string]$_ }
Instead, use the
FullNameproperty to get the full path or
Nameto get just the name:
# Returns each item's full path Get-ChildItem | ForEach-Object { $_.FullName } # Returns each item's name Get-ChildItem | ForEach-Object { $_.Name }
Use an Empty Error Type and Capability Checking When Handling "Invoke-WebRequest" Failures
The exception thrown by
Invoke-WebRequestis different between Windows PowerShell and PowerShell Core. On Windows PowerShell, it is a System.Net.WebException. On PowerShell Core, it is a Microsoft.PowerShell.Commands.HttpResponseException.
So, if you were handling failed web requests like this:
$uri = 'https://httpstat.us/500' try { Invoke-WebRequest -Uri $uri } catch [Net.WebException] { Write-Error -Message ('Failed requesting "{0}": {1}' -f $uri,$_.ErrorDetails) }
You should instead do:
$uri = 'https://httpstat.us/500' try { Invoke-WebRequest -Uri $uri } catch { $errorDetails = $null $response = $_.Exception | Select-Object -ExpandProperty 'Response' -ErrorAction Ignore if( $response ) { $errorDetails = $_.ErrorDetails } # Not an exception making the request or the failed request didn't have a response body. if( $errorDetails -eq $null ) { Write-Error -ErrorRecord $_ } else { Write-Error -Message ('Request to "{0}" failed: {1}' -f $uri,$errorDetails) } }
Notice that instead of checking what version of PowerShell we're on to know if the
ErrorDetailscontains the error's response body, we instead check for the existence of the
Responseproperty on the thrown exception. This property exists on the exception objects thrown by Windows PowerShell and PowerShell Core. This is called a capability check and is the preferred pattern for supporting different ways of doing things across versions and operating systems. When you check for functionality instead of versions, your code will work in more places.
Use "IsWindows", "IsLinux", and "IsMacOS" Variables Sparingly
PowerShell 6 introduces three global variables that you can use to check which platform you're on. You should use these sparingly, and instead use capability checks (see above). If you absolutely need to know what operating system you're on, the
IsWindows,
IsLinux, and
IsMacOSvariables work great.
We turn on strict mode in all our scripts (i.e.
Set-StrictMode -Version 'Latest'), so we can't just use these variables without getting errors on Windows PowerShell. Since they were introduced in PowerShell 6, and that version of PowerShell is the first to run on Linux and MacOS, if any of the variables don't exist, you know you're on Windows. If you have code/modules that need to run on Windows PowerShell and PowerShell Core, you can use this snippet to conditionally create these variables:
if( -not (Test-Variable 'variable:IsWindows') ) { # We know we're on Windows PowerShell 5.1 or earlier $IsWindows = $true $IsLinux = $IsMacOS = $false }
Be a good script/module neighbor by not making these global and instead restricting them to your script/module scope.
Thanks to Joseph Larionov, who helped edit this article.
Thanks, this is a really helpful/practical start for updating some of my code for cross-platform!