MSIX.Playbooks.ps1

# =============================================================================
# Playbook bus
# -----------------------------------------------------------------------------
# A playbook is a curated sequence of fixer cmdlet calls that targets a
# specific package fingerprint (Identity Name regex, signer subject regex,
# Application Executable leaf regex, or any combination). The bus loads
# playbooks from PowerShell files under .\playbooks\, matches them against a
# package, and runs the steps as a single signed pass.
#
# Why this exists:
# - EXAMPLES.md is documentation — non-executable.
# - The auto-fix orchestrator is fingerprint-blind (it reacts to findings).
# - For well-known applications we want a deterministic, named recipe that
# captures domain knowledge: which fixers, in which order, with which
# arguments. Playbooks are that recipe.
#
# Anatomy of a playbook (.ps1 file that returns a hashtable):
#
# @{
# Name = 'Notepad++'
# Description = 'Sparse shell merge + write virtualisation exclusions'
# Match = @{
# IdentityName = '^Notepad\+\+$|^Notepad$' # regex
# ExecutableLeaf = '^notepad\+\+\.exe$' # regex (optional)
# PublisherSubject = 'Notepad\+\+ Team' # regex (optional)
# }
# Steps = @(
# @{ Cmdlet = 'Import-MsixSparseShellExtension'; Args = @{ SparsePackagePath = 'VFS\ProgramFilesX64\Notepad++\contextMenu\NppShell.msix' } }
# @{ Cmdlet = 'Set-MsixFileSystemWriteVirtualization'; Args = @{ ExcludedDirectories = @(
# '$(KnownFolder:LocalAppData)',
# '$(KnownFolder:RoamingAppData)',
# 'VFS/ProgramFilesX64/Notepad++/plugins'
# 'VFS/ProgramFilesX64/Notepad++/themes'
# 'VFS/ProgramFilesX64/Notepad++/userDefineLangs'
# ) } }
# @{ Cmdlet = 'Remove-MsixUpdaterArtifact'; Args = @{} }
# @{ Cmdlet = 'Remove-MsixUninstallerArtifact'; Args = @{} }
# )
# }
#
# Match semantics: ALL conditions in -Match must succeed (AND across keys).
# A missing condition is "any". An empty Match block matches everything.
# =============================================================================

# Default playbook search roots. Callers can append more via -SearchPath.
$script:MsixPlaybookSearchRoots = @(
    (Join-Path $PSScriptRoot 'playbooks')
)

function Get-MsixPlaybook {
    <#
    .SYNOPSIS
        Loads playbook files from disk and returns the parsed objects.
 
    .DESCRIPTION
        Scans every *.ps1 under -SearchPath (default: the module's
        playbooks\ folder), dot-sources each, and collects the returned
        hashtables. Bad playbooks (missing Name, missing Steps, etc.) are
        skipped with a warning so one bad file doesn't break the bus.
 
    .PARAMETER SearchPath
        Override the default search roots. Pass one or more directory
        paths; each is scanned recursively for *.ps1 files.
 
    .OUTPUTS
        [pscustomobject[]] one per loaded playbook (PSTypeName MsixPlaybook).
 
    .EXAMPLE
        Get-MsixPlaybook | Format-Table Name, Description
    #>

    [CmdletBinding()]
    [OutputType([object[]])]
    param([string[]]$SearchPath)

    $roots = @($SearchPath; $script:MsixPlaybookSearchRoots) | Where-Object { $_ } | Sort-Object -Unique

    $loaded = @()
    foreach ($root in $roots) {
        if (-not (Test-Path -LiteralPath $root)) { continue }
        foreach ($file in Get-ChildItem -LiteralPath $root -Filter '*.ps1' -File -Recurse -ErrorAction SilentlyContinue) {
            try {
                $pb = & $file.FullName
                if (-not $pb)                       { Write-MsixLog Warning "Playbook '$($file.Name)' returned nothing — skipped."; continue }
                if (-not $pb.Name)                  { Write-MsixLog Warning "Playbook '$($file.Name)' has no Name — skipped."; continue }
                if (-not $pb.Steps -or $pb.Steps.Count -eq 0) { Write-MsixLog Warning "Playbook '$($pb.Name)' has no Steps — skipped."; continue }
                $loaded += [pscustomobject]@{
                    PSTypeName  = 'MsixPlaybook'
                    Name        = [string]$pb.Name
                    Description = [string]($pb.Description)
                    Match       = if ($pb.Match) { $pb.Match } else { @{} }
                    Steps       = @($pb.Steps)
                    SourceFile  = $file.FullName
                }
            } catch {
                Write-MsixLog Warning "Failed to load playbook '$($file.Name)': $_"
            }
        }
    }
    return $loaded
}

