Patch My PC / Blog

How to write a PowerShell script

by | Aug 20, 2024 | Blog

In this Knowledge Base (KB) article, we will guide you through the process of writing a PowerShell script, specifically focusing on creating a function. This function will help you automate a repeatable task, saving you time and effort.

But first, some basics.

What is Windows PowerShell

Windows PowerShell is a task automation framework and a command-line shell developed by Microsoft.

Windows PowerShell is built on top of the .NET Framework and supports cmdlets (pronounced “command-lets”), which are small, focused scripts or tools that perform a specific task. These cmdlets can be combined and reused to create more complex PowerShell scripts and automation workflows.

What is a PowerShell script?

PowerShell scripts are files that contain a series of PowerShell commands intended for scenarios like the automation of administrative tasks, configuration management and data processing.

Tools used

While tool selection is largely a matter of personal preference, I prefer using the following tools for writing, testing, and running scripts:

Components of a PowerShell script

Cmdlets

PowerShell cmdlets, or commandlets, are specialized commands created for system administration and automation tasks. They tend to follow a naming convention of verb-noun (e.g., Get-Process or New-Item). Cmdlets perform very specific operations, and their output is generally structured as objects, allowing them to be easily combined using pipelines to chain multiple commands together.

Examples include Get-Process, Set-Location, Get-Help, Start-Service, Remove-Item and Get-WmiObject

# Example PowerShell script using Get-Process
# Retrieve all running processes
$processes = Get-Process
# Filter processes that are consuming more than 100 MB of memory
$highMemoryProcesses = $processes | Where-Object { $_.WorkingSet -gt 100MB }
# Export the filtered list of processes to a CSV file
$highMemoryProcesses | Export-Csv -Path "C:TempHighMemoryProcesses.csv" -NoTypeInformation
# Output the count of high memory processes to the console
$processCount = $highMemoryProcesses.Count
Write-Output "Number of processes consuming more than 100 MB of memory: $processCount"
# Output message indicating the location of the exported CSV file
Write-Output "Filtered process list exported to C:TempHighMemoryProcesses.csv"

Admins will generally use Cmdlets to perform ad hoc tasks, such as managing Active Directory users, Exchange mailboxes, Azure resources, Endpoint devices, etc.

As an example, here is the Cmdlet Get-ADUser in use.

# Import the Active Directory module if not already imported
Import-Module ActiveDirectory
 
# Get a specific user by their username
$user = Get-ADUser -Identity "jdoe"
 
# Display the user details
Write-Output $user
 
DistinguishedName : CN=John Doe,OU=Users,DC=example,DC=com
Enabled           : True
GivenName         : John
Name              : John Doe
ObjectClass       : user
ObjectGUID        : 12345678-90ab-cdef-1234-567890abcdef
SamAccountName    : jdoe
SID               : S-1-5-21-1234567890-123456789-1234567890-1234
Surname           : Doe
UserPrincipalName : [email protected]
 

You can find more examples of common PowerShell uses here

Pipelines

We can take Cmdlets and supercharge them by combining them with other Cmdlets through the use of the Pipeline.

A pipeline is basically a series of commands connected together by the pipeline operator ( | ). Each time you add a pipeline, the results of the preceding command are forwarded to the next.

As an example, let’s get all processes using Get-Process, assign the results to $highMemoryProcesses, pipe that to Where-Object to find processes consuming over 100MB of memory, sort by WorkingSet, and take the top 5 highest results.

$highMemoryProcesses = Get-Process | Where-Object { $_.WorkingSet -gt 100MB }
$highMemoryProcesses | Sort-Object -Property WorkingSet -Descending | Select-Object -First 5
 
 NPM(K)    PM(M)      WS(M)     CPU(s)      Id  SI ProcessName
 ------    -----      -----     ------      --  -- -----------
      0     3.62     557.01       0.00    5004   0 Memory Compression
     53   726.05     533.55     145.42    9056   2 msedgewebview2
    195   374.01     507.14      14.14   39864   2 explorer
    262   396.18     487.10      19.59   39876   2 RemoteDesktopManager
    208   304.77     470.62      49.39   14872   2 msedge
 

