22// The .NET Foundation licenses this file to you under the MIT license.
33
44using System . CommandLine ;
5+ using System . Diagnostics ;
6+ using System . Formats . Tar ;
7+ using System . IO . Compression ;
8+ using System . Runtime . InteropServices ;
59using Aspire . Cli . Configuration ;
610using Aspire . Cli . Interaction ;
711using Aspire . Cli . Packaging ;
812using Aspire . Cli . Projects ;
913using Aspire . Cli . Resources ;
1014using Aspire . Cli . Utils ;
15+ using Microsoft . Extensions . Logging ;
1116using Spectre . Console ;
1217
1318namespace 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}
0 commit comments