Eigenverft.Manifested.Drydock.Dotnet.ps1

function Enable-TempDotnetTools {
<#
.SYNOPSIS
Install local-tools from a manifest into an ephemeral cache and expose them for THIS session.
 
.DESCRIPTION
- Reads a standard dotnet local tools manifest (dotnet-tools.json) with exact versions.
- Ensures each tool exists in a --tool-path cache (sticky or fresh).
- Puts that folder at the front of PATH for the current session only.
- Returns a single object: @{ ToolPath = "..."; Tools = [ @{Id,Version,Status[,Command]}, ... ] }.
 
.PARAMETER ManifestFile
Path to the dotnet local tools manifest (dotnet-tools.json).
 
.PARAMETER ToolPath
Optional explicit --tool-path. If omitted, a stable cache path is derived from the manifest hash.
 
.PARAMETER Fresh
If set, uses a brand-new GUID cache folder (cold start each time).
 
.PARAMETER NoCache
If set, passes --no-cache to dotnet (disables NuGet HTTP cache; slower).
 
.PARAMETER NoReturn
If set, the function does not return the object to the pipeline (console stays clean).
#>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$ManifestFile,
        [string]$ToolPath,
        [switch]$Fresh,
        [switch]$NoCache,
        [switch]$NoReturn
    )

    # -----------------------
    # Local helper functions
    # -----------------------

    function _GetToolsInPath {
        param([Parameter(Mandatory)][string]$Path)

        # Prefer --detail for stable parsing; fall back to table format but skip header.
        $map = @{}

        $detail = & dotnet tool list --tool-path $Path --detail 2>$null
        if ($LASTEXITCODE -eq 0 -and $detail -and ($detail -match 'Package Id\s*:')) {
            $block = @()
            foreach ($line in ($detail -split "`r?`n")) {
                if ([string]::IsNullOrWhiteSpace($line)) {
                    if ($block.Count) {
                        $id=$null; $ver=$null
                        foreach ($l in $block) {
                            if ($l -match '^\s*Package Id\s*:\s*(.+)$') { $id = $matches[1].Trim() }
                            elseif ($l -match '^\s*Version\s*:\s*(.+)$')   { $ver = $matches[1].Trim() }
                        }
                        if ($id) { $map[$id] = $ver }
                        $block = @()
                    }
                } else { $block += $line }
            }
            if ($block.Count) {
                $id=$null; $ver=$null
                foreach ($l in $block) {
                    if     ($l -match '^\s*Package Id\s*:\s*(.+)$') { $id = $matches[1].Trim() }
                    elseif ($l -match '^\s*Version\s*:\s*(.+)$')     { $ver = $matches[1].Trim() }
                }
                if ($id) { $map[$id] = $ver }
            }
            return $map
        }

        $table = & dotnet tool list --tool-path $Path 2>$null
        if ($LASTEXITCODE -ne 0 -or -not $table) { return @{} }
        foreach ($l in ($table -split "`r?`n")) {
            if ($l -match '^\s*(\S+)\s+(\S+)\s+') {
                $id = $matches[1]; $ver = $matches[2]
                if ($id -eq 'Package' -and $ver -eq 'Id') { continue } # skip header
                $map[$id] = $ver
            }
        }
        return $map
    }

    function _GetToolCommandsInPath {
        param([Parameter(Mandatory)][string]$Path)
        # Parse commands from --detail; returns @{ <id> = @('cmd1','cmd2') }
        $cmds = @{}
        $detail = & dotnet tool list --tool-path $Path --detail 2>$null
        if ($LASTEXITCODE -ne 0 -or -not $detail) { return $cmds }

        $block = @()
        foreach ($line in ($detail -split "`r?`n")) {
            if ([string]::IsNullOrWhiteSpace($line)) {
                if ($block.Count) {
                    $id=$null; $names=$null
                    foreach ($l in $block) {
                        if ($l -match '^\s*Package Id\s*:\s*(.+)$')        { $id    = $matches[1].Trim() }
                        elseif ($l -match '^\s*Tool command name\s*:\s*(.+)$') { $names = $matches[1].Trim() }
                    }
                    if ($id -and $names) {
                        $arr = @()
                        foreach ($n in ($names -split ',')) { $arr += $n.Trim() }
                        $cmds[$id] = $arr
                    }
                    $block = @()
                }
            } else { $block += $line }
        }
        if ($block.Count) {
            $id=$null; $names=$null
            foreach ($l in $block) {
                if     ($l -match '^\s*Package Id\s*:\s*(.+)$')           { $id    = $matches[1].Trim() }
                elseif ($l -match '^\s*Tool command name\s*:\s*(.+)$')    { $names = $matches[1].Trim() }
            }
            if ($id -and $names) {
                $arr = @()
                foreach ($n in ($names -split ',')) { $arr += $n.Trim() }
                $cmds[$id] = $arr
            }
        }
        return $cmds
    }

    function _EnsureExactTool {
        param(
            [Parameter(Mandatory)][string]$Path,
            [Parameter(Mandatory)][string]$Id,
            [Parameter(Mandatory)][string]$Version,
            [switch]$NoCache,
            [switch]$TryUpdateFirst
        )
        # Order: update -> install (when present) or install -> update (when missing)
        if ($TryUpdateFirst) {
            $cliArgs = @("tool","update","--tool-path",$Path,"--version",$Version,$Id)
            if ($NoCache) { $cliArgs += "--no-cache" }
            & dotnet @cliArgs 2>$null
            if ($LASTEXITCODE -eq 0) { return $true }
            $cliArgs = @("tool","install","--tool-path",$Path,"--version",$Version,$Id)
            if ($NoCache) { $cliArgs += "--no-cache" }
            & dotnet @cliArgs
            return ($LASTEXITCODE -eq 0)
        }
        else {
            $cliArgs = @("tool","install","--tool-path",$Path,"--version",$Version,$Id)
            if ($NoCache) { $cliArgs += "--no-cache" }
            & dotnet @cliArgs 2>$null
            if ($LASTEXITCODE -eq 0) { return $true }
            $cliArgs = @("tool","update","--tool-path",$Path,"--version",$Version,$Id)
            if ($NoCache) { $cliArgs += "--no-cache" }
            & dotnet @cliArgs
            return ($LASTEXITCODE -eq 0)
        }
    }

    function _PrependPathIfMissing {
        param([Parameter(Mandatory)][string]$Path)
        $sep = [IO.Path]::PathSeparator
        $parts = $env:PATH -split [regex]::Escape($sep)
        foreach ($p in $parts) { if ($p -eq $Path) { return } }
        $env:PATH = ($Path + $sep + $env:PATH)
    }

    # -----------------------
    # 1) Resolve inputs
    # -----------------------

    $mf = Resolve-Path -LiteralPath $ManifestFile -ErrorAction Stop
    Write-Host ("[dotnet-tools] Manifest: {0}" -f $mf.Path) -ForegroundColor DarkGray

    $manifest = Get-Content -Raw -LiteralPath $mf | ConvertFrom-Json
    if (-not $manifest.tools) { throw "Manifest has no 'tools' entries: $mf" }

    # Derive tool-path: fresh GUID or sticky cache (hash of manifest) or explicit path
    if ($Fresh) {
        $ToolPath = Join-Path $env:TEMP ("dotnet-tools\" + [guid]::NewGuid().ToString("n"))
    }
    elseif (-not $ToolPath) {
        $hash = (Get-FileHash -LiteralPath $mf -Algorithm SHA256).Hash.Substring(0,16)
        $base = [Environment]::GetFolderPath('LocalApplicationData')
        if (-not $base) { $base = $env:TEMP }
        $ToolPath = Join-Path $base ("dotnet-tools-cache\" + $hash)
    }
    New-Item -ItemType Directory -Force -Path $ToolPath | Out-Null
    Write-Host ("[dotnet-tools] Cache (toolpath): {0}" -f $ToolPath) -ForegroundColor DarkGray

    # -----------------------
    # 2) Snapshot BEFORE
    # -----------------------
    $before = _GetToolsInPath -Path $ToolPath
    if ($before.Count -gt 0) {
        Write-Host ("[dotnet-tools] Cache existing: {0}" -f $before.Count) -ForegroundColor DarkGray
        foreach ($k in ($before.Keys | Sort-Object)) {
            Write-Host (" - {0} {1}" -f $k, $before[$k]) -ForegroundColor DarkGray
        }
    } else {
        Write-Host "[dotnet-tools] Cache existing: none" -ForegroundColor DarkGray
    }

    # -----------------------
    # 3) Ensure each tool (sorted for readability)
    # -----------------------
    $toolsResult = @()
    $toolProps = $manifest.tools.PSObject.Properties | Sort-Object Name
    foreach ($prop in $toolProps) {
        $id = [string]$prop.Name
        $ver = [string]$prop.Value.version
        if (-not $ver) { throw "Tool '$id' in manifest lacks a 'version'." }

        $present   = $before.ContainsKey($id)
        $unchanged = $present -and ($before[$id] -eq $ver)

        $status = "AlreadyPresent"
        if (-not $unchanged) {
            Write-Host ("[dotnet-tools] Ensuring: {0}@{1}" -f $id, $ver) -ForegroundColor DarkGray
            $ok = _EnsureExactTool -Path $ToolPath -Id $id -Version $ver -NoCache:$NoCache -TryUpdateFirst:$present
            if (-not $ok) { throw "Failed to ensure $id@$ver in $ToolPath." }
            $status = $present ? "Updated" : "Installed"
        }

        # Per-tool status line with color
        switch ($status) {
            "Installed" { $fc = "Green" }
            "Updated"   { $fc = "Yellow" }
            default     { $fc = "Cyan" } # AlreadyPresent
        }
        Write-Host ("[{0,-10}] {1}@{2}" -f $status, $id, $ver) -ForegroundColor $fc

        $toolsResult += [pscustomobject]@{ Id = $id; Version = $ver; Status = $status }
    }

    # -----------------------
    # 4) PATH (session only)
    # -----------------------
    _PrependPathIfMissing -Path $ToolPath
    Write-Host ("[dotnet-tools] PATH updated: {0}" -f $ToolPath) -ForegroundColor DarkGray


    # -----------------------
    # 5) Snapshot AFTER (normalize versions actually resolved by dotnet) + commands (from manifest)
    # -----------------------
    $after = _GetToolsInPath -Path $ToolPath

    # Build command map from the manifest (tools.<id>.commands)
    $cmdInfo = @{}
    $manifestToolsProps = $manifest.tools.PSObject.Properties | Sort-Object Name
    foreach ($p in $manifestToolsProps) {
        $id = [string]$p.Name
        $cmds = @()
        if ($p.Value.PSObject.Properties.Name -contains 'commands') {
            foreach ($n in @($p.Value.commands)) {
                if ($n) { $cmds += [string]$n }
            }
        }
        if ($cmds.Count -gt 0) { $cmdInfo[$id] = $cmds }
    }

    for ($i = 0; $i -lt $toolsResult.Count; $i++) {
        $rid = $toolsResult[$i].Id
        if ($after.ContainsKey($rid)) {
            if ($toolsResult[$i].Version -ne $after[$rid]) {
                Write-Host ("[dotnet-tools] Resolved: {0} -> {1}" -f $toolsResult[$i].Version, $after[$rid]) -ForegroundColor DarkGray
            }
            $toolsResult[$i].Version = $after[$rid]
        }

        if ($cmdInfo.ContainsKey($rid)) {
            $cmdsText = ($cmdInfo[$rid] -join ", ")
            # Attach Command property always, so the printout is consistent.
            Add-Member -InputObject $toolsResult[$i] -NotePropertyName Command -NotePropertyValue $cmdsText -Force
        }
    }

    # -----------------------
    # Pretty print manifest-sourced command names (ASCII only, PS5-safe)
    # -----------------------
    $rows = @()
    foreach ($p in $manifestToolsProps) {
        $id = [string]$p.Name
        $hasCmd = $false
        $joined = "(none)"
        if ($cmdInfo.ContainsKey($id) -and $cmdInfo[$id].Count -gt 0) {
            $joined = ($cmdInfo[$id] -join ", ")
            $hasCmd = $true
        }
        $rows += New-Object psobject -Property @{ Id = $id; Cmds = $joined; Has = $hasCmd }
    }

    # Determine column width for PACKAGE column (min width 8)
    $idWidth = 8
    foreach ($r in $rows) { if ($r.Id.Length -gt $idWidth) { $idWidth = $r.Id.Length } }

    $headerLeft  = "PACKAGE".PadRight($idWidth)
    $headerRight = "TOOLCOMMANDNAMES"
    $sepLeft  = ("-" * $idWidth)
    $sepRight = ("-" * $headerRight.Length)

    Write-Host ("[dotnet-tools] Commands (manifest): {0} tool(s)" -f $rows.Count) -ForegroundColor Cyan
    Write-Host (" {0} {1}" -f $headerLeft, $headerRight) -ForegroundColor DarkGray
    Write-Host (" {0} {1}" -f $sepLeft,     $sepRight)   -ForegroundColor DarkGray

    foreach ($r in $rows) {
        # ASCII status symbol and simple colors (no Unicode)
        $symbol   = "-"
        $symColor = "DarkGray"
        if ($r.Has) { $symbol = "+"; $symColor = "Green" }

        $cmdColor = "DarkGray"
        if ($r.Has) { $cmdColor = "White" }

        Write-Host " " -NoNewline
        Write-Host $symbol -ForegroundColor $symColor -NoNewline
        Write-Host (" {0} " -f $r.Id.PadRight($idWidth)) -ForegroundColor Gray -NoNewline
        Write-Host " ... " -ForegroundColor DarkGray -NoNewline
        Write-Host $r.Cmds -ForegroundColor $cmdColor
    }


    # -----------------------
    # 6) Return single object (unless -NoReturn)
    # -----------------------
    if (-not $NoReturn) {
        return [pscustomobject]@{
            ToolPath = $ToolPath
            Tools    = $toolsResult
        }
    }
}

function Disable-TempDotnetTools {
<#
.SYNOPSIS
Remove the ephemeral tool cache from PATH and optionally delete it.
 
.DESCRIPTION
- Accepts either a direct --tool-path or a manifest file.
- When given -ManifestFile, computes the same sticky cache path used by Enable-TempDotnetTools:
  %LOCALAPPDATA%\dotnet-tools-cache\<SHA256(manifest CONTENT) first 16 chars>
- Removes that folder from the current session PATH.
- Optionally deletes the folder (cold start next time).
 
.PARAMETER ToolPath
The folder previously used with --tool-path.
 
.PARAMETER ManifestFile
Path to dotnet-tools.json; used to derive the sticky cache path.
 
.PARAMETER Delete
Also delete the cache folder on disk.
 
.EXAMPLE
Disable-TempDotnetTools -ManifestFile "$PSScriptRoot\.config\dotnet-tools.json" -Delete
#>

    [CmdletBinding(SupportsShouldProcess=$true, DefaultParameterSetName='ByToolPath')]
    param(
        [Parameter(Mandatory=$true, ParameterSetName='ByToolPath')]
        [string]$ToolPath,

        [Parameter(Mandatory=$true, ParameterSetName='ByManifestFile')]
        [string]$ManifestFile,

        [switch]$Delete
    )

    # Resolve tool path from manifest if requested (uses CONTENT hash to match Enable-TempDotnetTools)
    if ($PSCmdlet.ParameterSetName -eq 'ByManifestFile') {
        $mf = Resolve-Path -LiteralPath $ManifestFile -ErrorAction Stop
        $hash = (Get-FileHash -LiteralPath $mf -Algorithm SHA256).Hash.Substring(0,16)
        $base = [Environment]::GetFolderPath('LocalApplicationData')
        if (-not $base) { $base = $env:TEMP }
        $ToolPath = Join-Path $base ("dotnet-tools-cache\" + $hash)
    }

    # Remove from PATH (session only)
    $sep   = [IO.Path]::PathSeparator
    $parts = $env:PATH -split [regex]::Escape($sep)
    $env:PATH = ($parts | Where-Object { $_ -and ($_ -ne $ToolPath) }) -join $sep

    # Optionally delete on disk
    if ($Delete -and (Test-Path -LiteralPath $ToolPath)) {
        if ($PSCmdlet.ShouldProcess($ToolPath, "Remove-Item -Recurse -Force")) {
            Remove-Item -LiteralPath $ToolPath -Recurse -Force
        }
    }
}


# One-liner: sticky cache derived from manifest → fast subsequent runs
#$rep = Enable-TempDotnetTools -ManifestFile "C:\dev\github.com\eigenverft\Eigenverft.Manifested.Drydock\.github\workflows\.config\dotnet-tools\dotnet-tools.json" -NoReturn # <-- reuse the same temp cache per manifest
#$rep.Tools | Format-Table

# Use your tools anywhere in this session...
# e.g., dotnet-ef / dotnet ef / docfx, etc.
#docfx --help
# End of session: remove from PATH and (optionally) delete the cache
#Disable-TempDotnetTools -ManifestFile "C:\dev\github.com\eigenverft\Eigenverft.Manifested.Drydock\.github\workflows\.config\dotnet-tools\dotnet-tools.json" # keep cache (fast next time)