RepoFabric.Client.psm1

#Requires -Version 5.1
<#
    RepoFabric.Client
    Configure a Windows endpoint to install from a self-hosted RepoFabric private
    WinGet source: register the source as Trusted, set silent-install defaults, map
    the Mark-of-the-Web Local Intranet zone for self-signed / non-standard-port hosts,
    and verify health. No server dependencies; runs on any Windows 10/11 endpoint that
    has WinGet (App Installer). From RingoSystems Heavy Industries.

    Project: https://github.com/Ringosystems/RepoFabric-Public
    Container image: https://hub.docker.com/r/ringosystems/repofabric
#>


# ---- private helpers -------------------------------------------------------

function Get-RfWingetPath {
    $cmd = Get-Command 'winget.exe' -ErrorAction SilentlyContinue
    if (-not $cmd) { $cmd = Get-Command 'winget' -ErrorAction SilentlyContinue }
    if (-not $cmd) {
        throw 'winget (App Installer) was not found on PATH. Install "App Installer" from the Microsoft Store, then retry.'
    }
    return $cmd.Source
}

function Assert-RfAdmin {
    param([string]$Action = 'perform this operation')
    $identity  = [Security.Principal.WindowsIdentity]::GetCurrent()
    $principal = New-Object Security.Principal.WindowsPrincipal($identity)
    if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
        throw "Administrator rights are required to $Action. Re-run in an elevated PowerShell session."
    }
}

function Set-RfIntranetZone {
    # Map a URL origin (scheme + host + port) into the Local Intranet zone (value 1)
    # via the Site to Zone Assignment List, so WinGet's Mark-of-the-Web attachment
    # scan does not stall on installers from a self-signed, non-standard-port host.
    # The full URL is used because the per-host map cannot express a port.
    param([Parameter(Mandatory)][string]$Url)
    $origin = $Url
    try { $u = [Uri]$Url; $origin = $u.Scheme + '://' + $u.Authority } catch { }
    $zmk = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\Internet Settings\ZoneMapKey'
    if (-not (Test-Path $zmk)) { New-Item -Path $zmk -Force | Out-Null }
    New-ItemProperty -Path $zmk -Name $origin -Value '1' -PropertyType String -Force | Out-Null
    Write-Host "Mapped $origin to the Local Intranet zone."
}

# ---- public cmdlets --------------------------------------------------------

