Private/DFCatalog.Scoop.ps1
|
#Requires -Version 7.0 # Scoop catalog provider (snapshot kind). Never shells out to scoop.exe — # buckets are locally cloned git repos of JSON manifests, so search reads them # straight from disk; installed state comes from apps/<name>/current/manifest.json. function Get-DFCatalogScoopRoot { <# .SYNOPSIS Resolves the scoop root: $Env:SCOOP, then scoop's config.json root_path, then ~\scoop. #> [CmdletBinding()] [OutputType([string])] param() if ($Env:SCOOP) { return $Env:SCOOP } $configHome = $Env:XDG_CONFIG_HOME ? $Env:XDG_CONFIG_HOME : (Join-Path $HOME '.config') $config = Join-Path $configHome 'scoop/config.json' if (Test-Path $config) { try { $rootPath = (Get-Content $config -Raw | ConvertFrom-Json).root_path if ($rootPath) { return $rootPath } } catch { Write-Verbose "DotForge: unreadable scoop config '$config': $_" } } Join-Path $HOME 'scoop' } function Get-DFCatalogScoopFingerprint { <# .SYNOPSIS Cheap change-detection fingerprint for scoop buckets: each bucket's git HEAD commit, read directly from .git files (no process spawn). .PARAMETER ScoopRoot The scoop root directory. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [string]$ScoopRoot ) $bucketsDir = Join-Path $ScoopRoot 'buckets' if (-not (Test-Path $bucketsDir)) { return '' } $parts = foreach ($bucket in Get-ChildItem $bucketsDir -Directory | Sort-Object Name) { $hash = $null $headFile = Join-Path $bucket.FullName '.git/HEAD' if (Test-Path $headFile) { $head = (Get-Content $headFile -Raw).Trim() if ($head -match '^ref:\s*(.+)$') { $refPath = Join-Path $bucket.FullName ".git/$($Matches[1])" if (Test-Path $refPath) { $hash = (Get-Content $refPath -Raw).Trim() } else { $packed = Join-Path $bucket.FullName '.git/packed-refs' if (Test-Path $packed) { $line = Get-Content $packed | Where-Object { $_ -match "\s$([regex]::Escape($Matches[1]))$" } | Select-Object -First 1 if ($line) { $hash = ($line -split '\s+')[0] } } } } else { $hash = $head # detached HEAD holds the commit directly } } if (-not $hash) { $hash = $bucket.LastWriteTimeUtc.Ticks } "$($bucket.Name):$hash" } $parts -join ',' } function Build-DFCatalogScoopIndexData { <# .SYNOPSIS Projects all bucket manifests into flat index entries. .PARAMETER ScoopRoot The scoop root directory. .PARAMETER IncludeRaw When set, each entry also carries a 'raw' member holding the manifest's verbatim JSON text. Off by default so the runtime index shape (and its on-disk size) is unchanged; the package-universe build opts in to preserve the full manifest (checkver/autoupdate/bin/depends/...). #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ScoopRoot, [switch]$IncludeRaw ) $bucketsDir = Join-Path $ScoopRoot 'buckets' if (-not (Test-Path $bucketsDir)) { return @() } # Hot loop over thousands of manifests: raw .NET file + JSON APIs. # Get-Content | ConvertFrom-Json per file costs ~50s across the default # buckets; this path does the same work in a couple of seconds. foreach ($bucket in Get-ChildItem $bucketsDir -Directory) { $manifestDir = Join-Path $bucket.FullName 'bucket' if (-not (Test-Path $manifestDir)) { continue } foreach ($file in [System.IO.Directory]::EnumerateFiles($manifestDir, '*.json')) { $text = [System.IO.File]::ReadAllText($file) try { $doc = [System.Text.Json.JsonDocument]::Parse($text) } catch { Write-Verbose "DotForge: skipping unreadable scoop manifest '$file'" continue } try { $root = $doc.RootElement $el = [System.Text.Json.JsonElement]::new() $version = if ($root.TryGetProperty('version', [ref]$el)) { $el.ToString() } else { $null } $homepage = if ($root.TryGetProperty('homepage', [ref]$el)) { $el.ToString() } else { $null } $description = $null if ($root.TryGetProperty('description', [ref]$el)) { $description = if ($el.ValueKind -eq [System.Text.Json.JsonValueKind]::Array) { @($el.EnumerateArray() | ForEach-Object ToString) -join ' ' } else { $el.ToString() } } $license = $null if ($root.TryGetProperty('license', [ref]$el)) { $license = if ($el.ValueKind -eq [System.Text.Json.JsonValueKind]::Object) { $id = [System.Text.Json.JsonElement]::new() if ($el.TryGetProperty('identifier', [ref]$id)) { $id.ToString() } else { $null } } else { $el.ToString() } } $entry = [ordered]@{ name = [System.IO.Path]::GetFileNameWithoutExtension($file) bucket = $bucket.Name version = $version description = $description homepage = $homepage license = $license } if ($IncludeRaw) { $entry['raw'] = $text } [pscustomobject]$entry } finally { $doc.Dispose() } } } } function Update-DFCatalogScoopIndex { <# .SYNOPSIS Rebuilds the on-disk scoop index (catalogs/scoop/index.json) and its bucket-HEAD fingerprint key. No-op when catalog caching is disabled. .PARAMETER ScoopRoot The scoop root directory (defaults to Get-DFCatalogScoopRoot). #> [CmdletBinding()] param( [string]$ScoopRoot = (Get-DFCatalogScoopRoot) ) $cacheRoot = Get-DFCatalogCacheRoot if (-not $cacheRoot) { return } $dir = Join-Path $cacheRoot 'scoop' $data = @(Build-DFCatalogScoopIndexData -ScoopRoot $ScoopRoot) Write-DFCatalogCacheFile -Path (Join-Path $dir 'index.json') -Query '' -Results $data Set-Content -Path (Join-Path $dir 'index.key') ` -Value (Get-DFCatalogScoopFingerprint -ScoopRoot $ScoopRoot) -Encoding UTF8 } function Get-DFCatalogScoopIndex { <# .SYNOPSIS Returns @{Data; AgeMinutes} for the scoop index, rebuilding the disk cache when the bucket fingerprint changed, or building in memory when caching is disabled. .PARAMETER ScoopRoot The scoop root directory. .PARAMETER Force Rebuild even when the fingerprint matches. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ScoopRoot, [switch]$Force ) $cacheRoot = Get-DFCatalogCacheRoot if ($cacheRoot) { $indexFile = Join-Path $cacheRoot 'scoop/index.json' $keyFile = Join-Path $cacheRoot 'scoop/index.key' $fingerprint = Get-DFCatalogScoopFingerprint -ScoopRoot $ScoopRoot $valid = -not $Force -and (Test-Path $indexFile) -and (Test-Path $keyFile) -and ((Get-Content $keyFile -Raw).Trim() -eq $fingerprint) if (-not $valid) { Update-DFCatalogScoopIndex -ScoopRoot $ScoopRoot } $cached = Read-DFCatalogCacheFile -Path $indexFile -Ttl ([timespan]::MaxValue) if ($cached) { return $cached } } # Caching disabled or unreadable — build in memory for this call only. @{ Data = @(Build-DFCatalogScoopIndexData -ScoopRoot $ScoopRoot) AgeMinutes = 0 Stale = $false } } function Search-DFCatalogScoop { <# .SYNOPSIS Searches the scoop index. Exact name matches report MatchKind exact-name; otherwise every query word must appear in the name or description. .PARAMETER Query Name or keywords. .PARAMETER ScoopRoot The scoop root directory (defaults to Get-DFCatalogScoopRoot). .PARAMETER Fresh Force an index rebuild before searching. #> [CmdletBinding()] param( [Parameter(Mandatory, Position = 0)] [string]$Query, [string]$ScoopRoot = (Get-DFCatalogScoopRoot), [switch]$Fresh ) $index = Get-DFCatalogScoopIndex -ScoopRoot $ScoopRoot -Force:$Fresh $normalized = (ConvertTo-DFCatalogQueryKey -Query $Query).Normalized $words = $normalized -split ' ' $cacheTimestamp = [datetime]::UtcNow.AddMinutes(-$index.AgeMinutes) foreach ($entry in $index.Data) { $matchKind = if ($entry.name -eq $normalized) { 'exact-name' } else { $haystack = "$($entry.name) $($entry.description)" $all = $true foreach ($word in $words) { if ($haystack -notlike "*$word*") { $all = $false; break } } if ($all) { 'keyword' } else { $null } } if (-not $matchKind) { continue } New-DFToolSourceInfo -Source 'scoop' ` -PackageId "$($entry.bucket)/$($entry.name)" ` -Name $entry.name ` -Description $entry.description ` -LatestVersion $entry.version ` -Homepage $entry.homepage ` -License $entry.license ` -MatchKind $matchKind ` -CacheTimestamp $cacheTimestamp ` -CacheAgeMinutes $index.AgeMinutes } } function Get-DFCatalogScoopInstalled { <# .SYNOPSIS Lists scoop-installed apps and versions by reading apps/<name>/current/manifest.json — no scoop.exe invocation. .PARAMETER ScoopRoot The scoop root directory (defaults to Get-DFCatalogScoopRoot). #> [CmdletBinding()] param( [string]$ScoopRoot = (Get-DFCatalogScoopRoot) ) $appsDir = Join-Path $ScoopRoot 'apps' if (-not (Test-Path $appsDir)) { return } foreach ($app in Get-ChildItem $appsDir -Directory) { $manifestFile = Join-Path $app.FullName 'current/manifest.json' if (-not (Test-Path $manifestFile)) { continue } $version = $null try { $version = [string](Get-Content $manifestFile -Raw | ConvertFrom-Json).version } catch {} [pscustomobject]@{ Source = 'scoop' Name = $app.Name PackageId = $app.Name InstalledVersion = $version } } } function Get-DFCatalogScoopDetail { <# .SYNOPSIS Deep detail for a scoop package: the full bucket manifest (notes, depends, suggest, bin) read straight from the local bucket clone. Local disk is free — no detail cache involved. .PARAMETER PackageId 'bucket/name' as reported by Search-DFCatalogScoop, or a bare name (all buckets searched). .PARAMETER ScoopRoot The scoop root directory (defaults to Get-DFCatalogScoopRoot). #> [CmdletBinding()] param( [Parameter(Mandatory, Position = 0)] [string]$PackageId, [string]$ScoopRoot = (Get-DFCatalogScoopRoot) ) $bucket, $name = $PackageId -split '/', 2 if (-not $name) { $name = $bucket; $bucket = $null } $manifestFile = $null if ($bucket) { $candidate = Join-Path $ScoopRoot "buckets/$bucket/bucket/$name.json" if (Test-Path $candidate) { $manifestFile = $candidate } } else { $bucketsDir = Join-Path $ScoopRoot 'buckets' if (Test-Path $bucketsDir) { foreach ($dir in Get-ChildItem $bucketsDir -Directory) { $candidate = Join-Path $dir.FullName "bucket/$name.json" if (Test-Path $candidate) { $manifestFile = $candidate; $bucket = $dir.Name; break } } } } if (-not $manifestFile) { return $null } try { $manifest = Get-Content $manifestFile -Raw | ConvertFrom-Json } catch { Write-Verbose "DotForge: unreadable scoop manifest '$manifestFile': $_" return $null } # scoop manifest fields are string-or-array; flatten uniformly. $flat = { param($v) if ($null -eq $v) { @() } else { @($v | ForEach-Object { [string]$_ }) } } # bin entries are string | [exe, alias, args...]; the shim name is the # alias when present, else the exe's basename. $shims = @(foreach ($entry in @($manifest.bin)) { if ($null -eq $entry) { continue } if ($entry -is [array]) { $entry.Count -ge 2 ? [string]$entry[1] : [System.IO.Path]::GetFileNameWithoutExtension([string]$entry[0]) } else { [string]$entry } }) $suggest = @() if ($manifest.suggest) { $suggest = @($manifest.suggest.PSObject.Properties | ForEach-Object { "$($_.Name): $(@($_.Value) -join ', ')" }) } $extra = [ordered]@{} if ($shims) { $extra['bin'] = $shims } if ($suggest) { $extra['suggest'] = $suggest } New-DFToolSourceDetail -Source 'scoop' ` -PackageId "$bucket/$name" ` -Dependencies (& $flat $manifest.depends) ` -RepositoryUrl ((([string]$manifest.homepage) -match 'github\.com') ? [string]$manifest.homepage : '') ` -InstallHint "scoop install $bucket/$name" ` -Notes ((& $flat $manifest.notes) -join ' ') ` -Extra $extra } if (-not (Get-Variable -Name DFCatalogProviders -Scope Script -ErrorAction Ignore)) { $script:DFCatalogProviders = @{} } $script:DFCatalogProviders['scoop'] = @{ Name = 'scoop' Kind = 'snapshot' Test = { [bool](Get-Command scoop -ErrorAction Ignore) -or (Test-Path (Join-Path (Get-DFCatalogScoopRoot) 'buckets')) } Search = { param($Query, $Fresh) Search-DFCatalogScoop -Query $Query -Fresh:$Fresh } GetInstalled = { Get-DFCatalogScoopInstalled } Refresh = { param($Query) Update-DFCatalogScoopIndex } Detail = { param($PackageId, $Fresh) Get-DFCatalogScoopDetail -PackageId $PackageId } } |