Community Pick: Many members of our community have endorsed this article.
Editor's Choice: This article has been selected by our editors as an exceptional contribution.

Powershell: Update Scripts Across Multiple Computers

Dale HarrisSenior Customer Success Engineer
Published:
Updated:

This article takes on the challenge of keeping Powershell scripts updated across multiple computers.  The main target is beginners that are trying to learn Powershell as it explains a few core concepts of writing scripts and functions, but this also applies to everyone that has more than one administrator running Powershell scripts in your environment.


Why would I want to implement this?

A fair question, to be sure.  The reason you would want to keep your scripts updated is that when you are the sole Powershell scripter on your team, as you make updates to the scripts that they use, it's a little bit time-consuming to let them know they need to get the updates.

Of course you could run your scripts always from a single, central location. There are some reasons that might not work, for example when they are executed on remote sites, or if security issues do not allow for executing from the central location – or just because of performance.


How you would normally update scripts:

You could do this by emailing them the entire script and telling them to replace it in their scripts folder.
Weakness: it requires them to update it.

You could also just remote into their computers manually and drop it into their scripts folder.
Weakness: it takes a little bit of time and requires a list of all the IPs that are using your scripts.  If their computer is offline, then you'll have to wait until they are back up before you can give them the updated scripts.


Proposed Fix:

A script to do it all for you.  This script achieves the following:
Creates/reads a registry entry for the last time the script was ran
Checks for newer scripts on a centralized location
Downloads the new scripts as it applies to them
You can easily see that this is a push-style update, that is the "client" is checking for an update.


The Next Step:

The rest of this article will go into detail of the core components of this script and explain the Powershell concepts behind the code.

If you are an experienced coder, you might just want to scroll to the bottom and get the entire script.


Part 1: Working with the Registry

The great thing about Powershell is it's ability to traverse the Registry in Windows just like a folder on your computer.  You give it the path, in this case starting with HKCU or HKEY Current User, and go on down the line until you get to your Powershell entry.  This is important to note that you're modifying the USER part of the registry, not the entire computer.  I went with this method so that it joins up with updating the user's profile as well, which you will read later.
$Path = "HKCU:\Software\Policies\Microsoft\Windows\Powershell"
                      $RegSubKey = get-item "$Path"
                      $LastUpdate = $RegSubkey.getvalue("LastUpdate")
                      if ($LastUpdate -eq $null){New-ItemProperty $Path -name LastUpdate -value "01/01/2011"}

Open in new window


$RegSubKey now holds the "folder" of Powershell in your registry.  Inside of this "folder", you will see a few entries.  Basically, your Enable Scripts modifier, and your Execution Policy.  What we are doing is gathering that information under $RegSubKey.  On line 3, we are getting the value of the item LastUpdate.  In this case, it will be empty or null because it doesn't exist.  If it doesn't exist, then how do we create it?  Line 4 shows us the answer with a simple if statement.  If $LastUpdate is null, then create a new item property in $Path (our Powershell folder) with the new name of LastUpdate and give it a value of "01/01/2011".  Now, if it does find the LastUpdate key inside the folder, it will store it to the variable $LastUpdate.  Here's where it gets a little bit more tricky:
if ($LastUpdate -ge (get-date -f "MM/dd/yyyy")){
                      .\admin.ps1
                      }

Open in new window


On this example, we have another conditional statement that says "if $LastUpdate is greater than or equal to the current date in the form of Month/Day/Year, then go to our Admin script", which is just our script for managing users that Powershell loads on default.  More information on the Admin Script can be found in my previous article https://www.experts-exchange.com/A_4327-PowerShell-Where-do-I-start.html .  You don't necessarily have to use the Admin script.  You could just as easily tell it to exit the script.

Back to our comparison: If the date of last update is greater than (in the future) or equal to today, then don't keep running the script.  This ensures you aren't checking for updates every single time you run Powershell.  You just check for updates once a day.


Part 2: Compare-Object

Now we have determined that the user is ready for updates, so now it's time to go out and check for them.
$ScriptsLocation = "\\192.168.100.1\scripts"
                      $LocalScripts = "C:\Scripts"
                      Write-Host "Please wait while checking the server for updates..."
                      $Compare = Compare-Object $(gci $ScriptsLocation) $(gci $LocalScripts) -Property Name, LastWriteTime

Open in new window


