From d02c361fce67913aee4e902b18ec49b05328cafb Mon Sep 17 00:00:00 2001 From: Ivan Bondarev Date: Wed, 22 Jul 2026 23:32:26 +0300 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=D0=B0=D0=B2=D1=82=D0=BE-=D0=BE?= =?UTF-8?q?=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=8F=D0=BB=D0=BE=20=D0=BD=D0=B5=20=D1=82=D0=BE?= =?UTF-8?q?=D1=82=20=D1=84=D0=B0=D0=B9=D0=BB=20+=20=D0=BE=D1=82=D1=81?= =?UTF-8?q?=D1=83=D1=82=D1=81=D1=82=D0=B2=D0=B8=D0=B5=20winget=20=D0=B1?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D1=8F=D0=B5=D1=82=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MyAI-3y5i (P1). Проверка обновлений спрашивала "есть ли копия из галереи где-нибудь на машине", а ответ использовался так, будто это был ответ на вопрос "является ли выполняемый файл той самой копией". Оба условия штатно истинны одновременно: install.ps1 (с 2.15) ставит копию в %ProgramFiles%\WinClean, куда смотрит ярлык, а старая копия из Install-Script остаётся в Documents. Update-Script обновлял копию в Documents, печатал "Update complete! Please run WinClean again", выходил с кодом 0 - и ярлык продолжал запускать нетронутый старый файл. Бесконечно, без единой ошибки. Теперь выполняемый файл сравнивается с местом установки провайдера, и авто-обновление предлагается только когда это один и тот же файл. Остальным называется способ, который реально к ним применим. Ревью (16 раундов Codex + 4 агента) вскрыло, что задача шире исходной: - совет для не-галерейных копий предлагал Install-Script, а он СОЗДАЁТ вторую установку - то есть сам строил конфигурацию, породившую дефект; - при двух установках Update-Script нельзя нацелить (нет -Scope), поэтому авто-обновление теперь отключается с объяснением вместо мутации случайной копии. Тот же принцип, что Select-StorageSenseTask в 2.20; - весь путь был PowerShellGet-only: на машине только с PSResourceGet проверка падала, catch превращал это в "обновлений нет", ветка молча не работала. Теперь оба провайдера, и тот, что ОТВЕТИЛ при обнаружении, получает приоритет при обновлении; - Get-PSResource по умолчанию видит только CurrentUser, поэтому AllUsers-копия была невидима - и получала совет, создающий вторую установку; - обновление проверяется по версии в выполняемом файле; отказ проверки больше не неотличим от "у вас последняя версия"; - успешный путь пишет result JSON перед exit, иначе потребитель видел код 0 и отсутствующий артефакт. MyAI-iodp (P3). Отсутствие winget было ERROR, а код возврата считается только по ErrorsCount - значит машина без App Installer ВСЕГДА завершалась с кодом 1 при девяти выполненных фазах. Теперь WARNING. ERROR остаётся за случаем, когда winget есть и не отработал: провал самой проверки и необработанное исключение. Добавлено поле result JSON AppUpdatesStatus - понижение убрало единственный машиночитаемый признак "проверяли или нет". Факты, установленные экспериментом, а не рассуждением: InstalledLocation у скрипта это папка Scripts без версии; провайдеры видят установки друг друга; Select-Object -Unique регистрозависим; GetFullPath не убирает завершающий разделитель; [Version] 2.21 меньше 2.21.0; оба провайдера бросают и при "не установлено", но Get-InstalledScript без -Name возвращает пустой список - на этом и построено различение отказа от отсутствия копии. 452 -> 571 теста. Все защиты проверены мутациями (21 мутация, все пойманы). --- CHANGELOG.md | 73 +++ CLAUDE.md | 10 +- CONTRIBUTING.md | 4 +- README.md | 18 + README_RU.md | 18 + WinClean.ps1 | 780 ++++++++++++++++++++++++++++-- docs/result-json.md | 19 +- docs/troubleshooting.md | 44 ++ tests/Helpers.Tests.ps1 | 1016 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 1922 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fb3391..ab95d78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **The self-update updated a file that was not being run, and reported success.** The + check asked whether a PowerShell Gallery copy existed *anywhere on the machine*, and the + answer was acted on as if it had been *"is the file I am running that copy"*. Both are + routinely true at once: `install.ps1` (2.15) installs into `%ProgramFiles%\WinClean`, + which is what the desktop shortcut starts, while an older `Install-Script` copy can + still sit in `Documents\PowerShell\Scripts`. `Update-Script` then updated the copy in + Documents, printed "Update complete! Please run WinClean again to use the new version", + and exited 0 - and the shortcut kept starting the untouched old file, run after run, + with no error anywhere. The running file is now compared against the Gallery install + location, and a self-update is offered only when they are the same file. Every other + copy is told the method that actually applies to it. Not a 2.20 regression: the branch + dates from 2.10, and two parallel installations became an ordinary state in 2.15 +- **The advice shown to non-Gallery copies created the very problem above.** It read + `Install-Script -Name WinClean -Scope CurrentUser -Force`, which installs a *second* + copy in Documents and leaves the running one untouched - building the two-installation + state that the update logic then misread +- **An update that reported success is now verified against the file on disk.** "The + cmdlet did not throw" is not "the running file is now the new version"; the version is + read back from the executing script afterwards, and anything short of the expected + version is reported as a warning instead of being announced as complete. The check + states the final version of that file, which is what the next run will use; it does not + attempt to prove which actor put it there. Measured on 2026-07-22 with each provider in + turn: either reports the other's install, so detection does not depend on which one + performed it +- **The self-update now works on a machine that has only PSResourceGet.** Every step used + to be PowerShellGet: `Find-Script` for discovery and `Update-Script` for the update. + Where PowerShellGet is absent, discovery threw and the surrounding catch turned that into + "no update available" - it logged a warning, but no update was ever offered, and the + manual instruction named a command that machine cannot run. Discovery, the update and the + printed advice now each use whichever provider is present, and a discovery failure is + reported as a counted warning instead of resembling "you are up to date" +- **Several Gallery installations now disable the automatic update instead of guessing.** + `AllUsers` and `CurrentUser` copies can coexist; `Update-Script` has no `-Scope` at all, + and while `Update-PSResource` does have one, WinClean does not map a matched install + location back to a scope, so nothing currently directs the update at the copy being + executed. Verifying afterwards would report the miss honestly, but only after the unused + copy had already been modified. WinClean now says several installations exist, prints the + path it is running from, and leaves them alone - the same rule the Storage Sense lookup + follows: an ambiguous target is not acted on and the reason is stated +- **An `AllUsers` copy is now visible to PSResourceGet detection.** `Get-PSResource` + defaults to `CurrentUser` and searches only the Documents paths, so on a machine with + PSResourceGet but no PowerShellGet an `AllUsers` install - the natural scope for a script + that requires administrator - was invisible, and the running copy was told it did not + come from the Gallery and pointed at the installer, adding a second installation + +### Changed + +- **A failed self-update is a warning, not an error.** It was logged at `ERROR` without + incrementing the error counter, so the run printed "Update failed" and still exited 0 - + a contradiction in the one place that has to be believable. Failing to update the script + is not a failure of the maintenance that was actually requested, so it is now a counted + warning and the exit code agrees with the log +- **A missing `winget` is a warning, not an error.** The exit code is computed from the + error count alone, so every run on a machine without App Installer ended with code 1 + while all nine phases completed - which any scheduler, CI job or test harness reads as a + failed run. The absence of an optional third-party tool is a property of the machine, + not a failure of the run, by the same rule that makes a machine without Docker a normal + machine here. A `winget` that is present and then fails is still reported, at a severity + that follows whether the run can carry on: the upgrade check failing outright and an + unhandled error remain errors, while a stale source, a timeout or a partly failed batch + are warnings. Behaviour unchanged since 1.2; found by a full end-to-end run of the + published release, because ordinary stand runs pass `-SkipUpdates` and never reach it + +### Documentation + +- README (EN and RU) gained an **Updating an existing installation** table: update the + copy you actually run, with the method you installed it with +- `docs/troubleshooting.md` explains the "update complete but the version never changes" + symptom, how to inspect and clean up a two-installation machine, and states plainly that + a missing `winget` no longer makes the run exit non-zero + Planned for a later release: quick system health section (SMART, image integrity, WinRE), Windows Update driver listing, run-to-run delta and HTML report. See CLAUDE.md. diff --git a/CLAUDE.md b/CLAUDE.md index ba0d101..01c38d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,8 +43,8 @@ CleanScript/ │ └── logo.svg # Логотип проекта ├── get.ps1 # Bootstrap: разовый запуск одной командой (irm | iex) ├── install.ps1 # Bootstrap: установка/обновление + ярлык (RunAs admin) -├── tests/ # Pester тесты (452 всего; счётчик - прогоном, не грепом) -│ ├── Helpers.Tests.ps1 # Unit-тесты helper-функций (159, дот-сорсят продукт - нужны права админа) +├── tests/ # Pester тесты (571 всего; счётчик - прогоном, не грепом) +│ ├── Helpers.Tests.ps1 # Unit-тесты helper-функций (278, дот-сорсят продукт - нужны права админа) │ ├── Fixes.Tests.ps1 # Валидационные тесты исправлений (183) │ ├── Integration.Tests.ps1 # Интеграционные тесты в песочнице ФС (67, требуют admin) │ ├── StandHelpers.Tests.ps1 # Чистые хелперы стенда: dead-man + версионный гейт ассертов (17, без admin) @@ -198,7 +198,7 @@ Publish-PSResource -Path .\WinClean.ps1 -Repository PSGallery -ApiKey $env:PSGAL **Проверки (4 job'а, счёт сверять gh run view --json jobs, а не этой строкой):** 1. **lint** - PSScriptAnalyzer (Error+Warning) через общий ools/Invoke-Lint.ps1 - тот же, что зовёт релиз-гейт 2. **syntax** - Проверка синтаксиса PowerShell -3. **test** - Pester тесты (452; интеграционные требуют admin - на GitHub runners это выполняется) +3. **test** - Pester тесты (571; интеграционные требуют admin - на GitHub runners это выполняется) 4. **smoke** - прогон -ReportOnly + геометрия рамок + result JSON **Исключения PSScriptAnalyzer** (допустимые для CLI): @@ -208,7 +208,7 @@ Publish-PSResource -Path .\WinClean.ps1 -Repository PSGallery -ApiKey $env:PSGAL ### Pester тесты (v2.13+) -- `tests/Helpers.Tests.ps1` - 159 unit-тестов (дот-сорсят WinClean.ps1), `tests/Fixes.Tests.ps1` - 183, `tests/Integration.Tests.ps1` - 67 (песочница ФС, требуют admin), `tests/StandHelpers.Tests.ps1` - 17 (без admin), `tests/Docs.Tests.ps1` - 26 (гварды доков, без admin) +- `tests/Helpers.Tests.ps1` - 278 unit-тестов (дот-сорсят WinClean.ps1), `tests/Fixes.Tests.ps1` - 183, `tests/Integration.Tests.ps1` - 67 (песочница ФС, требуют admin), `tests/StandHelpers.Tests.ps1` - 17 (без admin), `tests/Docs.Tests.ps1` - 26 (гварды доков, без admin) - Особенности: функции в BeforeAll (не AST), regex для locale-независимости, отдельные It блоки --- @@ -379,7 +379,7 @@ pwsh tools/Invoke-ReleaseCheck.ps1 # версия во всех pwsh tools/Invoke-ReleaseCheck.ps1 -IncludeStand # + боевой прогон на VM (минуты) pwsh tools/Invoke-ReleaseCheck.ps1 -VerifyPublished # ПОСЛЕ выпуска: ассеты релиза и SHA256 -Invoke-Pester ./tests -Output Detailed # 452 Pester тестов (считать прогоном, не грепом) +Invoke-Pester ./tests -Output Detailed # 571 Pester тестов (считать прогоном, не грепом) pwsh tools/Invoke-SmokeTest.ps1 # Смоук: ReportOnly + геометрия UI pwsh tools/proxmox/Invoke-StandTest.ps1 -Mode Report # Стенд на Proxmox (RU=VM 190, EN: -ConfigPath ...en.json = VM 191) # Ночная матрица: cron 03:30 на proxmos (/opt/winclean-stand, /etc/cron.d/winclean-stand), отчёт в Telegram diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de2057a..667aae5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -128,7 +128,7 @@ Invoke-Pester ./tests/Integration.Tests.ps1 All PRs automatically run: - PSScriptAnalyzer (linting) - Syntax check -- Pester tests (452 tests) +- Pester tests (571 tests) ### Release-impacting changes @@ -251,7 +251,7 @@ Invoke-Pester ./tests/Integration.Tests.ps1 Все PR автоматически проходят: - PSScriptAnalyzer (линтинг) - Проверка синтаксиса -- Pester тесты (452 тестов) +- Pester тесты (571 тестов) ### Изменения, влияющие на релиз diff --git a/README.md b/README.md index 1829e1e..ac4780b 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,24 @@ cd WinClean .\WinClean.ps1 ``` +### 🔄 Updating an existing installation + +Update the copy you actually run, with the method you installed it with: + +| Installed with | Update with | +|----------------|-------------| +| `install.ps1` (desktop shortcut) | re-run `irm https://raw.githubusercontent.com/bivlked/WinClean/main/install.ps1 \| iex` from an elevated terminal | +| `get.ps1` one-liner | nothing to do - every run downloads the latest release | +| PowerShell Gallery (`Install-Script`) | `Update-Script -Name WinClean` | +| PowerShell Gallery (`Install-PSResource`) | `Update-PSResource -Name WinClean` | +| Manual download or clone | download the latest release again | + +When a newer version exists, WinClean names the option that applies to the copy you +launched (the check runs at startup unless `-SkipUpdates` is passed, and says nothing when +you are already up to date). Only a copy installed from the Gallery can update itself; the others cannot, +and are told what to do instead rather than being offered an update that would leave the +running file untouched. + --- diff --git a/README_RU.md b/README_RU.md index d0fec39..e54363c 100644 --- a/README_RU.md +++ b/README_RU.md @@ -102,6 +102,24 @@ cd WinClean .\WinClean.ps1 ``` +### 🔄 Обновление установленной копии + +Обновляйте ту копию, которую реально запускаете, тем же способом, каким ставили: + +| Как установлено | Как обновить | +|-----------------|--------------| +| `install.ps1` (ярлык на рабочем столе) | повторно выполнить `irm https://raw.githubusercontent.com/bivlked/WinClean/main/install.ps1 \| iex` в окне с правами администратора | +| One-liner `get.ps1` | ничего не нужно: каждый запуск скачивает последний релиз | +| PowerShell Gallery (`Install-Script`) | `Update-Script -Name WinClean` | +| PowerShell Gallery (`Install-PSResource`) | `Update-PSResource -Name WinClean` | +| Ручная загрузка или клон | скачать последний релиз заново | + +Когда в галерее есть версия новее, WinClean называет тот вариант, который подходит именно +запущенной копии (проверка идёт при запуске, если не передан `-SkipUpdates`, и молчит, +когда у вас уже последняя версия). Обновить себя может только копия, установленная из галереи; остальным +скрипт говорит, что делать, вместо предложения обновления, которое оставило бы +выполняемый файл нетронутым. + --- diff --git a/WinClean.ps1 b/WinClean.ps1 index ee51e92..7b05518 100644 --- a/WinClean.ps1 +++ b/WinClean.ps1 @@ -340,6 +340,13 @@ function New-RunStats { # apps actually installed (it silently skips pinned/manifest-less/UAC-cancelled ones), # so we only ever know how many it OFFERED. Naming it "installed" was a false claim. AppUpdatesOffered = 0 + # v2.21: why the app-update phase produced the count it did. Added because demoting a + # missing winget from error to warning removed the only machine-readable way to tell + # "checked, nothing to upgrade" from "could not check at all" - both are + # AppUpdatesOffered = 0 with WarningsCount incremented by one of many possible causes. + # 'not-run' | 'checked' | 'check-failed' | 'skipped-parameter' | 'skipped-offline' + # | 'skipped-no-winget' + AppUpdatesStatus = 'not-run' WarningsCount = 0 ErrorsCount = 0 RebootRequired = $false @@ -829,13 +836,552 @@ function Test-PSGalleryConnection { } } +function Test-PathInsideRoot { + <# + .SYNOPSIS + Tells whether a path lies inside a directory + .DESCRIPTION + Pure decision, added in v2.21 for the update-channel rule below. + The root gets a trailing separator before the comparison, so C:\Temp2\x is not + read as living inside C:\Temp. Case-insensitive, matching the file system. + An unusable path or root answers "not inside" rather than throwing: the only + consumer picks wording from the answer, and a wrong "yes" prints an instruction + that does not apply to the copy the user is running. + #> + param( + [AllowNull()][string]$Path, + [AllowNull()][string]$Root + ) + + if ([string]::IsNullOrWhiteSpace($Path) -or [string]::IsNullOrWhiteSpace($Root)) { return $false } + + try { + $fullPath = [System.IO.Path]::GetFullPath($Path) + $fullRoot = [System.IO.Path]::GetFullPath($Root).TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar + } catch { + return $false + } + + return $fullPath.StartsWith($fullRoot, [System.StringComparison]::OrdinalIgnoreCase) +} + +function Get-ScriptUpdateChannel { + <# + .SYNOPSIS + Decides how THIS running copy of WinClean can be updated + .DESCRIPTION + Pure decision, added in v2.21 to fix an update that reported success while + updating a different file. + Until now the question asked was "does a Gallery copy exist anywhere on this + machine", and the answer was acted on as if it had been "is the file I am + running that copy". Both are commonly true at once: install.ps1 (v2.15) puts a + copy in %ProgramFiles%\WinClean while an older Install-Script copy still sits in + Documents\PowerShell\Scripts. Update-Script then updated the copy in Documents, + printed "run WinClean again to use the new version", and the shortcut kept + starting the untouched Program Files copy - forever, with no error anywhere. + Returns one of: + gallery - the running file IS the only Gallery copy; it can update itself + gallery-ambiguous - it matches a Gallery copy, but several installs exist + installer - it lives in %ProgramFiles%\WinClean, so install.ps1 updates it + oneliner - it lives under TEMP, so get.ps1 downloaded it for this run + manual - somewhere else; only a manual download applies + unknown - the path is unavailable, so nothing may be promised + Ambiguity resolves away from 'gallery' on purpose: the cost of the wrong answer + is asymmetric. Printing an instruction to a copy that could have updated itself + is a minor annoyance; auto-updating a file nobody is running is the defect. + That is also why several installs (AllUsers and CurrentUser can coexist) refuse + the automatic path outright, raised in review. PowerShellGet's Update-Script has no + -Scope at all; PSResourceGet's Update-PSResource does have one, but WinClean does + not map a matched install location back to a scope, and the updater is chosen by + which provider answered rather than by which install matched. So the target cannot + currently be named, and declining beats guessing: verifying afterwards would report + a miss honestly, but only after the unused copy had already been modified. Same rule + as Select-StorageSenseTask. Aiming the PSResourceGet updater by scope is possible + and is left as future work rather than claimed here. + Known limit: the comparison is lexical. A path reached through a junction or an + 8.3 alias fails to match and is merely shown an instruction (safe). The reverse - + two files differing only in case inside one case-sensitive directory - would match + wrongly, and is left unhandled as a configuration this script does not support. + 'installer' and 'oneliner' describe WHERE the file is, which is what decides the + right instruction; they do not claim to prove which tool put it there. + #> + param( + [AllowNull()][string]$ExecutingPath, + [AllowNull()][string[]]$GalleryLocation, + [AllowNull()][string]$ProgramFilesRoot = [Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles), + [AllowNull()][string]$TempRoot = [System.IO.Path]::GetTempPath() + ) + + if ([string]::IsNullOrWhiteSpace($ExecutingPath)) { return 'unknown' } + try { $fullPath = [System.IO.Path]::GetFullPath($ExecutingPath) } catch { return 'unknown' } + + # Compare the full file path, not its folder: a Gallery install owns exactly + # WinClean.ps1 inside InstalledLocation, and a differently named copy sharing that + # folder is not the file the provider would replace. + # Deduplicate case-insensitively, matching the comparison below and the file system. + # Select-Object -Unique is case-SENSITIVE (verified, 22.07.2026), so the two providers + # reporting one install with different casing would have looked like two installs and + # silently switched a perfectly updatable machine to the refusal path. + $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $candidates = @() + foreach ($location in @($GalleryLocation)) { + if ([string]::IsNullOrWhiteSpace($location)) { continue } + try { $candidate = [System.IO.Path]::GetFullPath((Join-Path $location 'WinClean.ps1')) } catch { continue } + if ($seen.Add($candidate)) { $candidates += $candidate } + } + + foreach ($candidate in $candidates) { + if ([string]::Equals($fullPath, $candidate, [System.StringComparison]::OrdinalIgnoreCase)) { + if ($candidates.Count -gt 1) { return 'gallery-ambiguous' } + return 'gallery' + } + } + + $installerRoot = if ([string]::IsNullOrWhiteSpace($ProgramFilesRoot)) { $null } + else { Join-Path $ProgramFilesRoot 'WinClean' } + if (Test-PathInsideRoot -Path $fullPath -Root $installerRoot) { return 'installer' } + if (Test-PathInsideRoot -Path $fullPath -Root $TempRoot) { return 'oneliner' } + return 'manual' +} + +function Get-UpdateVerification { + <# + .SYNOPSIS + Decides whether an update that reported no error actually replaced the file + .DESCRIPTION + Pure decision, added in v2.21. "The cmdlet did not throw" is not "the file on + disk is now the new version" - the whole point of this release's predecessor was + that operations reporting success without doing anything are the expensive kind + of defect, and an updater is the last place to trust a silent success. + Returns Applied and Reason ('applied' | 'unchanged' | 'unreadable'). + An unreadable or unparsable version is never 'applied': that is the state where + nothing is known, and claiming success there is the behaviour being removed. + #> + param( + [AllowNull()][string]$ExpectedVersion, + [AllowNull()][string]$ObservedVersion + ) + + $observed = $null + $expected = $null + if (-not [Version]::TryParse([string]$ObservedVersion, [ref]$observed) -or + -not [Version]::TryParse([string]$ExpectedVersion, [ref]$expected)) { + return @{ Applied = $false; Reason = 'unreadable' } + } + + # Missing components are -1 in [Version], not 0, so "2.21" compares as LESS than + # "2.21.0" (raised in review). The Gallery is free to report either form for the same + # release, and without this the check would announce "the update did not apply" after + # a perfectly good update - a false alarm in the one place whose job is to be trusted. + $observed = [Version]::new($observed.Major, $observed.Minor, + [math]::Max(0, $observed.Build), [math]::Max(0, $observed.Revision)) + $expected = [Version]::new($expected.Major, $expected.Minor, + [math]::Max(0, $expected.Build), [math]::Max(0, $expected.Revision)) + + if ($observed -lt $expected) { return @{ Applied = $false; Reason = 'unchanged' } } + return @{ Applied = $true; Reason = 'applied' } +} + +function Get-ScriptFileVersion { + <# + .SYNOPSIS + Reads the .VERSION line out of a WinClean.ps1 file on disk + .DESCRIPTION + Added in v2.21 to verify an update against the file actually being run, rather + than against what a package provider believes it installed. PowerShell reads a + script into memory before executing it, so the running file can be replaced and + re-read while the current run continues. + Returns the version string, or $null when the file cannot be read or carries no + .VERSION line - both mean "not verified", never "verified". + #> + param([AllowNull()][string]$Path) + + if ([string]::IsNullOrWhiteSpace($Path)) { return $null } + try { $head = Get-Content -LiteralPath $Path -TotalCount 40 -ErrorAction Stop } catch { return $null } + + foreach ($line in $head) { + if ($line -match '^\s*\.VERSION\s+([\d.]+)\s*$') { return $Matches[1] } + } + return $null +} + +function Get-InstalledWinCleanLocation { + <# + .SYNOPSIS + Returns the folders holding a PowerShell Gallery copy of WinClean + .DESCRIPTION + Two providers can own that copy: PowerShellGet (Install-Script) and PSResourceGet + (Install-PSResource). Measured on 22.07.2026 with each in turn: both report the + other's install, because they share the InstalledScriptInfos metadata, and for a + script InstalledLocation is the Scripts folder itself - unlike modules, it carries + no version subfolder. Both are still queried, because which provider ships is a + property of the PowerShell version rather than of this machine. PSResourceGet is + asked for AllUsers explicitly, because its -Scope defaults to CurrentUser. + An array: CurrentUser and AllUsers installs can coexist. THROWS when no provider + covered the machine and at least one query failed - including when some locations + WERE found, because a partial list is not a smaller answer: a hidden install turns + an ambiguous target back into a confident one. An unreadable machine must not be + reported as a machine with no Gallery copy either, because that answer sends the + caller on to advise an installer command and build a second installation. + #> + $locations = @() + $failures = @() + $answered = $false + + # Each provider is isolated (raised in review): -ErrorAction SilentlyContinue only + # covers non-terminating errors, so a broken PowerShellGet used to abort this function + # outright and PSResourceGet was never asked - turning a repairable half-outage into + # "no Gallery copy exists", which is exactly the wrong answer to give this caller. + if (Get-Command Get-InstalledScript -ErrorAction SilentlyContinue) { + try { + # Enumerated without -Name and filtered here (raised in review, verified + # 22.07.2026): asking for a specific name raises a plain Exception when it is + # not installed, which cannot be told from a real outage without matching a + # localised message - so the query had to run with SilentlyContinue and a + # suppressed failure then passed as an authoritative "no copy installed". + # Listing everything returns an EMPTY COLLECTION when nothing is installed, so + # -ErrorAction Stop now separates the two properly. This provider enumerates + # both scopes, so one completed call covers the machine. + $locations += @(Get-InstalledScript -ErrorAction Stop | + Where-Object { $_.Name -eq 'WinClean' } | + ForEach-Object { $_.InstalledLocation }) + $answered = $true + } catch { + $failures += $_ + Write-Log "PowerShellGet could not be queried for installed copies: $_" -Level DETAIL + } + } + if (Get-Command Get-PSResource -ErrorAction SilentlyContinue) { + # -ErrorAction Stop here too, but for a different reason than above: measured + # 22.07.2026, this provider raises a TYPED ResourceNotFoundException for "nothing + # installed", so the two outcomes are separated by exception type rather than by + # asking a question that cannot fail. + $currentUserRead = $false + try { + $locations += @(Get-PSResource -Name 'WinClean' -ErrorAction Stop | + Where-Object { $_.Type -eq 'Script' } | + ForEach-Object { $_.InstalledLocation }) + $currentUserRead = $true + } catch { + if ($_.Exception.GetType().Name -eq 'ResourceNotFoundException') { + $currentUserRead = $true # answered: this scope holds no copy + } else { + $failures += $_ + Write-Log "PSResourceGet could not be queried for installed copies: $_" -Level DETAIL + } + } + + # AllUsers has to be asked for explicitly (raised in review, verified here): + # Get-PSResource's -Scope is not nullable, so an unbound call means CurrentUser and + # searches only the Documents paths. PowerShellGet's Get-InstalledScript enumerates + # both scopes, which is why this was invisible on any machine that has it - and why + # it mattered exactly on the PSResourceGet-only machine this release added support + # for. AllUsers is the natural scope for a script that requires administrator, so + # missing it classified a Gallery copy as 'manual' and advised install.ps1, adding a + # second installation. + # Support is read from the cmdlet's own metadata rather than tried and ignored + # (raised in review): "this build has no -Scope" is a limitation to accept, but any + # OTHER failure means the AllUsers half went unread, and swallowing that would hide + # precisely the installation this branch exists to find. + # Tracked per scope (raised in review): a single "somebody answered" flag let a + # successful CurrentUser query mask a failed AllUsers one, and AllUsers is the half + # this whole branch exists to read. This provider counts as having covered the + # machine only when BOTH scopes were read - or when the build predates -Scope, which + # is an accepted limitation rather than a failure. + $allUsersRead = $true + if ((Get-Command Get-PSResource).Parameters.ContainsKey('Scope')) { + $allUsersRead = $false + try { + $locations += @(Get-PSResource -Name 'WinClean' -Scope AllUsers -ErrorAction Stop | + Where-Object { $_.Type -eq 'Script' } | + ForEach-Object { $_.InstalledLocation }) + $allUsersRead = $true + } catch { + if ($_.Exception.GetType().Name -eq 'ResourceNotFoundException') { + $allUsersRead = $true + } else { + $failures += $_ + Write-Log "PSResourceGet could not be queried for AllUsers copies: $_" -Level DETAIL + } + } + } + + if ($currentUserRead -and $allUsersRead) { $answered = $true } + } + + # Nothing found AND something failed is not the same as nothing installed (raised in + # review). Treating them alike classified the running file as 'manual' and printed + # "this copy did not come from the Gallery" plus an installer command - which would add + # a SECOND installation next to the one that was merely unreadable, building the very + # state this release exists to stop misreporting. The caller turns this into a warning + # and offers nothing, which is the honest answer when the machine cannot be read. + # An absent provider is not a failure: a machine with no package provider at all + # legitimately has no Gallery copy. + # Keyed on "nobody answered", not "somebody failed" (raised in review): with a broken + # PowerShellGet beside a working PSResourceGet that legitimately reports no copy, a + # failure count above zero would raise a warning on a machine that answered correctly. + # Coverage alone decides, NOT emptiness (raised in review): with CurrentUser returning + # the running copy while the AllUsers query failed, a non-empty list looked like a + # complete answer - and a hidden second install turns 'gallery-ambiguous' back into + # 'gallery', re-enabling exactly the automatic update whose target cannot be resolved. + # A partial list is not a smaller answer, it is a different question answered. + if (-not $answered -and $failures.Count -gt 0) { + throw "installed copies could not be enumerated: $($failures[-1])" + } + + # Case-insensitive, for the reason given in Get-ScriptUpdateChannel: both providers + # report the same install, and differing casing between them must not read as two. + # Normalised first (raised in review), so "C:\Scripts" and "C:\Scripts\" are one + # location here too - this function promises distinct locations, and the caller is not + # the only thing entitled to rely on that. + $unique = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $result = @() + foreach ($location in $locations) { + if ([string]::IsNullOrWhiteSpace($location)) { continue } + $normalized = try { [System.IO.Path]::GetFullPath($location) } catch { $location } + # GetFullPath keeps a trailing separator, so "C:\Scripts" and "C:\Scripts\" would + # still be two. Trimmed everywhere except a root, where the separator is meaningful: + # "C:\" is the root while "C:" means the current directory on that drive. + $root = try { [System.IO.Path]::GetPathRoot($normalized) } catch { '' } + if ($normalized.Length -gt $root.Length) { $normalized = $normalized.TrimEnd('\', '/') } + if ($unique.Add($normalized)) { $result += $normalized } + } + return $result +} + +function Select-UpdateCommand { + <# + .SYNOPSIS + Picks the cmdlet that should perform the update on this machine + .DESCRIPTION + Added in v2.21, raised in review. Choosing the updater by mere presence sent a + machine whose PowerShellGet is installed but broken - an unregistered PSGallery, + say - straight back to Update-Script, even though discovery had just succeeded + through PSResourceGet. The provider that answered is evidence about which one + works, so it goes first; the other remains as a fallback for the case where the + answering provider has no updater available. + Returns the command name, or $null when neither exists. + #> + param([AllowNull()][string]$Provider) + + $order = if ($Provider -eq 'PSResourceGet') { @('Update-PSResource', 'Update-Script') } + else { @('Update-Script', 'Update-PSResource') } + + foreach ($command in $order) { + if (Get-Command $command -ErrorAction SilentlyContinue) { return $command } + } + return $null +} + +function Wait-ForKeyPress { + <# + .SYNOPSIS + Best-effort "press any key" pause for the update prompts + .DESCRIPTION + Split out in v2.21 for two reasons. It centralises the guard - Test-InteractiveConsole + can be satisfied by a host whose RawUI still refuses ReadKey, and an exception there + must never abort a maintenance run or a COMPLETED update. + It also makes the interactive branches testable at all: RawUI.ReadKey blocks on a + real console, so a test that reached it hung the whole suite until it was killed. + A named function can be mocked; a method call on $Host cannot. + #> + # The failure is recorded rather than erased: at the non-Gallery prompt this pause is + # what gives the user time to read the instruction the whole release exists to deliver, + # and a host that refuses ReadKey turns it into a no-op that scrolls past (raised in + # review). DETAIL, because it changes nothing about the run's outcome. + try { $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") } + catch { Write-Log "Console did not accept a keypress, continuing without the pause: $_" -Level DETAIL } +} + +function Get-UpdateInstruction { + <# + .SYNOPSIS + The correct way to update the copy identified by Get-ScriptUpdateChannel + .DESCRIPTION + Split out in v2.21 so the advice can be tested. The old code had exactly two + messages, and the one shown to every non-Gallery copy advised + "Install-Script -Name WinClean" - which installs a SECOND copy in Documents and + leaves the running one untouched. That is not a hint that fails to help; it + builds the two-installation state this release exists to stop misreporting. + Returns the lines to print. + #> + param( + [AllowNull()][string]$Channel, + [AllowNull()][string]$ExecutingPath, + [AllowNull()][string]$Provider + ) + + $installer = ' irm https://raw.githubusercontent.com/bivlked/WinClean/main/install.ps1 | iex' + + # Listing commands for the two-installation cases. Both providers are shown because + # either can report the other's install; whichever module is absent simply reports an + # unknown command, which is why the caller is told to expect that (raised in review - + # -ErrorAction cannot suppress a missing command, only a failing one). + $inspect = @( + ' Get-InstalledScript -Name WinClean -ErrorAction SilentlyContinue |', + ' Select-Object Version, InstalledLocation', + ' Get-PSResource -Name WinClean -ErrorAction SilentlyContinue |', + ' Where-Object { $_.Type -eq ''Script'' } | Select-Object Version, InstalledLocation', + ' (one of the two may not exist on this machine - that is expected)' + ) + $running = if ([string]::IsNullOrWhiteSpace($ExecutingPath)) { @() } + else { @(" The file you are running now is:", " $ExecutingPath") } + + switch ($Channel) { + 'gallery' { + # Name the command this machine actually has, preferring the provider that + # discovery just proved works (raised in review). Advising Update-Script on a + # PSResourceGet-only machine is advice that cannot be run; naming either one + # where NEITHER exists is the same mistake twice. + $manual = Select-UpdateCommand -Provider $Provider + if ($manual) { return @(" To update manually: $manual -Name WinClean") } + # "no update command is available" rather than "no provider is installed": + # Get-Command proves the former, and a module that fails to auto-load looks + # identical to one that is absent + return @( + ' No PowerShell update command is available, so this copy cannot update itself.', + ' Download the latest release: https://github.com/bivlked/WinClean/releases/latest' + ) + } + 'gallery-ambiguous' { + # Several Gallery installs exist and Update-Script cannot be aimed at one of + # them, so WinClean declines to touch any (raised in review). Naming the running + # path matters here: it is the only way the reader can tell the copies apart. + # Must end with something the reader can actually do (raised in review): + # removing an install is only safe when the copies differ, so the always-works + # answer - replace the printed file from the release - is stated first. + return @( + ' Several PowerShell Gallery installations of WinClean are present, and WinClean', + ' cannot tell which one an automatic update would change - so it did not try.' + ) + $running + @( + ' Update it directly by replacing that file with the latest release:', + ' https://github.com/bivlked/WinClean/releases/latest', + ' To stop this recurring, list the installations and remove the ones you do not run:' + ) + $inspect + } + 'gallery-unverified' { + # Shown after an update that reported success but left the running file at the + # old version. Raised in review: this branch used to print the 'manual' advice, + # telling a copy that IS Gallery-managed that it is not, and pointing it at + # install.ps1 - which would add a second installation, the very state that made + # the update target the wrong file in the first place. + return @( + ' The provider may have updated a different installation than the one you are running.' + ) + $running + @( + ' List the copies present and compare their locations with the path above:' + ) + $inspect + @( + ' Or download the latest release manually: https://github.com/bivlked/WinClean/releases/latest' + ) + } + 'installer' { + # Describes where the file is, not who put it there (the header of + # Get-ScriptUpdateChannel says the same): the instruction is right either way, + # because re-running the installer replaces exactly this location + return @( + ' This copy lives where install.ps1 installs. Update it by re-running the installer', + ' in an elevated terminal:', + $installer + ) + } + 'oneliner' { + return @( + ' This copy is running from a temporary folder, which is where get.ps1 puts the', + ' release it downloads - and it downloads the latest one every time:', + ' irm https://raw.githubusercontent.com/bivlked/WinClean/main/get.ps1 | iex' + ) + } + default { + # 'manual', 'unknown' and anything unforeseen: never advise Install-Script, + # which would add a copy instead of updating this one. + # States what was observed, not where the file came from (raised in review): + # the location list can also be short because a provider could not be read, and + # asserting provenance on that basis is a claim the code cannot back. + $lead = if ($Channel -eq 'unknown') { + ' The location of this copy could not be determined, so it cannot update itself.' + } else { + ' This copy does not match a PowerShell Gallery installation, so it cannot update itself.' + } + return @( + $lead, + ' Install it properly (creates an elevated desktop shortcut, updates in place):', + $installer, + ' Or download the release manually: https://github.com/bivlked/WinClean/releases/latest' + ) + } + } +} + +function Find-GalleryWinClean { + <# + .SYNOPSIS + Asks the PowerShell Gallery for the latest published WinClean + .DESCRIPTION + Added in v2.21, raised in review. Discovery called Find-Script unconditionally, + which is a PowerShellGet command. On a machine carrying only PSResourceGet - the + exact configuration the updater fallback below exists for - that command does not + exist, discovery threw, the surrounding catch turned it into "no update available", + and the entire update path was dead while every test around it passed. A fallback + that cannot be reached is not a fallback. + Returns Version, ReleaseNotes and Provider ('PowerShellGet' | 'PSResourceGet'), or + $null when the providers answered and the Gallery has nothing. THROWS when every + provider that exists failed - "could not ask" is not an answer, and swallowing it + made an unregistered repository look identical to "you are up to date". + Provider is carried because it is evidence: the one that just answered is known to + work, and the updater should not then be chosen by mere presence and land on the + one that failed. + #> + # Each provider is tried on its own merits (raised in review): falling back only when + # a command is ABSENT leaves a present-but-broken PowerShellGet - an unregistered + # PSGallery, say - masking a PSResourceGet that would have answered. Each keeps its own + # repository registration, so one failing says nothing about the other. Discovery is + # read-only, so trying both costs nothing but a round trip. + $found = $null + $provider = $null + $failures = @() + $answered = $false + + if (Get-Command Find-Script -ErrorAction SilentlyContinue) { + try { + $found = @(Find-Script -Name 'WinClean' -Repository PSGallery -ErrorAction Stop)[0] + $answered = $true + if ($found) { $provider = 'PowerShellGet' } + } catch { $failures += $_; $found = $null } + } + if (-not $found -and (Get-Command Find-PSResource -ErrorAction SilentlyContinue)) { + # Filtered to scripts: a module sharing the name would otherwise set the version. + # [0] of an empty filtered array is $null, which is the intended "nothing found". + # The provider returns the latest matching version, so no sorting is done here. + try { + $found = @(Find-PSResource -Name 'WinClean' -Repository PSGallery -ErrorAction Stop | + Where-Object { $_.Type -eq 'Script' })[0] + $answered = $true + if ($found) { $provider = 'PSResourceGet' } + } catch { $failures += $_; $found = $null } + } + + # "Could not ask" must not look like "asked, nothing newer" (raised in review - this + # was a regression introduced by the per-provider catches above). Before them, a failing + # Find-Script threw all the way to the caller, which logged a warning; swallowing it + # here made an unregistered PSGallery, a TLS or proxy failure and an unpublished script + # all read as "you are up to date", with nothing in the log at all. + # Keyed on "nobody answered", not on "somebody failed" (also raised in review): with a + # broken PowerShellGet beside a working PSResourceGet, a failure count above zero would + # turn a perfectly good answer into a warning on every run. + if (-not $answered -and $failures.Count -gt 0) { + throw "the PowerShell Gallery could not be queried: $($failures[-1])" + } + + if (-not $found) { return $null } + return @{ Version = $found.Version; ReleaseNotes = $found.ReleaseNotes; Provider = $provider } +} + function Test-ScriptUpdate { <# .SYNOPSIS Проверяет наличие обновлений WinClean в PowerShell Gallery .DESCRIPTION Сравнивает текущую версию скрипта с последней версией в PowerShell Gallery. - Проверяет, был ли скрипт установлен через PSGallery (для возможности автообновления). + Определяет, каким способом можно обновить ИМЕННО выполняемую копию (v2.21). .OUTPUTS [hashtable] с информацией об обновлении или $null если обновление не требуется #> @@ -847,24 +1393,31 @@ function Test-ScriptUpdate { try { $currentVersion = [Version]$script:Version - # Query PSGallery for latest version - $galleryScript = Find-Script -Name "WinClean" -Repository PSGallery -ErrorAction Stop + # Query PSGallery for latest version, through whichever provider this machine has + $galleryScript = Find-GalleryWinClean + if (-not $galleryScript) { return $null } $latestVersion = [Version]$galleryScript.Version if ($latestVersion -gt $currentVersion) { - # Check if installed via PSGallery (for auto-update capability) - $installedScript = Get-InstalledScript -Name "WinClean" -ErrorAction SilentlyContinue - + # v2.21: which copy is running decides what can be offered, not whether a + # Gallery copy exists somewhere on the machine return @{ CurrentVersion = $currentVersion.ToString() LatestVersion = $latestVersion.ToString() - IsInstalled = $null -ne $installedScript + Channel = Get-ScriptUpdateChannel -ExecutingPath $PSCommandPath ` + -GalleryLocation (Get-InstalledWinCleanLocation) + Provider = $galleryScript.Provider ReleaseNotes = $galleryScript.ReleaseNotes } } } catch { - # Silently fail - update check is not critical + # Counted (raised in review): Write-Log does not touch the counters - every warning + # in this file increments one by hand - so this one was invisible in the summary and + # in the result JSON. The "silently fail" comment it replaces had outlived the code: + # the level was already WARNING, and the try now covers channel classification, + # provider lookup and two [Version] casts, any of which can throw. Write-Log "Update check failed: $_" -Level WARNING + $script:Stats.WarningsCount++ } return $null @@ -903,66 +1456,148 @@ function Invoke-ScriptUpdate { Write-Host " (new)" -ForegroundColor DarkGreen Write-Host "" - Write-Log "Update available: v$($UpdateInfo.CurrentVersion) -> v$($UpdateInfo.LatestVersion)" -Level INFO + # The channel belongs in the log line, not only on screen (raised in review): the two + # automatic paths below print their advice with Write-Host alone, so a scheduled or CI + # run recorded that an update existed and nothing about which copy was running or what + # was advised - the one fact this release is about. + Write-Log "Update available: v$($UpdateInfo.CurrentVersion) -> v$($UpdateInfo.LatestVersion) (channel: $($UpdateInfo.Channel))" -Level INFO # In ReportOnly mode, just inform and continue if ($ReportOnly) { + Write-Log "ReportOnly mode - no update attempted" -Level INFO Write-Host " ReportOnly mode - skipping update" -ForegroundColor DarkGray + # Raised in review: the applicable method is still worth naming here. A preview run + # is often exactly when someone is deciding how to update, and printing nothing + # contradicted the documented promise that WinClean names the option that applies. + foreach ($line in (Get-UpdateInstruction -Channel $UpdateInfo.Channel -ExecutingPath $PSCommandPath -Provider $UpdateInfo.Provider)) { + Write-Host $line -ForegroundColor Gray + } Write-Host "" return } # Check if interactive console is available if (-not (Test-InteractiveConsole)) { + Write-Log "Non-interactive mode - no update attempted" -Level INFO Write-Host " Non-interactive mode - skipping update prompt" -ForegroundColor DarkGray - Write-Host " To update manually: Update-Script -Name WinClean" -ForegroundColor Gray + # v2.21: the instruction now follows the running copy. It used to name + # Update-Script unconditionally, which does nothing for the copy in + # %ProgramFiles% that the desktop shortcut starts. + foreach ($line in (Get-UpdateInstruction -Channel $UpdateInfo.Channel -ExecutingPath $PSCommandPath -Provider $UpdateInfo.Provider)) { + Write-Host $line -ForegroundColor Gray + } + Write-Host "" + return + } + + if ($UpdateInfo.Channel -ne 'gallery') { + # Anything but a single unambiguous Gallery copy: say what applies to THIS file and + # continue. 'gallery-ambiguous' deliberately lands here too - it IS a Gallery copy, + # but with several installs present no updater can be aimed at this one. + # The wording must not contradict the channel (raised in review): a + # 'gallery-ambiguous' copy IS Gallery-managed - that is precisely why it is here. + # 'unknown' exists precisely to promise nothing, so the log must not promise either + # (raised in review): it used to state flatly that the copy is not Gallery-managed + # while the console, two lines later, said its location could not be determined. + $why = switch ($UpdateInfo.Channel) { + 'gallery-ambiguous' { "several Gallery installations exist and WinClean does not resolve which one an update would change" } + 'unknown' { "the location of the running copy could not be determined" } + default { "this copy does not match a Gallery installation" } + } + Write-Log "Update available but $why (channel: $($UpdateInfo.Channel))" -Level INFO + foreach ($line in (Get-UpdateInstruction -Channel $UpdateInfo.Channel -ExecutingPath $PSCommandPath -Provider $UpdateInfo.Provider)) { + Write-Host $line -ForegroundColor Gray + } + Write-Host "" + Write-Host " Press any key to continue with current version..." -ForegroundColor DarkGray + Wait-ForKeyPress Write-Host "" return } - if ($UpdateInfo.IsInstalled) { - # Installed via PSGallery - can auto-update - Write-Host " Update now? (" -NoNewline -ForegroundColor Gray - Write-Host "Y" -NoNewline -ForegroundColor Green - Write-Host "/n): " -NoNewline -ForegroundColor Gray + # The running file is the Gallery copy, so updating it updates what runs next + Write-Host " Update now? (" -NoNewline -ForegroundColor Gray + Write-Host "Y" -NoNewline -ForegroundColor Green + Write-Host "/n): " -NoNewline -ForegroundColor Gray - $response = Read-Host - if ($response -eq '' -or $response -imatch '^[YyДд]') { - Write-Host "" - Write-Host " Updating WinClean..." -ForegroundColor Cyan + $response = Read-Host + if ($response -ne '' -and $response -inotmatch '^[YyДд]') { + Write-Log "Update skipped by user" -Level INFO + Write-Host " Update skipped. Continuing with current version..." -ForegroundColor DarkGray + Write-Host "" + return + } - try { - Update-Script -Name WinClean -Force -ErrorAction Stop - Write-Log "Update successful" -Level SUCCESS - Write-Host "" - Write-Host " ✓ Update complete!" -ForegroundColor Green - Write-Host " Please run WinClean again to use the new version." -ForegroundColor Gray - Write-Host "" - Write-Host " Press any key to exit..." -ForegroundColor DarkGray - $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") - exit 0 - } catch { - Write-Log "Update failed: $_" -Level ERROR - Write-Host " ✗ Update failed: $_" -ForegroundColor Red - Write-Host " Continuing with current version..." -ForegroundColor Yellow - Write-Host "" - } - } else { - Write-Log "Update skipped by user" -Level INFO - Write-Host " Update skipped. Continuing with current version..." -ForegroundColor DarkGray - Write-Host "" + Write-Host "" + Write-Host " Updating WinClean..." -ForegroundColor Cyan + + try { + # PowerShellGet ships with PowerShell today and PSResourceGet is its replacement; + # either can be the one present, and the one that answered discovery goes first + switch (Select-UpdateCommand -Provider $UpdateInfo.Provider) { + 'Update-Script' { Update-Script -Name WinClean -Force -ErrorAction Stop } + 'Update-PSResource' { Update-PSResource -Name WinClean -Force -TrustRepository -ErrorAction Stop } + default { throw "no update command available (neither Update-Script nor Update-PSResource)" } } - } else { - # Not installed via PSGallery - show manual instructions - Write-Host " WinClean was not installed via PowerShell Gallery." -ForegroundColor Yellow - Write-Host " To enable auto-updates, install with:" -ForegroundColor Gray - Write-Host "" - Write-Host " Install-Script -Name WinClean -Scope CurrentUser -Force" -ForegroundColor White + } catch { + # A warning, not an error (raised in review): the exit code is computed from + # ErrorsCount alone, and the old level/counter pairing logged ERROR while still + # exiting 0 - a contradiction in the one place that must be believable. Failing to + # update the script is not a failure of the maintenance the user actually asked for. + Write-Log "Update failed: $_" -Level WARNING + $script:Stats.WarningsCount++ + Write-Host " ✗ Update failed: $_" -ForegroundColor Red + Write-Host " Continuing with current version..." -ForegroundColor Yellow Write-Host "" - Write-Host " Press any key to continue with current version..." -ForegroundColor DarkGray - $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") + return + } + + # v2.21: verify against the file being executed. A provider that reports success + # while the running file stays at the old version is the exact failure this release + # fixes, and it must not be re-announced as "update complete". + $observedVersion = Get-ScriptFileVersion -Path $PSCommandPath + $verification = Get-UpdateVerification -ExpectedVersion $UpdateInfo.LatestVersion ` + -ObservedVersion $observedVersion + if (-not $verification.Applied) { + # Report what was actually read, not what this process started as: with several + # installations present those two can differ, and naming the wrong one would send + # the reader looking at the wrong file (raised in review). + $detail = if ($verification.Reason -eq 'unchanged') { + "the file still reports v$observedVersion" + } else { + "its version could not be read back" + } + Write-Log "Update reported success but $detail - continuing with the current version" -Level WARNING + $script:Stats.WarningsCount++ + Write-Host " ! The update reported success, but $detail." -ForegroundColor Yellow + foreach ($line in (Get-UpdateInstruction -Channel 'gallery-unverified' -ExecutingPath $PSCommandPath)) { + Write-Host $line -ForegroundColor Gray + } Write-Host "" + return } + + Write-Log "Update successful" -Level SUCCESS + Write-Host "" + Write-Host " ✓ Update complete!" -ForegroundColor Green + Write-Host " Please run WinClean again to use the new version." -ForegroundColor Gray + Write-Host "" + Write-Host " Press any key to exit..." -ForegroundColor DarkGray + + # This exit bypasses the finally in Start-WinClean, so the result JSON has to be + # written here (raised in review). Start-WinClean deletes any previous file before the + # update check, so without this a consumer saw exit code 0 and NO artefact at all - + # indistinguishable from a crash, and identical to a run that did nothing. Same shape + # as the PendingRebootDeclined path, which already does this. + $script:Stats.Aborted = 'UpdatedAndExited' + Write-ResultJson -Path $ResultJsonPath + + Wait-ForKeyPress + # Not an unconditional 0 (raised in review): Write-ResultJson reports its own failure by + # counting an error rather than throwing, and exiting 0 anyway would rebuild the exact + # "success with no artefact" state the JSON write was added here to prevent. + if ($script:Stats.ErrorsCount -gt 0) { exit 1 } + exit 0 } function Install-ModuleWithTimeout { @@ -2297,11 +2932,13 @@ function Update-Applications { if ($SkipUpdates) { Write-Log "Application updates skipped (parameter)" -Level INFO + $script:Stats.AppUpdatesStatus = 'skipped-parameter' return } if (-not (Test-InternetConnection)) { Write-Log "No internet connection - skipping app updates" -Level ERROR + $script:Stats.AppUpdatesStatus = 'skipped-offline' $script:Stats.ErrorsCount++ return } @@ -2322,11 +2959,28 @@ function Update-Applications { } if (-not $wingetPath) { - Write-Log "Winget not found - install App Installer from Microsoft Store" -Level ERROR - $script:Stats.ErrorsCount++ + # v2.21: a warning, not an error. The absence of an optional third-party tool is a + # property of the machine, not a failure of this run - by the same rule that makes + # a machine without Docker or Visual Studio a normal machine here. + # It mattered because the exit code is computed from ErrorsCount alone: every run + # on a machine without App Installer ended with code 1 while all nine phases + # completed, so any scheduler, CI job or test harness reading that code saw a + # failed run forever. A winget that IS present and then fails is still reported; + # its severity depends on whether the run can carry on - the upgrade check failing + # outright and an unhandled exception are errors, while a stale source, a timeout + # or a partly failed batch are warnings. + Write-Log "Winget not found - skipping application updates (install App Installer from Microsoft Store to enable them)" -Level WARNING + $script:Stats.AppUpdatesStatus = 'skipped-no-winget' + $script:Stats.WarningsCount++ return } + # From here winget exists and is about to be asked, but the status is only raised to + # 'checked' once the check actually returns a list (raised in review): setting it here + # meant a timed-out or failing check still reported 'checked' with AppUpdatesOffered = 0 + # - precisely the ambiguity this field was added to remove. + $script:Stats.AppUpdatesStatus = 'check-failed' + try { # Update sources only if not in ReportOnly mode (source update modifies state) if (-not $ReportOnly) { @@ -2407,6 +3061,9 @@ function Update-Applications { return } + # The check returned a usable list, so its count now means something + $script:Stats.AppUpdatesStatus = 'checked' + # Parse output for update count (language-independent approach) # Uses table separator "---" as marker, then counts all data lines $updateCount = 0 @@ -5261,6 +5918,10 @@ function Write-ResultJson { # OFFERED, not a confirmed install count (winget upgrade --all cannot report the # latter). No shipped consumer reads this field; the nightly stand does not. AppUpdatesOffered = $script:Stats.AppUpdatesOffered + # v2.21: distinguishes "checked, nothing to upgrade" from "could not check". + # Both are AppUpdatesOffered = 0, and demoting a missing winget to a warning + # removed the only other signal a consumer had (a non-zero exit code). + AppUpdatesStatus = $script:Stats.AppUpdatesStatus WarningsCount = $script:Stats.WarningsCount ErrorsCount = $script:Stats.ErrorsCount RebootRequired = [bool]$script:Stats.RebootRequired @@ -5429,10 +6090,20 @@ function Start-WinClean { # Check for script updates. v2.17: gated by -SkipUpdates - the flag promises no # update activity, and this path costs a PSGallery round trip on every run. + # + # Guarded (raised in review): this block runs BEFORE the main try/finally, so anything + # thrown here escaped Start-WinClean entirely - no result JSON, no phase buckets, no + # exit-code accounting - and killed the run before its first phase. The update check is + # optional; the maintenance it precedes is not, and must not be lost to it. if (-not $SkipUpdates) { - $updateInfo = Test-ScriptUpdate - if ($updateInfo) { - Invoke-ScriptUpdate -UpdateInfo $updateInfo + try { + $updateInfo = Test-ScriptUpdate + if ($updateInfo) { + Invoke-ScriptUpdate -UpdateInfo $updateInfo + } + } catch { + Write-Log "Update check could not be completed: $_" -Level WARNING + $script:Stats.WarningsCount++ } } @@ -5474,6 +6145,11 @@ function Start-WinClean { $null = New-SystemRestorePoint -Description "WinClean $(Get-Date -Format 'yyyy-MM-dd HH:mm')" } + # Recorded here, not inside Update-Applications (raised in review): -SkipUpdates + # stops the phase from dispatching at all, so the branch that sets this inside the + # function is unreachable in production and the status stayed 'not-run'. + if ($SkipUpdates) { $script:Stats.AppUpdatesStatus = 'skipped-parameter' } + Invoke-Phase -Name 'Updates' -Skip:$SkipUpdates -Action { Update-WindowsSystem Update-Applications diff --git a/docs/result-json.md b/docs/result-json.md index 279a96b..d686fe9 100644 --- a/docs/result-json.md +++ b/docs/result-json.md @@ -23,13 +23,14 @@ This page documents every field, gives a full sample, and explains how to consum | `FreedByCategory` | object | Map of category name to bytes freed, e.g. `{ "Temp": 187912345, "DriverStore": 451801088 }`. Categories are added as work happens, so a category can appear with `0` (for example DriverStore is recorded after a successful package removal even if the measured freed size was zero). | | `WindowsUpdatesCount` | number | Number of Windows updates installed (from PSWindowsUpdate, which reports per-update results, so this is a real installed count). | | `AppUpdatesOffered` | number | Number of application updates winget **offered**. See the note below - this is not a confirmed install count. | +| `AppUpdatesStatus` | string | Why that count is what it is: `checked`, `check-failed`, `skipped-parameter`, `skipped-offline`, `skipped-no-winget`, or `not-run`. Added in v2.21. | | `WarningsCount` | number | Count of warnings raised during the run. Warnings are the silent-failure alarm; treat a non-zero value as something to inspect. | | `ErrorsCount` | number | Count of errors raised during the run. A healthy run reports `0`. | | `RebootRequired` | bool | `true` when a change (a Windows update, an app update finishing on reboot) needs a restart to take effect. | | `LoggingDegraded` | bool | v2.20. `true` when writing the log file failed at some point during the run. The run itself still completed, but `LogPath` points at an incomplete file: do not read that log as the full record of what happened. | | `DiskCleanupPending` | bool | v2.20. `true` when Disk Cleanup outlived its timeout and was left running in the background. `TotalFreedBytes` is then a lower bound rather than the final figure, because deletion continued after this file was written. | | `ControlledFolderAccess` | string | Tri-state, see below. Reflects whether Defender's Controlled Folder Access may have silently blocked deletions. | -| `Aborted` | string or null | `null` unless the run stopped early for a known reason (currently only a declined pending reboot, `"PendingRebootDeclined"`, sets it). When set, the phase arrays below are incomplete by design. Note `null` does not by itself prove every phase ran - see the invariant note below. | +| `Aborted` | string or null | `null` unless the run stopped early for a known reason: `"PendingRebootDeclined"` (the user declined to continue with a reboot pending) or `"UpdatedAndExited"` (v2.21 - the script updated itself and exited so the new version runs next time). When set, the phase arrays below are incomplete by design. Note `null` does not by itself prove every phase ran - see the invariant note below. | | `PhasesCompleted` | array of string | Phases whose action ran to completion without an uncaught exception. | | `PhasesFailed` | array of string | Phases whose action threw. | | `PhasesSkipped` | array of string | Phases a skip flag suppressed before they ran. | @@ -62,6 +63,21 @@ Consequently: - It is **not** proof that N applications were installed. If you need the true installed set, parse winget's own per-package output separately; WinClean does not attempt this. - The console summary reflects the same honesty: `Windows: X installed, Apps: Y offered`. +### `AppUpdatesStatus` (added in v2.21) + +`AppUpdatesOffered: 0` on its own is ambiguous: it is what you get both when winget was asked and had nothing to upgrade, and when winget was never asked at all. Until v2.21 the two could be told apart by the exit code, because a missing winget was an error. That is no longer true - a missing optional tool is now a warning, so the run can exit 0 - and this field carries the distinction instead. + +| Value | Meaning | +|-------|---------| +| `checked` | winget was found, asked, and returned a list. `AppUpdatesOffered` is a real answer. | +| `check-failed` | winget was found but the check did not produce a list - it timed out, exited non-zero, or could not be completed at all. The count is meaningless. | +| `skipped-no-winget` | winget is not installed on this machine. The run continues; a warning is logged. | +| `skipped-offline` | No connectivity, so the update phases stopped before winget. This branch is still an **error**. | +| `skipped-parameter` | `-SkipUpdates` was passed. | +| `not-run` | The phase never executed (for example the run aborted earlier). | + +Treat any value other than `checked` as "the count means nothing", not as "there was nothing to update". + ### `ControlledFolderAccess` (tri-state string) | Value | Meaning | @@ -117,6 +133,7 @@ VisualStudioCleanup, DeepSystemCleanup, DiskSpaceReport, Telemetry }, "WindowsUpdatesCount": 0, "AppUpdatesOffered": 0, + "AppUpdatesStatus": "skipped-parameter", "WarningsCount": 1, "ErrorsCount": 0, "RebootRequired": false, diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f9aed5e..b532f1d 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -23,10 +23,54 @@ If the check itself cannot run (third-party antivirus, stripped image, broken WM If `winget` is not installed, the application-update phase is skipped and the run continues. Windows updates (via PSWindowsUpdate) are unaffected. +This is reported as a **warning**, and the run can still finish with **exit code 0**. Before v2.21 it was an error, and since the exit code is derived from the error count alone, every run on a machine without App Installer ended with code 1 even though all nine phases completed - which any scheduler or CI job reads as a failed run. A missing optional third-party tool is a property of the machine, not a failure of the run, the same way a machine without Docker is a normal machine here. A `winget` that **is** present and then fails is still reported, at a severity that follows whether the run can safely carry on: the upgrade check failing outright or an unhandled error is an error, while a stale package source, a timeout or a partly failed upgrade batch is a warning. + +Note that a machine with **no internet connection** never reaches the winget check: the update phases stop earlier, and that earlier branch is still an error. So "a missing winget no longer fails the run" holds for an online machine without App Installer, not for an offline one. + **Fix:** install **App Installer** from the Microsoft Store, or bootstrap winget, then re-run. Note that the app-update count in the summary is what winget **offered**, not a confirmed install count (see [FAQ](faq.md) and [result-json.md](result-json.md)). --- +## "Update complete" but the version never changes + +Symptom: WinClean announces an update, reports success, asks you to run it again - and the next run shows the same old version. Forever. + +Cause: **two installations on one machine**, which is an ordinary situation rather than an exotic one. `install.ps1` puts a copy in `%ProgramFiles%\WinClean` (this is what the desktop shortcut starts), while an older `Install-Script` copy may still sit in `Documents\PowerShell\Scripts`. Up to v2.20 WinClean asked only whether a Gallery copy existed **somewhere**, then acted as if the answer had been "the file you are running is that copy". `Update-Script` dutifully updated the copy in Documents, and the shortcut kept starting the untouched one. + +Since v2.21 the check compares the **running file** with the Gallery install location and offers a self-update only when they are the same file. Every other copy is told the method that actually applies to it (see [Updating an existing installation](../README.md#-updating-an-existing-installation)). An update that reports success is also verified against the version on disk afterwards, so a provider that silently changes nothing is reported instead of being announced as complete. + +**Fix on an affected machine:** update the copy the shortcut points at by re-running `install.ps1` elevated, and remove the abandoned Gallery copy if you no longer use it: + +**Step 1 - find out which copies exist and which one you actually run.** Either provider may report an install performed by the other, so ask both; whichever module is absent simply reports an unknown command, which is expected. + +```powershell +Get-InstalledScript -Name WinClean -ErrorAction SilentlyContinue | + Select-Object Version, InstalledLocation +Get-PSResource -Name WinClean -ErrorAction SilentlyContinue | + Where-Object { $_.Type -eq 'Script' } | Select-Object Version, InstalledLocation +Get-Item "$env:ProgramFiles\WinClean\WinClean.ps1" -ErrorAction SilentlyContinue +``` + +The copy you run is the one your shortcut points at (right-click the shortcut -> Properties -> Target). WinClean also prints that path itself, but only in the two cases where telling the installations apart is the point: several Gallery installs at once, and an update that reported success without changing the running file. + +**Step 2 - remove only a copy whose location is *not* the one you run.** `AllUsers` and `CurrentUser` installs can coexist, and an unscoped uninstall is not guaranteed to pick the one you meant, so pin it to the version you saw at the location you want gone: + +```powershell +Uninstall-Script -Name WinClean -RequiredVersion # PowerShellGet +Uninstall-PSResource -Name WinClean -Version # PSResourceGet +``` + +If two installs report the **same** version at different locations, `Uninstall-Script` cannot name one of them (it has no `-Scope`). `Uninstall-PSResource` does take `-Scope`, so it can, provided PSResourceGet performed that install. When in doubt do not guess: update the copy you run by **replacing that exact file** with the script asset from the [latest release](https://github.com/bivlked/WinClean/releases/latest), and remove the other installation's folder only once you have confirmed it is not the path you launch. Removing an `AllUsers` install needs an elevated session, and neither command touches the `%ProgramFiles%\WinClean` copy at all - that one is not a Gallery installation and is replaced by re-running `install.ps1`. + +### What WinClean does in each of these states + +The advice you see depends on which copy is running, so the two situations above behave differently: + +- **The running copy is the `%ProgramFiles%\WinClean` one** (the usual case for the desktop shortcut). It was never Gallery-managed, so there is no self-update to decline: WinClean names the installer as the way to update it. Removing the stray Gallery copy does not change this - re-running `install.ps1` is the update path for this copy, by design. +- **The running copy is one of several Gallery installs.** Here WinClean declines to update itself rather than modifying a copy at random, prints the path it is running from, and points at the manual replacement above. It keeps declining while more than one Gallery install remains; resolving the duplicate is what restores the automatic update. + +--- + ## The same application is offered for update on every single run WinClean asks `winget` what can be upgraded and then asks it to upgrade. When the same package keeps appearing run after run, the loop is almost always outside WinClean. There are two distinct causes, and they need different answers. diff --git a/tests/Helpers.Tests.ps1 b/tests/Helpers.Tests.ps1 index 4029b2b..3524b17 100644 --- a/tests/Helpers.Tests.ps1 +++ b/tests/Helpers.Tests.ps1 @@ -1371,3 +1371,1019 @@ Describe "Wait-StorageSenseTask" -Tag "Unit", "Helper", "V220" { } #endregion + +#region v2.21 self-update targeting + +Describe "Test-PathInsideRoot" -Tag "Unit", "Helper", "V221" { + It "accepts a file inside the root" { + Test-PathInsideRoot -Path 'C:\Program Files\WinClean\WinClean.ps1' -Root 'C:\Program Files\WinClean' | + Should -BeTrue + } + + It "does not treat a sibling with a shared prefix as inside the root" { + # A plain StartsWith without the separator would call C:\Temp2 a child of C:\Temp, + # labelling a manual copy as the one-liner's temporary download and printing an + # instruction that does nothing for it + Test-PathInsideRoot -Path 'C:\Temp2\WinClean.ps1' -Root 'C:\Temp' | Should -BeFalse + } + + It "ignores case, as the file system does" { + Test-PathInsideRoot -Path 'c:\program files\winclean\WinClean.ps1' -Root 'C:\Program Files\WinClean' | + Should -BeTrue + } + + It "tolerates a trailing separator on the root" { + Test-PathInsideRoot -Path 'C:\Tools\WinClean\WinClean.ps1' -Root 'C:\Tools\WinClean\' | Should -BeTrue + } + + It "answers false for instead of throwing" -ForEach @( + @{ name = 'an empty path'; path = ''; root = 'C:\Temp' } + @{ name = 'a null path'; path = $null; root = 'C:\Temp' } + @{ name = 'an empty root'; path = 'C:\Temp\x.ps1'; root = '' } + @{ name = 'a null root'; path = 'C:\Temp\x.ps1'; root = $null } + ) { + Test-PathInsideRoot -Path $path -Root $root | Should -BeFalse + } +} + +Describe "Get-ScriptUpdateChannel" -Tag "Unit", "Helper", "V221" { + It "calls the running file the gallery copy when it is the gallery copy" { + Get-ScriptUpdateChannel -ExecutingPath 'C:\Users\u\Documents\PowerShell\Scripts\WinClean.ps1' ` + -GalleryLocation @('C:\Users\u\Documents\PowerShell\Scripts') | Should -Be 'gallery' + } + + It "matches the gallery copy regardless of case" { + Get-ScriptUpdateChannel -ExecutingPath 'c:\users\u\documents\powershell\scripts\winclean.ps1' ` + -GalleryLocation @('C:\Users\u\Documents\PowerShell\Scripts') | Should -Be 'gallery' + } + + It "REGRESSION: a gallery copy elsewhere does not make the Program Files copy updatable" { + # The reported defect exactly: both installs exist, the shortcut starts the + # Program Files one, and the old code auto-updated the Documents one and told the + # user to run WinClean again for the new version - which kept starting the old file + Get-ScriptUpdateChannel -ExecutingPath 'C:\Program Files\WinClean\WinClean.ps1' ` + -GalleryLocation @('C:\Users\u\Documents\PowerShell\Scripts') ` + -ProgramFilesRoot 'C:\Program Files' | Should -Be 'installer' + } + + It "refuses the automatic path when several gallery installations exist" { + # It IS the gallery copy, but Update-Script has no -Scope: with AllUsers and + # CurrentUser copies present, nothing aims the updater at this one. Modifying the + # unused copy and only then discovering the miss is worse than declining + Get-ScriptUpdateChannel -ExecutingPath 'C:\Program Files\WindowsPowerShell\Scripts\WinClean.ps1' ` + -GalleryLocation @('C:\Users\u\Documents\PowerShell\Scripts', 'C:\Program Files\WindowsPowerShell\Scripts') ` + -ProgramFilesRoot 'C:\NoSuchProgramFiles' | Should -Be 'gallery-ambiguous' + } + + It "still allows the automatic path when both providers report the same single install" { + # Duplicates are not ambiguity: PowerShellGet and PSResourceGet each report the + # other's install, so the same path arriving twice must not disable self-update + Get-ScriptUpdateChannel -ExecutingPath 'C:\Users\u\Documents\PowerShell\Scripts\WinClean.ps1' ` + -GalleryLocation @('C:\Users\u\Documents\PowerShell\Scripts', 'C:\Users\u\Documents\PowerShell\Scripts\') ` + | Should -Be 'gallery' + } + + It "collapses duplicates that differ only in case" { + # Select-Object -Unique is case-SENSITIVE (verified 22.07.2026), while the match + # below is case-insensitive. With the two mismatched, one install reported with + # different casing by the two providers looked like two and silently cost the + # machine its self-update + Get-ScriptUpdateChannel -ExecutingPath 'C:\Users\u\Documents\PowerShell\Scripts\WinClean.ps1' ` + -GalleryLocation @('C:\Users\u\Documents\PowerShell\Scripts', 'c:\users\u\documents\powershell\scripts') ` + | Should -Be 'gallery' + } + + It "does not claim a differently named script sharing the gallery folder" { + # InstalledLocation is a shared Scripts folder; the provider owns WinClean.ps1 + # inside it, not everything that happens to sit there + Get-ScriptUpdateChannel -ExecutingPath 'C:\Users\u\Documents\PowerShell\Scripts\WinClean-test.ps1' ` + -GalleryLocation @('C:\Users\u\Documents\PowerShell\Scripts') ` + -ProgramFilesRoot 'C:\Program Files' -TempRoot 'C:\Temp' | Should -Be 'manual' + } + + It "recognises the temporary copy that get.ps1 downloads" { + Get-ScriptUpdateChannel -ExecutingPath 'C:\Temp\WinClean-0123abcd\WinClean.ps1' ` + -GalleryLocation @() -ProgramFilesRoot 'C:\Program Files' -TempRoot 'C:\Temp' | + Should -Be 'oneliner' + } + + It "returns unknown when the executing path is " -ForEach @( + @{ name = 'empty'; path = '' } + @{ name = 'null'; path = $null } + @{ name = 'whitespace'; path = ' ' } + ) { + Get-ScriptUpdateChannel -ExecutingPath $path -GalleryLocation @('C:\Users\u\Documents\PowerShell\Scripts') | + Should -Be 'unknown' + } + + It "falls back to manual with no gallery install at all" { + Get-ScriptUpdateChannel -ExecutingPath 'D:\Downloads\WinClean.ps1' -GalleryLocation @() ` + -ProgramFilesRoot 'C:\Program Files' -TempRoot 'C:\Temp' | Should -Be 'manual' + } + + It "survives a null location array and null roots" { + Get-ScriptUpdateChannel -ExecutingPath 'D:\Downloads\WinClean.ps1' -GalleryLocation $null ` + -ProgramFilesRoot $null -TempRoot $null | Should -Be 'manual' + } + + It "ignores empty entries among the locations" { + Get-ScriptUpdateChannel -ExecutingPath 'D:\Downloads\WinClean.ps1' -GalleryLocation @('', $null) ` + -ProgramFilesRoot 'C:\Program Files' -TempRoot 'C:\Temp' | Should -Be 'manual' + } + + It "uses real roots by default, since production never passes them" { + # Test-ScriptUpdate calls this with no roots at all, yet every other test here + # supplies them - so mutating either default survived the whole suite. The negative + # case matters most: a TempRoot collapsing to a drive root would classify every + # copy on C: as a one-liner download + $temp = [System.IO.Path]::GetTempPath() + $programFiles = [Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles) + + Get-ScriptUpdateChannel -ExecutingPath (Join-Path $temp 'WinClean-abc\WinClean.ps1') ` + -GalleryLocation @() | Should -Be 'oneliner' + Get-ScriptUpdateChannel -ExecutingPath (Join-Path $programFiles 'WinClean\WinClean.ps1') ` + -GalleryLocation @() | Should -Be 'installer' + Get-ScriptUpdateChannel -ExecutingPath 'C:\Tools\WinClean\WinClean.ps1' ` + -GalleryLocation @() | Should -Be 'manual' + } + + It "matches a gallery copy reached over UNC" { + Get-ScriptUpdateChannel -ExecutingPath '\\fileserver\profiles\u\Documents\PowerShell\Scripts\WinClean.ps1' ` + -GalleryLocation @('\\fileserver\profiles\u\Documents\PowerShell\Scripts') | Should -Be 'gallery' + } + + It "does not confuse two UNC shares with a shared prefix" { + Get-ScriptUpdateChannel -ExecutingPath '\\fileserver\profiles2\WinClean.ps1' ` + -GalleryLocation @('\\fileserver\profiles') ` + -ProgramFilesRoot 'C:\Program Files' -TempRoot 'C:\Temp' | Should -Be 'manual' + } +} + +Describe "Get-UpdateVerification" -Tag "Unit", "Helper", "V221" { + It "confirms an update when the file reports the expected version" { + $v = Get-UpdateVerification -ExpectedVersion '2.21' -ObservedVersion '2.21' + $v.Applied | Should -BeTrue + $v.Reason | Should -Be 'applied' + } + + It "confirms an update when the file is newer than expected" { + (Get-UpdateVerification -ExpectedVersion '2.21' -ObservedVersion '2.22').Applied | Should -BeTrue + } + + It "reports unchanged when the file kept the old version" { + $v = Get-UpdateVerification -ExpectedVersion '2.21' -ObservedVersion '2.19' + $v.Applied | Should -BeFalse + $v.Reason | Should -Be 'unchanged' + } + + It "never reports applied when the observed version is " -ForEach @( + @{ name = 'null'; observed = $null } + @{ name = 'empty'; observed = '' } + @{ name = 'unparsable'; observed = 'not-a-version' } + ) { + $v = Get-UpdateVerification -ExpectedVersion '2.21' -ObservedVersion $observed + $v.Applied | Should -BeFalse + $v.Reason | Should -Be 'unreadable' + } + + It "never reports applied when the expected version is unusable" { + (Get-UpdateVerification -ExpectedVersion $null -ObservedVersion '2.21').Applied | Should -BeFalse + } + + It "treats and as the same release" -ForEach @( + @{ observed = '2.21'; expected = '2.21.0' } + @{ observed = '2.21.0'; expected = '2.21' } + @{ observed = '2.21.0.0'; expected = '2.21' } + ) { + # [Version] fills missing components with -1, not 0, so plain -lt calls "2.21" + # OLDER than "2.21.0". The Gallery may report either form for the same release, and + # without normalisation a good update would be announced as not applied + $v = Get-UpdateVerification -ExpectedVersion $expected -ObservedVersion $observed + $v.Applied | Should -BeTrue + $v.Reason | Should -Be 'applied' + } + + It "still reports unchanged across component-count differences when genuinely older" { + (Get-UpdateVerification -ExpectedVersion '2.21.0' -ObservedVersion '2.20').Reason | Should -Be 'unchanged' + } +} + +Describe "Get-InstalledWinCleanLocation" -Tag "Unit", "Helper", "V221" { + It "keeps querying the second provider when the first one throws" { + # -ErrorAction SilentlyContinue only covers non-terminating errors. A broken + # PowerShellGet used to abort the whole lookup, so PSResourceGet was never asked + # and the answer became "no Gallery copy exists" - the wrong answer for a caller + # that decides whether the running file can update itself + Mock Get-InstalledScript { throw "provider is broken" } + Mock Get-PSResource { [pscustomobject]@{ Type = 'Script'; InstalledLocation = 'C:\Users\u\Documents\PowerShell\Scripts' } } + + Get-InstalledWinCleanLocation | Should -Contain 'C:\Users\u\Documents\PowerShell\Scripts' + } + + It "throws when nothing was found AND a provider failed" { + # "Could not read the machine" must not look like "no Gallery copy installed": + # the latter classifies the running file as 'manual' and prints an installer + # command, which would add a SECOND installation next to the unreadable one + Mock Get-InstalledScript { throw "broken" } + Mock Get-PSResource { throw "also broken" } + + { Get-InstalledWinCleanLocation } | Should -Throw + } + + It "stays silent when no provider exists at all, which is not a failure" { + # A machine with no package provider legitimately has no Gallery copy + Mock Get-Command { $null } -ParameterFilter { $Name -in 'Get-InstalledScript', 'Get-PSResource' } + + @(Get-InstalledWinCleanLocation).Count | Should -Be 0 + } + + It "treats 'nothing installed' as an answer rather than a provider outage" { + # PowerShellGet is hidden so only PSResourceGet answers, and PSResourceGet is left + # UNMOCKED: it really raises its typed ResourceNotFoundException here. Without that + # classification this throws, and every machine with no Gallery copy would then be + # told its running copy is not Gallery-managed - and pointed at the installer + Mock Get-Command { $null } -ParameterFilter { $Name -eq 'Get-InstalledScript' } + + { Get-InstalledWinCleanLocation } | Should -Not -Throw + } + + It "reports a non-terminating PowerShellGet failure, not just a thrown one" { + # The other mocks use `throw`, which behaves the same under Stop and + # SilentlyContinue - so they would pass even if the query went back to suppressing + # errors. This is the shape that used to slip through as "no copy installed" + Mock Get-InstalledScript { Write-Error "provider is unwell" } + Mock Get-Command { $null } -ParameterFilter { $Name -eq 'Get-PSResource' } + + { Get-InstalledWinCleanLocation } | Should -Throw + } + + It "ignores other installed scripts now that the name is filtered locally" { + # The query no longer passes -Name, so the filter is ours to get right + Mock Get-InstalledScript { + @([pscustomobject]@{ Name = 'SomethingElse'; InstalledLocation = 'C:\Other' }, + [pscustomobject]@{ Name = 'WinClean'; InstalledLocation = 'C:\Scripts' }) + } + Mock Get-Command { $null } -ParameterFilter { $Name -eq 'Get-PSResource' } + + $result = @(Get-InstalledWinCleanLocation) + $result | Should -Contain 'C:\Scripts' + $result | Should -Not -Contain 'C:\Other' + } + + It "reports failure even when the readable scope did return a copy" { + # A partial list is not a smaller answer: a hidden AllUsers install turns + # 'gallery-ambiguous' back into 'gallery' and re-enables the very automatic update + # whose target cannot be resolved + Mock Get-Command { $null } -ParameterFilter { $Name -eq 'Get-InstalledScript' } + Mock Get-PSResource { throw "AllUsers unreadable" } -ParameterFilter { $Scope -eq 'AllUsers' } + Mock Get-PSResource { [pscustomobject]@{ Type = 'Script'; InstalledLocation = 'C:\Users\u\Documents\PowerShell\Scripts' } } + + { Get-InstalledWinCleanLocation } | Should -Throw + } + + It "reports failure when the AllUsers half specifically could not be read" { + # A single "somebody answered" flag let a successful CurrentUser query mask this, + # and AllUsers is the half the scope query exists to read + Mock Get-InstalledScript { throw "broken" } + Mock Get-PSResource { throw "AllUsers unreadable" } -ParameterFilter { $Scope -eq 'AllUsers' } + Mock Get-PSResource { } + + { Get-InstalledWinCleanLocation } | Should -Throw + } + + It "asks PSResourceGet for AllUsers as well as the default scope" { + # Get-PSResource's -Scope is not nullable, so an unbound call means CurrentUser and + # never sees an AllUsers install - the natural scope for an admin-only script + Mock Get-InstalledScript { } + Mock Get-PSResource { [pscustomobject]@{ Type = 'Script'; InstalledLocation = 'C:\Program Files\WindowsPowerShell\Scripts' } } ` + -ParameterFilter { $Scope -eq 'AllUsers' } + Mock Get-PSResource { } + + Get-InstalledWinCleanLocation | Should -Contain 'C:\Program Files\WindowsPowerShell\Scripts' + } + + It "ignores a PSResourceGet module that shares the name" { + Mock Get-InstalledScript { } + Mock Get-PSResource { + @([pscustomobject]@{ Type = 'Module'; InstalledLocation = 'C:\Modules\WinClean\1.0' }, + [pscustomobject]@{ Type = 'Script'; InstalledLocation = 'C:\Scripts' }) + } + + $result = @(Get-InstalledWinCleanLocation) + $result | Should -Contain 'C:\Scripts' + $result | Should -Not -Contain 'C:\Modules\WinClean\1.0' + } + + It "drops duplicates reported by both providers" { + Mock Get-InstalledScript { [pscustomobject]@{ Name = 'WinClean'; InstalledLocation = 'C:\Scripts' } } + Mock Get-PSResource { [pscustomobject]@{ Type = 'Script'; InstalledLocation = 'C:\Scripts' } } + + @(Get-InstalledWinCleanLocation).Count | Should -Be 1 + } + + It "drops duplicates that the two providers spell with different casing" { + Mock Get-InstalledScript { [pscustomobject]@{ Name = 'WinClean'; InstalledLocation = 'C:\Scripts' } } + Mock Get-PSResource { [pscustomobject]@{ Type = 'Script'; InstalledLocation = 'c:\scripts' } } + + @(Get-InstalledWinCleanLocation).Count | Should -Be 1 + } + + It "keeps querying the first provider's result when the second one throws" { + # The mirror of the case above: isolation has to hold in both directions + Mock Get-InstalledScript { [pscustomobject]@{ Name = 'WinClean'; InstalledLocation = 'C:\Scripts' } } + Mock Get-PSResource { throw "provider is broken" } + + Get-InstalledWinCleanLocation | Should -Contain 'C:\Scripts' + } + + It "collapses locations that differ only by a trailing separator" { + Mock Get-InstalledScript { [pscustomobject]@{ Name = 'WinClean'; InstalledLocation = 'C:\Scripts' } } + Mock Get-PSResource { [pscustomobject]@{ Type = 'Script'; InstalledLocation = 'C:\Scripts\' } } + + @(Get-InstalledWinCleanLocation).Count | Should -Be 1 + } + + It "keeps two genuinely different locations" { + Mock Get-InstalledScript { [pscustomobject]@{ Name = 'WinClean'; InstalledLocation = 'C:\Users\u\Documents\PowerShell\Scripts' } } + Mock Get-PSResource { [pscustomobject]@{ Type = 'Script'; InstalledLocation = 'C:\Program Files\WindowsPowerShell\Scripts' } } + + @(Get-InstalledWinCleanLocation).Count | Should -Be 2 + } +} + +Describe "Get-ScriptFileVersion" -Tag "Unit", "Helper", "V221" { + It "reads the version out of the real WinClean.ps1" { + # Ties verification to the product: if the PSScriptInfo layout ever changes, this + # fails instead of silently returning $null forever, which would quietly turn + # every future update into "could not be verified" + Get-ScriptFileVersion -Path $script:WinCleanPath | Should -Be $script:Version + } + + It "returns null for a file that does not exist" { + Get-ScriptFileVersion -Path (Join-Path ([System.IO.Path]::GetTempPath()) "no-such-$(Get-Random).ps1") | + Should -BeNullOrEmpty + } + + It "returns null for a file without a .VERSION line" { + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) "WinCleanVer_$(Get-Random).ps1" + try { + Set-Content -LiteralPath $tmp -Value @('# nothing here', 'Write-Host "hi"') -Encoding UTF8 + Get-ScriptFileVersion -Path $tmp | Should -BeNullOrEmpty + } finally { Remove-Item $tmp -Force -ErrorAction SilentlyContinue } + } + + It "reads a version from a synthetic PSScriptInfo block" { + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) "WinCleanVer_$(Get-Random).ps1" + try { + Set-Content -LiteralPath $tmp -Value @('<#PSScriptInfo', '.VERSION 9.9', '.GUID x', '#>') -Encoding UTF8 + Get-ScriptFileVersion -Path $tmp | Should -Be '9.9' + } finally { Remove-Item $tmp -Force -ErrorAction SilentlyContinue } + } + + It "returns null for instead of throwing" -ForEach @( + @{ name = 'a null path'; path = $null } + @{ name = 'an empty path'; path = '' } + ) { + Get-ScriptFileVersion -Path $path | Should -BeNullOrEmpty + } +} + +Describe "Get-UpdateInstruction" -Tag "Unit", "Helper", "V221" { + It "tells the gallery copy to use Update-Script" { + ((Get-UpdateInstruction -Channel 'gallery') -join "`n").Contains('Update-Script') | Should -BeTrue + } + + It "names Update-PSResource instead on a machine without PowerShellGet" { + # Advice that cannot be run is not advice + Mock Get-Command { $null } -ParameterFilter { $Name -eq 'Update-Script' } + + $text = (Get-UpdateInstruction -Channel 'gallery') -join "`n" + $text.Contains('Update-PSResource') | Should -BeTrue + $text.Contains('Update-Script') | Should -BeFalse + } + + It "honours the answering provider even when both updaters are installed" { + # The realistic broken-PowerShellGet machine: both commands exist, so presence + # alone cannot choose. Ignoring Provider here would advise the command that just + # failed, and no other test would notice + $text = (Get-UpdateInstruction -Channel 'gallery' -Provider 'PSResourceGet') -join "`n" + $text.Contains('Update-PSResource') | Should -BeTrue + $text.Contains('Update-Script') | Should -BeFalse + } + + It "names no updater at all when neither provider is installed" { + # Naming a command that does not exist is the same mistake as naming the wrong one + Mock Get-Command { $null } -ParameterFilter { $Name -in 'Update-Script', 'Update-PSResource' } + + $text = (Get-UpdateInstruction -Channel 'gallery') -join "`n" + $text.Contains('Update-Script') | Should -BeFalse + $text.Contains('Update-PSResource') | Should -BeFalse + $text.Contains('releases/latest') | Should -BeTrue + } + + It "REGRESSION: never advises Install-Script for , which would add a second copy" -ForEach @( + @{ channel = 'installer' } + @{ channel = 'oneliner' } + @{ channel = 'manual' } + @{ channel = 'unknown' } + @{ channel = 'gallery-unverified' } + ) { + # The old code showed "Install-Script -Name WinClean" to every non-gallery copy. + # Following it builds the two-install state that made the update silently target + # the wrong file: the advice created the very configuration it could not handle. + ((Get-UpdateInstruction -Channel $channel) -join "`n").Contains('Install-Script') | Should -BeFalse + } + + It "points at " -ForEach @( + @{ channel = 'installer'; expected = 'install.ps1' } + @{ channel = 'oneliner'; expected = 'get.ps1' } + @{ channel = 'manual'; expected = 'install.ps1' } + ) { + ((Get-UpdateInstruction -Channel $channel) -join "`n").Contains($expected) | Should -BeTrue + } + + It "says the location is unknown only for the unknown channel" { + ((Get-UpdateInstruction -Channel 'unknown') -join "`n").Contains('could not be determined') | Should -BeTrue + ((Get-UpdateInstruction -Channel 'manual') -join "`n").Contains('could not be determined') | Should -BeFalse + } + + It "returns usable advice for an unforeseen channel value" { + $text = Get-UpdateInstruction -Channel 'something-new' + $text | Should -Not -BeNullOrEmpty + ($text -join "`n").Contains('install.ps1') | Should -BeTrue + } + + It "REGRESSION: does not tell a Gallery copy it came from somewhere else" { + # Shown when an update reported success but the running file did not change. This + # branch used to print the 'manual' text, which denies the copy's own provenance + # and points at install.ps1 - adding a second installation, the state that caused + # the wrong-target defect to begin with + $text = (Get-UpdateInstruction -Channel 'gallery-unverified') -join "`n" + # Positive invariants, not the absence of one phrasing: an assertion that hunts a + # string the product no longer contains can never fail, and looks like protection + $text.Contains('does not match') | Should -BeFalse # provenance is not denied + $text.Contains('install.ps1') | Should -BeFalse # no second installation + $text.Contains('Get-InstalledScript') | Should -BeTrue + $text.Contains('Get-PSResource') | Should -BeTrue + } +} + +Describe "Find-GalleryWinClean" -Tag "Unit", "Helper", "V221" { + It "asks PSResourceGet when PowerShellGet's Find-Script is not installed" { + # REGRESSION: discovery called Find-Script unconditionally. On a PSResourceGet-only + # machine it does not exist, discovery threw, the caller's catch turned that into + # "no update available", and the updater fallback below it could never be reached - + # the whole update path was dead while every surrounding test stayed green + Mock Get-Command { $null } -ParameterFilter { $Name -eq 'Find-Script' } + Mock Find-PSResource { [pscustomobject]@{ Type = 'Script'; Version = '9.9'; ReleaseNotes = 'notes' } } + + $found = Find-GalleryWinClean + $found.Version | Should -Be '9.9' + $found.ReleaseNotes | Should -Be 'notes' + } + + It "ignores a module of the same name when asking PSResourceGet" { + Mock Get-Command { $null } -ParameterFilter { $Name -eq 'Find-Script' } + Mock Find-PSResource { + @([pscustomobject]@{ Type = 'Module'; Version = '99.0' }, + [pscustomobject]@{ Type = 'Script'; Version = '9.9' }) + } + + (Find-GalleryWinClean).Version | Should -Be '9.9' + } + + It "returns null when neither provider can be asked" { + Mock Get-Command { $null } -ParameterFilter { $Name -in 'Find-Script', 'Find-PSResource' } + + Find-GalleryWinClean | Should -BeNullOrEmpty + } + + It "returns null when the Gallery knows nothing about WinClean" { + # Both must be silenced: an empty answer from one provider is not an answer for + # the other, so discovery goes on to ask it + Mock Find-Script { } + Mock Find-PSResource { } + + Find-GalleryWinClean | Should -BeNullOrEmpty + } + + It "asks PSResourceGet when Find-Script answers with nothing" { + # Pinned with -Invoke: an empty answer from one provider is not authoritative for + # the other, and a test where both are empty would pass either way + Mock Find-Script { } + Mock Find-PSResource { [pscustomobject]@{ Type = 'Script'; Version = '9.9'; ReleaseNotes = 'n' } } + + (Find-GalleryWinClean).Version | Should -Be '9.9' + Should -Invoke Find-PSResource -Times 1 + } + + It "reports which provider answered" { + Mock Find-Script { throw "broken" } + Mock Find-PSResource { [pscustomobject]@{ Type = 'Script'; Version = '9.9'; ReleaseNotes = 'n' } } + + (Find-GalleryWinClean).Provider | Should -Be 'PSResourceGet' + } + + It "tries PSResourceGet when Find-Script exists but fails" { + # Falling back only on ABSENCE let a present-but-broken PowerShellGet (an + # unregistered PSGallery, say) mask a PSResourceGet that would have answered. + # Each provider keeps its own repository registration + Mock Find-Script { throw "PSGallery is not registered for PowerShellGet" } + Mock Find-PSResource { [pscustomobject]@{ Type = 'Script'; Version = '9.9'; ReleaseNotes = 'n' } } + + (Find-GalleryWinClean).Version | Should -Be '9.9' + } + + It "REGRESSION: throws when both providers failed, instead of resembling 'up to date'" { + # The per-provider catches introduced for fallback swallowed the last error too, so + # an unregistered PSGallery, a TLS or proxy failure and an unpublished script all + # returned $null - which the caller reads as "asked, nothing newer" and prints + # nothing at all. Before those catches, the exception reached the caller and was + # logged. Failing to ask is not an answer + Mock Find-Script { throw "broken" } + Mock Find-PSResource { throw "also broken" } + + { Find-GalleryWinClean } | Should -Throw + } + + It "returns null when PSResourceGet only knows a module of that name" { + Mock Get-Command { $null } -ParameterFilter { $Name -eq 'Find-Script' } + Mock Find-PSResource { [pscustomobject]@{ Type = 'Module'; Version = '99.0' } } + + Find-GalleryWinClean | Should -BeNullOrEmpty + } +} + +Describe "Select-UpdateCommand" -Tag "Unit", "Helper", "V221" { + It "prefers PowerShellGet when discovery answered through it" { + Select-UpdateCommand -Provider 'PowerShellGet' | Should -Be 'Update-Script' + } + + It "prefers PSResourceGet when discovery answered through it, even with both installed" { + # The point of carrying the provider: on a machine where PowerShellGet exists but + # is broken, discovery succeeds through PSResourceGet and the updater must not go + # straight back to the provider that just failed + Select-UpdateCommand -Provider 'PSResourceGet' | Should -Be 'Update-PSResource' + } + + It "falls back to the other provider when the preferred one has no updater" { + Mock Get-Command { $null } -ParameterFilter { $Name -eq 'Update-PSResource' } + + Select-UpdateCommand -Provider 'PSResourceGet' | Should -Be 'Update-Script' + } + + It "defaults to PowerShellGet when the provider is " -ForEach @( + @{ name = 'unknown'; provider = $null } + @{ name = 'empty'; provider = '' } + @{ name = 'unrecognised'; provider = 'SomethingElse' } + ) { + Select-UpdateCommand -Provider $provider | Should -Be 'Update-Script' + } + + It "returns null when neither updater exists" { + Mock Get-Command { $null } -ParameterFilter { $Name -in 'Update-Script', 'Update-PSResource' } + + Select-UpdateCommand -Provider 'PowerShellGet' | Should -BeNullOrEmpty + } +} + +Describe "Test-ScriptUpdate" -Tag "Unit", "Helper", "V221" { + It "reports an update with the channel of the running copy" { + Mock Test-PSGalleryConnection { $true } + Mock Find-GalleryWinClean { @{ Version = '99.9'; ReleaseNotes = 'notes' } } + + $info = Test-ScriptUpdate + $info | Should -Not -BeNullOrEmpty + $info.LatestVersion | Should -Be '99.9' + $info.CurrentVersion | Should -Be ([Version]$script:Version).ToString() + # dot-sourced from the repository, so the running file is not a Gallery install + $info.Channel | Should -BeIn @('manual', 'installer', 'oneliner', 'gallery', 'gallery-ambiguous') + } + + It "classifies the channel from the installations actually found" { + # A positive control (raised in review): the -BeIn assertion above accepts five of + # six values, so replacing the real lookup with an empty list survived it. That + # mutation kills self-update outright and sends every user the installer advice, + # which is the exact "second installation" outcome this release removes + Mock Test-PSGalleryConnection { $true } + Mock Find-GalleryWinClean { @{ Version = '99.9'; ReleaseNotes = 'n'; Provider = 'PowerShellGet' } } + Mock Get-InstalledWinCleanLocation { @((Split-Path -Parent $script:WinCleanPath)) } + + (Test-ScriptUpdate).Channel | Should -Be 'gallery' + } + + It "carries the answering provider through to the caller" { + # Without this, dropping Provider here would silently send every machine back to + # PowerShellGet-first selection - including the ones where it is broken + Mock Test-PSGalleryConnection { $true } + Mock Find-GalleryWinClean { @{ Version = '99.9'; ReleaseNotes = 'n'; Provider = 'PSResourceGet' } } + + (Test-ScriptUpdate).Provider | Should -Be 'PSResourceGet' + } + + It "reports nothing when the Gallery is not newer" { + Mock Test-PSGalleryConnection { $true } + Mock Find-GalleryWinClean { @{ Version = '0.1'; ReleaseNotes = '' } } + + Test-ScriptUpdate | Should -BeNullOrEmpty + } + + It "reports nothing when no provider can answer" { + Mock Test-PSGalleryConnection { $true } + Mock Find-GalleryWinClean { $null } + + Test-ScriptUpdate | Should -BeNullOrEmpty + } + + It "turns a failure into a counted warning rather than silence" -ForEach @( + @{ name = 'discovery'; target = 'Find-GalleryWinClean' } + @{ name = 'installed-copy lookup'; target = 'Get-InstalledWinCleanLocation' } + ) { + # Both helpers now throw when they could not ask at all. That is only useful if the + # caller converts it into something a human or a log reader can see + Mock Test-PSGalleryConnection { $true } + Mock Find-GalleryWinClean { @{ Version = '99.9'; ReleaseNotes = 'n'; Provider = 'PowerShellGet' } } + Mock $target { throw "provider unavailable" } + $script:Stats.WarningsCount = 0 + + Test-ScriptUpdate | Should -BeNullOrEmpty + [int]$script:Stats.WarningsCount | Should -Be 1 + } +} + +Describe "Invoke-ScriptUpdate branches" -Tag "Unit", "Helper", "V221" { + # These branches were unreachable by the helper tests, which only proved that the + # instruction TEXT exists - not that Invoke-ScriptUpdate ever prints it (raised in + # review). Every branch here returns instead of reaching `exit 0`, so the suite is safe. + + BeforeEach { + $script:Stats.WarningsCount = 0 + $script:Stats.ErrorsCount = 0 + $script:Stats.Aborted = $null + $script:printed = [System.Collections.Generic.List[string]]::new() + Mock Write-Host { if ($Object) { $script:printed.Add([string]$Object) } } + # Without this the interactive branches block on a real console waiting for a + # keypress, which hung the whole suite until the process was killed + Mock Wait-ForKeyPress { } + # Pester 5 runs a Describe body during discovery only, so a variable declared there + # is gone by the time the tests run - it must be built per test + $script:info = @{ CurrentVersion = '2.20'; LatestVersion = '2.21'; Channel = 'installer' } + } + + It "prints the applicable instruction in ReportOnly mode instead of staying silent" { + $ReportOnly = $true + Invoke-ScriptUpdate -UpdateInfo $script:info + + ($script:printed -join "`n").Contains('install.ps1') | Should -BeTrue + } + + It "forwards the provider into the instruction" -ForEach @( + @{ mode = 'ReportOnly'; reportOnly = $true; interactive = $true } + @{ mode = 'non-interactive'; reportOnly = $false; interactive = $false } + ) { + # These two call sites pass -Provider; dropping it from either would advise + # Update-Script on a machine that just proved PowerShellGet does not work, and + # the direct Get-UpdateInstruction test would not notice + $ReportOnly = $reportOnly + Mock Test-InteractiveConsole { $interactive } + # Not optional (raised in review): this case is Channel='gallery' and interactive, + # so if the ReportOnly early return is ever lost, an unmocked run would call the + # real Update-Script on whatever machine executes the suite + Mock Read-Host { 'n' } + Mock Update-Script { } + Mock Update-PSResource { } + + Invoke-ScriptUpdate -UpdateInfo @{ CurrentVersion = '2.20'; LatestVersion = '2.21' + Channel = 'gallery'; Provider = 'PSResourceGet' } + + Should -Invoke Update-Script -Times 0 + Should -Invoke Update-PSResource -Times 0 + $text = $script:printed -join "`n" + $text.Contains('Update-PSResource') | Should -BeTrue + $text.Contains('Update-Script') | Should -BeFalse + } + + It "prints the running path in the instruction" -ForEach @( + @{ channel = 'gallery-ambiguous'; interactive = $true } + @{ channel = 'installer'; interactive = $false } + ) { + # -ExecutingPath was pinned at one call site only, so dropping it from the other + # three survived every test - including the ambiguous branch, where the printed + # path is the only way a reader can tell the installations apart + Mock Test-InteractiveConsole { $interactive } + Mock Read-Host { 'n' } + Mock Update-Script { } + + Invoke-ScriptUpdate -UpdateInfo @{ CurrentVersion = '2.20'; LatestVersion = '2.21' + Channel = $channel; Provider = 'PowerShellGet' } + + if ($channel -eq 'gallery-ambiguous') { + ($script:printed -join "`n").Contains($script:WinCleanPath) | Should -BeTrue + } else { + # 'installer' does not print the path, and the docs must not claim it does + ($script:printed -join "`n").Contains('install.ps1') | Should -BeTrue + } + } + + It "verifies the update against the file that is running" { + # Dropping -Path $PSCommandPath survived every test, because they all mock + # Get-ScriptFileVersion and none checks what it was asked about. The effect would be + # a false "could not be read back" on every genuinely successful update + # Captured directly rather than via -ParameterFilter: the filter form passed even + # with the path mutated to a nonsense value, so it was proving nothing + $script:askedPath = 'never set' + Mock Test-InteractiveConsole { $true } + Mock Read-Host { 'y' } + Mock Update-Script { } + Mock Get-ScriptFileVersion { $script:askedPath = $Path; '2.20' } + + Invoke-ScriptUpdate -UpdateInfo @{ CurrentVersion = '2.20'; LatestVersion = '2.21' + Channel = 'gallery'; Provider = 'PowerShellGet' } + + $script:askedPath | Should -Be $script:WinCleanPath + } + + It "writes the result JSON before exiting on a verified update" { + # The success path ends in `exit 0`, which bypasses the finally in Start-WinClean - + # and Start-WinClean has already deleted any previous result file by then, so + # without this a consumer saw exit code 0 and no artefact, indistinguishable from a + # crash. Wait-ForKeyPress is the last statement before the exit, so throwing from it + # proves the end of the path was reached without letting the suite exit + Mock Test-InteractiveConsole { $true } + Mock Read-Host { 'y' } + Mock Update-Script { } + Mock Get-ScriptFileVersion { '2.21' } + Mock Write-ResultJson { } + Mock Wait-ForKeyPress { throw 'REACHED_EXIT' } + + { Invoke-ScriptUpdate -UpdateInfo @{ CurrentVersion = '2.20'; LatestVersion = '2.21' + Channel = 'gallery'; Provider = 'PowerShellGet' } } | + Should -Throw -ExpectedMessage 'REACHED_EXIT' + + Should -Invoke Write-ResultJson -Times 1 + $script:Stats.Aborted | Should -Be 'UpdatedAndExited' + ($script:printed -join "`n").Contains('Update complete') | Should -BeTrue + } + + It "never calls an updater for a copy that is not Gallery-managed" { + Mock Test-InteractiveConsole { $false } + Mock Update-Script { } + Mock Update-PSResource { } + + Invoke-ScriptUpdate -UpdateInfo $script:info + + Should -Invoke Update-Script -Times 0 + Should -Invoke Update-PSResource -Times 0 + ($script:printed -join "`n").Contains('install.ps1') | Should -BeTrue + } + + It "declines to update when several Gallery installations exist, in an interactive session" { + # Interactive on purpose: with a non-interactive console the function returns + # earlier, so a regression treating 'gallery-ambiguous' as updatable would only + # show up here. Read-Host would say yes if it were ever asked + Mock Test-InteractiveConsole { $true } + Mock Read-Host { 'y' } + Mock Update-Script { } + Mock Update-PSResource { } + + Invoke-ScriptUpdate -UpdateInfo @{ CurrentVersion = '2.20'; LatestVersion = '2.21' + Channel = 'gallery-ambiguous' } + + Should -Invoke Update-Script -Times 0 + Should -Invoke Update-PSResource -Times 0 + $text = $script:printed -join "`n" + $text.Contains('cannot tell which one an automatic update would change') | Should -BeTrue + # this copy IS Gallery-managed; saying otherwise contradicts the reason it is here + $text.Contains('does not match a Gallery installation') | Should -BeFalse + # and the advice must end somewhere the reader can actually act + $text.Contains('releases/latest') | Should -BeTrue + } + + It "prints the path of the running copy so the installations can be told apart" { + # Every production call passes -ExecutingPath; without this assertion, dropping it + # from all four call sites would leave the suite green + Mock Test-InteractiveConsole { $false } + + Invoke-ScriptUpdate -UpdateInfo @{ CurrentVersion = '2.20'; LatestVersion = '2.21' + Channel = 'gallery-ambiguous' } + + ($script:printed -join "`n").Contains($script:WinCleanPath) | Should -BeTrue + } + + It "counts a failed update as a warning, so the run does not exit non-zero for it" { + Mock Test-InteractiveConsole { $true } + Mock Read-Host { 'y' } + Mock Update-Script { throw "gallery unreachable" } + + Invoke-ScriptUpdate -UpdateInfo @{ CurrentVersion = '2.20'; LatestVersion = '2.21' + Channel = 'gallery' } + + [int]$script:Stats.ErrorsCount | Should -Be 0 + [int]$script:Stats.WarningsCount | Should -Be 1 + } + + It "REGRESSION: an update that changed nothing is reported, not announced as complete" { + Mock Test-InteractiveConsole { $true } + Mock Read-Host { 'y' } + Mock Update-Script { } # reports success, changes nothing + Mock Get-ScriptFileVersion { '2.20' } # the running file stayed old + + Invoke-ScriptUpdate -UpdateInfo @{ CurrentVersion = '2.20'; LatestVersion = '2.21' + Channel = 'gallery' } + + [int]$script:Stats.WarningsCount | Should -Be 1 + $text = $script:printed -join "`n" + $text.Contains('Update complete') | Should -BeFalse + $text.Contains('still reports v2.20') | Should -BeTrue + # and it must not deny this copy's provenance or advise a second installation + $text.Contains('does not match a PowerShell Gallery installation') | Should -BeFalse + $text.Contains('install.ps1') | Should -BeFalse + } + + It "names the version actually read back, not the one the process started with" { + Mock Test-InteractiveConsole { $true } + Mock Read-Host { 'y' } + Mock Update-Script { } + Mock Get-ScriptFileVersion { '2.13' } # a third copy, older than either + + Invoke-ScriptUpdate -UpdateInfo @{ CurrentVersion = '2.20'; LatestVersion = '2.21' + Channel = 'gallery' } + + ($script:printed -join "`n").Contains('still reports v2.13') | Should -BeTrue + } + + It "does not update when the user declines" { + Mock Test-InteractiveConsole { $true } + Mock Read-Host { 'n' } + Mock Update-Script { } + + Invoke-ScriptUpdate -UpdateInfo @{ CurrentVersion = '2.20'; LatestVersion = '2.21' + Channel = 'gallery' } + + Should -Invoke Update-Script -Times 0 + } + + It "treats an empty answer as yes, as the Y/n prompt promises" { + Mock Test-InteractiveConsole { $true } + Mock Read-Host { '' } + Mock Update-Script { } + Mock Get-ScriptFileVersion { '2.20' } # keeps the test out of the exit 0 path + + Invoke-ScriptUpdate -UpdateInfo @{ CurrentVersion = '2.20'; LatestVersion = '2.21' + Channel = 'gallery' } + + Should -Invoke Update-Script -Times 1 + } + + It "uses Update-PSResource when Update-Script is unavailable" { + # Named for what it proves: the updater layer alone. Reaching this on a real + # PSResourceGet-only machine also needs discovery to work there, which is pinned + # separately by the Find-GalleryWinClean tests + Mock Test-InteractiveConsole { $true } + Mock Read-Host { 'y' } + Mock Get-Command { $null } -ParameterFilter { $Name -eq 'Update-Script' } + Mock Update-PSResource { } + Mock Get-ScriptFileVersion { '2.20' } + + Invoke-ScriptUpdate -UpdateInfo @{ CurrentVersion = '2.20'; LatestVersion = '2.21' + Channel = 'gallery' } + + Should -Invoke Update-PSResource -Times 1 + } + + It "updates through the provider that answered discovery, not the one that failed" { + # End to end for the broken-PowerShellGet machine: discovery fell back to + # PSResourceGet, so the update must use it too even though Update-Script exists + Mock Test-InteractiveConsole { $true } + Mock Read-Host { 'y' } + Mock Update-Script { } + Mock Update-PSResource { } + Mock Get-ScriptFileVersion { '2.20' } + + Invoke-ScriptUpdate -UpdateInfo @{ CurrentVersion = '2.20'; LatestVersion = '2.21' + Channel = 'gallery'; Provider = 'PSResourceGet' } + + Should -Invoke Update-PSResource -Times 1 + Should -Invoke Update-Script -Times 0 + } + + It "reports a counted warning when no update provider exists at all" { + Mock Test-InteractiveConsole { $true } + Mock Read-Host { 'y' } + Mock Get-Command { $null } -ParameterFilter { $Name -in 'Update-Script', 'Update-PSResource' } + + Invoke-ScriptUpdate -UpdateInfo @{ CurrentVersion = '2.20'; LatestVersion = '2.21' + Channel = 'gallery' } + + [int]$script:Stats.ErrorsCount | Should -Be 0 + [int]$script:Stats.WarningsCount | Should -Be 1 + } + + It "says the version could not be read back rather than claiming success" { + Mock Test-InteractiveConsole { $true } + Mock Read-Host { 'y' } + Mock Update-Script { } + Mock Get-ScriptFileVersion { $null } # the file cannot be read after the update + + Invoke-ScriptUpdate -UpdateInfo @{ CurrentVersion = '2.20'; LatestVersion = '2.21' + Channel = 'gallery' } + + [int]$script:Stats.WarningsCount | Should -Be 1 + $text = $script:printed -join "`n" + $text.Contains('could not be read back') | Should -BeTrue + $text.Contains('Update complete') | Should -BeFalse + } +} + +Describe "Update-Applications when winget is absent" -Tag "Unit", "Helper", "V221" { + + BeforeEach { + $script:Stats.ErrorsCount = 0 + $script:Stats.WarningsCount = 0 + } + + It "counts a machine without winget as a warning, so the run can still exit 0" { + # The exit code is computed from ErrorsCount alone. Counting a missing optional + # tool as an error made every run on such a machine exit 1 with all nine phases + # completed, which any scheduler or CI job reads as a failed run. + # ReportOnly as a safety net (raised in review): this is the only test that calls a + # top-level phase function rather than a pure helper, and its safety otherwise rests + # entirely on two -ParameterFilter mocks continuing to match. If either stops + # matching after a refactor, the unguarded version would run `winget upgrade --all` + # for real on whatever machine runs the suite - including the workstation where the + # release gate runs Pester. The winget-not-found branch is reached identically. + $ReportOnly = $true + Mock Test-InternetConnection { $true } + Mock Update-Progress { } + Mock Get-Command { $null } -ParameterFilter { $Name -eq 'winget.exe' } + Mock Test-Path { $false } -ParameterFilter { "$Path".Contains('winget.exe') } + + Update-Applications + + [int]$script:Stats.ErrorsCount | Should -Be 0 + [int]$script:Stats.WarningsCount | Should -Be 1 + # The count alone is ambiguous now that this no longer fails the run: 0 offered + # means "nothing to upgrade" only when the check actually happened + $script:Stats.AppUpdatesStatus | Should -Be 'skipped-no-winget' + } + + It "records the parameter skip distinctly from a missing winget" { + $SkipUpdates = $true + Mock Update-Progress { } + + Update-Applications + + $script:Stats.AppUpdatesStatus | Should -Be 'skipped-parameter' + } + + It "sets the parameter skip where production can reach it, not only inside the function" { + # Structural, because the behavioural test above enters Update-Applications + # directly while production never does when -SkipUpdates is passed: Invoke-Phase + # -Skip stops the dispatch, so the in-function branch is unreachable there and the + # status would stay 'not-run'. This pins the assignment that Start-WinClean makes + # before the phase, which no behavioural test in this suite can reach. + $source = Get-Content $script:WinCleanPath -Raw + $dispatch = $source.IndexOf("Invoke-Phase -Name 'Updates'") + $dispatch | Should -BeGreaterThan 0 + $preamble = $source.Substring([math]::Max(0, $dispatch - 400), [math]::Min(400, $dispatch)) + $preamble.Contains("AppUpdatesStatus = 'skipped-parameter'") | Should -BeTrue + } + + It "keeps a present-but-failing winget an ERROR, and does not call that check 'checked'" { + # Two things the docs promise and nothing pinned: the upgrade check exiting non-zero + # stays an error (unlike a MISSING winget), and the status must not read 'checked' + # for a check that produced no list - which is the ambiguity the field exists to end. + # ReportOnly keeps the source-update branch out of it; every external call is mocked. + $ReportOnly = $true + Mock Update-Progress { } + Mock Test-InternetConnection { $true } + Mock Get-Command { [pscustomobject]@{ Source = 'C:\fake\winget.exe' } } -ParameterFilter { $Name -eq 'winget.exe' } + Mock Start-Process { + $proc = [pscustomobject]@{ ExitCode = 1 } + $proc | Add-Member -MemberType ScriptMethod -Name WaitForExit -Value { param($ms) $true } + $proc | Add-Member -MemberType ScriptMethod -Name Kill -Value { param($tree) } + $proc + } + Mock Get-Content { '' } + Mock Remove-Item { } + + Update-Applications + + [int]$script:Stats.ErrorsCount | Should -Be 1 + $script:Stats.AppUpdatesStatus | Should -Be 'check-failed' + } + + It "records an offline machine distinctly, and that branch is still an error" { + # docs/troubleshooting.md promises this boundary explicitly: a missing winget is a + # warning, but no connectivity remains an error and still fails the run + Mock Update-Progress { } + Mock Test-InternetConnection { $false } + + Update-Applications + + $script:Stats.AppUpdatesStatus | Should -Be 'skipped-offline' + [int]$script:Stats.ErrorsCount | Should -Be 1 + } +} + +#endregion From eb5777efd3e21505f1d4290de6ace86a25090fdd Mon Sep 17 00:00:00 2001 From: Ivan Bondarev Date: Wed, 22 Jul 2026 23:51:04 +0300 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=D0=BE=D1=82=D1=81=D1=83=D1=82=D1=81?= =?UTF-8?q?=D1=82=D0=B2=D0=B8=D0=B5=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D0=BD?= =?UTF-8?q?=D0=B5=D1=82=D0=B0=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D1=80=D0=BE=D0=BD=D1=8F=D0=B5=D1=82=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=20(MyAI-7iej)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тот же класс, что отсутствие winget, и та же причина: код возврата считается только по ErrorsCount, поэтому машина без сети ВСЕГДА завершалась с кодом 1 - сколь бы полно ни отработала очистка. Ноутбук, обслуживаемый вне сети, рапортовал провал на каждом запуске. Обе половины фазы Updates (Windows Update и приложения) читают один мемоизованный результат проверки связности, поэтому понижены обе - оставить одну ошибкой значило бы сохранить код 1 и обессмыслить изменение. Состояние не исчезает из виду: WARNING логируется и считается, а result JSON несёт AppUpdatesStatus 'skipped-offline', который описывает всю фазу, включая половину Windows. Отдельное поле для Windows не заводилось именно поэтому. Осознанно НЕ тронуто: "PowerShell Gallery is unavailable" и соседние ветки (NuGet-провайдер, установка модуля) остаются ERROR. Это не "нет сети": проверка связности к тому моменту УЖЕ прошла, то есть сеть доказана рабочей, а не смогли получить зависимость, которую WinClean сам решил ставить. Отсутствующая предпосылка и неудавшаяся операция - разные вещи, и код возврата их различает. Разграничение проверено сторонним ревью и явно описано в docs/troubleshooting.md, где раздел про галерею теперь отсылает офлайн-читателя в раздел про связность. Доки исправлены там, где сами утверждали обратное: docs/result-json.md и docs/troubleshooting.md прямым текстом писали, что офлайн-ветка остаётся ошибкой. Заодно везде уточнено, что Updates - ОДНА фаза с двумя половинами, а не две фазы: формальные имена фаз попадают в result JSON, и прежняя формулировка читалась как два элемента. 571 -> 573 теста. Помимо двух тестов на уровень для каждой половины добавлен комбинированный: он готовит сам кеш связности, а не мокает проверку, потому что именно общий кеш и есть предпосылка, позволяющая одному полю описывать обе половины. Обе мутации (возврат любой ветки к ERROR) ловятся. --- CHANGELOG.md | 7 +++++++ CLAUDE.md | 10 +++++----- CONTRIBUTING.md | 4 ++-- WinClean.ps1 | 26 +++++++++++++++++-------- docs/result-json.md | 2 +- docs/troubleshooting.md | 10 +++++++--- tests/Helpers.Tests.ps1 | 42 +++++++++++++++++++++++++++++++++++++---- 7 files changed, 78 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab95d78..ba2642e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 a contradiction in the one place that has to be believable. Failing to update the script is not a failure of the maintenance that was actually requested, so it is now a counted warning and the exit code agrees with the log +- **No internet connection is a warning, not an error.** Same rule as the missing `winget` + below, and for the same measurable reason: the exit code comes from the error count + alone, so a machine with no connectivity ended **every** run with code 1 no matter how + completely the cleanup succeeded - a laptop that runs maintenance away from the network + reported failure forever. Both update functions read the same connectivity check, so + both are warnings now, and the state stays visible where it belongs: in the log and in + `AppUpdatesStatus: "skipped-offline"`, which covers the Windows half too - **A missing `winget` is a warning, not an error.** The exit code is computed from the error count alone, so every run on a machine without App Installer ended with code 1 while all nine phases completed - which any scheduler, CI job or test harness reads as a diff --git a/CLAUDE.md b/CLAUDE.md index 01c38d5..10c955b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,8 +43,8 @@ CleanScript/ │ └── logo.svg # Логотип проекта ├── get.ps1 # Bootstrap: разовый запуск одной командой (irm | iex) ├── install.ps1 # Bootstrap: установка/обновление + ярлык (RunAs admin) -├── tests/ # Pester тесты (571 всего; счётчик - прогоном, не грепом) -│ ├── Helpers.Tests.ps1 # Unit-тесты helper-функций (278, дот-сорсят продукт - нужны права админа) +├── tests/ # Pester тесты (573 всего; счётчик - прогоном, не грепом) +│ ├── Helpers.Tests.ps1 # Unit-тесты helper-функций (280, дот-сорсят продукт - нужны права админа) │ ├── Fixes.Tests.ps1 # Валидационные тесты исправлений (183) │ ├── Integration.Tests.ps1 # Интеграционные тесты в песочнице ФС (67, требуют admin) │ ├── StandHelpers.Tests.ps1 # Чистые хелперы стенда: dead-man + версионный гейт ассертов (17, без admin) @@ -198,7 +198,7 @@ Publish-PSResource -Path .\WinClean.ps1 -Repository PSGallery -ApiKey $env:PSGAL **Проверки (4 job'а, счёт сверять gh run view --json jobs, а не этой строкой):** 1. **lint** - PSScriptAnalyzer (Error+Warning) через общий ools/Invoke-Lint.ps1 - тот же, что зовёт релиз-гейт 2. **syntax** - Проверка синтаксиса PowerShell -3. **test** - Pester тесты (571; интеграционные требуют admin - на GitHub runners это выполняется) +3. **test** - Pester тесты (573; интеграционные требуют admin - на GitHub runners это выполняется) 4. **smoke** - прогон -ReportOnly + геометрия рамок + result JSON **Исключения PSScriptAnalyzer** (допустимые для CLI): @@ -208,7 +208,7 @@ Publish-PSResource -Path .\WinClean.ps1 -Repository PSGallery -ApiKey $env:PSGAL ### Pester тесты (v2.13+) -- `tests/Helpers.Tests.ps1` - 278 unit-тестов (дот-сорсят WinClean.ps1), `tests/Fixes.Tests.ps1` - 183, `tests/Integration.Tests.ps1` - 67 (песочница ФС, требуют admin), `tests/StandHelpers.Tests.ps1` - 17 (без admin), `tests/Docs.Tests.ps1` - 26 (гварды доков, без admin) +- `tests/Helpers.Tests.ps1` - 280 unit-тестов (дот-сорсят WinClean.ps1), `tests/Fixes.Tests.ps1` - 183, `tests/Integration.Tests.ps1` - 67 (песочница ФС, требуют admin), `tests/StandHelpers.Tests.ps1` - 17 (без admin), `tests/Docs.Tests.ps1` - 26 (гварды доков, без admin) - Особенности: функции в BeforeAll (не AST), regex для locale-независимости, отдельные It блоки --- @@ -379,7 +379,7 @@ pwsh tools/Invoke-ReleaseCheck.ps1 # версия во всех pwsh tools/Invoke-ReleaseCheck.ps1 -IncludeStand # + боевой прогон на VM (минуты) pwsh tools/Invoke-ReleaseCheck.ps1 -VerifyPublished # ПОСЛЕ выпуска: ассеты релиза и SHA256 -Invoke-Pester ./tests -Output Detailed # 571 Pester тестов (считать прогоном, не грепом) +Invoke-Pester ./tests -Output Detailed # 573 Pester тестов (считать прогоном, не грепом) pwsh tools/Invoke-SmokeTest.ps1 # Смоук: ReportOnly + геометрия UI pwsh tools/proxmox/Invoke-StandTest.ps1 -Mode Report # Стенд на Proxmox (RU=VM 190, EN: -ConfigPath ...en.json = VM 191) # Ночная матрица: cron 03:30 на proxmos (/opt/winclean-stand, /etc/cron.d/winclean-stand), отчёт в Telegram diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 667aae5..6a015f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -128,7 +128,7 @@ Invoke-Pester ./tests/Integration.Tests.ps1 All PRs automatically run: - PSScriptAnalyzer (linting) - Syntax check -- Pester tests (571 tests) +- Pester tests (573 tests) ### Release-impacting changes @@ -251,7 +251,7 @@ Invoke-Pester ./tests/Integration.Tests.ps1 Все PR автоматически проходят: - PSScriptAnalyzer (линтинг) - Проверка синтаксиса -- Pester тесты (571 тестов) +- Pester тесты (573 тестов) ### Изменения, влияющие на релиз diff --git a/WinClean.ps1 b/WinClean.ps1 index 7b05518..0bddb17 100644 --- a/WinClean.ps1 +++ b/WinClean.ps1 @@ -340,7 +340,7 @@ function New-RunStats { # apps actually installed (it silently skips pinned/manifest-less/UAC-cancelled ones), # so we only ever know how many it OFFERED. Naming it "installed" was a false claim. AppUpdatesOffered = 0 - # v2.21: why the app-update phase produced the count it did. Added because demoting a + # v2.21: why the app half of the Updates phase produced the count it did. Added because demoting a # missing winget from error to warning removed the only machine-readable way to tell # "checked, nothing to upgrade" from "could not check at all" - both are # AppUpdatesOffered = 0 with WarningsCount incremented by one of many possible causes. @@ -383,7 +383,7 @@ $script:Stats = New-RunStats $script:ProgressActivities = @() # Memoized Test-InternetConnection result for the whole run (v2.17, p.5 of the audit): -# the check costs up to ~15s offline and is called from two separate update phases +# the check costs up to ~15s offline and is called from both halves of the Updates phase $script:InternetConnectionCache = $null # Latched by Write-Log when the log file cannot be written, so the failure is reported @@ -762,8 +762,8 @@ function Test-InternetConnection { .DESCRIPTION Использует TcpClient с явным таймаутом (3 сек) вместо Test-NetConnection, который может зависать на 20-30 секунд при VPN или нестабильном соединении. - Результат кэшируется на весь прогон (v2.17): вызывается из двух фаз - (Windows Update, Applications Update), до 15 сек на офлайн-машине каждый раз. + Результат кэшируется на весь прогон (v2.17): вызывается из обеих половин фазы + Updates (Windows Update, Applications Update), до 15 сек на офлайн-машине каждый раз. Сетевая связность внутри одного прогона скрипта не меняется настолько часто, чтобы повторная проверка была оправдана. -Force сбрасывает кэш. #> @@ -2717,8 +2717,15 @@ function Update-WindowsSystem { } if (-not (Test-InternetConnection)) { - Write-Log "No internet connection - skipping Windows Update" -Level ERROR - $script:Stats.ErrorsCount++ + # v2.21: a warning, not an error, for the same reason a missing winget is one - the + # exit code is computed from ErrorsCount alone, so an offline machine ended every + # run with code 1 no matter how completely the cleanup succeeded, and a laptop that + # runs maintenance away from the network reported failure forever. Having no + # connectivity is a state of the environment, not a failure of this run. It stays + # visible: the warning is logged and counted, and the result JSON carries + # AppUpdatesStatus = 'skipped-offline' for the whole Updates phase. + Write-Log "No internet connection - skipping Windows Update" -Level WARNING + $script:Stats.WarningsCount++ return } @@ -2937,9 +2944,12 @@ function Update-Applications { } if (-not (Test-InternetConnection)) { - Write-Log "No internet connection - skipping app updates" -Level ERROR + # v2.21: warning, matching Update-WindowsSystem above - see the reasoning there. + # Both halves read the same memoised connectivity check, so this status describes + # the whole Updates phase, not only the winget half. + Write-Log "No internet connection - skipping app updates" -Level WARNING $script:Stats.AppUpdatesStatus = 'skipped-offline' - $script:Stats.ErrorsCount++ + $script:Stats.WarningsCount++ return } diff --git a/docs/result-json.md b/docs/result-json.md index d686fe9..ac22a2d 100644 --- a/docs/result-json.md +++ b/docs/result-json.md @@ -72,7 +72,7 @@ Consequently: | `checked` | winget was found, asked, and returned a list. `AppUpdatesOffered` is a real answer. | | `check-failed` | winget was found but the check did not produce a list - it timed out, exited non-zero, or could not be completed at all. The count is meaningless. | | `skipped-no-winget` | winget is not installed on this machine. The run continues; a warning is logged. | -| `skipped-offline` | No connectivity, so the update phases stopped before winget. This branch is still an **error**. | +| `skipped-offline` | No connectivity. Both halves of the Updates phase (Windows and apps) read the same connectivity check, so this value describes the whole phase, not just winget. A warning, not an error, since v2.21. | | `skipped-parameter` | `-SkipUpdates` was passed. | | `not-run` | The phase never executed (for example the run aborted earlier). | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b532f1d..3d215c2 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -21,11 +21,11 @@ If the check itself cannot run (third-party antivirus, stripped image, broken WM ## App updates are skipped (winget not found) -If `winget` is not installed, the application-update phase is skipped and the run continues. Windows updates (via PSWindowsUpdate) are unaffected. +If `winget` is not installed, the app half of the Updates phase is skipped and the run continues. Windows updates (via PSWindowsUpdate) are unaffected. This is reported as a **warning**, and the run can still finish with **exit code 0**. Before v2.21 it was an error, and since the exit code is derived from the error count alone, every run on a machine without App Installer ended with code 1 even though all nine phases completed - which any scheduler or CI job reads as a failed run. A missing optional third-party tool is a property of the machine, not a failure of the run, the same way a machine without Docker is a normal machine here. A `winget` that **is** present and then fails is still reported, at a severity that follows whether the run can safely carry on: the upgrade check failing outright or an unhandled error is an error, while a stale package source, a timeout or a partly failed upgrade batch is a warning. -Note that a machine with **no internet connection** never reaches the winget check: the update phases stop earlier, and that earlier branch is still an error. So "a missing winget no longer fails the run" holds for an online machine without App Installer, not for an offline one. +A machine with **no internet connection** never reaches the winget check at all: both halves of the Updates phase stop earlier, on the same connectivity check. Since v2.21 that branch is also a warning rather than an error, for the same reason - a laptop running maintenance away from the network otherwise reported failure on every run, however completely the cleanup succeeded. The result JSON records `AppUpdatesStatus: "skipped-offline"`, which covers the Windows half too. **Fix:** install **App Installer** from the Microsoft Store, or bootstrap winget, then re-run. Note that the app-update count in the summary is what winget **offered**, not a confirmed install count (see [FAQ](faq.md) and [result-json.md](result-json.md)). @@ -124,7 +124,11 @@ Either way the summary line stays truthful: it counts updates **offered**, not i ## Windows updates are skipped (PowerShell Gallery unreachable) -WinClean installs the `PSWindowsUpdate` module from the PowerShell Gallery. If the Gallery is unreachable (offline, proxy, TLS), the module cannot install and the Windows-update phase is skipped. The rest of the run continues normally. +WinClean installs the `PSWindowsUpdate` module from the PowerShell Gallery. If the Gallery is unreachable while general connectivity works (a proxy, a TLS-inspecting appliance, a blocked host), the module cannot install and the Windows half of the Updates phase is skipped. The rest of the run continues normally. + +This is **not** the offline case. A machine with no connectivity at all returns earlier, at the shared connectivity check described [above](#app-updates-are-skipped-winget-not-found), and never attempts the module install - so if you are simply offline, that section applies, not this one. + +Unlike the offline branch, this one is still an **error** and the run exits non-zero: connectivity was proven to work, and then a dependency WinClean needs could not be fetched - an attempted operation that failed rather than a precondition that was absent. This path is guarded by timeouts, so a stuck Gallery cannot hang the whole run. You will see a clear message with manual-install instructions. diff --git a/tests/Helpers.Tests.ps1 b/tests/Helpers.Tests.ps1 index 3524b17..b5c5822 100644 --- a/tests/Helpers.Tests.ps1 +++ b/tests/Helpers.Tests.ps1 @@ -2373,16 +2373,50 @@ Describe "Update-Applications when winget is absent" -Tag "Unit", "Helper", "V22 $script:Stats.AppUpdatesStatus | Should -Be 'check-failed' } - It "records an offline machine distinctly, and that branch is still an error" { - # docs/troubleshooting.md promises this boundary explicitly: a missing winget is a - # warning, but no connectivity remains an error and still fails the run + It "counts an offline machine as a warning too, so the run can still exit 0" { + # v2.21 extends the missing-winget reasoning to connectivity: an offline laptop + # used to end every run with code 1 no matter how completely the cleanup worked. + # The state stays visible through the status field rather than the exit code Mock Update-Progress { } Mock Test-InternetConnection { $false } Update-Applications $script:Stats.AppUpdatesStatus | Should -Be 'skipped-offline' - [int]$script:Stats.ErrorsCount | Should -Be 1 + [int]$script:Stats.ErrorsCount | Should -Be 0 + [int]$script:Stats.WarningsCount | Should -Be 1 + } + + It "counts the Windows half of an offline run as a warning as well" { + # Both update functions read the same memoised connectivity check, so leaving one + # of them an error would keep the exit code at 1 and make the change pointless + Mock Update-Progress { } + Mock Test-InternetConnection { $false } + + Update-WindowsSystem + + [int]$script:Stats.ErrorsCount | Should -Be 0 + [int]$script:Stats.WarningsCount | Should -Be 1 + } + + It "runs both halves offline through the real shared cache and still exits clean" { + # The premise that lets one status field describe both halves is the SHARED cache, + # which the two tests above bypass by mocking the check itself. Here the cache is + # primed directly and both functions are called in the order Start-WinClean uses, + # so a regression in the caching or in status propagation shows up. It does NOT + # exercise the dispatcher itself: reordering the two calls inside Start-WinClean + # would still pass, and running the real phase would run real maintenance + $previousCache = $script:InternetConnectionCache + $script:InternetConnectionCache = $false + Mock Update-Progress { } + try { + Update-WindowsSystem + Update-Applications + } finally { $script:InternetConnectionCache = $previousCache } + + $script:Stats.AppUpdatesStatus | Should -Be 'skipped-offline' + [int]$script:Stats.ErrorsCount | Should -Be 0 + [int]$script:Stats.WarningsCount | Should -Be 2 } } From 0712e6e33b20377aa2d636310bf623fdbfdda599 Mon Sep 17 00:00:00 2001 From: Ivan Bondarev Date: Thu, 23 Jul 2026 00:12:19 +0300 Subject: [PATCH 3/3] release: v2.21 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Версия поднята во всех местах: .VERSION, .RELEASENOTES, SYNOPSIS, NOTES с блоком изменений, $script:Version, диаграммы Execution Flow в обоих README. Бейджи динамические с 2.19 и правки не требуют. CHANGELOG: раздел [Unreleased] закрыт как [2.21] с датой; добавлены записи про документацию result JSON и про тесты (452 -> 573, 21 мутация). Релиз-гейт 8/10: провалены только 'дерево чисто' и 'ветка синхронна с origin' - оба про сам этот коммит. Версия 2.21 подтверждена из result JSON смоук-прогона. --- CHANGELOG.md | 23 +++++++++++++++++++++-- README.md | 2 +- README_RU.md | 2 +- WinClean.ps1 | 21 +++++++++++++++++---- 4 files changed, 40 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba2642e..c13266a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +Planned for a later release: quick system health section (SMART, image integrity, WinRE), +Windows Update driver listing, run-to-run delta and HTML report. See CLAUDE.md. + +--- + +## [2.21] - 2026-07-23 + +A release about telling the truth on the way out. The self-update could change a file +other than the one being run and still print "Update complete", and the exit code called +a perfectly good maintenance run a failure whenever the machine simply lacked an optional +tool or a network. Both are the same underlying habit: reporting a state the run did not +actually verify. + ### Fixed - **The self-update updated a file that was not being run, and reported success.** The @@ -88,9 +101,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `docs/troubleshooting.md` explains the "update complete but the version never changes" symptom, how to inspect and clean up a two-installation machine, and states plainly that a missing `winget` no longer makes the run exit non-zero +- `docs/result-json.md` documents `AppUpdatesStatus`, the new `UpdatedAndExited` value of + `Aborted`, and what each status means for reading `AppUpdatesOffered` -Planned for a later release: quick system health section (SMART, image integrity, WinRE), -Windows Update driver listing, run-to-run delta and HTML report. See CLAUDE.md. +### Tests + +- 452 to 573. New coverage for update-channel classification, provider discovery and + selection across both package providers, on-disk verification of an applied update, and + the severity of every skip in the Updates phase. Twenty-one mutations were run against + the new guards and each one failed a test --- diff --git a/README.md b/README.md index ac4780b..60b5850 100644 --- a/README.md +++ b/README.md @@ -321,7 +321,7 @@ WinClean is built to be safe to run on a working machine. The short version: ``` ┌────────────────────────────────────────────────────────────────┐ -│ WinClean v2.20 │ +│ WinClean v2.21 │ ├────────────────────────────────────────────────────────────────┤ │ PREPARATION │ │ ├─ ✓ Check Administrator Rights │ diff --git a/README_RU.md b/README_RU.md index e54363c..186589e 100644 --- a/README_RU.md +++ b/README_RU.md @@ -321,7 +321,7 @@ WinClean создан так, чтобы его можно было безопа ``` ┌────────────────────────────────────────────────────────────────┐ -│ WinClean v2.20 │ +│ WinClean v2.21 │ ├────────────────────────────────────────────────────────────────┤ │ ПОДГОТОВКА │ │ ├─ ✓ Проверка прав администратора │ diff --git a/WinClean.ps1 b/WinClean.ps1 index 0bddb17..84925f1 100644 --- a/WinClean.ps1 +++ b/WinClean.ps1 @@ -1,5 +1,5 @@ <#PSScriptInfo -.VERSION 2.20 +.VERSION 2.21 .GUID 8f7c3b2a-1d4e-5f6a-9b8c-0d1e2f3a4b5c .AUTHOR bivlked .COMPANYNAME @@ -12,6 +12,7 @@ .REQUIREDSCRIPTS .EXTERNALSCRIPTDEPENDENCIES .RELEASENOTES + v2.21: Self-update targeting and honest exit codes - the update could change a file that was not the one running and still report success; a missing winget or no connectivity no longer fails the run v2.20: Correctness and honesty round - a junction could bypass protected-path checks, four operations reported success while doing nothing, Storage Sense was unreachable so on machines where it works the slow Disk Cleanup no longer runs; where it fails, the new -SkipDiskCleanup is what removes the wait v2.19: Contract and documentation round - -SkipCleanup now skips ALL cleanup categories, result JSON gains a tri-state PhasesSkipped, AppUpdatesCount renamed to AppUpdatesOffered (offered, not installed), full docs overhaul v2.18: Correctness and hardening follow-up from external code review - diskpart failure detection, driver-store accounting, strict superseded-version rule, exact bootstrap host allowlist @@ -29,7 +30,7 @@ <# .SYNOPSIS - WinClean - Ultimate Windows 11 Maintenance Script v2.20 + WinClean - Ultimate Windows 11 Maintenance Script v2.21 .DESCRIPTION Комплексный скрипт для обновления и очистки Windows 11: - Обновление Windows (включая драйверы) @@ -43,8 +44,20 @@ - Подробный цветной вывод + лог-файл .NOTES Author: biv - Version: 2.20 + Version: 2.21 Requires: PowerShell 7.1+, Windows 11, Administrator rights + Changes in 2.21: + - The self-update could update a DIFFERENT copy than the one running and report + success - it asked whether a Gallery copy existed anywhere, not whether the + running file was that copy + - Several Gallery installations now disable the automatic update instead of + changing one at random; the running path is printed so they can be told apart + - Detection, updating and the printed advice work on a machine that has only + PSResourceGet; an AllUsers install is no longer invisible + - An update that reports success is verified against the version on disk + - A missing winget, no internet connection and a failed self-update are warnings, + not errors: the exit code no longer reports failure for a complete cleanup + - Result JSON gains AppUpdatesStatus, and Aborted gains UpdatedAndExited Changes in 2.20: - SECURITY: a junction whose target is a protected root could be used as a cleanup root - the path check compared text and never resolved the link @@ -403,7 +416,7 @@ if (-not $LogPath) { } # Script version (single source of truth for version checking) -$script:Version = "2.20" +$script:Version = "2.21" # Protected paths that should never be deleted $script:ProtectedPaths = @(