Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
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
37 changes: 37 additions & 0 deletions docs/specs/cli-output-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <reason>`, `notProbed: <reason>`) 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:
Expand Down
34 changes: 17 additions & 17 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 user-profile Aspire home.

## 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` |
| 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 `<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 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 `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 installs list`. Users with a custom-`--tool-path` install can confirm it directly with `<tool-path>/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.
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
22 changes: 11 additions & 11 deletions eng/scripts/get-aspire-cli-pr.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -1539,11 +1539,11 @@ function Test-InstallerModeEnvironment {
}
}

# Writes the PR-route install-source sidecar (.aspire-install.json) next to
# Writes the PR-source install-source sidecar (.aspire-install.json) next to
# the installed binary. Under -WhatIf, prints the target path and skips the
# write so a real user's sidecar is never overwritten by a describe pass.
# Authorship contract: docs/specs/install-routes.md.
function Write-PRRouteSidecar {
# Authorship contract: docs/specs/install-sources.md.
function Write-PRSourceSidecar {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory = $true)]
Expand All @@ -1557,12 +1557,12 @@ function Write-PRRouteSidecar {
$sidecarPath = Join-Path $sidecarDir '.aspire-install.json'
$sidecarContent = "{""source"":""pr""}`n"

if ($PSCmdlet.ShouldProcess($sidecarPath, "Write route sidecar")) {
if ($PSCmdlet.ShouldProcess($sidecarPath, "Write source sidecar")) {
[System.IO.Directory]::CreateDirectory($sidecarDir) | Out-Null
[System.IO.File]::WriteAllText($sidecarPath, $sidecarContent)
}
else {
Write-Host "What if: Route sidecar would be written to: $sidecarPath"
Write-Host "What if: Source sidecar would be written to: $sidecarPath"
}
}

Expand Down Expand Up @@ -1701,8 +1701,8 @@ function Start-InstallFromLocalDir {
Write-Message "Installing from local directory: $LocalDirPath" -Level Info

# Set installation paths.
# PR-route installs are isolated under <prefix>/dogfood/pr-<N>/bin so they don't
# collide with the script-route prefix or with other PR installs. Hives remain shared
# PR-source installs are isolated under <prefix>/dogfood/pr-<N>/bin so they don't
# collide with the script-source prefix or with other PR installs. Hives remain shared
# under <prefix>/hives/<label>/packages.
$cliBinDir = if ($PRNumber -gt 0) {
Join-Path (Join-Path (Join-Path $resolvedInstallPrefix "dogfood") "pr-$PRNumber") "bin"
Expand Down Expand Up @@ -1791,7 +1791,7 @@ function Start-InstallFromLocalDir {
# PR installs from archives get a sidecar; --local-dir installs are unmanaged,
# and dotnet-tool packages embed their own source=dotnet-tool sidecar.
if (-not $HiveOnly -and $InstallMode -eq 'Archive' -and $PRNumber -gt 0) {
Write-PRRouteSidecar -InstallPrefix $resolvedInstallPrefix -PRNumber $PRNumber
Write-PRSourceSidecar -InstallPrefix $resolvedInstallPrefix -PRNumber $PRNumber
}

# Update PATH environment variables
Expand Down Expand Up @@ -1837,8 +1837,8 @@ function Start-DownloadAndInstall {
Write-Message "Using workflow run https://github.com/$Script:Repository/actions/runs/$runId" -Level Info

# Set installation paths.
# PR-route installs are isolated under <prefix>/dogfood/pr-<N>/bin so they don't
# collide with the script-route prefix or with other PR installs. Hives remain shared
# PR-source installs are isolated under <prefix>/dogfood/pr-<N>/bin so they don't
# collide with the script-source prefix or with other PR installs. Hives remain shared
# under <prefix>/hives/<label>/packages.
$cliBinDir = if ($PRNumber -gt 0) {
Join-Path (Join-Path (Join-Path $resolvedInstallPrefix "dogfood") "pr-$PRNumber") "bin"
Expand Down Expand Up @@ -1927,7 +1927,7 @@ function Start-DownloadAndInstall {
}

if (-not $HiveOnly -and $InstallMode -eq 'Archive' -and $PRNumber -gt 0) {
Write-PRRouteSidecar -InstallPrefix $resolvedInstallPrefix -PRNumber $PRNumber
Write-PRSourceSidecar -InstallPrefix $resolvedInstallPrefix -PRNumber $PRNumber
}

# Update PATH environment variables
Expand Down
Loading
Loading