You set the $ScriptsLocation to your actual location you plan on storing all of your latest scripts.  I call it the Script Repository or Script Repo for short.  This is up to you, but I chose a location on a Networked Shared Drive.  Basically, a place where anyone can go to, that you can lock down by AD permissions, available 24/7 (i.e. not a laptop hard drive that shuts off the NIC when not in use).

Then you set the local scripts location.  It's common to use the C:\Scripts folder, but again it's your choice.

In line 3 we tell the user they must wait while the script checks on updates.

Finally we come to the enigmatic Compare-Object cmdlet on Line 4.  I chose Compare-Object because it's the fastest way to get a view of what's different between two folders.  Sure, I could've created two separate arrays populated with the names and write times of the files, but Powershell already made something to do it for us, so why don't we leverage it.

When you run Compare-Object on two directories, it tells you what's different by showing you little symbols like <= and =>.  You can also show things that are equal with a parameter called "-includeequal".
The order of when you compare two things matters greatly.  If you switch our command to do LocalScript first and ScriptsLocation second, what happens?  You guessed it, all the symbols are the opposite of what they were.

What's important to know here is that when you have two folders being compared, when you see a <=, that means the first directory in your comparison has a different file than your second directory.  You can also see how it's comparing the file name and last write time.  This is also important as it normally wouldn't tell you if the file existed on both directories because they would be considered "equal".  So when you update the lastwritetime on your new file, it will be shown as a different file.

Note: If you update a file on your local scripts folder and it's called script.ps1 with a write time newer than the script.ps1 file on your repo directory, it will still download the file and replace the new one you just edited.  This is because we want to keep all of our files on the script repo as our "master" and if you want to change your local files, just be warned that it will overwrite any changes you make unless you either name it something different, or put it into a separate directory.  You can always have more files in your scripts directory than your repo, since only those found in the "master" are checked and replaced.


Part 3: Adding the file names that are different to an array

Now that we've seen what files are different, let's go ahead and create an array based on their names for the new/different files on the script repository.
$UpdatesNeeded = @()
                      for ($i=0;$i -le $Compare.count;$i++){
                      if ($Compare[$i].sideindicator -eq "<="){$UpdatesNeeded += $i}
                      }#end for

Open in new window


Line 1 simply says "I'm about to start using $UpdatesNeeded as an array".  For all of you old-school VBScripters out there that haven't seen the light of Powershell yet, this would be called initializing the array.  You no longer have to redim!  Arrays were always very annoying in VBScript, which is why I love Powershell.

Line 2 shows "The For loop".  Another pillar of the programming world that you find in virtually every programming language.  You have your typical setup of
For
    Variable you're using and what it equals initially;
    keep doing the for code block while this condition stays true;
    what to do to your variable after every iteration
) {Code block}

In this case, I went with the classic variable of $i. $i is our counter variable, and according to our code, it's starting out with the value of 0.  It simply is a number that gets incremented each time we go through our For loop by writing $i++.  The ++ simply means: take whatever it was, and add one more to it.  Our condition is the amount of items in our variable $Compare we created earlier.  Inside of our array $compare, we have a listing of all of our files and the "<=" or "=>" symbol called a "side indicator".  If our counter is below our count of files in the $Compare array, then keep going, we have more to go through before we're done.

Now we go onto the next line of our code: a simple if statement.  This basically says if the $compare array has a side indicator of "<=", then we need to make sure we download it because we've deemed it's different than the file we have in our local scripts directory.  

But there's a small thing going on here you might miss because it seems so innocuous: $UpdatesNeeded += $i (which is a short version of $UpdatesNeeded = $UpdatesNeeded + $i) is actually an append to the array $UpdatesNeeded, not just adding an integer value to the previous content, as one might think on a first glance.
But why is $i getting stored to our $UpdatesNeeded array?  Shouldn't we be using the name of the object?  In this case, we can't use the name as we are going to be using the $i as a pointer or index number of which object we are trying to download in our $compare array.  Let me explain further:

As the loop progresses, let's say we are on the 24th file or object (0 was our first object, so the 24th object is referenced as 23 to our $Compare array) in our $Compare Array.  Let's look at how it would be seen to Powershell:
 
if ($Compare[23].sideindicator -eq "<="){$UpdatesNeeded += 23}
So as the loop progresses, every different file it finds, it tucks away the number of the associated file from the array into our $UpdatesNeeded array.

So now we have an array with numbers like 4,10,23, etc. referring to the $Compare Array's list of objects.


Part 4: Downloading the files that are different