As another example, we can use Get-ChildItem to get the contents of a directory and pipe the results directly to Sort-Object to sort by the file length property.

Get-ChildItem -Path "C:devHydrationKitWS2022" -File | Sort-Object -Property Length -Descending

    Directory: C:devHydrationKitWS2022

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a---          03/07/2024    11:12          31071 README.md
-a---          03/07/2024    11:12          15114 CustomizeHydrationKit.ps1
-a---          03/07/2024    11:12           7547 New-HydrationKitSetup.ps1
-a---          03/07/2024    11:12           4514 New-LabVMsForHyperV.ps1
-a---          03/07/2024    20:29           1857 Export-WindowsServer2022WIMfromISO.ps1
-a---          03/07/2024    11:29           1779 Export-Windows11EnterpriseWIMFromISO.ps1
-a---          03/07/2024    11:12           1096 LICENSE
-a---          03/07/2024    11:12            698 Update-MDTMediaCopyToHost.ps1

Variables:

Variables allow you to store all types of values, such as the output of a pipeline operation or data you want to use in other cmdlets, such as file paths or values.

There are a few different types of variables: user-defined, automatically defined, and preference variables.

User-defined variables

# Declaring a string variable
$message = "Hello, World!"
Write-Output $message  # Output: Hello, World!
 
# Declaring an array variable
$numbers = @(1, 2, 3, 4, 5)
Write-Output $numbers  # Output: 1 2 3 4 5
 
# Accessing array elements
Write-Output $numbers[0]  # Output: 1
Write-Output $numbers[2]  # Output: 3

# Declaring a hash table variable
$person = @{
    Name = "John Doe"
    Age = 30
    Country = "USA"
}
Write-Output $person  # Output: @{Name=John Doe; Age=30; Country=USA}

# $PSVersionTable: Contains information about the PowerShell version
Write-Output $PSVersionTable

# Declaring a global variable
$global:globalVariable = "I am global"
Write-Output $global:globalVariable  # Output: I am global

# Declaring a local variable
$local:localVariable = "I am local"
Write-Output $local:localVariable  # Output: I am local

# Arithmetic operations
$a = 10
$b = 20
$sum = $a + $b
Write-Output $sum  # Output: 30

# String concatenation
$firstName = "John"
$lastName = "Doe"
$fullName = $firstName + " " + $lastName
Write-Output $fullName  # Output: John Doe

# Using variables in strings
$greeting = "Hello, $firstName $lastName!"
Write-Output $greeting  # Output: Hello, John Doe!

Automatically defined variables
$PSVersionTable contains details about the PowerShell version and environment
$PSVersionTable

Name                           Value
----                           -----
PSVersion                      7.1.3
PSEdition                      Core
GitCommitId                    7.1.3
OS                             Microsoft Windows 10.0.19041
Platform                       Win32NT
PSCompatibleVersions           {1.0, 2.0, 3.0, 4.0...}
PSRemotingProtocolVersion      2.3
SerializationVersion           1.1.0.1
WSManStackVersion              3.0

$HOME contains the full path of the user’s home directory
$HOME
C:UsersUsername

$Profile contains the full path to the user’s profile script
$PROFILE
C:UsersUsernameDocumentsPowerShellMicrosoft.PowerShell_profile.ps1
 

Preference variables

As an example $ErrorActionPreference lets you instruct PowerShell what to do when there is a non-terminating error (an error that does not stop a cmdlet from executing)

Possible values:

  • Continue (default) – Displays the error and continues execution.

  • Stop – Stops execution.

  • SilentlyContinue – Suppresses the error message and continues execution.

  • Inquire – Asks for user input on how to proceed.

  • Ignore – Ignores the error completely (PowerShell 7+).

Flow Control

When you start to move away from using single cmdlets, one line scripts and combine everything together into a script, it may sound scarier than it actually is. Very simply, a script is just the same commands you’re already familiar with, bundled together and saved in a .PS1.

These controls include If, Else, ForEach, For, Do(including  While and Until), While, Break, Continue and Return.

