Skip to content

feat(cli): cleanup commands for Aspire CLI installs and installer scripts#17462

Draft
radical wants to merge 6 commits into
microsoft:mainfrom
radical:radical/installs-cleanup
Draft

feat(cli): cleanup commands for Aspire CLI installs and installer scripts#17462
radical wants to merge 6 commits into
microsoft:mainfrom
radical:radical/installs-cleanup

Conversation

@radical
Copy link
Copy Markdown
Member

@radical radical commented May 25, 2026

Builds on #17461. The description below covers the diff this PR adds on top of #17461.

What was missing. #17461 introduces aspire installs for discovering Aspire CLI state, but provides no way to remove that state. Cleaning up a pr-<N> dogfood install, an orphan NuGet hive, or a leftover ~/.aspire/bundle layout still requires remembering the install layout by heart. The installer scripts have no uninstall mode either, so users can't clean up at all if the CLI on PATH is broken or absent.

The fix. Add cleanup commands to the aspire installs surface and to the install scripts:

  • aspire installs uninstall <id> [--remove-shared-install] [--dry-run] [--yes] — removes Aspire-owned state for a discovered install or orphan hive listed by aspire installs list. Refuses managed installs (Homebrew, WinGet, dotnet tool) before any prompt and points the user back at the owning package manager. aspire installs list rows gain a Cleanup hint pointing at the right command for that row's source.
  • aspire hives list / aspire hives delete <name> — lower-level package-hive operations.
  • aspire uninstall --channel <ch> | --all — lower-level channel cleanup (drives the same CliCleanupService as installs uninstall).
  • get-aspire-cli{,-pr}.{sh,ps1} --uninstall (or -Uninstall) — clean stale state even when no Aspire CLI is on PATH. The PR script infers the channel from --pr-number; the release script requires explicit --channel or --all. Both require --yes for destruction and support --dry-run / -WhatIf.

Destructive-path hardening:

  • Channel names are validated against path separators and .. segments before being interpolated into any deletion path (CliCleanupService.ValidateChannel, plus validate_channel / Test-UninstallChannel in all four installer scripts).
  • PowerShell Remove-CleanupPath runs Remove-Item -ErrorAction Stop inside try/catch so a failed delete emits failed: <path> and propagates a non-zero exit instead of falling through to a removed: log line.
  • Get-BundleVersionTarget (both PowerShell scripts) wraps Get-Item with -ErrorAction Stop; an IO failure on the bundle symlink now throws rather than returning $null and letting Remove-BundleLayout silently strand the versions/<v>/ tree.
  • CliCleanupService.AddSharedInstallOperations resolves the bundle symlink target before deleting anything. When another aspire process holds a lease on the bundle version, both the symlink and the version directory are skipped — deleting the symlink alone would still break the lease holder's ability to re-resolve ~/.aspire/bundle. The exit code is non-zero outside --dry-run when the requested --remove-shared-install could not be carried out in full.
  • installs uninstall <id> requires an exact id match. A typoed-suffix id like pr-17416-2 is rejected instead of silently stripping to pr-17416 and deleting the wrong hive.
  • The release script's -Uninstall path tracks $Script:QualityExplicit (mirrors QUALITY_EXPLICIT in the bash sibling), so get-aspire-cli.ps1 -Uninstall -Yes with no other arguments errors out instead of inferring stable from the install-default -Quality.

Refs #15614

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 25, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 17462

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 17462"

@radical radical changed the title feat(cli): aspire installs surface for listing and uninstalling CLI state feat(cli): cleanup commands for Aspire CLI installs and installer scripts May 25, 2026
@github-actions
Copy link
Copy Markdown
Contributor

Re-running the failed jobs in the CI workflow for this pull request because 1 job was identified as retry-safe transient failures in the CI run attempt.
GitHub was asked to rerun all failed jobs for that attempt, and the rerun is being tracked in the rerun attempt.
The job links below point to the failed attempt jobs that matched the retry-safe transient failure rules.

