How do I set an env variable in PowerShell if it doesn't exist? - powershell

I'm surprised that I didn't get the answer for this common scenario after Googling for while...
How can an environment variable in be set in PowerShell if it does not exist?

The following code defines environment variable FOO for the current process, if it doesn't exist yet.
if ($null -eq $env:FOO) { $env:FOO = 'bar' }
# If you want to treat a *nonexistent* variable the same as
# an existent one whose value is the *empty string*, you can simplify to:
if (-not $env:FOO) { $env:FOO = 'bar' }
# Alternatively:
if (-not (Test-Path env:FOO)) { $env:FOO = 'bar' }
# Or even (quietly fails if the variable already exists):
New-Item -ErrorAction Ignore env:FOO -Value bar
In PowerShell (Core) 7.1+, which has null-coalescing operators, you can simplify to:
$env:FOO ??= 'bar'
Note:
Environment variables are strings by definition. If a given environment variable is defined, but has no value, its value is the empty string ('') rather than $null. Thus, comparing to $null can be used to distinguish between an undefined environment variable and one that is defined, but has no value. However, note that assigning to environment variables in PowerShell / .NET makes no distinction between $null and '', and either value results in undefining (removing) the target environment variable; similarly, in cmd.exe set FOO= results in removal/non-definition of variable FOO, and the GUI dialog (accessible via sysdm.cpl) doesn't allow you to define a variable with an empty string either. However, the Windows API (SetEnvironmentVariable) does permit creating environment variables that contain the empty string.
On Unix-like platforms, empty-string values are allowed too, and the native, POSIX-compatible shells (e.g, bash and /bin/sh) - unlike PowerShell - also allow you to create them (e.g, export FOO=). Note that environment variable definitions and lookups are case-sensitive on Unix, unlike on Windows.
Note: If the environment variable is created on demand by the assignment above ($env:FOO = ...), it will exist for the current process and any child processes it creates only Thanks, PetSerAl.
The following was mostly contributed by Ansgar Wiechers, with a supplement by Mathias R. Jessen:
On Windows[*], if you want to define an environment variable persistently, you need to use the static SetEnvironmentVariable() method of the [System.Environment] class:
# user environment
[Environment]::SetEnvironmentVariable('FOO', 'bar', 'User')
# system environment (requires admin privileges)
[Environment]::SetEnvironmentVariable('FOO', 'bar', 'Machine')
Note that these definitions take effect in future sessions (processes), so in order to define the variable for the current process as well, run $env:FOO = 'bar' in addition, which is effectively the same as [Environment]::SetEnvironmentVariable('FOO', 'bar', 'Process').
When using [Environment]::SetEnvironmentVariable() with User or Machine, a WM_SETTINGCHANGE message is sent to other applications to notify them of the change (though few applications react to such notifications).
This doesn't apply when targeting Process (or when assigning to $env:FOO), because no other applications (processes) can see the variable anyway.
See also: Creating and Modifying Environment Variables (TechNet article).
[*] On Unix-like platforms, attempts to target the persistent scopes - User or Machine- are quietly ignored, as of .NET (Core) 7, and this non-support for defining persistent environment variables is unlikely to change, given the lack of a unified mechanism across Unix platforms.

