Private/Rename-InstallerFile.ps1

function Rename-InstallerFile {
    <#
    .SYNOPSIS
        Normalizes the installer filename in a project's Files\ folder to AppName_arch_version.ext.
    .DESCRIPTION
        For each supported installer extension (msi/exe/msix/appx) this renames the ONE unambiguous
        installer of that extension to the clean base name.
 
        It used to loop over EVERY file of a given extension and rename each one to the SAME target
        name, doing `Remove-Item $newPath -Force` first to clear the way. With two .exe files in Files\
        (a vendor drop of setup.exe + helper.exe, an installer beside its uninstaller, ...) the first
        file was renamed to the target name and then DELETED by the second iteration — silent data loss,
        the installer simply gone. The same hole existed when a correctly-named file was already present:
        it was filtered out of the candidate list but still sat at $newPath, so it got deleted too.
 
        Now: if an extension has more than one candidate, the rename is SKIPPED for that extension and
        the collision is named in a warning. Nothing is deleted, ever.
 
        The clean name is cosmetic — nothing downstream depends on it. Get-InstallerFileInfo selects the
        installer out of Files\ by EXTENSION PRECEDENCE (msi > exe > msix > appx), taking the first match
        by name; it does not inspect content, and it now warns when the choice is ambiguous. So skipping
        the rename leaves the pipeline working, but on an ambiguous folder the operator — not this
        function — has to decide which binary is the real installer.
 
        PSADT's own binaries (Invoke-AppDeployToolkit.exe, ServiceUI.exe) are not installer candidates,
        matching the exclusion in Get-InstallerFileInfo, so a stray copy of one of them next to the real
        installer does not block the rename.
    .PARAMETER FilesPath
        The project's Files\ folder.
    #>

    [CmdletBinding()]
    param(
        [string]$FilesPath,
        [string]$AppName,
        [string]$Version,
        [string]$Architecture
    )

    # Build a clean base name: AppName_Architecture_Version (no spaces, filesystem-safe chars only)
    $cleanName = ($AppName -replace '[^A-Za-z0-9._-]', '_') -replace '_+', '_'
    $cleanVer  = ($Version  -replace '[^A-Za-z0-9._-]', '_') -replace '_+', '_'
    $cleanArch = ($Architecture -replace '[^A-Za-z0-9]', '').ToLowerInvariant()
    $baseName  = "${cleanName}_${cleanArch}_${cleanVer}"

    $renamed = $false
    # Bundles keep their own extension when renamed (App_x64_1.0.msixbundle) — the extension is
    # cosmetic; Get-InstallerFileInfo maps both to Type 'msix'/'appx'.
    foreach ($ext in @((Get-Win32ToolkitInstallerExtension) | ForEach-Object { $_.TrimStart('.') })) {
        # ALL files of this extension are candidates — including one that already carries the target
        # name. Excluding it from this list is what let the old code delete it.
        $candidates = @(Get-ChildItem -Path $FilesPath -Filter "*.$ext" -File -ErrorAction SilentlyContinue |
            Where-Object {
                $_.Name -notlike '*Invoke-AppDeployToolkit*' -and
                $_.Name -notlike '*ServiceUI*'
            })

        if ($candidates.Count -eq 0) { continue }

        if ($candidates.Count -gt 1) {
            # Ambiguous: we cannot know which one the installer is, and collapsing them onto one name
            # would destroy every file but the last. Leave them all alone and say so.
            Write-Warning ("Files\ contains {0} .{1} files ({2}) — cannot normalize the installer filename without overwriting one of them, so all of them are left as-is. Packaging continues, but which one is treated as THE installer is then decided by name order, not by content — remove the file(s) that are not the installer from Files\." -f `
                $candidates.Count, $ext, (($candidates | ForEach-Object { $_.Name }) -join ', '))
            continue
        }

        $file = $candidates[0]
        if ($file.BaseName -eq $baseName) { continue }   # already clean

        $newName = "$baseName.$ext"
        $newPath = Join-Path $FilesPath $newName

        # Belt and braces: the only way $newPath can exist here is if it is an excluded PSADT binary
        # (i.e. the operator's app is literally named ServiceUI). Never delete it — skip instead.
        if ((Test-Path -LiteralPath $newPath) -and ($newPath -ne $file.FullName)) {
            Write-Warning "Cannot rename '$($file.Name)' to '$newName' — a file with that name already exists in Files\. Leaving it as-is."
            continue
        }

        # -Force here only covers read-only/hidden source files; it can never clobber a sibling now that
        # the destination is guaranteed free by the check above.
        Rename-Item -LiteralPath $file.FullName -NewName $newName -Force
        Write-Host "Renamed: $($file.Name) -> $newName" -ForegroundColor Cyan
        $renamed = $true
    }

    if (-not $renamed) {
        Write-Host 'Installer filename already clean — no rename needed.' -ForegroundColor Gray
    }
}