Public/New-Win32ToolkitManualApp.ps1

function New-Win32ToolkitManualApp {
<#
.SYNOPSIS
    Creates a Win32 packaging project for an app that is NOT in winget.
.DESCRIPTION
    Scaffolds a PSADT v4 project from an operator-supplied installer, applies the org template, and
    writes the data-driven AppConfig.json — the same downstream automation (sandbox capture →
    uninstall, testing, packaging, upload) used by the winget flow then applies.
 
    Input is hybrid: pass what you can as parameters; you are prompted for any missing required field
    (Name, Version, Architecture, SourcePath).
 
    Two modes:
    - Easy — provide -SilentArgs, or an MSI (Zero-Config), or an MSIX/APPX package (installed via
              Add-AppxPackage/provisioning, uninstalled by package identity): the install runs
              data-driven. Add -Continue (or -RunTest/-PackageIntune/-PublishIntune) to finalise.
    - Hard — pass -Advanced (or an EXE with no -SilentArgs): the Install region of
              Invoke-AppDeployToolkit.ps1 is left for you to author. The uninstall stays automated.
              Finish later with Complete-Win32ToolkitManualApp.
 
    
.PARAMETER Name
    Application display name (required; prompted if omitted).
.PARAMETER Version
    Application version (required; prompted if omitted).
.PARAMETER Architecture
    x64 | x86 | arm64 (required; prompted if omitted).
.PARAMETER SourcePath
    Installer file, or a folder of files, copied into the project's Files\ folder (required).
.PARAMETER Publisher
    Publisher / vendor (used for the PSADT AppVendor and the Intune app-shell publisher).
.PARAMETER Description
    App description (Intune app shell).
.PARAMETER InformationUrl
    Information URL (Intune app shell).
.PARAMETER SilentArgs
    Silent-install switches. Providing them selects the "easy" (data-driven) install.
.PARAMETER IconPath
    Optional local image copied to Assets\AppIcon.png.
.PARAMETER TemplateName
    Org template to apply (under <BasePath>\Templates).
.PARAMETER BasePath
    Base folder (registry-backed default; prompts on first run).
.PARAMETER Reconfigure
    Re-prompt for the base folder and save the new value to the registry, ignoring any stored value.
.PARAMETER Advanced
    Hard app — leave the Install region for manual authoring.
.PARAMETER Force
    Overwrite an existing project folder of the same name without prompting.
.PARAMETER Continue
    Easy app — run the finalize phase (sandbox capture → uninstall → optional test/package/publish) inline.
.PARAMETER RunTest
    Test scenario(s) to run during the finalize phase: 'InstallUninstall', 'Update', or both. Implies
    -Continue behavior for easy apps.
.PARAMETER PackageIntune
    Package the project into a .intunewin during the finalize phase.
.PARAMETER PublishIntune
    Upload the packaged app to Microsoft Intune during the finalize phase (implies packaging).
.PARAMETER PublishUpdate
    Also publish the UPDATE app (a second, requirement-gated Intune app of the same version).
.PARAMETER DependsOn
    Dependencies to declare: 'winget:<Id>', 'project:<Template>\<Name>', or 'intune:<AppGuid>'
    references. Installed first in the test guest and related to the Intune app at publish time.
.PARAMETER DependencyType
    How Intune treats the declared dependencies: 'autoInstall' (default) or 'detect'.
.EXAMPLE
    # Easy app, end to end
    New-Win32ToolkitManualApp -Name 'Acme Tool' -Version '3.1.0' -Architecture x64 `
        -SourcePath 'C:\src\AcmeTool.exe' -SilentArgs '/S' -Publisher 'Acme' -TemplateName 'Contoso' `
        -Continue -RunTest InstallUninstall -PublishIntune
.EXAMPLE
    # Hard app — scaffold, then finish after editing the Install region
    New-Win32ToolkitManualApp -Name 'Legacy CAD' -Version '12.0' -Architecture x64 `
        -SourcePath 'C:\src\LegacyCAD\' -Advanced -TemplateName 'Contoso'
    Complete-Win32ToolkitManualApp -ProjectPath 'C:\Win32Apps\Projects\Contoso\Legacy_CAD_x64_12.0' -RunTest InstallUninstall
#>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [string]$Name,
        [string]$Version,
        [ValidateSet('x64', 'x86', 'arm64')]
        [string]$Architecture,
        [string]$SourcePath,
        [string]$Publisher,
        [string]$Description,
        [string]$InformationUrl,
        [string]$SilentArgs,
        [string]$IconPath,
        [string]$TemplateName,
        [string]$BasePath,
        [switch]$Reconfigure,
        [switch]$Advanced,
        [switch]$Force,
        [switch]$Continue,
        [ValidateSet('InstallUninstall', 'Update')]
        [string[]]$RunTest,
        [switch]$PackageIntune,
        [switch]$PublishIntune,
        # Also publish the update app (2nd app, same version, requirement-gated to installed devices).
        [switch]$PublishUpdate,

        # Apps Intune must install BEFORE this one (e.g. a Visual C++ redistributable).
        # 'winget:<id>' | 'project:<Template>\<Name>' | 'intune:<guid>' | a bare reference.
        # Declared into AppConfig.json; ALSO staged into the test/capture guest so the run installs the
        # dependency first, exactly as Intune does on a real device.
        [string[]]$DependsOn,

        [ValidateSet('autoInstall', 'detect')]
        [string]$DependencyType = 'autoInstall'
    )

    try {
        Write-Host 'Manual Win32 App Packager (non-winget)' -ForegroundColor Cyan
        Write-Host '======================================' -ForegroundColor Cyan

        # ── Base folder + hybrid input ────────────────────────────────────────────
        $BasePath = Get-Win32ToolkitBasePath -BasePath $BasePath -Reconfigure:$Reconfigure
        Write-Host "Base folder: $BasePath" -ForegroundColor DarkGray

        if ([string]::IsNullOrWhiteSpace($Name))       { $Name = (Read-Host 'App name').Trim() }
        if ([string]::IsNullOrWhiteSpace($Version))    { $Version = (Read-Host 'Version').Trim() }
        if ([string]::IsNullOrWhiteSpace($Architecture)) {
            do {
                $Architecture = (Read-Host 'Architecture (x64/x86/arm64) [x64]').Trim()
                if ([string]::IsNullOrWhiteSpace($Architecture)) { $Architecture = 'x64' }
            } while ($Architecture -notin @('x64', 'x86', 'arm64'))
        }
        if ([string]::IsNullOrWhiteSpace($SourcePath)) { $SourcePath = (Read-Host 'Installer path (file or folder)').Trim('"', ' ') }

        if ([string]::IsNullOrWhiteSpace($Name) -or [string]::IsNullOrWhiteSpace($Version) -or [string]::IsNullOrWhiteSpace($SourcePath)) {
            throw 'Name, Version, and SourcePath are required.'
        }
        if (-not (Test-Path -LiteralPath $SourcePath)) { throw "Source path not found: $SourcePath" }

        # ── Org template ──────────────────────────────────────────────────────────
        $script:OrgTemplate = Get-OrgTemplate -TemplateName $TemplateName -BasePath $BasePath

        # ── Project scaffold under Projects\<Template>\<name> ─────────────────────
        $projectName = '{0}_{1}_{2}' -f (Sanitize-ProjectName -Name $Name),
                                        (Sanitize-ProjectName -Name $Architecture),
                                        (Sanitize-ProjectName -Name $Version)

        $paths       = Get-Win32ToolkitPaths -BasePath $BasePath
        $templateSeg = Sanitize-ProjectName -Name $script:OrgTemplate.TemplateName
        if ([string]::IsNullOrWhiteSpace($templateSeg)) { $templateSeg = 'Default' }
        $projectsDir = Join-Path $paths.Projects $templateSeg
        if (-not (Test-Path $projectsDir)) { New-Item -Path $projectsDir -ItemType Directory -Force | Out-Null }

        Write-Host "Template folder: $templateSeg" -ForegroundColor DarkGray
        Write-Host "Project name : $projectName" -ForegroundColor Cyan

        if (-not (Create-PSADTProject -ProjectName $projectName -ProjectPath $projectsDir -Force:$Force)) {
            throw 'Failed to create PSADT project.'
        }
        $projectFullPath = Join-Path $projectsDir $projectName
        $filesPath       = Join-Path $projectFullPath 'Files'

        # ── Copy installer + detect type ──────────────────────────────────────────
        Add-Win32ToolkitInstallerFiles -SourcePath $SourcePath -FilesPath $filesPath | Out-Null
        $fileInfo = Get-InstallerFileInfo -FilesPath $filesPath
        if (-not $fileInfo.FileName) { throw "No installer (msi/exe/msix/appx) detected in: $filesPath" }
        Write-Host "Installer : $($fileInfo.FileName) ($($fileInfo.Type.ToUpper()))" -ForegroundColor Green

        # ── Write AppConfig (App + Installer) ─────────────────────────────────────
        $isMsi = ($fileInfo.Type -eq 'msi')
        $cfg = Get-Win32ToolkitAppConfig -ProjectPath $projectFullPath
        $cfg | Add-Member -NotePropertyName App -NotePropertyValue ([pscustomobject]@{
            Vendor         = if ($Publisher) { $Publisher } else { '' }
            Name           = if ($isMsi) { '' } else { $Name }   # empty ⇒ Zero-Config MSI
            DisplayName    = $Name                                # always populated (drives tattoo + detection)
            Version        = $Version
            Arch           = $Architecture
            ScriptAuthor   = if ($script:OrgTemplate -and $script:OrgTemplate.AppScriptAuthor) { $script:OrgTemplate.AppScriptAuthor } else { '' }
            ScriptDate     = (Get-Date -Format 'yyyy-MM-dd')
            Description    = if ($Description) { $Description } else { '' }
            InformationUrl = if ($InformationUrl) { $InformationUrl } else { '' }
        }) -Force
        $isAppx = $fileInfo.Type -in @('msix', 'appx')
        $cfg | Add-Member -NotePropertyName Installer -NotePropertyValue ([pscustomobject]@{
            Type       = $fileInfo.Type
            FileName   = $fileInfo.FileName
            SilentArgs = if ($isMsi -or $isAppx) { '' } elseif ($SilentArgs) { $SilentArgs } else { '' }
        }) -Force
        if (-not ($cfg.PSObject.Properties.Name -contains 'ProcessesToClose')) {
            $cfg | Add-Member -NotePropertyName ProcessesToClose -NotePropertyValue @() -Force
        }

        # ---- Org-template Intune publish defaults + doc branding (data only) ----
        # Mirrors Configure-PSADTForInstaller: a manual app never goes through that function, so without
        # this every org-template Intune default (min-OS, restart behavior, max runtime, description
        # boilerplate, privacy URL) and the doc branding were silently dropped for manual apps.
        if ($script:OrgTemplate -and $script:OrgTemplate.PSObject.Properties['IntuneDefaults'] -and $script:OrgTemplate.IntuneDefaults) {
            $cfg | Add-Member -NotePropertyName IntuneDefaults -NotePropertyValue (New-Win32ToolkitIntuneDefaults -Template $script:OrgTemplate) -Force
        }
        if ($script:OrgTemplate) {
            $companyM   = if ($script:OrgTemplate.PSObject.Properties['CompanyName']) { [string]$script:OrgTemplate.CompanyName } else { '' }
            $docFooterM = if ($script:OrgTemplate.PSObject.Properties['DocFooter'])   { [string]$script:OrgTemplate.DocFooter }   else { '' }
            if (-not [string]::IsNullOrWhiteSpace($companyM) -or -not [string]::IsNullOrWhiteSpace($docFooterM)) {
                $cfg | Add-Member -NotePropertyName Branding -NotePropertyValue ([pscustomobject]@{
                    CompanyName = $companyM
                    DocFooter   = $docFooterM
                }) -Force
            }
        }

        Set-Win32ToolkitAppConfig -ProjectPath $projectFullPath -Config $cfg | Out-Null
        Write-Host '✓ Wrote SupportFiles\AppConfig.json' -ForegroundColor Green

        # Declare Intune app dependencies (data-only) now that AppConfig.json exists. They are resolved to
        # real Intune app ids at publish time, and staged into the test/capture guest so the run installs
        # them BEFORE this app — the same order Intune uses on a device.
        if ($DependsOn) {
            $declared = Set-Win32ToolkitAppDependency -ProjectPath $projectFullPath -DependsOn $DependsOn -DependencyType $DependencyType
            Write-Host "✓ Dependencies : $((@($declared) | ForEach-Object { "$($_.Source):$($_.Ref)" }) -join ', ')" -ForegroundColor Green
        }

        # MSIX/APPX: identity-driven uninstall data, written at configure time (capture-independent).
        if ($isAppx) {
            if (-not (Update-PSADTMsixUninstallLogic -ProjectPath $projectFullPath)) {
                Write-Warning 'MSIX uninstall data could not be written — the package would have no working uninstall.'
            }
        }

        # ── Patch deploy script. Hard app = manual install region; uninstall always automated.
        # Only an EXE without silent args is hard (nothing to run automatically) — MSI uses
        # Zero-Config and msix/appx install via Add-AppxPackage/provisioning, no switches needed.
        $manual = [bool]$Advanced -or ($fileInfo.Type -eq 'exe' -and [string]::IsNullOrWhiteSpace($SilentArgs))
        $patched = Set-PSADTDataDrivenScript -ScriptPath (Join-Path $projectFullPath 'Invoke-AppDeployToolkit.ps1') -ManualInstall:$manual
        if ($patched) {
            Write-Host ("✓ Deploy script patched ({0})" -f ($(if ($manual) { 'manual install region' } else { 'data-driven install' }))) -ForegroundColor Green
        }
        else {
            Write-Warning 'Deploy-script patching did not complete cleanly (see warnings above) — review the project before packaging.'
        }

        # ── Org template + optional icon ──────────────────────────────────────────
        if ($script:OrgTemplate) {
            Write-Verbose 'Applying org template...'
            Apply-OrgTemplate -ProjectPath $projectFullPath -Template $script:OrgTemplate | Out-Null
        }
        if ($IconPath -and (Test-Path -LiteralPath $IconPath)) {
            $assets = Join-Path $projectFullPath 'Assets'
            if (-not (Test-Path $assets)) { New-Item -Path $assets -ItemType Directory -Force | Out-Null }
            Copy-Item -LiteralPath $IconPath -Destination (Join-Path $assets 'AppIcon.png') -Force
            # Record provenance so the capture-time icon reconcile (finalize) keeps this operator-supplied
            # icon over the one extracted from the install run.
            Set-Win32ToolkitIconSource -ProjectPath $projectFullPath -Source 'manual'
            Write-Host '✓ Applied custom icon' -ForegroundColor Green
        }

        Write-Host "`n✓ Manual app project created: $projectFullPath" -ForegroundColor Green
        $appInfo = [pscustomobject]@{ Name = $Name; Version = $Version; Id = $Name; Source = 'manual' }

        # ── Hard app: stop for manual authoring ───────────────────────────────────
        if ($manual) {
            Write-Host ''
            Write-Host 'HARD APP — next steps:' -ForegroundColor Yellow
            Write-Host " 1. Edit the Install region in:" -ForegroundColor White
            Write-Host " $projectFullPath\Invoke-AppDeployToolkit.ps1" -ForegroundColor Gray
            Write-Host ' (write your Pre-Install / Install / Post-Install logic).' -ForegroundColor Gray
            Write-Host ' 2. Then finalise (auto uninstall + test + package + upload):' -ForegroundColor White
            Write-Host " Complete-Win32ToolkitManualApp -ProjectPath '$projectFullPath' -RunTest InstallUninstall" -ForegroundColor Cyan
            return [pscustomobject]@{ ProjectPath = $projectFullPath; ProjectName = $projectName; Mode = 'Advanced' }
        }

        # ── Easy app: finalise inline if asked ────────────────────────────────────
        if ($Continue -or $RunTest -or $PackageIntune -or $PublishIntune -or $PublishUpdate) {
            $finalize = @{ ProjectPath = $projectFullPath; ProjectName = $projectName; AppInfo = $appInfo }
            if ($RunTest) { $finalize['RunTest'] = $RunTest }   # omit when null (ValidateSet rejects $null)
            Invoke-Win32ToolkitFinalize @finalize -PackageIntune:$PackageIntune -PublishIntune:$PublishIntune -PublishUpdate:$PublishUpdate
        }
        else {
            Write-Host "`nEasy app scaffolded. Finalise with:" -ForegroundColor Cyan
            Write-Host " Complete-Win32ToolkitManualApp -ProjectPath '$projectFullPath' -RunTest InstallUninstall" -ForegroundColor Cyan
            Write-Host ' (or re-run this command with -Continue).' -ForegroundColor Gray
        }
        return [pscustomobject]@{ ProjectPath = $projectFullPath; ProjectName = $projectName; Mode = 'Easy' }
    }
    catch {
        Write-Error "New-Win32ToolkitManualApp failed: $($_.Exception.Message)"
    }
    finally {
        # Clear the module-scoped org template so a stale value can't leak into the next
        # command in the same session. Runs on both success and failure. $null is the
        # cleared state the module initialises at load.
        $script:OrgTemplate = $null
    }
}