Chapter 16. Environmental Awareness

Introduction

While many of your scripts will be designed to work in isolation, you will often find it helpful to give your script information about its execution environment: its name, current working directory, environment variables, common system paths, and more.

PowerShell offers several ways to get at this information—from its cmdlets and built-in variables to features that it offers from the .NET Framework.

View and Modify Environment Variables

Problem

You want to interact with your system’s environment variables.

Solution

To interact with environment variables, access them in almost the same way that you access regular PowerShell variables. The only difference is that you place env: between the dollar sign ($) and the variable name:

PS > $env:Username
Lee

You can modify environment variables this way, too. For example, to temporarily add the current directory to the path:

PS > Invoke-DemonstrationScript
The term 'Invoke-DemonstrationScript' is not recognized as the name of a cmdlet,
function, script file, or operable program. Check the spelling of the name, or
if a path was included, verify that the path is correct and try again.
At line:1 char:27
+ Invoke-DemonstrationScript <<<<
    + CategoryInfo          : ObjectNotFound: (Invoke-DemonstrationScript
   :String) [], CommandNotFoundException
    + FullyQualifiedErrorId : CommandNotFoundException


Suggestion [3,General]: The command Invoke-DemonstrationScript was not 
found, but does exist in the current location. Windows PowerShell doesn't load
commands from the current location by default. If you trust this command, 
instead type ".\Invoke-DemonstrationScript". See "get-help about_Command_
Precedence" for more details.

PS > $env:PATH = $env:PATH + ".;"
PS > Invoke-DemonstrationScript
The script ran!

Discussion

In batch files, environment variables are the primary way to store temporary information or to transfer information between batch files. PowerShell variables and script parameters are more effective ways to solve those problems, but environment variables continue to provide a useful way to access common system settings, such as the system’s path, temporary directory, domain name, username, and more.

PowerShell surfaces environment variables through its environment provider: a container that lets you work with environment variables much as you would work with items in the filesystem or registry providers. By default, PowerShell defines an env: drive (much like c: or d:) that provides access to this information:

PS > dir env:

Name                          Value
----                          -----
Path                          c:\progra~1\ruby\bin;C:\WINDOWS\system32;C:\
TEMP                          C:\DOCUME~1\Lee\LOCALS~1\Temp
SESSIONNAME                   Console
PATHEXT                       .COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;
(...)

Since it is a regular PowerShell drive, the full way to get the value of an environment variable looks like this:

PS > Get-Content Env:\Username
Lee

When it comes to environment variables, though, that is a syntax you will almost never need to use, because of PowerShell’s support for the Get-Content and Set-Content variable syntax, which shortens that to:

PS > $env:Username
Lee

This syntax works for all drives but is used most commonly to access environment variables. For more information about this syntax, see Access Information About Your Command’s Invocation.

Some environment variables actually get their values from a combination of two places: the machine-wide settings and the current-user settings. If you want to access environment variable values specifically configured at the machine or user level, use the [Environment]::GetEnvironmentVariable() method. For example, if you’ve defined a tools directory in your path, you might see:

PS > [Environment]::GetEnvironmentVariable("Path", "User")
d:\lee\tools

To set these machine- or user-specific environment variables permanently, use the [Environment]::SetEnvironmentVariable() method:

[Environment]::SetEnvironmentVariable(<name>, <value>, <target>)

The target parameter defines where this variable should be stored: User for the current user and Machine for all users on the machine. For example, to permanently add your tools directory to your path:

$pathElements = @([Environment]::GetEnvironmentVariable("Path", "User") -split ";")
$pathElements += "d:\tools"
$newPath = $pathElements -join ";"
[Environment]::SetEnvironmentVariable("Path", $newPath, "User")

For more information about modifying the system path, see Modify the User or System Path.

For more information about the Get-Content and Set-Content variable syntax, see Variables. For more information about the environment provider, type Get-Help About_Environment.

Modify the User or System Path

Problem

You want to update your (or the system’s) PATH variable.

Solution

Use the [Environment]::SetEnvironmentVariable() method to set the PATH environment variable.

$scope = "User"
$pathElements = @([Environment]::GetEnvironmentVariable("Path", $scope)
                -split ";")
