<# .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','verify','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 /, 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 ' '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 ' '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 Invoke-Verify { <# Prove the installed dependencies are still the ones that shipped. Two independent records, both written at install time: requirements.txt a sha256 for every wheel, checked by pip on install bundle-lock.json the exact third-party payload of the bundle This re-checks what can still be checked on a live server. It reports; it changes nothing. #> Head 'Verify' $lock = Join-Path $AppRoot 'bundle-lock.json' if (Test-Path $lock) { try { $l = Get-Content $lock -Raw | ConvertFrom-Json Say (" installed from : {0} bundle ({1}, {2})" -f $l.generated, $l.pythontag, $l.platform) } catch { Say ' bundle-lock.json is present but unreadable' 'Yellow' } } else { Say ' no bundle-lock.json recorded (installed before payload locking, or by hand)' 'Yellow' } # The SBOM travels with the application, because a server on a vaulted # network cannot be scanned from anywhere else. When a CVE lands, this is # what answers "is that component here, and at what version" without needing # the build box, the internet, or anyone's memory. $sbom = Join-Path $AppRoot 'sbom.cdx.json' if (Test-Path $sbom) { try { $b = Get-Content $sbom -Raw | ConvertFrom-Json $shipped = @($b.components | Where-Object { $_.scope -eq 'required' }).Count Say (" components : {0} ({1} shipped), CycloneDX {2}" -f ` $b.components.Count, $shipped, $b.specVersion) Say (" bill of materials: {0}" -f $sbom) 'DarkGray' Say ' search it with : shopdb-admin.ps1 verify -Path ' 'DarkGray' } catch { Say ' sbom.cdx.json is present but unreadable' 'Yellow' } # A named component turns this into the actual question being asked. # Guarded on $b: an unreadable SBOM leaves it unset, and querying it then # would report 'none', which reads as "you are not affected". if ($Path -and $b) { Say '' Say (" matches for '{0}':" -f $Path) 'Cyan' $hits = @($b.components | Where-Object { $_.name -like ('*' + $Path + '*') }) if (-not $hits) { Say ' none - this server does not carry it' 'Green' } foreach ($h in $hits) { $tag = if ($h.scope -eq 'required') { 'SHIPPED' } else { 'build only' } Say (" {0,-40} {1,-14} {2}" -f $h.name, $h.version, $tag) ` $(if ($h.scope -eq 'required') { 'Yellow' } else { 'DarkGray' }) } } } else { Say ' no SBOM recorded (installed before SBOMs shipped, or by hand)' 'Yellow' } # pip's own audit. It re-reads the metadata of what is actually installed and # reports anything missing or version-inconsistent, which is the part that # can still drift after install - a hand-run `pip install` on the server. $py = Join-Path $AppRoot 'venv\Scripts\python.exe' if (-not (Test-Path $py)) { Say ' no venv - application not installed' 'Red'; return } Say '' Say ' checking installed packages against requirements.txt...' $req = Join-Path $AppRoot 'requirements.txt' if (-not (Test-Path $req)) { Say ' requirements.txt is missing from the install' 'Red'; return } $wanted = @{} foreach ($line in (Get-Content $req)) { if ($line -match '^([A-Za-z0-9._-]+)==([^ \\]+)') { $wanted[$Matches[1].ToLower().Replace('_','-')] = $Matches[2] } } $frozen = @{} foreach ($line in (& $py -m pip freeze 2>$null)) { if ($line -match '^([A-Za-z0-9._-]+)==(.+)$') { $frozen[$Matches[1].ToLower().Replace('_','-')] = $Matches[2] } } $bad = 0 foreach ($name in ($wanted.Keys | Sort-Object)) { if (-not $frozen.ContainsKey($name)) { Say (" MISSING {0} {1}" -f $name, $wanted[$name]) 'Red'; $bad++ } elseif ($frozen[$name] -ne $wanted[$name]) { Say (" DIFFERS {0} {1} installed, {2} expected" -f $name, $frozen[$name], $wanted[$name]) 'Red'; $bad++ } } if ($bad -eq 0) { Say (" all {0} packages match" -f $wanted.Count) 'Green' } else { Say '' Say (" {0} package(s) do not match what shipped." -f $bad) 'Red' Say ' Something was installed or upgraded on this server by hand. Re-run the' 'Yellow' Say ' installer to put the shipped set back.' 'Yellow' } & $py -m pip check 2>&1 | ForEach-Object { Say " $_" 'DarkGray' } } 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 V Verify this install' -ForegroundColor White Write-Host ' 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 } 'v' { Invoke-Verify } 'V' { Invoke-Verify } '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 } 'verify' { Invoke-Verify } 'uninstall' { Show-Uninstall } default { Show-Menu } }