Publish-PSLocalBase.ps1

<#
.SYNOPSIS
    Packages and publishes PSLocalBase to the PowerShell Gallery (PSGallery).

.DESCRIPTION
    Creates a clean, production-only staging package containing exclusively the
    core database engine and metadata files (PSLocalBase.psd1, PSLocalBase.psm1,
    PSLocalBase.Cmdlets.ps1, InitWinSqlite3Native.ps1, LICENSE, README.md, CHANGELOG.md).
    The cmdlet adapter remains a separate script, matching the source layout.
    
    Explicitly excludes development assets, tests, documentation, and the
    Playground visual GUI explorer.

.PARAMETER NuGetApiKey
    The PowerShell Gallery API key. If omitted, the script checks the
    PSGALLERY_API_KEY environment variable, or prompts securely for it.

.PARAMETER DryRun
    Creates staging, builds a local NuGet archive, and verifies its actual contents
    without uploading to PSGallery.

.PARAMETER KeepStaging
    Retains the staged module for inspection or isolated testing after completion.

.EXAMPLE
    # Test packaging without uploading:
    .\Publish-PSLocalBase.ps1 -DryRun

.EXAMPLE
    # Publish by passing API key directly:
    .\Publish-PSLocalBase.ps1 -NuGetApiKey "oy2a..."

.EXAMPLE
    # Publish using environment variable:
    $env:PSGALLERY_API_KEY = "oy2a..."
    .\Publish-PSLocalBase.ps1
#>


[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Position = 0)]
    [string]$NuGetApiKey,

    [switch]$DryRun,

    [switch]$KeepStaging
)

$ErrorActionPreference = 'Stop'