There's a lot of fluff and loops in here to provide information to the user, record input from the user and check for the existence of a directory.  It looks daunting, but really is very simple when broken into pieces.
if ($UpdatesNeeded.Count -gt 0)
                      {
                          Clear-Host
                          "The following Updates have been found:"
                          $UpdatesNeeded | %{Write-Host $Compare[$_].name}
                          $DownloadResponse = Read-Host "$($UpdatesNeeded.Count) Updates Found.  Download now? [Press Enter to Download or type S to Skip]"
                          if ($DownloadResponse.tolower().contains("s")){"Skipping Updates";.\admin.ps1}
                          Clear-Host
                          Write-Host "DO NOT CLOSE THIS WINDOW -- DOWNLOADING UPDATES" -foregroundcolor Red
                          foreach ($Item in $UpdatesNeeded){
                              "Downloading New File: $($Compare[$item].name)..."
                              Copy-Item "$ScriptsLocation\$($Compare[$item].name)" $LocalScripts
                          }#end foreach
                          Write-Host "Updates Complete"
                          if (Test-Path "$LocalScripts\Microsoft.Powershell_profile.ps1"){
                              if (Test-Path $env:Userprofile\documents\WindowsPowershell){
                                  Copy-Item "$LocalScripts\Microsoft.Powershell_profile.ps1" $env:UserProfile\documents\WindowsPowershell
                              }
                              else
                              {
                                  md $env:UserProfile\documents\WindowsPowershell
                                  Copy-Item "$LocalScripts\Microsoft.Powershell_profile.ps1" $env:UserProfile\documents\WindowsPowershell
                              }
                          }#end if
                      }
                      else
                      {
                          Write-Host "No Updates needed at this time..."
                      }#end if

Open in new window


Following it from the top in an English translation:

If the $UpdatesNeeded array actually had a file put into it (count is greater than 0), then bring us to the next stage of our script: copying the files.

Let's inform the user that they have new scripts to be downloaded, and also what they are called.  The way I did this was shorthand because I didn't want to take up too much room.  This is the first time I've used the "|" (Pipe) command and you'll also notice a % sign, and some curly brackets {}.  This may be dense looking, but it's very simple.  $UpdatesNeeded has items in it, right?  Yes, because we wouldn't be in this part of the script if it didn't.  

So we use the pipe "|" to say "whereever I just came from, take that information and use it in this next part".  We use the % sign, which means foreach, which I'll go into later.  Finally, we use Write-Host $Compare[$_].name.  Remember that we referred to the 24th element by using $Array[23]?  $UpdatesNeeded is just a list of numbers from our earlier for loop.  So when we use $_, it's referring to the object previously used in our pipe command.  And because we don't want our entire $Compare object being shown to the user, we just ask for the name with .name.

We don't always want to force the files to be downloaded.  What if they are in a hurry and they can't wait the 5 seconds to download the files?  What if they edited one of their own scripts and they forgot to rename it or put it into the script repository?  So we give them a choice: Hit enter to download or S to skip.
Then we compare their response, and only if it's an S or s (hence the .toLower() so it's lowercase no matter what they put in) we want to skip the updates and start our admin script.

So if we are past the previous if statement, that means they haven't said Skip and it's now time to download files.  Clear-Host  is like cls on command prompt.  It just clears the screen.  Then in big Red letters, it flashes the ominous warning: DO NOT CLOSE THIS WINDOW.  Maybe that's a little more than required, but I really felt like they shouldn't close that window when downloading updates.  I saw it on a couple of gaming consoles and liked the idea.

Next we have the highly utilized command of ForEach.  The Foreach is used so much, it's probably the concept you should take away from here if nothing else.  For each object you find in an array.  For each "thing" you return from your Query.  For each and every single line in the text file.  For each computer in Active Directory.  You get the idea.  You basically specify your new variable you're going to use, in this case $item, which is nothing special, just something I made up.  Then you specify which array you're going to be referencing.  In this case, $UpdatesNeeded is being used.

Looking at
Copy-Item "$ScriptsLocation\$($Compare[$item].name)" $LocalScripts
you'll notice that there's another special situation: $($Variable.name).  This is saying: do whatever is in the parentheses first, then make that into a variable.  We could write it different:
$FileName = $Compare[$item].name
Copy-Item $ScriptsLocation\$Filename

This other way of doing it is just saving one line of code from being used.  If you have multiple variables of a single object, you would have to create a variable for each one, then use it in your code.

Then it finishes and tells the user the updates we are complete.  Just after we do that, we use test-path to see if the WindowsPowershell folder exists in the Users directory.  Test-Path will return true if the path exists, and false if the folder is missing.

You also may have noticed we are using $env:UserProfile to call the user's documents folder.  This is especially important because we have to write the script in such a way that the profile of each user can be updated no matter who runs it.

To see the different Environment ($env) variables available to use, in your Powershell window, type in:
dir env:
(Note that I used dir instead of get-childitem or gci.  This actually could be written all three ways.)

This will show you all the different things that you can get associated with the computer you're using, the domain you're logged into, the domain controller you're using to login, and finally your special folders for the profile you're logged into.  Very useful items if you want to use them for future scripts.

Back to the script. Using the enviroment variable, it copies the Microsoft.Powershell_profile.ps1 file from the local scripts directory to the user profile documents folder.  This means we can actually affect the user in a lot of ways.  When you replace someone's file called Microsoft.Powershell_profile.ps1, since Powershell always checks there when loading and runs any commands in that text file, you can make them run things without them even knowing it.  It sounds ominous, but it's highly useful when you make a change to be able to add something like a new snap-in to be loaded on startup, or maybe send you an email when they are booting up Powershell with their information of their computer like IP, MAC, etc.  That way, you can keep a list of who's using your Powershell scripts.  You can standardize the usage of Powershell site-wide, and not worry about having them manually update their scripts or profile scripts.


Part 5: Writing the date to the registry

At the very end of the script, you'll find a one-liner basically writing the date in Month/Day/Year format to the LastUpdate key in your registry path we defined at the top of the script.  If they choose to skip the updates however, it doesn't actually get this far.  That way, when they start up Powershell again in the same day, it will let them know they still have pending updates to get from the Repository.


The Entire Script

If you're an advanced Powershell administrator, you'll most likely have skipped to here to get the script.  That's fine.  The only thing you have to be aware of is you'll have to change the paths for the Script Repository and local scripts, and the way the script works is by finding files that are different, not newer, that exist in the script repository with your local scripts directory.  That means if you have a file on the repo that's made in 2010, and you've edited your local version of the script, it will find a difference between the files, meaning the file will be copied from the Repository to the Local Scripts directory overwriting any changes you may have made.

Also, you can only write registry changes to your computer if you're running Powershell as an Administrator.  An easy way to fix this is to go to your Powershell shortcut on your desktop, or wherever you have it, and go to Properties.  Next, click on Advanced.  Then, check the box "Run As Administrator".  This will make sure it runs each and every time as an admin.  Here's the Startup Script I modified to check to make sure the user is running Powershell as an administrator:

