diff --git a/docs/specs/install-routes.md b/docs/specs/install-routes.md index 19ae3bf957f..4def1a0bdd5 100644 --- a/docs/specs/install-routes.md +++ b/docs/specs/install-routes.md @@ -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 @@ -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-/bin`. Package-manager installs and sidecar-less binaries keep +the default user-profile Aspire home. ## Per-route authorship @@ -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 `/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. @@ -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 `/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. diff --git a/localhive.ps1 b/localhive.ps1 index a87e05744df..bdea8c63a36 100644 --- a/localhive.ps1 +++ b/localhive.ps1 @@ -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 @@ -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" @@ -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)." } } } @@ -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" + } } } @@ -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 { @@ -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 @@ -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 /bin/, bundle + # extracted at parent-of-bin). + $sidecarPath = Join-Path $cliBinDir ".aspire-install.json" + Set-Content -LiteralPath $sidecarPath -Value '{"source":"localhive"}' -Encoding UTF8 -NoNewline + 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." } } } @@ -459,7 +521,47 @@ if ($Archive) { if ($bundleRid -like 'win-*') { $archivePath = "$Output.zip" Write-Log "Creating archive: $archivePath" - Compress-Archive -Path (Join-Path $Output '*') -DestinationPath $archivePath -Force + + # Use System.IO.Compression.ZipFile::CreateFromDirectory rather than + # Compress-Archive. Compress-Archive enumerates inputs via the PowerShell + # provider, which on non-Windows hosts treats files whose name starts with + # '.' as hidden and excludes them from `/*` wildcard expansion. The + # portable layout includes bin/.aspire-install.json — the localhive route + # sidecar that `aspire doctor` and route-aware Aspire-home selection rely + # on (see docs/specs/install-routes.md) — and silently dropping it from + # win-* zips built on Linux/macOS would produce sidecar-less installs on + # the target machine. ZipFile walks the filesystem directly and includes + # dotfiles unconditionally. + Add-Type -AssemblyName System.IO.Compression.FileSystem + if (Test-Path -LiteralPath $archivePath) { + Remove-Item -LiteralPath $archivePath -Force + } + [System.IO.Compression.ZipFile]::CreateFromDirectory( + $Output, + $archivePath, + [System.IO.Compression.CompressionLevel]::Optimal, + $false) + + # Belt-and-suspenders: if the portable layout has a sidecar on disk, the + # archive MUST also have it. Catches future regressions of the dotfile + # issue (e.g. a switch back to Compress-Archive or a wildcard-based copy). + $sidecarOnDisk = Join-Path $Output (Join-Path 'bin' '.aspire-install.json') + if (Test-Path -LiteralPath $sidecarOnDisk) { + $sidecarEntryName = 'bin/.aspire-install.json' + $zip = [System.IO.Compression.ZipFile]::OpenRead($archivePath) + try { + $hasSidecar = $false + foreach ($entry in $zip.Entries) { + if ($entry.FullName -eq $sidecarEntryName) { $hasSidecar = $true; break } + } + } + finally { + $zip.Dispose() + } + if (-not $hasSidecar) { + throw "Archive '$archivePath' is missing the install-route sidecar entry '$sidecarEntryName'. The zip creation path is dropping hidden files." + } + } } else { $archivePath = "$Output.tar.gz" Write-Log "Creating archive: $archivePath" @@ -479,10 +581,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:" diff --git a/localhive.sh b/localhive.sh index 3d85312203a..7f0dcff390c 100755 --- a/localhive.sh +++ b/localhive.sh @@ -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 @@ -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)" @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 /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 @@ -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:" diff --git a/src/Aspire.Cli/Acquisition/IInstallSidecarReader.cs b/src/Aspire.Cli/Acquisition/IInstallSidecarReader.cs new file mode 100644 index 00000000000..17f03aa5b43 --- /dev/null +++ b/src/Aspire.Cli/Acquisition/IInstallSidecarReader.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Acquisition; + +/// +/// Result of reading an install-route sidecar from a binary directory. +/// +/// +/// Absolute path of the sidecar file that was read. Always populated for +/// because a successful read requires a +/// resolved sidecar path. +/// +/// +/// Parsed install route. when the sidecar +/// exists but its source field does not match a known route. +/// +/// +/// The literal source string from the sidecar (may be a value not yet +/// understood by this build). Empty when the sidecar JSON is valid but the +/// source field is missing or empty. +/// +internal sealed record InstallSidecarInfo(string SidecarPath, InstallSource Source, string RawSource); + +/// +/// Result of attempting to read an install-route sidecar. +/// +/// +/// Path of the sidecar file that was considered. Absolute when the binary +/// directory could be resolved; empty () when the +/// caller passed an empty or unusable directory (e.g. +/// returned null/empty for the +/// candidate binary), in which case the result is always +/// . +/// +internal abstract record InstallSidecarReadResult(string SidecarPath) +{ + /// Sidecar was read and parsed. + public sealed record Ok(InstallSidecarInfo Info) : InstallSidecarReadResult(Info.SidecarPath); + + /// Sidecar file does not exist. + public sealed record NotFound(string Path) : InstallSidecarReadResult(Path); + + /// Sidecar file exists but could not be read or parsed. + public sealed record Invalid(string Path, string Reason) : InstallSidecarReadResult(Path); +} + +/// +/// Reads the install-route sidecar (.aspire-install.json) that an +/// install route writes next to the CLI binary. The sidecar identifies the +/// installation route so callers (e.g. BundleService, +/// aspire doctor, aspire uninstall) can branch behavior without +/// path-shape heuristics. +/// +/// +/// See docs/specs/install-routes.md for the file contract. The reader +/// is AOT-safe: parsing uses JsonDocument instead of reflection-based +/// deserialization. +/// +internal interface IInstallSidecarReader +{ + /// + /// Attempts to read the sidecar at + /// <>/.aspire-install.json. + /// + /// Directory containing the CLI binary. + /// A categorized read result. + InstallSidecarReadResult TryRead(string binaryDir); +} diff --git a/src/Aspire.Cli/Acquisition/IInstallationDiscovery.cs b/src/Aspire.Cli/Acquisition/IInstallationDiscovery.cs new file mode 100644 index 00000000000..99e86a18295 --- /dev/null +++ b/src/Aspire.Cli/Acquisition/IInstallationDiscovery.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Acquisition; + +/// +/// Discovers Aspire CLI installations on this machine for aspire doctor. +/// +/// +/// Two modes: +/// +/// +/// — cheap path that describes +/// only the currently running CLI. No process spawning, no filesystem +/// walks. Used by the hidden aspire doctor --self peer-probe path. +/// +/// +/// — walks $PATH plus +/// well-known install prefixes and asks each peer with required install +/// metadata to self-describe via a child aspire doctor --self --format json +/// call. Used by the default aspire doctor path. +/// +/// +/// +internal interface IInstallationDiscovery +{ + /// + /// Describes the currently running CLI. The result always has + /// = ok with version / + /// channel / route populated from in-process readers. + /// + InstallationInfo DescribeSelf(); + + /// + /// Discovers all Aspire CLI installations the running CLI can see and + /// returns one row per unique canonical path. The currently running CLI + /// is always the first element. Peer rows may have + /// = notProbed when the + /// required install metadata is missing or invalid, or failed + /// when a peer probe was attempted but failed. + /// + Task> DiscoverAllAsync(CancellationToken cancellationToken); +} diff --git a/src/Aspire.Cli/Acquisition/IPeerInstallProbe.cs b/src/Aspire.Cli/Acquisition/IPeerInstallProbe.cs new file mode 100644 index 00000000000..4f851472f41 --- /dev/null +++ b/src/Aspire.Cli/Acquisition/IPeerInstallProbe.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Acquisition; + +/// +/// Result of asking a peer Aspire CLI binary to self-describe via +/// <peer> doctor --self --format json. +/// +internal abstract record PeerProbeResult +{ + /// Peer responded with a parseable InstallationInfo. + public sealed record Ok(InstallationInfo Info) : PeerProbeResult; + + /// Peer was not probed (or probe failed). is human-readable. + public sealed record Failed(string Reason) : PeerProbeResult; +} + +/// +/// Spawns a peer Aspire CLI binary to ask it to describe itself. +/// Implementations MUST enforce a process-wide timeout, a stdout byte cap, +/// and kill the entire process tree on timeout so a hung or runaway peer +/// can't survive past aspire doctor's lifetime. +/// +internal interface IPeerInstallProbe +{ + /// + /// Runs doctor --self --format json and + /// returns either the parsed or a failure + /// reason. --self bounds the peer to describing only itself so the + /// probe does not recursively trigger a discovery walk inside the peer. + /// --format json selects the machine-readable contract (the + /// human-readable table is the default when --format is omitted). + /// Never throws for ordinary peer-probe failures (timeout, non-zero + /// exit, invalid JSON, missing executable); reserve exceptions for + /// cancellation propagation. + /// + Task ProbeAsync(string binaryPath, CancellationToken cancellationToken); +} diff --git a/src/Aspire.Cli/Acquisition/InstallSidecarReader.cs b/src/Aspire.Cli/Acquisition/InstallSidecarReader.cs new file mode 100644 index 00000000000..990c747f996 --- /dev/null +++ b/src/Aspire.Cli/Acquisition/InstallSidecarReader.cs @@ -0,0 +1,168 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Acquisition; + +/// +/// Default backed by the on-disk +/// .aspire-install.json file. Read with +/// (AOT-safe) and returns for missing, +/// empty, or unrecognized source values rather than throwing — +/// callers treat unknown sources as legacy / pre-sidecar installs and fall +/// back to the pre-sidecar layout heuristic. +/// +internal sealed class InstallSidecarReader : IInstallSidecarReader +{ + private readonly ILogger? _logger; + + public InstallSidecarReader(ILogger? logger = null) + { + _logger = logger; + } + + /// + /// Well-known file name of the sidecar that each install route writes + /// next to the CLI binary. Matches the contract in + /// docs/specs/install-routes.md. + /// + public const string SidecarFileName = ".aspire-install.json"; + + /// + /// Upper bound on the sidecar file size we'll read into memory. The + /// canonical payload is ~30 bytes ({"source":"localhive"}); we + /// pick a small cap so that a pathological or malicious sidecar planted + /// next to a candidate binary on PATH cannot force a large allocation + /// during installation discovery. + /// + internal const long MaxSidecarBytes = 64 * 1024; + + /// + public InstallSidecarReadResult TryRead(string binaryDir) + { + if (string.IsNullOrEmpty(binaryDir)) + { + _logger?.LogDebug("Install sidecar read skipped because the binary directory is empty."); + return new InstallSidecarReadResult.NotFound(string.Empty); + } + + var sidecarPath = Path.Combine(binaryDir, SidecarFileName); + if (!File.Exists(sidecarPath)) + { + _logger?.LogDebug("Install sidecar file '{SidecarPath}' does not exist.", sidecarPath); + return new InstallSidecarReadResult.NotFound(sidecarPath); + } + + if (TryGetOversizedSidecarReason(sidecarPath, out var oversizedReason)) + { + _logger?.LogDebug("Install sidecar file '{SidecarPath}' rejected: {Reason}.", sidecarPath, oversizedReason); + return new InstallSidecarReadResult.Invalid(sidecarPath, oversizedReason); + } + + if (!TryReadSourceFieldFromExistingSidecar(sidecarPath, out var rawSource, out var error)) + { + if (error is JsonException) + { + _logger?.LogDebug(error, "Install sidecar file '{SidecarPath}' contains malformed JSON.", sidecarPath); + } + else + { + _logger?.LogDebug(error, "Install sidecar file '{SidecarPath}' could not be read due to a path, permission, or IO error.", sidecarPath); + } + + return new InstallSidecarReadResult.Invalid(sidecarPath, error?.Message ?? "Install sidecar file could not be read."); + } + + var effectiveRawSource = rawSource ?? string.Empty; + var parsed = InstallSourceExtensions.ParseInstallSource(effectiveRawSource); + return new InstallSidecarReadResult.Ok(new InstallSidecarInfo(sidecarPath, parsed, effectiveRawSource)); + } + + /// + /// Reads the source field from a sidecar file at a known path. + /// Static helper used by BundleService.ComputeDefaultExtractDir, + /// which runs before DI is wired and cannot take a service dependency. + /// Returns when the file is missing, unreadable, + /// or contains malformed / unexpected JSON. + /// + internal static string? ReadSourceField(string sidecarPath) + { + if (!File.Exists(sidecarPath)) + { + return null; + } + + if (TryGetOversizedSidecarReason(sidecarPath, out _)) + { + return null; + } + + return TryReadSourceFieldFromExistingSidecar(sidecarPath, out var rawSource, out _) + ? rawSource + : null; + } + + private static bool TryGetOversizedSidecarReason(string sidecarPath, out string reason) + { + try + { + var length = new FileInfo(sidecarPath).Length; + if (length > MaxSidecarBytes) + { + reason = $"Sidecar file size {length} bytes exceeds the {MaxSidecarBytes}-byte limit."; + return true; + } + } + catch (Exception ex) when (IsSidecarReadException(ex)) + { + // FileInfo can throw the same IO/permission/path family as the read path. + // Let the regular reader produce the diagnostic in that case so we don't + // duplicate error reporting here. + } + + reason = string.Empty; + return false; + } + + private static string? ReadSourceFieldFromExistingSidecar(string sidecarPath) + { + using var stream = File.OpenRead(sidecarPath); + using var doc = JsonDocument.Parse(stream); + if (doc.RootElement.ValueKind == JsonValueKind.Object && + doc.RootElement.TryGetProperty("source", out var sourceElement) && + sourceElement.ValueKind == JsonValueKind.String) + { + return sourceElement.GetString(); + } + + return null; + } + + private static bool TryReadSourceFieldFromExistingSidecar(string sidecarPath, out string? rawSource, out Exception? exception) + { + rawSource = null; + exception = null; + + try + { + rawSource = ReadSourceFieldFromExistingSidecar(sidecarPath); + return true; + } + catch (Exception ex) when (IsSidecarReadException(ex)) + { + exception = ex; + return false; + } + } + + private static bool IsSidecarReadException(Exception ex) + => ex is JsonException + or IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException + or PathTooLongException + or System.Security.SecurityException; +} diff --git a/src/Aspire.Cli/Acquisition/InstallSource.cs b/src/Aspire.Cli/Acquisition/InstallSource.cs new file mode 100644 index 00000000000..f6f10830b53 --- /dev/null +++ b/src/Aspire.Cli/Acquisition/InstallSource.cs @@ -0,0 +1,93 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Acquisition; + +/// +/// Identifies the installation route that placed the running CLI binary. +/// The value is read from the source field of the +/// .aspire-install.json sidecar that sits next to the binary. +/// See docs/specs/install-routes.md. +/// +internal enum InstallSource +{ + /// + /// No sidecar was found, or the sidecar contained a value that does not + /// match any known route. Treated as legacy / pre-sidecar by callers. + /// + Unknown = 0, + + /// Release installer: get-aspire-cli.{sh,ps1}. + Script, + + /// PR / dogfood installer: get-aspire-cli-pr.{sh,ps1}. + Pr, + + /// WinGet portable manifest. + Winget, + + /// Homebrew cask. + Brew, + + /// .NET global tool (dotnet tool install -g Aspire.Cli). + DotnetTool, + + /// Locally-built install from localhive.sh / localhive.ps1. + LocalHive, +} + +/// +/// Maps between values and the wire strings used +/// in the sidecar's source field. +/// +internal static class InstallSourceExtensions +{ + // Wire strings — these match the contract in docs/specs/install-routes.md + // exactly. They are kebab-case strings consumed by both the installer + // scripts and BundleService.ComputeDefaultExtractDir. + internal const string ScriptWire = "script"; + internal const string PrWire = "pr"; + internal const string WingetWire = "winget"; + internal const string BrewWire = "brew"; + internal const string DotnetToolWire = "dotnet-tool"; + internal const string LocalHiveWire = "localhive"; + + /// + /// Parses a sidecar source string into the strongly-typed enum. + /// Returns for null, empty, or + /// unrecognized values so callers can treat unknown sources as a + /// legacy / pre-sidecar install. + /// + public static InstallSource ParseInstallSource(string? raw) + { + return raw switch + { + ScriptWire => InstallSource.Script, + PrWire => InstallSource.Pr, + WingetWire => InstallSource.Winget, + BrewWire => InstallSource.Brew, + DotnetToolWire => InstallSource.DotnetTool, + LocalHiveWire => InstallSource.LocalHive, + _ => InstallSource.Unknown, + }; + } + + /// + /// Returns the canonical wire string for a known + /// , or for + /// . + /// + public static string? ToWireString(this InstallSource source) + { + return source switch + { + InstallSource.Script => ScriptWire, + InstallSource.Pr => PrWire, + InstallSource.Winget => WingetWire, + InstallSource.Brew => BrewWire, + InstallSource.DotnetTool => DotnetToolWire, + InstallSource.LocalHive => LocalHiveWire, + _ => null, + }; + } +} diff --git a/src/Aspire.Cli/Acquisition/InstallationCandidateSources.cs b/src/Aspire.Cli/Acquisition/InstallationCandidateSources.cs new file mode 100644 index 00000000000..d9a573a778b --- /dev/null +++ b/src/Aspire.Cli/Acquisition/InstallationCandidateSources.cs @@ -0,0 +1,222 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Acquisition; + +/// +/// Finds raw Aspire CLI install candidates for . +/// +internal interface IInstallationCandidateSource +{ + IEnumerable GetCandidates(InstallationCandidateContext context); +} + +internal sealed record InstallationCandidateContext( + string AspireBinaryName, + DirectoryInfo HomeDirectory, + DirectoryInfo AspireHomeDirectory, + IReadOnlyList PathHits, + ILogger Logger, + CancellationToken CancellationToken) +{ + public string AspireHome => AspireHomeDirectory.FullName; +} + +internal sealed record InstallationPathHit(string OriginalPath, string CanonicalPath); + +// CanonicalPath is an optional pre-resolved hint from a candidate source that +// already had to resolve the symlink chain itself (currently only $PATH hits; +// FindAllAspireOnPath calls CliPathHelper.ResolveSymlinkToFullPath while +// enumerating). When provided, DiscoverAllAsync uses it directly instead of +// re-resolving, which avoids a redundant syscall and closes a small TOCTOU +// window between the two resolves where a symlink swap could produce +// divergent dedup keys. Other sources (release prefix, dogfood, dotnet-tool +// store) leave it null and rely on DiscoverAllAsync to resolve. +internal sealed record InstallationDiscoveryCandidate(string BinaryPath, string Origin, string? CanonicalPath = null); + +internal static class InstallationDiscoveryLayout +{ + public const string DogfoodDirectoryName = "dogfood"; +} + +internal sealed class PathInstallationCandidateSource : IInstallationCandidateSource +{ + public IEnumerable GetCandidates(InstallationCandidateContext context) + { + foreach (var pathHit in context.PathHits) + { + yield return new InstallationDiscoveryCandidate(pathHit.OriginalPath, "$PATH", pathHit.CanonicalPath); + } + } +} + +internal sealed class ReleasePrefixInstallationCandidateSource : IInstallationCandidateSource +{ + public IEnumerable GetCandidates(InstallationCandidateContext context) + { + var releaseDir = Path.Combine(context.AspireHome, "bin"); + var releaseBinary = Path.Combine(releaseDir, context.AspireBinaryName); + if (File.Exists(releaseBinary)) + { + context.Logger.LogDebug("Discovery: release prefix walk yielded '{Binary}'.", releaseBinary); + yield return new InstallationDiscoveryCandidate(releaseBinary, "well-known release prefix"); + } + else if (Directory.Exists(releaseDir)) + { + // Bin dir exists but no `aspire` inside it: likely a partially removed install + // or a third-party `~/.aspire/bin` use. Log so users can correlate with expectations. + context.Logger.LogDebug( + "Discovery: release prefix directory '{ReleaseDir}' exists but does not contain an 'aspire' binary — not classifying as a real install.", + releaseDir); + } + else + { + context.Logger.LogDebug("Discovery: release prefix '{ReleaseDir}' does not exist; skipping.", releaseDir); + } + } +} + +internal sealed class DogfoodInstallationCandidateSource : IInstallationCandidateSource +{ + public IEnumerable GetCandidates(InstallationCandidateContext context) + { + var dogfoodRoot = Path.Combine(context.AspireHome, InstallationDiscoveryLayout.DogfoodDirectoryName); + if (Directory.Exists(dogfoodRoot)) + { + var subdirCount = 0; + foreach (var prDir in InstallationCandidateSourceHelpers.EnumerateDirectoriesSafe(dogfoodRoot, context.Logger, context.CancellationToken)) + { + subdirCount++; + var binDir = Path.Combine(prDir, "bin"); + var binary = Path.Combine(binDir, context.AspireBinaryName); + if (File.Exists(binary)) + { + context.Logger.LogDebug("Discovery: dogfood walk yielded '{Binary}'.", binary); + yield return new InstallationDiscoveryCandidate(binary, "dogfood prefix"); + } + else + { + // A dogfood pr-N directory without bin/aspire is most commonly a stale + // leftover from a failed install or partial uninstall. + context.Logger.LogDebug( + "Discovery: dogfood directory '{PrDir}' exists but does not contain a '{Bin}/aspire' binary — not classifying as a real install.", + prDir, "bin"); + } + } + + if (subdirCount == 0) + { + context.Logger.LogDebug("Discovery: dogfood root '{DogfoodRoot}' exists but contains no subdirectories.", dogfoodRoot); + } + } + else + { + context.Logger.LogDebug("Discovery: dogfood root '{DogfoodRoot}' does not exist; skipping.", dogfoodRoot); + } + } +} + +internal sealed class DotnetToolStoreInstallationCandidateSource : IInstallationCandidateSource +{ + // Streaming-enumeration options used for the dotnet-tool store walk. The tool store + // is recursive (~/.dotnet/tools/.store/aspire.cli//aspire.cli.//tools/...) + // so we recurse, but we skip inaccessible subtrees instead of throwing mid-walk. + // That lets the surrounding foreach observe each yielded entry — and the caller's + // cancellation token — incrementally, instead of having to materialize the whole + // tree before the discovery loop can react to Ctrl+C on a slow filesystem. + // + // Reparse points (symlinks / junctions) are skipped so a symlink cycle anywhere + // under the store cannot make `Directory.EnumerateFiles` walk indefinitely. The + // legitimate tool-store layout contains no symlinks, so this loses nothing real + // and removes a self-DoS surface from `aspire doctor` discovery. + private static readonly EnumerationOptions s_enumerationOptions = new() + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.Hidden | FileAttributes.System | FileAttributes.ReparsePoint, + }; + + public IEnumerable GetCandidates(InstallationCandidateContext context) + { + var toolStore = Path.Combine(context.HomeDirectory.FullName, ".dotnet", "tools", ".store", "aspire.cli"); + if (!Directory.Exists(toolStore)) + { + context.Logger.LogDebug("Discovery: dotnet-tool store '{ToolStore}' does not exist; skipping.", toolStore); + yield break; + } + + var anyMatch = false; + foreach (var binary in InstallationCandidateSourceHelpers.EnumerateFilesSafe(toolStore, context.AspireBinaryName, s_enumerationOptions, context.Logger, context.CancellationToken)) + { + anyMatch = true; + context.Logger.LogDebug("Discovery: dotnet-tool store walk yielded '{Binary}'.", binary); + yield return new InstallationDiscoveryCandidate(binary, "dotnet-tool store"); + } + + if (!anyMatch) + { + context.Logger.LogDebug( + "Discovery: dotnet-tool store '{ToolStore}' exists but contains no '{BinaryName}' binary — not classifying as a real install.", + toolStore, context.AspireBinaryName); + } + } +} + +internal static class InstallationCandidateSourceHelpers +{ + // Streams filesystem entries under so the caller can observe each + // entry — and the surrounding cancellation token — incrementally. Materializing the + // whole list upfront would defeat the per-step cancellation cadence the discovery + // walkers rely on. Iterator methods cannot yield inside a catch, so we drive the + // enumerator manually and swallow IOExceptions raised on initial open or mid-walk. + public static IEnumerable EnumerateDirectoriesSafe(string root, ILogger logger, CancellationToken cancellationToken = default) + { + return EnumerateFileSystemEntriesSafe(root, logger, "directories", () => Directory.EnumerateDirectories(root).GetEnumerator(), cancellationToken); + } + + public static IEnumerable EnumerateFilesSafe(string root, string searchPattern, EnumerationOptions enumerationOptions, ILogger logger, CancellationToken cancellationToken = default) + { + return EnumerateFileSystemEntriesSafe(root, logger, "files", () => Directory.EnumerateFiles(root, searchPattern, enumerationOptions).GetEnumerator(), cancellationToken); + } + + private static IEnumerable EnumerateFileSystemEntriesSafe(string root, ILogger logger, string entryKind, Func> enumeratorFactory, CancellationToken cancellationToken) + { + IEnumerator enumerator; + try + { + enumerator = enumeratorFactory(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) + { + logger.LogDebug(ex, "Discovery: failed to enumerate {EntryKind} under '{Root}'.", entryKind, root); + yield break; + } + + using (enumerator) + { + while (true) + { + bool moved; + try + { + cancellationToken.ThrowIfCancellationRequested(); + moved = enumerator.MoveNext(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) + { + logger.LogDebug(ex, "Discovery: failed to enumerate {EntryKind} under '{Root}'.", entryKind, root); + yield break; + } + + if (!moved) + { + yield break; + } + + yield return enumerator.Current; + } + } + } +} diff --git a/src/Aspire.Cli/Acquisition/InstallationDiscovery.cs b/src/Aspire.Cli/Acquisition/InstallationDiscovery.cs new file mode 100644 index 00000000000..49f2f6cca3e --- /dev/null +++ b/src/Aspire.Cli/Acquisition/InstallationDiscovery.cs @@ -0,0 +1,481 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.RegularExpressions; +using Aspire.Cli.Utils; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Acquisition; + +/// +/// Default . The self-describe path +/// composes data already available in-process (channel from +/// , version from +/// , route from the +/// running binary's sidecar) so it is cheap and side-effect-free. +/// +/// +/// The default discovery path asks ordered candidate sources for possible installs, +/// then centralizes canonicalization, deduplication, install metadata checks, +/// peer probing, and row shaping in this class. +/// +internal sealed partial class InstallationDiscovery : IInstallationDiscovery +{ + private static readonly string s_aspireBinaryName = OperatingSystem.IsWindows() ? "aspire.exe" : "aspire"; + + private readonly IIdentityChannelReader _channelReader; + private readonly IInstallSidecarReader _sidecarReader; + private readonly IPeerInstallProbe _peerProbe; + private readonly CliExecutionContext _executionContext; + private readonly ILogger _logger; + private readonly IReadOnlyList _candidateSources; + + public InstallationDiscovery( + IIdentityChannelReader channelReader, + IInstallSidecarReader sidecarReader, + IPeerInstallProbe peerProbe, + CliExecutionContext executionContext, + ILogger logger, + IEnumerable? candidateSources = null) + { + ArgumentNullException.ThrowIfNull(channelReader); + ArgumentNullException.ThrowIfNull(sidecarReader); + ArgumentNullException.ThrowIfNull(peerProbe); + ArgumentNullException.ThrowIfNull(executionContext); + ArgumentNullException.ThrowIfNull(logger); + + _channelReader = channelReader; + _sidecarReader = sidecarReader; + _peerProbe = peerProbe; + _executionContext = executionContext; + _logger = logger; + var sources = candidateSources?.ToArray(); + _candidateSources = sources is { Length: > 0 } ? sources : CreateDefaultCandidateSources(); + } + + /// + public InstallationInfo DescribeSelf() + => DescribeSelf(Environment.ProcessPath, pathHits: null); + + /// + /// Test seam for : lets tests substitute the + /// running CLI's process path so we can exercise the firmlink-strip + /// path under + /// without manipulating . + /// + internal InstallationInfo DescribeSelf(string? processPath) + => DescribeSelf(processPath, pathHits: null); + + private InstallationInfo DescribeSelf(string? processPath, IReadOnlyList? pathHits) + { + var canonicalPath = CliPathHelper.ResolveSymlinkToFullPath(processPath, _logger); + var binaryDir = !string.IsNullOrEmpty(canonicalPath) ? Path.GetDirectoryName(canonicalPath) : null; + + var sidecar = !string.IsNullOrEmpty(binaryDir) && _sidecarReader.TryRead(binaryDir) is InstallSidecarReadResult.Ok selfSidecar + ? selfSidecar.Info + : null; + var pathStatus = GetPathStatus(canonicalPath, pathHits ?? FindAllAspireOnPath(_logger)); + // Use the wire string from the parsed source so callers see the same + // identifier the install scripts wrote, not the C# enum name. For + // sidecars with an unrecognized source value we surface the raw + // string so users see "(unknown: future-route)" rather than nothing. + // Route through the shared helper so an empty RawSource collapses to + // null and the JSON shape of `--self` matches the full discovery walk. + var route = sidecar is not null ? GetRouteFromSidecar(sidecar) : null; + + return new InstallationInfo + { + // Prefer the canonical (resolved + macOS-firmlink-stripped) form + // for display so the self row's Path column agrees with the form + // peer rows already use (PATH walks return un-firmlinked entries; + // candidate sources derive paths from the firmlink-stripped + // AspireHome). Falling back to the raw process path keeps the row + // non-empty when resolution fails on a malformed input. + Path = canonicalPath ?? processPath ?? string.Empty, + CanonicalPath = canonicalPath, + Version = VersionHelper.GetDefaultTemplateVersion(), + Channel = TryReadChannel(), + Route = route, + PathStatus = pathStatus, + Status = InstallationInfoStatus.Ok, + }; + } + + /// + public Task> DiscoverAllAsync(CancellationToken cancellationToken) + => DiscoverAllAsync(Environment.ProcessPath, cancellationToken); + + /// + /// Test seam for : lets + /// tests substitute the running CLI's process path so we can exercise + /// dedup across firmlinked / un-firmlinked path forms without + /// manipulating globally. + /// + internal async Task> DiscoverAllAsync(string? processPath, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var pathHits = FindAllAspireOnPath(_logger).ToList(); + var self = DescribeSelf(processPath, pathHits); + var aspireHome = _executionContext.AspireHomeDirectory.FullName; + _logger.LogDebug( + "Discovery: starting walk. self.Path='{SelfPath}', self.Canonical='{SelfCanonical}', AspireHome='{AspireHome}'.", + self.Path, + self.CanonicalPath ?? "(null)", + aspireHome); + + var results = new List { self }; + // Deduplicate by canonical path (case-insensitive on Windows). The + // running CLI is always the first row, so peers that resolve to + // the same canonical path are silently dropped. + var seen = new HashSet( + self.CanonicalPath is { Length: > 0 } sp ? [sp] : [], + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + + if (pathHits.Count == 0) + { + _logger.LogDebug("Discovery: no 'aspire' binary found on $PATH."); + } + else + { + foreach (var pathHit in pathHits) + { + _logger.LogDebug( + "Discovery: $PATH match: '{Path}' (canonical: '{Canonical}').", + pathHit.OriginalPath, pathHit.CanonicalPath); + } + } + + var candidateCount = 0; + var candidateContext = new InstallationCandidateContext( + s_aspireBinaryName, + _executionContext.HomeDirectory, + _executionContext.AspireHomeDirectory, + pathHits, + _logger, + cancellationToken); + foreach (var candidate in EnumerateDiscoveryCandidates(candidateContext)) + { + cancellationToken.ThrowIfCancellationRequested(); + candidateCount++; + + _logger.LogDebug( + "Discovery: considering candidate #{Index} '{Path}' (origin: {Origin}).", + candidateCount, candidate.BinaryPath, candidate.Origin); + + // Prefer the candidate's pre-resolved canonical hint when present: + // $PATH hits already had their canonical resolved by FindAllAspireOnPath, + // so re-resolving here would (a) double the syscalls and (b) open a + // TOCTOU window where a symlink swap between the two resolves could + // produce a different canonical and break dedup. Other sources (release + // prefix, dogfood, dotnet-tool store) leave the hint null and still + // resolve here. Empty-string -> skip-candidate semantics are preserved + // for both paths. + var canonical = !string.IsNullOrEmpty(candidate.CanonicalPath) + ? candidate.CanonicalPath + : CliPathHelper.ResolveSymlinkToFullPath(candidate.BinaryPath, _logger); + if (string.IsNullOrEmpty(canonical)) + { + _logger.LogDebug( + "Discovery: skipping candidate '{Candidate}' (origin: {Origin}) — could not resolve a canonical path; treating as not a real install.", + candidate.BinaryPath, candidate.Origin); + continue; + } + if (!seen.Add(canonical)) + { + _logger.LogDebug( + "Discovery: skipping duplicate of '{Canonical}' found via {Origin} at '{Candidate}'.", + canonical, candidate.Origin, candidate.BinaryPath); + continue; + } + + var binaryDir = Path.GetDirectoryName(canonical); + var sidecarResult = !string.IsNullOrEmpty(binaryDir) + ? _sidecarReader.TryRead(binaryDir) + : new InstallSidecarReadResult.NotFound(string.Empty); + var pathStatus = GetPathStatus(canonical, pathHits); + + // Only spawn peers that carry readable install metadata. Other PATH hits + // become notProbed rows so users see they exist, but we never execute them. + if (sidecarResult is not InstallSidecarReadResult.Ok { Info: var sidecar }) + { + _logger.LogDebug( + "Discovery: candidate '{Canonical}' (origin: {Origin}) did not pass install metadata sidecar read ({SidecarReadResult}) — treating as not-probed.", + canonical, candidate.Origin, sidecarResult.GetType().Name); + results.Add(new InstallationInfo + { + Path = candidate.BinaryPath, + CanonicalPath = canonical, + PathStatus = pathStatus, + Status = InstallationInfoStatus.NotProbed, + StatusReason = GetNotProbedReason(sidecarResult), + }); + continue; + } + + var probe = await _peerProbe.ProbeAsync(canonical, cancellationToken).ConfigureAwait(false); + switch (probe) + { + case PeerProbeResult.Ok ok: + // Preserve the original discovered path for display and + // canonical path for identity. Overlay the route from + // the LOCAL sidecar so older peers using the + // --version fallback (which can't report route) still + // surface the install route we already know about. + // Also derive the channel for PR builds — the channel + // is structurally `pr-` for a PR install, and we + // can recover it from the install path layout + // (dogfood/pr-/bin) or from the informational + // version string (-pr..) baked at + // build time, so we surface it even when the peer + // didn't report it. + var route = ok.Info.Route ?? GetRouteFromSidecar(sidecar); + var channel = ok.Info.Channel; + if (string.IsNullOrEmpty(channel) && sidecar.Source == InstallSource.Pr) + { + channel = TryDerivePrChannel(canonical); + } + if (string.IsNullOrEmpty(channel)) + { + // Final attempt: derive the channel from the peer's + // reported version. This is the only signal we have + // for older peers that don't recognize the + // `doctor --self` self-describe contract — they + // fall through to the `--version` floor in the + // probe and can't report their channel directly, + // but the assembly's InformationalVersion has it + // baked in for PR builds. + channel = TryDerivePrChannelFromVersion(ok.Info.Version); + } + + results.Add(ok.Info with + { + Path = candidate.BinaryPath, + CanonicalPath = canonical, + Route = route, + Channel = channel, + PathStatus = pathStatus, + }); + break; + case PeerProbeResult.Failed failed: + _logger.LogDebug( + "Discovery: candidate '{Canonical}' (origin: {Origin}, route: {Route}) failed peer probe: {Reason}.", + canonical, candidate.Origin, GetRouteFromSidecar(sidecar), failed.Reason); + results.Add(new InstallationInfo + { + Path = candidate.BinaryPath, + CanonicalPath = canonical, + Route = GetRouteFromSidecar(sidecar), + PathStatus = pathStatus, + Status = InstallationInfoStatus.Failed, + StatusReason = failed.Reason, + }); + break; + default: + throw new NotSupportedException($"Unsupported peer probe result type '{probe?.GetType().FullName ?? "(null)"}'."); + } + } + + _logger.LogDebug( + "Discovery: walk complete. Considered {Considered} candidate(s); produced {Total} row(s) total (including self).", + candidateCount, results.Count); + + return results; + } + + /// + /// Derives the pr-<N> identity channel for a PR-route install + /// from its on-disk path. The PR install layout is, by convention, + /// <root>/dogfood/pr-<N>/bin/aspire (or with a + /// .exe); this method walks up two directories from the binary + /// and returns the second-to-last component when it matches that + /// shape. For custom-prefix PR installs (--install-path with a + /// non-default layout) the lookup returns and + /// the row falls back to (unknown) for channel. + /// + /// + /// This derivation is purely cosmetic for the user-facing table: it + /// fills in the channel column when the older peer at the discovered + /// path has no surface to report its baked AspireCliChannel. + /// It is not used for any decision-making logic (extract dir, hive + /// resolution, etc.) — those continue to use the sidecar source. + /// + internal static string? TryDerivePrChannel(string canonicalBinaryPath) + { + // canonicalBinaryPath: /dogfood/pr-/bin/aspire[.exe] + // parent = /dogfood/pr-/bin + // grandparent = /dogfood/pr- ← we want the basename + // great-grandparent= /dogfood ← which must equal "dogfood" + var bin = Path.GetDirectoryName(canonicalBinaryPath); + if (string.IsNullOrEmpty(bin)) + { + return null; + } + + var prDir = Path.GetDirectoryName(bin); + if (string.IsNullOrEmpty(prDir)) + { + return null; + } + + var dogfoodDir = Path.GetDirectoryName(prDir); + if (string.IsNullOrEmpty(dogfoodDir) || + !string.Equals(Path.GetFileName(dogfoodDir), InstallationDiscoveryLayout.DogfoodDirectoryName, StringComparison.Ordinal)) + { + return null; + } + + var label = Path.GetFileName(prDir); + // Use Ordinal (case-sensitive) to match the dogfood directory check above + // and the producer side (IdentityChannelReader.IsValidChannel only accepts + // a lowercase pr- label). Using OrdinalIgnoreCase here would let + // "Dogfood/Pr-123" fail the dogfood check but pass this one on + // case-insensitive filesystems, producing an inconsistent classification. + if (string.IsNullOrEmpty(label) || !label.StartsWith("pr-", StringComparison.Ordinal)) + { + return null; + } + + // Validate the suffix is digits-only so e.g. `pr-foo` from a manual + // prefix doesn't get surfaced as an identity channel. + var suffix = label.AsSpan(3); + if (suffix.IsEmpty || suffix.ContainsAnyExceptInRange('0', '9')) + { + return null; + } + + return label; + } + + /// + /// Derives the pr-<N> identity channel from the peer's + /// reported informational version string. PR-channel CI builds bake + /// versions of the shape <x.y.z>-pr.<N>.<hash> + /// (for example 13.4.0-pr.17115.gcd700928); this method extracts + /// the digits-only <N> segment and returns + /// pr-<N>. Stable, staging, daily, and preview versions + /// don't carry that token and return . + /// + /// + /// Like , this is a purely + /// cosmetic enrichment for the user-facing table. It rescues the + /// channel column for peers that don't recognize the + /// doctor --self self-describe contract: those fall through to + /// the --version floor in the probe and can't report their + /// channel directly, but their assembly's InformationalVersion has + /// the PR number baked in regardless of route. + /// + internal static string? TryDerivePrChannelFromVersion(string? version) + { + if (string.IsNullOrEmpty(version)) + { + return null; + } + + var match = PrChannelVersionRegex().Match(version); + if (!match.Success) + { + return null; + } + + return string.Concat("pr-", match.Groups["number"].Value); + } + + // Version examples: + // 13.4.0-pr.17115.gcd700928 -> extract 17115 + // 13.3.0-pr.1234.abc -> extract 1234 + // 13.4.0-preview.1.99999.1 -> no -pr. -> null + // 13.4.0 -> no -pr. -> null + // Require a leading hyphen so we don't accept a stray "pr." token + // mid-version (e.g. a hypothetical "13.4.0-fix.pr.1" — defensive, + // not observed). Require the digits to terminate at '.', '+', or the + // end of the string so unrelated tokens don't get misclassified as PR + // channels. + [GeneratedRegex(@"-pr\.(?[0-9]+)(?:[.+]|$)", RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture)] + private static partial Regex PrChannelVersionRegex(); + + private string? TryReadChannel() + { + try + { + return _channelReader.ReadChannel(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Same defensive posture as doctor: a misconfigured dev build + // with no AspireCliChannel assembly metadata must not break + // aspire doctor. + _logger.LogDebug(ex, "Could not read identity channel for InstallationDiscovery."); + return null; + } + } + + private static string GetNotProbedReason(InstallSidecarReadResult result) + => result switch + { + InstallSidecarReadResult.NotFound => $"No install-route sidecar found at {result.SidecarPath}; peer was not probed.", + InstallSidecarReadResult.Invalid => $"Install-route sidecar at {result.SidecarPath} could not be read or parsed; peer was not probed.", + _ => $"No install-route sidecar found at {result.SidecarPath}; peer was not probed.", + }; + + private static string? GetRouteFromSidecar(InstallSidecarInfo sidecar) + => sidecar.Source.ToWireString() ?? (string.IsNullOrEmpty(sidecar.RawSource) ? null : sidecar.RawSource); + + /// + /// Walks $PATH looking for every aspire / + /// aspire.exe binary the shell could resolve. + /// + private static IEnumerable FindAllAspireOnPath(ILogger? logger) + { + foreach (var candidate in PathLookupHelper.FindAllFullPathsFromPath("aspire")) + { + var canonical = CliPathHelper.ResolveSymlinkToFullPath(candidate, logger); + if (!string.IsNullOrEmpty(canonical)) + { + yield return new InstallationPathHit(candidate, canonical); + } + } + } + + private IEnumerable EnumerateDiscoveryCandidates(InstallationCandidateContext context) + { + foreach (var source in _candidateSources) + { + foreach (var candidate in source.GetCandidates(context)) + { + yield return candidate; + } + } + } + + private static string GetPathStatus(string? canonicalPath, IEnumerable pathHits) + { + if (string.IsNullOrEmpty(canonicalPath)) + { + return InstallationPathStatus.NotOnPath; + } + + var comparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + var isFirst = true; + foreach (var pathHit in pathHits) + { + if (comparer.Equals(pathHit.CanonicalPath, canonicalPath)) + { + return isFirst ? InstallationPathStatus.Active : InstallationPathStatus.Shadowed; + } + + isFirst = false; + } + + return InstallationPathStatus.NotOnPath; + } + + private static IReadOnlyList CreateDefaultCandidateSources() + => + [ + new PathInstallationCandidateSource(), + new ReleasePrefixInstallationCandidateSource(), + new DogfoodInstallationCandidateSource(), + new DotnetToolStoreInstallationCandidateSource(), + ]; +} diff --git a/src/Aspire.Cli/Acquisition/InstallationInfo.cs b/src/Aspire.Cli/Acquisition/InstallationInfo.cs new file mode 100644 index 00000000000..ad021bf0771 --- /dev/null +++ b/src/Aspire.Cli/Acquisition/InstallationInfo.cs @@ -0,0 +1,170 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Aspire.Cli.Acquisition; + +/// +/// Describes one Aspire CLI installation, as surfaced by +/// aspire doctor --format json. Each entry corresponds to a single +/// binary either running this process or discovered on the system. +/// +/// +/// +/// The JSON shape is part of the installations property in the +/// aspire doctor --format json contract. Fields use camelCase wire names via +/// applied explicitly here so the +/// schema stays decoupled from the project-wide camelCase policy: another +/// process may parse this output across CLI versions and we don't want to +/// rename fields by changing a global option. +/// +/// +/// Nullable fields may be for any row, including +/// rows with . For example, a legacy +/// peer may respond through the --version fallback and leave +/// unknown. Consumers should treat null fields as +/// "unknown for this row" regardless of . +/// +/// +internal sealed record InstallationInfo +{ + /// + /// Absolute path of the CLI binary as discovered (i.e., the path that + /// appeared in $PATH or a well-known location). May be a symlink; + /// resolved canonical form is in . + /// + [JsonPropertyName("path")] + public required string Path { get; init; } + + /// + /// Symlink-resolved absolute path of the binary. Used for identity / + /// deduplication so that two PATH entries pointing at the same backing + /// file render as a single row. + /// + [JsonPropertyName("canonicalPath")] + public string? CanonicalPath { get; init; } + + /// + /// CLI version string (e.g., 13.0.0-preview.1.25366.3). Always + /// populated for the row representing the running CLI; for peer rows it + /// is populated only when the peer was successfully probed. + /// + [JsonPropertyName("version")] + public string? Version { get; init; } + + /// + /// Identity channel baked into the CLI assembly: one of + /// stable, staging, daily, local, or + /// pr-<N>. Always populated for the running row; for peer + /// rows it is populated only when the peer was successfully probed. + /// + [JsonPropertyName("channel")] + public string? Channel { get; init; } + + /// + /// Install route as recorded by the route's own sidecar + /// (.aspire-install.json). Wire string from + /// . May be + /// for PATH discoveries whose install metadata + /// sidecar is missing or invalid — see . + /// + [JsonPropertyName("route")] + public string? Route { get; init; } + + /// + /// Relationship between this binary and the user's $PATH. + /// See . + /// + [JsonPropertyName("pathStatus")] + public string PathStatus { get; init; } = InstallationPathStatus.NotOnPath; + + /// + /// Lifecycle status for the row. ok means the binary is usable + /// and any non-null fields on the row are correct, but nullable fields + /// may still be absent. notProbed means the binary was listed but + /// intentionally not executed because required install metadata was + /// missing or invalid. failed means a probe was attempted but the + /// peer did not return usable data. Wire values are kept lowercase for + /// stability. + /// + [JsonPropertyName("status")] + public required string Status { get; init; } + + /// + /// Free-form reason explaining a non-ok status; included only + /// when present. + /// + [JsonPropertyName("statusReason")] + public string? StatusReason { get; init; } +} + +/// +/// Wire constants for . +/// +internal static class InstallationInfoStatus +{ + /// Usable row; nullable fields may still be absent. + public const string Ok = "ok"; + + /// Row was discovered but not probed because required install metadata was missing or invalid. + public const string NotProbed = "notProbed"; + + /// Probe was attempted, but the peer did not cooperate (timeout, non-zero exit, malformed JSON, etc.). + public const string Failed = "failed"; +} + +/// +/// Wire constants for . +/// +internal static class InstallationPathStatus +{ + /// This binary is the first aspire entry resolved from $PATH. + public const string Active = "active"; + + /// This binary is on $PATH, but an earlier aspire entry shadows it. + public const string Shadowed = "shadowed"; + + /// This binary was not discovered through $PATH. + public const string NotOnPath = "notOnPath"; +} + +/// +/// Parses rows from the doctor installation discovery wire contract. +/// +internal static class InstallationInfoParser +{ + public static InstallationInfo Parse(JsonElement row) + { + string GetStringOr(string property, string fallback) + { + return row.TryGetProperty(property, out var el) && el.ValueKind == JsonValueKind.String + ? el.GetString() ?? fallback + : fallback; + } + + string? GetOptionalString(string property) + { + return row.TryGetProperty(property, out var el) && el.ValueKind == JsonValueKind.String + ? el.GetString() + : null; + } + + var pathStatus = GetOptionalString("pathStatus") is { Length: > 0 } parsedPathStatus + ? parsedPathStatus + : InstallationPathStatus.NotOnPath; + + return new InstallationInfo + { + Path = GetStringOr("path", string.Empty), + CanonicalPath = GetOptionalString("canonicalPath"), + Version = GetOptionalString("version"), + Channel = GetOptionalString("channel"), + Route = GetOptionalString("route"), + PathStatus = pathStatus, + Status = GetStringOr("status", InstallationInfoStatus.Ok), + StatusReason = GetOptionalString("statusReason"), + }; + } +} diff --git a/src/Aspire.Cli/Acquisition/PeerInstallProbe.cs b/src/Aspire.Cli/Acquisition/PeerInstallProbe.cs new file mode 100644 index 00000000000..c242e100cbc --- /dev/null +++ b/src/Aspire.Cli/Acquisition/PeerInstallProbe.cs @@ -0,0 +1,436 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using Aspire.Cli.Utils; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Acquisition; + +/// +/// Default . Spawns the peer with +/// doctor --self --format json, enforces a hard timeout, captures stdout +/// up to a byte cap, and kills the entire process tree on timeout so a +/// hung peer cannot survive past the parent's lifetime. +/// +/// +/// Uses directly rather than the project's +/// IProcessExecutionFactory because the latter's cancellation +/// semantics await +/// directly: on cancellation, the await throws before any kill branch can +/// run, leaving the peer alive. The peer-probe contract requires the kill +/// to actually fire. +/// +internal sealed class PeerInstallProbe : IPeerInstallProbe +{ + /// Maximum wall-clock time we wait for a peer to respond. + /// + /// 5 seconds is a generous budget for a native-AOT CLI to start, read + /// its assembly metadata, write 1 KB of JSON, and exit. A peer slower + /// than that is almost certainly broken; faster than that is the norm. + /// + internal static readonly TimeSpan s_defaultTimeout = TimeSpan.FromSeconds(5); + + /// + /// Maximum captured-output budget per stream. A misbehaving peer that spams + /// its stdout or stderr cannot allocate unbounded memory in the parent. + /// 1 MiB is far more than the well-behaved JSON shape (~200 bytes per + /// install) needs. + /// + /// + /// The cap is applied to the raw byte stream from each pipe and the + /// captured bytes are decoded as UTF-8 once at the end. Both stdout and + /// stderr are forced to UTF-8 on the spawn (see StandardOutputEncoding + /// / StandardErrorEncoding) so the decode matches the wire shape. + /// + internal const int OutputCap = 1 * 1024 * 1024; + + private readonly TimeSpan _timeout; + private readonly ILogger _logger; + + public PeerInstallProbe(ILogger logger) + : this(s_defaultTimeout, logger) + { + } + + internal PeerInstallProbe(TimeSpan timeout, ILogger logger) + { + ArgumentNullException.ThrowIfNull(logger); + _timeout = timeout; + _logger = logger; + } + + /// + public async Task ProbeAsync(string binaryPath, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(binaryPath) || !File.Exists(binaryPath)) + { + return new PeerProbeResult.Failed("Binary not found."); + } + + // Primary path: ask the peer to self-describe via `doctor --self --format json`. + // `--self` is required: without it the peer would run a full discovery + // walk and probe back into us (and into every other peer it finds), + // turning a single discovery invocation into a recursive fan-out + // bounded only by the per-level timeout. `--format json` is + // required so the peer emits a machine-readable row (the human + // table layout is the default when `--format` is omitted). + var primary = await SpawnAndCaptureAsync(binaryPath, ["doctor", "--self", "--format", "json"], cancellationToken).ConfigureAwait(false); + if (primary.Cancelled) + { + cancellationToken.ThrowIfCancellationRequested(); + } + + if (primary.Failure is { } primaryFailure) + { + return new PeerProbeResult.Failed(primaryFailure); + } + + if (primary.ExitCode == 0 && TryParseRichProbeResult(binaryPath, primary.Stdout, out var primaryInfo)) + { + return new PeerProbeResult.Ok(primaryInfo); + } + + // Fallback path. We reach here for: + // - peer exited non-zero (common: peer predates `doctor --self` + // and System.CommandLine rejected the unknown option), + // - peer emitted blank/whitespace-only stdout, + // - peer emitted JSON we couldn't parse as the expected rich shape. + // Older peers without `doctor --self` can't report their channel + // here, but `InstallationDiscovery` recovers `pr-` from the + // reported informational version string so the user-facing table + // still shows the channel for PR builds. + var fallback = await SpawnAndCaptureAsync(binaryPath, ["--version"], cancellationToken).ConfigureAwait(false); + if (fallback.Cancelled) + { + cancellationToken.ThrowIfCancellationRequested(); + } + + if (fallback.Failure is not null) + { + // Surface the rich-probe failure reason because it tells the user why + // the richer path didn't work; the version fallback failing on top is + // a secondary symptom. + return new PeerProbeResult.Failed(DescribePrimaryFailure(primary, alsoTriedVersion: true)); + } + + if (fallback.ExitCode != 0) + { + return new PeerProbeResult.Failed(DescribePrimaryFailure(primary, alsoTriedVersion: true)); + } + + var versionLine = ExtractVersionLine(fallback.Stdout); + if (string.IsNullOrEmpty(versionLine)) + { + return new PeerProbeResult.Failed(DescribePrimaryFailure(primary, alsoTriedVersion: true)); + } + + // Partial install details: version only. Route is overlaid by InstallationDiscovery + // from the locally-readable sidecar. Channel intentionally null — we can't + // read assembly metadata + // from outside an AOT binary, and the older peer has no surface that + // exposes its channel. + return new PeerProbeResult.Ok(new InstallationInfo + { + Path = binaryPath, + Version = versionLine, + Status = InstallationInfoStatus.Ok, + }); + } + + private bool TryParseRichProbeResult(string binaryPath, string stdout, out InstallationInfo info) + { + info = null!; + if (string.IsNullOrWhiteSpace(stdout)) + { + _logger.LogDebug("Peer probe at {BinaryPath} produced no rich JSON output.", binaryPath); + return false; + } + + try + { + using var doc = JsonDocument.Parse(stdout); + + JsonElement? row = null; + if (doc.RootElement.ValueKind == JsonValueKind.Object && + doc.RootElement.TryGetProperty("installations", out var installations) && + installations.ValueKind == JsonValueKind.Array && + installations.GetArrayLength() > 0) + { + row = installations[0]; + } + else if (doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0) + { + row = doc.RootElement[0]; + } + + // The first element MUST be a JSON object before we hand it to + // InstallationInfoParser. TryGetProperty (which the parser calls) + // throws InvalidOperationException for non-object kinds (e.g. [1], + // [null], [[]]). Treat anything else as a wrong-shape response and + // fall through to the --version fallback rather than aborting the + // whole discovery walk for the caller. + if (row is { ValueKind: JsonValueKind.Object } element) + { + info = InstallationInfoParser.Parse(element); + return true; + } + + _logger.LogDebug("Peer probe at {BinaryPath} returned JSON without an installation row; trying the --version fallback.", binaryPath); + return false; + } + catch (JsonException ex) + { + _logger.LogDebug(ex, "Peer probe at {BinaryPath} returned invalid JSON; trying the --version fallback.", binaryPath); + return false; + } + } + + /// + /// Spawns the peer with the given arguments and captures stdout under + /// the timeout / kill-on-timeout / stdout-cap contract. Returns a + /// structured result describing exit code, captured output, and any + /// transport-level failure (process couldn't start, etc.). + /// + private async Task SpawnAndCaptureAsync(string binaryPath, string[] arguments, CancellationToken cancellationToken) + { + var startInfo = new ProcessStartInfo + { + FileName = binaryPath, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + // Force UTF-8 decoding so a peer running under a non-UTF-8 console code page + // (e.g. legacy Windows CP1252) doesn't produce replacement characters when + // its stderr is folded into the failure reason. Aspire CLI peers in scope + // emit UTF-8 by default, so this aligns the decoder with the actual byte + // shape on the wire. + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + foreach (var arg in arguments) + { + startInfo.ArgumentList.Add(arg); + } + + var result = await ProcessCaptureRunner.RunAsync( + startInfo, + _timeout, + CapturePeerOutputAsync, + static () => new PeerProcessOutput(string.Empty, string.Empty, StderrTruncated: false), + _logger, + cancellationToken).ConfigureAwait(false); + + var failure = result.FailureKind switch + { + ProcessCaptureFailureKind.StartFailed => result.FailureMessage is { Length: > 0 } message + ? $"Could not start peer process: {message}" + : "Could not start peer process.", + ProcessCaptureFailureKind.CaptureFailed => result.FailureMessage is { Length: > 0 } message + ? $"Could not capture peer process output: {message}" + : "Could not capture peer process output.", + ProcessCaptureFailureKind.TimedOut => $"Peer probe timed out after {_timeout.TotalSeconds:F1}s.", + _ => null, + }; + + return new SpawnResult( + ExitCode: result.ExitCode, + Stdout: result.Capture.Stdout, + Stderr: result.Capture.Stderr, + StderrTruncated: result.Capture.StderrTruncated, + Failure: failure, + Cancelled: result.Cancelled); + } + + /// + /// Composes a user-facing reason for a probe failure. When the + /// --version fallback was also attempted, prefix the message so + /// users see both attempts in one row. + /// + private static string DescribePrimaryFailure(SpawnResult primary, bool alsoTriedVersion) + { + var suffix = alsoTriedVersion ? " (and --version fallback)" : string.Empty; + if (primary.Failure is { } reason) + { + return FoldStderrIntoReason(reason + suffix, primary); + } + if (primary.ExitCode != 0) + { + return FoldStderrIntoReason($"Peer exited with code {primary.ExitCode}{suffix}.", primary); + } + return FoldStderrIntoReason($"Peer produced no usable output{suffix}.", primary); + } + + /// + /// Pulls the first non-blank line out of aspire --version + /// output. Older Aspire CLI versions emit just the bare version + /// string; newer versions may add a banner, in which case the first + /// non-blank line still holds the version. + /// + private static string? ExtractVersionLine(string stdout) + { + foreach (var raw in stdout.Split('\n')) + { + var trimmed = raw.Trim(); + if (trimmed.Length == 0) + { + continue; + } + return trimmed; + } + return null; + } + + private static string FoldStderrIntoReason(string reason, SpawnResult result) + { + var stderr = SanitizeStderr(result.Stderr); + if (string.IsNullOrEmpty(stderr)) + { + return reason; + } + + if (result.StderrTruncated) + { + stderr += "... [truncated]"; + } + + return string.IsNullOrEmpty(reason) + ? stderr + : $"{reason}; stderr: {stderr}"; + } + + private readonly record struct SpawnResult(int ExitCode, string Stdout, string Stderr, bool StderrTruncated, string? Failure, bool Cancelled); + + private readonly record struct PeerProcessOutput(string Stdout, string Stderr, bool StderrTruncated); + + private readonly record struct CappedOutput(string Text, bool Truncated); + + private static async Task CapturePeerOutputAsync(Process process, CancellationToken cancellationToken) + { + var readStdoutTask = ReadCappedAsync(process.StandardOutput.BaseStream, OutputCap, cancellationToken); + var readStderrTask = ReadCappedAsync(process.StandardError.BaseStream, OutputCap, cancellationToken); + + var stdout = await SwallowAsync(readStdoutTask).ConfigureAwait(false); + var stderr = await SwallowAsync(readStderrTask).ConfigureAwait(false); + + return new PeerProcessOutput(stdout.Text, stderr.Text, stderr.Truncated); + } + + /// + /// Reads into a pooled buffer until EOF or + /// bytes have been captured, whichever comes + /// first. Past the cap the loop keeps draining the pipe so the peer + /// doesn't block on a full pipe; trailing bytes are discarded and the + /// returned flag is set. The cap + /// exists so a peer spamming output cannot make the parent allocate + /// unbounded memory. + /// + private static async Task ReadCappedAsync(Stream stream, int cap, CancellationToken cancellationToken) + { + using var output = new MemoryStream(capacity: Math.Min(cap, 4096)); + var buffer = ArrayPool.Shared.Rent(4096); + var truncated = false; + try + { + while (true) + { + int read; + try + { + read = await stream.ReadAsync(buffer.AsMemory(), cancellationToken).ConfigureAwait(false); + } + // OperationCanceledException is swallowed alongside the I/O exceptions + // because cancellation is owned by the process-kill path in + // ProcessCaptureRunner; the reader's job is just to stop pulling and + // surface whatever was captured so far. + catch (Exception ex) when (ex is IOException or OperationCanceledException or ObjectDisposedException) + { + break; + } + + if (read == 0) + { + break; + } + + var remaining = cap - (int)output.Length; + if (remaining <= 0) + { + truncated = true; + continue; + } + + var toWrite = Math.Min(read, remaining); + output.Write(buffer, 0, toWrite); + if (toWrite < read) + { + truncated = true; + } + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + return new CappedOutput( + Encoding.UTF8.GetString(output.GetBuffer().AsSpan(0, (int)output.Length)), + truncated); + } + + private static string SanitizeStderr(string stderr) + { + // The byte cap is applied before sanitization so raw peer output is + // always bounded; the truncation marker is appended after stripping. + if (string.IsNullOrEmpty(stderr)) + { + return string.Empty; + } + + var builder = new StringBuilder(stderr.Length); + for (var i = 0; i < stderr.Length; i++) + { + var ch = stderr[i]; + if (ch == '\u001b') + { + if (i + 1 < stderr.Length && stderr[i + 1] == '[') + { + i += 2; + while (i < stderr.Length && (stderr[i] < '@' || stderr[i] > '~')) + { + i++; + } + } + continue; + } + + if (char.IsControl(ch) && ch != '\n') + { + continue; + } + + builder.Append(ch); + } + + return builder.ToString().Trim(); + } + + private static async Task SwallowAsync(Task task) + { + try + { + return await task.ConfigureAwait(false); + } + catch + { + // Reader is being torn down alongside the killed process — + // any exception here is uninteresting noise. + return new CappedOutput(string.Empty, Truncated: false); + } + } + +} diff --git a/src/Aspire.Cli/Acquisition/WingetFirstRunProbe.cs b/src/Aspire.Cli/Acquisition/WingetFirstRunProbe.cs index 145ca196e36..e8a91cdf270 100644 --- a/src/Aspire.Cli/Acquisition/WingetFirstRunProbe.cs +++ b/src/Aspire.Cli/Acquisition/WingetFirstRunProbe.cs @@ -13,8 +13,6 @@ namespace Aspire.Cli.Acquisition; /// internal sealed class WingetFirstRunProbe { - internal const string SidecarFileName = ".aspire-install.json"; - private static readonly byte[] s_wingetSidecarContent = Encoding.UTF8.GetBytes("{\"source\":\"winget\"}"); private readonly IWindowsRegistryReader _registry; @@ -40,7 +38,7 @@ public void Run(string binaryDir) return; } - var sidecarPath = Path.Combine(binaryDir, SidecarFileName); + var sidecarPath = Path.Combine(binaryDir, InstallSidecarReader.SidecarFileName); if (File.Exists(sidecarPath)) { return; @@ -62,7 +60,7 @@ public void Run(string binaryDir) private void TryWriteSidecarAtomically(string binaryDir, string sidecarPath) { - var tempPath = Path.Combine(binaryDir, $"{SidecarFileName}.{Guid.NewGuid():N}.tmp"); + var tempPath = Path.Combine(binaryDir, $"{InstallSidecarReader.SidecarFileName}.{Guid.NewGuid():N}.tmp"); try { diff --git a/src/Aspire.Cli/Bundles/BundleService.cs b/src/Aspire.Cli/Bundles/BundleService.cs index 79982a458b5..4efe2b60332 100644 --- a/src/Aspire.Cli/Bundles/BundleService.cs +++ b/src/Aspire.Cli/Bundles/BundleService.cs @@ -6,7 +6,6 @@ using System.IO.Compression; using System.IO.Hashing; using System.Text; -using System.Text.Json; using Aspire.Cli.Acquisition; using Aspire.Cli.Layout; using Aspire.Cli.Utils; @@ -198,7 +197,7 @@ private async Task ExtractAsyncCore(string destinationPath, // non-Windows and once the sidecar already exists. if (wingetFirstRunProbe is not null && OperatingSystem.IsWindows()) { - var realBinaryPath = ResolveSymlinks(processPath, logger); + var realBinaryPath = CliPathHelper.ResolveSymlinkOrOriginalPath(processPath, logger); var binaryDir = Path.GetDirectoryName(realBinaryPath); if (!string.IsNullOrEmpty(binaryDir)) { @@ -385,7 +384,10 @@ private async Task ExtractVersionedLayoutAsync( /// Computes the bundle extract directory from the sidecar source value. /// See docs/specs/install-routes.md for the contract. /// - internal static string? ComputeDefaultExtractDir(string processPath, ILogger? logger = null) + internal static string? ComputeDefaultExtractDir(string processPath) + => ComputeDefaultExtractDir(processPath, logger: null); + + private static string? ComputeDefaultExtractDir(string processPath, ILogger? logger) { logger ??= NullLogger.Instance; @@ -394,72 +396,32 @@ private async Task ExtractVersionedLayoutAsync( return null; } - var realBinaryPath = ResolveSymlinks(processPath, logger); + var realBinaryPath = CliPathHelper.ResolveSymlinkOrOriginalPath(processPath, logger); var binaryDir = Path.GetDirectoryName(realBinaryPath); if (string.IsNullOrEmpty(binaryDir)) { return null; } - var sidecarPath = Path.Combine(binaryDir, SidecarFileName); - var source = ReadSidecarSource(sidecarPath, logger); + // Sidecar parsing is shared with InstallSidecarReader; the layout + // mapping below intentionally uses the raw wire string so the + // mapping remains a static, dependency-free function callable from + // any context (including code paths that run before DI is wired). + var sidecarPath = Path.Combine(binaryDir, InstallSidecarReader.SidecarFileName); + var source = InstallSidecarReader.ReadSourceField(sidecarPath); return source switch { - "winget" or "brew" or "dotnet-tool" => binaryDir, - "script" or "pr" => Path.GetDirectoryName(binaryDir) ?? binaryDir, + InstallSourceExtensions.WingetWire + or InstallSourceExtensions.BrewWire + or InstallSourceExtensions.DotnetToolWire => binaryDir, + InstallSourceExtensions.ScriptWire + or InstallSourceExtensions.PrWire + or InstallSourceExtensions.LocalHiveWire => Path.GetDirectoryName(binaryDir) ?? binaryDir, _ => Path.GetDirectoryName(binaryDir) ?? binaryDir, }; } - private const string SidecarFileName = ".aspire-install.json"; - - private static string? ReadSidecarSource(string sidecarPath, ILogger logger) - { - if (!File.Exists(sidecarPath)) - { - return null; - } - - try - { - using var stream = File.OpenRead(sidecarPath); - using var doc = JsonDocument.Parse(stream); - if (doc.RootElement.ValueKind == JsonValueKind.Object && - doc.RootElement.TryGetProperty("source", out var sourceElement) && - sourceElement.ValueKind == JsonValueKind.String) - { - return sourceElement.GetString(); - } - } - catch (Exception ex) - { - // Best-effort sidecar read: any failure (I/O, malformed JSON, permissions, - // etc.) falls through to the parent-of-binary heuristic. Log so an - // unexpectedly-missing sidecar is diagnosable. - logger.LogDebug(ex, "Failed to read install-route sidecar at {Path}; treating as missing.", sidecarPath); - } - - return null; - } - - private static string ResolveSymlinks(string path, ILogger logger) - { - try - { - var resolved = File.ResolveLinkTarget(path, returnFinalTarget: true); - return resolved is null ? path : resolved.FullName; - } - catch (Exception ex) - { - // Best-effort symlink resolution: any failure falls back to the raw - // path. Sidecar discovery using the raw path is still valid in the - // non-link case. - logger.LogDebug(ex, "Failed to resolve link target for {Path}; using raw path.", path); - return path; - } - } - /// /// Captures the current reparse-point targets for the public link paths so /// they can be restored if the post-flip sanity check fails. diff --git a/src/Aspire.Cli/CliExecutionContext.cs b/src/Aspire.Cli/CliExecutionContext.cs index 2439c253765..05585b040f9 100644 --- a/src/Aspire.Cli/CliExecutionContext.cs +++ b/src/Aspire.Cli/CliExecutionContext.cs @@ -5,7 +5,7 @@ namespace Aspire.Cli; -internal sealed class CliExecutionContext(DirectoryInfo workingDirectory, DirectoryInfo hivesDirectory, DirectoryInfo cacheDirectory, DirectoryInfo sdksDirectory, DirectoryInfo logsDirectory, string logFilePath, bool debugMode = false, IReadOnlyDictionary? environmentVariables = null, DirectoryInfo? homeDirectory = null, DirectoryInfo? packagesDirectory = null, string identityChannel = "local") +internal sealed class CliExecutionContext(DirectoryInfo workingDirectory, DirectoryInfo hivesDirectory, DirectoryInfo cacheDirectory, DirectoryInfo sdksDirectory, DirectoryInfo logsDirectory, string logFilePath, bool debugMode = false, IReadOnlyDictionary? environmentVariables = null, DirectoryInfo? homeDirectory = null, DirectoryInfo? packagesDirectory = null, string identityChannel = "local", DirectoryInfo? aspireHomeDirectory = null) { public DirectoryInfo WorkingDirectory { get; } = workingDirectory; public DirectoryInfo HivesDirectory { get; } = hivesDirectory; @@ -61,6 +61,27 @@ internal sealed class CliExecutionContext(DirectoryInfo workingDirectory, Direct public string? AppHostCliLogFilePath { get; set; } public DirectoryInfo HomeDirectory { get; } = homeDirectory ?? new DirectoryInfo(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); + + /// + /// Gets the Aspire state root used for route-specific install layouts. + /// + /// + /// Production wires this explicitly via + /// using + /// so the install-route sidecar lookup runs. When neither aspireHomeDirectory + /// nor homeDirectory are supplied (direct construction in tests), the same + /// install-route lookup is performed here so route-aware code paths see a + /// consistent value. When homeDirectory is supplied without + /// aspireHomeDirectory, the home stays contained within the test directory + /// at <homeDirectory>/.aspire — the install-route lookup is + /// intentionally skipped because tests passing an explicit homeDirectory + /// are declaring their own filesystem sandbox. + /// + public DirectoryInfo AspireHomeDirectory { get; } = aspireHomeDirectory ?? new DirectoryInfo( + homeDirectory is not null + ? Path.Combine(homeDirectory.FullName, ".aspire") + : Utils.CliPathHelper.GetAspireHomeDirectory()); + public bool DebugMode { get; } = debugMode; /// @@ -127,4 +148,4 @@ public int GetHiveCount() return HivesDirectory.GetDirectories().Length; } -} \ No newline at end of file +} diff --git a/src/Aspire.Cli/Commands/DoctorCommand.cs b/src/Aspire.Cli/Commands/DoctorCommand.cs index 4772dced3cf..63e79904fcf 100644 --- a/src/Aspire.Cli/Commands/DoctorCommand.cs +++ b/src/Aspire.Cli/Commands/DoctorCommand.cs @@ -3,12 +3,14 @@ using System.CommandLine; using System.Globalization; +using Aspire.Cli.Acquisition; using Aspire.Cli.Configuration; using Aspire.Cli.Interaction; using Aspire.Cli.Resources; using Aspire.Cli.Utils; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils.EnvironmentChecker; +using Microsoft.Extensions.Logging; using Spectre.Console; namespace Aspire.Cli.Commands; @@ -17,45 +19,84 @@ internal sealed class DoctorCommand : BaseCommand { internal override HelpGroup HelpGroup => HelpGroup.ToolsAndConfiguration; + // The cli-version environment check already surfaces "newer version available" + // information directly inside `checks[]` with structured metadata. The trailing + // BaseCommand-driven update banner would print a second, less-structured copy on + // stderr — pure duplication, plus noise for JSON consumers. Matches the convention + // already used by other --format json commands (ApiGet, ApiList, DocsSearch, DocsList). + protected override bool UpdateNotificationsEnabled => false; + private readonly IEnvironmentChecker _environmentChecker; + private readonly IInstallationDiscovery _installationDiscovery; + private readonly WingetFirstRunProbe _wingetFirstRunProbe; private readonly IAnsiConsole _ansiConsole; + private readonly ILogger _logger; private static readonly Option s_formatOption = new("--format") { Description = DoctorCommandStrings.JsonOptionDescription }; + private static readonly Option s_selfOption = new("--self") + { + Hidden = true, + }; public DoctorCommand( IEnvironmentChecker environmentChecker, + IInstallationDiscovery installationDiscovery, + WingetFirstRunProbe wingetFirstRunProbe, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, IInteractionService interactionService, IAnsiConsole ansiConsole, + ILogger logger, AspireCliTelemetry telemetry) : base("doctor", DoctorCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { _environmentChecker = environmentChecker; + _installationDiscovery = installationDiscovery; + _wingetFirstRunProbe = wingetFirstRunProbe; _ansiConsole = ansiConsole; + _logger = logger; Options.Add(s_formatOption); + Options.Add(s_selfOption); } protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { var format = parseResult.GetValue(s_formatOption); + var selfOnly = parseResult.GetValue(s_selfOption); + + if (selfOnly) + { + var self = InstallationInfoOutput.DescribeSelfSafely(_installationDiscovery, _logger); + if (format == OutputFormat.Json) + { + OutputJson([], self); + } + else + { + InstallationInfoOutput.OutputTable(_ansiConsole, self); + } + return CommandResult.Success(); + } + + var installationsTask = InstallationInfoOutput.DiscoverAllSafelyAsync(_installationDiscovery, _wingetFirstRunProbe, _logger, cancellationToken); // Run all prerequisite checks var results = await InteractionService.ShowStatusAsync( DoctorCommandStrings.CheckingPrerequisites, async () => await _environmentChecker.CheckAllAsync(cancellationToken)); + var installations = await installationsTask; if (format == OutputFormat.Json) { - OutputJson(results); + OutputJson(results, installations); } else { - OutputHumanReadable(results); + OutputHumanReadable(results, installations); } // Exit code: 0 if no failures (warnings are OK), 1 (InvalidCommand) if any failures @@ -63,7 +104,7 @@ protected override async Task ExecuteAsync(ParseResult parseResul return CommandResult.FromExitCode(hasFailures ? CliExitCodes.InvalidCommand : CliExitCodes.Success); } - private void OutputJson(IReadOnlyList results) + private void OutputJson(IReadOnlyList results, IReadOnlyList installations) { var passed = results.Count(r => r.Status == EnvironmentCheckStatus.Pass); var warnings = results.Count(r => r.Status == EnvironmentCheckStatus.Warning); @@ -77,7 +118,8 @@ private void OutputJson(IReadOnlyList results) Passed = passed, Warnings = warnings, Failed = failed - } + }, + Installations = installations.ToList() }; var json = System.Text.Json.JsonSerializer.Serialize(response, JsonSourceGenerationContext.RelaxedEscaping.DoctorCheckResponse); @@ -86,7 +128,7 @@ private void OutputJson(IReadOnlyList results) InteractionService.DisplayRawText(json, ConsoleOutput.Standard); } - private void OutputHumanReadable(IReadOnlyList results) + private void OutputHumanReadable(IReadOnlyList results, IReadOnlyList installations) { _ansiConsole.WriteLine(); _ansiConsole.MarkupLine($"[bold]{DoctorCommandStrings.EnvironmentCheckHeader}[/]"); @@ -124,6 +166,8 @@ private void OutputHumanReadable(IReadOnlyList results) const string prerequisitesUrl = "https://aka.ms/aspire-prerequisites"; _ansiConsole.MarkupLine(string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DetailedPrerequisitesLink, MarkupHelpers.SafeLink(InteractionService, prerequisitesUrl))); } + + InstallationInfoOutput.OutputTable(_ansiConsole, installations); } private void OutputCheckResult(EnvironmentCheckResult result) diff --git a/src/Aspire.Cli/Commands/InstallationInfoOutput.cs b/src/Aspire.Cli/Commands/InstallationInfoOutput.cs new file mode 100644 index 00000000000..f7e1e1e30cb --- /dev/null +++ b/src/Aspire.Cli/Commands/InstallationInfoOutput.cs @@ -0,0 +1,174 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Acquisition; +using Aspire.Cli.Resources; +using Microsoft.Extensions.Logging; +using Spectre.Console; + +namespace Aspire.Cli.Commands; + +internal static class InstallationInfoOutput +{ + public static async Task> DiscoverAllSafelyAsync( + IInstallationDiscovery discovery, + WingetFirstRunProbe wingetFirstRunProbe, + ILogger logger, + CancellationToken cancellationToken) + { + try + { + RunWingetFirstRunProbe(wingetFirstRunProbe); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "Could not run the winget first-run install sidecar probe before doctor installation discovery."); + } + + try + { + logger.LogDebug("Discovering Aspire CLI installations for doctor output."); + var installations = await discovery.DiscoverAllAsync(cancellationToken).ConfigureAwait(false); + logger.LogDebug("Discovered {InstallationCount} Aspire CLI installation(s) for doctor output.", installations.Count); + return installations; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not discover Aspire CLI installations for doctor output."); + return CreateFailedDiscoveryRow(); + } + } + + public static IReadOnlyList DescribeSelfSafely(IInstallationDiscovery discovery, ILogger logger) + { + try + { + return [discovery.DescribeSelf()]; + } + catch (OperationCanceledException) + { + // Symmetric with DiscoverAllSafelyAsync: cancellation must propagate + // so the caller can honor the cancellation token even if DescribeSelf + // ever becomes cancellable. + throw; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not describe the running Aspire CLI installation for doctor self-probe output."); + return CreateFailedDiscoveryRow(); + } + } + + public static void RunWingetFirstRunProbe(WingetFirstRunProbe wingetFirstRunProbe) + { + // Give a never-run winget install a chance to stamp its sidecar before + // we read it. The probe writes nothing on non-Windows hosts or when the + // running binary isn't a winget portable install, so this is a cheap + // no-op in the common case. + var processPath = Environment.ProcessPath; + if (string.IsNullOrEmpty(processPath)) + { + return; + } + + var binaryDir = Path.GetDirectoryName(processPath); + if (!string.IsNullOrEmpty(binaryDir)) + { + wingetFirstRunProbe.Run(binaryDir); + } + } + + public static void OutputTable(IAnsiConsole ansiConsole, IReadOnlyList installs) + { + ansiConsole.WriteLine(); + ansiConsole.MarkupLine($"[bold]{DoctorCommandStrings.HeaderInstallations.EscapeMarkup()}[/]"); + ansiConsole.WriteLine(new string('=', DoctorCommandStrings.HeaderInstallations.Length)); + ansiConsole.WriteLine(); + + var table = new Table() + .Border(TableBorder.Rounded) + .AddColumn(DoctorCommandStrings.ColumnPath) + .AddColumn(DoctorCommandStrings.ColumnVersion) + .AddColumn(DoctorCommandStrings.ColumnChannel) + .AddColumn(DoctorCommandStrings.ColumnRoute) + .AddColumn(DoctorCommandStrings.ColumnPathStatus); + + // The first row is, by contract, the running CLI (enforced by + // InstallationDiscovery, not by ordering here). Tag installs[0] + // directly rather than re-resolving Environment.ProcessPath and + // matching CanonicalPath: that re-derivation can disagree with + // the discovery layer's notion of self (e.g. when ProcessPath is + // unresolvable at render time but was resolvable when DescribeSelf + // ran, or when a peer happens to share a canonical path with the + // running CLI). + for (var i = 0; i < installs.Count; i++) + { + var install = installs[i]; + var isSelf = i == 0; + var pathDisplay = string.IsNullOrEmpty(install.Path) + ? DoctorCommandStrings.ValueUnknown + : install.Path; + pathDisplay = pathDisplay.EscapeMarkup(); + if (isSelf) + { + pathDisplay = $"{pathDisplay} [grey]{DoctorCommandStrings.ValueCurrentMarker.EscapeMarkup()}[/]"; + } + + table.AddRow( + pathDisplay, + ValueOrPlaceholder(install.Version, install.Status), + ValueOrPlaceholder(install.Channel, install.Status), + ValueOrPlaceholder(install.Route, install.Status), + PathStatusDisplay(install.PathStatus)); + } + + ansiConsole.Write(table); + } + + private static string PathStatusDisplay(string pathStatus) + { + return pathStatus switch + { + InstallationPathStatus.Active => DoctorCommandStrings.ValuePathActive, + InstallationPathStatus.Shadowed => DoctorCommandStrings.ValuePathShadowed, + InstallationPathStatus.NotOnPath => DoctorCommandStrings.ValuePathNotOnPath, + _ => pathStatus.EscapeMarkup(), + }; + } + + private static string ValueOrPlaceholder(string? value, string status) + { + if (!string.IsNullOrEmpty(value)) + { + return value.EscapeMarkup(); + } + + // Missing fields mean different things for skipped rows, failed + // probes, and rows that responded but did not populate this value. + return status switch + { + InstallationInfoStatus.NotProbed => DoctorCommandStrings.ValueNotProbed, + InstallationInfoStatus.Failed => DoctorCommandStrings.ValueProbeFailed, + _ => DoctorCommandStrings.ValueUnknown, + }; + } + + private static IReadOnlyList CreateFailedDiscoveryRow() + { + return + [ + new InstallationInfo + { + Path = Environment.ProcessPath ?? string.Empty, + CanonicalPath = null, + PathStatus = InstallationPathStatus.NotOnPath, + Status = InstallationInfoStatus.Failed, + StatusReason = DoctorCommandStrings.InstallationDiscoveryFailedReason, + } + ]; + } +} diff --git a/src/Aspire.Cli/JsonSourceGenerationContext.cs b/src/Aspire.Cli/JsonSourceGenerationContext.cs index 249715d1222..c0c36ff4e23 100644 --- a/src/Aspire.Cli/JsonSourceGenerationContext.cs +++ b/src/Aspire.Cli/JsonSourceGenerationContext.cs @@ -5,6 +5,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Nodes; +using Aspire.Cli.Acquisition; using Aspire.Cli.Caching; using Aspire.Cli.Projects; using Aspire.Cli.Certificates; @@ -51,6 +52,7 @@ namespace Aspire.Cli; [JsonSerializable(typeof(IntegrationSearchResult[]))] [JsonSerializable(typeof(string[]))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(InstallationInfo))] [JsonSerializable(typeof(AppHostInfoCacheEntry))] [JsonSerializable(typeof(AppHostProjectInspectionOutput))] internal partial class JsonSourceGenerationContext : JsonSerializerContext diff --git a/src/Aspire.Cli/Packaging/NuGetConfigMerger.cs b/src/Aspire.Cli/Packaging/NuGetConfigMerger.cs index d245300feef..2b3fd441efd 100644 --- a/src/Aspire.Cli/Packaging/NuGetConfigMerger.cs +++ b/src/Aspire.Cli/Packaging/NuGetConfigMerger.cs @@ -263,9 +263,9 @@ private static void UpdateExistingPackageSourceMapping(NuGetConfigContext contex RemoveOrphanedSafeToRemoveSources(context, sourcesInUse); } - // Strip safe-to-remove sources (e.g. ~/.aspire/hives/pr-/packages) from + // Strip safe-to-remove sources (e.g. ~/.aspire/hives/*/packages) from // when they have no corresponding entry after the merge and are not - // required by the new channel. Without this, a previous PR hive that was listed in + // required by the new channel. Without this, CLI-managed dogfood feeds that were listed in // but never mapped (or whose mapping was rewritten by an earlier merge) // would linger forever and break `dotnet restore` with NU1301 once the hive directory is // cleaned up on disk. @@ -723,7 +723,7 @@ private static bool IsAspireRelatedPattern(string pattern) private static bool IsSourceSafeToRemove(string sourceKey, string? sourceValue) { - // Only remove sources that we know are tied to Aspire channels or PR hives + // Only remove sources that we know are tied to Aspire channels or CLI-managed hive feeds if (string.IsNullOrEmpty(sourceKey) && string.IsNullOrEmpty(sourceValue)) { return false; @@ -731,7 +731,7 @@ private static bool IsSourceSafeToRemove(string sourceKey, string? sourceValue) var urlToCheck = sourceValue ?? sourceKey; - // Check if this is an Aspire PR hive + // Check if this is an Aspire hive feed if (!string.IsNullOrEmpty(urlToCheck) && urlToCheck.Contains(".aspire") && urlToCheck.Contains("hives")) { return true; diff --git a/src/Aspire.Cli/Packaging/PackageChannel.cs b/src/Aspire.Cli/Packaging/PackageChannel.cs index 0cce5207aa4..12d6dc7671e 100644 --- a/src/Aspire.Cli/Packaging/PackageChannel.cs +++ b/src/Aspire.Cli/Packaging/PackageChannel.cs @@ -3,17 +3,25 @@ using System.IO.Compression; using System.Xml.Linq; +using Aspire.Cli.Configuration; using Aspire.Cli.NuGet; using Aspire.Cli.Resources; using Aspire.Cli.Utils; +using Aspire.Hosting.Utils; using Microsoft.Extensions.Logging; using Semver; using NuGetPackage = Aspire.Shared.NuGetPackageCli; namespace Aspire.Cli.Packaging; -internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, string? pinnedVersion = null, ILogger? logger = null) +internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, IFeatures features, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, string? pinnedVersion = null, ILogger? logger = null) { + // Threaded so the local-folder integration listing can honor the same + // ShowDeprecatedPackages flag that NuGetPackageCache honors on the feed-based path. + // Without this, flipping the flag silently has no effect on local hive / PR hive listings + // (https://github.com/microsoft/aspire/issues — divergence between two paths through the same intent). + private readonly IFeatures _features = features; + private const string GuestAppHostSdkPackageId = "Aspire.Hosting"; public string Name { get; } = name; @@ -88,26 +96,10 @@ public async Task> GetTemplatePackagesAsync(DirectoryI public async Task> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken) { - // For local hive channels the Aspire* source is a flat folder of .nupkg files. - // dotnet package search does not support local folder sources and returns no results. - // When a pinned version is set, enumerate the .nupkg files directly instead. - if (PinnedVersion is not null) + var localPackageSource = GetLocalAspirePackageSource(); + if (localPackageSource is not null) { - var aspireMapping = Mappings?.FirstOrDefault(m => - m.PackageFilter.StartsWith("Aspire", StringComparison.OrdinalIgnoreCase) && - m.PackageFilter != PackageMapping.AllPackages && - !UrlHelper.IsHttpUrl(m.Source)); - - if (aspireMapping is not null && Directory.Exists(aspireMapping.Source)) - { - return Directory.EnumerateFiles(aspireMapping.Source, "*.nupkg", SearchOption.TopDirectoryOnly) - .Select(TryGetPackageIdentityFromPackageFileName) - .Where(p => p.HasValue) - .Select(p => p!.Value) - .Where(p => p.PackageId.StartsWith("Aspire.Hosting", StringComparison.OrdinalIgnoreCase)) - .Select(p => new NuGetPackage { Id = p.PackageId, Version = PinnedVersion, Source = aspireMapping.Source }) - .ToList(); - } + return GetIntegrationPackagesFromLocalPackageSource(localPackageSource, cancellationToken); } var tasks = new List>>(); @@ -150,6 +142,68 @@ public async Task> GetIntegrationPackagesAsync(Directo return filteredPackages; } + private DirectoryInfo? GetLocalAspirePackageSource() + { + if (Type is not PackageChannelType.Explicit || Mappings is null) + { + return null; + } + + foreach (var mapping in Mappings) + { + if (IsScopedAspireMapping(mapping) && Directory.Exists(mapping.Source)) + { + return new DirectoryInfo(mapping.Source); + } + } + + return null; + } + + private IEnumerable GetIntegrationPackagesFromLocalPackageSource(DirectoryInfo packageSource, CancellationToken cancellationToken) + { + // Mirror NuGetPackageCache.GetIntegrationPackagesAsync: a user who flipped + // ShowDeprecatedPackages to see deprecated packages on stable/staging/daily + // must also see them on local-hive / PR-hive listings. Previously the deprecation + // check was hardcoded into IsIntegrationPackageId and silently dropped them here. + var showDeprecatedPackages = _features.IsFeatureEnabled(KnownFeatures.ShowDeprecatedPackages, defaultValue: false); + + var packageMetadata = packageSource + .EnumerateFiles("*.nupkg", SearchOption.TopDirectoryOnly) + .Select(file => + { + cancellationToken.ThrowIfCancellationRequested(); + return GetPackageFileMetadata(file.FullName); + }) + .OfType() + .Where(metadata => IsIntegrationPackageId(metadata.PackageId)) + .Where(metadata => showDeprecatedPackages || !DeprecatedPackages.IsDeprecated(metadata.PackageId)) + .Where(IsAllowedByQuality); + + if (PinnedVersion is not null) + { + packageMetadata = packageMetadata + .Where(metadata => string.Equals(metadata.Version.ToString(), PinnedVersion, StringComparison.OrdinalIgnoreCase)); + } + + var source = PathNormalizer.NormalizePathForStorage(packageSource.FullName); + + return packageMetadata + .GroupBy(metadata => metadata.PackageId, StringComparers.NuGetPackageId) + .Select(group => group.OrderByDescending(metadata => metadata.Version, SemVersion.PrecedenceComparer).First()) + .OrderBy(metadata => metadata.PackageId, StringComparers.NuGetPackageId) + .Select(metadata => new NuGetPackage { Id = metadata.PackageId, Version = PinnedVersion ?? metadata.Version.ToString(), Source = source }) + .ToArray(); + + bool IsAllowedByQuality(PackageFileMetadata metadata) => new { metadata.Version, Quality } switch + { + { Quality: PackageChannelQuality.Both } => true, + { Quality: PackageChannelQuality.Stable, Version: { IsPrerelease: false } } => true, + { Quality: PackageChannelQuality.Prerelease, Version: { IsPrerelease: true } } => true, + _ => false + }; + } + public async Task> GetPackagesAsync(string packageId, DirectoryInfo workingDirectory, CancellationToken cancellationToken) { if (PinnedVersion is not null) @@ -338,7 +392,7 @@ public PackageChannel CreateScopedChannelForPackages(IEnumerable package .SelectMany(mapping => CreateScopedMappings(mapping, requestedPackageIds, logger)) .ToArray(); - return new PackageChannel(Name, Quality, scopedMappings, nuGetPackageCache, ConfigureGlobalPackagesFolder, CliDownloadBaseUrl, PinnedVersion, logger); + return new PackageChannel(Name, Quality, scopedMappings, nuGetPackageCache, _features, ConfigureGlobalPackagesFolder, CliDownloadBaseUrl, PinnedVersion, logger); } private static IEnumerable CreateScopedMappings(PackageMapping mapping, IReadOnlyCollection packageIds, ILogger? logger) @@ -478,18 +532,42 @@ private static bool IsScopedAspireMapping(PackageMapping mapping) !string.Equals(mapping.PackageFilter, PackageMapping.AllPackages, StringComparison.Ordinal); } - public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, string? pinnedVersion = null, ILogger? logger = null) + private static bool IsIntegrationPackageId(string packageId) + { + // NuGet package IDs are case-insensitive, so prefix checks use OrdinalIgnoreCase + // to stay consistent with StringComparers.NuGetPackageId used elsewhere in this + // file. .nupkg files on disk normally carry the canonical casing, but matching + // case-insensitively avoids silently dropping integrations whose file names were + // produced with a non-canonical casing (e.g. a third-party hive build). + // + // This method classifies a package id by namespace only. The deprecation filter + // is applied separately in GetIntegrationPackagesFromLocalPackageSource so it can + // be gated on the ShowDeprecatedPackages feature flag, matching the feed-based + // path in NuGetPackageCache. + var isHostingOrCommunityToolkitNamespaced = packageId.StartsWith("Aspire.Hosting.", StringComparison.OrdinalIgnoreCase) || + packageId.StartsWith("CommunityToolkit.Aspire.Hosting.", StringComparison.OrdinalIgnoreCase); + + var isExcluded = packageId.StartsWith("Aspire.Hosting.AppHost", StringComparison.OrdinalIgnoreCase) || + packageId.StartsWith("Aspire.Hosting.Sdk", StringComparison.OrdinalIgnoreCase) || + packageId.StartsWith("Aspire.Hosting.Orchestration", StringComparison.OrdinalIgnoreCase) || + packageId.StartsWith("Aspire.Hosting.Testing", StringComparison.OrdinalIgnoreCase) || + packageId.StartsWith("Aspire.Hosting.Msi", StringComparison.OrdinalIgnoreCase); + + return isHostingOrCommunityToolkitNamespaced && !isExcluded; + } + + public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, IFeatures features, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null, string? pinnedVersion = null, ILogger? logger = null) { - return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder, cliDownloadBaseUrl, pinnedVersion, logger); + return new PackageChannel(name, quality, mappings, nuGetPackageCache, features, configureGlobalPackagesFolder, cliDownloadBaseUrl, pinnedVersion, logger); } - public static PackageChannel CreateImplicitChannel(INuGetPackageCache nuGetPackageCache, ILogger? logger = null) + public static PackageChannel CreateImplicitChannel(INuGetPackageCache nuGetPackageCache, IFeatures features, ILogger? logger = null) { // The reason that PackageChannelQuality.Both is because there are situations like // in community toolkit where there is a newer beta version available for a package // in the case of implicit feeds we want to be able to show that, along side the stable // version. Not really an issue for template selection though (unless we start allowing) // for broader templating options. - return new PackageChannel("default", PackageChannelQuality.Both, null, nuGetPackageCache, logger: logger); + return new PackageChannel("default", PackageChannelQuality.Both, null, nuGetPackageCache, features, logger: logger); } } diff --git a/src/Aspire.Cli/Packaging/PackagingService.cs b/src/Aspire.Cli/Packaging/PackagingService.cs index 712bd412332..38d148d9c78 100644 --- a/src/Aspire.Cli/Packaging/PackagingService.cs +++ b/src/Aspire.Cli/Packaging/PackagingService.cs @@ -5,6 +5,7 @@ using Aspire.Cli.NuGet; using Aspire.Cli.Resources; using Aspire.Cli.Utils; +using Aspire.Hosting.Utils; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Semver; @@ -84,18 +85,18 @@ public PackagingService( public Task> GetChannelsAsync(CancellationToken cancellationToken = default, string? requestedChannelName = null) { - var defaultChannel = PackageChannel.CreateImplicitChannel(_nuGetPackageCache, _logger); + var defaultChannel = PackageChannel.CreateImplicitChannel(_nuGetPackageCache, _features, _logger); var stableChannel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Stable, PackageChannelQuality.Stable, new[] { new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg) - }, _nuGetPackageCache, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/ga/daily", logger: _logger); + }, _nuGetPackageCache, _features, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/ga/daily", logger: _logger); var dailyChannel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Daily, PackageChannelQuality.Prerelease, new[] { new PackageMapping("Aspire*", "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json"), new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg) - }, _nuGetPackageCache, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/daily", logger: _logger); + }, _nuGetPackageCache, _features, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/daily", logger: _logger); var prPackageChannels = new List(); @@ -165,12 +166,12 @@ private PackageChannel CreateLocalHiveChannel(string name, DirectoryInfo package var pinnedVersion = GetLocalHivePinnedVersion(packagesDirectory); // Use forward slashes for cross-platform NuGet config compatibility - var packagesPath = packagesDirectory.FullName.Replace('\\', '/'); + var packagesPath = PathNormalizer.NormalizePathForStorage(packagesDirectory.FullName); return PackageChannel.CreateExplicitChannel(name, PackageChannelQuality.Both, new[] { new PackageMapping("Aspire*", packagesPath), new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg) - }, _nuGetPackageCache, pinnedVersion: pinnedVersion, logger: _logger); + }, _nuGetPackageCache, _features, pinnedVersion: pinnedVersion, logger: _logger); } internal static DirectoryInfo? TryResolvePrInstallPackagesDirectory(string? processPath, string identityChannel) @@ -255,7 +256,7 @@ prDirectory.Parent is not { } dogfoodDirectory || { new PackageMapping("Aspire*", stagingFeedUrl), new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg) - }, _nuGetPackageCache, configureGlobalPackagesFolder: !useSharedFeed, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/rc/daily", pinnedVersion: pinnedVersion, logger: _logger); + }, _nuGetPackageCache, _features, configureGlobalPackagesFolder: !useSharedFeed, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/rc/daily", pinnedVersion: pinnedVersion, logger: _logger); // Surface the resolved staging routing so users can see what `--channel staging` actually // picked (the "show what was resolved" suggestion from the issue RCA). Pinned version is diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index e4e7677948d..315659208ad 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -56,9 +56,9 @@ public class Program { internal const string RootLoggerName = "Aspire.Cli"; - private static string GetUsersAspirePath() + private static string GetUsersAspirePath(string? processPath = null) { - return CliPathHelper.GetAspireHomeDirectory(); + return CliPathHelper.GetAspireHomeDirectory(processPath ?? Environment.ProcessPath); } /// @@ -93,7 +93,7 @@ public void Dispose() /// Parses logging options from command-line arguments. /// Returns all logging configuration including log level, debug mode, and file paths. /// - internal static CliLoggingOptions ParseLoggingOptions(string[]? args) + internal static CliLoggingOptions ParseLoggingOptions(string[]? args, string? processPath = null) { LogLevel? logLevel = null; var debugMode = false; @@ -123,7 +123,7 @@ internal static CliLoggingOptions ParseLoggingOptions(string[]? args) } } - var logsDirectory = Path.Combine(GetUsersAspirePath(), "logs"); + var logsDirectory = Path.Combine(GetUsersAspirePath(processPath), "logs"); var logFilePath = ParseLogFileOption(args) ?? FileLoggerProvider.GenerateLogFilePath(logsDirectory, TimeProvider.System); return new CliLoggingOptions(logLevel, debugMode, logsDirectory, logFilePath); @@ -412,6 +412,13 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -571,40 +578,41 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu return app; } - private static DirectoryInfo GetHivesDirectory() + private static DirectoryInfo GetHivesDirectory(string? processPath = null) { - var homeDirectory = GetUsersAspirePath(); + var homeDirectory = GetUsersAspirePath(processPath); var hivesDirectory = Path.Combine(homeDirectory, "hives"); return new DirectoryInfo(hivesDirectory); } - private static DirectoryInfo GetSdksDirectory() + private static DirectoryInfo GetSdksDirectory(string? processPath = null) { - var homeDirectory = GetUsersAspirePath(); + var homeDirectory = GetUsersAspirePath(processPath); var sdksPath = Path.Combine(homeDirectory, "sdks"); return new DirectoryInfo(sdksPath); } - private static CliExecutionContext BuildCliExecutionContext(bool debugMode, string logsDirectory, string logFilePath, string channel) + internal static CliExecutionContext BuildCliExecutionContext(bool debugMode, string logsDirectory, string logFilePath, string channel, string? processPath = null) { var workingDirectory = new DirectoryInfo(Environment.CurrentDirectory); - var hivesDirectory = GetHivesDirectory(); - var cacheDirectory = GetCacheDirectory(); - var sdksDirectory = GetSdksDirectory(); - var packagesDirectory = GetPackagesDirectory(); - return new CliExecutionContext(workingDirectory, hivesDirectory, cacheDirectory, sdksDirectory, new DirectoryInfo(logsDirectory), logFilePath, debugMode, packagesDirectory: packagesDirectory, identityChannel: channel); + var hivesDirectory = GetHivesDirectory(processPath); + var cacheDirectory = GetCacheDirectory(processPath); + var sdksDirectory = GetSdksDirectory(processPath); + var packagesDirectory = GetPackagesDirectory(processPath); + var aspireHomeDirectory = new DirectoryInfo(GetUsersAspirePath(processPath)); + return new CliExecutionContext(workingDirectory, hivesDirectory, cacheDirectory, sdksDirectory, new DirectoryInfo(logsDirectory), logFilePath, debugMode, packagesDirectory: packagesDirectory, identityChannel: channel, aspireHomeDirectory: aspireHomeDirectory); } - private static DirectoryInfo GetCacheDirectory() + private static DirectoryInfo GetCacheDirectory(string? processPath = null) { - var homeDirectory = GetUsersAspirePath(); + var homeDirectory = GetUsersAspirePath(processPath); var cacheDirectoryPath = Path.Combine(homeDirectory, "cache"); return new DirectoryInfo(cacheDirectoryPath); } - private static DirectoryInfo GetPackagesDirectory() + private static DirectoryInfo GetPackagesDirectory(string? processPath = null) { - var homeDirectory = GetUsersAspirePath(); + var homeDirectory = GetUsersAspirePath(processPath); var packagesDirectoryPath = Path.Combine(homeDirectory, "packages"); return new DirectoryInfo(packagesDirectoryPath); } @@ -648,7 +656,11 @@ internal static async Task DisplayFirstTimeUseNoticeIfNeededAsync(IServiceProvid { var configuration = serviceProvider.GetRequiredService(); var isInformationalCommand = args.Any(a => CommonOptionNames.InformationalOptionNames.Contains(a)); - var noLogo = args.Any(a => a == CommonOptionNames.NoLogo) || configuration.GetBool(CliConfigNames.NoLogo, defaultValue: false) || isInformationalCommand; + var isMachineReadableOutput = HasMachineReadableOutputFormat(args); + var noLogo = args.Any(a => a == CommonOptionNames.NoLogo) + || configuration.GetBool(CliConfigNames.NoLogo, defaultValue: false) + || isInformationalCommand + || isMachineReadableOutput; var showBanner = args.Any(a => a == CommonOptionNames.Banner); var sentinel = serviceProvider.GetRequiredService(); @@ -680,14 +692,43 @@ internal static async Task DisplayFirstTimeUseNoticeIfNeededAsync(IServiceProvid } // Don't persist the sentinel for informational commands (--version, --help, etc.) - // so the first-run experience is shown on the next real command invocation. - if (!isInformationalCommand) + // or for machine-readable invocations (--format json, including the hidden + // `doctor --self --format json` peer probe). Otherwise an automation invocation + // or peer probe — which deliberately suppressed the user-facing notice — would + // silently consume the first-run slot, and the next interactive invocation by + // the same user would never see the telemetry notice. + if (!isInformationalCommand && !isMachineReadableOutput) { sentinel.CreateIfNotExists(); } } } + // Machine-readable output flags should never have welcome/telemetry text + // interleaved with the structured payload. Today the only such flag is + // `--format json` (consumed by `aspire doctor`); extend this scan if new + // options are added. + private static bool HasMachineReadableOutputFormat(string[] args) + { + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (arg.Equals("--format=json", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (arg.Equals("--format", StringComparison.OrdinalIgnoreCase) + && i + 1 < args.Length + && args[i + 1].Equals("json", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + private static IAnsiConsole BuildAnsiConsole(IServiceProvider serviceProvider, TextWriter writer) { var configuration = serviceProvider.GetRequiredService(); diff --git a/src/Aspire.Cli/Resources/DoctorCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/DoctorCommandStrings.Designer.cs index e81ce3188df..6da919dceb0 100644 --- a/src/Aspire.Cli/Resources/DoctorCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/DoctorCommandStrings.Designer.cs @@ -203,6 +203,15 @@ public static string CliVersionUpdateCheckFailedMessage { } } + /// + /// Looks up a localized string similar to (channel: {0}). + /// + public static string ChannelSuffixFormat { + get { + return ResourceManager.GetString("ChannelSuffixFormat", resourceCulture); + } + } + /// /// Looks up a localized string similar to AppHost version {0} ({1}). /// @@ -391,5 +400,131 @@ public static string DevCertsTrustLabelPartial { return ResourceManager.GetString("DevCertsTrustLabelPartial", resourceCulture); } } + + /// + /// Looks up a localized string similar to Aspire CLI Installations. + /// + public static string HeaderInstallations { + get { + return ResourceManager.GetString("HeaderInstallations", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Path. + /// + public static string ColumnPath { + get { + return ResourceManager.GetString("ColumnPath", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Version. + /// + public static string ColumnVersion { + get { + return ResourceManager.GetString("ColumnVersion", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Channel. + /// + public static string ColumnChannel { + get { + return ResourceManager.GetString("ColumnChannel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Route. + /// + public static string ColumnRoute { + get { + return ResourceManager.GetString("ColumnRoute", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to PATH status. + /// + public static string ColumnPathStatus { + get { + return ResourceManager.GetString("ColumnPathStatus", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to (current). + /// + public static string ValueCurrentMarker { + get { + return ResourceManager.GetString("ValueCurrentMarker", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to (unknown). + /// + public static string ValueUnknown { + get { + return ResourceManager.GetString("ValueUnknown", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to (not probed). + /// + public static string ValueNotProbed { + get { + return ResourceManager.GetString("ValueNotProbed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to (probe failed). + /// + public static string ValueProbeFailed { + get { + return ResourceManager.GetString("ValueProbeFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to active. + /// + public static string ValuePathActive { + get { + return ResourceManager.GetString("ValuePathActive", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to shadowed. + /// + public static string ValuePathShadowed { + get { + return ResourceManager.GetString("ValuePathShadowed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to not on PATH. + /// + public static string ValuePathNotOnPath { + get { + return ResourceManager.GetString("ValuePathNotOnPath", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Install discovery failed. See the Aspire CLI logs for details.. + /// + public static string InstallationDiscoveryFailedReason { + get { + return ResourceManager.GetString("InstallationDiscoveryFailedReason", resourceCulture); + } + } } } diff --git a/src/Aspire.Cli/Resources/DoctorCommandStrings.resx b/src/Aspire.Cli/Resources/DoctorCommandStrings.resx index 5e9e89d0268..815d879d300 100644 --- a/src/Aspire.Cli/Resources/DoctorCommandStrings.resx +++ b/src/Aspire.Cli/Resources/DoctorCommandStrings.resx @@ -110,6 +110,10 @@ Could not check for Aspire CLI updates + + (channel: {0}) + {0} is the Aspire channel name (e.g., "stable", "staging", "daily", "local", "pr-1234"). Appended to CLI / AppHost version messages. + AppHost version {0} ({1}) {0} is the AppHost Aspire.Hosting version. {1} is the AppHost project path. @@ -177,4 +181,53 @@ [partial] + + Aspire CLI Installations + + + Path + + + Version + + + Channel + + + Route + + + PATH status + + + (current) + Suffix appended to the running CLI's path so the user can identify which install is themselves. + + + (unknown) + Placeholder shown in the table when a field could not be determined for a discovered install. + + + (not probed) + Placeholder shown in the table when an install was found but not asked to self-describe (e.g., trust gate). + + + (probe failed) + Placeholder shown in the table when an install was probed but did not return usable data. + + + active + Shown when an install is the first Aspire CLI resolved from PATH. + + + shadowed + Shown when an install is on PATH but an earlier Aspire CLI entry shadows it. + + + not on PATH + Shown when an install was not discovered from PATH. + + + Install discovery failed. See the Aspire CLI logs for details. + diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.cs.xlf index 72dd36b5600..7ace9ee9b0e 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.cs.xlf @@ -27,6 +27,11 @@ Aspire + + (channel: {0}) + (channel: {0}) + {0} is the Aspire channel name (e.g., "stable", "staging", "daily", "local", "pr-1234"). Appended to CLI / AppHost version messages. + Checking Aspire environment... Kontroluje se prostředí Aspire... @@ -52,6 +57,31 @@ Could not check for Aspire CLI updates + + Channel + Channel + + + + Path + Path + + + + PATH status + PATH status + + + + Route + Route + + + + Version + Version + + Container Runtime Modul runtime kontejneru @@ -167,6 +197,16 @@ Kontrola prostředí Aspire + + Aspire CLI Installations + Aspire CLI Installations + + + + Install discovery failed. See the Aspire CLI logs for details. + Install discovery failed. See the Aspire CLI logs for details. + + Output format (Table or Json) Výstup výsledků ve formátu JSON pro integraci nástrojů @@ -182,6 +222,41 @@ Shrnutí – úspěšné: {0}, upozornění: {1}, selhalo: {2} + + (current) + (current) + Suffix appended to the running CLI's path so the user can identify which install is themselves. + + + (not probed) + (not probed) + Placeholder shown in the table when an install was found but not asked to self-describe (e.g., trust gate). + + + active + active + Shown when an install is the first Aspire CLI resolved from PATH. + + + not on PATH + not on PATH + Shown when an install was not discovered from PATH. + + + shadowed + shadowed + Shown when an install is on PATH but an earlier Aspire CLI entry shadows it. + + + (probe failed) + (probe failed) + Placeholder shown in the table when an install was probed but did not return usable data. + + + (unknown) + (unknown) + Placeholder shown in the table when a field could not be determined for a discovered install. + unknown unknown diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.de.xlf index 18a2f301e0c..b1ee92460c2 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.de.xlf @@ -27,6 +27,11 @@ Aspire + + (channel: {0}) + (channel: {0}) + {0} is the Aspire channel name (e.g., "stable", "staging", "daily", "local", "pr-1234"). Appended to CLI / AppHost version messages. + Checking Aspire environment... Aspire-Umgebung wird überprüft … @@ -52,6 +57,31 @@ Could not check for Aspire CLI updates + + Channel + Channel + + + + Path + Path + + + + PATH status + PATH status + + + + Route + Route + + + + Version + Version + + Container Runtime Containerruntime @@ -167,6 +197,16 @@ Überprüfung der Aspire-Umgebung + + Aspire CLI Installations + Aspire CLI Installations + + + + Install discovery failed. See the Aspire CLI logs for details. + Install discovery failed. See the Aspire CLI logs for details. + + Output format (Table or Json) Ergebnisse als JSON für die Toolintegration ausgeben @@ -182,6 +222,41 @@ Zusammenfassung: {0} erfolgreich, {1} Warnungen, {2} fehlgeschlagen + + (current) + (current) + Suffix appended to the running CLI's path so the user can identify which install is themselves. + + + (not probed) + (not probed) + Placeholder shown in the table when an install was found but not asked to self-describe (e.g., trust gate). + + + active + active + Shown when an install is the first Aspire CLI resolved from PATH. + + + not on PATH + not on PATH + Shown when an install was not discovered from PATH. + + + shadowed + shadowed + Shown when an install is on PATH but an earlier Aspire CLI entry shadows it. + + + (probe failed) + (probe failed) + Placeholder shown in the table when an install was probed but did not return usable data. + + + (unknown) + (unknown) + Placeholder shown in the table when a field could not be determined for a discovered install. + unknown unknown diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.es.xlf index c030e54b1dd..c460c528f4d 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.es.xlf @@ -27,6 +27,11 @@ Aspire + + (channel: {0}) + (channel: {0}) + {0} is the Aspire channel name (e.g., "stable", "staging", "daily", "local", "pr-1234"). Appended to CLI / AppHost version messages. + Checking Aspire environment... Comprobando el entorno de Aspire... @@ -52,6 +57,31 @@ Could not check for Aspire CLI updates + + Channel + Channel + + + + Path + Path + + + + PATH status + PATH status + + + + Route + Route + + + + Version + Version + + Container Runtime Tiempo de ejecución del contenedor @@ -167,6 +197,16 @@ Comprobación del entorno de Aspire + + Aspire CLI Installations + Aspire CLI Installations + + + + Install discovery failed. See the Aspire CLI logs for details. + Install discovery failed. See the Aspire CLI logs for details. + + Output format (Table or Json) Resultados de salida como JSON para la integración de herramientas @@ -182,6 +222,41 @@ Resumen: {0} correctos, {1} advertencias, {2} fallos + + (current) + (current) + Suffix appended to the running CLI's path so the user can identify which install is themselves. + + + (not probed) + (not probed) + Placeholder shown in the table when an install was found but not asked to self-describe (e.g., trust gate). + + + active + active + Shown when an install is the first Aspire CLI resolved from PATH. + + + not on PATH + not on PATH + Shown when an install was not discovered from PATH. + + + shadowed + shadowed + Shown when an install is on PATH but an earlier Aspire CLI entry shadows it. + + + (probe failed) + (probe failed) + Placeholder shown in the table when an install was probed but did not return usable data. + + + (unknown) + (unknown) + Placeholder shown in the table when a field could not be determined for a discovered install. + unknown unknown diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.fr.xlf index be7c164734e..da57cb33781 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.fr.xlf @@ -27,6 +27,11 @@ Aspire + + (channel: {0}) + (channel: {0}) + {0} is the Aspire channel name (e.g., "stable", "staging", "daily", "local", "pr-1234"). Appended to CLI / AppHost version messages. + Checking Aspire environment... Vérification de l’environnement Aspire... @@ -52,6 +57,31 @@ Could not check for Aspire CLI updates + + Channel + Channel + + + + Path + Path + + + + PATH status + PATH status + + + + Route + Route + + + + Version + Version + + Container Runtime Runtime de conteneur @@ -167,6 +197,16 @@ Vérification de l’environnement Aspire + + Aspire CLI Installations + Aspire CLI Installations + + + + Install discovery failed. See the Aspire CLI logs for details. + Install discovery failed. See the Aspire CLI logs for details. + + Output format (Table or Json) Afficher les résultats au format JSON pour l’intégration des outils @@ -182,6 +222,41 @@ Résumé : {0} réussis, {1} avertissements, {2} échoués + + (current) + (current) + Suffix appended to the running CLI's path so the user can identify which install is themselves. + + + (not probed) + (not probed) + Placeholder shown in the table when an install was found but not asked to self-describe (e.g., trust gate). + + + active + active + Shown when an install is the first Aspire CLI resolved from PATH. + + + not on PATH + not on PATH + Shown when an install was not discovered from PATH. + + + shadowed + shadowed + Shown when an install is on PATH but an earlier Aspire CLI entry shadows it. + + + (probe failed) + (probe failed) + Placeholder shown in the table when an install was probed but did not return usable data. + + + (unknown) + (unknown) + Placeholder shown in the table when a field could not be determined for a discovered install. + unknown unknown diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.it.xlf index f5f955c2dd1..84288f06ea6 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.it.xlf @@ -27,6 +27,11 @@ Aspire + + (channel: {0}) + (channel: {0}) + {0} is the Aspire channel name (e.g., "stable", "staging", "daily", "local", "pr-1234"). Appended to CLI / AppHost version messages. + Checking Aspire environment... Verifica dell'ambiente Aspire in corso... @@ -52,6 +57,31 @@ Could not check for Aspire CLI updates + + Channel + Channel + + + + Path + Path + + + + PATH status + PATH status + + + + Route + Route + + + + Version + Version + + Container Runtime Runtime del contenitore @@ -167,6 +197,16 @@ Verifica ambiente Aspire + + Aspire CLI Installations + Aspire CLI Installations + + + + Install discovery failed. See the Aspire CLI logs for details. + Install discovery failed. See the Aspire CLI logs for details. + + Output format (Table or Json) Esporta i risultati in formato JSON per l'integrazione con gli strumenti @@ -182,6 +222,41 @@ Riepilogo: {0} superati, {1} avvisi, {2} non superati + + (current) + (current) + Suffix appended to the running CLI's path so the user can identify which install is themselves. + + + (not probed) + (not probed) + Placeholder shown in the table when an install was found but not asked to self-describe (e.g., trust gate). + + + active + active + Shown when an install is the first Aspire CLI resolved from PATH. + + + not on PATH + not on PATH + Shown when an install was not discovered from PATH. + + + shadowed + shadowed + Shown when an install is on PATH but an earlier Aspire CLI entry shadows it. + + + (probe failed) + (probe failed) + Placeholder shown in the table when an install was probed but did not return usable data. + + + (unknown) + (unknown) + Placeholder shown in the table when a field could not be determined for a discovered install. + unknown unknown diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ja.xlf index 410fa8468c5..86467014737 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ja.xlf @@ -27,6 +27,11 @@ Aspire + + (channel: {0}) + (channel: {0}) + {0} is the Aspire channel name (e.g., "stable", "staging", "daily", "local", "pr-1234"). Appended to CLI / AppHost version messages. + Checking Aspire environment... Aspire 環境を確認しています... @@ -52,6 +57,31 @@ Could not check for Aspire CLI updates + + Channel + Channel + + + + Path + Path + + + + PATH status + PATH status + + + + Route + Route + + + + Version + Version + + Container Runtime コンテナー ランタイム @@ -167,6 +197,16 @@ Aspire 環境確認 + + Aspire CLI Installations + Aspire CLI Installations + + + + Install discovery failed. See the Aspire CLI logs for details. + Install discovery failed. See the Aspire CLI logs for details. + + Output format (Table or Json) ツール統合の結果を JSON として出力します @@ -182,6 +222,41 @@ 概要: {0} 成功、{1} 警告、{2} 失敗 + + (current) + (current) + Suffix appended to the running CLI's path so the user can identify which install is themselves. + + + (not probed) + (not probed) + Placeholder shown in the table when an install was found but not asked to self-describe (e.g., trust gate). + + + active + active + Shown when an install is the first Aspire CLI resolved from PATH. + + + not on PATH + not on PATH + Shown when an install was not discovered from PATH. + + + shadowed + shadowed + Shown when an install is on PATH but an earlier Aspire CLI entry shadows it. + + + (probe failed) + (probe failed) + Placeholder shown in the table when an install was probed but did not return usable data. + + + (unknown) + (unknown) + Placeholder shown in the table when a field could not be determined for a discovered install. + unknown unknown diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ko.xlf index 41f74bfe3c9..374618a94c3 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ko.xlf @@ -27,6 +27,11 @@ Aspire + + (channel: {0}) + (channel: {0}) + {0} is the Aspire channel name (e.g., "stable", "staging", "daily", "local", "pr-1234"). Appended to CLI / AppHost version messages. + Checking Aspire environment... Aspire 환경을 확인하는 중... @@ -52,6 +57,31 @@ Could not check for Aspire CLI updates + + Channel + Channel + + + + Path + Path + + + + PATH status + PATH status + + + + Route + Route + + + + Version + Version + + Container Runtime 컨테이너 런타임 @@ -167,6 +197,16 @@ Aspire 환경 검사 + + Aspire CLI Installations + Aspire CLI Installations + + + + Install discovery failed. See the Aspire CLI logs for details. + Install discovery failed. See the Aspire CLI logs for details. + + Output format (Table or Json) 도구 통합을 위해 결과를 JSON으로 출력 @@ -182,6 +222,41 @@ 요약: {0}개 통과, {1}개 경고, {2}개 실패 + + (current) + (current) + Suffix appended to the running CLI's path so the user can identify which install is themselves. + + + (not probed) + (not probed) + Placeholder shown in the table when an install was found but not asked to self-describe (e.g., trust gate). + + + active + active + Shown when an install is the first Aspire CLI resolved from PATH. + + + not on PATH + not on PATH + Shown when an install was not discovered from PATH. + + + shadowed + shadowed + Shown when an install is on PATH but an earlier Aspire CLI entry shadows it. + + + (probe failed) + (probe failed) + Placeholder shown in the table when an install was probed but did not return usable data. + + + (unknown) + (unknown) + Placeholder shown in the table when a field could not be determined for a discovered install. + unknown unknown diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pl.xlf index 8228ef8e19a..c6059837b7a 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pl.xlf @@ -27,6 +27,11 @@ Aspire + + (channel: {0}) + (channel: {0}) + {0} is the Aspire channel name (e.g., "stable", "staging", "daily", "local", "pr-1234"). Appended to CLI / AppHost version messages. + Checking Aspire environment... Sprawdzanie środowiska Aspire... @@ -52,6 +57,31 @@ Could not check for Aspire CLI updates + + Channel + Channel + + + + Path + Path + + + + PATH status + PATH status + + + + Route + Route + + + + Version + Version + + Container Runtime Środowisko uruchomieniowe kontenera @@ -167,6 +197,16 @@ Sprawdzanie środowiska Aspire + + Aspire CLI Installations + Aspire CLI Installations + + + + Install discovery failed. See the Aspire CLI logs for details. + Install discovery failed. See the Aspire CLI logs for details. + + Output format (Table or Json) Wyświetl wyniki jako JSON na potrzeby integracji z narzędziami @@ -182,6 +222,41 @@ Podsumowanie: zaliczone {0}, ostrzeżenia {1}, niezaliczone {2} + + (current) + (current) + Suffix appended to the running CLI's path so the user can identify which install is themselves. + + + (not probed) + (not probed) + Placeholder shown in the table when an install was found but not asked to self-describe (e.g., trust gate). + + + active + active + Shown when an install is the first Aspire CLI resolved from PATH. + + + not on PATH + not on PATH + Shown when an install was not discovered from PATH. + + + shadowed + shadowed + Shown when an install is on PATH but an earlier Aspire CLI entry shadows it. + + + (probe failed) + (probe failed) + Placeholder shown in the table when an install was probed but did not return usable data. + + + (unknown) + (unknown) + Placeholder shown in the table when a field could not be determined for a discovered install. + unknown unknown diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pt-BR.xlf index 564238c6288..0fbc55e2649 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.pt-BR.xlf @@ -27,6 +27,11 @@ Aspire + + (channel: {0}) + (channel: {0}) + {0} is the Aspire channel name (e.g., "stable", "staging", "daily", "local", "pr-1234"). Appended to CLI / AppHost version messages. + Checking Aspire environment... Verificando o ambiente do Aspire... @@ -52,6 +57,31 @@ Could not check for Aspire CLI updates + + Channel + Channel + + + + Path + Path + + + + PATH status + PATH status + + + + Route + Route + + + + Version + Version + + Container Runtime Runtime do Contêiner @@ -167,6 +197,16 @@ Verificação de Ambiente do Aspire + + Aspire CLI Installations + Aspire CLI Installations + + + + Install discovery failed. See the Aspire CLI logs for details. + Install discovery failed. See the Aspire CLI logs for details. + + Output format (Table or Json) Resultados de saída como JSON para integração de ferramentas @@ -182,6 +222,41 @@ Resumo: {0} aprovado, {1} avisos, {2} falha + + (current) + (current) + Suffix appended to the running CLI's path so the user can identify which install is themselves. + + + (not probed) + (not probed) + Placeholder shown in the table when an install was found but not asked to self-describe (e.g., trust gate). + + + active + active + Shown when an install is the first Aspire CLI resolved from PATH. + + + not on PATH + not on PATH + Shown when an install was not discovered from PATH. + + + shadowed + shadowed + Shown when an install is on PATH but an earlier Aspire CLI entry shadows it. + + + (probe failed) + (probe failed) + Placeholder shown in the table when an install was probed but did not return usable data. + + + (unknown) + (unknown) + Placeholder shown in the table when a field could not be determined for a discovered install. + unknown unknown diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ru.xlf index a0a8aabf6a6..c7c56da3860 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.ru.xlf @@ -27,6 +27,11 @@ Aspire + + (channel: {0}) + (channel: {0}) + {0} is the Aspire channel name (e.g., "stable", "staging", "daily", "local", "pr-1234"). Appended to CLI / AppHost version messages. + Checking Aspire environment... Проверка среды Aspire... @@ -52,6 +57,31 @@ Could not check for Aspire CLI updates + + Channel + Channel + + + + Path + Path + + + + PATH status + PATH status + + + + Route + Route + + + + Version + Version + + Container Runtime Среда выполнения контейнера @@ -167,6 +197,16 @@ Проверка среды Aspire + + Aspire CLI Installations + Aspire CLI Installations + + + + Install discovery failed. See the Aspire CLI logs for details. + Install discovery failed. See the Aspire CLI logs for details. + + Output format (Table or Json) Вывод результатов в формате JSON для интеграции с инструментами @@ -182,6 +222,41 @@ Сводка: {0} успешно, предупреждений: {1}, {2} с ошибками + + (current) + (current) + Suffix appended to the running CLI's path so the user can identify which install is themselves. + + + (not probed) + (not probed) + Placeholder shown in the table when an install was found but not asked to self-describe (e.g., trust gate). + + + active + active + Shown when an install is the first Aspire CLI resolved from PATH. + + + not on PATH + not on PATH + Shown when an install was not discovered from PATH. + + + shadowed + shadowed + Shown when an install is on PATH but an earlier Aspire CLI entry shadows it. + + + (probe failed) + (probe failed) + Placeholder shown in the table when an install was probed but did not return usable data. + + + (unknown) + (unknown) + Placeholder shown in the table when a field could not be determined for a discovered install. + unknown unknown diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.tr.xlf index c01f42197fa..684b71ccfb7 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.tr.xlf @@ -27,6 +27,11 @@ Aspire + + (channel: {0}) + (channel: {0}) + {0} is the Aspire channel name (e.g., "stable", "staging", "daily", "local", "pr-1234"). Appended to CLI / AppHost version messages. + Checking Aspire environment... Aspire ortamı kontrol ediliyor... @@ -52,6 +57,31 @@ Could not check for Aspire CLI updates + + Channel + Channel + + + + Path + Path + + + + PATH status + PATH status + + + + Route + Route + + + + Version + Version + + Container Runtime Kapsayıcı Çalışma Zamanı @@ -167,6 +197,16 @@ Aspire Ortamı Kontrolü + + Aspire CLI Installations + Aspire CLI Installations + + + + Install discovery failed. See the Aspire CLI logs for details. + Install discovery failed. See the Aspire CLI logs for details. + + Output format (Table or Json) Araç tümleştirmesi için sonuçları JSON olarak çıktı @@ -182,6 +222,41 @@ Özet: {0} başarılı, {1} uyarı, {2} başarısız + + (current) + (current) + Suffix appended to the running CLI's path so the user can identify which install is themselves. + + + (not probed) + (not probed) + Placeholder shown in the table when an install was found but not asked to self-describe (e.g., trust gate). + + + active + active + Shown when an install is the first Aspire CLI resolved from PATH. + + + not on PATH + not on PATH + Shown when an install was not discovered from PATH. + + + shadowed + shadowed + Shown when an install is on PATH but an earlier Aspire CLI entry shadows it. + + + (probe failed) + (probe failed) + Placeholder shown in the table when an install was probed but did not return usable data. + + + (unknown) + (unknown) + Placeholder shown in the table when a field could not be determined for a discovered install. + unknown unknown diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hans.xlf index ad14f8e86fb..fc2a1cf2c0b 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hans.xlf @@ -27,6 +27,11 @@ Aspire + + (channel: {0}) + (channel: {0}) + {0} is the Aspire channel name (e.g., "stable", "staging", "daily", "local", "pr-1234"). Appended to CLI / AppHost version messages. + Checking Aspire environment... 正在检查 Aspire 环境... @@ -52,6 +57,31 @@ Could not check for Aspire CLI updates + + Channel + Channel + + + + Path + Path + + + + PATH status + PATH status + + + + Route + Route + + + + Version + Version + + Container Runtime 容器运行时 @@ -167,6 +197,16 @@ Aspire 环境检查 + + Aspire CLI Installations + Aspire CLI Installations + + + + Install discovery failed. See the Aspire CLI logs for details. + Install discovery failed. See the Aspire CLI logs for details. + + Output format (Table or Json) 将结果输出为 JSON g格式以便工具集成 @@ -182,6 +222,41 @@ 摘要: {0} 通过,{1} 警告, {2} 失败 + + (current) + (current) + Suffix appended to the running CLI's path so the user can identify which install is themselves. + + + (not probed) + (not probed) + Placeholder shown in the table when an install was found but not asked to self-describe (e.g., trust gate). + + + active + active + Shown when an install is the first Aspire CLI resolved from PATH. + + + not on PATH + not on PATH + Shown when an install was not discovered from PATH. + + + shadowed + shadowed + Shown when an install is on PATH but an earlier Aspire CLI entry shadows it. + + + (probe failed) + (probe failed) + Placeholder shown in the table when an install was probed but did not return usable data. + + + (unknown) + (unknown) + Placeholder shown in the table when a field could not be determined for a discovered install. + unknown unknown diff --git a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hant.xlf index 1c985201dca..7b50606a224 100644 --- a/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/DoctorCommandStrings.zh-Hant.xlf @@ -27,6 +27,11 @@ Aspire + + (channel: {0}) + (channel: {0}) + {0} is the Aspire channel name (e.g., "stable", "staging", "daily", "local", "pr-1234"). Appended to CLI / AppHost version messages. + Checking Aspire environment... 正在檢查 Aspire 環境... @@ -52,6 +57,31 @@ Could not check for Aspire CLI updates + + Channel + Channel + + + + Path + Path + + + + PATH status + PATH status + + + + Route + Route + + + + Version + Version + + Container Runtime 容器執行階段 @@ -167,6 +197,16 @@ Aspire 環境檢查 + + Aspire CLI Installations + Aspire CLI Installations + + + + Install discovery failed. See the Aspire CLI logs for details. + Install discovery failed. See the Aspire CLI logs for details. + + Output format (Table or Json) 輸出結果為 JSON 以用於工具整合 @@ -182,6 +222,41 @@ 摘要: {0} 個通過、{1} 個警告、{2} 個已失敗 + + (current) + (current) + Suffix appended to the running CLI's path so the user can identify which install is themselves. + + + (not probed) + (not probed) + Placeholder shown in the table when an install was found but not asked to self-describe (e.g., trust gate). + + + active + active + Shown when an install is the first Aspire CLI resolved from PATH. + + + not on PATH + not on PATH + Shown when an install was not discovered from PATH. + + + shadowed + shadowed + Shown when an install is on PATH but an earlier Aspire CLI entry shadows it. + + + (probe failed) + (probe failed) + Placeholder shown in the table when an install was probed but did not return usable data. + + + (unknown) + (unknown) + Placeholder shown in the table when a field could not be determined for a discovered install. + unknown unknown diff --git a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs index 1ad9c09420f..15cb84a2b06 100644 --- a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs +++ b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs @@ -66,7 +66,7 @@ private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, Can // or NewCommand's identity-match against a registered Explicit channel — see // `CliTemplateFactory.EmptyTemplate.cs` for how `ScaffoldContext.Channel` is sourced). // Do NOT fall back to `CliExecutionContext.IdentityChannel`: an identity that isn't a - // registered channel (e.g. `daily` on a CLI without the staging feature flag, or `pr-` + // registered channel (e.g. `staging` on a CLI without the staging feature flag, or `pr-` // on a machine without the matching hive) would otherwise pin a channel name that no // PSM rule can satisfy. When unset, `PrebuiltAppHostServer` aggregates sources from // every registered channel so `aspire add` / `aspire restore` still find the right diff --git a/src/Aspire.Cli/Utils/CliPathHelper.cs b/src/Aspire.Cli/Utils/CliPathHelper.cs index 48aedcb8e52..36fd5ce104d 100644 --- a/src/Aspire.Cli/Utils/CliPathHelper.cs +++ b/src/Aspire.Cli/Utils/CliPathHelper.cs @@ -1,14 +1,183 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Cli.Acquisition; using Aspire.Hosting.Backchannel; +using Microsoft.Extensions.Logging; namespace Aspire.Cli.Utils; internal static class CliPathHelper { - internal static string GetAspireHomeDirectory() - => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspire"); + internal static string GetAspireHomeDirectory(string? processPath = null, ILogger? logger = null) + { + var effectiveProcessPath = processPath ?? Environment.ProcessPath; + + return TryGetAspireHomeDirectoryFromInstallRoute(effectiveProcessPath, logger) + ?? Path.Combine(GetUserProfileDirectory(), ".aspire"); + } + + internal static string? TryGetAspireHomeDirectoryFromInstallRoute(string? processPath, ILogger? logger = null) + { + if (string.IsNullOrEmpty(processPath)) + { + return null; + } + + var realBinaryPath = ResolveSymlinkOrOriginalPath(processPath, logger); + var binaryDir = Path.GetDirectoryName(realBinaryPath); + if (string.IsNullOrEmpty(binaryDir)) + { + return null; + } + + var sidecarPath = Path.Combine(binaryDir, InstallSidecarReader.SidecarFileName); + var source = InstallSidecarReader.ReadSourceField(sidecarPath); + + return source switch + { + InstallSourceExtensions.ScriptWire + or InstallSourceExtensions.LocalHiveWire => Path.GetDirectoryName(binaryDir) ?? binaryDir, + InstallSourceExtensions.PrWire => TryGetPrInstallPrefix(binaryDir), + _ => null + }; + } + + private static string? TryGetPrInstallPrefix(string binaryDir) + { + var prDir = Path.GetDirectoryName(binaryDir); + if (string.IsNullOrEmpty(prDir)) + { + return null; + } + + var dogfoodDir = Path.GetDirectoryName(prDir); + if (string.IsNullOrEmpty(dogfoodDir) || + !string.Equals(Path.GetFileName(dogfoodDir), InstallationDiscoveryLayout.DogfoodDirectoryName, StringComparison.Ordinal)) + { + return null; + } + + return Path.GetDirectoryName(dogfoodDir); + } + + internal static string GetUserProfileDirectory() + => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + internal static string ResolveSymlinkOrOriginalPath(string path, ILogger? logger = null) + { + if (string.IsNullOrEmpty(path)) + { + return path; + } + + return MaybeStripMacOSFirmlinkPrefix(TryResolveSymlinkTarget(path, logger, "using the raw path") ?? path); + } + + internal static string? ResolveSymlinkToFullPath(string? path, ILogger? logger = null) + { + if (string.IsNullOrEmpty(path)) + { + return null; + } + + var resolved = TryResolveSymlinkTarget(path, logger, "trying the normalized path"); + if (resolved is not null) + { + return MaybeStripMacOSFirmlinkPrefix(resolved); + } + + try + { + return MaybeStripMacOSFirmlinkPrefix(Path.GetFullPath(path)); + } + catch (Exception ex) when (IsPathResolutionException(ex)) + { + logger?.LogDebug(ex, "Could not normalize path {Path}; skipping it.", path); + return null; + } + } + + /// + /// Returns with a leading macOS firmlink prefix + /// (/private/var, /private/tmp, /private/etc) + /// rewritten back to the user-facing logical form (/var, + /// /tmp, /etc). Returns the input unchanged when no + /// firmlink prefix matches. + /// + /// + /// macOS Catalina (10.15) and later use APFS firmlinks to transparently + /// redirect /var/private/var, /tmp → + /// /private/tmp, and /etc/private/etc at the + /// filesystem layer. Firmlinks are not symlinks — lstat reports + /// the directory directly and + /// returns . Meanwhile, + /// and libc realpath(3) return the /private/* form, while + /// , $PATH walks via + /// , NuGet's packageSourceMapping + /// lookup, and user-typed paths use the un-prefixed form. + /// + /// This asymmetry breaks every cross-surface path-string comparison + /// when the CLI is installed under a firmlinked prefix + /// (e.g. /var/folders/... from mktemp): the same + /// physical binary shows up as two distinct strings, which breaks the + /// dedup in and causes + /// NuGet to silently drop <packageSource> mappings whose + /// key is in the /private/* form (NuGet canonicalizes path-named + /// sources by stripping /private/ when registering the source, + /// but the packageSourceMapping key is matched against the + /// stored name as-written — so any mapping authored with the + /// /private/* key is unreachable and Aspire* patterns + /// fall through to the catch-all source). + /// + /// Normalizing all canonical paths back to the un-prefixed form keeps + /// every comparison site consistent. Do not "fix" the resolve helpers + /// above to return the realpath / /private/* form without also + /// updating every downstream consumer (dedup, nuget.config writer, + /// path-status check): the un-prefixed form is the one that crosses + /// tool boundaries correctly. + /// + /// The match is because + /// case-sensitive APFS volumes distinguish /Private/Var/... + /// (a real user-created path) from /private/var/... (the + /// firmlink). The match is also boundary-aware: /private/varlog + /// is preserved because varlog is not the var path + /// component followed by a separator. + /// + /// See https://support.apple.com/guide/security/firmlinks-secf3a9d2014/web + /// for Apple's firmlink reference. + /// + internal static string StripMacOSFirmlinkPrefix(string path) + { + if (string.IsNullOrEmpty(path) || !path.StartsWith("/private/", StringComparison.Ordinal)) + { + return path; + } + + foreach (var firmlink in s_macosFirmlinkPrefixes) + { + if (path.Length >= firmlink.Length && + path.StartsWith(firmlink, StringComparison.Ordinal) && + (path.Length == firmlink.Length || path[firmlink.Length] == '/')) + { + return path[PrivateSegmentLength..]; + } + } + + return path; + } + + // "/private".Length — the byte we trim off when rewriting a firmlink path. + private const int PrivateSegmentLength = 8; + + // Apple-documented user-visible firmlinks that take a `/private/` form + // on macOS Catalina and later. Other macOS firmlinks (under + // /System/Volumes/Data) do not surface as user paths and are not relevant + // to install-path comparisons. + private static readonly string[] s_macosFirmlinkPrefixes = ["/private/var", "/private/tmp", "/private/etc"]; + + private static string MaybeStripMacOSFirmlinkPrefix(string path) + => OperatingSystem.IsMacOS() ? StripMacOSFirmlinkPrefix(path) : path; /// /// Creates a randomized CLI-managed socket path. @@ -42,4 +211,26 @@ private static string GetCliRuntimeDirectory() private static string GetCliSocketDirectory() => Path.Combine(GetCliRuntimeDirectory(), "sockets"); + + private static string? TryResolveSymlinkTarget(string path, ILogger? logger, string fallbackDescription) + { + try + { + var resolved = File.ResolveLinkTarget(path, returnFinalTarget: true); + return resolved?.FullName; + } + catch (Exception ex) when (IsPathResolutionException(ex)) + { + logger?.LogDebug(ex, "Could not resolve symlink target for {Path}; {FallbackDescription}.", path, fallbackDescription); + return null; + } + } + + private static bool IsPathResolutionException(Exception ex) + => ex is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException + or PathTooLongException + or System.Security.SecurityException; } diff --git a/src/Aspire.Cli/Utils/CliUpdateNotifier.cs b/src/Aspire.Cli/Utils/CliUpdateNotifier.cs index c11b4cb2484..3a5c0be2c5c 100644 --- a/src/Aspire.Cli/Utils/CliUpdateNotifier.cs +++ b/src/Aspire.Cli/Utils/CliUpdateNotifier.cs @@ -17,7 +17,28 @@ internal interface ICliUpdateNotifier bool IsUpdateAvailable(); } -internal sealed record CliVersionStatus(string? CurrentVersion, string? LatestVersion, string? UpdateCommand, string? UpdateCheckError = null); +internal sealed record CliVersionStatus( + string? CurrentVersion, + string? LatestVersion, + string? UpdateCommand, + string? UpdateCheckError = null, + string? LatestVersionChannel = null); + +/// +/// Coarse-grained labels for the channel a recommended CLI update is being +/// pulled from. picks +/// between newestStable and newestPrerelease when computing +/// the recommendation, so labelling by stable vs prerelease is faithful to +/// the underlying decision rule. We deliberately don't try to distinguish +/// staging from daily here — the version string alone can't reliably do so, +/// and the user-visible doctor message only needs to convey "where to +/// look", not the specific feed identity. +/// +internal static class PackageUpdateRecommendationChannels +{ + public const string Stable = "stable"; + public const string Prerelease = "prerelease"; +} internal class CliUpdateNotifier( ILogger logger, @@ -96,7 +117,16 @@ private CliVersionStatus GetCachedVersionStatus(string? updateCheckError = null) var newerVersion = PackageUpdateHelpers.GetNewerVersion(logger, currentVersion, _availablePackages); var updateCommand = newerVersion is null ? null : DotNetToolDetection.GetDotNetToolUpdateCommand() ?? "aspire update"; - return new CliVersionStatus(currentVersionString, newerVersion?.ToString(), updateCommand); + // Derive the lane the recommendation comes from so doctor can show + // 'Latest version is X (channel: stable)' vs '(channel: prerelease)'. + // GetNewerVersion picks between newestStable and newestPrerelease + // by exactly this rule, so re-classifying from the returned + // version's prerelease flag is faithful to the decision the + // package helper made. + var latestChannel = newerVersion is null + ? null + : (newerVersion.IsPrerelease ? PackageUpdateRecommendationChannels.Prerelease : PackageUpdateRecommendationChannels.Stable); + return new CliVersionStatus(currentVersionString, newerVersion?.ToString(), updateCommand, UpdateCheckError: null, LatestVersionChannel: latestChannel); } private async Task> GetCliPackagesAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken) diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/AspireVersionCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/AspireVersionCheck.cs index 882c0b025f8..72c1581ca66 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/AspireVersionCheck.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/AspireVersionCheck.cs @@ -3,6 +3,8 @@ using System.Globalization; using System.Text.Json.Nodes; +using Aspire.Cli.Acquisition; +using Aspire.Cli.Configuration; using Aspire.Cli.Projects; using Aspire.Cli.Resources; using Microsoft.Extensions.Logging; @@ -17,6 +19,7 @@ internal sealed class AspireVersionCheck( ICliUpdateNotifier updateNotifier, IProjectLocator projectLocator, IAppHostProjectFactory projectFactory, + IIdentityChannelReader identityChannelReader, CliExecutionContext executionContext, ILogger logger) : IEnvironmentCheck { @@ -64,6 +67,15 @@ await GetCliVersionCheckAsync(cancellationToken) private async Task GetCliVersionCheckAsync(CancellationToken cancellationToken) { + // Read the identity channel up front so it can be attached to every + // CLI-version result (pass / out-of-date / update-check-failed). + // Identity channel is best-effort: misconfigured dev builds may have + // missing metadata, and we don't want to fail doctor over a labelling + // gap. ReadChannel() throws InvalidOperationException in that case; + // logging at debug keeps the diagnostic available without surfacing + // a scary warning in the human-readable output. + var identityChannel = TryReadIdentityChannel(); + try { var status = await updateNotifier.GetVersionStatusAsync(executionContext.WorkingDirectory, cancellationToken); @@ -79,9 +91,9 @@ private async Task GetCliVersionCheckAsync(CancellationT Category = "aspire", Name = "cli-version", Status = EnvironmentCheckStatus.Warning, - Message = string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.CliVersionMessageFormat, currentVersion), + Message = FormatCliVersionMessage(currentVersion, identityChannel), Details = $"{DoctorCommandStrings.CliVersionUpdateCheckFailedMessage}: {updateCheckError}", - Metadata = BuildCliVersionMetadata(currentVersion, latestVersion: null, status.UpdateCommand, updateCheckError) + Metadata = BuildCliVersionMetadata(currentVersion, latestVersion: null, status.UpdateCommand, updateCheckError, identityChannel, latestVersionChannel: null) }; } @@ -92,9 +104,21 @@ private async Task GetCliVersionCheckAsync(CancellationT Category = "aspire", Name = "cli-version", Status = EnvironmentCheckStatus.Warning, - Message = string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.CliVersionOutOfDateMessageFormat, currentVersion, latestVersion), + // Both versions get their channel inline next to them so + // the message is unambiguous: + // "...version 13.4.0-dev (channel: local) is out of + // date. Latest version is X (channel: prerelease)" + // The current-version channel comes from the running + // CLI's baked AspireCliChannel; the latest-version + // channel comes from the update notifier's recommendation + // lane (stable vs prerelease). + Message = string.Format( + CultureInfo.CurrentCulture, + DoctorCommandStrings.CliVersionOutOfDateMessageFormat, + WithChannelSuffix(currentVersion, identityChannel), + WithChannelSuffix(latestVersion, status.LatestVersionChannel)), Fix = string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.CliVersionOutOfDateFixFormat, status.UpdateCommand ?? "aspire update"), - Metadata = BuildCliVersionMetadata(currentVersion, latestVersion, status.UpdateCommand, updateCheckError: null) + Metadata = BuildCliVersionMetadata(currentVersion, latestVersion, status.UpdateCommand, updateCheckError: null, identityChannel, status.LatestVersionChannel) }; } @@ -103,8 +127,8 @@ private async Task GetCliVersionCheckAsync(CancellationT Category = "aspire", Name = "cli-version", Status = EnvironmentCheckStatus.Pass, - Message = string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.CliVersionMessageFormat, currentVersion), - Metadata = BuildCliVersionMetadata(currentVersion, latestVersion: null, status.UpdateCommand, updateCheckError: null) + Message = FormatCliVersionMessage(currentVersion, identityChannel), + Metadata = BuildCliVersionMetadata(currentVersion, latestVersion: null, status.UpdateCommand, updateCheckError: null, identityChannel, latestVersionChannel: null) }; } catch (OperationCanceledException) @@ -121,11 +145,68 @@ private async Task GetCliVersionCheckAsync(CancellationT Name = "cli-version", Status = EnvironmentCheckStatus.Warning, Message = DoctorCommandStrings.CliVersionUpdateCheckFailedMessage, - Details = ex.Message + Details = ex.Message, + Metadata = BuildCliVersionMetadata(currentVersion: null, latestVersion: null, updateCommand: null, updateCheckError: ex.Message, identityChannel, latestVersionChannel: null) }; } } + private static string FormatCliVersionMessage(string currentVersion, string? identityChannel) + { + return string.Format( + CultureInfo.CurrentCulture, + DoctorCommandStrings.CliVersionMessageFormat, + WithChannelSuffix(currentVersion, identityChannel)); + } + + /// + /// Returns with the channel suffix appended + /// inline (e.g. "13.0.0 (channel: stable)") so the channel is + /// unambiguously attached to that specific version in any message + /// template that mentions multiple versions. + /// + private static string WithChannelSuffix(string version, string? channel) + { + if (string.IsNullOrEmpty(channel)) + { + return version; + } + + return version + string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.ChannelSuffixFormat, channel); + } + + /// + /// Appends the channel suffix to an arbitrary message. Used only for + /// message templates that mention exactly one version (so there's no + /// ambiguity about which version the channel qualifies). For templates + /// with multiple versions, use inline + /// on the relevant version slot instead. + /// + private static string AppendChannelSuffix(string message, string? channel) + { + if (string.IsNullOrEmpty(channel)) + { + return message; + } + + return message + string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.ChannelSuffixFormat, channel); + } + + private string? TryReadIdentityChannel() + { + try + { + return identityChannelReader.ReadChannel(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Identity channel is informational; a misconfigured dev build + // (no AspireCliChannel assembly metadata) must not break doctor. + logger.LogDebug(ex, "Could not read identity channel for doctor output."); + return null; + } + } + private async Task GetAppHostVersionCheckAsync(CancellationToken cancellationToken) { IReadOnlyList appHostFiles; @@ -168,9 +249,20 @@ private async Task GetCliVersionCheckAsync(CancellationT }; } + // Pinned channel is best-effort and informational: AppHost discovery already + // succeeded, so an unreadable / malformed aspire.config.json must not flip + // doctor into a failure state. A null pinnedChannel simply omits the field + // from the JSON metadata and the channel suffix from the human-readable + // message — same behavior as a project that has not pinned a channel. + // Hoisted above the loop because the source directory is loop-invariant; + // re-reading per-AppHost would do redundant I/O and duplicate log lines + // on a misconfigured file. + var pinnedChannel = TryReadPinnedChannel(executionContext.WorkingDirectory); + foreach (var appHostFile in appHostFiles) { var relativePath = GetRelativePath(appHostFile); + try { var (isAppHost, version) = await ResolveAppHostVersionAsync(appHostFile, cancellationToken); @@ -186,8 +278,10 @@ private async Task GetCliVersionCheckAsync(CancellationT Category = "apphost", Name = "apphost-version", Status = EnvironmentCheckStatus.Warning, - Message = string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.AppHostVersionUnknownMessageFormat, relativePath), - Metadata = BuildAppHostVersionMetadata(relativePath, version: null) + Message = AppendChannelSuffix( + string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.AppHostVersionUnknownMessageFormat, relativePath), + pinnedChannel), + Metadata = BuildAppHostVersionMetadata(relativePath, version: null, pinnedChannel) }; } @@ -196,8 +290,18 @@ private async Task GetCliVersionCheckAsync(CancellationT Category = "apphost", Name = "apphost-version", Status = EnvironmentCheckStatus.Pass, - Message = string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.AppHostVersionMessageFormat, version, relativePath), - Metadata = BuildAppHostVersionMetadata(relativePath, version) + // Channel goes inline next to the version, not at the + // end of the message: "AppHost version 13.0.0 (channel: stable) (path/to/AppHost.csproj)" + // rather than "AppHost version 13.0.0 (path/to/AppHost.csproj) (channel: stable)" + // — the format trails the version with the AppHost + // path, so a tail-appended channel would attach to the + // path instead. + Message = string.Format( + CultureInfo.CurrentCulture, + DoctorCommandStrings.AppHostVersionMessageFormat, + WithChannelSuffix(version, pinnedChannel), + relativePath), + Metadata = BuildAppHostVersionMetadata(relativePath, version, pinnedChannel) }; } catch (OperationCanceledException) @@ -215,7 +319,7 @@ private async Task GetCliVersionCheckAsync(CancellationT Status = EnvironmentCheckStatus.Warning, Message = DoctorCommandStrings.AppHostVersionCheckFailedMessage, Details = ex.Message, - Metadata = BuildAppHostVersionMetadata(relativePath, version: null) + Metadata = BuildAppHostVersionMetadata(relativePath, version: null, pinnedChannel) }; } } @@ -223,6 +327,35 @@ private async Task GetCliVersionCheckAsync(CancellationT return null; } + /// + /// Reads the pinned channel from aspire.config.json sitting in + /// (the CLI's current working directory — + /// the same anchor used by AppHost discovery). Returns + /// when the file is absent, malformed, or has no channel field. The + /// lookup is best effort and never throws — doctor uses this only to enrich the + /// AppHost-version line. + /// + private string? TryReadPinnedChannel(DirectoryInfo configDirectory) + { + var directory = configDirectory.FullName; + if (string.IsNullOrEmpty(directory)) + { + return null; + } + + try + { + var config = AspireConfigFile.Load(directory); + var channel = config?.Channel; + return string.IsNullOrWhiteSpace(channel) ? null : channel; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogDebug(ex, "Could not read pinned channel from aspire.config.json in {Directory}.", directory); + return null; + } + } + private async Task> ResolveAppHostFilesAsync(CancellationToken cancellationToken) { // AppHost version reporting is intentionally shallow: use an AppHost explicitly @@ -278,12 +411,14 @@ private string GetRelativePath(FileInfo file) return Path.GetRelativePath(executionContext.WorkingDirectory.FullName, file.FullName); } - private static JsonObject BuildCliVersionMetadata(string currentVersion, string? latestVersion, string? updateCommand, string? updateCheckError) + private static JsonObject BuildCliVersionMetadata(string? currentVersion, string? latestVersion, string? updateCommand, string? updateCheckError, string? identityChannel, string? latestVersionChannel) { - var metadata = new JsonObject + var metadata = new JsonObject(); + + if (!string.IsNullOrWhiteSpace(currentVersion)) { - ["currentVersion"] = currentVersion - }; + metadata["currentVersion"] = currentVersion; + } if (!string.IsNullOrWhiteSpace(latestVersion)) { @@ -300,10 +435,20 @@ private static JsonObject BuildCliVersionMetadata(string currentVersion, string? metadata["updateCheckError"] = updateCheckError; } + if (!string.IsNullOrWhiteSpace(identityChannel)) + { + metadata["identityChannel"] = identityChannel; + } + + if (!string.IsNullOrWhiteSpace(latestVersionChannel)) + { + metadata["latestVersionChannel"] = latestVersionChannel; + } + return metadata; } - private static JsonObject BuildAppHostVersionMetadata(string relativePath, string? version) + private static JsonObject BuildAppHostVersionMetadata(string relativePath, string? version, string? pinnedChannel) { var metadata = new JsonObject { @@ -315,6 +460,11 @@ private static JsonObject BuildAppHostVersionMetadata(string relativePath, strin metadata["version"] = version; } + if (!string.IsNullOrWhiteSpace(pinnedChannel)) + { + metadata["pinnedChannel"] = pinnedChannel; + } + return metadata; } } diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/EnvironmentCheckResult.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/EnvironmentCheckResult.cs index 89024e29c47..2d2c8b725fd 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/EnvironmentCheckResult.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/EnvironmentCheckResult.cs @@ -4,6 +4,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using Aspire.Cli.Acquisition; namespace Aspire.Cli.Utils.EnvironmentChecker; @@ -130,6 +131,12 @@ internal sealed class DoctorCheckResponse /// [JsonPropertyName("summary")] public required DoctorCheckSummary Summary { get; set; } + + /// + /// Gets or sets the discovered Aspire CLI installations. + /// + [JsonPropertyName("installations")] + public List? Installations { get; set; } } /// diff --git a/src/Aspire.Cli/Utils/ProcessCaptureRunner.cs b/src/Aspire.Cli/Utils/ProcessCaptureRunner.cs new file mode 100644 index 00000000000..e47c75762e4 --- /dev/null +++ b/src/Aspire.Cli/Utils/ProcessCaptureRunner.cs @@ -0,0 +1,264 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Utils; + +internal static class ProcessCaptureRunner +{ + // Maximum time we'll wait for a process to actually exit after TryKillProcessTree + // returns. Kill is best-effort: it can fail silently (perm denied, job-object + // reparenting on Windows), and in those cases an unbounded WaitForExitAsync + // would deadlock the caller indefinitely even though we've already decided to + // abandon the process. 2s is well above the <100ms typical post-kill exit + // latency but small enough not to noticeably stall the caller. + private static readonly TimeSpan s_postKillExitWaitBound = TimeSpan.FromSeconds(2); + private static readonly TimeSpan s_postKillCaptureWaitBound = TimeSpan.FromMilliseconds(250); + + public static async Task> RunAsync( + ProcessStartInfo startInfo, + TimeSpan timeout, + Func> captureAsync, + Func createEmptyCapture, + ILogger logger, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(startInfo); + ArgumentNullException.ThrowIfNull(captureAsync); + ArgumentNullException.ThrowIfNull(createEmptyCapture); + ArgumentNullException.ThrowIfNull(logger); + + Process process; + try + { + var started = Process.Start(startInfo); + if (started is null) + { + return new ProcessCaptureResult( + ExitCode: -1, + Capture: createEmptyCapture(), + FailureKind: ProcessCaptureFailureKind.StartFailed, + FailureMessage: "Process.Start returned null.", + Cancelled: false); + } + + process = started; + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException or IOException) + { + logger.LogDebug(ex, "Could not start process '{FileName}'.", startInfo.FileName); + return new ProcessCaptureResult( + ExitCode: -1, + Capture: createEmptyCapture(), + FailureKind: ProcessCaptureFailureKind.StartFailed, + FailureMessage: ex.Message, + Cancelled: false); + } + + // Once the process has been started we OWN it. The finally below + // guarantees we kill any still-running process and dispose the handle + // even if the surrounding code throws an unexpected exception (for + // example InvalidOperationException from WaitForExitAsync when the + // process handle becomes invalid mid-wait, or an IOException from the + // underlying wait primitive). Without the try/finally, those rare + // exception paths would propagate out leaving the peer alive and the + // Process object undisposed — violating the file-level contract that + // no spawned peer outlives the parent. + try + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(timeout); + + Task captureTask; + try + { + captureTask = captureAsync(process, timeoutCts.Token); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Could not start capturing output for process '{FileName}'.", startInfo.FileName); + TryKillProcessTree(process, logger); + await SwallowExitWaitAsync(process, s_postKillExitWaitBound, logger).ConfigureAwait(false); + return new ProcessCaptureResult( + ExitCode: -1, + Capture: createEmptyCapture(), + FailureKind: ProcessCaptureFailureKind.CaptureFailed, + FailureMessage: ex.Message, + Cancelled: false); + } + + var timedOut = false; + var cancelled = false; + try + { + await process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + timedOut = true; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + cancelled = true; + } + // Any other exception (e.g. InvalidOperationException from a torn-down + // handle, IOException from the wait primitive) is left to propagate so + // the outer finally still runs the kill + dispose path. + + if (timedOut || cancelled) + { + TryKillProcessTree(process, logger); + await SwallowExitWaitAsync(process, s_postKillExitWaitBound, logger).ConfigureAwait(false); + var interruptedCapture = await SwallowCaptureAsync(captureTask, createEmptyCapture, logger, s_postKillCaptureWaitBound).ConfigureAwait(false); + + // Drive the capture task to completion if the bounded wait gave up on it. + // The capture task observes timeoutCts.Token, which is already cancelled in + // the timeout branch but not in the user-cancellation branch when the + // outer cancellationToken cancels before timeoutCts fires. Signalling here + // unifies both paths and ensures no capture task outlives this method's + // return — disposing the CTS in the surrounding `using` does NOT cancel it. + timeoutCts.Cancel(); + + return new ProcessCaptureResult( + ExitCode: -1, + Capture: interruptedCapture, + FailureKind: timedOut ? ProcessCaptureFailureKind.TimedOut : null, + FailureMessage: timedOut ? $"Process timed out after {timeout.TotalSeconds:F1}s." : null, + Cancelled: cancelled); + } + + // The process exited cleanly under the timeout, but the capture task may + // still be reading. The pipes normally close on child exit and the readers + // EOF immediately; however, a child that left descendants holding inherited + // stdout/stderr handles keeps them open. timeoutCts is still ticking, so an + // unbounded await here would block up to the remaining wall-clock timeout + // budget (potentially several seconds for a peer that exited in + // milliseconds). Cap the post-exit drain at the same bound we use after a + // kill so the success path doesn't pay the full timeout for that scenario. + var capture = await SwallowCaptureAsync(captureTask, createEmptyCapture, logger, s_postKillCaptureWaitBound).ConfigureAwait(false); + var exitCode = process.ExitCode; + + // If the bounded drain timed out (pipes inherited by descendants), the + // capture task is still awaiting on timeoutCts.Token. Disposing the CTS in + // the `using` does NOT cancel it, so without an explicit Cancel here the + // task could linger up to the remaining wall-clock timeout budget after we + // return, holding inherited stdout/stderr handles open for that long. + // Cancelling drives the read to terminate promptly. + timeoutCts.Cancel(); + + return new ProcessCaptureResult( + ExitCode: exitCode, + Capture: capture, + FailureKind: null, + FailureMessage: null, + Cancelled: false); + } + finally + { + // Belt-and-suspenders: kill any still-running process before + // disposing. TryKillProcessTree is a no-op once HasExited is true, + // so the happy path costs only a quick property read. + TryKillProcessTree(process, logger); + process.Dispose(); + } + } + + private static void TryKillProcessTree(Process process, ILogger logger) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException or System.ComponentModel.Win32Exception) + { + logger.LogDebug(ex, "Could not kill process {Pid}.", TryGetPid(process)); + } + } + + private static async Task SwallowExitWaitAsync(Process process, TimeSpan bound, ILogger logger) + { + // Bounded post-kill wait: if TryKillProcessTree silently failed and the + // process is still alive past `bound`, abandon rather than block the + // caller indefinitely. Logged at debug so an operator chasing a hung + // peer can see that the process outlived its termination request. + using var cts = new CancellationTokenSource(bound); + try + { + await process.WaitForExitAsync(cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + logger.LogDebug( + "Process {Pid} did not exit within {Bound}s after kill request; abandoning.", + TryGetPid(process), bound.TotalSeconds); + } + catch + { + // Already being torn down; failed wait is not actionable for the caller. + } + } + + private static int TryGetPid(Process process) + { + try + { + return process.Id; + } + catch (InvalidOperationException) + { + return -1; + } + } + + private static async Task SwallowCaptureAsync(Task task, Func createEmptyCapture, ILogger logger, TimeSpan? bound = null) + { + try + { + if (bound is { } waitBound) + { + return await task.WaitAsync(waitBound).ConfigureAwait(false); + } + + return await task.ConfigureAwait(false); + } + catch (TimeoutException ex) + { + logger.LogDebug(ex, "Timed out waiting for process output capture after process interruption."); + ObserveCaptureFault(task); + return createEmptyCapture(); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Could not capture process output."); + return createEmptyCapture(); + } + } + + private static void ObserveCaptureFault(Task task) + { + _ = task.ContinueWith( + static t => _ = t.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } +} + +internal readonly record struct ProcessCaptureResult( + int ExitCode, + TCapture Capture, + ProcessCaptureFailureKind? FailureKind, + string? FailureMessage, + bool Cancelled); + +internal enum ProcessCaptureFailureKind +{ + StartFailed, + CaptureFailed, + TimedOut, +} diff --git a/src/Shared/PathLookupHelper.cs b/src/Shared/PathLookupHelper.cs index 2c26f5a1809..bbcb10cc1f2 100644 --- a/src/Shared/PathLookupHelper.cs +++ b/src/Shared/PathLookupHelper.cs @@ -59,6 +59,20 @@ public static string ResolveExecutablePath(string executablePath, IDictionary + /// Finds all full paths of a command by searching the system PATH. + /// + /// The command name to search for. + /// The matching executable paths in PATH lookup order. + public static IEnumerable FindAllFullPathsFromPath(string command) + { + var pathExtensions = OperatingSystem.IsWindows() + ? Environment.GetEnvironmentVariable("PATHEXT")?.Split(';', StringSplitOptions.RemoveEmptyEntries) ?? [] + : null; + + return FindAllFullPathsFromPath(command, Environment.GetEnvironmentVariable("PATH"), Path.PathSeparator, FileExistsAndIsExecutable, pathExtensions); + } + /// /// Finds the full path of a command by searching the specified PATH variable. /// @@ -81,7 +95,39 @@ public static string ResolveExecutablePath(string executablePath, IDictionary + /// Finds all full paths of a command by searching the specified PATH variable. + /// + /// The command name to search for. + /// The PATH environment variable value to search. + /// The character used to separate paths in the PATH variable. + /// A function to check if a file exists at a given path. + /// Optional array of executable extensions to try (e.g. .exe, .cmd). When provided, these extensions will be appended to the command if not already present. + /// The matching executable paths in PATH lookup order. + internal static IEnumerable FindAllFullPathsFromPath(string command, string? pathVariable, char pathSeparator, Func fileExists, string[]? pathExtensions = null) + { + Debug.Assert(!string.IsNullOrWhiteSpace(command)); + + // If the command already has a known extension, just search for it directly. + if (pathExtensions is not null && pathExtensions.Any(ext => command.EndsWith(ext, StringComparison.OrdinalIgnoreCase))) + { + return FindAllFullPaths(command, pathVariable, pathSeparator, fileExists, pathExtensions: null); + } + + return FindAllFullPaths(command, pathVariable, pathSeparator, fileExists, pathExtensions); + } + private static string? FindFullPath(string command, string? pathVariable, char pathSeparator, Func fileExists, string[]? pathExtensions) + { + foreach (var fullPath in FindAllFullPaths(command, pathVariable, pathSeparator, fileExists, pathExtensions)) + { + return fullPath; + } + + return null; + } + + private static IEnumerable FindAllFullPaths(string command, string? pathVariable, char pathSeparator, Func fileExists, string[]? pathExtensions) { foreach (var directory in (pathVariable ?? string.Empty).Split(pathSeparator, StringSplitOptions.RemoveEmptyEntries)) { @@ -89,25 +135,61 @@ public static string ResolveExecutablePath(string executablePath, IDictionary 0) { + var foundExtensionMatch = false; foreach (var extension in pathExtensions) { - var fullPathWithExt = Path.Combine(directory, command + extension); - if (fileExists(fullPathWithExt)) + if (TryCombine(directory, command + extension, out var fullPathWithExt) && + FileExistsSafe(fileExists, fullPathWithExt)) { - return fullPathWithExt; + yield return fullPathWithExt; + foundExtensionMatch = true; + break; } } + + if (foundExtensionMatch) + { + continue; + } } // Try exact match (for non-Windows, or as fallback on Windows if no extension match found in this directory). - var fullPath = Path.Combine(directory, command); - if (fileExists(fullPath)) + if (TryCombine(directory, command, out var fullPath) && + FileExistsSafe(fileExists, fullPath)) { - return fullPath; + yield return fullPath; } } + } - return null; + private static bool TryCombine(string directory, string fileName, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? path) + { + try + { + path = Path.Combine(directory, fileName); + return true; + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + path = null; + return false; + } + } + + // Wrap a possibly-throwing existence probe so callers do not have to handle + // the IO/permission failure modes themselves. Used by the PATH-walk above + // when a directory on PATH is unreadable or a candidate path is malformed + // in a way that File.Exists rejects with an exception rather than false. + private static bool FileExistsSafe(Func fileExists, string path) + { + try + { + return fileExists(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PathTooLongException or System.Security.SecurityException) + { + return false; + } } private static bool IsExplicitExecutablePath(string executablePath) diff --git a/tests/Aspire.Acquisition.Tests/Scripts/Common/ScriptPaths.cs b/tests/Aspire.Acquisition.Tests/Scripts/Common/ScriptPaths.cs index 958df1a0a4a..2ed6826342c 100644 --- a/tests/Aspire.Acquisition.Tests/Scripts/Common/ScriptPaths.cs +++ b/tests/Aspire.Acquisition.Tests/Scripts/Common/ScriptPaths.cs @@ -11,4 +11,6 @@ internal static class ScriptPaths public static readonly string ReleasePowerShell = Path.Combine(s_scriptsDirectory, "get-aspire-cli.ps1"); public static readonly string PRShell = Path.Combine(s_scriptsDirectory, "get-aspire-cli-pr.sh"); public static readonly string PRPowerShell = Path.Combine(s_scriptsDirectory, "get-aspire-cli-pr.ps1"); + public const string LocalHiveShell = "localhive.sh"; + public const string LocalHivePowerShell = "localhive.ps1"; } diff --git a/tests/Aspire.Acquisition.Tests/Scripts/LocalHiveScriptFunctionTests.cs b/tests/Aspire.Acquisition.Tests/Scripts/LocalHiveScriptFunctionTests.cs new file mode 100644 index 00000000000..2d6c59b8878 --- /dev/null +++ b/tests/Aspire.Acquisition.Tests/Scripts/LocalHiveScriptFunctionTests.cs @@ -0,0 +1,112 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Aspire.Acquisition.Tests.Scripts; + +/// +/// Source-level contract tests for localhive installer scripts. +/// +public class LocalHiveScriptFunctionTests +{ + [Theory] + [InlineData(nameof(ScriptPaths.LocalHiveShell))] + [InlineData(nameof(ScriptPaths.LocalHivePowerShell))] + public void Source_DoesNotReferencePersistentShellProfileFiles(string scriptPathName) + { + var source = File.ReadAllText(GetRepoPath(GetScriptPath(scriptPathName))); + + Assert.DoesNotContain(".bashrc", source, StringComparison.Ordinal); + Assert.DoesNotContain(".zshrc", source, StringComparison.Ordinal); + Assert.DoesNotContain(".profile", source, StringComparison.Ordinal); + Assert.DoesNotContain(".bash_profile", source, StringComparison.Ordinal); + } + + [Theory] + [InlineData(nameof(ScriptPaths.LocalHivePowerShell))] + public void PowerShellSource_DoesNotWriteUserPathEnvironment(string scriptPathName) + { + var source = File.ReadAllText(GetRepoPath(GetScriptPath(scriptPathName))); + var userEnvironmentWrite = new System.Text.RegularExpressions.Regex( + @"\[Environment\]::SetEnvironmentVariable\([^)]*(['""`]User['""`]|EnvironmentVariableTarget\]::User)", + System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline); + + Assert.False( + userEnvironmentWrite.IsMatch(source), + $"{GetScriptPath(scriptPathName)} must not call [Environment]::SetEnvironmentVariable(..., 'User') or EnvironmentVariableTarget.User."); + } + + [Fact] + public void ShellSource_PrintsDirectBinaryPathActivationHint() + { + var source = File.ReadAllText(GetRepoPath(ScriptPaths.LocalHiveShell)); + + Assert.Contains("Run Aspire directly with: $CLI_BIN_DIR/$CLI_EXE_NAME", source, StringComparison.Ordinal); + Assert.Contains("For this shell only, run: export PATH=", source, StringComparison.Ordinal); + Assert.DoesNotContain("Add this to your shell profile", source, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Add the following to ~/.bashrc", source, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Add to your shell profile", source, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void PowerShellSource_PrintsDirectBinaryPathActivationHint() + { + var source = File.ReadAllText(GetRepoPath(ScriptPaths.LocalHivePowerShell)); + + Assert.Contains("Run Aspire directly with: $installedCliPath", source, StringComparison.Ordinal); + Assert.Contains("to PATH for this PowerShell session", source, StringComparison.Ordinal); + Assert.DoesNotContain("Add this to your shell profile", source, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Add the following to ~/.bashrc", source, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Add to your shell profile", source, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData(nameof(ScriptPaths.LocalHiveShell))] + [InlineData(nameof(ScriptPaths.LocalHivePowerShell))] + public void Source_DoesNotWriteGlobalChannel(string scriptPathName) + { + var source = File.ReadAllText(GetRepoPath(GetScriptPath(scriptPathName))); + + Assert.DoesNotContain("config set channel", source, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("aspire config set", source, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void PowerShellSource_WindowsArchivePathDoesNotUseCompressArchive() + { + // Compress-Archive enumerates inputs via the PowerShell provider, which on + // non-Windows hosts hides dotfiles from `/*` wildcard expansion. The + // portable layout includes bin/.aspire-install.json — the localhive route + // sidecar — and silently dropping it from a win-* zip built on Linux/macOS + // would produce sidecar-less installs on the target. Use + // [System.IO.Compression.ZipFile]::CreateFromDirectory instead. + var source = File.ReadAllText(GetRepoPath(ScriptPaths.LocalHivePowerShell)); + + // Strip PowerShell line comments so the contract check matches actual + // cmdlet invocations, not mentions of the cmdlet in explanatory comments. + var sourceWithoutComments = string.Join('\n', source.Split('\n').Select(line => + { + var hashIdx = line.IndexOf('#'); + return hashIdx >= 0 ? line[..hashIdx] : line; + })); + + Assert.DoesNotContain("Compress-Archive", sourceWithoutComments, StringComparison.Ordinal); + Assert.Contains("[System.IO.Compression.ZipFile]::CreateFromDirectory", source, StringComparison.Ordinal); + } + + private static string GetScriptPath(string scriptPathName) => scriptPathName switch + { + nameof(ScriptPaths.LocalHiveShell) => ScriptPaths.LocalHiveShell, + nameof(ScriptPaths.LocalHivePowerShell) => ScriptPaths.LocalHivePowerShell, + _ => throw new ArgumentOutOfRangeException(nameof(scriptPathName), scriptPathName, null) + }; + + private static string GetRepoPath(string relativePath) + { + var repoRoot = Aspire.Templates.Tests.TestUtils.FindRepoRoot()?.FullName + ?? throw new InvalidOperationException("Could not find repository root"); + + return Path.Combine(repoRoot, relativePath); + } +} diff --git a/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs index 01b3a0deacc..ecb1c46b5f0 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/DoctorCommandTests.cs @@ -91,12 +91,6 @@ public async Task DoctorCommand_WithSslCertDir_ShowsTrusted() await auto.EnterAsync(); await auto.WaitUntilAsync(s => { - // Wait for doctor to complete - if (!s.ContainsText("dev-certs")) - { - return false; - } - // Fail if we see partial trust when SSL_CERT_DIR is configured if (s.ContainsText("partially trusted")) { diff --git a/tests/Aspire.Cli.Tests/Acquisition/InstallSidecarReaderTests.cs b/tests/Aspire.Cli.Tests/Acquisition/InstallSidecarReaderTests.cs new file mode 100644 index 00000000000..e8bc04232ae --- /dev/null +++ b/tests/Aspire.Cli.Tests/Acquisition/InstallSidecarReaderTests.cs @@ -0,0 +1,315 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using Aspire.Cli.Acquisition; +using Aspire.Cli.Tests.Utils; + +namespace Aspire.Cli.Tests.Acquisition; + +/// +/// Behavior tests for . The sidecar contract +/// is documented in docs/specs/install-routes.md: a single-field JSON +/// file named .aspire-install.json with shape +/// { "source": "<route>" } living next to the CLI binary. +/// +public class InstallSidecarReaderTests(ITestOutputHelper outputHelper) +{ + [Theory] + [InlineData("script", "Script")] + [InlineData("pr", "Pr")] + [InlineData("winget", "Winget")] + [InlineData("brew", "Brew")] + [InlineData("dotnet-tool", "DotnetTool")] + [InlineData("localhive", "LocalHive")] + public void TryRead_ParsesEachKnownSource(string wireValue, string expectedEnumName) + { + var expected = Enum.Parse(expectedEnumName); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + WriteSidecar(workspace.WorkspaceRoot.FullName, $"{{\"source\":\"{wireValue}\"}}"); + + var reader = new InstallSidecarReader(); + var result = reader.TryRead(workspace.WorkspaceRoot.FullName); + + var ok = Assert.IsType(result); + Assert.Equal(expected, ok.Info.Source); + Assert.Equal(wireValue, ok.Info.RawSource); + } + + [Fact] + public void TryRead_ReturnsNotFoundWhenSidecarMissing() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var expectedPath = Path.Combine(workspace.WorkspaceRoot.FullName, InstallSidecarReader.SidecarFileName); + + var reader = new InstallSidecarReader(); + var result = reader.TryRead(workspace.WorkspaceRoot.FullName); + + var notFound = Assert.IsType(result); + Assert.Equal(expectedPath, notFound.SidecarPath); + } + + [Fact] + public void TryRead_ReturnsNotFoundForEmptyBinaryDir() + { + var reader = new InstallSidecarReader(); + + var empty = Assert.IsType(reader.TryRead(string.Empty)); + Assert.Equal(string.Empty, empty.SidecarPath); + + var nullResult = Assert.IsType(reader.TryRead(null!)); + Assert.Equal(string.Empty, nullResult.SidecarPath); + } + + [Fact] + public void TryRead_UnreadableSidecar_ReturnsInvalidWithReason() + { + if (OperatingSystem.IsWindows()) + { + Assert.Skip("Unix file modes are required to create a deterministic unreadable sidecar."); + return; + } + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sidecarPath = WriteSidecar(workspace.WorkspaceRoot.FullName, "{\"source\":\"script\"}"); + var originalMode = File.GetUnixFileMode(sidecarPath); + + try + { + File.SetUnixFileMode(sidecarPath, UnixFileMode.None); + + var reader = new InstallSidecarReader(); + var result = reader.TryRead(workspace.WorkspaceRoot.FullName); + + var invalid = Assert.IsType(result); + Assert.Equal(sidecarPath, invalid.SidecarPath); + Assert.NotEmpty(invalid.Reason); + } + finally + { + File.SetUnixFileMode(sidecarPath, originalMode | UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + } + + [Fact] + public void TryRead_MalformedJson_ReturnsInvalidWithParseReason() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sidecarPath = WriteSidecar(workspace.WorkspaceRoot.FullName, "{not valid json"); + + var reader = new InstallSidecarReader(); + var result = reader.TryRead(workspace.WorkspaceRoot.FullName); + + var invalid = Assert.IsType(result); + Assert.Equal(sidecarPath, invalid.SidecarPath); + Assert.NotEmpty(invalid.Reason); + } + + [Theory] + [InlineData("{\"source\":\"\"}", "", "empty source string")] + [InlineData("{\"source\":\"future-route\"}", "future-route", "unknown but well-formed source")] + [InlineData("[\"script\"]", "", "non-object root element")] + [InlineData("{\"source\": 42}", "", "non-string source field")] + public void TryRead_UnknownOrMalformedSource_ReturnsUnknownEnumWithRawSourcePreserved(string sidecarBody, string expectedRawSource, string scenario) + { + // All four shapes round-trip via the parser as InstallSource.Unknown so + // a future-route or otherwise-unrecognized sidecar never blocks the + // discovery walk. RawSource preserves the literal wire value so a + // future client can re-interpret it without re-reading the file. + _ = scenario; // surfaced in test name for debuggability + using var workspace = TemporaryWorkspace.Create(outputHelper); + WriteSidecar(workspace.WorkspaceRoot.FullName, sidecarBody); + + var reader = new InstallSidecarReader(); + var result = reader.TryRead(workspace.WorkspaceRoot.FullName); + + var ok = Assert.IsType(result); + Assert.Equal(InstallSource.Unknown, ok.Info.Source); + Assert.Equal(expectedRawSource, ok.Info.RawSource); + } + + [Fact] + public void TryRead_SidecarPathIsAbsolutePathOfReadFile() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + WriteSidecar(workspace.WorkspaceRoot.FullName, "{\"source\":\"script\"}"); + + var reader = new InstallSidecarReader(); + var result = reader.TryRead(workspace.WorkspaceRoot.FullName); + + var ok = Assert.IsType(result); + var expectedPath = Path.Combine(workspace.WorkspaceRoot.FullName, InstallSidecarReader.SidecarFileName); + Assert.Equal(expectedPath, ok.Info.SidecarPath); + } + + [Fact] + public void ReadSourceField_ReturnsRawSource() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sidecarPath = WriteSidecar(workspace.WorkspaceRoot.FullName, "{\"source\":\"script\"}"); + + var result = InstallSidecarReader.ReadSourceField(sidecarPath); + + Assert.Equal("script", result); + } + + [Fact] + public void ReadSourceField_MissingSidecar_ReturnsNull() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sidecarPath = Path.Combine(workspace.WorkspaceRoot.FullName, InstallSidecarReader.SidecarFileName); + + var result = InstallSidecarReader.ReadSourceField(sidecarPath); + + Assert.Null(result); + } + + [Fact] + public void ReadSourceField_MalformedJson_ReturnsNull() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sidecarPath = WriteSidecar(workspace.WorkspaceRoot.FullName, "{not valid json"); + + var result = InstallSidecarReader.ReadSourceField(sidecarPath); + + Assert.Null(result); + } + + [Fact] + public void ReadSourceField_UnreadableSidecar_ReturnsNull() + { + if (OperatingSystem.IsWindows()) + { + Assert.Skip("Unix file modes are required to create a deterministic unreadable sidecar."); + return; + } + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sidecarPath = WriteSidecar(workspace.WorkspaceRoot.FullName, "{\"source\":\"script\"}"); + var originalMode = File.GetUnixFileMode(sidecarPath); + + try + { + File.SetUnixFileMode(sidecarPath, UnixFileMode.None); + + var result = InstallSidecarReader.ReadSourceField(sidecarPath); + + Assert.Null(result); + } + finally + { + File.SetUnixFileMode(sidecarPath, originalMode | UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + } + + [Theory] + [InlineData("Script", "script")] + [InlineData("Pr", "pr")] + [InlineData("Winget", "winget")] + [InlineData("Brew", "brew")] + [InlineData("DotnetTool", "dotnet-tool")] + [InlineData("LocalHive", "localhive")] + public void ToWireString_RoundTripsWithParseInstallSource(string enumName, string expectedWire) + { + var source = Enum.Parse(enumName); + Assert.Equal(expectedWire, source.ToWireString()); + Assert.Equal(source, InstallSourceExtensions.ParseInstallSource(expectedWire)); + } + + [Fact] + public void ToWireString_ReturnsNullForUnknown() + { + Assert.Null(InstallSource.Unknown.ToWireString()); + } + + [Fact] + public void TryRead_OversizedSidecar_ReturnsInvalid() + { + // Discovery walks PATH and reads any .aspire-install.json next to a candidate + // binary. A pathological (or hostile) file planted next to such a candidate + // must not be parsed into memory in full. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sidecarPath = Path.Combine(workspace.WorkspaceRoot.FullName, InstallSidecarReader.SidecarFileName); + var oversized = new string('a', (int)InstallSidecarReader.MaxSidecarBytes + 1); + File.WriteAllText(sidecarPath, $"{{\"source\":\"{oversized}\"}}"); + + var reader = new InstallSidecarReader(); + var result = reader.TryRead(workspace.WorkspaceRoot.FullName); + + var invalid = Assert.IsType(result); + Assert.Equal(sidecarPath, invalid.SidecarPath); + Assert.Contains("exceeds", invalid.Reason); + } + + [Fact] + public void ReadSourceField_OversizedSidecar_ReturnsNull() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sidecarPath = Path.Combine(workspace.WorkspaceRoot.FullName, InstallSidecarReader.SidecarFileName); + var oversized = new string('a', (int)InstallSidecarReader.MaxSidecarBytes + 1); + File.WriteAllText(sidecarPath, $"{{\"source\":\"{oversized}\"}}"); + + Assert.Null(InstallSidecarReader.ReadSourceField(sidecarPath)); + } + + [Fact] + public void TryRead_HandlesUtf8Bom_ReturnsOk() + { + // `localhive.ps1` writes the sidecar via `Set-Content -Encoding UTF8`, + // which on Windows PowerShell 5.x prepends a UTF-8 BOM (0xEF 0xBB 0xBF). + // `JsonDocument.Parse` tolerates the BOM today; pin that behavior so a + // future parser change does not silently break sidecars planted by the + // legacy PS 5.x writer. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sidecarPath = Path.Combine(workspace.WorkspaceRoot.FullName, InstallSidecarReader.SidecarFileName); + var bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes("{\"source\":\"script\"}")).ToArray(); + File.WriteAllBytes(sidecarPath, bytes); + + var reader = new InstallSidecarReader(); + var result = reader.TryRead(workspace.WorkspaceRoot.FullName); + + var ok = Assert.IsType(result); + Assert.Equal(InstallSource.Script, ok.Info.Source); + Assert.Equal("script", ok.Info.RawSource); + } + + [Fact] + public void ReadSourceField_HandlesUtf8Bom_ReturnsScript() + { + // Same Windows PowerShell 5.x BOM scenario as TryRead_HandlesUtf8Bom_ReturnsOk, + // but exercising the lightweight ReadSourceField path. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var sidecarPath = Path.Combine(workspace.WorkspaceRoot.FullName, InstallSidecarReader.SidecarFileName); + var bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes("{\"source\":\"script\"}")).ToArray(); + File.WriteAllBytes(sidecarPath, bytes); + + Assert.Equal("script", InstallSidecarReader.ReadSourceField(sidecarPath)); + } + + [Fact] + public void TryRead_IgnoresUnknownAdditionalFields_ReturnsOk() + { + // The sidecar contract reserves room for future fields. Older parents + // must ignore unknown properties (and nested shapes) rather than reject + // them, so a newer CLI can extend the sidecar without breaking + // discovery on older installs. + using var workspace = TemporaryWorkspace.Create(outputHelper); + WriteSidecar(workspace.WorkspaceRoot.FullName, "{\"source\":\"script\",\"futureField\":\"value\",\"nested\":{\"a\":1,\"b\":[1,2]}}"); + + var reader = new InstallSidecarReader(); + var result = reader.TryRead(workspace.WorkspaceRoot.FullName); + + var ok = Assert.IsType(result); + Assert.Equal(InstallSource.Script, ok.Info.Source); + Assert.Equal("script", ok.Info.RawSource); + } + + private static string WriteSidecar(string binaryDir, string content) + { + var path = Path.Combine(binaryDir, InstallSidecarReader.SidecarFileName); + File.WriteAllText(path, content); + return path; + } + +} diff --git a/tests/Aspire.Cli.Tests/Acquisition/InstallationDiscoveryDiscoverAllTests.cs b/tests/Aspire.Cli.Tests/Acquisition/InstallationDiscoveryDiscoverAllTests.cs new file mode 100644 index 00000000000..01a0a6f3052 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Acquisition/InstallationDiscoveryDiscoverAllTests.cs @@ -0,0 +1,1026 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Acquisition; +using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Tests.Utils; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Cli.Tests.Acquisition; + +/// +/// Behavior tests for +/// focused on install metadata requirements and dedup-by-canonical-path semantics. +/// PATH and well-known-prefix walks are exercised via an isolated +/// HOME-equivalent so a developer's real home directory doesn't leak +/// into the test. +/// +/// +/// Tests in this class mutate process-wide environment variables (PATH, +/// PATHEXT, HOME/USERPROFILE) via . xUnit runs +/// test classes in parallel by default, so any other test in this assembly +/// that reads PATH (directly or via Process.Start / path lookup +/// helpers) could see the override transiently. The collection definition +/// disables parallelization across these tests and any other suite that +/// joins EnvVarMutatingTests. +/// +[Collection(EnvVarMutatingTestCollection.Name)] +public class InstallationDiscoveryDiscoverAllTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task DiscoverAllAsync_PathHit_WithoutSidecar_IsListedAsNotProbed_AndNeverSpawned() + { + // A binary on $PATH with no .aspire-install.json next to it must not be + // spawned. The user-installed binary on PATH is the most dangerous case: + // if a user runs `aspire doctor`, we cannot execute arbitrary + // same-named binaries we happened to find. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var pathDir = Path.Combine(workspace.WorkspaceRoot.FullName, "no-sidecar-bin"); + Directory.CreateDirectory(pathDir); + var noSidecarBinary = WriteFakeBinary(pathDir); + + var probe = new FakePeerInstallProbe(); + using var _ = new EnvVarOverride("PATH", pathDir + Path.PathSeparator + (Environment.GetEnvironmentVariable("PATH") ?? string.Empty)); + + var discovery = NewDiscovery(probe, workspace); + var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken); + + // Install metadata is required before probing: this PATH hit must not be probed. + Assert.DoesNotContain(probe.ProbedPaths, p => string.Equals(p, noSidecarBinary, StringComparison.Ordinal)); + + var noSidecarRow = Assert.Single(results, r => + string.Equals(r.CanonicalPath, noSidecarBinary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); + Assert.Equal(InstallationInfoStatus.NotProbed, noSidecarRow.Status); + var expectedSidecarPath = Path.Combine(pathDir, InstallSidecarReader.SidecarFileName); + Assert.Equal($"No install-route sidecar found at {expectedSidecarPath}; peer was not probed.", noSidecarRow.StatusReason); + } + + [Fact] + public async Task DiscoverAllAsync_PathHit_WithUnreadableSidecar_IsListedAsNotProbedWithInvalidSidecarReason() + { + if (OperatingSystem.IsWindows()) + { + Assert.Skip("Unix file modes are required to create a deterministic unreadable sidecar."); + return; + } + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var pathDir = Path.Combine(workspace.WorkspaceRoot.FullName, "unreadable-bin"); + Directory.CreateDirectory(pathDir); + var binary = WriteFakeBinary(pathDir); + var sidecarPath = Path.Combine(pathDir, InstallSidecarReader.SidecarFileName); + File.WriteAllText(sidecarPath, "{\"source\":\"script\"}"); + var originalMode = File.GetUnixFileMode(sidecarPath); + + try + { + File.SetUnixFileMode(sidecarPath, UnixFileMode.None); + + var probe = new FakePeerInstallProbe(); + using var _ = new EnvVarOverride("PATH", pathDir); + + var discovery = NewDiscovery(probe, workspace); + var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken); + + Assert.DoesNotContain(probe.ProbedPaths, p => string.Equals(p, binary, StringComparison.Ordinal)); + var row = Assert.Single(results, r => string.Equals(r.CanonicalPath, binary, StringComparison.Ordinal)); + Assert.Equal(InstallationInfoStatus.NotProbed, row.Status); + Assert.Equal($"Install-route sidecar at {sidecarPath} could not be read or parsed; peer was not probed.", row.StatusReason); + } + finally + { + File.SetUnixFileMode(sidecarPath, originalMode | UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + } + + [Fact] + public async Task DiscoverAllAsync_PathHit_WithMalformedSidecar_IsListedAsNotProbedWithInvalidSidecarReason() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var pathDir = Path.Combine(workspace.WorkspaceRoot.FullName, "malformed-bin"); + Directory.CreateDirectory(pathDir); + var binary = WriteFakeBinary(pathDir); + var sidecarPath = Path.Combine(pathDir, InstallSidecarReader.SidecarFileName); + File.WriteAllText(sidecarPath, "{not valid json"); + + var probe = new FakePeerInstallProbe(); + using var _ = new EnvVarOverride("PATH", pathDir); + + var discovery = NewDiscovery(probe, workspace); + var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken); + + Assert.DoesNotContain(probe.ProbedPaths, p => string.Equals(p, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); + var row = Assert.Single(results, r => + string.Equals(r.CanonicalPath, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); + Assert.Equal(InstallationInfoStatus.NotProbed, row.Status); + Assert.Equal($"Install-route sidecar at {sidecarPath} could not be read or parsed; peer was not probed.", row.StatusReason); + } + + [Fact] + public async Task DiscoverAllAsync_TrustedSidecar_IsSpawnedAndDecoratedWithDiscoveredPath() + { + // A binary with a script-route sidecar in its directory has enough + // install metadata to be probed. The peer probe is called, and its returned + // InstallationInfo is merged with the discovered path so the row + // displayed to the user matches what `which` would show. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var binDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "bin"); + Directory.CreateDirectory(binDir); + var binary = WriteFakeBinary(binDir); + File.WriteAllText(Path.Combine(binDir, InstallSidecarReader.SidecarFileName), "{\"source\":\"script\"}"); + + var probe = new FakePeerInstallProbe(new Dictionary + { + [binary] = new PeerProbeResult.Ok(new InstallationInfo + { + Path = "/peer-says/aspire", + Version = "12.5.0", + Channel = "stable", + Route = "script", + Status = InstallationInfoStatus.Ok, + }), + }); + + var discovery = NewDiscovery(probe, workspace); + var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken); + + Assert.Contains(probe.ProbedPaths, p => string.Equals(p, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); + + var discoveredRow = results.Single(r => + string.Equals(r.CanonicalPath, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); + Assert.Equal("12.5.0", discoveredRow.Version); + Assert.Equal("stable", discoveredRow.Channel); + // Discovered path wins over what the peer reported, so the table + // reflects where the binary lives on disk. + Assert.Equal(binary, discoveredRow.Path); + } + + [Fact] + public async Task DiscoverAllAsync_PathStatusTracksActiveShadowedAndOffPathInstalls() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var firstPathDir = Path.Combine(workspace.WorkspaceRoot.FullName, "path-first"); + var secondPathDir = Path.Combine(workspace.WorkspaceRoot.FullName, "path-second"); + var offPathDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "bin"); + Directory.CreateDirectory(firstPathDir); + Directory.CreateDirectory(secondPathDir); + Directory.CreateDirectory(offPathDir); + + var firstPathBinary = WriteTrustedFakeBinary(firstPathDir, "script"); + var secondPathBinary = WriteTrustedFakeBinary(secondPathDir, "pr"); + var offPathBinary = WriteTrustedFakeBinary(offPathDir, "script"); + + var probe = new FakePeerInstallProbe(new Dictionary + { + [firstPathBinary] = new PeerProbeResult.Ok(new InstallationInfo + { + Path = firstPathBinary, + Version = "13.0.1", + Status = InstallationInfoStatus.Ok, + }), + [secondPathBinary] = new PeerProbeResult.Ok(new InstallationInfo + { + Path = secondPathBinary, + Version = "13.0.2", + Status = InstallationInfoStatus.Ok, + }), + [offPathBinary] = new PeerProbeResult.Ok(new InstallationInfo + { + Path = offPathBinary, + Version = "13.0.3", + Status = InstallationInfoStatus.Ok, + }), + }); + + using var pathOverride = new EnvVarOverride("PATH", firstPathDir + Path.PathSeparator + secondPathDir); + using var pathExtOverride = new EnvVarOverride("PATHEXT", ".EXE"); + + var discovery = NewDiscovery(probe, workspace); + var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken); + + var firstPathRow = results.Single(r => + string.Equals(r.CanonicalPath, firstPathBinary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); + var secondPathRow = results.Single(r => + string.Equals(r.CanonicalPath, secondPathBinary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); + var offPathRow = results.Single(r => + string.Equals(r.CanonicalPath, offPathBinary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); + + Assert.Equal(InstallationPathStatus.Active, firstPathRow.PathStatus); + Assert.Equal(InstallationPathStatus.Shadowed, secondPathRow.PathStatus); + Assert.Equal(InstallationPathStatus.NotOnPath, offPathRow.PathStatus); + } + + [Fact] + public async Task DiscoverAllAsync_UnknownSidecarSource_WithSuccessfulProbe_ProbesAndSurfacesRawRoute() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var binDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "bin"); + Directory.CreateDirectory(binDir); + var binary = WriteTrustedFakeBinary(binDir, "apt"); + + var probe = new FakePeerInstallProbe(new Dictionary + { + [binary] = new PeerProbeResult.Ok(new InstallationInfo + { + Path = binary, + Version = "13.1.0", + Channel = "daily", + Status = InstallationInfoStatus.Ok, + }), + }); + + var discovery = NewDiscovery(probe, workspace); + var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken); + + Assert.Contains(probe.ProbedPaths, p => string.Equals(p, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); + var row = results.Single(r => + string.Equals(r.CanonicalPath, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); + Assert.Equal(InstallationInfoStatus.Ok, row.Status); + Assert.Equal("apt", row.Route); + Assert.Equal("13.1.0", row.Version); + Assert.Equal("daily", row.Channel); + } + + [Fact] + public async Task DiscoverAllAsync_UnknownSidecarSource_WithFailedProbe_ProbesAndSurfacesFailedWithRawRoute() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var binDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "bin"); + Directory.CreateDirectory(binDir); + var binary = WriteTrustedFakeBinary(binDir, "apt"); + + var probe = new FakePeerInstallProbe(new Dictionary + { + [binary] = new PeerProbeResult.Failed("simulated probe failure"), + }); + + var discovery = NewDiscovery(probe, workspace); + var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken); + + Assert.Contains(probe.ProbedPaths, p => string.Equals(p, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); + var row = results.Single(r => + string.Equals(r.CanonicalPath, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); + Assert.Equal(InstallationInfoStatus.Failed, row.Status); + Assert.Equal("apt", row.Route); + Assert.Equal("simulated probe failure", row.StatusReason); + } + + [Fact] + public async Task DiscoverAllAsync_PeerProbeFails_RowSurvivesAsFailed_WithRouteIntact() + { + // A peer that fails (timeout / non-zero exit / invalid JSON) is per-row, + // not whole-command. The route from the sidecar is still surfaced so the + // user sees "this is a PR install but it wouldn't talk to me", not nothing. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var prDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "dogfood", "pr-9999", "bin"); + Directory.CreateDirectory(prDir); + var binary = WriteFakeBinary(prDir); + File.WriteAllText(Path.Combine(prDir, InstallSidecarReader.SidecarFileName), "{\"source\":\"pr\"}"); + + var probe = new FakePeerInstallProbe(new Dictionary + { + [binary] = new PeerProbeResult.Failed("simulated peer hang"), + }); + + var discovery = NewDiscovery(probe, workspace); + var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken); + + var row = results.Single(r => + string.Equals(r.CanonicalPath, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); + Assert.Equal(InstallationInfoStatus.Failed, row.Status); + Assert.Equal("pr", row.Route); + Assert.Contains("simulated peer hang", row.StatusReason!); + } + + [Fact] + public async Task DiscoverAllAsync_UnknownPeerProbeResult_Throws() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var binDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "bin"); + Directory.CreateDirectory(binDir); + var binary = WriteFakeBinary(binDir); + File.WriteAllText(Path.Combine(binDir, InstallSidecarReader.SidecarFileName), "{\"source\":\"script\"}"); + + var probe = new FakePeerInstallProbe(new Dictionary + { + [binary] = new UnknownPeerProbeResult(), + }); + + var discovery = NewDiscovery(probe, workspace); + + var exception = await Assert.ThrowsAsync(() => discovery.DiscoverAllAsync(TestContext.Current.CancellationToken)); + Assert.Contains(nameof(UnknownPeerProbeResult), exception.Message); + } + + [Theory] + [InlineData(true, InstallationInfoStatus.Failed)] + [InlineData(false, InstallationInfoStatus.NotProbed)] + public async Task DiscoverAllAsync_ProbeFailureAndMissingInstallMetadata_SurfaceDistinctStatuses(bool hasInstallMetadata, string expectedStatus) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var binDir = Path.Combine(workspace.WorkspaceRoot.FullName, hasInstallMetadata ? "metadata-bin" : "no-metadata-bin"); + Directory.CreateDirectory(binDir); + var binary = WriteFakeBinary(binDir); + + if (hasInstallMetadata) + { + File.WriteAllText(Path.Combine(binDir, InstallSidecarReader.SidecarFileName), "{\"source\":\"script\"}"); + } + + var probe = new FakePeerInstallProbe(new Dictionary + { + [binary] = new PeerProbeResult.Failed("peer returned malformed JSON"), + }); + + using var _ = new EnvVarOverride("PATH", binDir + Path.PathSeparator + (Environment.GetEnvironmentVariable("PATH") ?? string.Empty)); + + var discovery = NewDiscovery(probe, workspace); + var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken); + + var row = results.Single(r => + string.Equals(r.CanonicalPath, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); + Assert.Equal(expectedStatus, row.Status); + + if (hasInstallMetadata) + { + Assert.Contains(probe.ProbedPaths, p => string.Equals(p, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); + Assert.Equal("script", row.Route); + Assert.Contains("peer returned malformed JSON", row.StatusReason!); + } + else + { + Assert.DoesNotContain(probe.ProbedPaths, p => string.Equals(p, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); + Assert.Contains("peer was not probed", row.StatusReason!, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public async Task DiscoverAllAsync_PrRoute_DerivesChannelFromDogfoodPathWhenPeerOmits() + { + // The structural channel for a PR install is `pr-` regardless + // of whether the older peer's --version output includes channel + // info. Discovery should overlay it from the dogfood/pr-/ + // path layout when probe.Channel comes back null. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var prDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "dogfood", "pr-12345", "bin"); + Directory.CreateDirectory(prDir); + var binary = WriteFakeBinary(prDir); + File.WriteAllText(Path.Combine(prDir, InstallSidecarReader.SidecarFileName), "{\"source\":\"pr\"}"); + + var probe = new FakePeerInstallProbe(new Dictionary + { + // Older peer using --version fallback: version only, no channel. + [binary] = new PeerProbeResult.Ok(new InstallationInfo + { + Path = binary, + Version = "13.4.0-pr.12345.gabcdef", + Status = InstallationInfoStatus.Ok, + }), + }); + + var discovery = NewDiscovery(probe, workspace); + var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken); + + var prRow = results.Single(r => + string.Equals(r.CanonicalPath, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); + Assert.Equal("pr-12345", prRow.Channel); + Assert.Equal("pr", prRow.Route); + Assert.Equal("13.4.0-pr.12345.gabcdef", prRow.Version); + } + + [Fact] + public async Task DiscoverAllAsync_PrRoute_DoesNotOverwritePeerReportedChannel() + { + // When the peer DOES report a channel, the discovery layer must not overwrite it + // with the path-derived value, even if they happen to match. + // This guards against a bug where overlay logic assumes channel + // is always missing on the fallback path. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var prDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "dogfood", "pr-12345", "bin"); + Directory.CreateDirectory(prDir); + var binary = WriteFakeBinary(prDir); + File.WriteAllText(Path.Combine(prDir, InstallSidecarReader.SidecarFileName), "{\"source\":\"pr\"}"); + + var probe = new FakePeerInstallProbe(new Dictionary + { + [binary] = new PeerProbeResult.Ok(new InstallationInfo + { + Path = binary, + Version = "13.4.0-pr.12345.gabcdef", + Channel = "pr-12345-from-peer", // intentionally distinct + Status = InstallationInfoStatus.Ok, + }), + }); + + var discovery = NewDiscovery(probe, workspace); + var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken); + + var prRow = results.Single(r => + string.Equals(r.CanonicalPath, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); + Assert.Equal("pr-12345-from-peer", prRow.Channel); + } + + [Fact] + public async Task DiscoverAllAsync_BrewRoute_DerivesPrChannelFromVersionWhenPeerOmits() + { + // Brew-installed PR builds (e.g. from `brew install aspire@pr-N`) live + // under a path that doesn't carry the `dogfood/pr-/bin` shape, so + // the path-based derivation can't help. The version string, on the + // other hand, IS baked at build time as `-pr..` — + // discovery should fall back to that signal so older brew peers, + // which don't recognize the `doctor --self` self-describe contract, + // still surface their channel instead of "(unknown)". + using var workspace = TemporaryWorkspace.Create(outputHelper); + var brewDir = Path.Combine(workspace.WorkspaceRoot.FullName, "Caskroom", "aspire", "13.4.0-pr.17115.gcd700928"); + Directory.CreateDirectory(brewDir); + var binary = WriteFakeBinary(brewDir); + File.WriteAllText(Path.Combine(brewDir, InstallSidecarReader.SidecarFileName), "{\"source\":\"brew\"}"); + + var probe = new FakePeerInstallProbe(new Dictionary + { + // Older brew peer: doctor --self unsupported, so the probe + // took the --version fallback path and reported version only. + [binary] = new PeerProbeResult.Ok(new InstallationInfo + { + Path = binary, + Version = "13.4.0-pr.17115.gcd700928", + Status = InstallationInfoStatus.Ok, + }), + }); + using var _ = new EnvVarOverride("PATH", brewDir); + + var discovery = NewDiscovery(probe, workspace); + var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken); + + var brewRow = results.Single(r => + string.Equals(r.CanonicalPath, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); + Assert.Equal("pr-17115", brewRow.Channel); + Assert.Equal("brew", brewRow.Route); + Assert.Equal("13.4.0-pr.17115.gcd700928", brewRow.Version); + } + + [Fact] + public async Task DiscoverAllAsync_BrewRoute_LeavesChannelNullForStableVersion() + { + // Same shape as the previous test, but with a non-PR version. The + // version-based derivation must return null (we can't recover the + // channel from a `13.4.0` style stable version), so the channel + // stays unset and the doctor table renders "(unknown)". + using var workspace = TemporaryWorkspace.Create(outputHelper); + var brewDir = Path.Combine(workspace.WorkspaceRoot.FullName, "Caskroom", "aspire", "13.4.0"); + Directory.CreateDirectory(brewDir); + var binary = WriteFakeBinary(brewDir); + File.WriteAllText(Path.Combine(brewDir, InstallSidecarReader.SidecarFileName), "{\"source\":\"brew\"}"); + + var probe = new FakePeerInstallProbe(new Dictionary + { + [binary] = new PeerProbeResult.Ok(new InstallationInfo + { + Path = binary, + Version = "13.4.0", + Status = InstallationInfoStatus.Ok, + }), + }); + using var _ = new EnvVarOverride("PATH", brewDir); + + var discovery = NewDiscovery(probe, workspace); + var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken); + + var brewRow = results.Single(r => + string.Equals(r.CanonicalPath, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); + Assert.Null(brewRow.Channel); + Assert.Equal("brew", brewRow.Route); + } + + [Theory] + [InlineData("pr-")] // empty PR number suffix + [InlineData("pr-not-digits")] // non-digit suffix + [InlineData("pull-12345")] // wrong prefix + [InlineData("PR-1234")] // wrong case — producer only emits lowercase + [InlineData("Pr-1234")] // wrong case — producer only emits lowercase + public void TryDerivePrChannel_RejectsMalformedPrLabels(string labelName) + { + // We only synthesize a channel when the directory name strictly + // matches `pr-`; anything else (custom --install-path + // installs, manual layouts, future label shapes) returns null so + // we don't surface a misleading channel string. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var binary = Path.Combine(workspace.WorkspaceRoot.FullName, "dogfood", labelName, "bin", "aspire"); + Directory.CreateDirectory(Path.GetDirectoryName(binary)!); + + var derived = InstallationDiscovery.TryDerivePrChannel(binary); + Assert.Null(derived); + } + + [Fact] + public void TryDerivePrChannel_RejectsNonDogfoodGrandparent() + { + // The grandparent dir must literally be `dogfood` — anything else + // (e.g., `~/.aspire/staging/pr-1/bin`) is not the conventional + // PR-script layout and we shouldn't synthesize a channel from it. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var binary = Path.Combine(workspace.WorkspaceRoot.FullName, "staging", "pr-1234", "bin", "aspire"); + Directory.CreateDirectory(Path.GetDirectoryName(binary)!); + + var derived = InstallationDiscovery.TryDerivePrChannel(binary); + Assert.Null(derived); + } + + [Fact] + public void TryDerivePrChannel_AcceptsValidDogfoodLayout() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var binary = Path.Combine(workspace.WorkspaceRoot.FullName, "dogfood", "pr-9876", "bin", "aspire"); + + var derived = InstallationDiscovery.TryDerivePrChannel(binary); + Assert.Equal("pr-9876", derived); + } + + [Theory] + [InlineData("13.4.0-pr.17115.gcd700928", "pr-17115")] // canonical PR build + [InlineData("13.3.0-pr.1234.abc", "pr-1234")] // short hash suffix + [InlineData("13.4.0-pr.5", "pr-5")] // missing hash suffix (defensive) + [InlineData("13.4.0-pr.99999+build.42", "pr-99999")] // SemVer build-metadata separator + public void TryDerivePrChannelFromVersion_AcceptsPrShapedVersions(string version, string expected) + { + Assert.Equal(expected, InstallationDiscovery.TryDerivePrChannelFromVersion(version)); + } + + [Theory] + [InlineData(null)] // null version (peer reported nothing) + [InlineData("")] // empty version + [InlineData("13.4.0")] // stable release + [InlineData("13.4.0-staging.42")] // staging release + [InlineData("13.4.0-daily.abc123")] // daily release + [InlineData("13.4.0-preview.1.99999.1")] // preview build (predates pr-channel) + [InlineData("13.4.0-pr.")] // marker with no digits + [InlineData("13.4.0-pr.foo.bar")] // non-digit suffix + [InlineData("13.4.0-pr.1foo.bar")] // mixed digits/letters in the N segment + [InlineData("13.4.0-prerelease.1.gabc")] // contains "pr" but not "-pr." + public void TryDerivePrChannelFromVersion_RejectsNonPrShapedVersions(string? version) + { + Assert.Null(InstallationDiscovery.TryDerivePrChannelFromVersion(version)); + } + + [Fact] + public async Task DiscoverAllAsync_LogsInstallMetadataRejection_AtDebugLevel() + { + // When the install metadata check rejects a candidate (no sidecar), the user + // should see why in --log-level debug output. Without this, an + // install that "doesn't show up correctly" in `aspire doctor` + // is hard to diagnose. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var pathDir = Path.Combine(workspace.WorkspaceRoot.FullName, "no-sidecar-bin"); + Directory.CreateDirectory(pathDir); + WriteFakeBinary(pathDir); + + using var _ = new EnvVarOverride("PATH", pathDir + Path.PathSeparator + (Environment.GetEnvironmentVariable("PATH") ?? string.Empty)); + + var capturedLog = new CapturingLogger(); + var discovery = new InstallationDiscovery( + channelReader: new FakeIdentityChannelReader("local"), + sidecarReader: new InstallSidecarReader(), + peerProbe: new FakePeerInstallProbe(), + executionContext: CreateExecutionContext(workspace), + logger: capturedLog); + + await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken); + + Assert.Contains(capturedLog.Entries, e => + e.Level == LogLevel.Debug && + e.Message.Contains("did not pass install metadata sidecar read", StringComparison.Ordinal) && + e.Message.Contains("NotFound", StringComparison.Ordinal) && + e.Message.Contains("not-probed", StringComparison.Ordinal)); + } + + [Fact] + public async Task DiscoverAllAsync_LogsDogfoodDirectoryWithoutBinary_AtDebugLevel() + { + // A stale ~/.aspire/dogfood/pr-N directory without a bin/aspire + // inside (failed install, partial uninstall, manual mucking) is + // worth flagging in debug output so the user can correlate. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var staleDogfoodDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "dogfood", "pr-9999"); + Directory.CreateDirectory(staleDogfoodDir); // exists, but no bin/aspire inside + + var capturedLog = new CapturingLogger(); + var discovery = new InstallationDiscovery( + channelReader: new FakeIdentityChannelReader("local"), + sidecarReader: new InstallSidecarReader(), + peerProbe: new FakePeerInstallProbe(), + executionContext: CreateExecutionContext(workspace), + logger: capturedLog); + + await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken); + + Assert.Contains(capturedLog.Entries, e => + e.Level == LogLevel.Debug && + e.Message.Contains(staleDogfoodDir, StringComparison.Ordinal) && + e.Message.Contains("not classifying as a real install", StringComparison.Ordinal)); + } + + [Fact] + public async Task DiscoverAllAsync_UnreadableDogfoodRoot_DoesNotFailDiscovery() + { + if (OperatingSystem.IsWindows()) + { + Assert.Skip("Unix file modes are required to create a deterministic unreadable directory."); + return; + } + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var dogfoodRoot = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "dogfood"); + Directory.CreateDirectory(dogfoodRoot); + var originalMode = File.GetUnixFileMode(dogfoodRoot); + + try + { + File.SetUnixFileMode(dogfoodRoot, UnixFileMode.None); + + var capturedLog = new CapturingLogger(); + var discovery = new InstallationDiscovery( + channelReader: new FakeIdentityChannelReader("local"), + sidecarReader: new InstallSidecarReader(), + peerProbe: new FakePeerInstallProbe(), + executionContext: CreateExecutionContext(workspace), + logger: capturedLog); + + var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken); + + Assert.NotEmpty(results); + Assert.Equal(InstallationInfoStatus.Ok, results[0].Status); + Assert.Contains(capturedLog.Entries, e => + e.Level == LogLevel.Debug && + e.Message.Contains("failed to enumerate directories", StringComparison.Ordinal) && + e.Message.Contains(dogfoodRoot, StringComparison.Ordinal)); + } + finally + { + File.SetUnixFileMode(dogfoodRoot, originalMode | UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + } + + [Fact] + public async Task DiscoverAllAsync_UnreadableDotnetToolStore_DoesNotFailDiscovery() + { + if (OperatingSystem.IsWindows()) + { + Assert.Skip("Unix file modes are required to create a deterministic unreadable directory."); + return; + } + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var toolStore = Path.Combine(workspace.WorkspaceRoot.FullName, ".dotnet", "tools", ".store", "aspire.cli"); + Directory.CreateDirectory(toolStore); + var originalMode = File.GetUnixFileMode(toolStore); + + try + { + File.SetUnixFileMode(toolStore, UnixFileMode.None); + + var capturedLog = new CapturingLogger(); + var discovery = new InstallationDiscovery( + channelReader: new FakeIdentityChannelReader("local"), + sidecarReader: new InstallSidecarReader(), + peerProbe: new FakePeerInstallProbe(), + executionContext: CreateExecutionContext(workspace), + logger: capturedLog); + + var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken); + + // The contract is "an unreadable tool store does not break discovery": + // self still resolves, and no entry from the unreadable tree leaks + // into the result set. The candidate source uses + // EnumerationOptions.IgnoreInaccessible to silently skip the + // inaccessible root, so MoveNext returns false and the walk + // produces zero candidates — there is intentionally no error log + // to assert on for this case (an unreadable subtree is exactly + // the IgnoreInaccessible scenario, not an error). + Assert.NotEmpty(results); + Assert.Equal(InstallationInfoStatus.Ok, results[0].Status); + Assert.DoesNotContain(results, info => + info.Path.StartsWith(toolStore, StringComparison.Ordinal) || + (info.CanonicalPath?.StartsWith(toolStore, StringComparison.Ordinal) ?? false)); + } + finally + { + File.SetUnixFileMode(toolStore, originalMode | UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + } + + [Fact] + public async Task DiscoverAllAsync_RunningCliIsAlwaysFirst() + { + // Self must appear first regardless of what walks find — both for + // the table display contract ("(current)" marker) and to keep peer + // dedup deterministic. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var discovery = NewDiscovery(new FakePeerInstallProbe(), workspace); + var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken); + + Assert.NotEmpty(results); + Assert.Equal(InstallationInfoStatus.Ok, results[0].Status); + var canonicalSelf = ResolveCanonicalProcessPath(); + Assert.Equal(canonicalSelf, results[0].CanonicalPath, StringComparer.OrdinalIgnoreCase); + } + + [Fact] + public async Task DiscoverAllAsync_RunningCliUsesIdentityChannel() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var discovery = NewDiscovery(new FakePeerInstallProbe(), workspace, identityChannel: "staging"); + var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken); + + Assert.NotEmpty(results); + Assert.Equal("staging", results[0].Channel); + } + + [Fact] + public async Task DiscoverAllAsync_HonorsExecutionContextHomeDirectory_WithoutEnvOverride() + { + // Regression: discovery must read home from the injected + // CliExecutionContext, not from Environment.GetFolderPath / + // USERPROFILE. The HOME/USERPROFILE env-var override technique is + // platform-conditional — it propagates through GetFolderPath on + // Unix but not on Windows (where GetFolderPath reads from the + // security token). Injecting via CliExecutionContext.HomeDirectory + // is the only redirection that works uniformly. This test + // intentionally does NOT set HOME/USERPROFILE so it would fail on + // Windows pre-fix. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var releaseBinDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "bin"); + Directory.CreateDirectory(releaseBinDir); + var binary = WriteFakeBinary(releaseBinDir); + File.WriteAllText(Path.Combine(releaseBinDir, InstallSidecarReader.SidecarFileName), "{\"source\":\"script\"}"); + + var probe = new FakePeerInstallProbe(new Dictionary + { + [binary] = new PeerProbeResult.Ok(new InstallationInfo + { + Path = binary, + Version = "13.4.0", + Channel = "stable", + Route = "script", + Status = InstallationInfoStatus.Ok, + }), + }); + + var discovery = NewDiscovery(probe, workspace); + var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken); + + Assert.Contains(results, r => + string.Equals(r.CanonicalPath, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); + } + + [Fact] + public async Task DiscoverAllAsync_UsesAspireHomeDirectoryForPortableInstallPrefixes() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var userHome = workspace.CreateDirectory("user-home"); + var aspireHome = workspace.CreateDirectory("portable-home"); + var releaseBinDir = Path.Combine(aspireHome.FullName, "bin"); + Directory.CreateDirectory(releaseBinDir); + var binary = WriteFakeBinary(releaseBinDir); + File.WriteAllText(Path.Combine(releaseBinDir, InstallSidecarReader.SidecarFileName), "{\"source\":\"localhive\"}"); + + var probe = new FakePeerInstallProbe(new Dictionary + { + [binary] = new PeerProbeResult.Ok(new InstallationInfo + { + Path = binary, + Version = "13.4.0", + Channel = "local", + Route = "localhive", + Status = InstallationInfoStatus.Ok, + }), + }); + var root = workspace.WorkspaceRoot; + var context = new CliExecutionContext( + workingDirectory: root, + hivesDirectory: root, + cacheDirectory: root, + sdksDirectory: root, + logsDirectory: root, + logFilePath: Path.Combine(root.FullName, "test.log"), + homeDirectory: userHome, + aspireHomeDirectory: aspireHome); + var discovery = new InstallationDiscovery( + channelReader: new FakeIdentityChannelReader("local"), + sidecarReader: new InstallSidecarReader(), + peerProbe: probe, + executionContext: context, + logger: NullLogger.Instance); + + var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken); + + Assert.Contains(results, r => + string.Equals(r.CanonicalPath, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); + } + + [Fact] + public async Task DiscoverAllAsync_OnMacOS_DedupsSelfAgainstPathHit_AcrossFirmlinkBoundary() + { + // Bug A regression: when Environment.ProcessPath comes back firmlinked + // (/private/var/...) but the same physical binary is discovered via $PATH + // in the un-firmlinked form (/var/...), the dedup `seen` HashSet treats + // them as distinct strings and the same install lands in the table twice. + // The fix lives in CliPathHelper.ResolveSymlinkToFullPath which strips + // macOS firmlink prefixes; this test pins the end-to-end behavior so + // a regression in either the helper or the dedup site is caught. + Assert.SkipUnless(OperatingSystem.IsMacOS(), "Firmlink dedup only applies on macOS where /var → /private/var is a firmlink."); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var binDir = Path.Combine(workspace.WorkspaceRoot.FullName, "firmlink-self-bin"); + Directory.CreateDirectory(binDir); + var binary = WriteTrustedFakeBinary(binDir, "pr"); + + // Drop the binary's bin directory at the front of PATH so the PATH walk + // discovers it. PATH entries don't pass through firmlinking, so this + // appears to discovery as /var/folders/.../aspire. + using var pathOverride = new EnvVarOverride( + "PATH", + binDir + Path.PathSeparator + (Environment.GetEnvironmentVariable("PATH") ?? string.Empty)); + + // Simulate the macOS .NET runtime: Environment.ProcessPath always comes + // back realpath-resolved on macOS, which collapses /var → /private/var. + // Both forms resolve to the same physical file via APFS firmlinks. + var firmlinkedProcessPath = "/private" + binary; + Assert.True(File.Exists(firmlinkedProcessPath), "firmlinked /private/var path should resolve to the same file"); + + var probe = new FakePeerInstallProbe(new Dictionary + { + // The probe is keyed on the canonical (firmlink-stripped) path. + // If dedup is broken, the second row will be probed too and the + // result count will exceed 1. + [binary] = new PeerProbeResult.Ok(new InstallationInfo + { + Path = binary, + CanonicalPath = binary, + Version = "test-version", + Channel = "pr-99999", + Route = "pr", + Status = InstallationInfoStatus.Ok, + }), + }); + var discovery = NewDiscovery(probe, workspace); + + var results = await discovery.DiscoverAllAsync(firmlinkedProcessPath, TestContext.Current.CancellationToken); + + // Exactly one row for this physical binary across every canonical + // form that could point at it. Pre-fix the self row would carry the + // firmlinked canonical (`/private/var/...`) while the $PATH-walked + // candidate would carry the un-firmlinked form (`/var/...`), and + // both would land in `results` because the dedup `seen` HashSet + // saw two distinct strings. Match by either form so a regression + // shows up as a count of two. + var matching = results + .Where(r => + string.Equals(r.CanonicalPath, binary, StringComparison.Ordinal) || + string.Equals(r.CanonicalPath, firmlinkedProcessPath, StringComparison.Ordinal)) + .ToList(); + Assert.Single(matching); + var row = matching[0]; + // Self surfaces the un-firmlinked path everywhere (Path, + // CanonicalPath) so the displayed table is consistent with peer + // rows (PATH walks return un-firmlinked entries; candidate sources + // derive from the firmlink-stripped AspireHome). + Assert.Equal(binary, row.Path); + Assert.Equal(binary, row.CanonicalPath); + Assert.DoesNotContain("/private/", row.Path, StringComparison.Ordinal); + Assert.DoesNotContain("/private/", row.CanonicalPath!, StringComparison.Ordinal); + // Self is `active` on PATH (first hit), not `notOnPath`. Pre-fix, the + // pathStatus comparison used distinct strings and would land on + // notOnPath even though the binary was clearly on PATH. + Assert.Equal(InstallationPathStatus.Active, row.PathStatus); + } + + [Fact] + public async Task DiscoverAllAsync_UsesPreResolvedCanonicalHint_WithoutReResolving() + { + // Pins the contract that DiscoverAllAsync uses InstallationDiscoveryCandidate.CanonicalPath + // verbatim instead of re-resolving via CliPathHelper. We construct a candidate whose + // CanonicalPath is a deliberately-different non-existent path (no real symlink involved): + // if DiscoverAllAsync were re-resolving, ResolveSymlinkToFullPath would return the real + // BinaryPath (or an empty string for a fake path) and the resulting row's CanonicalPath + // would not match the hint. This is the strongest way to pin "we used the hint" because + // there's no filesystem trick that could produce this canonical from the binary on disk. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var binDir = Path.Combine(workspace.WorkspaceRoot.FullName, "hint-bin"); + Directory.CreateDirectory(binDir); + var binary = WriteFakeBinary(binDir); + var fakeCanonical = Path.Combine(workspace.WorkspaceRoot.FullName, "deliberately-different-canonical-path", "aspire"); + + var probe = new FakePeerInstallProbe(); + var hintSource = new FixedCandidateSource(new InstallationDiscoveryCandidate(binary, "test-hint", fakeCanonical)); + var discovery = new InstallationDiscovery( + channelReader: new FakeIdentityChannelReader("local"), + sidecarReader: new InstallSidecarReader(), + peerProbe: probe, + executionContext: CreateExecutionContext(workspace), + logger: NullLogger.Instance, + candidateSources: [hintSource]); + + var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken); + + // The candidate row carries CanonicalPath = fakeCanonical, proving DiscoverAllAsync + // did NOT re-resolve via ResolveSymlinkToFullPath (which would have returned `binary` + // or empty). The Path field carries the original BinaryPath unchanged. + var hintRow = Assert.Single(results, r => string.Equals(r.Path, binary, StringComparison.Ordinal)); + Assert.Equal(fakeCanonical, hintRow.CanonicalPath); + } + + private sealed class FixedCandidateSource(params InstallationDiscoveryCandidate[] candidates) : IInstallationCandidateSource + { + public IEnumerable GetCandidates(InstallationCandidateContext context) => candidates; + } + + private static string ResolveCanonicalProcessPath() + { + var path = Environment.ProcessPath!; + try + { + var resolved = File.ResolveLinkTarget(path, returnFinalTarget: true); + return resolved?.FullName ?? Path.GetFullPath(path); + } + catch (IOException) + { + return Path.GetFullPath(path); + } + } + + private static InstallationDiscovery NewDiscovery(FakePeerInstallProbe probe, TemporaryWorkspace workspace, string identityChannel = "local") + { + return new InstallationDiscovery( + channelReader: new FakeIdentityChannelReader(identityChannel), + sidecarReader: new InstallSidecarReader(), + peerProbe: probe, + executionContext: CreateExecutionContext(workspace), + logger: NullLogger.Instance); + } + + /// + /// Builds a whose HomeDirectory + /// points at the test workspace. This is the canonical knob the + /// walk reads to resolve + /// ~/.aspire and ~/.dotnet — bypassing the developer's real + /// home directory and avoiding Windows-specific behavior of + /// + /// (which ignores USERPROFILE overrides on Windows). + /// + private static CliExecutionContext CreateExecutionContext(TemporaryWorkspace workspace) + { + var root = workspace.WorkspaceRoot; + return new CliExecutionContext( + workingDirectory: root, + hivesDirectory: root, + cacheDirectory: root, + sdksDirectory: root, + logsDirectory: root, + logFilePath: Path.Combine(root.FullName, "test.log"), + homeDirectory: root); + } + + /// + /// Writes a stub "binary" file to disk. The discovery walk only checks + /// existence; it never executes — the FakePeerInstallProbe handles + /// what would have been the spawn. + /// + private static string WriteFakeBinary(string dir) + { + var name = OperatingSystem.IsWindows() ? "aspire.exe" : "aspire"; + var path = Path.Combine(dir, name); + File.WriteAllBytes(path, [0x00]); // existence is what matters + if (!OperatingSystem.IsWindows()) + { + // Match shell semantics: a non-executable file is not a binary on PATH. + // PathLookupHelper.FindFullPathFromPath honors the executable bit on Unix, + // so without this chmod the PATH walk would skip the test fixture. + File.SetUnixFileMode(path, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + | UnixFileMode.GroupRead | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + } + return path; + } + + private static string WriteTrustedFakeBinary(string dir, string source) + { + var binary = WriteFakeBinary(dir); + File.WriteAllText(Path.Combine(dir, InstallSidecarReader.SidecarFileName), $"{{\"source\":\"{source}\"}}"); + return binary; + } +} + +/// +/// Collection definition that disables parallel execution for tests which +/// mutate process-wide environment variables (PATH, PATHEXT, HOME) and +/// would otherwise race with other tests in the assembly. +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class EnvVarMutatingTestCollection +{ + public const string Name = "EnvVarMutatingTests"; +} diff --git a/tests/Aspire.Cli.Tests/Acquisition/PeerInstallProbeTests.cs b/tests/Aspire.Cli.Tests/Acquisition/PeerInstallProbeTests.cs new file mode 100644 index 00000000000..ab636a43025 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Acquisition/PeerInstallProbeTests.cs @@ -0,0 +1,779 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Acquisition; +using Aspire.Cli.Tests.Utils; +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using System.Text; + +namespace Aspire.Cli.Tests.Acquisition; + +/// +/// Behavior tests for . These tests spawn +/// a real child process — a tiny test helper binary built into the test +/// project — to exercise the timeout / stdout-cap / kill paths against +/// real process semantics. +/// +public class PeerInstallProbeTests(ITestOutputHelper outputHelper) : IDisposable +{ + // Route internal probe diagnostics (LogDebug for "JSON without an + // installation row", "invalid JSON", etc.) into the xunit test output + // so a failure log tells us why the probe took whichever code path it + // took. Keep the factory alive for the lifetime of the test class so + // logs aren't cut off mid-probe by an early dispose. + private readonly ILoggerFactory _loggerFactory = LoggerFactory.Create(builder => builder.AddXunit(outputHelper, LogLevel.Trace)); + + public void Dispose() => _loggerFactory.Dispose(); + + private ILogger ProbeLogger => _loggerFactory.CreateLogger(); + + // Surface the actual Failed.Reason on Ok-expected assertions. Without + // this helper, Assert.IsType(result) discards the (often + // multi-line) failure reason and reports only "expected Ok, got + // Failed" — useless for diagnosing CI-only failures. + private static PeerProbeResult.Ok AssertProbeOk(PeerProbeResult result) + { + if (result is PeerProbeResult.Failed failed) + { + Assert.Fail($"Expected PeerProbeResult.Ok, got PeerProbeResult.Failed. Reason:{Environment.NewLine}{failed.Reason}"); + } + + return Assert.IsType(result); + } + + [Fact] + public async Task ProbeAsync_BinaryNotFound_ReturnsFailed() + { + var probe = new PeerInstallProbe(ProbeLogger); + using var workspace = TemporaryWorkspace.Create(outputHelper); + var missing = Path.Combine(workspace.WorkspaceRoot.FullName, "does-not-exist"); + + var result = await probe.ProbeAsync(missing, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task ProbeAsync_InvokesPeerWithDoctorSelfFormatJson() + { + // The peer must be asked to describe ONLY itself. Without --self, + // `aspire doctor` would run full installation discovery and the peer would + // recursively probe back into us — and into every other peer it + // finds — turning a single discovery invocation into a fan-out bounded + // only by the per-level timeout. `--format json` selects the + // machine-readable contract because the human-readable table is the + // default when `--format` is omitted. + using var fakePeer = FakePeerScript.BuildArgvRecorder(outputHelper); + + var probe = new PeerInstallProbe(ProbeLogger); + var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); + + AssertProbeOk(result); + Assert.NotNull(fakePeer.ArgvFile); + Assert.True(File.Exists(fakePeer.ArgvFile), $"Expected argv recorder file at {fakePeer.ArgvFile} to exist."); + var argv = await File.ReadAllLinesAsync(fakePeer.ArgvFile, TestContext.Current.CancellationToken); + Assert.Equal(["doctor", "--self", "--format", "json"], argv); + } + + [Fact] + public async Task ProbeAsync_PeerEmitsValidJsonArray_ReturnsOk() + { + using var fakePeer = FakePeerScript.Build( + outputHelper, + stdout: """ + { + "checks": [], + "summary": { "passed": 0, "warnings": 0, "failed": 0 }, + "installations": [ + { + "path": "/peer/aspire", + "version": "12.5.0", + "channel": "stable", + "route": "script", + "pathStatus": "shadowed", + "status": "ok" + } + ] + } + """, + exitCode: 0); + + var probe = new PeerInstallProbe(ProbeLogger); + var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); + + var ok = AssertProbeOk(result); + Assert.Equal("12.5.0", ok.Info.Version); + Assert.Equal("stable", ok.Info.Channel); + Assert.Equal("script", ok.Info.Route); + Assert.Equal(InstallationPathStatus.Shadowed, ok.Info.PathStatus); + } + + [Fact] + public async Task ProbeAsync_PeerOmitsPathStatus_DefaultsToNotOnPath() + { + using var fakePeer = FakePeerScript.Build( + outputHelper, + stdout: """ + [ + { + "path": "/peer/aspire", + "version": "12.5.0", + "status": "ok" + } + ] + """, + exitCode: 0); + + var probe = new PeerInstallProbe(ProbeLogger); + var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); + + var ok = AssertProbeOk(result); + Assert.Equal(InstallationPathStatus.NotOnPath, ok.Info.PathStatus); + } + + [Fact] + public async Task ProbeAsync_PeerEmitsInvalidPathStatus_DefaultsToNotOnPath() + { + using var fakePeer = FakePeerScript.Build( + outputHelper, + stdout: """ + [ + { + "path": "/peer/aspire", + "version": "12.5.0", + "pathStatus": 123, + "status": "ok" + } + ] + """, + exitCode: 0); + + var probe = new PeerInstallProbe(ProbeLogger); + var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); + + var ok = AssertProbeOk(result); + Assert.Equal(InstallationPathStatus.NotOnPath, ok.Info.PathStatus); + } + + [Fact] + public async Task ProbeAsync_PeerExitsNonZero_ReturnsFailedWhenVersionAlsoFails() + { + // doctor path scripted to exit 7; --version not supported by this + // script (the default EmitExit body) → fallback path also fails + // and the user sees the failure. + using var fakePeer = FakePeerScript.Build(outputHelper, stdout: "{}", exitCode: 7); + + var failed = await ProbeFakeFailureAsync(fakePeer); + + Assert.Contains("code 7", failed.Reason); + } + + [Fact] + public async Task ProbeAsync_PeerExitsNonZero_IncludesCapturedStderr() + { + using var fakePeer = FakePeerScript.Build(outputHelper, stdout: "{}", stderr: "peer exploded", exitCode: 7); + + var failed = await ProbeFakeFailureAsync(fakePeer); + + Assert.Contains("Peer exited with code 7", failed.Reason, StringComparison.Ordinal); + Assert.Contains("stderr: peer exploded", failed.Reason, StringComparison.Ordinal); + } + + [Fact] + public async Task ProbeAsync_PeerFailureStderr_StripsAnsiEscapes() + { + using var fakePeer = FakePeerScript.Build(outputHelper, stdout: "{}", stderr: "\u001b[31mhello\u001b[0m", exitCode: 7); + + var failed = await ProbeFakeFailureAsync(fakePeer); + + Assert.Contains("stderr: hello", failed.Reason, StringComparison.Ordinal); + Assert.DoesNotContain("\u001b[31m", failed.Reason, StringComparison.Ordinal); + Assert.DoesNotContain("\u001b[0m", failed.Reason, StringComparison.Ordinal); + } + + [Fact] + public async Task ProbeAsync_PeerFailureStderr_StripsControlCharactersExceptNewline() + { + using var fakePeer = FakePeerScript.Build(outputHelper, stdout: "{}", stderr: "first\0\u0001\nsecond\u0002", exitCode: 7); + + var failed = await ProbeFakeFailureAsync(fakePeer); + + Assert.Contains("stderr: first\nsecond", failed.Reason, StringComparison.Ordinal); + Assert.DoesNotContain("\0", failed.Reason, StringComparison.Ordinal); + Assert.DoesNotContain("\u0001", failed.Reason, StringComparison.Ordinal); + Assert.DoesNotContain("\u0002", failed.Reason, StringComparison.Ordinal); + } + + [Fact] + public async Task ProbeAsync_PeerFailureStderr_ReportsTruncationWhenByteCapIsExceeded() + { + using var fakePeer = FakePeerScript.BuildRepeatedStderr(outputHelper, PeerInstallProbe.OutputCap + 10, exitCode: 7); + + var failed = await ProbeFakeFailureAsync(fakePeer); + + Assert.Contains("... [truncated]", failed.Reason, StringComparison.Ordinal); + } + + [Fact] + public async Task ProbeAsync_PeerExitsNonZero_WithEmptyStderr_KeepsReasonUnchanged() + { + using var fakePeer = FakePeerScript.Build(outputHelper, stdout: "{}", stderr: string.Empty, exitCode: 7); + + var failed = await ProbeFakeFailureAsync(fakePeer); + + Assert.Equal("Peer exited with code 7 (and --version fallback).", failed.Reason); + } + + [Fact] + public async Task ProbeAsync_PeerExitsNonZero_FallsBackToVersionAndReturnsPartialOk() + { + // Older peers (predating rich self-probe support) exit non-zero for + // the primary probe but support `--version`. The probe must fall back so we + // still surface a version string for those installs. + using var fakePeer = FakePeerScript.BuildDoctorOrVersion( + outputHelper, + doctorStdout: string.Empty, + doctorExitCode: 1, + versionStdout: "13.4.0-pr.16817.g790d6fa3\n", + versionExitCode: 0); + + var probe = new PeerInstallProbe(ProbeLogger); + var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); + + var ok = AssertProbeOk(result); + Assert.Equal("13.4.0-pr.16817.g790d6fa3", ok.Info.Version); + // Fallback can't read route or channel from the older peer; the + // discovery layer overlays the route from the local sidecar. + Assert.Null(ok.Info.Channel); + } + + [Fact] + public async Task ProbeAsync_BothInfoAndVersionFail_ReturnsFailed() + { + // When both attempts fail, the primary failure reason is what the + // user sees (with a (and --version fallback) suffix so they know + // we tried). + using var fakePeer = FakePeerScript.BuildDoctorOrVersion( + outputHelper, + doctorStdout: string.Empty, + doctorExitCode: 1, + versionStdout: string.Empty, + versionExitCode: 1); + + var probe = new PeerInstallProbe(ProbeLogger); + var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); + + var failed = Assert.IsType(result); + Assert.Contains("--version fallback", failed.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ProbeAsync_PeerEmitsEmptyArray_FallsBackToVersion() + { + // Empty rich output is treated as "doctor didn't tell us anything useful" + // and triggers the --version fallback. With no version response + // scripted either, the overall probe fails. + using var fakePeer = FakePeerScript.BuildDoctorOrVersion( + outputHelper, + doctorStdout: "[]", + doctorExitCode: 0, + versionStdout: string.Empty, + versionExitCode: 1); + + var probe = new PeerInstallProbe(ProbeLogger); + var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task ProbeAsync_PeerEmitsInvalidJson_FallsBackToVersion() + { + // Invalid JSON on the doctor path is treated as a peer failure mode + // where the command emits help / error text, and triggers the + // --version fallback. + using var fakePeer = FakePeerScript.BuildDoctorOrVersion( + outputHelper, + doctorStdout: "not json at all", + doctorExitCode: 0, + versionStdout: "9.0.0\n", + versionExitCode: 0); + + var probe = new PeerInstallProbe(ProbeLogger); + var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); + + var ok = AssertProbeOk(result); + Assert.Equal("9.0.0", ok.Info.Version); + } + + [Theory] + [InlineData("[1]", "number")] + [InlineData("[null]", "null")] + [InlineData("[\"string\"]", "string")] + [InlineData("[[]]", "nested array")] + public async Task ProbeAsync_PeerEmitsArrayWithNonObjectFirstElement_FallsBackToVersion(string doctorStdout, string kind) + { + // The peer emitted a syntactically valid JSON array but the first + // element is not an object. InstallationInfoParser.Parse calls + // TryGetProperty on the element, which throws InvalidOperationException + // for non-object kinds — that would otherwise abort the whole + // discovery walk for the caller. The probe must treat it as a + // wrong-shape response and fall back to --version. + _ = kind; // surfaced in test name for debuggability + using var fakePeer = FakePeerScript.BuildDoctorOrVersion( + outputHelper, + doctorStdout: doctorStdout, + doctorExitCode: 0, + versionStdout: "9.0.0\n", + versionExitCode: 0); + + var probe = new PeerInstallProbe(ProbeLogger); + var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); + + var ok = AssertProbeOk(result); + Assert.Equal("9.0.0", ok.Info.Version); + } + + [Fact] + public async Task ProbeAsync_PeerHangs_TimesOutAndKills() + { + // Sleep significantly longer than the probe timeout we configure so + // the timeout path is the one that completes the await. + using var fakePeer = FakePeerScript.BuildSleeper(outputHelper, sleepSeconds: 30); + + // Construct a probe with a deliberately tight timeout so the test + // doesn't have to wait the production 5s budget. + var probe = new PeerInstallProbe(TimeSpan.FromMilliseconds(300), ProbeLogger); + var sw = System.Diagnostics.Stopwatch.StartNew(); + var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); + sw.Stop(); + + var failed = Assert.IsType(result); + Assert.Contains("timed out", failed.Reason, StringComparison.OrdinalIgnoreCase); + Assert.True(sw.Elapsed < TimeSpan.FromSeconds(5), + $"Expected probe to return within a few seconds after timing out; took {sw.Elapsed}."); + } + + [Fact] + public async Task ProbeAsync_CallerCancels_KillsSpawnedProcess() + { + Assert.SkipWhen(OperatingSystem.IsWindows(), + "This regression test records the shell process id using POSIX $$; Windows process-tree cancellation is covered by production code."); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var pidFile = Path.Combine(workspace.WorkspaceRoot.FullName, "peer.pid"); + using var fakePeer = FakePeerScript.BuildSleeperWithPidFile(outputHelper, pidFile, sleepSeconds: 30); + + var probe = new PeerInstallProbe(TimeSpan.FromSeconds(30), ProbeLogger); + using var cts = new CancellationTokenSource(); + var probeTask = probe.ProbeAsync(fakePeer.Path, cts.Token); + + using var process = await WaitForProcessIdAsync(pidFile, TestContext.Current.CancellationToken); + cts.Cancel(); + + await Assert.ThrowsAsync(() => probeTask); + await WaitForExitAsync(process, TestContext.Current.CancellationToken); + + Assert.True(process.HasExited); + } + + private async Task ProbeFakeFailureAsync(FakeScriptResult fakePeer) + { + // Spawn the production probe against the scripted peer and assert the + // result is Failed. Centralizing the spawn + assertion keeps each + // negative-path test focused on the failure reason it cares about. + // + // The 30s timeout is well above the production 5s default. Under heavy + // CI load (saturated CPU, slow disk) the fake peer script — which on + // Windows shells out to powershell.exe to emit raw stderr bytes — can + // take several seconds just to start. These tests are about how the + // probe formats a Failed result from real peer stderr/exit semantics, + // not about the timeout behavior (see ProbeAsync_PeerHangs_TimesOutAndKills + // for that). A wider budget here removes the CI flake without changing + // what's being tested. + var probe = new PeerInstallProbe(TimeSpan.FromSeconds(30), ProbeLogger); + var result = await probe.ProbeAsync(fakePeer.Path, TestContext.Current.CancellationToken); + return Assert.IsType(result); + } + + private static async Task WaitForProcessIdAsync(string pidFile, CancellationToken cancellationToken) + { + while (true) + { + if (File.Exists(pidFile)) + { + var pidText = await File.ReadAllTextAsync(pidFile, cancellationToken); + if (int.TryParse(pidText.Trim(), System.Globalization.CultureInfo.InvariantCulture, out var pid)) + { + return Process.GetProcessById(pid); + } + } + + await Task.Delay(20, cancellationToken); + } + } + + private static async Task WaitForExitAsync(Process process, CancellationToken cancellationToken) + { + var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5); + while (!process.HasExited && DateTimeOffset.UtcNow < deadline) + { + await Task.Delay(20, cancellationToken); + process.Refresh(); + } + } +} + +/// +/// Builds a tiny shell/batch script in a temp dir that emits scripted +/// stdout/stderr and exits with a given code. Used as a stand-in peer in +/// PeerInstallProbeTests so we don't have to spawn a real Aspire CLI. +/// +internal static class FakePeerScript +{ + /// + /// Produces a script that writes verbatim + /// and exits with . The script dispatches on + /// its first argument, so it works with both the probe's + /// doctor --self --format json invocation and the --version + /// fallback. + /// + internal static FakeScriptResult Build(ITestOutputHelper outputHelper, string stdout, int exitCode) + { + return Build(outputHelper, stdout, stderr: string.Empty, exitCode); + } + + internal static FakeScriptResult Build(ITestOutputHelper outputHelper, string stdout, string stderr, int exitCode) + { + return BuildInternal(outputHelper, body: ScriptBody.EmitAndExit(stdout, stderr, exitCode)); + } + + /// + /// Builds a script that responds differently to doctor vs + /// --version arguments so PeerInstallProbeTests can exercise + /// the rich-probe → version fallback path. + /// + internal static FakeScriptResult BuildDoctorOrVersion( + ITestOutputHelper outputHelper, + string doctorStdout, + int doctorExitCode, + string versionStdout, + int versionExitCode) + { + return BuildInternal(outputHelper, body: ScriptBody.DoctorOrVersion( + doctorStdout, doctorExitCode, versionStdout, versionExitCode)); + } + + internal static FakeScriptResult BuildSleeper(ITestOutputHelper outputHelper, int sleepSeconds) + { + return BuildInternal(outputHelper, body: ScriptBody.Sleep(sleepSeconds)); + } + + internal static FakeScriptResult BuildSleeperWithPidFile(ITestOutputHelper outputHelper, string pidFile, int sleepSeconds) + { + return BuildInternal(outputHelper, body: ScriptBody.SleepWithPidFile(pidFile, sleepSeconds)); + } + + internal static FakeScriptResult BuildRepeatedStderr(ITestOutputHelper outputHelper, int byteCount, int exitCode) + { + return BuildInternal(outputHelper, body: ScriptBody.StderrRepeat(byteCount, exitCode)); + } + + /// + /// Builds a script that records each positional argument (one per line) + /// to an in-workspace file and then emits a minimal valid doctor JSON so + /// the probe completes via the primary path without falling back to + /// --version. The recorded argv file path is exposed on the + /// returned . + /// + internal static FakeScriptResult BuildArgvRecorder(ITestOutputHelper outputHelper) + { + var workspace = TemporaryWorkspace.Create(outputHelper); + var argvFile = Path.Combine(workspace.WorkspaceRoot.FullName, "argv.txt"); + var path = OperatingSystem.IsWindows() + ? Path.Combine(workspace.WorkspaceRoot.FullName, "peer.cmd") + : Path.Combine(workspace.WorkspaceRoot.FullName, "peer"); + + var body = ScriptBody.ArgvRecorder(argvFile); + var content = OperatingSystem.IsWindows() ? body.RenderBatch() : body.RenderShell(); + File.WriteAllText(path, content); + + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(path, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + } + + DumpScript(outputHelper, path, content); + return new FakeScriptResult(path, workspace, ArgvFile: argvFile); + } + + private static FakeScriptResult BuildInternal(ITestOutputHelper outputHelper, ScriptBody body) + { + var workspace = TemporaryWorkspace.Create(outputHelper); + var path = OperatingSystem.IsWindows() + ? Path.Combine(workspace.WorkspaceRoot.FullName, "peer.cmd") + : Path.Combine(workspace.WorkspaceRoot.FullName, "peer"); + + var content = OperatingSystem.IsWindows() ? body.RenderBatch() : body.RenderShell(); + File.WriteAllText(path, content); + + if (!OperatingSystem.IsWindows()) + { + // chmod +x for /bin/sh execution. File.SetUnixFileMode is the + // .NET-supported way to do this on Unix. + File.SetUnixFileMode(path, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + } + + DumpScript(outputHelper, path, content); + return new FakeScriptResult(path, workspace); + } + + // Write the rendered script body to the test output so a failed run's + // log shows exactly what the probe executed (and where). xUnit only + // surfaces test output for failing tests, so passing runs aren't + // affected. + private static void DumpScript(ITestOutputHelper outputHelper, string path, string content) + { + outputHelper.WriteLine($"[FakePeerScript] --- begin script at {path} ---"); + outputHelper.WriteLine(content); + outputHelper.WriteLine($"[FakePeerScript] --- end script at {path} ---"); + } +} + +internal sealed record FakeScriptResult(string Path, TemporaryWorkspace Workspace, string? ArgvFile = null) : IDisposable +{ + public void Dispose() => Workspace.Dispose(); +} + +internal abstract record ScriptBody +{ + public abstract string RenderShell(); + public abstract string RenderBatch(); + + public static ScriptBody EmitAndExit(string stdout, string stderr, int exitCode) => new EmitExit(stdout, stderr, exitCode); + public static ScriptBody Sleep(int seconds) => new SleepScript(seconds); + public static ScriptBody SleepWithPidFile(string pidFile, int seconds) => new SleepWithPidFileScript(pidFile, seconds); + public static ScriptBody StderrRepeat(int byteCount, int exitCode) => new StderrRepeatScript(byteCount, exitCode); + public static ScriptBody DoctorOrVersion(string doctorStdout, int doctorExitCode, string versionStdout, int versionExitCode) + => new DoctorOrVersionScript(doctorStdout, doctorExitCode, versionStdout, versionExitCode); + public static ScriptBody ArgvRecorder(string argvFile) => new ArgvRecorderScript(argvFile); + + private sealed record EmitExit(string Stdout, string Stderr, int ExitCode) : ScriptBody + { + public override string RenderShell() + { + // The script behaves differently based on its first arg: + // - "doctor" → emit the scripted stdout and exit with the scripted code + // - anything else (e.g. "--version") → emit nothing and exit 127 + // This lets PeerInstallProbeTests isolate the "rich probe failed" + // case without the fallback `--version` accidentally succeeding + // by virtue of the script ignoring its args. + return $""" + #!/bin/sh + if [ "$1" != "doctor" ]; then + exit 127 + fi + cat <<'__ASPIRE_PEER_EOF__' + {Stdout} + __ASPIRE_PEER_EOF__ + {RenderShellStderr(Stderr)} + exit {ExitCode} + """; + } + + public override string RenderBatch() + { + var lines = Stdout.Split('\n'); + var sb = new System.Text.StringBuilder(); + sb.AppendLine("@echo off"); + sb.AppendLine("if not \"%~1\" == \"doctor\" exit /b 127"); + foreach (var line in lines) + { + sb.Append("echo ").AppendLine(line.TrimEnd('\r')); + } + AppendBatchStderr(sb, Stderr); + sb.AppendLine($"exit /b {ExitCode}"); + return sb.ToString(); + } + } + + private sealed record StderrRepeatScript(int ByteCount, int ExitCode) : ScriptBody + { + public override string RenderShell() => + $""" + #!/bin/sh + if [ "$1" != "doctor" ]; then + exit 127 + fi + dd if=/dev/zero bs={ByteCount} count=1 2>/dev/null | LC_ALL=C tr '\000' 'x' 1>&2 + exit {ExitCode} + """; + + public override string RenderBatch() + { + var sb = new StringBuilder(); + sb.AppendLine("@echo off"); + sb.AppendLine("if not \"%~1\" == \"doctor\" exit /b 127"); + sb.AppendLine($"powershell -NoProfile -ExecutionPolicy Bypass -Command \"[Console]::Error.Write(('x' * {ByteCount}))\""); + sb.AppendLine($"exit /b {ExitCode}"); + return sb.ToString(); + } + } + + private sealed record SleepScript(int Seconds) : ScriptBody + { + public override string RenderShell() => + $""" + #!/bin/sh + sleep {Seconds} + """; + + public override string RenderBatch() => + // Built-in timeout /t requires interactive console handling + // sometimes; ping localhost is the conventional sleep stand-in. + $""" + @echo off + ping -n {Seconds + 1} 127.0.0.1 > nul + """; + } + + private sealed record SleepWithPidFileScript(string PidFile, int Seconds) : ScriptBody + { + public override string RenderShell() => + $$""" + #!/bin/sh + printf '%s\n' "$$" > '{{PidFile}}' + sleep {{Seconds}} + """; + + public override string RenderBatch() => + throw new PlatformNotSupportedException("POSIX pid-file sleeper is not supported on Windows."); + } + + private sealed record DoctorOrVersionScript(string DoctorStdout, int DoctorExitCode, string VersionStdout, int VersionExitCode) : ScriptBody + { + public override string RenderShell() + { + return $""" + #!/bin/sh + if [ "$1" = "doctor" ]; then + cat <<'__ASPIRE_DOCTOR_EOF__' + {DoctorStdout} + __ASPIRE_DOCTOR_EOF__ + exit {DoctorExitCode} + fi + if [ "$1" = "--version" ]; then + cat <<'__ASPIRE_VERSION_EOF__' + {VersionStdout} + __ASPIRE_VERSION_EOF__ + exit {VersionExitCode} + fi + exit 127 + """; + } + + public override string RenderBatch() + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine("@echo off"); + sb.AppendLine("if \"%~1\" == \"doctor\" goto :doctor"); + sb.AppendLine("if \"%~1\" == \"--version\" goto :version"); + sb.AppendLine("exit /b 127"); + sb.AppendLine(":doctor"); + foreach (var line in DoctorStdout.Split('\n')) + { + sb.Append("echo ").AppendLine(line.TrimEnd('\r')); + } + sb.AppendLine($"exit /b {DoctorExitCode}"); + sb.AppendLine(":version"); + foreach (var line in VersionStdout.Split('\n')) + { + sb.Append("echo ").AppendLine(line.TrimEnd('\r')); + } + sb.AppendLine($"exit /b {VersionExitCode}"); + return sb.ToString(); + } + } + + private sealed record ArgvRecorderScript(string ArgvFile) : ScriptBody + { + // Minimal valid doctor JSON: enough for the probe to take the primary + // path (no fallback to --version), so the recorded argv reflects the + // first invocation only. + private const string DoctorJson = """{"checks":[],"summary":{"passed":0,"warnings":0,"failed":0},"installations":[{"path":"/peer/aspire","version":"1.0.0","status":"ok"}]}"""; + + public override string RenderShell() + { + // POSIX: truncate the recorder file, then write one arg per line, + // honoring quoted args via "$@" (not $*) so multi-word args round-trip. + return $$""" + #!/bin/sh + : > "{{ArgvFile}}" + for a in "$@"; do + printf '%s\n' "$a" >> "{{ArgvFile}}" + done + cat <<'__ASPIRE_PEER_EOF__' + {{DoctorJson}} + __ASPIRE_PEER_EOF__ + exit 0 + """; + } + + public override string RenderBatch() + { + // Batch: shift through %1 until empty, appending each arg on its + // own line. type nul > creates an empty file (truncate). + var sb = new System.Text.StringBuilder(); + sb.AppendLine("@echo off"); + sb.AppendLine($"type nul > \"{ArgvFile}\""); + sb.AppendLine(":loop"); + sb.AppendLine("if \"%~1\"==\"\" goto :emit"); + sb.AppendLine($"echo %~1>>\"{ArgvFile}\""); + sb.AppendLine("shift"); + sb.AppendLine("goto :loop"); + sb.AppendLine(":emit"); + sb.AppendLine($"echo {DoctorJson}"); + sb.AppendLine("exit /b 0"); + return sb.ToString(); + } + } + + private static string RenderShellStderr(string stderr) + { + if (stderr.Length == 0) + { + return string.Empty; + } + + return $"printf '{ToShellPrintfEscaped(stderr)}' 1>&2"; + } + + private static string ToShellPrintfEscaped(string value) + { + var builder = new StringBuilder(); + foreach (var valueByte in Encoding.UTF8.GetBytes(value)) + { + builder.Append('\\').Append(Convert.ToString(valueByte, 8).PadLeft(3, '0')); + } + + return builder.ToString(); + } + + private static void AppendBatchStderr(StringBuilder sb, string stderr) + { + if (stderr.Length == 0) + { + return; + } + + var encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(stderr)); + sb.AppendLine($"powershell -NoProfile -ExecutionPolicy Bypass -Command \"$bytes=[Convert]::FromBase64String('{encoded}'); [Console]::Error.Write([Text.Encoding]::UTF8.GetString($bytes))\""); + } +} diff --git a/tests/Aspire.Cli.Tests/BundleServiceComputeDefaultExtractDirTests.cs b/tests/Aspire.Cli.Tests/BundleServiceComputeDefaultExtractDirTests.cs index 1ef288ae942..864908f2c0c 100644 --- a/tests/Aspire.Cli.Tests/BundleServiceComputeDefaultExtractDirTests.cs +++ b/tests/Aspire.Cli.Tests/BundleServiceComputeDefaultExtractDirTests.cs @@ -17,14 +17,16 @@ public class BundleServiceComputeDefaultExtractDirTests { private const string SidecarFileName = ".aspire-install.json"; - [Fact] - public void ComputeDefaultExtractDir_ScriptSource_ReturnsParentOfBinaryDir() + [Theory] + [InlineData("script")] // get-aspire-cli.{sh,ps1} + [InlineData("localhive")] // localhive.{sh,ps1} + public void ComputeDefaultExtractDir_SharedPrefixSource_ReturnsParentOfBinaryDir(string source) { - // get-aspire-cli.{sh,ps1} writes the sidecar next to the binary at - // /bin/.aspire-install.json with source=script. Extraction must - // land at / (parent of the binary's dir) so the eventual - // versions// tree sits next to the bin/ directory rather than - // inside it. + // Both the get-aspire-cli script route and the localhive route lay the + // CLI out at /bin/aspire with a colocated .aspire-install.json, + // so extraction must land at / (parent of the binary's dir) so + // the eventual versions// tree sits next to the bin/ directory + // rather than inside it. using var temp = new TestTempDirectory(); var prefixDir = Path.Combine(temp.Path, "aspire"); var binDir = Path.Combine(prefixDir, "bin"); @@ -32,7 +34,7 @@ public void ComputeDefaultExtractDir_ScriptSource_ReturnsParentOfBinaryDir() var binaryPath = Path.Combine(binDir, ExeName("aspire")); File.WriteAllText(binaryPath, string.Empty); - File.WriteAllText(Path.Combine(binDir, SidecarFileName), "{\"source\":\"script\"}"); + File.WriteAllText(Path.Combine(binDir, SidecarFileName), $"{{\"source\":\"{source}\"}}"); var result = BundleService.ComputeDefaultExtractDir(binaryPath); diff --git a/tests/Aspire.Cli.Tests/Bundles/BundleServiceCrossRouteExtractionTests.cs b/tests/Aspire.Cli.Tests/Bundles/BundleServiceCrossRouteExtractionTests.cs index eff4de60c56..0722a6cdc8c 100644 --- a/tests/Aspire.Cli.Tests/Bundles/BundleServiceCrossRouteExtractionTests.cs +++ b/tests/Aspire.Cli.Tests/Bundles/BundleServiceCrossRouteExtractionTests.cs @@ -6,12 +6,12 @@ namespace Aspire.Cli.Tests.Bundles; /// -/// Verifies against +/// Verifies against /// every (source × prefix shape) combination the supported install routes produce. /// The matrix locks in the contract: the reader walks the sidecar's /// source field — and nothing else — to decide between /// binaryDir (winget / brew / dotnet-tool) and the parent of -/// binaryDir (script / pr). Missing, invalid, or unknown sidecars fall +/// binaryDir (script / pr / localhive). Missing, invalid, or unknown sidecars fall /// through to the parent-of-binary heuristic. /// public class BundleServiceCrossRouteExtractionTests @@ -35,22 +35,24 @@ public class BundleServiceCrossRouteExtractionTests [InlineData("script", ".aspire/bin/aspire", ".aspire")] // 5) PR-script canonical: per-PR dogfood prefix. [InlineData("pr", ".aspire/dogfood/pr-16817/bin/aspire", ".aspire/dogfood/pr-16817")] - // 6) Cross-route smuggle case: a brew-source sidecar dropped into a + // 6) localhive canonical: local dev hive with the same bin layout as script. + [InlineData("localhive", ".aspire/local/bin/aspire", ".aspire/local")] + // 7) Cross-route smuggle case: a brew-source sidecar dropped into a // script-layout prefix MUST resolve to binaryDir per the switch — the // reader is honest about whatever the producer put on disk. When the // producer side correctly suppresses the smuggled sidecar, this row's // input condition never arises in practice; the test verifies the // reader's behavior is well-defined and consistent for all inputs. [InlineData("brew", ".aspire/dogfood/pr-16817/bin/aspire", ".aspire/dogfood/pr-16817/bin")] - // 7) script source dropped at a flat-cellar layout (misuse, but defined): + // 8) script source dropped at a flat-cellar layout (misuse, but defined): // script maps to parent-of-binary, so the result is one level above the // cask version dir. Not a real install pattern; locks in determinism. [InlineData("script", "Caskroom/aspire/13.2.0/aspire", "Caskroom/aspire")] - // 8) No sidecar at all: fallback heuristic = parent of binaryDir. + // 9) No sidecar at all: fallback heuristic = parent of binaryDir. [InlineData(null, ".aspire/bin/aspire", ".aspire")] - // 9) Sidecar with invalid JSON: parser throws, treated as no sidecar. + // 10) Sidecar with invalid JSON: parser throws, treated as no sidecar. [InlineData("__invalid__", ".aspire/bin/aspire", ".aspire")] - // 10) Sidecar with an unknown source value: switch default arm, same as + // 11) Sidecar with an unknown source value: switch default arm, same as // missing sidecar — parent-of-binary. [InlineData("github-actions", ".aspire/bin/aspire", ".aspire")] public void ComputeDefaultExtractDir_RouteAndPrefixCombinations_ProduceExpectedExtractDir( diff --git a/tests/Aspire.Cli.Tests/CliBootstrapTests.cs b/tests/Aspire.Cli.Tests/CliBootstrapTests.cs index 3f42c218947..bd7925e7aa7 100644 --- a/tests/Aspire.Cli.Tests/CliBootstrapTests.cs +++ b/tests/Aspire.Cli.Tests/CliBootstrapTests.cs @@ -4,6 +4,7 @@ using System.Reflection; using Aspire.Cli.Acquisition; using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Tests.Utils; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -16,7 +17,7 @@ namespace Aspire.Cli.Tests; /// , registered in DI by /// . /// -public class CliBootstrapTests +public class CliBootstrapTests(ITestOutputHelper outputHelper) { private static readonly string[] s_fixedChannels = ["stable", "staging", "daily", "local"]; @@ -103,5 +104,54 @@ public async Task BuildApplication_CliExecutionContextChannel_MatchesAssemblyMet Assert.Equal(bakedChannel, context.IdentityChannel); } -} + [Fact] + public void ParseLoggingOptions_PrInstall_UsesInstallPrefixForDefaultLogsDirectory() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var installPrefix = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire-pr-test"); + var binaryPath = WriteBinaryWithSidecar(Path.Combine(installPrefix, "dogfood", "pr-17159", "bin"), InstallSourceExtensions.PrWire); + + var loggingOptions = Program.ParseLoggingOptions([], binaryPath); + + Assert.Equal(Path.Combine(installPrefix, "logs"), loggingOptions.LogsDirectory); + Assert.Equal(loggingOptions.LogsDirectory, Path.GetDirectoryName(loggingOptions.LogFilePath)); + } + + [Fact] + public void BuildCliExecutionContext_PrInstall_UsesInstallPrefixForStateDirectoriesAndKeepsIdentityChannel() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var installPrefix = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire-pr-test"); + var binaryPath = WriteBinaryWithSidecar(Path.Combine(installPrefix, "dogfood", "pr-17159", "bin"), InstallSourceExtensions.PrWire); + var logsDirectory = Path.Combine(installPrefix, "logs"); + var logFilePath = Path.Combine(logsDirectory, "aspire.log"); + + var context = Program.BuildCliExecutionContext( + debugMode: true, + logsDirectory: logsDirectory, + logFilePath: logFilePath, + channel: "pr-17159", + processPath: binaryPath); + + Assert.Equal(Path.Combine(installPrefix, "hives"), context.HivesDirectory.FullName); + Assert.Equal(Path.Combine(installPrefix, "cache"), context.CacheDirectory.FullName); + Assert.Equal(Path.Combine(installPrefix, "sdks"), context.SdksDirectory.FullName); + Assert.Equal(Path.Combine(installPrefix, "packages"), context.PackagesDirectory?.FullName); + Assert.Equal(installPrefix, context.AspireHomeDirectory.FullName); + Assert.Equal(logsDirectory, context.LogsDirectory.FullName); + Assert.Equal(logFilePath, context.LogFilePath); + Assert.True(context.DebugMode); + Assert.Equal("pr-17159", context.IdentityChannel); + } + + private static string WriteBinaryWithSidecar(string binaryDir, string source) + { + Directory.CreateDirectory(binaryDir); + var binaryPath = Path.Combine(binaryDir, OperatingSystem.IsWindows() ? "aspire.exe" : "aspire"); + File.WriteAllText(binaryPath, string.Empty); + File.WriteAllText(Path.Combine(binaryDir, InstallSidecarReader.SidecarFileName), $$"""{"source":"{{source}}"}"""); + + return binaryPath; + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index 43383bdcd1b..88978c21244 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -386,8 +386,8 @@ public async Task IntegrationSearchCommandFormatJsonWithAppHostUsesConfiguredCha options.PackagingServiceFactory = _ => new TestPackagingService { GetChannelsAsyncCallback = _ => Task.FromResult>([ - PackageChannel.CreateImplicitChannel(implicitCache), - PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [new PackageMapping("Aspire*", "daily")], dailyCache) + PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures()), + PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [new PackageMapping("Aspire*", "daily")], dailyCache, new TestFeatures()) ]) }; }); @@ -440,8 +440,8 @@ public async Task IntegrationSearchCommandFormatJsonWithAppHostUsesConfiguredSta options.PackagingServiceFactory = _ => new TestPackagingService { GetChannelsAsyncCallback = _ => Task.FromResult>([ - PackageChannel.CreateImplicitChannel(implicitCache), - PackageChannel.CreateExplicitChannel(PackageChannelNames.Staging, PackageChannelQuality.Both, [new PackageMapping("Aspire*", "staging")], stagingCache) + PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures()), + PackageChannel.CreateExplicitChannel(PackageChannelNames.Staging, PackageChannelQuality.Both, [new PackageMapping("Aspire*", "staging")], stagingCache, new TestFeatures()) ]) }; }); @@ -534,8 +534,8 @@ public async Task IntegrationSearchCommandFormatJsonWithUnpinnedAppHostUsesImpli options.PackagingServiceFactory = _ => new TestPackagingService { GetChannelsAsyncCallback = _ => Task.FromResult>([ - PackageChannel.CreateImplicitChannel(implicitCache), - PackageChannel.CreateExplicitChannel(PackageChannelNames.Staging, PackageChannelQuality.Both, [new PackageMapping("Aspire*", "staging")], stagingCache) + PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures()), + PackageChannel.CreateExplicitChannel(PackageChannelNames.Staging, PackageChannelQuality.Both, [new PackageMapping("Aspire*", "staging")], stagingCache, new TestFeatures()) ]) }; }); @@ -582,8 +582,8 @@ public async Task IntegrationListCommandFormatJsonPrefersImplicitChannelWhenMult options.PackagingServiceFactory = _ => new TestPackagingService { GetChannelsAsyncCallback = _ => Task.FromResult>([ - PackageChannel.CreateImplicitChannel(implicitCache), - PackageChannel.CreateExplicitChannel("test-hive", PackageChannelQuality.Both, [new PackageMapping("Aspire*", "test-hive")], explicitCache) + PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures()), + PackageChannel.CreateExplicitChannel("test-hive", PackageChannelQuality.Both, [new PackageMapping("Aspire*", "test-hive")], explicitCache, new TestFeatures()) ]) }; }); @@ -1800,7 +1800,7 @@ public async Task AddCommandPrompter_FiltersToHighestVersionPerPackageId() // Create a fake channel var fakeCache = new FakeNuGetPackageCache(); - var channel = PackageChannel.CreateImplicitChannel(fakeCache); + var channel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures()); // Create multiple versions of the same package var packages = new[] @@ -1848,7 +1848,7 @@ public async Task AddCommandPrompter_FiltersToHighestVersionPerChannel() // Create a fake channel var fakeCache = new FakeNuGetPackageCache(); - var channel = PackageChannel.CreateImplicitChannel(fakeCache); + var channel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures()); // Create multiple versions of the same package from same channel var packages = new[] @@ -1896,10 +1896,10 @@ public async Task AddCommandPrompter_ShowsHighestVersionPerChannelWhenMultipleCh // Create two different channels var fakeCache = new FakeNuGetPackageCache(); - var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache); + var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures()); var mappings = new[] { new PackageMapping("Aspire*", "https://preview-feed") }; - var explicitChannel = PackageChannel.CreateExplicitChannel("preview", PackageChannelQuality.Prerelease, mappings, fakeCache); + var explicitChannel = PackageChannel.CreateExplicitChannel("preview", PackageChannelQuality.Prerelease, mappings, fakeCache, new TestFeatures()); // Create packages from different channels with different versions var packages = new[] diff --git a/tests/Aspire.Cli.Tests/Commands/DoctorCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DoctorCommandTests.cs index ad21d597a42..b91cda7c8b5 100644 --- a/tests/Aspire.Cli.Tests/Commands/DoctorCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DoctorCommandTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Json; +using Aspire.Cli.Acquisition; using Aspire.Cli.Interaction; using Aspire.Cli.Projects; using Aspire.Cli.Tests.TestServices; @@ -11,6 +12,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.AspNetCore.InternalTesting; +using Spectre.Console; namespace Aspire.Cli.Tests.Commands; @@ -36,31 +38,16 @@ public async Task DoctorCommand_Help_Works() public async Task DoctorCommand_Json_IncludesCliVersionStatus() { using var workspace = TemporaryWorkspace.Create(outputHelper); - var interactionService = new TestInteractionService(); - var updateNotifier = new TestCliUpdateNotifier - { - GetVersionStatusAsyncCallback = (_, _) => Task.FromResult(new CliVersionStatus("13.0.0", "13.1.0", "aspire update")) - }; - var services = CreateDoctorVersionServiceCollection(workspace, outputHelper, options => - { - options.InteractionServiceFactory = _ => interactionService; - options.CliUpdateNotifierFactory = _ => updateNotifier; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("doctor --format json"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); - - var (json, console) = Assert.Single(interactionService.DisplayedRawText); - Assert.Equal(ConsoleOutput.Standard, console); - using var document = JsonDocument.Parse(json); - var cliVersionCheck = document.RootElement.GetProperty("checks").EnumerateArray() - .Single(check => check.GetProperty("name").GetString() == "cli-version"); + using var doc = await RunDoctorJsonAsync(workspace, + configureOptions: options => + { + options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier + { + GetVersionStatusAsyncCallback = (_, _) => Task.FromResult(new CliVersionStatus("13.0.0", "13.1.0", "aspire update")) + }; + }); + var cliVersionCheck = GetCheckByName(doc, "cli-version"); Assert.Equal("aspire", cliVersionCheck.GetProperty("category").GetString()); Assert.Equal("warning", cliVersionCheck.GetProperty("status").GetString()); Assert.Contains("13.0.0", cliVersionCheck.GetProperty("message").GetString()!); @@ -72,36 +59,68 @@ public async Task DoctorCommand_Json_IncludesCliVersionStatus() } [Fact] - public async Task DoctorCommand_Json_IncludesAppHostVersionWhenAppHostExists() + public async Task DoctorCommand_Json_VersionUpdateBanner_IsSuppressed() { + // The cli-version environment check already surfaces "newer version available" inside + // checks[]; the post-command update banner would be a second, less-structured copy of + // the same data. DoctorCommand opts out of BaseCommand's update notifier + // (UpdateNotificationsEnabled => false) so the banner does not fire at all — neither + // on stdout (which would break JSON parsing) nor on stderr (where it would just be noise + // duplicating checks[].cli-version). using var workspace = TemporaryWorkspace.Create(outputHelper); - var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); - await File.WriteAllTextAsync(appHostFile.FullName, ""); + var outputWriter = new TestOutputTextWriter(outputHelper); + var errorWriter = new StringWriter(); + var notifierInvoked = false; - var interactionService = new TestInteractionService(); var services = CreateDoctorVersionServiceCollection(workspace, outputHelper, options => { - options.InteractionServiceFactory = _ => interactionService; - options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); - options.AppHostProjectFactory = _ => new TestAppHostProjectFactory + options.OutputTextWriter = outputWriter; + options.ErrorTextWriter = errorWriter; + options.CliUpdateNotifierFactory = sp => new TestCliUpdateNotifier { - GetAspireHostingVersionAsyncCallback = (_, _) => Task.FromResult("13.0.0") + NotifyIfUpdateAvailableCallback = () => + { + notifierInvoked = true; + var interactionService = sp.GetRequiredService(); + interactionService.DisplayVersionUpdateNotification("13.99.0", "aspire update"); + } }; }); using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); var result = command.Parse("doctor --format json"); - var exitCode = await result.InvokeAsync().DefaultTimeout(); - Assert.Equal(CliExitCodes.Success, exitCode); - var (json, _) = Assert.Single(interactionService.DisplayedRawText); - using var document = JsonDocument.Parse(json); - var appHostVersionCheck = document.RootElement.GetProperty("checks").EnumerateArray() - .Single(check => check.GetProperty("name").GetString() == "apphost-version"); + Assert.False(notifierInvoked, "DoctorCommand should not invoke the CLI update notifier; the cli-version check carries that information directly in checks[]."); + + var stdoutText = string.Concat(outputWriter.Logs); + using var doc = JsonDocument.Parse(stdoutText); + Assert.True(doc.RootElement.TryGetProperty("checks", out _)); + + var stderrText = errorWriter.ToString(); + Assert.DoesNotContain("13.99.0", stderrText, StringComparison.Ordinal); + } + + [Fact] + public async Task DoctorCommand_Json_IncludesAppHostVersionWhenAppHostExists() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(appHostFile.FullName, ""); + + using var doc = await RunDoctorJsonAsync(workspace, + configureOptions: options => + { + options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); + options.AppHostProjectFactory = _ => new TestAppHostProjectFactory + { + GetAspireHostingVersionAsyncCallback = (_, _) => Task.FromResult("13.0.0") + }; + }); + var appHostVersionCheck = GetCheckByName(doc, "apphost-version"); Assert.Equal("apphost", appHostVersionCheck.GetProperty("category").GetString()); Assert.Equal("pass", appHostVersionCheck.GetProperty("status").GetString()); Assert.Contains("13.0.0", appHostVersionCheck.GetProperty("message").GetString()!); @@ -127,42 +146,30 @@ await File.WriteAllTextAsync( } """); - var interactionService = new TestInteractionService(); var runnerCalled = false; - var services = CreateDoctorVersionServiceCollection(workspace, outputHelper, options => - { - options.InteractionServiceFactory = _ => interactionService; - options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); - options.AppHostProjectFactory = _ => new TestAppHostProjectFactory + using var doc = await RunDoctorJsonAsync(workspace, + configureOptions: options => { - CanHandleCallback = file => file.Extension.Equals(".ts", StringComparison.OrdinalIgnoreCase), - DetectionPatterns = ["apphost.ts"], - GetAspireHostingVersionAsyncCallback = (_, _) => Task.FromResult("13.1.0") - }; - options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner - { - GetAppHostInformationAsyncCallback = (_, _, _) => + options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); + options.AppHostProjectFactory = _ => new TestAppHostProjectFactory { - runnerCalled = true; - return (0, true, "unexpected"); - } - }; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("doctor --format json"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); + CanHandleCallback = file => file.Extension.Equals(".ts", StringComparison.OrdinalIgnoreCase), + DetectionPatterns = ["apphost.ts"], + GetAspireHostingVersionAsyncCallback = (_, _) => Task.FromResult("13.1.0") + }; + options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner + { + GetAppHostInformationAsyncCallback = (_, _, _) => + { + runnerCalled = true; + return (0, true, "unexpected"); + } + }; + }); - Assert.Equal(CliExitCodes.Success, exitCode); Assert.False(runnerCalled); - var (json, _) = Assert.Single(interactionService.DisplayedRawText); - using var document = JsonDocument.Parse(json); - var appHostVersionCheck = document.RootElement.GetProperty("checks").EnumerateArray() - .Single(check => check.GetProperty("name").GetString() == "apphost-version"); - + var appHostVersionCheck = GetCheckByName(doc, "apphost-version"); Assert.Equal("apphost", appHostVersionCheck.GetProperty("category").GetString()); Assert.Equal("pass", appHostVersionCheck.GetProperty("status").GetString()); Assert.Contains("13.1.0", appHostVersionCheck.GetProperty("message").GetString()!); @@ -179,34 +186,23 @@ public async Task DoctorCommand_Json_DoesNotDiscoverNestedAppHostWithoutConfig() var appHostFile = CreateDeepAppHostFile(workspace, depth: LanguageInfo.DetectionRecurseLimit + 1); await File.WriteAllTextAsync(appHostFile.FullName, ""); - var interactionService = new TestInteractionService(); var versionLookupCalled = false; - var services = CreateDoctorVersionServiceCollection(workspace, outputHelper, options => - { - options.InteractionServiceFactory = _ => interactionService; - options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); - options.AppHostProjectFactory = _ => new TestAppHostProjectFactory + using var doc = await RunDoctorJsonAsync(workspace, + configureOptions: options => { - GetAspireHostingVersionAsyncCallback = (_, _) => + options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); + options.AppHostProjectFactory = _ => new TestAppHostProjectFactory { - versionLookupCalled = true; - return Task.FromResult("unexpected"); - } - }; - }); - using var provider = services.BuildServiceProvider(); + GetAspireHostingVersionAsyncCallback = (_, _) => + { + versionLookupCalled = true; + return Task.FromResult("unexpected"); + } + }; + }); - var command = provider.GetRequiredService(); - var result = command.Parse("doctor --format json"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); Assert.False(versionLookupCalled); - - var (json, _) = Assert.Single(interactionService.DisplayedRawText); - using var document = JsonDocument.Parse(json); - Assert.DoesNotContain(document.RootElement.GetProperty("checks").EnumerateArray(), + Assert.DoesNotContain(doc.RootElement.GetProperty("checks").EnumerateArray(), check => check.GetProperty("name").GetString() == "apphost-version"); } @@ -217,35 +213,24 @@ public async Task DoctorCommand_Json_DoesNotShowAppHostVersionForNonAppHostProje var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Normal.csproj")); await File.WriteAllTextAsync(projectFile.FullName, ""); - var interactionService = new TestInteractionService(); var versionLookupCalled = false; - var services = CreateDoctorVersionServiceCollection(workspace, outputHelper, options => - { - options.InteractionServiceFactory = _ => interactionService; - options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); - options.AppHostProjectFactory = _ => new TestAppHostProjectFactory + using var doc = await RunDoctorJsonAsync(workspace, + configureOptions: options => { - ValidateAppHostCallback = _ => new AppHostValidationResult(IsValid: false), - GetAspireHostingVersionAsyncCallback = (_, _) => + options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); + options.AppHostProjectFactory = _ => new TestAppHostProjectFactory { - versionLookupCalled = true; - return Task.FromResult("unexpected"); - } - }; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("doctor --format json"); + ValidateAppHostCallback = _ => new AppHostValidationResult(IsValid: false), + GetAspireHostingVersionAsyncCallback = (_, _) => + { + versionLookupCalled = true; + return Task.FromResult("unexpected"); + } + }; + }); - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); Assert.False(versionLookupCalled); - - var (json, _) = Assert.Single(interactionService.DisplayedRawText); - using var document = JsonDocument.Parse(json); - Assert.DoesNotContain(document.RootElement.GetProperty("checks").EnumerateArray(), + Assert.DoesNotContain(doc.RootElement.GetProperty("checks").EnumerateArray(), check => check.GetProperty("name").GetString() == "apphost-version"); } @@ -259,30 +244,19 @@ public async Task DoctorCommand_Json_DoesNotDiscoverNestedAppHostWhenAnotherProj var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj")); await File.WriteAllTextAsync(appHostFile.FullName, ""); - var interactionService = new TestInteractionService(); - var services = CreateDoctorVersionServiceCollection(workspace, outputHelper, options => - { - options.InteractionServiceFactory = _ => interactionService; - options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); - options.AppHostProjectFactory = _ => new TestAppHostProjectFactory + using var doc = await RunDoctorJsonAsync(workspace, + configureOptions: options => { - ValidateAppHostCallback = file => new AppHostValidationResult( - IsValid: file.Name.Equals("AppHost.csproj", StringComparison.OrdinalIgnoreCase)), - GetAspireHostingVersionAsyncCallback = (_, _) => Task.FromResult("13.2.0") - }; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("doctor --format json"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); + options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); + options.AppHostProjectFactory = _ => new TestAppHostProjectFactory + { + ValidateAppHostCallback = file => new AppHostValidationResult( + IsValid: file.Name.Equals("AppHost.csproj", StringComparison.OrdinalIgnoreCase)), + GetAspireHostingVersionAsyncCallback = (_, _) => Task.FromResult("13.2.0") + }; + }); - var (json, _) = Assert.Single(interactionService.DisplayedRawText); - using var document = JsonDocument.Parse(json); - Assert.DoesNotContain(document.RootElement.GetProperty("checks").EnumerateArray(), + Assert.DoesNotContain(doc.RootElement.GetProperty("checks").EnumerateArray(), check => check.GetProperty("name").GetString() == "apphost-version"); } @@ -293,34 +267,23 @@ public async Task DoctorCommand_Json_DoesNotChooseBetweenMultipleDirectAppHostsW await File.WriteAllTextAsync(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"), ""); await File.WriteAllTextAsync(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.fsproj"), ""); - var interactionService = new TestInteractionService(); var versionLookupCalled = false; - var services = CreateDoctorVersionServiceCollection(workspace, outputHelper, options => - { - options.InteractionServiceFactory = _ => interactionService; - options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); - options.AppHostProjectFactory = _ => new TestAppHostProjectFactory + using var doc = await RunDoctorJsonAsync(workspace, + configureOptions: options => { - GetAspireHostingVersionAsyncCallback = (_, _) => + options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); + options.AppHostProjectFactory = _ => new TestAppHostProjectFactory { - versionLookupCalled = true; - return Task.FromResult("unexpected"); - } - }; - }); - using var provider = services.BuildServiceProvider(); + GetAspireHostingVersionAsyncCallback = (_, _) => + { + versionLookupCalled = true; + return Task.FromResult("unexpected"); + } + }; + }); - var command = provider.GetRequiredService(); - var result = command.Parse("doctor --format json"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); Assert.False(versionLookupCalled); - - var (json, _) = Assert.Single(interactionService.DisplayedRawText); - using var document = JsonDocument.Parse(json); - Assert.DoesNotContain(document.RootElement.GetProperty("checks").EnumerateArray(), + Assert.DoesNotContain(doc.RootElement.GetProperty("checks").EnumerateArray(), check => check.GetProperty("name").GetString() == "apphost-version"); } @@ -331,34 +294,21 @@ public async Task DoctorCommand_Json_PreservesCliVersionWhenAppHostVersionResolu var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts")); await File.WriteAllTextAsync(appHostFile.FullName, "export {};"); - var interactionService = new TestInteractionService(); - var services = CreateDoctorVersionServiceCollection(workspace, outputHelper, options => - { - options.InteractionServiceFactory = _ => interactionService; - options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); - options.AppHostProjectFactory = _ => new TestAppHostProjectFactory + using var doc = await RunDoctorJsonAsync(workspace, + configureOptions: options => { - CanHandleCallback = file => file.Extension.Equals(".ts", StringComparison.OrdinalIgnoreCase), - DetectionPatterns = ["apphost.ts"], - GetAspireHostingVersionAsyncCallback = (_, _) => - throw new InvalidOperationException("invalid aspire.config.json") - }; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("doctor --format json"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); + options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); + options.AppHostProjectFactory = _ => new TestAppHostProjectFactory + { + CanHandleCallback = file => file.Extension.Equals(".ts", StringComparison.OrdinalIgnoreCase), + DetectionPatterns = ["apphost.ts"], + GetAspireHostingVersionAsyncCallback = (_, _) => + throw new InvalidOperationException("invalid aspire.config.json") + }; + }); - var (json, _) = Assert.Single(interactionService.DisplayedRawText); - using var document = JsonDocument.Parse(json); - var cliVersionCheck = document.RootElement.GetProperty("checks").EnumerateArray() - .Single(check => check.GetProperty("name").GetString() == "cli-version"); - var appHostVersionCheck = document.RootElement.GetProperty("checks").EnumerateArray() - .Single(check => check.GetProperty("name").GetString() == "apphost-version"); + var cliVersionCheck = GetCheckByName(doc, "cli-version"); + var appHostVersionCheck = GetCheckByName(doc, "apphost-version"); Assert.Equal("pass", cliVersionCheck.GetProperty("status").GetString()); Assert.Equal("warning", appHostVersionCheck.GetProperty("status").GetString()); @@ -373,31 +323,18 @@ public async Task DoctorCommand_Json_PreservesCliVersionWhenAppHostDiscoveryFail { using var workspace = TemporaryWorkspace.Create(outputHelper); - var interactionService = new TestInteractionService(); - var services = CreateDoctorVersionServiceCollection(workspace, outputHelper, options => - { - options.InteractionServiceFactory = _ => interactionService; - options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); - options.ProjectLocatorFactory = _ => new TestProjectLocator + using var doc = await RunDoctorJsonAsync(workspace, + configureOptions: options => { - GetAppHostFromSettingsAsyncCallback = _ => throw new IOException("settings lookup failed") - }; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("doctor --format json"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); + options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); + options.ProjectLocatorFactory = _ => new TestProjectLocator + { + GetAppHostFromSettingsAsyncCallback = _ => throw new IOException("settings lookup failed") + }; + }); - var (json, _) = Assert.Single(interactionService.DisplayedRawText); - using var document = JsonDocument.Parse(json); - var cliVersionCheck = document.RootElement.GetProperty("checks").EnumerateArray() - .Single(check => check.GetProperty("name").GetString() == "cli-version"); - var appHostVersionCheck = document.RootElement.GetProperty("checks").EnumerateArray() - .Single(check => check.GetProperty("name").GetString() == "apphost-version"); + var cliVersionCheck = GetCheckByName(doc, "cli-version"); + var appHostVersionCheck = GetCheckByName(doc, "apphost-version"); Assert.Equal("pass", cliVersionCheck.GetProperty("status").GetString()); Assert.Equal("warning", appHostVersionCheck.GetProperty("status").GetString()); @@ -420,41 +357,625 @@ await File.WriteAllTextAsync( } """); - var interactionService = new TestInteractionService(); + using var doc = await RunDoctorJsonAsync(workspace, + configureOptions: options => + { + options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); + options.AppHostProjectFactory = _ => new TestAppHostProjectFactory + { + GetAspireHostingVersionAsyncCallback = (_, _) => Task.FromResult("13.2.0") + }; + }); + + var appHostVersionCheck = GetCheckByName(doc, "apphost-version"); + Assert.Equal("apphost", appHostVersionCheck.GetProperty("category").GetString()); + Assert.Equal("pass", appHostVersionCheck.GetProperty("status").GetString()); + Assert.Contains("13.2.0", appHostVersionCheck.GetProperty("message").GetString()!); + Assert.Contains("AppHost.csproj", appHostVersionCheck.GetProperty("message").GetString()!); + var appHostVersionMetadata = appHostVersionCheck.GetProperty("metadata"); + Assert.Equal("13.2.0", appHostVersionMetadata.GetProperty("version").GetString()); + Assert.Equal( + Path.Combine("level0", "level1", "level2", "level3", "level4", "level5", "AppHost.csproj"), + appHostVersionMetadata.GetProperty("appHostPath").GetString()); + } + + [Fact] + public async Task DoctorCommand_Json_CliVersion_IncludesIdentityChannelFromReader() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + // Override the channel reader registered by CliTestHelper with a fake + // returning a deterministic value, so the assertion is not coupled to + // whichever channel the test host's Aspire.Cli assembly happens to bake in. + using var doc = await RunDoctorJsonAsync(workspace, + configureOptions: options => + { + options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier + { + GetVersionStatusAsyncCallback = (_, _) => Task.FromResult(new CliVersionStatus("13.0.0", LatestVersion: null, UpdateCommand: null)) + }; + }, + configureServices: services => + { + services.RemoveAll(); + services.AddSingleton(_ => new FakeIdentityChannelReader("staging")); + }); + + var cliVersionCheck = GetCheckByName(doc, "cli-version"); + var metadata = cliVersionCheck.GetProperty("metadata"); + Assert.Equal("staging", metadata.GetProperty("identityChannel").GetString()); + Assert.Contains("channel: staging", cliVersionCheck.GetProperty("message").GetString()!); + } + + [Fact] + public async Task DoctorCommand_Json_CliVersion_OmitsIdentityChannelWhenReaderThrows() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var doc = await RunDoctorJsonAsync(workspace, + configureOptions: options => + { + options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier + { + GetVersionStatusAsyncCallback = (_, _) => Task.FromResult(new CliVersionStatus("13.0.0", LatestVersion: null, UpdateCommand: null)) + }; + }, + configureServices: services => + { + services.RemoveAll(); + // Throws to simulate a misconfigured dev build with no AspireCliChannel metadata. + services.AddSingleton(_ => new FakeIdentityChannelReader(throwOnRead: true)); + }); + + // The channel lookup failing is informational; the rest of doctor should still complete. + var cliVersionCheck = GetCheckByName(doc, "cli-version"); + var metadata = cliVersionCheck.GetProperty("metadata"); + Assert.False(metadata.TryGetProperty("identityChannel", out _)); + Assert.DoesNotContain("channel:", cliVersionCheck.GetProperty("message").GetString()!); + } + + [Fact] + public async Task DoctorCommand_Json_AppHostVersion_IncludesPinnedChannelFromAspireConfig() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(appHostFile.FullName, ""); + await File.WriteAllTextAsync( + Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json"), + """ + { + "channel": "daily" + } + """); + + using var doc = await RunDoctorJsonAsync(workspace, + configureOptions: options => + { + options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); + options.AppHostProjectFactory = _ => new TestAppHostProjectFactory + { + GetAspireHostingVersionAsyncCallback = (_, _) => Task.FromResult("13.0.0") + }; + }); + + var appHostVersionCheck = GetCheckByName(doc, "apphost-version"); + var metadata = appHostVersionCheck.GetProperty("metadata"); + Assert.Equal("daily", metadata.GetProperty("pinnedChannel").GetString()); + Assert.Contains("channel: daily", appHostVersionCheck.GetProperty("message").GetString()!); + } + + [Fact] + public async Task DoctorCommand_Json_AppHostVersion_IncludesPinnedChannelFromAspireConfigWhenAppHostIsNested() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var nestedAppHostDir = workspace.WorkspaceRoot.CreateSubdirectory("src").CreateSubdirectory("NestedAppHost"); + var appHostFile = new FileInfo(Path.Combine(nestedAppHostDir.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(appHostFile.FullName, ""); + await File.WriteAllTextAsync( + Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json"), + """ + { + "appHost": { + "path": "src/NestedAppHost/AppHost.csproj" + }, + "channel": "daily" + } + """); + + using var doc = await RunDoctorJsonAsync(workspace, + configureOptions: options => + { + options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); + options.AppHostProjectFactory = _ => new TestAppHostProjectFactory + { + GetAspireHostingVersionAsyncCallback = (_, _) => Task.FromResult("13.0.0") + }; + }); + + var appHostVersionCheck = GetCheckByName(doc, "apphost-version"); + var metadata = appHostVersionCheck.GetProperty("metadata"); + Assert.Equal("daily", metadata.GetProperty("pinnedChannel").GetString()); + Assert.Contains("channel: daily", appHostVersionCheck.GetProperty("message").GetString()!); + Assert.Equal(Path.Combine("src", "NestedAppHost", "AppHost.csproj"), metadata.GetProperty("appHostPath").GetString()); + } + + [Fact] + public async Task DoctorCommand_Json_AppHostVersion_OmitsPinnedChannelWhenAspireConfigAbsent() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")); + await File.WriteAllTextAsync(appHostFile.FullName, ""); + // Intentionally no aspire.config.json — verifies the lookup degrades silently. + + using var doc = await RunDoctorJsonAsync(workspace, + configureOptions: options => + { + options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); + options.AppHostProjectFactory = _ => new TestAppHostProjectFactory + { + GetAspireHostingVersionAsyncCallback = (_, _) => Task.FromResult("13.0.0") + }; + }); + + var appHostVersionCheck = GetCheckByName(doc, "apphost-version"); + var metadata = appHostVersionCheck.GetProperty("metadata"); + Assert.False(metadata.TryGetProperty("pinnedChannel", out _)); + Assert.DoesNotContain("channel:", appHostVersionCheck.GetProperty("message").GetString()!); + } + + [Fact] + public async Task DoctorCommand_Json_CliVersion_IncludesLatestVersionChannel_WhenUpdateAvailable() + { + // When an update is available, doctor should surface BOTH channel + // labels — identityChannel for the running CLI, latestVersionChannel + // for the recommendation lane (stable vs prerelease) — so the user + // can see exactly where the recommendation is being pulled from. + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var doc = await RunDoctorJsonAsync(workspace, + configureOptions: options => + { + options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier + { + GetVersionStatusAsyncCallback = (_, _) => Task.FromResult(new CliVersionStatus( + CurrentVersion: "13.4.0-dev", + LatestVersion: "13.4.0-preview.1.26264.8", + UpdateCommand: "aspire update", + UpdateCheckError: null, + LatestVersionChannel: "prerelease")) + }; + }, + configureServices: services => + { + services.RemoveAll(); + services.AddSingleton(_ => new FakeIdentityChannelReader("local")); + }); + + var cliVersionCheck = GetCheckByName(doc, "cli-version"); + + // Both channels surface in metadata. + var metadata = cliVersionCheck.GetProperty("metadata"); + Assert.Equal("local", metadata.GetProperty("identityChannel").GetString()); + Assert.Equal("prerelease", metadata.GetProperty("latestVersionChannel").GetString()); + + // The human-readable message attaches the channel to each version + // it qualifies. Both must appear at well-defined positions so the + // user can't mis-read which channel is which. + var message = cliVersionCheck.GetProperty("message").GetString()!; + var currentIdx = message.IndexOf("13.4.0-dev (channel: local)", StringComparison.Ordinal); + var latestIdx = message.IndexOf("13.4.0-preview.1.26264.8 (channel: prerelease)", StringComparison.Ordinal); + Assert.True(currentIdx >= 0, $"Expected current version with channel suffix in message; got: {message}"); + Assert.True(latestIdx >= 0, $"Expected latest version with channel suffix in message; got: {message}"); + Assert.True(currentIdx < latestIdx, "Current version must appear before latest version in message."); + } + + [Fact] + public async Task DoctorCommand_Json_IncludesDiscoveredInstallations() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var doc = await RunDoctorJsonAsync(workspace, + configureOptions: options => + { + options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); + }, + configureServices: services => UseFakeInstallationDiscovery( + services, + self: new InstallationInfo + { + Path = "/home/test/.aspire/bin/aspire", + CanonicalPath = "/home/test/.aspire/bin/aspire", + Version = "13.0.0", + Channel = "stable", + Route = "script", + PathStatus = InstallationPathStatus.Active, + Status = InstallationInfoStatus.Ok, + }, + others: + [ + new InstallationInfo + { + Path = "/home/test/.aspire/dogfood/pr-1234/bin/aspire", + CanonicalPath = "/home/test/.aspire/dogfood/pr-1234/bin/aspire", + Version = "13.1.0-preview", + Channel = "pr-1234", + Route = "pr", + PathStatus = InstallationPathStatus.Shadowed, + Status = InstallationInfoStatus.Ok, + }, + ])); + + var installations = doc.RootElement.GetProperty("installations").EnumerateArray().ToArray(); + Assert.Equal(2, installations.Length); + + var self = installations[0]; + Assert.Equal("/home/test/.aspire/bin/aspire", self.GetProperty("path").GetString()); + Assert.Equal("stable", self.GetProperty("channel").GetString()); + Assert.Equal("script", self.GetProperty("route").GetString()); + Assert.Equal(InstallationPathStatus.Active, self.GetProperty("pathStatus").GetString()); + + var peer = installations[1]; + Assert.Equal("/home/test/.aspire/dogfood/pr-1234/bin/aspire", peer.GetProperty("path").GetString()); + Assert.Equal("pr-1234", peer.GetProperty("channel").GetString()); + Assert.Equal("pr", peer.GetProperty("route").GetString()); + Assert.Equal(InstallationPathStatus.Shadowed, peer.GetProperty("pathStatus").GetString()); + } + + [Fact] + public async Task DoctorCommand_HumanReadable_Self_RendersOnlyRunningInstallationAndSkipsChecks() + { + // `doctor --self` is the peer-probe surface. Without --format the + // human-readable table is the default; with --format json the + // probe gets a machine-readable row. Either way, no environment + // checks run and only the running CLI's row is rendered. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var output = new StringWriter(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Interactive = InteractionSupport.No, + Out = new AnsiConsoleOutput(output), + Enrichment = new ProfileEnrichment { UseDefaultEnrichers = false }, + }); + console.Profile.Width = int.MaxValue; + var services = CreateDoctorVersionServiceCollection(workspace, outputHelper, options => { - options.InteractionServiceFactory = _ => interactionService; options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); - options.AppHostProjectFactory = _ => new TestAppHostProjectFactory + }); + services.RemoveAll(); + services.AddSingleton(console); + UseFakeInstallationDiscovery( + services, + self: new InstallationInfo { - GetAspireHostingVersionAsyncCallback = (_, _) => Task.FromResult("13.2.0") - }; + Path = "/home/test/.aspire/bin/aspire", + CanonicalPath = "/home/test/.aspire/bin/aspire", + Version = "13.4.0-pr.17115.gcd700928", + Channel = "pr-17115", + Route = "brew", + PathStatus = InstallationPathStatus.Active, + Status = InstallationInfoStatus.Ok, + }, + others: + [ + new InstallationInfo + { + Path = "/peer/aspire", + CanonicalPath = "/peer/aspire", + Version = "13.1.0-preview", + Channel = "pr-1234", + Route = "pr", + PathStatus = InstallationPathStatus.Shadowed, + Status = InstallationInfoStatus.Ok, + }, + ]); + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("doctor --self"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + var rendered = output.ToString(); + Assert.Contains("Aspire CLI Installations", rendered, StringComparison.Ordinal); + Assert.Contains("13.4.0-pr.17115.gcd700928", rendered, StringComparison.Ordinal); + Assert.Contains("pr-17115", rendered, StringComparison.Ordinal); + // No environment checks ran, so no Summary line. + Assert.DoesNotContain("Summary:", rendered, StringComparison.Ordinal); + // No peer rows — --self bounds the output to the running CLI only. + Assert.DoesNotContain("/peer/aspire", rendered, StringComparison.Ordinal); + Assert.DoesNotContain("pr-1234", rendered, StringComparison.Ordinal); + } + + [Fact] + public async Task DoctorCommand_HumanReadable_AppendsInstallationsAfterSummary() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var output = new StringWriter(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Interactive = InteractionSupport.No, + Out = new AnsiConsoleOutput(output), + Enrichment = new ProfileEnrichment { UseDefaultEnrichers = false }, }); + console.Profile.Width = int.MaxValue; + + var services = CreateDoctorVersionServiceCollection(workspace, outputHelper, options => + { + options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); + }); + services.RemoveAll(); + services.AddSingleton(console); + UseFakeInstallationDiscovery( + services, + self: new InstallationInfo + { + Path = "/home/test/.aspire/bin/aspire", + CanonicalPath = "/home/test/.aspire/bin/aspire", + Version = "13.0.0", + Channel = "stable", + Route = "script", + PathStatus = InstallationPathStatus.Active, + Status = InstallationInfoStatus.Ok, + }, + others: + [ + new InstallationInfo + { + Path = "/peer/aspire", + CanonicalPath = "/peer/aspire", + Version = "13.1.0-preview", + Channel = "pr-1234", + Route = "pr", + PathStatus = InstallationPathStatus.Shadowed, + Status = InstallationInfoStatus.Ok, + }, + ]); + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("doctor"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + var rendered = output.ToString(); + var summaryIndex = rendered.IndexOf("Summary:", StringComparison.Ordinal); + var installationsIndex = rendered.IndexOf("Aspire CLI Installations", StringComparison.Ordinal); + Assert.True(summaryIndex >= 0, $"Expected doctor summary in output:{Environment.NewLine}{rendered}"); + Assert.True(installationsIndex > summaryIndex, $"Expected installations after summary in output:{Environment.NewLine}{rendered}"); + Assert.Contains("/peer/aspire", rendered, StringComparison.Ordinal); + Assert.Contains("pr-1234", rendered, StringComparison.Ordinal); + } + [Fact] + public async Task DoctorCommand_HumanReadable_EscapesUnknownPathStatus() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var output = new StringWriter(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Interactive = InteractionSupport.No, + Out = new AnsiConsoleOutput(output), + Enrichment = new ProfileEnrichment { UseDefaultEnrichers = false }, + }); + console.Profile.Width = int.MaxValue; + + // pathStatus is parsed from untrusted peer-probe stdout. A peer + // that emits an unrecognized string must not be able to inject + // Spectre markup into the parent's rendered table; the default + // branch of PathStatusDisplay must EscapeMarkup() the value. + var services = CreateDoctorVersionServiceCollection(workspace, outputHelper, options => + { + options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); + }); + services.RemoveAll(); + services.AddSingleton(console); + UseFakeInstallationDiscovery( + services, + self: new InstallationInfo + { + Path = "/home/test/.aspire/bin/aspire", + CanonicalPath = "/home/test/.aspire/bin/aspire", + Version = "13.0.0", + Channel = "stable", + Route = "script", + PathStatus = "custom[red]status[/]", + Status = InstallationInfoStatus.Ok, + }); + + using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("doctor --format json"); + var result = command.Parse("doctor"); var exitCode = await result.InvokeAsync().DefaultTimeout(); Assert.Equal(CliExitCodes.Success, exitCode); + var rendered = output.ToString(); + Assert.Contains("custom[red]status[/]", rendered, StringComparison.Ordinal); + } - var (json, _) = Assert.Single(interactionService.DisplayedRawText); - using var document = JsonDocument.Parse(json); - var appHostVersionCheck = document.RootElement.GetProperty("checks").EnumerateArray() - .Single(check => check.GetProperty("name").GetString() == "apphost-version"); + [Fact] + public void DoctorCommand_InfoCommandIsNotRegistered() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper); + using var provider = services.BuildServiceProvider(); - Assert.Equal("apphost", appHostVersionCheck.GetProperty("category").GetString()); - Assert.Equal("pass", appHostVersionCheck.GetProperty("status").GetString()); - Assert.Contains("13.2.0", appHostVersionCheck.GetProperty("message").GetString()!); - Assert.Contains("AppHost.csproj", appHostVersionCheck.GetProperty("message").GetString()!); - var appHostVersionMetadata = appHostVersionCheck.GetProperty("metadata"); - Assert.Equal("13.2.0", appHostVersionMetadata.GetProperty("version").GetString()); - Assert.Equal( - Path.Combine("level0", "level1", "level2", "level3", "level4", "level5", "AppHost.csproj"), - appHostVersionMetadata.GetProperty("appHostPath").GetString()); + var command = provider.GetRequiredService(); + + Assert.DoesNotContain(command.Subcommands, subcommand => subcommand.Name == "info"); + } + + [Fact] + public async Task DoctorCommand_Json_Self_ReturnsOnlyRunningInstallation() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var doc = await RunDoctorJsonAsync(workspace, + commandLine: "doctor --self --format json", + configureOptions: options => + { + options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); + }, + configureServices: services => UseFakeInstallationDiscovery( + services, + self: new InstallationInfo + { + Path = "/usr/local/bin/aspire", + CanonicalPath = "/usr/local/bin/aspire", + Version = "13.0.0", + Channel = "stable", + Route = "script", + PathStatus = InstallationPathStatus.Active, + Status = InstallationInfoStatus.Ok, + })); + + Assert.Empty(doc.RootElement.GetProperty("checks").EnumerateArray()); + var installations = doc.RootElement.GetProperty("installations").EnumerateArray().ToArray(); + var row = Assert.Single(installations); + Assert.Equal("/usr/local/bin/aspire", row.GetProperty("path").GetString()); + Assert.Equal("13.0.0", row.GetProperty("version").GetString()); + Assert.Equal("stable", row.GetProperty("channel").GetString()); + Assert.Equal("script", row.GetProperty("route").GetString()); + Assert.Equal(InstallationPathStatus.Active, row.GetProperty("pathStatus").GetString()); + Assert.Equal(InstallationInfoStatus.Ok, row.GetProperty("status").GetString()); + } + + [Fact] + public async Task DoctorCommand_Json_WhenInstallDiscoveryFails_StillReturnsDoctorResults() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var doc = await RunDoctorJsonAsync(workspace, + configureOptions: options => + { + options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); + }, + configureServices: services => UseFakeInstallationDiscovery( + services, + self: new InstallationInfo + { + Path = "/test/aspire", + Status = InstallationInfoStatus.Ok, + }, + discoverAllException: new IOException("PATH lookup failed"))); + + Assert.NotEmpty(doc.RootElement.GetProperty("checks").EnumerateArray()); + var row = Assert.Single(doc.RootElement.GetProperty("installations").EnumerateArray()); + Assert.Equal(InstallationInfoStatus.Failed, row.GetProperty("status").GetString()); + Assert.Equal("Install discovery failed. See the Aspire CLI logs for details.", row.GetProperty("statusReason").GetString()); } + [Theory] + [InlineData(InstallationInfoStatus.Failed, "(probe failed)")] + [InlineData(InstallationInfoStatus.NotProbed, "(not probed)")] + [InlineData(InstallationInfoStatus.Ok, "(unknown)")] + public async Task DoctorCommand_HumanReadable_RendersMissingInstallationValuesBasedOnStatus(string status, string expectedPlaceholder) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var output = new StringWriter(); + var console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Interactive = InteractionSupport.No, + Out = new AnsiConsoleOutput(output), + Enrichment = new ProfileEnrichment { UseDefaultEnrichers = false }, + }); + console.Profile.Width = int.MaxValue; + + var services = CreateDoctorVersionServiceCollection(workspace, outputHelper, options => + { + options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier(); + }); + services.RemoveAll(); + services.AddSingleton(console); + UseFakeInstallationDiscovery( + services, + self: new InstallationInfo + { + Path = "/home/test/.aspire/bin/aspire", + CanonicalPath = "/home/test/.aspire/bin/aspire", + Version = "13.0.0", + Channel = "stable", + Route = "script", + PathStatus = InstallationPathStatus.Active, + Status = InstallationInfoStatus.Ok, + }, + others: + [ + new InstallationInfo + { + Path = $"/peer/{status}/aspire", + CanonicalPath = $"/peer/{status}/aspire", + Status = status, + }, + ]); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("doctor"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(CliExitCodes.Success, exitCode); + + var rendered = output.ToString(); + Assert.Contains($"/peer/{status}/aspire", rendered, StringComparison.Ordinal); + Assert.Contains(expectedPlaceholder, rendered, StringComparison.Ordinal); + + foreach (var otherPlaceholder in new[] { "(probe failed)", "(not probed)", "(unknown)" }.Where(p => p != expectedPlaceholder)) + { + Assert.DoesNotContain(otherPlaceholder, rendered, StringComparison.Ordinal); + } + } + + // Centralizes the scaffolding shared by `doctor --format json` tests: + // build services via CreateDoctorVersionServiceCollection wired to a + // TextWriter capturing the real stdout sink, optionally tweak the + // registered services (e.g. swap IIdentityChannelReader), run the + // requested doctor command, assert success, and hand the caller a + // parsed JsonDocument. + // + // Capturing from the actual stdout sink (rather than a TestInteractionService + // collection) means any non-JSON text emitted on stdout — status messages, + // update notifications, error banners — fails the test at JsonDocument.Parse. + // This matches the pattern used by every other `--format json` test in the + // CLI (see e.g. LsCommandTests.LsCommand_JsonFormat_ReturnsCandidateAppHosts) + // and is what guarantees `aspire doctor --format json` stdout stays + // machine-readable. + // + // The caller owns disposal of the returned JsonDocument so it can read + // elements off it across multiple assertions in the test body. + private async Task RunDoctorJsonAsync( + TemporaryWorkspace workspace, + Action configureOptions, + Action? configureServices = null, + string commandLine = "doctor --format json") + { + var outputWriter = new TestOutputTextWriter(outputHelper); + var services = CreateDoctorVersionServiceCollection(workspace, outputHelper, options => + { + options.OutputTextWriter = outputWriter; + configureOptions(options); + }); + configureServices?.Invoke(services); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse(commandLine); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + Assert.Equal(CliExitCodes.Success, exitCode); + + var stdoutText = string.Concat(outputWriter.Logs); + return JsonDocument.Parse(stdoutText); + } + + private static JsonElement GetCheckByName(JsonDocument document, string checkName) + => document.RootElement.GetProperty("checks").EnumerateArray() + .Single(check => check.GetProperty("name").GetString() == checkName); + private static IServiceCollection CreateDoctorVersionServiceCollection( TemporaryWorkspace workspace, ITestOutputHelper outputHelper, @@ -463,9 +984,31 @@ private static IServiceCollection CreateDoctorVersionServiceCollection( var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, configure); services.RemoveAll(); services.AddSingleton(); + UseFakeInstallationDiscovery( + services, + self: new InstallationInfo + { + Path = "/test/aspire", + CanonicalPath = "/test/aspire", + Version = "13.0.0", + Channel = "stable", + Route = "script", + PathStatus = InstallationPathStatus.Active, + Status = InstallationInfoStatus.Ok, + }); return services; } + private static void UseFakeInstallationDiscovery( + IServiceCollection services, + InstallationInfo self, + IReadOnlyList? others = null, + Exception? discoverAllException = null) + { + services.RemoveAll(); + services.AddSingleton(_ => new FakeInstallationDiscovery(self, others, discoverAllException)); + } + private static FileInfo CreateDeepAppHostFile(TemporaryWorkspace workspace, int depth) { var directory = workspace.WorkspaceRoot; diff --git a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs index 19c0b37e72c..15c8820d94a 100644 --- a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs @@ -42,7 +42,7 @@ private static void ConfigureImplicitTemplateChannel(CliServiceCollectionTestOpt [new NuGetPackageCli { Id = "Aspire.ProjectTemplates", Source = "nuget.org", Version = version }]) }; - var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache); + var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures()); var packagingService = new TestPackagingService { @@ -673,12 +673,13 @@ public async Task InitCommand_WhenSolutionExistsAndPrHivesPresent_DoesNotWidenTo [new NuGetPackageCli { Id = "Aspire.ProjectTemplates", Source = "pr-hive", Version = "99.0.0-pr.12345" }]) }; - var implicitChannel = PackageChannel.CreateImplicitChannel(implicitCache); + var implicitChannel = PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures()); var prHiveChannel = PackageChannel.CreateExplicitChannel( "pr-12345", PackageChannelQuality.Both, [new PackageMapping("Aspire*", hivesDir.FullName + "/pr-12345/packages")], - prHiveCache); + prHiveCache, + features: new TestFeatures()); return new TestPackagingService { @@ -738,7 +739,7 @@ public async Task InitCommand_WhenChannelTemplateSearchFails_DisplaysFriendlyErr GetTemplatePackagesAsyncCallback = (_, _, _, _) => throw new NuGetPackageCacheException("Package search failed: simulated network failure") }; - var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache); + var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures()); return new TestPackagingService { GetChannelsAsyncCallback = _ => Task.FromResult>([implicitChannel]) @@ -1029,7 +1030,7 @@ public async Task InitCommand_OnLocalChannelCli_WithNoLocalHive_FallsBackToImpli Task.FromResult>( [new NuGetPackageCli { Id = "Aspire.ProjectTemplates", Source = "nuget.org", Version = "13.3.0" }]) }; - var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache); + var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures()); return new TestPackagingService { GetChannelsAsyncCallback = _ => Task.FromResult>([implicitChannel]) @@ -1415,7 +1416,7 @@ public async Task InitCommand_ProjectMode_LocalIdentityChannelWithNoLocalChannel Task.FromResult>( [new NuGetPackageCli { Id = "Aspire.ProjectTemplates", Source = "nuget.org", Version = "13.3.0" }]) }; - var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache); + var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures()); return new TestPackagingService { GetChannelsAsyncCallback = _ => Task.FromResult>([implicitChannel]) @@ -1538,7 +1539,8 @@ private static TestPackagingService CreateMultiChannelPackagingService(params st channelName, PackageChannelQuality.Both, [new PackageMapping("Aspire*", channelSource), new PackageMapping(PackageMapping.AllPackages, fallbackSource)], - fakeCache); + fakeCache, + features: new TestFeatures()); }).ToArray(); return new TestPackagingService diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs index 7e50f3bed43..8288dc321c1 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs @@ -76,7 +76,7 @@ public async Task NewCommand_DoesNotConsultGlobalConfigurationServiceForChannelK Task.FromResult>( [new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "13.3.0" }]) }; - var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache); + var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures()); return new TestPackagingService { GetChannelsAsyncCallback = _ => Task.FromResult>([implicitChannel]) @@ -290,7 +290,7 @@ private static IPackagingService BuildPackagingService(string identityChannel, s Task.FromResult>( [new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "13.3.0" }]) }; - var implicitChannel = PackageChannel.CreateImplicitChannel(implicitCache); + var implicitChannel = PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures()); // Always register a stable channel — matches what PackagingService advertises in // production. Its version (13.5.0) is distinct from Implicit (13.3.0) so a test @@ -305,7 +305,8 @@ private static IPackagingService BuildPackagingService(string identityChannel, s PackageChannelNames.Stable, PackageChannelQuality.Stable, [new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")], - stableCache); + stableCache, + features: new TestFeatures()); var channels = new List { implicitChannel, stableChannel }; @@ -330,7 +331,8 @@ [new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index. new PackageMapping("Aspire*", "https://example.invalid/feed/v3/index.json"), new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json"), ], - explicitCache)); + explicitCache, + features: new TestFeatures())); } // PR hives are an additional explicit channel shape (PackageChannelQuality.Both @@ -352,7 +354,8 @@ [new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index. new PackageMapping("Aspire*", "/fake/pr-hive/packages"), new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json"), ], - prCache)); + prCache, + features: new TestFeatures())); } return new TestPackagingService diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTemplateConfigPersistenceTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTemplateConfigPersistenceTests.cs index e1137c9babe..c51ce2d3948 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTemplateConfigPersistenceTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTemplateConfigPersistenceTests.cs @@ -412,7 +412,7 @@ private static IPackagingService BuildPackagingService(string identityChannel, b Task.FromResult>( [new NuGetPackage { Id = "Aspire.ProjectTemplates", Source = "nuget", Version = "13.3.0" }]) }; - var implicitChannel = PackageChannel.CreateImplicitChannel(implicitCache); + var implicitChannel = PackageChannel.CreateImplicitChannel(implicitCache, new TestFeatures()); var stableCache = new FakeNuGetPackageCache { @@ -424,7 +424,8 @@ private static IPackagingService BuildPackagingService(string identityChannel, b PackageChannelNames.Stable, PackageChannelQuality.Stable, [new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg)], - stableCache); + stableCache, + features: new TestFeatures()); var channels = new List { implicitChannel, stableChannel }; @@ -451,7 +452,8 @@ [new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg)], new PackageMapping("Aspire*", feed), new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg), ], - cache)); + cache, + features: new TestFeatures())); } return new TestPackagingService diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index a22140dfc81..e5d768c8cb7 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -318,8 +318,8 @@ public async Task NewCommandWithChannelOptionUsesSpecifiedChannel() return Task.FromResult>([package]); }; - var stableChannel = PackageChannel.CreateExplicitChannel("stable", PackageChannelQuality.Both, [], stableCache); - var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache); + var stableChannel = PackageChannel.CreateExplicitChannel("stable", PackageChannelQuality.Both, [], stableCache, new TestFeatures()); + var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache, new TestFeatures()); return Task.FromResult>([stableChannel, dailyChannel]); }; @@ -396,7 +396,7 @@ public async Task NewCommandWithChannelOptionAutoSelectsHighestVersion() return Task.FromResult>(packages); }; - var stableChannel = PackageChannel.CreateExplicitChannel("stable", PackageChannelQuality.Both, [], fakeCache); + var stableChannel = PackageChannel.CreateExplicitChannel("stable", PackageChannelQuality.Both, [], fakeCache, new TestFeatures()); return Task.FromResult>([stableChannel]); }; @@ -473,7 +473,7 @@ public async Task NewCommandWithPrChannelPrefersCurrentCliVersion() return Task.FromResult>(packages); }; - var prChannel = PackageChannel.CreateExplicitChannel("pr-12345", PackageChannelQuality.Both, [], fakeCache); + var prChannel = PackageChannel.CreateExplicitChannel("pr-12345", PackageChannelQuality.Both, [], fakeCache, new TestFeatures()); return Task.FromResult>([prChannel]); }; @@ -1585,7 +1585,7 @@ public async Task NewCommandWithTypeScriptEmptyTemplatePassesResolvedVersionAndC return Task.FromResult>([package]); }; - var stableChannel = PackageChannel.CreateExplicitChannel("stable", PackageChannelQuality.Both, [], stableCache); + var stableChannel = PackageChannel.CreateExplicitChannel("stable", PackageChannelQuality.Both, [], stableCache, new TestFeatures()); return Task.FromResult>([stableChannel]); }; @@ -1782,7 +1782,7 @@ public async Task NewCommandWithTypeScriptStarterGeneratesSdkArtifacts() } }; - var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache); + var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache, new TestFeatures()); return Task.FromResult>([dailyChannel]); } }; @@ -1857,7 +1857,7 @@ public async Task NewCommandWithTypeScriptStarterReturnsFailedToBuildArtifactsWh } }; - var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache); + var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache, new TestFeatures()); return Task.FromResult>([dailyChannel]); } }; @@ -1908,7 +1908,7 @@ public async Task NewCommandWithTypeScriptStarterAndSourceOverridePersistsSource return Task.FromResult>([package]); } }; - var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache); + var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache, new TestFeatures()); return Task.FromResult>([dailyChannel]); } }; @@ -2004,7 +2004,7 @@ public async Task NewCommandWithTypeScriptStarterAndFailedRestoreDoesNotWarnAbou return Task.FromResult>([package]); } }; - var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache); + var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, [], dailyCache, new TestFeatures()); return Task.FromResult>([dailyChannel]); } }; @@ -2823,7 +2823,7 @@ public async Task NewCommandWhenChannelTemplateSearchFailsDisplaysFriendlyError( GetTemplatePackagesAsyncCallback = (_, _, _, _) => throw new NuGetPackageCacheException("Package search failed: simulated network failure") }; - var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache); + var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures()); return new TestPackagingService { GetChannelsAsyncCallback = _ => Task.FromResult>([implicitChannel]) diff --git a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs index 41cde6120b4..6361b3ee38e 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -280,6 +280,7 @@ public async Task UpdateCommand_WhenProjectUpdatedSuccessfully_AndChannelSupport PackageChannelQuality.Stable, new[] { new PackageMapping("Aspire*", "https://api.nuget.org/v3/index.json") }, null!, + features: new TestFeatures(), configureGlobalPackagesFolder: false, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/ga/daily"); return Task.FromResult>(new[] { stableChannel }); @@ -521,6 +522,7 @@ public async Task UpdateCommand_WhenProjectUpdatedSuccessfullyAndRunningAsDotnet PackageChannelQuality.Stable, new[] { new PackageMapping("Aspire*", "https://api.nuget.org/v3/index.json") }, null!, + features: new TestFeatures(), configureGlobalPackagesFolder: false, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/ga/daily"); return Task.FromResult>(new[] { stableChannel }); @@ -596,6 +598,7 @@ public async Task UpdateCommand_WhenProjectUpdatedSuccessfullyAndRunningAsCustom PackageChannelQuality.Stable, new[] { new PackageMapping("Aspire*", "https://api.nuget.org/v3/index.json") }, null!, + features: new TestFeatures(), configureGlobalPackagesFolder: false, cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/ga/daily"); return Task.FromResult>(new[] { stableChannel }); @@ -759,6 +762,7 @@ public async Task UpdateCommand_WhenChannelHasNoCliDownloadUrl_DoesNotPromptForC PackageChannelQuality.Prerelease, new[] { new PackageMapping("Aspire*", "/path/to/pr/hive") }, null!, + features: new TestFeatures(), configureGlobalPackagesFolder: false, cliDownloadBaseUrl: null); // No CLI download URL for PR channels return Task.FromResult>(new[] { prChannel }); @@ -1042,8 +1046,8 @@ public async Task UpdateCommand_ProjectUpdate_WithChannelOption_DoesNotPromptFor GetChannelsAsyncCallback = (ct) => { // Create test channels matching the expected names - var stableChannel = new PackageChannel("stable", PackageChannelQuality.Stable, null, null!); - var dailyChannel = new PackageChannel("daily", PackageChannelQuality.Prerelease, null, null!); + var stableChannel = new PackageChannel("stable", PackageChannelQuality.Stable, null, null!, null!); + var dailyChannel = new PackageChannel("daily", PackageChannelQuality.Prerelease, null, null!, null!); return Task.FromResult>(new[] { stableChannel, dailyChannel }); } }; @@ -1107,8 +1111,8 @@ public async Task UpdateCommand_ProjectUpdate_WithQualityOption_DoesNotPromptFor GetChannelsAsyncCallback = (ct) => { // Create test channels matching the expected names - var stableChannel = new PackageChannel("stable", PackageChannelQuality.Stable, null, null!); - var dailyChannel = new PackageChannel("daily", PackageChannelQuality.Prerelease, null, null!); + var stableChannel = new PackageChannel("stable", PackageChannelQuality.Stable, null, null!, null!); + var dailyChannel = new PackageChannel("daily", PackageChannelQuality.Prerelease, null, null!, null!); return Task.FromResult>(new[] { stableChannel, dailyChannel }); } }; @@ -1161,8 +1165,8 @@ public async Task UpdateCommand_ProjectUpdate_WithInvalidQuality_DisplaysError() GetChannelsAsyncCallback = (ct) => { // Create test channels matching the expected names - var stableChannel = new PackageChannel("stable", PackageChannelQuality.Stable, null, null!); - var dailyChannel = new PackageChannel("daily", PackageChannelQuality.Prerelease, null, null!); + var stableChannel = new PackageChannel("stable", PackageChannelQuality.Stable, null, null!, null!); + var dailyChannel = new PackageChannel("daily", PackageChannelQuality.Prerelease, null, null!, null!); return Task.FromResult>(new[] { stableChannel, dailyChannel }); } }; @@ -1228,8 +1232,8 @@ public async Task UpdateCommand_ProjectUpdate_ChannelTakesPrecedenceOverQuality( { GetChannelsAsyncCallback = (ct) => { - var stableChannel = new PackageChannel("stable", PackageChannelQuality.Stable, null, null!); - var dailyChannel = new PackageChannel("daily", PackageChannelQuality.Prerelease, null, null!); + var stableChannel = new PackageChannel("stable", PackageChannelQuality.Stable, null, null!, null!); + var dailyChannel = new PackageChannel("daily", PackageChannelQuality.Prerelease, null, null!, null!); return Task.FromResult>(new[] { stableChannel, dailyChannel }); } }; @@ -1289,7 +1293,7 @@ public async Task UpdateCommand_ProjectUpdate_WhenCancelled_DisplaysCancellation { GetChannelsAsyncCallback = (ct) => { - var stableChannel = new PackageChannel("stable", PackageChannelQuality.Stable, null, null!); + var stableChannel = new PackageChannel("stable", PackageChannelQuality.Stable, null, null!, null!); return Task.FromResult>(new[] { stableChannel }); } }; @@ -1351,7 +1355,7 @@ public async Task UpdateCommand_WithoutHives_UsesImplicitChannelWithoutPrompting GetChannelsAsyncCallback = (ct) => { var fakeCache = new FakeNuGetPackageCache(); - var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache); + var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures()); return Task.FromResult>(new[] { implicitChannel }); } }; @@ -1506,7 +1510,7 @@ public async Task UpdateCommand_ConfiguredChannelNotInChannelList_ThrowsChannelN var fakeCache = new FakeNuGetPackageCache(); return Task.FromResult>(new[] { - PackageChannel.CreateImplicitChannel(fakeCache), + PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures()), }); } }; @@ -1564,8 +1568,8 @@ public async Task UpdateCommand_ChannelStagingRequestedButPackagingServiceReport var fakeCache = new FakeNuGetPackageCache(); return Task.FromResult>(new[] { - PackageChannel.CreateImplicitChannel(fakeCache), - PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, mappings: null, fakeCache), + PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures()), + PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, mappings: null, fakeCache, new TestFeatures()), }); }, GetStagingChannelUnavailableReasonCallback = () => unavailableReason @@ -1792,10 +1796,10 @@ public async Task UpdateCommand_WithHives_PromptOffersChannelsInPackagingService GetChannelsAsyncCallback = (ct) => { var fakeCache = new FakeNuGetPackageCache(); - var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache); - var stableChannel = PackageChannel.CreateExplicitChannel("stable", PackageChannelQuality.Stable, mappings: null, fakeCache); - var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, mappings: null, fakeCache); - var hiveChannel = PackageChannel.CreateExplicitChannel("pr-12345", PackageChannelQuality.Both, mappings: null, fakeCache); + var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures()); + var stableChannel = PackageChannel.CreateExplicitChannel("stable", PackageChannelQuality.Stable, mappings: null, fakeCache, new TestFeatures()); + var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, mappings: null, fakeCache, new TestFeatures()); + var hiveChannel = PackageChannel.CreateExplicitChannel("pr-12345", PackageChannelQuality.Both, mappings: null, fakeCache, new TestFeatures()); return Task.FromResult>(new[] { implicitChannel, stableChannel, dailyChannel, hiveChannel }); } }; @@ -2193,9 +2197,9 @@ public async Task UpdateCommand_PerProjectConfigChannelOverridesIdentityChannel( GetChannelsAsyncCallback = (ct) => { var fakeCache = new FakeNuGetPackageCache(); - var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache); - var stagingChannel = PackageChannel.CreateExplicitChannel("staging", PackageChannelQuality.Stable, mappings: null, fakeCache); - var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, mappings: null, fakeCache); + var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures()); + var stagingChannel = PackageChannel.CreateExplicitChannel("staging", PackageChannelQuality.Stable, mappings: null, fakeCache, new TestFeatures()); + var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, mappings: null, fakeCache, new TestFeatures()); var channels = new List { implicitChannel, stagingChannel, dailyChannel }; // Optional pr-* and local channels for identity-channel tests. Production @@ -2208,14 +2212,14 @@ public async Task UpdateCommand_PerProjectConfigChannelOverridesIdentityChannel( { if (hive.Name.StartsWith("pr-", StringComparison.OrdinalIgnoreCase)) { - channels.Add(PackageChannel.CreateExplicitChannel(hive.Name, PackageChannelQuality.Both, mappings: null, fakeCache)); + channels.Add(PackageChannel.CreateExplicitChannel(hive.Name, PackageChannelQuality.Both, mappings: null, fakeCache, new TestFeatures())); } } } if (includeLocalInChannels) { - channels.Add(PackageChannel.CreateExplicitChannel(PackageChannelNames.Local, PackageChannelQuality.Both, mappings: null, fakeCache)); + channels.Add(PackageChannel.CreateExplicitChannel(PackageChannelNames.Local, PackageChannelQuality.Both, mappings: null, fakeCache, new TestFeatures())); } return Task.FromResult>(channels); @@ -2485,8 +2489,8 @@ public async Task UpdateCommand_NonInteractive_WithYesAndChannel_SucceedsWithout { GetChannelsAsyncCallback = (ct) => { - var stableChannel = new PackageChannel("stable", PackageChannelQuality.Stable, null, null!); - var dailyChannel = new PackageChannel("daily", PackageChannelQuality.Prerelease, null, null!); + var stableChannel = new PackageChannel("stable", PackageChannelQuality.Stable, null, null!, null!); + var dailyChannel = new PackageChannel("daily", PackageChannelQuality.Prerelease, null, null!, null!); return Task.FromResult>([stableChannel, dailyChannel]); } }; @@ -2606,6 +2610,7 @@ private static PackageChannel CreatePackageChannelWithGuestSdkVersion(string sdk PackageChannelQuality.Stable, [new PackageMapping("Aspire*", "https://api.nuget.org/v3/index.json")], fakeCache, + features: new TestFeatures(), configureGlobalPackagesFolder: false, cliDownloadBaseUrl: cliDownloadBaseUrl); } diff --git a/tests/Aspire.Cli.Tests/Configuration/DotNetBasedAppHostServerChannelResolutionTests.cs b/tests/Aspire.Cli.Tests/Configuration/DotNetBasedAppHostServerChannelResolutionTests.cs index 271d441577b..f9365c651b8 100644 --- a/tests/Aspire.Cli.Tests/Configuration/DotNetBasedAppHostServerChannelResolutionTests.cs +++ b/tests/Aspire.Cli.Tests/Configuration/DotNetBasedAppHostServerChannelResolutionTests.cs @@ -130,7 +130,8 @@ private static TestPackagingService CreatePackagingServiceWithExplicitChannels(p name, PackageChannelQuality.Both, mappings: [], - cache)) + cache, + new TestFeatures())) .ToArray(); return Task.FromResult>(channels); } diff --git a/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs b/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs index 9d3150f3aea..0d82ac521d8 100644 --- a/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs +++ b/tests/Aspire.Cli.Tests/Mcp/MockPackagingService.cs @@ -23,7 +23,7 @@ public static TestPackagingService Create(NuGetPackageCli[]? integrationPackages GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult>(packages) }; - return Task.FromResult>([PackageChannel.CreateImplicitChannel(cache)]); + return Task.FromResult>([PackageChannel.CreateImplicitChannel(cache, new TestFeatures())]); } }; } diff --git a/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerTests.cs b/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerTests.cs index d65fe5cd67c..54c8ca0f5fd 100644 --- a/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/NuGetConfigMergerTests.cs @@ -26,7 +26,7 @@ private static async Task WriteConfigAsync(DirectoryInfo dir, string c return new FileInfo(path); } - private static PackageChannel CreateChannel(PackageMapping[] mappings) => PackageChannel.CreateExplicitChannel("test", PackageChannelQuality.Both, mappings, new FakeNuGetPackageCache()); + private static PackageChannel CreateChannel(PackageMapping[] mappings) => PackageChannel.CreateExplicitChannel("test", PackageChannelQuality.Both, mappings, new FakeNuGetPackageCache(), new TestFeatures()); [Fact] public async Task CreateOrUpdateAsync_CreatesConfigFromMappings_WhenNoExistingConfig() @@ -191,7 +191,7 @@ await WriteConfigAsync(root, new PackageMapping("Aspire.*", stableSource) }; - var channel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Stable, PackageChannelQuality.Both, mappings, new FakeNuGetPackageCache()); + var channel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Stable, PackageChannelQuality.Both, mappings, new FakeNuGetPackageCache(), new TestFeatures()); await NuGetConfigMerger.CreateOrUpdateAsync(root, channel).DefaultTimeout(); var xml = XDocument.Load(Path.Combine(root.FullName, "nuget.config")); diff --git a/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs index 0f5ddb73fdd..981a2e1b527 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackageChannelTests.cs @@ -1,13 +1,16 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Cli.Configuration; using Aspire.Cli.Packaging; using Aspire.Cli.Resources; using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Tests.Utils; +using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.Packaging; -public class PackageChannelTests +public class PackageChannelTests(ITestOutputHelper outputHelper) { [Fact] public void SourceDetails_ImplicitChannel_ReturnsBasedOnNuGetConfig() @@ -16,7 +19,7 @@ public void SourceDetails_ImplicitChannel_ReturnsBasedOnNuGetConfig() var cache = new FakeNuGetPackageCache(); // Act - var channel = PackageChannel.CreateImplicitChannel(cache); + var channel = PackageChannel.CreateImplicitChannel(cache, new TestFeatures()); // Assert Assert.Equal(PackagingStrings.BasedOnNuGetConfig, channel.SourceDetails); @@ -36,7 +39,7 @@ public void SourceDetails_ExplicitChannelWithAspireMapping_ReturnsSourceFromMapp }; // Act - var channel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Prerelease, mappings, cache); + var channel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Prerelease, mappings, cache, new TestFeatures()); // Assert Assert.Equal(aspireSource, channel.SourceDetails); @@ -56,7 +59,7 @@ public void SourceDetails_ExplicitChannelWithPrHivePath_ReturnsLocalPath() }; // Act - var channel = PackageChannel.CreateExplicitChannel("pr-10981", PackageChannelQuality.Prerelease, mappings, cache); + var channel = PackageChannel.CreateExplicitChannel("pr-10981", PackageChannelQuality.Prerelease, mappings, cache, new TestFeatures()); // Assert Assert.Equal(prHivePath, channel.SourceDetails); @@ -76,7 +79,7 @@ public void SourceDetails_ExplicitChannelWithStagingUrl_ReturnsStagingUrl() }; // Act - var channel = PackageChannel.CreateExplicitChannel("staging", PackageChannelQuality.Stable, mappings, cache, configureGlobalPackagesFolder: true); + var channel = PackageChannel.CreateExplicitChannel("staging", PackageChannelQuality.Stable, mappings, cache, new TestFeatures(), configureGlobalPackagesFolder: true); // Assert Assert.Equal(stagingUrl, channel.SourceDetails); @@ -92,10 +95,174 @@ public void SourceDetails_EmptyMappingsArray_ReturnsBasedOnNuGetConfig() var mappings = Array.Empty(); // Act - var channel = PackageChannel.CreateExplicitChannel("empty", PackageChannelQuality.Stable, mappings, cache); + var channel = PackageChannel.CreateExplicitChannel("empty", PackageChannelQuality.Stable, mappings, cache, new TestFeatures()); // Assert Assert.Equal(PackagingStrings.BasedOnNuGetConfig, channel.SourceDetails); Assert.Equal(PackageChannelType.Explicit, channel.Type); } + + [Fact] + public async Task GetIntegrationPackagesAsync_WithPinnedLocalSource_ReturnsOnlyPinnedLocalIntegrationPackages() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var packagesDirectory = workspace.CreateDirectory("packages"); + const string pinnedVersion = "13.4.0-pr.16820.gabcdef"; + + // Kept — Aspire.Hosting.* / CommunityToolkit.Aspire.Hosting.* integration namespaces. + File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.Hosting.Redis.{pinnedVersion}.nupkg"), string.Empty); + File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.Hosting.PostgreSQL.{pinnedVersion}.nupkg"), string.Empty); + File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"CommunityToolkit.Aspire.Hosting.NodeJS.{pinnedVersion}.nupkg"), string.Empty); + + // Dropped — pinned-version mismatch (otherwise-eligible integration at the wrong version). + File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.Hosting.SqlServer.13.3.0.nupkg"), string.Empty); + + // Dropped — outside the integration namespace. + File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.ProjectTemplates.{pinnedVersion}.nupkg"), string.Empty); + + // Dropped — internal Aspire framework packages (AppHost, Sdk, Orchestration.*, Testing, Msi). + // Orchestration is seeded with a RID-suffixed shape because no bare + // Aspire.Hosting.Orchestration nupkg is produced by the build; the exclusion is a + // prefix rule, so one RID variant exercises the rule against a realistic package name + // (a regression that tightened StartsWith to Equals would leak every . variant). + File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.Hosting.AppHost.{pinnedVersion}.nupkg"), string.Empty); + File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.Hosting.Sdk.{pinnedVersion}.nupkg"), string.Empty); + File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.Hosting.Orchestration.linux-arm64.{pinnedVersion}.nupkg"), string.Empty); + File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.Hosting.Testing.{pinnedVersion}.nupkg"), string.Empty); + File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.Hosting.Msi.{pinnedVersion}.nupkg"), string.Empty); + + // Dropped — deprecated packages enumerated in DeprecatedPackages. + File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.Hosting.Dapr.{pinnedVersion}.nupkg"), string.Empty); + File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.Hosting.NodeJs.{pinnedVersion}.nupkg"), string.Empty); + + var cache = new FakeNuGetPackageCache + { + GetIntegrationPackagesAsyncCallback = (_, _, _, _) => throw new InvalidOperationException("Local package sources should be enumerated directly.") + }; + var packageSource = packagesDirectory.FullName.Replace('\\', '/'); + var mappings = new[] + { + new PackageMapping("Aspire*", packageSource), + new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") + }; + var channel = PackageChannel.CreateExplicitChannel("local", PackageChannelQuality.Both, mappings, cache, new TestFeatures(), pinnedVersion: pinnedVersion); + + var packages = (await channel.GetIntegrationPackagesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout()).ToArray(); + + Assert.Collection( + packages, + package => + { + Assert.Equal("Aspire.Hosting.PostgreSQL", package.Id); + Assert.Equal(pinnedVersion, package.Version); + Assert.Equal(packageSource, package.Source); + }, + package => + { + Assert.Equal("Aspire.Hosting.Redis", package.Id); + Assert.Equal(pinnedVersion, package.Version); + Assert.Equal(packageSource, package.Source); + }, + package => + { + Assert.Equal("CommunityToolkit.Aspire.Hosting.NodeJS", package.Id); + Assert.Equal(pinnedVersion, package.Version); + Assert.Equal(packageSource, package.Source); + }); + } + + [Fact] + public async Task GetIntegrationPackagesAsync_WithStableLocalSource_ReturnsOnlyStablePackages() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var packagesDirectory = workspace.CreateDirectory("packages"); + + File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.Hosting.Redis.13.4.0.nupkg"), string.Empty); + File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.Hosting.Redis.13.5.0-preview.1.nupkg"), string.Empty); + File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.Hosting.PostgreSQL.13.4.0-preview.1.nupkg"), string.Empty); + + var channel = CreateLocalChannel(packagesDirectory, PackageChannelQuality.Stable); + + var packages = (await channel.GetIntegrationPackagesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout()).ToArray(); + + var package = Assert.Single(packages); + Assert.Equal("Aspire.Hosting.Redis", package.Id); + Assert.Equal("13.4.0", package.Version); + } + + [Fact] + public async Task GetIntegrationPackagesAsync_WithPrereleaseLocalSource_ReturnsOnlyPrereleasePackages() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var packagesDirectory = workspace.CreateDirectory("packages"); + + File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.Hosting.Redis.13.4.0.nupkg"), string.Empty); + File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.Hosting.Redis.13.5.0-preview.1.nupkg"), string.Empty); + File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.Hosting.PostgreSQL.13.4.0.nupkg"), string.Empty); + + var channel = CreateLocalChannel(packagesDirectory, PackageChannelQuality.Prerelease); + + var packages = (await channel.GetIntegrationPackagesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout()).ToArray(); + + var package = Assert.Single(packages); + Assert.Equal("Aspire.Hosting.Redis", package.Id); + Assert.Equal("13.5.0-preview.1", package.Version); + } + + [Fact] + public async Task GetIntegrationPackagesAsync_LocalFolderSource_FiltersDeprecatedByDefault() + { + // Mirrors the feed-based behavior in NuGetPackageCache: when the + // ShowDeprecatedPackages feature flag is off (the default), deprecated + // integration package ids must be hidden from local-hive / PR-hive listings. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var packagesDirectory = workspace.CreateDirectory("packages"); + + File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.Hosting.Dapr.13.4.0.nupkg"), string.Empty); + File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.Hosting.Sql.13.4.0.nupkg"), string.Empty); + + var channel = CreateLocalChannel(packagesDirectory, PackageChannelQuality.Stable); + + var packages = (await channel.GetIntegrationPackagesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout()).ToArray(); + + Assert.DoesNotContain(packages, p => string.Equals(p.Id, "Aspire.Hosting.Dapr", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(packages, p => string.Equals(p.Id, "Aspire.Hosting.Sql", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task GetIntegrationPackagesAsync_LocalFolderSource_IncludesDeprecatedWhenFlagEnabled() + { + // When ShowDeprecatedPackages is enabled, deprecated ids must appear in + // local-hive listings just as they do on the feed-based path; without this, + // a user who flipped the flag silently sees nothing change on PR/local hives. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var packagesDirectory = workspace.CreateDirectory("packages"); + + File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.Hosting.Dapr.13.4.0.nupkg"), string.Empty); + File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.Hosting.Sql.13.4.0.nupkg"), string.Empty); + + var features = new TestFeatures().SetFeature(KnownFeatures.ShowDeprecatedPackages, true); + var channel = CreateLocalChannel(packagesDirectory, PackageChannelQuality.Stable, features); + + var packages = (await channel.GetIntegrationPackagesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout()).ToArray(); + + Assert.Contains(packages, p => string.Equals(p.Id, "Aspire.Hosting.Dapr", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(packages, p => string.Equals(p.Id, "Aspire.Hosting.Sql", StringComparison.OrdinalIgnoreCase)); + } + + private static PackageChannel CreateLocalChannel(DirectoryInfo packagesDirectory, PackageChannelQuality quality, IFeatures? features = null) + { + var cache = new FakeNuGetPackageCache + { + GetIntegrationPackagesAsyncCallback = (_, _, _, _) => throw new InvalidOperationException("Local package sources should be enumerated directly.") + }; + var packageSource = packagesDirectory.FullName.Replace('\\', '/'); + var mappings = new[] + { + new PackageMapping("Aspire*", packageSource), + new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") + }; + + return PackageChannel.CreateExplicitChannel("local", quality, mappings, cache, features ?? new TestFeatures()); + } } diff --git a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs index 5f5f373254e..5b25a742709 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs @@ -1506,7 +1506,7 @@ public async Task GetChannelsAsync_WhenIdentityChannelIsNotLocal_HiveKeepsDirect /// /// Verifies that for a local hive channel with a pinned version, GetIntegrationPackagesAsync - /// enumerates .nupkg files directly from the local folder and returns all Aspire.Hosting.* + /// enumerates .nupkg files directly from the local folder and returns Aspire.Hosting.* integration /// packages without calling dotnet package search (which does not support local folder sources). /// [Fact] @@ -1522,8 +1522,9 @@ public async Task LocalHiveChannel_WithPinnedVersion_ReturnsIntegrationPackagesF localPackagesDir.Create(); const string localVersion = "13.4.0-pr.16820.g1a99aa46"; - // Hosting integration packages that should be returned + // Root hosting package that should not appear in integration search File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.Hosting.{localVersion}.nupkg"), string.Empty); + // Hosting integration packages that should be returned File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.Hosting.Redis.{localVersion}.nupkg"), string.Empty); File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.Hosting.JavaScript.{localVersion}.nupkg"), string.Empty); // Non-hosting packages that should NOT be returned by GetIntegrationPackagesAsync @@ -1539,12 +1540,12 @@ public async Task LocalHiveChannel_WithPinnedVersion_ReturnsIntegrationPackagesF // Assert var packageList = integrationPackages.ToList(); - Assert.Equal(3, packageList.Count); + Assert.Equal(2, packageList.Count); Assert.All(packageList, p => Assert.Equal(localVersion, p.Version)); - Assert.Contains(packageList, p => p.Id == "Aspire.Hosting"); Assert.Contains(packageList, p => p.Id == "Aspire.Hosting.Redis"); Assert.Contains(packageList, p => p.Id == "Aspire.Hosting.JavaScript"); // Non-hosting packages must not appear + Assert.DoesNotContain(packageList, p => p.Id == "Aspire.Hosting"); Assert.DoesNotContain(packageList, p => p.Id == "Aspire.ProjectTemplates"); Assert.DoesNotContain(packageList, p => p.Id == "Aspire.AppHost.Sdk"); } diff --git a/tests/Aspire.Cli.Tests/Packaging/TemporaryNuGetConfigTests.cs b/tests/Aspire.Cli.Tests/Packaging/TemporaryNuGetConfigTests.cs index 7bf5f2bf270..dcf8d9305fa 100644 --- a/tests/Aspire.Cli.Tests/Packaging/TemporaryNuGetConfigTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/TemporaryNuGetConfigTests.cs @@ -124,4 +124,60 @@ [new PackageMapping("Aspire.*", "https://example.com/feed")], Assert.NotNull(globalPackagesFolder); Assert.Equal(".nugetpackages", globalPackagesFolder!.Attributes!["value"]!.Value); } + + [Theory] + [InlineData("https://example.com/feed")] + [InlineData("/var/folders/X/hives/pr-17105/packages")] + [InlineData(@"C:\Users\X\.aspire\hives\pr-17105\packages")] + public async Task CreateAsync_PackageSourceAddKeyMatchesPackageSourceMappingKey(string source) + { + // Bug B defense: NuGet's packageSourceMapping lookup matches the + // attribute against the source name registered + // from . A future refactor that splits + // those keys (or canonicalizes one side and not the other) would silently + // drop the mapping. This invariant lives at the writer; pin it. + // + // Note that we ALSO need the source written here to be in the form NuGet + // will accept after its own internal canonicalization (e.g. on macOS the + // upstream caller must strip /private/var → /var before constructing the + // PackageMapping — see CliPathHelper.StripMacOSFirmlinkPrefix and the + // GetAspireHomeDirectory_OnMacOS_PrRouteWithFirmlinkedProcessPath test). + // This test only pins the writer's symmetry contract. + var mappings = new PackageMapping[] + { + new("Aspire*", source), + new(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json"), + }; + + using var tempConfig = await TemporaryNuGetConfig.CreateAsync(mappings); + + var xmlDoc = new XmlDocument(); + xmlDoc.LoadXml(await File.ReadAllTextAsync(tempConfig.ConfigFile.FullName)); + + // Collect entries (filter out ). + var addNodes = xmlDoc.SelectNodes("//packageSources/add")!; + var addKeys = new List(); + foreach (XmlNode add in addNodes) + { + addKeys.Add(add.Attributes!["key"]!.Value); + Assert.Equal(add.Attributes!["key"]!.Value, add.Attributes!["value"]!.Value); + } + + // Collect entries. + var mappingNodes = xmlDoc.SelectNodes("//packageSourceMapping/packageSource")!; + var mappingKeys = new List(); + foreach (XmlNode m in mappingNodes) + { + mappingKeys.Add(m.Attributes!["key"]!.Value); + } + + // Every mapping key must have a matching , byte-for-byte. + foreach (var mappingKey in mappingKeys) + { + Assert.Contains(mappingKey, addKeys); + } + + // The mapping for our source must be present and exactly equal the input source. + Assert.Contains(source, mappingKeys); + } } diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs index 6ef1f7fc86b..3fce1851cc7 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs @@ -281,15 +281,15 @@ await File.WriteAllTextAsync(aspireConfigPath, """ { new PackageMapping("Aspire*", prOldHivePath), new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") - }, nugetCache); + }, nugetCache, new TestFeatures()); var prNewChannel = PackageChannel.CreateExplicitChannel("pr-new", PackageChannelQuality.Prerelease, new[] { new PackageMapping("Aspire*", prNewHivePath), new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") - }, nugetCache); + }, nugetCache, new TestFeatures()); - var implicitChannel = PackageChannel.CreateImplicitChannel(nugetCache); + var implicitChannel = PackageChannel.CreateImplicitChannel(nugetCache, new TestFeatures()); return Task.FromResult>(new[] { implicitChannel, prOldChannel, prNewChannel }); } @@ -374,7 +374,7 @@ await File.WriteAllTextAsync(aspireConfigPath, """ { new PackageMapping("Aspire*", "https://pkgs.dev.azure.com/fake/v3/index.json"), new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") - }, nugetCache); + }, nugetCache, new TestFeatures()); var packagingService = new TestPackagingService { GetChannelsAsyncCallback = _ => Task.FromResult>(new[] { dailyChannel }) @@ -426,7 +426,7 @@ await File.WriteAllTextAsync(aspireConfigPath, """ var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Prerelease, new[] { new PackageMapping("Aspire*", channelFeed) - }, nugetCache); + }, nugetCache, new TestFeatures()); var packagingService = new TestPackagingService { GetChannelsAsyncCallback = _ => Task.FromResult>(new[] { dailyChannel }) diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs index a00f348c5fd..7e3a30b7038 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs @@ -577,7 +577,7 @@ await File.WriteAllTextAsync(configPath, """ ]) }; - var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache); + var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures()); var interactionService = new TestInteractionService { @@ -676,7 +676,8 @@ await File.WriteAllTextAsync(configPath, """ PackageChannelNames.Stable, PackageChannelQuality.Both, [new PackageMapping("Aspire.*", "stable")], - stableCache); + stableCache, + features: new TestFeatures()); var interactionService = new TestInteractionService { @@ -730,7 +731,8 @@ await File.WriteAllTextAsync(configPath, """ PackageChannelNames.Staging, PackageChannelQuality.Both, [new PackageMapping("Aspire*", "staging")], - stagingCache); + stagingCache, + features: new TestFeatures()); var interactionService = new TestInteractionService { @@ -785,7 +787,8 @@ await File.WriteAllTextAsync(configPath, """ PackageChannelNames.Stable, PackageChannelQuality.Both, [new PackageMapping("Aspire.*", "stable")], - stableCache); + stableCache, + features: new TestFeatures()); var project = CreateGuestAppHostProject(); diff --git a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs index 7c94fc81d5d..ded0ee4c0b1 100644 --- a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs @@ -431,6 +431,7 @@ public async Task TryCreateTemporaryNuGetConfig_LocalIdentity_StagingRequested_E quality: PackageChannelQuality.Both, mappings: mappings, nuGetPackageCache: new FakeNuGetPackageCache(), + features: new TestFeatures(), configureGlobalPackagesFolder: true); var server = CreateServerWithChannel(workspace, stagingChannel, executionContext); @@ -502,7 +503,8 @@ public async Task TryCreateTemporaryNuGetConfig_WithPackageSourceOverrideWithout new PackageMapping("Aspire*", channelSource), new PackageMapping(PackageMapping.AllPackages, NuGetOrgSource) ], - nuGetPackageCache: new FakeNuGetPackageCache()); + nuGetPackageCache: new FakeNuGetPackageCache(), + features: new TestFeatures()); var server = CreateServerWithChannel(workspace, explicitChannel, CreateContextWithIdentityChannel("pr-12345")); using var result = await InvokeTryCreateTemporaryNuGetConfigAsync( @@ -528,6 +530,7 @@ public async Task TryCreateTemporaryNuGetConfig_WithPackageSourceOverride_Preser quality: PackageChannelQuality.Both, mappings: [new PackageMapping("CommunityToolkit*", channelSource)], nuGetPackageCache: new FakeNuGetPackageCache(), + features: new TestFeatures(), configureGlobalPackagesFolder: true); var server = CreateServerWithChannel(workspace, stagingChannel, CreateContextWithIdentityChannel("pr-12345")); @@ -556,7 +559,8 @@ public async Task TryCreateTemporaryNuGetConfig_WithPackageSourceOverride_DropsR name: "staging", quality: PackageChannelQuality.Both, mappings: [new PackageMapping("Aspire*", channelSource), new PackageMapping(PackageMapping.AllPackages, NuGetOrgSource)], - nuGetPackageCache: new FakeNuGetPackageCache()); + nuGetPackageCache: new FakeNuGetPackageCache(), + features: new TestFeatures()); var server = CreateServerWithChannel(workspace, stagingChannel, CreateContextWithIdentityChannel("pr-12345")); using var result = await InvokeTryCreateTemporaryNuGetConfigAsync( @@ -601,7 +605,8 @@ public async Task TryCreateTemporaryNuGetConfig_WithPackageSourceOverride_UsesCh name: "staging", quality: PackageChannelQuality.Both, mappings: [new PackageMapping(PackageMapping.AllPackages, channelSource)], - nuGetPackageCache: new FakeNuGetPackageCache()); + nuGetPackageCache: new FakeNuGetPackageCache(), + features: new TestFeatures()); var server = CreateServerWithChannel(workspace, stagingChannel, CreateContextWithIdentityChannel("pr-12345")); using var result = await InvokeTryCreateTemporaryNuGetConfigAsync( @@ -654,7 +659,8 @@ public async Task GetNuGetSources_WithPackageSourceOverrideAndMatchedChannel_Omi name: "staging", quality: PackageChannelQuality.Both, mappings: [new PackageMapping("Aspire*", channelSource)], - nuGetPackageCache: new FakeNuGetPackageCache()); + nuGetPackageCache: new FakeNuGetPackageCache(), + features: new TestFeatures()); var server = CreateServerWithChannel(workspace, stagingChannel, CreateContextWithIdentityChannel("pr-12345")); var sources = await InvokeGetNuGetSourcesAsync(server, requestedChannel: "staging", packageSourceOverride: packageSourceOverride); @@ -675,7 +681,8 @@ public async Task GetNuGetSources_WithPackageSourceOverrideAndMatchedChannelNonA name: "staging", quality: PackageChannelQuality.Both, mappings: [new PackageMapping("CommunityToolkit*", channelSource)], - nuGetPackageCache: new FakeNuGetPackageCache()); + nuGetPackageCache: new FakeNuGetPackageCache(), + features: new TestFeatures()); var server = CreateServerWithChannel(workspace, stagingChannel, CreateContextWithIdentityChannel("pr-12345")); var sources = await InvokeGetNuGetSourcesAsync(server, requestedChannel: "staging", packageSourceOverride: packageSourceOverride); @@ -698,7 +705,8 @@ public async Task GetNuGetSources_WithPackageSourceOverrideAndMatchedChannelAllP name: "staging", quality: PackageChannelQuality.Both, mappings: [new PackageMapping(PackageMapping.AllPackages, channelSource)], - nuGetPackageCache: new FakeNuGetPackageCache()); + nuGetPackageCache: new FakeNuGetPackageCache(), + features: new TestFeatures()); var server = CreateServerWithChannel(workspace, stagingChannel, CreateContextWithIdentityChannel("pr-12345")); var sources = await InvokeGetNuGetSourcesAsync(server, requestedChannel: "staging", packageSourceOverride: packageSourceOverride); @@ -792,7 +800,7 @@ public async Task GetNuGetSources_NonStagingRequest_NotAffectedByStagingUnavaila new PackageMapping(PackageMapping.AllPackages, "https://pkgs.dev.azure.com/fake/v3/index.json") }; var dailyChannel = PackageChannel.CreateExplicitChannel( - "daily", PackageChannelQuality.Both, mappings, new FakeNuGetPackageCache()); + "daily", PackageChannelQuality.Both, mappings, new FakeNuGetPackageCache(), new TestFeatures()); var packagingService = new TestPackagingService { GetChannelsAsyncCallback = _ => Task.FromResult>([dailyChannel]), @@ -837,7 +845,7 @@ private static PrebuiltAppHostServer CreateServerWithUnavailableStagingChannel( new PackageMapping(PackageMapping.AllPackages, "https://pkgs.dev.azure.com/fake/v3/index.json") }; var dailyChannel = PackageChannel.CreateExplicitChannel( - "daily", PackageChannelQuality.Both, mappings, new FakeNuGetPackageCache()); + "daily", PackageChannelQuality.Both, mappings, new FakeNuGetPackageCache(), new TestFeatures()); var packagingService = new TestPackagingService { @@ -885,7 +893,7 @@ private static PrebuiltAppHostServer CreateServerWithExplicitChannel( new PackageMapping(PackageMapping.AllPackages, "https://pkgs.dev.azure.com/fake/v3/index.json") }; var channel = PackageChannel.CreateExplicitChannel( - channelName, PackageChannelQuality.Both, mappings, new FakeNuGetPackageCache()); + channelName, PackageChannelQuality.Both, mappings, new FakeNuGetPackageCache(), new TestFeatures()); return CreateServerWithChannel(workspace, channel, executionContext); } @@ -1157,7 +1165,8 @@ await File.WriteAllTextAsync(aspireConfigPath, $$""" name: channelName, quality: PackageChannelQuality.Both, mappings: [new PackageMapping("Aspire*", packageSource.FullName)], - nuGetPackageCache: new FakeNuGetPackageCache()); + nuGetPackageCache: new FakeNuGetPackageCache(), + features: new TestFeatures()); var packagingService = new TestPackagingService { GetChannelsAsyncCallback = _ => Task.FromResult>([channel]) @@ -1220,7 +1229,8 @@ await File.WriteAllTextAsync(aspireConfigPath, """ new PackageMapping("Aspire*", hivePackageSource.FullName), new PackageMapping(PackageMapping.AllPackages, channelSource) ], - nuGetPackageCache: new FakeNuGetPackageCache()); + nuGetPackageCache: new FakeNuGetPackageCache(), + features: new TestFeatures()); var packagingService = new TestPackagingService { GetChannelsAsyncCallback = _ => Task.FromResult>([channel]) @@ -1274,7 +1284,8 @@ await File.WriteAllTextAsync(aspireConfigPath, """ name: "daily", quality: PackageChannelQuality.Both, mappings: [new PackageMapping("Aspire*", channelSource)], - nuGetPackageCache: new FakeNuGetPackageCache()); + nuGetPackageCache: new FakeNuGetPackageCache(), + features: new TestFeatures()); var packagingService = new TestPackagingService { GetChannelsAsyncCallback = _ => Task.FromResult>([channel]) @@ -1388,7 +1399,8 @@ await File.WriteAllTextAsync(aspireConfigPath, """ name: "pr-12345", quality: PackageChannelQuality.Both, mappings: [new PackageMapping("Aspire*", missingPackageSource)], - nuGetPackageCache: new FakeNuGetPackageCache()); + nuGetPackageCache: new FakeNuGetPackageCache(), + features: new TestFeatures()); var packagingService = new TestPackagingService { GetChannelsAsyncCallback = _ => Task.FromResult>([channel]) @@ -1456,7 +1468,8 @@ await File.WriteAllTextAsync(aspireConfigPath, """ new PackageMapping("Aspire*", channelSource), new PackageMapping(PackageMapping.AllPackages, NuGetOrgSource) ], - nuGetPackageCache: new FakeNuGetPackageCache()); + nuGetPackageCache: new FakeNuGetPackageCache(), + features: new TestFeatures()); var packagingService = new TestPackagingService { GetChannelsAsyncCallback = _ => Task.FromResult>([dailyChannel]) @@ -1637,7 +1650,8 @@ await File.WriteAllTextAsync(aspireConfigPath, """ name: "daily", quality: PackageChannelQuality.Both, mappings: [new PackageMapping("Aspire*", channelSource)], - nuGetPackageCache: new FakeNuGetPackageCache()); + nuGetPackageCache: new FakeNuGetPackageCache(), + features: new TestFeatures()); var packagingService = new TestPackagingService { GetChannelsAsyncCallback = _ => Task.FromResult>([dailyChannel]) @@ -1716,7 +1730,8 @@ await File.WriteAllTextAsync(aspireConfigPath, """ name: "pr-12345", quality: PackageChannelQuality.Both, mappings: [new PackageMapping("Aspire*", localHive)], - nuGetPackageCache: new FakeNuGetPackageCache()); + nuGetPackageCache: new FakeNuGetPackageCache(), + features: new TestFeatures()); var packagingService = new TestPackagingService { GetChannelsAsyncCallback = _ => Task.FromResult>([prChannel]) @@ -1880,7 +1895,8 @@ public async Task PrepareAsync_WithStagingPinnedProjectOutsideLaunchDirectory_Us new PackageMapping("Aspire*", stagingFeed), new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json") ], - new FakeNuGetPackageCache()); + new FakeNuGetPackageCache(), + new TestFeatures()); var packagingService = new TestPackagingService { GetChannelsAsyncCallback = _ => Task.FromResult>([stagingChannel]) diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index 0a9cdf29808..fe8ce58149a 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -31,7 +31,7 @@ public DotNetTemplateFactoryTests(ITestOutputHelper outputHelper) } private static PackageChannel CreateExplicitChannel(PackageMapping[] mappings) => - PackageChannel.CreateExplicitChannel("test", PackageChannelQuality.Both, mappings, new FakeNuGetPackageCache()); + PackageChannel.CreateExplicitChannel("test", PackageChannelQuality.Both, mappings, new FakeNuGetPackageCache(), new TestFeatures()); private static async Task WriteNuGetConfigAsync(DirectoryInfo dir, string content) { @@ -218,7 +218,7 @@ public async Task NuGetConfigMerger_ImplicitChannel_DoesNothing() var workingDir = workspace.WorkspaceRoot; var outputDir = Directory.CreateDirectory(Path.Combine(workingDir.FullName, "MyProject")); - var channel = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache()); + var channel = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache(), new TestFeatures()); // Act await NuGetConfigMerger.CreateOrUpdateAsync(outputDir, channel).DefaultTimeout(); diff --git a/tests/Aspire.Cli.Tests/Templating/TemplateNuGetConfigServiceTests.cs b/tests/Aspire.Cli.Tests/Templating/TemplateNuGetConfigServiceTests.cs index 87994463017..91899402079 100644 --- a/tests/Aspire.Cli.Tests/Templating/TemplateNuGetConfigServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/TemplateNuGetConfigServiceTests.cs @@ -89,7 +89,8 @@ public async Task CreateOrUpdateNuGetConfigForSourceOverrideAsync_PreservesReque new PackageMapping("CommunityToolkit*", communitySource), new PackageMapping(PackageMapping.AllPackages, fallbackSource), ], - new FakeNuGetPackageCache()); + new FakeNuGetPackageCache(), + features: new TestFeatures()); return Task.FromResult>([channel]); } }; @@ -225,7 +226,7 @@ public async Task ResolveTemplatePackageAsync_NullRequestedChannel_UsesImplicitC var implicitCh = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache { GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult(Enumerable.Empty()) - }); + }, new TestFeatures()); return Task.FromResult>([implicitCh]); } }; @@ -258,7 +259,7 @@ public async Task ResolveTemplatePackage_RequestedChannel_NotFound_Throws() { GetChannelsAsyncCallback = _ => { - var implicitCh = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache()); + var implicitCh = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache(), new TestFeatures()); return Task.FromResult>([implicitCh]); } }; @@ -285,7 +286,7 @@ public async Task ResolveTemplatePackage_RequestedChannel_Matches_ReturnsThatCha { GetChannelsAsyncCallback = _ => { - var implicitCh = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache()); + var implicitCh = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache(), new TestFeatures()); var stableCh = PackageChannel.CreateExplicitChannel( "stable", PackageChannelQuality.Both, @@ -296,7 +297,8 @@ [new PackageMapping("Aspire*", "stable-src")], [ new Aspire.Shared.NuGetPackageCli { Id = TemplateNuGetConfigService.TemplatesPackageName, Version = "13.3.0", Source = "stable-src" } ]) - }); + }, + features: new TestFeatures()); return Task.FromResult>([implicitCh, stableCh]); } }; @@ -326,7 +328,7 @@ public async Task ResolveTemplatePackageAsync_NonExistentRequestedChannel_NotLoc { GetChannelsAsyncCallback = _ => { - var implicitCh = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache()); + var implicitCh = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache(), new TestFeatures()); return Task.FromResult>([implicitCh]); } }; @@ -387,12 +389,13 @@ public async Task ResolveTemplatePackageAsync_IncludePrHives_RespectsHiveGate( [ new Aspire.Shared.NuGetPackageCli { Id = TemplateNuGetConfigService.TemplatesPackageName, Version = "1.0.0", Source = "implicit-src" } ]) - }); + }, new TestFeatures()); var hiveCh = PackageChannel.CreateExplicitChannel( "pr-12345", PackageChannelQuality.Both, [new PackageMapping("Aspire*", "pr-src")], new FakeNuGetPackageCache(), + features: new TestFeatures(), pinnedVersion: "2.0.0"); return Task.FromResult>([implicitCh, hiveCh]); } diff --git a/tests/Aspire.Cli.Tests/TestServices/CapturingLogger.cs b/tests/Aspire.Cli.Tests/TestServices/CapturingLogger.cs new file mode 100644 index 00000000000..f450c133a13 --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/CapturingLogger.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Tests.TestServices; + +/// +/// In-memory logger that records every log call so tests can assert +/// on the structured rejection messages emitted by InstallationDiscovery. +/// +internal sealed class CapturingLogger : ILogger +{ + public List<(LogLevel Level, string Message)> Entries { get; } = new(); + + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + Entries.Add((logLevel, formatter(state, exception))); + } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + public void Dispose() { } + } +} diff --git a/tests/Aspire.Cli.Tests/TestServices/EnvVarOverride.cs b/tests/Aspire.Cli.Tests/TestServices/EnvVarOverride.cs new file mode 100644 index 00000000000..645a9e45962 --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/EnvVarOverride.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Tests.TestServices; + +/// +/// Restores an environment variable to its prior value on dispose. Used +/// in DiscoverAll tests to point the discovery walk at a controlled +/// HOME / USERPROFILE / PATH sandbox. +/// +internal sealed class EnvVarOverride : IDisposable +{ + private readonly string _name; + private readonly string? _previous; + + public EnvVarOverride(string name, string? value) + { + _name = name; + _previous = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(_name, _previous); + } +} diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeIdentityChannelReader.cs b/tests/Aspire.Cli.Tests/TestServices/FakeIdentityChannelReader.cs new file mode 100644 index 00000000000..1c9ead55c63 --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/FakeIdentityChannelReader.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Acquisition; + +namespace Aspire.Cli.Tests.TestServices; + +/// +/// In-memory for tests. Returns a fixed +/// channel string, or throws when +/// configured to simulate a misconfigured build with missing +/// AspireCliChannel assembly metadata. +/// +internal sealed class FakeIdentityChannelReader : IIdentityChannelReader +{ + private readonly string? _channel; + private readonly bool _throw; + + public FakeIdentityChannelReader(string channel) + { + _channel = channel; + _throw = false; + } + + public FakeIdentityChannelReader(bool throwOnRead) + { + _channel = null; + _throw = throwOnRead; + } + + public string ReadChannel() + { + if (_throw) + { + throw new InvalidOperationException("Simulated missing AspireCliChannel metadata."); + } + + return _channel!; + } +} diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeInstallationDiscovery.cs b/tests/Aspire.Cli.Tests/TestServices/FakeInstallationDiscovery.cs new file mode 100644 index 00000000000..6429874133e --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/FakeInstallationDiscovery.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Acquisition; + +namespace Aspire.Cli.Tests.TestServices; + +/// +/// Fixed-result for command tests. +/// Returns a curated self row and (optionally) curated peers from +/// , decoupling the test from host filesystem +/// state, $PATH, and any real sidecar files. +/// +internal sealed class FakeInstallationDiscovery : IInstallationDiscovery +{ + private readonly InstallationInfo _self; + private readonly IReadOnlyList _others; + private readonly Exception? _discoverAllException; + + public FakeInstallationDiscovery(InstallationInfo self, IReadOnlyList? others = null, Exception? discoverAllException = null) + { + _self = self; + _others = others ?? []; + _discoverAllException = discoverAllException; + } + + public InstallationInfo DescribeSelf() => _self; + + public Task> DiscoverAllAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_discoverAllException is not null) + { + throw _discoverAllException; + } + + // Self is always the first element by InstallationDiscovery contract. + IReadOnlyList all = [_self, .. _others]; + return Task.FromResult(all); + } +} diff --git a/tests/Aspire.Cli.Tests/TestServices/FakePeerInstallProbe.cs b/tests/Aspire.Cli.Tests/TestServices/FakePeerInstallProbe.cs new file mode 100644 index 00000000000..b392b5d8185 --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/FakePeerInstallProbe.cs @@ -0,0 +1,59 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Acquisition; + +namespace Aspire.Cli.Tests.TestServices; + +/// +/// In-memory for tests. Returns +/// scripted results based on the binary path passed in, so we can +/// exercise discovery + trust-gate logic without spawning real CLI +/// processes. +/// +internal sealed class FakePeerInstallProbe : IPeerInstallProbe +{ + private readonly Dictionary _responses; + private readonly StringComparer _pathComparer; + private readonly List _probedPaths = new(); + private readonly object _probedPathsLock = new(); + + public FakePeerInstallProbe(IDictionary? responses = null) + { + _pathComparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + _responses = new Dictionary(_pathComparer); + if (responses is not null) + { + foreach (var kvp in responses) + { + _responses[kvp.Key] = kvp.Value; + } + } + } + + // Snapshot accessor: returns a copy so callers can iterate without risking + // concurrent mutation. Mutations themselves are serialized via _probedPathsLock + // so ProbeAsync stays safe even if a future change parallelizes peer probes. + public IReadOnlyList ProbedPaths + { + get + { + lock (_probedPathsLock) + { + return _probedPaths.ToArray(); + } + } + } + + public Task ProbeAsync(string binaryPath, CancellationToken cancellationToken) + { + lock (_probedPathsLock) + { + _probedPaths.Add(binaryPath); + } + var result = _responses.TryGetValue(binaryPath, out var value) + ? value + : new PeerProbeResult.Failed("No response configured."); + return Task.FromResult(result); + } +} diff --git a/tests/Aspire.Cli.Tests/TestServices/TestPackagingService.cs b/tests/Aspire.Cli.Tests/TestServices/TestPackagingService.cs index 8840ee9591f..962b78ccf23 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestPackagingService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestPackagingService.cs @@ -28,7 +28,7 @@ public Task> GetChannelsAsync(CancellationToken canc } // Default: Return a fake channel with template packages - var testChannel = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache()); + var testChannel = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache(), new TestFeatures()); return Task.FromResult>(new[] { testChannel }); } diff --git a/tests/Aspire.Cli.Tests/TestServices/UnknownPeerProbeResult.cs b/tests/Aspire.Cli.Tests/TestServices/UnknownPeerProbeResult.cs new file mode 100644 index 00000000000..44b4fd21a06 --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/UnknownPeerProbeResult.cs @@ -0,0 +1,8 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Acquisition; + +namespace Aspire.Cli.Tests.TestServices; + +internal sealed record UnknownPeerProbeResult : PeerProbeResult; diff --git a/tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs b/tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs index 2c570508e98..c4aabe5dc7b 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliPathHelperTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Cli.Acquisition; using Aspire.Cli.Utils; namespace Aspire.Cli.Tests.Utils; @@ -48,4 +49,230 @@ public void CreateUnixDomainSocketPath_UsesRandomizedIdentifier() Assert.Matches("^apphost\\.sock\\.[a-f0-9]{12}$", Path.GetFileName(socketPath1)); Assert.Matches("^apphost\\.sock\\.[a-f0-9]{12}$", Path.GetFileName(socketPath2)); } + + [Theory] + [InlineData("script")] + [InlineData("localhive")] + public void TryGetAspireHomeDirectoryFromInstallRoute_SharedPrefixRoute_ReturnsInstallPrefix(string source) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var installPrefix = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire"); + var binDir = Path.Combine(installPrefix, "bin"); + var binaryPath = WriteBinaryWithSidecar(binDir, source); + + var result = CliPathHelper.TryGetAspireHomeDirectoryFromInstallRoute(binaryPath); + + Assert.Equal(installPrefix, result); + } + + [Fact] + public void TryGetAspireHomeDirectoryFromInstallRoute_PrRoute_ReturnsOuterInstallPrefix() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var installPrefix = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire-pr-test"); + var binDir = Path.Combine(installPrefix, "dogfood", "pr-17159", "bin"); + var binaryPath = WriteBinaryWithSidecar(binDir, "pr"); + + var result = CliPathHelper.TryGetAspireHomeDirectoryFromInstallRoute(binaryPath); + + Assert.Equal(installPrefix, result); + } + + [Theory] + [InlineData("brew")] + [InlineData("winget")] + [InlineData("dotnet-tool")] + [InlineData("unknown")] + public void TryGetAspireHomeDirectoryFromInstallRoute_PackageManagerOrUnknownRoute_ReturnsNull(string source) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var binaryPath = WriteBinaryWithSidecar(workspace.WorkspaceRoot.FullName, source); + + var result = CliPathHelper.TryGetAspireHomeDirectoryFromInstallRoute(binaryPath); + + Assert.Null(result); + } + + [Fact] + public void GetAspireHomeDirectory_PrRoute_UsesOuterInstallPrefix() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var installPrefix = Path.Combine(workspace.WorkspaceRoot.FullName, "portable"); + var binDir = Path.Combine(installPrefix, "dogfood", "pr-17159", "bin"); + var binaryPath = WriteBinaryWithSidecar(binDir, "pr"); + + var result = CliPathHelper.GetAspireHomeDirectory(binaryPath); + + Assert.Equal(installPrefix, result); + } + + [Fact] + public void ResolveSymlinkOrOriginalPath_NonLink_ReturnsOriginalPath() + { + const string path = "relative/path/aspire"; + + var result = CliPathHelper.ResolveSymlinkOrOriginalPath(path); + + Assert.Equal(path, result); + } + + [Fact] + public void ResolveSymlinkToFullPath_NonLink_ReturnsNormalizedFullPath() + { + var path = Path.Combine("relative", "path", "aspire"); + + var result = CliPathHelper.ResolveSymlinkToFullPath(path); + + Assert.Equal(Path.GetFullPath(path), result); + } + + [Fact] + public void ResolveSymlinkToFullPath_InvalidPath_ReturnsNull() + { + var result = CliPathHelper.ResolveSymlinkToFullPath("invalid\0path"); + + Assert.Null(result); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "Symlink resolution test only runs on Linux/macOS where unprivileged symlink creation is reliable.")] + public void ResolveSymlinkHelpers_Link_ReturnsTarget() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var target = Path.Combine(workspace.WorkspaceRoot.FullName, "target-aspire"); + File.WriteAllText(target, string.Empty); + + var link = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire"); + File.CreateSymbolicLink(link, target); + + Assert.Equal(target, CliPathHelper.ResolveSymlinkOrOriginalPath(link)); + Assert.Equal(target, CliPathHelper.ResolveSymlinkToFullPath(link)); + } + + // macOS uses APFS firmlinks (/var → /private/var, /tmp → /private/tmp, + // /etc → /private/etc) that .NET's Environment.ProcessPath and libc + // realpath() resolve to the /private/* form, while Path.GetFullPath, + // PathLookupHelper $PATH walks, and NuGet packageSourceMapping lookups + // use the un-prefixed form. The stripper rewrites the /private/* form + // back to the user-facing logical form so every comparison site agrees. + // Tests are table-driven and OS-agnostic — the stripper itself runs the + // same logic regardless of host OS; OS-gating happens in the wrapping + // resolve helpers (covered below). + [Theory] + [InlineData("/private/var/folders/X/aspire", "/var/folders/X/aspire")] + [InlineData("/private/tmp/aspire-pr-test/bin/aspire", "/tmp/aspire-pr-test/bin/aspire")] + [InlineData("/private/etc/hosts", "/etc/hosts")] + // Exact-prefix matches: the input is exactly the firmlink, no trailing slash or path component. + [InlineData("/private/var", "/var")] + [InlineData("/private/tmp", "/tmp")] + [InlineData("/private/etc", "/etc")] + // Trailing-slash form preserves the trailing slash after the rewrite. + [InlineData("/private/var/", "/var/")] + // Boundary safety: matching segments must end at a path separator. /private/varlog + // is NOT a firmlink (varlog is a non-existent sibling of var). Preserve as-is. + [InlineData("/private/varlog", "/private/varlog")] + [InlineData("/private/varfoo/X", "/private/varfoo/X")] + [InlineData("/private/tmpfile", "/private/tmpfile")] + [InlineData("/private/etchosts", "/private/etchosts")] + // No-firmlink shapes pass through unchanged. + [InlineData("/var/folders/X/aspire", "/var/folders/X/aspire")] + [InlineData("/tmp/aspire", "/tmp/aspire")] + [InlineData("/etc/hosts", "/etc/hosts")] + [InlineData("/usr/local/bin/aspire", "/usr/local/bin/aspire")] + [InlineData("/Users/ankj/.aspire/bin/aspire", "/Users/ankj/.aspire/bin/aspire")] + // /private without a firmlink subdir, or with a non-firmlink subdir, must not strip. + // /Users, /Applications, /Library are real on macOS (not firmlinked through /private). + [InlineData("/private", "/private")] + [InlineData("/private/", "/private/")] + [InlineData("/private/Users/foo", "/private/Users/foo")] + [InlineData("/private/opt/X", "/private/opt/X")] + // Empty input passes through unchanged. + [InlineData("", "")] + // Windows-style path: starts with a drive letter, so the /private/ prefix can't match. + [InlineData(@"C:\Users\X\.aspire\bin\aspire.exe", @"C:\Users\X\.aspire\bin\aspire.exe")] + public void StripMacOSFirmlinkPrefix_RewritesFirmlinksAndPreservesEverythingElse(string input, string expected) + { + Assert.Equal(expected, CliPathHelper.StripMacOSFirmlinkPrefix(input)); + } + + [Fact] + public void StripMacOSFirmlinkPrefix_IsCaseSensitive() + { + // APFS case-sensitive volumes can host a literal /Private/Var/... directory tree + // that is not the firmlink. Use Ordinal comparison so we never rewrite a real + // user-created path that only differs in case from the firmlink prefix. + Assert.Equal("/Private/Var/folders/X", CliPathHelper.StripMacOSFirmlinkPrefix("/Private/Var/folders/X")); + Assert.Equal("/PRIVATE/VAR/folders/X", CliPathHelper.StripMacOSFirmlinkPrefix("/PRIVATE/VAR/folders/X")); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Windows | TestPlatforms.Linux, "Firmlink stripping in resolve helpers only applies on macOS.")] + public void ResolveSymlinkToFullPath_OnMacOS_StripsFirmlinkPrefix() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + // Place a real file under the workspace (which sits on /var/folders on macOS), + // then construct the firmlinked-form input by prepending /private. Both forms + // resolve to the same physical file at the kernel level, so File.Exists + // returns true for either spelling. + var realPath = Path.Combine(workspace.WorkspaceRoot.FullName, "binary-under-test"); + File.WriteAllText(realPath, string.Empty); + + var firmlinkedPath = "/private" + realPath; + Assert.True(File.Exists(firmlinkedPath), "firmlinked /private/var path should resolve to the same physical file"); + + var result = CliPathHelper.ResolveSymlinkToFullPath(firmlinkedPath); + + Assert.Equal(realPath, result); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Windows | TestPlatforms.Linux, "Firmlink stripping in resolve helpers only applies on macOS.")] + public void ResolveSymlinkOrOriginalPath_OnMacOS_StripsFirmlinkPrefix() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var realPath = Path.Combine(workspace.WorkspaceRoot.FullName, "binary-under-test"); + File.WriteAllText(realPath, string.Empty); + + var firmlinkedPath = "/private" + realPath; + + var result = CliPathHelper.ResolveSymlinkOrOriginalPath(firmlinkedPath); + + Assert.Equal(realPath, result); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Windows | TestPlatforms.Linux, "Firmlink propagation only applies on macOS.")] + public void GetAspireHomeDirectory_OnMacOS_PrRouteWithFirmlinkedProcessPath_ReturnsUnfirmlinkedPrefix() + { + // Bug B regression: when Environment.ProcessPath comes back firmlinked (/private/var/...), + // every derivation hanging off it (AspireHome → HivesDirectory → PackagingService source path) + // inherits the /private/ form and lands in nuget.config in a shape NuGet's packageSourceMapping + // lookup silently drops. The fix lives in the resolve helpers; this test pins the propagation + // so a future refactor doesn't reintroduce the asymmetry. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var installPrefix = Path.Combine(workspace.WorkspaceRoot.FullName, "portable"); + var binDir = Path.Combine(installPrefix, "dogfood", "pr-17105", "bin"); + var binaryPath = WriteBinaryWithSidecar(binDir, "pr"); + + // Construct the firmlinked-form input. workspace.WorkspaceRoot is rooted under + // /var/folders, so prepending /private produces the form Environment.ProcessPath + // would carry on macOS. + var firmlinkedProcessPath = "/private" + binaryPath; + + var result = CliPathHelper.GetAspireHomeDirectory(firmlinkedProcessPath); + + // Expected un-firmlinked AspireHome: drop /private/ from the prefix. + Assert.Equal(installPrefix, result); + Assert.DoesNotContain("/private/", result, StringComparison.Ordinal); + } + + private static string WriteBinaryWithSidecar(string binaryDir, string source) + { + Directory.CreateDirectory(binaryDir); + var binaryPath = Path.Combine(binaryDir, OperatingSystem.IsWindows() ? "aspire.exe" : "aspire"); + File.WriteAllText(binaryPath, string.Empty); + File.WriteAllText(Path.Combine(binaryDir, InstallSidecarReader.SidecarFileName), $$"""{"source":"{{source}}"}"""); + + return binaryPath; + } } diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 545ded68c96..d45b06d73b8 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text; +using Aspire.Cli.Acquisition; using Aspire.Cli.Agents; using Aspire.Cli.Agents.Playwright; using Aspire.Cli.Backchannel; @@ -160,6 +161,17 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddSingleton(options.BundlePayloadProviderFactory); services.AddSingleton(options.BundleServiceFactory); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + // Always register the null reader by default so unit tests don't reach into the + // actual user registry through WingetFirstRunProbe on Windows. Tests that need + // the real reader (or a fake) should replace the registration explicitly. + services.AddSingleton(); + // IdentityChannelReader for AspireVersionCheck (doctor) — uses the same + // pattern as production wiring in Program.cs. + services.AddSingleton(_ => new IdentityChannelReader(typeof(Program).Assembly)); services.AddSingleton(); // AppHost project handlers - must match Program.cs registration pattern diff --git a/tests/Aspire.Hosting.Tests/PathLookupHelperTests.cs b/tests/Aspire.Hosting.Tests/PathLookupHelperTests.cs index d02d4d91235..a9bf07567a6 100644 --- a/tests/Aspire.Hosting.Tests/PathLookupHelperTests.cs +++ b/tests/Aspire.Hosting.Tests/PathLookupHelperTests.cs @@ -174,6 +174,100 @@ public void FindFullPathFromPath_ReturnsFirstMatchFromPath() Assert.Equal(Path.Combine("/first/path", "mycommand"), result); } + [Fact] + public void FindAllFullPathsFromPath_ReturnsMatchesFromPath() + { + var firstPath = Path.Combine("/first/path", "mycommand"); + var secondPath = Path.Combine("/second/path", "mycommand"); + var existingFiles = new HashSet + { + firstPath, + secondPath + }; + + var result = PathLookupHelper.FindAllFullPathsFromPath("mycommand", "/first/path:/second/path", ':', existingFiles.Contains, null); + + Assert.Equal([firstPath, secondPath], result); + } + + [Fact] + public void FindAllFullPathsFromPath_WhenCommandNotOnPath_ReturnsEmpty() + { + static bool AlwaysFalse(string _) => false; + + var result = PathLookupHelper.FindAllFullPathsFromPath("mycommand", "/usr/bin:/usr/local/bin", ':', AlwaysFalse, null); + + Assert.Empty(result); + } + + [Fact] + public void FindAllFullPathsFromPath_SkipsNonExecutableFilesOnUnix() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + using var tempDirectory = new TestTempDirectory(); + var firstBinPath = Path.Combine(tempDirectory.Path, "first-bin"); + var secondBinPath = Path.Combine(tempDirectory.Path, "second-bin"); + Directory.CreateDirectory(firstBinPath); + Directory.CreateDirectory(secondBinPath); + + var nonExecutablePath = Path.Combine(firstBinPath, "mycommand"); + File.WriteAllText(nonExecutablePath, string.Empty); + File.SetUnixFileMode(nonExecutablePath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + + var executablePath = Path.Combine(secondBinPath, "mycommand"); + File.WriteAllText(executablePath, string.Empty); + File.SetUnixFileMode(executablePath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + static bool FileExistsAndIsExecutable(string path) + { + if (!File.Exists(path)) + { + return false; + } + + if (OperatingSystem.IsWindows()) + { + return true; + } + + const UnixFileMode ExecuteBits = UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; + return (File.GetUnixFileMode(path) & ExecuteBits) != 0; + } + + var result = PathLookupHelper.FindAllFullPathsFromPath( + "mycommand", + string.Join(Path.PathSeparator, firstBinPath, secondBinPath), + Path.PathSeparator, + FileExistsAndIsExecutable, + null); + + Assert.Equal([executablePath], result); + } + + [Fact] + public void FindAllFullPathsFromPath_WithPathExtensions_ReturnsOneMatchPerDirectory() + { + var firstExePath = Path.Combine("first", "bin", "code.EXE"); + var firstCmdPath = Path.Combine("first", "bin", "code.CMD"); + var secondCmdPath = Path.Combine("second", "bin", "code.CMD"); + var existingFiles = new HashSet + { + firstExePath, + firstCmdPath, + secondCmdPath + }; + var pathExtensions = new[] { ".EXE", ".CMD" }; + var path = string.Join(';', Path.Combine("first", "bin"), Path.Combine("second", "bin")); + + var result = PathLookupHelper.FindAllFullPathsFromPath("code", path, ';', existingFiles.Contains, pathExtensions); + + Assert.Equal([firstExePath, secondCmdPath], result); + } + [Fact] public void FindFullPathFromPath_UsesCorrectPathSeparator() {