Skip to content
Closed
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
6 changes: 5 additions & 1 deletion src/Aspire.Cli/Commands/BaseCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ protected BaseCommand(string name, string description, IFeatures features, ICliU

var exitCode = await ExecuteAsync(parseResult, cancellationToken);

if (UpdateNotificationsEnabled && features.IsFeatureEnabled(KnownFeatures.UpdateNotificationsEnabled, true))
if (AutoUpdater.AppliedVersion is not null)
{
interactionService.DisplayMessage(KnownEmojis.Rocket, $"Auto-updated to version {AutoUpdater.AppliedVersion}. Changes take effect on next run.");
}
else if (UpdateNotificationsEnabled && features.IsFeatureEnabled(KnownFeatures.UpdateNotificationsEnabled, true))
{
try
{
Expand Down
103 changes: 13 additions & 90 deletions src/Aspire.Cli/Commands/UpdateCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,19 +89,7 @@ public UpdateCommand(

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);
}
private static bool IsRunningAsDotNetTool() => CliUpdateHelper.IsRunningAsDotNetTool();

protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -340,7 +328,7 @@ private async Task ExtractAndUpdateAsync(string archivePath, CancellationToken c
throw new InvalidOperationException($"Unable to determine installation directory from: {currentExePath}");
}

var exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "aspire.exe" : "aspire";
var exeName = CliUpdateHelper.ExeName;
var targetExePath = Path.Combine(installDir, exeName);
var tempExtractDir = Directory.CreateTempSubdirectory("aspire-cli-extract").FullName;

Expand All @@ -357,33 +345,16 @@ private async Task ExtractAndUpdateAsync(string archivePath, CancellationToken c
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(KnownEmojis.FloppyDisk, "Backing up current CLI...");
_logger.LogDebug("Creating backup: {BackupPath}", backupPath);
// Backup current executable and replace with new one
InteractionService.DisplayMessage(KnownEmojis.FloppyDisk, "Backing up current CLI...");

// Clean up old backup files
CleanupOldBackupFiles(targetExePath);
// Clean up old backup files first
CliUpdateHelper.CleanupOldBackupFiles(targetExePath);

// Rename current executable to .old.[timestamp]
File.Move(targetExePath, backupPath);
}
var backupPath = CliUpdateHelper.ReplaceExecutable(targetExePath, newExePath);

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 (!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);
Expand All @@ -393,7 +364,7 @@ private async Task ExtractAndUpdateAsync(string archivePath, CancellationToken c
}

// If we get here, the update was successful, clean up old backups
CleanupOldBackupFiles(targetExePath);
CliUpdateHelper.CleanupOldBackupFiles(targetExePath);

// 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,
Expand All @@ -407,9 +378,9 @@ private async Task ExtractAndUpdateAsync(string archivePath, CancellationToken c
}
catch
{
// If anything goes wrong, restore the backup
// If verification fails, restore the backup
_logger.LogWarning("Update failed, restoring backup");
if (File.Exists(backupPath))
if (backupPath is not null && File.Exists(backupPath))
{
if (File.Exists(targetExePath))
{
Expand Down Expand Up @@ -451,23 +422,6 @@ private static bool IsInPath(string directory)
: StringComparison.Ordinal));
}

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
Expand All @@ -489,14 +443,14 @@ private void SetExecutablePermission(string filePath)

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
Expand All @@ -505,38 +459,7 @@ private void SetExecutablePermission(string filePath)
}
}

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);
}
}
internal static void CleanupOldBackupFiles(string targetExePath) => CliUpdateHelper.CleanupOldBackupFiles(targetExePath);

private void CleanupDirectory(string directory)
{
Expand Down
6 changes: 6 additions & 0 deletions src/Aspire.Cli/KnownFeatures.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ internal static class KnownFeatures
public static string ExperimentalPolyglotGo => "experimentalPolyglot:go";
public static string ExperimentalPolyglotPython => "experimentalPolyglot:python";
public static string RunningInstanceDetectionEnabled => "runningInstanceDetectionEnabled";
public static string AutoUpdateEnabled => "autoUpdateEnabled";

private static readonly Dictionary<string, FeatureMetadata> s_featureMetadata = new()
{
Expand Down Expand Up @@ -100,6 +101,11 @@ internal static class KnownFeatures
[RunningInstanceDetectionEnabled] = new(
RunningInstanceDetectionEnabled,
"Enable or disable detection of already running Aspire instances to prevent conflicts",
DefaultValue: true),

[AutoUpdateEnabled] = new(
AutoUpdateEnabled,
"Enable or disable automatic background updates of the Aspire CLI to the latest version",
DefaultValue: true)
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,17 @@

namespace Aspire.Cli.NuGet;

internal sealed class NuGetPackagePrefetcher(ILogger<NuGetPackagePrefetcher> logger, CliExecutionContext executionContext, IFeatures features, IPackagingService packagingService, ICliUpdateNotifier cliUpdateNotifier) : BackgroundService
/// <summary>
/// Background service that prefetches NuGet package metadata and triggers CLI auto-updates.
/// </summary>
internal sealed class CliBackgroundService(
ILogger<CliBackgroundService> logger,
CliExecutionContext executionContext,
IFeatures features,
IPackagingService packagingService,
ICliUpdateNotifier cliUpdateNotifier,
ICliHostEnvironment hostEnvironment,
IConfigurationService configurationService) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
Expand Down Expand Up @@ -45,29 +55,70 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
}, stoppingToken);
}