$pathElements += "d:\tools"
$newPath = $pathElements -join ";"
[Environment]::SetEnvironmentVariable("Path", $newPath, $scope)

Discussion

In Windows, the PATH environment variable describes the list of directories that applications should search when looking for executable commands. As a convention, items in the path are separated by the semicolon character.

As mentioned in View and Modify Environment Variables, environment variables have two scopes: systemwide variables, and per-user variables. The PATH variable that you see when you type $env:PATH is the result of combining these two.

When you want to modify the path, you need to decide if you want the path changes to apply to all users on the system, or just yourself. If you want the changes to apply to the entire system, use a scope of Machine in the example given by the Solution. If you want it to apply just to your user account, use a scope of User.

As mentioned, elements in the path are separated by the semicolon character. To update the path, the Solution first uses the -split operator to create a list of the individual directories that were separated by semicolons. It adds a new element to the path, and then uses the -join operator to recombine the elements with the semicolon character. This helps prevent doubled-up semicolons, missing semicolons, or having to worry whether the semicolons go before the path element or after.

For more information about working with environment variables, see View and Modify Environment Variables.

Access Information About Your Command’s Invocation

Problem

You want to learn about how the user invoked your script, function, or script block.

Solution

To access information about how the user invoked your command, use the $PSScriptRoot, $PSCommandPath, and $myInvocation variables:

"Script's path: $PSCommandPath"
"Script's location: $PSScriptRoot"
"You invoked this script by typing: " + $myInvocation.Line

Discussion

The $PSScriptRoot and $PSCommandPath variables provide quick access to the information a command most commonly needs about itself: its full path and location.

In addition, the $myInvocation variable provides a great deal of information about the current script, function, or script block—and the context in which it was invoked:

MyCommand

Information about the command (script, function, or script block) itself.

ScriptLineNumber

The line number in the script that called this command.

ScriptName

In a function or script block, the name of the script that called this command.

Line

The verbatim text used in the line of script (or command line) that called this command.

InvocationName

The name that the user supplied to invoke this command. This will be different from the information given by MyCommand if the user has defined an alias for the command.

PipelineLength

The number of commands in the pipeline that invoked this command.

PipelinePosition

The position of this command in the pipeline that invoked this command.

One important point about working with the $myInvocation variable is that it changes depending on the type of command from which you call it. If you access this information from a function, it provides information specific to that function—not the script from which it was called. Since scripts, functions, and script blocks are fairly unique, information in the $myInvocation.MyCommand variable changes slightly between the different command types.

Scripts

Definition and Path

The full path to the currently running script

Name

The name of the currently running script

CommandType

Always ExternalScript

Functions

Definition and ScriptBlock

The source code of the currently running function

Options

The options (None, ReadOnly, Constant, Private, AllScope) that apply to the currently running function

Name

The name of the currently running function

CommandType

Always Function

Script blocks

Definition and ScriptBlock

The source code of the currently running script block

Name

Empty

CommandType

Always Script

Program: Investigate the InvocationInfo Variable

When you’re experimenting with the information available through the $myInvocation variable, it is helpful to see how this information changes between scripts, functions, and script blocks. For a useful deep dive into the resources provided by the $myInvocation variable, review the output of Example 16-1.

Example 16-1. Get-InvocationInfo.ps1

##############################################################################
##
## Get-InvocationInfo
##
## From Windows PowerShell Cookbook (O'Reilly)
## by Lee Holmes (http://www.leeholmes.com/guide)
##
##############################################################################

<#

.SYNOPSIS

Display the information provided by the $myInvocation variable

#>

param(
    ## Switch to no longer recursively call ourselves
    [switch] $PreventExpansion
)

Set-StrictMode -Version 3

## Define a helper function, so that we can see how $myInvocation changes
## when it is called, and when it is dot-sourced
function HelperFunction
{
    "    MyInvocation from function:"
    "-"*50
    $myInvocation

    "    Command from function:"
    "-"*50
    $myInvocation.MyCommand
}

