|
// 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.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;
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,
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");
var projectFile = await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, createSettingsFile: true, cancellationToken);
if (projectFile is null)
{
return ExitCodeConstants.FailedToFindProject;
}
var channels = await _packagingService.GetChannelsAsync(cancellationToken);
var channel = await InteractionService.PromptForSelectionAsync(
UpdateCommandStrings.SelectChannelPrompt,
channels,
(c) => $"{c.Name} ({c.SourceDetails})",
cancellationToken);
await _projectUpdater.UpdateProjectAsync(projectFile!, channel, cancellationToken);
}
catch (ProjectUpdaterException ex)
{
var message = Markup.Escape(ex.Message);
InteractionService.DisplayError(message);
return ExitCodeConstants.FailedToUpgradeProject;
}
catch (ProjectLocatorException ex)
{
return HandleProjectLocatorException(ex, InteractionService);
}
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 = Directory.CreateTempSubdirectory("aspire-cli-extract").FullName;
try
{
// 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 unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var backupPath = $"{targetExePath}.old.{unixTimestamp}";
if (File.Exists(targetExePath))
{
InteractionService.DisplayMessage("floppy_disk", "Backing up current CLI...");
_logger.LogDebug("Creating backup: {BackupPath}", backupPath);
// Clean up old backup files
CleanupOldBackupFiles(targetExePath);
// Rename current executable to .old.[timestamp]
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, clean up old backups
CleanupOldBackupFiles(targetExePath);
// 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 = Path.PathSeparator;
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 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 (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;
}
}
internal void CleanupOldBackupFiles(string targetExePath)
{
try
{
var directory = Path.GetDirectoryName(targetExePath);
if (string.IsNullOrEmpty(directory))
{
return;
}
var exeName = Path.GetFileName(targetExePath);
var searchPattern = $"{exeName}.old.*";
var oldBackupFiles = Directory.GetFiles(directory, searchPattern);
foreach (var backupFile in oldBackupFiles)
{
try
{
File.Delete(backupFile);
_logger.LogDebug("Deleted old backup file: {BackupFile}", backupFile);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to delete old backup file: {BackupFile}", backupFile);
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to cleanup old backup files for: {TargetExePath}", targetExePath);
}
}
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);
}
}
}
|