Resources/NerdioShellApps.psm1

[CmdletBinding()]
param ()

# Configure the environment
$ProgressPreference = "SilentlyContinue"
$InformationPreference = "Continue"
$ErrorActionPreference = "Stop"
if ([System.Enum]::IsDefined([System.Net.SecurityProtocolType], "Tls13")) {
    [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls13
}
else {
    [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
}

# Set up global variables for credentials and environment
$script:creds = [PSCustomObject] @{
    ClientId       = $null
    ClientSecret   = $null
    TenantId       = $null
    ApiScope       = $null
    NmeUri         = $null
    SubscriptionId = $null
    OAuthToken     = $null
}

$script:env = [PSCustomObject] @{
    resourceGroupName  = $null
    storageAccountName = $null
    containerName      = $null
    nmeHost            = $null
}

$psStyleVar = Get-Variable -Name "PSStyle" -Scope Global -ErrorAction "SilentlyContinue"
if ($psStyleVar) {
    $script:logStyle = $psStyleVar.Value
}
else {
    $global:PSStyle = [PSCustomObject]@{
        Foreground = [PSCustomObject]@{
            Green  = ""
            Cyan   = ""
            Yellow = ""
        }
    }
    $script:logStyle = $global:PSStyle
}

function Get-TrimmedValueOrNull {
    param (
        [Parameter(Mandatory = $false)]
        [System.String] $Value,

        [Parameter(Mandatory = $false)]
        [System.Management.Automation.SwitchParameter] $TrimTrailingSlash
    )

    if ([string]::IsNullOrWhiteSpace($Value)) {
        return $null
    }

    $trimmedValue = $Value.Trim()
    if ($TrimTrailingSlash) {
        return $trimmedValue.TrimEnd('/')
    }

    return $trimmedValue
}

function Set-NmeCredentials {
    param (
        [Parameter(Mandatory = $false)]
        [System.String] $ClientId,

        [Parameter(Mandatory = $false)]
        [System.String] $ClientSecret,

        [Parameter(Mandatory = $false)]
        [System.String] $TenantId,

        [Parameter(Mandatory = $false)]
        [System.String] $ApiScope,

        [Parameter(Mandatory = $false)]
        [System.String] $SubscriptionId,

        [Parameter(Mandatory = $false)]
        [System.String] $OAuthToken,

        [Parameter(Mandatory = $false)]
        [System.String] $ResourceGroupName,

        [Parameter(Mandatory = $false)]
        [System.String] $StorageAccountName,

        [Parameter(Mandatory = $false)]
        [System.String] $ContainerName,

        [Parameter(Mandatory = $false)]
        [System.String] $NmeHost
    )

    $script:creds = [PSCustomObject] @{
        ClientId       = Get-TrimmedValueOrNull -Value $ClientId
        ClientSecret   = Get-TrimmedValueOrNull -Value $ClientSecret
        TenantId       = Get-TrimmedValueOrNull -Value $TenantId
        ApiScope       = Get-TrimmedValueOrNull -Value $ApiScope
        NmeUri         = $null
        SubscriptionId = Get-TrimmedValueOrNull -Value $SubscriptionId
        OAuthToken     = Get-TrimmedValueOrNull -Value $OAuthToken
    }

    $script:env = [PSCustomObject] @{
        resourceGroupName  = Get-TrimmedValueOrNull -Value $ResourceGroupName
        storageAccountName = Get-TrimmedValueOrNull -Value $StorageAccountName
        containerName      = Get-TrimmedValueOrNull -Value $ContainerName
        nmeHost            = Get-TrimmedValueOrNull -Value $NmeHost -TrimTrailingSlash
    }
}

function Connect-Nme {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false)]
        [System.Management.Automation.SwitchParameter] $PassThru
    )
    try {
        $tokenUri = if ([string]::IsNullOrWhiteSpace($script:creds.OAuthToken)) {
            "https://login.microsoftonline.com/$($script:creds.TenantId)/oauth2/v2.0/token"
        }
        else {
            $script:creds.OAuthToken
        }

        if (-not [System.Uri]::IsWellFormedUriString($tokenUri, [System.UriKind]::Absolute)) {
            throw "Invalid OAuth token endpoint URI: '$tokenUri'"
        }

        $params = @{
            Uri             = $tokenUri
            Body            = @{
                "grant_type"  = "client_credentials"
                scope         = $script:creds.ApiScope
                client_id     = $script:creds.ClientId
                client_secret = $script:creds.ClientSecret
            }
            Headers         = @{
                "Accept"        = "application/json, text/plain, */*"
                "Content-Type"  = "application/x-www-form-urlencoded"
                "Cache-Control" = "no-cache"
            }
            Method          = "POST"
            UseBasicParsing = $true
        }
        $script:Token = Invoke-RestMethod @params
        Write-Information -MessageData "$($PSStyle.Foreground.Green)Authenticated to Nerdio Manager."
        Write-Information -MessageData "$($PSStyle.Foreground.Cyan)Token expires: $((Get-Date).AddSeconds($script:Token.expires_in).ToString())"
        if ($PassThru) {
            return $script:Token
        }
    }
    catch {
        throw "Failed to authenticate to Nerdio Manager: $($_.Exception.Message)"
    }
}

