diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 065dea5..6f7e32d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -64,6 +64,13 @@ channels derive from it: `JacobOptimiza/scoop-bucket` repository (`packaging/scoop/`). - **WinGet** — immutable versioned manifests with SHA-256 (`packaging/winget/`), submitted to `microsoft/winget-pkgs`. +- **Chocolatey (not published)** — the accepted future design is a + machine-owned package layout with a separate + `.devnav-managed-by-chocolatey` marker and a machine `PSModulePath` entry. + It is not implemented as a distribution channel yet. Chocolatey's Community + verifier runs on Windows Server 2019; DevNav currently supports Windows 10 + and 11, so verifier compatibility or an exemption remains an open release + decision. ## `dev` invocation flow diff --git a/powershell/DevNav.psm1 b/powershell/DevNav.psm1 index 70835ea..1ee2e73 100644 --- a/powershell/DevNav.psm1 +++ b/powershell/DevNav.psm1 @@ -225,16 +225,25 @@ function Initialize-DevUpdateCheckPreference { return $enabled } +function Get-DevInstallationOwner { + # Package managers own the files they install; DevNav must not self-update + # underneath them. Each manager has its own marker next to this module. + $owners = @( + if (Test-Path -LiteralPath (Join-Path $PSScriptRoot '.devnav-managed-by-scoop') -PathType Leaf) { 'scoop' } + if (Test-Path -LiteralPath (Join-Path $PSScriptRoot '.devnav-managed-by-chocolatey') -PathType Leaf) { 'chocolatey' } + ) + if ($owners.Count -gt 1) { return 'ambiguous' } + if ($owners.Count -eq 1) { return $owners[0] } + return 'unmanaged' +} + function Test-DevManagedInstallation { - # Package managers such as Scoop own the files they install; DevNav must not - # self-update underneath them. Managed layouts carry a marker file next to - # this module. - return (Test-Path -LiteralPath (Join-Path $PSScriptRoot '.devnav-managed-by-scoop') -PathType Leaf) + return (Get-DevInstallationOwner) -ne 'unmanaged' } function Invoke-DevStartupUpdateCheck { if ([Console]::IsInputRedirected) { return } - if (Test-DevManagedInstallation) { return } + if ((Get-DevInstallationOwner) -ne 'unmanaged') { return } Initialize-DevLanguage | Out-Null if (-not (Initialize-DevUpdateCheckPreference)) { return } @@ -380,13 +389,37 @@ function Update-DevNavigator { [CmdletBinding(SupportsShouldProcess)] param() - if (Test-DevManagedInstallation) { - if ((Get-DevLanguage) -eq 'en-US') { - Write-Host 'This installation is managed by Scoop; DevNav will not self-update.' -ForegroundColor Yellow - Write-Host 'Update it with: scoop update devnav' -ForegroundColor Cyan - } else { - Write-Host 'Esta instalación la gestiona Scoop; DevNav no se actualiza a sí mismo.' -ForegroundColor Yellow - Write-Host 'Para actualizar, ejecuta: scoop update devnav' -ForegroundColor Cyan + $owner = Get-DevInstallationOwner + if ($owner -ne 'unmanaged') { + $english = (Get-DevLanguage) -eq 'en-US' + switch ($owner) { + 'scoop' { + if ($english) { + Write-Host 'This installation is managed by Scoop; DevNav will not self-update.' -ForegroundColor Yellow + Write-Host 'Update it with: scoop update devnav' -ForegroundColor Cyan + } else { + Write-Host 'Esta instalación la gestiona Scoop; DevNav no se actualiza a sí mismo.' -ForegroundColor Yellow + Write-Host 'Para actualizar, ejecuta: scoop update devnav' -ForegroundColor Cyan + } + } + 'chocolatey' { + if ($english) { + Write-Host 'This installation is managed by Chocolatey; DevNav will not self-update.' -ForegroundColor Yellow + Write-Host 'Update it with: choco upgrade devnav' -ForegroundColor Cyan + } else { + Write-Host 'Esta instalación la gestiona Chocolatey; DevNav no se actualiza a sí mismo.' -ForegroundColor Yellow + Write-Host 'Para actualizar, ejecuta: choco upgrade devnav' -ForegroundColor Cyan + } + } + 'ambiguous' { + if ($english) { + Write-Host 'DevNav ownership is ambiguous: Scoop and Chocolatey markers are both present. DevNav will not self-update.' -ForegroundColor Red + Write-Host 'Remove the incompatible marker before choosing a package manager.' -ForegroundColor Yellow + } else { + Write-Host 'La propiedad de DevNav es ambigua: existen markers de Scoop y Chocolatey. DevNav no se actualiza a sí mismo.' -ForegroundColor Red + Write-Host 'Elimina el marker incompatible antes de elegir un gestor de paquetes.' -ForegroundColor Yellow + } + } } return } diff --git a/tests/powershell/DevNav.Tests.ps1 b/tests/powershell/DevNav.Tests.ps1 index 9470c06..f0ebf59 100644 --- a/tests/powershell/DevNav.Tests.ps1 +++ b/tests/powershell/DevNav.Tests.ps1 @@ -1071,6 +1071,102 @@ Describe 'DevNav Scoop-managed installation' { } } +Describe 'DevNav package-manager ownership' { + BeforeAll { + $chocolateyModuleRoot = Join-Path $testLocalAppData 'chocolatey-module' + $ambiguousModuleRoot = Join-Path $testLocalAppData 'ambiguous-module' + foreach ($root in @($chocolateyModuleRoot, $ambiguousModuleRoot)) { + New-Item -ItemType Directory -Path $root -Force | Out-Null + Copy-Item -LiteralPath $modulePath -Destination (Join-Path $root 'DevNav.psm1') + } + New-Item -ItemType File -Path (Join-Path $chocolateyModuleRoot '.devnav-managed-by-chocolatey') -Force | Out-Null + New-Item -ItemType File -Path (Join-Path $ambiguousModuleRoot '.devnav-managed-by-scoop') -Force | Out-Null + New-Item -ItemType File -Path (Join-Path $ambiguousModuleRoot '.devnav-managed-by-chocolatey') -Force | Out-Null + Import-Module (Join-Path $chocolateyModuleRoot 'DevNav.psm1') -Force + Import-Module (Join-Path $ambiguousModuleRoot 'DevNav.psm1') -Force + $chocolateyModule = Get-Module | Where-Object { $_.Path -eq (Join-Path $chocolateyModuleRoot 'DevNav.psm1') } | Select-Object -First 1 + $ambiguousModule = Get-Module | Where-Object { $_.Path -eq (Join-Path $ambiguousModuleRoot 'DevNav.psm1') } | Select-Object -First 1 + } + + It 'keeps unmanaged installations available for normal self-update' { + $devModule.Invoke({ Get-DevInstallationOwner }) | Should -Be 'unmanaged' + $devModule.Invoke({ Test-DevManagedInstallation }) | Should -BeFalse + } + + It 'detects Chocolatey ownership without requiring Chocolatey on the test machine' { + $chocolateyModule.Invoke({ Get-DevInstallationOwner }) | Should -Be 'chocolatey' + $chocolateyModule.Invoke({ Test-DevManagedInstallation }) | Should -BeTrue + } + + It 'detects ambiguous ownership when both markers exist' { + $ambiguousModule.Invoke({ Get-DevInstallationOwner }) | Should -Be 'ambiguous' + $ambiguousModule.Invoke({ Test-DevManagedInstallation }) | Should -BeTrue + } + + It 'skips the startup update check for Chocolatey ownership' { + $chocolateyModule.Invoke({ + Mock Initialize-DevUpdateCheckPreference { throw 'Chocolatey startup must not initialize update preferences.' } + Mock Get-DevLatestRelease { throw 'Chocolatey startup must not query GitHub.' } + { Invoke-DevStartupUpdateCheck } | Should -Not -Throw + }) + } + + It 'skips the startup update check for ambiguous ownership' { + $ambiguousModule.Invoke({ + Mock Initialize-DevUpdateCheckPreference { throw 'Ambiguous startup must not initialize update preferences.' } + Mock Get-DevLatestRelease { throw 'Ambiguous startup must not query GitHub.' } + { Invoke-DevStartupUpdateCheck } | Should -Not -Throw + }) + } + + It 'reports Chocolatey instructions in English without downloading' { + $global:DevNavChocolateyMessages = [System.Collections.Generic.List[string]]::new() + $global:DevNavChocolateyLatestCalls = 0 + $global:DevNavChocolateyDownloadCalls = 0 + $chocolateyModule.Invoke({ + Mock Get-DevLanguage { 'en-US' } + Mock Get-DevLatestRelease { $global:DevNavChocolateyLatestCalls++ } + Mock Invoke-DevDownload { $global:DevNavChocolateyDownloadCalls++ } + Mock Write-Host { [void]$global:DevNavChocolateyMessages.Add([string]$Object) } + Update-DevNavigator -Confirm:$false + }) + $global:DevNavChocolateyMessages | Should -Contain 'This installation is managed by Chocolatey; DevNav will not self-update.' + $global:DevNavChocolateyMessages | Should -Contain 'Update it with: choco upgrade devnav' + $global:DevNavChocolateyLatestCalls | Should -Be 0 + $global:DevNavChocolateyDownloadCalls | Should -Be 0 + Remove-Variable DevNavChocolateyMessages, DevNavChocolateyLatestCalls, DevNavChocolateyDownloadCalls -Scope Global -ErrorAction SilentlyContinue + } + + It 'reports Chocolatey instructions in Spanish without downloading' { + $global:DevNavChocolateyMessages = [System.Collections.Generic.List[string]]::new() + $chocolateyModule.Invoke({ + Mock Get-DevLanguage { 'es-ES' } + Mock Write-Host { [void]$global:DevNavChocolateyMessages.Add([string]$Object) } + Update-DevNavigator -Confirm:$false + }) + $global:DevNavChocolateyMessages | Should -Contain 'Esta instalación la gestiona Chocolatey; DevNav no se actualiza a sí mismo.' + $global:DevNavChocolateyMessages | Should -Contain 'Para actualizar, ejecuta: choco upgrade devnav' + Remove-Variable DevNavChocolateyMessages -Scope Global -ErrorAction SilentlyContinue + } + + It 'fails safe for ambiguous ownership without downloading' { + $global:DevNavAmbiguousMessages = [System.Collections.Generic.List[string]]::new() + $global:DevNavAmbiguousLatestCalls = 0 + $global:DevNavAmbiguousDownloadCalls = 0 + $ambiguousModule.Invoke({ + Mock Get-DevLanguage { 'en-US' } + Mock Get-DevLatestRelease { $global:DevNavAmbiguousLatestCalls++ } + Mock Invoke-DevDownload { $global:DevNavAmbiguousDownloadCalls++ } + Mock Write-Host { [void]$global:DevNavAmbiguousMessages.Add([string]$Object) } + Update-DevNavigator -Confirm:$false + }) + ($global:DevNavAmbiguousMessages -join "`n") | Should -Match 'ownership is ambiguous' + $global:DevNavAmbiguousLatestCalls | Should -Be 0 + $global:DevNavAmbiguousDownloadCalls | Should -Be 0 + Remove-Variable DevNavAmbiguousMessages, DevNavAmbiguousLatestCalls, DevNavAmbiguousDownloadCalls -Scope Global -ErrorAction SilentlyContinue + } +} + Describe 'DevNav shortcut commands' { BeforeAll { cargo build --quiet --manifest-path (Join-Path $repositoryRoot 'Cargo.toml')