Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
41e2def
feat(cli): `aspire doctor` lists every Aspire CLI install on the machine
radical May 20, 2026
6703ce2
fix(cli): show Aspire integrations from local hive in `aspire add`
radical May 20, 2026
8b0f3a0
fix(cli): stop `localhive` script from flipping the global Aspire cha…
radical May 20, 2026
be48b66
fix(cli): address PR review feedback
radical May 20, 2026
ab4517f
Merge remote-tracking branch 'origin/main' into pr/17105/aspire-commands
radical May 20, 2026
858825b
test(cli): widen PeerInstallProbe failure-test timeout to absorb CI n…
radical May 20, 2026
3f2a71b
refactor(cli/acquisition): unify PeerInstallProbe capped-output readers
radical May 20, 2026
89c5729
refactor(cli/packaging): inline IsAllowedByQuality as a local function
radical May 20, 2026
08e3f16
refactor(cli/acquisition): factor manually-driven enumerator pattern …
radical May 20, 2026
e29f18c
refactor(cli/acquisition): use GeneratedRegex for PR-channel version …
radical May 20, 2026
3f5455f
test(cli): pin doctor --format json output to the real stdout sink
radical May 20, 2026
0d48260
fix(cli/localhive): filter copy-mode nupkgs by version suffix
radical May 20, 2026
89e495a
test(cli/acquisition): hoist CapturingLogger, EnvVarOverride, Unknown…
radical May 20, 2026
bac8b64
test(cli/acquisition): pin sidecar BOM and unknown-field tolerance
radical May 20, 2026
a9dc662
fix(cli/packaging): honor ShowDeprecatedPackages on local-folder inte…
radical May 20, 2026
4fb4e5b
refactor(cli/acquisition): reuse pre-resolved canonical path for $PAT…
radical May 20, 2026
04fc313
Merge branch 'main' into aspire-commands
radical May 20, 2026
a4ebf2a
test(cli/acquisition): surface probe logs and Failed.Reason in PeerIn…
radical May 20, 2026
31433a1
feat(cli/doctor): align --format json with other JSON commands (no du…
radical May 20, 2026
4aa03d1
chore(cli): address self-review TODOs (FileExistsSafe rename + SkipOn…
radical May 20, 2026
d58445e
fix(localhive): include dotfile install-route sidecar in win-* zip ar…
radical May 20, 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
23 changes: 19 additions & 4 deletions docs/specs/install-routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

The CLI binary identifies its install route 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`.
`source` field selects the extract-dir shape used by `BundleService` and, for
portable installs, the Aspire home used for hives and local state.

## File contract

Expand All @@ -22,12 +23,19 @@ The CLI binary identifies its install route by reading a single
| `dotnet-tool` | `dotnet tool install -g Aspire.Cli` |
| `script` | `get-aspire-cli.{sh,ps1}` |
| `pr` | `get-aspire-cli-pr.{sh,ps1}` |
| `localhive` | `localhive.{sh,ps1}` (locally-built dev install) |

`BundleService.ComputeDefaultExtractDir` maps `source` to extract-dir shape:

- `winget` / `brew` / `dotnet-tool` → `binaryDir` (flat: bundle extracts beside the binary).
- `script` / `pr` → `Path.GetDirectoryName(binaryDir)` (bin layout: bundle extracts as a sibling of `bin/`).
- missing or unknown sidecar → parent-of-binary, matching the legacy heuristic for pre-sidecar installs.
- `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.

`CliPathHelper.GetAspireHomeDirectory` maps sidecar-owned portable installs to
their install prefix so hives, caches, logs, and SDK state stay with the
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

Expand All @@ -40,6 +48,7 @@ The CLI binary identifies its install route by reading a single
| 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). |

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.

Expand All @@ -58,4 +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 or unreadable). 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/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.

`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`.