# Example of an if statement
$number = 10
if ($number -gt 5) {
    Write-Output "The number is greater than 5."
} elseif ($number -eq 5) {
    Write-Output "The number is equal to 5."
} else {
    Write-Output "The number is less than 5."
}

# Using foreach loop
$array = 1..5
foreach ($item in $array) {
    if ($item -eq 3) {
        Write-Output "Skipping item $item using continue."
        continue
    }
    Write-Output "Processing item $item in foreach loop."
}

# Using while loop
$counter = 0
while ($counter -lt 5) {
    if ($counter -eq 3) {
        Write-Output "Breaking out of the while loop at counter $counter."
        break
    }
    Write-Output "Processing counter $counter in while loop."
    $counter++
}

# Using for loop
for ($i = 0; $i -lt $array.Length; $i++) {
    if ($i -eq 2) {
        Write-Output "Breaking out of the for loop at index $i."
        break
    }
    Write-Output "Processing index $i in for loop."
}

Functions

The last piece of our PowerShell script puzzle is functions. With a function, you can take one-line cmdlets you regularly use, modify them, and make them more easily reusable. Writing functions lets you reuse your code across multiple scripts instead of rewriting the same thing repeatedly or even save them as modules, which enables you to call them in PowerShell without referencing them directly by their full path.

Two critical things for a function are how to name it and its parameters, so let’s look at those things!

Naming

Naming Functions is important! They should follow the PascalCase style format and use an approved verb followed by a singular noun, e.g., Get-Version, Convert-Image, or Test-Binding. This is especially helpful for other people who may come across your function; a well-named function can immediately tell someone exactly what it does.

You can find a full list of approved verbs by using Get-Verb.

Parameters

Parameters let you expand your function, making it more fluid than static.

If we look at another simple Hello World example, This function will write out “Hello, World!” each time it is called.

function Say-Hello {
    Write-Output "Hello, World!"
}

Say-Hello
Hello, World!

But if we expand the function to include a string parameter, we can have it do more! By adding a Name as a parameter, we can instruct the function to add a name to the output.  

function Say-Hello {
    param (
    [string]$Name
    )

    Write-Output "Hello, $Name!"
}

# To call the function with a parameter
Say-Hello -Name "Alice"
Hello, Alice!

Lastly, if we combine our parameter with an if statement, we can expand the functionality further to change the output depending on whether or not the parameter was used.

function Say-Hello {
    param (
        [string]$Name
    )

    if ($name){
        Write-Output "Hello, $Name!"
    } else {
        Write-Output "Hello, World!"
    }
}

You can also easily transform your simple function into an advanced function with CmdletBiding, SupportShouldProcess, Validation, Pipeline input, and Error Handling, but we’ll look at those in some examples later.

Formatting

I won’t reinvent the wheel here, rules and recommendations for formatting PowerShell scripts are fairly standard and widely accepted, So lets take some advise from Don Jones, Matt Penny, Carlos Perez, Joel Bennett and the PowerShell Community.

Let’s look at what I believe to be the three most essential things in formatting.

Consistency

While the rules may differ between projects, the goal is to format code consistently. Always aim to stick to the style guidelines laid out in a project, or pick a style for your own projects and stick with it.

Capitalization  

PowerShell isn’t case-sensitive, but following capitalization conventions makes code easier to read. Microsoft created these conventions for .NET, and as PowerShell is based on .NET, they all. You can read more about these conventions here – Capitalization Conventions – Framework Design Guidelines | Microsoft Learn, but tl;dr

  • lowercase – all lowercase, no word separation

    • Keywords like foreach are written in lower case, as well as operators like -eq and -match

  • UPPERCASE – all capitals, no word separation

    • Keywords in comment-based help are written in upper case for readability

  • PascalCase – capitalize the first letter of each word

    • PascalCase is used for all public identifiers.

  • camelCase – capitalize the first letter of each word except the first.

    • camelCase is really a matter of personal preference.

Indentation

Contrary to the style guide, Tabs not spaces. Going back to consistency, if you have a preference then stick with it or stick with the style of the project.

Other things to think about

Our first PowerShell script

Before you can start writing PowerShell scripts, you really need to know what you want to do or what problem you are trying to solve. Let’s take a problem I encountered recently.

