Building a PowerShell Console Menu Revisited, Part 2

tiny-people-working-on-computer-hero-img
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"

Defining a menu title variable (Image Credit: Jeff Hicks)
Defining a menu title variable (Image Credit: Jeff Hicks)

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.

Building a menu object (Image Credit: Jeff Hicks)
Building a menu object (Image Credit: Jeff Hicks)

The variable $MyMenu is now my menu object.
My menu object (Image Credit: Jeff Hicks)
My menu object (Image Credit: Jeff Hicks)

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:\scripts\MySampleConsoleMenu.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"

My imported menu (Image Credit: Jeff Hicks)
My imported menu (Image Credit: Jeff Hicks)

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)

The menu in action (Image Credit: Jeff Hicks)
The menu in action (Image Credit: Jeff Hicks)

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:\scripts\Invoke-MyMenu.ps1
Invoke-Mymenu -Path C:\scripts\MySampleConsoleMenu.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:\scripts\ADTasks.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:\scripts\Invoke-MyMenu.ps1
Invoke-MyMenu -Path C:\scripts\ADTasks.xml

My Active Directory console menu (Image Credit: Jeff Hicks)
My Active Directory console menu (Image Credit: Jeff Hicks)


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.