From 032494703308e543055876e138667ff066fa7e69 Mon Sep 17 00:00:00 2001 From: Shaun Date: Sun, 18 Aug 2024 05:06:43 -0400 Subject: [PATCH] Introduce DotMake.CommandLine for easier System.CommandLine organization --- NodeSwap/Commands/AvailCommand.cs | 31 +++-- NodeSwap/Commands/InstallCommand.cs | 165 +++++++++++++++----------- NodeSwap/Commands/ListCommand.cs | 15 +-- NodeSwap/Commands/RootCommand.cs | 6 + NodeSwap/Commands/UninstallCommand.cs | 27 +++-- NodeSwap/Commands/UseCommand.cs | 33 +++--- NodeSwap/NodeSwap.csproj | 6 +- NodeSwap/Program.cs | 65 +++------- 8 files changed, 185 insertions(+), 163 deletions(-) create mode 100644 NodeSwap/Commands/RootCommand.cs diff --git a/NodeSwap/Commands/AvailCommand.cs b/NodeSwap/Commands/AvailCommand.cs index 58fcc14..47ec17f 100644 --- a/NodeSwap/Commands/AvailCommand.cs +++ b/NodeSwap/Commands/AvailCommand.cs @@ -1,23 +1,32 @@ using System; -using System.CommandLine.Invocation; using System.Threading.Tasks; +using DotMake.CommandLine; using NodeSwap.Utils; namespace NodeSwap.Commands; -public class AvailCommand(NodeJsWebApi nodeWeb) : ICommandHandler +[CliCommand( + Description = + "Discover Node.js versions available for download.", + Parent = typeof(RootCommand) +)] +public class AvailCommand(NodeJsWebApi nodeWeb) { - public Task InvokeAsync(InvocationContext context) - { - var versionPrefix = context.ParseResult.ValueForArgument("prefix"); + [CliArgument( + Description = + "Can be specific like `22.6.0`, or fuzzy like `22.6` or `22`.") + ] + public string Prefix { get; set; } = ""; + public async Task RunAsync() + { try { - var versions = nodeWeb.GetInstallableNodeVersions(versionPrefix?.ToString()); + var versions = await nodeWeb.GetInstallableNodeVersions(Prefix); if (versions.Count == 0) { Console.WriteLine("None found"); - return Task.Factory.StartNew(() => 1); + return 1; } var consoleWidth = Console.WindowWidth; @@ -29,13 +38,13 @@ public Task InvokeAsync(InvocationContext context) (v) => v.ToString().PadLeft(consoleWidth / numColumns, ' ') ); Console.WriteLine(); - - return Task.Factory.StartNew(() => 0); } catch (Exception e) { - Console.Error.WriteLine(e.Message); - return Task.Factory.StartNew(() => 1); + await Console.Error.WriteLineAsync(e.Message); + return 1; } + + return 0; } } \ No newline at end of file diff --git a/NodeSwap/Commands/InstallCommand.cs b/NodeSwap/Commands/InstallCommand.cs index fbc953e..c7acd88 100644 --- a/NodeSwap/Commands/InstallCommand.cs +++ b/NodeSwap/Commands/InstallCommand.cs @@ -1,82 +1,88 @@ using System; -using System.CommandLine.Invocation; using System.IO; using System.IO.Compression; -using System.Net; +using System.Net.Http; using System.Threading.Tasks; using System.Timers; +using DotMake.CommandLine; using NodeSwap.Utils; using ShellProgressBar; namespace NodeSwap.Commands; +[CliCommand( + Description = "Install a version of Node.js", + Parent = typeof(RootCommand) +)] public class InstallCommand(GlobalContext globalContext, NodeJsWebApi nodeWeb, NodeJs nodeLocal) - : ICommandHandler { - public async Task InvokeAsync(InvocationContext context) + [CliArgument(Description = "`latest`, specific e.g. `22.6.0`, or fuzzy e.g. `22.6` or `22`.")] + public string Version { get; set; } + + [CliOption(Description = "Re-install if installed already")] + public bool Force { get; set; } + + public async Task RunAsync() { - var rawVersion = context.ParseResult.ValueForArgument("version"); - if (rawVersion == null) + // Retrieve and validate version argument + if (string.IsNullOrEmpty(Version)) { await Console.Error.WriteLineAsync("Missing version argument"); return 1; } - Version version; - if (rawVersion.ToString()?.ToLower() == "latest") - { - try - { - version = nodeWeb.GetLatestNodeVersion(); - } - catch (Exception) - { - await Console.Error.WriteLineAsync("Unable to determine latest Node.js version."); - return 1; - } - } - else if (rawVersion.ToString()?.Split(".").Length < 3) + // Determine the version to install + var version = await GetVersion(Version); + if (version == null) return 1; + + // Check if the requested version is already installed + if (!Force && IsVersionInstalled(version)) { - try - { - version = nodeWeb.GetLatestNodeVersion(rawVersion.ToString()); - } - catch (Exception) - { - await Console.Error.WriteLineAsync($"Unable to get latest Node.js version " + - $"with prefix {rawVersion}."); - return 1; - } + await Console.Error.WriteLineAsync($"{version} already installed"); + return 1; } - else + + // Download and install Node.js + var downloadUrl = nodeWeb.GetDownloadUrl(version); + var zipPath = Path.Join(globalContext.StoragePath, Path.GetFileName(downloadUrl)); + var downloadResult = await DownloadNodeJs(downloadUrl, zipPath); + + if (!downloadResult) return 1; + + // Extract the downloaded file + ExtractNodeJs(zipPath); + + // Completion message + Console.WriteLine($"Done. To use, run `nodeswap use {version}`"); + return 0; + } + + private async Task GetVersion(string rawVersion) + { + try { - try - { - version = VersionParser.Parse(rawVersion.ToString()); - } - catch (ArgumentException) - { - await Console.Error.WriteLineAsync($"Invalid version argument: {rawVersion}"); - return 1; - } - } + if (rawVersion.Equals("latest", StringComparison.CurrentCultureIgnoreCase)) + return await nodeWeb.GetLatestNodeVersion(); - // - // Is the requested version already installed? - // + if (rawVersion.Split(".").Length < 3) + return await nodeWeb.GetLatestNodeVersion(rawVersion); - if (nodeLocal.GetInstalledVersions().FindIndex(v => v.Version.Equals(version)) != -1) + return VersionParser.Parse(rawVersion); + } + catch (Exception ex) { - await Console.Error.WriteLineAsync($"{version} already installed"); - return 1; + await Console.Error.WriteLineAsync($"Error determining version: {ex.Message}"); + return null; } + } - // - // Download it - // + private bool IsVersionInstalled(Version version) + { + return nodeLocal.GetInstalledVersions().FindIndex(v => v.Version.Equals(version)) != -1; + } - var downloadUrl = nodeWeb.GetDownloadUrl(version); - var zipPath = Path.Join(globalContext.StoragePath, Path.GetFileName(downloadUrl)); + private async Task DownloadNodeJs(string downloadUrl, string zipPath) + { var progressBar = new ProgressBar(100, "Download progress", new ProgressBarOptions { ProgressCharacter = '\u2593', @@ -84,35 +90,58 @@ await Console.Error.WriteLineAsync($"Unable to get latest Node.js version " + ForegroundColorDone = ConsoleColor.Green, }); - var webClient = new WebClient(); - webClient.DownloadProgressChanged += (s, e) => { progressBar.Tick(e.ProgressPercentage); }; - webClient.DownloadFileCompleted += (s, e) => { progressBar.Dispose(); }; - try { - await webClient.DownloadFileTaskAsync(downloadUrl, zipPath).ConfigureAwait(false); + var httpClient = new HttpClient(); + using var response = await httpClient.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + + var totalBytes = response.Content.Headers.ContentLength ?? -1L; + var canReportProgress = totalBytes != -1; + + await using var fileStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write, FileShare.None); + await using var contentStream = await response.Content.ReadAsStreamAsync(); + + var buffer = new byte[8192]; + long totalRead = 0; + int bytesRead; + + while ((bytesRead = await contentStream.ReadAsync(buffer)) > 0) + { + await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead)); + if (!canReportProgress) continue; + totalRead += bytesRead; + var progressPercentage = (int) (totalRead * 100 / totalBytes); + progressBar.Tick(progressPercentage); + } + + progressBar.Dispose(); + return true; } catch (Exception e) { await Console.Error.WriteLineAsync("Unable to download the Node.js zip file."); - if (e.InnerException == null) return 1; + if (e.InnerException == null) return false; await Console.Error.WriteLineAsync(e.InnerException.Message); - await Console.Error.WriteLineAsync("You may need to run this command from an " + - "elevated prompt. (Run as Administrator)"); - return 1; + await Console.Error.WriteLineAsync( + "You may need to run this command from an elevated prompt. (Run as Administrator)"); + return false; } + } + private void ExtractNodeJs(string zipPath) + { Console.WriteLine("Extracting..."); ConsoleSpinner.Instance.Update(); + var timer = new Timer(250); - timer.Elapsed += (s, e) => ConsoleSpinner.Instance.Update(); - timer.Enabled = true; - ZipFile.ExtractToDirectory(zipPath, globalContext.StoragePath); - timer.Enabled = false; + timer.Elapsed += (_, _) => ConsoleSpinner.Instance.Update(); + timer.Start(); + + ZipFile.ExtractToDirectory(zipPath, globalContext.StoragePath, overwriteFiles: true); + + timer.Stop(); ConsoleSpinner.Reset(); File.Delete(zipPath); - - Console.WriteLine($"Done. To use, run `nodeswap use {version}`"); - return 0; } } \ No newline at end of file diff --git a/NodeSwap/Commands/ListCommand.cs b/NodeSwap/Commands/ListCommand.cs index 61b44ce..d958871 100644 --- a/NodeSwap/Commands/ListCommand.cs +++ b/NodeSwap/Commands/ListCommand.cs @@ -1,18 +1,21 @@ using System; -using System.CommandLine.Invocation; -using System.Threading.Tasks; +using DotMake.CommandLine; namespace NodeSwap.Commands; -public class ListCommand(NodeJs nodeJs) : ICommandHandler +[CliCommand( + Description = "List installed versions of Node.js.", + Parent = typeof(RootCommand) +)] +public class ListCommand(NodeJs nodeJs) { - public Task InvokeAsync(InvocationContext context) + public void Run() { var versions = nodeJs.GetInstalledVersions(); if (versions.Count == 0) { Console.WriteLine("None installed"); - return Task.Factory.StartNew(() => 0); + return; } Console.WriteLine(); @@ -22,7 +25,5 @@ public Task InvokeAsync(InvocationContext context) Console.WriteLine($"{prefix}{v.Version}"); }); Console.WriteLine(); - - return Task.Factory.StartNew(() => 0); } } \ No newline at end of file diff --git a/NodeSwap/Commands/RootCommand.cs b/NodeSwap/Commands/RootCommand.cs new file mode 100644 index 0000000..e06ceaa --- /dev/null +++ b/NodeSwap/Commands/RootCommand.cs @@ -0,0 +1,6 @@ +using DotMake.CommandLine; + +namespace NodeSwap.Commands; + +[CliCommand] +public class RootCommand; \ No newline at end of file diff --git a/NodeSwap/Commands/UninstallCommand.cs b/NodeSwap/Commands/UninstallCommand.cs index d48fd98..a1aa792 100644 --- a/NodeSwap/Commands/UninstallCommand.cs +++ b/NodeSwap/Commands/UninstallCommand.cs @@ -1,29 +1,34 @@ using System; -using System.CommandLine.Invocation; using System.IO; -using System.Threading.Tasks; +using DotMake.CommandLine; namespace NodeSwap.Commands; -public class UninstallCommand(NodeJs nodeLocal) : ICommandHandler +[CliCommand( + Description = "Uninstall a specific version of Node.js", + Parent = typeof(RootCommand) +)] +public class UninstallCommand(NodeJs nodeLocal) { - public async Task InvokeAsync(InvocationContext context) + [CliArgument(Description = "e.g. `22.6.0`. Run `list` command to see installed versions.")] + public string Version { get; set; } + + public int Run() { - var rawVersion = context.ParseResult.ValueForArgument("version"); - if (rawVersion == null) + if (Version == null) { - await Console.Error.WriteLineAsync($"Missing version argument"); + Console.Error.WriteLine("Missing version argument"); return 1; } Version version; try { - version = VersionParser.StrictParse(rawVersion.ToString()!); + version = VersionParser.StrictParse(Version); } catch (ArgumentException) { - await Console.Error.WriteLineAsync($"Invalid version argument: {rawVersion}"); + Console.Error.WriteLine($"Invalid version argument: {Version}"); return 1; } @@ -34,7 +39,7 @@ public async Task InvokeAsync(InvocationContext context) var nodeVersion = nodeLocal.GetInstalledVersions().Find(v => v.Version.Equals(version)); if (nodeVersion == null) { - await Console.Error.WriteLineAsync($"{version} not installed"); + Console.Error.WriteLine($"{version} not installed"); return 1; } @@ -48,7 +53,7 @@ public async Task InvokeAsync(InvocationContext context) } catch (IOException) { - await Console.Error.WriteLineAsync($"Unable to delete {nodeVersion.Path}"); + Console.Error.WriteLine($"Unable to delete {nodeVersion.Path}"); return 1; } diff --git a/NodeSwap/Commands/UseCommand.cs b/NodeSwap/Commands/UseCommand.cs index 1d9a8a3..fbc0934 100644 --- a/NodeSwap/Commands/UseCommand.cs +++ b/NodeSwap/Commands/UseCommand.cs @@ -1,30 +1,35 @@ using System; -using System.CommandLine.Invocation; using System.IO; using System.Runtime.InteropServices; -using System.Threading.Tasks; +using DotMake.CommandLine; namespace NodeSwap.Commands; -public class UseCommand(GlobalContext globalContext, NodeJs nodeLocal) : ICommandHandler +[CliCommand( + Description = "Switch to an installed version of Node.js.", + Parent = typeof(RootCommand) +)] +public class UseCommand(GlobalContext globalContext, NodeJs nodeLocal) { - public async Task InvokeAsync(InvocationContext context) + [CliArgument(Description = "`latest` or specific e.g. `22.6.0`. Run `list` command to see installed versions.")] + public string Version { get; set; } + + public int Run() { - var rawVersion = context.ParseResult.ValueForArgument("version"); - if (rawVersion == null) + if (Version == null) { - await Console.Error.WriteLineAsync("Missing version argument"); + Console.Error.WriteLine("Missing version argument"); return 1; } NodeJsVersion nodeVersion; - if (rawVersion.ToString() == "latest") + if (Version == "latest") { nodeVersion = nodeLocal.GetLatestInstalledVersion(); if (nodeVersion == null) { - await Console.Error.WriteLineAsync("There are no versions installed"); + Console.Error.WriteLine("There are no versions installed"); return 1; } } @@ -33,11 +38,11 @@ public async Task InvokeAsync(InvocationContext context) Version version; try { - version = VersionParser.StrictParse(rawVersion.ToString()!); + version = VersionParser.StrictParse(Version); } catch (ArgumentException) { - await Console.Error.WriteLineAsync($"Invalid version argument: {rawVersion}"); + Console.Error.WriteLine($"Invalid version argument: {Version}"); return 1; } @@ -48,7 +53,7 @@ public async Task InvokeAsync(InvocationContext context) nodeVersion = nodeLocal.GetInstalledVersions().Find(v => v.Version.Equals(version)); if (nodeVersion == null) { - await Console.Error.WriteLineAsync($"{version} not installed"); + Console.Error.WriteLine($"{version} not installed"); return 1; } } @@ -65,7 +70,7 @@ public async Task InvokeAsync(InvocationContext context) } catch (IOException) { - await Console.Error.WriteLineAsync( + Console.Error.WriteLine( $"Unable to delete the symlink at {globalContext.SymlinkPath}. Be sure you are running this in an elevated terminal (i.e. Run as Administrator)."); return 1; } @@ -74,7 +79,7 @@ await Console.Error.WriteLineAsync( CreateSymbolicLink(globalContext.SymlinkPath, nodeVersion.Path, SymbolicLink.Directory); if (!Directory.Exists(globalContext.SymlinkPath)) { - await Console.Error.WriteLineAsync( + Console.Error.WriteLine( $"Unable to create the symlink at {globalContext.SymlinkPath}. Be sure you are running this in an elevated terminal (i.e. Run as Administrator)."); return 1; } diff --git a/NodeSwap/NodeSwap.csproj b/NodeSwap/NodeSwap.csproj index 7bbc230..7dbbbde 100644 --- a/NodeSwap/NodeSwap.csproj +++ b/NodeSwap/NodeSwap.csproj @@ -4,7 +4,7 @@ Exe net8.0-windows false - 1.3.3 + 1.4.0 true win-x64 @@ -33,9 +33,11 @@ + + + - diff --git a/NodeSwap/Program.cs b/NodeSwap/Program.cs index 65d7284..35f9edb 100644 --- a/NodeSwap/Program.cs +++ b/NodeSwap/Program.cs @@ -1,15 +1,16 @@ using System; -using System.CommandLine; using System.IO; +using System.Threading.Tasks; +using DotMake.CommandLine; +using Microsoft.Extensions.DependencyInjection; using NodeSwap.Commands; -using Container = SimpleInjector.Container; namespace NodeSwap; internal static class Program { private const string StorageEnv = "NODESWAP_STORAGE"; - private static readonly Container Container; + private static readonly IServiceProvider ServiceProvider; static Program() { @@ -22,22 +23,19 @@ static Program() }; globalContext.ActiveVersionTrackerFilePath = Path.Combine(globalContext.StoragePath, "last-used"); - - Container = new Container(); - Container.RegisterInstance(globalContext); - Container.RegisterSingleton(); - Container.RegisterSingleton(); - Container.RegisterSingleton(); - Container.RegisterSingleton(); - Container.RegisterSingleton(); - Container.RegisterSingleton(); - Container.RegisterSingleton(); - Container.Verify(); + + var services = new ServiceCollection(); + services.AddSingleton(globalContext); + services.AddSingleton(); + services.AddSingleton(); + ServiceProvider = services.BuildServiceProvider(); + + Cli.Ext.SetServiceProvider(ServiceProvider); } - private static int Main(string[] args) + private static async Task Main(string[] args) { - var globalContext = Container.GetInstance(); + var globalContext = ServiceProvider.GetRequiredService(); if (Environment.GetEnvironmentVariable(StorageEnv) == null) { Console.Error.WriteLine($"Missing {StorageEnv} ENV var. It should exist and contain a folder path."); @@ -51,39 +49,6 @@ private static int Main(string[] args) return 1; } - var rootCommand = new RootCommand(); - - var listCommand = new Command("list") - { - Handler = Container.GetInstance(), - Description = "List the Node.js installations.", - }; - rootCommand.Add(listCommand); - - var availPrefixArg = new Argument("prefix"); - availPrefixArg.SetDefaultValue(""); - var availCommand = new Command("avail") {availPrefixArg}; - availCommand.Description = - "List versions available for download. Prefix can be specific like `14.16.1`, or fuzzy like `14.16` or `14`."; - availCommand.Handler = Container.GetInstance(); - rootCommand.Add(availCommand); - - var installCommand = new Command("install") {new Argument("version")}; - installCommand.Description = - "The version can be `latest`, a specific version like `14.16.1`, or a fuzzy version like `14.16` or `14`."; - installCommand.Handler = Container.GetInstance(); - rootCommand.Add(installCommand); - - var uninstallCommand = new Command("uninstall") {new Argument("version")}; - uninstallCommand.Description = "The version must be specific like `14.16.1`."; - uninstallCommand.Handler = Container.GetInstance(); - rootCommand.Add(uninstallCommand); - - var useCommand = new Command("use") {new Argument("version")}; - useCommand.Description = "Switch to a specific version. May be `latest` or specific like `14.16.1`."; - useCommand.Handler = Container.GetInstance(); - rootCommand.Add(useCommand); - - return rootCommand.InvokeAsync(args).Result; + return await Cli.RunAsync(args); } } \ No newline at end of file