Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
318 changes: 317 additions & 1 deletion src/Aspire.Cli/Commands/UpdateCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.CommandLine;
using System.Diagnostics;
using System.Formats.Tar;
using System.IO.Compression;
using System.Runtime.InteropServices;
using Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.Packaging;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
using Spectre.Console;

namespace Aspire.Cli.Commands;
Expand All @@ -17,24 +22,82 @@ internal sealed class UpdateCommand : BaseCommand
private readonly IProjectLocator _projectLocator;
private readonly IPackagingService _packagingService;
private readonly IProjectUpdater _projectUpdater;
private readonly ILogger<UpdateCommand> _logger;
private readonly ICliDownloader? _cliDownloader;

public UpdateCommand(IProjectLocator projectLocator, IPackagingService packagingService, IProjectUpdater projectUpdater, IInteractionService interactionService, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext) : base("update", UpdateCommandStrings.Description, features, updateNotifier, executionContext, interactionService)
public UpdateCommand(
IProjectLocator projectLocator,
IPackagingService packagingService,
IProjectUpdater projectUpdater,
ILogger<UpdateCommand> logger,
ICliDownloader? cliDownloader,
IInteractionService interactionService,
IFeatures features,
ICliUpdateNotifier updateNotifier,
CliExecutionContext executionContext)
: base("update", UpdateCommandStrings.Description, features, updateNotifier, executionContext, interactionService)
{
ArgumentNullException.ThrowIfNull(projectLocator);
ArgumentNullException.ThrowIfNull(packagingService);
ArgumentNullException.ThrowIfNull(projectUpdater);
ArgumentNullException.ThrowIfNull(logger);

_projectLocator = projectLocator;
_packagingService = packagingService;
_projectUpdater = projectUpdater;
_logger = logger;
_cliDownloader = cliDownloader;

var projectOption = new Option<FileInfo?>("--project");
projectOption.Description = UpdateCommandStrings.ProjectArgumentDescription;
Options.Add(projectOption);

// Only add --self option if not running as dotnet tool
if (!IsRunningAsDotNetTool())
{
var selfOption = new Option<bool>("--self");
selfOption.Description = "Update the Aspire CLI itself to the latest version";
Options.Add(selfOption);

var qualityOption = new Option<string?>("--quality");
qualityOption.Description = "Quality level to update to when using --self (stable, staging, daily)";
Options.Add(qualityOption);
}
}

protected override bool UpdateNotificationsEnabled => false;

private static bool IsRunningAsDotNetTool()
{
// When running as a dotnet tool, the process path points to "dotnet" or "dotnet.exe"
// When running as a native binary, it points to "aspire" or "aspire.exe"
var processPath = Environment.ProcessPath;
if (string.IsNullOrEmpty(processPath))
{
return false;
}

var fileName = Path.GetFileNameWithoutExtension(processPath);
return string.Equals(fileName, "dotnet", StringComparison.OrdinalIgnoreCase);
}

protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
var isSelfUpdate = parseResult.GetValue<bool>("--self");

// If --self is specified, handle CLI self-update
if (isSelfUpdate)
{
if (_cliDownloader is null)
{
InteractionService.DisplayError("CLI self-update is not available in this environment.");
return ExitCodeConstants.InvalidCommand;
}

return await ExecuteSelfUpdateAsync(parseResult, cancellationToken);
}

// Otherwise, handle project update
try
{
var passedAppHostProjectFile = parseResult.GetValue<FileInfo?>("--project");
Expand Down Expand Up @@ -63,4 +126,257 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell

return 0;
}

private async Task<int> ExecuteSelfUpdateAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
var quality = parseResult.GetValue<string?>("--quality");

// If quality is not specified, prompt the user
if (string.IsNullOrEmpty(quality))
{
var qualities = new[] { "stable", "staging", "daily" };
quality = await InteractionService.PromptForSelectionAsync(
"Select the quality level to update to:",
qualities,
q => q,
cancellationToken);
}

try
{
// Get current executable path for display purposes only
var currentExePath = Environment.ProcessPath;
if (string.IsNullOrEmpty(currentExePath))
{
InteractionService.DisplayError("Unable to determine the current executable path.");
return ExitCodeConstants.InvalidCommand;
}

InteractionService.DisplayMessage("package", $"Current CLI location: {currentExePath}");
InteractionService.DisplayMessage("up_arrow", $"Updating to quality level: {quality}");

// Download the latest CLI
var archivePath = await _cliDownloader!.DownloadLatestCliAsync(quality, cancellationToken);

// Extract and update to $HOME/.aspire/bin
await ExtractAndUpdateAsync(archivePath, cancellationToken);

return 0;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update CLI");
InteractionService.DisplayError($"Failed to update CLI: {ex.Message}");
return ExitCodeConstants.InvalidCommand;
}
}

private async Task ExtractAndUpdateAsync(string archivePath, CancellationToken cancellationToken)
{
// Always install to $HOME/.aspire/bin
var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrEmpty(homeDir))
{
throw new InvalidOperationException("Unable to determine home directory.");
}

var installDir = Path.Combine(homeDir, ".aspire", "bin");
Directory.CreateDirectory(installDir);

var exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "aspire.exe" : "aspire";
var targetExePath = Path.Combine(installDir, exeName);
var tempExtractDir = Path.Combine(Path.GetTempPath(), $"aspire-cli-extract-{Guid.NewGuid():N}");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use Directory.CreateTempSubdirectory instead.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to use Directory.CreateTempSubdirectory() for automatic cleanup. (commit: pending)


try
{
Directory.CreateDirectory(tempExtractDir);

// Extract archive
InteractionService.DisplayMessage("package", "Extracting new CLI...");
await ExtractArchiveAsync(archivePath, tempExtractDir, cancellationToken);

// 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}");
}

// Backup current executable if it exists
var backupPath = $"{targetExePath}.old";
if (File.Exists(targetExePath))
{
InteractionService.DisplayMessage("floppy_disk", "Backing up current CLI...");
_logger.LogDebug("Creating backup: {BackupPath}", backupPath);

// Remove old backup if it exists
if (File.Exists(backupPath))
{
File.Delete(backupPath);
}

// Rename current executable to .old
File.Move(targetExePath, backupPath);
}

try
{
// Copy new executable to install location
InteractionService.DisplayMessage("wrench", $"Installing new CLI to {installDir}...");
File.Copy(newExePath, targetExePath, overwrite: true);

// On Unix systems, ensure the executable bit is set
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
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.");
}

// If we get here, the update was successful, remove the backup
if (File.Exists(backupPath))
{
_logger.LogDebug("Update successful, removing backup");
File.Delete(backupPath);
}

// Display helpful message about PATH
if (!IsInPath(installDir))
{
InteractionService.DisplayMessage("information", $"Note: {installDir} is not in your PATH. Add it to use the updated CLI globally.");
}
}
catch
{
// If anything goes wrong, restore the backup
_logger.LogWarning("Update failed, restoring backup");
if (File.Exists(backupPath))
{
if (File.Exists(targetExePath))
{
File.Delete(targetExePath);
}
File.Move(backupPath, targetExePath);
}
throw;
}
}
finally
{
// Clean up temp directories
CleanupDirectory(tempExtractDir);
CleanupDirectory(Path.GetDirectoryName(archivePath)!);
}
}

private static bool IsInPath(string directory)
{
var pathEnv = Environment.GetEnvironmentVariable("PATH");
if (string.IsNullOrEmpty(pathEnv))
{
return false;
}

var pathSeparator = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ';' : ':';
Copy link
Member

@davidfowl davidfowl Oct 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use Path.PathSeparator.

Suggested change
var pathSeparator = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ';' : ':';
var pathSeparator = Path.PathSeparator;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to use Path.PathSeparator for cross-platform compatibility. (commit: pending)

var paths = pathEnv.Split(pathSeparator, StringSplitOptions.RemoveEmptyEntries);

return paths.Any(p =>
string.Equals(Path.GetFullPath(p.Trim()), Path.GetFullPath(directory),
RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal));
}

private static async Task ExtractArchiveAsync(string archivePath, string destinationPath, CancellationToken cancellationToken)
{
if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
ZipFile.ExtractToDirectory(archivePath, destinationPath, overwriteFiles: true);
}
else if (archivePath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase))
{
await using var fileStream = new FileStream(archivePath, FileMode.Open, FileAccess.Read);
await using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress);
await TarFile.ExtractToDirectoryAsync(gzipStream, destinationPath, overwriteFiles: true, cancellationToken);
}
else
{
throw new NotSupportedException($"Unsupported archive format: {archivePath}");
}
}

private static void SetExecutablePermission(string filePath)
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
try
{
var mode = File.GetUnixFileMode(filePath);
mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
File.SetUnixFileMode(filePath, mode);
}
catch
{
// Best effort, ignore failures
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Log a warning here if we are unable to make it executable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added warning logging when unable to set executable permission, including the exception details and file path. (commit: pending)

}
Comment on lines 321 to 330
Copy link

@KalleOlaviNiemitalo KalleOlaviNiemitalo Oct 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO any exception thrown by GetUnixFileMode or SetUnixFileMode should be at least logged as a warning, if it does not cause the update to be rolled back entirely.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot implement this feedback.

}
}

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);
}
}
}
7 changes: 4 additions & 3 deletions src/Aspire.Cli/Packaging/PackageChannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@

namespace Aspire.Cli.Packaging;

internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false)
internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null)
{
public string Name { get; } = name;
public PackageChannelQuality Quality { get; } = quality;
public PackageMapping[]? Mappings { get; } = mappings;
public PackageChannelType Type { get; } = mappings is null ? PackageChannelType.Implicit : PackageChannelType.Explicit;
public bool ConfigureGlobalPackagesFolder { get; } = configureGlobalPackagesFolder;
public string? CliDownloadBaseUrl { get; } = cliDownloadBaseUrl;

public async Task<IEnumerable<NuGetPackage>> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -151,9 +152,9 @@ public async Task<IEnumerable<NuGetPackage>> GetPackagesAsync(string packageId,
return filteredPackages;
}

public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false)
public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null)
{
return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder);
return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder, cliDownloadBaseUrl);
}

public static PackageChannel CreateImplicitChannel(INuGetPackageCache nuGetPackageCache)
Expand Down
Loading