File: Commands\UpdateCommand.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.CommandLine;
using System.Diagnostics;
using System.Globalization;
using Aspire.Cli.Acquisition;
using Aspire.Cli.Configuration;
using Aspire.Cli.Exceptions;
using Aspire.Cli.Interaction;
using Aspire.Cli.Migrations;
using Aspire.Cli.Packaging;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Semver;
using Spectre.Console;
 
namespace Aspire.Cli.Commands;
 
internal sealed class UpdateCommand : BaseCommand
{
    internal override HelpGroup HelpGroup => HelpGroup.AppCommands;
 
    // CLI package metadata is required for this command's core update workflow, independently of
    // whether the generic update notification is enabled.
    internal override bool RequiresCliPackageMetadata => true;
 
    private readonly IProjectLocator _projectLocator;
    private readonly IPackagingService _packagingService;
    private readonly IAppHostProjectFactory _projectFactory;
    private readonly ILogger<UpdateCommand> _logger;
    private readonly ICliDownloader? _cliDownloader;
    private readonly ICliUpdateNotifier _updateNotifier;
    private readonly IFeatures _features;
    private readonly IEnvironment _environment;
    private readonly IConfigurationService _configurationService;
    private readonly IConfiguration _configuration;
    private readonly IEnumerable<IMigration> _migrations;
    private readonly IProcessPathProvider _processPathProvider;
 
    private static readonly OptionWithLegacy<FileInfo?> s_appHostOption = new("--apphost", "--project", UpdateCommandStrings.ProjectArgumentDescription);
    private static readonly Option<bool> s_selfOption = new("--self")
    {
        Description = UpdateCommandStrings.SelfOptionDescription
    };
    private static readonly Option<bool> s_yesOption = new("--yes")
    {
        Description = UpdateCommandStrings.YesOptionDescription,
        Aliases = { "-y" }
    };
    private static readonly Option<bool> s_migrateOption = new("--migrate")
    {
        Description = UpdateCommandStrings.MigrateOptionDescription
    };
    private static readonly Option<string?> s_nugetConfigDirOption = new("--nuget-config-dir")
    {
        Description = UpdateCommandStrings.NuGetConfigDirOptionDescription
    };
    private readonly Option<string?> _channelOption;
    private readonly Option<string?> _qualityOption;
 
    public UpdateCommand(
        IProjectLocator projectLocator,
        IPackagingService packagingService,
        IAppHostProjectFactory projectFactory,
        ILogger<UpdateCommand> logger,
        ICliDownloader? cliDownloader,
        IEnvironment environment,
        IConfigurationService configurationService,
        IConfiguration configuration,
        IEnumerable<IMigration> migrations,
        IProcessPathProvider processPathProvider,
        CommonCommandServices services)
        : base("update", UpdateCommandStrings.Description, services)
    {
        _projectLocator = projectLocator;
        _packagingService = packagingService;
        _projectFactory = projectFactory;
        _logger = logger;
        _cliDownloader = cliDownloader;
        _updateNotifier = services.UpdateNotifier;
        _features = services.Features;
        _environment = environment;
        _configurationService = configurationService;
        _configuration = configuration;
        _migrations = migrations;
        _processPathProvider = processPathProvider;
 
        Options.Add(s_appHostOption);
        Options.Add(s_selfOption);
        Options.Add(s_yesOption);
        Options.Add(s_migrateOption);
        Options.Add(s_nugetConfigDirOption);
 
        AddNonInteractiveRequiresYesValidator(this, s_yesOption);
 
        // Customize description based on whether staging channel is enabled
        var isStagingEnabled = IsStagingChannelAvailable();
 
        _channelOption = new Option<string?>("--channel")
        {
            Description = isStagingEnabled
                ? UpdateCommandStrings.ChannelOptionDescriptionWithStaging
                : UpdateCommandStrings.ChannelOptionDescription
        };
        Options.Add(_channelOption);
 
        // Keep --quality for backward compatibility but hide it
        _qualityOption = new Option<string?>("--quality")
        {
            Description = isStagingEnabled
                ? UpdateCommandStrings.QualityOptionDescriptionWithStaging
                : UpdateCommandStrings.QualityOptionDescription,
            Hidden = true
        };
        Options.Add(_qualityOption);
    }
 
    private string? GetDotNetToolUpdateCommand()
    {
        return DotNetToolDetection.GetDotNetToolUpdateCommand(_processPathProvider.ProcessPath);
    }
 
    private static string? GetNpmUpdateCommand()
    {
        return NpmInstallDetection.GetNpmUpdateCommand();
    }
 
