Files
shopdb-flask/deploy/windows/installer/shopdb-admin.ps1
cproudlock 0fa5f1e910 feat(deploy): add the air-gapped Windows installer
Roughly 2500 lines of tested installer had been living in ~/Downloads and an
untracked folder - nothing was under version control.

It goes here rather than in a repo of its own because it depends on application
internals: the `flask plugin` verbs, site-profile.json, MOUNT_PATH, and the
plugin registry. Versioned separately it would drift out of step with the thing
it installs.

Contents: the read-only preflight, the staged installer (bundled MySQL, runtime,
schema, IIS, verify, uninstall), the operator console, the Inno Setup wizard, the
bundle builder and the artwork generator.

bundle/ and Output/ are ignored - regenerable, and ~220MB. plugins.iss is ignored
because build-installer.sh generates it from the staged payload. The artwork IS
committed so a Windows build box does not need Python and cairosvg.

Verified end to end on Windows Server 2025 against a bundled MySQL 8.0 and an
existing MySQL 5.6: fresh install, upgrade with backup and rollback, re-run
idempotency, uninstall, and both deployment methods including switching between
them. Not yet verified: a hypervisor-level air-gapped run, and any load from a
real browser (every HTTP check so far used curl, which sends no Origin header).
2026-08-03 01:47:34 -04:00

530 lines
23 KiB
PowerShell

