Last Update: Sep 04, 2024 | Published: Feb 04, 2016
In a previous article, I demonstrated how to create a text-based PowerShell menu. This is something you could wrap up in a script for a technician, end-user, or even yourself. But I wanted to do more, so let’s see how to use PowerShell tools to build new tools.
We’ll start by initializing a counter variable.
$i=0
In my sample menu from last time, all of my entries were numbered. I’m too lazy to count, so I’ll let PowerShell do it. I’m also going to prompt for a menu title:
$Title = Read-Host "Enter the title for your menu"
For the sake of my demonstration, simply assign a value to $Title. At this point, I’m going to create a custom object. You’ll see why in a bit.
$MyMenu = [pscustomobject]@{ Title = $Title Items = @() }
Note that you can only use the [pscustomobject] type in PowerShell 3.0 and later. Now that I have two items to the menu, I think the process is easier if you enter them in the order you want them presented.
To accomplish this, I’m going to use a do loop to prompt for a menu description and a corresponding PowerShell expression. Each time through the loop, the counter is incremented by one, and I create a nested custom object with the counter number, the menu item or description, and the action.
Do { #increment the counter $i++ #prompt for a menu item $item = Read-Host "Enter the menu item, e.g. Restart Spooler. Leave blank to finish." if ($item) { #prompt for a corresponding action $action = Read-Host "Enter the PowerShell expression to execute" $sb = [scriptblock]::Create($action) $MyMenu.Items += [pscustomobject]@{ ItemNumber = $i MenuItem = $item Action = $sb } } #if $item } While ($item)
The loop will continue as long as something is entered for Read-Host.
The variable $MyMenu is now my menu object.
As an alternative to being prompted, you could create a hashtable of menu items.
$hash = [ordered]@{ "Get running services" = {Get-Service | where status -eq 'running'} "Get uptime" = {((Get-Date) - (Get-CimInstance win32_operatingsystem).lastbootuptime).ToString()} "Get Drive C" = {Get-CimInstance win32_logicaldisk -filter "deviceid='c:'"} "Get System Eventlog" = {Get-Eventlog -LogName System -Newest 10} } #create item objects $items = $hash.GetEnumerator() | foreach -Begin { $i=0 } -Process { $i++ [pscustomobject]@{ ItemNumber = $i MenuItem = $_.Name Action = $_.Value } } -end {} $MyMenu = [pscustomObject]@{ Title = $Title Items = $items }
My actions are all local, but you could invoke your own scripts or functions that could prompt for computer names or other specific information. For now, I just want to focus on the structure. In either event, I end up with a custom object that represents my menu. You’ll note that I didn’t include any options to quit, which you’ll see why in a moment. The reason I went through all of this effort is to save the results to an XML file.
$Path = "C:scriptsMySampleConsoleMenu.xml" $MyMenu | Export-Clixml -Path $path
Now my menu definition is stored in a structured document. This makes it easier to create different menus, but use the same script to display them. To do that, I need to import the XML file.
$importedMenu = Import-Clixml -Path $path
Next, I build a here string from the imported items.
$hereMenu = @" $($ImportedMenu.title) "@ foreach ($item in $importedMenu.Items) { $hereMenu+= "{0} - {1}`n" -f $item.ItemNumber,$item.MenuItem } $hereMenu += "Enter a menu number or Q to quit"
All that remains is to put some logic behind the menu and respond to Read-Host. I invoke the corresponding scriptblock using Invoke-Command. Although the XML format appears to store a scriptblock, it gets imported as a string so I have to take an extra step to turn it back into a scriptblock. I could have used Invoke-Expression, but that’s a bad security practice.
#Keep looping and running the menu until the user selects Q (or q). $Running = $True Do { cls $r = Read-Host $hereMenu if ($r -match "^q" -OR $r.length -eq 0) { #quit the menu $running = $False Write-Host "Exiting the menu. Have a nice day" -ForegroundColor green #bail out Return } elseif ( -Not ([int]$r -ge 1 -AND [int]$r -le $($importedMenu.Items.count)) ) { Write-Warning "Enter a menu choice between 1 and $($importedMenu.Items.count) or Q to quit" } else { #create a scriptblock from the corresponding action $cmd = [scriptblock]::Create($importedMenu.Items[$r-1].action) Invoke-Command -ScriptBlock $cmd | Out-Host } #pause $nothing = Read-Host "Press any key to continue" } While ($Running)
You’ll see that I added logic to Quit separately. Here’s a more complete function for the entire process:
Function Invoke-MyMenu { [cmdletbinding()] Param( [Parameter(Position=0,Mandatory,HelpMessage = "Enter the path to an XML file with an exported menu")] [ValidateScript({ if (Test-Path $_) { $True } else { Throw "Cannot validate path $_" } })] [string]$Path ) $importedMenu = Import-Clixml -Path $path #verify there are title and item properties if ($importedMenu.Title -AND $importedMenu.Items) { $hereMenu = @" $($ImportedMenu.title) "@ foreach ($item in $importedMenu.Items) { $hereMenu+= "{0} - {1}`n" -f $item.ItemNumber,$item.MenuItem } $hereMenu += "Enter a menu number or Q to quit" #Keep looping and running the menu until the user selects Q (or q). $Running = $True Do { cls $r = Read-Host $hereMenu if ($r -match "^q" -OR $r.length -eq 0) { #quit the menu $running = $False Write-Host "Exiting the menu. Have a nice day" -ForegroundColor green #bail out Return } elseif ( -Not ([int]$r -ge 1 -AND [int]$r -le $($importedMenu.Items.count)) ) { Write-Warning "Enter a menu choice between 1 and $($importedMenu.Items.count) or Q to quit" } else { #create a scriptblock $cmd = [scriptblock]::Create($importedMenu.Items[$r-1].action) Invoke-Command -ScriptBlock $cmd | Out-Host #pause $nothing = Read-Host "Press any key to continue" } } While ($Running) } else { Write-Warning "$Path does not appear to have menu information" } } #end Function
With this, my script is actually quite simple:
#dot source the help desk . C:scriptsInvoke-MyMenu.ps1 Invoke-Mymenu -Path C:scriptsMySampleConsoleMenu.xml
By separating the menu from the script, you have a bit more control. You can use a script to create the menu file, like this one to create an Active Directory related menu.
$title = "AD Tasks" $path = "C:scriptsADTasks.xml" $hash = [ordered]@{ "Get Domain Admins" = {get-adgroupmember "Domain Admins" | Select Name} "Get Domain Controllers" = {(Get-ADDomain).ReplicaDirectoryServers | Get-ADDomainController | Select Name,IPv4Address,IsGlobalCatalog} "Get FSMO Roles" = $a = { $domain = Get-ADDomain $forest = Get-ADForest [pscustomobject]@{ Domain = $domain.name Forest = $forest.Name PDC = $domain.PDCEmulator RIDMaster = $domain.RIDMaster InfrastructureMaster = $domain.InfrastructureMaster SchemaMaster = $forest.SchemaMaster DomainNamingMaster = $forest.DomainNamingMaster } } "Check DC services" = { $dcs = (Get-ADDomain).ReplicaDirectoryServers $svc = "DNS","KDC","ADWS","NTDS" Get-Service -Name $svc -ComputerName $dcs | Sort Status,Computername | Select @{Name="DomainController";Expression={$_.Machinename}},Name,Displayname,Status } } #create item objects $items = $hash.GetEnumerator() | foreach -Begin { $i=0 } -Process { $i++ [pscustomobject]@{ ItemNumber = $i MenuItem = $_.Name Action = $_.Value } } -end {} $MyMenu = [pscustomObject]@{ Title = $Title Items = $items } $MyMenu | Export-Clixml -Path $path
Now, we’ll create a simple script to invoke it.
#requires -version 4.0 #requires -module ActiveDirectory #dot source the help desk . C:scriptsInvoke-MyMenu.ps1 Invoke-MyMenu -Path C:scriptsADTasks.xml
If I need to modify this menu, I can manually modify the XML or revise the first script and rerun it. I don’t have to touch any script that uses it. I hope you’ll let me know what you think of this approach.