Code
function Set-LocalEnvironmentVariable {
param (
[Parameter()]
[System.String]
$Name,
[Parameter()]
[System.String]
$Value,
[Parameter()]
[Switch]
$Append
)
if($Append.IsPresent)
{
if(Test-Path "env:$Name")
{
$Value = (Get-Item "env:$Name").Value + $Value
}
}
Set-Item env:$Name -Value "$value" | Out-Null
}
function Set-PersistentEnvironmentVariable {
param (
[Parameter()]
[System.String]
$Name,
[Parameter()]
[System.String]
$Value,
[Parameter()]
[Switch]
$Append
)
Set-LocalEnvironmentVariable -Name $Name -Value $Value -Append:$Append
if ($Append.IsPresent) {
$value = (Get-Item "env:$Name").Value
}
if ($IsWindows) {
setx "$Name" "$Value" | Out-Null
return
}
$pattern = "\s*export[ \t]+$Name=[\w]*[ \t]*>[ \t]*\/dev\/null[ \t]*;[ \t]*#[ \t]*$Name\s*"
if ($IsLinux) {
$file = "~/.bash_profile"
$content = (Get-Content "$file" -ErrorAction Ignore -Raw) + [System.String]::Empty
$content = [System.Text.RegularExpressions.Regex]::Replace($content, $pattern, [String]::Empty);
$content += [System.Environment]::NewLine + [System.Environment]::NewLine + "export $Name=$Value > /dev/null ; # $Name"
Set-Content "$file" -Value $content -Force
return
}
if ($IsMacOS) {
$file = "~/.zprofile"
$content = (Get-Content "$file" -ErrorAction Ignore -Raw) + [System.String]::Empty
$content = [System.Text.RegularExpressions.Regex]::Replace($content, $pattern, [String]::Empty);
$content += [System.Environment]::NewLine + [System.Environment]::NewLine + "export $Name=$Value > /dev/null ; # $Name"
Set-Content "$file" -Value $content -Force
return
}
throw "Invalid platform."
}
function Set-PersistentEnvironmentVariable
Set a variable/value in actual process and system. This function calls Set-LocalEnvironmentVariable function to set process scope variables and perform task for set variables in machine scope.
On Windows you can use:
[Environment]::SetEnvironmentVariable with machine scope, user or machine don't work on Linux or MacOS
setx command
On Linux we can add export VARIABLE_NAME=Variable value to file ~/.bash_profile. For a new bash terminal the process execute these instructions located in ~/.bash_profile.
On MacOS similiar to Linux but if you have zsh terminal the file is .zprofile, if the default terminal is bash, the file is .bash_profile. In my function code we need to add detection of default terminal if you wish. I assume that default terminal is zsh.
function Set-LocalEnvironmentVariable
Set a variable/value in actual process. Using Drive env:.
Examples
#Set "Jo" value to variable "NameX", this value is accesible in current process and subprocesses, this value is accessible in new opened terminal.
Set-PersistentEnvironmentVariable -Name "NameX" -Value "Jo"; Write-Host $env:NameX
#Append value "ma" to current value of variable "NameX", this value is accesible in current process and subprocesses, this value is accessible in new opened terminal.
Set-PersistentEnvironmentVariable -Name "NameX" -Value "ma" -Append; Write-Host $env:NameX
#Set ".JomaProfile" value to variable "ProfileX", this value is accesible in current process/subprocess.
Set-LocalEnvironmentVariable "ProfileX" ".JomaProfile"; Write-Host $env:ProfileX
Output
Windows 10
Ubuntu WSL
References
Check About Environment Variables
Shell initialization files
ZSH: .zprofile, .zshrc, .zlogin - What goes where?

You can use the following code to set an environment variable in PowerShell if it doesn't exist:
if (!(Test-Path -Path Env:VAR_NAME)) {
New-Item -Path Env:VAR_NAME -Value "VAR_VALUE"
}
Replace VAR_NAME with the name of the environment variable and VAR_VALUE with the desired value.

Related

How to load variables automatically in PowerShell for every session? [duplicate]