<#
.SYNOPSIS
Day-to-day control of a ShopDB-Flask installation, on the server itself.
.DESCRIPTION
Installed alongside the application so an operator never has to open IIS
Manager, hunt for a log, or remember an appcmd incantation.
Run it with no arguments for a menu, or pass a command directly:
.\shopdb-admin.ps1 status
.\shopdb-admin.ps1 restart
.\shopdb-admin.ps1 backup D:\backups
Everything here is safe to run at any time EXCEPT backup/restore, which are
called out explicitly.
.NOTES
Written for stock Windows PowerShell 5.1. No modules to install.
#>
[CmdletBinding()]
param(
[ValidateSet('menu','status','start','stop','restart','logs','open','backup',
'check','sessions','plugins','add-plugin','uninstall')]
[string] $Command = 'menu',
[string] $Path = '',
[string] $AppRoot = 'C:\shopdb-flask',
[string] $SiteName = 'shopdb-flask',
[string] $AppPool = 'shopdbflask',
[int] $SitePort = 8090
)
$ErrorActionPreference = 'Continue'
$AppCmd = Join-Path $env:windir 'System32\inetsrv\appcmd.exe'
# --- bitness ---------------------------------------------------------------
# IIS's management COM objects are 64-BIT ONLY. Under the 32-bit PowerShell,
# Import-Module WebAdministration SUCCEEDS but Get-Website then fails with
# Retrieving the COM class factory ... REGDB_E_CLASSNOTREG
# which this script caught and reported as "cannot read IIS" - indistinguishable
# from IIS being absent.
#
# A 32-bit launcher is easy to end up with: Inno Setup is a 32-bit process, so
# anything it starts gets SysWOW64 powershell through WOW64 redirection. Rather
# than fix every launcher, relaunch under the native PowerShell. 'Sysnative' is
# the alias that lets a 32-bit process reach the real System32, and it exists
# ONLY for 32-bit processes - hence the guard.
if ([Environment]::Is64BitOperatingSystem -and -not [Environment]::Is64BitProcess) {
$native = Join-Path $env:windir 'Sysnative\WindowsPowerShell\v1.0\powershell.exe'
if (Test-Path $native) {
$relaunch = @('-NoProfile', '-ExecutionPolicy', 'Bypass',
'-File', ('"' + $PSCommandPath + '"'), $Command)
if ($Path) { $relaunch += @('-Path', ('"' + $Path + '"')) }
Start-Process -FilePath $native -ArgumentList $relaunch -Wait -NoNewWindow
return
}
}
# --- elevation -------------------------------------------------------------
# Reading IIS state needs Administrator: the WebAdministration module and the
# IIS: drive both fail without it. Unelevated, this tool used to report
# "IIS not available / application pool: not installed", which reads as "your
# install is broken" when it actually means "I cannot see it". Relaunch elevated
# instead of reporting a false state.
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Host ''
Write-Host ' Administrator rights are needed to read IIS state.' -ForegroundColor Yellow
Write-Host ' Re-launching elevated - approve the prompt.' -ForegroundColor Yellow
$argList = @('-NoExit', '-NoProfile', '-ExecutionPolicy', 'Bypass',
'-File', ('"' + $PSCommandPath + '"'), $Command)
if ($Path) { $argList += @('-Path', ('"' + $Path + '"')) }
try {
Start-Process -FilePath 'powershell.exe' -ArgumentList $argList -Verb RunAs | Out-Null
} catch {
Write-Host ''
Write-Host ' Elevation was declined.' -ForegroundColor Red
Write-Host ' Right-click the shortcut and choose "Run as administrator".' -ForegroundColor Red
Read-Host ' Press Enter to close' | Out-Null
}
return
}
# --- brand header ----------------------------------------------------------
# Deliberately typographic, NOT ASCII art. The GE monogram is fine cursive
# linework; rendered as block characters at console resolution it reads as noise,
# which looks worse than no mark at all. A clean rule and correct wordmark says
# "considered"; mushy art says the opposite.
#
# Box-drawing characters used here are all in code page 437 (the Windows console
# default), and this file is saved UTF-8 WITH BOM so PowerShell 5.1 reads them
# correctly rather than assuming the ANSI code page.
function Show-Banner {
$rule = ([string][char]0x2500) * 58
Write-Host ''
Write-Host ' GE AEROSPACE' -ForegroundColor Blue
Write-Host (' ' + $rule) -ForegroundColor DarkGray
Write-Host ' ShopDB-Flask' -ForegroundColor White
Write-Host ' Asset management for the shop floor' -ForegroundColor DarkGray
Write-Host ''
}
function Say { param($m, $c = 'Gray') Write-Host $m -ForegroundColor $c }
function Head {
param($m)
Write-Host ''
Write-Host (' ' + $m) -ForegroundColor Cyan
Write-Host (' ' + (([string][char]0x2500) * $m.Length)) -ForegroundColor DarkGray
}
function Get-EnvValue {
param([string] $Key)
$envFile = Join-Path $AppRoot '.env'
if (-not (Test-Path $envFile)) { return '' }
$line = Get-Content $envFile | Where-Object { $_ -like "$Key=*" } | Select-Object -First 1
if ($line) { return $line.Substring($Key.Length + 1) }
return ''
}
function Get-Deployment {
<#
Which way was this installed?
Method A: its own IIS site on $SitePort.
Method B: an IIS Application under an existing site, at /<alias>, reached
on that site's port. MOUNT_PATH in .env is what distinguishes
them - it is the same value wsgi.py uses to mount the app.
Without this the console looked for a SITE that method B never creates and
reported "web site: not installed" on a perfectly healthy server, then
probed the wrong port and said it was not responding.
#>
$mount = (Get-EnvValue 'MOUNT_PATH').Trim()
if (-not $mount) {
return @{ Subpath = $false; Alias = ''; BaseUrl = "http://localhost:$SitePort"; Port = $SitePort }
}
$alias = $mount.Trim('/')
$port = 80
$parent = 'Default Web Site'
try {
Import-Module WebAdministration -ErrorAction Stop
foreach ($site in (Get-Website)) {
$app = Get-WebApplication -Site $site.Name -Name $alias -ErrorAction SilentlyContinue
if ($app) {
$parent = $site.Name
$b = $site.bindings.Collection | Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1
if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $port = [int]$Matches[1] }
break
}
}
} catch { }
$base = "http://localhost:$port/$alias"
if ($port -eq 80) { $base = "http://localhost/$alias" }
return @{ Subpath = $true; Alias = $alias; Parent = $parent; BaseUrl = $base; Port = $port }
}
function Get-DbParts {
# Pull host/port/name/user out of DATABASE_URL without printing the password.
$url = Get-EnvValue 'DATABASE_URL'
if ($url -match '://([^:]+):([^@]*)@([^:/]+):(\d+)/([^?]+)') {
# The password is PERCENT-ENCODED in DATABASE_URL (the installer applies
# [uri]::EscapeDataString). SQLAlchemy unescapes it; so must we. Without
# this, every command here fails to authenticate whenever the password
# contains a space, @, %, ! or /, and the console reports the database as
# unreachable on a server where the application is running perfectly.
return @{ User = [uri]::UnescapeDataString($Matches[1])
Pass = [uri]::UnescapeDataString($Matches[2])
Host = $Matches[3]; Port = $Matches[4]; Name = $Matches[5] }
}
return $null
}
function Find-MysqlClient {
$candidates = @(
'C:\Program Files\MySQL\MySQL Server 8.0\bin\mysql.exe',
'C:\mysql56\bin\mysql.exe'
) + @(Get-ChildItem 'C:\Program Files\MySQL' -Filter mysql.exe -Recurse -EA SilentlyContinue |
Select-Object -ExpandProperty FullName)
foreach ($c in $candidates) { if ($c -and (Test-Path $c)) { return $c } }
return ''
}
# --------------------------------------------------------------------------
function Show-Status {
Head 'Status'
$deploy = Get-Deployment
$siteState = 'not installed'
$poolState = 'not installed'
try {
Import-Module WebAdministration -ErrorAction Stop
if ($deploy.Subpath) {
# Method B: there is no site of our own - we are an Application.
$app = Get-WebApplication -Site $deploy.Parent -Name $deploy.Alias -ErrorAction SilentlyContinue
if ($app) {
$parentSite = Get-Website -Name $deploy.Parent -ErrorAction SilentlyContinue
$siteState = ("/{0} under '{1}' ({2})" -f $deploy.Alias, $deploy.Parent,
$(if ($parentSite) { $parentSite.State } else { 'unknown' }))
}
} else {
$site = Get-Website -Name $SiteName -ErrorAction SilentlyContinue
if ($site) { $siteState = $site.State }
}
if (Test-Path "IIS:\AppPools\$AppPool") { $poolState = (Get-Item "IIS:\AppPools\$AppPool").State }
} catch {
# Should be unreachable now that the script self-elevates, but if IIS is
# genuinely absent say THAT, rather than implying ShopDB is missing.
# Name the real cause instead of implying ShopDB is missing.
if (-not (Get-Service W3SVC -ErrorAction SilentlyContinue)) {
$siteState = 'IIS is not installed on this server'
} else {
$siteState = 'could not read IIS - ' + $_.Exception.Message
}
$poolState = $siteState
}
$colour = if (($siteState -eq 'Started') -or ($siteState -like '*Started*')) { 'Green' } else { 'Yellow' }
Say (" published as : {0}" -f $siteState) $colour
Say (" application pool: {0}" -f $poolState) $colour
Say (" installed at : {0}" -f $(if (Test-Path $AppRoot) { $AppRoot } else { 'not found' }))
# Does it actually answer? A "Started" pool proves nothing. Under method B
# this must include the mount path, or it tests a URL that never existed.
$url = $deploy.BaseUrl + '/'
try {
$r = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 20
Say (" responding : yes (HTTP {0})" -f $r.StatusCode) 'Green'
} catch {
Say ' responding : NO' 'Red'
Say (" {0}" -f $_.Exception.Message) 'DarkGray'
}
$db = Get-DbParts
if ($db) {
Say (" database : {0} on {1}:{2} as {3}" -f $db.Name, $db.Host, $db.Port, $db.User)
$mysql = Find-MysqlClient
if ($mysql) {
$q = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$($db.Name)';"
$n = $q | & $mysql "-u$($db.User)" "-p$($db.Pass)" "-h$($db.Host)" "-P$($db.Port)" -N 2>$null
if ($LASTEXITCODE -eq 0) { Say (" tables : {0}" -f $n) 'Green' }
else { Say ' tables : could not connect' 'Red' }
}
} else { Say ' database : no .env found' 'Yellow' }
# Is anyone set up yet?
try {
$na = Invoke-WebRequest -Uri ($deploy.BaseUrl + '/api/setup/needs-admin') -UseBasicParsing -TimeoutSec 15
if ($na.Content -match '"needsadmin"\s*:\s*true') {
Say ' first run : NOT SET UP - open the site to create the first administrator' 'Yellow'
} else { Say ' first run : complete' 'Green' }
} catch { }
Say ''
if ($deploy.Subpath) {
$shown = "http://{0}/{1}/login" -f $env:COMPUTERNAME, $deploy.Alias
if ($deploy.Port -ne 80) { $shown = "http://{0}:{1}/{2}/login" -f $env:COMPUTERNAME, $deploy.Port, $deploy.Alias }
} else {
$shown = "http://{0}:{1}/login" -f $env:COMPUTERNAME, $SitePort
}
Say (" open with : {0}" -f $shown) 'White'
}
function Start-App {
Head 'Starting'
$deploy = Get-Deployment
& $AppCmd start apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say " $_" }
if (-not $deploy.Subpath) {
& $AppCmd start site /site.name:$SiteName 2>&1 | ForEach-Object { Say " $_" }
} else {
# Method B shares the parent site - starting or stopping THAT would take
# every other application on this server with it.
Say (" (published under '{0}' - only the application pool is ours to control)" -f $deploy.Parent) 'DarkGray'
}
Say ' started' 'Green'
}
function Stop-App {
Head 'Stopping'
$deploy = Get-Deployment
Say ' ShopDB-Flask will be unavailable until you start it again.' 'Yellow'
if (-not $deploy.Subpath) {
& $AppCmd stop site /site.name:$SiteName 2>&1 | ForEach-Object { Say " $_" }
} else {
Say (" (leaving site '{0}' running - stopping it would take down everything else on it)" -f $deploy.Parent) 'DarkGray'
}
& $AppCmd stop apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say " $_" }
Say ' stopped' 'Yellow'
}
function Restart-App {
Head 'Restarting'
# Recycle rather than stop/start: it drains existing requests instead of
# cutting them off, and it is what a config change actually needs.
& $AppCmd recycle apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say " $_" }
Start-Sleep -Seconds 2
try {
$r = Invoke-WebRequest -Uri ((Get-Deployment).BaseUrl + '/') -UseBasicParsing -TimeoutSec 30
Say (" back up (HTTP {0})" -f $r.StatusCode) 'Green'
} catch { Say ' did not respond after restart - run: shopdb-admin.ps1 logs' 'Red' }
}
function Show-Logs {
Head 'Recent logs'
$appLog = Join-Path $AppRoot 'logs'
if (Test-Path $appLog) {
$newest = Get-ChildItem "$appLog\*.log" -EA SilentlyContinue |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($newest) {
Say (" application: {0}" -f $newest.FullName) 'White'
Get-Content $newest.FullName -Tail 20 | ForEach-Object { Say " $_" }
} else { Say ' no application log yet' }
}
$inst = 'C:\ProgramData\ShopDB-Flask\logs'
if (Test-Path $inst) {
$newest = Get-ChildItem "$inst\*.log" -EA SilentlyContinue |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($newest) {
Say ''
Say (" install: {0}" -f $newest.FullName) 'White'
Get-Content $newest.FullName -Tail 12 | ForEach-Object { Say " $_" }
}
}
}
function Open-Site {
$deploy = Get-Deployment
if ($deploy.Subpath) {
if ($deploy.Port -eq 80) { $u = "http://{0}/{1}/login" -f $env:COMPUTERNAME, $deploy.Alias }
else { $u = "http://{0}:{1}/{2}/login" -f $env:COMPUTERNAME, $deploy.Port, $deploy.Alias }
} else {
$u = "http://{0}:{1}/login" -f $env:COMPUTERNAME, $SitePort
}
Start-Process $u
Say ' opened in your browser' 'Green'
}
function Backup-Db {
param([string] $Dest)
Head 'Database backup'
$db = Get-DbParts
if (-not $db) { Say ' no .env found - cannot determine the database' 'Red'; return }
if (-not $Dest) { $Dest = 'C:\ProgramData\ShopDB-Flask\backups' }
if (-not (Test-Path $Dest)) { New-Item -ItemType Directory -Path $Dest -Force | Out-Null }
$mysql = Find-MysqlClient
if (-not $mysql) { Say ' mysql client not found' 'Red'; return }
$dump = Join-Path (Split-Path $mysql -Parent) 'mysqldump.exe'
if (-not (Test-Path $dump)) { Say ' mysqldump not found' 'Red'; return }
$file = Join-Path $Dest ("shopdb_flask-{0}.sql" -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
Say (" writing {0}" -f $file)
# Redirect rather than --result-file: keeps it working on 5.6 and 8.0 alike.
# Do NOT pipe mysqldump through the PowerShell pipeline into Out-File.
# PowerShell 5.1 writes a UTF-8 BOM and re-encodes the stream through the
# console code page, producing a dump MySQL refuses to load and mangling any
# non-ASCII data - discovered only when the backup is finally needed.
# Redirect the process's stdout straight to the file instead.
$args = @("-u$($db.User)", "-p$($db.Pass)", "-h$($db.Host)", "-P$($db.Port)",
'--single-transaction', '--routines', '--triggers', $db.Name)
$quoted = $args | ForEach-Object {
if ($_ -match '\s' -and $_ -notmatch '^".*"$') { '"' + $_ + '"' } else { $_ }
}
$err = [System.IO.Path]::GetTempFileName()
try {
$proc = Start-Process -FilePath $dump -ArgumentList $quoted -Wait -PassThru -NoNewWindow `
-RedirectStandardOutput $file -RedirectStandardError $err
if ($proc.ExitCode -ne 0) {
Say (" mysqldump failed (exit {0})" -f $proc.ExitCode) 'Red'
Get-Content $err -Tail 3 -EA SilentlyContinue | ForEach-Object { Say (" " + $_) 'DarkGray' }
return
}
} finally { Remove-Item $err -Force -ErrorAction SilentlyContinue }
# Verify it is complete rather than merely present.
$tail = @(Get-Content $file -Tail 5 -ErrorAction SilentlyContinue)
if ((Test-Path $file) -and ((Get-Item $file).Length -gt 1024) -and ($tail -match 'Dump completed')) {
Say (" done - {0:N1} MB, verified complete" -f ((Get-Item $file).Length / 1MB)) 'Green'
Say ' Store this off the server. It contains all of your asset data.' 'Yellow'
} else {
Say ' backup is empty or truncated - do NOT rely on it' 'Red'
Remove-Item $file -Force -ErrorAction SilentlyContinue
}
}
function Invoke-Check {
Head 'Health check'
$flask = Join-Path $AppRoot 'venv\Scripts\flask.exe'
if (-not (Test-Path $flask)) { Say ' application not installed' 'Red'; return }
Push-Location $AppRoot
$env:FLASK_APP = 'shopdb'
try { & $flask db-utils preflight 2>&1 | ForEach-Object { Say " $_" } }
finally { Pop-Location }
}
function Show-Sessions {
Head 'Worker processes'
$w = Get-CimInstance Win32_Process -Filter "Name='w3wp.exe'" -EA SilentlyContinue
if (-not $w) { Say ' no IIS worker running (the site starts one on first request)' }
else {
foreach ($p in $w) {
Say (" pid {0} {1:N0} MB started {2}" -f $p.ProcessId,
($p.WorkingSetSize/1MB), $p.CreationDate)
}
}
$py = Get-CimInstance Win32_Process -Filter "Name='python.exe'" -EA SilentlyContinue |
Where-Object { $_.CommandLine -like "*$AppRoot*" }
if ($py) { foreach ($p in $py) { Say (" python pid {0} {1:N0} MB" -f $p.ProcessId, ($p.WorkingSetSize/1MB)) } }
}
function Invoke-Flask {
param([string[]] $Arguments)
$flask = Join-Path $AppRoot 'venv\Scripts\flask.exe'
if (-not (Test-Path $flask)) { Say ' application not installed' 'Red'; return $null }
Push-Location $AppRoot
$env:FLASK_APP = 'shopdb'
# The app logs plugin startup to STDERR even on success; with EAP=Stop that
# becomes a terminating error and a healthy command looks like a failure.
$prev = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try { & $flask @Arguments 2>&1 }
finally { $ErrorActionPreference = $prev; Pop-Location }
}
function Show-Plugins {
Head 'Plugins'
Invoke-Flask @('plugin','list') | ForEach-Object { Say " $_" }
# What is on disk but NOT installed can still be added here. What is absent
# from disk cannot - see the note below.
$dir = Join-Path $AppRoot 'plugins'
if (Test-Path $dir) {
$onDisk = (Get-ChildItem $dir -Directory -EA SilentlyContinue |
Where-Object { Test-Path (Join-Path $_.FullName 'manifest.json') } |
Select-Object -ExpandProperty Name) -join ', '
Say ''
Say (" shipped in this build : {0}" -f $onDisk) 'White'
}
Say ''
Say ' Add one that is shipped : shopdb-admin.ps1 add-plugin -Path <name>' 'White'
Say ''
Say ' A plugin NOT listed above is not on this server at all. This build was' 'DarkGray'
Say ' made for your site''s chosen plugin set, so its code was never shipped.' 'DarkGray'
Say ' Adding one means a new installer built from an updated site profile.' 'DarkGray'
}
function Add-Plugin {
param([string] $Name)
Head 'Add a plugin'
if (-not $Name) { Say ' usage: shopdb-admin.ps1 add-plugin -Path <plugin-name>' 'Yellow'; return }
$dir = Join-Path $AppRoot ('plugins\' + $Name)
if (-not (Test-Path (Join-Path $dir 'manifest.json'))) {
Say (" '{0}' is not present on this server." -f $Name) 'Red'
Say ''
Say ' This build ships only the plugins your site chose. Adding a new one' 'Yellow'
Say ' requires a new installer built from an updated site profile - the' 'Yellow'
Say ' code is not here to install.' 'Yellow'
Say ''
Say ' Run "shopdb-admin.ps1 plugins" to see what IS available.' 'White'
return
}
Say (" installing {0}..." -f $Name)
Invoke-Flask @('plugin','install',$Name) | ForEach-Object { Say " $_" }
Say ' applying its database migrations...'
Invoke-Flask @('plugin','upgrade-all') | ForEach-Object { Say " $_" }
Say ' restarting so its routes register...'
Restart-App
Say (" {0} added" -f $Name) 'Green'
}
function Show-Uninstall {
Head 'Uninstall'
Say ' Use Settings > Apps > ShopDB-Flask, or Add/Remove Programs.'
Say ''
Say ' That removes the web site, application pool, firewall rule and files.'
Say ' It does NOT drop the database and does NOT uninstall MySQL.' 'Yellow'
Say ' Take a backup first: shopdb-admin.ps1 backup' 'Yellow'
}
function Show-Menu {
$first = $true
while ($true) {
if ($first) { Show-Banner; $first = $false }
Show-Status
Write-Host ''
Write-Host ' 1 Restart the application 6 Back up the database' -ForegroundColor White
Write-Host ' 2 Stop the application 7 Worker processes' -ForegroundColor White
Write-Host ' 3 Start the application 8 Open in browser' -ForegroundColor White
Write-Host ' 4 View recent logs 9 Plugins' -ForegroundColor White
Write-Host ' 5 Health check 0 Exit' -ForegroundColor White
Write-Host ''
$c = Read-Host ' Choose'
switch ($c) {
'1' { Restart-App } '2' { Stop-App } '3' { Start-App }
'4' { Show-Logs } '5' { Invoke-Check } '6' { Backup-Db $Path }
'7' { Show-Sessions } '8' { Open-Site }
'9' { Show-Plugins
$add = Read-Host ' Name of a shipped plugin to add (Enter to skip)'
if ($add) { Add-Plugin $add } }
'0' { return }
default { Say ' not a choice' 'Yellow' }
}
Write-Host ''
Read-Host ' Press Enter to continue' | Out-Null
Clear-Host
Show-Banner
}
}
switch ($Command) {
'status' { Show-Banner; Show-Status }
'start' { Start-App }
'stop' { Stop-App }
'restart' { Restart-App }
'logs' { Show-Logs }
'open' { Open-Site }
'backup' { Backup-Db $Path }
'check' { Invoke-Check }
'sessions' { Show-Sessions }
'plugins' { Show-Plugins }
'add-plugin'{ Add-Plugin $Path }
'uninstall' { Show-Uninstall }
default { Show-Menu }
}