Modules/businessdev.ALbuild.Core/Public/Install-ALbuildModuleVersion.ps1

function Install-ALbuildModuleVersion {
    <#
    .SYNOPSIS
        Atomically install ONE businessdev.ALbuild version into a module root, serialised across processes.
 
    .DESCRIPTION
        The safe replacement for `Install-Module`, which expands straight into the destination and is NOT
        atomic - the 2026-08 outage was a file lost when concurrent agents (separate processes, one shared
        account/module root) ran `Install-Module` into the same folder at once.
 
        This:
          1. takes a machine-wide mutex keyed by the module root (agents are separate processes, so a
             per-process lock is useless) - §4.2;
          2. re-checks under the lock (the common contended case is that another job already did the work);
          3. `Save-Module`s into a private staging folder ON THE SAME VOLUME as the module root, so the
             final publish is an atomic rename (a reader sees either the old folder or the new one, never a
             half-written one) - §4.1;
          4. structurally verifies the staged copy and writes the '.albuild-complete' marker (the count+hash
             fingerprint Assert-ALbuildModuleComplete later checks);
          5. parks a pre-existing but BROKEN target as '<version>.defekt-<timestamp>' (never deletes it - §4.5/R8)
             and moves the verified copy into place;
          6. prunes old versions to -KeepVersions inside the same lock (§4.5).
 
    .PARAMETER Version
        The exact version to install (e.g. '2.18.2').
 
    .PARAMETER ModuleRoot
        The module root to install into (…\Modules). Defaults to this edition's CurrentUser module root
        (WindowsPowerShell vs PowerShell - they differ, which is itself a documented trap).
 
    .PARAMETER Repository
        PowerShellGet repository name. Default 'PSGallery'.
 
    .PARAMETER KeepVersions
        Prune to this many newest complete versions after a successful install (0 = do not prune).
        '*.defekt-*' folders are always exempt. Default 3.
 
    .PARAMETER MutexTimeoutSeconds
        Max seconds to wait for the install lock. Default 600.
 
    .PARAMETER Force
        Reinstall even if the target already verifies as complete.
 
    .OUTPUTS
        PSCustomObject { Version; Path; Action ('present'|'installed'); ModuleRoot }.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Version,
        [string] $ModuleRoot,
        [string] $Repository = 'PSGallery',
        [ValidateRange(0, [int]::MaxValue)] [int] $KeepVersions = 3,
        [ValidateRange(1, 3600)] [int] $MutexTimeoutSeconds = 600,
        [switch] $Force
    )

    if (-not $ModuleRoot) { $ModuleRoot = Get-ALbuildUserModuleRoot }
    $moduleDir = Join-Path $ModuleRoot 'businessdev.ALbuild'
    $target = Join-Path $moduleDir $Version

    # Machine-wide lock keyed by the module root: agents are separate processes under one account, so only a
    # 'Global\' mutex serialises them. Keying by root lets a WinPS-5.1 root and a PS7 root install in parallel.
    $key = 'Global\ALbuild-install-' + ([BitConverter]::ToString(
            [Security.Cryptography.MD5]::Create().ComputeHash(
                [Text.Encoding]::UTF8.GetBytes($ModuleRoot.ToLowerInvariant()))) -replace '-')
    $mutex = New-Object System.Threading.Mutex($false, $key)
    $held = $false
    try {
        try { $held = $mutex.WaitOne([TimeSpan]::FromSeconds($MutexTimeoutSeconds)) }
        catch [System.Threading.AbandonedMutexException] { $held = $true }  # prior holder crashed; we own it
        if (-not $held) { throw "Timed out after $MutexTimeoutSeconds s waiting for the businessdev.ALbuild install lock ($key)." }

        # Re-check under the lock: another process may have completed the install while we waited.
        if (-not $Force -and (Assert-ALbuildModuleComplete -Path $target -PassThru).Complete) {
            return [PSCustomObject]@{ Version = $Version; Path = $target; Action = 'present'; ModuleRoot = $ModuleRoot }
        }
        if (-not $PSCmdlet.ShouldProcess($target, "Install businessdev.ALbuild $Version")) { return }

        New-Item -ItemType Directory -Force -Path $moduleDir | Out-Null
        # Stage UNDER the module root (same volume) so [IO.Directory]::Move below is an atomic rename, not a
        # cross-volume copy (which %TEMP% staging would be).
        $staging = Join-Path $moduleDir ('.albuild-staging-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
        try {
            New-Item -ItemType Directory -Force -Path $staging | Out-Null
            Save-Module -Name 'businessdev.ALbuild' -RequiredVersion $Version -Path $staging -Repository $Repository -ErrorAction Stop
            $source = Join-Path (Join-Path $staging 'businessdev.ALbuild') $Version
            if (-not (Test-Path -LiteralPath $source -PathType Container)) { throw "Save-Module did not produce '$source'." }

            # Structural check of the FRESH copy (before the marker exists): manifest parses, nested modules
            # present. A truncated download fails here and never reaches the module root.
            $manifest = Join-Path $source 'businessdev.ALbuild.psd1'
            if (-not (Test-Path -LiteralPath $manifest -PathType Leaf)) { throw 'the downloaded package has no manifest.' }
            $psd = Import-PowerShellDataFile -Path $manifest -ErrorAction Stop
            foreach ($nested in @($psd.NestedModules)) {
                if (-not (Test-Path -LiteralPath (Join-Path $source $nested) -PathType Leaf)) { throw "the downloaded package is missing nested module '$nested'." }
            }

            # Write the completeness marker from the fingerprint of the fresh copy.
            $inv = Get-ALbuildModuleInventory -Path $source
            $marker = [ordered]@{ version = $Version; fileCount = $inv.FileCount; manifestHash = $inv.ManifestHash; installedUtc = (Get-Date).ToUniversalTime().ToString('o') }
            Set-Content -LiteralPath (Join-Path $source '.albuild-complete') -Value ($marker | ConvertTo-Json -Compress) -Encoding UTF8

            # Publish atomically. If a complete target already exists (race lost, not -Force), keep it.
            if (Test-Path -LiteralPath $target) {
                if (-not $Force -and (Assert-ALbuildModuleComplete -Path $target -PassThru).Complete) {
                    return [PSCustomObject]@{ Version = $Version; Path = $target; Action = 'present'; ModuleRoot = $ModuleRoot }
                }
                # Present but broken (or -Force): park it for post-mortem, never delete (R8).
                $defekt = "$target.defekt-$((Get-Date).ToString('yyyyMMddHHmmss'))"
                try { [System.IO.Directory]::Move($target, $defekt); Write-ALbuildLog -Level Warning "Parked a broken businessdev.ALbuild $Version as '$defekt'." }
                catch { Remove-Item -LiteralPath $target -Recurse -Force -ErrorAction SilentlyContinue }
            }
            [System.IO.Directory]::Move($source, $target)
        }
        finally {
            if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue }
        }

        if ($KeepVersions -gt 0) { [void](Invoke-ALbuildModulePrune -ModuleDir $moduleDir -KeepVersions $KeepVersions) }
        Write-ALbuildLog -Level Success "Installed businessdev.ALbuild $Version into '$target'."
        [PSCustomObject]@{ Version = $Version; Path = $target; Action = 'installed'; ModuleRoot = $ModuleRoot }
    }
    finally {
        if ($held) { $mutex.ReleaseMutex() }
        $mutex.Dispose()
    }
}