A one-line guide for running the installer.
Runs on PowerShell 7+ (
pwsh), but bootstraps itself from Windows PowerShell 5.1. The installer's logic requires PowerShell 7. Run from the built-in Windows PowerShell 5.1 (powershell.exe) — the only shell on a fresh machine — and it finds PowerShell 7, or installs it with no consent prompt, then relaunches itself underpwshin the same window with your switches forwarded (#225). A-WhatIfrun never installs anything — without PowerShell 7 present it just previews the bootstrap.Installing it takes one of two paths, in order:
- winget, when the invoking account has it.
- The official MSI, when it does not — the usual reason being that you elevated as a separate admin account, since winget is a per-user MSIX and a never-logged-in account has no copy of it. This path downloads ~110 MB and then runs
msiexec, so expect a couple of minutes; it printsX of 110.2 MB (N%)progress lines throughout, and a stalled download fails with a message rather than waiting forever (#263).
From the repository root, execute (after cloning):
pwsh -ExecutionPolicy Unrestricted -File .\winget-app-install.ps1No download/clone needed (one-line-run, from any PowerShell prompt — pwsh or the built-in
Windows PowerShell 5.1, which self-bootstraps as described above):
Set-ExecutionPolicy Unrestricted -Scope Process -Force; irm "https://raw.githubusercontent.com/J-MaFf/winget-app-setup/refs/heads/main/winget-app-install.ps1" | iex
raw.githubusercontent.comreturns429: Too Many Requests? That's GitHub throttling the machine's shared public IP (common behind corporate NAT/VPN egress), not a problem with the script. Retry in a few minutes, or fall back to the jsDelivr CDN mirror of the same file:Set-ExecutionPolicy Unrestricted -Scope Process -Force; irm "https://cdn.jsdelivr.net/gh/J-MaFf/winget-app-setup@main/winget-app-install.ps1" | iex
Note for 5.1 starts via irm | iex: there is no script file on disk to relaunch, so the
bootstrap re-downloads the installer from the URL above to a temp file and runs that under
pwsh. Starting from a file (-File .\winget-app-install.ps1) relaunches the same file
instead.
The script will trust the required Winget sources, elevate if necessary, and install or update the curated app list. Repeat step 1 anytime you open a new PowerShell window before running it.
The curated app list is Get-DefaultAppCatalog (WingetAppSetup/Public/AppCatalog.ps1) — the
single source of truth shared by the installer and winget-app-uninstall.ps1. Entries may
declare an optional applicability condition (a scriptblock, with a human-readable
conditionDescription), evaluated before any winget call: an app whose condition is falsy on
the current machine is reported as Skipping: <id> (not applicable: <reason>) and counted as
Skipped in the summary instead of being pointlessly installed. A condition that throws fails
open — a warning, then a normal install — so a broken probe can never silently drop an app.
Dell.CommandUpdate.Universal is gated this way (Dell hardware only): it installs only when
Win32_ComputerSystem reports a Dell manufacturer (#217).
The installer never asks a yes/no question on any path — elevation, the PowerShell 7 bootstrap,
and low disk space all proceed without prompting, so the one-liner above can be run and left
alone from a normal console. Pass -NonInteractive for RMM, CI, or scheduled-task use to also
suppress the two interactive-only extras: the summary grid-view window and the final "press any
key to exit":
pwsh -ExecutionPolicy Unrestricted -File .\winget-app-install.ps1 -NonInteractiveNon-interactive mode is also auto-detected when the session is non-interactive (e.g.
pwsh -NonInteractive, services, scheduled tasks) or stdin is redirected.
| Code | Meaning |
|---|---|
| 0 | Success — all apps installed or already present |
| 1 | One or more apps failed to install (also: the PowerShell 7 bootstrap could not provision pwsh from a pre-7 session, pre-flight system checks failed, or elevation unavailable under remote execution) |
| 2 | Winget is unavailable and could not be installed |
| 3 | App-definition validation failed, or no valid app definitions remain |
Every run writes a full transcript to
%ProgramData%\winget-app-setup\logs\install-<yyyyMMdd-HHmmss>.log (dry runs get a -whatif
suffix, e.g. install-20260708-143000-whatif.log). The path is printed at startup and repeated
with the final summary. ProgramData is used — rather than the elevating account's %TEMP% — so
the log survives cross-user elevation and can be collected after a failed install on a remote
machine. If the transcript cannot be started, the installer warns and continues: logging never
blocks an install.
Each transcript begins with an Installer build: line carrying the content-derived build id
(<module version>+<8-char SHA256 fragment of the assembled functions>) stamped by
build/Build-WingetInstallScript.ps1, so you can tell exactly which installer build produced a
given log.
Ongoing updates are handled by Winget-AutoUpdate (WAU),
which the installer sets up automatically (a pinned, SHA256-verified version). WAU runs as SYSTEM on a
weekly schedule (2 AM) and updates installed apps machine-wide, plus a user-context pass for the
logged-on user — which avoids the cross-user 0x80073d19 problems a per-user scheduled task hits.
WAU's own self-update is disabled so the version stays pinned; bump it via Get-WauPin in
WingetAppSetup/Public/WingetAutoUpdate.ps1. winget-app-uninstall.ps1 removes WAU (and any legacy
scheduled-update task from older versions).
The unit suite mocks every external call, so a real install is exercised by a scheduled
end-to-end run (.github/workflows/e2e-install.yml, issue #214) on a GitHub-hosted
windows-latest runner — a throwaway VM by construction:
- When it runs: weekly (Mondays 06:00 UTC), on manual dispatch, and on pull requests that
touch the e2e machinery itself (
.github/workflows/e2e-install.yml,e2e/**) so those changes validate themselves pre-merge. - What it does: installs the curated catalog twice — scheduled/dispatch runs use the true
production path (
irm <raw main URL> | iex), PR runs use the checkout'swinget-app-install.ps1— asserting exit 0 both times (the second pass proves idempotence), then runs the shared assertion scripte2e/Assert-Install.ps1 -ExpectAllSkippedOnSecondRun: every applicableGet-DefaultAppCatalogapp resolves viawinget list(exit-code classified) — the script evaluates each app's catalog condition on the runner, and not-applicable apps must instead show theirnot applicableskip line in the latest transcript — the WAU scheduled task exists, the installed WAU version matchesGet-WauPin, and a transcript with theInstaller buildstamp exists — with every applicable app Skipped on the second pass. The script's-SkipAppsparameter is an escape hatch for runner-platform incompatibilities only; each use must reference a GitHub issue at the call site. Dell Command Update is no longer skip-listed there: the catalog's manufacturer condition (#217) gates it in the product itself, so the non-Dell runners exercise the gating for real on every run. - Where the transcripts land: on the runner under
%ProgramData%\winget-app-setup\logs(the same place as production runs), always uploaded as thee2e-install-transcriptsartifact on the workflow run. - On failure: scheduled/dispatched runs (never PR runs) create — or comment on an existing
open — GitHub issue titled
E2E install run failedwith the run URL and the last 50 transcript lines. - Trigger manually:
gh workflow run e2e-install.yml, then watch withgh run list --workflow e2e-install.yml/gh run watch <run-id>.
Tier 2 (#215) will reuse
e2e/Assert-Install.ps1 for a cross-user elevation run on a snapshot-rollback VM.
The installer's logic lives in the WingetAppSetup PowerShell module under WingetAppSetup/
(Public/ for exported functions, Private/ for internal helpers). The single-file
winget-app-install.ps1 is generated from that module so the irm | iex one-liner keeps
working — do not edit it by hand.
After changing anything under WingetAppSetup/, regenerate the installer:
pwsh -File .\build\Build-WingetInstallScript.ps1Verify the committed script is in sync with the module (useful in CI / pre-commit):
pwsh -File .\build\Build-WingetInstallScript.ps1 -CheckRun the test suite (one <Area>.Tests.ps1 per module file under tests/; each loads the
module directly via tests/TestHelpers.ps1):
Invoke-Pester .\testsThe repo tracks a pre-commit hook (.githooks/pre-commit) that runs the same -Check
before a commit lands. Enable it once per clone:
git config core.hooksPath .githooksThe hook is fast and forgiving by design: it only runs when the staged files touch
WingetAppSetup/, build/, a .psd1 manifest, or winget-app-install.ps1 itself, and if
pwsh is not on PATH it prints a warning and lets the commit through — CI enforces the same
check on every push and pull request, so nothing ships unverified either way. On failure it
prints how to fix it: re-run the build and stage the regenerated installer together with your
module change.
If you also use the beads hooks:
bd hooks install(opt-in — the shims under.beads/hooks/are inert by default) writes its hooks into.git/hooks/, and settingcore.hooksPathmakes git ignore.git/hooks/entirely, silently disabling them. If you want both, leavecore.hooksPathunset and instead add a line to your.git/hooks/pre-committhat invokes.githooks/pre-commit— the drift check runs the same way from either location.
The generated installer is guaranteed to match the WingetAppSetup module by a stack of
guards, most of which run in both build and -Check modes of
build/Build-WingetInstallScript.ps1:
- Byte-compare with BOM rejection —
-Checkregenerates the installer in memory and compares it (LF-normalized) against the committed file byte for byte; it also inspects the raw bytes and rejects a leading UTF-8 BOM that a text comparison would silently strip (#183). - Assembled-script parse guard — the assembled script is parsed and any syntax error fails the build with line/column details, so an unbalanced brace in a module file can no longer ship a broken installer (#183).
- AST undefined-reference guard — every hyphenated command the assembled script invokes must resolve to a module-defined function (matched case-sensitively, so a stale call site cannot silently resolve to an external cmdlet that differs only by case) or an external command; catches functions dropped from the module while still being called — the drift class that broke the one-liner in #154. Runs on Windows, where the installer's Windows-only cmdlets are resolvable.
- psd1 export assertion —
WingetAppSetup.psd1'sFunctionsToExportmust exactly (case-sensitively) match the functions defined underWingetAppSetup/Public/*.ps1, so a new public function cannot be silently filtered on manifest imports (#191). - Non-ASCII token guard (Windows PowerShell 5.1 parse safety) — every non-comment token
of the assembled script must be pure ASCII. The installer ships as BOM-less UTF-8, which
5.1 decodes as ANSI: a multi-byte character inside a string literal misdecodes (an em
dash's 0x94 byte becomes a string-terminating curly quote) and cascades into parser
errors before the version dispatch can run. Keeping code tokens ASCII keeps the file
5.1-parseable so 5.1 reaches the version check and runs the PowerShell 7 bootstrap
(find-or-install
pwsh, then relaunch — #225); comments are exempt because misdecoded bytes there cannot change tokenization (#210). - Content-derived build id — the banner and
$script:InstallerBuildIdare stamped with<module version>+<8-hex SHA256 fragment of the assembled functions>, derived from content only (never git metadata or timestamps) so rebuilding the same tree is byte-identical and the-Checkbyte-compare stays deterministic; transcripts log the id at startup so a log identifies the exact installer build (#189). - CI enforcement —
.github/workflows/windows-tests.ymlruns-Checkon every push tomainand on every pull request, so drift fails CI instead of shipping (#156). - Local pre-commit hook —
.githooks/pre-commit(above) runs the same-Checkbefore a commit that touches the module, the build, a manifest, or the installer, catching drift before it is even committed (#211).