function Register-RfSource {
<#
.SYNOPSIS
    Register a RepoFabric private WinGet source on this endpoint, as a Trusted source.
.DESCRIPTION
    Registers a Microsoft.Rest WinGet source pointing at your RepoFabric instance and
    marks it Trusted so installs run without the source-trust prompt and skip the
    Mark-of-the-Web attachment scan. Optionally trusts a CA certificate (for self-signed
    instances) and maps the source and installer origins into the Local Intranet zone so
    large installers from a self-signed, non-standard-port host do not stall.
    Idempotent. Certificate and registry operations require an elevated session.
.PARAMETER Url
    The RepoFabric REST source URL, for example https://winget.contoso.com/api/
.PARAMETER Name
    Local source name. Default 'repofabric'.
.PARAMETER InstallerSite
    Optional installer origin (scheme://host[:port]) to map into the Intranet zone,
    for example https://installers.contoso.com. Needed only for self-signed or
    non-standard-port hosts.
.PARAMETER CaCertPath
    Optional path to a PEM/CER CA certificate to import into LocalMachine\Root
    (for self-signed RepoFabric instances).
.PARAMETER MapIntranetZone
    Map the source URL origin (and InstallerSite, if supplied) into the Local Intranet zone.
.PARAMETER Untrusted
    Register the source WITHOUT --trust-level trusted (not recommended for RepoFabric).
.EXAMPLE
    Register-RfSource -Url https://winget.contoso.com/api/
.EXAMPLE
    Register-RfSource -Url https://winget.lab.local:8443/api/ -InstallerSite https://installers.lab.local:8443 -CaCertPath .\rf-ca.crt -MapIntranetZone
.LINK
    https://github.com/Ringosystems/RepoFabric-Public
.LINK
    https://hub.docker.com/r/ringosystems/repofabric
#>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)][string]$Url,
        [string]$Name = 'repofabric',
        [string]$InstallerSite,
        [string]$CaCertPath,
        [switch]$MapIntranetZone,
        [switch]$Untrusted
    )
    $winget = Get-RfWingetPath

    if ($CaCertPath) {
        Assert-RfAdmin -Action 'import a CA certificate into LocalMachine\Root'
        if (-not (Test-Path $CaCertPath)) { throw "CA certificate not found: $CaCertPath" }
        if ($PSCmdlet.ShouldProcess('LocalMachine\Root', "Import CA $CaCertPath")) {
            Import-Certificate -FilePath $CaCertPath -CertStoreLocation Cert:\LocalMachine\Root | Out-Null
            Write-Host "Trusted CA '$CaCertPath' (LocalMachine\Root)."
        }
    }

    if ($PSCmdlet.ShouldProcess($Name, "Register WinGet source -> $Url")) {
        & $winget source remove --name $Name 2>$null | Out-Null
        $addArgs = @('source', 'add', '--name', $Name, '--arg', $Url, '--type', 'Microsoft.Rest', '--accept-source-agreements')
        if ($Untrusted) {
            & $winget @addArgs 2>$null | Out-Null
        } else {
            & $winget @addArgs --trust-level trusted 2>$null | Out-Null
            if ($LASTEXITCODE -ne 0) {
                Write-Warning 'winget did not accept --trust-level (older build?); retrying without it.'
                & $winget @addArgs 2>$null | Out-Null
            }
        }
        if ($LASTEXITCODE -ne 0) {
            throw "winget source add failed (exit $LASTEXITCODE). Confirm the host resolves (add a hosts entry for a self-signed hostname), the CA is trusted, and the URL ends in /api/."
        }
        Write-Host "Registered WinGet source '$Name' -> $Url"
    }

    if ($MapIntranetZone) {
        Assert-RfAdmin -Action 'map the Local Intranet zone (HKLM)'
        Set-RfIntranetZone -Url $Url
        if ($InstallerSite) { Set-RfIntranetZone -Url $InstallerSite }
    }
}

function Unregister-RfSource {
<#
.SYNOPSIS
    Remove a RepoFabric WinGet source from this endpoint.
.PARAMETER Name
    Source name to remove. Default 'repofabric'.
.EXAMPLE
    Unregister-RfSource -Name repofabric
.LINK
    https://github.com/Ringosystems/RepoFabric-Public
#>

    [CmdletBinding(SupportsShouldProcess)]
    param([string]$Name = 'repofabric')
    $winget = Get-RfWingetPath
    if ($PSCmdlet.ShouldProcess($Name, 'Remove WinGet source')) {
        & $winget source remove --name $Name
        if ($LASTEXITCODE -ne 0) {
            Write-Warning "winget source remove returned exit $LASTEXITCODE (the source may not exist)."
        } else {
            Write-Host "Removed WinGet source '$Name'."
        }
    }
}

function Get-RfSource {
<#
.SYNOPSIS
    List the RepoFabric (Microsoft.Rest) WinGet sources registered on this endpoint.
.PARAMETER Name
    Return only the source with this exact name.
.EXAMPLE
    Get-RfSource
.LINK
    https://github.com/Ringosystems/RepoFabric-Public
#>

    [CmdletBinding()]
    param([string]$Name)
    $winget = Get-RfWingetPath
    $raw = & $winget source export 2>$null | Out-String
    $sources = @()
    try { $sources = @($raw | ConvertFrom-Json) } catch { $sources = @() }
    foreach ($s in $sources) {
        if ($Name) { if ($s.Name -ne $Name) { continue } }
        elseif (-not ($s.Type -eq 'Microsoft.Rest' -or "$($s.Arg)" -match '/api')) { continue }
        [pscustomobject]@{
            Name     = $s.Name
            Argument = $s.Arg
            Type     = $s.Type
        }
    }
}

