diff --git a/.github/workflows/build-cli-native-archives.yml b/.github/workflows/build-cli-native-archives.yml index 685e4d89b6b..af29b4337b0 100644 --- a/.github/workflows/build-cli-native-archives.yml +++ b/.github/workflows/build-cli-native-archives.yml @@ -33,6 +33,18 @@ jobs: strategy: matrix: targets: ${{ fromJson(inputs.targets) }} + # Compute the Aspire CLI channel from the triggering GH Actions event, + # mirroring the algorithm in eng/pipelines/templates/build_sign_native.yml + # (computeCliChannel step): + # pull_request -> pr + # refs/heads/release/* -> staging + # refs/heads/internal/release/* -> staging + # anything else (push to main, schedule, workflow_dispatch) -> daily + # The AzDO 'stable' branch (DotNetFinalVersionKind == 'release') is + # intentionally omitted: stable archives are produced only by the AzDO + # release pipeline, never by GH Actions. + env: + ASPIRE_CLI_CHANNEL: ${{ github.event_name == 'pull_request' && 'pr' || (startsWith(github.ref, 'refs/heads/release/') || startsWith(github.ref, 'refs/heads/internal/release/')) && 'staging' || 'daily' }} steps: - name: Checkout code @@ -56,6 +68,7 @@ jobs: /bl:${{ github.workspace }}/artifacts/log/${{ inputs.configuration }}/BuildBundleDeps.binlog /p:ContinuousIntegrationBuild=true /p:BuildBundleDepsOnly=true + /p:AspireCliChannel=${{ env.ASPIRE_CLI_CHANNEL }} ${{ inputs.versionOverrideArg }} - name: Build bundle payload archive @@ -85,6 +98,7 @@ jobs: /p:SkipManagedBuild=true /p:TargetRids=${{ matrix.targets.rids }} /p:BundlePayloadPath=${{ github.workspace }}/artifacts/bundle/aspire-ci-bundlepayload-${{ matrix.targets.rids }}.tar.gz + /p:AspireCliChannel=${{ env.ASPIRE_CLI_CHANNEL }} ${{ inputs.versionOverrideArg }} - name: Verify CLI tool nupkg diff --git a/.github/workflows/polyglot-validation/setup-local-cli.sh b/.github/workflows/polyglot-validation/setup-local-cli.sh index 13243e5a942..97a2fe62a9f 100644 --- a/.github/workflows/polyglot-validation/setup-local-cli.sh +++ b/.github/workflows/polyglot-validation/setup-local-cli.sh @@ -36,14 +36,27 @@ echo "=== Extracting bundle ===" # Set up NuGet hive echo "=== Setting up NuGet package hive ===" -HIVE_DIR="$ASPIRE_HOME/hives/local/packages" -mkdir -p "$HIVE_DIR" SHIPPING_DIR="$NUGETS_DIR/Release/Shipping" if [ ! -d "$SHIPPING_DIR" ]; then SHIPPING_DIR="$NUGETS_DIR" fi +# Auto-detect PR identity from .nupkg filenames (e.g. "Aspire.Cli.13.4.0-pr.16820.g3703c5c4.nupkg") +# so PR-built packages land in the same hive the CLI's CliExecutionContext.Channel resolves to +# ("pr-"). Falls back to "local" for true local-dev builds. +HIVE_LABEL="local" +SAMPLE_NUPKG=$(find "$SHIPPING_DIR" "$NUGETS_RID_DIR" -maxdepth 4 -name "Aspire.Cli.*.nupkg" 2>/dev/null | head -1) +if [ -n "$SAMPLE_NUPKG" ]; then + SUFFIX=$(basename "$SAMPLE_NUPKG" | sed -nE 's/.*-(pr\.[0-9]+\.[0-9a-g]+).*\.nupkg$/\1/p') + if [[ "$SUFFIX" =~ ^pr\.([0-9]+)\.[0-9a-g]+$ ]]; then + HIVE_LABEL="pr-${BASH_REMATCH[1]}" + fi +fi +HIVE_DIR="$ASPIRE_HOME/hives/$HIVE_LABEL/packages" +echo " Using hive label: $HIVE_LABEL" +mkdir -p "$HIVE_DIR" + if [ -d "$SHIPPING_DIR" ]; then find "$SHIPPING_DIR" -name "*.nupkg" -exec cp {} "$HIVE_DIR/" \; echo " βœ“ Copied $(find "$HIVE_DIR" -name "*.nupkg" | wc -l) packages" @@ -56,11 +69,5 @@ fi echo " Total packages in hive: $(find "$HIVE_DIR" -name "*.nupkg" | wc -l)" -# Set the channel to 'local' so CLI uses our hive -echo "=== Configuring CLI channel ===" -"$ASPIRE_HOME/bin/aspire" config set channel local --global || { - echo " Warning: Failed to set channel" -} - echo "" echo "=== Aspire CLI setup complete ===" diff --git a/.gitignore b/.gitignore index 85eb4eac46a..0898b5fa56c 100644 --- a/.gitignore +++ b/.gitignore @@ -210,3 +210,5 @@ extension/package.nls.*.json # Azure Functions local settings (may contain encrypted secrets) local.settings.json diagnostics.log +.squad/ +tags diff --git a/eng/clipack/Common.projitems b/eng/clipack/Common.projitems index 2796b9f4182..77319b6db1c 100644 --- a/eng/clipack/Common.projitems +++ b/eng/clipack/Common.projitems @@ -64,6 +64,11 @@ + + diff --git a/eng/pipelines/templates/build_sign_native.yml b/eng/pipelines/templates/build_sign_native.yml index 684f339b1b4..fda82e5823d 100644 --- a/eng/pipelines/templates/build_sign_native.yml +++ b/eng/pipelines/templates/build_sign_native.yml @@ -72,6 +72,40 @@ jobs: displayName: 🟣Restore steps: + # Compute the Aspire CLI channel for the AspireCliChannel MSBuild + # property consumed by the native CLI build below. This is the canonical + # AzDO compute: build_sign_native runs BEFORE the `build` stage (per the + # dependsOn graph), so an output variable from a later stage would not + # be visible here, and there is no other AzDO consumer of this value. + # The variable is job-scoped (no isOutput=true) since it is consumed by + # a later step in the same job. + - pwsh: | + $ErrorActionPreference = 'Stop' + $reason = '$(Build.Reason)' + $sourceBranch = '$(Build.SourceBranch)' + Write-Host "Build.Reason: '$reason'" + Write-Host "Build.SourceBranch: '$sourceBranch'" + + $versionKind = & "$(Build.SourcesDirectory)/$(dotnetScript)" msbuild "$(Build.SourcesDirectory)/eng/Versions.props" -getProperty:DotNetFinalVersionKind + $versionKind = $versionKind.Trim() + Write-Host "DotNetFinalVersionKind: '$versionKind'" + + if ($reason -eq 'PullRequest') { + $channel = 'pr' + } elseif ($versionKind -eq 'release') { + $channel = 'stable' + } elseif ($sourceBranch -match '^refs/heads/(release|internal/release)/') { + $channel = 'staging' + } else { + # main and any other branch fall through to daily + $channel = 'daily' + } + + Write-Host "Aspire CLI channel: $channel" + Write-Host "##vso[task.setvariable variable=aspireCliChannel]$channel" + name: computeCliChannel + displayName: 🟣Determine Aspire CLI channel + # Build, publish, and sign aspire-managed before creating the bundle layout. # This ensures aspire-managed is signed before CreateLayout packs it into the bundle archive. # Step 1: dotnet publish produces the self-contained single-file binary. @@ -143,6 +177,7 @@ jobs: /p:SkipManagedBuild=true /p:TargetRids=${{ targetRid }} /p:BundlePayloadPath=$(Build.SourcesDirectory)/artifacts/bundle/aspire-ci-bundlepayload-${{ targetRid }}.tar.gz + /p:AspireCliChannel=$(aspireCliChannel) $(_buildArgs) ${{ parameters.extraBuildArgs }} /bl:$(Build.Arcade.LogsPath)Build.binlog diff --git a/eng/scripts/get-aspire-cli-pr.ps1 b/eng/scripts/get-aspire-cli-pr.ps1 index 2dce146de5b..d3eb801c984 100755 --- a/eng/scripts/get-aspire-cli-pr.ps1 +++ b/eng/scripts/get-aspire-cli-pr.ps1 @@ -24,7 +24,7 @@ Mutually exclusive with PRNumber and WorkflowRunId. .PARAMETER HiveLabel - Override the NuGet hive label (default: pr-PRNUMBER, run-RUNID, or run-GITHUB_RUN_ID for LocalDir). + Override the NuGet hive label (default: pr-PRNUMBER, run-RUNID, or local for LocalDir). .PARAMETER InstallPath Directory prefix to install (default: $HOME/.aspire on Unix, %USERPROFILE%\.aspire on Windows) @@ -109,7 +109,7 @@ param( [Parameter(HelpMessage = "Use pre-downloaded artifacts from a local directory instead of downloading from GitHub")] [string]$LocalDir = "", - [Parameter(HelpMessage = "Override the NuGet hive label (default: pr-, run-, or run- (run-local when GITHUB_RUN_ID is unset))")] + [Parameter(HelpMessage = "Override the NuGet hive label (default: pr-, run-, or local for --LocalDir)")] [string]$HiveLabel = "", [Parameter(HelpMessage = "Directory prefix to install")] @@ -742,37 +742,6 @@ function Remove-TempDirectory { # END: Shared code # ============================================================================= -# Function to save global settings using the aspire CLI -# Uses 'aspire config set -g' to set global configuration values -# Expected schema of ~/.aspire/globalsettings.json: -# { -# "channel": "string" // The channel name (e.g., "daily", "staging", "pr-1234") -# } -function Save-GlobalSettings { - [CmdletBinding(SupportsShouldProcess)] - param( - [Parameter(Mandatory = $true)] - [string]$CliPath, - - [Parameter(Mandatory = $true)] - [string]$Key, - - [Parameter(Mandatory = $true)] - [string]$Value - ) - - if ($PSCmdlet.ShouldProcess("$Key = $Value", "Set global config via aspire CLI")) { - Write-Message "Setting global config: $Key = $Value" -Level Verbose - - $output = & $CliPath config set -g $Key $Value 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Message "Failed to set global config via aspire CLI" -Level Warning - return - } - Write-Message "Global config saved: $Key = $Value" -Level Verbose - } -} - # Function to check if gh command is available function Test-GitHubCLIDependency { [CmdletBinding()] @@ -1268,10 +1237,21 @@ function Start-InstallFromLocalDir { $cliBinDir = Join-Path $resolvedInstallPrefix "bin" $resolvedHiveLabel = if ($HiveLabel) { $HiveLabel - } elseif ($env:GITHUB_RUN_ID) { - "run-$($env:GITHUB_RUN_ID)" } else { - "run-local" + # Auto-detect PR identity from .nupkg filenames (e.g. "13.4.0-pr.16820.g3703c5c4") + # so PR-built packages land in the same hive the CLI's CliExecutionContext.Channel + # resolves to ("pr-"). Falls back to "local" for true local-dev builds. + $detectedLabel = "local" + try { + $detectedSuffix = Get-VersionSuffixFromPackages -DownloadDir $LocalDirPath + if ($detectedSuffix -match '^pr\.(\d+)\.[0-9a-g]+$') { + $detectedLabel = "pr-$($Matches[1])" + } + } + catch { + # No PR-style packages in the local dir; keep "local". + } + $detectedLabel } $nugetHiveDir = Join-Path $resolvedInstallPrefix "hives" $resolvedHiveLabel "packages" @@ -1298,13 +1278,6 @@ function Start-InstallFromLocalDir { Write-Message "Could not extract version suffix from local packages: $($_.Exception.Message)" -Level Warning } - # Save the global channel setting - if (-not $HiveOnly) { - $cliExe = if ($Script:HostOS -eq "win") { "aspire.exe" } else { "aspire" } - $cliPath = Join-Path $cliBinDir $cliExe - Save-GlobalSettings -CliPath $cliPath -Key "channel" -Value $resolvedHiveLabel - } - # Update PATH environment variables if (-not $HiveOnly) { if ($SkipPath) { @@ -1399,15 +1372,6 @@ function Start-DownloadAndInstall { } } - # Save the global channel setting to the PR hive channel - # This allows 'aspire new' and 'aspire init' to use the same channel by default - if (-not $HiveOnly) { - # Determine CLI path - $cliExe = if ($Script:HostOS -eq "win") { "aspire.exe" } else { "aspire" } - $cliPath = Join-Path $cliBinDir $cliExe - Save-GlobalSettings -CliPath $cliPath -Key "channel" -Value $resolvedHiveLabel - } - # Update PATH environment variables if (-not $HiveOnly) { if ($SkipPath) { diff --git a/eng/scripts/get-aspire-cli-pr.sh b/eng/scripts/get-aspire-cli-pr.sh index 9fa8e7693fe..336f2577286 100755 --- a/eng/scripts/get-aspire-cli-pr.sh +++ b/eng/scripts/get-aspire-cli-pr.sh @@ -71,7 +71,7 @@ USAGE: The directory must contain CLI archive files (aspire-cli-*.tar.gz or .zip) and optionally NuGet packages (*.nupkg). --hive-label LABEL Override the NuGet hive label (default: pr-, run-, - or run- (or run-local if GITHUB_RUN_ID is unset) for --local-dir) + or local for --local-dir) -i, --install-path PATH Directory prefix to install (default: ~/.aspire) CLI installs to: /bin NuGet hive: /hives/pr-/packages (or run-) @@ -444,36 +444,6 @@ install_archive() { say_verbose "Successfully installed archive" } -# Function to save global settings using the aspire CLI -# Uses 'aspire config set -g' to set global configuration values -# Parameters: -# $1 - cli_path: Path to the aspire CLI executable -# $2 - key: The configuration key to set -# $3 - value: The value to set -# Expected schema of ~/.aspire/globalsettings.json: -# { -# "channel": "string" // The channel name (e.g., "daily", "staging", "pr-1234") -# } -save_global_settings() { - local cli_path="$1" - local key="$2" - local value="$3" - - if [[ "$DRY_RUN" == true ]]; then - say_info "[DRY RUN] Would run: $cli_path config set -g $key $value" - return 0 - fi - - say_verbose "Setting global config: $key = $value" - - if ! "$cli_path" config set -g "$key" "$value" 2>/dev/null; then - say_warn "Failed to set global config via aspire CLI" - return 1 - fi - - say_verbose "Global config saved: $key = $value" -} - # Function to add PATH to shell configuration file # Parameters: # $1 - config_file: Path to the shell configuration file @@ -1017,10 +987,17 @@ install_from_local_dir() { local hive_label if [[ -n "$HIVE_LABEL" ]]; then hive_label="$HIVE_LABEL" - elif [[ -n "${GITHUB_RUN_ID:-}" ]]; then - hive_label="run-$GITHUB_RUN_ID" else - hive_label="run-local" + # Auto-detect PR identity from .nupkg filenames (e.g. "13.4.0-pr.16820.g3703c5c4") + # so PR-built packages land in the same hive the CLI's CliExecutionContext.Channel + # resolves to ("pr-"). Falls back to "local" for true local-dev builds. + local detected_suffix + if detected_suffix=$(extract_version_suffix_from_packages "$local_dir") \ + && [[ "$detected_suffix" =~ ^pr\.([0-9]+)\.[0-9a-g]+$ ]]; then + hive_label="pr-${BASH_REMATCH[1]}" + else + hive_label="local" + fi fi local nuget_hive_dir="$INSTALL_PREFIX/hives/$hive_label/packages" @@ -1075,17 +1052,6 @@ install_from_local_dir() { else say_warn "Could not extract version suffix from local packages" fi - - # Save the global channel setting - if [[ "$HIVE_ONLY" != true ]]; then - local cli_path - if [[ -f "$cli_install_dir/aspire.exe" ]]; then - cli_path="$cli_install_dir/aspire.exe" - else - cli_path="$cli_install_dir/aspire" - fi - save_global_settings "$cli_path" "channel" "$hive_label" || true - fi } # Main function to download and install from PR or workflow run ID @@ -1198,20 +1164,6 @@ download_and_install_from_pr() { install_aspire_extension "$extension_download_dir" fi fi - - # Save the global channel setting to the PR hive channel - # This allows 'aspire new' and 'aspire init' to use the same channel by default - if [[ "$HIVE_ONLY" != true ]]; then - # Determine CLI path - local cli_path - if [[ -f "$cli_install_dir/aspire.exe" ]]; then - cli_path="$cli_install_dir/aspire.exe" - else - cli_path="$cli_install_dir/aspire" - fi - # Non-fatal: channel can be set manually if this fails - save_global_settings "$cli_path" "channel" "$hive_label" || true - fi } # Main entry point β€” wraps everything after function definitions. diff --git a/eng/scripts/get-aspire-cli.ps1 b/eng/scripts/get-aspire-cli.ps1 index 985ea60509a..1aab106d33c 100755 --- a/eng/scripts/get-aspire-cli.ps1 +++ b/eng/scripts/get-aspire-cli.ps1 @@ -788,65 +788,6 @@ function ConvertTo-ChannelName { } } -# Function to save global settings using the aspire CLI -# Uses 'aspire config set -g' to set global configuration values -# Expected schema of ~/.aspire/globalsettings.json: -# { -# "channel": "string" // The channel name (e.g., "daily", "staging", "pr-1234") -# } -function Save-GlobalSettings { - [CmdletBinding(SupportsShouldProcess)] - param( - [Parameter(Mandatory = $true)] - [string]$CliPath, - - [Parameter(Mandatory = $true)] - [string]$Key, - - [Parameter(Mandatory = $true)] - [string]$Value - ) - - if ($PSCmdlet.ShouldProcess("$Key = $Value", "Set global config via aspire CLI")) { - Write-Message "Setting global config: $Key = $Value" -Level Verbose - - $output = & $CliPath config set -g $Key $Value 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Message "Failed to set global config via aspire CLI: $output" -Level Warning - return - } - if ($output) { - Write-Message "$output" -Level Verbose - } - Write-Message "Global config saved: $Key = $Value" -Level Verbose - } -} - -# Function to remove a global setting using the aspire CLI -# Uses 'aspire config delete -g' to remove global configuration values -# This is used when installing the release/stable channel to avoid forcing nuget.config creation -function Remove-GlobalSettings { - [CmdletBinding(SupportsShouldProcess)] - param( - [Parameter(Mandatory = $true)] - [string]$CliPath, - - [Parameter(Mandatory = $true)] - [string]$Key - ) - - if ($PSCmdlet.ShouldProcess($Key, "Remove global config via aspire CLI")) { - Write-Message "Removing global config: $Key" -Level Verbose - - $output = & $CliPath config delete -g $Key 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Message "Failed to delete global config via aspire CLI: $output" -Level Verbose - return - } - Write-Message "Global config removed: $Key" -Level Verbose - } -} - # Simplified installation path determination function Get-InstallPath { [CmdletBinding()] @@ -1239,19 +1180,6 @@ function Install-AspireCli { Write-Message "Aspire CLI successfully installed to: $cliPath" -Level Success } - # Save the global channel setting if using quality-based download (not version-specific) - # This allows 'aspire new' and 'aspire init' to use the same channel by default - # For release/stable channel, remove the setting to avoid forcing nuget.config creation - if ([string]::IsNullOrWhiteSpace($Version)) { - $channel = ConvertTo-ChannelName -Quality $Quality - if ($channel -eq "stable") { - Remove-GlobalSettings -CliPath $cliPath -Key "channel" - } - else { - Save-GlobalSettings -CliPath $cliPath -Key "channel" -Value $channel - } - } - # Download and install VS Code extension if requested if ($InstallExtension) { Write-Message "" -Level Info diff --git a/eng/scripts/get-aspire-cli.sh b/eng/scripts/get-aspire-cli.sh index 59eec27adb3..c2224afcd90 100755 --- a/eng/scripts/get-aspire-cli.sh +++ b/eng/scripts/get-aspire-cli.sh @@ -496,70 +496,6 @@ map_quality_to_channel() { esac } -# Function to save the global settings using the aspire CLI -# Uses 'aspire config set -g' to set global configuration values -# Parameters: -# $1 - cli_path: Path to the aspire CLI executable -# $2 - key: The configuration key to set -# $3 - value: The value to set -# Expected schema of ~/.aspire/globalsettings.json: -# { -# "channel": "string" // The channel name (e.g., "daily", "staging", "pr-1234") -# } -save_global_settings() { - local cli_path="$1" - local key="$2" - local value="$3" - - if [[ "$DRY_RUN" == true ]]; then - say_info "[DRY RUN] Would run: $cli_path config set -g $key $value" - return 0 - fi - - say_verbose "Setting global config: $key = $value" - - local output - output=$("$cli_path" config set -g "$key" "$value" 2>&1) - local exit_code=$? - if [[ $exit_code -ne 0 ]]; then - say_warn "Failed to set global config via aspire CLI: $output" - return 1 - fi - if [[ -n "$output" ]]; then - say_verbose "$output" - fi - - say_verbose "Global config saved: $key = $value" -} - -# Function to remove a global setting using the aspire CLI -# Uses 'aspire config delete -g' to remove global configuration values -# This is used when installing the release/stable channel to avoid forcing nuget.config creation -# Parameters: -# $1 - cli_path: Path to the aspire CLI executable -# $2 - key: The configuration key to remove -remove_global_settings() { - local cli_path="$1" - local key="$2" - - if [[ "$DRY_RUN" == true ]]; then - say_info "[DRY RUN] Would run: $cli_path config delete -g $key" - return 0 - fi - - say_verbose "Removing global config: $key" - - local output - output=$("$cli_path" config delete -g "$key" 2>&1) - local exit_code=$? - if [[ $exit_code -ne 0 ]]; then - say_verbose "Failed to delete global config via aspire CLI: $output" - return 1 - fi - - say_verbose "Global config removed: $key" -} - # Function to add PATH to shell configuration file # Parameters: # $1 - config_file: Path to the shell configuration file @@ -1021,19 +957,6 @@ download_and_install_archive() { say_info "Aspire CLI successfully installed to: ${GREEN}$cli_path${RESET}" - # Save the global channel setting if using quality-based download (not version-specific) - # This allows 'aspire new' and 'aspire init' to use the same channel by default - # For release/stable channel, remove the setting to avoid forcing nuget.config creation - if [[ -z "$VERSION" ]]; then - local channel - channel=$(map_quality_to_channel "$QUALITY") - if [[ "$channel" == "stable" ]]; then - remove_global_settings "$cli_path" "channel" - else - save_global_settings "$cli_path" "channel" "$channel" - fi - fi - # Download and install VS Code extension if requested if [[ "$INSTALL_EXTENSION" == true ]]; then printf "\n" diff --git a/src/Aspire.Cli/Acquisition/IdentityChannelReader.cs b/src/Aspire.Cli/Acquisition/IdentityChannelReader.cs new file mode 100644 index 00000000000..b13758d2cbb --- /dev/null +++ b/src/Aspire.Cli/Acquisition/IdentityChannelReader.cs @@ -0,0 +1,157 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Reflection; + +namespace Aspire.Cli.Acquisition; + +/// +/// Reads the acquisition channel that the running CLI assembly was built for. +/// +/// +/// The channel is baked into the CLI assembly at build time as +/// [AssemblyMetadata("AspireCliChannel", "<value>")]. The value is +/// one of stable, staging, daily, pr, or local +/// (the default for developer builds with no /p:AspireCliChannel= override). +/// +internal interface IIdentityChannelReader +{ + /// + /// Returns the channel baked into the CLI assembly. + /// + /// One of stable, staging, daily, pr, or local. + /// + /// Thrown when the AspireCliChannel assembly metadata is missing or empty. + /// + string ReadChannel(); +} + +/// +/// Default backed by an 's +/// values. +/// +/// +/// AOT-safe: enumerating via +/// over a sealed, build-time-known +/// attribute type is preserved by the trimmer / native compiler. No +/// reflection-based JSON, no dynamic type loading. +/// +internal sealed class IdentityChannelReader : IIdentityChannelReader +{ + private const string ChannelMetadataKey = "AspireCliChannel"; + private const string PrChannelMarker = "-pr"; + + private readonly Assembly _assembly; + private readonly Lazy _channel; + + /// + /// Initializes a new instance that reads metadata from the supplied + /// . The assembly is required: defaulting to + /// is intentionally NOT supported + /// because under Microsoft.DotNet.RemoteExecutor and other test + /// hosts the entry assembly resolves to the host (not the CLI), which + /// silently breaks [AssemblyMetadata] reads. Callers must pin the + /// read explicitly: production passes typeof(Program).Assembly; + /// tests pass a fake assembly carrying the desired metadata. + /// + /// + /// The assembly to read AspireCliChannel metadata from. Must not be + /// . + /// + /// + /// Thrown when is . + /// + /// + /// The constructor does NOT read or validate the metadata; that is deferred + /// to the first call so DI consumers see the + /// validation error eagerly when they first try to read the channel rather + /// than at container build time. The resolved value is cached for the + /// lifetime of this instance via with + /// (the default), + /// avoiding repeated scans under + /// the singleton DI registration. + /// + public IdentityChannelReader(Assembly assembly) + { + ArgumentNullException.ThrowIfNull(assembly); + _assembly = assembly; + _channel = new Lazy(ResolveChannel, LazyThreadSafetyMode.ExecutionAndPublication); + } + + /// + public string ReadChannel() => _channel.Value; + + private string ResolveChannel() + { + var metadata = _assembly + .GetCustomAttributes() + .FirstOrDefault(a => string.Equals(a.Key, ChannelMetadataKey, StringComparison.Ordinal)); + + if (metadata is null || string.IsNullOrEmpty(metadata.Value)) + { + throw new InvalidOperationException( + $"Assembly metadata '{ChannelMetadataKey}' is missing or empty on '{_assembly.GetName().Name}'. " + + "The CLI must be built with /p:AspireCliChannel= (one of stable, staging, daily, pr, local)."); + } + + return metadata.Value; + } + + /// + /// Parses the PR number out of an + /// value of the form 0.0.0-pr.<N>.g<sha> (the shape produced by + /// .github/workflows/ci.yml via + /// /p:VersionSuffix=pr.$PR_NUMBER.g$SHORT_SHA) or the legacy + /// 0.0.0-pr<N>.<sha> shape with no separator. + /// + /// + /// The informational version string. Typically obtained from + /// typeof(Program).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion. + /// + /// + /// The PR number when contains the + /// -pr marker followed (optionally separated by a single .) + /// by one or more ASCII digits; otherwise . + /// + internal static int? ParsePrNumber(string? informationalVersion) + { + if (string.IsNullOrEmpty(informationalVersion)) + { + return null; + } + + var idx = informationalVersion.IndexOf(PrChannelMarker, StringComparison.Ordinal); + if (idx < 0) + { + return null; + } + + var start = idx + PrChannelMarker.Length; + + // Accept an optional '.' separator between the "-pr" marker and the digits to + // match the real CI shape "-pr..g" produced by ci.yml. Reject if the + // character after "-pr" is anything else non-digit (e.g. "-preview" must NOT + // match). + if (start < informationalVersion.Length && informationalVersion[start] == '.') + { + start++; + } + + var end = start; + while (end < informationalVersion.Length && char.IsAsciiDigit(informationalVersion[end])) + { + end++; + } + + if (end == start) + { + return null; + } + + var span = informationalVersion.AsSpan(start, end - start); + return int.TryParse(span, NumberStyles.None, CultureInfo.InvariantCulture, out var prNumber) + ? prNumber + : null; + } +} diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 4a9cd810838..ce41048c833 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -25,8 +25,20 @@ $(DefineConstants);CLI true false + + local + + + + true diff --git a/src/Aspire.Cli/CliExecutionContext.cs b/src/Aspire.Cli/CliExecutionContext.cs index 59c31c93a89..db4dd56e94a 100644 --- a/src/Aspire.Cli/CliExecutionContext.cs +++ b/src/Aspire.Cli/CliExecutionContext.cs @@ -5,13 +5,59 @@ 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) +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 channel = "local", int? prNumber = null) { public DirectoryInfo WorkingDirectory { get; } = workingDirectory; public DirectoryInfo HivesDirectory { get; } = hivesDirectory; public DirectoryInfo CacheDirectory { get; } = cacheDirectory; public DirectoryInfo SdksDirectory { get; } = sdksDirectory; + /// + /// Gets the resolved hive label for the running CLI. For non-PR builds this is the + /// identity channel verbatim β€” one of local, stable, staging, or + /// daily. For PR builds (identity channel pr with a non-null + /// ) this is the per-PR hive label pr-<N> (for example + /// pr-16820), matching the directory layout the packaging service creates under + /// the hives root. + /// + /// + /// This is the value reseed call sites (template factories, scaffolding, guest apphost + /// project) write into a project's aspire.config.json#channel: it is the consumer- + /// facing label that subsequent CLI runs use to select the right hive. The raw build-time + /// identity value (the literal pr for PR builds) is exposed separately via + /// for callers that need the build-time taxonomy. + /// + public string Channel => _channel == "pr" && PrNumber.HasValue + ? $"pr-{PrNumber.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)}" + : _channel; + + /// + /// Gets the raw build-time identity channel value for the running CLI β€” one of + /// local, stable, staging, daily, or pr. Unlike + /// , this never resolves to a per-PR hive label; the literal + /// pr is returned for every PR build regardless of . + /// + public string IdentityChannel => _channel; + + private readonly string _channel = channel == "pr" && prNumber is null + ? throw new InvalidOperationException( + "CliExecutionContext was constructed with channel='pr' but no PrNumber. " + + "PR-channel CLIs must always carry a PrNumber parsed from the assembly's " + + "InformationalVersion; a null PrNumber here indicates a malformed PR build " + + "(missing or unparseable PR suffix in the version string).") + : channel; + + /// + /// Gets the pull-request number associated with this invocation. Parsed from the + /// running assembly's InformationalVersion independently of the identity + /// channel string, and exposed verbatim β€” non-null PrNumbers can occur on any + /// channel when a version-suffix happens to parse as a PR number. The value is + /// only semantically meaningful when is pr, + /// where it is required (the constructor throws otherwise) and is used to derive + /// the per-PR hive label exposed via . + /// + public int? PrNumber { get; } = prNumber; + /// /// Gets the directory where restored NuGet packages are cached for apphost server sessions. /// @@ -80,12 +126,13 @@ public Command? Command public TaskCompletionSource CommandSelected { get; } = new(); /// - /// Gets the count of PR hives (PR build directories) on the developer machine. + /// Gets the count of hives (per-channel CLI build directories) on the developer machine, + /// including the local hive and any pr-* hives. /// Hives are detected as subdirectories in the hives directory. /// This method accesses the file system. /// - /// The number of PR hive subdirectories, or 0 if the hives directory does not exist. - public int GetPrHiveCount() + /// The number of hive subdirectories, or 0 if the hives directory does not exist. + public int GetHiveCount() { if (!HivesDirectory.Exists) { diff --git a/src/Aspire.Cli/Commands/AddCommand.cs b/src/Aspire.Cli/Commands/AddCommand.cs index 02f717468b0..1faa7acf755 100644 --- a/src/Aspire.Cli/Commands/AddCommand.cs +++ b/src/Aspire.Cli/Commands/AddCommand.cs @@ -317,7 +317,7 @@ 1 when filteredPackagesWithShortName.First().Package.Version == version p => p.Package.Version, out var cliVersionPackage, channelName: null, - hasPrHives: ExecutionContext.GetPrHiveCount() > 0)) + hasPrHives: ExecutionContext.GetHiveCount() > 0)) { return cliVersionPackage; } diff --git a/src/Aspire.Cli/Commands/InitCommand.cs b/src/Aspire.Cli/Commands/InitCommand.cs index 2411e11c50f..1017f147f3d 100644 --- a/src/Aspire.Cli/Commands/InitCommand.cs +++ b/src/Aspire.Cli/Commands/InitCommand.cs @@ -258,8 +258,9 @@ private async Task DropCSharpSingleFileSkeletonAsync(DirectoryInfo workingD File.WriteAllText(appHostPath, appHostContent); InteractionService.DisplayMessage(KnownEmojis.CheckMarkButton, "Created apphost.cs"); - // Ensure the workspace has a NuGet.config that exposes the configured channel's - // package sources. This is required so MSBuild can resolve + // Ensure the workspace has a NuGet.config that exposes the running CLI binary's + // identity-channel package sources (CliExecutionContext.Channel β€” stable, + // staging, daily, pr-, or local). This is required so MSBuild can resolve // `#:sdk Aspire.AppHost.Sdk@` from the apphost.cs SDK directive β€” both // for `aspire add` (`dotnet package add --file apphost.cs`) and for // `dotnet run --file apphost.cs`. Without it, any non-stable channel (PR/run @@ -269,7 +270,7 @@ private async Task DropCSharpSingleFileSkeletonAsync(DirectoryInfo workingD // creates a new file or merges missing sources into an existing one, so adding // hives later is handled the same way as for templates. var createdNuGetConfig = await _templateNuGetConfigService.CreateOrUpdateNuGetConfigWithoutPromptAsync( - channelName: null, + channelName: _executionContext.Channel, outputPath: workingDirectory.FullName, cancellationToken).ConfigureAwait(false); if (createdNuGetConfig) @@ -319,15 +320,17 @@ private async Task DropCSharpProjectSkeletonAsync(FileInfo solutionFile, Ca return ExitCodeConstants.Success; } - // Resolve the channel-aware template package version + feed mapping. This makes init - // honor the global `channel` configuration (matching `aspire new`) and ensures the - // staging/daily/PR feed is queried for non-stable CLI builds. PR hives are intentionally - // excluded β€” init should produce the same template on every machine for a given CLI build. + // Resolve the channel-aware template package version + feed mapping. The running + // CLI binary's identity channel (CliExecutionContext.Channel β€” stable, staging, + // daily, pr-, or local) drives the selection so a developer scaffolding with a + // pr- CLI gets a project wired to the matching pr- hive. PR hives are + // intentionally excluded β€” init should produce the same template on every machine + // for a given CLI build. TemplatePackageSelection selection; try { var query = new TemplatePackageQuery( - ChannelOverride: null, + ChannelOverride: _executionContext.Channel, VersionOverride: null, SourceOverride: null, IncludePrHives: false); diff --git a/src/Aspire.Cli/Commands/IntegrationPackageSearchService.cs b/src/Aspire.Cli/Commands/IntegrationPackageSearchService.cs index cdf45091827..fad35175ecb 100644 --- a/src/Aspire.Cli/Commands/IntegrationPackageSearchService.cs +++ b/src/Aspire.Cli/Commands/IntegrationPackageSearchService.cs @@ -30,7 +30,7 @@ internal sealed class IntegrationPackageSearchService( allChannels = allChannels.Where(c => string.Equals(c.Name, configuredChannel, StringComparison.OrdinalIgnoreCase)); } - var hasHives = executionContext.GetPrHiveCount() > 0; + var hasHives = executionContext.GetHiveCount() > 0; var channels = hasHives || !string.IsNullOrEmpty(configuredChannel) ? allChannels : allChannels.Where(c => c.Type is PackageChannelType.Implicit); diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index b6a537301be..ccefe7f8abf 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -29,7 +29,6 @@ internal sealed class NewCommand : BaseCommand, IPackageMetaPrefetchingCommand private readonly ITemplate[] _templates; private readonly IFeatures _features; private readonly IPackagingService _packagingService; - private readonly IConfigurationService _configurationService; private readonly AgentInitCommand _agentInitCommand; private readonly ICliHostEnvironment _hostEnvironment; @@ -82,7 +81,6 @@ public NewCommand( ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, IPackagingService packagingService, - IConfigurationService configurationService, AgentInitCommand agentInitCommand, ICliHostEnvironment hostEnvironment, IConfiguration configuration) @@ -92,7 +90,6 @@ public NewCommand( _templateProvider = templateProvider; _features = features; _packagingService = packagingService; - _configurationService = configurationService; _agentInitCommand = agentInitCommand; _hostEnvironment = hostEnvironment; @@ -325,13 +322,27 @@ private async Task ResolveCliTemplateVersionAsync( var channels = await _packagingService.GetChannelsAsync(cancellationToken); var configuredChannelName = parseResult.GetValue(_channelOption); - if (string.IsNullOrWhiteSpace(configuredChannelName)) + + // When the running CLI was built on a local-build channel (pr-*, run-*, local) and + // the matching hive-backed channel is registered, prefer it over the Implicit + // (nuget.org) channel. Without this, `aspire new` resolves the template version + // from nuget.org and yields the latest stable (e.g. 13.2.4), which is then routed + // by Package Source Mapping to the PR hive and rejected because the hive only + // contains the corresponding prerelease (e.g. 13.4.0-pr.16820.gSHA). Aligning the + // selected channel with the CLI's own build channel keeps the version-range + // semantics coherent end-to-end (build β†’ execution context β†’ hive β†’ restore). + PackageChannel? localBuildChannel = null; + if (string.IsNullOrWhiteSpace(configuredChannelName) && + VersionHelper.IsLocalBuildChannel(ExecutionContext.Channel)) { - configuredChannelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken); + localBuildChannel = channels.FirstOrDefault(c => + string.Equals(c.Name, ExecutionContext.Channel, StringComparison.OrdinalIgnoreCase)); } var selectedChannel = string.IsNullOrWhiteSpace(configuredChannelName) - ? channels.FirstOrDefault(c => c.Type is PackageChannelType.Implicit) ?? channels.FirstOrDefault() + ? localBuildChannel + ?? channels.FirstOrDefault(c => c.Type is PackageChannelType.Implicit) + ?? channels.FirstOrDefault() : channels.FirstOrDefault(c => string.Equals(c.Name, configuredChannelName, StringComparison.OrdinalIgnoreCase)); if (selectedChannel is null) @@ -346,7 +357,7 @@ private async Task ResolveCliTemplateVersionAsync( var packages = (await selectedChannel.GetTemplatePackagesAsync(ExecutionContext.WorkingDirectory, cancellationToken)) .Where(p => Semver.SemVersion.TryParse(p.Version, Semver.SemVersionStyles.Strict, out _)) .ToArray(); - var hasPrHives = ExecutionContext.GetPrHiveCount() > 0; + var hasPrHives = ExecutionContext.GetHiveCount() > 0; NuGetPackage? package = VersionHelper.TryGetCurrentCliVersionMatch( packages, diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index e815a74d6f1..dc7ece04774 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -200,7 +200,7 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell { // If there are hives (PR build directories), prompt for channel selection. // Otherwise, use the implicit/default channel automatically. - var hasHives = ExecutionContext.GetPrHiveCount() > 0; + var hasHives = ExecutionContext.GetHiveCount() > 0; if (hasHives) { @@ -308,9 +308,10 @@ private async Task ExecuteSelfUpdateAsync(ParseResult parseResult, Cancella { var channel = selectedChannel ?? parseResult.GetValue(_channelOption) ?? parseResult.GetValue(_qualityOption); - // If channel is not specified, always prompt the user to select one. - // This ensures they consciously choose a channel that will be saved to global settings - // for future 'aspire new' and 'aspire init' commands. + // If channel is not specified, prompt the user to select one. The choice + // applies only to this self-update invocation; subsequent 'aspire new' + // and 'aspire init' commands resolve channel per-project from + // aspire.config.json, not from any global setting. if (string.IsNullOrEmpty(channel)) { var isStagingEnabled = KnownFeatures.IsStagingChannelEnabled(_features, _configuration); @@ -345,20 +346,6 @@ private async Task ExecuteSelfUpdateAsync(ParseResult parseResult, Cancella // Extract and update to $HOME/.aspire/bin await ExtractAndUpdateAsync(archivePath, cancellationToken); - // Save the selected channel to global settings for future use with 'aspire new' and 'aspire init' - // For stable channel, clear the setting to leave it blank (like the install scripts do) - // For other channels (staging, daily), save the channel name - if (string.Equals(channel, PackageChannelNames.Stable, StringComparison.OrdinalIgnoreCase)) - { - await _configurationService.DeleteConfigurationAsync("channel", isGlobal: true, cancellationToken); - _logger.LogDebug("Cleared global channel setting for stable channel"); - } - else - { - await _configurationService.SetConfigurationAsync("channel", channel, isGlobal: true, cancellationToken); - _logger.LogDebug("Saved global channel setting: {Channel}", channel); - } - return 0; } catch (OperationCanceledException) diff --git a/src/Aspire.Cli/Packaging/PackageChannel.cs b/src/Aspire.Cli/Packaging/PackageChannel.cs index a093d3a810e..1f22129cf07 100644 --- a/src/Aspire.Cli/Packaging/PackageChannel.cs +++ b/src/Aspire.Cli/Packaging/PackageChannel.cs @@ -86,6 +86,28 @@ 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 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.Contains("Aspire.Hosting", StringComparison.OrdinalIgnoreCase)) + .Select(p => new NuGetPackage { Id = p.PackageId, Version = PinnedVersion, Source = aspireMapping.Source }) + .ToList(); + } + } + var tasks = new List>>(); using var tempNuGetConfig = Type is PackageChannelType.Explicit ? await TemporaryNuGetConfig.CreateAsync(Mappings!) : null; diff --git a/src/Aspire.Cli/Packaging/PackageChannelNames.cs b/src/Aspire.Cli/Packaging/PackageChannelNames.cs index ce4a5796340..525eb87098c 100644 --- a/src/Aspire.Cli/Packaging/PackageChannelNames.cs +++ b/src/Aspire.Cli/Packaging/PackageChannelNames.cs @@ -27,4 +27,10 @@ internal static class PackageChannelNames /// The default channel name, based on the user's NuGet configuration. /// public const string Default = "default"; + + /// + /// The local channel name, used when the CLI binary was built locally + /// (i.e., with AspireCliChannel=local). + /// + public const string Local = "local"; } diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index d3a813978e2..645cd842a1c 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -5,9 +5,11 @@ using System.CommandLine.Parsing; using System.Diagnostics; using System.Globalization; +using System.Reflection; using System.Runtime.InteropServices; using System.Text; using System.Text.Json; +using Aspire.Cli.Acquisition; using Aspire.Cli.Agents; using Aspire.Cli.Agents.ClaudeCode; using Aspire.Cli.Agents.CopilotCli; @@ -169,6 +171,11 @@ private static string GetGlobalSettingsPath(ILogger logger) var legacyJson = File.ReadAllText(legacyPath); var legacyConfig = JsonSerializer.Deserialize(legacyJson, JsonSourceGenerationContext.Default.AspireJsonConfiguration); var config = AspireConfigFile.FromLegacy(legacyConfig, profiles: null); + // Drop the legacy global identity-channel field β€” the CLI's channel is now + // baked into the binary (AspireCliChannel assembly metadata) and never read + // from global config. The per-project channel migration in AspireConfigFile.FromLegacy + // remains for project-local aspire.config.json files. + config.Channel = null; config.Save(usersAspirePath); } catch (Exception ex) @@ -323,9 +330,19 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu builder.Services.AddSingleton(sp => new TelemetryManager(sp.GetRequiredService(), args)); // Shared services. + builder.Services.AddSingleton(_ => new IdentityChannelReader(typeof(Program).Assembly)); builder.Services.AddSingleton(sp => { - return BuildCliExecutionContext(startupContext.LoggingOptions.DebugMode, startupContext.LoggingOptions.LogsDirectory, startupContext.LoggingOptions.LogFilePath); + var channelReader = sp.GetRequiredService(); + var channel = channelReader.ReadChannel(); + int? prNumber = null; + if (channel == "pr") + { + var infoVersion = typeof(Program).Assembly + .GetCustomAttribute()?.InformationalVersion; + prNumber = IdentityChannelReader.ParsePrNumber(infoVersion); + } + return BuildCliExecutionContext(startupContext.LoggingOptions.DebugMode, startupContext.LoggingOptions.LogsDirectory, startupContext.LoggingOptions.LogFilePath, channel, prNumber); }); builder.Services.AddSingleton(s => new ConsoleEnvironment( BuildAnsiConsole(s, Console.Out), @@ -557,14 +574,14 @@ private static DirectoryInfo GetSdksDirectory() return new DirectoryInfo(sdksPath); } - private static CliExecutionContext BuildCliExecutionContext(bool debugMode, string logsDirectory, string logFilePath) + private static CliExecutionContext BuildCliExecutionContext(bool debugMode, string logsDirectory, string logFilePath, string channel, int? prNumber) { 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); + return new CliExecutionContext(workingDirectory, hivesDirectory, cacheDirectory, sdksDirectory, new DirectoryInfo(logsDirectory), logFilePath, debugMode, packagesDirectory: packagesDirectory, channel: channel, prNumber: prNumber); } private static DirectoryInfo GetCacheDirectory() diff --git a/src/Aspire.Cli/Projects/AppHostServerProject.cs b/src/Aspire.Cli/Projects/AppHostServerProject.cs index a0aacb7bef7..4059fadadff 100644 --- a/src/Aspire.Cli/Projects/AppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/AppHostServerProject.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.Bundles; -using Aspire.Cli.Configuration; using Aspire.Cli.DotNet; using Aspire.Cli.NuGet; using Aspire.Cli.Packaging; @@ -26,10 +25,10 @@ internal interface IAppHostServerProjectFactory internal sealed class AppHostServerProjectFactory( IDotNetCliRunner dotNetCliRunner, IPackagingService packagingService, - IConfigurationService configurationService, IBundleService bundleService, BundleNuGetService bundleNuGetService, IDotNetSdkInstaller sdkInstaller, + CliExecutionContext executionContext, ILoggerFactory loggerFactory) : IAppHostServerProjectFactory { public async Task CreateAsync(string appPath, CancellationToken cancellationToken = default) @@ -46,7 +45,6 @@ public async Task CreateAsync(string appPath, Cancellatio repoRoot, dotNetCliRunner, packagingService, - configurationService, loggerFactory.CreateLogger()); } @@ -64,7 +62,7 @@ public async Task CreateAsync(string appPath, Cancellatio dotNetCliRunner, sdkInstaller, packagingService, - configurationService, + executionContext, loggerFactory.CreateLogger()); } diff --git a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs index 896e149a2c0..c72cb3a2ed8 100644 --- a/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/DotNetBasedAppHostServerProject.cs @@ -41,7 +41,6 @@ internal sealed class DotNetBasedAppHostServerProject : IAppHostServerProject private readonly string _repoRoot; private readonly IDotNetCliRunner _dotNetCliRunner; private readonly IPackagingService _packagingService; - private readonly IConfigurationService _configurationService; private readonly ILogger _logger; public DotNetBasedAppHostServerProject( @@ -50,7 +49,6 @@ public DotNetBasedAppHostServerProject( string repoRoot, IDotNetCliRunner dotNetCliRunner, IPackagingService packagingService, - IConfigurationService configurationService, ILogger logger, string? projectModelPath = null) { @@ -61,7 +59,6 @@ public DotNetBasedAppHostServerProject( _repoRoot = Path.GetFullPath(repoRoot) + Path.DirectorySeparatorChar; _dotNetCliRunner = dotNetCliRunner; _packagingService = packagingService; - _configurationService = configurationService; _logger = logger; var pathHash = SHA256.HashData(Encoding.UTF8.GetBytes(_appPath)); @@ -327,11 +324,6 @@ private XDocument CreateProjectFile(IEnumerable integratio var configuredChannelName = AspireConfigFile.Load(_appPath)?.Channel ?? AspireJsonConfiguration.Load(_appPath)?.Channel; - if (string.IsNullOrEmpty(configuredChannelName)) - { - configuredChannelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken); - } - // Resolve channel sources and add them via RestoreAdditionalProjectSources // This is additive β€” it preserves the user's nuget.config and adds channel-specific sources var channelSources = new List(); diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 0a514f55200..9c965077237 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -37,6 +37,7 @@ internal sealed class GuestAppHostProject : IAppHostProject, IGuestAppHostSdkGen private readonly IConfiguration _configuration; private readonly IFeatures _features; private readonly ILanguageDiscovery _languageDiscovery; + private readonly CliExecutionContext _executionContext; private readonly ILogger _logger; private readonly FileLoggerProvider _fileLoggerProvider; private readonly TimeProvider _timeProvider; @@ -57,6 +58,7 @@ public GuestAppHostProject( IConfiguration configuration, IFeatures features, ILanguageDiscovery languageDiscovery, + CliExecutionContext executionContext, ILogger logger, FileLoggerProvider fileLoggerProvider, TimeProvider? timeProvider = null) @@ -71,6 +73,7 @@ public GuestAppHostProject( _configuration = configuration; _features = features; _languageDiscovery = languageDiscovery; + _executionContext = executionContext; _logger = logger; _fileLoggerProvider = fileLoggerProvider; _timeProvider = timeProvider ?? TimeProvider.System; @@ -341,12 +344,10 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken return (Success: true, Output: prepareOutput, Error: (string?)null, ChannelName: channelName, NeedsCodeGen: needsCodeGen); }, emoji: KnownEmojis.Gear); - // Save the channel to settings if available (config already has SdkVersion) - if (buildResult.ChannelName is not null) - { - config.Channel = buildResult.ChannelName; - SaveConfiguration(config, directory); - } + // Persist the channel: prepare-resolved value wins; otherwise fall back to the + // channel baked into the running CLI (CliExecutionContext.Channel). + config.Channel = buildResult.ChannelName ?? _executionContext.Channel; + SaveConfiguration(config, directory); if (!buildResult.Success) { @@ -1205,11 +1206,11 @@ public async Task UpdatePackagesAsync(UpdatePackagesContex { config.SdkVersion = newSdkVersion; } - // Update channel if it's an explicit channel (not the implicit/default one) - if (context.Channel.Type == Packaging.PackageChannelType.Explicit) - { - config.Channel = context.Channel.Name; - } + // Explicit channel wins; otherwise fall back to the channel baked into the + // running CLI (CliExecutionContext.Channel). + config.Channel = context.Channel.Type == Packaging.PackageChannelType.Explicit + ? context.Channel.Name + : _executionContext.Channel; foreach (var (packageId, _, newVersion) in updates) { config.AddOrUpdatePackage(packageId, newVersion); diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index c0c24e9abfd..9c6b8581faf 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -32,7 +32,7 @@ internal sealed class PrebuiltAppHostServer : IAppHostServerProject private readonly IDotNetCliRunner _dotNetCliRunner; private readonly IDotNetSdkInstaller _sdkInstaller; private readonly IPackagingService _packagingService; - private readonly IConfigurationService _configurationService; + private readonly CliExecutionContext _executionContext; private readonly ILogger _logger; private readonly string _workingDirectory; @@ -49,7 +49,7 @@ internal sealed class PrebuiltAppHostServer : IAppHostServerProject /// The .NET CLI runner for building project references. /// The SDK installer for checking .NET SDK availability. /// The packaging service for channel resolution. - /// The configuration service for reading channel settings. + /// The CLI execution context providing identity channel information. /// The logger for diagnostic output. public PrebuiltAppHostServer( string appPath, @@ -59,7 +59,7 @@ public PrebuiltAppHostServer( IDotNetCliRunner dotNetCliRunner, IDotNetSdkInstaller sdkInstaller, IPackagingService packagingService, - IConfigurationService configurationService, + CliExecutionContext executionContext, ILogger logger) { _appDirectoryPath = Path.GetFullPath(appPath); @@ -69,7 +69,7 @@ public PrebuiltAppHostServer( _dotNetCliRunner = dotNetCliRunner; _sdkInstaller = sdkInstaller; _packagingService = packagingService; - _configurationService = configurationService; + _executionContext = executionContext; _logger = logger; // Create a working directory for this app host session @@ -109,8 +109,8 @@ public async Task PrepareAsync( try { - // Resolve the configured channel (local settings.json β†’ global config fallback) - var channelName = await ResolveChannelNameAsync(cancellationToken); + // Resolve the configured channel from the local project config + var channelName = ResolveChannelName(); if (projectRefs.Count > 0) { @@ -335,20 +335,14 @@ internal static string GenerateIntegrationProjectFile( } /// - /// Resolves the configured channel name from local project config or global config. + /// Resolves the configured channel name from the local project config. /// - private async Task ResolveChannelNameAsync(CancellationToken cancellationToken) + private string? ResolveChannelName() { // Check aspire.config.json first, then fall back to legacy .aspire/settings.json. var channelName = AspireConfigFile.Load(_appDirectoryPath)?.Channel ?? AspireJsonConfiguration.Load(_appDirectoryPath)?.Channel; - // Fall back to global config - if (string.IsNullOrEmpty(channelName)) - { - channelName = await _configurationService.GetConfigurationAsync("channel", cancellationToken); - } - if (!string.IsNullOrEmpty(channelName)) { _logger.LogDebug("Resolved channel: {Channel}", channelName); @@ -421,6 +415,16 @@ internal static string GenerateIntegrationProjectFile( return null; } + // Skip PSM only for the local-build identity hive β€” it exists for dev convenience + // (a locally-built CLI consuming its own per-run hive) and should not restrict NuGet + // resolution. For all other identity channels (stable, staging, daily, pr-*) PSM + // must emit so restore honors the channel's package source mappings even when + // channelName == IdentityChannel. + if (string.Equals(_executionContext.IdentityChannel, PackageChannelNames.Local, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + // Materializing the temp config is required for explicit channels so that // restore honors the channel's package source mappings. Let IO/XML failures // surface instead of silently falling back to the caller's unmapped sources, diff --git a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs index 6c6aed0aa4a..8da257b7954 100644 --- a/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs +++ b/src/Aspire.Cli/Scaffolding/ScaffoldingService.cs @@ -22,17 +22,20 @@ internal sealed class ScaffoldingService : IScaffoldingService private readonly IAppHostServerProjectFactory _appHostServerProjectFactory; private readonly ILanguageDiscovery _languageDiscovery; private readonly IInteractionService _interactionService; + private readonly CliExecutionContext _cliExecutionContext; private readonly ILogger _logger; public ScaffoldingService( IAppHostServerProjectFactory appHostServerProjectFactory, ILanguageDiscovery languageDiscovery, IInteractionService interactionService, + CliExecutionContext cliExecutionContext, ILogger logger) { _appHostServerProjectFactory = appHostServerProjectFactory; _languageDiscovery = languageDiscovery; _interactionService = interactionService; + _cliExecutionContext = cliExecutionContext; _logger = logger; } @@ -61,14 +64,20 @@ private async Task ScaffoldGuestLanguageAsync(ScaffoldContext context, Can { config.SdkVersion = context.SdkVersion; } - if (!string.IsNullOrWhiteSpace(context.Channel)) + + // Seed the project channel: explicit user input wins; otherwise default to the channel + // baked into the running CLI (CliExecutionContext.Channel). Silent default β€” no prompt. + var seedChannel = string.IsNullOrWhiteSpace(context.Channel) + ? _cliExecutionContext.Channel + : context.Channel; + if (!string.IsNullOrEmpty(seedChannel)) { - config.Channel = context.Channel; + config.Channel = seedChannel; } PreAddJavaScriptHostingForBrownfieldTypeScript(config, directory, language, sdkVersion); if (!string.IsNullOrWhiteSpace(context.SdkVersion) || - !string.IsNullOrWhiteSpace(context.Channel)) + !string.IsNullOrEmpty(seedChannel)) { config.Save(directory.FullName); } @@ -194,10 +203,9 @@ await GenerateCodeViaRpcAsync( } config.Profiles = profiles; - if (prepareResult.ChannelName is not null) - { - config.Channel = prepareResult.ChannelName; - } + // Persist the resolved channel: prepare-resolved value wins; otherwise fall back to + // the channel baked into the running CLI (CliExecutionContext.Channel). + config.Channel = prepareResult.ChannelName ?? _cliExecutionContext.Channel; config.AppHost ??= new AspireConfigAppHost(); config.AppHost.Path ??= language.AppHostFileName; config.AppHost.Language = language.LanguageId; diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.GoStarterTemplate.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.GoStarterTemplate.cs index e0df1ae4295..37f2b1a5fd3 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.GoStarterTemplate.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.GoStarterTemplate.cs @@ -57,13 +57,18 @@ private async Task ApplyGoStarterTemplateAsync(CallbackTemplate _logger.LogDebug("Copying embedded Go starter template files to '{OutputPath}'.", outputPath); await CopyTemplateTreeToDiskAsync("go-starter", outputPath, ApplyAllTokens, cancellationToken); - // Write channel to settings.json before restore so package resolution uses the selected channel. - if (!string.IsNullOrEmpty(inputs.Channel)) + // Seed the channel into settings.json before restore so package resolution + // uses the correct channel. Explicit input wins; otherwise default to the + // channel baked into the running CLI (CliExecutionContext.Channel). + var seedChannel = string.IsNullOrEmpty(inputs.Channel) + ? _executionContext.Channel + : inputs.Channel; + if (!string.IsNullOrEmpty(seedChannel)) { var config = AspireJsonConfiguration.Load(outputPath); if (config is not null) { - config.Channel = inputs.Channel; + config.Channel = seedChannel; config.Save(outputPath); } } diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.PythonStarterTemplate.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.PythonStarterTemplate.cs index d4c3c9ddff4..700426272a8 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.PythonStarterTemplate.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.PythonStarterTemplate.cs @@ -71,13 +71,18 @@ string ApplyAllTokens(string content) => ConditionalBlockProcessor.Process( AddRedisPackageToConfig(outputPath, aspireVersion); } - // Write channel to settings.json before restore so package resolution uses the selected channel. - if (!string.IsNullOrEmpty(inputs.Channel)) + // Seed the channel into settings.json before restore so package resolution + // uses the correct channel. Explicit input wins; otherwise default to the + // channel baked into the running CLI (CliExecutionContext.Channel). + var seedChannel = string.IsNullOrEmpty(inputs.Channel) + ? _executionContext.Channel + : inputs.Channel; + if (!string.IsNullOrEmpty(seedChannel)) { var config = AspireJsonConfiguration.Load(outputPath); if (config is not null) { - config.Channel = inputs.Channel; + config.Channel = seedChannel; config.Save(outputPath); } } diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs index b1f630783ba..151e5b85c92 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs @@ -60,12 +60,13 @@ private async Task ApplyTypeScriptStarterTemplateAsync(CallbackT await CopyTemplateTreeToDiskAsync("ts-starter", outputPath, ApplyAllTokens, cancellationToken); // Persist the template SDK version before restore so integration and codegen package - // resolution stays aligned with the project we just created. + // resolution stays aligned with the project we just created. Seed the channel: + // explicit input wins; otherwise default to the channel baked into the running + // CLI (CliExecutionContext.Channel). var config = AspireConfigFile.LoadOrCreate(outputPath, aspireVersion); - if (!string.IsNullOrEmpty(inputs.Channel)) - { - config.Channel = inputs.Channel; - } + config.Channel = !string.IsNullOrEmpty(inputs.Channel) + ? inputs.Channel + : _executionContext.Channel; config.Save(outputPath); var appHostProject = _projectFactory.TryGetProject(new FileInfo(Path.Combine(outputPath, "apphost.ts"))); diff --git a/src/Aspire.Cli/Templating/TemplateNuGetConfigService.cs b/src/Aspire.Cli/Templating/TemplateNuGetConfigService.cs index 779bc458343..63aea433cde 100644 --- a/src/Aspire.Cli/Templating/TemplateNuGetConfigService.cs +++ b/src/Aspire.Cli/Templating/TemplateNuGetConfigService.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Cli.Commands; -using Aspire.Cli.Configuration; using Aspire.Cli.DotNet; using Aspire.Cli.Exceptions; using Aspire.Cli.Interaction; @@ -20,7 +19,6 @@ internal sealed class TemplateNuGetConfigService( IInteractionService interactionService, CliExecutionContext executionContext, IPackagingService packagingService, - IConfigurationService configurationService, ITemplateVersionPrompter templateVersionPrompter, ICliHostEnvironment hostEnvironment) { @@ -67,18 +65,14 @@ public async Task PromptToCreateOrUpdateNuGetConfigAsync(PackageChannel channel, } /// - /// Applies NuGet.config create/update behavior for a channel name (option or global config value). + /// Applies NuGet.config create/update behavior for a channel name resolved from + /// command input (e.g. --channel) or per-project aspire.config.json. /// /// The optional channel name from command input. /// The output path where the project was created. /// A cancellation token. public async Task PromptToCreateOrUpdateNuGetConfigAsync(string? channelName, string outputPath, CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(channelName)) - { - channelName = await configurationService.GetConfigurationAsync("channel", cancellationToken); - } - if (string.IsNullOrWhiteSpace(channelName)) { return; @@ -109,11 +103,6 @@ public async Task PromptToCreateOrUpdateNuGetConfigAsync(string? channelName, st /// if a NuGet.config was created or updated; otherwise . public async Task CreateOrUpdateNuGetConfigWithoutPromptAsync(string? channelName, string outputPath, CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(channelName)) - { - channelName = await configurationService.GetConfigurationAsync("channel", cancellationToken); - } - if (string.IsNullOrWhiteSpace(channelName)) { return false; @@ -153,16 +142,11 @@ public async Task ResolveTemplatePackageAsync(Template { var allChannels = await packagingService.GetChannelsAsync(cancellationToken); - // Channel override (e.g. --channel) takes priority over the global setting. var channelName = query.ChannelOverride; - if (string.IsNullOrEmpty(channelName)) - { - channelName = await configurationService.GetConfigurationAsync("channel", cancellationToken); - } // Honor PR hives only when the caller opts in. Init suppresses this so a developer // with stale ~/.aspire/hives/* doesn't get a different template than on a clean machine. - var hasPrHives = query.IncludePrHives && executionContext.GetPrHiveCount() > 0; + var hasPrHives = query.IncludePrHives && executionContext.GetHiveCount() > 0; var hasChannelSetting = !string.IsNullOrEmpty(channelName); IEnumerable channels; @@ -171,9 +155,30 @@ public async Task ResolveTemplatePackageAsync(Template var matchingChannel = allChannels.FirstOrDefault(c => string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase)); if (matchingChannel is null) { - throw new ChannelNotFoundException($"No channel found matching '{channelName}'. Valid options are: {string.Join(", ", allChannels.Select(c => c.Name))}"); + // A locally-built CLI bakes channel="local" into its assembly metadata, but the + // matching ~/.aspire/hives/local hive only exists if the developer installed the + // build via the dogfood script. On a clean machine the hive is absent and the + // PackagingService produces no "local" channel, which would otherwise fail any + // caller that forwards CliExecutionContext.Channel as an explicit override + // (e.g. `aspire init`). Treat this exact case as a request for the implicit + // channel β€” semantically a CLI with no local hive is just a CLI that uses the + // ambient NuGet configuration. The implicit channel is the CLI's own + // no-channel default, derived from ambient NuGet, not from any settings file. + if (string.Equals(channelName, PackageChannelNames.Local, StringComparison.OrdinalIgnoreCase)) + { + var implicitChannel = allChannels.FirstOrDefault(c => c.Type is PackageChannelType.Implicit) + ?? throw new ChannelNotFoundException($"No channel found matching '{channelName}' and no implicit channel is registered. Valid options are: {string.Join(", ", allChannels.Select(c => c.Name))}"); + channels = new[] { implicitChannel }; + } + else + { + throw new ChannelNotFoundException($"No channel found matching '{channelName}'. Valid options are: {string.Join(", ", allChannels.Select(c => c.Name))}"); + } + } + else + { + channels = new[] { matchingChannel }; } - channels = new[] { matchingChannel }; } else { @@ -227,8 +232,9 @@ await Parallel.ForEachAsync(channels, cancellationToken, async (channel, ct) => return new TemplatePackageSelection(cliVersionMatch.Package, cliVersionMatch.Channel); } - // If channel was specified via --channel option or global setting (but no --version), - // automatically select the highest version from that channel without prompting. + // If channel was specified via --channel option or per-project aspire.config.json + // (but no --version), automatically select the highest version from that channel + // without prompting. if (hasChannelSetting) { var first = orderedPackagesFromChannels.First(); @@ -306,7 +312,7 @@ public async Task InstallTemplatePackageAsync( /// /// Inputs that control how picks a channel and version. /// -/// Optional channel name override (e.g. from --channel). When null, the global channel configuration is consulted. +/// Optional channel name override resolved upstream from --channel or per-project aspire.config.json. When null, channel selection proceeds without an explicit override (the resolver's default behavior). /// Optional explicit template version (e.g. from --version). /// Optional source override carried for symmetry with ; not consulted by resolution today. /// When true (e.g. for aspire new), local PR hive directories under ~/.aspire/hives participate in channel discovery; when false (e.g. for aspire init), they are ignored. diff --git a/src/Aspire.Cli/Utils/VersionHelper.cs b/src/Aspire.Cli/Utils/VersionHelper.cs index 1be7449066d..cda6f0f2d3c 100644 --- a/src/Aspire.Cli/Utils/VersionHelper.cs +++ b/src/Aspire.Cli/Utils/VersionHelper.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; +using Aspire.Cli.Packaging; using Aspire.Cli.Resources; using Aspire.Shared; @@ -11,12 +12,14 @@ internal static class VersionHelper { /// /// Returns when identifies a - /// locally-built channel β€” either a PR hive (pr-*) or a workflow-run hive (run-*). + /// locally-built channel β€” a PR hive (pr-*), a workflow-run hive (run-*), + /// or a local development build (local). /// public static bool IsLocalBuildChannel(string? channelName) { return channelName is not null && - (channelName.StartsWith("pr-", StringComparison.OrdinalIgnoreCase) || + (channelName.Equals(PackageChannelNames.Local, StringComparison.OrdinalIgnoreCase) || + channelName.StartsWith("pr-", StringComparison.OrdinalIgnoreCase) || channelName.StartsWith("run-", StringComparison.OrdinalIgnoreCase)); } diff --git a/tests/Aspire.Acquisition.Tests/Scripts/PRScriptPowerShellTests.cs b/tests/Aspire.Acquisition.Tests/Scripts/PRScriptPowerShellTests.cs index a9c7f377c02..e154a37ef97 100644 --- a/tests/Aspire.Acquisition.Tests/Scripts/PRScriptPowerShellTests.cs +++ b/tests/Aspire.Acquisition.Tests/Scripts/PRScriptPowerShellTests.cs @@ -318,4 +318,31 @@ public async Task HiveOnly_SkipsCLIDownload() result.EnsureSuccessful(); Assert.Contains("Skipping CLI download", result.Output, StringComparison.OrdinalIgnoreCase); } + + [Fact] + public async Task LocalDir_WhatIf_WithGitHubRunIdEnvSet_UsesLocalHiveLabel() + { + // Regression guard: the GITHUB_RUN_ID env var must NOT influence the hive label + // when -LocalDir is used. Without this test, re-introducing a GITHUB_RUN_ID + // branch would produce "run-99999" silently instead of the expected "local" + // hive label. + using var env = new TestEnvironment(); + using var cmd = new ScriptToolCommand(s_scriptPath, env, _testOutput); + var localDir = Path.Combine(env.TempDirectory, "local-artifacts"); + Directory.CreateDirectory(localDir); + // Non-PR-style version ensures auto-detect falls through to "local" label. + await FakeArchiveHelper.CreateFakeNupkgAsync(localDir, "Aspire.Cli", "13.3.0-dev.1"); + + // Inject GITHUB_RUN_ID only into the launched process β€” not the test process environment. + cmd.WithEnvironmentVariable("GITHUB_RUN_ID", "99999"); + + var result = await cmd.ExecuteAsync( + "-LocalDir", localDir, + "-HiveOnly", + "-WhatIf"); + + result.EnsureSuccessful(); + Assert.Contains("local", result.Output, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("run-99999", result.Output, StringComparison.OrdinalIgnoreCase); + } } diff --git a/tests/Aspire.Acquisition.Tests/Scripts/PRScriptShellTests.cs b/tests/Aspire.Acquisition.Tests/Scripts/PRScriptShellTests.cs index 440178408a3..e754ee1037b 100644 --- a/tests/Aspire.Acquisition.Tests/Scripts/PRScriptShellTests.cs +++ b/tests/Aspire.Acquisition.Tests/Scripts/PRScriptShellTests.cs @@ -417,4 +417,30 @@ public async Task HiveOnly_SkipsCLIDownload() result.EnsureSuccessful(); Assert.Contains("Skipping CLI download", result.Output, StringComparison.OrdinalIgnoreCase); } + + [Fact] + public async Task LocalDir_DryRun_WithGitHubRunIdEnvSet_UsesLocalHiveLabel() + { + // Regression guard: the GITHUB_RUN_ID env var must NOT influence the hive label + // when --local-dir is used. Without this test, re-introducing a GITHUB_RUN_ID + // branch would produce "run-99999" silently instead of the expected "local" + // hive label. + using var env = new TestEnvironment(); + using var cmd = new ScriptToolCommand(s_scriptPath, env, _testOutput); + var localDir = Path.Combine(env.TempDirectory, "local-artifacts"); + Directory.CreateDirectory(localDir); + await FakeArchiveHelper.CreateFakeNupkgAsync(localDir, "Aspire.Cli", "13.3.0-local.1"); + + // Inject GITHUB_RUN_ID only into the launched process β€” not the test process environment. + cmd.WithEnvironmentVariable("GITHUB_RUN_ID", "99999"); + + var result = await cmd.ExecuteAsync( + "--local-dir", localDir, + "--hive-only", + "--dry-run"); + + result.EnsureSuccessful(); + Assert.Contains("local", result.Output, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("run-99999", result.Output, StringComparison.OrdinalIgnoreCase); + } } diff --git a/tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptPowerShellTests.cs b/tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptPowerShellTests.cs index c6d3ef771f9..82e45212a85 100644 --- a/tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptPowerShellTests.cs +++ b/tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptPowerShellTests.cs @@ -217,4 +217,27 @@ public async Task InstallExtensionWithNonDevQuality_ReturnsError(string quality) Assert.NotEqual(0, result.ExitCode); Assert.Contains("dev", result.Output, StringComparison.OrdinalIgnoreCase); } + + [Theory] + [InlineData("dev")] + [InlineData("staging")] + [InlineData("release")] + public async Task WhatIf_DoesNotCreateGlobalAspireConfigJson(string quality) + { + using var env = new TestEnvironment(); + using var cmd = new ScriptToolCommand(s_scriptPath, env, _testOutput); + + var result = await cmd.ExecuteAsync("-Quality", quality, "-WhatIf"); + + result.EnsureSuccessful(); + + var globalConfig = Path.Combine(env.MockHome, ".aspire", "aspire.config.json"); + Assert.False( + File.Exists(globalConfig), + $"Release script must not write {globalConfig}; channel is baked into the CLI binary, not stored globally."); + + // The script should not even plan a global-channel write in its what-if output. + Assert.DoesNotContain("aspire.config.json", result.Output, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Save-GlobalSettings", result.Output, StringComparison.OrdinalIgnoreCase); + } } diff --git a/tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptShellTests.cs b/tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptShellTests.cs index 4a5cf9caa69..383e0160f5a 100644 --- a/tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptShellTests.cs +++ b/tests/Aspire.Acquisition.Tests/Scripts/ReleaseScriptShellTests.cs @@ -169,4 +169,27 @@ public async Task InstallExtensionWithNonDevQuality_ReturnsError(string quality) Assert.NotEqual(0, result.ExitCode); Assert.Contains("--quality dev", result.Output, StringComparison.OrdinalIgnoreCase); } + + [Theory] + [InlineData("dev")] + [InlineData("staging")] + [InlineData("release")] + public async Task DryRun_DoesNotCreateGlobalAspireConfigJson(string quality) + { + using var env = new TestEnvironment(); + using var cmd = new ScriptToolCommand(s_scriptPath, env, _testOutput); + + var result = await cmd.ExecuteAsync("--dry-run", "--quality", quality); + + result.EnsureSuccessful(); + + var globalConfig = Path.Combine(env.MockHome, ".aspire", "aspire.config.json"); + Assert.False( + File.Exists(globalConfig), + $"Release script must not write {globalConfig}; channel is baked into the CLI binary, not stored globally."); + + // The script should not even plan a global-channel write in its dry-run output. + Assert.DoesNotContain("aspire.config.json", result.Output, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("save_global_settings", result.Output, StringComparison.OrdinalIgnoreCase); + } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ConfigMigrationTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ConfigMigrationTests.cs index 87ae85b7068..fc1b2d7417d 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ConfigMigrationTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ConfigMigrationTests.cs @@ -101,8 +101,10 @@ private static void AssertFileDoesNotContain(string filePath, params string[] un /// /// Verifies that a legacy ~/.aspire/globalsettings.json is automatically migrated to - /// ~/.aspire/aspire.config.json when a CLI command is run, and that the legacy file - /// is preserved for backward compatibility with older CLI versions. + /// ~/.aspire/aspire.config.json when a CLI command is run, that the legacy file is + /// preserved for backward compatibility with older CLI versions, and that the + /// channel field is dropped during migration (channel is baked into the binary as + /// AspireCliChannel assembly metadata; it is not stored in global config). /// [Fact] public async Task GlobalSettings_MigratedFromLegacyFormat() @@ -139,23 +141,25 @@ public async Task GlobalSettings_MigratedFromLegacyFormat() await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - // Verify aspire.config.json was created with migrated values (host-side). - AssertFileContains(newConfigPath, "staging", "polyglotSupportEnabled"); + // Migrated aspire.config.json retains non-channel content (features, sdkVersion). + AssertFileContains(newConfigPath, "polyglotSupportEnabled"); + + // Channel is intentionally dropped from the migrated global config. + AssertFileDoesNotContain(newConfigPath, "\"channel\"", "staging"); - // Verify the legacy file was preserved (intentional for backward compat). - AssertFileContains(legacyPath, "channel"); + // Legacy file is preserved unchanged for backward compatibility, so it still + // contains the original channel value. + AssertFileContains(legacyPath, "channel", "staging"); - // Verify migrated values are accessible via aspire config get. + // aspire config get channel returns the not-found error and a non-zero exit + // because the migrated global config has no channel key. await auto.ClearScreenAsync(counter); await auto.TypeAsync("aspire config get channel"); await auto.EnterAsync(); - await auto.WaitUntilTextAsync("staging", timeout: TimeSpan.FromSeconds(10)); - await auto.WaitForSuccessPromptAsync(counter); + await auto.WaitUntilTextAsync("not found", timeout: TimeSpan.FromSeconds(10)); + await auto.WaitForAnyPromptAsync(counter); // Cleanup. - await auto.TypeAsync("aspire config delete channel -g"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); await auto.TypeAsync("aspire config delete features.polyglotSupportEnabled -g"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); @@ -276,7 +280,9 @@ public async Task GlobalMigration_HandlesMalformedLegacyJson() /// /// Verifies that legacy globalsettings.json containing JSON comments and trailing commas - /// (common when hand-edited) is correctly parsed and migrated to aspire.config.json. + /// (common when hand-edited) is correctly parsed and migrated to aspire.config.json, + /// and that the channel field is dropped during migration (channel is baked into the + /// binary as AspireCliChannel assembly metadata; it is not stored in global config). /// [Fact] public async Task GlobalMigration_HandlesCommentsAndTrailingCommas() @@ -320,20 +326,25 @@ public async Task GlobalMigration_HandlesCommentsAndTrailingCommas() await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - // Verify migration succeeded despite comments/trailing commas (host-side). - AssertFileContains(newConfigPath, "staging", "polyglotSupportEnabled"); + // Migrated aspire.config.json retains non-channel content (features). + AssertFileContains(newConfigPath, "polyglotSupportEnabled"); + + // Channel is intentionally dropped from the migrated global config. + AssertFileDoesNotContain(newConfigPath, "\"channel\"", "staging"); - // Verify value accessible via config get. + // Legacy file is preserved unchanged for backward compatibility, so it still + // contains the original channel value. + AssertFileContains(legacyPath, "channel", "staging"); + + // aspire config get channel returns the not-found error and a non-zero exit + // because the migrated global config has no channel key. await auto.ClearScreenAsync(counter); await auto.TypeAsync("aspire config get channel"); await auto.EnterAsync(); - await auto.WaitUntilTextAsync("staging", timeout: TimeSpan.FromSeconds(10)); - await auto.WaitForSuccessPromptAsync(counter); + await auto.WaitUntilTextAsync("not found", timeout: TimeSpan.FromSeconds(10)); + await auto.WaitForAnyPromptAsync(counter); // Cleanup. - await auto.TypeAsync("aspire config delete channel -g"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); await auto.TypeAsync("aspire config delete features.polyglotSupportEnabled -g"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); @@ -429,8 +440,9 @@ public async Task ConfigSetGet_CreatesNestedJsonFormat() /// /// Verifies that migration from globalsettings.json preserves all supported - /// value types: channel (string), features (dictionary of bools), and packages - /// (dictionary of strings). + /// value types: features (dictionary of bools) and packages (dictionary of strings), + /// and that the channel field is dropped during migration (channel is baked into the + /// binary as AspireCliChannel assembly metadata; it is not stored in global config). /// [Fact] public async Task GlobalMigration_PreservesAllValueTypes() @@ -478,24 +490,28 @@ public async Task GlobalMigration_PreservesAllValueTypes() await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); - // Verify all value types were migrated (host-side). + // Migrated aspire.config.json retains non-channel content (features, packages). AssertFileContains(newConfigPath, - "preview", "polyglotSupportEnabled", "stagingChannelEnabled", "Aspire.Hosting.Redis"); - // Verify individual value via config get. + // Channel is intentionally dropped from the migrated global config. + AssertFileDoesNotContain(newConfigPath, "\"channel\"", "preview"); + + // Legacy file is preserved unchanged for backward compatibility, so it still + // contains the original channel value. + AssertFileContains(legacyPath, "channel", "preview"); + + // aspire config get channel returns the not-found error and a non-zero exit + // because the migrated global config has no channel key. await auto.ClearScreenAsync(counter); await auto.TypeAsync("aspire config get channel"); await auto.EnterAsync(); - await auto.WaitUntilTextAsync("preview", timeout: TimeSpan.FromSeconds(10)); - await auto.WaitForSuccessPromptAsync(counter); + await auto.WaitUntilTextAsync("not found", timeout: TimeSpan.FromSeconds(10)); + await auto.WaitForAnyPromptAsync(counter); // Cleanup. - await auto.TypeAsync("aspire config delete channel -g"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter); await auto.TypeAsync("aspire config delete features.polyglotSupportEnabled -g"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); diff --git a/tests/Aspire.Cli.Tests/Acquisition/IdentityChannelReaderTests.cs b/tests/Aspire.Cli.Tests/Acquisition/IdentityChannelReaderTests.cs new file mode 100644 index 00000000000..927966b740a --- /dev/null +++ b/tests/Aspire.Cli.Tests/Acquisition/IdentityChannelReaderTests.cs @@ -0,0 +1,191 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using System.Reflection.Emit; +using Aspire.Cli.Acquisition; + +namespace Aspire.Cli.Tests.Acquisition; + +public class IdentityChannelReaderTests +{ + private const string ChannelMetadataKey = "AspireCliChannel"; + + [Theory] + [InlineData("stable")] + [InlineData("staging")] + [InlineData("daily")] + [InlineData("pr")] + [InlineData("local")] + public void ReadChannel_AssemblyHasMetadataForKnownChannel_ReturnsValue(string channel) + { + var assembly = BuildFakeAssemblyWithChannelMetadata($"FakeCli_{channel}", channel); + + var reader = new IdentityChannelReader(assembly); + + Assert.Equal(channel, reader.ReadChannel()); + } + + [Fact] + public void ReadChannel_AssemblyMissingChannelMetadata_ThrowsWithAssemblyName() + { + const string assemblyName = "FakeCli_NoChannel"; + var assembly = BuildFakeAssemblyWithChannelMetadata(assemblyName, channelValue: null); + + var reader = new IdentityChannelReader(assembly); + + var ex = Assert.Throws(reader.ReadChannel); + Assert.Contains(ChannelMetadataKey, ex.Message, StringComparison.Ordinal); + Assert.Contains(assemblyName, ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void ReadChannel_AssemblyHasEmptyChannelMetadata_Throws() + { + var assembly = BuildFakeAssemblyWithChannelMetadata("FakeCli_EmptyChannel", channelValue: string.Empty); + + var reader = new IdentityChannelReader(assembly); + + var ex = Assert.Throws(reader.ReadChannel); + Assert.Contains(ChannelMetadataKey, ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void ReadChannel_ChannelMetadataValueIsUnknownString_ReturnedVerbatim() + { + // The reader does not validate the value β€” invalid values are caught at build time + // by AssemblyMetadataChannelTests (the smoke test). Document the + // intentional "trust the build" behavior here. + var assembly = BuildFakeAssemblyWithChannelMetadata("FakeCli_Foobar", "foobar"); + + var reader = new IdentityChannelReader(assembly); + + Assert.Equal("foobar", reader.ReadChannel()); + } + + [Fact] + public void ReadChannel_AssemblyHasMultipleChannelMetadataAttributes_ReturnsFirstNonEmpty() + { + // MSBuild misconfiguration could conceivably emit two AspireCliChannel entries. + // The reader uses FirstOrDefault, so the first attribute encountered wins. Document + // this so future changes don't silently flip ordering. + var assembly = BuildFakeAssemblyWithChannelMetadata( + "FakeCli_DualChannel", + channelValues: ["staging", "pr"]); + + var reader = new IdentityChannelReader(assembly); + + Assert.Equal("staging", reader.ReadChannel()); + } + + [Fact] + public void ReadChannel_ChannelMetadataValueIsWhitespaceOnly_ReturnedVerbatim() + { + // Production reader treats only null/empty as "missing" (string.IsNullOrEmpty), not + // string.IsNullOrWhiteSpace. A whitespace-only value is therefore returned. This is + // a known but low-risk gap β€” the build-time smoke test catches it. Documenting the + // current behavior here so any future tightening is a deliberate decision. + var assembly = BuildFakeAssemblyWithChannelMetadata("FakeCli_Whitespace", " "); + + var reader = new IdentityChannelReader(assembly); + + Assert.Equal(" ", reader.ReadChannel()); + } + + [Fact] + public void ReadChannel_KeyLookupIsCaseSensitive_DifferentCaseTreatedAsMissing() + { + var assembly = BuildFakeAssemblyWithMetadata( + "FakeCli_CaseMismatch", + metadata: [(Key: "aspirecliChannel", Value: "stable")]); + + var reader = new IdentityChannelReader(assembly); + + var ex = Assert.Throws(reader.ReadChannel); + Assert.Contains(ChannelMetadataKey, ex.Message, StringComparison.Ordinal); + } + + // GitHub Actions PR builds emit `InformationalVersion` of the form + // `-pr..g(+)?` (set via `/p:VersionSuffix=pr.$PR_NUMBER.g$SHORT_SHA`). + // The parser MUST accept that real shape and extract . The legacy no-separator + // `-pr.` form is also accepted for back-compat with assemblies built from + // local dev shells. + [Theory] + [InlineData("0.0.0-pr12345.deadbeef", 12345)] // legacy no-separator + [InlineData("0.0.0-pr.5", 5)] // CI shape, short + [InlineData("0.0.0-pr.12345.gabcd1234", 12345)] // real GH Actions shape + [InlineData("0.0.0-pr.12345.gabcd1234+abc", 12345)] // with build metadata + [InlineData("1.2.3-preview.5", null)] // -preview must NOT match + [InlineData(null, null)] + [InlineData("0.0.0-pr0", 0)] + [InlineData("", null)] + [InlineData("0.0.0", null)] + [InlineData("0.0.0-pr", null)] // marker but no digits + [InlineData("0.0.0-pr-12345", null)] // hyphen separator NOT accepted + [InlineData("0.0.0-pr12345abc", 12345)] + [InlineData("0.0.0-pr2147483647", int.MaxValue)] + [InlineData("0.0.0-pr2147483648", null)] // overflow β†’ null + [InlineData("0.0.0-prabc.def", null)] // marker followed by non-digits + [InlineData("1.0.0-rc.1.pr12345", null)] // `.pr` (no leading `-`) must NOT match + // The parser is the gatekeeper for the bootstrap path in Program.cs; it MUST return null cleanly + // (never throw) for every shape a caller might hand us β€” whitespace, free-form garbage, the dot- + // separated `-pr.` form (CI-shape with a non-numeric tail), and overflow inputs that are + // dot-separated (the legacy no-separator overflow case is already covered by `0.0.0-pr2147483648`). + [InlineData(" ", null)] // whitespace-only + [InlineData("not-a-version", null)] // free-form garbage + [InlineData("1.0.0-pr.abc", null)] // CI-shape marker with non-numeric tail + [InlineData("1.0.0-pr.99999999999999999999", null)] // CI-shape overflow β†’ null (no throw) + public void ParsePrNumber_ReturnsExpected(string? input, int? expected) + { + Assert.Equal(expected, IdentityChannelReader.ParsePrNumber(input)); + } + + [Fact] + public void ParsePrNumber_RealCliInformationalVersion_DoesNotThrow() + { + // Defensive smoke: whatever the test-host CLI assembly's InformationalVersion looks + // like today, ParsePrNumber must never throw. It either returns a positive number + // (when the host happens to be a PR build) or null. + var infoVersion = typeof(Aspire.Cli.Program).Assembly + .GetCustomAttribute() + ?.InformationalVersion; + + var prNumber = IdentityChannelReader.ParsePrNumber(infoVersion); + + Assert.True(prNumber is null or >= 0); + } + + private static Assembly BuildFakeAssemblyWithChannelMetadata(string assemblyName, string? channelValue) + { + if (channelValue is null) + { + return BuildFakeAssemblyWithMetadata(assemblyName, metadata: []); + } + + return BuildFakeAssemblyWithMetadata( + assemblyName, + metadata: [(Key: ChannelMetadataKey, Value: channelValue)]); + } + + private static Assembly BuildFakeAssemblyWithChannelMetadata(string assemblyName, string[] channelValues) + { + var metadata = channelValues + .Select(v => (Key: ChannelMetadataKey, Value: v)) + .ToArray(); + return BuildFakeAssemblyWithMetadata(assemblyName, metadata); + } + + private static Assembly BuildFakeAssemblyWithMetadata(string assemblyName, (string Key, string Value)[] metadata) + { + var name = new AssemblyName(assemblyName); + var builder = AssemblyBuilder.DefineDynamicAssembly(name, AssemblyBuilderAccess.Run); + + var attributeCtor = typeof(AssemblyMetadataAttribute).GetConstructor([typeof(string), typeof(string)])!; + foreach (var (key, value) in metadata) + { + builder.SetCustomAttribute(new CustomAttributeBuilder(attributeCtor, [key, value])); + } + + return builder; + } +} diff --git a/tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj b/tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj index 6ef19c420ed..9a4885bf531 100644 --- a/tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj +++ b/tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj @@ -13,6 +13,23 @@ true + + + local + + + + + diff --git a/tests/Aspire.Cli.Tests/AssemblyMetadataChannelTests.cs b/tests/Aspire.Cli.Tests/AssemblyMetadataChannelTests.cs new file mode 100644 index 00000000000..b212eb45268 --- /dev/null +++ b/tests/Aspire.Cli.Tests/AssemblyMetadataChannelTests.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 System.Reflection; +using Aspire.Cli.Packaging; + +namespace Aspire.Cli.Tests; + +public class AssemblyMetadataChannelTests +{ + private static readonly string[] s_validChannels = ["stable", "staging", "daily", "pr", "local"]; + + [Fact] + public void AspireCliChannel_AssemblyMetadata_IsOneOfExpectedValues() + { + var assembly = typeof(Aspire.Cli.Program).Assembly; + + var metadata = assembly + .GetCustomAttributes() + .FirstOrDefault(a => a.Key == "AspireCliChannel"); + + Assert.NotNull(metadata); + Assert.Contains(metadata.Value, s_validChannels); + } + + [Fact] + public void CliAssembly_BakesChannel_AsLocal_MatchingCsprojDefault() + { + var assembly = typeof(Aspire.Cli.Program).Assembly; + + var metadata = assembly + .GetCustomAttributes() + .FirstOrDefault(a => a.Key == "AspireCliChannel"); + + Assert.NotNull(metadata); + // Exact equality (not set-membership) β€” guards the csproj default + // local. The membership test above passes + // for "daily" too; this one fails if the csproj default reverts. + Assert.Equal(PackageChannelNames.Local, metadata.Value); + } +} diff --git a/tests/Aspire.Cli.Tests/CliBootstrapTests.cs b/tests/Aspire.Cli.Tests/CliBootstrapTests.cs new file mode 100644 index 00000000000..cea4dcd88f0 --- /dev/null +++ b/tests/Aspire.Cli.Tests/CliBootstrapTests.cs @@ -0,0 +1,139 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using Aspire.Cli.Acquisition; +using Aspire.Cli.Tests.TestServices; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Tests; + +/// +/// Integration tests for the bootstrap wiring: the running CLI's +/// is sourced from the binary's +/// [AssemblyMetadata("AspireCliChannel")] value via +/// , registered in DI by +/// . +/// +public class CliBootstrapTests +{ + private static readonly string[] s_validChannels = ["stable", "staging", "daily", "pr", "local"]; + + private static async Task BuildHostAsync() + { + var loggingOptions = Program.ParseLoggingOptions([]); + var errorWriter = new TestStartupErrorWriter(); + var (loggerFactory, fileLoggerProvider) = Program.CreateLoggerFactory([], loggingOptions, errorWriter); + var startupContext = new Program.CliStartupContext(loggingOptions, errorWriter, loggerFactory, fileLoggerProvider, loggerFactory.CreateLogger()); + return await Program.BuildApplicationAsync([], startupContext); + } + + [Fact] + public void IdentityChannelReader_NullAssembly_ThrowsArgumentNullException() + { + // Explicit null produces an immediate, descriptive ArgumentNullException so misuse + // is caught at construction time rather than surfacing later as the cryptic + // "metadata missing on '?'" exception. + Assert.Throws(() => new IdentityChannelReader(null!)); + } + + [Fact] + public void IdentityChannelReader_OnRunningCliAssembly_ReturnsKnownChannel() + { + var reader = new IdentityChannelReader(typeof(Aspire.Cli.Program).Assembly); + + var channel = reader.ReadChannel(); + + Assert.Contains(channel, s_validChannels); + } + + [Fact] + public async Task BuildApplication_RegistersIIdentityChannelReader_AsIdentityChannelReaderInstance() + { + // Program.BuildApplicationAsync registers IIdentityChannelReader as a singleton, + // backed by the default IdentityChannelReader (which reads from + // Assembly.GetEntryAssembly()). + using var host = await BuildHostAsync(); + + var reader = host.Services.GetRequiredService(); + + Assert.NotNull(reader); + Assert.IsType(reader); + } + + [Fact] + public async Task BuildApplication_PopulatesCliExecutionContextChannel_FromIdentityChannelReader() + { + // The CliExecutionContext factory delegate must source Channel from + // IIdentityChannelReader.ReadChannel() rather than the constructor default. + // Without this wiring, the entire reseed chain would write "daily" for every + // CLI build regardless of the baked AspireCliChannel. + using var host = await BuildHostAsync(); + + var reader = host.Services.GetRequiredService(); + var context = host.Services.GetRequiredService(); + + Assert.Equal(reader.ReadChannel(), context.Channel); + } + + [Fact] + public async Task BuildApplication_LocallyBuiltCli_ChannelMatchesTestHostAssemblyMetadata() + { + // The Aspire.Cli.csproj defaults AspireCliChannel to "daily" when not overridden by + // CI; the test csproj forwards $(AspireCliChannel) the same way (see csproj comment). + // The test host and production assembly therefore stay in lockstep regardless of + // whether the build runs under /p:AspireCliChannel=stable or unspecified β€” both pick + // up the same value. We assert the bootstrapped context's channel matches the + // *test host's* baked metadata, NOT a hard-coded literal, so the test stops being + // an accidental regression for any non-default build. + using var host = await BuildHostAsync(); + + var entryAssembly = Assembly.GetEntryAssembly(); + Assert.NotNull(entryAssembly); + var bakedChannel = entryAssembly + .GetCustomAttributes() + .Single(a => string.Equals(a.Key, "AspireCliChannel", StringComparison.Ordinal)) + .Value; + Assert.False(string.IsNullOrEmpty(bakedChannel)); + + var context = host.Services.GetRequiredService(); + + Assert.Equal(bakedChannel, context.Channel); + // PrNumber is non-null only when the test host is itself a PR build. In a local + // dev build (and the default CI path) it should be null. We assert the contract: + // PrNumber.HasValue iff the channel resolved to "pr-". + if (context.IdentityChannel == "pr") + { + Assert.NotNull(context.PrNumber); + } + else + { + Assert.Null(context.PrNumber); + } + } + + [Fact] + public async Task BuildApplication_CliExecutionContextChannel_MatchesAssemblyMetadataAttribute() + { + // End-to-end coherence: the channel flowing through the DI container must equal the + // value baked into the entry assembly by [AssemblyMetadata("AspireCliChannel", "...")]. + // The bootstrap registers the default IdentityChannelReader, which reads from + // Assembly.GetEntryAssembly(); under `dotnet test` that's the test host (which mirrors + // the production "daily" via the test csproj's AssemblyMetadata item). + var entryAssembly = Assembly.GetEntryAssembly(); + Assert.NotNull(entryAssembly); + var bakedChannel = entryAssembly + .GetCustomAttributes() + .Single(a => string.Equals(a.Key, "AspireCliChannel", StringComparison.Ordinal)) + .Value; + Assert.False(string.IsNullOrEmpty(bakedChannel)); + + using var host = await BuildHostAsync(); + + var context = host.Services.GetRequiredService(); + + Assert.Equal(bakedChannel, context.Channel); + } +} diff --git a/tests/Aspire.Cli.Tests/CliExecutionContextTests.cs b/tests/Aspire.Cli.Tests/CliExecutionContextTests.cs new file mode 100644 index 00000000000..e057fbd7986 --- /dev/null +++ b/tests/Aspire.Cli.Tests/CliExecutionContextTests.cs @@ -0,0 +1,130 @@ +// 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; + +public class CliExecutionContextTests +{ + private static CliExecutionContext CreateContext(string channel = "daily", int? prNumber = null) + { + var workingDir = new DirectoryInfo(AppContext.BaseDirectory); + var hivesDir = new DirectoryInfo(Path.Combine(AppContext.BaseDirectory, "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(AppContext.BaseDirectory, "cache")); + var sdksDir = new DirectoryInfo(Path.Combine(AppContext.BaseDirectory, "sdks")); + var logsDir = new DirectoryInfo(Path.Combine(AppContext.BaseDirectory, "logs")); + return new CliExecutionContext(workingDir, hivesDir, cacheDir, sdksDir, logsDir, "test.log", channel: channel, prNumber: prNumber); + } + + [Fact] + public void Channel_DefaultsToLocal_WhenNotSpecified() + { + var workingDir = new DirectoryInfo(AppContext.BaseDirectory); + var hivesDir = new DirectoryInfo(Path.Combine(AppContext.BaseDirectory, "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(AppContext.BaseDirectory, "cache")); + var sdksDir = new DirectoryInfo(Path.Combine(AppContext.BaseDirectory, "sdks")); + var logsDir = new DirectoryInfo(Path.Combine(AppContext.BaseDirectory, "logs")); + + var ctx = new CliExecutionContext(workingDir, hivesDir, cacheDir, sdksDir, logsDir, "test.log"); + + Assert.Equal("local", ctx.Channel); + Assert.Null(ctx.PrNumber); + } + + [Fact] + public void Channel_AndPrNumber_AreReadable_WhenConstructedWithNullPrNumber() + { + var ctx = CreateContext(channel: "stable", prNumber: null); + + Assert.Equal("stable", ctx.Channel); + Assert.Null(ctx.PrNumber); + } + + [Theory] + [InlineData("stable")] + [InlineData("staging")] + [InlineData("daily")] + public void PrNumber_IsNull_ForNonPrChannels(string channel) + { + var ctx = CreateContext(channel: channel, prNumber: null); + + Assert.Equal(channel, ctx.Channel); + Assert.Null(ctx.PrNumber); + } + + [Fact] + public void PrNumber_IsSet_WhenChannelIsPr() + { + var ctx = CreateContext(channel: "pr", prNumber: 16798); + + // Option-(a) Channel resolution: `pr` + PrNumber β†’ `pr-` (the per-PR hive label + // PackagingService creates). The raw build-time value is exposed separately via + // IdentityChannel. + Assert.Equal("pr-16798", ctx.Channel); + Assert.Equal("pr", ctx.IdentityChannel); + Assert.Equal(16798, ctx.PrNumber); + } + + [Theory] + [InlineData("stable")] + [InlineData("staging")] + [InlineData("daily")] + public void Channel_Getter_ReturnsExactValuePassedToConstructor(string channel) + { + // For non-PR channels, Channel is the constructor value verbatim. The pr β†’ pr- + // transformation is covered separately by Channel_PrChannelWithPrNumber_ReturnsPrDashN. + var ctx = CreateContext(channel: channel, prNumber: null); + + Assert.Equal(channel, ctx.Channel); + } + + // Spec: option-(a) Channel resolution. CliExecutionContext.Channel returns `pr-` + // when constructed with channel="pr" AND prNumber.HasValue. Anything else returns + // the constructor-provided channel verbatim. The raw build-time value is preserved + // on IdentityChannel for callers that need the build taxonomy. + + [Fact] + public void Channel_PrChannelWithPrNumber_ReturnsPrDashN() + { + var ctx = CreateContext(channel: "pr", prNumber: 12345); + + Assert.Equal("pr-12345", ctx.Channel); + Assert.Equal("pr", ctx.IdentityChannel); + Assert.Equal(12345, ctx.PrNumber); + } + + [Fact] + public void Channel_PrChannelWithoutPrNumber_Throws() + { + // Type-level invariant: a 'pr' identity-channel without a parsed PrNumber is + // a malformed CLI build (PR build whose InformationalVersion suffix could not + // be parsed). Failing fast in the ctor prevents downstream channel resolvers + // from hunting for a non-existent 'pr' channel and surfacing a confusing + // ChannelNotFoundException far from the actual bootstrap defect. + var ex = Assert.Throws( + () => CreateContext(channel: "pr", prNumber: null)); + + Assert.Contains("PrNumber", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void Channel_StableChannelWithPrNumber_ReturnsStable() + { + // PrNumber is ignored for non-pr channels β€” the trigger for the pr- shape is + // the IdentityChannel value, not the presence of PrNumber. + var ctx = CreateContext(channel: "stable", prNumber: 99); + + Assert.Equal("stable", ctx.Channel); + Assert.Equal("stable", ctx.IdentityChannel); + Assert.Equal(99, ctx.PrNumber); + } + + [Fact] + public void Channel_DailyWithoutPrNumber_ReturnsDaily() + { + var ctx = CreateContext(channel: "daily", prNumber: null); + + Assert.Equal("daily", ctx.Channel); + Assert.Equal("daily", ctx.IdentityChannel); + Assert.Null(ctx.PrNumber); + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs index 40d3bea7867..b2b634161de 100644 --- a/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AddCommandTests.cs @@ -1970,6 +1970,167 @@ public async Task AddCommand_WithPrHive_PrefersCurrentCliVersion() Assert.False(promptedForVersion); Assert.Equal(cliVersion, selectedPackageVersion); } + [Fact] + public async Task AddCommand_WithLocalHive_PrefersCurrentCliVersion() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var localPackagesDir = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives", PackageChannelNames.Local, "packages")); + localPackagesDir.Create(); + + var cliVersion = VersionHelper.GetDefaultSdkVersion(); + // Aspire.Hosting drives GetLocalHivePinnedVersion; Aspire.Hosting.Redis is the integration we add. + File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.Hosting.{cliVersion}.nupkg"), string.Empty); + File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.Hosting.Redis.{cliVersion}.nupkg"), string.Empty); + + var selectedPackageVersion = string.Empty; + var promptedForVersion = false; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.AddCommandPrompterFactory = (sp) => + { + var interactionService = sp.GetRequiredService(); + var prompter = new TestAddCommandPrompter(interactionService); + prompter.PromptForIntegrationVersionCallback = (packages) => + { + promptedForVersion = true; + throw new InvalidOperationException("Should not prompt when the current CLI version is available in the local hive."); + }; + + return prompter; + }; + + options.ProjectLocatorFactory = _ => new TestProjectLocator(); + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner(); + // The local channel enumerates .nupkg files directly and does not call package search. + // Implicit channel still goes through search; return a stable that should be ignored. + runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, invocationOptions, cancellationToken) => + { + var implicitPackage = new NuGetPackage + { + Id = "Aspire.Hosting.Redis", + Source = "implicit", + Version = "13.2.2" + }; + + return (0, new[] { implicitPackage }); + }; + + runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, invocationOptions, cancellationToken) => + { + selectedPackageVersion = packageVersion; + return 0; + }; + + return runner; + }; + }); + + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("add redis"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(0, exitCode); + Assert.False(promptedForVersion); + Assert.Equal(cliVersion, selectedPackageVersion); + } + + [Fact] + public async Task AddCommand_WithLocalAndPrHives_PrefersHiveMatchingCurrentCliVersion() + { + // F (cross-channel mixing precedence): both `local` and `pr-12345` hives are populated. + // The local hive is pinned to the current CLI version; pr-12345 is pinned to a stale version. + // AddCommand routes through VersionHelper.TryGetCurrentCliVersionMatch, which iterates + // candidates from local-build channels (`IsLocalBuildChannel` = local | pr-* | run-*) and + // returns the first version that exactly matches GetDefaultSdkVersion(). Only the local + // hive's package matches, so it wins regardless of which channel ran first. + // + // NOTE on undocumented contract: when BOTH hives contain a CLI-version-exact match, + // selection falls through to enumeration order of GetChannelsAsync's + // HivesDirectory.GetDirectories() (filesystem-dependent, typically alphabetical), + // combined with Parallel.ForEachAsync ordering in IntegrationPackageSearchService. + // No deterministic precedence is currently defined for that case. Flagged for policy. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var hivesRoot = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives")); + var localPackagesDir = new DirectoryInfo(Path.Combine(hivesRoot.FullName, PackageChannelNames.Local, "packages")); + var prPackagesDir = new DirectoryInfo(Path.Combine(hivesRoot.FullName, "pr-12345", "packages")); + localPackagesDir.Create(); + prPackagesDir.Create(); + + var cliVersion = VersionHelper.GetDefaultSdkVersion(); + const string staleVersion = "13.0.0-pr.99999.gstale01"; + + File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.Hosting.{cliVersion}.nupkg"), string.Empty); + File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.Hosting.Redis.{cliVersion}.nupkg"), string.Empty); + + File.WriteAllText(Path.Combine(prPackagesDir.FullName, $"Aspire.Hosting.{staleVersion}.nupkg"), string.Empty); + File.WriteAllText(Path.Combine(prPackagesDir.FullName, $"Aspire.Hosting.Redis.{staleVersion}.nupkg"), string.Empty); + + var selectedPackageVersion = string.Empty; + var selectedPackageSource = string.Empty; + var promptedForVersion = false; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.AddCommandPrompterFactory = (sp) => + { + var interactionService = sp.GetRequiredService(); + var prompter = new TestAddCommandPrompter(interactionService); + prompter.PromptForIntegrationVersionCallback = (packages) => + { + promptedForVersion = true; + throw new InvalidOperationException("Should not prompt; CLI-version match in local hive should win."); + }; + + return prompter; + }; + + options.ProjectLocatorFactory = _ => new TestProjectLocator(); + + options.DotNetCliRunnerFactory = (sp) => + { + var runner = new TestDotNetCliRunner(); + runner.SearchPackagesAsyncCallback = (dir, query, exactMatch, prerelease, take, skip, nugetSource, useCache, invocationOptions, cancellationToken) => + { + var implicitPackage = new NuGetPackage + { + Id = "Aspire.Hosting.Redis", + Source = "implicit", + Version = "13.2.2" + }; + + return (0, new[] { implicitPackage }); + }; + + runner.AddPackageAsyncCallback = (projectFilePath, packageName, packageVersion, nugetSource, noRestore, invocationOptions, cancellationToken) => + { + selectedPackageVersion = packageVersion; + selectedPackageSource = nugetSource ?? string.Empty; + return 0; + }; + + return runner; + }; + }); + + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("add redis"); + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(0, exitCode); + Assert.False(promptedForVersion); + Assert.Equal(cliVersion, selectedPackageVersion); + Assert.NotEqual(staleVersion, selectedPackageVersion); + } private static NuGetPackage CreatePackage(string id, string version) { diff --git a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs index 183f6c68c94..bd50cbaaf4e 100644 --- a/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs @@ -21,11 +21,17 @@ public class InitCommandTests(ITestOutputHelper outputHelper) { /// /// Configures the test packaging service factory to return a single implicit channel - /// whose template package cache yields one Aspire.ProjectTemplates entry. Init's project-mode path needs this - /// to resolve a template version before invoking dotnet new install. + /// whose template package cache yields one Aspire.ProjectTemplates entry, and pins the + /// running CLI's identity channel to default so the resolver matches that implicit + /// channel by name. Init's project-mode path uses + /// as the channel override; this helper keeps + /// tests that don't care about channel selection on a single, predictable channel. /// private static void ConfigureImplicitTemplateChannel(CliServiceCollectionTestOptions options, string version = "13.3.0") { + options.CliExecutionContextFactory = _ => + BuildExecutionContext(options.WorkingDirectory, channel: "default", prNumber: null); + options.PackagingServiceFactory = _ => { var fakeCache = new FakeNuGetPackageCache @@ -577,79 +583,6 @@ public async Task InitCommand_WhenAppHostAlreadyExists_DoesNotOverwriteIt() Assert.Equal(preExistingContent, await File.ReadAllTextAsync(appHostPath)); } - [Fact] - public async Task InitCommand_WhenSolutionExistsAndChannelIsExplicit_PassesTemporaryNuGetConfigToTemplateInstall() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - - var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln")); - File.WriteAllText(solutionFile.FullName, "Fake solution file"); - - FileInfo? capturedNuGetConfigFile = null; - string? capturedNuGetSource = null; - string? capturedTemplateNuGetConfigContents = null; - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - // Match the bug repro: user has a global `channel` setting pointing at an explicit channel. - options.ConfigurationServiceFactory = _ => new FakeConfigurationServiceWithChannel("staging"); - - options.PackagingServiceFactory = _ => - { - var fakeCache = new FakeNuGetPackageCache - { - GetTemplatePackagesAsyncCallback = (_, _, _, _) => - Task.FromResult>( - [new NuGetPackageCli { Id = "Aspire.ProjectTemplates", Source = "https://example.test/staging/v3/index.json", Version = "13.3.0" }]) - }; - - var explicitChannel = PackageChannel.CreateExplicitChannel( - "staging", - PackageChannelQuality.Both, - [new PackageMapping("Aspire*", "https://example.test/staging/v3/index.json")], - fakeCache); - - return new TestPackagingService - { - GetChannelsAsyncCallback = _ => Task.FromResult>([explicitChannel]) - }; - }; - - options.DotNetCliRunnerFactory = _ => - { - var runner = new TestDotNetCliRunner(); - runner.InstallTemplateAsyncCallback = (_, version, nugetConfigFile, nugetSource, _, _, _) => - { - capturedNuGetConfigFile = nugetConfigFile; - capturedNuGetSource = nugetSource; - if (nugetConfigFile is not null && File.Exists(nugetConfigFile.FullName)) - { - capturedTemplateNuGetConfigContents = File.ReadAllText(nugetConfigFile.FullName); - } - return (0, version); - }; - runner.NewProjectAsyncCallback = (_, _, outputPath, _, _) => - { - Directory.CreateDirectory(outputPath); - return 0; - }; - return runner; - }; - }); - - var serviceProvider = services.BuildServiceProvider(); - var initCommand = serviceProvider.GetRequiredService(); - - var parseResult = initCommand.Parse("init"); - var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); - - Assert.Equal(ExitCodeConstants.Success, exitCode); - Assert.NotNull(capturedNuGetConfigFile); - Assert.Equal("https://example.test/staging/v3/index.json", capturedNuGetSource); - Assert.NotNull(capturedTemplateNuGetConfigContents); - Assert.Contains("https://example.test/staging/v3/index.json", capturedTemplateNuGetConfigContents); - } - [Fact] public async Task InitCommand_WhenSolutionExistsAndChannelIsImplicit_LeavesNuGetConfigNull() { @@ -703,7 +636,7 @@ public async Task InitCommand_WhenSolutionExistsAndPrHivesPresent_DoesNotWidenTo var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln")); File.WriteAllText(solutionFile.FullName, "Fake solution file"); - // Simulate a stale PR hive on disk so executionContext.GetPrHiveCount() returns > 0. + // Simulate a stale PR hive on disk so executionContext.GetHiveCount() returns > 0. var hivesDir = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives")); Directory.CreateDirectory(Path.Combine(hivesDir.FullName, "pr-12345", "packages")); @@ -711,6 +644,9 @@ public async Task InitCommand_WhenSolutionExistsAndPrHivesPresent_DoesNotWidenTo var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { + options.CliExecutionContextFactory = _ => + BuildExecutionContext(options.WorkingDirectory, channel: "default", prNumber: null); + options.PackagingServiceFactory = _ => { // Implicit channel offers the expected stable version; PR hive channel offers a much @@ -769,7 +705,7 @@ [new PackageMapping("Aspire*", hivesDir.FullName + "/pr-12345/packages")], } [Fact] - public async Task InitCommand_WhenChannelResolutionThrowsChannelNotFound_DisplaysFriendlyError() + public async Task InitCommand_WhenChannelTemplateSearchFails_DisplaysFriendlyError() { using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -780,19 +716,18 @@ public async Task InitCommand_WhenChannelResolutionThrowsChannelNotFound_Display var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.InteractionServiceFactory = _ => interactionService; + options.CliExecutionContextFactory = _ => + BuildExecutionContext(options.WorkingDirectory, channel: "default", prNumber: null); - // Configure global channel = "missing-channel" so the resolver looks for a channel that doesn't exist. - options.ConfigurationServiceFactory = _ => new FakeConfigurationServiceWithChannel("missing-channel"); + options.InteractionServiceFactory = _ => interactionService; - // Return only an implicit/default channel so the lookup fails to match "missing-channel". + // Fake cache throws NuGetPackageCacheException to simulate offline / inaccessible feed. options.PackagingServiceFactory = _ => { var fakeCache = new FakeNuGetPackageCache { GetTemplatePackagesAsyncCallback = (_, _, _, _) => - Task.FromResult>( - [new NuGetPackageCli { Id = "Aspire.ProjectTemplates", Source = "nuget.org", Version = "13.3.0" }]) + throw new NuGetPackageCacheException("Package search failed: simulated network failure") }; var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache); return new TestPackagingService @@ -806,7 +741,7 @@ public async Task InitCommand_WhenChannelResolutionThrowsChannelNotFound_Display var runner = new TestDotNetCliRunner(); runner.InstallTemplateAsyncCallback = (_, _, _, _, _, _, _) => { - throw new InvalidOperationException("InstallTemplateAsync should not run when channel resolution fails."); + throw new InvalidOperationException("InstallTemplateAsync should not run when channel search fails."); }; return runner; }; @@ -819,30 +754,272 @@ public async Task InitCommand_WhenChannelResolutionThrowsChannelNotFound_Display var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); Assert.Equal(ExitCodeConstants.FailedToInstallTemplates, exitCode); - Assert.Contains(interactionService.DisplayedErrors, e => e.Contains("missing-channel", StringComparison.Ordinal)); + Assert.Contains(interactionService.DisplayedErrors, e => e.Contains("simulated network failure", StringComparison.Ordinal)); } + /// + /// When the user does not pass --channel, the project-mode init path must resolve + /// its template package against the channel baked into the running CLI binary (exposed + /// as ). One named explicit channel is registered + /// per theory row and uniquely sourced; the assertion captures the package source seen by + /// dotnet new install and verifies it came from the matching channel β€” proving the + /// resolver picked the binary's identity channel rather than the implicit default or any + /// other registered channel. + /// + [Theory] + [InlineData("stable")] + [InlineData("staging")] + [InlineData("daily")] + [InlineData("pr-12345")] + public async Task InitCommand_ProjectMode_NoChannelOverride_ResolvesAgainstCliExecutionContextChannel(string contextChannel) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln")); + File.WriteAllText(solutionFile.FullName, "Fake solution file"); + + string? capturedNuGetSource = null; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CliExecutionContextFactory = _ => CreateExecutionContextForChannel(workspace.WorkspaceRoot, contextChannel); + options.PackagingServiceFactory = _ => CreateNamedChannelPackagingService(contextChannel); + + options.DotNetCliRunnerFactory = _ => + { + var runner = new TestDotNetCliRunner(); + runner.InstallTemplateAsyncCallback = (_, version, _, nugetSource, _, _, _) => + { + capturedNuGetSource = nugetSource; + return (0, version); + }; + runner.NewProjectAsyncCallback = (_, _, outputPath, _, _) => + { + Directory.CreateDirectory(outputPath); + return 0; + }; + return runner; + }; + }); + + var serviceProvider = services.BuildServiceProvider(); + var initCommand = serviceProvider.GetRequiredService(); + + var parseResult = initCommand.Parse("init"); + var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.Equal(SourceForChannel(contextChannel), capturedNuGetSource); + } + + /// + /// Exercises the full produce β†’ bake β†’ resolve pipeline for PR builds: a CLI binary built + /// with identity channel pr and PR number 12345 must resolve its init-time template + /// against the pr-12345 hive. This is a joint-contract test β€” per-layer unit tests + /// can pass while the producer emits pr, the consumer expects pr-12345, and + /// only an end-to-end run catches the mismatch. + /// [Fact] - public async Task InitCommand_WhenChannelTemplateSearchFails_DisplaysFriendlyError() + public async Task InitCommand_ProjectMode_PrBuildResolvesToPrNumberedHive() { using var workspace = TemporaryWorkspace.Create(outputHelper); var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln")); File.WriteAllText(solutionFile.FullName, "Fake solution file"); - var interactionService = new TestInteractionService(); + string? capturedNuGetSource = null; var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { - options.InteractionServiceFactory = _ => interactionService; + options.CliExecutionContextFactory = _ => BuildExecutionContext(workspace.WorkspaceRoot, channel: "pr", prNumber: 12345); + options.PackagingServiceFactory = _ => CreateNamedChannelPackagingService("pr-12345"); + + options.DotNetCliRunnerFactory = _ => + { + var runner = new TestDotNetCliRunner(); + runner.InstallTemplateAsyncCallback = (_, version, _, nugetSource, _, _, _) => + { + capturedNuGetSource = nugetSource; + return (0, version); + }; + runner.NewProjectAsyncCallback = (_, _, outputPath, _, _) => + { + Directory.CreateDirectory(outputPath); + return 0; + }; + return runner; + }; + }); + + var serviceProvider = services.BuildServiceProvider(); + var initCommand = serviceProvider.GetRequiredService(); + + var parseResult = initCommand.Parse("init"); + var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.Equal(SourceForChannel("pr-12345"), capturedNuGetSource); + } + + /// + /// When the user does not pass --channel, the single-file init path must wire the + /// workspace nuget.config to the channel baked into the running CLI binary + /// (exposed as ). One named explicit channel is + /// registered per theory row with a uniquely-sourced feed; the assertion reads the + /// workspace nuget.config emitted by NuGetConfigMerger and verifies it + /// carries the matching feed URL β€” proving the resolver picked the binary's identity + /// channel rather than skipping the merge or selecting a different registered channel. + /// + [Theory] + [InlineData("stable")] + [InlineData("staging")] + [InlineData("daily")] + [InlineData("pr-12345")] + public async Task InitCommand_SingleFileMode_NoChannelOverride_WiresNuGetConfigToCliExecutionContextChannel(string contextChannel) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CliExecutionContextFactory = _ => CreateExecutionContextForChannel(workspace.WorkspaceRoot, contextChannel); + options.PackagingServiceFactory = _ => CreateNamedChannelPackagingService(contextChannel); + }); + + var serviceProvider = services.BuildServiceProvider(); + var initCommand = serviceProvider.GetRequiredService(); + + var parseResult = initCommand.Parse("init"); + var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"))); + + var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config"); + Assert.True(File.Exists(nugetConfigPath), $"nuget.config should be created in workspace for channel '{contextChannel}'."); + + var nugetConfigContent = File.ReadAllText(nugetConfigPath); + Assert.Contains(SourceForChannel(contextChannel), nugetConfigContent); + } + + /// + /// Negative-shape tripwire: aspire init must never read the channel key from + /// the global . The injected configuration service throws + /// on any GetConfigurationAsync(key, ...) or GetConfigurationFromDirectoryAsync + /// call where the key is channel; if init invokes either, the test fails with the + /// thrown message. Runs in project mode (with a solution file present) so the + /// template-package resolver is exercised. + /// + [Fact] + public async Task InitCommand_DoesNotConsultGlobalConfigurationServiceForChannelKey() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln")); + File.WriteAllText(solutionFile.FullName, "Fake solution file"); + + var tripwireConfigService = new global::Aspire.Cli.Tests.TestServices.TestConfigurationService + { + OnGetConfiguration = key => + { + if (string.Equals(key, "channel", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "aspire init must not consult IConfigurationService for the 'channel' key. " + + "Channel resolution sources from CliExecutionContext.Channel only."); + } + return null; + }, + OnGetConfigurationFromDirectory = (key, _) => + { + if (string.Equals(key, "channel", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "aspire init must not consult IConfigurationService.GetConfigurationFromDirectoryAsync " + + "for the 'channel' key. Channel resolution sources from CliExecutionContext.Channel only."); + } + return null; + } + }; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CliExecutionContextFactory = _ => CreateExecutionContextForChannel(workspace.WorkspaceRoot, "stable"); + options.PackagingServiceFactory = _ => CreateNamedChannelPackagingService("stable"); + options.ConfigurationServiceFactory = _ => tripwireConfigService; + + options.DotNetCliRunnerFactory = _ => + { + var runner = new TestDotNetCliRunner(); + runner.InstallTemplateAsyncCallback = (_, version, _, _, _, _, _) => (0, version); + runner.NewProjectAsyncCallback = (_, _, outputPath, _, _) => + { + Directory.CreateDirectory(outputPath); + return 0; + }; + return runner; + }; + }); + + var serviceProvider = services.BuildServiceProvider(); + var initCommand = serviceProvider.GetRequiredService(); + + var parseResult = initCommand.Parse("init"); + var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + } + + /// + /// Fresh-machine regression. On a developer machine that has never used Aspire, + /// ~/.aspire/hives/ does not exist (so no per-hive channels are registered). + /// A locally-built CLI bakes local as its identity channel via + /// [AssemblyMetadata("AspireCliChannel", "local")], and + /// returns that value verbatim. aspire init currently passes + /// as the channel-override into + /// TemplateNuGetConfigService.ResolveTemplatePackageAsync, which name-matches against + /// the channels produced by : default + /// (implicit), stable, daily, optional staging, and one entry per hive + /// directory. With no local hive on disk, the lookup would otherwise throw + /// and clean-machine + /// aspire init would fail. + /// + /// Pinned behavior: when the running CLI's identity channel is local AND no matching + /// named channel is registered, init falls back to the implicit channel rather than throwing. + /// This mirrors how the local channel gracefully degrades to public-feed package + /// resolution when no local hive has been scaffolded yet. + /// + [Fact] + public async Task InitCommand_OnLocalChannelCli_WithNoLocalHive_FallsBackToImplicitChannel() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln")); + File.WriteAllText(solutionFile.FullName, "Fake solution file"); + + // Simulate a fresh machine: hives directory does not exist, no per-hive channels. + var hivesDir = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives")); + Assert.False(hivesDir.Exists, "Test precondition: hives directory must not exist on a fresh machine."); + + string? capturedTemplateVersion = null; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + // Pin the running CLI's identity channel to "local" β€” the value baked into a CLI + // built without an explicit /p:AspireCliChannel= override. + options.CliExecutionContextFactory = _ => + BuildExecutionContext(options.WorkingDirectory, channel: "local", prNumber: null); - // Fake cache throws NuGetPackageCacheException to simulate offline / inaccessible feed. options.PackagingServiceFactory = _ => { + // Only the implicit channel is registered β€” no `local` named channel, no PR hives. + // This matches what PackagingService.GetChannelsAsync returns on a clean machine + // for a CLI whose channel is `local` (where stable/daily/staging are also present + // but irrelevant β€” they don't match the `local` channel name). var fakeCache = new FakeNuGetPackageCache { GetTemplatePackagesAsyncCallback = (_, _, _, _) => - throw new NuGetPackageCacheException("Package search failed: simulated network failure") + Task.FromResult>( + [new NuGetPackageCli { Id = "Aspire.ProjectTemplates", Source = "nuget.org", Version = "13.3.0" }]) }; var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache); return new TestPackagingService @@ -854,9 +1031,15 @@ public async Task InitCommand_WhenChannelTemplateSearchFails_DisplaysFriendlyErr options.DotNetCliRunnerFactory = _ => { var runner = new TestDotNetCliRunner(); - runner.InstallTemplateAsyncCallback = (_, _, _, _, _, _, _) => + runner.InstallTemplateAsyncCallback = (_, version, _, _, _, _, _) => { - throw new InvalidOperationException("InstallTemplateAsync should not run when channel search fails."); + capturedTemplateVersion = version; + return (0, version); + }; + runner.NewProjectAsyncCallback = (_, _, outputPath, _, _) => + { + Directory.CreateDirectory(outputPath); + return 0; }; return runner; }; @@ -868,36 +1051,67 @@ public async Task InitCommand_WhenChannelTemplateSearchFails_DisplaysFriendlyErr var parseResult = initCommand.Parse("init"); var exitCode = await parseResult.InvokeAsync().DefaultTimeout(); - Assert.Equal(ExitCodeConstants.FailedToInstallTemplates, exitCode); - Assert.Contains(interactionService.DisplayedErrors, e => e.Contains("simulated network failure", StringComparison.Ordinal)); + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.Equal("13.3.0", capturedTemplateVersion); } - private sealed class FakeConfigurationServiceWithChannel(string channelValue) : IConfigurationService + private static CliExecutionContext CreateExecutionContextForChannel(DirectoryInfo workingDirectory, string contextChannel) { - public Task GetConfigurationAsync(string key, CancellationToken cancellationToken = default) - => Task.FromResult(string.Equals(key, "channel", StringComparison.Ordinal) ? channelValue : null); - - public Task GetConfigurationFromDirectoryAsync(string key, DirectoryInfo startDirectory, CancellationToken cancellationToken = default) - => GetConfigurationAsync(key, cancellationToken); + if (contextChannel.StartsWith("pr-", StringComparison.Ordinal) && + int.TryParse(contextChannel.AsSpan(3), out var prNumber)) + { + return BuildExecutionContext(workingDirectory, channel: "pr", prNumber: prNumber); + } - public Task SetConfigurationAsync(string key, string value, bool isGlobal = false, CancellationToken cancellationToken = default) - => Task.CompletedTask; + return BuildExecutionContext(workingDirectory, channel: contextChannel, prNumber: null); + } - public Task DeleteConfigurationAsync(string key, bool isGlobal = false, CancellationToken cancellationToken = default) - => Task.FromResult(false); + private static CliExecutionContext BuildExecutionContext(DirectoryInfo workingDirectory, string channel, int? prNumber) + { + var hivesDirectory = new DirectoryInfo(Path.Combine(workingDirectory.FullName, ".aspire", "hives")); + var cacheDirectory = new DirectoryInfo(Path.Combine(workingDirectory.FullName, ".aspire", "cache")); + var sdksDirectory = new DirectoryInfo(Path.Combine(workingDirectory.FullName, ".aspire", "sdks")); + var logsDirectory = new DirectoryInfo(Path.Combine(workingDirectory.FullName, ".aspire", "logs")); + var logFilePath = Path.Combine(logsDirectory.FullName, "test.log"); + + return new CliExecutionContext( + workingDirectory: workingDirectory, + hivesDirectory: hivesDirectory, + cacheDirectory: cacheDirectory, + sdksDirectory: sdksDirectory, + logsDirectory: logsDirectory, + logFilePath: logFilePath, + channel: channel, + prNumber: prNumber); + } - public Task> GetAllConfigurationAsync(CancellationToken cancellationToken = default) - => Task.FromResult(new Dictionary()); + private static TestPackagingService CreateNamedChannelPackagingService(string channelName) + { + var source = SourceForChannel(channelName); + var version = "13.3.0"; - public Task> GetLocalConfigurationAsync(CancellationToken cancellationToken = default) - => Task.FromResult(new Dictionary()); + var fakeCache = new FakeNuGetPackageCache + { + GetTemplatePackagesAsyncCallback = (_, _, _, _) => + Task.FromResult>( + [new NuGetPackageCli { Id = "Aspire.ProjectTemplates", Source = source, Version = version }]) + }; - public Task> GetGlobalConfigurationAsync(CancellationToken cancellationToken = default) - => Task.FromResult(new Dictionary()); + var explicitChannel = PackageChannel.CreateExplicitChannel( + channelName, + PackageChannelQuality.Both, + [new PackageMapping("Aspire*", source)], + fakeCache); - public string GetSettingsFilePath(bool isGlobal) => string.Empty; + return new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>([explicitChannel]) + }; } + private static string SourceForChannel(string channelName) + => $"https://feeds.test.invalid/{channelName}/v3/index.json"; + private sealed class TestScaffoldingService : IScaffoldingService { public Task ScaffoldAsync(ScaffoldContext context, CancellationToken cancellationToken) diff --git a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs index 60f8945ecdc..f7495af4d14 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -1718,6 +1718,94 @@ private static string GetAspireExecutableName() { return OperatingSystem.IsWindows() ? "aspire.exe" : "aspire"; } + + // `aspire update --self` no longer mutates the global identity channel via + // IConfigurationService. The freshly extracted binary already carries its own channel + // via [AssemblyMetadata("AspireCliChannel")], so the global write is dead weight and + // a contamination source. + + [Theory] + [InlineData("update --self --channel stable")] + [InlineData("update --self --channel staging")] + [InlineData("update --self --channel daily")] + [InlineData("update --self --quality daily")] + public async Task UpdateCommand_SelfUpdate_DoesNotWriteChannelToGlobalConfiguration(string commandLine) + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var setKeys = new List<(string Key, string Value, bool IsGlobal)>(); + var deleteKeys = new List<(string Key, bool IsGlobal)>(); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.ConfigurationServiceFactory = _ => new Aspire.Cli.Tests.TestServices.TestConfigurationService + { + OnSetConfiguration = (key, value, isGlobal) => setKeys.Add((key, value, isGlobal)), + OnDeleteConfiguration = (key, isGlobal) => deleteKeys.Add((key, isGlobal)), + }; + + options.CliDownloaderFactory = _ => new TestCliDownloader(workspace.WorkspaceRoot) + { + DownloadLatestCliAsyncCallback = (channel, ct) => + { + var archivePath = Path.Combine(workspace.WorkspaceRoot.FullName, "test-cli.tar.gz"); + File.WriteAllText(archivePath, "fake archive"); + return Task.FromResult(archivePath); + } + }; + }); + + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse(commandLine); + + // Extraction will fail (the fake archive isn't a real tar.gz) β€” that's fine, + // the assertions are about what was/wasn't written to global config before + // extraction completed. + await result.InvokeAsync().DefaultTimeout(); + + Assert.DoesNotContain(setKeys, e => e.Key.Equals("channel", StringComparison.Ordinal) && e.IsGlobal); + Assert.DoesNotContain(deleteKeys, e => e.Key.Equals("channel", StringComparison.Ordinal) && e.IsGlobal); + } + + [Fact] + public async Task UpdateCommand_SelfUpdate_StableChannel_DoesNotDeleteGlobalChannel() + { + // Pre-S9, selecting `stable` triggered DeleteConfigurationAsync("channel", isGlobal: true) + // to roll back any prior write. Verify the delete is gone β€” it shouldn't fire even + // for the stable channel (no writer => no rollback). + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var deleteCalls = new List<(string Key, bool IsGlobal)>(); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.ConfigurationServiceFactory = _ => new Aspire.Cli.Tests.TestServices.TestConfigurationService + { + OnDeleteConfiguration = (key, isGlobal) => deleteCalls.Add((key, isGlobal)), + }; + + options.CliDownloaderFactory = _ => new TestCliDownloader(workspace.WorkspaceRoot) + { + DownloadLatestCliAsyncCallback = (channel, ct) => + { + var archivePath = Path.Combine(workspace.WorkspaceRoot.FullName, "test-cli.tar.gz"); + File.WriteAllText(archivePath, "fake archive"); + return Task.FromResult(archivePath); + } + }; + }); + + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + var result = command.Parse("update --self --channel stable"); + + await result.InvokeAsync().DefaultTimeout(); + + Assert.DoesNotContain(deleteCalls, e => e.Key.Equals("channel", StringComparison.Ordinal) && e.IsGlobal); + } } // Helper class to track DisplayCancellationMessage calls diff --git a/tests/Aspire.Cli.Tests/Configuration/PrebuiltAppHostServerChannelResolutionTests.cs b/tests/Aspire.Cli.Tests/Configuration/PrebuiltAppHostServerChannelResolutionTests.cs new file mode 100644 index 00000000000..21d274b2362 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Configuration/PrebuiltAppHostServerChannelResolutionTests.cs @@ -0,0 +1,80 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using Aspire.Cli.Configuration; +using Aspire.Cli.Layout; +using Aspire.Cli.NuGet; +using Aspire.Cli.Projects; +using Aspire.Cli.Tests.Mcp; +using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Tests.Utils; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Cli.Tests.Configuration; + +/// +/// Behavioral guards on 's channel resolution: it +/// consults only per-project state (aspire.config.json) and returns +/// when no per-project channel is set. +/// +public class PrebuiltAppHostServerChannelResolutionTests(ITestOutputHelper outputHelper) +{ + [Fact] + public void PrebuiltAppHostServer_ResolveChannelName_ReturnsNullWhenNoAspireConfigJson() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var appHostDirectory = workspace.CreateDirectory("apphost"); + + var server = CreateServer(appHostDirectory.FullName); + + var resolveChannelName = typeof(PrebuiltAppHostServer) + .GetMethod("ResolveChannelName", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("ResolveChannelName not found on PrebuiltAppHostServer."); + + var resolved = resolveChannelName.Invoke(server, parameters: null); + + Assert.Null(resolved); + } + + [Fact] + public void PrebuiltAppHostServer_ResolveChannelName_HonorsAspireConfigJson() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var appHostDirectory = workspace.CreateDirectory("apphost"); + + var config = AspireConfigFile.LoadOrCreate(appHostDirectory.FullName); + config.Channel = "staging"; + config.Save(appHostDirectory.FullName); + + var server = CreateServer(appHostDirectory.FullName); + + var resolveChannelName = typeof(PrebuiltAppHostServer) + .GetMethod("ResolveChannelName", BindingFlags.Instance | BindingFlags.NonPublic)!; + + var resolved = (string?)resolveChannelName.Invoke(server, parameters: null); + + Assert.Equal("staging", resolved); + } + + private static PrebuiltAppHostServer CreateServer(string appPath) + { + var nugetService = new BundleNuGetService( + new NullLayoutDiscovery(), + new LayoutProcessRunner(new TestProcessExecutionFactory()), + new TestFeatures(), + TestExecutionContextFactory.CreateTestContext(), + NullLogger.Instance); + + return new PrebuiltAppHostServer( + appPath, + socketPath: "test.sock", + new LayoutConfiguration(), + nugetService, + new TestDotNetCliRunner(), + new TestDotNetSdkInstaller(), + MockPackagingServiceFactory.Create(), + TestExecutionContextFactory.CreateTestContext(), + NullLogger.Instance); + } +} diff --git a/tests/Aspire.Cli.Tests/Packaging/CliMetadataPackagingTests.cs b/tests/Aspire.Cli.Tests/Packaging/CliMetadataPackagingTests.cs new file mode 100644 index 00000000000..b7ed7f53020 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Packaging/CliMetadataPackagingTests.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; + +namespace Aspire.Cli.Tests.Packaging; + +public class CliMetadataPackagingTests +{ + [Fact] + public void Assembly_HasAspireCliChannelMetadata_WithNonEmptyValue() + { + var assembly = typeof(Aspire.Cli.Program).Assembly; + + var metadata = assembly + .GetCustomAttributes() + .FirstOrDefault(a => a.Key == "AspireCliChannel"); + + Assert.NotNull(metadata); + Assert.False(string.IsNullOrEmpty(metadata.Value), "AspireCliChannel assembly metadata must have a non-empty value."); + } +} diff --git a/tests/Aspire.Cli.Tests/Packaging/ClipackPropagationTests.cs b/tests/Aspire.Cli.Tests/Packaging/ClipackPropagationTests.cs new file mode 100644 index 00000000000..9796229fdbb --- /dev/null +++ b/tests/Aspire.Cli.Tests/Packaging/ClipackPropagationTests.cs @@ -0,0 +1,74 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Xml.Linq; + +namespace Aspire.Cli.Tests.Packaging; + +/// +/// Asserts that the clipack staging projitems explicitly forwards AspireCliChannel +/// to the inner Aspire.Cli publish through the MSBuild task's AdditionalProperties. +/// The downstream baked metadata is also covered by the +/// end-to-end check; +/// this test pins the projitems-level forwarding so a refactor that drops the +/// explicit forward fails here directly instead of surfacing as an obscure +/// channel mismatch in published bits. +/// +public class ClipackPropagationTests +{ + [Fact] + public void CommonProjitems_ForwardsAspireCliChannel_ThroughAdditionalProperties() + { + var projitemsPath = Path.Combine(GetRepoRoot(), "eng", "clipack", "Common.projitems"); + Assert.True(File.Exists(projitemsPath), $"Expected projitems file at {projitemsPath}"); + + var doc = XDocument.Load(projitemsPath); + + var forwardingItem = doc.Descendants() + .Where(e => e.Name.LocalName == "AdditionalProperties") + .FirstOrDefault(e => + string.Equals( + (string?)e.Attribute("Include"), + "AspireCliChannel=$(AspireCliChannel)", + StringComparison.Ordinal)); + + Assert.NotNull(forwardingItem); + + // The forwarding must be guarded so an unset channel does not pass through + // an empty AspireCliChannel= value to the inner publish. + var condition = (string?)forwardingItem.Attribute("Condition"); + Assert.False( + string.IsNullOrWhiteSpace(condition), + "AspireCliChannel forwarding must have a Condition that skips empty values."); + Assert.Contains("AspireCliChannel", condition!, StringComparison.Ordinal); + } + + [Fact] + public void CommonProjitems_PublishMSBuildTask_ConsumesAdditionalPropertiesItemGroup() + { + var projitemsPath = Path.Combine(GetRepoRoot(), "eng", "clipack", "Common.projitems"); + var doc = XDocument.Load(projitemsPath); + + var publishTask = doc.Descendants() + .Where(e => e.Name.LocalName == "MSBuild") + .FirstOrDefault(e => + string.Equals((string?)e.Attribute("Targets"), "Publish", StringComparison.Ordinal)); + + Assert.NotNull(publishTask); + + var properties = (string?)publishTask.Attribute("Properties"); + Assert.False(string.IsNullOrWhiteSpace(properties)); + Assert.Contains("@(AdditionalProperties)", properties!, StringComparison.Ordinal); + } + + private static string GetRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "global.json"))) + { + dir = dir.Parent; + } + Assert.NotNull(dir); + return dir.FullName; + } +} diff --git a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs index 40d8646179f..9515829f197 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs @@ -6,6 +6,7 @@ using Aspire.Cli.Packaging; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; +using Aspire.Cli.Utils; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using System.Xml.Linq; @@ -933,6 +934,233 @@ public async Task StagingChannel_WithoutPinnedVersion_ReturnsAllPrereleasePackag Assert.Contains(packageList, p => p.Version!.StartsWith("13.2")); } + /// + /// Verifies that hive channel names always match their directory name regardless of + /// the CLI identity channel. PackagingService no longer renames hive directories + /// in-memory; the script writes the hive as "local" directly. + /// + [Fact] + public async Task GetChannelsAsync_HiveChannelNameAlwaysMatchesDirectoryName() + { + // Arrange + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + + // Hive directory named "local" (as written by get-aspire-cli-pr.sh --local-dir) + const string localHiveName = "local"; + var localPackagesDir = new DirectoryInfo(Path.Combine(hivesDir.FullName, localHiveName, "packages")); + localPackagesDir.Create(); + + const string pinnedVersion = "13.4.0-pr.16820.g1a99aa46"; + File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.ProjectTemplates.{pinnedVersion}.nupkg"), string.Empty); + + // CLI binary built with AspireCliChannel=local + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, + new DirectoryInfo(Path.Combine(tempDir.FullName, "aspire-test-runtimes")), + new DirectoryInfo(Path.Combine(tempDir.FullName, "aspire-test-logs")), + "test.log", channel: "local"); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), new ConfigurationBuilder().Build(), NullLogger.Instance); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + // Assert: channel name == directory name (no in-memory rename) + var localChannel = channels.FirstOrDefault(c => c.Name == localHiveName); + Assert.NotNull(localChannel); + Assert.Equal(pinnedVersion, localChannel.PinnedVersion); + } + + /// + /// Verifies that when the CLI identity channel is NOT "local" (e.g. "daily"), + /// hive channels keep their directory-derived names. + /// + [Fact] + public async Task GetChannelsAsync_WhenIdentityChannelIsNotLocal_HiveKeepsDirectoryName() + { + // Arrange + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + + const string runHiveName = "run-99999"; + var runPackagesDir = new DirectoryInfo(Path.Combine(hivesDir.FullName, runHiveName, "packages")); + runPackagesDir.Create(); + + const string pinnedVersion = "13.4.0-pr.16820.g1a99aa46"; + File.WriteAllText(Path.Combine(runPackagesDir.FullName, $"Aspire.ProjectTemplates.{pinnedVersion}.nupkg"), string.Empty); + + // CLI binary built with non-local channel ("daily") β€” explicit so the test stays + // accurate even as the CliExecutionContext default channel evolves. + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), + "test.log", channel: "daily"); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), new ConfigurationBuilder().Build(), NullLogger.Instance); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + // Assert: the hive channel keeps its directory name + var hiveChannel = channels.FirstOrDefault(c => c.Name == runHiveName); + Assert.NotNull(hiveChannel); + } + + /// + /// 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.* + /// packages without calling dotnet package search (which does not support local folder sources). + /// + [Fact] + public async Task LocalHiveChannel_WithPinnedVersion_ReturnsIntegrationPackagesFromNupkgFiles() + { + // Arrange + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), + "test.log"); + + var localPackagesDir = new DirectoryInfo(Path.Combine(hivesDir.FullName, "local", "packages")); + localPackagesDir.Create(); + + const string localVersion = "13.4.0-pr.16820.g1a99aa46"; + // Hosting integration packages that should be returned + File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.Hosting.{localVersion}.nupkg"), string.Empty); + 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 + File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.ProjectTemplates.{localVersion}.nupkg"), string.Empty); + File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.AppHost.Sdk.{localVersion}.nupkg"), string.Empty); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), new ConfigurationBuilder().Build(), NullLogger.Instance); + + // Act + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + var localChannel = channels.First(c => c.Name == "local"); + var integrationPackages = await localChannel.GetIntegrationPackagesAsync(tempDir, CancellationToken.None).DefaultTimeout(); + + // Assert + var packageList = integrationPackages.ToList(); + Assert.Equal(3, 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.ProjectTemplates"); + Assert.DoesNotContain(packageList, p => p.Id == "Aspire.AppHost.Sdk"); + } + + [Fact] + public async Task GetChannelsAsync_LocalHive_AspireMappingPointsAtLocalDirectory_NotPublicFeed() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), + "test.log"); + + var localPackagesDir = new DirectoryInfo(Path.Combine(hivesDir.FullName, PackageChannelNames.Local, "packages")); + localPackagesDir.Create(); + File.WriteAllText(Path.Combine(localPackagesDir.FullName, "Aspire.Hosting.13.4.0-pr.16820.g1a99aa46.nupkg"), string.Empty); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), new ConfigurationBuilder().Build(), NullLogger.Instance); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + var localChannel = channels.First(c => c.Name == PackageChannelNames.Local); + + Assert.NotNull(localChannel.Mappings); + var aspireMapping = localChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == "Aspire*"); + Assert.NotNull(aspireMapping); + var expectedLocalPath = localPackagesDir.FullName.Replace('\\', '/'); + Assert.Equal(expectedLocalPath, aspireMapping.Source); + Assert.False(UrlHelper.IsHttpUrl(aspireMapping.Source), "Local hive Aspire* mapping must be a filesystem path, not an HTTP feed."); + + var fallbackMapping = localChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == PackageMapping.AllPackages); + Assert.NotNull(fallbackMapping); + Assert.Equal("https://api.nuget.org/v3/index.json", fallbackMapping.Source); + } + + [Fact] + public async Task GetChannelsAsync_LocalHive_EmptyDirectory_ReturnsChannelWithNullPinnedVersion() + { + // Partial-state shape: the user has `~/.aspire/hives/local/` on disk but no + // packages have been deposited yet (e.g., a partially-completed local build, or an + // install script that created the layout but failed before staging packages). + // Pinning behavior: + // - GetChannelsAsync still produces a `local` channel because GetDirectories() sees the dir. + // - GetLocalHivePinnedVersion returns null (no Aspire.ProjectTemplates / Aspire.Hosting / + // Aspire.AppHost.Sdk nupkgs to derive a version from), so the channel's PinnedVersion + // is null. Downstream, PackageChannel.GetIntegrationPackagesAsync only takes the + // direct-enumeration shortcut when PinnedVersion != null, so an empty local hive + // falls through to the standard NuGet search path with the local dir as a source. + // This test pins the channel-construction half of that contract. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), + "test.log"); + + // Create the hive root but no `packages/` subdir β€” directory-exists-but-unpopulated. + var localHiveDir = new DirectoryInfo(Path.Combine(hivesDir.FullName, PackageChannelNames.Local)); + localHiveDir.Create(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), new ConfigurationBuilder().Build(), NullLogger.Instance); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + var localChannel = channels.FirstOrDefault(c => c.Name == PackageChannelNames.Local); + Assert.NotNull(localChannel); + Assert.Null(localChannel.PinnedVersion); + Assert.NotNull(localChannel.Mappings); + var aspireMapping = localChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == "Aspire*"); + Assert.NotNull(aspireMapping); + // Mapping still points at the (non-existent) packages dir; PackageChannel guards on + // Directory.Exists before enumerating, so this is safe at downstream call time. + var expectedLocalPackagesPath = Path.Combine(localHiveDir.FullName, "packages").Replace('\\', '/'); + Assert.Equal(expectedLocalPackagesPath, aspireMapping.Source); + } + + [Fact] + public async Task GetChannelsAsync_LocalHive_EmptyPackagesDirectory_ReturnsChannelWithNullPinnedVersion() + { + // Partial-state variant: `~/.aspire/hives/local/packages/` exists but contains zero `*.nupkg` files. + // GetLocalHivePinnedVersion returns null because no FindHighestVersion lookup matches. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), + "test.log"); + + var localPackagesDir = new DirectoryInfo(Path.Combine(hivesDir.FullName, PackageChannelNames.Local, "packages")); + localPackagesDir.Create(); + + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), new ConfigurationBuilder().Build(), NullLogger.Instance); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + var localChannel = channels.FirstOrDefault(c => c.Name == PackageChannelNames.Local); + Assert.NotNull(localChannel); + Assert.Null(localChannel.PinnedVersion); + } + private sealed class FakeNuGetPackageCacheWithPackages(List packages) : INuGetPackageCache { public Task> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs index da432332fea..a89a407ed89 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerProjectTests.cs @@ -32,7 +32,6 @@ private DotNetBasedAppHostServerProject CreateProject(string? appPath = null) appPath ??= _workspace.WorkspaceRoot.FullName; var runner = new TestDotNetCliRunner(); var packagingService = MockPackagingServiceFactory.Create(); - var configurationService = new TestConfigurationService(); var logger = NullLogger.Instance; // Generate socket path same way as factory @@ -41,7 +40,7 @@ private DotNetBasedAppHostServerProject CreateProject(string? appPath = null) // Use workspace root as repo root for testing var repoRoot = _workspace.WorkspaceRoot.FullName; - return new DotNetBasedAppHostServerProject(appPath, socketPath, repoRoot, runner, packagingService, configurationService, logger); + return new DotNetBasedAppHostServerProject(appPath, socketPath, repoRoot, runner, packagingService, logger); } [Fact] @@ -269,13 +268,6 @@ await File.WriteAllTextAsync(aspireConfigPath, """ } """); - // Configure global config to return "pr-old" (the WRONG channel) - // This simulates a stale global config that hasn't been updated - var configurationService = new TestConfigurationService - { - OnGetConfiguration = key => key == "channel" ? "pr-old" : null - }; - // Create a packaging service that returns explicit channels for both PR hives var prOldHivePath = prOldHive.FullName; var prNewHivePath = prNewHive.FullName; @@ -315,7 +307,7 @@ await File.WriteAllTextAsync(aspireConfigPath, """ // Use a workspace-local ProjectModelPath for test isolation var projectModelPath = Path.Combine(appPath, ".aspire_server"); - var project = new DotNetBasedAppHostServerProject(appPath, "test.sock", appPath, runner, packagingService, configurationService, logger, projectModelPath); + var project = new DotNetBasedAppHostServerProject(appPath, "test.sock", appPath, runner, packagingService, logger, projectModelPath); var packages = new List { diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs index 5f8e9ccebf2..0408c982750 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs @@ -543,6 +543,15 @@ private static GuestAppHostProject CreateGuestAppHostProject() var logFilePath = Path.Combine(Path.GetTempPath(), $"test-guest-{Guid.NewGuid()}.log"); + var workspace = new DirectoryInfo(AppContext.BaseDirectory); + var executionContext = new CliExecutionContext( + workingDirectory: workspace, + hivesDirectory: workspace, + cacheDirectory: workspace, + sdksDirectory: workspace, + logsDirectory: workspace, + logFilePath: logFilePath); + return new GuestAppHostProject( language: language, interactionService: new TestInteractionService(), @@ -554,6 +563,7 @@ private static GuestAppHostProject CreateGuestAppHostProject() configuration: configuration, features: new Features(configuration, NullLogger.Instance), languageDiscovery: new TestLanguageDiscovery(), + executionContext: executionContext, logger: NullLogger.Instance, fileLoggerProvider: new FileLoggerProvider(logFilePath, new TestStartupErrorWriter())); } diff --git a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs index 4f2ba90fa0b..c3f44866dd3 100644 --- a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs @@ -5,6 +5,7 @@ using Aspire.Cli.Configuration; using Aspire.Cli.Layout; using Aspire.Cli.NuGet; +using Aspire.Cli.Packaging; using Aspire.Cli.Projects; using Aspire.Cli.Tests.Mcp; using Aspire.Cli.Tests.TestServices; @@ -165,7 +166,7 @@ public void Constructor_UsesWorkspaceAspireDirectoryForWorkingDirectory() new TestDotNetCliRunner(), new TestDotNetSdkInstaller(), Aspire.Cli.Tests.Mcp.MockPackagingServiceFactory.Create(), - new TestConfigurationService(), + Aspire.Cli.Tests.Mcp.TestExecutionContextFactory.CreateTestContext(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); var workingDirectory = Assert.IsType( @@ -215,7 +216,7 @@ public void Constructor_UsesDistinctWorkingDirectoriesForMultipleAppHostsInSameW new TestDotNetCliRunner(), new TestDotNetSdkInstaller(), Aspire.Cli.Tests.Mcp.MockPackagingServiceFactory.Create(), - new TestConfigurationService(), + Aspire.Cli.Tests.Mcp.TestExecutionContextFactory.CreateTestContext(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); var firstServer = CreateServer(firstAppHost.FullName); @@ -246,8 +247,142 @@ public void Constructor_UsesDistinctWorkingDirectoriesForMultipleAppHostsInSameW } } + // PSM-guard cross-product tests. + // Guard predicate: IdentityChannel == "local" + // β†’ fires only for locally-built CLIs; TryCreateTemporaryNuGetConfigAsync returns null. + // For every other identity channel (stable, staging, daily, pr) PSM must emit so restore + // honors the channel's package source mappings, even when channelName == IdentityChannel. + + [Fact] + public async Task TryCreateTemporaryNuGetConfig_LocalIdentityChannel_LocalChannelName_ReturnsNull() + { + // Locally-built CLI consuming its own local hive β€” only case the guard should fire. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var executionContext = CreateContextWithChannel("local"); + var server = CreateServerWithExplicitChannel(workspace, "local", executionContext); + + var result = await InvokeTryCreateTemporaryNuGetConfigAsync(server, "local"); + + Assert.Null(result); + } + + [Fact] + public async Task TryCreateTemporaryNuGetConfig_LocalIdentityChannel_PrChannelName_ReturnsNull() + { + // IdentityChannel == "local" β€” guard always fires regardless of the requested channel. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var executionContext = CreateContextWithChannel("local"); + var server = CreateServerWithExplicitChannel(workspace, "pr-12345", executionContext); + + var result = await InvokeTryCreateTemporaryNuGetConfigAsync(server, "pr-12345"); + + Assert.Null(result); + } + + [Fact] + public async Task TryCreateTemporaryNuGetConfig_StableIdentityChannel_AnyChannel_ReturnsConfig() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var executionContext = CreateContextWithChannel("stable"); + var server = CreateServerWithExplicitChannel(workspace, "local", executionContext); + + using var result = await InvokeTryCreateTemporaryNuGetConfigAsync(server, "local"); + + Assert.NotNull(result); + } + + [Fact] + public async Task TryCreateTemporaryNuGetConfig_DailyIdentityChannel_DailyChannel_ReturnsConfig() + { + // A 'daily' CLI consuming the 'daily' channel must still get a per-channel NuGet config. + // The local-hive guard fires only when both the identity channel AND the requested + // channel are 'local'; with identity=='daily' the guard must not trip. + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var executionContext = CreateContextWithChannel("daily"); + var server = CreateServerWithExplicitChannel(workspace, "daily", executionContext); + + using var result = await InvokeTryCreateTemporaryNuGetConfigAsync(server, "daily"); + + Assert.NotNull(result); + } + + [Fact] + public async Task TryCreateTemporaryNuGetConfig_PrIdentityChannel_PrChannelName_ReturnsConfig() + { + // PR-build CLI installing a different PR's hive β€” guard does not fire (identity != "local"). + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var executionContext = CreateContextWithChannel("pr", prNumber: 12345); + var server = CreateServerWithExplicitChannel(workspace, "pr-12345", executionContext); + + using var result = await InvokeTryCreateTemporaryNuGetConfigAsync(server, "pr-12345"); + + Assert.NotNull(result); + } + + private static CliExecutionContext CreateContextWithChannel(string channel, int? prNumber = null) => + new(new DirectoryInfo(Path.GetTempPath()), + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "hives")), + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "cache")), + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "sdks")), + new DirectoryInfo(Path.Combine(Path.GetTempPath(), "logs")), + "test.log", + channel: channel, + prNumber: prNumber); + + private static PrebuiltAppHostServer CreateServerWithExplicitChannel( + TemporaryWorkspace workspace, + string channelName, + CliExecutionContext executionContext) + { + var mappings = new[] + { + new PackageMapping(PackageMapping.AllPackages, "https://pkgs.dev.azure.com/fake/v3/index.json") + }; + var channel = PackageChannel.CreateExplicitChannel( + channelName, PackageChannelQuality.Both, mappings, new FakeNuGetPackageCache()); + var packagingService = new TestPackagingService + { + GetChannelsAsyncCallback = _ => Task.FromResult>([channel]) + }; + + var nugetService = new BundleNuGetService( + new NullLayoutDiscovery(), + new LayoutProcessRunner(new TestProcessExecutionFactory()), + new TestFeatures(), + executionContext, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + + return new PrebuiltAppHostServer( + workspace.WorkspaceRoot.FullName, + "test.sock", + new LayoutConfiguration(), + nugetService, + new TestDotNetCliRunner(), + new TestDotNetSdkInstaller(), + packagingService, + executionContext, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + } + + private static async Task InvokeTryCreateTemporaryNuGetConfigAsync( + PrebuiltAppHostServer server, string channelName) + { + var method = typeof(PrebuiltAppHostServer).GetMethod( + "TryCreateTemporaryNuGetConfigAsync", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.NotNull(method); + + var task = (Task)method.Invoke(server, [channelName, CancellationToken.None])!; + return await task; + } + [Fact] - public async Task ResolveChannelNameAsync_UsesProjectLocalAspireConfig_NotGlobalChannel() + public async Task ResolveChannelName_UsesProjectLocalAspireConfig() { using var workspace = TemporaryWorkspace.Create(outputHelper); @@ -258,11 +393,6 @@ await File.WriteAllTextAsync(aspireConfigPath, """ } """); - var configurationService = new TestConfigurationService - { - OnGetConfiguration = key => key == "channel" ? "pr-old" : null - }; - var nugetService = new BundleNuGetService(new NullLayoutDiscovery(), new LayoutProcessRunner(new TestProcessExecutionFactory()), new TestFeatures(), TestExecutionContextFactory.CreateTestContext(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); var server = new PrebuiltAppHostServer( workspace.WorkspaceRoot.FullName, @@ -272,14 +402,13 @@ await File.WriteAllTextAsync(aspireConfigPath, """ new TestDotNetCliRunner(), new TestDotNetSdkInstaller(), Aspire.Cli.Tests.Mcp.MockPackagingServiceFactory.Create(), - configurationService, + Aspire.Cli.Tests.Mcp.TestExecutionContextFactory.CreateTestContext(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - var method = typeof(PrebuiltAppHostServer).GetMethod("ResolveChannelNameAsync", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + var method = typeof(PrebuiltAppHostServer).GetMethod("ResolveChannelName", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); Assert.NotNull(method); - var channelTask = Assert.IsType>(method.Invoke(server, [CancellationToken.None])); - var channel = await channelTask; + var channel = (string?)method.Invoke(server, []); Assert.Equal("pr-new", channel); } diff --git a/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs b/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs new file mode 100644 index 00000000000..29e6d07787c --- /dev/null +++ b/tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs @@ -0,0 +1,263 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using Aspire.Cli.Configuration; +using Aspire.Cli.Projects; +using Aspire.Cli.Scaffolding; +using Aspire.Cli.Tests.TestServices; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Cli.Tests.Scaffolding; + +/// +/// Regression tests for project-channel reseed sites, ensuring that when saving the resolved +/// CLI channel to a project's aspire.config.json during scaffolding or initialization, the value +/// persisted is the one from (the resolved, consumer-facing +/// label: pr-<N> for PR builds, identity channel verbatim otherwise). +/// +/// Earlier tests inlined the production expression and round-tripped it through +/// . Those tests gave false confidence β€” they could pass against +/// a regression that replaced _executionContext.Channel with a literal string. The tests +/// here exercise the actual production codepath where possible, and otherwise lock the source-level +/// reference shape so the regression cannot land silently. +/// +/// +public class ChannelReseedTests +{ + // The following reseed call sites must write the resolved channel from CliExecutionContext.Channel: + // Behavioral coverage exists for ScaffoldingService below; the others are covered by source-level + // + structural reflection guards because they sit behind heavyweight DI (AppHostServerProjectFactory + // + RPC + project I/O) that this unit-test layer cannot reasonably stand up. + // + // ScaffoldingService.cs β€” line 75 (early-save) ← behavioral + // ScaffoldingService.cs β€” line 208 (post-prepare) + // Templating/CliTemplateFactory.PythonStarterTemplate β€” line 79 + // Templating/CliTemplateFactory.GoStarterTemplate β€” (parallel pattern) + // Projects/GuestAppHostProject.cs β€” lines 349, 1213 + + [Theory] + [InlineData("stable")] + [InlineData("staging")] + [InlineData("daily")] + [InlineData("pr-12345")] // option-(a) resolved label β€” what reseed sites must persist + public async Task ScaffoldAsync_NoExplicitChannel_PersistsCliExecutionContextChannel(string contextChannel) + { + var dir = Directory.CreateTempSubdirectory(); + try + { + var executionContext = CreateExecutionContext(contextChannel); + var scaffoldingService = CreateScaffoldingService(executionContext); + + var ctx = new ScaffoldContext( + Language: s_testLanguage, + TargetDirectory: dir, + ProjectName: "test", + SdkVersion: null, + Channel: null); + + // ScaffoldGuestLanguageAsync writes the early channel save to disk + // BEFORE the AppHostServerProject is created β€” so we capture the + // reseed even though IAppHostServerProjectFactory.CreateAsync throws. + await Assert.ThrowsAnyAsync( + async () => await scaffoldingService.ScaffoldAsync(ctx, CancellationToken.None)); + + var reloaded = AspireConfigFile.Load(dir.FullName); + Assert.NotNull(reloaded); + Assert.Equal(contextChannel, reloaded.Channel); + } + finally + { + dir.Delete(recursive: true); + } + } + + [Fact] + public async Task ScaffoldAsync_ExplicitChannel_OverridesCliExecutionContextChannel() + { + var dir = Directory.CreateTempSubdirectory(); + try + { + var executionContext = CreateExecutionContext(channel: "daily"); + var scaffoldingService = CreateScaffoldingService(executionContext); + + var ctx = new ScaffoldContext( + Language: s_testLanguage, + TargetDirectory: dir, + ProjectName: "test", + SdkVersion: null, + Channel: "explicit-staging"); + + await Assert.ThrowsAnyAsync( + async () => await scaffoldingService.ScaffoldAsync(ctx, CancellationToken.None)); + + var reloaded = AspireConfigFile.Load(dir.FullName); + Assert.NotNull(reloaded); + Assert.Equal("explicit-staging", reloaded.Channel); + } + finally + { + dir.Delete(recursive: true); + } + } + + // The Python starter template factory and GuestAppHostProject reseed sites are + // structurally identical to ScaffoldingService β€” but they sit behind much heavier + // DI surfaces (template tree extraction, project factory, codegen RPC). Behavior + // tests would require setting up most of the CLI host. Instead, we lock the + // source-level reference shape: each site MUST read from the + // CliExecutionContext field (not a literal). A future change that replaces the + // dynamic read with a hard-coded string will fail these tests. + + [Fact] + public void PythonStarterTemplate_ReseedSite_ReadsExecutionContextChannel() + { + var source = LoadSourceFile("src/Aspire.Cli/Templating/CliTemplateFactory.PythonStarterTemplate.cs"); + + // Production line: var seedChannel = !string.IsNullOrEmpty(inputs.Channel) + // ? inputs.Channel : _executionContext.Channel; + Assert.Contains("_executionContext.Channel", source); + Assert.Contains("inputs.Channel", source); + Assert.Contains("config.Channel = seedChannel", source); + } + + [Fact] + public void GoStarterTemplate_ReseedSite_ReadsExecutionContextChannel() + { + var source = LoadSourceFile("src/Aspire.Cli/Templating/CliTemplateFactory.GoStarterTemplate.cs"); + + Assert.Contains("_executionContext.Channel", source); + } + + [Fact] + public void GuestAppHostProject_ReseedSites_ReadExecutionContextChannel() + { + var source = LoadSourceFile("src/Aspire.Cli/Projects/GuestAppHostProject.cs"); + + // Two reseed call sites in this file (build-result fallback and + // PrepareAsync channel-record). Both MUST source from _executionContext.Channel. + var hits = CountOccurrences(source, "_executionContext.Channel"); + Assert.True(hits >= 2, $"Expected β‰₯2 references to _executionContext.Channel in GuestAppHostProject.cs, found {hits}."); + } + + [Fact] + public void ScaffoldingService_ReseedSites_ReadExecutionContextChannel() + { + var source = LoadSourceFile("src/Aspire.Cli/Scaffolding/ScaffoldingService.cs"); + + // Two reseed call sites: the early save and the post-prepare save. + var hits = CountOccurrences(source, "_cliExecutionContext.Channel"); + Assert.True(hits >= 2, $"Expected β‰₯2 references to _cliExecutionContext.Channel in ScaffoldingService.cs, found {hits}."); + } + + // Structural reflection guards: the constructor-injected dependency exists. + // Without these, all the source-level guards above could pass while the type + // had stopped accepting the dependency entirely. + + [Fact] + public void ScaffoldingService_HoldsCliExecutionContextDependency() + { + var field = typeof(ScaffoldingService) + .GetField("_cliExecutionContext", BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.NotNull(field); + Assert.Equal(typeof(CliExecutionContext), field.FieldType); + } + + [Fact] + public void GuestAppHostProject_HoldsCliExecutionContextDependency() + { + var field = typeof(GuestAppHostProject) + .GetField("_executionContext", BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.NotNull(field); + Assert.Equal(typeof(CliExecutionContext), field.FieldType); + } + + [Fact] + public void CliTemplateFactory_HoldsCliExecutionContextDependency() + { + var field = typeof(Aspire.Cli.Templating.CliTemplateFactory) + .GetField("_executionContext", BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.NotNull(field); + Assert.Equal(typeof(CliExecutionContext), field.FieldType); + } + + private static readonly LanguageInfo s_testLanguage = new( + LanguageId: new LanguageId(KnownLanguageId.TypeScript), + DisplayName: "TypeScript", + PackageName: string.Empty, + DetectionPatterns: ["apphost.ts"], + CodeGenerator: "TypeScript", + AppHostFileName: "apphost.ts"); + + private static CliExecutionContext CreateExecutionContext(string channel) + { + // For "pr-" we still call through the regular ctor with channel="pr" + prNumber + // so that CliExecutionContext.Channel resolves option-(a). For non-pr values the + // channel is passed verbatim. + if (channel.StartsWith("pr-", StringComparison.Ordinal) && + int.TryParse(channel.AsSpan(3), out var prNumber)) + { + return BuildContext(channel: "pr", prNumber: prNumber); + } + + return BuildContext(channel: channel, prNumber: null); + } + + private static CliExecutionContext BuildContext(string channel, int? prNumber) + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + return new CliExecutionContext( + workingDirectory: dir, + hivesDirectory: dir, + cacheDirectory: dir, + sdksDirectory: dir, + logsDirectory: dir, + logFilePath: "test.log", + channel: channel, + prNumber: prNumber); + } + + private static ScaffoldingService CreateScaffoldingService(CliExecutionContext executionContext) + { + return new ScaffoldingService( + appHostServerProjectFactory: new TestAppHostServerProjectFactory(), + languageDiscovery: new TestLanguageDiscovery(s_testLanguage), + interactionService: new TestInteractionService(), + cliExecutionContext: executionContext, + logger: NullLogger.Instance); + } + + private static string LoadSourceFile(string repoRelativePath) + { + var fullPath = Path.GetFullPath(Path.Combine(GetRepoRoot(), repoRelativePath)); + Assert.True(File.Exists(fullPath), $"Expected source file at {fullPath}"); + return File.ReadAllText(fullPath); + } + + private static string GetRepoRoot() + { + // Walk up from the test bin output until we find the repo root (global.json sits there). + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "global.json"))) + { + dir = dir.Parent; + } + Assert.NotNull(dir); + return dir.FullName; + } + + private static int CountOccurrences(string source, string needle) + { + var count = 0; + var idx = 0; + while ((idx = source.IndexOf(needle, idx, StringComparison.Ordinal)) >= 0) + { + count++; + idx += needle.Length; + } + return count; + } +} diff --git a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs index 689a914607f..e2b54c4fc2e 100644 --- a/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs +++ b/tests/Aspire.Cli.Tests/Templating/DotNetTemplateFactoryTests.cs @@ -7,7 +7,6 @@ using Aspire.Cli.Backchannel; using Aspire.Cli.Certificates; using Aspire.Cli.Commands; -using Aspire.Cli.Configuration; using Aspire.Cli.DotNet; using Aspire.Cli.Interaction; using Aspire.Cli.Packaging; @@ -352,10 +351,9 @@ private static DotNetTemplateFactory CreateTemplateFactory(TestFeatures features var cacheDirectory = new DirectoryInfo("/tmp/cache"); var executionContext = new CliExecutionContext(workingDirectory, hivesDirectory, cacheDirectory, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log"); sdkInstaller ??= new TestDotNetSdkInstaller(); - var configurationService = new FakeConfigurationService(); var telemetry = TestTelemetryHelper.CreateInitializedTelemetry(); var hostEnvironment = new FakeCliHostEnvironment(nonInteractive); - var templateNuGetConfigService = new TemplateNuGetConfigService(interactionService, executionContext, packagingService, configurationService, prompter, hostEnvironment); + var templateNuGetConfigService = new TemplateNuGetConfigService(interactionService, executionContext, packagingService, prompter, hostEnvironment); return new DotNetTemplateFactory( interactionService, @@ -370,49 +368,6 @@ private static DotNetTemplateFactory CreateTemplateFactory(TestFeatures features templateNuGetConfigService); } - private sealed class FakeConfigurationService : IConfigurationService - { - public Task SetConfigurationAsync(string key, string value, bool isGlobal = false, CancellationToken cancellationToken = default) - { - return Task.CompletedTask; - } - - public Task DeleteConfigurationAsync(string key, bool isGlobal = false, CancellationToken cancellationToken = default) - { - return Task.FromResult(false); - } - - public Task> GetAllConfigurationAsync(CancellationToken cancellationToken = default) - { - return Task.FromResult(new Dictionary()); - } - - public Task> GetLocalConfigurationAsync(CancellationToken cancellationToken = default) - { - return Task.FromResult(new Dictionary()); - } - - public Task> GetGlobalConfigurationAsync(CancellationToken cancellationToken = default) - { - return Task.FromResult(new Dictionary()); - } - - public Task GetConfigurationAsync(string key, CancellationToken cancellationToken = default) - { - return Task.FromResult(null); - } - - public Task GetConfigurationFromDirectoryAsync(string key, DirectoryInfo startDirectory, CancellationToken cancellationToken = default) - { - return Task.FromResult(null); - } - - public string GetSettingsFilePath(bool isGlobal) - { - return "/tmp/settings.json"; - } - } - private sealed class TestInteractionService : IInteractionService { public ConsoleOutput Console { get; set; } diff --git a/tests/Aspire.Cli.Tests/Templating/TemplateNuGetConfigServiceTests.cs b/tests/Aspire.Cli.Tests/Templating/TemplateNuGetConfigServiceTests.cs new file mode 100644 index 00000000000..0640fe8bf04 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Templating/TemplateNuGetConfigServiceTests.cs @@ -0,0 +1,185 @@ +// 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.Packaging; +using Aspire.Cli.Templating; +using Aspire.Cli.Tests.Mcp; +using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Utils; + +namespace Aspire.Cli.Tests.Templating; + +/// +/// Channel-resolution behavior for . +/// None of the channel-resolving entry points +/// (, +/// , +/// ) +/// may resolve a channel by reading from a global identity-channel source; channel input +/// must come from the caller-supplied argument or fall back to the implicit channel only. +/// +public class TemplateNuGetConfigServiceTests +{ + [Fact] + public async Task PromptToCreateOrUpdateNuGetConfigAsync_NullChannelName_ShortCircuits() + { + // Null/whitespace channelName must short-circuit without consulting any + // ambient channel source. No exception, no implicit-channel work requested. + var service = CreateService(); + + await service.PromptToCreateOrUpdateNuGetConfigAsync(channelName: null, outputPath: Directory.CreateTempSubdirectory().FullName, CancellationToken.None); + await service.PromptToCreateOrUpdateNuGetConfigAsync(channelName: "", outputPath: Directory.CreateTempSubdirectory().FullName, CancellationToken.None); + await service.PromptToCreateOrUpdateNuGetConfigAsync(channelName: " ", outputPath: Directory.CreateTempSubdirectory().FullName, CancellationToken.None); + } + + [Fact] + public async Task CreateOrUpdateNuGetConfigWithoutPromptAsync_NullChannelName_ShortCircuits() + { + var service = CreateService(); + + var dir = Directory.CreateTempSubdirectory(); + try + { + // Null/whitespace inputs must short-circuit and return false without + // resolving a channel from any ambient source. + Assert.False(await service.CreateOrUpdateNuGetConfigWithoutPromptAsync(channelName: null, outputPath: dir.FullName, CancellationToken.None)); + Assert.False(await service.CreateOrUpdateNuGetConfigWithoutPromptAsync(channelName: "", outputPath: dir.FullName, CancellationToken.None)); + Assert.False(await service.CreateOrUpdateNuGetConfigWithoutPromptAsync(channelName: " ", outputPath: dir.FullName, CancellationToken.None)); + } + finally + { + dir.Delete(recursive: true); + } + } + + [Fact] + public async Task ResolveTemplatePackageAsync_NullChannelOverride_UsesImplicitChannelOnly() + { + // No explicit ChannelOverride: the resolver picks the implicit channel only. + // We exercise the production codepath with a tracking packaging service so the + // assertion is that the resolver completes (no exception is thrown by + // an unexpected channel-lookup path) and only the implicit channel is in play. + var requestedChannels = new List(); + var packagingService = new TestPackagingService + { + GetChannelsAsyncCallback = _ => + { + var implicitCh = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache + { + GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult(Enumerable.Empty()) + }); + return Task.FromResult>([implicitCh]); + } + }; + + var service = CreateService(packagingService: packagingService); + + var query = new TemplatePackageQuery( + ChannelOverride: null, + VersionOverride: null, + SourceOverride: null, + IncludePrHives: false); + + // No packages were staged on the implicit channel, so the resolver throws + // EmptyChoicesException β€” that's the expected terminal state for "implicit was + // tried, nothing matched". The assertion is that this exception is the one that + // surfaces (not ChannelNotFoundException from a different lookup path). + await Assert.ThrowsAsync( + async () => await service.ResolveTemplatePackageAsync(query, CancellationToken.None)); + } + + [Fact] + public async Task ResolveTemplatePackageAsync_LocalChannelOverride_NoLocalHive_FallsBackToImplicitChannel() + { + // A locally-built CLI bakes channel="local" into assembly metadata. On a clean + // machine without ~/.aspire/hives/local, PackagingService produces no "local" + // channel, and InitCommand forwards CliExecutionContext.Channel ("local") as + // ChannelOverride. Without the resolver-level fallback this throws + // ChannelNotFoundException and `aspire init` is unusable on a clean machine. + // The fallback policy: a request for "local" with no matching channel resolves + // to the implicit channel (ambient NuGet config) β€” a CLI with no local hive is + // semantically just a CLI using ambient NuGet. + var packagingService = new TestPackagingService + { + GetChannelsAsyncCallback = _ => + { + var implicitCh = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache + { + GetTemplatePackagesAsyncCallback = (_, _, _, _) => Task.FromResult>( + [ + new Aspire.Shared.NuGetPackageCli { Id = TemplateNuGetConfigService.TemplatesPackageName, Version = "13.3.0", Source = "implicit" } + ]) + }); + return Task.FromResult>([implicitCh]); + } + }; + + var service = CreateService(packagingService: packagingService); + + var query = new TemplatePackageQuery( + ChannelOverride: PackageChannelNames.Local, + VersionOverride: null, + SourceOverride: null, + IncludePrHives: false); + + var selection = await service.ResolveTemplatePackageAsync(query, CancellationToken.None); + + Assert.Equal(PackageChannelType.Implicit, selection.Channel.Type); + } + + [Fact] + public async Task ResolveTemplatePackageAsync_NonExistentChannelOverride_NotLocal_StillThrowsChannelNotFound() + { + // The fallback is intentionally narrow: only "local" β†’ implicit. A request for + // any other unrecognized channel name must still fail loudly so typos surface + // (e.g., "stalbe" for "stable"). + var packagingService = new TestPackagingService + { + GetChannelsAsyncCallback = _ => + { + var implicitCh = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache()); + return Task.FromResult>([implicitCh]); + } + }; + + var service = CreateService(packagingService: packagingService); + + var query = new TemplatePackageQuery( + ChannelOverride: "stalbe", + VersionOverride: null, + SourceOverride: null, + IncludePrHives: false); + + await Assert.ThrowsAsync( + async () => await service.ResolveTemplatePackageAsync(query, CancellationToken.None)); + } + + private static TemplateNuGetConfigService CreateService( + TestPackagingService? packagingService = null) + { + return new TemplateNuGetConfigService( + new TestInteractionService(), + TestExecutionContextFactory.CreateTestContext(), + packagingService ?? MockPackagingServiceFactory.Create(), + new StubTemplateVersionPrompter(), + new StubCliHostEnvironment()); + } + + private sealed class StubTemplateVersionPrompter : Aspire.Cli.Commands.ITemplateVersionPrompter + { + public Task<(Aspire.Shared.NuGetPackageCli Package, PackageChannel Channel)> PromptForTemplatesVersionAsync( + IEnumerable<(Aspire.Shared.NuGetPackageCli Package, PackageChannel Channel)> candidatePackages, + CancellationToken cancellationToken) + { + throw new InvalidOperationException( + "TemplateNuGetConfigService unexpectedly entered the version-prompt path; this stub is wired in tests where the prompt should never be reached."); + } + } + + private sealed class StubCliHostEnvironment : ICliHostEnvironment + { + public bool SupportsInteractiveInput => false; + public bool SupportsInteractiveOutput => false; + public bool SupportsAnsi => false; + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 99a14cb117f..71a46bf6d21 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -449,8 +449,9 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser var appHostServerProjectFactory = serviceProvider.GetRequiredService(); var languageDiscovery = serviceProvider.GetRequiredService(); var interactionService = serviceProvider.GetRequiredService(); + var cliExecutionContext = serviceProvider.GetRequiredService(); var logger = serviceProvider.GetRequiredService>(); - return new ScaffoldingService(appHostServerProjectFactory, languageDiscovery, interactionService, logger); + return new ScaffoldingService(appHostServerProjectFactory, languageDiscovery, interactionService, cliExecutionContext, logger); }; public Func DotNetCliExecutionFactoryFactory { get; set; } = (IServiceProvider serviceProvider) => diff --git a/tests/Aspire.Cli.Tests/Utils/VersionHelperTests.cs b/tests/Aspire.Cli.Tests/Utils/VersionHelperTests.cs index 0adb81c53d4..ef217314d55 100644 --- a/tests/Aspire.Cli.Tests/Utils/VersionHelperTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/VersionHelperTests.cs @@ -27,4 +27,18 @@ public void TryGetCurrentCliVersionMatch_WithPrHivesAndNoChannel_ReturnsCurrentC Assert.True(result); Assert.Equal(cliVersion, match); } + + [Theory] + [InlineData("pr-16820", true)] + [InlineData("run-25422767716", true)] + [InlineData("local", true)] + [InlineData("LOCAL", true)] + [InlineData("stable", false)] + [InlineData("daily", false)] + [InlineData("staging", false)] + [InlineData(null, false)] + public void IsLocalBuildChannel_RecognizesAllLocalChannelForms(string? channelName, bool expected) + { + Assert.Equal(expected, VersionHelper.IsLocalBuildChannel(channelName)); + } }