**Related issue:** NA Adds an article and companion guide on putting **Microsoft Store apps** into Windows self-service using winget and `.ps1` script-only packages. | File | Category | URL | |---|---|---| | `build-your-own-windows-self-service-with-winget-and-script-only-packages.md` | `articles` | `/articles/build-your-own-windows-self-service-with-winget-and-script-only-packages` | | `build-your-own-windows-self-service-with-winget-and-script-only-packages-guide.md` | `guides` | `/guides/build-your-own-windows-self-service-with-winget-and-script-only-packages-guide` | ## What changed and why Fleet 4.89.0 added an uninstall script, pre-install query, and post-install script to script-only packages. On Linux that was enough to build a [self-service catalog on apt and dnf](https://fleetdm.com/articles/build-your-own-linux-self-service-with-script-only-packages). These are the Windows counterparts, scoped specifically to Store apps. The per-app work is two lines: ```powershell winget install --id <StoreId> --source msstore --accept-package-agreements --accept-source-agreements --disable-interactivity winget uninstall --id <StoreId> --accept-source-agreements --disable-interactivity ``` Everything else in each script is boilerplate, and the generator emits it. ## The constraint both pieces are built around Fleet's agent runs Windows scripts as SYSTEM (`orbit/pkg/scripts/exec_windows.go:17`), and Store apps cannot be installed that way: - The `msstore` source rejects device-wide installs outright: "Device wide install for msstore type is not supported under admin context" ([winget-cli#3553](https://github.com/microsoft/winget-cli/issues/3553)). Store packages are per-user by design. - winget's CLI is [not supported in the system context](https://learn.microsoft.com/en-us/windows/package-manager/winget/troubleshooting) at all, because App Installer is an MSIX package that cannot be registered for `NT AUTHORITY\SYSTEM`. - Running winget in the system context is still [an open feature request](https://github.com/microsoft/winget-pkgs/issues/346975). So the scripts run winget inside the logged-on user's session via a short-lived scheduled task, mirroring the pattern already used by Fleet's own per-user Windows maintained apps (`ee/maintained-apps/inputs/winget/scripts/figma_install.ps1`). ## Two things worth a reviewer's attention Both are corrections that fall out of the Store focus, and both would have produced silently wrong content: 1. **Verification uses `Get-AppxPackage -AllUsers`, not the registry.** Store apps never register in the HKLM uninstall keys, so a registry check fails on a perfectly good install. This is called out explicitly in the guide's Troubleshoot section, since it's a natural wrong instinct if you've built tiles for ordinary Windows installers. 2. **`winget install --scope machine` is documented as a trap, not a shortcut.** For a Store package it [installs under the SYSTEM account](https://github.com/microsoft/winget-cli/issues/4748) instead of provisioning the app, which reports success and leaves users with nothing. The scheduled task's exit code also doesn't propagate back to the calling script, so the install reports success whenever the task ran. Both pieces treat the post-install verification as mandatory rather than optional because of this. ## Machine-wide path For apps that must exist for every user, the content documents `winget download` plus `Add-AppxProvisionedPackage`, which does work as SYSTEM, along with its two costs: license download [requires Entra ID authentication](https://learn.microsoft.com/en-us/windows/package-manager/winget/download) by a Global Administrator, User Administrator, or License Administrator, and you now have a file to host, so it wants a Fleet custom package rather than a script-only one. ## Notes for reviewers - **Content only.** No Go, frontend, migration, or config changes, so no changes file is needed and the code-focused template sections below are removed as the template instructs. - **The PowerShell has not been executed.** There is no `pwsh` on the authoring machine. The generator's here-string escaping was traced by hand but not run. Worth one execution on a real Windows host before publish. - **`Microsoft.CompanyPortal` / `9WZDNCRFJ3PZ`** are used as the worked example. Both identifiers are now verified (PackageFamilyName `Microsoft.CompanyPortal_8wekyb3d8bbwe`), and the guide still tells readers to derive both themselves. - **A verification pass was run over every claim** (Fleet docs, Microsoft Learn, winget-cli issues). It caught one real bug: `winget uninstall` with no flags can hang on an msstore source-agreement prompt ([winget-cli#1736](https://github.com/microsoft/winget-cli/issues/1736)), invisible inside the scheduled task. All uninstalls now carry `--accept-source-agreements`, both directions carry `--disable-interactivity`, and the explorer.exe owner lookup takes the first result so multiple explorer processes can't break `Register-ScheduledTask`. - **`articleImageUrl` is intentionally absent** from the article. The build treats it as optional, but the Linux article has one, so a `1200x627@2x.png` in `website/assets/images/articles/` plus the meta tag would bring it to parity. The guide has none, matching the Linux guide. - **`publishedOn` is `2026-08-05`** on both. Update if these are being scheduled. Checked against the build's enforced constraints (`website/scripts/build-static-content.js`): valid `category`, `articleTitle` matches each H1 exactly, descriptions are 133 and 125 characters (limit 150), `publishedOn` matches the required ISO pattern, no `@fleetdm.com` addresses. `check-pr-template` does not run on this PR, since it only triggers on `frontend/**`, `**/*.go`, `go.mod`, and `go.sum`. # Checklist for submitter ## Testing - [ ] QA'd all new/changed functionality manually Not applicable to content-only changes. Verified instead by running the website build's own validation rules against both files, and by sourcing every technical claim to Microsoft Learn, `winget-cli` issues, or Fleet's docs and code. The PowerShell samples are unexecuted, as noted above.
14 KiB
Add Microsoft Store apps to Windows self-service with winget
Fleet 4.89.0 gave script-only packages an uninstall script, a pre-install query, and a post-install script. That is enough to put Microsoft Store apps on your end users' self-service page using winget, with no packages to host. This guide builds one Store app tile end to end, then generates the rest from a Store ID. It covers per-user Store installs driven through self-service, which is the only scope the Store supports through winget.
Warning:
winget install --source msstorecannot run as SYSTEM, and Fleet runs Windows scripts as SYSTEM. The Store rejects device-wide installs with "Device wide install for msstore type is not supported under admin context." Step 3 works around this by running winget in the logged-on user's session. If you need a Store app installed machine-wide for every user, see Deploy a Store app machine-wide instead.
Prerequisites
- Fleet 4.89.0 or later. The uninstall script, pre-install query, and post-install script on script-only packages were added in this release.
- A GitOps repository connected to your Fleet instance. Software is defined per fleet in
fleets/<name>.yml. - Windows hosts enrolled in Fleet with scripts enabled. See the scripts guide if you deployed Fleet's agent without
--enable-scripts. - App Installer present on those hosts, which provides winget. Windows Server and LTSC images often ship without it.
- Fleet Desktop available to end users, so the self-service page is reachable from the Windows system tray.
Note: Script-only packages do not support an
install_scriptkey, because the file's contents already are the install script. They also do not support automatic install through a policy. Drive these through self-service.
Step 1: Find the Store ID and package name
You need two identifiers, and they are not the same thing.
-
Get the Store ID, a twelve-character string winget uses to install the app.
winget search --source msstore "company portal"For Company Portal, the ID is
9WZDNCRFJ3PZ. -
Install the app by hand on one machine, then get the MSIX package name, which you need for verification in Step 5.
Get-AppxPackage | Select-Object Name, PackageFamilyNameFor Company Portal, the name is
Microsoft.CompanyPortal.
Note: Always pin to the Store ID rather than the app name. A name match can resolve to the wrong package, and nothing is watching the output when Fleet runs the script.
Step 2: Confirm the commands you are wrapping
These are the only two winget commands involved. Everything in Step 3 exists to get them running in the right place.
winget install --id 9WZDNCRFJ3PZ --source msstore --accept-package-agreements --accept-source-agreements --disable-interactivity
winget uninstall --id 9WZDNCRFJ3PZ --accept-source-agreements --disable-interactivity
Warning: Keep the agreement flags on the uninstall too. winget prompts for msstore source agreements even on uninstall, and in an unattended script that prompt hangs forever.
--disable-interactivityturns any remaining prompt into a failure instead.
Step 3: Write the install script
Fleet runs the script as SYSTEM, where neither winget nor the Store will cooperate. Create a short-lived scheduled task owned by the logged-on user, start it, wait for it to finish, then remove it.
Save this as install-company-portal.ps1.
$StoreId = "9WZDNCRFJ3PZ"
$WingetArgs = "install --id $StoreId --source msstore --accept-package-agreements --accept-source-agreements --disable-interactivity"
$exitCode = 0
$taskName = "fleet-store-$StoreId"
try {
$userName = (Get-CimInstance Win32_Process -Filter 'name = "explorer.exe"' |
Invoke-CimMethod -MethodName GetOwner | Select-Object -First 1).User
if (-not $userName) { throw "No logged-on user, so there is no session to install into." }
$action = New-ScheduledTaskAction -Execute "winget.exe" -Argument $WingetArgs
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries
$task = New-ScheduledTask -Action $action -Settings $settings
Register-ScheduledTask $taskName -InputObject $task -User $userName | Out-Null
Start-ScheduledTask -TaskName $taskName -TaskPath "\"
$startDate = Get-Date
do {
Start-Sleep -Seconds 5
$state = (Get-ScheduledTask -TaskName $taskName).State
Write-Host "Scheduled task is '$state'."
if ((New-TimeSpan -Start $startDate -End (Get-Date)).TotalSeconds -gt 600) {
throw "Timed out waiting for winget to finish."
}
} while ($state -eq "Running" -or $state -eq "Queued")
} catch {
Write-Host "Error: $_"
$exitCode = 1
} finally {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
}
exit $exitCode
Only the first two lines are app-specific. Inside the user's session, plain winget.exe resolves through the app execution alias, so no path resolution is needed.
Warning: The scheduled task's exit code does not come back to this script, so it reports success whenever the task ran, whether or not winget installed anything. Step 5 is what catches that, and it is not optional here.
Step 4: Write the uninstall script
Copy the install script and change one line. Save it as uninstall-company-portal.ps1.
$WingetArgs = "uninstall --id $StoreId --accept-source-agreements --disable-interactivity"
Note: If winget cannot match the installed package on uninstall, remove it directly with
Remove-AppxPackageinside the same scheduled task, using the package family name from Step 1.
Step 5: Verify the install landed
Store apps do not register in HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall, so a registry check fails on a good install. Ask the MSIX subsystem instead. Fleet runs this as SYSTEM, which is elevated, so -AllUsers will see a package installed in a user's profile.
Save this as verify-company-portal.ps1.
$PackageName = "Microsoft.CompanyPortal"
$pkg = Get-AppxPackage -AllUsers -Name $PackageName -ErrorAction SilentlyContinue
if (-not $pkg) {
Write-Host "$PackageName is not installed for any user."
exit 1
}
Write-Host "Found $($pkg[0].Name) $($pkg[0].Version)."
exit 0
A non-zero exit from the post-install script fails the install and triggers your uninstall script.
Step 6: Register the package in your fleet file
Save all three scripts under lib/windows/scripts/ in your GitOps repo. Then register the package in fleets/<name>.yml or fleets/unassigned.yml.
labels:
- name: Windows
query: "SELECT 1 FROM os_version WHERE platform = 'windows';"
label_membership_type: dynamic
software:
packages:
- path: ../lib/windows/scripts/install-company-portal.ps1
display_name: Company Portal
self_service: true
categories:
- "💻 Productivity"
labels_include_any:
- Windows
uninstall_script:
path: ../lib/windows/scripts/uninstall-company-portal.ps1
post_install_script:
path: ../lib/windows/scripts/verify-company-portal.ps1
With self_service: true, the app appears on the end user's self-service page, reachable from the Fleet icon in the Windows system tray.
Note: Any label you reference on a package must be defined in the
labelssection first. Uselabels_include_any,labels_include_all, orlabels_exclude_any, but only one per package. Use a label to exclude hosts without App Installer, such as Windows Server and LTSC images.
Step 7: Generate new tiles from a Store ID
Only two lines differ between apps, so the rest can be emitted. Fleet script-only packages are single files with no shared helper to import, so the boilerplate is inlined into each generated script.
Save this as New-StoreAppTile.ps1.
param(
[Parameter(Mandatory)][string]$StoreId,
[Parameter(Mandatory)][string]$DisplayName,
[Parameter(Mandatory)][string]$PackageName,
[string]$Category = "💻 Productivity"
)
$dir = "lib/windows/scripts"
New-Item -ItemType Directory -Path $dir -Force | Out-Null
$slug = $DisplayName.ToLower() -replace '[^a-z0-9]+', '-'
$body = @'
$exitCode = 0
$taskName = "fleet-store-$StoreId"
try {
$userName = (Get-CimInstance Win32_Process -Filter 'name = "explorer.exe"' |
Invoke-CimMethod -MethodName GetOwner | Select-Object -First 1).User
if (-not $userName) { throw "No logged-on user, so there is no session to install into." }
$action = New-ScheduledTaskAction -Execute "winget.exe" -Argument $WingetArgs
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries
$task = New-ScheduledTask -Action $action -Settings $settings
Register-ScheduledTask $taskName -InputObject $task -User $userName | Out-Null
Start-ScheduledTask -TaskName $taskName -TaskPath "\"
$startDate = Get-Date
do {
Start-Sleep -Seconds 5
$state = (Get-ScheduledTask -TaskName $taskName).State
Write-Host "Scheduled task is '$state'."
if ((New-TimeSpan -Start $startDate -End (Get-Date)).TotalSeconds -gt 600) {
throw "Timed out waiting for winget to finish."
}
} while ($state -eq "Running" -or $state -eq "Queued")
} catch {
Write-Host "Error: $_"
$exitCode = 1
} finally {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
}
exit $exitCode
'@
@"
`$StoreId = "$StoreId"
`$WingetArgs = "install --id `$StoreId --source msstore --accept-package-agreements --accept-source-agreements --disable-interactivity"
$body
"@ | Set-Content "$dir/install-$slug.ps1" -Encoding UTF8
@"
`$StoreId = "$StoreId"
`$WingetArgs = "uninstall --id `$StoreId --accept-source-agreements --disable-interactivity"
$body
"@ | Set-Content "$dir/uninstall-$slug.ps1" -Encoding UTF8
@"
`$PackageName = "$PackageName"
`$pkg = Get-AppxPackage -AllUsers -Name `$PackageName -ErrorAction SilentlyContinue
if (-not `$pkg) { Write-Host "`$PackageName is not installed for any user."; exit 1 }
Write-Host "Found `$(`$pkg[0].Name) `$(`$pkg[0].Version)."
exit 0
"@ | Set-Content "$dir/verify-$slug.ps1" -Encoding UTF8
@"
# Add to your fleet's software.packages:
- path: ../$dir/install-$slug.ps1
display_name: $DisplayName
self_service: true
categories:
- "$Category"
labels_include_any:
- Windows
uninstall_script:
path: ../$dir/uninstall-$slug.ps1
post_install_script:
path: ../$dir/verify-$slug.ps1
"@
Run it with the two identifiers from Step 1, then paste the printed block into your fleet file and open a pull request.
./New-StoreAppTile.ps1 -StoreId 9WZDNCRFJ3PZ -DisplayName "Company Portal" -PackageName Microsoft.CompanyPortal
Deploy a Store app machine-wide
Self-service installs are per-user. If an app has to be present for every user on a host, provision it instead of installing it, which does work as SYSTEM.
-
On an admin workstation, download the package and its license.
winget download --id 9WZDNCRFJ3PZ --source msstore --download-directory C:\staging -
Provision it on the host.
Add-AppxProvisionedPackage -Online -PackagePath .\app.msixbundle -LicensePath .\9WZDNCRFJ3PZ_License.xml
Warning: Downloading a Store package's license file requires Entra ID authentication by an account with the Global Administrator, User Administrator, or License Administrator role. You also now have a file to deliver, so use a Fleet custom package rather than a script-only one.
Note: Do not reach for
winget install --scope machineas a shortcut here. For a Store package it installs under the SYSTEM account rather than provisioning the app, which looks like success and leaves no app for your users.
Troubleshoot
The install fails with "Device wide install for msstore type is not supported under admin context." The script is running winget as SYSTEM. Store apps are per-user, so wrap the call in the scheduled task from Step 3, or provision the app machine-wide instead.
The install reports success but the app is not there. The scheduled task ran and winget failed inside it. The task's exit code never reaches your script, so add the verification script from Step 5, which fails the install and triggers the uninstall.
Verification fails even though the app is installed. Check that you are using Get-AppxPackage -AllUsers and the MSIX package name from Step 1, not the Store ID. Store apps never appear in the uninstall registry keys, so a registry-based check always fails here.
Nothing happens and the script throws "No logged-on user." There is no explorer.exe owner to run the task as. This is expected on a host at the login screen, and self-service installs assume somebody is signed in.
The script fails because winget is not recognized. The host has no App Installer package, which is common on Windows Server and LTSC images. Exclude those hosts with a label, or install App Installer first.
The tile does not appear on a host's self-service page. Confirm the host matches the package's label, and that the label query returns the host in the Fleet UI under the label's host list.
Further reading
- Deploy software guide for full detail on script-only packages, pre-install queries, and uninstall scripts.
- Put Microsoft Store apps in Windows self-service with winget for the reasoning behind this approach.
- winget troubleshooting for Microsoft's guidance on the system context.
- GitOps YAML reference.