diff --git a/docs/dogfooding-pull-requests.md b/docs/dogfooding-pull-requests.md index 8ef28152def..febabdac85a 100644 --- a/docs/dogfooding-pull-requests.md +++ b/docs/dogfooding-pull-requests.md @@ -28,14 +28,14 @@ Notes: ## What gets installed - Aspire CLI: - - Default location: `~/.aspire/bin/aspire` (or `aspire.exe` on Windows) - - Important: If you already have the Aspire CLI installed under the same prefix (default `~/.aspire`), running this script will overwrite that installation. To switch back to the official build, simply re-run the standard Aspire CLI install script referenced in the README to reinstall the released version. + - Default location: `~/.aspire/dogfood/pr-/aspire` (or `aspire.exe` on Windows) + - Important: PR installs are self-contained under `~/.aspire/dogfood/pr-/` and do not overwrite the stable CLI at `~/.aspire/bin/aspire`. Each PR has its own isolated install root. - PR-scoped NuGet packages "hive": - - Default location: `~/.aspire/hives/pr-/packages` + - Default location: `~/.aspire/dogfood/pr-/hives/pr-/packages` - This local, PR-specific hive is isolated, making it easy to create new projects with just the packages produced by the PR build without affecting your global NuGet caches or other projects. -The scripts attempt to add `~/.aspire/bin` to your shell/profile PATH so you can invoke `aspire` directly in new terminals. If PATH isn't updated automatically, add it manually per the script's message. +The scripts attempt to add `~/.aspire/dogfood/pr-` to your shell/profile PATH so you can invoke `aspire` directly in new terminals. If PATH isn't updated automatically, add it manually per the script's message. ## Quickstart @@ -166,15 +166,17 @@ The scripts auto-detect your OS and architecture and locate the latest `ci.yml` ## Uninstall/Cleanup -- Remove the CLI: - - Delete `~/.aspire/bin/aspire` (or the custom install path you used) +- Remove a PR dogfood install (CLI, bundle, hives — everything): + - Delete the entire install root: `rm -rf ~/.aspire/dogfood/pr-` (or the custom install path you used) - Remove the PATH entry from your shell profile if added -- Remove PR-specific packages: - - Delete `~/.aspire/hives/pr-/packages` +- Remove all dogfood installs: + - `rm -rf ~/.aspire/dogfood` + +- The stable CLI at `~/.aspire/bin/aspire` is not affected by dogfood installs. ## Safety note Remote one-liners execute scripts fetched from the repository. Review the script source before running if needed: - Bash: `eng/scripts/get-aspire-cli-pr.sh` -- PowerShell: `eng/scripts/get-aspire-cli-pr.ps1` \ No newline at end of file +- PowerShell: `eng/scripts/get-aspire-cli-pr.ps1` diff --git a/eng/scripts/get-aspire-cli-pr.ps1 b/eng/scripts/get-aspire-cli-pr.ps1 index 98ed830b042..028e28445d2 100755 --- a/eng/scripts/get-aspire-cli-pr.ps1 +++ b/eng/scripts/get-aspire-cli-pr.ps1 @@ -21,8 +21,8 @@ .PARAMETER InstallPath Directory prefix to install (default: $HOME/.aspire on Unix, %USERPROFILE%\.aspire on Windows) - CLI will be installed to InstallPath\bin (or InstallPath/bin on Unix) - NuGet packages will be installed to InstallPath\hives\pr-PRNUMBER\packages + CLI will be installed to InstallPath\dogfood\pr-PRNUMBER (or InstallPath/dogfood/pr-PRNUMBER on Unix) + NuGet packages will be installed to InstallPath\dogfood\pr-PRNUMBER\hives\pr-PRNUMBER\packages .PARAMETER OS Override OS detection (win, linux, linux-musl, osx) @@ -392,11 +392,11 @@ function Backup-ExistingCliExecutable { [Parameter(Mandatory = $true)] [string]$TargetExePath ) - + if (Test-Path $TargetExePath) { $unixTimestamp = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() $backupPath = "$TargetExePath.old.$unixTimestamp" - + if ($PSCmdlet.ShouldProcess($TargetExePath, "Backup to $backupPath")) { Write-Message "Backing up existing CLI: $TargetExePath -> $backupPath" -Level Verbose @@ -412,7 +412,7 @@ function Backup-ExistingCliExecutable { return $backupPath } } - + return $null } @@ -422,18 +422,18 @@ function Restore-CliExecutableFromBackup { param( [Parameter(Mandatory = $true)] [string]$BackupPath, - + [Parameter(Mandatory = $true)] [string]$TargetExePath ) - + if ($PSCmdlet.ShouldProcess($BackupPath, "Restore to $TargetExePath")) { Write-Message "Restoring CLI from backup: $BackupPath -> $TargetExePath" -Level Warning - + if (Test-Path $TargetExePath) { Remove-Item -Path $TargetExePath -Force -ErrorAction SilentlyContinue } - + Move-Item -Path $BackupPath -Destination $TargetExePath -Force -ErrorAction Stop } } @@ -445,15 +445,15 @@ function Remove-OldCliBackupFiles { [Parameter(Mandatory = $true)] [string]$TargetExePath ) - + $directory = Split-Path -Parent $TargetExePath if ([string]::IsNullOrEmpty($directory)) { return } - + $exeName = Split-Path -Leaf $TargetExePath $searchPattern = "$exeName.old.*" - + $oldBackupFiles = Get-ChildItem -Path $directory -Filter $searchPattern -ErrorAction SilentlyContinue foreach ($backupFile in $oldBackupFiles) { if ($PSCmdlet.ShouldProcess($backupFile.FullName, "Delete old backup")) { @@ -720,37 +720,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()] @@ -874,32 +843,32 @@ function Get-VersionSuffixFromPackages { [Parameter(Mandatory = $true)] [string]$DownloadDir ) - + if ($PSCmdlet.ShouldProcess("packages", "Extract version suffix from packages") -and $WhatIfPreference) { # Return a mock version for WhatIf return "pr.1234.a1b2c3d4" } - + # Look for any .nupkg file and extract version from its name $nupkgFiles = Get-ChildItem -Path $DownloadDir -Filter "*.nupkg" -Recurse | Select-Object -First 1 - + if (-not $nupkgFiles) { Write-Message "No .nupkg files found to extract version from" -Level Verbose throw "No NuGet packages found to extract version information from" } - + $filename = $nupkgFiles.Name Write-Message "Extracting version from package: $filename" -Level Verbose - + # Extract version from package name using a more robust approach # Remove .nupkg extension first, then look for the specific version pattern $baseName = $filename -replace '\.nupkg$', '' - + # Look for semantic version pattern with PR suffix (more specific and robust) if ($baseName -match '.*\.(\d+\.\d+\.\d+-pr\.\d+\.[0-9a-g]+)$') { $version = $Matches[1] Write-Message "Extracted version: $version" -Level Verbose - + # Extract just the PR suffix part using more specific regex if ($version -match '(pr\.[0-9]+\.[0-9a-g]+)') { $versionSuffix = $Matches[1] @@ -1210,9 +1179,9 @@ function Start-DownloadAndInstall { Write-Message "Using workflow run https://github.com/$Script:Repository/actions/runs/$runId" -Level Info - # Set installation paths - $cliBinDir = Join-Path $resolvedInstallPrefix "bin" - $nugetHiveDir = Join-Path $resolvedInstallPrefix "hives" "pr-$PRNumber" "packages" + # Set installation paths (self-contained dogfood layout) + $cliBinDir = Join-Path $resolvedInstallPrefix "dogfood" "pr-$PRNumber" + $nugetHiveDir = Join-Path $resolvedInstallPrefix "dogfood" "pr-$PRNumber" "hives" "pr-$PRNumber" "packages" $rid = Get-RuntimeIdentifier $OS $Architecture @@ -1257,14 +1226,8 @@ 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 "pr-$PRNumber" - } + # Dogfood installs no longer set the global channel. + # The self-contained install discovers its own hives relative to its install root. # Update PATH environment variables if (-not $HiveOnly) { diff --git a/eng/scripts/get-aspire-cli-pr.sh b/eng/scripts/get-aspire-cli-pr.sh index 3500a6ddcd4..85c0fb97def 100755 --- a/eng/scripts/get-aspire-cli-pr.sh +++ b/eng/scripts/get-aspire-cli-pr.sh @@ -61,8 +61,8 @@ USAGE: PR_NUMBER Pull request number (required) --run-id, -r WORKFLOW_ID Workflow run ID to download from (optional) -i, --install-path PATH Directory prefix to install (default: ~/.aspire) - CLI installs to: /bin - NuGet hive: /hives/pr-/packages + CLI installs to: /dogfood/pr- + NuGet hive: /dogfood/pr-/hives/pr-/packages --os OS Override OS detection (win, linux, linux-musl, osx) --arch ARCH Override architecture detection (x64, arm64) --hive-only Only install NuGet packages to the hive, skip CLI download @@ -408,36 +408,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 @@ -631,41 +601,41 @@ get_pr_head_sha() { # Function to extract version suffix from downloaded NuGet packages extract_version_suffix_from_packages() { local download_dir="$1" - + if [[ "$DRY_RUN" == true ]]; then # Return a mock version for dry run printf "pr.1234.a1b2c3d4" return 0 fi - + # Look for any .nupkg file and extract version from its name local nupkg_file nupkg_file=$(find "$download_dir" -name "*.nupkg" | head -1) - + if [[ -z "$nupkg_file" ]]; then say_verbose "No .nupkg files found to extract version from" return 1 fi - + local filename filename=$(basename "$nupkg_file") say_verbose "Extracting version from package: $filename" - + # Extract version from package name using a more robust two-step approach # First remove the .nupkg extension, then extract the version part local base_name="${filename%.nupkg}" local version - + # Look for semantic version pattern with PR suffix (more specific and robust) version=$(echo "$base_name" | sed -En 's/.*\.([0-9]+\.[0-9]+\.[0-9]+-pr\.[0-9]+\.[a-g0-9]+)/\1/p') - + if [[ -z "$version" ]]; then say_verbose "Could not extract version from package name: $filename" return 1 fi - + say_verbose "Extracted full version: $version" - + # Extract just the PR suffix part using bash regex for better compatibility if [[ "$version" =~ (pr\.[0-9]+\.[a-g0-9]+) ]]; then local version_suffix="${BASH_REMATCH[1]}" @@ -972,9 +942,9 @@ download_and_install_from_pr() { say_info "Using workflow run https://github.com/${REPO}/actions/runs/$workflow_run_id" - # Set installation paths - local cli_install_dir="$INSTALL_PREFIX/bin" - local nuget_hive_dir="$INSTALL_PREFIX/hives/pr-$PR_NUMBER/packages" + # Set installation paths (self-contained dogfood layout) + local cli_install_dir="$INSTALL_PREFIX/dogfood/pr-$PR_NUMBER" + local nuget_hive_dir="$INSTALL_PREFIX/dogfood/pr-$PR_NUMBER/hives/pr-$PR_NUMBER/packages" # First, download both artifacts local cli_archive_path nuget_download_dir @@ -1039,19 +1009,8 @@ download_and_install_from_pr() { 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" "pr-$PR_NUMBER" || true - fi + # Dogfood installs no longer set the global channel. + # The self-contained install discovers its own hives relative to its install root. } # ============================================================================= @@ -1084,9 +1043,9 @@ else INSTALL_PREFIX_UNEXPANDED="$INSTALL_PREFIX" fi -# Set paths based on install prefix -cli_install_dir="$INSTALL_PREFIX/bin" -INSTALL_PATH_UNEXPANDED="$INSTALL_PREFIX_UNEXPANDED/bin" +# Set paths based on install prefix (self-contained dogfood layout) +cli_install_dir="$INSTALL_PREFIX/dogfood/pr-$PR_NUMBER" +INSTALL_PATH_UNEXPANDED="$INSTALL_PREFIX_UNEXPANDED/dogfood/pr-$PR_NUMBER" # Create a temporary directory for downloads if [[ "$DRY_RUN" == true ]]; then diff --git a/localhive.ps1 b/localhive.ps1 index b5f9693280a..97d35a6a5a7 100644 --- a/localhive.ps1 +++ b/localhive.ps1 @@ -206,7 +206,7 @@ if (-not $packages -or $packages.Count -eq 0) { } Write-Log ("Found {0} packages in {1}" -f $packages.Count, $pkgDir) -$hivesRoot = Join-Path (Join-Path $HOME '.aspire') 'hives' +$hivesRoot = Join-Path (Join-Path (Join-Path (Join-Path $HOME '.aspire') 'dogfood') $Name) 'hives' $hiveRoot = Join-Path $hivesRoot $Name $hivePath = Join-Path $hiveRoot 'packages' @@ -262,7 +262,8 @@ if ($IsWindows) { } $aspireRoot = Join-Path $HOME '.aspire' -$cliBinDir = Join-Path $aspireRoot 'bin' +$dogfoodRoot = Join-Path $aspireRoot 'dogfood' $Name +$cliBinDir = $dogfoodRoot # Build the bundle (aspire-managed + DCP, and optionally native AOT CLI) if (-not $SkipBundle) { @@ -287,10 +288,10 @@ if (-not $SkipBundle) { exit 1 } - # Copy managed/ and dcp/ to $HOME/.aspire so the CLI auto-discovers them + # Copy managed/ and dcp/ to dogfood install root so the CLI auto-discovers them foreach ($component in @('managed', 'dcp')) { $sourceDir = Join-Path $bundleLayoutDir $component - $destDir = Join-Path $aspireRoot $component + $destDir = Join-Path $dogfoodRoot $component if (Test-Path -LiteralPath $sourceDir) { if (Test-Path -LiteralPath $destDir) { Remove-Item -LiteralPath $destDir -Force -Recurse @@ -302,10 +303,10 @@ if (-not $SkipBundle) { } } - Write-Log "Bundle installed to $aspireRoot (managed/ + dcp/)" + Write-Log "Bundle installed to $dogfoodRoot (managed/ + dcp/)" } -# Install the CLI to $HOME/.aspire/bin +# Install the CLI to the dogfood root if (-not $SkipCli) { $cliExeName = if ($IsWindows) { 'aspire.exe' } else { 'aspire' } @@ -377,9 +378,8 @@ if (-not $SkipCli) { $installedCliPath = Join-Path $cliBinDir $cliExeName Write-Log "Aspire CLI installed to: $installedCliPath" - # Set the channel to the local hive so templates and packages resolve from it - & $installedCliPath config set channel $Name -g 2>$null - Write-Log "Set global channel to '$Name'" + # Dogfood installs no longer set the global channel. + # The self-contained install discovers its own hives relative to its install root. # Check if the bin directory is in PATH $pathSeparator = [System.IO.Path]::PathSeparator @@ -404,12 +404,12 @@ Write-Host Write-Log "Channel behavior: Aspire* comes from the hive; others from nuget.org." Write-Host if (-not $SkipCli) { - Write-Log "The locally-built CLI was installed to: $(Join-Path (Join-Path $HOME '.aspire') 'bin')" + Write-Log "The locally-built CLI was installed to: $dogfoodRoot" Write-Host } if (-not $SkipBundle) { - Write-Log "Bundle (aspire-managed + DCP) installed to: $(Join-Path $HOME '.aspire')" - Write-Log " The CLI at ~/.aspire/bin/ will auto-discover managed/ and dcp/ in the parent directory." + Write-Log "Bundle (aspire-managed + DCP) installed to: $dogfoodRoot" + Write-Log " The CLI at $dogfoodRoot/ will auto-discover managed/ and dcp/ in the same directory." Write-Host } Write-Log 'The Aspire CLI discovers channels automatically from the hives directory; no extra flags are required.' diff --git a/localhive.sh b/localhive.sh index 859865339ac..cfb4d2d4815 100755 --- a/localhive.sh +++ b/localhive.sh @@ -192,7 +192,7 @@ if [[ $pkg_count -eq 0 ]]; then fi log "Found $pkg_count packages in $PKG_DIR" -HIVES_ROOT="$HOME/.aspire/hives" +HIVES_ROOT="$HOME/.aspire/dogfood/$HIVE_NAME/hives" HIVE_ROOT="$HIVES_ROOT/$HIVE_NAME" HIVE_PATH="$HIVE_ROOT/packages" @@ -238,7 +238,8 @@ case "$(uname -s)" in esac ASPIRE_ROOT="$HOME/.aspire" -CLI_BIN_DIR="$ASPIRE_ROOT/bin" +DOGFOOD_ROOT="$ASPIRE_ROOT/dogfood/$HIVE_NAME" +CLI_BIN_DIR="$DOGFOOD_ROOT" # Build the bundle (aspire-managed + DCP, and optionally native AOT CLI) if [[ $SKIP_BUNDLE -eq 0 ]]; then @@ -263,10 +264,10 @@ if [[ $SKIP_BUNDLE -eq 0 ]]; then exit 1 fi - # Copy managed/ and dcp/ to $HOME/.aspire so the CLI auto-discovers them + # Copy managed/ and dcp/ to dogfood install root so the CLI auto-discovers them for component in managed dcp; do SOURCE_DIR="$BUNDLE_LAYOUT_DIR/$component" - DEST_DIR="$ASPIRE_ROOT/$component" + DEST_DIR="$DOGFOOD_ROOT/$component" if [[ -d "$SOURCE_DIR" ]]; then rm -rf "$DEST_DIR" log "Copying $component/ to $DEST_DIR" @@ -282,10 +283,10 @@ if [[ $SKIP_BUNDLE -eq 0 ]]; then fi done - log "Bundle installed to $ASPIRE_ROOT (managed/ + dcp/)" + log "Bundle installed to $DOGFOOD_ROOT (managed/ + dcp/)" fi -# Install the CLI to $HOME/.aspire/bin +# Install the CLI to the dogfood root if [[ $SKIP_CLI -eq 0 ]]; then if [[ $NATIVE_AOT -eq 1 ]]; then # Native AOT CLI from Bundle.proj publish @@ -319,11 +320,8 @@ if [[ $SKIP_CLI -eq 0 ]]; then log "Aspire CLI installed to: $CLI_BIN_DIR/aspire" - if "$CLI_BIN_DIR/aspire" config set channel "$HIVE_NAME" -g >/dev/null 2>&1; then - log "Set global channel to '$HIVE_NAME'" - else - warn "Failed to set global channel to '$HIVE_NAME'. Run: aspire config set channel '$HIVE_NAME' -g" - fi + # Dogfood installs no longer set the global channel. + # The self-contained install discovers its own hives relative to its install root. # Check if the bin directory is in PATH if [[ ":$PATH:" != *":$CLI_BIN_DIR:"* ]]; then @@ -345,12 +343,12 @@ echo log "Channel behavior: Aspire* comes from the hive; others from nuget.org." echo if [[ $SKIP_CLI -eq 0 ]]; then - log "The locally-built CLI was installed to: $HOME/.aspire/bin" + log "The locally-built CLI was installed to: $DOGFOOD_ROOT" echo fi if [[ $SKIP_BUNDLE -eq 0 ]]; then - log "Bundle (aspire-managed + DCP) installed to: $HOME/.aspire" - log " The CLI at ~/.aspire/bin/ will auto-discover managed/ and dcp/ in the parent directory." + log "Bundle (aspire-managed + DCP) installed to: $DOGFOOD_ROOT" + log " The CLI at $DOGFOOD_ROOT/ will auto-discover managed/ and dcp/ in the same directory." echo fi log "The Aspire CLI discovers channels automatically from the hives directory; no extra flags are required." diff --git a/src/Aspire.Cli/Bundles/BundleService.cs b/src/Aspire.Cli/Bundles/BundleService.cs index c06ff55b5b1..72471bcd5d0 100644 --- a/src/Aspire.Cli/Bundles/BundleService.cs +++ b/src/Aspire.Cli/Bundles/BundleService.cs @@ -156,8 +156,8 @@ private async Task ExtractCoreAsync(string destinationPath, /// /// Determines the default extraction directory for the current CLI binary. - /// If CLI is at ~/.aspire/bin/aspire, returns ~/.aspire/ so layout discovery - /// finds components via the bin/ layout pattern. + /// For stable layout (CLI in bin/), returns the parent directory. + /// For flat/dogfood layout (CLI directly in dir), returns that directory. /// internal static string? GetDefaultExtractDir(string processPath) { @@ -167,7 +167,21 @@ private async Task ExtractCoreAsync(string destinationPath, return null; } - return Path.GetDirectoryName(cliDir) ?? cliDir; + var fileName = Path.GetFileName(processPath); + if (!string.Equals(fileName, "aspire", StringComparison.OrdinalIgnoreCase) && + !string.Equals(fileName, "aspire.exe", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var cliDirectoryInfo = new DirectoryInfo(cliDir); + if (string.Equals(cliDirectoryInfo.Name, "bin", StringComparison.OrdinalIgnoreCase) && + cliDirectoryInfo.Parent is not null) + { + return cliDirectoryInfo.Parent.FullName; + } + + return cliDirectoryInfo.FullName; } /// diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 2bcdfbf53e6..db2c044a487 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -512,10 +512,46 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu } private static DirectoryInfo GetHivesDirectory() + { + var installRoot = GetInstallRootDirectory(); + return new DirectoryInfo(Path.Combine(installRoot.FullName, "hives")); + } + + internal static DirectoryInfo GetInstallRootDirectory() + { + return GetInstallRootDirectory(Environment.ProcessPath); + } + + internal static DirectoryInfo GetInstallRootDirectory(string? processPath) { var homeDirectory = GetUsersAspirePath(); - var hivesDirectory = Path.Combine(homeDirectory, "hives"); - return new DirectoryInfo(hivesDirectory); + + if (string.IsNullOrEmpty(processPath)) + { + return new DirectoryInfo(homeDirectory); + } + + var fileName = Path.GetFileName(processPath); + if (!string.Equals(fileName, "aspire", StringComparison.OrdinalIgnoreCase) && + !string.Equals(fileName, "aspire.exe", StringComparison.OrdinalIgnoreCase)) + { + return new DirectoryInfo(homeDirectory); + } + + var cliDir = Path.GetDirectoryName(processPath); + if (string.IsNullOrEmpty(cliDir)) + { + return new DirectoryInfo(homeDirectory); + } + + var cliDirectoryInfo = new DirectoryInfo(cliDir); + if (string.Equals(cliDirectoryInfo.Name, "bin", StringComparison.OrdinalIgnoreCase) && + cliDirectoryInfo.Parent is not null) + { + return cliDirectoryInfo.Parent; + } + + return cliDirectoryInfo; } private static DirectoryInfo GetSdksDirectory() diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs index 57f388cf504..56f98324c65 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs @@ -87,7 +87,8 @@ internal static async Task InstallAspireCliInDockerAsync( await auto.TypeAsync($"/opt/aspire-scripts/get-aspire-cli-pr.sh {prNumber}"); await auto.EnterAsync(); await auto.WaitForSuccessPromptFailFastAsync(counter, TimeSpan.FromSeconds(300)); - await auto.TypeAsync("export PATH=~/.aspire/bin:~/.aspire:$PATH"); + // Self-contained dogfood installs go to ~/.aspire/dogfood/pr- + await auto.TypeAsync($"export PATH=~/.aspire/dogfood/pr-{prNumber}:~/.aspire/bin:~/.aspire:$PATH"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); break; @@ -200,12 +201,15 @@ internal static async Task InstallAspireBundleFromPullRequestAsync( /// /// Configures the PATH and environment variables for the Aspire CLI bundle in a non-Docker environment. /// Unlike , this includes ~/.aspire in PATH for bundle tools. + /// When is provided, the self-contained dogfood path is also added. /// internal static async Task SourceAspireBundleEnvironmentAsync( this Hex1bTerminalAutomator auto, - SequenceCounter counter) + SequenceCounter counter, + int? prNumber = null) { - await auto.TypeAsync("export PATH=~/.aspire/bin:~/.aspire:$PATH ASPIRE_PLAYGROUND=true TERM=xterm DOTNET_CLI_TELEMETRY_OPTOUT=true DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true DOTNET_GENERATE_ASPNET_CERTIFICATE=false"); + var dogfoodPrefix = prNumber.HasValue ? $"~/.aspire/dogfood/pr-{prNumber}:" : ""; + await auto.TypeAsync($"export PATH={dogfoodPrefix}~/.aspire/bin:~/.aspire:$PATH ASPIRE_PLAYGROUND=true TERM=xterm DOTNET_CLI_TELEMETRY_OPTOUT=true DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true DOTNET_GENERATE_ASPNET_CERTIFICATE=false"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JavaCodegenValidationTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JavaCodegenValidationTests.cs index 033ec31a64d..6ea569d6fea 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/JavaCodegenValidationTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/JavaCodegenValidationTests.cs @@ -42,15 +42,8 @@ public async Task RestoreGeneratesSdkFiles() await auto.WaitUntilTextAsync("Created AppHost.java", timeout: TimeSpan.FromMinutes(2)); await auto.DeclineAgentInitPromptAsync(counter); - await auto.TypeAsync("aspire add Aspire.Hosting.Redis"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("aspire add Aspire.Hosting.SqlServer"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter); + await auto.AspireAddAsync("Aspire.Hosting.Redis", counter); + await auto.AspireAddAsync("Aspire.Hosting.SqlServer", counter); await auto.TypeAsync("aspire restore"); await auto.EnterAsync(); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JavaPolyglotTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JavaPolyglotTests.cs index 6742deae355..41463ff0c81 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/JavaPolyglotTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/JavaPolyglotTests.cs @@ -50,10 +50,7 @@ public async Task CreateJavaAppHostWithViteApp() await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); - await auto.TypeAsync("aspire add Aspire.Hosting.JavaScript"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter); + await auto.AspireAddAsync("Aspire.Hosting.JavaScript", counter); var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.java"); var newContent = """ diff --git a/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs index 194e81090da..5164c3baa2c 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/JavaScriptPublishTests.cs @@ -53,13 +53,8 @@ public async Task AllPublishMethodsBuildDockerImages() await auto.WaitUntilTextAsync("Created apphost.ts", timeout: TimeSpan.FromMinutes(2)); await auto.DeclineAgentInitPromptAsync(counter); - await auto.TypeAsync("aspire add Aspire.Hosting.JavaScript"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180)); - - await auto.TypeAsync("aspire add Aspire.Hosting.Docker"); - await auto.EnterAsync(); - await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180)); + await auto.AspireAddAsync("Aspire.Hosting.JavaScript", counter, timeout: TimeSpan.FromSeconds(180)); + await auto.AspireAddAsync("Aspire.Hosting.Docker", counter, timeout: TimeSpan.FromSeconds(180)); // Copy checked-in fixture apps and write the apphost CopyFixtures(workspace); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs index ef8ff4add5f..38a281c4530 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptCodegenValidationTests.cs @@ -40,15 +40,8 @@ public async Task RestoreGeneratesSdkFiles() await auto.WaitForSuccessPromptAsync(counter); // Step 2: Add two integrations - await auto.TypeAsync("aspire add Aspire.Hosting.Redis"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("aspire add Aspire.Hosting.SqlServer"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter); + await auto.AspireAddAsync("Aspire.Hosting.Redis", counter); + await auto.AspireAddAsync("Aspire.Hosting.SqlServer", counter); // Step 3: Run aspire restore and verify success await auto.TypeAsync("aspire restore"); @@ -117,7 +110,7 @@ public async Task RunWithMissingAwaitShowsHelpfulError() if (isCI) { await auto.InstallAspireBundleFromPullRequestAsync(prNumber, counter); - await auto.SourceAspireBundleEnvironmentAsync(counter); + await auto.SourceAspireBundleEnvironmentAsync(counter, prNumber); await auto.VerifyAspireCliVersionAsync(commitSha, counter); } @@ -126,10 +119,7 @@ public async Task RunWithMissingAwaitShowsHelpfulError() await auto.WaitUntilTextAsync("Created apphost.ts", timeout: TimeSpan.FromMinutes(2)); await auto.WaitForSuccessPromptAsync(counter); - await auto.TypeAsync("aspire add Aspire.Hosting.PostgreSQL"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter); + await auto.AspireAddAsync("Aspire.Hosting.PostgreSQL", counter); await auto.TypeAsync("aspire restore"); await auto.EnterAsync(); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs index 9551e830709..8b3f1cf1f32 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPolyglotTests.cs @@ -58,12 +58,7 @@ public async Task CreateTypeScriptAppHostWithViteApp() await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2)); // Step 4: Add Aspire.Hosting.JavaScript package - // When channel is set (CI) and there's only one channel with one version, - // the version is auto-selected without prompting. - await auto.TypeAsync("aspire add Aspire.Hosting.JavaScript"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter); + await auto.AspireAddAsync("Aspire.Hosting.JavaScript", counter); // Step 5: Modify apphost.ts to add the Vite app var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPublishTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPublishTests.cs index 995512c31d5..a11e2e4aa81 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPublishTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptPublishTests.cs @@ -43,15 +43,8 @@ public async Task PublishWithDockerComposeServiceCallbackSucceeds() await auto.WaitUntilTextAsync("Created apphost.ts", timeout: TimeSpan.FromMinutes(2)); await auto.DeclineAgentInitPromptAsync(counter); - await auto.TypeAsync("aspire add Aspire.Hosting.Docker"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter); - - await auto.TypeAsync("aspire add Aspire.Hosting.PostgreSQL"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter); + await auto.AspireAddAsync("Aspire.Hosting.Docker", counter); + await auto.AspireAddAsync("Aspire.Hosting.PostgreSQL", counter); await auto.TypeAsync("aspire restore"); await auto.EnterAsync(); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptReusablePackageTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptReusablePackageTests.cs index 779d73bc7cc..eb8a26b2971 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptReusablePackageTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/TypeScriptReusablePackageTests.cs @@ -47,10 +47,7 @@ public async Task RestoreSupportsConfigOnlyHelperPackageAndCrossPackageTypes() var appAppHostPath = Path.Combine(appDirectory.FullName, "apphost.ts"); Assert.True(File.Exists(appAppHostPath), $"Expected the CLI-created app to contain {appAppHostPath}."); - await auto.TypeAsync("aspire add Aspire.Hosting.Redis"); - await auto.EnterAsync(); - await auto.WaitUntilTextAsync("The package Aspire.Hosting.", timeout: TimeSpan.FromMinutes(2)); - await auto.WaitForSuccessPromptAsync(counter); + await auto.AspireAddAsync("Aspire.Hosting.Redis", counter); var sdkVersion = GetSdkVersion(appDirectory); WriteHelperPackageFiles(helperDirectory, helperSourceDirectory, sdkVersion); diff --git a/tests/Aspire.Cli.Tests/BundleServiceTests.cs b/tests/Aspire.Cli.Tests/BundleServiceTests.cs index 9d8f5f015f5..2a3c71bc2f1 100644 --- a/tests/Aspire.Cli.Tests/BundleServiceTests.cs +++ b/tests/Aspire.Cli.Tests/BundleServiceTests.cs @@ -53,7 +53,7 @@ public void VersionMarker_ReturnsNull_WhenMissing() } [Fact] - public void GetDefaultExtractDir_ReturnsParentOfParent() + public void GetDefaultExtractDir_StableLayout_ReturnsParentOfBinDir() { if (OperatingSystem.IsWindows()) { @@ -67,6 +67,31 @@ public void GetDefaultExtractDir_ReturnsParentOfParent() } } + [Fact] + public void GetDefaultExtractDir_DogfoodFlatLayout_ReturnsCliDirectory() + { + if (OperatingSystem.IsWindows()) + { + var result = BundleService.GetDefaultExtractDir(@"C:\Users\test\.aspire\dogfood\pr-1234\aspire.exe"); + Assert.Equal(@"C:\Users\test\.aspire\dogfood\pr-1234", result); + } + else + { + var result = BundleService.GetDefaultExtractDir("/home/test/.aspire/dogfood/pr-1234/aspire"); + Assert.Equal("/home/test/.aspire/dogfood/pr-1234", result); + } + } + + [Fact] + public void GetDefaultExtractDir_ReturnsNull_WhenProcessIsNotAspireCli() + { + // Regression: without the filename guard, this would return a path instead of null. + // Both resolvers (Program.GetInstallRootDirectory and BundleService.GetDefaultExtractDir) + // must agree that non-aspire processes are not valid install roots. + var result = BundleService.GetDefaultExtractDir("/usr/local/share/dotnet/dotnet"); + Assert.Null(result); + } + [Fact] public void GetCurrentVersion_ReturnsNonNull() { diff --git a/tests/Aspire.Cli.Tests/ProgramTests.cs b/tests/Aspire.Cli.Tests/ProgramTests.cs index bcaa4d6b7fa..ed21bc47bfd 100644 --- a/tests/Aspire.Cli.Tests/ProgramTests.cs +++ b/tests/Aspire.Cli.Tests/ProgramTests.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Cli.Utils; + namespace Aspire.Cli.Tests; public class ProgramTests @@ -28,4 +30,62 @@ public void ParseLogFileOption_IgnoresValue_WhenOptionAppearsAfterDelimiter() Assert.Null(result); } + + [Fact] + public void GetInstallRootDirectory_FallsBackToHome_WhenProcessIsNotAspireCli() + { + // The test runner is not named "aspire", so it should fall back to ~/.aspire + var result = Program.GetInstallRootDirectory(); + var expected = CliPathHelper.GetAspireHomeDirectory(); + + Assert.Equal(expected, result.FullName); + } + + [Fact] + public void GetInstallRootDirectory_StableLayout_ReturnsParentOfBinDir() + { + if (OperatingSystem.IsWindows()) + { + var result = Program.GetInstallRootDirectory(@"C:\Users\test\.aspire\bin\aspire.exe"); + Assert.Equal(@"C:\Users\test\.aspire", result.FullName); + } + else + { + var result = Program.GetInstallRootDirectory("/home/test/.aspire/bin/aspire"); + Assert.Equal("/home/test/.aspire", result.FullName); + } + } + + [Fact] + public void GetInstallRootDirectory_DogfoodFlatLayout_ReturnsCliDirectory() + { + if (OperatingSystem.IsWindows()) + { + var result = Program.GetInstallRootDirectory(@"C:\Users\test\.aspire\dogfood\pr-1234\aspire.exe"); + Assert.Equal(@"C:\Users\test\.aspire\dogfood\pr-1234", result.FullName); + } + else + { + var result = Program.GetInstallRootDirectory("/home/test/.aspire/dogfood/pr-1234/aspire"); + Assert.Equal("/home/test/.aspire/dogfood/pr-1234", result.FullName); + } + } + + [Fact] + public void GetInstallRootDirectory_NullProcessPath_FallsBackToHome() + { + var result = Program.GetInstallRootDirectory(null); + var expected = CliPathHelper.GetAspireHomeDirectory(); + + Assert.Equal(expected, result.FullName); + } + + [Fact] + public void GetInstallRootDirectory_NonAspireProcess_FallsBackToHome() + { + var result = Program.GetInstallRootDirectory("/usr/local/share/dotnet/dotnet"); + var expected = CliPathHelper.GetAspireHomeDirectory(); + + Assert.Equal(expected, result.FullName); + } } diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2EAutomatorHelpers.cs b/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2EAutomatorHelpers.cs index 85fa9c741f4..fb9c49959da 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2EAutomatorHelpers.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/Helpers/DeploymentE2EAutomatorHelpers.cs @@ -102,12 +102,15 @@ internal static async Task InstallAspireBundleFromPullRequestAsync( /// /// Sources the Aspire Bundle environment after installation. /// Adds both the bundle's bin/ and root directories to PATH. + /// When is provided, the self-contained dogfood path is also added. /// internal static async Task SourceAspireBundleEnvironmentAsync( this Hex1bTerminalAutomator auto, - SequenceCounter counter) + SequenceCounter counter, + int? prNumber = null) { - await auto.TypeAsync("export PATH=~/.aspire/bin:~/.aspire:$PATH ASPIRE_PLAYGROUND=true TERM=xterm DOTNET_CLI_TELEMETRY_OPTOUT=true DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true DOTNET_GENERATE_ASPNET_CERTIFICATE=false"); + var dogfoodPrefix = prNumber.HasValue ? $"~/.aspire/dogfood/pr-{prNumber}:" : ""; + await auto.TypeAsync($"export PATH={dogfoodPrefix}~/.aspire/bin:~/.aspire:$PATH ASPIRE_PLAYGROUND=true TERM=xterm DOTNET_CLI_TELEMETRY_OPTOUT=true DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true DOTNET_GENERATE_ASPNET_CERTIFICATE=false"); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter); } diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptExpressDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptExpressDeploymentTests.cs index 1c9cf2a26ba..5a9eeaa1462 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptExpressDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptExpressDeploymentTests.cs @@ -83,7 +83,7 @@ private async Task DeployTypeScriptExpressTemplateToAzureContainerAppsCore(Cance output.WriteLine($"Step 2: Installing Aspire bundle from PR #{prNumber}..."); await auto.InstallAspireBundleFromPullRequestAsync(prNumber, counter); } - await auto.SourceAspireBundleEnvironmentAsync(counter); + await auto.SourceAspireBundleEnvironmentAsync(counter, prNumber > 0 ? prNumber : null); } // Step 3: Create TypeScript Express/React project using aspire new diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptVnetSqlServerInfraDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptVnetSqlServerInfraDeploymentTests.cs index bea38786351..72c5e0cf376 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptVnetSqlServerInfraDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/TypeScriptVnetSqlServerInfraDeploymentTests.cs @@ -80,7 +80,7 @@ private async Task DeployTypeScriptVnetSqlServerInfrastructureCore(CancellationT output.WriteLine($"Step 2: Installing Aspire bundle from PR #{prNumber}..."); await auto.InstallAspireBundleFromPullRequestAsync(prNumber, counter); } - await auto.SourceAspireBundleEnvironmentAsync(counter); + await auto.SourceAspireBundleEnvironmentAsync(counter, prNumber > 0 ? prNumber : null); } // Step 3: Create TypeScript AppHost using aspire init diff --git a/tests/Shared/Hex1bAutomatorTestHelpers.cs b/tests/Shared/Hex1bAutomatorTestHelpers.cs index aad2b2a8522..ab4467e908e 100644 --- a/tests/Shared/Hex1bAutomatorTestHelpers.cs +++ b/tests/Shared/Hex1bAutomatorTestHelpers.cs @@ -252,10 +252,35 @@ await auto.WaitUntilAsync( default: throw new ArgumentOutOfRangeException(nameof(template), template, $"Unsupported template: {template}"); } + + // Dogfood installs may show "Select a template version" before the project + // name prompt. Wait for whichever appears first to avoid a race condition + // where a fixed timeout misses a slow-appearing version prompt. + var versionPromptHandled = false; + var sawVersionPrompt1 = false; await auto.WaitUntilAsync( - s => new CellPatternSearcher().Find("Enter the project name").Search(s).Count > 0, - timeout: TimeSpan.FromSeconds(10), - description: "project name prompt"); + s => + { + if (new CellPatternSearcher().Find("Select a template version").Search(s).Count > 0) + { + sawVersionPrompt1 = true; + return true; + } + return new CellPatternSearcher().Find("Enter the project name").Search(s).Count > 0; + }, + timeout: TimeSpan.FromSeconds(15), + description: "version prompt or project name prompt"); + + if (sawVersionPrompt1) + { + versionPromptHandled = true; + await auto.EnterAsync(); // Accept the default version + + await auto.WaitUntilAsync( + s => new CellPatternSearcher().Find("Enter the project name").Search(s).Count > 0, + timeout: TimeSpan.FromSeconds(10), + description: "project name prompt"); + } await auto.TypeAsync(projectName); await auto.EnterAsync(); @@ -266,11 +291,44 @@ await auto.WaitUntilAsync( description: "output path prompt"); await auto.EnterAsync(); - // Step 5: URLs prompt (all templates have this) - await auto.WaitUntilAsync( - s => new CellPatternSearcher().Find("Use *.dev.localhost URLs").Search(s).Count > 0, - timeout: TimeSpan.FromSeconds(10), - description: "URLs prompt"); + // Dogfood installs may show "Select a template version" after the output + // path (e.g. Starter template). Only check if not already handled above + // to avoid matching stale text from a previous version selection. + if (!versionPromptHandled) + { + var sawVersionPrompt2 = false; + await auto.WaitUntilAsync( + s => + { + if (new CellPatternSearcher().Find("Select a template version").Search(s).Count > 0) + { + sawVersionPrompt2 = true; + return true; + } + return new CellPatternSearcher().Find("Use *.dev.localhost URLs").Search(s).Count > 0; + }, + timeout: TimeSpan.FromSeconds(15), + description: "version prompt or URLs prompt"); + + if (sawVersionPrompt2) + { + await auto.EnterAsync(); // Accept the default version + + await auto.WaitUntilAsync( + s => new CellPatternSearcher().Find("Use *.dev.localhost URLs").Search(s).Count > 0, + timeout: TimeSpan.FromSeconds(10), + description: "URLs prompt"); + } + } + else + { + // Version already handled at position 1 — just wait for the URLs prompt + await auto.WaitUntilAsync( + s => new CellPatternSearcher().Find("Use *.dev.localhost URLs").Search(s).Count > 0, + timeout: TimeSpan.FromSeconds(15), + description: "URLs prompt"); + } + await auto.EnterAsync(); // Accept default "No" // Step 6: Redis prompt (only Starter, JsReact, PythonReact) @@ -335,4 +393,42 @@ await auto.WaitUntilAsync( await auto.DeclineAgentInitPromptAsync(counter); } + + /// + /// Runs aspire add {packageName} and handles the optional version selection prompt + /// that appears with dogfood installs. Waits for the success prompt. + /// + internal static async Task AspireAddAsync( + this Hex1bTerminalAutomator auto, + string packageName, + SequenceCounter counter, + TimeSpan? timeout = null) + { + var effectiveTimeout = timeout ?? TimeSpan.FromMinutes(2); + + await auto.TypeAsync($"aspire add {packageName}"); + await auto.EnterAsync(); + + // Dogfood installs may present a version selection prompt for the package. + // Wait for either the version selection prompt or the package success message. + var versionSelectionShown = false; + await auto.WaitUntilAsync(s => + { + if (new CellPatternSearcher().Find($"Select a version of {packageName}").Search(s).Count > 0) + { + versionSelectionShown = true; + return true; + } + + return new CellPatternSearcher().Find($"The package {packageName}").Search(s).Count > 0; + }, timeout: effectiveTimeout, description: $"version selection or package added ({packageName})"); + + if (versionSelectionShown) + { + // Accept the default version + await auto.EnterAsync(); + } + + await auto.WaitForSuccessPromptAsync(counter); + } }