Private/DFCatalog.Npm.ps1

#Requires -Version 7.0

# npm registry catalog provider (query-cache kind).

function Invoke-DFCatalogNpmFetch {
    <#
    .SYNOPSIS
        Live npm registry lookup: exact package doc first (single-word queries),
        then keyword search; results deduped by name.
    .PARAMETER Query
        Normalized query text.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$Query
    )

    $results = [System.Collections.Generic.List[object]]::new()

    if ($Query -notmatch ' ') {
        try {
            $doc = Invoke-RestMethod -Uri "https://registry.npmjs.org/$([uri]::EscapeDataString($Query))" -TimeoutSec 10
            $version = [string]$doc.'dist-tags'.latest
            $published = $null
            if ($version -and $doc.time.$version) { try { $published = [datetime]$doc.time.$version } catch {} }
            $license = if ($doc.license -is [string]) { $doc.license } else { [string]$doc.license.type }

            $results.Add((New-DFToolSourceInfo -Source 'npm' `
                -PackageId $doc.name -Name $doc.name `
                -Description ([string]$doc.description) `
                -LatestVersion $version `
                -Homepage ([string]$doc.homepage) `
                -License $license `
                -PublishedAt $published `
                -MatchKind 'exact-name'))
        } catch {
            Write-Verbose "DotForge: npm exact lookup for '$Query' failed (probably not a package): $_"
        }
    }

    # Search failure propagates to the engine so it can fall back to cache.
    $response = Invoke-RestMethod -Uri "https://registry.npmjs.org/-/v1/search?text=$([uri]::EscapeDataString($Query))&size=15" -TimeoutSec 10
    foreach ($object in @($response.objects)) {
        $package = $object.package
        if (-not $package.name) { continue }
        if ($results | Where-Object Name -eq $package.name) { continue }

        $published = $null
        if ($package.date) { try { $published = [datetime]$package.date } catch {} }

        $results.Add((New-DFToolSourceInfo -Source 'npm' `
            -PackageId $package.name -Name $package.name `
            -Description ([string]$package.description) `
            -LatestVersion ([string]$package.version) `
            -Homepage ([string]$package.links.homepage) `
            -PublishedAt $published `
            -MatchKind ($package.name -eq $Query ? 'exact-name' : 'keyword')))
    }

    $results
}

function Search-DFCatalogNpm {
    <#
    .SYNOPSIS
        Cache-first npm registry search.
    .PARAMETER Query
        Name or keywords.
    .PARAMETER Fresh
        Block on a live fetch.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$Query,

        [switch]$Fresh
    )

    Search-DFCatalogQueryCache -Provider 'npm' -Query $Query -Fresh:$Fresh `
        -Fetch { param($q) Invoke-DFCatalogNpmFetch -Query $q }
}

function Get-DFCatalogNpmInstalled {
    <#
    .SYNOPSIS
        Lists globally installed npm packages by enumerating the global
        node_modules directory — avoids the slow `npm ls -g` spawn.
    .PARAMETER NpmPrefix
        The npm global prefix (defaults to %APPDATA%\npm on Windows).
    #>

    [CmdletBinding()]
    param(
        [string]$NpmPrefix = ($Env:APPDATA ? (Join-Path $Env:APPDATA 'npm') : $null)
    )

    if (-not $NpmPrefix) { return }
    $modules = Join-Path $NpmPrefix 'node_modules'
    if (-not (Test-Path $modules)) { return }

    $readPackage = {
        param($Dir, $Name)
        $packageJson = Join-Path $Dir 'package.json'
        if (-not (Test-Path $packageJson)) { return }
        $version = $null
        try { $version = [string](Get-Content $packageJson -Raw | ConvertFrom-Json).version } catch {}
        [pscustomobject]@{
            Source           = 'npm'
            Name             = $Name
            PackageId        = $Name
            InstalledVersion = $version
        }
    }

    foreach ($dir in Get-ChildItem $modules -Directory | Where-Object Name -notlike '.*') {
        if ($dir.Name -like '@*') {
            foreach ($scoped in Get-ChildItem $dir.FullName -Directory) {
                & $readPackage $scoped.FullName "$($dir.Name)/$($scoped.Name)"
            }
        } else {
            & $readPackage $dir.FullName $dir.Name
        }
    }
}

function Invoke-DFCatalogNpmDetailFetch {
    <#
    .SYNOPSIS
        Live npm registry full-doc lookup, mapped to DotForge.ToolSourceDetail.
    .PARAMETER PackageId
        The npm package name.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$PackageId
    )

    $doc = Invoke-RestMethod -Uri "https://registry.npmjs.org/$([uri]::EscapeDataString($PackageId))" -TimeoutSec 10
    if (-not $doc.name) { return $null }

    $latest = [string]$doc.'dist-tags'.latest
    $deps = @()
    if ($latest -and $doc.versions.$latest.dependencies) {
        $deps = @($doc.versions.$latest.dependencies.PSObject.Properties | ForEach-Object { "$($_.Name)@$($_.Value)" })
    }
    $distTags = @()
    if ($doc.'dist-tags') {
        $distTags = @($doc.'dist-tags'.PSObject.Properties | ForEach-Object { "$($_.Name): $($_.Value)" })
    }

    $extra = [ordered]@{}
    if ($distTags) { $extra['dist-tags'] = $distTags }

    New-DFToolSourceDetail -Source 'npm' `
        -PackageId $doc.name `
        -Maintainers @(@($doc.maintainers) | ForEach-Object { [string]$_.name } | Where-Object { $_ }) `
        -Dependencies $deps `
        -Tags @(@($doc.keywords) | ForEach-Object { [string]$_ } | Where-Object { $_ }) `
        -RepositoryUrl ([string]$doc.repository.url) `
        -InstallHint "npm install -g $($doc.name)" `
        -Readme ([string]$doc.readme) `
        -Extra $extra
}

function Get-DFCatalogNpmDetail {
    <#
    .SYNOPSIS
        Cache-first npm detail lookup.
    .PARAMETER PackageId
        The npm package name.
    .PARAMETER Fresh
        Force a live fetch.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$PackageId,

        [switch]$Fresh
    )

    Get-DFCatalogDetailCache -Provider 'npm' -PackageId $PackageId -Fresh:$Fresh `
        -Fetch { param($id) Invoke-DFCatalogNpmDetailFetch -PackageId $id }
}

if (-not (Get-Variable -Name DFCatalogProviders -Scope Script -ErrorAction Ignore)) { $script:DFCatalogProviders = @{} }
$script:DFCatalogProviders['npm'] = @{
    Name         = 'npm'
    Kind         = 'query-cache'
    Test         = { $true }
    Search       = { param($Query, $Fresh) Search-DFCatalogNpm -Query $Query -Fresh:$Fresh }
    GetInstalled = { Get-DFCatalogNpmInstalled }
    Refresh      = { param($Query) if ($Query) { $null = Search-DFCatalogNpm -Query $Query -Fresh } }
    Detail       = { param($PackageId, $Fresh) Get-DFCatalogNpmDetail -PackageId $PackageId -Fresh:$Fresh }
}