## Define a script block, so that we can see how $myInvocation changes
## when it is called, and when it is dot-sourced
$myScriptBlock = {
    "    MyInvocation from script block:"
    "-"*50
    $myInvocation

    "    Command from script block:"
    "-"*50
    $myInvocation.MyCommand
}

## Define a helper alias
Set-Alias gii .\Get-InvocationInfo

## Illustrate how $myInvocation.Line returns the entire line that the
## user typed.
"You invoked this script by typing: " + $myInvocation.Line

## Show the information that $myInvocation returns from a script
"MyInvocation from script:"
"-"*50
$myInvocation

"Command from script:"
"-"*50
$myInvocation.MyCommand

## If we were called with the -PreventExpansion switch, don't go
## any further
if($preventExpansion)
{
    return
}

## Show the information that $myInvocation returns from a function
"Calling HelperFunction"
"-"*50
HelperFunction

## Show the information that $myInvocation returns from a dot-sourced
## function
"Dot-Sourcing HelperFunction"
"-"*50
. HelperFunction

## Show the information that $myInvocation returns from an aliased script
"Calling aliased script"
"-"*50
gii -PreventExpansion

## Show the information that $myInvocation returns from a script block
"Calling script block"
"-"*50
& $myScriptBlock

## Show the information that $myInvocation returns from a dot-sourced
## script block
"Dot-Sourcing script block"
"-"*50
. $myScriptBlock

## Show the information that $myInvocation returns from an aliased script
"Calling aliased script"
"-"*50
gii -PreventExpansion

For more information about running scripts, see Run Programs, Scripts, and Existing Tools.

Find Your Script’s Name

Problem

You want to know the path and name of the currently running script.

Solution

To determine the full path and filename of the currently executing script, use the $PSCommandPath variable. To determine the text that the user actually typed to invoke your script (for example, in a “Usage” message), use the $myInvocation.InvocationName variable.

Discussion

Because it is so commonly used, PowerShell provides access to the script’s full path through the $PSCommandPath variable. If you want to know just the name of the script (rather than its full path), use the Split-Path cmdlet:

$scriptName = Split-Path -Leaf $PSCommandPath

However, the $PSCommandPath variable was introduced in PowerShell version 3. If you need to access this information in PowerShell version 2, use this function:

function Get-ScriptName
{
    $myInvocation.ScriptName
}

By placing the $myInvocation.ScriptName statement in a function, we drastically simplify the logic it takes to determine the name of the currently running script. If you don’t want to use a function, you can invoke a script block directly, which also simplifies the logic required to determine the current script’s name:

$scriptName = & { $myInvocation.ScriptName }

Although this is a fairly complex way to get access to the current script’s name, the alternative is a bit more error-prone. If you are in the body of a script, you can directly get the name of the current script by typing:

$myInvocation.Path

If you are in a function or script block, though, you must use:

$myInvocation.ScriptName

Working with the $myInvocation.InvocationName variable is sometimes tricky, as it returns the script name when called directly in the script, but not when called from a function in that script. If you need this information from a function, pass it to the function as a parameter.

For more information about working with the $myInvocation variable, see Access Information About Your Command’s Invocation.

Find Your Script’s Location

Problem

You want to know the location of the currently running script.

Solution

To determine the location of the currently executing script, use the $PSScriptRoot variable. For example, to load a data file from the same location as your script:

$dataPath = Join-Path $PSScriptRoot data.clixml

Or to run a command from the same location as your script:

$helperUtility = Join-Path $PSScriptRoot helper.exe
& $helperUtility

Discussion

Because it is so commonly used, PowerShell provides access to the script’s location through the $PSScriptRoot variable. However, this variable was introduced in PowerShell version 3. If you need to access this information in PowerShell version 2, use this function:

function Get-ScriptPath
{
    Split-Path $myInvocation.ScriptName
}

Once we know the full path to a script, the Split-Path cmdlet makes it easy to determine its location. Its sibling, the Join-Path cmdlet, makes it easy to form new paths from their components as well.

By accessing the $myInvocation.ScriptName variable in a function, we drastically simplify the logic it takes to determine the location of the currently running script. For a discussion about alternatives to using a function for this purpose, see Find Your Script’s Name.

For more information about working with the $myInvocation variable, see Access Information About Your Command’s Invocation.

