modules/shared/Installer.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Manifest-driven prerequisite installer for azure-analyzer.
.DESCRIPTION
    Reads the `install` section of each tool in tools/tool-manifest.json and
    auto-installs missing PowerShell modules, CLI binaries, and git-cloned
    tools (e.g. AzGovViz).
 
    Install kinds supported:
      - psmodule : Install-Module (PowerShell Gallery).
      - cli : winget (Windows), brew (macOS), pipx/pip (any), or a
                   URL hint shown to the user.
      - gitclone : git clone --depth 1 into a target directory under the
                   repo root.
      - none : nothing to install (e.g. ado-connections uses REST only).
 
    Security hardening:
      - Package names are validated against a conservative regex before
        being handed to a package manager. Anything with shell metachars
        is refused.
      - Git clone URLs must be HTTPS and from an allow-listed host
        (github.com by default).
      - Every install call runs with a timeout (default 300s) so a hung
        mirror can never block the orchestrator indefinitely.
      - Stdout and stderr from package managers are scrubbed via
        Remove-Credentials before being surfaced to the user.
      - Install kinds outside {none, psmodule, cli, gitclone} are refused.
 
    Behaviour:
      - Only installs tools the user has NOT excluded via -ExcludeTools and
        has enabled via -IncludeTools (if specified).
      - Always idempotent: skips any tool whose probe command / module /
        file already resolves.
      - Never throws -- emits warnings and returns the number of remaining
        unmet prerequisites so the caller can decide how to proceed.
#>

[CmdletBinding()]
param ()

Set-StrictMode -Version Latest

# ---------------------------------------------------------------------------
# Security policy
# ---------------------------------------------------------------------------
$script:AllowedInstallKinds   = @('none', 'psmodule', 'cli', 'gitclone')
$script:AllowedPackageManagers = @('winget', 'brew', 'pipx', 'pip', 'snap')
$script:AllowedGitHosts       = @('github.com')
# Package names: letters, digits, dots, dashes, underscores, slashes, at-signs.
# Covers winget IDs (Publisher.Package), brew taps (org/tap/pkg), pipx, pip.
$script:PackageNamePattern    = '^[A-Za-z0-9][A-Za-z0-9._\-/@]{0,127}$'
$script:DefaultInstallTimeoutSec = 300
# Install manifest for version pinning + SHA-256 verification
$script:InstallManifestPath = (Join-Path (Split-Path $PSScriptRoot -Parent) 'tools' 'install-manifest.json')
# Default path for optional install configuration
$script:DefaultInstallConfigPath = (Join-Path (Split-Path $PSScriptRoot -Parent) 'tools' 'install-config.json')

# Lightweight credential scrubber used when Remove-Credentials isn't in scope.
if (-not (Get-Command -Name Remove-Credentials -ErrorAction SilentlyContinue)) {
    function Remove-Credentials {
        param ([string] $Text)
        if ([string]::IsNullOrEmpty($Text)) { return $Text }
        # Strip common token patterns: GitHub, bearer, basic, Azure keys.
        $scrubbed = $Text
        $scrubbed = $scrubbed -replace '(gh[pousr]_[A-Za-z0-9]{36,255})', '[redacted-gh-token]'
        $scrubbed = $scrubbed -replace '(?i)(authorization[:= ]+bearer\s+)[A-Za-z0-9\._\-]+', '$1[redacted]'
        $scrubbed = $scrubbed -replace '(?i)(password[:= ]+)[^\s,;]+', '$1[redacted]'
        return $scrubbed
    }
}

function Get-CurrentOS {
    if ($IsWindows -or ($PSVersionTable.Platform -eq 'Win32NT')) { return 'windows' }
    if ($IsMacOS) { return 'macos' }
    return 'linux'
}

function Test-CliAvailable {
    param ([Parameter(Mandatory)][string] $Command)
    return [bool](Get-Command $Command -ErrorAction SilentlyContinue)
}

function Test-PSModuleAvailable {
    param ([Parameter(Mandatory)][string] $Name)
    return [bool](Get-Module -ListAvailable -Name $Name -ErrorAction SilentlyContinue)
}