function Find-MsixPlaybook {
    <#
    .SYNOPSIS
        Returns the playbook(s) whose Match block fits the supplied package
        fingerprint.
 
    .DESCRIPTION
        Reads the package's manifest (no full unpack — uses Get-MsixManifest)
        to extract Identity Name, the first Application's Executable leaf,
        and Publisher subject. Then evaluates each loaded playbook's Match
        block against those values. Multiple playbooks can match — the
        caller chooses (e.g. by Name) which to run.
 
        Match keys (all optional; missing key = any):
          IdentityName regex matched against Package/Identity/@Name
          ExecutableLeaf regex matched against the first Application
                             Executable attribute's leaf filename
          PublisherSubject regex matched against Package/Identity/@Publisher
 
    .PARAMETER PackagePath
        .msix or AppxManifest.xml to fingerprint.
 
    .PARAMETER SearchPath
        Forwarded to Get-MsixPlaybook.
 
    .OUTPUTS
        [pscustomobject[]] matched playbooks. Empty array when none match.
    #>

    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory)] [string]$PackagePath,
        [string[]]$SearchPath
    )

    $playbooks = Get-MsixPlaybook -SearchPath $SearchPath
    if (-not $playbooks) { return @() }

    [xml]$manifest = Get-MsixManifest -Path $PackagePath
    $identityName    = $manifest.Package.Identity.GetAttribute('Name')
    $publisherSubj   = $manifest.Package.Identity.GetAttribute('Publisher')
    $firstApp        = @($manifest.Package.Applications.Application) | Select-Object -First 1
    $exeAttr         = if ($firstApp) { $firstApp.GetAttribute('Executable') } else { $null }
    $exeLeaf         = if ($exeAttr) { $exeAttr.Split('\')[-1] } else { $null }

    $matched = @()
    foreach ($pb in $playbooks) {
        $m = $pb.Match
        if ($m.IdentityName     -and $identityName  -notmatch $m.IdentityName)     { continue }
        if ($m.ExecutableLeaf   -and ($null -eq $exeLeaf -or $exeLeaf -notmatch $m.ExecutableLeaf)) { continue }
        if ($m.PublisherSubject -and $publisherSubj -notmatch $m.PublisherSubject) { continue }
        $matched += $pb
    }
    return $matched
}

function Invoke-MsixPlaybook {
    <#
    .SYNOPSIS
        Runs the Steps of a playbook against a package as a single signed
        pass (or unsigned with -SkipSigning / -NoSign).
 
    .DESCRIPTION
        Each Step is a hashtable with two keys:
          Cmdlet = '<Cmdlet-Name>'
          Args = @{ Param1 = 'Value1'; ... }
 
        For every step the playbook bus:
          1. Verifies the cmdlet exists and is from this module (refuses to
             invoke arbitrary commands).
          2. Injects -PackagePath (or -MSIXFolder when the cmdlet has it
             instead) so the playbook author doesn't repeat it for every
             step.
          3. Forces -SkipSigning on every intermediate step so the final
             signing pass is a single deterministic call at the end.
          4. With -DryRun, prints the plan and stops without running.
 
    .PARAMETER PackagePath
        .msix to act on.
 
    .PARAMETER Playbook
        A playbook object from Get-MsixPlaybook / Find-MsixPlaybook, OR a
        playbook NAME — when a name is given, the bus matches it against
        Find-MsixPlaybook results, requiring an exact name match.
 
    .PARAMETER DryRun
        Print the resolved plan and exit without executing.
 
    .PARAMETER OutputPath / Pfx / PfxPassword / SkipSigning
        Forwarded to the final signing call. Same semantics as
        Invoke-MsixAutoFixFromAnalysis.
 
    .EXAMPLE
        Find-MsixPlaybook -PackagePath app.msix |
            Invoke-MsixPlaybook -PackagePath app.msix -DryRun
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)] [string]$PackagePath,
        [Parameter(Mandatory, ValueFromPipeline)] $Playbook,
        [switch]$DryRun,
        [string]$OutputPath,
        [Alias('NoSign')] [switch]$SkipSigning,
        [string]$Pfx,
        [SecureString]$PfxPassword
    )

    process {
    # Resolve playbook by name if a string was supplied.
    if ($Playbook -is [string]) {
        $candidates = @(Find-MsixPlaybook -PackagePath $PackagePath | Where-Object Name -eq $Playbook)
        if ($candidates.Count -eq 0) { throw "No playbook named '$Playbook' matches '$PackagePath'." }
        if ($candidates.Count -gt 1) { throw "Multiple playbooks named '$Playbook' — pass the object instead." }
        $Playbook = $candidates[0]
    }

    Write-MsixLog Info "Playbook: $($Playbook.Name) ($($Playbook.Steps.Count) step(s))"

    $current = if ($OutputPath -and ($OutputPath -ne $PackagePath)) {
        if (-not $DryRun) { Copy-Item -LiteralPath $PackagePath -Destination $OutputPath -Force }
        $OutputPath
    } else { $PackagePath }

    $i = 0
    foreach ($step in $Playbook.Steps) {
        $i++
        $cmdletName = [string]$step.Cmdlet
        $cmd        = Get-Command $cmdletName -ErrorAction SilentlyContinue
        if (-not $cmd) { throw "Step $i references unknown cmdlet '$cmdletName'." }
        if ($cmd.Source -ne 'MSIX' -and $cmd.ModuleName -ne 'MSIX') {
            # Defence-in-depth — only run cmdlets owned by this module.
            throw "Step $i references '$cmdletName' which is not from the MSIX module (source: $($cmd.Source))."
        }

        $callArgs = @{}
        if ($step.Args) { foreach ($k in $step.Args.Keys) { $callArgs[$k] = $step.Args[$k] } }

        # Inject the right path parameter — Add-MsixCapability/PSF/etc. use
        # -PackagePath; a few _MsixFolder-style internals use -MSIXFolder
        # which we never route here.
        if (-not $callArgs.ContainsKey('PackagePath') -and $cmd.Parameters.ContainsKey('PackagePath')) {
            $callArgs['PackagePath'] = $current
        }
        # Force SkipSigning on every intermediate step so we sign once at end.
        if ($cmd.Parameters.ContainsKey('SkipSigning') -and -not $callArgs.ContainsKey('SkipSigning')) {
            $callArgs['SkipSigning'] = $true
        }

        Write-MsixLog Info " Step $i / $($Playbook.Steps.Count): $cmdletName"
        foreach ($k in $callArgs.Keys) { Write-MsixLog Debug " $k = $($callArgs[$k])" }

        if (-not $DryRun -and $PSCmdlet.ShouldProcess($current, "Playbook '$($Playbook.Name)' step ${i}: $cmdletName")) {
            & $cmd @callArgs
        }
    }

    if ($DryRun) {
        Write-MsixLog Info '[DryRun] Plan only — package unchanged.'
        return [pscustomobject]@{
            Playbook    = $Playbook.Name
            PackagePath = $current
            DryRun      = $true
            Steps       = $Playbook.Steps.Count
        }
    }

    if (-not $SkipSigning) {
        Write-MsixLog Info '==> Sign'
        Invoke-MsixSigning -PackagePath $current -Pfx $Pfx -PfxPassword $PfxPassword
    } else {
        Write-MsixLog Info 'NoSign requested; package left unsigned.'
    }

    return [pscustomobject]@{
        Playbook    = $Playbook.Name
        PackagePath = $current
        DryRun      = $false
        Steps       = $Playbook.Steps.Count
    }
    }   # end process block
}