For more information about the Join-Path cmdlet, see Safely Build File Paths Out of Their Components.

Find the Location of Common System Paths

Problem

You want to know the location of common system paths and special folders, such as My Documents and Program Files.

Solution

To determine the location of common system paths and special folders, use the [Environment]::GetFolderPath() method:

PS > [Environment]::GetFolderPath("System")
C:\WINDOWS\system32

For paths not supported by this method (such as All Users Start Menu), use the WScript.Shell COM object:

$shell = New-Object -Com WScript.Shell
$allStartMenu = $shell.SpecialFolders.Item("AllUsersStartMenu")

Discussion

The [Environment]::GetFolderPath() method lets you access the many common locations used in Windows. To use it, provide the short name for the location (such as System or Personal). Since you probably don’t have all these short names memorized, one way to see all these values is to use the [Enum]::GetValues() method, as shown in Example 16-2.

Example 16-2. Folders supported by the [Environment]::GetFolderPath() method

PS > [Enum]::GetValues([Environment+SpecialFolder])
Desktop
Programs
Personal
Favorites
Startup
Recent
SendTo
StartMenu
MyMusic
DesktopDirectory
MyComputer
Templates
ApplicationData
LocalApplicationData
InternetCache
Cookies
History
CommonApplicationData
System
ProgramFiles
MyPictures
CommonProgramFiles

Since this is such a common task for all enumerated constants, though, PowerShell actually provides the possible values in the error message if it is unable to convert your input:

PS > [Environment]::GetFolderPath("aouaoue")
Cannot convert argument "0", with value: "aouaoue", for "GetFolderPath" to
type "System.Environment+SpecialFolder": "Cannot convert value "aouaoue"
to type "System.Environment+SpecialFolder" due to invalid enumeration values.
Specify one of the following enumeration values and try again. The possible
enumeration values are "Desktop, Programs, Personal, MyDocuments, Favorites,
Startup, Recent, SendTo, StartMenu, MyMusic, DesktopDirectory, MyComputer,
Templates, ApplicationData, LocalApplicationData, InternetCache, Cookies,
History, CommonApplicationData, System, ProgramFiles, MyPictures,
CommonProgramFiles"."
At line:1 char:29
+ [Environment]::GetFolderPath( <<<< "aouaoue")

Although this method provides access to the most-used common system paths, it does not provide access to all of them. For the paths that the [Environment]::GetFolderPath() method does not support, use the WScript.Shell COM object. The WScript.Shell COM object supports the following paths: AllUsersDesktop, AllUsersStartMenu, AllUsersPrograms, AllUsersStartup, Desktop, Favorites, Fonts, MyDocuments, NetHood, PrintHood, Programs, Recent, SendTo, StartMenu, Startup, and Templates.

It would be nice if you could use either the [Environment]::GetFolderPath() method or the WScript.Shell COM object, but each of them supports a significant number of paths that the other does not, as Example 16-3 illustrates.

Example 16-3. Differences between folders supported by [Environment]::GetFolderPath() and the WScript.Shell COM object

PS > $shell = New-Object -Com WScript.Shell
PS > $shellPaths = $shell.SpecialFolders | Sort-Object
PS >
PS > $netFolders = [Enum]::GetValues([Environment+SpecialFolder])
PS > $netPaths = $netFolders |
    Foreach-Object { [Environment]::GetFolderPath($_) } | Sort-Object

PS > ## See the shell-only paths
PS > Compare-Object $shellPaths $netPaths |
    Where-Object { $_.SideIndicator -eq "<=" }


InputObject                                                 SideIndicator
-----------                                                 -------------
C:\Documents and Settings\All Users\Desktop                 <=
C:\Documents and Settings\All Users\Start Menu              <=
C:\Documents and Settings\All Users\Start Menu\Programs     <=
C:\Documents and Settings\All Users\Start Menu\Programs\... <=
C:\Documents and Settings\Lee\NetHood                       <=
C:\Documents and Settings\Lee\PrintHood                     <=
C:\Windows\Fonts                                            <=


PS > ## See the .NET-only paths
PS > Compare-Object $shellPaths $netPaths |
     Where-Object { $_.SideIndicator -eq "=>" }


