Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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.
69 changes: 51 additions & 18 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 @@ -365,18 +381,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 +424,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 +440,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 +460,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 +511,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
107 changes: 83 additions & 24 deletions localhive.sh
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,24 @@ is_valid_versionsuffix() {
return 0
}

# Restrict hive names to a safe identifier set: this value is concatenated
# into $HIVES_ROOT/$HIVE_NAME and then passed to `rm -rf`, so any path
# separator, leading dot, or `..` segment would let the removal target
# escape the hives directory and delete arbitrary parent paths.
is_valid_hivename() {
local s="$1"
if [[ -z "$s" ]]; then
return 1
fi
if [[ ! "$s" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
return 1
fi
if [[ "$s" == *".."* ]]; then
return 1
fi
return 0
}


# Parse flags and positional fallbacks
while [[ $# -gt 0 ]]; do
Expand Down Expand Up @@ -157,6 +175,11 @@ if [[ $ARCHIVE -eq 1 ]] && [[ -z "$OUTPUT_DIR" ]]; then
exit 1
fi

if ! is_valid_hivename "$HIVE_NAME"; then
error "Invalid hive name '$HIVE_NAME'. Hive names must match [A-Za-z0-9][A-Za-z0-9._-]* and cannot contain path separators or '..'."
exit 1
fi

if [[ -n "$TARGET_RID" ]] && [[ $NATIVE_AOT -eq 1 ]]; then
# Detect if this is a cross-OS build (e.g. building linux-x64 on macOS)
HOST_OS="$(uname -s)"
Expand Down Expand Up @@ -256,6 +279,12 @@ else
esac
fi

if [[ "$BUNDLE_RID" == win-* ]]; then
CLI_EXE_NAME="aspire.exe"
else
CLI_EXE_NAME="aspire"
fi

if [[ -n "$OUTPUT_DIR" ]]; then
ASPIRE_ROOT="$OUTPUT_DIR"
else
Expand Down Expand Up @@ -326,12 +355,18 @@ if [[ $SKIP_BUNDLE -eq 0 ]]; then

if [[ $NATIVE_AOT -eq 1 ]]; then
log "Building bundle (aspire-managed + DCP + native AOT CLI)..."
set +e
dotnet build "$BUNDLE_PROJ" -c "$EFFECTIVE_CONFIG" "/p:VersionSuffix=$VERSION_SUFFIX" "/p:TargetRid=$BUNDLE_RID"
rc=$?
set -e
else
log "Building bundle (aspire-managed + DCP)..."
set +e
dotnet build "$BUNDLE_PROJ" -c "$EFFECTIVE_CONFIG" /p:SkipNativeBuild=true "/p:VersionSuffix=$VERSION_SUFFIX" "/p:TargetRid=$BUNDLE_RID"
rc=$?
set -e
fi
if [[ $? -ne 0 ]]; then
if [[ $rc -ne 0 ]]; then
error "Bundle build failed."
exit 1
fi
Expand Down Expand Up @@ -365,24 +400,36 @@ if [[ $SKIP_CLI -eq 0 ]]; then
if [[ -n "$BUNDLE_PAYLOAD_ARCHIVE" ]]; then
PUBLISH_ARGS+=("/p:BundlePayloadPath=$BUNDLE_PAYLOAD_ARCHIVE")
fi
set +e
dotnet publish "$CLI_PROJ" "${PUBLISH_ARGS[@]}"
if [[ $? -ne 0 ]]; then
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
error "CLI publish for RID $TARGET_RID failed."
exit 1
fi
else
# Framework-dependent CLI with embedded bundle payload
CLI_PROJ="$REPO_ROOT/src/Aspire.Cli/Aspire.Cli.Tool.csproj"
CLI_PUBLISH_DIR="$REPO_ROOT/artifacts/bin/Aspire.Cli.Tool/$EFFECTIVE_CONFIG/net10.0/publish"
if [[ -n "$BUNDLE_PAYLOAD_ARCHIVE" ]]; then
log "Publishing Aspire CLI (dotnet tool) with embedded bundle payload..."
dotnet publish "$CLI_PROJ" -c "$EFFECTIVE_CONFIG" "/p:VersionSuffix=$VERSION_SUFFIX" "/p:BundlePayloadPath=$BUNDLE_PAYLOAD_ARCHIVE"
if [[ $? -ne 0 ]]; then
# 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 $BUNDLE_RID.
CLI_PUBLISH_DIR="$REPO_ROOT/artifacts/bin/Aspire.Cli.Tool/$EFFECTIVE_CONFIG/net10.0/$BUNDLE_RID/publish"
log "Publishing Aspire CLI (dotnet tool, native AOT) with embedded bundle payload..."
set +e
dotnet publish "$CLI_PROJ" -c "$EFFECTIVE_CONFIG" -r "$BUNDLE_RID" "/p:VersionSuffix=$VERSION_SUFFIX" "/p:BundlePayloadPath=$BUNDLE_PAYLOAD_ARCHIVE"
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
error "CLI publish with embedded bundle failed."
exit 1
fi
elif [[ ! -d "$CLI_PUBLISH_DIR" ]]; then
CLI_PUBLISH_DIR="$REPO_ROOT/artifacts/bin/Aspire.Cli.Tool/$EFFECTIVE_CONFIG/net10.0"
else
# --skip-bundle builds Aspire.Cli.Tool with PublishAot=false, which keeps the
# historical framework-dependent, non-RID output layout.
CLI_PUBLISH_DIR="$REPO_ROOT/artifacts/bin/Aspire.Cli.Tool/$EFFECTIVE_CONFIG/net10.0/publish"
if [[ ! -d "$CLI_PUBLISH_DIR" ]]; then
CLI_PUBLISH_DIR="$REPO_ROOT/artifacts/bin/Aspire.Cli.Tool/$EFFECTIVE_CONFIG/net10.0"
fi
fi

if [[ ! -f "$CLI_PUBLISH_DIR/aspire" ]]; then
Expand All @@ -396,7 +443,7 @@ if [[ $SKIP_CLI -eq 0 ]]; then
fi
fi

CLI_SOURCE_PATH="$CLI_PUBLISH_DIR/aspire"
CLI_SOURCE_PATH="$CLI_PUBLISH_DIR/$CLI_EXE_NAME"

if [ -f "$CLI_SOURCE_PATH" ]; then
if [[ $NATIVE_AOT -eq 1 ]]; then
Expand All @@ -407,24 +454,31 @@ if [[ $SKIP_CLI -eq 0 ]]; then
mkdir -p "$CLI_BIN_DIR"

# Copy all files from the publish directory (CLI and its dependencies)
cp -f "$CLI_PUBLISH_DIR"/* "$CLI_BIN_DIR"/ 2>/dev/null || true
if ! cp -f "$CLI_PUBLISH_DIR"/* "$CLI_BIN_DIR"/; then
error "Failed to copy CLI files from $CLI_PUBLISH_DIR to $CLI_BIN_DIR"
exit 1
fi

# Ensure the CLI is executable
chmod +x "$CLI_BIN_DIR/aspire"
chmod +x "$CLI_BIN_DIR/$CLI_EXE_NAME"
if [[ ! -f "$CLI_BIN_DIR/$CLI_EXE_NAME" ]]; then
error "Installed CLI executable was not found at $CLI_BIN_DIR/$CLI_EXE_NAME"
exit 1
fi

log "Aspire CLI installed to: $CLI_BIN_DIR/aspire"
# 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).
printf '%s' '{"source":"localhive"}' > "$CLI_BIN_DIR/.aspire-install.json"

if [[ -z "$OUTPUT_DIR" ]]; then
if "$CLI_BIN_DIR/aspire" config set channel "$HIVE_NAME" -g >/dev/null 2>&1; then
log "Set global channel to '$HIVE_NAME'"
else
warn "Failed to set global channel to '$HIVE_NAME'. Run: aspire config set channel '$HIVE_NAME' -g"
fi
log "Aspire CLI installed to: $CLI_BIN_DIR/$CLI_EXE_NAME"

# Check if the bin directory is in PATH
if [[ -z "$OUTPUT_DIR" ]]; then
log "Run Aspire directly with: $CLI_BIN_DIR/$CLI_EXE_NAME"
if [[ ":$PATH:" != *":$CLI_BIN_DIR:"* ]]; then
warn "The CLI bin directory is not in your PATH."
log "Add it to your PATH with: export PATH=\"$CLI_BIN_DIR:\$PATH\""
log "For this shell only, run: export PATH=\"$CLI_BIN_DIR:\$PATH\""
fi
fi
else
Expand Down Expand Up @@ -458,8 +512,13 @@ if [[ -n "$OUTPUT_DIR" ]]; then
log "Archive: $ARCHIVE_PATH"
log ""
log "To install on the target machine:"
log " mkdir -p ~/.aspire && tar -xzf $(basename "$ARCHIVE_PATH") -C ~/.aspire"
log " ~/.aspire/bin/aspire config set channel '$HIVE_NAME' -g"
if [[ "$BUNDLE_RID" == win-* ]]; then
log " Expand-Archive -Path $(basename "$ARCHIVE_PATH") -DestinationPath \$HOME\\.aspire"
log " \$HOME\\.aspire\\bin\\aspire.exe"
else
log " mkdir -p ~/.aspire && tar -xzf $(basename "$ARCHIVE_PATH") -C ~/.aspire"
log " ~/.aspire/bin/aspire"
fi
fi
else
log "Aspire CLI will discover a channel named '$HIVE_NAME' from:"
Expand Down
Loading
Loading