// Prefetch CLI packages if needed
// Prefetch CLI packages and trigger auto-update if needed
if (shouldPrefetchCli)
{
_ = Task.Run(async () =>
{
if (features.IsFeatureEnabled(KnownFeatures.UpdateNotificationsEnabled, true))
try
{
try
{
await cliUpdateNotifier.CheckForCliUpdatesAsync(
workingDirectory: executionContext.WorkingDirectory,
cancellationToken: stoppingToken
);
}
catch (System.Exception ex)
{
logger.LogDebug(ex, "Non-fatal error while prefetching CLI packages. This is not critical to the operation of the CLI.");
}
await cliUpdateNotifier.CheckForCliUpdatesAsync(
workingDirectory: executionContext.WorkingDirectory,
cancellationToken: stoppingToken
);

// Trigger auto-update if an update is available
await TryTriggerAutoUpdateAsync(command, stoppingToken);
}
catch (System.Exception ex)
{
logger.LogDebug(ex, "Non-fatal error while prefetching CLI packages. This is not critical to the operation of the CLI.");
}
}, stoppingToken);
}
}

private async Task TryTriggerAutoUpdateAsync(SystemCommand? command, CancellationToken cancellationToken)
{
try
{
if (!cliUpdateNotifier.IsUpdateAvailable())
{
return;
}

// Skip auto-update for 'aspire update' command (user is managing updates)
if (command?.Name is "update")
{
return;
}

if (!AutoUpdater.ShouldAutoUpdate(hostEnvironment, features, executionContext))
{
return;
}

// Determine which channel to use for download
var channelName = await configurationService.GetConfigurationAsync("channel", cancellationToken) ?? PackageChannelNames.Stable;
var channels = await packagingService.GetChannelsAsync(cancellationToken);
var channel = channels.FirstOrDefault(c => c.Name.Equals(channelName, StringComparison.OrdinalIgnoreCase));

if (channel?.CliDownloadBaseUrl is null)
{
return;
}

var newerVersion = cliUpdateNotifier.GetNewerVersionString();
AutoUpdater.SpawnBackgroundDownload(channel.CliDownloadBaseUrl, newerVersion);

logger.LogDebug("Spawned background auto-update process for channel {Channel}", channelName);
}
catch (System.Exception ex)
{
logger.LogDebug(ex, "Non-fatal error while triggering auto-update.");
}
}

private async Task<SystemCommand?> WaitForCommandSelectionAsync(CancellationToken cancellationToken)
{
try
Expand Down
13 changes: 11 additions & 2 deletions src/Aspire.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -278,8 +278,8 @@ internal static async Task<IHost> BuildApplicationAsync(string[] args, Dictionar
return sp.GetRequiredService<NuGetPackageCache>();
});

builder.Services.AddSingleton<NuGetPackagePrefetcher>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<NuGetPackagePrefetcher>());
builder.Services.AddSingleton<CliBackgroundService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<CliBackgroundService>());
builder.Services.AddSingleton<AuxiliaryBackchannelMonitor>();
builder.Services.AddSingleton<IAuxiliaryBackchannelMonitor>(sp => sp.GetRequiredService<AuxiliaryBackchannelMonitor>());
builder.Services.AddHostedService(sp => sp.GetRequiredService<AuxiliaryBackchannelMonitor>());
Expand Down Expand Up @@ -574,6 +574,15 @@ public static async Task<int> Main(string[] args)

Console.OutputEncoding = Encoding.UTF8;

// Handle internal auto-update command (runs in a detached background process, no DI needed)
if (args.Length >= 2 && args[0] == "internal-auto-update")
{
return await Utils.AutoUpdater.SilentDownloadAndStageAsync(args[1], args.Length >= 3 ? args[2] : null);
}

// Apply staged update if available (before normal startup)
Utils.AutoUpdater.AppliedVersion = Utils.AutoUpdater.TryApplyStagedUpdate();

using var app = await BuildApplicationAsync(args);

await app.StartAsync().ConfigureAwait(false);
Expand Down
Loading