Files
fleet/articles/build-your-own-windows-self-service-with-winget-and-script-only-packages-guide.md
T
Allen Houchins 4d55e96f9f Add Microsoft Store apps in Windows self-service article and guide (#50637)
**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.
2026-08-07 12:19:48 -05:00

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 msstore cannot 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_script key, 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.

  1. 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.

  2. 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, PackageFamilyName
    

    For 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-interactivity turns 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-AppxPackage inside 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 labels section first. Use labels_include_any, labels_include_all, or labels_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.

  1. On an admin workstation, download the package and its license.

    winget download --id 9WZDNCRFJ3PZ --source msstore --download-directory C:\staging
    
  2. 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 machine as 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