Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
317 changes: 316 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 @@ -67,4 +130,256 @@ 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 = 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 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 = 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;
}
}

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 @@ -8,13 +8,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 string SourceDetails { get; } = ComputeSourceDetails(mappings);

Expand Down Expand Up @@ -174,9 +175,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