    private bool IsNixInstall()
    {
        var processPath = _processPathProvider.ProcessPath;
        if (string.IsNullOrEmpty(processPath))
        {
            return false;
        }
 
        var resolvedProcessPath = CliPathHelper.ResolveSymlinkOrOriginalPath(processPath, _logger);
        var binaryDir = Path.GetDirectoryName(resolvedProcessPath);
        if (string.IsNullOrEmpty(binaryDir))
        {
            return false;
        }
 
        var sidecarPath = Path.Combine(binaryDir, InstallSidecarReader.SidecarFileName);
        var source = InstallSidecarReader.ReadSourceField(sidecarPath);
        return InstallSourceExtensions.ParseInstallSource(source) == InstallSource.Nix;
    }
 
    protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        var isSelfUpdate = parseResult.GetValue(s_selfOption);
 
        // If --self is specified, handle CLI self-update
        if (isSelfUpdate)
        {
            // When running as a dotnet tool, print the update command instead of executing
            var dotNetToolUpdateCommand = GetDotNetToolUpdateCommand();
            if (dotNetToolUpdateCommand is not null)
            {
                InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.DotNetToolSelfUpdateMessage);
                InteractionService.DisplayPlainText($"  {dotNetToolUpdateCommand}");
                return CommandResult.FromExitCode(0);
            }
 
