I have recently started using Set-StrictMode to get into better scripting habbits (i.e. declaring variables and such) and I have run into a small issue. For most of my scripts I will create a hashtable $Script = #{} and then declare all variables used within the script as sub properties under that because no matter what, when you start the script, all the variables will be clean and if you print out all the variables at the end of your script you know they will be from that specific session.
Historically if I needed to see if a subvariable such as $Script.RunOnce was declared I would just use If ($Script.RunOnce) {} but with strict mode you have to do something along the lines of this If (Test-Path Variable:\Script.WriteOnce) {} except test-path sees "Script.WriteOnce" as its own variable, not a sub variables underneath $Script
Why do I need to do this you may ask? Well I am writing a function that uses .Net Streamwriter and I want to make sure that if the variable "$WriteTee.StreamWriter" exists run the $WriteTee.StreamWriter.Close and $WriteTee.StreamWriter.Flush prior to declaring $Write-Tee again or else when I attempt to open a new streamwriter it will error out and I have to manually close the .net handle on the file before I can continue testing the script.
Long story short
Is there a way to test for $WriteTee.StreamWriter using Test-Path or some other way that doesn't create an error with Set-Strictmode
Super gutted example verison of my script.
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Inquire'
Function Write-Tee {
Begin {
#Variables that are needed only the first time the log function is started.
If ($WriteTee.RunOnce) {
If ($WriteTee.StreamWriter) { Write-Tee -Severity Error -Message "Log Writer Already Open. Attempting to close." -Close}
New-Variable -ErrorAction SilentlyContinue -Force -Name WriteTee -Value #{} -Scope Script
$Script:WriteTee.RunOnce = $True
}
}#End-Begin
Process {}#End-Process
End {
If ($Close -AND $Script:WriteTee) {
Write-Tee -Severity Info -Message "Flushing Log Writer and closing."
$WriteTee.StreamWriter.Flush()
$WriteTee.StreamWriter.Close()
Remove-Variable -ErrorAction SilentlyContinue -Force -Name WriteTee -Scope Script
Remove-Variable -ErrorAction SilentlyContinue -Force -Name WriteTee
}#End-If
}#End-End
}
Write-Tee -Message "Test" -Severity "Warning"
You can use test-path with the variable provider and the name of the variable to find out if a variable has been assigned to, but finding properties of a variable (or items in a hashtable, it's not clear which you are dealing with) requires other tactics:
To find out if a hashtable has an item with a key, you can do something like:
$writeTee.ContainsKey('StreamWriter') #returns $true or $false
To find out if a variable has a particular property, you can use get-member:
$writeTee | Get-Member -name StreamWriter #returns the member or nothing
Related
I am trying to build a small program that shutdown WSL and other programs that are running WSL in it like Windows Terminal.
I want the user to be able to set TRUE or FALSE in a .conf file variable depending on if the Windows Terminal wants to be closed or not by checking it with a conditional but when i add the if statement checking for the variable the program just simply doesn't work.Any ideas on where i could be making the mistake?
Here is the code:
# Gets the settings from the conig file
Foreach ($i in $(Get-Content stop.conf))
{
Set-Variable -Name $i.split("=")[0] -Value $i.split("=",2)[1]
}
# Cheks for open processes and saves it to a variable
$IDE = Get-Process $EDITOR -ErrorAction SilentlyContinue
$windowsTerminal = Get-Process $windowsterminal -ErrorAction SilentlyContinue
$linuxsys = Get-Process $DISTRO -ErrorAction SilentlyContinue
# OPTIONAL
# Checks if Windows Terminal is open and closes it
if ($WINTERMINAL -eq $true)
{
if ($windowsTerminal)
{
# Try gratefully first
$windowsTerminal.CloseMainWindow()
Sleep 5
if (!$windowsTerminal.HasExited)
{
$windowsTerminal | Stop-Process -Force
}
}
}
Just in case the .conf file looks like this:
DISTRO=ubuntu2004
EDITOR=code
WINTERMINAL=$true
I agree with #santiago-squarzon that a .JSON file would be easier here. Part of the reason it would be easier here would be that you would automatically get support for certain data types, like Boolean.
However, I think your code could work with a couple of minors changes:
# Gets the settings from the conig file
Foreach ($i in $(Get-Content stop.conf))
{
# Change one, using $executionContext here (speed)
$varName, $varValue = $i.Split("=", 2)
$executionContext.SessionState.PSVariable.Set($varName, $varValue)
}
# Cheks for open processes and saves it to a variable
$IDE = Get-Process $EDITOR -ErrorAction SilentlyContinue
$windowsTerminal = Get-Process $windowsterminal -ErrorAction SilentlyContinue
$linuxsys = Get-Process $DISTRO -ErrorAction SilentlyContinue
# At this point, your variables are all strings. There's two ways forward from here:
# 1. Make it an actual boolean
# 2. Match potential yes values (I'll do #2)
if ($WINTERMINAL -match "^(?>$true|yes|y)$")
{
if ($windowsTerminal)
{
# Try gratefully first
$windowsTerminal.CloseMainWindow()
Sleep 5
if (!$windowsTerminal.HasExited)
{
$windowsTerminal | Stop-Process -Force
}
}
}
The reason I'm choosing to match instead of [bool]::Parse($winTerminal) is that your original code had a truth value of "Yes", not "True". If you want to provide a more human-friendly option than "true" or "false", this is the way to go.
Since you're reading from a plain-text file, your dynamically created variables based on the file's content will contain string values.
Thus, use a verbatim (single-quoted) string ('...') to check for a string with verbatim value $true (as opposed to the automatic (built-in) $true variable (conceptually, a constant), which is a Boolean)[1]:
if ($WINTERMINAL -eq '$true') # Note the '...' quoting; ditto for '$false'
If you want to be more flexible and also accept verbatim true and yes (or any case variation thereof - note that PowerShell is generally case-insensitive by default):
if ($WINTERMINAL -in '$true', 'true', 'yes')
Note:
While using JSON as your config file's format, as suggested, would indeed give you support for a few data types (other than string), including Booleans, maintaining JSON files manually is error-prone.
A friendlier format that is also type-aware, which PowerShell does not natively support, however, is TOML, which you can think of as a typed superset of INI files (.ini)
GitHub issue #9035, which was declined, asked for INI-file support, in the context of which TOML support was discussed too.
As of this writing, there are no TOML modules in the PowerShell Gallery.
You can use a .NET library such as Tomlyn directly, but use of NuGet packages in PowerShell is currently cumbersome, unfortunately - see this answer for an example.
[1] When you coerce a Boolean to a string, such as in an -eq operation with a string as the LHS, $true and $false become verbatim strings True and False, respectively. E.g., 'True' -eq $true is true.
I have been given the task to write a PS script that will, from a list of machines in a text file:
Output the IP address of the machine
Get the version of the SCCM client on the machine
Produce a GPResult HTMl file
OR
Indicate that the machine is offline
With a final stipulation of running the script in the background (Job)
I have the scriptblock that will do all of these things, and even have the output formatted like I want. What I cannot seem to do, is get the scriptblock to call the source file from within the same directory as the script. I realize that I could simply hard-code the directories, but I want to be able to run this on any machine, in any directory, as I will need to use the script in multiple locations.
Any suggestions?
Code is as follows (Note: I am in the middle of trying stuff I gathered from other articles, so it has a fragment or two in it [most recent attempt was to specify working directory], but the core code is still there. I also had the idea to declare the scriptblock first, like you do with variables in other programming languages, but more for readability than anything else):
# List of commands to process in job
$ScrptBlk = {
param($wrkngdir)
Get-Content Hostnames.txt | ForEach-Object {
# Check to see if Host is online
IF ( Test-Connection $_ -count 1 -Quiet) {
# Get IP address, extracting only IP value
$addr = (test-connection $_ -count 1).IPV4Address
# Get SCCM version
$sccm = (Get-WmiObject -NameSpace Root\CCM -Class Sms_Client).ClientVersion
# Generate GPResult HTML file
Get-GPResultantSetOfPolicy -computer $_.name -reporttype HTML -path ".\GPRes\$_ GPResults.html"}
ELSE {
$addr = "Offline"
$sccm = " "}
$tbl = New-Object psobject -Property #{
Computername = $_
IPV4Address = $addr
SCCM_Version = $sccm}}}
# Create (or clear) output file
Echo "" > OnlineCheckResults.txt
# Create subdirectory, if it does not exist
IF (-Not (Get-Item .\GPRes)) { New-Item -ItemType dir ".\GPRes" }
# Get current working directory
$wrkngdir = $PSScriptRoot
# Execute script
Start-Job -name "OnlineCheck" -ScriptBlock $ScrptBlk -ArgumentList $wrkngdir
# Let job run
Wait-Job OnlineCheck
# Get results of job
$results = Receive-Job OnlineCheck
# Output results to file
$results >> OnlineCheckResults.txt | FT Computername,IPV4Address,SCCM_Version
I appreciate any help you may have to offer.
Cheers.
~DavidM~
EDIT
Thanks for all the help. Setting the working directory works, but I am now getting a new error. It has no line reference, so I am not sure where the problem might be. New code below. I have moved the sriptblock to the bottom, so it is separate from the rest of the code. I thought that might be a bit tidier. I do apologize for my earlier code formatting. I will attempt to do better with the new example.
# Store working directory
$getwkdir = $PWD.Path
# Create (or clear) output file
Write-Output "" > OnlineCheckResults.txt
# Create subdirectory, if it does not exist. Delete and recreate if it does
IF (Get-Item .\GPRes) {
Remove-Item -ItemType dir "GPRes"
New-Item -ItemType dir "GPRes"}
ELSE{
New-Item -ItemType dir "GPRes"}
# Start the job
Start-Job -name "OnlineCheck" -ScriptBlock $ScrptBlk -ArgumentList $getwkdir
# Let job run
Wait-Job OnlineCheck
# Get results of job
$results = Receive-Job OnlineCheck
# Output results to file
$results >> OnlineCheckResults.txt | FT Computername,IPV4Address,SCCM_Version
$ScrptBlk = {
param($wrkngdir)
Set-Location $wrkngdir
Get-Content Hostnames.txt | ForEach-Object {
IF ( Test-Connection $_ -count 1 -Quiet) {
# Get IP address, extracting only IP value
$addr = (test-connection $_ -count 1).IPV4Address
# Get SCCM version
$sccm = (Get-WmiObject -NameSpace Root\CCM -Class Sms_Client).ClientVersion
Get-GPResultantSetOfPolicy -computer $_.name -reporttype HTML -path ".\GPRes\$_ GPResults.html"}
ELSE {
$addr = "Offline"
$sccm = " "}
$tbl = New-Object psobject -Property #{
Computername = $_
IPV4Address = $addr
SCCM_Version = $sccm}}}
Error text:
Cannot validate argument on parameter 'ComputerName'. The argument is null or empty. Provide an argument that
is not null or empty, and then try the command again.
+ CategoryInfo : InvalidData: (:) [Test-Connection], ParameterBindingValidationException
+ FullyQualifiedErrorId : ParameterArgumentValidationError,Microsoft.PowerShell.Commands.TestConnectionCommand
+ PSComputerName : localhost
As Theo observes, you're on the right track by trying to pass the desired working directory to the script block via -ArgumentList $wrkngdir, but you're then not using that argument inside your script block.
All it takes is to use Set-Location at the start of your script block to switch to the working directory that was passed:
$ScrptBlk = {
param($wrkngdir)
# Change to the specified working dir.
Set-Location $wrkngdir
# ... Get-Content Hostnames.txt | ...
}
# Start the job and pass the directory in which this script is located as the working dir.
Start-Job -name "OnlineCheck" -ScriptBlock $ScrptBlk -ArgumentList $PSScriptRoot
In PSv3+, you can simplify the solution by using the $using: scope, which allows you to reference variables in the caller's scope directly; here's a simplified example, which you can run directly from the prompt (I'm using $PWD as the desired working dir., because $PSScriptRoot isn't defined at the prompt (in the global scope)):
Start-Job -ScriptBlock { Set-Location $using:PWD; Get-Location } |
Receive-Job -Wait -AutoRemove
If you invoke the above command from, say, C:\tmp, the output will reflect that path too, proving that the background job ran in the same working directory as the caller.
Working directories in PowerShell background jobs:
Before PowerShell 7.0, starting background jobs with Start-Job uses the directory returned by [environment]::GetFolderPath('MyDocuments') as the initial working directory, which on Windows is typically $HOME\Documents, whereas it is just $HOME on Unix-like platforms (in PowerShell Core).
Setting the working dir. for the background job via Start-Job's -InitializationScript script-block argument via a $using: reference - e.g., Start-Job -InitializationScript { $using:PWD } { ... } should work, but doesn't in Windows PowerShell v5.1 / PowerShell [Core] 6.x, due to a bug (the bug is still present in PowerShell 7.0, but there you can use -WorkingDirectory).
In PowerShell (Core) 7+, Start-Job now sensibly defaults to the caller's working directory and also supports a -WorkingDirectory parameter to simplify specifying a working directory.
In PowerShell (Core) 6+ you can alternatively start background jobs with a post-positional & - the same way that POSIX-like shells such as bash do - in which case the caller's working directory is inherited; e.g.:
# PS Core only:
# Outputs the caller's working dir., proving that the background job
# inherited the caller's working dir.
(Get-Location &) | Receive-Job -Wait -AutoRemove
If I understand correctly, I think that the issue you are having is because the working directory path is different inside the execution of the Script Block. This commonly happens when you execute scripts from Scheduled tasks or pass scripts to powershell.exe
To prove this, let's do a simple PowerShell code:
#Change current directory to the root of C: illustrate what's going on
cd C:\
Get-Location
Path
----
C:\
#Execute Script Block
$ScriptBlock = { Get-Location }
$Job = Start-Job -ScriptBlock $ScriptBlock
Receive-Job $Job
Path
----
C:\Users\HAL9256\Documents
As you can see the current path inside the execution of the script block is different than where you executed it. I have also seen inside of Scheduled tasks, paths like C:\Windows\System32 .
Since you are trying to reference everything by relative paths inside the script block, it won't find anything. One solution is to use the passed parameter to change your working directory to something known first.
Also, I would use $PWD.Path to get the current working directory instead of $PSScriptRoot as $PSScriptRoot is empty if you run the code from the console.
I have a cute little script in my $PROFILE helping me start Notepad++ from the script showing a file of my choosing.
function Edit{
param([string]$file = " ")
Start-Process "C:\Program Files\Notepad++\notepad++.exe" -ArgumentList $file
}
It's worked great until recently, where I jump between different systems. I discovered that NPP is installed in C:\Program Files on some systems but in C:\Program Files (x86) on others. I can edit the script adapting it but having done so a gazillion times (i.e. 5 to this point), I got sick and tired of it, realizing that I have to automate this insanity.
Knowing little about scripting, I wonder what I should Google for. Does best practice dictate using exception handling in such a case or is it more appropriate to go for conditional expressions?
According to Get-Host | Select-Object Version I'm running version 5.1, if it's of any significance. Perhaps there's an even neater method I'm unaware of? Relying on an environment variable? I'd also prefer to not use a method valid in an older version of PS, although working, if there's a more convenient approach in a later one. (And given my experience on the subject, I can't tell a duck from a goose.)
I would use conditionals for this one.
One option is to test the path directly if you know for certain it is in a particular location.
Hard coded paths:
function Edit{
param([string]$file = " ")
$32bit = "C:\Program Files (x86)\Notepad++\notepad++.exe"
$64bit = "C:\Program Files\Notepad++\notepad++.exe"
if (Test-Path $32bit) {Start-Process -FilePath $32bit -ArgumentList $file}
elseif (Test-Path $64bit) {Start-Process -FilePath $64bit -ArgumentList $file}
else {Write-Error -Exception "NotePad++ not found."}
}
Another option is pulling path information from registry keys, if they're available:
function Edit{
param([string]$file = " ")
$32bit = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Notepad++\' -ErrorAction SilentlyContinue).("(default)")
$64bit = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\WOW6432Node\Notepad++\' -ErrorAction SilentlyContinue).("(default)")
if ($32bit) {Start-Process -FilePath "$32bit\notepad++.exe" -ArgumentList $file}
elseif ($64bit) {Start-Process -FilePath "$64bit\notepad++.exe" -ArgumentList $file}
else {Write-Error -Exception "NotePad++ not found."}
}
Based on the great help from #BoogaRoo (who should get some +1 for effort) and asked by the same to post my own version of the answer, I go against my reluctance to post asnwers to own questions due to strong sensation of tackiness.
My final version, taking into account systems that lack NP++ but still want to show the editor of some kind.
function Edit{
param([string]$file = " ")
$executable = "Notepad++\notepad++.exe"
$32bit = "C:\Program Files (x86)\" + $executable
$64bit = "C:\Program Files\" + $executable
$target = "notepad"
if(Test-Path $32bit) { $target = $32bit }
if(Test-Path $64bit) { $target = $64bit }
Start-Process $target -ArgumentList $file
}
Let me offer a streamlined version that also supports passing multiple files:
function Edit {
param(
# Allow passing multiple files, both with explicit array syntax (`,`-separated)
# or as indiv. arguments.
[Parameter(ValueFromRemainingArguments)]
[string[]] $File
)
# Construct the potential Notepad++ paths.
# Note: `-replace '$'` is a trick to append a string to each element
# of an array.
$exePaths = $env:ProgramFiles, ${env:ProgramFiles(x86)} -replace '$', '\Notepad++\notepad++.exe'
# See which one, if any, exists, using Get-Command.
$exeToUse = Get-Command -ErrorAction Ignore $exePaths | Select-Object -First 1
# Fall back to Notepad.
if (-not $exeToUse) { $exeToUse = 'notepad.exe' }
# Invoke whatever editor was found with the optional file(s).
# Note that both Notepad++ and NotePad can be invoked directly
# without blocking subsequent commands, so there is no need for `Start-Process`,
# whose argument processing is buggy.
& $exeToUse $File
}
An array of potential executable paths is passed to Get-Command, which returns a command-info object for each actual executable found, if any.
-ErrorAction Ignore quietly ignores any errors.
Select-Object -First 1 extracts the first command-info object, if present, from the Get-Command output; this is necessary to guard against the (perhaps unlikely) case where the executable exists in both locations.
$exeToUse receives $null (effectively) if Get-Command produces no output, in which case Boolean expression -not $exeToUse evaluates to $true, causing the fallback to notepad.exe to take effect.
Both command names (strings) and command-info objects (instances of System.Management.Automation.CommandInfo or derived classes, as returned by Get-Command) can be executed via &, the call operator.
I have written a power shell script to get the PATH value from regedit. (The reason I have to read it from regedit is that myscript will detect the PATH value every time after it installs something, the environment PATH value won't be reloaded until it restarts, so I need to get the value from regedit, parse it and do some operation).
This is the original script,
function getEnv($type){
$retval = (Get-ItemProperty -Path "Registry::HKEY_CURRENT_USER\Environment" -Name $type -ErrorAction SilentlyContinue)
if (($retval -eq $null) -or ($retval.Length -eq 0))
{
return ""
}
else
{
return $retval.$type
}
}
getEnv $args[0]
The issue is that I got nothing in the console, even if I added some "Write-host" message to the script, looks like the whole script is not loaded, but it works fine if I call the script in the powershell interactive console.
Ansgar Wiechers has given some advice, thanks very much for your help, but it still doesn't output anything.
Ansgar Wiechers & Mathias, thanks a lot for your help, I still could not get it work. I doubt it is caused by other setting, but I haven't got a thread for it.
If you need a script to just echo the current value of a given user environment variable simply expanding the respective value should work just fine:
Get-ItemProperty -Path HKCU:\Environment -Name $args[0] -EA SilentlyContinue |
select -Expand $args[0]
That will echo either the value of the given environment variable, nothing if the variable doesn't exist, or a list of all properties of HKCU:\Environment when no argument was passed to the script.
Demonstration:
C:\>echo %COMSPEC%
C:\Windows\system32\cmd.exe
C:\>powershell .\getenv.ps1 foo
C:\>powershell .\getenv.ps1 PATH
C:\bin;C:\apps\ant\bin;C:\Python33\bin
C:\>powershell .\getenv.ps1
DIRCMD : /o:gn
PATH : C:\bin;C:\apps\ant\bin;C:\Python33\bin
TEMP : C:\Users\foo\AppData\Local\Temp
TMP : C:\Users\foo\AppData\Local\Temp
PSPath : Microsoft.PowerShell.Core\Registry::HKEY_CURRENT_USER\Environment
PSParentPath : Microsoft.PowerShell.Core\Registry::HKEY_CURRENT_USER
PSChildName : Environment
PSDrive : HKCU
PSProvider : Microsoft.PowerShell.Core\Registry
If you want to suppress the property list when no arguments were passed to the script, add a check like this:
if ($args[0]) {
Get-ItemProperty -Path HKCU:\Environment -Name $args[0] -EA SilentlyContinue |
select -Expand $args[0]
}
I'm interested in adding a property to my files under a certain scope that contains their current locations in my file system, in order to track file movement. I would think that this could be done with New-ItemProperty, with a command similar to the following:
Get-ChildItem -recurse | foreach { New-ItemProperty -Path $.FullName -Name "OriginalLocation" -PropertyType string -Value $.FullName }
However, when I try this, I'm spammed with the following error:
New-ItemProperty : Cannot use interface. The IDynamicPropertyCmdletProvider interface is not implemented by this provider.
After some searching, it appears that New-ItemProperty is all but useless except for working with the registry. Fine. Windows has myriad other file properties I should be able to hijack in order to get this done. "Label" and "Tags" come to mind. So let's try setting those via Set-ItemProperty instead.
Set-ItemProperty : Property System.String Label=D:\test\file.txt does not exist.
It appears I need to create these properties after all. Is this a shortcoming of New-ItemProperty? Maybe setting properties such as this on arbitrary items is some WMI thing I don't know about?
Here is my solution using the redirections ('<' & '>') that allow to manipulate alternate data stream in CMD.EXE. It works in Powershell without any extentions
# AlternateDataStream.ps1
$scriptBlockSetStream = {cmd /C `"echo $($Args[0])`>$($Args[1]):$($Args[2])`"}
$scriptBlockGetStream = {cmd /C `"more `<$($Args[0]):$($Args[1])`"}
$streamName = "NativeFilePath"
$File = "C:\Temp\ADSTest\toto.txt"
$streamContent = Split-Path -Path $File -Parent
# Set the data stream
Invoke-Command -ScriptBlock $scriptBlockSetStream -ArgumentList $streamContent,$File,$streamName
# Get the Data Stream
$res = Invoke-Command -ScriptBlock $scriptBlockGetStream -ArgumentList $File,$streamName
$res
Another option might be to use alternate data streams to store your path. If you are running PowerShell 3.0, you can manipulate them quite easily. Based on the first article, you would have something resembling:
"echo test" | out-file c:\powershell\test.ps1
$fs = new NTFS.FileStreams('c:\powershell\test.ps1')
$fs.add('OriginalPath')
$stream = $fs.Item('OriginalPath').open()
$sw = [System.IO.streamwriter]$stream
$sw.writeline('<path>')
$sw.close()
$stream.close()