function New-InstallerError {
    <#
    .SYNOPSIS
        Build a rich, sanitized error object describing an install failure.
    #>

    param (
        [Parameter(Mandatory)][string] $Tool,
        [Parameter(Mandatory)][ValidateSet('psmodule','cli','gitclone','none')][string] $Kind,
        [Parameter(Mandatory)][string] $Reason,
        [string] $Package,
        [string] $Url,
        [string] $Remediation,
        [string] $Output,
        [string] $Category = 'InstallFailed'
    )
    return [PSCustomObject]@{
        Tool         = $Tool
        Kind         = $Kind
        OS           = Get-CurrentOS
        Package      = $Package
        Url          = $Url
        Category     = $Category
        Reason       = $Reason
        Remediation  = $Remediation
        Output       = Remove-Credentials ([string]$Output)
        TimestampUtc = (Get-Date).ToUniversalTime().ToString('o')
    }
}

function Write-InstallerError {
    param ([Parameter(Mandatory)] $Err)
    $line = "[installer] {0} ({1}/{2}): {3}" -f $Err.Tool, $Err.Kind, $Err.Category, $Err.Reason
    if ($Err.Remediation) { $line += " Action: $($Err.Remediation)" }
    Write-Warning $line
    if ($Err.Output) { Write-Verbose $Err.Output }
}

function Invoke-WithInstallRetry {
    <#
    .SYNOPSIS
        Retry a transient install-related scriptblock with jittered
        exponential backoff. Permanent failures (auth, not-found,
        conflict) are surfaced immediately without retry.
    #>

    param (
        [Parameter(Mandatory)][scriptblock] $ScriptBlock,
        [int] $MaxRetries = 2,
        [int] $BaseDelaySec = 2,
        [int] $MaxDelaySec = 20,
        [string[]] $TransientMarkers = @(
            'timed out', 'timeout', 'temporary failure', 'connection reset',
            'could not resolve host', 'network is unreachable',
            'service unavailable', '503', '429', 'rate limit',
            'read timed out', 'tls handshake'
        )
    )
    for ($i = 0; $i -le $MaxRetries; $i++) {
        $result = & $ScriptBlock
        if ($null -eq $result) { return $null }
        if ($result.ExitCode -eq 0) { return $result }
        $lower = ([string]$result.Output).ToLowerInvariant()
        $transient = $false
        foreach ($m in $TransientMarkers) { if ($lower -like "*$m*") { $transient = $true; break } }
        if (-not $transient -or $i -eq $MaxRetries) {
            $result | Add-Member -NotePropertyName Attempts  -NotePropertyValue ($i + 1)      -Force
            $result | Add-Member -NotePropertyName Exhausted -NotePropertyValue ($i -eq $MaxRetries -and $transient) -Force
            return $result
        }
        $delay = Get-JitteredDelay -RetryIndex $i -BaseDelaySec $BaseDelaySec -MaxDelaySec $MaxDelaySec
        Write-Verbose "[installer] transient failure (exit=$($result.ExitCode)); retrying in ${delay}s (attempt $($i + 2)/$($MaxRetries + 1))"
        if ($delay -gt 0) { Start-Sleep -Seconds $delay }
    }
}

function Test-SafePackageName {
    <#
    .SYNOPSIS
        Guard against shell injection via manifest-supplied package names.
    #>

    param ([Parameter(Mandatory)][string] $Name)
    return ($Name -match $script:PackageNamePattern)
}