On Windows, not counting ISE or x86, there are four (4) profile scripts.
AllUsersAllHosts # C:\Program Files\PowerShell\6\profile.ps1
AllUsersCurrentHost # C:\Program Files\PowerShell\6\Microsoft.PowerShell_profile.ps1
CurrentUserAllHosts # C:\Users\lit\Documents\PowerShell\profile.ps1
CurrentUserCurrentHost # C:\Users\lit\Documents\PowerShell\Microsoft.PowerShell_profile.ps1
On Linux with pwsh 6.2.0 I can find only two locations.
CurrentUserAllHosts # ~/.config/powershell/Microsoft.PowerShell_profile.ps1
CurrentUserCurrentHost # ~/.config/powershell/profile.ps1
Are there any "AllUsers" profile scripts on Linux? If so, where are they?
tl;dr (also applies to Windows):
The conceptual about_Profiles help topic describes PowerShell's profiles (initialization files).
The automatic $PROFILE variable contains a string that is the path of the initialization file for the current user and the current PowerShell host environment (typically, the terminal a.k.a console).
Additional profile files are defined - along the dimensions of (a) all-users vs. current-user and (b) all host environments vs. the current one - which are exposed via properties that the $PROFILE string variable is decorated with, which makes them nontrivial to discover - see below.
None of the profile files exist by default, and in some case even their parent directories may not; the bottom section of this answer shows programmatic on-demand creation and updating of the $PROFILE file.
Olaf provided the crucial pointer in comment:
$PROFILE | select * # short for: $profile | Select-Object -Property *
shows all profile file locations, whether or not the individual profile files exist.
E.g., on my Ubuntu machine with PowerShell installed in /home/jdoe/.powershell, I get:
AllUsersAllHosts : /home/jdoe/.powershell/profile.ps1
AllUsersCurrentHost : /home/jdoe/.powershell/Microsoft.PowerShell_profile.ps1
CurrentUserAllHosts : /home/jdoe/.config/powershell/profile.ps1
CurrentUserCurrentHost : /home/jdoe/.config/powershell/Microsoft.PowerShell_profile.ps1
Length : 62
Note the presence of the [string] type's native Length property, which you could omit if you used $PROFILE | select *host* instead.
That you can get the profile locations that way is not obvious, given that $PROFILE is a string variable (type [string]).
PowerShell decorates that [string] instance with NoteProperty members reflecting all profile locations, which is why select (Select-Object) is able to extract them.
Outputting just $PROFILE - i.e. the string value - yields /home/jdoe/.config/powershell/Microsoft.PowerShell_profile.ps1, i.e. the same path as its CurrentUserCurrentHost property, i.e. the path of the user-specific profile file specific to the current PowerShell host environment (typically, the terminal aka console).[1]
You can verify the presence of these properties with reflection as follows, (which reveals their values too):
$PROFILE | Get-Member -Type NoteProperty
This means that you can also use regular property access and tab completion to retrieve individual profile locations; e.g.:
# Use tab-completion to find a specific profile location.
# Expands to .Length first, then cycles through the profile-location properties.
$profile.<tab>
# Open the all-users, all-hosts profiles for editing.
# Note: Only works if the file already exists.
# Also, you must typically run as admin to modify all-user profiles.
Invoke-Item $profile.AllUsersAllHosts
Convenience functions for getting profile locations and opening profiles for editing:
The code below defines:
Get-Profile enumerates profiles, showing their location and whether they exist on a given machine.
Edit-Profile opens profile(s) for editing (use -Force to create them on demand); note that modifying all-user profiles typically requires running as admin.
function Get-Profile {
<#
.SYNOPSIS
Gets the location of PowerShell profile files and shows whether they exist.
#>
[CmdletBinding(PositionalBinding=$false)]
param (
[Parameter(Position=0)]
[ValidateSet('AllUsersAllHosts', 'AllUsersCurrentHost', 'CurrentUserAllHosts', 'CurrentUserCurrentHost')]
[string[]] $Scope
)
if (-not $Scope) {
$Scope = 'AllUsersAllHosts', 'AllUsersCurrentHost', 'CurrentUserAllHosts', 'CurrentUserCurrentHost'
}
foreach ($thisScope in $Scope) {
[pscustomobject] #{
Scope = $thisScope
FilePath = $PROFILE.$thisScope
Exists = (Test-Path -PathType Leaf -LiteralPath $PROFILE.$thisScope)
}
}
}
function Edit-Profile {
<#
.SYNOPSIS
Opens PowerShell profile files for editing. Add -Force to create them on demand.
#>
[CmdletBinding(PositionalBinding=$false, DefaultParameterSetName='Select')]
param (
[Parameter(Position=0, ValueFromPipelineByPropertyName, ParameterSetName='Select')]
[ValidateSet('AllUsersAllHosts', 'AllUsersCurrentHost', 'CurrentUserAllHosts', 'CurrentUserCurrentHost')]
[string[]] $Scope = 'CurrentUserCurrentHost'
,
[Parameter(ParameterSetName='All')]
[switch] $All
,
[switch] $Force
)
begin {
$scopes = New-Object Collections.Generic.List[string]
if ($All) {
$scopes = 'AllUsersAllHosts', 'AllUsersCurrentHost', 'CurrentUserAllHosts', 'CurrentUserCurrentHost'
}
}
process {
if (-not $All) { $scopes.Add($Scope) }
}
end {
$filePaths = foreach ($sc in $scopes) { $PROFILE.$sc }
$extantFilePaths = foreach ($filePath in $filePaths) {
if (-not (Test-Path -LiteralPath $filePath)) {
if ($Force) {
if ((New-Item -Force -Type Directory -Path (Split-Path -LiteralPath $filePath)) -and (New-Item -Force -Type File -Path $filePath)) {
$filePath
}
} else {
Write-Verbose "Skipping nonexistent profile: $filePath"
}
} else {
$filePath
}
}
if ($extantFilePaths.Count) {
Write-Verbose "Opening for editing: $extantFilePaths"
Invoke-Item -LiteralPath $extantFilePaths
} else {
Write-Warning "The implied or specified profile file(s) do not exist yet. To force their creation, pass -Force."
}
}
}
[1] PowerShell considers the current-user, current-host profile the profile of interest, which is why $PROFILE's string value contains that value. Note that in order to decorate a [string] instance with note properties, Add-Member alone is not enough; you must use the following idiom: $decoratedString = $string | Add-Member -PassThru propName propValue - see the Add-Member help topic.

