If you’re reading this series, I expect you have some experience with writing scripts already. As such, I think we can dispense with the typical “Hello world” examples and jump right into building something you might actually use. The goal in learning a new coding language like PowerShell should be the ability to solve your own problems with it, or at least know enough to be dangerous.
To that end, let’s build an installer.
Rest assured, I don’t expect you to rewrite Installomator in PowerShell, at least not overnight. Nobody builds a house the first time they pick up a hammer. By the end of this article, though, you should have a few new tools to add to your utility belt. And maybe a basic birdhouse.
If you faithfully followed the instructions in part 1 of this series you should already have PowerShell Core installed on your Mac. There are many ways to install and keep PowerShell updated: it has its own built-in update feature; you can update it through Homebrew; or you could clone the git repository and build your own from source. That last one is for those playing on Extreme Mode.
So, while the task of keeping PowerShell updated is a solved problem, it still serves our purpose as a decent example to build our first project around. It receives regular updates, exists as a standard PKG installer, and is consistently available from a single canonical source. If nothing else, it’ll be easy to reinstall if we foul something up.
To install an app from a PKG, we typically need to follow a simple recipe:
That’s pretty basic stuff. Let’s ramp up the difficulty a little beyond “Hello world”, shall we? If we’re going to the trouble of downloading and installing something, shouldn’t we check to see if it’s already installed first? And if it is, if the version we’re downloading is actually newer than what we have?
Sure, we already know PowerShell is installed. Just ignore the chicken-and-egg issues for the time being, and bear with me. To check the current installed version number, in a Terminal window, run
% pwsh —version
PowerShell 7.3.2
Which is fine if you’re checking the version of pwsh from bash. We’re meant to be running PowerShell already. Time to fire up “pwsh” in Terminal.
Like bash and other shells, there are built-in environment variables in PowerShell that will be persistent between sessions. To see a list, you can dig into the documentation, or just type a $ at a pwsh prompt, and press tab twice to see the autocomplete suggestions. The version number, among other useful information, is stored in “$PSVersionTable”. As mentioned before, there’s no need to use “echo” – or the equivalent “Write-Output” – to see the value of a variable.
PS > $PSVersionTable
Name Value
---- -----
PSVersion 7.3.2
PSEdition Core
GitCommitId 7.3.2
OS Darwin 22.2.0 Darwin Kernel Version 22.2.0: Fri Nov 11 02:03:51 PST 2022; root:xnu-8792.61.2~4/RELEASE_ARM64_T6000
Platform Unix
PSCompatibleVersions {1.0, 2.0, 3.0, 4.0…}
PSRemotingProtocolVersion 2.3
SerializationVersion 1.1.0.1
WSManStackVersion 3.0
Delightful, but perhaps a bit too much information than is strictly necessary. As with nearly everything in PowerShell, “$PSVersionTable” is an object with multiple member items. To drill down to the numerical version number in the format “major.minor.patch”, you’ll want “$PSVersionTable.PSVersion”
Major Minor Patch PreReleaseLabel BuildLabel
----- ----- ----- --------------- ----------
7 3 2
Again, helpful, and nicely formatted. But what if I just want the string “7.3.2” like a reasonable person? We’ll need to convert that array to a string with the function “.ToString()” by appending that to the end of the object in question:
PS > $PSVersionTable.PSVersion.ToString()
7.3.2
It’s a bit roundabout, but we finally know what version of PowerShell is installed and running. Now we need to see what version is available from Microsoft.
Poking around at the Github repo for PowerShell there’s a link for the latest release which, at the time of writing, is 7.3.3. In the immortal words of Nigel Tufnel, “It’s one higher.” It’s a good thing I’m writing a script to update that, eh?
In the bash world, I would normally use “curl” to download files or make web requests. But to invoke a web request in PowerShell, the command is the appropriately verbose “Invoke-WebRequest”. Clearly the original designers of PowerShell did not expect its users to be such lazy typists as their Unix forebears.
“Invoke-WebRequest” works much like “curl” in that it takes a URL and a variety of optional parameters, and returns the fetched results. As with nearly all data handled in PowerShell, the result is a structured object, rather than a blob of ASCII. Fetch the release tagged “latest” from GitHub thus:
PS > Invoke-WebRequest "https://github.com/PowerShell/PowerShell/releases/latest"
Okay, so it’s not just a blob of ASCII, it’s several blobs of ASCII. Conveniently sliced into objects that contain the HTTP status code (200 if the URL is valid, 404 if you mistyped it), all the links, images, and form fields on the page, as well as the raw HTML content. There’s a lot to unpack, and while it might be entertaining to dive into this and parse out the relevant bits and pieces, there’s a better way. GitHub, thankfully, provides an API.
Just as I would normally use “curl” to call an API endpoint, so “Invoke-WebRequest” will do the job as well. A slight modification to the URL should be enough to retrieve all the same information, but in a properly structured format.
PS > Invoke-WebRequest "https://api.github.com/repos/PowerShell/PowerShell/releases/latest"
StatusCode : 200
StatusDescription : OK
Content : {"url":"https://api.github.com/repos/PowerShell/PowerShell/releases/93516998","assets_url":"https://api.github.com/repos/PowerShell/PowerShell/releases/93516998/assets","upload_url":"https://uploads.
[...]
Images : {}
InputFields : {}
Links : {}
RawContentLength : 36267
RelationLink : {}
It’s still a lot of text to scroll through, but if you look carefully, you’ll see that the “content” item isn’t HTML any more. In a delightful turn, the API has returned to us all the relevant data, but in JSON format. While PowerShell does have the facilities to render meaning from HTML and other markup formats, it’s particularly adept at parsing JSON. It’s almost as if the format was designed specifically for the interchange of object data in key/value pairs and arrays.
To make it easier to slice and dice, I’ll save the entire result in a variable “$APIResult”:
PS > $APIResult = Invoke-WebRequest "https://api.github.com/repos/PowerShell/PowerShell/releases/latest"
Now, we can dive into the contents, and convert from JSON into fun-sized morsels with the command “ConvertFrom-Json”. Unlike “ToString()”, the conversion is a command, not a method. And, just like in bash, to string commands together, we use a pipe. The results of this command should be nicely organized into key/value pairs.
PS > $APIResult.Content | ConvertFrom-Json
It would be super convenient if there were, say, a key called “Version” that matches up with the reported version number in “$PSVersionTable”, but no such luck. There is, however, a “tag_name” with a value “v7.3.3” so that’s what we’ll have to work with.
We could dump the converted data into another variable like “$ResultContents” to make for slightly more legible code, and there would be no shame in that. However, then I wouldn’t have the opportunity to demonstrate the alternative to bash’s sub-shells for nesting commands. Since the result of “$APIResult.Content | ConvertFrom-Json” is an object, we can address a child of that resulting object by wrapping it in parentheses.
PS > ($APIResult.Content | ConvertFrom-Json).tag_name
v7.3.3
In fact, parentheses can nest as deep as you like, though it does start to get difficult to read after a while.
PS > ((Invoke-WebRequest "https://api.github.com/repos/PowerShell/PowerShell/releases/latest").Content | ConvertFrom-Json).tag_name
v7.3.3
Under more normal circumstances, I’d go to the trouble of checking the major, minor, and patch versions to ensure that what I have installed is not somehow newer than the version available on GitHub. That’s a rabbit hole we can traverse in a later installment. Since GitHub is the canonical source, and the API is telling me that 7.3.3 is the “latest”, if I have something installed that’s different, it’s safe to assume my installed version is out of date. And I know what they say about assuming.
Regardless, the tag returned from the API containing the version number consistently begins with “v” so we could do some simple string manipulation to get rid of that, and be left with just the numbers for comparison. Since we’re just going to compare it with the PSVersion from before, we can prepend a “v” to that and save a step. Concatenation with strings is accomplished with “+”.
PS > $InstalledVersion = "v" + $PSVersionTable.PSVersion
PS > $InstalledVersion
v7.3.2
Note: The spaces around “=“ and “+” are not strictly necessary, but merely there to make the code slightly more readable.
Now that we have two values to compare, it’s time to dive into PowerShell’s conditionals and comparison operators. It’s also time to fire up Visual Studio Code and write our commands into a script. Refer to Part 1 for some basic setup instructions. To switch modes and activate the PowerShell console, click on the PowerShell icon in the left sidebar (screenshot 1). Command+N will create a new text file. If it’s all working properly, your screen should look something like this. (screen shot 2)
Screenshot 1 |
Screenshot 2 |
True to form, as with bash scripts, your first PowerShell script should begin with a “shebang” to invoke the “pwsh” interpreter. You can confirm the install location in bash with “which pwsh”:
#!/usr/local/bin/pwsh
The syntax for an “if…then” conditional is pretty standard stuff, if you’ve done much coding. Which is to say, it uses braces instead of “then” and terminating with “fi”:
if (condition) {
# Actions to perform if the condition is true
} else {
# Actions to perform if the condition is false
}
The comparison operator to use inside the parentheses needs to return true if two version strings are equal. So the operator is “-eq”. Or you could reverse the logic and return true if they are not equal, and use “-ne”. For numerical values, there’s also greater-than and less-than (“-gt” and “-lt”), and so on. But for strings, equal or not will suffice. Putting all that together, the beginnings of an actual script will look something like this:
#!/usr/local/bin/pwsh
$AvailableVersion = ((Invoke-WebRequest "https://api.github.com/repos/PowerShell/PowerShell/releases/latest").Content | ConvertFrom-Json).tag_name
$InstalledVersion = "v" + $PSVersionTable.PSVersion
if ($AvailableVersion -eq $InstalledVersion) {
Write-Output "Installed version $InstalledVersion is the latest."
} else {
Write-Output "Available version $AvailableVersion is newer than installed version."
# Download and install steps
# [...]
}
Slightly optimizing as we go, we can actually dispense with the “else {…}” lines, and replace them with a simple “Exit” command – an alias to "Exit-PSHostProcess" – since the script won’t proceed past that point if the installed and available versions are equal.
if ($AvailableVersion -eq $InstalledVersion) {
Write-Output "Installed version $InstalledVersion is the latest."
Exit # Execution will stop here if installed = latest
}
Write-Output "Available version $AvailableVersion is newer than installed version."
# All that's left to do is everything.
We’ve done the hard part, which is determining whether or not to go to the trouble of downloading and installing the PKG file. If it turns out there is a newer version available, the next step is to determine the URL of the PKG file to download. Digging into the GitHub API results a bit, you can see that while it does return a list of assets – installers for various operating systems – those asset objects have no specific key for the platform or file format. If they did, we could search through them for one that matches our environment, and grab the “browser_download_url” value to link to. Luckily, however, the PowerShell maintainers have named their files fairly consistently.
The PKG installer file names all look something like this: “powershell-7.3.3-osx-arm64.pkg”. Breaking it down, the naming convention seems to be
Once again, a reminder that making assumptions can be dangerous. Sure, we will have to update the name format in our script if they decide to change the naming convention. However, considering macOS hasn’t been “OS X” since 2016, I doubt that will happen soon.
It should be easy enough to build up a URL string from the component pieces. The only slightly complicating factor is that the installers aren’t universal: there are separate downloads for Intel CPUs (x64) and for Apple Silicon (arm64).
There are a few ways to programmatically determine if the Mac your script is running on has Intel or Apple Silicon under the hood. Instead of a detour into the land of hardware registers, instead take a moment to scroll up a few paragraphs to the discussion of “$PSVersionTable”. You should see one of the items in that table, “OS”, includes a string at the end that looks intriguingly like the name of a CPU architecture.
Darwin 22.2.0 Darwin Kernel Version 22.2.0: Fri Nov 11 02:03:51 PST 2022; root:xnu-8792.61.2~4/RELEASE_ARM64_T6000
On an Intel Mac, that same parameter would end with “X86_64”.
Well, that’s handy. Also handy is PowerShell’s “Contains()” method, which can be brought to bear on any output in string format. Remember how to get the “OS” item from inside “$PSVersionTable”? Append to that “.Contains()” with the substring to search for inside the parentheses. Like so:
PS > $PSVersionTable.OS.Contains("ARM64")
True
Not to be confused with the “-Contains” method (note the dash) for searching within collections of objects, the string method “.Contains()” (dot) is a basic substring search. It’s case-sensitive, and doesn’t allow wildcards or other regular expression tokens. If the string contains the provided substring, the method will return the boolean “True”, otherwise “False”. For example:
PS > $PSVersionTable.OS.Contains("arm")
False
PS > $PSVersionTable.OS.Contains("ARM*")
False
Back again to our script, and you should be able to see where we’re going – another conditional “if” block.
if ($PSVersionTable.OS.Contains("ARM64")) {
$Architecture = "arm64" # CPU is Apple Silicon
} else {
$Architecture = "x64" # CPU is Intel
}
Now, we have all the pieces to build a download URL, and we’re finally ready to grab the PKG.
As we’ve already covered, concatenating strings is handled with “+” and variable substitution is achieved by simply dropping the variable into our code, complete with “$”. So it’s a simple matter to build up a filename from a mix of quoted strings and named variables to match the naming convention the PowerShell devs have established.
$PKGFileName = "powershell-" + $AvailableVersion + "-osx-" + $Architecture + ".pkg"
Hmm. Did you happen to catch my mistake?
PS > $PKGFileName
powershell-v7.3.3-osx-arm64.pkg
The “v” in “v7.3.3” is not part of the actual filename. Lazy me from a few paragraphs ago didn’t bother to do the necessary string edits, and it’s come back to bite me. I did not see that coming. (Okay, I did. But I wanted to cover string addition before doing subtraction.)
A very handy string method, right up there with “.Contains()” is “.Replace()”. As parameters, it takes a substring to search for and a second string to replace it with. It will replace all occurrences of the first string with the second. That’s it. Again, like “.Contains()”, it’s case sensitive and literal, so no wildcards. If you need more finesse, regular expressions, or flexibility, use “-Replace” instead (again, note the dash). We’ll cover the differences, plus much more, in later installments in this series. For now, we need to remove “v” and replace it with nothing, so an empty string, “”.
PS > $PKGFileName = "powershell-" + $AvailableVersion.Replace("v","") + "-osx-" + $Architecture + ".pkg" ; $PKGFileName
powershell-7.3.3-osx-arm64.pkg
Notice that, like bash, you can put multiple commands on a single line, separated by “;”.
Back to our script, and you should know how to append “$PKGFilename” to the first part of the URL, “https://github.com/PowerShell/PowerShell/releases/download/“, followed by the version number (this time, with the “v”) and another “/“, as well as how to initiate a download of the resulting complete URL. If you’ve been paying attention, that is.
$DownloadURL = "https://github.com/PowerShell/PowerShell/releases/download/" + $AvailableVersion + "/" + $PKGFileName
In our previous invocation of a web request, the result was diverted into a named object. This time, though, we’ll need it to fetch and save a file. To do that, we need to specify an “OutFile” to write to. Regardless of what the remote file’s name is, we have to specify the local file name. Which actually saves us some trouble. Instead of ending up with different local files based on the version number and CPU architecture, we can specify something like “powershell-latest.pkg”. For simplicity, I’ll save it in “/tmp”. If you only provide a name for “-OutFile” and not a full path, “Invoke-WebRequest” will write the result to the current working directory.
Invoke-WebRequest $DownloadURL -OutFile "/tmp/powershell-latest.pkg"
From this point, installation of the PKG should seem familiar, if you’ve ever written an installer script in bash before. There’s nothing different about PowerShell’s method of calling Apple’s installer binary at “/usr/sbin/installer” and its two required parameters of the PKG and target.
/usr/sbin/installer -pkg "/tmp/powershell-latest.pkg" -target /
Which leads to a couple of questions.
First: Doesn’t the installer need to be run with admin privileges? Answer: Yes. Yes it does. Hold that thought.
Second: How do we even run this script, regardless of admin privileges? If you’re writing all this in VSCode, there is a button near the upper right corner of the interface to run your script. (Screenshot 3) Next to it is another particularly helpful button, which will run the current selection. That will come in handy for debugging and testing later.
Screenshot 3
Before running anything, you should save your work. Customarily, PowerShell scripts are named with a PS1 extension, but I’ve seen (and used) a handful of variations. As long as you know what your file is named, and the shebang line at the top is properly formatted, there’s nothing special about the name or extension. For this example, let’s name it “powershell-update.ps1”.
Once you’ve done that, you can press the play button to run the script within VSCode, and follow the progress in the lower pane of the interface. There are four tabs there, each useful in its own way for troubleshooting a buggy script, but I’d start with the one labeled “Terminal”. This will show you the same output, notifications, or errors that you would see if you opened Terminal.app, and ran your script directly with “pwsh”.
As for running the installation step of the script as an admin, you have two choices. Option one: you can escalate privileges with “sudo” for just the commands that require it inside the script. The downside to this approach is that your script will pause each time it’s run and wait for the admin password to proceed.
sudo /usr/sbin/installer -pkg /tmp/powershell-latest.pkg -target /
Alternately, you can run the whole script as root in the Terminal with “sudo pwsh”:
sudo pwsh powershell-update.ps1
The requisite caveats apply, of course: You take full responsibility for whatever you break while using “sudo”.
Note: I kept running into strange behavior while testing this script inside VSCode. In particular, the installer binary simply refused to work, even when run as root. I finally tracked down the trouble to the fact that my version of VSCode was an Intel binary, running under Rosetta emulation on my M2 Mac. Within VSCode, “$PSVersionTable.OS” still read as running on an ARM Mac, but the “arch” command reported “i386”. I was invoking “installer” in that environment, pointed at an ARM PKG. It kindly demurred when asked to install something that appeared my Mac wouldn’t support. Downloading and installing a more recent universal binary of VSCode fixed the problem.
The full script so far:
#!/usr/local/bin/pwsh
$AvailableVersion = ((Invoke-WebRequest "https://api.github.com/repos/PowerShell/PowerShell/releases/latest").Content | ConvertFrom-Json).tag_name
$InstalledVersion = "v" + $PSVersionTable.PSVersion
if ($AvailableVersion -eq $InstalledVersion) {
Write-Output "Installed version $InstalledVersion is the latest."
Exit # Nothing to do.
}
Write-Output "Available version $AvailableVersion is newer than installed version $InstalledVersion."
# Build the download URL
# eg: https://github.com/PowerShell/PowerShell/releases/download/v7.3.3/powershell-7.3.3-osx-arm64.pkg
# Filename depends on the CPU architecture.
if ($PSVersionTable.OS.Contains("ARM64")) {
$Architecture = "arm64" # CPU is Apple Silicon
} else {
$Architecture = "x64" # CPU is Intel
}
# Build the filename - version number without "v"
$PKGFileName = "powershell-" + $AvailableVersion.Replace("v","") + "-osx-" + $Architecture + ".pkg"
# Complete the URL/path - including version number *with* "v"
$DownloadURL = "https://github.com/PowerShell/PowerShell/releases/download/" + $AvailableVersion + "/" + $PKGFileName
# Download the PKG
Write-Output "Downloading $DownloadURL"
Invoke-WebRequest $DownloadURL -OutFile "/tmp/powershell-latest.pkg"
# Install the PKG
sudo /usr/sbin/installer -pkg /tmp/powershell-latest.pkg -target /
Now we’re in the home stretch. Hopefully, you’re able to test your script and… Oh wait. You probably already have the latest version of PowerShell, don’t you. All you’ll get when you run this is the message "Installed version 7.3.3 is the latest." Oh, bother.
Well, to properly test that the script downloads and installs the correct PKG from GitHub, even over top of the current version, temporarily comment out “Exit” in line 7. Comments, as you hopefully have gathered by now, are preceded by an octothorpe “#” and ignored when the script is run.
There’s plenty of room for improvement in this script. Not to worry, though, as that will be covered in more detail in Part 4. I anticipate many of your questions will be answered, such as
Actually, that last one is pretty straightforward. Let’s do that now.
After any process finishes, it will return a numerical exit code to report to the shell how everything went. An exit code of zero means success, in that there were zero errors. Anything not equal to zero means the process probably failed in its appointed task. The exit code of the most recent process is accessible in PowerShell via an environment variable “$LASTEXITCODE” – why is that in all caps instead of the usual mixed case with the occasional dash? You’ll have to ask the author. Of PowerShell. Not me. In any case, we can also choose how our own script reports success or failure, by providing a number from 0 to 255 after “Exit”. To indicate everything went smoothly, “Exit 0”. As long as you provide users with some way to find and fix problems they encounter, any error can “Exit 1”, but write a different message to the console. It’s up to you to set different nonzero status codes for the different types of errors you anticipate – or not.
Putting all that together to round out our inaugural script, plus a few more lines for housekeeping, we finish thus:
# Success or Failure?
if ($LASTEXITCODE -ne 0) {
Write-Output "There was an error installing PowerShell. Check install.log for more information."
Exit 1 # Something went wrong!
} else {
Write-Output "Successfully installed PowerShell version $AvailableVersion"
# On success, clean up the download
Remove-Item "/tmp/powershell-latest.pkg"
}
The complete script so far, along with anything else written as part of this ongoing series, will appear on my GitHub repository. While you wait for the next installment, work on your own improvements to this script and let me know what you come up with. Keep your installation of PowerShell up to date, and be sure to drink your Ovaltine.
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.
New to the site? Take a look at these additional resources:
Ready to join us? You can register here.