function Get-TempDirectory {
    if ($env:OS -eq 'Windows_NT') {
        if (-not [string]::IsNullOrWhiteSpace($env:TEMP)) {
            return $env:TEMP
        }

        if (-not [string]::IsNullOrWhiteSpace($env:TMP)) {
            return $env:TMP
        }

        return (Join-Path -Path $env:USERPROFILE -ChildPath 'AppData\Local\Temp')
    }

    if (-not [string]::IsNullOrWhiteSpace($env:TMPDIR)) {
        return $env:TMPDIR
    }

    return '/tmp'
}

function Get-MD5Hash {
    param (
        [Parameter(Mandatory)]
        [System.String] $InputString
    )

    $bytes = [System.Text.Encoding]::UTF8.GetBytes($InputString)
    $md5 = [System.Security.Cryptography.MD5]::Create()
    $hash = $md5.ComputeHash($bytes)
    -join ($hash | ForEach-Object { $_.ToString('x2') })
}

function Get-RemoteFileHash {
    param (
        [Parameter(Mandatory = $true)]
        [System.String] $Url
    )
    try {
        $WebClient = [System.Net.WebClient]::new()
        Write-Information -MessageData "$($PSStyle.Foreground.Cyan)Opening remote stream: $Url"
        $Stream = $WebClient.OpenRead($Url)
        if ($null -eq $Stream) {
            throw "Failed to open remote stream. Stream is null."
        }
        Write-Information -MessageData "$($PSStyle.Foreground.Cyan)Calculating SHA256 hash."
        $hash = Get-FileHash -Algorithm "SHA256" -InputStream $Stream
        Write-Information -MessageData "$($PSStyle.Foreground.Cyan)Hash: $($hash.Hash)"
        return $hash.Hash
    }
    catch {
        Write-Error "Error occurred: $($_.Exception.Message)"
    }
    finally {
        if ($Stream) { $Stream.Dispose() }
        if ($WebClient) { $WebClient.Dispose() }
    }
}

