Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
fbce609
refactor(cli): rename install-route identifiers to install-source
radical May 26, 2026
85a1752
fix(cli): preserve on-disk executable casing in PathLookupHelper on W…
radical May 26, 2026
402388b
feat(cli): add `aspire --info` to enumerate the running CLI's installs
radical May 26, 2026
bba13cd
Merge remote-tracking branch 'origin/main' into radical/installs-disc…
radical May 26, 2026
3c6d5a0
fix(cli): make --info informational gate position-aware
radical May 27, 2026
a181d18
Merge remote-tracking branch 'origin/main' into radical/installs-disc…
radical May 27, 2026
2860545
refactor(cli): unify aspire --info JSON wire shape
radical May 27, 2026
d1a2479
fix(cli): stop walking the Windows registry on every non-winget startup
radical May 27, 2026
84e412d
fix(cli): tolerate broken AspireCliChannel metadata in NewCommand/Upd…
radical May 27, 2026
abd887c
fix(cli): IsInformationalInvocation misclassifies `--info` after root…
radical May 27, 2026
4ccfb90
fix(cli): stop writing winget-backfill sentinel on non-Windows
radical May 27, 2026
4b9003e
fix(cli): make `aspire --info` accept non-lowercase Windows binary names
radical May 27, 2026
0646764
fix(cli): prevent duplicate `id` values in `aspire --info` output
radical May 27, 2026
c21a614
test(cli): pin rendered `--self`/`--format` token in parse-error mess…
radical May 27, 2026
f174ca1
test(cli): route Azure-Monitor-enabled --info gate tests through host…
radical May 27, 2026
dafaf4f
review(cli): address Copilot review pass on installs-discovery PR
radical May 27, 2026
ddf56f7
Merge remote-tracking branch 'origin/main' into radical/installs-disc…
radical May 27, 2026
e87b52b
fix(cli): escape Markdown special chars in `aspire --info` row headings
radical May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/dogfooding-pull-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down
68 changes: 68 additions & 0 deletions docs/specs/cli-output-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,74 @@ 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 --info`

`aspire --info --format json` emits a JSON object combining the running CLI's version, its identity channel, and the list of Aspire CLI installs and orphan package hives that the running CLI can discover:

```json
{
"version": "13.2.0",
"channel": "stable",
"installs": [
{
"id": "script",
"kind": "script",
"path": "/home/user/.aspire/bin/aspire",
"canonicalPath": "/home/user/.aspire/bin/aspire",
"version": "13.2.0",
"channel": "stable",
"source": "script",
"hive": "/home/user/.aspire/hives/stable",
"pathStatus": "active",
"status": "ok"
},
{
"id": "pr-17400",
"kind": "orphan-hive",
"channel": "pr-17400",
"hive": "/home/user/.aspire/hives/pr-17400",
"pathStatus": "notOnPath",
"status": "no install found",
"statusReason": "No discovered install reports this hive's channel."
},
{
"id": "stable-2",
"kind": "homebrew",
"path": "/opt/homebrew/Caskroom/aspire/13.2.0/aspire",
"canonicalPath": "/opt/homebrew/Caskroom/aspire/13.2.0/aspire",
"version": "13.2.0",
"channel": "stable",
"source": "brew",
"pathStatus": "shadowed",
"status": "ok",
"managedBy": "homebrew"
},
{
"id": "pr-17500",
"kind": "pr",
"path": "/home/user/.aspire/dogfood/pr-17500/bin/aspire",
"canonicalPath": "/home/user/.aspire/dogfood/pr-17500/bin/aspire",
"channel": "pr-17500",
"source": "pr",
"pathStatus": "notOnPath",
"status": "failed",
"statusReason": "Peer probe exited with code 137 (SIGKILL)."
}
]
}
```

Each entry in `installs[*]` is an `InstallationInfo` row — the same record shape used by `aspire --info --self --format json` (described below). The two status axes are orthogonal:

- `status` is the lifecycle: `ok` (usable), `failed` (probe attempted but did not return usable data), `notProbed` (listed but not executed because required install metadata was missing or invalid), or `no install found` (orphan-hive directories with no matching binary).
- `pathStatus` is the PATH-axis: `active` (first `aspire` resolved from `$PATH`), `shadowed` (on `$PATH` but masked by an earlier entry), or `notOnPath` (not discovered via `$PATH`).

Programmatic consumers should `switch` on each axis independently. The `statusReason` field carries a human-readable explanation for non-`ok` rows; the `status` value itself stays enum-shaped. Nullable fields are omitted from JSON when absent (e.g., orphan-hive rows omit `path`, `canonicalPath`, `version`, `source`, `managedBy`).

The top-level `channel` is the running CLI's identity (`local`, `stable`, `staging`, `daily`, or `pr-<N>`). It is omitted from the JSON output when the running binary has missing or invalid `AspireCliChannel` assembly metadata; the command still exits 0 so users can use `aspire --info` to diagnose a broken binary. The `installs` array is always present, possibly empty.

`aspire --info --self --format json` is a hidden modifier used by the install-discovery peer-probe path so a newer CLI can ask a peer CLI to describe itself. Its output is a single-element bare array whose element is an `InstallationInfo` row — the same record shape as `installs[*]` above. The `--self` row populates per-binary fields (`path`, `canonicalPath`, `version`, `channel`, `source`, `pathStatus`, `status`) for the running binary; the aggregate-only fields (`id`, `kind`, `hive`, `managedBy`) are omitted from a `--self` response because they are properties of the enumeration, not of the binary. This is the internal cross-version contract between Aspire CLI builds and is not a stable surface for external tooling; it 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:
Expand Down
36 changes: 18 additions & 18 deletions docs/specs/install-routes.md → docs/specs/install-sources.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -13,10 +13,10 @@ portable installs, the Aspire home used for hives and local state.
(`<binaryDir>/.aspire-install.json`) and contains exactly one field:

```json
{ "source": "<route>" }
{ "source": "<source>" }
```

| `source` value | Install route |
| `source` value | Install source |
|----------------|--------------------------------------------------------|
| `brew` | Homebrew cask |
| `winget` | WinGet portable manifest |
Expand All @@ -37,26 +37,26 @@ install. `script` and `localhive` use the parent of `bin`; `pr` uses the parent
of `dogfood/pr-<N>/bin`. Package-manager installs and sidecar-less binaries keep
the default Aspire home (`ASPIRE_HOME` when set, otherwise `$HOME/.aspire`).

## Per-route authorship
## Per-source authorship

**The shared per-RID CLI archives (`aspire-cli-<rid>-*.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-<rid>-*.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` |
| 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 |
| Homebrew | shared per-RID tarball | cask `postflight` block in `eng/homebrew/aspire.rb.template` |
| winget | shared per-RID zip | CLI sidecar back-fill (`WingetSidecarBackfill`) — 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 `<prefix>/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 `<prefix>/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 `<prefix>/dogfood/pr-<N>/bin/.aspire-install.json`, and `BundleService` then selected `binaryDir` (the `brew` flat-layout case) as the extract dir — producing `<prefix>/dogfood/pr-<N>/bin/versions/<v>/` instead of `<prefix>/dogfood/pr-<N>/versions/<v>/`.
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 `brew` sidecar landed in the script-source prefix at `<prefix>/dogfood/pr-<N>/bin/.aspire-install.json`, and `BundleService` then selected `binaryDir` (the `brew` flat-layout case) as the extract dir — producing `<prefix>/dogfood/pr-<N>/bin/versions/<v>/` instead of `<prefix>/dogfood/pr-<N>/versions/<v>/`.

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)

Expand All @@ -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 the default Aspire home so unrecognized installs do not try to write next to the CLI binary. 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 the default Aspire home so unrecognized installs do not try to write next to the CLI binary. 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 `brew` 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 `<tool-path>/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 --info`. Users with a custom-`--tool-path` install can confirm it directly with `<tool-path>/aspire --info --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 --info --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.
10 changes: 5 additions & 5 deletions eng/clipack/Common.projitems
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,12 @@
</Target>