PowerShell -Confirm only once per function call

I'm obviously trying to do something wrong, so hoping someone can help since it seems easy and it is probably an easy mistake. Basically I am writing a little script to create new folders and I want to include -Confirm, but I want it to basically ask ONCE if the user wants to continue, instead of asking for each nested folder that is created.
Here's what I have so far
function Create-NewFolderStructure{
[CmdletBinding(SupportsShouldProcess)]
param (
[Parameter(Mandatory)]
[string]$NewFolderName,
[Parameter()]
$LiteralPath = (Get-Location),
[Parameter()]
$inputFile = "zChildDirectoryNames.txt"
)
process {
$here = $LiteralPath
$directories = Get-Content -LiteralPath "$($here)\$($inputFile)"
If (-not (Test-Path -Path "$($here)\$($NewFolderName)")){
$directories | ForEach-Object {New-Item -ItemType "directory" -Path "$($here)\$($NewFolderName)" -Name $_}
}
Else {
Write-Host "A $($NewFolderName) folder already exists"
}
}
end {
}
}
The issue is that if I use -Confirm when calling my function, or directly at New-Item - I end up getting the confirmation prompt for every single 'child folder' that is being created. Even with the first prompt, saying "Yes to all" doesn't suppress future prompts
In order to consume the user's answer to the confirmation prompt, you have to actually ask them!
You can do so by calling $PSCmdlet.ShouldContinue():
function Create-NewFolderStructure {
[CmdletBinding(SupportsShouldProcess)]
param (
[Parameter(Mandatory, ValueFromPipeline)]
[string]$NewFolderName,
[Parameter()]
$LiteralPath = (Get-Location),
[Parameter()]
$inputFile = "zChildDirectoryNames.txt",
[switch]$Force
)
process {
if(-not $Force){
$yesToAll = $false
$noToAll = $false
if($PSCmdlet.ShouldContinue("Do you want to go ahead and create new directory '${NewFolderName}'?", "Danger!", [ref]$yesToAll, [ref]$noToAll)){
if($yesToAll){
$Force = $true
}
}
else{
return
}
}
# Replace with actual directory creation logic here,
# pass `-Force` or `-Confirm:$false` to New-Item to suppress its own confirmation prompt
Write-Host "Creating new folder ${NewFolderName}" -ForegroundColor Red
}
}
Now, if the user presses [A] ("Yes To All"), we'll remember it by setting $Force to true, which will skip any subsequent calls to ShouldContinue():
PS ~> "folder 1","folder 2"|Create-NewFolderStructure
Danger!
Do you want to go ahead and create new directory 'folder 1'?
[Y] Yes [A] Yes to All [N] No [L] No to All [S] Suspend [?] Help (default is "Y"): A
Creating new folder folder 1
Creating new folder folder 2
PS ~>
I'd strongly suggest reading through this deep dive to better understand the SupportsShouldProcess facility: Everything you wanted to know about ShouldProcess
Mathias R. Jessen's helpful answer provides an alternative to using the common -Confirm parameter, via a slightly different mechanism based on a custom -Force switch that inverts the logic of your own attempt (prompt is shown by default, can be skipped with -Force).
If you want to make your -Confirm-based approach work:
Call .ShouldProcess() yourself at the start of the process block, which presents the familiar confirmation prompt, ...
... and, if the method returns $true - indicating that the use confirmed - perform the desired operations after setting a local copy of the $ConfirmPreference preference variable to 'None', so as to prevent the cmdlets involved in your operations to each prompt as well.
function New-FolderStructure {
[CmdletBinding(SupportsShouldProcess)]
param (
[Parameter(Mandatory, ValueFromPipeline)]
[string] $NewFolderName,
[string] $LiteralPath = $PWD,
[string] $InputFile = "zChildDirectoryNames.txt"
)
# This block is called once, before pipeline processing begins.
begin {
$directories = Get-Content -LiteralPath "$LiteralPath\$InputFile"
}
# This block is called once if parameter $NewFolderName is bound
# by an *argument* and once for each input string if bound via the *pipeline*.
process {
# If -Confirm was passed, this will prompt for confirmation
# and return `$true` if the user confirms.
# Otherwise `$true` is automatically returned, which also happens
# if '[A] Yes to All' was chosen for a previous input string.
if ($PSCmdlet.ShouldProcess($NewFolderName)) {
# Prevent the cmdlets called below from *also* prompting for confirmation.
# Note: This creates a *local copy* of the $ConfirmPreference
# preference variable.
$ConfirmPreference = 'None'
If (-not (Test-Path -LiteralPath "$LiteralPath\$NewFolderName")) {
$directories | ForEach-Object { New-Item -ItemType Directory -Path "$LiteralPath\$NewFolderName" -Name $_ }
}
Else {
Write-Host "A $NewFolderName folder already exists"
}
}
}
}
Note:
I've changed your function's name from Create-NewFolderStructure to New-FolderStructure to comply with PowerShell's naming conventions, using the approved "verb" New and streamlined the code in a number of ways.
ValueFromPipeline was added as a property to the $NewFolderName parameter so as to support passing multiple values via the pipeline (e.g.
'foo', 'bar' | New-FolderStructure -Confirm)
Note that the process block is then called for each input string, and will also prompt for each, unless you respond with [A] Yes to All); PowerShell remembers this choice between process calls and makes .ShouldProcess() automatically return $true in subsequent calls; similarly, responding to [L] No to All automatically returns $false in subsequent calls.
The need to set $ConfirmPreference to 'None' stems from the fact that PowerShell automatically translates the caller's use of -Confirm into a function-local $ConfirmPreference variable with value 'Low', which makes all cmdlets that support -Confirm act as if -Confirm had been passed.
.ShouldProcess() does not pick up in-function changes to the value of this $ConfirmPreference copy, so it is OK to set it to 'None', without affecting the prompt logic of subsequent process invocations.

How to pass parameters to a PS script invoked through Start-Job?

I want to use start-job to run a .ps1 script requiring a parameter. Here's the script file:
#Test-Job.ps1
Param (
[Parameter(Mandatory=$True)][String]$input
)
$output = "$input to output"
return $output
and here is how I am running it:
$input = "input"
Start-Job -FilePath 'C:\PowerShell\test_job.ps1' -ArgumentList $input -Name "TestJob"
Get-Job -name "TestJob" | Wait-Job | Receive-Job
Get-Job -name "TestJob" | Remove-Job
Run like this, it returns " to output", so $input is null in the script run by the job.
I've seen other questions similar to this, but they mostly use -Scriptblock in place of -FilePath. Is there a different method for passing parameters to files through Start-Job?
tl;dr
$input is an automatic variable (value supplied by PowerShell) and shouldn't be used as a custom variable.
Simply renaming $input to, say, $InputObject solves your problem.
As Lee_Dailey notes, $input is an automatic variable and shouldn't be assigned to (it is automatically managed by PowerShell to provide an enumerator of pipeline input in non-advanced scripts and functions).
Regrettably and unexpectedly, several automatic variables, including $input, can be assigned to: see this answer.
$input is a particularly insidious example, because if you use it as a parameter variable, any value you pass to it is quietly discarded, because in the context of a function or script $input invariably is an enumerator for any pipeline input.
Here's a simple example to demonstrate the problem:
PS> & { param($input) "[$input]" } 'hi'
# !! No output - the argument was quietly discarded.
That the built-in definition of $input takes precedence can be demonstrated as follows:
PS> 'ho' | & { param($input) "[$input]" } 'hi'
ho # !! pipeline input took precedence
While you can technically get away with using $input as a regular variable (rather than a parameter variable) as long as you don't cross scope boundaries, custom use of $input should still be avoided:
& {
$input = 'foo' # TO BE AVOIDED
"[$input]" # Technically works: -> '[foo]'
& { "[$input]" } # FAILS, due to child scope: -> '[]'
}

Delay-bind script block does not work when function is exported from module

