PowerShell for Admins PowerShell for Developers Tips and Tricks

Tips for Writing Cross-Platform PowerShell Code

Aaron Jensen
8 min read
Share:

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]::MachineName

n [Environment]::NewLine rn [Environment]::NewLine $env:TEMP [IO.Path]::GetTempDirectory()

The

Environment class 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

PATH on 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/dotnet on Linux/MacOS and

\usr\bin\dotnet on 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-Path to 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-Path requires 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 the

IO.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-Path to create a path or our strategy above, instead of hard-coding the directory separator character, use the

[IO.Path]::DirectorySeparatorChar property 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” {.wp-block-heading}

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

PSDrive property on the

FileInfo object 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

/SomePath on Linux/MacOS and

C:\SomePath on 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

Value on Windows and MacOS, and

$null on 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.

ls mapping to

Get-ChildItem . We had one test fixture that was using

sc instead 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

PATH environment variable. Windows uses

; . Linux/MacOS use

: . Instead of hard-coding those characters, use the

[IO.Path]::PathSeparator property to use the correct separator for the current operating system. For example, this code shows how to split/join the

PATH environment 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 {.wp-block-heading}

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

.exe extension. 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

.exe extension for you (it actually uses the extensions in the

PATHEXT environment 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

PATH environment variable, consider adding that directory to your

PATH either 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

FileInfo and

DirectoryInfo objects to strings (i.e. the objects returned by using

Get-ChildItem against 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

FullName property to get the full path or

Name to 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 {.wp-block-heading}

The exception thrown by

Invoke-WebRequest is 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

ErrorDetails contains the error’s response body, we instead check for the existence of the

Response property 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

IsMacOS variables 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.

Related Articles

Sep 12, 2019

The Ternary Cometh

Developers are likely to be familiar with ternary conditional operators as they’re legal in many languages (Ruby, Python, C++, etc). They’re also often used in coding interviews to test an applicant as they can be a familiar source of code errors. While some developers couldn’t care less about ternary operators, there’s been a cult following waiting for them to show up in Powershell. That day is almost upon us. Any Powershell developer can easily be forgiven for scratching their heads and wondering what a ternary is.