pacwin.psm1
|
# ============================================================ # pacwin.psm1 - Universal Package Layer for Windows # Abstraction over: winget | chocolatey | scoop # Compatible: PowerShell 5.1 + PowerShell 7+ # v0.6.0 (Refactoring & Concurrency Rebrand) # ============================================================ Set-StrictMode -Version 2.0 $ErrorActionPreference = "Continue" # Force UTF8 for better character rendering in PS 5.1 if ($PSVersionTable.PSVersion.Major -lt 6) { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } # Resolve script root folder for dot-sourcing helper files $scriptDir = $PSScriptRoot if (-not $scriptDir) { $scriptDir = $PWD.Path } # Fallback theme in case file is missing $script:THEME = @{ Primary = "Red" Secondary = "Green" Success = "Green" Warning = "Yellow" Danger = "Red" Dim = "DarkGray" Version = "0.6.0" Layout = @{ TitleWidth = 68 MinWidth = 80 Separator = "━" * 68 Spinner = "|/-\" ColWidths = @{ Name = 0.5 ID = 0.3 Version = 0.2 } } } # Load Theme configuration $themePath = Join-Path $scriptDir "config/themes/default.ps1" if (Test-Path $themePath) { . $themePath } # Load Centralized Error Handler $errorHandlerPath = Join-Path $scriptDir "internal/helpers/ErrorHandler.ps1" if (Test-Path $errorHandlerPath) { . $errorHandlerPath } # Load Concurrency Helper $concurrencyPath = Join-Path $scriptDir "internal/helpers/Concurrency.ps1" if (Test-Path $concurrencyPath) { . $concurrencyPath } # Precompiled Regexes for Performance (Fase 5.1) $script:RegexPathValidate = [regex]::new('^[a-zA-Z0-9\._\-@/\\:]+$', 'Compiled') $script:RegexWingetSkip = [regex]::new('^\s*$|^-|^[^\x00-\x7F]|%|[\d.]+\s+[KMG]B\s*/', 'Compiled') $script:RegexWingetFallbackSkip = [regex]::new('^\s*$|^-{3,}|[^\x00-\x7F]|%|[\d.]+\s+[KMG]B\s*/', 'Compiled') $script:RegexScoopSkip = [regex]::new('^\s*[-=]{3,}\s*$', 'Compiled') $script:RegexScoopHeaderSkip = [regex]::new('^\s*([Nn]ame\s+[Vv]ersion|[Nn]ombre\s+[Vv]ersi|^Results from)', 'Compiled') $script:RegexScoopObject = [regex]::new('^@{Name=([^;]+); Version=([^;]+); Source=([^;]+)', 'Compiled') $script:RegexScoopParen = [regex]::new('^\s*(\S+)\s+\(([^)]+)\)', 'Compiled') $script:RegexScoopColumnSkip = [regex]::new('^[Nn]ame$|^[Ss]ource$|^[Nn]ombre$|^[Oo]rigen$', 'Compiled') $script:RegexScoopOutdatedSkip = [regex]::new('^[-\s=]{3,}$', 'Compiled') $script:RegexScoopOutdatedHeaderSkip = [regex]::new('^\s*([Nn]ame\s+|^Results from|[Nn]ombre\s+)', 'Compiled') $script:RegexScoopOutdatedArrow = [regex]::new('^\s*(\S+)\s+\(([^)]+)\s*->\s*([^)]+)\)', 'Compiled') $script:RegexScoopOutdatedNew = [regex]::new('(\S+)\s+has\s+a\s+new\s+version', 'Compiled') $script:RegexScoopOutdatedColon = [regex]::new('^\s*(\S+)\s*:\s*(\S+)\s*->\s*(\S+)', 'Compiled') $script:RegexScoopOutdatedSfsuSkip = [regex]::new('^[Nn]ame|^[Cc]urrent|^[Aa]vailable|^[Nn]ombre', 'Compiled') # Load Commands $commandsDir = Join-Path $scriptDir "internal/commands" if (Test-Path $commandsDir) { Get-ChildItem $commandsDir -Filter *.ps1 | ForEach-Object { . $_.FullName } } # Setup Command Dispatch Map $script:CommandMap = [ordered]@{ "^(search|-Ss)$" = { param($ctx) _pw_cmd_search $ctx } "^(info|-Si)$" = { param($ctx) _pw_cmd_info $ctx } "^(install|-S)$" = { param($ctx) _pw_cmd_install $ctx } "^(uninstall|-R)$" = { param($ctx) _pw_cmd_uninstall $ctx } "^(update|upgrade|-Syu)$" = { param($ctx) _pw_cmd_update $ctx } "^(outdated|-Qu)$" = { param($ctx) _pw_cmd_outdated $ctx } "^(list|-Q)$" = { param($ctx) _pw_cmd_list $ctx } "^(export)$" = { param($ctx) _pw_cmd_export $ctx } "^(import)$" = { param($ctx) _pw_cmd_import $ctx } "^(pin|hold)$" = { param($ctx) _pw_cmd_pin $ctx } "^(unpin|unhold)$" = { param($ctx) _pw_cmd_unpin $ctx } "^(doctor|check)$" = { param($ctx) _pw_cmd_doctor $ctx } "^(sync|dupes|dedup)$" = { param($ctx) _pw_cmd_sync $ctx } "^(status)$" = { param($ctx) _pw_cmd_status $ctx } "^(version|-V|--version|--version|-v)$" = { param($ctx) _pw_cmd_version $ctx } "^(self-update)$" = { param($ctx) _pw_cmd_self_update $ctx } "^(help|--help|-h)$" = { param($ctx) _pw_cmd_help $ctx } } # TROPICAL_UI :: BRAND_PITAHAYA (High-Energy industrial) # Primary: Red (ANSI) / #FF2400 # Secondary: Green (ANSI) / #2E8B57 # Success: Green # Warning: Yellow # Danger: Red # Dim: DarkGray #region -- Security & Validation ----------------------------- <# .SYNOPSIS Sanitizes user input strings for safe shell execution. .DESCRIPTION Removes potentially dangerous shell command injection characters. Only preserves alphanumeric characters, dots, hyphens, and plus signs. .PARAMETER inputStr The raw string input to sanitize. .OUTPUTS System.String The sanitized string, or an empty string if null or whitespace. .EXAMPLE _pw_sanitize "google chrome; rm -rf /" Returns: "googlechrome" #> function _pw_sanitize { param([string]$inputStr) if ([string]::IsNullOrWhiteSpace($inputStr)) { return "" } return $inputStr -replace "[^\w\.\-\+]", "" } <# .SYNOPSIS Validates filesystem paths against unsafe characters. .DESCRIPTION Ensures file paths only contain safe characters to prevent directory traversal or parameter injection. Allowed characters: alphanumeric, dot, underscore, hyphen, at, forward/backward slashes, and colon. .PARAMETER pathInput The raw file path input to validate. .OUTPUTS System.String The validated path string, or null if validation fails. .EXAMPLE _pw_validate_path "C:\Users\User\app-config.json" #> function _pw_validate_path { # Validates file paths: allowed characters are \ / : . - _ a-z A-Z 0-9 param([string]$pathInput) if (-not $pathInput) { return $null } if ($script:RegexPathValidate.IsMatch($pathInput)) { return $pathInput } _pw_color " [!] PATH_RISK: '$pathInput'" Red return $null } #endregion #region -- Helpers ------------------------------------------ function _pw_color { param( [string]$text, [string]$color = "White", [switch]$NoNewline ) if ($NoNewline) { Write-Host $text -ForegroundColor $color -NoNewline } else { Write-Host $text -ForegroundColor $color } } function _pw_header { param($managers) _pw_color "" _pw_color " .---. " $script:THEME.Primary -NoNewline; _pw_color "██████╗ █████╗ ██████╗██╗ ██╗██╗███╗ ██╗" $script:THEME.Dim _pw_color " / \ " $script:THEME.Primary -NoNewline; _pw_color "██╔══██╗██╔══██╗██╔════╝██║ ██║██║████╗ ██║" $script:THEME.Dim _pw_color " | ■ ■ | " $script:THEME.Primary -NoNewline; _pw_color "██████╔╝███████║██║ ██║ █╗ ██║██║██╔██╗ ██║" $script:THEME.Dim _pw_color " | | " $script:THEME.Primary -NoNewline; _pw_color "██╔═══╝ ██╔══██║██║ ██║███╗██║██║██║╚██╗██║" $script:THEME.Dim _pw_color " 'vvvvvvv' " $script:THEME.Primary -NoNewline; _pw_color "██║ ██║ ██║╚██████╗╚███╔███╔╝██║██║ ╚████║" $script:THEME.Dim _pw_color " ' ' " $script:THEME.Primary -NoNewline; _pw_color "╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══╝╚══╝ ╚═╝╚═╝ ╚═══╝" $script:THEME.Dim _pw_color "" _pw_color " [ pacwin.ps1 ] // Powered by: Tropical // BUILD: STABLE" $script:THEME.Dim _pw_color "" if ($null -ne $managers) { # TUI_STATUS_BAR (PITAHAYA_DENSE) _pw_color " " -NoNewline $keys = "winget", "choco", "scoop" for ($i = 0; $i -lt $keys.Count; $i++) { $k = $keys[$i] $indicator = if ($managers[$k]) { "ONLINE" } else { "OFFLINE" } $color = if ($managers[$k]) { $script:THEME.Success } else { $script:THEME.Dim } _pw_color "┌ " $script:THEME.Dim -NoNewline _pw_color "$($k.ToUpper())" White -NoNewline _pw_color " ┐" $script:THEME.Dim -NoNewline _pw_color "─" $script:THEME.Dim -NoNewline _pw_color "[" $script:THEME.Dim -NoNewline _pw_color "$indicator" $color -NoNewline _pw_color "]" $script:THEME.Dim -NoNewline if ($i -lt $keys.Count - 1) { _pw_color " " -NoNewline } } _pw_color " // " $script:THEME.Dim -NoNewline _pw_color "v$($script:THEME.Version)" $script:THEME.Primary -NoNewline _pw_color " // " $script:THEME.Dim -NoNewline _pw_color "ENV: WINDOWS_X64" $script:THEME.Dim } _pw_sep } function _pw_sep { $w = try { $Host.UI.RawUI.WindowSize.Width - 4 } catch { $script:THEME.Layout.TitleWidth } if ($w -lt 40) { $w = $script:THEME.Layout.TitleWidth } _pw_color (" " + ([string]$script:THEME.Layout.Separator[0] * $w)) $script:THEME.Primary } function _pw_last_exit_code { if (Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue) { return $global:LASTEXITCODE } return 0 } function _pw_exe { param([string]$name) # Use -CommandType to bypass functions/aliases (like sfsu hook) $cmd = Get-Command $name -CommandType Application,ExternalScript -ErrorAction SilentlyContinue | Select-Object -First 1 if ($cmd) { return $cmd.Source } return $null } $script:BinaryCache = @{} function _pw_exe_cached { param([string]$name) if (-not $script:BinaryCache.ContainsKey($name)) { $script:BinaryCache[$name] = _pw_exe $name } return $script:BinaryCache[$name] } function _pw_is_admin { $currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) return $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } #endregion #region -- Manager Detection -------------------------------- function _pw_detect_managers { $m = [ordered]@{} $wingetExe = _pw_exe_cached "winget" $chocoExe = _pw_exe_cached "choco" $scoopExe = _pw_exe_cached "scoop" $sfsuExe = _pw_exe_cached "sfsu" if ($wingetExe) { $m["winget"] = $wingetExe } if ($chocoExe) { $m["choco"] = $chocoExe } if ($scoopExe) { $m["scoop"] = $scoopExe } if ($sfsuExe) { $m["sfsu"] = $sfsuExe } return $m } function _pw_assert_managers { param($managers) if ($managers.Count -eq 0) { _pw_color " [!] No package manager detected." Red _pw_color " Install winget, chocolatey, or scoop to use pacwin." Yellow return $false } return $true } function _pw_filter_manager { param($managers, [string]$mgr) if (-not $mgr) { return $managers } if (-not $managers[$mgr]) { _pw_color " [!] Manager '$mgr' not available on this system." Red return $null } $sub = [ordered]@{} $sub[$mgr] = $managers[$mgr] return $sub } #endregion #region -- Parsers ------------------------------------------ <# .SYNOPSIS Parses winget query and search output. .DESCRIPTION Parses tabular winget output into structured objects. Supports three variants: 1. Segmented dashes: "--- ---- -----" (standard & most robust) 2. Single long dashes separator: "----------------" 3. Separator-less: Fallback whitespace-heuristic splitting Avoids parsing column header text directly to maintain localization-safety. .PARAMETER lines String array output captured from a winget execution. .OUTPUTS System.Collections.Generic.List[PSCustomObject] A list of objects containing Name, ID, Version, Source, and Manager properties. .EXAMPLE $results = _pw_parse_winget_lines $wingetSearchOutput #> function _pw_find_winget_separator { param([string[]]$lines) return $lines | Where-Object { $_ -match "^-{5,}" } | Select-Object -First 1 } function _pw_extract_offsets_from_dashes { param([string]$separatorLine) $matches = [regex]::Matches($separatorLine, "-+") if ($matches.Count -lt 2) { return $null } $nameOff = $matches[0].Index $nameLen = $matches[0].Length $idOff = $matches[1].Index $idLen = $matches[1].Length $versionOff = if ($matches.Count -ge 3) { $matches[2].Index } else { -1 } $versionLen = if ($matches.Count -ge 3) { $matches[2].Length } else { -1 } $sourceOff = if ($matches.Count -ge 4) { $matches[3].Index } else { -1 } return @{ nameOff = $nameOff nameLen = $nameLen idOff = $idOff idLen = $idLen versionOff = $versionOff versionLen = $versionLen sourceOff = $sourceOff } } function _pw_extract_offsets_from_header { param([string[]]$lines, [string]$separatorLine) $sepIdx = [array]::IndexOf($lines, $separatorLine) if ($sepIdx -le 0) { return $null } $headerLine = $lines[$sepIdx - 1] $parts = [regex]::Matches($headerLine, "\S+") if ($parts.Count -lt 2) { return $null } $nameOff = $parts[0].Index $idOff = $parts[1].Index $versionOff = if ($parts.Count -ge 3) { $parts[2].Index } else { -1 } $sourceOff = if ($parts.Count -ge 4) { $parts[3].Index } else { -1 } $nameLen = $idOff - $nameOff $idLen = if ($versionOff -gt 0) { $versionOff - $idOff } else { 100 } $versionLen = if ($sourceOff -gt 0) { $sourceOff - $versionOff } else { 100 } return @{ nameOff = $nameOff nameLen = $nameLen idOff = $idOff idLen = $idLen versionOff = $versionOff versionLen = $versionLen sourceOff = $sourceOff } } function _pw_winget_row_to_object { param([string]$line, [hashtable]$offsets) $name = _pw_extract_column $line $offsets.nameOff $offsets.nameLen $null $id = _pw_extract_column $line $offsets.idOff $offsets.idLen $null if (-not $name -or -not $id) { return $null } $versionOff = $offsets.versionOff $sourceOff = $offsets.sourceOff $versionLen = $offsets.versionLen $vLen = if ($sourceOff -gt $versionOff -and $versionOff -gt 0) { $sourceOff - $versionOff } else { $versionLen } $ver = _pw_extract_column $line $versionOff $vLen "?" return [PSCustomObject]@{ Name = $name ID = $id Version = $ver Source = "winget" Manager = "winget" } } function _pw_parse_winget_lines { param([string[]]$lines) $results = [System.Collections.Generic.List[PSCustomObject]]::new() # 1. Identify the header/separator structure $separatorLine = _pw_find_winget_separator $lines if (-not $separatorLine) { # Fallback: Heuristic split if no separator is found foreach ($line in $lines) { # Skip progress bars, empty lines, and header-like lines if ($script:RegexWingetFallbackSkip.IsMatch($line)) { continue } $parts = ($line.Trim() -split "\s{2,}").Where({ $_ -ne "" }) if ($parts.Count -lt 2) { continue } $id = if ($parts.Count -ge 3) { $parts[1].Trim() } else { $parts[0].Trim() } $version = if ($parts.Count -ge 3) { $parts[2].Trim() } else { $parts[1].Trim() } $results.Add([PSCustomObject]@{ Name = $parts[0].Trim() ID = $id Version = $version Source = "winget" Manager = "winget" }) } return $results } # 2. Extract offsets $offsets = _pw_extract_offsets_from_dashes $separatorLine if (-not $offsets) { $offsets = _pw_extract_offsets_from_header $lines $separatorLine } if (-not $offsets) { return $results } # 3. Parse data lines $dataStart = $false foreach ($line in $lines) { if ($line -eq $separatorLine) { $dataStart = $true continue } if (-not $dataStart -or $script:RegexWingetSkip.IsMatch($line)) { continue } $obj = _pw_winget_row_to_object $line $offsets if ($obj) { $results.Add($obj) } } return ,$results } <# .SYNOPSIS Parses Chocolatey pipe-delimited output. .DESCRIPTION Parses Chocolatey search output when executed with `--limit-output`. Expects lines in the standard pipe format 'name|version'. .PARAMETER lines String array output captured from a choco search command. .OUTPUTS System.Collections.Generic.List[PSCustomObject] .EXAMPLE $results = _pw_parse_choco_lines $chocoOutput #> function _pw_parse_choco_lines { param([string[]]$lines) $results = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($line in $lines) { $parts = $line -split "\|" if ($parts.Count -ge 2 -and $parts[0].Trim() -ne "") { $results.Add([PSCustomObject]@{ Name = $parts[0].Trim() ID = $parts[0].Trim() Version = $parts[1].Trim() Source = "chocolatey" Manager = "choco" }) } } return ,$results } <# .SYNOPSIS Parses Scoop search and list outputs. .DESCRIPTION Parses output from Scoop search or list commands. Handles three formatting formats: 1. Serialization syntax: '@{Name=...; Version=...; Source=...}' 2. Parenthesis versioning: 'name (version) [source]' or 'name (version)' 3. Columnar table style: 'Name Version Source' .PARAMETER lines String array output captured from a scoop execution. .OUTPUTS System.Collections.Generic.List[PSCustomObject] .EXAMPLE $results = _pw_parse_scoop_lines $scoopOutput #> function _pw_parse_scoop_lines { param([string[]]$lines) $results = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($line in $lines) { if ([string]::IsNullOrWhiteSpace($line)) { continue } # Skip headers or separators (supports multi-language) if ($script:RegexScoopSkip.IsMatch($line) -or $script:RegexScoopHeaderSkip.IsMatch($line)) { continue } # If we get a line that looks like a PSCustomObject string representation, try to handle it $mObj = $script:RegexScoopObject.Match($line) if ($mObj.Success) { $nameVal = $mObj.Groups[1].Value.Trim() $results.Add([PSCustomObject]@{ Name = $nameVal; ID = $nameVal Version = $mObj.Groups[2].Value.Trim(); Source = "scoop"; Manager = "scoop" }) continue } # Handle 'name (version) [source]' or 'name (version)' format $mParen = $script:RegexScoopParen.Match($line) if ($mParen.Success) { $nameVal = $mParen.Groups[1].Value $results.Add([PSCustomObject]@{ Name = $nameVal; ID = $nameVal Version = $mParen.Groups[2].Value; Source = "scoop"; Manager = "scoop" }) continue } # Handle columnar format: Name Version Source $parts = ($line.Trim() -split "\s{2,}").Where({ $_ -ne "" }) if ($parts.Count -eq 0 -or $script:RegexScoopColumnSkip.IsMatch($parts[0])) { continue } $version = if ($parts.Count -ge 2) { $parts[1] } else { "?" } $results.Add([PSCustomObject]@{ Name = $parts[0] ID = $parts[0] Version = $version Source = "scoop" Manager = "scoop" }) } return ,$results } <# .SYNOPSIS Parses Scoop status or outdated app listings. .DESCRIPTION Parses Scoop outdated results, matching four distinct output signatures: 1. Parenthesized arrow: 'neovim (0.9.4 -> 0.9.5)' 2. English notification text: 'neovim has a new version' 3. Colon arrow: 'neovim: 0.9.4 -> 0.9.5' 4. Tabular format (scoop status / sfsu outdated): 'Name Current Available' .PARAMETER lines String array output from scoop status or sfsu outdated commands. .OUTPUTS System.Collections.Generic.List[PSCustomObject] .EXAMPLE $results = _pw_parse_scoop_outdated_lines $scoopStatusOutput #> function _pw_parse_scoop_outdated_lines { param([string[]]$lines) $results = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($line in $lines) { if ([string]::IsNullOrWhiteSpace($line)) { continue } # Skip headers and table separators (supports multi-language) if ($script:RegexScoopOutdatedSkip.IsMatch($line) -or $script:RegexScoopOutdatedHeaderSkip.IsMatch($line)) { continue } # Format 1: "neovim (0.9.4 -> 0.9.5)" $mArrow = $script:RegexScoopOutdatedArrow.Match($line) if ($mArrow.Success) { $nameVal = $mArrow.Groups[1].Value $results.Add([PSCustomObject]@{ Name = $nameVal ID = $nameVal Version = $mArrow.Groups[3].Value # The new version Source = "scoop" Manager = "scoop" }) continue } # Format 2: "neovim has a new version" (English classic) $mNew = $script:RegexScoopOutdatedNew.Match($line) if ($mNew.Success) { $nameVal = $mNew.Groups[1].Value $results.Add([PSCustomObject]@{ Name = $nameVal ID = $nameVal Version = "Later" Source = "scoop" Manager = "scoop" }) continue } # Format 3: "neovim: 0.9.4 -> 0.9.5" $mColon = $script:RegexScoopOutdatedColon.Match($line) if ($mColon.Success) { $nameVal = $mColon.Groups[1].Value $results.Add([PSCustomObject]@{ Name = $nameVal ID = $nameVal Version = $mColon.Groups[3].Value Source = "scoop" Manager = "scoop" }) continue } # Format 4: Columnar format of 'sfsu outdated' or 'scoop status' table: # Name Current Available # ---- ------- --------- # neovim 0.9.4 0.9.5 $parts = ($line.Trim() -split "\s{2,}").Where({ $_ -ne "" }) if ($parts.Count -ge 2 -and -not $script:RegexScoopOutdatedSfsuSkip.IsMatch($parts[0])) { $newVer = if ($parts.Count -ge 3) { $parts[2] } else { $parts[1] } $results.Add([PSCustomObject]@{ Name = $parts[0] ID = $parts[0] Version = $newVer Source = "scoop" Manager = "scoop" }) } } return ,$results } #endregion #region -- Search Engine ------------------------------------ function _pw_get_search_scripts { param($managers) $scripts = [ordered]@{} if ($managers["winget"]) { $scripts["winget"] = { param($exe, $q) try { # Run search and capture both success and error streams $out = & $exe search --query $q --accept-source-agreements 2>&1 return $out } catch { return @() } } } if ($managers["choco"]) { $scripts["choco"] = { param($exe, $q) try { $out = & $exe search $q --limit-output 2>&1 return $out } catch { return @() } } } if ($managers["scoop"]) { $scripts["scoop"] = { param($exe, $q) try { # Check if sfsu is available for ultra-fast scoop search $sfsu = Get-Command sfsu -ErrorAction SilentlyContinue if ($sfsu) { $out = & $sfsu.Source search $q 2>&1 return $out } $out = & $exe search $q 2>&1 return $out } catch { return @() } } } return $scripts } function _pw_search_all { param($managers, [string]$query, [int]$limit = 40, [int]$timeoutSeconds = 35) $scripts = _pw_get_search_scripts -managers $managers $poolCtx = New-PwTaskPool -Scripts $scripts -Managers $managers -Query $query Wait-PwTaskPool -PoolContext $poolCtx -TimeoutSeconds $timeoutSeconds $results = Receive-PwTaskPool -PoolContext $poolCtx if ($null -ne $results -and $results.Count -gt $limit) { return $results | Select-Object -First $limit } return $results } #endregion #region -- Main Entry Point --------------------------------- function _pw_parse_args { param( [string]$Command, [string]$Query, [string[]]$Unbound ) $allArgs = New-Object System.Collections.Generic.List[string] if ($Command) { [void]$allArgs.Add($Command) } if ($Query) { [void]$allArgs.Add($Query) } if ($Unbound) { foreach ($a in $Unbound) { [void]$allArgs.Add($a) } } if ($allArgs.Count -gt 0) { $flagIdx = -1 $flagRegex = "^-(S|Ss|R|Q|Qu|Syu|Si|V|h|v)$|^--(help|version)$" for ($i = 0; $i -lt $allArgs.Count; $i++) { if ($allArgs[$i] -match $flagRegex) { $flagIdx = $i break } } if ($flagIdx -ne -1) { $foundFlag = $allArgs[$flagIdx] $allArgs.RemoveAt($flagIdx) $Command = $foundFlag $Query = if ($allArgs.Count -gt 0) { $allArgs[0] } else { $null } } } if ([string]::IsNullOrWhiteSpace($Command)) { $Command = "help" } return [PSCustomObject]@{ Command = $Command Query = $Query } } function _pw_check_admin_requirements { param( [hashtable]$managers, [string]$Manager, [string]$Command ) if (-not (_pw_is_admin)) { if ($Manager -eq "choco" -or ($null -eq $Manager -and $managers["choco"])) { if ($Command -match "^(install|uninstall|update|upgrade|import|pin|unpin|hold|unhold|-S|-R|-Syu)") { _pw_color " [!] Warning: You are running as a standard user." Yellow _pw_color " Chocolatey (choco) usually requires Administrator privileges to perform this action." Yellow _pw_color "" } } } } function _pw_show_help { _pw_color " Core Commands" Cyan _pw_color " search <q> Find packages, pick # to install (-Ss)" White _pw_color " search <q> -ni Non-interactive: just list results" White _pw_color " install <id> Search and install a package (-S)" White _pw_color "" _pw_color " Maintenance" Cyan _pw_color " update [id] Upgrade one or all packages (-Syu)" White _pw_color " outdated Show packages with newer versions (-Qu)" White _pw_color " doctor Check environment health" White _pw_color "" _pw_color " Management" Cyan _pw_color " list [filter] Show installed packages (-Q)" White _pw_color " hold [id] Pin/unpin versions (prevents updates)" White _pw_color " sync Detect and fix duplicate installs" White _pw_color "" _pw_color " System" Cyan _pw_color " status Show manager paths" White _pw_color " self-update Update pacwin script to latest" White _pw_color " help Show this menu" White _pw_color "" _pw_color " Example:" Gray _pw_color " pacwin search nodejs" White } function _pw_handle_update { param( [hashtable]$targetManagers, [string]$Query, [string]$Manager ) if (-not $Query) { _pw_do_update_all $targetManagers return } _pw_color " Looking for update candidates for '$Query'..." Cyan if ($Manager) { _pw_do_update_single $Query $Manager return } _pw_color " Searching in outdated packages..." Gray $outdated = _pw_do_outdated $targetManagers -Silent $targetMatches = @(@($outdated).Where({ $_.ID -eq $Query -or $_.Name -eq $Query })) if ($targetMatches.Count -eq 0) { _pw_color " No outdated package found matching '$Query'. Trying direct update..." Gray foreach ($m in $targetManagers.Keys) { _pw_do_update_single $Query $m } return } if ($targetMatches.Count -eq 1) { _pw_do_update_single $targetMatches[0].ID $targetMatches[0].Manager return } _pw_color " Multiple managers have updates for '$Query':" Yellow $pkg = _pw_pick_source $targetMatches if ($pkg) { _pw_do_update_single $pkg.ID $pkg.Manager } } <# .SYNOPSIS Universal package management layer CLI for Windows. .DESCRIPTION Unifies package search, installation, holding, sync, and system updates across winget, chocolatey, and scoop behind a clean developer terminal interface. .PARAMETER Command The action shorthand (-S, -Ss, -R, doctor, hold, etc.) or full command. .PARAMETER Query The package name, query term, or ID depending on the command context. .PARAMETER Manager Filters execution to a single package manager ('winget', 'choco', 'scoop'). .PARAMETER Limit Max results to display for queries. Defaults to 40. .PARAMETER Timeout Max duration in seconds to wait for async operations. Defaults to 35. .PARAMETER NoHeader If set, suppresses printing the visual ASCII brand logo and status header. .PARAMETER NoInteractive If set, executes in non-interactive scripting mode without user prompt picks. .PARAMETER _pw_unbound Collects additional unbound parameters and processes shorthands. .EXAMPLE pacwin search nodejs .EXAMPLE pacwin -S neovim --manager scoop .EXAMPLE pacwin doctor #> function pacwin { [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Position = 0)] [string]$Command, [Parameter(Position = 1)] [string]$Query, [Parameter()] [ValidateSet("winget", "choco", "scoop")] [string]$Manager, [Parameter()] [int]$Limit = 40, [Parameter()] [int]$Timeout = 35, [Parameter()] [switch]$NoHeader, [Parameter()] [switch]$NoInteractive, [Parameter(ValueFromRemainingArguments = $true)] [string[]]$_pw_unbound ) # Normalize command and query from input # Handle -ni / -NoInteractive from _pw_unbound (PowerShell may not bind it correctly in all cases) if ($_pw_unbound -contains "-ni" -or $_pw_unbound -contains "-NoInteractive") { $NoInteractive = $true $_pw_unbound = @($_pw_unbound | Where-Object { $_ -ne "-ni" -and $_ -ne "-NoInteractive" }) } $parsed = _pw_parse_args -Command $Command -Query $Query -Unbound $_pw_unbound $Command = $parsed.Command $Query = $parsed.Query $managers = _pw_detect_managers $targetManagers = _pw_filter_manager $managers $Manager if (-not $NoHeader) { _pw_header $managers } if (-not (_pw_assert_managers $managers)) { return } if (-not $targetManagers) { return } # Global Admin check for choco/winget operations _pw_check_admin_requirements -managers $managers -Manager $Manager -Command $Command if ($Query) { $Query = _pw_sanitize $Query if (-not $Query) { return } } $ctx = @{ Command = $Command Query = $Query Manager = $Manager Limit = $Limit Timeout = $Timeout NoInteractive = $NoInteractive targetManagers = $targetManagers managers = $managers } $executed = $false foreach ($pattern in $script:CommandMap.Keys) { if ($Command -match $pattern) { $cmdBlock = $script:CommandMap[$pattern] & $cmdBlock $ctx $executed = $true break } } if (-not $executed) { _pw_color " Unknown command '$Command'." Yellow _pw_color " Type 'pacwin help' for the full command list." Gray } } #endregion #region -- Renderer ----------------------------------------- $script:SRC_COLORS = @{ "winget" = "Cyan" "chocolatey" = "Yellow" "scoop" = "Green" } <# .SYNOPSIS Truncates a string to a maximum length, padding it if necessary. .DESCRIPTION Pads the input string to the specified width if it's shorter. If it's longer, truncates it to (max - 1) characters and appends a dot. .PARAMETER str The string to process. .PARAMETER max The target length. .OUTPUTS System.String .EXAMPLE _pw_truncate "example string" 10 #> function _pw_truncate { param([string]$str, [int]$max) if (-not $str) { return "".PadRight($max) } if ($str.Length -le $max) { return $str.PadRight($max) } return ($str.Substring(0, $max - 1) + ".") } <# .SYNOPSIS Extracts a substring column from a tabular line. .DESCRIPTION Safely extracts a column of text given an offset and length, checking bounds and returning a fallback value if the extracted text is empty or out of range. .PARAMETER line The source line string. .PARAMETER off The character offset where the column starts (0-indexed). .PARAMETER len The maximum width of the column. .PARAMETER fallback The value to return if the column is out of bounds or empty. Defaults to "?". .OUTPUTS System.String .EXAMPLE _pw_extract_column "Google Chrome Google.Chrome" 15 13 #> function _pw_extract_column { param([string]$line, [int]$off, [int]$len, [string]$fallback = "?") if ($off -lt 0 -or $off -ge $line.Length) { return $fallback } $actualLen = [Math]::Min($len, $line.Length - $off) if ($actualLen -le 0) { return $fallback } $val = $line.Substring($off, $actualLen).Trim() if ($val) { return $val } return $fallback } function _pw_render_results { param([object]$results, [string]$query = "", [switch]$NoIndex) $arr = @($results) if ($arr.Count -eq 0) { if ($query) { _pw_color " No results for '$query'." Yellow } return } $termWidth = try { $Host.UI.RawUI.WindowSize.Width } catch { 100 } if ($termWidth -lt 80) { $termWidth = 80 } $idxW = if ($NoIndex) { 2 } else { 8 } $srcW = 12 $remW = $termWidth - $idxW - $srcW - 4 $nameW = [int]($remW * 0.5) $idW = [int]($remW * 0.3) $verW = $remW - $nameW - $idW _pw_color "" if (-not $NoIndex) { _pw_color (" {0,-7} {1,-$($nameW-1)} {2,-$($idW-1)} {3,-$($verW-1)} {4}" -f "ENTRY", "NAME", "ID", "VERSION", "SOURCE") DarkGray } else { _pw_color (" {0,-$($nameW-1)} {1,-$($idW-1)} {2,-$($verW-1)} {3}" -f "NAME", "ID", "VERSION", "SOURCE") DarkGray } _pw_sep $i = 1 foreach ($r in $arr) { $col = if ($script:SRC_COLORS[$r.Source]) { $script:SRC_COLORS[$r.Source] } else { "White" } $name = _pw_truncate $r.Name ($nameW - 2) $id = _pw_truncate $r.ID ($idW - 2) $ver = _pw_truncate $r.Version ($verW - 2) if (-not $NoIndex) { # TropicalUI Row selection style _pw_color " " -NoNewline _pw_color "│" DarkGray -NoNewline _pw_color (" {0,2} " -f $i) Cyan -NoNewline _pw_color "│ " DarkGray -NoNewline } else { _pw_color " " DarkGray -NoNewline } _pw_color ("{0,-$nameW}{1,-$idW}{2,-$verW}" -f $name, $id, $ver) White -NoNewline _pw_color $r.Source $col $i++ } _pw_color "" } #endregion #region -- Pin / Hold --------------------------------------- function _pw_do_pin { [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory=$true)] [string]$id, [Parameter(Mandatory=$true)] [string]$mgr, [switch]$Unpin ) $action = if ($Unpin) { "Unpinning" } else { "Pinning" } _pw_color " -> $action '$id' with $mgr ..." Cyan _pw_sep if (-not $PSCmdlet.ShouldProcess("$action $id with $mgr")) { return } switch ($mgr) { "winget" { if ($Unpin) { winget pin remove --id $id } else { winget pin add --id $id --accept-source-agreements } } "choco" { if ($Unpin) { choco pin remove --name $id } else { choco pin add --name $id } } "scoop" { if ($Unpin) { scoop unhold $id } else { scoop hold $id } } } _pw_handle_result $mgr (_pw_last_exit_code) @() } function _pw_do_pin_list { param($managers) _pw_color " Pinned / held packages:" Cyan _pw_sep if ($managers["winget"]) { _pw_color " -- winget -----------------------------" Cyan winget pin list } if ($managers["choco"]) { _pw_color " -- chocolatey -------------------------" Yellow choco pin list } if ($managers["scoop"]) { _pw_color " -- scoop ------------------------------" Green # Scoop doesn't have a direct list command; we check for .hold file $scoopDir = if ($env:SCOOP) { $env:SCOOP } else { "$HOME\scoop" } $apps = Get-ChildItem "$scoopDir\apps" -Directory -ErrorAction SilentlyContinue foreach ($app in $apps) { $holdFile = Join-Path $app.FullName "current\.hold" if (Test-Path $holdFile) { _pw_color " * $($app.Name) [held]" Green } } } } #endregion #region -- Export / Import ---------------------------------- function _pw_do_export { param($managers, [string]$outPath) if (-not $outPath) { $outPath = Join-Path $HOME "pacwin-export-$(Get-Date -Format 'yyyyMMdd-HHmm').json" } _pw_color " Collecting installed packages..." Cyan $export = [ordered]@{ generated = (Get-Date -Format 'o'); packages = @() } if ($managers["winget"]) { # winget export can be slow, we use --accept-source-agreements try { $rawJson = winget export - --accept-source-agreements 2>$null if ($rawJson) { $raw = $rawJson | ConvertFrom-Json if ($raw.Sources) { foreach ($src in $raw.Sources) { foreach ($pkg in $src.Packages) { $export.packages += [ordered]@{ manager = "winget"; id = $pkg.PackageIdentifier } } } } } } catch { _pw_color " [!] Error exporting from winget: $_" Yellow } } if ($managers["choco"]) { $raw = choco list --local-only --limit-output 2>$null foreach ($line in $raw) { $parts = $line -split "\|" if ($parts.Count -ge 1 -and $parts[0].Trim()) { $export.packages += [ordered]@{ manager = "choco"; id = $parts[0].Trim() } } } } if ($managers["scoop"]) { try { $rawJson = scoop export 2>$null if ($rawJson) { $raw = $rawJson | ConvertFrom-Json if ($raw.apps) { foreach ($app in $raw.apps) { $export.packages += [ordered]@{ manager = "scoop"; id = $app.Name } } } } } catch { _pw_color " [!] Error exporting from scoop: $_" Yellow } } $export | ConvertTo-Json -Depth 5 | Out-File $outPath -Encoding UTF8 _pw_color " [OK] Exported $($export.packages.Count) packages to:" Green _pw_color " $outPath" DarkGray } function _pw_do_import { [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory=$true)] $managers, [Parameter(Mandatory=$true)] [string]$inPath ) if (-not $inPath -or -not (Test-Path $inPath)) { _pw_color " [!] File not found: '$inPath'" Red; return } $data = Get-Content $inPath -Raw | ConvertFrom-Json -ErrorAction Stop _pw_color " Importing $($data.packages.Count) packages from export..." Cyan _pw_sep $failed = [System.Collections.Generic.List[string]]::new() foreach ($pkg in $data.packages) { if (-not $managers[$pkg.manager]) { _pw_color " [SKIP] $($pkg.id) - manager '$($pkg.manager)' not available." DarkGray continue } _pw_color " -> $($pkg.manager): $($pkg.id)" Cyan if (-not $PSCmdlet.ShouldProcess("Install $($pkg.id) via $($pkg.manager)")) { continue } switch ($pkg.manager) { "winget" { winget install --id $pkg.id --accept-package-agreements --accept-source-agreements 2>&1 | Out-Null } "choco" { choco install $pkg.id -y 2>&1 | Out-Null } "scoop" { scoop install $pkg.id 2>&1 | Out-Null } } if ((_pw_last_exit_code) -ne 0) { $failed.Add($pkg.id) } } _pw_sep if ($failed.Count -eq 0) { _pw_color " [OK] All packages installed successfully." Green } else { _pw_color " [!] Failed: $($failed -join ', ')" Red } } #endregion #region -- Doctor ------------------------------------------- function _pw_doctor_check_admin { param($managers) $issues = 0 $isAdmin = _pw_is_admin _pw_color (" Privileges : {0}" -f $(if ($isAdmin) { "Administrator" } else { "User" })) $(if ($isAdmin) { "Green" } else { "Yellow" }) if (-not $isAdmin -and $managers["choco"]) { _pw_color " [!] Warning: Chocolatey (choco) usually requires Administrator privileges." Yellow $issues++ } return $issues } function _pw_doctor_check_psversion { $issues = 0 $psv = $PSVersionTable.PSVersion _pw_color (" PS Version : {0}" -f $psv) $(if ($psv.Major -ge 5) { "Green" } else { "Red" }) if ($psv.Major -lt 5) { _pw_color " [!] PowerShell 5.1+ required." Red; $issues++ } return $issues } function _pw_doctor_check_managers { foreach ($mgr in @("winget","choco","scoop","sfsu")) { $exe = _pw_exe_cached $mgr if ($exe) { $ver = try { switch ($mgr) { "winget" { (winget --version 2>$null) -replace "[^\d\.]","" } "choco" { (choco --version 2>$null) } "scoop" { (scoop --version 2>$null) | Select-Object -First 1 } "sfsu" { (sfsu --version 2>$null) | Select-Object -First 1 } } } catch { "Error" } _pw_color (" {0,-12} : OK {1}" -f $mgr, $ver) Green } else { _pw_color (" {0,-12} : NOT FOUND" -f $mgr) DarkGray } } return 0 } function _pw_doctor_check_connectivity { $issues = 0 _pw_color "" _pw_color " Connectivity:" DarkGray $hosts = @("api.github.com","community.chocolatey.org","github.com") foreach ($h in $hosts) { $ok = Test-Connection -ComputerName $h -Count 1 -Quiet -ErrorAction SilentlyContinue _pw_color (" {0,-32} : {1}" -f $h, $(if ($ok) { "OK" } else { "UNREACHABLE" })) $(if ($ok) { "Green" } else { "Red" }) if (-not $ok) { $issues++ } } return $issues } function _pw_doctor_check_scoop_buckets { param($managers) $issues = 0 if ($managers["scoop"]) { _pw_color "" _pw_color " Scoop buckets:" DarkGray $buckets = scoop bucket list 2>$null foreach ($b in $buckets) { $name = ($b -split "\s+")[0] _pw_color " Bucket: $name" DarkGray } # Check last update time of main bucket $scoopDir = if ($env:SCOOP) { $env:SCOOP } else { "$HOME\scoop" } $mainBucket = "$scoopDir\buckets\main" if (Test-Path $mainBucket) { $lastFetch = Get-Item "$mainBucket\.git\FETCH_HEAD" -ErrorAction SilentlyContinue if ($lastFetch) { $age = (Get-Date) - $lastFetch.LastWriteTime $ageStr = "{0}d {1}h" -f [int]$age.TotalDays, $age.Hours $stale = $age.TotalDays -gt 3 _pw_color (" main bucket age : {0}" -f $ageStr) $(if ($stale) { "Yellow" } else { "Green" }) if ($stale) { _pw_color " [!] Stale bucket. Run: scoop update" Yellow $issues++ } } } } return $issues } function _pw_do_doctor { param($managers) _pw_color " Running diagnostics..." Cyan _pw_sep $issues = 0 $issues += _pw_doctor_check_admin $managers $issues += _pw_doctor_check_psversion $issues += _pw_doctor_check_managers $issues += _pw_doctor_check_connectivity $issues += _pw_doctor_check_scoop_buckets $managers _pw_sep if ($issues -eq 0) { _pw_color " [OK] No issues detected." Green } else { _pw_color (" [{0} issue(s) found]" -f $issues) Yellow } } #endregion #region -- Self-Update --------------------------------------- function _pw_self_update { $repoBaseUrl = "https://raw.githubusercontent.com/julesklord/pacwin/main" $moduleName = "pacwin" _pw_color " [i] Checking for pacwin updates..." Cyan # Detect module location $module = Get-Module $moduleName if (-not $module) { _pw_color " [!] Module pacwin is not loaded in current session." Red return } $moduleDir = Split-Path $module.Path _pw_color " Target Directory: $moduleDir" DarkGray # Scenario 1: Git repository if (Test-Path (Join-Path $moduleDir ".git")) { _pw_color " [i] Git repository detected. Updating via 'git pull'..." Cyan $oldDir = Get-Location try { Set-Location $moduleDir $out = git pull 2>&1 if ((_pw_last_exit_code) -eq 0) { _pw_color " [v] Update successful via Git." Green _pw_color " $out" Gray } else { _pw_color " [!] Git pull failed: $out" Red } } finally { Set-Location $oldDir } } # Scenario 2: Standard installation else { _pw_color " [i] Downloading latest version from GitHub..." Cyan $files = @("pacwin.psm1", "pacwin.psd1") $success = $true foreach ($f in $files) { $url = "$repoBaseUrl/$f" $dest = Join-Path $moduleDir $f try { Invoke-WebRequest -Uri $url -OutFile $dest -ErrorAction Stop -UseBasicParsing _pw_color " [v] Updated $f" Gray } catch { _pw_color " [!] Failed to update ${f}: $_" Red $success = $false } } if ($success) { _pw_color "`n [SUCCESS] pacwin has been updated to the latest version." Green } } _pw_color " To apply changes, please restart your terminal or run:" Gray _pw_color " Import-Module $moduleName -Force" White } #endregion #region -- Sync (duplicate detection) ---------------------- function _pw_do_sync { param($managers) _pw_color " Scanning for cross-manager duplicates..." Cyan _pw_sep $installed = [System.Collections.Generic.List[PSCustomObject]]::new() if ($managers["winget"]) { $raw = winget list --accept-source-agreements 2>$null $lines = [System.Collections.Generic.List[string]]::new($raw.Count); foreach ($r in $raw) { $lines.Add([string]$r) } $parsed = _pw_parse_winget_lines $lines foreach ($p in $parsed) { $installed.Add($p) } } if ($managers["choco"]) { $raw = choco list --local-only --limit-output 2>$null $parsed = _pw_parse_choco_lines $raw foreach ($p in $parsed) { $installed.Add($p) } } if ($managers["scoop"]) { $sfsu = Get-Command sfsu -ErrorAction SilentlyContinue $raw = if ($sfsu) { & $sfsu.Source list 2>$null } else { & $managers["scoop"] list 2>$null } $parsed = _pw_parse_scoop_lines $raw foreach ($p in $parsed) { $installed.Add($p) } } # Normalize name (lowercase, no symbols) for grouping # But also consider IDs if they are identical $groups = $installed | Group-Object { $_.Name.ToLower() -replace "[\-_\. ]","" } $dupes = $groups | Where-Object { $_.Count -gt 1 } if ($dupes.Count -eq 0) { _pw_color " [OK] No duplicate packages detected." Green return } _pw_color (" Found {0} potential duplicate(s):" -f $dupes.Count) Yellow _pw_color "" foreach ($dupe in $dupes) { _pw_color (" Package: {0}" -f $dupe.Group[0].Name) White foreach ($pkg in $dupe.Group) { $col = if ($script:SRC_COLORS[$pkg.Source]) { $script:SRC_COLORS[$pkg.Source] } else { "White" } _pw_color (" [{0,-10}] ID: {1,-25} v{2}" -f $pkg.Source, $pkg.ID, $pkg.Version) $col } _pw_color " Suggestion: keep one, run 'pacwin uninstall <id> -Manager <mgr>'" DarkGray _pw_color "" } } #endregion #region -- Operations --------------------------------------- function _pw_do_install { [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory=$true)] $pkg ) _pw_color "" _pw_color " -> Installing: $($pkg.Name) [$($pkg.Source) v$($pkg.Version)]" Cyan _pw_sep if (-not $PSCmdlet.ShouldProcess("Installing $($pkg.Name) via $($pkg.Source)")) { return } $output = @() switch ($pkg.Manager) { "winget" { $output = winget install --id $pkg.ID --accept-package-agreements --accept-source-agreements 2>&1 } "choco" { $output = choco install $pkg.ID -y 2>&1 } "scoop" { $output = scoop install $pkg.ID 2>&1 } } _pw_handle_result $pkg.Manager (_pw_last_exit_code) $output } function _pw_do_uninstall { [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory=$true)] [string]$name, [Parameter(Mandatory=$true)] [string]$mgr ) _pw_color "" _pw_color " -> Uninstalling '$name' with $mgr ..." Yellow _pw_sep if (-not $PSCmdlet.ShouldProcess("Uninstalling '$name' with $mgr")) { return } $output = @() switch ($mgr) { "winget" { $output = winget uninstall --id $name 2>&1 } "choco" { $output = choco uninstall $name -y 2>&1 } "scoop" { $output = scoop uninstall $name 2>&1 } } _pw_handle_result $mgr (_pw_last_exit_code) $output } function _pw_do_update_single { [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory=$true)] [string]$id, [Parameter(Mandatory=$true)] [string]$mgr ) _pw_color "" _pw_color " -> Updating '$id' with $mgr ..." Cyan _pw_sep if (-not $PSCmdlet.ShouldProcess("Updating '$id' with $mgr")) { return } $output = @() switch ($mgr) { "winget" { $output = winget upgrade --id $id --accept-package-agreements --accept-source-agreements 2>&1 } "choco" { $output = choco upgrade $id -y 2>&1 } "scoop" { $output = scoop update $id 2>&1 } } _pw_handle_result $mgr (_pw_last_exit_code) $output } function _pw_do_update_all { [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory=$true)] $managers ) _pw_color " :: Synchronizing package databases..." Cyan if ($managers["scoop"]) { _pw_color " Updating scoop database..." Gray & $managers["scoop"] update | Out-Null } _pw_color " :: Searching for outdated packages..." Cyan $outdated = _pw_do_outdated $managers -Silent if ($outdated.Count -eq 0) { _pw_color " [OK] All packages are up to date." Green return } _pw_color " Packages to upgrade ($($outdated.Count) total):" Yellow _pw_render_results $outdated _pw_color "" $confirmation = Read-Host " :: Proceed with installation? [Y/n]" if ($confirmation -ne "" -and $confirmation -notmatch "^(y|Y)$") { _pw_color " Aborted." Yellow return } _pw_color " :: Starting full system upgrade..." Cyan if ($managers["winget"]) { _pw_color " -- winget -----------------------------" Cyan if ($PSCmdlet.ShouldProcess("winget upgrade --all")) { & $managers["winget"] upgrade --all --accept-package-agreements --accept-source-agreements } } if ($managers["choco"]) { _pw_color " -- chocolatey -------------------------" Yellow if ($PSCmdlet.ShouldProcess("choco upgrade all")) { & $managers["choco"] upgrade all -y } } if ($managers["scoop"]) { _pw_color " -- scoop ------------------------------" Green if ($PSCmdlet.ShouldProcess("scoop update *")) { & $managers["scoop"] update * } } } function _pw_do_outdated { param($managers, [switch]$Silent) $allResults = [System.Collections.Generic.List[PSCustomObject]]::new() if ($managers["winget"]) { if (-not $Silent) { _pw_color " -- winget -----------------------------" Cyan } $out = winget upgrade --accept-source-agreements 2>$null $lines = [System.Collections.Generic.List[string]]::new($out.Count); foreach ($o in $out) { $lines.Add([string]$o) } $parsed = _pw_parse_winget_lines $lines foreach ($p in $parsed) { $allResults.Add($p) } } if ($managers["choco"]) { if (-not $Silent) { _pw_color " -- chocolatey -------------------------" Yellow } $out = choco outdated --limit-output 2>$null $parsed = _pw_parse_choco_lines $out foreach ($p in $parsed) { $allResults.Add($p) } } if ($managers["scoop"]) { if (-not $Silent) { _pw_color " -- scoop ------------------------------" Green } $sfsu = Get-Command sfsu -ErrorAction SilentlyContinue $out = if ($sfsu) { & $sfsu.Source outdated 2>$null } else { & $managers["scoop"] status 2>$null } $lines = [System.Collections.Generic.List[string]]::new($out.Count) foreach ($line in $out) { if ($line) { $lines.Add([string]$line) } } $parsed = _pw_parse_scoop_outdated_lines $lines foreach ($p in $parsed) { $allResults.Add($p) } } if ($Silent) { return $allResults } # Non-silent: render results via standard table if ($allResults.Count -eq 0) { _pw_color " [OK] All packages are up to date." Green } else { _pw_color " Outdated packages ($($allResults.Count) found):" Yellow _pw_render_results $allResults } } function _pw_do_list { param($managers, [string]$filter) _pw_color " Listing installed packages..." Cyan if ($filter) { _pw_color " (Filter: '$filter')" DarkGray } if ($managers["winget"]) { _pw_color " -- winget -----------------------------" Cyan if ($filter) { winget list --query $filter } else { winget list } } if ($managers["choco"]) { _pw_color " -- chocolatey -------------------------" Yellow if ($filter) { choco list --local-only $filter } else { choco list --local-only } } if ($managers["scoop"]) { _pw_color " -- scoop ------------------------------" Green $sfsu = Get-Command sfsu -ErrorAction SilentlyContinue if ($sfsu) { if ($filter) { & $sfsu.Source list $filter } else { & $sfsu.Source list } } else { if ($filter) { & $managers["scoop"] list $filter } else { & $managers["scoop"] list } } } } function _pw_do_info { param($managers, [string]$name) _pw_color " Fetching information for '$name'..." Cyan if ($managers["winget"]) { _pw_color " -- winget -----------------------------" Cyan winget show --id $name --accept-source-agreements } if ($managers["choco"]) { _pw_color " -- chocolatey -------------------------" Yellow choco info $name } if ($managers["scoop"]) { _pw_color " -- scoop ------------------------------" Green scoop info $name } } function _pw_handle_result { param( [string]$manager, [int]$exitCode, [string[]]$output ) $result = _pw_handle_error -Manager $manager -ExitCode $exitCode -Message "" -Output $output if ($result.Success) { _pw_color " [OK] Operation completed successfully. $($result.Message)" Green } else { _pw_color " [FAILURE] The operation could not be completed." Red if ($result.Message) { _pw_color " Detail: $($result.Message)" Yellow } else { _pw_color " Check previous output for more details." DarkGray } } } #endregion #region -- Source Picker ------------------------------------ function _pw_pick_source { param([object]$candidates) $arr = @($candidates) if ($arr.Count -eq 1) { return $arr[0] } _pw_color " [PROMPT] Multiple candidates found. Specify target index:" Cyan _pw_render_results $arr _pw_color " >> ENTRY_INDEX (ENTER TO CANCEL): " Cyan -NoNewline $choice = Read-Host if ([string]::IsNullOrWhiteSpace($choice)) { return $null } $idx = 0 if (-not [int]::TryParse($choice, [ref]$idx) -or $idx -lt 1 -or $idx -gt $arr.Count) { _pw_color " Invalid selection." Red; return $null } return $arr[$idx - 1] } #endregion #region -- Tab Completion ----------------------------------- Register-ArgumentCompleter -CommandName pacwin -ParameterName Command -ScriptBlock { param($commandName, $parameterName, $wordToComplete) $cmds = @( 'search','install','uninstall','update','outdated','list', 'info','pin','unpin','export','import','doctor','status','help', 'hold','unhold','check','sync','dupes','dedup','self-update','version', '-S','-Ss','-Syu','-R','-Q','-Qu','-Si','-V','-h','--help','--version' ) foreach ($cmd in $cmds.Where({ $_ -like "$wordToComplete*" })) { [System.Management.Automation.CompletionResult]::new( $cmd, # completionText $cmd, # listItemText [System.Management.Automation.CompletionResultType]::ParameterValue, $cmd # toolTip ) } } Register-ArgumentCompleter -CommandName pacwin -ParameterName Manager -ScriptBlock { param($commandName, $parameterName, $wordToComplete) foreach ($mgr in @('winget','choco','scoop').Where({ $_ -like "$wordToComplete*" })) { [System.Management.Automation.CompletionResult]::new($mgr, $mgr, [System.Management.Automation.CompletionResultType]::ParameterValue, $mgr) } } # Tab completion for -NoInteractive flag Register-ArgumentCompleter -CommandName pacwin -ParameterName NoInteractive -ScriptBlock { param($commandName, $parameterName, $wordToComplete) foreach ($opt in @('$true','$false','-ni','-NoInteractive').Where({ $_ -like "$wordToComplete*" })) { [System.Management.Automation.CompletionResult]::new($opt, $opt, [System.Management.Automation.CompletionResultType]::ParameterValue, $opt) } } # Completar -Query con paquetes instalados cuando el comando es uninstall/pin/update Register-ArgumentCompleter -CommandName pacwin -ParameterName Query -ScriptBlock { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) $cmd = $fakeBoundParameters['Command'] if ($cmd -notin @('uninstall','pin','unpin','update','info')) { return } # Intenta completar con winget list (rapido si existe) if (-not (Get-Command "winget" -ErrorAction SilentlyContinue)) { return } $raw = winget list --query $wordToComplete 2>$null | Select-Object -Skip 3 foreach ($line in $raw) { $parts = ($line -split "\s{2,}").Where({ $_ -ne "" }) if ($parts.Count -ge 2 -and $parts[0] -notmatch "^-{3}") { $id = $parts[1] [System.Management.Automation.CompletionResult]::new($id, $id, [System.Management.Automation.CompletionResultType]::ParameterValue, $parts[0]) } } } #endregion |