<!--
No install-route sidecar is staged into the per-RID CLI archive. The
shared archives are sidecar-free by contract; each route writes its own
No install-source sidecar is staged into the per-RID CLI archive. The
shared archives are sidecar-free by contract; each source writes its own
.aspire-install.json at install time. The dotnet-tool nupkg is the
exception (route-exclusive, payload-embedded by
exception (source-exclusive, payload-embedded by
src/Aspire.Cli/Aspire.Cli.csproj _PreparePreBuiltCliBinaryForPackTool).
See docs/specs/install-routes.md for the full authorship table.
See docs/specs/install-sources.md for the full authorship table.

_AssertNoSidecarInArchiveStaging below is the build-time guard that
fails the build if a regression starts staging the sidecar back in.
Expand All @@ -59,7 +59,7 @@
<_ArchiveSidecarPath>$([MSBuild]::NormalizePath($(OutputPath), '.aspire-install.json'))</_ArchiveSidecarPath>
</PropertyGroup>
<Error Condition="Exists('$(_ArchiveSidecarPath)')"
Text="Per-RID CLI archives must not contain '.aspire-install.json' — shared across install routes. See docs/specs/install-routes.md. Found smuggled sidecar at $(_ArchiveSidecarPath)." />
Text="Per-RID CLI archives must not contain '.aspire-install.json' — shared across install sources. See docs/specs/install-sources.md. Found smuggled sidecar at $(_ArchiveSidecarPath)." />
</Target>

<Target Name="_PublishProject">
Expand Down
Loading
Loading