Private/Add-Win32ToolkitOrgHooks.ps1

function New-Win32ToolkitOrgHookStub {
    <#
    .SYNOPSIS
        Builds the CONSTANT deploy-script stub for one org-hook phase (no template values spliced).
 
    .DESCRIPTION
        Every argument is a fixed constant from the phase table in Add-Win32ToolkitOrgHooks (phase key,
        the pristine PSADT marker, one of six fixed hook filenames, and the Fail|Continue policy). No
        operator/template free-text ever reaches this text — the module's "never splice untrusted values
        into SYSTEM-run code" rule is satisfied structurally: operator script CONTENT is copied as a file
        and merely DOT-SOURCED at runtime; only this compile-time-constant stub is injected.
 
        The stub keeps the original marker as its LAST line so (a) re-apply stays idempotent (the pattern
        in Add-Win32ToolkitOrgHooks matches "begin…end + marker") and (b) on the Post-Install /
        Post-Uninstall phases the win32-toolkit tattoo — which the data-driven pass re-emitted directly
        below the marker — is preserved and still runs AFTER the hook. Hook-throws-before-tattoo is the
        safe order: a failed hook then prevents the detection tattoo from being written.
 
        All emitted syntax is Windows PowerShell 5.1-safe (Join-Path/Test-Path/dot-source/try-catch).
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][string]$Phase,
        [Parameter(Mandatory)][string]$File,
        [Parameter(Mandatory)][string]$Marker,
        [ValidateSet('Fail', 'Continue')][string]$FailureAction = 'Fail',
        [string]$NewLine = "`r`n"
    )

    $ind = ' '   # 4-space indent, matching the PSADT deploy-function body
    $lines = [System.Collections.Generic.List[string]]::new()
    $lines.Add("$ind## Org hook: $Phase (begin — managed by org template; edit the file in SupportFiles\OrgHooks, not this stub)")
    $lines.Add("$ind`$orgHookScript = Join-Path -Path `$adtSession.DirSupportFiles -ChildPath 'OrgHooks\$File'")
    if ($FailureAction -eq 'Continue') {
        $lines.Add("$ind`if (Test-Path -LiteralPath `$orgHookScript)")
        $lines.Add("$ind{")
        $lines.Add("$ind try { . `$orgHookScript }")
        $lines.Add("$ind catch { Write-ADTLogEntry -Message ""Org hook $File failed (continuing): `$(`$_.Exception.Message)"" -Severity 2 }")
        $lines.Add("$ind}")
    } else {
        $lines.Add("$ind`if (Test-Path -LiteralPath `$orgHookScript) { . `$orgHookScript }")
    }
    $lines.Add("$ind## Org hook: $Phase (end)")
    $lines.Add("$ind$Marker")
    return ($lines -join $NewLine)
}