I have following function:
function PipeScript {
param(
[Parameter(ValueFromPipeline)]
[Object] $InputObject,
[Object] $ScriptBlock
)
process {
$value = Invoke-Command -ScriptBlock $ScriptBlock
Write-Host "Script: $value"
}
}
When I define this function directly in script and pipe input into it I get following result which is expected:
#{ Name = 'Test' } | PipeScript -ScriptBlock { $_.Name }
# Outputs: "Script: Test"
But when I define this function inside module and export it with Export-ModuleMember -Function PipeScript then pipeline variable $_ inside script block is always null:
Import-Module PipeModule
#{ Name = 'Test' } | PipeScript -ScriptBlock { $_.Name }
# Outputs: "Script: "
Full repro is available at: https://github.com/lpatalas/DelayBindScriptBlock
Can someone explain this behaviour?
Tip of the hat to PetSerAl for all his help.
Here's a simple solution, but note that it runs the script block directly in the caller's scope, i.e. it effectively "dot-sources", which allows modification of the caller's variables.
By contrast, your use of Invoke-Command runs the script block in a child scope of the caller's scope - if that is truly the intent, see the variant solution below.
"Dot-sourcing" the script block is also what standard cmdlets such as Where-Object and ForEach-Object do.
# Define the function in an (in-memory) module.
# An in-memory module is automatically imported.
$null = New-Module {
function PipeScript {
param(
[Parameter(ValueFromPipeline)]
[Object] $InputObject
,
[scriptblock] $ScriptBlock
)
process {
# Use ForEach-Object to create the automatic $_ variable
# in the script block's origin scope.
$value = ForEach-Object -Process $ScriptBlock -InputObject $InputObject
# Output the value
"Script: $value"
}
}
}
# Test the function:
$var = 42; #{ Name = 'Test' } | PipeScript -ScriptBlock { $_.Name; ++$var }
$var # -> 43 - the script block ran in the caller's scope.
The above outputs string Script: Test and 43 afterwards, proving that the input object was seen as $_ and that dot-sourcing worked ($var was successfully incremented in the caller's scope).
Here's a variant, via the PowerShell SDK, that runs the script block in a child scope of the caller's scope.
This can be helpful if you don't want the execution of the script block to accidentally modify the caller's variables.
It is the same behavior you get with the engine-level delay-bind script-block and calculated-property features - though it's unclear whether that behavior was chosen intentionally.
$null = New-Module {
function PipeScript {
param(
[Parameter(ValueFromPipeline)]
[Object] $InputObject
,
[scriptblock] $ScriptBlock
)
process {
# Use ScriptBlock.InvokeContext() to inject a $_ variable
# into the child scope that the script block runs in:
# Creating a custom version of what is normally an *automatic* variable
# seems hacky, but the docs do state:
# "The list of variables may include the special variables
# $input, $_ and $this." - see https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.scriptblock.invokewithcontext
$value = $ScriptBlock.InvokeWithContext(
$null, # extra functions to define (none here)
[psvariable]::new('_', $InputObject) # actual parameter type is List<PSVariable>
)
# Output the value
"Script: $value"
}
}
}
# Test the function:
$var = 42
#{ Name = 'Test' } | PipeScript -ScriptBlock { $_.Name; ++$var }
$var # -> 42 - unaltered, because the script block ran in a child scope.
The above outputs string Script: Test, followed by 42, proving that the script block saw the input object as $_ and that variable $var - although seen in the script block, was not modified, due to running in a child scope.
The ScriptBlock.InvokeWithContext() method is documented here.
As for why your attempt didn't work:
Generally, script blocks are bound to the scope and scope domain in which they are created (except if they're created expressly as unbound script blocks, with [scriptblock]::Create('...')).
A scope outside of a module is part of the default scope domain. Every module has its own scope domain, and except for the global scope, which all scopes across all scope domains see, scopes in different scope domains do not see one another.
Your script block is created in the default scope domain, and when the module-defined function invokes it, the $_ is looked for in the scope of origin, i.e., in the (non-module) caller scope, where it isn't defined, because the automatic $_ variable is created by PowerShell on demand in the local scope, which is in the enclosing module's scope domain.
By using .InvokeWithContext(), the script block runs in a child scope of the caller's scope (as would be the case with .Invoke() and Invoke-Command by default), into which the above code injects a custom $_ variable so that the script block can reference it.
Providing better SDK support for these scenarios is being discussed in GitHub issue #3581.

Cannot modify a script-scoped variable from inside a function

I am currently making a script which is supposed to connect to 42 different local servers and getting the Users of a specific group (fjärrskrivbordsanvändare(Remote desktop users in swedish :D)) from active directory. After it has gotten all the users from the server it has to export the users to a file on MY desktop
The csv file has to look like this:
Company;Users
LawyerSweden;Mike
LawyerSweden;Jennifer
Stockholm Candymakers;Pedro
(Examples)
etc.
Here's the code as of now:
cls
$MolnGroup = 'fjärrskrivbordsanvändare'
$ActiveDirectory = 'activedirectory'
$script:CloudArray
Set-Variable -Name OutputAnvandare -Value ($null) -Scope Script
Set-Variable -Name OutputDomain -Value ($null) -Scope Script
function ReadInfo {
Write-Host("A")
Get-Variable -Exclude PWD,*Preference | Remove-Variable -EA 0
if (Test-Path "C:\file\frickin\path.txt") {
Write-Host("File found")
}else {
Write-Host("Error: File not found, filepath might be invalid.")
Exit
}
$filename = "C:\File\Freakin'\path\super.txt"
$Headers = "IPAddress", "Username", "Password", "Cloud"
$Importedcsv = Import-csv $filename -Delimiter ";" -Header $Headers
$PasswordsArray += #($Importedcsv.password)
$AddressArray = #($Importedcsv | ForEach-Object { $_.IPAddress } )
$UsernamesArray += #($Importedcsv.username)
$CloudArray += #($Importedcsv.cloud)
GetData
}
function GetData([int]$p) {
Write-Host("B")
for ($row = 1; $row -le $UsernamesArray.Length; $row++)
{
# (If the customer has cloud-service on server, proceed)
if($CloudArray[$row] -eq 1)
{
# Code below uses the information read in from a file to connect pc to server(s)
$secstr = New-Object -TypeName System.Security.SecureString
$PasswordsArray[$row].ToCharArray() | ForEach-Object {$secstr.AppendChar($_)}
$cred = new-object -typename System.Management.Automation.PSCredential -argumentlist $UsernamesArray[$row], $secstr
# Runs command on server
$OutputAnvandare = Invoke-Command -computername $AddressArray[$row] -credential $cred -ScriptBlock {
Import-Module Activedirectory
foreach ($Anvandare in (Get-ADGroupMember fjärrskrivbordsanvändare))
{
$Anvandare.Name
}
}
$OutputDomain = Invoke-Command -computername $AddressArray[$row] -credential $cred -ScriptBlock {
Import-Module Activedirectory
foreach ($Anvandare in (Get-ADGroupMember fjärrskrivbordsanvändare))
{
gc env:UserDomain
}
}
$OutputDomain + $OutputAnvandare
}
}
}
function Export {
Write-Host("C")
# Variabler för att bygga up en CSV-fil genom Out-File
$filsökväg = "C:\my\file\path\Coolkids.csv"
$ColForetag = "Company"
$ColAnvandare = "Users"
$Emptyline = "`n"
$delimiter = ";"
for ($p = 1; $p -le $AA.Length; $p++) {
# writes out columns in the csv file
$ColForetag + $delimiter + $ColAnvandare | Out-File $filsökväg
# Writes out the domain name and the users
$OutputDomain + $delimiter + $OutputAnvandare | Out-File $filsökväg -Append
}
}
ReadInfo
Export
My problem is, I can't export the users or the domain. As you can see i tried to make the variables global to the whole script, but $outputanvandare and $outputdomain only contains the information i need inside of the foreach loop. If I try to print them out anywhere else, they're empty?!
This answer focuses on variable scoping, because it is the immediate cause of the problem.
However, it is worth mentioning that modifying variables across scopes is best avoided to begin with; instead, pass values via the success stream (or, less typically, via by-reference variables and parameters ([ref]).
To expound on PetSerAl's helpful comment on the question: The perhaps counter-intuitive thing about PowerShell variable scoping is that:
while you can see (read) variables from ancestral (higher-up) scopes (such as the parent scope) by referring to them by their mere name (e.g., $OutputDomain),
you cannot modify them by name only - to modify them you must explicitly refer to the scope that they were defined in.
Without scope qualification, assigning to a variable defined in an ancestral scope implicitly creates a new variable with the same name in the current scope.
Example that demonstrates the issue:
# Create empty script-level var.
Set-Variable -Scope Script -Name OutputDomain -Value 'original'
# This is the same as:
# $script:OutputDomain = 'original'
# Declare a function that reads and modifies $OutputDomain
function func {
# $OutputDomain from the script scope can be READ
# without scope qualification:
$OutputDomain # -> 'original'
# Try to modify $OutputDomain.
# !! Because $OutputDomain is ASSIGNED TO WITHOUT SCOPE QUALIFICATION
# !! a NEW variable in the scope of the FUNCTION is created, and that
# !! new variable goes out of scope when the function returns.
# !! The SCRIPT-LEVEL $OutputDomain is left UNTOUCHED.
$OutputDomain = 'new'
# !! Now that a local variable has been created, $OutputDomain refers to the LOCAL one.
# !! Without scope qualification, you cannot see the script-level variable
# !! anymore.
$OutputDomain # -> 'new'
}
# Invoke the function.
func
# Print the now current value of $OutputDomain at the script level:
$OutputDomain # !! -> 'original', because the script-level variable was never modified.
Solution:
There are several ways to add scope qualification to a variable reference:
Use a scope modifier, such as script in $script:OutputDomain.
In the case at hand, this is the simplest solution:
$script:OutputDomain = 'new'
Note that this only works with absolute scopes global, script, and local (the default).
A caveat re global variables: they are session-global, so a script assigning to a global variable could inadvertently modify a preexisting global variable, and, conversely, global variables created inside a script continue to exist after the script terminates.
Use Get/Set-Variable -Scope, which - in addition to supporting the absolute scope modifiers - supports relative scope references by 0-based index, where 0 represents the current scope, 1 the parent scope, and so on.
In the case at hand, since the script scope is the next higher scope,
Get-Variable -Scope 1 OutputDomain is the same as $script:OutputDomain, and
Set-Variable -Scope 1 OutputDomain 'new' equals $script:OutputDomain = 'new'.
(A rarely used alternative available inside functions and trap handlers is to use [ref], which allows modifying the variable in the most immediate ancestral scope in which it is defined: ([ref] $OutputDomain).Value = 'new', which, as PetSerAl points out in a comment, is the same as (Get-Variable OutputDomain).Value = 'new')
For more information, see:
Get-Help about_Variables
Get-Help about_Scopes
Finally, for the sake of completeness, Set-Variable -Option AllScope is a way to avoid having to use scope qualification at all (in all descendent scopes), because effectively then only a single variable by that name exists, which can be read and modified without scope qualification from any (descendent) scope.
# By defining $OutputDomain this way, all descendent scopes
# can both read and assign to $OutpuDomain without scope qualification
# (because the variable is effectively a singleton).
Set-Variable -Scope Script -Option AllScope -Name OutputDomain
However, I would not recommend it (at least not without adopting a naming convention), as it obscures the distinction between modifying local variables and all-scope variables:
in the absence of scope qualification, looking at a statement such as $OutputDomain = 'new' in isolation, you cannot tell if a local or an all-scope variable is being modified.
Since you've mentioned that you want to learn, I hope you'll pardon my answer, which is a bit longer than normal.
The issue that's impacting you here is PowerShell Variable Scoping. When you're commiting the values of $outputAvandare and $outputDomain, they only exist for as long as that function is running.
Function variables last until the function ends.
Script variables last until the script ends.
Session/global variables last until the session ends.
Environmental variable persist forever.
If you want to get the values out of them, you could make them Global variables instead, using this syntax:
$global:OutputAnvandare = blahblahblah
While that would be the easiest fix for your code, Global variables are frowned upon in PowerShell, since they subvert the normal PowerShell expectations of variable scopes.
Much better solution :)
Don't be dismayed, you're actually almost there with a really good solution that conforms to PowerShell design rules.
Today, your GetData function grabs the values that we want, but it only emits them to the console. You can see this in this line on GetData:
$OutputDomain + $OutputAnvandare
This is what we'd call emitting an object, or emiting data to the console. We need to STORE this data instead of just writing it. So instead of simply calling the function, as you do today, do this instead:
$Output = GetData
Then your function will run and grab all the AD Users, etc, and we'll grab the results and stuff them in $output. Then you can export the contents of $output later on.