function Get-AppMetadata {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline)]
        [PSCustomObject] $Definition
    )
    process {
        switch ($Definition.source.type) {
            "Evergreen" {
                Write-Information -MessageData "$($PSStyle.Foreground.Cyan)Query: Get-EvergreenApp -Name $($Definition.source.app) | Where-Object { $($Definition.source.filter) }"
                $Metadata = Get-EvergreenApp -Name $Definition.source.app | `
                    Where-Object { Invoke-Expression "$($Definition.source.filter)" } | `
                    Select-Object -First 1
                Write-Information -MessageData "$($PSStyle.Foreground.Cyan)Found version: $($Metadata.Version)"
                return $Metadata
            }
            "VcRedist" {
                Write-Information -MessageData "$($PSStyle.Foreground.Cyan)Query: Get-VcList | Where-Object { $($Definition.source.filter) }"
                $Metadata = Get-VcList | Where-Object { Invoke-Expression "$($Definition.source.filter)" }
                Write-Information -MessageData "$($PSStyle.Foreground.Cyan)Found version: $($Metadata.Version)"
                return $Metadata
            }
            "Static" {
                if ([System.String]::IsNullOrEmpty($Definition.source.url)) {
                    # TODO - add an object that uses local file system
                    return $null
                }
                else {
                    Write-Information -MessageData "$($PSStyle.Foreground.Cyan)Using direct source URL: $($Definition.source.url)"
                    $Metadata = [PSCustomObject] @{
                        Version = $Definition.source.version
                        URI     = $Definition.source.url
                    }
                    return $Metadata
                }
            }
        }
    }
}

function Get-ShellAppDefinition {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [System.String] $Path
    )
    process {
        if (Test-Path -Path $Path -PathType "Container") {
            Write-Information -MessageData "$($PSStyle.Foreground.Cyan)Reading Shell App definition from: $Path"
            $Definition = Get-Content -Path (Join-Path -Path $Path -ChildPath "Definition.json") | ConvertFrom-Json
            $InstallScript = Get-Content -Path (Join-Path -Path $Path -ChildPath "Install.ps1") -Raw
            $UninstallScript = Get-Content -Path (Join-Path -Path $Path -ChildPath "Uninstall.ps1") -Raw
            $DetectScript = Get-Content -Path (Join-Path -Path $Path -ChildPath "Detect.ps1") -Raw
            $Definition.detectScript = $DetectScript
            $Definition.installScript = $InstallScript
            $Definition.uninstallScript = $UninstallScript
            return $Definition
        }
        else {
            Write-Error -Message "Path does not exist or is not a directory: $Path"
            return $null
        }
    }
}

function Get-ShellApp {
    [CmdletBinding()]
    param ()
    process {
        try {
            # Get existing Shell Apps
            $params = @{
                Uri             = "https://$($script:env.nmeHost)/api/v1/shell-app"
                Headers         = @{
                    "Accept"        = "application/json; utf-8"
                    "Authorization" = "Bearer $($script:Token.access_token)"
                    "Content-Type"  = "application/x-www-form-urlencoded"
                    "Cache-Control" = "no-cache"
                }
                Method          = "GET"
                UseBasicParsing = $true
            }
            $Result = Invoke-RestMethod @params
            return $Result.items
        }
        catch {
            throw $_
        }
    }
}

function Get-ShellAppVersion {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName, ValueFromPipeline)]
        [System.String] $Id
    )
    process {
        # Get versions of existing Shell App
        $params = @{
            Uri             = "https://$($script:env.nmeHost)/api/v1/shell-app/$Id/version"
            Headers         = @{
                "Accept"        = "application/json; utf-8"
                "Authorization" = "Bearer $($script:Token.access_token)"
                "Content-Type"  = "application/x-www-form-urlencoded"
                "Cache-Control" = "no-cache"
            }
            Method          = "GET"
            UseBasicParsing = $true
        }
        $Result = Invoke-RestMethod @params
        return $Result.items
    }
}

function New-ShellAppFile {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [PSCustomObject] $AppMetadata,

        [Parameter(Mandatory = $false)]
        [System.Management.Automation.SwitchParameter] $UseRemoteUrl,

        [Parameter(Mandatory = $false)]
        [System.String] $TempPath = (Get-TempDirectory)
    )

    if ($UseRemoteUrl) {
        # Use the remote URL to get the file. This assumes an object passed from Get-AppMetadata
        if ($null -eq $AppMetadata.Sha256) {
            $Sha256 = Get-RemoteFileHash -Url $AppMetadata.URI
        }
        else {
            Write-Information -MessageData "$($PSStyle.Foreground.Cyan)Using provided SHA256 hash: $($AppMetadata.Sha256)"
            $Sha256 = $AppMetadata.Sha256
        }

        # Create a PSCustomObject with the file details
        $Output = [PSCustomObject] @{
            Sha256    = $Sha256
            FileType  = [System.IO.Path]::GetExtension($AppMetadata.URI).TrimStart('.')
            SourceUrl = $AppMetadata.URI
        }
        return $Output
    }
    else {
        if ([System.String]::IsNullOrEmpty($AppMetadata.URI)) {
            # Assume $AppMetadata.File is provided on the object
            try {
                $File = Get-Item -Path $AppMetadata.File
            }
            catch {
                Write-Error -Message "File not found: $($AppMetadata.File). Ensure the file exists."
                exit 1
            }
        }
        else {
            # If the URI is provided, download the file with Evergreen
            New-Item -Path $TempPath -ItemType "Directory" -Force | Out-Null
            $File = $AppMetadata | Save-EvergreenApp -LiteralPath $TempPath
            Write-Information -MessageData "$($PSStyle.Foreground.Cyan)Downloaded file: $($File.FullName)"
        }

        # Determine the SHA256 hash of the file
        if ($null -eq $AppMetadata.Sha256) {
            $Sha256 = (Get-FileHash -Path $File.FullName -Algorithm "SHA256").Hash
        }
        else {
            $Sha256 = $AppMetadata.Sha256
        }

        # Get storage account key; Create storage context
        Write-Information -MessageData "$($PSStyle.Foreground.Cyan)Get storage acccount key from: $($script:env.resourceGroupName) / $($script:env.storageAccountName)"
        $StorageAccountKey = (Get-AzStorageAccountKey -ResourceGroupName $script:env.resourceGroupName -Name $script:env.storageAccountName)[0].Value
        $Context = New-AzStorageContext -StorageAccountName $script:env.storageAccountName -StorageAccountKey $StorageAccountKey

        # Upload file to blob container
        # Permissions required: "Storage Blob Data Contributor"
        $BlobName = "$(Get-MD5Hash -InputString $Sha256).$(Split-Path -Path $File.FullName -Leaf)"
        Write-Information -MessageData "$($PSStyle.Foreground.Cyan)Uploading file to blob: $BlobName"
        $ProgressPreference = "Continue"
        $params = @{
            File      = $File.FullName
            Container = $script:env.containerName
            Blob      = $BlobName
            Context   = $Context
            Force     = $true
        }
        $BlobFile = Set-AzStorageBlobContent @params
        if ($null -eq $BlobFile.ICloudBlob.Uri.AbsoluteUri) {
            Write-Error -Message "Failed to upload blob file to storage account."
            exit 1
        }
        else {
            Write-Information -MessageData "$($PSStyle.Foreground.Cyan)Uploaded file to blob: $($BlobFile.ICloudBlob.Uri.AbsoluteUri)"
        }

        # Get a read-only SAS token for the blob with a long expiry time
        $params = @{
            Context    = $Context
            Container  = $script:env.containerName
            Blob       = $BlobName
            Permission = "r"
            ExpiryTime = (Get-Date).AddYears(5)
            FullUri    = $true
        }
        $SasToken = New-AzStorageBlobSASToken @params

        # Determine the source URL, if a SAS token is provided, use it; otherwise, use the blob URI
        if ($SasToken) {
            Write-Information -MessageData "$($PSStyle.Foreground.Cyan)Using SAS token for source URL."
            $SourceUrl = $SasToken
        }
        else {
            Write-Information -MessageData "$($PSStyle.Foreground.Cyan)Using blob URI for source URL."
            $SourceUrl = $BlobFile.ICloudBlob.Uri.AbsoluteUri
        }

        # Create a PSCustomObject with the file details
        $Output = [PSCustomObject] @{
            Sha256    = $Sha256
            FileType  = $File.Extension.TrimStart('.')
            SourceUrl = $SourceUrl
        }
        return $Output
    }
}

function New-ShellApp {
    <#
    .SYNOPSIS
        Automates the import and creation of a Nerdio Manager Shell App using application definitions and scripts.
 
    .DESCRIPTION
        This script streamlines the process of importing a Shell App into Nerdio Manager by:
        - Reading app definitions and scripts from a specified directory.
        - Querying the latest application version and download URL using the Evergreen module.
        - Optionally downloading the application binary and uploading it to Azure Blob Storage.
        - Calculating the SHA256 hash of the application binary.
        - Updating the app definition with version, source URL, and hash.
        - Creating the Shell App in Nerdio Manager via its API.
 
    .PARAMETER AppPath
        Path(s) to the directory containing the Shell App definition and scripts. Defaults to a Visual Studio Code app path.
 
    .PARAMETER EnvironmentFile
        Path to the JSON file containing environment variables such as resource group, storage account, and Nerdio Manager host.
 
    .PARAMETER CredentialsFile
        Path to the JSON file containing Azure and Nerdio Manager credentials.
 
    .PARAMETER TempPath
        Temporary directory path for storing downloaded application binaries.
 
    .PARAMETER UseRemoteUrl
        Switch to use the remote application URL directly instead of downloading and uploading to Azure Blob Storage.
 
    .NOTES
        - Requires Az.Accounts, Az.Storage, and Evergreen PowerShell modules.
        - Credentials are read from local JSON files and are not transmitted in plain text.
        - Azure authentication uses device authentication; update for managed identity in CI/CD pipelines.
        - Requires "Storage Blob Data Contributor" permissions to upload to Azure Blob Storage.
 
    .EXAMPLE
        .\New-ShellApp.ps1 -AppPath "C:\Apps\MyShellApp" -EnvironmentFile ".\env.json" -CredentialsFile ".\creds.json"
 
    .LINK
        https://github.com/aaron/projects/nerdio-actions
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [PSCustomObject] $Definition,

        [Parameter(Mandatory = $true)]
        [PSCustomObject] $AppMetadata,

        [Parameter(Mandatory = $false)]
        [System.Management.Automation.SwitchParameter] $UseRemoteUrl
    )
    process {
        try {
            # Create a file object for the Shell App
            $params = @{
                AppMetadata  = $AppMetadata
                UseRemoteUrl = $UseRemoteUrl
            }
            $File = New-ShellAppFile @params

            # Update the app definition
            # if ($File.FileType -eq "zip") {
            # Write-Information -MessageData "$($PSStyle.Foreground.Cyan)Using fileUnzip: true for zip files."
            # $Definition.fileUnzip = $true
            # }
            $Definition.versions[0].name = $AppMetadata.Version
            $Definition.versions[0].file.sourceUrl = $File.SourceUrl
            $Definition.versions[0].file.sha256 = $File.Sha256
            $DefinitionJson = $Definition | Select-Object -ExcludeProperty "source" | ConvertTo-Json -Depth 10

            # Create the Shell App in Nerdio Manager
            if ($script:Token) {
                $params = @{
                    Uri             = "https://$($script:env.nmeHost)/api/v1/shell-app"
                    Method          = "POST"
                    Headers         = @{
                        "accept"        = "application/json"
                        "Authorization" = "Bearer $($script:Token.access_token)"
                    }
                    Body            = $DefinitionJson
                    ContentType     = "application/json"
                    UseBasicParsing = $true
                }
                $Result = Invoke-RestMethod @params
                if ($Result.job.status -eq "Completed") {
                    Write-Information -MessageData "$($PSStyle.Foreground.Green)Shell App created successfully. Job Id: $($Result.job.id)"
                }
                else {
                    Write-Error -Message "Failed to create Shell App. Status: $($Result.job.status)"
                }
                return $Result
            }
        }
        catch {
            $lineNumber = $_.InvocationInfo.ScriptLineNumber
            $scriptName = $_.InvocationInfo.ScriptName
            $errorMsg = $_.Exception.Message
            Write-Error -Message "Error on line $lineNumber in ${scriptName}: $errorMsg"
        }
    }
}

function New-ShellAppVersion {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName, ValueFromPipeline)]
        [System.String] $Id,

        [Parameter(Mandatory = $true)]
        [PSCustomObject] $AppMetadata,

        [Parameter(Mandatory = $false)]
        [System.Management.Automation.SwitchParameter] $UseRemoteUrl
    )
    process {
        # Create the Shell App version in Nerdio Manager
        try {
            # Create a file object for the Shell App version
            $params = @{
                AppMetadata  = $AppMetadata
                UseRemoteUrl = $UseRemoteUrl
            }
            $File = New-ShellAppFile @params

            # Definition required for a Shell App version
            $Definition = @"
{
    "name": "#version",
    "isPreview": false,
    "installScriptOverride": null,
    "file": {
        "sourceUrl": "#sourceUrl",
        "sha256": "#sha256"
    }
}
"@
 | ConvertFrom-Json -Depth 10

            # Update the app definition with the new version details
            $Definition.name = $AppMetadata.Version
            $Definition.file.sourceUrl = $File.SourceUrl
            $Definition.file.sha256 = $File.Sha256
            $DefinitionJson = $Definition | ConvertTo-Json -Depth 10

            if ($script:Token) {
                $params = @{
                    Uri             = "https://$($script:env.nmeHost)/api/v1/shell-app/$Id/version"
                    Method          = "POST"
                    Headers         = @{
                        "accept"        = "application/json"
                        "Authorization" = "Bearer $($script:Token.access_token)"
                    }
                    Body            = $DefinitionJson
                    ContentType     = "application/json"
                    UseBasicParsing = $true
                }
                $Result = Invoke-RestMethod @params
                if ($Result.job.status -eq "Completed") {
                    Write-Information -MessageData "$($PSStyle.Foreground.Green)Shell App version created successfully. Job Id: $($Result.job.id)"
                }
                else {
                    Write-Error -Message "Failed to create Shell App version. Status: $($Result.job.status)"
                }
                return $Result
            }
        }
        catch {
            $lineNumber = $_.InvocationInfo.ScriptLineNumber
            $scriptName = $_.InvocationInfo.ScriptName
            $errorMsg = $_.Exception.Message
            Write-Error -Message "Error on line $lineNumber in ${scriptName}: $errorMsg"
        }
    }
}

function Remove-ShellApp {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = "High")]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName)]
        [System.String] $Id
    )
    process {
        if ($PSCmdlet.ShouldProcess("Shell App: $Id", "Remove")) {
            Write-Information -MessageData "$($PSStyle.Foreground.Cyan)Removing Shell App Id: $Id"
            try {
                $params = @{
                    Uri             = "https://$($script:env.nmeHost)/api/v1/shell-app/$Id"
                    Headers         = @{
                        "Accept"        = "application/json; utf-8"
                        "Authorization" = "Bearer $($script:Token.access_token)"
                        "Cache-Control" = "no-cache"
                    }
                    Method          = "DELETE"
                    UseBasicParsing = $true
                }
                $Result = Invoke-RestMethod @params
                if ($Result.job.status -eq "Completed") {
                    Write-Information -MessageData "$($PSStyle.Foreground.Green)Shell App ($Id) removed successfully. Job Id: $($Result.job.id)"
                }
                elseif ($Result.job.status -eq "Pending") {
                    Write-Information -MessageData "$($PSStyle.Foreground.Yellow)Shell App ($Id) removal status: $($Result.job.status)."
                }
                elseif ($Result.job.status -eq "Failed") {
                    Write-Error -Message "Failed to remove Shell App ($Id). Status: $($Result.job.status)"
                }
                return $Result
            }
            catch {
                throw "Failed to remove Shell App: $($_.Exception.Message)"
            }
        }
    }
}

function Remove-ShellAppVersion {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = "High")]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName)]
        [System.String] $Id,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName)]
        [System.String] $Name
    )
    process {
        if ($PSCmdlet.ShouldProcess("Shell App: $Name", "Remove")) {
            Write-Information -MessageData "$($PSStyle.Foreground.Cyan)Removing Shell App version: $Id, $Name"
            try {
                $params = @{
                    Uri             = "https://$($script:env.nmeHost)/api/v1/shell-app/$Id/version/$Name"
                    Headers         = @{
                        "Accept"        = "application/json; utf-8"
                        "Authorization" = "Bearer $($script:Token.access_token)"
                        "Cache-Control" = "no-cache"
                    }
                    Method          = "DELETE"
                    UseBasicParsing = $true
                }
                $Result = Invoke-RestMethod @params
                if ($Result.job.status -eq "Completed") {
                    Write-Information -MessageData "$($PSStyle.Foreground.Green)Shell App version ($Id, $Name) removed successfully. Job Id: $($Result.job.id)"
                }
                elseif ($Result.job.status -eq "Pending") {
                    Write-Information -MessageData "$($PSStyle.Foreground.Yellow)Shell App version ($Id, $Name) removal status: $($Result.job.status)."
                }
                elseif ($Result.job.status -eq "Failed") {
                    Write-Error -Message "Failed to remove Shell App version ($Id, $Name). Status: $($Result.job.status)"
                }
                return $Result
            }
            catch {
                throw "Failed to remove Shell App: $($_.Exception.Message)"
            }
        }
        else {
            Write-Information -MessageData "$($PSStyle.Foreground.Yellow)Skipping removal of Shell App Id: $Id with version: $Name"
            return
        }
    }
}

function Update-ShellApp {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName)]
        [System.String] $Id,

        [Parameter(Mandatory = $true)]
        [PSCustomObject] $Definition
    )
    process {
        try {
            Write-Information -MessageData "$($PSStyle.Foreground.Cyan)Updating Shell App Id: $Id"
            $DefinitionJson = $Definition | ConvertTo-Json -Depth 10
            $params = @{
                Uri             = "https://$($script:env.nmeHost)/api/v1/shell-app/$Id"
                Method          = "PATCH"
                Headers         = @{
                    "accept"        = "application/json"
                    "Authorization" = "Bearer $($script:Token.access_token)"
                }
                Body            = $DefinitionJson
                ContentType     = "application/json"
                UseBasicParsing = $true
            }
            $Result = Invoke-RestMethod @params
            if ($Result.job.status -eq "Completed") {
                Write-Information -MessageData "$($PSStyle.Foreground.Green)Shell App updated successfully. Job Id: $($Result.job.id)"
            }
            else {
                Write-Error -Message "Failed to update Shell App. Status: $($Result.job.status)"
            }
            return $Result
        }
        catch {
            throw "Failed to update Shell App: $($_.Exception.Message)"
        }
    }
}

function Remove-NerdioManagerSecretsFromMemory {
    [CmdletBinding()]
    param ()
    process {
        if (!([System.String]::IsNullOrEmpty($script:creds.ClientSecret))) { $script:creds.ClientSecret = $null }
        if (!([System.String]::IsNullOrEmpty($script:Token.access_token))) { $script:Token.access_token = $null }
        Write-Information -MessageData "$($PSStyle.Foreground.Cyan)Client secret and access token cleared from memory. Use 'Set-NmeCredentials' and 'Connect-Nme' to re-authenticate."
    }
}

function Get-AppGroup {
    [CmdletBinding()]
    param ()
    process {
        try {
            $params = @{
                Uri             = "https://$($script:env.nmeHost)/api/v1/app-management/app-group"
                Headers         = @{
                    "Accept"        = "application/json; utf-8"
                    "Authorization" = "Bearer $($script:Token.access_token)"
                    "Cache-Control" = "no-cache"
                }
                Method          = "GET"
                UseBasicParsing = $true
            }
            $Result = Invoke-RestMethod @params
            return $Result.items
        }
        catch {
            throw "Failed to get App Group: $($_.Exception.Message)"
        }
    }
}