InputObject                                                 SideIndicator
-----------                                                 -------------
                                                            =>
C:\Documents and Settings\All Users\Application Data        =>
C:\Documents and Settings\Lee\Cookies                       =>
C:\Documents and Settings\Lee\Local Settings\Application... =>
C:\Documents and Settings\Lee\Local Settings\History        =>
C:\Documents and Settings\Lee\Local Settings\Temporary I... =>
C:\Program Files                                            =>
C:\Program Files\Common Files                               =>
C:\WINDOWS\system32                                         =>
d:\lee                                                      =>
D:\Lee\My Music                                             =>
D:\Lee\My Pictures                                          =>

For more information about working with classes from the .NET Framework, see Work with .NET Objects.

Get the Current Location

Problem

You want to determine the current location.

Solution

To determine the current location, use the Get-Location cmdlet:

PS > Get-Location

Path
----
C:\temp

PS > $currentLocation = (Get-Location).Path
PS > $currentLocation
C:\temp

In addition, PowerShell also provides access to the current location through the $pwd automatic variable:

PS > $pwd

Path
----
C:\temp

PS > $currentLocation = $pwd.Path
PS > $currentLocation
C:\temp

Discussion

One problem that sometimes impacts scripts that work with the .NET Framework is that PowerShell’s concept of “current location” isn’t always the same as the PowerShell.exe process’s “current directory.” Take, for example:

PS > Get-Location

Path
----
C:\temp

PS > Get-Process | Export-CliXml processes.xml
PS > $reader = New-Object Xml.XmlTextReader processes.xml
PS > $reader.BaseURI
file:///C:/Documents and Settings/Lee/processes.xml

PowerShell keeps these concepts separate because it supports multiple pipelines of execution. The process-wide current directory affects the entire process, so you would risk corrupting the environment of all background tasks as you navigate around the shell if that changed the process’s current directory.

When you use filenames in most .NET methods, the best practice is to use fully qualified pathnames. The Resolve-Path cmdlet makes this easy:

PS > Get-Location

Path
----
C:\temp

PS > Get-Process | Export-CliXml processes.xml
PS > $reader = New-Object Xml.XmlTextReader (Resolve-Path processes.xml)
PS > $reader.BaseURI
file:///C:/temp/processes.xml

If you want to access a path that doesn’t already exist, use the Join-Path cmdlet in combination with the Get-Location cmdlet:

PS > Join-Path (Get-Location) newfile.txt
C:\temp\newfile.txt

For more information about the Join-Path cmdlet, see Safely Build File Paths Out of Their Components.

Safely Build File Paths Out of Their Components

Problem

You want to build a new path out of a combination of subpaths.

Solution

To join elements of a path together, use the Join-Path cmdlet:

PS > Join-Path (Get-Location) newfile.txt
C:\temp\newfile.txt

Discussion

The usual way to create new paths is by combining strings for each component, placing a path separator between them:

PS > "$(Get-Location)\newfile.txt"
C:\temp\newfile.txt

Unfortunately, this approach suffers from a handful of problems:

  • What if the directory returned by Get-Location already has a slash at the end?

  • What if the path contains forward slashes instead of backslashes?

  • What if we are talking about registry paths instead of filesystem paths?

Fortunately, the Join-Path cmdlet resolves these issues and more.

For more information about the Join-Path cmdlet, type Get-Help Join-Path.

Interact with PowerShell’s Global Environment

Problem

You want to store information in the PowerShell environment so that other scripts have access to it.

Solution

To make a variable available to the entire PowerShell session, use a $GLOBAL: prefix when you store information in that variable:

## Create the web service cache, if it doesn't already exist
if(-not (Test-Path Variable:\Lee.Holmes.WebServiceCache))
{
    ${GLOBAL:Lee.Holmes.WebServiceCache} = @{}
}

Discussion

The primary guidance when it comes to storing information in the session’s global environment is to avoid it when possible. Scripts that store information in the global scope are prone to breaking other scripts and prone to being broken by other scripts.

This is a common practice in batch file programming, but script parameters and return values usually provide a much cleaner alternative.