function Set-RfClientDefault {
<#
.SYNOPSIS
    Make WinGet installs silent, non-interactive, and machine-scoped on this endpoint.
.DESCRIPTION
    Writes winget settings.json (machine scope, interactivity disabled) so packages from
    the RepoFabric source install silently. AllUsers scope writes the default-user profile
    and every existing user profile (requires elevation); CurrentUser writes only the
    current user. Optionally installs wgi/wgu/wgup convenience wrappers into the all-users
    PowerShell profiles, and maps an installer origin into the Local Intranet zone.
    Mirrors the Intune platform script deploy/intune/Set-RfSilentDefaults.ps1.
.PARAMETER InstallerSite
    Optional installer origin to map into the Intranet zone (requires elevation).
.PARAMETER InstallWrappers
    Also add Install-/Uninstall-/Upgrade-WingetSilent wrappers (wgi/wgu/wgup) to the
    all-users PowerShell profiles.
.PARAMETER Scope
    'AllUsers' (default, requires elevation) or 'CurrentUser'.
.EXAMPLE
    Set-RfClientDefault
.EXAMPLE
    Set-RfClientDefault -Scope CurrentUser
.LINK
    https://github.com/Ringosystems/RepoFabric-Public
#>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [string]$InstallerSite,
        [switch]$InstallWrappers,
        [ValidateSet('AllUsers', 'CurrentUser')][string]$Scope = 'AllUsers'
    )

    $settingsJson = @{
        '$schema'       = 'https://aka.ms/winget-settings.schema.json'
        installBehavior = @{ preferences = @{ scope = 'machine' } }
        interactivity   = @{ disable = $true }
    } | ConvertTo-Json -Depth 6
    $relPath = 'AppData\Local\Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState\settings.json'

    if ($Scope -eq 'CurrentUser') {
        $target = Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState\settings.json'
        if ($PSCmdlet.ShouldProcess($target, 'Write winget settings.json (current user)')) {
            New-Item -ItemType Directory -Force -Path (Split-Path -Parent $target) | Out-Null
            Set-Content -Path $target -Value $settingsJson -Encoding UTF8
            Write-Host "Wrote silent-install settings for the current user."
        }
    } else {
        Assert-RfAdmin -Action 'write machine-wide winget defaults (AllUsers)'
        if ($PSCmdlet.ShouldProcess('all users', 'Write winget settings.json')) {
            $defaultUserDir = Join-Path "$env:SystemDrive\Users\Default" (Split-Path -Parent $relPath)
            New-Item -ItemType Directory -Force -Path $defaultUserDir | Out-Null
            Set-Content -Path (Join-Path $defaultUserDir 'settings.json') -Value $settingsJson -Encoding UTF8
            Get-ChildItem "$env:SystemDrive\Users" -Directory -ErrorAction SilentlyContinue |
                Where-Object { $_.Name -notin 'Default', 'Public', 'All Users', 'Default User' } |
                ForEach-Object {
                    $t = Join-Path $_.FullName $relPath
                    if (Test-Path (Split-Path -Parent $t)) {
                        Set-Content -Path $t -Value $settingsJson -Encoding UTF8
                    }
                }
            Write-Host "Wrote silent-install settings for the default and existing user profiles."
        }
    }

    if ($InstallWrappers) {
        $profileBody = @'
# --- REPOFABRIC always-silent winget wrappers ------------------------------------
function Install-WingetSilent { [CmdletBinding()] param([Parameter(Mandatory, Position = 0)][string]$Id, [Parameter(ValueFromRemainingArguments)]$Rest) & winget.exe install --id $Id --silent --disable-interactivity --accept-package-agreements --accept-source-agreements --scope machine --exact @Rest }
function Uninstall-WingetSilent { [CmdletBinding()] param([Parameter(Mandatory, Position = 0)][string]$Id, [Parameter(ValueFromRemainingArguments)]$Rest) & winget.exe uninstall --id $Id --silent --disable-interactivity --exact @Rest }
function Upgrade-WingetSilent { [CmdletBinding()] param([Parameter(ValueFromRemainingArguments)]$Rest) & winget.exe upgrade --silent --disable-interactivity --accept-package-agreements --accept-source-agreements --scope machine @Rest }
Set-Alias wgi Install-WingetSilent
Set-Alias wgu Uninstall-WingetSilent
Set-Alias wgup Upgrade-WingetSilent
# ---------------------------------------------------------------------------
'@

        $profileTargets = if ($Scope -eq 'CurrentUser') {
            @($PROFILE.CurrentUserAllHosts)
        } else {
            @("$env:windir\System32\WindowsPowerShell\v1.0\Profile.ps1", 'C:\Program Files\PowerShell\7\Profile.ps1')
        }
        foreach ($p in $profileTargets) {
            $dir = Split-Path -Parent $p
            if (-not (Test-Path $dir)) { continue }
            $existing = if (Test-Path $p) { Get-Content -Raw $p } else { '' }
            if ($existing -notmatch '# --- REPOFABRIC always-silent winget wrappers') {
                if ($PSCmdlet.ShouldProcess($p, 'Add winget silent wrappers')) {
                    Add-Content -Path $p -Value "`r`n$profileBody`r`n"
                    Write-Host "Added wgi/wgu/wgup wrappers to $p"
                }
            }
        }
    }

    if ($InstallerSite) {
        Assert-RfAdmin -Action 'map the Local Intranet zone (HKLM)'
        Set-RfIntranetZone -Url $InstallerSite
    }
}

