| name | new-fma |
| description | Add a Fleet-maintained app (FMA) for macOS (Homebrew) and/or Windows (winget), or write/clean up an FMA's custom install or uninstall script. Use when asked to "add X as a macOS/Windows FMA", "add a Fleet-maintained app", to debug FMA validator failures, or to review comments in an FMA script. Emphasizes verifying installer metadata with real tools (msitools, plist) instead of guessing, proving where an installer actually lands when run as SYSTEM, and keeping shipped script comments admin-facing. |
| allowed-tools | Bash, Read, Write, Edit, Grep, Glob, WebFetch, WebSearch |
| model | opus |
| effort | high |
You are adding a Fleet-maintained app (FMA) to this repo: $ARGUMENTS
The authoritative contributor docs are ee/maintained-apps/README.md. This skill captures the workflow PLUS the hard-won gotchas the README doesn't cover. Read the README too, but follow the rules here.
Golden rule: verify, don't guess
The single biggest source of wasted cycles is trusting winget/Homebrew metadata for the fields that must match what osquery actually sees on a host. The catalog metadata (winget PackageName/Publisher, cask names) frequently does NOT match the installed app's registry/bundle identity. Always confirm identity fields against the real installer:
- Windows
unique_identifier must equal the registry DisplayName (osquery programs.name).
- Windows publisher in the exists query must equal the registry Publisher (osquery
programs.publisher).
- macOS
unique_identifier must equal the app's CFBundleIdentifier.
- Version must reconcile with what osquery reports (
programs.version on Windows; bundle_short_version/bundle_version on macOS).
The same rule applies to where the app installs: verify it on a host running as SYSTEM rather than believing the manifest's Scope. See Per-user installers and the SYSTEM context.
Real examples from this codebase where the metadata lied:
| App | winget/cask says | Registry/bundle actually is |
|---|
| Amazon Corretto | PackageName "Amazon Corretto 25" | DisplayName Amazon Corretto (x64) (no version), Publisher Amazon |
| Genesys Cloud | PackageName "GenesysCloud" | DisplayName GenesysCloud (you'd guess "Genesys Cloud") |
| P4V | PackageName "P4 Apps", locale Publisher "Perforce Software, Inc." | DisplayName P4 Apps, Publisher Perforce Software |
| GoToMeeting | MSI ProductName "GoToMeeting 10.19.19950" | registry DisplayName GoToMeeting 10.19.0.19950 (bootstrapper!) |
Prerequisites (one-time)
brew install msitools
gh auth status
Verification toolkit
1. Read the winget manifest (Windows)
gh api 'repos/microsoft/winget-pkgs/contents/manifests/<x>/<Publisher>' --jq '.[].name'
gh api 'repos/microsoft/winget-pkgs/contents/manifests/<x>/<Pub>/<Pkg>' --jq '.[].name' | sort -V | tail
gh api 'repos/microsoft/winget-pkgs/contents/manifests/<x>/<Pub>/<Pkg>/<ver>/<Pkg>.installer.yaml' --jq '.content' | base64 -d
gh api 'repos/.../<Pkg>.locale.en-US.yaml' --jq '.content' | base64 -d | grep -E "Publisher:|PackageName:|ShortDescription:"
2. Inspect the MSI (Windows) — the authoritative source for identity
curl -sIL "<InstallerUrl>" | grep -i content-length
cd /tmp && curl -sL -o app.msi "<InstallerUrl>"
msiinfo export /tmp/app.msi Property | grep -iE "ProductName|ARPDISPLAY|Manufacturer|ProductVersion|UpgradeCode|ProductCode|ALLUSERS|ARPSYSTEMCOMPONENT"
msiinfo export /tmp/app.msi Registry
rm -f /tmp/app.msi
Map MSI properties → FMA fields:
ProductName → registry DisplayName → unique_identifier (unless ARPDISPLAYNAME overrides it)
Manufacturer → registry Publisher → program_publisher (if it differs from the winget locale Publisher)
ProductVersion → expected programs.version (but see bootstrapper caveat below)
UpgradeCode → for upgrade-code uninstall scripts
ALLUSERS=1 → installs per-machine regardless of switches
ARPSYSTEMCOMPONENT=1 → STOP: this is a bootstrapper (see Pitfall 2)
3. Inspect the macOS app bundle (DMG)
cd /tmp && curl -sL -o app.dmg "<cask url>"
MP=$(mktemp -d); hdiutil attach -nobrowse -readonly -mountpoint "$MP" app.dmg >/dev/null
APP=$(find "$MP" -maxdepth 1 -name "*.app" | head -1)
/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$APP/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$APP/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$APP/Contents/Info.plist"
hdiutil detach "$MP" >/dev/null; rm -f app.dmg
(For pkg-format casks, the bundle id is harder to read offline — the cask zap/uninstall pkgutil/launchctl/savedState paths are strong hints, e.g. <bundleid>.savedState.)
4. Silent install/uninstall flags — use documented sources, never guess
- The winget installer manifest's
InstallerSwitches (Silent, Custom) is the first source.
- silentinstallhq.com has per-app guides with the exact switches (e.g. GoToMeeting uses
/silent, not /S). Use WebFetch on https://silentinstallhq.com/<app>-silent-install-how-to-guide/.
- Cross-check the vendor's own docs.
Workflow
macOS (Homebrew cask)
- Find the cask:
curl -s https://formulae.brew.sh/api/cask/<token>.json
- Inspect the DMG/pkg for the real
CFBundleIdentifier (toolkit #3).
- Create
ee/maintained-apps/inputs/homebrew/<token>.json — minimal: name, slug (<app>/darwin), unique_identifier (bundle id), token, installer_format (dmg/pkg/zip), default_categories. Install/uninstall scripts auto-generate from the cask (artifacts + zap).
- Generate, add description, check icon (below).
Windows (winget)
- Read the winget manifests (toolkit #1). Pick machine scope, x64 (or the only arch available — some apps are x86-only).
- Inspect the MSI (toolkit #2) to confirm DisplayName, Publisher, version, codes, and to detect bootstrappers.
- Create
ee/maintained-apps/inputs/winget/<slug-name>.json:
name (catalog display, can be friendly), slug (<app>/windows), package_identifier, unique_identifier (= verified DisplayName), installer_arch, installer_type, installer_scope, default_categories.
program_publisher if registry Publisher ≠ winget locale Publisher.
fuzzy_match_name / exists_query as needed (below).
install_script_path / uninstall_script_path for any non-MSI-machine installer.
- Generate, add description, check icon.
Installer type mapping (winget InstallerType → FMA installer_type + silent flags)
| winget type | FMA type | install silent | uninstall |
|---|
msi, wix | msi | auto (msiexec /i /quiet /norestart) | auto upgrade-code (machine scope only) |
nullsoft (NSIS) | exe | /S | registry UninstallString + /S |
inno (Inno Setup) | exe | /VERYSILENT /SUPPRESSMSGBOXES /NORESTART | registry UninstallString + same |
burn (WiX bundle) | exe | /quiet /norestart | bundle UninstallString /uninstall /quiet /norestart |
msix | msix | n/a | n/a |
The ingester only auto-generates scripts for machine-scope MSI. Everything else needs custom install_script_path + uninstall_script_path. MSI success codes to treat as success: 0, 3010 (reboot required), 1641 (reboot initiated).
Custom script comments: these ship to customers
FMA install/uninstall scripts are not internal code. They're returned verbatim by GET /fleet/software/fleet_maintained_apps/:id and by the software title endpoint, and rendered in the "Install script" / "Uninstall script" editors of the Edit software modal (AdvancedOptionsFields.tsx), where an admin reads them and can edit them. Every comment you leave is product copy — treat it like the app description, not like a commit message.
Budget: the Fleet template header (# Learn more about .exe install scripts: + URL) if the script started from a template, then at most ~4 lines of app-specific comment. Of the 580 scripts in inputs/*/scripts/, only 51 open with a longer block than that — a big header is the exception you have to justify, not the norm.
Keep a comment only if an admin who edits this script would break something without it, or would be surprised at install time:
- Host-visible side effects: the app is force-quit, users are logged out, a reboot happens, existing config is preserved or deleted.
- Scope and destructiveness decisions — e.g. box-tools-uninstall.sh: removal sweeps every local user's home, and only the Box Edit subdirectory goes because the parent is shared with Box Drive.
- Constraints that must survive an edit: the required switch and why the obvious one is wrong (
/VERYSILENT — this is Inno Setup, /S opens the GUI), removal ordering, "must run as the logged-in user."
- Exit-code meanings (
1605 = not installed, 3010 = reboot required).
Cut — this belongs in the PR description, not the shipped script:
- Fleet's own tooling: "the validator's 10-minute timeout", "hangs in CI", "the ingester", "osquery's programs table". A customer has no validator.
- Catalog archaeology: what winget/Homebrew metadata claimed vs. reality,
silentinstallhq.com links, PR/issue numbers.
- Debugging narrative: what you tried first and why it failed ("a plain
Start-Process -Wait would block until killed").
- First person ("we", "our", "ourselves") — describe what the script does, in present tense and sentence case.
- Restating the next line (
# Prints the exit code above a Write-Host).
If the fact matters at run time rather than at edit time, Write-Host/echo it instead of commenting it — script output lands in the host's software install details, which is where an admin debugging a failure actually looks.
Before/after — darktable_install.ps1's 20-line header carries three admin-relevant facts and 16 lines of internal history:
# Learn more about .exe install scripts:
# http://fleetdm.com/learn-more-about/exe-install-scripts
#
# darktable uses an Inno Setup installer: it needs /VERYSILENT (the NSIS /S
# switch winget's metadata implies does nothing) and installs machine-wide when
# elevated. Its installer stays running after a silent install, so this script
# waits for darktable to register in Programs and Features, then stops it.
Dropped: that winget mislabeled the installer type, that PrivilegesRequiredOverridesAllowed=dialog rules out /ALLUSERS, that the lingering process holds the installer file lock, what a plain -Wait did. All of it goes in the PR body, where reviewers need it and customers don't see it.
Body comments follow the same rule — keep the one above a non-obvious registry match or a load-bearing helper, drop the rest. When you touch an existing script for any reason, prune its comments in the same edit.
Generate, validate, finalize
go run cmd/maintained-apps/main.go --slug="<app>/<platform>" --debug
- Output lands in
ee/maintained-apps/outputs/<slug>.json; an entry is appended to outputs/apps.json with an empty description — fill it in (sentence case, "<App> is a(n)..."). The generator does NOT update unique_identifier on an existing apps.json entry — edit it manually if you change it.
- Verify the generated SHA matches the manifest, and the exists/patched queries look right:
grep -E 'exists|patched|sha256' outputs/<slug>.json.
python3 -m json.tool ee/maintained-apps/outputs/apps.json >/dev/null to confirm valid JSON.
- Icon: check
frontend/pages/SoftwarePage/components/icons/index.ts for a key matching the lowercased catalog name. If missing, generate via tools/software/icons before merge. Icons key off the lowercased name, so platforms sharing a name share an icon. After generating, confirm the new SOFTWARE_NAME_TO_ICON_MAP key really is the lowercased name — when name and slug differ it is easy to end up keyed off the slug, and the lookup then misses. If an icon component for that name already exists, revert any regenerated .tsx/.png and reuse it.
- The validator is a Windows/macOS host (often ephemeral — you can't query it after the run). To cross-compile the Windows validator after editing it:
GOOS=windows go build ./cmd/maintained-apps/validate/.
Per-user installers and the SYSTEM context
Fleet runs install and uninstall scripts as SYSTEM. Most Windows FMA breakage traces back to this, and it does not reproduce in CI (see Validating for real), so it ships silently.
Always prefer machine-wide. Try the installer's all-users switch first (ALLUSERS=1/2, /ALLUSERS, G2MINSTALLFORALLUSERS=1). Machine-wide installs land in Program Files with an HKLM registration and everything downstream just works.
Verify the switch was honoured — do not trust that it was. Signal accepts /S /allusers and silently ignores it, still installing per-user. Install it on a real host as SYSTEM and look at where the payload and the registration actually went. Equally, do not trust the input's installer_scope: it comes from the winget manifest and is sometimes a default rather than a fact. bluej, julia-app and readest are all declared installer_scope: user yet install machine-wide to Program Files under HKLM, because their custom scripts already handle SYSTEM deliberately (bluej passes ALLUSERS=2). Scope alone is not evidence of a bug.
When the app has no machine-wide mode
Electron/NSIS/Squirrel apps and some Inno apps only ever install into the running user's profile. Run as SYSTEM they land in C:\Windows\system32\config\systemprofile\AppData\Local\..., where no signed-in user can launch them — the install "succeeds" and is useless. Symptoms vary and none of them says "wrong scope": Notion's installer crashes outright (0xC0000005, installing nothing), amazon-chime hangs until the timeout, granola returns 0 and installs into SYSTEM's profile.
The fix is to hand the installer to the signed-in user via a scheduled task. figma, slack, brave, arc, postman, notion and others follow this shape:
$owner = Get-CimInstance Win32_Process -Filter 'name = "explorer.exe"' -ErrorAction SilentlyContinue |
Invoke-CimMethod -MethodName GetOwner -ErrorAction SilentlyContinue |
Where-Object { $_.User } | Select-Object -First 1