Private/Template.ps1

# Template catalogs (docs/src/content/docs/explanation/catalog-design.md). A template is a valid machine with a Description
# key; a catalog is a folder of templates. WS_CREATOR_TEMPLATES lists catalogs separated by ';':
# local paths or git repos (optionally #<branch|tag>), then the module's own templates\ comes last.

# Path shown to the user: ~ instead of the home folder
function Format-UserPath([string]$Path) {
    if ($Path.StartsWith($HOME, [StringComparison]::OrdinalIgnoreCase)) { '~' + $Path.Substring($HOME.Length) } else { $Path }
}

function Get-ModuleVersion { (Import-PowerShellDataFile -Path (Join-Path -Path $script:Root -ChildPath 'WsCreator.psd1')).ModuleVersion }

# One entry per catalog, in resolution order: Kind (local, git, module), Dir, Display (base of
# the source shown by list), and for git: Url, Ref.
function Get-TemplateSource {
    $entries = @(($env:WS_CREATOR_TEMPLATES -split ';') | ForEach-Object { $_.Trim() } | Where-Object { $_ })
    foreach ($entry in $entries) {
        if ($entry -match '^(https://|ssh://|git@)') {
            $url, $ref = $entry -split '#', 2
            if ($url -notmatch '^(?:https://|ssh://(?:[^@/]+@)?|git@)([^/:]+)[/:](.+?)(?:\.git)?/?$') { throw "WS_CREATOR_TEMPLATES: cannot read git URL '$url'" }
            $hostName, $repoPath = $Matches[1], $Matches[2]
            [pscustomobject]@{
                Kind    = 'git'
                Url     = $url
                Ref     = $ref
                Dir     = Join-Path -Path $script:CatalogsDir -ChildPath (Join-Path -Path $hostName -ChildPath ($repoPath -replace '/', '\'))
                Display = "$hostName/$repoPath"
            }
        }
        else {
            $dir = [IO.Path]::GetFullPath($entry)
            [pscustomobject]@{ Kind = 'local'; Url = $null; Ref = $null; Dir = $dir; Display = $dir }
        }
    }
    $dir = Join-Path -Path $script:Root -ChildPath 'templates'
    [pscustomobject]@{ Kind = 'module'; Url = $null; Ref = $null; Dir = $dir; Display = (Format-UserPath $dir) }
}

# Clones a git catalog into the cache, or fetches it again (the cache is ws-creator's: reset --hard)
function Update-TemplateCatalog($Source) {
    if ($Source.Kind -ne 'git') { return }
    if (-not (Get-Command git -ErrorAction SilentlyContinue)) { throw "git not found in PATH: needed for the catalog $($Source.Url)" }
    $PSNativeCommandUseErrorActionPreference = $false
    $ref = if ($Source.Ref) { $Source.Ref } else { 'HEAD' }
    if (-not (Test-Path -Path (Join-Path -Path $Source.Dir -ChildPath '.git'))) {
        New-Item -ItemType Directory -Force -Path (Split-Path -Path $Source.Dir -Parent) | Out-Null
        $branch = if ($Source.Ref) { @('--branch', $Source.Ref) } else { @() }
        git clone --quiet --depth 1 @branch $Source.Url $Source.Dir 2>&1 | ForEach-Object { Write-Warning "$_" }
        if ($LASTEXITCODE) { throw "git clone $($Source.Url) failed" }
        return
    }
    git -C $Source.Dir fetch --quiet --depth 1 origin $ref 2>&1 | ForEach-Object { Write-Warning "$_" }
    if ($LASTEXITCODE) { throw "git fetch $($Source.Url) failed" }
    git -C $Source.Dir reset --quiet --hard FETCH_HEAD
    if ($LASTEXITCODE) { throw "git reset in $($Source.Dir) failed" }
}

# Templates of all catalogs, in resolution order. A name found again in a later catalog is
# Shadowed. Version: the catalog's commit (git), the module version (module), none (local).
function Get-Template {
    $seen = @{}
    foreach ($source in Get-TemplateSource) {
        if (-not (Test-Path -Path $source.Dir)) {
            if ($source.Kind -eq 'local') { Write-Warning "WS_CREATOR_TEMPLATES: $($source.Dir) not found" }
            continue
        }
        $version = switch ($source.Kind) {
            'git' { git -C $source.Dir rev-parse --short HEAD }
            'module' { Get-ModuleVersion }
            default { $null }
        }
        foreach ($dir in Get-ChildItem -Path $source.Dir -Directory | Sort-Object -Property Name) {
            $psd1 = Join-Path -Path $dir.FullName -ChildPath 'machine.psd1'
            if (-not (Test-Path -Path $psd1)) { continue }
            $data = Import-PowerShellDataFile -Path $psd1
            $display = if ($source.Kind -eq 'git') { "$($source.Display)/$($dir.Name)" } else { Format-UserPath $dir.FullName }
            [pscustomobject]@{
                Name        = $dir.Name
                Description = $data.Description
                Dir         = $dir.FullName
                Source      = $display
                Version     = $version
                Catalog     = $source
                Shadowed    = $seen.ContainsKey($dir.Name)
            }
            $seen[$dir.Name] = $true
        }
    }
}

# Hash of a machine's files, as init wrote them: sorted relative paths and contents, line
# endings ignored (git may check machine.psd1 out with CRLF), provenance keys left out.
function Get-MachineHash([string]$Dir) {
    $sha = [Security.Cryptography.SHA256]::Create()
    $lines = foreach ($file in Get-ChildItem -Path $Dir -Recurse -File | Sort-Object -Property FullName) {
        $rel = [IO.Path]::GetRelativePath($Dir, $file.FullName) -replace '\\', '/'
        $text = [IO.File]::ReadAllText($file.FullName) -replace "`r", ''
        if ($rel -eq 'machine.psd1') { $text = $text -replace '(?m)^\s*Template(Source|Version|Hash)?\s*=.*\n', '' }
        $hash = [Convert]::ToHexString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($text))).ToLower()
        "$rel $hash"
    }
    'sha256:' + [Convert]::ToHexString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes(($lines -join "`n")))).ToLower()
}

# Provenance line of a machine for list
function Get-MachineProvenance([string]$Dir) {
    $data = Import-PowerShellDataFile -Path (Join-Path -Path $Dir -ChildPath 'machine.psd1')
    if (-not $data.TemplateSource) { return 'written by hand' }
    $text = "copied from $($data.TemplateSource)"
    if ($data.TemplateVersion) { $text += " @ $($data.TemplateVersion)" }
    if ($data.TemplateHash -and $data.TemplateHash -ne (Get-MachineHash $Dir)) { $text += ', modified since' }
    return $text
}