function Test-RfClientHealth {
<#
.SYNOPSIS
    Verify this endpoint is configured to install from a RepoFabric source.
.DESCRIPTION
    Reports on: winget presence, whether the named RepoFabric source is registered,
    whether its REST endpoint responds, whether machine-scope silent defaults are set,
    and whether the source origin is mapped into the Local Intranet zone. Returns one
    result object per check.
.PARAMETER Name
    Source name to check. Default 'repofabric'.
.EXAMPLE
    Test-RfClientHealth | Format-Table
.LINK
    https://github.com/Ringosystems/RepoFabric-Public
#>

    [CmdletBinding()]
    param([string]$Name = 'repofabric')

    $results = New-Object System.Collections.Generic.List[object]
    function Add-Result { param($n, $ok, $detail) $results.Add([pscustomobject]@{ Check = $n; Status = $(if ($ok) { 'Pass' } else { 'Fail' }); Detail = $detail }) }

    $winget = $null
    try { $winget = Get-RfWingetPath; Add-Result 'winget present' $true $winget }
    catch { Add-Result 'winget present' $false $_.Exception.Message; return $results }

    $src = Get-RfSource -Name $Name | Select-Object -First 1
    Add-Result "source '$Name' registered" ([bool]$src) $(if ($src) { $src.Argument } else { 'not registered' })

    if ($src -and $src.Argument) {
        try {
            $probe = ($src.Argument.TrimEnd('/')) + '/information'
            $resp = Invoke-WebRequest -Uri $probe -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop
            Add-Result 'source REST endpoint responds' ($resp.StatusCode -eq 200) "HTTP $($resp.StatusCode) from $probe"
        } catch {
            Add-Result 'source REST endpoint responds' $false $_.Exception.Message
        }
    }

    $userSettings = Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState\settings.json'
    Add-Result 'silent defaults present' (Test-Path $userSettings) $userSettings

    if ($src -and $src.Argument) {
        $origin = $src.Argument
        try { $u = [Uri]$src.Argument; $origin = $u.Scheme + '://' + $u.Authority } catch { }
        $zmk = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\Internet Settings\ZoneMapKey'
        $mapped = $false
        try { $mapped = [bool](Get-ItemProperty -Path $zmk -Name $origin -ErrorAction Stop) } catch { }
        Add-Result 'Intranet zone mapped' $mapped $origin
    }

    return $results
}

Export-ModuleMember -Function Register-RfSource, Unregister-RfSource, Get-RfSource, Set-RfClientDefault, Test-RfClientHealth