            // When running from a global npm install, defer to npm rather than overwriting
            // npm-owned files with the GitHub-binary downloader. Detected via env vars the
            // npm launcher (eng/clipack/npm/aspire.js) sets when spawning the native binary.
            var npmUpdateCommand = GetNpmUpdateCommand();
            if (npmUpdateCommand is not null)
            {
                InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.NpmSelfUpdateMessage);
                InteractionService.DisplayPlainText($"  {npmUpdateCommand}");
                return CommandResult.Success();
            }
 
            try
            {
                return await ExecuteSelfUpdateAsync(parseResult, null, cancellationToken);
            }
            catch (OperationCanceledException)
            {
                return CommandResult.Cancelled();
            }
        }
 
        // Otherwise, handle project update
        try
        {
            var passedAppHostProjectFile = parseResult.GetValue(s_appHostOption);
 
            // `aspire update` is a recovery tool: when the AppHost's pinned Aspire.AppHost.Sdk
            // can no longer be resolved (e.g. updating from one PR build to another after the
            // hive was refreshed) the configured AppHost path must still be locatable, because
            // rewriting that pin is precisely what this command does. Prefer the settings
            // lookup, which does not MSBuild-validate the path, and only fall through to the
            // strict discovery path when no AppHost is recorded in settings.
            FileInfo? projectFile;
            if (passedAppHostProjectFile is not null)
            {
                projectFile = await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, createSettingsFile: true, cancellationToken);
            }
            else
            {
                projectFile = await _projectLocator.GetAppHostFromSettingsAsync(cancellationToken)
                    ?? await _projectLocator.UseOrFindAppHostProjectFileAsync(null, createSettingsFile: true, cancellationToken);
            }
            if (projectFile is null)
            {
                return CommandResult.Failure(CliExitCodes.FailedToFindProject);
            }
 
            var project = _projectFactory.GetProject(projectFile);
            var isProjectReferenceMode = project.IsUsingProjectReferences(projectFile);
 
            // Resolve the channel using the documented precedence:
            //   1. explicit --channel / hidden --quality
            //   2. nearest local app config "channel" (relative to the resolved AppHost project, NOT cwd)
            //   3. global config "channel"
            //   4. interactive channel prompt when appropriate (PR hives present)
            //   5. implicit/default channel as the documented fallback
            // The directory-scoped lookup is critical: `aspire update --apphost <elsewhere>`
            // must consult the selected project's config tree, not the user's launch cwd.
            // The process-wide IConfiguration is rooted at the launch cwd at startup, so
            // using it here would silently read the wrong app's local config (issue #16650).
            //
            // Step 3 (global config "channel") is intentionally a read-only path: no CLI
            // code path seeds the global "channel" config (neither the acquisition scripts
            // nor `aspire update --self` write it), and the running CLI's channel is
            // already discoverable via the AspireCliChannel assembly metadata. The global
            // read remains so users who explicitly ran `aspire config set -g channel <x>`
            // continue to have their preference honored.
            // TODO: revisit removing the step-3 fallback once telemetry confirms global
            // channel usage is negligible.
            var channelName = parseResult.GetValue(_channelOption) ?? parseResult.GetValue(_qualityOption);
            var channelFromConfig = false;
            if (string.IsNullOrWhiteSpace(channelName))
            {
                var configLookupDirectory = projectFile.Directory ?? ExecutionContext.WorkingDirectory;
                channelName = await _configurationService.GetConfigurationFromDirectoryAsync("channel", configLookupDirectory, cancellationToken: cancellationToken);
                channelFromConfig = !string.IsNullOrWhiteSpace(channelName);
            }
 
            PackageChannel channel;
 
            var allChannels = await InteractionService.ShowStatusAsync(
                UpdateCommandStrings.CheckingForUpdates,
                async () => await _packagingService.GetChannelsAsync(cancellationToken, channelName));
 
            if (!string.IsNullOrWhiteSpace(channelName))
            {
                // Try to find a channel matching the provided channel/quality
                var matchedChannel = allChannels.FirstOrDefault(c => string.Equals(c.Name, channelName, StringComparisons.ChannelName));
                if (matchedChannel is null)
                {
                    // When the user explicitly asked for the 'staging' channel and the packaging
                    // service refused to synthesize it (daily/local/pr-N CLI without an override),
                    // surface the packaging-service reason instead of the generic "no channel
                    // matching" message — the generic message hides the actual fix from the user.
                    // See https://github.com/microsoft/aspire/issues/16652.
                    if (string.Equals(channelName, PackageChannelNames.Staging, StringComparisons.ChannelName))
                    {
                        var stagingUnavailableReason = _packagingService.GetStagingChannelUnavailableReason();
                        if (stagingUnavailableReason is not null)
                        {
                            throw new ChannelNotFoundException(stagingUnavailableReason);
                        }
                    }
 
                    throw new ChannelNotFoundException(string.Format(
                        CultureInfo.CurrentCulture,
                        UpdateCommandStrings.NoChannelFoundMatching,
                        channelName,
                        string.Join(", ", allChannels.Select(c => c.Name))));
                }
 
                channel = matchedChannel;
 
                if (channelFromConfig)
                {
                    _logger.LogDebug("Using channel '{ChannelName}' from configuration.", channel.Name);
                }
            }
            else if (isProjectReferenceMode)
            {
                channel = allChannels.FirstOrDefault(c => c.Type is PackageChannelType.Implicit)
                    ?? allChannels.First();
            }
            else
            {
                // Before falling through to the hives prompt, default to the running CLI's
                // identity channel (the value baked into the assembly via the
                // AspireCliChannel metadata) when it matches a registered channel. Without
                // this, a `pr-<N>` or `daily` CLI updating an AppHost that has no
                // per-project `channel` and no global `channel` config would silently land
                // on the Implicit ("default") channel, which resolves Aspire packages from
                // public NuGet and effectively moves the project to daily even though the
                // running CLI knows which channel it shipped from.
                //
                // `local` is intentionally skipped: a developer-built CLI must not silently
                // pin a real project to a hive that only exists on that machine. We also
                // require the identity to match an entry in `allChannels`, so a stale
                // `pr-<N>` identity (e.g. the matching hive was deleted) falls through to
                // the existing prompt/implicit logic instead of failing.
                var identityChannel = ExecutionContext.IdentityChannel;
                PackageChannel? identityMatch = null;
                if (!string.IsNullOrWhiteSpace(identityChannel)
                    && !string.Equals(identityChannel, PackageChannelNames.Local, StringComparisons.ChannelName))
                {
                    identityMatch = allChannels.FirstOrDefault(c => string.Equals(c.Name, identityChannel, StringComparisons.ChannelName));
                }
 
                if (identityMatch is not null)
                {
                    _logger.LogDebug("Defaulting to identity channel '{ChannelName}'.", identityMatch.Name);
                    channel = identityMatch;
                }
                else
                {
                    // If there are hives (PR build directories), prompt for channel selection.
                    // Otherwise, use the implicit/default channel automatically.
                    var hasHives = ExecutionContext.GetHiveCount() > 0;
 
                    if (hasHives)
                    {
                        // Prompt for channel selection
                        var channelBinding = PromptBinding.Create(parseResult, _channelOption);
                        channel = await InteractionService.PromptForSelectionAsync(
                            UpdateCommandStrings.SelectChannelPrompt,
                            allChannels,
                            (c) => $"{c.Name.EscapeMarkup()} ({c.SourceDetails.EscapeMarkup()})",
                            binding: channelBinding,
                            cancellationToken: cancellationToken);
                    }
                    else
                    {
                        // Use the default (implicit) channel
                        channel = allChannels.FirstOrDefault(c => c.Type is PackageChannelType.Implicit)
                            ?? allChannels.First();
                    }
                }
            }
 
            // Update packages using the appropriate project handler
            // The validator ensures --yes is required when --non-interactive is specified,
            // so by this point --yes is always explicitly provided in non-interactive mode.
            // defaultValue: true means the interactive prompt defaults to "yes" (accept).
            var confirmBinding = PromptBinding.Create(parseResult, s_yesOption, defaultValue: true);
            var nugetConfigDirBinding = PromptBinding.Create(parseResult, s_nugetConfigDirOption);
            var updateContext = new UpdatePackagesContext
            {
                AppHostFile = projectFile,
                Channel = channel,
                ConfirmBinding = confirmBinding,
                NuGetConfigDirBinding = nugetConfigDirBinding
            };
            var cliUpdateResult = await TryUpdateCliBeforeGuestProjectUpdateAsync(project, projectFile, channel, confirmBinding, parseResult, cancellationToken);
            if (cliUpdateResult is not null)
            {
                return cliUpdateResult;
            }
 
            await project.UpdatePackagesAsync(updateContext, cancellationToken);
 
            // The package update may have moved the project onto a newer Aspire version whose
            // recommended conventions require an on-disk migration (e.g. apphost.ts -> apphost.mts).
            // When --migrate is passed we apply pending migrations now, after the packages are
            // updated, since a migration may depend on the newer package version. Without --migrate
            // we stay non-destructive and only surface a non-blocking advisory. Detection reuses the
            // same IMigration registry behind `aspire doctor`, so any future migration shows up here
            // automatically.
            await HandlePendingMigrationsAsync(parseResult.GetValue(s_migrateOption), projectFile, confirmBinding, cancellationToken);
 
            // After successful project update, check if CLI update is available and prompt
            // Only prompt if the channel supports CLI downloads (has a non-null CliDownloadBaseUrl)
            if (_cliDownloader is not null &&
                _updateNotifier.IsUpdateAvailable() &&
                !string.IsNullOrEmpty(channel.CliDownloadBaseUrl))
            {
                var shouldUpdateCli = await InteractionService.PromptConfirmAsync(
                    UpdateCommandStrings.UpdateCliAfterProjectUpdatePrompt,
                    binding: confirmBinding,
                    cancellationToken: cancellationToken);
 
                if (shouldUpdateCli)
                {
                    var dotNetToolUpdateCommand = GetDotNetToolUpdateCommand();
                    if (dotNetToolUpdateCommand is not null)
                    {
                        InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.DotNetToolSelfUpdateMessage);
                        InteractionService.DisplayPlainText($"  {dotNetToolUpdateCommand}");
                        return CommandResult.Success();
                    }
 
                    var npmUpdateCommand = GetNpmUpdateCommand();
                    if (npmUpdateCommand is not null)
                    {
                        InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.NpmSelfUpdateMessage);
                        InteractionService.DisplayPlainText($"  {npmUpdateCommand}");
                        return CommandResult.Success();
                    }
 
                    // Use the same channel that was selected for the project update
                    return await ExecuteSelfUpdateAsync(parseResult, channel.Name, cancellationToken);
                }
            }
        }
        catch (ProjectUpdaterException ex)
        {
            var message = Markup.Escape(ex.Message);
            Telemetry.RecordError(message, ex);
            return CommandResult.Failure(CliExitCodes.FailedToUpgradeProject, message);
        }
        catch (ChannelNotFoundException ex)
        {
            var message = Markup.Escape(ex.Message);
            Telemetry.RecordError(message, ex);
            return CommandResult.Failure(CliExitCodes.FailedToUpgradeProject, message);
        }
        catch (ProjectLocatorException ex)
        {
            // Check if this is a "no project found" error and prompt for self-update
            if (string.Equals(ex.Message, ErrorStrings.NoProjectFileFound, StringComparisons.CliInputOrOutput))
            {
                // dotnet tool and npm installs have package-manager-specific update commands, so
                // this recovery path does not prompt for archive self-update in those cases. Nix
                // does not have a launcher-provided command here; let it reach ExecuteSelfUpdateAsync
                // where the Nix store guard can print Nix-specific guidance instead of writing to
                // the read-only install path.
                if (GetDotNetToolUpdateCommand() is null && GetNpmUpdateCommand() is null && _cliDownloader is not null)
                {
                    var shouldUpdateCli = await InteractionService.PromptConfirmAsync(
                        UpdateCommandStrings.NoAppHostFoundUpdateCliPrompt,
                        binding: PromptBinding.Create(parseResult, s_yesOption, false),
                        cancellationToken: cancellationToken);
 
                    if (shouldUpdateCli)
                    {
                        return await ExecuteSelfUpdateAsync(parseResult, null, cancellationToken);
                    }
                }
            }
 
            return HandleProjectLocatorException(ex, InteractionService, Telemetry);
        }
        catch (OperationCanceledException)
        {
            return CommandResult.Cancelled();
        }
 
        return CommandResult.FromExitCode(0);
    }
 
    /// <summary>
    /// After a successful project update, detects pending migrations from the <see cref="IMigration"/>
    /// registry. When <paramref name="applyRequested"/> is <c>true</c> (the user passed <c>--migrate</c>)
    /// the migrations are applied in <see cref="IMigration.Order"/> after an optional confirmation;
    /// otherwise a non-blocking advisory is printed nudging the user toward <c>aspire update --migrate</c>.
    /// Migrations run after the package update so that any migration depending on a newer package
    /// version sees the updated packages. A failure here never turns a successful update into a failure.
    /// </summary>
    private async Task HandlePendingMigrationsAsync(bool applyRequested, FileInfo projectFile, PromptBinding<bool> confirmBinding, CancellationToken cancellationToken)
    {
        var context = new MigrationContext(projectFile);
 
        // Detect once up front so we can list the full set before asking for a single confirmation.
        var pending = new List<(IMigration Migration, MigrationDescriptor Descriptor)>();
        foreach (var migration in _migrations.OrderBy(m => m.Order))
        {
            MigrationDescriptor? descriptor;
            try
            {
                descriptor = await migration.DetectAsync(context, cancellationToken);
            }
            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
            {
                throw;
            }
            catch (Exception ex)
            {
                // A broken migration provider must not turn a successful update into a failure.
                _logger.LogDebug(ex, "Migration '{MigrationId}' detection failed", migration.Id);
                continue;
            }
 
            if (descriptor is not null)
            {
                pending.Add((migration, descriptor));
            }
        }
 
        if (!applyRequested)
        {
            if (pending.Count == 0)
            {
                return;
            }
 
            InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.PendingMigrationsHeader);
            foreach (var (_, descriptor) in pending)
            {
                InteractionService.DisplaySubtleMessage($"  - {descriptor.Title}");
            }
 
            InteractionService.DisplaySubtleMessage(UpdateCommandStrings.PendingMigrationsHint);
            return;
        }
 
        if (pending.Count == 0)
        {
            InteractionService.DisplaySubtleMessage(MigrationStrings.NothingToMigrate);
            return;
        }
 
        InteractionService.DisplayMessage(KnownEmojis.Gear, MigrationStrings.AvailableMigrationsHeader);
        foreach (var (_, descriptor) in pending)
        {
            InteractionService.DisplaySubtleMessage($"  - {descriptor.Title}");
        }
 
        // Applying migrations rewrites source files, so confirm before doing so. The shared
        // confirmBinding is pre-set to "yes" when --yes was passed (and the non-interactive
        // validator already required --yes), so this only prompts in interactive runs.
        var confirmed = await InteractionService.PromptConfirmAsync(
            MigrationStrings.ConfirmApplyPrompt,
            binding: confirmBinding,
            cancellationToken: cancellationToken);
        if (!confirmed)
        {
            InteractionService.DisplaySubtleMessage(MigrationStrings.MigrationCancelled);
            return;
        }
 
        foreach (var (migration, _) in pending)
        {
            try
            {
                await migration.ApplyAsync(context, cancellationToken);
            }
            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
            {
                throw;
            }
            catch (Exception ex)
            {
                // The packages were already updated successfully; a migration failure should warn
                // but not fail the overall update so the user keeps the successful package update.
                _logger.LogDebug(ex, "Migration '{MigrationId}' apply failed", migration.Id);
                InteractionService.DisplayMessage(KnownEmojis.Warning, UpdateCommandStrings.MigrationApplyFailedWarning);
            }
        }
    }
 
    private async Task<CommandResult?> TryUpdateCliBeforeGuestProjectUpdateAsync(
        IAppHostProject project,
        FileInfo projectFile,
        PackageChannel channel,
        PromptBinding<bool> confirmBinding,
        ParseResult parseResult,
        CancellationToken cancellationToken)
    {
        if (_cliDownloader is null ||
            string.IsNullOrEmpty(channel.CliDownloadBaseUrl) ||
            project.LanguageId.Equals(KnownLanguageId.CSharp, StringComparison.OrdinalIgnoreCase) ||
            projectFile.Directory is not { } projectDirectory)
        {
            return null;
        }
 
        var targetSdkVersion = await GetLatestGuestSdkVersionAsync(channel, projectDirectory, cancellationToken);
        if (targetSdkVersion is null ||
            !SemVersion.TryParse(ExecutionContext.IdentitySdkVersion, SemVersionStyles.Strict, out var currentCliVersion) ||
            SemVersion.PrecedenceComparer.Compare(targetSdkVersion, currentCliVersion) <= 0)
        {
            return null;
        }
 
        var shouldUpdateCli = await InteractionService.PromptConfirmAsync(
            UpdateCommandStrings.UpdateCliBeforeGuestProjectUpdatePrompt,
            binding: confirmBinding,
            cancellationToken: cancellationToken);
 
        if (!shouldUpdateCli)
        {
            return null;
        }
 
        var dotNetToolUpdateCommand = GetDotNetToolUpdateCommand();
        if (dotNetToolUpdateCommand is not null)
        {
            InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.DotNetToolSelfUpdateMessage);
            InteractionService.DisplayPlainText($"  {dotNetToolUpdateCommand}");
            InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.ProjectUpdateSkippedAfterCliUpdateMessage);
            return CommandResult.Success();
        }
 
        var npmUpdateCommand = GetNpmUpdateCommand();
        if (npmUpdateCommand is not null)
        {
            InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.NpmSelfUpdateMessage);
            InteractionService.DisplayPlainText($"  {npmUpdateCommand}");
            InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.ProjectUpdateSkippedAfterCliUpdateMessage);
            return CommandResult.Success();
        }
 
        var selfUpdateResult = await ExecuteSelfUpdateAsync(parseResult, channel.Name, cancellationToken);
        if (selfUpdateResult.ExitCode == CliExitCodes.Success)
        {
            InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.ProjectUpdateSkippedAfterCliUpdateMessage);
        }
 
        return selfUpdateResult;
    }
 
    private async Task<SemVersion?> GetLatestGuestSdkVersionAsync(PackageChannel channel, DirectoryInfo projectDirectory, CancellationToken cancellationToken)
    {
        try
        {
            var sdkPackage = await channel.GetLatestGuestAppHostSdkPackageAsync(projectDirectory, cancellationToken);
            return sdkPackage is not null && SemVersion.TryParse(sdkPackage.Version, SemVersionStyles.Strict, out var version)
                ? version
                : null;
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Failed to check target Aspire SDK version before project update");
            return null;
        }
    }
 
    private bool IsStagingChannelAvailable()
    {
        return KnownFeatures.IsStagingChannelEnabled(_features, _configuration)
            || string.Equals(ExecutionContext.IdentityChannel, PackageChannelNames.Staging, StringComparisons.ChannelName);
    }
 
    private async Task<CommandResult> ExecuteSelfUpdateAsync(ParseResult parseResult, string? selectedChannel, CancellationToken cancellationToken)
    {
        // Keep the Nix guard at the archive self-update boundary. The dotnet tool and npm
        // checks live at the call sites because they produce package-manager commands before
        // the user is prompted or before project-update skip messaging is emitted. Nix installs
        // are identified from the sidecar next to the resolved binary, and every path that
        // reaches this method is about to replace that binary in-place. A Nix profile or flake
        // points at an immutable /nix/store path, so archive self-update would either fail with
        // no write permission or bypass Nix if it fell back to another directory. Let Nix update
        // the profile/flake instead.
        if (IsNixInstall())
        {
            InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.NixSelfUpdateMessage);
            InteractionService.DisplayPlainText("  nix profile upgrade aspire-cli");
            InteractionService.DisplayPlainText("  nix flake update <input-name>");
            return CommandResult.Success();
        }
 
        if (_cliDownloader is null)
        {
            return CommandResult.Failure(CliExitCodes.InvalidCommand, "CLI self-update is not available in this environment.");
        }
 
        var channel = selectedChannel ?? parseResult.GetValue(_channelOption) ?? parseResult.GetValue(_qualityOption);
 
        // If channel is not specified, prompt the user to select one. Installs with a route
        // sidecar persist the choice as CLI identity for later implicit updates. This does not
        // create a global project setting; project channels remain configured in aspire.config.json.
        if (string.IsNullOrEmpty(channel))
        {
            var isStagingEnabled = IsStagingChannelAvailable();
            var channels = isStagingEnabled
                ? new[] { PackageChannelNames.Stable, PackageChannelNames.Staging, PackageChannelNames.Daily }
                : new[] { PackageChannelNames.Stable, PackageChannelNames.Daily };
 
            // In non-interactive mode, avoid prompting. Prefer the CLI identity channel when it
            // maps to an update channel; use stable for local dev builds; otherwise require --channel.
            var nonInteractive = parseResult.GetValue(RootCommand.NonInteractiveOption);
            if (nonInteractive)
            {
                var identityChannel = ExecutionContext.IdentityChannel;
                if (!string.IsNullOrWhiteSpace(identityChannel)
                    && !string.Equals(identityChannel, PackageChannelNames.Local, StringComparisons.ChannelName)
                    && channels.FirstOrDefault(c => string.Equals(c, identityChannel, StringComparisons.ChannelName)) is { } matchedChannel)
                {
                    channel = matchedChannel;
                }
                else if (string.Equals(identityChannel, PackageChannelNames.Local, StringComparisons.ChannelName))
                {
                    channel = PackageChannelNames.Stable;
                }
                else
                {
                    var channelOptionDisplayName = $"'{_channelOption.Name}'";
                    InteractionService.DisplayError(
                        string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.NonInteractiveOptionRequired, channelOptionDisplayName));
                    throw new NonInteractiveException(channelOptionDisplayName);
                }
            }
            else
            {
                var channelBinding = PromptBinding.Create(parseResult, _channelOption);
                channel = await InteractionService.PromptForSelectionAsync(
                    "Select the channel to update to:",
                    channels,
                    q => q,
                    binding: channelBinding,
                    cancellationToken: cancellationToken);
            }
        }
 
        try
        {
            // Get current executable path for display purposes only
            var currentExePath = _processPathProvider.ProcessPath;
            if (string.IsNullOrEmpty(currentExePath))
            {
                return CommandResult.Failure(CliExitCodes.InvalidCommand, "Unable to determine the current executable path.");
            }
 
            InteractionService.DisplayMessage(KnownEmojis.Package, $"Current CLI location: {currentExePath}");
            InteractionService.DisplayMessage(KnownEmojis.UpButton, $"Updating to channel: {channel}");
 
            // Download the latest CLI
            var archivePath = await _cliDownloader.DownloadLatestCliAsync(channel, cancellationToken);
 
            // Replace the current CLI in-place. Package-manager-owned installs that should not
            // be mutated, such as dotnet tool, npm, and Nix, are handled before this path.
            await ExtractAndUpdateAsync(archivePath, channel, cancellationToken);
 
            return CommandResult.Success();
        }
        catch (OperationCanceledException)
        {
            return CommandResult.Cancelled();
        }
        catch (Exception ex)
        {
            Telemetry.RecordError("Failed to update CLI", ex);
            var errorMessage = $"Failed to update CLI: {ex.Message}";
            return CommandResult.Failure(CliExitCodes.InvalidCommand, errorMessage);
        }
    }
 
    private async Task ExtractAndUpdateAsync(string archivePath, string channel, CancellationToken cancellationToken)
    {
        // Archive self-update is a same-directory replacement, not an installer that searches
        // for a writable fallback. If the executable is package-manager-owned, installing
        // elsewhere would leave the user's PATH/profile pointing at the old package-managed CLI.
        var currentExePath = _processPathProvider.ProcessPath;
        if (string.IsNullOrEmpty(currentExePath))
        {
            throw new InvalidOperationException("Unable to determine current CLI location.");
        }
 
        var installDir = Path.GetDirectoryName(currentExePath);
        if (string.IsNullOrEmpty(installDir))
        {
            throw new InvalidOperationException($"Unable to determine installation directory from: {currentExePath}");
        }
 
        var exeName = _environment.IsWindows() ? "aspire.exe" : "aspire";
        var targetExePath = Path.Combine(installDir, exeName);
        var tempExtractDir = Directory.CreateTempSubdirectory("aspire-cli-extract").FullName;
 
        try
        {
            // Extract archive
            await InteractionService.ShowStatusAsync(
                UpdateCommandStrings.ExtractingNewCli,
                async () =>
                {
                    await ArchiveHelper.ExtractAsync(archivePath, tempExtractDir, _environment, cancellationToken);
                    return 0;
                },
                KnownEmojis.Package);
 
            InteractionService.DisplayMessage(KnownEmojis.Package, UpdateCommandStrings.ExtractedNewCli);
 
            // Find the aspire executable in the extracted files
            var newExePath = Path.Combine(tempExtractDir, exeName);
            if (!File.Exists(newExePath))
            {
                throw new FileNotFoundException($"Extracted CLI executable not found: {newExePath}");
            }
 
            // Prepare the sidecar before replacing the running single-file executable. JSON
            // serialization can load framework assemblies lazily, and after replacement the
            // bundle loader could resolve those assemblies from the new executable instead of
            // the bundle used by this process.
            using var sidecarUpdate = InstallSidecarWriter.PrepareForSelfUpdate(installDir, channel);
 
            // Backup current executable if it exists
            var exeDir = Path.GetDirectoryName(targetExePath)!;
            FileDeleteHelper.TryCleanupOldItems(exeDir, exeName);
 
            string? backupPath = null;
            if (File.Exists(targetExePath))
            {
                InteractionService.DisplayMessage(KnownEmojis.FloppyDisk, "Backing up current CLI...");
 
                // Rename current executable to .old.[timestamp]
                var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
                backupPath = $"{targetExePath}.old.{unixTimestamp}";
                _logger.LogDebug("Creating backup: {BackupPath}", backupPath);
                File.Move(targetExePath, backupPath);
            }
 
            try
            {
                // Copy new executable to install location
                InteractionService.DisplayMessage(KnownEmojis.Wrench, $"Installing new CLI to {installDir}...");
                File.Copy(newExePath, targetExePath, overwrite: true);
 
                // On Unix systems, ensure the executable bit is set
                if (!_environment.IsWindows())
                {
                    SetExecutablePermission(targetExePath);
                }
 
                // Test the new executable and display its version
                _logger.LogDebug("Testing new CLI executable and displaying version");
                var newVersion = await GetNewVersionAsync(targetExePath, cancellationToken);
                if (newVersion is null)
                {
                    throw new InvalidOperationException("New CLI executable failed verification test.");
                }
 
                // The new binary will extract its embedded bundle on first run via EnsureExtractedAsync.
                // No proactive extraction needed — the payload is inside the new binary's embedded resources,
                // which are only accessible when that binary is running.
 
                // Display helpful message about PATH
                if (!IsInPath(installDir, _environment))
                {
                    InteractionService.DisplayMessage(KnownEmojis.Information, $"Note: {installDir} is not in your PATH. Add it to use the updated CLI globally.");
                }
 
                // Shared staging archives can contain a ship-candidate binary deliberately stamped
                // as stable. Persist the channel selected by this update so the next invocation keeps
                // using staging instead of falling back to the binary stamp. Remove any sidecar version
                // and commit assigned to the previous executable so identity falls back to the replacement
                // binary's metadata. Commit while the executable backup still exists so a sidecar
                // failure restores the previous CLI.
                sidecarUpdate?.Commit();
 
                // If we get here, both the executable and its identity were updated successfully.
                FileDeleteHelper.TryCleanupOldItems(exeDir, exeName);
            }
            catch
            {
                // If anything goes wrong, restore the backup
                _logger.LogWarning("Update failed, restoring backup");
                if (backupPath is not null && File.Exists(backupPath))
                {
                    if (File.Exists(targetExePath))
                    {
                        File.Delete(targetExePath);
                    }
                    File.Move(backupPath, targetExePath);
                }
                throw;
            }
        }
        catch (UnauthorizedAccessException)
        {
            throw new UnauthorizedAccessException(
                string.Format(CultureInfo.CurrentCulture, UpdateCommandStrings.NoWritePermissionToInstallDirectory, installDir));
        }
        finally
        {
            // Clean up temp directories
            CleanupDirectory(tempExtractDir);
            CleanupDirectory(Path.GetDirectoryName(archivePath)!);
        }
    }
 
    private static bool IsInPath(string directory, IEnvironment environment)
    {
        var pathEnv = Environment.GetEnvironmentVariable("PATH");
        if (string.IsNullOrEmpty(pathEnv))
        {
            return false;
        }
 
        var pathSeparator = Path.PathSeparator;
        var paths = pathEnv.Split(pathSeparator, StringSplitOptions.RemoveEmptyEntries);
 
        return paths.Any(p =>
            string.Equals(Path.GetFullPath(p.Trim()), Path.GetFullPath(directory),
                environment.IsWindows()
                    ? StringComparison.OrdinalIgnoreCase
                    : StringComparison.Ordinal));
    }
 
    private void SetExecutablePermission(string filePath)
    {
        if (!_environment.IsWindows())
        {
            try
            {
                var mode = File.GetUnixFileMode(filePath);
                mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
                File.SetUnixFileMode(filePath, mode);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "Failed to set executable permission on {FilePath}", filePath);
            }
        }
    }
 
    private async Task<string?> GetNewVersionAsync(string exePath, CancellationToken cancellationToken)
    {
        try
        {
            var psi = new ProcessStartInfo
            {
                FileName = exePath,
                Arguments = "--version",
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false
            };
 
            using var process = Process.Start(psi);
            if (process is null)
            {
                return null;
            }
 
            var output = await process.StandardOutput.ReadToEndAsync(cancellationToken);
            await process.WaitForExitAsync(cancellationToken);
 
            if (process.ExitCode == 0)
            {
                var version = output.Trim();
                InteractionService.DisplaySuccess($"Updated to version: {version}");
                return version;
            }
 
            return null;
        }
        catch
        {
            return null;
        }
    }
 
    private void CleanupDirectory(string directory)
    {
        try
        {
            if (Directory.Exists(directory))
            {
                Directory.Delete(directory, recursive: true);
            }
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Failed to clean up directory {Directory}", directory);
        }
    }
 
}