File: Commands\UpdateCommandTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections;
using System.Formats.Tar;
using System.IO.Compression;
using System.Runtime.InteropServices;
using System.Text.Json;
using Aspire.Cli.Acquisition;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Commands;
using Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.Migrations;
using Aspire.Cli.Packaging;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Spectre.Console;
using Spectre.Console.Rendering;
using Microsoft.AspNetCore.InternalTesting;
 
namespace Aspire.Cli.Tests.Commands;
 
public class UpdateCommandTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task UpdateCommandWithHelpArgumentReturnsZero()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --help");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public void UpdateCommand_WhenIdentityChannelIsStaging_DescribesStagingChannelOption()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: PackageChannelNames.Staging);
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<UpdateCommand>();
 
        var channelOption = command.Options.Single(option => option.Name == "--channel");
        Assert.Equal(UpdateCommandStrings.ChannelOptionDescriptionWithStaging, channelOption.Description);
    }
 
    [Theory]
    [InlineData("update --non-interactive")]
    [InlineData("--non-interactive update")]
    public async Task UpdateCommandFailsFastWhenNonInteractiveWithoutYes(string commandLine)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
 
        var result = command.Parse(commandLine);
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
        var error = Assert.Single(result.Errors);
        Assert.Equal(string.Format(System.Globalization.CultureInfo.CurrentCulture, SharedCommandStrings.NonInteractiveRequiresYesFormat, "update"), error.Message);
    }
 
    [Fact]
    public async Task UpdateCommand_WhenExplicitAppHostHasUnresolvableSdk_ReachesProjectUpdater()
    {
        // https://github.com/microsoft/aspire/issues/19035. `aspire update` is the recovery tool for a
        // pinned Aspire.AppHost.Sdk that can no longer be restored, so rewriting that pin is exactly what
        // the user is asking for. Failing inside project resolution makes the break unrecoverable.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostProjectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(appHostProjectFile.FullName, """
            <Project Sdk="Microsoft.NET.Sdk">
              <Sdk Name="Aspire.AppHost.Sdk" Version="0.0.0-does-not-exist" />
            </Project>
            """);
 
        FileInfo? updatedProjectFile = null;
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => new TestInteractionService();
 
            options.DotNetCliRunnerFactory = _ =>
            {
                var runner = new TestDotNetCliRunner();
                // MSBuild cannot evaluate a project whose SDK cannot be resolved, so every property
                // query fails until the pin is rewritten.
                runner.GetProjectItemsAndPropertiesAsyncCallbackWithTargets = (_, _, _, _, _, _) => (1, null);
                return runner;
            };
 
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater()
            {
                UpdateProjectAsyncCallback = (context, _) =>
                {
                    updatedProjectFile = context.AppHostFile;
                    return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = true });
                }
            };
 
            options.PackagingServiceFactory = _ => new TestPackagingService();
        });
 
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"update --apphost {appHostProjectFile.FullName}");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal(appHostProjectFile.FullName, updatedProjectFile?.FullName);
    }
 
    [Fact]
    public async Task UpdateCommand_WhenProjectOptionSpecified_PassesProjectFileToProjectLocator()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        FileInfo? capturedProjectFile = null;
        var projectLocatorInvoked = false;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) =>
                {
                    projectLocatorInvoked = true;
                    capturedProjectFile = projectFile;
                    return Task.FromResult<FileInfo?>(projectFile);
                }
            };
 
            options.InteractionServiceFactory = _ => new TestInteractionService();
 
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner();
 
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater()
            {
                UpdateProjectAsyncCallback = (context, cancellationToken) =>
                {
                    return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = false });
                }
            };
 
            options.PackagingServiceFactory = _ => new TestPackagingService();
        });
 
        using var provider = services.BuildServiceProvider();
 
        // Act
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"update --apphost AppHost.csproj");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        // Assert
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(projectLocatorInvoked);
        Assert.NotNull(capturedProjectFile);
        Assert.Equal("AppHost.csproj", capturedProjectFile.Name);
    }
 
    [Fact]
    public void CleanupOldBackupFiles_DeletesFilesMatchingPattern()
    {
        // Arrange
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var targetExePath = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.exe");
        var oldBackup1 = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.exe.old.1234567890");
        var oldBackup2 = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.exe.old.9876543210");
        var otherFile = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.exe.something");
 
        // Create test files
        File.WriteAllText(oldBackup1, "test");
        File.WriteAllText(oldBackup2, "test");
        File.WriteAllText(otherFile, "test");
 
        // Act
        FileDeleteHelper.TryCleanupOldItems(workspace.WorkspaceRoot.FullName, "aspire.exe");
 
        // Assert
        Assert.False(File.Exists(oldBackup1), "Old backup file should be deleted");
        Assert.False(File.Exists(oldBackup2), "Old backup file should be deleted");
        Assert.True(File.Exists(otherFile), "Other files should not be deleted");
    }
 
    [Fact]
    public void CleanupOldBackupFiles_HandlesInUseFilesGracefully()
    {
        // Arrange
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var oldBackup = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.exe.old.1234567890");
 
        // Create and lock the backup file
        File.WriteAllText(oldBackup, "test");
        using var fileStream = new FileStream(oldBackup, FileMode.Open, FileAccess.Read, FileShare.None);
 
        // Act & Assert - should not throw exception
        FileDeleteHelper.TryCleanupOldItems(workspace.WorkspaceRoot.FullName, "aspire.exe");
 
        // On Windows, locked files cannot be deleted, so the file should still exist
        // On Mac/Linux, locked files can be deleted, so the file may be deleted
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            Assert.True(File.Exists(oldBackup), "Locked file should still exist on Windows");
        }
        else
        {
            Assert.False(File.Exists(oldBackup), "Locked file should be deleted on Mac/Linux");
        }
    }
 
    [Fact]
    public void CleanupOldBackupFiles_HandlesNonExistentDirectory()
    {
        // Act & Assert - should not throw exception
        FileDeleteHelper.TryCleanupOldItems(Path.Combine("C:", "NonExistent"), "aspire.exe");
    }
 
    [Fact]
    public void CleanupOldBackupFiles_HandlesEmptyDirectory()
    {
        // Arrange
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        // Act & Assert - should not throw exception
        FileDeleteHelper.TryCleanupOldItems(workspace.WorkspaceRoot.FullName, "aspire.exe");
    }
 
    [Fact]
    public async Task UpdateCommand_WhenNoProjectFound_PromptsForCliSelfUpdate()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var confirmCallbackInvoked = false;
        string? confirmPrompt = null;
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) =>
                {
                    // Simulate no project found by throwing ProjectLocatorException
                    throw new ProjectLocatorException(ErrorStrings.NoProjectFileFound, ProjectLocatorFailureReason.NoProjectFileFound);
                }
            };
 
            options.InteractionServiceFactory = _ => new TestInteractionService()
            {
                ConfirmCallback = (prompt, defaultValue) =>
                {
                    confirmCallbackInvoked = true;
                    confirmPrompt = prompt;
                    return false; // User says no
                }
            };
 
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner();
        });
 
        using var provider = services.BuildServiceProvider();
 
        // Act
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        // Assert
        Assert.True(confirmCallbackInvoked, "Confirm prompt should have been shown");
        Assert.NotNull(confirmPrompt);
        Assert.Contains("Would you like to update the Aspire CLI", confirmPrompt);
        Assert.Equal(CliExitCodes.FailedToFindProject, exitCode);
    }
 
    [Fact]
    public async Task UpdateCommand_WhenProjectUpdatedSuccessfully_AndChannelSupportsCliDownload_PromptsForCliUpdate()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var confirmCallbackInvoked = false;
        string? confirmPrompt = null;
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) =>
                {
                    return Task.FromResult<FileInfo?>(new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")));
                }
            };
 
            options.InteractionServiceFactory = _ => new TestInteractionService()
            {
                ConfirmCallback = (prompt, defaultValue) =>
                {
                    confirmCallbackInvoked = true;
                    confirmPrompt = prompt;
                    return false; // User says no
                }
            };
 
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner();
 
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater()
            {
                UpdateProjectAsyncCallback = (context, cancellationToken) =>
                {
                    return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = true });
                }
            };
 
            // Return a channel with CliDownloadBaseUrl to enable CLI update prompts
            options.PackagingServiceFactory = _ => new TestPackagingService()
            {
                GetChannelsAsyncCallback = (cancellationToken) =>
                {
                    var stableChannel = PackageChannel.CreateExplicitChannel(
                        "stable",
                        PackageChannelQuality.Stable,
                        new[] { new PackageMapping("Aspire*", "https://api.nuget.org/v3/index.json") },
                        null!,
                        features: new TestFeatures(),
                        NullLogger.Instance,
                        configureGlobalPackagesFolder: false,
                        cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/ga/daily");
                    return Task.FromResult<IEnumerable<PackageChannel>>(new[] { stableChannel });
                }
            };
 
            // Configure update notifier to report that an update is available
            options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier()
            {
                IsUpdateAvailableCallback = () => true
            };
        });
 
        using var provider = services.BuildServiceProvider();
 
        // Act
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --apphost AppHost.csproj");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        // Assert
        Assert.True(confirmCallbackInvoked, "Confirm prompt should have been shown after successful project update");
        Assert.NotNull(confirmPrompt);
        Assert.Contains("An update is available for the Aspire CLI", confirmPrompt);
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task UpdateCommand_GuestProject_WhenTargetSdkNewerThanCli_PromptsForCliUpdateBeforeProjectUpdateAndSkipsWhenAccepted()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        const string processPath = "/home/test/.dotnet/tools/.store/aspire.cli/9.4.0/aspire.cli.linux-x64/9.4.0/tools/net10.0/linux-x64/aspire";
        var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts");
        File.WriteAllText(appHostPath, "// test apphost");
 
        var updateProjectInvoked = false;
        string? confirmPrompt = null;
        var interactionService = new TestInteractionService
        {
            ConfirmCallback = (prompt, _) =>
            {
                confirmPrompt = prompt;
                return true;
            }
        };
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            UseProcessPath(options, processPath);
 
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (_, _, _) => Task.FromResult<FileInfo?>(new FileInfo(appHostPath))
            };
 
            options.AppHostProjectFactory = _ => new TestAppHostProjectFactory
            {
                CanHandleCallback = _ => true,
                LanguageId = "typescript/nodejs",
                DisplayName = "TypeScript (Node.js)",
                DetectionPatterns = ["apphost.ts"],
                UpdatePackagesAsyncCallback = (_, _) =>
                {
                    updateProjectInvoked = true;
                    return Task.FromResult(new UpdatePackagesResult { UpdatesApplied = true });
                }
            };
 
            options.InteractionServiceFactory = _ => interactionService;
            options.PackagingServiceFactory = _ => new TestPackagingService
            {
                GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>(
                    [CreatePackageChannelWithGuestSdkVersion("99.0.0", cliDownloadBaseUrl: "https://example.test/aspire")])
            };
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --apphost apphost.ts");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(updateProjectInvoked);
        Assert.NotNull(confirmPrompt);
        Assert.Contains("newer than this Aspire CLI", confirmPrompt);
        Assert.Contains("re-run `aspire update`", confirmPrompt);
        Assert.Contains(interactionService.DisplayedPlainText, text => text.Contains("dotnet tool update -g Aspire.Cli", StringComparison.Ordinal));
        Assert.Contains(interactionService.DisplayedMessages, message => message.Message.Contains("Project update skipped", StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task UpdateCommand_GuestProject_WhenTargetSdkNewerThanCliAndCliUpdateDeclined_ContinuesProjectUpdate()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts");
        File.WriteAllText(appHostPath, "// test apphost");
 
        var updateProjectInvoked = false;
        var confirmCallbackInvoked = false;
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (_, _, _) => Task.FromResult<FileInfo?>(new FileInfo(appHostPath))
            };
 
            options.AppHostProjectFactory = _ => new TestAppHostProjectFactory
            {
                CanHandleCallback = _ => true,
                LanguageId = "typescript/nodejs",
                DisplayName = "TypeScript (Node.js)",
                DetectionPatterns = ["apphost.ts"],
                UpdatePackagesAsyncCallback = (_, _) =>
                {
                    updateProjectInvoked = true;
                    return Task.FromResult(new UpdatePackagesResult { UpdatesApplied = true });
                }
            };
 
            options.InteractionServiceFactory = _ => new TestInteractionService
            {
                ConfirmCallback = (_, _) =>
                {
                    confirmCallbackInvoked = true;
                    return false;
                }
            };
            options.PackagingServiceFactory = _ => new TestPackagingService
            {
                GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>(
                    [CreatePackageChannelWithGuestSdkVersion("99.0.0", cliDownloadBaseUrl: "https://example.test/aspire")])
            };
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --apphost apphost.ts");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(confirmCallbackInvoked);
        Assert.True(updateProjectInvoked);
    }
 
    [Fact]
    public async Task UpdateCommand_GuestProject_WhenChannelCannotDownloadCli_DoesNotPromptBeforeProjectUpdate()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts");
        File.WriteAllText(appHostPath, "// test apphost");
 
        var updateProjectInvoked = false;
        var confirmCallbackInvoked = false;
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (_, _, _) => Task.FromResult<FileInfo?>(new FileInfo(appHostPath))
            };
 
            options.AppHostProjectFactory = _ => new TestAppHostProjectFactory
            {
                CanHandleCallback = _ => true,
                LanguageId = "typescript/nodejs",
                DisplayName = "TypeScript (Node.js)",
                DetectionPatterns = ["apphost.ts"],
                UpdatePackagesAsyncCallback = (_, _) =>
                {
                    updateProjectInvoked = true;
                    return Task.FromResult(new UpdatePackagesResult { UpdatesApplied = true });
                }
            };
 
            options.InteractionServiceFactory = _ => new TestInteractionService
            {
                ConfirmCallback = (_, _) =>
                {
                    confirmCallbackInvoked = true;
                    return false;
                }
            };
            options.PackagingServiceFactory = _ => new TestPackagingService
            {
                GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>(
                    [CreatePackageChannelWithGuestSdkVersion("99.0.0", cliDownloadBaseUrl: null)])
            };
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --apphost apphost.ts");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(confirmCallbackInvoked);
        Assert.True(updateProjectInvoked);
    }
 
    [Fact]
    public async Task UpdateCommand_WhenProjectUpdatedSuccessfullyAndRunningAsDotnetTool_DisplaysDotnetToolUpdateCommand()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        const string processPath = "/home/test/.dotnet/tools/.store/aspire.cli/9.4.0/aspire.cli.linux-x64/9.4.0/tools/net10.0/linux-x64/aspire";
        var interactionService = new TestInteractionService()
        {
            ConfirmCallback = (_, _) => true
        };
        var downloaderInvoked = false;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            UseProcessPath(options, processPath);
 
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) =>
                {
                    return Task.FromResult<FileInfo?>(new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")));
                }
            };
 
            options.InteractionServiceFactory = _ => interactionService;
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner();
 
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater()
            {
                UpdateProjectAsyncCallback = (context, cancellationToken) =>
                {
                    return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = true });
                }
            };
 
            options.PackagingServiceFactory = _ => new TestPackagingService()
            {
                GetChannelsAsyncCallback = (cancellationToken) =>
                {
                    var stableChannel = PackageChannel.CreateExplicitChannel(
                        "stable",
                        PackageChannelQuality.Stable,
                        new[] { new PackageMapping("Aspire*", "https://api.nuget.org/v3/index.json") },
                        null!,
                        features: new TestFeatures(),
                        NullLogger.Instance,
                        configureGlobalPackagesFolder: false,
                        cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/ga/daily");
                    return Task.FromResult<IEnumerable<PackageChannel>>(new[] { stableChannel });
                }
            };
 
            options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier()
            {
                IsUpdateAvailableCallback = () => true
            };
 
            options.CliDownloaderFactory = _ => new TestCliDownloader(workspace.WorkspaceRoot)
            {
                DownloadLatestCliAsyncCallback = (_, _) =>
                {
                    downloaderInvoked = true;
                    return Task.FromResult(string.Empty);
                }
            };
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --apphost AppHost.csproj");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(downloaderInvoked, "Archive self-update should not be used for dotnet tool installs.");
        Assert.Contains(interactionService.DisplayedPlainText, text => text.Contains("dotnet tool update -g Aspire.Cli", StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task UpdateCommand_WhenProjectUpdatedSuccessfullyAndRunningAsCustomToolPathDotnetTool_DisplaysToolPathUpdateCommand()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var installDir = workspace.CreateDirectory("install");
        var toolPath = Path.Combine(installDir.FullName, "custom tool path");
        var processPath = CreateCustomToolPathInstall(toolPath);
        var interactionService = new TestInteractionService()
        {
            ConfirmCallback = (_, _) => true
        };
        var downloaderInvoked = false;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            UseProcessPath(options, processPath);
 
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) =>
                {
                    return Task.FromResult<FileInfo?>(new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")));
                }
            };
 
            options.InteractionServiceFactory = _ => interactionService;
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner();
 
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater()
            {
                UpdateProjectAsyncCallback = (context, cancellationToken) =>
                {
                    return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = true });
                }
            };
 
            options.PackagingServiceFactory = _ => new TestPackagingService()
            {
                GetChannelsAsyncCallback = (cancellationToken) =>
                {
                    var stableChannel = PackageChannel.CreateExplicitChannel(
                        "stable",
                        PackageChannelQuality.Stable,
                        new[] { new PackageMapping("Aspire*", "https://api.nuget.org/v3/index.json") },
                        null!,
                        features: new TestFeatures(),
                        NullLogger.Instance,
                        configureGlobalPackagesFolder: false,
                        cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/ga/daily");
                    return Task.FromResult<IEnumerable<PackageChannel>>(new[] { stableChannel });
                }
            };
 
            options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier()
            {
                IsUpdateAvailableCallback = () => true
            };
 
            options.CliDownloaderFactory = _ => new TestCliDownloader(workspace.WorkspaceRoot)
            {
                DownloadLatestCliAsyncCallback = (_, _) =>
                {
                    downloaderInvoked = true;
                    return Task.FromResult(string.Empty);
                }
            };
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --apphost AppHost.csproj");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(downloaderInvoked, "Archive self-update should not be used for dotnet tool installs.");
        Assert.Contains(interactionService.DisplayedPlainText, text => text.Contains($"dotnet tool update --tool-path \"{toolPath}\" Aspire.Cli", StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task UpdateCommand_WhenProjectUpdatedSuccessfullyAndRunningFromNpm_DisplaysNpmUpdateCommand()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var npmScope = NpmInstallDetection.UseEnvironmentForTesting(CreateNpmInstallEnvironment());
        var interactionService = new TestInteractionService()
        {
            ConfirmCallback = (_, _) => true
        };
        var downloaderInvoked = false;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            UseProcessPath(options, "/home/test/.aspire/bin/aspire");
 
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) =>
                {
                    return Task.FromResult<FileInfo?>(new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")));
                }
            };
 
            options.InteractionServiceFactory = _ => interactionService;
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner();
 
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater()
            {
                UpdateProjectAsyncCallback = (context, cancellationToken) =>
                {
                    return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = true });
                }
            };
 
            options.PackagingServiceFactory = _ => new TestPackagingService()
            {
                GetChannelsAsyncCallback = (cancellationToken) =>
                {
                    var stableChannel = PackageChannel.CreateExplicitChannel(
                        "stable",
                        PackageChannelQuality.Stable,
                        new[] { new PackageMapping("Aspire*", "https://api.nuget.org/v3/index.json") },
                        null!,
                        features: new TestFeatures(),
                        NullLogger.Instance,
                        configureGlobalPackagesFolder: false,
                        cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/ga/daily");
                    return Task.FromResult<IEnumerable<PackageChannel>>(new[] { stableChannel });
                }
            };
 
            options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier()
            {
                IsUpdateAvailableCallback = () => true
            };
 
            options.CliDownloaderFactory = _ => new TestCliDownloader(workspace.WorkspaceRoot)
            {
                DownloadLatestCliAsyncCallback = (_, _) =>
                {
                    downloaderInvoked = true;
                    return Task.FromResult(string.Empty);
                }
            };
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --apphost AppHost.csproj");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(downloaderInvoked, "Archive self-update should not be used for npm installs.");
        Assert.Contains(interactionService.DisplayedPlainText, text => text.Contains("npm install -g @microsoft/aspire-cli@latest", StringComparison.Ordinal));
        Assert.DoesNotContain(interactionService.DisplayedPlainText, text => text.Contains("dotnet tool update", StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task UpdateCommand_WithoutAutoConfirmOption_UsesFalseConfirmationDefault()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var updateProjectInvoked = false;
        var confirmBindingResolved = false;
        var confirmBindingValue = false;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) =>
                {
                    return Task.FromResult<FileInfo?>(new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")));
                }
            };
 
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater()
            {
                UpdateProjectAsyncCallback = (context, cancellationToken) =>
                {
                    updateProjectInvoked = true;
                    (confirmBindingResolved, confirmBindingValue) = context.ConfirmBinding.Resolve();
                    return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = false });
                }
            };
 
            options.PackagingServiceFactory = _ => new TestPackagingService();
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --apphost AppHost.csproj");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(updateProjectInvoked);
        Assert.False(confirmBindingResolved);
        Assert.False(confirmBindingValue);
    }
 
    [Fact]
    public async Task UpdateCommand_WithYesOption_ResolvesConfirmationFromCli()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var updateProjectInvoked = false;
        var confirmBindingResolved = false;
        var confirmBindingValue = false;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) =>
                {
                    return Task.FromResult<FileInfo?>(new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")));
                }
            };
 
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater()
            {
                UpdateProjectAsyncCallback = (context, cancellationToken) =>
                {
                    updateProjectInvoked = true;
                    (confirmBindingResolved, confirmBindingValue) = context.ConfirmBinding.Resolve();
                    return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = false });
                }
            };
 
            options.PackagingServiceFactory = _ => new TestPackagingService();
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --apphost AppHost.csproj --yes");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(updateProjectInvoked);
        Assert.True(confirmBindingResolved);
        Assert.True(confirmBindingValue);
    }
 
    [Fact]
    public async Task UpdateCommand_WhenChannelHasNoCliDownloadUrl_DoesNotPromptForCliUpdate()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var confirmCallbackInvoked = false;
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) =>
                {
                    return Task.FromResult<FileInfo?>(new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")));
                }
            };
 
            options.InteractionServiceFactory = _ => new TestInteractionService()
            {
                ConfirmCallback = (prompt, defaultValue) =>
                {
                    confirmCallbackInvoked = true;
                    return false; // User says no
                }
            };
 
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner();
 
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater()
            {
                UpdateProjectAsyncCallback = (context, cancellationToken) =>
                {
                    return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = true });
                }
            };
 
            // Return a channel without CliDownloadBaseUrl (like PR channels)
            options.PackagingServiceFactory = _ => new TestPackagingService()
            {
                GetChannelsAsyncCallback = (cancellationToken) =>
                {
                    var prChannel = PackageChannel.CreateExplicitChannel(
                        "pr-12658",
                        PackageChannelQuality.Prerelease,
                        new[] { new PackageMapping("Aspire*", "/path/to/pr/hive") },
                        null!,
                        features: new TestFeatures(),
                        NullLogger.Instance,
                        configureGlobalPackagesFolder: false,
                        cliDownloadBaseUrl: null); // No CLI download URL for PR channels
                    return Task.FromResult<IEnumerable<PackageChannel>>(new[] { prChannel });
                }
            };
 
            // Configure update notifier to report that an update is available
            options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier()
            {
                IsUpdateAvailableCallback = () => true
            };
        });
 
        using var provider = services.BuildServiceProvider();
 
        // Act
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --apphost AppHost.csproj");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        // Assert
        Assert.False(confirmCallbackInvoked, "Confirm prompt should NOT have been shown for channels without CLI download support");
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task UpdateCommand_WhenProjectUpdatedSuccessfully_AndMigrationPending_DisplaysAdvisory()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var descriptor = new MigrationDescriptor
        {
            Title = "Migrate 'apphost.ts' to 'apphost.mts'",
            Detail = "The legacy AppHost needs migrating"
        };
        var pendingMigration = new TestMigration("test-pending-migration", 100, descriptor);
 
        var subtleMessages = new List<string>();
        var interactionService = new TestInteractionService()
        {
            DisplaySubtleMessageCallback = subtleMessages.Add
        };
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) =>
                {
                    return Task.FromResult<FileInfo?>(new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")));
                }
            };
 
            options.InteractionServiceFactory = _ => interactionService;
 
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner();
 
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater()
            {
                UpdateProjectAsyncCallback = (context, cancellationToken) =>
                {
                    return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = true });
                }
            };
 
            // Use a PR channel (no CLI download URL) so the CLI self-update prompt path is skipped
            // and the test focuses purely on the post-update migration advisory.
            options.PackagingServiceFactory = _ => new TestPackagingService()
            {
                GetChannelsAsyncCallback = (cancellationToken) =>
                {
                    var prChannel = PackageChannel.CreateExplicitChannel(
                        "pr-12658",
                        PackageChannelQuality.Prerelease,
                        new[] { new PackageMapping("Aspire*", "/path/to/pr/hive") },
                        null!,
                        features: new TestFeatures(),
                        NullLogger.Instance,
                        configureGlobalPackagesFolder: false,
                        cliDownloadBaseUrl: null);
                    return Task.FromResult<IEnumerable<PackageChannel>>(new[] { prChannel });
                }
            };
        });
 
        services.AddSingleton<IMigration>(pendingMigration);
 
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --apphost AppHost.csproj");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Contains(interactionService.DisplayedMessages, m => m.Message == UpdateCommandStrings.PendingMigrationsHeader);
        Assert.Contains($"  - {descriptor.Title}", subtleMessages);
        Assert.Contains(UpdateCommandStrings.PendingMigrationsHint, subtleMessages);
 
        // The advisory is non-destructive: `update` points at `aspire update --migrate` rather
        // than applying the migration itself.
        Assert.NotNull(pendingMigration.DetectedContext?.AppHostFile);
        Assert.Equal(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"), pendingMigration.DetectedContext.AppHostFile.FullName);
        Assert.False(pendingMigration.ApplyInvoked);
    }
 
    [Fact]
    public async Task UpdateCommand_WithMigrateFlagAndYes_AppliesPendingMigration()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var descriptor = new MigrationDescriptor
        {
            Title = "Migrate 'apphost.ts' to 'apphost.mts'",
            Detail = "The legacy AppHost needs migrating"
        };
        var pendingMigration = new TestMigration("test-pending-migration", 100, descriptor);
 
        var interactionService = new TestInteractionService();
        var services = CreateMigrationUpdateServices(workspace, interactionService, pendingMigration);
 
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --apphost AppHost.csproj --migrate --yes");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.NotNull(pendingMigration.DetectedContext?.AppHostFile);
        Assert.Equal(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"), pendingMigration.DetectedContext.AppHostFile.FullName);
        Assert.NotNull(pendingMigration.AppliedContext?.AppHostFile);
        Assert.Equal(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"), pendingMigration.AppliedContext.AppHostFile.FullName);
        Assert.True(pendingMigration.ApplyInvoked);
        Assert.Contains(interactionService.DisplayedMessages, m => m.Message == MigrationStrings.AvailableMigrationsHeader);
    }
 
    [Fact]
    public async Task UpdateCommand_WithMigrateFlag_WhenDeclined_DoesNotApplyMigration()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var descriptor = new MigrationDescriptor
        {
            Title = "Migrate 'apphost.ts' to 'apphost.mts'",
            Detail = "The legacy AppHost needs migrating"
        };
        var pendingMigration = new TestMigration("test-pending-migration", 100, descriptor);
 
        var subtleMessages = new List<string>();
        var interactionService = new TestInteractionService()
        {
            ConfirmCallback = (_, _) => false,
            DisplaySubtleMessageCallback = subtleMessages.Add
        };
        var services = CreateMigrationUpdateServices(workspace, interactionService, pendingMigration);
 
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --apphost AppHost.csproj --migrate");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(pendingMigration.ApplyInvoked);
        Assert.Contains(MigrationStrings.MigrationCancelled, subtleMessages);
    }
 
    [Fact]
    public async Task UpdateCommand_WithMigrateFlag_AndNothingPending_ReportsNothingToMigrate()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        // Detection returns null, so there is nothing to migrate.
        var migration = new TestMigration("test-no-pending-migration", 100, descriptor: null);
 
        var subtleMessages = new List<string>();
        var interactionService = new TestInteractionService()
        {
            DisplaySubtleMessageCallback = subtleMessages.Add
        };
        var services = CreateMigrationUpdateServices(workspace, interactionService, migration);
 
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --apphost AppHost.csproj --migrate --yes");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(migration.ApplyInvoked);
        Assert.Contains(MigrationStrings.NothingToMigrate, subtleMessages);
    }
 
    private IServiceCollection CreateMigrationUpdateServices(
        TemporaryWorkspace workspace,
        TestInteractionService interactionService,
        IMigration migration)
    {
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) =>
                {
                    return Task.FromResult<FileInfo?>(new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")));
                }
            };
 
            options.InteractionServiceFactory = _ => interactionService;
 
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner();
 
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater()
            {
                UpdateProjectAsyncCallback = (context, cancellationToken) =>
                {
                    return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = true });
                }
            };
 
            // Use a PR channel (no CLI download URL) so the CLI self-update prompt path is skipped
            // and the test focuses purely on the post-update migration handling.
            options.PackagingServiceFactory = _ => new TestPackagingService()
            {
                GetChannelsAsyncCallback = (cancellationToken) =>
                {
                    var prChannel = PackageChannel.CreateExplicitChannel(
                        "pr-12658",
                        PackageChannelQuality.Prerelease,
                        new[] { new PackageMapping("Aspire*", "/path/to/pr/hive") },
                        null!,
                        features: new TestFeatures(),
                        NullLogger.Instance,
                        configureGlobalPackagesFolder: false,
                        cliDownloadBaseUrl: null);
                    return Task.FromResult<IEnumerable<PackageChannel>>(new[] { prChannel });
                }
            };
        });
 
        services.AddSingleton<IMigration>(migration);
        return services;
    }
 
    [Fact]
    public async Task UpdateCommand_SelfUpdate_WhenRunningAsNativeAotDotnetTool_DisplaysDotnetToolUpdateCommand()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var interactionService = new TestInteractionService();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            UseProcessPath(options, "/home/test/.dotnet/tools/.store/aspire.cli/9.4.0/aspire.cli.linux-x64/9.4.0/tools/any/linux-x64/aspire");
            options.InteractionServiceFactory = _ => interactionService;
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --self");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Contains(interactionService.DisplayedPlainText, text => text.Contains("dotnet tool update -g Aspire.Cli", StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task UpdateCommand_SelfUpdate_WhenRunningFromNpm_DisplaysNpmUpdateCommand()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var npmScope = NpmInstallDetection.UseEnvironmentForTesting(CreateNpmInstallEnvironment());
        var interactionService = new TestInteractionService();
        var downloaderInvoked = false;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            UseProcessPath(options, "/home/test/.aspire/bin/aspire");
            options.InteractionServiceFactory = _ => interactionService;
            options.CliDownloaderFactory = _ => new TestCliDownloader(workspace.WorkspaceRoot)
            {
                DownloadLatestCliAsyncCallback = (_, _) =>
                {
                    downloaderInvoked = true;
                    return Task.FromResult(string.Empty);
                }
            };
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --self");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(downloaderInvoked, "Archive self-update should not be used for npm installs.");
        Assert.Contains(interactionService.DisplayedPlainText, text => text.Contains("npm install -g @microsoft/aspire-cli@latest", StringComparison.Ordinal));
        Assert.DoesNotContain(interactionService.DisplayedPlainText, text => text.Contains("dotnet tool update", StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task UpdateCommand_SelfUpdate_WhenRunningFromNix_DisplaysNixUpdateGuidance()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var installDir = workspace.CreateDirectory("install");
        var processPath = CreateNixInstall(installDir.FullName);
        var interactionService = new TestInteractionService();
        var downloaderInvoked = false;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            UseProcessPath(options, processPath);
            options.InteractionServiceFactory = _ => interactionService;
            options.CliDownloaderFactory = _ => new TestCliDownloader(workspace.WorkspaceRoot)
            {
                DownloadLatestCliAsyncCallback = (_, _) =>
                {
                    downloaderInvoked = true;
                    return Task.FromResult(string.Empty);
                }
            };
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --self");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(downloaderInvoked, "Archive self-update should not be used for Nix installs.");
        AssertNixUpdateGuidance(interactionService, expectProjectUpdateSkippedMessage: false);
    }
 
    [Theory]
    [InlineData(NixSelfUpdateEntryPoint.AfterProjectUpdate)]
    [InlineData(NixSelfUpdateEntryPoint.NoProjectFound)]
    [InlineData(NixSelfUpdateEntryPoint.BeforeGuestProjectUpdate)]
    public async Task UpdateCommand_WhenRunningFromNix_DisplaysNixUpdateGuidanceForSelfUpdateEntryPoints(NixSelfUpdateEntryPoint entryPoint)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var installDir = workspace.CreateDirectory("install");
        var processPath = CreateNixInstall(installDir.FullName);
        var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts");
 
        // Only the TypeScript (BeforeGuestProjectUpdate) case needs a real apphost.ts on disk.
        // For the C# AppHost.csproj (AfterProjectUpdate) case, a sibling apphost.ts is an unrealistic
        // layout that would also trip the legacy-apphost migration advisory and pollute the assertions.
        if (entryPoint == NixSelfUpdateEntryPoint.BeforeGuestProjectUpdate)
        {
            File.WriteAllText(appHostPath, "// test apphost");
        }
 
        var updateProjectInvoked = false;
        var downloaderInvoked = false;
        var interactionService = new TestInteractionService
        {
            ConfirmCallback = (_, _) => true
        };
 
        var commandLine = entryPoint switch
        {
            NixSelfUpdateEntryPoint.AfterProjectUpdate => "update --apphost AppHost.csproj",
            NixSelfUpdateEntryPoint.NoProjectFound => "update",
            NixSelfUpdateEntryPoint.BeforeGuestProjectUpdate => "update --apphost apphost.ts",
            _ => throw new InvalidOperationException($"Unexpected entry point: {entryPoint}")
        };
        var expectProjectUpdateSkippedMessage = false;
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            UseProcessPath(options, processPath);
            options.InteractionServiceFactory = _ => interactionService;
            options.CliDownloaderFactory = _ => new TestCliDownloader(workspace.WorkspaceRoot)
            {
                DownloadLatestCliAsyncCallback = (_, _) =>
                {
                    downloaderInvoked = true;
                    return Task.FromResult(string.Empty);
                }
            };
 
            switch (entryPoint)
            {
                case NixSelfUpdateEntryPoint.AfterProjectUpdate:
                    options.ProjectLocatorFactory = _ => new TestProjectLocator()
                    {
                        UseOrFindAppHostProjectFileAsyncCallback = (_, _, _) => Task.FromResult<FileInfo?>(new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")))
                    };
                    options.ProjectUpdaterFactory = _ => new TestProjectUpdater()
                    {
                        UpdateProjectAsyncCallback = (_, _) =>
                        {
                            updateProjectInvoked = true;
                            return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = true });
                        }
                    };
                    options.PackagingServiceFactory = _ => new TestPackagingService
                    {
                        GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>(
                            [CreatePackageChannelWithGuestSdkVersion("99.0.0", cliDownloadBaseUrl: "https://example.test/aspire")])
                    };
                    options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier
                    {
                        IsUpdateAvailableCallback = () => true
                    };
                    break;
 
                case NixSelfUpdateEntryPoint.NoProjectFound:
                    options.ProjectLocatorFactory = _ => new TestProjectLocator()
                    {
                        UseOrFindAppHostProjectFileAsyncCallback = (_, _, _) =>
                        {
                            throw new ProjectLocatorException(ErrorStrings.NoProjectFileFound, ProjectLocatorFailureReason.NoProjectFileFound);
                        }
                    };
                    break;
 
                case NixSelfUpdateEntryPoint.BeforeGuestProjectUpdate:
                    expectProjectUpdateSkippedMessage = true;
                    options.ProjectLocatorFactory = _ => new TestProjectLocator()
                    {
                        UseOrFindAppHostProjectFileAsyncCallback = (_, _, _) => Task.FromResult<FileInfo?>(new FileInfo(appHostPath))
                    };
                    options.AppHostProjectFactory = _ => new TestAppHostProjectFactory
                    {
                        CanHandleCallback = _ => true,
                        LanguageId = "typescript/nodejs",
                        DisplayName = "TypeScript (Node.js)",
                        DetectionPatterns = ["apphost.ts"],
                        UpdatePackagesAsyncCallback = (_, _) =>
                        {
                            updateProjectInvoked = true;
                            return Task.FromResult(new UpdatePackagesResult { UpdatesApplied = true });
                        }
                    };
                    options.PackagingServiceFactory = _ => new TestPackagingService
                    {
                        GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>(
                            [CreatePackageChannelWithGuestSdkVersion("99.0.0", cliDownloadBaseUrl: "https://example.test/aspire")])
                    };
                    break;
 
                default:
                    throw new InvalidOperationException($"Unexpected entry point: {entryPoint}");
            }
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse(commandLine);
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(downloaderInvoked, "Archive self-update should not be used for Nix installs.");
        AssertNixUpdateGuidance(interactionService, expectProjectUpdateSkippedMessage);
 
        if (expectProjectUpdateSkippedMessage)
        {
            Assert.False(updateProjectInvoked);
        }
    }
 
    [Fact]
    public async Task UpdateCommand_SelfUpdate_WhenRunningAsCustomToolPathDotnetTool_DisplaysToolPathUpdateCommand()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var installDir = workspace.CreateDirectory("install");
        var toolPath = Path.Combine(installDir.FullName, "custom tool path");
        var processPath = CreateCustomToolPathInstall(toolPath);
        var interactionService = new TestInteractionService();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            UseProcessPath(options, processPath);
            options.InteractionServiceFactory = _ => interactionService;
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --self");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Contains(interactionService.DisplayedPlainText, text => text.Contains($"dotnet tool update --tool-path \"{toolPath}\" Aspire.Cli", StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task UpdateCommand_WhenNoProjectFoundAndRunningAsDotnetTool_DoesNotPromptForArchiveSelfUpdate()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var confirmCallbackInvoked = false;
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            UseProcessPath(options, "/home/test/.dotnet/tools/.store/aspire.cli/9.4.0/aspire.cli.linux-x64/9.4.0/tools/any/linux-x64/aspire");
 
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) =>
                {
                    throw new ProjectLocatorException(ErrorStrings.NoProjectFileFound, ProjectLocatorFailureReason.NoProjectFileFound);
                }
            };
 
            options.InteractionServiceFactory = _ => new TestInteractionService()
            {
                ConfirmCallback = (_, _) =>
                {
                    confirmCallbackInvoked = true;
                    return true;
                }
            };
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.False(confirmCallbackInvoked, "Archive self-update prompt should not be shown for dotnet tool installs.");
        Assert.Equal(CliExitCodes.FailedToFindProject, exitCode);
    }
 
    [Fact]
    public async Task UpdateCommand_SelfUpdate_WithChannelOption_DoesNotPromptForChannel()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var promptForSelectionInvoked = false;
        string? capturedChannel = null;
        
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => new TestInteractionService()
            {
                PromptForSelectionCallback = (prompt, choices, formatter, ct) =>
                {
                    promptForSelectionInvoked = true;
                    return "stable";
                }
            };
 
            options.CliDownloaderFactory = _ => new TestCliDownloader(workspace.WorkspaceRoot)
            {
                DownloadLatestCliAsyncCallback = (channel, ct) =>
                {
                    capturedChannel = channel;
                    // Create a fake archive file
                    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();
 
        // Act
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --self --channel daily");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        // Assert
        Assert.False(promptForSelectionInvoked, "Channel prompt should not be shown when --channel is provided");
        Assert.Equal("daily", capturedChannel);
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "The self-update archive contains a POSIX shell executable.")]
    public async Task UpdateCommand_SelfUpdate_PersistsSelectedChannelInInstallSidecar()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var installDirectory = workspace.CreateDirectory("install");
        var processPath = Path.Combine(installDirectory.FullName, "aspire");
        await File.WriteAllTextAsync(processPath, "#!/bin/sh\nexit 0\n");
        SetUnixExecutableMode(processPath);
 
        var sidecarPath = Path.Combine(installDirectory.FullName, InstallSidecarReader.SidecarFileName);
        await File.WriteAllTextAsync(
            sidecarPath,
            """{"source":"script","channel":"stable","version":"13.4.6","commit":"01234567","futureField":"preserved"}""");
 
        var archivePath = await CreateSelfUpdateArchiveAsync(workspace);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            UseProcessPath(options, processPath);
            options.CliDownloaderFactory = _ => new TestCliDownloader(workspace.WorkspaceRoot)
            {
                DownloadLatestCliAsyncCallback = (_, _) => Task.FromResult(archivePath)
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --self --channel staging");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        using var document = JsonDocument.Parse(await File.ReadAllBytesAsync(sidecarPath));
        Assert.Equal("script", document.RootElement.GetProperty("source").GetString());
        Assert.Equal("staging", document.RootElement.GetProperty("channel").GetString());
        // Version and commit describe the executable that was replaced. Keeping either value would
        // override the new binary's assembly metadata and make update routing use stale identity.
        Assert.False(document.RootElement.TryGetProperty("version", out _));
        Assert.False(document.RootElement.TryGetProperty("commit", out _));
        Assert.Equal("preserved", document.RootElement.GetProperty("futureField").GetString());
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "The self-update archive contains a POSIX shell executable.")]
    public async Task UpdateCommand_SelfUpdate_WhenSidecarUpdateFails_RestoresPreviousExecutable()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var installDirectory = workspace.CreateDirectory("install");
        var processPath = Path.Combine(installDirectory.FullName, "aspire");
        const string originalExecutable = "#!/bin/sh\necho original\n";
        await File.WriteAllTextAsync(processPath, originalExecutable);
        SetUnixExecutableMode(processPath);
 
        var sidecarPath = Path.Combine(installDirectory.FullName, InstallSidecarReader.SidecarFileName);
        const string originalSidecar = """{"source":"script","channel":"stable"}""";
        await File.WriteAllTextAsync(sidecarPath, originalSidecar);
 
        // The replacement removes the prepared file during its version probe. This forces the
        // sidecar commit to fail after executable replacement, exercising the rollback path.
        var archivePath = await CreateSelfUpdateArchiveAsync(workspace, deletePreparedSidecarDuringVersionProbe: true);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            UseProcessPath(options, processPath);
            options.CliDownloaderFactory = _ => new TestCliDownloader(workspace.WorkspaceRoot)
            {
                DownloadLatestCliAsyncCallback = (_, _) => Task.FromResult(archivePath)
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --self --channel staging");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
        Assert.Equal(originalExecutable, await File.ReadAllTextAsync(processPath));
        Assert.Equal(originalSidecar, await File.ReadAllTextAsync(sidecarPath));
    }
 
    [Fact]
    public async Task UpdateCommand_SelfUpdate_WithQualityOption_DoesNotPromptForQuality()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var promptForSelectionInvoked = false;
        string? capturedQuality = null;
        
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => new TestInteractionService()
            {
                PromptForSelectionCallback = (prompt, choices, formatter, ct) =>
                {
                    promptForSelectionInvoked = true;
                    return "stable";
                }
            };
 
            options.CliDownloaderFactory = _ => new TestCliDownloader(workspace.WorkspaceRoot)
            {
                DownloadLatestCliAsyncCallback = (quality, ct) =>
                {
                    capturedQuality = quality;
                    // Create a fake archive file
                    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();
 
        // Act
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --self --quality daily");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        // Assert
        Assert.False(promptForSelectionInvoked, "Quality prompt should not be shown when --quality is provided");
        Assert.Equal("daily", capturedQuality);
    }
 
    [Fact]
    public async Task UpdateCommand_SelfUpdate_WithChannelOption_TracksChannelParameter()
    {
        // This test verifies that the channel parameter flows through the self-update command.
        // Full integration testing of SetConfigurationAsync would require creating a valid
        // tar.gz archive with a working CLI executable, which is complex for a unit test.
        // The test verifies the channel value is properly captured and would be passed
        // to configuration service if the extraction succeeds.
        
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        string? capturedChannel = null;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliDownloaderFactory = _ => new TestCliDownloader(workspace.WorkspaceRoot)
            {
                DownloadLatestCliAsyncCallback = (channel, ct) =>
                {
                    capturedChannel = channel;
                    // Create a fake archive file - extraction will fail but channel is captured
                    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();
 
        // Act
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --self --channel daily");
 
        // Note: exitCode will be non-zero because extraction fails, but that's okay for this test
        await result.InvokeAsync().DefaultTimeout();
 
        // Assert - verify the channel parameter was correctly passed through
        Assert.Equal("daily", capturedChannel);
    }
 
    [Fact]
    public async Task UpdateCommand_ProjectUpdate_WithChannelOption_DoesNotPromptForChannel()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var promptForSelectionInvoked = false;
        PackageChannel? capturedChannel = null;
        
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) =>
                {
                    return Task.FromResult<FileInfo?>(new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")));
                }
            };
 
            options.InteractionServiceFactory = _ => new TestInteractionService()
            {
                PromptForSelectionCallback = (prompt, choices, formatter, ct) =>
                {
                    promptForSelectionInvoked = true;
                    return choices.Cast<PackageChannel>().First();
                }
            };
 
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner();
 
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater()
            {
                UpdateProjectAsyncCallback = (context, cancellationToken) =>
                {
                    capturedChannel = context.Channel;
                    return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = true });
                }
            };
 
            options.PackagingServiceFactory = _ => new TestPackagingService()
            {
                GetChannelsAsyncCallback = (ct) =>
                {
                    // Create test channels matching the expected names
                    var stableChannel = new PackageChannel("stable", PackageChannelQuality.Stable, null, null!, null!, NullLogger.Instance);
                    var dailyChannel = new PackageChannel("daily", PackageChannelQuality.Prerelease, null, null!, null!, NullLogger.Instance);
                    return Task.FromResult<IEnumerable<PackageChannel>>(new[] { stableChannel, dailyChannel });
                }
            };
        });
 
        using var provider = services.BuildServiceProvider();
 
        // Act
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --channel daily");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        // Assert
        Assert.False(promptForSelectionInvoked, "Channel prompt should not be shown when --channel is provided");
        Assert.NotNull(capturedChannel);
        Assert.Equal("daily", capturedChannel.Name);
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task UpdateCommand_ProjectUpdate_WithQualityOption_DoesNotPromptForChannel()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var promptForSelectionInvoked = false;
        PackageChannel? capturedChannel = null;
        
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) =>
                {
                    return Task.FromResult<FileInfo?>(new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")));
                }
            };
 
            options.InteractionServiceFactory = _ => new TestInteractionService()
            {
                PromptForSelectionCallback = (prompt, choices, formatter, ct) =>
                {
                    promptForSelectionInvoked = true;
                    return choices.Cast<PackageChannel>().First();
                }
            };
 
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner();
 
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater()
            {
                UpdateProjectAsyncCallback = (context, cancellationToken) =>
                {
                    capturedChannel = context.Channel;
                    return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = true });
                }
            };
 
            options.PackagingServiceFactory = _ => new TestPackagingService()
            {
                GetChannelsAsyncCallback = (ct) =>
                {
                    // Create test channels matching the expected names
                    var stableChannel = new PackageChannel("stable", PackageChannelQuality.Stable, null, null!, null!, NullLogger.Instance);
                    var dailyChannel = new PackageChannel("daily", PackageChannelQuality.Prerelease, null, null!, null!, NullLogger.Instance);
                    return Task.FromResult<IEnumerable<PackageChannel>>(new[] { stableChannel, dailyChannel });
                }
            };
        });
 
        using var provider = services.BuildServiceProvider();
 
        // Act
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --quality daily");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        // Assert
        Assert.False(promptForSelectionInvoked, "Channel prompt should not be shown when --quality is provided");
        Assert.NotNull(capturedChannel);
        Assert.Equal("daily", capturedChannel.Name);
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task UpdateCommand_ProjectUpdate_WithInvalidQuality_DisplaysError()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        TestInteractionService? testInteractionService = null;
        
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) =>
                {
                    return Task.FromResult<FileInfo?>(new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")));
                }
            };
 
            options.InteractionServiceFactory = _ =>
            {
                testInteractionService = new TestInteractionService();
                return testInteractionService;
            };
 
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner();
 
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater();
 
            options.PackagingServiceFactory = _ => new TestPackagingService()
            {
                GetChannelsAsyncCallback = (ct) =>
                {
                    // Create test channels matching the expected names
                    var stableChannel = new PackageChannel("stable", PackageChannelQuality.Stable, null, null!, null!, NullLogger.Instance);
                    var dailyChannel = new PackageChannel("daily", PackageChannelQuality.Prerelease, null, null!, null!, NullLogger.Instance);
                    return Task.FromResult<IEnumerable<PackageChannel>>(new[] { stableChannel, dailyChannel });
                }
            };
        });
 
        using var provider = services.BuildServiceProvider();
 
        // Act
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --quality invalid");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        // Assert
        Assert.NotNull(testInteractionService);
        Assert.NotEmpty(testInteractionService.DisplayedErrors);
        var errorMessage = Assert.Single(testInteractionService.DisplayedErrors);
        Assert.Contains("invalid", errorMessage);
        Assert.Contains("stable", errorMessage);
        Assert.Contains("daily", errorMessage);
        Assert.Equal(CliExitCodes.FailedToUpgradeProject, exitCode);
    }
 
    [Fact]
    public async Task UpdateCommand_ProjectUpdate_ChannelTakesPrecedenceOverQuality()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var promptForSelectionInvoked = false;
        PackageChannel? capturedChannel = null;
        
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) =>
                {
                    return Task.FromResult<FileInfo?>(new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")));
                }
            };
 
            options.InteractionServiceFactory = _ => new TestInteractionService()
            {
                PromptForSelectionCallback = (prompt, choices, formatter, ct) =>
                {
                    promptForSelectionInvoked = true;
                    return choices.Cast<PackageChannel>().First();
                }
            };
 
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner();
 
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater()
            {
                UpdateProjectAsyncCallback = (context, cancellationToken) =>
                {
                    capturedChannel = context.Channel;
                    return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = true });
                }
            };
 
            options.PackagingServiceFactory = _ => new TestPackagingService()
            {
                GetChannelsAsyncCallback = (ct) =>
                {
                    var stableChannel = new PackageChannel("stable", PackageChannelQuality.Stable, null, null!, null!, NullLogger.Instance);
                    var dailyChannel = new PackageChannel("daily", PackageChannelQuality.Prerelease, null, null!, null!, NullLogger.Instance);
                    return Task.FromResult<IEnumerable<PackageChannel>>(new[] { stableChannel, dailyChannel });
                }
            };
        });
 
        using var provider = services.BuildServiceProvider();
 
        // Act - specify both --channel and --quality, --channel should win
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --channel stable --quality daily");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        // Assert - should use "stable" from --channel, not "daily" from --quality
        Assert.False(promptForSelectionInvoked, "Channel prompt should not be shown");
        Assert.NotNull(capturedChannel);
        Assert.Equal("stable", capturedChannel.Name);
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task UpdateCommand_ProjectUpdate_WhenCancelled_DisplaysCancellationMessage()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        // Create a hive directory so the channel prompt is shown
        var hivesDir = workspace.CreateDirectory(".aspire").CreateSubdirectory("hives");
        hivesDir.CreateSubdirectory("pr-12345");
 
        var cancellationMessageDisplayed = false;
        
        var wrappedService = new CancellationTrackingInteractionService(new TestInteractionService()
        {
            PromptForSelectionCallback = (prompt, choices, formatter, ct) =>
            {
                // Simulate user pressing Ctrl+C during selection prompt
                throw new OperationCanceledException();
            }
        });
        wrappedService.OnCancellationMessageDisplayed = () => cancellationMessageDisplayed = true;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) =>
                {
                    return Task.FromResult<FileInfo?>(new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")));
                }
            };
 
            options.InteractionServiceFactory = _ => wrappedService;
 
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner();
 
            options.PackagingServiceFactory = _ => new TestPackagingService()
            {
                GetChannelsAsyncCallback = (ct) =>
                {
                    var stableChannel = new PackageChannel("stable", PackageChannelQuality.Stable, null, null!, null!, NullLogger.Instance);
                    return Task.FromResult<IEnumerable<PackageChannel>>(new[] { stableChannel });
                }
            };
        });
 
        using var provider = services.BuildServiceProvider();
 
        // Act
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        // Assert
        Assert.True(cancellationMessageDisplayed, "Cancellation message should have been displayed");
        Assert.Equal(CliExitCodes.Cancelled, exitCode);
    }
 
    [Fact]
    public async Task UpdateCommand_WithoutHives_UsesImplicitChannelWithoutPrompting()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var promptForSelectionInvoked = false;
        var updatedWithChannel = string.Empty;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) =>
                {
                    return Task.FromResult<FileInfo?>(new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")));
                }
            };
 
            options.InteractionServiceFactory = _ => new TestInteractionService()
            {
                PromptForSelectionCallback = (prompt, choices, formatter, ct) =>
                {
                    promptForSelectionInvoked = true;
                    return choices.Cast<object>().First();
                }
            };
 
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner();
 
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater()
            {
                UpdateProjectAsyncCallback = (context, cancellationToken) =>
                {
                    updatedWithChannel = context.Channel.Name;
                    return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = false });
                }
            };
 
            options.PackagingServiceFactory = _ => new TestPackagingService()
            {
                GetChannelsAsyncCallback = (ct) =>
                {
                    var fakeCache = new FakeNuGetPackageCache();
                    var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures(), NullLogger.Instance);
                    return Task.FromResult<IEnumerable<PackageChannel>>(new[] { implicitChannel });
                }
            };
        });
 
        using var provider = services.BuildServiceProvider();
 
        // Act - without hives, should automatically use implicit channel
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        // Assert
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(promptForSelectionInvoked, "Channel selection prompt should not be shown when there are no hives");
        Assert.Equal("default", updatedWithChannel); // Implicit channel is named "default"
    }
 
    [Fact]
    public async Task UpdateCommand_LocalConfiguredChannel_IsUsed()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        // Write a local aspire.config.json that selects the "staging" channel BEFORE the
        // configuration is built so RegisterSettingsFiles picks it up.
        var localConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
        File.WriteAllText(localConfigPath, """{ "channel": "staging" }""");
 
        var (exitCode, updatedWithChannel, promptInvoked) = await RunUpdateAndCaptureChannelAsync(
            workspace,
            updateArgs: "update");
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(promptInvoked, "Channel selection prompt should not be shown when channel is configured locally");
        Assert.Equal("staging", updatedWithChannel);
    }
 
    [Fact]
    public async Task UpdateCommand_GlobalConfiguredChannel_IsUsed()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        // Write a global settings file that selects the "staging" channel. CliTestHelper points
        // the global settings file at <workspace>/.aspire/settings.global.json.
        var globalDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire");
        Directory.CreateDirectory(globalDir);
        var globalSettingsPath = Path.Combine(globalDir, "settings.global.json");
        File.WriteAllText(globalSettingsPath, """{ "channel": "staging" }""");
 
        var (exitCode, updatedWithChannel, promptInvoked) = await RunUpdateAndCaptureChannelAsync(
            workspace,
            updateArgs: "update");
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(promptInvoked, "Channel selection prompt should not be shown when channel is configured globally");
        Assert.Equal("staging", updatedWithChannel);
    }
 
    [Fact]
    public async Task UpdateCommand_ExplicitChannelOverridesConfiguredChannel()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        // Local config says staging, but command line specifies daily; daily must win.
        var localConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
        File.WriteAllText(localConfigPath, """{ "channel": "staging" }""");
 
        var (exitCode, updatedWithChannel, promptInvoked) = await RunUpdateAndCaptureChannelAsync(
            workspace,
            updateArgs: "update --channel daily");
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(promptInvoked, "Channel selection prompt should not be shown when --channel is specified");
        Assert.Equal("daily", updatedWithChannel);
    }
 
    [Fact]
    public async Task UpdateCommand_LocalConfiguredChannel_OverridesGlobalConfiguredChannel()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        // Global says daily, local says staging — local should win.
        var globalDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire");
        Directory.CreateDirectory(globalDir);
        File.WriteAllText(Path.Combine(globalDir, "settings.global.json"), """{ "channel": "daily" }""");
 
        var localConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
        File.WriteAllText(localConfigPath, """{ "channel": "staging" }""");
 
        var (exitCode, updatedWithChannel, promptInvoked) = await RunUpdateAndCaptureChannelAsync(
            workspace,
            updateArgs: "update");
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(promptInvoked, "Channel selection prompt should not be shown when channel is configured");
        Assert.Equal("staging", updatedWithChannel);
    }
 
    [Fact]
    public async Task UpdateCommand_WithoutHives_ConfiguredChannel_TakesPrecedenceOverImplicitFallback()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        // Without PR hives the legacy behavior was to silently pick the implicit "default"
        // channel. With a configured channel present (here global), that configured channel
        // must be used instead of the implicit fallback.
        var globalDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire");
        Directory.CreateDirectory(globalDir);
        File.WriteAllText(Path.Combine(globalDir, "settings.global.json"), """{ "channel": "staging" }""");
 
        var (exitCode, updatedWithChannel, promptInvoked) = await RunUpdateAndCaptureChannelAsync(
            workspace,
            updateArgs: "update");
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(promptInvoked, "Channel selection prompt should not be shown when channel is configured");
        Assert.NotEqual("default", updatedWithChannel);
        Assert.Equal("staging", updatedWithChannel);
    }
 
    [Fact]
    public async Task UpdateCommand_ConfiguredChannelNotInChannelList_ThrowsChannelNotFound()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        // Configure a channel that doesn't exist in the channel list to ensure the
        // configured value is actually consulted (and not silently ignored).
        var localConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
        File.WriteAllText(localConfigPath, """{ "channel": "no-such-channel" }""");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) =>
                {
                    return Task.FromResult<FileInfo?>(new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")));
                }
            };
 
            options.InteractionServiceFactory = _ => new TestInteractionService();
 
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner();
 
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater();
 
            options.PackagingServiceFactory = _ => new TestPackagingService()
            {
                GetChannelsAsyncCallback = (ct) =>
                {
                    var fakeCache = new FakeNuGetPackageCache();
                    return Task.FromResult<IEnumerable<PackageChannel>>(new[]
                    {
                        PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures(), NullLogger.Instance),
                    });
                }
            };
        });
 
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToUpgradeProject, exitCode);
    }
 
    [Fact]
    public async Task UpdateCommand_ChannelStagingRequestedButPackagingServiceReportsUnavailable_SurfacesStagingReason()
    {
        // Regression: https://github.com/microsoft/aspire/issues/16652
        // When `aspire update --channel staging` is run on a daily/local/pr-N CLI, the packaging
        // service refuses to synthesize the staging channel and returns a reason explaining why.
        // UpdateCommand must surface that reason in its error path instead of the generic
        // "No channel found matching 'staging'" message — the generic message hides the actual
        // recovery action (set overrideStagingFeed or install a staging CLI) from the user.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        const string unavailableReason = "FAKE staging unavailable reason (identity=daily)";
 
        TestInteractionService? capturedInteractionService = null;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new TestProjectLocator
            {
                UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) =>
                    Task.FromResult<FileInfo?>(new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")))
            };
 
            options.InteractionServiceFactory = _ =>
            {
                capturedInteractionService = new TestInteractionService();
                return capturedInteractionService;
            };
 
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner();
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater();
 
            options.PackagingServiceFactory = _ => new TestPackagingService
            {
                // Simulate the production PackagingService refusing staging synthesis: staging is
                // omitted from the channel list AND GetStagingChannelUnavailableReason() reports
                // a non-null, user-facing reason.
                GetChannelsAsyncCallback = _ =>
                {
                    var fakeCache = new FakeNuGetPackageCache();
                    return Task.FromResult<IEnumerable<PackageChannel>>(new[]
                    {
                        PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures(), NullLogger.Instance),
                        PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, mappings: null, fakeCache, new TestFeatures(), NullLogger.Instance),
                    });
                },
                GetStagingChannelUnavailableReasonCallback = () => unavailableReason
            };
        });
 
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --channel staging");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToUpgradeProject, exitCode);
 
        Assert.NotNull(capturedInteractionService);
        var errorText = string.Join("\n", capturedInteractionService!.DisplayedErrors);
        Assert.Contains(unavailableReason, errorText);
        Assert.DoesNotContain("No channel found matching", errorText);
    }
 
    [Fact]
    public async Task UpdateCommand_ProjectInOtherDirectory_UsesProjectLocalConfiguredChannel()
    {
        // The CLI runs with `cwd == workspace.WorkspaceRoot` and we point --apphost at a project
        // file living in a sibling directory inside the workspace. The configured channel must
        // come from the project's directory tree, NOT from the cwd's. The cwd has no config.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var projectDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "elsewhere"));
        var projectConfigPath = Path.Combine(projectDirectory.FullName, AspireConfigFile.FileName);
        File.WriteAllText(projectConfigPath, """{ "channel": "staging" }""");
 
        var (exitCode, updatedWithChannel, promptInvoked) = await RunUpdateAndCaptureChannelAsync(
            workspace,
            updateArgs: $"update --apphost {Path.Combine(projectDirectory.FullName, "AppHost.csproj")}",
            projectDirectory: projectDirectory);
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(promptInvoked, "Channel selection prompt should not be shown when channel is configured locally to the project");
        Assert.Equal("staging", updatedWithChannel);
    }
 
    [Fact]
    public async Task UpdateCommand_ProjectInOtherDirectory_UsesNearestParentConfiguredChannelWhenProjectDirectoryHasNoConfig()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        File.WriteAllText(
            Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName),
            """{ "channel": "daily" }""");
 
        var globalDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire");
        Directory.CreateDirectory(globalDir);
        File.WriteAllText(Path.Combine(globalDir, "settings.global.json"), """{ "channel": "staging" }""");
 
        var projectDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "elsewhere"));
 
        var (exitCode, updatedWithChannel, promptInvoked) = await RunUpdateAndCaptureChannelAsync(
            workspace,
            updateArgs: $"update --apphost {Path.Combine(projectDirectory.FullName, "AppHost.csproj")}",
            projectDirectory: projectDirectory);
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(promptInvoked, "Channel selection prompt should not be shown when channel is configured in the nearest parent config");
        Assert.Equal("daily", updatedWithChannel);
    }
 
    [Fact]
    public async Task UpdateCommand_ProjectInOtherDirectory_PrefersProjectLocalConfigOverCwdConfig()
    {
        // Cwd has its own aspire.config.json with a different channel; the project's directory
        // tree has the user's intended channel. The project-relative config must win, otherwise
        // the user's stated intent (per --apphost) is silently overridden by an unrelated cwd.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        File.WriteAllText(
            Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName),
            """{ "channel": "daily" }""");
 
        var projectDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "elsewhere"));
        File.WriteAllText(
            Path.Combine(projectDirectory.FullName, AspireConfigFile.FileName),
            """{ "channel": "staging" }""");
 
        var (exitCode, updatedWithChannel, promptInvoked) = await RunUpdateAndCaptureChannelAsync(
            workspace,
            updateArgs: $"update --apphost {Path.Combine(projectDirectory.FullName, "AppHost.csproj")}",
            projectDirectory: projectDirectory);
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(promptInvoked, "Channel selection prompt should not be shown when channel is configured locally to the project");
        Assert.Equal("staging", updatedWithChannel);
    }
 
    [Fact]
    public async Task UpdateCommand_ProjectInOtherDirectory_ProjectLocalConfigWithoutChannel_FallsBackToGlobalConfig()
    {
        // The project-relative config exists but does not set a "channel" key. Channel resolution
        // must fall back to the global settings file rather than the cwd-based process config.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        File.WriteAllText(
            Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName),
            """{ "channel": "daily" }""");
 
        var globalDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire");
        Directory.CreateDirectory(globalDir);
        File.WriteAllText(Path.Combine(globalDir, "settings.global.json"), """{ "channel": "staging" }""");
 
        var projectDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "elsewhere"));
        File.WriteAllText(
            Path.Combine(projectDirectory.FullName, AspireConfigFile.FileName),
            """{ "language": "csharp" }""");
 
        var (exitCode, updatedWithChannel, promptInvoked) = await RunUpdateAndCaptureChannelAsync(
            workspace,
            updateArgs: $"update --apphost {Path.Combine(projectDirectory.FullName, "AppHost.csproj")}",
            projectDirectory: projectDirectory);
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(promptInvoked, "Channel selection prompt should not be shown when channel is configured globally");
        Assert.Equal("staging", updatedWithChannel);
    }
 
    [Fact]
    public async Task UpdateCommand_WithHivesAndConfiguredChannel_DoesNotPromptForSelection()
    {
        // Hive presence enables the channel picker, but a configured channel (here from
        // the --channel flag) must short-circuit the picker before it is ever surfaced.
        // This is the configured-channel × hive-present intersection that the rest of
        // the channel-resolution tests don't cover — the no-hive tests pass trivially
        // because the prompt branch is unreachable, and the with-hive tests don't
        // configure a channel.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var hivesDir = workspace.CreateDirectory(".aspire").CreateSubdirectory("hives");
        hivesDir.CreateSubdirectory("pr-12345");
 
        var (exitCode, updatedWithChannel, promptInvoked) = await RunUpdateAndCaptureChannelAsync(
            workspace,
            updateArgs: "update --channel daily");
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(promptInvoked, "Channel selection prompt should not be shown when --channel is specified even if hives are present");
        Assert.Equal("daily", updatedWithChannel);
    }
 
    [Fact]
    public async Task UpdateCommand_WithHivesAndLocallyConfiguredChannel_DoesNotPromptForSelection()
    {
        // Same precedence rule, but the channel comes from local aspire.config.json
        // instead of the command line. Covers the (hive-present, project-config-set)
        // intersection which is the most common interactive PR-build flow.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var hivesDir = workspace.CreateDirectory(".aspire").CreateSubdirectory("hives");
        hivesDir.CreateSubdirectory("pr-12345");
 
        File.WriteAllText(
            Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName),
            """{ "channel": "staging" }""");
 
        var (exitCode, updatedWithChannel, promptInvoked) = await RunUpdateAndCaptureChannelAsync(
            workspace,
            updateArgs: "update");
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(promptInvoked, "Channel selection prompt should not be shown when channel is configured locally, even with hives present");
        Assert.Equal("staging", updatedWithChannel);
    }
 
    [Fact]
    public async Task UpdateCommand_WithHives_PromptOffersChannelsInPackagingServiceOrder()
    {
        // Establishes the prompt-content contract: the channels passed to
        // `PromptForSelectionAsync` are exactly the sequence returned by
        // `PackagingService.GetChannelsAsync`, in the same order, with the
        // user-facing label following the documented "Name (SourceDetails)"
        // format. Without this assertion, a regression that reorders channels
        // or relabels them (e.g. drops the SourceDetails suffix) is silently
        // green — every other prompt test on this code path checks only that
        // the callback was invoked.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var hivesDir = workspace.CreateDirectory(".aspire").CreateSubdirectory("hives");
        hivesDir.CreateSubdirectory("pr-12345");
 
        List<PackageChannel>? capturedChoices = null;
        Func<object, string>? capturedFormatter = null;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) =>
                {
                    return Task.FromResult<FileInfo?>(new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")));
                }
            };
 
            options.InteractionServiceFactory = _ => new TestInteractionService()
            {
                PromptForSelectionCallback = (prompt, choices, formatter, ct) =>
                {
                    capturedChoices = choices.Cast<PackageChannel>().ToList();
                    capturedFormatter = formatter;
                    return capturedChoices.First();
                }
            };
 
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner();
 
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater()
            {
                UpdateProjectAsyncCallback = (context, cancellationToken) =>
                {
                    return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = false });
                }
            };
 
            options.PackagingServiceFactory = _ => new TestPackagingService()
            {
                GetChannelsAsyncCallback = (ct) =>
                {
                    var fakeCache = new FakeNuGetPackageCache();
                    var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures(), NullLogger.Instance);
                    var stableChannel = PackageChannel.CreateExplicitChannel("stable", PackageChannelQuality.Stable, mappings: null, fakeCache, new TestFeatures(), NullLogger.Instance);
                    var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, mappings: null, fakeCache, new TestFeatures(), NullLogger.Instance);
                    var hiveChannel = PackageChannel.CreateExplicitChannel("pr-12345", PackageChannelQuality.Both, mappings: null, fakeCache, new TestFeatures(), NullLogger.Instance);
                    return Task.FromResult<IEnumerable<PackageChannel>>(new[] { implicitChannel, stableChannel, dailyChannel, hiveChannel });
                }
            };
        });
 
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update");
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.NotNull(capturedChoices);
        Assert.NotNull(capturedFormatter);
 
        // Channels must be presented in the order PackagingService returns them.
        // The implicit channel's display name is "default" (see PackageChannelNames).
        Assert.Equal(
            new[] { "default", "stable", "daily", "pr-12345" },
            capturedChoices!.Select(c => c.Name).ToArray());
 
        // Each prompt row must include both the channel name and its source details
        // (the documented "Name (SourceDetails)" format from UpdateCommand.cs).
        foreach (var channel in capturedChoices)
        {
            var rendered = capturedFormatter!(channel);
            Assert.Contains(channel.Name, rendered);
            Assert.Contains(channel.SourceDetails, rendered);
        }
    }
 
    // ------------------------------------------------------------------
    // `aspire update` is the recovery command for an AppHost whose pinned
    // Aspire.AppHost.Sdk version no longer resolves, so it must be able
    // to locate the configured AppHost without first round-tripping
    // through MSBuild validation. ProjectLocator.GetAppHostFromSettingsAsync
    // trusts the path recorded in settings;
    // UseOrFindAppHostProjectFileAsync runs the strict discovery path
    // (ValidateAppHostAsync → DotNetCliRunner.GetAppHostInformationAsync)
    // which surfaces "No buildable AppHosts were found" when the SDK pin
    // is broken — exactly the state this command exists to repair.
    //
    // The contract this test locks down: when no AppHost is passed
    // explicitly, `aspire update` consults the settings lookup first and
    // its result short-circuits the strict discovery path.
    // ------------------------------------------------------------------
    [Fact]
    public async Task UpdateCommand_WhenAppHostSdkVersionUnresolvable_UsesSettingsLookup()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var settingsLookupCalled = false;
        var discoveryPathCalled = false;
        var resolved = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                GetAppHostFromSettingsAsyncCallback = _ =>
                {
                    settingsLookupCalled = true;
                    return Task.FromResult<FileInfo?>(resolved);
                },
                // Discovery path would invoke MSBuild validation in production; it
                // must not be reached when settings lookup already returned a path.
                UseOrFindAppHostProjectFileAsyncCallback = (_, _, _) =>
                {
                    discoveryPathCalled = true;
                    return Task.FromResult<FileInfo?>(null);
                }
            };
 
            options.InteractionServiceFactory = _ => new TestInteractionService();
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner();
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater();
            options.PackagingServiceFactory = _ => new TestPackagingService();
        });
 
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update");
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(settingsLookupCalled, "Expected UpdateCommand to consult GetAppHostFromSettingsAsync before the strict discovery path.");
        Assert.False(discoveryPathCalled, "Expected UpdateCommand to short-circuit discovery when settings lookup returns a path.");
    }
 
    // ------------------------------------------------------------------
    // Identity-channel fallback contract: when no channel is supplied on
    // the command line and neither the per-project nor the global
    // "channel" config pins one, `aspire update` falls back to the
    // running CLI's identity channel (the value baked into the assembly
    // via AssemblyMetadata("AspireCliChannel", ...)) before reaching the
    // implicit/default channel. Without that fallback, a `pr-<N>` or
    // `daily` CLI updating an AppHost that has nothing pinning the
    // channel silently lands on the Implicit ("default") channel, which
    // resolves Aspire packages from public NuGet and effectively moves
    // the AppHost to daily even though the running CLI knows which
    // channel it shipped from.
    //
    // The tests below pin down the matrix: identity-channel match wins
    // for `daily` and `pr-<N>`; `local` is intentionally skipped so a
    // developer-built CLI cannot pin a real project to a hive that only
    // exists on that machine; an identity that does not match a
    // registered channel falls through to the existing prompt/implicit
    // logic; explicit `--channel` and per-project config still override
    // identity.
    // ------------------------------------------------------------------
    [Fact]
    public async Task UpdateCommand_WhenStagingIdentityRegistersChannel_UsesStagingForUnpinnedProject()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var promptForSelectionInvoked = false;
        var updatedWithChannel = string.Empty;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: PackageChannelNames.Staging);
 
            // A real staging build always bakes the build's commit hash into its
            // AssemblyInformationalVersion (e.g. "13.4.0-preview.1.26280.6+<sha>"), and the staging
            // identity now routes to that build's SHA-specific darc-pub-microsoft-aspire-<sha> feed
            // regardless of version shape. The test host assembly has no +<commit> metadata, so the
            // feed could not be derived and the staging channel would never be synthesized. Provide a
            // stamped informational version override so the derivation matches a real staging build.
            options.ConfigurationCallback += config =>
            {
                config[PackagingService.OverrideCliInformationalVersionConfigKey] = "13.4.0-preview.1.26280.6+2574ef57e97fc393aff67592fd442afca6a6d02f";
            };
 
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) =>
                {
                    return Task.FromResult<FileInfo?>(new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")));
                }
            };
 
            options.InteractionServiceFactory = _ => new TestInteractionService()
            {
                PromptForSelectionCallback = (prompt, choices, formatter, ct) =>
                {
                    promptForSelectionInvoked = true;
                    return choices.Cast<object>().First();
                }
            };
 
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner();
            options.NuGetPackageCacheFactory = _ => new FakeNuGetPackageCache();
 
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater()
            {
                UpdateProjectAsyncCallback = (context, cancellationToken) =>
                {
                    updatedWithChannel = context.Channel.Name;
                    return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = false });
                }
            };
        });
 
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(promptForSelectionInvoked, "Staging identity should resolve through the registered staging channel without prompting.");
        Assert.Equal(PackageChannelNames.Staging, updatedWithChannel);
    }
 
    [Fact]
    public async Task UpdateCommand_WhenAppHostOutsideLaunchDirectoryConfiguresStaging_UsesStagingFromRealPackagingService()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "elsewhere"));
        var appHostFile = new FileInfo(Path.Combine(projectDirectory.FullName, "AppHost.csproj"));
        File.WriteAllText(appHostFile.FullName, string.Empty);
        File.WriteAllText(
            Path.Combine(projectDirectory.FullName, AspireConfigFile.FileName),
            """{ "channel": "staging" }""");
 
        var promptForSelectionInvoked = false;
        var updatedWithChannel = string.Empty;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: PackageChannelNames.Stable);
            options.NuGetPackageCacheFactory = _ => new FakeNuGetPackageCache();
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (_, _, _) => Task.FromResult<FileInfo?>(appHostFile)
            };
            options.InteractionServiceFactory = _ => new TestInteractionService()
            {
                PromptForSelectionCallback = (prompt, choices, formatter, ct) =>
                {
                    promptForSelectionInvoked = true;
                    return choices.Cast<object>().First();
                }
            };
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner();
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater()
            {
                UpdateProjectAsyncCallback = (context, cancellationToken) =>
                {
                    updatedWithChannel = context.Channel.Name;
                    return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = false });
                }
            };
        });
 
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"update --apphost \"{appHostFile.FullName}\"");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(promptForSelectionInvoked, "Project-local staging config should resolve without falling through to channel selection.");
        Assert.Equal(PackageChannelNames.Staging, updatedWithChannel);
    }
 
    [Theory]
    [InlineData("pr-12345", "pr-12345")]
    [InlineData("daily", "daily")]
    [InlineData("DAILY", "daily")] // case-insensitive match against allChannels
    public async Task UpdateCommand_WhenIdentityChannelMatchesRegisteredChannel_UsesItWithoutPrompting(string identityChannel, string expectedChannelName)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        // Create a hive so pr-* identities have a registered channel to match and so
        // the identity fallback proves it bypasses the prompt when a prompt would
        // otherwise be available.
        var hivesDir = workspace.CreateDirectory(".aspire").CreateSubdirectory("hives");
        hivesDir.CreateSubdirectory("pr-12345");
 
        var (exitCode, updatedWithChannel, promptInvoked) = await RunUpdateAndCaptureChannelAsync(
            workspace,
            updateArgs: "update",
            identityChannel: identityChannel);
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(promptInvoked, "Identity-channel match should bypass the channel prompt.");
        Assert.Equal(expectedChannelName, updatedWithChannel);
    }
 
    [Fact]
    public async Task UpdateCommand_WhenIdentityChannelIsLocal_StillPromptsWhenHivesExist()
    {
        // A developer-built CLI must NOT silently pin a real project to a
        // hive that only exists on that machine. Even though "local" is
        // technically a registered channel name, identity-match deliberately
        // skips it and lets the prompt run.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var hivesDir = workspace.CreateDirectory(".aspire").CreateSubdirectory("hives");
        hivesDir.CreateSubdirectory("pr-12345");
 
        var (exitCode, _, promptInvoked) = await RunUpdateAndCaptureChannelAsync(
            workspace,
            updateArgs: "update",
            identityChannel: PackageChannelNames.Local,
            includeLocalInChannels: true);
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(promptInvoked, "Local-identity CLI must still prompt for channel selection when hives exist.");
    }
 
    [Fact]
    public async Task UpdateCommand_WhenIdentityChannelHasNoMatchingChannel_FallsThroughToPrompt()
    {
        // A stale PR identity (e.g. the matching hive was removed) must not
        // crash — it falls through to the prompt/implicit logic the user
        // already gets when no identity-match exists.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var hivesDir = workspace.CreateDirectory(".aspire").CreateSubdirectory("hives");
        hivesDir.CreateSubdirectory("pr-12345");
 
        var (exitCode, _, promptInvoked) = await RunUpdateAndCaptureChannelAsync(
            workspace,
            updateArgs: "update",
            identityChannel: "pr-99999");
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(promptInvoked, "Unregistered identity channel must fall through to the existing prompt.");
    }
 
    [Fact]
    public async Task UpdateCommand_ExplicitChannelFlagOverridesIdentityChannel()
    {
        // Identity is daily; --channel staging wins because --channel is
        // step 1 in the resolution precedence and identity-match only runs
        // when steps 1-3 have all missed.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var hivesDir = workspace.CreateDirectory(".aspire").CreateSubdirectory("hives");
        hivesDir.CreateSubdirectory("pr-12345");
 
        var (exitCode, updatedWithChannel, promptInvoked) = await RunUpdateAndCaptureChannelAsync(
            workspace,
            updateArgs: "update --channel staging",
            identityChannel: PackageChannelNames.Daily);
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(promptInvoked);
        Assert.Equal("staging", updatedWithChannel);
    }
 
    [Fact]
    public async Task UpdateCommand_PerProjectConfigChannelOverridesIdentityChannel()
    {
        // Identity is daily; per-project aspire.config.json#channel=staging
        // wins because step 2 takes precedence over identity-match.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var hivesDir = workspace.CreateDirectory(".aspire").CreateSubdirectory("hives");
        hivesDir.CreateSubdirectory("pr-12345");
 
        File.WriteAllText(
            Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName),
            """{ "channel": "staging" }""");
 
        var (exitCode, updatedWithChannel, promptInvoked) = await RunUpdateAndCaptureChannelAsync(
            workspace,
            updateArgs: "update",
            identityChannel: PackageChannelNames.Daily);
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(promptInvoked);
        Assert.Equal("staging", updatedWithChannel);
    }
 
    private Task<(int ExitCode, string UpdatedWithChannel, bool PromptInvoked)> RunUpdateAndCaptureChannelAsync(
        TemporaryWorkspace workspace,
        string updateArgs)
    {
        return RunUpdateAndCaptureChannelAsync(workspace, updateArgs, projectDirectory: workspace.WorkspaceRoot);
    }
 
    private Task<(int ExitCode, string UpdatedWithChannel, bool PromptInvoked)> RunUpdateAndCaptureChannelAsync(
        TemporaryWorkspace workspace,
        string updateArgs,
        string identityChannel,
        bool includeLocalInChannels = false)
    {
        return RunUpdateAndCaptureChannelAsync(
            workspace,
            updateArgs,
            projectDirectory: workspace.WorkspaceRoot,
            identityChannel: identityChannel,
            includeLocalInChannels: includeLocalInChannels);
    }
 
    private async Task<(int ExitCode, string UpdatedWithChannel, bool PromptInvoked)> RunUpdateAndCaptureChannelAsync(
        TemporaryWorkspace workspace,
        string updateArgs,
        DirectoryInfo projectDirectory,
        string identityChannel = "local",
        bool includeLocalInChannels = false)
    {
        var promptForSelectionInvoked = false;
        var updatedWithChannel = string.Empty;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: identityChannel);
 
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) =>
                {
                    return Task.FromResult<FileInfo?>(new FileInfo(Path.Combine(projectDirectory.FullName, "AppHost.csproj")));
                }
            };
 
            options.InteractionServiceFactory = _ => new TestInteractionService()
            {
                PromptForSelectionCallback = (prompt, choices, formatter, ct) =>
                {
                    promptForSelectionInvoked = true;
                    return choices.Cast<object>().First();
                }
            };
 
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner();
 
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater()
            {
                UpdateProjectAsyncCallback = (context, cancellationToken) =>
                {
                    updatedWithChannel = context.Channel.Name;
                    return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = false });
                }
            };
 
            options.PackagingServiceFactory = _ => new TestPackagingService()
            {
                GetChannelsAsyncCallback = (ct) =>
                {
                    var fakeCache = new FakeNuGetPackageCache();
                    var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures(), NullLogger.Instance);
                    var stagingChannel = PackageChannel.CreateExplicitChannel("staging", PackageChannelQuality.Stable, mappings: null, fakeCache, new TestFeatures(), NullLogger.Instance);
                    var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Both, mappings: null, fakeCache, new TestFeatures(), NullLogger.Instance);
                    var channels = new List<PackageChannel> { implicitChannel, stagingChannel, dailyChannel };
 
                    // Optional pr-* and local channels for identity-channel tests. Production
                    // PackagingService enumerates pr-* hives from disk; we register them here
                    // so the identity-match lookup in UpdateCommand has something to match.
                    var hivesDirectory = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives"));
                    if (hivesDirectory.Exists)
                    {
                        foreach (var hive in hivesDirectory.GetDirectories())
                        {
                            if (hive.Name.StartsWith("pr-", StringComparison.OrdinalIgnoreCase))
                            {
                                channels.Add(PackageChannel.CreateExplicitChannel(hive.Name, PackageChannelQuality.Both, mappings: null, fakeCache, new TestFeatures(), NullLogger.Instance));
                            }
                        }
                    }
 
                    if (includeLocalInChannels)
                    {
                        channels.Add(PackageChannel.CreateExplicitChannel(PackageChannelNames.Local, PackageChannelQuality.Both, mappings: null, fakeCache, new TestFeatures(), NullLogger.Instance));
                    }
 
                    return Task.FromResult<IEnumerable<PackageChannel>>(channels);
                }
            };
        });
 
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse(updateArgs);
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        return (exitCode, updatedWithChannel, promptForSelectionInvoked);
    }
 
    [Fact]
    public async Task UpdateCommand_SelfUpdate_WhenCancelled_DisplaysCancellationMessage()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        // Create a hive directory so the channel prompt is shown
        var hivesDir = workspace.CreateDirectory(".aspire").CreateSubdirectory("hives");
        hivesDir.CreateSubdirectory("pr-12345");
 
        var cancellationMessageDisplayed = false;
        
        var wrappedService = new CancellationTrackingInteractionService(new TestInteractionService()
        {
            PromptForSelectionCallback = (prompt, choices, formatter, ct) =>
            {
                // Simulate user pressing Ctrl+C during channel selection prompt
                throw new OperationCanceledException();
            }
        });
        wrappedService.OnCancellationMessageDisplayed = () => cancellationMessageDisplayed = true;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => wrappedService;
 
            options.CliDownloaderFactory = _ => new TestCliDownloader(workspace.WorkspaceRoot);
        });
 
        using var provider = services.BuildServiceProvider();
 
        // Act
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --self");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        // Assert
        Assert.True(cancellationMessageDisplayed, "Cancellation message should have been displayed");
        Assert.Equal(CliExitCodes.Cancelled, exitCode);
    }
 
    [Fact]
    public async Task UpdateCommand_SelfUpdate_WhenStagingFeatureFlagDisabled_DoesNotShowStagingChannel()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        IEnumerable? capturedChoices = null;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => new TestInteractionService()
            {
                PromptForSelectionCallback = (prompt, choices, formatter, ct) =>
                {
                    capturedChoices = choices;
                    return PackageChannelNames.Stable;
                }
            };
 
            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<RootCommand>();
        var result = command.Parse("update --self");
 
        await result.InvokeAsync().DefaultTimeout();
 
        Assert.NotNull(capturedChoices);
        var channelList = capturedChoices.Cast<string>().ToList();
        Assert.DoesNotContain(PackageChannelNames.Staging, channelList);
        Assert.Contains(PackageChannelNames.Stable, channelList);
        Assert.Contains(PackageChannelNames.Daily, channelList);
    }
 
    [Fact]
    public async Task UpdateCommand_SelfUpdate_WhenStagingFeatureFlagEnabled_ShowsStagingChannel()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        IEnumerable? capturedChoices = null;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.EnabledFeatures = [KnownFeatures.StagingChannelEnabled];
 
            options.InteractionServiceFactory = _ => new TestInteractionService()
            {
                PromptForSelectionCallback = (prompt, choices, formatter, ct) =>
                {
                    capturedChoices = choices;
                    return PackageChannelNames.Stable;
                }
            };
 
            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<RootCommand>();
        var result = command.Parse("update --self");
 
        await result.InvokeAsync().DefaultTimeout();
 
        Assert.NotNull(capturedChoices);
        var channelList = capturedChoices.Cast<string>().ToList();
        Assert.Contains(PackageChannelNames.Staging, channelList);
        Assert.Contains(PackageChannelNames.Stable, channelList);
        Assert.Contains(PackageChannelNames.Daily, channelList);
    }
 
    [Fact]
    public async Task UpdateCommand_SelfUpdate_WhenIdentityChannelIsStaging_ShowsStagingChannel()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        IEnumerable? capturedChoices = null;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: PackageChannelNames.Staging);
 
            options.InteractionServiceFactory = _ => new TestInteractionService()
            {
                PromptForSelectionCallback = (prompt, choices, formatter, ct) =>
                {
                    capturedChoices = choices;
                    return PackageChannelNames.Stable;
                }
            };
 
            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<RootCommand>();
        var result = command.Parse("update --self");
 
        await result.InvokeAsync().DefaultTimeout();
 
        Assert.NotNull(capturedChoices);
        var channelList = capturedChoices.Cast<string>().ToList();
        Assert.Contains(PackageChannelNames.Staging, channelList);
        Assert.Contains(PackageChannelNames.Stable, channelList);
        Assert.Contains(PackageChannelNames.Daily, channelList);
    }
 
    [Fact]
    public async Task UpdateCommand_SelfOption_IsAvailableAndParseable()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliDownloaderFactory = _ => new TestCliDownloader(workspace.WorkspaceRoot)
            {
                DownloadLatestCliAsyncCallback = (channel, ct) =>
                {
                    // Create a fake archive file
                    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<RootCommand>();
        
        // Act - Parse command with --self option
        var result = command.Parse("update --self --channel stable");
        
        // Assert - Command should parse successfully without errors
        Assert.Empty(result.Errors);
    }
 
    [Fact]
    public async Task UpdateCommand_NonInteractive_WithYesAndChannel_SucceedsWithoutPrompting()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var promptForSelectionInvoked = false;
        var confirmCallbackInvoked = false;
        PackageChannel? capturedChannel = null;
        UpdatePackagesContext? capturedContext = null;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new TestProjectLocator()
            {
                UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) =>
                {
                    return Task.FromResult<FileInfo?>(new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj")));
                }
            };
 
            options.InteractionServiceFactory = _ => new TestInteractionService()
            {
                PromptForSelectionCallback = (prompt, choices, formatter, ct) =>
                {
                    promptForSelectionInvoked = true;
                    return choices.Cast<object>().First();
                },
                ConfirmCallback = (prompt, defaultValue) =>
                {
                    confirmCallbackInvoked = true;
                    return true;
                }
            };
 
            options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner();
 
            options.ProjectUpdaterFactory = _ => new TestProjectUpdater()
            {
                UpdateProjectAsyncCallback = (context, cancellationToken) =>
                {
                    capturedContext = context;
                    capturedChannel = context.Channel;
                    return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = false });
                }
            };
 
            options.PackagingServiceFactory = _ => new TestPackagingService()
            {
                GetChannelsAsyncCallback = (ct) =>
                {
                    var stableChannel = new PackageChannel("stable", PackageChannelQuality.Stable, null, null!, null!, NullLogger.Instance);
                    var dailyChannel = new PackageChannel("daily", PackageChannelQuality.Prerelease, null, null!, null!, NullLogger.Instance);
                    return Task.FromResult<IEnumerable<PackageChannel>>([stableChannel, dailyChannel]);
                }
            };
        });
 
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("update --yes --channel stable --non-interactive");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(promptForSelectionInvoked, "No selection prompt should be shown in non-interactive mode with --channel");
        Assert.False(confirmCallbackInvoked, "No confirm prompt should be shown in non-interactive mode with --yes");
        Assert.NotNull(capturedChannel);
        Assert.Equal("stable", capturedChannel.Name);
        Assert.NotNull(capturedContext);
    }
 
    [Theory]
    [InlineData("daily", "daily")]
    [InlineData("stable", "stable")]
    [InlineData("DAILY", "daily")] // case-insensitive match; canonical name from channels
    public async Task UpdateCommand_SelfUpdate_NonInteractive_WhenIdentityChannelMatchesKnownChannel_UsesItWithoutPrompting(string identityChannel, string expectedChannel)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var (_, capturedChannel, promptInvoked, _) = await RunNonInteractiveSelfUpdateAsync(
            workspace, identityChannel: identityChannel);
 
        Assert.False(promptInvoked, "Identity-channel match should bypass the channel prompt.");
        Assert.Equal(expectedChannel, capturedChannel);
    }
 
    [Fact]
    public async Task UpdateCommand_SelfUpdate_NonInteractive_WhenIdentityChannelIsLocal_DefaultsToStable()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var (_, capturedChannel, promptInvoked, _) = await RunNonInteractiveSelfUpdateAsync(
            workspace, identityChannel: PackageChannelNames.Local);
 
        Assert.False(promptInvoked, "Non-interactive mode should not prompt; should default to stable.");
        Assert.Equal(PackageChannelNames.Stable, capturedChannel);
    }
 
    [Fact]
    public async Task UpdateCommand_SelfUpdate_NonInteractive_WhenIdentityChannelIsStalePr_RequiresExplicitChannel()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var (exitCode, capturedChannel, promptInvoked, interactionService) = await RunNonInteractiveSelfUpdateAsync(
            workspace, identityChannel: "pr-99999");
 
        Assert.Equal(CliExitCodes.MissingRequiredArgument, exitCode);
        Assert.False(promptInvoked, "Non-interactive mode should not prompt when channel cannot be resolved.");
        Assert.Null(capturedChannel);
        Assert.Contains(
            interactionService.DisplayedErrors,
            e => e.Contains("--channel", StringComparison.Ordinal) && e.Contains("non-interactive", StringComparison.OrdinalIgnoreCase));
    }
 
    [Fact]
    public async Task UpdateCommand_SelfUpdate_ExplicitChannelOverridesIdentityChannel()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var (_, capturedChannel, promptInvoked, _) = await RunNonInteractiveSelfUpdateAsync(
            workspace, identityChannel: PackageChannelNames.Daily, updateArgs: "update --self --non-interactive --channel stable -y");
 
        Assert.False(promptInvoked, "Explicit --channel should bypass the prompt.");
        Assert.Equal(PackageChannelNames.Stable, capturedChannel);
    }
 
    [Fact]
    public async Task UpdateCommand_SelfUpdate_NonInteractive_WhenIdentityChannelIsStalePr_ExplicitChannelSucceeds()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var (_, capturedChannel, promptInvoked, _) = await RunNonInteractiveSelfUpdateAsync(
            workspace, identityChannel: "pr-99999", updateArgs: "update --self --non-interactive --channel daily -y");
 
        Assert.False(promptInvoked, "Explicit --channel should bypass the prompt even with stale identity.");
        Assert.Equal(PackageChannelNames.Daily, capturedChannel);
    }
 
    private async Task<(int ExitCode, string? CapturedChannel, bool PromptInvoked, TestInteractionService InteractionService)> RunNonInteractiveSelfUpdateAsync(
        TemporaryWorkspace workspace,
        string identityChannel,
        string updateArgs = "update --self --non-interactive -y")
    {
        var promptForSelectionInvoked = false;
        string? capturedChannel = null;
        TestInteractionService? interactionService = null;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.CliExecutionContextFactory = _ => workspace.CreateExecutionContext(identityChannel: identityChannel);
 
            options.InteractionServiceFactory = _ =>
            {
                interactionService = new TestInteractionService()
                {
                    PromptForSelectionCallback = (prompt, choices, formatter, ct) =>
                    {
                        promptForSelectionInvoked = true;
                        return PackageChannelNames.Stable;
                    }
                };
                return interactionService;
            };
 
            options.CliDownloaderFactory = _ => new TestCliDownloader(workspace.WorkspaceRoot)
            {
                DownloadLatestCliAsyncCallback = (channel, ct) =>
                {
                    capturedChannel = channel;
                    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<RootCommand>();
        var result = command.Parse(updateArgs);
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        return (exitCode, capturedChannel, promptForSelectionInvoked, interactionService!);
    }
 
    private static async Task<string> CreateSelfUpdateArchiveAsync(
        TemporaryWorkspace workspace,
        bool deletePreparedSidecarDuringVersionProbe = false)
    {
        var contentDirectory = workspace.CreateDirectory("self-update-content");
        var executablePath = Path.Combine(contentDirectory.FullName, "aspire");
        var deletePreparedSidecarCommand = deletePreparedSidecarDuringVersionProbe
            ? """rm -f "$(dirname "$0")"/.aspire-install.json.*.tmp"""
            : "";
        await File.WriteAllTextAsync(
            executablePath,
            $$"""
            #!/bin/sh
            if [ "${1:-}" = "--version" ]; then
                {{deletePreparedSidecarCommand}}
                echo "13.5.0"
                exit 0
            fi
            exit 1
            """);
        SetUnixExecutableMode(executablePath);
 
        var archiveDirectory = workspace.CreateDirectory("self-update-download");
        var tarPath = Path.Combine(archiveDirectory.FullName, "aspire.tar");
        TarFile.CreateFromDirectory(contentDirectory.FullName, tarPath, includeBaseDirectory: false);
 
        var archivePath = Path.Combine(archiveDirectory.FullName, "aspire.tar.gz");
        await using var tarStream = File.OpenRead(tarPath);
        await using var archiveStream = File.Create(archivePath);
        await using (var gzipStream = new GZipStream(archiveStream, CompressionLevel.Fastest, leaveOpen: true))
        {
            await tarStream.CopyToAsync(gzipStream);
        }
 
        return archivePath;
    }
 
    private static void SetUnixExecutableMode(string path)
    {
        if (OperatingSystem.IsWindows())
        {
            throw new PlatformNotSupportedException();
        }
 
        File.SetUnixFileMode(
            path,
            UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
            UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
            UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
    }
 
    private static string CreateCustomToolPathInstall(string toolPath)
    {
        var processPath = Path.Combine(toolPath, GetAspireExecutableName());
        var storeExecutablePath = Path.Combine(
            toolPath,
            ".store",
            "aspire.cli",
            "9.4.0",
            "aspire.cli.linux-x64",
            "9.4.0",
            "tools",
            "net10.0",
            "linux-x64",
            GetAspireExecutableName());
 
        Directory.CreateDirectory(toolPath);
        Directory.CreateDirectory(Path.GetDirectoryName(storeExecutablePath)!);
        File.WriteAllText(processPath, string.Empty);
        File.WriteAllText(storeExecutablePath, string.Empty);
 
        return processPath;
    }
 
    private static string CreateNixInstall(string installPath)
    {
        var binaryDir = Path.Combine(installPath, "nix", "store", "hash-aspire-cli", "lib", "aspire-cli");
        Directory.CreateDirectory(binaryDir);
 
        var processPath = Path.Combine(binaryDir, GetAspireExecutableName());
        File.WriteAllText(processPath, string.Empty);
        File.WriteAllText(Path.Combine(binaryDir, InstallSidecarReader.SidecarFileName), """{"source":"nix"}""");
 
        return processPath;
    }
 
    private static void AssertNixUpdateGuidance(TestInteractionService interactionService, bool expectProjectUpdateSkippedMessage)
    {
        Assert.Collection(
            interactionService.DisplayedPlainText,
            text => Assert.Equal("  nix profile upgrade aspire-cli", text),
            text => Assert.Equal("  nix flake update <input-name>", text));
 
        string[] expectedMessages = expectProjectUpdateSkippedMessage
            ? [
                UpdateCommandStrings.NixSelfUpdateMessage,
                UpdateCommandStrings.ProjectUpdateSkippedAfterCliUpdateMessage
            ]
            : [UpdateCommandStrings.NixSelfUpdateMessage];
 
        Assert.Equal(
            expectedMessages,
            interactionService.DisplayedMessages.Select(message => message.Message));
    }
 
    private static string GetAspireExecutableName()
    {
        return OperatingSystem.IsWindows() ? "aspire.exe" : "aspire";
    }
 
    private static IReadOnlyDictionary<string, string?> CreateNpmInstallEnvironment()
    {
        return new Dictionary<string, string?>
        {
            [NpmInstallDetection.PackageEnvironmentVariableName] = NpmInstallDetection.ExpectedPackageName,
            [NpmInstallDetection.PackageVersionEnvironmentVariableName] = "9.4.0",
            [NpmInstallDetection.PackageRidEnvironmentVariableName] = "linux-x64"
        };
    }
 
    // `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.CreateForCli(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<RootCommand>();
        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);
    }
 
    // Pre-S9 the `stable` channel also triggered DeleteConfigurationAsync("channel", isGlobal: true)
    // to roll back any prior write. That rollback path is covered by the stable row of
    // UpdateCommand_SelfUpdate_DoesNotWriteChannelToGlobalConfiguration above (asserts both
    // DoesNotContain set + DoesNotContain delete) — no standalone test required here.
 
    private static PackageChannel CreatePackageChannelWithGuestSdkVersion(string sdkVersion, string? cliDownloadBaseUrl)
    {
        var fakeCache = new FakeNuGetPackageCache
        {
            GetPackagesAsyncCallback = (_, packageId, _, _, _, _, _) =>
                Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>(
                [
                    new Aspire.Shared.NuGetPackageCli { Id = packageId, Version = sdkVersion, Source = "test" }
                ])
        };
 
        return PackageChannel.CreateExplicitChannel(
            "stable",
            PackageChannelQuality.Stable,
            [new PackageMapping("Aspire*", "https://api.nuget.org/v3/index.json")],
            fakeCache,
            features: new TestFeatures(),
            NullLogger.Instance,
            configureGlobalPackagesFolder: false,
            cliDownloadBaseUrl: cliDownloadBaseUrl);
    }
 
    private static void UseProcessPath(CliServiceCollectionTestOptions options, string? processPath)
    {
        options.ProcessPathProviderFactory = _ => new TestProcessPathProvider(processPath);
    }
 
    public enum NixSelfUpdateEntryPoint
    {
        AfterProjectUpdate,
        NoProjectFound,
        BeforeGuestProjectUpdate
    }
 
}
 
// Helper class to track DisplayCancellationMessage calls
internal sealed class CancellationTrackingInteractionService : IInteractionService
{
    private readonly IInteractionService _innerService;
 
    public ConsoleOutput Console
    {
        get => _innerService.Console;
        set => _innerService.Console = value;
    }
 
    public bool SupportsLinks => _innerService.SupportsLinks;
 
    public Action? OnCancellationMessageDisplayed { get; set; }
 
    public CancellationTrackingInteractionService(IInteractionService innerService)
    {
        _innerService = innerService;
    }
 
    public Task<T> ShowStatusAsync<T>(string statusText, Func<Task<T>> action, KnownEmoji? emoji = null, bool allowMarkup = false) => _innerService.ShowStatusAsync(statusText, action, emoji, allowMarkup);
    public Task<T> ShowDynamicStatusAsync<T>(string initialStatusText, Func<Action<string>, Task<T>> action, KnownEmoji? emoji = null) => _innerService.ShowDynamicStatusAsync(initialStatusText, action, emoji);
    public void ShowStatus(string statusText, Action action, KnownEmoji? emoji = null, bool allowMarkup = false) => _innerService.ShowStatus(statusText, action, emoji, allowMarkup);
    public Task<string> PromptForStringAsync(string promptText, Func<string, ValidationResult>? validator = null, bool isSecret = false, bool required = false, PromptBinding<string?>? binding = null, CancellationToken cancellationToken = default) 
        => _innerService.PromptForStringAsync(promptText, validator, isSecret, required, binding, cancellationToken);
    public Task<string> PromptForFilePathAsync(string promptText, Func<string, ValidationResult>? validator = null, bool directory = false, bool required = false, PromptBinding<string?>? binding = null, bool retryOnValidationFailure = false, CancellationToken cancellationToken = default)
        => _innerService.PromptForFilePathAsync(promptText, validator, directory, required, binding, retryOnValidationFailure, cancellationToken);
    public Task<bool> PromptConfirmAsync(string promptText, PromptBinding<bool>? binding = null, CancellationToken cancellationToken = default) 
        => _innerService.PromptConfirmAsync(promptText, binding, cancellationToken);
    public Task<T> PromptForSelectionAsync<T>(string promptText, IEnumerable<T> choices, Func<T, string> choiceFormatter, PromptBinding<string?>? binding = null, bool echoSelected = true, CancellationToken cancellationToken = default) where T : notnull 
        => _innerService.PromptForSelectionAsync(promptText, choices, choiceFormatter, binding, echoSelected, cancellationToken);
    public Task<IReadOnlyList<T>> PromptForSelectionsAsync<T>(string promptText, IEnumerable<T> choices, Func<T, string> choiceFormatter, IEnumerable<T>? preSelected = null, bool optional = false, PromptBinding<string?>? binding = null, bool echoSelected = true, IEnumerable<T>? bindingChoices = null, CancellationToken cancellationToken = default) where T : notnull 
        => _innerService.PromptForSelectionsAsync(promptText, choices, choiceFormatter, preSelected, optional, binding, echoSelected, bindingChoices, cancellationToken);
    public int DisplayIncompatibleVersionError(AppHostIncompatibleException ex, string appHostHostingVersion) 
        => _innerService.DisplayIncompatibleVersionError(ex, appHostHostingVersion);
    public void DisplayError(string errorMessage, bool allowMarkup = false) => _innerService.DisplayError(errorMessage, allowMarkup);
    public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false, ConsoleOutput? consoleOverride = null) => _innerService.DisplayMessage(emoji, message, allowMarkup, consoleOverride);
    public void DisplayPlainText(string text) => _innerService.DisplayPlainText(text);
    public void DisplayRawText(string text, ConsoleOutput? consoleOverride = null) => _innerService.DisplayRawText(text, consoleOverride);
    public void DisplayMarkdown(string markdown, ConsoleOutput? consoleOverride = null, int? maxWidth = null) => _innerService.DisplayMarkdown(markdown, consoleOverride, maxWidth);
    public void DisplayMarkupLine(string markup) => _innerService.DisplayMarkupLine(markup);
    public void DisplaySuccess(string message, bool allowMarkup = false) => _innerService.DisplaySuccess(message, allowMarkup);
    public void DisplaySubtleMessage(string message, bool allowMarkup = false) => _innerService.DisplaySubtleMessage(message, allowMarkup);
    public void DisplayLines(IEnumerable<(OutputLineStream Stream, string Line)> lines) => _innerService.DisplayLines(lines);
    public void DisplayCancellationMessage(string? message = null, ConsoleOutput? consoleOverride = null)
    {
        OnCancellationMessageDisplayed?.Invoke();
        _innerService.DisplayCancellationMessage(message, consoleOverride);
    }
    public void DisplayEmptyLine() => _innerService.DisplayEmptyLine();
    public void DisplayVersionUpdateNotification(string newerVersion, string? updateCommand = null) 
        => _innerService.DisplayVersionUpdateNotification(newerVersion, updateCommand);
    public void WriteConsoleLog(string message, int? lineNumber = null, string? type = null, bool isErrorMessage = false) 
        => _innerService.WriteConsoleLog(message, lineNumber, type, isErrorMessage);
    public void DisplayRenderable(IRenderable renderable) => _innerService.DisplayRenderable(renderable);
    public Task DisplayLiveAsync(IRenderable initialRenderable, Func<Action<IRenderable>, Task> callback) => _innerService.DisplayLiveAsync(initialRenderable, callback);
}
 
// Test implementation of IProjectUpdater
internal sealed class TestProjectUpdater : IProjectUpdater
{
    public Func<UpdatePackagesContext, CancellationToken, Task<ProjectUpdateResult>>? UpdateProjectAsyncCallback { get; set; }
 
    public Task<ProjectUpdateResult> UpdateProjectAsync(UpdatePackagesContext context, CancellationToken cancellationToken = default)
    {
        if (UpdateProjectAsyncCallback != null)
        {
            return UpdateProjectAsyncCallback(context, cancellationToken);
        }
 
        // Default behavior
        return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = false });
    }
}