Converting images

Problem

I encountered an issue where downloading images from the internet that had a file extension of .PNG were actually .JPG, and while that may not seem like that big of an issue, it can cause problems with app packaging for Intune.

Solution

Go to https://cloudconvert.com/, right? No. Do it in PowerShell!

If we break it down, I want to

  1. Take in an image path

  2. Take in an optional output path

  3. Take in a required output image type (jpg, jpeg, png, gif, bmp or tiff)

  4. Import the image to a variable

  5. Check if an output path has been specified

    1. If not, generate one

  6. Convert the image to the selected format

  7. Save it to the output path with the new file type

  8. Write out success or failure to the console

Getting started

The first thing we want to do is take in an image path, an optional output path, and a required output image type. Let’s set up our CmdletBinding and parameters.

PowerShell CmdletBinding & Parameters to convert an image
  1. Firstly, we’ve added a CmdletBinding to add Validation and Error Handling.

  2. Now, let’s add a mandatory parameter called $InputImagePath to specify which image to pull in.

  3. Then, we add a mandatory parameter called $OutputFormat to let us specify which format we want to convert our image to. We’ve also added validation to ensure that the output format is an accepted format.

  4. Lastly, we’ve added a parameter called $OutputImagePath so that we can choose to specify an output path, but we don’t have to as it isn’t mandatory.

Now that we have everything we need to take in, Lets figure out processing.

Validation

Before we go any further, we want to ensure that the $InputImagePath is provided.

Test-Path

We can test the $InputImagePath using Test-Path, and if it doesn’t exist, we’ll throw an error message. Throw will stop the execution of the script unless there is a try/catch block, which we’ll come to shortly.

Working with directories

Now, we need to do a little more validation to ensure that our output directory exists so that we can save our converted image later.

Working with directories

We’ll do this in 2 steps

  1. Check if our $OutputImagePath variable was passed

    1. If it wasn’t, set the $OutputImagePath to the same directory as $InputImagePath.

    2. If $OutputImagePath was specified, validate that it exists and is a valid path.

  2. If it is not a valid path, throw an error

    1. If it is a valid path, set it as our $OutputImagePath

Now that we have our $OutputImagePath set, we can start to work with our images.

Working with images

Now that we’ve added some validation, we now need to start working with images. We can do this by using Add-Type to load in the System.Drawing assembly which gives our script access to classes and methods used to manipulate images.

Once System.Drawingis imported, we can load our image from $InputImagePath, using the FromFile() method, into PowerShell to start manipulating it.

Add assembly and load image

With our image stored, we now need to set the output format, which we’ll take from the required $OutputFormat parameter.

Output format switch

I’ve done this using a switch so we can see what is happening with the format variable, but we can shorten this to one line, as shown below.

Output format 1 line

Here, I have condensed this to one line that takes $OutputFormat and sets it as our output file format.

We can save our image once we have our $OutputImagePath and format!

$image.save()

Does it work?

Here, we can see two things.

  1. There is only one image file, called ThisIsA.png.

  2. There are a several ways to run PowerShell scripts, but we’ll dot source this one so that we can call our function in the scope of my terminal.

Before running our Powershell script to covnert the image

When we run our function, we pass in the required $InputImagePath and $OutputFormat parameters, and we can now see that we have a second file called ThisIsA.jpeg

After running our Powershell script to covnert the image

That’s us all done, right? Well, it could be, but let’s take it just one step further and look at error handling.

Error handling

While it may not seem like a big deal for a simple small function, but it multiple small functions in a large script with no error handling can make troubleshooting difficult.

try {}
Working with PowerShell and try and catch

I’ve added a try/catch block around the Process section of my function, which lets me catch and  handle different types of error. You don’t necessarily need to be overly granular with your catches, but if you are, you can perform different actions depending on the type of exception. In my scenario, I’m just going to write out a different error depending on the exception thrown.

A try/catch is not complete without a finally. While this won’t always be required, it’s generally good practice to release the resources held by the object you were working with, in this case, our $image.

finally {}

So, after our catches, I’ve added a check to see if our image variable is null. If it isn’t, we’ll dispose of it to release the resources it was using to store our image.

