Skip to content

Commit 6240bd5

Browse files
CopilotmitchdennydavidfowlMitch Denny
authored
Add aspire update --self command for CLI self-updates (#12118)
* Initial plan * Add aspire self update command implementation Co-authored-by: mitchdenny <[email protected]> * Add documentation for aspire self update command Co-authored-by: mitchdenny <[email protected]> * Use File.GetUnixFileMode/SetUnixFileMode instead of chmod command Co-authored-by: davidfowl <[email protected]> * Display version after update and hide self command for dotnet tool Co-authored-by: davidfowl <[email protected]> * Update quality names to match existing channels and add interactive prompt Co-authored-by: davidfowl <[email protected]> * Change command from 'aspire self update' to 'aspire update --self' Co-authored-by: davidfowl <[email protected]> * Use ShowStatusAsync for CLI download operation Co-authored-by: davidfowl <[email protected]> * Fix tests. * Update emojis. * Emoji fixes. * Move CLI download URLs from CliDownloader to PackagingService Co-authored-by: mitchdenny <[email protected]> * PR feedback. * Apply PR feedback: use Directory.CreateTempSubdirectory, singleton HttpClient, Path.PathSeparator, and log warnings Co-authored-by: davidfowl <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: mitchdenny <[email protected]> Co-authored-by: davidfowl <[email protected]> Co-authored-by: Mitch Denny <[email protected]> Co-authored-by: Mitch Denny <[email protected]>
1 parent a04174e commit 6240bd5

File tree

8 files changed

+591
-7
lines changed

8 files changed

+591
-7
lines changed

src/Aspire.Cli/Commands/UpdateCommand.cs

Lines changed: 316 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.CommandLine;
5+
using System.Diagnostics;
6+
using System.Formats.Tar;
7+
using System.IO.Compression;
8+
using System.Runtime.InteropServices;
59
using Aspire.Cli.Configuration;
610
using Aspire.Cli.Interaction;
711
using Aspire.Cli.Packaging;
812
using Aspire.Cli.Projects;
913
using Aspire.Cli.Resources;
1014
using Aspire.Cli.Utils;
15+
using Microsoft.Extensions.Logging;
1116
using Spectre.Console;
1217

1318
namespace Aspire.Cli.Commands;
@@ -17,24 +22,82 @@ internal sealed class UpdateCommand : BaseCommand
1722
private readonly IProjectLocator _projectLocator;
1823
private readonly IPackagingService _packagingService;
1924
private readonly IProjectUpdater _projectUpdater;
25+
private readonly ILogger<UpdateCommand> _logger;
26+
private readonly ICliDownloader? _cliDownloader;
2027

21-
public UpdateCommand(IProjectLocator projectLocator, IPackagingService packagingService, IProjectUpdater projectUpdater, IInteractionService interactionService, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext) : base("update", UpdateCommandStrings.Description, features, updateNotifier, executionContext, interactionService)
28+
public UpdateCommand(
29+
IProjectLocator projectLocator,
30+
IPackagingService packagingService,
31+
IProjectUpdater projectUpdater,
32+
ILogger<UpdateCommand> logger,
33+
ICliDownloader? cliDownloader,
34+
IInteractionService interactionService,
35+
IFeatures features,
36+
ICliUpdateNotifier updateNotifier,
37+
CliExecutionContext executionContext)
38+
: base("update", UpdateCommandStrings.Description, features, updateNotifier, executionContext, interactionService)
2239
{
2340
ArgumentNullException.ThrowIfNull(projectLocator);
2441
ArgumentNullException.ThrowIfNull(packagingService);
2542
ArgumentNullException.ThrowIfNull(projectUpdater);
43+
ArgumentNullException.ThrowIfNull(logger);
2644

2745
_projectLocator = projectLocator;
2846
_packagingService = packagingService;
2947
_projectUpdater = projectUpdater;
48+
_logger = logger;
49+
_cliDownloader = cliDownloader;
3050

3151
var projectOption = new Option<FileInfo?>("--project");
3252
projectOption.Description = UpdateCommandStrings.ProjectArgumentDescription;
3353
Options.Add(projectOption);
54+
55+
// Only add --self option if not running as dotnet tool
56+
if (!IsRunningAsDotNetTool())
57+
{
58+
var selfOption = new Option<bool>("--self");
59+
selfOption.Description = "Update the Aspire CLI itself to the latest version";
60+
Options.Add(selfOption);
61+
62+
var qualityOption = new Option<string?>("--quality");
63+
qualityOption.Description = "Quality level to update to when using --self (stable, staging, daily)";
64+
Options.Add(qualityOption);
65+
}
66+
}
67+
68+
protected override bool UpdateNotificationsEnabled => false;
69+
70+
private static bool IsRunningAsDotNetTool()
71+
{
72+
// When running as a dotnet tool, the process path points to "dotnet" or "dotnet.exe"
73+
// When running as a native binary, it points to "aspire" or "aspire.exe"
74+
var processPath = Environment.ProcessPath;
75+
if (string.IsNullOrEmpty(processPath))
76+
{
77+
return false;
78+
}
79+
80+
var fileName = Path.GetFileNameWithoutExtension(processPath);
81+
return string.Equals(fileName, "dotnet", StringComparison.OrdinalIgnoreCase);
3482
}
3583

3684
protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
3785
{
86+
var isSelfUpdate = parseResult.GetValue<bool>("--self");
87+
88+
// If --self is specified, handle CLI self-update
89+
if (isSelfUpdate)
90+
{
91+
if (_cliDownloader is null)
92+
{
93+
InteractionService.DisplayError("CLI self-update is not available in this environment.");
94+
return ExitCodeConstants.InvalidCommand;
95+
}
96+
97+
return await ExecuteSelfUpdateAsync(parseResult, cancellationToken);
98+
}
99+
100+
// Otherwise, handle project update
38101
try
39102
{
40103
var passedAppHostProjectFile = parseResult.GetValue<FileInfo?>("--project");
@@ -67,4 +130,256 @@ protected override async Task<int> ExecuteAsync(ParseResult parseResult, Cancell
67130

68131
return 0;
69132
}
133+
134+
private async Task<int> ExecuteSelfUpdateAsync(ParseResult parseResult, CancellationToken cancellationToken)
135+
{
136+
var quality = parseResult.GetValue<string?>("--quality");
137+
138+
// If quality is not specified, prompt the user
139+
if (string.IsNullOrEmpty(quality))
140+
{
141+
var qualities = new[] { "stable", "staging", "daily" };
142+
quality = await InteractionService.PromptForSelectionAsync(
143+
"Select the quality level to update to:",
144+
qualities,
145+
q => q,
146+
cancellationToken);
147+
}
148+
149+
try
150+
{
151+
// Get current executable path for display purposes only
152+
var currentExePath = Environment.ProcessPath;
153+
if (string.IsNullOrEmpty(currentExePath))
154+
{
155+
InteractionService.DisplayError("Unable to determine the current executable path.");
156+
return ExitCodeConstants.InvalidCommand;
157+
}
158+
159+
InteractionService.DisplayMessage("package", $"Current CLI location: {currentExePath}");
160+
InteractionService.DisplayMessage("up_arrow", $"Updating to quality level: {quality}");
161+
162+
// Download the latest CLI
163+
var archivePath = await _cliDownloader!.DownloadLatestCliAsync(quality, cancellationToken);
164+
165+
// Extract and update to $HOME/.aspire/bin
166+
await ExtractAndUpdateAsync(archivePath, cancellationToken);
167+
168+
return 0;
169+
}
170+
catch (Exception ex)
171+
{
172+
_logger.LogError(ex, "Failed to update CLI");
173+
InteractionService.DisplayError($"Failed to update CLI: {ex.Message}");
174+
return ExitCodeConstants.InvalidCommand;
175+
}
176+
}
177+
178+
private async Task ExtractAndUpdateAsync(string archivePath, CancellationToken cancellationToken)
179+
{
180+
// Always install to $HOME/.aspire/bin
181+
var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
182+
if (string.IsNullOrEmpty(homeDir))
183+
{
184+
throw new InvalidOperationException("Unable to determine home directory.");
185+
}
186+
187+
var installDir = Path.Combine(homeDir, ".aspire", "bin");
188+
Directory.CreateDirectory(installDir);
189+
190+
var exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "aspire.exe" : "aspire";
191+
var targetExePath = Path.Combine(installDir, exeName);
192+
var tempExtractDir = Directory.CreateTempSubdirectory("aspire-cli-extract").FullName;
193+
194+
try
195+
{
196+
197+
// Extract archive
198+
InteractionService.DisplayMessage("package", "Extracting new CLI...");
199+
await ExtractArchiveAsync(archivePath, tempExtractDir, cancellationToken);
200+
201+
// Find the aspire executable in the extracted files
202+
var newExePath = Path.Combine(tempExtractDir, exeName);
203+
if (!File.Exists(newExePath))
204+
{
205+
throw new FileNotFoundException($"Extracted CLI executable not found: {newExePath}");
206+
}
207+
208+
// Backup current executable if it exists
209+
var backupPath = $"{targetExePath}.old";
210+
if (File.Exists(targetExePath))
211+
{
212+
InteractionService.DisplayMessage("floppy_disk", "Backing up current CLI...");
213+
_logger.LogDebug("Creating backup: {BackupPath}", backupPath);
214+
215+
// Remove old backup if it exists
216+
if (File.Exists(backupPath))
217+
{
218+
File.Delete(backupPath);
219+
}
220+
221+
// Rename current executable to .old
222+
File.Move(targetExePath, backupPath);
223+
}
224+
225+
try
226+
{
227+
// Copy new executable to install location
228+
InteractionService.DisplayMessage("wrench", $"Installing new CLI to {installDir}...");
229+
File.Copy(newExePath, targetExePath, overwrite: true);
230+
231+
// On Unix systems, ensure the executable bit is set
232+
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
233+
{
234+
SetExecutablePermission(targetExePath);
235+
}
236+
237+
// Test the new executable and display its version
238+
_logger.LogDebug("Testing new CLI executable and displaying version");
239+
var newVersion = await GetNewVersionAsync(targetExePath, cancellationToken);
240+
if (newVersion is null)
241+
{
242+
throw new InvalidOperationException("New CLI executable failed verification test.");
243+
}
244+
245+
// If we get here, the update was successful, remove the backup
246+
if (File.Exists(backupPath))
247+
{
248+
_logger.LogDebug("Update successful, removing backup");
249+
File.Delete(backupPath);
250+
}
251+
252+
// Display helpful message about PATH
253+
if (!IsInPath(installDir))
254+
{
255+
InteractionService.DisplayMessage("information", $"Note: {installDir} is not in your PATH. Add it to use the updated CLI globally.");
256+
}
257+
}
258+
catch
259+
{
260+
// If anything goes wrong, restore the backup
261+
_logger.LogWarning("Update failed, restoring backup");
262+
if (File.Exists(backupPath))
263+
{
264+
if (File.Exists(targetExePath))
265+
{
266+
File.Delete(targetExePath);
267+
}
268+
File.Move(backupPath, targetExePath);
269+
}
270+
throw;
271+
}
272+
}
273+
finally
274+
{
275+
// Clean up temp directories
276+
CleanupDirectory(tempExtractDir);
277+
CleanupDirectory(Path.GetDirectoryName(archivePath)!);
278+
}
279+
}
280+
281+
private static bool IsInPath(string directory)
282+
{
283+
var pathEnv = Environment.GetEnvironmentVariable("PATH");
284+
if (string.IsNullOrEmpty(pathEnv))
285+
{
286+
return false;
287+
}
288+
289+
var pathSeparator = Path.PathSeparator;
290+
var paths = pathEnv.Split(pathSeparator, StringSplitOptions.RemoveEmptyEntries);
291+
292+
return paths.Any(p =>
293+
string.Equals(Path.GetFullPath(p.Trim()), Path.GetFullPath(directory),
294+
RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
295+
? StringComparison.OrdinalIgnoreCase
296+
: StringComparison.Ordinal));
297+
}
298+
299+
private static async Task ExtractArchiveAsync(string archivePath, string destinationPath, CancellationToken cancellationToken)
300+
{
301+
if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
302+
{
303+
ZipFile.ExtractToDirectory(archivePath, destinationPath, overwriteFiles: true);
304+
}
305+
else if (archivePath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase))
306+
{
307+
await using var fileStream = new FileStream(archivePath, FileMode.Open, FileAccess.Read);
308+
await using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress);
309+
await TarFile.ExtractToDirectoryAsync(gzipStream, destinationPath, overwriteFiles: true, cancellationToken);
310+
}
311+
else
312+
{
313+
throw new NotSupportedException($"Unsupported archive format: {archivePath}");
314+
}
315+
}
316+
317+
private void SetExecutablePermission(string filePath)
318+
{
319+
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
320+
{
321+
try
322+
{
323+
var mode = File.GetUnixFileMode(filePath);
324+
mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
325+
File.SetUnixFileMode(filePath, mode);
326+
}
327+
catch (Exception ex)
328+
{
329+
_logger.LogWarning(ex, "Failed to set executable permission on {FilePath}", filePath);
330+
}
331+
}
332+
}
333+
334+
private async Task<string?> GetNewVersionAsync(string exePath, CancellationToken cancellationToken)
335+
{
336+
try
337+
{
338+
var psi = new ProcessStartInfo
339+
{
340+
FileName = exePath,
341+
Arguments = "--version",
342+
RedirectStandardOutput = true,
343+
RedirectStandardError = true,
344+
UseShellExecute = false
345+
};
346+
347+
using var process = Process.Start(psi);
348+
if (process is null)
349+
{
350+
return null;
351+
}
352+
353+
var output = await process.StandardOutput.ReadToEndAsync(cancellationToken);
354+
await process.WaitForExitAsync(cancellationToken);
355+
356+
if (process.ExitCode == 0)
357+
{
358+
var version = output.Trim();
359+
InteractionService.DisplaySuccess($"Updated to version: {version}");
360+
return version;
361+
}
362+
363+
return null;
364+
}
365+
catch
366+
{
367+
return null;
368+
}
369+
}
370+
371+
private void CleanupDirectory(string directory)
372+
{
373+
try
374+
{
375+
if (Directory.Exists(directory))
376+
{
377+
Directory.Delete(directory, recursive: true);
378+
}
379+
}
380+
catch (Exception ex)
381+
{
382+
_logger.LogWarning(ex, "Failed to clean up directory {Directory}", directory);
383+
}
384+
}
70385
}