Most scripts that use global variables do that to maintain state between invocations. PowerShell handles this in a much cleaner way through the use of modules. For information about this technique, see Write Commands That Maintain State.

If you do need to write variables to the global scope, make sure that you create them with a name unique enough to prevent collisions with other scripts, as illustrated in the Solution. Good options for naming prefixes are the script name, author’s name, or company name.

For more information about setting variables at the global scope (and others), see Control Access and Scope of Variables and Other Items.

Determine PowerShell Version Information

Problem

You want information about the current PowerShell version, CLR version, compatible PowerShell versions, and more.

Solution

Access the $PSVersionTable automatic variable:

PS > $psVersionTable

Name                           Value
----                           -----
PSVersion                      3.0
WSManStackVersion              3.0
SerializationVersion           1.1.0.1
CLRVersion                     4.0.30319.18010
BuildVersion                   6.2.9200.16384
PSCompatibleVersions           {1.0, 2.0, 3.0}
PSRemotingProtocolVersion      2.2

Discussion

The $PSVersionTable automatic variable holds version information for all of PowerShell’s components: the PowerShell version, its build information, Common Language Runtime (CLR) version, and more.

This automatic variable was introduced in version 2 of PowerShell, so if your script might be launched in PowerShell version 1, you should use the Test-Path cmdlet to test for the existence of the $PSVersionTable automatic variable if your script needs to change its behavior:

if(Test-Path variable:\PSVersionTable)
{
    ...
}

This technique isn’t completely sufficient for writing scripts that work in all versions of PowerShell, however. If your script uses language features introduced by newer versions of PowerShell (such as new keywords), the script will fail to load in earlier versions.

If the ability to run your script in multiple versions of PowerShell is a strong requirement, the best approach is to simply write a script that works in the oldest version of PowerShell that you need to support. It will automatically work in newer versions.

Test for Administrative Privileges

Problem

You have a script that will fail if not run from an administrative session and want to detect this as soon as the script starts.

Solution

Use the IsInRole() method of the System.Security.Principal.WindowsPrincipal class:

$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [System.Security.Principal.WindowsPrincipal] $identity
$role = [System.Security.Principal.WindowsBuiltInRole] "Administrator"

if(-not $principal.IsInRole($role))
{
    throw "This script must be run from an elevated shell."
}

Discussion

Testing for administrative rights, while seemingly simple, is a much trickier task than might be expected.

Before PowerShell, many batch files tried to simply write a file into the operating system’s installation directory. If that worked, you’re an administrator so you can clean up and move on. If not, generate an error. But if you use C:\Windows as the path, your script will fail when somebody installs the operating system on a different drive. If you use the %SYSTEMROOT% environment variable, you still might trigger suspicion from antivirus programs.

As an improvement to that technique, some batch files try to parse the output of the NET LOCALGROUP Administrators command. Unfortunately, this fails on non-English machines, where the group name might be NET LOCALGROUP Administratoren. Most importantly, it detects only if the user is part of the Administrators group, not if his current shell is elevated and he can act as one.

Given that PowerShell has full access to the .NET Framework, the command becomes much simpler. The System.Security.Principal.WindowsPrincipal class provides a method to let you detect if the current session is acting in its administrative capacity.

This method isn’t without its faults, though. Most examples that you’ll find on the Internet are simply wrong. The most common example of applying this API uses this as the command: $principal.IsInRole("Administrators"). If you examine the method definitions, though, you’ll see that the common example ends up calling the first overload definition that takes a string:

PS > $principal.IsInRole

OverloadDefinitions
-------------------
bool IsInRole(string role)
bool IsInRole(System.Security.Principal.WindowsBuiltInRole role)
bool IsInRole(int rid)
bool IsInRole(System.Security.Principal.SecurityIdentifier sid)
bool IPrincipal.IsInRole(string role)

If you look up the documentation, this string-based overload suffers from the same flaw that the NET LOCALGROUP Administrators command does: it relies on group names that change when the operating system language changes.

Fortunately, the API offers an overload that takes a System.Security.Principal.WindowsBuiltInRole enumeration, and those values don’t change between languages. This is the approach that the Solution relies upon.

For more information about dealing with .NET objects, see Work with .NET Objects.