src/UI/05-Init.ps1
|
# 05-Init.ps1 — lazy UI bootstrap: dependency, console, UI state. # Ensure PwshSpectreConsole (ships the Spectre.Console assembly) is loaded, # offering to install it on first use. NEVER runs at profile import. # # Imported WITHOUT -Global on purpose (gh#16). This is psmm's own UI engine, # not something the user asked for, and it has no business appearing in their # `Get-Module`. A private import is enough for psmm's needs: the Spectre # assembly loads process-wide so the types resolve either way, and the # PwshSpectreConsole cmdlets psmm calls (Read-SpectreConfirm / # Read-SpectreSelection) are visible in psmm's own session state - verified. # Note the asymmetry with Import-PSMMModuleTimed, which MUST use -Global: the # user's modules belong in the user's session, psmm's belong in psmm's. function script:Initialize-PSMMUI { [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'First-use install prompt is interactive host output by design (mirrors the original block).')] [CmdletBinding()] param() $dep = Get-PSMMUIDependencyName if (-not ('Spectre.Console.AnsiConsole' -as [type])) { if (Get-Module -ListAvailable -Name $dep) { Register-PSMMPrivateImport -Module (Import-Module $dep -PassThru -ErrorAction Stop) } else { Write-Host "$dep is required for the interactive manager." -ForegroundColor Yellow if ((Read-Host 'Install it now from the PowerShell Gallery? (y/N)') -notmatch '^(y|yes)$') { return $false } try { Install-PSMMModule -Name $dep Register-PSMMPrivateImport -Module (Import-Module $dep -PassThru -ErrorAction Stop) } catch { Write-Warning "Could not install ${dep}: $($_.Exception.Message)" return $false } } } # NB: when the type already resolves nothing is imported and nothing is # registered - the copy in play is the user's own (or an earlier psmm run's, # already registered), and either way the grid should report it as it finds # it. $null = Get-PSMMConsole $true } # First manager run with no config file anywhere: create the main config, # seeded with psmm's own UI dependency as a managed entry (2026-07-05 # feedback). Mode InstallOnly keeps profile startup import-free - psmm loads # Spectre lazily itself. Only the TUI does this, never Invoke-PSMMStartup: # profile import must not write files. function script:Initialize-PSMMMainConfig { [CmdletBinding()] param() $null = Get-PSMMEntry # (re)build file metadata before inspecting it if (@((Get-PSMMFileMeta).Values | Where-Object { $_.Kind -ne 'inline' }).Count) { return } $main = Get-PSMMMainConfigPath if (Test-Path -LiteralPath $main) { return } try { $dir = Split-Path -Parent $main if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } ([ordered]@{ Includes = @() Modules = @([ordered]@{ Name = 'PwshSpectreConsole' Description = 'TUI engine used by psmm itself' Install = 'IfMissing' Mode = 'InstallOnly' }) } | ConvertTo-Json -Depth 10) | Set-Content -LiteralPath $main -Encoding utf8 $script:PSMM_UI.Status = "[$script:PSMM_ColOk]created $(ConvertTo-PSMMSafe $main) - psmm's UI dependency is managed there[/]" } catch { } # best-effort: the add flows still offer creation } # Fresh UI state for a manager session. function script:Initialize-PSMMUIState { [CmdletBinding()] param() $script:PSMM_UI = @{ Entries = [System.Collections.Generic.List[object]]::new() Cursor = 0 Top = 0 Sel = [System.Collections.Generic.HashSet[int]]::new() Filter = '' FilterMode = $false View = @() Status = '' Dirty = $false HardQuit = $false Goto = $null # set by the g goto overlay: unwind to the # manager loop, which routes to the target Unmanaged = $null # results of the background scan (#26) ShowUnmanaged = $false # 'm' toggles unmanaged rows in the grid Elevated = Test-PSMMElevated Engine = Get-PSMMInstallEngine Version = Get-PSMMVersionString # OneDrive-backed primary module location? (cached: grid renders a # standing notice, and per-frame path checks would be too slow) OneDrivePrimary = [bool](@(Get-PSMMModulePathInfo) | Where-Object { $_.First -and $_.OneDrive }) # newer psmm on the gallery, per the daily cached background check SelfUpdate = Test-PSMMUpdateAvailable } $null = Start-PSMMSelfUpdateCheck # throttled to once a day Initialize-PSMMMainConfig Sync-PSMMUIEntries -FullScan # unknown $PSMM_Theme: glacier took over silently at source time - say so # (last, so a first-run "created config" status cannot overwrite it) if (Test-PSMMThemeFallback) { $script:PSMM_UI.Status = "[$script:PSMM_ColWarn]unknown `$PSMM_Theme '$(ConvertTo-PSMMSafe (Get-PSMMSetting -Name 'PSMM_Theme'))' - using glacier (glacier|ember|moss)[/]" } } # (Re)build the entry list from config + disk. -FullScan does the one # expensive Get-Module -ListAvailable sweep (open + explicit reload only). function script:Sync-PSMMUIEntries { [CmdletBinding()] param([switch]$FullScan) # Disk facts have to SURVIVE the rebuild below. # # Get-PSMMEntry mints brand-new entry objects on every call, blank-slate: # Installed = $false, InstalledVersion = $null, InstallScope = $null # (Entry.ps1). The only thing that ever fills them is Update-PSMMAvailable, # which is gated on -FullScan because it is a whole-disk sweep. So a bare # Sync used to hand back a list where every managed module read as # "missing" - and pressing 'm' (show/hide unmanaged) is a bare Sync, which # is how psmm came to report its own running UI engine as not installed. # Update-PSMMLoaded was called unconditionally, so Loaded stayed honest # while everything disk-derived went blank - hence a row that was both # "missing" and able to list its 51 commands. # # Re-reading the disk on every toggle would be correct but costs a full # Get-Module -ListAvailable sweep per keypress. The facts have not changed, # so carry them across instead. Keyed by source+name: the same module name # can appear in several files. $carry = @{} if (-not $FullScan) { foreach ($e in @(Get-PSMMAllEntries)) { if ($e.Name -and $e.Installed) { $carry["$($e.Source)|$($e.Name)"] = $e } } } $active = Get-PSMMEntry $list = [System.Collections.Generic.List[object]]::new() foreach ($e in $active) { $list.Add($e) } # unmanaged rows appended when the toggle is on and the scan has results if ($script:PSMM_UI.ShowUnmanaged -and $script:PSMM_UI.Unmanaged) { $known = [System.Collections.Generic.HashSet[string]]::new( [string[]]@($active.Name), [System.StringComparer]::OrdinalIgnoreCase) foreach ($u in $script:PSMM_UI.Unmanaged) { if ($known.Contains($u.Name)) { continue } $x = Resolve-PSMMEntry -Raw ([pscustomobject]@{ Name = $u.Name; Description = $u.Description }) -Source '<unmanaged>' -Writable $false $x | Add-Member -NotePropertyName FromMain -NotePropertyValue $false -Force $x | Add-Member -NotePropertyName FileEnabled -NotePropertyValue $true -Force $x | Add-Member -NotePropertyName Unmanaged -NotePropertyValue $true -Force $x.Installed = $true $x.InstalledVersion = $u.Version $x.InstallScope = $u.Scope $x.IsSystem = [bool]$u.IsSystem $x.Mode = '-' $x.Install = '-' $list.Add($x) } } $script:PSMM_UI.Entries = $list # @() is mandatory: a single-entry result (fresh install: the one seeded # PwshSpectreConsole module) unrolls to a scalar PSObject on return, and # scalar + array below throws op_Addition (gh#1) $all = @(Get-PSMMAllEntries) if ($FullScan) { Update-PSMMAvailable -Entries ($all + @($list | Where-Object { $_.PSObject.Properties['Unmanaged'] })) } else { # restore what the previous generation knew about disk and the gallery foreach ($e in $all) { $p = $carry["$($e.Source)|$($e.Name)"] if (-not $p) { continue } foreach ($f in 'Installed', 'InstalledVersion', 'InstalledPrerelease', 'InstalledVersions', 'InstallScope', 'IsSystem', 'LatestVersion', 'LatestPrerelease', 'UpdateAvailable') { $e.$f = $p.$f } } } Update-PSMMLoaded -Entries $list Update-PSMMLoaded -Entries $all $script:PSMM_UI.Sel.Clear() $script:PSMM_UI.Dirty = $false } # Kick off the background unmanaged-module scan (#26). Results surface as an # unobtrusive overlay line; 'm' shows them in the grid. function script:Start-PSMMUnmanagedScan { [CmdletBinding()] param() # psmm's own modules are excluded like managed ones: they are infrastructure, # not something to adopt into a config (gh#16) $managed = @(@((Get-PSMMAllEntries).Name | Where-Object { $_ }) + @(Get-PSMMOwnModuleName)) # The job runs the ENGINE's Get-PSMMUnmanagedModule, dot-sourced in as # source text. It used to re-implement the scan with its own inlined scope # classifier, so the tested code was not the running code (gh#27). $payload = [pscustomobject]@{ Managed = $managed Prelude = (Get-PSMMUnmanagedScanPrelude) } $null = Start-PSMMTask -Label 'scan: unmanaged modules' -Kind 'unmanagedscan' -ArgumentList (, $payload) -ScriptBlock { param($payload) . ([scriptblock]::Create($payload.Prelude)) Get-PSMMUnmanagedModule -ManagedNames $payload.Managed } } |