The full script

I’ve also added a comment-based help section to the top of our script, including a description, parameter details, examples, and notes.

Note: You’ll notice that I’ve used a mixture of PascalCase and camelCase in my script. I like to do this to differentiate Parameters (PascaleCase) from variables (camelCase), but this is purely a personal preference.

<#
    .SYNOPSIS
    Converts the format of an image file to a specified format.

    .DESCRIPTION
    The Convert-ImageFormat function converts an image from one format to another. It requires the path to the input image and the desired output format. Optionally, a path for the output image can be specified; otherwise, the output will be saved in the same location as the input image with the new format.

    .PARAMETER InputImagePath
    The path to the input image file that needs to be converted.

    .PARAMETER OutputFormat
    The desired format for the output image. Valid options are "jpg", "jpeg", "png", "gif", "bmp", and "tiff".

    .PARAMETER OutputImagePath
    (Optional) The path where the converted image will be saved. If not specified, the image will be saved in the same location as the input file with the new format.

    .EXAMPLE
    Convert-ImageFormat -InputImagePath "C:pathtoinputimage.png" -OutputFormat "jpg"
    This command converts an image named "image.png" to a JPG format.

    .EXAMPLE
    Convert-ImageFormat -InputImagePath "C:pathtoinputimage.png" -OutputFormat "gif" -OutputImagePath "C:pathtooutputimage.gif"
    This command converts an image named "image.png" to a GIF format and saves it as "image.gif" in the specified path.

    .NOTES
    Author: Scott McAlliister
    Version: 1.0
#>
function Convert-ImageFormat {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [string]$InputImagePath,

        [Parameter(Mandatory=$true)]
        [ValidateSet("jpg", "jpeg", "png", "gif", "bmp", "tiff")]
        [string]$OutputFormat,

        [Parameter(Mandatory=$false)]
        [string]$OutputImagePath
    )

    try {
        if (-not (Test-Path -Path $InputImagePath -PathType Leaf)) {
            throw "Input image path does not exist: $InputImagePath"
        }

        if (-not $OutputImagePath) {
            $fileDirectory = [System.IO.Path]::GetDirectoryName($InputImagePath)
            $fileNameWithoutExtension = [System.IO.Path]::GetFileNameWithoutExtension($InputImagePath)
            $OutputImagePath = Join-Path -Path $fileDirectory -ChildPath "$fileNameWithoutExtension.$OutputFormat"
        } else {
            if (-not (Test-Path -Path $OutputImagePath -PathType Container)) {
                throw "Output image path is not a valid directory: $OutputImagePath"
            }
            $fileNameWithoutExtension = [System.IO.Path]::GetFileNameWithoutExtension($InputImagePath)
            $OutputImagePath = Join-Path -Path $OutputImagePath -ChildPath "$fileNameWithoutExtension.$OutputFormat"
        }

        Add-Type -AssemblyName System.Drawing
        $image = [System.Drawing.Image]::FromFile($InputImagePath)
        
        $format = [System.Drawing.Imaging.ImageFormat]::$OutputFormat

        $image.Save($OutputImagePath, $format)
        
    } catch [System.IO.FileNotFoundException], [System.IO.DirectoryNotFoundException] {
        Write-Error "File or directory not found: $_"
    } catch [System.ArgumentException] {
        Write-Error "An argument provided to a method was not valid: $_"
    } catch [System.Runtime.InteropServices.ExternalException] {
        Write-Error "An error occurred in the external GDI+ graphics interface: $_"
    } catch [System.OutOfMemoryException] {
        Write-Error "Not enough memory to continue the execution of the program: $_"
    } catch {
        Write-Error "An unexpected error occurred: $_"
    } finally {
        if ($image -ne $null) {
            $image.Dispose()
        }
    }
}

Finishing up

This article is in no way the definitive guide to writing PowerShell scripts. You can write your scripts however you want, as long as they are readable, consistent, and formatted.

While this blog focussed specifically on a PowerShell script that contained a single function, a PowerShell script can be made of up many functions, single line commands or even call other scripts!

I recommend checking out a few resources if you’re interested in improving your PowerShell.