src/Aspire.Cli/Packaging/PackageChannel.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@
88

99
namespace Aspire.Cli.Packaging;
1010

11-
internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false)
11+
internal class PackageChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null)
1212
{
1313
public string Name { get; } = name;
1414
public PackageChannelQuality Quality { get; } = quality;
1515
public PackageMapping[]? Mappings { get; } = mappings;
1616
public PackageChannelType Type { get; } = mappings is null ? PackageChannelType.Implicit : PackageChannelType.Explicit;
1717
public bool ConfigureGlobalPackagesFolder { get; } = configureGlobalPackagesFolder;
18+
public string? CliDownloadBaseUrl { get; } = cliDownloadBaseUrl;
1819

1920
public string SourceDetails { get; } = ComputeSourceDetails(mappings);
2021

@@ -174,9 +175,9 @@ public async Task<IEnumerable<NuGetPackage>> GetPackagesAsync(string packageId,
174175
return filteredPackages;
175176
}
176177

177-
public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false)
178+
public static PackageChannel CreateExplicitChannel(string name, PackageChannelQuality quality, PackageMapping[]? mappings, INuGetPackageCache nuGetPackageCache, bool configureGlobalPackagesFolder = false, string? cliDownloadBaseUrl = null)
178179
{
179-
return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder);
180+
return new PackageChannel(name, quality, mappings, nuGetPackageCache, configureGlobalPackagesFolder, cliDownloadBaseUrl);
180181
}
181182

182183
public static PackageChannel CreateImplicitChannel(INuGetPackageCache nuGetPackageCache)

0 commit comments

Comments
 (0)