Register for Semperis' Hybrid Identity Protection (HIP) Conference - June 30 - July 1 Register for Semperis' Hybrid Identity Protection (HIP) Conference - June 30 - July 1
PowerShell

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.

Related Topics:

BECOME A PETRI MEMBER:

Don't have a login but want to join the conversation? Sign up for a Petri Account

Register
Comments (0)

Leave a Reply

Register for the Hybrid Identity Protection (HIP) Europe Conference!

Hybrid Identity Protection (HIP) Europe 2021 - Virtual Conference

Mobile workforces, cloud applications, and digitalization are changing every aspect of the modern enterprise. And with radical transformation come new business risks. Hybrid Identity Protection (HIP) is the premier educational forum for identity-centric practitioners. At the inaugural HIP Europe, join your local IAM experts and Microsoft MVPs to learn all the latest from the Hybrid Identity world.