Publish.ps1

<#
.SYNOPSIS
Publishes StevesScriptorium to PowerShell Gallery with pre-flight checks.

.DESCRIPTION
Validates the manifest, parse-checks all Public/*.ps1, cross-checks
FunctionsToExport against actual files, ensures the working tree is
clean and on main, ensures CHANGELOG.md has content under [Unreleased],
then publishes. API key is read from Windows Credential Manager via
the Get-StoredSecret helper (defined in $PROFILE).

.PARAMETER WhatIf
Run all checks and show what would happen without publishing.

.PARAMETER SkipGitCheck
Skip the clean-tree-on-main check. Use only when you have a deliberate
reason (e.g. publishing from a release branch).

.EXAMPLE
.\Publish.ps1 -WhatIf

.EXAMPLE
.\Publish.ps1
#>

[CmdletBinding(SupportsShouldProcess)]
param(
    [switch]$SkipGitCheck
)

$ErrorActionPreference = 'Stop'

# --- Configuration ---------------------------------------------------------
$ModuleName   = 'StevesScriptorium'
$ManifestPath = ".\$ModuleName.psd1"
$CredTarget   = 'PSGallery-StevesScriptorium'

# --- Helpers ---------------------------------------------------------------
function Write-Step  { param($m) Write-Host "==> $m" -ForegroundColor Cyan }
function Write-Ok    { param($m) Write-Host " OK: $m" -ForegroundColor Green }
function Write-Warn2 { param($m) Write-Host " !! $m" -ForegroundColor Yellow }

# --- Pre-flight: warn early if neither secret source is available ----------
$hasStoredSecret = [bool](Get-Command Get-StoredSecret -ErrorAction SilentlyContinue)
$hasEnvKey       = [bool]$env:PSGALLERY_API_KEY
if (-not $hasStoredSecret -and -not $hasEnvKey) {
    throw @'
No API key source found. Set one up before publishing:

  Option A — Windows Credential Manager (recommended for regular publishers):
    Add Get-StoredSecret / Set-StoredSecret helpers to your $PROFILE, then:
    Set-StoredSecret -Target 'PSGallery-StevesScriptorium' -Secret '<your-key>'

  Option B — Environment variable (simplest for one-off use):
    $env:PSGALLERY_API_KEY = '<your-key>'
    .\Publish.ps1

Your PS Gallery API key: https://www.powershellgallery.com/account/apikeys
'@

}

# --- 1. Manifest validation ------------------------------------------------
Write-Step 'Validating manifest'
if (-not (Test-Path $ManifestPath)) {
    throw "Manifest not found at $ManifestPath. Run from the repo root."
}
$manifest = Test-ModuleManifest -Path $ManifestPath
Write-Ok "Manifest parses, version $($manifest.Version)"

# --- 2. Parse-check Public/*.ps1 -------------------------------------------
Write-Step 'Parse-checking Public/*.ps1'
$parseErrors = @()
Get-ChildItem -Path .\Public -Filter *.ps1 -Recurse | ForEach-Object {
    $tokens = $null
    $errs   = $null
    [System.Management.Automation.Language.Parser]::ParseFile(
        $_.FullName, [ref]$tokens, [ref]$errs
    ) | Out-Null
    if ($errs) {
        $parseErrors += "$($_.Name): $($errs[0].Message)"
    }
}
if ($parseErrors) {
    $parseErrors | ForEach-Object { Write-Error $_ }
    throw 'Parse errors found. Aborting.'
}
Write-Ok 'All Public/*.ps1 parse cleanly'

# --- 3. Cross-check FunctionsToExport vs actual files ----------------------
Write-Step 'Cross-checking FunctionsToExport against Public/'
$declared = @($manifest.ExportedFunctions.Keys)
$actual   = @(Get-ChildItem .\Public -Filter *.ps1 | Select-Object -ExpandProperty BaseName)

$missingInManifest = $actual   | Where-Object { $_ -notin $declared }
$missingFiles = $declared | Where-Object { $_ -ne 'toolkit' -and $_ -notin $actual }

if ($missingInManifest) {
    Write-Warn2 "In Public/ but not in FunctionsToExport: $($missingInManifest -join ', ')"
}
if ($missingFiles) {
    Write-Warn2 "In FunctionsToExport but no .ps1: $($missingFiles -join ', ')"
}
if ($missingInManifest -or $missingFiles) {
    throw 'Manifest and Public/ are out of sync. Fix the manifest before publishing.'
}
Write-Ok 'Manifest and Public/ are in sync'

# --- 4. Clean git tree on main (unless skipped) ----------------------------
if (-not $SkipGitCheck) {
    Write-Step 'Checking git working tree'
    $dirty = git status --porcelain
    if ($dirty) {
        Write-Host $dirty
        throw 'Working tree has uncommitted changes. Commit or stash first.'
    }
    $branch = git rev-parse --abbrev-ref HEAD
    if ($branch -ne 'main') {
        throw "Not on main (current: $branch). Switch to main before publishing."
    }
    Write-Ok 'Clean tree, on main'
}

# --- 5. CHANGELOG sanity ---------------------------------------------------
Write-Step 'Checking CHANGELOG.md'
if (-not (Test-Path .\CHANGELOG.md)) {
    throw 'CHANGELOG.md not found. Create one before publishing.'
}
$changelog = Get-Content .\CHANGELOG.md -Raw
if ($changelog -notmatch '## \[Unreleased\]\s*\r?\n([\s\S]*?)(?=\r?\n## \[|\z)') {
    throw 'CHANGELOG.md missing [Unreleased] section.'
}
$unreleasedBody = $Matches[1].Trim()
# Empty section headers (### Added/Fixed with nothing under them) are OK as scaffolding
$content = $unreleasedBody -replace '###\s+\w+\s*(?=\r?\n###|\z)', ''
if ([string]::IsNullOrWhiteSpace($content.Trim())) {
    throw '[Unreleased] section is empty. Add release notes before publishing.'
}
Write-Ok '[Unreleased] has content'

# --- 6. Read API key (Credential Manager → env var fallback) ---------------
Write-Step 'Reading API key'
$apiKey = $null
if ($hasStoredSecret) {
    $apiKey = Get-StoredSecret -Target $CredTarget
}
if (-not $apiKey) {
    $apiKey = $env:PSGALLERY_API_KEY
}
if (-not $apiKey) {
    throw "API key empty. Check your Credential Manager entry '$CredTarget' or set `$env:PSGALLERY_API_KEY."
}
Write-Ok 'API key retrieved'

# --- 7. Publish ------------------------------------------------------------
Write-Step "Publishing $ModuleName v$($manifest.Version)"
if ($PSCmdlet.ShouldProcess($ModuleName, "Publish v$($manifest.Version) to PSGallery")) {
    Publish-Module -Path . -NuGetApiKey $apiKey -Verbose
    Write-Ok 'Published. Allow 15-30 minutes for PS Gallery to index.'
} else {
    Write-Host ' (WhatIf: skipped Publish-Module)' -ForegroundColor DarkGray
}