diff --git a/src/D2L.Bmx/BmxPaths.cs b/src/D2L.Bmx/BmxPaths.cs index ba13e5f8..cf586859 100644 --- a/src/D2L.Bmx/BmxPaths.cs +++ b/src/D2L.Bmx/BmxPaths.cs @@ -11,4 +11,5 @@ internal static class BmxPaths { public static readonly string SESSIONS_FILE_LEGACY_NAME = Path.Join( BMX_DIR, "sessions" ); public static readonly string UPDATE_CHECK_FILE_NAME = Path.Join( CACHE_DIR, "updateCheck" ); public static readonly string AWS_CREDS_CACHE_FILE_NAME = Path.Join( CACHE_DIR, "awsCreds" ); + public static readonly string OLD_BMX_VERSIONS_PATH = Path.Join( BMX_DIR, "temp" ); } diff --git a/src/D2L.Bmx/GithubRelease.cs b/src/D2L.Bmx/GithubRelease.cs new file mode 100644 index 00000000..77c630fd --- /dev/null +++ b/src/D2L.Bmx/GithubRelease.cs @@ -0,0 +1,37 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; + +namespace D2L.Bmx; +internal record GithubRelease { + [JsonPropertyName( "tag_name" )] + public string? TagName { get; init; } + + [JsonPropertyName( "assets" )] + public List? Assets { get; init; } + + public Version? GetReleaseVersion() { + string? version = TagName?.TrimStart( 'v' ); + if( version is null ) { + return null; + } + return new Version( version ); + } + + public static async Task GetLatestReleaseDataAsync() { + using var httpClient = new HttpClient(); + httpClient.BaseAddress = new Uri( "https://api.github.com" ); + httpClient.Timeout = TimeSpan.FromSeconds( 2 ); + httpClient.DefaultRequestHeaders.Add( "User-Agent", "BMX" ); + return await httpClient.GetFromJsonAsync( + "repos/Brightspace/bmx/releases/latest", + SourceGenerationContext.Default.GithubRelease ); + } +} + +internal record GithubAsset { + [JsonPropertyName( "name" )] + public string? Name { get; init; } + + [JsonPropertyName( "browser_download_url" )] + public string? BrowserDownloadUrl { get; init; } +} diff --git a/src/D2L.Bmx/Program.cs b/src/D2L.Bmx/Program.cs index 8c791d16..1d3b07e5 100644 --- a/src/D2L.Bmx/Program.cs +++ b/src/D2L.Bmx/Program.cs @@ -198,6 +198,12 @@ ); } ); +var updateCommand = new Command( "update", "Updates BMX to the latest version" ); +updateCommand.SetHandler( ( InvocationContext context ) => { + var handler = new UpdateHandler(); + return handler.HandleAsync(); +} ); + // root command var rootCommand = new RootCommand( "BMX grants you API access to your AWS accounts!" ) { // put more frequently used commands first, as the order here affects help text @@ -205,6 +211,7 @@ writeCommand, loginCommand, configureCommand, + updateCommand, }; // start bmx @@ -226,6 +233,7 @@ } } + UpdateHandler.Cleanup(); await UpdateChecker.CheckForUpdatesAsync( configProvider.GetConfiguration() ); await next( context ); diff --git a/src/D2L.Bmx/UpdateChecker.cs b/src/D2L.Bmx/UpdateChecker.cs index 54ce9ef6..2dbe485e 100644 --- a/src/D2L.Bmx/UpdateChecker.cs +++ b/src/D2L.Bmx/UpdateChecker.cs @@ -1,7 +1,6 @@ using System.Reflection; using System.Runtime.InteropServices; using System.Text.Json; -using System.Text.Json.Serialization; namespace D2L.Bmx; @@ -12,7 +11,12 @@ public static async Task CheckForUpdatesAsync( BmxConfig config ) { var localVersion = Assembly.GetExecutingAssembly().GetName().Version; var latestVersion = new Version( cachedVersion?.VersionName ?? "0.0.0" ); if( ShouldFetchLatestVersion( cachedVersion ) ) { - latestVersion = new Version( await GetLatestReleaseVersionAsync() ); + GithubRelease? releaseData = await GithubRelease.GetLatestReleaseDataAsync(); + latestVersion = releaseData?.GetReleaseVersion(); + if( latestVersion is null ) { + return; + } + SaveVersion( latestVersion ); } string updateLocation = string.Equals( config.Org, "d2l", StringComparison.OrdinalIgnoreCase ) @@ -49,30 +53,9 @@ private static void DisplayUpdateMessage( string message ) { Console.Error.WriteLine(); } - private static async Task GetLatestReleaseVersionAsync() { - using var httpClient = new HttpClient(); - httpClient.BaseAddress = new Uri( "https://api.github.com" ); - httpClient.Timeout = TimeSpan.FromSeconds( 2 ); - httpClient.DefaultRequestHeaders.Add( "User-Agent", "BMX" ); - var response = await httpClient.GetAsync( "repos/Brightspace/bmx/releases/latest" ); - response.EnsureSuccessStatusCode(); - - await using var responseStream = await response.Content.ReadAsStreamAsync(); - var releaseData = await JsonSerializer.DeserializeAsync( - responseStream, - SourceGenerationContext.Default.GithubRelease - ); - string version = releaseData?.TagName?.TrimStart( 'v' ) ?? string.Empty; - SaveLatestVersion( version ); - return version; - } - - private static void SaveLatestVersion( string version ) { - if( string.IsNullOrWhiteSpace( version ) ) { - return; - } + private static void SaveVersion( Version version ) { var cache = new UpdateCheckCache { - VersionName = version, + VersionName = version.ToString(), TimeLastChecked = DateTimeOffset.UtcNow }; string content = JsonSerializer.Serialize( cache, SourceGenerationContext.Default.UpdateCheckCache ); @@ -112,10 +95,6 @@ private static bool ShouldFetchLatestVersion( UpdateCheckCache? cache ) { } } -internal record GithubRelease { - [JsonPropertyName( "tag_name" )] - public string? TagName { get; set; } -} internal record UpdateCheckCache { public string? VersionName { get; set; } diff --git a/src/D2L.Bmx/UpdateHandler.cs b/src/D2L.Bmx/UpdateHandler.cs new file mode 100644 index 00000000..2d79946a --- /dev/null +++ b/src/D2L.Bmx/UpdateHandler.cs @@ -0,0 +1,156 @@ +using System.Formats.Tar; +using System.IO.Compression; +using System.Reflection; +using System.Runtime.InteropServices; + +namespace D2L.Bmx; + +internal class UpdateHandler { + + public async Task HandleAsync() { + if( !Directory.Exists( BmxPaths.OLD_BMX_VERSIONS_PATH ) ) { + try { + Directory.CreateDirectory( BmxPaths.OLD_BMX_VERSIONS_PATH ); + } catch( Exception ex ) { + throw new BmxException( "Failed to initialize temporary BMX file directory (~/.bmx/temp)", ex ); + } + } + + using var httpClient = new HttpClient(); + GithubRelease? releaseData = await GithubRelease.GetLatestReleaseDataAsync(); + Version? latestVersion = releaseData?.GetReleaseVersion(); + if( latestVersion is null ) { + throw new BmxException( "Failed to find the latest version of BMX." ); + } + + var localVersion = Assembly.GetExecutingAssembly().GetName().Version; + if( latestVersion <= localVersion ) { + Console.WriteLine( $"You already have the latest version {latestVersion}" ); + return; + } + + string archiveName = GetOSFileName(); + string? downloadUrl = releaseData?.Assets?.FirstOrDefault( a => a.Name == archiveName )?.BrowserDownloadUrl; + if( string.IsNullOrWhiteSpace( downloadUrl ) ) { + throw new BmxException( "Failed to find the download URL of the latest BMX" ); + } + + string? currentFilePath = Environment.ProcessPath; + if( string.IsNullOrEmpty( currentFilePath ) ) { + throw new BmxException( "BMX could not update" ); + } + + string downloadPath = Path.GetTempFileName(); + try { + var archiveRes = await httpClient.GetAsync( downloadUrl, HttpCompletionOption.ResponseHeadersRead ); + using var fs = new FileStream( downloadPath, FileMode.Create, FileAccess.Write, FileShare.None ); + await archiveRes.Content.CopyToAsync( fs ); + await fs.FlushAsync(); + } catch( Exception ex ) { + throw new BmxException( "Failed to download the update", ex ); + } + + string extractFolder = Path.Combine( Path.GetTempPath(), Path.GetRandomFileName() ); + try { + Directory.CreateDirectory( extractFolder ); + } catch( Exception ex ) { + File.Delete( downloadPath ); + throw new BmxException( "Failed to initialize temporary folder for downloaded file", ex ); + } + + string currentDirectory = Path.GetDirectoryName( currentFilePath )!; + long backupPathTimeStamp = DateTime.Now.Millisecond; + string backupPath = Path.Join( BmxPaths.OLD_BMX_VERSIONS_PATH, $"bmx-v{localVersion}-{backupPathTimeStamp}-old.bak" ); + try { + string extension = Path.GetExtension( downloadUrl ); + + if( extension.Equals( ".zip", StringComparison.OrdinalIgnoreCase ) ) { + ExtractZipFile( downloadPath, extractFolder ); + } else if( extension.Equals( ".gz", StringComparison.OrdinalIgnoreCase ) ) { + ExtractTarGzipFile( downloadPath, extractFolder ); + } else { + throw new Exception( "Unknown archive type" ); + } + } catch( Exception ex ) { + Directory.Delete( extractFolder, recursive: true ); + throw new BmxException( "Failed to update with new files", ex ); + } finally { + File.Delete( downloadPath ); + } + + try { + File.Move( currentFilePath, backupPath ); + } catch( IOException ex ) { + Directory.Delete( extractFolder, recursive: true ); + throw new BmxException( "Could not remove the old version. Please try again with elevated permissions.", ex ); + } catch { + Directory.Delete( extractFolder, recursive: true ); + throw new BmxException( "BMX could not update" ); + } + + try { + foreach( string file in Directory.GetFiles( extractFolder ) ) { + string destinationFile = Path.Combine( currentDirectory, Path.GetFileName( file ) ); + File.Move( file, destinationFile ); + } + } catch( Exception ex ) { + File.Move( backupPath, currentFilePath ); + throw new BmxException( "BMX could not update with the new version", ex ); + } finally { + Directory.Delete( extractFolder, recursive: true ); + } + } + + private static string GetOSFileName() { + + if( RuntimeInformation.IsOSPlatform( OSPlatform.Windows ) ) { + return "bmx-win-x64.zip"; + } else if( RuntimeInformation.IsOSPlatform( OSPlatform.OSX ) ) { + return "bmx-osx-x64.tar.gz"; + } else if( RuntimeInformation.IsOSPlatform( OSPlatform.Linux ) ) { + return "bmx-linux-x64.tar.gz"; + } else { + throw new BmxException( "New version does not support you current OS" ); + } + } + + public static void Cleanup() { + if( Directory.Exists( BmxPaths.OLD_BMX_VERSIONS_PATH ) ) { + try { + Directory.Delete( BmxPaths.OLD_BMX_VERSIONS_PATH, recursive: true ); + } catch( Exception ) { + Console.Error.WriteLine( "WARNING: Failed to delete old version files" ); + } + } + } + + private static void ExtractTarGzipFile( string compressedFilePath, string decompressedFilePath ) { + string tarPath = Path.Combine( decompressedFilePath, "bmx.tar" ); + using( FileStream compressedFileStream = File.Open( + compressedFilePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read ) + ) { + using FileStream outputFileStream = File.Create( tarPath ); + using var decompressor = new GZipStream( compressedFileStream, CompressionMode.Decompress ); + decompressor.CopyTo( outputFileStream ); + } + + try { + TarFile.ExtractToDirectory( tarPath, decompressedFilePath, true ); + } finally { + File.Delete( tarPath ); + } + } + + private static void ExtractZipFile( string compressedFilePath, string decompressedFilePath ) { + using ZipArchive archive = ZipFile.OpenRead( compressedFilePath ); + foreach( ZipArchiveEntry entry in archive.Entries ) { + string destinationPath = Path.GetFullPath( Path.Combine( decompressedFilePath, entry.FullName ) ); + if( destinationPath.StartsWith( decompressedFilePath, StringComparison.Ordinal ) ) { + entry.ExtractToFile( destinationPath, overwrite: true ); + } + } + } +}