# Ensure TLS 1.2 is enabled for PowerShell Gallery (required for Windows PowerShell 5.1)
try {
    [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12
} catch { }

# 1. Resolve root repository path
$repoRoot = $PSScriptRoot
if (-not $repoRoot) { $repoRoot = (Get-Location).Path }

$manifestPath = Join-Path $repoRoot "PSLocalBase.psd1"
if (-not (Test-Path $manifestPath)) {
    throw "Cannot find PSLocalBase.psd1 at '$repoRoot'. Run this script from the repository root."
}

# 2. Validate manifest syntax before staging
$manifest = Test-ModuleManifest -Path $manifestPath
$version = $manifest.Version.ToString()
Write-Host "==========================================================" -ForegroundColor Cyan
Write-Host "Publisher script: $PSCommandPath" -ForegroundColor Gray
Write-Host " Preparing to Publish PSLocalBase v$version" -ForegroundColor Cyan
Write-Host "==========================================================" -ForegroundColor Cyan

# 3. Resolve API Key
$resolvedKey = $NuGetApiKey
if ([string]::IsNullOrWhiteSpace($resolvedKey)) {
    $resolvedKey = $env:PSGALLERY_API_KEY
}

if (-not $DryRun -and [string]::IsNullOrWhiteSpace($resolvedKey)) {
    Write-Host "No API key was passed as parameter or found in `$env:PSGALLERY_API_KEY." -ForegroundColor Yellow
    $secureKey = Read-Host -Prompt "Enter your PowerShell Gallery API Key" -AsSecureString
    $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureKey)
    try {
        $resolvedKey = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
    } finally {
        [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
    }
}

if (-not $DryRun -and [string]::IsNullOrWhiteSpace($resolvedKey)) {
    throw "A valid PowerShell Gallery API key is required to publish. Use -DryRun to test packaging."
}

# 4. Prepare Staging Directory (Excluding Playground, _TESTS, _DOCS, .git)
$stagingId = [System.Guid]::NewGuid().ToString("N").Substring(0, 8)
# Note: PowerShellGet requires the leaf folder name to match the module name exactly ('PSLocalBase')
$stagingRoot = Join-Path ([System.IO.Path]::GetTempPath()) "PSLocalBase_Staging_$stagingId"
$stagingDir = Join-Path $stagingRoot "PSLocalBase"
$resolvedTempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd('\', '/')
$resolvedStagingRoot = [System.IO.Path]::GetFullPath($stagingRoot)
if ([System.IO.Path]::GetDirectoryName($resolvedStagingRoot) -ne $resolvedTempRoot -or
    [System.IO.Path]::GetFileName($resolvedStagingRoot) -notmatch '\APSLocalBase_Staging_[a-f0-9]{8}\z') {
    throw "Staging directory must be a dedicated child of the system temporary directory."
}

try {
    if (Test-Path -LiteralPath $stagingRoot) { throw "Staging directory already exists: '$stagingRoot'." }
    New-Item -ItemType Directory -Path $stagingDir | Out-Null

    Write-Host "Staging production files..." -ForegroundColor Gray
    $filesToPackage = @(
        "PSLocalBase.psd1",
        "PSLocalBase.psm1",
        "PSLocalBase.Cmdlets.ps1",
        "InitWinSqlite3Native.ps1",
        "LICENSE",
        "README.md",
        "CHANGELOG.md"
    )

    foreach ($file in $filesToPackage) {
        $sourcePath = Join-Path $repoRoot $file
        if (-not (Test-Path $sourcePath)) {
            throw "Required package file not found: '$sourcePath'"
        }
        Copy-Item -Path $sourcePath -Destination $stagingDir -Force
        Write-Host " + Included: $file" -ForegroundColor DarkGreen
    }

    # Normalize README.md links in staging to prevent broken links
    # (replaces relative links to Playground, _DOCS, _TESTS with absolute GitHub URLs)
    $stagedReadme = Join-Path $stagingDir "README.md"
    if (Test-Path $stagedReadme) {
        $readmeText = [System.IO.File]::ReadAllText($stagedReadme, [System.Text.Encoding]::UTF8)

        # 1. Images: Playground/screenshots/... -> raw.githubusercontent.com
        $readmeText = $readmeText -replace '\(Playground/screenshots/([^\)]+)\)', '(https://raw.githubusercontent.com/cmartineztech/PSLocalBase/main/Playground/screenshots/$1)'

        # 2. Folders: (_DOCS/) or (_DOCS/#section) -> tree/main/_DOCS
        $readmeText = $readmeText -replace '\(_DOCS/(#[^\)]*)?\)', '(https://github.com/cmartineztech/PSLocalBase/tree/main/_DOCS/$1)'

        # 3. Specific documents in _DOCS, _TESTS, Playground
        $readmeText = $readmeText -replace '\(_DOCS/([a-zA-Z0-9_\-\.]+\.md)(#[^\)]*)?\)', '(https://github.com/cmartineztech/PSLocalBase/blob/main/_DOCS/$1$2)'
        $readmeText = $readmeText -replace '\(_TESTS/([^\)]+)\)', '(https://github.com/cmartineztech/PSLocalBase/blob/main/_TESTS/$1)'
        $readmeText = $readmeText -replace '\(Playground/([^\)]+)\)', '(https://github.com/cmartineztech/PSLocalBase/blob/main/Playground/$1)'
        $readmeText = $readmeText -replace '\(SECURITY\.md\)', '(https://github.com/cmartineztech/PSLocalBase/blob/main/SECURITY.md)'

        [System.IO.File]::WriteAllText($stagedReadme, $readmeText, [System.Text.Encoding]::UTF8)
        Write-Host " * Normalized staged README.md relative links to absolute GitHub URLs (no broken links)." -ForegroundColor DarkGreen
    }

    # Verify staging manifest
    $stagedManifestPath = Join-Path $stagingDir "PSLocalBase.psd1"
    $stagedManifest = Test-ModuleManifest -Path $stagedManifestPath
    $actualFiles = @(Get-ChildItem -LiteralPath $stagingDir -File | Select-Object -ExpandProperty Name)
    if (@(Compare-Object $filesToPackage $actualFiles).Count -ne 0 -or
        @(Get-ChildItem -LiteralPath $stagingDir -Directory).Count -ne 0 -or
        @(Compare-Object $filesToPackage @($stagedManifest.FileList | ForEach-Object { Split-Path $_ -Leaf })).Count -ne 0) {
        throw 'Staged package contents do not match the seven-file allowlist.'
    }
    Write-Host "[OK] Staged module manifest verified successfully (v$($stagedManifest.Version))." -ForegroundColor Green

    # Display package contents
    Write-Host "`nStaged Package Contents ($stagingDir):" -ForegroundColor Cyan
    Get-ChildItem -Path $stagingDir | Select-Object Name, Length | Format-Table -AutoSize | Out-String | Write-Host

    Write-Host "Excluded from package:" -ForegroundColor DarkGray
    Write-Host " - Playground/ (GUI explorer)" -ForegroundColor DarkGray
    Write-Host " - _TESTS/ (Unit and stress test suites)" -ForegroundColor DarkGray
    Write-Host " - _DOCS/ (Extended technical specifications)" -ForegroundColor DarkGray
    Write-Host " - .github/ (CI/CD workflows)" -ForegroundColor DarkGray

    # Build the actual NuGet archive locally, then inspect it before any upload.
    # Publish-Module is deliberately restricted to this temporary local repository.
    $packageFeed = Join-Path $stagingRoot 'package'
    $localRepository = 'PSLocalBasePackageCheck_' + [System.Guid]::NewGuid().ToString('N')
    New-Item -ItemType Directory -Path $packageFeed | Out-Null
    $registeredLocalRepository = $false
    try {
        PowerShellGet\Register-PSRepository -Name $localRepository -SourceLocation $packageFeed -PublishLocation $packageFeed -InstallationPolicy Trusted -ErrorAction Stop
        $registeredLocalRepository = $true
        PowerShellGet\Publish-Module -Path $stagingDir -Repository $localRepository -WhatIf:$false -ErrorAction Stop
    } finally {
        if ($registeredLocalRepository) {
            PowerShellGet\Unregister-PSRepository -Name $localRepository -ErrorAction Stop
        }
    }
    $packagePath = Join-Path $packageFeed "PSLocalBase.$version.nupkg"
    if (-not (Test-Path -LiteralPath $packagePath -PathType Leaf)) {
        throw "Expected NuGet archive not generated: '$packagePath'."
    }
    Add-Type -AssemblyName System.IO.Compression.FileSystem
    $archive = [System.IO.Compression.ZipFile]::OpenRead($packagePath)
    try {
        $archiveEntries = @($archive.Entries | ForEach-Object { $_.FullName.Replace('\', '/') })
        $packagePayload = @($archiveEntries | Where-Object {
            $_ -ne '_rels/.rels' -and $_ -ne '[Content_Types].xml' -and
            $_ -notmatch '\Apackage/services/metadata/core-properties/[^/]+\.psmdcp\z'
        })
        $expectedPayload = @($filesToPackage) + 'PSLocalBase.nuspec'
        if ($packagePayload.Count -ne $expectedPayload.Count -or
            @(Compare-Object $expectedPayload $packagePayload).Count -ne 0) {
            throw "UPLOAD BLOCKED: actual .nupkg contains unexpected or missing files: $($packagePayload -join ', ')"
        }
    } finally {
        $archive.Dispose()
    }
    $verifiedHash = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash
    Write-Host "[OK] Actual .nupkg verified: seven module files plus generated nuspec." -ForegroundColor Green
    Write-Host "Verified archive: $packagePath" -ForegroundColor Cyan
    Write-Host "SHA256: $verifiedHash" -ForegroundColor Gray

    # 5. Execute Publish or Dry Run
    if ($DryRun) {
        Write-Host "`n[DRY RUN COMPLETE] Package is valid and ready. No upload was performed." -ForegroundColor Yellow
        Write-Host "To publish for real, run:" -ForegroundColor Yellow
        Write-Host " .\Publish-PSLocalBase.ps1 -NuGetApiKey 'TU_API_KEY'" -ForegroundColor White
    } else {
        Write-Host "`nPublishing PSLocalBase v$version to PowerShell Gallery (PSGallery)..." -ForegroundColor Cyan
        # Push the inspected archive itself; never rebuild from the repository root.
        $nugetCandidates = @(
            (Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\PowerShell\PowerShellGet\NuGet.exe'),
            (Join-Path $env:ProgramData 'Microsoft\Windows\PowerShell\PowerShellGet\NuGet.exe')
        )
        $nugetCommand = Get-Command NuGet.exe -CommandType Application -ErrorAction SilentlyContinue
        if ($nugetCommand) { $nugetCandidates += $nugetCommand.Source }
        $nugetExe = $nugetCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
        if (-not $nugetExe) { throw 'NuGet.exe was not found. No upload was attempted.' }
        if ($PSCmdlet.ShouldProcess("PSGallery / PSLocalBase $version", 'Upload verified seven-file NuGet archive')) {
            if ((Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash -ne $verifiedHash) {
                throw 'UPLOAD BLOCKED: archive changed after verification.'
            }
            & $nugetExe push $packagePath -Source 'https://www.powershellgallery.com/api/v2/package' -ApiKey $resolvedKey -NonInteractive
            if ($LASTEXITCODE -ne 0) { throw "NuGet push failed with exit code $LASTEXITCODE." }
            Write-Host "`n[SUCCESS] PSLocalBase v$version was published successfully to PowerShell Gallery!" -ForegroundColor Green
            Write-Host "View your published module at: https://www.powershellgallery.com/packages/PSLocalBase/$version" -ForegroundColor Cyan
            Write-Host "Install with: Install-Module -Name PSLocalBase" -ForegroundColor White
        }
    }
} finally {
    if ($KeepStaging) {
        Write-Host "Staged module retained at: $stagingDir" -ForegroundColor Cyan
        if ($packagePath) { Write-Host "Verified archive retained at: $packagePath" -ForegroundColor Cyan }
    } elseif (Test-Path -LiteralPath $resolvedStagingRoot) {
        Remove-Item -LiteralPath $resolvedStagingRoot -Recurse -Force -ErrorAction SilentlyContinue
    }
}