radical and others added 6 commits May 25, 2026 18:27
Move CLI install enumeration and per-install metadata reporting out of
`aspire doctor` into a new `aspire installs` command surface, and align
the install-metadata vocabulary on `source` everywhere so the CLI,
sidecar spec, installer scripts, and documentation use the same term.

`aspire installs list` reports every discovered Aspire CLI install and
every orphan hive in a vertical, color-aware layout. `--format json`
emits the same rows as a stable contract documented in
`docs/specs/cli-output-formats.md`.

`aspire installs --self --format json` replaces the hidden `aspire
doctor --self` endpoint that install discovery uses to cross-check a
peer install's reported metadata. `aspire doctor` no longer ships an
install table or `--self` flag; it now reports environment checks only.
Install discovery still discovers peers, but the peer probe now invokes
`aspire installs --self`.

The sidecar spec moves to `docs/specs/install-sources.md`; the
`InstallSidecarReader`, `InstallationInfo`, scripts, homebrew template,
localhive helpers, and tests all read and write `source` (formerly
`route`). Homebrew installs are tagged with the explicit `homebrew`
source value in the sidecar and user-facing output.

The WinGet first-run install sidecar probe now runs before `aspire
installs list` instead of `aspire doctor`, so PR users still get a fresh
sidecar populated on the first invocation after `winget install
Microsoft.Aspire`.

On Windows, the shared `PathLookupHelper` preserves the executable
casing recorded on disk after PATHEXT resolution, so `aspire.exe` does
not render as `aspire.EXE` just because PATHEXT contains `.EXE`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The install-source sidecar's `source` field is the wire-level schema
value: the release script, PR installer, dotnet-tool packaging, and the
Homebrew cask all write the literal `brew` string (matching the cask's
`postflight` block and the actual Homebrew CLI binary name).
`BundleService.ComputeDefaultExtractDir`, `ParseInstallSource`, and the
installer test theory rows consume the same wire value.

The user-facing surfaces in `aspire installs list` — `GetInstallKind`,
`GetCleanupHint`, `GetManagedBy`, and the matching test expectations —
render the friendlier `homebrew` / `Homebrew` label because that's what
users recognise.

Match on `"brew"` at the wire layer, translate to `"homebrew"` at the
display layer. The schema stays minimal, the cask sidecar stays a
literal one-liner, and users still see the conventional spelling.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…xt factory

WingetFirstRunProbe is the writer that lets a winget portable install identify
itself as such on first run — winget has no install hook, so the running CLI
self-stamps `.aspire-install.json` after consulting Windows ARP. It was being
invoked from two unrelated consumer sites:

  - InstallsCommand.ListCommand.ExecuteAsync, before discovery reads the sidecar
  - BundleService.GetBundleExtractDirForCurrentProcess, before the extract dir
    is computed from the sidecar source