function Get-FileHash256 {
    <#
    .SYNOPSIS
        Compute SHA-256 hash of a file and return lowercase hex string.
    #>

    param ([Parameter(Mandatory)][string] $Path)
    if (-not (Test-Path $Path)) {
        $err = New-InstallerError -Tool 'Get-FileHash256' -Kind 'none' `
            -Category 'NotFound' `
            -Reason "File not found for hash computation: $Path" `
            -Remediation 'Verify the file path; if downloaded, re-run the install step that produced it.'
        Write-InstallerError -Err $err
        throw "[installer] $($err.Tool) ($($err.Kind)/$($err.Category)): $($err.Reason)"
    }
    $hash = (Get-FileHash -Path $Path -Algorithm SHA256).Hash
    return $hash.ToLowerInvariant()
}

function Test-InstallManifestHash {
    <#
    .SYNOPSIS
        Verify a downloaded file's SHA-256 against install-manifest.json.
        Returns $true if hash matches, $false if mismatch or no hash in manifest.
    #>

    param (
        [Parameter(Mandatory)][string] $FilePath,
        [Parameter(Mandatory)][string] $ToolName,
        [string] $Platform = (Get-CurrentOS)
    )
    
    if (-not (Test-Path $script:InstallManifestPath)) {
        Write-Verbose "[hash] install-manifest.json not found; skipping verification for $ToolName"
        return $true
    }
    
    try {
        $manifest = Get-Content $script:InstallManifestPath -Raw | ConvertFrom-Json
        $tool = $manifest.tools | Where-Object { $_.name -eq $ToolName } | Select-Object -First 1
        
        if (-not $tool) {
            Write-Verbose "[hash] $ToolName not found in install manifest; skipping hash check"
            return $true
        }
        
        if (-not $tool.platforms -or -not $tool.platforms.PSObject.Properties[$Platform]) {
            Write-Verbose "[hash] No $Platform entry for $ToolName; skipping hash check"
            return $true
        }
        
        $platformData = $tool.platforms.$Platform
        
        # Check if sha256 property exists
        if (-not $platformData.PSObject.Properties['sha256']) {
            Write-Verbose "[hash] No sha256 property for $ToolName on $Platform; delegating to package manager"
            return $true
        }
        
        if (-not $platformData.sha256 -or $platformData.sha256 -like '*PLACEHOLDER*') {
            Write-Verbose "[hash] No SHA-256 pin for $ToolName on $Platform; delegating to package manager"
            return $true
        }
        
        $expectedHash = $platformData.sha256.ToLowerInvariant()
        $actualHash = Get-FileHash256 -Path $FilePath
        
        if ($actualHash -eq $expectedHash) {
            Write-Verbose "[hash] ✓ $ToolName SHA-256 verified: $actualHash"
            return $true
        } else {
            Write-Warning "[hash] ✗ $ToolName SHA-256 MISMATCH!"
            Write-Warning " Expected: $expectedHash"
            Write-Warning " Actual: $actualHash"
            Write-Warning " File: $FilePath"
            return $false
        }
    } catch {
        Write-Warning "[hash] Could not verify $ToolName hash: $($_.Exception.Message)"
        return $false
    }
}

function Test-SafeGitUrl {
    <#
    .SYNOPSIS
        Enforce HTTPS + allow-listed host for auto-cloned tools.
    #>

    param ([Parameter(Mandatory)][string] $Url)
    if ($Url -notmatch '^https://') { return $false }
    $hostPart = ($Url -replace '^https://', '').Split('/')[0].ToLowerInvariant()
    return ($script:AllowedGitHosts -contains $hostPart)
}

function Invoke-WithTimeout {
    <#
    .SYNOPSIS
        Run an external command with stdout+stderr capture and a hard
        timeout. Returns [PSCustomObject]@{ ExitCode; Output }.
    #>

    param (
        [Parameter(Mandatory)][string]   $Command,
        [Parameter(Mandatory)][string[]] $Arguments,
        [int] $TimeoutSec = $script:DefaultInstallTimeoutSec
    )

    $psi = [System.Diagnostics.ProcessStartInfo]::new()
    $psi.FileName = $Command
    foreach ($a in $Arguments) { $psi.ArgumentList.Add($a) }
    $psi.RedirectStandardOutput = $true
    $psi.RedirectStandardError  = $true
    $psi.UseShellExecute = $false

    $proc = [System.Diagnostics.Process]::new()
    $proc.StartInfo = $psi
    $null = $proc.Start()

    # Drain streams asynchronously so a full pipe buffer can't deadlock us.
    $stdoutTask = $proc.StandardOutput.ReadToEndAsync()
    $stderrTask = $proc.StandardError.ReadToEndAsync()

    if (-not $proc.WaitForExit($TimeoutSec * 1000)) {
        try { $proc.Kill($true) } catch {
            Write-Verbose ("Invoke-WithTimeout: Process.Kill after timeout failed (process likely already exited). Reason: {0}" -f $_.Exception.Message)
        }
        return [PSCustomObject]@{ ExitCode = -1; Output = "Timed out after $TimeoutSec seconds"; Stdout = ''; Stderr = "Timed out after $TimeoutSec seconds" }
    }

    $stdout = $stdoutTask.Result
    $stderr = $stderrTask.Result
    $combined = Remove-Credentials (($stdout + "`n" + $stderr).Trim())
    return [PSCustomObject]@{ ExitCode = $proc.ExitCode; Output = $combined; Stdout = Remove-Credentials $stdout; Stderr = Remove-Credentials $stderr }
}

function Install-PSModules {
    param ([string[]] $Names, [string] $ToolName)
    foreach ($mod in $Names) {
        if (-not (Test-SafePackageName -Name $mod)) {
            Write-Warning "Refusing to install PowerShell module with unsafe name '$mod' for $ToolName."
            continue
        }
        if (Test-PSModuleAvailable -Name $mod) {
            Write-Verbose " v $mod already installed"
            continue
        }
        Write-Host " Installing PowerShell module $mod ..." -ForegroundColor Yellow
        try {
            Install-Module $mod -Scope CurrentUser -Force -AllowClobber -AcceptLicense -ErrorAction Stop
            Write-Host " v $mod installed" -ForegroundColor Green
        } catch {
            Write-Warning (Remove-Credentials "Could not install $mod for ${ToolName}: $($_.Exception.Message). Run manually: Install-Module $mod -Scope CurrentUser")
        }
    }
}

function Invoke-PackageManager {
    <#
    .SYNOPSIS
        Runs a system package manager quietly and returns $true on success.
    #>

    param (
        [Parameter(Mandatory)][string] $Manager,
        [Parameter(Mandatory)][string] $Package
    )

    if ($script:AllowedPackageManagers -notcontains $Manager) {
        Write-Warning "Refusing to use disallowed package manager '$Manager'."
        return $false
    }
    if (-not (Test-SafePackageName -Name $Package)) {
        Write-Warning "Refusing to install package with unsafe name '$Package'."
        return $false
    }

    $mgrArgs = switch ($Manager) {
        'winget' { @('install', '--silent', '--accept-source-agreements', '--accept-package-agreements', '--id', $Package) }
        'brew'   { @('install', $Package) }
        'pipx'   { @('install', $Package) }
        'pip'    { @('install', '--user', $Package) }
        'snap'   { @('install', $Package) }
        default  { $null }
    }
    if (-not $mgrArgs) { return $false }
    if (-not (Test-CliAvailable -Command $Manager)) { return $false }

    $result = Invoke-WithTimeout -Command $Manager -Arguments $mgrArgs
    if ($result.ExitCode -eq 0) { return $true }
    Write-Verbose $result.Output
    return $false
}

function Install-CliTool {
    param (
        [Parameter(Mandatory)][PSCustomObject] $InstallSpec,
        [Parameter(Mandatory)][string] $ToolName,
        [string] $ManagerOverride
    )

    $cmd = $InstallSpec.command
    if (Test-CliAvailable -Command $cmd) {
        Write-Verbose " v $cmd already installed"
        return $true
    }

    $os = Get-CurrentOS
    $osBlock = if ($InstallSpec.PSObject.Properties[$os]) { $InstallSpec.$os } else { $null }
    if (-not $osBlock) {
        Write-Warning "No install recipe for $ToolName on $os. Install $cmd manually."
        return $false
    }

    # Try manager override first (from install config)
    if ($ManagerOverride) {
        if ($script:AllowedPackageManagers -notcontains $ManagerOverride) {
            Write-Warning "[install-config] Manager override '$ManagerOverride' for $ToolName is not in the allow-list. Falling back to manifest."
        } elseif ($osBlock.PSObject.Properties[$ManagerOverride]) {
            $pkg = [string]$osBlock.$ManagerOverride
            Write-Host " Installing $ToolName via $ManagerOverride (config override, $pkg) ..." -ForegroundColor Yellow
            if (Invoke-PackageManager -Manager $ManagerOverride -Package $pkg) {
                if (Test-CliAvailable -Command $cmd) {
                    Write-Host " v $ToolName installed (config override)" -ForegroundColor Green
                    return $true
                }
            }
            Write-Verbose "[install-config] Override manager $ManagerOverride failed for $ToolName; falling back to manifest."
        } else {
            Write-Verbose "[install-config] Override manager $ManagerOverride has no package for $ToolName on $os; falling back to manifest."
        }
    }

    # Use preferredManagers from manifest when present (ordered list);
    # fall back to global allow-list otherwise. Defense-in-depth: each
    # manager is re-checked against the allow-list before use.
    $mgrList = $script:AllowedPackageManagers
    if ($InstallSpec.PSObject.Properties['preferredManagers'] -and $InstallSpec.preferredManagers) {
        $mgrList = @($InstallSpec.preferredManagers)
    }

    foreach ($mgr in $mgrList) {
        # Defense-in-depth: reject any manager not in the global allow-list,
        # even if it somehow ended up in preferredManagers.
        if ($script:AllowedPackageManagers -notcontains $mgr) {
            Write-Warning "Skipping disallowed manager '$mgr' in preferredManagers for $ToolName."
            continue
        }
        if ($osBlock.PSObject.Properties[$mgr]) {
            $pkg = [string]$osBlock.$mgr
            Write-Host " Installing $ToolName via $mgr ($pkg) ..." -ForegroundColor Yellow
            if (Invoke-PackageManager -Manager $mgr -Package $pkg) {
                if (Test-CliAvailable -Command $cmd) {
                    Write-Host " v $ToolName installed" -ForegroundColor Green
                    return $true
                }
                Write-Warning "$mgr install succeeded but $cmd still not on PATH. Open a new shell and re-run."
                return $false
            }
        }
    }

    if ($osBlock.PSObject.Properties['url']) {
        Write-Warning "No package manager available for $ToolName on $os. Download from: $($osBlock.url)"
    } else {
        Write-Warning "$ToolName could not be installed automatically. Install it manually."
    }
    return $false
}

function Install-GitClone {
    param (
        [Parameter(Mandatory)][PSCustomObject] $InstallSpec,
        [Parameter(Mandatory)][string] $ToolName,
        [Parameter(Mandatory)][string] $RepoRoot
    )

    $repoUrl = [string]$InstallSpec.repo
    if (-not (Test-SafeGitUrl -Url $repoUrl)) {
        Write-Warning "Refusing to clone $ToolName from disallowed URL '$repoUrl'. Allowed hosts: $($script:AllowedGitHosts -join ', ')."
        return $false
    }

    $targetRel = $InstallSpec.target
    $target = Join-Path $RepoRoot $targetRel
    $probeFile = Join-Path $target $InstallSpec.probe

    if (Test-Path $probeFile) {
        Write-Verbose " v $ToolName already present at $targetRel"
        return $true
    }

    if (-not (Test-CliAvailable -Command 'git')) {
        Write-Warning "git not found -- cannot bootstrap $ToolName. Install git or clone $repoUrl to $targetRel manually."
        return $false
    }

    Write-Host " Cloning $ToolName from $repoUrl ..." -ForegroundColor Yellow
    try {
        $parent = Split-Path $target -Parent
        if (-not (Test-Path $parent)) { $null = New-Item -ItemType Directory -Path $parent -Force }
        # Remove any partial clone
        if (Test-Path $target) { Remove-Item $target -Recurse -Force -ErrorAction SilentlyContinue }
        $result = Invoke-WithTimeout -Command 'git' -Arguments @('clone', '--depth', '1', '--quiet', $repoUrl, $target)
        if ($result.ExitCode -ne 0 -or -not (Test-Path $probeFile)) {
            Write-Warning "git clone of $ToolName failed: $($result.Output)"
            return $false
        }
        Write-Host " v $ToolName cloned into $targetRel" -ForegroundColor Green
        return $true
    } catch {
        Write-Warning (Remove-Credentials "Failed to clone ${ToolName}: $($_.Exception.Message)")
        return $false
    }
}

function Test-InstallConfig {
    <#
    .SYNOPSIS
        Validate an install config object against the expected schema.
        Returns a PSCustomObject with Valid (bool) and Errors (string[]).
    .PARAMETER Config
        The parsed install-config.json object.
    .PARAMETER Manifest
        The parsed tool-manifest.json object, used to validate tool names.
    #>

    param (
        [Parameter(Mandatory)] $Config,
        [Parameter(Mandatory)] $Manifest
    )
    $errors = [System.Collections.Generic.List[string]]::new()

    # Schema version check
    $hasSchemaVer = $Config.PSObject.Properties['schemaVersion']
    if (-not $hasSchemaVer -or $Config.schemaVersion -ne '1.0') {
        $actual = if ($hasSchemaVer) { Remove-Credentials ([string]$Config.schemaVersion) } else { '<missing>' }
        $errors.Add("schemaVersion must be '1.0', got '${actual}'.")
    }

    # Validate defaults block
    if ($Config.PSObject.Properties['defaults']) {
        $defaults = $Config.defaults
        $allowedDefaultKeys = @('autoInstall')
        foreach ($prop in $defaults.PSObject.Properties) {
            if ($prop.Name -notin $allowedDefaultKeys) {
                $errors.Add("Unknown key 'defaults.$(Remove-Credentials $prop.Name)'.")
            }
        }
        if ($defaults.PSObject.Properties['autoInstall'] -and $defaults.autoInstall -isnot [bool]) {
            $errors.Add("defaults.autoInstall must be a boolean.")
        }
    }

    # Validate tools block
    if ($Config.PSObject.Properties['tools'] -and $null -ne $Config.tools) {
        $manifestNames = @($Manifest.tools | ForEach-Object { $_.name })
        $allowedToolKeys = @('enabled', 'manager')

        foreach ($prop in $Config.tools.PSObject.Properties) {
            $toolName = Remove-Credentials $prop.Name
            $toolCfg  = $prop.Value

            if ($prop.Name -notin $manifestNames) {
                $errors.Add("Tool '${toolName}' not found in tool-manifest.json.")
            }

            foreach ($k in $toolCfg.PSObject.Properties) {
                if ($k.Name -notin $allowedToolKeys) {
                    $errors.Add("Unknown key 'tools.${toolName}.$(Remove-Credentials $k.Name)'.")
                }
            }

            if ($toolCfg.PSObject.Properties['enabled'] -and $toolCfg.enabled -isnot [bool]) {
                $errors.Add("tools.${toolName}.enabled must be a boolean.")
            }

            if ($toolCfg.PSObject.Properties['manager']) {
                $mgr = Remove-Credentials ([string]$toolCfg.manager)
                if ($script:AllowedPackageManagers -notcontains $mgr) {
                    $errors.Add("tools.${toolName}.manager '${mgr}' is not in the allow-list ($($script:AllowedPackageManagers -join ', ')).")
                }
            }
        }
    }

    # Reject unknown top-level keys
    $allowedTopLevel = @('schemaVersion', 'defaults', 'tools')
    foreach ($prop in $Config.PSObject.Properties) {
        if ($prop.Name -notin $allowedTopLevel) {
            $errors.Add("Unknown top-level key '$(Remove-Credentials $prop.Name)'.")
        }
    }

    return [PSCustomObject]@{
        Valid  = ($errors.Count -eq 0)
        Errors = $errors.ToArray()
    }
}

function Read-InstallConfig {
    <#
    .SYNOPSIS
        Load and validate tools/install-config.json. Returns $null if the
        file is missing (backward-compatible) or invalid (warning emitted).
    .PARAMETER Path
        Path to the install-config.json file.
    .PARAMETER Manifest
        The parsed tool-manifest.json object, used for tool-name validation.
    #>

    param (
        [string] $Path,
        [Parameter(Mandatory)] $Manifest
    )

    if (-not $Path) { $Path = $script:DefaultInstallConfigPath }
    if (-not (Test-Path $Path)) {
        Write-Verbose "[install-config] No config file at $Path; using manifest defaults."
        return $null
    }

    try {
        $config = Get-Content $Path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
    } catch {
        Write-Warning (Remove-Credentials "[install-config] Failed to parse ${Path}: $($_.Exception.Message). Using manifest defaults.")
        return $null
    }

    $validation = Test-InstallConfig -Config $config -Manifest $Manifest
    if (-not $validation.Valid) {
        foreach ($err in $validation.Errors) {
            Write-Warning (Remove-Credentials "[install-config] $err")
        }
        Write-Warning "[install-config] Config validation failed; falling back to manifest defaults."
        return $null
    }

    Write-Verbose "[install-config] Loaded install config from $Path"
    return $config
}

function Install-PrerequisitesFromManifest {
    <#
    .SYNOPSIS
        Install all prerequisites for enabled, non-excluded tools.
    .PARAMETER Manifest
        The parsed tool manifest object (from tool-manifest.json).
    .PARAMETER RepoRoot
        Repository root used as the base for gitclone targets.
    .PARAMETER ShouldRunTool
        Scriptblock that returns $true if a given tool name should run
        (honours -IncludeTools / -ExcludeTools in the caller).
    .PARAMETER SkipInstall
        When set, only reports what's missing without installing.
    .PARAMETER InstallConfig
        Optional parsed install-config.json object. Tools with
        enabled=false are skipped; manager overrides are applied.
    .PARAMETER CliIncludedTools
        Tool names explicitly passed via -IncludeTools. These override
        config enabled=false (CLI > config > manifest).
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] $Manifest,
        [Parameter(Mandatory)][string] $RepoRoot,
        [Parameter(Mandatory)][scriptblock] $ShouldRunTool,
        [switch] $SkipInstall,
        $InstallConfig,
        [string[]] $CliIncludedTools
    )

    Write-Host "`n[prereq] Checking prerequisites (manifest-driven)..." -ForegroundColor Yellow
    $missing = [System.Collections.Generic.List[string]]::new()
    $skipped = [System.Collections.Generic.List[string]]::new()

    foreach ($tool in $Manifest.tools) {
        if (-not $tool.enabled) { continue }
        if (-not (& $ShouldRunTool $tool.name)) { continue }

        # Check install config for enabled=false override, but CLI
        # -IncludeTools takes precedence (CLI > config > manifest).
        $cliExplicit = $CliIncludedTools -and ($tool.name -in $CliIncludedTools)
        if (-not $cliExplicit -and
            $null -ne $InstallConfig -and
            $InstallConfig.PSObject.Properties['tools'] -and
            $null -ne $InstallConfig.tools -and
            $InstallConfig.tools.PSObject.Properties[$tool.name]) {
            $toolCfg = $InstallConfig.tools.($tool.name)
            if ($toolCfg.PSObject.Properties['enabled'] -and $toolCfg.enabled -eq $false) {
                Write-Verbose "[prereq] $($tool.name) disabled by install config; skipping."
                $skipped.Add($tool.name)
                continue
            }
        }

        if (-not $tool.PSObject.Properties['install'] -or -not $tool.install) { continue }
        $install = $tool.install
        $kind = [string]$install.kind

        if ($script:AllowedInstallKinds -notcontains $kind) {
            Write-Warning "Refusing to honour unknown install kind '$kind' for $($tool.name)."
            continue
        }

        # Resolve manager override from install config
        $managerOverride = $null
        if ($null -ne $InstallConfig -and
            $InstallConfig.PSObject.Properties['tools'] -and
            $null -ne $InstallConfig.tools -and
            $InstallConfig.tools.PSObject.Properties[$tool.name]) {
            $toolCfg = $InstallConfig.tools.($tool.name)
            if ($toolCfg.PSObject.Properties['manager']) {
                $managerOverride = [string]$toolCfg.manager
            }
        }

        switch ($kind) {
            'none' { }
            'psmodule' {
                $names = @($install.modules)
                $anyMissing = $false
                foreach ($m in $names) { if (-not (Test-PSModuleAvailable -Name $m)) { $anyMissing = $true; break } }
                if (-not $anyMissing) { break }
                if ($SkipInstall) {
                    $missing.Add("$($tool.displayName) ($($names -join ', '))")
                } else {
                    Install-PSModules -Names $names -ToolName $tool.displayName
                    foreach ($m in $names) {
                        if (-not (Test-PSModuleAvailable -Name $m)) { $missing.Add("$($tool.displayName) ($m)"); break }
                    }
                }
            }
            'cli' {
                if (Test-CliAvailable -Command $install.command) { break }
                if ($SkipInstall) {
                    $missing.Add("$($tool.displayName) ($($install.command))")
                } else {
                    $ok = Install-CliTool -InstallSpec $install -ToolName $tool.displayName -ManagerOverride $managerOverride
                    if (-not $ok) { $missing.Add($tool.displayName) }
                }
            }
            'gitclone' {
                $probe = Join-Path (Join-Path $RepoRoot $install.target) $install.probe
                if (Test-Path $probe) { break }
                if ($SkipInstall) {
                    $missing.Add("$($tool.displayName) ($($install.target))")
                } else {
                    $ok = Install-GitClone -InstallSpec $install -ToolName $tool.displayName -RepoRoot $RepoRoot
                    if (-not $ok) { $missing.Add($tool.displayName) }
                }
            }
        }
    }

    if ($skipped.Count -gt 0) {
        Write-Host "[prereq] $($skipped.Count) tool(s) disabled by install config: $($skipped -join ', ')" -ForegroundColor DarkGray
    }

    # Process top-level $Manifest.prerequisites (cross-tool helpers like
    # kubelogin that are not themselves tools but are required by one or
    # more wrappers). Honoured only when at least one consumer tool will run.
    if ($Manifest.PSObject.Properties['prerequisites'] -and $Manifest.prerequisites) {
        foreach ($prereq in $Manifest.prerequisites) {
            if (-not $prereq.PSObject.Properties['install'] -or -not $prereq.install) { continue }
            $install = $prereq.install
            $kind = [string]$install.kind
            if ($script:AllowedInstallKinds -notcontains $kind) {
                Write-Warning "Refusing to honour unknown install kind '$kind' for prereq $($prereq.name)."
                continue
            }
            # Only install if at least one consumer tool is going to run.
            $consumers = @($prereq.consumedBy)
            $anyConsumer = $false
            foreach ($c in $consumers) {
                if (& $ShouldRunTool $c) { $anyConsumer = $true; break }
            }
            if (-not $anyConsumer) { continue }

            switch ($kind) {
                'cli' {
                    if (Test-CliAvailable -Command $install.command) { break }
                    if ($SkipInstall) {
                        $missing.Add("$($prereq.displayName) ($($install.command))")
                    } else {
                        $ok = Install-CliTool -InstallSpec $install -ToolName $prereq.displayName
                        if (-not $ok) { $missing.Add($prereq.displayName) }
                    }
                }
                'psmodule' {
                    $names = @($install.modules)
                    $anyMissing = $false
                    foreach ($m in $names) { if (-not (Test-PSModuleAvailable -Name $m)) { $anyMissing = $true; break } }
                    if (-not $anyMissing) { break }
                    if ($SkipInstall) {
                        $missing.Add("$($prereq.displayName) ($($names -join ', '))")
                    } else {
                        Install-PSModules -Names $names -ToolName $prereq.displayName
                        foreach ($m in $names) {
                            if (-not (Test-PSModuleAvailable -Name $m)) { $missing.Add("$($prereq.displayName) ($m)"); break }
                        }
                    }
                }
                default { }
            }
        }
    }

    if ($missing.Count -gt 0) {
        Write-Host "`n[prereq] $($missing.Count) tool(s) still missing: $($missing -join '; ')" -ForegroundColor DarkYellow
    } else {
        Write-Host "[prereq] All prerequisites for enabled tools are available." -ForegroundColor Green
    }
    return $missing.Count
}