diff --git a/README.md b/README.md index 273c436..730a3b5 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,13 @@ Each entry in `Applications` declares what to do and what to do it to: | `OpenUrl` | Opens a URL in the default browser | | `CheckProcess` | Verifies a process is running; `Target` is an array of names | | `CheckPath` | Verifies a path exists; launches nothing | +| `CheckWinget` | Verifies a winget package id is installed; installs nothing | + +`CheckWinget` asks App Installer whether a package id is installed, which is +stable where an install path is not. It never installs or upgrades anything. On +an image without winget — LTSC and stripped images — the run reports that once +as a warning and every `CheckWinget` entry becomes a **Manual Step** to verify by +hand. `Environment` and `FormFactor` filter an entry to matching machines. A filtered-out target is reported **Not Applicable** in the checklist rather than omitted, so the diff --git a/WinContextDeploy.psd1 b/WinContextDeploy.psd1 index 7436ff9..1d1968d 100644 --- a/WinContextDeploy.psd1 +++ b/WinContextDeploy.psd1 @@ -31,6 +31,9 @@ # CheckProcess verify a process is running, launch nothing # (Target is an array of process names) # CheckPath verify a path exists, launch nothing + # CheckWinget verify a winget package id is installed, + # launch nothing, install nothing (Target is + # the exact package id) # Target Path, URL, command, or array of process names. Required. # Environment Restrict to 'Workstation' or 'Vdi'. Omit to always apply. # FormFactor Restrict to 'Laptop' or 'Desktop'. Omit to always apply. @@ -145,6 +148,17 @@ Prompt = $true } + # Verify a package that arrived through App Installer. The package id + # is stable where an install path is not. Never installs anything, and + # on an image without winget the entry becomes a Manual Step. + # @{ + # Step = 'AppPowerToys' + # Name = 'PowerToys' + # Action = 'CheckWinget' + # Target = 'Microsoft.PowerToys' + # Optional = $true + # } + # @{ # Step = 'AppFleetTelemetry' # Name = 'Fleet telemetry portal' diff --git a/docs/manual.pdf b/docs/manual.pdf index 172b43b..b524380 100644 Binary files a/docs/manual.pdf and b/docs/manual.pdf differ diff --git a/docs/manual.typ b/docs/manual.typ index 12c9809..783489a 100644 --- a/docs/manual.typ +++ b/docs/manual.typ @@ -648,8 +648,18 @@ The manifest-driven core of the tool. It walks the `Applications` list in [`OpenUrl`], [Opens a URL in the default browser.], [`CheckProcess`], [Verifies a process is running. Launches nothing. `Target` is a list of process names, and any one of them counts.], [`CheckPath`], [Verifies a path exists. Launches nothing.], + [`CheckWinget`], [Verifies a winget package id is installed. Launches nothing, and installs nothing. `Target` is the exact package id.], ) +#note[ + `CheckWinget` exists because an install path moves between versions and + between machines — a per-user MSIX lands somewhere quite different from a + per-machine MSI — while the package id does not. winget itself is missing from + LTSC and stripped images: the run probes for it once, reports that as a single + warning, and marks every `CheckWinget` entry as a Manual Step to verify by + hand. One honest cause, and rows a technician can act on. +] + Adding, removing or reordering an application is a manifest edit. No code changes, no new module. diff --git a/src/Config-Applications.ps1 b/src/Config-Applications.ps1 index 79c3df2..489d962 100644 --- a/src/Config-Applications.ps1 +++ b/src/Config-Applications.ps1 @@ -4,7 +4,71 @@ # Adding, removing or reordering an application is a manifest edit; nothing in # this file needs to change for it. -$script:WcdApplicationActions = @('Launch', 'OpenFolder', 'OpenUrl', 'CheckProcess', 'CheckPath') +$script:WcdApplicationActions = @('Launch', 'OpenFolder', 'OpenUrl', 'CheckProcess', 'CheckPath', 'CheckWinget') + +function Test-WcdWingetAvailable { + <# + .SYNOPSIS + Reports whether winget.exe exists on this machine. + + .DESCRIPTION + App Installer is absent from LTSC and stripped images, so a CheckWinget + target cannot be verified there at all. Probed once per run rather than + once per target, so a missing App Installer reads as one cause instead of + N identical failures. + + .OUTPUTS + [bool] $true when winget.exe can be resolved. + + .EXAMPLE + if (-not (Test-WcdWingetAvailable)) { 'verify by hand' } + #> + [CmdletBinding()] + param() + + return ($null -ne (Get-Command -Name 'winget.exe' -ErrorAction SilentlyContinue)) +} + +function Test-WcdWingetPackageInstalled { + <# + .SYNOPSIS + Reports whether winget lists a package id as installed. + + .DESCRIPTION + Verify only: this never installs or upgrades anything. The agreement and + interactivity flags are required, not polish - a winget that has never + run otherwise blocks on a source-agreement prompt, and nothing in a + one-shot run may stop for input. Without --exact a bare name matches + several packages and the check means nothing. + + Exit code 0 means installed. The 'no applications found' code means + absent. Any other code means winget itself failed, which throws rather + than being reported as an absent package. + + .PARAMETER Id + Exact winget package id, for example 'Microsoft.PowerToys'. + + .OUTPUTS + [bool] $true when the package is installed. + + .EXAMPLE + Test-WcdWingetPackageInstalled -Id 'Microsoft.PowerToys' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Id + ) + + $output = & winget.exe list --id $Id --exact --accept-source-agreements --disable-interactivity 2>&1 + $exitCode = $LASTEXITCODE + + if ($exitCode -eq 0) { return $true } + # APPINSTALLER_CLI_ERROR_NO_APPLICATIONS_FOUND (0x8a150014): not installed. + if ($exitCode -eq -1978335212) { return $false } + + throw ('winget list failed for {0} (exit {1}): {2}' -f $Id, $exitCode, ((($output | Where-Object { $_ -match '\S' }) -join ' ').Trim())) +} function Test-WcdTargetPresent { <# @@ -15,7 +79,8 @@ function Test-WcdTargetPresent { Only targets that look like filesystem paths are checked here. A bare command such as 'ms-teams.exe' is resolved by the shell, and OpenUrl and CheckProcess targets are not paths at all, so all three are reported - present and left for Invoke-WcdApplicationTarget to attempt. + present and left for Invoke-WcdApplicationTarget to attempt. A + CheckWinget target is a package id, so it is asked of winget instead. .PARAMETER Entry One Application Target entry from the manifest. @@ -36,6 +101,8 @@ function Test-WcdTargetPresent { # us. Only targets that look like filesystem paths are checked up front. if ($Entry.Action -eq 'OpenUrl') { return $true } if ($Entry.Action -eq 'CheckProcess'){ return $true } + # A package id is not a path: winget is the only thing that can answer. + if ($Entry.Action -eq 'CheckWinget') { return (Test-WcdWingetPackageInstalled -Id ([string]$Entry.Target)) } if ([string]$Entry.Target -notmatch '[\\/]') { return $true } return (Test-Path -LiteralPath ([string]$Entry.Target)) @@ -72,6 +139,7 @@ function Invoke-WcdApplicationTarget { 'OpenFolder' { Start-Process 'explorer.exe' -ArgumentList ([string]$Entry.Target) -ErrorAction Stop } 'OpenUrl' { Open-WcdUrl -Url ([string]$Entry.Target) } 'CheckPath' { } # presence already established by Test-WcdTargetPresent + 'CheckWinget'{ } # ditto - and this Action never installs anything 'CheckProcess' { $running = @(Get-Process -Name @($Entry.Target) -ErrorAction SilentlyContinue) if ($running.Count -eq 0) { @@ -97,6 +165,10 @@ function Set-WcdApplicationsConfiguration { declared Optional is a note; an absent required target is a warning naming the manifest key to fix. + CheckWinget targets need winget itself, which some images do not have. + It is probed once before the loop: absent, that is one warning and every + CheckWinget entry becomes a Manual Step rather than a failure. + .PARAMETER Targets Application Target entries to run, already filtered for the current Environment, Form Factor and selected Optional Tools by @@ -139,17 +211,53 @@ function Set-WcdApplicationsConfiguration { return @([pscustomobject]@{ Step = 'ApplicationsSkip'; Success = $true; Error = ''; Severity = 'INFO' }) } + # winget is absent from LTSC and stripped images. Probe once, before the + # loop: one honest cause and N actionable rows beats N identical failures + # all pointing back at the same missing App Installer. + $wingetAvailable = $true + if (@($Targets | Where-Object { $_.Action -eq 'CheckWinget' }).Count -gt 0) { + $wingetAvailable = Test-WcdWingetAvailable + if (-not $wingetAvailable) { + $wingetDetail = 'winget unavailable - App Installer not provisioned on this image' + Write-WcdLog -Path $resolvedLogPath -Level 'WARNING' -Message $wingetDetail + $results += [pscustomobject]@{ + Step = 'WingetUnavailable' + Success = $true + Error = $wingetDetail + Severity = 'WARNING' + RemedyKey = 'WingetMissing' + RemedyArgs = @() + } + } + } + foreach ($entry in @($Targets)) { $step = [string]$entry.Step Invoke-WcdProgressCallback -ProgressCallback $ProgressCallback -ModuleName $moduleName -StepKey $step -Event 'Start' + if ($entry.Action -eq 'CheckWinget' -and -not $wingetAvailable) { + # The cause is reported once above; each package is then a Manual + # Step rather than a package that is genuinely missing. The + # checklist row names it, so the detail does not repeat the name. + $detail = 'winget unavailable - verify by hand.' + + Write-WcdLog -Path $resolvedLogPath -Level 'INFO' -Message ('{0}: {1}' -f $entry.Name, $detail) + Invoke-WcdProgressCallback -ProgressCallback $ProgressCallback -ModuleName $moduleName -StepKey $step -Event 'Finish' -Kind 'warning' + $results += [pscustomobject]@{ Step = $step; Success = $true; Error = $detail; Severity = 'MANUAL' } + continue + } + try { if (-not (Test-WcdTargetPresent -Entry $entry)) { # Absent and declared Optional is a normal outcome on many # machines; absent and required is worth a warning. $severity = if ($entry.Optional) { 'INFO' } else { 'WARNING' } $kind = if ($entry.Optional) { 'success' } else { 'warning' } - $detail = '{0} not found at {1}' -f $entry.Name, $entry.Target + $detail = if ($entry.Action -eq 'CheckWinget') { + 'winget does not list {0} as installed ({1})' -f $entry.Name, $entry.Target + } else { + '{0} not found at {1}' -f $entry.Name, $entry.Target + } Write-WcdLog -Path $resolvedLogPath -Level 'INFO' -Message $detail Invoke-WcdProgressCallback -ProgressCallback $ProgressCallback -ModuleName $moduleName -StepKey $step -Event 'Finish' -Kind $kind @@ -160,7 +268,9 @@ function Set-WcdApplicationsConfiguration { Severity = $severity # An Optional target that is simply absent is a normal # outcome, so it gets a note but no call to action. - RemedyKey = if ($entry.Optional) { '' } else { 'TargetMissing' } + RemedyKey = if ($entry.Optional) { '' } + elseif ($entry.Action -eq 'CheckWinget') { 'WingetPackageMissing' } + else { 'TargetMissing' } RemedyArgs = @([string]$entry.Target, [string]$entry.Name) } continue @@ -186,6 +296,11 @@ function Set-WcdApplicationsConfiguration { } elseif ($entry.Action -eq 'CheckProcess') { $remedyKey = 'ProcessNotRunning' $remedyArgs = @([string]$entry.Name) + } elseif ($entry.Action -eq 'CheckWinget') { + # Reaching here means winget ran and failed, not that the + # package is absent - that is handled as a missing target. + $remedyKey = 'WingetCheckFailed' + $remedyArgs = @([string]$entry.Name, [string]$entry.Target) } else { $remedyKey = 'TargetLaunchFailed' $remedyArgs = @([string]$entry.Target, [string]$entry.Name) diff --git a/src/Invoke-WcdConfiguration.ps1 b/src/Invoke-WcdConfiguration.ps1 index bf83d4b..a712074 100644 --- a/src/Invoke-WcdConfiguration.ps1 +++ b/src/Invoke-WcdConfiguration.ps1 @@ -163,6 +163,7 @@ $T = if ($ScriptUI -eq 'EN') { Helpdesk = 'Helpdesk portal' Favorites = 'Browser favorites' Network = 'Network adapters' + Winget = 'App Installer (winget)' DiskHealth = 'Disk health' DiskFreeSpace = 'Free space' Tpm = 'TPM readiness' @@ -283,6 +284,9 @@ $T = if ($ScriptUI -eq 'EN') { TargetMissing = "Not found at {0}. Update Applications['{1}'].Target in WinContextDeploy.psd1, or remove the entry." TargetLaunchFailed = "Could not start {0}. Check Applications['{1}'].Target and Action in WinContextDeploy.psd1." ProcessNotRunning = 'Confirm {0} is installed and started, or mark the entry Optional in WinContextDeploy.psd1.' + WingetMissing = 'App Installer (winget) is not provisioned on this image, so the packages below could not be checked. Verify them by hand, or install App Installer from the Microsoft Store.' + WingetPackageMissing = "winget does not list {0} as installed. Confirm imaging delivered it, or fix the package id in Applications['{1}'].Target in WinContextDeploy.psd1." + WingetCheckFailed = "winget could not check {0}. Run 'winget list --id {1} --exact' by hand to see why." UnknownAction = "Unknown Action '{0}' for step '{1}'. Valid: {2}." RequiresAdmin = 'Requires Administrator. Relaunch elevated to apply.' PowerCfgFailed = 'powercfg refused the change. Check that no Group Policy pins the power plan.' @@ -329,6 +333,7 @@ $T = if ($ScriptUI -eq 'EN') { Helpdesk = 'Portail de soutien' Favorites = 'Favoris du navigateur' Network = 'Adaptateurs reseau' + Winget = 'App Installer (winget)' DiskHealth = 'Sante du disque' DiskFreeSpace = 'Espace libre' Tpm = 'Etat du TPM' @@ -449,6 +454,9 @@ $T = if ($ScriptUI -eq 'EN') { TargetMissing = "Introuvable a {0}. Corriger Applications['{1}'].Target dans WinContextDeploy.psd1, ou retirer l entree." TargetLaunchFailed = "Impossible de demarrer {0}. Verifier Applications['{1}'].Target et Action dans WinContextDeploy.psd1." ProcessNotRunning = 'Confirmer que {0} est installe et demarre, ou marquer l entree Optional dans WinContextDeploy.psd1.' + WingetMissing = 'App Installer (winget) n est pas provisionne sur cette image, donc les paquets ci-dessous n ont pas pu etre verifies. Les verifier a la main, ou installer App Installer depuis le Microsoft Store.' + WingetPackageMissing = "winget ne liste pas {0} comme installe. Confirmer que l imagerie l a livre, ou corriger l identifiant de paquet dans Applications['{1}'].Target dans WinContextDeploy.psd1." + WingetCheckFailed = "winget n a pas pu verifier {0}. Lancer 'winget list --id {1} --exact' a la main pour voir pourquoi." UnknownAction = "Action '{0}' inconnue pour l etape '{1}'. Valides: {2}." RequiresAdmin = 'Exige les droits Administrateur. Relancer en tant qu administrateur pour appliquer.' PowerCfgFailed = 'powercfg a refuse la modification. Verifier qu aucune GPO ne fige le mode de gestion d alimentation.' @@ -1481,6 +1489,12 @@ function Resolve-WcdAutomaticEntry { return New-WcdDiagnosticEntry -Label $Label -Kind 'warning' -Detail (Get-WcdAggregateDetail -Results $results -StepLabels $StepLabels) -Step $stepId } + # A Step the Module could not run, and deliberately handed back to the + # technician, is a Manual Step rather than a failure. + if ($severity -eq 'MANUAL') { + return New-WcdDiagnosticEntry -Label $Label -Kind 'manual' -Detail (Get-WcdAggregateDetail -Results $results -StepLabels $StepLabels) -Step $stepId + } + if ($missingStepKeys.Count -gt 0) { $missingLabels = @($missingStepKeys | ForEach-Object { if ($StepLabels.ContainsKey($_)) { $StepLabels[$_] } else { $_ } @@ -1725,6 +1739,13 @@ function Get-WcdFinalChecklistEntries { -OptionalTools $ExecutionOptions.OptionalTools | ForEach-Object { [string]$_.Step }) + # Config-Applications reports the winget probe once, and only when the + # manifest has CheckWinget entries at all. + if ($lookup.ContainsKey('WingetUnavailable')) { + $entries += Resolve-WcdAutomaticEntry -Label $T.Checklist.Winget -ResultLookup $lookup ` + -StepKeys @('WingetUnavailable') -StepLabels $StepLabels + } + foreach ($entry in @($Config.Applications)) { $step = [string]$entry.Step $name = [string]$entry.Name diff --git a/src/WcdHelpers.ps1 b/src/WcdHelpers.ps1 index 11e75ae..a015d19 100644 --- a/src/WcdHelpers.ps1 +++ b/src/WcdHelpers.ps1 @@ -313,7 +313,7 @@ function Get-WcdResultSeverity { A Step Result object. .OUTPUTS - [string] 'ERROR', 'WARNING' or 'INFO'. + [string] 'ERROR', 'WARNING', 'MANUAL' or 'INFO'. .EXAMPLE Get-WcdResultSeverity -Result ([pscustomobject]@{ Step = 'X'; Success = $false }) # ERROR @@ -561,6 +561,7 @@ function Get-WcdTechnicalStepLabels { 'DisplayLanguage' = 'Display language' 'KeyboardLayout' = 'Keyboard layout' 'ApplicationsSkip' = 'Applications skipped' + 'WingetUnavailable' = 'App Installer (winget)' 'DeviceManagerStatus' = 'Device Manager' 'DiskHealth' = 'Disk health' 'DiskFreeSpace' = 'Free space' diff --git a/tests/Config-Applications.Tests.ps1 b/tests/Config-Applications.Tests.ps1 index bb2e4d6..ee886dc 100644 --- a/tests/Config-Applications.Tests.ps1 +++ b/tests/Config-Applications.Tests.ps1 @@ -95,4 +95,102 @@ Describe 'Config-Applications' { $results[0].Success | Should -BeTrue $results[0].Error | Should -Match 'not found' } + + Context 'CheckWinget' { + BeforeAll { + # Hors du manifeste partage: les autres tests lancent toutes les + # cibles, et aucune ne doit appeler winget pour de vrai. + $script:WingetTargets = @( + @{ Step = 'AppWinget'; Name = 'PowerToys'; Action = 'CheckWinget'; Target = 'Microsoft.PowerToys' } + ) + $script:WingetOptionalTargets = @( + @{ Step = 'AppWingetOpt'; Name = 'Fancy tool'; Action = 'CheckWinget'; Target = 'Vendor.FancyTool'; Optional = $true } + ) + } + + It 'rapporte un paquet installe comme reussi' { + $logPath = Join-Path $TestDrive 'log_winget_ok.txt' + Mock -CommandName 'Test-WcdWingetAvailable' { $true } + Mock -CommandName 'Test-WcdWingetPackageInstalled' { $true } + + $results = @(Set-WcdApplicationsConfiguration -Targets $script:WingetTargets -OpenApps $true -LogPath $logPath) + + $results.Count | Should -Be 1 + $results[0].Step | Should -Be 'AppWinget' + $results[0].Success | Should -BeTrue + $results[0].Error | Should -BeNullOrEmpty + } + + It 'avertit quand le paquet n est pas installe' { + $logPath = Join-Path $TestDrive 'log_winget_absent.txt' + Mock -CommandName 'Test-WcdWingetAvailable' { $true } + Mock -CommandName 'Test-WcdWingetPackageInstalled' { $false } + + $results = @(Set-WcdApplicationsConfiguration -Targets $script:WingetTargets -OpenApps $true -LogPath $logPath) + + $results[0].Severity | Should -Be 'WARNING' + $results[0].Success | Should -BeTrue + $results[0].RemedyKey | Should -Be 'WingetPackageMissing' + } + + It 'traite un paquet optionnel absent comme une note' { + $logPath = Join-Path $TestDrive 'log_winget_optional.txt' + Mock -CommandName 'Test-WcdWingetAvailable' { $true } + Mock -CommandName 'Test-WcdWingetPackageInstalled' { $false } + + $results = @(Set-WcdApplicationsConfiguration -Targets $script:WingetOptionalTargets -OpenApps $true -LogPath $logPath) + + $results[0].Severity | Should -Be 'INFO' + $results[0].RemedyKey | Should -Be '' + } + + It 'signale une seule cause et passe les entrees en MANUAL quand winget est absent' { + $logPath = Join-Path $TestDrive 'log_winget_missing.txt' + Mock -CommandName 'Test-WcdWingetAvailable' { $false } + Mock -CommandName 'Test-WcdWingetPackageInstalled' { throw 'winget ne doit pas etre appele.' } + + $targets = @($script:WingetTargets + $script:WingetOptionalTargets) + $results = @(Set-WcdApplicationsConfiguration -Targets $targets -OpenApps $true -LogPath $logPath) + + $probe = @($results | Where-Object Step -eq 'WingetUnavailable') + $probe.Count | Should -Be 1 + $probe[0].Severity | Should -Be 'WARNING' + $probe[0].RemedyKey | Should -Be 'WingetMissing' + + # une cause honnete, et une ligne actionnable par paquet + @($results | Where-Object { $_.Step -like 'AppWinget*' }).Severity | Should -Be @('MANUAL', 'MANUAL') + Should -Invoke -CommandName 'Test-WcdWingetAvailable' -Times 1 -Exactly + } + + It 'traite un echec de winget comme une erreur, pas comme un paquet absent' { + $logPath = Join-Path $TestDrive 'log_winget_broken.txt' + Mock -CommandName 'Test-WcdWingetAvailable' { $true } + Mock -CommandName 'Test-WcdWingetPackageInstalled' { throw 'winget list failed for Microsoft.PowerToys (exit 5).' } + + $results = @(Set-WcdApplicationsConfiguration -Targets $script:WingetTargets -OpenApps $true -LogPath $logPath) + + $results[0].Severity | Should -Be 'ERROR' + $results[0].Success | Should -BeFalse + $results[0].RemedyKey | Should -Be 'WingetCheckFailed' + } + + It 'lit le code de sortie de winget: 0 installe, code introuvable absent, autre erreur' { + # -Scope 1 pose LASTEXITCODE dans le scope appelant, la ou + # Test-WcdWingetPackageInstalled le relit. + function winget.exe { + Set-Variable -Name 'LASTEXITCODE' -Value $script:WingetExit -Scope 1 + 'sortie winget' + } + + $script:WingetExit = 0 + Test-WcdWingetPackageInstalled -Id 'Microsoft.PowerToys' | Should -BeTrue + + # APPINSTALLER_CLI_ERROR_NO_APPLICATIONS_FOUND + $script:WingetExit = -1978335212 + Test-WcdWingetPackageInstalled -Id 'Microsoft.PowerToys' | Should -BeFalse + + $script:WingetExit = 5 + { Test-WcdWingetPackageInstalled -Id 'Microsoft.PowerToys' } | Should -Throw + } + } }