function Add-Win32ToolkitOrgHooks {
    <#
    .SYNOPSIS
        A1/A3 — inject org deploy-phase hook stubs + copy hook files and the org PSADT extension module
        into a configured project.
 
    .DESCRIPTION
        Called from Apply-OrgTemplate AFTER Set-PSADTDataDrivenScript, so the six target markers exist:
        Pre-Install / Pre-Uninstall / Pre-Repair / Repair-Post are pristine, and Post-Install /
        Post-Uninstall were re-emitted directly above the tattoo blocks.
 
        For each of the six phases, if the template has hooks enabled AND that phase's file exists in
        Templates\<name>\Hooks\, the file is COPIED into SupportFiles\OrgHooks\ and a constant stub is
        injected at the marker (idempotent: the pattern matches either the pristine marker or a
        previously-injected begin…end+marker region). Phases without a file (or with hooks disabled) are
        restored to their pristine marker — so removing a hook file and re-applying cleanly removes its
        stub. Everything is copy-not-splice; the only injected text is a compile-time constant.
 
        A3: any Templates\<name>\PSAppDeployToolkit.<Org>\ folder is copied to the project root, where the
        v4 frontend auto-imports every sibling dir matching 'PSAppDeployToolkit\..+'.
 
        Every copied file is parse-checked under real Windows PowerShell 5.1 (warn-only) because it runs
        on-device as 5.1.
 
    .PARAMETER ProjectPath
        The configured PSADT project.
 
    .PARAMETER Template
        The org-template object (schema 3.0+). Read defensively — Hooks/ExtensionModule may be absent on
        older templates.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$ProjectPath,
        [Parameter(Mandatory)][PSCustomObject]$Template
    )

    $scriptPath = Join-Path $ProjectPath 'Invoke-AppDeployToolkit.ps1'
    if (-not (Test-Path -LiteralPath $scriptPath)) { return }

    # ── read feature flags defensively (no registry unless a feature is on) ──
    $hooksEnabled = $false; $failureAction = 'Fail'
    $hp = $Template.PSObject.Properties['Hooks']
    if ($hp -and $hp.Value) {
        if ($hp.Value.PSObject.Properties['Enabled']) { $hooksEnabled = [bool]$hp.Value.Enabled }
        if ($hooksEnabled -and $hp.Value.PSObject.Properties['FailureAction'] -and $hp.Value.FailureAction -in @('Fail', 'Continue')) {
            $failureAction = [string]$hp.Value.FailureAction
        }
    }
    $extEnabled = $false
    if ($Template.PSObject.Properties['ExtensionModule']) { $extEnabled = [bool]$Template.ExtensionModule }

    # ── resolve the template's asset folder only when a feature needs it ──
    $assetFolder = $null
    if ($hooksEnabled -or $extEnabled) {
        $tn = if ($Template.PSObject.Properties['TemplateName']) { [string]$Template.TemplateName } else { '' }
        if ($tn) {
            try { $assetFolder = Get-Win32ToolkitTemplateAssetFolder -TemplateName $tn } catch {
                Write-Verbose "Org hooks/module: could not resolve template asset folder — skipping ($($_.Exception.Message))"
            }
        }
    }

    # Phase → (pristine marker, fixed hook filename).
    $phases = @(
        @{ Key = 'PreInstall';    Marker = '## <Perform Pre-Installation tasks here>';    File = 'PreInstall.ps1' }
        @{ Key = 'PostInstall';   Marker = '## <Perform Post-Installation tasks here>';   File = 'PostInstall.ps1' }
        @{ Key = 'PreUninstall';  Marker = '## <Perform Pre-Uninstallation tasks here>';  File = 'PreUninstall.ps1' }
        @{ Key = 'PostUninstall'; Marker = '## <Perform Post-Uninstallation tasks here>'; File = 'PostUninstall.ps1' }
        @{ Key = 'PreRepair';     Marker = '## <Perform Pre-Repair tasks here>';          File = 'PreRepair.ps1' }
        @{ Key = 'PostRepair';    Marker = '## <Perform Post-Repair tasks here>';         File = 'PostRepair.ps1' }
    )

    $scr = Get-Content -LiteralPath $scriptPath -Raw -Encoding UTF8
    $nl  = if ($scr -match "`r`n") { "`r`n" } else { "`n" }
    $hooksSrc = if ($assetFolder) { Join-Path $assetFolder 'Hooks' } else { $null }
    $destHookDir = Join-Path $ProjectPath 'SupportFiles\OrgHooks'
    $changed = $false

    foreach ($ph in $phases) {
        $srcFile  = if ($hooksSrc) { Join-Path $hooksSrc $ph.File } else { $null }
        $wantHook = $hooksEnabled -and $srcFile -and (Test-Path -LiteralPath $srcFile)

        # Idempotent anchor: a previously-injected "begin … end + marker" region, OR the pristine marker.
        # Longer alternative first so it wins at the begin-tag position. Singleline (-Multiline) lets
        # .*? span newlines; the marker string is unique in the file so neither branch over-reaches.
        $mkEsc    = [regex]::Escape($ph.Marker)
        $beginEsc = [regex]::Escape("## Org hook: $($ph.Key) (begin")
        $pattern  = "[ \t]*$beginEsc.*?\(end\)\r?\n[ \t]*$mkEsc" + '|' + "[ \t]*$mkEsc"

        if ($wantHook) {
            if (-not (Test-Path -LiteralPath $destHookDir)) { New-Item -ItemType Directory -Path $destHookDir -Force | Out-Null }
            $destFile = Join-Path $destHookDir $ph.File
            Copy-Item -LiteralPath $srcFile -Destination $destFile -Force

            $errs = @(Test-Win32ToolkitPS51Syntax -Path $destFile)
            if ($errs.Count) {
                Write-Warning "Org hook '$($ph.File)' has Windows PowerShell 5.1 syntax issue(s) — it runs on-device as 5.1: $($errs -join ' | ')"
            }

            $stub    = New-Win32ToolkitOrgHookStub -Phase $ph.Key -File $ph.File -Marker $ph.Marker -FailureAction $failureAction -NewLine $nl
            $newScr  = Set-TextBlock -Text $scr -Pattern $pattern -Replacement $stub -Multiline -Label "org hook: $($ph.Key)"
        } else {
            # No hook for this phase → ensure the marker is pristine (removes any stale stub). Silent:
            # a pristine marker replaces itself (no-op) and a missing marker must not warn when the
            # feature is off.
            $newScr = Set-TextBlock -Text $scr -Pattern $pattern -Replacement (" " + $ph.Marker) -Multiline
        }

        if ($newScr -ne $scr) { $changed = $true; $scr = $newScr }
    }

    if ($changed) {
        # UTF-8 WITH BOM — the deploy script runs on-device under Windows PowerShell 5.1.
        [System.IO.File]::WriteAllText($scriptPath, $scr, (New-Object System.Text.UTF8Encoding($true)))
        Write-Verbose ' org hook stubs updated in Invoke-AppDeployToolkit.ps1'
    }

    # ── A3: org PSADT extension module(s) ──
    if ($extEnabled -and $assetFolder -and (Test-Path -LiteralPath $assetFolder)) {
        $modDirs = @(Get-ChildItem -LiteralPath $assetFolder -Directory -ErrorAction SilentlyContinue |
            Where-Object { $_.Name -match '^PSAppDeployToolkit\..+' })
        foreach ($md in $modDirs) {
            $dest = Join-Path $ProjectPath $md.Name
            if (Test-Path -LiteralPath $dest) { Remove-Item -LiteralPath $dest -Recurse -Force -ErrorAction SilentlyContinue }
            Copy-Item -LiteralPath $md.FullName -Destination $dest -Recurse -Force
            Get-ChildItem -LiteralPath $dest -Recurse -File -Include '*.ps1', '*.psm1' -ErrorAction SilentlyContinue | ForEach-Object {
                $e = @(Test-Win32ToolkitPS51Syntax -Path $_.FullName)
                if ($e.Count) { Write-Warning "Org extension module file '$($_.Name)' has 5.1 syntax issue(s): $($e -join ' | ')" }
            }
            Write-Verbose " org extension module '$($md.Name)' copied into project root"
        }
    }
}