> **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`.

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.
113 changes: 88 additions & 25 deletions localhive.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,22 @@ function Test-VersionSuffix {
return $true
}

# Restrict hive names to a safe identifier set: this value is joined into
# $hivesRoot\$Name and then passed to Remove-Item -Recurse, so any path
# separator or '..' segment would let the removal escape the hives root.
function Test-HiveName {
param([Parameter(Mandatory)][string]$HiveName)
if ([string]::IsNullOrEmpty($HiveName)) { return $false }
if ($HiveName -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$') { return $false }
if ($HiveName.Contains('..')) { return $false }
return $true
}

if (-not (Test-HiveName -HiveName $Name)) {
Write-Err "Invalid hive name '$Name'. Hive names must match [A-Za-z0-9][A-Za-z0-9._-]* and cannot contain path separators or '..'."
exit 1
}

# Auto-generate version suffix if not specified
if (-not $VersionSuffix) {
$utc = [DateTime]::UtcNow
Expand Down Expand Up @@ -272,15 +288,43 @@ if (Test-Path -LiteralPath $hiveRoot) {
}

function Copy-PackagesToHive {
param([string]$Source,[string]$Destination)
# When $VersionSuffix is non-empty, only .nupkg filenames containing the suffix are copied,
# and zero matches is treated as a hard failure. This mirrors localhive.sh's $USE_COPY path
# (localhive.sh:308-322) and exists because PackagingService.GetLocalHivePinnedVersion picks
# the *highest* SemVer-precedence package in the hive
# (src/Aspire.Cli/Packaging/PackagingService.cs:338-350). Without the filter, leftover packages
# from a prior localhive run with a higher-precedence suffix would silently pin this hive to
# that stale version.
# When $VersionSuffix is empty, all .nupkg files are copied — this is used only by the
# symlink/junction failure fallback below, where the user did not opt into copy mode and
# parity with the bash fallback (localhive.sh:329-342) takes priority over the staleness
# check.
param(
[string]$Source,
[string]$Destination,
[string]$VersionSuffix
)
New-Item -ItemType Directory -Path $Destination -Force | Out-Null
Get-ChildItem -LiteralPath $Source -Filter *.nupkg -File | Copy-Item -Destination $Destination -Force
$candidates = Get-ChildItem -LiteralPath $Source -Filter *.nupkg -File
if ($VersionSuffix) {
$candidates = $candidates | Where-Object { $_.Name -like "*$VersionSuffix*" }
}
$copied = 0
foreach ($pkg in $candidates) {
Copy-Item -LiteralPath $pkg.FullName -Destination $Destination -Force
$copied++
}
if ($VersionSuffix -and $copied -eq 0) {
Write-Err "No .nupkg files matching version suffix '$VersionSuffix' found in $Source."
exit 1
}
return $copied
}

if ($Copy) {
Write-Log "Populating hive '$Name' by copying .nupkg files"
Copy-PackagesToHive -Source $pkgDir -Destination $hivePath
Write-Log "Created/updated hive '$Name' at $hivePath (copied packages)."
Write-Log "Populating hive '$Name' by copying .nupkg files (version suffix: $VersionSuffix)"
$copied = Copy-PackagesToHive -Source $pkgDir -Destination $hivePath -VersionSuffix $VersionSuffix
Write-Log "Created/updated hive '$Name' at $hivePath (copied $copied packages)."
}
else {
Write-Log "Linking hive '$Name/packages' to $pkgDir"
Expand All @@ -298,8 +342,10 @@ else {
}
catch {
Write-Warn "Link creation failed; copying .nupkg files instead"
Copy-PackagesToHive -Source $pkgDir -Destination $hivePath
Write-Log "Created/updated hive '$Name' at $hivePath (copied packages)."
# Fallback path: user did not request -Copy, so mirror the unfiltered bash fallback
# (localhive.sh:329-342) and copy everything to maximize the chance the build succeeds.
$copied = Copy-PackagesToHive -Source $pkgDir -Destination $hivePath -VersionSuffix ''
Write-Log "Created/updated hive '$Name' at $hivePath (copied $copied packages)."
}
}
}
Expand Down Expand Up @@ -365,18 +411,24 @@ if (-not $SkipCli) {
exit 1
}
} else {
# Framework-dependent CLI with embedded bundle payload
$cliProj = Join-Path $RepoRoot "src" "Aspire.Cli" "Aspire.Cli.Tool.csproj"
$cliPublishDir = Join-Path $RepoRoot "artifacts" "bin" "Aspire.Cli.Tool" $effectiveConfig "net10.0" "publish"
if ($bundlePayloadArchive) {
Write-Log "Publishing Aspire CLI (dotnet tool) with embedded bundle payload..."
& dotnet publish $cliProj -c $effectiveConfig "/p:VersionSuffix=$VersionSuffix" "/p:BundlePayloadPath=$($bundlePayloadArchive.FullName)"
# NativeAOT CLI (Aspire.Cli.csproj sets PublishAot=true) with embedded bundle payload.
# Publish output is RID-specific when we pass -r, so the path includes $bundleRid.
$cliPublishDir = Join-Path $RepoRoot "artifacts" "bin" "Aspire.Cli.Tool" $effectiveConfig "net10.0" $bundleRid "publish"
Write-Log "Publishing Aspire CLI (dotnet tool, native AOT) with embedded bundle payload..."
& dotnet publish $cliProj -c $effectiveConfig -r $bundleRid "/p:VersionSuffix=$VersionSuffix" "/p:BundlePayloadPath=$($bundlePayloadArchive.FullName)"
if ($LASTEXITCODE -ne 0) {
Write-Err "CLI publish with embedded bundle failed."
exit 1
}
} elseif (-not (Test-Path -LiteralPath $cliPublishDir)) {
$cliPublishDir = Join-Path $RepoRoot "artifacts" "bin" "Aspire.Cli.Tool" $effectiveConfig "net10.0"
} else {
# -SkipBundle builds Aspire.Cli.Tool with PublishAot=false, which keeps the
# historical framework-dependent, non-RID output layout.
$cliPublishDir = Join-Path $RepoRoot "artifacts" "bin" "Aspire.Cli.Tool" $effectiveConfig "net10.0" "publish"
if (-not (Test-Path -LiteralPath $cliPublishDir)) {
$cliPublishDir = Join-Path $RepoRoot "artifacts" "bin" "Aspire.Cli.Tool" $effectiveConfig "net10.0"
}
}
}

Expand All @@ -402,9 +454,12 @@ if (-not $SkipCli) {
}
}

$installedCliPath = Join-Path $cliBinDir $cliExeName

try {
# Copy all files from the publish directory (CLI and its dependencies)
# Use -ErrorAction SilentlyContinue for individual files that may be locked by running processes
# Capture individual copy failures so we can restore the previous CLI and avoid stamping
# a sidecar onto a stale or partial install.
$copyErrors = @()
Get-ChildItem -LiteralPath $cliPublishDir -File | ForEach-Object {
try {
Expand All @@ -415,7 +470,11 @@ if (-not $SkipCli) {
}
}
if ($copyErrors.Count -gt 0) {
Write-Warn "$($copyErrors.Count) file(s) could not be overwritten (likely locked by a running process). The CLI executable was updated successfully."
throw "Failed to copy $($copyErrors.Count) CLI file(s) from $cliPublishDir to $cliBinDir. First error: $($copyErrors[0])"
}

if (-not (Test-Path -LiteralPath $installedCliPath)) {
throw "Installed CLI executable was not found at $installedCliPath"
}

# Clean up old backup files
Expand All @@ -431,20 +490,23 @@ if (-not $SkipCli) {
throw
}

$installedCliPath = Join-Path $cliBinDir $cliExeName
# Stamp the install-route sidecar so `aspire info` / `aspire uninstall`
# can identify this binary as a locally-built (`localhive`) install.
# The format matches docs/specs/install-routes.md exactly; localhive
# shares the script-route layout (binary under <prefix>/bin/, bundle
# extracted at parent-of-bin).
$sidecarPath = Join-Path $cliBinDir ".aspire-install.json"
Set-Content -LiteralPath $sidecarPath -Value '{"source":"localhive"}' -Encoding UTF8 -NoNewline
Comment thread
radical marked this conversation as resolved.

Write-Log "Aspire CLI installed to: $installedCliPath"

if (-not $Output) {
# Set the channel to the local hive so templates and packages resolve from it
& $installedCliPath config set channel $Name -g 2>$null
Write-Log "Set global channel to '$Name'"

# Check if the bin directory is in PATH
$pathSeparator = [System.IO.Path]::PathSeparator
$currentPathArray = $env:PATH.Split($pathSeparator, [StringSplitOptions]::RemoveEmptyEntries)
$currentPathArray = if ($env:PATH) { $env:PATH.Split($pathSeparator, [StringSplitOptions]::RemoveEmptyEntries) } else { @() }
Write-Log "Run Aspire directly with: $installedCliPath"
if ($currentPathArray -notcontains $cliBinDir) {
Write-Warn "The CLI bin directory is not in your PATH."
Write-Log "Add it to your PATH with: `$env:PATH = '$cliBinDir' + '$pathSeparator' + `$env:PATH"
$env:PATH = (@($cliBinDir) + $currentPathArray) -join $pathSeparator
Write-Log "Added $cliBinDir to PATH for this PowerShell session."
}
}
}
Expand Down Expand Up @@ -479,10 +541,11 @@ if ($Output) {
Write-Log "To install on the target machine:"
if ($bundleRid -like 'win-*') {
Write-Log " Expand-Archive -Path $(Split-Path $archivePath -Leaf) -DestinationPath `$HOME\.aspire"
Write-Log " `$HOME\.aspire\bin\aspire.exe"
} else {
Write-Log " mkdir -p ~/.aspire && tar -xzf $(Split-Path $archivePath -Leaf) -C ~/.aspire"
Write-Log " ~/.aspire/bin/aspire"
}
Write-Log " ~/.aspire/bin/aspire config set channel '$Name' -g"
}
} else {
Write-Log "Aspire CLI will discover a channel named '$Name' from:"
Expand Down
Loading
Loading