Each consumer paid the same try/catch cost and each leaked installer-specific
knowledge into a layer that should be installer-agnostic ("commands shouldn't
depend on any specific installer"). Worse, the factory that builds
CliExecutionContext also reads the sidecar (via GetUsersAspirePath ->
CliPathHelper.GetAspireHomeDirectory) but ran *before* either consumer fired
the probe — so a fresh winget install on its first invocation derived its
Aspire home with the sidecar still unstamped.

Hoist the invocation into the CliExecutionContext DI singleton factory in
Program.cs, immediately before BuildCliExecutionContext. The factory body now
runs the probe once per CLI invocation, before any downstream code reads the
sidecar. BundleService drops its optional WingetFirstRunProbe? parameter and
InstallsCommand drops its ctor parameter, field, and explicit call site.

Tests:

  - WindowsRegistryReaderTests (new, 7 tests, Windows-only) exercises the real
    HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall hive: matching
    entry returns true; wrong identifier / missing target / different target /
    surrounded by noise all return false; path comparison is case-insensitive;
    empty processPath returns false. Each test creates a uniquely-named
    `Microsoft.Aspire.Tests.<guid>` subkey and deletes it in a using; a static
    sweeper removes leftovers from any crashed prior run before tests start.

  - WingetStartupProbeTests (new, 2 tests) pins the new TryRunWingetFirstRunProbe
    contract: it resolves the probe from DI and invokes Run with the binary
    directory of Environment.ProcessPath, and any IWindowsRegistryReader
    exception is swallowed so CLI startup is never broken by a probe failure.
    TryRunWingetFirstRunProbe is internal for testing; the factory's use of it
    remains a one-line check at code review.

Net: -54/+50 production lines; +279 test lines; all existing tests still pass
(1366/1366 in the Acquisition + Commands + Bundles surface on macOS).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add an Aspire-owned cleanup surface for script and PR installs so users
can remove stale Aspire CLI state without remembering install layouts.

`aspire installs uninstall <id>` removes Aspire-owned install state for
a discovered install or orphan hive, refusing managed installs (Homebrew,
WinGet, dotnet tool) before any prompt and pointing the user back at the
owning package manager. PR installs also clean their dogfood install
directory; the shared script install under `~/.aspire/bin`,
`~/.aspire/bundle`, and `~/.aspire/versions/<v>` is only removed when the
caller passes `--remove-shared-install`.

Lower-level commands let callers operate on the pieces individually:
`aspire hives list/delete` for package hives, and `aspire uninstall
--channel|--all` for direct channel cleanup. `aspire installs list`
output now carries a per-row `Cleanup` hint pointing at the matching
command (`aspire installs uninstall <id>` for Aspire-owned rows,
package-manager command for managed rows).

The release and PR acquisition scripts gain a `--uninstall` /
`-Uninstall` mode so users can clean stale state even when no correct
Aspire CLI binary is on PATH. The scripts infer the channel from the PR
number (PR script) or `--quality` (release script), support
`--dry-run` / `-WhatIf`, require `--yes` for destructive actions, and
keep the shared script install untouched unless `--remove-shared-install`
is passed explicitly.

Refs microsoft#15614

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…aller uninstall paths

`validate_channel` was only present in `get-aspire-cli.sh`; `get-aspire-cli-pr.sh` accepted any channel value and interpolated it straight into the `rm -rf` target. Both `get-aspire-cli.ps1` and `get-aspire-cli-pr.ps1` had no validation at all, so a `-Channel "../../some-dir"` value resolved outside the hives root and was passed to `Remove-Item -Recurse`.

Mirror the existing `validate_channel` from `get-aspire-cli.sh` into `get-aspire-cli-pr.sh`, and add an equivalent `Test-UninstallChannel` to both PowerShell installer scripts. Reject channel names that contain path separators or `..` segments before composing any deletion path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two paths in `aspire uninstall --remove-shared-install` and the matching `--uninstall --remove-shared-install` script flows reported success on failure:

  - `TryGetBundleVersionTarget` caught `ResolveLinkTarget` exceptions and returned `false`, conflating "doesn't apply" with "couldn't resolve". The bundle symlink itself was then deleted on the way out and the user saw a clean operations list, while the on-disk `versions/<v>/` tree was silently stranded.
  - `UninstallAsync` with `--remove-shared-install` enumerated the bundle version directory and deleted it without checking `BundleVersionLease.HasActiveLease`. `BundleService.TryCleanupStaleVersions` explicitly skips leased entries; without the equivalent check here, another running CLI / AppHost could lose files mid-execution.
  - `Remove-CleanupPath` in `get-aspire-cli{,-pr}.ps1` called `Remove-Item -Recurse -Force` without `-ErrorAction Stop` and without a try/catch. With the script's default error action of `Continue`, `Remove-Item` failures (permission denied, file in use) were written to the error stream and execution fell through to the `Write-Message "removed: $Path"` line, producing a success-looking exit while the path was still on disk.

Refactor the shared-install cleanup so the bundle link target is resolved before the symlink is deleted; surface `ResolveLinkTarget` failures as a `Failed` cleanup operation; and skip the version directory with a clear reason when a `BundleVersionLease` is active. Wrap the PowerShell helper in try/catch with `-ErrorAction Stop` so a failed delete emits `failed: <path>` and propagates a non-zero exit.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@radical radical force-pushed the radical/installs-cleanup branch from 67ef4a8 to 82397bb Compare May 25, 2026 22:54
@radical radical added this to the 13.4 milestone May 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant