From dd1a955509daf7f1521353871854ffeb428f69c7 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Sun, 24 May 2026 19:12:16 -0400 Subject: [PATCH 1/5] refactor(cli): move install discovery to `aspire installs` 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> --- docs/dogfooding-pull-requests.md | 8 +- docs/specs/cli-output-formats.md | 37 ++ .../{install-routes.md => install-sources.md} | 38 +- eng/clipack/Common.projitems | 10 +- eng/homebrew/aspire.rb.template | 2 +- eng/scripts/get-aspire-cli-pr.ps1 | 22 +- eng/scripts/get-aspire-cli-pr.sh | 28 +- eng/scripts/get-aspire-cli.ps1 | 8 +- eng/scripts/get-aspire-cli.sh | 6 +- eng/scripts/verify-cli-archive.ps1 | 10 +- eng/scripts/verify-cli-tool-nupkg.ps1 | 2 +- localhive.ps1 | 14 +- localhive.sh | 6 +- src/Aspire.Cli/Acquisition/HiveEnumerator.cs | 37 ++ .../Acquisition/IInstallSidecarReader.cs | 19 +- .../Acquisition/IInstallationDiscovery.cs | 10 +- .../Acquisition/IPeerInstallProbe.cs | 6 +- .../Acquisition/InstallSidecarReader.cs | 4 +- src/Aspire.Cli/Acquisition/InstallSource.cs | 16 +- .../InstallationCandidateSources.cs | 2 +- .../Acquisition/InstallationDiscovery.cs | 46 +- .../Acquisition/InstallationInfo.cs | 16 +- .../Acquisition/PeerInstallProbe.cs | 12 +- .../Acquisition/WingetFirstRunProbe.cs | 4 +- src/Aspire.Cli/Bundles/BundleService.cs | 6 +- src/Aspire.Cli/Bundles/IBundleService.cs | 2 +- src/Aspire.Cli/CliExecutionContext.cs | 8 +- src/Aspire.Cli/Commands/DoctorCommand.cs | 47 +- .../Commands/InstallationInfoOutput.cs | 116 +---- src/Aspire.Cli/Commands/InstallsCommand.cs | 323 +++++++++++++ src/Aspire.Cli/Commands/RootCommand.cs | 2 + src/Aspire.Cli/Commands/SetupCommand.cs | 8 +- src/Aspire.Cli/JsonSourceGenerationContext.cs | 2 + src/Aspire.Cli/Program.cs | 8 +- .../DoctorCommandStrings.Designer.cs | 117 ----- .../Resources/DoctorCommandStrings.resx | 46 -- .../Resources/xlf/DoctorCommandStrings.cs.xlf | 65 --- .../Resources/xlf/DoctorCommandStrings.de.xlf | 65 --- .../Resources/xlf/DoctorCommandStrings.es.xlf | 65 --- .../Resources/xlf/DoctorCommandStrings.fr.xlf | 65 --- .../Resources/xlf/DoctorCommandStrings.it.xlf | 65 --- .../Resources/xlf/DoctorCommandStrings.ja.xlf | 65 --- .../Resources/xlf/DoctorCommandStrings.ko.xlf | 65 --- .../Resources/xlf/DoctorCommandStrings.pl.xlf | 65 --- .../xlf/DoctorCommandStrings.pt-BR.xlf | 65 --- .../Resources/xlf/DoctorCommandStrings.ru.xlf | 65 --- .../Resources/xlf/DoctorCommandStrings.tr.xlf | 65 --- .../xlf/DoctorCommandStrings.zh-Hans.xlf | 65 --- .../xlf/DoctorCommandStrings.zh-Hant.xlf | 65 --- src/Aspire.Cli/Utils/CliPathHelper.cs | 4 +- .../EnvironmentCheckResult.cs | 7 - src/Shared/PathLookupHelper.cs | 55 ++- .../Scripts/Common/FakeArchiveHelper.cs | 4 +- .../Scripts/LocalHiveScriptFunctionTests.cs | 12 +- .../Scripts/PRScriptInstallE2ETests.cs | 4 +- .../Scripts/PRScriptInstallerModeTests.cs | 10 +- .../Scripts/PRScriptPowerShellTests.cs | 36 +- .../Scripts/PRScriptShellTests.cs | 40 +- .../Scripts/PRScriptToolModeTests.cs | 12 +- .../Scripts/ReleaseScriptPowerShellTests.cs | 12 +- .../Scripts/ReleaseScriptShellTests.cs | 12 +- .../VerifyCliArchivePowerShellTests.cs | 6 +- .../Acquisition/InstallSidecarReaderTests.cs | 12 +- .../InstallationDiscoveryDiscoverAllTests.cs | 62 +-- .../Acquisition/PeerInstallProbeTests.cs | 75 +++- ...dleServiceComputeDefaultExtractDirTests.cs | 14 +- ...undleServiceCrossSourceExtractionTests.cs} | 16 +- .../Commands/DoctorCommandTests.cs | 263 +---------- .../Commands/InstallsCommandTests.cs | 423 ++++++++++++++++++ .../Commands/SetupCommandTests.cs | 12 +- ...asedAppHostServerChannelResolutionTests.cs | 2 +- .../Packaging/TemporaryNuGetConfigTests.cs | 2 +- .../Utils/CliPathHelperTests.cs | 16 +- tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 2 + .../PathLookupHelperTests.cs | 25 ++ 75 files changed, 1296 insertions(+), 1735 deletions(-) rename docs/specs/{install-routes.md => install-sources.md} (54%) create mode 100644 src/Aspire.Cli/Acquisition/HiveEnumerator.cs create mode 100644 src/Aspire.Cli/Commands/InstallsCommand.cs rename tests/Aspire.Cli.Tests/Bundles/{BundleServiceCrossRouteExtractionTests.cs => BundleServiceCrossSourceExtractionTests.cs} (89%) create mode 100644 tests/Aspire.Cli.Tests/Commands/InstallsCommandTests.cs diff --git a/docs/dogfooding-pull-requests.md b/docs/dogfooding-pull-requests.md index dafd90fe7d1..12cf09188fd 100644 --- a/docs/dogfooding-pull-requests.md +++ b/docs/dogfooding-pull-requests.md @@ -329,9 +329,9 @@ The file is scoped to the solution directory and only affects projects under it. The Homebrew cask (`eng/homebrew/aspire.rb.template`) installs Aspire entirely inside the Caskroom version directory — `brew uninstall aspire` removes -the binary and the route sidecar end-to-end. The cask intentionally carries -no `zap` stanza, because `~/.aspire/` is a shared prefix with the script-route -and PR-route installers and a brew-driven recursive delete would clobber state +the binary and the source sidecar end-to-end. The cask intentionally carries +no `zap` stanza, because `~/.aspire/` is a shared prefix with the script-source +and PR-source installers and a brew-driven recursive delete would clobber state those installers still own. If you installed via the Homebrew cask before this change, you may have a @@ -343,7 +343,7 @@ Clean it up manually once after upgrading the cask: rm -rf ~/.aspire/installs/brew-stable ``` -NuGet hives under `~/.aspire/hives/` and any script-route or PR-route +NuGet hives under `~/.aspire/hives/` and any script-source or PR-source binaries under `~/.aspire/bin/` and `~/.aspire/dogfood/` are not touched by the cask in either direction; manage those with the steps above. diff --git a/docs/specs/cli-output-formats.md b/docs/specs/cli-output-formats.md index bbe2393303a..a8c02e0831f 100644 --- a/docs/specs/cli-output-formats.md +++ b/docs/specs/cli-output-formats.md @@ -474,6 +474,43 @@ The JSON form includes secret values. Do not redirect it to logs or files unless `status` is one of `pass`, `warning`, or `fail`. Individual checks can include `details`, `fix`, `link`, or command-specific `metadata`. +### `aspire installs list` + +`aspire installs list --format json` emits the Aspire CLI installs and orphan package hives that the running CLI can discover: + +```json +[ + { + "id": "script", + "kind": "script", + "channel": "stable", + "path": "/home/user/.aspire/bin/aspire", + "hive": "/home/user/.aspire/hives/stable", + "status": "active" + }, + { + "id": "pr-17400", + "kind": "orphan-hive", + "channel": "pr-17400", + "hive": "/home/user/.aspire/hives/pr-17400", + "status": "no install found", + "statusReason": "No discovered install reports this hive's channel." + }, + { + "id": "stable", + "kind": "homebrew", + "channel": "stable", + "path": "/opt/homebrew/Caskroom/aspire/13.2.0/aspire", + "status": "active", + "managedBy": "homebrew" + } +] +``` + +`status` uses the install-discovery status (`active`, `shadowed`, `notOnPath`, `failed: `, `notProbed: `) or `no install found` for orphan hives. + +`aspire installs --self --format json` is a hidden command used by the install-discovery peer-probe path so a newer CLI can ask a peer CLI to describe itself. The shape is an internal cross-version contract between Aspire CLI builds — not a stable surface for tooling — and may change without notice. + ### `aspire config info` `aspire config info --json` is a hidden tooling command that emits configuration paths, feature metadata, settings schemas, and advertised CLI capabilities: diff --git a/docs/specs/install-routes.md b/docs/specs/install-sources.md similarity index 54% rename from docs/specs/install-routes.md rename to docs/specs/install-sources.md index 4def1a0bdd5..67b2984a029 100644 --- a/docs/specs/install-routes.md +++ b/docs/specs/install-sources.md @@ -1,8 +1,8 @@ -# Aspire CLI install-route sidecar +# Aspire CLI install-source sidecar > Pairs with `docs/specs/bundle.md` (bundle extraction layout) and `docs/ci/native-cli-packaging.md` (how archives are produced). -The CLI binary identifies its install route by reading a single +The CLI binary identifies its install source by reading a single `.aspire-install.json` sidecar that lives next to the binary. The sidecar's `source` field selects the extract-dir shape used by `BundleService` and, for portable installs, the Aspire home used for hives and local state. @@ -13,12 +13,12 @@ portable installs, the Aspire home used for hives and local state. (`/.aspire-install.json`) and contains exactly one field: ```json -{ "source": "" } +{ "source": "" } ``` -| `source` value | Install route | +| `source` value | Install source | |----------------|--------------------------------------------------------| -| `brew` | Homebrew cask | +| `homebrew` | Homebrew cask | | `winget` | WinGet portable manifest | | `dotnet-tool` | `dotnet tool install -g Aspire.Cli` | | `script` | `get-aspire-cli.{sh,ps1}` | @@ -27,7 +27,7 @@ portable installs, the Aspire home used for hives and local state. `BundleService.ComputeDefaultExtractDir` maps `source` to extract-dir shape: -- `winget` / `brew` / `dotnet-tool` → `binaryDir` (flat: bundle extracts beside the binary). +- `winget` / `homebrew` / `dotnet-tool` → `binaryDir` (flat: bundle extracts beside the binary). - `script` / `pr` / `localhive` → `Path.GetDirectoryName(binaryDir)` (bin layout: bundle extracts as a sibling of `bin/`). - missing, unreadable, malformed, or unknown `source` sidecar → parent-of-binary, matching the legacy heuristic for pre-sidecar installs. @@ -37,26 +37,26 @@ install. `script` and `localhive` use the parent of `bin`; `pr` uses the parent of `dogfood/pr-/bin`. Package-manager installs and sidecar-less binaries keep the default user-profile Aspire home. -## Per-route authorship +## Per-source authorship -**The shared per-RID CLI archives (`aspire-cli--*.zip` / `.tar.gz`) ship sidecar-free.** Those archives are reused across brew, winget, the release script, and the PR script — none of them owns the route label. Each route writes its own sidecar at install time. +**The shared per-RID CLI archives (`aspire-cli--*.zip` / `.tar.gz`) ship sidecar-free.** Those archives are reused across Homebrew, WinGet, the release script, and the PR script — none of them owns the source label. Each source writes its own sidecar at install time. -| Route | Archive shape | Sidecar writer | +| Source | Archive shape | Sidecar writer | |-------------|----------------------------------------|---------------------------------------------------------------------| -| brew | shared per-RID tarball | cask `postflight` block in `eng/homebrew/aspire.rb.template` | +| Homebrew | shared per-RID tarball | cask `postflight` block in `eng/homebrew/aspire.rb.template` | | winget | shared per-RID zip | CLI first-run probe (`WingetFirstRunProbe`) — uses the WinGet portable ARP registry entry to confirm the running binary was placed by winget, then stamps the sidecar | | script | shared per-RID archive | `eng/scripts/get-aspire-cli.{sh,ps1}` (post-extraction) | | PR script | shared per-RID archive | `eng/scripts/get-aspire-cli-pr.{sh,ps1}` (post-extraction) | -| dotnet-tool | route-exclusive nupkg | payload-embedded (staged by `Aspire.Cli.csproj` `_PreparePreBuiltCliBinaryForPackTool`) | -| localhive | local-only (no shared archive) | `localhive.{sh,ps1}` writes the sidecar after copying the CLI binary into `/bin/`. When `--output PATH` is used, the sidecar is written inside the output dir, which is appropriate because localhive archives are route-exclusive (only consumed as localhive installs). | +| dotnet-tool | source-exclusive nupkg | payload-embedded (staged by `Aspire.Cli.csproj` `_PreparePreBuiltCliBinaryForPackTool`) | +| localhive | local-only (no shared archive) | `localhive.{sh,ps1}` writes the sidecar after copying the CLI binary into `/bin/`. When `--output PATH` is used, the sidecar is written inside the output dir, which is appropriate because localhive archives are source-exclusive (only consumed as localhive installs). | -The dotnet-tool nupkg is the one exception that payload-embeds the sidecar: the nupkg is route-exclusive (only `dotnet tool install` consumes it), so the embedded sidecar cannot leak into another route's prefix. +The dotnet-tool nupkg is the one exception that payload-embeds the sidecar: the nupkg is source-exclusive (only `dotnet tool install` consumes it), so the embedded sidecar cannot leak into another source's prefix. ## Why no payload-embed in shared archives -Until PR 16817 the per-RID archives baked `{"source":"brew"}` (osx-*) and `{"source":"winget"}` (win-*) into the archive root via an MSBuild target. Because the osx-* tarball is also consumed by `get-aspire-cli-pr.sh`, the smuggled `brew` sidecar landed in the script-route prefix at `/dogfood/pr-/bin/.aspire-install.json`, and `BundleService` then selected `binaryDir` (the `brew` flat-layout case) as the extract dir — producing `/dogfood/pr-/bin/versions//` instead of `/dogfood/pr-/versions//`. +Until PR 16817 the per-RID archives baked source sidecars (`osx-*` as Homebrew, `win-*` as WinGet) into the archive root via an MSBuild target. Because the `osx-*` tarball is also consumed by `get-aspire-cli-pr.sh`, the smuggled `homebrew` sidecar landed in the script-source prefix at `/dogfood/pr-/bin/.aspire-install.json`, and `BundleService` then selected `binaryDir` (the `homebrew` flat-layout case) as the extract dir — producing `/dogfood/pr-/bin/versions//` instead of `/dogfood/pr-/versions//`. -Removing the MSBuild target and moving each route to author its own sidecar at install time makes the per-RID archive route-agnostic and prevents the leak by construction. +Removing the MSBuild target and moving each source to author its own sidecar at install time makes the per-RID archive source-agnostic and prevents the leak by construction. ## Producer-side invariants (build / CI) @@ -67,10 +67,10 @@ Two mechanical checks guard the contract: ## Reader-side invariants (runtime) -`BundleService.ComputeDefaultExtractDir` is the single point of truth for layout selection. It performs no path-shape detection: layout is a pure function of the sidecar `source` value (or the fallback when the sidecar is absent, unreadable, malformed, or has an unknown `source`). Unknown `source` values fall back to parent-of-binary for typed bundle layout handling. Coverage lives in `tests/Aspire.Cli.Tests/Bundles/BundleServiceCrossRouteExtractionTests.cs` as a theory over (source × prefix-shape) rows, including the cross-route case where a `brew` sidecar lands under a script-style prefix. +`BundleService.ComputeDefaultExtractDir` is the single point of truth for layout selection. It performs no path-shape detection: layout is a pure function of the sidecar `source` value (or the fallback when the sidecar is absent, unreadable, malformed, or has an unknown `source`). Unknown `source` values fall back to parent-of-binary for typed bundle layout handling. Coverage lives in `tests/Aspire.Cli.Tests/Bundles/BundleServiceCrossSourceExtractionTests.cs` as a theory over (source × prefix-shape) rows, including the cross-source case where a `homebrew` sidecar lands under a script-style prefix. -`CliPathHelper.GetAspireHomeDirectory` is the single point of truth for Aspire-home selection. It reads the same sidecar but only changes home for Aspire-owned portable routes (`script`, `pr`, and `localhive`); package-manager routes use the user-profile home because their install roots are package-manager-owned. Coverage lives in `tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs`. +`CliPathHelper.GetAspireHomeDirectory` is the single point of truth for Aspire-home selection. It reads the same sidecar but only changes home for Aspire-owned portable sources (`script`, `pr`, and `localhive`); package-manager sources use the user-profile home because their install roots are package-manager-owned. Coverage lives in `tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs`. -> **Discovery scope (dotnet-tool route).** Install discovery walks the default `dotnet tool install -g` location at `~/.dotnet/tools/.store/aspire.cli` only. Custom `--tool-path` installs are not discovered today: the dotnet CLI has no machine-wide registry of arbitrary `--tool-path` installs to enumerate, and walking the filesystem would balloon the cost of `aspire doctor`. Users with a custom-`--tool-path` install can confirm it directly with `/aspire doctor --self`. +> **Discovery scope (dotnet-tool source).** Install discovery walks the default `dotnet tool install -g` location at `~/.dotnet/tools/.store/aspire.cli` only. Custom `--tool-path` installs are not discovered today: the dotnet CLI has no machine-wide registry of arbitrary `--tool-path` installs to enumerate, and walking the filesystem would balloon the cost of `aspire installs list`. Users with a custom-`--tool-path` install can confirm it directly with `/aspire installs --self`. -For read-only install discovery (`aspire doctor --format json`), sidecar existence is the trust signal for peer probing. A candidate with any readable sidecar is probed even when `source` is not in the known route table; the raw `source` string is surfaced as the installation `route` so future package-manager routes can appear before this consumer updates. Sidecar-less, unreadable, or malformed candidates are listed without executing the binary. +For read-only install discovery (`aspire installs list --format json`), sidecar existence is the trust signal for peer probing. A candidate with any readable sidecar is probed even when `source` is not in the known source table; the raw `source` string is surfaced as the installation `source` so future package-manager sources can appear before this consumer updates. Sidecar-less, unreadable, or malformed candidates are listed without executing the binary. diff --git a/eng/clipack/Common.projitems b/eng/clipack/Common.projitems index d9bfc4e9dab..049c8403def 100644 --- a/eng/clipack/Common.projitems +++ b/eng/clipack/Common.projitems @@ -42,12 +42,12 @@