function Get-UamRepository {
    [CmdletBinding()]
    param ()
    process {
        try {
            $params = @{
                Uri             = "https://$($script:env.nmeHost)/api/v1/app-management/repository"
                Headers         = @{
                    "Accept"        = "application/json; utf-8"
                    "Authorization" = "Bearer $($script:Token.access_token)"
                    "Cache-Control" = "no-cache"
                }
                Method          = "GET"
                UseBasicParsing = $true
            }
            $Result = Invoke-RestMethod @params
            return $Result
        }
        catch {
            throw "Failed to get UAM Repository: $($_.Exception.Message)"
        }
    }
}

function New-AppGroupPayload {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [System.Int32] $RepoId,

        [Parameter(Mandatory = $true, ValueFromPipeline)]
        [Object[]] $ShellApp
    )
    process {
        foreach ($App in $ShellApp) {
            [PSCustomObject]@{
                repoId     = $RepoId
                externalId = $App.publicId
                version    = "latest"
                cachedName = $App.name
                reboot     = $false
            }
        }
    }
}

function Get-ShellAppsRepositoryId {
    [CmdletBinding()]
    param ()
    process {
        $Id = Get-UamRepository | Where-Object { $_.type -eq "Shell" } | Select-Object -ExpandProperty "id"
        return $Id
    }
}

function New-AppGroup {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [System.String] $Name,

        [Parameter(Mandatory = $true)]
        [Object] $Payload
    )
    process {
        try {
            $Body = [Ordered]@{
                name  = $Name
                items = $Payload
            } | ConvertTo-Json -Depth 10
            $params = @{
                Uri             = "https://$($script:env.nmeHost)/api/v1/app-management/app-group"
                Method          = "POST"
                Headers         = @{
                    "Accept"        = "application/json; utf-8"
                    "Authorization" = "Bearer $($script:Token.access_token)"
                    "Cache-Control" = "no-cache"
                }
                Body            = $Body
                ContentType     = "application/json"
                UseBasicParsing = $true
            }
            $Result = Invoke-RestMethod @params
            return $Result
        }
        catch {
            throw "Failed to create App Group: $($_.Exception.Message)"
        }
    }
}

function Update-AppGroup {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [System.Int32] $Id,

        [Parameter(Mandatory = $true)]
        [System.String] $Name,

        [Parameter(Mandatory = $true)]
        [Object] $Payload
    )
    process {
        try {
            $Body = [Ordered]@{
                name  = $Name
                items = $Payload
            } | ConvertTo-Json -Depth 10
            $params = @{
                Uri             = "https://$($script:env.nmeHost)/api/v1/app-management/app-group/$Id"
                Method          = "PATCH"
                Headers         = @{
                    "Accept"        = "application/json; utf-8"
                    "Authorization" = "Bearer $($script:Token.access_token)"
                    "Cache-Control" = "no-cache"
                }
                Body            = $Body
                ContentType     = "application/json"
                UseBasicParsing = $true
            }
            $Result = Invoke-RestMethod @params
            return $Result
        }
        catch {
            throw "Failed to update App Group: $($_.Exception.Message)"
        }
    }
}

function Get-MsiVersion {
    param (
        [Parameter(Mandatory)]
        [System.String]$Path
    )
    try {
        $installer = New-Object -ComObject "WindowsInstaller.Installer"
        $database = $installer.GetType().InvokeMember("OpenDatabase", 'InvokeMethod', $null, $installer, @($Path, 0))
        $view = $database.GetType().InvokeMember("OpenView", 'InvokeMethod', $null, $database, @("SELECT `Value` FROM `Property` WHERE `Property` = 'ProductVersion'"))
        $view.GetType().InvokeMember("Execute", 'InvokeMethod', $null, $view, $null)
        $record = $view.GetType().InvokeMember("Fetch", 'InvokeMethod', $null, $view, $null)
        $version = $record.GetType().InvokeMember("StringData", 'GetProperty', $null, $record, 1)
        return $version
    }
    catch {
        throw "Failed to read MSI version: $_"
    }
}