$Title = $host.ui.rawui.get_windowTitle()
                      if (!$Title.contains("Administrator")){
                      Write_host "You must run Powershell as an Administrator."
                      Write-Host "To do this, right-click on the Powershell Shortcut"
                      Write-Host "And Click `"Run as Administrator`""
                      $Pause = Read-Host "Press any key to continue"
                      }

Open in new window


Here's the actual script called Update.ps1 in its entirety:

$Path = "HKCU:\Software\Policies\Microsoft\Windows\Powershell"
                      $RegSubKey = get-item "$Path"
                      $LastUpdate = $RegSubkey.getvalue("LastUpdate")
                      if ($LastUpdate -eq $null){New-ItemProperty $Path -name LastUpdate -value "01/01/2011"}
                      if ($LastUpdate -ge (get-date -f "MM/dd/yyyy")){
                      .\admin.ps1
                      }
                      $ScriptsLocation = "\\192.168.100.1\scripts"
                      $LocalScripts = "C:\Scripts"
                      
                      Write-Host "Please wait while checking the server for updates..."
                      
                      $Compare = Compare-Object $(gci $ScriptsLocation) $(gci $LocalScripts) -Property Name, LastWriteTime
                      $UpdatesNeeded = @()
                      for ($i=0;$i -le $Compare.count;$i++){
                      if ($Compare[$i].sideindicator -eq "<="){$UpdatesNeeded += $i}
                      }#end for
                      
                      #Once it checks for updates, it gives a count needed to download
                      if ($UpdatesNeeded.Count -gt 0)
                      {
                          #This is the main loop to actually iterate through each file and copy it to the local computer
                          Clear-Host
                          "The following Updates have been found:"
                          $UpdatesNeeded | %{Write-Host $Compare[$_].name}
                          $DownloadResponse = Read-Host "$($UpdatesNeeded.Count) Updates Found.  Download now? [Press Enter to Download or type S to Skip]"
                          if ($DownloadResponse.tolower().contains("s")){"Skipping Updates";.\admin.ps1}
                          #Next block is to copy files
                          Clear-Host
                          Write-Host "DO NOT CLOSE THIS WINDOW -- DOWNLOADING UPDATES" -foregroundcolor Red
                          foreach ($Item in $UpdatesNeeded){
                              "Downloading New File: $($Compare[$item].name)..."
                              Copy-Item "$ScriptsLocation\$($Compare[$item].name)" $LocalScripts
                          }#end foreach
                          Write-Host "Updates Complete"
                          if (Test-Path "$LocalScripts\Microsoft.Powershell_profile.ps1"){
                              if (Test-Path $env:Userprofile\documents\WindowsPowershell){
                                  Copy-Item "$LocalScripts\Microsoft.Powershell_profile.ps1" $env:UserProfile\documents\WindowsPowershell
                              }
                              else
                              {
                                  md $env:UserProfile\documents\WindowsPowershell
                                  Copy-Item "$LocalScripts\Microsoft.Powershell_profile.ps1" $env:UserProfile\documents\WindowsPowershell
                              }
                          }#end if
                      }
                      else
                      {
                          Write-Host "No Updates needed at this time..."
                      }#end if
                      
                      #Set Reg Entry to reflect today's date
                      Set-ItemProperty $Path -name LastUpdate -value (Get-Date -f "MM/dd/yyyy")
                      .\admin.ps1

Open in new window


Well, that's all you need to get going.  If you have any issues getting this working in your environment, please leave it in the comments below.  If you read it and thought it was useful to you, mark this article as Helpful.
8
7,809 Views
Dale HarrisSenior Customer Success Engineer

Comments (4)

Commented:
Helpful article.

Another method is to use the Windows Task Scheduler (which works much better in Windows 7 / 2008 than it did in XP / 2003) to run your PS script.  Although, using Robocopy might be a little easier than building something from scratch like this.

The benefit of using the Task Scheduler is that you can run it overnight so that a) it doesn't interfere with your users, and b) your users do not have the option of declining the update.  Additionally, Task Scheduler can run a task /  script with specific credentials (avoiding making users administrators of their computers).  

The benefit of Robocopy is that it can run in restartable mode so you don't have to worry as much about reliable connections, which, depending on the environment, can be a concern (also probably allows for larger file sizes).  Simply set Robocopy to exclude older, and it will only download new or changed files.

This is what we did, and it is working nicely.
Dale HarrisSenior Customer Success Engineer

Author

Commented:
RobShift,

Thanks for the input.  I have never thought of doing it that way.  In our environment, we are forced to turn off Task Scheduler except for the Exchange Server so it purges the logs.  This severely hampers our ability to automate a lot of our tasks.  I think it would be helpful to others if you posted the Robocopy code needed to do something like this.

One question: I wouldn't want this copying to all computers, so would there be some type of list provided to Robocopy to only copy certain files to certain computers?  Also, would you be able to tell it to copy the latest and greatest Startup Script (Microsoft.Powershell_profile.ps1) to the user's Documents folder?

Lastly, I do think this code might not apply to every environment, but it's also a good example of a Powershell script for others to learn from.  This is sort of my attempt to knock out two birds with one stone.

Thanks again.

Dale Harris

Commented:
DaleHarris,

The Robocopy command would depend on your environment, but to give a general idea the following command would copy the contents of the sourceDIR to the destinationDIR including subdirectories, excluding older files, in restartable mode, restarting a maximum of 10 times, displaying the process on the terminal, and creating a log file named logPath.

Robocopy <sourceDir> <destinationDir> /E /XO /Z /R:10 /LOG:<logPath>

Open in new window

See HERE for a list of switches.

I wouldn't want this copying to all computers, so would there be some type of list provided to Robocopy to only copy certain files to certain computers?
We build most of our scripts to be agnostic (and specify the image type(s) that each will run on), but I don't see why you couldn't just make unique repositories for each computer set.


Also, would you be able to tell it to copy the latest and greatest Startup Script (Microsoft.Powershell_profile.ps1) to the user's Documents folder?
Robocopy is simply a more 'robust' copy application.  I think that it would be better to run that as a separate task / script.
What we do here is we have a respository for all of our powershell scripts in Bitbucket.  With an approval process for making changes.  Servers that leverage the repository have Git installed with a scheduled task that checks the repository for updates and pulls the repository down.

Have a question about something in this article? You can receive help directly from the article author. Sign up for a free trial to get started.