diff --git a/.github/scripts/filter-apps-json.sh b/.github/scripts/filter-apps-json.sh index 9cbaef3421..9c802605d6 100755 --- a/.github/scripts/filter-apps-json.sh +++ b/.github/scripts/filter-apps-json.sh @@ -1,7 +1,7 @@ #!/bin/bash # Script to filter apps.json to only include specified app slugs -# Usage: filter-apps-json.sh +# Usage: filter-apps-json.sh set -euo pipefail @@ -16,9 +16,20 @@ if ! command -v jq &> /dev/null; then fi # Parse arguments -SLUGS_JSON="$1" +SLUGS_INPUT="$1" OUTPUT_FILE="$2" +# Accept the slugs as either a literal JSON array string or a path to a file containing +# the JSON array. The file form avoids cross-shell quoting problems: Windows PowerShell +# mangles embedded quotes when forwarding a JSON string as a native-command argument to +# bash, corrupting the value before jq sees it. Callers passing a literal JSON string +# (e.g. on macOS) are unaffected since that value is not a path to an existing file. +if [ -n "$SLUGS_INPUT" ] && [ -f "$SLUGS_INPUT" ]; then + SLUGS_JSON="$(cat "$SLUGS_INPUT")" +else + SLUGS_JSON="$SLUGS_INPUT" +fi + if [ -z "$SLUGS_JSON" ] || [ "$SLUGS_JSON" == "[]" ] || [ "$SLUGS_JSON" == "null" ]; then echo "No slugs provided, creating empty apps.json" echo '{"version": 2, "apps": []}' > "$OUTPUT_FILE" diff --git a/.github/workflows/test-fma-windows-pr-only.yml b/.github/workflows/test-fma-windows-pr-only.yml index f0855b16f9..da8ee9c881 100644 --- a/.github/workflows/test-fma-windows-pr-only.yml +++ b/.github/workflows/test-fma-windows-pr-only.yml @@ -99,6 +99,7 @@ jobs: "has_7zip=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "has_firefox=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "has_nodejs=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + "has_powershell=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append exit 0 } @@ -112,6 +113,7 @@ jobs: "has_7zip=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "has_firefox=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "has_nodejs=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + "has_powershell=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append Write-Host "No windows apps changed, skipping Windows workflow" } else { "has_windows_apps=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append @@ -150,6 +152,14 @@ jobs: } else { "has_nodejs=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append } + + # Check if powershell/windows is in the changed apps + if ("powershell/windows" -in $windowsSlugs) { + "has_powershell=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + Write-Host "PowerShell detected in changed apps" + } else { + "has_powershell=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + } } shell: pwsh @@ -405,6 +415,79 @@ jobs: } shell: pwsh + - name: Remove pre-installed PowerShell + if: steps.check-windows-apps.outputs.has_windows_apps == 'true' && steps.check-windows-apps.outputs.has_powershell == 'true' + # NOTE: this step (and the verify step below) run under Windows PowerShell 5.1 + # (shell: powershell), NOT pwsh. We are about to uninstall PowerShell 7, so we + # must not be executing inside pwsh.exe (it would be locked / unavailable). + run: | + Write-Host "Listing all installed packages containing 'PowerShell':" + Get-Package | Where-Object { $_.Name -like "*PowerShell*" } | ForEach-Object { + Write-Host " - $($_.Name) (Version: $($_.Version))" + } + + # PowerShell 7 installs via MSI and registers under "PowerShell 7-x64" / + # "Microsoft Corporation". GitHub-hosted windows runners ship with it + # pre-installed, which must be removed so the validator starts from a clean state. + $uninstallPaths = @( + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", + "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" + ) + + $found = $false + foreach ($path in $uninstallPaths) { + $entries = Get-ItemProperty $path -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like "PowerShell 7*" -and $_.Publisher -like "*Microsoft Corporation*" } + foreach ($entry in $entries) { + if (-not $entry) { continue } + $found = $true + Write-Host "Found PowerShell uninstall entry: $($entry.DisplayName) (Version: $($entry.DisplayVersion))" + + $uninstallString = if ($entry.QuietUninstallString) { + $entry.QuietUninstallString + } elseif ($entry.UninstallString) { + $entry.UninstallString + } else { + $null + } + + if ($uninstallString) { + Write-Host "Found PowerShell uninstall path: $uninstallString" + try { + # PowerShell 7 uses an MSI uninstaller (MsiExec.exe /X{GUID} or /I{GUID}) + if ($uninstallString -match "/[XI]\{([A-F0-9\-]+)\}") { + $guid = $matches[1] + Write-Host "Uninstalling PowerShell MSI with GUID: $guid" + Start-Process -FilePath "msiexec.exe" -ArgumentList "/X{$guid}", "/quiet", "/norestart" -Wait -NoNewWindow + Write-Host "Successfully removed PowerShell via MSI uninstaller" + } else { + Write-Host "Could not parse uninstall string format: $uninstallString" + } + } catch { + Write-Host "Failed to remove PowerShell: $($_.Exception.Message)" + } + } else { + Write-Host "PowerShell uninstall string not found in registry entry" + } + } + } + + if (-not $found) { + Write-Host "PowerShell uninstall path not found in registry" + } + + # Force-remove leftover PowerShell 7 directory in case files remain after MSI removal + $psDir = "C:\Program Files\PowerShell\7" + if (Test-Path $psDir) { + Write-Host "Removing leftover directory: $psDir" + Remove-Item -Path $psDir -Recurse -Force -ErrorAction SilentlyContinue + if (Test-Path $psDir) { + Write-Host "WARNING: Failed to fully remove $psDir" + } else { + Write-Host "Removed $psDir" + } + } + shell: powershell + - name: Filter apps.json and verify changed apps if: steps.check-windows-apps.outputs.has_windows_apps == 'true' run: | @@ -415,16 +498,33 @@ jobs: # Filter changed apps to only include windows platform $changedAppsJson = '${{ steps.detect-changed.outputs.CHANGED_APPS }}' $windowsSlugs = ($changedAppsJson | ConvertFrom-Json | Where-Object { $_ -like "*/windows" }) - $windowsSlugsJson = ($windowsSlugs | ConvertTo-Json -Compress) + + # Build a JSON array of the windows slugs. Construct it explicitly so a single + # slug still serializes as an array (Windows PowerShell unwraps single-element + # arrays via ConvertTo-Json), and write it to a BOM-free file. We then pass the + # file PATH -- not the JSON string -- to the bash script: forwarding a quoted + # JSON string across the PowerShell -> bash argument boundary mangles the + # embedded quotes under Windows PowerShell 5.1, which breaks jq --argjson. + $slugsArray = @($windowsSlugs) + $windowsSlugsJson = "[" + (($slugsArray | ForEach-Object { $_ | ConvertTo-Json -Compress }) -join ",") + "]" Write-Host "Filtering apps.json for slugs: $windowsSlugsJson" + $windowsSlugsFile = Join-Path $env:TEMP "windows-slugs-$(New-Guid).json" + Set-Content -Path $windowsSlugsFile -Value $windowsSlugsJson -Encoding ascii -NoNewline + # Use forward slashes so Git Bash reads the path reliably (it reads this arg as a file). + $windowsSlugsFileForBash = $windowsSlugsFile -replace '\\', '/' + # Backup original apps.json Copy-Item -Path "ee\maintained-apps\outputs\apps.json" -Destination "ee\maintained-apps\outputs\apps.json.backup" # Create filtered apps.json # Use a fixed path for the temp file to avoid issues with bash $filteredAppsJson = Join-Path $env:TEMP "filtered-apps-$(New-Guid).json" - bash .github/scripts/filter-apps-json.sh "$windowsSlugsJson" "$filteredAppsJson" + bash .github/scripts/filter-apps-json.sh "$windowsSlugsFileForBash" "$filteredAppsJson" + if ($LASTEXITCODE -ne 0) { + Write-Host "Error: filter-apps-json.sh failed with exit code $LASTEXITCODE" + exit 1 + } # Verify the filtered file was created if (-not (Test-Path $filteredAppsJson)) { @@ -441,4 +541,6 @@ jobs: # Restore original apps.json Move-Item -Path "ee\maintained-apps\outputs\apps.json.backup" -Destination "ee\maintained-apps\outputs\apps.json" -Force - shell: pwsh + # Use Windows PowerShell 5.1 (not pwsh): when validating the PowerShell FMA we + # uninstall PowerShell 7 in the step above, so pwsh.exe may not be available here. + shell: powershell diff --git a/ee/maintained-apps/inputs/winget/powershell.json b/ee/maintained-apps/inputs/winget/powershell.json new file mode 100644 index 0000000000..cec197a044 --- /dev/null +++ b/ee/maintained-apps/inputs/winget/powershell.json @@ -0,0 +1,10 @@ +{ + "name": "PowerShell", + "slug": "powershell/windows", + "package_identifier": "Microsoft.PowerShell", + "unique_identifier": "PowerShell 7-x64", + "installer_arch": "x64", + "installer_type": "msi", + "installer_scope": "machine", + "default_categories": ["Developer tools"] +} diff --git a/ee/maintained-apps/outputs/apps.json b/ee/maintained-apps/outputs/apps.json index 20ea0a6283..5b44a580f8 100644 --- a/ee/maintained-apps/outputs/apps.json +++ b/ee/maintained-apps/outputs/apps.json @@ -1982,6 +1982,13 @@ "unique_identifier": "Microsoft Power BI Desktop (x64)", "description": "Power BI is an interactive data visualization platform with a primary focus on business intelligence (BI)." }, + { + "name": "PowerShell", + "slug": "powershell/windows", + "platform": "windows", + "unique_identifier": "PowerShell 7-x64", + "description": "PowerShell is a cross-platform task automation solution made up of a command-line shell, a scripting language, and a configuration management framework." + }, { "name": "PowerToys", "slug": "powertoys/windows", diff --git a/ee/maintained-apps/outputs/powershell/windows.json b/ee/maintained-apps/outputs/powershell/windows.json new file mode 100644 index 0000000000..26b3908e1e --- /dev/null +++ b/ee/maintained-apps/outputs/powershell/windows.json @@ -0,0 +1,22 @@ +{ + "versions": [ + { + "version": "7.6.2.0", + "queries": { + "exists": "SELECT 1 FROM programs WHERE name = 'PowerShell 7-x64' AND publisher = 'Microsoft Corporation';", + "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'PowerShell 7-x64' AND publisher = 'Microsoft Corporation' AND version_compare(version, '7.6.2.0') < 0);" + }, + "installer_url": "https://github.com/PowerShell/PowerShell/releases/download/v7.6.2/PowerShell-7.6.2-win-x64.msi", + "install_script_ref": "8959087b", + "uninstall_script_ref": "dccec067", + "sha256": "096a6dbb5bb330c5e14559ff1a7081bd274c07c07e2545755b93a93417e32629", + "default_categories": [ + "Developer tools" + ] + } + ], + "refs": { + "8959087b": "$logFile = \"${env:TEMP}/fleet-install-software.log\"\n\ntry {\n\n$installProcess = Start-Process msiexec.exe `\n -ArgumentList \"/quiet /norestart /lv ${logFile} /i `\"${env:INSTALLER_PATH}`\"\" `\n -PassThru -Verb RunAs -Wait\n\nGet-Content $logFile -Tail 500\n\nExit $installProcess.ExitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", + "dccec067": "$product_code = '{E5C8C749-88E5-48D7-9D16-4C41ED869462}'\n$timeoutSeconds = 300 # 5 minute timeout\n\n# Fleet uninstalls app using product code that's extracted on upload\n$process = Start-Process msiexec -ArgumentList @(\"/quiet\", \"/x\", $product_code, \"/norestart\") -PassThru\n\n# Wait for process with timeout\n$completed = $process.WaitForExit($timeoutSeconds * 1000)\n\nif (-not $completed) {\n Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue\n Exit 1603 # ERROR_UNINSTALL_FAILURE\n}\n\n# Check exit code and output result\nif ($process.ExitCode -eq 0) {\n Write-Output \"Exit 0\"\n Exit 0\n} else {\n Write-Output \"Exit $($process.ExitCode)\"\n Exit $process.ExitCode\n}\n" + } +} diff --git a/frontend/pages/SoftwarePage/components/icons/Powershell.tsx b/frontend/pages/SoftwarePage/components/icons/Powershell.tsx new file mode 100644 index 0000000000..25aad52cde --- /dev/null +++ b/frontend/pages/SoftwarePage/components/icons/Powershell.tsx @@ -0,0 +1,14 @@ +import * as React from "react"; + +import type { SVGProps } from "react"; + +const Powershell = (props: SVGProps) => ( + + + +); +export default Powershell; diff --git a/frontend/pages/SoftwarePage/components/icons/index.ts b/frontend/pages/SoftwarePage/components/icons/index.ts index 8e11535ad0..aff113e8bc 100644 --- a/frontend/pages/SoftwarePage/components/icons/index.ts +++ b/frontend/pages/SoftwarePage/components/icons/index.ts @@ -55,6 +55,7 @@ import PlantronicsHub from "./PlantronicsHub"; import PowerAutomate from "./PowerAutomate"; import PowerBi from "./PowerBi"; import Plugdata from "./Plugdata"; +import Powershell from "./Powershell"; import Powertoys from "./Powertoys"; import Prisma from "./Prisma"; import Proxifier from "./Proxifier"; @@ -516,6 +517,7 @@ export const SOFTWARE_NAME_TO_ICON_MAP = { postman: Postman, "power automate": PowerAutomate, "power bi": PowerBi, + powershell: Powershell, powertoys: Powertoys, prisma: Prisma, privileges: Privileges, diff --git a/website/assets/images/app-icon-powershell-60x60@2x.png b/website/assets/images/app-icon-powershell-60x60@2x.png new file mode 100644 index 0000000000..9875826681 Binary files /dev/null and b/website/assets/images/app-icon-powershell-60x60@2x.png differ