Public/Find-DFPackage.ps1
|
#Requires -Version 7.0 function Find-DFPackage { <# .SYNOPSIS Searches every installer catalog (scoop, winget, choco, npm, PyPI, crates.io, PSGallery) for a tool and returns a merged summary — description, installed status and source, per-catalog availability and versions, homepage, license, and cache age. .DESCRIPTION Cache-first for speed: answers come from local catalog caches instantly; stale or missing web-catalog entries are refreshed in the background so the NEXT query is current (use -Fresh to block on live data instead). Interactively, a confident single match renders a rich info card and ambiguous keyword searches render a compact table. When output is piped or redirected (or with -AsObject), raw DotForge.ToolInfo objects are emitted instead — pipeline-safe, no ANSI. .PARAMETER Query Command name or keywords. Multiple words may be passed unquoted: trifle static site generator .PARAMETER Source Restrict the search to these catalogs. .PARAMETER Fresh Block on live catalog fetches instead of serving cached data. .PARAMETER AsObject Emit DotForge.ToolInfo objects even at an interactive terminal. Use this when capturing: $x = trifle rg -AsObject (assignment looks interactive to pipeline-position detection, so the default would be rendered strings). .PARAMETER Category Filter by function category — see Get-DFCategoryList for valid values. Renders the match table (never the detail card); combine with -WorksWith to AND the two facets. .PARAMETER WorksWith Filter by what the tool works with — see Get-DFCategoryList for valid values. .PARAMETER All Always render the full match table, never the detail card — even on an otherwise-exact match. The table's Id column shows values usable as a qualified query: trifle <source>:<id>. .PARAMETER Readme After rendering the detail card, fetch and page the package's readme (npm registry readme, GitHub readme, or PyPI long description). Requires the detail path (a qualified id or an exact match); otherwise a warning is shown and the match table is rendered instead. .PARAMETER GitInfo Resolve the package's GitHub repository (from source details or the homepage) and add stars/latest release/activity to the detail card. Requires the detail path, like -Readme. .EXAMPLE trifle ripgrep Renders an info card: installed status, catalogs carrying it, versions. .EXAMPLE trifle json parser -Source scoop,winget Keyword search limited to two catalogs; renders a match table. .EXAMPLE Find-DFPackage ripgrep -AsObject | Select-Object Name, InstalledVia Pipeline-friendly object output. .EXAMPLE trifle winget:Zed.Zed Qualified source:id query — zeroes in on one package in one catalog and renders its detail card, bypassing keyword ranking entirely. .EXAMPLE trifle -Category search -WorksWith filesystem Facet search: every seed-db tool tagged 'search' AND 'filesystem', resolved through live catalog search (accurate installed state), rendered as a table. .OUTPUTS PSCustomObject (DotForge.ToolInfo) when piped or with -AsObject; rendered System.String lines otherwise. .NOTES Assigning without -AsObject captures rendered strings — pipeline position cannot distinguish assignment from a terminal. #> [CmdletBinding(DefaultParameterSetName = 'Query')] [OutputType([PSCustomObject])] param( [Parameter(Mandatory, Position = 0, ValueFromRemainingArguments, ParameterSetName = 'Query')] [string[]]$Query, [Parameter(ParameterSetName = 'Category')] [string[]]$Category, [Parameter(ParameterSetName = 'Category')] [string[]]$WorksWith, [ValidateSet('scoop', 'winget', 'choco', 'npm', 'pypi', 'crates', 'psgallery')] [string[]]$Source, [switch]$Fresh, [switch]$AsObject, [Parameter(ParameterSetName = 'Query')] [switch]$All, [switch]$Readme, [switch]$GitInfo ) if ($PSCmdlet.ParameterSetName -eq 'Category') { if (-not $Category -and -not $WorksWith) { Write-Error 'DotForge: -Category and/or -WorksWith requires at least one value. Run Get-DFCategoryList to see valid terms.' -ErrorAction Stop } $db = Get-DFCategoryDb if (-not $db.Raw) { return 'Category database unavailable — run Update-DFCategoryDb or check the module install.' } $validateFacet = { param($Values, $FacetName, $Vocab) $bad = @($Values | Where-Object { $_ -notin $Vocab }) if ($bad) { Write-Error "DotForge: unknown $FacetName value(s): $($bad -join ', '). Run Get-DFCategoryList -Facet $FacetName to see valid terms." -ErrorAction Stop } } if ($Category) { & $validateFacet $Category 'function' @($db.Raw.taxonomy.function) } if ($WorksWith) { & $validateFacet $WorksWith 'worksWith' @($db.Raw.taxonomy.worksWith) } $unionFacet = { param($Values, $Prefix) if (-not $Values) { return $null } $union = [System.Collections.Generic.HashSet[string]]::new() foreach ($v in $Values) { foreach ($k in @($db.FacetIndex["${Prefix}:$v"])) { $null = $union.Add($k) } } $union } $catMatch = & $unionFacet $Category 'function' $wwMatch = & $unionFacet $WorksWith 'worksWith' $matchedKeys = if ($catMatch -and $wwMatch) { @($catMatch | Where-Object { $wwMatch.Contains($_) }) } elseif ($catMatch) { @($catMatch) } else { @($wwMatch) } $canonicalOrder = @('scoop', 'winget', 'choco', 'npm', 'pypi', 'crates', 'psgallery') $allowedSources = $Source ? $Source : $canonicalOrder $merged = [System.Collections.Generic.List[object]]::new() foreach ($key in ($matchedKeys | Sort-Object -Unique)) { $entry = $db.Raw.tools.$key $probeQueryText = $key $probeSource = $Source if ($entry.ids) { foreach ($src in $canonicalOrder) { if ($src -notin $allowedSources) { continue } $idProp = $entry.ids.PSObject.Properties[$src] if ($idProp) { $probeQueryText = $idProp.Value; $probeSource = @($src); break } } } # A db entry whose ids no longer resolve against any live catalog # (renamed/removed upstream) is silently skipped — stale seed # data, not a search failure. $hits = @(Resolve-DFCatalogQueryMerge -QueryText $probeQueryText -Source $probeSource -Fresh:$Fresh -RecordSeenQuery $false) if ($hits) { $merged.Add($hits[0]) } } $merged = @($merged | Sort-Object Name) $qualified = $null $detailMode = $false $queryText = "-Category $($Category -join ',') -WorksWith $($WorksWith -join ',')".Trim() } else { $queryText = $Query -join ' ' # Qualified id (source:packageId, from the -All table) → zero in on one # package in one catalog. Unknown prefixes stay ordinary keyword queries. $qualified = $null if ($queryText -match '^(?<src>scoop|winget|choco|npm|pypi|crates|psgallery):(?<id>.+)$') { $qualified = @{ Source = $Matches.src.ToLowerInvariant(); Id = $Matches.id.Trim() } # Cross-catalog searches use the bare trailing segment ONLY for scoop # ids, which are bucket-qualified (bucket/name). Other catalogs' ids # ARE the name — notably npm scoped packages (@scope/tool), where # splitting on '/' would search for the bare tool name and lose the # scope. $queryText = ($qualified.Source -eq 'scoop' -and $qualified.Id.Contains('/')) ? ($qualified.Id -split '/')[-1] : $qualified.Id } $normalized = (ConvertTo-DFCatalogQueryKey -Query $queryText).Normalized $merged = Resolve-DFCatalogQueryMerge -QueryText $queryText -Source $Source -Fresh:$Fresh if ($qualified) { # Keep only the group that actually contains the qualified package. $exact = @($merged | Where-Object { @($_.Sources | Where-Object { $_.Source -eq $qualified.Source -and $_.PackageId -ieq $qualified.Id }).Count -gt 0 }) if ($exact) { $merged = @($exact | Select-Object -First 1) } else { Write-Warning "DotForge: No package '$($qualified.Id)' found in $($qualified.Source) — showing matches for '$queryText'." $qualified = $null } } } # Detail path: a qualified id, or an exact top match without -All. Facet # mode (-Category/-WorksWith) always renders the table, so the exact-match # computation is skipped entirely there — an $All-less facet search whose # sole surviving hit resolves to an exact-id/exact-name match would # otherwise still read as "confident single match" and wrongly promote to # the detail card. $topExact = $PSCmdlet.ParameterSetName -ne 'Category' -and $merged.Count -gt 0 -and ( $merged[0].MatchKind -in 'exact-id', 'exact-name' -or $merged[0].Name -ieq $normalized) $detailMode = [bool]$qualified -or (-not $All -and $topExact) if ($detailMode -and $merged.Count -gt 0) { $top = $merged[0] $top.Details = Get-DFToolInfoDetails -Info $top -Fresh:$Fresh $catDb = Get-DFCategoryDb $catEntry = Get-DFCategoryDbEntry -Info $top -Database $catDb if ($catEntry) { $top.Category = [pscustomobject]@{ Entry = $catEntry.Entry Related = Get-DFCategoryRelatedTools -Database $catDb -Key $catEntry.Key } } if ($GitInfo) { $repo = Resolve-DFGitHubRepoUrl -Info $top if ($repo) { $top.GitHub = Get-DFGitHubRepoInfo -Owner $repo.Owner -Repo $repo.Repo -Fresh:$Fresh } } } if ($AsObject -or (Test-DFOutputPiped -Invocation $MyInvocation)) { return $merged } if ($merged.Count -eq 0) { return "No packages found matching '$queryText'." } $color = (-not $Env:NO_COLOR) -and $Host.UI.SupportsVirtualTerminal if ($detailMode -and $merged.Count -gt 0) { $card = [System.Collections.Generic.List[string]](Format-DFToolDetailCard -Info $merged[0] -Color $color ` -MoreMatches ($merged.Count - 1) -QueryText $queryText) if ($GitInfo -and -not $merged[0].GitHub) { $faintOn = $color ? "`e[2m" : '' $faintOff = $color ? "`e[0m" : '' $card.Add("${faintOn}GitHub — no repository resolved${faintOff}") } if ($Readme) { $readmeLines = Get-DFPackageReadme -Info $merged[0] -Fresh:$Fresh if ($readmeLines) { $card return ($readmeLines | Invoke-DFWithPager) } Write-Warning "DotForge: no readme found for '$($merged[0].Name)'." } return $card } if (($Readme -or $GitInfo) -and -not $detailMode) { Write-Warning 'DotForge: -Readme/-GitInfo need an exact match — showing the match table instead.' } $width = 120 try { if ($Host.UI.RawUI.WindowSize.Width -gt 0) { $width = $Host.UI.RawUI.WindowSize.Width } } catch {} Format-DFToolInfoTable -Infos $merged -Color $color -Width $width } Set-Alias -Name trifle -Value Find-DFPackage -Scope Global -Force |